若干更新迭代

This commit is contained in:
andy committed 2026-10-02 19:56:32 +08:00
1 parent deb9cc5bcf
commit 4ac786aafb
64 files changed
+3251 -291

No files matched your search

+123 -5
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"math"
"strconv"
"strings"
"time"
@@ -55,6 +56,9 @@ func (p *SettlementProcessor) Advance(ctx context.Context, job jobs.Job) (jobs.J
if advanced.Provider == "minimax" && billing.IsMinimaxH3BillingModel(advanced.ReqKey) {
return p.settleMinimaxH3(ctx, advanced)
}
if advanced.Provider == "bailian" && advanced.ReqKey == billing.BailianWan30VideoModel {
return p.settleBailianWan30(ctx, advanced)
}
if advanced.Provider != "seedance" {
return advanced, nil
}
@@ -168,6 +172,9 @@ func (p *SettlementProcessor) settleMinimaxH3(ctx context.Context, job jobs.Job)
charge.raw["settledAt"] = p.now().UTC().Format(time.RFC3339Nano)
return p.write(ctx, job, charge.raw)
}
if !usage.hasInputImageCount {
usage.inputImageCount = minimaxQuotedInputImageCount(charge.raw, job.RequestPayload)
}
resolution := minimaxResolution(charge.raw)
actualAmount, err := billing.CalculateMinimaxH3ActualAmountFen(billing.MinimaxH3ActualAmountInput{
Resolution: resolution, OutputSeconds: usage.outputSeconds, InputVideoSeconds: usage.inputSeconds,
@@ -221,6 +228,100 @@ func (p *SettlementProcessor) settleMinimaxH3(ctx context.Context, job jobs.Job)
return p.write(ctx, job, charge.raw)
}
func (p *SettlementProcessor) settleBailianWan30(ctx context.Context, job jobs.Job) (jobs.Job, error) {
var charge billingState
if json.Unmarshal(job.Billing, &charge) != nil {
return jobs.Job{}, errors.New("settle Wan 3.0 generation charge")
}
status, _ := charge.raw["settlementStatus"].(string)
if status == "settled" || status == "estimated" {
return job, nil
}
var use usageContext
if len(job.UsageContext) != 0 && json.Unmarshal(job.UsageContext, &use) != nil {
return jobs.Job{}, errors.New("settle Wan 3.0 generation charge")
}
quotaExempt := charge.QuotaExempt || use.Source == "platform" && use.Role == "super_admin"
chargeReady := charge.Status == "charged" || quotaExempt && charge.Status == "not_charged"
if !chargeReady || !quotaExempt && use.OrganizationID == "" {
return job, nil
}
outputSeconds := bailianWan30OutputSeconds(job.ResponsePayload)
if outputSeconds <= 0 {
charge.raw["settlementStatus"] = "estimated"
charge.raw["settlementReason"] = "provider_usage_unavailable"
charge.raw["settledAt"] = p.now().UTC().Format(time.RFC3339Nano)
return p.write(ctx, job, charge.raw)
}
unitPrice, ok := integer(charge.raw["standardUnitPriceFen"])
if !ok {
return jobs.Job{}, errors.New("settle Wan 3.0 generation charge")
}
actualAmount, err := billing.CalculateBailianWan30ActualAmountFen(outputSeconds, unitPrice, numberOrZero(charge.raw["markupMultiplier"]))
if err != nil {
return jobs.Job{}, errors.New("settle Wan 3.0 generation charge")
}
delta := actualAmount - charge.AmountFen
settledAt := p.now().UTC()
var settlementLedgerID string
if delta != 0 && !quotaExempt {
if p.ledger == nil {
return jobs.Job{}, errors.New("settle Wan 3.0 generation charge")
}
description := capabilityLabel(job.Capability) + "实际用量差额退回"
if delta > 0 {
description = capabilityLabel(job.Capability) + "实际用量补扣"
}
posting, postErr := p.ledger.Settle(ctx, billing.SettlementRequest{
OrganizationID: use.OrganizationID, AccountID: use.AccountID, JobID: job.ID,
DeltaFen: delta, Description: description,
Metadata: map[string]any{
"operation": "bailian_wan30_actual_settlement", "reservedAmountFen": charge.AmountFen,
"actualAmountFen": actualAmount, "outputSeconds": outputSeconds,
"resolution": seedanceResolution(charge.raw), "model": job.ReqKey,
},
})
if postErr != nil {
return jobs.Job{}, errors.New("settle Wan 3.0 generation charge")
}
settlementLedgerID = posting.LedgerID
if !posting.CreatedAt.IsZero() {
settledAt = posting.CreatedAt.UTC()
}
}
charge.raw["amountFen"] = actualAmount
charge.raw["settlementStatus"] = "settled"
charge.raw["settledAt"] = settledAt.Format(time.RFC3339Nano)
if settlementLedgerID != "" {
charge.raw["settlementLedgerEntryId"] = settlementLedgerID
} else {
delete(charge.raw, "settlementLedgerEntryId")
}
charge.raw["providerUsage"] = map[string]any{
"model": job.ReqKey, "resolution": seedanceResolution(charge.raw), "outputSeconds": outputSeconds,
"videoPriceFenPerSecond": unitPrice,
}
return p.write(ctx, job, charge.raw)
}
func bailianWan30OutputSeconds(payload json.RawMessage) float64 {
var response struct {
Usage map[string]any `json:"usage"`
}
if json.Unmarshal(payload, &response) != nil {
return 0
}
if milliseconds := numberOrZero(response.Usage["outputMilliseconds"]); milliseconds > 0 {
return milliseconds / 1000
}
seconds := numberOrZero(response.Usage["outputSeconds"])
if math.IsNaN(seconds) || math.IsInf(seconds, 0) || seconds <= 0 {
return 0
}
return seconds
}
func (p *SettlementProcessor) settleSeedreamLayers(ctx context.Context, job jobs.Job) (jobs.Job, error) {
var charge billingState
if json.Unmarshal(job.Billing, &charge) != nil {
@@ -325,9 +426,10 @@ func seedanceCompletionTokens(payload json.RawMessage) int64 {
}
type minimaxUsage struct {
outputSeconds int64
inputSeconds int64
inputImageCount int64
outputSeconds int64
inputSeconds int64
inputImageCount int64
hasInputImageCount bool
}
func minimaxH3Usage(payload json.RawMessage) minimaxUsage {
@@ -339,8 +441,24 @@ func minimaxH3Usage(payload json.RawMessage) minimaxUsage {
}
outputSeconds, _ := nonNegativeInteger(response.Usage["outputSeconds"])
inputSeconds, _ := nonNegativeInteger(response.Usage["inputSeconds"])
inputImageCount, _ := nonNegativeInteger(response.Usage["inputImageCount"])
return minimaxUsage{outputSeconds: outputSeconds, inputSeconds: inputSeconds, inputImageCount: inputImageCount}
inputImageCount, hasInputImageCount := nonNegativeInteger(response.Usage["inputImageCount"])
return minimaxUsage{outputSeconds: outputSeconds, inputSeconds: inputSeconds, inputImageCount: inputImageCount, hasInputImageCount: hasInputImageCount}
}
func minimaxQuotedInputImageCount(snapshot map[string]any, requestPayload json.RawMessage) int64 {
parameters, _ := snapshot["parameters"].(map[string]any)
for _, key := range []string{"inputImageCount", "referenceImageCount"} {
if count, ok := nonNegativeInteger(parameters[key]); ok {
return count
}
}
var request map[string]any
if json.Unmarshal(requestPayload, &request) == nil {
if count, ok := nonNegativeInteger(billing.NormalizeBillingParameters(request, nil)["referenceImageCount"]); ok {
return count
}
}
return 0
}
func minimaxResolution(snapshot map[string]any) string {
@@ -124,6 +124,104 @@ func TestSettlementProcessorSettlesSuccessfulMinimaxH3Usage(t *testing.T) {
}
}
func TestSettlementProcessorDistinguishesMissingMinimaxImageUsageFromExplicitZero(t *testing.T) {
for _, test := range []struct {
name, usageJSON, billingJSON, requestJSON string
wantAmount, wantDelta int64
wantImageCount float64
}{
{
name: "missing usage falls back to quoted count", usageJSON: `{"outputSeconds":5}`,
billingJSON: `{"status":"charged","amountFen":576,"standardUnitPriceFen":80,"markupMultiplier":1.2,"settlementStatus":"pending","parameters":{"resolution":"2k","inputImageCount":9}}`,
wantAmount: 576, wantImageCount: 9,
},
{
name: "missing usage falls back to request materials", usageJSON: `{"outputSeconds":5}`,
billingJSON: `{"status":"charged","amountFen":576,"standardUnitPriceFen":80,"markupMultiplier":1.2,"settlementStatus":"pending","parameters":{"resolution":"2k"}}`,
requestJSON: `{"materials":[{"type":"image"},{"type":"image"},{"type":"image"},{"type":"image"},{"type":"image"},{"type":"image"},{"type":"image"},{"type":"image"},{"type":"image"}]}`,
wantAmount: 576, wantImageCount: 9,
},
{
name: "explicit zero overrides quote", usageJSON: `{"outputSeconds":5,"inputImageCount":0}`,
billingJSON: `{"status":"charged","amountFen":576,"standardUnitPriceFen":80,"markupMultiplier":1.2,"settlementStatus":"pending","parameters":{"resolution":"2k","inputImageCount":9}}`,
wantAmount: 480, wantDelta: -96, wantImageCount: 0,
},
} {
t.Run(test.name, func(t *testing.T) {
state := &settlementStateStub{}
ledger := &settlementLedgerStub{}
job := jobs.Job{
ID: "minimax-multiref", Provider: "minimax", ReqKey: billing.MinimaxH3Model, Capability: "video.generate", Status: jobs.StatusSucceeded,
ResponsePayload: json.RawMessage(`{"usage":` + test.usageJSON + `}`),
RequestPayload: json.RawMessage(test.requestJSON), Billing: json.RawMessage(test.billingJSON),
UsageContext: json.RawMessage(`{"organizationId":"org-1","accountId":"account-1"}`),
}
got, err := NewSettlementProcessor(settlementProcessorStub{job: job}, ledger, state, time.Now).Advance(context.Background(), jobs.Job{ID: job.ID})
if err != nil {
t.Fatal(err)
}
var snapshot map[string]any
_ = json.Unmarshal(got.Billing, &snapshot)
usage := snapshot["providerUsage"].(map[string]any)
if snapshot["amountFen"] != float64(test.wantAmount) || usage["inputImageCount"] != test.wantImageCount || state.calls != 1 {
t.Fatalf("billing=%#v state.calls=%d", snapshot, state.calls)
}
if test.wantDelta == 0 && ledger.calls != 0 || test.wantDelta != 0 && (ledger.calls != 1 || ledger.request.DeltaFen != test.wantDelta) {
t.Fatalf("ledger=%#v calls=%d", ledger.request, ledger.calls)
}
})
}
}
func TestSettlementProcessorSettlesWan30FromFractionalOutputDuration(t *testing.T) {
state := &settlementStateStub{}
ledger := &settlementLedgerStub{posting: billing.WalletPosting{LedgerID: "wan30-settlement"}}
advanced := jobs.Job{
ID: "wan30-job", Provider: "bailian", ReqKey: billing.BailianWan30VideoModel, Capability: "video.generate", Status: jobs.StatusSucceeded,
ResponsePayload: json.RawMessage(`{"usage":{"outputMilliseconds":4201,"outputSeconds":5}}`),
Billing: json.RawMessage(`{"status":"charged","amountFen":360,"reservedAmountFen":360,"standardUnitPriceFen":60,"markupMultiplier":1.2,"settlementStatus":"pending","parameters":{"resolution":"720p"}}`),
UsageContext: json.RawMessage(`{"organizationId":"org-1","accountId":"account-1"}`),
}
processor := NewSettlementProcessor(settlementProcessorStub{job: advanced}, ledger, state, time.Now)
got, err := processor.Advance(context.Background(), jobs.Job{ID: advanced.ID})
if err != nil {
t.Fatal(err)
}
// 4.201 seconds * 60 fen * 1.2 = 302.472 fen, rounded once to 303.
if ledger.calls != 1 || ledger.request.DeltaFen != -57 || ledger.request.Metadata["outputSeconds"] != 4.201 {
t.Fatalf("ledger=%#v calls=%d", ledger.request, ledger.calls)
}
var snapshot map[string]any
_ = json.Unmarshal(got.Billing, &snapshot)
if snapshot["amountFen"] != float64(303) || snapshot["settlementStatus"] != "settled" || snapshot["settlementLedgerEntryId"] != "wan30-settlement" {
t.Fatalf("billing=%#v", snapshot)
}
processor.inner = settlementProcessorStub{job: got}
_, err = processor.Advance(context.Background(), got)
if err != nil || ledger.calls != 1 || state.calls != 1 {
t.Fatalf("repeat calls ledger=%d state=%d err=%v", ledger.calls, state.calls, err)
}
}
func TestSettlementProcessorKeepsWan30EstimateWhenUsageMissing(t *testing.T) {
state := &settlementStateStub{}
advanced := jobs.Job{
ID: "wan30-job", Provider: "bailian", ReqKey: billing.BailianWan30VideoModel, Capability: "video.generate", Status: jobs.StatusSucceeded,
ResponsePayload: json.RawMessage(`{"usage":{}}`),
Billing: json.RawMessage(`{"status":"charged","amountFen":360,"standardUnitPriceFen":60,"markupMultiplier":1.2,"settlementStatus":"pending"}`),
UsageContext: json.RawMessage(`{"organizationId":"org-1"}`),
}
got, err := NewSettlementProcessor(settlementProcessorStub{job: advanced}, &settlementLedgerStub{}, state, time.Now).Advance(context.Background(), jobs.Job{ID: advanced.ID})
if err != nil {
t.Fatal(err)
}
var snapshot map[string]any
_ = json.Unmarshal(got.Billing, &snapshot)
if snapshot["amountFen"] != float64(360) || snapshot["settlementStatus"] != "estimated" || snapshot["settlementReason"] != "provider_usage_unavailable" {
t.Fatalf("billing=%#v", snapshot)
}
}
func TestSettlementProcessorRefundsUnusedSeedreamLayerReservation(t *testing.T) {
state := &settlementStateStub{}
ledger := &settlementLedgerStub{posting: billing.WalletPosting{LedgerID: "layer-settlement", CreatedAt: time.Date(2026, 8, 21, 8, 0, 0, 0, time.UTC)}}