package main import ( "embed" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" "github.com/getlantern/systray" ) //go:embed icon.ico var icon []byte var _ = embed.FS{} // 保证 Go 1.26+ embed 引用检查 // ---------- 配置常量 ---------- const ( apiTimeout = 15 * time.Second // HTTP 请求超时 refreshPeriod = 10 * time.Minute // 自动刷新间隔 lowBalanceYuan = 1.0 // 低余额告警阈值(元) balanceDefaultURL = "https://api.deepseek.com/user/balance" ) var ( apiKey string balanceURL = balanceDefaultURL ) // ---------- API 响应 ---------- type balanceResp struct { IsAvailable bool `json:"is_available"` BalanceInfos []balanceItem `json:"balance_infos"` } type balanceItem struct { Currency string `json:"currency"` TotalBalance string `json:"total_balance"` GrantedBalance string `json:"granted_balance"` ToppedUpBalance string `json:"topped_up_balance"` } // ---------- 入口 ---------- func main() { loadDotEnv() apiKey = envOr("DEEPSEEK_API_KEY", "") if v := envOr("DEEPSEEK_BALANCE_URL", ""); v != "" { balanceURL = v } systray.Run(onReady, func() {}) } func onReady() { systray.SetIcon(icon) systray.SetTitle("DeepSeek") if apiKey == "" { systray.SetTooltip("DeepSeek - 未配置 API Key\n请在 .env 文件中设置 DEEPSEEK_API_KEY") } else { systray.SetTooltip("DeepSeek 余额查询 - 正在获取...") } // 菜单 mBalance := systray.AddMenuItem("余额: 查询中...", "") mBalance.Disable() mRefresh := systray.AddMenuItem("刷新余额", "手动刷新") mAuto := systray.AddMenuItem("自动刷新: 开启", "切换自动刷新") mAlert := systray.AddMenuItem("低余额告警: 关闭", "切换低余额告警") systray.AddSeparator() mDotEnv := systray.AddMenuItem("打开 .env 配置文件", "编辑 API Key") mAbout := systray.AddMenuItem("关于", "状态信息") mQuit := systray.AddMenuItem("退出", "关闭程序") // 运行时状态(仅在事件循环 goroutine 中访问,无数据竞争) autoRefresh := true alertOn := false var ticker *time.Ticker var tickStop chan struct{} startTick := func() { ticker = time.NewTicker(refreshPeriod) tickStop = make(chan struct{}) go func() { for { select { case <-ticker.C: if apiKey != "" { balance, _ := refreshAndUpdate(mBalance) if alertOn && balance < lowBalanceYuan && balance > 0 { showMessageBox("DeepSeek 余额不足", fmt.Sprintf("当前余额 ¥%.2f,低于阈值 ¥%.0f,请及时充值。", balance, lowBalanceYuan)) } } case <-tickStop: ticker.Stop() return } } }() } stopTick := func() { if tickStop != nil { close(tickStop) tickStop = nil } } startTick() // 首次查询 go func() { if apiKey == "" { mBalance.SetTitle("余额: 未配置 Key") mBalance.SetTooltip("点击「打开 .env 配置文件」设置 API Key") } else { refreshAndUpdate(mBalance) } }() // 事件循环 for { select { case <-mRefresh.ClickedCh: if apiKey == "" { showMessageBox("DeepSeek Tray", "未配置 API Key。\n请点击「打开 .env 配置文件」设置 DEEPSEEK_API_KEY。") } else { refreshAndUpdate(mBalance) } case <-mAuto.ClickedCh: if autoRefresh { stopTick() mAuto.SetTitle("自动刷新: 关闭") } else { startTick() mAuto.SetTitle("自动刷新: 开启") } autoRefresh = !autoRefresh case <-mAlert.ClickedCh: alertOn = !alertOn if alertOn { mAlert.SetTitle("低余额告警: 开启") mAlert.SetTooltip(fmt.Sprintf("余额低于 ¥%.0f 时提醒", lowBalanceYuan)) if apiKey != "" { balance, _ := refreshAndUpdate(mBalance) if balance < lowBalanceYuan && balance > 0 { showMessageBox("DeepSeek 余额不足", fmt.Sprintf("当前余额 ¥%.2f,低于阈值 ¥%.0f,请及时充值。", balance, lowBalanceYuan)) } } } else { mAlert.SetTitle("低余额告警: 关闭") mAlert.SetTooltip("切换低余额告警") } case <-mDotEnv.ClickedCh: openDotEnv() case <-mAbout.ClickedCh: ar := "否" if autoRefresh { ar = "是" } ao := "否" if alertOn { ao = "是" } showMessageBox("DeepSeek Balance Tray", fmt.Sprintf("DeepSeek 余额查询系统托盘\n\n自动刷新: %s\n刷新间隔: %d 分钟\n低余额告警: %s\n\n配置文件: .env", ar, int(refreshPeriod.Minutes()), ao)) case <-mQuit.ClickedCh: stopTick() systray.Quit() return } } } // ---------- 核心:余额获取与 UI 更新 ---------- // refreshAndUpdate 请求 API 并更新托盘状态,返回 CNY 余额数值。 func refreshAndUpdate(m *systray.MenuItem) (cnyBalance float64, err error) { text, currency, cny := fetchBalance() tooltip := fmt.Sprintf("DeepSeek 余额: %s %s\n更新时间: %s", text, currency, time.Now().Format("15:04:05")) systray.SetTooltip(tooltip) m.SetTitle("余额: " + text + " " + currency) m.SetTooltip(tooltip) if cny > 0 { systray.SetTitle(text) } else { systray.SetTitle("DeepSeek") } return cny, nil } // fetchBalance 返回 (余额文本, 货币, CNY 数值)。 func fetchBalance() (string, string, float64) { client := &http.Client{Timeout: apiTimeout} req, err := http.NewRequest("GET", balanceURL, nil) if err != nil { return "创建请求失败", "", 0 } req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Accept", "application/json") resp, err := client.Do(req) if err != nil { return "网络错误: " + err.Error(), "", 0 } defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized { return "API Key 无效", "", 0 } if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(body)), "", 0 } var br balanceResp if err := json.NewDecoder(resp.Body).Decode(&br); err != nil { return "解析响应失败", "", 0 } if !br.IsAvailable || len(br.BalanceInfos) == 0 { return "余额不可用", "", 0 } // 取第一个币种为主显示,叠加所有 CNY 为数值 info := br.BalanceInfos[0] first := fmt.Sprintf("%s %s", info.TotalBalance, info.Currency) var cnyTotal float64 for _, item := range br.BalanceInfos { var v float64 fmt.Sscanf(item.TotalBalance, "%f", &v) if item.Currency == "CNY" { cnyTotal += v } } return first, info.Currency, cnyTotal } // ---------- .env 支持 ---------- func loadDotEnv() { dir := exeDir() if dir == "" { return } data, err := os.ReadFile(filepath.Join(dir, ".env")) if err != nil { return } for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if line == "" || line[0] == '#' { continue } idx := strings.IndexByte(line, '=') if idx < 0 { continue } key := strings.TrimSpace(line[:idx]) val := strings.Trim(strings.TrimSpace(line[idx+1:]), `"`) // .env 不覆盖已有环境变量 if os.Getenv(key) == "" && key != "" { os.Setenv(key, val) } } } func openDotEnv() { dir := exeDir() if dir == "" { showMessageBox("错误", "无法获取程序路径") return } path := filepath.Join(dir, ".env") if _, err := os.Stat(path); os.IsNotExist(err) { tmpl := "# DeepSeek API Key(必填)\nDEEPSEEK_API_KEY=sk-your-key-here\n\n# 余额查询接口(可选)\n# DEEPSEEK_BALANCE_URL=https://api.deepseek.com/user/balance\n" if err := os.WriteFile(path, []byte(tmpl), 0644); err != nil { showMessageBox("错误", "无法创建 .env 文件: "+err.Error()) return } } runNotepad(path) } // exeDir 返回可执行文件所在目录路径。 func exeDir() string { p, err := os.Executable() if err != nil { return "" } return filepath.Dir(p) } // ---------- 工具 ---------- func envOr(key, def string) string { if v, ok := os.LookupEnv(key); ok && v != "" { return v } return def }