Jelajahi Sumber

fix(admin): 统一 SKU specs 存库为数组格式,修复规格名丢失

specs 字段格式三层不一致(schema 声明数组 [{"name","value"}]、前端保存
对象 Map、后端 DTO 只认对象),导致规格名记录不进去:
- ProductSkuEditor.normalizeSpecsToJson 改为输出数组格式,空值默认 '[]'
- ProductSkuDTO.from 兼容数组格式(数组→Map),旧数组数据可正常回显
- formatSpecsForInput/parseSpecs 数组分支正确解析 {name,value},不再显示 [object Object]
- CartService 兼容对象格式(旧数据兜底),修复购物车规格描述丢失
- ProductSkuService 空 specs 默认 '[]'

同步修复预存编译错误:HealthReportController 缺 HttpServletRequest import、
HealthReportService reportMapper 应为 healthReportMapper、HealthReportDraft
补 remark 字段(含 schema.sql + DatabaseInitializer 迁移180)。

Reviewed-by: Sisyphus
asus 2 bulan lalu
induk
melakukan
f057162c0b

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -8112,6 +8112,9 @@ private void runMigration100() {
 		ensureColumn("health_reports", "proxy_uploader_id", "BIGINT COMMENT '代上传人ID(规划师/营养师)'");
 		ensureColumn("health_reports", "proxy_upload_note", "TEXT COMMENT '代上传备注'");
 		ensureColumn("health_reports", "ai_analysis", "TEXT COMMENT 'AI解读结果JSON'");
+
+		// 迁移180: health_report_drafts 添加备注字段(代上传说明文字)
+		ensureColumn("health_report_drafts", "remark", "VARCHAR(500) DEFAULT NULL COMMENT '备注(如代上传时的说明文字)'");
 		}
 	}
 }

+ 23 - 3
cfc-backend/src/main/java/com/etotem/cfc/dto/ProductSkuDTO.java

