siteinfosync.go 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. package tools
  2. import (
  3. "context"
  4. "fmt"
  5. "time"
  6. "github.com/mark3labs/mcp-go/mcp"
  7. "fwh-toolbox-server/agentmcp/kit"
  8. "fwh-toolbox-server/domain"
  9. "fwh-toolbox-server/repositories"
  10. "fwh-toolbox-server/services"
  11. )
  12. // 站点信息表同步(SiteInfoManageRecord)的状态判断。
  13. // 同步由主服务的任务队列执行,耗时可能超过 1.5 小时;记录只有 progress、createTime(请求时间)、
  14. // updateTime(完成时间,仅 complete 时更新)与 total,没有心跳,只能按请求时间判断是否疑似中断。
  15. const (
  16. // syncStaleAfter pending / running 超过该时长视为疑似中断(例如主服务重启),需要 force 才重新触发
  17. syncStaleAfter = 6 * time.Hour
  18. // syncFreshWithin 最近一次成功同步在该时长内时认为数据较新,不重新触发(force 除外)
  19. syncFreshWithin = 24 * time.Hour
  20. )
  21. // 同步状态
  22. const (
  23. syncStateNever = "never" // 从未同步
  24. syncStateSyncing = "syncing" // 同步中(pending / running)
  25. syncStateStale = "stale" // pending / running 但超过 syncStaleAfter,疑似中断
  26. syncStateIdle = "idle" // 没有进行中的同步(最近一次已完成或失败)
  27. )
  28. var beijing = time.FixedZone("CST", 8*3600)
  29. func fmtBeijing(t time.Time) string {
  30. return t.In(beijing).Format(time.DateTime)
  31. }
  32. func fmtHours(d time.Duration) string {
  33. if d < time.Hour {
  34. return fmt.Sprintf("%d 分钟", int(d.Minutes()))
  35. }
  36. return fmt.Sprintf("%.1f 小时", d.Hours())
  37. }
  38. type siteInfoSync struct {
  39. State string
  40. Latest *domain.SiteInfoUpdateRecord
  41. LastSuccess *domain.SiteInfoUpdateRecord
  42. now time.Time
  43. }
  44. func loadSiteInfoSync() (*siteInfoSync, error) {
  45. latest, err := repositories.GetSiteInfoUpdateNewRecord()
  46. if err != nil {
  47. return nil, err
  48. }
  49. lastComplete, err := repositories.GetSiteInfoUpdateLastComplete()
  50. if err != nil {
  51. return nil, err
  52. }
  53. return evalSiteInfoSync(latest, lastComplete, time.Now()), nil
  54. }
  55. // evalSiteInfoSync 根据最近一次记录与最近一次成功记录判断同步状态
  56. func evalSiteInfoSync(latest, lastComplete *domain.SiteInfoUpdateRecord, now time.Time) *siteInfoSync {
  57. s := &siteInfoSync{Latest: latest, LastSuccess: lastComplete, now: now}
  58. switch {
  59. case latest == nil:
  60. s.State = syncStateNever
  61. case latest.Progress == domain.ProgressPending || latest.Progress == domain.ProgressRunning:
  62. s.State = syncStateSyncing
  63. if s.now.Sub(latest.CreateTime) > syncStaleAfter {
  64. s.State = syncStateStale
  65. }
  66. default:
  67. s.State = syncStateIdle
  68. }
  69. return s
  70. }
  71. // decideRefresh 决定 site_info_refresh 是否触发同步;trigger=false 时 action 说明原因
  72. func (s *siteInfoSync) decideRefresh(force bool) (action string, trigger bool) {
  73. switch {
  74. case s.State == syncStateSyncing:
  75. return "tracking", false
  76. case s.State == syncStateStale && !force:
  77. return "skipped_stale", false
  78. case s.State == syncStateIdle && s.Latest.Progress == domain.ProgressComplete && s.fresh() && !force:
  79. return "skipped_fresh", false
  80. }
  81. return "triggered", true
  82. }
  83. // fresh 最近一次成功同步在 syncFreshWithin 内
  84. func (s *siteInfoSync) fresh() bool {
  85. return s.LastSuccess != nil && s.now.Sub(s.LastSuccess.UpdateTime) < syncFreshWithin
  86. }
  87. func (s *siteInfoSync) view() map[string]any {
  88. out := map[string]any{"state": s.State}
  89. if r := s.Latest; r != nil {
  90. latest := map[string]any{
  91. "id": r.Id.Hex(),
  92. "progress": r.Progress,
  93. "requestedAt": fmtBeijing(r.CreateTime),
  94. }
  95. if r.Progress == domain.ProgressComplete {
  96. latest["completedAt"] = fmtBeijing(r.UpdateTime)
  97. latest["duration"] = fmtHours(r.UpdateTime.Sub(r.CreateTime))
  98. latest["total"] = r.Total
  99. } else {
  100. latest["elapsed"] = fmtHours(s.now.Sub(r.CreateTime))
  101. }
  102. out["latest"] = latest
  103. }
  104. if r := s.LastSuccess; r != nil {
  105. out["lastSuccess"] = map[string]any{
  106. "completedAt": fmtBeijing(r.UpdateTime),
  107. "age": fmtHours(s.now.Sub(r.UpdateTime)),
  108. "total": r.Total,
  109. }
  110. }
  111. out["advice"] = s.advice()
  112. return out
  113. }
  114. func (s *siteInfoSync) advice() string {
  115. switch s.State {
  116. case syncStateNever:
  117. return "从未同步过,可以调用 site_info_refresh 触发同步。"
  118. case syncStateSyncing:
  119. return fmt.Sprintf("同步进行中(已 %s),无需重新触发。同步耗时可能超过 1.5 小时,稍后再用 site_info_sync_status 查看。",
  120. fmtHours(s.now.Sub(s.Latest.CreateTime)))
  121. case syncStateStale:
  122. return fmt.Sprintf("最近一次同步已请求 %s 仍未完成,超过 %s,疑似中断(如主服务重启)。确认后可用 site_info_refresh 并设置 force=true 重新同步。",
  123. fmtHours(s.now.Sub(s.Latest.CreateTime)), fmtHours(syncStaleAfter))
  124. }
  125. if s.Latest.Progress != domain.ProgressComplete {
  126. return "最近一次同步失败,可以调用 site_info_refresh 重新同步。"
  127. }
  128. if s.fresh() {
  129. return fmt.Sprintf("最近一次同步在 %s 前完成,数据较新,一般无需重新同步;确需最新数据时用 site_info_refresh 并设置 force=true。",
  130. fmtHours(s.now.Sub(s.LastSuccess.UpdateTime)))
  131. }
  132. return fmt.Sprintf("最近一次同步在 %s 前完成,超过 %s,可以调用 site_info_refresh 重新同步。",
  133. fmtHours(s.now.Sub(s.LastSuccess.UpdateTime)), fmtHours(syncFreshWithin))
  134. }
  135. // ---- site_info_sync_status ----
  136. var SiteInfoSyncStatus = kit.Def[kit.NoArgs]{
  137. Name: "site_info_sync_status",
  138. Title: "查询站点信息表同步状态",
  139. Description: "查询 ToolBox 站点信息表(site_search 的数据来源)从 FWH 平台同步的状态,只读。" +
  140. "返回 state:never(从未同步)、syncing(同步中)、stale(超过 6 小时未完成,疑似中断)、idle(没有进行中的同步);" +
  141. "latest(最近一次同步:progress 为 pending/running/complete/error,requestedAt 请求时间,完成时有 completedAt、duration、total 站点数,未完成时有 elapsed);" +
  142. "lastSuccess(最近一次成功同步的 completedAt、age 距今时长、total);advice(是否需要重新同步的建议)。时间为北京时间。" +
  143. "同步耗时可能超过 1.5 小时:触发同步前先调用本工具,同步中时用本工具跟踪进度,不要重复触发。",
  144. ReadOnly: true,
  145. Handler: func(ctx context.Context, _ kit.NoArgs) (*mcp.CallToolResult, error) {
  146. s, err := loadSiteInfoSync()
  147. if err != nil {
  148. return kit.Upstream("ToolBox 站点信息同步记录", err), nil
  149. }
  150. return kit.Object(s.view()), nil
  151. },
  152. }
  153. // ---- site_info_refresh ----
  154. var refreshMessages = map[string]string{
  155. "tracking": "同步进行中,未重复触发。",
  156. "skipped_stale": "上次同步疑似中断,未触发;确认后设置 force=true 重新同步。",
  157. "skipped_fresh": "最近 24 小时内已成功同步,未触发;确需最新数据时设置 force=true。",
  158. "triggered": "已触发同步,任务在主服务后台执行,耗时可能超过 1.5 小时;用 site_info_sync_status 跟踪进度。",
  159. }
  160. type siteInfoRefreshArgs struct {
  161. Force bool `json:"force,omitempty" jsonschema:"为 true 时:最近 24 小时内已成功同步、或上次同步疑似中断(stale)时仍重新触发。同步进行中(syncing)时任何情况下都不会重复触发"`
  162. }
  163. var SiteInfoRefresh = kit.Def[siteInfoRefreshArgs]{
  164. Name: "site_info_refresh",
  165. Title: "触发站点信息表同步",
  166. Description: "触发 ToolBox 站点信息表从 FWH 平台重新同步,立即返回,同步在主服务后台执行,耗时可能超过 1.5 小时。" +
  167. "触发前先检查同步状态:同步进行中时不重复触发,返回当前进度(action=tracking);最近 24 小时内已成功同步时不触发(action=skipped_fresh);" +
  168. "上次同步超过 6 小时未完成、疑似中断时不触发(action=skipped_stale);以上两种不触发的情况可用 force=true 强制触发。" +
  169. "已触发时 action=triggered。返回中的 status 与 site_info_sync_status 相同,之后用 site_info_sync_status 跟踪进度。" +
  170. "建议先调用 site_info_sync_status 判断是否需要同步,再调用本工具。这是写操作,执行前会请求确认。",
  171. ReadOnly: false,
  172. Handler: func(ctx context.Context, a siteInfoRefreshArgs) (*mcp.CallToolResult, error) {
  173. s, err := loadSiteInfoSync()
  174. if err != nil {
  175. return kit.Upstream("ToolBox 站点信息同步记录", err), nil
  176. }
  177. action, trigger := s.decideRefresh(a.Force)
  178. if trigger {
  179. if err := services.UpdateSiteInfoDate(); err != nil {
  180. return kit.Upstream("ToolBox 站点信息同步", err), nil
  181. }
  182. if s, err = loadSiteInfoSync(); err != nil {
  183. return kit.Upstream("ToolBox 站点信息同步记录", err), nil
  184. }
  185. }
  186. return kit.Object(map[string]any{"action": action, "message": refreshMessages[action], "status": s.view()}), nil
  187. },
  188. }