Kaynağa Gözat

会员管理代码

wwh 2 hafta önce
ebeveyn
işleme
d676246772

+ 10 - 0
baqing-shop/src/main/java/com/ruoyi/web/modules/goods/constant/GoodsConstants.java

@@ -53,6 +53,16 @@ public final class GoodsConstants
53
 
53
 
54
     public static final String MSG_ATTR_TEMPLATE_INVALID = "请选择本店有效属性模版";
54
     public static final String MSG_ATTR_TEMPLATE_INVALID = "请选择本店有效属性模版";
55
 
55
 
56
+    public static final String MSG_ATTR_ITEM_NAME_REQUIRED = "请输入属性项/规格项名称";
57
+
58
+    public static final String MSG_ATTR_ITEM_NAME_DUPLICATE = "属性项/规格项名称不可重复";
59
+
60
+    public static final String MSG_ATTR_CROSS_ITEM_NAME = "属性项与规格项名称不可相同";
61
+
62
+    public static final String MSG_ATTR_VALUE_REQUIRED = "请为每项至少添加一个属性值/规格值";
63
+
64
+    public static final String MSG_ATTR_VALUE_DUPLICATE = "同一项下属性值/规格值不可重复";
65
+
56
     public static final String MSG_DELETE_INVALID = "当前状态不可删除";
66
     public static final String MSG_DELETE_INVALID = "当前状态不可删除";
57
 
67
 
58
     public static final String MSG_UNFINISHED_ORDER = "该商品存在未完成订单,无法下架";
68
     public static final String MSG_UNFINISHED_ORDER = "该商品存在未完成订单,无法下架";

+ 25 - 0
baqing-shop/src/main/java/com/ruoyi/web/modules/goods/controller/SellerGoodsController.java

@@ -28,6 +28,8 @@ import com.ruoyi.web.modules.goods.dto.GoodsOffShelfDTO;
28
 import com.ruoyi.web.modules.goods.dto.GoodsSaveDTO;
28
 import com.ruoyi.web.modules.goods.dto.GoodsSaveDTO;
29
 import com.ruoyi.web.modules.goods.exception.GoodsBatchOperationException;
29
 import com.ruoyi.web.modules.goods.exception.GoodsBatchOperationException;
30
 import com.ruoyi.web.modules.goods.service.IGoodsService;
30
 import com.ruoyi.web.modules.goods.service.IGoodsService;
31
+import com.ruoyi.web.modules.template.facade.IAttrTemplateFacade;
32
+import com.ruoyi.web.modules.template.vo.AttrTemplateDetailVO;
31
 
33
 
32
 /**
34
 /**
33
  * 商家端商品管理
35
  * 商家端商品管理
@@ -48,6 +50,9 @@ public class SellerGoodsController extends BaseController
48
     @Autowired
50
     @Autowired
49
     private IGoodsServiceFacade goodsServiceFacade;
51
     private IGoodsServiceFacade goodsServiceFacade;
50
 
52
 
53
+    @Autowired
54
+    private IAttrTemplateFacade attrTemplateFacade;
55
+
51
     @PreAuthorize("@ss.hasPermi('agri:seller:goods:list')")
56
     @PreAuthorize("@ss.hasPermi('agri:seller:goods:list')")
52
     @GetMapping("/list")
57
     @GetMapping("/list")
53
     public TableDataInfo list(BizGoods query)
58
     public TableDataInfo list(BizGoods query)
@@ -81,6 +86,26 @@ public class SellerGoodsController extends BaseController
81
         return success(data);
86
         return success(data);
82
     }
87
     }
83
 
88
 
89
+    @PreAuthorize("@ss.hasPermi('agri:seller:goods:list') or @ss.hasPermi('agri:seller:goods:add') or @ss.hasPermi('agri:seller:goods:edit')")
90
+    @GetMapping("/attrTemplateOptions")
91
+    public AjaxResult attrTemplateOptions()
92
+    {
93
+        return success(attrTemplateFacade.listOptionsByShopId(SellerShopContext.getShopId()));
94
+    }
95
+
96
+    @PreAuthorize("@ss.hasPermi('agri:seller:goods:list') or @ss.hasPermi('agri:seller:goods:add') or @ss.hasPermi('agri:seller:goods:edit')")
97
+    @GetMapping("/attrTemplate/{templateId}")
98
+    public AjaxResult attrTemplateDetail(@PathVariable("templateId") Long templateId)
99
+    {
100
+        AttrTemplateDetailVO detail = attrTemplateFacade.getDetailForGoods(templateId, SellerShopContext.getShopId());
101
+        Map<String, Object> data = new HashMap<>();
102
+        data.put("templateId", detail.getTemplateId());
103
+        data.put("templateName", detail.getTemplateName());
104
+        data.put("attributes", detail.getAttributes());
105
+        data.put("specs", detail.getSpecs());
106
+        return success(data);
107
+    }
108
+
84
     @PreAuthorize("@ss.hasPermi('agri:seller:goods:query')")
109
     @PreAuthorize("@ss.hasPermi('agri:seller:goods:query')")
85
     @GetMapping("/{goodsId}")
110
     @GetMapping("/{goodsId}")
86
     public AjaxResult getInfo(@PathVariable("goodsId") Long goodsId)
111
     public AjaxResult getInfo(@PathVariable("goodsId") Long goodsId)

+ 26 - 0
baqing-shop/src/main/java/com/ruoyi/web/modules/goods/dto/GoodsSaveDTO.java

@@ -30,6 +30,12 @@ public class GoodsSaveDTO
30
 
30
 
31
     private List<Long> serviceIds;
31
     private List<Long> serviceIds;
32
 
32
 
33
+    /** 商品属性项(可选;选用模版后可修改) */
34
+    private List<GoodsAttrItemDTO> attributes;
35
+
36
+    /** 商品规格项(可选;选用模版后可修改) */
37
+    private List<GoodsAttrItemDTO> specs;
38
+
33
     /** 禁止通过保存接口修改,传入则拒收 */
39
     /** 禁止通过保存接口修改,传入则拒收 */
34
     private String goodsStatus;
40
     private String goodsStatus;
35
 
41
 
@@ -133,6 +139,26 @@ public class GoodsSaveDTO
133
         this.serviceIds = serviceIds;
139
         this.serviceIds = serviceIds;
134
     }
140
     }
135
 
141
 
142
+    public List<GoodsAttrItemDTO> getAttributes()
143
+    {
144
+        return attributes;
145
+    }
146
+
147
+    public void setAttributes(List<GoodsAttrItemDTO> attributes)
148
+    {
149
+        this.attributes = attributes;
150
+    }
151
+
152
+    public List<GoodsAttrItemDTO> getSpecs()
153
+    {
154
+        return specs;
155
+    }
156
+
157
+    public void setSpecs(List<GoodsAttrItemDTO> specs)
158
+    {
159
+        this.specs = specs;
160
+    }
161
+
136
     public String getGoodsStatus()
162
     public String getGoodsStatus()
137
     {
163
     {
138
         return goodsStatus;
164
         return goodsStatus;

+ 28 - 0
baqing-shop/src/main/java/com/ruoyi/web/modules/goods/service/impl/GoodsServiceImpl.java

@@ -23,16 +23,20 @@ import com.ruoyi.web.modules.goodsservice.facade.IGoodsServiceFacade;
23
 import com.ruoyi.web.modules.goodsservice.mapper.BizGoodsServiceMapper;
23
 import com.ruoyi.web.modules.goodsservice.mapper.BizGoodsServiceMapper;
24
 import com.ruoyi.web.modules.goods.constant.GoodsConstants;
24
 import com.ruoyi.web.modules.goods.constant.GoodsConstants;
25
 import com.ruoyi.web.modules.goods.domain.BizGoods;
25
 import com.ruoyi.web.modules.goods.domain.BizGoods;
26
+import com.ruoyi.web.modules.goods.domain.BizGoodsAttr;
26
 import com.ruoyi.web.modules.goods.domain.BizGoodsServiceSnapshot;
27
 import com.ruoyi.web.modules.goods.domain.BizGoodsServiceSnapshot;
27
 import com.ruoyi.web.modules.goods.dto.GoodsAuditDTO;
28
 import com.ruoyi.web.modules.goods.dto.GoodsAuditDTO;
29
+import com.ruoyi.web.modules.goods.dto.GoodsAttrItemDTO;
28
 import com.ruoyi.web.modules.goods.dto.GoodsBatchIdsDTO;
30
 import com.ruoyi.web.modules.goods.dto.GoodsBatchIdsDTO;
29
 import com.ruoyi.web.modules.goods.dto.GoodsOffShelfDTO;
31
 import com.ruoyi.web.modules.goods.dto.GoodsOffShelfDTO;
30
 import com.ruoyi.web.modules.goods.dto.GoodsSaveDTO;
32
 import com.ruoyi.web.modules.goods.dto.GoodsSaveDTO;
31
 import com.ruoyi.web.modules.goods.exception.GoodsBatchOperationException;
33
 import com.ruoyi.web.modules.goods.exception.GoodsBatchOperationException;
32
 import com.ruoyi.web.modules.goods.facade.IGoodsOrderFacade;
34
 import com.ruoyi.web.modules.goods.facade.IGoodsOrderFacade;
35
+import com.ruoyi.web.modules.goods.mapper.BizGoodsAttrMapper;
33
 import com.ruoyi.web.modules.goods.mapper.BizGoodsMapper;
36
 import com.ruoyi.web.modules.goods.mapper.BizGoodsMapper;
34
 import com.ruoyi.web.modules.goods.mapper.BizGoodsServiceSnapshotMapper;
37
 import com.ruoyi.web.modules.goods.mapper.BizGoodsServiceSnapshotMapper;
35
 import com.ruoyi.web.modules.goods.service.IGoodsService;
38
 import com.ruoyi.web.modules.goods.service.IGoodsService;
39
+import com.ruoyi.web.modules.goods.support.GoodsAttrSupport;
36
 import com.ruoyi.web.modules.goods.support.GoodsSnGenerator;
40
 import com.ruoyi.web.modules.goods.support.GoodsSnGenerator;
37
 import com.ruoyi.web.modules.goods.support.GoodsStatusUtils;
41
 import com.ruoyi.web.modules.goods.support.GoodsStatusUtils;
38
 import com.ruoyi.web.modules.goods.vo.GoodsDetailVO;
42
 import com.ruoyi.web.modules.goods.vo.GoodsDetailVO;
@@ -45,6 +49,7 @@ import com.ruoyi.web.modules.store.constant.ShopConstants;
45
 import com.ruoyi.web.modules.store.domain.BizShop;
49
 import com.ruoyi.web.modules.store.domain.BizShop;
46
 import com.ruoyi.web.modules.store.facade.IShopGlobalConfigFacade;
50
 import com.ruoyi.web.modules.store.facade.IShopGlobalConfigFacade;
47
 import com.ruoyi.web.modules.store.mapper.BizShopMapper;
51
 import com.ruoyi.web.modules.store.mapper.BizShopMapper;
52
+import com.ruoyi.web.modules.template.constant.AttrTemplateConstants;
48
 import com.ruoyi.web.modules.template.domain.BizGoodsAttrTemplate;
53
 import com.ruoyi.web.modules.template.domain.BizGoodsAttrTemplate;
49
 import com.ruoyi.web.modules.template.mapper.BizGoodsAttrTemplateMapper;
54
 import com.ruoyi.web.modules.template.mapper.BizGoodsAttrTemplateMapper;
50
 
55
 
@@ -87,6 +92,9 @@ public class GoodsServiceImpl implements IGoodsService
87
     @Autowired
92
     @Autowired
88
     private BizGoodsAttrTemplateMapper attrTemplateMapper;
93
     private BizGoodsAttrTemplateMapper attrTemplateMapper;
89
 
94
 
95
+    @Autowired
96
+    private BizGoodsAttrMapper goodsAttrMapper;
97
+
90
     @Override
98
     @Override
91
     public List<GoodsListVO> selectPlatformList(BizGoods query)
99
     public List<GoodsListVO> selectPlatformList(BizGoods query)
92
     {
100
     {
@@ -150,6 +158,7 @@ public class GoodsServiceImpl implements IGoodsService
150
         goods.setCreateBy(operator);
158
         goods.setCreateBy(operator);
151
         goodsMapper.insert(goods);
159
         goodsMapper.insert(goods);
152
         saveServiceSnapshots(goods.getGoodsId(), dto.getServiceIds());
160
         saveServiceSnapshots(goods.getGoodsId(), dto.getServiceIds());
161
+        saveGoodsAttrs(goods.getGoodsId(), dto.getAttributes(), dto.getSpecs());
153
         return goods.getGoodsId();
162
         return goods.getGoodsId();
154
     }
163
     }
155
 
164
 
@@ -176,6 +185,7 @@ public class GoodsServiceImpl implements IGoodsService
176
         update.setUpdateBy(operator);
185
         update.setUpdateBy(operator);
177
         goodsMapper.updateSeller(update);
186
         goodsMapper.updateSeller(update);
178
         saveServiceSnapshots(dto.getGoodsId(), dto.getServiceIds());
187
         saveServiceSnapshots(dto.getGoodsId(), dto.getServiceIds());
188
+        saveGoodsAttrs(dto.getGoodsId(), dto.getAttributes(), dto.getSpecs());
179
     }
189
     }
