| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336 |
- // ============================================================================
- // 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})
- }
|