feat: 后台订单按平台、代理商、在线支付拆分列表与导出
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m34s

- 新增 GET /orders/platform、/orders/agent、/orders/online,类别由路径固定,参数、响应、权限同原订单列表
- 新增导出场景 order_platform、order_agent、order_online,筛选与表头同 order
- 类别按钱从哪来划分:客户端下单为在线支付;后台代理钱包扣款(含平台代扣)为代理商;其余后台订单为平台
- 列表与导出共用 internal/query/ordercategory 的类别条件
- 企业账号访问订单列表、详情、购买预检改为无权限,修复其可看到全部订单的问题
- 原订单列表与 order 导出场景保留不变

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
邹进
2026-10-26 12:53:24 +09:00
parent 3618fc2b20
commit eb9f12d160
16 changed files with 700 additions and 25 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-26

View File

@@ -0,0 +1,83 @@
## Context
完整技术方案见 `docs/engineering/后台订单按类别拆分列表技术方案.md`,此处只记录实现决策。
- 列表:`OrderHandler.List`(`internal/handler/admin/order.go:90`)→ `order.Service.List` → `OrderStore.List`(`internal/store/postgres/order_store.go:85`)。Handler 对代理账号固定 `buyer_type = agent AND buyer_id = 当前店铺`;Store 叠加 `applyOrderScope`(下级店铺范围)。
- 导出:`OrderDataSource`(`internal/exporter/order_scene.go`)的 `applyFilters` 复刻上述两层条件,按创建任务时冻结的账号类型、`ScopeShopIDs`、`CreatorShopID` 过滤;`Count`、`Fetch` 共用 `applyFilters`。场景经 `NewDefaultRegistry` 注册,`timeFilterScenes` 登记统一时间筛选。
- 订单字段:`source`(`admin` / `client`,非空,默认 `admin`);后台下单只支持 `wallet` 与 `offline`。
- 测试环境 165 笔:客户端 53;后台钱包 52(代理操作 21、平台代扣 31);后台线下 60(平台代购 41、自营与赠送等 19)。生产分布未查到,上线前补查。
- 老卡管:扣代理余额的单在"代理商订单"页(`tbl_agent_order`),C 端公众号订单在"微信订单"页(`tbl_agent_weixin_order`),平台加套餐不生成订单。
## Goals / Non-Goals
**Goals:**
- 新增三个按类别固定的列表接口与三个导出场景,原接口与原场景不变。
- 三类互斥且覆盖全部订单;列表与导出口径一致。
- 权限、数据范围、筛选条件、响应与表头与原列表、原导出一致。
**Non-Goals:**
- 不改订单详情、取消、购买预检、后台下单。
- 不新增字段、不做数据迁移、不改历史订单。
- 不调整代理账号的数据范围;不新增在线支付订单的"订单来源、收款方"筛选。
## Decisions
### 1. 按付款来源分类,不按操作人
| 类别 | 条件 |
|---|---|
| 在线支付 | `source = 'client'` |
| 代理商 | `source = 'admin' AND payment_method = 'wallet'` |
| 平台 | `source = 'admin' AND (payment_method IS NULL OR payment_method <> 'wallet')` |
平台代扣代理钱包与代理自购的扣款钱包、买家、成本价、订单角色、退款方式(只能退回代理钱包)、佣金基准完全一致,只有操作人不同,归代理商订单,与老卡管"扣代理余额即代理商订单"一致。平台代购线下收款、退款走客户账户,不涉及代理钱包,归平台订单。
备选:按操作账号类型(`operator_account_type`,为空回退 `operator_type`)。放弃:会把平台代扣归到平台订单,与资金归属不一致,且要兜底历史空值。
### 2. 类别条件只写一份
订单类别常量放 `pkg/constants/order_category.go`;按表别名生成条件的函数放 `internal/query/ordercategory`(如 `Condition(alias, category string) (sql string, args []any, ok bool)`),只依赖 `pkg/constants`,不产生循环引用。Store 以无别名调用,导出以别名 `o` 调用。与不轮询清单列表和导出共用 `internal/query/pollingpause` 的做法一致。
备选:Store 与导出各写一份条件。放弃:两处容易改漏,违反"列表与导出口径一致"。
### 3. 类别作为内部参数传递,不进 DTO
- Store:`OrderStore.List` 的 filters 新增内部键 `order_category`。
- Service:`order.Service.List` 增加类别入参并写入 filters;原调用传空类别。
- Handler:原 `List` 的参数解析、时间范围解析、代理买家条件提取为内部方法 `list(c, category)`;原 `List` 以空类别调用,新增 `ListPlatform`、`ListAgent`、`ListOnline` 以固定类别调用,权限逻辑只有一份。
- `dto.OrderListRequest` 不加字段,请求中携带类别参数不会被解析。
### 4. 导出用新场景表示类别
新增场景常量 `ExportTaskSceneOrderPlatform`(`order_platform`)、`ExportTaskSceneOrderAgent`(`order_agent`)、`ExportTaskSceneOrderOnline`(`order_online`)。`OrderDataSource` 增加类别字段,构造为 `NewOrderDataSource(db, category)`;`Scene()` 按类别返回场景、类别为空返回 `order`;`applyFilters` 末尾叠加类别条件。`NewDefaultRegistry` 再注册三个类别的数据源,`timeFilterScenes` 与 `IsSupportedScene` 加入三个新场景。
备选:在 `order` 场景的 filters 中加类别参数。放弃:导出本来按 `scene` 区分数据,与列表"路径决定类别、不传类型参数"一致;也避免旧前端漏传参数时导出全部订单。
### 5. 企业账号拒绝访问订单接口
现状:后台认证允许企业账号;原列表只对代理固定买家条件,`applyOrderScope` 对企业账号不加店铺范围(`GetSubordinateShopIDs` 为空即不限),接口权限中间件(`pkg/middleware/permission.go`)未接入路由,因此企业账号调用列表与详情能看到全部订单。下单已拒绝企业账号(`OrderHandler.Create`),取消只允许代理,导出任务不允许企业账号创建。
做法:在 `OrderHandler` 增加一个内部校验,`List`、三个新列表、`Get`、`PurchaseCheck` 开头调用,企业账号返回 `CodeForbidden`("企业账号无权访问订单")。放在 Handler 而非 Store:Store 的 `applyOrderScope` 被订单相关的其他调用方共用(如资产订单查询、退款),在 Store 拦会影响企业账号合法的资产订单查询。
备选:在 `applyOrderScope` 对企业账号加 `1 = 0`。放弃:影响面不可控,且返回空列表而不是无权限,前端无法区分。
### 6. 路由注册顺序
`/orders/platform`、`/orders/agent`、`/orders/online` 必须在 `GET /orders/:id`(`internal/routes/order.go:30`)之前注册,否则被当作订单 ID 解析并返回"无效的订单ID"。OpenAPI 输入输出与原列表相同。
## Risks / Trade-offs
- [代理账号调用在线支付订单接口总为空] 原列表对代理只返回买家是本店铺的订单。→ 按"权限不变"保留,在接口说明中写明;需要时另起变更调整权限。
- [代理在平台订单接口看到平台代购单] 平台代购的买家是代理,原列表对其可见。→ 预期行为,接口说明中写明。
- [类别条件无专用索引] → 测试环境看执行计划;生产订单量明显增长时再评估 `(source, payment_method, created_at)` 组合索引。
- [将来后台新增支付方式或新的订单来源] 会按规则落入平台订单。→ 类别条件集中在一处,新增时同步评估;"三类之和等于原列表"作为验证项。
- [生产存在非预期取值] 例如 `source` 不是 `admin`/`client`,会不属于任何类别。→ 上线前按 `source`、`payment_method` 补查生产分布。
## Migration Plan
纯新增接口与导出场景,无数据迁移。部署后前端切换到新接口与新场景;原接口与原场景保留,回滚只需不调用或回退代码。
## Open Questions
- 平台代购是否改归代理商订单——有要求再做。
- 代理账号是否需要在在线支付订单接口看到自己资产上的个人客户订单——属于权限调整,有要求再做。

