# 商城数据统计 — 技术方案

> **依据：** 《商城数据统计功能需求.md》v1.0.3  
> **关联：** 《订单管理技术方案.md》v1.0.2、《商品分类功能需求》v1.5、《商城设置技术方案.md》v1.1  
> **范围：** 对外 **只读** 六项统计；**无** 平台 Web 菜单；**不新建** 业务明细表，**聚合查询** 现有订单/分类/店铺/评价数据。  
> **原则：** 统计 **不阻塞** 交易写路径；**准实时** + **Redis 缓存**；Token **与** 会员 JWT **隔离**。

---

## 1. 技术架构

| 项 | 选型 |
|----|------|
| 基础框架 | RuoYi **v3.9.2**（`springboot2` 分支） |
| 数据库 | **MySQL 5.7.39** |
| ORM / 响应 | MyBatis；`AjaxResult`（`code` / `msg` / `data`） |
| 缓存 | **Redis**（统计结果 TTL，见 §5） |
| 认证 | **AES-128-CBC** 加密 UUID（请求头校验；**不走** `@PreAuthorize` 登录态；**无** Token 库表） |
| 分词（词云） | **HanLP** 或 **Ansj**（二选一，应用层；结果缓存） |
| 时区 | **`Asia/Shanghai`**（统计日/年边界） |

### 1.1 模块落位

**文档目录：** `doc/平台后台/外部接口/`  
**后端包：** `com.ruoyi.web.modules.openstats`

```text
openstats/
├── controller/MallStatsOpenController.java     # /api/open/stats/**
├── filter/OpenStatsTokenFilter.java            # Token 校验（仅匹配 open stats 路径）
├── service/IMallStatsOpenService.java
├── service/impl/MallStatsOpenServiceImpl.java
├── mapper/MallStatsOpenMapper.java
├── vo/                                         # 六项 VO + OverviewVO
├── support/
│   ├── OpenStatsTokenSupport.java              # Token 校验入口
│   ├── OpenStatsTokenCryptoSupport.java        # AES 解密 + UUID 格式校验
│   ├── AddressCityParser.java                  # consignee_address → 城市
│   ├── ReviewWordCloudSupport.java             # 分词 + 停用词 + Top50
│   └── StatsCacheKeys.java                     # Redis key 规范
└── constant/OpenStatsConstants.java            # Key/IV、缓存 TTL 等

sql/
└── （统计索引见 `sql/biz_order.sql` 中 `idx_stats_finish`）
```

**与 C 端 `@Anonymous` 区别：** 路径 `/api/open/stats/**` **须** 携带有效 Token；Filter **先于** Controller；无效 Token 返回 **401**，**不** 进入业务。

---

## 2. 数据库设计

### 2.1 设计原则

| 原则 | 说明 |
|------|------|
| **不建统计事实表（v1）** | 六项指标 **实时/缓存聚合**；避免与订单写路径双写 |
| **只读关联** | **禁止** 统计接口内 UPDATE 业务表 |
| **不建 Token 表** | 认证为 **固定 AES 密钥** 解密后校验 UUID；**无** `biz_open_api_token` |

### 2.2 依赖业务表（只读）

| 表 | 统计项 | 关键字段 |
|----|--------|----------|
| **biz_order** | 订单趋势、区域金额 | `order_id`, `order_status`, `create_time`, `finish_time`, `pay_amount`, `consignee_address` |
| **biz_order_item** | 品类销量、热销 | `order_id`, `goods_id`, `quantity` |
| **biz_goods** | 品类归并 | `goods_id`, `category_id`（平台 **二级**） |
| **biz_goods_category** | 一级品类名 | `category_id`, `parent_id`, `category_level`, `shop_id IS NULL`, `category_name` |
| **biz_shop** | 店铺入驻 | `shop_id`, `create_time` |
| **biz_goods_review** | 词云 | `content`, `del_flag`, `show_flag` |

**状态常量（对齐 `OrderConstants`）：**

| 值 | 含义 | 参与指标 |
|----|------|----------|
| `3` | 已完成 | §5.1、§5.2、§5.5（`finish_time` 非空） |
| `0~4` | 非删除 | §5.3（按 `create_time`） |
| `5` | 已删除 | **全部排除** |

