Parcourir la source

feat:站点信息同步新增状态查询,site_info_refresh触发前判断是否需要同步

- 新增只读工具 site_info_sync_status:最近一次同步进度、请求/完成时间、最近一次成功同步与建议
- site_info_refresh 立即返回:同步中不重复触发;24 小时内已成功或超过 6 小时疑似中断时不触发,force 可强制
- 修复 fwh-agent-mcp 调用 site_info_refresh 时任务队列客户端为空导致 panic:只创建入队客户端
- 任务队列 Redis 库号提为 task_core.RedisDB,主服务与 MCP 共用
- 入队失败时把同步记录标记为 error,避免一直停在 pending

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang il y a 5 jours
Parent
commit
9e797e6239

+ 2 - 1
deploy/agent-assistant/skills/fwh-assistant/SKILL.md

@@ -18,7 +18,7 @@ description: FranklinWH(FWH)储能业务数据助手。只要问题涉及 Fr
 
 | 领域 | 工具 |
 | --- | --- |
-| 站点 | `site_search`(搜索/分页)、`site_get`(单站点概况:版本、网络、工作模式、当前告警数)、`site_power_flow`(单日功率流)、`site_power_flow_export`(多日导出 CSV) |
+| 站点 | `site_search`(搜索/分页)、`site_get`(单站点概况:版本、网络、工作模式、当前告警数)、`site_power_flow`(单日功率流)、`site_power_flow_export`(多日导出 CSV)、`site_info_sync_status`(站点信息表同步状态与最近同步时间) |
 | 告警 | `alarm_current`(当前告警)、`alarm_events`(时间范围内告警/事件)、`alarm_snapshot`(某日快照)、`alarm_stats`(全量统计)、`alarm_sites_by_code`(触发某告警码的站点) |
 | 停机 | `shutdown_stats`(某日汇总,不传日期取最新)、`shutdown_sites`(某日停机站点) |
 | 日志 | `mqtt_log_search`、`ems_log_records`、`update_log_system`(传记录 ID 看详情)、`update_log_ahub` |
@@ -38,6 +38,7 @@ description: FranklinWH(FWH)储能业务数据助手。只要问题涉及 Fr
 - **升级问题**:`update_log_system` 找到记录 → 传记录 ID 看详情;aHub 用 `update_log_ahub`。
 - **TOU 模版**:`tou_template_list` → `tou_template_get`;字段与调度模式见 references/tou-template.md。对比生产服与测试服时分别用 `env=prod`、`env=test` 取详情再逐策略、逐时段比较。
 - **SIM 卡**:`sim_get` / `sim_search`;用量单位以工具说明为准,月度明细用 `sim_usage_monthly`。
+- **站点信息是否最新**:`site_search` 的数据来自定期同步的站点信息表。用户质疑数据过旧、找不到新激活的站点时,先用 `site_info_sync_status` 查看最近一次同步时间与状态,在回答中说明数据截至何时;需要重新同步见 references/write-operations.md。
 
 ## 数据量规则(必须遵守)
 

+ 13 - 1
deploy/agent-assistant/skills/fwh-assistant/references/write-operations.md

@@ -8,7 +8,19 @@
 | --- | --- | --- |
 | `feedback_create` | 在 Plane 创建问题反馈 / 需求 | 调用前先向用户摘要标题、类型(bug / feature)、优先级和描述;描述中写清背景、复现步骤或期望效果 |
 | `tou_template_save` | 新增或更新**测试服** TOU 模版 | 只支持测试服;先按 tou-template.md 的规则构建并自检(时段覆盖整天、日类型成对、数值类型正确),向用户摘要“新增/更新、模版名、地区、策略与关键时段”,保存后用 `mcp__gateway__fwh__tou_template_get`(`env=test`)回读核对 |
