// ============================================================================ // apiserver.go — 内置 API 服务 // // 用 Gin 提供的本机 HTTP API:健康检查、通知、窗口控制、电源命令、屏幕截图、 // 鼠标提醒等;日志写控制台并存入环形缓冲(供「主机」页实时查看)。 // ============================================================================ package main import ( "context" "fmt" "io" "log" "net/http" "os" "strconv" "strings" "sync" "time" "github.com/gen2brain/beeep" // 跨平台系统通知,Windows 下为托盘气泡/Toast "github.com/gin-gonic/gin" ) // notifyRequest 通知接口的请求体 type notifyRequest struct { Title string `json:"title"` // 通知标题,为空时使用应用名 Message string `json:"message"` // 通知正文 } // mouseAlertRequest 鼠标提醒接口的请求体 type mouseAlertRequest struct { Text string `json:"text"` // 提示文字,为空时使用默认文案 } // ------------------------------------------------------------------------ // Gin 日志环形缓冲:日志同时写控制台与内存缓冲,供「主机」页实时查看 // ------------------------------------------------------------------------ const apiLogCapacity = 300 // 环形缓冲最多保留的日志行数 // logRing 固定容量的日志环形缓冲,实现 io.Writer type logRing struct { mu sync.Mutex lines []string } // Write 按行拆分写入缓冲,并向所有前端窗口广播 "apilog" 事件 func (r *logRing) Write(p []byte) (int, error) { text := strings.TrimRight(string(p), "\r\n") r.mu.Lock() for _, line := range strings.Split(text, "\n") { if strings.TrimSpace(line) == "" { continue } r.lines = append(r.lines, line) if len(r.lines) > apiLogCapacity { r.lines = r.lines[len(r.lines)-apiLogCapacity:] // 丢弃最旧的行 } if app != nil { app.Event.Emit("apilog", line) // 实时推送到「主机」页 } } r.mu.Unlock() return len(p), nil } // Snapshot 返回当前缓冲的全部日志行 func (r *logRing) Snapshot() []string { r.mu.Lock() defer r.mu.Unlock() out := make([]string, len(r.lines)) copy(out, r.lines) return out } // Clear 清空日志缓冲 func (r *logRing) Clear() { r.mu.Lock() r.lines = nil r.mu.Unlock() } var apiLogRing = &logRing{} // ------------------------------------------------------------------------ // API 服务生命周期:地址变化时平滑重启(仅监听配置的地址) // ------------------------------------------------------------------------ var ( apiMu sync.Mutex apiServer *http.Server apiAddr string // 当前监听地址 apiStartedAt time.Time // 本次启动时间 apiLastErr string // 最近一次启动 / 运行错误 ) // StopAPIServer 停止内置 API 服务(从服务页手动关闭)。 // 停止后 apiServer 置 nil、apiAddr 保留,便于再次启动或恢复。 func StopAPIServer() { apiMu.Lock() old := apiServer apiServer = nil apiAddr = "" apiLastErr = "" apiMu.Unlock() if old != nil { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) _ = old.Shutdown(ctx) // 平滑关闭,中断不复用连接 cancel() } log.Printf("API server stopped by user") fmt.Fprintf(apiLogRing, "[SYS] API 服务已停止\n") } // StartAPIServer 启动 / 重启内置 API 服务(从服务页手动启动或恢复)。 // 使用配置中的地址(为空时用默认地址)。 func StartAPIServer() { ensureAPIServer(loadSettings().APIAddr) } // APIStatus API 服务运行状态,供「主机」页展示 type APIStatus struct { Addr string `json:"addr"` // 当前监听地址 Running bool `json:"running"` // 是否正在监听 StartedAt string `json:"startedAt"` // 启动时间 Error string `json:"error"` // 最近错误信息 } // ensureAPIServer 确保 API 服务监听在指定地址;地址变化时先关闭旧服务再启动 func ensureAPIServer(addr string) { addr = strings.TrimSpace(addr) if addr == "" { addr = DefaultAPIAddr } apiMu.Lock() if apiServer != nil && apiAddr == addr && apiLastErr == "" { apiMu.Unlock() return // 地址未变且运行正常,无需重启 } old := apiServer apiServer = nil apiAddr = addr apiStartedAt = time.Now() apiLastErr = "" apiMu.Unlock() if old != nil { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) _ = old.Shutdown(ctx) // 平滑关闭旧服务,中断不复用连接 cancel() } go runAPIServer(addr) } // runAPIServer 构建路由并启动监听(出错只记录状态,不影响应用本身) func runAPIServer(addr string) { gin.SetMode(gin.ReleaseMode) // 生产模式,减少日志噪音 router := gin.New() router.Use(gin.LoggerWithConfig(gin.LoggerConfig{ Output: io.MultiWriter(os.Stdout, apiLogRing), // 日志同时写控制台与环形缓冲 })) router.Use(gin.Recovery()) // 捕获 panic,避免接口崩溃导致进程退出 router.Use(corsMiddleware()) // 允许跨域,便于前端或其他本机工具调用 router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) // 健康检查 }) api := router.Group("/api") api.POST("/notify", handleNotify) // 弹出系统托盘通知 api.POST("/window/show", handleShowWindow) // 显示主窗口 api.POST("/mouse/alert", handleMouseAlert) // 触发系统级鼠标提醒(闪烁光标+提示文字) api.GET("/settings", handleGetSettings) // 读取配置 api.POST("/settings", handleSaveSettings) // 保存配置 api.GET("/stocks", handleGetStocks) // 获取股票行情 api.GET("/screenshot", handleScreenshot) // 截取屏幕,返回 PNG 数据 URI pc := api.Group("/pc") // 主机控制接口(移植自主机服务) pc.GET("/poweroff", handlePoweroff) // 30 秒后关机 pc.GET("/restart", handleRestart) // 30 秒后重启 pc.GET("/sleep", handleSleep) // 30 秒后休眠 pc.GET("/lock", handleLock) // 立即锁屏 pc.GET("/cancel", handleCancel) // 取消待执行命令 pc.GET("/status", handlePCStatus) // 查询命令状态 pc.GET("/timesync", handleTimeSync) // 同步系统时间 server := &http.Server{Addr: addr, Handler: router} apiMu.Lock() apiServer = server apiMu.Unlock() log.Printf("API server listening on http://%s", addr) fmt.Fprintf(apiLogRing, "[SYS] API 服务已启动 http://%s\n", addr) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { apiMu.Lock() apiLastErr = err.Error() // 端口被占用等错误只记录,不影响应用启动 apiMu.Unlock() log.Printf("api server stopped: %v", err) fmt.Fprintf(apiLogRing, "[SYS] API 服务异常停止: %v\n", err) } } // getAPIStatus 返回当前 API 服务状态快照 func getAPIStatus() APIStatus { apiMu.Lock() defer apiMu.Unlock() started := "" if !apiStartedAt.IsZero() { started = apiStartedAt.Format("01-02 15:04:05") } return APIStatus{ Addr: apiAddr, Running: apiServer != nil, StartedAt: started, Error: apiLastErr, } } // corsMiddleware 简单放行跨域请求(服务默认仅监听 127.0.0.1,风险可控) func corsMiddleware() gin.HandlerFunc { return func(c *gin.Context) { c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") c.Header("Access-Control-Allow-Headers", "Content-Type") if c.Request.Method == http.MethodOptions { c.AbortWithStatus(http.StatusNoContent) // 预检请求直接返回 return } c.Next() } } // handleNotify 通过系统通知弹出消息,例如:POST /api/notify {"message":"任务完成"} func handleNotify(c *gin.Context) { var req notifyRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Title == "" { req.Title = "腾图工具箱" // 未指定标题时使用默认标题 } if err := beeep.Notify(req.Title, req.Message, ""); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) // 通知已发出 } // handleMouseAlert 触发系统级鼠标提醒,例如:POST /api/mouse/alert {"text":"有新订单"} func handleMouseAlert(c *gin.Context) { var req mouseAlertRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } triggerMouseAlert(req.Text) // 触发鼠标闪烁 + 光标下方透明文字 c.JSON(http.StatusOK, gin.H{"ok": true}) } // handleShowWindow 显示并聚焦主窗口(从托盘恢复) func handleShowWindow(c *gin.Context) { showMainWindow() c.JSON(http.StatusOK, gin.H{"ok": true}) } // handleGetSettings 返回当前配置 func handleGetSettings(c *gin.Context) { c.JSON(http.StatusOK, loadSettings()) } // handleSaveSettings 保存配置并立即生效 func handleSaveSettings(c *gin.Context) { var settings AppSettings if err := c.ShouldBindJSON(&settings); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } settings = normalizeSettings(settings) settings = mergeTickerPositions(settings) // 保留拖动保存的滚动条位置 if err := saveSettings(settings); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } applySettings(settings) c.JSON(http.StatusOK, settings) // 返回校正后的配置 } // handleGetStocks 获取股票行情;支持 ?codes=sh600519,sz000001 临时指定代码 func handleGetStocks(c *gin.Context) { codes := loadSettings().StockCodes if query := c.Query("codes"); query != "" { codes = nil for _, raw := range strings.Split(query, ",") { if code := normalizeStockCode(raw); code != "" { codes = append(codes, code) } } } quotes, err := fetchStockQuotes(codes) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"codes": codes, "quotes": quotes}) } // handleScreenshot 截取屏幕并返回 PNG 数据 URI。 // 支持 ?display=N 指定显示器(0 起);缺省或 -1 截取全部屏幕。 func handleScreenshot(c *gin.Context) { idx := -1 if v := c.Query("display"); v != "" { if n, err := strconv.Atoi(v); err == nil { idx = n } } dataURI, err := captureScreen(idx) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"image": dataURI, "display": idx}) }