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:
2026-08-28 17:33:59 +08:00
parent 0f8b9711ed
commit 3e0b7bafc3
3 changed files with 918 additions and 0 deletions
+396
View File
@@ -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 解析并校验 rulesJSONSaveConfig 与结算时调用)
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 rulesspec §4.2 ======================
// modelRules 模型计费规则:unit + tiered + rulesrules 按序首条命中)
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 subjectspec §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
}
+132
View File
@@ -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)
}
}
+390
View File
@@ -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 servicecommon 统一锁),账务经钱包 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 → SETTLEDrows=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
}