-| `site_info_refresh` | 触发站点信息表从 FWH 平台重新同步 | 同步约需 1.5 小时;已在同步中时不要重复触发 |
+| `site_info_refresh` | 触发站点信息表从 FWH 平台重新同步 | 见下方“站点信息同步” |
+
+## 站点信息同步
+
+同步在主服务后台执行,耗时可能超过 1.5 小时;`site_info_refresh` 立即返回,不等同步完成。
+
+1. **先查状态**:调用只读工具 `mcp__gateway__fwh__site_info_sync_status`(无需审批),按 `state` 处理:
+   - `syncing`:同步进行中,**不要触发**,告诉用户已进行多久,之后再用本工具查看;
+   - `stale`:超过 6 小时未完成,疑似中断(如主服务重启)。向用户说明情况,用户确认后才用 `force=true` 重新触发;
+   - `idle` 且最近一次成功同步在 24 小时内(`advice` 会说明):数据较新,告诉用户数据截至 `lastSuccess.completedAt`,一般无需同步;用户坚持要最新数据时用 `force=true`;
+   - `idle` 且超过 24 小时、上次失败,或 `never`:可以触发。
+2. **再触发**:调用 `site_info_refresh`。工具会再次检查状态,按返回的 `action` 回复:`triggered`(已触发)、`tracking`(同步中,未重复触发)、`skipped_fresh` / `skipped_stale`(未触发,原因见 `message`)。
+3. **跟踪**:触发后告诉用户同步需要较长时间、完成后 `site_search` 才会更新;用户之后询问进度时用 `site_info_sync_status` 查看,不要在一轮对话中反复轮询。
 
 通用规则:
 

+ 2 - 1
docs/ai-assistant-agentserver/agent-mcp.md

@@ -176,6 +176,7 @@ schema 由 mcp-go 依赖的 `google/jsonschema-go` 生成:`jsonschema` 标签
 | | `site_get` | 单站点概况:基本信息、版本、网络、工作模式、当前告警数 | `QuerySiteBaseInfoList`、`QuerySiteModeInfo`、`QuerySiteAlarmList` |
 | | `site_power_flow` | 单站点单日功率流(5 分钟粒度,`Table`) | `QuerySitePowerFlow` |
 | | `site_power_flow_export` | 单站点多日功率流导出(≤ 31 天,CSV) | 同上,按天循环 |
+| | `site_info_sync_status` | 站点信息表同步状态:最近一次同步的进度、请求/完成时间,最近一次成功同步的时间与站点数,是否需要重新同步的建议 | `GetSiteInfoUpdateNewRecord`、`GetSiteInfoUpdateLastComplete` |
 | 告警 | `alarm_current` | 站点当前告警列表 | `QuerySiteAlarmList` |
 | | `alarm_events` | 站点一段时间内的告警/事件记录 | `QueryEventOrAlarmListOnTimeRange` |
 | | `alarm_snapshot` | 站点某日的告警快照 | `QueryAlarmSnapshotInfo`、PG 快照数据 |
