apiserver.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. // ============================================================================
  2. // apiserver.go — 内置 API 服务
  3. //
  4. // 用 Gin 提供的本机 HTTP API:健康检查、通知、窗口控制、电源命令、屏幕截图、
  5. // 鼠标提醒等;日志写控制台并存入环形缓冲(供「主机」页实时查看)。
  6. // ============================================================================
  7. package main
  8. import (
  9. "context"
  10. "fmt"
  11. "io"
  12. "log"
  13. "net/http"
  14. "os"
  15. "strconv"
  16. "strings"
  17. "sync"
  18. "time"
  19. "github.com/gen2brain/beeep" // 跨平台系统通知,Windows 下为托盘气泡/Toast
  20. "github.com/gin-gonic/gin"
  21. )
  22. // notifyRequest 通知接口的请求体
  23. type notifyRequest struct {
  24. Title string `json:"title"` // 通知标题,为空时使用应用名
  25. Message string `json:"message"` // 通知正文
  26. }
  27. // mouseAlertRequest 鼠标提醒接口的请求体
  28. type mouseAlertRequest struct {
  29. Text string `json:"text"` // 提示文字,为空时使用默认文案
  30. }
  31. // ------------------------------------------------------------------------
  32. // Gin 日志环形缓冲:日志同时写控制台与内存缓冲,供「主机」页实时查看
  33. // ------------------------------------------------------------------------
  34. const apiLogCapacity = 300 // 环形缓冲最多保留的日志行数
  35. // logRing 固定容量的日志环形缓冲,实现 io.Writer
  36. type logRing struct {
  37. mu sync.Mutex
  38. lines []string
  39. }
  40. // Write 按行拆分写入缓冲,并向所有前端窗口广播 "apilog" 事件
  41. func (r *logRing) Write(p []byte) (int, error) {
  42. text := strings.TrimRight(string(p), "\r\n")
  43. r.mu.Lock()
  44. for _, line := range strings.Split(text, "\n") {
  45. if strings.TrimSpace(line) == "" {
  46. continue
  47. }
  48. r.lines = append(r.lines, line)
  49. if len(r.lines) > apiLogCapacity {
  50. r.lines = r.lines[len(r.lines)-apiLogCapacity:] // 丢弃最旧的行
  51. }
  52. if app != nil {
  53. app.Event.Emit("apilog", line) // 实时推送到「主机」页
  54. }
  55. }
  56. r.mu.Unlock()
  57. return len(p), nil
  58. }
  59. // Snapshot 返回当前缓冲的全部日志行
  60. func (r *logRing) Snapshot() []string {
  61. r.mu.Lock()
  62. defer r.mu.Unlock()
  63. out := make([]string, len(r.lines))
  64. copy(out, r.lines)
  65. return out
  66. }
  67. // Clear 清空日志缓冲
  68. func (r *logRing) Clear() {
  69. r.mu.Lock()
  70. r.lines = nil
  71. r.mu.Unlock()
  72. }
  73. var apiLogRing = &logRing{}
  74. // ------------------------------------------------------------------------
  75. // API 服务生命周期:地址变化时平滑重启(仅监听配置的地址)
  76. // ------------------------------------------------------------------------
  77. var (
  78. apiMu sync.Mutex
  79. apiServer *http.Server
  80. apiAddr string // 当前监听地址
  81. apiStartedAt time.Time // 本次启动时间
  82. apiLastErr string // 最近一次启动 / 运行错误
  83. )
  84. // StopAPIServer 停止内置 API 服务(从服务页手动关闭)。
  85. // 停止后 apiServer 置 nil、apiAddr 保留,便于再次启动或恢复。
  86. func StopAPIServer() {
  87. apiMu.Lock()
  88. old := apiServer
  89. apiServer = nil
  90. apiAddr = ""
  91. apiLastErr = ""
  92. apiMu.Unlock()
  93. if old != nil {
  94. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  95. _ = old.Shutdown(ctx) // 平滑关闭,中断不复用连接
  96. cancel()
  97. }
  98. log.Printf("API server stopped by user")
  99. fmt.Fprintf(apiLogRing, "[SYS] API 服务已停止\n")
  100. }
  101. // StartAPIServer 启动 / 重启内置 API 服务(从服务页手动启动或恢复)。
  102. // 使用配置中的地址(为空时用默认地址)。
  103. func StartAPIServer() {
  104. ensureAPIServer(loadSettings().APIAddr)
  105. }
  106. // APIStatus API 服务运行状态,供「主机」页展示
  107. type APIStatus struct {
  108. Addr string `json:"addr"` // 当前监听地址
  109. Running bool `json:"running"` // 是否正在监听
  110. StartedAt string `json:"startedAt"` // 启动时间
  111. Error string `json:"error"` // 最近错误信息
  112. }
  113. // ensureAPIServer 确保 API 服务监听在指定地址;地址变化时先关闭旧服务再启动
  114. func ensureAPIServer(addr string) {
  115. addr = strings.TrimSpace(addr)
  116. if addr == "" {
  117. addr = DefaultAPIAddr
  118. }
  119. apiMu.Lock()
  120. if apiServer != nil && apiAddr == addr && apiLastErr == "" {
  121. apiMu.Unlock()
  122. return // 地址未变且运行正常,无需重启
  123. }
  124. old := apiServer
  125. apiServer = nil
  126. apiAddr = addr
  127. apiStartedAt = time.Now()
  128. apiLastErr = ""
  129. apiMu.Unlock()
  130. if old != nil {
  131. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  132. _ = old.Shutdown(ctx) // 平滑关闭旧服务,中断不复用连接
  133. cancel()
  134. }
  135. go runAPIServer(addr)
  136. }
  137. // runAPIServer 构建路由并启动监听(出错只记录状态,不影响应用本身)
  138. func runAPIServer(addr string) {
  139. gin.SetMode(gin.ReleaseMode) // 生产模式,减少日志噪音
  140. router := gin.New()
  141. router.Use(gin.LoggerWithConfig(gin.LoggerConfig{
  142. Output: io.MultiWriter(os.Stdout, apiLogRing), // 日志同时写控制台与环形缓冲
  143. }))
  144. router.Use(gin.Recovery()) // 捕获 panic,避免接口崩溃导致进程退出
  145. router.Use(corsMiddleware()) // 允许跨域,便于前端或其他本机工具调用
  146. router.GET("/health", func(c *gin.Context) {
  147. c.JSON(http.StatusOK, gin.H{"status": "ok"}) // 健康检查
  148. })
  149. api := router.Group("/api")
  150. api.POST("/notify", handleNotify) // 弹出系统托盘通知
  151. api.POST("/window/show", handleShowWindow) // 显示主窗口
  152. api.POST("/mouse/alert", handleMouseAlert) // 触发系统级鼠标提醒(闪烁光标+提示文字)
  153. api.GET("/settings", handleGetSettings) // 读取配置
  154. api.POST("/settings", handleSaveSettings) // 保存配置
  155. api.GET("/stocks", handleGetStocks) // 获取股票行情
  156. api.GET("/screenshot", handleScreenshot) // 截取屏幕,返回 PNG 数据 URI
  157. pc := api.Group("/pc") // 主机控制接口(移植自主机服务)
  158. pc.GET("/poweroff", handlePoweroff) // 30 秒后关机
  159. pc.GET("/restart", handleRestart) // 30 秒后重启
  160. pc.GET("/sleep", handleSleep) // 30 秒后休眠
  161. pc.GET("/lock", handleLock) // 立即锁屏
  162. pc.GET("/cancel", handleCancel) // 取消待执行命令
  163. pc.GET("/status", handlePCStatus) // 查询命令状态
  164. pc.GET("/timesync", handleTimeSync) // 同步系统时间
  165. server := &http.Server{Addr: addr, Handler: router}
  166. apiMu.Lock()
  167. apiServer = server
  168. apiMu.Unlock()
  169. log.Printf("API server listening on http://%s", addr)
  170. fmt.Fprintf(apiLogRing, "[SYS] API 服务已启动 http://%s\n", addr)
  171. if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
  172. apiMu.Lock()
  173. apiLastErr = err.Error() // 端口被占用等错误只记录,不影响应用启动
  174. apiMu.Unlock()
  175. log.Printf("api server stopped: %v", err)
  176. fmt.Fprintf(apiLogRing, "[SYS] API 服务异常停止: %v\n", err)
  177. }
  178. }
  179. // getAPIStatus 返回当前 API 服务状态快照
  180. func getAPIStatus() APIStatus {
  181. apiMu.Lock()
  182. defer apiMu.Unlock()
  183. started := ""
  184. if !apiStartedAt.IsZero() {
  185. started = apiStartedAt.Format("01-02 15:04:05")
  186. }
  187. return APIStatus{
  188. Addr: apiAddr,
  189. Running: apiServer != nil,
  190. StartedAt: started,
  191. Error: apiLastErr,
  192. }
  193. }
  194. // corsMiddleware 简单放行跨域请求(服务默认仅监听 127.0.0.1,风险可控)
  195. func corsMiddleware() gin.HandlerFunc {
  196. return func(c *gin.Context) {
  197. c.Header("Access-Control-Allow-Origin", "*")
  198. c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
  199. c.Header("Access-Control-Allow-Headers", "Content-Type")
  200. if c.Request.Method == http.MethodOptions {
  201. c.AbortWithStatus(http.StatusNoContent) // 预检请求直接返回
  202. return
  203. }
  204. c.Next()
  205. }
  206. }
  207. // handleNotify 通过系统通知弹出消息,例如:POST /api/notify {"message":"任务完成"}
  208. func handleNotify(c *gin.Context) {
  209. var req notifyRequest
  210. if err := c.ShouldBindJSON(&req); err != nil {
  211. c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
  212. return
  213. }
  214. if req.Title == "" {
  215. req.Title = "腾图工具箱" // 未指定标题时使用默认标题
  216. }
  217. if err := beeep.Notify(req.Title, req.Message, ""); err != nil {
  218. c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
  219. return
  220. }
  221. c.JSON(http.StatusOK, gin.H{"ok": true}) // 通知已发出
  222. }
  223. // handleMouseAlert 触发系统级鼠标提醒,例如:POST /api/mouse/alert {"text":"有新订单"}
  224. func handleMouseAlert(c *gin.Context) {
  225. var req mouseAlertRequest
  226. if err := c.ShouldBindJSON(&req); err != nil {
  227. c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
  228. return
  229. }
  230. triggerMouseAlert(req.Text) // 触发鼠标闪烁 + 光标下方透明文字
  231. c.JSON(http.StatusOK, gin.H{"ok": true})
  232. }
  233. // handleShowWindow 显示并聚焦主窗口(从托盘恢复)
  234. func handleShowWindow(c *gin.Context) {
  235. showMainWindow()
  236. c.JSON(http.StatusOK, gin.H{"ok": true})
  237. }
  238. // handleGetSettings 返回当前配置
  239. func handleGetSettings(c *gin.Context) {
  240. c.JSON(http.StatusOK, loadSettings())
  241. }
  242. // handleSaveSettings 保存配置并立即生效
  243. func handleSaveSettings(c *gin.Context) {
  244. var settings AppSettings
  245. if err := c.ShouldBindJSON(&settings); err != nil {
  246. c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
  247. return
  248. }
  249. settings = normalizeSettings(settings)
  250. settings = mergeTickerPositions(settings) // 保留拖动保存的滚动条位置
  251. if err := saveSettings(settings); err != nil {
  252. c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
  253. return
  254. }
  255. applySettings(settings)
  256. c.JSON(http.StatusOK, settings) // 返回校正后的配置
  257. }
  258. // handleGetStocks 获取股票行情;支持 ?codes=sh600519,sz000001 临时指定代码
  259. func handleGetStocks(c *gin.Context) {
  260. codes := loadSettings().StockCodes
  261. if query := c.Query("codes"); query != "" {
  262. codes = nil
  263. for _, raw := range strings.Split(query, ",") {
  264. if code := normalizeStockCode(raw); code != "" {
  265. codes = append(codes, code)
  266. }
  267. }
  268. }
  269. quotes, err := fetchStockQuotes(codes)
  270. if err != nil {
  271. c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
  272. return
  273. }
  274. c.JSON(http.StatusOK, gin.H{"codes": codes, "quotes": quotes})
  275. }
  276. // handleScreenshot 截取屏幕并返回 PNG 数据 URI。
  277. // 支持 ?display=N 指定显示器(0 起);缺省或 -1 截取全部屏幕。
  278. func handleScreenshot(c *gin.Context) {
  279. idx := -1
  280. if v := c.Query("display"); v != "" {
  281. if n, err := strconv.Atoi(v); err == nil {
  282. idx = n
  283. }
  284. }
  285. dataURI, err := captureScreen(idx)
  286. if err != nil {
  287. c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
  288. return
  289. }
  290. c.JSON(http.StatusOK, gin.H{"image": dataURI, "display": idx})
  291. }