Bladeren bron

feat:site_search支持按国家、在线、激活、运行状态、版本、硬件、激活时间等条件筛选

- 条件全部为“且”,复用站点信息表的过滤;统计数量时 size=1 看 total
- 关键字与包含匹配转义正则,按字面值匹配,带正则符号的输入不再导致查询出错

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 4 dagen geleden
bovenliggende
commit
ec95f3f415

+ 1 - 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_info_sync_status`(站点信息表同步状态与最近同步时间) |
+| 站点 | `site_search`(关键字 + 国家、州省、在线、激活、运行状态、联网方式、版本、硬件、激活时间筛选;统计数量用 `size=1` 看 `total`)、`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` |

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

@@ -172,7 +172,7 @@ schema 由 mcp-go 依赖的 `google/jsonschema-go` 生成:`jsonschema` 标签
 
 | 领域 | 工具 | 说明 | 来源 |
 | --- | --- | --- | --- |
-| 站点 | `site_search` | 按 SN 片段、国家、激活状态、版本搜索站点,分页 | `QuerySiteInfoList`、站点信息表 |
+| 站点 | `site_search` | 按关键字与条件(国家、州省、在线、激活、运行状态、联网方式、固件/协议版本、aGate 硬件、激活时间,全部为“且”)搜索站点,分页;关键字与包含匹配按字面值(转义正则) | `QuerySiteInfoDataList`、站点信息表 |
 | | `site_get` | 单站点概况:基本信息、版本、网络、工作模式、当前告警数 | `QuerySiteBaseInfoList`、`QuerySiteModeInfo`、`QuerySiteAlarmList` |
 | | `site_power_flow` | 单站点单日功率流(5 分钟粒度,`Table`) | `QuerySitePowerFlow` |
 | | `site_power_flow_export` | 单站点多日功率流导出(≤ 31 天,CSV) | 同上,按天循环 |

+ 121 - 10
service/agentmcp/tools/site.go

@@ -3,6 +3,7 @@ package tools
 import (
 	"context"
 	"fmt"
+	"regexp"
 	"strconv"
 	"strings"
 	"time"
@@ -10,6 +11,7 @@ import (
 	"github.com/mark3labs/mcp-go/mcp"
 
 	"fwh-toolbox-server/agentmcp/kit"
+	"fwh-toolbox-server/domain"
 	"fwh-toolbox-server/dto"
 	"fwh-toolbox-server/repositories"
 	"fwh-toolbox-server/services"
@@ -18,36 +20,145 @@ import (
 // ---- site_search ----
 
 type siteSearchArgs struct {
-	Keyword string `json:"keyword,omitempty" jsonschema:"搜索关键字,匹配 SN、站点名称等;不填则按默认排序列出"`
-	Page    int    `json:"page,omitempty" jsonschema:"页码,从 1 开始,默认 1"`
-	Size    int    `json:"size,omitempty" jsonschema:"每页条数,1~100,默认 20"`
+	Keyword     string `json:"keyword,omitempty" jsonschema:"关键字,匹配 SN、站点名称、经销商/安装商、aPower SN、ICCID、版本、国家/州省/城市、备注等(不区分大小写);不填则只按筛选条件"`
+	Country     string `json:"country,omitempty" jsonschema:"国家:us(美国)、au(澳大利亚)"`
+	Province    string `json:"province,omitempty" jsonschema:"州/省(英文,包含匹配),如 California"`
+	Online      string `json:"online,omitempty" jsonschema:"网络状态:online(在线)、offline(离线)"`
+	Active      string `json:"active,omitempty" jsonschema:"激活状态:active(已激活)、inactive(未激活)"`
+	RunStatus   string `json:"runStatus,omitempty" jsonschema:"运行状态:standby(待机)、charging(充电)、discharging(放电)、fault(故障)"`
+	ConnType    string `json:"connType,omitempty" jsonschema:"联网方式:none(未连接)、eth0、eth1、wifi、4g"`
+	Version     string `json:"version,omitempty" jsonschema:"系统固件版本(包含匹配),如 V10R11B42"`
+	ProtocolVer string `json:"protocolVer,omitempty" jsonschema:"协议版本(包含匹配),如 V1.12.45"`
+	Hardware    string `json:"hardware,omitempty" jsonschema:"aGate 系统硬件名称(包含匹配),如 System1.5"`
+	ActiveFrom  string `json:"activeFrom,omitempty" jsonschema:"激活时间起(含),yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss,北京时间"`
+	ActiveTo    string `json:"activeTo,omitempty" jsonschema:"激活时间止(含),格式同上"`
+	Page        int    `json:"page,omitempty" jsonschema:"页码,从 1 开始,默认 1"`
+	Size        int    `json:"size,omitempty" jsonschema:"每页条数,1~100,默认 20;只需要数量时用 1,看 total"`
 }
 
+var (
+	siteCountryIDs = map[string]int64{"us": 2, "au": 3}
+	siteOnline     = map[string]int64{"offline": 0, "online": 1}
+	siteActive     = map[string]int64{"inactive": 0, "active": 1}
+	siteRunStatus  = map[string]int64{"standby": 0, "charging": 1, "discharging": 2, "fault": 3}
+	siteConnTypes  = map[string]int64{"none": 0, "eth0": 1, "eth1": 2, "wifi": 3, "4g": 4}
+)
+
 var SiteSearch = kit.Def[siteSearchArgs]{
 	Name:  "site_search",
 	Title: "搜索站点",
-	Description: "在 ToolBox 站点信息表中搜索站点(按 SN 片段、站点名称等关键字),分页返回。" +
-		"返回字段:sn(aGate SN)、name、国家/州省/城市、激活状态与时间、在线状态、系统与固件版本、aPower 数量等(以实际返回为准,个人信息已脱敏)。" +
-		"返回 total 为符合条件的总数,用 page 翻页。查看单个站点完整概况请用 site_get。",
+	Description: "在 ToolBox 站点信息表(约 6 万个站点,每天从 FWH 平台同步)中按关键字与条件搜索站点,分页返回;多个条件同时满足。" +
+		"可按国家、州/省、在线、激活、运行状态、联网方式、固件/协议版本、aGate 硬件、激活时间筛选。统计数量时 size=1 看 total," +
+		"如“美国在线的 System1.5 站点有多少”。返回字段:sn(aGate SN)、name、国家/州省/城市、激活状态与时间、在线状态、版本、aPower SN 等(个人信息已脱敏)。" +
+		"数据是否最新见 site_info_sync_status;查看单个站点完整概况用 site_get。",
 	ReadOnly: true,
+	Properties: map[string]map[string]any{
+		"country":   {"enum": []string{"us", "au"}},
+		"online":    {"enum": []string{"online", "offline"}},
+		"active":    {"enum": []string{"active", "inactive"}},
+		"runStatus": {"enum": []string{"standby", "charging", "discharging", "fault"}},
+		"connType":  {"enum": []string{"none", "eth0", "eth1", "wifi", "4g"}},
+	},
 	Handler: func(ctx context.Context, a siteSearchArgs) (*mcp.CallToolResult, error) {
 		page := kit.Page(a.Page)
 		size := kit.Limit(a.Size, 20, 100)
+		filter, err := siteSearchFilter(a)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		keyword := strings.TrimSpace(a.Keyword)
 		data, err := services.QuerySiteInfoDataList(dto.SiteInfoRequest{
-			Search:   strings.TrimSpace(a.Keyword),
+			Search:   regexp.QuoteMeta(keyword), // 仓库按正则匹配,转义后按字面匹配
+			Filter:   filter,
 			PageNum:  uint(page),
 			PageSize: uint(size),
 		})
 		if err != nil {
 			return kit.Upstream("站点信息表", err), nil
 		}
-		if data == nil {
-			return kit.Empty(""), nil
+		if data == nil || data.Total == 0 {
+			return kit.Empty("没有符合条件的站点。"), nil
 		}
-		return kit.List(data.Data, int(data.Total), size, fmt.Sprintf("共 %d 条,当前第 %d 页,可用 page 翻页", data.Total, page)), nil
+		return kit.List(data.Data, int(data.Total), size, fmt.Sprintf("共 %d 条,当前第 %d 页,可用 page 翻页或增加条件", data.Total, page)), nil
 	},
 }
 
+// activeTimeBound 解析激活时间范围的一端:接受日期或日期时间;只给日期时,止时间取当天最后一秒
+func activeTimeBound(name, v string, end bool) (string, error) {
+	v = strings.TrimSpace(v)
+	if t, err := time.Parse(time.DateTime, v); err == nil {
+		return t.Format(time.DateTime), nil
+	}
+	t, err := time.Parse(time.DateOnly, v)
+	if err != nil {
+		return "", fmt.Errorf("参数 %s 格式错误,需要 yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss:%s", name, v)
+	}
+	if end {
+		t = t.Add(24*time.Hour - time.Second)
+	}
+	return t.Format(time.DateTime), nil
+}
+
+// siteSearchFilter 把筛选参数转为站点信息表的过滤条件(全部为“且”);没有条件时返回 nil
+func siteSearchFilter(a siteSearchArgs) (*domain.SiteInfoFilter, error) {
+	var conds []domain.SiteInfoFilterConfig
+	eqInt := func(key string, v int64) {
+		conds = append(conds, domain.SiteInfoFilterConfig{Key: key, Type: domain.FilterEqualInt, Para1: domain.FlexiblePara{Int: &v}})
+	}
+	contains := func(key, v string) {
+		if v = strings.TrimSpace(v); v != "" {
+			q := regexp.QuoteMeta(v)
+			conds = append(conds, domain.SiteInfoFilterConfig{Key: key, Type: domain.FilterIncludeString, Para1: domain.FlexiblePara{String: &q}})
+		}
+	}
+	enums := []struct {
+		key, name, val string
+		m              map[string]int64
+	}{
+		{"countryId", "country", a.Country, siteCountryIDs},
+		{"status", "online", a.Online, siteOnline},
+		{"activeStatus", "active", a.Active, siteActive},
+		{"runStatus", "runStatus", a.RunStatus, siteRunStatus},
+		{"connType", "connType", a.ConnType, siteConnTypes},
+	}
+	for _, e := range enums {
+		if e.val == "" {
+			continue
+		}
+		v, ok := e.m[strings.ToLower(e.val)]
+		if !ok {
+			return nil, fmt.Errorf("参数 %s 的值 %q 无效", e.name, e.val)
+		}
+		eqInt(e.key, v)
+	}
+	contains("province", a.Province)
+	contains("version", a.Version)
+	contains("protocolVer", a.ProtocolVer)
+	contains("realSysHdVersion", a.Hardware)
+
+	if a.ActiveFrom != "" || a.ActiveTo != "" {
+		// activeTime 以 "yyyy-MM-dd HH:mm:ss" 字符串保存,按字符串比较
+		from, to := "0000-00-00 00:00:00", "9999-12-31 23:59:59"
+		var err error
+		if a.ActiveFrom != "" {
+			if from, err = activeTimeBound("activeFrom", a.ActiveFrom, false); err != nil {
+				return nil, err
+			}
+		}
+		if a.ActiveTo != "" {
+			if to, err = activeTimeBound("activeTo", a.ActiveTo, true); err != nil {
+				return nil, err
+			}
+		}
+		conds = append(conds, domain.SiteInfoFilterConfig{Key: "activeTime", Type: domain.FilterRangeTime,
+			Para1: domain.FlexiblePara{String: &from}, Para2: &domain.FlexiblePara{String: &to}})
+	}
+	if len(conds) == 0 {
+		return nil, nil
+	}
+	return &domain.SiteInfoFilter{Logic: "and", SubFilter: []domain.SiteInfoSubFilter{{Logic: "and", Config: conds}}}, nil
+}
+
 // ---- site_get ----
 
 type siteGetArgs struct {

+ 45 - 0
service/agentmcp/tools/site_test.go

@@ -0,0 +1,45 @@
+package tools
+
+import (
+	"testing"
+
+	"fwh-toolbox-server/domain"
+)
+
+func TestSiteSearchFilter(t *testing.T) {
+	if f, err := siteSearchFilter(siteSearchArgs{Keyword: "x"}); err != nil || f != nil {
+		t.Errorf("没有筛选条件时应返回 nil:%+v %v", f, err)
+	}
+
+	f, err := siteSearchFilter(siteSearchArgs{Country: "us", Online: "online", Version: "V10R11(B42", ActiveFrom: "2026-01-01", ActiveTo: "2026-01-31"})
+	if err != nil || f == nil || f.Logic != "and" || len(f.SubFilter) != 1 {
+		t.Fatalf("应生成一组“且”条件:%+v %v", f, err)
+	}
+	got := map[string]domain.SiteInfoFilterConfig{}
+	for _, c := range f.SubFilter[0].Config {
+		got[c.Key] = c
+	}
+	if c := got["countryId"]; c.Type != domain.FilterEqualInt || *c.Para1.Int != 2 {
+		t.Errorf("us 应为 countryId=2:%+v", c)
+	}
+	if c := got["status"]; *c.Para1.Int != 1 {
+		t.Errorf("online 应为 status=1:%+v", c)
+	}
+	if c := got["version"]; c.Type != domain.FilterIncludeString || *c.Para1.String != `V10R11\(B42` {
+		t.Errorf("版本应转义为字面匹配:%q", *c.Para1.String)
+	}
+	if c := got["activeTime"]; *c.Para1.String != "2026-01-01 00:00:00" || *c.Para2.String != "2026-01-31 23:59:59" {
+		t.Errorf("激活时间范围不对:%s ~ %s", *c.Para1.String, *c.Para2.String)
+	}
+
+	f, _ = siteSearchFilter(siteSearchArgs{ActiveFrom: "2026-01-01 08:00:00"})
+	if c := f.SubFilter[0].Config[0]; *c.Para1.String != "2026-01-01 08:00:00" || *c.Para2.String != "9999-12-31 23:59:59" {
+		t.Errorf("只给起始时间时止时间不限:%s ~ %s", *c.Para1.String, *c.Para2.String)
+	}
+
+	for _, bad := range []siteSearchArgs{{Country: "cn"}, {RunStatus: "busy"}, {ActiveTo: "2026/01/01"}} {
+		if _, err := siteSearchFilter(bad); err == nil {
+			t.Errorf("应拒绝:%+v", bad)
+		}
+	}
+}