main.go 8.4 KB

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