diff --git a/docs/engineering/后台订单按类别拆分列表技术方案.md b/docs/engineering/后台订单按类别拆分列表技术方案.md new file mode 100644 index 00000000..31770196 --- /dev/null +++ b/docs/engineering/后台订单按类别拆分列表技术方案.md @@ -0,0 +1,242 @@ +# 后台订单按类别拆分列表技术方案 + +最后更新:2026-10-26 +状态:技术方案(未实现)。已定事项与后续事项见第六部分。 + +文档结构: +- 第一部分:现状逻辑(As-Is) +- 第二部分:老卡管对照 +- 第三部分:方案(To-Be) +- 第四部分:改动范围 +- 第五部分:验证 +- 第六部分:已定事项与后续事项 + +--- + +## 第一部分:现状逻辑(As-Is) + +### 1. 接口 + +| 接口 | 实现 | 说明 | +|---|---|---| +| `GET /api/admin/orders` | `OrderHandler.List`(`internal/handler/admin/order.go:90`) | 订单列表,平台、代理商、在线支付三类订单混在一起返回 | +| `GET /api/admin/orders/:id` | `OrderHandler.Get` | 订单详情 | +| `POST /api/admin/orders` | `OrderHandler.Create` | 后台下单 | +| `POST /api/admin/orders/:id/cancel` | `OrderHandler.Cancel` | 取消订单,仅代理账号 | +| `POST /api/admin/orders/purchase-check` | `OrderHandler.PurchaseCheck` | 套餐购买预检 | +| `POST /api/admin/export-tasks`(`scene=order`) | `OrderDataSource`(`internal/exporter/order_scene.go`) | 订单导出,与列表一样三类混在一起 | + +路由在 `internal/routes/order.go`,`GET /orders` 在第 22 行,`GET /orders/:id` 在第 30 行。 + +### 2. 列表的权限与数据范围 + +两层条件,全部在服务端决定,前端不能改: + +1. Handler 层:代理账号固定买家条件 `buyer_type = agent AND buyer_id = 当前店铺`;平台账号与超管不加买家条件。 +2. Store 层:`OrderStore.List`(`internal/store/postgres/order_store.go:85`)叠加 `applyOrderScope`(第 200 行)。代理账号限定为买家、销售店铺或操作者属于本店及下级店铺;平台账号与超管不限。 + +后台认证 `AdminAuth` 允许超管、平台、代理、企业四类账号访问。 + +订单导出(`OrderDataSource.applyFilters`,`order_scene.go:130`)复刻了上面两层条件:创建导出任务时冻结账号类型、店铺范围(`ScopeShopIDs`)与发起店铺(`CreatorShopID`),执行时按冻结值过滤;代理账号同样固定"买家是本店"。导出任务只允许超管、平台、代理创建(`internal/service/export_task/service.go`)。 + +导出场景的接入点: +- 场景常量:`pkg/constants/constants.go:400` `ExportTaskSceneOrder = "order"`。 +- 注册:`exporter.NewDefaultRegistry`(`internal/exporter/registry.go`)按数据源的 `Scene()` 建表,创建任务时用 `IsSupported` 校验场景。 +- 时间筛选:`timeFilterScenes`(`internal/exporter/time_filters.go`)登记了 `order`,创建任务时按统一格式冻结 `start_time`/`end_time`。 + +### 3. 订单的来源与支付方式 + +| 字段 | 取值 | 说明 | +|---|---|---| +| `source` | `admin` 后台 / `client` 客户端 | 非空,默认 `admin`(`pkg/constants/order_source.go`) | +| `payment_method` | `wallet` / `offline` / `wechat` / `alipay` | 后台下单只支持 `wallet` 与 `offline` | +| `buyer_type` | `agent` / `personal` | 买家类型 | +| `operator_account_type`、`operator_type` | `platform` / `agent` / `personal_customer` / 空 | 操作账号类型;历史订单有空值 | + +### 4. 各下单场景 + +后台下单 `CreateAdminOrder`(`internal/service/order/service.go:214`): + +| 场景 | 操作账号 | 支付方式 | 买家 | 扣谁的钱 | 退款方式 | +|---|---|---|---|---|---| +| 平台自营 | 平台 | 线下 | 个人(id 0) | 不扣钱包,线下收款 | 退到客户账户 | +| 赠送 | 平台 | 线下 | 个人(id 0) | 不收钱 | 退到客户账户 | +| 平台代购 | 平台 | 线下 | 资源所属代理 | 不扣钱包,线下收款 | 退到客户账户 | +| 平台代扣代理钱包 | 平台 | 钱包 | 资源所属代理 | 该代理主钱包 | 退回代理钱包 | +| 代理自购 | 代理 | 钱包 | 本代理 | 本代理主钱包 | 退回代理钱包 | +| 代理给下级购买 | 代理 | 钱包 | 下级代理 | 本代理(操作方)主钱包 | 退回代理钱包 | +| 代理 OpenAPI 下单 | 代理 | 钱包 | 同代理后台下单 | 代理主钱包 | 退回代理钱包 | +| 资产套餐批量订购 | 同发起账号 | 同上 | 同上 | 同上 | 同上 | + +客户端下单(`source = client`):个人客户在 H5 下单,用微信、支付宝或卡钱包支付,含待支付订单;自动续费(`internal/application/assetautorenewal/renew.go`)、自动购买(`internal/task/auto_purchase.go`)也记为客户端订单。 + +平台代扣代理钱包与代理自购的扣款钱包、买家、成本价、订单角色(都是 `self_purchase`)、退款方式、佣金基准完全相同,只有操作人字段不同。 + +### 5. 测试环境数据分布(165 笔) + +| 来源 | 支付方式 | 场景 | 笔数 | +|---|---|---|---| +| client | 微信、支付宝、卡钱包、空(待支付) | 个人客户在线下单 | 53 | +| admin | wallet | 代理操作 | 21 | +| admin | wallet | 平台代扣代理钱包 | 31 | +| admin | offline | 平台代购 | 41 | +| admin | offline | 平台自营、赠送等(买家个人) | 19 | + +生产分布未查到(查询时数据库连接工具断开),实施前需补查,见第五部分。 + +### 6. 现状问题 + +- 运营无法按"平台订单 / 代理商订单 / 在线支付订单"分页面查看,只能靠支付方式、订单角色间接筛,而且这两个条件都区分不出"平台代扣代理钱包"和"平台代购"。 + +--- + +## 第二部分:老卡管对照 + +源码 `kyhl-src`(`apps/kyhl-crm`)中三类订单是三张表、三个页面: + +| 老页面 | 表 | 内容 | 谁能看 | +|---|---|---|---| +| 微信订单(`agentWeixinOrder/list`) | `tbl_agent_weixin_order` | C 端公众号订单:微信支付、余额支付(卡钱包)、自动续费 | 平台全部;代理看自己与下级卡上的订单 | +| 代理商订单(平台 `agentOrder/syslist`,代理 `agentOrder/list`) | `tbl_agent_order` | 代理账号在后台给卡加套餐,从代理预存款余额扣款(`CardViewComp.agentRecharge`) | 平台全部;代理只看自己的 | +| 平台加套餐 | 不生成订单 | 平台账号给卡加套餐只写套餐记录(`addCardOrBatchImportPackage`) | — | + +结论:老系统判断是否为代理商订单,看的是"是否扣代理余额",不看操作人。新系统的平台线下订单(自营、赠送、代购)在老系统里没有订单记录。 + +附:老系统首页统计的"平台订单 / 代理商订单"按收款公众号划分(`weixin_agent_id` 是否为空),与列表页口径不同,不作为本方案的依据。 + +--- + +## 第三部分:方案(To-Be) + +### 1. 接口 + +保留 `GET /api/admin/orders`,参数、响应、行为均不变。新增三个列表接口: + +| 接口 | 类别 | +|---|---| +| `GET /api/admin/orders/platform` | 平台订单 | +| `GET /api/admin/orders/agent` | 代理商订单 | +| `GET /api/admin/orders/online` | 在线支付订单 | + +- 类别由路径固定,不接收类型参数。 +- 查询参数、分页、响应结构与原列表相同:入参 `dto.OrderListRequest`,出参 `dto.OrderListResponse`。 +- 认证、用户类型准入、数据范围与原列表相同。 +- 订单导出同样分三类,见本部分第 6 节。 +- 订单详情、取消、购买预检、后台下单不变。 + +### 2. 类别划分:按钱从哪来,不按操作人 + +| 类别 | 条件 | 包含 | +|---|---|---| +| 在线支付订单 | `source = 'client'` | 个人客户微信、支付宝、卡钱包支付,自动续费,自动购买,待支付 | +| 代理商订单 | `source = 'admin' AND payment_method = 'wallet'` | 代理自购、代理给下级购买、平台代扣代理钱包、代理 OpenAPI 下单 | +| 平台订单 | `source = 'admin' AND payment_method <> 'wallet'`(含空) | 平台自营、赠送、平台代购 | + +- `source` 非空且只有两个取值,后台订单按是否用代理钱包支付二分,所以三类互斥且覆盖全部订单。相同条件下,三个新接口的总数之和等于原接口的总数。 +- 不走代理钱包的后台订单一律不进代理商订单接口。平台代购的买家虽然记为代理,但按线下收款、退款走客户账户,归平台订单;后续如有要求再调整。 +- 不依赖操作人字段,历史订单操作人为空也不影响分类。 + +放弃的方案:按操作账号类型划分(`operator_account_type`,为空回退 `operator_type`)。这样会把平台代扣代理钱包归到平台订单,与资金归属和老卡管口径都不一致,还要兜底历史空值。 + +### 3. 实现 + +1. 常量:在 `pkg/constants/order_category.go` 定义订单类别 `platform` / `agent` / `online`。 +2. Store:`OrderStore.List` 支持内部过滤键 `order_category`,类别条件集中在一个函数里,三个接口与导出共用(见第 6 节)。 +3. Service:`order.Service.List` 增加订单类别入参并透传给 Store;原调用传空类别,行为不变。 +4. Handler:把现有 `List` 的参数解析、时间范围解析、代理买家条件提取为内部方法 `list(c, category)`。原 `List` 以空类别调用,新增 `ListPlatform`、`ListAgent`、`ListOnline` 分别以固定类别调用。权限逻辑只有一份。 +5. 路由:三个静态路径必须注册在 `GET /orders/:id`(`internal/routes/order.go:30`)之前,否则会被当作订单 ID 解析,返回"无效的订单ID"。 + +类别不进 DTO,所以请求里即使带 `order_category` 等参数也不会被解析。 + +### 4. 代理账号看到的结果 + +权限不变,代理账号在新接口看到的是原接口结果按类别切开的部分: + +| 接口 | 代理账号能看到 | +|---|---| +| 平台订单 | 平台为本店名下的卡代购的订单 | +| 代理商订单 | 买家是本店的钱包订单:本店自购、平台代扣本店钱包、上级给本店购买 | +| 在线支付订单 | 始终为空:原接口对代理只返回买家是本店的订单,客户端订单的买家是个人客户 | + +代理在"给下级购买"时,订单买家是下级,原接口对该代理不可见,新接口也不可见。 + +### 4.1 企业账号不能访问订单接口(修复现有问题) + +现状:后台认证允许企业账号,原列表对非代理账号不加买家条件,店铺范围对企业账号也不生效,接口权限中间件未接入路由,所以企业账号调用 `GET /orders` 与 `GET /orders/:id` 会看到全部订单。下单已拒绝企业账号,取消只允许代理,导出任务不允许企业账号创建。 + +做法:`OrderHandler` 增加企业账号校验,原列表、三个新列表、订单详情、购买预检对企业账号返回无权限。校验放在 Handler,不放 Store 的 `applyOrderScope`,因为后者被资产订单查询等其他场景共用。企业账号查看自己资产的订单继续用 `GET /assets/{identifier}/orders`(按资产数据范围校验),不受影响。 + +这是本方案中唯一的破坏性变更,只影响企业账号。 + +### 5. 性能 + +类别条件只用 `source` 与 `payment_method`,不加专用索引。实施后在测试环境看执行计划;生产订单量明显增长时,再评估 `(source, payment_method, created_at)` 组合索引。 + +### 6. 导出分三类 + +保留 `scene=order`,行为不变。新增三个导出场景,与三个列表接口一一对应: + +| 场景 | 对应列表 | +|---|---| +| `order_platform` | `GET /orders/platform` | +| `order_agent` | `GET /orders/agent` | +| `order_online` | `GET /orders/online` | + +- 与列表一致:类别由场景决定,`filters` 里不加类别参数;筛选条件、表头、列内容、数据范围与 `scene=order` 完全相同,只多一个固定的类别条件。 +- 用新场景而不是给 `order` 加参数,原因是导出本来就靠 `scene` 区分数据,和列表"路径决定类别"是同一个思路,前端每个页面各传自己的场景即可。 + +实现: + +1. 场景常量:在 `pkg/constants` 新增 `ExportTaskSceneOrderPlatform`、`ExportTaskSceneOrderAgent`、`ExportTaskSceneOrderOnline`。 +2. 数据源:`OrderDataSource` 增加类别字段,`NewOrderDataSource(db, category)`;`Scene()` 按类别返回场景,类别为空时返回 `order`;`applyFilters` 在现有条件后叠加类别条件。`Count`、`Fetch` 共用 `applyFilters`,条数与内容自动一致。 +3. 类别条件只写一份:放在 `internal/query/ordercategory`,按表别名生成条件(导出查询用别名 `o`,Store 无别名),列表与导出共用,保证口径一致。与不轮询清单共用 `internal/query/pollingpause` 的做法一致。 +4. 注册:`NewDefaultRegistry` 再注册三个类别的 `OrderDataSource`;`timeFilterScenes` 加入三个新场景,时间格式校验与 `order` 相同;`IsSupportedScene` 同步加入。 +5. 导出任务创建接口的场景说明(`internal/routes/export_task.go`)补充三个新场景。 + +--- + +## 第四部分:改动范围 + +| 文件 | 改动 | +|---|---| +| `pkg/constants`(订单相关常量文件) | 新增订单类别常量 | +| `internal/store/postgres/order_store.go` | `List` 支持 `order_category` | +| `internal/service/order/service.go` | `List` 透传订单类别 | +| `internal/handler/admin/order.go` | 提取内部列表方法,新增三个 Handler;企业账号校验 | +| `internal/routes/order.go` | 注册三个路由,写明类别口径和"参数、响应、权限同原订单列表" | +| `pkg/constants/constants.go` | 新增三个订单导出场景常量 | +| `internal/exporter/order_scene.go` | 数据源支持类别,叠加类别条件 | +| `internal/exporter/registry.go`、`internal/exporter/time_filters.go` | 注册三个新场景,纳入时间筛选 | +| `internal/routes/export_task.go` | 场景列表说明补充三个新场景 | +| OpenAPI 文档、前端接口变更说明 | 补充三个列表接口与三个导出场景 | + +- 不新增字段,不需要迁移,不改历史数据。 +- 原导出场景 `order` 不变;不改订单详情、取消、下单。 +- 无自动化测试(项目无 `*_test.go`),在测试环境验证。 + +--- + +## 第五部分:验证(测试环境) + +1. 平台账号,相同条件调用三个新接口与原接口:三个新接口的总数之和等于原接口总数;抽查平台代扣代理钱包在代理商订单、平台代购在平台订单、自动续费在在线支付订单。 +2. 平台账号,叠加支付状态、时间、资产标识、订单号等原有条件,结果正确;原接口结果与上线前一致。 +3. 代理账号,三个新接口的结果都是原接口结果的子集,总数之和等于原接口;在线支付订单为空。 +4. `GET /orders/:id` 仍正常;`/orders/platform` 等路径不会被当作订单 ID。 +4.1 企业账号调用原列表、三个新列表、订单详情、购买预检返回无权限;`GET /assets/{identifier}/orders` 查询本企业资产不受影响。 +5. 导出:平台账号与代理账号分别用三个新场景导出,条数与对应列表接口的总数一致,表头与 `scene=order` 相同;`scene=order` 导出结果与上线前一致;新场景传旧时间格式时与 `order` 一样被拒绝。 +6. 上线前补查生产分布:按 `source`、`payment_method` 统计,确认没有 `source` 为其他值的订单、后台订单只有钱包和线下两种支付方式。 + +--- + +## 第六部分:已定事项与后续事项 + +已定: +- 原接口保留,新接口不传类型参数;超管、平台、代理的权限与数据范围不变。 +- 企业账号不能访问 `/orders` 下的列表、详情、购买预检,修复其可看到全部订单的问题。 +- 按钱从哪来划分:代理钱包扣款的后台订单(含平台代扣)归代理商订单;不走代理钱包的后台订单归平台订单。 +- 导出也分三类:新增 `order_platform`、`order_agent`、`order_online` 三个场景,原 `order` 场景保留。 + +后续(有要求再做): +- 平台代购是否改归代理商订单。 +- 代理账号是否需要在在线支付订单接口看到自己资产上的个人客户订单(属于权限调整)。 +- 在线支付订单是否增加老卡管微信订单页的"订单来源、收款方"筛选。 diff --git a/internal/exporter/order_scene.go b/internal/exporter/order_scene.go index de293d53..f6e0e87f 100644 --- a/internal/exporter/order_scene.go +++ b/internal/exporter/order_scene.go @@ -9,6 +9,7 @@ import ( "gorm.io/gorm" "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/query/ordercategory" "github.com/break/junhong_cmp_fiber/pkg/constants" ) @@ -20,19 +21,30 @@ const ( orderExportOperatorHeader = "操作人" ) -// OrderDataSource 订单导出数据源。 +// OrderDataSource 订单导出数据源。category 为空时导出全部订单(场景 order), +// 非空时只导出该类别的订单(场景 order_platform / order_agent / order_online),其余逻辑完全相同。 type OrderDataSource struct { - db *gorm.DB + db *gorm.DB + category string } -// NewOrderDataSource 创建订单导出数据源。 -func NewOrderDataSource(db *gorm.DB) *OrderDataSource { - return &OrderDataSource{db: db} +// NewOrderDataSource 创建订单导出数据源;category 为空表示不限类别。 +func NewOrderDataSource(db *gorm.DB, category string) *OrderDataSource { + return &OrderDataSource{db: db, category: category} } // Scene 返回导出场景编码。 func (s *OrderDataSource) Scene() string { - return constants.ExportTaskSceneOrder + switch s.category { + case constants.OrderCategoryPlatform: + return constants.ExportTaskSceneOrderPlatform + case constants.OrderCategoryAgent: + return constants.ExportTaskSceneOrderAgent + case constants.OrderCategoryOnline: + return constants.ExportTaskSceneOrderOnline + default: + return constants.ExportTaskSceneOrder + } } // Count 统计订单导出行数。 @@ -129,6 +141,14 @@ func (s *OrderDataSource) baseQuery(ctx context.Context) *gorm.DB { // is_expired 在接口 Service 中未进入 Store 筛选,这里按当前接口行为忽略。 func (s *OrderDataSource) applyFilters(ctx context.Context, query *gorm.DB, params ExportParams) (*gorm.DB, error) { query = s.applyOrderScope(query, params) + // 订单类别与列表共用同一份条件,导出条数与对应列表一致。 + if s.category != "" { + condition, args, ok := ordercategory.Condition("o", s.category) + if !ok { + return nil, fmt.Errorf("不支持的订单类别: %s", s.category) + } + query = query.Where(condition, args...) + } if params.UserType == constants.UserTypeAgent { if params.CreatorShopID != nil { diff --git a/internal/exporter/registry.go b/internal/exporter/registry.go index 854f5d1a..f6d95634 100644 --- a/internal/exporter/registry.go +++ b/internal/exporter/registry.go @@ -33,7 +33,10 @@ func NewDefaultRegistry(db *gorm.DB) *Registry { return NewRegistry( NewDeviceDataSource(db), NewIotCardDataSource(db), - NewOrderDataSource(db), + NewOrderDataSource(db, ""), + NewOrderDataSource(db, constants.OrderCategoryPlatform), + NewOrderDataSource(db, constants.OrderCategoryAgent), + NewOrderDataSource(db, constants.OrderCategoryOnline), NewPackageDataSource(db), NewAgentWalletTransactionDataSource(db), NewAgentRechargeDataSource(db), @@ -78,6 +81,9 @@ func IsSupportedScene(scene string) bool { case constants.ExportTaskSceneDevice, constants.ExportTaskSceneIotCard, constants.ExportTaskSceneOrder, + constants.ExportTaskSceneOrderPlatform, + constants.ExportTaskSceneOrderAgent, + constants.ExportTaskSceneOrderOnline, constants.ExportTaskScenePackage, constants.ExportTaskSceneAgentWalletTransaction, constants.ExportTaskSceneAgentRecharge, diff --git a/internal/exporter/time_filters.go b/internal/exporter/time_filters.go index 226eda71..ed67f5c5 100644 --- a/internal/exporter/time_filters.go +++ b/internal/exporter/time_filters.go @@ -22,6 +22,9 @@ var timeFilterScenes = map[string]struct{}{ constants.ExportTaskSceneExchange: {}, constants.ExportTaskSceneAgentRecharge: {}, constants.ExportTaskSceneOrder: {}, + constants.ExportTaskSceneOrderPlatform: {}, + constants.ExportTaskSceneOrderAgent: {}, + constants.ExportTaskSceneOrderOnline: {}, constants.ExportTaskSceneCommissionRecord: {}, constants.ExportTaskSceneExpiringAsset: {}, constants.ExportTaskScenePackageTrafficAlert: {}, diff --git a/internal/handler/admin/order.go b/internal/handler/admin/order.go index 90a3ad34..7f9fdd14 100644 --- a/internal/handler/admin/order.go +++ b/internal/handler/admin/order.go @@ -74,6 +74,9 @@ func (h *OrderHandler) Create(c *fiber.Ctx) error { } func (h *OrderHandler) Get(c *fiber.Ctx) error { + if err := rejectEnterpriseOrderAccess(c); err != nil { + return err + } id, err := strconv.ParseUint(c.Params("id"), 10, 64) if err != nil { return errors.New(errors.CodeInvalidParam, "无效的订单ID") @@ -87,7 +90,32 @@ func (h *OrderHandler) Get(c *fiber.Ctx) error { return response.Success(c, order) } +// List 获取订单列表(不限类别)。 func (h *OrderHandler) List(c *fiber.Ctx) error { + return h.list(c, "") +} + +// ListPlatform 获取平台订单列表:后台下单且不走代理钱包。 +func (h *OrderHandler) ListPlatform(c *fiber.Ctx) error { + return h.list(c, constants.OrderCategoryPlatform) +} + +// ListAgent 获取代理商订单列表:后台下单且从代理钱包扣款。 +func (h *OrderHandler) ListAgent(c *fiber.Ctx) error { + return h.list(c, constants.OrderCategoryAgent) +} + +// ListOnline 获取在线支付订单列表:客户端下单。 +func (h *OrderHandler) ListOnline(c *fiber.Ctx) error { + return h.list(c, constants.OrderCategoryOnline) +} + +// list 是订单列表的唯一实现:参数解析、时间范围与代理买家条件只有一份, +// 按类别拆分的接口只额外固定订单类别,权限与数据范围与原列表一致。 +func (h *OrderHandler) list(c *fiber.Ctx, category string) error { + if err := rejectEnterpriseOrderAccess(c); err != nil { + return err + } var req dto.OrderListRequest if err := c.QueryParser(&req); err != nil { return errors.New(errors.CodeInvalidParam, "请求参数解析失败") @@ -112,7 +140,7 @@ func (h *OrderHandler) List(c *fiber.Ctx) error { buyerID = 0 } - orders, err := h.service.List(ctx, &req, startTime, endTime, buyerType, buyerID) + orders, err := h.service.List(ctx, &req, startTime, endTime, buyerType, buyerID, category) if err != nil { return err } @@ -146,6 +174,9 @@ func (h *OrderHandler) Cancel(c *fiber.Ctx) error { // 参数: order_type(订单类型), resource_id(资源ID), package_ids(套餐ID列表) // 响应: 套餐总价、强充要求、实际支付金额等预检结果 func (h *OrderHandler) PurchaseCheck(c *fiber.Ctx) error { + if err := rejectEnterpriseOrderAccess(c); err != nil { + return err + } var req dto.PurchaseCheckRequest if err := c.BodyParser(&req); err != nil { return errors.New(errors.CodeInvalidParam, "请求参数解析失败") @@ -158,3 +189,12 @@ func (h *OrderHandler) PurchaseCheck(c *fiber.Ctx) error { return response.Success(c, result) } + +// rejectEnterpriseOrderAccess 拒绝企业账号访问后台订单接口:后台对企业账号不加订单数据范围, +// 放行会看到全部订单。企业账号的下单与取消已由各自接口拒绝。 +func rejectEnterpriseOrderAccess(c *fiber.Ctx) error { + if middleware.GetUserTypeFromContext(c.UserContext()) == constants.UserTypeEnterprise { + return errors.New(errors.CodeForbidden, "企业账号无权访问订单") + } + return nil +} diff --git a/internal/query/ordercategory/condition.go b/internal/query/ordercategory/condition.go new file mode 100644 index 00000000..88c72763 --- /dev/null +++ b/internal/query/ordercategory/condition.go @@ -0,0 +1,35 @@ +// Package ordercategory 定义后台订单类别的查询条件,供订单列表与订单导出共用,保证两边口径一致 +// (split-admin-order-list-by-category)。 +package ordercategory + +import ( + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// Condition 按表别名生成订单类别条件;alias 为空时不加前缀。类别不受支持时 ok 为 false。 +// +// 按钱从哪来划分,不按操作人: +// - 在线支付:客户端下单; +// - 代理商:后台下单且以代理钱包支付(含平台代扣代理钱包); +// - 平台:后台下单且不走代理钱包(线下支付,支付方式为空也归此类,含平台代购)。 +func Condition(alias, category string) (string, []any, bool) { + prefix := "" + if alias != "" { + prefix = alias + "." + } + source := prefix + "source" + paymentMethod := prefix + "payment_method" + switch category { + case constants.OrderCategoryOnline: + return source + " = ?", []any{constants.OrderSourceClient}, true + case constants.OrderCategoryAgent: + return source + " = ? AND " + paymentMethod + " = ?", + []any{constants.OrderSourceAdmin, model.PaymentMethodWallet}, true + case constants.OrderCategoryPlatform: + return source + " = ? AND (" + paymentMethod + " IS NULL OR " + paymentMethod + " <> ?)", + []any{constants.OrderSourceAdmin, model.PaymentMethodWallet}, true + default: + return "", nil, false + } +} diff --git a/internal/routes/export_task.go b/internal/routes/export_task.go index 5178055f..f8e3f2e3 100644 --- a/internal/routes/export_task.go +++ b/internal/routes/export_task.go @@ -14,8 +14,9 @@ func registerExportTaskRoutes(router fiber.Router, handler *admin.ExportTaskHand Register(exportTasks, doc, groupPath, "POST", "", handler.Create, RouteSpec{ Summary: "创建导出任务", - Description: "创建统一导出任务,支持场景 scene=device/iot_card/order/package/agent_wallet_transaction/agent_recharge/refund/exchange/commission_record/package_traffic_alert/expiring_asset/operations_activation/operations_renewal/stop_resume_rule_dry_run/polling_pause/polling_stop_refused_alert 和格式 format=xlsx/csv。" + + Description: "创建统一导出任务,支持场景 scene=device/iot_card/order/order_platform/order_agent/order_online/package/agent_wallet_transaction/agent_recharge/refund/exchange/commission_record/package_traffic_alert/expiring_asset/operations_activation/operations_renewal/stop_resume_rule_dry_run/polling_pause/polling_stop_refused_alert 和格式 format=xlsx/csv。" + "polling_pause 的 query.filters 同 GET /card-polling-pauses(asset_type、carrier_id、source、status、identifier、operator_id、start_time、end_time);polling_stop_refused_alert 的 query.filters 为 carrier_id、status、iccid、start_time、end_time;两者仅超级管理员与平台账号可导出。device、iot_card 场景支持 enable_polling 筛选,导出最后一列为「是否轮询」。" + + "order_platform、order_agent、order_online 分别导出平台订单、代理商订单、在线支付订单(与 GET /orders/platform、/orders/agent、/orders/online 一一对应),query.filters、表头与数据范围同 order,不接收类别参数。" + "受影响场景的时间筛选固定为 query.filters.start_time 与 query.filters.end_time,取值必须为带显式时区的 RFC3339 秒级时间,创建时规范化为 UTC 秒级字符串后冻结。", Tags: []string{"导出任务"}, Input: new(dto.CreateExportTaskRequest), diff --git a/internal/routes/order.go b/internal/routes/order.go index 7f164971..ed6d4708 100644 --- a/internal/routes/order.go +++ b/internal/routes/order.go @@ -20,19 +20,49 @@ func registerAdminOrderRoutes(router fiber.Router, handler *admin.OrderHandler, }) Register(router, doc, basePath, "GET", "/orders", handler.List, RouteSpec{ - Summary: "获取订单列表", - Tags: []string{"订单管理"}, - Input: new(dto.OrderListRequest), - Output: new(dto.OrderListResponse), - Auth: true, + Summary: "获取订单列表", + Description: "平台订单、代理商订单、在线支付订单全部返回;按类别查看请用 `/orders/platform`、`/orders/agent`、`/orders/online`。代理账号只看到买家是本店铺的订单。企业账号无权访问。", + Tags: []string{"订单管理"}, + Input: new(dto.OrderListRequest), + Output: new(dto.OrderListResponse), + Auth: true, + }) + + // 按类别拆分的列表是静态路径,必须注册在 /orders/:id 之前,否则会被当作订单 ID 解析。 + Register(router, doc, basePath, "GET", "/orders/platform", handler.ListPlatform, RouteSpec{ + Summary: "获取平台订单列表", + Description: "平台订单:后台下单且不走代理钱包(线下支付),含平台自营、赠送、平台代购代理名下的卡。查询参数、分页、响应结构、权限与数据范围与 `GET /orders` 相同,只额外固定订单类别,不接收类别参数。代理账号只看到买家是本店铺的订单。企业账号无权访问。", + Tags: []string{"订单管理"}, + Input: new(dto.OrderListRequest), + Output: new(dto.OrderListResponse), + Auth: true, + }) + + Register(router, doc, basePath, "GET", "/orders/agent", handler.ListAgent, RouteSpec{ + Summary: "获取代理商订单列表", + Description: "代理商订单:后台下单且从代理钱包扣款,含代理自购、代理给下级购买、平台代扣代理钱包、代理 OpenAPI 下单。查询参数、分页、响应结构、权限与数据范围与 `GET /orders` 相同,只额外固定订单类别,不接收类别参数。代理账号只看到买家是本店铺的订单。企业账号无权访问。", + Tags: []string{"订单管理"}, + Input: new(dto.OrderListRequest), + Output: new(dto.OrderListResponse), + Auth: true, + }) + + Register(router, doc, basePath, "GET", "/orders/online", handler.ListOnline, RouteSpec{ + Summary: "获取在线支付订单列表", + Description: "在线支付订单:个人客户在客户端下的全部订单,含微信、支付宝、卡钱包支付、自动续费、自动购买与待支付订单。查询参数、分页、响应结构、权限与数据范围与 `GET /orders` 相同,只额外固定订单类别,不接收类别参数。代理账号只看到买家是本店铺的订单。企业账号无权访问。在线支付订单的买家是个人客户,代理账号调用结果为空。", + Tags: []string{"订单管理"}, + Input: new(dto.OrderListRequest), + Output: new(dto.OrderListResponse), + Auth: true, }) Register(router, doc, basePath, "GET", "/orders/:id", handler.Get, RouteSpec{ - Summary: "获取订单详情", - Tags: []string{"订单管理"}, - Input: new(dto.GetOrderRequest), - Output: new(dto.OrderDetailResponse), - Auth: true, + Summary: "获取订单详情", + Description: "企业账号无权访问。", + Tags: []string{"订单管理"}, + Input: new(dto.GetOrderRequest), + Output: new(dto.OrderDetailResponse), + Auth: true, }) Register(router, doc, basePath, "POST", "/orders/:id/cancel", handler.Cancel, RouteSpec{ @@ -44,11 +74,12 @@ func registerAdminOrderRoutes(router fiber.Router, handler *admin.OrderHandler, }) Register(router, doc, basePath, "POST", "/orders/purchase-check", handler.PurchaseCheck, RouteSpec{ - Summary: "套餐购买预检", - Tags: []string{"订单管理"}, - Input: new(dto.PurchaseCheckRequest), - Output: new(dto.PurchaseCheckResponse), - Auth: true, + Summary: "套餐购买预检", + Description: "企业账号无权访问。", + Tags: []string{"订单管理"}, + Input: new(dto.PurchaseCheckRequest), + Output: new(dto.PurchaseCheckResponse), + Auth: true, }) } diff --git a/internal/service/order/service.go b/internal/service/order/service.go index 9da04c1c..afc7e91f 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -1370,7 +1370,8 @@ func (s *Service) GetDetail(ctx context.Context, id uint) (*dto.OrderDetailRespo return &dto.OrderDetailResponse{OrderResponse: resp, Payments: payments}, nil } -func (s *Service) List(ctx context.Context, req *dto.OrderListRequest, startTime, endTime *time.Time, buyerType string, buyerID uint) (*dto.OrderListResponse, error) { +// List 查询后台订单列表。category 为空表示不限类别(原订单列表),非空时只返回该类别的订单。 +func (s *Service) List(ctx context.Context, req *dto.OrderListRequest, startTime, endTime *time.Time, buyerType string, buyerID uint, category string) (*dto.OrderListResponse, error) { page := req.Page pageSize := req.PageSize if page == 0 { @@ -1386,6 +1387,9 @@ func (s *Service) List(ctx context.Context, req *dto.OrderListRequest, startTime } filters := map[string]any{} + if category != "" { + filters["order_category"] = category + } // 仅代理用户需要限定买家范围;平台/超管不加限制,可查看所有订单 if buyerType != "" { filters["buyer_type"] = buyerType diff --git a/internal/store/postgres/order_store.go b/internal/store/postgres/order_store.go index e62dd8dc..071b4421 100644 --- a/internal/store/postgres/order_store.go +++ b/internal/store/postgres/order_store.go @@ -7,7 +7,9 @@ import ( "time" "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/query/ordercategory" "github.com/break/junhong_cmp_fiber/internal/store" + "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/middleware" "github.com/redis/go-redis/v9" "gorm.io/gorm" @@ -90,6 +92,15 @@ func (s *OrderStore) List(ctx context.Context, opts *store.QueryOptions, filters query = s.applyOrderScope(ctx, query) + // 订单类别是内部过滤键,由后台按类别拆分的列表接口固定写入,不来自请求参数。 + if v, ok := filters["order_category"].(string); ok && v != "" { + condition, args, valid := ordercategory.Condition("", v) + if !valid { + return nil, 0, errors.New(errors.CodeInvalidParam, "不支持的订单类别") + } + query = query.Where(condition, args...) + } + if v, ok := filters["payment_status"]; ok { query = query.Where("payment_status = ?", v) } diff --git a/openspec/changes/split-admin-order-list-by-category/.openspec.yaml b/openspec/changes/split-admin-order-list-by-category/.openspec.yaml new file mode 100644 index 00000000..d58ba023 --- /dev/null +++ b/openspec/changes/split-admin-order-list-by-category/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-26 diff --git a/openspec/changes/split-admin-order-list-by-category/design.md b/openspec/changes/split-admin-order-list-by-category/design.md new file mode 100644 index 00000000..0790caf1 --- /dev/null +++ b/openspec/changes/split-admin-order-list-by-category/design.md @@ -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 + +- 平台代购是否改归代理商订单——有要求再做。 +- 代理账号是否需要在在线支付订单接口看到自己资产上的个人客户订单——属于权限调整,有要求再做。 diff --git a/openspec/changes/split-admin-order-list-by-category/proposal.md b/openspec/changes/split-admin-order-list-by-category/proposal.md new file mode 100644 index 00000000..90167cdd --- /dev/null +++ b/openspec/changes/split-admin-order-list-by-category/proposal.md @@ -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 生成文档与前端接口变更说明补充三个接口与三个导出场景。 diff --git a/openspec/changes/split-admin-order-list-by-category/specs/admin-order-category-list/spec.md b/openspec/changes/split-admin-order-list-by-category/specs/admin-order-category-list/spec.md new file mode 100644 index 00000000..7bbac57b --- /dev/null +++ b/openspec/changes/split-admin-order-list-by-category/specs/admin-order-category-list/spec.md @@ -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** 行为与本次变更前一致 diff --git a/openspec/changes/split-admin-order-list-by-category/tasks.md b/openspec/changes/split-admin-order-list-by-category/tasks.md new file mode 100644 index 00000000..c439499a --- /dev/null +++ b/openspec/changes/split-admin-order-list-by-category/tasks.md @@ -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` 为其他值的订单、后台订单只有钱包和线下两种支付方式 diff --git a/pkg/constants/order_category.go b/pkg/constants/order_category.go new file mode 100644 index 00000000..f615a591 --- /dev/null +++ b/pkg/constants/order_category.go @@ -0,0 +1,25 @@ +package constants + +// 后台订单类别:按下单入口与付款来源划分,三类互斥且覆盖全部订单(split-admin-order-list-by-category)。 +const ( + OrderCategoryPlatform = "platform" // 平台订单:后台下单且不走代理钱包(线下) + OrderCategoryAgent = "agent" // 代理商订单:后台下单且从代理钱包扣款 + OrderCategoryOnline = "online" // 在线支付订单:客户端下单 +) + +// 按订单类别拆分的订单导出场景,与 /orders/platform、/orders/agent、/orders/online 一一对应。 +const ( + ExportTaskSceneOrderPlatform = "order_platform" // 导出场景:平台订单 + ExportTaskSceneOrderAgent = "order_agent" // 导出场景:代理商订单 + ExportTaskSceneOrderOnline = "order_online" // 导出场景:在线支付订单 +) + +// IsOrderCategory 判断是否为受支持的订单类别。 +func IsOrderCategory(category string) bool { + switch category { + case OrderCategoryPlatform, OrderCategoryAgent, OrderCategoryOnline: + return true + default: + return false + } +}