# 属性模版 — 技术方案

> **依据：** 《属性模版功能需求.md》v1.0  
> **关联：** 《关联需求分析.md》v1.6；平台《商品服务管理技术方案》v1.0.1、《商品分类技术方案》v1.3、《商品审核技术方案》v1.1；商家《店铺商品列表技术方案》v1.4、《店铺商品分类技术方案》v1.2  
> **范围：** 商家端 **当前店铺** 属性模版 CRUD；为 **商品列表 · 属性模版** 字段提供下拉与详情预填；**平台不参与** 模版维护。  
> **原则：** `shop_id` 店铺隔离；逻辑删除；批量删除 **整批失败**；编辑模版 **不 UPDATE** 已有商品属性/规格（快照原则）。

---

## 1. 技术架构

| 项 | 选型 |
|----|------|
| 基础框架 | RuoYi **v3.9.2**（`springboot2` 分支） |
| 数据库 | **MySQL 5.7.39** |
| ORM / 权限 / 响应 | MyBatis；`@PreAuthorize`；`AjaxResult` / `TableDataInfo` |
| 店铺上下文 | `SellerShopContextInterceptor` + **`X-Shop-Id`**；`SellerShopContext.getShopId()` |
| 删店拦截 | `IShopFacade.assertShopMaintable(shopId)` |
| 日志 | 增删改 `@Log` |

### 1.1 与平台侧边界

| 维度 | 平台 · 商品管理/审核 | 本模块 · 属性模版 |
|------|----------------------|-------------------|
| 维护端 | **无** 模版 CRUD | **仅** 商家端 |
| 数据范围 | — | `shop_id = 当前店铺` |
| 商品展示 | 读 **商品档案** 属性/规格 | **不** 被平台直接读取 |
| 路径 | `/agri/goods`、`/agri/goodsAudit` | **`/agri/seller/attrTemplate`** |

> **对齐商品服务：** 模版 = 店铺主数据；商品保存时 **拷贝** 属性/规格到商品侧表，并记录 `attr_template_id` 供删模版校验（类比 `biz_goods_service` → `biz_goods_service_snapshot`）。

### 1.2 业务链

```text
GET /agri/seller/context          → 前端设置 X-Shop-Id
        ↓
biz_goods_attr_template（shop_id = 当前店铺）
        ├── biz_goods_attr_template_item（item_type：1 属性项 / 2 规格项）
        └── biz_goods_attr_template_value
        ↓
【商品列表】选用模版 → 预填表单 → 保存
        ├── biz_goods.attr_template_id（引用关系，删模版校验）
        └── biz_goods_attr（商品侧属性/规格快照，**已实现**）
        ↓
【平台审核/监管】读 biz_goods + biz_goods_attr，不读模版表
```

| 关联模块 | 协作 |
|----------|------|
| **店铺管理** | 上游 `shop_id`；删店后 `assertShopMaintable` 阻断 |
| **店铺商品分类** | **并列**；无表关联 |
| **商品服务** | **并列**；发品可同时选服务 + 选模版 |
| **商品列表** | 下拉 + 详情预填；保存写 `attr_template_id` 与 `biz_goods_attr` |
| **商品审核（平台）** | 只读商品详情，**无** 本模块接口 |

### 1.3 模块落位

> **包名说明：** Java 包为 **`template`**（非 `attrtemplate`），与 URL 路径 `/attrTemplate` 区分。

**本模块包：** `com.ruoyi.web.modules.template`

