# 商品分类 — 技术方案

> **依据：** 《商品分类功能需求.md》v1.5  
> **关联：** 《商户管理》《店铺管理》《商品管理》功能需求 v1.3.1 / v1.3.2 / v1.3.3；对应技术方案 v1.3 / v1.2.1 / v1.2  
> **范围：** **平台端 + 商家端** 分类 CRUD；对商品/C 端提供 **Facade 与协作接口**。  
> **原则：** **仅两级**（一级 + 二级）；`shop_id` 为 **NULL** 表示平台分类、非空表示店铺分类；发品 **category_id** 挂 **平台二级**、**shop_category_id** 可选挂 **本店二级**；商家端复用 **当前店铺** 上下文（`X-Shop-Id`）。

---

## 1. 技术架构

| 项 | 选型 |
|----|------|
| 基础框架 | RuoYi **v3.9.2**（`springboot2` 分支） |
| 数据库 | **MySQL 5.7.39** |
| ORM / 权限 | MyBatis；`@PreAuthorize`；`AjaxResult` / `TableDataInfo` |
| 店铺上下文 | 复用 `SellerShopContextInterceptor`（`X-Shop-Id` / Session） |
| 文件 | 分类图片走 `CommonController` 上传 |

### 1.1 模块落位

```text
baqing-shop/src/main/java/com/ruoyi/web/modules/category/
├── controller/PlatformCategoryController.java           # /agri/category（平台 CRUD）
├── controller/SellerShopGoodsCategoryController.java    # /agri/seller/shopCategory（店铺 CRUD）
├── controller/SellerPlatformCategoryController.java     # /agri/seller/category（平台分类只读）
├── service/IPlatformCategoryService、IShopGoodsCategoryService
├── service/impl/CategoryServiceImpl.java
└── facade/CategoryFacadeImpl、ShopGoodsCategoryFacadeImpl
```

**前端页面：**

| 端 | 组件路径 |
|----|----------|
| 平台 | `agri/category/index` |
| 商家 · 店铺商品分类 | `agri/seller/shopCategory/index` |

### 1.2 业务链

```text
biz_goods_category（shop_id 可空：NULL=平台分类；非空=店铺分类）
    ├── 平台分类树（shop_id IS NULL）
    │       └── biz_goods.category_id（发品「商品分类」，平台二级）
    └── biz_shop
            └── 店铺商品分类树（shop_id = 店铺ID）
                    └── biz_goods.shop_category_id（发品「店铺商品分类」，本店二级）
```

| 模块 | 关系 |
|------|------|
| 店铺管理 | 上游；`shop_id`；删店后本店分类 **不可再维护** |
| 商品管理 | 下游；`IGoodsFacade.existsByCategoryId` 删类前置；发品下拉 **仅二级** |
| 商户管理 | 无直接操作 |
| C 端 | `ICategoryFacade` 读 `show_flag` |

### 1.3 跨模块 Facade

| 接口 | 提供方 | 调用方 | 方法 |
|------|--------|--------|------|
| **`ICategoryFacade`** | **分类** | 商品、C端 | `isCategoryVisible(categoryId)` |
| | | | `listPlatformLevel2Options()` — 平台二级下拉 |
| | | | `listLevel2ByShopId(shopId)` — **店铺**二级下拉（`shop_id` 非空） |
| | | | `getCategoryPath(categoryId)` → `一级 > 二级` |
| | | | `listVisibleByShopId(shopId)` — C 端导航（show_flag=1） |
| **`IPlatformCategoryService`** | **分类** | 商品 | `selectPlatformLevel2Options()` — 发品「商品分类」 |
| **`IShopGoodsCategoryFacade`** | **店铺商品分类** | 商品 | `listOptionsByShopId(shopId, visibleOnly)` |
| **`IGoodsFacade`** | 商品 | **分类** | `existsByCategoryId(categoryId)` |
| **`IShopFacade`** | 店铺 | 分类 | 校验店铺未逻辑删除 |

### 1.4 状态联动

| 事件 | 分类数据 | 商品状态 |
|------|----------|----------|
| 改 show_flag/hot_flag/排序/名称 | 更新本表 | **不变**（G8） |
| 店铺停业 | 商家 **仍可** 维护分类 | 不变；C 端禁单由店铺 Facade |
| 店铺逻辑删除 | 接口阻断；数据 **保留** | 按商品/删店规则 |
| 删一级（级联二级） | 逻辑删除子树 | 不自动改商品 |

