Files
junhong_cmp_fiber/openspec/changes/archive/2025-04-11-fix-commission-status-constants/design.md
huang 677d6239ce
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m33s
fix: 统一佣金状态常量并修复佣金明细关联查询
- 删除 model/commission.go 中与 constants 包冲突的旧常量(值=1/2)
- 所有服务层改用 constants.CommissionStatusReleased(值=3)写入和查询
- 数据库迁移:status 1→3(已发放),2→4(已失效)
- 修复佣金明细列表接口,通过 JOIN 关联返回 order_no、iccid、virtual_no、order_created_at
- 新增 seller_shop_id / seller_shop_name 销售来源字段
- 统计接口过滤条件从精确匹配改为排除无效(NOT IN 4,99)
- 更新 OpenAPI 文档及 commission-record-query spec
2026-04-11 10:42:37 +08:00

196 lines
7.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
### 当前状态
佣金系统存在两套冲突的状态常量定义:
| 常量位置 | 状态 | 值 | 实际含义 |
|---------|------|-----|---------|
| `model/commission.go` | `CommissionStatusReleased` | 1 | 已入账 |
| `model/commission.go` | `CommissionStatusInvalid` | 2 | 已失效 |
| `pkg/constants/iot.go` | `CommissionStatusFrozen` | 1 | 已冻结 |
| `pkg/constants/iot.go` | `CommissionStatusUnfreezing` | 2 | 解冻中 |
| `pkg/constants/iot.go` | `CommissionStatusReleased` | 3 | 已发放 |
| `pkg/constants/iot.go` | `CommissionStatusInvalid` | 4 | 已失效 |
**问题**
1. 佣金计算时写入 `model.CommissionStatusReleased`(值=1`getCommissionStatusName()` 使用 `pkg/constants/` 的映射,导致 status=1 显示为"已冻结"
2. `commission-records` 接口 OrderNo、ICCID、VirtualNo 等字段硬编码为空,未关联查询
3. 缺少销售来源店铺信息
### 受影响代码位置
| 文件 | 问题 |
|------|------|
| `model/commission.go:40-46` | 定义了与 constants 包冲突的常量 |
| `commission_calculation/service.go:151,216,659` | 使用 model 常量 |
| `shop_commission/service.go:421` | 使用 constants 包做映射(值冲突) |
| `shop_commission/service.go:423-426` | 硬编码空值 |
| `commission_record_store.go:60-110` | 过滤条件未实现 |
## Goals / Non-Goals
**Goals:**
1. 统一佣金状态常量定义,消除二义性
2. 修复 `commission-records` 接口的关联查询
3. 新增销售来源店铺信息
**Non-Goals:**
- 不修改订单佣金状态(`order.commission_status`)的定义
- 不修改钱包冻结/解冻的业务逻辑
- 不修改佣金计算引擎的逻辑
## Decisions
### Decision 1: 统一使用 `pkg/constants/iot.go` 的四态定义
**选择理由**
- `pkg/constants/iot.go` 已有完整的四态定义(冻结→解冻中→已发放→失效)
- 该常量包被多处使用,包括 `getCommissionStatusName()` 函数
**关于冻结/解冻机制的澄清**
IoT 卡佣金系统**不实现**冻结/解冻机制(见 `add-one-time-commission/design.md` Non-Goals。四态常量中 status=1已冻结和 status=2解冻中是为号卡业务预留的IoT 卡差价佣金和一次性佣金均直接入账status=3。因此本次修复的核心是消除 `model` 包旧常量(值=1 表示"已入账")与 `constants` 包新常量(值=1 表示"已冻结")之间的语义冲突,将所有写入和查询统一到 `constants.CommissionStatusReleased`(值=3
**变更内容**
```go
// 删除 model/commission.go 第 40-46 行
const (
// CommissionStatusReleased = 1 // 删除
// CommissionStatusInvalid = 2 // 删除
)
// 使用 constants 包的定义
Status: constants.CommissionStatusReleased // 值=3
```
### Decision 2: 数据库状态值迁移
**方案**:通过 SQL 直接迁移历史数据
```sql
-- 迁移脚本
UPDATE tb_commission_record SET status = 3 WHERE status = 1; -- 已入账(旧值=1→ 已发放(新值=3
UPDATE tb_commission_record SET status = 4 WHERE status = 2; -- 已失效(旧值=2→ 已失效(新值=4
```
**注意**:迁移后需要验证数据一致性。
### Decision 3: Store 层实现 JOIN 关联查询
**方案**:修改 `ListByShopID` 方法,添加 LEFT JOIN并引入专用结果结构体承接扫描结果
```go
// commission_record_store.go
// CommissionRecordWithRelations 包含关联字段的查询结果
type CommissionRecordWithRelations struct {
model.CommissionRecord
OrderNo string `gorm:"column:order_no"`
OrderCreatedAt *time.Time `gorm:"column:order_created_at"`
ICCID string `gorm:"column:iccid"`
VirtualNo string `gorm:"column:virtual_no"`
SellerShopID *uint `gorm:"column:seller_shop_id"`
}
func (s *CommissionRecordStore) ListByShopID(...) ([]*CommissionRecordWithRelations, int64, error) {
query := s.db.WithContext(ctx).Model(&model.CommissionRecord{}).
Joins("LEFT JOIN tb_order o ON tb_commission_record.order_id = o.id").
Joins("LEFT JOIN tb_iot_card ic ON tb_commission_record.iot_card_id = ic.id").
Joins("LEFT JOIN tb_device d ON tb_commission_record.device_id = d.id")
// 投影必要字段
query = query.Select(`tb_commission_record.*,
o.order_no, o.created_at as order_created_at, o.seller_shop_id,
ic.iccid, d.virtual_no`)
// 过滤条件...
var records []*CommissionRecordWithRelations
// ...
query.Find(&records)
}
```
**说明**
- JOIN 条件必须使用实际表名 `tb_commission_record`GORM 不自动生成别名
- 使用嵌入 `model.CommissionRecord` 的专用结构体,避免污染原始模型
- `SellerShopID` 直接从订单表 JOIN 取得,`SellerShopName` 在 Service 层批量查询(见 Decision 5
**替代方案考虑**
- 方案 A当前选择Store 层 JOIN - 减少数据库往返次数
- 方案 BService 层批量查询 - 更灵活但 N+1 查询
### Decision 4: DTO 新增销售来源字段
```go
// shop_commission_dto.go
type ShopCommissionRecordItem struct {
// ... 现有字段
SellerShopID uint `json:"seller_shop_id"` // 新增
SellerShopName string `json:"seller_shop_name"` // 新增
}
```
**查询逻辑**
- `SellerShopID`:通过 `o.seller_shop_id` 从订单 JOIN 直接获取(已在 Decision 3 的 SELECT 中包含)
- `SellerShopName`Store 层不再多加一次 JOIN避免进一步增加 JOIN 复杂度),由 Service 层收集所有 `SellerShopID` 后批量查询 `tb_shop`,填充到 DTO
```go
// service 层伪代码
sellerShopIDs := collectUniqueSellerShopIDs(records)
shops, _ := s.shopStore.GetByIDs(ctx, sellerShopIDs)
shopNameMap := buildShopNameMap(shops)
for _, item := range items {
item.SellerShopName = shopNameMap[item.SellerShopID]
}
```
### Decision 5: 佣金统计查询的状态过滤语义
**问题**`GetStats``GetDailyStats` 目前用 `status = model.CommissionStatusReleased`(值=1过滤语义是"只统计已发放的佣金"。但总佣金应包含冻结中的佣金status=1,2,3 均为有效佣金,仅 status=4 失效、status=99 待人工处理应排除)。
**决策**:将两处过滤条件从"精确匹配已发放"改为"排除无效和待审"
```go
// 修改前
Where("status = ?", model.CommissionStatusReleased) // 值=1旧语义已入账
// 修改后
Where("status NOT IN (?)", []int{constants.CommissionStatusInvalid, constants.CommissionStatusPendingReview})
// 即 status NOT IN (4, 99),包含已冻结(1)、解冻中(2)、已发放(3)
```
**影响文件**`internal/store/postgres/commission_record_store.go` 第 123 行(`GetStats`)和第 169 行(`GetDailyStats`)。
IoT 卡当前实现中不存在 status=1/2 的记录,此改动为面向未来的正确语义,不影响现有数据结果。
## Risks / Trade-offs
**[风险] 数据库迁移可能影响历史数据**
**缓解措施**
1. 迁移前先备份数据
2. 在测试环境验证迁移脚本
3. 迁移后对比记录数确认
**[风险] JOIN 查询可能影响性能**
**缓解措施**
1. 确保 `order_id``iot_card_id``device_id` 有索引
2. 添加 LIMIT 和分页
3. 监控查询性能P95 < 200ms
**[风险] 常量变更可能影响其他模块**
**缓解措施**
1. 全局搜索 `model.CommissionStatus` 确保无遗漏
2. 编写单元测试验证状态值
## Open Questions
1. ~~**一次性佣金是否需要冻结逻辑?**~~ **已确认**IoT 卡差价佣金和一次性佣金均直接入账,不实现冻结/解冻。冻结机制为号卡业务预留IoT 卡侧本期 Non-Goal`add-one-time-commission/design.md`)。
2. **是否需要回滚旧数据的 status 值?** 还是直接迁移?→ 直接迁移1→3, 2→4
3. **销售店铺名称是否需要缓存?** 避免每次查询都 JOIN shop 表。