fix(billing): 视频时长按实际秒数线性计费

将五秒基础价按 duration / 5 比例结算,保留 provider 返回的小数时长,避免六秒视频被按两个完整单位收费。

影响:所有使用 5s 视频基础价的模型,quantity 与 durationUnitCount 允许小数。新增迁移同步现存规则的旧 ceil 公式元数据。

验证:go vet ./...;pnpm lint;pnpm test;pnpm build;./tests/ci/migrations-test.sh
This commit is contained in:
2026-07-21 13:32:23 +08:00
parent e533ec2367
commit e3dfe8162b
8 changed files with 178 additions and 24 deletions
+29 -4
View File
@@ -184,6 +184,22 @@ func multiplyFixedAmountRatio(amount fixedAmount, numerator int, denominator int
return fixedAmountFromBigInt(roundBigIntRatio(product, big.NewInt(int64(denominator))))
}
func multiplyFixedProductRatio(base fixedAmount, integerFactors []int, fixedFactors []fixedAmount, denominator int) (fixedAmount, error) {
if denominator == 0 {
return 0, fmt.Errorf("division by zero")
}
product := big.NewInt(int64(base))
for _, factor := range integerFactors {
product.Mul(product, big.NewInt(int64(factor)))
}
divisor := big.NewInt(int64(denominator))
for _, factor := range fixedFactors {
product.Mul(product, big.NewInt(int64(factor)))
divisor.Mul(divisor, big.NewInt(fixedScale))
}
return fixedAmountFromBigInt(roundBigIntRatio(product, divisor))
}
func fixedAmountFromBigInt(value *big.Int) (fixedAmount, error) {
if value == nil || !value.IsInt64() {
return 0, errFixedAmountOverflow
@@ -649,7 +665,11 @@ func (s *Service) billingsWithResolvedPricingV2(
baseKey = "videoBase"
duration, durationSource := billingDurationSeconds(body, response)
audioEnabled, audioSource := billingAudioEnabled(body, response)
durationUnits := int(math.Max(1, math.Ceil(duration/5)))
durationUnits := videoDurationUnits(duration)
durationFixed, durationErr := fixedAmountFromAny(duration)
if durationErr != nil {
return nil, 0, resolvedPricing{}, pricing.calculationError(resource, durationErr)
}
price, priceErr := pricing.requiredPrice(resource, baseKey, "basePrice")
if priceErr != nil {
return nil, 0, resolvedPricing{}, priceErr
@@ -670,11 +690,16 @@ func (s *Service) billingsWithResolvedPricingV2(
if weightErr != nil {
return nil, 0, resolvedPricing{}, weightErr
}
amount, calculationErr := pricing.calculate(resource, price, []int{count, durationUnits}, resolutionWeight, audioWeight, referenceVideoWeight, voiceWeight, discount)
amount, calculationErr := multiplyFixedProductRatio(
price,
[]int{count},
[]fixedAmount{durationFixed, resolutionWeight, audioWeight, referenceVideoWeight, voiceWeight, discount},
videoBillingUnitSeconds,
)
if calculationErr != nil {
return nil, 0, resolvedPricing{}, calculationErr
return nil, 0, resolvedPricing{}, pricing.calculationError(resource, calculationErr)
}
item := buildLine(resource, unit, count*durationUnits, amount, map[string]any{
item := buildLine(resource, unit, videoDurationQuantity(duration, count), amount, map[string]any{
"count": count, "audio": audioEnabled, "audioSource": audioSource,
"durationSeconds": duration, "durationSource": durationSource,
"durationUnit": "5s", "durationUnitCount": durationUnits,