balance.go 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. package main
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "io"
  7. "net/http"
  8. "os"
  9. "time"
  10. )
  11. const (
  12. balanceHTTPTimeout = 15 * time.Second
  13. balanceRefreshMin = 10 // auto-refresh interval in minutes
  14. lowBalanceThreshold = 1.0 // alert when CNY total drops below this (元)
  15. )
  16. // envTemplate is written to .env when the user opens a missing config file.
  17. const envTemplate = "# DeepSeek API Key(必填)\nDEEPSEEK_API_KEY=sk-your-key-here\n\n# 余额查询接口(可选)\n# DEEPSEEK_BALANCE_URL=https://api.deepseek.com/user/balance\n"
  18. // BalanceAPI abstracts the balance endpoint so the tray feature is testable
  19. // without the network and without globals.
  20. type BalanceAPI interface {
  21. Query(ctx context.Context, apiKey string) (Balance, error)
  22. }
  23. // Balance is a successful balance response.
  24. type Balance struct {
  25. Available bool
  26. Items []BalanceItem
  27. }
  28. // BalanceItem is one currency row from the DeepSeek balance endpoint.
  29. type BalanceItem struct {
  30. Currency string `json:"currency"`
  31. TotalBalance string `json:"total_balance"`
  32. GrantedBalance string `json:"granted_balance"`
  33. ToppedUpBalance string `json:"topped_up_balance"`
  34. }
  35. type balanceResponse struct {
  36. IsAvailable bool `json:"is_available"`
  37. BalanceInfos []BalanceItem `json:"balance_infos"`
  38. }
  39. // httpBalanceAPI is the production BalanceAPI implementation.
  40. type httpBalanceAPI struct {
  41. URL string
  42. Client *http.Client
  43. }
  44. func (a httpBalanceAPI) Query(ctx context.Context, apiKey string) (Balance, error) {
  45. req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.URL, nil)
  46. if err != nil {
  47. return Balance{}, fmt.Errorf("创建请求失败: %w", err)
  48. }
  49. req.Header.Set("Authorization", "Bearer "+apiKey)
  50. req.Header.Set("Accept", "application/json")
  51. resp, err := a.Client.Do(req)
  52. if err != nil {
  53. return Balance{}, fmt.Errorf("网络错误: %w", err)
  54. }
  55. defer resp.Body.Close()
  56. if resp.StatusCode != http.StatusOK {
  57. if resp.StatusCode == http.StatusUnauthorized {
  58. return Balance{}, fmt.Errorf("API Key 无效")
  59. }
  60. body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
  61. return Balance{}, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
  62. }
  63. var api balanceResponse
  64. if err := json.NewDecoder(resp.Body).Decode(&api); err != nil {
  65. return Balance{}, fmt.Errorf("解析响应失败: %w", err)
  66. }
  67. if !api.IsAvailable || len(api.BalanceInfos) == 0 {
  68. return Balance{}, fmt.Errorf("余额不可用")
  69. }
  70. return Balance{Available: true, Items: api.BalanceInfos}, nil
  71. }
  72. // Display returns the primary display amount and its currency.
  73. func (b Balance) Display() (text, currency string) {
  74. if len(b.Items) == 0 {
  75. return "", ""
  76. }
  77. return b.Items[0].TotalBalance, b.Items[0].Currency
  78. }
  79. // CNYTotal sums every CNY row, used by the low-balance alert.
  80. func (b Balance) CNYTotal() float64 {
  81. var sum float64
  82. for _, it := range b.Items {
  83. if it.Currency == "CNY" {
  84. var v float64
  85. fmt.Sscanf(it.TotalBalance, "%f", &v)
  86. sum += v
  87. }
  88. }
  89. return sum
  90. }
  91. // balanceFeature implements Feature for the DeepSeek balance tray UI.
  92. type balanceFeature struct {
  93. cfg Config
  94. api BalanceAPI
  95. dotenvPath string
  96. autoRefresh bool // owned by the Run goroutine only
  97. alertOn bool // owned by the Run goroutine only
  98. menu *balanceMenu
  99. }
  100. type balanceMenu struct {
  101. balance *Item
  102. refresh *Item
  103. auto *Item
  104. alert *Item
  105. dotenv *Item
  106. about *Item
  107. quit *Item
  108. }
  109. func newBalanceFeature(cfg Config, dotenvPath string) *balanceFeature {
  110. return &balanceFeature{
  111. cfg: cfg,
  112. api: httpBalanceAPI{URL: cfg.BalanceURL, Client: &http.Client{Timeout: balanceHTTPTimeout}},
  113. dotenvPath: dotenvPath,
  114. autoRefresh: true,
  115. }
  116. }
  117. func (b *balanceFeature) Name() string { return "balance" }
  118. func (b *balanceFeature) Install(m *Menu) {
  119. b.menu = &balanceMenu{
  120. balance: m.AddItem("余额: 查询中...", ""),
  121. refresh: m.AddItem("刷新余额", "手动刷新"),
  122. auto: m.AddItem("自动刷新: 开启", "切换自动刷新"),
  123. alert: m.AddItem("低余额告警: 关闭", "切换低余额告警"),
  124. }
  125. b.menu.balance.Disable()
  126. m.AddSeparator()
  127. b.menu.dotenv = m.AddItem("打开 .env 配置文件", "编辑 API Key")
  128. b.menu.about = m.AddItem("关于", "状态信息")
  129. b.menu.quit = m.AddItem("退出", "关闭程序")
  130. if b.cfg.APIKey != "" {
  131. setTrayTooltip("DeepSeek 余额查询 - 正在获取...")
  132. } else {
  133. setTrayTooltip("DeepSeek - 未配置 API Key\n请在 .env 文件中设置 DEEPSEEK_API_KEY")
  134. }
  135. }
  136. func (b *balanceFeature) Stop() {}
  137. func (b *balanceFeature) Run(ctx context.Context) {
  138. results := make(chan queryResult, 1)
  139. go func() {
  140. if b.cfg.APIKey == "" {
  141. b.menu.balance.SetTitle("余额: 未配置 Key")
  142. b.menu.balance.SetTooltip("点击「打开 .env 配置文件」设置 API Key")
  143. return
  144. }
  145. b.queryInto(b.menu.balance, results)
  146. }()
  147. ticker := time.NewTicker(balanceRefreshMin * time.Minute)
  148. defer ticker.Stop()
  149. for {
  150. select {
  151. case r := <-results:
  152. if b.alertOn && r.cny > 0 && r.cny < lowBalanceThreshold {
  153. alertLowBalance(r.cny)
  154. }
  155. case <-ticker.C:
  156. if b.cfg.APIKey == "" || !b.autoRefresh {
  157. continue
  158. }
  159. b.queryInto(b.menu.balance, results)
  160. case <-b.menu.refresh.Clicked:
  161. if b.cfg.APIKey == "" {
  162. showMessageBox("DeepSeek Tray", "未配置 API Key。\n请点击「打开 .env 配置文件」设置 DEEPSEEK_API_KEY。")
  163. continue
  164. }
  165. b.queryInto(b.menu.balance, results)
  166. case <-b.menu.auto.Clicked:
  167. b.autoRefresh = !b.autoRefresh
  168. if b.autoRefresh {
  169. b.menu.auto.SetTitle("自动刷新: 开启")
  170. } else {
  171. b.menu.auto.SetTitle("自动刷新: 关闭")
  172. }
  173. case <-b.menu.alert.Clicked:
  174. b.alertOn = !b.alertOn
  175. if b.alertOn {
  176. b.menu.alert.SetTitle("低余额告警: 开启")
  177. if b.cfg.APIKey != "" {
  178. b.queryInto(b.menu.balance, results)
  179. }
  180. } else {
  181. b.menu.alert.SetTitle("低余额告警: 关闭")
  182. }
  183. case <-b.menu.dotenv.Clicked:
  184. openDotEnvFile(b.dotenvPath)
  185. case <-b.menu.about.Clicked:
  186. showAbout(b.autoRefresh, b.alertOn)
  187. case <-b.menu.quit.Clicked:
  188. quitTray()
  189. return
  190. case <-ctx.Done():
  191. return
  192. }
  193. }
  194. }
  195. // queryResult is a snapshot used for the low-balance alert decision.
  196. type queryResult struct {
  197. ok bool
  198. cny float64
  199. }
  200. // queryInto queries the balance endpoint, updates the tray UI, and reports the
  201. // result for the alert path (non-blocking).
  202. func (b *balanceFeature) queryInto(it *Item, results chan<- queryResult) {
  203. bal, err := b.api.Query(context.Background(), b.cfg.APIKey)
  204. now := time.Now().Format("15:04:05")
  205. var r queryResult
  206. if err == nil {
  207. text, currency := bal.Display()
  208. display := text + " " + currency
  209. tip := fmt.Sprintf("余额: %s | %s", display, now)
  210. setTrayTooltip(tip)
  211. it.SetTitle("余额: " + display)
  212. it.SetTooltip(tip)
  213. setTrayTitle(display)
  214. r = queryResult{ok: true, cny: bal.CNYTotal()}
  215. } else {
  216. tip := fmt.Sprintf("%s | %s", err.Error(), now)
  217. setTrayTooltip(tip)
  218. it.SetTitle(err.Error())
  219. it.SetTooltip(tip)
  220. setTrayTitle("DeepSeek")
  221. }
  222. select {
  223. case results <- r:
  224. default:
  225. }
  226. }
  227. func alertLowBalance(cny float64) {
  228. showMessageBox("DeepSeek 余额不足",
  229. fmt.Sprintf("当前余额 ¥%.2f,低于阈值 ¥%.0f,请及时充值。", cny, lowBalanceThreshold))
  230. }
  231. func showAbout(autoRefresh, alertOn bool) {
  232. showMessageBox("DeepSeek Balance Tray",
  233. fmt.Sprintf("自动刷新: %s\n刷新间隔: %d 分钟\n低余额告警: %s (阈值 ¥%.0f)",
  234. yesNo(autoRefresh), balanceRefreshMin, yesNo(alertOn), lowBalanceThreshold))
  235. }
  236. func yesNo(b bool) string {
  237. if b {
  238. return "是"
  239. }
  240. return "否"
  241. }
  242. // openDotEnvFile opens the .env file, creating a template first when absent.
  243. func openDotEnvFile(path string) {
  244. if _, err := os.Stat(path); os.IsNotExist(err) {
  245. if err := os.WriteFile(path, []byte(envTemplate), 0644); err != nil {
  246. showMessageBox("错误", "无法创建 .env 文件: "+err.Error())
  247. return
  248. }
  249. }
  250. runNotepad(path)
  251. }