siteinfosync.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. package tools
  2. import (
  3. "context"
  4. "fmt"
  5. "time"
  6. "github.com/mark3labs/mcp-go/mcp"
  7. "go.mongodb.org/mongo-driver/bson/primitive"
  8. "fwh-toolbox-server/agentmcp/kit"
  9. "fwh-toolbox-server/domain"
  10. "fwh-toolbox-server/repositories"
  11. "fwh-toolbox-server/services"
  12. "fwh-toolbox-server/task/queue/fwhQueue"
  13. )
  14. // 站点信息表同步(SiteInfoManageRecord)的状态判断。
  15. // 同步由主服务的任务队列执行,耗时可能超过 1.5 小时;记录只有 progress、createTime(请求时间)、
  16. // updateTime(完成时间,仅 complete 时更新)与 total,没有心跳,只能按请求时间判断是否疑似中断。
  17. const (
  18. // syncStaleAfter pending / running 超过该时长视为疑似中断(例如主服务重启),需要 force 才重新触发
  19. syncStaleAfter = 6 * time.Hour
  20. // syncFreshWithin 最近一次成功同步在该时长内时认为数据较新,不重新触发(force 除外)
  21. syncFreshWithin = 24 * time.Hour
  22. )
  23. // 同步状态
  24. const (
  25. syncStateNever = "never" // 从未同步
  26. syncStateSyncing = "syncing" // 同步中(pending / running)
  27. syncStateStale = "stale" // pending / running 但超过 syncStaleAfter,疑似中断
  28. syncStateIdle = "idle" // 没有进行中的同步(最近一次已完成或失败)
  29. )
  30. var beijing = time.FixedZone("CST", 8*3600)
  31. func fmtBeijing(t time.Time) string {
  32. return t.In(beijing).Format(time.DateTime)
  33. }
  34. func fmtHours(d time.Duration) string {
  35. if d < time.Hour {
  36. return fmt.Sprintf("%d 分钟", int(d.Minutes()))
  37. }
  38. return fmt.Sprintf("%.1f 小时", d.Hours())
  39. }
  40. type siteInfoSync struct {
  41. State string
  42. Latest *domain.SiteInfoUpdateRecord
  43. LastSuccess *domain.SiteInfoUpdateRecord
  44. now time.Time
  45. }
  46. func loadSiteInfoSync() (*siteInfoSync, error) {
  47. latest, err := repositories.GetSiteInfoUpdateNewRecord()
  48. if err != nil {
  49. return nil, err
  50. }
  51. lastComplete, err := repositories.GetSiteInfoUpdateLastComplete()
  52. if err != nil {
  53. return nil, err
  54. }
  55. return evalSiteInfoSync(latest, lastComplete, time.Now()), nil
  56. }
  57. // evalSiteInfoSync 根据最近一次记录与最近一次成功记录判断同步状态
  58. func evalSiteInfoSync(latest, lastComplete *domain.SiteInfoUpdateRecord, now time.Time) *siteInfoSync {
  59. // 最近一条已完成时它就是最近一次成功(调用方可能只传了最近一条)
  60. if lastComplete == nil && latest != nil && latest.Progress == domain.ProgressComplete {
  61. lastComplete = latest
  62. }
  63. s := &siteInfoSync{Latest: latest, LastSuccess: lastComplete, now: now}
  64. switch {
  65. case latest == nil:
  66. s.State = syncStateNever
  67. case latest.Progress == domain.ProgressPending || latest.Progress == domain.ProgressRunning:
  68. s.State = syncStateSyncing
  69. if s.now.Sub(latest.CreateTime) > syncStaleAfter {
  70. s.State = syncStateStale
  71. }
  72. default:
  73. s.State = syncStateIdle
  74. }
  75. return s
  76. }
  77. // decideRefresh 决定 site_info_refresh 是否触发同步;trigger=false 时 action 说明原因
  78. func (s *siteInfoSync) decideRefresh(force bool) (action string, trigger bool) {
  79. switch {
  80. case s.State == syncStateSyncing:
  81. return "tracking", false
  82. case s.State == syncStateStale && !force:
  83. return "skipped_stale", false
  84. case s.State == syncStateIdle && s.Latest.Progress == domain.ProgressComplete && s.fresh() && !force:
  85. return "skipped_fresh", false
  86. }
  87. return "triggered", true
  88. }
  89. // fresh 最近一次成功同步在 syncFreshWithin 内
  90. func (s *siteInfoSync) fresh() bool {
  91. return s.LastSuccess != nil && s.now.Sub(s.LastSuccess.UpdateTime) < syncFreshWithin
  92. }
  93. func (s *siteInfoSync) view() map[string]any {
  94. out := map[string]any{"state": s.State}
  95. if r := s.Latest; r != nil {
  96. latest := map[string]any{
  97. "id": r.Id.Hex(),
  98. "progress": r.Progress,
  99. "requestedAt": fmtBeijing(r.CreateTime),
  100. }
  101. if r.Progress == domain.ProgressComplete {
  102. latest["completedAt"] = fmtBeijing(r.UpdateTime)
  103. latest["duration"] = fmtHours(r.UpdateTime.Sub(r.CreateTime))
  104. latest["total"] = r.Total
  105. } else {
  106. latest["elapsed"] = fmtHours(s.now.Sub(r.CreateTime))
  107. }
  108. out["latest"] = latest
  109. }
  110. if r := s.LastSuccess; r != nil {
  111. out["lastSuccess"] = map[string]any{
  112. "completedAt": fmtBeijing(r.UpdateTime),
  113. "age": fmtHours(s.now.Sub(r.UpdateTime)),
  114. "total": r.Total,
  115. }
  116. }
  117. out["advice"] = s.advice()
  118. return out
  119. }
  120. func (s *siteInfoSync) advice() string {
  121. switch s.State {
  122. case syncStateNever:
  123. return "从未同步过,可以调用 site_info_refresh 触发同步。"
  124. case syncStateSyncing:
  125. return fmt.Sprintf("同步进行中(已 %s),无需重新触发。同步耗时可能超过 1.5 小时,稍后再用 site_info_sync_status 查看。",
  126. fmtHours(s.now.Sub(s.Latest.CreateTime)))
  127. case syncStateStale:
  128. return fmt.Sprintf("最近一次同步已请求 %s 仍未完成,超过 %s,疑似中断(如主服务重启)。确认后可用 site_info_refresh 并设置 force=true 重新同步。",
  129. fmtHours(s.now.Sub(s.Latest.CreateTime)), fmtHours(syncStaleAfter))
  130. }
  131. if s.Latest.Progress != domain.ProgressComplete {
  132. return "最近一次同步失败,可以调用 site_info_refresh 重新同步。"
  133. }
  134. if s.fresh() {
  135. return fmt.Sprintf("最近一次同步在 %s 前完成,数据较新,一般无需重新同步;确需最新数据时用 site_info_refresh 并设置 force=true。",
  136. fmtHours(s.now.Sub(s.LastSuccess.UpdateTime)))
  137. }
  138. return fmt.Sprintf("最近一次同步在 %s 前完成,超过 %s,可以调用 site_info_refresh 重新同步。",
  139. fmtHours(s.now.Sub(s.LastSuccess.UpdateTime)), fmtHours(syncFreshWithin))
  140. }
  141. // ---- site_info_sync_status ----
  142. var SiteInfoSyncStatus = kit.Def[kit.NoArgs]{
  143. Name: "site_info_sync_status",
  144. Title: "查询站点信息表同步状态",
  145. Description: "查询 ToolBox 站点信息表(site_search 的数据来源)从 FWH 平台同步的状态,只读。" +
  146. "返回 state:never(从未同步)、syncing(同步中)、stale(超过 6 小时未完成,疑似中断)、idle(没有进行中的同步);" +
  147. "latest(最近一次同步:progress 为 pending/running/complete/error,requestedAt 请求时间,完成时有 completedAt、duration、total 站点数,未完成时有 elapsed);" +
  148. "lastSuccess(最近一次成功同步的 completedAt、age 距今时长、total);advice(是否需要重新同步的建议)。时间为北京时间。" +
  149. "同步耗时可能超过 1.5 小时:触发同步前先调用本工具,同步中时用本工具跟踪进度,不要重复触发。",
  150. ReadOnly: true,
  151. Handler: func(ctx context.Context, _ kit.NoArgs) (*mcp.CallToolResult, error) {
  152. s, err := loadSiteInfoSync()
  153. if err != nil {
  154. return kit.Upstream("ToolBox 站点信息同步记录", err), nil
  155. }
  156. return kit.Object(s.view()), nil
  157. },
  158. }
  159. // ---- site_info_refresh ----
  160. var refreshMessages = map[string]string{
  161. "tracking": "同步进行中,未重复触发。",
  162. "skipped_stale": "上次同步疑似中断,未触发;确认后设置 force=true 重新同步。",
  163. "skipped_fresh": "最近 24 小时内已成功同步,未触发;确需最新数据时设置 force=true。",
  164. "triggered": "已触发同步,任务在主服务后台执行,耗时可能超过 1.5 小时;用 site_info_sync_status 跟踪进度。",
  165. }
  166. type siteInfoRefreshArgs struct {
  167. Force bool `json:"force,omitempty" jsonschema:"为 true 时:最近 24 小时内已成功同步、或上次同步疑似中断(stale)时仍重新触发。同步进行中(syncing)时任何情况下都不会重复触发"`
  168. }
  169. // siteInfoTaskPoll 平台查询任务状态的间隔:同步约 1 小时,每分钟查一次足够
  170. const siteInfoTaskPoll = time.Minute
  171. var SiteInfoRefresh = kit.TaskDef[siteInfoRefreshArgs]{
  172. Def: kit.Def[siteInfoRefreshArgs]{
  173. Name: "site_info_refresh",
  174. Title: "触发站点信息表同步",
  175. Description: "触发 ToolBox 站点信息表从 FWH 平台重新同步,同步在主服务后台执行,耗时约 1 小时,可能超过 1.5 小时。" +
  176. "触发前会检查同步状态:同步进行中时不重复触发,改为跟踪这次同步(action=tracking);最近 24 小时内已成功同步时不触发(action=skipped_fresh);" +
  177. "上次同步超过 6 小时未完成、疑似中断时不触发(action=skipped_stale);这两种不触发的情况可用 force=true 强制触发。已触发时 action=triggered。" +
  178. "以长任务方式执行:触发或跟踪时立即返回“已转为后台”,同步结束后平台会把结果(完成时间、站点数、耗时,或失败原因)发给你,无需轮询;" +
  179. "用户询问进度时用 site_info_sync_status。这是写操作,执行前会请求确认。",
  180. ReadOnly: false,
  181. // 同步中或数据较新时不重复触发,网络出错时平台重试一次不会产生第二次同步
  182. Idempotent: true,
  183. Handler: func(ctx context.Context, a siteInfoRefreshArgs) (*mcp.CallToolResult, error) {
  184. action, s, fail := refreshSiteInfo(a.Force)
  185. if fail != nil {
  186. return fail, nil
  187. }
  188. return kit.Object(map[string]any{"action": action, "message": refreshMessages[action], "status": s.view()}), nil
  189. },
  190. },
  191. Poll: siteInfoTaskPoll,
  192. Start: func(ctx context.Context, a siteInfoRefreshArgs) (kit.TaskStart, *mcp.CallToolResult) {
  193. action, s, fail := refreshSiteInfo(a.Force)
  194. if fail != nil {
  195. return kit.TaskStart{}, fail
  196. }
  197. // 每种结果都对应一条同步记录:触发的是新记录,跟踪、不触发的是最近一条记录
  198. note := refreshMessages[action]
  199. if action == "triggered" || action == "tracking" {
  200. note += "同步结束后平台会把结果发给你,现在可以先告诉用户已提交。"
  201. }
  202. return kit.TaskStart{Key: s.Latest.Id.Hex(), Flag: action, Note: note}, nil
  203. },
  204. Status: func(ctx context.Context, ref kit.TaskRef) (kit.TaskState, error) {
  205. id, err := primitive.ObjectIDFromHex(ref.Key)
  206. if err != nil {
  207. return kit.TaskState{}, kit.ErrTaskNotFound
  208. }
  209. rec, err := repositories.GetSiteInfoUpdateRecord(id)
  210. if err != nil {
  211. return kit.TaskState{}, err
  212. }
  213. if rec == nil {
  214. return kit.TaskState{}, kit.ErrTaskNotFound
  215. }
  216. return siteInfoTaskState(ref.Flag, rec, time.Now()), nil
  217. },
  218. Cancel: func(ctx context.Context, ref kit.TaskRef) error {
  219. // 只有本次调用触发的同步才真正停止;跟踪他人触发的同步时只是停止跟踪(其他会话可能还在等它)
  220. if ref.Flag != "triggered" {
  221. return nil
  222. }
  223. id, err := primitive.ObjectIDFromHex(ref.Key)
  224. if err != nil {
  225. return kit.ErrTaskNotFound
  226. }
  227. return fwhQueue.CancelSiteInfoUpdate(id)
  228. },
  229. }
  230. // refreshSiteInfo 判断并在需要时触发同步,返回动作与触发后的状态;普通调用与任务方式调用共用
  231. func refreshSiteInfo(force bool) (string, *siteInfoSync, *mcp.CallToolResult) {
  232. s, err := loadSiteInfoSync()
  233. if err != nil {
  234. return "", nil, kit.Upstream("ToolBox 站点信息同步记录", err)
  235. }
  236. action, trigger := s.decideRefresh(force)
  237. if trigger {
  238. if err := services.UpdateSiteInfoDate(); err != nil {
  239. return "", nil, kit.Upstream("ToolBox 站点信息同步", err)
  240. }
  241. if s, err = loadSiteInfoSync(); err != nil {
  242. return "", nil, kit.Upstream("ToolBox 站点信息同步记录", err)
  243. }
  244. }
  245. return action, s, nil
  246. }
  247. // siteInfoTaskState 把同步记录翻译为任务状态。flag 为发起任务时的动作:
  248. // skipped_* 时任务立即完成(结果是不触发的原因);triggered / tracking 时跟随这条同步记录
  249. func siteInfoTaskState(flag string, rec *domain.SiteInfoUpdateRecord, now time.Time) kit.TaskState {
  250. st := kit.TaskState{CreatedAt: rec.CreateTime}
  251. if flag == "skipped_fresh" || flag == "skipped_stale" {
  252. st.Status, st.Message = mcp.TaskStatusCompleted, "未触发同步"
  253. st.Result = kit.Object(map[string]any{"action": flag, "message": refreshMessages[flag], "status": evalSiteInfoSync(rec, nil, now).view()})
  254. return st
  255. }
  256. elapsed := now.Sub(rec.CreateTime)
  257. switch rec.Progress {
  258. case domain.ProgressPending, domain.ProgressRunning:
  259. if elapsed > syncStaleAfter {
  260. st.Status = mcp.TaskStatusFailed
  261. st.Message = fmt.Sprintf("同步已 %s 仍未完成,疑似中断", fmtHours(elapsed))
  262. st.Result = kit.Failf("站点信息同步已请求 %s 仍未完成,超过 %s,疑似中断(如主服务重启)。可以告诉用户,确认后用 site_info_refresh 并设置 force=true 重新同步。",
  263. fmtHours(elapsed), fmtHours(syncStaleAfter))
  264. return st
  265. }
  266. st.Status = mcp.TaskStatusWorking
  267. if rec.Progress == domain.ProgressPending {
  268. st.Message = fmt.Sprintf("排队中,已等待 %s", fmtHours(elapsed))
  269. } else {
  270. st.Message = fmt.Sprintf("同步中,已进行 %s", fmtHours(elapsed))
  271. }
  272. case domain.ProgressComplete:
  273. st.Status, st.Message = mcp.TaskStatusCompleted, "同步完成"
  274. st.Result = kit.Object(map[string]any{
  275. "message": fmt.Sprintf("站点信息表同步完成:%d 个站点,耗时 %s,完成于 %s(北京时间)。", rec.Total, fmtHours(rec.UpdateTime.Sub(rec.CreateTime)), fmtBeijing(rec.UpdateTime)),
  276. "requestedAt": fmtBeijing(rec.CreateTime),
  277. "completedAt": fmtBeijing(rec.UpdateTime),
  278. "total": rec.Total,
  279. })
  280. case domain.ProgressCancelled:
  281. st.Status, st.Message = mcp.TaskStatusCancelled, "同步已取消"
  282. st.Result = kit.Failf("站点信息同步已取消。")
  283. default: // error / failed
  284. st.Status, st.Message = mcp.TaskStatusFailed, "同步失败"
  285. st.Result = kit.Failf("站点信息同步失败(主服务同步任务出错,详情见主服务日志)。可以稍后用 site_info_refresh 重新同步。")
  286. }
  287. return st
  288. }