# 商城数据统计 — 技术方案 > **依据:** 《商城数据统计功能需求.md》v1.0.4 > **关联:** 《订单管理技术方案.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 YEAR(o.finish_time) = #{statYear} -- 当年,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 品类销售 | 1 h | | GET | `/hotCategoryRank` | §5.2 热销 Top5 品类 | 1 h | | 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(可选,默认当年):** | 参数 | 适用 | 默认 | |------|------|------| | `statYear` | categorySales、hotCategoryRank、orderTrend、shopEntry、regionRank、overview | 今年 `yyyy` | > v1 **不开放** 任意历史区间(对齐功能需求非本期);参数 **仅用于联调**,生产大屏 **可不传**。 ### 4.2 响应结构(`data`) #### `GET /categorySales` ```json { "statYear": 2026, "totalQty": 1200, "items": [ { "categoryId": 1, "categoryName": "兽药", "qty": 500, "ratio": 41.7 } ] } ``` #### `GET /hotCategoryRank` ```json { "statYear": 2026, "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:{year}` | 1 h | | `openstats:cat:hot:{year}` | 1 h | | `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 并写入 | | **年度指标捆绑刷新**:同一 `statYear` 下 **5 项年度指标**(品类销售、热销、订单趋势、入驻、区域)**任一缓存 miss 或不全** 时,**一次性重建并写入全部 5 个 key**,避免各接口数据时间点不一致 | | **`overview`**:在年度捆绑就绪且词云缓存存在时组装;**任一缺失** 时 **同步刷新年度捆绑 + 词云** 后再返回 | | 订单 **确认收货/自动确认** 后 **不主动删缓存**;靠 TTL **准实时**(对齐功能需求) | | **`overview` / 单接口** 均 **复用** 上述子 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.3** | 年度 5 项指标 **捆绑刷新**;`overview` miss 时 **同步更新** 年度指标 + 词云,避免部分缓存过期 | | **v1.2** | §5.1/§5.2 改为 **统计年**(`YEAR(finish_time)`);接口参数统一 `statYear`;缓存 key/TTL 对齐年度指标 | | **v1.1** | Token 改为 **AES-128-CBC 加密 UUID** 校验;**移除** `biz_open_api_token` 库表方案 | *文档版本:v1.3 · 关联《商城数据统计功能需求.md》v1.0.4 · 统计索引:`sql/biz_order.sql` → `idx_stats_finish`*