@@ -258,7 +259,7 @@ Profile `fwh-assistant` 勾选 `fwh`。工具使用的业务说明(字段含
 | --- | --- | --- |
 | `feedback_create` | 在 Plane 创建 Intake Issue | 提交人固定为服务账号“AI 助手”;描述中由 Agent 写明背景。需要真实提交人时由 ToolBox 页面提交,不经过 MCP |
 | `tou_template_save` | 保存测试服 TOU 模版 | 只允许测试服;写入前按调度配置需求中的规则校验,返回差异 |
-| `site_info_refresh` | 触发站点信息表更新 | 幂等;限频 |
+| `site_info_refresh` | 触发站点信息表同步,立即返回 | 触发前检查同步状态(与 `site_info_sync_status` 相同的判断):同步中不重复触发;最近 24 小时内已成功、或超过 6 小时未完成疑似中断时不触发,`force=true` 可强制。任务投递到主服务的队列执行(MCP 只创建入队客户端,Redis 库号与主服务一致) |
 
 无用户模型下,写工具的“谁做的”只记录在 ToolBox 会话归属和 AgentServer 审计中,业务系统里看到的是服务账号。如果某个写操作在业务上必须归属到具体的人,就不放进 MCP。
 

+ 1 - 1
service/agentmcp/agentmcp_test.go

@@ -80,7 +80,7 @@ func TestToolConstraints(t *testing.T) {
 // 一期清单(agent-mcp.md 第 4 节)全部注册
 func TestReadOnlyToolList(t *testing.T) {
 	want := []string{
-		"site_search", "site_get", "site_power_flow", "site_power_flow_export",
+		"site_search", "site_get", "site_power_flow", "site_power_flow_export", "site_info_sync_status",
 		"alarm_current", "alarm_events", "alarm_snapshot", "alarm_stats", "alarm_sites_by_code",
 		"shutdown_stats", "shutdown_sites",
 		"mqtt_log_search", "ems_log_records", "update_log_system", "update_log_ahub",

+ 5 - 0
service/agentmcp/bootstrap.go

@@ -6,6 +6,7 @@ import (
 	"os"
 	"path/filepath"
 
+	"github.com/hibiken/asynq"
 	"github.com/spf13/viper"
 	"go.uber.org/zap"
 	"go.uber.org/zap/zapcore"
@@ -15,6 +16,8 @@ import (
 	"fwh-toolbox-server/database"
 	"fwh-toolbox-server/logger"
 	"fwh-toolbox-server/repositories"
+	task_core "fwh-toolbox-server/task/core"
+	"fwh-toolbox-server/task/queue/fwhQueue"
 )
 
 // DefaultConfigFile fwh-agent-mcp 的独立配置文件(相对工作目录),与主服务的 config.env、旧 MCP 的 config.mcp.env 互不共用。
@@ -108,5 +111,7 @@ func InitInfra(configFile string) error {
 	repositories.FwhTestHttpInit(&cfg.FwhTestApi)
 	// 必须用 InitPlane(含 Feedback 专用实例 repositories.Plane),否则 feedback_create 会空指针
 	repositories.InitPlane(&cfg.PlaneWebApi)
+	// 只入队、不启动 worker:site_info_refresh 投递的任务由主服务执行,否则 TaskClient 为空会 panic
+	fwhQueue.InitEnqueueClient(asynq.RedisClientOpt{Addr: cfg.App.RedisAddr, DB: task_core.RedisDB})
 	return nil
 }

+ 0 - 16
service/agentmcp/tools/ops.go

@@ -118,19 +118,3 @@ func strategyIssues(organized map[string]any) []string {
 	}
 	return out
 }
-
-// ---- site_info_refresh ----
-
-var SiteInfoRefresh = kit.Def[kit.NoArgs]{
-	Name:  "site_info_refresh",
-	Title: "触发站点信息表更新",
-	Description: "触发 ToolBox 站点信息表的更新任务(从 FWH 平台同步最新站点数据),任务在后台执行。" +
-		"返回是否已触发;同步通常需要数分钟,完成后 site_search 的结果才会更新。这是写操作,执行前会请求确认。",
-	ReadOnly: false,
-	Handler: func(ctx context.Context, _ kit.NoArgs) (*mcp.CallToolResult, error) {
-		if err := services.UpdateSiteInfoDate(); err != nil {
-			return kit.Upstream("ToolBox 站点信息更新", err), nil
-		}
-		return mcp.NewToolResultText("已触发站点信息表更新,任务在后台执行。"), nil
-	},
-}

+ 1 - 1
service/agentmcp/tools/registry.go

@@ -7,7 +7,7 @@ import "fwh-toolbox-server/agentmcp/kit"
 func ReadOnly() []kit.Registrar {
 	return []kit.Registrar{
 		// 站点
-		SiteSearch, SiteGet, SitePowerFlow, SitePowerFlowExport,
+		SiteSearch, SiteGet, SitePowerFlow, SitePowerFlowExport, SiteInfoSyncStatus,
 		// 告警
 		AlarmCurrent, AlarmEvents, AlarmSnapshot, AlarmStats, AlarmSitesByCode,
 		// 停机

+ 209 - 0
service/agentmcp/tools/siteinfosync.go

@@ -0,0 +1,209 @@
+package tools
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"github.com/mark3labs/mcp-go/mcp"
+
+	"fwh-toolbox-server/agentmcp/kit"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/repositories"
+	"fwh-toolbox-server/services"
+)
+
+// 站点信息表同步(SiteInfoManageRecord)的状态判断。
+// 同步由主服务的任务队列执行,耗时可能超过 1.5 小时;记录只有 progress、createTime(请求时间)、
+// updateTime(完成时间,仅 complete 时更新)与 total,没有心跳,只能按请求时间判断是否疑似中断。
+const (
+	// syncStaleAfter pending / running 超过该时长视为疑似中断(例如主服务重启),需要 force 才重新触发
+	syncStaleAfter = 6 * time.Hour
+	// syncFreshWithin 最近一次成功同步在该时长内时认为数据较新,不重新触发(force 除外)
+	syncFreshWithin = 24 * time.Hour
+)
+
+// 同步状态
+const (
+	syncStateNever   = "never"   // 从未同步
+	syncStateSyncing = "syncing" // 同步中(pending / running)
+	syncStateStale   = "stale"   // pending / running 但超过 syncStaleAfter,疑似中断
+	syncStateIdle    = "idle"    // 没有进行中的同步(最近一次已完成或失败)
+)
+
+var beijing = time.FixedZone("CST", 8*3600)
+
+func fmtBeijing(t time.Time) string {
+	return t.In(beijing).Format(time.DateTime)
+}
+
+func fmtHours(d time.Duration) string {
+	if d < time.Hour {
+		return fmt.Sprintf("%d 分钟", int(d.Minutes()))
+	}
+	return fmt.Sprintf("%.1f 小时", d.Hours())
+}
+
+type siteInfoSync struct {
+	State       string
+	Latest      *domain.SiteInfoUpdateRecord
+	LastSuccess *domain.SiteInfoUpdateRecord
+	now         time.Time
+}
+
+func loadSiteInfoSync() (*siteInfoSync, error) {
+	latest, err := repositories.GetSiteInfoUpdateNewRecord()
+	if err != nil {
+		return nil, err
+	}
+	lastComplete, err := repositories.GetSiteInfoUpdateLastComplete()
+	if err != nil {
+		return nil, err
+	}
+	return evalSiteInfoSync(latest, lastComplete, time.Now()), nil
+}
+
+// evalSiteInfoSync 根据最近一次记录与最近一次成功记录判断同步状态
+func evalSiteInfoSync(latest, lastComplete *domain.SiteInfoUpdateRecord, now time.Time) *siteInfoSync {
+	s := &siteInfoSync{Latest: latest, LastSuccess: lastComplete, now: now}
+	switch {
+	case latest == nil:
+		s.State = syncStateNever
+	case latest.Progress == domain.ProgressPending || latest.Progress == domain.ProgressRunning:
+		s.State = syncStateSyncing
+		if s.now.Sub(latest.CreateTime) > syncStaleAfter {
+			s.State = syncStateStale
+		}
+	default:
+		s.State = syncStateIdle
+	}
+	return s
+}
+
+// decideRefresh 决定 site_info_refresh 是否触发同步;trigger=false 时 action 说明原因
+func (s *siteInfoSync) decideRefresh(force bool) (action string, trigger bool) {
+	switch {
+	case s.State == syncStateSyncing:
+		return "tracking", false
+	case s.State == syncStateStale && !force:
+		return "skipped_stale", false
+	case s.State == syncStateIdle && s.Latest.Progress == domain.ProgressComplete && s.fresh() && !force:
+		return "skipped_fresh", false
+	}
+	return "triggered", true
+}
+
+// fresh 最近一次成功同步在 syncFreshWithin 内
+func (s *siteInfoSync) fresh() bool {
+	return s.LastSuccess != nil && s.now.Sub(s.LastSuccess.UpdateTime) < syncFreshWithin
+}
+
+func (s *siteInfoSync) view() map[string]any {
+	out := map[string]any{"state": s.State}
+	if r := s.Latest; r != nil {
+		latest := map[string]any{
+			"id":          r.Id.Hex(),
+			"progress":    r.Progress,
+			"requestedAt": fmtBeijing(r.CreateTime),
+		}
+		if r.Progress == domain.ProgressComplete {
+			latest["completedAt"] = fmtBeijing(r.UpdateTime)
+			latest["duration"] = fmtHours(r.UpdateTime.Sub(r.CreateTime))
+			latest["total"] = r.Total
+		} else {
+			latest["elapsed"] = fmtHours(s.now.Sub(r.CreateTime))
+		}
+		out["latest"] = latest
+	}
+	if r := s.LastSuccess; r != nil {
+		out["lastSuccess"] = map[string]any{
+			"completedAt": fmtBeijing(r.UpdateTime),
+			"age":         fmtHours(s.now.Sub(r.UpdateTime)),
+			"total":       r.Total,
+		}
+	}
+	out["advice"] = s.advice()
+	return out
+}
+
+func (s *siteInfoSync) advice() string {
+	switch s.State {
+	case syncStateNever:
+		return "从未同步过,可以调用 site_info_refresh 触发同步。"
+	case syncStateSyncing:
+		return fmt.Sprintf("同步进行中(已 %s),无需重新触发。同步耗时可能超过 1.5 小时,稍后再用 site_info_sync_status 查看。",
+			fmtHours(s.now.Sub(s.Latest.CreateTime)))
+	case syncStateStale:
+		return fmt.Sprintf("最近一次同步已请求 %s 仍未完成,超过 %s,疑似中断(如主服务重启)。确认后可用 site_info_refresh 并设置 force=true 重新同步。",
+			fmtHours(s.now.Sub(s.Latest.CreateTime)), fmtHours(syncStaleAfter))
+	}
+	if s.Latest.Progress != domain.ProgressComplete {
+		return "最近一次同步失败,可以调用 site_info_refresh 重新同步。"
+	}
+	if s.fresh() {
+		return fmt.Sprintf("最近一次同步在 %s 前完成,数据较新,一般无需重新同步;确需最新数据时用 site_info_refresh 并设置 force=true。",
+			fmtHours(s.now.Sub(s.LastSuccess.UpdateTime)))
+	}
+	return fmt.Sprintf("最近一次同步在 %s 前完成,超过 %s,可以调用 site_info_refresh 重新同步。",
+		fmtHours(s.now.Sub(s.LastSuccess.UpdateTime)), fmtHours(syncFreshWithin))
+}
+
+// ---- site_info_sync_status ----
+
+var SiteInfoSyncStatus = kit.Def[kit.NoArgs]{
+	Name:  "site_info_sync_status",
+	Title: "查询站点信息表同步状态",
+	Description: "查询 ToolBox 站点信息表(site_search 的数据来源)从 FWH 平台同步的状态,只读。" +
+		"返回 state:never(从未同步)、syncing(同步中)、stale(超过 6 小时未完成,疑似中断)、idle(没有进行中的同步);" +
+		"latest(最近一次同步:progress 为 pending/running/complete/error,requestedAt 请求时间,完成时有 completedAt、duration、total 站点数,未完成时有 elapsed);" +
+		"lastSuccess(最近一次成功同步的 completedAt、age 距今时长、total);advice(是否需要重新同步的建议)。时间为北京时间。" +
+		"同步耗时可能超过 1.5 小时:触发同步前先调用本工具,同步中时用本工具跟踪进度,不要重复触发。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, _ kit.NoArgs) (*mcp.CallToolResult, error) {
+		s, err := loadSiteInfoSync()
+		if err != nil {
+			return kit.Upstream("ToolBox 站点信息同步记录", err), nil
+		}
+		return kit.Object(s.view()), nil
+	},
+}
+
+// ---- site_info_refresh ----
+
+var refreshMessages = map[string]string{
+	"tracking":      "同步进行中,未重复触发。",
+	"skipped_stale": "上次同步疑似中断,未触发;确认后设置 force=true 重新同步。",
+	"skipped_fresh": "最近 24 小时内已成功同步,未触发;确需最新数据时设置 force=true。",
+	"triggered":     "已触发同步,任务在主服务后台执行,耗时可能超过 1.5 小时;用 site_info_sync_status 跟踪进度。",
+}
+
+type siteInfoRefreshArgs struct {
+	Force bool `json:"force,omitempty" jsonschema:"为 true 时:最近 24 小时内已成功同步、或上次同步疑似中断(stale)时仍重新触发。同步进行中(syncing)时任何情况下都不会重复触发"`
+}
+
+var SiteInfoRefresh = kit.Def[siteInfoRefreshArgs]{
+	Name:  "site_info_refresh",
+	Title: "触发站点信息表同步",
+	Description: "触发 ToolBox 站点信息表从 FWH 平台重新同步,立即返回,同步在主服务后台执行,耗时可能超过 1.5 小时。" +
+		"触发前先检查同步状态:同步进行中时不重复触发,返回当前进度(action=tracking);最近 24 小时内已成功同步时不触发(action=skipped_fresh);" +
+		"上次同步超过 6 小时未完成、疑似中断时不触发(action=skipped_stale);以上两种不触发的情况可用 force=true 强制触发。" +
+		"已触发时 action=triggered。返回中的 status 与 site_info_sync_status 相同,之后用 site_info_sync_status 跟踪进度。" +
+		"建议先调用 site_info_sync_status 判断是否需要同步,再调用本工具。这是写操作,执行前会请求确认。",
+	ReadOnly: false,
+	Handler: func(ctx context.Context, a siteInfoRefreshArgs) (*mcp.CallToolResult, error) {
+		s, err := loadSiteInfoSync()
+		if err != nil {
+			return kit.Upstream("ToolBox 站点信息同步记录", err), nil
+		}
+		action, trigger := s.decideRefresh(a.Force)
+		if trigger {
+			if err := services.UpdateSiteInfoDate(); err != nil {
+				return kit.Upstream("ToolBox 站点信息同步", err), nil
+			}
+			if s, err = loadSiteInfoSync(); err != nil {
+				return kit.Upstream("ToolBox 站点信息同步记录", err), nil
+			}
+		}
+		return kit.Object(map[string]any{"action": action, "message": refreshMessages[action], "status": s.view()}), nil
+	},
+}

+ 57 - 0
service/agentmcp/tools/siteinfosync_test.go

@@ -0,0 +1,57 @@
+package tools
+
+import (
+	"testing"
+	"time"
+
+	"go.mongodb.org/mongo-driver/bson/primitive"
+
+	"fwh-toolbox-server/domain"
+)
+
+func syncRecord(p domain.Progress, created, completed time.Time) *domain.SiteInfoUpdateRecord {
+	id := primitive.NewObjectID()
+	return &domain.SiteInfoUpdateRecord{Id: &id, Progress: p, CreateTime: created, UpdateTime: completed, Total: 100}
+}
+
+func TestSiteInfoSyncDecide(t *testing.T) {
+	now := time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC)
+	ago := func(h float64) time.Time { return now.Add(-time.Duration(h * float64(time.Hour))) }
+
+	freshDone := syncRecord(domain.ProgressComplete, ago(3), ago(1))
+	oldDone := syncRecord(domain.ProgressComplete, ago(50), ago(48))
+
+	cases := []struct {
+		name         string
+		latest, last *domain.SiteInfoUpdateRecord
+		force        bool
+		state        string
+		action       string
+	}{
+		{"从未同步", nil, nil, false, syncStateNever, "triggered"},
+		{"pending 刚请求", syncRecord(domain.ProgressPending, ago(0.1), ago(0.1)), oldDone, false, syncStateSyncing, "tracking"},
+		{"running 2 小时仍在同步", syncRecord(domain.ProgressRunning, ago(2), ago(2)), oldDone, false, syncStateSyncing, "tracking"},
+		{"同步中 force 也不重复触发", syncRecord(domain.ProgressRunning, ago(2), ago(2)), oldDone, true, syncStateSyncing, "tracking"},
+		{"running 超过 6 小时疑似中断", syncRecord(domain.ProgressRunning, ago(7), ago(7)), oldDone, false, syncStateStale, "skipped_stale"},
+		{"疑似中断 force 重新触发", syncRecord(domain.ProgressRunning, ago(7), ago(7)), oldDone, true, syncStateStale, "triggered"},
+		{"24 小时内已成功", freshDone, freshDone, false, syncStateIdle, "skipped_fresh"},
+		{"24 小时内已成功 force", freshDone, freshDone, true, syncStateIdle, "triggered"},
+		{"成功已超过 24 小时", oldDone, oldDone, false, syncStateIdle, "triggered"},
+		{"上次失败", syncRecord(domain.ProgressError, ago(1), ago(1)), freshDone, false, syncStateIdle, "triggered"},
+	}
+	for _, c := range cases {
+		s := evalSiteInfoSync(c.latest, c.last, now)
+		if s.State != c.state {
+			t.Errorf("%s: state = %s,期望 %s", c.name, s.State, c.state)
+		}
+		if action, _ := s.decideRefresh(c.force); action != c.action {
+			t.Errorf("%s: action = %s,期望 %s", c.name, action, c.action)
+		}
+		if _, ok := refreshMessages[c.action]; !ok {
+			t.Errorf("%s: action %s 缺少提示文案", c.name, c.action)
+		}
+		if v := s.view(); v["advice"] == "" {
+			t.Errorf("%s: advice 为空", c.name)
+		}
+	}
+}

+ 2 - 1
service/main.go

@@ -10,6 +10,7 @@ import (
 	"fwh-toolbox-server/repositories/api/fwh_pro"
 	"fwh-toolbox-server/repositories/api/fwh_test"
 	"fwh-toolbox-server/task"
+	task_core "fwh-toolbox-server/task/core"
 	"fwh-toolbox-server/task/queue/faultRecordQueue"
 	"fwh-toolbox-server/utils/casdoor"
 	"fwh-toolbox-server/utils/minio"
@@ -89,7 +90,7 @@ func main() {
 	// 初始化任务
 	task.InitTask(task.TaskRedisOpt{
 		Addr: cfg.App.RedisAddr,
-		DB:   12,
+		DB:   task_core.RedisDB,
 	}, &cfg.App)
 
 	// 初始化Casdoor

+ 20 - 0
service/repositories/mongo/siteInfoManageRepo.go

@@ -96,6 +96,26 @@ func GetSiteInfoUpdateNewRecord() (*domain.SiteInfoUpdateRecord, error) {
 	return &result, nil
 }
 
+// 获取最近一次成功完成的站点信息更新记录;没有时返回 nil
+func GetSiteInfoUpdateLastComplete() (*domain.SiteInfoUpdateRecord, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection(CollectionName_SiteInfoManageRecord)
+
+	opts := options.FindOne().SetSort(bson.D{{Key: "_id", Value: -1}})
+
+	var result domain.SiteInfoUpdateRecord
+	err := collection.FindOne(ctx, bson.M{"progress": domain.ProgressComplete}, opts).Decode(&result)
+	if err != nil {
+		if errors.Is(err, mongo.ErrNoDocuments) {
+			return nil, nil
+		}
+		return nil, err
+	}
+	return &result, nil
+}
+
 // 从数据库获取站点信息列表
 func GetSiteInfoManageList(page, size uint, filter *domain.SiteInfoFilter, search string) (*dto.ResPage[domain.SiteInfoDomain], error) {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)

+ 1 - 0
service/repositories/repositories.go

@@ -422,6 +422,7 @@ var DeleteAutoUpdateSpecialConfig = mongo.DeleteAutoUpdateSpecialConfig
 var CreateSiteInfoUpdateInfo = mongo.CreateSiteInfoUpdateInfo
 var UpdateSiteInfoUpdateResult = mongo.UpdateSiteInfoUpdateResult
 var GetSiteInfoUpdateNewRecord = mongo.GetSiteInfoUpdateNewRecord
+var GetSiteInfoUpdateLastComplete = mongo.GetSiteInfoUpdateLastComplete
 var GetSiteInfoManageList = mongo.GetSiteInfoManageList
 var InsertSiteInfoData = mongo.InsertSiteInfoData
 var GetSiteInfoManageListFromGiccid = mongo.GetSiteInfoManageListFromGiccid

+ 4 - 0
service/services/siteInfoManageServices.go

@@ -34,6 +34,10 @@ func UpdateSiteInfoDate() error {
 	if err := fwhQueue.EnqueueSiteInfoUpdateTask(domain.TaskSiteInfoUpdate{
 		Id: *id,
 	}); err != nil {
+		// 入队失败时标记记录为失败,避免一直停在 pending 被当成“同步中”
+		if e := repositories.UpdateSiteInfoUpdateResult(*id, domain.ProgressError, 0); e != nil {
+			zap.L().Error("UpdateSiteInfoDate mark error failed.", zap.String("id", id.Hex()), zap.String("err", e.Error()))
+		}
 		return err
 	}
 	return nil

+ 3 - 0
service/task/core/taskCore.go

@@ -7,6 +7,9 @@ import (
 	"go.uber.org/zap"
 )
 
+// RedisDB 任务队列使用的 Redis 库号。主服务(worker)与只入队的独立进程(fwh-agent-mcp)必须一致
+const RedisDB = 12
+
 // type TaskCore interface {
 // 	CreateTask() *asynq.Client
 // }

+ 6 - 0
service/task/queue/fwhQueue/fwhTaskQueue.go

@@ -33,6 +33,12 @@ var (
 	inspector  *asynq.Inspector
 )
 
+// InitEnqueueClient 只创建入队客户端,不启动 worker、不注册处理函数。
+// 供 fwh-agent-mcp 等独立进程把任务投递给主服务执行;主服务使用 InitFwhTaskQueue。
+func InitEnqueueClient(redisOpt asynq.RedisClientOpt) {
+	TaskClient = asynq.NewClient(redisOpt)
+}
+
 // InitFwhTaskQueue 初始化新的 FWH 任务队列
 // 通过 TaskCore 创建 Asynq Server(worker)、Client(入队)、Inspector(状态查询)
 func InitFwhTaskQueue(tc *task_core.TaskCore) {