main.go 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. package main
  2. import (
  3. "embed"
  4. "encoding/json"
  5. "fmt"
  6. "io"
  7. "net/http"
  8. "os"
  9. "path/filepath"
  10. "strings"
  11. "time"
  12. "github.com/getlantern/systray"
  13. )
  14. //go:embed icon.ico
  15. var icon []byte
  16. var _ = embed.FS{} // 保证 Go 1.26+ embed 引用检查
  17. // ---------- 配置常量 ----------
  18. const (
  19. apiTimeout = 15 * time.Second // HTTP 请求超时
  20. refreshPeriod = 10 * time.Minute // 自动刷新间隔
  21. lowBalanceYuan = 1.0 // 低余额告警阈值(元)
  22. balanceDefaultURL = "https://api.deepseek.com/user/balance"
  23. )
  24. var (
  25. apiKey string
  26. balanceURL = balanceDefaultURL
  27. )
  28. // ---------- API 响应 ----------
  29. type balanceResp struct {
  30. IsAvailable bool `json:"is_available"`
  31. BalanceInfos []balanceItem `json:"balance_infos"`
  32. }
  33. type balanceItem struct {
  34. Currency string `json:"currency"`
  35. TotalBalance string `json:"total_balance"`
  36. GrantedBalance string `json:"granted_balance"`
  37. ToppedUpBalance string `json:"topped_up_balance"`
  38. }
  39. // ---------- 入口 ----------
  40. func main() {
  41. loadDotEnv()
  42. apiKey = envOr("DEEPSEEK_API_KEY", "")
  43. if v := envOr("DEEPSEEK_BALANCE_URL", ""); v != "" {
  44. balanceURL = v
  45. }
  46. systray.Run(onReady, func() {})
  47. }
  48. func onReady() {
  49. systray.SetIcon(icon)
  50. systray.SetTitle("DeepSeek")
  51. if apiKey == "" {
  52. systray.SetTooltip("DeepSeek - 未配置 API Key\n请在 .env 文件中设置 DEEPSEEK_API_KEY")
  53. } else {
  54. systray.SetTooltip("DeepSeek 余额查询 - 正在获取...")
  55. }
  56. // 菜单
  57. mBalance := systray.AddMenuItem("余额: 查询中...", "")
  58. mBalance.Disable()
  59. mRefresh := systray.AddMenuItem("刷新余额", "手动刷新")
  60. mAuto := systray.AddMenuItem("自动刷新: 开启", "切换自动刷新")
  61. mAlert := systray.AddMenuItem("低余额告警: 关闭", "切换低余额告警")
  62. systray.AddSeparator()
  63. mDotEnv := systray.AddMenuItem("打开 .env 配置文件", "编辑 API Key")
  64. mAbout := systray.AddMenuItem("关于", "状态信息")
  65. mQuit := systray.AddMenuItem("退出", "关闭程序")
  66. // 运行时状态(仅在事件循环 goroutine 中访问,无数据竞争)
  67. autoRefresh := true
  68. alertOn := false
  69. var ticker *time.Ticker
  70. var tickStop chan struct{}
  71. startTick := func() {
  72. ticker = time.NewTicker(refreshPeriod)
  73. tickStop = make(chan struct{})
  74. go func() {
  75. for {
  76. select {
  77. case <-ticker.C:
  78. if apiKey != "" {
  79. balance, _ := refreshAndUpdate(mBalance)
  80. if alertOn && balance < lowBalanceYuan && balance > 0 {
  81. showMessageBox("DeepSeek 余额不足",
  82. fmt.Sprintf("当前余额 ¥%.2f,低于阈值 ¥%.0f,请及时充值。", balance, lowBalanceYuan))
  83. }
  84. }
  85. case <-tickStop:
  86. ticker.Stop()
  87. return
  88. }
  89. }
  90. }()
  91. }
  92. stopTick := func() {
  93. if tickStop != nil {
  94. close(tickStop)
  95. tickStop = nil
  96. }
  97. }
  98. startTick()
  99. // 首次查询
  100. go func() {
  101. if apiKey == "" {
  102. mBalance.SetTitle("余额: 未配置 Key")
  103. mBalance.SetTooltip("点击「打开 .env 配置文件」设置 API Key")
  104. } else {
  105. refreshAndUpdate(mBalance)
  106. }
  107. }()
  108. // 事件循环
  109. for {
  110. select {
  111. case <-mRefresh.ClickedCh:
  112. if apiKey == "" {
  113. showMessageBox("DeepSeek Tray",
  114. "未配置 API Key。\n请点击「打开 .env 配置文件」设置 DEEPSEEK_API_KEY。")
  115. } else {
  116. refreshAndUpdate(mBalance)
  117. }
  118. case <-mAuto.ClickedCh:
  119. if autoRefresh {
  120. stopTick()
  121. mAuto.SetTitle("自动刷新: 关闭")
  122. } else {
  123. startTick()
  124. mAuto.SetTitle("自动刷新: 开启")
  125. }
  126. autoRefresh = !autoRefresh
  127. case <-mAlert.ClickedCh:
  128. alertOn = !alertOn
  129. if alertOn {
  130. mAlert.SetTitle("低余额告警: 开启")
  131. mAlert.SetTooltip(fmt.Sprintf("余额低于 ¥%.0f 时提醒", lowBalanceYuan))
  132. if apiKey != "" {
  133. balance, _ := refreshAndUpdate(mBalance)
  134. if balance < lowBalanceYuan && balance > 0 {
  135. showMessageBox("DeepSeek 余额不足",
  136. fmt.Sprintf("当前余额 ¥%.2f,低于阈值 ¥%.0f,请及时充值。", balance, lowBalanceYuan))
  137. }
  138. }
  139. } else {
  140. mAlert.SetTitle("低余额告警: 关闭")
  141. mAlert.SetTooltip("切换低余额告警")
  142. }
  143. case <-mDotEnv.ClickedCh:
  144. openDotEnv()
  145. case <-mAbout.ClickedCh:
  146. ar := "否"
  147. if autoRefresh {
  148. ar = "是"
  149. }
  150. ao := "否"
  151. if alertOn {
  152. ao = "是"
  153. }
  154. showMessageBox("DeepSeek Balance Tray",
  155. fmt.Sprintf("DeepSeek 余额查询系统托盘\n\n自动刷新: %s\n刷新间隔: %d 分钟\n低余额告警: %s\n\n配置文件: .env",
  156. ar, int(refreshPeriod.Minutes()), ao))
  157. case <-mQuit.ClickedCh:
  158. stopTick()
  159. systray.Quit()
  160. return
  161. }
  162. }
  163. }
  164. // ---------- 核心:余额获取与 UI 更新 ----------
  165. // refreshAndUpdate 请求 API 并更新托盘状态,返回 CNY 余额数值。
  166. func refreshAndUpdate(m *systray.MenuItem) (cnyBalance float64, err error) {
  167. text, currency, cny := fetchBalance()
  168. tooltip := fmt.Sprintf("DeepSeek 余额: %s %s\n更新时间: %s",
  169. text, currency, time.Now().Format("15:04:05"))
  170. systray.SetTooltip(tooltip)
  171. m.SetTitle("余额: " + text + " " + currency)
  172. m.SetTooltip(tooltip)
  173. if cny > 0 {
  174. systray.SetTitle(text)
  175. } else {
  176. systray.SetTitle("DeepSeek")
  177. }
  178. return cny, nil
  179. }
  180. // fetchBalance 返回 (余额文本, 货币, CNY 数值)。
  181. func fetchBalance() (string, string, float64) {
  182. client := &http.Client{Timeout: apiTimeout}
  183. req, err := http.NewRequest("GET", balanceURL, nil)
  184. if err != nil {
  185. return "创建请求失败", "", 0
  186. }
  187. req.Header.Set("Authorization", "Bearer "+apiKey)
  188. req.Header.Set("Accept", "application/json")
  189. resp, err := client.Do(req)
  190. if err != nil {
  191. return "网络错误: " + err.Error(), "", 0
  192. }
  193. defer resp.Body.Close()
  194. if resp.StatusCode == http.StatusUnauthorized {
  195. return "API Key 无效", "", 0
  196. }
  197. if resp.StatusCode != http.StatusOK {
  198. body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
  199. return fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(body)), "", 0
  200. }
  201. var br balanceResp
  202. if err := json.NewDecoder(resp.Body).Decode(&br); err != nil {
  203. return "解析响应失败", "", 0
  204. }
  205. if !br.IsAvailable || len(br.BalanceInfos) == 0 {
  206. return "余额不可用", "", 0
  207. }
  208. // 取第一个币种为主显示,叠加所有 CNY 为数值
  209. info := br.BalanceInfos[0]
  210. first := fmt.Sprintf("%s %s", info.TotalBalance, info.Currency)
  211. var cnyTotal float64
  212. for _, item := range br.BalanceInfos {
  213. var v float64
  214. fmt.Sscanf(item.TotalBalance, "%f", &v)
  215. if item.Currency == "CNY" {
  216. cnyTotal += v
  217. }
  218. }
  219. return first, info.Currency, cnyTotal
  220. }
  221. // ---------- .env 支持 ----------
  222. func loadDotEnv() {
  223. dir := exeDir()
  224. if dir == "" {
  225. return
  226. }
  227. data, err := os.ReadFile(filepath.Join(dir, ".env"))
  228. if err != nil {
  229. return
  230. }
  231. for _, line := range strings.Split(string(data), "\n") {
  232. line = strings.TrimSpace(line)
  233. if line == "" || line[0] == '#' {
  234. continue
  235. }
  236. idx := strings.IndexByte(line, '=')
  237. if idx < 0 {
  238. continue
  239. }
  240. key := strings.TrimSpace(line[:idx])
  241. val := strings.Trim(strings.TrimSpace(line[idx+1:]), `"`)
  242. // .env 不覆盖已有环境变量
  243. if os.Getenv(key) == "" && key != "" {
  244. os.Setenv(key, val)
  245. }
  246. }
  247. }
  248. func openDotEnv() {
  249. dir := exeDir()
  250. if dir == "" {
  251. showMessageBox("错误", "无法获取程序路径")
  252. return
  253. }
  254. path := filepath.Join(dir, ".env")
  255. if _, err := os.Stat(path); os.IsNotExist(err) {
  256. tmpl := "# DeepSeek API Key(必填)\nDEEPSEEK_API_KEY=sk-your-key-here\n\n# 余额查询接口(可选)\n# DEEPSEEK_BALANCE_URL=https://api.deepseek.com/user/balance\n"
  257. if err := os.WriteFile(path, []byte(tmpl), 0644); err != nil {
  258. showMessageBox("错误", "无法创建 .env 文件: "+err.Error())
  259. return
  260. }
  261. }
  262. runNotepad(path)
  263. }
  264. // exeDir 返回可执行文件所在目录路径。
  265. func exeDir() string {
  266. p, err := os.Executable()
  267. if err != nil {
  268. return ""
  269. }
  270. return filepath.Dir(p)
  271. }
  272. // ---------- 工具 ----------
  273. func envOr(key, def string) string {
  274. if v, ok := os.LookupEnv(key); ok && v != "" {
  275. return v
  276. }
  277. return def
  278. }