217 lines
7.3 KiB
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)
|