|
|
@@ -0,0 +1,261 @@
|
|
|
+package main
|
|
|
+
|
|
|
+import (
|
|
|
+ "embed"
|
|
|
+ "encoding/json"
|
|
|
+ "fmt"
|
|
|
+ "io"
|
|
|
+ "net/http"
|
|
|
+ "os"
|
|
|
+ "sync"
|
|
|
+ "time"
|
|
|
+
|
|
|
+ "github.com/getlantern/systray"
|
|
|
+)
|
|
|
+
|
|
|
+// ---------- 嵌入资源 ----------
|
|
|
+//go:embed icon.ico
|
|
|
+var iconData []byte
|
|
|
+
|
|
|
+// 确保 embed 包被"使用"(Go 1.26+ 要求)
|
|
|
+var _ = embed.FS{}
|
|
|
+
|
|
|
+// ---------- 默认配置(可被环境变量覆盖) ----------
|
|
|
+var (
|
|
|
+ apiKey = getEnv("DEEPSEEK_API_KEY", "")
|
|
|
+ balanceURL = getEnv("DEEPSEEK_BALANCE_URL", "https://api.deepseek.com/user/balance")
|
|
|
+ requestTimeout = 15 // 秒
|
|
|
+ refreshMinutes = 10 // 自动刷新间隔,0 表示关闭
|
|
|
+ lowThreshold = 1.0 // 余额低于此值(元)时告警
|
|
|
+)
|
|
|
+
|
|
|
+// ---------- 运行时状态 ----------
|
|
|
+var (
|
|
|
+ lastBalance string
|
|
|
+ lastUpdate time.Time
|
|
|
+ mu sync.RWMutex
|
|
|
+)
|
|
|
+
|
|
|
+// ---------- API 响应结构 ----------
|
|
|
+type balanceResponse struct {
|
|
|
+ IsAvailable bool `json:"is_available"`
|
|
|
+ BalanceInfos []struct {
|
|
|
+ Currency string `json:"currency"`
|
|
|
+ TotalBalance string `json:"total_balance"`
|
|
|
+ GrantedBalance string `json:"granted_balance"`
|
|
|
+ ToppedUpBalance string `json:"topped_up_balance"`
|
|
|
+ } `json:"balance_infos"`
|
|
|
+}
|
|
|
+
|
|
|
+func main() {
|
|
|
+ if apiKey == "" {
|
|
|
+ fmt.Fprintln(os.Stderr, "错误: 环境变量 DEEPSEEK_API_KEY 未设置")
|
|
|
+ fmt.Fprintln(os.Stderr, "请设置后运行: set DEEPSEEK_API_KEY=sk-your-key")
|
|
|
+ os.Exit(1)
|
|
|
+ }
|
|
|
+ systray.Run(onReady, onExit)
|
|
|
+}
|
|
|
+
|
|
|
+func onReady() {
|
|
|
+ systray.SetIcon(iconData)
|
|
|
+ systray.SetTitle("DeepSeek")
|
|
|
+ systray.SetTooltip("DeepSeek 余额查询 - 正在获取...")
|
|
|
+
|
|
|
+ // 菜单项
|
|
|
+ mBalance := systray.AddMenuItem("余额: 查询中...", "")
|
|
|
+ mBalance.Disable()
|
|
|
+
|
|
|
+ mManualRefresh := systray.AddMenuItem("刷新余额", "手动刷新")
|
|
|
+ mAutoRefresh := systray.AddMenuItem("自动刷新: 开启", "切换自动刷新")
|
|
|
+ mThreshold := systray.AddMenuItem("低余额告警: 关闭", "切换低余额告警")
|
|
|
+ systray.AddSeparator()
|
|
|
+ mAbout := systray.AddMenuItem("关于", "关于本程序")
|
|
|
+ mQuit := systray.AddMenuItem("退出", "关闭程序")
|
|
|
+
|
|
|
+ // 首次查询
|
|
|
+ go refreshBalance(mBalance)
|
|
|
+
|
|
|
+ // 定时器控制
|
|
|
+ var (
|
|
|
+ ticker *time.Ticker
|
|
|
+ tickerStop chan struct{}
|
|
|
+ autoRefresh = true
|
|
|
+ alertOn = false
|
|
|
+ )
|
|
|
+
|
|
|
+ startTicker := func() {
|
|
|
+ if refreshMinutes <= 0 {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ ticker = time.NewTicker(time.Duration(refreshMinutes) * time.Minute)
|
|
|
+ tickerStop = make(chan struct{})
|
|
|
+ go func() {
|
|
|
+ for {
|
|
|
+ select {
|
|
|
+ case <-ticker.C:
|
|
|
+ refreshBalance(mBalance)
|
|
|
+ if alertOn {
|
|
|
+ checkLowBalance()
|
|
|
+ }
|
|
|
+ case <-tickerStop:
|
|
|
+ ticker.Stop()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }()
|
|
|
+ }
|
|
|
+
|
|
|
+ stopTicker := func() {
|
|
|
+ if tickerStop != nil {
|
|
|
+ close(tickerStop)
|
|
|
+ tickerStop = nil
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ startTicker()
|
|
|
+
|
|
|
+ // 事件循环
|
|
|
+ for {
|
|
|
+ select {
|
|
|
+ case <-mManualRefresh.ClickedCh:
|
|
|
+ refreshBalance(mBalance)
|
|
|
+
|
|
|
+ case <-mAutoRefresh.ClickedCh:
|
|
|
+ if autoRefresh {
|
|
|
+ stopTicker()
|
|
|
+ mAutoRefresh.SetTitle("自动刷新: 关闭")
|
|
|
+ autoRefresh = false
|
|
|
+ } else {
|
|
|
+ startTicker()
|
|
|
+ mAutoRefresh.SetTitle("自动刷新: 开启")
|
|
|
+ autoRefresh = true
|
|
|
+ }
|
|
|
+
|
|
|
+ case <-mThreshold.ClickedCh:
|
|
|
+ alertOn = !alertOn
|
|
|
+ if alertOn {
|
|
|
+ mThreshold.SetTitle("低余额告警: 开启")
|
|
|
+ mThreshold.SetTooltip(fmt.Sprintf("余额低于 ¥%.0f 时提醒", lowThreshold))
|
|
|
+ checkLowBalance()
|
|
|
+ } else {
|
|
|
+ mThreshold.SetTitle("低余额告警: 关闭")
|
|
|
+ mThreshold.SetTooltip("切换低余额告警")
|
|
|
+ }
|
|
|
+
|
|
|
+ case <-mAbout.ClickedCh:
|
|
|
+ systray.SetTooltip(fmt.Sprintf(
|
|
|
+ "自动刷新:%s | 间隔:%dmin | 告警:%s",
|
|
|
+ boolToStr(autoRefresh), refreshMinutes, boolToStr(alertOn),
|
|
|
+ ))
|
|
|
+
|
|
|
+ case <-mQuit.ClickedCh:
|
|
|
+ stopTicker()
|
|
|
+ systray.Quit()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func onExit() {
|
|
|
+ // 清理
|
|
|
+}
|
|
|
+
|
|
|
+// refreshBalance 请求 API 并更新托盘状态
|
|
|
+func refreshBalance(menuItem *systray.MenuItem) {
|
|
|
+ text, currency, totalVal := fetchBalance()
|
|
|
+ mu.Lock()
|
|
|
+ lastBalance = text
|
|
|
+ lastUpdate = time.Now()
|
|
|
+ mu.Unlock()
|
|
|
+
|
|
|
+ tooltip := fmt.Sprintf("DeepSeek 余额: %s %s\n更新时间: %s",
|
|
|
+ text, currency, lastUpdate.Format("15:04:05"))
|
|
|
+ systray.SetTooltip(tooltip)
|
|
|
+ menuItem.SetTitle("余额: " + text + " " + currency)
|
|
|
+ menuItem.SetTooltip(tooltip)
|
|
|
+
|
|
|
+ // 更新程序标题
|
|
|
+ if totalVal > 0 {
|
|
|
+ systray.SetTitle(text)
|
|
|
+ } else {
|
|
|
+ systray.SetTitle("DeepSeek")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// fetchBalance 返回 (余额文本, 货币, 数值用于比较)
|
|
|
+func fetchBalance() (string, string, float64) {
|
|
|
+ client := &http.Client{Timeout: time.Duration(requestTimeout) * time.Second}
|
|
|
+ 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 balanceResponse
|
|
|
+ if err := json.NewDecoder(resp.Body).Decode(&br); err != nil {
|
|
|
+ return "解析响应失败", "", 0
|
|
|
+ }
|
|
|
+
|
|
|
+ if !br.IsAvailable || len(br.BalanceInfos) == 0 {
|
|
|
+ return "余额不可用", "", 0
|
|
|
+ }
|
|
|
+
|
|
|
+ // 合并所有币种
|
|
|
+ var parts []string
|
|
|
+ var total float64
|
|
|
+ currency := "CNY"
|
|
|
+ for _, info := range br.BalanceInfos {
|
|
|
+ var val float64
|
|
|
+ fmt.Sscanf(info.TotalBalance, "%f", &val)
|
|
|
+ parts = append(parts, fmt.Sprintf("%s %s", info.TotalBalance, info.Currency))
|
|
|
+ if info.Currency == "CNY" {
|
|
|
+ total += val
|
|
|
+ }
|
|
|
+ currency = info.Currency
|
|
|
+ }
|
|
|
+
|
|
|
+ if len(parts) == 1 {
|
|
|
+ return parts[0], currency, total
|
|
|
+ }
|
|
|
+ return fmt.Sprintf("%v", parts), currency, total
|
|
|
+}
|
|
|
+
|
|
|
+// checkLowBalance 检查余额是否低于阈值,通过弹窗告警(使用 Windows API)
|
|
|
+func checkLowBalance() {
|
|
|
+ _, _, totalVal := fetchBalance()
|
|
|
+ if totalVal > 0 && totalVal < lowThreshold {
|
|
|
+ msg := fmt.Sprintf("当前余额 ¥%.2f,低于阈值 ¥%.0f,请及时充值。", totalVal, lowThreshold)
|
|
|
+ showMessageBox("⚠️ DeepSeek 余额不足", msg)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ---------- 工具函数 ----------
|
|
|
+
|
|
|
+func getEnv(key, defaultVal string) string {
|
|
|
+ if val, ok := os.LookupEnv(key); ok && val != "" {
|
|
|
+ return val
|
|
|
+ }
|
|
|
+ return defaultVal
|
|
|
+}
|
|
|
+
|
|
|
+func boolToStr(b bool) string {
|
|
|
+ if b {
|
|
|
+ return "是"
|
|
|
+ }
|
|
|
+ return "否"
|
|
|
+}
|