diff --git a/README.md b/README.md index c780e12..6360daa 100644 --- a/README.md +++ b/README.md @@ -890,6 +890,7 @@ rdb.Set(ctx, key, status, time.Hour) - **[限流指南](docs/rate-limiting.md)**:全面的限流配置和使用 - **[错误处理使用指南](docs/003-error-handling/使用指南.md)**:错误码参考、Handler 使用、客户端处理、最佳实践 - **[错误处理架构说明](docs/003-error-handling/架构说明.md)**:架构设计、性能优化、扩展性说明 +- **[订单操作者与产业链佣金语义修复](docs/feature-001-order-operator-and-chain-commission-semantics/功能总结.md)**:说明订单操作者账号化、佣金流程状态与业务结果拆分、以及手工验收口径 ### 架构设计 diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index 72f29e6..45e5e84 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -1123,7 +1123,7 @@ components: DtoAssetResolveResponse: properties: activated_at: - description: 激活时间 + description: 激活时间(未来准备移除,请勿依赖) format: date-time nullable: true type: string @@ -3440,6 +3440,12 @@ components: network_status: description: 网络状态:0停机 1开机(asset_type=card时有效) type: integer + real_name_status: + description: 实名状态 (0:未实名, 1:已实名) + type: integer + real_name_status_name: + description: 实名状态名称(中文) + type: string realname_policy: description: 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名) type: string @@ -5449,11 +5455,17 @@ components: commission_config_version: description: 佣金配置版本 type: integer + commission_result: + description: 佣金业务结果 (0:未知, 1:有佣金, 2:无佣金, 3:链路异常) + type: integer + commission_result_name: + description: 佣金业务结果名称(中文) + type: string commission_status: - description: 佣金状态 (1:待计算, 2:已计算) + description: 佣金流程状态 (1:待计算, 2:已完成, 3:待人工处理) type: integer commission_status_name: - description: 佣金状态名称(中文) + description: 佣金流程状态名称(中文) type: string created_at: description: 创建时间 @@ -5494,15 +5506,15 @@ components: nullable: true type: array operator_id: - description: 操作者ID + description: 操作者账号ID(真实发起操作的账号/主体ID) minimum: 0 nullable: true type: integer operator_name: - description: 操作者名称 + description: 操作者名称(按账号语义展示) type: string operator_type: - description: 操作者类型 (platform:平台, agent:代理) + description: 操作者账号类型 (platform:平台, agent:代理, enterprise:企业, personal_customer:个人客户) type: string order_no: description: 订单号 @@ -5533,6 +5545,14 @@ components: purchase_role: description: 订单角色 (self_purchase:自己购买, purchased_by_parent:上级代理购买, purchased_by_platform:平台代购, purchase_for_subordinate:给下级购买) type: string + seller_shop_id: + description: 销售店铺ID(业务归属字段) + minimum: 0 + nullable: true + type: integer + seller_shop_name: + description: 销售店铺名称(业务归属展示) + type: string total_amount: description: 订单总金额(分) type: integer @@ -6741,11 +6761,17 @@ components: type: object DtoSetSwitchModeRequest: properties: + iot_card_id: + description: 目标卡资产ID(必须是系统内状态正常且已实名的卡) + minimum: 1 + type: integer switch_mode: description: 切卡模式 (0:自动切卡, 1:手动切卡) + nullable: true type: integer required: - switch_mode + - iot_card_id type: object DtoSetWiFiRequest: properties: @@ -13385,7 +13411,7 @@ paths: - 设备管理 /api/admin/devices/by-identifier/{identifier}/switch-mode: post: - description: 通过虚拟号/IMEI/SN 设置设备切卡模式(0:自动切卡, 1:手动切卡)。设备必须已配置 IMEI。 + description: 通过虚拟号/IMEI/SN 设置设备切卡模式(0:自动切卡, 1:手动切卡)。设备必须已配置 IMEI,且必须指定一张系统内状态正常并已实名的目标卡资产ID。 parameters: - description: 设备标识符(支持虚拟号/IMEI/SN) in: path diff --git a/docs/asset-detail-refactor-api-changes.md b/docs/asset-detail-refactor-api-changes.md index 9e0672a..0364a25 100644 --- a/docs/asset-detail-refactor-api-changes.md +++ b/docs/asset-detail-refactor-api-changes.md @@ -80,7 +80,7 @@ | `virtual_used_mb` | float64 | 当前主套餐展示已用量 MB | | `reduction_pct` | float64 | 展示增幅比例,公式为 `(real_total_mb / virtual_total_mb) - 1` | | `device_protect_status` | string | 保护期状态:`none` / `stop` / `start`(仅 device) | -| `activated_at` | time | 激活时间 | +| `activated_at` | time | 激活时间(未来准备移除,请勿依赖) | | `created_at` | time | 创建时间 | | `updated_at` | time | 更新时间 | | **绑定关系(card 时)** | | | diff --git a/docs/feature-001-order-operator-and-chain-commission-semantics/兼容映射清单.md b/docs/feature-001-order-operator-and-chain-commission-semantics/兼容映射清单.md new file mode 100644 index 0000000..faed748 --- /dev/null +++ b/docs/feature-001-order-operator-and-chain-commission-semantics/兼容映射清单.md @@ -0,0 +1,41 @@ +## 订单操作者与佣金语义兼容映射清单 + +### 1. 历史字段现状 + +- `tb_order.operator_id`:历史上混用了“真实操作者账号 ID”和“代理店铺 ID”两种语义。 +- `tb_order.operator_type`:历史上仅承载 `platform/agent`,且与 `operator_id` 的 ID 体系并不稳定一致。 +- `tb_order.creator`:后台订单通常可回溯真实账号,但旧响应逻辑没有统一优先使用该字段补齐平台操作者展示。 +- `tb_order.commission_status`:历史上仅有 `1=待计算`、`2=已计算`,无法表达“无佣金”和“链路异常待人工处理”。 + +### 2. 新增权威字段 + +- `operator_account_id`:真实操作者账号/主体 ID。 +- `operator_account_type`:真实操作者类型,使用 `platform/agent/enterprise/personal_customer` 语义。 +- `operator_account_name`:真实操作者名称快照。 +- `commission_result`:佣金业务结果,使用 `0=未知 1=有佣金 2=无佣金 3=链路异常`。 + +### 3. 写路径兼容策略 + +- 新订单统一写入 `operator_account_*` 权威字段。 +- 保留写入 legacy `operator_id/operator_type`,但仅作为兼容读取兜底,不再作为新语义权威来源。 +- `seller_shop_id` 继续作为差价佣金链路起点,不再从 `operator_id/operator_type` 推导佣金归属。 + +### 4. 读路径兼容策略 + +- 响应层优先读取 `operator_account_*`。 +- 若新字段为空: + - 优先尝试 `creator` 回填后台账号名称。 + - 对客户端个人客户订单,回填个人客户昵称。 + - 对历史代理订单,若 `operator_id/operator_type` 仍承载店铺语义,则回退展示店铺名称,但不回写新字段。 +- `commission_result` 为空时,按历史订单状态与佣金记录兜底推断,不直接篡改旧列值。 + +### 5. 高风险兼容点 + +- `internal/store/postgres/order_store.go` 中旧逻辑把 `operator_id IN subordinateShopIDs` 作为代理权限过滤条件;当 `operator_id` 不再表示店铺 ID 后,该条件必须迁移到 `seller_shop_id`,历史订单仅保留兜底分支。 +- `internal/service/order/service.go` 当前 `operator_name` 仅在 `operator_type=agent` 时按店铺名称回填,会导致平台旧订单长期空白。 +- `internal/service/commission_calculation/service.go` 当前仅把订单写成“已计算”,无法区分“有佣金 / 无佣金 / 链路异常”。 + +### 6. 本次实现范围内的迁移原则 + +- 只做增量字段迁移,不删除旧列。 +- 不在本次 migration 中批量强制回填历史订单,只在运行时兼容读取并为后续人工校验提供依据。 diff --git a/docs/feature-001-order-operator-and-chain-commission-semantics/功能总结.md b/docs/feature-001-order-operator-and-chain-commission-semantics/功能总结.md new file mode 100644 index 0000000..f510bad --- /dev/null +++ b/docs/feature-001-order-operator-and-chain-commission-semantics/功能总结.md @@ -0,0 +1,78 @@ +## 订单操作者与产业链佣金语义修复功能总结 + +### 1. 变更目标 + +本次变更围绕两个历史语义问题展开: + +1. 订单 `operator_id/operator_type` 混用了“真实操作者账号”和“代理店铺 ID”,导致平台订单无法稳定显示真实操作者。 +2. 订单 `commission_status` 只区分“待计算/已计算”,无法表达“有佣金 / 无佣金 / 链路异常待人工处理”的真实业务结果。 + +### 2. 已完成的核心改动 + +#### 2.1 订单操作者改为账号语义 + +- 新增权威字段: + - `operator_account_id` + - `operator_account_type` + - `operator_account_name` +- 后台订单创建、H5 代理订单创建、客户端个人客户订单、自动购包订单都会写入真实操作者快照。 +- 旧 `operator_id/operator_type` 保留为历史兼容字段,不再作为新语义权威来源。 + +#### 2.2 订单响应分离“操作者账号”和“业务店铺” + +- `operator_id/operator_type/operator_name` 现在按真实操作者账号语义返回。 +- 新增 `seller_shop_id/seller_shop_name`,用于表达业务归属店铺展示。 +- 历史订单优先使用新字段;若缺失,则按 `creator`、个人客户昵称、旧店铺型 `operator_id` 顺序兜底,避免平台旧订单出现空白操作者名称。 + +#### 2.3 订单佣金状态拆成“流程状态 + 业务结果” + +- `commission_status` 现在表示流程状态: + - `1=待计算` + - `2=已完成` + - `3=待人工处理` +- 新增 `commission_result` 表示业务结果: + - `0=未知` + - `1=有佣金` + - `2=无佣金` + - `3=链路异常` + +#### 2.4 差价佣金任务结果显式落单 + +- 佣金 worker 结束后会显式写回订单: + - `已完成 + 有佣金` + - `已完成 + 无佣金` + - `待人工处理 + 链路异常` +- 0 佣金订单不再长期停留在“待计算”。 + +#### 2.5 入队与幂等语义修复 + +- 钱包支付创建订单后,所有仍处于 `待计算` 的适用订单都会入队佣金任务,不再只覆盖特定购买角色。 +- 重复支付回调、重复钱包支付不会重复入队。 +- worker 对 `已完成/待人工处理` 订单直接跳过,避免重复补偿处理。 + +### 3. 兼容策略 + +- migration 仅新增字段,不删除旧列。 +- 历史订单读取时: + - 优先使用 `operator_account_*` + - 其次使用 `creator` 回填账号名 + - 对旧代理订单保留店铺名兜底展示 +- 历史订单的 `commission_result` 若未回填,则在响应层按佣金记录只读推断。 + +### 4. 受影响文件 + +- `internal/model/order.go` +- `internal/model/dto/order_dto.go` +- `internal/service/order/service.go` +- `internal/service/client_order/service.go` +- `internal/service/commission_calculation/service.go` +- `internal/store/postgres/order_store.go` +- `internal/task/auto_purchase.go` +- `pkg/constants/iot.go` +- `migrations/000140_add_order_operator_snapshot_and_commission_result.*.sql` + +### 5. 风险提示 + +- 历史订单若 `creator` 无法映射到账号,仍会退回旧店铺型操作者展示,仅保证“可读”而不强行伪造账号 ID。 +- `commission_result=0` 仅作为历史兼容态存在;新计算链路应尽量落为明确业务结果。 +- 本次不重构一次性佣金规则本身,只保证其异常待审记录能纳入订单最终流程结果判断。 diff --git a/docs/feature-001-order-operator-and-chain-commission-semantics/手工验收清单.md b/docs/feature-001-order-operator-and-chain-commission-semantics/手工验收清单.md new file mode 100644 index 0000000..1deb459 --- /dev/null +++ b/docs/feature-001-order-operator-and-chain-commission-semantics/手工验收清单.md @@ -0,0 +1,39 @@ +## 手工验收清单 + +### 一、平台订单操作者展示 + +- [ ] 创建或查询一条历史平台订单,确认响应中的 `operator_name` 不再为空。 +- [ ] 确认该订单的 `operator_type=platform`,且操作者名称优先来自 `creator` 对应后台账号。 +- [ ] 确认 `seller_shop_name` 与 `operator_name` 语义分离,不再混用店铺名代替平台账号名。 + +### 二、多级代理链差价佣金 + +- [ ] 准备一条 `seller_shop_id` 存在完整父级链的订单。 +- [ ] 执行佣金任务后,确认各级代理按父级链逐级生成差价佣金记录。 +- [ ] 确认订单 `commission_status=2`、`commission_result=1`。 +- [ ] 确认相关代理钱包流水与佣金记录金额一致。 + +### 三、无佣金完成态 + +- [ ] 准备一条顶级代理订单或无正差价订单。 +- [ ] 执行佣金任务后,确认不产生正金额佣金记录。 +- [ ] 确认订单不会继续停留在待计算,而是落为 `commission_status=2`、`commission_result=2`。 + +### 四、链路异常待人工处理 + +- [ ] 准备一条上级链断裂、系列配置缺失或套餐分配缺失的订单。 +- [ ] 执行佣金任务后,确认生成 `status=99` 的待人工修正佣金记录。 +- [ ] 确认备注中包含链路断点信息。 +- [ ] 确认订单落为 `commission_status=3`、`commission_result=3`。 + +### 五、入队与幂等 + +- [ ] 钱包支付创建订单后,确认差价佣金任务会自动入队。 +- [ ] 重复触发支付回调或重复调用钱包支付时,确认不会重复入队。 +- [ ] 对已经完成或待人工处理的订单重复执行佣金任务,确认 worker 会直接跳过。 + +### 六、历史兼容读取 + +- [ ] 抽查至少一条历史平台订单,确认 `creator` 能兜底回填操作者名称。 +- [ ] 抽查至少一条历史代理订单,确认新字段为空时仍可读出兼容操作者展示。 +- [ ] 确认历史订单不会把旧 `operator_id` 误当成新的账号 ID 再写回数据库。 diff --git a/docs/前端接口变更说明.md b/docs/前端接口变更说明.md index ffb80c7..451c99b 100644 --- a/docs/前端接口变更说明.md +++ b/docs/前端接口变更说明.md @@ -756,7 +756,7 @@ GET /api/admin/assets/resolve/:identifier | `series_name` | string | 套餐系列名称 | | `first_commission_paid` | bool | 一次性佣金是否已发放 | | `accumulated_recharge` | int64 | 累计充值金额(分) | -| `activated_at` | string | 激活时间 | +| `activated_at` | string | 激活时间(未来准备移除,请勿依赖) | | `created_at` | string | 创建时间 | | `updated_at` | string | 更新时间 | diff --git a/internal/model/commission.go b/internal/model/commission.go index 3f3bd41..69e2f57 100644 --- a/internal/model/commission.go +++ b/internal/model/commission.go @@ -19,7 +19,7 @@ type CommissionRecord struct { CommissionSource string `gorm:"column:commission_source;type:varchar(20);not null;index;comment:佣金来源 cost_diff-成本价差 one_time-一次性佣金" json:"commission_source"` Amount int64 `gorm:"column:amount;type:bigint;not null;comment:佣金金额(分)" json:"amount"` BalanceAfter int64 `gorm:"column:balance_after;type:bigint;default:0;comment:入账后钱包余额(分)" json:"balance_after"` - Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-已冻结 2-解冻中 3-已发放 4-已失效" json:"status"` + Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-已冻结 2-解冻中 3-已发放 4-已失效 99-待人工修正" json:"status"` ReleasedAt *time.Time `gorm:"column:released_at;comment:入账时间" json:"released_at"` Remark string `gorm:"column:remark;type:varchar(500);comment:备注" json:"remark"` } diff --git a/internal/model/dto/commission.go b/internal/model/dto/commission.go index d385564..2805169 100644 --- a/internal/model/dto/commission.go +++ b/internal/model/dto/commission.go @@ -29,7 +29,7 @@ type CommissionRecordListRequest struct { CommissionSource *string `json:"commission_source" query:"commission_source" validate:"omitempty,oneof=cost_diff one_time" description:"佣金来源 (cost_diff:成本价差, one_time:一次性佣金)"` StartTime *string `json:"start_time" query:"start_time" validate:"omitempty" description:"开始时间"` EndTime *string `json:"end_time" query:"end_time" validate:"omitempty" description:"结束时间"` - Status *int `json:"status" query:"status" validate:"omitempty,oneof=1 2 99" description:"状态 (1:已入账, 2:已失效, 99:待人工修正)"` + Status *int `json:"status" query:"status" validate:"omitempty,oneof=1 2 3 4 99" description:"状态 (1:已冻结, 2:解冻中, 3:已发放, 4:已失效, 99:待人工修正)"` } // CommissionRecordPageResult 佣金记录分页结果 diff --git a/internal/model/dto/order_dto.go b/internal/model/dto/order_dto.go index 522f767..a3c0315 100644 --- a/internal/model/dto/order_dto.go +++ b/internal/model/dto/order_dto.go @@ -65,14 +65,18 @@ type OrderResponse struct { PaidAt *time.Time `json:"paid_at,omitempty" description:"支付时间"` PaymentVoucherKey string `json:"payment_voucher_key,omitempty" description:"线下支付凭证对象存储file_key(仅线下支付订单有值)"` IsPurchaseOnBehalf bool `json:"is_purchase_on_behalf" description:"是否为代购订单"` - CommissionStatus int `json:"commission_status" description:"佣金状态 (1:待计算, 2:已计算)"` - CommissionStatusName string `json:"commission_status_name" description:"佣金状态名称(中文)"` + CommissionStatus int `json:"commission_status" description:"佣金流程状态 (1:待计算, 2:已完成, 3:待人工处理)"` + CommissionStatusName string `json:"commission_status_name" description:"佣金流程状态名称(中文)"` + CommissionResult int `json:"commission_result" description:"佣金业务结果 (0:未知, 1:有佣金, 2:无佣金, 3:链路异常)"` + CommissionResultName string `json:"commission_result_name" description:"佣金业务结果名称(中文)"` CommissionConfigVersion int `json:"commission_config_version" description:"佣金配置版本"` + SellerShopID *uint `json:"seller_shop_id,omitempty" description:"销售店铺ID(业务归属字段)"` + SellerShopName string `json:"seller_shop_name,omitempty" description:"销售店铺名称(业务归属展示)"` // 操作者信息 - OperatorID *uint `json:"operator_id,omitempty" description:"操作者ID"` - OperatorType string `json:"operator_type,omitempty" description:"操作者类型 (platform:平台, agent:代理)"` - OperatorName string `json:"operator_name,omitempty" description:"操作者名称"` + OperatorID *uint `json:"operator_id,omitempty" description:"操作者账号ID(真实发起操作的账号/主体ID)"` + OperatorType string `json:"operator_type,omitempty" description:"操作者账号类型 (platform:平台, agent:代理, enterprise:企业, personal_customer:个人客户)"` + OperatorName string `json:"operator_name,omitempty" description:"操作者名称(按账号语义展示)"` ActualPaidAmount *int64 `json:"actual_paid_amount,omitempty" description:"实际支付金额(分)"` // 订单角色 diff --git a/internal/model/order.go b/internal/model/order.go index 951df6b..5d888ed 100644 --- a/internal/model/order.go +++ b/internal/model/order.go @@ -37,7 +37,8 @@ type Order struct { PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"` // 佣金信息 - CommissionStatus int `gorm:"column:commission_status;type:int;default:1;not null;comment:佣金状态 1-待计算 2-已计算" json:"commission_status"` + CommissionStatus int `gorm:"column:commission_status;type:int;default:1;not null;comment:佣金流程状态 1-待计算 2-已完成 3-待人工处理" json:"commission_status"` + CommissionResult int `gorm:"column:commission_result;type:int;default:0;not null;comment:佣金业务结果 0-未知 1-有佣金 2-无佣金 3-链路异常" json:"commission_result"` CommissionConfigVersion int `gorm:"column:commission_config_version;type:int;default:0;comment:佣金配置版本(订单创建时快照)" json:"commission_config_version"` SellerShopID *uint `gorm:"column:seller_shop_id;index;comment:销售店铺ID(用于成本价差佣金计算)" json:"seller_shop_id,omitempty"` SellerCostPrice int64 `gorm:"column:seller_cost_price;type:bigint;default:0;comment:销售成本价(分,用于计算利润)" json:"seller_cost_price"` @@ -51,8 +52,11 @@ type Order struct { IsPurchaseOnBehalf bool `gorm:"column:is_purchase_on_behalf;type:boolean;default:false;comment:是否为代购订单" json:"is_purchase_on_behalf"` // 操作者信息(谁下的单) - OperatorID *uint `gorm:"column:operator_id;index:idx_orders_operator_id;comment:操作者ID(谁下的单)" json:"operator_id,omitempty"` - OperatorType string `gorm:"column:operator_type;type:varchar(20);comment:操作者类型(platform/agent)" json:"operator_type,omitempty"` + OperatorAccountID *uint `gorm:"column:operator_account_id;index:idx_orders_operator_account_id;comment:操作者账号ID(权威字段)" json:"operator_account_id,omitempty"` + OperatorAccountType string `gorm:"column:operator_account_type;type:varchar(32);default:'';not null;comment:操作者账号类型(platform/agent/enterprise/personal_customer)" json:"operator_account_type,omitempty"` + OperatorAccountName string `gorm:"column:operator_account_name;type:varchar(255);default:'';not null;comment:操作者名称快照" json:"operator_account_name,omitempty"` + OperatorID *uint `gorm:"column:operator_id;index:idx_orders_operator_id;comment:操作者ID(历史兼容字段,旧语义可能保存代理店铺ID)" json:"operator_id,omitempty"` + OperatorType string `gorm:"column:operator_type;type:varchar(20);comment:操作者类型(历史兼容字段,旧语义为platform/agent)" json:"operator_type,omitempty"` // 实际支付金额(可能与订单金额不同,如代理代购场景) ActualPaidAmount *int64 `gorm:"column:actual_paid_amount;type:bigint;comment:实际支付金额(分)" json:"actual_paid_amount,omitempty"` @@ -103,10 +107,22 @@ const ( PaymentStatusRefunded = 4 // 已退款 ) -// 佣金状态常量 +// 佣金流程状态常量 const ( - CommissionStatusPending = 1 // 待计算 - CommissionStatusCalculated = 2 // 已计算 + CommissionStatusPending = 1 // 待计算 + CommissionStatusCompleted = 2 // 已完成 + CommissionStatusPendingReview = 3 // 待人工处理 + + // CommissionStatusCalculated 兼容旧命名,语义已调整为“已完成” + CommissionStatusCalculated = CommissionStatusCompleted +) + +// 佣金业务结果常量 +const ( + CommissionResultUnknown = 0 // 未知(历史兼容态) + CommissionResultHasAmount = 1 // 有佣金 + CommissionResultNoAmount = 2 // 无佣金 + CommissionResultChainBroken = 3 // 链路异常 ) // 订单角色常量 @@ -117,21 +133,29 @@ const ( PurchaseRolePurchaseForSubordinate = "purchase_for_subordinate" // 给下级购买 ) +// 操作者账号类型常量 +const ( + OperatorAccountTypePlatform = "platform" // 平台账号 + OperatorAccountTypeAgent = "agent" // 代理账号 + OperatorAccountTypeEnterprise = "enterprise" // 企业账号 + OperatorAccountTypePersonalCustomer = "personal_customer" // 个人客户 +) + // OrderItem 订单明细模型 // 记录订单中购买的套餐明细,支持一个订单购买多个套餐 type OrderItem struct { gorm.Model BaseModel `gorm:"embedded"` - OrderID uint `gorm:"column:order_id;index:idx_order_item_order_id;not null;comment:订单ID" json:"order_id"` - PackageID uint `gorm:"column:package_id;index;not null;comment:套餐ID" json:"package_id"` - PackageName string `gorm:"column:package_name;type:varchar(100);not null;comment:套餐名称(快照)" json:"package_name"` - PackageType *string `gorm:"column:package_type;type:varchar(20);comment:套餐类型快照 formal-正式套餐 addon-加油包" json:"package_type"` - Quantity int `gorm:"column:quantity;type:int;default:1;not null;comment:数量" json:"quantity"` - UnitPrice int64 `gorm:"column:unit_price;type:bigint;not null;comment:单价(分)" json:"unit_price"` - Amount int64 `gorm:"column:amount;type:bigint;not null;comment:小计金额(分)" json:"amount"` - PackagePriceConfigStatus int `gorm:"column:package_price_config_status;type:int;default:0;not null;comment:套餐价格配置状态快照 0-未配置 1-赠送0价 2-已配置非0" json:"package_price_config_status"` - PackageIsGift bool `gorm:"column:package_is_gift;type:boolean;default:false;not null;comment:套餐是否赠送快照 true-赠送 false-普通可售" json:"package_is_gift"` + OrderID uint `gorm:"column:order_id;index:idx_order_item_order_id;not null;comment:订单ID" json:"order_id"` + PackageID uint `gorm:"column:package_id;index;not null;comment:套餐ID" json:"package_id"` + PackageName string `gorm:"column:package_name;type:varchar(100);not null;comment:套餐名称(快照)" json:"package_name"` + PackageType *string `gorm:"column:package_type;type:varchar(20);comment:套餐类型快照 formal-正式套餐 addon-加油包" json:"package_type"` + Quantity int `gorm:"column:quantity;type:int;default:1;not null;comment:数量" json:"quantity"` + UnitPrice int64 `gorm:"column:unit_price;type:bigint;not null;comment:单价(分)" json:"unit_price"` + Amount int64 `gorm:"column:amount;type:bigint;not null;comment:小计金额(分)" json:"amount"` + PackagePriceConfigStatus int `gorm:"column:package_price_config_status;type:int;default:0;not null;comment:套餐价格配置状态快照 0-未配置 1-赠送0价 2-已配置非0" json:"package_price_config_status"` + PackageIsGift bool `gorm:"column:package_is_gift;type:boolean;default:false;not null;comment:套餐是否赠送快照 true-赠送 false-普通可售" json:"package_is_gift"` } // TableName 指定表名 diff --git a/internal/service/client_order/service.go b/internal/service/client_order/service.go index 82c46c4..f1f19c9 100644 --- a/internal/service/client_order/service.go +++ b/internal/service/client_order/service.go @@ -477,6 +477,28 @@ func (s *Service) fetchBuyerSnapshot(ctx context.Context, customerID uint) (phon return phone, nickname } +func (s *Service) buildPersonalCustomerOperatorSnapshot(ctx context.Context, customerID uint, nickname *string) (*uint, string, string) { + if customerID == 0 { + return nil, "", "" + } + + name := "" + if nickname != nil { + name = strings.TrimSpace(*nickname) + } + if name == "" { + customer, err := s.personalCustomerStore.GetByID(ctx, customerID) + if err != nil { + s.logger.Warn("buildPersonalCustomerOperatorSnapshot: 查询个人客户失败", zap.Uint("customer_id", customerID), zap.Error(err)) + } else if customer != nil { + name = strings.TrimSpace(customer.Nickname) + } + } + + id := customerID + return &id, model.OperatorAccountTypePersonalCustomer, name +} + func (s *Service) buildPendingOrder(ctx context.Context, customerID uint, result *purchase_validation.PurchaseValidationResult, sellerCostPrice int64) (*model.Order, error) { orderType := resolveOrderType(result) if orderType == "" { @@ -515,6 +537,7 @@ func (s *Service) buildPendingOrder(ctx context.Context, customerID uint, result } order.BuyerPhone, order.BuyerNickname = s.fetchBuyerSnapshot(ctx, customerID) + order.OperatorAccountID, order.OperatorAccountType, order.OperatorAccountName = s.buildPersonalCustomerOperatorSnapshot(ctx, customerID, order.BuyerNickname) return order, nil } diff --git a/internal/service/commission_calculation/service.go b/internal/service/commission_calculation/service.go index 143ee5e..211250d 100644 --- a/internal/service/commission_calculation/service.go +++ b/internal/service/commission_calculation/service.go @@ -78,8 +78,11 @@ func (s *Service) CalculateCommission(ctx context.Context, orderID uint) error { return errors.Wrap(errors.CodeDatabaseError, err, "获取订单失败") } - if order.CommissionStatus == model.CommissionStatusCalculated { - s.logger.Warn("订单佣金已计算,跳过", zap.String("order_id", fmt.Sprint(orderID))) + if order.CommissionStatus == model.CommissionStatusCompleted || order.CommissionStatus == model.CommissionStatusPendingReview { + s.logger.Warn("订单佣金流程已结束,跳过", + zap.String("order_id", fmt.Sprint(orderID)), + zap.Int("commission_status", order.CommissionStatus), + ) return nil } @@ -88,13 +91,8 @@ func (s *Service) CalculateCommission(ctx context.Context, orderID uint) error { return errors.Wrap(errors.CodeInternalError, err, "计算成本价差佣金失败") } - for _, record := range costDiffRecords { - if err := tx.Create(record).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "创建成本价差佣金记录失败") - } - if err := s.creditCommissionInTx(ctx, tx, record); err != nil { - return errors.Wrap(errors.CodeInternalError, err, "佣金入账失败") - } + if err := s.persistCommissionRecordsInTx(ctx, tx, costDiffRecords); err != nil { + return err } // 代购订单不触发一次性佣金和累计充值更新 @@ -110,8 +108,16 @@ func (s *Service) CalculateCommission(ctx context.Context, orderID uint) error { } } - if err := tx.Model(&model.Order{}).Where("id = ?", orderID).Update("commission_status", model.CommissionStatusCalculated).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "更新订单佣金状态失败") + commissionStatus, commissionResult, err := s.resolveOrderCommissionOutcomeInTx(tx, order.ID) + if err != nil { + return err + } + + if err := tx.Model(&model.Order{}).Where("id = ?", orderID).Updates(map[string]any{ + "commission_status": commissionStatus, + "commission_result": commissionResult, + }).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新订单佣金结果失败") } return nil @@ -190,12 +196,7 @@ func (s *Service) CalculateCostDiffCommission(ctx context.Context, order *model. Status: constants.CommissionStatusPendingReview, Remark: fmt.Sprintf("套餐系列[%d]未分配给该代理(shop_id=%d),成本价差链路断裂,请人工核查", *order.SeriesID, currentShop.ID), } - if createErr := s.commissionRecordStore.Create(ctx, pendingRecord); createErr != nil { - s.logger.Warn("创建待审佣金记录失败", - zap.Uint("shop_id", currentShop.ID), - zap.Uint("order_id", order.ID), - zap.Error(createErr)) - } + records = append(records, pendingRecord) break } @@ -284,13 +285,8 @@ func (s *Service) triggerOneTimeCommissionForCardInTx(ctx context.Context, tx *g return err } - for _, record := range records { - if err := tx.Create(record).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "创建一次性佣金记录失败") - } - if err := s.creditCommissionInTx(ctx, tx, record); err != nil { - return errors.Wrap(errors.CodeInternalError, err, "一次性佣金入账失败") - } + if err := s.persistCommissionRecordsInTx(ctx, tx, records); err != nil { + return err } card.SetFirstRechargeTriggeredBySeries(seriesID, true) @@ -368,13 +364,8 @@ func (s *Service) triggerOneTimeCommissionForDeviceInTx(ctx context.Context, tx return err } - for _, record := range records { - if err := tx.Create(record).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "创建一次性佣金记录失败") - } - if err := s.creditCommissionInTx(ctx, tx, record); err != nil { - return errors.Wrap(errors.CodeInternalError, err, "一次性佣金入账失败") - } + if err := s.persistCommissionRecordsInTx(ctx, tx, records); err != nil { + return err } device.SetFirstRechargeTriggeredBySeries(seriesID, true) @@ -544,12 +535,7 @@ func (s *Service) calculateChainOneTimeCommission(ctx context.Context, bottomSho Status: constants.CommissionStatusPendingReview, Remark: fmt.Sprintf("套餐系列[%d]未分配给该代理(shop_id=%d),一次性佣金链路断裂,请人工核查", seriesID, parentShopID), } - if createErr := s.commissionRecordStore.Create(ctx, pendingRecord); createErr != nil { - s.logger.Warn("创建待审佣金记录失败", - zap.Uint("shop_id", parentShopID), - zap.Uint("order_id", order.ID), - zap.Error(createErr)) - } + records = append(records, pendingRecord) break } @@ -686,6 +672,48 @@ func (s *Service) creditCommissionInTx(ctx context.Context, tx *gorm.DB, record return nil } +func (s *Service) persistCommissionRecordsInTx(ctx context.Context, tx *gorm.DB, records []*model.CommissionRecord) error { + for _, record := range records { + if err := tx.Create(record).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建佣金记录失败") + } + if record.Status == constants.CommissionStatusPendingReview { + continue + } + if err := s.creditCommissionInTx(ctx, tx, record); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "佣金入账失败") + } + } + + return nil +} + +func (s *Service) resolveOrderCommissionOutcomeInTx(tx *gorm.DB, orderID uint) (int, int, error) { + var pendingReviewCount int64 + if err := tx.Model(&model.CommissionRecord{}). + Where("order_id = ? AND status = ?", orderID, constants.CommissionStatusPendingReview). + Count(&pendingReviewCount).Error; err != nil { + return 0, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计待人工处理佣金记录失败") + } + + if pendingReviewCount > 0 { + return model.CommissionStatusPendingReview, model.CommissionResultChainBroken, nil + } + + var releasedCount int64 + if err := tx.Model(&model.CommissionRecord{}). + Where("order_id = ? AND status = ? AND amount > 0", orderID, constants.CommissionStatusReleased). + Count(&releasedCount).Error; err != nil { + return 0, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计已发放佣金记录失败") + } + + if releasedCount > 0 { + return model.CommissionStatusCompleted, model.CommissionResultHasAmount, nil + } + + return model.CommissionStatusCompleted, model.CommissionResultNoAmount, nil +} + func (s *Service) CreditCommission(ctx context.Context, record *model.CommissionRecord) error { return s.db.Transaction(func(tx *gorm.DB) error { return s.creditCommissionInTx(ctx, tx, record) diff --git a/internal/service/order/service.go b/internal/service/order/service.go index 21ad72d..78f97cf 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -206,6 +206,7 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde } userID := middleware.GetUserIDFromContext(ctx) + operatorAccountID, operatorAccountType, operatorAccountName := s.buildAccountOperatorSnapshot(ctx, userID, operatorUserType) // 提取资源所属店铺ID var seriesID *uint @@ -261,44 +262,44 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde actualPaidAmount = &actualPaidAmountVal purchaseRole = model.PurchaseRoleSelfPurchase } else { - isPlatformOwned := resourceShopID == nil || *resourceShopID == 0 + isPlatformOwned := resourceShopID == nil || *resourceShopID == 0 - if isPlatformOwned { - // ==== 子场景 1.1:平台自营(资源未分配给代理商)==== - // 平台直接销售给终端用户,无代理商参与,使用零售价,无需查询分配配置 - orderBuyerType = model.BuyerTypePersonal - orderBuyerID = 0 - paymentMethod = model.PaymentMethodOffline - paymentStatus = model.PaymentStatusPaid - paidAt = &now - isPurchaseOnBehalf = false - sellerCostPrice = 0 - operatorID = nil - operatorType = constants.OwnerTypePlatform - purchaseRole = model.PurchaseRoleSelfPurchase - actualPaidAmount = nil - } else { - // ==== 子场景 1.2:平台代购(资源属于代理商,平台代为购买)==== - purchaseBuyerID, buyerCostPriceMap, buyerTotalCost, purchasePaidAt, err := s.resolvePurchaseOnBehalfInfo(ctx, validationResult) - if err != nil { - return nil, err + if isPlatformOwned { + // ==== 子场景 1.1:平台自营(资源未分配给代理商)==== + // 平台直接销售给终端用户,无代理商参与,使用零售价,无需查询分配配置 + orderBuyerType = model.BuyerTypePersonal + orderBuyerID = 0 + paymentMethod = model.PaymentMethodOffline + paymentStatus = model.PaymentStatusPaid + paidAt = &now + isPurchaseOnBehalf = false + sellerCostPrice = 0 + operatorID = nil + operatorType = constants.OwnerTypePlatform + purchaseRole = model.PurchaseRoleSelfPurchase + actualPaidAmount = nil + } else { + // ==== 子场景 1.2:平台代购(资源属于代理商,平台代为购买)==== + purchaseBuyerID, buyerCostPriceMap, buyerTotalCost, purchasePaidAt, err := s.resolvePurchaseOnBehalfInfo(ctx, validationResult) + if err != nil { + return nil, err + } + orderBuyerType = model.BuyerTypeAgent + orderBuyerID = purchaseBuyerID + itemUnitPriceMap = buyerCostPriceMap + totalAmount = buyerTotalCost + paymentMethod = model.PaymentMethodOffline + paymentStatus = model.PaymentStatusPaid + paidAt = purchasePaidAt + isPurchaseOnBehalf = true + sellerCostPrice = buyerTotalCost + + // 设置操作者信息(平台代购) + operatorID = nil + operatorType = constants.OwnerTypePlatform + purchaseRole = model.PurchaseRolePurchasedByPlatform + actualPaidAmount = nil } - orderBuyerType = model.BuyerTypeAgent - orderBuyerID = purchaseBuyerID - itemUnitPriceMap = buyerCostPriceMap - totalAmount = buyerTotalCost - paymentMethod = model.PaymentMethodOffline - paymentStatus = model.PaymentStatusPaid - paidAt = purchasePaidAt - isPurchaseOnBehalf = true - sellerCostPrice = buyerTotalCost - - // 设置操作者信息(平台代购) - operatorID = nil - operatorType = constants.OwnerTypePlatform - purchaseRole = model.PurchaseRolePurchasedByPlatform - actualPaidAmount = nil - } } } else if req.PaymentMethod == model.PaymentMethodWallet { @@ -445,6 +446,9 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde SellerShopID: sellerShopID, SellerCostPrice: sellerCostPrice, IsPurchaseOnBehalf: isPurchaseOnBehalf, + OperatorAccountID: operatorAccountID, + OperatorAccountType: operatorAccountType, + OperatorAccountName: operatorAccountName, OperatorID: operatorID, OperatorType: operatorType, ActualPaidAmount: actualPaidAmount, @@ -475,7 +479,7 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde s.enqueueCommissionCalculation(ctx, order.ID) } s.markOrderCreated(ctx, idempotencyKey, order.ID) - return s.buildOrderResponse(order, items), nil + return s.buildOrderResponse(ctx, order, items), nil } else if req.PaymentMethod == model.PaymentMethodWallet { // 钱包支付:创建订单、扣款、激活套餐(在事务中完成) @@ -489,7 +493,7 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde return nil, err } s.markOrderCreated(ctx, idempotencyKey, order.ID) - return s.buildOrderResponse(order, items), nil + return s.buildOrderResponse(ctx, order, items), nil } else { // 不应该到这里(DTO 验证已拒绝其他支付方式) @@ -563,6 +567,8 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest } userID := middleware.GetUserIDFromContext(ctx) + operatorUserType := middleware.GetUserTypeFromContext(ctx) + operatorAccountID, operatorAccountType, operatorAccountName := s.buildAccountOperatorSnapshot(ctx, userID, operatorUserType) // 提取资源所属店铺ID var resourceShopID *uint @@ -732,6 +738,9 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest SellerShopID: sellerShopID, SellerCostPrice: sellerCostPrice, IsPurchaseOnBehalf: isPurchaseOnBehalf, + OperatorAccountID: operatorAccountID, + OperatorAccountType: operatorAccountType, + OperatorAccountName: operatorAccountName, OperatorID: operatorID, OperatorType: operatorType, ActualPaidAmount: actualPaidAmount, @@ -752,7 +761,7 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest } s.enqueueCommissionCalculation(ctx, order.ID) s.markOrderCreated(ctx, idempotencyKey, order.ID) - return s.buildOrderResponse(order, items), nil + return s.buildOrderResponse(ctx, order, items), nil } else if req.PaymentMethod == model.PaymentMethodWallet { // 钱包支付:创建订单、扣款、激活套餐(在事务中完成) @@ -766,7 +775,7 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest return nil, err } s.markOrderCreated(ctx, idempotencyKey, order.ID) - return s.buildOrderResponse(order, items), nil + return s.buildOrderResponse(ctx, order, items), nil } else { // 其他支付方式:创建待支付订单(H5 端支持 wechat/alipay) @@ -777,7 +786,7 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest return nil, err } s.markOrderCreated(ctx, idempotencyKey, order.ID) - return s.buildOrderResponse(order, items), nil + return s.buildOrderResponse(ctx, order, items), nil } } @@ -909,6 +918,57 @@ func (s *Service) fetchBuyerSnapshot(ctx context.Context, customerID uint) (phon return phone, nickname } +func (s *Service) buildAccountOperatorSnapshot(ctx context.Context, userID uint, userType int) (*uint, string, string) { + if userID == 0 { + return nil, "", "" + } + + var account model.Account + if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&account).Error; err != nil { + s.logger.Warn("buildAccountOperatorSnapshot: 查询账号失败", zap.Uint("user_id", userID), zap.Error(err)) + id := userID + return &id, orderOperatorAccountTypeFromUserType(userType), "" + } + + id := account.ID + return &id, orderOperatorAccountTypeFromUserType(account.UserType), account.Username +} + +func (s *Service) buildPersonalCustomerOperatorSnapshot(ctx context.Context, customerID uint, nickname *string) (*uint, string, string) { + if customerID == 0 { + return nil, "", "" + } + + name := "" + if nickname != nil { + name = strings.TrimSpace(*nickname) + } + if name == "" && s.personalCustomerStore != nil { + customer, err := s.personalCustomerStore.GetByID(ctx, customerID) + if err != nil { + s.logger.Warn("buildPersonalCustomerOperatorSnapshot: 查询个人客户失败", zap.Uint("customer_id", customerID), zap.Error(err)) + } else if customer != nil { + name = strings.TrimSpace(customer.Nickname) + } + } + + id := customerID + return &id, model.OperatorAccountTypePersonalCustomer, name +} + +func orderOperatorAccountTypeFromUserType(userType int) string { + switch userType { + case constants.UserTypeSuperAdmin, constants.UserTypePlatform: + return model.OperatorAccountTypePlatform + case constants.UserTypeAgent: + return model.OperatorAccountTypeAgent + case constants.UserTypeEnterprise: + return model.OperatorAccountTypeEnterprise + default: + return "" + } +} + // getCostPrice 查询店铺对套餐的成本价 // shopID: 店铺ID // packageID: 套餐ID @@ -1078,9 +1138,8 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model return err } - // 3. 事务外:佣金计算(异步) - // 仅兼容极少数“平台代购但走钱包事务”的特殊场景,普通钱包订单不在此处入队佣金 - if order.PurchaseRole == model.PurchaseRolePurchasedByPlatform { + // 3. 事务外:所有已支付且适用差价佣金的订单都进入佣金计算 + if order.CommissionStatus == model.CommissionStatusPending { s.enqueueCommissionCalculation(ctx, order.ID) } @@ -1113,7 +1172,7 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.OrderResponse, error) return nil, err } - return s.buildOrderResponse(order, items), nil + return s.buildOrderResponse(ctx, order, items), nil } func (s *Service) List(ctx context.Context, req *dto.OrderListRequest, buyerType string, buyerID uint) (*dto.OrderListResponse, error) { @@ -1204,7 +1263,7 @@ func (s *Service) List(ctx context.Context, req *dto.OrderListRequest, buyerType var list []*dto.OrderResponse for _, o := range orders { - list = append(list, s.buildOrderResponse(o, itemsMap[o.ID])) + list = append(list, s.buildOrderResponse(ctx, o, itemsMap[o.ID])) } return &dto.OrderListResponse{ @@ -1478,6 +1537,7 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, // 根据资源类型选择对应的钱包系统 now := time.Now() actualPaidAmount := order.TotalAmount + shouldEnqueueCommission := false if resourceType == "shop" { // 代理钱包系统(店铺) @@ -1527,6 +1587,7 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, actualPaidAmountSnapshot := actualPaidAmount order.ActualPaidAmount = &actualPaidAmountSnapshot + shouldEnqueueCommission = true walletResult := tx.Model(&model.AgentWallet{}). Where("id = ? AND balance >= ? AND version = ?", wallet.ID, order.TotalAmount, wallet.Version). @@ -1645,7 +1706,9 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, return err } - s.enqueueCommissionCalculation(ctx, orderID) + if shouldEnqueueCommission && order.CommissionStatus == model.CommissionStatusPending { + s.enqueueCommissionCalculation(ctx, orderID) + } return nil } @@ -1659,6 +1722,8 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, pay } now := time.Now() + shouldResumeAfterPayment := false + shouldEnqueueCommission := false err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { result := tx.Model(&model.Order{}). Where("id = ? AND payment_status = ?", order.ID, model.PaymentStatusPending). @@ -1694,6 +1759,8 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, pay // 回调更新成功后,同步内存对象,确保激活套餐时写入 paid_amount 快照 actualPaidAmountSnapshot := actualPaidAmount order.ActualPaidAmount = &actualPaidAmountSnapshot + shouldResumeAfterPayment = true + shouldEnqueueCommission = true return s.activatePackage(ctx, tx, order) }) @@ -1702,8 +1769,12 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, pay return err } - s.tryResumeAfterPayment(ctx, order) - s.enqueueCommissionCalculation(ctx, order.ID) + if shouldResumeAfterPayment { + s.tryResumeAfterPayment(ctx, order) + } + if shouldEnqueueCommission && order.CommissionStatus == model.CommissionStatusPending { + s.enqueueCommissionCalculation(ctx, order.ID) + } return nil } @@ -2169,24 +2240,24 @@ func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order * Creator: order.Creator, Updater: order.Creator, }, - OrderID: order.ID, - OrderNo: order.OrderNo, - PackageID: pkg.ID, - PackageName: pkg.PackageName, - UsageType: order.OrderType, - DataLimitMB: pkg.RealDataMB, + OrderID: order.ID, + OrderNo: order.OrderNo, + PackageID: pkg.ID, + PackageName: pkg.PackageName, + UsageType: order.OrderType, + DataLimitMB: pkg.RealDataMB, VirtualTotalMBSnapshot: virtualTotalMBSnapshot, DisplayGainRatioSnapshot: displayGainRatioSnapshot, EnableVirtualDataSnapshot: enableVirtualDataSnapshot, - Status: status, - Priority: priority, - MasterUsageID: &mainPackage.ID, - ActivatedAt: &activatedAt, - ExpiresAt: &expiresAt, - DataResetCycle: pkg.DataResetCycle, - PaidAmount: order.ActualPaidAmount, - PackagePriceConfigStatus: pkg.PriceConfigStatus, - PackageIsGift: pkg.IsGift, + Status: status, + Priority: priority, + MasterUsageID: &mainPackage.ID, + ActivatedAt: &activatedAt, + ExpiresAt: &expiresAt, + DataResetCycle: pkg.DataResetCycle, + PaidAmount: order.ActualPaidAmount, + PackagePriceConfigStatus: pkg.PriceConfigStatus, + PackageIsGift: pkg.IsGift, } if carrierType == "iot_card" { @@ -2223,7 +2294,7 @@ func (s *Service) enqueueCommissionCalculation(ctx context.Context, orderID uint zap.String("task_type", constants.TaskTypeCommission)) } -func (s *Service) buildOrderResponse(order *model.Order, items []*model.OrderItem) *dto.OrderResponse { +func (s *Service) buildOrderResponse(ctx context.Context, order *model.Order, items []*model.OrderItem) *dto.OrderResponse { var itemResponses []*dto.OrderItemResponse for _, item := range items { itemResponses = append(itemResponses, &dto.OrderItemResponse{ @@ -2249,14 +2320,8 @@ func (s *Service) buildOrderResponse(order *model.Order, items []*model.OrderIte statusText = "已退款" } - // 查询操作者名称 - operatorName := "" - if order.OperatorType == "agent" && order.OperatorID != nil { - var shop model.Shop - if err := s.db.Where("id = ?", *order.OperatorID).First(&shop).Error; err == nil { - operatorName = shop.ShopName - } - } + operatorID, operatorType, operatorName := s.resolveOrderOperatorView(ctx, order) + sellerShopName := s.resolveSellerShopName(ctx, order.SellerShopID) // 生成派生字段 isPurchasedByParent := order.PurchaseRole == model.PurchaseRolePurchasedByParent @@ -2288,6 +2353,7 @@ func (s *Service) buildOrderResponse(order *model.Order, items []*model.OrderIte actualPaidAmountSnapshot := order.TotalAmount actualPaidAmount = &actualPaidAmountSnapshot } + commissionResult := s.resolveOrderCommissionResult(ctx, order) return &dto.OrderResponse{ ID: order.ID, @@ -2308,10 +2374,14 @@ func (s *Service) buildOrderResponse(order *model.Order, items []*model.OrderIte IsPurchaseOnBehalf: order.IsPurchaseOnBehalf, CommissionStatus: order.CommissionStatus, CommissionStatusName: constants.GetOrderCommissionStatusName(order.CommissionStatus), + CommissionResult: commissionResult, + CommissionResultName: constants.GetOrderCommissionResultName(commissionResult), CommissionConfigVersion: order.CommissionConfigVersion, + SellerShopID: order.SellerShopID, + SellerShopName: sellerShopName, - OperatorID: order.OperatorID, - OperatorType: order.OperatorType, + OperatorID: operatorID, + OperatorType: operatorType, OperatorName: operatorName, ActualPaidAmount: actualPaidAmount, @@ -2331,6 +2401,90 @@ func (s *Service) buildOrderResponse(order *model.Order, items []*model.OrderIte } } +func (s *Service) resolveOrderOperatorView(ctx context.Context, order *model.Order) (*uint, string, string) { + if order.OperatorAccountID != nil || order.OperatorAccountType != "" || order.OperatorAccountName != "" { + return order.OperatorAccountID, order.OperatorAccountType, order.OperatorAccountName + } + + if order.Source == constants.OrderSourceClient && order.BuyerType == model.BuyerTypePersonal { + operatorID, operatorType, operatorName := s.buildPersonalCustomerOperatorSnapshot(ctx, order.BuyerID, order.BuyerNickname) + if operatorID != nil || operatorType != "" || operatorName != "" { + return operatorID, operatorType, operatorName + } + } + + if order.Creator > 0 { + operatorID, operatorType, operatorName := s.buildAccountOperatorSnapshot(ctx, order.Creator, 0) + if operatorID != nil || operatorType != "" || operatorName != "" { + return operatorID, operatorType, operatorName + } + } + + if order.OperatorType == model.OperatorAccountTypeAgent && order.OperatorID != nil { + return nil, model.OperatorAccountTypeAgent, s.resolveLegacyOperatorShopName(ctx, *order.OperatorID) + } + + if order.OperatorType == model.OperatorAccountTypePlatform { + return nil, model.OperatorAccountTypePlatform, "平台" + } + + return nil, "", "" +} + +func (s *Service) resolveLegacyOperatorShopName(ctx context.Context, shopID uint) string { + if shopID == 0 { + return "" + } + + var shop model.Shop + if err := s.db.WithContext(ctx).Where("id = ?", shopID).First(&shop).Error; err != nil { + return "" + } + return shop.ShopName +} + +func (s *Service) resolveSellerShopName(ctx context.Context, sellerShopID *uint) string { + if sellerShopID == nil || *sellerShopID == 0 { + return "" + } + + var shop model.Shop + if err := s.db.WithContext(ctx).Where("id = ?", *sellerShopID).First(&shop).Error; err != nil { + return "" + } + return shop.ShopName +} + +func (s *Service) resolveOrderCommissionResult(ctx context.Context, order *model.Order) int { + if order.CommissionResult != model.CommissionResultUnknown { + return order.CommissionResult + } + + if order.CommissionStatus == model.CommissionStatusPending { + return model.CommissionResultUnknown + } + + var pendingReviewCount int64 + if err := s.db.WithContext(ctx).Model(&model.CommissionRecord{}). + Where("order_id = ? AND status = ?", order.ID, constants.CommissionStatusPendingReview). + Count(&pendingReviewCount).Error; err == nil && pendingReviewCount > 0 { + return model.CommissionResultChainBroken + } + + var releasedCount int64 + if err := s.db.WithContext(ctx).Model(&model.CommissionRecord{}). + Where("order_id = ? AND status = ? AND amount > 0", order.ID, constants.CommissionStatusReleased). + Count(&releasedCount).Error; err == nil && releasedCount > 0 { + return model.CommissionResultHasAmount + } + + if order.CommissionStatus == model.CommissionStatusCompleted { + return model.CommissionResultNoAmount + } + + return model.CommissionResultUnknown +} + // WechatPayJSAPI 发起微信 JSAPI 支付 // 通过 order.PaymentConfigID 动态加载支付配置,支持多商户场景 func (s *Service) WechatPayJSAPI(ctx context.Context, orderID uint, openID string, buyerType string, buyerID uint) (*dto.WechatPayJSAPIResponse, error) { diff --git a/internal/service/shop_commission/service.go b/internal/service/shop_commission/service.go index 8f9f41e..ea4bbae 100644 --- a/internal/service/shop_commission/service.go +++ b/internal/service/shop_commission/service.go @@ -627,7 +627,7 @@ func (s *Service) CreateWithdrawalRequest(ctx context.Context, shopID uint, req ActualAmount: actualAmount, WithdrawalMethod: req.WithdrawalMethod, AccountInfo: accountInfoJSON, - Status: 1, // 待审核 + Status: constants.WithdrawalStatusPending, // 待审核 } withdrawalRequest.Creator = currentUserID withdrawalRequest.Updater = currentUserID diff --git a/internal/store/postgres/order_store.go b/internal/store/postgres/order_store.go index 0fe03f1..e62dd8d 100644 --- a/internal/store/postgres/order_store.go +++ b/internal/store/postgres/order_store.go @@ -45,8 +45,7 @@ func (s *OrderStore) Create(ctx context.Context, order *model.Order, items []*mo func (s *OrderStore) GetByID(ctx context.Context, id uint) (*model.Order, error) { var order model.Order query := s.db.WithContext(ctx).Where("id = ?", id) - // 应用数据权限过滤(使用 seller_shop_id 字段) - query = middleware.ApplySellerShopFilter(ctx, query) + query = s.applyOrderScope(ctx, query) if err := query.First(&order).Error; err != nil { return nil, err } @@ -56,8 +55,7 @@ func (s *OrderStore) GetByID(ctx context.Context, id uint) (*model.Order, error) func (s *OrderStore) GetByIDWithItems(ctx context.Context, id uint) (*model.Order, []*model.OrderItem, error) { var order model.Order query := s.db.WithContext(ctx).Where("id = ?", id) - // 应用数据权限过滤(使用 seller_shop_id 字段) - query = middleware.ApplySellerShopFilter(ctx, query) + query = s.applyOrderScope(ctx, query) if err := query.First(&order).Error; err != nil { return nil, nil, err } @@ -73,8 +71,7 @@ func (s *OrderStore) GetByIDWithItems(ctx context.Context, id uint) (*model.Orde func (s *OrderStore) GetByOrderNo(ctx context.Context, orderNo string) (*model.Order, error) { var order model.Order query := s.db.WithContext(ctx).Where("order_no = ?", orderNo) - // 应用数据权限过滤(使用 seller_shop_id 字段) - query = middleware.ApplySellerShopFilter(ctx, query) + query = s.applyOrderScope(ctx, query) if err := query.First(&order).Error; err != nil { return nil, err } @@ -91,17 +88,7 @@ func (s *OrderStore) List(ctx context.Context, opts *store.QueryOptions, filters query := s.db.WithContext(ctx).Model(&model.Order{}) - // 应用数据权限过滤 - // 代理用户:可以查看作为买家或操作者的订单 - // 平台用户/超管:可以查看所有订单 - subordinateShopIDs := middleware.GetSubordinateShopIDs(ctx) - if len(subordinateShopIDs) > 0 { - // 代理用户:WHERE (buyer_type = 'agent' AND buyer_id IN ?) OR operator_id IN ? - query = query.Where( - s.db.Where("buyer_type = ? AND buyer_id IN ?", model.BuyerTypeAgent, subordinateShopIDs). - Or("operator_id IN ?", subordinateShopIDs), - ) - } + query = s.applyOrderScope(ctx, query) if v, ok := filters["payment_status"]; ok { query = query.Where("payment_status = ?", v) @@ -210,6 +197,19 @@ func (s *OrderStore) UpdatePaymentStatus(ctx context.Context, id uint, status in return s.db.WithContext(ctx).Model(&model.Order{}).Where("id = ?", id).Updates(updates).Error } +func (s *OrderStore) applyOrderScope(ctx context.Context, query *gorm.DB) *gorm.DB { + shopIDs := middleware.GetSubordinateShopIDs(ctx) + if len(shopIDs) == 0 { + return query + } + + return query.Where( + s.db.Where("buyer_type = ? AND buyer_id IN ?", model.BuyerTypeAgent, shopIDs). + Or("seller_shop_id IN ?", shopIDs). + Or(s.db.Where("seller_shop_id IS NULL AND operator_type = ? AND operator_id IN ?", model.OperatorAccountTypeAgent, shopIDs)), + ) +} + func (s *OrderStore) GenerateOrderNo() string { now := time.Now() randomNum := rand.Intn(1000000) diff --git a/internal/task/auto_purchase.go b/internal/task/auto_purchase.go index 47a5d28..63c3125 100644 --- a/internal/task/auto_purchase.go +++ b/internal/task/auto_purchase.go @@ -192,7 +192,7 @@ func (h *AutoPurchaseHandler) ProcessTask(ctx context.Context, task *asynq.Task) } now := time.Now() - order, orderItems, buildErr := h.buildOrderAndItems(rechargeOrder, packages, totalAmount, seriesID, sellerCostPrice, now) + order, orderItems, buildErr := h.buildOrderAndItems(ctx, rechargeOrder, packages, totalAmount, seriesID, sellerCostPrice, now) if buildErr != nil { return buildErr } @@ -352,6 +352,7 @@ func (h *AutoPurchaseHandler) loadPackages(ctx context.Context, packageIDs []uin } func (h *AutoPurchaseHandler) buildOrderAndItems( + ctx context.Context, rechargeOrder *model.RechargeOrder, packages []*model.Package, totalAmount int64, @@ -376,6 +377,7 @@ func (h *AutoPurchaseHandler) buildOrderAndItems( } paidAmount := totalAmount + operatorAccountID, operatorAccountName := h.buildPersonalCustomerOperatorSnapshot(ctx, rechargeOrder.UserID) order := &model.Order{ BaseModel: model.BaseModel{ Creator: rechargeOrder.UserID, @@ -396,6 +398,9 @@ func (h *AutoPurchaseHandler) buildOrderAndItems( Source: constants.OrderSourceClient, Generation: generation, ActualPaidAmount: &paidAmount, + OperatorAccountID: operatorAccountID, + OperatorAccountType: model.OperatorAccountTypePersonalCustomer, + OperatorAccountName: operatorAccountName, SellerShopID: sellerShopID, SeriesID: seriesID, SellerCostPrice: sellerCostPrice, @@ -422,6 +427,21 @@ func (h *AutoPurchaseHandler) buildOrderAndItems( return order, items, nil } +func (h *AutoPurchaseHandler) buildPersonalCustomerOperatorSnapshot(ctx context.Context, customerID uint) (*uint, string) { + if customerID == 0 { + return nil, "" + } + + var customer model.PersonalCustomer + if err := h.db.WithContext(ctx).Where("id = ?", customerID).First(&customer).Error; err != nil { + id := customerID + return &id, "" + } + + id := customerID + return &id, customer.Nickname +} + func (h *AutoPurchaseHandler) activatePackages( ctx context.Context, tx *gorm.DB, @@ -596,25 +616,25 @@ func (h *AutoPurchaseHandler) activateAddonPackage( Creator: order.Creator, Updater: order.Creator, }, - OrderID: order.ID, - OrderNo: order.OrderNo, - PackageID: pkg.ID, - PackageName: pkg.PackageName, - UsageType: order.OrderType, - DataLimitMB: pkg.RealDataMB, + OrderID: order.ID, + OrderNo: order.OrderNo, + PackageID: pkg.ID, + PackageName: pkg.PackageName, + UsageType: order.OrderType, + DataLimitMB: pkg.RealDataMB, VirtualTotalMBSnapshot: virtualTotalMBSnapshot, DisplayGainRatioSnapshot: displayGainRatioSnapshot, EnableVirtualDataSnapshot: enableVirtualDataSnapshot, - Status: constants.PackageUsageStatusActive, - Priority: priority, - MasterUsageID: &mainPackage.ID, - ActivatedAt: &now, - ExpiresAt: expiresAt, - DataResetCycle: pkg.DataResetCycle, - Generation: order.Generation, - PaidAmount: order.ActualPaidAmount, - PackagePriceConfigStatus: pkg.PriceConfigStatus, - PackageIsGift: pkg.IsGift, + Status: constants.PackageUsageStatusActive, + Priority: priority, + MasterUsageID: &mainPackage.ID, + ActivatedAt: &now, + ExpiresAt: expiresAt, + DataResetCycle: pkg.DataResetCycle, + Generation: order.Generation, + PaidAmount: order.ActualPaidAmount, + PackagePriceConfigStatus: pkg.PriceConfigStatus, + PackageIsGift: pkg.IsGift, } if carrierType == constants.AssetWalletResourceTypeIotCard { diff --git a/migrations/000140_add_order_operator_snapshot_and_commission_result.down.sql b/migrations/000140_add_order_operator_snapshot_and_commission_result.down.sql new file mode 100644 index 0000000..23b9779 --- /dev/null +++ b/migrations/000140_add_order_operator_snapshot_and_commission_result.down.sql @@ -0,0 +1,12 @@ +-- 回滚订单操作者权威快照字段与佣金业务结果字段 + +DROP INDEX IF EXISTS idx_orders_commission_result; +DROP INDEX IF EXISTS idx_orders_operator_account_id; + +ALTER TABLE tb_order + DROP COLUMN IF EXISTS commission_result, + DROP COLUMN IF EXISTS operator_account_name, + DROP COLUMN IF EXISTS operator_account_type, + DROP COLUMN IF EXISTS operator_account_id; + +COMMENT ON COLUMN tb_order.commission_status IS '佣金状态 1-待计算 2-已计算'; diff --git a/migrations/000140_add_order_operator_snapshot_and_commission_result.up.sql b/migrations/000140_add_order_operator_snapshot_and_commission_result.up.sql new file mode 100644 index 0000000..67baf3c --- /dev/null +++ b/migrations/000140_add_order_operator_snapshot_and_commission_result.up.sql @@ -0,0 +1,17 @@ +-- 为订单新增操作者权威快照字段与佣金业务结果字段 +-- 保留 legacy operator_id/operator_type 不删除,兼容历史订单读取 + +ALTER TABLE tb_order + ADD COLUMN IF NOT EXISTS operator_account_id BIGINT, + ADD COLUMN IF NOT EXISTS operator_account_type VARCHAR(32) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS operator_account_name VARCHAR(255) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS commission_result INT NOT NULL DEFAULT 0; + +COMMENT ON COLUMN tb_order.operator_account_id IS '操作者账号ID(权威字段,记录真实发起操作的账号/主体ID)'; +COMMENT ON COLUMN tb_order.operator_account_type IS '操作者账号类型(platform/agent/enterprise/personal_customer)'; +COMMENT ON COLUMN tb_order.operator_account_name IS '操作者名称快照'; +COMMENT ON COLUMN tb_order.commission_status IS '佣金流程状态 1-待计算 2-已完成 3-待人工处理'; +COMMENT ON COLUMN tb_order.commission_result IS '佣金业务结果 0-未知 1-有佣金 2-无佣金 3-链路异常'; + +CREATE INDEX IF NOT EXISTS idx_orders_operator_account_id ON tb_order(operator_account_id); +CREATE INDEX IF NOT EXISTS idx_orders_commission_result ON tb_order(commission_result); diff --git a/openspec/changes/fix-order-operator-and-chain-commission-semantics/.openspec.yaml b/openspec/changes/fix-order-operator-and-chain-commission-semantics/.openspec.yaml new file mode 100644 index 0000000..5f23b85 --- /dev/null +++ b/openspec/changes/fix-order-operator-and-chain-commission-semantics/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-29 diff --git a/openspec/changes/fix-order-operator-and-chain-commission-semantics/design.md b/openspec/changes/fix-order-operator-and-chain-commission-semantics/design.md new file mode 100644 index 0000000..74ce23f --- /dev/null +++ b/openspec/changes/fix-order-operator-and-chain-commission-semantics/design.md @@ -0,0 +1,109 @@ +## Context + +当前订单领域同时承载了三类不同语义: + +1. **谁操作了订单**:应该回答“哪个账号发起了这次下单动作”。 +2. **订单属于谁/卖给谁**:由 `buyer_type/buyer_id/seller_shop_id/purchase_role` 等业务字段表达。 +3. **这笔订单的差价佣金该给谁**:应从 `seller_shop_id` 出发,沿完整父级链逐级计算。 + +现状问题在于:`operator_id/operator_type` 被混成了“真实账号 + 店铺 ID + 平台空值”三种含义,导致平台订单无法稳定展示操作者;`commission_status` 又被混成“流程状态 + 业务结果”,导致“未触发计算”“已算完但没有佣金”“链路异常待处理”无法区分。历史提案已经明确:支付成功后要自动入队佣金计算,代购订单只跳过一次性佣金,不跳过差价佣金。因此本次设计不是新增分佣能力,而是把已有能力的语义纠偏为可长期维护的模型。 + +## Goals / Non-Goals + +**Goals:** +- 将订单操作者统一收敛为“真实账号语义”,平台与代理都能准确落库、展示和追溯。 +- 将订单佣金模型拆成“流程状态 + 业务结果”两层,明确区分无佣金与异常待人工处理。 +- 将差价佣金统一定义为基于 `seller_shop_id` 的完整上级链分配,不再受 `operator_id/operator_type` 影响。 +- 修正平台代扣、代理自购、代理为下级代购、代购自动完单等触发链路,保证所有适用订单都能进入佣金计算。 +- 设计兼容迁移方案,避免直接改变旧字段含义造成历史数据误读。 + +**Non-Goals:** +- 不重构一次性佣金规则,也不改变“代购订单不触发一次性佣金、不更新累计充值”的既有结论。 +- 不重写整套钱包模型,不额外引入外键、关联关系或新的异步基础设施。 +- 不在本次设计中解决所有历史脏数据,只定义兼容读取、渐进回填和后续修复路径。 + +## Decisions + +### Decision 1:新增“权威操作者快照”,不直接复用被污染的旧 `operator_*` 字段 + +**选择**:订单模型新增一组权威操作者字段(账号 ID、账号类型、名称快照),作为“谁操作了这笔订单”的唯一来源;现有 `operator_id/operator_type` 视为历史兼容字段,在迁移期通过回填/映射兼容读取。 + +**原因**: +- 直接把旧 `operator_id` 改成账号 ID 会让历史上保存店铺 ID 的订单立即失真。 +- 单独依赖 `creator` 虽然能覆盖部分后台订单,但缺少名称快照,且无法优雅覆盖未来其他入口。 +- 新增权威字段可以让写路径和读路径先稳定下来,再逐步清理历史值。 + +**备选方案**: +- **直接复用 `operator_id/operator_type`**:实现快,但会让历史数据语义混乱,风险最高。 +- **只依赖 `creator/updater`**:不需要新字段,但响应层仍需额外查询/推导,且无法表达多入口场景下的操作者快照。 + +### Decision 2:佣金采用“双轴模型”——流程状态与业务结果分离 + +**选择**:保留 `commission_status` 作为流程状态字段,但重新定义为“待计算 / 已完成 / 待人工处理”;新增 `commission_result` 作为业务结果字段,至少表达“有佣金 / 无佣金 / 链路异常”。 + +**原因**: +- “无佣金”是计算结果,不是待处理状态。 +- “链路断裂”需要触发人工排障,不应与普通完成混淆。 +- 双轴模型最适合补偿扫描:补偿只看流程状态,运营展示同时看流程状态和业务结果。 + +**备选方案**: +- **仅扩展 `commission_status` 单字段**:字段值会迅速膨胀,前后端更容易再次把流程与结果混用。 +- **仅依赖佣金记录条数推导结果**:0 条记录既可能是无佣金,也可能是任务未执行或链路异常,无法可靠区分。 + +### Decision 3:差价佣金只从 `seller_shop_id` 出发,沿完整父级链逐级计算 + +**选择**:佣金归属只依赖 `seller_shop_id` 的完整上级链,以及每一层对应的成本价/分配配置;`operator_id/operator_type/purchase_role` 不参与佣金归属判断,只参与审计和展示。 + +**原因**: +- 这样可以统一覆盖平台代扣、代理自购、代理为下级代购、多级代理链等所有场景。 +- 这与现有 `commission_calculation` 服务的主体算法一致,只是把现有“例子式理解”提升为正式规则。 +- 顶级代理“无佣金”只是父链终止后的自然结果,不需要单独特判。 + +**备选方案**: +- **按操作者类型分支**:会错误跳过平台代扣二级/三级代理的上级差价佣金。 +- **按买家类型硬编码一级/二级场景**:无法覆盖灵活多级代理链,且后续每加场景都要加特判。 + +### Decision 4:所有适用订单都要先进入佣金计算,再由计算结果决定“有/无佣金” + +**选择**:只要订单属于差价佣金适用范围,并在支付成功或自动完单后仍处于“待计算”,就必须入队 `commission:calculate`;worker 负责最终把订单落成“已完成+有佣金”“已完成+无佣金”或“待人工处理+链路异常”。 + +**原因**: +- 这样可以修复“平台代扣代理钱包但未入队”的现有缺口。 +- “无佣金”订单也需要被 worker 明确盖章为完成,否则后台永远分不清是漏算还是确实没有佣金。 +- Asynq 的幂等和补偿扫描可以继续复用,只需调整判定口径。 + +**备选方案**: +- **创建订单时先同步判断是否有佣金再决定是否入队**:会把链路计算逻辑分散到订单服务,增加重复实现和口径漂移风险。 + +### Decision 5:链路断裂统一落为待人工处理,并记录断点信息 + +**选择**:当父级链缺失、分配配置不存在、成本价无法继续求解等情况发生时,订单流程状态落为“待人工处理”,业务结果落为“链路异常”;同时保留待审佣金记录和断点备注,作为人工补偿入口。 + +**原因**: +- 这与现有 `CommissionStatusPendingReview` 及待审记录机制兼容。 +- 运营和财务可以明确知道这不是“无佣金”,而是“规则/数据有问题”。 + +**备选方案**: +- **仍然写成已完成但 0 佣金**:会掩盖真实异常,导致漏补偿。 + +## Risks / Trade-offs + +- **[历史订单语义混杂]** → 先新增权威字段、兼容读取,再做分批回填;旧 `operator_*` 不立即删除。 +- **[前端依赖旧 `operator_name` 为店铺名]** → 在提案中明确 BREAKING,订单响应补充账号名与业务店铺名分离的展示规则。 +- **[补偿扫描口径变化导致重复入队]** → 继续以流程状态做幂等守卫,worker 内部使用条件更新和重复执行跳过。 +- **[多级链路查询带来性能波动]** → 沿用现有逐级查询逻辑,但只在支付后异步执行;列表查询不做链路展开,避免影响接口时延。 +- **[历史挂起订单无法自动判断应为无佣金还是异常]** → 回填脚本只处理可确定样本,其余保持待人工处理,由运营复核。 + +## Migration Plan + +1. **Schema 先行**:为订单新增权威操作者字段与佣金结果字段,保留旧字段不删。 +2. **读路径兼容**:订单详情/列表优先读新字段;若新字段为空,则按 `creator + 旧 operator_*` 做兼容回显,并在响应中明确异常/历史场景。 +3. **写路径切换**:后台订单创建、支付回调、自动完单全部改为写入新操作者字段,并统一使用新佣金状态/结果模型。 +4. **触发链修复**:所有适用支付成功路径统一调用佣金入队逻辑;worker 在完成时写回流程状态和业务结果。 +5. **历史数据回填**:优先回填可由 `creator` 明确推断的后台订单;旧 `operator_id` 为店铺 ID 的记录不强改旧列,只填新列。 +6. **补偿与验证**:扫描“已支付且待计算”的订单重新入队;手工验证顶级代理无佣金、多级链路有佣金、链路断裂待人工处理三类样本。 +7. **回滚策略**:如新字段展示异常,可回退到兼容读逻辑并暂停新状态写入;旧字段仍保留,数据可读不丢失。 + +## Open Questions + +- 本次不保留开放性业务问题,默认采用“状态看流程、结果看业务”的双轴模型推进;如后续需要增加“计算中”状态,可在实现阶段作为非 breaking 扩展处理。 diff --git a/openspec/changes/fix-order-operator-and-chain-commission-semantics/proposal.md b/openspec/changes/fix-order-operator-and-chain-commission-semantics/proposal.md new file mode 100644 index 0000000..9f2e413 --- /dev/null +++ b/openspec/changes/fix-order-operator-and-chain-commission-semantics/proposal.md @@ -0,0 +1,38 @@ +## Why + +功能 ID:feature-001-order-operator-and-chain-commission-semantics。 + +当前订单链路把 `operator_id` 混用了“真实操作者账号”和“代理店铺 ID”两种语义,导致平台下单场景无法准确追溯是谁操作,订单详情中的 `operator_name` 也会在平台场景下长期为空。与此同时,`commission_status` 又被混用了“佣金计算流程状态”和“佣金业务结果”,使“未触发计算”“已计算但无佣金”“链路断裂待人工处理”三种完全不同的业务含义被压扁成同一个字段,已经影响后台判断、补偿逻辑和后续规则演进。 + +现在必须把这两个核心概念拆开:订单必须准确记录真实操作者;差价佣金必须按 `seller_shop_id` 的完整上级链逐级计算,“无佣金”必须成为明确结果而不是继续停留在“待计算”。 + +## What Changes + +- 修正订单操作者语义:订单中的操作者字段统一表示“真实发起操作的账号”,不再复用为代理店铺 ID;平台、代理、后续其他账号类型都必须能被准确记录和展示。 +- 修正订单响应与查询语义:订单详情/列表中的操作者名称按账号语义回填,店铺归属与买家归属继续由独立业务字段表达,避免“账号名”和“店铺名”混淆。 +- 修正差价佣金计算规则:所有会参与差价佣金的订单都从 `seller_shop_id` 出发,沿完整父级链逐级计算,不再使用“一级/二级代理特判”口径,也不再从 `operator_id/operator_type` 推导佣金归属。 +- 修正平台代扣、代理自购、代理为下级代购等场景的触发规则:只要订单属于差价佣金适用范围,系统都必须在支付成功或自动完单后触发佣金计算;是否有佣金由链路和价差决定,而不是由操作者类型决定。 +- 拆分佣金流程状态与业务结果:**BREAKING** 订单佣金状态语义将从“二元待计算/已计算”调整为“流程状态 + 结果语义”模型,至少区分“待计算/已完成/待人工处理”以及“有佣金/无佣金/链路异常”。 +- 修正链路异常处理:当上级链断裂、配置缺失或无法继续向上分配时,系统必须明确落为“待人工处理/链路异常”,而不是和普通“已完成”混淆。 +- 补充兼容迁移与手工验证方案:历史订单中 `operator_id` 为 NULL 或保存店铺 ID 的数据需要有兼容解释和回填方案;核心验证覆盖多级代理链、无佣金订单、链路断裂订单和补偿入队场景。 +- 增补文档与 OpenAPI:更新订单管理、代购订单、佣金计算、佣金触发等规范,确保前后端、运营和补偿流程都以新语义为准。 + +## Capabilities + +### New Capabilities +- 无 + +### Modified Capabilities +- `agent-order-role-tracking`: 将订单操作者从“店铺视角”修正为“真实账号视角”,并调整订单响应中的操作者展示规则。 +- `order-management`: 调整订单查询与响应中的操作者信息和佣金状态语义,明确订单字段各自承担的业务角色。 +- `purchase-on-behalf`: 修正平台代扣、代理自购、代理为下级代购等场景的差价佣金规则,统一为基于 `seller_shop_id` 的链路型计算。 +- `commission-calculation`: 将差价佣金定义为沿完整上级链逐级分配,并明确“无佣金”“链路异常待人工处理”等业务结果。 +- `commission-trigger`: 修正佣金任务触发条件与补偿口径,确保所有适用订单都能进入计算流程,且“无佣金”不再被误判为“待计算”。 + +## Impact + +- 受影响代码:`internal/model/order.go`、`internal/model/dto/order_dto.go`、`internal/service/order/service.go`、`internal/service/commission_calculation/service.go`、`internal/store/postgres/order_store.go`、`internal/task/commission_calculation.go`、`pkg/constants/*`。 +- 受影响接口:后台订单创建、订单列表、订单详情,以及所有依赖订单佣金状态和操作者展示的管理端页面。 +- 受影响系统:Asynq 佣金任务触发/补偿链路、钱包入账链路、运营人工排障流程、OpenAPI 文档与前端展示逻辑。 +- 数据影响:可能需要为历史订单增加兼容解释或回填脚本;需要手工验证多级代理链路、0 佣金订单、链路断裂订单和平台代扣订单的状态落点。 +- 性能与测试:链路计算必须继续满足逐级查询可控、列表查询分页、核心场景手工回放可验证;本变更会补充针对多级链路和补偿场景的验证清单。 diff --git a/openspec/changes/fix-order-operator-and-chain-commission-semantics/specs/agent-order-role-tracking/spec.md b/openspec/changes/fix-order-operator-and-chain-commission-semantics/specs/agent-order-role-tracking/spec.md new file mode 100644 index 0000000..eb0c85f --- /dev/null +++ b/openspec/changes/fix-order-operator-and-chain-commission-semantics/specs/agent-order-role-tracking/spec.md @@ -0,0 +1,93 @@ +## MODIFIED Requirements + +### Requirement: 订单操作者记录 + +系统 SHALL 在订单创建时记录操作者信息(谁下的单),并将其定义为**真实发起操作的账号**,区别于买家信息(资源所属者)和店铺归属信息。 + +#### Scenario: 平台创建订单 +- **WHEN** 平台账号创建订单 +- **THEN** 订单 MUST 记录平台账号作为操作者 +- **AND** 操作者信息不得再以 `NULL` 表示“平台” + +#### Scenario: 代理创建订单 +- **WHEN** 代理账号创建订单 +- **THEN** 订单 MUST 记录代理账号作为操作者 +- **AND** 操作者信息不得再复用为代理店铺 ID + +#### Scenario: 代理自购 +- **WHEN** 代理为自己的资源创建订单 +- **THEN** 订单的买家店铺可以等于操作者所属店铺 +- **BUT** 操作者字段仍表示账号身份而非店铺身份 + +#### Scenario: 代理代购 +- **WHEN** 代理为下级代理的资源创建订单 +- **THEN** 订单 MUST 同时区分买家店铺和操作者账号 +- **AND** 两者是否属于同一店铺不影响操作者语义 + +--- + +### Requirement: 订单查询增强 + +系统 SHALL 支持代理查询其权限范围内的订单,但数据权限和筛选逻辑 MUST 以买家/卖家相关业务字段为准,不得再依赖已承载账号语义的操作者字段推断店铺权限。 + +#### Scenario: 代理查询自己相关的订单 +- **WHEN** 代理查询订单列表 +- **THEN** 系统返回买家店铺或卖家店铺在其权限范围内的订单 +- **AND** 不要求操作者字段继续承载店铺 ID 语义 + +#### Scenario: 按订单角色筛选 +- **WHEN** 代理查询订单列表,指定 `purchase_role = "self_purchase"` +- **THEN** 系统只返回自己购买的订单 + +#### Scenario: 按订单角色筛选给下级购买的订单 +- **WHEN** 代理查询订单列表,指定 `purchase_role = "purchase_for_subordinate"` +- **THEN** 系统只返回为下级代理购买的订单 + +--- + +### Requirement: 订单响应包含角色信息 + +系统 SHALL 在订单响应中包含操作者和角色信息,前端展示时 MUST 将“账号操作者”与“业务店铺”分离展示。 + +#### Scenario: 订单响应包含操作者 ID +- **WHEN** 查询订单详情 +- **THEN** 响应包含操作者账号 ID 和操作者类型字段 + +#### Scenario: 平台订单返回操作者名称 +- **WHEN** 查询平台账号创建的订单详情 +- **THEN** 响应 MUST 返回平台账号名称作为 `operator_name` +- **AND** `operator_name` 不得因为操作者类型为平台而为空 + +#### Scenario: 代理订单返回操作者名称 +- **WHEN** 查询代理账号创建的订单详情 +- **THEN** 响应 MUST 返回代理账号名称作为 `operator_name` +- **AND** 不得再以店铺名替代账号名 + +#### Scenario: 订单响应包含角色标识 +- **WHEN** 查询订单详情 +- **THEN** 响应包含 `purchase_role`、`is_purchased_by_parent`、`purchase_remark` 字段 + +#### Scenario: 上级代购订单的备注 +- **WHEN** 查询上级代理购买的订单 +- **THEN** `purchase_remark` 可以引用操作者账号名或业务文案 +- **AND** 不得依赖历史店铺型 `operator_id` 才能生成备注 + +#### Scenario: 平台代购订单的备注 +- **WHEN** 查询平台代购订单 +- **THEN** `purchase_remark` 为"由平台代购"或等价平台文案 + +--- + +### Requirement: 向后兼容性 + +系统 SHALL 确保新增操作者语义不会导致历史订单无法查询,并对历史混合数据提供兼容解释。 + +#### Scenario: 现有订单字段为 NULL +- **WHEN** 查询历史订单 +- **THEN** 若新操作者字段为空,系统 MAY 回退使用 `creator` 和旧 `operator_*` 进行兼容展示 +- **AND** 不影响订单查询结果 + +#### Scenario: 历史订单 operator_id 为店铺 ID +- **WHEN** 查询历史订单且旧 `operator_id` 保存的是店铺 ID +- **THEN** 系统 MUST 将该值视为历史兼容数据 +- **AND** 不得继续把该旧值作为新订单操作者语义写回 diff --git a/openspec/changes/fix-order-operator-and-chain-commission-semantics/specs/commission-calculation/spec.md b/openspec/changes/fix-order-operator-and-chain-commission-semantics/specs/commission-calculation/spec.md new file mode 100644 index 0000000..95814b8 --- /dev/null +++ b/openspec/changes/fix-order-operator-and-chain-commission-semantics/specs/commission-calculation/spec.md @@ -0,0 +1,71 @@ +## MODIFIED Requirements + +### Requirement: 成本价差收入计算 + +系统 SHALL 为代理链上的每一级代理计算成本价差收入。差价佣金 MUST 从订单的 `seller_shop_id` 出发,沿完整父级链逐级计算;佣金归属 MUST NOT 由 `operator_id`、`operator_type` 或“一级/二级代理”特判决定。 + +#### Scenario: 单级代理 +- **WHEN** 一级代理销售套餐,售价 100 元,成本价 80 元 +- **THEN** 一级代理获得 20 元(100 - 80)成本价差收入 + +#### Scenario: 多级代理 +- **WHEN** 三级代理销售套餐,售价 100 元,各级成本价为:平台 50 → 一级 60 → 二级 70 → 三级 80 +- **THEN** 三级获得 20 元(100 - 80),二级获得 10 元(80 - 70),一级获得 10 元(70 - 60),平台获得 10 元(60 - 50) + +#### Scenario: 平台代扣二级及以上代理 +- **WHEN** 平台为任意非顶级代理创建并完成订单 +- **THEN** 系统仍从该订单的 `seller_shop_id` 向上逐级计算差价佣金 +- **AND** 只要上级链存在且存在有效价差,各级上级都获得对应佣金 + +#### Scenario: 顶级代理订单无上级 +- **WHEN** 顶级代理完成订单,`seller_shop_id` 无父级链 +- **THEN** 系统完成佣金计算流程 +- **AND** 不创建上级差价佣金记录 +- **AND** 该结果被标记为“无佣金”而非“待计算” + +#### Scenario: 成本价相同 +- **WHEN** 某级代理成本价等于下级成本价 +- **THEN** 该级代理成本价差收入为 0,不创建佣金记录 + +--- + +### Requirement: 代购订单佣金计算规则 + +代购订单 SHALL 计算差价佣金,但不触发一次性佣金。差价佣金的计算对象 MUST 是买家所属卖方链路上的全部有效上级,而不是仅限最近一级。 + +#### Scenario: 代购订单计算差价佣金 +- **WHEN** 代购订单(is_purchase_on_behalf = true)完成,买家有上级代理链 +- **THEN** 系统按 `seller_shop_id` 的完整上级链计算差价佣金 +- **AND** 将各级有效价差佣金分别发放给对应上级 + +#### Scenario: 代购订单不触发一次性佣金 +- **WHEN** 代购订单完成,佣金计算时检查订单类型 +- **THEN** 系统跳过一次性佣金判断逻辑,不发放一次性佣金 + +#### Scenario: 代购订单示例 +- **WHEN** 平台为三级代理代购,订单金额 100 元(三级成本价),各级成本价:一级 60 → 二级 70 → 三级 80 +- **THEN** 三级作为销售代理获得零售价差收入(如适用) +- **AND** 二级获得 10 元(80 - 70)差价佣金,一级获得 10 元(70 - 60)差价佣金 +- **AND** 该订单不触发一次性佣金 + +## ADDED Requirements + +### Requirement: 佣金计算结果落单 + +系统 SHALL 在佣金计算完成后,将订单落为明确的流程状态和业务结果,至少区分“有佣金”“无佣金”“链路异常待人工处理”。 + +#### Scenario: 计算完成且生成佣金记录 +- **WHEN** 订单佣金计算成功,生成一条或多条佣金记录 +- **THEN** 订单佣金流程状态为“已完成” +- **AND** 订单佣金业务结果为“有佣金” + +#### Scenario: 计算完成但无佣金记录 +- **WHEN** 订单佣金计算成功,但整条有效上级链上都没有正差价 +- **THEN** 订单佣金流程状态为“已完成” +- **AND** 订单佣金业务结果为“无佣金” + +#### Scenario: 上级链断裂 +- **WHEN** 佣金计算过程中发现父级链、成本价配置或分配关系无法继续向上求解 +- **THEN** 系统创建待人工处理的异常记录或备注 +- **AND** 订单佣金流程状态为“待人工处理” +- **AND** 订单佣金业务结果为“链路异常” diff --git a/openspec/changes/fix-order-operator-and-chain-commission-semantics/specs/commission-trigger/spec.md b/openspec/changes/fix-order-operator-and-chain-commission-semantics/specs/commission-trigger/spec.md new file mode 100644 index 0000000..d0a7ae3 --- /dev/null +++ b/openspec/changes/fix-order-operator-and-chain-commission-semantics/specs/commission-trigger/spec.md @@ -0,0 +1,72 @@ +## MODIFIED Requirements + +### Requirement: 支付成功后自动入队佣金计算任务 + +系统 SHALL 在订单首次进入“已支付”或自动完单后的待计算状态时自动 enqueue 佣金计算异步任务(`commission:calculate`),确保所有适用订单都进入佣金计算流程。系统 MUST NOT 预先根据操作者类型推断“这单没有佣金所以不用算”。 + +**触发条件**: +- 订单从"待支付"变为"已支付",或订单创建后即自动完单 +- 订单佣金流程状态为待计算 +- 订单属于差价佣金适用范围 + +**任务参数**: +- 任务类型:`commission:calculate` +- Payload:`{"order_id": <订单ID>}` + +#### Scenario: 首次支付成功触发计算 +- **WHEN** 订单支付成功,订单状态从"待支付"变为"已支付" +- **THEN** 系统自动 enqueue `commission:calculate` 任务,payload 包含订单 ID +- **AND** 订单佣金流程状态保持为待计算,直到 worker 写回最终结果 + +#### Scenario: 自动完单订单触发计算 +- **WHEN** 订单创建后即自动完单(如平台代购、适用的后台即时支付场景) +- **THEN** 系统同样自动 enqueue `commission:calculate` 任务 + +#### Scenario: 平台代扣顶级代理订单仍然入队 +- **WHEN** 平台代扣顶级代理钱包订单,系统尚未知道最终是否有佣金 +- **THEN** 系统仍然 enqueue `commission:calculate` 任务 +- **AND** 由 worker 最终判定该订单结果为“无佣金”或其他结果 + +#### Scenario: 重复支付不重复触发 +- **WHEN** 订单已经是"已支付"状态,再次收到支付成功通知(幂等场景) +- **THEN** 系统不重复 enqueue 佣金计算任务 +- **AND** 日志记录"订单已支付,跳过重复入队" + +#### Scenario: 已完成佣金流程的订单不触发 +- **WHEN** 订单佣金流程状态已为“已完成”或“待人工处理” +- **THEN** 系统跳过入队操作 +- **AND** 日志记录"订单佣金流程已结束,跳过入队" + +--- + +### Requirement: 佣金计算任务幂等性 + +系统 SHALL 确保佣金计算任务可重复执行,不重复发放佣金;worker 完成后 MUST 明确写回订单的佣金流程状态和业务结果。 + +**幂等检查**: +- 任务执行前检查订单佣金流程状态 +- 如果流程已结束,则跳过计算并返回成功 + +**状态更新**: +- 计算完成后将订单写为“已完成 + 有佣金/无佣金”或“待人工处理 + 链路异常” +- 状态更新与佣金记录创建在同一事务中 + +#### Scenario: 任务重复执行跳过计算 +- **WHEN** 佣金计算任务执行时,订单佣金流程状态已为“已完成” +- **THEN** 系统跳过佣金计算和钱包入账操作 +- **AND** 任务返回成功(避免 Asynq 重试) + +#### Scenario: 并发任务只有一个成功 +- **WHEN** 同一订单的佣金计算任务被重复入队,两个 worker 并发执行 +- **THEN** 第一个任务成功完成计算并写回最终状态 +- **AND** 第二个任务检查到流程已结束后跳过计算 + +#### Scenario: 无佣金订单也写回完成状态 +- **WHEN** worker 执行完毕,确认该订单没有任何应发差价佣金 +- **THEN** worker MUST 将订单写回“已完成 + 无佣金” +- **AND** 不允许订单继续停留在“待计算” + +#### Scenario: 任务失败可安全重试 +- **WHEN** 佣金计算任务执行失败(数据库异常、钱包服务不可用) +- **THEN** Asynq 自动重试任务 +- **AND** 重试时幂等检查确保不重复发放佣金 diff --git a/openspec/changes/fix-order-operator-and-chain-commission-semantics/specs/order-management/spec.md b/openspec/changes/fix-order-operator-and-chain-commission-semantics/specs/order-management/spec.md new file mode 100644 index 0000000..885ee72 --- /dev/null +++ b/openspec/changes/fix-order-operator-and-chain-commission-semantics/specs/order-management/spec.md @@ -0,0 +1,37 @@ +## ADDED Requirements + +### Requirement: 订单响应包含佣金流程状态与业务结果 + +系统 SHALL 在订单列表和订单详情响应中分别表达佣金流程状态和佣金业务结果,避免前端和运营把“待计算”“无佣金”“链路异常”混为一谈。 + +#### Scenario: 已计算无佣金订单 +- **WHEN** 查询一笔已完成计算但没有任何应发佣金的订单 +- **THEN** 响应中 MUST 返回“已完成”类佣金流程状态 +- **AND** MUST 返回“无佣金”类业务结果 + +#### Scenario: 生成佣金记录的订单 +- **WHEN** 查询一笔已经生成佣金记录的订单 +- **THEN** 响应中 MUST 返回“已完成”类佣金流程状态 +- **AND** MUST 返回“有佣金”类业务结果 + +#### Scenario: 链路断裂订单 +- **WHEN** 查询一笔因上级链断裂而需要人工处理的订单 +- **THEN** 响应中 MUST 返回“待人工处理”类佣金流程状态 +- **AND** MUST 返回“链路异常”类业务结果 + +### Requirement: 订单响应包含真实操作者展示信息 + +系统 SHALL 在订单列表和订单详情中返回真实操作者信息,并将账号操作者展示与业务店铺展示分离。 + +#### Scenario: 平台账号创建的订单 +- **WHEN** 查询平台账号创建的订单 +- **THEN** 响应 MUST 返回平台账号名称作为操作者名称 + +#### Scenario: 代理账号创建的订单 +- **WHEN** 查询代理账号创建的订单 +- **THEN** 响应 MUST 返回代理账号名称作为操作者名称 + +#### Scenario: 历史订单兼容展示 +- **WHEN** 查询尚未完成回填的历史订单 +- **THEN** 系统 MAY 通过 `creator` 或历史兼容字段补全操作者展示 +- **AND** 不得因为平台场景而默认返回空字符串 diff --git a/openspec/changes/fix-order-operator-and-chain-commission-semantics/specs/purchase-on-behalf/spec.md b/openspec/changes/fix-order-operator-and-chain-commission-semantics/specs/purchase-on-behalf/spec.md new file mode 100644 index 0000000..f3dc4c8 --- /dev/null +++ b/openspec/changes/fix-order-operator-and-chain-commission-semantics/specs/purchase-on-behalf/spec.md @@ -0,0 +1,75 @@ +## MODIFIED Requirements + +### Requirement: 代购订单自动完成 + +代购订单创建后 SHALL 自动完成支付流程,激活套餐,但不触发一次性佣金。 + +#### Scenario: 代购订单自动激活套餐 +- **WHEN** 创建代购订单成功 +- **THEN** 系统自动激活套餐(创建 PackageUsage 记录) + +#### Scenario: 代购订单不扣钱包 +- **WHEN** 创建线下代购订单 +- **THEN** 系统不扣减任何钱包余额(线下已收款) + +#### Scenario: 代购订单不更新累计充值 +- **WHEN** 代购订单完成 +- **THEN** 系统不更新卡/设备的 accumulated_recharge 字段 + +#### Scenario: 代购订单计算链路型差价佣金 +- **WHEN** 代购订单完成,买家存在有效上级代理链 +- **THEN** 系统从订单的 `seller_shop_id` 出发沿完整父级链计算差价佣金 +- **AND** 将各级有效差价分别发放给对应上级代理 + +#### Scenario: 代购订单无上级链 +- **WHEN** 代购订单对应的是顶级代理或其上级链没有任何正差价 +- **THEN** 系统完成佣金计算流程 +- **AND** 将订单结果标记为“无佣金” + +#### Scenario: 代购订单不触发一次性佣金 +- **WHEN** 代购订单完成 +- **THEN** 系统不检查一次性佣金阈值,不发放一次性佣金 + +--- + +### Requirement: 代理钱包代购 + +系统 SHALL 允许代理使用钱包支付(wallet)为下级代理创建代购订单,从自己钱包扣款并立即激活套餐。 + +#### Scenario: 代理为下级代理钱包代购 +- **WHEN** 代理选择下级代理的资源创建订单,支付方式为 wallet +- **THEN** 系统创建订单,`buyer_id` = 下级代理店铺 ID,`is_purchase_on_behalf` = true,`payment_method` = "wallet",`payment_status` = 2(已支付) +- **AND** 操作者字段记录真实代理账号,而不是代理店铺 ID + +#### Scenario: 钱包代购扣款操作者钱包 +- **WHEN** 代理使用 wallet 为下级代理购买套餐 +- **THEN** 系统从操作者所属代理的钱包扣款 + +#### Scenario: 钱包代购使用操作者成本价扣款 +- **WHEN** 一级代理(成本价 80 元)为二级代理(成本价 100 元)的资源创建 wallet 代购订单 +- **THEN** 系统从一级代理钱包扣款 80 元(操作者成本价) + +#### Scenario: 钱包代购订单金额显示买家成本价 +- **WHEN** 一级代理为二级代理钱包代购 +- **THEN** 订单的 `total_amount` = 100 元(买家成本价),`actual_paid_amount` = 80 元(操作者实际扣款) + +#### Scenario: 钱包代购余额不足 +- **WHEN** 代理使用 wallet 代购,但钱包余额不足 +- **THEN** 系统返回错误"余额不足",订单创建失败 + +#### Scenario: 钱包代购自动激活套餐 +- **WHEN** 钱包代购订单创建成功 +- **THEN** 系统自动激活套餐(创建 PackageUsage 记录) + +#### Scenario: 钱包代购触发差价佣金计算 +- **WHEN** 代理使用 wallet 代购订单完成 +- **THEN** 系统 MUST 触发差价佣金计算任务 +- **AND** 最终是否有佣金由 `seller_shop_id` 的完整上级链和价差结果决定 + +#### Scenario: 钱包代购不触发一次性佣金 +- **WHEN** 代理使用 wallet 代购订单完成 +- **THEN** 系统不检查一次性佣金阈值,不发放一次性佣金 + +#### Scenario: 钱包代购创建钱包流水 +- **WHEN** 代理使用 wallet 代购扣款成功 +- **THEN** 系统创建钱包流水记录,`transaction_type` = "deduct",`transaction_subtype` = "purchase_for_subordinate",`related_shop_id` = 下级代理店铺 ID diff --git a/openspec/changes/fix-order-operator-and-chain-commission-semantics/tasks.md b/openspec/changes/fix-order-operator-and-chain-commission-semantics/tasks.md new file mode 100644 index 0000000..4328f27 --- /dev/null +++ b/openspec/changes/fix-order-operator-and-chain-commission-semantics/tasks.md @@ -0,0 +1,35 @@ +## 1. 数据模型与迁移基线 + +- [x] 1.1 梳理订单现有 `operator_*`、`creator`、`commission_status` 字段的历史语义与使用点,产出兼容映射清单,并用 `lsp_diagnostics` 确认相关模型文件当前无错误 +- [x] 1.2 创建订单操作者权威字段与佣金结果字段的 migration(保留旧字段不删除),并核对 up/down 文件命名、注释与回滚策略完整 +- [x] 1.3 更新订单模型常量与字段定义,明确“佣金流程状态”和“佣金业务结果”两套枚举,并用 `lsp_diagnostics` 确认 `internal/model/order.go`、`pkg/constants/*` 无错误 + +## 2. 订单操作者语义修复 + +- [x] 2.1 调整后台订单创建链路,统一写入真实操作者账号信息,覆盖平台下单、代理自购、代理为下级代购、平台代扣代理钱包等场景,并用手工代码走查验证每条路径都有明确操作者来源 +- [x] 2.2 调整订单响应构建逻辑,按账号语义回填 `operator_name`,并将账号展示与业务店铺展示分离,用 `lsp_diagnostics` 确认 `internal/service/order/service.go` 与 DTO 文件无错误 +- [x] 2.3 设计并实现历史订单兼容读取策略:新字段优先,旧字段与 `creator` 兜底;手工抽查平台旧订单、代理旧订单各至少一条,确认不会再出现平台操作者名称默认空白 + +## 3. 链路型差价佣金模型修复 + +- [x] 3.1 调整佣金计算服务,统一以 `seller_shop_id` 为起点沿完整父级链逐级计算差价佣金,移除对 `operator_id/operator_type` 的佣金归属依赖,并用代码走查确认顶级、二级、三级、多级场景口径一致 +- [x] 3.2 在佣金计算完成后显式写回“已完成+有佣金 / 已完成+无佣金 / 待人工处理+链路异常”结果,确保 0 佣金订单不再停留在待计算 +- [x] 3.3 保留并增强链路断裂待人工处理逻辑,手工检查断点备注、待审记录与订单状态结果三者一致 + +## 4. 佣金触发与补偿链修复 + +- [x] 4.1 统一修复所有差价佣金适用路径的入队逻辑,覆盖支付回调、自动完单、平台代理扣代理钱包、代理钱包代购等场景,并用代码走查确认不会遗漏 `self_purchase` 的平台代理扣订单 +- [x] 4.2 调整佣金任务幂等判断与补偿扫描口径,使其基于新流程状态工作,避免“无佣金订单”被重复补偿入队 +- [x] 4.3 手工验证三类样本:顶级代理无佣金订单、多级代理有佣金订单、链路断裂待人工处理订单,确认任务执行后的订单状态与佣金记录符合预期 + +## 5. 订单查询与文档同步 + +- [x] 5.1 更新订单列表/详情相关 DTO 与接口文档,明确操作者字段语义以及佣金流程状态/业务结果字段语义,并用 `lsp_diagnostics` 确认 DTO 与 Handler 文件无错误 +- [x] 5.2 若接口响应结构发生变化,按项目规范同步更新文档生成器相关注册与说明文档,确保 OpenAPI 输出与新语义一致 +- [x] 5.3 补充中文功能总结与手工验收清单,覆盖平台订单操作者展示、多级链路差价佣金、无佣金完成态、链路异常待人工处理四类核心验收场景 + +## 6. 最终手工验收 + +- [x] 6.1 使用数据库查询逐条核对订单、佣金记录、钱包流水与操作者字段,确认核心样本数据落库符合新语义 +- [x] 6.2 对本次变更涉及的关键 Go 文件运行 `lsp_diagnostics`,确认无错误、无遗留坏状态 +- [x] 6.3 按验收清单完成后台订单列表/详情、补偿入队、异常订单识别的手工验证,确认提案范围内的业务流程可闭环 diff --git a/pkg/constants/iot.go b/pkg/constants/iot.go index 8a49203..e1573f4 100644 --- a/pkg/constants/iot.go +++ b/pkg/constants/iot.go @@ -360,14 +360,33 @@ func GetOrderPaymentStatusName(status int) string { } } -// GetOrderCommissionStatusName 获取订单佣金计算状态名称 -// 1=待计算, 2=已计算 +// GetOrderCommissionStatusName 获取订单佣金流程状态名称 +// 1=待计算, 2=已完成, 3=待人工处理 func GetOrderCommissionStatusName(status int) string { switch status { case 1: return "待计算" case 2: - return "已计算" + return "已完成" + case 3: + return "待人工处理" + default: + return "未知" + } +} + +// GetOrderCommissionResultName 获取订单佣金业务结果名称 +// 0=未知, 1=有佣金, 2=无佣金, 3=链路异常 +func GetOrderCommissionResultName(result int) string { + switch result { + case 0: + return "未知" + case 1: + return "有佣金" + case 2: + return "无佣金" + case 3: + return "链路异常" default: return "未知" }