main.go 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. package main
  2. import (
  3. "embed"
  4. "encoding/json"
  5. "fmt"
  6. "io"
  7. "net/http"
  8. "os"
  9. "sync"
  10. "time"
  11. "github.com/getlantern/systray"
  12. )
  13. // ---------- 嵌入资源 ----------
  14. //go:embed icon.ico
  15. var iconData []byte
  16. // 确保 embed 包被"使用"(Go 1.26+ 要求)
  17. var _ = embed.FS{}
  18. // ---------- 默认配置(可被环境变量覆盖) ----------
  19. var (
  20. apiKey = getEnv("DEEPSEEK_API_KEY", "")
  21. balanceURL = getEnv("DEEPSEEK_BALANCE_URL", "https://api.deepseek.com/user/balance")
  22. requestTimeout = 15 // 秒
  23. refreshMinutes = 10 // 自动刷新间隔,0 表示关闭
  24. lowThreshold = 1.0 // 余额低于此值(元)时告警
  25. )
  26. // ---------- 运行时状态 ----------
  27. var (
  28. lastBalance string
  29. lastUpdate time.Time
  30. mu sync.RWMutex
  31. )
  32. // ---------- API 响应结构 ----------
  33. type balanceResponse struct {
  34. IsAvailable bool `json:"is_available"`
  35. BalanceInfos []struct {
  36. Currency string `json:"currency"`
  37. TotalBalance string `json:"total_balance"`
  38. GrantedBalance string `json:"granted_balance"`
  39. ToppedUpBalance string `json:"topped_up_balance"`
  40. } `json:"balance_infos"`
  41. }
  42. func main() {
  43. if apiKey == "" {
  44. fmt.Fprintln(os.Stderr, "错误: 环境变量 DEEPSEEK_API_KEY 未设置")
  45. fmt.Fprintln(os.Stderr, "请设置后运行: set DEEPSEEK_API_KEY=sk-your-key")
  46. os.Exit(1)
  47. }
  48. systray.Run(onReady, onExit)
  49. }
  50. func onReady() {
  51. systray.SetIcon(iconData)
  52. systray.SetTitle("DeepSeek")
  53. systray.SetTooltip("DeepSeek 余额查询 - 正在获取...")
  54. // 菜单项
  55. mBalance := systray.AddMenuItem("余额: 查询中...", "")
  56. mBalance.Disable()
  57. mManualRefresh := systray.AddMenuItem("刷新余额", "手动刷新")
  58. mAutoRefresh := systray.AddMenuItem("自动刷新: 开启", "切换自动刷新")
  59. mThreshold := systray.AddMenuItem("低余额告警: 关闭", "切换低余额告警")
  60. systray.AddSeparator()
  61. mAbout := systray.AddMenuItem("关于", "关于本程序")
  62. mQuit := systray.AddMenuItem("退出", "关闭程序")
  63. // 首次查询
  64. go refreshBalance(mBalance)
  65. // 定时器控制
  66. var (
  67. ticker *time.Ticker
  68. tickerStop chan struct{}
  69. autoRefresh = true
  70. alertOn = false
  71. )
  72. startTicker := func() {
  73. if refreshMinutes <= 0 {
  74. return
  75. }
  76. ticker = time.NewTicker(time.Duration(refreshMinutes) * time.Minute)
  77. tickerStop = make(chan struct{})
  78. go func() {
  79. for {
  80. select {
  81. case <-ticker.C:
  82. refreshBalance(mBalance)
  83. if alertOn {
  84. checkLowBalance()
  85. }
  86. case <-tickerStop:
  87. ticker.Stop()
  88. return
  89. }
  90. }
  91. }()
  92. }
  93. stopTicker := func() {
  94. if tickerStop != nil {
  95. close(tickerStop)
  96. tickerStop = nil
  97. }
  98. }
  99. startTicker()
  100. // 事件循环
  101. for {
  102. select {
  103. case <-mManualRefresh.ClickedCh:
  104. refreshBalance(mBalance)
  105. case <-mAutoRefresh.ClickedCh:
  106. if autoRefresh {
  107. stopTicker()
  108. mAutoRefresh.SetTitle("自动刷新: 关闭")
  109. autoRefresh = false
  110. } else {
  111. startTicker()
  112. mAutoRefresh.SetTitle("自动刷新: 开启")
  113. autoRefresh = true
  114. }
  115. case <-mThreshold.ClickedCh:
  116. alertOn = !alertOn
  117. if alertOn {
  118. mThreshold.SetTitle("低余额告警: 开启")
  119. mThreshold.SetTooltip(fmt.Sprintf("余额低于 ¥%.0f 时提醒", lowThreshold))
  120. checkLowBalance()
  121. } else {
  122. mThreshold.SetTitle("低余额告警: 关闭")
  123. mThreshold.SetTooltip("切换低余额告警")
  124. }
  125. case <-mAbout.ClickedCh:
  126. systray.SetTooltip(fmt.Sprintf(
  127. "自动刷新:%s | 间隔:%dmin | 告警:%s",
  128. boolToStr(autoRefresh), refreshMinutes, boolToStr(alertOn),
  129. ))
  130. case <-mQuit.ClickedCh:
  131. stopTicker()
  132. systray.Quit()
  133. return
  134. }
  135. }
  136. }
  137. func onExit() {
  138. // 清理
  139. }
  140. // refreshBalance 请求 API 并更新托盘状态
  141. func refreshBalance(menuItem *systray.MenuItem) {
  142. text, currency, totalVal := fetchBalance()
  143. mu.Lock()
  144. lastBalance = text
  145. lastUpdate = time.Now()
  146. mu.Unlock()
  147. tooltip := fmt.Sprintf("DeepSeek 余额: %s %s\n更新时间: %s",
  148. text, currency, lastUpdate.Format("15:04:05"))
  149. systray.SetTooltip(tooltip)
  150. menuItem.SetTitle("余额: " + text + " " + currency)
  151. menuItem.SetTooltip(tooltip)
  152. // 更新程序标题
  153. if totalVal > 0 {
  154. systray.SetTitle(text)
  155. } else {
  156. systray.SetTitle("DeepSeek")
  157. }
  158. }
  159. // fetchBalance 返回 (余额文本, 货币, 数值用于比较)
  160. func fetchBalance() (string, string, float64) {
  161. client := &http.Client{Timeout: time.Duration(requestTimeout) * time.Second}
  162. req, err := http.NewRequest("GET", balanceURL, nil)
  163. if err != nil {
  164. return "创建请求失败", "", 0
  165. }
  166. req.Header.Set("Authorization", "Bearer "+apiKey)
  167. req.Header.Set("Accept", "application/json")
  168. resp, err := client.Do(req)
  169. if err != nil {
  170. return "请求失败: " + err.Error(), "", 0
  171. }
  172. defer resp.Body.Close()
  173. if resp.StatusCode == http.StatusUnauthorized {
  174. return "API Key 无效", "", 0
  175. }
  176. if resp.StatusCode != http.StatusOK {
  177. body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
  178. return fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(body)), "", 0
  179. }
  180. var br balanceResponse
  181. if err := json.NewDecoder(resp.Body).Decode(&br); err != nil {
  182. return "解析响应失败", "", 0
  183. }
  184. if !br.IsAvailable || len(br.BalanceInfos) == 0 {
  185. return "余额不可用", "", 0
  186. }
  187. // 合并所有币种
  188. var parts []string
  189. var total float64
  190. currency := "CNY"
  191. for _, info := range br.BalanceInfos {
  192. var val float64
  193. fmt.Sscanf(info.TotalBalance, "%f", &val)
  194. parts = append(parts, fmt.Sprintf("%s %s", info.TotalBalance, info.Currency))
  195. if info.Currency == "CNY" {
  196. total += val
  197. }
  198. currency = info.Currency
  199. }
  200. if len(parts) == 1 {
  201. return parts[0], currency, total
  202. }
  203. return fmt.Sprintf("%v", parts), currency, total
  204. }
  205. // checkLowBalance 检查余额是否低于阈值,通过弹窗告警(使用 Windows API)
  206. func checkLowBalance() {
  207. _, _, totalVal := fetchBalance()
  208. if totalVal > 0 && totalVal < lowThreshold {
  209. msg := fmt.Sprintf("当前余额 ¥%.2f,低于阈值 ¥%.0f,请及时充值。", totalVal, lowThreshold)
  210. showMessageBox("⚠️ DeepSeek 余额不足", msg)
  211. }
  212. }
  213. // ---------- 工具函数 ----------
  214. func getEnv(key, defaultVal string) string {
  215. if val, ok := os.LookupEnv(key); ok && val != "" {
  216. return val
  217. }
  218. return defaultVal
  219. }
  220. func boolToStr(b bool) string {
  221. if b {
  222. return "是"
  223. }
  224. return "否"
  225. }