diff --git a/docs/项目分析.md b/docs/项目分析.md index b999d05..e775af3 100644 --- a/docs/项目分析.md +++ b/docs/项目分析.md @@ -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 # 安装前后端依赖