180
 
190
 
181
     @Override
191
     @Override
@@ -357,6 +367,7 @@ public class GoodsServiceImpl implements IGoodsService
357
         for (Long goodsId : dto.getGoodsIds())
367
         for (Long goodsId : dto.getGoodsIds())
358
         {
368
         {
359
             snapshotMapper.deleteByGoodsId(goodsId);
369
             snapshotMapper.deleteByGoodsId(goodsId);
370
+            goodsAttrMapper.deleteByGoodsId(goodsId);
360
         }
371
         }
361
     }
372
     }
362
 
373
 
@@ -444,6 +455,9 @@ public class GoodsServiceImpl implements IGoodsService
444
         vo.setCanEdit(true);
455
         vo.setCanEdit(true);
445
         List<GoodsServiceSnapshotVO> services = snapshotMapper.selectVisibleByGoodsId(goods.getGoodsId());
456
         List<GoodsServiceSnapshotVO> services = snapshotMapper.selectVisibleByGoodsId(goods.getGoodsId());
446
         vo.setServices(services);
457
         vo.setServices(services);
458
+        List<BizGoodsAttr> attrRows = goodsAttrMapper.selectByGoodsId(goods.getGoodsId());
459
+        vo.setAttributes(GoodsAttrSupport.groupByItem(attrRows, AttrTemplateConstants.ITEM_TYPE_ATTR));
460
+        vo.setSpecs(GoodsAttrSupport.groupByItem(attrRows, AttrTemplateConstants.ITEM_TYPE_SPEC));
447
         if (platform)
461
         if (platform)
448
         {
462
         {
449
             fillShopMerchantName(vo, goods.getShopId());
463
             fillShopMerchantName(vo, goods.getShopId());
@@ -524,6 +538,20 @@ public class GoodsServiceImpl implements IGoodsService
524
         }
538
         }
525
     }
539
     }
526
 
540
 
541
+    private void saveGoodsAttrs(Long goodsId, List<GoodsAttrItemDTO> attributes, List<GoodsAttrItemDTO> specs)
542
+    {
543
+        goodsAttrMapper.deleteByGoodsId(goodsId);
544
+        if ((attributes == null || attributes.isEmpty()) && (specs == null || specs.isEmpty()))
545
+        {
546
+            return;
547
+        }
548
+        List<BizGoodsAttr> rows = GoodsAttrSupport.flatten(goodsId, attributes, specs);
549
+        if (!rows.isEmpty())
550
+        {
551
+            goodsAttrMapper.batchInsert(rows);
552
+        }
553
+    }
554
+
527
     private BizGoods buildGoodsFromDto(GoodsSaveDTO dto)
555
     private BizGoods buildGoodsFromDto(GoodsSaveDTO dto)
