feat(billing): 统一计价与候选冻结估算

新增 effective-pricing-v2、9 位十进制定点计算、显式免费校验和结构化缺价错误。估价与生产预处理覆盖全部可用候选,并按最大候选费用冻结;规则优先级为平台模型、平台、基准模型。\n\n同步计价契约和 OpenAPI,补充 Token 参数别名、视频五秒向上取整及缺价回归测试。\n\n验证:go test ./...、pnpm openapi、Web 测试与构建通过。
This commit is contained in:
2026-07-20 23:22:45 +08:00
parent 01a013c809
commit 5114686c35
14 changed files with 1192 additions and 28 deletions
+198 -7
View File
@@ -3,8 +3,10 @@ package store
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
@@ -15,9 +17,10 @@ status, metadata, created_at, updated_at`
const pricingRuleColumns = `
id::text, COALESCE(rule_set_id::text, ''), rule_key, display_name, scope_type,
COALESCE(scope_id::text, ''), resource_type, unit, base_price::float8, currency,
COALESCE(scope_id::text, ''), resource_type, unit, base_price::float8, is_free, currency,
base_weight, dynamic_weight, calculator_type, dimension_schema, formula_config,
priority, status, metadata, created_at, updated_at`
priority, status, metadata, COALESCE(effective_from::text, ''), COALESCE(effective_to::text, ''),
created_at, updated_at`
type PricingRuleInput struct {
RuleKey string `json:"ruleKey"`
@@ -25,6 +28,7 @@ type PricingRuleInput struct {
ResourceType string `json:"resourceType"`
Unit string `json:"unit"`
BasePrice float64 `json:"basePrice"`
IsFree bool `json:"isFree"`
Currency string `json:"currency"`
BaseWeight map[string]any `json:"baseWeight"`
DynamicWeight map[string]any `json:"dynamicWeight"`
@@ -34,6 +38,17 @@ type PricingRuleInput struct {
Priority int `json:"priority"`
Status string `json:"status"`
Metadata map[string]any `json:"metadata"`
EffectiveFrom string `json:"effectiveFrom"`
EffectiveTo string `json:"effectiveTo"`
}
type EffectivePricingConfig struct {
RuleSetID string
RuleSetKey string
Currency string
Config map[string]any
FreeResource map[string]bool
Snapshot map[string]any
}
type PricingRuleSetInput struct {
@@ -247,6 +262,173 @@ ORDER BY priority ASC, resource_type ASC`, id)
return config, nil
}
func (s *Store) PricingRuleSetBillingConfigV2(ctx context.Context, id string) (EffectivePricingConfig, error) {
id = strings.TrimSpace(id)
if id == "" {
return EffectivePricingConfig{}, fmt.Errorf("pricing rule set id is required")
}
var ruleSetKey string
var currency string
var status string
if err := s.pool.QueryRow(ctx, `
SELECT rule_set_key, currency, status
FROM model_pricing_rule_sets
WHERE id = $1::uuid`, id).Scan(&ruleSetKey, &currency, &status); err != nil {
return EffectivePricingConfig{}, err
}
if status != "active" {
return EffectivePricingConfig{}, fmt.Errorf("pricing rule set %s is not active", id)
}
if currency != "resource" {
return EffectivePricingConfig{}, fmt.Errorf("pricing rule set %s uses unsupported currency %s", id, currency)
}
rows, err := s.pool.Query(ctx, `
SELECT rule_key, resource_type, unit, base_price::text, currency, base_weight,
dynamic_weight, calculator_type, dimension_schema, formula_config,
priority, is_free, COALESCE(effective_from::text, ''), COALESCE(effective_to::text, '')
FROM model_pricing_rules
WHERE rule_set_id = $1::uuid
AND status = 'active'
AND (effective_from IS NULL OR effective_from <= now())
AND (effective_to IS NULL OR effective_to > now())
ORDER BY priority ASC, resource_type ASC, rule_key ASC`, id)
if err != nil {
return EffectivePricingConfig{}, err
}
defer rows.Close()
config := map[string]any{}
freeResource := map[string]bool{}
rules := make([]any, 0)
seen := map[string]bool{}
for rows.Next() {
var ruleKey string
var resourceType string
var unit string
var basePrice string
var ruleCurrency string
var baseWeightBytes []byte
var dynamicWeightBytes []byte
var calculatorType string
var dimensionSchemaBytes []byte
var formulaConfigBytes []byte
var priority int
var isFree bool
var effectiveFrom string
var effectiveTo string
if err := rows.Scan(
&ruleKey, &resourceType, &unit, &basePrice, &ruleCurrency, &baseWeightBytes,
&dynamicWeightBytes, &calculatorType, &dimensionSchemaBytes, &formulaConfigBytes,
&priority, &isFree, &effectiveFrom, &effectiveTo,
); err != nil {
return EffectivePricingConfig{}, err
}
if seen[resourceType] {
continue
}
if ruleCurrency != currency {
return EffectivePricingConfig{}, fmt.Errorf("pricing rule %s currency does not match its rule set", ruleKey)
}
switch calculatorType {
case "token_usage", "unit_weight", "duration_weight":
default:
return EffectivePricingConfig{}, fmt.Errorf("pricing rule %s uses unsupported calculator %s", ruleKey, calculatorType)
}
if strings.HasPrefix(strings.TrimSpace(basePrice), "-") {
return EffectivePricingConfig{}, fmt.Errorf("pricing rule %s has a negative base price", ruleKey)
}
dynamicWeight := decodeObject(dynamicWeightBytes)
formulaConfig := decodeObject(formulaConfigBytes)
addPricingRuleToConfig(config, resourceType, basePrice, dynamicWeight, formulaConfig)
freeResource[resourceType] = isFree
seen[resourceType] = true
rules = append(rules, map[string]any{
"ruleKey": ruleKey, "resourceType": resourceType, "unit": unit,
"basePrice": basePrice, "currency": ruleCurrency, "baseWeight": decodeObject(baseWeightBytes),
"dynamicWeight": dynamicWeight, "calculatorType": calculatorType,
"dimensionSchema": decodeObject(dimensionSchemaBytes), "formulaConfig": formulaConfig,
"priority": priority, "isFree": isFree,
"effectiveFrom": effectiveFrom, "effectiveTo": effectiveTo,
})
}
if err := rows.Err(); err != nil {
return EffectivePricingConfig{}, err
}
if len(rules) == 0 {
return EffectivePricingConfig{}, fmt.Errorf("pricing rule set %s has no effective rules", id)
}
return EffectivePricingConfig{
RuleSetID: id, RuleSetKey: ruleSetKey, Currency: currency, Config: config,
FreeResource: freeResource,
Snapshot: map[string]any{
"pricingVersion": "effective-pricing-v2",
"ruleSetId": id,
"ruleSetKey": ruleSetKey,
"currency": currency,
"resolvedAt": time.Now().UTC().Format(time.RFC3339Nano),
"rules": rules,
},
}, nil
}
func addPricingRuleToConfig(config map[string]any, resourceType string, basePrice string, dynamicWeight map[string]any, formulaConfig map[string]any) {
resourceConfig := map[string]any{"basePrice": basePrice}
if len(dynamicWeight) > 0 {
resourceConfig["dynamicWeight"] = dynamicWeight
}
if len(formulaConfig) > 0 {
resourceConfig["formulaConfig"] = formulaConfig
}
switch resourceType {
case "text_input":
config["textInputPer1k"] = basePrice
case "text_cached_input":
config["textCachedInputPer1k"] = basePrice
case "text_output":
config["textOutputPer1k"] = basePrice
case "text_total":
inputPrice := any(basePrice)
if value, ok := pricingRuleValueFromKeys(formulaConfig, "inputTokenPrice", "input_token_price", "textInputPer1k", "text_input"); ok {
inputPrice = value
}
config["textInputPer1k"] = inputPrice
if value, ok := pricingRuleValueFromKeys(formulaConfig, "cachedInputTokenPrice", "cached_input_token_price", "textCachedInputPer1k", "text_cached_input", "inputCacheHitTokenPrice", "input_cache_hit_token_price"); ok {
config["textCachedInputPer1k"] = value
}
if value, ok := pricingRuleValueFromKeys(formulaConfig, "outputTokenPrice", "output_token_price", "textOutputPer1k", "text_output"); ok {
config["textOutputPer1k"] = value
}
config["text_total"] = resourceConfig
case "image":
config["imageBase"] = basePrice
config["image"] = resourceConfig
case "image_edit":
config["editBase"] = basePrice
config["image_edit"] = resourceConfig
case "video":
config["videoBase"] = basePrice
config["video"] = resourceConfig
case "music":
config["musicBase"] = basePrice
config["music"] = resourceConfig
case "audio":
config["audioBase"] = basePrice
config["audio"] = resourceConfig
default:
config[resourceType] = resourceConfig
}
}
func pricingRuleValueFromKeys(config map[string]any, keys ...string) (any, bool) {
for _, key := range keys {
if value, ok := config[key]; ok {
return value, true
}
}
return nil, false
}
func pricingRuleNumberFromKeys(config map[string]any, keys ...string) (float64, bool) {
if len(config) == 0 {
return 0, false
@@ -305,13 +487,16 @@ func insertPricingRules(ctx context.Context, tx pgx.Tx, ruleSetID string, defaul
if _, err := tx.Exec(ctx, `
INSERT INTO model_pricing_rules (
rule_set_id, rule_key, display_name, scope_type, scope_id, resource_type,
unit, base_price, currency, base_weight, dynamic_weight, calculator_type,
dimension_schema, formula_config, priority, status, metadata
unit, base_price, is_free, currency, base_weight, dynamic_weight, calculator_type,
dimension_schema, formula_config, priority, status, metadata, effective_from, effective_to
)
VALUES ($1::uuid, $2, $3, 'rule_set', $1::uuid, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
VALUES (
$1::uuid, $2, $3, 'rule_set', $1::uuid, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, NULLIF($17, '')::timestamptz, NULLIF($18, '')::timestamptz
)`,
ruleSetID, rule.RuleKey, rule.DisplayName, rule.ResourceType, rule.Unit,
rule.BasePrice, rule.Currency, baseWeight, dynamicWeight, rule.CalculatorType,
dimensionSchema, formulaConfig, rule.Priority, rule.Status, metadata,
rule.BasePrice, rule.IsFree, rule.Currency, baseWeight, dynamicWeight, rule.CalculatorType,
dimensionSchema, formulaConfig, rule.Priority, rule.Status, metadata, rule.EffectiveFrom, rule.EffectiveTo,
); err != nil {
return err
}
@@ -358,6 +543,7 @@ func scanPricingRule(scanner pricingScanner) (PricingRule, error) {
&item.ResourceType,
&item.Unit,
&item.BasePrice,
&item.IsFree,
&item.Currency,
&baseWeight,
&dynamicWeight,
@@ -367,6 +553,8 @@ func scanPricingRule(scanner pricingScanner) (PricingRule, error) {
&item.Priority,
&item.Status,
&metadata,
&item.EffectiveFrom,
&item.EffectiveTo,
&item.CreatedAt,
&item.UpdatedAt,
); err != nil {
@@ -444,6 +632,7 @@ func pricingInputsToRules(ruleSetID string, defaultCurrency string, rules []Pric
ResourceType: input.ResourceType,
Unit: input.Unit,
BasePrice: input.BasePrice,
IsFree: input.IsFree,
Currency: input.Currency,
BaseWeight: emptyObjectIfNil(input.BaseWeight),
DynamicWeight: emptyObjectIfNil(input.DynamicWeight),
@@ -453,6 +642,8 @@ func pricingInputsToRules(ruleSetID string, defaultCurrency string, rules []Pric
Priority: input.Priority,
Status: input.Status,
Metadata: emptyObjectIfNil(input.Metadata),
EffectiveFrom: input.EffectiveFrom,
EffectiveTo: input.EffectiveTo,
})
}
return items