返回 DeepSeek-Reasonix
balance.go
根目录 / internal / billing / balance.go
1 // Package billing queries a provider's wallet balance for the status line. The
2 // only documented shape today is DeepSeek's GET /user/balance, so Fetch speaks
3 // that schema. Balance is strictly optional: a provider with no balance_url is
4 // never queried — callers pass "" and get (nil, nil) back, and surfaces simply
5 // omit the readout. Kept tiny and dependency-free (net/http + encoding/json) so
6 // every frontend can share one fetch.
7 package billing
8
9 import (
10 "context"
11 "encoding/json"
12 "fmt"
13 "io"
14 "net/http"
15 "strings"
16 "time"
17 )
18
19 // Balance is a wallet balance normalized for display.
20 type Balance struct {
21 Available bool // the provider reports the account can still serve API calls
22 Infos []Info // one entry per currency the provider returns
23 }
24
25 // Info is one currency's balance (DeepSeek returns one per currency).
26 type Info struct {
27 Currency string // "CNY" | "USD"
28 TotalBalance string // total available (granted + topped-up)
29 GrantedBalance string // unexpired promotional credit
30 ToppedUpBalance string // paid-in credit
31 }
32
33 // deepseekResp mirrors the GET /user/balance response shape.
34 type deepseekResp struct {
35 IsAvailable bool `json:"is_available"`
36 BalanceInfos []struct {
37 Currency string `json:"currency"`
38 TotalBalance string `json:"total_balance"`
39 GrantedBalance string `json:"granted_balance"`
40 ToppedUpBalance string `json:"topped_up_balance"`
41 } `json:"balance_infos"`
42 }
43
44 // httpClient bounds the balance query so a slow endpoint can't hang the status
45 // line; the per-call ctx still cancels it on shutdown.
46 var httpClient = &http.Client{Timeout: 12 * time.Second}
47
48 // Fetch queries url (a DeepSeek-style balance endpoint) with a Bearer apiKey and
49 // returns the normalized balance. An empty url yields (nil, nil) — "not
50 // configured", not an error — so callers can treat both the same and just omit
51 // the readout.
52 func Fetch(ctx context.Context, url, apiKey string) (*Balance, error) {
53 return FetchWithClient(ctx, httpClient, url, apiKey)
54 }
55
56 // FetchWithClient queries the balance endpoint using the caller-provided client.
57 // A nil client falls back to the package default.
58 func FetchWithClient(ctx context.Context, client *http.Client, url, apiKey string) (*Balance, error) {
59 if strings.TrimSpace(url) == "" {
60 return nil, nil
61 }
62 if client == nil {
63 client = httpClient
64 }
65 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
66 if err != nil {
67 return nil, err
68 }
69 req.Header.Set("Accept", "application/json")
70 if apiKey != "" {
71 req.Header.Set("Authorization", "Bearer "+apiKey)
72 }
73 resp, err := client.Do(req)
74 if err != nil {
75 return nil, err
76 }
77 defer resp.Body.Close()
78 body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
79 if resp.StatusCode != http.StatusOK {
80 return nil, fmt.Errorf("balance: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
81 }
82 var dr deepseekResp
83 if err := json.Unmarshal(body, &dr); err != nil {
84 return nil, fmt.Errorf("balance: decode: %w", err)
85 }
86 b := &Balance{Available: dr.IsAvailable}
87 for _, bi := range dr.BalanceInfos {
88 b.Infos = append(b.Infos, Info{
89 Currency: bi.Currency,
90 TotalBalance: bi.TotalBalance,
91 GrantedBalance: bi.GrantedBalance,
92 ToppedUpBalance: bi.ToppedUpBalance,
93 })
94 }
95 return b, nil
96 }
97
98 // symbol maps an ISO currency code to a compact symbol; an unknown code passes
99 // through with a trailing space ("XYZ 12.00").
100 func symbol(currency string) string {
101 switch strings.ToUpper(currency) {
102 case "CNY", "RMB":
103 return "¥"
104 case "USD":
105 return "$"
106 default:
107 if currency == "" {
108 return ""
109 }
110 return currency + " "
111 }
112 }
113
114 // Display renders the primary balance compactly, e.g. "¥110.00". It preserves
115 // the legacy CNY-first behavior for callers that have no display-currency
116 // preference.
117 func (b *Balance) Display() string {
118 return b.DisplayForCurrency("")
119 }
120
121 // DisplayForCurrency renders the balance matching the requested pricing
122 // currency. When the provider does not return that currency, it falls back to
123 // Display's legacy CNY-first selection and prefixes the provider's real ISO
124 // currency (for example "CNY ¥70.16"); it never performs an implicit
125 // exchange-rate conversion.
126 func (b *Balance) DisplayForCurrency(currency string) string {
127 if b == nil || len(b.Infos) == 0 {
128 return ""
129 }
130 pick := b.Infos[0]
131 preferred := normalizeCurrency(currency)
132 if preferred != "" {
133 for _, i := range b.Infos {
134 if normalizeCurrency(i.Currency) == preferred {
135 return symbol(i.Currency) + strings.TrimSpace(i.TotalBalance)
136 }
137 }
138 }
139 for _, i := range b.Infos {
140 if normalizeCurrency(i.Currency) == "CNY" {
141 pick = i
142 break
143 }
144 }
145 display := symbol(pick.Currency) + strings.TrimSpace(pick.TotalBalance)
146 actual := normalizeCurrency(pick.Currency)
147 if preferred != "" && actual != "" && actual != preferred {
148 return actual + " " + display
149 }
150 return display
151 }
152
153 func normalizeCurrency(currency string) string {
154 switch strings.ToUpper(strings.TrimSpace(currency)) {
155 case "CNY", "RMB", "CNH", "¥", "¥":
156 return "CNY"
157 case "USD", "$", "US$":
158 return "USD"
159 default:
160 return ""
161 }
162 }
163
163 lines GO