chore(docs): 移除 superpowers 设计与计划文档

This commit is contained in:
2026-09-03 13:20:00 +08:00
parent a52d66cd88
commit 136463f074
42 changed files with 1006 additions and 3729 deletions
+3 -1
View File
@@ -1 +1,3 @@
/.idea/*
/.idea/*
/.superpowers/
/docs/superpowers/
+10 -14
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
package pricing
// ChargeOrderStatus 计费单状态(状态机单迁移:CREATED → SETTLED / FAILED
type ChargeOrderStatus int
const (
ChargeOrderStatusCreated ChargeOrderStatus = 1 // 已建单(未结算,不动钱)
ChargeOrderStatusSettled ChargeOrderStatus = 2 // 已结算(实收完成)
ChargeOrderStatusFailed ChargeOrderStatus = 3 // 已失败(不扣费)
)
+11 -2
View File
@@ -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" // 计费单表
)
+7 -5
View File
@@ -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" // 支出(结算实扣,允许负余额)
)
+30
View File
@@ -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)
}
-32
View File
@@ -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)
}
+1 -1
View File
@@ -15,7 +15,7 @@ import (
knapsackEntity "shop-user-trade/model/entity/knapsack"
)
var Knapsack = new(knapsackDao)
var Knapsack = &knapsackDao{}
type knapsackDao struct{}
+1 -1
View File
@@ -15,7 +15,7 @@ import (
marketEntity "shop-user-trade/model/entity/market"
)
var Market = new(marketDao)
var Market = &marketDao{}
type marketDao struct{}
+16 -33
View File
@@ -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 规范)。
// 方法入参统一吃 DTOmodel/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 → SETTLEDrows=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
}
+20 -42
View File
@@ -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 规范)。
// 方法入参统一吃 DTOmodel/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→entitymap[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()
}
+143
View File
@@ -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 规范)。
// 方法入参统一吃 DTOmodel/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
}
-134
View File
@@ -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
}
@@ -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`, 标准库 testingTDD)。
## 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 <TestName> -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: 重写 validateTieredBandsTDD
**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: PASS6 个用例)
- [ ] **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` helpertoken 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,值须配置 pricetoken/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: PASS5 个用例)
- [ ] **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 只有 audiovideo 输入落默认价
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.0250000 → 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)
}
}
// M3cached > 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)
}
}
// P2validate 拒绝 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)
}
}
// P1validate 拒绝缺 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")
}
}
// P1validate 拒绝缺 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 行加 mediaPrices102-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、加 mediaPrices151-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: 推理示例去掉文本遮蔽规则、音频差价改 mediaPrices162-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: PASSservice/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.1validateTieredBands)→ Task 2;§5.2mediaPrices 校验 + ModelMediaSet)→ Task 1/3;§4.1pickModelPrice + 两处 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`Task1Task3/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.goHEAD 存在 10 测试)被覆盖删除,用户裁决「维持删除,Task 4 补回归」——Task 4 已加 8 个回归测试(阶梯算费/无匹配/cache-hit/M3 负值钳制/P2 base 不匹配/P1 nil-price×2/单位多路径),fixture 去 mediaTypeper_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 计算器」文本与之冲突,用户裁决「保留删除 + 记录确认」。
@@ -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/businessmodel 无需选择不入参)"`
SubjectType string `json:"subjectType" v:"required|in:workflow,business" dc:"计价对象类型(model 非法:模型计费方式无需选择)"`
SubjectID string `json:"subjectId" v:"required" dc:"计价对象IDworkflow→workflowbusiness→业务模块标识"`
}
// 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: PASS4 个用例)
- [ ] **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` 签名跨步骤一致。
@@ -1,323 +0,0 @@
# 定价模块计价对象枚举化与模型计价设计
> 版本:v0.3(本期)—— 在 v0.2(无预扣)基础上,将计价对象固定枚举化 + 接入模型计价与业务模块周期计价。
> 范围:shop-user-trade **pricing 计价模块**。交付「枚举 + 配置管理 + per_1M/per_period 计算器」层;模型/业务模块结算接入另期。
> 约定:金额一律**元**float64DB `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 = 整个 modelCfgunit/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/chargeModeChargeOrderInfo 的 bizKey → subjectType/subjectIdDAO 入参同步
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/periodSave 按类型校验 rulessettleOrder 派发
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 ./...` 验证
@@ -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 modelRulecharge_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 应为 32000R3/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 配置仍按顺序优先)
- workflowper_item/per_second/per_token)、businessper_period)计算器
- `pricing_config` 表结构、`charge_order` 表结构、`ChargeUsage` 字段
- `GET /pricing/subjects` 与 charge-modes 接口
@@ -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/businessmodel 不入参 | 模型计费方式由类型/配置决定,前端无需选择(用户确认) |
| 按 `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 结构均不变。
@@ -1,561 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>计价配置管理 · 原型预览</title>
<style>
:root{
--primary:#409eff; --primary-light:#ecf5ff; --danger:#f56c6c; --success:#67c23a;
--text:#303133; --text2:#606266; --text3:#909399; --border:#dcdfe6; --bg:#f5f7fa; --card:#fff;
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:"PingFang SC","Microsoft YaHei",system-ui,sans-serif;background:var(--bg);color:var(--text);font-size:14px}
.topbar{background:#fff;border-bottom:1px solid var(--border);padding:14px 24px;display:flex;align-items:center;justify-content:space-between}
.topbar h1{font-size:18px;font-weight:600}
.topbar .tag{font-size:12px;color:var(--text3);margin-left:12px}
.topbar .badge-proto{background:var(--primary-light);color:var(--primary);border-radius:4px;padding:3px 8px;font-size:12px}
.layout{display:flex;gap:16px;padding:16px 24px;align-items:flex-start}
.panel{background:var(--card);border:1px solid var(--border);border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.04)}
/* 左侧枚举列表 */
.sidebar{width:300px;flex-shrink:0;overflow:hidden}
.sidebar .p-head{padding:12px 16px;border-bottom:1px solid var(--border);font-weight:600;display:flex;justify-content:space-between;align-items:center}
.sidebar .p-head .hint{font-size:12px;color:var(--text3);font-weight:400}
.grp-title{padding:10px 16px 4px;font-size:12px;color:var(--text3)}
.subj{display:flex;align-items:center;gap:8px;padding:10px 16px;cursor:pointer;border-left:3px solid transparent;transition:.15s}
.subj:hover{background:var(--primary-light)}
.subj.active{background:var(--primary-light);border-left-color:var(--primary)}
.subj .name{flex:1;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.subj .type-badge{font-size:11px;border-radius:3px;padding:1px 6px;color:#fff;flex-shrink:0}
.subj .conf-badge{font-size:11px;border-radius:3px;padding:1px 6px;flex-shrink:0}
.conf-yes{background:#f0f9eb;color:var(--success)} .conf-no{background:#fef0f0;color:var(--danger)}
.t-reason{background:#e6a23c}.t-image{background:#00bcd4}.t-audio{background:#67c23a}.t-video{background:#909399}.t-workflow{background:#8e44ad}.t-business{background:#e74c3c}
.mode-chip{font-size:11px;background:#f4f4f5;color:var(--text2);border-radius:3px;padding:1px 5px}
/* 右侧编辑区 */
.editor{flex:1;min-width:0;overflow:hidden}
.e-head{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
.e-head .st{color:var(--text3);font-size:12px}
.e-head .modes{margin-top:4px}
.e-body{padding:20px;display:flex;gap:20px;flex-wrap:wrap}
.col{flex:1;min-width:380px}
.card{border:1px solid var(--border);border-radius:6px;margin-bottom:16px;overflow:hidden}
.card .c-head{background:#fafafa;padding:10px 14px;font-weight:600;display:flex;align-items:center;justify-content:space-between;font-size:14px}
.card .c-head .st{font-size:12px;color:var(--text3);font-weight:400}
.card .c-body{padding:14px}
.f-row{display:flex;gap:12px;margin-bottom:10px;align-items:center;flex-wrap:wrap}
.f{display:flex;flex-direction:column;gap:4px}
.f label{font-size:12px;color:var(--text2)}
.f input,.f select{height:30px;border:1px solid var(--border);border-radius:4px;padding:0 8px;font-size:13px;outline:none;background:#fff}
.f input:focus,.f select:focus{border-color:var(--primary)}
.f input.wide{width:220px}
.f input.num{width:110px}
.tbl{width:100%;border-collapse:collapse}
.tbl th,.tbl td{border:1px solid var(--border);padding:5px 8px;font-size:13px;text-align:left}
.tbl th{background:#fafafa;font-weight:500;color:var(--text2)}
.tbl input{width:100%;border:none;outline:none;font-size:13px;padding:2px}
.tbl input:focus{background:var(--primary-light)}
.btn{height:30px;padding:0 14px;border-radius:4px;border:1px solid var(--border);background:#fff;color:var(--text);cursor:pointer;font-size:13px}
.btn:hover{color:var(--primary);border-color:var(--primary)}
.btn.primary{background:var(--primary);border-color:var(--primary);color:#fff}
.btn.primary:hover{background:#66b1ff}
.btn.mini{height:24px;padding:0 8px;font-size:12px}
.btn.danger:hover{color:var(--danger);border-color:var(--danger)}
.hint{font-size:12px;color:var(--text3)}
.switch{position:relative;display:inline-block;width:40px;height:20px}
.switch input{opacity:0;width:0;height:0}
.slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:#ccc;transition:.2s;border-radius:10px}
.slider:before{position:absolute;content:"";height:16px;width:16px;left:2px;bottom:2px;background:#fff;transition:.2s;border-radius:50%}
input:checked+.slider{background:var(--primary)}
input:checked+.slider:before{transform:translateX(20px)}
input:disabled+.slider{background:#e4e7ed;cursor:not-allowed}
.json{width:100%;min-height:320px;font-family:Consolas,Menlo,monospace;font-size:12.5px;border:1px solid var(--border);border-radius:6px;padding:12px;background:#1e1e1e;color:#d4d4d4;white-space:pre;overflow:auto;line-height:1.55}
.json .p-key{color:#9cdcfe}.json .p-str{color:#ce9178}.json .p-num{color:#b5cea8}.json .p-bool{color:#569cd6}.json .p-null{color:#6a9955}
.note{border:1px solid #e6a23c;background:#fdf6ec;color:#b88230;border-radius:6px;padding:10px 14px;font-size:12.5px;line-height:1.7;margin-bottom:16px}
.toast{position:fixed;top:20px;left:50%;transform:translateX(-50%);background:var(--success);color:#fff;padding:10px 20px;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,.15);opacity:0;transition:.3s;z-index:99;font-size:14px}
.toast.show{opacity:1}
.section-title{font-size:13px;color:var(--text2);margin:18px 0 10px;display:flex;align-items:center;gap:6px}
.section-title::before{content:"";width:3px;height:14px;background:var(--primary);border-radius:2px}
.api-box{margin-top:16px}
.rule-card{border:1px solid var(--border);border-radius:6px;margin-bottom:10px;padding:12px}
.rule-card .rc-head{display:flex;align-items:center;gap:8px;margin-bottom:10px}
.rule-card .rc-head input{flex:1}
.rc-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
.rc-sec{font-size:12px;color:var(--text3);margin:10px 0 6px;display:flex;align-items:center;gap:6px}
.rc-sec::before{content:"";width:3px;height:12px;background:#e6a23c;border-radius:2px}
.rc-sec.price::before{background:var(--primary)}
.money-note{font-size:12px;color:var(--text3);margin-bottom:8px}
.field-pill{font-size:11px;color:var(--text2);background:#f0f2f5;border-radius:3px;padding:1px 5px;margin-right:4px}
</style>
</head>
<body>
<div class="topbar">
<h1>计价配置管理 <span class="tag">/pricing · 原型预览</span></h1>
<span class="badge-proto">原型预览 · 静态无后端</span>
</div>
<div class="layout">
<!-- ============ 左侧:计价对象枚举 ============ -->
<div class="panel sidebar">
<div class="p-head">计价对象 <span class="hint">GET /pricing/subjects</span></div>
<div class="grp-title">工作流</div>
<div id="subj-workflow"></div>
<div class="grp-title">模型(实时拉取 model-gateway 系统模型)</div>
<div id="subj-models"></div>
<div class="grp-title">业务模块</div>
<div id="subj-business"></div>
</div>
<!-- ============ 右侧:配置编辑 ============ -->
<div class="panel editor">
<div class="e-head">
<div>
<div style="font-size:15px;font-weight:600" id="ed-title">工作流</div>
<div class="st" id="ed-sub">subjectType=workflow · subjectId=workflow</div>
</div>
<div>
<button class="btn" onclick="loadApiExample()">枚举响应示例</button>
<button class="btn primary" onclick="saveMock()">保存配置</button>
</div>
</div>
<!-- 工作流编辑 -->
<div class="e-body" id="ed-workflow" style="display:none">
<div class="col">
<div class="note">工作流可同时配置 <b>按条 / 按秒 / 按token</b> 三套价格。创建/使用工作流时,从这些配置中<b>选择本次执行的扣费方式</b>。只配置的模式才会出现在 rules 里。</div>
<!-- per_item -->
<div class="card">
<div class="c-head">按条计费 per_item
<span class="st"><label class="switch"><input type="checkbox" id="w-item-on" onchange="renderWorkflow()"><span class="slider"></span></label></span>
</div>
<div class="c-body">
<div class="hint">档位开放列表,上不封顶。命中首个 <code>maxSec ≥ 时长</code> 的档;超出最大档按 overflowUnitPrice 线性叠加。</div>
<table class="tbl" style="margin:10px 0">
<tr><th>档位最大秒数 maxSec</th><th>价格(元)</th><th></th></tr>
<tbody id="w-item-tiers"></tbody>
</table>
<button class="btn mini" onclick="addTier()">+ 加档</button>
<div class="f-row" style="margin-top:10px">
<div class="f"><label>超出最大档单价(元/秒)</label><input class="num" id="w-item-overflow" type="number" step="0.01" value="0.9" oninput="renderWorkflow()"></div>
</div>
</div>
</div>
<!-- per_second -->
<div class="card">
<div class="c-head">按秒计费 per_second
<span class="st"><label class="switch"><input type="checkbox" id="w-sec-on" onchange="renderWorkflow()"><span class="slider"></span></label></span>
</div>
<div class="c-body">
<div class="hint">按秒向上取整(不满 1 秒按 1 秒),再乘单价。</div>
<div class="f-row" style="margin-top:10px">
<div class="f"><label>单价(元/秒)</label><input class="num" id="w-sec-unit" type="number" step="0.01" value="0.9" oninput="renderWorkflow()"></div>
</div>
</div>
</div>
<!-- per_token -->
<div class="card">
<div class="c-head">按token计费 per_token
<span class="st"><label class="switch"><input type="checkbox" id="w-tok-on" onchange="renderWorkflow()"><span class="slider"></span></label></span>
</div>
<div class="c-body">
<div class="note" style="margin-bottom:0">价格<b>取自各模型自己的计价配置</b>(模型列表里配的那份),不在此重复配置。未配价的模型按 0 计。⚠️ per_token 费率不冻结在快照,结算时按模型实时价。</div>
</div>
</div>
</div>
<div class="col">
<div class="section-title">公共配置</div>
<div class="card"><div class="c-body">
<div class="f-row">
<div class="f"><label>门禁 min_balance(元)</label><input class="num" id="w-minbalance" type="number" step="0.01" value="0" oninput="renderWorkflow()"></div>
<div class="f"><label>币种</label><select id="w-currency" onchange="renderWorkflow()"><option value="CNY" selected>CNY</option><option value="USD">USD</option></select></div>
<div class="f"><label>启用</label><label class="switch"><input type="checkbox" id="w-enabled" checked onchange="renderWorkflow()"><span class="slider"></span></label></div>
</div>
</div></div>
<div class="section-title">rules JSON(实时预览)</div>
<textarea class="json" id="wf-json" readonly spellcheck="false"></textarea>
</div>
</div>
<!-- 模型编辑 -->
<div class="e-body" id="ed-model" style="display:none">
<div class="col">
<div class="card">
<div class="c-head">模型计费规则(按模型类型自适应)
<span class="st"><select id="m-example" onchange="loadExample()"><option value="">选择示例填充</option></select></span>
</div>
<div class="c-body">
<div class="f-row">
<div class="f"><label>计费单位 unit</label>
<select id="m-unit" onchange="onUnitChange()">
<option value="per_1M">per_1M(每百万token</option>
<option value="per_1K">per_1K(每千token</option>
<option value="per_1">per_1(每个/每张图)</option>
<option value="per_second">per_second(每秒)</option>
<option value="per_minute">per_minute(每分钟)</option>
<option value="per_hour">per_hour(每小时)</option>
<option value="per_char">per_char(每字)</option>
</select>
</div>
<div class="f"><label>阶梯分档 tiered</label>
<label class="switch"><input type="checkbox" id="m-tiered" disabled onchange="renderModel()"><span class="slider"></span></label>
</div>
<div class="f"><label>币种</label><select id="m-currency" onchange="renderModel()"><option value="CNY" selected>CNY</option><option value="USD">USD</option></select></div>
<div class="f"><label>启用</label><label class="switch"><input type="checkbox" id="m-enabled" checked onchange="renderModel()"><span class="slider"></span></label></div>
</div>
<div class="hint" id="m-tiered-hint">阶梯=按输入 token 分档,<b>命中哪档整单按哪档价(不累加)</b>。档位区间相邻不重叠(下档 max+1 = 上档 min)。仅 token 单位(per_1K/per_1M)可用。</div>
<div class="money-note" id="m-price-note" style="margin-top:8px"></div>
<div id="m-rules"></div>
<button class="btn mini" onclick="addModelRule()">+ 加规则</button>
</div>
</div>
</div>
<div class="col">
<div class="section-title">公共配置</div>
<div class="card"><div class="c-body">
<div class="f-row">
<div class="f"><label>门禁 min_balance(元)</label><input class="num" id="m-minbalance" type="number" step="0.01" value="0" oninput="renderModel()"></div>
</div>
</div></div>
<div class="section-title">rules JSON(实时预览)</div>
<textarea class="json" id="m-json" readonly spellcheck="false"></textarea>
<div class="note" style="margin-top:12px">结算入参(ChargeUsage 扩展,本期备用):<code>{promptTokens, completionTokens, cachedTokens, charCount, imageCount, mediaType, thinking, outputAudio, outputResolution}</code>。匹配 = 全部条件满足的<b>首条规则</b>命中,无命中报错。</div>
</div>
</div>
<!-- 业务模块编辑 -->
<div class="e-body" id="ed-business" style="display:none">
<div class="col">
<div class="card">
<div class="c-head">周期订阅 per_period
<span class="st">通用周期建模,后续可扩 month/quarter</span>
</div>
<div class="c-body">
<div class="hint" style="margin-bottom:10px">按周期一次性收取,与用量无关。本期支持按年(period=year)。</div>
<div class="f-row">
<div class="f"><label>周期 period</label>
<select id="b-period" onchange="renderBusiness()"><option value="year" selected>year(按年)</option></select>
</div>
<div class="f"><label>每周期价格(元)</label>
<input class="num" id="b-price" type="number" step="0.01" value="1999" oninput="renderBusiness()">
</div>
</div>
</div>
</div>
</div>
<div class="col">
<div class="section-title">公共配置</div>
<div class="card"><div class="c-body">
<div class="f-row">
<div class="f"><label>门禁 min_balance(元)</label><input class="num" id="b-minbalance" type="number" step="0.01" value="0" oninput="renderBusiness()"></div>
<div class="f"><label>币种</label><select id="b-currency" onchange="renderBusiness()"><option value="CNY" selected>CNY</option><option value="USD">USD</option></select></div>
<div class="f"><label>启用</label><label class="switch"><input type="checkbox" id="b-enabled" checked onchange="renderBusiness()"><span class="slider"></span></label></div>
</div>
</div></div>
<div class="section-title">rules JSON(实时预览)</div>
<textarea class="json" id="b-json" readonly spellcheck="false"></textarea>
<div class="hint" style="margin-top:8px">订阅购买/续费流程接入另期;本期提供价格标准配置。</div>
</div>
</div>
<!-- 枚举响应示例 -->
<div class="e-body" id="ed-api" style="display:none">
<div class="col" style="flex:1;min-width:0">
<div class="section-title">GET /pricing/subjects · 枚举响应</div>
<textarea class="json" id="api-json" readonly spellcheck="false"></textarea>
<div class="hint" style="margin-top:8px">模型部分实时调 model-gateway <code>/listModelManage</code>system_model=true);modelType 透传 model-gateway 枚举编码(100推理/200图片/300音频/600视频);model-gateway 不可用时接口报错。</div>
</div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
/* ================= 主体数据(模拟枚举) ================= */
const SUBJECTS = [
{type:'workflow', id:'workflow', name:'工作流', modes:['per_item','per_second','per_token'], hasConfig:true, enabled:1},
{type:'model', id:'101', name:'deepseek-v3(推理)', modelType:'reason', typeLabel:'推理', conf:true},
{type:'model', id:'102', name:'文生视频·配音', modelType:'video', typeLabel:'视频', conf:false},
{type:'model', id:'103', name:'语音合成', modelType:'audio', typeLabel:'音频', conf:false},
{type:'model', id:'104', name:'文生图', modelType:'image', typeLabel:'图片', conf:false},
{type:'business', id:'ai_customer_service', name:'AI客服', typeLabel:'业务', conf:false},
];
/* ================= 四类模型示例(对应新设计 §4.2 ================= */
const EXAMPLES = {
video:[
{label:'视频·按秒(分辨率×有声/无声×输入媒体)', unit:'per_second', tiered:false, rules:[
{name:'无声-720p-输入不含视频', mediaType:'text', outputAudio:false, outputResolution:'720p', unitPrice:0.8},
{name:'有声-1080p-输入含视频', mediaType:'video', outputAudio:true, outputResolution:'1080p', unitPrice:1.5},
]},
{label:'视频·按token(百万)', unit:'per_1M', tiered:false, rules:[
{name:'文本输入-无声-1080p', mediaType:'text', outputAudio:false, outputResolution:'1080p', input:0.6, output:3.6, cacheHit:0.12},
{name:'视频输入-有声-1080p', mediaType:'video', outputAudio:true, outputResolution:'1080p', input:9, output:27, cacheHit:1.8},
]},
],
reason:[
{label:'推理·阶梯(思考×输入token档×输入媒体)', unit:'per_1M', tiered:true, rules:[
{name:'思考-输入≤32000-文本', mediaType:'text', thinking:true, inLenMax:32000, input:0.6, output:3.6, cacheHit:0.12},
{name:'思考-输入32001~128000-文本', mediaType:'text', thinking:true, inLenMin:32001, inLenMax:128000, input:0.9, output:5.4, cacheHit:0.18},
{name:'思考-输入含音频', mediaType:'audio', thinking:true, input:9, output:3.6, cacheHit:1.8},
{name:'非思考-统一价', thinking:false, input:0.3, output:1.2, cacheHit:0.06},
]},
],
audio:[
{label:'音频·按字数', unit:'per_char', tiered:false, rules:[
{name:'语音-统一价(万字符5元)', unitPrice:0.0005},
]},
{label:'音频·按分钟', unit:'per_minute', tiered:false, rules:[
{name:'语音-统一价', unitPrice:2},
]},
{label:'音频·按token(百万)', unit:'per_1M', tiered:false, rules:[
{name:'统一价', input:500, output:500, cacheHit:0},
]},
],
image:[
{label:'图片·按张数×分辨率', unit:'per_1', tiered:false, rules:[
{name:'512x512', outputResolution:'512x512', unitPrice:0.2},
{name:'1024x1024', outputResolution:'1024x1024', unitPrice:0.5},
]},
],
};
/* ================= 状态 ================= */
let current = 'workflow';
let currentModelType = '';
let modelRules = [];
/* ================= 渲染左侧枚举 ================= */
function renderSubjects(){
document.getElementById('subj-workflow').innerHTML = subjectHTML(SUBJECTS.find(s=>s.type==='workflow'));
document.getElementById('subj-models').innerHTML = SUBJECTS.filter(s=>s.type==='model').map(subjectHTML).join('');
document.getElementById('subj-business').innerHTML = SUBJECTS.filter(s=>s.type==='business').map(subjectHTML).join('');
}
function subjectHTML(s){
const conf = s.type==='workflow' ? s.hasConfig : s.conf;
const typeLabel = s.type==='workflow' ? '工作流' : (s.type==='business' ? '业务' : s.typeLabel);
const tclass = s.type==='workflow' ? 't-workflow' : (s.type==='business' ? 't-business' : 't-'+s.modelType);
let extra='';
if(s.type==='workflow'){ extra = s.modes.map(m=>`<span class="mode-chip">${m}</span>`).join(' '); }
if(s.type==='business'){ extra = `<span class="mode-chip">per_period</span>`; }
return `<div class="subj ${current===s.type+':'+s.id?'active':''}" onclick="selectSubject('${s.type}','${s.id}')">
<span class="type-badge ${tclass}">${typeLabel}</span>
<span class="name">${s.name}</span>
${extra}
<span class="conf-badge ${conf?'conf-yes':'conf-no'}">${conf?'已配置':'未配置'}</span>
</div>`;
}
/* ================= 切换主体 ================= */
function selectSubject(type, id){
current = type+':'+id;
renderSubjects();
['ed-workflow','ed-model','ed-business','ed-api'].forEach(p=>document.getElementById(p).style.display='none');
if(type==='workflow'){
document.getElementById('ed-workflow').style.display='flex';
document.getElementById('ed-title').textContent='工作流';
document.getElementById('ed-sub').textContent='subjectType=workflow · subjectId=workflow';
renderWorkflowTiers(); renderWorkflow();
} else if(type==='model'){
document.getElementById('ed-model').style.display='flex';
const s = SUBJECTS.find(x=>x.type==='model'&&x.id===id);
document.getElementById('ed-title').textContent=s.name;
document.getElementById('ed-sub').textContent=`subjectType=model · subjectId=${s.id}${s.typeLabel}modelType=${s.modelType}`;
selectModel(s);
} else if(type==='business'){
document.getElementById('ed-business').style.display='flex';
const s = SUBJECTS.find(x=>x.type==='business'&&x.id===id);
document.getElementById('ed-title').textContent=s.name;
document.getElementById('ed-sub').textContent=`subjectType=business · subjectId=${s.id}`;
renderBusiness();
}
}
/* ================= 模型编辑器 ================= */
function selectModel(s){
currentModelType = s.modelType;
const sel = document.getElementById('m-example');
sel.innerHTML = `<option value="">选择示例填充</option>` +
EXAMPLES[s.modelType].map((e,i)=>`<option value="${i}">${e.label}</option>`).join('');
// 切模型自动加载该类型第一个示例
sel.value = '0';
loadExample();
}
function emptyRule(){
return {name:'', mediaType:'', thinking:'', outputAudio:'', outputResolution:'', inLenMin:'', inLenMax:'', input:0, output:0, cacheHit:0, unitPrice:0};
}
function addModelRule(){ modelRules.push(emptyRule()); renderModel(); }
function loadExample(){
const i = document.getElementById('m-example').value;
if(i===''){ modelRules=[emptyRule()]; renderModel(); return; }
const ex = EXAMPLES[currentModelType][+i];
document.getElementById('m-unit').value = ex.unit;
document.getElementById('m-tiered').checked = !!ex.tiered;
document.getElementById('m-currency').value = ex.currency||'CNY';
document.getElementById('m-minbalance').value = ex.minBalance||0;
modelRules = ex.rules.map(r=>({name:r.name, mediaType:r.mediaType||'', thinking:r.thinking==null?'':String(r.thinking),
outputAudio:r.outputAudio==null?'':String(r.outputAudio), outputResolution:r.outputResolution||'',
inLenMin:r.inLenMin==null?'':String(r.inLenMin), inLenMax:r.inLenMax==null?'':String(r.inLenMax),
input:r.input==null?0:r.input, output:r.output==null?0:r.output, cacheHit:r.cacheHit==null?0:r.cacheHit,
unitPrice:r.unitPrice==null?0:r.unitPrice}));
onUnitChange(); renderModel();
}
function onUnitChange(){
const unit = document.getElementById('m-unit').value;
const token = unit==='per_1K'||unit==='per_1M';
document.getElementById('m-tiered').disabled = !token;
if(!token){ document.getElementById('m-tiered').checked = false; }
document.getElementById('m-price-note').innerHTML = token
? 'token 单位价格按 <b>元/基准</b>per_1M=每百万token)计:input=输入单价、output=输出单价、cacheHit=缓存命中输入单价。'
: '非 token 单位价格按 <b>元/单位</b> 计:unitPrice=每单位单价(秒/分钟/小时/字/张)。';
renderModel();
}
function fsel(key, i, opts, labels){
const o = opts.map((v,j)=>`<option value="${v}" ${modelRules[i][key]===v?'selected':''}>${labels[j]}</option>`).join('');
return `<div class="f"><label>${key}</label><select onchange="modelRules[${i}].${key}=this.value;renderModel()">${o}</select></div>`;
}
function ftext(key, i, ph){
return `<div class="f"><label>${key}</label><input value="${esc(modelRules[i][key])}" placeholder="${ph}" oninput="modelRules[${i}].${key}=this.value;renderModel()"></div>`;
}
function fnum(key, i, label, step){
return `<div class="f"><label>${label}</label><input class="num" type="number" step="${step||'0.01'}" value="${modelRules[i][key]}" oninput="modelRules[${i}].${key}=+this.value;renderModel()"></div>`;
}
function renderModel(){
const box = document.getElementById('m-rules');
const unit = document.getElementById('m-unit').value;
const token = unit==='per_1K'||unit==='per_1M';
box.innerHTML = modelRules.map((r,i)=>{
const mt = currentModelType;
let match = '';
match += `<div class="rc-sec">命中条件 match</div><div class="rc-grid">`;
match += fsel('mediaType', i, ['','text','audio','video','image'], ['不限制','文本 text','音频 audio','视频 video','图片 image']);
if(mt==='reason'){
match += fsel('thinking', i, ['','true','false'], ['不限制','思考模式','非思考模式']);
match += fnum('inLenMin', i, '输入token下界', 1);
match += fnum('inLenMax', i, '输入token上界', 1);
}
if(mt==='video'){
match += fsel('outputAudio', i, ['','true','false'], ['不限制','有声','无声']);
match += ftext('outputResolution', i, '480p/720p/1080p/2k/4k');
}
if(mt==='image'){
match += ftext('outputResolution', i, '512x512/1024x1024/2048x2048');
}
match += `</div>`;
const price = token
? fnum('input', i, '输入单价 input') + fnum('output', i, '输出单价 output') + fnum('cacheHit', i, '缓存命中 cacheHit')
: fnum('unitPrice', i, '每单位单价 unitPrice');
return `<div class="rule-card">
<div class="rc-head">
<span style="color:var(--text3);font-size:12px">规则 #${i+1}</span>
<input value="${esc(r.name)}" placeholder="规则名(如:有声-1080p-输入含视频)" oninput="modelRules[${i}].name=this.value;renderModel()">
<button class="btn mini danger" onclick="modelRules.splice(${i},1);renderModel()">删</button>
</div>
${match}
<div class="rc-sec price">计价项 price</div>
<div class="rc-grid">${price}</div>
</div>`;
}).join('');
const cfg = buildModelCfg();
document.getElementById('m-json').value = JSON.stringify(cfg,null,2);
}
function buildModelCfg(){
const unit = document.getElementById('m-unit').value;
const token = unit==='per_1K'||unit==='per_1M';
const tiered = document.getElementById('m-tiered').checked;
const rules = modelRules.filter(r=>r.name||Object.values(r).slice(1).some(v=>v!==''&&v!==0))
.map(r=>{
const match = {};
if(r.mediaType) match.mediaType = r.mediaType;
if(r.thinking!=='') match.thinking = r.thinking==='true';
if(r.outputAudio!=='') match.outputAudio = r.outputAudio==='true';
if(r.outputResolution) match.outputResolution = r.outputResolution;
if(r.inLenMin!=='') match.inputLengthMin = +r.inLenMin;
if(r.inLenMax!=='') match.inputLengthMax = +r.inLenMax;
const price = token ? {input:+r.input, output:+r.output, cacheHit:+r.cacheHit} : {unitPrice:+r.unitPrice};
return {name:r.name, ...(Object.keys(match).length?{match}:{}), price};
});
const modelCfg = { unit, tiered, rules,
currency:document.getElementById('m-currency').value,
discount:null };
return { subjectType:'model', subjectId:current.split(':')[1], rules:modelCfg,
minBalance:+document.getElementById('m-minbalance').value,
enabled:document.getElementById('m-enabled').checked?1:0 };
}
/* ================= 业务模块 ================= */
function renderBusiness(){
const cfg = { subjectType:'business', subjectId:current.split(':')[1],
rules:{ period:document.getElementById('b-period').value, price:+document.getElementById('b-price').value },
minBalance:+document.getElementById('b-minbalance').value,
currency:document.getElementById('b-currency').value,
enabled:document.getElementById('b-enabled').checked?1:0 };
document.getElementById('b-json').value = JSON.stringify(cfg,null,2);
}
/* ================= 枚举响应示例 ================= */
function loadApiExample(){
['ed-workflow','ed-model','ed-api'].forEach(p=>document.getElementById(p).style.display='none');
document.getElementById('ed-api').style.display='flex';
document.getElementById('ed-title').textContent='枚举响应示例';
document.getElementById('ed-sub').textContent='GET /pricing/subjects';
const mt = {'reason':100,'image':200,'audio':300,'video':600};
const api = {
subjects:[
{subjectType:'workflow', subjectId:'workflow', name:'工作流', chargeModes:['per_item','per_second','per_token'], hasConfig:true, enabled:1},
...SUBJECTS.filter(s=>s.type==='model').map(s=>({subjectType:'model', subjectId:s.id, name:s.name, modelType:s.modelType, modelTypeCode:mt[s.modelType], hasConfig:s.conf, enabled:1})),
...SUBJECTS.filter(s=>s.type==='business').map(s=>({subjectType:'business', subjectId:s.id, name:s.name, chargeModes:['per_period'], hasConfig:s.conf, enabled:1}))
]
};
document.getElementById('api-json').value = JSON.stringify(api,null,2);
}
/* ================= 工作流编辑 ================= */
let tiers = [{maxSec:15,price:29},{maxSec:30,price:49},{maxSec:60,price:89}];
function renderWorkflowTiers(){
const tb=document.getElementById('w-item-tiers');
tb.innerHTML = tiers.map((t,i)=>`<tr>
<td><input type="number" value="${t.maxSec}" oninput="tiers[${i}].maxSec=+this.value;renderWorkflow()"></td>
<td><input type="number" step="0.01" value="${t.price}" oninput="tiers[${i}].price=+this.value;renderWorkflow()"></td>
<td><button class="btn mini danger" onclick="tiers.splice(${i},1);renderWorkflowTiers();renderWorkflow()">删</button></td></tr>`).join('');
}
function addTier(){ tiers.push({maxSec:60,price:89}); renderWorkflowTiers(); renderWorkflow(); }
function renderWorkflow(){
const rules = {};
if(document.getElementById('w-item-on').checked){
rules.per_item = {tiers:tiers.filter(t=>t&&t.maxSec>0), overflowUnitPrice:+document.getElementById('w-item-overflow').value};
}
if(document.getElementById('w-sec-on').checked){
rules.per_second = {unitPrice:+document.getElementById('w-sec-unit').value};
}
if(document.getElementById('w-tok-on').checked){
rules.per_token = {};
}
const cfg = { subjectType:'workflow', subjectId:'workflow', rules,
minBalance:+document.getElementById('w-minbalance').value,
currency:document.getElementById('w-currency').value,
enabled:document.getElementById('w-enabled').checked?1:0 };
document.getElementById('wf-json').value = JSON.stringify(cfg,null,2);
}
/* ================= 保存(mock ================= */
function saveMock(){
const t=document.getElementById('toast');
t.textContent='已保存(模拟)· rules JSON 已在上方预览';
t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1800);
}
/* ================= 工具 ================= */
function esc(s){ return (s||'').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
/* ================= 初始化 ================= */
renderSubjects();
selectSubject('model','102'); // 默认打开视频模型,展示多维匹配
</script>
</body>
</html>
+10 -3
View File
@@ -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
+13
View File
@@ -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=
+52 -56
View File
@@ -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(见设计§4rules 自描述)
rules JSONB NOT NULL DEFAULT '{}', -- 费率JSON对象(见设计§4rules 自描述)
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 '计价对象IDworkflow/模型ID/业务模块标识)';
COMMENT ON COLUMN pricing_config.rules IS '费率JSONworkflow三模式容器/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 '计价对象IDworkflow/模型ID/业务模块标识)';
COMMENT ON COLUMN wallet_pricing_config.rules IS '费率JSONworkflow三模式容器/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表语句---------------------------
--------------------pgsql创建wallet_charge_order表语句---------------------------
+4 -1
View File
@@ -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 adapterWATCH/MULTI 归属释放)
)
func main() {
@@ -20,7 +22,8 @@ func main() {
http.RouteRegister([]interface{}{
knapsackController.Knapsack,
marketController.Market,
walletController.Wallet,
walletController.Account,
pricingController.Pricing,
})
select {}
}
+61 -59
View File
@@ -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:"计价对象IDorderId 为空时必填)"`
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:"计价对象IDorderId 为空时必填)"`
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:"计价对象IDorderId 为空时必填)"`
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:"计价对象IDorderId 为空时必填)"`
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:"计价对象IDorderId 为空时必填)"`
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,条件更新幂等)
+24 -41
View File
@@ -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:"更新时间"`
}
+6 -5
View File
@@ -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 按主体查询可选计费方式请求
+85
View File
@@ -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"`
}
-112
View File
@@ -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"`
}
+21 -21
View File
@@ -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:"结算时间"`
}
+4 -12
View File
@@ -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:"计价对象IDworkflow/模型ID/业务模块标识)"`
Rules string `orm:"rules" json:"rules" description:"费率JSON(见设计§4rules 自描述不再有 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 ""
}
@@ -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:"乐观锁版本号"`
}
+54
View File
@@ -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",
}
-24
View File
@@ -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)"`
}
-33
View File
@@ -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",
}
+25 -14
View File
@@ -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 rulesspec §4.2 ======================
@@ -197,9 +206,9 @@ func (perTokenCalculator) charge(rules interface{}, usage *ChargeUsage) (float64
// modelRules 模型计费规则:unit + tiered + rulesrules 按序首条命中)
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 ======================
-335
View File
@@ -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 只有 audiovideo 输入落默认价
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.0250000 → 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)
}
}
// M3cached > 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)
}
}
// P2validate 拒绝 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)
}
}
// P1validate 拒绝缺 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")
}
}
// P1validate 拒绝缺 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)
}
}
+166
View File
@@ -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 JSONspec §4):
//
// workflow:三模式容器逐个校验已配模式;model:先解析 unit 再用对应计算器;businessper_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 校验):
//
// workflowchargeMode 必填 ∈ rules 已配模式;快照 = rules[chargeMode]
// modelchargeMode 可空 = config.unit;快照 = 整个 modelCfg
// businesschargeMode 可空 = 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 计费方式固定取配置 unitcfg.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
}
+85 -232
View File
@@ -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 servicecommon 统一锁),账务经钱包 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 subjectchargeMode 可空 = 配置 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 JSONspec §4):
// workflow:三模式容器逐个校验已配模式;model:先解析 unit 再用对应计算器;businessper_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 校验):
// workflowchargeMode 必填 ∈ rules 已配模式;快照 = rules[chargeMode]
// modelchargeMode 可空 = config.unit;快照 = 整个 modelCfg
// businesschargeMode 可空 = 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 计费方式固定取配置 unitcfg.Unit() 见 entityTask 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
}
+6
View File
@@ -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,
})
}
-63
View File
@@ -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")
}
}
+128
View File
@@ -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.WithLockuuid 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
}
-153
View File
@@ -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
}