**品类归并 SQL 逻辑：**

```text
biz_order_item oi
  JOIN biz_order o ON o.order_id = oi.order_id
  JOIN biz_goods g ON g.goods_id = oi.goods_id
  JOIN biz_goods_category c2 ON c2.category_id = g.category_id AND c2.shop_id IS NULL
  JOIN biz_goods_category c1 ON c1.category_id = c2.parent_id AND c1.shop_id IS NULL
       → c1 为一级品类
  一级缺失 → category_id=0, category_name='未分类'（全接口统一）
```

### 2.3 统计索引（`biz_order`）

**脚本：** `sql/biz_order.sql` — 建表已含 **`idx_stats_finish`**（`order_status`, `finish_time`）；存量库见该文件 **末尾升级注释**。

---

## 3. 核心查询口径（MyBatis）

### 3.1 农资品类销售 / 热销 Top5（共用数据集）

**条件：**

```sql
o.order_status = '3'
AND DATE(o.finish_time) = #{statDate}   -- 当天，Asia/Shanghai
AND o.order_status <> '5'
```

**聚合：**

```sql
SELECT c1.category_id, c1.category_name, SUM(oi.quantity) AS qty
FROM biz_order o
INNER JOIN biz_order_item oi ON oi.order_id = o.order_id
INNER JOIN biz_goods g ON g.goods_id = oi.goods_id
INNER JOIN biz_goods_category c2 ON c2.category_id = g.category_id AND c2.shop_id IS NULL
INNER JOIN biz_goods_category c1 ON c1.category_id = c2.parent_id AND c1.shop_id IS NULL
WHERE ...
GROUP BY c1.category_id, c1.category_name
```

| 接口 | Service 处理 |
|------|----------------|
| 品类销售 | 算 `totalQty`、各品类 `ratio`（1 位小数，误差归最大项） |
| 热销 Top5 | `ORDER BY qty DESC, category_id ASC LIMIT 5`（并列第 5 **截断**，对齐 ST-H2） |

### 3.2 商城订单趋势

```sql
SELECT MONTH(o.create_time) AS m, COUNT(*) AS cnt
FROM biz_order o
WHERE YEAR(o.create_time) = #{statYear}
  AND o.order_status <> '5'
GROUP BY MONTH(o.create_time)
```

Service **补全 1～12 月**，无数据月 `cnt=0`。

### 3.3 店铺入驻

```sql
SELECT MONTH(create_time) AS m, COUNT(*) AS cnt
FROM biz_shop
WHERE YEAR(create_time) = #{statYear}
GROUP BY MONTH(create_time)
```

Service 算各月 **占比**（`yearTotal=0` 则占比全空）。

### 3.4 消费区域 Top5

```sql
SELECT o.consignee_address, SUM(o.pay_amount) AS amount
FROM biz_order o
WHERE o.order_status = '3'
  AND YEAR(o.finish_time) = #{statYear}
GROUP BY ... -- 应用层 AddressCityParser 解析后 GROUP BY city
ORDER BY amount DESC
LIMIT 5
```

| 项 | 实现 |
|----|------|
| 城市 | Java **`AddressCityParser`** 从 `consignee_address` 提取地级市；解析失败 → **`未知`** |
| 金额 | `amount / 10000`，**保留 2 位小数** |

> **v1 不在库内加 `consignee_city` 字段**；若地址格式不稳定，**v1.1** 可下单时冗余城市字段。

### 3.5 消费者评价词云

```sql
SELECT content FROM biz_goods_review
WHERE del_flag = '0' AND show_flag = '1'
  AND content IS NOT NULL AND TRIM(content) <> ''
```

**应用层：** `ReviewWordCloudSupport.segment()` → 去停用词 → 词频降序 → **Top50**；**全量结果 Redis 缓存 24h**。

---

## 4. 接口设计

**基路径：** `/api/open/stats`  
**认证：** 请求头 **`X-Open-Token: {Base64密文}`**（见 §5.1）  
**权限：** `@Anonymous` + **`OpenStatsTokenFilter`**（Filter 内校验，失败 **401**）  
**响应：** `AjaxResult`；`data` 为下表 VO。