528
     {
556
     {
529
         BizGoods goods = new BizGoods();
557
         BizGoods goods = new BizGoods();

+ 25 - 0
baqing-shop/src/main/java/com/ruoyi/web/modules/goods/vo/GoodsDetailVO.java

@@ -4,6 +4,7 @@ import java.math.BigDecimal;
4
 import java.util.ArrayList;
4
 import java.util.ArrayList;
5
 import java.util.Date;
5
 import java.util.Date;
6
 import java.util.List;
6
 import java.util.List;
7
+import com.ruoyi.web.modules.goods.dto.GoodsAttrItemDTO;
7
 
8
 
8
 /**
9
 /**
9
  * 商品详情
10
  * 商品详情
@@ -44,6 +45,10 @@ public class GoodsDetailVO extends GoodsListVO
44
 
45
 
45
     private List<GoodsServiceSnapshotVO> services = new ArrayList<>();
46
     private List<GoodsServiceSnapshotVO> services = new ArrayList<>();
46
 
47
 
48
+    private List<GoodsAttrItemDTO> attributes = new ArrayList<>();
49
+
50
+    private List<GoodsAttrItemDTO> specs = new ArrayList<>();
51
+
47
     public Long getShopId()
52
     public Long getShopId()
48
     {
53
     {
49
         return shopId;
54
         return shopId;
@@ -213,4 +218,24 @@ public class GoodsDetailVO extends GoodsListVO
213
     {
218
     {
214
         this.services = services;
219
         this.services = services;
215
     }
220
     }
221
+
222
+    public List<GoodsAttrItemDTO> getAttributes()
223
+    {
224
+        return attributes;
225
+    }
226
+
227
+    public void setAttributes(List<GoodsAttrItemDTO> attributes)
228
+    {
229
+        this.attributes = attributes;
230
+    }
231
+
232
+    public List<GoodsAttrItemDTO> getSpecs()
233
+    {
234
+        return specs;
235
+    }
236
+
237
+    public void setSpecs(List<GoodsAttrItemDTO> specs)
238
+    {
239
+        this.specs = specs;
240
+    }
216
 }
241
 }

+ 41 - 0
baqing-shop/src/test/java/com/ruoyi/web/modules/goods/controller/SellerGoodsControllerTest.java

@@ -42,6 +42,10 @@ import com.ruoyi.web.modules.goods.exception.GoodsBatchOperationException;
42
 import com.ruoyi.web.modules.goods.service.IGoodsService;
42
 import com.ruoyi.web.modules.goods.service.IGoodsService;
43
 import com.ruoyi.web.modules.goods.vo.GoodsDetailVO;
43
 import com.ruoyi.web.modules.goods.vo.GoodsDetailVO;
44
 import com.ruoyi.web.modules.goods.vo.GoodsListVO;
44
 import com.ruoyi.web.modules.goods.vo.GoodsListVO;
45
+import com.ruoyi.web.modules.template.dto.AttrTemplateItemDTO;
46
+import com.ruoyi.web.modules.template.facade.IAttrTemplateFacade;
47
+import com.ruoyi.web.modules.template.vo.AttrTemplateDetailVO;
48
+import com.ruoyi.web.modules.template.vo.AttrTemplateOptionVO;
45
 
49
 
46
 @ExtendWith(MockitoExtension.class)
50
 @ExtendWith(MockitoExtension.class)
47
 class SellerGoodsControllerTest
51
 class SellerGoodsControllerTest
@@ -58,6 +62,9 @@ class SellerGoodsControllerTest
58
     @Mock
62
     @Mock
59
     private IGoodsServiceFacade goodsServiceFacade;
63
     private IGoodsServiceFacade goodsServiceFacade;
60
 
64
 
65
+    @Mock
66
+    private IAttrTemplateFacade attrTemplateFacade;
67
+
61
     @Spy
68
     @Spy
62
     @InjectMocks
69
     @InjectMocks
63
     private SellerGoodsController controller;
70
     private SellerGoodsController controller;
@@ -195,6 +202,40 @@ class SellerGoodsControllerTest
195
                 .andExpect(jsonPath("$.data[0].label").value("肥料 > 复合肥"));
202
                 .andExpect(jsonPath("$.data[0].label").value("肥料 > 复合肥"));
196
     }
203
     }
197
 
204
 
205
+    @Test
206
+    void attrTemplateDetail_returnsPrefillData() throws Exception
207
+    {
208
+        AttrTemplateItemDTO attr = new AttrTemplateItemDTO();
209
+        attr.setItemName("品牌");
210
+        attr.setValues(Collections.singletonList("华为"));
211
+        AttrTemplateDetailVO detail = new AttrTemplateDetailVO();
212
+        detail.setTemplateId(4L);
213
+        detail.setTemplateName("手机");
214
+        detail.setAttributes(Collections.singletonList(attr));
215
+        detail.setSpecs(Collections.emptyList());
216
+        when(attrTemplateFacade.getDetailForGoods(4L, 101L)).thenReturn(detail);
217
+
218
+        mockMvc.perform(get("/agri/seller/goods/attrTemplate/4"))
219
+                .andExpect(status().isOk())
220
+                .andExpect(jsonPath("$.code").value(200))
221
+                .andExpect(jsonPath("$.data.templateName").value("手机"))
222
+                .andExpect(jsonPath("$.data.attributes[0].itemName").value("品牌"));
223
+    }
224
+
225
+    @Test
226
+    void attrTemplateOptions_returnsList() throws Exception
227
+    {
228
+        AttrTemplateOptionVO option = new AttrTemplateOptionVO();
229
+        option.setTemplateId(4L);
230
+        option.setTemplateName("手机");
231
+        when(attrTemplateFacade.listOptionsByShopId(101L)).thenReturn(Collections.singletonList(option));
232
+
233
+        mockMvc.perform(get("/agri/seller/goods/attrTemplateOptions"))
234
+                .andExpect(status().isOk())
235
+                .andExpect(jsonPath("$.code").value(200))
236
+                .andExpect(jsonPath("$.data[0].templateName").value("手机"));
237
+    }
238
+
198
     private GoodsSaveDTO buildSaveDto()
239
     private GoodsSaveDTO buildSaveDto()
199
     {
240
     {
200
         GoodsSaveDTO dto = new GoodsSaveDTO();
241
         GoodsSaveDTO dto = new GoodsSaveDTO();

+ 42 - 0
baqing-shop/src/test/java/com/ruoyi/web/modules/goods/service/GoodsServiceImplTest.java

@@ -29,11 +29,13 @@ import com.ruoyi.web.modules.goodsservice.mapper.BizGoodsServiceMapper;
29
 import com.ruoyi.web.modules.goods.constant.GoodsConstants;
29
 import com.ruoyi.web.modules.goods.constant.GoodsConstants;
30
 import com.ruoyi.web.modules.goods.domain.BizGoods;
30
 import com.ruoyi.web.modules.goods.domain.BizGoods;
31
 import com.ruoyi.web.modules.goods.dto.GoodsAuditDTO;
31
 import com.ruoyi.web.modules.goods.dto.GoodsAuditDTO;
32
+import com.ruoyi.web.modules.goods.dto.GoodsAttrItemDTO;
32
 import com.ruoyi.web.modules.goods.dto.GoodsBatchIdsDTO;
33
 import com.ruoyi.web.modules.goods.dto.GoodsBatchIdsDTO;
33
 import com.ruoyi.web.modules.goods.dto.GoodsOffShelfDTO;
34
 import com.ruoyi.web.modules.goods.dto.GoodsOffShelfDTO;
34
 import com.ruoyi.web.modules.goods.dto.GoodsSaveDTO;
35
 import com.ruoyi.web.modules.goods.dto.GoodsSaveDTO;
35
 import com.ruoyi.web.modules.goods.exception.GoodsBatchOperationException;
36
 import com.ruoyi.web.modules.goods.exception.GoodsBatchOperationException;
36
 import com.ruoyi.web.modules.goods.facade.IGoodsOrderFacade;
37
 import com.ruoyi.web.modules.goods.facade.IGoodsOrderFacade;
38
+import com.ruoyi.web.modules.goods.mapper.BizGoodsAttrMapper;
37
 import com.ruoyi.web.modules.goods.mapper.BizGoodsMapper;
39
 import com.ruoyi.web.modules.goods.mapper.BizGoodsMapper;
38
 import com.ruoyi.web.modules.goods.mapper.BizGoodsServiceSnapshotMapper;
40
 import com.ruoyi.web.modules.goods.mapper.BizGoodsServiceSnapshotMapper;
39
 import com.ruoyi.web.modules.goods.service.impl.GoodsServiceImpl;
41
 import com.ruoyi.web.modules.goods.service.impl.GoodsServiceImpl;
@@ -85,6 +87,9 @@ class GoodsServiceImplTest
85
     @Mock
87
     @Mock
86
     private BizGoodsAttrTemplateMapper attrTemplateMapper;
88
     private BizGoodsAttrTemplateMapper attrTemplateMapper;
87
 
89
 
90
+    @Mock
91
+    private BizGoodsAttrMapper goodsAttrMapper;
92
+
88
     @InjectMocks
93
     @InjectMocks
89
     private GoodsServiceImpl goodsService;
94
     private GoodsServiceImpl goodsService;
90
 
95
 
@@ -439,6 +444,42 @@ class GoodsServiceImplTest
439
         verify(goodsMapper, never()).insert(any());
444
         verify(goodsMapper, never()).insert(any());
440
     }
445
     }
441
 
446
 
447
+    @Test
448
+    void insert_withAttributes_persistsGoodsAttrRows()
449
+    {
450
+        when(goodsSnGenerator.nextSn()).thenReturn("G20260526000004");
451
+        BizGoodsCategory platformCat = new BizGoodsCategory();
452
+        platformCat.setCategoryLevel(CategoryConstants.LEVEL_TWO);
453
+        when(categoryMapper.selectById(100L, null)).thenReturn(platformCat);
454
+        when(goodsMapper.insert(any())).thenAnswer(inv -> {
455
+            BizGoods g = inv.getArgument(0);
456
+            g.setGoodsId(102L);
457
+            return 1;
458
+        });
459
+
460
+        GoodsAttrItemDTO attr = new GoodsAttrItemDTO();
461
+        attr.setItemName("品牌");
462
+        attr.setValues(Collections.singletonList("华为"));
463
+        GoodsAttrItemDTO spec = new GoodsAttrItemDTO();
464
+        spec.setItemName("内存");
465
+        spec.setValues(Arrays.asList("8G", "16G"));
466
+
467
+        GoodsSaveDTO dto = new GoodsSaveDTO();
468
+        dto.setCategoryId(100L);
469
+        dto.setGoodsName("手机");
470
+        dto.setMainPic("/a.jpg");
471
+        dto.setDetailContent("detail");
472
+        dto.setSalePrice(new BigDecimal("1999"));
473
+        dto.setStock(10);
474
+        dto.setAttributes(Collections.singletonList(attr));
475
+        dto.setSpecs(Collections.singletonList(spec));
476
+
477
+        goodsService.insertSellerGoods(10L, dto, "seller");
478
+
479
+        verify(goodsAttrMapper).deleteByGoodsId(102L);
480
+        verify(goodsAttrMapper).batchInsert(any());
481
+    }
482
+
442
     @Test
483
     @Test
443
     void delete_draft_success()
484
     void delete_draft_success()
444
     {
485
     {
@@ -450,6 +491,7 @@ class GoodsServiceImplTest
450
 
491
 
451
         verify(goodsMapper).logicDeleteByIds(any(), eq(10L), eq("seller"));
492
         verify(goodsMapper).logicDeleteByIds(any(), eq(10L), eq("seller"));
452
         verify(snapshotMapper).deleteByGoodsId(7L);
493
         verify(snapshotMapper).deleteByGoodsId(7L);
494
+        verify(goodsAttrMapper).deleteByGoodsId(7L);
453
     }
495
     }
454
 
496
 
455
     @Test
497
     @Test

+ 14 - 6
doc/店铺后台/商品管理/商品列表/商品列表前端技术方案.md

@@ -70,15 +70,18 @@
70
 └── 添加/编辑弹窗(720px · v1.0 单规格)
70
 └── 添加/编辑弹窗(720px · v1.0 单规格)
71
     ├── 商品分类 categoryId(必填,平台二级)
71
     ├── 商品分类 categoryId(必填,平台二级)
72
     ├── 店铺商品分类 shopCategoryId(选填,本店二级)
72
     ├── 店铺商品分类 shopCategoryId(选填,本店二级)
73
-    ├── 属性模版 attrTemplateId(选填;下拉 `/agri/seller/attrTemplate/options`)
73
+    ├── 属性模版 attrTemplateId(选填)
74
+    │   ├── 下拉 GET /agri/seller/goods/attrTemplateOptions
75
+    │   ├── 选用预填 GET /agri/seller/goods/attrTemplate/{templateId}
76
+    │   └── 属性 attributes[] / 规格 specs[](itemName + values[],可增删改)
74
     ├── 商品名称、主图、销售价、库存
77
     ├── 商品名称、主图、销售价、库存
75
     ├── 商品详情 detailContent(Editor 富文本)
78
     ├── 商品详情 detailContent(Editor 富文本)
76
     └── 服务说明 serviceIds(多选;新建默认勾选 defaultShow)
79
     └── 服务说明 serviceIds(多选;新建默认勾选 defaultShow)
77
 ```
80
 ```
78
 
81
 
79
-**v1.0 未实现(后续扩展):** 多图 gallery、多规格 SKU、运费模版、属性/规格明细编辑(`biz_goods_attr`)、独立 form 路由页。
82
+**v1.0 未实现(后续扩展):** 多图 gallery、多规格 SKU、运费模版、独立 form 路由页。
80
 
83
 
81
-**v1.0 后端已支持:** 保存/详情 `attrTemplateId`(模版引用 ID);前端发品表单待接入下拉与回显
84
+**已实现(后端):** `attrTemplateId` + `attributes`/`specs` 保存与详情回显(`biz_goods_attr`);前端发品表单待接入
82
 
85
 
83
 ---
86
 ---
84
 
87
 
@@ -101,7 +104,8 @@
101
 | `listSellerGoods` | GET | `/list` | 分页列表 |
104
 | `listSellerGoods` | GET | `/list` | 分页列表 |
102
 | `sellerGoodsCategoryOptions` | GET | `/categoryOptions` | 平台二级分类 |
105
 | `sellerGoodsCategoryOptions` | GET | `/categoryOptions` | 平台二级分类 |
103
 | `sellerGoodsShopCategoryOptions` | GET | `/shopCategoryOptions` | 本店二级店铺分类 |
106
 | `sellerGoodsShopCategoryOptions` | GET | `/shopCategoryOptions` | 本店二级店铺分类 |
104
-| `sellerAttrTemplateOptions` | GET | `/agri/seller/attrTemplate/options` | 本店属性模版(属性模版模块) |
107
+| `sellerAttrTemplateOptions` | GET | `/attrTemplateOptions` | 本店属性模版下拉 |
108
+| `getSellerAttrTemplateDetail` | GET | `/attrTemplate/{templateId}` | 选用模版预填 attributes/specs |
105
 | `sellerGoodsServiceOptions` | GET | `/serviceOptions` | `{ all, defaultShow }` |
109
 | `sellerGoodsServiceOptions` | GET | `/serviceOptions` | `{ all, defaultShow }` |
106
 | `getSellerGoods` | GET | `/{goodsId}` | 详情 |
110
 | `getSellerGoods` | GET | `/{goodsId}` | 详情 |
107
 | `addSellerGoods` | POST | `/` | 新增 → status=0 |
111
 | `addSellerGoods` | POST | `/` | 新增 → status=0 |
@@ -140,6 +144,8 @@
140
 | categoryId | 必填 | 必填 | 平台二级 |
144
 | categoryId | 必填 | 必填 | 平台二级 |
141
 | shopCategoryId | 选填 | 选填 | 本店二级 |
145
 | shopCategoryId | 选填 | 选填 | 本店二级 |
142
 | attrTemplateId | 选填 | 选填 | 本店有效属性模版 |
146
 | attrTemplateId | 选填 | 选填 | 本店有效属性模版 |
147
+| attributes | 选填 | 选填 | `[{ itemName, values[] }]` 属性项 |
148
+| specs | 选填 | 选填 | `[{ itemName, values[] }]` 规格项 |
143
 | goodsName | 必填 | 必填 | max 200 |
149
 | goodsName | 必填 | 必填 | max 200 |
144
 | mainPic | 必填 | 必填 | image-upload |
150
 | mainPic | 必填 | 必填 | image-upload |
145
 | detailContent | 必填 | 必填 | 富文本 |
151
 | detailContent | 必填 | 必填 | 富文本 |
@@ -170,6 +176,7 @@
170
 | services[] | 服务快照;编辑时回显 `serviceIds` |
176
 | services[] | 服务快照;编辑时回显 `serviceIds` |
171
 | shopCategoryPath | 店铺分类路径 |
177
 | shopCategoryPath | 店铺分类路径 |
172
 | attrTemplateId / attrTemplateName | 属性模版引用与名称 |
178
 | attrTemplateId / attrTemplateName | 属性模版引用与名称 |
179
+| attributes / specs | 属性/规格快照(同保存体结构) |
173
 
180
 
174
 ---
181
 ---
175
 
182
 
@@ -194,8 +201,8 @@
194
 - [ ] 添加 → 未上架 → 提交上架(免审开/关两种策略)
201
 - [ ] 添加 → 未上架 → 提交上架(免审开/关两种策略)
195
 - [ ] 出售中编辑保存后状态不变
202
 - [ ] 出售中编辑保存后状态不变
196
 - [ ] 批量操作含非法状态时后端报错
203
 - [ ] 批量操作含非法状态时后端报错
204
+- [ ] 选模版 → 预填 attributes/specs → 修改后保存 → 详情回显一致
197
 - [ ] 分类/店铺分类/属性模版/服务下拉数据正确
205
 - [ ] 分类/店铺分类/属性模版/服务下拉数据正确
198
-- [ ] 保存带 attrTemplateId 后详情回显 attrTemplateName
199
 
206
 
200
 ---
207
 ---
201
 
208
 
@@ -205,7 +212,8 @@
205
 |------|------|
212
 |------|------|
206
 | v1.0 | 首版:列表 + v1.0 单规格发品弹窗 + 详情抽屉 + 状态流转操作 |
213
 | v1.0 | 首版:列表 + v1.0 单规格发品弹窗 + 详情抽屉 + 状态流转操作 |
207
 | v1.1 | 对齐后端 `attrTemplateId`:保存体/详情字段;下拉走属性模版模块 |
214
 | v1.1 | 对齐后端 `attrTemplateId`:保存体/详情字段;下拉走属性模版模块 |
215
+| v1.2 | 对齐 `biz_goods_attr`:attributes/specs 保存体;goods 侧预填/下拉 API |
208
 
216
 
209
 ---
217
 ---
210
 
218
 
211
-*文档版本:v1.1 · 关联《商品列表功能需求.md》v1.3、《店铺商品列表技术方案.md》v1.2*
219
+*文档版本:v1.2 · 关联《商品列表功能需求.md》v1.4、《店铺商品列表技术方案.md》v1.4*

+ 7 - 7
doc/店铺后台/商品管理/商品列表/商品列表功能需求.md

@@ -268,7 +268,7 @@
268
 | 基础信息 | 编号、名称、主图/详情图、售价、库存、销量、重量、条码、关键词、简述等 |
268
 | 基础信息 | 编号、名称、主图/详情图、售价、库存、销量、重量、条码、关键词、简述等 |
269
 | 分类 | **商品分类**(一级 > 二级)、**店铺商品分类**(一级 > 二级,若有)、**属性模版**(名称,若选用) |
269
 | 分类 | **商品分类**(一级 > 二级)、**店铺商品分类**(一级 > 二级,若有)、**属性模版**(名称,若选用) |
270
 | 规格与运费 | 销售规格(统一/多规格)、规格明细(市场价、库存);快递运费(固定/模版) |
270
 | 规格与运费 | 销售规格(统一/多规格)、规格明细(市场价、库存);快递运费(固定/模版) |
271
-| 属性 | 基于 **属性模版** 扩展的属性项、规格项及其值 |
271
+| 属性 | 基于 **属性模版** 扩展的属性项、规格项及其值(详情 `attributes`/`specs` 快照) |
272
 | 详情内容 | 图文详情(图片块 + 文本块) |
272
 | 详情内容 | 图文详情(图片块 + 文本块) |
273
 | 服务快照 | 已勾选 **商品服务** 项的快照(只读) |
273
 | 服务快照 | 已勾选 **商品服务** 项的快照(只读) |
274
 | 状态信息 | 当前状态;**待审核/审核失败** 展示提交时间、审核时间(若有)、**驳回原因** |
274
 | 状态信息 | 当前状态;**待审核/审核失败** 展示提交时间、审核时间(若有)、**驳回原因** |
@@ -298,7 +298,7 @@
298
 | 基础 | 商品条码 | 否 | — |
298
 | 基础 | 商品条码 | 否 | — |
299
 | 基础 | 搜索关键词 | 否 | — |
299
 | 基础 | 搜索关键词 | 否 | — |
300
 | 基础 | 商品简述 | 视产品 | — |
300
 | 基础 | 商品简述 | 视产品 | — |
301
-| 属性 | 属性模版 | 否 | 下拉选用本店模版(数据源见 **属性模版** 模块);保存 **引用 ID**;属性/规格键值明细 **v1.x** |
301
+| 属性 | 属性模版 | 否 | 下拉选用本店模版;保存 **引用 ID** + **`attributes`/`specs` 快照** 至 `biz_goods_attr` |
302
 | 销售 | 销售规格 | 是 | **统一规格 / 多规格** |
302
 | 销售 | 销售规格 | 是 | **统一规格 / 多规格** |
303
 | 销售 | 规格明细 | 是 | 每种规格:**市场价、库存**(首期单规格时一行) |
303
 | 销售 | 规格明细 | 是 | 每种规格:**市场价、库存**(首期单规格时一行) |
304
 | 物流 | 快递运费 | 是 | **固定运费 / 运费模版** |
304
 | 物流 | 快递运费 | 是 | **固定运费 / 运费模版** |
@@ -321,7 +321,7 @@
321
 | 当前店铺有效 | 停业店仍可发品;**已逻辑删除** 店铺不可用 |
321
 | 当前店铺有效 | 停业店仍可发品;**已逻辑删除** 店铺不可用 |
322
 | 商品分类 | 平台已维护 **平台二级分类**(至少一条可选);商家 **只选** |
322
 | 商品分类 | 平台已维护 **平台二级分类**(至少一条可选);商家 **只选** |
323
 | 商品服务 | 平台已配置目录则可选;无目录时服务区域为空 |
323
 | 商品服务 | 平台已配置目录则可选;无目录时服务区域为空 |
324
-| 属性模版 | 可选;下拉来自 **属性模版** 模块 `/agri/seller/attrTemplate/options`;无模版时可不传 |
324
+| 属性模版 | 可选;下拉 `GET /agri/seller/goods/attrTemplateOptions`;选用后 `GET .../attrTemplate/{id}` 预填;无模版时可手工维护属性/规格 |
325
 
325
 
326
 ---
326
 ---
327
 
327
 
@@ -600,7 +600,7 @@
600
 | **GL15** | 须携带 **当前店铺上下文**;切换店铺后数据范围随之变化 |
600
 | **GL15** | 须携带 **当前店铺上下文**;切换店铺后数据范围随之变化 |
601
 | **GL16** | 商品创建后 **不可换店** |
601
 | **GL16** | 商品创建后 **不可换店** |
602
 | **GL17** | **任意状态** 均可 **查看详情**;审核失败须展示 **驳回原因** |
602
 | **GL17** | **任意状态** 均可 **查看详情**;审核失败须展示 **驳回原因** |
603
-| **GL18** | 发品可选 **属性模版**;若传须为 **当前店铺** 有效模版;保存写入引用;模版项/值快照 **biz_goods_attr** 另案(v1.x) |
603
+| **GL18** | 发品可选 **属性模版**;若传须为 **当前店铺** 有效模版;保存写入 `attr_template_id` 与 **`biz_goods_attr` 快照**(用户可改后再存) |
604
 
604
 
605
 ---
605
 ---
606
 
606
 
@@ -625,8 +625,7 @@
625
 
625
 
626
 | 项 | 说明 |
626
 | 项 | 说明 |
627
 |----|------|
627
 |----|------|
628
-| 属性模版 **维护** | 见 **属性模版** 模块;本模块 **仅选用 + 保存引用** |
629
-| 属性/规格 **明细落库** | `biz_goods_attr` 写入 **v1.x**;当前仅 `attr_template_id` |
628
+| 属性模版 **维护** | 见 **属性模版** 模块;本模块 **选用 + 保存引用与快照** |
630
 | 运费模版 **维护** | 假定平台或它模块已提供可选模版 |
629
 | 运费模版 **维护** | 假定平台或它模块已提供可选模版 |
631
 | 多规格 SKU 完整能力 | 草稿含多规格字段;复杂 SKU 以商品管理全模块为准 |
630
 | 多规格 SKU 完整能力 | 草稿含多规格字段;复杂 SKU 以商品管理全模块为准 |
632
 | 平台代商家改价/改库存 | 平台侧能力 |
631
 | 平台代商家改价/改库存 | 平台侧能力 |
@@ -665,7 +664,8 @@
665
 | **v1.1** | 对齐《店铺商品分类功能需求》v1.1:店铺商品分类为 **两级**,发品 **仅挂二级** |
664
 | **v1.1** | 对齐《店铺商品分类功能需求》v1.1:店铺商品分类为 **两级**,发品 **仅挂二级** |
666
 | **v1.2** | 统一「商品分类 = 平台二级 / 店铺商品分类 = 本店二级」口径;与《店铺商品列表技术方案》v1.1 及实现对齐 |
665
 | **v1.2** | 统一「商品分类 = 平台二级 / 店铺商品分类 = 本店二级」口径;与《店铺商品列表技术方案》v1.1 及实现对齐 |
667
 | **v1.3** | 对齐属性模版协作:发品可选 `attrTemplateId` 并保存引用;属性/规格明细 `biz_goods_attr` 仍标 v1.x |
666
 | **v1.3** | 对齐属性模版协作:发品可选 `attrTemplateId` 并保存引用;属性/规格明细 `biz_goods_attr` 仍标 v1.x |
667
+| **v1.4** | `biz_goods_attr` 已实现:`attributes`/`specs` 保存与详情回显;商品侧预填/下拉接口 |
668
 
668
 
669
 ---
669
 ---
670
 
670
 
671
-*文档版本:v1.3 · 关联《关联需求分析.md》v1.6、《商品审核功能需求.md》v1.0、《商品分类功能需求.md》v1.5、《商品服务管理功能需求.md》v1.0.1、《店铺商品分类功能需求.md》v1.1、《属性模版功能需求.md》v1.0 · 草稿《商品列表功能需求-草稿.md》保持不变。*
671
+*文档版本:v1.4 · 关联《店铺商品列表技术方案.md》v1.4、《属性模版功能需求.md》v1.1 · 草稿《商品列表功能需求-草稿.md》保持不变。*

+ 63 - 26
doc/店铺后台/商品管理/商品列表/店铺商品列表技术方案.md

@@ -1,7 +1,7 @@
1
 # 店铺商品列表 — 技术方案
1
 # 店铺商品列表 — 技术方案
2
 
2
 
3
-> **依据:** 《商品列表功能需求.md》v1.3  
4
-> **关联:** 《关联需求分析.md》v1.6;平台《商品审核技术方案》v1.1、《商品分类技术方案》v1.3、《商品服务管理技术方案》v1.0.1;商家《店铺商品分类技术方案》v1.2、《属性模版技术方案》v1.2  
3
+> **依据:** 《商品列表功能需求.md》v1.4  
4
+> **关联:** 《关联需求分析.md》v1.6;平台《商品审核技术方案》v1.1、《商品分类技术方案》v1.3、《商品服务管理技术方案》v1.0.1;商家《店铺商品分类技术方案》v1.2、《属性模版技术方案》v1.3  
5
 > **范围:** 商家端 **当前店铺** 商品列表、检索、发品、编辑、提交上架、下架、删除;**复用** 平台商品主表与状态机,**不另建** 商家商品表。  
5
 > **范围:** 商家端 **当前店铺** 商品列表、检索、发品、编辑、提交上架、下架、删除;**复用** 平台商品主表与状态机,**不另建** 商家商品表。  
6
 > **原则:** `goods_status` 仅经 submit / audit / offShelf 变更(P17);`X-Shop-Id` 店铺上下文;批量含非法状态 **整批失败**。
6
 > **原则:** `goods_status` 仅经 submit / audit / offShelf 变更(P17);`X-Shop-Id` 店铺上下文;批量含非法状态 **整批失败**。
7
 
7
 
@@ -56,7 +56,7 @@ biz_goods_category
56
 | **商品分类** | `category_id` ← `IPlatformCategoryService.selectPlatformLevel2Options()` |
56
 | **商品分类** | `category_id` ← `IPlatformCategoryService.selectPlatformLevel2Options()` |
57
 | **店铺商品分类** | `shop_category_id` ← `IShopGoodsCategoryFacade.listOptionsByShopId()` |
57
 | **店铺商品分类** | `shop_category_id` ← `IShopGoodsCategoryFacade.listOptionsByShopId()` |
58
 | **商品服务** | 发品多选 ← `IGoodsServiceFacade`;保存写快照 |
58
 | **商品服务** | 发品多选 ← `IGoodsServiceFacade`;保存写快照 |
59
-| **属性模版** | `attr_template_id` ← 保存时 `assertAttrTemplateIfPresent`;下拉 ← `/agri/seller/attrTemplate/options` 或 `IAttrTemplateFacade` |
59
+| **属性模版** | 选用模版预填 `attributes`/`specs`;保存 `attr_template_id` + **先删后插** `biz_goods_attr` |
60
 | **店铺设置** | `IShopGlobalConfigFacade.getDefaultAuditPass()` 决定 submit 目标态 |
60
 | **店铺设置** | `IShopGlobalConfigFacade.getDefaultAuditPass()` 决定 submit 目标态 |
61
 | **订单管理** | 下架前校验 **O10 未完成订单**(**待实现**,`IGoodsOrderFacade` 占位) |
61
 | **订单管理** | 下架前校验 **O10 未完成订单**(**待实现**,`IGoodsOrderFacade` 占位) |
62
 | **店铺管理** | `IGoodsShopFacade.hasBlockingGoodsForShopDelete`(`GoodsFacadeImpl`)删店前置 |
62
 | **店铺管理** | `IGoodsShopFacade.hasBlockingGoodsForShopDelete`(`GoodsFacadeImpl`)删店前置 |
@@ -73,12 +73,14 @@ baqing-shop/src/main/java/com/ruoyi/web/modules/goods/
73
 │   └── GoodsAuditController.java            # /agri/goodsAudit(平台审核菜单薄封装)
73
 │   └── GoodsAuditController.java            # /agri/goodsAudit(平台审核菜单薄封装)
74
 ├── service/
74
 ├── service/
75
 │   ├── IGoodsService.java
75
 │   ├── IGoodsService.java
76
-│   └── impl/GoodsServiceImpl.java           # 商家 + 平台共用;注入 template 模块 Mapper 校验 attrTemplateId
76
+│   └── impl/GoodsServiceImpl.java           # saveServiceSnapshots + saveGoodsAttrs
77
 ├── domain/
77
 ├── domain/
78
 │   ├── BizGoods.java
78
 │   ├── BizGoods.java
79
-│   └── BizGoodsServiceSnapshot.java
79
+│   ├── BizGoodsServiceSnapshot.java
80
+│   └── BizGoodsAttr.java
80
 ├── dto/
81
 ├── dto/
81
 │   ├── GoodsSaveDTO.java
82
 │   ├── GoodsSaveDTO.java
83
+│   ├── GoodsAttrItemDTO.java                # attributes[] / specs[] 项
82
 │   ├── GoodsAuditDTO.java
84
 │   ├── GoodsAuditDTO.java
83
 │   ├── GoodsBatchIdsDTO.java                # 批量提交 / 批量删除
85
 │   ├── GoodsBatchIdsDTO.java                # 批量提交 / 批量删除
84
 │   └── GoodsOffShelfDTO.java                # 批量下架
86
 │   └── GoodsOffShelfDTO.java                # 批量下架
@@ -89,7 +91,8 @@ baqing-shop/src/main/java/com/ruoyi/web/modules/goods/
89
 │   └── GoodsPurchaseVO.java                 # C 端/采购 Facade 用
91
 │   └── GoodsPurchaseVO.java                 # C 端/采购 Facade 用
90
 ├── mapper/
92
 ├── mapper/
91
 │   ├── BizGoodsMapper.java
93
 │   ├── BizGoodsMapper.java
92
-│   └── BizGoodsServiceSnapshotMapper.java
94
+│   ├── BizGoodsServiceSnapshotMapper.java
95
+│   └── BizGoodsAttrMapper.java
93
 ├── facade/
96
 ├── facade/
94
 │   ├── IGoodsOrderFacade.java               # 下架 O10(DefaultGoodsOrderFacade 占位)
97
 │   ├── IGoodsOrderFacade.java               # 下架 O10(DefaultGoodsOrderFacade 占位)
95
 │   ├── IGoodsPurchaseFacade.java
98
 │   ├── IGoodsPurchaseFacade.java
@@ -101,16 +104,19 @@ baqing-shop/src/main/java/com/ruoyi/web/modules/goods/
101
 ├── support/
104
 ├── support/
102
 │   ├── GoodsSnGenerator.java
105
 │   ├── GoodsSnGenerator.java
103
 │   ├── GoodsStatusUtils.java
106
 │   ├── GoodsStatusUtils.java
107
+│   ├── GoodsAttrSupport.java                # 属性/规格校验与扁平化/聚合
104
 │   └── GoodsBatchResponseSupport.java
108
 │   └── GoodsBatchResponseSupport.java
105
 ├── exception/GoodsBatchOperationException.java
109
 ├── exception/GoodsBatchOperationException.java
106
 └── constant/GoodsConstants.java
110
 └── constant/GoodsConstants.java
107
 
111
 
108
 baqing-shop/src/main/resources/mapper/goods/
112
 baqing-shop/src/main/resources/mapper/goods/
109
 ├── BizGoodsMapper.xml
113
 ├── BizGoodsMapper.xml
110
-└── BizGoodsServiceSnapshotMapper.xml
114
+├── BizGoodsServiceSnapshotMapper.xml
115
+└── BizGoodsAttrMapper.xml
111
 
116
 
112
 sql/biz_goods.sql                              # 含 shop_category_id、attr_template_id
117
 sql/biz_goods.sql                              # 含 shop_category_id、attr_template_id
113
 sql/biz_goods_service_snapshot.sql
118
 sql/biz_goods_service_snapshot.sql
119
+sql/biz_goods_attr.sql
114
 ```
120
 ```
115
 
121
 
116
 **跨模块依赖(接口与实现分离):**
122
 **跨模块依赖(接口与实现分离):**
@@ -121,8 +127,7 @@ sql/biz_goods_service_snapshot.sql
121
 | 店铺商品分类下拉 | `category/facade/IShopGoodsCategoryFacade.java` | `category/facade/impl/ShopGoodsCategoryFacadeImpl.java` |
127
 | 店铺商品分类下拉 | `category/facade/IShopGoodsCategoryFacade.java` | `category/facade/impl/ShopGoodsCategoryFacadeImpl.java` |
122
 | 平台二级分类下拉 | `category/service/IPlatformCategoryService.java` | `SellerGoodsController` 或 `SellerPlatformCategoryController` |
128
 | 平台二级分类下拉 | `category/service/IPlatformCategoryService.java` | `SellerGoodsController` 或 `SellerPlatformCategoryController` |
123
 | 分类路径 | `category/facade/ICategoryFacade.java` | `CategoryFacadeImpl` |
129
 | 分类路径 | `category/facade/ICategoryFacade.java` | `CategoryFacadeImpl` |
124
-| 属性模版校验 | `template/mapper/BizGoodsAttrTemplateMapper.java` | `GoodsServiceImpl` 直接注入 |
125
-| 属性模版下拉 | `template/facade/IAttrTemplateFacade.java` | 前端亦可调 `/agri/seller/attrTemplate/options` |
130
+| 属性模版校验/预填 | `template/facade/IAttrTemplateFacade.java` | `SellerGoodsController` `/attrTemplateOptions`、`/attrTemplate/{id}`;亦可调 `/agri/seller/attrTemplate/*` |
126
 | 商品服务目录 | `goodsservice/facade/IGoodsServiceFacade.java` | 发品 `serviceOptions` |
131
 | 商品服务目录 | `goodsservice/facade/IGoodsServiceFacade.java` | 发品 `serviceOptions` |
127
 | 店铺上下文 | `category/support/SellerShopContext.java` | 拦截器 `SellerShopContextInterceptor` |
132
 | 店铺上下文 | `category/support/SellerShopContext.java` | 拦截器 `SellerShopContextInterceptor` |
128
 | 店铺上下文 API | `store/controller/SellerShopContextController.java` | `GET /agri/seller/context` |
133
 | 店铺上下文 API | `store/controller/SellerShopContextController.java` | `GET /agri/seller/context` |
@@ -139,6 +144,7 @@ sql/biz_goods_service_snapshot.sql
139
 |------|------------|
144
 |------|------------|
140
 | **`biz_goods`** | 商品主表;商家 CRUD + 状态字段 |
145
 | **`biz_goods`** | 商品主表;商家 CRUD + 状态字段 |
141
 | **`biz_goods_service_snapshot`** | 发品勾选服务的 **展示快照** |
146
 | **`biz_goods_service_snapshot`** | 发品勾选服务的 **展示快照** |
147
+| **`biz_goods_attr`** | 发品属性/规格 **展示快照**(选用模版后可改) |
142
 | `biz_goods_category` | join 分类路径;**不** 由本模块维护 |
148
 | `biz_goods_category` | join 分类路径;**不** 由本模块维护 |
143
 | `biz_shop` | 店铺上下文、删店 join |
149
 | `biz_shop` | 店铺上下文、删店 join |
144
 | `biz_shop_global_config` | 全局默认审核开关 |
150
 | `biz_shop_global_config` | 全局默认审核开关 |
@@ -169,7 +175,7 @@ sql/biz_goods_service_snapshot.sql
169
 | del_flag | char(1) | 0 存在 2 逻辑删除 |
175
 | del_flag | char(1) | 0 存在 2 逻辑删除 |
170
 | create_by / create_time / update_by / update_time | | 审计 |
176
 | create_by / create_time / update_by / update_time | | 审计 |
171
 
177
 
172
-**首期未落库、后续扩展(需求 §8 完整表单):** 多图、重量、条码、关键词、简述、多规格 SKU、运费模版、**属性/规格键值(`biz_goods_attr`)** 等 → 见 §2.5;**`attr_template_id` 引用已实现**,不阻塞 v1.0 列表/单规格发品
178
+**首期未落库、后续扩展(需求 §8 完整表单):** 多图、重量、条码、关键词、简述、多规格 SKU、运费模版等 → 见 §2.5;**`attr_template_id` + `biz_goods_attr` 已实现**
173
 
179
 
174
 ### 2.3 增量 DDL
180
 ### 2.3 增量 DDL
175
 
181
 
@@ -187,7 +193,20 @@ ALTER TABLE `biz_goods`
187
 
193
 
188
 完整建表见 `sql/biz_goods.sql`。
194
 完整建表见 `sql/biz_goods.sql`。
189
 
195
 
190
-### 2.4 `biz_goods_service_snapshot`
196
+### 2.4 `biz_goods_attr`
197
+
198
+| 字段 | 类型 | 说明 |
199
+|------|------|------|
200
+| attr_id | bigint PK | |
201
+| goods_id | bigint NOT NULL | 商品 ID |
202
+| attr_type | char(1) | **1** 属性项 **2** 规格项 |
203
+| item_name | varchar(64) | 项名称 |
204
+| value_text | varchar(128) | 单值一行;多项多行 |
205
+| sort_no | int | 展示顺序 |
206
+
207
+保存商品时 **先删后插** 该商品全部属性行;删除商品时物理删除关联行。模版编辑 **不追溯** 已保存商品(AT8)。
208
+
209
+### 2.5 `biz_goods_service_snapshot`
191
 
210
 
192
 | 字段 | 说明 |
211
 | 字段 | 说明 |
193
 |------|------|
212
 |------|------|
@@ -198,18 +217,17 @@ ALTER TABLE `biz_goods`
198
 
217
 
199
 保存商品时 **先删后插** 该商品全部快照行。
218
 保存商品时 **先删后插** 该商品全部快照行。
200
 
219
 
201
-### 2.5 扩展表(非 v1.0 · 概要)
220
+### 2.6 扩展表(非 v1.0 · 概要)
202
 
221
 
203
 | 表(规划) | 用途 |
222
 | 表(规划) | 用途 |
204
 |------------|------|
223
 |------------|------|
205
 | `biz_goods_pic` | 多图 gallery |
224
 | `biz_goods_pic` | 多图 gallery |
206
 | `biz_goods_sku` | 多规格 SKU(市场价、库存) |
225
 | `biz_goods_sku` | 多规格 SKU(市场价、库存) |
207
 | `biz_goods_freight` | 固定运费 / 运费模版 ID |
226
 | `biz_goods_freight` | 固定运费 / 运费模版 ID |
208
-| `biz_goods_attr` | 属性项/规格项键值 |
209
 
227
 
210
-v1.0 **仅** 使用主表 `sale_price`/`stock`/`main_pic`/`detail_content`。
228
+v1.0 **使用** 主表 `sale_price`/`stock`/`main_pic`/`detail_content` + 服务快照 + **属性/规格快照**
211
 
229
 
212
-### 2.6 索引
230
+### 2.7 索引
213
 
231
 
214
 | 索引 | 字段 | 用途 |
232
 | 索引 | 字段 | 用途 |
215
 |------|------|------|
233
 |------|------|------|
@@ -218,9 +236,10 @@ v1.0 **仅** 使用主表 `sale_price`/`stock`/`main_pic`/`detail_content`。
218
 | idx_category_id | category_id, del_flag | 分类检索 |
236
 | idx_category_id | category_id, del_flag | 分类检索 |
219
 | idx_shop_category_id | shop_category_id, del_flag | 店铺分类删校验 |
237
 | idx_shop_category_id | shop_category_id, del_flag | 店铺分类删校验 |
220
 | idx_attr_template_id | attr_template_id, del_flag | 属性模版删校验 |
238
 | idx_attr_template_id | attr_template_id, del_flag | 属性模版删校验 |
239
+| idx_goods_attr | goods_id, attr_type, sort_no | 详情聚合属性/规格 |
221
 | idx_submit_time | submit_time | 待审核排序(平台侧) |
240
 | idx_submit_time | submit_time | 待审核排序(平台侧) |
222
 
241
 
223
-### 2.7 商家列表 SQL 约定
242
+### 2.8 商家列表 SQL 约定
224
 
243
 
225
 ```sql
244
 ```sql
226
 WHERE g.del_flag = '0'
245
 WHERE g.del_flag = '0'
@@ -228,7 +247,7 @@ WHERE g.del_flag = '0'
228
   -- 含 goods_status='0',与平台列表不同
247
   -- 含 goods_status='0',与平台列表不同
229
 ```
248
 ```
230
 
249
 
231
-### 2.8 字典
250
+### 2.9 字典
232
 
251
 
233
 | dict_type | 说明 |
252
 | dict_type | 说明 |
234
 |-----------|------|
253
 |-----------|------|
@@ -337,6 +356,7 @@ WHERE g.del_flag = '0'
337
 | 列表字段 | 同 §5.1 |
356
 | 列表字段 | 同 §5.1 |
338
 | shopId, categoryId, shopCategoryId | |
357
 | shopId, categoryId, shopCategoryId | |
339
 | attrTemplateId, attrTemplateName | 选用模版 ID 与名称(详情回显) |
358
 | attrTemplateId, attrTemplateName | 选用模版 ID 与名称(详情回显) |
359
+| attributes[], specs[] | 属性/规格快照(`GoodsAttrItemDTO`:`itemName` + `values[]`) |
340
 | detailContent, stock | |
360
 | detailContent, stock | |
341
 | rejectReason, submitTime, auditTime, offShelfTime | |
361
 | rejectReason, submitTime, auditTime, offShelfTime | |
342
 | services[] | 服务快照列表 |
362
 | services[] | 服务快照列表 |
@@ -351,7 +371,7 @@ WHERE g.del_flag = '0'
351
 | 权限 | `agri:seller:goods:add` |
371
 | 权限 | `agri:seller:goods:add` |
352
 | 日志 | title=商家商品 |
372
 | 日志 | title=商家商品 |
353
 
373
 
354
-**Body(GoodsSaveDTO · v1.0):**
374
+**Body(GoodsSaveDTO):**
355
 
375
 
356
 ```json
376
 ```json
357
 {
377
 {
@@ -363,7 +383,13 @@ WHERE g.del_flag = '0'
363
   "detailContent": "<p>详情</p>",
383
   "detailContent": "<p>详情</p>",
364
   "salePrice": 128.00,
384
   "salePrice": 128.00,
365
   "stock": 500,
385
   "stock": 500,
366
-  "serviceIds": [1, 3]
386
+  "serviceIds": [1, 3],
387
+  "attributes": [
388
+    { "itemName": "品牌", "values": ["华为"] }
389
+  ],
390
+  "specs": [
391
+    { "itemName": "内存", "values": ["8G", "16G"] }
392
+  ]
367
 }
393
 }
368
 ```
394
 ```
369
 
395
 
@@ -372,6 +398,7 @@ WHERE g.del_flag = '0'
372
 | categoryId | 须为 **平台二级** 有效分类 |
398
 | categoryId | 须为 **平台二级** 有效分类 |
373
 | shopCategoryId | 若传,须属 **当前店** 二级分类 |
399
 | shopCategoryId | 若传,须属 **当前店** 二级分类 |
374
 | attrTemplateId | 若传,须属 **当前店** 有效属性模版 |
400
 | attrTemplateId | 若传,须属 **当前店** 有效属性模版 |
401
+| attributes / specs | 可选;若传则项名非空、每项至少一值、同段项名/值不重复、属性与规格项名不可相同 |
375
 | serviceIds | 须全部存在于未删除服务目录 |
402
 | serviceIds | 须全部存在于未删除服务目录 |
376
 | goodsStatus | **禁止** 传入 |
403
 | goodsStatus | **禁止** 传入 |
377
 
404
 
@@ -387,7 +414,7 @@ WHERE g.del_flag = '0'
387
 | Body | 同 §5.3 + **goodsId** 必填 |
414
 | Body | 同 §5.3 + **goodsId** 必填 |
388
 
415
 
389
 - **不修改** `goods_status`、`shop_id`、`goods_sn`。  
416
 - **不修改** `goods_status`、`shop_id`、`goods_sn`。  
390
-- 各状态均可编辑(P14);保存后刷新服务快照。
417
+- 各状态均可编辑(P14);保存后刷新服务快照与 **属性/规格快照**
391
 
418
 
392
 ---
419
 ---
393
 
420
 
@@ -459,9 +486,9 @@ WHERE g.del_flag = '0'
459
 | GET | `/agri/seller/goods/shopCategoryOptions` | `list` | 本店二级「店铺商品分类」`IShopGoodsCategoryFacade.listOptionsByShopId(shopId, false)` |
486
 | GET | `/agri/seller/goods/shopCategoryOptions` | `list` | 本店二级「店铺商品分类」`IShopGoodsCategoryFacade.listOptionsByShopId(shopId, false)` |
460
 | GET | `/agri/seller/category/platformLevel2Options` | 同上 | 与上同源(分类模块只读入口,可选) |
487
 | GET | `/agri/seller/category/platformLevel2Options` | 同上 | 与上同源(分类模块只读入口,可选) |
461
 | GET | `/agri/seller/goods/serviceOptions` | `list` | `{ all: [], defaultShow: [] }` |
488
 | GET | `/agri/seller/goods/serviceOptions` | `list` | `{ all: [], defaultShow: [] }` |
462
-| GET | `/agri/seller/attrTemplate/options` | 属性模版模块 | 本店属性模版下拉(**非** goods 子路径;见《属性模版技术方案》§3.6) |
463
-
464
-> **规划(可选挂载):** `GET /agri/seller/goods/attrTemplateOptions` 委托 `IAttrTemplateFacade`,与上同源。
489
+| GET | `/agri/seller/goods/attrTemplateOptions` | `list` / `add` / `edit` | 本店属性模版下拉;委托 `IAttrTemplateFacade.listOptionsByShopId` |
490
+| GET | `/agri/seller/goods/attrTemplate/{templateId}` | 同上 | 选用模版预填;返回 `attributes`、`specs`(委托 `getDetailForGoods`) |
491
+| GET | `/agri/seller/attrTemplate/options` | 属性模版模块 | 与 `attrTemplateOptions` **同源**(亦可直调) |
465
 
492
 
466
 ---
493
 ---
467
 
494
 
@@ -490,6 +517,11 @@ insertSellerGoods
490
   → goodsSn = GoodsSnGenerator.next()
517
   → goodsSn = GoodsSnGenerator.next()
491
   → insert status=0(含 attr_template_id)
518
   → insert status=0(含 attr_template_id)
492
   → saveServiceSnapshots
519
   → saveServiceSnapshots
520
+  → saveGoodsAttrs(先删后插 biz_goods_attr)
521
+
522
+updateSellerGoods
523
+  → 同上校验(goodsId 属当前店)
524
+  → updateSeller + saveServiceSnapshots + saveGoodsAttrs
493
 
525
 
494
 submitGoods
526
 submitGoods
495
   → status in (0,3,4)
527
   → status in (0,3,4)
@@ -504,6 +536,7 @@ offShelfSeller
504
 deleteSellerGoodsBatch
536
 deleteSellerGoodsBatch
505
   → status in (0,3,4)
537
   → status in (0,3,4)
506
   → logic delete + 整批失败策略
538
   → logic delete + 整批失败策略
539
+  → delete service snapshot + goods_attr rows
507
   → [待] orderFacade 校验 O10(下架时)
540
   → [待] orderFacade 校验 O10(下架时)
508
 ```
541
 ```
509
 
542
 
@@ -515,6 +548,8 @@ deleteSellerGoodsBatch
515
 | shop_category_id | `biz_goods_category.category_id`,`shop_id = #{shopId}`,`parent_id > 0` |
548
 | shop_category_id | `biz_goods_category.category_id`,`shop_id = #{shopId}`,`parent_id > 0` |
516
 | attr_template_id | `biz_goods_attr_template.template_id`,`shop_id = #{shopId}`,`del_flag = '0'` |
549
 | attr_template_id | `biz_goods_attr_template.template_id`,`shop_id = #{shopId}`,`del_flag = '0'` |
517
 
550
 
551
+**错误 msg(属性/规格):** 项名为空 / 重复 / 与对端重名 / 值为空 / 值重复 → 见 `GoodsConstants.MSG_ATTR_*`。
552
+
518
 **错误 msg(属性模版):** 无效或非本店模版 → `请选择本店有效属性模版`(`GoodsConstants.MSG_ATTR_TEMPLATE_INVALID`)。
553
 **错误 msg(属性模版):** 无效或非本店模版 → `请选择本店有效属性模版`(`GoodsConstants.MSG_ATTR_TEMPLATE_INVALID`)。
519
 
554
 
520
 ---
555
 ---
@@ -542,9 +577,9 @@ deleteSellerGoodsBatch
542
 | **v1.0** | 列表/详情/单规格 CRUD/单独 submit·offShelf/服务快照/平台分类与服务下拉 | **已实现** |
577
 | **v1.0** | 列表/详情/单规格 CRUD/单独 submit·offShelf/服务快照/平台分类与服务下拉 | **已实现** |
543
 | **v1.0 补全** | `shop_category_id` + 店铺分类下拉 + 删除 + 批量 submit/offShelf/delete | **已实现** |
578
 | **v1.0 补全** | `shop_category_id` + 店铺分类下拉 + 删除 + 批量 submit/offShelf/delete | **已实现** |
544
 | **v1.0 补全·模版引用** | `attr_template_id` 保存/详情回显 + 本店模版校验 | **已实现** |
579
 | **v1.0 补全·模版引用** | `attr_template_id` 保存/详情回显 + 本店模版校验 | **已实现** |
580
+| **v1.0 补全·属性快照** | `biz_goods_attr` 写入/详情回显;预填接口;`GoodsAttrSupport` | **已实现** |
545
 | **v1.0 补全·O10** | 下架前未完成订单校验(`IGoodsOrderFacade`) | **待实现** |
581
 | **v1.0 补全·O10** | 下架前未完成订单校验(`IGoodsOrderFacade`) | **待实现** |
546
-| **v1.x** | 多图、多规格 SKU、运费、`biz_goods_attr` 写入、关键词检索扩展 | 规划 |
547
-| **v1.x·可选** | `/agri/seller/goods/attrTemplateOptions` 挂载 | 规划 |
582
+| **v1.x** | 多图、多规格 SKU、运费、关键词检索扩展 | 规划 |
548
 
583
 
549
 ---
584
 ---
550
 
585
 
@@ -563,6 +598,7 @@ deleteSellerGoodsBatch
563
 | T9 | 跨店 goodsId → MSG_NOT_OWNER |
598
 | T9 | 跨店 goodsId → MSG_NOT_OWNER |
564
 | T10 | 服务快照:保存后详情与目录变更隔离 |
599
 | T10 | 服务快照:保存后详情与目录变更隔离 |
565
 | T11 | attrTemplateId:本店有效模版写入;跨店/无效 ID → 失败 |
600
 | T11 | attrTemplateId:本店有效模版写入;跨店/无效 ID → 失败 |
601
+| T12 | attributes/specs:保存后详情回显;校验项名/值 |
566
 
602
 
567
 ---
603
 ---
568
 
604
 
@@ -574,7 +610,8 @@ deleteSellerGoodsBatch
574
 | **v1.1** | 同步代码:v1.0 补全已实现;统一平台/店铺分类口径;O10 仍标待实现 |
610
 | **v1.1** | 同步代码:v1.0 补全已实现;统一平台/店铺分类口径;O10 仍标待实现 |
575
 | **v1.2** | 同步 `attr_template_id`:GoodsSaveDTO/详情/Mapper/校验;下拉走属性模版模块 `/options` |
611
 | **v1.2** | 同步 `attr_template_id`:GoodsSaveDTO/详情/Mapper/校验;下拉走属性模版模块 `/options` |
576
 | **v1.3** | §1.3 模块落位与代码对齐:`IGoodsFacade` 跨包、`template` 依赖、快照/订单 Facade |
612
 | **v1.3** | §1.3 模块落位与代码对齐:`IGoodsFacade` 跨包、`template` 依赖、快照/订单 Facade |
613
+| **v1.4** | `biz_goods_attr` 保存/回显;`attributes`/`specs` 请求体;`/goods/attrTemplateOptions` 与预填接口 |
577
 
614
 
578
 ---
615
 ---
579
 
616
 
580
-*文档版本:v1.3 · 关联《商品列表功能需求.md》v1.3、《商品审核技术方案.md》v1.1、《商品分类技术方案.md》v1.3、《商品服务管理技术方案.md》v1.0.1、《店铺商品分类技术方案.md》v1.2、《属性模版技术方案.md》v1.2 · 技术栈 RuoYi v3.9.2-springboot2 + MySQL 5.7.39。*
617
+*文档版本:v1.4 · 关联《商品列表功能需求.md》v1.4、《属性模版技术方案.md》v1.3 · 技术栈 RuoYi v3.9.2-springboot2 + MySQL 5.7.39。*

+ 63 - 6
doc/店铺后台/商品管理/商品列表/店铺商品列表测试用例.md

@@ -1,11 +1,11 @@
1
 # 店铺商品列表 — 测试用例
1
 # 店铺商品列表 — 测试用例
2
 
2
 
3
-> **依据:** 《商品列表功能需求.md》v1.3、《店铺商品列表技术方案.md》v1.2  
3
+> **依据:** 《商品列表功能需求.md》v1.4、《店铺商品列表技术方案.md》v1.4  
4
 > **关联:** 《商品审核功能需求.md》《商品分类功能需求.md》《店铺商品分类功能需求.md》v1.1、《属性模版功能需求.md》、《角色管理测试用例.md》  
4
 > **关联:** 《商品审核功能需求.md》《商品分类功能需求.md》《店铺商品分类功能需求.md》v1.1、《属性模版功能需求.md》、《角色管理测试用例.md》  
5
-> **范围:** 商家端 `/agri/seller/goods`;`GoodsServiceImpl`(商家 scope)/ `SellerGoodsController`;`IGoodsFacade` / 服务快照 / 属性模版引用协作  
6
-> **排除:** 平台端审核/监管 UI E2E、C 端下单、多规格 SKU/运费模版完整表单、属性模版 **维护** UI、**biz_goods_attr** 明细写入  
5
+> **范围:** 商家端 `/agri/seller/goods`;`GoodsServiceImpl`(商家 scope)/ `SellerGoodsController`;`IGoodsFacade` / 服务快照 / 属性模版与 **biz_goods_attr** 协作  
6
+> **排除:** 平台端审核/监管 UI E2E、C 端下单、多规格 SKU/运费模版完整表单、属性模版 **维护** UI  
7
 > **环境:** RuoYi v3.9.2-springboot2;MySQL 5.7.39;请求头 **`X-Shop-Id`** 标识当前店铺  
7
 > **环境:** RuoYi v3.9.2-springboot2;MySQL 5.7.39;请求头 **`X-Shop-Id`** 标识当前店铺  
8
-> **实现分期:** v1.0 + v1.0 补全 **已实现**(含 shopCategoryId、attrTemplateId、删除、批量 submit/offShelf);**O10 订单阻断仍待实现**
8
+> **实现分期:** v1.0 补全 **已实现**(含 shopCategoryId、attrTemplateId、**biz_goods_attr**、删除、批量 submit/offShelf);**O10 仍待实现**
9
 
9
 
10
 ---
10
 ---
11
 
11
 
@@ -349,6 +349,34 @@
349
 | **测试步骤** | insertSellerGoods(101, dto含attrTemplateId) |
349
 | **测试步骤** | insertSellerGoods(101, dto含attrTemplateId) |
350
 | **预期结果** | 失败;msg 含「属性模版」;不调用 insert |
350
 | **预期结果** | 失败;msg 含「属性模版」;不调用 insert |
351
 
351
 
352
+### SGL-UT-026 保存写入 biz_goods_attr
353
+
354
+| 要素 | 内容 |
355
+|------|------|
356
+| **用例编号** | SGL-UT-026 |
357
+| **测试模块** | 店铺商品列表 |
358
+| **测试项** | saveGoodsAttrs |
359
+| **测试类型** | 单元测试 |
360
+| **测试工具** | JUnit 5 + Mockito |
361
+| **测试目的** | 验证 GL18:attributes/specs 先删后插 |
362
+| **前置条件** | 合法 GoodsSaveDTO;attributes 含「品牌」→「华为」;specs 含「内存」→「8G」「16G」 |
363
+| **测试步骤** | insertSellerGoods(101, dto) |
364
+| **预期结果** | `goodsAttrMapper.deleteByGoodsId` + `batchInsert` 被调用 |
365
+
366
+### SGL-UT-027 属性项校验失败
367
+
368
+| 要素 | 内容 |
369
+|------|------|
370
+| **用例编号** | SGL-UT-027 |
371
+| **测试模块** | 店铺商品列表 |
372
+| **测试项** | GoodsAttrSupport 校验 |
373
+| **测试类型** | 单元测试 |
374
+| **测试工具** | JUnit 5 |
375
+| **测试目的** | 属性与规格项名相同拒绝 |
376
+| **前置条件** | attributes 与 specs 均含 itemName=「内存」 |
377
+| **测试步骤** | insertSellerGoods |
378
+| **预期结果** | 失败;msg 含「不可相同」 |
379
+
352
 ### SGL-UT-021 删除可删状态成功
380
 ### SGL-UT-021 删除可删状态成功
353
 
381
 
354
 | 要素 | 内容 |
382
 | 要素 | 内容 |
@@ -787,6 +815,34 @@
787
 | **测试步骤** | 商家 GET /1002 |
815
 | **测试步骤** | 商家 GET /1002 |
788
 | **预期结果** | goods_status=3;rejectReason 非空 |
816
 | **预期结果** | goods_status=3;rejectReason 非空 |
789
 
817
 
818
+### SGL-API-029 属性模版预填
819
+
820
+| 要素 | 内容 |
821
+|------|------|
822
+| **用例编号** | SGL-API-029 |
823
+| **测试模块** | 店铺商品列表 |
824
+| **测试项** | GET /attrTemplate/{templateId} |
825
+| **测试类型** | 接口测试 |
826
+| **测试工具** | MockMvc |
827
+| **测试目的** | 验证选用模版预填 attributes/specs |
828
+| **前置条件** | templateId=4 属 shop 101 |
829
+| **测试步骤** | GET /agri/seller/goods/attrTemplate/4 |
830
+| **预期结果** | code=200;data.attributes / data.specs 结构与模版详情一致 |
831
+
832
+### SGL-API-030 保存带 attributes 后详情回显
833
+
834
+| 要素 | 内容 |
835
+|------|------|
836
+| **用例编号** | SGL-API-030 |
837
+| **测试模块** | 店铺商品列表 |
838
+| **测试项** | POST + GET 详情 |
839
+| **测试类型** | 接口测试 |
840
+| **测试工具** | Apifox |
841
+| **测试目的** | 验证 biz_goods_attr 写入与聚合回显 |
842
+| **前置条件** | 新建商品 body 含 attributes/specs |
843
+| **测试步骤** | POST 保存 → GET 详情 |
844
+| **预期结果** | 详情 attributes/specs 与保存体一致 |
845
+
790
 ---
846
 ---
791
 
847
 
792
 ## 三、界面测试(Playwright)
848
 ## 三、界面测试(Playwright)
@@ -1048,7 +1104,7 @@
1048
 | GL9 删除状态限制 | UT-021,022 | API-020,021 | UI-016 |
1104
 | GL9 删除状态限制 | UT-021,022 | API-020,021 | UI-016 |
1049
 | GL10 批量整批失败 | — | API-022,023 | UI-012 |
1105
 | GL10 批量整批失败 | — | API-022,023 | UI-012 |
1050
 | GL11 分类二级/店铺分类 | UT-007,020 | API-024,026 | UI-004 |
1106
 | GL11 分类二级/店铺分类 | UT-007,020 | API-024,026 | UI-004 |
1051
-| GL18 属性模版引用 | UT-024,025 | — | — |
1107
+| GL18 属性模版引用 | UT-024,025,026,027 | API-029,030 | — |
1052
 | GL12 服务快照 | UT-018,019 | API-025 | UI-017 |
1108
 | GL12 服务快照 | UT-018,019 | API-025 | UI-017 |
1053
 | GL13 保存不可改态 | UT-006 | API-010 | — |
1109
 | GL13 保存不可改态 | UT-006 | API-010 | — |
1054
 | GL14 外部变更不级联 | — | API-028 | — |
1110
 | GL14 外部变更不级联 | — | API-028 | — |
@@ -1068,7 +1124,8 @@
1068
 | **v1.0** | 首版:23 单元 + 28 接口 + 17 UI;覆盖 GL1~GL17 及状态机/检索/权限 |
1124
 | **v1.0** | 首版:23 单元 + 28 接口 + 17 UI;覆盖 GL1~GL17 及状态机/检索/权限 |
1069
 | **v1.1** | 同步代码 v1.0 补全已实现;O10 仍标待实现;统一平台/店铺分类口径 |
1125
 | **v1.1** | 同步代码 v1.0 补全已实现;O10 仍标待实现;统一平台/店铺分类口径 |
1070
 | **v1.2** | 新增 GL18 / UT-024、025:attrTemplateId 保存与校验 |
1126
 | **v1.2** | 新增 GL18 / UT-024、025:attrTemplateId 保存与校验 |
1127
+| **v1.3** | 新增 UT-026/027、API-029/030:biz_goods_attr 保存与预填 |
1071
 
1128
 
1072
 ---
1129
 ---
1073
 
1130
 
1074
-*文档版本:v1.2 · 关联《商品列表功能需求.md》v1.3、《店铺商品列表技术方案.md》v1.2*
1131
+*文档版本:v1.3 · 关联《商品列表功能需求.md》v1.4、《店铺商品列表技术方案.md》v1.4*

+ 2 - 1
doc/店铺后台/商品管理/属性模版/属性模版功能需求.md

@@ -541,7 +541,8 @@
541
 |------|------|
541
 |------|------|
542
 | **v1.0** | 首版定稿:对齐草稿 §1~§6 及原型;关联《关联需求分析》《店铺管理》《商品列表》及平台商品相关需求 |
542
 | **v1.0** | 首版定稿:对齐草稿 §1~§6 及原型;关联《关联需求分析》《店铺管理》《商品列表》及平台商品相关需求 |
543
 | **v1.1** | 对齐商品列表协作:发品保存 `attr_template_id` 已实现(见《店铺商品列表技术方案》v1.2) |
543
 | **v1.1** | 对齐商品列表协作:发品保存 `attr_template_id` 已实现(见《店铺商品列表技术方案》v1.2) |
544
+| **v1.2** | 对齐 `biz_goods_attr` 快照保存与预填流程(见《店铺商品列表技术方案》v1.4) |
544
 
545
 
545
 ---
546
 ---
546
 
547
 
547
-*文档版本:v1.1 · 关联《属性模版功能需求-草稿》· 平台侧见《关联需求分析.md》《商品分类功能需求.md》《商品服务管理功能需求.md》《商品审核功能需求.md》· 商家侧见《商品列表功能需求.md》v1.3、《店铺商品分类功能需求.md》*
548
+*文档版本:v1.2 · 商家侧见《商品列表功能需求.md》v1.4*

+ 12 - 8
doc/店铺后台/商品管理/属性模版/属性模版技术方案.md

@@ -1,7 +1,7 @@
1
 # 属性模版 — 技术方案
1
 # 属性模版 — 技术方案
2
 
2
 
3
 > **依据:** 《属性模版功能需求.md》v1.0  
3
 > **依据:** 《属性模版功能需求.md》v1.0  
4
-> **关联:** 《关联需求分析.md》v1.6;平台《商品服务管理技术方案》v1.0.1、《商品分类技术方案》v1.3、《商品审核技术方案》v1.1;商家《店铺商品列表技术方案》v1.3、《店铺商品分类技术方案》v1.2  
4
+> **关联:** 《关联需求分析.md》v1.6;平台《商品服务管理技术方案》v1.0.1、《商品分类技术方案》v1.3、《商品审核技术方案》v1.1;商家《店铺商品列表技术方案》v1.4、《店铺商品分类技术方案》v1.2  
5
 > **范围:** 商家端 **当前店铺** 属性模版 CRUD;为 **商品列表 · 属性模版** 字段提供下拉与详情预填;**平台不参与** 模版维护。  
5
 > **范围:** 商家端 **当前店铺** 属性模版 CRUD;为 **商品列表 · 属性模版** 字段提供下拉与详情预填;**平台不参与** 模版维护。  
6
 > **原则:** `shop_id` 店铺隔离;逻辑删除;批量删除 **整批失败**;编辑模版 **不 UPDATE** 已有商品属性/规格(快照原则)。
6
 > **原则:** `shop_id` 店铺隔离;逻辑删除;批量删除 **整批失败**;编辑模版 **不 UPDATE** 已有商品属性/规格(快照原则)。
7
 
7
 
@@ -40,7 +40,7 @@ biz_goods_attr_template(shop_id = 当前店铺)
40
40
41
 【商品列表】选用模版 → 预填表单 → 保存
41
 【商品列表】选用模版 → 预填表单 → 保存
42
         ├── biz_goods.attr_template_id(引用关系,删模版校验)
42
         ├── biz_goods.attr_template_id(引用关系,删模版校验)
43
-        └── biz_goods_attr(商品侧属性/规格快照,v1.x 落库
43
+        └── biz_goods_attr(商品侧属性/规格快照,**已实现**
44
44
45
 【平台审核/监管】读 biz_goods + biz_goods_attr,不读模版表
45
 【平台审核/监管】读 biz_goods + biz_goods_attr,不读模版表
46
 ```
46
 ```
@@ -102,6 +102,7 @@ sql/agri_seller_attr_template_menu.sql             # 菜单(待补)
102
 | 场景 | 商品模块代码 |
102
 | 场景 | 商品模块代码 |
103
 |------|--------------|
103
 |------|--------------|
104
 | 保存校验 `attrTemplateId` | `goods/service/impl/GoodsServiceImpl` 注入 `BizGoodsAttrTemplateMapper` |
104
 | 保存校验 `attrTemplateId` | `goods/service/impl/GoodsServiceImpl` 注入 `BizGoodsAttrTemplateMapper` |
105
+| 保存属性/规格快照 | `goods/mapper/BizGoodsAttrMapper` + `GoodsAttrSupport` + `saveGoodsAttrs` |
105
 | 删模版引用校验 | `category/facade/IGoodsFacade.existsByAttrTemplateId` ← `goods/facade/impl/GoodsFacadeImpl` |
106
 | 删模版引用校验 | `category/facade/IGoodsFacade.existsByAttrTemplateId` ← `goods/facade/impl/GoodsFacadeImpl` |
106
 
107
 
107
 ### 1.4 跨模块 Facade
108
 ### 1.4 跨模块 Facade
@@ -170,7 +171,7 @@ sql/agri_seller_attr_template_menu.sql             # 菜单(待补)
170
 | `biz_goods` | `attr_template_id` bigint **NULL** | 发品选用模版 ID;**删模版校验** |
171
 | `biz_goods` | `attr_template_id` bigint **NULL** | 发品选用模版 ID;**删模版校验** |
171
 | `biz_goods_attr` | 商品属性/规格 **快照** | 保存商品时写入;**不随模版编辑追溯** |
172
 | `biz_goods_attr` | 商品属性/规格 **快照** | 保存商品时写入;**不随模版编辑追溯** |
172
 
173
 
173
-**`biz_goods_attr`(v1.x 与发品完整表单同步落库):**
174
+**`biz_goods_attr`(商品列表协作 · 已实现):**
174
 
175
 
175
 | 字段 | 类型 | 说明 |
176
 | 字段 | 类型 | 说明 |
176
 |------|------|------|
177
 |------|------|------|
@@ -181,7 +182,7 @@ sql/agri_seller_attr_template_menu.sql             # 菜单(待补)
181
 | value_text | varchar(128) | 单值一行;多值多行 |
182
 | value_text | varchar(128) | 单值一行;多值多行 |
182
 | sort_no | int | |
183
 | sort_no | int | |
183
 
184
 
184
-> **v1.0 本模块:** 可先完成模版 CRUD + `attr_template_id` + Facade;`biz_goods_attr` 写入随商品列表 v1.x 一并实现
185
+> **说明:** 建表见 `sql/biz_goods_attr.sql`;写入逻辑在 **商品模块** `GoodsServiceImpl.saveGoodsAttrs`
185
 
186
 
186
 ### 2.6 索引
187
 ### 2.6 索引
187
 
188
 
@@ -390,13 +391,15 @@ INSERT 新 item/value(sort_no 按数组顺序 0,1,2…)
390
 
391
 
391
 商品模块亦可调用 `IAttrTemplateFacade.listOptionsByShopId(shopId)`;选用后 `getDetailForGoods(templateId, shopId)` 预填表单。
392
 商品模块亦可调用 `IAttrTemplateFacade.listOptionsByShopId(shopId)`;选用后 `getDetailForGoods(templateId, shopId)` 预填表单。
392
 
393
 
393
-**商品侧挂载(规划):**
394
+**商品侧挂载(已实现):**
394
 
395
 
395
 | 方法 | 路径 | 说明 |
396
 | 方法 | 路径 | 说明 |
396
 |------|------|------|
397
 |------|------|------|
397
 | GET | `/agri/seller/goods/attrTemplateOptions` | 委托 `IAttrTemplateFacade.listOptionsByShopId` |
398
 | GET | `/agri/seller/goods/attrTemplateOptions` | 委托 `IAttrTemplateFacade.listOptionsByShopId` |
398
 | GET | `/agri/seller/goods/attrTemplate/{templateId}` | 委托 `getDetailForGoods`(选用预填) |
399
 | GET | `/agri/seller/goods/attrTemplate/{templateId}` | 委托 `getDetailForGoods`(选用预填) |
399
 
400
 
401
+保存商品时在 `GoodsSaveDTO` 携带 `attributes` / `specs`,商品模块 **先删后插** `biz_goods_attr`。
402
+
400
 ---
403
 ---
401
 
404
 
402
 ## 4. 对外协作
405
 ## 4. 对外协作
@@ -407,7 +410,7 @@ INSERT 新 item/value(sort_no 按数组顺序 0,1,2…)
407
 |------|------|
410
 |------|------|
408
 | 发品下拉 | `GET /options` 或 Facade |
411
 | 发品下拉 | `GET /options` 或 Facade |
409
 | 选用预填 | `GET /{templateId}` / `getDetailForGoods` |
412
 | 选用预填 | `GET /{templateId}` / `getDetailForGoods` |
410
-| 保存商品 | 写 `biz_goods.attr_template_id`(**已实现**);**先删后插** `biz_goods_attr`(v1.x) |
413
+| 保存商品 | 写 `biz_goods.attr_template_id` + **先删后插** `biz_goods_attr`(**已实现**) |
411
 | 删模版校验 | `IGoodsFacade.existsByAttrTemplateId` |
414
 | 删模版校验 | `IGoodsFacade.existsByAttrTemplateId` |
412
 
415
 
413
 **`existsByAttrTemplateId` SQL:**
416
 **`existsByAttrTemplateId` SQL:**
@@ -543,7 +546,7 @@ buildNameList(templateId)      // 列表聚合列
543
 |------|------|------|
546
 |------|------|------|
544
 | **v1.0** | 模版三表 CRUD;`/options`;`IGoodsFacade.existsByAttrTemplateId`;菜单权限 | **已实现** |
547
 | **v1.0** | 模版三表 CRUD;`/options`;`IGoodsFacade.existsByAttrTemplateId`;菜单权限 | **已实现** |
545
 | **v1.0·商品协作** | 商品保存/详情 `attr_template_id`;`assertAttrTemplateIfPresent` | **已实现** |
548
 | **v1.0·商品协作** | 商品保存/详情 `attr_template_id`;`assertAttrTemplateIfPresent` | **已实现** |
546
-| **v1.x** | `biz_goods_attr` 写入;`/agri/seller/goods/attrTemplateOptions` 挂载 | 规划 |
549
+| **v1.0·属性快照** | `biz_goods_attr` 写入/回显;商品侧预填/下拉 API | **已实现** |
547
 | **非本期** | 模版导入导出、版本历史、平台代维、与分类自动绑定 | — |
550
 | **非本期** | 模版导入导出、版本历史、平台代维、与分类自动绑定 | — |
548
 
551
 
549
 ---
552
 ---
@@ -555,7 +558,8 @@ buildNameList(templateId)      // 列表聚合列
555
 | **v1.0** | 首版:三表模型;`/agri/seller/attrTemplate`;与商品列表/平台审核协作;对齐《属性模版功能需求》v1.0 |
558
 | **v1.0** | 首版:三表模型;`/agri/seller/attrTemplate`;与商品列表/平台审核协作;对齐《属性模版功能需求》v1.0 |
556
 | **v1.1** | 同步实现:模版 CRUD + 删引用校验;商品侧 `attr_template_id` 保存/回显已实现 |
559
 | **v1.1** | 同步实现:模版 CRUD + 删引用校验;商品侧 `attr_template_id` 保存/回显已实现 |
557
 | **v1.2** | §1.3 模块落位与代码对齐:包名 `template`、Mapper XML 路径、跨模块 Facade 说明 |
560
 | **v1.2** | §1.3 模块落位与代码对齐:包名 `template`、Mapper XML 路径、跨模块 Facade 说明 |
561
+| **v1.3** | 商品协作:`biz_goods_attr`、预填/下拉 API、保存体 attributes/specs |
558
 
562
 
559
 ---
563
 ---
560
 
564
 
561
-*文档版本:v1.2 · 关联《属性模版功能需求.md》v1.1 · 技术栈 RuoYi v3.9.2-springboot2 + MySQL 5.7.39*
565
+*文档版本:v1.3 · 关联《属性模版功能需求.md》v1.1、《店铺商品列表技术方案.md》v1.4*

+ 10 - 9
doc/店铺后台/商品管理/属性模版/属性模版测试用例.md

@@ -684,19 +684,19 @@
684
 | **测试步骤** | GET /list |
684
 | **测试步骤** | GET /list |
685
 | **预期结果** | HTTP 403 |
685
 | **预期结果** | HTTP 403 |
686
 
686
 
687
-### SAT-API-019 GET /goods/attrTemplateOptions 协作(v1.x
687
+### SAT-API-019 GET /goods/attrTemplateOptions 协作(已实现
688
 
688
 
689
 | 要素 | 内容 |
689
 | 要素 | 内容 |
690
 |------|------|
690
 |------|------|
691
 | **用例编号** | SAT-API-019 |
691
 | **用例编号** | SAT-API-019 |
692
 | **测试模块** | 属性模版 |
692
 | **测试模块** | 属性模版 |
693
-| **测试项** | 商品侧下拉 |
693
+| **测试项** | 商品侧下拉 + 预填 |
694
 | **测试类型** | 接口测试 |
694
 | **测试类型** | 接口测试 |
695
-| **测试工具** | Apifox |
696
-| **测试目的** | 验证技术方案 §3.6 商品挂载 |
697
-| **前置条件** | 商品模块已实现 `/agri/seller/goods/attrTemplateOptions` |
698
-| **测试步骤** | GET 该路径,X-Shop-Id=101 |
699
-| **预期结果** | 与 GET /attrTemplate/options **数据一致** |
695
+| **测试工具** | Apifox / MockMvc |
696
+| **测试目的** | 验证 AT9:商品发品下拉与预填 API |
697
+| **前置条件** | shop 101 有模版;`X-Shop-Id=101` |
698
+| **测试步骤** | GET `/agri/seller/goods/attrTemplateOptions`;GET `/agri/seller/goods/attrTemplate/4` |
699
+| **预期结果** | options 与 `/attrTemplate/options` 一致;预填含 attributes/specs |
700
 
700
 
701
 ---
701
 ---
702
 
702
 
@@ -968,8 +968,9 @@
968
 | 版本 | 说明 |
968
 | 版本 | 说明 |
969
 |------|------|
969
 |------|------|
970
 | **v1.0** | 首版:28 单元 + 19 接口 + 17 UI;覆盖 AT1~AT12 及正常/异常/协作流程 |
970
 | **v1.0** | 首版:28 单元 + 19 接口 + 17 UI;覆盖 AT1~AT12 及正常/异常/协作流程 |
971
-| **v1.1** | 商品协作:`attr_template_id` 保存/回显已实现;`biz_goods_attr` 仍标 v1.x |
971
+| **v1.1** | 商品协作:`attr_template_id` 保存/回显已实现 |
972
+| **v1.2** | `biz_goods_attr` 与商品侧预填 API 已实现;SAT-API-019 标已实现 |
972
 
973
 
973
 ---
974
 ---
974
 
975
 
975
-*文档版本:v1.1 · 关联《属性模版功能需求.md》v1.1、《属性模版技术方案.md》v1.1*
976
+*文档版本:v1.2 · 关联《属性模版功能需求.md》v1.1、《属性模版技术方案.md》v1.3*

+ 12 - 0
sql/biz_goods.sql

@@ -44,3 +44,15 @@ CREATE TABLE IF NOT EXISTS `biz_goods_service_snapshot` (
44
   PRIMARY KEY (`snapshot_id`),
44
   PRIMARY KEY (`snapshot_id`),
45
   KEY `idx_goods_id` (`goods_id`)
45
   KEY `idx_goods_id` (`goods_id`)
46
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品服务展示快照';
46
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品服务展示快照';
47
+
48
+-- 商品属性/规格快照
49
+CREATE TABLE IF NOT EXISTS `biz_goods_attr` (
50
+  `attr_id`    bigint(20)  NOT NULL AUTO_INCREMENT,
51
+  `goods_id`   bigint(20)  NOT NULL COMMENT '商品ID',
52
+  `attr_type`  char(1)     NOT NULL COMMENT '1属性2规格',
53
+  `item_name`  varchar(64) NOT NULL COMMENT '属性项/规格项名称',
54
+  `value_text` varchar(128) NOT NULL COMMENT '属性值/规格值',
55
+  `sort_no`    int(11)     NOT NULL DEFAULT '0' COMMENT '展示顺序',
56
+  PRIMARY KEY (`attr_id`),
57
+  KEY `idx_goods_id` (`goods_id`,`attr_type`,`sort_no`)
58
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品属性规格快照';

+ 11 - 0
sql/biz_goods_attr.sql

@@ -0,0 +1,11 @@
1
+-- 商品属性/规格快照(发品保存;不随属性模版编辑追溯)
2
+CREATE TABLE IF NOT EXISTS `biz_goods_attr` (
3
+  `attr_id`    bigint(20)  NOT NULL AUTO_INCREMENT,
4
+  `goods_id`   bigint(20)  NOT NULL COMMENT '商品ID',
5
+  `attr_type`  char(1)     NOT NULL COMMENT '1属性2规格',
6
+  `item_name`  varchar(64) NOT NULL COMMENT '属性项/规格项名称',
7
+  `value_text` varchar(128) NOT NULL COMMENT '属性值/规格值',
8
+  `sort_no`    int(11)     NOT NULL DEFAULT '0' COMMENT '展示顺序',
9
+  PRIMARY KEY (`attr_id`),
10
+  KEY `idx_goods_id` (`goods_id`,`attr_type`,`sort_no`)
11
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品属性规格快照';