feat(pricing): 模型 token/单位计算器 + per_period + 按 subject_type 分表派发
pricing_service.go 三处调用同步改为 subject 签名(Task 5 将整体重构)。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
pricingConsts "shop-user-trade/consts/pricing"
|
||||
)
|
||||
|
||||
// ====================== 用量结构 ======================
|
||||
|
||||
// ChargeUsage 实际用量(结算入参,工作流/调用方上报)。
|
||||
// DurationSec 秒、ItemCount 条数、TokensByModel 各模型token数(per_token 用)。
|
||||
// 模型计价字段见各计算器注释(spec §5.3)。
|
||||
type ChargeUsage struct {
|
||||
DurationSec float64 `json:"durationSec"` // 时长(秒)
|
||||
ItemCount int64 `json:"itemCount"` // 条数(按条计费可选)
|
||||
TokensByModel map[string]int64 `json:"tokensByModel"` // 各模型消耗token
|
||||
// —— 模型计价扩展(spec §5.3)——
|
||||
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"` // 视频/图片 输出分辨率
|
||||
}
|
||||
|
||||
// ====================== 各计费方式费率 JSON 结构(全 DB 配置) ======================
|
||||
|
||||
// per_item 按条:开放档位(上不封顶),超出最大档位按 overflowUnitPrice 线性叠加。
|
||||
// {"tiers":[{"maxSec":15,"price":29},{"maxSec":30,"price":49},{"maxSec":60,"price":89}],"overflowUnitPrice":0.9}
|
||||
type perItemRules struct {
|
||||
Tiers []perItemTier `json:"tiers"`
|
||||
OverflowUnitPrice float64 `json:"overflowUnitPrice"` // 元/秒,超出最大档位后线性计费
|
||||
}
|
||||
|
||||
type perItemTier struct {
|
||||
MaxSec float64 `json:"maxSec"` // 该档位最大时长(秒),open-ended
|
||||
Price float64 `json:"price"` // 该档位价格(元)
|
||||
}
|
||||
|
||||
// per_second 按秒:单位时长单价。
|
||||
// {"unitPrice":0.9}(0.9元/秒)。
|
||||
type perSecondRules struct {
|
||||
UnitPrice float64 `json:"unitPrice"` // 元/秒
|
||||
}
|
||||
|
||||
// ====================== 计算器注册表 ======================
|
||||
|
||||
// calculator 计费方式计算器(新增方式:实现该接口并按 subject_type 注册进对应分表)。
|
||||
// rules 为 rulesJSON 解析后的强类型费率结构;所有金额单位均为元(保留2位小数)。
|
||||
type calculator interface {
|
||||
mode() pricingConsts.ChargeMode
|
||||
// validate 解析并校验 rulesJSON(SaveConfig 与结算时调用)
|
||||
validate(rulesJSON string) (interface{}, error)
|
||||
// charge 结算实际金额(元),不足1分向上取整
|
||||
charge(rules interface{}, usage *ChargeUsage) (float64, error)
|
||||
}
|
||||
|
||||
// ceilFen 金额向上取整到分(1分=0.01元),保证结果保留2位小数(不足1分按1分收)。
|
||||
func ceilFen(v float64) float64 {
|
||||
return math.Ceil(v*100) / 100
|
||||
}
|
||||
|
||||
// roundCost 去浮点噪声:乘法累积误差 ~1e-16,四舍五入到 9 位小数后再 ceilFen,
|
||||
// 避免 2.2×0.9=1.9800000000000002 在分位边界被错误进位(1.98 → 1.99)。
|
||||
func roundCost(v float64) float64 {
|
||||
return math.Round(v*1e9) / 1e9
|
||||
}
|
||||
|
||||
// chargeCalculators 计费方式注册表(按 subject_type 分表;代码不写死金额,只挂计算器)
|
||||
var (
|
||||
// workflow 计算器(spec §4.1 三模式容器,per_token 为空标记)
|
||||
workflowCalculators = map[pricingConsts.ChargeMode]calculator{
|
||||
pricingConsts.ChargeModePerItem: perItemCalculator{},
|
||||
pricingConsts.ChargeModePerSecond: perSecondCalculator{},
|
||||
pricingConsts.ChargeModePerToken: perTokenCalculator{},
|
||||
}
|
||||
// model 计算器(spec §4.2 类型自适应)
|
||||
modelCalculators = map[pricingConsts.ChargeMode]calculator{
|
||||
pricingConsts.ChargeModePer1K: modelTokenCalculator{base: 1000},
|
||||
pricingConsts.ChargeModePer1M: modelTokenCalculator{base: 1e6},
|
||||
pricingConsts.ChargeModePer1: modelUnitCalculator{base: 1, usage: usageImageCount},
|
||||
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},
|
||||
}
|
||||
// business 计算器(spec §4.3 周期订阅)
|
||||
businessCalculators = map[pricingConsts.ChargeMode]calculator{
|
||||
pricingConsts.ChargeModePerPeriod: perPeriodCalculator{},
|
||||
}
|
||||
)
|
||||
|
||||
// getCalculator 获取计算器(按 subject_type 分表)
|
||||
func getCalculator(subjectType pricingConsts.SubjectType, mode pricingConsts.ChargeMode) (calculator, error) {
|
||||
calcs := workflowCalculators
|
||||
switch subjectType {
|
||||
case pricingConsts.SubjectTypeModel:
|
||||
calcs = modelCalculators
|
||||
case pricingConsts.SubjectTypeBusiness:
|
||||
calcs = businessCalculators
|
||||
}
|
||||
c, ok := calcs[mode]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("不支持的计费方式: %s", mode)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// calcCharge 结算实际金额(元)
|
||||
func calcCharge(subjectType pricingConsts.SubjectType, mode pricingConsts.ChargeMode, rulesJSON string, usage *ChargeUsage) (float64, error) {
|
||||
c, err := getCalculator(subjectType, mode)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rules, err := c.validate(rulesJSON)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return c.charge(rules, usage)
|
||||
}
|
||||
|
||||
// ====================== per_item 按条 ======================
|
||||
|
||||
type perItemCalculator struct{}
|
||||
|
||||
func (perItemCalculator) mode() pricingConsts.ChargeMode { return pricingConsts.ChargeModePerItem }
|
||||
|
||||
func (perItemCalculator) validate(rulesJSON string) (interface{}, error) {
|
||||
var rules perItemRules
|
||||
if err := json.Unmarshal([]byte(rulesJSON), &rules); err != nil {
|
||||
return nil, fmt.Errorf("per_item 费率JSON解析失败: %v", err)
|
||||
}
|
||||
if len(rules.Tiers) == 0 {
|
||||
return nil, errors.New("per_item 费率至少需要一个档位 tiers")
|
||||
}
|
||||
return &rules, nil
|
||||
}
|
||||
|
||||
func (perItemCalculator) charge(rules interface{}, usage *ChargeUsage) (float64, error) {
|
||||
r := rules.(*perItemRules)
|
||||
last := r.Tiers[len(r.Tiers)-1]
|
||||
for _, t := range r.Tiers {
|
||||
if usage.DurationSec <= t.MaxSec {
|
||||
return ceilFen(t.Price), nil
|
||||
}
|
||||
}
|
||||
// 超出最大档位:上不封顶,线性叠加(元)
|
||||
overflow := ceilFen((usage.DurationSec - last.MaxSec) * r.OverflowUnitPrice)
|
||||
return ceilFen(last.Price + overflow), nil
|
||||
}
|
||||
|
||||
// ====================== per_second 按秒 ======================
|
||||
|
||||
type perSecondCalculator struct{}
|
||||
|
||||
func (perSecondCalculator) mode() pricingConsts.ChargeMode { return pricingConsts.ChargeModePerSecond }
|
||||
|
||||
func (perSecondCalculator) validate(rulesJSON string) (interface{}, error) {
|
||||
var rules perSecondRules
|
||||
if err := json.Unmarshal([]byte(rulesJSON), &rules); err != nil {
|
||||
return nil, fmt.Errorf("per_second 费率JSON解析失败: %v", err)
|
||||
}
|
||||
if rules.UnitPrice <= 0 {
|
||||
return nil, errors.New("per_second 费率须配置 unitPrice(元/秒)")
|
||||
}
|
||||
return &rules, nil
|
||||
}
|
||||
|
||||
func (perSecondCalculator) charge(rules interface{}, usage *ChargeUsage) (float64, error) {
|
||||
r := rules.(*perSecondRules)
|
||||
// 按秒向上取整(不满1秒按1秒计),再乘单价(元/秒)
|
||||
return ceilFen(math.Ceil(usage.DurationSec) * r.UnitPrice), nil
|
||||
}
|
||||
|
||||
// ====================== per_token 按token ======================
|
||||
|
||||
type perTokenCalculator struct{}
|
||||
|
||||
func (perTokenCalculator) mode() pricingConsts.ChargeMode { return pricingConsts.ChargeModePerToken }
|
||||
|
||||
func (perTokenCalculator) validate(rulesJSON string) (interface{}, error) {
|
||||
// per_token 为 {} 仅启用标记;价格取自各模型 subject 实时配置(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)
|
||||
}
|
||||
return &container, nil
|
||||
}
|
||||
|
||||
func (perTokenCalculator) charge(rules interface{}, usage *ChargeUsage) (float64, error) {
|
||||
// 不在此计价:per_token 按模型 subject 实时价,由 service 层 calcPerTokenOrder 处理
|
||||
return 0, errors.New("per_token 计价在 service 层按模型 subject 实时价计算")
|
||||
}
|
||||
|
||||
// ====================== 模型计费 JSON 结构(model subject rules,spec §4.2) ======================
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
type modelRule struct {
|
||||
Name string `json:"name"`
|
||||
Match *modelMatch `json:"match,omitempty"` // 空=任意调用
|
||||
Price *modelPrice `json:"price"`
|
||||
}
|
||||
|
||||
// modelMatch 命中条件(全可选,命中=全部满足)
|
||||
type modelMatch struct {
|
||||
MediaType string `json:"mediaType,omitempty"` // text/audio/video/image
|
||||
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上界
|
||||
}
|
||||
|
||||
// modelPrice 计价项:token 单位用 input/output/cacheHit;非 token 单位用 unitPrice
|
||||
type modelPrice struct {
|
||||
Input float64 `json:"input,omitempty"`
|
||||
Output float64 `json:"output,omitempty"`
|
||||
CacheHit float64 `json:"cacheHit,omitempty"`
|
||||
UnitPrice float64 `json:"unitPrice,omitempty"`
|
||||
}
|
||||
|
||||
// perPeriodRules 周期订阅规则(business subject,spec §4.3)
|
||||
type perPeriodRules struct {
|
||||
Period string `json:"period"` // year(扩月/季只加 PeriodSet)
|
||||
Price float64 `json:"price"` // 每周期价格(元)
|
||||
}
|
||||
|
||||
// modelRuleMatch 单条 match 判定(nil match=无条件命中)
|
||||
func modelRuleMatch(m *modelMatch, u *ChargeUsage) bool {
|
||||
if m == nil {
|
||||
return true
|
||||
}
|
||||
if m.MediaType != "" && m.MediaType != u.MediaType {
|
||||
return false
|
||||
}
|
||||
if m.Thinking != nil && (u.Thinking == nil || *m.Thinking != *u.Thinking) {
|
||||
return false
|
||||
}
|
||||
if m.OutputAudio != nil && (u.OutputAudio == nil || *m.OutputAudio != *u.OutputAudio) {
|
||||
return false
|
||||
}
|
||||
if m.OutputResolution != "" && m.OutputResolution != u.OutputResolution {
|
||||
return false
|
||||
}
|
||||
if m.InputLengthMin > 0 && u.PromptTokens < m.InputLengthMin {
|
||||
return false
|
||||
}
|
||||
if m.InputLengthMax > 0 && u.PromptTokens > m.InputLengthMax {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// matchModelRule 按序返回首条命中规则(先写优先级高)
|
||||
func matchModelRule(r *modelRules, u *ChargeUsage) *modelRule {
|
||||
for i := range r.Rules {
|
||||
if modelRuleMatch(r.Rules[i].Match, u) {
|
||||
return &r.Rules[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ====================== model token 计算器(per_1K / per_1M) ======================
|
||||
|
||||
// modelTokenCalculator 模型 token 计价:首条命中规则后按基准换算。
|
||||
// cost = (prompt-cached)/base×input + cached/base×cacheHit + completion/base×output
|
||||
type modelTokenCalculator struct{ base float64 }
|
||||
|
||||
func (modelTokenCalculator) mode() pricingConsts.ChargeMode { return pricingConsts.ChargeModePer1M }
|
||||
|
||||
func (c modelTokenCalculator) validate(rulesJSON string) (interface{}, error) {
|
||||
var r modelRules
|
||||
if err := json.Unmarshal([]byte(rulesJSON), &r); err != nil {
|
||||
return nil, fmt.Errorf("model token 费率JSON解析失败: %v", err)
|
||||
}
|
||||
if r.Unit != pricingConsts.ChargeModePer1K && r.Unit != pricingConsts.ChargeModePer1M {
|
||||
return nil, errors.New("model token 费率 unit 须为 per_1K/per_1M")
|
||||
}
|
||||
if len(r.Rules) == 0 {
|
||||
return nil, errors.New("model 费率至少一条 rules")
|
||||
}
|
||||
if r.Tiered {
|
||||
if err := validateTieredBands(r.Rules); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// validateTieredBands 阶梯分档校验:档位区间相邻不重叠(下档 max+1 = 上档 min)
|
||||
func validateTieredBands(rules []modelRule) error {
|
||||
for i := 0; i < len(rules); i++ {
|
||||
for j := i + 1; j < len(rules); j++ {
|
||||
a, b := rules[i].Match, rules[j].Match
|
||||
if a == nil || b == nil || a.InputLengthMax == 0 || b.InputLengthMin == 0 {
|
||||
continue // 无档位约束的规则不参与重叠校验
|
||||
}
|
||||
if b.InputLengthMin <= a.InputLengthMax {
|
||||
return errors.New("阶梯档位区间重叠:规则间 InputLengthMin 须 > 前一档 InputLengthMax")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c modelTokenCalculator) charge(rules interface{}, usage *ChargeUsage) (float64, error) {
|
||||
r := rules.(*modelRules)
|
||||
rule := matchModelRule(r, usage)
|
||||
if rule == nil {
|
||||
return 0, errors.New("无匹配计费规则")
|
||||
}
|
||||
p := rule.Price
|
||||
cost := float64(usage.PromptTokens-usage.CachedTokens)/c.base*p.Input +
|
||||
float64(usage.CachedTokens)/c.base*p.CacheHit +
|
||||
float64(usage.CompletionTokens)/c.base*p.Output
|
||||
return ceilFen(roundCost(cost)), nil
|
||||
}
|
||||
|
||||
// ====================== model 单位计算器(per_1 / per_second / per_minute / per_hour / per_char) ======================
|
||||
|
||||
// modelUnitCalculator 模型非 token 计价:用量/base×unitPrice。
|
||||
// usage 为用量提取函数(张数/秒/秒/秒/字数),base 为基准(1/1/60/3600/1)。
|
||||
type modelUnitCalculator struct {
|
||||
base float64
|
||||
usage func(*ChargeUsage) float64
|
||||
}
|
||||
|
||||
func (modelUnitCalculator) mode() pricingConsts.ChargeMode { return pricingConsts.ChargeModePerSecond }
|
||||
|
||||
func (c modelUnitCalculator) validate(rulesJSON string) (interface{}, error) {
|
||||
var r modelRules
|
||||
if err := json.Unmarshal([]byte(rulesJSON), &r); err != nil {
|
||||
return nil, fmt.Errorf("model 单位费率JSON解析失败: %v", err)
|
||||
}
|
||||
if !pricingConsts.ModelUnitSet[r.Unit] {
|
||||
return nil, errors.New("model 单位费率 unit 不在允许集合内")
|
||||
}
|
||||
if len(r.Rules) == 0 {
|
||||
return nil, errors.New("model 费率至少一条 rules")
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (c modelUnitCalculator) charge(rules interface{}, usage *ChargeUsage) (float64, error) {
|
||||
r := rules.(*modelRules)
|
||||
rule := matchModelRule(r, usage)
|
||||
if rule == nil {
|
||||
return 0, errors.New("无匹配计费规则")
|
||||
}
|
||||
return ceilFen(roundCost(c.usage(usage) / c.base * rule.Price.UnitPrice)), nil
|
||||
}
|
||||
|
||||
// 用量提取函数
|
||||
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) }
|
||||
|
||||
// ====================== per_period 周期订阅(business) ======================
|
||||
|
||||
type perPeriodCalculator struct{}
|
||||
|
||||
func (perPeriodCalculator) mode() pricingConsts.ChargeMode { return pricingConsts.ChargeModePerPeriod }
|
||||
|
||||
func (perPeriodCalculator) validate(rulesJSON string) (interface{}, error) {
|
||||
var r perPeriodRules
|
||||
if err := json.Unmarshal([]byte(rulesJSON), &r); err != nil {
|
||||
return nil, fmt.Errorf("per_period 费率JSON解析失败: %v", err)
|
||||
}
|
||||
if !pricingConsts.PeriodSet[r.Period] {
|
||||
return nil, fmt.Errorf("不支持的周期: %s", r.Period)
|
||||
}
|
||||
if r.Price <= 0 {
|
||||
return nil, errors.New("per_period 费率须配置 price>0")
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (perPeriodCalculator) charge(rules interface{}, usage *ChargeUsage) (float64, error) {
|
||||
r := rules.(*perPeriodRules)
|
||||
return ceilFen(r.Price), nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 阶梯命中:思考≤32000 → input 0.6/output 3.6;>32000 → 0.9/5.4
|
||||
const modelTokenTieredJSON = `{
|
||||
"unit":"per_1M","tiered":true,
|
||||
"rules":[
|
||||
{"name":"思考≤32000","match":{"thinking":true,"inputLengthMax":32000,"mediaType":"text"},"price":{"input":0.6,"output":3.6,"cacheHit":0.12}},
|
||||
{"name":"思考>32000","match":{"thinking":true,"inputLengthMin":32001,"mediaType":"text"},"price":{"input":0.9,"output":5.4,"cacheHit":0.18}}
|
||||
]}`
|
||||
|
||||
func ptr(b bool) *bool { return &b }
|
||||
|
||||
func TestModelTokenCalculator_TieredMatch(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1e6}
|
||||
rules, err := c.validate(modelTokenTieredJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
}
|
||||
// 第一档:prompt 20000 → (0.6*20000 + 3.6*1000)/1e6 = 0.0156 → ceil → 0.02
|
||||
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 20000, CompletionTokens: 1000, MediaType: "text", Thinking: ptr(true)})
|
||||
if err != nil {
|
||||
t.Fatalf("charge err: %v", err)
|
||||
}
|
||||
if got != 0.02 {
|
||||
t.Fatalf("tier1 got %v want 0.02", got)
|
||||
}
|
||||
// 第二档:prompt 50000 → (0.9*50000 + 5.4*1000)/1e6 = 0.0504 → ceil → 0.06
|
||||
got, err = c.charge(rules, &ChargeUsage{PromptTokens: 50000, CompletionTokens: 1000, MediaType: "text", Thinking: ptr(true)})
|
||||
if err != nil {
|
||||
t.Fatalf("charge err: %v", err)
|
||||
}
|
||||
if got != 0.06 {
|
||||
t.Fatalf("tier2 got %v want 0.06", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelTokenCalculator_NoMatch(t *testing.T) {
|
||||
c := modelTokenCalculator{base: 1e6}
|
||||
rules, err := c.validate(modelTokenTieredJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
}
|
||||
// thinking=false 不在任一档 → 无匹配报错
|
||||
_, err = c.charge(rules, &ChargeUsage{PromptTokens: 1000, MediaType: "text", Thinking: ptr(false)})
|
||||
if err == nil {
|
||||
t.Fatal("expected no-match error")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// (2000-1000)/1000*1 + 1000/1000*0.1 + 500/1000*2 = 1 + 0.1 + 1 = 2.1
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelUnitCalculator(t *testing.T) {
|
||||
// 音频按时长(分钟):90 秒 × 2 元/分钟 = 3
|
||||
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)
|
||||
}
|
||||
// 音频按字数:10000 字 × 0.0005 = 5(独立 per_char 规则)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerPeriodCalculator(t *testing.T) {
|
||||
c := perPeriodCalculator{}
|
||||
rules, err := c.validate(`{"period":"year","price":1999}`)
|
||||
if err != nil {
|
||||
t.Fatalf("validate err: %v", err)
|
||||
}
|
||||
got, err := c.charge(rules, &ChargeUsage{})
|
||||
if err != nil || got != 1999 {
|
||||
t.Fatalf("per_period got %v want 1999, err %v", got, err)
|
||||
}
|
||||
// 非法周期报错
|
||||
if _, err = c.validate(`{"period":"month","price":100}`); err == nil {
|
||||
t.Fatal("expected invalid period error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalcChargeBySubjectType(t *testing.T) {
|
||||
// workflow per_second 走整秒向上取整计算器:ceil(2.2)=3 × 0.9 = 2.7
|
||||
got, err := calcCharge("workflow", "per_second", `{"unitPrice":0.9}`, &ChargeUsage{DurationSec: 2.2})
|
||||
if err != nil || got != 2.7 {
|
||||
t.Fatalf("workflow per_second got %v want 2.7, err %v", got, err)
|
||||
}
|
||||
// model per_second 走精确秒单位计算器:2.2 × 0.9 = 1.98
|
||||
got, err = calcCharge("model", "per_second", `{"unit":"per_second","tiered":false,"rules":[{"name":"统一","price":{"unitPrice":0.9}}]}`, &ChargeUsage{DurationSec: 2.2})
|
||||
if err != nil || got != 1.98 {
|
||||
t.Fatalf("model per_second got %v want 1.98, err %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
|
||||
|
||||
pricingConsts "shop-user-trade/consts/pricing"
|
||||
walletConsts "shop-user-trade/consts/wallet"
|
||||
chargeDao "shop-user-trade/dao/pricing"
|
||||
walletDao "shop-user-trade/dao/wallet"
|
||||
pricingDto "shop-user-trade/model/dto/pricing"
|
||||
walletDto "shop-user-trade/model/dto/wallet"
|
||||
pricingEntity "shop-user-trade/model/entity/pricing"
|
||||
walletService "shop-user-trade/service/wallet"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrOrderAlreadyHandled 计费单已被处理(并发幂等:让事务回滚,避免账务重复)
|
||||
ErrOrderAlreadyHandled = errors.New("计费单已被处理")
|
||||
)
|
||||
|
||||
// pricing 计价服务:算钱(定价+计费单生命周期+门禁)。
|
||||
// 职责:建单(不动钱)→ 结算/取消(实收)→ 失败(不扣费)。
|
||||
// 锁经 wallet service(common 统一锁),账务经钱包 dao 的 Change 原语;
|
||||
// 钱包与计费单状态在同一事务内原子更新,不直接操作钱包表。
|
||||
var Pricing = new(pricing)
|
||||
|
||||
type pricing struct{}
|
||||
|
||||
// ====================== 建单(不动钱) ======================
|
||||
|
||||
// OpenOrder 建单:校验配置 + 门禁(余额 >= min_balance,非冻结),建 CREATED 计费单,不动钱。
|
||||
// 幂等键 biz_key + biz_order_no(工作流 execId),重复调用返回既有单。
|
||||
func (s *pricing) OpenOrder(ctx context.Context, req *pricingDto.OpenOrderReq) (*pricingDto.ChargeOrderInfo, error) {
|
||||
cfg, err := s.getEnabledConfig(ctx, req.BizKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 幂等快路径:同 biz_key + biz_order_no 已建单直接返回
|
||||
if exist, err := chargeDao.ChargeOrder.GetOrder(ctx, &pricingDto.GetChargeOrderReq{BizKey: req.BizKey, BizOrderNo: req.BizOrderNo}); err != nil {
|
||||
return nil, err
|
||||
} else if exist != nil {
|
||||
info := s.toOrderInfo(exist)
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// 建单前校验费率 JSON(脏配置在建单即暴露,不拖到结算)
|
||||
c, err := getCalculator(pricingConsts.SubjectTypeWorkflow, cfg.ChargeMode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = c.validate(cfg.Rules); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 门禁(非冻结):调用前可用余额须 >= min_balance。
|
||||
// 不动钱,普通读即可,无需锁钱包;真正的扣费发生在结算时(允许负余额兜底)。
|
||||
w, err := walletDao.Account.Get(ctx, &walletDto.GetAccountByUserIdReq{UserId: req.UserId})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if w == nil {
|
||||
return nil, walletService.ErrWalletNotFound
|
||||
}
|
||||
if w.Status != walletConsts.WalletStatusEnabled {
|
||||
return nil, walletService.ErrWalletDisabled
|
||||
}
|
||||
if cfg.MinBalance > 0 && w.Balance < cfg.MinBalance {
|
||||
return nil, fmt.Errorf("余额不足:可用余额须不低于 %.2f 元才能发起", cfg.MinBalance)
|
||||
}
|
||||
|
||||
id, err := chargeDao.ChargeOrder.Insert(ctx, &pricingDto.CreateChargeOrderReq{
|
||||
BizKey: req.BizKey,
|
||||
BizOrderNo: req.BizOrderNo,
|
||||
UserId: req.UserId,
|
||||
ChargeMode: cfg.ChargeMode,
|
||||
Status: pricingConsts.ChargeOrderStatusCreated,
|
||||
RuleSnapshot: cfg.Rules, // 费率快照,结算据此计价,防改价影响在途单
|
||||
})
|
||||
if err != nil {
|
||||
// 唯一键冲突:并发已建单,幂等返回既有单
|
||||
if exist, e2 := chargeDao.ChargeOrder.GetOrder(ctx, &pricingDto.GetChargeOrderReq{BizKey: req.BizKey, BizOrderNo: req.BizOrderNo}); e2 == nil && exist != nil {
|
||||
info := s.toOrderInfo(exist)
|
||||
return &info, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
entity := &pricingEntity.ChargeOrder{
|
||||
BizKey: req.BizKey,
|
||||
BizOrderNo: req.BizOrderNo,
|
||||
UserId: req.UserId,
|
||||
ChargeMode: cfg.ChargeMode,
|
||||
Status: pricingConsts.ChargeOrderStatusCreated,
|
||||
RuleSnapshot: cfg.Rules,
|
||||
}
|
||||
entity.Id = id
|
||||
info := s.toOrderInfo(entity)
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// ====================== 结算(成功:按最终产出;取消:按已消耗) ======================
|
||||
|
||||
// Settle 结算:按实际用量计价实收(余额不足允许为负=欠费),幂等。
|
||||
func (s *pricing) Settle(ctx context.Context, req *pricingDto.SettleReq) (*pricingDto.ChargeOrderInfo, error) {
|
||||
order, err := s.resolveOrder(ctx, req.OrderId, req.BizKey, req.BizOrderNo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = s.settleOrder(ctx, order, req.Usage, "结算:"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info := s.toOrderInfo(order)
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// Cancel 取消:中途取消按已消耗用量计价实收(非退款),幂等。
|
||||
func (s *pricing) Cancel(ctx context.Context, req *pricingDto.CancelReq) (*pricingDto.ChargeOrderInfo, error) {
|
||||
order, err := s.resolveOrder(ctx, req.OrderId, req.BizKey, req.BizOrderNo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = s.settleOrder(ctx, order, req.Usage, "取消结算(已消耗):"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info := s.toOrderInfo(order)
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// settleOrder 核心结算:按 rule_snapshot 计价实收(允许负余额),状态 CREATED → SETTLED。
|
||||
// 钱包扣款与计费单状态在同一事务内原子;条件状态迁移保证幂等(重复结算回滚账务)。
|
||||
func (s *pricing) settleOrder(ctx context.Context, order *pricingEntity.ChargeOrder, usageJSON, desc string) (float64, error) {
|
||||
// 幂等:已结算直接返回既有结果
|
||||
if order.Status == pricingConsts.ChargeOrderStatusSettled {
|
||||
return order.ActualAmount, nil
|
||||
}
|
||||
if order.Status != pricingConsts.ChargeOrderStatusCreated {
|
||||
return 0, errors.New("计费单状态不可结算(已失败或状态异常)")
|
||||
}
|
||||
|
||||
var usage ChargeUsage
|
||||
if err := json.Unmarshal([]byte(usageJSON), &usage); err != nil {
|
||||
return 0, fmt.Errorf("用量JSON解析失败: %v", err)
|
||||
}
|
||||
// 用建单时费率快照计价(防改价影响在途单)
|
||||
actualAmount, err := calcCharge(pricingConsts.SubjectTypeWorkflow, order.ChargeMode, order.RuleSnapshot, &usage)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
err = walletService.Account.WithUserLock(ctx, order.UserId, func(ctx context.Context) error {
|
||||
return gfdb.DB(ctx).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
// 实收扣款(允许负余额=欠费,靠充值归正,见技术设计 §9)。
|
||||
// 用事务透明的 Change:锁(WithUserLock)与事务(Transaction)已由本 service 层编排。
|
||||
if err := walletDao.Account.Change(ctx, &walletDto.ChangeAccountReq{
|
||||
UserId: order.UserId,
|
||||
DeltaBalance: -actualAmount,
|
||||
Type: walletConsts.WalletLogTypeExpense,
|
||||
Amount: actualAmount,
|
||||
OrderNo: fmt.Sprintf("charge:%d", order.Id),
|
||||
Description: desc + order.BizKey,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
// 条件状态迁移:仅 CREATED → SETTLED;rows=0 说明已被并发处理,回滚账务
|
||||
ok, err := chargeDao.ChargeOrder.UpdateToSettled(ctx, &pricingDto.SettleChargeOrderReq{
|
||||
Id: order.Id,
|
||||
ActualAmount: actualAmount,
|
||||
Usage: usageJSON,
|
||||
RuleSnapshot: order.RuleSnapshot,
|
||||
SettleTime: gtime.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrOrderAlreadyHandled
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
order.Status = pricingConsts.ChargeOrderStatusSettled
|
||||
order.ActualAmount = actualAmount
|
||||
order.Usage = usageJSON
|
||||
return actualAmount, nil
|
||||
}
|
||||
|
||||
// ====================== 失败(不扣费) ======================
|
||||
|
||||
// Fail 失败处理:执行失败/无产出,不扣费、不动钱,仅状态迁移 CREATED → FAILED。幂等。
|
||||
func (s *pricing) Fail(ctx context.Context, req *pricingDto.FailReq) (*pricingDto.ChargeOrderInfo, error) {
|
||||
order, err := s.resolveOrder(ctx, req.OrderId, req.BizKey, req.BizOrderNo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 幂等:已失败直接返回
|
||||
if order.Status == pricingConsts.ChargeOrderStatusFailed {
|
||||
info := s.toOrderInfo(order)
|
||||
return &info, nil
|
||||
}
|
||||
if order.Status != pricingConsts.ChargeOrderStatusCreated {
|
||||
return nil, errors.New("计费单状态不可转失败(已结算或状态异常)")
|
||||
}
|
||||
|
||||
ok, err := chargeDao.ChargeOrder.UpdateToFailed(ctx, &pricingDto.FailChargeOrderReq{Id: order.Id})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, ErrOrderAlreadyHandled
|
||||
}
|
||||
order.Status = pricingConsts.ChargeOrderStatusFailed
|
||||
info := s.toOrderInfo(order)
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// ====================== 查询 ======================
|
||||
|
||||
// GetOrder 查询计费单(按ID或 biz_key + biz_order_no)
|
||||
func (s *pricing) GetOrder(ctx context.Context, req *pricingDto.GetOrderReq) (*pricingDto.ChargeOrderInfo, error) {
|
||||
order, err := s.resolveOrder(ctx, req.OrderId, req.BizKey, req.BizOrderNo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if order == nil {
|
||||
return nil, errors.New("计费单不存在")
|
||||
}
|
||||
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 配置;同 biz_key 唯一)
|
||||
func (s *pricing) SaveConfig(ctx context.Context, req *pricingDto.SaveConfigReq) (*pricingDto.SaveConfigData, error) {
|
||||
c, err := getCalculator(pricingConsts.SubjectTypeWorkflow, pricingConsts.ChargeMode(req.ChargeMode))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 保存前校验费率 JSON 结构(防止脏配置上线后结算报错)
|
||||
if _, err = c.validate(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
|
||||
}
|
||||
|
||||
// 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, errors.New("计价配置不存在: " + req.BizKey)
|
||||
}
|
||||
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, bizKey string) (*pricingEntity.PricingConfig, error) {
|
||||
cfg, err := chargeDao.PricingConfig.Get(ctx, &pricingDto.GetConfigReq{BizKey: bizKey})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil, errors.New("计价配置不存在: " + bizKey)
|
||||
}
|
||||
if cfg.Enabled != 1 {
|
||||
return nil, errors.New("计价配置未启用: " + bizKey)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// resolveOrder 按ID或 biz_key + biz_order_no 定位计费单
|
||||
func (s *pricing) resolveOrder(ctx context.Context, orderId int64, bizKey, bizOrderNo string) (*pricingEntity.ChargeOrder, error) {
|
||||
if orderId > 0 {
|
||||
order, err := chargeDao.ChargeOrder.GetOrder(ctx, &pricingDto.GetChargeOrderReq{Id: orderId})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if order == nil {
|
||||
return nil, errors.New("计费单不存在")
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
if bizKey == "" || bizOrderNo == "" {
|
||||
return nil, errors.New("orderId 与 bizKey/bizOrderNo 须二选一")
|
||||
}
|
||||
order, err := chargeDao.ChargeOrder.GetOrder(ctx, &pricingDto.GetChargeOrderReq{BizKey: bizKey, BizOrderNo: bizOrderNo})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if order == nil {
|
||||
return nil, errors.New("计费单不存在")
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
// toOrderInfo 计费单实体转响应
|
||||
func (s *pricing) toOrderInfo(e *pricingEntity.ChargeOrder) pricingDto.ChargeOrderInfo {
|
||||
info := pricingDto.ChargeOrderInfo{
|
||||
ID: e.Id,
|
||||
BizKey: e.BizKey,
|
||||
BizOrderNo: e.BizOrderNo,
|
||||
UserId: e.UserId,
|
||||
ChargeMode: string(e.ChargeMode),
|
||||
Status: int(e.Status),
|
||||
ActualAmount: e.ActualAmount,
|
||||
Usage: e.Usage,
|
||||
RuleSnapshot: e.RuleSnapshot,
|
||||
}
|
||||
if e.CreatedAt != nil {
|
||||
info.CreatedAt = e.CreatedAt.String()
|
||||
}
|
||||
if e.SettleTime != nil {
|
||||
info.SettleTime = e.SettleTime.String()
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// toConfigInfo 计价配置实体转响应
|
||||
func (s *pricing) toConfigInfo(e *pricingEntity.PricingConfig) pricingDto.PricingConfigInfo {
|
||||
info := pricingDto.PricingConfigInfo{
|
||||
ID: e.Id,
|
||||
BizKey: e.BizKey,
|
||||
Name: e.Name,
|
||||
ChargeMode: string(e.ChargeMode),
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user