### 4.1 接口一览

| 方法 | 路径 | 功能需求 | 缓存 TTL |
|------|------|----------|----------|
| GET | `/categorySales` | §5.1 品类销售 | 5 min |
| GET | `/hotCategoryRank` | §5.2 热销 Top5 品类 | 5 min |
| GET | `/orderTrend` | §5.3 订单趋势 | 1 h |
| GET | `/shopEntry` | §5.4 店铺入驻 | 1 h |
| GET | `/regionRank` | §5.5 区域 Top5 | 1 h |
| GET | `/reviewWordCloud` | §5.6 词云 Top50 | 24 h |
| GET | `/overview` | **组合** 上述六项（大屏一次拉取） | 取各子项最小 TTL |

**Query（可选，默认当天/当年）：**

| 参数 | 适用 | 默认 |
|------|------|------|
| `statDate` | categorySales、hotCategoryRank | 今天 `yyyy-MM-dd` |
| `statYear` | orderTrend、shopEntry、regionRank | 今年 `yyyy` |

> v1 **不开放** 任意历史区间（对齐功能需求非本期）；参数 **仅用于联调**，生产大屏 **可不传**。

### 4.2 响应结构（`data`）

#### `GET /categorySales`

```json
{
  "statDate": "2026-05-26",
  "totalQty": 1200,
  "items": [
    { "categoryId": 1, "categoryName": "兽药", "qty": 500, "ratio": 41.7 }
  ]
}
```

#### `GET /hotCategoryRank`

```json
{
  "statDate": "2026-05-26",
  "items": [
    { "rank": 1, "categoryId": 1, "categoryName": "兽药", "qty": 500 }
  ]
}
```

#### `GET /orderTrend`

```json
{
  "statYear": 2026,
  "items": [
    { "month": 1, "orderCount": 320 },
    { "month": 2, "orderCount": 0 }
  ]
}
```

#### `GET /shopEntry`

```json
{
  "statYear": 2026,
  "yearTotal": 48,
  "items": [
    { "month": 1, "shopCount": 4, "ratio": 8.3 }
  ]
}
```

#### `GET /regionRank`

```json
{
  "statYear": 2026,
  "items": [
    { "rank": 1, "city": "北京市", "amountWan": 12.35 }
  ]
}
```

#### `GET /reviewWordCloud`

```json
{
  "items": [
    { "word": "质量好", "count": 86 }
  ]
}
```

#### `GET /overview`

```json
{
  "categorySales": { },
  "hotCategoryRank": { },
  "orderTrend": { },
  "shopEntry": { },
  "regionRank": { },
  "reviewWordCloud": { }
}
```

### 4.3 错误码（示例）

| HTTP | code | 场景 |
|------|------|------|
| 401 | 401 | Token 缺失、Base64/AES 解密失败、或解密后 **非标准 UUID** |
| 200 | 200 | 成功（含空数据 `items: []`） |
| 500 | 500 | 系统异常 |

---

## 5. 认证与性能

### 5.1 Token 校验流程

**约定（调用方 ↔ 服务端）：**

| 项 | 值 |
|----|-----|
| 明文 | 调用方生成 **标准 UUID**（如 `550e8400-e29b-41d4-a716-446655440000`） |
| 算法 | **AES-128-CBC**，填充 **PKCS5Padding** |
| Key | `mdYJB5ENzTwEbql2`（16 字节 UTF-8） |
| IV | `FU2GR30Iw76PjXbO`（16 字节 UTF-8） |
| 传输 | 密文 **Base64** 编码后放入请求头 **`X-Open-Token`** |

**调用方生成（示例逻辑）：**

```text
uuid = UUID.randomUUID()
cipherBytes = AES-128-CBC-Encrypt(uuid UTF-8, key, iv)
token = Base64(cipherBytes)
Header: X-Open-Token: {token}
```

**服务端校验：**

```text
请求 /api/open/stats/**
    → OpenStatsTokenFilter
        → 读 Header X-Open-Token
        → OpenStatsTokenCryptoSupport
            → Base64 解码
            → AES-128-CBC 解密（OpenStatsConstants 中 key/iv）
            → UUID.fromString 校验明文格式
            → 通过 → chain.doFilter
            → 失败 → 401 JSON，不访问 Controller
```

