// ============================================================================ // apiserver.go — 内置 API 服务 // // 用 Gin 提供的本机 HTTP API:健康检查、通知、窗口控制、电源命令、屏幕截图、 // 鼠标提醒等;日志写控制台并存入环形缓冲(供「主机」页实时查看)。 // ============================================================================ package main import ( "archive/zip" "context" "crypto/rand" "crypto/subtle" "encoding/base64" "encoding/binary" "fmt" "io" "log" "net/http" "net/url" "os" "path/filepath" "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"}) // 健康检查 }) router.GET("/upload", handleUploadPage) // 客户端上传页(手机等设备浏览器访问) router.GET("/api/qr", handleQRImage) // 生成二维码图片(供客户端上传页/桌面端展示) api := router.Group("/api") api.POST("/notify", handleNotify) // 弹出系统托盘通知 api.POST("/window/show", handleShowWindow) // 显示主窗口 api.POST("/window/compact", handleWindowCompact) // 窗口收拢/还原(与双击手柄等效) api.POST("/mouse/alert", handleMouseAlert) // 触发系统级鼠标提醒(闪烁光标+提示文字) api.POST("/handle/flash", handleHandleFlash) // 触发拖动手柄圆点闪烁(颜色+大小变化) api.GET("/settings", handleGetSettings) // 读取配置 api.POST("/settings", handleSaveSettings) // 保存配置 api.GET("/stocks", handleGetStocks) // 获取股票行情 api.GET("/screenshot", handleScreenshot) // 截取屏幕,返回 PNG 数据 URI api.POST("/upload", handleUpload) // 上传文件(需密码) api.GET("/download/:filename", handleDownload) // 下载已上传文件(需密码) 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}) } // handleHandleFlash 触发拖动手柄圆点闪烁,例如:POST /api/handle/flash {"count":3} // count 为闪烁次数(可选,默认 3);前端收到 handleFlash 事件后播放颜色+大小变化动画。 func handleHandleFlash(c *gin.Context) { var req struct { Count int `json:"count"` } // 允许空 body(默认 3 次闪烁) _ = c.ShouldBindJSON(&req) (&SettingsService{}).HandleFlash(req.Count) c.JSON(http.StatusOK, gin.H{"ok": true}) } // handleShowWindow 显示并聚焦主窗口(从托盘恢复) func handleShowWindow(c *gin.Context) { showMainWindow() c.JSON(http.StatusOK, gin.H{"ok": true}) } // handleWindowCompact 窗口收拢/还原(与双击手柄等效)。 // 返回当前是否处于收拢(仅手柄)状态。 func handleWindowCompact(c *gin.Context) { isCompact := (&SettingsService{}).ToggleWindowCompact() c.JSON(http.StatusOK, gin.H{"ok": true, "compact": isCompact}) } // 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}) } // ------------------------------------------------------------------------ // 文件上传 / 下载(需密码) // // 上传:POST /api/upload 表单字段 password(密码)、file(文件)。 // 密码正确才保存到 uploadDir 目录;返回文件 URL 供二维码展示。 // 下载:GET /api/download/:filename?password=xxx 校验密码后返回文件内容。 // ------------------------------------------------------------------------ // uploadDirPath 返回上传保存目录:配置为空时回退到用户配置目录下 uploads/。 func uploadDirPath() string { dir := strings.TrimSpace(loadSettings().UploadDir) if dir != "" { return dir } cfg, err := os.UserConfigDir() if err != nil { return "." // 兜底:当前目录 } return filepath.Join(cfg, "TengTuToolbox", "uploads") } // checkUploadPassword 校验上传密码(常数时间比较,防时序侧信道)。 // 未设置密码时允许访问(与「空密码=不启用」语义一致)。 func checkUploadPassword(pwd string) bool { expected := loadSettings().UploadPassword if strings.TrimSpace(expected) == "" { return true // 未设置密码:默认放行(用户未启用该功能) } return subtle.ConstantTimeCompare([]byte(pwd), []byte(expected)) == 1 } // handleUpload 处理文件上传:密码正确才落盘,返回下载 URL 与文件信息。 func handleUpload(c *gin.Context) { // 1) 校验密码(表单字段 password) pwd := c.PostForm("password") if !checkUploadPassword(pwd) { c.JSON(http.StatusUnauthorized, gin.H{"error": "密码错误"}) return } // 2) 读取上传文件 fileHeader, err := c.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "缺少文件字段 file"}) return } if fileHeader.Size <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "文件为空"}) return } // 3) 校验文件大小:上限由配置「上传大小上限」决定(默认 512MB) maxMB := loadSettings().UploadMaxSize if maxMB <= 0 { maxMB = 512 } maxUploadSize := int64(maxMB) << 20 // MB -> 字节 if fileHeader.Size > maxUploadSize { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("文件过大,上限 %dMB", maxMB)}) return } // 4) 构造保存路径:仅用文件名(去路径),避免路径穿越 dir := uploadDirPath() if err := os.MkdirAll(dir, 0o755); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "无法创建上传目录: " + err.Error()}) return } origBase := filepath.Base(strings.ReplaceAll(fileHeader.Filename, "\\", "/")) if origBase == "." || origBase == "" { origBase = "upload.bin" } // 5) 落盘:存储文件名用「时间戳+随机数+原扩展名」,不可推测且天然唯一,避免覆盖与遍历。 // zip 内条目保留原文件名,下载者解压后仍能看到原始文件名。 storedName := newStoredName(origBase) final := filepath.Join(dir, storedName) if err := c.SaveUploadedFile(fileHeader, final); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败: " + err.Error()}) return } // 6) 安全处理:把上传的原文件压缩为 zip(zip 内保留原文件名,文件名同样用时间戳+随机数), // 压缩成功后删除原文件,之后仅提供 zip 下载,避免可执行文件等被直接下载。 zipName := newStoredName(origBase + ".zip") zipPath := filepath.Join(dir, zipName) if err := zipFile(final, zipPath, origBase); err != nil { // 压缩失败:删除原文件并报错,避免遗留未压缩文件 os.Remove(final) c.JSON(http.StatusInternalServerError, gin.H{"error": "压缩文件失败: " + err.Error()}) return } os.Remove(final) // 压缩成功,删除原文件 // 7) 返回下载 URL(指向 zip,相对路径,前端拼完整地址)与文件信息 savedName := filepath.Base(zipPath) downloadURL := "/api/download/" + url.PathEscape(savedName) fmt.Fprintf(apiLogRing, "[SYS] 文件已上传并压缩: %s -> %s (%d 字节)\n", origBase, savedName, fileHeader.Size) c.JSON(http.StatusOK, gin.H{ "ok": true, "filename": origBase, "size": fileHeader.Size, "downloadUrl": downloadURL, }) } // newStoredName 生成存储文件名:时间戳_6位随机数+原扩展名。 // 扩展名做白名单过滤(仅保留字母数字,最长 10),防止可疑后缀。 func newStoredName(orig string) string { ext := filepath.Ext(orig) ext = strings.Map(func(r rune) rune { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' { return r } return -1 }, ext) if len(ext) > 10 { ext = ext[:10] } ts := time.Now().Format("20060102_150405") rnd := fmt.Sprintf("%06d", cryptoRandInt(1000000)) return fmt.Sprintf("%s_%s%s", ts, rnd, ext) } // cryptoRandInt 返回 [0, n) 的随机整数(crypto/rand),失败时回退 time 纳秒。 func cryptoRandInt(n int) int { var b [8]byte if _, err := rand.Read(b[:]); err != nil { return int(time.Now().UnixNano() % int64(n)) } v := int64(binary.BigEndian.Uint64(b[:])) if v < 0 { v = -v } return int(v % int64(n)) } // zipFile 把 src 文件压缩为 dst zip,zip 内条目名用 name(一般取原文件名)。 func zipFile(src, dst, name string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.Create(dst) if err != nil { return err } defer out.Close() zw := zip.NewWriter(out) defer zw.Close() w, err := zw.Create(name) if err != nil { return err } _, err = io.Copy(w, in) return err } // handleDownload 处理文件下载:密码正确才返回文件内容。 func handleDownload(c *gin.Context) { if !checkUploadPassword(c.Query("password")) { c.JSON(http.StatusUnauthorized, gin.H{"error": "密码错误"}) return } name := filepath.Base(strings.ReplaceAll(c.Param("filename"), "\\", "/")) if name == "." || name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "文件名无效"}) return } path := filepath.Join(uploadDirPath(), name) if _, err := os.Stat(path); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"}) return } c.FileAttachment(path, name) } // ------------------------------------------------------------------------ // 客户端上传页:手机/其他设备在浏览器中访问 http://<服务地址>/upload, // 输入密码并选择文件即可上传到本机,上传成功显示下载二维码。 // ------------------------------------------------------------------------ const clientUploadHTML = `