---

## 2. 数据库设计

### 2.1 本模块表

| 表名 | 说明 |
|------|------|
| `biz_goods_category` | 商品分类（一级 + 二级同表；平台与店铺共用） |

### 2.2 字段 `biz_goods_category`

| 字段 | 类型 | 说明 |
|------|------|------|
| category_id | bigint PK | |
| shop_id | bigint，**可空** | **NULL=平台分类**；非空=店铺分类，数据隔离维度 |
| parent_id | bigint | **0=一级**；二级=父级 `category_id` |
| category_level | char(1) | **1** 一级 **2** 二级（冗余，便于校验） |
| category_name | varchar(64) | 同 **数据范围**（`shop_id` 相同，含 NULL）、同 `parent_id` 下唯一（del_flag=0） |
| category_pic | varchar(512) | 分类图 URL |
| sort_no | int | 同层排序，越小越靠前；默认 0 |
| show_flag | char(1) | 0 否 1 是；默认 **1** |
| hot_flag | char(1) | 0 否 1 是；默认 **0** |
| del_flag | char(1) | 0 存在 2 删除 |
| create_by/time, update_by/time | | |

**层级规则（C1～C3）：**

| category_level | parent_id |
|:--------------:|-----------|
| 1 | 0 |
| 2 | 本 **数据范围** 内某一级 `category_id`（且该父节点 `category_level=1`） |

**禁止：** `parent_id` 指向二级节点（即三级）。

### 2.3 索引

| 索引 | 字段 |
|------|------|
| idx_shop_parent | shop_id, parent_id, del_flag |
| idx_shop_level | shop_id, category_level, del_flag, sort_no |

名称唯一：**应用层**校验 `(shop_id, parent_id, category_name)` where `del_flag=0`（`shop_id` 为 NULL 时用 `IS NULL` 匹配平台范围）。

### 2.4 DDL

```sql
CREATE TABLE `biz_goods_category` (
  `category_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '分类ID',
  `shop_id` bigint(20) DEFAULT NULL COMMENT '店铺ID；NULL=平台分类',
  `parent_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '0为一级',
  ...
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分类';
```

**存量库迁移：** `sql/biz_goods_category_shop_id_nullable.sql`（`shop_id` 改为可空）。

### 2.5 字典

| dict_type | 值 |
|-----------|-----|
| category_level | 1 一级 / 2 二级 |
| yes_no | show_flag、hot_flag |

---

## 3. 接口设计（商家端 · 店铺商品分类）

> **路径：** `/agri/seller/shopCategory`（**非** `/agri/seller/category`）  
> **权限：** `agri:seller:shopCategory:list|add|edit|remove`  
> **数据范围：** `shop_id = 当前店铺`（请求头 `X-Shop-Id`）；店铺已逻辑删除则拦截。

### 3.0 商家端 · 平台分类只读

| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/agri/seller/category/platformLevel2Options` | 平台二级下拉（发品「商品分类」；与商品模块 `/agri/seller/goods/categoryOptions` 同源） |

### 3.1 接口一览（店铺商品分类 CRUD）

| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/scope` | 当前店铺 `shopId`、`shopName`（列表分页不携带） |
| GET | `/list` | **分页**扁平列表（`TableDataInfo`；见 3.2） |
| GET | `/tree` | 树形 `children` 嵌套（下拉等；不分页） |
| GET | `/level1Options` | 一级下拉（添加二级时用） |
| GET | `/level2Options` | **仅二级** 下拉（商品发品复用） |
| POST | `/` | 新增一级或二级 |
| PUT | `/` | 编辑 |
| DELETE | `/{categoryIds}` | 删除（批量；整批失败） |

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

### 3.2 列表 `GET /list`

**响应（RuoYi `TableDataInfo`）：**

```json
{
  "code": 200,
  "msg": "查询成功",
  "total": 25,
  "rows": [
    {
      "categoryId": 1,
      "categoryName": "肥料",
      "categoryLevel": "1",
      "parentId": 0,
      "parentName": null,
      "categoryPic": "/profile/upload/c1.jpg",
      "showFlag": "1",
      "hotFlag": "0",
      "sortNo": 0,
      "canAddChild": true
    },
    {
      "categoryId": 10,
      "categoryName": "复合肥",
      "categoryLevel": "2",
      "parentId": 1,
      "parentName": "肥料",
      "showFlag": "1",
      "hotFlag": "1",
      "sortNo": 0,
      "canAddChild": false
    }
  ]
}
```