@@ -30,7 +30,8 @@ public class ProductSkuDTO {
         dto.setStock(sku.getStock());
         dto.setImage(sku.getImage());
         dto.setEnabled(sku.getEnabled() != null && sku.getEnabled() == 1);
-        // Parse specs JSON to Map (stored as JSON object in DB)
+        // Parse specs JSON to Map (supports both array and object formats)
+        // 数组格式 [{"name":"颜色","value":"红色"}] 与对象格式 {"颜色":"红色"} 均兼容
         if (sku.getSpecs() != null) {
             try {
                 String specsStr;
@@ -39,8 +40,27 @@ public class ProductSkuDTO {
                 } else {
                     specsStr = JSON.toJSONString(sku.getSpecs());
                 }
-                Map<String, String> specsMap = JSON.parseObject(specsStr,
-                    new TypeReference<Map<String, String>>() {});
+                Map<String, String> specsMap = null;
+                String trimmed = specsStr.trim();
+                if (trimmed.startsWith("[")) {
+                    // 数组格式: [{"name":"颜色","value":"红色"}] → Map {"颜色":"红色"}
+                    java.util.List<com.alibaba.fastjson.JSONObject> list =
+                        JSON.parseArray(trimmed, com.alibaba.fastjson.JSONObject.class);
+                    if (list != null) {
+                        specsMap = new java.util.LinkedHashMap<>();
+                        for (com.alibaba.fastjson.JSONObject obj : list) {
+                            if (obj == null) continue;
+                            String name = obj.getString("name");
+                            String value = obj.getString("value");
+                            if (name != null) {
+                                specsMap.put(name, value != null ? value : "");
+                            }
+                        }
+                    }
+                } else {
+                    specsMap = JSON.parseObject(specsStr,
+                        new TypeReference<Map<String, String>>() {});
+                }
                 dto.setSpecs(specsMap);
             } catch (Exception e) {
                 dto.setSpecs(null);

+ 26 - 12
cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java

@@ -16,6 +16,7 @@ import javax.annotation.Resource;
 import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONArray;
 
@@ -152,20 +153,33 @@ public class CartService {
             ProductSku sku = productSkuService.getById(cart.getSkuId());
             if (sku != null && sku.getSpecs() != null) {
                 try {
-                    JSONArray specsArr = JSON.parseArray(sku.getSpecs().toString());
-                    if (specsArr != null) {
-                        StringBuilder specDesc = new StringBuilder();
-                        for (int i = 0; i < specsArr.size(); i++) {
-                            com.alibaba.fastjson.JSONObject specObj = specsArr.getJSONObject(i);
-                            if (specObj == null) continue;
-                            String name = specObj.getString("name");
-                            String value = specObj.getString("value");
-                            if (name == null || value == null) continue;
-                            if (specDesc.length() > 0) specDesc.append(";");
-                            specDesc.append(name).append(":").append(value);
+                    String specsStr = sku.getSpecs().toString().trim();
+                    StringBuilder specDesc = new StringBuilder();
+                    if (specsStr.startsWith("[")) {
+                        // 数组格式: [{"name":"颜色","value":"红色"}]
+                        JSONArray specsArr = JSON.parseArray(specsStr);
+                        if (specsArr != null) {
+                            for (int i = 0; i < specsArr.size(); i++) {
+                                com.alibaba.fastjson.JSONObject specObj = specsArr.getJSONObject(i);
+                                if (specObj == null) continue;
+                                String name = specObj.getString("name");
+                                String value = specObj.getString("value");
+                                if (name == null || value == null) continue;
+                                if (specDesc.length() > 0) specDesc.append(";");
+                                specDesc.append(name).append(":").append(value);
+                            }
+                        }
+                    } else {
+                        // 对象格式: {"颜色":"红色"}(旧数据兼容)
+                        com.alibaba.fastjson.JSONObject specObj = JSON.parseObject(specsStr);
+                        if (specObj != null) {
+                            for (Map.Entry<String, Object> entry : specObj.entrySet()) {
+                                if (specDesc.length() > 0) specDesc.append(";");
+                                specDesc.append(entry.getKey()).append(":").append(entry.getValue());
+                            }
                         }
-                        dto.setSpecDesc(specDesc.toString());
                     }
+                    dto.setSpecDesc(specDesc.toString());
                 } catch (Exception e) {
                     log.warn("解析SKU specs失败: skuId={}", cart.getSkuId(), e);
                 }

+ 2 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/ProductSkuService.java

@@ -33,7 +33,7 @@ public class ProductSkuService {
     @Transactional
     public ProductSku create(ProductSku sku) {
         if (sku.getSpecs() == null || sku.getSpecs().trim().isEmpty()) {
-            sku.setSpecs("{}");
+            sku.setSpecs("[]");
         }
         sku.setCreateTime(new Date());
         sku.setUpdateTime(new Date());
@@ -50,7 +50,7 @@ public class ProductSkuService {
     @Transactional
     public void update(ProductSku sku) {
         if (sku.getSpecs() == null || sku.getSpecs().trim().isEmpty()) {
-            sku.setSpecs("{}");
+            sku.setSpecs("[]");
         }
         sku.setUpdateTime(new Date());
         skuMapper.updateById(sku);

+ 1 - 0
cfc-backend/src/main/resources/schema.sql

@@ -1749,6 +1749,7 @@ CREATE TABLE IF NOT EXISTS health_report_drafts (
     status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/confirmed/discarded',
     expire_at DATETIME COMMENT '草稿过期时间',
     confirmed_report_id BIGINT COMMENT 'confirm阶段入库后的健康报告ID',
+    remark VARCHAR(500) DEFAULT NULL COMMENT '备注(如代上传时的说明文字)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     INDEX idx_user_id (user_id),

+ 31 - 6
cfc-web/src/views/admin/components/ProductSkuEditor.vue

@@ -213,10 +213,26 @@ const baseURL = process.env.VUE_APP_BASE_API || ''
     parseSpecs(specs) {
       if (!specs) return '-'
       try {
+        if (typeof specs === 'object' && Array.isArray(specs)) {
+          return specs.map((item) => {
+            if (item && typeof item === 'object') {
+              return (item.name || '') + ':' + (item.value !== undefined && item.value !== null ? item.value : '')
+            }
+            return String(item)
+          }).filter(s => s && s !== ':').join('; ')
+        }
         if (typeof specs === 'object') {
           return Object.entries(specs).map(([k, v]) => k + ':' + v).join('; ')
         }
         const obj = JSON.parse(specs)
+        if (Array.isArray(obj)) {
+          return obj.map((item) => {
+            if (item && typeof item === 'object') {
+              return (item.name || '') + ':' + (item.value !== undefined && item.value !== null ? item.value : '')
+            }
+            return String(item)
+          }).filter(s => s && s !== ':').join('; ')
+        }
         if (typeof obj === 'object' && obj !== null) {
           return Object.entries(obj).map(([k, v]) => k + ':' + v).join('; ')
         }
@@ -227,7 +243,7 @@ const baseURL = process.env.VUE_APP_BASE_API || ''
     },
     formatSpecsForInput(specs) {
       if (!specs) return ''
-      if (typeof specs === 'object') {
+      if (typeof specs === 'object' && !Array.isArray(specs)) {
         return Object.entries(specs).map(([k, v]) => k + ':' + v).join('; ')
       }
       const text = String(specs).trim()
@@ -235,7 +251,12 @@ const baseURL = process.env.VUE_APP_BASE_API || ''
       try {
         const parsed = JSON.parse(text)
         if (Array.isArray(parsed)) {
-          return parsed.map((item, index) => '规格' + (index + 1) + ':' + item).join('; ')
+          return parsed.map((item) => {
+            if (item && typeof item === 'object') {
+              return (item.name || '') + ':' + (item.value !== undefined && item.value !== null ? item.value : '')
+            }
+            return String(item)
+          }).filter(s => s && s !== ':').join('; ')
         }
         if (parsed && typeof parsed === 'object') {
           return Object.entries(parsed).map(([k, v]) => k + ':' + v).join('; ')
@@ -245,10 +266,11 @@ const baseURL = process.env.VUE_APP_BASE_API || ''
       return text
     },
     normalizeSpecsToJson(specs) {
-      if (!specs || !specs.trim()) return '{}'
+      if (!specs || !specs.trim()) return '[]'
       const trimmed = specs.trim()
+      // 已是 JSON(对象或数组格式),原样返回
       if (trimmed.startsWith('{') || trimmed.startsWith('[')) return trimmed
-      const map = {}
+      const arr = []
       trimmed.split(/[;;]/).forEach(pair => {
         pair = pair.trim()
         if (!pair) return
@@ -256,10 +278,13 @@ const baseURL = process.env.VUE_APP_BASE_API || ''
         const idx2 = pair.indexOf(':')
         const ci = idx === -1 ? idx2 : (idx2 === -1 ? idx : Math.min(idx, idx2))
         if (ci > 0) {
-          map[pair.substring(0, ci).trim()] = pair.substring(ci + 1).trim()
+          arr.push({
+            name: pair.substring(0, ci).trim(),
+            value: pair.substring(ci + 1).trim()
+          })
         }
       })
-      return Object.keys(map).length ? JSON.stringify(map) : '{}'
+      return arr.length ? JSON.stringify(arr) : '[]'
     },
     formatPrice(val) {
       if (!val) return '¥0.00'