```text
baqing-shop/src/main/java/com/ruoyi/web/modules/template/
├── controller/SellerAttrTemplateController.java    # /agri/seller/attrTemplate
├── service/
│   ├── IAttrTemplateService.java
│   └── impl/AttrTemplateServiceImpl.java
├── domain/
│   ├── BizGoodsAttrTemplate.java
│   ├── BizGoodsAttrTemplateItem.java
│   └── BizGoodsAttrTemplateValue.java
├── dto/
│   ├── AttrTemplateSaveDTO.java
│   └── AttrTemplateItemDTO.java
├── vo/
│   ├── AttrTemplateListVO.java
│   ├── AttrTemplateDetailVO.java
│   └── AttrTemplateOptionVO.java
├── mapper/
│   ├── BizGoodsAttrTemplateMapper.java
│   ├── BizGoodsAttrTemplateItemMapper.java
│   └── BizGoodsAttrTemplateValueMapper.java
├── facade/
│   ├── IAttrTemplateFacade.java
│   └── impl/AttrTemplateFacadeImpl.java
├── exception/AttrTemplateBatchDeleteException.java
└── constant/AttrTemplateConstants.java

baqing-shop/src/main/resources/mapper/template/
├── BizGoodsAttrTemplateMapper.xml
├── BizGoodsAttrTemplateItemMapper.xml
└── BizGoodsAttrTemplateValueMapper.xml

sql/biz_goods_attr_template.sql                  # 模版三表 + biz_goods.attr_template_id 扩展
sql/agri_seller_attr_template_menu.sql             # 菜单（待补）
```

**前端页面：** `agri/seller/attrTemplate/index`（列表 + 添加/编辑弹窗，对齐原型）。

**跨模块协作（商品侧）：**

| 场景 | 商品模块代码 |
|------|--------------|
| 保存校验 `attrTemplateId` | `goods/service/impl/GoodsServiceImpl` 注入 `BizGoodsAttrTemplateMapper` |
| 保存属性/规格快照 | `goods/mapper/BizGoodsAttrMapper` + `GoodsAttrSupport` + `saveGoodsAttrs` |
| 删模版引用校验 | `category/facade/IGoodsFacade.existsByAttrTemplateId` ← `goods/facade/impl/GoodsFacadeImpl` |

### 1.4 跨模块 Facade

| 接口 | 提供方 | 调用方 | 方法 |
|------|--------|--------|------|
| **`IAttrTemplateFacade`** | **本模块** | 商品 | `listOptionsByShopId(shopId)` — 发品下拉 |
| | | | `getDetailForGoods(templateId, shopId)` — 选用后预填 |
| **`IGoodsFacade`** | 商品（`category/facade/IGoodsFacade` + `goods/facade/impl/GoodsFacadeImpl`） | **本模块** | `existsByAttrTemplateId(templateId)` — 删模版前置 |
| **`IShopFacade`** | 店铺 | 本模块 | `assertShopMaintable` / `getShopName` |

---

## 2. 数据库设计

### 2.1 本模块表

| 表名 | 说明 |
|------|------|
| `biz_goods_attr_template` | 属性模版主表 |
| `biz_goods_attr_template_item` | 属性项 / 规格项 |
| `biz_goods_attr_template_value` | 项下可选值 |

### 2.2 `biz_goods_attr_template`

| 字段 | 类型 | 说明 |
|------|------|------|
| template_id | bigint PK | 模版 ID |
| shop_id | bigint NOT NULL | **当前店铺 ID** |
| template_name | varchar(64) NOT NULL | 模版名称；本店 `del_flag=0` 唯一 |
| del_flag | char(1) | 0 存在 **2** 删除 |
| create_by / create_time / update_by / update_time | | RuoYi 审计 |

### 2.3 `biz_goods_attr_template_item`

| 字段 | 类型 | 说明 |
|------|------|------|
| item_id | bigint PK | |
| template_id | bigint NOT NULL | FK → 模版 |
| item_type | char(1) NOT NULL | **1** 属性项 **2** 规格项 |
| item_name | varchar(64) NOT NULL | 项名称 |
| sort_no | int NOT NULL DEFAULT 0 | 同模版、同 type 内排序，越小越靠前 |
| del_flag | char(1) | 0 存在 2 删除（编辑时 **物理删** 旧行后重插，简化实现） |

**约束（应用层）：**

- 同一 `template_id` 下，`item_type=1` 的 `item_name` 互不重复；
- 同一 `template_id` 下，`item_type=2` 的 `item_name` 互不重复；
- **属性项名与规格项名不可相同**（跨 type 唯一）。

### 2.4 `biz_goods_attr_template_value`

| 字段 | 类型 | 说明 |
|------|------|------|
| value_id | bigint PK | |
| item_id | bigint NOT NULL | FK → item |
| value_text | varchar(128) NOT NULL | 属性值 / 规格值 |
| sort_no | int NOT NULL DEFAULT 0 | 同 item 内排序 |