排序：先一级 `sort_no ASC, create_time ASC`；其下二级同样规则。  
**过滤：** 仅 `del_flag=0`（已逻辑删除分类 **不出现在** list / level2Options）。

#### 3.2.1 树形 `GET /tree`（可选）

```json
{
  "shopId": 1,
  "shopName": "张三农资店",
  "tree": [
    {
      "categoryId": 1,
      "categoryName": "肥料",
      "categoryLevel": "1",
      "sortNo": 0,
      "children": [
        {
          "categoryId": 10,
          "categoryName": "复合肥",
          "categoryLevel": "2",
          "sortNo": 0
        }
      ]
    }
  ]
}
```

### 3.3 新增 `POST /`

**一级：**

```json
{
  "categoryLevel": "1",
  "categoryName": "肥料",
  "categoryPic": "/profile/upload/c1.jpg",
  "sortNo": 0,
  "showFlag": "1",
  "hotFlag": "0"
}
```

**二级：**

```json
{
  "categoryLevel": "2",
  "parentId": 1,
  "categoryName": "复合肥",
  "categoryPic": "/profile/upload/c2.jpg",
  "sortNo": 0,
  "showFlag": "1",
  "hotFlag": "0"
}
```

| 校验 | 说明 |
|------|------|
| parentId | 一级必须为 0；二级须为本店一级 |
| 名称 | 同 `shop_id+parent_id` 唯一 |
| 层级 | 不得出现三级 |

### 3.4 编辑 `PUT /`

| 字段 | 一级 | 二级 |
|------|:----:|:----:|
| categoryName, categoryPic, sortNo, showFlag, hotFlag | ✓ | ✓ |
| parentId | **不可改**（固定 0） | 可改为本店其他 **一级** |

不可将二级改为一级或把 parent 设为二级节点。

**PUT 二级换上级样例：**

```json
{
  "categoryId": 10,
  "categoryLevel": "2",
  "parentId": 2,
  "categoryName": "复合肥",
  "categoryPic": "/profile/upload/c2.jpg",
  "sortNo": 0,
  "showFlag": "1",
  "hotFlag": "0"
}
```

保存时按新 `parentId` 校验同级名称唯一；`category_level` 仍为 2。

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

| 类型 | 逻辑 |
|------|------|
| **二级** | `IGoodsFacade.existsByCategoryId(id)` → 有则失败 |
| **一级** | 遍历其下全部未删二级，**任一** 有关联商品 → 整棵禁止删 |
| 一级通过校验 | **级联** 逻辑删除其下全部二级 + 自身 |
| **批量** | 逐条校验；**任一条失败则整批不删**，返回 `reasons[]` 汇总 |

**批量混选一级 + 二级时的校验顺序（定稿）：**

```text
1. 展开勾选中的「一级」为其下全部未删二级 ID（去重）
2. 对最终 ID 集合逐条做商品关联校验（existsByCategoryId）
3. 任一失败 → 整批不删，汇总 reasons
4. 全部通过 → 先删二级再删一级（或统一 UPDATE del_flag=2，须同一事务）
```

```json
{
  "code": 500,
  "msg": "整批操作已取消",
  "data": {
    "reasons": ["分类「复合肥」下存在商品，请先处理商品"]
  }
}
```

前端删一级前可先 `confirm`：「将同时删除其下全部二级分类，是否继续？」

---

## 3.5 接口设计（平台端）

**路径：** `/agri/category`  
**权限：** `agri:category:list|add|edit|remove`  
**数据范围：** `shop_id IS NULL`（平台分类）；写入时 `shop_id` 固定为 NULL。

| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/list` | **分页**扁平列表（`TableDataInfo`；参数同商家端 §3.2） |
| GET | `/tree` | 树形结构（不分页） |
| GET | `/level1Options` | 平台一级下拉 |
| GET | `/level2Options` | 平台二级下拉 |
| POST / PUT / DELETE | 同商家端 | 校验规则一致；`parentId` 须为平台范围内一级 |

**菜单 SQL：** `sql/agri_platform_category_menu.sql`（组件 `agri/category/index`）。

---

## 4. 对外协作接口

### 4.1 商品模块复用

| 场景 | 接口 / Facade |
|------|----------------|
| 商家发品选「商品分类」 | **`GET /agri/seller/goods/categoryOptions`** 或 `IPlatformCategoryService.selectPlatformLevel2Options()` |
| 同上（分类模块入口） | `GET /agri/seller/category/platformLevel2Options` |
| 商家发品选「店铺商品分类」 | `GET /agri/seller/goods/shopCategoryOptions` 或 `IShopGoodsCategoryFacade.listOptionsByShopId` |
| 平台商品列表展示路径 | `ICategoryFacade.getCategoryPath(categoryId)` |
| 平台商品列表筛分类 | **`GET /agri/goods/categoryOptions`**（只读，见 4.1.1） |
| 删类前置 | `IGoodsFacade.existsByCategoryId`（见 §8.4） |

#### 4.1.1 平台只读 `GET /agri/goods/categoryOptions`

挂在 **商品模块** `GoodsController`（平台权限 `agri:goods:query`），**不** 提供分类 CRUD。

| Query | 说明 |
|-------|------|
| shopId | 必填（**当前实现保留参数，返回平台二级分类全量**；按店筛分类为后续扩展） |

返回 **平台** 未删除二级分类，供平台商品列表「按分类筛选」下拉；展示 `label` = `一级 > 二级`。

#### 4.1.2 `IGoodsFacade.existsByCategoryId`（商品模块实现，口径定稿）

```sql
EXISTS (
  SELECT 1 FROM biz_goods g
  WHERE g.category_id = #{categoryId}
    AND g.del_flag = '0'
)
```

- 商品 **仅挂二级**，故只校验该 `category_id`（二级节点 ID）。  
- **不统计** 已逻辑删除商品。  
- 一级删除前：对其 **每个未删二级子节点** 各执行一次上述判断。

**`level2Options` 响应：**

```json
{
  "data": [
    { "categoryId": 10, "label": "肥料 > 复合肥", "categoryName": "复合肥" }
  ]
}
```

### 4.2 `ICategoryFacade` 实现要点

```java
boolean isCategoryVisible(Long categoryId);
```

| 条件 | 说明 |
|------|------|
| 节点 | `category_level=2`（商品只挂二级，C 端按二级类判断） |
| 本节点 | `show_flag=1` 且 `del_flag=0` |
| **父级一级** | `parent.show_flag=1` 且 `parent.del_flag=0`（**定稿**：父隐藏则子类目不展示） |
| 店铺 | `biz_shop.del_flag=0` 且开业等由下单 Facade 另判 |

```java
String getCategoryPath(Long categoryId);
// 一级名 + " > " + 二级名；用于平台/商家展示

List<CategoryLevel2VO> listLevel2ByShopId(Long shopId);
// del_flag=0 的全部二级，发品下拉

List<CategoryVisibleVO> listVisibleByShopId(Long shopId);
// C 端：一级 show_flag=1 且其下存在可见二级；二级同上 isCategoryVisible
// 按 sort_no；hot_flag 供热门区
```

### 4.3 C 端（概要）

- 分类导航：`listVisibleByShopId`。  
- `show_flag=0`：不展示入口；**不** 自动下架商品。  
- 下单仍走 `IGoodsPurchaseFacade` + `IShopFacade`（店铺停业禁单，G10）。

---

## 5. 端到端流程

```text
商家：登录 → 选当前店铺 → GET /seller/category/list → POST 一级/二级
    ↓
商家：POST /seller/goods + categoryId（二级）
    ↓
平台：GET /agri/goods/list?categoryId=  （只读分类数据）
    ↓
