docs: 补充管理端商户上下文复盘

记录这一轮「超管看不到下游订单」的完整复盘:实测现象、根因(同一个人在统计接口与
列表接口里语义不同)、更深一层的三个设计问题(Tenant 把解析与授权揉在一起、
挂在所有已登录请求上、商户上下文是前端自由拼装的非受控状态)、四处处理与验证,
以及修复后仍然存在的四项风险与建议(可识别错误码实现自愈、size 回显不一致、
平台接口不得再用 GetMerchantID、商户列表页缺显式入口)。
This commit is contained in:
yml2213
2026-10-06 20:47:12 +08:00
parent 663d7e1e9f
commit 7640dd43ac
+61 -1
View File
@@ -350,7 +350,67 @@ DB 里的值被静默忽略,且没有任何日志提示。
---
## 八、本地开发与验证
## 八、管理端商户上下文复盘(2026-10-06)
起因是一个来自使用者的反馈:**「超级管理员是不是看不到下游订单?」**
实测后确认属实,而且比"看不到"更糟——同一身份在同一时刻看到两个互相矛盾的数字。
### 8.1 现象(实测数据)
| 调用 | 结果 |
|------|------|
| `GET /api/dashboard`(管理员) | `scope=platform`,`order_count=37` |
| `GET /api/merchant/orders`(管理员,默认) | `total=29`,全部为自营商户 |
| `GET /api/merchant/orders` + `X-Merchant-ID: 2` | `total=8` |
| 数据库实际分布 | 自营商户 29 + 测试商户 8 = 37 |
首页显示 37 单、订单页只有 29 单,界面上没有任何解释。
### 8.2 根因:`role=admin` 在两个接口里语义不同
- `handler/order.go` 的 `isPlatformAdmin` 只看 JWT 角色 → 统计是**平台视角**,跨商户聚合;
- `middleware.Tenant` 只认 `X-Merchant-ID`,没有就回退到账号自己的成员关系 →
管理员唯一的成员关系是自营商户,被**静默框定在单个商户**;
- 35 条 `/platform/*` 路由里没有任何订单入口,前端也没有商户切换器或上下文提示。
### 8.3 更深一层:三个设计问题
1. **`Tenant` 中间件把「解析」与「授权」揉在一起**,解析失败直接 403。
而解析失败有两种完全不同的原因——客户端状态过期(商户被禁用/删除)与真的无权限——
中间件把它们压成同一个 403,客户端无法区分,也就无法自愈。
2. **它挂在「所有已登录请求」上**,包括根本不看商户的平台接口。
于是「LocalStorage 里躺了一个已失效的商户 ID」这种客户端状态问题,
升级成了**整个管理端不可用**:连去把上下文改回来的平台页面都打不开。
3. **商户上下文是前端自由拼装的请求头,不是受控状态**。
它只存在 `localStorage`,只在登录/登出时清除;唯一的写入点是「平台商户」页的
「成员」按钮(一个副作用),没有显式入口、没有提示、也没有退出方式。
### 8.4 处理
| 提交 | 内容 |
|------|------|
| `8c12d1d` | 新增 `GET /api/platform/orders` 跨商户订单视图 + 前端「下游订单」页;商户侧隔离语义不动,并补测试卡住边界 |
| `e692790` | 平台分组从 `Tenant` 中摘出(逐条核对:35 条平台路由对租户的依赖为 0);前端对 `/platform/*` 与管理员 `/dashboard` 不再发送 `X-Merchant-ID` |
| `04c3369` | 顶栏常驻显示「当前商户:xxx」+「退出商户视角」 |
| `663d7e1` | 自查发现筛选下拉被分页上限静默截断,新增不分页的 `/api/platform/merchant-options` |
**关于"为什么不静默回退"**:商户上下文解析失败时后端仍返回 403。
商户后台存在写操作(人工下单、取消订单、成员管理),若自动回退到默认商户,
写入会落到**错误的商户**上——这比"多点一次退出"严重得多。
因此选择「失败要响,但必须给得出路」,出路就是顶栏的退出入口。
### 8.5 修复后仍然存在的风险
| 风险 | 说明 | 建议 |
|------|------|------|
| 仍走 `Tenant` 的 42 条路由(37 merchant + 5 auth)在上下文失效时返回 403 | 前端已跳过管理员的 `/platform/*` 与 `/dashboard` 请求头,但 curl / 移动端 / 旧的缓存 bundle 仍会命中 | 给 `Tenant` 的失败加上可识别的业务错误码,客户端据此清理上下文并重试一次,实现自愈 |
| `response.Page` 回显的是**请求的** `size`,不是**生效的** `size` | `normalizePage` 会把 `size>100` 降级为 20,但响应里仍写 200,客户端按它翻页会跳数据 | 把 size 归一化上移到 handler 边界,或让 service 返回生效值 |
| 平台路由不再解析商户上下文 | 这是有意的:平台接口本来就跨商户。但未来若某个平台接口**确实需要**知道当前商户,不能再依赖 `GetMerchantID`,必须显式传参 | 在 router 的注释里已写明该约束 |
| 平台商户列表页缺少「以该商户视角查看」入口 | 目前仍只能靠「成员」按钮的副作用进入商户上下文 | 补一个显式按钮 |
---
## 九、本地开发与验证
```bash
make install # 安装前后端依赖