View File

@@ -0,0 +1,36 @@
## Why
后台订单列表 `GET /api/admin/orders` 与订单导出(`scene=order`)把平台订单、代理商订单和个人客户在线支付订单混在一起,运营无法分页面查看与导出;现有的支付方式、订单角色条件也区分不出"平台代扣代理钱包"与"平台代购"。需要在保留原接口的前提下按订单类别拆出三个列表接口与三个导出场景。技术方案见 `docs/engineering/后台订单按类别拆分列表技术方案.md`。
## What Changes
- 保留 `GET /api/admin/orders` 与导出场景 `order`,行为、参数、响应均不变。
- 新增三个列表接口,类别由路径固定,不接收类型参数:
- `GET /api/admin/orders/platform`:平台订单。
- `GET /api/admin/orders/agent`:代理商订单。
- `GET /api/admin/orders/online`:在线支付订单。
- 新增三个导出场景 `order_platform`、`order_agent`、`order_online`,与三个列表接口一一对应,筛选条件中不加类别参数。
- 类别按钱从哪来划分,不按操作人:
- 在线支付订单:客户端下单(`source = client`),含微信、支付宝、卡钱包支付,自动续费,自动购买,待支付。
- 代理商订单:后台下单且从代理钱包扣款(`payment_method = wallet`),含代理自购、代理给下级购买、平台代扣代理钱包、代理 OpenAPI 下单。
- 平台订单:后台下单且不走代理钱包,含平台自营、赠送、平台代购(平台代购买家虽记为代理,但线下收款、不扣代理钱包)。
- 新接口与新导出场景的查询参数、分页、响应结构、表头、认证、权限与数据范围与原列表、原导出完全一致,只多一个固定的类别条件。
- **BREAKING** 企业账号不能访问后台订单接口:原列表、三个新列表、订单详情、购买预检对企业账号返回无权限(下单、取消已不允许企业账号)。修复现有问题:企业账号调用订单列表与详情不受任何数据范围限制,能看到全部订单。企业账号查看自己资产的订单继续使用 `GET /api/admin/assets/{identifier}/orders`。
- 订单详情、取消、购买预检、后台下单的其他行为不变。
## Capabilities
### New Capabilities
- `admin-order-category-list`:后台订单按类别(平台、代理商、在线支付)分接口查询与分场景导出;三类互斥且覆盖全部订单;权限、数据范围、筛选条件沿用原订单列表与原订单导出。
### Modified Capabilities
(无)原订单列表、原订单导出与统一时间筛选的需求不变;新接口与新场景沿用原有契约,在新能力中写明。
## Impact
- 代码:`internal/routes/order.go`(三个路由,须注册在 `/orders/:id` 之前)、`internal/handler/admin/order.go`、`internal/service/order/service.go`、`internal/store/postgres/order_store.go`、`internal/exporter/order_scene.go`、`internal/exporter/registry.go`、`internal/exporter/time_filters.go`、`internal/routes/export_task.go`、`pkg/constants`;类别条件放在列表与导出共用的 `internal/query/ordercategory`。
- 数据库:不新增字段,不需要迁移,不改历史数据;按现有 `source`、`payment_method` 判断类别。
- 接口:新增 3 个 GET 接口与 3 个导出场景,原接口与原场景对超管、平台、代理不变;企业账号访问 `/orders` 下接口改为无权限(破坏性,仅影响企业账号)。前端在平台订单、代理商订单、在线支付订单三个页面分别调用对应列表接口与导出场景。
- 文档:OpenAPI 生成文档与前端接口变更说明补充三个接口与三个导出场景。