| 说明 |
|------|
| **不** 查库、**不** 校验 UUID 是否重复；每次请求可生成 **新 UUID** 再加密 |
| Key/IV 变更须 **服务端发版/改配置** 并 **同步调用方**；**无** 单 Token 吊销 |
| 后续可将 Key/IV 外置至 `application.yml`（**非本期必做**） |

### 5.2 缓存策略

| Redis Key 示例 | TTL |
|----------------|-----|
| `openstats:cat:sales:{statDate}` | 5 min |
| `openstats:cat:hot:{statDate}` | 5 min |
| `openstats:order:month:{year}` | 1 h |
| `openstats:shop:month:{year}` | 1 h |
| `openstats:region:{year}` | 1 h |
| `openstats:wordcloud` | 24 h |

| 规则 |
|------|
| **Cache-Aside**：先读 Redis，miss 再查 DB 并写入 |
| 订单 **确认收货/自动确认** 后 **不主动删缓存**；靠 TTL **准实时**（对齐功能需求） |
| **`overview`** 内部并行调 6 段逻辑，**共用** 各 key |

### 5.3 性能约束

| 项 | 要求 |
|----|------|
| 单次 SQL | **禁止** 无时间/状态条件的全表扫 **biz_order_item**；**必须** 先过滤 `biz_order` |
| 词云 | **禁止** 每次请求全量分词；**必须** 走 24h 缓存 |
| 连接 | 统计走 **从库只读**（若架构有读写分离）为 **可选优化** |

---

## 6. 业务规则映射

| 编号 | 实现 |
|------|------|
| ST-R2 | SQL `order_status <> '5'` |
| ST-R3 | 仅读 `finish_time`；**不读** `ship_time` |
| ST-C* / ST-H* | §3.1 + Service 占比/Top5 |
| ST-O* | §3.2 |
| ST-S* | §3.3 |
| ST-A* | §3.4 + `pay_amount` |
| ST-RV* | §3.5 + `del_flag=0 AND show_flag=1` |
| ST-T* | Filter + AES 解密 + UUID 校验（`OpenStatsTokenCryptoSupport`） |
| ST-TST3a | 自动确认写入的 `finish_time` 参与 §3.1 日期过滤 |

---

## 7. 实施与 SQL 清单

| 阶段 | 内容 |
|------|------|
| P1 | Filter + AES Token 校验 + Controller 骨架 |
| P2 | 五项 SQL 聚合 + Service + 单元测试（Mock 数据） |
| P3 | Redis 缓存 + `overview` |
| P4 | 词云分词 + 停用词表资源文件 |
| P5 | 可选索引 + 压测大屏并发 |

| 脚本 | 说明 |
|------|------|
| `sql/biz_order.sql` | 含 `idx_stats_finish`；存量库执行文件末尾 ALTER 注释 |

---

## 8. 测试要点

| 编号 | 场景 |
|------|------|
| OS-T1 | 无 Token / 明文 UUID / 错误密文 → 401 |
| OS-T2 | 当日 3 品类完成单 → 占比合计 ≈100% |
| OS-T3 | 品类销量第 6 → 不在 hotCategoryRank |
| OS-T4 | 自动确认 `finish_time` 在今日 → 计入 categorySales |
| OS-T5 | `order_status=5` → 各接口均不计 |
| OS-T6 | overview 六项结构与单接口一致 |
| OS-T7 | 第二次请求命中 Redis（集成环境可观测） |

---

## 9. 修订记录

| 版本 | 说明 |
|------|------|
| **v1.0** | 首版：RuoYi3.9.2 + MySQL5.7；复用业务表聚合；六项 Open API + overview；Redis 缓存 |
| **v1.1** | Token 改为 **AES-128-CBC 加密 UUID** 校验；**移除** `biz_open_api_token` 库表方案 |

*文档版本：v1.1 · 关联《商城数据统计功能需求.md》v1.0.3 · 统计索引：`sql/biz_order.sql` → `idx_stats_finish`*
