Эх сурвалжийг харах

update main.go and msgbox_windows.go

caesar 2 сар өмнө
parent
commit
2b7a007974
2 өөрчлөгдсөн 219 нэмэгдсэн , 159 устгасан
  1. 189 147
      main.go
  2. 30 12
      msgbox_windows.go

+ 189 - 147
main.go

@@ -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)
 }

+ 30 - 12
msgbox_windows.go

@@ -6,20 +6,38 @@ import (
 	"unsafe"
 )
 
-// showMessageBox 调用 Windows MessageBoxW 弹出模态对话框
-func showMessageBox(title, msg string) {
-	user32 := syscall.NewLazyDLL("user32.dll")
-	msgBox := user32.NewProc("MessageBoxW")
+var (
+	user32  = syscall.NewLazyDLL("user32.dll")
+	msgBoxW = user32.NewProc("MessageBoxW")
+)
+
+const (
+	mbOK            = 0x00000000
+	mbIconWarning   = 0x00000030
+	mbSetForeground = 0x00010000
+	mbTopmost       = 0x00040000
+)
 
-	lpTitle, _ := syscall.UTF16PtrFromString(title)
-	lpText, _ := syscall.UTF16PtrFromString(msg)
+func showMessageBox(title, msg string) {
+	ptitle, err := syscall.UTF16PtrFromString(title)
+	if err != nil {
+		return
+	}
+	ptext, err := syscall.UTF16PtrFromString(msg)
+	if err != nil {
+		return
+	}
 
-	// MB_OK | MB_ICONWARNING | MB_SETFOREGROUND | MB_TOPMOST
-	const flags = 0x00000000 | 0x00000030 | 0x00010000 | 0x00040000
-	msgBox.Call(0, uintptr(unsafe.Pointer(lpText)), uintptr(unsafe.Pointer(lpTitle)), uintptr(flags))
+	msgBoxW.Call(0,
+		uintptr(unsafe.Pointer(ptext)),
+		uintptr(unsafe.Pointer(ptitle)),
+		uintptr(mbOK|mbIconWarning|mbSetForeground|mbTopmost),
+	)
 }
 
-// runNotepad 调用 notepad.exe 打开指定文件
 func runNotepad(path string) {
-	exec.Command("notepad.exe", path).Start()
-}
+	err := exec.Command("notepad.exe", path).Start()
+	if err != nil {
+		showMessageBox("错误", "无法打开记事本: "+err.Error())
+	}
+}