删二级：existsByCategoryId=false → DELETE
删一级：子级均无商品 → 级联 DELETE
```

---

## 6. 业务规则映射

| 编号 | 实现 |
|------|------|
| G1 | 商家 SQL 带 `shop_id`；平台 SQL 带 `shop_id IS NULL` |
| G2 | `category_level` + `parent_id` 校验 |
| G3 | 名称唯一 `(shop_id, parent_id)`（含 NULL 平台范围） |
| G4 | 删一级级联子级 |
| G5 | `existsByCategoryId` |
| G6 | 平台 `/agri/category` + 商家 `/agri/seller/category` |
| G7 | 商品仅 `level2Options` / 商品 Service 校验 level=2 **且本店** |
| G8 | 无 UPDATE goods |
| G9 | DELETE 批量整批失败 |
| G10/G11 | 停业不拦分类维护；经营账号 + shop 上下文 |

---

## 7. 菜单权限

### 7.1 商家端

| 权限 | 标识 |
|------|------|
| 列表 | agri:seller:category:list |
| 新增 | agri:seller:category:add |
| 修改 | agri:seller:category:edit |
| 删除 | agri:seller:category:remove |

**菜单 SQL：** `sql/agri_seller_category_menu.sql`（组件 `agri/seller/category/index`）。

### 7.2 平台端

| 权限 | 标识 |
|------|------|
| 列表 | agri:category:list |
| 新增 | agri:category:add |
| 修改 | agri:category:edit |
| 删除 | agri:category:remove |

**菜单 SQL：** `sql/agri_platform_category_menu.sql`（组件 `agri/category/index`）。

---

## 8. 实现补充

### 8.1 业务错误 msg

| 场景 | msg |
|------|-----|
| 名称重复 | 同级分类名称已存在 |
| 非法上级 | 二级分类的上级必须是一级分类 |
| 禁止三级 | 不支持三级分类 |
| 删二级有商品 | 该分类下存在商品，请先修改商品分类或处理商品 |
| 删一级有商品 | 该一级分类下存在商品，请先处理商品 |
| 批量失败 | 部分分类不满足删除条件，整批操作已取消 |
| 店铺已关闭 | 店铺已关闭，无法维护分类 |

### 8.2 字段校验

| 字段 | 规则 |
|------|------|
| categoryName | 非空，≤64 |
| categoryPic | 非空 URL；jpg/png/jpeg；≤5MB |
| sortNo | 整数 ≥0 |
| showFlag, hotFlag | 0/1 |

### 8.3 删店后行为

`biz_shop.del_flag=2` 时：商家端分类接口统一拦截；**不自动** 删 `biz_goods_category` 数据。

### 8.4 商品关联与可见性（定稿口径）

| 能力 | 口径 |
|------|------|
| `existsByCategoryId` | 见 §4.1.2 |
| `isCategoryVisible` | 见 §4.2；父级一级须可见 |
| `level2Options` / `list` / `tree` | 仅 `del_flag=0` |
| 历史商品展示已删分类名 | 平台/商家详情 **冗余存路径** 或 join 历史表；本期可 join 分类表（已删则显示「分类已删除」） |

### 8.5 写入一致性

新增/编辑时 Service **同时** 写入 `parent_id` 与 `category_level`，禁止仅改其一导致层级漂移。

---

## 9. 文档索引

| 文档 | 版本 |
|------|------|
| 商品分类功能需求.md | v1.4 |
| 商品分类测试用例.md | v1.0 |
| 商品管理功能需求/技术方案 | v1.3.3 / v1.2 |
| 店铺管理功能需求/技术方案 | v1.3.2 / v1.2.1 |
| 商户管理功能需求/技术方案 | v1.3.1 / v1.3 |

---

## 10. 非本期实现

三级分类、分类恢复、导出、C 端完整类目 REST（`listVisibleByShopId` Facade 已定义）、子管理员细粒度权限、商品挂平台分类。

---

## 11. v1.2 修订摘要

| 项 | 内容 |
|----|------|
| shop_id | 改为 **可空**；NULL=平台分类 |
| 平台端 | 新增 `PlatformCategoryController`、`/agri/category` CRUD |
| 列表 | `GET /list` 改为 **分页** `TableDataInfo`（`pageNum`/`pageSize`/`keyword`） |
| 商家 scope | 新增 `GET /seller/category/scope` 返回店铺信息 |
| 前端 | 平台 `agri/category/index`、商家 `agri/seller/category/index`（扁平表 + 分页） |

## 12. v1.1 修订摘要

| 项 | 内容 |
|----|------|
| `existsByCategoryId` | 仅 `biz_goods.del_flag=0` |
| `isCategoryVisible` | 二级可见且 **父级一级** `show_flag=1` |
| 平台筛类 | `GET /agri/goods/categoryOptions?shopId=` |
| 批量删除 | 混选一级+二级的展开与校验顺序 |
| 列表 | 默认 `/list` 扁平；`/tree` 结构示例；`del_flag` 过滤 |

---

*文档版本：v1.3 · MySQL 5.7.39 · RuoYi v3.9.2-springboot2 · 关联《商品分类功能需求.md》v1.5、《商品分类测试用例.md》v1.0*
