Files
NianAIGC/backend/internal/billing/service.go

217 lines
7.3 KiB
Go

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
}
if ledger == nil {
ledger = []LedgerEntry{}
}
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
}
if organizations == nil {
organizations = []Organization{}
}
if members == nil {
members = []Member{}
}
if ledger == nil {
ledger = []LedgerEntry{}
}
if rules == nil {
rules = []PriceRule{}
}
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)