|
|
@@ -1,7 +1,6 @@
|
|
|
package main
|
|
|
|
|
|
import (
|
|
|
- "embed"
|
|
|
"encoding/json"
|
|
|
"fmt"
|
|
|
"io"
|
|
|
@@ -12,28 +11,31 @@ import (
|
|
|
"time"
|
|
|
|
|
|
"github.com/getlantern/systray"
|
|
|
+
|
|
|
+ _ "embed" // 仅用于 //go:embed 指令
|
|
|
)
|
|
|
|
|
|
//go:embed icon.ico
|
|
|
-var icon []byte
|
|
|
-
|
|
|
-var _ = embed.FS{} // 保证 Go 1.26+ embed 引用检查
|
|
|
+var appIcon []byte
|
|
|
|
|
|
-// ---------- 配置常量 ----------
|
|
|
const (
|
|
|
- apiTimeout = 15 * time.Second // HTTP 请求超时
|
|
|
- refreshPeriod = 10 * time.Minute // 自动刷新间隔
|
|
|
- lowBalanceYuan = 1.0 // 低余额告警阈值(元)
|
|
|
- balanceDefaultURL = "https://api.deepseek.com/user/balance"
|
|
|
+ httpTimeout = 15 * time.Second
|
|
|
+ tickInterval = 10 * time.Minute
|
|
|
+ refreshMinutes = 10
|
|
|
+ lowBalanceAlert = 1.0 // 单位:元 (CNY)
|
|
|
+ defaultAPIURL = "https://api.deepseek.com/user/balance"
|
|
|
)
|
|
|
|
|
|
+// main 中写入,之后所有 goroutine 只读。
|
|
|
var (
|
|
|
apiKey string
|
|
|
- balanceURL = balanceDefaultURL
|
|
|
+ balanceURL string
|
|
|
+ exeDir string
|
|
|
)
|
|
|
|
|
|
-// ---------- API 响应 ----------
|
|
|
-type balanceResp struct {
|
|
|
+// ---------- API ----------
|
|
|
+
|
|
|
+type balanceAPI struct {
|
|
|
IsAvailable bool `json:"is_available"`
|
|
|
BalanceInfos []balanceItem `json:"balance_infos"`
|
|
|
}
|
|
|
@@ -45,26 +47,31 @@ type balanceItem struct {
|
|
|
ToppedUpBalance string `json:"topped_up_balance"`
|
|
|
}
|
|
|
|
|
|
-// ---------- 入口 ----------
|
|
|
+// balanceResult 是查询结果的不可变快照。
|
|
|
+// ok 为 false 时 text 是错误消息;为 true 时 text 是纯数字金额(不含币种)。
|
|
|
+type balanceResult struct {
|
|
|
+ ok bool
|
|
|
+ text string
|
|
|
+ currency string
|
|
|
+ cny float64 // CNY 总额,失败时为 0
|
|
|
+}
|
|
|
+
|
|
|
func main() {
|
|
|
- loadDotEnv()
|
|
|
- apiKey = envOr("DEEPSEEK_API_KEY", "")
|
|
|
- if v := envOr("DEEPSEEK_BALANCE_URL", ""); v != "" {
|
|
|
- balanceURL = v
|
|
|
- }
|
|
|
+ exeDir = executableDir()
|
|
|
+ apiKey, balanceURL = loadConfig()
|
|
|
systray.Run(onReady, func() {})
|
|
|
}
|
|
|
|
|
|
func onReady() {
|
|
|
- systray.SetIcon(icon)
|
|
|
+ systray.SetIcon(appIcon)
|
|
|
systray.SetTitle("DeepSeek")
|
|
|
- if apiKey == "" {
|
|
|
- systray.SetTooltip("DeepSeek - 未配置 API Key\n请在 .env 文件中设置 DEEPSEEK_API_KEY")
|
|
|
- } else {
|
|
|
+
|
|
|
+ if apiKey != "" {
|
|
|
systray.SetTooltip("DeepSeek 余额查询 - 正在获取...")
|
|
|
+ } else {
|
|
|
+ systray.SetTooltip("DeepSeek - 未配置 API Key\n请在 .env 文件中设置 DEEPSEEK_API_KEY")
|
|
|
}
|
|
|
|
|
|
- // 菜单
|
|
|
mBalance := systray.AddMenuItem("余额: 查询中...", "")
|
|
|
mBalance.Disable()
|
|
|
mRefresh := systray.AddMenuItem("刷新余额", "手动刷新")
|
|
|
@@ -75,27 +82,31 @@ func onReady() {
|
|
|
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{})
|
|
|
+ balanceCh := make(chan balanceResult, 1) // timer 和首查均通过此 channel 传回结果
|
|
|
+ var ticker *time.Ticker // 仅 startTicker 写入
|
|
|
+ var tickerStop chan struct{}
|
|
|
+
|
|
|
+ startTicker := func() {
|
|
|
+ ticker = time.NewTicker(tickInterval)
|
|
|
+ tickerStop = make(chan struct{})
|
|
|
+ stop := tickerStop
|
|
|
+
|
|
|
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))
|
|
|
- }
|
|
|
+ if apiKey == "" {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ r := queryAndUpdate(mBalance)
|
|
|
+ select {
|
|
|
+ case balanceCh <- r:
|
|
|
+ default:
|
|
|
}
|
|
|
- case <-tickStop:
|
|
|
+ case <-stop:
|
|
|
ticker.Stop()
|
|
|
return
|
|
|
}
|
|
|
@@ -103,197 +114,235 @@ func onReady() {
|
|
|
}()
|
|
|
}
|
|
|
|
|
|
- stopTick := func() {
|
|
|
- if tickStop != nil {
|
|
|
- close(tickStop)
|
|
|
- tickStop = nil
|
|
|
+ stopTicker := func() {
|
|
|
+ if tickerStop != nil {
|
|
|
+ close(tickerStop)
|
|
|
+ tickerStop = nil
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- startTick()
|
|
|
+ startTicker()
|
|
|
|
|
|
- // 首次查询
|
|
|
+ // 首次查询(结果同样送入 balanceCh,确保告警逻辑一致)
|
|
|
go func() {
|
|
|
if apiKey == "" {
|
|
|
mBalance.SetTitle("余额: 未配置 Key")
|
|
|
mBalance.SetTooltip("点击「打开 .env 配置文件」设置 API Key")
|
|
|
- } else {
|
|
|
- refreshAndUpdate(mBalance)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ r := queryAndUpdate(mBalance)
|
|
|
+ select {
|
|
|
+ case balanceCh <- r:
|
|
|
+ default:
|
|
|
}
|
|
|
}()
|
|
|
|
|
|
- // 事件循环
|
|
|
for {
|
|
|
select {
|
|
|
+ case r := <-balanceCh:
|
|
|
+ if alertOn && r.cny > 0 && r.cny < lowBalanceAlert {
|
|
|
+ alertLowBalance(r.cny)
|
|
|
+ }
|
|
|
+
|
|
|
case <-mRefresh.ClickedCh:
|
|
|
if apiKey == "" {
|
|
|
- showMessageBox("DeepSeek Tray",
|
|
|
+ showMessageBox("DeepSeek Tray",
|
|
|
"未配置 API Key。\n请点击「打开 .env 配置文件」设置 DEEPSEEK_API_KEY。")
|
|
|
- } else {
|
|
|
- refreshAndUpdate(mBalance)
|
|
|
+ continue
|
|
|
}
|
|
|
+ queryAndUpdate(mBalance)
|
|
|
|
|
|
case <-mAuto.ClickedCh:
|
|
|
+ autoRefresh = !autoRefresh
|
|
|
if autoRefresh {
|
|
|
- stopTick()
|
|
|
- mAuto.SetTitle("自动刷新: 关闭")
|
|
|
- } else {
|
|
|
- startTick()
|
|
|
+ startTicker()
|
|
|
mAuto.SetTitle("自动刷新: 开启")
|
|
|
+ } else {
|
|
|
+ stopTicker()
|
|
|
+ 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))
|
|
|
+ r := queryAndUpdate(mBalance)
|
|
|
+ if r.cny > 0 && r.cny < lowBalanceAlert {
|
|
|
+ alertLowBalance(r.cny)
|
|
|
}
|
|
|
}
|
|
|
} else {
|
|
|
mAlert.SetTitle("低余额告警: 关闭")
|
|
|
- mAlert.SetTooltip("切换低余额告警")
|
|
|
}
|
|
|
|
|
|
case <-mDotEnv.ClickedCh:
|
|
|
- openDotEnv()
|
|
|
+ openDotEnvFile()
|
|
|
|
|
|
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))
|
|
|
+ showAbout(autoRefresh, alertOn)
|
|
|
|
|
|
case <-mQuit.ClickedCh:
|
|
|
- stopTick()
|
|
|
+ stopTicker()
|
|
|
systray.Quit()
|
|
|
return
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// ---------- 核心:余额获取与 UI 更新 ----------
|
|
|
+// ---------- 查询 ----------
|
|
|
|
|
|
-// refreshAndUpdate 请求 API 并更新托盘状态,返回 CNY 余额数值。
|
|
|
-func refreshAndUpdate(m *systray.MenuItem) (cnyBalance float64, err error) {
|
|
|
- text, currency, cny := fetchBalance()
|
|
|
+func queryAndUpdate(m *systray.MenuItem) balanceResult {
|
|
|
+ r := queryBalance()
|
|
|
+ now := time.Now().Format("15:04:05")
|
|
|
|
|
|
- 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)
|
|
|
+ if r.ok {
|
|
|
+ display := r.text + " " + r.currency
|
|
|
+ tip := fmt.Sprintf("余额: %s | %s", display, now)
|
|
|
+ systray.SetTooltip(tip)
|
|
|
+ m.SetTitle("余额: " + display)
|
|
|
+ m.SetTooltip(tip)
|
|
|
+ systray.SetTitle(display)
|
|
|
} else {
|
|
|
+ tip := fmt.Sprintf("%s | %s", r.text, now)
|
|
|
+ systray.SetTooltip(tip)
|
|
|
+ m.SetTitle(r.text)
|
|
|
+ m.SetTooltip(tip)
|
|
|
systray.SetTitle("DeepSeek")
|
|
|
}
|
|
|
- return cny, nil
|
|
|
+ return r
|
|
|
}
|
|
|
|
|
|
-// fetchBalance 返回 (余额文本, 货币, CNY 数值)。
|
|
|
-func fetchBalance() (string, string, float64) {
|
|
|
- client := &http.Client{Timeout: apiTimeout}
|
|
|
- req, err := http.NewRequest("GET", balanceURL, nil)
|
|
|
+func queryBalance() balanceResult {
|
|
|
+ client := &http.Client{Timeout: httpTimeout}
|
|
|
+ req, err := http.NewRequest(http.MethodGet, balanceURL, nil)
|
|
|
if err != nil {
|
|
|
- return "创建请求失败", "", 0
|
|
|
+ return balanceResult{text: "创建请求失败"}
|
|
|
}
|
|
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
|
|
resp, err := client.Do(req)
|
|
|
if err != nil {
|
|
|
- return "网络错误: " + err.Error(), "", 0
|
|
|
+ return balanceResult{text: "网络错误: " + err.Error()}
|
|
|
}
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
- if resp.StatusCode == http.StatusUnauthorized {
|
|
|
- return "API Key 无效", "", 0
|
|
|
- }
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
+ if resp.StatusCode == http.StatusUnauthorized {
|
|
|
+ return balanceResult{text: "API Key 无效"}
|
|
|
+ }
|
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
|
- return fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(body)), "", 0
|
|
|
+ return balanceResult{text: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(body))}
|
|
|
}
|
|
|
|
|
|
- var br balanceResp
|
|
|
- if err := json.NewDecoder(resp.Body).Decode(&br); err != nil {
|
|
|
- return "解析响应失败", "", 0
|
|
|
+ var api balanceAPI
|
|
|
+ if err := json.NewDecoder(resp.Body).Decode(&api); err != nil {
|
|
|
+ return balanceResult{text: "解析响应失败"}
|
|
|
}
|
|
|
-
|
|
|
- if !br.IsAvailable || len(br.BalanceInfos) == 0 {
|
|
|
- return "余额不可用", "", 0
|
|
|
+ if !api.IsAvailable || len(api.BalanceInfos) == 0 {
|
|
|
+ return balanceResult{text: "余额不可用"}
|
|
|
}
|
|
|
|
|
|
- // 取第一个币种为主显示,叠加所有 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)
|
|
|
+ for _, item := range api.BalanceInfos {
|
|
|
if item.Currency == "CNY" {
|
|
|
+ var v float64
|
|
|
+ fmt.Sscanf(item.TotalBalance, "%f", &v)
|
|
|
cnyTotal += v
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- return first, info.Currency, cnyTotal
|
|
|
+ first := api.BalanceInfos[0]
|
|
|
+ return balanceResult{
|
|
|
+ ok: true,
|
|
|
+ text: first.TotalBalance,
|
|
|
+ currency: first.Currency,
|
|
|
+ cny: cnyTotal,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func alertLowBalance(cny float64) {
|
|
|
+ showMessageBox("DeepSeek 余额不足",
|
|
|
+ fmt.Sprintf("当前余额 ¥%.2f,低于阈值 ¥%.0f,请及时充值。", cny, lowBalanceAlert))
|
|
|
+}
|
|
|
+
|
|
|
+func showAbout(autoRefresh, alertOn bool) {
|
|
|
+ auto := "否"
|
|
|
+ if autoRefresh {
|
|
|
+ auto = "是"
|
|
|
+ }
|
|
|
+ alert := "否"
|
|
|
+ if alertOn {
|
|
|
+ alert = "是"
|
|
|
+ }
|
|
|
+ showMessageBox("DeepSeek Balance Tray",
|
|
|
+ fmt.Sprintf("自动刷新: %s\n刷新间隔: %d 分钟\n低余额告警: %s (阈值 ¥%.0f)",
|
|
|
+ auto, refreshMinutes, alert, lowBalanceAlert))
|
|
|
}
|
|
|
|
|
|
-// ---------- .env 支持 ----------
|
|
|
+// ---------- 配置加载 ----------
|
|
|
+
|
|
|
+// loadConfig 返回 (apiKey, balanceURL)。
|
|
|
+// apiKey 优先级:DEEPSEEK_API_KEY 环境变量 > .env 文件。
|
|
|
+// balanceURL 优先级:DEEPSEEK_BALANCE_URL 环境变量 > .env 文件 > defaultAPIURL。
|
|
|
+func loadConfig() (key, url string) {
|
|
|
+ dotenv := readDotEnv(filepath.Join(exeDir, ".env"))
|
|
|
+ // dotenv 可能为 nil;Go 读取 nil map 返回零值,安全。
|
|
|
+
|
|
|
+ if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" {
|
|
|
+ key = v
|
|
|
+ } else if dotenv != nil {
|
|
|
+ key = dotenv["DEEPSEEK_API_KEY"]
|
|
|
+ }
|
|
|
|
|
|
-func loadDotEnv() {
|
|
|
- dir := exeDir()
|
|
|
- if dir == "" {
|
|
|
- return
|
|
|
+ if v := os.Getenv("DEEPSEEK_BALANCE_URL"); v != "" {
|
|
|
+ url = v
|
|
|
+ } else if dotenv != nil {
|
|
|
+ if v := dotenv["DEEPSEEK_BALANCE_URL"]; v != "" {
|
|
|
+ url = v
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if url == "" {
|
|
|
+ url = defaultAPIURL
|
|
|
}
|
|
|
- data, err := os.ReadFile(filepath.Join(dir, ".env"))
|
|
|
+ return
|
|
|
+}
|
|
|
+
|
|
|
+func readDotEnv(path string) map[string]string {
|
|
|
+ data, err := os.ReadFile(path)
|
|
|
if err != nil {
|
|
|
- return
|
|
|
+ return nil
|
|
|
}
|
|
|
- for _, line := range strings.Split(string(data), "\n") {
|
|
|
- line = strings.TrimSpace(line)
|
|
|
+ m := make(map[string]string)
|
|
|
+ for _, raw := range strings.Split(string(data), "\n") {
|
|
|
+ line := strings.TrimSpace(raw)
|
|
|
if line == "" || line[0] == '#' {
|
|
|
continue
|
|
|
}
|
|
|
- idx := strings.IndexByte(line, '=')
|
|
|
- if idx < 0 {
|
|
|
+ eq := strings.IndexByte(line, '=')
|
|
|
+ if eq < 1 {
|
|
|
continue
|
|
|
}
|
|
|
- key := strings.TrimSpace(line[:idx])
|
|
|
- val := strings.Trim(strings.TrimSpace(line[idx+1:]), `"`)
|
|
|
- // .env 不覆盖已有环境变量
|
|
|
- if os.Getenv(key) == "" && key != "" {
|
|
|
- os.Setenv(key, val)
|
|
|
+ k := strings.TrimSpace(line[:eq])
|
|
|
+ if k == "" {
|
|
|
+ continue
|
|
|
}
|
|
|
+ v := strings.TrimSpace(line[eq+1:])
|
|
|
+ v = strings.Trim(v, `"'`)
|
|
|
+ m[k] = v
|
|
|
}
|
|
|
+ return m
|
|
|
}
|
|
|
|
|
|
-func openDotEnv() {
|
|
|
- dir := exeDir()
|
|
|
- if dir == "" {
|
|
|
- showMessageBox("错误", "无法获取程序路径")
|
|
|
- return
|
|
|
- }
|
|
|
- path := filepath.Join(dir, ".env")
|
|
|
+func openDotEnvFile() {
|
|
|
+ path := filepath.Join(exeDir, ".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"
|
|
|
+ const 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())
|
|
|
+ showMessageBox("错误", "无法创建 .env 文件: "+err.Error())
|
|
|
return
|
|
|
}
|
|
|
}
|
|
|
@@ -301,20 +350,13 @@ func openDotEnv() {
|
|
|
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
|
|
|
+func executableDir() string {
|
|
|
+ exe, err := os.Executable()
|
|
|
+ if err != nil {
|
|
|
+ wd, _ := os.Getwd()
|
|
|
+ return wd
|
|
|
}
|
|
|
- return def
|
|
|
+ return filepath.Dir(exe)
|
|
|
}
|