View File

@@ -0,0 +1,103 @@
## ADDED Requirements
### Requirement: 订单类别划分
系统 SHALL 按订单的下单入口与付款来源把每笔订单划为且仅划为以下一类,MUST NOT 按操作人划分:
- 在线支付订单:订单来源为客户端(`source = client`)。
- 代理商订单:订单来源为后台(`source = admin`),且以代理钱包支付(`payment_method = wallet`)。
- 平台订单:订单来源为后台,且不以代理钱包支付(线下支付等,支付方式为空也归此类)。
列表接口与导出场景 MUST 使用同一份类别判断。
#### Scenario: 个人客户在线下单归在线支付订单
- **WHEN** 个人客户在客户端下单,无论以微信、支付宝还是卡钱包支付,或仍为待支付
- **THEN** 该订单归为在线支付订单
#### Scenario: 自动续费与自动购买归在线支付订单
- **WHEN** 自动续费或自动购买任务创建订单(订单来源为客户端)
- **THEN** 该订单归为在线支付订单
#### Scenario: 代理钱包扣款归代理商订单
- **WHEN** 代理账号在后台为自己或下级下单,或代理通过 OpenAPI 下单,均从代理钱包扣款
- **THEN** 该订单归为代理商订单
#### Scenario: 平台代扣代理钱包归代理商订单
- **WHEN** 平台账号在后台以代理钱包支付为代理名下的卡下单,从该代理主钱包扣款
- **THEN** 该订单归为代理商订单,与代理自购同类
#### Scenario: 不走代理钱包的后台订单归平台订单
- **WHEN** 平台账号在后台以线下支付下单,包括平台自营、赠送、代购代理名下的卡
- **THEN** 该订单归为平台订单;平台代购即使买家记为代理也归平台订单
### Requirement: 按类别分接口查询订单列表
系统 SHALL 提供 `GET /api/admin/orders/platform`、`GET /api/admin/orders/agent`、`GET /api/admin/orders/online` 三个列表接口,分别只返回平台订单、代理商订单、在线支付订单。类别由路径固定,接口 MUST NOT 接收订单类别参数。三个接口的查询参数(含 `start_time`/`end_time` 的统一时间筛选契约)、分页、响应结构 SHALL 与 `GET /api/admin/orders` 相同。`GET /api/admin/orders` 保持不变。
#### Scenario: 按路径返回对应类别
- **WHEN** 平台账号调用 `GET /api/admin/orders/online`
- **THEN** 只返回在线支付订单,其他查询条件(支付状态、时间、资产标识、订单号等)照常生效
#### Scenario: 三个接口合起来等于原列表
- **WHEN** 同一账号以相同查询条件分别调用三个新接口与原列表接口
- **THEN** 三个新接口的总数之和等于原列表接口的总数,且同一订单只出现在一个新接口中
#### Scenario: 原列表接口不变
- **WHEN** 调用 `GET /api/admin/orders`
- **THEN** 返回结果、参数与响应与本次变更前一致
#### Scenario: 新路径不被当作订单 ID
- **WHEN** 调用 `GET /api/admin/orders/platform`
- **THEN** 返回平台订单列表,而不是按订单 ID 查询详情;`GET /api/admin/orders/{id}` 仍正常返回订单详情
### Requirement: 按类别分场景导出订单
系统 SHALL 提供导出场景 `order_platform`、`order_agent`、`order_online`,分别导出平台订单、代理商订单、在线支付订单,与三个列表接口一一对应。类别由场景固定,筛选条件中 MUST NOT 需要类别参数。三个场景的筛选条件、时间筛选校验、表头、列内容与数据范围 SHALL 与导出场景 `order` 相同。导出场景 `order` 保持不变。
#### Scenario: 导出条数与列表一致
- **WHEN** 同一账号以相同筛选条件创建 `order_agent` 导出并调用 `GET /api/admin/orders/agent`
- **THEN** 导出行对应的订单与列表接口返回的订单一致,订单数等于列表总数
#### Scenario: 表头与原导出相同
- **WHEN** 创建任一新场景的导出任务
- **THEN** 导出文件表头与 `order` 场景相同
#### Scenario: 时间格式校验与原导出相同
- **WHEN** 新场景的筛选条件携带不符合统一时间筛选契约的 `start_time`/`end_time`
- **THEN** 与 `order` 场景一样在创建导出任务时拒绝
#### Scenario: 原导出场景不变
- **WHEN** 创建 `order` 场景的导出任务
- **THEN** 导出结果与本次变更前一致
### Requirement: 分类别列表与导出沿用原权限与数据范围
三个新列表接口 SHALL 使用与 `GET /api/admin/orders` 相同的认证、用户类型准入与数据范围;三个新导出场景 SHALL 使用与 `order` 场景相同的创建权限与冻结的数据范围。新接口与新场景返回的订单 MUST 是原列表或原导出在相同账号、相同条件下返回订单的子集。
#### Scenario: 代理账号的数据范围不变
- **WHEN** 代理账号调用任一新列表接口或创建任一新导出场景
- **THEN** 只返回原列表或原导出对该代理可见、且属于该类别的订单
#### Scenario: 代理账号的在线支付订单为空
- **WHEN** 代理账号调用 `GET /api/admin/orders/online`
- **THEN** 返回空列表,因为原列表对代理只返回买家是本店铺的订单,在线支付订单的买家是个人客户
#### Scenario: 平台与超管看全部
- **WHEN** 平台账号或超管调用任一新接口
- **THEN** 返回全部属于该类别的订单
### Requirement: 企业账号不能访问后台订单接口
后台订单接口 `GET /api/admin/orders`、`GET /api/admin/orders/platform`、`GET /api/admin/orders/agent`、`GET /api/admin/orders/online`、`GET /api/admin/orders/{id}`、`POST /api/admin/orders/purchase-check` MUST 对企业账号返回无权限错误,不返回任何订单数据。超管、平台、代理账号的行为不变。企业账号查看自己资产的订单 SHALL 继续通过资产订单接口 `GET /api/admin/assets/{identifier}/orders`,该接口按资产数据范围校验。
#### Scenario: 企业账号调用订单列表
- **WHEN** 企业账号调用 `GET /api/admin/orders` 或任一新列表接口
- **THEN** 返回无权限错误,不返回订单
#### Scenario: 企业账号查看订单详情
- **WHEN** 企业账号调用 `GET /api/admin/orders/{id}`
- **THEN** 返回无权限错误,不返回订单详情
#### Scenario: 企业账号查看自己资产的订单
- **WHEN** 企业账号调用 `GET /api/admin/assets/{identifier}/orders` 查询本企业资产
- **THEN** 行为与本次变更前一致

