图片功能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,9 +27,12 @@ import java.util.concurrent.TimeUnit;
public class AttachmentController {
private final AttachmentPlaceholderProperties placeholderProperties;
private final AttachmentUrlValidator urlValidator;
public AttachmentController(AttachmentPlaceholderProperties placeholderProperties) {
public AttachmentController(AttachmentPlaceholderProperties placeholderProperties,
AttachmentUrlValidator urlValidator) {
this.placeholderProperties = placeholderProperties;
this.urlValidator = urlValidator;
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -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}

View File

@@ -14,7 +14,12 @@
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_shops_status` (`status`)
字段说明:
- id: 主键,自增
- name: 店铺名称
- status: 店铺状态1启用/0停用
- created_at/updated_at: 创建/更新时间
- deleted_at: 逻辑删除时间
### users
| Column Name | Data Type | Nullable | Default | Comment |
@@ -31,8 +36,11 @@
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_users_shop` (`shop_id`) - UNIQUE: `ux_users_shop_phone` (`shop_id`,`phone`)
**Foreign Keys**: - `fk_users_shop`: `shop_id``shops(id)` ON UPDATE NO ACTION ON DELETE NO ACTION
字段说明:
- shop_id: 归属店铺
- role: 角色标识字符串
- is_owner: 是否店主标记
- 其余同名含义
### user_identities
| Column Name | Data Type | Nullable | Default | Comment |
@@ -49,8 +57,9 @@
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_identity_shop` (`shop_id`) - KEY: `idx_identity_user` (`user_id`) - UNIQUE: `ux_identity_provider_openid` (`provider`,`openid`) - UNIQUE: `ux_identity_unionid` (`unionid`)
**Foreign Keys**: - `fk_identity_shop`: `shop_id``shops(id)` ON UPDATE NO ACTION ON DELETE NO ACTION - `fk_identity_user`: `user_id``users(id)` ON UPDATE NO ACTION ON DELETE NO ACTION
字段说明:
- provider: wechat_mp小程序、wechat_appAPP
- openid/unionid: 微信身份标识
### wechat_sessions
| Column Name | Data Type | Nullable | Default | Comment |
@@ -62,7 +71,8 @@
| expires_at | DATETIME | NOT NULL | | |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_wechat_session_expires` (`expires_at`) - UNIQUE: `ux_wechat_session` (`provider`,`openid`)
字段说明:
- session_key/expires_at: 会话密钥与过期时间
### system_parameters
| Column Name | Data Type | Nullable | Default | Comment |
@@ -75,8 +85,8 @@
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_sysparams_shop` (`shop_id`) - UNIQUE: `ux_sysparams_shop_key` (`shop_id`,`key`)
**Foreign Keys**: - `fk_sysparams_shop`: `shop_id``shops(id)` ON UPDATE NO ACTION ON DELETE NO ACTION - `fk_sysparams_user`: `user_id``users(id)` ON UPDATE NO ACTION ON DELETE NO ACTION
字段说明:
- key/value: 键/值JSON
### product_units
| Column Name | Data Type | Nullable | Default | Comment |
@@ -89,6 +99,9 @@
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
字段说明product_units
- name: 单位名称,如 件/个/箱
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_units_shop` (`shop_id`) - UNIQUE: `ux_units_shop_name` (`shop_id`,`name`)
**Foreign Keys**: - `fk_units_shop`: `shop_id``shops(id)` ON UPDATE NO ACTION ON DELETE NO ACTION - `fk_units_user`: `user_id``users(id)` ON UPDATE NO ACTION ON DELETE NO ACTION
@@ -108,6 +121,12 @@
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
字段说明global_skus
- name/brand/model/spec/barcode: SKU 基本属性
- unit_id: 对应的计量单位
- tags: 结构化标签 JSON
- status: 上架状态published/offline
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_global_skus_brand_model` (`brand`,`model`) - UNIQUE: `ux_global_skus_barcode` (`barcode`)
**Foreign Keys**: - `fk_globalsku_unit`: `unit_id``product_units(id)` ON UPDATE NO ACTION ON DELETE NO ACTION
@@ -124,6 +143,10 @@
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
字段说明product_categories
- parent_id: 父分类,可为空
- sort_order: 排序
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_categories_shop` (`shop_id`) - KEY: `idx_categories_parent` (`parent_id`) - UNIQUE: `ux_categories_shop_name` (`shop_id`,`name`)
**Foreign Keys**: - `fk_categories_shop`: `shop_id``shops(id)` ON UPDATE NO ACTION ON DELETE NO ACTION - `fk_categories_user`: `user_id``users(id)` ON UPDATE NO ACTION ON DELETE NO ACTION - `fk_categories_parent`: `parent_id``product_categories(id)` ON UPDATE NO ACTION ON DELETE NO ACTION
@@ -151,6 +174,11 @@
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
字段说明products
- category_id/unit_id/global_sku_id: 归属分类/单位/全局SKU
- safe_min/safe_max: 安全库存上下限
- search_text: 聚合检索字段(触发器维护)
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_products_shop` (`shop_id`) - KEY: `idx_products_category` (`category_id`) - KEY: `idx_products_unit` (`unit_id`) - FULLTEXT: `ft_products_search` (`name`,`brand`,`model`,`spec`,`search_text`) - UNIQUE: `ux_products_shop_barcode` (`shop_id`,`barcode`)
**Foreign Keys**: - `fk_products_shop`: `shop_id``shops(id)` - `fk_products_user`: `user_id``users(id)` - `fk_products_category`: `category_id``product_categories(id)` - `fk_products_unit`: `unit_id``product_units(id)` - `fk_products_globalsku`: `global_sku_id``global_skus(id)`
@@ -166,6 +194,9 @@
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
字段说明product_aliases
- alias: 商品别名(同义词)
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_product_alias_product` (`product_id`) - UNIQUE: `ux_product_alias` (`product_id`,`alias`)
**Foreign Keys**: - `fk_alias_shop`: `shop_id``shops(id)` - `fk_alias_user`: `user_id``users(id)` - `fk_alias_product`: `product_id``products(id)`
@@ -181,6 +212,10 @@
| sort_order | INT | NOT NULL | 0 | |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
字段说明product_images
- url/hash: 图片地址/内容哈希
- sort_order: 展示顺序
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_product_images_product` (`product_id`) - UNIQUE: `ux_product_image_hash` (`product_id`,`hash`)
**Foreign Keys**: - `fk_pimg_shop`: `shop_id``shops(id)` - `fk_pimg_user`: `user_id``users(id)` - `fk_pimg_product`: `product_id``products(id)` ON DELETE CASCADE
@@ -196,6 +231,10 @@
| big_client_price | DECIMAL(18,2) | NOT NULL | 0.00 | |
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
字段说明product_prices
- purchase_price: 当前进价(用于近似毛利)
- retail/wholesale/big_client_price: 售价列
**Indexes**: - PRIMARY KEY: `product_id` - KEY: `idx_prices_shop` (`shop_id`)
**Foreign Keys**: - `fk_prices_product`: `product_id``products(id)` ON DELETE CASCADE - `fk_prices_shop`: `shop_id``shops(id)` - `fk_prices_user`: `user_id``users(id)`
@@ -208,6 +247,9 @@
| quantity | DECIMAL(18,3) | NOT NULL | 0.000 | |
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
字段说明inventories
- quantity: 当前库存数量(按商品一行聚合)
**Indexes**: - PRIMARY KEY: `product_id` - KEY: `idx_inventories_shop` (`shop_id`)
**Foreign Keys**: - `fk_inv_product`: `product_id``products(id)` ON DELETE CASCADE - `fk_inv_shop`: `shop_id``shops(id)` - `fk_inv_user`: `user_id``users(id)`
@@ -231,6 +273,11 @@
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
字段说明customers
- level: 等级标签
- price_level: 默认售价列(中文存储:零售价/批发价/大单报价)
- ar_opening: 期初应收
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_customers_shop` (`shop_id`) - KEY: `idx_customers_phone` (`phone`) - KEY: `idx_customers_mobile` (`mobile`)
**Foreign Keys**: - `fk_customers_shop`: `shop_id``shops(id)` - `fk_customers_user`: `user_id``users(id)`
@@ -253,6 +300,9 @@
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
字段说明suppliers
- ap_opening/ap_payable: 期初应付/当前应付
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_suppliers_shop` (`shop_id`) - KEY: `idx_suppliers_phone` (`phone`) - KEY: `idx_suppliers_mobile` (`mobile`)
**Foreign Keys**: - `fk_suppliers_shop`: `shop_id``shops(id)` - `fk_suppliers_user`: `user_id``users(id)`
@@ -272,6 +322,10 @@
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
字段说明accounts
- type: 账户类型cash/bank/alipay/wechat/other
- bank_name/bank_account: 银行账户信息type=bank 时使用)
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_accounts_shop` (`shop_id`) - UNIQUE: `ux_accounts_shop_name` (`shop_id`,`name`)
**Foreign Keys**: - `fk_accounts_shop`: `shop_id``shops(id)` - `fk_accounts_user`: `user_id``users(id)`
@@ -292,6 +346,10 @@
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
字段说明sales_orders
- status: 单据状态draft/approved/returned/void
- amount/paid_amount: 应收/已收合计
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_sales_shop_time` (`shop_id`,`order_time`) - KEY: `idx_sales_customer` (`customer_id`) - UNIQUE: `ux_sales_order_no` (`shop_id`,`order_no`)
**Foreign Keys**: - `fk_sales_shop`: `shop_id``shops(id)` - `fk_sales_user`: `user_id``users(id)` - `fk_sales_customer`: `customer_id``customers(id)`
@@ -306,6 +364,9 @@
| discount_rate | DECIMAL(5,2) | NOT NULL | 0.00 | 折扣百分比0-100 |
| amount | DECIMAL(18,2) | NOT NULL | | |
字段说明sales_order_items
- quantity/unit_price/discount_rate/amount: 数量/单价/折扣%/行金额
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_soi_order` (`order_id`) - KEY: `idx_soi_product` (`product_id`)
**Foreign Keys**: - `fk_soi_order`: `order_id``sales_orders(id)` ON DELETE CASCADE - `fk_soi_product`: `product_id``products(id)`
@@ -326,6 +387,9 @@
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
字段说明purchase_orders/purchase_order_items
- 与销售单结构类似,含应付与明细
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_purchase_shop_time` (`shop_id`,`order_time`) - KEY: `idx_purchase_supplier` (`supplier_id`) - UNIQUE: `ux_purchase_order_no` (`shop_id`,`order_no`)
**Foreign Keys**: - `fk_purchase_shop`: `shop_id``shops(id)` - `fk_purchase_user`: `user_id``users(id)` - `fk_purchase_supplier`: `supplier_id``suppliers(id)`
@@ -357,6 +421,11 @@
| remark | VARCHAR(255) | YES | | |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
字段说明payments
- biz_type/biz_id: 业务来源及关联主键
- direction: in 收款 / out 付款
- account_id: 使用的结算账户
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_payments_shop_time` (`shop_id`,`pay_time`) - KEY: `idx_payments_biz` (`biz_type`,`biz_id`)
**Foreign Keys**: - `fk_payments_shop`: `shop_id``shops(id)` - `fk_payments_user`: `user_id``users(id)` - `fk_payments_account`: `account_id``accounts(id)`
@@ -378,6 +447,10 @@
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
字段说明other_transactions
- type/category: 收入/支出与分类
- counterparty_type/id: 往来单位(可空)
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_ot_shop_time` (`shop_id`,`tx_time`) - KEY: `idx_ot_account` (`account_id`)
**Foreign Keys**: - `fk_ot_shop`: `shop_id``shops(id)` - `fk_ot_user`: `user_id``users(id)` - `fk_ot_account`: `account_id``accounts(id)`
@@ -394,6 +467,10 @@
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
字段说明finance_categories
- key: 稳定标识(代码不随展示文案改变)
- label: 展示名称
**Indexes**: - PRIMARY KEY: `id` - UNIQUE: `ux_finance_cat` (`shop_id`,`type`,`key`) - KEY: `idx_finance_cat_shop_type` (`shop_id`,`type`)
**Foreign Keys**: - `fk_finance_cat_shop`: `shop_id``shops(id)`
@@ -420,6 +497,11 @@
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
字段说明notices
- is_pinned: 是否置顶
- starts_at/ends_at: 生效时间窗
- status: 草稿/发布/下线
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_notices_time` (`starts_at`,`ends_at`)
**Foreign Keys**: - 无
@@ -439,6 +521,11 @@
| remark | VARCHAR(255) | YES | | |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
字段说明inventory_movements
- qty_delta: 数量变动(入库为正、出库为负)
- amount_delta: 金额变动(可选)
- source_type/source_id: 变动来源追溯
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_im_shop_time` (`shop_id`,`tx_time`) - KEY: `idx_im_product` (`product_id`)
**Foreign Keys**: - `fk_im_shop`: `shop_id``shops(id)` - `fk_im_user`: `user_id``users(id)` - `fk_im_product`: `product_id``products(id)`
@@ -466,6 +553,9 @@
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
字段说明sales_return_orders
- 与销售单结构一致,用于退货业务;状态为 approved/void
**Indexes**: - PRIMARY KEY: `id` - UNIQUE: `ux_sr_order_no` (`shop_id`,`order_no`) - KEY: `idx_sr_shop_time` (`shop_id`,`order_time`)
**Foreign Keys**: - `fk_sr_shop`: `shop_id``shops(id)` - `fk_sr_user`: `user_id``users(id)` - `fk_sr_customer`: `customer_id``customers(id)`
@@ -500,6 +590,9 @@
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
字段说明purchase_return_orders
- 与销售单结构一致,用于退货业务;状态为 approved/void
**Indexes**: - PRIMARY KEY: `id` - UNIQUE: `ux_pr_order_no` (`shop_id`,`order_no`) - KEY: `idx_pr_shop_time` (`shop_id`,`order_time`)
**Foreign Keys**: - `fk_pr_shop`: `shop_id``shops(id)` - `fk_pr_user`: `user_id``users(id)` - `fk_pr_supplier`: `supplier_id``suppliers(id)`

View File

@@ -876,8 +876,8 @@ paths:
direction: { type: string }
/api/attachments:
post:
summary: 上传附件(✅ Fully Implemented占位图方案
description: 接收 multipart 上传但忽略文件内容,始终返回占位图 URL后端配置项 `attachments.placeholder.image-path` 指向本地占位图片URL 固定 `/api/attachments/placeholder` 可通过 `attachments.placeholder.url-path` 覆盖)
summary: 上传附件(❌ Partially Implemented已废弃采用纯URL引用
description: 方案A采用纯URL引用不再支持文件直传。历史兼容此接口仍存在但返回占位图 URL建议前端改用 `/api/attachments/validate-url` 进行URL校验后直接写入业务字段
requestBody:
required: true
content:
@@ -902,6 +902,36 @@ paths:
properties:
url:
type: string
/api/attachments/validate-url:
post:
summary: 校验图片URL✅ Fully Implemented
description: 提交外部图片URL后端进行白名单、SSR防护、类型与大小校验返回标准化后的可用URL和元信息。支持 `application/json` 与 `application/x-www-form-urlencoded`。
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
url: { type: string }
required: [url]
application/x-www-form-urlencoded:
schema:
type: object
properties:
url: { type: string }
required: [url]
responses:
'200':
description: 成功
content:
application/json:
schema:
type: object
properties:
url: { type: string }
contentType: { type: string }
contentLength: { type: integer, format: int64, nullable: true }
/api/attachments/placeholder:
get:
summary: 附件占位图读取(✅ Fully Implemented

View File

@@ -45,3 +45,23 @@ if (typeof uni !== 'undefined' && typeof uni.connectSocket === 'function') {
}
}
// #endif
// 全局安全返回:首屏无法后退时自动回到首页 tab微信小程序
// #ifdef MP-WEIXIN
if (typeof uni !== 'undefined' && typeof uni.navigateBack === 'function') {
const _navigateBack = uni.navigateBack
uni.navigateBack = function(params = {}) {
try {
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []
const maxDelta = pages.length > 0 ? (pages.length - 1) : 0
const d = Number(params.delta || 1)
if (maxDelta >= 1 && d <= maxDelta) {
return _navigateBack.call(this, params)
}
return uni.switchTab({ url: '/pages/index/index' })
} catch (e) {
return uni.switchTab({ url: '/pages/index/index' })
}
}
}
// #endif

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -62,6 +62,22 @@ if (typeof common_vendor.index !== "undefined" && typeof common_vendor.index.con
return task;
};
}
if (typeof common_vendor.index !== "undefined" && typeof common_vendor.index.navigateBack === "function") {
const _navigateBack = common_vendor.index.navigateBack;
common_vendor.index.navigateBack = function(params = {}) {
try {
const pages = typeof getCurrentPages === "function" ? getCurrentPages() : [];
const maxDelta = pages.length > 0 ? pages.length - 1 : 0;
const d = Number(params.delta || 1);
if (maxDelta >= 1 && d <= maxDelta) {
return _navigateBack.call(this, params);
}
return common_vendor.index.switchTab({ url: "/pages/index/index" });
} catch (e) {
return common_vendor.index.switchTab({ url: "/pages/index/index" });
}
};
}
createApp().app.mount("#app");
exports.createApp = createApp;
//# sourceMappingURL=../.sourcemap/mp-weixin/app.js.map

View File

@@ -1,52 +1,3 @@
9.16王德鹏1
前端1.公告:删除示例,删除“更多”,更改为“广告”
2.调整核心数据概览内容(根据需求文档)
3.导航栏内容改为“首页”“货品”“开单”“明细”“报表”“我的”(根据需求文档)
4.开单功能√
后端1.公告的显示功能√
2.默认用户张老板id=2
数据库新增notice表/所有表填充模拟数据
doc里有数据库文档和需求文档
9.17王德鹏1
前端1.货品功能√
后端1.首页核心数据√
2.所有图片用占位图backend\picture代替
9.18王德鹏1
前端1.明细功能√
后端1.数据库驱动改为硬编码
2.货品功能√
9.18王德鹏2
前端1.客户功能√
数据库1.客户表更改
9.18王德鹏3
前端1.供应商功能√
后端1.客户功能√
2.供应商功能√v
3.开单功能(销售进货)√
9.20王德鹏1
前端1.账户功能√
2.我的功能√
3.报表功能√
后端1.账户功能√
2.开单功能√
3.明细功能√
4.报表功能√
9.20王德鹏2
除了“我的”,正常功能基本实现
9.20王德鹏3
导航栏持久化
前端问题:
1.都只做了小程序的,就没看过安卓端,还需要做双端适配
2.图标
@@ -60,5 +11,5 @@
5.缓存问题
6.“我的”没做
7.vip功能模糊
8.管理端没做
9.咨询功能没做
8.管理端功能模糊
9.咨询功能模糊