图片功能url实现
This commit is contained in:
@@ -9,6 +9,7 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
@@ -26,10 +27,13 @@ import java.util.concurrent.TimeUnit;
|
||||
public class AttachmentController {
|
||||
|
||||
private final AttachmentPlaceholderProperties placeholderProperties;
|
||||
private final AttachmentUrlValidator urlValidator;
|
||||
|
||||
public AttachmentController(AttachmentPlaceholderProperties placeholderProperties) {
|
||||
this.placeholderProperties = placeholderProperties;
|
||||
}
|
||||
public AttachmentController(AttachmentPlaceholderProperties placeholderProperties,
|
||||
AttachmentUrlValidator urlValidator) {
|
||||
this.placeholderProperties = placeholderProperties;
|
||||
this.urlValidator = urlValidator;
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<Map<String, Object>> upload(@RequestParam("file") MultipartFile file,
|
||||
@@ -42,6 +46,27 @@ public class AttachmentController {
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@PostMapping(path = "/validate-url", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<Map<String, Object>> validateUrlJson(@RequestBody Map<String, Object> body) {
|
||||
String url = body == null ? null : String.valueOf(body.get("url"));
|
||||
AttachmentUrlValidator.ValidationResult vr = urlValidator.validate(url);
|
||||
Map<String, Object> resp = new HashMap<>();
|
||||
resp.put("url", vr.url());
|
||||
resp.put("contentType", vr.contentType());
|
||||
if (vr.contentLength() != null) resp.put("contentLength", vr.contentLength());
|
||||
return ResponseEntity.ok(resp);
|
||||
}
|
||||
|
||||
@PostMapping(path = "/validate-url", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
||||
public ResponseEntity<Map<String, Object>> validateUrlForm(@RequestParam("url") String url) {
|
||||
AttachmentUrlValidator.ValidationResult vr = urlValidator.validate(url);
|
||||
Map<String, Object> resp = new HashMap<>();
|
||||
resp.put("url", vr.url());
|
||||
resp.put("contentType", vr.contentType());
|
||||
if (vr.contentLength() != null) resp.put("contentLength", vr.contentLength());
|
||||
return ResponseEntity.ok(resp);
|
||||
}
|
||||
|
||||
@GetMapping("/placeholder")
|
||||
public ResponseEntity<Resource> placeholder() throws IOException {
|
||||
String imagePath = placeholderProperties.getImagePath();
|
||||
@@ -53,7 +78,12 @@ public class AttachmentController {
|
||||
return ResponseEntity.status(404).build();
|
||||
}
|
||||
Resource resource = new FileSystemResource(path);
|
||||
String contentType = Files.probeContentType(path);
|
||||
String contentType = null;
|
||||
try {
|
||||
contentType = Files.probeContentType(path);
|
||||
} catch (IOException ignore) {
|
||||
contentType = null;
|
||||
}
|
||||
MediaType mediaType;
|
||||
try {
|
||||
mediaType = StringUtils.hasText(contentType) ? MediaType.parseMediaType(contentType) : MediaType.IMAGE_PNG;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.example.demo.attachment;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "attachments.url")
|
||||
public class AttachmentUrlValidationProperties {
|
||||
|
||||
private boolean ssrfProtection = true;
|
||||
private boolean allowPrivateIp = false;
|
||||
private boolean followRedirects = true;
|
||||
private int maxRedirects = 2;
|
||||
private int connectTimeoutMs = 3000;
|
||||
private int readTimeoutMs = 5000;
|
||||
private int maxSizeMb = 5;
|
||||
private List<String> allowlist = new ArrayList<>();
|
||||
private List<String> allowedContentTypes = new ArrayList<>(Arrays.asList(
|
||||
"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"
|
||||
));
|
||||
|
||||
public boolean isSsrfProtection() { return ssrfProtection; }
|
||||
public void setSsrfProtection(boolean ssrfProtection) { this.ssrfProtection = ssrfProtection; }
|
||||
|
||||
public boolean isAllowPrivateIp() { return allowPrivateIp; }
|
||||
public void setAllowPrivateIp(boolean allowPrivateIp) { this.allowPrivateIp = allowPrivateIp; }
|
||||
|
||||
public boolean isFollowRedirects() { return followRedirects; }
|
||||
public void setFollowRedirects(boolean followRedirects) { this.followRedirects = followRedirects; }
|
||||
|
||||
public int getMaxRedirects() { return maxRedirects; }
|
||||
public void setMaxRedirects(int maxRedirects) { this.maxRedirects = maxRedirects; }
|
||||
|
||||
public int getConnectTimeoutMs() { return connectTimeoutMs; }
|
||||
public void setConnectTimeoutMs(int connectTimeoutMs) { this.connectTimeoutMs = connectTimeoutMs; }
|
||||
|
||||
public int getReadTimeoutMs() { return readTimeoutMs; }
|
||||
public void setReadTimeoutMs(int readTimeoutMs) { this.readTimeoutMs = readTimeoutMs; }
|
||||
|
||||
public int getMaxSizeMb() { return maxSizeMb; }
|
||||
public void setMaxSizeMb(int maxSizeMb) { this.maxSizeMb = maxSizeMb; }
|
||||
|
||||
public List<String> getAllowlist() { return allowlist; }
|
||||
public void setAllowlist(List<String> allowlist) { this.allowlist = allowlist; }
|
||||
|
||||
public List<String> getAllowedContentTypes() { return allowedContentTypes; }
|
||||
public void setAllowedContentTypes(List<String> allowedContentTypes) { this.allowedContentTypes = allowedContentTypes; }
|
||||
|
||||
public long getMaxSizeBytes() {
|
||||
return (long) maxSizeMb * 1024L * 1024L;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package com.example.demo.attachment;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.*;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
@Service
|
||||
public class AttachmentUrlValidator {
|
||||
|
||||
private final AttachmentUrlValidationProperties props;
|
||||
|
||||
public AttachmentUrlValidator(AttachmentUrlValidationProperties props) {
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
public ValidationResult validate(String urlString) {
|
||||
if (!StringUtils.hasText(urlString)) {
|
||||
throw new IllegalArgumentException("url不能为空");
|
||||
}
|
||||
|
||||
try {
|
||||
URI uri = new URI(urlString.trim());
|
||||
if (!"http".equalsIgnoreCase(uri.getScheme()) && !"https".equalsIgnoreCase(uri.getScheme())) {
|
||||
throw new IllegalArgumentException("仅支持http/https");
|
||||
}
|
||||
if (!StringUtils.hasText(uri.getHost())) {
|
||||
throw new IllegalArgumentException("URL缺少主机名");
|
||||
}
|
||||
|
||||
// allowlist 校验
|
||||
enforceAllowlist(uri.getHost());
|
||||
|
||||
// SSRF/IP 私网校验
|
||||
if (props.isSsrfProtection()) {
|
||||
enforceIpSafety(uri.getHost());
|
||||
}
|
||||
|
||||
// 发起 HEAD 请求(必要时回退 GET Range)并处理重定向
|
||||
HttpResult http = headCheckWithRedirects(uri, props.getMaxRedirects());
|
||||
|
||||
// 内容类型校验
|
||||
String contentType = normalizeContentType(http.contentType);
|
||||
if (!isAllowedContentType(contentType)) {
|
||||
// 允许根据扩展名兜底一次
|
||||
String guessed = guessContentTypeFromPath(http.finalUri.getPath());
|
||||
if (!isAllowedContentType(guessed)) {
|
||||
throw new IllegalArgumentException("不支持的图片类型");
|
||||
}
|
||||
contentType = guessed;
|
||||
}
|
||||
|
||||
// 大小校验(若已知)
|
||||
if (http.contentLength != null && http.contentLength > 0) {
|
||||
if (http.contentLength > props.getMaxSizeBytes()) {
|
||||
throw new IllegalArgumentException("图片过大,超过上限" + props.getMaxSizeMb() + "MB");
|
||||
}
|
||||
}
|
||||
|
||||
return new ValidationResult(http.finalUri.toString(), contentType, http.contentLength);
|
||||
} catch (URISyntaxException e) {
|
||||
throw new IllegalArgumentException("URL不合法");
|
||||
}
|
||||
}
|
||||
|
||||
private void enforceAllowlist(String host) {
|
||||
List<String> list = props.getAllowlist();
|
||||
if (list == null || list.isEmpty()) return;
|
||||
String h = host.toLowerCase(Locale.ROOT);
|
||||
for (String item : list) {
|
||||
if (!StringUtils.hasText(item)) continue;
|
||||
String rule = item.trim().toLowerCase(Locale.ROOT);
|
||||
if (rule.startsWith("*.")) {
|
||||
String suf = rule.substring(1); // .example.com
|
||||
if (h.endsWith(suf)) return;
|
||||
} else {
|
||||
if (h.equals(rule) || h.endsWith("." + rule)) return;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("域名不在白名单");
|
||||
}
|
||||
|
||||
private void enforceIpSafety(String host) {
|
||||
try {
|
||||
InetAddress[] all = InetAddress.getAllByName(host);
|
||||
for (InetAddress addr : all) {
|
||||
if (!isPublicAddress(addr)) {
|
||||
if (!props.isAllowPrivateIp()) {
|
||||
throw new IllegalArgumentException("禁止访问内网/本地地址");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (UnknownHostException e) {
|
||||
throw new IllegalArgumentException("无法解析域名");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isPublicAddress(InetAddress addr) {
|
||||
return !(addr.isAnyLocalAddress() || addr.isLoopbackAddress() || addr.isLinkLocalAddress() ||
|
||||
addr.isSiteLocalAddress() || addr.isMulticastAddress());
|
||||
}
|
||||
|
||||
private HttpResult headCheckWithRedirects(URI uri, int remainingRedirects) {
|
||||
try {
|
||||
URI current = uri;
|
||||
int redirects = 0;
|
||||
while (true) {
|
||||
HttpURLConnection conn = (HttpURLConnection) current.toURL().openConnection();
|
||||
conn.setConnectTimeout(props.getConnectTimeoutMs());
|
||||
conn.setReadTimeout(props.getReadTimeoutMs());
|
||||
conn.setInstanceFollowRedirects(false);
|
||||
conn.setRequestProperty("User-Agent", "PartsInquiry/1.0");
|
||||
try {
|
||||
conn.setRequestMethod("HEAD");
|
||||
} catch (ProtocolException ignored) {
|
||||
// 一些实现不允许设置,忽略
|
||||
}
|
||||
|
||||
int code;
|
||||
try {
|
||||
code = conn.getResponseCode();
|
||||
} catch (IOException ex) {
|
||||
// 回退到GET Range
|
||||
return getRange0(current, redirects);
|
||||
}
|
||||
|
||||
if (isRedirect(code)) {
|
||||
if (!props.isFollowRedirects() || redirects >= props.getMaxRedirects()) {
|
||||
throw new IllegalArgumentException("重定向过多");
|
||||
}
|
||||
String loc = conn.getHeaderField("Location");
|
||||
if (!StringUtils.hasText(loc)) {
|
||||
throw new IllegalArgumentException("重定向无Location");
|
||||
}
|
||||
URI next = current.resolve(loc);
|
||||
if (!"http".equalsIgnoreCase(next.getScheme()) && !"https".equalsIgnoreCase(next.getScheme())) {
|
||||
throw new IllegalArgumentException("非法重定向协议");
|
||||
}
|
||||
// 重定向目标再做一次安全检查
|
||||
enforceAllowlist(next.getHost());
|
||||
if (props.isSsrfProtection()) enforceIpSafety(next.getHost());
|
||||
current = next;
|
||||
redirects++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (code >= 200 && code < 300) {
|
||||
String ct = conn.getHeaderField("Content-Type");
|
||||
String cl = conn.getHeaderField("Content-Length");
|
||||
Long len = parseLongSafe(totalFromContentRange(conn.getHeaderField("Content-Range"), cl));
|
||||
return new HttpResult(current, ct, len);
|
||||
}
|
||||
|
||||
// HEAD 不被允许时(405等)回退到 GET Range
|
||||
if (code == HttpURLConnection.HTTP_BAD_METHOD || code == HttpURLConnection.HTTP_FORBIDDEN) {
|
||||
return getRange0(current, redirects);
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("URL不可访问,HTTP " + code);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException("无法访问URL");
|
||||
}
|
||||
}
|
||||
|
||||
private HttpResult getRange0(URI uri, int redirects) throws IOException {
|
||||
HttpURLConnection conn = (HttpURLConnection) uri.toURL().openConnection();
|
||||
conn.setConnectTimeout(props.getConnectTimeoutMs());
|
||||
conn.setReadTimeout(props.getReadTimeoutMs());
|
||||
conn.setInstanceFollowRedirects(false);
|
||||
conn.setRequestProperty("User-Agent", "PartsInquiry/1.0");
|
||||
conn.setRequestProperty("Range", "bytes=0-0");
|
||||
int code = conn.getResponseCode();
|
||||
if (isRedirect(code)) {
|
||||
if (!props.isFollowRedirects() || redirects >= props.getMaxRedirects()) {
|
||||
throw new IllegalArgumentException("重定向过多");
|
||||
}
|
||||
String loc = conn.getHeaderField("Location");
|
||||
if (!StringUtils.hasText(loc)) throw new IllegalArgumentException("重定向无Location");
|
||||
URI next = uri.resolve(loc);
|
||||
enforceAllowlist(next.getHost());
|
||||
if (props.isSsrfProtection()) enforceIpSafety(next.getHost());
|
||||
return headCheckWithRedirects(next, props.getMaxRedirects() - redirects - 1);
|
||||
}
|
||||
if (code >= 200 && code < 300 || code == HttpURLConnection.HTTP_PARTIAL) {
|
||||
String ct = conn.getHeaderField("Content-Type");
|
||||
String cl = conn.getHeaderField("Content-Length");
|
||||
Long len = parseLongSafe(totalFromContentRange(conn.getHeaderField("Content-Range"), cl));
|
||||
return new HttpResult(uri, ct, len);
|
||||
}
|
||||
throw new IllegalArgumentException("URL不可访问,HTTP " + code);
|
||||
}
|
||||
|
||||
private boolean isRedirect(int code) {
|
||||
return code == HttpURLConnection.HTTP_MOVED_PERM || code == HttpURLConnection.HTTP_MOVED_TEMP
|
||||
|| code == HttpURLConnection.HTTP_SEE_OTHER || code == 307 || code == 308;
|
||||
}
|
||||
|
||||
private String normalizeContentType(String ct) {
|
||||
if (!StringUtils.hasText(ct)) return null;
|
||||
int idx = ct.indexOf(';');
|
||||
String base = (idx > 0 ? ct.substring(0, idx) : ct).trim().toLowerCase(Locale.ROOT);
|
||||
return base;
|
||||
}
|
||||
|
||||
private boolean isAllowedContentType(String ct) {
|
||||
if (!StringUtils.hasText(ct)) return false;
|
||||
for (String allowed : props.getAllowedContentTypes()) {
|
||||
if (ct.equalsIgnoreCase(allowed)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String guessContentTypeFromPath(String path) {
|
||||
if (path == null) return null;
|
||||
String p = path.toLowerCase(Locale.ROOT);
|
||||
if (p.endsWith(".jpg") || p.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (p.endsWith(".png")) return "image/png";
|
||||
if (p.endsWith(".gif")) return "image/gif";
|
||||
if (p.endsWith(".webp")) return "image/webp";
|
||||
if (p.endsWith(".svg")) return "image/svg+xml";
|
||||
return null;
|
||||
}
|
||||
|
||||
private String totalFromContentRange(String contentRange, String fallbackLength) {
|
||||
// Content-Range: bytes 0-0/12345 -> 12345
|
||||
if (contentRange != null) {
|
||||
int slash = contentRange.lastIndexOf('/');
|
||||
if (slash > 0 && slash + 1 < contentRange.length()) {
|
||||
String total = contentRange.substring(slash + 1).trim();
|
||||
if (!"*".equals(total)) return total;
|
||||
}
|
||||
}
|
||||
return fallbackLength;
|
||||
}
|
||||
|
||||
private Long parseLongSafe(String v) {
|
||||
if (!StringUtils.hasText(v)) return null;
|
||||
try { return Long.parseLong(v.trim()); } catch (Exception e) { return null; }
|
||||
}
|
||||
|
||||
public record ValidationResult(String url, String contentType, Long contentLength) {}
|
||||
|
||||
private record HttpResult(URI finalUri, String contentType, Long contentLength) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ public class DashboardRepository {
|
||||
public BigDecimal sumTodaySalesOrders(Long shopId) {
|
||||
Object result = entityManager.createNativeQuery(
|
||||
"SELECT COALESCE(SUM(amount), 0) FROM sales_orders " +
|
||||
"WHERE shop_id = :shopId AND status = 'approved' AND DATE(order_time) = CURRENT_DATE()"
|
||||
"WHERE shop_id = :shopId AND status = 'approved' " +
|
||||
"AND order_time >= CURRENT_DATE() AND order_time < DATE_ADD(CURRENT_DATE(), INTERVAL 1 DAY)"
|
||||
).setParameter("shopId", shopId).getSingleResult();
|
||||
return toBigDecimal(result);
|
||||
}
|
||||
|
||||
@@ -24,9 +24,6 @@ public class ProductPrice {
|
||||
@Column(name = "retail_price", precision = 18, scale = 2, nullable = false)
|
||||
private BigDecimal retailPrice = BigDecimal.ZERO;
|
||||
|
||||
@Column(name = "distribution_price", precision = 18, scale = 2, nullable = false)
|
||||
private BigDecimal distributionPrice = BigDecimal.ZERO;
|
||||
|
||||
@Column(name = "wholesale_price", precision = 18, scale = 2, nullable = false)
|
||||
private BigDecimal wholesalePrice = BigDecimal.ZERO;
|
||||
|
||||
@@ -46,8 +43,6 @@ public class ProductPrice {
|
||||
public void setPurchasePrice(BigDecimal purchasePrice) { this.purchasePrice = purchasePrice; }
|
||||
public BigDecimal getRetailPrice() { return retailPrice; }
|
||||
public void setRetailPrice(BigDecimal retailPrice) { this.retailPrice = retailPrice; }
|
||||
public BigDecimal getDistributionPrice() { return distributionPrice; }
|
||||
public void setDistributionPrice(BigDecimal distributionPrice) { this.distributionPrice = distributionPrice; }
|
||||
public BigDecimal getWholesalePrice() { return wholesalePrice; }
|
||||
public void setWholesalePrice(BigDecimal wholesalePrice) { this.wholesalePrice = wholesalePrice; }
|
||||
public BigDecimal getBigClientPrice() { return bigClientPrice; }
|
||||
|
||||
@@ -18,6 +18,8 @@ public interface ProductRepository extends JpaRepository<Product, Long> {
|
||||
Pageable pageable);
|
||||
|
||||
boolean existsByShopIdAndBarcode(Long shopId, String barcode);
|
||||
|
||||
long countByShopIdAndBarcodeAndIdNot(Long shopId, String barcode, Long id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -160,6 +160,12 @@ public class ProductService {
|
||||
validate(shopId, req);
|
||||
Product p = productRepository.findById(id).orElseThrow();
|
||||
if (!p.getShopId().equals(shopId)) throw new IllegalArgumentException("跨店铺数据");
|
||||
// 条码唯一性:允许与自身相同,但不允许与其他商品重复
|
||||
if (req.barcode != null && !req.barcode.isBlank()) {
|
||||
if (productRepository.countByShopIdAndBarcodeAndIdNot(shopId, req.barcode, id) > 0) {
|
||||
throw new IllegalArgumentException("条码已存在");
|
||||
}
|
||||
}
|
||||
p.setUserId(userId);
|
||||
p.setName(req.name);
|
||||
p.setBarcode(emptyToNull(req.barcode));
|
||||
|
||||
@@ -39,6 +39,19 @@ spring.datasource.hikari.connection-timeout=30000
|
||||
attachments.placeholder.image-path=${ATTACHMENTS_PLACEHOLDER_IMAGE}
|
||||
attachments.placeholder.url-path=${ATTACHMENTS_PLACEHOLDER_URL:/api/attachments/placeholder}
|
||||
|
||||
# 纯URL引用校验配置(方案A)
|
||||
attachments.url.ssrf-protection=${ATTACHMENTS_URL_SSRF_PROTECTION:true}
|
||||
attachments.url.allow-private-ip=${ATTACHMENTS_URL_ALLOW_PRIVATE_IP:false}
|
||||
attachments.url.follow-redirects=${ATTACHMENTS_URL_FOLLOW_REDIRECTS:true}
|
||||
attachments.url.max-redirects=${ATTACHMENTS_URL_MAX_REDIRECTS:2}
|
||||
attachments.url.connect-timeout-ms=${ATTACHMENTS_URL_CONNECT_TIMEOUT_MS:3000}
|
||||
attachments.url.read-timeout-ms=${ATTACHMENTS_URL_READ_TIMEOUT_MS:5000}
|
||||
attachments.url.max-size-mb=${ATTACHMENTS_URL_MAX_SIZE_MB:5}
|
||||
# 逗号分隔域名,支持前缀通配 *.example.com
|
||||
attachments.url.allowlist=${ATTACHMENTS_URL_ALLOWLIST:}
|
||||
# 逗号分隔Content-Type
|
||||
attachments.url.allowed-content-types=${ATTACHMENTS_URL_ALLOWED_CONTENT_TYPES:image/jpeg,image/png,image/gif,image/webp,image/svg+xml}
|
||||
|
||||
# 应用默认上下文(用于开发/演示环境)
|
||||
app.defaults.shop-id=${APP_DEFAULT_SHOP_ID:1}
|
||||
app.defaults.user-id=${APP_DEFAULT_USER_ID:2}
|
||||
|
||||
Reference in New Issue
Block a user