main.go 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. package main
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "io"
  6. "net/http"
  7. "os"
  8. "path/filepath"
  9. "strings"
  10. "time"
  11. "github.com/getlantern/systray"
  12. _ "embed" // 仅用于 //go:embed 指令
  13. )
  14. //go:embed icon.ico
  15. var appIcon []byte
  16. const (
  17. httpTimeout = 15 * time.Second
  18. tickInterval = 10 * time.Minute
  19. refreshMinutes = 10
  20. lowBalanceAlert = 1.0 // 单位:元 (CNY)
  21. defaultAPIURL = "https://api.deepseek.com/user/balance"
  22. )
  23. // main 中写入,之后所有 goroutine 只读。
  24. var (
  25. apiKey string
  26. balanceURL string
  27. exeDir string
  28. )
  29. // ---------- API ----------
  30. type balanceAPI struct {
  31. IsAvailable bool `json:"is_available"`
  32. BalanceInfos []balanceItem `json:"balance_infos"`
  33. }
  34. type balanceItem struct {
  35. Currency string `json:"currency"`
  36. TotalBalance string `json:"total_balance"`
  37. GrantedBalance string `json:"granted_balance"`
  38. ToppedUpBalance string `json:"topped_up_balance"`
  39. }
  40. // balanceResult 是查询结果的不可变快照。
  41. // ok 为 false 时 text 是错误消息;为 true 时 text 是纯数字金额(不含币种)。
  42. type balanceResult struct {
  43. ok bool
  44. text string
  45. currency string
  46. cny float64 // CNY 总额,失败时为 0
  47. }
  48. func main() {
  49. exeDir = executableDir()
  50. apiKey, balanceURL = loadConfig()
  51. systray.Run(onReady, func() {})
  52. }
  53. func onReady() {
  54. systray.SetIcon(appIcon)
  55. systray.SetTitle("DeepSeek")
  56. if apiKey != "" {
  57. systray.SetTooltip("DeepSeek 余额查询 - 正在获取...")
  58. } else {
  59. systray.SetTooltip("DeepSeek - 未配置 API Key\n请在 .env 文件中设置 DEEPSEEK_API_KEY")
  60. }
  61. mBalance := systray.AddMenuItem("余额: 查询中...", "")
  62. mBalance.Disable()
  63. mRefresh := systray.AddMenuItem("刷新余额", "手动刷新")
  64. mAuto := systray.AddMenuItem("自动刷新: 开启", "切换自动刷新")
  65. mAlert := systray.AddMenuItem("低余额告警: 关闭", "切换低余额告警")
  66. systray.AddSeparator()
  67. mDotEnv := systray.AddMenuItem("打开 .env 配置文件", "编辑 API Key")
  68. mAbout := systray.AddMenuItem("关于", "状态信息")
  69. mQuit := systray.AddMenuItem("退出", "关闭程序")
  70. autoRefresh := true
  71. alertOn := false
  72. balanceCh := make(chan balanceResult, 1) // timer 和首查均通过此 channel 传回结果
  73. var ticker *time.Ticker // 仅 startTicker 写入
  74. var tickerStop chan struct{}
  75. startTicker := func() {
  76. ticker = time.NewTicker(tickInterval)
  77. tickerStop = make(chan struct{})
  78. stop := tickerStop
  79. go func() {
  80. for {
  81. select {
  82. case <-ticker.C:
  83. if apiKey == "" {
  84. continue
  85. }
  86. r := queryAndUpdate(mBalance)
  87. select {
  88. case balanceCh <- r:
  89. default:
  90. }
  91. case <-stop:
  92. ticker.Stop()
  93. return
  94. }
  95. }
  96. }()
  97. }
  98. stopTicker := func() {
  99. if tickerStop != nil {
  100. close(tickerStop)
  101. tickerStop = nil
  102. }
  103. }
  104. startTicker()
  105. // 首次查询(结果同样送入 balanceCh,确保告警逻辑一致)
  106. go func() {
  107. if apiKey == "" {
  108. mBalance.SetTitle("余额: 未配置 Key")
  109. mBalance.SetTooltip("点击「打开 .env 配置文件」设置 API Key")
  110. return
  111. }
  112. r := queryAndUpdate(mBalance)
  113. select {
  114. case balanceCh <- r:
  115. default:
  116. }
  117. }()
  118. for {
  119. select {
  120. case r := <-balanceCh:
  121. if alertOn && r.cny > 0 && r.cny < lowBalanceAlert {
  122. alertLowBalance(r.cny)
  123. }
  124. case <-mRefresh.ClickedCh:
  125. if apiKey == "" {
  126. showMessageBox("DeepSeek Tray",
  127. "未配置 API Key。\n请点击「打开 .env 配置文件」设置 DEEPSEEK_API_KEY。")
  128. continue
  129. }
  130. queryAndUpdate(mBalance)
  131. case <-mAuto.ClickedCh:
  132. autoRefresh = !autoRefresh
  133. if autoRefresh {
  134. startTicker()
  135. mAuto.SetTitle("自动刷新: 开启")
  136. } else {
  137. stopTicker()
  138. mAuto.SetTitle("自动刷新: 关闭")
  139. }
  140. case <-mAlert.ClickedCh:
  141. alertOn = !alertOn
  142. if alertOn {
  143. mAlert.SetTitle("低余额告警: 开启")
  144. if apiKey != "" {
  145. r := queryAndUpdate(mBalance)
  146. if r.cny > 0 && r.cny < lowBalanceAlert {
  147. alertLowBalance(r.cny)
  148. }
  149. }
  150. } else {
  151. mAlert.SetTitle("低余额告警: 关闭")
  152. }
  153. case <-mDotEnv.ClickedCh:
  154. openDotEnvFile()
  155. case <-mAbout.ClickedCh:
  156. showAbout(autoRefresh, alertOn)
  157. case <-mQuit.ClickedCh:
  158. stopTicker()
  159. systray.Quit()
  160. return
  161. }
  162. }
  163. }
  164. // ---------- 查询 ----------
  165. func queryAndUpdate(m *systray.MenuItem) balanceResult {
  166. r := queryBalance()
  167. now := time.Now().Format("15:04:05")
  168. if r.ok {
  169. display := r.text + " " + r.currency
  170. tip := fmt.Sprintf("余额: %s | %s", display, now)
  171. systray.SetTooltip(tip)
  172. m.SetTitle("余额: " + display)
  173. m.SetTooltip(tip)
  174. systray.SetTitle(display)
  175. } else {
  176. tip := fmt.Sprintf("%s | %s", r.text, now)
  177. systray.SetTooltip(tip)
  178. m.SetTitle(r.text)
  179. m.SetTooltip(tip)
  180. systray.SetTitle("DeepSeek")
  181. }
  182. return r
  183. }
  184. func queryBalance() balanceResult {
  185. client := &http.Client{Timeout: httpTimeout}
  186. req, err := http.NewRequest(http.MethodGet, balanceURL, nil)
  187. if err != nil {
  188. return balanceResult{text: "创建请求失败"}
  189. }
  190. req.Header.Set("Authorization", "Bearer "+apiKey)
  191. req.Header.Set("Accept", "application/json")
  192. resp, err := client.Do(req)
  193. if err != nil {
  194. return balanceResult{text: "网络错误: " + err.Error()}
  195. }
  196. defer resp.Body.Close()
  197. if resp.StatusCode != http.StatusOK {
  198. if resp.StatusCode == http.StatusUnauthorized {
  199. return balanceResult{text: "API Key 无效"}
  200. }
  201. body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
  202. return balanceResult{text: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(body))}
  203. }
  204. var api balanceAPI
  205. if err := json.NewDecoder(resp.Body).Decode(&api); err != nil {
  206. return balanceResult{text: "解析响应失败"}
  207. }
  208. if !api.IsAvailable || len(api.BalanceInfos) == 0 {
  209. return balanceResult{text: "余额不可用"}
  210. }
  211. var cnyTotal float64
  212. for _, item := range api.BalanceInfos {
  213. if item.Currency == "CNY" {
  214. var v float64
  215. fmt.Sscanf(item.TotalBalance, "%f", &v)
  216. cnyTotal += v
  217. }
  218. }
  219. first := api.BalanceInfos[0]
  220. return balanceResult{
  221. ok: true,
  222. text: first.TotalBalance,
  223. currency: first.Currency,
  224. cny: cnyTotal,
  225. }
  226. }
  227. func alertLowBalance(cny float64) {
  228. showMessageBox("DeepSeek 余额不足",
  229. fmt.Sprintf("当前余额 ¥%.2f,低于阈值 ¥%.0f,请及时充值。", cny, lowBalanceAlert))
  230. }
  231. func showAbout(autoRefresh, alertOn bool) {
  232. auto := "否"
  233. if autoRefresh {
  234. auto = "是"
  235. }
  236. alert := "否"
  237. if alertOn {
  238. alert = "是"
  239. }
  240. showMessageBox("DeepSeek Balance Tray",
  241. fmt.Sprintf("自动刷新: %s\n刷新间隔: %d 分钟\n低余额告警: %s (阈值 ¥%.0f)",
  242. auto, refreshMinutes, alert, lowBalanceAlert))
  243. }
  244. // ---------- 配置加载 ----------
  245. // loadConfig 返回 (apiKey, balanceURL)。
  246. // apiKey 优先级:DEEPSEEK_API_KEY 环境变量 > .env 文件。
  247. // balanceURL 优先级:DEEPSEEK_BALANCE_URL 环境变量 > .env 文件 > defaultAPIURL。
  248. func loadConfig() (key, url string) {
  249. dotenv := readDotEnv(filepath.Join(exeDir, ".env"))
  250. // dotenv 可能为 nil;Go 读取 nil map 返回零值,安全。
  251. if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" {
  252. key = v
  253. } else if dotenv != nil {
  254. key = dotenv["DEEPSEEK_API_KEY"]
  255. }
  256. if v := os.Getenv("DEEPSEEK_BALANCE_URL"); v != "" {
  257. url = v
  258. } else if dotenv != nil {
  259. if v := dotenv["DEEPSEEK_BALANCE_URL"]; v != "" {
  260. url = v
  261. }
  262. }
  263. if url == "" {
  264. url = defaultAPIURL
  265. }
  266. return
  267. }
  268. func readDotEnv(path string) map[string]string {
  269. data, err := os.ReadFile(path)
  270. if err != nil {
  271. return nil
  272. }
  273. m := make(map[string]string)
  274. for _, raw := range strings.Split(string(data), "\n") {
  275. line := strings.TrimSpace(raw)
  276. if line == "" || line[0] == '#' {
  277. continue
  278. }
  279. eq := strings.IndexByte(line, '=')
  280. if eq < 1 {
  281. continue
  282. }
  283. k := strings.TrimSpace(line[:eq])
  284. if k == "" {
  285. continue
  286. }
  287. v := strings.TrimSpace(line[eq+1:])
  288. v = strings.Trim(v, `"'`)
  289. m[k] = v
  290. }
  291. return m
  292. }
  293. func openDotEnvFile() {
  294. path := filepath.Join(exeDir, ".env")
  295. if _, err := os.Stat(path); os.IsNotExist(err) {
  296. const tmpl = "# DeepSeek API Key(必填)\nDEEPSEEK_API_KEY=sk-your-key-here\n" +
  297. "\n# 余额查询接口(可选)\n# DEEPSEEK_BALANCE_URL=https://api.deepseek.com/user/balance\n"
  298. if err := os.WriteFile(path, []byte(tmpl), 0644); err != nil {
  299. showMessageBox("错误", "无法创建 .env 文件: "+err.Error())
  300. return
  301. }
  302. }
  303. runNotepad(path)
  304. }
  305. // ---------- 工具 ----------
  306. func executableDir() string {
  307. exe, err := os.Executable()
  308. if err != nil {
  309. wd, _ := os.Getwd()
  310. return wd
  311. }
  312. return filepath.Dir(exe)
  313. }