View File

@@ -0,0 +1,33 @@
## 1. 类别定义
- [x] 1.1 在 `pkg/constants/order_category.go` 定义订单类别常量(platform / agent / online);在 `internal/query/ordercategory` 提供按表别名生成类别条件的函数(在线支付 `source=client`;代理商 `source=admin` 且 `payment_method=wallet`;平台为其余后台订单,含支付方式为空)
- [x] 1.2 在 `pkg/constants` 新增导出场景常量 `order_platform`、`order_agent`、`order_online`
## 2. 列表接口
- [x] 2.1 `OrderStore.List` 支持内部过滤键 `order_category`,调用 1.1 的函数(无别名)
- [x] 2.2 `order.Service.List` 增加订单类别入参并写入 filters,原调用传空类别
- [x] 2.3 `OrderHandler` 把原 `List` 的参数解析、时间范围、代理买家条件提取为内部方法 `list(c, category)`,新增 `ListPlatform`、`ListAgent`、`ListOnline`
- [x] 2.4 `OrderHandler` 增加企业账号校验,`List`、`ListPlatform`、`ListAgent`、`ListOnline`、`Get`、`PurchaseCheck` 开头调用,企业账号返回无权限
- [x] 2.5 `internal/routes/order.go` 在 `/orders/:id` 之前注册 `GET /orders/platform`、`/orders/agent`、`/orders/online`;摘要与说明写明类别口径、"参数、响应、权限同原订单列表"、代理账号在线支付订单为空
## 3. 导出场景
- [x] 3.1 `OrderDataSource` 增加类别字段,`NewOrderDataSource(db, category)`;`Scene()` 按类别返回场景,类别为空返回 `order`;`applyFilters` 末尾调用 1.1 的函数(别名 `o`)叠加类别条件
- [x] 3.2 `NewDefaultRegistry` 注册三个类别数据源;`timeFilterScenes` 加入三个新场景(`IsSupportedScene` 无调用方,同步加入保持一致)
- [x] 3.3 `internal/routes/export_task.go` 场景列表说明补充 `order_platform`、`order_agent`、`order_online`
## 4. 构建与文档
- [x] 4.1 `go build ./...` 与 `go vet` 通过,重新生成 OpenAPI 文档,确认三个列表接口参数与响应同原列表
- [x] 4.2 前端接口变更说明补充三个列表接口与三个导出场景(路径、类别口径、与原列表一致的参数与响应、代理账号可见范围)
## 5. 测试环境验证
- [ ] 5.1 平台账号:相同条件下三个新接口总数之和等于原接口总数;抽查平台代扣代理钱包在代理商订单、平台代购在平台订单、自动续费在在线支付订单
- [ ] 5.2 平台账号:新接口叠加支付状态、时间、资产标识、订单号等条件结果正确;原接口结果与变更前一致
- [ ] 5.3 代理账号:三个新接口结果都是原接口结果的子集,总数之和等于原接口;在线支付订单为空
- [ ] 5.4 `GET /orders/:id` 仍正常;`/orders/platform` 等路径不被当作订单 ID
- [ ] 5.5 企业账号:原列表、三个新列表、订单详情、购买预检返回无权限;`GET /assets/{identifier}/orders` 查询本企业资产不受影响(测试环境无企业账号时,造一个本 Change 自己的企业账号,验证后删除)
- [ ] 5.6 导出:平台账号与代理账号用三个新场景导出,订单数与对应列表总数一致,表头同 `order`;`order` 场景结果与变更前一致;新场景传非法时间格式在创建时被拒绝
- [ ] 5.7 上线前补查生产分布(只读):按 `source`、`payment_method` 统计,确认没有 `source` 为其他值的订单、后台订单只有钱包和线下两种支付方式