返回 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 "sort"
16 "strings"
17 "time"
18 )
19
20 // Balance is a wallet balance normalized for display.
21 type Balance struct {
22 Available bool // the provider reports the account can still serve API calls
23 Infos []Info // one entry per currency the provider returns
24 }
25
26 // Currencies returns the distinct ISO currency codes reported by the wallet,
27 // in stable order. It never converts or combines balances.
28 func (b *Balance) Currencies() []string {
29 if b == nil {
30 return nil
31 }
32 seen := map[string]struct{}{}
33 for _, info := range b.Infos {
34 cur := strings.ToUpper(strings.TrimSpace(info.Currency))
35 if normalized := normalizeCurrency(cur); normalized != "" {
36 cur = normalized
37 }
38 if cur != "" {
39 seen[cur] = struct{}{}
40 }
41 }
42 result := make([]string, 0, len(seen))
43 for cur := range seen {
44 result = append(result, cur)
45 }
46 sort.Strings(result)
47 return result
48 }
49
50 // PrimaryCurrency returns the sole usable wallet currency, if there is one.
51 func (b *Balance) PrimaryCurrency() string {
52 currencies := b.Currencies()
53 if len(currencies) != 1 {
54 return ""
55 }
56 if currencies[0] != "CNY" && currencies[0] != "USD" {
57 return ""
58 }
59 return currencies[0]
60 }
61
62 // MultiCurrency reports whether more than one wallet currency is present.
63 func (b *Balance) MultiCurrency() bool { return len(b.Currencies()) > 1 }
64
65 // Info is one currency's balance (DeepSeek returns one per currency).
66 type Info struct {
67 Currency string // "CNY" | "USD"
68 TotalBalance string // total available (granted + topped-up)
69 GrantedBalance string // unexpired promotional credit
70 ToppedUpBalance string // paid-in credit
71 }
72
73 // deepseekResp mirrors the GET /user/balance response shape.
74 type deepseekResp struct {
75 IsAvailable bool `json:"is_available"`
76 BalanceInfos []struct {
77 Currency string `json:"currency"`
78 TotalBalance string `json:"total_balance"`
79 GrantedBalance string `json:"granted_balance"`
80 ToppedUpBalance string `json:"topped_up_balance"`
81 } `json:"balance_infos"`
82 }
83
84 // httpClient bounds the balance query so a slow endpoint can't hang the status
85 // line; the per-call ctx still cancels it on shutdown.
86 var httpClient = &http.Client{Timeout: 12 * time.Second}
87
88 // Fetch queries url (a DeepSeek-style balance endpoint) with a Bearer apiKey and
89 // returns the normalized balance. An empty url yields (nil, nil) — "not
90 // configured", not an error — so callers can treat both the same and just omit
91 // the readout.
92 func Fetch(ctx context.Context, url, apiKey string) (*Balance, error) {
93 return FetchWithClient(ctx, httpClient, url, apiKey)
94 }
95
96 // FetchWithClient queries the balance endpoint using the caller-provided client.
97 // A nil client falls back to the package default.
98 func FetchWithClient(ctx context.Context, client *http.Client, url, apiKey string) (*Balance, error) {
99 if strings.TrimSpace(url) == "" {
100 return nil, nil
101 }
102 if client == nil {
103 client = httpClient
104 }
105 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
106 if err != nil {
107 return nil, err
108 }
109 req.Header.Set("Accept", "application/json")
110 if apiKey != "" {
111 req.Header.Set("Authorization", "Bearer "+apiKey)
112 }
113 resp, err := client.Do(req)
114 if err != nil {
115 return nil, err
116 }
117 defer resp.Body.Close()
118 body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
119 if resp.StatusCode != http.StatusOK {
120 return nil, fmt.Errorf("balance: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
121 }
122 var dr deepseekResp
123 if err := json.Unmarshal(body, &dr); err != nil {
124 return nil, fmt.Errorf("balance: decode: %w", err)
125 }
126 b := &Balance{Available: dr.IsAvailable}
127 for _, bi := range dr.BalanceInfos {
128 b.Infos = append(b.Infos, Info{
129 Currency: bi.Currency,
130 TotalBalance: bi.TotalBalance,
131 GrantedBalance: bi.GrantedBalance,
132 ToppedUpBalance: bi.ToppedUpBalance,
133 })
134 }
135 return b, nil
136 }
137
138 // symbol maps an ISO currency code to a compact symbol; an unknown code passes
139 // through with a trailing space ("XYZ 12.00").
140 func symbol(currency string) string {
141 switch strings.ToUpper(currency) {
142 case "CNY", "RMB":
143 return "¥"
144 case "USD":
145 return "$"
146 default:
147 if currency == "" {
148 return ""
149 }
150 return currency + " "
151 }
152 }
153
154 // Display renders the primary balance compactly, e.g. "¥110.00". It preserves
155 // the legacy CNY-first behavior for callers that have no display-currency
156 // preference.
157 func (b *Balance) Display() string {
158 return b.DisplayForCurrency("")
159 }
160
161 // DisplayForCurrency renders the balance matching the requested pricing
162 // currency. When the provider does not return that currency, it falls back to
163 // Display's legacy CNY-first selection and prefixes the provider's real ISO
164 // currency (for example "CNY ¥70.16"); it never performs an implicit
165 // exchange-rate conversion.
166 func (b *Balance) DisplayForCurrency(currency string) string {
167 if b == nil || len(b.Infos) == 0 {
168 return ""
169 }
170 pick := b.Infos[0]
171 preferred := normalizeCurrency(currency)
172 if preferred != "" {
173 for _, i := range b.Infos {
174 if normalizeCurrency(i.Currency) == preferred {
175 return symbol(i.Currency) + strings.TrimSpace(i.TotalBalance)
176 }
177 }
178 }
179 for _, i := range b.Infos {
180 if normalizeCurrency(i.Currency) == "CNY" {
181 pick = i
182 break
183 }
184 }
185 display := symbol(pick.Currency) + strings.TrimSpace(pick.TotalBalance)
186 actual := strings.ToUpper(strings.TrimSpace(pick.Currency))
187 if normalized := normalizeCurrency(actual); normalized != "" {
188 actual = normalized
189 }
190 if preferred != "" && actual != "" && actual != preferred {
191 return actual + " " + display
192 }
193 return display
194 }
195
196 func normalizeCurrency(currency string) string {
197 switch strings.ToUpper(strings.TrimSpace(currency)) {
198 case "CNY", "RMB", "CNH", "¥", "¥":
199 return "CNY"
200 case "USD", "$", "US$":
201 return "USD"
202 default:
203 return ""
204 }
205 }
206
206 lines GO