main.go 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  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. "sync"
  12. "time"
  13. "github.com/getlantern/systray"
  14. )
  15. // ---------- 嵌入资源 ----------
  16. //go:embed icon.ico
  17. var iconData []byte
  18. // 确保 embed 包被"使用"(Go 1.26+ 要求)
  19. var _ = embed.FS{}
  20. // ---------- 配置(优先级:环境变量 > .env 文件 > 默认值) ----------
  21. var (
  22. apiKey string
  23. balanceURL string
  24. requestTimeout = 15 // 秒
  25. refreshMinutes = 10 // 自动刷新间隔,0 表示关闭
  26. lowThreshold = 1.0 // 余额低于此值(元)时告警
  27. )
  28. // ---------- 运行时状态 ----------
  29. var (
  30. lastBalance string
  31. lastUpdate time.Time
  32. mu sync.RWMutex
  33. )
  34. // ---------- API 响应结构 ----------
  35. type balanceResponse struct {
  36. IsAvailable bool `json:"is_available"`
  37. BalanceInfos []struct {
  38. Currency string `json:"currency"`
  39. TotalBalance string `json:"total_balance"`
  40. GrantedBalance string `json:"granted_balance"`
  41. ToppedUpBalance string `json:"topped_up_balance"`
  42. } `json:"balance_infos"`
  43. }
  44. func main() {
  45. // 加载 .env 文件(同级目录),不覆盖已有环境变量
  46. loadEnvFile()
  47. // 读取配置
  48. apiKey = getEnv("DEEPSEEK_API_KEY", "")
  49. balanceURL = getEnv("DEEPSEEK_BALANCE_URL", "https://api.deepseek.com/user/balance")
  50. // 始终启动托盘(即使无 Key,也显示图标+提示)
  51. systray.Run(onReady, onExit)
  52. }
  53. func onReady() {
  54. systray.SetIcon(iconData)
  55. systray.SetTitle("DeepSeek")
  56. if apiKey == "" {
  57. systray.SetTooltip("DeepSeek - 未配置 API Key\n请在 .env 文件中设置 DEEPSEEK_API_KEY")
  58. } else {
  59. systray.SetTooltip("DeepSeek 余额查询 - 正在获取...")
  60. }
  61. // 菜单项
  62. mBalance := systray.AddMenuItem("余额: 查询中...", "")
  63. mBalance.Disable()
  64. mManualRefresh := systray.AddMenuItem("刷新余额", "手动刷新")
  65. mAutoRefresh := systray.AddMenuItem("自动刷新: 开启", "切换自动刷新")
  66. mThreshold := systray.AddMenuItem("低余额告警: 关闭", "切换低余额告警")
  67. systray.AddSeparator()
  68. mConfigPath := systray.AddMenuItem("打开 .env 配置文件", "用记事本编辑 .env")
  69. mAbout := systray.AddMenuItem("关于", "关于本程序")
  70. mQuit := systray.AddMenuItem("退出", "关闭程序")
  71. // 首次查询
  72. go func() {
  73. if apiKey == "" {
  74. mBalance.SetTitle("余额: 未配置 Key")
  75. mBalance.SetTooltip("点击「打开 .env 配置文件」设置 API Key")
  76. } else {
  77. refreshBalance(mBalance)
  78. }
  79. }()
  80. // 定时器控制
  81. var (
  82. ticker *time.Ticker
  83. tickerStop chan struct{}
  84. autoRefresh = true
  85. alertOn = false
  86. )
  87. startTicker := func() {
  88. if refreshMinutes <= 0 {
  89. return
  90. }
  91. ticker = time.NewTicker(time.Duration(refreshMinutes) * time.Minute)
  92. tickerStop = make(chan struct{})
  93. go func() {
  94. for {
  95. select {
  96. case <-ticker.C:
  97. if apiKey != "" {
  98. refreshBalance(mBalance)
  99. if alertOn {
  100. checkLowBalance()
  101. }
  102. }
  103. case <-tickerStop:
  104. ticker.Stop()
  105. return
  106. }
  107. }
  108. }()
  109. }
  110. stopTicker := func() {
  111. if tickerStop != nil {
  112. close(tickerStop)
  113. tickerStop = nil
  114. }
  115. }
  116. startTicker()
  117. // 事件循环
  118. for {
  119. select {
  120. case <-mManualRefresh.ClickedCh:
  121. if apiKey == "" {
  122. showMessageBox("DeepSeek Tray", "未配置 API Key。\n请点击右键菜单「打开 .env 配置文件」设置 DEEPSEEK_API_KEY。")
  123. } else {
  124. refreshBalance(mBalance)
  125. }
  126. case <-mAutoRefresh.ClickedCh:
  127. if autoRefresh {
  128. stopTicker()
  129. mAutoRefresh.SetTitle("自动刷新: 关闭")
  130. autoRefresh = false
  131. } else {
  132. startTicker()
  133. mAutoRefresh.SetTitle("自动刷新: 开启")
  134. autoRefresh = true
  135. }
  136. case <-mThreshold.ClickedCh:
  137. alertOn = !alertOn
  138. if alertOn {
  139. mThreshold.SetTitle("低余额告警: 开启")
  140. mThreshold.SetTooltip(fmt.Sprintf("余额低于 ¥%.0f 时提醒", lowThreshold))
  141. if apiKey != "" {
  142. checkLowBalance()
  143. }
  144. } else {
  145. mThreshold.SetTitle("低余额告警: 关闭")
  146. mThreshold.SetTooltip("切换低余额告警")
  147. }
  148. case <-mConfigPath.ClickedCh:
  149. openEnvFile()
  150. case <-mAbout.ClickedCh:
  151. info := fmt.Sprintf("DeepSeek 余额查询系统托盘\n\n自动刷新: %s\n刷新间隔: %d 分钟\n低余额告警: %s\n\n配置文件: deepseek-tray.exe 同目录下的 .env",
  152. boolToStr(autoRefresh), refreshMinutes, boolToStr(alertOn))
  153. showMessageBox("DeepSeek Balance Tray", info)
  154. case <-mQuit.ClickedCh:
  155. stopTicker()
  156. systray.Quit()
  157. return
  158. }
  159. }
  160. }
  161. func onExit() {
  162. // 清理
  163. }
  164. // refreshBalance 请求 API 并更新托盘状态
  165. func refreshBalance(menuItem *systray.MenuItem) {
  166. text, currency, totalVal := fetchBalance()
  167. mu.Lock()
  168. lastBalance = text
  169. lastUpdate = time.Now()
  170. mu.Unlock()
  171. tooltip := fmt.Sprintf("DeepSeek 余额: %s %s\n更新时间: %s",
  172. text, currency, lastUpdate.Format("15:04:05"))
  173. systray.SetTooltip(tooltip)
  174. menuItem.SetTitle("余额: " + text + " " + currency)
  175. menuItem.SetTooltip(tooltip)
  176. // 更新程序标题(显示在托盘旁)
  177. if totalVal > 0 {
  178. systray.SetTitle(text)
  179. } else {
  180. systray.SetTitle("DeepSeek")
  181. }
  182. }
  183. // fetchBalance 返回 (余额文本, 货币, 数值用于比较)
  184. func fetchBalance() (string, string, float64) {
  185. client := &http.Client{Timeout: time.Duration(requestTimeout) * time.Second}
  186. req, err := http.NewRequest("GET", balanceURL, nil)
  187. if err != nil {
  188. return "创建请求失败", "", 0
  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 "网络错误: " + err.Error(), "", 0
  195. }
  196. defer resp.Body.Close()
  197. if resp.StatusCode == http.StatusUnauthorized {
  198. return "API Key 无效", "", 0
  199. }
  200. if resp.StatusCode != http.StatusOK {
  201. body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
  202. return fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(body)), "", 0
  203. }
  204. var br balanceResponse
  205. if err := json.NewDecoder(resp.Body).Decode(&br); err != nil {
  206. return "解析响应失败", "", 0
  207. }
  208. if !br.IsAvailable || len(br.BalanceInfos) == 0 {
  209. return "余额不可用", "", 0
  210. }
  211. // 合并所有币种
  212. var parts []string
  213. var total float64
  214. currency := "CNY"
  215. for _, info := range br.BalanceInfos {
  216. var val float64
  217. fmt.Sscanf(info.TotalBalance, "%f", &val)
  218. parts = append(parts, fmt.Sprintf("%s %s", info.TotalBalance, info.Currency))
  219. if info.Currency == "CNY" {
  220. total += val
  221. }
  222. currency = info.Currency
  223. }
  224. if len(parts) == 1 {
  225. return parts[0], currency, total
  226. }
  227. return fmt.Sprintf("%v", parts), currency, total
  228. }
  229. // checkLowBalance 检查余额是否低于阈值,通过弹窗告警
  230. func checkLowBalance() {
  231. _, _, totalVal := fetchBalance()
  232. if totalVal > 0 && totalVal < lowThreshold {
  233. msg := fmt.Sprintf("当前余额 ¥%.2f,低于阈值 ¥%.0f,请及时充值。", totalVal, lowThreshold)
  234. showMessageBox("⚠️ DeepSeek 余额不足", msg)
  235. }
  236. }
  237. // ---------- .env 文件支持 ----------
  238. // loadEnvFile 从可执行文件同级目录读取 .env 文件(不覆盖已有环境变量)
  239. func loadEnvFile() {
  240. exePath, err := os.Executable()
  241. if err != nil {
  242. return
  243. }
  244. envPath := filepath.Join(filepath.Dir(exePath), ".env")
  245. data, err := os.ReadFile(envPath)
  246. if err != nil {
  247. return // .env 不存在,静默跳过
  248. }
  249. lines := strings.Split(string(data), "\n")
  250. for _, line := range lines {
  251. line = strings.TrimSpace(line)
  252. if line == "" || strings.HasPrefix(line, "#") {
  253. continue
  254. }
  255. // 支持 KEY=VALUE 或 KEY="VALUE" 格式
  256. idx := strings.IndexByte(line, '=')
  257. if idx < 0 {
  258. continue
  259. }
  260. key := strings.TrimSpace(line[:idx])
  261. val := strings.TrimSpace(line[idx+1:])
  262. val = strings.Trim(val, `"`) // 移除双引号
  263. // 不覆盖已有环境变量
  264. if os.Getenv(key) == "" {
  265. os.Setenv(key, val)
  266. }
  267. }
  268. }
  269. // openEnvFile 用记事本打开 .env 文件,若不存在则创建模板
  270. func openEnvFile() {
  271. exePath, err := os.Executable()
  272. if err != nil {
  273. showMessageBox("错误", "无法获取程序路径")
  274. return
  275. }
  276. envPath := filepath.Join(filepath.Dir(exePath), ".env")
  277. // 如果 .env 不存在,创建模板
  278. if _, err := os.Stat(envPath); os.IsNotExist(err) {
  279. tmpl := "# DeepSeek API Key(必填)\nDEEPSEEK_API_KEY=sk-your-key-here\n\n# DeepSeek 余额查询接口(可选)\n# DEEPSEEK_BALANCE_URL=https://api.deepseek.com/user/balance\n"
  280. if err := os.WriteFile(envPath, []byte(tmpl), 0644); err != nil {
  281. showMessageBox("错误", "无法创建 .env 文件: "+err.Error())
  282. return
  283. }
  284. }
  285. // 调用记事本打开
  286. runNotepad(envPath)
  287. }
  288. // ---------- 工具函数 ----------
  289. func getEnv(key, defaultVal string) string {
  290. // 先查环境变量,再查 .env(已在 loadEnvFile 中注入到 os.Environ)
  291. if val, ok := os.LookupEnv(key); ok && val != "" {
  292. return val
  293. }
  294. return defaultVal
  295. }
  296. func boolToStr(b bool) string {
  297. if b {
  298. return "是"
  299. }
  300. return "否"
  301. }