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
+107
View File
@@ -1,10 +1,12 @@
package runner
import (
"context"
"errors"
"math"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
@@ -39,6 +41,9 @@ func TestFixedAmountOperationsRejectOverflow(t *testing.T) {
if _, err := pricing.calculate("image", maximum, []int{2}); !isPricingUnavailable(err) {
t.Fatalf("pricing overflow should be unavailable: %v", err)
}
if _, err := multiplyFixedProductRatio(maximum, []int{2}, nil, 1); !errors.Is(err, errFixedAmountOverflow) {
t.Fatalf("product ratio overflow error=%v", err)
}
}
func TestEstimatedOutputTokensUsesAliasesAndCapabilityFallback(t *testing.T) {
@@ -138,6 +143,108 @@ func TestPricingWeightsUseFixedPrecisionAndRejectInvalidValues(t *testing.T) {
}
}
func TestVideoBillingV2ProratesFiveSecondPriceByActualDuration(t *testing.T) {
service := &Service{}
candidate := store.RuntimeModelCandidate{ModelName: "video-model"}
pricing := resolvedPricing{
Config: map[string]any{
"video": map[string]any{
"basePrice": 100,
"dynamicWeight": map[string]any{
"audioWeights": map[string]any{"true": 2},
},
},
},
Currency: "resource",
}
tests := []struct {
name string
duration float64
wantUnits float64
wantAmount float64
}{
{name: "three seconds uses zero point six units", duration: 3, wantUnits: 0.6, wantAmount: 120},
{name: "five seconds uses one unit", duration: 5, wantUnits: 1, wantAmount: 200},
{name: "six seconds uses one point two units", duration: 6, wantUnits: 1.2, wantAmount: 240},
{name: "fractional seconds retain fixed amount precision", duration: 6.000000001, wantUnits: 1.2000000002, wantAmount: 240.00000004},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
items, total, _, err := service.billingsWithResolvedPricingV2(
context.Background(), nil, "videos.generations",
map[string]any{"duration": test.duration, "audio": true},
candidate, clients.Response{}, true, pricing,
)
if err != nil {
t.Fatalf("bill video: %v", err)
}
line := firstBillingLine(t, items)
if got := total.Float64(); math.Abs(got-test.wantAmount) > 1e-9 {
t.Fatalf("total amount=%v, want %v", got, test.wantAmount)
}
if got := floatFromAny(line["amount"]); math.Abs(got-test.wantAmount) > 1e-9 {
t.Fatalf("line amount=%v, want %v", got, test.wantAmount)
}
if got := floatFromAny(line["quantity"]); math.Abs(got-test.wantUnits) > 1e-12 {
t.Fatalf("quantity=%v, want %v", got, test.wantUnits)
}
if got := floatFromAny(line["durationUnitCount"]); math.Abs(got-test.wantUnits) > 1e-12 {
t.Fatalf("duration units=%v, want %v", got, test.wantUnits)
}
})
}
}
func TestVideoBillingV2RoundsOnlyAfterApplyingDurationCountAndWeights(t *testing.T) {
service := &Service{}
candidate := store.RuntimeModelCandidate{ModelName: "video-model"}
tests := []struct {
name string
body map[string]any
pricing resolvedPricing
wantAmount string
}{
{
name: "count preserves a sub-nano duration share",
body: map[string]any{"duration": 1, "count": 5},
pricing: resolvedPricing{Config: map[string]any{
"video": map[string]any{"basePrice": "0.000000001"},
}},
wantAmount: "0.000000001",
},
{
name: "weight does not amplify a rounded duration share",
body: map[string]any{"duration": 3, "audio": true},
pricing: resolvedPricing{Config: map[string]any{
"video": map[string]any{
"basePrice": "0.000000001",
"dynamicWeight": map[string]any{
"audioWeights": map[string]any{"true": 2},
},
},
}},
wantAmount: "0.000000001",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, total, _, err := service.billingsWithResolvedPricingV2(
context.Background(), nil, "videos.generations", test.body,
candidate, clients.Response{}, true, test.pricing,
)
if err != nil {
t.Fatalf("bill video: %v", err)
}
if got := total.String(); got != test.wantAmount {
t.Fatalf("total amount=%s, want %s", got, test.wantAmount)
}
})
}
}
func mustFixedAmount(t *testing.T, value string) fixedAmount {
t.Helper()
amount, err := parseFixedAmount(value)