package billing import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "strings" "time" ) type Store interface { BillingWallet(context.Context, string) (Wallet, error) BillingWallets(context.Context) ([]Wallet, error) BillingLedger(context.Context, string, string, int) ([]LedgerEntry, error) BillingOrganizations(context.Context) ([]Organization, error) BillingMembers(context.Context) ([]Member, error) BillingOrganizationExists(context.Context, string) (bool, error) ListBillingPriceRules(context.Context, bool) ([]PriceRule, error) GetBillingPriceRule(context.Context, string) (*PriceRule, error) UpdateBillingPriceRule(context.Context, string, PricePatch) (*PriceRule, error) PostBillingWalletEntry(context.Context, WalletPostParams) (WalletPosting, error) } type Service struct { store Store newID func() string enabled bool } func NewService(store Store, newID func() string) *Service { if newID == nil { newID = billingID } return &Service{store: store, newID: newID, enabled: true} } // SetEnabled configures whether generation quotes are required. The default is // enabled; disabling preserves the historical optional-billing behavior and // does not consult or seed the price catalog. func (s *Service) SetEnabled(enabled bool) *Service { s.enabled = enabled return s } func (s *Service) Overview(ctx context.Context, organizationID, accountID string) (Overview, error) { wallet, err := s.store.BillingWallet(ctx, organizationID) if err != nil { return Overview{}, err } ledger, err := s.store.BillingLedger(ctx, organizationID, "", 500) if err != nil { return Overview{}, err } personal, err := s.store.BillingLedger(ctx, organizationID, accountID, 500) if err != nil { return Overview{}, err } return Overview{Wallet: wallet, Ledger: ledger, Summary: Summarize(ledger), Personal: Summarize(personal)}, nil } func (s *Service) Quote(ctx context.Context, command QuoteCommand) (*Quote, error) { if !s.enabled { return nil, nil } if seeder, ok := s.store.(PriceRuleSeeder); ok { if err := seeder.SeedBillingPriceRules(ctx, DefaultBillingPriceRules()); err != nil { return nil, err } } rules, err := s.store.ListBillingPriceRules(ctx, false) if err != nil { return nil, err } parameters := NormalizeBillingParameters(command.Payload, command.Parameters) quote, err := (Catalog{Rules: rules}).Quote(QuoteInput{Provider: command.Provider, Capability: command.Capability, ReqKey: command.ReqKey, Source: "platform", Role: command.Role, Parameters: parameters}) if err != nil || quote == nil || command.Provider != "seedance" || command.ReqKey != "doubao-seedance-2-0-260128" { return quote, err } inputVideo, inputDuration := seedanceInputVideo(command.Payload) estimated, err := EstimateSeedanceAmountFen(SeedanceEstimateInput{ Resolution: fmt.Sprint(parameters["resolution"]), AspectRatio: fmt.Sprint(parameters["aspectRatio"]), OutputDurationSeconds: quote.Quantity, InputVideo: inputVideo, InputVideoDurationSeconds: inputDuration, MarkupMultiplier: quote.MarkupMultiplier, }) if err != nil { return nil, err } if estimated > quote.AmountFen { quote.AmountFen = estimated } quote.ReservedAmountFen = quote.AmountFen quote.SettlementStatus = "pending" if quote.Parameters == nil { quote.Parameters = Parameters{} } quote.Parameters["inputVideo"] = inputVideo return quote, nil } func (s *Service) AdminOverview(ctx context.Context) (AdminOverview, error) { organizations, err := s.store.BillingOrganizations(ctx) if err != nil { return AdminOverview{}, err } wallets, err := s.store.BillingWallets(ctx) if err != nil { return AdminOverview{}, err } members, err := s.store.BillingMembers(ctx) if err != nil { return AdminOverview{}, err } ledger, err := s.store.BillingLedger(ctx, "", "", 500) if err != nil { return AdminOverview{}, err } rules, err := s.store.ListBillingPriceRules(ctx, true) if err != nil { return AdminOverview{}, err } byOrganization := map[string]Wallet{} for _, wallet := range wallets { byOrganization[wallet.OrganizationID] = wallet } for i := range organizations { if wallet, ok := byOrganization[organizations[i].ID]; ok { organizations[i].Wallet = wallet } else { organizations[i].Wallet = Wallet{OrganizationID: organizations[i].ID, Currency: CurrencyCNY, UpdatedAt: organizations[i].UpdatedAt} } } return AdminOverview{Organizations: organizations, Members: members, Ledger: ledger, PriceRules: rules}, nil } func (s *Service) ListPrices(ctx context.Context) ([]PriceRule, error) { return s.store.ListBillingPriceRules(ctx, true) } func (s *Service) GetPrice(ctx context.Context, id string) (*PriceRule, error) { return s.store.GetBillingPriceRule(ctx, id) } func (s *Service) UpdatePrice(ctx context.Context, id string, patch PricePatch) (*PriceRule, error) { return s.store.UpdateBillingPriceRule(ctx, id, patch) } func (s *Service) Adjust(ctx context.Context, command AdjustmentCommand) (AdjustmentResult, error) { exists, err := s.store.BillingOrganizationExists(ctx, command.OrganizationID) if err != nil { return AdjustmentResult{}, err } if !exists { return AdjustmentResult{}, &StatusError{400, errors.New("组织不存在。")} } kind, description := "adjustment", "管理员扣减 · "+command.Note if command.Direction == "credit" { kind, description = "recharge", "管理员上账 · "+command.Note } post, err := s.store.PostBillingWalletEntry(ctx, WalletPostParams{LedgerID: s.newID(), OrganizationID: command.OrganizationID, Kind: kind, DeltaFen: command.DeltaFen, Currency: CurrencyCNY, IdempotencyKey: "manual-adjustment:" + s.newID(), Description: description, Metadata: map[string]any{"operation": map[bool]string{true: "admin_top_up", false: "manual_adjustment"}[command.Direction == "credit"], "direction": command.Direction, "note": command.Note, "operatorId": command.OperatorID, "amountYuan": fmt.Sprintf("%.2f", float64(command.AmountFen)/100)}}) if err != nil { message := err.Error() if strings.Contains(message, "BILLING_INSUFFICIENT_BALANCE") { return AdjustmentResult{}, &StatusError{402, ErrInsufficientBalance} } if strings.Contains(message, "BILLING_IDEMPOTENCY_PAYLOAD_MISMATCH") { return AdjustmentResult{}, &StatusError{409, ErrIdempotencyConflict} } return AdjustmentResult{}, err } return PostingResult(post, command.OrganizationID), nil } func Summarize(entries []LedgerEntry) Summary { var out Summary for _, entry := range entries { switch { case entry.Kind == "recharge", entry.Kind == "adjustment" && entry.DeltaFen > 0: if entry.DeltaFen > 0 { out.RechargeFen += entry.DeltaFen } case entry.Kind == "charge": if entry.DeltaFen < 0 { out.ChargedFen -= entry.DeltaFen } case entry.Kind == "refund": if entry.DeltaFen > 0 { out.RefundedFen += entry.DeltaFen } } } out.NetConsumedFen = out.ChargedFen - out.RefundedFen if out.NetConsumedFen < 0 { out.NetConsumedFen = 0 } return out } func billingID() string { raw := make([]byte, 12) if _, err := rand.Read(raw); err != nil { return fmt.Sprintf("entry-%d", time.Now().UnixNano()) } return "entry-" + hex.EncodeToString(raw) } var _ HTTPService = (*Service)(nil)