图片功能url实现

This commit is contained in:
2025-09-21 16:01:59 +08:00
parent e5eb8d6174
commit 39679f7330
15 changed files with 538 additions and 73 deletions

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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) {}
}

View File

@@ -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);
}

View File

@@ -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; }

View File

@@ -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);
}

View File

@@ -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));

View File

@@ -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}