**约束：** 同一 `item_id` 下 `value_text` **不可重复**；每项 **至少一条** 值。

### 2.5 商品侧扩展（商品列表协作）

| 表 | 字段 / 用途 | 说明 |
|----|-------------|------|
| `biz_goods` | `attr_template_id` bigint **NULL** | 发品选用模版 ID；**删模版校验** |
| `biz_goods_attr` | 商品属性/规格 **快照** | 保存商品时写入；**不随模版编辑追溯** |

**`biz_goods_attr`（商品列表协作 · 已实现）：**

| 字段 | 类型 | 说明 |
|------|------|------|
| attr_id | bigint PK | |
| goods_id | bigint NOT NULL | |
| attr_type | char(1) | **1** 属性 **2** 规格（与模版 item_type 一致） |
| item_name | varchar(64) | |
| value_text | varchar(128) | 单值一行；多值多行 |
| sort_no | int | |

> **说明：** 建表见 `sql/biz_goods_attr.sql`；写入逻辑在 **商品模块** `GoodsServiceImpl.saveGoodsAttrs`。

### 2.6 索引

| 表 | 索引 | 字段 |
|----|------|------|
| template | PRIMARY | template_id |
| template | idx_shop_del | shop_id, del_flag, create_time |
| item | idx_template_type | template_id, item_type, sort_no |
| value | idx_item_sort | item_id, sort_no |
| goods | idx_attr_template | attr_template_id, del_flag |

`template_name` 唯一：**应用层** `(shop_id, template_name)` WHERE `del_flag='0'`。

### 2.7 DDL

```sql
-- sql/biz_goods_attr_template.sql
CREATE TABLE IF NOT EXISTS `biz_goods_attr_template` (
  `template_id`   bigint(20)   NOT NULL AUTO_INCREMENT COMMENT '模版ID',
  `shop_id`       bigint(20)   NOT NULL COMMENT '店铺ID',
  `template_name` varchar(64)  NOT NULL COMMENT '属性模版名称',
  `del_flag`      char(1)      NOT NULL DEFAULT '0' COMMENT '0存在2删除',
  `create_by`     varchar(64)  DEFAULT '',
  `create_time`   datetime     DEFAULT NULL,
  `update_by`     varchar(64)  DEFAULT '',
  `update_time`   datetime     DEFAULT NULL,
  PRIMARY KEY (`template_id`),
  KEY `idx_shop_del` (`shop_id`,`del_flag`,`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品属性模版';

