chore(docs): 移除 superpowers 设计与计划文档
This commit is contained in:
+3
-1
@@ -1 +1,3 @@
|
||||
/.idea/*
|
||||
/.idea/*
|
||||
/.superpowers/
|
||||
/docs/superpowers/
|
||||
|
||||
+10
-14
@@ -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
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package pricing
|
||||
|
||||
// ChargeOrderStatus 计费单状态(状态机单迁移:CREATED → SETTLED / FAILED)
|
||||
type ChargeOrderStatus int
|
||||
|
||||
const (
|
||||
ChargeOrderStatusCreated ChargeOrderStatus = 1 // 已建单(未结算,不动钱)
|
||||
ChargeOrderStatusSettled ChargeOrderStatus = 2 // 已结算(实收完成)
|
||||
ChargeOrderStatusFailed ChargeOrderStatus = 3 // 已失败(不扣费)
|
||||
)
|
||||
@@ -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" // 计费单表
|
||||
)
|
||||
|
||||
@@ -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" // 支出(结算实扣,允许负余额)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
knapsackEntity "shop-user-trade/model/entity/knapsack"
|
||||
)
|
||||
|
||||
var Knapsack = new(knapsackDao)
|
||||
var Knapsack = &knapsackDao{}
|
||||
|
||||
type knapsackDao struct{}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
marketEntity "shop-user-trade/model/entity/market"
|
||||
)
|
||||
|
||||
var Market = new(marketDao)
|
||||
var Market = &marketDao{}
|
||||
|
||||
type marketDao struct{}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
pricingConsts "shop-user-trade/consts/pricing"
|
||||
"shop-user-trade/consts/public"
|
||||
@@ -12,7 +13,7 @@ import (
|
||||
pricingEntity "shop-user-trade/model/entity/pricing"
|
||||
)
|
||||
|
||||
// chargeOrderDao 计费单数据访问:薄查询/状态迁移原语(同 model-gateway dao 规范)。
|
||||
// ChargeOrder chargeOrderDao 计费单数据访问:薄查询/状态迁移原语(同 model-gateway dao 规范)。
|
||||
// 方法入参统一吃 DTO(model/dto/pricing);无锁、无事务、不定义业务错误。
|
||||
var ChargeOrder = &chargeOrderDao{}
|
||||
|
||||
@@ -21,7 +22,7 @@ type chargeOrderDao struct{}
|
||||
// GetOrder 按 ID 或 subject_type+subject_id+biz_order_no 查询计费单(Id>0 走 ID)。
|
||||
// 幂等查询复用:工作流断点续跑/恢复 execId 不变,据此去重。
|
||||
func (d *chargeOrderDao) GetOrder(ctx context.Context, req *pricingDto.GetChargeOrderReq) (res *pricingEntity.ChargeOrder, err error) {
|
||||
m := gfdb.DB(ctx).Model(ctx, public.TableNameChargeOrder).Model
|
||||
m := gfdb.DB(ctx, public.DbNameWallet).Model(ctx, public.TableNameChargeOrder).Model
|
||||
if req.Id > 0 {
|
||||
m = m.Where(pricingEntity.ChargeOrderCol.Id, req.Id)
|
||||
} else {
|
||||
@@ -40,18 +41,14 @@ func (d *chargeOrderDao) GetOrder(ctx context.Context, req *pricingDto.GetCharge
|
||||
return
|
||||
}
|
||||
|
||||
// Insert 创建计费单(事务透明:在调用方事务内执行)
|
||||
// Insert 创建计费单(事务透明:在调用方事务内执行;同 model-gateway dao 规范)
|
||||
func (d *chargeOrderDao) Insert(ctx context.Context, req *pricingDto.CreateChargeOrderReq) (id int64, err error) {
|
||||
entity := &pricingEntity.ChargeOrder{
|
||||
SubjectType: req.SubjectType,
|
||||
SubjectID: req.SubjectID,
|
||||
BizOrderNo: req.BizOrderNo,
|
||||
UserId: req.UserId,
|
||||
ChargeMode: req.ChargeMode,
|
||||
Status: req.Status,
|
||||
RuleSnapshot: req.RuleSnapshot,
|
||||
var e = new(pricingEntity.ChargeOrder)
|
||||
err = gconv.Struct(req, &e)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameChargeOrder).Data(entity).Insert()
|
||||
r, err := gfdb.DB(ctx, public.DbNameWallet).Model(ctx, public.TableNameChargeOrder).Insert(e)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -61,24 +58,24 @@ func (d *chargeOrderDao) Insert(ctx context.Context, req *pricingDto.CreateCharg
|
||||
// UpdateToSettled 结算迁移:仅 CREATED → SETTLED;rows=0 表示已处理(幂等)
|
||||
func (d *chargeOrderDao) UpdateToSettled(ctx context.Context, req *pricingDto.SettleChargeOrderReq) (bool, error) {
|
||||
return d.migrateStatus(ctx, req.Id, gdb.Map{
|
||||
"status": int(pricingConsts.ChargeOrderStatusSettled),
|
||||
"actual_amount": req.ActualAmount,
|
||||
"usage": req.Usage,
|
||||
"rule_snapshot": req.RuleSnapshot,
|
||||
"settle_time": req.SettleTime,
|
||||
pricingEntity.ChargeOrderCol.Status: int(pricingConsts.ChargeOrderStatusSettled),
|
||||
pricingEntity.ChargeOrderCol.ActualAmount: req.ActualAmount,
|
||||
pricingEntity.ChargeOrderCol.Usage: req.Usage,
|
||||
pricingEntity.ChargeOrderCol.RuleSnapshot: req.RuleSnapshot,
|
||||
pricingEntity.ChargeOrderCol.SettleTime: req.SettleTime,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateToFailed 失败迁移:仅 CREATED → FAILED(不扣费,不动钱)
|
||||
func (d *chargeOrderDao) UpdateToFailed(ctx context.Context, req *pricingDto.FailChargeOrderReq) (bool, error) {
|
||||
return d.migrateStatus(ctx, req.Id, gdb.Map{
|
||||
"status": int(pricingConsts.ChargeOrderStatusFailed),
|
||||
pricingEntity.ChargeOrderCol.Status: int(pricingConsts.ChargeOrderStatusFailed),
|
||||
})
|
||||
}
|
||||
|
||||
// migrateStatus 条件状态迁移(幂等:非 CREATED 时 Update 影响 0 行返回 false)
|
||||
func (d *chargeOrderDao) migrateStatus(ctx context.Context, id int64, data gdb.Map) (bool, error) {
|
||||
result, err := gfdb.DB(ctx).Model(ctx, public.TableNameChargeOrder).
|
||||
result, err := gfdb.DB(ctx, public.DbNameWallet).Model(ctx, public.TableNameChargeOrder).
|
||||
Data(data).
|
||||
Where(pricingEntity.ChargeOrderCol.Id, id).
|
||||
Where(pricingEntity.ChargeOrderCol.Status, int(pricingConsts.ChargeOrderStatusCreated)).
|
||||
@@ -92,17 +89,3 @@ func (d *chargeOrderDao) migrateStatus(ctx context.Context, id int64, data gdb.M
|
||||
}
|
||||
return rows > 0, nil
|
||||
}
|
||||
|
||||
// ListByUser 分页查询某用户的计费单
|
||||
func (d *chargeOrderDao) ListByUser(ctx context.Context, req *pricingDto.ListOrdersReq) (res []pricingEntity.ChargeOrder, total int, err error) {
|
||||
r, total, err := gfdb.DB(ctx).Model(ctx, public.TableNameChargeOrder).
|
||||
Where(pricingEntity.ChargeOrderCol.UserId, req.UserId).
|
||||
OrderDesc(pricingEntity.ChargeOrderCol.CreatedAt).
|
||||
Page(req.Page, req.PageSize).
|
||||
AllAndCount(false)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = r.Structs(&res)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,23 +4,22 @@ import (
|
||||
"context"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
pricingConsts "shop-user-trade/consts/pricing"
|
||||
"shop-user-trade/consts/public"
|
||||
pricingDto "shop-user-trade/model/dto/pricing"
|
||||
pricingEntity "shop-user-trade/model/entity/pricing"
|
||||
)
|
||||
|
||||
// pricingConfigDao 计价配置数据访问:薄 CRUD(同 model-gateway dao 规范)。
|
||||
// PricingConfig pricingConfigDao 计价配置数据访问:薄 CRUD(同 model-gateway dao 规范)。
|
||||
// 方法入参统一吃 DTO(model/dto/pricing);无锁、无事务、不定义业务错误。
|
||||
var PricingConfig = &pricingConfigDao{}
|
||||
|
||||
type pricingConfigDao struct{}
|
||||
|
||||
// Get 按计价对象(subject_type + subject_id)获取计价配置
|
||||
func (d *pricingConfigDao) Get(ctx context.Context, req *pricingDto.GetConfigReq) (res *pricingEntity.PricingConfig, err error) {
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNamePricingConfig).
|
||||
func (d *pricingConfigDao) Get(ctx context.Context, req *pricingDto.GetPricingConfigReq) (res *pricingEntity.PricingConfig, err error) {
|
||||
r, err := gfdb.DB(ctx, public.DbNameWallet).Model(ctx, public.TableNamePricingConfig).NoTenantId(ctx).
|
||||
Where(pricingEntity.PricingConfigCol.SubjectType, req.SubjectType).
|
||||
Where(pricingEntity.PricingConfigCol.SubjectID, req.SubjectID).
|
||||
One()
|
||||
@@ -34,52 +33,31 @@ func (d *pricingConfigDao) Get(ctx context.Context, req *pricingDto.GetConfigReq
|
||||
return
|
||||
}
|
||||
|
||||
// Save 新增或更新计价配置(subject_type+subject_id 唯一,更新走整行覆盖)
|
||||
func (d *pricingConfigDao) Save(ctx context.Context, req *pricingDto.SaveConfigReq) (id int64, err error) {
|
||||
if req.Id > 0 {
|
||||
_, err = gfdb.DB(ctx).Model(ctx, public.TableNamePricingConfig).
|
||||
Data(gdb.Map{
|
||||
"subject_type": req.SubjectType,
|
||||
"subject_id": req.SubjectID,
|
||||
"rules": req.Rules,
|
||||
"min_balance": req.MinBalance,
|
||||
"currency": req.Currency,
|
||||
"enabled": req.Enabled,
|
||||
"version": gdb.Raw("version + 1"),
|
||||
}).
|
||||
Where(pricingEntity.PricingConfigCol.Id, req.Id).
|
||||
Update()
|
||||
return req.Id, err
|
||||
// Insert 插入
|
||||
func (d *pricingConfigDao) Insert(ctx context.Context, req *pricingDto.SavePricingConfigReq) (id int64, err error) {
|
||||
var e = new(pricingEntity.PricingConfig)
|
||||
err = gconv.Struct(req, &e)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
entity := &pricingEntity.PricingConfig{
|
||||
SubjectType: pricingConsts.SubjectType(req.SubjectType),
|
||||
SubjectID: req.SubjectID,
|
||||
Rules: req.Rules,
|
||||
MinBalance: req.MinBalance,
|
||||
Currency: req.Currency,
|
||||
Enabled: req.Enabled,
|
||||
Version: 1,
|
||||
}
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNamePricingConfig).Data(entity).Insert()
|
||||
r, err := gfdb.DB(ctx, public.DbNameWallet).Model(ctx, public.TableNamePricingConfig).Insert(e)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
// List 分页查询计价配置(可按 subjectType / enabled 过滤)
|
||||
func (d *pricingConfigDao) List(ctx context.Context, req *pricingDto.ListConfigsReq) (res []pricingEntity.PricingConfig, total int, err error) {
|
||||
m := gfdb.DB(ctx).Model(ctx, public.TableNamePricingConfig).Model
|
||||
if req.SubjectType != "" {
|
||||
m = m.Where(pricingEntity.PricingConfigCol.SubjectType, req.SubjectType)
|
||||
// Update 更新
|
||||
func (d *pricingConfigDao) Update(ctx context.Context, req *pricingDto.SavePricingConfigReq) (rows int64, err error) {
|
||||
// 与 Insert 一致:DTO→entity(map[string]any),Data 以 map 落 JSONB 列
|
||||
var e = new(pricingEntity.PricingConfig)
|
||||
if err = gconv.Struct(req, &e); err != nil {
|
||||
return
|
||||
}
|
||||
if req.Enabled != 0 {
|
||||
m = m.Where(pricingEntity.PricingConfigCol.Enabled, req.Enabled)
|
||||
}
|
||||
r, total, err := m.OrderDesc(pricingEntity.PricingConfigCol.UpdatedAt).Page(req.Page, req.PageSize).AllAndCount(false)
|
||||
r, err := gfdb.DB(ctx, public.DbNameWallet).Model(ctx, public.TableNamePricingConfig).
|
||||
OmitEmpty().Data(e).Where(pricingEntity.PricingConfigCol.Id, req.Id).Update()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = r.Structs(&res)
|
||||
return
|
||||
return r.RowsAffected()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"shop-user-trade/consts/public"
|
||||
walletConsts "shop-user-trade/consts/wallet"
|
||||
walletDto "shop-user-trade/model/dto/wallet"
|
||||
walletEntity "shop-user-trade/model/entity/wallet"
|
||||
)
|
||||
|
||||
// Account AccountDao 钱包数据访问:薄查询/账务原语(同 model-gateway dao 规范)。
|
||||
// 方法入参统一吃 DTO(model/dto/wallet);无锁、无事务、不定义业务错误。
|
||||
// 锁与事务由 service 编排;钱包是否存在/是否可用的业务校验在 service 层做。
|
||||
// Change 为账务原子原语,事务透明,必须由调用方在事务内执行。
|
||||
var Account = &accountDao{}
|
||||
|
||||
type accountDao struct{}
|
||||
|
||||
// ====================== 查询 ======================
|
||||
|
||||
// Get 根据用户ID获取钱包(查询用,不加锁)
|
||||
func (d *accountDao) Get(ctx context.Context, req *walletDto.GetAccountByUserIdReq) (res *walletEntity.Account, err error) {
|
||||
r, err := gfdb.DB(ctx, public.DbNameWallet).Model(ctx, public.TableNameWalletAccount).
|
||||
Where(walletEntity.AccountCol.UserID, req.UserId).
|
||||
One()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if r.IsEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
err = r.Struct(&res)
|
||||
return
|
||||
}
|
||||
|
||||
// GetForUpdate 行锁读取钱包(须在事务内调用),供账务变动使用
|
||||
func (d *accountDao) GetForUpdate(ctx context.Context, req *walletDto.GetAccountByUserIdReq) (res *walletEntity.Account, err error) {
|
||||
r, err := gfdb.DB(ctx, public.DbNameWallet).Model(ctx, public.TableNameWalletAccount).
|
||||
Where(walletEntity.AccountCol.UserID, req.UserId).
|
||||
LockUpdate().
|
||||
One()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if r.IsEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
err = r.Struct(&res)
|
||||
return
|
||||
}
|
||||
|
||||
// Create 创建钱包(余额 0,默认启用;同 model-gateway dao 规范)
|
||||
func (d *accountDao) Create(ctx context.Context, req *walletDto.CreateAccountReq) (id int64, err error) {
|
||||
var e = new(walletEntity.Account)
|
||||
err = gconv.Struct(req, &e)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r, err := gfdb.DB(ctx, public.DbNameWallet).Model(ctx, public.TableNameWalletAccount).Insert(e)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
// ====================== 原子账务 ======================
|
||||
|
||||
// Change 单次原子账务变动(事务透明:须在调用方事务内执行,gfdb.DB(ctx) 自动继承事务)。
|
||||
// 行锁读取 → 校验状态 → 条件更新余额 → 写流水(transaction_no 唯一幂等)。
|
||||
// 锁与事务由上层 service 编排(common 统一锁 + gfdb.DB(ctx).Transaction),本 DAO 不加锁、不开事务。
|
||||
// ⚠️ 不做余额非负拦截:结算允许负余额(欠费,靠充值归正),见技术设计 §9。
|
||||
func (d *accountDao) Change(ctx context.Context, req *walletDto.ChangeAccountReq) error {
|
||||
w, err := d.GetForUpdate(ctx, &walletDto.GetAccountByUserIdReq{UserId: req.UserId})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if w == nil {
|
||||
return errors.New("钱包不存在")
|
||||
}
|
||||
if w.Status != walletConsts.WalletStatusEnabled {
|
||||
return errors.New("钱包状态不可用")
|
||||
}
|
||||
|
||||
// 行锁(GetForUpdate 的 FOR UPDATE)在事务内持续持有,直接以 Go 侧计算结果写回,
|
||||
// 避免浮点金额走字符串拼接进 SQL 引入精度误差;余额写入 NUMERIC(15,2) 列时由 DB 四舍五入。
|
||||
updateData := gdb.Map{
|
||||
walletEntity.AccountCol.Balance: w.Balance + req.DeltaBalance,
|
||||
}
|
||||
result, err := gfdb.DB(ctx, public.DbNameWallet).Model(ctx, public.TableNameWalletAccount).
|
||||
Data(updateData).
|
||||
Where(walletEntity.AccountCol.Id, w.Id).
|
||||
Update()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return errors.New("钱包余额更新失败,请重试")
|
||||
}
|
||||
|
||||
_, err = gfdb.DB(ctx, public.DbNameWallet).Model(ctx, public.TableNameWalletAccountLog).Data(&walletEntity.AccountLog{
|
||||
UserID: req.UserId,
|
||||
WalletID: w.Id,
|
||||
OrderNo: req.OrderNo,
|
||||
TransactionNo: uuid.NewString(),
|
||||
Type: req.Type,
|
||||
Amount: req.Amount,
|
||||
BalanceBefore: w.Balance,
|
||||
BalanceAfter: w.Balance + req.DeltaBalance,
|
||||
Currency: w.Currency,
|
||||
Description: req.Description,
|
||||
ExtraData: req.ExtraData,
|
||||
}).Insert()
|
||||
return err
|
||||
}
|
||||
|
||||
// ====================== 流水 ======================
|
||||
|
||||
// ListLogs 分页获取钱包流水
|
||||
func (d *accountDao) ListLogs(ctx context.Context, req *walletDto.GetAccountLogsReq) (res []walletEntity.AccountLog, total int, err error) {
|
||||
m := gfdb.DB(ctx, public.DbNameWallet).Model(ctx, public.TableNameWalletAccountLog).
|
||||
Where(walletEntity.AccountLogCol.UserID, req.UserId).
|
||||
OrderDesc(walletEntity.AccountLogCol.CreatedAt)
|
||||
if req.Page != nil {
|
||||
m.Page(int(req.Page.PageNum), int(req.Page.PageSize))
|
||||
}
|
||||
r, total, err := m.AllAndCount(false)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = r.Structs(&res)
|
||||
return
|
||||
}
|
||||
@@ -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`, 标准库 testing(TDD)。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 金额一律**元**(float64,2 位小数),不足 1 分向上取整(`ceilFen`)
|
||||
- `modelRule` 加 `MediaPrices map[string]*modelPrice`;`modelMatch` **删 `MediaType` 字段**;`modelRuleMatch` **删 mediaType 等值判定**(仅此一处删匹配逻辑)
|
||||
- 运行时取价统一 `pickModelPrice(rule, usage)`:`mediaPrices[usage.MediaType]` 命中用媒体价,否则默认 `rule.Price`;`modelTokenCalculator.charge` 与 `modelUnitCalculator.charge` 都改
|
||||
- `validateTieredBands`:带长度区间规则按下界升序后**相邻不重叠**;无长度规则不参与;上不封顶档(max=0)须为最后一个长度档;**修复两个同起 0 档位漏检**
|
||||
- `mediaPrices` 校验:键须 ∈ `ModelMediaSet`(`text/audio/video/image`),值非 nil;`modelTokenCalculator.validate` 与 `modelUnitCalculator.validate` 都加,共享 `validateMediaPrices` helper
|
||||
- `ChargeUsage.MediaType` 字段**保留**(上报入参,不删)
|
||||
- 不动 workflow/business 计算器、`pricing_config` DB 结构、`ChargeMode` 枚举、金额单位
|
||||
- **提交排除用户 WIP**:工作树有大量未完成改动(钱包重构、老 pricing 测试删除等,`git status --short` 可查)。commit 一律 `git add <精确路径> && git commit --only <精确路径>`,提交前 `git status --short` 核对只含本任务文件
|
||||
- **注意**:`service/pricing/charge_calc.go` 工作树已含用户未提交 WIP(删 `ItemCount`/`mode()` 等清理,`go build` 已通过)。本计划的编辑叠加其上,commit 该文件时会一并带上这批 WIP 改动(同区域清理,无法用 `--only` 分离,属预期)
|
||||
- 测试命令(已验证无需额外 env):`cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -run <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: 重写 validateTieredBands(TDD)
|
||||
|
||||
**Files:**
|
||||
- Test: `service/pricing/charge_calc_test.go`(新建)
|
||||
- Modify: `service/pricing/charge_calc.go`(import 加 `sort`,305-319 重写)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 的 `modelRule`/`modelMatch` 新结构
|
||||
- Produces: 新语义 `validateTieredBands(rules []modelRule) error`——带长度规则按下界升序判相邻;无长度规则跳过;上不封顶最后
|
||||
|
||||
- [ ] **Step 1: 写失败测试 `service/pricing/charge_calc_test.go`**
|
||||
|
||||
```go
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
func TestValidateTieredBandsAdjacentOK(t *testing.T) {
|
||||
rules := []modelRule{
|
||||
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}},
|
||||
{Match: &modelMatch{InputLengthMin: 32001, InputLengthMax: 128000}},
|
||||
{Match: &modelMatch{InputLengthMin: 128001, InputLengthMax: 256000}},
|
||||
}
|
||||
if err := validateTieredBands(rules); err != nil {
|
||||
t.Fatalf("相邻档位应通过: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTieredBandsSameStartRejected(t *testing.T) {
|
||||
// 旧实现对 InputLengthMin==0 的规则对 continue,两个同起 0 的档位查不出重叠
|
||||
rules := []modelRule{
|
||||
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}},
|
||||
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 64000}},
|
||||
}
|
||||
if err := validateTieredBands(rules); err == nil {
|
||||
t.Fatal("两个同起 0 的档位必须判重叠")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTieredBandsUnsortedOverlapDetected(t *testing.T) {
|
||||
// 乱序提交仍须判重叠(旧实现依赖提交顺序,此用例对旧实现漏检)
|
||||
rules := []modelRule{
|
||||
{Match: &modelMatch{InputLengthMin: 50000, InputLengthMax: 64000}},
|
||||
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 52000}},
|
||||
}
|
||||
if err := validateTieredBands(rules); err == nil {
|
||||
t.Fatal("重叠必须与提交顺序无关地被检出")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTieredBandsOpenEndedLastOK(t *testing.T) {
|
||||
rules := []modelRule{
|
||||
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}},
|
||||
{Match: &modelMatch{InputLengthMin: 32001, InputLengthMax: 0}}, // 上不封顶
|
||||
}
|
||||
if err := validateTieredBands(rules); err != nil {
|
||||
t.Fatalf("上不封顶在最后应通过: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTieredBandsOpenEndedNotLastRejected(t *testing.T) {
|
||||
rules := []modelRule{
|
||||
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}},
|
||||
{Match: &modelMatch{InputLengthMin: 32001, InputLengthMax: 0}}, // 上不封顶
|
||||
{Match: &modelMatch{InputLengthMin: 64001, InputLengthMax: 96000}},
|
||||
}
|
||||
if err := validateTieredBands(rules); err == nil {
|
||||
t.Fatal("上不封顶档之后不能再有长度档")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTieredBandsNonLengthRuleIgnored(t *testing.T) {
|
||||
// thinking 兜底档无长度区间,不应参与重叠校验
|
||||
rules := []modelRule{
|
||||
{Match: &modelMatch{Thinking: boolPtr(false)}},
|
||||
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}},
|
||||
}
|
||||
if err := validateTieredBands(rules); err != nil {
|
||||
t.Fatalf("无长度规则不应干扰: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
Run: `cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -run TestValidateTieredBands -count=1 2>&1 | head -20`
|
||||
Expected: FAIL(`TestValidateTieredBandsSameStartRejected`、`TestValidateTieredBandsUnsortedOverlapDetected` 因旧实现返回 nil 而失败)
|
||||
|
||||
- [ ] **Step 3: 重写 `validateTieredBands`(305-319)+ import 加 `sort`**
|
||||
|
||||
import 块第 3 行 `"errors"` 之后加 `"sort"`:
|
||||
|
||||
```go
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
pricingConsts "shop-user-trade/consts/pricing"
|
||||
)
|
||||
```
|
||||
|
||||
替换整个旧函数(305-319):
|
||||
|
||||
```go
|
||||
// validateTieredBands 阶梯分档校验:带长度区间的规则按下界升序后相邻不重叠。
|
||||
// 无长度区间规则不参与;上不封顶档(max=0)须为最后一个长度档。
|
||||
func validateTieredBands(rules []modelRule) error {
|
||||
var bands []modelRule
|
||||
for _, r := range rules {
|
||||
if r.Match != nil && (r.Match.InputLengthMin > 0 || r.Match.InputLengthMax > 0) {
|
||||
bands = append(bands, r)
|
||||
}
|
||||
}
|
||||
sort.Slice(bands, func(i, j int) bool {
|
||||
return bands[i].Match.InputLengthMin < bands[j].Match.InputLengthMin
|
||||
})
|
||||
var prevMax int64 = -1
|
||||
openEnded := false
|
||||
for _, r := range bands {
|
||||
m := r.Match
|
||||
if openEnded {
|
||||
return errors.New("阶梯档位区间重叠:上不封顶档后不能再有档位")
|
||||
}
|
||||
if m.InputLengthMin <= prevMax {
|
||||
return errors.New("阶梯档位区间重叠:规则间 InputLengthMin 须 > 前一档 InputLengthMax")
|
||||
}
|
||||
if m.InputLengthMax == 0 {
|
||||
openEnded = true // 上不封顶,之后不可再有长度档
|
||||
} else {
|
||||
prevMax = m.InputLengthMax
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行确认通过**
|
||||
|
||||
Run: `cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -run TestValidateTieredBands -count=1`
|
||||
Expected: PASS(6 个用例)
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add service/pricing/charge_calc.go service/pricing/charge_calc_test.go
|
||||
git status --short
|
||||
git commit --only service/pricing/charge_calc.go service/pricing/charge_calc_test.go -m "fix(pricing): validateTieredBands 按下界排序判档位相邻 + 修同起 0 漏检"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: mediaPrices 键/值校验(TDD)
|
||||
|
||||
**Files:**
|
||||
- Test: `service/pricing/charge_calc_test.go`(末尾追加)
|
||||
- Modify: `service/pricing/charge_calc.go`(新增 `validateMediaPrices` helper;token validate 292-296、unit validate 359-363 各加一处调用)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 的 `ModelMediaSet`、`modelRule.MediaPrices`
|
||||
- Produces: `validateMediaPrices(mp map[string]*modelPrice) error`(共享 helper,两计算器调用)
|
||||
|
||||
- [ ] **Step 1: 追加失败测试**
|
||||
|
||||
```go
|
||||
func TestTokenValidateMediaPricesBadKey(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1e6}
|
||||
_, err := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":1},"mediaPrices":{"audios":{"input":2}}}]}`)
|
||||
if err == nil {
|
||||
t.Fatal("mediaPrices 非法键必须报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenValidateMediaPricesNilValue(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1e6}
|
||||
_, err := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":1},"mediaPrices":{"audio":null}}]}`)
|
||||
if err == nil {
|
||||
t.Fatal("mediaPrices 值为空必须报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenValidateMediaPricesValid(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1e6}
|
||||
_, err := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":1},"mediaPrices":{"audio":{"input":2},"video":{"input":3}}}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("合法 mediaPrices 应通过: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnitValidateMediaPricesBadKey(t *testing.T) {
|
||||
c := modelUnitCalculator{base: 1, usage: usageDurationSec}
|
||||
_, err := c.validate(`{"unit":"per_second","rules":[{"name":"r","price":{"unitPrice":1},"mediaPrices":{"snd":{"unitPrice":2}}}]}`)
|
||||
if err == nil {
|
||||
t.Fatal("mediaPrices 非法键必须报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnitValidateMediaPricesValid(t *testing.T) {
|
||||
c := modelUnitCalculator{base: 1, usage: usageDurationSec}
|
||||
_, err := c.validate(`{"unit":"per_second","rules":[{"name":"r","price":{"unitPrice":1},"mediaPrices":{"audio":{"unitPrice":2}}}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("合法 mediaPrices 应通过: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
Run: `cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -run TestMediaPrices -count=1 2>&1 | head -20`
|
||||
Expected: FAIL(`TestTokenValidateMediaPricesBadKey`、`TestTokenValidateMediaPricesNilValue`、`TestUnitValidateMediaPricesBadKey` 旧实现不校验而失败)
|
||||
|
||||
- [ ] **Step 3: 新增 `validateMediaPrices` helper + 两处调用**
|
||||
|
||||
在 `modelRuleMatch` 之后(`matchModelRule` 之前)插入:
|
||||
|
||||
```go
|
||||
// validateMediaPrices mediaPrices 校验:键须 ∈ ModelMediaSet,值须配置 price(token/unit 计算器共用)
|
||||
func validateMediaPrices(mp map[string]*modelPrice) error {
|
||||
for mt, p := range mp {
|
||||
if !pricingConsts.ModelMediaSet[mt] {
|
||||
return errors.New("model 费率 mediaPrices 键须为 text/audio/video/image")
|
||||
}
|
||||
if p == nil {
|
||||
return errors.New("model 费率 mediaPrices 值须配置 price")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
`modelTokenCalculator.validate` 的规则循环(292-296)改为:
|
||||
|
||||
```go
|
||||
for i := range r.Rules {
|
||||
if r.Rules[i].Price == nil {
|
||||
return nil, errors.New("model 费率规则须配置 price")
|
||||
}
|
||||
if err := validateMediaPrices(r.Rules[i].MediaPrices); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`modelUnitCalculator.validate` 的规则循环(359-363)改为:
|
||||
|
||||
```go
|
||||
for i := range r.Rules {
|
||||
if r.Rules[i].Price == nil {
|
||||
return nil, errors.New("model 费率规则须配置 price")
|
||||
}
|
||||
if err := validateMediaPrices(r.Rules[i].MediaPrices); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行确认通过**
|
||||
|
||||
Run: `cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -run TestMediaPrices -count=1`
|
||||
Expected: PASS(5 个用例)
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add service/pricing/charge_calc.go service/pricing/charge_calc_test.go
|
||||
git status --short
|
||||
git commit --only service/pricing/charge_calc.go service/pricing/charge_calc_test.go -m "feat(pricing): mediaPrices 键/值校验 + ModelMediaSet 白名单"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: pickModelPrice 运行时取价 + 两处 charge 接线(TDD)
|
||||
|
||||
**Files:**
|
||||
- Test: `service/pricing/charge_calc_test.go`(末尾追加)
|
||||
- Modify: `service/pricing/charge_calc.go`(新增 `pickModelPrice`;token charge 327 改 `p := pickModelPrice(rule, usage)`;unit charge 373 改)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 的 `modelRule.MediaPrices`、`ChargeUsage.MediaType`(保留字段)
|
||||
- Produces: `pickModelPrice(rule *modelRule, usage *ChargeUsage) *modelPrice`
|
||||
|
||||
- [ ] **Step 1: 追加失败测试**
|
||||
|
||||
```go
|
||||
func TestTokenChargeMediaPricesAudio(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1e6}
|
||||
rules, err := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":0.3,"output":1.8,"cacheHit":0.12},"mediaPrices":{"audio":{"input":4.5,"output":1.8,"cacheHit":1.8}}}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1e6, MediaType: "audio"})
|
||||
if err != nil {
|
||||
t.Fatalf("charge: %v", err)
|
||||
}
|
||||
if got != 4.5 {
|
||||
t.Fatalf("含音频应收媒体价 4.5,实收 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenChargeDefaultWithoutMedia(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1e6}
|
||||
rules, _ := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":0.3,"output":1.8,"cacheHit":0.12},"mediaPrices":{"audio":{"input":4.5,"output":1.8,"cacheHit":1.8}}}]}`)
|
||||
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1e6, MediaType: "text"})
|
||||
if err != nil {
|
||||
t.Fatalf("charge: %v", err)
|
||||
}
|
||||
if got != 0.3 {
|
||||
t.Fatalf("不含媒体应收默认价 0.3,实收 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenChargeMediaPricesMultiType(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1000}
|
||||
rules, _ := c.validate(`{"unit":"per_1K","rules":[{"name":"r","price":{"input":0.3},"mediaPrices":{"audio":{"input":4.5},"video":{"input":6}}}]}`)
|
||||
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1000, MediaType: "video"})
|
||||
if err != nil {
|
||||
t.Fatalf("charge: %v", err)
|
||||
}
|
||||
if got != 6 {
|
||||
t.Fatalf("video 应收媒体价 6,实收 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenChargeMediaPricesUnmatchedTypeUsesDefault(t *testing.T) {
|
||||
// mediaPrices 只有 audio,video 输入落默认价
|
||||
c := modelTokenCalculator{base: 1000}
|
||||
rules, _ := c.validate(`{"unit":"per_1K","rules":[{"name":"r","price":{"input":0.3},"mediaPrices":{"audio":{"input":4.5}}}]}`)
|
||||
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1000, MediaType: "video"})
|
||||
if err != nil {
|
||||
t.Fatalf("charge: %v", err)
|
||||
}
|
||||
if got != 0.3 {
|
||||
t.Fatalf("未配 mediaPrices 的媒体应收默认价 0.3,实收 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnitChargeMediaPrices(t *testing.T) {
|
||||
c := modelUnitCalculator{base: 1, usage: usageDurationSec}
|
||||
rules, err := c.validate(`{"unit":"per_second","rules":[{"name":"r","price":{"unitPrice":1},"mediaPrices":{"audio":{"unitPrice":2}}}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
got, err := c.charge(rules, &ChargeUsage{DurationSec: 3, MediaType: "audio"})
|
||||
if err != nil {
|
||||
t.Fatalf("charge: %v", err)
|
||||
}
|
||||
if got != 6 {
|
||||
t.Fatalf("audio 单位价应收 6,实收 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnitChargeMediaPricesDefault(t *testing.T) {
|
||||
c := modelUnitCalculator{base: 1, usage: usageDurationSec}
|
||||
rules, _ := c.validate(`{"unit":"per_second","rules":[{"name":"r","price":{"unitPrice":1},"mediaPrices":{"audio":{"unitPrice":2}}}]}`)
|
||||
got, err := c.charge(rules, &ChargeUsage{DurationSec: 3, MediaType: "video"})
|
||||
if err != nil {
|
||||
t.Fatalf("charge: %v", err)
|
||||
}
|
||||
if got != 3 {
|
||||
t.Fatalf("未配媒体价应收默认 3,实收 %v", got)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**回归测试(既有 charge/validate 行为,用户裁决:旧 10 测试维持删除,Task 4 补回归)。** 前 6 个 mediaPrices 测试为「红」步骤(旧实现必失败);以下 8 个回归测试覆盖设计未改动却会被 Task 4 触碰的行为,作为绿色安全网(fixture 已去掉 match.mediaType——Task 1 已从结构移除):
|
||||
|
||||
```go
|
||||
// 阶梯算费 fixture(原 fixture 的 mediaType:"text" 已随 match 移除)
|
||||
const modelTokenTieredJSON = `{
|
||||
"unit":"per_1M","tiered":true,
|
||||
"rules":[
|
||||
{"name":"思考≤32000","match":{"thinking":true,"inputLengthMax":32000},"price":{"input":0.6,"output":3.6,"cacheHit":0.12}},
|
||||
{"name":"思考>32000","match":{"thinking":true,"inputLengthMin":32001},"price":{"input":0.9,"output":5.4,"cacheHit":0.18}}
|
||||
]}`
|
||||
|
||||
// 阶梯算费:prompt 20000 → (0.6*20000+3.6*1000)/1e6=0.0156→0.02;50000 → 0.0504→0.06
|
||||
func TestModelTokenCalculator_TieredMatch(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1e6}
|
||||
rules, err := c.validate(modelTokenTieredJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
}
|
||||
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 20000, CompletionTokens: 1000, Thinking: boolPtr(true)})
|
||||
if err != nil {
|
||||
t.Fatalf("charge err: %v", err)
|
||||
}
|
||||
if got != 0.02 {
|
||||
t.Fatalf("tier1 got %v want 0.02", got)
|
||||
}
|
||||
got, err = c.charge(rules, &ChargeUsage{PromptTokens: 50000, CompletionTokens: 1000, Thinking: boolPtr(true)})
|
||||
if err != nil {
|
||||
t.Fatalf("charge err: %v", err)
|
||||
}
|
||||
if got != 0.06 {
|
||||
t.Fatalf("tier2 got %v want 0.06", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 无匹配报错:thinking=false 不在任一档
|
||||
func TestModelTokenCalculator_NoMatch(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1e6}
|
||||
rules, err := c.validate(modelTokenTieredJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
}
|
||||
_, err = c.charge(rules, &ChargeUsage{PromptTokens: 1000, Thinking: boolPtr(false)})
|
||||
if err == nil {
|
||||
t.Fatal("expected no-match error")
|
||||
}
|
||||
}
|
||||
|
||||
// cache-hit 算费:(2000-1000)/1000*1 + 1000/1000*0.1 + 500/1000*2 = 2.1
|
||||
func TestModelTokenCalculator_CacheHit(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1000}
|
||||
rules, err := c.validate(`{"unit":"per_1K","tiered":false,"rules":[{"name":"统一","price":{"input":1,"output":2,"cacheHit":0.1}}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
}
|
||||
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 2000, CompletionTokens: 500, CachedTokens: 1000})
|
||||
if err != nil {
|
||||
t.Fatalf("charge err: %v", err)
|
||||
}
|
||||
if got != 2.1 {
|
||||
t.Fatalf("cache got %v want 2.1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// M3:cached > prompt 时金额不为负(promptInput 钳为 0):0 + 3000/1000*0.1 = 0.3
|
||||
func TestModelTokenCalculator_CachedGreaterThanPrompt(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1000}
|
||||
rules, err := c.validate(`{"unit":"per_1K","tiered":false,"rules":[{"name":"统一","price":{"input":1,"output":2,"cacheHit":0.1}}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
}
|
||||
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1000, CompletionTokens: 0, CachedTokens: 3000})
|
||||
if err != nil {
|
||||
t.Fatalf("charge err: %v", err)
|
||||
}
|
||||
if got < 0 {
|
||||
t.Fatalf("got negative amount %v", got)
|
||||
}
|
||||
if got != 0.3 {
|
||||
t.Fatalf("got %v want 0.3", got)
|
||||
}
|
||||
}
|
||||
|
||||
// P2:validate 拒绝 unit 与实例 base 不匹配的配置
|
||||
func TestModelTokenCalculator_ValidateUnitBaseMismatch(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1e6}
|
||||
if _, err := c.validate(`{"unit":"per_1K","tiered":false,"rules":[{"name":"统一","price":{"input":1}}]}`); err == nil {
|
||||
t.Fatal("expected unit/base mismatch error for per_1M instance with per_1K unit")
|
||||
}
|
||||
c2 := modelTokenCalculator{base: 1000}
|
||||
if _, err := c2.validate(`{"unit":"per_1M","tiered":false,"rules":[{"name":"统一","price":{"input":1}}]}`); err == nil {
|
||||
t.Fatal("expected unit/base mismatch error for per_1K instance with per_1M unit")
|
||||
}
|
||||
if _, err := c.validate(`{"unit":"per_1M","tiered":false,"rules":[{"name":"统一","price":{"input":1}}]}`); err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if _, err := c2.validate(`{"unit":"per_1K","tiered":false,"rules":[{"name":"统一","price":{"input":1}}]}`); err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// P1:validate 拒绝缺 price 的规则(token 计算器)
|
||||
func TestModelTokenCalculator_ValidateNilPrice(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1e6}
|
||||
if _, err := c.validate(`{"unit":"per_1M","tiered":false,"rules":[{"name":"无价"}]}`); err == nil {
|
||||
t.Fatal("expected nil price validation error")
|
||||
}
|
||||
}
|
||||
|
||||
// P1:validate 拒绝缺 price 的规则(单位计算器)
|
||||
func TestModelUnitCalculator_ValidateNilPrice(t *testing.T) {
|
||||
c := modelUnitCalculator{base: 60, usage: usageDurationSec}
|
||||
if _, err := c.validate(`{"unit":"per_minute","tiered":false,"rules":[{"name":"无价"}]}`); err == nil {
|
||||
t.Fatal("expected nil price validation error")
|
||||
}
|
||||
}
|
||||
|
||||
// 单位计算器多路径:按分钟(90s/60*2=3)、按字(10000*0.0005=5)、按张×分辨率(3*0.5=1.5)
|
||||
func TestModelUnitCalculator_Paths(t *testing.T) {
|
||||
c := modelUnitCalculator{base: 60, usage: usageDurationSec}
|
||||
rules, err := c.validate(`{"unit":"per_minute","tiered":false,"rules":[{"name":"统一","price":{"unitPrice":2}}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
}
|
||||
got, err := c.charge(rules, &ChargeUsage{DurationSec: 90})
|
||||
if err != nil || got != 3 {
|
||||
t.Fatalf("per_minute got %v want 3, err %v", got, err)
|
||||
}
|
||||
c2 := modelUnitCalculator{base: 1, usage: usageCharCount}
|
||||
rules2, err := c2.validate(`{"unit":"per_char","tiered":false,"rules":[{"name":"统一","price":{"unitPrice":0.0005}}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
}
|
||||
got, err = c2.charge(rules2, &ChargeUsage{CharCount: 10000})
|
||||
if err != nil || got != 5 {
|
||||
t.Fatalf("per_char got %v want 5, err %v", got, err)
|
||||
}
|
||||
c3 := modelUnitCalculator{base: 1, usage: usageImageCount}
|
||||
rules3, err := c3.validate(`{"unit":"per_1","tiered":false,"rules":[
|
||||
{"name":"512x512","match":{"outputResolution":"512x512"},"price":{"unitPrice":0.2}},
|
||||
{"name":"1024x1024","match":{"outputResolution":"1024x1024"},"price":{"unitPrice":0.5}}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
}
|
||||
got, err = c3.charge(rules3, &ChargeUsage{ImageCount: 3, OutputResolution: "1024x1024"})
|
||||
if err != nil || got != 1.5 {
|
||||
t.Fatalf("per_1 got %v want 1.5, err %v", got, err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(`boolPtr` 已在 Task 2 测试文件定义,直接复用。旧 `TestPerPeriodCalculator`/`TestCalcChargeBySubjectType` 覆盖 business/分派路径,本设计不改动它们,按用户裁决不恢复——记 ledger minor。)
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
Run: `cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -run TestTokenChargeMediaPricesAudio -count=1 2>&1 | head -15`
|
||||
Expected: FAIL(旧实现 `p := rule.Price`,含音频用例收 0.3 而非 4.5;回归测试为绿色网,不在本红步骤验证范围)
|
||||
|
||||
- [ ] **Step 3: 新增 `pickModelPrice` + 两处 charge 接线**
|
||||
|
||||
在 `matchModelRule` 之后(token 计算器之前)插入:
|
||||
|
||||
```go
|
||||
// pickModelPrice 命中规则后按输入媒体取价:mediaPrices 命中使用媒体价,否则默认 price。
|
||||
func pickModelPrice(rule *modelRule, usage *ChargeUsage) *modelPrice {
|
||||
if len(rule.MediaPrices) > 0 {
|
||||
if p, ok := rule.MediaPrices[usage.MediaType]; ok {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return rule.Price
|
||||
}
|
||||
```
|
||||
|
||||
`modelTokenCalculator.charge` 第 327 行 `p := rule.Price` 改为:
|
||||
|
||||
```go
|
||||
p := pickModelPrice(rule, usage)
|
||||
```
|
||||
|
||||
`modelUnitCalculator.charge` 第 373 行改为:
|
||||
|
||||
```go
|
||||
p := pickModelPrice(rule, usage)
|
||||
return ceilFen(roundCost(c.usage(usage) / c.base * p.UnitPrice)), nil
|
||||
```
|
||||
|
||||
(`validate` 已保证 `rule.Price` 与 `mediaPrices` 各值为非 nil,`pickModelPrice` 返回必非 nil。)
|
||||
|
||||
- [ ] **Step 4: 运行确认通过**
|
||||
|
||||
Run: `cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -count=1`
|
||||
Expected: PASS(全部用例,含 Task 2/3/4 共 25 个 = 6 + 5 + 6 新 + 8 回归)
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add service/pricing/charge_calc.go service/pricing/charge_calc_test.go
|
||||
git status --short
|
||||
git commit --only service/pricing/charge_calc.go service/pricing/charge_calc_test.go -m "feat(pricing): pickModelPrice 按输入媒体取媒体价/默认价"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 旧 spec §4.2/§5.2/§8 文档同步 + 全量验证
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/specs/2026-08-28-pricing-enum-design.md`(9 处精确替换)
|
||||
- Modify: `docs/superpowers/specs/2026-09-01-pricing-media-price-design.md`(§5.2 代码块语法修正)
|
||||
- 无 Go 代码改动
|
||||
|
||||
**Interfaces:**
|
||||
- 无(纯文档;同步 v0.4 设计,指向 `2026-09-01-pricing-media-price-design.md`)
|
||||
|
||||
- [ ] **Step 1: 统一结构 JSON 去掉 `discount:null`、rules 行加 mediaPrices(102-112)**
|
||||
|
||||
```json
|
||||
{
|
||||
"unit": "per_1M",
|
||||
"tiered": false,
|
||||
"rules": [
|
||||
{ "name": "规则名", "match": {...}, "price": {...}, "mediaPrices": {"audio": {...}} }
|
||||
],
|
||||
"currency": "CNY"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: match 表删 `mediaType` 行(130)**
|
||||
|
||||
删除该行:
|
||||
|
||||
```
|
||||
| `mediaType` | text/audio/video/image | 输入媒体类型 | 视频「输入含/不含视频」、推理「输入含/不含音频」 |
|
||||
```
|
||||
|
||||
- [ ] **Step 3: price 段追加 mediaPrices 说明(136-138 之后)**
|
||||
|
||||
在「非 token 单位…」行之后追加:
|
||||
|
||||
```
|
||||
**mediaPrices 媒体类型价**(可选):`媒体类型 → price` 的 map,键 ∈ {text,audio,video,image}。输入含该媒体时用对应价;否则用默认 `price`(**默认价 = 输入不含以下媒体**)。一个规则可挂多套媒体价;缺 key 即走默认价。
|
||||
```
|
||||
|
||||
- [ ] **Step 4: model-gateway 关系注记改写(142)**
|
||||
|
||||
```markdown
|
||||
> 与 model-gateway `PriceConfig` 的关系:不复用其字段结构(`inputAudio/cacheHitAudio/cacheStorageHour` 删除,音频输入差价改由 `mediaPrices.audio` 表达)。`discount` 废弃(代码无实现)。v0.4 修订全文见 `2026-09-01-pricing-media-price-design.md`。
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 视频示例去 mediaType、加 mediaPrices(151-152)**
|
||||
|
||||
```json
|
||||
{ "name": "无声-720p", "match": {"outputAudio": false, "outputResolution": "720p"}, "price": {"unitPrice": 0.8}, "mediaPrices": {"video": {"unitPrice": 1.0}} },
|
||||
{ "name": "有声-1080p", "match": {"outputAudio": true, "outputResolution": "1080p"}, "price": {"unitPrice": 1.5}, "mediaPrices": {"video": {"unitPrice": 2.0}} }
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 推理示例去掉文本遮蔽规则、音频差价改 mediaPrices(162-165)**
|
||||
|
||||
```json
|
||||
{ "name": "思考-输入≤32000", "match": {"thinking": true, "inputLengthMax": 32000}, "price": {"input": 0.6, "output": 3.6, "cacheHit": 0.12}, "mediaPrices": {"audio": {"input": 9, "output": 3.6, "cacheHit": 1.8}} },
|
||||
{ "name": "思考-输入32001~128000", "match": {"thinking": true, "inputLengthMin": 32001, "inputLengthMax": 128000}, "price": {"input": 0.9, "output": 5.4, "cacheHit": 0.18}, "mediaPrices": {"audio": {"input": 13.5, "output": 5.4, "cacheHit": 2.7}} },
|
||||
{ "name": "非思考-统一价", "match": {"thinking": false}, "price": {"input": 0.3, "output": 1.2, "cacheHit": 0.06} }
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Save 校验行同步(181)**
|
||||
|
||||
```markdown
|
||||
- Save 校验:`unit ∈ {per_1K, per_1M, per_1, per_second, per_minute, per_hour, per_char}`;`tiered:true` 仅 token 单位且 inputLength 档按下界排序相邻不重叠(上不封顶档须最后);mediaPrices 键 ∈ {text,audio,video,image} 且值非空;match 字段取值合法;price 非负。
|
||||
```
|
||||
|
||||
- [ ] **Step 8: §5.2 折扣行改写(210)**
|
||||
|
||||
```markdown
|
||||
- 折扣:废弃(v0.4 修订为规则级 `mediaPrices`,见 `2026-09-01-pricing-media-price-design.md`)。
|
||||
```
|
||||
|
||||
- [ ] **Step 9: §8 决策表「价格项收敛」行改写(296)**
|
||||
|
||||
```markdown
|
||||
| 价格项收敛 input/output/cacheHit + unitPrice | 四类模型一套结构;音频输入差价用 `mediaPrices.audio` 表达(删 inputAudio/cacheHitAudio/cacheStorageHour 与 match.mediaType) |
|
||||
```
|
||||
|
||||
(`ChargeUsage.MediaType` §5.3 225 行保留不动——上报入参字段。)
|
||||
|
||||
- [ ] **Step 10: 设计文档 §5.2 代码块语法修正**
|
||||
|
||||
`docs/superpowers/specs/2026-09-01-pricing-media-price-design.md` §5.2 的 mediaPrices 校验代码块中,`ModelMediaSet` 是 `map[string]struct{}`,`!pricingConsts.ModelMediaSet[mt]` 无法编译。将该判断改为 `_, ok` 成员判定(与实现一致;实现已将校验提取为共享 `validateMediaPrices` helper):
|
||||
|
||||
```go
|
||||
if len(r.Rules[i].MediaPrices) > 0 {
|
||||
for mt, mp := range r.Rules[i].MediaPrices {
|
||||
if _, ok := pricingConsts.ModelMediaSet[mt]; !ok {
|
||||
return nil, errors.New("model 费率 mediaPrices 键须为 text/audio/video/image")
|
||||
}
|
||||
if mp == nil {
|
||||
return nil, errors.New("model 费率 mediaPrices 值须配置 price")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 11: 全量编译 + 全量测试**
|
||||
|
||||
Run: `cd /c/App/GolandProjects/shop-user-trade && go build ./... && GF_GCFG_PATH="C:/App/GolandProjects/shop-user-trade" go test ./service/pricing/ ./model/dto/pricing/ -count=1`
|
||||
Expected: PASS(service/pricing 25 用例全绿;GF_GCFG_PATH 规避 common/consul init panic)
|
||||
|
||||
- [ ] **Step 12: 提交**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/specs/2026-08-28-pricing-enum-design.md docs/superpowers/specs/2026-09-01-pricing-media-price-design.md
|
||||
git status --short
|
||||
git commit --only docs/superpowers/specs/2026-08-28-pricing-enum-design.md docs/superpowers/specs/2026-09-01-pricing-media-price-design.md -m "docs(pricing): 枚举设计 §4.2/§5.2/§8 同步 mediaPrices v0.4 + 设计文档 §5.2 语法修正"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 记录
|
||||
|
||||
- **Spec 覆盖**:v0.4 spec §3(结构变更)→ Task 1;§5.1(validateTieredBands)→ Task 2;§5.2(mediaPrices 校验 + ModelMediaSet)→ Task 1/3;§4.1(pickModelPrice + 两处 charge)→ Task 4;§8 兼容与迁移(match.mediaType 移除、discount 废弃、旧 spec 同步)→ Task 1/5;§10 测试要点 → Task 2-4 全覆盖(相邻合法/同起0/乱序/上不封顶/无长度规则;audio/默认价/多类型/未匹配走默认;键非法/值 nil/两计算器)。
|
||||
- **占位符扫描**:无 TBD/TODO;所有代码块为完整可粘贴代码;错误文案与 spec §5.2 逐字一致。
|
||||
- **类型一致性**:`ModelMediaSet`(Task1 定义)Task3 消费;`modelRule.MediaPrices`(Task1)Task3/4 消费;`pickModelPrice`/`validateMediaPrices` 签名在定义与调用处一致;`modelMatch` 无 MediaType 贯穿全部测试构造。
|
||||
- **测试互不依赖**:Task 3/4 测试追加到同一 `charge_calc_test.go`,Task 4 全量跑含 Task 2/3 用例(25 个 = Task2 6 + Task3 5 + Task4 媒体价 6 + 回归 8),顺序无关。
|
||||
- **回归裁决**:Task 2 审查发现旧 charge_calc_test.go(HEAD 存在 10 测试)被覆盖删除,用户裁决「维持删除,Task 4 补回归」——Task 4 已加 8 个回归测试(阶梯算费/无匹配/cache-hit/M3 负值钳制/P2 base 不匹配/P1 nil-price×2/单位多路径),fixture 去 mediaType;per_period/分派路径未恢复(设计不改动),记 ledger minor。
|
||||
- **范围外死代码删除(最终审查裁决)**:Task 1 顺带删除了共享 `calculator` 接口的 `mode()` 方法(6 个计算器实现)与 `ChargeUsage.ItemCount` 字段。最终整分支审查(opus)核实为**死代码**——`.mode(` 全模块零调用(分派走显式 workflowCalculators/modelCalculators/businessCalculators maps + getCalculator),`ItemCount` 零 Go 引用(仅 charge_dto.go:33 doc 注释提及)。计划 §11「不改动 ChargeUsage 字段 / workflow-business 计算器」文本与之冲突,用户裁决「保留删除 + 记录确认」。
|
||||
@@ -1,282 +0,0 @@
|
||||
# 按 SubjectType 查询可选计费方式 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 提供 `GET /pricing/controller/subjects/charge-modes?subjectType=X&subjectId=Y`,返回该主体可选计费方式(mode + 中文名),供前端渲染"选择计费方式"下拉。
|
||||
|
||||
**Architecture:** 纯常量服务,零外部依赖。计费方式中文名 `ChargeModeNames` map + workflow 三模式 `WorkflowChargeModes` 常量放 `consts/pricing/`;DTO 定义请求/响应;`subject_service.go` 新增 `ListChargeModes` 按 subjectType+subjectId 派发(workflow 校验固定 subjectId,business 查 `BusinessSubjects`);controller 注册 `ChargeModes` 方法。
|
||||
|
||||
**Tech Stack:** Go 1.26.1,GoFrame v2(请求 DTO 带 `g.Meta` 路由标签),标准库 testing。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `subjectType` 校验 `in:workflow,business`;**model 非法**——模型计费方式无需选择,误传 400(spec §2)
|
||||
- `subjectId` 必填:workflow 时须=`WorkflowSubjectID`(`workflow`);business 时须∈`BusinessSubjects`,否则报「未知业务模块」(spec §2/§3)
|
||||
- 纯查常量:不调 model-gateway、不查 `pricing_config`(spec §3)
|
||||
- 路由 `path:"/subjects/charge-modes" method:"get"`,URL 前缀 `/pricing/controller/`(复用既有 SubjectListReq 前缀)
|
||||
- 中文名覆盖全部 10 个枚举(spec §4 表,值逐字照抄:按条/按秒/按token/每千token/每百万token/每张·每个/每分钟/每小时/每字/周期订阅);`name` 未命中回退 `mode` 本身
|
||||
- `WorkflowChargeModes` 三键须与 `charge_calc.go` 的 `workflowCalculators` 一致:`per_item, per_second, per_token`
|
||||
- 不改 `/pricing/subjects` 枚举、`pricing_config` save 校验、计算器注册表、DB 结构
|
||||
- 金额一律不动;新增代码不动 `ChargeMode` 常量本身
|
||||
|
||||
---
|
||||
## Task 1: 常量与 DTO
|
||||
|
||||
**Files:**
|
||||
- Modify: `consts/pricing/charge_mode.go`(文件末尾追加)
|
||||
- Modify: `consts/pricing/subject.go`(`BusinessSubjects` 之后追加)
|
||||
- Modify: `model/dto/pricing/subject_dto.go`(`SubjectInfo` 之后追加)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 既有 `ChargeMode` 常量(`per_item`/`per_second`/`per_token`/`per_1K`/`per_1M`/`per_1`/`per_minute`/`per_hour`/`per_char`/`per_period`)、`WorkflowSubjectID`、`BusinessSubjects`、`SubjectTypeWorkflow`
|
||||
- Produces:
|
||||
- `pricingConsts.ChargeModeNames map[ChargeMode]string`(10 键全枚举)
|
||||
- `pricingConsts.WorkflowChargeModes []ChargeMode`(三键,声明序)
|
||||
- `pricingDto.ChargeModeListReq{SubjectType, SubjectID string}`(g.Meta path `/subjects/charge-modes` method get;`subjectType v:"required|in:workflow,business"`、`subjectId v:"required"`)
|
||||
- `pricingDto.ChargeModeListRes{SubjectType, SubjectID string; ChargeModes []ChargeModeInfo}`
|
||||
- `pricingDto.ChargeModeInfo{Mode, Name string}`
|
||||
|
||||
- [ ] **Step 1: `consts/pricing/charge_mode.go` 追加中文名 map**
|
||||
|
||||
在文件末尾(`PeriodSet` 之后)追加:
|
||||
|
||||
```go
|
||||
|
||||
// ChargeModeNames 计费方式中文名(前端下拉渲染;全枚举覆盖,未命中回退 mode 本身)
|
||||
var ChargeModeNames = map[ChargeMode]string{
|
||||
ChargeModePerItem: "按条",
|
||||
ChargeModePerSecond: "按秒",
|
||||
ChargeModePerToken: "按token",
|
||||
ChargeModePer1K: "每千token",
|
||||
ChargeModePer1M: "每百万token",
|
||||
ChargeModePer1: "每张/每个",
|
||||
ChargeModePerMinute: "每分钟",
|
||||
ChargeModePerHour: "每小时",
|
||||
ChargeModePerChar: "每字",
|
||||
ChargeModePerPeriod: "周期订阅",
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `consts/pricing/subject.go` 追加 workflow 三模式**
|
||||
|
||||
在 `BusinessSubjects` 定义之后追加:
|
||||
|
||||
```go
|
||||
|
||||
// WorkflowChargeModes workflow 主体可选计费方式(与 charge_calc.go workflowCalculators 三键一致)
|
||||
var WorkflowChargeModes = []ChargeMode{ChargeModePerItem, ChargeModePerSecond, ChargeModePerToken}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `model/dto/pricing/subject_dto.go` 追加 DTO**
|
||||
|
||||
在 `SubjectInfo` 之后追加:
|
||||
|
||||
```go
|
||||
|
||||
// ChargeModeListReq 按主体查询可选计费方式请求
|
||||
type ChargeModeListReq struct {
|
||||
g.Meta `path:"/subjects/charge-modes" method:"get" tags:"计价配置" summary:"查询主体可选计费方式" dc:"按 subjectType+subjectId 返回该主体可选计费方式(workflow/business;model 无需选择不入参)"`
|
||||
|
||||
SubjectType string `json:"subjectType" v:"required|in:workflow,business" dc:"计价对象类型(model 非法:模型计费方式无需选择)"`
|
||||
SubjectID string `json:"subjectId" v:"required" dc:"计价对象ID:workflow→workflow;business→业务模块标识"`
|
||||
}
|
||||
|
||||
// ChargeModeListRes 可选计费方式响应
|
||||
type ChargeModeListRes struct {
|
||||
SubjectType string `json:"subjectType" dc:"计价对象类型"`
|
||||
SubjectID string `json:"subjectId" dc:"计价对象ID"`
|
||||
ChargeModes []ChargeModeInfo `json:"chargeModes" dc:"可选计费方式列表"`
|
||||
}
|
||||
|
||||
// ChargeModeInfo 计费方式项
|
||||
type ChargeModeInfo struct {
|
||||
Mode string `json:"mode" dc:"提交值:per_item/per_second/per_token/per_1K/per_1M/per_1/per_minute/per_hour/per_char/per_period"`
|
||||
Name string `json:"name" dc:"展示名:按条/按秒/按token/每千token/每百万token/每张·每个/每分钟/每小时/每字/周期订阅"`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 编译验证**
|
||||
|
||||
Run: `cd /c/App/GolandProjects/shop-user-trade && go build ./consts/... ./model/dto/...`
|
||||
Expected: PASS(无输出即成功)
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add consts/pricing/charge_mode.go consts/pricing/subject.go model/dto/pricing/subject_dto.go
|
||||
git commit -m "feat(pricing): 计费方式中文名 + workflow 三模式常量 + charge-modes DTO"
|
||||
```
|
||||
(注意:工作树有用户 WIP 已暂存,`git add` 指定路径、commit 用 `--only` 排除 WIP——见 Global 注意事项,提交前确认 `git status` 只含本任务文件)
|
||||
|
||||
---
|
||||
## Task 2: 服务逻辑 + 控制器 + 测试
|
||||
|
||||
**Files:**
|
||||
- Modify: `service/pricing/subject_service.go`(`modelTypeName` 之后追加)
|
||||
- Modify: `controller/pricing/pricing_controller.go`(`Subjects` 方法之后追加)
|
||||
- Create: `service/pricing/subject_service_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 产物 `pricingConsts.ChargeModeNames`、`pricingConsts.WorkflowChargeModes`、`pricingDto.ChargeModeListReq/Res/Info`;既有 `pricingConsts.SubjectTypeWorkflow/Business`、`WorkflowSubjectID`、`BusinessSubjects`、`ChargeMode`;既有 `var Pricing = new(pricing)` 与 `func (s *pricing)` 接收者模式(pricing_service.go:36-38)
|
||||
- Produces: `(*pricing).ListChargeModes(ctx, *pricingDto.ChargeModeListReq) (*pricingDto.ChargeModeListRes, error)`;controller 方法 `ChargeModes`
|
||||
|
||||
- [ ] **Step 1: 写失败测试 `service/pricing/subject_service_test.go`**
|
||||
|
||||
```go
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
pricingConsts "shop-user-trade/consts/pricing"
|
||||
pricingDto "shop-user-trade/model/dto/pricing"
|
||||
)
|
||||
|
||||
// 纯常量逻辑,无 DB 依赖(service 包无 init,直接调 Pricing 单例)
|
||||
func TestListChargeModesWorkflow(t *testing.T) {
|
||||
res, err := Pricing.ListChargeModes(context.Background(), &pricingDto.ChargeModeListReq{
|
||||
SubjectType: string(pricingConsts.SubjectTypeWorkflow),
|
||||
SubjectID: pricingConsts.WorkflowSubjectID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if res.SubjectType != "workflow" || len(res.ChargeModes) != 3 {
|
||||
t.Fatalf("got %+v", res)
|
||||
}
|
||||
want := []string{"per_item", "per_second", "per_token"}
|
||||
for i, m := range res.ChargeModes {
|
||||
if m.Mode != want[i] {
|
||||
t.Fatalf("mode[%d]=%s want %s", i, m.Mode, want[i])
|
||||
}
|
||||
if m.Name == "" {
|
||||
t.Fatalf("mode %s 缺中文名", m.Mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListChargeModesWorkflowWrongSubjectID(t *testing.T) {
|
||||
if _, err := Pricing.ListChargeModes(context.Background(), &pricingDto.ChargeModeListReq{
|
||||
SubjectType: "workflow", SubjectID: "not-workflow",
|
||||
}); err == nil {
|
||||
t.Fatal("expected error for wrong workflow subjectId")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListChargeModesBusiness(t *testing.T) {
|
||||
res, err := Pricing.ListChargeModes(context.Background(), &pricingDto.ChargeModeListReq{
|
||||
SubjectType: "business", SubjectID: "ai_customer_service",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(res.ChargeModes) != 1 || res.ChargeModes[0].Mode != "per_period" {
|
||||
t.Fatalf("got %+v", res)
|
||||
}
|
||||
if res.ChargeModes[0].Name != "周期订阅" {
|
||||
t.Fatalf("name=%s want 周期订阅", res.ChargeModes[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListChargeModesBusinessUnknown(t *testing.T) {
|
||||
if _, err := Pricing.ListChargeModes(context.Background(), &pricingDto.ChargeModeListReq{
|
||||
SubjectType: "business", SubjectID: "nope",
|
||||
}); err == nil {
|
||||
t.Fatal("expected unknown business error")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
Run: `cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -run TestListChargeModes -count=1 2>&1 | head -20`
|
||||
Expected: FAIL(`Pricing.ListChargeModes undefined`)
|
||||
|
||||
- [ ] **Step 3: 实现服务方法**
|
||||
|
||||
`service/pricing/subject_service.go` 末尾(`modelTypeName` 函数之后)追加:
|
||||
|
||||
```go
|
||||
|
||||
// ListChargeModes 按 (subjectType, subjectId) 返回主体自身可选计费方式(spec 2026-09-01)。
|
||||
// model 无需选择不入参(subjectType 校验层拦截);workflow/business 纯查常量,零外部依赖。
|
||||
func (s *pricing) ListChargeModes(ctx context.Context, req *pricingDto.ChargeModeListReq) (*pricingDto.ChargeModeListRes, error) {
|
||||
switch pricingConsts.SubjectType(req.SubjectType) {
|
||||
case pricingConsts.SubjectTypeWorkflow:
|
||||
if req.SubjectID != pricingConsts.WorkflowSubjectID {
|
||||
return nil, fmt.Errorf("workflow 主体 subjectId 必须为 %q", pricingConsts.WorkflowSubjectID)
|
||||
}
|
||||
return &pricingDto.ChargeModeListRes{
|
||||
SubjectType: req.SubjectType,
|
||||
SubjectID: req.SubjectID,
|
||||
ChargeModes: toChargeModeInfo(pricingConsts.WorkflowChargeModes),
|
||||
}, nil
|
||||
case pricingConsts.SubjectTypeBusiness:
|
||||
for _, b := range pricingConsts.BusinessSubjects {
|
||||
if b.SubjectID == req.SubjectID {
|
||||
return &pricingDto.ChargeModeListRes{
|
||||
SubjectType: req.SubjectType,
|
||||
SubjectID: req.SubjectID,
|
||||
ChargeModes: toChargeModeInfo(b.ChargeModes),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("未知业务模块: %s", req.SubjectID)
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的计价对象类型: %s", req.SubjectType)
|
||||
}
|
||||
}
|
||||
|
||||
// toChargeModeInfo 计费方式 → {mode, name}(name 未命中回退 mode,避免展示空串)
|
||||
func toChargeModeInfo(modes []pricingConsts.ChargeMode) []pricingDto.ChargeModeInfo {
|
||||
items := make([]pricingDto.ChargeModeInfo, 0, len(modes))
|
||||
for _, m := range modes {
|
||||
name, ok := pricingConsts.ChargeModeNames[m]
|
||||
if !ok {
|
||||
name = string(m)
|
||||
}
|
||||
items = append(items, pricingDto.ChargeModeInfo{Mode: string(m), Name: name})
|
||||
}
|
||||
return items
|
||||
}
|
||||
```
|
||||
|
||||
(`fmt` 已在文件现有 import 中;`context` 已在现有 import 中——见 subject_service.go:1-12)
|
||||
|
||||
- [ ] **Step 4: 注册控制器方法**
|
||||
|
||||
`controller/pricing/pricing_controller.go` 的 `Subjects` 方法之后追加:
|
||||
|
||||
```go
|
||||
|
||||
// ChargeModes 查询主体可选计费方式
|
||||
func (c *pricingController) ChargeModes(ctx context.Context, req *pricingDto.ChargeModeListReq) (*pricingDto.ChargeModeListRes, error) {
|
||||
return pricingService.Pricing.ListChargeModes(ctx, req)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行测试确认通过**
|
||||
|
||||
Run: `cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -run TestListChargeModes -count=1`
|
||||
Expected: PASS(4 个用例)
|
||||
|
||||
- [ ] **Step 6: 全量编译 + 全量测试**
|
||||
|
||||
Run: `cd /c/App/GolandProjects/shop-user-trade && go build ./... && go test ./service/pricing/ ./model/dto/pricing/ -count=1`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: 提交**
|
||||
|
||||
```bash
|
||||
git add service/pricing/subject_service.go service/pricing/subject_service_test.go controller/pricing/pricing_controller.go
|
||||
git commit -m "feat(pricing): 按 subjectType+subjectId 查询可选计费方式接口"
|
||||
```
|
||||
(commit 用 `--only` 指定上述文件,排除用户 WIP;提交前 `git status --short` 核对只含本任务文件)
|
||||
|
||||
---
|
||||
## Self-Review 记录
|
||||
|
||||
- **Spec 覆盖**:§2 契约(DTO 路由/校验)、§3 数据来源(纯常量、workflow 校验固定 ID、business 查列表)、§4 中文名全枚举、§5 改动清单 1-6 全部映射到 Task 1/2。
|
||||
- **占位符扫描**:无 TBD/TODO;所有代码块为完整可粘贴代码。
|
||||
- **类型一致性**:`ChargeModeNames`/`WorkflowChargeModes`/`ChargeModeListReq/Res/Info` 在 Task 1 定义、Task 2 消费,命名一致;`ListChargeModes`/`ChargeModes`/`toChargeModeInfo` 签名跨步骤一致。
|
||||
@@ -1,323 +0,0 @@
|
||||
# 定价模块计价对象枚举化与模型计价设计
|
||||
|
||||
> 版本:v0.3(本期)—— 在 v0.2(无预扣)基础上,将计价对象固定枚举化 + 接入模型计价与业务模块周期计价。
|
||||
> 范围:shop-user-trade **pricing 计价模块**。交付「枚举 + 配置管理 + per_1M/per_period 计算器」层;模型/业务模块结算接入另期。
|
||||
> 约定:金额一律**元**(float64,DB `NUMERIC(15,2)`,2 位小数),不足 1 分向上取整(`ceilFen`)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
现状(v0.2):`pricing_config` 是「一 `biz_key` → 单一 `charge_mode` + 单模式 `rules`」,只能给任意业务标识配一种计费方式。
|
||||
|
||||
诉求:
|
||||
1. 计价对象**固定枚举**:`工作流` + `模型列表`(model-gateway 系统模型)+ `业务模块`(如 AI客服)。后端提供枚举接口,管理端为这些项配置价格标准。
|
||||
2. 工作流可**同时配置三套价格**(per_item / per_second / per_token),创建/使用工作流时按本次执行**选择一种**扣费方式。
|
||||
3. 模型计价按模型类型自适应:视频(输出有声/无声 × 分辨率 × 输入含不含视频 × 按token/按秒)、推理(阶梯 × 思考/非思考 × 输入token区间 × 输入输出价 × 输入含不含音频)、音频(按字数/时长/按token)、图片(按分辨率 × 按张数)。
|
||||
4. 业务模块按**周期**订阅计价(按年),通用 `per_period` 建模,后续可扩月/季。
|
||||
|
||||
目标:模型价格**单一来源**(只配一份,工作流与直接调模型共用);计价对象枚举 = 表结构;计算器注册表统一派发。
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心概念:计价对象(subject)枚举
|
||||
|
||||
`pricing_config` 按 `(subject_type, subject_id)` 唯一:
|
||||
|
||||
| subject_type | subject_id | 说明 |
|
||||
|---|---|---|
|
||||
| `workflow` | `workflow` | 固定单主体,规则为三模式容器 |
|
||||
| `model` | model-gateway 模型 **ID** | 每个系统模型一个主体,规则为其模型类型自适应计费规则(见 §4.2) |
|
||||
| `business` | 业务模块标识(如 `ai_customer_service`) | 固定写死的一组业务模块,规则为周期订阅价格 |
|
||||
|
||||
枚举接口 `GET /pricing/subjects` 返回全部可配置主体:
|
||||
- `workflow`(静态,恒有,附 `chargeModes: [per_item, per_second, per_token]`)
|
||||
- 各系统模型(**实时**调 model-gateway `/listModelManage`,`system_model=true` 过滤,返回 modelId/modelName/modelType)
|
||||
- 各业务模块(静态写死,如 AI客服,附 `chargeModes: [per_period]`)
|
||||
- 每项附 `hasConfig` + `enabled`,管理端据此区分未配价主体。
|
||||
|
||||
模型主体以 **modelId** 标识(稳定不变),枚举同时返回 modelName 供展示。业务模块列表固定写死在 `consts/pricing`(新增业务模块改常量,暂不做管理端增删)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据模型
|
||||
|
||||
### 3.1 pricing_config(改造:biz_key/charge_mode → subject_type/subject_id)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS pricing_config (
|
||||
id BIGINT PRIMARY KEY,
|
||||
-- 基础字段(SQLBaseDO 同前)
|
||||
tenant_id BIGINT NOT NULL DEFAULT 0,
|
||||
creator VARCHAR(64) NOT NULL DEFAULT '', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updater VARCHAR(64) NOT NULL DEFAULT '', updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at timestamp(6),
|
||||
subject_type VARCHAR(16) NOT NULL, -- workflow | model | business
|
||||
subject_id VARCHAR(64) NOT NULL, -- workflow | model-gateway 模型ID | 业务模块标识
|
||||
rules TEXT NOT NULL, -- 见 §4
|
||||
min_balance NUMERIC(15,2) NOT NULL DEFAULT 0, -- 门禁(元),0=不校验
|
||||
currency VARCHAR(16) NOT NULL DEFAULT 'CNY',
|
||||
enabled SMALLINT NOT NULL DEFAULT 1,
|
||||
version BIGINT NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_pricing_config_subject ON pricing_config(subject_type, subject_id);
|
||||
```
|
||||
|
||||
- **去掉 `charge_mode` 列**:rules 自描述(workflow 三模式都在 rules 里;model 的 unit/tiered 在 modelCfg 内)。
|
||||
- 存量迁移:旧 `biz_key` 行 → `subject_type='workflow', subject_id=biz_key`;`charge_mode` 列 DROP。线上无真实数据可直接重建表(本期结算未接)。
|
||||
|
||||
### 3.2 charge_order(改造:biz_key → subject_type/subject_id)
|
||||
|
||||
```sql
|
||||
-- 唯一幂等键:uk_charge_order_subject (subject_type, subject_id, biz_order_no)
|
||||
subject_type VARCHAR(16) NOT NULL,
|
||||
subject_id VARCHAR(64) NOT NULL,
|
||||
charge_mode VARCHAR(32) NOT NULL, -- 建单时选中/派发键:per_item/per_second/per_token/per_1K/per_1M/per_1/per_minute/per_hour/per_char/per_period
|
||||
rule_snapshot TEXT NOT NULL, -- workflow: 选中模式的规则片段;model: 整个 modelCfg(§4.2);business: 周期价格规则
|
||||
-- 其余字段(user_id/status/actual_amount/usage/settle_time 等)不变
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. rules JSON 格式
|
||||
|
||||
### 4.1 workflow —— 多模式容器(三套价格全配,只配已配置的)
|
||||
|
||||
```json
|
||||
{
|
||||
"per_item": { "tiers": [ {"maxSec":15,"price":29}, {"maxSec":30,"price":49}, {"maxSec":60,"price":89} ], "overflowUnitPrice": 0.9 },
|
||||
"per_second": { "unitPrice": 0.9 },
|
||||
"per_token": {}
|
||||
}
|
||||
```
|
||||
|
||||
- mode 名 ∈ `{per_item, per_second, per_token}`;未配的模式不出现。
|
||||
- `per_token` 为 `{}`(仅启用标记),**不含价格映射**——价格取自各模型自己的计价配置(见 §5.3)。
|
||||
- Save 校验:逐个校验已配模式的 JSON 结构(档位/单价/模型名合法、mode 名合法)。
|
||||
|
||||
### 4.2 model —— 模型类型自适应计费规则
|
||||
|
||||
统一结构(rules 按序**首条命中**,先写优先级高):
|
||||
|
||||
```json
|
||||
{
|
||||
"unit": "per_1M",
|
||||
"tiered": false,
|
||||
"rules": [
|
||||
{ "name": "规则名", "match": {...}, "price": {...}, "mediaPrices": {"audio": {...}} }
|
||||
],
|
||||
"currency": "CNY"
|
||||
}
|
||||
```
|
||||
|
||||
**unit 计费单位**(7 种,charge_mode 集合的子集):
|
||||
|
||||
| unit | 语义 | 适用 |
|
||||
|---|---|---|
|
||||
| `per_1M` | 每百万 token | 推理 / 视频按token / 音频按token |
|
||||
| `per_1K` | 每千 token | 同上(小模型) |
|
||||
| `per_1` | 每个(每张图) | 图片按张数 |
|
||||
| `per_second` | 每秒 | 视频按时长 |
|
||||
| `per_minute` | 每分钟 | 音频时长 |
|
||||
| `per_hour` | 每小时 | 音频时长 |
|
||||
| `per_char` | 每字 | 音频按字数 |
|
||||
|
||||
**match 命中条件**(全可选,空=任意调用;命中=全部条件满足):
|
||||
|
||||
| 字段 | 值 | 说明 | 用于 |
|
||||
|---|---|---|---|
|
||||
| `thinking` | true/false | 思考/非思考模式 | 推理 |
|
||||
| `outputAudio` | true/false | 输出有声/无声 | 视频 |
|
||||
| `outputResolution` | 自由字符串 | 输出分辨率(视频 480p/720p/1080p/2k/4k;图片 512x512/1024x1024) | 视频、图片 |
|
||||
| `inputLengthMin/Max` | int | 输入 token 范围(>=/<=,阶梯分档用) | 推理 |
|
||||
|
||||
**price 计价项**:
|
||||
- token 单位(per_1K/per_1M):`input`(输入单价)、`output`(输出单价)、`cacheHit`(缓存命中输入单价,缺省 0=无优惠)
|
||||
- 非 token 单位(per_1/per_second/per_minute/per_hour/per_char):`unitPrice`(每单位单价)
|
||||
- **mediaPrices 媒体类型价**(可选):`媒体类型 → price` 的 map,键 ∈ {text,audio,video,image}。输入含该媒体时用对应价;否则用默认 `price`(**默认价 = 输入不含以下媒体**)。一个规则可挂多套媒体价;缺 key 即走默认价。
|
||||
|
||||
**tiered 阶梯(分档不累加)**:仅 token 单位;`tiered:true` 表示按输入 token 分档——档位区间相邻不重叠(下档 max+1=上档 min),结算按 promptTokens 命中哪档就整单按哪档 input/output/cacheHit 价;`tiered:false` 规则间靠其他维度区分。
|
||||
|
||||
> 与 model-gateway `PriceConfig` 的关系:不复用其字段结构(`inputAudio/cacheHitAudio/cacheStorageHour` 删除,音频输入差价改由 `mediaPrices.audio` 表达)。`discount` 废弃(代码无实现)。v0.4 修订全文见 `2026-09-01-pricing-media-price-design.md`。
|
||||
|
||||
**示例**:
|
||||
|
||||
视频 · 按秒(分辨率 × 有声/无声 × 输入含不含视频):
|
||||
```json
|
||||
{
|
||||
"unit": "per_second", "tiered": false,
|
||||
"rules": [
|
||||
{ "name": "无声-720p", "match": {"outputAudio": false, "outputResolution": "720p"}, "price": {"unitPrice": 0.8}, "mediaPrices": {"video": {"unitPrice": 1.0}} },
|
||||
{ "name": "有声-1080p", "match": {"outputAudio": true, "outputResolution": "1080p"}, "price": {"unitPrice": 1.5}, "mediaPrices": {"video": {"unitPrice": 2.0}} }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
推理 · 按token 阶梯(思考/非思考 × 输入token档 × 输入含不含音频):
|
||||
```json
|
||||
{
|
||||
"unit": "per_1M", "tiered": true,
|
||||
"rules": [
|
||||
{ "name": "思考-输入≤32000", "match": {"thinking": true, "inputLengthMax": 32000}, "price": {"input": 0.6, "output": 3.6, "cacheHit": 0.12}, "mediaPrices": {"audio": {"input": 9, "output": 3.6, "cacheHit": 1.8}} },
|
||||
{ "name": "思考-输入32001~128000", "match": {"thinking": true, "inputLengthMin": 32001, "inputLengthMax": 128000}, "price": {"input": 0.9, "output": 5.4, "cacheHit": 0.18}, "mediaPrices": {"audio": {"input": 13.5, "output": 5.4, "cacheHit": 2.7}} },
|
||||
{ "name": "非思考-统一价", "match": {"thinking": false}, "price": {"input": 0.3, "output": 1.2, "cacheHit": 0.06} }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
音频 · 按字数;图片 · 按张数×分辨率:
|
||||
```json
|
||||
{ "unit": "per_char", "tiered": false, "rules": [ { "name": "语音-统一价(万字符5元)", "price": {"unitPrice": 0.0005} } ] }
|
||||
|
||||
{ "unit": "per_1", "tiered": false,
|
||||
"rules": [
|
||||
{ "name": "512x512", "match": {"outputResolution": "512x512"}, "price": {"unitPrice": 0.2} },
|
||||
{ "name": "1024x1024", "match": {"outputResolution": "1024x1024"}, "price": {"unitPrice": 0.5} }
|
||||
] }
|
||||
```
|
||||
|
||||
- Save 校验:`unit ∈ {per_1K, per_1M, per_1, per_second, per_minute, per_hour, per_char}`;`tiered:true` 仅 token 单位且 inputLength 档按下界排序相邻不重叠(上不封顶档须最后);mediaPrices 键 ∈ {text,audio,video,image} 且值非空;match 字段取值合法;price 非负。
|
||||
|
||||
### 4.3 business —— 周期订阅(per_period)
|
||||
|
||||
```json
|
||||
{ "period": "year", "price": 1999 }
|
||||
```
|
||||
|
||||
- `period`:周期单位,本期 `year`,后续可扩 `month/quarter` 等(扩值即支持,计算器按允许集合校验)。
|
||||
- `price`:每周期价格(元),一次性收取。
|
||||
- Save 校验:`period ∈ 允许集合`(本期 `{year}`)、`price > 0`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 计算器注册表(charge_calc.go)
|
||||
|
||||
### 5.1 统一 charge_mode 枚举
|
||||
|
||||
```
|
||||
per_item | per_second | per_token | per_1K | per_1M | per_1 | per_minute | per_hour | per_char | per_period
|
||||
```
|
||||
|
||||
### 5.2 新增 token 计算器(per_1K/per_1M)与单位计算器(per_1/per_second/per_minute/per_hour/per_char)
|
||||
|
||||
- **token 计算器**(注册 per_1K、per_1M 两键,base=1000/100 万):
|
||||
`cost = (prompt-cached)/base×input + cached/base×cacheHit + completion/base×output`;match 用 promptTokens 判 inputLength 档、用 usage 的 MediaType/Thinking/OutputAudio/OutputResolution 判维度,**首条命中**。
|
||||
- **单位计算器**(注册 per_1/per_second/per_minute/per_hour/per_char 五键,base=1/1/60/3600/1,用量源=张数/秒/秒/秒/字数):`cost = 用量/base×unitPrice`。
|
||||
- **阶梯=分档不累加**:tiered 只影响 match 语义(按输入 token 档)与校验(档相邻不重叠),无分段累加逻辑。
|
||||
- **无规则命中 → 报错「无匹配计费规则」**(配置缺失建单即暴露,不静默收 0)。
|
||||
- 折扣:废弃(v0.4 修订为规则级 `mediaPrices`,见 `2026-09-01-pricing-media-price-design.md`)。
|
||||
|
||||
### 5.3 ChargeUsage 扩展(模型计价入参,本期备用)
|
||||
|
||||
```go
|
||||
type ChargeUsage struct {
|
||||
DurationSec float64 `json:"durationSec"`
|
||||
ItemCount int64 `json:"itemCount"`
|
||||
TokensByModel map[string]int64 `json:"tokensByModel"`
|
||||
// —— 模型计价扩展 ——
|
||||
PromptTokens int64 `json:"promptTokens"` // token 计费用:输入
|
||||
CompletionTokens int64 `json:"completionTokens"` // token 计费用:输出
|
||||
CachedTokens int64 `json:"cachedTokens"` // token 计费用:缓存命中
|
||||
CharCount int64 `json:"charCount"` // per_char 用
|
||||
ImageCount int64 `json:"imageCount"` // per_1(图片)用
|
||||
MediaType string `json:"mediaType"` // text/audio/video/image(输入媒体)
|
||||
Thinking *bool `json:"thinking"` // 推理 思考/非思考
|
||||
OutputAudio *bool `json:"outputAudio"` // 视频 输出有声/无声
|
||||
OutputResolution string `json:"outputResolution"` // 视频/图片 输出分辨率
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 per_token 语义(工作流结算)
|
||||
|
||||
结算时:`usage.TokensByModel` 逐模型 → 查该模型 subject 的 modelCfg(§4.2)→ token 计算器算费 → 累加;**未配价模型按 0 计**(不收费、不报错)。
|
||||
|
||||
> ⚠️ **设计决策**:per_token **费率不冻结在 rule_snapshot**(建单时未知会用哪些模型),结算时按模型 subject **实时价格**算。这是「模型价格单一来源」的代价,与 v0.2「改价不影响在途单」的 rule_snapshot 保证有冲突,仅 per_token 模式例外,其余模式仍走快照。
|
||||
|
||||
### 5.5 新增 per_period 计算器(周期订阅)
|
||||
|
||||
- `validate`:`period ∈ 允许集合`(本期 `{year}`)+ `price > 0`。
|
||||
- `charge`:返回 `ceilFen(price)`(每次购买按整周期一次性收取,与用量无关;`usage.ItemCount` 预留多条数叠加)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 接口设计
|
||||
|
||||
管理端:
|
||||
|
||||
| 路由 | 方法 | 说明 |
|
||||
|---|---|---|
|
||||
| `/pricing/subjects` | GET | 枚举:workflow + 实时 model-gateway 系统模型 + 业务模块,每项附 hasConfig/enabled |
|
||||
| `/pricing/config/save` | POST | `{subjectType, subjectId, rules, minBalance, currency, enabled}`,按 subject_type 校验 rules |
|
||||
| `/pricing/config/get` | GET | 按 subjectType+subjectId |
|
||||
| `/pricing/config/list` | GET | 分页,可按 subjectType/enabled 过滤 |
|
||||
|
||||
内部结算(本期只调契约,模型结算未接):
|
||||
|
||||
| 路由 | 方法 | 说明 |
|
||||
|---|---|---|
|
||||
| `/pricing/open_order` | POST | `{userId, subjectType, subjectId, chargeMode?, bizOrderNo}`;workflow 的 chargeMode 必填且须已配置;model 的 chargeMode 可空(默认取 unit);business 的 chargeMode 可空(默认 per_period) |
|
||||
| `/pricing/settle` `/cancel` `/fail` `/order` | — | 契约字段同步换 subjectType/subjectId |
|
||||
|
||||
**OpenOrder 校验**:
|
||||
- workflow:`chargeMode ∈ rules 的 key`;`rule_snapshot = rules[chargeMode]`
|
||||
- model:`charge_mode = config.unit`;`rule_snapshot = 整个 modelCfg(unit/tiered/rules)`
|
||||
- business:`charge_mode = per_period`;`rule_snapshot = 周期规则 JSON`
|
||||
- 门禁 `min_balance` 照旧;建单前校验规则 JSON;幂等返回既有单。
|
||||
|
||||
---
|
||||
|
||||
## 7. 枚举实现(subject_service.go,新)
|
||||
|
||||
- `GET /pricing/subjects`:consul 解析 model-gateway 服务地址 → 调 `GET /listModelManage`(`system_model=true`)→ 合并固定 workflow 项 + 固定业务模块组(`consts/pricing`)。
|
||||
- 模型项返回 `modelId/modelName/modelType`(modelType 透传 model-gateway 枚举编码:100推理/200图片/300音频/600视频),管理端按 modelType 分组渲染对应编辑器。
|
||||
- 每项查 `pricing_config` 是否已存在(hasConfig / enabled)。
|
||||
- model-gateway 服务地址与鉴权放 `config.yml`(复用 `common/http` + consul 解析,同 market_service 调订单服务的模式)。
|
||||
- model-gateway 不可用时:枚举接口**直接报错**(管理端低频接口,失败暴露更清晰,可重试);workflow 项仍为固定返回。
|
||||
|
||||
---
|
||||
|
||||
## 8. 设计决策汇总
|
||||
|
||||
| 决策 | 理由 |
|
||||
|---|---|
|
||||
| 计价对象固定枚举 = 表结构(subject_type+subject_id) | 天然匹配「固定枚举」,workflow 多模式自描述 |
|
||||
| 模型价格单一来源(工作流 per_token 用模型自己的价) | 避免两份价格来源不一致 |
|
||||
| per_token 费率不冻结(结算按模型实时价) | 建单时未知模型集合,单一价格来源的代价 |
|
||||
| 模型主体按 modelId 标识 | 稳定不变,枚举另返回 name 展示 |
|
||||
| pricing_config 去 charge_mode 列 | rules 自描述,workflow 三模式都在 rules 里 |
|
||||
| 未配价模型按 0 计 | 工作流可含未单独配价的模型,不阻塞结算 |
|
||||
| min_balance 门禁照旧 | 非冻结门禁语义不变 |
|
||||
| 业务模块固定写死(AI客服等) | 与「固定枚举」一致;新增业务模块改常量,暂不做管理端增删 |
|
||||
| 周期计费用 per_period 通用建模 | 扩月/季只扩 period 允许集合,不改代码结构 |
|
||||
| 阶梯=分档不累加 | 用户确认;匹配统一 rules 首条命中,tiered 仅约束 inputLength 分档校验 |
|
||||
| match 维度全可选 + 首条命中 | 与 model-gateway 移植逻辑一致,规则即优先级 |
|
||||
| 价格项收敛 input/output/cacheHit + unitPrice | 四类模型一套结构;音频输入差价用 `mediaPrices.audio` 表达(删 inputAudio/cacheHitAudio/cacheStorageHour 与 match.mediaType) |
|
||||
| 输出分辨率自由字符串 | 视频/图片枚举值管理端 UI 下拉提供,DB 不预设 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 遗留(另期)
|
||||
|
||||
- 模型 subject 结算接入:ai-agent 上报模型用量(PromptTokens/CompletionTokens/CachedTokens/CharCount/ImageCount/MediaType/Thinking/OutputAudio/OutputResolution)→ open_order/settle。
|
||||
- 业务模块订阅购买流程接入(按周期下单/扣费/续费)。
|
||||
- model-gateway 停扣(切换时机沿用 v0.2 §8.2)。
|
||||
- 工作流侧「选择本次扣费方式」的 UI/配置项(ai-agent 侧,本期不动)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 改动清单
|
||||
|
||||
1. `consts/pricing/charge_mode.go`:加 `per_1K/per_1M/per_1/per_minute/per_hour/per_char/per_period`(含 period 允许集合 `{year}`)
|
||||
2. `consts/pricing/subject.go`(新):subject_type 常量 workflow/model/business + 业务模块固定列表(AI客服 → `ai_customer_service`)
|
||||
3. `model/entity/pricing/pricing_config.go`:biz_key/charge_mode → subject_type/subject_id
|
||||
4. `model/dto/pricing/config_dto.go`:Save/Get/List/Info 换 subject 字段
|
||||
5. `model/dto/pricing/charge_dto.go`:OpenOrderReq 加 subjectType/subjectId/chargeMode;ChargeOrderInfo 的 bizKey → subjectType/subjectId;DAO 入参同步
|
||||
6. `dao/pricing/pricing_config_dao.go`:Get/Save/List 按 (subject_type, subject_id)
|
||||
7. `dao/pricing/charge_order_dao.go`:幂等键改 (subject_type, subject_id, biz_order_no)
|
||||
8. `service/pricing/charge_calc.go`:token 计算器(per_1K/per_1M)+ 单位计算器(per_1/per_second/per_minute/per_hour/per_char)+ per_period 计算器 + ChargeUsage 扩展(模型维度)+ per_token 语义改模型价
|
||||
9. `service/pricing/pricing_service.go`:OpenOrder 校验 chargeMode/unit/period;Save 按类型校验 rules;settleOrder 派发
|
||||
10. `service/pricing/subject_service.go`(新):枚举接口(workflow + 实时模型 + 固定业务模块)
|
||||
11. `controller/pricing/pricing_controller.go`:加 Subjects
|
||||
12. `init.sql`:pricing_config 改结构、charge_order 改键、迁移 SQL
|
||||
13. `config.yml`:加 model-gateway 服务地址
|
||||
14. `go build ./...` 验证
|
||||
@@ -1,244 +0,0 @@
|
||||
# 模型计费 mediaPrices(媒体类型价)与阶梯校验修复设计
|
||||
|
||||
> 版本:v0.4(本期)—— 修订 v0.3 `2026-08-28-pricing-enum-design.md` §4.2/§5.2。将模型规则"输入媒体"维度从 `match` 命中条件**改为规则级 `mediaPrices` 媒体价 map**,重写阶梯档位重叠校验,解决「有音频/无音频 × 档位」矩阵配置无法保存的问题与前端"媒体类型 4 选 1"体验问题。
|
||||
> 范围:shop-user-trade **pricing 计价模块**。只动模型计费结构(`charge_calc.go` + `consts`);workflow/business 计算器与 DB 结构不动。
|
||||
> 约定:金额一律**元**(float64,2 位小数),不足 1 分向上取整(`ceilFen`)——沿用 v0.3。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
管理端保存模型计费配置时,提交"输入含音频价 + 输入不含音频价 × 输入 token 档位"的矩阵数据被 `validateTieredBands` 拒绝,报错 **「阶梯档位区间重叠:规则间 InputLengthMin 须 > 前一档 InputLengthMax」**。
|
||||
|
||||
根因(两层):
|
||||
|
||||
1. **设计缺陷**:`validateTieredBands`(charge_calc.go:306)对全部规则做全局两两比较,**不感知媒体维度**。不同媒体类型的同档位规则也被判为"重叠"→ 媒体 × 档位矩阵永远存不进去。
|
||||
2. **表达缺陷**:v0.3 用 `match.mediaType`(等值命中)表达"输入含某媒体",无法表达"**不含**某媒体"——`mediaType:"text"` 只匹配文本输入,图片/视频输入匹配不到;前端被迫做"文本/音频/视频/图片 4 选 1",选"非音频"时只能硬选"文本",语义是假的(用户确认)。
|
||||
|
||||
另:`validateTieredBands` 对 `InputLengthMin == 0` 的规则对直接 `continue`,导致**两个同起于 0 的档位重叠查不出**(潜伏 bug)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案决策:mediaPrices map(规则级媒体价)
|
||||
|
||||
用**规则级 `mediaPrices map[string]*modelPrice`** 表达"输入含某媒体"的价差,**默认 `price` 即"不含以下媒体"**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "输入长度 [0,32k]",
|
||||
"match": { "inputLengthMin": 0, "inputLengthMax": 32000 },
|
||||
"price": { "input": 0.3, "output": 1.8, "cacheHit": 0.12 },
|
||||
"mediaPrices": { "audio": { "input": 4.5, "output": 1.8, "cacheHit": 1.8 } }
|
||||
}
|
||||
```
|
||||
|
||||
- 语义:命中规则后,`usage.MediaType` 在 `mediaPrices` 里有 key → 用该媒体价;**没有 → 默认 `price`**。"不含音频"自动成立(文本/图片/视频都落默认价),**不需要负向字段、不需要假装"文本"**。
|
||||
- 多类型天然支持:`audio`/`video`/`image`/`text` 任意个 key,一个规则一套档位内可挂多套媒体价。
|
||||
- 顺序无关:map 按键查,无遮蔽、无首条命中顺序依赖。
|
||||
- `mediaPrices` 为空 = 纯单价格,与 v0.3 结构兼容。
|
||||
|
||||
**否决过的备选**:
|
||||
- `mediaTypeNot` 负向字段:不必要,默认价即负向侧。
|
||||
- 单字段 `mediaPrice`(只支持一个媒体):不满足"多个媒体类型"。
|
||||
- 继续用 `match.mediaType` 等值匹配:无法表达"不含",且与前端体验冲突。
|
||||
|
||||
---
|
||||
|
||||
## 3. rules JSON 结构变更
|
||||
|
||||
### 3.1 modelRule(charge_calc.go)
|
||||
|
||||
```go
|
||||
type modelRule struct {
|
||||
Name string `json:"name"`
|
||||
Match *modelMatch `json:"match,omitempty"` // 命中条件(不含输入媒体维度)
|
||||
Price *modelPrice `json:"price"` // 默认价:输入不含以下媒体
|
||||
MediaPrices map[string]*modelPrice `json:"mediaPrices,omitempty"` // 媒体类型→该媒体价(输入含该媒体时)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 modelMatch(移除 MediaType)
|
||||
|
||||
```go
|
||||
type modelMatch struct {
|
||||
Thinking *bool `json:"thinking,omitempty"` // 思考/非思考
|
||||
OutputAudio *bool `json:"outputAudio,omitempty"` // 输出有声/无声
|
||||
OutputResolution string `json:"outputResolution,omitempty"` // 输出分辨率
|
||||
InputLengthMin int64 `json:"inputLengthMin,omitempty"` // 输入token下界
|
||||
InputLengthMax int64 `json:"inputLengthMax,omitempty"` // 输入token上界
|
||||
}
|
||||
```
|
||||
|
||||
- `modelRules`(unit/tiered/rules/currency)、`modelPrice`(input/output/cacheHit/unitPrice)不变。
|
||||
- `discount` 字段**废弃**:v0.3 spec 文本与预览页的 `discount:null` 占位一律移除(代码 `modelRules` 本无该字段)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 语义与运行时
|
||||
|
||||
### 4.1 取价(pickModelPrice)
|
||||
|
||||
```go
|
||||
// pickModelPrice 命中规则后按输入媒体取价:mediaPrices 命中使用媒体价,否则默认 price。
|
||||
func pickModelPrice(rule *modelRule, usage *ChargeUsage) *modelPrice {
|
||||
if len(rule.MediaPrices) > 0 {
|
||||
if p, ok := rule.MediaPrices[usage.MediaType]; ok {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return rule.Price
|
||||
}
|
||||
```
|
||||
|
||||
- `modelTokenCalculator.charge`:`p := pickModelPrice(rule, usage)` 后按 `p.Input/p.Output/p.CacheHit` 计费(原公式不变)。
|
||||
- `modelUnitCalculator.charge`:`p := pickModelPrice(rule, usage)` 后按 `p.UnitPrice` 计费(原公式不变)。
|
||||
- `modelRuleMatch`:**删除 `MediaType` 等值判定**(该行是本设计唯一删除的匹配逻辑)。
|
||||
|
||||
### 4.2 上报契约(媒体类型)
|
||||
|
||||
`ChargeUsage.MediaType`(上报入参,字段保留)口径:
|
||||
|
||||
- 推理模型:输入**含音频**报 `"audio"`,否则报 `"text"`。
|
||||
- 视频模型:输入**含视频**报 `"video"`,否则报 `"text"`。
|
||||
- 模型结算接入为另期;本设计只定契约,结算方按此上报。
|
||||
|
||||
---
|
||||
|
||||
## 5. 校验规则
|
||||
|
||||
### 5.1 阶梯档位(validateTieredBands 重写)
|
||||
|
||||
按输入长度下界升序后**相邻不重叠**;无长度区间的规则(如 thinking 维度的兜底档)不参与重叠校验;上不封顶档(max=0)必须是最后一个长度档:
|
||||
|
||||
```go
|
||||
// validateTieredBands 阶梯分档校验:带长度区间的规则按下界升序后相邻不重叠。
|
||||
// 无长度区间规则不参与;上不封顶档(max=0)须为最后一个长度档。
|
||||
func validateTieredBands(rules []modelRule) error {
|
||||
var bands []modelRule
|
||||
for _, r := range rules {
|
||||
if r.Match != nil && (r.Match.InputLengthMin > 0 || r.Match.InputLengthMax > 0) {
|
||||
bands = append(bands, r)
|
||||
}
|
||||
}
|
||||
sort.Slice(bands, func(i, j int) bool {
|
||||
return bands[i].Match.InputLengthMin < bands[j].Match.InputLengthMin
|
||||
})
|
||||
var prevMax int64 = -1
|
||||
openEnded := false
|
||||
for _, r := range bands {
|
||||
m := r.Match
|
||||
if openEnded {
|
||||
return errors.New("阶梯档位区间重叠:上不封顶档后不能再有档位")
|
||||
}
|
||||
if m.InputLengthMin <= prevMax {
|
||||
return errors.New("阶梯档位区间重叠:规则间 InputLengthMin 须 > 前一档 InputLengthMax")
|
||||
}
|
||||
if m.InputLengthMax == 0 {
|
||||
openEnded = true // 上不封顶,之后不可再有长度档
|
||||
} else {
|
||||
prevMax = m.InputLengthMax
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
要点:
|
||||
- **修复同起 0 重叠**:两个档位 min 均为 0 时,第二个 `0 <= prevMax` → 报错(旧代码因 `InputLengthMin==0 continue` 漏检)。
|
||||
- **顺序无关**:先排序再判,乱序提交也正确。
|
||||
- 档位**相邻**(下档 min = 上档 max+1)由前端自动生成;后端只强约束不重叠(允许间隙,间隙内长度无档命中 → 结算时报「无匹配计费规则」,由配置质量保证)。
|
||||
- 无长度规则(如 `thinking:false` 兜底档)不参与重叠校验,与长度档共存合法。
|
||||
|
||||
### 5.2 mediaPrices 校验
|
||||
|
||||
`modelTokenCalculator.validate` 与 `modelUnitCalculator.validate` 的规则循环内追加:
|
||||
|
||||
```go
|
||||
if len(r.Rules[i].MediaPrices) > 0 {
|
||||
for mt, mp := range r.Rules[i].MediaPrices {
|
||||
if _, ok := pricingConsts.ModelMediaSet[mt]; !ok {
|
||||
return nil, errors.New("model 费率 mediaPrices 键须为 text/audio/video/image")
|
||||
}
|
||||
if mp == nil {
|
||||
return nil, errors.New("model 费率 mediaPrices 值须配置 price")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`consts/pricing` 新增媒体类型集合(复用常量,前端下拉同源):
|
||||
|
||||
```go
|
||||
// ModelMediaSet 模型费率 mediaPrices 允许的媒体类型键
|
||||
var ModelMediaSet = map[string]struct{}{
|
||||
"text": {}, "audio": {}, "video": {}, "image": {},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端交互(已定稿)
|
||||
|
||||
模型计费规则编辑器(参考 `docs/superpowers/specs/pricing-config-preview.html` 已改版):
|
||||
|
||||
- **每条规则**:`默认价 price`(输入不含以下媒体)+ `媒体类型价 mediaPrices` 列表(可增删;每项 = 媒体类型选择(音频/视频/图片/文本)+ 对应价格)。
|
||||
- 不再有「媒体类型 4 选 1」强制下拉;要几种媒体就加几张卡。
|
||||
- 档位区间前端**自动相邻**(下档 min = 上档 max+1),不再手填重叠值。
|
||||
- 提交 JSON 结构见 §2 示例。
|
||||
|
||||
---
|
||||
|
||||
## 7. 数据修正示例(用户提交的 6 条 → 3 条)
|
||||
|
||||
```json
|
||||
{
|
||||
"unit": "per_1M", "tiered": true, "currency": "CNY",
|
||||
"rules": [
|
||||
{ "name": "输入长度 [0,32k]", "match": { "inputLengthMin": 0, "inputLengthMax": 32000 },
|
||||
"price": { "input": 0.3, "output": 1.8, "cacheHit": 0.12 }, "mediaPrices": { "audio": { "input": 4.5, "output": 1.8, "cacheHit": 1.8 } } },
|
||||
{ "name": "输入长度 (32k,128k]", "match": { "inputLengthMin": 32001, "inputLengthMax": 128000 },
|
||||
"price": { "input": 0.45, "output": 0.18, "cacheHit": 2.7 }, "mediaPrices": { "audio": { "input": 6.75, "output": 2.7, "cacheHit": 2.7 } } },
|
||||
{ "name": "输入长度 (128k,256k]","match": { "inputLengthMin": 128001, "inputLengthMax": 256000 },
|
||||
"price": { "input": 0.9, "output": 5.4, "cacheHit": 0.36 }, "mediaPrices": { "audio": { "input": 13.5, "output": 5.4, "cacheHit": 5.4 } } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
(原数据问题:R1 max=3200 应为 32000;R3/R5 档位边界 32000/128000 须 +1 相邻;无 mediaType 的文本规则遮蔽同名音频规则——新结构下不存在该问题。)
|
||||
|
||||
---
|
||||
|
||||
## 8. 兼容与迁移
|
||||
|
||||
- **`match.mediaType` 移除**:旧配置中 `match.mediaType` 被 Go JSON 反序列化**静默忽略**(字段删除)。旧"音频差价"规则需重配为 `price`(默认)+ `mediaPrices.audio`。本期结算未接、无线上真实配置,直接切换即可。
|
||||
- **`discount` 废弃**:spec 文本与预览页占位删除(代码本无实现)。
|
||||
- workflow/business 计算器、`pricing_config` DB 结构、`ChargeMode` 枚举、金额单位均不动。
|
||||
|
||||
---
|
||||
|
||||
## 9. 改动清单
|
||||
|
||||
1. `consts/pricing/`(charge_mode.go 或新文件):加 `ModelMediaSet`(text/audio/video/image)
|
||||
2. `service/pricing/charge_calc.go`:
|
||||
- `modelMatch` 删 `MediaType` 字段;`modelRule` 加 `MediaPrices map[string]*modelPrice`
|
||||
- `modelRuleMatch` 删 `MediaType` 等值判定
|
||||
- 新增 `pickModelPrice`;`modelTokenCalculator.charge` / `modelUnitCalculator.charge` 改用之
|
||||
- `validateTieredBands` 重写(§5.1)
|
||||
- 两个 model validate 追加 `mediaPrices` 键/值校验(§5.2)
|
||||
3. 测试(新 `service/pricing/charge_calc_test.go`):见 §10
|
||||
4. `docs/superpowers/specs/2026-08-28-pricing-enum-design.md`:§4.2 示例与 §8 决策表同步更新(指向本设计)
|
||||
5. `docs/superpowers/specs/pricing-config-preview.html`:已按本设计改版(mockup)
|
||||
6. `go build ./...` + `go test ./service/pricing/`(需 `GF_GCFG_PATH=C:/App/GolandProjects/shop-user-trade` 规避 common/consul init 环境问题)
|
||||
|
||||
## 10. 测试要点
|
||||
|
||||
- **validateTieredBands**:相邻档合法;两档同起 0 重叠报错;乱序提交仍正确判重叠;上不封顶在最后合法、其后还有长度档报错;无长度规则(thinking 兜底)不干扰。
|
||||
- **pickModelPrice / charge**:含音频命中 `mediaPrices.audio`;不含音频走默认 `price`;多媒体类型各命中;`mediaPrices` 空走默认;命中规则后无 `mediaPrices` key 的媒体类型(如 video 输入配了 audio-only)走默认。
|
||||
- **validate**:`mediaPrices` 键非法报错;值 nil 报错;token/unit 两个计算器都覆盖。
|
||||
|
||||
## 11. 不改动
|
||||
|
||||
- `modelTokenCalculator` 计费公式、`modelUnitCalculator` 公式、`matchModelRule` 首条命中语义(非 tiered 配置仍按顺序优先)
|
||||
- workflow(per_item/per_second/per_token)、business(per_period)计算器
|
||||
- `pricing_config` 表结构、`charge_order` 表结构、`ChargeUsage` 字段
|
||||
- `GET /pricing/subjects` 与 charge-modes 接口
|
||||
@@ -1,95 +0,0 @@
|
||||
# 按 SubjectType 查询可选计费方式 设计
|
||||
|
||||
> 版本:v0.1(本期)—— 在 v0.3 计价对象枚举化基础上,提供前端「选择计费方式」所需的轻量查询接口。
|
||||
> 范围:shop-user-trade **pricing 计价模块**。交付「按 subjectType 查可选 chargeMode」接口 + 计费方式中文名常量。**不触碰 model 主体**。
|
||||
> 前置:依赖 v0.3 枚举设计 `2026-08-28-pricing-enum-design.md`(SubjectType / ChargeMode / BusinessSubjects 已在代码落地)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
v0.3 把计价对象固定枚举为 `workflow / model / business`。管理端为工作流配置价格时,需要从三套计费方式(`per_item` / `per_second` / `per_token`)中**选择一种**;为业务模块配置时选择周期订阅(`per_period`)。当前没有任何接口告诉前端「某类型可选哪些计费方式」,且计费方式枚举**缺中文名**,前端无法渲染下拉。
|
||||
|
||||
**model 主体不需要此查询**:模型的计费单位(unit)由其模型类型自适应决定、在模型配置编辑器内随 rules 一起配置,不存在"选择计费方式"这一步(用户确认)。故接口只服务需要「选」的类型。
|
||||
|
||||
目标:一个 `GET /pricing/controller/subjects/charge-modes?subjectType=X&subjectId=Y` 接口,返回该具体主体的可选计费方式(mode + 中文名),**纯查常量、零外部依赖**。
|
||||
|
||||
## 2. 接口契约
|
||||
|
||||
```
|
||||
GET /pricing/controller/subjects/charge-modes?subjectType={workflow|business}&subjectId={workflow|业务模块标识}
|
||||
```
|
||||
|
||||
| 参数 | 必填 | 校验 |
|
||||
|---|---|---|
|
||||
| `subjectType` | 是 | `in:workflow,business`;**model 非法**——模型计费方式无需选择,误传 400 |
|
||||
| `subjectId` | 是 | workflow 时须 = `WorkflowSubjectID`(`workflow`);business 时须 ∈ `BusinessSubjects`,否则报「未知业务模块」 |
|
||||
|
||||
**按具体主体查询**:返回该 `(subjectType, subjectId)` 主体自身可选计费方式(与 `pricing_config` 的 `(subject_type, subject_id)` 唯一键同构)。
|
||||
|
||||
响应(前端下拉直接渲染 `name`,`mode` 作提交值):
|
||||
|
||||
```json
|
||||
{
|
||||
"subjectType": "workflow",
|
||||
"subjectId": "workflow",
|
||||
"chargeModes": [
|
||||
{ "mode": "per_item", "name": "按条" },
|
||||
{ "mode": "per_second", "name": "按秒" },
|
||||
{ "mode": "per_token", "name": "按token" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`subjectType=business, subjectId=ai_customer_service` 时 `chargeModes` = 该业务模块自身的 `BusinessSubject.ChargeModes`(现为 `[per_period]`)。
|
||||
|
||||
## 3. 数据来源(纯常量,零外部依赖)
|
||||
|
||||
| subjectType | subjectId | chargeModes | 来源 |
|
||||
|---|---|---|---|
|
||||
| `workflow` | `workflow`(须=WorkflowSubjectID) | `[per_item, per_second, per_token]` | `consts/pricing/subject.go` 新增 `WorkflowChargeModes` |
|
||||
| `business` | 业务模块标识(须∈BusinessSubjects) | 该主体自身 `ChargeModes` | `consts/pricing/subject.go` 既有 `BusinessSubject.ChargeModes` |
|
||||
|
||||
- 不调 model-gateway、不查 `pricing_config`,**纯查常量**。
|
||||
- model 由校验层拦截(`v:"required|in:workflow,business"`);`subjectId` 必填且按类型校验存在性。
|
||||
|
||||
## 4. 计费方式中文名
|
||||
|
||||
`consts/pricing/charge_mode.go` 新增 `ChargeModeNames map[ChargeMode]string`,覆盖全部 10 个枚举(单一来源,前端直接渲染):
|
||||
|
||||
| mode | name |
|
||||
|---|---|
|
||||
| `per_item` | 按条 |
|
||||
| `per_second` | 按秒 |
|
||||
| `per_token` | 按token |
|
||||
| `per_1K` | 每千token |
|
||||
| `per_1M` | 每百万token |
|
||||
| `per_1` | 每张/每个 |
|
||||
| `per_minute` | 每分钟 |
|
||||
| `per_hour` | 每小时 |
|
||||
| `per_char` | 每字 |
|
||||
| `per_period` | 周期订阅 |
|
||||
|
||||
## 5. 改动清单
|
||||
|
||||
1. `consts/pricing/charge_mode.go`:加 `ChargeModeNames` map(§4 全表)
|
||||
2. `consts/pricing/subject.go`:加 `WorkflowChargeModes = []ChargeMode{per_item, per_second, per_token}`(与 `charge_calc.go` 的 `workflowCalculators` 三键一致)
|
||||
3. `model/dto/pricing/subject_dto.go`:`ChargeModeListReq`(`path:"/subjects/charge-modes" method:"get"`,`subjectType v:"required|in:workflow,business"` + `subjectId v:"required"`)+ `ChargeModeListRes{SubjectType string, SubjectID string, ChargeModes []ChargeModeInfo}` + `ChargeModeInfo{Mode string, Name string}`
|
||||
4. `service/pricing/subject_service.go`:`ListChargeModes(ctx, req)` — workflow:`subjectId` 须=WorkflowSubjectID,返回 `WorkflowChargeModes`;business:`subjectId` 查 `BusinessSubjects`(缺失报「未知业务模块」),返回该主体 `ChargeModes`
|
||||
5. `controller/pricing/pricing_controller.go`:注册 `ChargeModes` 方法(复用既有 Subjects 的 Controller 模式)
|
||||
6. `go build ./...` 验证
|
||||
|
||||
## 6. 设计决策汇总
|
||||
|
||||
| 决策 | 理由 |
|
||||
|---|---|
|
||||
| 只服务 workflow/business,model 不入参 | 模型计费方式由类型/配置决定,前端无需选择(用户确认) |
|
||||
| 按 `subjectType + subjectId` 查具体主体 | 与 `pricing_config` 的 `(subject_type, subject_id)` 唯一键同构;business 各主体可不同计费方式,按主体精确返回 |
|
||||
| `subjectId` 必填并按类型校验存在性 | workflow 须=`WorkflowSubjectID`;business 须∈`BusinessSubjects`,误传即报错,前端不可能拿到空下拉 |
|
||||
| 不调 model-gateway、不查 `pricing_config` | 纯查常量,接口轻量稳定,与 `/pricing/subjects` 解耦 |
|
||||
| 中文名放 consts map(`ChargeModeNames`) | 前端直接渲染,单一来源 |
|
||||
| workflow 模式用 `WorkflowChargeModes` 常量 | 与计算器注册表三键一致,不耦合 `charge_calc` 内部 |
|
||||
|
||||
## 7. 不改动
|
||||
|
||||
- `/pricing/subjects` 枚举响应、`pricing_config` 的 save 校验(unit∈ModelUnitSet 等)、计算器注册表、DB 结构均不变。
|
||||
@@ -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,'"').replace(/</g,'<'); }
|
||||
|
||||
/* ================= 初始化 ================= */
|
||||
renderSubjects();
|
||||
selectSubject('model','102'); // 默认打开视频模型,展示多维匹配
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -244,8 +244,7 @@ CREATE TABLE IF NOT EXISTS wallet_account (
|
||||
user_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||
balance NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||
currency VARCHAR(16) NOT NULL DEFAULT 'CNY',
|
||||
status SMALLINT NOT NULL DEFAULT 1,
|
||||
version BIGINT NOT NULL DEFAULT 1
|
||||
status SMALLINT NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- 索引
|
||||
@@ -267,7 +266,6 @@ COMMENT ON COLUMN wallet_account.user_name IS '用户名';
|
||||
COMMENT ON COLUMN wallet_account.balance IS '可用余额(元,保留2位小数),可为负=欠费';
|
||||
COMMENT ON COLUMN wallet_account.currency IS '货币类型:CNY-人民币';
|
||||
COMMENT ON COLUMN wallet_account.status IS '状态:1启用/0禁用/-1冻结';
|
||||
COMMENT ON COLUMN wallet_account.version IS '乐观锁版本号';
|
||||
|
||||
--------------------pgsql创建wallet_account表语句---------------------------
|
||||
|
||||
@@ -327,14 +325,14 @@ COMMENT ON COLUMN wallet_account_log.extra_data IS '额外数据(JSONB)';
|
||||
|
||||
--------------------pgsql创建wallet_account_log表语句---------------------------
|
||||
|
||||
--------------------pgsql创建pricing_config表语句---------------------------
|
||||
--------------------pgsql创建wallet_pricing_config表语句---------------------------
|
||||
|
||||
-- 计价配置表(费率全 DB 配置,改价/加档只改 rules,代码不写死金额)
|
||||
CREATE TABLE IF NOT EXISTS pricing_config (
|
||||
CREATE TABLE IF NOT EXISTS wallet_pricing_config (
|
||||
-- 基础字段(继承 SQLBaseCol 通用字段,与 SQLBaseDO 对齐)
|
||||
id BIGINT PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 0,
|
||||
creator VARCHAR(64) NOT NULL,
|
||||
id BIGINT PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 0,
|
||||
creator VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updater VARCHAR(64) NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -343,40 +341,38 @@ CREATE TABLE IF NOT EXISTS pricing_config (
|
||||
-- 计价配置核心字段
|
||||
subject_type VARCHAR(16) NOT NULL, -- workflow | model | business
|
||||
subject_id VARCHAR(64) NOT NULL, -- workflow | 模型ID | 业务模块标识
|
||||
rules TEXT NOT NULL, -- 费率JSON(见设计§4,rules 自描述)
|
||||
rules JSONB NOT NULL DEFAULT '{}', -- 费率JSON对象(见设计§4,rules 自描述)
|
||||
min_balance NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||
currency VARCHAR(16) NOT NULL DEFAULT 'CNY',
|
||||
enabled SMALLINT NOT NULL DEFAULT 1,
|
||||
version BIGINT NOT NULL DEFAULT 1
|
||||
);
|
||||
enabled SMALLINT NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_pricing_config_subject ON pricing_config(subject_type, subject_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pricing_config_enabled ON pricing_config(enabled);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_pricing_config_subject ON wallet_pricing_config(subject_type, subject_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pricing_config_enabled ON wallet_pricing_config(enabled);
|
||||
|
||||
-- 表和字段注释
|
||||
COMMENT ON TABLE pricing_config IS '计价配置表(计价对象固定枚举,费率全 DB 配置)';
|
||||
COMMENT ON COLUMN pricing_config.id IS '主键ID(非自增)';
|
||||
COMMENT ON COLUMN pricing_config.tenant_id IS '租户ID';
|
||||
COMMENT ON COLUMN pricing_config.creator IS '创建人';
|
||||
COMMENT ON COLUMN pricing_config.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN pricing_config.updater IS '更新人';
|
||||
COMMENT ON COLUMN pricing_config.updated_at IS '更新时间';
|
||||
COMMENT ON COLUMN pricing_config.deleted_at IS '删除时间(软删)';
|
||||
COMMENT ON COLUMN pricing_config.subject_type IS '计价对象类型:workflow/model/business';
|
||||
COMMENT ON COLUMN pricing_config.subject_id IS '计价对象ID(workflow/模型ID/业务模块标识)';
|
||||
COMMENT ON COLUMN pricing_config.rules IS '费率JSON(workflow三模式容器/model自适应/business周期)';
|
||||
COMMENT ON COLUMN pricing_config.min_balance IS '门禁:调用前可用余额须>=该值(元),0=不校验';
|
||||
COMMENT ON COLUMN pricing_config.currency IS '货币类型';
|
||||
COMMENT ON COLUMN pricing_config.enabled IS '1启用 0停用';
|
||||
COMMENT ON COLUMN pricing_config.version IS '乐观锁版本号';
|
||||
COMMENT ON TABLE wallet_pricing_config IS '计价配置表(计价对象固定枚举,费率全 DB 配置)';
|
||||
COMMENT ON COLUMN wallet_pricing_config.id IS '主键ID(非自增)';
|
||||
COMMENT ON COLUMN wallet_pricing_config.tenant_id IS '租户ID';
|
||||
COMMENT ON COLUMN wallet_pricing_config.creator IS '创建人';
|
||||
COMMENT ON COLUMN wallet_pricing_config.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN wallet_pricing_config.updater IS '更新人';
|
||||
COMMENT ON COLUMN wallet_pricing_config.updated_at IS '更新时间';
|
||||
COMMENT ON COLUMN wallet_pricing_config.deleted_at IS '删除时间(软删)';
|
||||
COMMENT ON COLUMN wallet_pricing_config.subject_type IS '计价对象类型:workflow/model/business';
|
||||
COMMENT ON COLUMN wallet_pricing_config.subject_id IS '计价对象ID(workflow/模型ID/业务模块标识)';
|
||||
COMMENT ON COLUMN wallet_pricing_config.rules IS '费率JSON(workflow三模式容器/model自适应/business周期)';
|
||||
COMMENT ON COLUMN wallet_pricing_config.min_balance IS '门禁:调用前可用余额须>=该值(元),0=不校验';
|
||||
COMMENT ON COLUMN wallet_pricing_config.currency IS '货币类型';
|
||||
COMMENT ON COLUMN wallet_pricing_config.enabled IS '1启用 0停用';
|
||||
|
||||
--------------------pgsql创建pricing_config表语句---------------------------
|
||||
--------------------pgsql创建wallet_pricing_config表语句---------------------------
|
||||
|
||||
--------------------pgsql创建charge_order表语句---------------------------
|
||||
--------------------pgsql创建wallet_charge_order表语句---------------------------
|
||||
|
||||
-- 计费单表(一次计价调用 = 一张计费单,幂等键 subject_type + subject_id + biz_order_no)
|
||||
CREATE TABLE IF NOT EXISTS charge_order (
|
||||
CREATE TABLE IF NOT EXISTS wallet_charge_order (
|
||||
-- 基础字段(继承 SQLBaseCol 通用字段,与 SQLBaseDO 对齐)
|
||||
id BIGINT PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 0,
|
||||
@@ -394,34 +390,34 @@ CREATE TABLE IF NOT EXISTS charge_order (
|
||||
charge_mode VARCHAR(32) NOT NULL,
|
||||
status SMALLINT NOT NULL DEFAULT 1,
|
||||
actual_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||
usage TEXT NOT NULL DEFAULT '',
|
||||
rule_snapshot TEXT NOT NULL DEFAULT '',
|
||||
usage JSONB NOT NULL DEFAULT '{}',
|
||||
rule_snapshot JSONB NOT NULL DEFAULT '{}',
|
||||
settle_time TIMESTAMP
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_charge_order_subject ON charge_order(subject_type, subject_id, biz_order_no);
|
||||
CREATE INDEX IF NOT EXISTS idx_charge_order_user_id ON charge_order(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_charge_order_status ON charge_order(status);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_charge_order_subject ON wallet_charge_order(subject_type, subject_id, biz_order_no);
|
||||
CREATE INDEX IF NOT EXISTS idx_charge_order_user_id ON wallet_charge_order(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_charge_order_status ON wallet_charge_order(status);
|
||||
|
||||
-- 表和字段注释
|
||||
COMMENT ON TABLE charge_order IS '计费单表(一次计价调用 = 一张计费单)';
|
||||
COMMENT ON COLUMN charge_order.id IS '主键ID(非自增)';
|
||||
COMMENT ON COLUMN charge_order.tenant_id IS '租户ID';
|
||||
COMMENT ON COLUMN charge_order.creator IS '创建人';
|
||||
COMMENT ON COLUMN charge_order.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN charge_order.updater IS '更新人';
|
||||
COMMENT ON COLUMN charge_order.updated_at IS '更新时间';
|
||||
COMMENT ON COLUMN charge_order.deleted_at IS '删除时间(软删)';
|
||||
COMMENT ON COLUMN charge_order.subject_type IS '计价对象类型';
|
||||
COMMENT ON COLUMN charge_order.subject_id IS '计价对象ID';
|
||||
COMMENT ON COLUMN charge_order.biz_order_no IS '业务单据号(幂等键,如工作流 execId)';
|
||||
COMMENT ON COLUMN charge_order.user_id IS '扣费用户ID';
|
||||
COMMENT ON COLUMN charge_order.charge_mode IS '计费方式(建单快照)';
|
||||
COMMENT ON COLUMN charge_order.status IS '状态:1已建单 2已结算 3已失败';
|
||||
COMMENT ON COLUMN charge_order.actual_amount IS '实收金额(元,保留2位小数)';
|
||||
COMMENT ON COLUMN charge_order.usage IS '实际用量JSON';
|
||||
COMMENT ON COLUMN charge_order.rule_snapshot IS '结算用费率快照JSON(防改价影响在途单)';
|
||||
COMMENT ON COLUMN charge_order.settle_time IS '结算时间';
|
||||
COMMENT ON TABLE wallet_charge_order IS '计费单表(一次计价调用 = 一张计费单)';
|
||||
COMMENT ON COLUMN wallet_charge_order.id IS '主键ID(非自增)';
|
||||
COMMENT ON COLUMN wallet_charge_order.tenant_id IS '租户ID';
|
||||
COMMENT ON COLUMN wallet_charge_order.creator IS '创建人';
|
||||
COMMENT ON COLUMN wallet_charge_order.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN wallet_charge_order.updater IS '更新人';
|
||||
COMMENT ON COLUMN wallet_charge_order.updated_at IS '更新时间';
|
||||
COMMENT ON COLUMN wallet_charge_order.deleted_at IS '删除时间(软删)';
|
||||
COMMENT ON COLUMN wallet_charge_order.subject_type IS '计价对象类型';
|
||||
COMMENT ON COLUMN wallet_charge_order.subject_id IS '计价对象ID';
|
||||
COMMENT ON COLUMN wallet_charge_order.biz_order_no IS '业务单据号(幂等键,如工作流 execId)';
|
||||
COMMENT ON COLUMN wallet_charge_order.user_id IS '扣费用户ID';
|
||||
COMMENT ON COLUMN wallet_charge_order.charge_mode IS '计费方式(建单快照)';
|
||||
COMMENT ON COLUMN wallet_charge_order.status IS '状态:1已建单 2已结算 3已失败';
|
||||
COMMENT ON COLUMN wallet_charge_order.actual_amount IS '实收金额(元,保留2位小数)';
|
||||
COMMENT ON COLUMN wallet_charge_order.usage IS '实际用量JSON';
|
||||
COMMENT ON COLUMN wallet_charge_order.rule_snapshot IS '结算用费率快照JSON(防改价影响在途单)';
|
||||
COMMENT ON COLUMN wallet_charge_order.settle_time IS '结算时间';
|
||||
|
||||
--------------------pgsql创建charge_order表语句---------------------------
|
||||
--------------------pgsql创建wallet_charge_order表语句---------------------------
|
||||
@@ -5,12 +5,14 @@ import (
|
||||
|
||||
knapsackController "shop-user-trade/controller/knapsack"
|
||||
marketController "shop-user-trade/controller/market"
|
||||
pricingController "shop-user-trade/controller/pricing"
|
||||
walletController "shop-user-trade/controller/wallet"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/http"
|
||||
"gitea.redpowerfuture.com/red-future/common/jaeger"
|
||||
_ "gitea.redpowerfuture.com/red-future/common/swagger"
|
||||
_ "github.com/gogf/gf/contrib/drivers/pgsql/v2"
|
||||
_ "github.com/gogf/gf/contrib/nosql/redis/v2" // common utils.WithLock 需要 go-redis adapter(WATCH/MULTI 归属释放)
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -20,7 +22,8 @@ func main() {
|
||||
http.RouteRegister([]interface{}{
|
||||
knapsackController.Knapsack,
|
||||
marketController.Market,
|
||||
walletController.Wallet,
|
||||
walletController.Account,
|
||||
pricingController.Pricing,
|
||||
})
|
||||
select {}
|
||||
}
|
||||
|
||||
@@ -26,11 +26,11 @@ type OpenOrderReq struct {
|
||||
type SettleReq struct {
|
||||
g.Meta `path:"/settle" method:"post" tags:"计价扣费" summary:"结算" dc:"按实际用量计价实收(允许负余额),幂等"`
|
||||
|
||||
OrderId int64 `json:"orderId" dc:"计费单ID(与 subjectType+subjectId+bizOrderNo 二选一)"`
|
||||
SubjectType string `json:"subjectType" dc:"计价对象类型(orderId 为空时必填)"`
|
||||
SubjectID string `json:"subjectId" dc:"计价对象ID(orderId 为空时必填)"`
|
||||
BizOrderNo string `json:"bizOrderNo" dc:"业务单据号(orderId 为空时必填)"`
|
||||
Usage string `json:"usage" v:"required" dc:"用量JSON:{durationSec,itemCount,tokensByModel,...}"`
|
||||
OrderId int64 `json:"orderId" dc:"计费单ID(与 subjectType+subjectId+bizOrderNo 二选一)"`
|
||||
SubjectType string `json:"subjectType" dc:"计价对象类型(orderId 为空时必填)"`
|
||||
SubjectID string `json:"subjectId" dc:"计价对象ID(orderId 为空时必填)"`
|
||||
BizOrderNo string `json:"bizOrderNo" dc:"业务单据号(orderId 为空时必填)"`
|
||||
Usage map[string]any `json:"usage" v:"required" dc:"用量JSON对象(前端传对象)。工作流口径:{durationSec,feeByModel}——feeByModel 各模型=按次调 /calc 已记录费用(per_token 结算/时长取消补收据此合计实收);durationSec=生成视频总时长(per_item/per_second)"`
|
||||
}
|
||||
|
||||
// ====================== 取消(按已消耗用量结算) ======================
|
||||
@@ -39,11 +39,11 @@ type SettleReq struct {
|
||||
type CancelReq struct {
|
||||
g.Meta `path:"/cancel" method:"post" tags:"计价扣费" summary:"取消" dc:"中途取消按已消耗用量计价实收(非退款),幂等"`
|
||||
|
||||
OrderId int64 `json:"orderId" dc:"计费单ID(与 subjectType+subjectId+bizOrderNo 二选一)"`
|
||||
SubjectType string `json:"subjectType" dc:"计价对象类型(orderId 为空时必填)"`
|
||||
SubjectID string `json:"subjectId" dc:"计价对象ID(orderId 为空时必填)"`
|
||||
BizOrderNo string `json:"bizOrderNo" dc:"业务单据号(orderId 为空时必填)"`
|
||||
Usage string `json:"usage" v:"required" dc:"已消耗用量JSON"`
|
||||
OrderId int64 `json:"orderId" dc:"计费单ID(与 subjectType+subjectId+bizOrderNo 二选一)"`
|
||||
SubjectType string `json:"subjectType" dc:"计价对象类型(orderId 为空时必填)"`
|
||||
SubjectID string `json:"subjectId" dc:"计价对象ID(orderId 为空时必填)"`
|
||||
BizOrderNo string `json:"bizOrderNo" dc:"业务单据号(orderId 为空时必填)"`
|
||||
Usage map[string]any `json:"usage" v:"required" dc:"已消耗用量JSON对象(前端传对象)"`
|
||||
}
|
||||
|
||||
// ====================== 失败(不扣费) ======================
|
||||
@@ -61,45 +61,47 @@ type FailReq struct {
|
||||
|
||||
// ====================== 查询 ======================
|
||||
|
||||
// GetOrderReq 查询计费单请求
|
||||
type GetOrderReq struct {
|
||||
g.Meta `path:"/get_order" method:"get" tags:"计价扣费" summary:"查询计费单" dc:"按ID或 subjectType+subjectId+bizOrderNo 查询"`
|
||||
|
||||
OrderId int64 `json:"orderId" dc:"计费单ID(与 subjectType+subjectId+bizOrderNo 二选一)"`
|
||||
SubjectType string `json:"subjectType" dc:"计价对象类型(orderId 为空时必填)"`
|
||||
SubjectID string `json:"subjectId" dc:"计价对象ID(orderId 为空时必填)"`
|
||||
BizOrderNo string `json:"bizOrderNo" dc:"业务单据号(orderId 为空时必填)"`
|
||||
}
|
||||
|
||||
// ListOrdersReq 分页查询计费单请求
|
||||
type ListOrdersReq struct {
|
||||
g.Meta `path:"/orders" method:"get" tags:"计价扣费" summary:"分页查询计费单" dc:"按用户分页查询计费单"`
|
||||
|
||||
UserId int64 `json:"userId" v:"required|min:1" dc:"用户ID"`
|
||||
Page int `json:"page" v:"required|min:1" dc:"页码"`
|
||||
PageSize int `json:"pageSize" v:"required|min:1|max:100" dc:"每页大小"`
|
||||
}
|
||||
|
||||
// ChargeOrderList 计费单列表
|
||||
type ChargeOrderList struct {
|
||||
Orders []ChargeOrderInfo `json:"orders"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// ChargeOrderInfo 计费单信息
|
||||
type ChargeOrderInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
SubjectType string `json:"subjectType"`
|
||||
SubjectID string `json:"subjectId"`
|
||||
BizOrderNo string `json:"bizOrderNo"`
|
||||
UserId int64 `json:"userId"`
|
||||
ChargeMode string `json:"chargeMode"`
|
||||
Status int `json:"status"` // 1已建单 2已结算 3已失败
|
||||
ActualAmount float64 `json:"actualAmount"`
|
||||
Usage string `json:"usage"`
|
||||
RuleSnapshot string `json:"ruleSnapshot"`
|
||||
SettleTime string `json:"settleTime"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ID int64 `json:"id"`
|
||||
SubjectType string `json:"subjectType"`
|
||||
SubjectID string `json:"subjectId"`
|
||||
BizOrderNo string `json:"bizOrderNo"`
|
||||
UserId int64 `json:"userId"`
|
||||
ChargeMode string `json:"chargeMode"`
|
||||
Status int `json:"status"` // 1已建单 2已结算 3已失败
|
||||
ActualAmount float64 `json:"actualAmount"`
|
||||
Usage map[string]any `json:"usage"`
|
||||
RuleSnapshot map[string]any `json:"ruleSnapshot"`
|
||||
SettleTime string `json:"settleTime"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// GetOrderInfoReq 查询计费单(工作流结算前取 CreatedAt 算时长)
|
||||
type GetOrderInfoReq struct {
|
||||
g.Meta `path:"/order" method:"get" tags:"计价扣费" summary:"查询计费单" dc:"按ID或subjectType+subjectId+bizOrderNo查询计费单(工作流结算前取CreatedAt算时长)"`
|
||||
|
||||
Id int64 `json:"id" dc:"计费单ID(与 subjectType+subjectId+bizOrderNo 二选一)"`
|
||||
SubjectType string `json:"subjectType" dc:"计价对象类型"`
|
||||
SubjectID string `json:"subjectId" dc:"计价对象ID"`
|
||||
BizOrderNo string `json:"bizOrderNo" dc:"业务单据号"`
|
||||
}
|
||||
|
||||
// ====================== 算费(不建单不扣费,model-gateway 调用) ======================
|
||||
|
||||
// CalcFeeReq 按用量计算费用(不建单不扣费;未配置/未启用计价即报错)
|
||||
type CalcFeeReq struct {
|
||||
g.Meta `path:"/calc" method:"post" tags:"计价扣费" summary:"按用量计算费用" dc:"按计价配置与用量计算费用(不建单不扣费;model-gateway 调用)"`
|
||||
|
||||
SubjectType string `json:"subjectType" v:"required" dc:"计价对象类型"`
|
||||
SubjectID string `json:"subjectId" v:"required" dc:"计价对象ID"`
|
||||
ChargeMode string `json:"chargeMode" dc:"计费方式(model 可空=配置 unit)"`
|
||||
Usage map[string]any `json:"usage" v:"required" dc:"用量JSON对象(ChargeUsage 形状:promptTokens/completionTokens/cachedTokens/mediaType/durationSec 等)"`
|
||||
}
|
||||
|
||||
// CalcFeeRes 算费结果
|
||||
type CalcFeeRes struct {
|
||||
Cost float64 `json:"cost" dc:"费用(元,不足1分向上取整)"`
|
||||
}
|
||||
|
||||
// ====================== DAO 入参(统一吃 DTO,同 model-gateway) ======================
|
||||
@@ -114,22 +116,22 @@ type GetChargeOrderReq struct {
|
||||
|
||||
// CreateChargeOrderReq DAO 创建计费单
|
||||
type CreateChargeOrderReq struct {
|
||||
SubjectType pricingConsts.SubjectType `json:"subjectType"`
|
||||
SubjectID string `json:"subjectId"`
|
||||
BizOrderNo string `json:"bizOrderNo"`
|
||||
UserId int64 `json:"userId"`
|
||||
ChargeMode pricingConsts.ChargeMode `json:"chargeMode"`
|
||||
Status pricingConsts.ChargeOrderStatus `json:"status"`
|
||||
RuleSnapshot string `json:"ruleSnapshot"`
|
||||
SubjectType pricingConsts.SubjectType `json:"subjectType"`
|
||||
SubjectID string `json:"subjectId"`
|
||||
BizOrderNo string `json:"bizOrderNo"`
|
||||
UserId int64 `json:"userId"`
|
||||
ChargeMode pricingConsts.ChargeMode `json:"chargeMode"`
|
||||
Status pricingConsts.ChargeOrderStatus `json:"status"`
|
||||
RuleSnapshot map[string]any `json:"ruleSnapshot"`
|
||||
}
|
||||
|
||||
// SettleChargeOrderReq DAO 结算迁移(CREATED→SETTLED,条件更新幂等)
|
||||
type SettleChargeOrderReq struct {
|
||||
Id int64 `json:"id"`
|
||||
ActualAmount float64 `json:"actualAmount"`
|
||||
Usage string `json:"usage"`
|
||||
RuleSnapshot string `json:"ruleSnapshot"`
|
||||
SettleTime *gtime.Time `json:"settleTime"`
|
||||
Id int64 `json:"id"`
|
||||
ActualAmount float64 `json:"actualAmount"`
|
||||
Usage map[string]any `json:"usage"`
|
||||
RuleSnapshot map[string]any `json:"ruleSnapshot"`
|
||||
SettleTime *gtime.Time `json:"settleTime"`
|
||||
}
|
||||
|
||||
// FailChargeOrderReq DAO 失败迁移(CREATED→FAILED,条件更新幂等)
|
||||
|
||||
@@ -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:"更新时间"`
|
||||
}
|
||||
|
||||
@@ -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 按主体查询可选计费方式请求
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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:"结算时间"`
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
pricingConsts "shop-user-trade/consts/pricing"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
@@ -16,7 +14,6 @@ type pricingConfigCol struct {
|
||||
MinBalance string
|
||||
Currency string
|
||||
Enabled string
|
||||
Version string
|
||||
}
|
||||
|
||||
var PricingConfigCol = pricingConfigCol{
|
||||
@@ -27,7 +24,6 @@ var PricingConfigCol = pricingConfigCol{
|
||||
MinBalance: "min_balance",
|
||||
Currency: "currency",
|
||||
Enabled: "enabled",
|
||||
Version: "version",
|
||||
}
|
||||
|
||||
// PricingConfig 计价配置实体(费率全 DB 配置,改价/加档只改 rules,代码不写死金额)
|
||||
@@ -36,21 +32,17 @@ type PricingConfig struct {
|
||||
|
||||
SubjectType pricingConsts.SubjectType `orm:"subject_type" json:"subjectType" description:"计价对象类型:workflow/model/business"`
|
||||
SubjectID string `orm:"subject_id" json:"subjectId" description:"计价对象ID(workflow/模型ID/业务模块标识)"`
|
||||
Rules string `orm:"rules" json:"rules" description:"费率JSON(见设计§4,rules 自描述不再有 charge_mode 列)"`
|
||||
Rules map[string]any `orm:"rules" json:"rules" description:"费率JSON对象(见设计§4,rules 自描述不再有 charge_mode 列)"`
|
||||
MinBalance float64 `orm:"min_balance" json:"minBalance" description:"门禁:调用前可用余额须>=该值(元),0=不校验"`
|
||||
Currency string `orm:"currency" json:"currency" description:"货币类型"`
|
||||
Enabled int `orm:"enabled" json:"enabled" description:"1启用 0停用"`
|
||||
Version int64 `orm:"version" json:"version" description:"乐观锁版本号"`
|
||||
}
|
||||
|
||||
// Unit 从 rules 中解析模型计费单位(仅 subject_type=model 有效,其余返回空)。
|
||||
// service 层 per_token 结算/建单解析计费方式时使用,避免重复解析 rules。
|
||||
func (e *PricingConfig) Unit() pricingConsts.ChargeMode {
|
||||
var cfg struct {
|
||||
Unit pricingConsts.ChargeMode `json:"unit"`
|
||||
if unit, ok := e.Rules["unit"].(string); ok {
|
||||
return pricingConsts.ChargeMode(unit)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(e.Rules), &cfg); err != nil {
|
||||
return ""
|
||||
}
|
||||
return cfg.Unit
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -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:"乐观锁版本号"`
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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)"`
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -13,16 +13,20 @@ import (
|
||||
// ====================== 用量结构 ======================
|
||||
|
||||
// ChargeUsage 实际用量(结算入参,工作流/调用方上报)。
|
||||
// DurationSec 秒、TokensByModel 各模型token数(per_token 用)。
|
||||
// DurationSec 秒;工作流 token 计费按 FeeByModel 合计实收(各模型=调用方按次调 shop /calc 返回费求和,
|
||||
// 每次调用已 ceilFen 到分、媒体/费率按调用时快照)。逐模型 token/媒体明细由调用方保留(ai-agent
|
||||
// node_execution.token_info),本结构解析只取计价字段,未知键原样落库 order.usage 作审计(见 settleOrder)。
|
||||
// 模型计价字段见各计算器注释(spec §5.3)。
|
||||
type ChargeUsage struct {
|
||||
DurationSec float64 `json:"durationSec"` // 时长(秒)
|
||||
TokensByModel map[string]int64 `json:"tokensByModel"` // 各模型消耗token
|
||||
DurationSec float64 `json:"durationSec"` // 时长(秒):per_item/per_second 工作流结算、model 单位计价用
|
||||
// FeeByModel 各模型已消耗费用(= 调用方按次调 shop /calc 返回费的合计,每次调用已 ceilFen 到分、各次媒体/费率
|
||||
// 按调用时快照)。per_item/per_second 取消补收、per_token 结算的 token 部分按此合计实收(不再按聚合 token
|
||||
// 重算,避免丢失「每次调用不足1分按1分」的兜底与逐调用媒体价——聚合重算会把两笔 0.01 合并成 0.01 且拿错媒体价)。
|
||||
FeeByModel map[string]float64 `json:"feeByModel"`
|
||||
// —— 模型计价扩展(spec §5.3)——
|
||||
PromptTokens int64 `json:"promptTokens"` // token 计费用:输入
|
||||
CompletionTokens int64 `json:"completionTokens"` // token 计费用:输出
|
||||
CompletionTokens int64 `json:"completionTokens"` // token 计费用:输出;per_char 模型=输出字数(调用方把字数映射到该字段传输)
|
||||
CachedTokens int64 `json:"cachedTokens"` // token 计费用:缓存命中
|
||||
CharCount int64 `json:"charCount"` // per_char 用(音频字数)
|
||||
ImageCount int64 `json:"imageCount"` // per_1 用(图片张数)
|
||||
MediaType string `json:"mediaType"` // text/audio/video/image(输入媒体)
|
||||
Thinking *bool `json:"thinking"` // 推理 思考/非思考
|
||||
@@ -88,7 +92,7 @@ var (
|
||||
pricingConsts.ChargeModePerSecond: modelUnitCalculator{base: 1, usage: usageDurationSec},
|
||||
pricingConsts.ChargeModePerMinute: modelUnitCalculator{base: 60, usage: usageDurationSec},
|
||||
pricingConsts.ChargeModePerHour: modelUnitCalculator{base: 3600, usage: usageDurationSec},
|
||||
pricingConsts.ChargeModePerChar: modelUnitCalculator{base: 1, usage: usageCharCount},
|
||||
pricingConsts.ChargeModePerChar: modelUnitCalculator{base: 1, usage: usageCompletionTokens}, // 输出字数经 completionTokens 传输
|
||||
}
|
||||
// business 计算器(spec §4.3 周期订阅)
|
||||
businessCalculators = map[pricingConsts.ChargeMode]calculator{
|
||||
@@ -142,6 +146,10 @@ func (perItemCalculator) validate(rulesJSON string) (interface{}, error) {
|
||||
|
||||
func (perItemCalculator) charge(rules interface{}, usage *ChargeUsage) (float64, error) {
|
||||
r := rules.(*perItemRules)
|
||||
// 0秒无产出(中途取消/未生成视频):按已消耗=0 计费,不落入第一档位价
|
||||
if usage.DurationSec <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
last := r.Tiers[len(r.Tiers)-1]
|
||||
for _, t := range r.Tiers {
|
||||
if usage.DurationSec <= t.MaxSec {
|
||||
@@ -179,7 +187,7 @@ func (perSecondCalculator) charge(rules interface{}, usage *ChargeUsage) (float6
|
||||
type perTokenCalculator struct{}
|
||||
|
||||
func (perTokenCalculator) validate(rulesJSON string) (interface{}, error) {
|
||||
// per_token 为 {} 仅启用标记;价格取自各模型 subject 实时配置(spec §5.4)
|
||||
// per_token 为 {} 仅启用标记;费率不落槽位(建单无快照),结算按调用方上报的按次费用(FeeByModel)合计实收(spec §5.4)
|
||||
var container map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(rulesJSON), &container); err != nil {
|
||||
return nil, fmt.Errorf("per_token 费率JSON解析失败: %v", err)
|
||||
@@ -188,8 +196,9 @@ func (perTokenCalculator) validate(rulesJSON string) (interface{}, error) {
|
||||
}
|
||||
|
||||
func (perTokenCalculator) charge(rules interface{}, usage *ChargeUsage) (float64, error) {
|
||||
// 不在此计价:per_token 按模型 subject 实时价,由 service 层 calcPerTokenOrder 处理
|
||||
return 0, errors.New("per_token 计价在 service 层按模型 subject 实时价计算")
|
||||
// 不在此计价:per_token 无费率档位(建单槽位 {}),按调用方上报的按次已记录费用(FeeByModel)合计实收,
|
||||
// 由 service 层 sumRecordedFee 处理
|
||||
return 0, errors.New("per_token 计价在 service 层按调用方上报的按次费用(FeeByModel)合计实收")
|
||||
}
|
||||
|
||||
// ====================== 模型计费 JSON 结构(model subject rules,spec §4.2) ======================
|
||||
@@ -197,9 +206,9 @@ func (perTokenCalculator) charge(rules interface{}, usage *ChargeUsage) (float64
|
||||
// modelRules 模型计费规则:unit + tiered + rules(rules 按序首条命中)
|
||||
type modelRules struct {
|
||||
Unit pricingConsts.ChargeMode `json:"unit"`
|
||||
Tiered bool `json:"tiered"`
|
||||
Rules []modelRule `json:"rules"`
|
||||
Currency string `json:"currency"`
|
||||
Tiered bool `json:"tiered"`
|
||||
Rules []modelRule `json:"rules"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
type modelRule struct {
|
||||
@@ -418,8 +427,10 @@ func (c modelUnitCalculator) charge(rules interface{}, usage *ChargeUsage) (floa
|
||||
|
||||
// 用量提取函数
|
||||
func usageDurationSec(u *ChargeUsage) float64 { return u.DurationSec }
|
||||
func usageCharCount(u *ChargeUsage) float64 { return float64(u.CharCount) }
|
||||
func usageImageCount(u *ChargeUsage) float64 { return float64(u.ImageCount) }
|
||||
|
||||
// usageCompletionTokens per_char 用:调用方把输出字数映射到 CompletionTokens 传输(无独立 charCount 字段)
|
||||
func usageCompletionTokens(u *ChargeUsage) float64 { return float64(u.CompletionTokens) }
|
||||
func usageImageCount(u *ChargeUsage) float64 { return float64(u.ImageCount) }
|
||||
|
||||
// ====================== per_period 周期订阅(business) ======================
|
||||
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
func TestValidateTieredBandsAdjacentOK(t *testing.T) {
|
||||
rules := []modelRule{
|
||||
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}},
|
||||
{Match: &modelMatch{InputLengthMin: 32001, InputLengthMax: 128000}},
|
||||
{Match: &modelMatch{InputLengthMin: 128001, InputLengthMax: 256000}},
|
||||
}
|
||||
if err := validateTieredBands(rules); err != nil {
|
||||
t.Fatalf("相邻档位应通过: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTieredBandsSameStartRejected(t *testing.T) {
|
||||
// 旧实现对 InputLengthMin==0 的规则对 continue,两个同起 0 的档位查不出重叠
|
||||
rules := []modelRule{
|
||||
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}},
|
||||
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 64000}},
|
||||
}
|
||||
if err := validateTieredBands(rules); err == nil {
|
||||
t.Fatal("两个同起 0 的档位必须判重叠")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTieredBandsUnsortedOverlapDetected(t *testing.T) {
|
||||
// 乱序提交仍须判重叠(旧实现依赖提交顺序,此用例对旧实现漏检)
|
||||
rules := []modelRule{
|
||||
{Match: &modelMatch{InputLengthMin: 50000, InputLengthMax: 64000}},
|
||||
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 52000}},
|
||||
}
|
||||
if err := validateTieredBands(rules); err == nil {
|
||||
t.Fatal("重叠必须与提交顺序无关地被检出")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTieredBandsOpenEndedLastOK(t *testing.T) {
|
||||
rules := []modelRule{
|
||||
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}},
|
||||
{Match: &modelMatch{InputLengthMin: 32001, InputLengthMax: 0}}, // 上不封顶
|
||||
}
|
||||
if err := validateTieredBands(rules); err != nil {
|
||||
t.Fatalf("上不封顶在最后应通过: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTieredBandsOpenEndedNotLastRejected(t *testing.T) {
|
||||
rules := []modelRule{
|
||||
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}},
|
||||
{Match: &modelMatch{InputLengthMin: 32001, InputLengthMax: 0}}, // 上不封顶
|
||||
{Match: &modelMatch{InputLengthMin: 64001, InputLengthMax: 96000}},
|
||||
}
|
||||
if err := validateTieredBands(rules); err == nil {
|
||||
t.Fatal("上不封顶档之后不能再有长度档")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTieredBandsNonLengthRuleIgnored(t *testing.T) {
|
||||
// thinking 兜底档无长度区间,不应参与重叠校验
|
||||
rules := []modelRule{
|
||||
{Match: &modelMatch{Thinking: boolPtr(false)}},
|
||||
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}},
|
||||
}
|
||||
if err := validateTieredBands(rules); err != nil {
|
||||
t.Fatalf("无长度规则不应干扰: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenValidateMediaPricesBadKey(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1e6}
|
||||
_, err := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":1},"mediaPrices":{"audios":{"input":2}}}]}`)
|
||||
if err == nil {
|
||||
t.Fatal("mediaPrices 非法键必须报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenValidateMediaPricesNilValue(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1e6}
|
||||
_, err := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":1},"mediaPrices":{"audio":null}}]}`)
|
||||
if err == nil {
|
||||
t.Fatal("mediaPrices 值为空必须报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenValidateMediaPricesValid(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1e6}
|
||||
_, err := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":1},"mediaPrices":{"audio":{"input":2},"video":{"input":3}}}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("合法 mediaPrices 应通过: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnitValidateMediaPricesBadKey(t *testing.T) {
|
||||
c := modelUnitCalculator{base: 1, usage: usageDurationSec}
|
||||
_, err := c.validate(`{"unit":"per_second","rules":[{"name":"r","price":{"unitPrice":1},"mediaPrices":{"snd":{"unitPrice":2}}}]}`)
|
||||
if err == nil {
|
||||
t.Fatal("mediaPrices 非法键必须报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnitValidateMediaPricesValid(t *testing.T) {
|
||||
c := modelUnitCalculator{base: 1, usage: usageDurationSec}
|
||||
_, err := c.validate(`{"unit":"per_second","rules":[{"name":"r","price":{"unitPrice":1},"mediaPrices":{"audio":{"unitPrice":2}}}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("合法 mediaPrices 应通过: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenChargeMediaPricesAudio(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1e6}
|
||||
rules, err := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":0.3,"output":1.8,"cacheHit":0.12},"mediaPrices":{"audio":{"input":4.5,"output":1.8,"cacheHit":1.8}}}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1e6, MediaType: "audio"})
|
||||
if err != nil {
|
||||
t.Fatalf("charge: %v", err)
|
||||
}
|
||||
if got != 4.5 {
|
||||
t.Fatalf("含音频应收媒体价 4.5,实收 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenChargeDefaultWithoutMedia(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1e6}
|
||||
rules, _ := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":0.3,"output":1.8,"cacheHit":0.12},"mediaPrices":{"audio":{"input":4.5,"output":1.8,"cacheHit":1.8}}}]}`)
|
||||
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1e6, MediaType: "text"})
|
||||
if err != nil {
|
||||
t.Fatalf("charge: %v", err)
|
||||
}
|
||||
if got != 0.3 {
|
||||
t.Fatalf("不含媒体应收默认价 0.3,实收 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenChargeMediaPricesMultiType(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1000}
|
||||
rules, _ := c.validate(`{"unit":"per_1K","rules":[{"name":"r","price":{"input":0.3},"mediaPrices":{"audio":{"input":4.5},"video":{"input":6}}}]}`)
|
||||
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1000, MediaType: "video"})
|
||||
if err != nil {
|
||||
t.Fatalf("charge: %v", err)
|
||||
}
|
||||
if got != 6 {
|
||||
t.Fatalf("video 应收媒体价 6,实收 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenChargeMediaPricesUnmatchedTypeUsesDefault(t *testing.T) {
|
||||
// mediaPrices 只有 audio,video 输入落默认价
|
||||
c := modelTokenCalculator{base: 1000}
|
||||
rules, _ := c.validate(`{"unit":"per_1K","rules":[{"name":"r","price":{"input":0.3},"mediaPrices":{"audio":{"input":4.5}}}]}`)
|
||||
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1000, MediaType: "video"})
|
||||
if err != nil {
|
||||
t.Fatalf("charge: %v", err)
|
||||
}
|
||||
if got != 0.3 {
|
||||
t.Fatalf("未配 mediaPrices 的媒体应收默认价 0.3,实收 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnitChargeMediaPrices(t *testing.T) {
|
||||
c := modelUnitCalculator{base: 1, usage: usageDurationSec}
|
||||
rules, err := c.validate(`{"unit":"per_second","rules":[{"name":"r","price":{"unitPrice":1},"mediaPrices":{"audio":{"unitPrice":2}}}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
got, err := c.charge(rules, &ChargeUsage{DurationSec: 3, MediaType: "audio"})
|
||||
if err != nil {
|
||||
t.Fatalf("charge: %v", err)
|
||||
}
|
||||
if got != 6 {
|
||||
t.Fatalf("audio 单位价应收 6,实收 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnitChargeMediaPricesDefault(t *testing.T) {
|
||||
c := modelUnitCalculator{base: 1, usage: usageDurationSec}
|
||||
rules, _ := c.validate(`{"unit":"per_second","rules":[{"name":"r","price":{"unitPrice":1},"mediaPrices":{"audio":{"unitPrice":2}}}]}`)
|
||||
got, err := c.charge(rules, &ChargeUsage{DurationSec: 3, MediaType: "video"})
|
||||
if err != nil {
|
||||
t.Fatalf("charge: %v", err)
|
||||
}
|
||||
if got != 3 {
|
||||
t.Fatalf("未配媒体价应收默认 3,实收 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 阶梯算费 fixture(原 fixture 的 mediaType:"text" 已随 match 移除)
|
||||
const modelTokenTieredJSON = `{
|
||||
"unit":"per_1M","tiered":true,
|
||||
"rules":[
|
||||
{"name":"思考≤32000","match":{"thinking":true,"inputLengthMax":32000},"price":{"input":0.6,"output":3.6,"cacheHit":0.12}},
|
||||
{"name":"思考>32000","match":{"thinking":true,"inputLengthMin":32001},"price":{"input":0.9,"output":5.4,"cacheHit":0.18}}
|
||||
]}`
|
||||
|
||||
// 阶梯算费:prompt 20000 → (0.6*20000+3.6*1000)/1e6=0.0156→0.02;50000 → 0.0504→0.06
|
||||
func TestModelTokenCalculator_TieredMatch(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1e6}
|
||||
rules, err := c.validate(modelTokenTieredJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
}
|
||||
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 20000, CompletionTokens: 1000, Thinking: boolPtr(true)})
|
||||
if err != nil {
|
||||
t.Fatalf("charge err: %v", err)
|
||||
}
|
||||
if got != 0.02 {
|
||||
t.Fatalf("tier1 got %v want 0.02", got)
|
||||
}
|
||||
got, err = c.charge(rules, &ChargeUsage{PromptTokens: 50000, CompletionTokens: 1000, Thinking: boolPtr(true)})
|
||||
if err != nil {
|
||||
t.Fatalf("charge err: %v", err)
|
||||
}
|
||||
if got != 0.06 {
|
||||
t.Fatalf("tier2 got %v want 0.06", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 无匹配报错:thinking=false 不在任一档
|
||||
func TestModelTokenCalculator_NoMatch(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1e6}
|
||||
rules, err := c.validate(modelTokenTieredJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
}
|
||||
_, err = c.charge(rules, &ChargeUsage{PromptTokens: 1000, Thinking: boolPtr(false)})
|
||||
if err == nil {
|
||||
t.Fatal("expected no-match error")
|
||||
}
|
||||
}
|
||||
|
||||
// cache-hit 算费:(2000-1000)/1000*1 + 1000/1000*0.1 + 500/1000*2 = 2.1
|
||||
func TestModelTokenCalculator_CacheHit(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1000}
|
||||
rules, err := c.validate(`{"unit":"per_1K","tiered":false,"rules":[{"name":"统一","price":{"input":1,"output":2,"cacheHit":0.1}}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
}
|
||||
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 2000, CompletionTokens: 500, CachedTokens: 1000})
|
||||
if err != nil {
|
||||
t.Fatalf("charge err: %v", err)
|
||||
}
|
||||
if got != 2.1 {
|
||||
t.Fatalf("cache got %v want 2.1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// M3:cached > prompt 时金额不为负(promptInput 钳为 0):0 + 3000/1000*0.1 = 0.3
|
||||
func TestModelTokenCalculator_CachedGreaterThanPrompt(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1000}
|
||||
rules, err := c.validate(`{"unit":"per_1K","tiered":false,"rules":[{"name":"统一","price":{"input":1,"output":2,"cacheHit":0.1}}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
}
|
||||
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1000, CompletionTokens: 0, CachedTokens: 3000})
|
||||
if err != nil {
|
||||
t.Fatalf("charge err: %v", err)
|
||||
}
|
||||
if got < 0 {
|
||||
t.Fatalf("got negative amount %v", got)
|
||||
}
|
||||
if got != 0.3 {
|
||||
t.Fatalf("got %v want 0.3", got)
|
||||
}
|
||||
}
|
||||
|
||||
// P2:validate 拒绝 unit 与实例 base 不匹配的配置
|
||||
func TestModelTokenCalculator_ValidateUnitBaseMismatch(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1e6}
|
||||
if _, err := c.validate(`{"unit":"per_1K","tiered":false,"rules":[{"name":"统一","price":{"input":1}}]}`); err == nil {
|
||||
t.Fatal("expected unit/base mismatch error for per_1M instance with per_1K unit")
|
||||
}
|
||||
c2 := modelTokenCalculator{base: 1000}
|
||||
if _, err := c2.validate(`{"unit":"per_1M","tiered":false,"rules":[{"name":"统一","price":{"input":1}}]}`); err == nil {
|
||||
t.Fatal("expected unit/base mismatch error for per_1K instance with per_1M unit")
|
||||
}
|
||||
if _, err := c.validate(`{"unit":"per_1M","tiered":false,"rules":[{"name":"统一","price":{"input":1}}]}`); err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if _, err := c2.validate(`{"unit":"per_1K","tiered":false,"rules":[{"name":"统一","price":{"input":1}}]}`); err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// P1:validate 拒绝缺 price 的规则(token 计算器)
|
||||
func TestModelTokenCalculator_ValidateNilPrice(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1e6}
|
||||
if _, err := c.validate(`{"unit":"per_1M","tiered":false,"rules":[{"name":"无价"}]}`); err == nil {
|
||||
t.Fatal("expected nil price validation error")
|
||||
}
|
||||
}
|
||||
|
||||
// P1:validate 拒绝缺 price 的规则(单位计算器)
|
||||
func TestModelUnitCalculator_ValidateNilPrice(t *testing.T) {
|
||||
c := modelUnitCalculator{base: 60, usage: usageDurationSec}
|
||||
if _, err := c.validate(`{"unit":"per_minute","tiered":false,"rules":[{"name":"无价"}]}`); err == nil {
|
||||
t.Fatal("expected nil price validation error")
|
||||
}
|
||||
}
|
||||
|
||||
// 单位计算器多路径:按分钟(90s/60*2=3)、按字(10000*0.0005=5)、按张×分辨率(3*0.5=1.5)
|
||||
func TestModelUnitCalculator_Paths(t *testing.T) {
|
||||
c := modelUnitCalculator{base: 60, usage: usageDurationSec}
|
||||
rules, err := c.validate(`{"unit":"per_minute","tiered":false,"rules":[{"name":"统一","price":{"unitPrice":2}}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
}
|
||||
got, err := c.charge(rules, &ChargeUsage{DurationSec: 90})
|
||||
if err != nil || got != 3 {
|
||||
t.Fatalf("per_minute got %v want 3, err %v", got, err)
|
||||
}
|
||||
c2 := modelUnitCalculator{base: 1, usage: usageCharCount}
|
||||
rules2, err := c2.validate(`{"unit":"per_char","tiered":false,"rules":[{"name":"统一","price":{"unitPrice":0.0005}}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
}
|
||||
got, err = c2.charge(rules2, &ChargeUsage{CharCount: 10000})
|
||||
if err != nil || got != 5 {
|
||||
t.Fatalf("per_char got %v want 5, err %v", got, err)
|
||||
}
|
||||
c3 := modelUnitCalculator{base: 1, usage: usageImageCount}
|
||||
rules3, err := c3.validate(`{"unit":"per_1","tiered":false,"rules":[
|
||||
{"name":"512x512","match":{"outputResolution":"512x512"},"price":{"unitPrice":0.2}},
|
||||
{"name":"1024x1024","match":{"outputResolution":"1024x1024"},"price":{"unitPrice":0.5}}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
}
|
||||
got, err = c3.charge(rules3, &ChargeUsage{ImageCount: 3, OutputResolution: "1024x1024"})
|
||||
if err != nil || got != 1.5 {
|
||||
t.Fatalf("per_1 got %v want 1.5, err %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
pricingConsts "shop-user-trade/consts/pricing"
|
||||
chargeDao "shop-user-trade/dao/pricing"
|
||||
pricingDto "shop-user-trade/model/dto/pricing"
|
||||
pricingEntity "shop-user-trade/model/entity/pricing"
|
||||
)
|
||||
|
||||
// ====================== 配置管理 ======================
|
||||
|
||||
// SavePricingConfig 新增/更新计价配置(费率全 DB 配置;按 subject 类型校验 rules 结构,防止脏配置上线后结算报错)
|
||||
func (s *pricing) SavePricingConfig(ctx context.Context, req *pricingDto.SavePricingConfigReq) (*pricingDto.SavePricingConfigRes, error) {
|
||||
subjectType := pricingConsts.SubjectType(req.SubjectType)
|
||||
rulesBytes, marshalErr := json.Marshal(req.Rules)
|
||||
if marshalErr != nil {
|
||||
return nil, fmt.Errorf("费率JSON序列化失败: %v", marshalErr)
|
||||
}
|
||||
if err := s.validateConfigRules(subjectType, string(rulesBytes)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Currency == "" {
|
||||
req.Currency = "CNY"
|
||||
}
|
||||
if req.Id == 0 && g.IsEmpty(req.Enabled) {
|
||||
req.Enabled = new(1)
|
||||
}
|
||||
var id int64
|
||||
var err error
|
||||
if req.Id > 0 {
|
||||
// Update 返回受影响行数;配置 ID 即 req.Id
|
||||
if _, err = chargeDao.PricingConfig.Update(ctx, req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id = req.Id
|
||||
} else {
|
||||
id, err = chargeDao.PricingConfig.Insert(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &pricingDto.SavePricingConfigRes{Id: id}, nil
|
||||
}
|
||||
|
||||
// GetPricingConfig 查询计价配置
|
||||
func (s *pricing) GetPricingConfig(ctx context.Context, req *pricingDto.GetPricingConfigReq) (res *pricingDto.GetPricingConfigRes, err error) {
|
||||
cfg, err := chargeDao.PricingConfig.Get(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
res = new(pricingDto.GetPricingConfigRes)
|
||||
if err = gconv.Struct(cfg, res); err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// validateConfigRules 按 subject 类型校验 rules JSON(spec §4):
|
||||
//
|
||||
// workflow:三模式容器逐个校验已配模式;model:先解析 unit 再用对应计算器;business:per_period 计算器
|
||||
func (s *pricing) validateConfigRules(subjectType pricingConsts.SubjectType, rulesJSON string) error {
|
||||
switch subjectType {
|
||||
case pricingConsts.SubjectTypeWorkflow:
|
||||
return s.validateWorkflowRules(rulesJSON)
|
||||
case pricingConsts.SubjectTypeModel:
|
||||
var cfg struct {
|
||||
Unit pricingConsts.ChargeMode `json:"unit"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(rulesJSON), &cfg); err != nil {
|
||||
return fmt.Errorf("model 费率JSON解析失败: %v", err)
|
||||
}
|
||||
c, err := getCalculator(subjectType, cfg.Unit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.validate(rulesJSON)
|
||||
return err
|
||||
case pricingConsts.SubjectTypeBusiness:
|
||||
c, err := getCalculator(subjectType, pricingConsts.ChargeModePerPeriod)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.validate(rulesJSON)
|
||||
return err
|
||||
default:
|
||||
return errors.New("未知计价对象类型: " + string(subjectType))
|
||||
}
|
||||
}
|
||||
|
||||
// validateWorkflowRules 校验三模式容器:逐个校验已配模式(spec §4.1)
|
||||
func (s *pricing) validateWorkflowRules(rulesJSON string) error {
|
||||
var container map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(rulesJSON), &container); err != nil {
|
||||
return fmt.Errorf("workflow 费率JSON解析失败: %v", err)
|
||||
}
|
||||
if len(container) == 0 {
|
||||
return errors.New("workflow 费率至少配置一种模式")
|
||||
}
|
||||
for mode, raw := range container {
|
||||
chargeMode := pricingConsts.ChargeMode(mode)
|
||||
c, err := getCalculator(pricingConsts.SubjectTypeWorkflow, chargeMode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = c.validate(string(raw)); err != nil {
|
||||
return fmt.Errorf("workflow %s 模式校验失败: %v", mode, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getEnabledConfig 获取启用中的计价配置
|
||||
func (s *pricing) getEnabledConfig(ctx context.Context, subjectType pricingConsts.SubjectType, subjectID string) (*pricingEntity.PricingConfig, error) {
|
||||
cfg, err := chargeDao.PricingConfig.Get(ctx, &pricingDto.GetPricingConfigReq{SubjectType: string(subjectType), SubjectID: subjectID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
if cfg.Enabled != 1 {
|
||||
return nil, errors.New("计价配置未启用: " + subjectID)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// resolveChargeMode 建单时解析实际计费方式与规则快照(spec §6 OpenOrder 校验):
|
||||
//
|
||||
// workflow:chargeMode 必填 ∈ rules 已配模式;快照 = rules[chargeMode]
|
||||
// model:chargeMode 可空 = config.unit;快照 = 整个 modelCfg
|
||||
// business:chargeMode 可空 = per_period;快照 = 周期规则
|
||||
func (s *pricing) resolveChargeMode(ctx context.Context, cfg *pricingEntity.PricingConfig, req *pricingDto.OpenOrderReq) (pricingConsts.ChargeMode, map[string]any, error) {
|
||||
switch cfg.SubjectType {
|
||||
case pricingConsts.SubjectTypeWorkflow:
|
||||
if req.ChargeMode == "" {
|
||||
return "", nil, errors.New("workflow 建单须指定 chargeMode")
|
||||
}
|
||||
return pricingConsts.ChargeMode(req.ChargeMode), s.workflowRuleSnapshot(cfg.Rules, req.ChargeMode), nil
|
||||
case pricingConsts.SubjectTypeModel:
|
||||
// model 计费方式固定取配置 unit(cfg.Unit() 见 entity);快照 = 整个 rules 对象
|
||||
return cfg.Unit(), cfg.Rules, nil
|
||||
case pricingConsts.SubjectTypeBusiness:
|
||||
return pricingConsts.ChargeModePerPeriod, cfg.Rules, nil
|
||||
default:
|
||||
return "", nil, errors.New("未知计价对象类型: " + string(cfg.SubjectType))
|
||||
}
|
||||
}
|
||||
|
||||
// workflowRuleSnapshot 从三模式容器取选中模式的规则片段对象(spec §4.1)
|
||||
func (s *pricing) workflowRuleSnapshot(rules map[string]any, mode string) map[string]any {
|
||||
if snapshot, ok := rules[mode].(map[string]any); ok {
|
||||
return snapshot
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -8,10 +8,12 @@ import (
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
|
||||
|
||||
pricingConsts "shop-user-trade/consts/pricing"
|
||||
"shop-user-trade/consts/public"
|
||||
walletConsts "shop-user-trade/consts/wallet"
|
||||
chargeDao "shop-user-trade/dao/pricing"
|
||||
walletDao "shop-user-trade/dao/wallet"
|
||||
@@ -28,7 +30,7 @@ var (
|
||||
ErrConfigNotFound = errors.New("计价配置不存在")
|
||||
)
|
||||
|
||||
// pricing 计价服务:算钱(定价+计费单生命周期+门禁)。
|
||||
// Pricing pricing 计价服务:算钱(定价+计费单生命周期+门禁)。
|
||||
// 职责:建单(不动钱)→ 结算/取消(实收)→ 失败(不扣费)。
|
||||
// 锁经 wallet service(common 统一锁),账务经钱包 dao 的 Change 原语;
|
||||
// 钱包与计费单状态在同一事务内原子更新,不直接操作钱包表。
|
||||
@@ -67,7 +69,11 @@ func (s *pricing) OpenOrder(ctx context.Context, req *pricingDto.OpenOrderReq) (
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = c.validate(ruleSnapshot); err != nil {
|
||||
ruleJSON, err := json.Marshal(ruleSnapshot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("费率快照序列化失败: %v", err)
|
||||
}
|
||||
if _, err = c.validate(string(ruleJSON)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -151,7 +157,7 @@ func (s *pricing) Cancel(ctx context.Context, req *pricingDto.CancelReq) (*prici
|
||||
// settleOrder 核心结算:按 rule_snapshot 计价实收(允许负余额),状态 CREATED → SETTLED。
|
||||
// 钱包写入在独立 DB group,无法随 default 事务回滚;故持用户锁内权威重读状态为 CREATED 才扣费,
|
||||
// 条件状态迁移(CAS)作最后防线。重复结算返回既有结果。
|
||||
func (s *pricing) settleOrder(ctx context.Context, order *pricingEntity.ChargeOrder, usageJSON, desc string) (float64, error) {
|
||||
func (s *pricing) settleOrder(ctx context.Context, order *pricingEntity.ChargeOrder, usage map[string]any, desc string) (float64, error) {
|
||||
// 快路径(非权威):锁外状态仅供提前返回,扣费与否以锁内重读为准
|
||||
if order.Status == pricingConsts.ChargeOrderStatusSettled {
|
||||
return order.ActualAmount, nil
|
||||
@@ -160,17 +166,31 @@ func (s *pricing) settleOrder(ctx context.Context, order *pricingEntity.ChargeOr
|
||||
return 0, errors.New("计费单状态不可结算(已失败或状态异常)")
|
||||
}
|
||||
|
||||
var usage ChargeUsage
|
||||
if err := json.Unmarshal([]byte(usageJSON), &usage); err != nil {
|
||||
return 0, fmt.Errorf("用量JSON解析失败: %v", err)
|
||||
var usageStruct ChargeUsage
|
||||
if err := gconv.Struct(usage, &usageStruct); err != nil {
|
||||
return 0, fmt.Errorf("用量解析失败: %v", err)
|
||||
}
|
||||
// 用建单时费率快照计价(防改价影响在途单);per_token 特殊:按各模型 subject 实时价(spec §5.4)
|
||||
// 用建单时费率快照计价(防改价影响在途单);per_token 无快照(槽位 {}),按调用方上报的
|
||||
// 「按次已记录费用」(feeByModel)合计实收——每次模型调用在发生时已由 shop /calc 计价
|
||||
// (calculator.charge 内部 ceilFen,「不足1分按1分」按调用次生效 + 调用时媒体/费率快照),
|
||||
// 直接合计即实收,不再按聚合 token 重算(两笔各 0.01 合并重算成 0.01 的误收根因)。
|
||||
// 调用方原始上报的用量整份落库 order.usage 作审计(本处 gconv 解析只取计价字段,未知键忽略,不影响落库)。
|
||||
var actualAmount float64
|
||||
var err error
|
||||
if order.SubjectType == pricingConsts.SubjectTypeWorkflow && order.ChargeMode == pricingConsts.ChargeModePerToken {
|
||||
actualAmount, err = s.calcPerTokenOrder(ctx, &usage)
|
||||
actualAmount = sumRecordedFee(usageStruct.FeeByModel)
|
||||
} else {
|
||||
actualAmount, err = calcCharge(order.SubjectType, order.ChargeMode, order.RuleSnapshot, &usage)
|
||||
var ruleJSON []byte
|
||||
ruleJSON, err = json.Marshal(order.RuleSnapshot)
|
||||
if err == nil {
|
||||
actualAmount, err = calcCharge(order.SubjectType, order.ChargeMode, string(ruleJSON), &usageStruct)
|
||||
}
|
||||
// per_item / per_second 取消补收:中途取消通常无视频时长产出(时长计价=0),但已完成节点可能已消耗
|
||||
// token。调用方在 cancel 场景附 feeByModel(不含视频节点,其消耗由时长计价覆盖),与时长计价相加;
|
||||
// 正常结算调用方不附费用拆分 → 仅时长,行为不变。
|
||||
if err == nil && order.SubjectType == pricingConsts.SubjectTypeWorkflow && len(usageStruct.FeeByModel) > 0 {
|
||||
actualAmount = ceilFen(roundCost(actualAmount + sumRecordedFee(usageStruct.FeeByModel)))
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -180,8 +200,9 @@ func (s *pricing) settleOrder(ctx context.Context, order *pricingEntity.ChargeOr
|
||||
var settledAmount float64
|
||||
err = walletService.Account.WithUserLock(ctx, order.UserId, func(ctx context.Context) error {
|
||||
// 锁内权威重读:并发 Settle/Cancel 经同一用户锁串行,锁内状态才是最终判定。
|
||||
// 钱包写入在独立 DB group,无法随 default 事务回滚;故在扣费前先锁内确认状态仍为 CREATED,
|
||||
// 防止「先扣款、后 CAS 失败」的并发重复扣费。
|
||||
// 钱包 account 与计费单同处 wallet DB group,事务须开在 wallet 组(gfdb.DB(ctx) 无参 = default 组,
|
||||
// 而 shop-user-trade 的 default 组指向 shop_user_trade 库、无钱包表,会在 BEGIN 即失败),
|
||||
// 钱包扣款与订单状态迁移才能在同一事务内原子生效。
|
||||
fresh, err := chargeDao.ChargeOrder.GetOrder(ctx, &pricingDto.GetChargeOrderReq{Id: order.Id})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -197,7 +218,7 @@ func (s *pricing) settleOrder(ctx context.Context, order *pricingEntity.ChargeOr
|
||||
if fresh.Status != pricingConsts.ChargeOrderStatusCreated {
|
||||
return errors.New("计费单状态不可结算(已失败或状态异常)")
|
||||
}
|
||||
return gfdb.DB(ctx).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
return gfdb.DB(ctx, public.DbNameWallet).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
// 实收扣款(允许负余额=欠费,靠充值归正,见技术设计 §9)。
|
||||
if err := walletDao.Account.Change(ctx, &walletDto.ChangeAccountReq{
|
||||
UserId: order.UserId,
|
||||
@@ -213,7 +234,7 @@ func (s *pricing) settleOrder(ctx context.Context, order *pricingEntity.ChargeOr
|
||||
ok, err := chargeDao.ChargeOrder.UpdateToSettled(ctx, &pricingDto.SettleChargeOrderReq{
|
||||
Id: order.Id,
|
||||
ActualAmount: actualAmount,
|
||||
Usage: usageJSON,
|
||||
Usage: usage,
|
||||
RuleSnapshot: order.RuleSnapshot,
|
||||
SettleTime: gtime.Now(),
|
||||
})
|
||||
@@ -232,47 +253,27 @@ func (s *pricing) settleOrder(ctx context.Context, order *pricingEntity.ChargeOr
|
||||
if alreadySettled {
|
||||
order.Status = pricingConsts.ChargeOrderStatusSettled
|
||||
order.ActualAmount = settledAmount
|
||||
order.Usage = usageJSON
|
||||
order.Usage = usage
|
||||
return settledAmount, nil
|
||||
}
|
||||
order.Status = pricingConsts.ChargeOrderStatusSettled
|
||||
order.ActualAmount = actualAmount
|
||||
order.Usage = usageJSON
|
||||
order.Usage = usage
|
||||
return actualAmount, nil
|
||||
}
|
||||
|
||||
// calcPerTokenOrder 工作流 per_token 结算:按 usage.TokensByModel 逐模型查该模型 subject 配置计价累加。
|
||||
// 未配价/未启用模型按 0 计(不收费、不报错);费率不冻结,结算按模型实时价(spec §5.4)。
|
||||
func (s *pricing) calcPerTokenOrder(ctx context.Context, usage *ChargeUsage) (float64, error) {
|
||||
// sumRecordedFee 按次已记录费用合计:feeByModel 各值为调用方按次调 shop /calc 的返回费
|
||||
// (calculator.charge 内部已 ceilFen 到分),直接相加再归一化到分(去浮点噪声)。
|
||||
// 负值防御性忽略(异常上报不抵减)。per_item/per_second 取消补收与 per_token 结算的 token 金额据此实收。
|
||||
func sumRecordedFee(feeByModel map[string]float64) float64 {
|
||||
var total float64
|
||||
for modelID, tokens := range usage.TokensByModel {
|
||||
if tokens <= 0 {
|
||||
for _, fee := range feeByModel {
|
||||
if fee < 0 {
|
||||
continue
|
||||
}
|
||||
cfg, err := chargeDao.PricingConfig.Get(ctx, &pricingDto.GetConfigReq{
|
||||
SubjectType: string(pricingConsts.SubjectTypeModel), SubjectID: modelID,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if cfg == nil || cfg.Enabled != 1 {
|
||||
continue // 未配价模型按 0 计
|
||||
}
|
||||
c, err := getCalculator(cfg.SubjectType, cfg.Unit())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rules, err := c.validate(cfg.Rules)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
amount, err := c.charge(rules, &ChargeUsage{PromptTokens: tokens, CompletionTokens: 0, CachedTokens: 0})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total += amount
|
||||
total += fee
|
||||
}
|
||||
return ceilFen(total), nil
|
||||
return ceilFen(roundCost(total))
|
||||
}
|
||||
|
||||
// ====================== 失败(不扣费) ======================
|
||||
@@ -324,185 +325,58 @@ func (s *pricing) Fail(ctx context.Context, req *pricingDto.FailReq) (*pricingDt
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// ====================== 查询 ======================
|
||||
// ====================== 算费(不建单不扣费) ======================
|
||||
|
||||
// GetOrder 查询计费单(按ID或 subject_type + subject_id + biz_order_no)
|
||||
func (s *pricing) GetOrder(ctx context.Context, req *pricingDto.GetOrderReq) (*pricingDto.ChargeOrderInfo, error) {
|
||||
order, err := s.resolveOrder(ctx, req.OrderId, req.SubjectType, req.SubjectID, req.BizOrderNo)
|
||||
// CalcFee 按计价配置与用量计算费用(不建单不扣费)。未配置/未启用 → 报错(model-gateway 据此阻塞调用)。
|
||||
// model subject:chargeMode 可空 = 配置 unit;快照 = 整个 rules。复用 getEnabledConfig + resolveChargeMode + 计算器。
|
||||
func (s *pricing) CalcFee(ctx context.Context, req *pricingDto.CalcFeeReq) (*pricingDto.CalcFeeRes, error) {
|
||||
cfg, err := s.getEnabledConfig(ctx, pricingConsts.SubjectType(req.SubjectType), req.SubjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if order == nil {
|
||||
return nil, errors.New("计费单不存在")
|
||||
chargeMode, ruleSnapshot, err := s.resolveChargeMode(ctx, cfg, &pricingDto.OpenOrderReq{
|
||||
SubjectType: req.SubjectType, SubjectID: req.SubjectID, ChargeMode: req.ChargeMode,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var usage ChargeUsage
|
||||
if err := gconv.Struct(req.Usage, &usage); err != nil {
|
||||
return nil, fmt.Errorf("用量解析失败: %v", err)
|
||||
}
|
||||
c, err := getCalculator(cfg.SubjectType, chargeMode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ruleJSON, err := json.Marshal(ruleSnapshot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("费率快照序列化失败: %v", err)
|
||||
}
|
||||
rules, err := c.validate(string(ruleJSON))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cost, err := c.charge(rules, &usage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pricingDto.CalcFeeRes{Cost: cost}, nil
|
||||
}
|
||||
|
||||
// ====================== 查询 ======================
|
||||
|
||||
// GetOrderInfo 查询计费单(复用 resolveOrder + toOrderInfo;工作流结算前取 CreatedAt 算时长)
|
||||
func (s *pricing) GetOrderInfo(ctx context.Context, req *pricingDto.GetOrderInfoReq) (*pricingDto.ChargeOrderInfo, error) {
|
||||
order, err := s.resolveOrder(ctx, req.Id, req.SubjectType, req.SubjectID, req.BizOrderNo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info := s.toOrderInfo(order)
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// ListOrders 分页查询某用户的计费单
|
||||
func (s *pricing) ListOrders(ctx context.Context, req *pricingDto.ListOrdersReq) (*pricingDto.ChargeOrderList, error) {
|
||||
list, total, err := chargeDao.ChargeOrder.ListByUser(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := &pricingDto.ChargeOrderList{Total: total}
|
||||
for i := range list {
|
||||
resp.Orders = append(resp.Orders, s.toOrderInfo(&list[i]))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ====================== 配置管理 ======================
|
||||
|
||||
// SaveConfig 新增/更新计价配置(费率全 DB 配置;按 subject 类型校验 rules 结构,防止脏配置上线后结算报错)
|
||||
func (s *pricing) SaveConfig(ctx context.Context, req *pricingDto.SaveConfigReq) (*pricingDto.SaveConfigData, error) {
|
||||
subjectType := pricingConsts.SubjectType(req.SubjectType)
|
||||
if err := s.validateConfigRules(subjectType, req.Rules); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Currency == "" {
|
||||
req.Currency = "CNY"
|
||||
}
|
||||
if req.Id == 0 && req.Enabled == 0 {
|
||||
req.Enabled = 1
|
||||
}
|
||||
id, err := chargeDao.PricingConfig.Save(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pricingDto.SaveConfigData{Id: id}, nil
|
||||
}
|
||||
|
||||
// validateConfigRules 按 subject 类型校验 rules JSON(spec §4):
|
||||
// workflow:三模式容器逐个校验已配模式;model:先解析 unit 再用对应计算器;business:per_period 计算器
|
||||
func (s *pricing) validateConfigRules(subjectType pricingConsts.SubjectType, rulesJSON string) error {
|
||||
switch subjectType {
|
||||
case pricingConsts.SubjectTypeWorkflow:
|
||||
return s.validateWorkflowRules(rulesJSON)
|
||||
case pricingConsts.SubjectTypeModel:
|
||||
var cfg struct {
|
||||
Unit pricingConsts.ChargeMode `json:"unit"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(rulesJSON), &cfg); err != nil {
|
||||
return fmt.Errorf("model 费率JSON解析失败: %v", err)
|
||||
}
|
||||
c, err := getCalculator(subjectType, cfg.Unit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.validate(rulesJSON)
|
||||
return err
|
||||
case pricingConsts.SubjectTypeBusiness:
|
||||
c, err := getCalculator(subjectType, pricingConsts.ChargeModePerPeriod)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.validate(rulesJSON)
|
||||
return err
|
||||
default:
|
||||
return errors.New("未知计价对象类型: " + string(subjectType))
|
||||
}
|
||||
}
|
||||
|
||||
// validateWorkflowRules 校验三模式容器:逐个校验已配模式(spec §4.1)
|
||||
func (s *pricing) validateWorkflowRules(rulesJSON string) error {
|
||||
var container map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(rulesJSON), &container); err != nil {
|
||||
return fmt.Errorf("workflow 费率JSON解析失败: %v", err)
|
||||
}
|
||||
if len(container) == 0 {
|
||||
return errors.New("workflow 费率至少配置一种模式")
|
||||
}
|
||||
for mode, raw := range container {
|
||||
chargeMode := pricingConsts.ChargeMode(mode)
|
||||
c, err := getCalculator(pricingConsts.SubjectTypeWorkflow, chargeMode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = c.validate(string(raw)); err != nil {
|
||||
return fmt.Errorf("workflow %s 模式校验失败: %v", mode, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetConfig 查询计价配置
|
||||
func (s *pricing) GetConfig(ctx context.Context, req *pricingDto.GetConfigReq) (*pricingDto.PricingConfigInfo, error) {
|
||||
cfg, err := chargeDao.PricingConfig.Get(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
info := s.toConfigInfo(cfg)
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// ListConfigs 分页查询计价配置
|
||||
func (s *pricing) ListConfigs(ctx context.Context, req *pricingDto.ListConfigsReq) (*pricingDto.PricingConfigList, error) {
|
||||
list, total, err := chargeDao.PricingConfig.List(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := &pricingDto.PricingConfigList{Total: total}
|
||||
for i := range list {
|
||||
resp.Configs = append(resp.Configs, s.toConfigInfo(&list[i]))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ====================== 内部辅助 ======================
|
||||
|
||||
// getEnabledConfig 获取启用中的计价配置
|
||||
func (s *pricing) getEnabledConfig(ctx context.Context, subjectType pricingConsts.SubjectType, subjectID string) (*pricingEntity.PricingConfig, error) {
|
||||
cfg, err := chargeDao.PricingConfig.Get(ctx, &pricingDto.GetConfigReq{SubjectType: string(subjectType), SubjectID: subjectID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
if cfg.Enabled != 1 {
|
||||
return nil, errors.New("计价配置未启用: " + subjectID)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// resolveChargeMode 建单时解析实际计费方式与规则快照(spec §6 OpenOrder 校验):
|
||||
// workflow:chargeMode 必填 ∈ rules 已配模式;快照 = rules[chargeMode]
|
||||
// model:chargeMode 可空 = config.unit;快照 = 整个 modelCfg
|
||||
// business:chargeMode 可空 = per_period;快照 = 周期规则
|
||||
func (s *pricing) resolveChargeMode(ctx context.Context, cfg *pricingEntity.PricingConfig, req *pricingDto.OpenOrderReq) (pricingConsts.ChargeMode, string, error) {
|
||||
switch cfg.SubjectType {
|
||||
case pricingConsts.SubjectTypeWorkflow:
|
||||
if req.ChargeMode == "" {
|
||||
return "", "", errors.New("workflow 建单须指定 chargeMode")
|
||||
}
|
||||
return pricingConsts.ChargeMode(req.ChargeMode), s.workflowRuleSnapshot(cfg.Rules, req.ChargeMode), nil
|
||||
case pricingConsts.SubjectTypeModel:
|
||||
// model 计费方式固定取配置 unit(cfg.Unit() 见 entity,Task 3 定义)
|
||||
return cfg.Unit(), cfg.Rules, nil
|
||||
case pricingConsts.SubjectTypeBusiness:
|
||||
return pricingConsts.ChargeModePerPeriod, cfg.Rules, nil
|
||||
default:
|
||||
return "", "", errors.New("未知计价对象类型: " + string(cfg.SubjectType))
|
||||
}
|
||||
}
|
||||
|
||||
// workflowRuleSnapshot 从三模式容器取选中模式的规则片段(spec §4.1)
|
||||
func (s *pricing) workflowRuleSnapshot(rulesJSON, mode string) string {
|
||||
var container map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(rulesJSON), &container); err != nil {
|
||||
return rulesJSON
|
||||
}
|
||||
raw, ok := container[mode]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// resolveOrder 按ID或 subject_type+subject_id+biz_order_no 定位计费单
|
||||
func (s *pricing) resolveOrder(ctx context.Context, orderId int64, subjectType, subjectID, bizOrderNo string) (*pricingEntity.ChargeOrder, error) {
|
||||
if orderId > 0 {
|
||||
@@ -550,24 +424,3 @@ func (s *pricing) toOrderInfo(e *pricingEntity.ChargeOrder) pricingDto.ChargeOrd
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// toConfigInfo 计价配置实体转响应
|
||||
func (s *pricing) toConfigInfo(e *pricingEntity.PricingConfig) pricingDto.PricingConfigInfo {
|
||||
info := pricingDto.PricingConfigInfo{
|
||||
ID: e.Id,
|
||||
SubjectType: string(e.SubjectType),
|
||||
SubjectID: e.SubjectID,
|
||||
Rules: e.Rules,
|
||||
MinBalance: e.MinBalance,
|
||||
Currency: e.Currency,
|
||||
Enabled: e.Enabled,
|
||||
Version: e.Version,
|
||||
}
|
||||
if e.CreatedAt != nil {
|
||||
info.CreatedAt = e.CreatedAt.String()
|
||||
}
|
||||
if e.UpdatedAt != nil {
|
||||
info.UpdatedAt = e.UpdatedAt.String()
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
|
||||
"gitea.redpowerfuture.com/red-future/common/utils"
|
||||
|
||||
walletConsts "shop-user-trade/consts/wallet"
|
||||
walletDao "shop-user-trade/dao/wallet"
|
||||
walletDto "shop-user-trade/model/dto/wallet"
|
||||
walletEntity "shop-user-trade/model/entity/wallet"
|
||||
)
|
||||
|
||||
// Account 钱包服务:管钱(钱包账务 + 流水)。
|
||||
// 事务与 Redis 锁统一在本层编排:Recharge 持 common 锁 + 单事务原子完成;
|
||||
// pricing 结算复用本层 WithUserLock 编排「钱包账务 + 计费单状态」。
|
||||
// DAO 只提供事务透明的数据原语(Change),不加锁、不开事务、不互相调用。
|
||||
var Account = new(account)
|
||||
|
||||
type account struct{}
|
||||
|
||||
const (
|
||||
walletLockTTL = 30 // 钱包 Redis 锁 TTL(秒);锁串行化跨节点互斥,DB 事务 + 行锁兜底正确性
|
||||
walletLockRetry = 20 // 抢锁最多尝试次数(每次间隔 500ms,≈10s),避免无限阻塞
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrWalletNotFound 钱包不存在(service 层业务错误;DAO 不定义业务错误,见分层规范)
|
||||
ErrWalletNotFound = errors.New("钱包不存在")
|
||||
// ErrWalletDisabled 钱包状态不可用
|
||||
ErrWalletDisabled = errors.New("钱包状态不可用")
|
||||
)
|
||||
|
||||
// GetAccountByUserId 根据用户ID获取钱包信息
|
||||
func (s *account) GetAccountByUserId(ctx context.Context, req *walletDto.GetAccountByUserIdReq) (*walletDto.AccountInfo, error) {
|
||||
w, err := walletDao.Account.Get(ctx, req)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "获取用户钱包失败, userId: %d, error: %v", req.UserId, err)
|
||||
return nil, err
|
||||
}
|
||||
if w == nil {
|
||||
return nil, ErrWalletNotFound
|
||||
}
|
||||
return &walletDto.AccountInfo{
|
||||
ID: w.Id,
|
||||
UserID: w.UserID,
|
||||
Balance: w.Balance,
|
||||
Currency: w.Currency,
|
||||
Status: int(w.Status),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WithUserLock 持用户钱包 Redis 锁执行 fn(跨节点串行化单钱包互斥)。
|
||||
// 锁用 common 统一实现 utils.WithLock(uuid token + SET NX EX + WATCH/MULTI 归属释放 + 自动续期)。
|
||||
// 事务由调用方自行开启(gfdb.DB(ctx).Transaction),账务原语在事务内自动继承,本方法不开事务。
|
||||
func (s *account) WithUserLock(ctx context.Context, userID int64, fn func(ctx context.Context) error) error {
|
||||
_, err := utils.WithLock(ctx, fmt.Sprintf("wallet:lock:%d", userID), walletLockTTL, fn, walletLockRetry)
|
||||
return err
|
||||
}
|
||||
|
||||
// Recharge 充值(钱包不存在则自动创建)。
|
||||
// 事务在 service 层编排:持钱包锁 → 单事务内「建钱包 + income 流水 + 余额增加」原子完成。
|
||||
func (s *account) Recharge(ctx context.Context, req *walletDto.RechargeReq) error {
|
||||
if req.Amount <= 0 {
|
||||
return errors.New("充值金额必须大于 0")
|
||||
}
|
||||
currency := req.Currency
|
||||
if currency == "" {
|
||||
currency = "CNY"
|
||||
}
|
||||
err := s.WithUserLock(ctx, req.UserId, func(ctx context.Context) error {
|
||||
return gfdb.DB(ctx).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
w, err := walletDao.Account.GetForUpdate(ctx, &walletDto.GetAccountByUserIdReq{UserId: req.UserId})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if w == nil {
|
||||
if _, err = walletDao.Account.Create(ctx, &walletDto.CreateAccountReq{UserId: req.UserId, UserName: req.UserName, Status: 1, Currency: currency}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return walletDao.Account.Change(ctx, &walletDto.ChangeAccountReq{
|
||||
UserId: req.UserId,
|
||||
DeltaBalance: req.Amount,
|
||||
Type: walletConsts.WalletLogTypeIncome,
|
||||
Amount: req.Amount,
|
||||
OrderNo: req.OrderNo,
|
||||
Description: req.Description,
|
||||
})
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetWalletLogs 分页获取钱包流水
|
||||
func (s *account) GetWalletLogs(ctx context.Context, req *walletDto.GetAccountLogsReq) (*walletDto.AccountLogData, error) {
|
||||
logs, total, err := walletDao.Account.ListLogs(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var infos []walletDto.AccountLogInfo
|
||||
for i := range logs {
|
||||
infos = append(infos, s.logEntityToInfo(&logs[i]))
|
||||
}
|
||||
return &walletDto.AccountLogData{Logs: infos, Total: total}, nil
|
||||
}
|
||||
|
||||
// logEntityToInfo 流水实体转Info
|
||||
func (s *account) logEntityToInfo(e *walletEntity.AccountLog) walletDto.AccountLogInfo {
|
||||
info := walletDto.AccountLogInfo{}
|
||||
_ = gconv.Struct(e, &info)
|
||||
info.ID = e.Id
|
||||
info.Type = string(e.Type)
|
||||
if e.CreatedAt != nil {
|
||||
info.CreatedAt = e.CreatedAt.String()
|
||||
}
|
||||
return info
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user