From 136463f0742a7a248402e425c1e2aaaaf1d71c14 Mon Sep 17 00:00:00 2001 From: qhd <1766646056@qq.com> Date: Thu, 3 Sep 2026 13:20:00 +0800 Subject: [PATCH] =?UTF-8?q?chore(docs):=20=E7=A7=BB=E9=99=A4=20superpowers?= =?UTF-8?q?=20=E8=AE=BE=E8=AE=A1=E4=B8=8E=E8=AE=A1=E5=88=92=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- config.yml | 24 +- consts/pricing/order_status.go | 10 + consts/public/table_name.go | 13 +- consts/wallet/wallet_log_type.go | 12 +- controller/wallet/account_controller.go | 30 + controller/wallet/wallet_controller.go | 32 - dao/knapsack/knapsack_dao.go | 2 +- dao/market/market_dao.go | 2 +- dao/pricing/charge_order_dao.go | 49 +- dao/pricing/pricing_config_dao.go | 62 +- dao/wallet/account_dao.go | 143 ++++ dao/wallet/wallet_dao.go | 134 --- .../plans/2026-09-01-pricing-mediaprices.md | 787 ------------------ .../plans/2026-09-01-subject-charge-modes.md | 282 ------- .../specs/2026-08-28-pricing-enum-design.md | 323 ------- .../2026-09-01-pricing-media-price-design.md | 244 ------ .../2026-09-01-subject-charge-modes-design.md | 95 --- .../specs/pricing-config-preview.html | 561 ------------- go.mod | 13 +- go.sum | 13 + init.sql | 108 ++- main.go | 5 +- model/dto/pricing/charge_dto.go | 120 +-- model/dto/pricing/config_dto.go | 65 +- model/dto/pricing/subject_dto.go | 11 +- model/dto/wallet/account_dto.go | 85 ++ model/dto/wallet/wallet_dto.go | 112 --- model/entity/pricing/charge_order.go | 42 +- model/entity/pricing/pricing_config.go | 16 +- model/entity/wallet/{wallet.go => account.go} | 18 +- model/entity/wallet/account_log.go | 54 ++ model/entity/wallet/wallet_log.go | 24 - model/entity/wallet/wallet_log_col.go | 33 - service/pricing/charge_calc.go | 39 +- service/pricing/charge_calc_test.go | 335 -------- service/pricing/pricing_config_service.go | 166 ++++ service/pricing/pricing_service.go | 317 ++----- service/pricing/subject_service.go | 6 + service/pricing/subject_service_test.go | 63 -- service/wallet/account_service.go | 128 +++ service/wallet/wallet_service.go | 153 ---- 42 files changed, 1006 insertions(+), 3729 deletions(-) create mode 100644 consts/pricing/order_status.go create mode 100644 controller/wallet/account_controller.go delete mode 100644 controller/wallet/wallet_controller.go create mode 100644 dao/wallet/account_dao.go delete mode 100644 dao/wallet/wallet_dao.go delete mode 100644 docs/superpowers/plans/2026-09-01-pricing-mediaprices.md delete mode 100644 docs/superpowers/plans/2026-09-01-subject-charge-modes.md delete mode 100644 docs/superpowers/specs/2026-08-28-pricing-enum-design.md delete mode 100644 docs/superpowers/specs/2026-09-01-pricing-media-price-design.md delete mode 100644 docs/superpowers/specs/2026-09-01-subject-charge-modes-design.md delete mode 100644 docs/superpowers/specs/pricing-config-preview.html create mode 100644 model/dto/wallet/account_dto.go delete mode 100644 model/dto/wallet/wallet_dto.go rename model/entity/wallet/{wallet.go => account.go} (56%) create mode 100644 model/entity/wallet/account_log.go delete mode 100644 model/entity/wallet/wallet_log.go delete mode 100644 model/entity/wallet/wallet_log_col.go delete mode 100644 service/pricing/charge_calc_test.go create mode 100644 service/pricing/pricing_config_service.go delete mode 100644 service/pricing/subject_service_test.go create mode 100644 service/wallet/account_service.go delete mode 100644 service/wallet/wallet_service.go diff --git a/.gitignore b/.gitignore index 61069b7..b1c7a03 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -/.idea/* \ No newline at end of file +/.idea/* +/.superpowers/ +/docs/superpowers/ diff --git a/config.yml b/config.yml index 5d9e88a..e3382c3 100644 --- a/config.yml +++ b/config.yml @@ -9,10 +9,10 @@ server: database: default: - type: "pgsql" - host: "116.204.74.41" + host: "192.168.0.83" port: "15432" user: "postgres" - pass: "Bjang09@686^*^" + pass: "Q!P@z#M$1@686^*^.." name: "shop_user_trade" role: "master" debug: false @@ -28,10 +28,10 @@ database: deletedAt: "deleted_at" timeMaintainDisabled: false - type: "pgsql" - host: "116.204.74.41" + host: "192.168.0.83" port: "15432" user: "postgres" - pass: "Bjang09@686^*^" + pass: "Q!P@z#M$1@686^*^.." name: "shop_user_trade" role: "slave" debug: false @@ -48,10 +48,10 @@ database: timeMaintainDisabled: false wallet: - type: "pgsql" - host: "localhost" - port: "5432" + host: "192.168.0.83" + port: "15432" user: "postgres" - pass: "123456" + pass: "Q!P@z#M$1@686^*^.." name: "wallet" prefix: "wallet_" # (可选)表名前缀 role: "master" @@ -70,7 +70,7 @@ database: redis: default: - address: localhost:6379 + address: 192.168.0.83:6379 db: 0 idleTimeout: "60s" maxConnLifetime: "90s" @@ -81,11 +81,7 @@ redis: maxActive: 100 consul: - address: localhost:8500 + address: 192.168.0.83:8500 jaeger: - addr: localhost:4318 - -# pricing 计价模块 -pricing: - modelGatewayService: "model-gateway" # model-gateway consul 服务名(枚举接口拉系统模型用) + addr: 192.168.0.83:4318 diff --git a/consts/pricing/order_status.go b/consts/pricing/order_status.go new file mode 100644 index 0000000..a99c407 --- /dev/null +++ b/consts/pricing/order_status.go @@ -0,0 +1,10 @@ +package pricing + +// ChargeOrderStatus 计费单状态(状态机单迁移:CREATED → SETTLED / FAILED) +type ChargeOrderStatus int + +const ( + ChargeOrderStatusCreated ChargeOrderStatus = 1 // 已建单(未结算,不动钱) + ChargeOrderStatusSettled ChargeOrderStatus = 2 // 已结算(实收完成) + ChargeOrderStatusFailed ChargeOrderStatus = 3 // 已失败(不扣费) +) diff --git a/consts/public/table_name.go b/consts/public/table_name.go index 438cee8..da8b932 100644 --- a/consts/public/table_name.go +++ b/consts/public/table_name.go @@ -1,11 +1,20 @@ package public +// 数据库名称 +const ( + DbNameWallet = "wallet" +) + // 数据库表名 const ( TableNameKnapsack = "knapsack" // 背包表 TableNameKnapsackLog = "knapsack_log" // 背包日志表 TableNameMarket = "market" // 市场表 TableNameMarketLog = "market_log" // 市场日志表 - TableNameWallet = "wallet" // 钱包表 - TableNameWalletLog = "wallet_log" // 钱包日志表 + + TableNameWalletAccount = "account" // 钱包表 + TableNameWalletAccountLog = "account_log" // 钱包日志表 + + TableNamePricingConfig = "pricing_config" // 计价配置表 + TableNameChargeOrder = "charge_order" // 计费单表 ) diff --git a/consts/wallet/wallet_log_type.go b/consts/wallet/wallet_log_type.go index 7490010..175ad78 100644 --- a/consts/wallet/wallet_log_type.go +++ b/consts/wallet/wallet_log_type.go @@ -1,11 +1,13 @@ package wallet -// WalletLogType 钱包日志操作类型 +// WalletLogType 钱包账务类型 +// 语义按金额变动定义(与钱包 status 的整钱包冻结无关): +// +// income 收入/充值 balance += amount +// expense 支出(结算实扣) balance -= amount(允许为负=欠费) type WalletLogType string const ( - WalletLogTypeIncome WalletLogType = "income" // 收入 - WalletLogTypeExpense WalletLogType = "expense" // 支出 - WalletLogTypeFreeze WalletLogType = "freeze" // 冻结 - WalletLogTypeUnfreeze WalletLogType = "unfreeze" // 解冻 + WalletLogTypeIncome WalletLogType = "income" // 收入/充值 + WalletLogTypeExpense WalletLogType = "expense" // 支出(结算实扣,允许负余额) ) diff --git a/controller/wallet/account_controller.go b/controller/wallet/account_controller.go new file mode 100644 index 0000000..a6a876d --- /dev/null +++ b/controller/wallet/account_controller.go @@ -0,0 +1,30 @@ +package controller + +import ( + "context" + + walletDto "shop-user-trade/model/dto/wallet" + walletService "shop-user-trade/service/wallet" + + "gitea.redpowerfuture.com/red-future/common/beans" +) + +var Account = new(accountController) + +type accountController struct{} + +// Get 获取钱包 +func (c *accountController) Get(ctx context.Context, req *walletDto.GetAccountByUserIdReq) (*walletDto.AccountInfo, error) { + return walletService.Account.GetAccountByUserId(ctx, req) +} + +// Recharge 充值 +func (c *accountController) Recharge(ctx context.Context, req *walletDto.RechargeReq) (res *beans.ResponseEmpty, err error) { + err = walletService.Account.Recharge(ctx, req) + return +} + +// GetLogs 获取钱包流水 +func (c *accountController) GetLogs(ctx context.Context, req *walletDto.GetAccountLogsReq) (*walletDto.AccountLogData, error) { + return walletService.Account.GetWalletLogs(ctx, req) +} diff --git a/controller/wallet/wallet_controller.go b/controller/wallet/wallet_controller.go deleted file mode 100644 index 90b012f..0000000 --- a/controller/wallet/wallet_controller.go +++ /dev/null @@ -1,32 +0,0 @@ -package controller - -import ( - "context" - - walletDto "shop-user-trade/model/dto/wallet" - walletService "shop-user-trade/service/wallet" -) - -var Wallet = new(walletController) - -type walletController struct{} - -// GetByUserId 根据用户ID获取钱包 -func (c *walletController) GetByUserId(ctx context.Context, req *walletDto.GetWalletByUserIdReq) (res *walletDto.GetWalletByUserIdResp, err error) { - return walletService.Wallet.GetByUserId(ctx, req) -} - -// Create 创建钱包 -func (c *walletController) Create(ctx context.Context, req *walletDto.CreateWalletReq) (res *walletDto.CreateWalletResp, err error) { - return walletService.Wallet.Create(ctx, req) -} - -// UpdateBalance 更新余额 -func (c *walletController) UpdateBalance(ctx context.Context, req *walletDto.UpdateBalanceReq) (res *walletDto.UpdateBalanceResp, err error) { - return walletService.Wallet.UpdateBalance(ctx, req) -} - -// GetWalletLogs 获取钱包日志 -func (c *walletController) GetWalletLogs(ctx context.Context, req *walletDto.GetWalletLogsReq) (res *walletDto.GetWalletLogsResp, err error) { - return walletService.Wallet.GetWalletLogs(ctx, req) -} diff --git a/dao/knapsack/knapsack_dao.go b/dao/knapsack/knapsack_dao.go index 641377c..893bd11 100644 --- a/dao/knapsack/knapsack_dao.go +++ b/dao/knapsack/knapsack_dao.go @@ -15,7 +15,7 @@ import ( knapsackEntity "shop-user-trade/model/entity/knapsack" ) -var Knapsack = new(knapsackDao) +var Knapsack = &knapsackDao{} type knapsackDao struct{} diff --git a/dao/market/market_dao.go b/dao/market/market_dao.go index 83a3aeb..0973d20 100644 --- a/dao/market/market_dao.go +++ b/dao/market/market_dao.go @@ -15,7 +15,7 @@ import ( marketEntity "shop-user-trade/model/entity/market" ) -var Market = new(marketDao) +var Market = &marketDao{} type marketDao struct{} diff --git a/dao/pricing/charge_order_dao.go b/dao/pricing/charge_order_dao.go index f8d17ae..d875e5b 100644 --- a/dao/pricing/charge_order_dao.go +++ b/dao/pricing/charge_order_dao.go @@ -5,6 +5,7 @@ import ( "gitea.redpowerfuture.com/red-future/common/db/gfdb" "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/util/gconv" pricingConsts "shop-user-trade/consts/pricing" "shop-user-trade/consts/public" @@ -12,7 +13,7 @@ import ( pricingEntity "shop-user-trade/model/entity/pricing" ) -// chargeOrderDao 计费单数据访问:薄查询/状态迁移原语(同 model-gateway dao 规范)。 +// ChargeOrder chargeOrderDao 计费单数据访问:薄查询/状态迁移原语(同 model-gateway dao 规范)。 // 方法入参统一吃 DTO(model/dto/pricing);无锁、无事务、不定义业务错误。 var ChargeOrder = &chargeOrderDao{} @@ -21,7 +22,7 @@ type chargeOrderDao struct{} // GetOrder 按 ID 或 subject_type+subject_id+biz_order_no 查询计费单(Id>0 走 ID)。 // 幂等查询复用:工作流断点续跑/恢复 execId 不变,据此去重。 func (d *chargeOrderDao) GetOrder(ctx context.Context, req *pricingDto.GetChargeOrderReq) (res *pricingEntity.ChargeOrder, err error) { - m := gfdb.DB(ctx).Model(ctx, public.TableNameChargeOrder).Model + m := gfdb.DB(ctx, public.DbNameWallet).Model(ctx, public.TableNameChargeOrder).Model if req.Id > 0 { m = m.Where(pricingEntity.ChargeOrderCol.Id, req.Id) } else { @@ -40,18 +41,14 @@ func (d *chargeOrderDao) GetOrder(ctx context.Context, req *pricingDto.GetCharge return } -// Insert 创建计费单(事务透明:在调用方事务内执行) +// Insert 创建计费单(事务透明:在调用方事务内执行;同 model-gateway dao 规范) func (d *chargeOrderDao) Insert(ctx context.Context, req *pricingDto.CreateChargeOrderReq) (id int64, err error) { - entity := &pricingEntity.ChargeOrder{ - SubjectType: req.SubjectType, - SubjectID: req.SubjectID, - BizOrderNo: req.BizOrderNo, - UserId: req.UserId, - ChargeMode: req.ChargeMode, - Status: req.Status, - RuleSnapshot: req.RuleSnapshot, + var e = new(pricingEntity.ChargeOrder) + err = gconv.Struct(req, &e) + if err != nil { + return } - r, err := gfdb.DB(ctx).Model(ctx, public.TableNameChargeOrder).Data(entity).Insert() + r, err := gfdb.DB(ctx, public.DbNameWallet).Model(ctx, public.TableNameChargeOrder).Insert(e) if err != nil { return } @@ -61,24 +58,24 @@ func (d *chargeOrderDao) Insert(ctx context.Context, req *pricingDto.CreateCharg // UpdateToSettled 结算迁移:仅 CREATED → SETTLED;rows=0 表示已处理(幂等) func (d *chargeOrderDao) UpdateToSettled(ctx context.Context, req *pricingDto.SettleChargeOrderReq) (bool, error) { return d.migrateStatus(ctx, req.Id, gdb.Map{ - "status": int(pricingConsts.ChargeOrderStatusSettled), - "actual_amount": req.ActualAmount, - "usage": req.Usage, - "rule_snapshot": req.RuleSnapshot, - "settle_time": req.SettleTime, + pricingEntity.ChargeOrderCol.Status: int(pricingConsts.ChargeOrderStatusSettled), + pricingEntity.ChargeOrderCol.ActualAmount: req.ActualAmount, + pricingEntity.ChargeOrderCol.Usage: req.Usage, + pricingEntity.ChargeOrderCol.RuleSnapshot: req.RuleSnapshot, + pricingEntity.ChargeOrderCol.SettleTime: req.SettleTime, }) } // UpdateToFailed 失败迁移:仅 CREATED → FAILED(不扣费,不动钱) func (d *chargeOrderDao) UpdateToFailed(ctx context.Context, req *pricingDto.FailChargeOrderReq) (bool, error) { return d.migrateStatus(ctx, req.Id, gdb.Map{ - "status": int(pricingConsts.ChargeOrderStatusFailed), + pricingEntity.ChargeOrderCol.Status: int(pricingConsts.ChargeOrderStatusFailed), }) } // migrateStatus 条件状态迁移(幂等:非 CREATED 时 Update 影响 0 行返回 false) func (d *chargeOrderDao) migrateStatus(ctx context.Context, id int64, data gdb.Map) (bool, error) { - result, err := gfdb.DB(ctx).Model(ctx, public.TableNameChargeOrder). + result, err := gfdb.DB(ctx, public.DbNameWallet).Model(ctx, public.TableNameChargeOrder). Data(data). Where(pricingEntity.ChargeOrderCol.Id, id). Where(pricingEntity.ChargeOrderCol.Status, int(pricingConsts.ChargeOrderStatusCreated)). @@ -92,17 +89,3 @@ func (d *chargeOrderDao) migrateStatus(ctx context.Context, id int64, data gdb.M } return rows > 0, nil } - -// ListByUser 分页查询某用户的计费单 -func (d *chargeOrderDao) ListByUser(ctx context.Context, req *pricingDto.ListOrdersReq) (res []pricingEntity.ChargeOrder, total int, err error) { - r, total, err := gfdb.DB(ctx).Model(ctx, public.TableNameChargeOrder). - Where(pricingEntity.ChargeOrderCol.UserId, req.UserId). - OrderDesc(pricingEntity.ChargeOrderCol.CreatedAt). - Page(req.Page, req.PageSize). - AllAndCount(false) - if err != nil { - return - } - err = r.Structs(&res) - return -} diff --git a/dao/pricing/pricing_config_dao.go b/dao/pricing/pricing_config_dao.go index d92e6a9..26bd1a9 100644 --- a/dao/pricing/pricing_config_dao.go +++ b/dao/pricing/pricing_config_dao.go @@ -4,23 +4,22 @@ import ( "context" "gitea.redpowerfuture.com/red-future/common/db/gfdb" - "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/util/gconv" - pricingConsts "shop-user-trade/consts/pricing" "shop-user-trade/consts/public" pricingDto "shop-user-trade/model/dto/pricing" pricingEntity "shop-user-trade/model/entity/pricing" ) -// pricingConfigDao 计价配置数据访问:薄 CRUD(同 model-gateway dao 规范)。 +// PricingConfig pricingConfigDao 计价配置数据访问:薄 CRUD(同 model-gateway dao 规范)。 // 方法入参统一吃 DTO(model/dto/pricing);无锁、无事务、不定义业务错误。 var PricingConfig = &pricingConfigDao{} type pricingConfigDao struct{} // Get 按计价对象(subject_type + subject_id)获取计价配置 -func (d *pricingConfigDao) Get(ctx context.Context, req *pricingDto.GetConfigReq) (res *pricingEntity.PricingConfig, err error) { - r, err := gfdb.DB(ctx).Model(ctx, public.TableNamePricingConfig). +func (d *pricingConfigDao) Get(ctx context.Context, req *pricingDto.GetPricingConfigReq) (res *pricingEntity.PricingConfig, err error) { + r, err := gfdb.DB(ctx, public.DbNameWallet).Model(ctx, public.TableNamePricingConfig).NoTenantId(ctx). Where(pricingEntity.PricingConfigCol.SubjectType, req.SubjectType). Where(pricingEntity.PricingConfigCol.SubjectID, req.SubjectID). One() @@ -34,52 +33,31 @@ func (d *pricingConfigDao) Get(ctx context.Context, req *pricingDto.GetConfigReq return } -// Save 新增或更新计价配置(subject_type+subject_id 唯一,更新走整行覆盖) -func (d *pricingConfigDao) Save(ctx context.Context, req *pricingDto.SaveConfigReq) (id int64, err error) { - if req.Id > 0 { - _, err = gfdb.DB(ctx).Model(ctx, public.TableNamePricingConfig). - Data(gdb.Map{ - "subject_type": req.SubjectType, - "subject_id": req.SubjectID, - "rules": req.Rules, - "min_balance": req.MinBalance, - "currency": req.Currency, - "enabled": req.Enabled, - "version": gdb.Raw("version + 1"), - }). - Where(pricingEntity.PricingConfigCol.Id, req.Id). - Update() - return req.Id, err +// Insert 插入 +func (d *pricingConfigDao) Insert(ctx context.Context, req *pricingDto.SavePricingConfigReq) (id int64, err error) { + var e = new(pricingEntity.PricingConfig) + err = gconv.Struct(req, &e) + if err != nil { + return } - entity := &pricingEntity.PricingConfig{ - SubjectType: pricingConsts.SubjectType(req.SubjectType), - SubjectID: req.SubjectID, - Rules: req.Rules, - MinBalance: req.MinBalance, - Currency: req.Currency, - Enabled: req.Enabled, - Version: 1, - } - r, err := gfdb.DB(ctx).Model(ctx, public.TableNamePricingConfig).Data(entity).Insert() + r, err := gfdb.DB(ctx, public.DbNameWallet).Model(ctx, public.TableNamePricingConfig).Insert(e) if err != nil { return } return r.LastInsertId() } -// List 分页查询计价配置(可按 subjectType / enabled 过滤) -func (d *pricingConfigDao) List(ctx context.Context, req *pricingDto.ListConfigsReq) (res []pricingEntity.PricingConfig, total int, err error) { - m := gfdb.DB(ctx).Model(ctx, public.TableNamePricingConfig).Model - if req.SubjectType != "" { - m = m.Where(pricingEntity.PricingConfigCol.SubjectType, req.SubjectType) +// Update 更新 +func (d *pricingConfigDao) Update(ctx context.Context, req *pricingDto.SavePricingConfigReq) (rows int64, err error) { + // 与 Insert 一致:DTO→entity(map[string]any),Data 以 map 落 JSONB 列 + var e = new(pricingEntity.PricingConfig) + if err = gconv.Struct(req, &e); err != nil { + return } - if req.Enabled != 0 { - m = m.Where(pricingEntity.PricingConfigCol.Enabled, req.Enabled) - } - r, total, err := m.OrderDesc(pricingEntity.PricingConfigCol.UpdatedAt).Page(req.Page, req.PageSize).AllAndCount(false) + r, err := gfdb.DB(ctx, public.DbNameWallet).Model(ctx, public.TableNamePricingConfig). + OmitEmpty().Data(e).Where(pricingEntity.PricingConfigCol.Id, req.Id).Update() if err != nil { return } - err = r.Structs(&res) - return + return r.RowsAffected() } diff --git a/dao/wallet/account_dao.go b/dao/wallet/account_dao.go new file mode 100644 index 0000000..e92a29e --- /dev/null +++ b/dao/wallet/account_dao.go @@ -0,0 +1,143 @@ +package dao + +import ( + "context" + "errors" + + "gitea.redpowerfuture.com/red-future/common/db/gfdb" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/util/gconv" + "github.com/google/uuid" + + "shop-user-trade/consts/public" + walletConsts "shop-user-trade/consts/wallet" + walletDto "shop-user-trade/model/dto/wallet" + walletEntity "shop-user-trade/model/entity/wallet" +) + +// Account AccountDao 钱包数据访问:薄查询/账务原语(同 model-gateway dao 规范)。 +// 方法入参统一吃 DTO(model/dto/wallet);无锁、无事务、不定义业务错误。 +// 锁与事务由 service 编排;钱包是否存在/是否可用的业务校验在 service 层做。 +// Change 为账务原子原语,事务透明,必须由调用方在事务内执行。 +var Account = &accountDao{} + +type accountDao struct{} + +// ====================== 查询 ====================== + +// Get 根据用户ID获取钱包(查询用,不加锁) +func (d *accountDao) Get(ctx context.Context, req *walletDto.GetAccountByUserIdReq) (res *walletEntity.Account, err error) { + r, err := gfdb.DB(ctx, public.DbNameWallet).Model(ctx, public.TableNameWalletAccount). + Where(walletEntity.AccountCol.UserID, req.UserId). + One() + if err != nil { + return + } + if r.IsEmpty() { + return nil, nil + } + err = r.Struct(&res) + return +} + +// GetForUpdate 行锁读取钱包(须在事务内调用),供账务变动使用 +func (d *accountDao) GetForUpdate(ctx context.Context, req *walletDto.GetAccountByUserIdReq) (res *walletEntity.Account, err error) { + r, err := gfdb.DB(ctx, public.DbNameWallet).Model(ctx, public.TableNameWalletAccount). + Where(walletEntity.AccountCol.UserID, req.UserId). + LockUpdate(). + One() + if err != nil { + return + } + if r.IsEmpty() { + return nil, nil + } + err = r.Struct(&res) + return +} + +// Create 创建钱包(余额 0,默认启用;同 model-gateway dao 规范) +func (d *accountDao) Create(ctx context.Context, req *walletDto.CreateAccountReq) (id int64, err error) { + var e = new(walletEntity.Account) + err = gconv.Struct(req, &e) + if err != nil { + return + } + r, err := gfdb.DB(ctx, public.DbNameWallet).Model(ctx, public.TableNameWalletAccount).Insert(e) + if err != nil { + return + } + return r.LastInsertId() +} + +// ====================== 原子账务 ====================== + +// Change 单次原子账务变动(事务透明:须在调用方事务内执行,gfdb.DB(ctx) 自动继承事务)。 +// 行锁读取 → 校验状态 → 条件更新余额 → 写流水(transaction_no 唯一幂等)。 +// 锁与事务由上层 service 编排(common 统一锁 + gfdb.DB(ctx).Transaction),本 DAO 不加锁、不开事务。 +// ⚠️ 不做余额非负拦截:结算允许负余额(欠费,靠充值归正),见技术设计 §9。 +func (d *accountDao) Change(ctx context.Context, req *walletDto.ChangeAccountReq) error { + w, err := d.GetForUpdate(ctx, &walletDto.GetAccountByUserIdReq{UserId: req.UserId}) + if err != nil { + return err + } + if w == nil { + return errors.New("钱包不存在") + } + if w.Status != walletConsts.WalletStatusEnabled { + return errors.New("钱包状态不可用") + } + + // 行锁(GetForUpdate 的 FOR UPDATE)在事务内持续持有,直接以 Go 侧计算结果写回, + // 避免浮点金额走字符串拼接进 SQL 引入精度误差;余额写入 NUMERIC(15,2) 列时由 DB 四舍五入。 + updateData := gdb.Map{ + walletEntity.AccountCol.Balance: w.Balance + req.DeltaBalance, + } + result, err := gfdb.DB(ctx, public.DbNameWallet).Model(ctx, public.TableNameWalletAccount). + Data(updateData). + Where(walletEntity.AccountCol.Id, w.Id). + Update() + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return errors.New("钱包余额更新失败,请重试") + } + + _, err = gfdb.DB(ctx, public.DbNameWallet).Model(ctx, public.TableNameWalletAccountLog).Data(&walletEntity.AccountLog{ + UserID: req.UserId, + WalletID: w.Id, + OrderNo: req.OrderNo, + TransactionNo: uuid.NewString(), + Type: req.Type, + Amount: req.Amount, + BalanceBefore: w.Balance, + BalanceAfter: w.Balance + req.DeltaBalance, + Currency: w.Currency, + Description: req.Description, + ExtraData: req.ExtraData, + }).Insert() + return err +} + +// ====================== 流水 ====================== + +// ListLogs 分页获取钱包流水 +func (d *accountDao) ListLogs(ctx context.Context, req *walletDto.GetAccountLogsReq) (res []walletEntity.AccountLog, total int, err error) { + m := gfdb.DB(ctx, public.DbNameWallet).Model(ctx, public.TableNameWalletAccountLog). + Where(walletEntity.AccountLogCol.UserID, req.UserId). + OrderDesc(walletEntity.AccountLogCol.CreatedAt) + if req.Page != nil { + m.Page(int(req.Page.PageNum), int(req.Page.PageSize)) + } + r, total, err := m.AllAndCount(false) + if err != nil { + return + } + err = r.Structs(&res) + return +} diff --git a/dao/wallet/wallet_dao.go b/dao/wallet/wallet_dao.go deleted file mode 100644 index bdbb4da..0000000 --- a/dao/wallet/wallet_dao.go +++ /dev/null @@ -1,134 +0,0 @@ -package dao - -import ( - "context" - "errors" - - "gitea.redpowerfuture.com/red-future/common/db/gfdb" - "github.com/gogf/gf/v2/database/gdb" - "github.com/gogf/gf/v2/util/gconv" - - "shop-user-trade/consts/public" - walletConsts "shop-user-trade/consts/wallet" - walletDto "shop-user-trade/model/dto/wallet" - walletEntity "shop-user-trade/model/entity/wallet" -) - -var Wallet = new(walletDao) - -type walletDao struct{} - -// GetByUserID 根据用户ID获取钱包 -func (d *walletDao) GetByUserID(ctx context.Context, userID int64) (res *walletEntity.Wallet, err error) { - r, err := gfdb.DB(ctx).Model(ctx, public.TableNameWallet). - Where(walletEntity.WalletCol.UserID, userID). - WhereNot(walletEntity.WalletCol.Status, int(walletConsts.WalletStatusFrozen)). - One() - if err != nil { - return - } - if r.IsEmpty() { - return nil, nil - } - err = r.Struct(&res) - return -} - -// GetByID 根据ID获取钱包 -func (d *walletDao) GetByID(ctx context.Context, walletID int64) (res *walletEntity.Wallet, err error) { - r, err := gfdb.DB(ctx).Model(ctx, public.TableNameWallet). - Where(walletEntity.WalletCol.Id, walletID). - One() - if err != nil { - return - } - if r.IsEmpty() { - return nil, nil - } - err = r.Struct(&res) - return -} - -// Create 创建钱包 -func (d *walletDao) Create(ctx context.Context, req *walletDto.CreateWalletReq) (id int64, err error) { - entity := &walletEntity.Wallet{ - UserID: req.UserId, - Balance: 0, - Currency: req.Currency, - Status: walletConsts.WalletStatusEnabled, - Version: 1, - } - r, err := gfdb.DB(ctx).Model(ctx, public.TableNameWallet).Data(entity).Insert() - if err != nil { - return - } - return r.LastInsertId() -} - -// UpdateBalance 更新余额(乐观锁) -func (d *walletDao) UpdateBalance(ctx context.Context, walletID int64, amount int64, opType string, version int64) (bool, error) { - var updateData gdb.Map - switch opType { - case "income": - updateData = gdb.Map{ - "balance": gdb.Raw("balance + " + gconv.String(amount)), - "version": gdb.Raw("version + 1"), - } - case "expense": - updateData = gdb.Map{ - "balance": gdb.Raw("balance - " + gconv.String(amount)), - "version": gdb.Raw("version + 1"), - } - case "freeze": - updateData = gdb.Map{ - "status": int(walletConsts.WalletStatusFrozen), - } - case "unfreeze": - updateData = gdb.Map{ - "status": int(walletConsts.WalletStatusEnabled), - } - default: - return false, errors.New("unsupported operation type") - } - - result, err := gfdb.DB(ctx).Model(ctx, public.TableNameWallet). - Data(updateData). - Where(walletEntity.WalletCol.Id, walletID). - Where(walletEntity.WalletCol.Version, version). - Update() - if err != nil { - return false, err - } - rows, err := result.RowsAffected() - if err != nil { - return false, err - } - return rows > 0, nil -} - -// CreateLog 创建钱包日志 -func (d *walletDao) CreateLog(ctx context.Context, req *walletDto.CreateWalletLogReq) (id int64, err error) { - var entity *walletEntity.WalletLog - if err = gconv.Struct(req, &entity); err != nil { - return - } - r, err := gfdb.DB(ctx).Model(ctx, public.TableNameWalletLog).Data(entity).Insert() - if err != nil { - return - } - return r.LastInsertId() -} - -// ListLogs 获取钱包日志列表 -func (d *walletDao) ListLogs(ctx context.Context, userID int64, page, pageSize int) (res []walletEntity.WalletLog, total int, err error) { - r, total, err := gfdb.DB(ctx).Model(ctx, public.TableNameWalletLog). - Where(walletEntity.WalletLogCol.UserID, userID). - OrderDesc(walletEntity.WalletLogCol.CreatedAt). - Page(page, pageSize). - AllAndCount(false) - if err != nil { - return - } - err = r.Structs(&res) - return -} diff --git a/docs/superpowers/plans/2026-09-01-pricing-mediaprices.md b/docs/superpowers/plans/2026-09-01-pricing-mediaprices.md deleted file mode 100644 index 1022656..0000000 --- a/docs/superpowers/plans/2026-09-01-pricing-mediaprices.md +++ /dev/null @@ -1,787 +0,0 @@ -# 模型计费 mediaPrices(媒体类型价)与阶梯校验修复 实现计划 - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 将模型费率规则的"输入媒体"维度从 `match.mediaType` 命中条件改为规则级 `mediaPrices` 媒体价 map(默认 `price` = 不含以下媒体),并重写阶梯档位重叠校验(按长度下界排序判相邻 + 修同起 0 漏检),使"有音频/无音频 × 档位"矩阵配置可保存。 - -**Architecture:** 纯 `service/pricing/charge_calc.go` + `consts/pricing/charge_mode.go` 变更。`modelRule` 加 `MediaPrices map[string]*modelPrice`,`modelMatch` 删 `MediaType`;运行时新增 `pickModelPrice(rule, usage)`(mediaPrices 命中用媒体价,否则默认价)供 token/unit 两个 model 计算器共用;`validateTieredBands` 重写 + 共享 `validateMediaPrices` helper(两计算器共用)。 - -**Tech Stack:** Go 1.26.1, 标准库 `encoding/json`/`errors`/`sort`/`math`, 标准库 testing(TDD)。 - -## Global Constraints - -- 金额一律**元**(float64,2 位小数),不足 1 分向上取整(`ceilFen`) -- `modelRule` 加 `MediaPrices map[string]*modelPrice`;`modelMatch` **删 `MediaType` 字段**;`modelRuleMatch` **删 mediaType 等值判定**(仅此一处删匹配逻辑) -- 运行时取价统一 `pickModelPrice(rule, usage)`:`mediaPrices[usage.MediaType]` 命中用媒体价,否则默认 `rule.Price`;`modelTokenCalculator.charge` 与 `modelUnitCalculator.charge` 都改 -- `validateTieredBands`:带长度区间规则按下界升序后**相邻不重叠**;无长度规则不参与;上不封顶档(max=0)须为最后一个长度档;**修复两个同起 0 档位漏检** -- `mediaPrices` 校验:键须 ∈ `ModelMediaSet`(`text/audio/video/image`),值非 nil;`modelTokenCalculator.validate` 与 `modelUnitCalculator.validate` 都加,共享 `validateMediaPrices` helper -- `ChargeUsage.MediaType` 字段**保留**(上报入参,不删) -- 不动 workflow/business 计算器、`pricing_config` DB 结构、`ChargeMode` 枚举、金额单位 -- **提交排除用户 WIP**:工作树有大量未完成改动(钱包重构、老 pricing 测试删除等,`git status --short` 可查)。commit 一律 `git add <精确路径> && git commit --only <精确路径>`,提交前 `git status --short` 核对只含本任务文件 -- **注意**:`service/pricing/charge_calc.go` 工作树已含用户未提交 WIP(删 `ItemCount`/`mode()` 等清理,`go build` 已通过)。本计划的编辑叠加其上,commit 该文件时会一并带上这批 WIP 改动(同区域清理,无法用 `--only` 分离,属预期) -- 测试命令(已验证无需额外 env):`cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -run -count=1` -- 错误文案与 spec §5.2 逐字一致:`"model 费率 mediaPrices 键须为 text/audio/video/image"`、`"model 费率 mediaPrices 值须配置 price"`、`"阶梯档位区间重叠:规则间 InputLengthMin 须 > 前一档 InputLengthMax"`、`"阶梯档位区间重叠:上不封顶档后不能再有档位"` - ---- - -## Task 1: 常量 ModelMediaSet + 结构变更(mediaPrices 入 modelRule、match 去 MediaType) - -**Files:** -- Modify: `consts/pricing/charge_mode.go`(`ModelUnitSet` 块之后追加) -- Modify: `service/pricing/charge_calc.go`(`modelRule` 204-208、`modelMatch` 211-218、`modelRuleMatch` 239-241) - -**Interfaces:** -- Produces: `pricingConsts.ModelMediaSet map[string]struct{}{text,audio,video,image}`;`modelRule` 新增 `MediaPrices` 字段;`modelMatch` 无 `MediaType`;`modelRuleMatch` 不再读 `m.MediaType` -- 纯结构变更,无行为变化;Task 2-4 依赖此结构 - -- [ ] **Step 1: `consts/pricing/charge_mode.go` 追加 ModelMediaSet** - -在 `ModelUnitSet` 块(第 23 行 `}`)之后追加: - -```go - -// ModelMediaSet 模型费率 mediaPrices 允许的媒体类型键(输入媒体白名单,前端下拉同源) -var ModelMediaSet = map[string]struct{}{ - "text": {}, "audio": {}, "video": {}, "image": {}, -} -``` - -- [ ] **Step 2: `service/pricing/charge_calc.go` 改 `modelRule`(204-208)** - -```go -type modelRule struct { - Name string `json:"name"` - Match *modelMatch `json:"match,omitempty"` // 空=任意调用 - Price *modelPrice `json:"price"` // 默认价:输入不含以下媒体 - MediaPrices map[string]*modelPrice `json:"mediaPrices,omitempty"` // 媒体类型→该媒体价(输入含该媒体时) -} -``` - -- [ ] **Step 3: `modelMatch` 删 `MediaType` 字段(212)** - -```go -type modelMatch struct { - Thinking *bool `json:"thinking,omitempty"` // 思考/非思考 - OutputAudio *bool `json:"outputAudio,omitempty"` // 输出有声/无声 - OutputResolution string `json:"outputResolution,omitempty"` // 输出分辨率 - InputLengthMin int64 `json:"inputLengthMin,omitempty"` // 输入token下界 - InputLengthMax int64 `json:"inputLengthMax,omitempty"` // 输入token上界 -} -``` - -- [ ] **Step 4: `modelRuleMatch` 删 mediaType 等值判定(239-241)** - -删除以下 3 行(整个 if 块): - -```go - if m.MediaType != "" && m.MediaType != u.MediaType { - return false - } -``` - -- [ ] **Step 5: 编译验证** - -Run: `cd /c/App/GolandProjects/shop-user-trade && go build ./consts/... ./service/pricing/` -Expected: PASS(无输出即成功) - -- [ ] **Step 6: 提交** - -```bash -git add consts/pricing/charge_mode.go service/pricing/charge_calc.go -git status --short # 确认暂存区只含上述两文件(charge_calc.go 会带上已有 WIP 清理) -git commit --only consts/pricing/charge_mode.go service/pricing/charge_calc.go -m "refactor(pricing): modelRule 加 mediaPrices + match 移除 mediaType" -``` - ---- - -## Task 2: 重写 validateTieredBands(TDD) - -**Files:** -- Test: `service/pricing/charge_calc_test.go`(新建) -- Modify: `service/pricing/charge_calc.go`(import 加 `sort`,305-319 重写) - -**Interfaces:** -- Consumes: Task 1 的 `modelRule`/`modelMatch` 新结构 -- Produces: 新语义 `validateTieredBands(rules []modelRule) error`——带长度规则按下界升序判相邻;无长度规则跳过;上不封顶最后 - -- [ ] **Step 1: 写失败测试 `service/pricing/charge_calc_test.go`** - -```go -package service - -import "testing" - -func boolPtr(v bool) *bool { return &v } - -func TestValidateTieredBandsAdjacentOK(t *testing.T) { - rules := []modelRule{ - {Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}}, - {Match: &modelMatch{InputLengthMin: 32001, InputLengthMax: 128000}}, - {Match: &modelMatch{InputLengthMin: 128001, InputLengthMax: 256000}}, - } - if err := validateTieredBands(rules); err != nil { - t.Fatalf("相邻档位应通过: %v", err) - } -} - -func TestValidateTieredBandsSameStartRejected(t *testing.T) { - // 旧实现对 InputLengthMin==0 的规则对 continue,两个同起 0 的档位查不出重叠 - rules := []modelRule{ - {Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}}, - {Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 64000}}, - } - if err := validateTieredBands(rules); err == nil { - t.Fatal("两个同起 0 的档位必须判重叠") - } -} - -func TestValidateTieredBandsUnsortedOverlapDetected(t *testing.T) { - // 乱序提交仍须判重叠(旧实现依赖提交顺序,此用例对旧实现漏检) - rules := []modelRule{ - {Match: &modelMatch{InputLengthMin: 50000, InputLengthMax: 64000}}, - {Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 52000}}, - } - if err := validateTieredBands(rules); err == nil { - t.Fatal("重叠必须与提交顺序无关地被检出") - } -} - -func TestValidateTieredBandsOpenEndedLastOK(t *testing.T) { - rules := []modelRule{ - {Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}}, - {Match: &modelMatch{InputLengthMin: 32001, InputLengthMax: 0}}, // 上不封顶 - } - if err := validateTieredBands(rules); err != nil { - t.Fatalf("上不封顶在最后应通过: %v", err) - } -} - -func TestValidateTieredBandsOpenEndedNotLastRejected(t *testing.T) { - rules := []modelRule{ - {Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}}, - {Match: &modelMatch{InputLengthMin: 32001, InputLengthMax: 0}}, // 上不封顶 - {Match: &modelMatch{InputLengthMin: 64001, InputLengthMax: 96000}}, - } - if err := validateTieredBands(rules); err == nil { - t.Fatal("上不封顶档之后不能再有长度档") - } -} - -func TestValidateTieredBandsNonLengthRuleIgnored(t *testing.T) { - // thinking 兜底档无长度区间,不应参与重叠校验 - rules := []modelRule{ - {Match: &modelMatch{Thinking: boolPtr(false)}}, - {Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}}, - } - if err := validateTieredBands(rules); err != nil { - t.Fatalf("无长度规则不应干扰: %v", err) - } -} -``` - -- [ ] **Step 2: 运行确认失败** - -Run: `cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -run TestValidateTieredBands -count=1 2>&1 | head -20` -Expected: FAIL(`TestValidateTieredBandsSameStartRejected`、`TestValidateTieredBandsUnsortedOverlapDetected` 因旧实现返回 nil 而失败) - -- [ ] **Step 3: 重写 `validateTieredBands`(305-319)+ import 加 `sort`** - -import 块第 3 行 `"errors"` 之后加 `"sort"`: - -```go -import ( - "encoding/json" - "errors" - "fmt" - "math" - "sort" - - pricingConsts "shop-user-trade/consts/pricing" -) -``` - -替换整个旧函数(305-319): - -```go -// validateTieredBands 阶梯分档校验:带长度区间的规则按下界升序后相邻不重叠。 -// 无长度区间规则不参与;上不封顶档(max=0)须为最后一个长度档。 -func validateTieredBands(rules []modelRule) error { - var bands []modelRule - for _, r := range rules { - if r.Match != nil && (r.Match.InputLengthMin > 0 || r.Match.InputLengthMax > 0) { - bands = append(bands, r) - } - } - sort.Slice(bands, func(i, j int) bool { - return bands[i].Match.InputLengthMin < bands[j].Match.InputLengthMin - }) - var prevMax int64 = -1 - openEnded := false - for _, r := range bands { - m := r.Match - if openEnded { - return errors.New("阶梯档位区间重叠:上不封顶档后不能再有档位") - } - if m.InputLengthMin <= prevMax { - return errors.New("阶梯档位区间重叠:规则间 InputLengthMin 须 > 前一档 InputLengthMax") - } - if m.InputLengthMax == 0 { - openEnded = true // 上不封顶,之后不可再有长度档 - } else { - prevMax = m.InputLengthMax - } - } - return nil -} -``` - -- [ ] **Step 4: 运行确认通过** - -Run: `cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -run TestValidateTieredBands -count=1` -Expected: PASS(6 个用例) - -- [ ] **Step 5: 提交** - -```bash -git add service/pricing/charge_calc.go service/pricing/charge_calc_test.go -git status --short -git commit --only service/pricing/charge_calc.go service/pricing/charge_calc_test.go -m "fix(pricing): validateTieredBands 按下界排序判档位相邻 + 修同起 0 漏检" -``` - ---- - -## Task 3: mediaPrices 键/值校验(TDD) - -**Files:** -- Test: `service/pricing/charge_calc_test.go`(末尾追加) -- Modify: `service/pricing/charge_calc.go`(新增 `validateMediaPrices` helper;token validate 292-296、unit validate 359-363 各加一处调用) - -**Interfaces:** -- Consumes: Task 1 的 `ModelMediaSet`、`modelRule.MediaPrices` -- Produces: `validateMediaPrices(mp map[string]*modelPrice) error`(共享 helper,两计算器调用) - -- [ ] **Step 1: 追加失败测试** - -```go -func TestTokenValidateMediaPricesBadKey(t *testing.T) { - c := modelTokenCalculator{base: 1e6} - _, err := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":1},"mediaPrices":{"audios":{"input":2}}}]}`) - if err == nil { - t.Fatal("mediaPrices 非法键必须报错") - } -} - -func TestTokenValidateMediaPricesNilValue(t *testing.T) { - c := modelTokenCalculator{base: 1e6} - _, err := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":1},"mediaPrices":{"audio":null}}]}`) - if err == nil { - t.Fatal("mediaPrices 值为空必须报错") - } -} - -func TestTokenValidateMediaPricesValid(t *testing.T) { - c := modelTokenCalculator{base: 1e6} - _, err := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":1},"mediaPrices":{"audio":{"input":2},"video":{"input":3}}}]}`) - if err != nil { - t.Fatalf("合法 mediaPrices 应通过: %v", err) - } -} - -func TestUnitValidateMediaPricesBadKey(t *testing.T) { - c := modelUnitCalculator{base: 1, usage: usageDurationSec} - _, err := c.validate(`{"unit":"per_second","rules":[{"name":"r","price":{"unitPrice":1},"mediaPrices":{"snd":{"unitPrice":2}}}]}`) - if err == nil { - t.Fatal("mediaPrices 非法键必须报错") - } -} - -func TestUnitValidateMediaPricesValid(t *testing.T) { - c := modelUnitCalculator{base: 1, usage: usageDurationSec} - _, err := c.validate(`{"unit":"per_second","rules":[{"name":"r","price":{"unitPrice":1},"mediaPrices":{"audio":{"unitPrice":2}}}]}`) - if err != nil { - t.Fatalf("合法 mediaPrices 应通过: %v", err) - } -} -``` - -- [ ] **Step 2: 运行确认失败** - -Run: `cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -run TestMediaPrices -count=1 2>&1 | head -20` -Expected: FAIL(`TestTokenValidateMediaPricesBadKey`、`TestTokenValidateMediaPricesNilValue`、`TestUnitValidateMediaPricesBadKey` 旧实现不校验而失败) - -- [ ] **Step 3: 新增 `validateMediaPrices` helper + 两处调用** - -在 `modelRuleMatch` 之后(`matchModelRule` 之前)插入: - -```go -// validateMediaPrices mediaPrices 校验:键须 ∈ ModelMediaSet,值须配置 price(token/unit 计算器共用) -func validateMediaPrices(mp map[string]*modelPrice) error { - for mt, p := range mp { - if !pricingConsts.ModelMediaSet[mt] { - return errors.New("model 费率 mediaPrices 键须为 text/audio/video/image") - } - if p == nil { - return errors.New("model 费率 mediaPrices 值须配置 price") - } - } - return nil -} -``` - -`modelTokenCalculator.validate` 的规则循环(292-296)改为: - -```go - for i := range r.Rules { - if r.Rules[i].Price == nil { - return nil, errors.New("model 费率规则须配置 price") - } - if err := validateMediaPrices(r.Rules[i].MediaPrices); err != nil { - return nil, err - } - } -``` - -`modelUnitCalculator.validate` 的规则循环(359-363)改为: - -```go - for i := range r.Rules { - if r.Rules[i].Price == nil { - return nil, errors.New("model 费率规则须配置 price") - } - if err := validateMediaPrices(r.Rules[i].MediaPrices); err != nil { - return nil, err - } - } -``` - -- [ ] **Step 4: 运行确认通过** - -Run: `cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -run TestMediaPrices -count=1` -Expected: PASS(5 个用例) - -- [ ] **Step 5: 提交** - -```bash -git add service/pricing/charge_calc.go service/pricing/charge_calc_test.go -git status --short -git commit --only service/pricing/charge_calc.go service/pricing/charge_calc_test.go -m "feat(pricing): mediaPrices 键/值校验 + ModelMediaSet 白名单" -``` - ---- - -## Task 4: pickModelPrice 运行时取价 + 两处 charge 接线(TDD) - -**Files:** -- Test: `service/pricing/charge_calc_test.go`(末尾追加) -- Modify: `service/pricing/charge_calc.go`(新增 `pickModelPrice`;token charge 327 改 `p := pickModelPrice(rule, usage)`;unit charge 373 改) - -**Interfaces:** -- Consumes: Task 1 的 `modelRule.MediaPrices`、`ChargeUsage.MediaType`(保留字段) -- Produces: `pickModelPrice(rule *modelRule, usage *ChargeUsage) *modelPrice` - -- [ ] **Step 1: 追加失败测试** - -```go -func TestTokenChargeMediaPricesAudio(t *testing.T) { - c := modelTokenCalculator{base: 1e6} - rules, err := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":0.3,"output":1.8,"cacheHit":0.12},"mediaPrices":{"audio":{"input":4.5,"output":1.8,"cacheHit":1.8}}}]}`) - if err != nil { - t.Fatalf("validate: %v", err) - } - got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1e6, MediaType: "audio"}) - if err != nil { - t.Fatalf("charge: %v", err) - } - if got != 4.5 { - t.Fatalf("含音频应收媒体价 4.5,实收 %v", got) - } -} - -func TestTokenChargeDefaultWithoutMedia(t *testing.T) { - c := modelTokenCalculator{base: 1e6} - rules, _ := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":0.3,"output":1.8,"cacheHit":0.12},"mediaPrices":{"audio":{"input":4.5,"output":1.8,"cacheHit":1.8}}}]}`) - got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1e6, MediaType: "text"}) - if err != nil { - t.Fatalf("charge: %v", err) - } - if got != 0.3 { - t.Fatalf("不含媒体应收默认价 0.3,实收 %v", got) - } -} - -func TestTokenChargeMediaPricesMultiType(t *testing.T) { - c := modelTokenCalculator{base: 1000} - rules, _ := c.validate(`{"unit":"per_1K","rules":[{"name":"r","price":{"input":0.3},"mediaPrices":{"audio":{"input":4.5},"video":{"input":6}}}]}`) - got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1000, MediaType: "video"}) - if err != nil { - t.Fatalf("charge: %v", err) - } - if got != 6 { - t.Fatalf("video 应收媒体价 6,实收 %v", got) - } -} - -func TestTokenChargeMediaPricesUnmatchedTypeUsesDefault(t *testing.T) { - // mediaPrices 只有 audio,video 输入落默认价 - c := modelTokenCalculator{base: 1000} - rules, _ := c.validate(`{"unit":"per_1K","rules":[{"name":"r","price":{"input":0.3},"mediaPrices":{"audio":{"input":4.5}}}]}`) - got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1000, MediaType: "video"}) - if err != nil { - t.Fatalf("charge: %v", err) - } - if got != 0.3 { - t.Fatalf("未配 mediaPrices 的媒体应收默认价 0.3,实收 %v", got) - } -} - -func TestUnitChargeMediaPrices(t *testing.T) { - c := modelUnitCalculator{base: 1, usage: usageDurationSec} - rules, err := c.validate(`{"unit":"per_second","rules":[{"name":"r","price":{"unitPrice":1},"mediaPrices":{"audio":{"unitPrice":2}}}]}`) - if err != nil { - t.Fatalf("validate: %v", err) - } - got, err := c.charge(rules, &ChargeUsage{DurationSec: 3, MediaType: "audio"}) - if err != nil { - t.Fatalf("charge: %v", err) - } - if got != 6 { - t.Fatalf("audio 单位价应收 6,实收 %v", got) - } -} - -func TestUnitChargeMediaPricesDefault(t *testing.T) { - c := modelUnitCalculator{base: 1, usage: usageDurationSec} - rules, _ := c.validate(`{"unit":"per_second","rules":[{"name":"r","price":{"unitPrice":1},"mediaPrices":{"audio":{"unitPrice":2}}}]}`) - got, err := c.charge(rules, &ChargeUsage{DurationSec: 3, MediaType: "video"}) - if err != nil { - t.Fatalf("charge: %v", err) - } - if got != 3 { - t.Fatalf("未配媒体价应收默认 3,实收 %v", got) - } -} -``` - -**回归测试(既有 charge/validate 行为,用户裁决:旧 10 测试维持删除,Task 4 补回归)。** 前 6 个 mediaPrices 测试为「红」步骤(旧实现必失败);以下 8 个回归测试覆盖设计未改动却会被 Task 4 触碰的行为,作为绿色安全网(fixture 已去掉 match.mediaType——Task 1 已从结构移除): - -```go -// 阶梯算费 fixture(原 fixture 的 mediaType:"text" 已随 match 移除) -const modelTokenTieredJSON = `{ - "unit":"per_1M","tiered":true, - "rules":[ - {"name":"思考≤32000","match":{"thinking":true,"inputLengthMax":32000},"price":{"input":0.6,"output":3.6,"cacheHit":0.12}}, - {"name":"思考>32000","match":{"thinking":true,"inputLengthMin":32001},"price":{"input":0.9,"output":5.4,"cacheHit":0.18}} - ]}` - -// 阶梯算费:prompt 20000 → (0.6*20000+3.6*1000)/1e6=0.0156→0.02;50000 → 0.0504→0.06 -func TestModelTokenCalculator_TieredMatch(t *testing.T) { - c := modelTokenCalculator{base: 1e6} - rules, err := c.validate(modelTokenTieredJSON) - if err != nil { - t.Fatalf("validate err: %v", err) - } - got, err := c.charge(rules, &ChargeUsage{PromptTokens: 20000, CompletionTokens: 1000, Thinking: boolPtr(true)}) - if err != nil { - t.Fatalf("charge err: %v", err) - } - if got != 0.02 { - t.Fatalf("tier1 got %v want 0.02", got) - } - got, err = c.charge(rules, &ChargeUsage{PromptTokens: 50000, CompletionTokens: 1000, Thinking: boolPtr(true)}) - if err != nil { - t.Fatalf("charge err: %v", err) - } - if got != 0.06 { - t.Fatalf("tier2 got %v want 0.06", got) - } -} - -// 无匹配报错:thinking=false 不在任一档 -func TestModelTokenCalculator_NoMatch(t *testing.T) { - c := modelTokenCalculator{base: 1e6} - rules, err := c.validate(modelTokenTieredJSON) - if err != nil { - t.Fatalf("validate err: %v", err) - } - _, err = c.charge(rules, &ChargeUsage{PromptTokens: 1000, Thinking: boolPtr(false)}) - if err == nil { - t.Fatal("expected no-match error") - } -} - -// cache-hit 算费:(2000-1000)/1000*1 + 1000/1000*0.1 + 500/1000*2 = 2.1 -func TestModelTokenCalculator_CacheHit(t *testing.T) { - c := modelTokenCalculator{base: 1000} - rules, err := c.validate(`{"unit":"per_1K","tiered":false,"rules":[{"name":"统一","price":{"input":1,"output":2,"cacheHit":0.1}}]}`) - if err != nil { - t.Fatalf("validate err: %v", err) - } - got, err := c.charge(rules, &ChargeUsage{PromptTokens: 2000, CompletionTokens: 500, CachedTokens: 1000}) - if err != nil { - t.Fatalf("charge err: %v", err) - } - if got != 2.1 { - t.Fatalf("cache got %v want 2.1", got) - } -} - -// M3:cached > prompt 时金额不为负(promptInput 钳为 0):0 + 3000/1000*0.1 = 0.3 -func TestModelTokenCalculator_CachedGreaterThanPrompt(t *testing.T) { - c := modelTokenCalculator{base: 1000} - rules, err := c.validate(`{"unit":"per_1K","tiered":false,"rules":[{"name":"统一","price":{"input":1,"output":2,"cacheHit":0.1}}]}`) - if err != nil { - t.Fatalf("validate err: %v", err) - } - got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1000, CompletionTokens: 0, CachedTokens: 3000}) - if err != nil { - t.Fatalf("charge err: %v", err) - } - if got < 0 { - t.Fatalf("got negative amount %v", got) - } - if got != 0.3 { - t.Fatalf("got %v want 0.3", got) - } -} - -// P2:validate 拒绝 unit 与实例 base 不匹配的配置 -func TestModelTokenCalculator_ValidateUnitBaseMismatch(t *testing.T) { - c := modelTokenCalculator{base: 1e6} - if _, err := c.validate(`{"unit":"per_1K","tiered":false,"rules":[{"name":"统一","price":{"input":1}}]}`); err == nil { - t.Fatal("expected unit/base mismatch error for per_1M instance with per_1K unit") - } - c2 := modelTokenCalculator{base: 1000} - if _, err := c2.validate(`{"unit":"per_1M","tiered":false,"rules":[{"name":"统一","price":{"input":1}}]}`); err == nil { - t.Fatal("expected unit/base mismatch error for per_1K instance with per_1M unit") - } - if _, err := c.validate(`{"unit":"per_1M","tiered":false,"rules":[{"name":"统一","price":{"input":1}}]}`); err != nil { - t.Fatalf("unexpected err: %v", err) - } - if _, err := c2.validate(`{"unit":"per_1K","tiered":false,"rules":[{"name":"统一","price":{"input":1}}]}`); err != nil { - t.Fatalf("unexpected err: %v", err) - } -} - -// P1:validate 拒绝缺 price 的规则(token 计算器) -func TestModelTokenCalculator_ValidateNilPrice(t *testing.T) { - c := modelTokenCalculator{base: 1e6} - if _, err := c.validate(`{"unit":"per_1M","tiered":false,"rules":[{"name":"无价"}]}`); err == nil { - t.Fatal("expected nil price validation error") - } -} - -// P1:validate 拒绝缺 price 的规则(单位计算器) -func TestModelUnitCalculator_ValidateNilPrice(t *testing.T) { - c := modelUnitCalculator{base: 60, usage: usageDurationSec} - if _, err := c.validate(`{"unit":"per_minute","tiered":false,"rules":[{"name":"无价"}]}`); err == nil { - t.Fatal("expected nil price validation error") - } -} - -// 单位计算器多路径:按分钟(90s/60*2=3)、按字(10000*0.0005=5)、按张×分辨率(3*0.5=1.5) -func TestModelUnitCalculator_Paths(t *testing.T) { - c := modelUnitCalculator{base: 60, usage: usageDurationSec} - rules, err := c.validate(`{"unit":"per_minute","tiered":false,"rules":[{"name":"统一","price":{"unitPrice":2}}]}`) - if err != nil { - t.Fatalf("validate err: %v", err) - } - got, err := c.charge(rules, &ChargeUsage{DurationSec: 90}) - if err != nil || got != 3 { - t.Fatalf("per_minute got %v want 3, err %v", got, err) - } - c2 := modelUnitCalculator{base: 1, usage: usageCharCount} - rules2, err := c2.validate(`{"unit":"per_char","tiered":false,"rules":[{"name":"统一","price":{"unitPrice":0.0005}}]}`) - if err != nil { - t.Fatalf("validate err: %v", err) - } - got, err = c2.charge(rules2, &ChargeUsage{CharCount: 10000}) - if err != nil || got != 5 { - t.Fatalf("per_char got %v want 5, err %v", got, err) - } - c3 := modelUnitCalculator{base: 1, usage: usageImageCount} - rules3, err := c3.validate(`{"unit":"per_1","tiered":false,"rules":[ - {"name":"512x512","match":{"outputResolution":"512x512"},"price":{"unitPrice":0.2}}, - {"name":"1024x1024","match":{"outputResolution":"1024x1024"},"price":{"unitPrice":0.5}}]}`) - if err != nil { - t.Fatalf("validate err: %v", err) - } - got, err = c3.charge(rules3, &ChargeUsage{ImageCount: 3, OutputResolution: "1024x1024"}) - if err != nil || got != 1.5 { - t.Fatalf("per_1 got %v want 1.5, err %v", got, err) - } -} -``` - -(`boolPtr` 已在 Task 2 测试文件定义,直接复用。旧 `TestPerPeriodCalculator`/`TestCalcChargeBySubjectType` 覆盖 business/分派路径,本设计不改动它们,按用户裁决不恢复——记 ledger minor。) - -- [ ] **Step 2: 运行确认失败** - -Run: `cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -run TestTokenChargeMediaPricesAudio -count=1 2>&1 | head -15` -Expected: FAIL(旧实现 `p := rule.Price`,含音频用例收 0.3 而非 4.5;回归测试为绿色网,不在本红步骤验证范围) - -- [ ] **Step 3: 新增 `pickModelPrice` + 两处 charge 接线** - -在 `matchModelRule` 之后(token 计算器之前)插入: - -```go -// pickModelPrice 命中规则后按输入媒体取价:mediaPrices 命中使用媒体价,否则默认 price。 -func pickModelPrice(rule *modelRule, usage *ChargeUsage) *modelPrice { - if len(rule.MediaPrices) > 0 { - if p, ok := rule.MediaPrices[usage.MediaType]; ok { - return p - } - } - return rule.Price -} -``` - -`modelTokenCalculator.charge` 第 327 行 `p := rule.Price` 改为: - -```go - p := pickModelPrice(rule, usage) -``` - -`modelUnitCalculator.charge` 第 373 行改为: - -```go - p := pickModelPrice(rule, usage) - return ceilFen(roundCost(c.usage(usage) / c.base * p.UnitPrice)), nil -``` - -(`validate` 已保证 `rule.Price` 与 `mediaPrices` 各值为非 nil,`pickModelPrice` 返回必非 nil。) - -- [ ] **Step 4: 运行确认通过** - -Run: `cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -count=1` -Expected: PASS(全部用例,含 Task 2/3/4 共 25 个 = 6 + 5 + 6 新 + 8 回归) - -- [ ] **Step 5: 提交** - -```bash -git add service/pricing/charge_calc.go service/pricing/charge_calc_test.go -git status --short -git commit --only service/pricing/charge_calc.go service/pricing/charge_calc_test.go -m "feat(pricing): pickModelPrice 按输入媒体取媒体价/默认价" -``` - ---- - -## Task 5: 旧 spec §4.2/§5.2/§8 文档同步 + 全量验证 - -**Files:** -- Modify: `docs/superpowers/specs/2026-08-28-pricing-enum-design.md`(9 处精确替换) -- Modify: `docs/superpowers/specs/2026-09-01-pricing-media-price-design.md`(§5.2 代码块语法修正) -- 无 Go 代码改动 - -**Interfaces:** -- 无(纯文档;同步 v0.4 设计,指向 `2026-09-01-pricing-media-price-design.md`) - -- [ ] **Step 1: 统一结构 JSON 去掉 `discount:null`、rules 行加 mediaPrices(102-112)** - -```json -{ - "unit": "per_1M", - "tiered": false, - "rules": [ - { "name": "规则名", "match": {...}, "price": {...}, "mediaPrices": {"audio": {...}} } - ], - "currency": "CNY" -} -``` - -- [ ] **Step 2: match 表删 `mediaType` 行(130)** - -删除该行: - -``` -| `mediaType` | text/audio/video/image | 输入媒体类型 | 视频「输入含/不含视频」、推理「输入含/不含音频」 | -``` - -- [ ] **Step 3: price 段追加 mediaPrices 说明(136-138 之后)** - -在「非 token 单位…」行之后追加: - -``` -**mediaPrices 媒体类型价**(可选):`媒体类型 → price` 的 map,键 ∈ {text,audio,video,image}。输入含该媒体时用对应价;否则用默认 `price`(**默认价 = 输入不含以下媒体**)。一个规则可挂多套媒体价;缺 key 即走默认价。 -``` - -- [ ] **Step 4: model-gateway 关系注记改写(142)** - -```markdown -> 与 model-gateway `PriceConfig` 的关系:不复用其字段结构(`inputAudio/cacheHitAudio/cacheStorageHour` 删除,音频输入差价改由 `mediaPrices.audio` 表达)。`discount` 废弃(代码无实现)。v0.4 修订全文见 `2026-09-01-pricing-media-price-design.md`。 -``` - -- [ ] **Step 5: 视频示例去 mediaType、加 mediaPrices(151-152)** - -```json - { "name": "无声-720p", "match": {"outputAudio": false, "outputResolution": "720p"}, "price": {"unitPrice": 0.8}, "mediaPrices": {"video": {"unitPrice": 1.0}} }, - { "name": "有声-1080p", "match": {"outputAudio": true, "outputResolution": "1080p"}, "price": {"unitPrice": 1.5}, "mediaPrices": {"video": {"unitPrice": 2.0}} } -``` - -- [ ] **Step 6: 推理示例去掉文本遮蔽规则、音频差价改 mediaPrices(162-165)** - -```json - { "name": "思考-输入≤32000", "match": {"thinking": true, "inputLengthMax": 32000}, "price": {"input": 0.6, "output": 3.6, "cacheHit": 0.12}, "mediaPrices": {"audio": {"input": 9, "output": 3.6, "cacheHit": 1.8}} }, - { "name": "思考-输入32001~128000", "match": {"thinking": true, "inputLengthMin": 32001, "inputLengthMax": 128000}, "price": {"input": 0.9, "output": 5.4, "cacheHit": 0.18}, "mediaPrices": {"audio": {"input": 13.5, "output": 5.4, "cacheHit": 2.7}} }, - { "name": "非思考-统一价", "match": {"thinking": false}, "price": {"input": 0.3, "output": 1.2, "cacheHit": 0.06} } -``` - -- [ ] **Step 7: Save 校验行同步(181)** - -```markdown -- Save 校验:`unit ∈ {per_1K, per_1M, per_1, per_second, per_minute, per_hour, per_char}`;`tiered:true` 仅 token 单位且 inputLength 档按下界排序相邻不重叠(上不封顶档须最后);mediaPrices 键 ∈ {text,audio,video,image} 且值非空;match 字段取值合法;price 非负。 -``` - -- [ ] **Step 8: §5.2 折扣行改写(210)** - -```markdown -- 折扣:废弃(v0.4 修订为规则级 `mediaPrices`,见 `2026-09-01-pricing-media-price-design.md`)。 -``` - -- [ ] **Step 9: §8 决策表「价格项收敛」行改写(296)** - -```markdown -| 价格项收敛 input/output/cacheHit + unitPrice | 四类模型一套结构;音频输入差价用 `mediaPrices.audio` 表达(删 inputAudio/cacheHitAudio/cacheStorageHour 与 match.mediaType) | -``` - -(`ChargeUsage.MediaType` §5.3 225 行保留不动——上报入参字段。) - -- [ ] **Step 10: 设计文档 §5.2 代码块语法修正** - -`docs/superpowers/specs/2026-09-01-pricing-media-price-design.md` §5.2 的 mediaPrices 校验代码块中,`ModelMediaSet` 是 `map[string]struct{}`,`!pricingConsts.ModelMediaSet[mt]` 无法编译。将该判断改为 `_, ok` 成员判定(与实现一致;实现已将校验提取为共享 `validateMediaPrices` helper): - -```go - if len(r.Rules[i].MediaPrices) > 0 { - for mt, mp := range r.Rules[i].MediaPrices { - if _, ok := pricingConsts.ModelMediaSet[mt]; !ok { - return nil, errors.New("model 费率 mediaPrices 键须为 text/audio/video/image") - } - if mp == nil { - return nil, errors.New("model 费率 mediaPrices 值须配置 price") - } - } - } -``` - -- [ ] **Step 11: 全量编译 + 全量测试** - -Run: `cd /c/App/GolandProjects/shop-user-trade && go build ./... && GF_GCFG_PATH="C:/App/GolandProjects/shop-user-trade" go test ./service/pricing/ ./model/dto/pricing/ -count=1` -Expected: PASS(service/pricing 25 用例全绿;GF_GCFG_PATH 规避 common/consul init panic) - -- [ ] **Step 12: 提交** - -```bash -git add docs/superpowers/specs/2026-08-28-pricing-enum-design.md docs/superpowers/specs/2026-09-01-pricing-media-price-design.md -git status --short -git commit --only docs/superpowers/specs/2026-08-28-pricing-enum-design.md docs/superpowers/specs/2026-09-01-pricing-media-price-design.md -m "docs(pricing): 枚举设计 §4.2/§5.2/§8 同步 mediaPrices v0.4 + 设计文档 §5.2 语法修正" -``` - ---- - -## Self-Review 记录 - -- **Spec 覆盖**:v0.4 spec §3(结构变更)→ Task 1;§5.1(validateTieredBands)→ Task 2;§5.2(mediaPrices 校验 + ModelMediaSet)→ Task 1/3;§4.1(pickModelPrice + 两处 charge)→ Task 4;§8 兼容与迁移(match.mediaType 移除、discount 废弃、旧 spec 同步)→ Task 1/5;§10 测试要点 → Task 2-4 全覆盖(相邻合法/同起0/乱序/上不封顶/无长度规则;audio/默认价/多类型/未匹配走默认;键非法/值 nil/两计算器)。 -- **占位符扫描**:无 TBD/TODO;所有代码块为完整可粘贴代码;错误文案与 spec §5.2 逐字一致。 -- **类型一致性**:`ModelMediaSet`(Task1 定义)Task3 消费;`modelRule.MediaPrices`(Task1)Task3/4 消费;`pickModelPrice`/`validateMediaPrices` 签名在定义与调用处一致;`modelMatch` 无 MediaType 贯穿全部测试构造。 -- **测试互不依赖**:Task 3/4 测试追加到同一 `charge_calc_test.go`,Task 4 全量跑含 Task 2/3 用例(25 个 = Task2 6 + Task3 5 + Task4 媒体价 6 + 回归 8),顺序无关。 -- **回归裁决**:Task 2 审查发现旧 charge_calc_test.go(HEAD 存在 10 测试)被覆盖删除,用户裁决「维持删除,Task 4 补回归」——Task 4 已加 8 个回归测试(阶梯算费/无匹配/cache-hit/M3 负值钳制/P2 base 不匹配/P1 nil-price×2/单位多路径),fixture 去 mediaType;per_period/分派路径未恢复(设计不改动),记 ledger minor。 -- **范围外死代码删除(最终审查裁决)**:Task 1 顺带删除了共享 `calculator` 接口的 `mode()` 方法(6 个计算器实现)与 `ChargeUsage.ItemCount` 字段。最终整分支审查(opus)核实为**死代码**——`.mode(` 全模块零调用(分派走显式 workflowCalculators/modelCalculators/businessCalculators maps + getCalculator),`ItemCount` 零 Go 引用(仅 charge_dto.go:33 doc 注释提及)。计划 §11「不改动 ChargeUsage 字段 / workflow-business 计算器」文本与之冲突,用户裁决「保留删除 + 记录确认」。 diff --git a/docs/superpowers/plans/2026-09-01-subject-charge-modes.md b/docs/superpowers/plans/2026-09-01-subject-charge-modes.md deleted file mode 100644 index ced7102..0000000 --- a/docs/superpowers/plans/2026-09-01-subject-charge-modes.md +++ /dev/null @@ -1,282 +0,0 @@ -# 按 SubjectType 查询可选计费方式 实现计划 - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 提供 `GET /pricing/controller/subjects/charge-modes?subjectType=X&subjectId=Y`,返回该主体可选计费方式(mode + 中文名),供前端渲染"选择计费方式"下拉。 - -**Architecture:** 纯常量服务,零外部依赖。计费方式中文名 `ChargeModeNames` map + workflow 三模式 `WorkflowChargeModes` 常量放 `consts/pricing/`;DTO 定义请求/响应;`subject_service.go` 新增 `ListChargeModes` 按 subjectType+subjectId 派发(workflow 校验固定 subjectId,business 查 `BusinessSubjects`);controller 注册 `ChargeModes` 方法。 - -**Tech Stack:** Go 1.26.1,GoFrame v2(请求 DTO 带 `g.Meta` 路由标签),标准库 testing。 - -## Global Constraints - -- `subjectType` 校验 `in:workflow,business`;**model 非法**——模型计费方式无需选择,误传 400(spec §2) -- `subjectId` 必填:workflow 时须=`WorkflowSubjectID`(`workflow`);business 时须∈`BusinessSubjects`,否则报「未知业务模块」(spec §2/§3) -- 纯查常量:不调 model-gateway、不查 `pricing_config`(spec §3) -- 路由 `path:"/subjects/charge-modes" method:"get"`,URL 前缀 `/pricing/controller/`(复用既有 SubjectListReq 前缀) -- 中文名覆盖全部 10 个枚举(spec §4 表,值逐字照抄:按条/按秒/按token/每千token/每百万token/每张·每个/每分钟/每小时/每字/周期订阅);`name` 未命中回退 `mode` 本身 -- `WorkflowChargeModes` 三键须与 `charge_calc.go` 的 `workflowCalculators` 一致:`per_item, per_second, per_token` -- 不改 `/pricing/subjects` 枚举、`pricing_config` save 校验、计算器注册表、DB 结构 -- 金额一律不动;新增代码不动 `ChargeMode` 常量本身 - ---- -## Task 1: 常量与 DTO - -**Files:** -- Modify: `consts/pricing/charge_mode.go`(文件末尾追加) -- Modify: `consts/pricing/subject.go`(`BusinessSubjects` 之后追加) -- Modify: `model/dto/pricing/subject_dto.go`(`SubjectInfo` 之后追加) - -**Interfaces:** -- Consumes: 既有 `ChargeMode` 常量(`per_item`/`per_second`/`per_token`/`per_1K`/`per_1M`/`per_1`/`per_minute`/`per_hour`/`per_char`/`per_period`)、`WorkflowSubjectID`、`BusinessSubjects`、`SubjectTypeWorkflow` -- Produces: - - `pricingConsts.ChargeModeNames map[ChargeMode]string`(10 键全枚举) - - `pricingConsts.WorkflowChargeModes []ChargeMode`(三键,声明序) - - `pricingDto.ChargeModeListReq{SubjectType, SubjectID string}`(g.Meta path `/subjects/charge-modes` method get;`subjectType v:"required|in:workflow,business"`、`subjectId v:"required"`) - - `pricingDto.ChargeModeListRes{SubjectType, SubjectID string; ChargeModes []ChargeModeInfo}` - - `pricingDto.ChargeModeInfo{Mode, Name string}` - -- [ ] **Step 1: `consts/pricing/charge_mode.go` 追加中文名 map** - -在文件末尾(`PeriodSet` 之后)追加: - -```go - -// ChargeModeNames 计费方式中文名(前端下拉渲染;全枚举覆盖,未命中回退 mode 本身) -var ChargeModeNames = map[ChargeMode]string{ - ChargeModePerItem: "按条", - ChargeModePerSecond: "按秒", - ChargeModePerToken: "按token", - ChargeModePer1K: "每千token", - ChargeModePer1M: "每百万token", - ChargeModePer1: "每张/每个", - ChargeModePerMinute: "每分钟", - ChargeModePerHour: "每小时", - ChargeModePerChar: "每字", - ChargeModePerPeriod: "周期订阅", -} -``` - -- [ ] **Step 2: `consts/pricing/subject.go` 追加 workflow 三模式** - -在 `BusinessSubjects` 定义之后追加: - -```go - -// WorkflowChargeModes workflow 主体可选计费方式(与 charge_calc.go workflowCalculators 三键一致) -var WorkflowChargeModes = []ChargeMode{ChargeModePerItem, ChargeModePerSecond, ChargeModePerToken} -``` - -- [ ] **Step 3: `model/dto/pricing/subject_dto.go` 追加 DTO** - -在 `SubjectInfo` 之后追加: - -```go - -// ChargeModeListReq 按主体查询可选计费方式请求 -type ChargeModeListReq struct { - g.Meta `path:"/subjects/charge-modes" method:"get" tags:"计价配置" summary:"查询主体可选计费方式" dc:"按 subjectType+subjectId 返回该主体可选计费方式(workflow/business;model 无需选择不入参)"` - - SubjectType string `json:"subjectType" v:"required|in:workflow,business" dc:"计价对象类型(model 非法:模型计费方式无需选择)"` - SubjectID string `json:"subjectId" v:"required" dc:"计价对象ID:workflow→workflow;business→业务模块标识"` -} - -// ChargeModeListRes 可选计费方式响应 -type ChargeModeListRes struct { - SubjectType string `json:"subjectType" dc:"计价对象类型"` - SubjectID string `json:"subjectId" dc:"计价对象ID"` - ChargeModes []ChargeModeInfo `json:"chargeModes" dc:"可选计费方式列表"` -} - -// ChargeModeInfo 计费方式项 -type ChargeModeInfo struct { - Mode string `json:"mode" dc:"提交值:per_item/per_second/per_token/per_1K/per_1M/per_1/per_minute/per_hour/per_char/per_period"` - Name string `json:"name" dc:"展示名:按条/按秒/按token/每千token/每百万token/每张·每个/每分钟/每小时/每字/周期订阅"` -} -``` - -- [ ] **Step 4: 编译验证** - -Run: `cd /c/App/GolandProjects/shop-user-trade && go build ./consts/... ./model/dto/...` -Expected: PASS(无输出即成功) - -- [ ] **Step 5: 提交** - -```bash -git add consts/pricing/charge_mode.go consts/pricing/subject.go model/dto/pricing/subject_dto.go -git commit -m "feat(pricing): 计费方式中文名 + workflow 三模式常量 + charge-modes DTO" -``` -(注意:工作树有用户 WIP 已暂存,`git add` 指定路径、commit 用 `--only` 排除 WIP——见 Global 注意事项,提交前确认 `git status` 只含本任务文件) - ---- -## Task 2: 服务逻辑 + 控制器 + 测试 - -**Files:** -- Modify: `service/pricing/subject_service.go`(`modelTypeName` 之后追加) -- Modify: `controller/pricing/pricing_controller.go`(`Subjects` 方法之后追加) -- Create: `service/pricing/subject_service_test.go` - -**Interfaces:** -- Consumes: Task 1 产物 `pricingConsts.ChargeModeNames`、`pricingConsts.WorkflowChargeModes`、`pricingDto.ChargeModeListReq/Res/Info`;既有 `pricingConsts.SubjectTypeWorkflow/Business`、`WorkflowSubjectID`、`BusinessSubjects`、`ChargeMode`;既有 `var Pricing = new(pricing)` 与 `func (s *pricing)` 接收者模式(pricing_service.go:36-38) -- Produces: `(*pricing).ListChargeModes(ctx, *pricingDto.ChargeModeListReq) (*pricingDto.ChargeModeListRes, error)`;controller 方法 `ChargeModes` - -- [ ] **Step 1: 写失败测试 `service/pricing/subject_service_test.go`** - -```go -package service - -import ( - "context" - "testing" - - pricingConsts "shop-user-trade/consts/pricing" - pricingDto "shop-user-trade/model/dto/pricing" -) - -// 纯常量逻辑,无 DB 依赖(service 包无 init,直接调 Pricing 单例) -func TestListChargeModesWorkflow(t *testing.T) { - res, err := Pricing.ListChargeModes(context.Background(), &pricingDto.ChargeModeListReq{ - SubjectType: string(pricingConsts.SubjectTypeWorkflow), - SubjectID: pricingConsts.WorkflowSubjectID, - }) - if err != nil { - t.Fatalf("err: %v", err) - } - if res.SubjectType != "workflow" || len(res.ChargeModes) != 3 { - t.Fatalf("got %+v", res) - } - want := []string{"per_item", "per_second", "per_token"} - for i, m := range res.ChargeModes { - if m.Mode != want[i] { - t.Fatalf("mode[%d]=%s want %s", i, m.Mode, want[i]) - } - if m.Name == "" { - t.Fatalf("mode %s 缺中文名", m.Mode) - } - } -} - -func TestListChargeModesWorkflowWrongSubjectID(t *testing.T) { - if _, err := Pricing.ListChargeModes(context.Background(), &pricingDto.ChargeModeListReq{ - SubjectType: "workflow", SubjectID: "not-workflow", - }); err == nil { - t.Fatal("expected error for wrong workflow subjectId") - } -} - -func TestListChargeModesBusiness(t *testing.T) { - res, err := Pricing.ListChargeModes(context.Background(), &pricingDto.ChargeModeListReq{ - SubjectType: "business", SubjectID: "ai_customer_service", - }) - if err != nil { - t.Fatalf("err: %v", err) - } - if len(res.ChargeModes) != 1 || res.ChargeModes[0].Mode != "per_period" { - t.Fatalf("got %+v", res) - } - if res.ChargeModes[0].Name != "周期订阅" { - t.Fatalf("name=%s want 周期订阅", res.ChargeModes[0].Name) - } -} - -func TestListChargeModesBusinessUnknown(t *testing.T) { - if _, err := Pricing.ListChargeModes(context.Background(), &pricingDto.ChargeModeListReq{ - SubjectType: "business", SubjectID: "nope", - }); err == nil { - t.Fatal("expected unknown business error") - } -} -``` - -- [ ] **Step 2: 运行确认失败** - -Run: `cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -run TestListChargeModes -count=1 2>&1 | head -20` -Expected: FAIL(`Pricing.ListChargeModes undefined`) - -- [ ] **Step 3: 实现服务方法** - -`service/pricing/subject_service.go` 末尾(`modelTypeName` 函数之后)追加: - -```go - -// ListChargeModes 按 (subjectType, subjectId) 返回主体自身可选计费方式(spec 2026-09-01)。 -// model 无需选择不入参(subjectType 校验层拦截);workflow/business 纯查常量,零外部依赖。 -func (s *pricing) ListChargeModes(ctx context.Context, req *pricingDto.ChargeModeListReq) (*pricingDto.ChargeModeListRes, error) { - switch pricingConsts.SubjectType(req.SubjectType) { - case pricingConsts.SubjectTypeWorkflow: - if req.SubjectID != pricingConsts.WorkflowSubjectID { - return nil, fmt.Errorf("workflow 主体 subjectId 必须为 %q", pricingConsts.WorkflowSubjectID) - } - return &pricingDto.ChargeModeListRes{ - SubjectType: req.SubjectType, - SubjectID: req.SubjectID, - ChargeModes: toChargeModeInfo(pricingConsts.WorkflowChargeModes), - }, nil - case pricingConsts.SubjectTypeBusiness: - for _, b := range pricingConsts.BusinessSubjects { - if b.SubjectID == req.SubjectID { - return &pricingDto.ChargeModeListRes{ - SubjectType: req.SubjectType, - SubjectID: req.SubjectID, - ChargeModes: toChargeModeInfo(b.ChargeModes), - }, nil - } - } - return nil, fmt.Errorf("未知业务模块: %s", req.SubjectID) - default: - return nil, fmt.Errorf("不支持的计价对象类型: %s", req.SubjectType) - } -} - -// toChargeModeInfo 计费方式 → {mode, name}(name 未命中回退 mode,避免展示空串) -func toChargeModeInfo(modes []pricingConsts.ChargeMode) []pricingDto.ChargeModeInfo { - items := make([]pricingDto.ChargeModeInfo, 0, len(modes)) - for _, m := range modes { - name, ok := pricingConsts.ChargeModeNames[m] - if !ok { - name = string(m) - } - items = append(items, pricingDto.ChargeModeInfo{Mode: string(m), Name: name}) - } - return items -} -``` - -(`fmt` 已在文件现有 import 中;`context` 已在现有 import 中——见 subject_service.go:1-12) - -- [ ] **Step 4: 注册控制器方法** - -`controller/pricing/pricing_controller.go` 的 `Subjects` 方法之后追加: - -```go - -// ChargeModes 查询主体可选计费方式 -func (c *pricingController) ChargeModes(ctx context.Context, req *pricingDto.ChargeModeListReq) (*pricingDto.ChargeModeListRes, error) { - return pricingService.Pricing.ListChargeModes(ctx, req) -} -``` - -- [ ] **Step 5: 运行测试确认通过** - -Run: `cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -run TestListChargeModes -count=1` -Expected: PASS(4 个用例) - -- [ ] **Step 6: 全量编译 + 全量测试** - -Run: `cd /c/App/GolandProjects/shop-user-trade && go build ./... && go test ./service/pricing/ ./model/dto/pricing/ -count=1` -Expected: PASS - -- [ ] **Step 7: 提交** - -```bash -git add service/pricing/subject_service.go service/pricing/subject_service_test.go controller/pricing/pricing_controller.go -git commit -m "feat(pricing): 按 subjectType+subjectId 查询可选计费方式接口" -``` -(commit 用 `--only` 指定上述文件,排除用户 WIP;提交前 `git status --short` 核对只含本任务文件) - ---- -## Self-Review 记录 - -- **Spec 覆盖**:§2 契约(DTO 路由/校验)、§3 数据来源(纯常量、workflow 校验固定 ID、business 查列表)、§4 中文名全枚举、§5 改动清单 1-6 全部映射到 Task 1/2。 -- **占位符扫描**:无 TBD/TODO;所有代码块为完整可粘贴代码。 -- **类型一致性**:`ChargeModeNames`/`WorkflowChargeModes`/`ChargeModeListReq/Res/Info` 在 Task 1 定义、Task 2 消费,命名一致;`ListChargeModes`/`ChargeModes`/`toChargeModeInfo` 签名跨步骤一致。 diff --git a/docs/superpowers/specs/2026-08-28-pricing-enum-design.md b/docs/superpowers/specs/2026-08-28-pricing-enum-design.md deleted file mode 100644 index 72d4123..0000000 --- a/docs/superpowers/specs/2026-08-28-pricing-enum-design.md +++ /dev/null @@ -1,323 +0,0 @@ -# 定价模块计价对象枚举化与模型计价设计 - -> 版本:v0.3(本期)—— 在 v0.2(无预扣)基础上,将计价对象固定枚举化 + 接入模型计价与业务模块周期计价。 -> 范围:shop-user-trade **pricing 计价模块**。交付「枚举 + 配置管理 + per_1M/per_period 计算器」层;模型/业务模块结算接入另期。 -> 约定:金额一律**元**(float64,DB `NUMERIC(15,2)`,2 位小数),不足 1 分向上取整(`ceilFen`)。 - ---- - -## 1. 背景与目标 - -现状(v0.2):`pricing_config` 是「一 `biz_key` → 单一 `charge_mode` + 单模式 `rules`」,只能给任意业务标识配一种计费方式。 - -诉求: -1. 计价对象**固定枚举**:`工作流` + `模型列表`(model-gateway 系统模型)+ `业务模块`(如 AI客服)。后端提供枚举接口,管理端为这些项配置价格标准。 -2. 工作流可**同时配置三套价格**(per_item / per_second / per_token),创建/使用工作流时按本次执行**选择一种**扣费方式。 -3. 模型计价按模型类型自适应:视频(输出有声/无声 × 分辨率 × 输入含不含视频 × 按token/按秒)、推理(阶梯 × 思考/非思考 × 输入token区间 × 输入输出价 × 输入含不含音频)、音频(按字数/时长/按token)、图片(按分辨率 × 按张数)。 -4. 业务模块按**周期**订阅计价(按年),通用 `per_period` 建模,后续可扩月/季。 - -目标:模型价格**单一来源**(只配一份,工作流与直接调模型共用);计价对象枚举 = 表结构;计算器注册表统一派发。 - ---- - -## 2. 核心概念:计价对象(subject)枚举 - -`pricing_config` 按 `(subject_type, subject_id)` 唯一: - -| subject_type | subject_id | 说明 | -|---|---|---| -| `workflow` | `workflow` | 固定单主体,规则为三模式容器 | -| `model` | model-gateway 模型 **ID** | 每个系统模型一个主体,规则为其模型类型自适应计费规则(见 §4.2) | -| `business` | 业务模块标识(如 `ai_customer_service`) | 固定写死的一组业务模块,规则为周期订阅价格 | - -枚举接口 `GET /pricing/subjects` 返回全部可配置主体: -- `workflow`(静态,恒有,附 `chargeModes: [per_item, per_second, per_token]`) -- 各系统模型(**实时**调 model-gateway `/listModelManage`,`system_model=true` 过滤,返回 modelId/modelName/modelType) -- 各业务模块(静态写死,如 AI客服,附 `chargeModes: [per_period]`) -- 每项附 `hasConfig` + `enabled`,管理端据此区分未配价主体。 - -模型主体以 **modelId** 标识(稳定不变),枚举同时返回 modelName 供展示。业务模块列表固定写死在 `consts/pricing`(新增业务模块改常量,暂不做管理端增删)。 - ---- - -## 3. 数据模型 - -### 3.1 pricing_config(改造:biz_key/charge_mode → subject_type/subject_id) - -```sql -CREATE TABLE IF NOT EXISTS pricing_config ( - id BIGINT PRIMARY KEY, - -- 基础字段(SQLBaseDO 同前) - tenant_id BIGINT NOT NULL DEFAULT 0, - creator VARCHAR(64) NOT NULL DEFAULT '', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updater VARCHAR(64) NOT NULL DEFAULT '', updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at timestamp(6), - subject_type VARCHAR(16) NOT NULL, -- workflow | model | business - subject_id VARCHAR(64) NOT NULL, -- workflow | model-gateway 模型ID | 业务模块标识 - rules TEXT NOT NULL, -- 见 §4 - min_balance NUMERIC(15,2) NOT NULL DEFAULT 0, -- 门禁(元),0=不校验 - currency VARCHAR(16) NOT NULL DEFAULT 'CNY', - enabled SMALLINT NOT NULL DEFAULT 1, - version BIGINT NOT NULL DEFAULT 1 -); -CREATE UNIQUE INDEX IF NOT EXISTS uk_pricing_config_subject ON pricing_config(subject_type, subject_id); -``` - -- **去掉 `charge_mode` 列**:rules 自描述(workflow 三模式都在 rules 里;model 的 unit/tiered 在 modelCfg 内)。 -- 存量迁移:旧 `biz_key` 行 → `subject_type='workflow', subject_id=biz_key`;`charge_mode` 列 DROP。线上无真实数据可直接重建表(本期结算未接)。 - -### 3.2 charge_order(改造:biz_key → subject_type/subject_id) - -```sql --- 唯一幂等键:uk_charge_order_subject (subject_type, subject_id, biz_order_no) -subject_type VARCHAR(16) NOT NULL, -subject_id VARCHAR(64) NOT NULL, -charge_mode VARCHAR(32) NOT NULL, -- 建单时选中/派发键:per_item/per_second/per_token/per_1K/per_1M/per_1/per_minute/per_hour/per_char/per_period -rule_snapshot TEXT NOT NULL, -- workflow: 选中模式的规则片段;model: 整个 modelCfg(§4.2);business: 周期价格规则 --- 其余字段(user_id/status/actual_amount/usage/settle_time 等)不变 -``` - ---- - -## 4. rules JSON 格式 - -### 4.1 workflow —— 多模式容器(三套价格全配,只配已配置的) - -```json -{ - "per_item": { "tiers": [ {"maxSec":15,"price":29}, {"maxSec":30,"price":49}, {"maxSec":60,"price":89} ], "overflowUnitPrice": 0.9 }, - "per_second": { "unitPrice": 0.9 }, - "per_token": {} -} -``` - -- mode 名 ∈ `{per_item, per_second, per_token}`;未配的模式不出现。 -- `per_token` 为 `{}`(仅启用标记),**不含价格映射**——价格取自各模型自己的计价配置(见 §5.3)。 -- Save 校验:逐个校验已配模式的 JSON 结构(档位/单价/模型名合法、mode 名合法)。 - -### 4.2 model —— 模型类型自适应计费规则 - -统一结构(rules 按序**首条命中**,先写优先级高): - -```json -{ - "unit": "per_1M", - "tiered": false, - "rules": [ - { "name": "规则名", "match": {...}, "price": {...}, "mediaPrices": {"audio": {...}} } - ], - "currency": "CNY" -} -``` - -**unit 计费单位**(7 种,charge_mode 集合的子集): - -| unit | 语义 | 适用 | -|---|---|---| -| `per_1M` | 每百万 token | 推理 / 视频按token / 音频按token | -| `per_1K` | 每千 token | 同上(小模型) | -| `per_1` | 每个(每张图) | 图片按张数 | -| `per_second` | 每秒 | 视频按时长 | -| `per_minute` | 每分钟 | 音频时长 | -| `per_hour` | 每小时 | 音频时长 | -| `per_char` | 每字 | 音频按字数 | - -**match 命中条件**(全可选,空=任意调用;命中=全部条件满足): - -| 字段 | 值 | 说明 | 用于 | -|---|---|---|---| -| `thinking` | true/false | 思考/非思考模式 | 推理 | -| `outputAudio` | true/false | 输出有声/无声 | 视频 | -| `outputResolution` | 自由字符串 | 输出分辨率(视频 480p/720p/1080p/2k/4k;图片 512x512/1024x1024) | 视频、图片 | -| `inputLengthMin/Max` | int | 输入 token 范围(>=/<=,阶梯分档用) | 推理 | - -**price 计价项**: -- token 单位(per_1K/per_1M):`input`(输入单价)、`output`(输出单价)、`cacheHit`(缓存命中输入单价,缺省 0=无优惠) -- 非 token 单位(per_1/per_second/per_minute/per_hour/per_char):`unitPrice`(每单位单价) -- **mediaPrices 媒体类型价**(可选):`媒体类型 → price` 的 map,键 ∈ {text,audio,video,image}。输入含该媒体时用对应价;否则用默认 `price`(**默认价 = 输入不含以下媒体**)。一个规则可挂多套媒体价;缺 key 即走默认价。 - -**tiered 阶梯(分档不累加)**:仅 token 单位;`tiered:true` 表示按输入 token 分档——档位区间相邻不重叠(下档 max+1=上档 min),结算按 promptTokens 命中哪档就整单按哪档 input/output/cacheHit 价;`tiered:false` 规则间靠其他维度区分。 - -> 与 model-gateway `PriceConfig` 的关系:不复用其字段结构(`inputAudio/cacheHitAudio/cacheStorageHour` 删除,音频输入差价改由 `mediaPrices.audio` 表达)。`discount` 废弃(代码无实现)。v0.4 修订全文见 `2026-09-01-pricing-media-price-design.md`。 - -**示例**: - -视频 · 按秒(分辨率 × 有声/无声 × 输入含不含视频): -```json -{ - "unit": "per_second", "tiered": false, - "rules": [ - { "name": "无声-720p", "match": {"outputAudio": false, "outputResolution": "720p"}, "price": {"unitPrice": 0.8}, "mediaPrices": {"video": {"unitPrice": 1.0}} }, - { "name": "有声-1080p", "match": {"outputAudio": true, "outputResolution": "1080p"}, "price": {"unitPrice": 1.5}, "mediaPrices": {"video": {"unitPrice": 2.0}} } - ] -} -``` - -推理 · 按token 阶梯(思考/非思考 × 输入token档 × 输入含不含音频): -```json -{ - "unit": "per_1M", "tiered": true, - "rules": [ - { "name": "思考-输入≤32000", "match": {"thinking": true, "inputLengthMax": 32000}, "price": {"input": 0.6, "output": 3.6, "cacheHit": 0.12}, "mediaPrices": {"audio": {"input": 9, "output": 3.6, "cacheHit": 1.8}} }, - { "name": "思考-输入32001~128000", "match": {"thinking": true, "inputLengthMin": 32001, "inputLengthMax": 128000}, "price": {"input": 0.9, "output": 5.4, "cacheHit": 0.18}, "mediaPrices": {"audio": {"input": 13.5, "output": 5.4, "cacheHit": 2.7}} }, - { "name": "非思考-统一价", "match": {"thinking": false}, "price": {"input": 0.3, "output": 1.2, "cacheHit": 0.06} } - ] -} -``` - -音频 · 按字数;图片 · 按张数×分辨率: -```json -{ "unit": "per_char", "tiered": false, "rules": [ { "name": "语音-统一价(万字符5元)", "price": {"unitPrice": 0.0005} } ] } - -{ "unit": "per_1", "tiered": false, - "rules": [ - { "name": "512x512", "match": {"outputResolution": "512x512"}, "price": {"unitPrice": 0.2} }, - { "name": "1024x1024", "match": {"outputResolution": "1024x1024"}, "price": {"unitPrice": 0.5} } - ] } -``` - -- Save 校验:`unit ∈ {per_1K, per_1M, per_1, per_second, per_minute, per_hour, per_char}`;`tiered:true` 仅 token 单位且 inputLength 档按下界排序相邻不重叠(上不封顶档须最后);mediaPrices 键 ∈ {text,audio,video,image} 且值非空;match 字段取值合法;price 非负。 - -### 4.3 business —— 周期订阅(per_period) - -```json -{ "period": "year", "price": 1999 } -``` - -- `period`:周期单位,本期 `year`,后续可扩 `month/quarter` 等(扩值即支持,计算器按允许集合校验)。 -- `price`:每周期价格(元),一次性收取。 -- Save 校验:`period ∈ 允许集合`(本期 `{year}`)、`price > 0`。 - ---- - -## 5. 计算器注册表(charge_calc.go) - -### 5.1 统一 charge_mode 枚举 - -``` -per_item | per_second | per_token | per_1K | per_1M | per_1 | per_minute | per_hour | per_char | per_period -``` - -### 5.2 新增 token 计算器(per_1K/per_1M)与单位计算器(per_1/per_second/per_minute/per_hour/per_char) - -- **token 计算器**(注册 per_1K、per_1M 两键,base=1000/100 万): - `cost = (prompt-cached)/base×input + cached/base×cacheHit + completion/base×output`;match 用 promptTokens 判 inputLength 档、用 usage 的 MediaType/Thinking/OutputAudio/OutputResolution 判维度,**首条命中**。 -- **单位计算器**(注册 per_1/per_second/per_minute/per_hour/per_char 五键,base=1/1/60/3600/1,用量源=张数/秒/秒/秒/字数):`cost = 用量/base×unitPrice`。 -- **阶梯=分档不累加**:tiered 只影响 match 语义(按输入 token 档)与校验(档相邻不重叠),无分段累加逻辑。 -- **无规则命中 → 报错「无匹配计费规则」**(配置缺失建单即暴露,不静默收 0)。 -- 折扣:废弃(v0.4 修订为规则级 `mediaPrices`,见 `2026-09-01-pricing-media-price-design.md`)。 - -### 5.3 ChargeUsage 扩展(模型计价入参,本期备用) - -```go -type ChargeUsage struct { - DurationSec float64 `json:"durationSec"` - ItemCount int64 `json:"itemCount"` - TokensByModel map[string]int64 `json:"tokensByModel"` - // —— 模型计价扩展 —— - PromptTokens int64 `json:"promptTokens"` // token 计费用:输入 - CompletionTokens int64 `json:"completionTokens"` // token 计费用:输出 - CachedTokens int64 `json:"cachedTokens"` // token 计费用:缓存命中 - CharCount int64 `json:"charCount"` // per_char 用 - ImageCount int64 `json:"imageCount"` // per_1(图片)用 - MediaType string `json:"mediaType"` // text/audio/video/image(输入媒体) - Thinking *bool `json:"thinking"` // 推理 思考/非思考 - OutputAudio *bool `json:"outputAudio"` // 视频 输出有声/无声 - OutputResolution string `json:"outputResolution"` // 视频/图片 输出分辨率 -} -``` - -### 5.4 per_token 语义(工作流结算) - -结算时:`usage.TokensByModel` 逐模型 → 查该模型 subject 的 modelCfg(§4.2)→ token 计算器算费 → 累加;**未配价模型按 0 计**(不收费、不报错)。 - -> ⚠️ **设计决策**:per_token **费率不冻结在 rule_snapshot**(建单时未知会用哪些模型),结算时按模型 subject **实时价格**算。这是「模型价格单一来源」的代价,与 v0.2「改价不影响在途单」的 rule_snapshot 保证有冲突,仅 per_token 模式例外,其余模式仍走快照。 - -### 5.5 新增 per_period 计算器(周期订阅) - -- `validate`:`period ∈ 允许集合`(本期 `{year}`)+ `price > 0`。 -- `charge`:返回 `ceilFen(price)`(每次购买按整周期一次性收取,与用量无关;`usage.ItemCount` 预留多条数叠加)。 - ---- - -## 6. 接口设计 - -管理端: - -| 路由 | 方法 | 说明 | -|---|---|---| -| `/pricing/subjects` | GET | 枚举:workflow + 实时 model-gateway 系统模型 + 业务模块,每项附 hasConfig/enabled | -| `/pricing/config/save` | POST | `{subjectType, subjectId, rules, minBalance, currency, enabled}`,按 subject_type 校验 rules | -| `/pricing/config/get` | GET | 按 subjectType+subjectId | -| `/pricing/config/list` | GET | 分页,可按 subjectType/enabled 过滤 | - -内部结算(本期只调契约,模型结算未接): - -| 路由 | 方法 | 说明 | -|---|---|---| -| `/pricing/open_order` | POST | `{userId, subjectType, subjectId, chargeMode?, bizOrderNo}`;workflow 的 chargeMode 必填且须已配置;model 的 chargeMode 可空(默认取 unit);business 的 chargeMode 可空(默认 per_period) | -| `/pricing/settle` `/cancel` `/fail` `/order` | — | 契约字段同步换 subjectType/subjectId | - -**OpenOrder 校验**: -- workflow:`chargeMode ∈ rules 的 key`;`rule_snapshot = rules[chargeMode]` -- model:`charge_mode = config.unit`;`rule_snapshot = 整个 modelCfg(unit/tiered/rules)` -- business:`charge_mode = per_period`;`rule_snapshot = 周期规则 JSON` -- 门禁 `min_balance` 照旧;建单前校验规则 JSON;幂等返回既有单。 - ---- - -## 7. 枚举实现(subject_service.go,新) - -- `GET /pricing/subjects`:consul 解析 model-gateway 服务地址 → 调 `GET /listModelManage`(`system_model=true`)→ 合并固定 workflow 项 + 固定业务模块组(`consts/pricing`)。 -- 模型项返回 `modelId/modelName/modelType`(modelType 透传 model-gateway 枚举编码:100推理/200图片/300音频/600视频),管理端按 modelType 分组渲染对应编辑器。 -- 每项查 `pricing_config` 是否已存在(hasConfig / enabled)。 -- model-gateway 服务地址与鉴权放 `config.yml`(复用 `common/http` + consul 解析,同 market_service 调订单服务的模式)。 -- model-gateway 不可用时:枚举接口**直接报错**(管理端低频接口,失败暴露更清晰,可重试);workflow 项仍为固定返回。 - ---- - -## 8. 设计决策汇总 - -| 决策 | 理由 | -|---|---| -| 计价对象固定枚举 = 表结构(subject_type+subject_id) | 天然匹配「固定枚举」,workflow 多模式自描述 | -| 模型价格单一来源(工作流 per_token 用模型自己的价) | 避免两份价格来源不一致 | -| per_token 费率不冻结(结算按模型实时价) | 建单时未知模型集合,单一价格来源的代价 | -| 模型主体按 modelId 标识 | 稳定不变,枚举另返回 name 展示 | -| pricing_config 去 charge_mode 列 | rules 自描述,workflow 三模式都在 rules 里 | -| 未配价模型按 0 计 | 工作流可含未单独配价的模型,不阻塞结算 | -| min_balance 门禁照旧 | 非冻结门禁语义不变 | -| 业务模块固定写死(AI客服等) | 与「固定枚举」一致;新增业务模块改常量,暂不做管理端增删 | -| 周期计费用 per_period 通用建模 | 扩月/季只扩 period 允许集合,不改代码结构 | -| 阶梯=分档不累加 | 用户确认;匹配统一 rules 首条命中,tiered 仅约束 inputLength 分档校验 | -| match 维度全可选 + 首条命中 | 与 model-gateway 移植逻辑一致,规则即优先级 | -| 价格项收敛 input/output/cacheHit + unitPrice | 四类模型一套结构;音频输入差价用 `mediaPrices.audio` 表达(删 inputAudio/cacheHitAudio/cacheStorageHour 与 match.mediaType) | -| 输出分辨率自由字符串 | 视频/图片枚举值管理端 UI 下拉提供,DB 不预设 | - ---- - -## 9. 遗留(另期) - -- 模型 subject 结算接入:ai-agent 上报模型用量(PromptTokens/CompletionTokens/CachedTokens/CharCount/ImageCount/MediaType/Thinking/OutputAudio/OutputResolution)→ open_order/settle。 -- 业务模块订阅购买流程接入(按周期下单/扣费/续费)。 -- model-gateway 停扣(切换时机沿用 v0.2 §8.2)。 -- 工作流侧「选择本次扣费方式」的 UI/配置项(ai-agent 侧,本期不动)。 - ---- - -## 10. 改动清单 - -1. `consts/pricing/charge_mode.go`:加 `per_1K/per_1M/per_1/per_minute/per_hour/per_char/per_period`(含 period 允许集合 `{year}`) -2. `consts/pricing/subject.go`(新):subject_type 常量 workflow/model/business + 业务模块固定列表(AI客服 → `ai_customer_service`) -3. `model/entity/pricing/pricing_config.go`:biz_key/charge_mode → subject_type/subject_id -4. `model/dto/pricing/config_dto.go`:Save/Get/List/Info 换 subject 字段 -5. `model/dto/pricing/charge_dto.go`:OpenOrderReq 加 subjectType/subjectId/chargeMode;ChargeOrderInfo 的 bizKey → subjectType/subjectId;DAO 入参同步 -6. `dao/pricing/pricing_config_dao.go`:Get/Save/List 按 (subject_type, subject_id) -7. `dao/pricing/charge_order_dao.go`:幂等键改 (subject_type, subject_id, biz_order_no) -8. `service/pricing/charge_calc.go`:token 计算器(per_1K/per_1M)+ 单位计算器(per_1/per_second/per_minute/per_hour/per_char)+ per_period 计算器 + ChargeUsage 扩展(模型维度)+ per_token 语义改模型价 -9. `service/pricing/pricing_service.go`:OpenOrder 校验 chargeMode/unit/period;Save 按类型校验 rules;settleOrder 派发 -10. `service/pricing/subject_service.go`(新):枚举接口(workflow + 实时模型 + 固定业务模块) -11. `controller/pricing/pricing_controller.go`:加 Subjects -12. `init.sql`:pricing_config 改结构、charge_order 改键、迁移 SQL -13. `config.yml`:加 model-gateway 服务地址 -14. `go build ./...` 验证 \ No newline at end of file diff --git a/docs/superpowers/specs/2026-09-01-pricing-media-price-design.md b/docs/superpowers/specs/2026-09-01-pricing-media-price-design.md deleted file mode 100644 index ae04108..0000000 --- a/docs/superpowers/specs/2026-09-01-pricing-media-price-design.md +++ /dev/null @@ -1,244 +0,0 @@ -# 模型计费 mediaPrices(媒体类型价)与阶梯校验修复设计 - -> 版本:v0.4(本期)—— 修订 v0.3 `2026-08-28-pricing-enum-design.md` §4.2/§5.2。将模型规则"输入媒体"维度从 `match` 命中条件**改为规则级 `mediaPrices` 媒体价 map**,重写阶梯档位重叠校验,解决「有音频/无音频 × 档位」矩阵配置无法保存的问题与前端"媒体类型 4 选 1"体验问题。 -> 范围:shop-user-trade **pricing 计价模块**。只动模型计费结构(`charge_calc.go` + `consts`);workflow/business 计算器与 DB 结构不动。 -> 约定:金额一律**元**(float64,2 位小数),不足 1 分向上取整(`ceilFen`)——沿用 v0.3。 - ---- - -## 1. 背景与问题 - -管理端保存模型计费配置时,提交"输入含音频价 + 输入不含音频价 × 输入 token 档位"的矩阵数据被 `validateTieredBands` 拒绝,报错 **「阶梯档位区间重叠:规则间 InputLengthMin 须 > 前一档 InputLengthMax」**。 - -根因(两层): - -1. **设计缺陷**:`validateTieredBands`(charge_calc.go:306)对全部规则做全局两两比较,**不感知媒体维度**。不同媒体类型的同档位规则也被判为"重叠"→ 媒体 × 档位矩阵永远存不进去。 -2. **表达缺陷**:v0.3 用 `match.mediaType`(等值命中)表达"输入含某媒体",无法表达"**不含**某媒体"——`mediaType:"text"` 只匹配文本输入,图片/视频输入匹配不到;前端被迫做"文本/音频/视频/图片 4 选 1",选"非音频"时只能硬选"文本",语义是假的(用户确认)。 - -另:`validateTieredBands` 对 `InputLengthMin == 0` 的规则对直接 `continue`,导致**两个同起于 0 的档位重叠查不出**(潜伏 bug)。 - ---- - -## 2. 方案决策:mediaPrices map(规则级媒体价) - -用**规则级 `mediaPrices map[string]*modelPrice`** 表达"输入含某媒体"的价差,**默认 `price` 即"不含以下媒体"**: - -```json -{ - "name": "输入长度 [0,32k]", - "match": { "inputLengthMin": 0, "inputLengthMax": 32000 }, - "price": { "input": 0.3, "output": 1.8, "cacheHit": 0.12 }, - "mediaPrices": { "audio": { "input": 4.5, "output": 1.8, "cacheHit": 1.8 } } -} -``` - -- 语义:命中规则后,`usage.MediaType` 在 `mediaPrices` 里有 key → 用该媒体价;**没有 → 默认 `price`**。"不含音频"自动成立(文本/图片/视频都落默认价),**不需要负向字段、不需要假装"文本"**。 -- 多类型天然支持:`audio`/`video`/`image`/`text` 任意个 key,一个规则一套档位内可挂多套媒体价。 -- 顺序无关:map 按键查,无遮蔽、无首条命中顺序依赖。 -- `mediaPrices` 为空 = 纯单价格,与 v0.3 结构兼容。 - -**否决过的备选**: -- `mediaTypeNot` 负向字段:不必要,默认价即负向侧。 -- 单字段 `mediaPrice`(只支持一个媒体):不满足"多个媒体类型"。 -- 继续用 `match.mediaType` 等值匹配:无法表达"不含",且与前端体验冲突。 - ---- - -## 3. rules JSON 结构变更 - -### 3.1 modelRule(charge_calc.go) - -```go -type modelRule struct { - Name string `json:"name"` - Match *modelMatch `json:"match,omitempty"` // 命中条件(不含输入媒体维度) - Price *modelPrice `json:"price"` // 默认价:输入不含以下媒体 - MediaPrices map[string]*modelPrice `json:"mediaPrices,omitempty"` // 媒体类型→该媒体价(输入含该媒体时) -} -``` - -### 3.2 modelMatch(移除 MediaType) - -```go -type modelMatch struct { - Thinking *bool `json:"thinking,omitempty"` // 思考/非思考 - OutputAudio *bool `json:"outputAudio,omitempty"` // 输出有声/无声 - OutputResolution string `json:"outputResolution,omitempty"` // 输出分辨率 - InputLengthMin int64 `json:"inputLengthMin,omitempty"` // 输入token下界 - InputLengthMax int64 `json:"inputLengthMax,omitempty"` // 输入token上界 -} -``` - -- `modelRules`(unit/tiered/rules/currency)、`modelPrice`(input/output/cacheHit/unitPrice)不变。 -- `discount` 字段**废弃**:v0.3 spec 文本与预览页的 `discount:null` 占位一律移除(代码 `modelRules` 本无该字段)。 - ---- - -## 4. 语义与运行时 - -### 4.1 取价(pickModelPrice) - -```go -// pickModelPrice 命中规则后按输入媒体取价:mediaPrices 命中使用媒体价,否则默认 price。 -func pickModelPrice(rule *modelRule, usage *ChargeUsage) *modelPrice { - if len(rule.MediaPrices) > 0 { - if p, ok := rule.MediaPrices[usage.MediaType]; ok { - return p - } - } - return rule.Price -} -``` - -- `modelTokenCalculator.charge`:`p := pickModelPrice(rule, usage)` 后按 `p.Input/p.Output/p.CacheHit` 计费(原公式不变)。 -- `modelUnitCalculator.charge`:`p := pickModelPrice(rule, usage)` 后按 `p.UnitPrice` 计费(原公式不变)。 -- `modelRuleMatch`:**删除 `MediaType` 等值判定**(该行是本设计唯一删除的匹配逻辑)。 - -### 4.2 上报契约(媒体类型) - -`ChargeUsage.MediaType`(上报入参,字段保留)口径: - -- 推理模型:输入**含音频**报 `"audio"`,否则报 `"text"`。 -- 视频模型:输入**含视频**报 `"video"`,否则报 `"text"`。 -- 模型结算接入为另期;本设计只定契约,结算方按此上报。 - ---- - -## 5. 校验规则 - -### 5.1 阶梯档位(validateTieredBands 重写) - -按输入长度下界升序后**相邻不重叠**;无长度区间的规则(如 thinking 维度的兜底档)不参与重叠校验;上不封顶档(max=0)必须是最后一个长度档: - -```go -// validateTieredBands 阶梯分档校验:带长度区间的规则按下界升序后相邻不重叠。 -// 无长度区间规则不参与;上不封顶档(max=0)须为最后一个长度档。 -func validateTieredBands(rules []modelRule) error { - var bands []modelRule - for _, r := range rules { - if r.Match != nil && (r.Match.InputLengthMin > 0 || r.Match.InputLengthMax > 0) { - bands = append(bands, r) - } - } - sort.Slice(bands, func(i, j int) bool { - return bands[i].Match.InputLengthMin < bands[j].Match.InputLengthMin - }) - var prevMax int64 = -1 - openEnded := false - for _, r := range bands { - m := r.Match - if openEnded { - return errors.New("阶梯档位区间重叠:上不封顶档后不能再有档位") - } - if m.InputLengthMin <= prevMax { - return errors.New("阶梯档位区间重叠:规则间 InputLengthMin 须 > 前一档 InputLengthMax") - } - if m.InputLengthMax == 0 { - openEnded = true // 上不封顶,之后不可再有长度档 - } else { - prevMax = m.InputLengthMax - } - } - return nil -} -``` - -要点: -- **修复同起 0 重叠**:两个档位 min 均为 0 时,第二个 `0 <= prevMax` → 报错(旧代码因 `InputLengthMin==0 continue` 漏检)。 -- **顺序无关**:先排序再判,乱序提交也正确。 -- 档位**相邻**(下档 min = 上档 max+1)由前端自动生成;后端只强约束不重叠(允许间隙,间隙内长度无档命中 → 结算时报「无匹配计费规则」,由配置质量保证)。 -- 无长度规则(如 `thinking:false` 兜底档)不参与重叠校验,与长度档共存合法。 - -### 5.2 mediaPrices 校验 - -`modelTokenCalculator.validate` 与 `modelUnitCalculator.validate` 的规则循环内追加: - -```go -if len(r.Rules[i].MediaPrices) > 0 { - for mt, mp := range r.Rules[i].MediaPrices { - if _, ok := pricingConsts.ModelMediaSet[mt]; !ok { - return nil, errors.New("model 费率 mediaPrices 键须为 text/audio/video/image") - } - if mp == nil { - return nil, errors.New("model 费率 mediaPrices 值须配置 price") - } - } -} -``` - -`consts/pricing` 新增媒体类型集合(复用常量,前端下拉同源): - -```go -// ModelMediaSet 模型费率 mediaPrices 允许的媒体类型键 -var ModelMediaSet = map[string]struct{}{ - "text": {}, "audio": {}, "video": {}, "image": {}, -} -``` - ---- - -## 6. 前端交互(已定稿) - -模型计费规则编辑器(参考 `docs/superpowers/specs/pricing-config-preview.html` 已改版): - -- **每条规则**:`默认价 price`(输入不含以下媒体)+ `媒体类型价 mediaPrices` 列表(可增删;每项 = 媒体类型选择(音频/视频/图片/文本)+ 对应价格)。 -- 不再有「媒体类型 4 选 1」强制下拉;要几种媒体就加几张卡。 -- 档位区间前端**自动相邻**(下档 min = 上档 max+1),不再手填重叠值。 -- 提交 JSON 结构见 §2 示例。 - ---- - -## 7. 数据修正示例(用户提交的 6 条 → 3 条) - -```json -{ - "unit": "per_1M", "tiered": true, "currency": "CNY", - "rules": [ - { "name": "输入长度 [0,32k]", "match": { "inputLengthMin": 0, "inputLengthMax": 32000 }, - "price": { "input": 0.3, "output": 1.8, "cacheHit": 0.12 }, "mediaPrices": { "audio": { "input": 4.5, "output": 1.8, "cacheHit": 1.8 } } }, - { "name": "输入长度 (32k,128k]", "match": { "inputLengthMin": 32001, "inputLengthMax": 128000 }, - "price": { "input": 0.45, "output": 0.18, "cacheHit": 2.7 }, "mediaPrices": { "audio": { "input": 6.75, "output": 2.7, "cacheHit": 2.7 } } }, - { "name": "输入长度 (128k,256k]","match": { "inputLengthMin": 128001, "inputLengthMax": 256000 }, - "price": { "input": 0.9, "output": 5.4, "cacheHit": 0.36 }, "mediaPrices": { "audio": { "input": 13.5, "output": 5.4, "cacheHit": 5.4 } } } - ] -} -``` - -(原数据问题:R1 max=3200 应为 32000;R3/R5 档位边界 32000/128000 须 +1 相邻;无 mediaType 的文本规则遮蔽同名音频规则——新结构下不存在该问题。) - ---- - -## 8. 兼容与迁移 - -- **`match.mediaType` 移除**:旧配置中 `match.mediaType` 被 Go JSON 反序列化**静默忽略**(字段删除)。旧"音频差价"规则需重配为 `price`(默认)+ `mediaPrices.audio`。本期结算未接、无线上真实配置,直接切换即可。 -- **`discount` 废弃**:spec 文本与预览页占位删除(代码本无实现)。 -- workflow/business 计算器、`pricing_config` DB 结构、`ChargeMode` 枚举、金额单位均不动。 - ---- - -## 9. 改动清单 - -1. `consts/pricing/`(charge_mode.go 或新文件):加 `ModelMediaSet`(text/audio/video/image) -2. `service/pricing/charge_calc.go`: - - `modelMatch` 删 `MediaType` 字段;`modelRule` 加 `MediaPrices map[string]*modelPrice` - - `modelRuleMatch` 删 `MediaType` 等值判定 - - 新增 `pickModelPrice`;`modelTokenCalculator.charge` / `modelUnitCalculator.charge` 改用之 - - `validateTieredBands` 重写(§5.1) - - 两个 model validate 追加 `mediaPrices` 键/值校验(§5.2) -3. 测试(新 `service/pricing/charge_calc_test.go`):见 §10 -4. `docs/superpowers/specs/2026-08-28-pricing-enum-design.md`:§4.2 示例与 §8 决策表同步更新(指向本设计) -5. `docs/superpowers/specs/pricing-config-preview.html`:已按本设计改版(mockup) -6. `go build ./...` + `go test ./service/pricing/`(需 `GF_GCFG_PATH=C:/App/GolandProjects/shop-user-trade` 规避 common/consul init 环境问题) - -## 10. 测试要点 - -- **validateTieredBands**:相邻档合法;两档同起 0 重叠报错;乱序提交仍正确判重叠;上不封顶在最后合法、其后还有长度档报错;无长度规则(thinking 兜底)不干扰。 -- **pickModelPrice / charge**:含音频命中 `mediaPrices.audio`;不含音频走默认 `price`;多媒体类型各命中;`mediaPrices` 空走默认;命中规则后无 `mediaPrices` key 的媒体类型(如 video 输入配了 audio-only)走默认。 -- **validate**:`mediaPrices` 键非法报错;值 nil 报错;token/unit 两个计算器都覆盖。 - -## 11. 不改动 - -- `modelTokenCalculator` 计费公式、`modelUnitCalculator` 公式、`matchModelRule` 首条命中语义(非 tiered 配置仍按顺序优先) -- workflow(per_item/per_second/per_token)、business(per_period)计算器 -- `pricing_config` 表结构、`charge_order` 表结构、`ChargeUsage` 字段 -- `GET /pricing/subjects` 与 charge-modes 接口 diff --git a/docs/superpowers/specs/2026-09-01-subject-charge-modes-design.md b/docs/superpowers/specs/2026-09-01-subject-charge-modes-design.md deleted file mode 100644 index 9c1308c..0000000 --- a/docs/superpowers/specs/2026-09-01-subject-charge-modes-design.md +++ /dev/null @@ -1,95 +0,0 @@ -# 按 SubjectType 查询可选计费方式 设计 - -> 版本:v0.1(本期)—— 在 v0.3 计价对象枚举化基础上,提供前端「选择计费方式」所需的轻量查询接口。 -> 范围:shop-user-trade **pricing 计价模块**。交付「按 subjectType 查可选 chargeMode」接口 + 计费方式中文名常量。**不触碰 model 主体**。 -> 前置:依赖 v0.3 枚举设计 `2026-08-28-pricing-enum-design.md`(SubjectType / ChargeMode / BusinessSubjects 已在代码落地)。 - ---- - -## 1. 背景与目标 - -v0.3 把计价对象固定枚举为 `workflow / model / business`。管理端为工作流配置价格时,需要从三套计费方式(`per_item` / `per_second` / `per_token`)中**选择一种**;为业务模块配置时选择周期订阅(`per_period`)。当前没有任何接口告诉前端「某类型可选哪些计费方式」,且计费方式枚举**缺中文名**,前端无法渲染下拉。 - -**model 主体不需要此查询**:模型的计费单位(unit)由其模型类型自适应决定、在模型配置编辑器内随 rules 一起配置,不存在"选择计费方式"这一步(用户确认)。故接口只服务需要「选」的类型。 - -目标:一个 `GET /pricing/controller/subjects/charge-modes?subjectType=X&subjectId=Y` 接口,返回该具体主体的可选计费方式(mode + 中文名),**纯查常量、零外部依赖**。 - -## 2. 接口契约 - -``` -GET /pricing/controller/subjects/charge-modes?subjectType={workflow|business}&subjectId={workflow|业务模块标识} -``` - -| 参数 | 必填 | 校验 | -|---|---|---| -| `subjectType` | 是 | `in:workflow,business`;**model 非法**——模型计费方式无需选择,误传 400 | -| `subjectId` | 是 | workflow 时须 = `WorkflowSubjectID`(`workflow`);business 时须 ∈ `BusinessSubjects`,否则报「未知业务模块」 | - -**按具体主体查询**:返回该 `(subjectType, subjectId)` 主体自身可选计费方式(与 `pricing_config` 的 `(subject_type, subject_id)` 唯一键同构)。 - -响应(前端下拉直接渲染 `name`,`mode` 作提交值): - -```json -{ - "subjectType": "workflow", - "subjectId": "workflow", - "chargeModes": [ - { "mode": "per_item", "name": "按条" }, - { "mode": "per_second", "name": "按秒" }, - { "mode": "per_token", "name": "按token" } - ] -} -``` - -`subjectType=business, subjectId=ai_customer_service` 时 `chargeModes` = 该业务模块自身的 `BusinessSubject.ChargeModes`(现为 `[per_period]`)。 - -## 3. 数据来源(纯常量,零外部依赖) - -| subjectType | subjectId | chargeModes | 来源 | -|---|---|---|---| -| `workflow` | `workflow`(须=WorkflowSubjectID) | `[per_item, per_second, per_token]` | `consts/pricing/subject.go` 新增 `WorkflowChargeModes` | -| `business` | 业务模块标识(须∈BusinessSubjects) | 该主体自身 `ChargeModes` | `consts/pricing/subject.go` 既有 `BusinessSubject.ChargeModes` | - -- 不调 model-gateway、不查 `pricing_config`,**纯查常量**。 -- model 由校验层拦截(`v:"required|in:workflow,business"`);`subjectId` 必填且按类型校验存在性。 - -## 4. 计费方式中文名 - -`consts/pricing/charge_mode.go` 新增 `ChargeModeNames map[ChargeMode]string`,覆盖全部 10 个枚举(单一来源,前端直接渲染): - -| mode | name | -|---|---| -| `per_item` | 按条 | -| `per_second` | 按秒 | -| `per_token` | 按token | -| `per_1K` | 每千token | -| `per_1M` | 每百万token | -| `per_1` | 每张/每个 | -| `per_minute` | 每分钟 | -| `per_hour` | 每小时 | -| `per_char` | 每字 | -| `per_period` | 周期订阅 | - -## 5. 改动清单 - -1. `consts/pricing/charge_mode.go`:加 `ChargeModeNames` map(§4 全表) -2. `consts/pricing/subject.go`:加 `WorkflowChargeModes = []ChargeMode{per_item, per_second, per_token}`(与 `charge_calc.go` 的 `workflowCalculators` 三键一致) -3. `model/dto/pricing/subject_dto.go`:`ChargeModeListReq`(`path:"/subjects/charge-modes" method:"get"`,`subjectType v:"required|in:workflow,business"` + `subjectId v:"required"`)+ `ChargeModeListRes{SubjectType string, SubjectID string, ChargeModes []ChargeModeInfo}` + `ChargeModeInfo{Mode string, Name string}` -4. `service/pricing/subject_service.go`:`ListChargeModes(ctx, req)` — workflow:`subjectId` 须=WorkflowSubjectID,返回 `WorkflowChargeModes`;business:`subjectId` 查 `BusinessSubjects`(缺失报「未知业务模块」),返回该主体 `ChargeModes` -5. `controller/pricing/pricing_controller.go`:注册 `ChargeModes` 方法(复用既有 Subjects 的 Controller 模式) -6. `go build ./...` 验证 - -## 6. 设计决策汇总 - -| 决策 | 理由 | -|---|---| -| 只服务 workflow/business,model 不入参 | 模型计费方式由类型/配置决定,前端无需选择(用户确认) | -| 按 `subjectType + subjectId` 查具体主体 | 与 `pricing_config` 的 `(subject_type, subject_id)` 唯一键同构;business 各主体可不同计费方式,按主体精确返回 | -| `subjectId` 必填并按类型校验存在性 | workflow 须=`WorkflowSubjectID`;business 须∈`BusinessSubjects`,误传即报错,前端不可能拿到空下拉 | -| 不调 model-gateway、不查 `pricing_config` | 纯查常量,接口轻量稳定,与 `/pricing/subjects` 解耦 | -| 中文名放 consts map(`ChargeModeNames`) | 前端直接渲染,单一来源 | -| workflow 模式用 `WorkflowChargeModes` 常量 | 与计算器注册表三键一致,不耦合 `charge_calc` 内部 | - -## 7. 不改动 - -- `/pricing/subjects` 枚举响应、`pricing_config` 的 save 校验(unit∈ModelUnitSet 等)、计算器注册表、DB 结构均不变。 diff --git a/docs/superpowers/specs/pricing-config-preview.html b/docs/superpowers/specs/pricing-config-preview.html deleted file mode 100644 index 9bfc1c0..0000000 --- a/docs/superpowers/specs/pricing-config-preview.html +++ /dev/null @@ -1,561 +0,0 @@ - - - - - -计价配置管理 · 原型预览 - - - - -
-

计价配置管理 /pricing · 原型预览

- 原型预览 · 静态无后端 -
- -
- - - - - -
-
-
-
工作流
-
subjectType=workflow · subjectId=workflow
-
-
- - -
-
- - - - - - - - - - - - -
-
- -
- - - - \ No newline at end of file diff --git a/go.mod b/go.mod index e5f8521..b0ed504 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,17 @@ module shop-user-trade go 1.26.1 require ( - gitea.redpowerfuture.com/red-future/common v0.0.29 + gitea.redpowerfuture.com/red-future/common v0.0.33 github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2 github.com/gogf/gf/v2 v2.10.2 + github.com/google/uuid v1.6.0 +) + +require ( + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect ) require ( @@ -28,7 +36,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-json v0.10.6 // indirect - github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.2 // indirect + github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.2 github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5 // indirect github.com/gogf/gf/contrib/trace/otlphttp/v2 v2.9.5 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -39,7 +47,6 @@ require ( github.com/golang/snappy v1.0.0 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/flatbuffers v25.12.19+incompatible // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/grokify/html-strip-tags-go v0.1.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect diff --git a/go.sum b/go.sum index 80cd1d1..43ba567 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,10 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= gitea.redpowerfuture.com/red-future/common v0.0.23 h1:xieoA00iKOCDm5SO9iXn+cSyMKBAlZwI0fuEVPWrHLg= gitea.redpowerfuture.com/red-future/common v0.0.23/go.mod h1:50U1Xi+Ie56z09S5LQbZvaken0Mxv3OeS9LgR7U/ZRY= +gitea.redpowerfuture.com/red-future/common v0.0.29 h1:5McaN5pSewvrLUHQzWMX6EaUvD+B5I5bMYoU+clHJk4= gitea.redpowerfuture.com/red-future/common v0.0.29/go.mod h1:50U1Xi+Ie56z09S5LQbZvaken0Mxv3OeS9LgR7U/ZRY= +gitea.redpowerfuture.com/red-future/common v0.0.33 h1:AhWJ6l9zrjc1U0UEfyIZu8wkkVFxNe0hfuA51vOnOIo= +gitea.redpowerfuture.com/red-future/common v0.0.33/go.mod h1:FtI9KJJSKo4/K0emjVkbL8yoSIPHJdZXr27vnScQpmM= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= @@ -296,6 +299,16 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tiger1103/gfast-token v1.0.10 h1:fNiBE/Dq5iTHvTGlCx3DmXa2o4hr0NtumFpffZ39k6s= github.com/tiger1103/gfast-token v1.0.10/go.mod h1:a/21mxmj7zFeNvjhZSC0XpEAFHfb1aT2k6DXnufFU1s= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= diff --git a/init.sql b/init.sql index 281e557..c9fdc88 100644 --- a/init.sql +++ b/init.sql @@ -244,8 +244,7 @@ CREATE TABLE IF NOT EXISTS wallet_account ( user_name VARCHAR(64) NOT NULL DEFAULT '', balance NUMERIC(15,2) NOT NULL DEFAULT 0, currency VARCHAR(16) NOT NULL DEFAULT 'CNY', - status SMALLINT NOT NULL DEFAULT 1, - version BIGINT NOT NULL DEFAULT 1 + status SMALLINT NOT NULL DEFAULT 1 ); -- 索引 @@ -267,7 +266,6 @@ COMMENT ON COLUMN wallet_account.user_name IS '用户名'; COMMENT ON COLUMN wallet_account.balance IS '可用余额(元,保留2位小数),可为负=欠费'; COMMENT ON COLUMN wallet_account.currency IS '货币类型:CNY-人民币'; COMMENT ON COLUMN wallet_account.status IS '状态:1启用/0禁用/-1冻结'; -COMMENT ON COLUMN wallet_account.version IS '乐观锁版本号'; --------------------pgsql创建wallet_account表语句--------------------------- @@ -327,14 +325,14 @@ COMMENT ON COLUMN wallet_account_log.extra_data IS '额外数据(JSONB)'; --------------------pgsql创建wallet_account_log表语句--------------------------- ---------------------pgsql创建pricing_config表语句--------------------------- +--------------------pgsql创建wallet_pricing_config表语句--------------------------- -- 计价配置表(费率全 DB 配置,改价/加档只改 rules,代码不写死金额) -CREATE TABLE IF NOT EXISTS pricing_config ( +CREATE TABLE IF NOT EXISTS wallet_pricing_config ( -- 基础字段(继承 SQLBaseCol 通用字段,与 SQLBaseDO 对齐) - id BIGINT PRIMARY KEY, - tenant_id BIGINT NOT NULL DEFAULT 0, - creator VARCHAR(64) NOT NULL, + id BIGINT PRIMARY KEY, + tenant_id BIGINT NOT NULL DEFAULT 0, + creator VARCHAR(64) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updater VARCHAR(64) NOT NULL, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -343,40 +341,38 @@ CREATE TABLE IF NOT EXISTS pricing_config ( -- 计价配置核心字段 subject_type VARCHAR(16) NOT NULL, -- workflow | model | business subject_id VARCHAR(64) NOT NULL, -- workflow | 模型ID | 业务模块标识 - rules TEXT NOT NULL, -- 费率JSON(见设计§4,rules 自描述) + rules JSONB NOT NULL DEFAULT '{}', -- 费率JSON对象(见设计§4,rules 自描述) min_balance NUMERIC(15,2) NOT NULL DEFAULT 0, currency VARCHAR(16) NOT NULL DEFAULT 'CNY', - enabled SMALLINT NOT NULL DEFAULT 1, - version BIGINT NOT NULL DEFAULT 1 -); + enabled SMALLINT NOT NULL DEFAULT 1 + ); -- 索引 -CREATE UNIQUE INDEX IF NOT EXISTS uk_pricing_config_subject ON pricing_config(subject_type, subject_id); -CREATE INDEX IF NOT EXISTS idx_pricing_config_enabled ON pricing_config(enabled); +CREATE UNIQUE INDEX IF NOT EXISTS uk_pricing_config_subject ON wallet_pricing_config(subject_type, subject_id); +CREATE INDEX IF NOT EXISTS idx_pricing_config_enabled ON wallet_pricing_config(enabled); -- 表和字段注释 -COMMENT ON TABLE pricing_config IS '计价配置表(计价对象固定枚举,费率全 DB 配置)'; -COMMENT ON COLUMN pricing_config.id IS '主键ID(非自增)'; -COMMENT ON COLUMN pricing_config.tenant_id IS '租户ID'; -COMMENT ON COLUMN pricing_config.creator IS '创建人'; -COMMENT ON COLUMN pricing_config.created_at IS '创建时间'; -COMMENT ON COLUMN pricing_config.updater IS '更新人'; -COMMENT ON COLUMN pricing_config.updated_at IS '更新时间'; -COMMENT ON COLUMN pricing_config.deleted_at IS '删除时间(软删)'; -COMMENT ON COLUMN pricing_config.subject_type IS '计价对象类型:workflow/model/business'; -COMMENT ON COLUMN pricing_config.subject_id IS '计价对象ID(workflow/模型ID/业务模块标识)'; -COMMENT ON COLUMN pricing_config.rules IS '费率JSON(workflow三模式容器/model自适应/business周期)'; -COMMENT ON COLUMN pricing_config.min_balance IS '门禁:调用前可用余额须>=该值(元),0=不校验'; -COMMENT ON COLUMN pricing_config.currency IS '货币类型'; -COMMENT ON COLUMN pricing_config.enabled IS '1启用 0停用'; -COMMENT ON COLUMN pricing_config.version IS '乐观锁版本号'; +COMMENT ON TABLE wallet_pricing_config IS '计价配置表(计价对象固定枚举,费率全 DB 配置)'; +COMMENT ON COLUMN wallet_pricing_config.id IS '主键ID(非自增)'; +COMMENT ON COLUMN wallet_pricing_config.tenant_id IS '租户ID'; +COMMENT ON COLUMN wallet_pricing_config.creator IS '创建人'; +COMMENT ON COLUMN wallet_pricing_config.created_at IS '创建时间'; +COMMENT ON COLUMN wallet_pricing_config.updater IS '更新人'; +COMMENT ON COLUMN wallet_pricing_config.updated_at IS '更新时间'; +COMMENT ON COLUMN wallet_pricing_config.deleted_at IS '删除时间(软删)'; +COMMENT ON COLUMN wallet_pricing_config.subject_type IS '计价对象类型:workflow/model/business'; +COMMENT ON COLUMN wallet_pricing_config.subject_id IS '计价对象ID(workflow/模型ID/业务模块标识)'; +COMMENT ON COLUMN wallet_pricing_config.rules IS '费率JSON(workflow三模式容器/model自适应/business周期)'; +COMMENT ON COLUMN wallet_pricing_config.min_balance IS '门禁:调用前可用余额须>=该值(元),0=不校验'; +COMMENT ON COLUMN wallet_pricing_config.currency IS '货币类型'; +COMMENT ON COLUMN wallet_pricing_config.enabled IS '1启用 0停用'; ---------------------pgsql创建pricing_config表语句--------------------------- +--------------------pgsql创建wallet_pricing_config表语句--------------------------- ---------------------pgsql创建charge_order表语句--------------------------- +--------------------pgsql创建wallet_charge_order表语句--------------------------- -- 计费单表(一次计价调用 = 一张计费单,幂等键 subject_type + subject_id + biz_order_no) -CREATE TABLE IF NOT EXISTS charge_order ( +CREATE TABLE IF NOT EXISTS wallet_charge_order ( -- 基础字段(继承 SQLBaseCol 通用字段,与 SQLBaseDO 对齐) id BIGINT PRIMARY KEY, tenant_id BIGINT NOT NULL DEFAULT 0, @@ -394,34 +390,34 @@ CREATE TABLE IF NOT EXISTS charge_order ( charge_mode VARCHAR(32) NOT NULL, status SMALLINT NOT NULL DEFAULT 1, actual_amount NUMERIC(15,2) NOT NULL DEFAULT 0, - usage TEXT NOT NULL DEFAULT '', - rule_snapshot TEXT NOT NULL DEFAULT '', + usage JSONB NOT NULL DEFAULT '{}', + rule_snapshot JSONB NOT NULL DEFAULT '{}', settle_time TIMESTAMP ); -- 索引 -CREATE UNIQUE INDEX IF NOT EXISTS uk_charge_order_subject ON charge_order(subject_type, subject_id, biz_order_no); -CREATE INDEX IF NOT EXISTS idx_charge_order_user_id ON charge_order(user_id); -CREATE INDEX IF NOT EXISTS idx_charge_order_status ON charge_order(status); +CREATE UNIQUE INDEX IF NOT EXISTS uk_charge_order_subject ON wallet_charge_order(subject_type, subject_id, biz_order_no); +CREATE INDEX IF NOT EXISTS idx_charge_order_user_id ON wallet_charge_order(user_id); +CREATE INDEX IF NOT EXISTS idx_charge_order_status ON wallet_charge_order(status); -- 表和字段注释 -COMMENT ON TABLE charge_order IS '计费单表(一次计价调用 = 一张计费单)'; -COMMENT ON COLUMN charge_order.id IS '主键ID(非自增)'; -COMMENT ON COLUMN charge_order.tenant_id IS '租户ID'; -COMMENT ON COLUMN charge_order.creator IS '创建人'; -COMMENT ON COLUMN charge_order.created_at IS '创建时间'; -COMMENT ON COLUMN charge_order.updater IS '更新人'; -COMMENT ON COLUMN charge_order.updated_at IS '更新时间'; -COMMENT ON COLUMN charge_order.deleted_at IS '删除时间(软删)'; -COMMENT ON COLUMN charge_order.subject_type IS '计价对象类型'; -COMMENT ON COLUMN charge_order.subject_id IS '计价对象ID'; -COMMENT ON COLUMN charge_order.biz_order_no IS '业务单据号(幂等键,如工作流 execId)'; -COMMENT ON COLUMN charge_order.user_id IS '扣费用户ID'; -COMMENT ON COLUMN charge_order.charge_mode IS '计费方式(建单快照)'; -COMMENT ON COLUMN charge_order.status IS '状态:1已建单 2已结算 3已失败'; -COMMENT ON COLUMN charge_order.actual_amount IS '实收金额(元,保留2位小数)'; -COMMENT ON COLUMN charge_order.usage IS '实际用量JSON'; -COMMENT ON COLUMN charge_order.rule_snapshot IS '结算用费率快照JSON(防改价影响在途单)'; -COMMENT ON COLUMN charge_order.settle_time IS '结算时间'; +COMMENT ON TABLE wallet_charge_order IS '计费单表(一次计价调用 = 一张计费单)'; +COMMENT ON COLUMN wallet_charge_order.id IS '主键ID(非自增)'; +COMMENT ON COLUMN wallet_charge_order.tenant_id IS '租户ID'; +COMMENT ON COLUMN wallet_charge_order.creator IS '创建人'; +COMMENT ON COLUMN wallet_charge_order.created_at IS '创建时间'; +COMMENT ON COLUMN wallet_charge_order.updater IS '更新人'; +COMMENT ON COLUMN wallet_charge_order.updated_at IS '更新时间'; +COMMENT ON COLUMN wallet_charge_order.deleted_at IS '删除时间(软删)'; +COMMENT ON COLUMN wallet_charge_order.subject_type IS '计价对象类型'; +COMMENT ON COLUMN wallet_charge_order.subject_id IS '计价对象ID'; +COMMENT ON COLUMN wallet_charge_order.biz_order_no IS '业务单据号(幂等键,如工作流 execId)'; +COMMENT ON COLUMN wallet_charge_order.user_id IS '扣费用户ID'; +COMMENT ON COLUMN wallet_charge_order.charge_mode IS '计费方式(建单快照)'; +COMMENT ON COLUMN wallet_charge_order.status IS '状态:1已建单 2已结算 3已失败'; +COMMENT ON COLUMN wallet_charge_order.actual_amount IS '实收金额(元,保留2位小数)'; +COMMENT ON COLUMN wallet_charge_order.usage IS '实际用量JSON'; +COMMENT ON COLUMN wallet_charge_order.rule_snapshot IS '结算用费率快照JSON(防改价影响在途单)'; +COMMENT ON COLUMN wallet_charge_order.settle_time IS '结算时间'; ---------------------pgsql创建charge_order表语句--------------------------- \ No newline at end of file +--------------------pgsql创建wallet_charge_order表语句--------------------------- \ No newline at end of file diff --git a/main.go b/main.go index 6773171..10c9ffc 100644 --- a/main.go +++ b/main.go @@ -5,12 +5,14 @@ import ( knapsackController "shop-user-trade/controller/knapsack" marketController "shop-user-trade/controller/market" + pricingController "shop-user-trade/controller/pricing" walletController "shop-user-trade/controller/wallet" "gitea.redpowerfuture.com/red-future/common/http" "gitea.redpowerfuture.com/red-future/common/jaeger" _ "gitea.redpowerfuture.com/red-future/common/swagger" _ "github.com/gogf/gf/contrib/drivers/pgsql/v2" + _ "github.com/gogf/gf/contrib/nosql/redis/v2" // common utils.WithLock 需要 go-redis adapter(WATCH/MULTI 归属释放) ) func main() { @@ -20,7 +22,8 @@ func main() { http.RouteRegister([]interface{}{ knapsackController.Knapsack, marketController.Market, - walletController.Wallet, + walletController.Account, + pricingController.Pricing, }) select {} } diff --git a/model/dto/pricing/charge_dto.go b/model/dto/pricing/charge_dto.go index 7d5e01a..4b412d6 100644 --- a/model/dto/pricing/charge_dto.go +++ b/model/dto/pricing/charge_dto.go @@ -26,11 +26,11 @@ type OpenOrderReq struct { type SettleReq struct { g.Meta `path:"/settle" method:"post" tags:"计价扣费" summary:"结算" dc:"按实际用量计价实收(允许负余额),幂等"` - OrderId int64 `json:"orderId" dc:"计费单ID(与 subjectType+subjectId+bizOrderNo 二选一)"` - SubjectType string `json:"subjectType" dc:"计价对象类型(orderId 为空时必填)"` - SubjectID string `json:"subjectId" dc:"计价对象ID(orderId 为空时必填)"` - BizOrderNo string `json:"bizOrderNo" dc:"业务单据号(orderId 为空时必填)"` - Usage string `json:"usage" v:"required" dc:"用量JSON:{durationSec,itemCount,tokensByModel,...}"` + OrderId int64 `json:"orderId" dc:"计费单ID(与 subjectType+subjectId+bizOrderNo 二选一)"` + SubjectType string `json:"subjectType" dc:"计价对象类型(orderId 为空时必填)"` + SubjectID string `json:"subjectId" dc:"计价对象ID(orderId 为空时必填)"` + BizOrderNo string `json:"bizOrderNo" dc:"业务单据号(orderId 为空时必填)"` + Usage map[string]any `json:"usage" v:"required" dc:"用量JSON对象(前端传对象)。工作流口径:{durationSec,feeByModel}——feeByModel 各模型=按次调 /calc 已记录费用(per_token 结算/时长取消补收据此合计实收);durationSec=生成视频总时长(per_item/per_second)"` } // ====================== 取消(按已消耗用量结算) ====================== @@ -39,11 +39,11 @@ type SettleReq struct { type CancelReq struct { g.Meta `path:"/cancel" method:"post" tags:"计价扣费" summary:"取消" dc:"中途取消按已消耗用量计价实收(非退款),幂等"` - OrderId int64 `json:"orderId" dc:"计费单ID(与 subjectType+subjectId+bizOrderNo 二选一)"` - SubjectType string `json:"subjectType" dc:"计价对象类型(orderId 为空时必填)"` - SubjectID string `json:"subjectId" dc:"计价对象ID(orderId 为空时必填)"` - BizOrderNo string `json:"bizOrderNo" dc:"业务单据号(orderId 为空时必填)"` - Usage string `json:"usage" v:"required" dc:"已消耗用量JSON"` + OrderId int64 `json:"orderId" dc:"计费单ID(与 subjectType+subjectId+bizOrderNo 二选一)"` + SubjectType string `json:"subjectType" dc:"计价对象类型(orderId 为空时必填)"` + SubjectID string `json:"subjectId" dc:"计价对象ID(orderId 为空时必填)"` + BizOrderNo string `json:"bizOrderNo" dc:"业务单据号(orderId 为空时必填)"` + Usage map[string]any `json:"usage" v:"required" dc:"已消耗用量JSON对象(前端传对象)"` } // ====================== 失败(不扣费) ====================== @@ -61,45 +61,47 @@ type FailReq struct { // ====================== 查询 ====================== -// GetOrderReq 查询计费单请求 -type GetOrderReq struct { - g.Meta `path:"/get_order" method:"get" tags:"计价扣费" summary:"查询计费单" dc:"按ID或 subjectType+subjectId+bizOrderNo 查询"` - - OrderId int64 `json:"orderId" dc:"计费单ID(与 subjectType+subjectId+bizOrderNo 二选一)"` - SubjectType string `json:"subjectType" dc:"计价对象类型(orderId 为空时必填)"` - SubjectID string `json:"subjectId" dc:"计价对象ID(orderId 为空时必填)"` - BizOrderNo string `json:"bizOrderNo" dc:"业务单据号(orderId 为空时必填)"` -} - -// ListOrdersReq 分页查询计费单请求 -type ListOrdersReq struct { - g.Meta `path:"/orders" method:"get" tags:"计价扣费" summary:"分页查询计费单" dc:"按用户分页查询计费单"` - - UserId int64 `json:"userId" v:"required|min:1" dc:"用户ID"` - Page int `json:"page" v:"required|min:1" dc:"页码"` - PageSize int `json:"pageSize" v:"required|min:1|max:100" dc:"每页大小"` -} - -// ChargeOrderList 计费单列表 -type ChargeOrderList struct { - Orders []ChargeOrderInfo `json:"orders"` - Total int `json:"total"` -} - // ChargeOrderInfo 计费单信息 type ChargeOrderInfo struct { - ID int64 `json:"id"` - SubjectType string `json:"subjectType"` - SubjectID string `json:"subjectId"` - BizOrderNo string `json:"bizOrderNo"` - UserId int64 `json:"userId"` - ChargeMode string `json:"chargeMode"` - Status int `json:"status"` // 1已建单 2已结算 3已失败 - ActualAmount float64 `json:"actualAmount"` - Usage string `json:"usage"` - RuleSnapshot string `json:"ruleSnapshot"` - SettleTime string `json:"settleTime"` - CreatedAt string `json:"createdAt"` + ID int64 `json:"id"` + SubjectType string `json:"subjectType"` + SubjectID string `json:"subjectId"` + BizOrderNo string `json:"bizOrderNo"` + UserId int64 `json:"userId"` + ChargeMode string `json:"chargeMode"` + Status int `json:"status"` // 1已建单 2已结算 3已失败 + ActualAmount float64 `json:"actualAmount"` + Usage map[string]any `json:"usage"` + RuleSnapshot map[string]any `json:"ruleSnapshot"` + SettleTime string `json:"settleTime"` + CreatedAt string `json:"createdAt"` +} + +// GetOrderInfoReq 查询计费单(工作流结算前取 CreatedAt 算时长) +type GetOrderInfoReq struct { + g.Meta `path:"/order" method:"get" tags:"计价扣费" summary:"查询计费单" dc:"按ID或subjectType+subjectId+bizOrderNo查询计费单(工作流结算前取CreatedAt算时长)"` + + Id int64 `json:"id" dc:"计费单ID(与 subjectType+subjectId+bizOrderNo 二选一)"` + SubjectType string `json:"subjectType" dc:"计价对象类型"` + SubjectID string `json:"subjectId" dc:"计价对象ID"` + BizOrderNo string `json:"bizOrderNo" dc:"业务单据号"` +} + +// ====================== 算费(不建单不扣费,model-gateway 调用) ====================== + +// CalcFeeReq 按用量计算费用(不建单不扣费;未配置/未启用计价即报错) +type CalcFeeReq struct { + g.Meta `path:"/calc" method:"post" tags:"计价扣费" summary:"按用量计算费用" dc:"按计价配置与用量计算费用(不建单不扣费;model-gateway 调用)"` + + SubjectType string `json:"subjectType" v:"required" dc:"计价对象类型"` + SubjectID string `json:"subjectId" v:"required" dc:"计价对象ID"` + ChargeMode string `json:"chargeMode" dc:"计费方式(model 可空=配置 unit)"` + Usage map[string]any `json:"usage" v:"required" dc:"用量JSON对象(ChargeUsage 形状:promptTokens/completionTokens/cachedTokens/mediaType/durationSec 等)"` +} + +// CalcFeeRes 算费结果 +type CalcFeeRes struct { + Cost float64 `json:"cost" dc:"费用(元,不足1分向上取整)"` } // ====================== DAO 入参(统一吃 DTO,同 model-gateway) ====================== @@ -114,22 +116,22 @@ type GetChargeOrderReq struct { // CreateChargeOrderReq DAO 创建计费单 type CreateChargeOrderReq struct { - SubjectType pricingConsts.SubjectType `json:"subjectType"` - SubjectID string `json:"subjectId"` - BizOrderNo string `json:"bizOrderNo"` - UserId int64 `json:"userId"` - ChargeMode pricingConsts.ChargeMode `json:"chargeMode"` - Status pricingConsts.ChargeOrderStatus `json:"status"` - RuleSnapshot string `json:"ruleSnapshot"` + SubjectType pricingConsts.SubjectType `json:"subjectType"` + SubjectID string `json:"subjectId"` + BizOrderNo string `json:"bizOrderNo"` + UserId int64 `json:"userId"` + ChargeMode pricingConsts.ChargeMode `json:"chargeMode"` + Status pricingConsts.ChargeOrderStatus `json:"status"` + RuleSnapshot map[string]any `json:"ruleSnapshot"` } // SettleChargeOrderReq DAO 结算迁移(CREATED→SETTLED,条件更新幂等) type SettleChargeOrderReq struct { - Id int64 `json:"id"` - ActualAmount float64 `json:"actualAmount"` - Usage string `json:"usage"` - RuleSnapshot string `json:"ruleSnapshot"` - SettleTime *gtime.Time `json:"settleTime"` + Id int64 `json:"id"` + ActualAmount float64 `json:"actualAmount"` + Usage map[string]any `json:"usage"` + RuleSnapshot map[string]any `json:"ruleSnapshot"` + SettleTime *gtime.Time `json:"settleTime"` } // FailChargeOrderReq DAO 失败迁移(CREATED→FAILED,条件更新幂等) diff --git a/model/dto/pricing/config_dto.go b/model/dto/pricing/config_dto.go index 72dce38..c0ba579 100644 --- a/model/dto/pricing/config_dto.go +++ b/model/dto/pricing/config_dto.go @@ -6,58 +6,41 @@ import ( // ====================== 配置管理 ====================== -// SaveConfigReq 新增/更新计价配置请求(费率全 DB 配置,改价/加档只改 rules) -type SaveConfigReq struct { +// SavePricingConfigReq 新增/更新计价配置请求(费率全 DB 配置,改价/加档只改 rules) +type SavePricingConfigReq struct { g.Meta `path:"/config/save" method:"post" tags:"计价配置" summary:"保存计价配置" dc:"新增或更新计价配置(subject_type+subject_id 唯一)"` - Id int64 `json:"id" dc:"配置ID(>0 更新,0 新增)"` - SubjectType string `json:"subjectType" v:"required|in:workflow,model,business" dc:"计价对象类型"` - SubjectID string `json:"subjectId" v:"required" dc:"计价对象ID"` - Rules string `json:"rules" v:"required" dc:"费率JSON(见设计§4)"` - MinBalance float64 `json:"minBalance" dc:"门禁:调用前可用余额须>=该值(元),0=不校验"` - Currency string `json:"currency" dc:"货币类型,默认CNY"` - Enabled int `json:"enabled" dc:"1启用 0停用"` + Id int64 `json:"id" dc:"配置ID(>0 更新,0 新增)"` + SubjectType string `json:"subjectType" v:"required|in:workflow,model,business" dc:"计价对象类型"` + SubjectID string `json:"subjectId" v:"required" dc:"计价对象ID"` + Rules map[string]any `json:"rules" v:"required" dc:"费率JSON对象(见设计§4;前端传对象)"` + MinBalance *float64 `json:"minBalance" dc:"门禁:调用前可用余额须>=该值(元),0=不校验"` + Currency string `json:"currency" dc:"货币类型,默认CNY"` + Enabled *int `json:"enabled" dc:"1启用 0停用"` } -// SaveConfigData 保存计价配置数据 -type SaveConfigData struct { +// SavePricingConfigRes 保存计价配置数据 +type SavePricingConfigRes struct { Id int64 `json:"id"` } -// GetConfigReq 查询计价配置请求 -type GetConfigReq struct { +// GetPricingConfigReq 查询计价配置请求 +type GetPricingConfigReq struct { g.Meta `path:"/config/get" method:"get" tags:"计价配置" summary:"查询计价配置" dc:"按 subjectType+subjectId 查询计价配置"` SubjectType string `json:"subjectType" v:"required" dc:"计价对象类型"` SubjectID string `json:"subjectId" v:"required" dc:"计价对象ID"` } -// ListConfigsReq 分页查询计价配置请求 -type ListConfigsReq struct { - g.Meta `path:"/config/list" method:"get" tags:"计价配置" summary:"分页查询计价配置" dc:"可按 subjectType/enabled 过滤"` - - SubjectType string `json:"subjectType" dc:"计价对象类型"` - Enabled int `json:"enabled" dc:"1启用 0停用,0不过滤"` - Page int `json:"page" v:"required|min:1" dc:"页码"` - PageSize int `json:"pageSize" v:"required|min:1|max:100" dc:"每页大小"` -} - -// PricingConfigList 计价配置列表 -type PricingConfigList struct { - Configs []PricingConfigInfo `json:"configs"` - Total int `json:"total"` -} - -// PricingConfigInfo 计价配置信息 -type PricingConfigInfo struct { - ID int64 `json:"id"` - SubjectType string `json:"subjectType"` - SubjectID string `json:"subjectId"` - Rules string `json:"rules"` - MinBalance float64 `json:"minBalance"` - Currency string `json:"currency"` - Enabled int `json:"enabled"` - Version int64 `json:"version"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` +// GetPricingConfigRes 计价配置信息 +type GetPricingConfigRes struct { + ID int64 `json:"id,string"` + SubjectType string `json:"subjectType" dc:"计价对象类型"` + SubjectID string `json:"subjectId" dc:"计价对象ID"` + Rules map[string]any `json:"rules" dc:"费率JSON对象(见设计§4)"` + MinBalance float64 `json:"minBalance" dc:"门禁:调用前可用余额须>=该值(元),0=不校验"` + Currency string `json:"currency" dc:"货币类型,默认CNY"` + Enabled int `json:"enabled" dc:"1启用 0停用"` + CreatedAt string `json:"createdAt" dc:"创建时间"` + UpdatedAt string `json:"updatedAt" dc:"更新时间"` } diff --git a/model/dto/pricing/subject_dto.go b/model/dto/pricing/subject_dto.go index 33327f9..bca53fd 100644 --- a/model/dto/pricing/subject_dto.go +++ b/model/dto/pricing/subject_dto.go @@ -16,11 +16,12 @@ type SubjectListRes struct { // SubjectInfo 计价对象枚举项 type SubjectInfo struct { - SubjectType string `json:"subjectType"` - SubjectID string `json:"subjectId"` - Name string `json:"name"` - ModelType string `json:"modelType,omitempty"` // 模型类型名(管理端分组展示,如 推理/视频) - ModelTypeCode int `json:"modelTypeCode,omitempty"` // 模型类型编码:100推理/200图片/300音频/600视频 + SubjectType string `json:"subjectType"` + SubjectID string `json:"subjectId"` + Name string `json:"name"` + ModelType string `json:"modelType,omitempty"` // 模型类型名(管理端分组展示,如 推理/视频) + ModelTypeCode int `json:"modelTypeCode,omitempty"` // 模型类型编码:100推理/200图片/300音频/600视频 + ChargeModes []string `json:"chargeModes,omitempty"` // workflow/business 用(可选的计费方式) } // ChargeModeListReq 按主体查询可选计费方式请求 diff --git a/model/dto/wallet/account_dto.go b/model/dto/wallet/account_dto.go new file mode 100644 index 0000000..236e54e --- /dev/null +++ b/model/dto/wallet/account_dto.go @@ -0,0 +1,85 @@ +package dto + +import ( + "gitea.redpowerfuture.com/red-future/common/beans" + "github.com/gogf/gf/v2/frame/g" + + walletConsts "shop-user-trade/consts/wallet" +) + +// GetAccountByUserIdReq 获取钱包请求 +type GetAccountByUserIdReq struct { + g.Meta `path:"/get" method:"get" tags:"钱包管理" summary:"获取钱包" dc:"根据用户ID获取钱包信息"` + + UserId int64 `json:"userId" v:"required|min:1" dc:"用户ID"` +} + +// AccountInfo 钱包信息 +type AccountInfo struct { + ID int64 `json:"id" dc:"钱包ID"` + UserID int64 `json:"userId" dc:"用户ID"` + Balance float64 `json:"balance" dc:"可用余额(元),负值=欠费"` + Currency string `json:"currency" dc:"货币类型"` + Status int `json:"status" dc:"状态:1启用 0禁用 -1冻结"` +} + +// RechargeReq 充值请求 +type RechargeReq struct { + g.Meta `path:"/recharge" method:"post" tags:"钱包管理" summary:"充值" dc:"为用户钱包充值"` + + UserId int64 `json:"userId" v:"required|min:1" dc:"用户ID"` + UserName string `json:"userName" v:"required" dc:"账户名"` + Amount float64 `json:"amount" v:"required|min:0.01" dc:"充值金额(元,保留2位小数)"` + Currency string `json:"currency" dc:"货币类型,默认CNY"` + OrderNo string `json:"orderNo" dc:"外部订单号"` + Description string `json:"description" dc:"描述"` +} + +// GetAccountLogsReq 获取钱包流水请求 +type GetAccountLogsReq struct { + g.Meta `path:"/logs" method:"get" tags:"钱包管理" summary:"获取钱包流水" dc:"分页获取钱包账务流水"` + + UserId int64 `json:"userId" v:"required|min:1" dc:"用户ID"` + *beans.Page `json:"page"` +} + +// AccountLogData 钱包流水数据 +type AccountLogData struct { + Logs []AccountLogInfo `json:"logs"` + Total int `json:"total"` +} + +// AccountLogInfo 钱包流水信息 +type AccountLogInfo struct { + ID int64 `json:"id"` + OrderNo string `json:"orderNo" dc:"业务单据号(计费单号/充值单号)"` + TransactionNo string `json:"transactionNo" dc:"账务流水号(唯一,幂等)"` + Type string `json:"type" dc:"账务类型:income/expense"` + Amount float64 `json:"amount" dc:"本次变动金额(元,正数)"` + BalanceBefore float64 `json:"balanceBefore" dc:"可用余额变动前(元)"` + BalanceAfter float64 `json:"balanceAfter" dc:"可用余额变动后(元)"` + Currency string `json:"currency" dc:"货币类型"` + Description string `json:"description" dc:"描述"` + CreatedAt string `json:"createdAt" dc:"创建时间"` +} + +// ====================== DAO 入参(统一吃 DTO,同 model-gateway) ====================== + +// CreateAccountReq 创建钱包请求(钱包不存在时自动创建,余额 0) +type CreateAccountReq struct { + UserId int64 `json:"userId"` + UserName string `json:"userName"` + Status int `json:"status"` + Currency string `json:"currency"` +} + +// ChangeAccountReq 单次账务变动请求(事务透明,由调用方在 service 事务内使用) +type ChangeAccountReq struct { + UserId int64 `json:"userId"` + DeltaBalance float64 `json:"deltaBalance"` // 余额增量(元,可为负=扣款) + Type walletConsts.WalletLogType `json:"type"` // income/expense + Amount float64 `json:"amount"` // 记账金额(元,正数) + OrderNo string `json:"orderNo"` + Description string `json:"description"` + ExtraData map[string]interface{} `json:"extraData"` +} diff --git a/model/dto/wallet/wallet_dto.go b/model/dto/wallet/wallet_dto.go deleted file mode 100644 index fb7b694..0000000 --- a/model/dto/wallet/wallet_dto.go +++ /dev/null @@ -1,112 +0,0 @@ -package dto - -import ( - "github.com/gogf/gf/v2/frame/g" -) - -// GetWalletByUserIdReq 根据用户ID获取钱包请求 -type GetWalletByUserIdReq struct { - g.Meta `path:"/getWallet" method:"get" tags:"钱包管理" summary:"获取钱包" dc:"根据用户ID获取钱包信息"` - - UserId int64 `json:"userId" v:"required|min:1" dc:"用户ID"` -} - -// GetWalletByUserIdResp 根据用户ID获取钱包响应 -type GetWalletByUserIdResp struct { - Data WalletInfo `json:"data"` -} - -// WalletInfo 钱包信息 -type WalletInfo struct { - ID int64 `json:"id" dc:"钱包ID"` - UserID int64 `json:"userId" dc:"用户ID"` - Balance int64 `json:"balance" dc:"余额(分)"` - Currency string `json:"currency" dc:"货币类型"` - Status int `json:"status" dc:"状态"` -} - -// CreateWalletReq 创建钱包请求 -type CreateWalletReq struct { - g.Meta `path:"/createWallet" method:"post" tags:"钱包管理" summary:"创建钱包" dc:"为用户创建钱包"` - - UserId int64 `json:"userId" v:"required|min:1" dc:"用户ID"` - Currency string `json:"currency" v:"required" dc:"货币类型"` -} - -// CreateWalletResp 创建钱包响应 -type CreateWalletResp struct { - Data CreateWalletData `json:"data"` -} - -// CreateWalletData 创建钱包数据 -type CreateWalletData struct { - WalletID int64 `json:"walletId" dc:"钱包ID"` -} - -// UpdateBalanceReq 更新余额请求 -type UpdateBalanceReq struct { - g.Meta `path:"/updateBalance" method:"post" tags:"钱包管理" summary:"更新余额" dc:"对钱包余额进行收入/支出/冻结/解冻操作"` - - WalletID int64 `json:"walletId" v:"required" dc:"钱包ID"` - Amount int64 `json:"amount" v:"required|min:1" dc:"金额(分)"` - Type string `json:"type" v:"required|in:income,expense,freeze,unfreeze" dc:"操作类型"` - OrderNo string `json:"orderNo" dc:"业务订单号"` - TransactionNo string `json:"transactionNo" dc:"钱包交易流水号"` - Description string `json:"description" dc:"描述"` - ExtraData map[string]interface{} `json:"extraData" dc:"额外数据"` -} - -// UpdateBalanceResp 更新余额响应 -type UpdateBalanceResp struct { - Success bool `json:"success"` - Message string `json:"message"` -} - -// GetWalletLogsReq 获取钱包日志请求 -type GetWalletLogsReq struct { - g.Meta `path:"/getWalletLogs" method:"get" tags:"钱包管理" summary:"获取钱包日志" dc:"分页获取钱包操作日志"` - - UserId int64 `json:"userId" v:"required|min:1" dc:"用户ID"` - Page int `json:"page" v:"required|min:1" dc:"页码"` - PageSize int `json:"pageSize" v:"required|min:1|max:100" dc:"每页大小"` -} - -// GetWalletLogsResp 获取钱包日志响应 -type GetWalletLogsResp struct { - Data WalletLogData `json:"data"` -} - -// WalletLogData 钱包日志数据 -type WalletLogData struct { - Logs []WalletLogInfo `json:"logs"` - Total int `json:"total"` -} - -// WalletLogInfo 钱包日志信息 -type WalletLogInfo struct { - ID int64 `json:"id"` - OrderNo string `json:"orderNo"` - TransactionNo string `json:"transactionNo"` - Type string `json:"type"` - Amount int64 `json:"amount"` - BalanceBefore int64 `json:"balanceBefore"` - BalanceAfter int64 `json:"balanceAfter"` - Currency string `json:"currency"` - Description string `json:"description"` - CreatedAt string `json:"createdAt"` -} - -// CreateWalletLogReq 创建钱包日志请求(内部使用) -type CreateWalletLogReq struct { - UserID int64 `json:"userId"` - WalletID int64 `json:"walletId"` - OrderNo string `json:"orderNo"` - TransactionNo string `json:"transactionNo"` - Type string `json:"type"` - Amount int64 `json:"amount"` - BalanceBefore int64 `json:"balanceBefore"` - BalanceAfter int64 `json:"balanceAfter"` - Currency string `json:"currency"` - Description string `json:"description"` - ExtraData map[string]interface{} `json:"extraData"` -} diff --git a/model/entity/pricing/charge_order.go b/model/entity/pricing/charge_order.go index 0f450a3..77ca7d6 100644 --- a/model/entity/pricing/charge_order.go +++ b/model/entity/pricing/charge_order.go @@ -22,17 +22,17 @@ type chargeOrderCol struct { } var ChargeOrderCol = chargeOrderCol{ - SQLBaseCol: beans.DefSQLBaseCol, - SubjectType: "subject_type", - SubjectID: "subject_id", - BizOrderNo: "biz_order_no", - UserId: "user_id", - ChargeMode: "charge_mode", - Status: "status", - ActualAmount: "actual_amount", - Usage: "usage", - RuleSnapshot: "rule_snapshot", - SettleTime: "settle_time", + SQLBaseCol: beans.DefSQLBaseCol, + SubjectType: "subject_type", + SubjectID: "subject_id", + BizOrderNo: "biz_order_no", + UserId: "user_id", + ChargeMode: "charge_mode", + Status: "status", + ActualAmount: "actual_amount", + Usage: "usage", + RuleSnapshot: "rule_snapshot", + SettleTime: "settle_time", } // ChargeOrder 计费单实体:一次计价调用 = 一张计费单(幂等键 subject_type+subject_id+biz_order_no) @@ -40,14 +40,14 @@ var ChargeOrderCol = chargeOrderCol{ type ChargeOrder struct { beans.SQLBaseDO `orm:",inherit"` - SubjectType pricingConsts.SubjectType `orm:"subject_type" json:"subjectType" description:"计价对象类型"` - SubjectID string `orm:"subject_id" json:"subjectId" description:"计价对象ID"` - BizOrderNo string `orm:"biz_order_no" json:"bizOrderNo" description:"业务单据号(幂等键,如工作流 execId)"` - UserId int64 `orm:"user_id" json:"userId,string" description:"扣费用户ID"` - ChargeMode pricingConsts.ChargeMode `orm:"charge_mode" json:"chargeMode" description:"计费方式(建单快照)"` - Status pricingConsts.ChargeOrderStatus `orm:"status" json:"status" description:"状态:1已建单 2已结算 3已失败"` - ActualAmount float64 `orm:"actual_amount" json:"actualAmount" description:"实收金额(元,保留2位小数)"` - Usage string `orm:"usage" json:"usage" description:"实际用量JSON"` - RuleSnapshot string `orm:"rule_snapshot" json:"ruleSnapshot" description:"结算用费率快照JSON(防改价影响在途单)"` - SettleTime *gtime.Time `orm:"settle_time" json:"settleTime" description:"结算时间"` + SubjectType pricingConsts.SubjectType `orm:"subject_type" json:"subjectType" description:"计价对象类型"` + SubjectID string `orm:"subject_id" json:"subjectId" description:"计价对象ID"` + BizOrderNo string `orm:"biz_order_no" json:"bizOrderNo" description:"业务单据号(幂等键,如工作流 execId)"` + UserId int64 `orm:"user_id" json:"userId,string" description:"扣费用户ID"` + ChargeMode pricingConsts.ChargeMode `orm:"charge_mode" json:"chargeMode" description:"计费方式(建单快照)"` + Status pricingConsts.ChargeOrderStatus `orm:"status" json:"status" description:"状态:1已建单 2已结算 3已失败"` + ActualAmount float64 `orm:"actual_amount" json:"actualAmount" description:"实收金额(元,保留2位小数)"` + Usage map[string]any `orm:"usage" json:"usage" description:"实际用量JSON对象"` + RuleSnapshot map[string]any `orm:"rule_snapshot" json:"ruleSnapshot" description:"结算用费率快照JSON对象(防改价影响在途单)"` + SettleTime *gtime.Time `orm:"settle_time" json:"settleTime" description:"结算时间"` } diff --git a/model/entity/pricing/pricing_config.go b/model/entity/pricing/pricing_config.go index f24c56e..f23fb5f 100644 --- a/model/entity/pricing/pricing_config.go +++ b/model/entity/pricing/pricing_config.go @@ -1,8 +1,6 @@ package entity import ( - "encoding/json" - pricingConsts "shop-user-trade/consts/pricing" "gitea.redpowerfuture.com/red-future/common/beans" @@ -16,7 +14,6 @@ type pricingConfigCol struct { MinBalance string Currency string Enabled string - Version string } var PricingConfigCol = pricingConfigCol{ @@ -27,7 +24,6 @@ var PricingConfigCol = pricingConfigCol{ MinBalance: "min_balance", Currency: "currency", Enabled: "enabled", - Version: "version", } // PricingConfig 计价配置实体(费率全 DB 配置,改价/加档只改 rules,代码不写死金额) @@ -36,21 +32,17 @@ type PricingConfig struct { SubjectType pricingConsts.SubjectType `orm:"subject_type" json:"subjectType" description:"计价对象类型:workflow/model/business"` SubjectID string `orm:"subject_id" json:"subjectId" description:"计价对象ID(workflow/模型ID/业务模块标识)"` - Rules string `orm:"rules" json:"rules" description:"费率JSON(见设计§4,rules 自描述不再有 charge_mode 列)"` + Rules map[string]any `orm:"rules" json:"rules" description:"费率JSON对象(见设计§4,rules 自描述不再有 charge_mode 列)"` MinBalance float64 `orm:"min_balance" json:"minBalance" description:"门禁:调用前可用余额须>=该值(元),0=不校验"` Currency string `orm:"currency" json:"currency" description:"货币类型"` Enabled int `orm:"enabled" json:"enabled" description:"1启用 0停用"` - Version int64 `orm:"version" json:"version" description:"乐观锁版本号"` } // Unit 从 rules 中解析模型计费单位(仅 subject_type=model 有效,其余返回空)。 // service 层 per_token 结算/建单解析计费方式时使用,避免重复解析 rules。 func (e *PricingConfig) Unit() pricingConsts.ChargeMode { - var cfg struct { - Unit pricingConsts.ChargeMode `json:"unit"` + if unit, ok := e.Rules["unit"].(string); ok { + return pricingConsts.ChargeMode(unit) } - if err := json.Unmarshal([]byte(e.Rules), &cfg); err != nil { - return "" - } - return cfg.Unit + return "" } diff --git a/model/entity/wallet/wallet.go b/model/entity/wallet/account.go similarity index 56% rename from model/entity/wallet/wallet.go rename to model/entity/wallet/account.go index 435ab45..8d17290 100644 --- a/model/entity/wallet/wallet.go +++ b/model/entity/wallet/account.go @@ -6,31 +6,33 @@ import ( "gitea.redpowerfuture.com/red-future/common/beans" ) -type walletCol struct { +type accountCol struct { beans.SQLBaseCol UserID string + UserName string Balance string Currency string Status string - Version string } -var WalletCol = walletCol{ +var AccountCol = accountCol{ SQLBaseCol: beans.DefSQLBaseCol, UserID: "user_id", + UserName: "user_name", Balance: "balance", Currency: "currency", Status: "status", - Version: "version", } -// Wallet 钱包实体 -type Wallet struct { +// Account 钱包实体 +// 单余额模型:balance 可用余额(元),可为负(欠费,靠充值归正)。 +// status 为整钱包状态(1启用/0禁用/-1冻结,风控用)。 +type Account struct { beans.SQLBaseDO `orm:",inherit"` UserID int64 `orm:"user_id" json:"userId,string" description:"用户ID"` - Balance int64 `orm:"balance" json:"balance" description:"余额(分)"` + UserName string `orm:"user_name" json:"userName" description:"用户名"` + Balance float64 `orm:"balance" json:"balance" description:"可用余额(元,保留2位小数),可为负=欠费"` Currency string `orm:"currency" json:"currency" description:"货币类型:CNY-人民币"` Status walletConsts.WalletStatus `orm:"status" json:"status" description:"状态:1启用/0禁用/-1冻结"` - Version int64 `orm:"version" json:"version" description:"乐观锁版本号"` } diff --git a/model/entity/wallet/account_log.go b/model/entity/wallet/account_log.go new file mode 100644 index 0000000..dde608e --- /dev/null +++ b/model/entity/wallet/account_log.go @@ -0,0 +1,54 @@ +package entity + +import ( + walletConsts "shop-user-trade/consts/wallet" + + "gitea.redpowerfuture.com/red-future/common/beans" +) + +// AccountLog 钱包操作日志实体 +type AccountLog struct { + beans.SQLBaseDO `orm:",inherit"` + + UserID int64 `orm:"user_id" json:"userId,string" description:"用户ID"` + WalletID int64 `orm:"wallet_id" json:"walletId,string" description:"钱包ID"` + OrderNo string `orm:"order_no" json:"orderNo" description:"业务单据号(计费单号/充值单号)"` + TransactionNo string `orm:"transaction_no" json:"transactionNo" description:"账务流水号(唯一,幂等)"` + Type walletConsts.WalletLogType `orm:"type" json:"type" description:"账务类型:income/expense"` + Amount float64 `orm:"amount" json:"amount" description:"本次变动金额(元,保留2位小数,正数)"` + BalanceBefore float64 `orm:"balance_before" json:"balanceBefore" description:"可用余额变动前(元)"` + BalanceAfter float64 `orm:"balance_after" json:"balanceAfter" description:"可用余额变动后(元)"` + Currency string `orm:"currency" json:"currency" description:"货币类型"` + Description string `orm:"description" json:"description" description:"描述"` + ExtraData map[string]interface{} `orm:"extra_data" json:"extraData" description:"额外数据(JSONB)"` +} + +type accountLogCol struct { + beans.SQLBaseCol + UserID string + WalletID string + OrderNo string + TransactionNo string + Type string + Amount string + BalanceBefore string + BalanceAfter string + Currency string + Description string + ExtraData string +} + +var AccountLogCol = accountLogCol{ + SQLBaseCol: beans.DefSQLBaseCol, + UserID: "user_id", + WalletID: "wallet_id", + OrderNo: "order_no", + TransactionNo: "transaction_no", + Type: "type", + Amount: "amount", + BalanceBefore: "balance_before", + BalanceAfter: "balance_after", + Currency: "currency", + Description: "description", + ExtraData: "extra_data", +} diff --git a/model/entity/wallet/wallet_log.go b/model/entity/wallet/wallet_log.go deleted file mode 100644 index 0159918..0000000 --- a/model/entity/wallet/wallet_log.go +++ /dev/null @@ -1,24 +0,0 @@ -package entity - -import ( - walletConsts "shop-user-trade/consts/wallet" - - "gitea.redpowerfuture.com/red-future/common/beans" -) - -// WalletLog 钱包操作日志实体 -type WalletLog struct { - beans.SQLBaseDO `orm:",inherit"` - - UserID int64 `orm:"user_id" json:"userId,string" description:"用户ID"` - WalletID int64 `orm:"wallet_id" json:"walletId,string" description:"钱包ID"` - OrderNo string `orm:"order_no" json:"orderNo" description:"业务订单号"` - TransactionNo string `orm:"transaction_no" json:"transactionNo" description:"钱包交易流水号"` - Type walletConsts.WalletLogType `orm:"type" json:"type" description:"操作类型"` - Amount int64 `orm:"amount" json:"amount" description:"金额(分)"` - BalanceBefore int64 `orm:"balance_before" json:"balanceBefore" description:"操作前余额"` - BalanceAfter int64 `orm:"balance_after" json:"balanceAfter" description:"操作后余额"` - Currency string `orm:"currency" json:"currency" description:"货币类型"` - Description string `orm:"description" json:"description" description:"描述"` - ExtraData map[string]interface{} `orm:"extra_data" json:"extraData" description:"额外数据(JSONB)"` -} diff --git a/model/entity/wallet/wallet_log_col.go b/model/entity/wallet/wallet_log_col.go deleted file mode 100644 index 40d43e6..0000000 --- a/model/entity/wallet/wallet_log_col.go +++ /dev/null @@ -1,33 +0,0 @@ -package entity - -import "gitea.redpowerfuture.com/red-future/common/beans" - -type walletLogCol struct { - beans.SQLBaseCol - UserID string - WalletID string - OrderNo string - TransactionNo string - Type string - Amount string - BalanceBefore string - BalanceAfter string - Currency string - Description string - ExtraData string -} - -var WalletLogCol = walletLogCol{ - SQLBaseCol: beans.DefSQLBaseCol, - UserID: "user_id", - WalletID: "wallet_id", - OrderNo: "order_no", - TransactionNo: "transaction_no", - Type: "type", - Amount: "amount", - BalanceBefore: "balance_before", - BalanceAfter: "balance_after", - Currency: "currency", - Description: "description", - ExtraData: "extra_data", -} diff --git a/service/pricing/charge_calc.go b/service/pricing/charge_calc.go index deb15cb..8b61b96 100644 --- a/service/pricing/charge_calc.go +++ b/service/pricing/charge_calc.go @@ -13,16 +13,20 @@ import ( // ====================== 用量结构 ====================== // ChargeUsage 实际用量(结算入参,工作流/调用方上报)。 -// DurationSec 秒、TokensByModel 各模型token数(per_token 用)。 +// DurationSec 秒;工作流 token 计费按 FeeByModel 合计实收(各模型=调用方按次调 shop /calc 返回费求和, +// 每次调用已 ceilFen 到分、媒体/费率按调用时快照)。逐模型 token/媒体明细由调用方保留(ai-agent +// node_execution.token_info),本结构解析只取计价字段,未知键原样落库 order.usage 作审计(见 settleOrder)。 // 模型计价字段见各计算器注释(spec §5.3)。 type ChargeUsage struct { - DurationSec float64 `json:"durationSec"` // 时长(秒) - TokensByModel map[string]int64 `json:"tokensByModel"` // 各模型消耗token + DurationSec float64 `json:"durationSec"` // 时长(秒):per_item/per_second 工作流结算、model 单位计价用 + // FeeByModel 各模型已消耗费用(= 调用方按次调 shop /calc 返回费的合计,每次调用已 ceilFen 到分、各次媒体/费率 + // 按调用时快照)。per_item/per_second 取消补收、per_token 结算的 token 部分按此合计实收(不再按聚合 token + // 重算,避免丢失「每次调用不足1分按1分」的兜底与逐调用媒体价——聚合重算会把两笔 0.01 合并成 0.01 且拿错媒体价)。 + FeeByModel map[string]float64 `json:"feeByModel"` // —— 模型计价扩展(spec §5.3)—— PromptTokens int64 `json:"promptTokens"` // token 计费用:输入 - CompletionTokens int64 `json:"completionTokens"` // token 计费用:输出 + CompletionTokens int64 `json:"completionTokens"` // token 计费用:输出;per_char 模型=输出字数(调用方把字数映射到该字段传输) CachedTokens int64 `json:"cachedTokens"` // token 计费用:缓存命中 - CharCount int64 `json:"charCount"` // per_char 用(音频字数) ImageCount int64 `json:"imageCount"` // per_1 用(图片张数) MediaType string `json:"mediaType"` // text/audio/video/image(输入媒体) Thinking *bool `json:"thinking"` // 推理 思考/非思考 @@ -88,7 +92,7 @@ var ( pricingConsts.ChargeModePerSecond: modelUnitCalculator{base: 1, usage: usageDurationSec}, pricingConsts.ChargeModePerMinute: modelUnitCalculator{base: 60, usage: usageDurationSec}, pricingConsts.ChargeModePerHour: modelUnitCalculator{base: 3600, usage: usageDurationSec}, - pricingConsts.ChargeModePerChar: modelUnitCalculator{base: 1, usage: usageCharCount}, + pricingConsts.ChargeModePerChar: modelUnitCalculator{base: 1, usage: usageCompletionTokens}, // 输出字数经 completionTokens 传输 } // business 计算器(spec §4.3 周期订阅) businessCalculators = map[pricingConsts.ChargeMode]calculator{ @@ -142,6 +146,10 @@ func (perItemCalculator) validate(rulesJSON string) (interface{}, error) { func (perItemCalculator) charge(rules interface{}, usage *ChargeUsage) (float64, error) { r := rules.(*perItemRules) + // 0秒无产出(中途取消/未生成视频):按已消耗=0 计费,不落入第一档位价 + if usage.DurationSec <= 0 { + return 0, nil + } last := r.Tiers[len(r.Tiers)-1] for _, t := range r.Tiers { if usage.DurationSec <= t.MaxSec { @@ -179,7 +187,7 @@ func (perSecondCalculator) charge(rules interface{}, usage *ChargeUsage) (float6 type perTokenCalculator struct{} func (perTokenCalculator) validate(rulesJSON string) (interface{}, error) { - // per_token 为 {} 仅启用标记;价格取自各模型 subject 实时配置(spec §5.4) + // per_token 为 {} 仅启用标记;费率不落槽位(建单无快照),结算按调用方上报的按次费用(FeeByModel)合计实收(spec §5.4) var container map[string]interface{} if err := json.Unmarshal([]byte(rulesJSON), &container); err != nil { return nil, fmt.Errorf("per_token 费率JSON解析失败: %v", err) @@ -188,8 +196,9 @@ func (perTokenCalculator) validate(rulesJSON string) (interface{}, error) { } func (perTokenCalculator) charge(rules interface{}, usage *ChargeUsage) (float64, error) { - // 不在此计价:per_token 按模型 subject 实时价,由 service 层 calcPerTokenOrder 处理 - return 0, errors.New("per_token 计价在 service 层按模型 subject 实时价计算") + // 不在此计价:per_token 无费率档位(建单槽位 {}),按调用方上报的按次已记录费用(FeeByModel)合计实收, + // 由 service 层 sumRecordedFee 处理 + return 0, errors.New("per_token 计价在 service 层按调用方上报的按次费用(FeeByModel)合计实收") } // ====================== 模型计费 JSON 结构(model subject rules,spec §4.2) ====================== @@ -197,9 +206,9 @@ func (perTokenCalculator) charge(rules interface{}, usage *ChargeUsage) (float64 // modelRules 模型计费规则:unit + tiered + rules(rules 按序首条命中) type modelRules struct { Unit pricingConsts.ChargeMode `json:"unit"` - Tiered bool `json:"tiered"` - Rules []modelRule `json:"rules"` - Currency string `json:"currency"` + Tiered bool `json:"tiered"` + Rules []modelRule `json:"rules"` + Currency string `json:"currency"` } type modelRule struct { @@ -418,8 +427,10 @@ func (c modelUnitCalculator) charge(rules interface{}, usage *ChargeUsage) (floa // 用量提取函数 func usageDurationSec(u *ChargeUsage) float64 { return u.DurationSec } -func usageCharCount(u *ChargeUsage) float64 { return float64(u.CharCount) } -func usageImageCount(u *ChargeUsage) float64 { return float64(u.ImageCount) } + +// usageCompletionTokens per_char 用:调用方把输出字数映射到 CompletionTokens 传输(无独立 charCount 字段) +func usageCompletionTokens(u *ChargeUsage) float64 { return float64(u.CompletionTokens) } +func usageImageCount(u *ChargeUsage) float64 { return float64(u.ImageCount) } // ====================== per_period 周期订阅(business) ====================== diff --git a/service/pricing/charge_calc_test.go b/service/pricing/charge_calc_test.go deleted file mode 100644 index 5d5fba7..0000000 --- a/service/pricing/charge_calc_test.go +++ /dev/null @@ -1,335 +0,0 @@ -package service - -import "testing" - -func boolPtr(v bool) *bool { return &v } - -func TestValidateTieredBandsAdjacentOK(t *testing.T) { - rules := []modelRule{ - {Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}}, - {Match: &modelMatch{InputLengthMin: 32001, InputLengthMax: 128000}}, - {Match: &modelMatch{InputLengthMin: 128001, InputLengthMax: 256000}}, - } - if err := validateTieredBands(rules); err != nil { - t.Fatalf("相邻档位应通过: %v", err) - } -} - -func TestValidateTieredBandsSameStartRejected(t *testing.T) { - // 旧实现对 InputLengthMin==0 的规则对 continue,两个同起 0 的档位查不出重叠 - rules := []modelRule{ - {Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}}, - {Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 64000}}, - } - if err := validateTieredBands(rules); err == nil { - t.Fatal("两个同起 0 的档位必须判重叠") - } -} - -func TestValidateTieredBandsUnsortedOverlapDetected(t *testing.T) { - // 乱序提交仍须判重叠(旧实现依赖提交顺序,此用例对旧实现漏检) - rules := []modelRule{ - {Match: &modelMatch{InputLengthMin: 50000, InputLengthMax: 64000}}, - {Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 52000}}, - } - if err := validateTieredBands(rules); err == nil { - t.Fatal("重叠必须与提交顺序无关地被检出") - } -} - -func TestValidateTieredBandsOpenEndedLastOK(t *testing.T) { - rules := []modelRule{ - {Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}}, - {Match: &modelMatch{InputLengthMin: 32001, InputLengthMax: 0}}, // 上不封顶 - } - if err := validateTieredBands(rules); err != nil { - t.Fatalf("上不封顶在最后应通过: %v", err) - } -} - -func TestValidateTieredBandsOpenEndedNotLastRejected(t *testing.T) { - rules := []modelRule{ - {Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}}, - {Match: &modelMatch{InputLengthMin: 32001, InputLengthMax: 0}}, // 上不封顶 - {Match: &modelMatch{InputLengthMin: 64001, InputLengthMax: 96000}}, - } - if err := validateTieredBands(rules); err == nil { - t.Fatal("上不封顶档之后不能再有长度档") - } -} - -func TestValidateTieredBandsNonLengthRuleIgnored(t *testing.T) { - // thinking 兜底档无长度区间,不应参与重叠校验 - rules := []modelRule{ - {Match: &modelMatch{Thinking: boolPtr(false)}}, - {Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}}, - } - if err := validateTieredBands(rules); err != nil { - t.Fatalf("无长度规则不应干扰: %v", err) - } -} - -func TestTokenValidateMediaPricesBadKey(t *testing.T) { - c := modelTokenCalculator{base: 1e6} - _, err := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":1},"mediaPrices":{"audios":{"input":2}}}]}`) - if err == nil { - t.Fatal("mediaPrices 非法键必须报错") - } -} - -func TestTokenValidateMediaPricesNilValue(t *testing.T) { - c := modelTokenCalculator{base: 1e6} - _, err := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":1},"mediaPrices":{"audio":null}}]}`) - if err == nil { - t.Fatal("mediaPrices 值为空必须报错") - } -} - -func TestTokenValidateMediaPricesValid(t *testing.T) { - c := modelTokenCalculator{base: 1e6} - _, err := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":1},"mediaPrices":{"audio":{"input":2},"video":{"input":3}}}]}`) - if err != nil { - t.Fatalf("合法 mediaPrices 应通过: %v", err) - } -} - -func TestUnitValidateMediaPricesBadKey(t *testing.T) { - c := modelUnitCalculator{base: 1, usage: usageDurationSec} - _, err := c.validate(`{"unit":"per_second","rules":[{"name":"r","price":{"unitPrice":1},"mediaPrices":{"snd":{"unitPrice":2}}}]}`) - if err == nil { - t.Fatal("mediaPrices 非法键必须报错") - } -} - -func TestUnitValidateMediaPricesValid(t *testing.T) { - c := modelUnitCalculator{base: 1, usage: usageDurationSec} - _, err := c.validate(`{"unit":"per_second","rules":[{"name":"r","price":{"unitPrice":1},"mediaPrices":{"audio":{"unitPrice":2}}}]}`) - if err != nil { - t.Fatalf("合法 mediaPrices 应通过: %v", err) - } -} - -func TestTokenChargeMediaPricesAudio(t *testing.T) { - c := modelTokenCalculator{base: 1e6} - rules, err := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":0.3,"output":1.8,"cacheHit":0.12},"mediaPrices":{"audio":{"input":4.5,"output":1.8,"cacheHit":1.8}}}]}`) - if err != nil { - t.Fatalf("validate: %v", err) - } - got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1e6, MediaType: "audio"}) - if err != nil { - t.Fatalf("charge: %v", err) - } - if got != 4.5 { - t.Fatalf("含音频应收媒体价 4.5,实收 %v", got) - } -} - -func TestTokenChargeDefaultWithoutMedia(t *testing.T) { - c := modelTokenCalculator{base: 1e6} - rules, _ := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":0.3,"output":1.8,"cacheHit":0.12},"mediaPrices":{"audio":{"input":4.5,"output":1.8,"cacheHit":1.8}}}]}`) - got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1e6, MediaType: "text"}) - if err != nil { - t.Fatalf("charge: %v", err) - } - if got != 0.3 { - t.Fatalf("不含媒体应收默认价 0.3,实收 %v", got) - } -} - -func TestTokenChargeMediaPricesMultiType(t *testing.T) { - c := modelTokenCalculator{base: 1000} - rules, _ := c.validate(`{"unit":"per_1K","rules":[{"name":"r","price":{"input":0.3},"mediaPrices":{"audio":{"input":4.5},"video":{"input":6}}}]}`) - got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1000, MediaType: "video"}) - if err != nil { - t.Fatalf("charge: %v", err) - } - if got != 6 { - t.Fatalf("video 应收媒体价 6,实收 %v", got) - } -} - -func TestTokenChargeMediaPricesUnmatchedTypeUsesDefault(t *testing.T) { - // mediaPrices 只有 audio,video 输入落默认价 - c := modelTokenCalculator{base: 1000} - rules, _ := c.validate(`{"unit":"per_1K","rules":[{"name":"r","price":{"input":0.3},"mediaPrices":{"audio":{"input":4.5}}}]}`) - got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1000, MediaType: "video"}) - if err != nil { - t.Fatalf("charge: %v", err) - } - if got != 0.3 { - t.Fatalf("未配 mediaPrices 的媒体应收默认价 0.3,实收 %v", got) - } -} - -func TestUnitChargeMediaPrices(t *testing.T) { - c := modelUnitCalculator{base: 1, usage: usageDurationSec} - rules, err := c.validate(`{"unit":"per_second","rules":[{"name":"r","price":{"unitPrice":1},"mediaPrices":{"audio":{"unitPrice":2}}}]}`) - if err != nil { - t.Fatalf("validate: %v", err) - } - got, err := c.charge(rules, &ChargeUsage{DurationSec: 3, MediaType: "audio"}) - if err != nil { - t.Fatalf("charge: %v", err) - } - if got != 6 { - t.Fatalf("audio 单位价应收 6,实收 %v", got) - } -} - -func TestUnitChargeMediaPricesDefault(t *testing.T) { - c := modelUnitCalculator{base: 1, usage: usageDurationSec} - rules, _ := c.validate(`{"unit":"per_second","rules":[{"name":"r","price":{"unitPrice":1},"mediaPrices":{"audio":{"unitPrice":2}}}]}`) - got, err := c.charge(rules, &ChargeUsage{DurationSec: 3, MediaType: "video"}) - if err != nil { - t.Fatalf("charge: %v", err) - } - if got != 3 { - t.Fatalf("未配媒体价应收默认 3,实收 %v", got) - } -} - -// 阶梯算费 fixture(原 fixture 的 mediaType:"text" 已随 match 移除) -const modelTokenTieredJSON = `{ - "unit":"per_1M","tiered":true, - "rules":[ - {"name":"思考≤32000","match":{"thinking":true,"inputLengthMax":32000},"price":{"input":0.6,"output":3.6,"cacheHit":0.12}}, - {"name":"思考>32000","match":{"thinking":true,"inputLengthMin":32001},"price":{"input":0.9,"output":5.4,"cacheHit":0.18}} - ]}` - -// 阶梯算费:prompt 20000 → (0.6*20000+3.6*1000)/1e6=0.0156→0.02;50000 → 0.0504→0.06 -func TestModelTokenCalculator_TieredMatch(t *testing.T) { - c := modelTokenCalculator{base: 1e6} - rules, err := c.validate(modelTokenTieredJSON) - if err != nil { - t.Fatalf("validate err: %v", err) - } - got, err := c.charge(rules, &ChargeUsage{PromptTokens: 20000, CompletionTokens: 1000, Thinking: boolPtr(true)}) - if err != nil { - t.Fatalf("charge err: %v", err) - } - if got != 0.02 { - t.Fatalf("tier1 got %v want 0.02", got) - } - got, err = c.charge(rules, &ChargeUsage{PromptTokens: 50000, CompletionTokens: 1000, Thinking: boolPtr(true)}) - if err != nil { - t.Fatalf("charge err: %v", err) - } - if got != 0.06 { - t.Fatalf("tier2 got %v want 0.06", got) - } -} - -// 无匹配报错:thinking=false 不在任一档 -func TestModelTokenCalculator_NoMatch(t *testing.T) { - c := modelTokenCalculator{base: 1e6} - rules, err := c.validate(modelTokenTieredJSON) - if err != nil { - t.Fatalf("validate err: %v", err) - } - _, err = c.charge(rules, &ChargeUsage{PromptTokens: 1000, Thinking: boolPtr(false)}) - if err == nil { - t.Fatal("expected no-match error") - } -} - -// cache-hit 算费:(2000-1000)/1000*1 + 1000/1000*0.1 + 500/1000*2 = 2.1 -func TestModelTokenCalculator_CacheHit(t *testing.T) { - c := modelTokenCalculator{base: 1000} - rules, err := c.validate(`{"unit":"per_1K","tiered":false,"rules":[{"name":"统一","price":{"input":1,"output":2,"cacheHit":0.1}}]}`) - if err != nil { - t.Fatalf("validate err: %v", err) - } - got, err := c.charge(rules, &ChargeUsage{PromptTokens: 2000, CompletionTokens: 500, CachedTokens: 1000}) - if err != nil { - t.Fatalf("charge err: %v", err) - } - if got != 2.1 { - t.Fatalf("cache got %v want 2.1", got) - } -} - -// M3:cached > prompt 时金额不为负(promptInput 钳为 0):0 + 3000/1000*0.1 = 0.3 -func TestModelTokenCalculator_CachedGreaterThanPrompt(t *testing.T) { - c := modelTokenCalculator{base: 1000} - rules, err := c.validate(`{"unit":"per_1K","tiered":false,"rules":[{"name":"统一","price":{"input":1,"output":2,"cacheHit":0.1}}]}`) - if err != nil { - t.Fatalf("validate err: %v", err) - } - got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1000, CompletionTokens: 0, CachedTokens: 3000}) - if err != nil { - t.Fatalf("charge err: %v", err) - } - if got < 0 { - t.Fatalf("got negative amount %v", got) - } - if got != 0.3 { - t.Fatalf("got %v want 0.3", got) - } -} - -// P2:validate 拒绝 unit 与实例 base 不匹配的配置 -func TestModelTokenCalculator_ValidateUnitBaseMismatch(t *testing.T) { - c := modelTokenCalculator{base: 1e6} - if _, err := c.validate(`{"unit":"per_1K","tiered":false,"rules":[{"name":"统一","price":{"input":1}}]}`); err == nil { - t.Fatal("expected unit/base mismatch error for per_1M instance with per_1K unit") - } - c2 := modelTokenCalculator{base: 1000} - if _, err := c2.validate(`{"unit":"per_1M","tiered":false,"rules":[{"name":"统一","price":{"input":1}}]}`); err == nil { - t.Fatal("expected unit/base mismatch error for per_1K instance with per_1M unit") - } - if _, err := c.validate(`{"unit":"per_1M","tiered":false,"rules":[{"name":"统一","price":{"input":1}}]}`); err != nil { - t.Fatalf("unexpected err: %v", err) - } - if _, err := c2.validate(`{"unit":"per_1K","tiered":false,"rules":[{"name":"统一","price":{"input":1}}]}`); err != nil { - t.Fatalf("unexpected err: %v", err) - } -} - -// P1:validate 拒绝缺 price 的规则(token 计算器) -func TestModelTokenCalculator_ValidateNilPrice(t *testing.T) { - c := modelTokenCalculator{base: 1e6} - if _, err := c.validate(`{"unit":"per_1M","tiered":false,"rules":[{"name":"无价"}]}`); err == nil { - t.Fatal("expected nil price validation error") - } -} - -// P1:validate 拒绝缺 price 的规则(单位计算器) -func TestModelUnitCalculator_ValidateNilPrice(t *testing.T) { - c := modelUnitCalculator{base: 60, usage: usageDurationSec} - if _, err := c.validate(`{"unit":"per_minute","tiered":false,"rules":[{"name":"无价"}]}`); err == nil { - t.Fatal("expected nil price validation error") - } -} - -// 单位计算器多路径:按分钟(90s/60*2=3)、按字(10000*0.0005=5)、按张×分辨率(3*0.5=1.5) -func TestModelUnitCalculator_Paths(t *testing.T) { - c := modelUnitCalculator{base: 60, usage: usageDurationSec} - rules, err := c.validate(`{"unit":"per_minute","tiered":false,"rules":[{"name":"统一","price":{"unitPrice":2}}]}`) - if err != nil { - t.Fatalf("validate err: %v", err) - } - got, err := c.charge(rules, &ChargeUsage{DurationSec: 90}) - if err != nil || got != 3 { - t.Fatalf("per_minute got %v want 3, err %v", got, err) - } - c2 := modelUnitCalculator{base: 1, usage: usageCharCount} - rules2, err := c2.validate(`{"unit":"per_char","tiered":false,"rules":[{"name":"统一","price":{"unitPrice":0.0005}}]}`) - if err != nil { - t.Fatalf("validate err: %v", err) - } - got, err = c2.charge(rules2, &ChargeUsage{CharCount: 10000}) - if err != nil || got != 5 { - t.Fatalf("per_char got %v want 5, err %v", got, err) - } - c3 := modelUnitCalculator{base: 1, usage: usageImageCount} - rules3, err := c3.validate(`{"unit":"per_1","tiered":false,"rules":[ - {"name":"512x512","match":{"outputResolution":"512x512"},"price":{"unitPrice":0.2}}, - {"name":"1024x1024","match":{"outputResolution":"1024x1024"},"price":{"unitPrice":0.5}}]}`) - if err != nil { - t.Fatalf("validate err: %v", err) - } - got, err = c3.charge(rules3, &ChargeUsage{ImageCount: 3, OutputResolution: "1024x1024"}) - if err != nil || got != 1.5 { - t.Fatalf("per_1 got %v want 1.5, err %v", got, err) - } -} diff --git a/service/pricing/pricing_config_service.go b/service/pricing/pricing_config_service.go new file mode 100644 index 0000000..82ae07a --- /dev/null +++ b/service/pricing/pricing_config_service.go @@ -0,0 +1,166 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" + + pricingConsts "shop-user-trade/consts/pricing" + chargeDao "shop-user-trade/dao/pricing" + pricingDto "shop-user-trade/model/dto/pricing" + pricingEntity "shop-user-trade/model/entity/pricing" +) + +// ====================== 配置管理 ====================== + +// SavePricingConfig 新增/更新计价配置(费率全 DB 配置;按 subject 类型校验 rules 结构,防止脏配置上线后结算报错) +func (s *pricing) SavePricingConfig(ctx context.Context, req *pricingDto.SavePricingConfigReq) (*pricingDto.SavePricingConfigRes, error) { + subjectType := pricingConsts.SubjectType(req.SubjectType) + rulesBytes, marshalErr := json.Marshal(req.Rules) + if marshalErr != nil { + return nil, fmt.Errorf("费率JSON序列化失败: %v", marshalErr) + } + if err := s.validateConfigRules(subjectType, string(rulesBytes)); err != nil { + return nil, err + } + if req.Currency == "" { + req.Currency = "CNY" + } + if req.Id == 0 && g.IsEmpty(req.Enabled) { + req.Enabled = new(1) + } + var id int64 + var err error + if req.Id > 0 { + // Update 返回受影响行数;配置 ID 即 req.Id + if _, err = chargeDao.PricingConfig.Update(ctx, req); err != nil { + return nil, err + } + id = req.Id + } else { + id, err = chargeDao.PricingConfig.Insert(ctx, req) + if err != nil { + return nil, err + } + } + return &pricingDto.SavePricingConfigRes{Id: id}, nil +} + +// GetPricingConfig 查询计价配置 +func (s *pricing) GetPricingConfig(ctx context.Context, req *pricingDto.GetPricingConfigReq) (res *pricingDto.GetPricingConfigRes, err error) { + cfg, err := chargeDao.PricingConfig.Get(ctx, req) + if err != nil { + return nil, err + } + if cfg == nil { + return nil, ErrConfigNotFound + } + res = new(pricingDto.GetPricingConfigRes) + if err = gconv.Struct(cfg, res); err != nil { + return + } + return +} + +// validateConfigRules 按 subject 类型校验 rules JSON(spec §4): +// +// workflow:三模式容器逐个校验已配模式;model:先解析 unit 再用对应计算器;business:per_period 计算器 +func (s *pricing) validateConfigRules(subjectType pricingConsts.SubjectType, rulesJSON string) error { + switch subjectType { + case pricingConsts.SubjectTypeWorkflow: + return s.validateWorkflowRules(rulesJSON) + case pricingConsts.SubjectTypeModel: + var cfg struct { + Unit pricingConsts.ChargeMode `json:"unit"` + } + if err := json.Unmarshal([]byte(rulesJSON), &cfg); err != nil { + return fmt.Errorf("model 费率JSON解析失败: %v", err) + } + c, err := getCalculator(subjectType, cfg.Unit) + if err != nil { + return err + } + _, err = c.validate(rulesJSON) + return err + case pricingConsts.SubjectTypeBusiness: + c, err := getCalculator(subjectType, pricingConsts.ChargeModePerPeriod) + if err != nil { + return err + } + _, err = c.validate(rulesJSON) + return err + default: + return errors.New("未知计价对象类型: " + string(subjectType)) + } +} + +// validateWorkflowRules 校验三模式容器:逐个校验已配模式(spec §4.1) +func (s *pricing) validateWorkflowRules(rulesJSON string) error { + var container map[string]json.RawMessage + if err := json.Unmarshal([]byte(rulesJSON), &container); err != nil { + return fmt.Errorf("workflow 费率JSON解析失败: %v", err) + } + if len(container) == 0 { + return errors.New("workflow 费率至少配置一种模式") + } + for mode, raw := range container { + chargeMode := pricingConsts.ChargeMode(mode) + c, err := getCalculator(pricingConsts.SubjectTypeWorkflow, chargeMode) + if err != nil { + return err + } + if _, err = c.validate(string(raw)); err != nil { + return fmt.Errorf("workflow %s 模式校验失败: %v", mode, err) + } + } + return nil +} + +// getEnabledConfig 获取启用中的计价配置 +func (s *pricing) getEnabledConfig(ctx context.Context, subjectType pricingConsts.SubjectType, subjectID string) (*pricingEntity.PricingConfig, error) { + cfg, err := chargeDao.PricingConfig.Get(ctx, &pricingDto.GetPricingConfigReq{SubjectType: string(subjectType), SubjectID: subjectID}) + if err != nil { + return nil, err + } + if cfg == nil { + return nil, ErrConfigNotFound + } + if cfg.Enabled != 1 { + return nil, errors.New("计价配置未启用: " + subjectID) + } + return cfg, nil +} + +// resolveChargeMode 建单时解析实际计费方式与规则快照(spec §6 OpenOrder 校验): +// +// workflow:chargeMode 必填 ∈ rules 已配模式;快照 = rules[chargeMode] +// model:chargeMode 可空 = config.unit;快照 = 整个 modelCfg +// business:chargeMode 可空 = per_period;快照 = 周期规则 +func (s *pricing) resolveChargeMode(ctx context.Context, cfg *pricingEntity.PricingConfig, req *pricingDto.OpenOrderReq) (pricingConsts.ChargeMode, map[string]any, error) { + switch cfg.SubjectType { + case pricingConsts.SubjectTypeWorkflow: + if req.ChargeMode == "" { + return "", nil, errors.New("workflow 建单须指定 chargeMode") + } + return pricingConsts.ChargeMode(req.ChargeMode), s.workflowRuleSnapshot(cfg.Rules, req.ChargeMode), nil + case pricingConsts.SubjectTypeModel: + // model 计费方式固定取配置 unit(cfg.Unit() 见 entity);快照 = 整个 rules 对象 + return cfg.Unit(), cfg.Rules, nil + case pricingConsts.SubjectTypeBusiness: + return pricingConsts.ChargeModePerPeriod, cfg.Rules, nil + default: + return "", nil, errors.New("未知计价对象类型: " + string(cfg.SubjectType)) + } +} + +// workflowRuleSnapshot 从三模式容器取选中模式的规则片段对象(spec §4.1) +func (s *pricing) workflowRuleSnapshot(rules map[string]any, mode string) map[string]any { + if snapshot, ok := rules[mode].(map[string]any); ok { + return snapshot + } + return nil +} diff --git a/service/pricing/pricing_service.go b/service/pricing/pricing_service.go index 8fab8c0..8664923 100644 --- a/service/pricing/pricing_service.go +++ b/service/pricing/pricing_service.go @@ -8,10 +8,12 @@ import ( "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/gconv" "gitea.redpowerfuture.com/red-future/common/db/gfdb" pricingConsts "shop-user-trade/consts/pricing" + "shop-user-trade/consts/public" walletConsts "shop-user-trade/consts/wallet" chargeDao "shop-user-trade/dao/pricing" walletDao "shop-user-trade/dao/wallet" @@ -28,7 +30,7 @@ var ( ErrConfigNotFound = errors.New("计价配置不存在") ) -// pricing 计价服务:算钱(定价+计费单生命周期+门禁)。 +// Pricing pricing 计价服务:算钱(定价+计费单生命周期+门禁)。 // 职责:建单(不动钱)→ 结算/取消(实收)→ 失败(不扣费)。 // 锁经 wallet service(common 统一锁),账务经钱包 dao 的 Change 原语; // 钱包与计费单状态在同一事务内原子更新,不直接操作钱包表。 @@ -67,7 +69,11 @@ func (s *pricing) OpenOrder(ctx context.Context, req *pricingDto.OpenOrderReq) ( if err != nil { return nil, err } - if _, err = c.validate(ruleSnapshot); err != nil { + ruleJSON, err := json.Marshal(ruleSnapshot) + if err != nil { + return nil, fmt.Errorf("费率快照序列化失败: %v", err) + } + if _, err = c.validate(string(ruleJSON)); err != nil { return nil, err } @@ -151,7 +157,7 @@ func (s *pricing) Cancel(ctx context.Context, req *pricingDto.CancelReq) (*prici // settleOrder 核心结算:按 rule_snapshot 计价实收(允许负余额),状态 CREATED → SETTLED。 // 钱包写入在独立 DB group,无法随 default 事务回滚;故持用户锁内权威重读状态为 CREATED 才扣费, // 条件状态迁移(CAS)作最后防线。重复结算返回既有结果。 -func (s *pricing) settleOrder(ctx context.Context, order *pricingEntity.ChargeOrder, usageJSON, desc string) (float64, error) { +func (s *pricing) settleOrder(ctx context.Context, order *pricingEntity.ChargeOrder, usage map[string]any, desc string) (float64, error) { // 快路径(非权威):锁外状态仅供提前返回,扣费与否以锁内重读为准 if order.Status == pricingConsts.ChargeOrderStatusSettled { return order.ActualAmount, nil @@ -160,17 +166,31 @@ func (s *pricing) settleOrder(ctx context.Context, order *pricingEntity.ChargeOr return 0, errors.New("计费单状态不可结算(已失败或状态异常)") } - var usage ChargeUsage - if err := json.Unmarshal([]byte(usageJSON), &usage); err != nil { - return 0, fmt.Errorf("用量JSON解析失败: %v", err) + var usageStruct ChargeUsage + if err := gconv.Struct(usage, &usageStruct); err != nil { + return 0, fmt.Errorf("用量解析失败: %v", err) } - // 用建单时费率快照计价(防改价影响在途单);per_token 特殊:按各模型 subject 实时价(spec §5.4) + // 用建单时费率快照计价(防改价影响在途单);per_token 无快照(槽位 {}),按调用方上报的 + // 「按次已记录费用」(feeByModel)合计实收——每次模型调用在发生时已由 shop /calc 计价 + // (calculator.charge 内部 ceilFen,「不足1分按1分」按调用次生效 + 调用时媒体/费率快照), + // 直接合计即实收,不再按聚合 token 重算(两笔各 0.01 合并重算成 0.01 的误收根因)。 + // 调用方原始上报的用量整份落库 order.usage 作审计(本处 gconv 解析只取计价字段,未知键忽略,不影响落库)。 var actualAmount float64 var err error if order.SubjectType == pricingConsts.SubjectTypeWorkflow && order.ChargeMode == pricingConsts.ChargeModePerToken { - actualAmount, err = s.calcPerTokenOrder(ctx, &usage) + actualAmount = sumRecordedFee(usageStruct.FeeByModel) } else { - actualAmount, err = calcCharge(order.SubjectType, order.ChargeMode, order.RuleSnapshot, &usage) + var ruleJSON []byte + ruleJSON, err = json.Marshal(order.RuleSnapshot) + if err == nil { + actualAmount, err = calcCharge(order.SubjectType, order.ChargeMode, string(ruleJSON), &usageStruct) + } + // per_item / per_second 取消补收:中途取消通常无视频时长产出(时长计价=0),但已完成节点可能已消耗 + // token。调用方在 cancel 场景附 feeByModel(不含视频节点,其消耗由时长计价覆盖),与时长计价相加; + // 正常结算调用方不附费用拆分 → 仅时长,行为不变。 + if err == nil && order.SubjectType == pricingConsts.SubjectTypeWorkflow && len(usageStruct.FeeByModel) > 0 { + actualAmount = ceilFen(roundCost(actualAmount + sumRecordedFee(usageStruct.FeeByModel))) + } } if err != nil { return 0, err @@ -180,8 +200,9 @@ func (s *pricing) settleOrder(ctx context.Context, order *pricingEntity.ChargeOr var settledAmount float64 err = walletService.Account.WithUserLock(ctx, order.UserId, func(ctx context.Context) error { // 锁内权威重读:并发 Settle/Cancel 经同一用户锁串行,锁内状态才是最终判定。 - // 钱包写入在独立 DB group,无法随 default 事务回滚;故在扣费前先锁内确认状态仍为 CREATED, - // 防止「先扣款、后 CAS 失败」的并发重复扣费。 + // 钱包 account 与计费单同处 wallet DB group,事务须开在 wallet 组(gfdb.DB(ctx) 无参 = default 组, + // 而 shop-user-trade 的 default 组指向 shop_user_trade 库、无钱包表,会在 BEGIN 即失败), + // 钱包扣款与订单状态迁移才能在同一事务内原子生效。 fresh, err := chargeDao.ChargeOrder.GetOrder(ctx, &pricingDto.GetChargeOrderReq{Id: order.Id}) if err != nil { return err @@ -197,7 +218,7 @@ func (s *pricing) settleOrder(ctx context.Context, order *pricingEntity.ChargeOr if fresh.Status != pricingConsts.ChargeOrderStatusCreated { return errors.New("计费单状态不可结算(已失败或状态异常)") } - return gfdb.DB(ctx).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + return gfdb.DB(ctx, public.DbNameWallet).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { // 实收扣款(允许负余额=欠费,靠充值归正,见技术设计 §9)。 if err := walletDao.Account.Change(ctx, &walletDto.ChangeAccountReq{ UserId: order.UserId, @@ -213,7 +234,7 @@ func (s *pricing) settleOrder(ctx context.Context, order *pricingEntity.ChargeOr ok, err := chargeDao.ChargeOrder.UpdateToSettled(ctx, &pricingDto.SettleChargeOrderReq{ Id: order.Id, ActualAmount: actualAmount, - Usage: usageJSON, + Usage: usage, RuleSnapshot: order.RuleSnapshot, SettleTime: gtime.Now(), }) @@ -232,47 +253,27 @@ func (s *pricing) settleOrder(ctx context.Context, order *pricingEntity.ChargeOr if alreadySettled { order.Status = pricingConsts.ChargeOrderStatusSettled order.ActualAmount = settledAmount - order.Usage = usageJSON + order.Usage = usage return settledAmount, nil } order.Status = pricingConsts.ChargeOrderStatusSettled order.ActualAmount = actualAmount - order.Usage = usageJSON + order.Usage = usage return actualAmount, nil } -// calcPerTokenOrder 工作流 per_token 结算:按 usage.TokensByModel 逐模型查该模型 subject 配置计价累加。 -// 未配价/未启用模型按 0 计(不收费、不报错);费率不冻结,结算按模型实时价(spec §5.4)。 -func (s *pricing) calcPerTokenOrder(ctx context.Context, usage *ChargeUsage) (float64, error) { +// sumRecordedFee 按次已记录费用合计:feeByModel 各值为调用方按次调 shop /calc 的返回费 +// (calculator.charge 内部已 ceilFen 到分),直接相加再归一化到分(去浮点噪声)。 +// 负值防御性忽略(异常上报不抵减)。per_item/per_second 取消补收与 per_token 结算的 token 金额据此实收。 +func sumRecordedFee(feeByModel map[string]float64) float64 { var total float64 - for modelID, tokens := range usage.TokensByModel { - if tokens <= 0 { + for _, fee := range feeByModel { + if fee < 0 { continue } - cfg, err := chargeDao.PricingConfig.Get(ctx, &pricingDto.GetConfigReq{ - SubjectType: string(pricingConsts.SubjectTypeModel), SubjectID: modelID, - }) - if err != nil { - return 0, err - } - if cfg == nil || cfg.Enabled != 1 { - continue // 未配价模型按 0 计 - } - c, err := getCalculator(cfg.SubjectType, cfg.Unit()) - if err != nil { - return 0, err - } - rules, err := c.validate(cfg.Rules) - if err != nil { - return 0, err - } - amount, err := c.charge(rules, &ChargeUsage{PromptTokens: tokens, CompletionTokens: 0, CachedTokens: 0}) - if err != nil { - return 0, err - } - total += amount + total += fee } - return ceilFen(total), nil + return ceilFen(roundCost(total)) } // ====================== 失败(不扣费) ====================== @@ -324,185 +325,58 @@ func (s *pricing) Fail(ctx context.Context, req *pricingDto.FailReq) (*pricingDt return &info, nil } -// ====================== 查询 ====================== +// ====================== 算费(不建单不扣费) ====================== -// GetOrder 查询计费单(按ID或 subject_type + subject_id + biz_order_no) -func (s *pricing) GetOrder(ctx context.Context, req *pricingDto.GetOrderReq) (*pricingDto.ChargeOrderInfo, error) { - order, err := s.resolveOrder(ctx, req.OrderId, req.SubjectType, req.SubjectID, req.BizOrderNo) +// CalcFee 按计价配置与用量计算费用(不建单不扣费)。未配置/未启用 → 报错(model-gateway 据此阻塞调用)。 +// model subject:chargeMode 可空 = 配置 unit;快照 = 整个 rules。复用 getEnabledConfig + resolveChargeMode + 计算器。 +func (s *pricing) CalcFee(ctx context.Context, req *pricingDto.CalcFeeReq) (*pricingDto.CalcFeeRes, error) { + cfg, err := s.getEnabledConfig(ctx, pricingConsts.SubjectType(req.SubjectType), req.SubjectID) if err != nil { return nil, err } - if order == nil { - return nil, errors.New("计费单不存在") + chargeMode, ruleSnapshot, err := s.resolveChargeMode(ctx, cfg, &pricingDto.OpenOrderReq{ + SubjectType: req.SubjectType, SubjectID: req.SubjectID, ChargeMode: req.ChargeMode, + }) + if err != nil { + return nil, err + } + var usage ChargeUsage + if err := gconv.Struct(req.Usage, &usage); err != nil { + return nil, fmt.Errorf("用量解析失败: %v", err) + } + c, err := getCalculator(cfg.SubjectType, chargeMode) + if err != nil { + return nil, err + } + ruleJSON, err := json.Marshal(ruleSnapshot) + if err != nil { + return nil, fmt.Errorf("费率快照序列化失败: %v", err) + } + rules, err := c.validate(string(ruleJSON)) + if err != nil { + return nil, err + } + cost, err := c.charge(rules, &usage) + if err != nil { + return nil, err + } + return &pricingDto.CalcFeeRes{Cost: cost}, nil +} + +// ====================== 查询 ====================== + +// GetOrderInfo 查询计费单(复用 resolveOrder + toOrderInfo;工作流结算前取 CreatedAt 算时长) +func (s *pricing) GetOrderInfo(ctx context.Context, req *pricingDto.GetOrderInfoReq) (*pricingDto.ChargeOrderInfo, error) { + order, err := s.resolveOrder(ctx, req.Id, req.SubjectType, req.SubjectID, req.BizOrderNo) + if err != nil { + return nil, err } info := s.toOrderInfo(order) return &info, nil } -// ListOrders 分页查询某用户的计费单 -func (s *pricing) ListOrders(ctx context.Context, req *pricingDto.ListOrdersReq) (*pricingDto.ChargeOrderList, error) { - list, total, err := chargeDao.ChargeOrder.ListByUser(ctx, req) - if err != nil { - return nil, err - } - resp := &pricingDto.ChargeOrderList{Total: total} - for i := range list { - resp.Orders = append(resp.Orders, s.toOrderInfo(&list[i])) - } - return resp, nil -} - -// ====================== 配置管理 ====================== - -// SaveConfig 新增/更新计价配置(费率全 DB 配置;按 subject 类型校验 rules 结构,防止脏配置上线后结算报错) -func (s *pricing) SaveConfig(ctx context.Context, req *pricingDto.SaveConfigReq) (*pricingDto.SaveConfigData, error) { - subjectType := pricingConsts.SubjectType(req.SubjectType) - if err := s.validateConfigRules(subjectType, req.Rules); err != nil { - return nil, err - } - if req.Currency == "" { - req.Currency = "CNY" - } - if req.Id == 0 && req.Enabled == 0 { - req.Enabled = 1 - } - id, err := chargeDao.PricingConfig.Save(ctx, req) - if err != nil { - return nil, err - } - return &pricingDto.SaveConfigData{Id: id}, nil -} - -// validateConfigRules 按 subject 类型校验 rules JSON(spec §4): -// workflow:三模式容器逐个校验已配模式;model:先解析 unit 再用对应计算器;business:per_period 计算器 -func (s *pricing) validateConfigRules(subjectType pricingConsts.SubjectType, rulesJSON string) error { - switch subjectType { - case pricingConsts.SubjectTypeWorkflow: - return s.validateWorkflowRules(rulesJSON) - case pricingConsts.SubjectTypeModel: - var cfg struct { - Unit pricingConsts.ChargeMode `json:"unit"` - } - if err := json.Unmarshal([]byte(rulesJSON), &cfg); err != nil { - return fmt.Errorf("model 费率JSON解析失败: %v", err) - } - c, err := getCalculator(subjectType, cfg.Unit) - if err != nil { - return err - } - _, err = c.validate(rulesJSON) - return err - case pricingConsts.SubjectTypeBusiness: - c, err := getCalculator(subjectType, pricingConsts.ChargeModePerPeriod) - if err != nil { - return err - } - _, err = c.validate(rulesJSON) - return err - default: - return errors.New("未知计价对象类型: " + string(subjectType)) - } -} - -// validateWorkflowRules 校验三模式容器:逐个校验已配模式(spec §4.1) -func (s *pricing) validateWorkflowRules(rulesJSON string) error { - var container map[string]json.RawMessage - if err := json.Unmarshal([]byte(rulesJSON), &container); err != nil { - return fmt.Errorf("workflow 费率JSON解析失败: %v", err) - } - if len(container) == 0 { - return errors.New("workflow 费率至少配置一种模式") - } - for mode, raw := range container { - chargeMode := pricingConsts.ChargeMode(mode) - c, err := getCalculator(pricingConsts.SubjectTypeWorkflow, chargeMode) - if err != nil { - return err - } - if _, err = c.validate(string(raw)); err != nil { - return fmt.Errorf("workflow %s 模式校验失败: %v", mode, err) - } - } - return nil -} - -// GetConfig 查询计价配置 -func (s *pricing) GetConfig(ctx context.Context, req *pricingDto.GetConfigReq) (*pricingDto.PricingConfigInfo, error) { - cfg, err := chargeDao.PricingConfig.Get(ctx, req) - if err != nil { - return nil, err - } - if cfg == nil { - return nil, ErrConfigNotFound - } - info := s.toConfigInfo(cfg) - return &info, nil -} - -// ListConfigs 分页查询计价配置 -func (s *pricing) ListConfigs(ctx context.Context, req *pricingDto.ListConfigsReq) (*pricingDto.PricingConfigList, error) { - list, total, err := chargeDao.PricingConfig.List(ctx, req) - if err != nil { - return nil, err - } - resp := &pricingDto.PricingConfigList{Total: total} - for i := range list { - resp.Configs = append(resp.Configs, s.toConfigInfo(&list[i])) - } - return resp, nil -} - // ====================== 内部辅助 ====================== -// getEnabledConfig 获取启用中的计价配置 -func (s *pricing) getEnabledConfig(ctx context.Context, subjectType pricingConsts.SubjectType, subjectID string) (*pricingEntity.PricingConfig, error) { - cfg, err := chargeDao.PricingConfig.Get(ctx, &pricingDto.GetConfigReq{SubjectType: string(subjectType), SubjectID: subjectID}) - if err != nil { - return nil, err - } - if cfg == nil { - return nil, ErrConfigNotFound - } - if cfg.Enabled != 1 { - return nil, errors.New("计价配置未启用: " + subjectID) - } - return cfg, nil -} - -// resolveChargeMode 建单时解析实际计费方式与规则快照(spec §6 OpenOrder 校验): -// workflow:chargeMode 必填 ∈ rules 已配模式;快照 = rules[chargeMode] -// model:chargeMode 可空 = config.unit;快照 = 整个 modelCfg -// business:chargeMode 可空 = per_period;快照 = 周期规则 -func (s *pricing) resolveChargeMode(ctx context.Context, cfg *pricingEntity.PricingConfig, req *pricingDto.OpenOrderReq) (pricingConsts.ChargeMode, string, error) { - switch cfg.SubjectType { - case pricingConsts.SubjectTypeWorkflow: - if req.ChargeMode == "" { - return "", "", errors.New("workflow 建单须指定 chargeMode") - } - return pricingConsts.ChargeMode(req.ChargeMode), s.workflowRuleSnapshot(cfg.Rules, req.ChargeMode), nil - case pricingConsts.SubjectTypeModel: - // model 计费方式固定取配置 unit(cfg.Unit() 见 entity,Task 3 定义) - return cfg.Unit(), cfg.Rules, nil - case pricingConsts.SubjectTypeBusiness: - return pricingConsts.ChargeModePerPeriod, cfg.Rules, nil - default: - return "", "", errors.New("未知计价对象类型: " + string(cfg.SubjectType)) - } -} - -// workflowRuleSnapshot 从三模式容器取选中模式的规则片段(spec §4.1) -func (s *pricing) workflowRuleSnapshot(rulesJSON, mode string) string { - var container map[string]json.RawMessage - if err := json.Unmarshal([]byte(rulesJSON), &container); err != nil { - return rulesJSON - } - raw, ok := container[mode] - if !ok { - return "" - } - return string(raw) -} - // resolveOrder 按ID或 subject_type+subject_id+biz_order_no 定位计费单 func (s *pricing) resolveOrder(ctx context.Context, orderId int64, subjectType, subjectID, bizOrderNo string) (*pricingEntity.ChargeOrder, error) { if orderId > 0 { @@ -550,24 +424,3 @@ func (s *pricing) toOrderInfo(e *pricingEntity.ChargeOrder) pricingDto.ChargeOrd } return info } - -// toConfigInfo 计价配置实体转响应 -func (s *pricing) toConfigInfo(e *pricingEntity.PricingConfig) pricingDto.PricingConfigInfo { - info := pricingDto.PricingConfigInfo{ - ID: e.Id, - SubjectType: string(e.SubjectType), - SubjectID: e.SubjectID, - Rules: e.Rules, - MinBalance: e.MinBalance, - Currency: e.Currency, - Enabled: e.Enabled, - Version: e.Version, - } - if e.CreatedAt != nil { - info.CreatedAt = e.CreatedAt.String() - } - if e.UpdatedAt != nil { - info.UpdatedAt = e.UpdatedAt.String() - } - return info -} diff --git a/service/pricing/subject_service.go b/service/pricing/subject_service.go index 7275a03..7759e03 100644 --- a/service/pricing/subject_service.go +++ b/service/pricing/subject_service.go @@ -30,14 +30,20 @@ func (s *pricing) ListSubjects(ctx context.Context, req *pricingDto.SubjectListR SubjectType: string(pricingConsts.SubjectTypeWorkflow), SubjectID: pricingConsts.WorkflowSubjectID, Name: "工作流", + ChargeModes: []string{string(pricingConsts.ChargeModePerItem), string(pricingConsts.ChargeModePerSecond), string(pricingConsts.ChargeModePerToken)}, }) // 2. 业务模块(固定写死) for _, b := range pricingConsts.BusinessSubjects { + modes := make([]string, 0, len(b.ChargeModes)) + for _, m := range b.ChargeModes { + modes = append(modes, string(m)) + } res.Subjects = append(res.Subjects, pricingDto.SubjectInfo{ SubjectType: string(pricingConsts.SubjectTypeBusiness), SubjectID: b.SubjectID, Name: b.Name, + ChargeModes: modes, }) } diff --git a/service/pricing/subject_service_test.go b/service/pricing/subject_service_test.go deleted file mode 100644 index a704998..0000000 --- a/service/pricing/subject_service_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package service - -import ( - "context" - "testing" - - pricingConsts "shop-user-trade/consts/pricing" - pricingDto "shop-user-trade/model/dto/pricing" -) - -// 纯常量逻辑,无 DB 依赖(service 包无 init,直接调 Pricing 单例) -func TestListChargeModesWorkflow(t *testing.T) { - res, err := Pricing.ListChargeModes(context.Background(), &pricingDto.ChargeModeListReq{ - SubjectType: string(pricingConsts.SubjectTypeWorkflow), - SubjectID: pricingConsts.WorkflowSubjectID, - }) - if err != nil { - t.Fatalf("err: %v", err) - } - if res.SubjectType != "workflow" || len(res.ChargeModes) != 3 { - t.Fatalf("got %+v", res) - } - want := []string{"per_item", "per_second", "per_token"} - for i, m := range res.ChargeModes { - if m.Mode != want[i] { - t.Fatalf("mode[%d]=%s want %s", i, m.Mode, want[i]) - } - if m.Name == "" { - t.Fatalf("mode %s 缺中文名", m.Mode) - } - } -} - -func TestListChargeModesWorkflowWrongSubjectID(t *testing.T) { - if _, err := Pricing.ListChargeModes(context.Background(), &pricingDto.ChargeModeListReq{ - SubjectType: "workflow", SubjectID: "not-workflow", - }); err == nil { - t.Fatal("expected error for wrong workflow subjectId") - } -} - -func TestListChargeModesBusiness(t *testing.T) { - res, err := Pricing.ListChargeModes(context.Background(), &pricingDto.ChargeModeListReq{ - SubjectType: "business", SubjectID: "ai_customer_service", - }) - if err != nil { - t.Fatalf("err: %v", err) - } - if len(res.ChargeModes) != 1 || res.ChargeModes[0].Mode != "per_period" { - t.Fatalf("got %+v", res) - } - if res.ChargeModes[0].Name != "周期订阅" { - t.Fatalf("name=%s want 周期订阅", res.ChargeModes[0].Name) - } -} - -func TestListChargeModesBusinessUnknown(t *testing.T) { - if _, err := Pricing.ListChargeModes(context.Background(), &pricingDto.ChargeModeListReq{ - SubjectType: "business", SubjectID: "nope", - }); err == nil { - t.Fatal("expected unknown business error") - } -} diff --git a/service/wallet/account_service.go b/service/wallet/account_service.go new file mode 100644 index 0000000..006baf0 --- /dev/null +++ b/service/wallet/account_service.go @@ -0,0 +1,128 @@ +package service + +import ( + "context" + "errors" + "fmt" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" + + "gitea.redpowerfuture.com/red-future/common/db/gfdb" + "gitea.redpowerfuture.com/red-future/common/utils" + + walletConsts "shop-user-trade/consts/wallet" + walletDao "shop-user-trade/dao/wallet" + walletDto "shop-user-trade/model/dto/wallet" + walletEntity "shop-user-trade/model/entity/wallet" +) + +// Account 钱包服务:管钱(钱包账务 + 流水)。 +// 事务与 Redis 锁统一在本层编排:Recharge 持 common 锁 + 单事务原子完成; +// pricing 结算复用本层 WithUserLock 编排「钱包账务 + 计费单状态」。 +// DAO 只提供事务透明的数据原语(Change),不加锁、不开事务、不互相调用。 +var Account = new(account) + +type account struct{} + +const ( + walletLockTTL = 30 // 钱包 Redis 锁 TTL(秒);锁串行化跨节点互斥,DB 事务 + 行锁兜底正确性 + walletLockRetry = 20 // 抢锁最多尝试次数(每次间隔 500ms,≈10s),避免无限阻塞 +) + +var ( + // ErrWalletNotFound 钱包不存在(service 层业务错误;DAO 不定义业务错误,见分层规范) + ErrWalletNotFound = errors.New("钱包不存在") + // ErrWalletDisabled 钱包状态不可用 + ErrWalletDisabled = errors.New("钱包状态不可用") +) + +// GetAccountByUserId 根据用户ID获取钱包信息 +func (s *account) GetAccountByUserId(ctx context.Context, req *walletDto.GetAccountByUserIdReq) (*walletDto.AccountInfo, error) { + w, err := walletDao.Account.Get(ctx, req) + if err != nil { + g.Log().Errorf(ctx, "获取用户钱包失败, userId: %d, error: %v", req.UserId, err) + return nil, err + } + if w == nil { + return nil, ErrWalletNotFound + } + return &walletDto.AccountInfo{ + ID: w.Id, + UserID: w.UserID, + Balance: w.Balance, + Currency: w.Currency, + Status: int(w.Status), + }, nil +} + +// WithUserLock 持用户钱包 Redis 锁执行 fn(跨节点串行化单钱包互斥)。 +// 锁用 common 统一实现 utils.WithLock(uuid token + SET NX EX + WATCH/MULTI 归属释放 + 自动续期)。 +// 事务由调用方自行开启(gfdb.DB(ctx).Transaction),账务原语在事务内自动继承,本方法不开事务。 +func (s *account) WithUserLock(ctx context.Context, userID int64, fn func(ctx context.Context) error) error { + _, err := utils.WithLock(ctx, fmt.Sprintf("wallet:lock:%d", userID), walletLockTTL, fn, walletLockRetry) + return err +} + +// Recharge 充值(钱包不存在则自动创建)。 +// 事务在 service 层编排:持钱包锁 → 单事务内「建钱包 + income 流水 + 余额增加」原子完成。 +func (s *account) Recharge(ctx context.Context, req *walletDto.RechargeReq) error { + if req.Amount <= 0 { + return errors.New("充值金额必须大于 0") + } + currency := req.Currency + if currency == "" { + currency = "CNY" + } + err := s.WithUserLock(ctx, req.UserId, func(ctx context.Context) error { + return gfdb.DB(ctx).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + w, err := walletDao.Account.GetForUpdate(ctx, &walletDto.GetAccountByUserIdReq{UserId: req.UserId}) + if err != nil { + return err + } + if w == nil { + if _, err = walletDao.Account.Create(ctx, &walletDto.CreateAccountReq{UserId: req.UserId, UserName: req.UserName, Status: 1, Currency: currency}); err != nil { + return err + } + } + return walletDao.Account.Change(ctx, &walletDto.ChangeAccountReq{ + UserId: req.UserId, + DeltaBalance: req.Amount, + Type: walletConsts.WalletLogTypeIncome, + Amount: req.Amount, + OrderNo: req.OrderNo, + Description: req.Description, + }) + }) + }) + if err != nil { + return err + } + return nil +} + +// GetWalletLogs 分页获取钱包流水 +func (s *account) GetWalletLogs(ctx context.Context, req *walletDto.GetAccountLogsReq) (*walletDto.AccountLogData, error) { + logs, total, err := walletDao.Account.ListLogs(ctx, req) + if err != nil { + return nil, err + } + var infos []walletDto.AccountLogInfo + for i := range logs { + infos = append(infos, s.logEntityToInfo(&logs[i])) + } + return &walletDto.AccountLogData{Logs: infos, Total: total}, nil +} + +// logEntityToInfo 流水实体转Info +func (s *account) logEntityToInfo(e *walletEntity.AccountLog) walletDto.AccountLogInfo { + info := walletDto.AccountLogInfo{} + _ = gconv.Struct(e, &info) + info.ID = e.Id + info.Type = string(e.Type) + if e.CreatedAt != nil { + info.CreatedAt = e.CreatedAt.String() + } + return info +} diff --git a/service/wallet/wallet_service.go b/service/wallet/wallet_service.go deleted file mode 100644 index 6201fe6..0000000 --- a/service/wallet/wallet_service.go +++ /dev/null @@ -1,153 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/util/gconv" - - walletDao "shop-user-trade/dao/wallet" - walletDto "shop-user-trade/model/dto/wallet" - walletEntity "shop-user-trade/model/entity/wallet" -) - -type wallet struct{} - -// Wallet 钱包服务 -var Wallet = new(wallet) - -// GetByUserId 根据用户ID获取钱包 -func (s *wallet) GetByUserId(ctx context.Context, req *walletDto.GetWalletByUserIdReq) (*walletDto.GetWalletByUserIdResp, error) { - w, err := walletDao.Wallet.GetByUserID(ctx, req.UserId) - if err != nil { - g.Log().Errorf(ctx, "获取用户钱包失败, userId: %d, error: %v", req.UserId, err) - return nil, err - } - if w == nil { - return nil, errors.New("钱包不存在") - } - return &walletDto.GetWalletByUserIdResp{ - Data: walletDto.WalletInfo{ - ID: w.Id, - UserID: w.UserID, - Balance: w.Balance, - Currency: w.Currency, - Status: int(w.Status), - }, - }, nil -} - -// Create 创建钱包 -func (s *wallet) Create(ctx context.Context, req *walletDto.CreateWalletReq) (*walletDto.CreateWalletResp, error) { - // 检查钱包是否已存在 - existing, err := walletDao.Wallet.GetByUserID(ctx, req.UserId) - if err != nil { - g.Log().Errorf(ctx, "检查用户钱包失败, userId: %d, error: %v", req.UserId, err) - return nil, err - } - if existing != nil { - return nil, errors.New("钱包已存在") - } - id, err := walletDao.Wallet.Create(ctx, req) - if err != nil { - g.Log().Errorf(ctx, "创建钱包失败, userId: %d, error: %v", req.UserId, err) - return nil, err - } - return &walletDto.CreateWalletResp{ - Data: walletDto.CreateWalletData{WalletID: id}, - }, nil -} - -// UpdateBalance 更新余额 -func (s *wallet) UpdateBalance(ctx context.Context, req *walletDto.UpdateBalanceReq) (*walletDto.UpdateBalanceResp, error) { - w, err := walletDao.Wallet.GetByID(ctx, req.WalletID) - if err != nil { - g.Log().Errorf(ctx, "获取钱包失败, walletId: %d, error: %v", req.WalletID, err) - return nil, err - } - if w == nil { - return nil, errors.New("钱包不存在") - } - - // 检查余额是否充足 - if req.Type == "expense" && w.Balance < req.Amount { - return nil, errors.New("余额不足") - } - - // 记录操作前余额 - balanceBefore := w.Balance - var balanceAfter int64 - switch req.Type { - case "income": - balanceAfter = balanceBefore + req.Amount - case "expense": - balanceAfter = balanceBefore - req.Amount - default: - balanceAfter = balanceBefore - } - - // 更新余额(乐观锁) - success, err := walletDao.Wallet.UpdateBalance(ctx, req.WalletID, req.Amount, req.Type, w.Version) - if err != nil { - g.Log().Errorf(ctx, "更新余额失败, walletId: %d, error: %v", req.WalletID, err) - return nil, err - } - if !success { - return nil, errors.New("余额更新失败,请重试") - } - - // 记录流水日志 - logReq := &walletDto.CreateWalletLogReq{ - UserID: w.UserID, - WalletID: w.Id, - OrderNo: req.OrderNo, - TransactionNo: req.TransactionNo, - Type: req.Type, - Amount: req.Amount, - BalanceBefore: balanceBefore, - BalanceAfter: balanceAfter, - Currency: w.Currency, - Description: req.Description, - ExtraData: req.ExtraData, - } - if _, err = walletDao.Wallet.CreateLog(ctx, logReq); err != nil { - g.Log().Warningf(ctx, "记录钱包日志失败, walletId: %d, error: %v", req.WalletID, err) - } - - return &walletDto.UpdateBalanceResp{ - Success: true, - Message: "更新成功", - }, nil -} - -// GetWalletLogs 获取钱包日志 -func (s *wallet) GetWalletLogs(ctx context.Context, req *walletDto.GetWalletLogsReq) (*walletDto.GetWalletLogsResp, error) { - logs, total, err := walletDao.Wallet.ListLogs(ctx, req.UserId, req.Page, req.PageSize) - if err != nil { - return nil, fmt.Errorf("获取钱包日志失败: %w", err) - } - var logInfos []walletDto.WalletLogInfo - for _, log := range logs { - logInfos = append(logInfos, s.logEntityToInfo(&log)) - } - return &walletDto.GetWalletLogsResp{ - Data: walletDto.WalletLogData{ - Logs: logInfos, - Total: total, - }, - }, nil -} - -// logEntityToInfo 日志实体转换为Info -func (s *wallet) logEntityToInfo(e *walletEntity.WalletLog) walletDto.WalletLogInfo { - info := walletDto.WalletLogInfo{} - _ = gconv.Struct(e, &info) - info.ID = e.Id - info.Type = string(e.Type) - if e.CreatedAt != nil { - info.CreatedAt = e.CreatedAt.String() - } - return info -}