CREATE TABLE IF NOT EXISTS `biz_goods_attr_template_item` (
  `item_id`     bigint(20)  NOT NULL AUTO_INCREMENT,
  `template_id` bigint(20)  NOT NULL COMMENT '模版ID',
  `item_type`   char(1)     NOT NULL COMMENT '1属性项2规格项',
  `item_name`   varchar(64) NOT NULL COMMENT '项名称',
  `sort_no`     int(11)     NOT NULL DEFAULT '0',
  `del_flag`    char(1)     NOT NULL DEFAULT '0',
  PRIMARY KEY (`item_id`),
  KEY `idx_template_type` (`template_id`,`item_type`,`sort_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='属性模版项';

CREATE TABLE IF NOT EXISTS `biz_goods_attr_template_value` (
  `value_id`   bigint(20)   NOT NULL AUTO_INCREMENT,
  `item_id`    bigint(20)   NOT NULL COMMENT '模版项ID',
  `value_text` varchar(128) NOT NULL COMMENT '属性值/规格值',
  `sort_no`    int(11)      NOT NULL DEFAULT '0',
  PRIMARY KEY (`value_id`),
  KEY `idx_item_sort` (`item_id`,`sort_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='属性模版值';
```

```sql
-- sql/biz_goods_attr_template_goods.sql
ALTER TABLE `biz_goods`
  ADD COLUMN `attr_template_id` bigint(20) DEFAULT NULL COMMENT '属性模版ID' AFTER `shop_category_id`,
  ADD KEY `idx_attr_template_id` (`attr_template_id`,`del_flag`);

CREATE TABLE IF NOT EXISTS `biz_goods_attr` (
  `attr_id`    bigint(20)  NOT NULL AUTO_INCREMENT,
  `goods_id`   bigint(20)  NOT NULL COMMENT '商品ID',
  `attr_type`  char(1)     NOT NULL COMMENT '1属性2规格',
  `item_name`  varchar(64) NOT NULL,
  `value_text` varchar(128) NOT NULL,
  `sort_no`    int(11)     NOT NULL DEFAULT '0',
  PRIMARY KEY (`attr_id`),
  KEY `idx_goods_id` (`goods_id`,`attr_type`,`sort_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品属性规格快照';
```

### 2.8 字典

| dict_type | 说明 |
|-----------|------|
| attr_template_item_type | 1 属性项 / 2 规格项 |

---

## 3. 接口设计

**基路径：** `/agri/seller/attrTemplate`  
**公共请求头：** `X-Shop-Id`（必填；未携带 →「请先选择当前店铺」）  
**数据范围：** `shop_id = 当前店铺` AND `del_flag='0'`。

### 3.1 接口一览

| 方法 | 路径 | 权限 | 说明 |
|------|------|------|------|
| GET | `/list` | list | **分页**列表（`TableDataInfo`） |
| GET | `/{templateId}` | query | 详情（编辑回显） |
| POST | `/` | add | 新增 |
| PUT | `/` | edit | 编辑（**全量替换** 项与值） |
| DELETE | `/{templateIds}` | remove | 删除（**支持批量**；整批失败） |
| GET | `/options` | list | 发品下拉（不分页） |

**列表查询参数：** `pageNum`、`pageSize`、`keyword`（模版名称模糊，可选）。

### 3.2 列表 `GET /list`

**排序：** `create_time DESC`（对齐需求默认最新在前）。  
**名称列表：** Service 聚合 — 按 `item_type ASC, sort_no ASC` 取全部 `item_name`，逗号拼接。

**响应示例：**

```json
{
  "code": 200,
  "msg": "查询成功",
  "total": 2,
  "rows": [
    {
      "templateId": 1,
      "templateName": "宠物",
      "nameList": "类型,品种"
    },
    {
      "templateId": 2,
      "templateName": "手机",
      "nameList": "屏幕,生产日期,品牌,颜色,内存,延保"
    }
  ]
}
```

### 3.3 详情 `GET /{templateId}`

**校验：** `templateId` 须属于当前店铺。

```json
{
  "code": 200,
  "data": {
    "templateId": 2,
    "templateName": "手机",
    "attributes": [
      { "itemName": "屏幕", "values": ["2800x1260"] },
      { "itemName": "生产日期", "values": ["2026.1.06-2026.12.31"] },
      { "itemName": "品牌", "values": ["小米", "华为", "VIVO", "OPPO", "苹果"] }
    ],
    "specs": [
      { "itemName": "颜色", "values": ["红色", "白色", "绿色", "灰色"] },
      { "itemName": "内存", "values": ["128G", "256G"] }
    ]
  }
}
```

### 3.4 新增 / 编辑 `POST /`、`PUT /`

**请求体：**

```json
{
  "templateId": 2,
  "templateName": "手机",
  "attributes": [
    { "itemName": "品牌", "values": ["小米", "华为"] }
  ],
  "specs": [
    { "itemName": "颜色", "values": ["红色", "白色"] }
  ]
}
```

| 校验 | 说明 |
|------|------|
| templateName | 非空，≤64；本店唯一（编辑排除自身） |
| attributes + specs | **至少一段非空**（AT-A2） |
| itemName | 非空；段内不重名；**属性与规格段之间不重名** |
| values | 每项 **≥1** 个非空值；同项内 **不重复** |
| templateId | PUT 必填；须属于当前店铺 |

**持久化策略（PUT）：**

```text
UPDATE biz_goods_attr_template（名称）
DELETE 旧 item/value（物理删）
INSERT 新 item/value（sort_no 按数组顺序 0,1,2…）
```

> **不 UPDATE** `biz_goods` / `biz_goods_attr`（AT8）。

### 3.5 删除 `DELETE /{templateIds}`

```text
校验 templateIds 均属于当前店铺
    → 对每个 ID 调用 IGoodsFacade.existsByAttrTemplateId
    ├── 任一有商品 → AttrTemplateBatchDeleteException；整批取消；data.reasons[]
    └── 全无引用 → logicDelete template（item/value 可保留或级联物理删）
```

- **逻辑删除** 模版主表；支持 **批量**；**任一条不通过则整批失败**。

### 3.6 下拉 `GET /options`

供 **商品列表 · 属性模版** 字段；**不分页**。

```json
{
  "code": 200,
  "data": [
    { "templateId": 1, "templateName": "宠物", "nameList": "类型,品种" },
    { "templateId": 2, "templateName": "手机", "nameList": "屏幕,生产日期,品牌,颜色,内存,延保" }
  ]
}
```

商品模块亦可调用 `IAttrTemplateFacade.listOptionsByShopId(shopId)`；选用后 `getDetailForGoods(templateId, shopId)` 预填表单。

**商品侧挂载（已实现）：**

| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/agri/seller/goods/attrTemplateOptions` | 委托 `IAttrTemplateFacade.listOptionsByShopId` |
| GET | `/agri/seller/goods/attrTemplate/{templateId}` | 委托 `getDetailForGoods`（选用预填） |

保存商品时在 `GoodsSaveDTO` 携带 `attributes` / `specs`，商品模块 **先删后插** `biz_goods_attr`。

---

## 4. 对外协作

### 4.1 商品模块（下游）

| 场景 | 实现 |
|------|------|
| 发品下拉 | `GET /options` 或 Facade |
| 选用预填 | `GET /{templateId}` / `getDetailForGoods` |
| 保存商品 | 写 `biz_goods.attr_template_id` + **先删后插** `biz_goods_attr`（**已实现**） |
| 删模版校验 | `IGoodsFacade.existsByAttrTemplateId` |

**`existsByAttrTemplateId` SQL：**

```sql
SELECT 1 FROM biz_goods g
WHERE g.attr_template_id = #{templateId}
  AND g.del_flag = '0'
LIMIT 1
```

### 4.2 与商品服务快照对照

| 维度 | 商品服务 | 属性模版 |
|------|----------|----------|
| 主数据 | `biz_goods_service`（平台） | `biz_goods_attr_template*`（店铺） |
| 商品引用字段 | 快照表 `service_id` | `biz_goods.attr_template_id` |
| 展示数据 | `biz_goods_service_snapshot` | `biz_goods_attr` |
| 主数据变更 | 不追溯快照 | 不追溯商品 attr 行 |

### 4.3 平台侧

- **无** 平台 CRUD / 列表接口。  
- **商品审核** 详情 join `biz_goods_attr`（或 JSON 字段），**不** join 模版表。  
- 平台 **不可** 代商家维护模版（AT2）。

### 4.4 C 端

- 读 **商品** 已保存 `biz_goods_attr`；**不** 读模版表。  
- 模版变更 **不自动** 改商品上下架（AT10）。

---

## 5. 核心流程

### 5.1 Service 伪代码

```text
requireShopId()
assertShopMaintable(shopId)
validateSaveDto()              // AT-A1～A5；跨段 itemName 唯一
countByName(shopId, name)      // 本店唯一
insertTemplate + insertItems   // 事务
updateTemplate                 // 全量 replace items/values
existsByAttrTemplateId()       // 删前
logicDeleteByIds()             // 批量整批失败
buildNameList(templateId)      // 列表聚合列
```

### 5.2 端到端

```text
【平台】店铺管理 ── 开店（模版初始为空）
            ↓
【商家】X-Shop-Id → POST /attrTemplate ── 维护模版
            ↓
【商家】商品列表 ── 选模版 → 预填 → 可增删 → 保存 attr_template_id + biz_goods_attr
            ↓
【平台】商品审核 ── 读商品 attr，不读模版
```

---

## 6. 业务规则映射

| 编号 | 实现 |
|------|------|
| AT1 | SQL `shop_id = X-Shop-Id` AND `del_flag='0'` |
| AT2 | 无平台写接口 |
| AT3 | `(shop_id, template_name)` 唯一校验 |
| AT4 | item_type 1/2；保存时 attributes∪specs 非空 |
| AT5 | 项名/值重复校验；每项至少一值 |
| AT6 | `buildNameList`：type 1 在前，同 type 按 sort_no |
| AT7 | `existsByAttrTemplateId` + 批量整批失败 |
| AT8 | 无 UPDATE biz_goods_attr |
| AT9 | Facade + 商品侧 options/detail |
| AT10 | 无 UPDATE biz_goods.goods_status |
| AT11 | 拦截器 + Service 双重 shop_id 校验 |
| AT12 | 主表 logicDelete |

---

## 7. 菜单权限

| 权限 | 标识 |
|------|------|
| 列表 / 下拉 | `agri:seller:attrTemplate:list` |
| 详情 | `agri:seller:attrTemplate:query` |
| 新增 | `agri:seller:attrTemplate:add` |
| 修改 | `agri:seller:attrTemplate:edit` |
| 删除 | `agri:seller:attrTemplate:remove` |

**菜单 SQL：** `sql/agri_seller_attr_template_menu.sql`  
**组件路径：** `agri/seller/attrTemplate/index`  
**上级菜单：** 店铺后台 · 商品管理（与店铺商品分类、商品列表并列）。

---

## 8. 校验与错误 msg

| 场景 | msg |
|------|-----|
| 未选店铺 | 请先选择当前店铺 |
| 模版名称为空 | 请输入属性模版名称 |
| 模版名称重复 | 属性模版名称已存在 |
| 无属性项且无规格项 | 请至少添加一个属性项或规格项 |
| 项名称为空 | 请输入属性项/规格项名称 |
| 项名称重复 | 属性项/规格项名称不可重复 |
| 属性与规格重名 | 属性项与规格项名称不可相同 |
| 值为空 | 请为每项至少添加一个属性值/规格值 |
| 值重复 | 同一项下属性值/规格值不可重复 |
| 删除有引用 | 该属性模版已被商品使用，无法删除 |
| 批量删失败 | 部分属性模版不满足删除条件，整批操作已取消 |
| 跨店 ID | 属性模版不存在 |
| 店铺已关闭 | 店铺已关闭，无法维护属性模版 |

---

## 9. 测试要点

| 类型 | 覆盖 |
|------|------|
| 单元 | `AttrTemplateServiceImpl`：shop scope；名称唯一；项/值校验；跨段重名；批量删整批失败 |
| 接口 | `/list`、`/{id}`、POST/PUT、DELETE 批量、`/options` |
| 协作 | `existsByAttrTemplateId`；商品 options 预填 |
| UI | 列表名称列表；属性/规格动态增删；批量删除 |

---

## 10. 实现分期

| 阶段 | 内容 | 状态 |
|------|------|------|
| **v1.0** | 模版三表 CRUD；`/options`；`IGoodsFacade.existsByAttrTemplateId`；菜单权限 | **已实现** |
| **v1.0·商品协作** | 商品保存/详情 `attr_template_id`；`assertAttrTemplateIfPresent` | **已实现** |
| **v1.0·属性快照** | `biz_goods_attr` 写入/回显；商品侧预填/下拉 API | **已实现** |
| **非本期** | 模版导入导出、版本历史、平台代维、与分类自动绑定 | — |

---

## 11. 修订记录

| 版本 | 说明 |
|------|------|
| **v1.0** | 首版：三表模型；`/agri/seller/attrTemplate`；与商品列表/平台审核协作；对齐《属性模版功能需求》v1.0 |
| **v1.1** | 同步实现：模版 CRUD + 删引用校验；商品侧 `attr_template_id` 保存/回显已实现 |
| **v1.2** | §1.3 模块落位与代码对齐：包名 `template`、Mapper XML 路径、跨模块 Facade 说明 |
| **v1.3** | 商品协作：`biz_goods_attr`、预填/下拉 API、保存体 attributes/specs |

---

*文档版本：v1.3 · 关联《属性模版功能需求.md》v1.1、《店铺商品列表技术方案.md》v1.4*
