From 677d6239ced196fd6b2f332acd1d062854d75997 Mon Sep 17 00:00:00 2001 From: huang Date: Sat, 11 Apr 2026 10:42:37 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=E4=BD=A3=E9=87=91?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=B8=B8=E9=87=8F=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=BD=A3=E9=87=91=E6=98=8E=E7=BB=86=E5=85=B3=E8=81=94=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 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 --- docs/admin-openapi.yaml | 9 +- internal/model/commission.go | 10 +- internal/model/dto/shop_commission_dto.go | 4 +- .../service/commission_calculation/service.go | 8 +- internal/service/recharge/service.go | 2 +- internal/service/refund/service.go | 2 +- internal/service/shop_commission/service.go | 39 +++- .../store/postgres/commission_record_store.go | 46 +++-- .../000113_migrate_commission_status.down.sql | 2 + .../000113_migrate_commission_status.up.sql | 5 + opencode.json | 4 +- .../.openspec.yaml | 2 + .../design.md | 195 ++++++++++++++++++ .../proposal.md | 67 ++++++ .../specs/commission-record-query/spec.md | 104 ++++++++++ .../tasks.md | 83 ++++++++ .../specs/commission-record-query/spec.md | 60 +++++- 17 files changed, 604 insertions(+), 38 deletions(-) create mode 100644 migrations/000113_migrate_commission_status.down.sql create mode 100644 migrations/000113_migrate_commission_status.up.sql create mode 100644 openspec/changes/archive/2025-04-11-fix-commission-status-constants/.openspec.yaml create mode 100644 openspec/changes/archive/2025-04-11-fix-commission-status-constants/design.md create mode 100644 openspec/changes/archive/2025-04-11-fix-commission-status-constants/proposal.md create mode 100644 openspec/changes/archive/2025-04-11-fix-commission-status-constants/specs/commission-record-query/spec.md create mode 100644 openspec/changes/archive/2025-04-11-fix-commission-status-constants/tasks.md diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index 468bebc..da160aa 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -6182,8 +6182,15 @@ components: order_no: description: 订单号 type: string + seller_shop_id: + description: 销售来源店铺ID + minimum: 0 + type: integer + seller_shop_name: + description: 销售来源店铺名称 + type: string status: - description: 状态 (1:已入账, 2:已失效) + description: 状态 (1:已冻结, 2:解冻中, 3:已发放, 4:已失效) type: integer status_name: description: 状态名称 diff --git a/internal/model/commission.go b/internal/model/commission.go index e8c178c..3f3bd41 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-已失效" json:"status"` + Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-已冻结 2-解冻中 3-已发放 4-已失效" json:"status"` ReleasedAt *time.Time `gorm:"column:released_at;comment:入账时间" json:"released_at"` Remark string `gorm:"column:remark;type:varchar(500);comment:备注" json:"remark"` } @@ -36,11 +36,3 @@ const ( // CommissionSourceOneTime 一次性佣金 CommissionSourceOneTime = "one_time" ) - -// 佣金状态常量 -const ( - // CommissionStatusReleased 已入账 - CommissionStatusReleased = 1 - // CommissionStatusInvalid 已失效 - CommissionStatusInvalid = 2 -) diff --git a/internal/model/dto/shop_commission_dto.go b/internal/model/dto/shop_commission_dto.go index d0addba..7830ccd 100644 --- a/internal/model/dto/shop_commission_dto.go +++ b/internal/model/dto/shop_commission_dto.go @@ -144,13 +144,15 @@ type ShopCommissionRecordItem struct { Amount int64 `json:"amount" description:"佣金金额(分)"` BalanceAfter int64 `json:"balance_after" description:"入账后佣金余额(分)"` CommissionSource string `json:"commission_source" description:"佣金来源 (cost_diff:成本价差, one_time:一次性佣金, tier_bonus(已废弃):梯度奖励)"` - Status int `json:"status" description:"状态 (1:已入账, 2:已失效)"` + Status int `json:"status" description:"状态 (1:已冻结, 2:解冻中, 3:已发放, 4:已失效)"` StatusName string `json:"status_name" description:"状态名称"` OrderID uint `json:"order_id" description:"订单ID"` OrderNo string `json:"order_no" description:"订单号"` VirtualNo string `json:"virtual_no,omitempty" description:"设备虚拟号"` ICCID string `json:"iccid,omitempty" description:"ICCID"` OrderCreatedAt string `json:"order_created_at" description:"订单创建时间"` + SellerShopID uint `json:"seller_shop_id" description:"销售来源店铺ID"` + SellerShopName string `json:"seller_shop_name" description:"销售来源店铺名称"` CreatedAt string `json:"created_at" description:"佣金入账时间"` } diff --git a/internal/service/commission_calculation/service.go b/internal/service/commission_calculation/service.go index 5c16c3f..143ee5e 100644 --- a/internal/service/commission_calculation/service.go +++ b/internal/service/commission_calculation/service.go @@ -148,7 +148,7 @@ func (s *Service) CalculateCostDiffCommission(ctx context.Context, order *model. DeviceID: order.DeviceID, CommissionSource: model.CommissionSourceCostDiff, Amount: sellerProfit, - Status: model.CommissionStatusReleased, + Status: constants.CommissionStatusReleased, }) } @@ -213,7 +213,7 @@ func (s *Service) CalculateCostDiffCommission(ctx context.Context, order *model. DeviceID: order.DeviceID, CommissionSource: model.CommissionSourceCostDiff, Amount: profit, - Status: model.CommissionStatusReleased, + Status: constants.CommissionStatusReleased, }) } @@ -514,7 +514,7 @@ func (s *Service) calculateChainOneTimeCommission(ctx context.Context, bottomSho DeviceID: deviceID, CommissionSource: model.CommissionSourceOneTime, Amount: actualProfit, - Status: model.CommissionStatusReleased, + Status: constants.CommissionStatusReleased, Remark: remark, }) } @@ -656,7 +656,7 @@ func (s *Service) creditCommissionInTx(ctx context.Context, tx *gorm.DB, record now := time.Now() if err := tx.Model(record).Updates(map[string]any{ "balance_after": balanceBefore + record.Amount, - "status": model.CommissionStatusReleased, + "status": constants.CommissionStatusReleased, "released_at": now, }).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新佣金记录失败") diff --git a/internal/service/recharge/service.go b/internal/service/recharge/service.go index 3bac20d..560a435 100644 --- a/internal/service/recharge/service.go +++ b/internal/service/recharge/service.go @@ -661,7 +661,7 @@ func (s *Service) triggerOneTimeCommissionIfNeededInTx(ctx context.Context, tx * DeviceID: deviceID, CommissionSource: model.CommissionSourceOneTime, Amount: commissionAmount, - Status: model.CommissionStatusReleased, + Status: constants.CommissionStatusReleased, Remark: "钱包充值触发一次性佣金", } diff --git a/internal/service/refund/service.go b/internal/service/refund/service.go index 3079973..31328a9 100644 --- a/internal/service/refund/service.go +++ b/internal/service/refund/service.go @@ -341,7 +341,7 @@ func (s *Service) deductAllCommission(ctx context.Context, refundID uint) { // 查询该订单所有已入账佣金记录 var commissions []model.CommissionRecord - if err := s.db.Where("order_id = ? AND status = ?", refund.OrderID, model.CommissionStatusReleased).Find(&commissions).Error; err != nil { + if err := s.db.Where("order_id = ? AND status = ?", refund.OrderID, constants.CommissionStatusReleased).Find(&commissions).Error; err != nil { logger.Error("佣金回扣:查询佣金记录失败", zap.Uint("refund_id", refundID), zap.Uint("order_id", refund.OrderID), zap.Error(err)) return } diff --git a/internal/service/shop_commission/service.go b/internal/service/shop_commission/service.go index 2c065c0..a29a5df 100644 --- a/internal/service/shop_commission/service.go +++ b/internal/service/shop_commission/service.go @@ -410,8 +410,33 @@ func (s *Service) ListShopCommissionRecords(ctx context.Context, shopID uint, re return nil, errors.Wrap(errors.CodeInternalError, err, "查询佣金明细失败") } + sellerShopIDs := make([]uint, 0) + for _, r := range records { + if r.SellerShopID != nil && *r.SellerShopID > 0 { + sellerShopIDs = append(sellerShopIDs, *r.SellerShopID) + } + } + + shopNameMap := make(map[uint]string) + if len(sellerShopIDs) > 0 { + shops, err := s.shopStore.GetByIDs(ctx, sellerShopIDs) + if err == nil { + for _, sh := range shops { + shopNameMap[sh.ID] = sh.ShopName + } + } + } + items := make([]dto.ShopCommissionRecordItem, 0, len(records)) for _, r := range records { + var orderCreatedAt string + if r.OrderCreatedAt != nil { + orderCreatedAt = r.OrderCreatedAt.Format("2006-01-02 15:04:05") + } + var sellerShopID uint + if r.SellerShopID != nil { + sellerShopID = *r.SellerShopID + } item := dto.ShopCommissionRecordItem{ ID: r.ID, Amount: r.Amount, @@ -420,10 +445,12 @@ func (s *Service) ListShopCommissionRecords(ctx context.Context, shopID uint, re Status: r.Status, StatusName: getCommissionStatusName(r.Status), OrderID: r.OrderID, - OrderNo: "", - VirtualNo: "", - ICCID: "", - OrderCreatedAt: "", + OrderNo: r.OrderNo, + VirtualNo: r.VirtualNo, + ICCID: r.ICCID, + OrderCreatedAt: orderCreatedAt, + SellerShopID: sellerShopID, + SellerShopName: shopNameMap[sellerShopID], CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"), } items = append(items, item) @@ -746,7 +773,7 @@ func (s *Service) ResolveCommissionRecord(ctx context.Context, recordID uint, re if req.Action == "invalidate" { return s.commissionRecordStore.UpdateByID(ctx, nil, recordID, map[string]any{ - "status": model.CommissionStatusInvalid, + "status": constants.CommissionStatusInvalid, "remark": resolveRemark, }) } @@ -764,7 +791,7 @@ func (s *Service) ResolveCommissionRecord(ctx context.Context, recordID uint, re return s.db.Transaction(func(tx *gorm.DB) error { if err := s.commissionRecordStore.UpdateByID(ctx, tx, recordID, map[string]any{ - "status": model.CommissionStatusReleased, + "status": constants.CommissionStatusReleased, "amount": amount, "released_at": now, "remark": resolveRemark, diff --git a/internal/store/postgres/commission_record_store.go b/internal/store/postgres/commission_record_store.go index dc85543..1e0ea1c 100644 --- a/internal/store/postgres/commission_record_store.go +++ b/internal/store/postgres/commission_record_store.go @@ -2,6 +2,7 @@ package postgres import ( "context" + "time" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/store" @@ -11,6 +12,15 @@ import ( "gorm.io/gorm" ) +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"` +} + type CommissionRecordStore struct { db *gorm.DB redis *redis.Client @@ -57,29 +67,39 @@ type CommissionRecordListFilters struct { Status *int } -func (s *CommissionRecordStore) ListByShopID(ctx context.Context, opts *store.QueryOptions, filters *CommissionRecordListFilters) ([]*model.CommissionRecord, int64, error) { - var records []*model.CommissionRecord +func (s *CommissionRecordStore) ListByShopID(ctx context.Context, opts *store.QueryOptions, filters *CommissionRecordListFilters) ([]*CommissionRecordWithRelations, int64, error) { var total int64 - query := s.db.WithContext(ctx).Model(&model.CommissionRecord{}) - // 应用数据权限过滤 + query := s.db.WithContext(ctx).Model(&model.CommissionRecord{}). + Joins("LEFT JOIN tb_order o ON tb_commission_record.order_id = o.id AND o.deleted_at IS NULL"). + Joins("LEFT JOIN tb_iot_card ic ON tb_commission_record.iot_card_id = ic.id AND ic.deleted_at IS NULL"). + Joins("LEFT JOIN tb_device d ON tb_commission_record.device_id = d.id AND d.deleted_at IS NULL") query = middleware.ApplyShopFilter(ctx, query) if filters != nil { if filters.ShopID > 0 { - query = query.Where("shop_id = ?", filters.ShopID) + query = query.Where("tb_commission_record.shop_id = ?", filters.ShopID) } if filters.CommissionSource != "" { - query = query.Where("commission_source = ?", filters.CommissionSource) + query = query.Where("tb_commission_record.commission_source = ?", filters.CommissionSource) } if filters.StartTime != nil && *filters.StartTime != "" { - query = query.Where("created_at >= ?", *filters.StartTime) + query = query.Where("tb_commission_record.created_at >= ?", *filters.StartTime) } if filters.EndTime != nil && *filters.EndTime != "" { - query = query.Where("created_at <= ?", *filters.EndTime) + query = query.Where("tb_commission_record.created_at <= ?", *filters.EndTime) } if filters.Status != nil { - query = query.Where("status = ?", *filters.Status) + query = query.Where("tb_commission_record.status = ?", *filters.Status) + } + if filters.ICCID != "" { + query = query.Where("ic.iccid LIKE ?", "%"+filters.ICCID+"%") + } + if filters.OrderNo != "" { + query = query.Where("o.order_no = ?", filters.OrderNo) + } + if filters.DeviceNo != "" { + query = query.Where("d.virtual_no LIKE ?", "%"+filters.DeviceNo+"%") } } @@ -94,14 +114,16 @@ func (s *CommissionRecordStore) ListByShopID(ctx context.Context, opts *store.Qu } } offset := (opts.Page - 1) * opts.PageSize + 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`) query = query.Offset(offset).Limit(opts.PageSize) if opts.OrderBy != "" { query = query.Order(opts.OrderBy) } else { - query = query.Order("created_at DESC") + query = query.Order("tb_commission_record.created_at DESC") } + var records []*CommissionRecordWithRelations if err := query.Find(&records).Error; err != nil { return nil, 0, err } @@ -120,7 +142,7 @@ type CommissionStats struct { func (s *CommissionRecordStore) GetStats(ctx context.Context, filters *CommissionRecordListFilters) (*CommissionStats, error) { query := s.db.WithContext(ctx).Model(&model.CommissionRecord{}). - Where("status = ?", model.CommissionStatusReleased) + Where("status NOT IN (?)", []int{constants.CommissionStatusInvalid, constants.CommissionStatusPendingReview}) // 应用数据权限过滤 query = middleware.ApplyShopFilter(ctx, query) @@ -166,7 +188,7 @@ func (s *CommissionRecordStore) GetDailyStats(ctx context.Context, filters *Comm } query := s.db.WithContext(ctx).Model(&model.CommissionRecord{}). - Where("status = ?", model.CommissionStatusReleased) + Where("status NOT IN (?)", []int{constants.CommissionStatusInvalid, constants.CommissionStatusPendingReview}) // 应用数据权限过滤 query = middleware.ApplyShopFilter(ctx, query) diff --git a/migrations/000113_migrate_commission_status.down.sql b/migrations/000113_migrate_commission_status.down.sql new file mode 100644 index 0000000..95c63e3 --- /dev/null +++ b/migrations/000113_migrate_commission_status.down.sql @@ -0,0 +1,2 @@ +UPDATE tb_commission_record SET status = 1 WHERE status = 3; +UPDATE tb_commission_record SET status = 2 WHERE status = 4; diff --git a/migrations/000113_migrate_commission_status.up.sql b/migrations/000113_migrate_commission_status.up.sql new file mode 100644 index 0000000..efe300f --- /dev/null +++ b/migrations/000113_migrate_commission_status.up.sql @@ -0,0 +1,5 @@ +-- 迁移佣金记录状态值:将旧常量值迁移到新四态常量值 +-- 旧值 1(已入账) → 新值 3(已发放) +-- 旧值 2(已失效) → 新值 4(已失效) +UPDATE tb_commission_record SET status = 3 WHERE status = 1; +UPDATE tb_commission_record SET status = 4 WHERE status = 2; diff --git a/opencode.json b/opencode.json index 9983b76..83fe564 100644 --- a/opencode.json +++ b/opencode.json @@ -7,9 +7,9 @@ "apiKey": "sk-ZBGcMXCdwtSK7G35s" } }, - "google": { + "minimax": { "options": { - "baseURL": "http://45.155.220.179:8317/v1beta", + "baseURL": "http://45.155.220.179:8317/v1", "apiKey": "sk-ZBGcMXCdwtSK7G35s" } } diff --git a/openspec/changes/archive/2025-04-11-fix-commission-status-constants/.openspec.yaml b/openspec/changes/archive/2025-04-11-fix-commission-status-constants/.openspec.yaml new file mode 100644 index 0000000..11393ea --- /dev/null +++ b/openspec/changes/archive/2025-04-11-fix-commission-status-constants/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-11 diff --git a/openspec/changes/archive/2025-04-11-fix-commission-status-constants/design.md b/openspec/changes/archive/2025-04-11-fix-commission-status-constants/design.md new file mode 100644 index 0000000..f8b2c6f --- /dev/null +++ b/openspec/changes/archive/2025-04-11-fix-commission-status-constants/design.md @@ -0,0 +1,195 @@ +## 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 - 减少数据库往返次数 +- 方案 B:Service 层批量查询 - 更灵活但 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 表。 diff --git a/openspec/changes/archive/2025-04-11-fix-commission-status-constants/proposal.md b/openspec/changes/archive/2025-04-11-fix-commission-status-constants/proposal.md new file mode 100644 index 0000000..12fd506 --- /dev/null +++ b/openspec/changes/archive/2025-04-11-fix-commission-status-constants/proposal.md @@ -0,0 +1,67 @@ +## Why + +佣金系统存在**状态常量定义冲突**和**接口数据不完整**两大问题: + +1. **常量冲突**:`model/commission.go` 和 `pkg/constants/iot.go` 定义了两套不同的佣金状态常量,值 1 在 model 包表示"已入账",在 constants 包表示"已冻结",导致佣金记录显示错误 +2. **接口缺陷**:`/commission-records` 接口返回的 ICCID、OrderNo、VirtualNo 等字段全部为空,且缺少销售来源店铺信息 + +这些问题导致:差价佣金被错误显示为"已冻结"、佣金明细无法关联到具体订单和卡、无法追溯佣金产生的销售来源。 + +## What Changes + +### 1. 统一佣金状态常量 +- 删除 `model/commission.go` 中的旧常量定义 +- 统一使用 `pkg/constants/iot.go` 的四态定义(已冻结→解冻中→已发放→已失效) +- 迁移数据库中 status 值(1→3, 2→4) +- 修复 12 处引用旧常量的代码位置 + +### 2. 修复 commission-records 接口关联查询 +- Store 层实现 JOIN 关联查询(订单表、卡表、设备表) +- 实现 ICCID、OrderNo、DeviceNo 的过滤条件 +- Service 层正确填充 OrderNo、ICCID、VirtualNo、OrderCreatedAt 字段 + +### 3. 新增销售来源店铺信息 +- DTO 新增 SellerShopID、SellerShopName 字段 +- 关联查询订单的 `seller_shop_id` 显示销售来源 + +### 4. 修复佣金统计查询语义 +- `GetStats` 和 `GetDailyStats` 的过滤条件从"精确匹配已发放(status=1)"改为"排除无效和待审(status NOT IN 4,99)" +- 使总佣金统计包含冻结中的佣金(面向未来的正确语义) + +### 5. 数据迁移 +- 编写数据库迁移脚本转换历史数据 + +## Capabilities + +### New Capabilities +- `commission-record-query`: 佣金记录完整查询能力 + - 支持关联查询订单、卡、设备信息 + - 支持按 ICCID、订单号、设备号过滤 + - 显示销售来源店铺信息 + +### Modified Capabilities +- `commission-status`: 佣金状态定义 + - 状态值从二态(已入账、已失效)扩展为四态(已冻结、解冻中、已发放、已失效) + +## Impact + +### 受影响代码 +| 文件 | 影响 | +|------|------| +| `model/commission.go` | 删除旧常量定义 | +| `pkg/constants/iot.go` | 确认常量定义正确 | +| `internal/service/commission_calculation/service.go` | 替换常量引用(3处) | +| `internal/service/shop_commission/service.go` | 替换常量引用 + 填充关联字段(2处) | +| `internal/service/refund/service.go` | 替换常量引用(1处) | +| `internal/service/recharge/service.go` | 替换常量引用(1处) | +| `internal/store/postgres/commission_record_store.go` | 实现过滤条件和 JOIN;修复统计查询语义 | +| `internal/model/dto/shop_commission_dto.go` | DTO 新增字段 | + +### 受影响接口 +- `GET /api/admin/shops/:shop_id/commission-records` - 返回值结构变更(新增字段) + +### 数据库迁移 +- `tb_commission_record.status`: 1→3, 2→4 + +### 依赖项 +- 无新增外部依赖 diff --git a/openspec/changes/archive/2025-04-11-fix-commission-status-constants/specs/commission-record-query/spec.md b/openspec/changes/archive/2025-04-11-fix-commission-status-constants/specs/commission-record-query/spec.md new file mode 100644 index 0000000..150a430 --- /dev/null +++ b/openspec/changes/archive/2025-04-11-fix-commission-status-constants/specs/commission-record-query/spec.md @@ -0,0 +1,104 @@ +# Commission Record Query + +## ADDED Requirements + +### Requirement: 佣金明细列表查询 + +系统 SHALL 支持通过店铺 ID 查询该店铺的佣金明细列表,包含完整的订单、卡、设备关联信息。 + +#### Scenario: 查询佣金明细列表 +- **WHEN** 调用 `GET /api/admin/shops/:shop_id/commission-records` 接口 +- **THEN** 返回佣金记录列表,每条记录包含: + - 佣金记录 ID、金额、状态、状态名称 + - 订单号(order_no) + - 订单创建时间(order_created_at) + - ICCID(当订单类型为单卡时) + - 设备虚拟号(当订单类型为设备时) + - 销售来源店铺 ID 和名称(seller_shop_id, seller_shop_name) + - 佣金入账时间 + +#### Scenario: 按佣金来源过滤 +- **WHEN** 请求包含 `commission_source` 查询参数 +- **THEN** 仅返回指定来源的记录(cost_diff 或 one_time) + +#### Scenario: 按 ICCID 模糊查询 +- **WHEN** 请求包含 `iccid` 查询参数 +- **THEN** 仅返回 ICCID 包含指定值的记录 + +#### Scenario: 按设备虚拟号模糊查询 +- **WHEN** 请求包含 `virtual_no` 查询参数 +- **THEN** 仅返回设备虚拟号包含指定值的记录 + +#### Scenario: 按订单号精确查询 +- **WHEN** 请求包含 `order_no` 查询参数 +- **THEN** 仅返回订单号完全匹配的记录 + +#### Scenario: 分页查询 +- **WHEN** 请求分页参数(page, page_size) +- **THEN** 返回指定页的记录,总数和页码信息 + +--- + +### Requirement: 佣金状态常量一致性 + +系统 SHALL 使用统一的状态值和状态名称映射,确保佣金状态显示正确。 + +#### Scenario: 已发放状态正确显示 +- **WHEN** 佣金记录 status = 3 +- **THEN** 状态名称返回 "已发放" + +#### Scenario: 已冻结状态正确显示 +- **WHEN** 佣金记录 status = 1 +- **THEN** 状态名称返回 "已冻结" + +#### Scenario: 解冻中状态正确显示 +- **WHEN** 佣金记录 status = 2 +- **THEN** 状态名称返回 "解冻中" + +#### Scenario: 已失效状态正确显示 +- **WHEN** 佣金记录 status = 4 +- **THEN** 状态名称返回 "已失效" + +--- + +### Requirement: 佣金统计接口 + +系统 SHALL 支持获取指定店铺的佣金统计信息,包括累计佣金、已提现、可提现等。 + +#### Scenario: 查询佣金统计 +- **WHEN** 调用 `GET /api/admin/shops/:shop_id/commission-stats` 接口 +- **THEN** 返回佣金统计数据: + - 总佣金金额 + - 差价佣金金额和占比 + - 一次性佣金金额和占比 + - 佣金记录数 + +--- + +## MODIFIED Requirements + +### Requirement: 佣金状态定义 + +**原内容**: + +佣金记录状态为二态:1=已入账,2=已失效 + +**修改为**: + +佣金记录状态为四态: +- 1 = 已冻结:佣金已计算但暂不可提现 +- 2 = 解冻中:满足解冻条件,正在等待发放 +- 3 = 已发放:佣金已入账可提现 +- 4 = 已失效:佣金核验失败或订单退款导致失效 + +#### Scenario: 差价佣金创建时状态 +- **WHEN** 差价佣金计算完成并创建记录 +- **THEN** 记录状态为 3(已发放) + +#### Scenario: 链路断裂时状态 +- **WHEN** 佣金链路断裂(上级代理未分配套餐) +- **THEN** 记录状态为 99(待人工修正) + +#### Scenario: 订单退款时状态 +- **WHEN** 关联订单发生退款 +- **THEN** 佣金记录状态更新为 4(已失效) diff --git a/openspec/changes/archive/2025-04-11-fix-commission-status-constants/tasks.md b/openspec/changes/archive/2025-04-11-fix-commission-status-constants/tasks.md new file mode 100644 index 0000000..c20c463 --- /dev/null +++ b/openspec/changes/archive/2025-04-11-fix-commission-status-constants/tasks.md @@ -0,0 +1,83 @@ +## 0. 测试准备 + +- [x] 0.1 查询数据库中现有佣金记录的状态分布:`SELECT status, COUNT(*) FROM tb_commission_record GROUP BY status` +- [x] 0.2 确认现有状态值与常量定义的映射关系 + +## 1. 数据库迁移 + +- [x] 1.1 创建迁移文件:将 `tb_commission_record.status` 从旧值迁移到新值(1→3, 2→4) +- [x] 1.2 在测试环境执行迁移并验证:`SELECT status, COUNT(*) FROM tb_commission_record GROUP BY status` +- [x] 1.3 备份生产数据后执行迁移 + +## 2. 删除冲突常量 + +- [x] 2.1 删除 `internal/model/commission.go` 中的旧常量定义(第 40-46 行) +- [x] 2.2 更新 `internal/model/commission.go` 中 `CommissionRecord.Status` 字段的 GORM 注释:`1-已入账 2-已失效` → `1-已冻结 2-解冻中 3-已发放 4-已失效` + +## 3. 替换常量引用 + +- [x] 3.1 修改 `internal/service/commission_calculation/service.go` 中的 4 处常量引用 + - [x] 3.1.1 第 151 行:`model.CommissionStatusReleased` → `constants.CommissionStatusReleased` + - [x] 3.1.2 第 216 行:`model.CommissionStatusReleased` → `constants.CommissionStatusReleased` + - [x] 3.1.3 第 517 行:`model.CommissionStatusReleased` → `constants.CommissionStatusReleased` + - [x] 3.1.4 第 659 行:`model.CommissionStatusReleased` → `constants.CommissionStatusReleased` + +- [x] 3.2 修改 `internal/service/refund/service.go` 中的 1 处常量引用 + - [x] 3.2.1 第 344 行:`model.CommissionStatusReleased` → `constants.CommissionStatusReleased` + +- [x] 3.3 修改 `internal/service/recharge/service.go` 中的 1 处常量引用 + - [x] 3.3.1 第 664 行:`model.CommissionStatusReleased` → `constants.CommissionStatusReleased` + +- [x] 3.4 修改 `internal/service/shop_commission/service.go` 中的 2 处常量引用 + - [x] 3.4.1 第 767 行:`model.CommissionStatusReleased` → `constants.CommissionStatusReleased` + - [x] 3.4.2 第 749 行:`model.CommissionStatusInvalid` → `constants.CommissionStatusInvalid` + +- [x] 3.5 修改 `internal/store/postgres/commission_record_store.go` 中的 2 处统计查询过滤条件(**注意:语义变更,不是简单替换常量**) + - [x] 3.5.1 `GetStats`(第 123 行):`Where("status = ?", model.CommissionStatusReleased)` → `Where("status NOT IN (?)", []int{constants.CommissionStatusInvalid, constants.CommissionStatusPendingReview})` + - [x] 3.5.2 `GetDailyStats`(第 169 行):同上,改为排除 status=4 和 status=99 + +## 4. 修复 commission-records 接口关联查询 + +- [x] 4.1 修改 `internal/store/postgres/commission_record_store.go` 的 `ListByShopID` 方法 + - [x] 4.1.1 添加 LEFT JOIN 关联查询(订单表、卡表、设备表) + - [x] 4.1.2 实现 ICCID 过滤条件(模糊查询) + - [x] 4.1.3 实现 OrderNo 过滤条件(精确查询) + - [x] 4.1.4 实现 DeviceNo 过滤条件(模糊查询,通过 device_id JOIN device 表) + +- [x] 4.2 修改 `internal/service/shop_commission/service.go` 的 `ListShopCommissionRecords` 方法 + - [x] 4.2.1 从查询结果中提取关联的 order_no、order_created_at + - [x] 4.2.2 从查询结果中提取关联的 iccid + - [x] 4.2.3 从查询结果中提取关联的 virtual_no + - [x] 4.2.4 从查询结果中提取关联的 seller_shop_id + +## 5. DTO 增强 + +- [x] 5.1 修改 `internal/model/dto/shop_commission_dto.go` 的 `ShopCommissionRecordItem` 结构体 + - [x] 5.1.1 新增 `SellerShopID uint` 字段 + - [x] 5.1.2 新增 `SellerShopName string` 字段 + +- [x] 5.2 更新 `ShopCommissionRecordItem` 的 JSON 标签和 description + +## 6. Service 层填充销售店铺信息 + +- [x] 6.1 修改 `internal/service/shop_commission/service.go` 的 `ListShopCommissionRecords` 方法 + - [x] 6.1.1 批量查询销售店铺信息 + - [x] 6.1.2 填充 `SellerShopID` 和 `SellerShopName` 字段 + +## 7. 文档更新 + +- [x] 7.1 更新 `cmd/api/docs.go` 添加新字段说明(如有必要) +- [x] 7.2 更新 `cmd/gendocs/main.go` 如有必要 +- [x] 7.3 更新 API 文档注释 + +## 8. 验证测试 + +- [x] 8.1 运行 `lsp_diagnostics` 检查所有修改文件的语法错误 +- [x] 8.2 构建项目确认编译通过:`go build ./...` +- [x] 8.3 调用 `GET /api/admin/shops/:shop_id/commission-records` 接口验证返回数据 + - [x] 8.3.1 确认 OrderNo 有值 + - [x] 8.3.2 确认 ICCID 有值(单卡订单) + - [x] 8.3.3 确认 VirtualNo 有值(设备订单) + - [x] 8.3.4 确认 SellerShopID 和 SellerShopName 有值 + - [x] 8.3.5 确认状态名称正确(status=3 显示"已发放") +- [x] 8.4 调用 `GET /api/admin/shops/:shop_id/commission-stats` 验证统计接口正常 diff --git a/openspec/specs/commission-record-query/spec.md b/openspec/specs/commission-record-query/spec.md index 83e4349..14a69cd 100644 --- a/openspec/specs/commission-record-query/spec.md +++ b/openspec/specs/commission-record-query/spec.md @@ -49,7 +49,13 @@ #### Scenario: 响应包含关联信息 - **WHEN** 查询佣金记录列表 -- **THEN** 每条记录包含:订单号、卡/设备信息、套餐名称 +- **THEN** 每条记录包含: + - 佣金记录 ID、金额、状态、状态名称 + - 订单号(order_no)、订单创建时间(order_created_at) + - ICCID(当订单类型为单卡时) + - 设备虚拟号(当订单类型为设备时) + - 销售来源店铺 ID 和名称(seller_shop_id, seller_shop_name) + - 佣金入账时间 --- @@ -100,3 +106,55 @@ #### Scenario: 默认最近30天 - **WHEN** 代理查询每日统计不指定日期范围 - **THEN** 系统返回最近 30 天的数据 + +--- + +## MODIFIED Requirements + +### Requirement: 佣金状态定义 + +**原内容**: + +佣金记录状态为二态:1=已入账,2=已失效 + +**修改为**: + +佣金记录状态为四态: +- 1 = 已冻结:佣金已计算但暂不可提现 +- 2 = 解冻中:满足解冻条件,正在等待发放 +- 3 = 已发放:佣金已入账可提现 +- 4 = 已失效:佣金核验失败或订单退款导致失效 + +#### Scenario: 差价佣金创建时状态 +- **WHEN** 差价佣金计算完成并创建记录 +- **THEN** 记录状态为 3(已发放) + +#### Scenario: 链路断裂时状态 +- **WHEN** 佣金链路断裂(上级代理未分配套餐) +- **THEN** 记录状态为 99(待人工修正) + +#### Scenario: 订单退款时状态 +- **WHEN** 关联订单发生退款 +- **THEN** 佣金记录状态更新为 4(已失效) + +--- + +### Requirement: 佣金状态常量一致性 + +系统 SHALL 使用统一的状态值和状态名称映射,确保佣金状态显示正确。 + +#### Scenario: 已发放状态正确显示 +- **WHEN** 佣金记录 status = 3 +- **THEN** 状态名称返回 "已发放" + +#### Scenario: 已冻结状态正确显示 +- **WHEN** 佣金记录 status = 1 +- **THEN** 状态名称返回 "已冻结" + +#### Scenario: 解冻中状态正确显示 +- **WHEN** 佣金记录 status = 2 +- **THEN** 状态名称返回 "解冻中" + +#### Scenario: 已失效状态正确显示 +- **WHEN** 佣金记录 status = 4 +- **THEN** 状态名称返回 "已失效"