From 3e0b7bafc3eddc825541c2a98cab168693d9a2a1 Mon Sep 17 00:00:00 2001 From: qhd <1766646056@qq.com> Date: Fri, 28 Aug 2026 17:33:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(pricing):=20=E6=A8=A1=E5=9E=8B=20token/?= =?UTF-8?q?=E5=8D=95=E4=BD=8D=E8=AE=A1=E7=AE=97=E5=99=A8=20+=20per=5Fperio?= =?UTF-8?q?d=20+=20=E6=8C=89=20subject=5Ftype=20=E5=88=86=E8=A1=A8?= =?UTF-8?q?=E6=B4=BE=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pricing_service.go 三处调用同步改为 subject 签名(Task 5 将整体重构)。 Co-Authored-By: Claude --- service/pricing/charge_calc.go | 396 ++++++++++++++++++++++++++++ service/pricing/charge_calc_test.go | 132 ++++++++++ service/pricing/pricing_service.go | 390 +++++++++++++++++++++++++++ 3 files changed, 918 insertions(+) create mode 100644 service/pricing/charge_calc.go create mode 100644 service/pricing/charge_calc_test.go create mode 100644 service/pricing/pricing_service.go diff --git a/service/pricing/charge_calc.go b/service/pricing/charge_calc.go new file mode 100644 index 0000000..cceeebb --- /dev/null +++ b/service/pricing/charge_calc.go @@ -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 +} diff --git a/service/pricing/charge_calc_test.go b/service/pricing/charge_calc_test.go new file mode 100644 index 0000000..6d104b8 --- /dev/null +++ b/service/pricing/charge_calc_test.go @@ -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) + } +} diff --git a/service/pricing/pricing_service.go b/service/pricing/pricing_service.go new file mode 100644 index 0000000..228d80a --- /dev/null +++ b/service/pricing/pricing_service.go @@ -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 +}