commit 226224162f607ffc8b42942824256e0a0285d851 Author: lily Date: Thu Sep 17 18:49:22 2026 +0800 add:脚本初始化 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e77241 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# local secrets / env config +config/config.dev.yaml + +# generated DML dumps +haoxian_hisense/agentic_project/check_agentic_project_name/dml_*.sql + +# Go +*.exe +*.test +*.out +vendor/ + +# OS / IDE +.DS_Store +.idea/ +.vscode/ +*.swp diff --git a/cmd/check_sheet_permission/main.go b/cmd/check_sheet_permission/main.go new file mode 100644 index 0000000..82070a0 --- /dev/null +++ b/cmd/check_sheet_permission/main.go @@ -0,0 +1,49 @@ +package main + +import ( + "context" + "fmt" + "os" + + "ai_script/common_util/feishu_util" +) + +func main() { + client, err := feishu_util.DefaultClient() + if err != nil { + fail("加载配置失败", err) + } + + url := feishu_util.DefaultOptimizationIssueSheetURL + title := feishu_util.DefaultOptimizationIssueSheetTitle + if len(os.Args) > 1 { + url = os.Args[1] + } + if len(os.Args) > 2 { + title = os.Args[2] + } + + ctx := context.Background() + meta, token, err := client.CheckReadPermission(ctx, url, title) + if err != nil { + fail("读取权限校验失败", err) + } + fmt.Printf("OK: 具备读取权限\n") + fmt.Printf(" spreadsheet_token: %s\n", token) + fmt.Printf(" sheet_id: %s\n", meta.SheetID) + fmt.Printf(" sheet_title: %s\n", meta.Title) + + values, err := client.ReadSpreadsheetURL(ctx, url, title) + if err != nil { + fail("读取表格失败", err) + } + fmt.Printf(" rows: %d\n", len(values.Values)) + if len(values.Values) > 0 { + fmt.Printf(" first_row_cols: %d\n", len(values.Values[0])) + } +} + +func fail(msg string, err error) { + fmt.Fprintf(os.Stderr, "%s: %v\n", msg, err) + os.Exit(1) +} diff --git a/common_util/feishu_util/client.go b/common_util/feishu_util/client.go new file mode 100644 index 0000000..1489e91 --- /dev/null +++ b/common_util/feishu_util/client.go @@ -0,0 +1,56 @@ +package feishu_util + +import ( + "fmt" + "sync" + + "ai_script/config" + + lark "github.com/larksuite/oapi-sdk-go/v3" +) + +var ( + clientOnce sync.Once + clientInst *Client + clientErr error +) + +// Client 封装飞书 OpenAPI 客户端与应用凭证。 +type Client struct { + AppID string + AppSecret string + Lark *lark.Client +} + +// NewClient 使用显式凭证创建客户端。 +func NewClient(appID, appSecret string) *Client { + return &Client{ + AppID: appID, + AppSecret: appSecret, + Lark: lark.NewClient(appID, appSecret), + } +} + +// NewClientFromConfig 从项目配置创建客户端。 +func NewClientFromConfig(cfg *config.Config) (*Client, error) { + if cfg == nil { + return nil, fmt.Errorf("config 为空") + } + if cfg.Feishu.AppID == "" || cfg.Feishu.AppSecret == "" { + return nil, fmt.Errorf("配置缺少 feishu.app_id / feishu.app_secret") + } + return NewClient(cfg.Feishu.AppID, cfg.Feishu.AppSecret), nil +} + +// DefaultClient 懒加载:读取本地 config/config.{APP_ENV}.yaml 并创建单例客户端。 +func DefaultClient() (*Client, error) { + clientOnce.Do(func() { + cfg, err := config.Load() + if err != nil { + clientErr = err + return + } + clientInst, clientErr = NewClientFromConfig(cfg) + }) + return clientInst, clientErr +} diff --git a/common_util/feishu_util/defaults.go b/common_util/feishu_util/defaults.go new file mode 100644 index 0000000..3809f8a --- /dev/null +++ b/common_util/feishu_util/defaults.go @@ -0,0 +1,7 @@ +package feishu_util + +// 月度优化问题汇总管理表(知识库挂载的电子表格) +const ( + DefaultOptimizationIssueSheetURL = "https://mi8elatzm1x.feishu.cn/wiki/IbCUwR4bvil4E1k9k2LcCiwSn8t?sheet=QyDfOp" + DefaultOptimizationIssueSheetTitle = "9月各产业优化问题汇总表" +) diff --git a/common_util/feishu_util/sheet.go b/common_util/feishu_util/sheet.go new file mode 100644 index 0000000..f7c396e --- /dev/null +++ b/common_util/feishu_util/sheet.go @@ -0,0 +1,236 @@ +package feishu_util + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + larksheets "github.com/larksuite/oapi-sdk-go/v3/service/sheets/v3" +) + +// SheetMeta 工作表元信息。 +type SheetMeta struct { + SheetID string + Title string + Index int + RowCount int + ColumnCount int +} + +// SheetValues 读取到的单元格数据。 +type SheetValues struct { + SpreadsheetToken string + SheetID string + Title string + Range string + Values [][]interface{} +} + +// ListSheets 列出电子表格下全部工作表。 +func (c *Client) ListSheets(ctx context.Context, spreadsheetToken string) ([]*SheetMeta, error) { + req := larksheets.NewQuerySpreadsheetSheetReqBuilder(). + SpreadsheetToken(spreadsheetToken). + Build() + resp, err := c.Lark.Sheets.V3.SpreadsheetSheet.Query(ctx, req) + if err != nil { + return nil, fmt.Errorf("查询工作表列表失败: %w", err) + } + if !resp.Success() { + return nil, fmt.Errorf("查询工作表列表失败: code=%d msg=%s log_id=%s detail=%s", + resp.Code, resp.Msg, resp.RequestId(), larkcore.Prettify(resp.CodeError)) + } + if resp.Data == nil { + return nil, nil + } + out := make([]*SheetMeta, 0, len(resp.Data.Sheets)) + for i, s := range resp.Data.Sheets { + meta := &SheetMeta{Index: i} + if s.SheetId != nil { + meta.SheetID = *s.SheetId + } + if s.Title != nil { + meta.Title = *s.Title + } + if s.GridProperties != nil { + if s.GridProperties.RowCount != nil { + meta.RowCount = *s.GridProperties.RowCount + } + if s.GridProperties.ColumnCount != nil { + meta.ColumnCount = *s.GridProperties.ColumnCount + } + } + out = append(out, meta) + } + return out, nil +} + +// FindSheet 按 sheetID 或标题查找工作表;优先 sheetID,其次精确匹配标题。 +func (c *Client) FindSheet(ctx context.Context, spreadsheetToken, sheetID, title string) (*SheetMeta, error) { + sheets, err := c.ListSheets(ctx, spreadsheetToken) + if err != nil { + return nil, err + } + if sheetID != "" { + for _, s := range sheets { + if s.SheetID == sheetID { + return s, nil + } + } + return nil, fmt.Errorf("未找到 sheet_id=%s 的工作表", sheetID) + } + title = strings.TrimSpace(title) + if title != "" { + for _, s := range sheets { + if s.Title == title { + return s, nil + } + } + return nil, fmt.Errorf("未找到标题为 %q 的工作表", title) + } + return nil, fmt.Errorf("请提供 sheetID 或 sheet 标题") +} + +// ReadSheetValues 读取指定工作表范围。 +// cellRange 为空时按工作表实际行列读取整表(飞书 open range 列数上限约 100)。 +func (c *Client) ReadSheetValues(ctx context.Context, spreadsheetToken, sheetID, cellRange string) (*SheetValues, error) { + if spreadsheetToken == "" || sheetID == "" { + return nil, fmt.Errorf("spreadsheetToken / sheetID 不能为空") + } + if cellRange == "" { + var err error + cellRange, err = c.defaultSheetRange(ctx, spreadsheetToken, sheetID) + if err != nil { + return nil, err + } + } + rangeExpr := cellRange + if !strings.Contains(cellRange, "!") { + rangeExpr = sheetID + "!" + cellRange + } + + apiPath := fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values/%s", spreadsheetToken, rangeExpr) + apiResp, err := c.Lark.Do(ctx, &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: apiPath, + QueryParams: larkcore.QueryParams{"valueRenderOption": []string{"ToString"}}, + SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}, + }) + if err != nil { + return nil, fmt.Errorf("读取表格失败: %w", err) + } + + var raw struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Revision int `json:"revision"` + SpreadsheetToken string `json:"spreadsheetToken"` + ValueRange struct { + Range string `json:"range"` + Values [][]interface{} `json:"values"` + } `json:"valueRange"` + } `json:"data"` + } + if err := json.Unmarshal(apiResp.RawBody, &raw); err != nil { + return nil, fmt.Errorf("解析表格响应失败: %w", err) + } + if raw.Code != 0 { + return nil, fmt.Errorf("读取表格失败: code=%d msg=%s body=%s", raw.Code, raw.Msg, string(apiResp.RawBody)) + } + + return &SheetValues{ + SpreadsheetToken: spreadsheetToken, + SheetID: sheetID, + Range: raw.Data.ValueRange.Range, + Values: raw.Data.ValueRange.Values, + }, nil +} + +// ReadSpreadsheetURL 解析飞书链接并读取指定 sheet(按 URL 中 sheet id 或标题)。 +func (c *Client) ReadSpreadsheetURL(ctx context.Context, rawURL, sheetTitle string) (*SheetValues, error) { + ref, err := ParseSpreadsheetURL(rawURL) + if err != nil { + return nil, err + } + token, err := c.ResolveSpreadsheetToken(ctx, ref) + if err != nil { + return nil, err + } + meta, err := c.FindSheet(ctx, token, ref.SheetID, sheetTitle) + if err != nil { + return nil, err + } + values, err := c.ReadSheetValues(ctx, token, meta.SheetID, "") + if err != nil { + return nil, err + } + values.Title = meta.Title + return values, nil +} + +func (c *Client) defaultSheetRange(ctx context.Context, spreadsheetToken, sheetID string) (string, error) { + sheets, err := c.ListSheets(ctx, spreadsheetToken) + if err != nil { + return "", err + } + rows, cols := 200, 26 + for _, s := range sheets { + if s.SheetID != sheetID { + continue + } + if s.RowCount > 0 { + rows = s.RowCount + } + if s.ColumnCount > 0 { + cols = s.ColumnCount + } + break + } + // 飞书 values 接口对形如 A:Z 的开放范围最多约 100 列 + if cols > 100 { + cols = 100 + } + if rows < 1 { + rows = 1 + } + return fmt.Sprintf("A1:%s%d", colIndexToName(cols), rows), nil +} + +// colIndexToName 将 1-based 列序号转为 Excel 列名(1->A, 26->Z, 27->AA)。 +func colIndexToName(index int) string { + if index < 1 { + index = 1 + } + name := make([]byte, 0, 4) + for index > 0 { + index-- + name = append([]byte{byte('A' + index%26)}, name...) + index /= 26 + } + return string(name) +} + +// CheckReadPermission 验证应用是否具备读取目标飞书表格的权限。 +// 成功时返回可访问的工作表元信息;失败时返回飞书错误详情,便于排查权限/授权。 +func (c *Client) CheckReadPermission(ctx context.Context, rawURL, sheetTitle string) (*SheetMeta, string, error) { + ref, err := ParseSpreadsheetURL(rawURL) + if err != nil { + return nil, "", err + } + token, err := c.ResolveSpreadsheetToken(ctx, ref) + if err != nil { + return nil, "", err + } + meta, err := c.FindSheet(ctx, token, ref.SheetID, sheetTitle) + if err != nil { + return nil, token, err + } + // 再读一小段确认内容读权限(不仅是元信息) + if _, err := c.ReadSheetValues(ctx, token, meta.SheetID, "A1:A1"); err != nil { + return meta, token, fmt.Errorf("可列举工作表,但读取内容失败(请检查 sheets 读权限及文档授权): %w", err) + } + return meta, token, nil +} diff --git a/common_util/feishu_util/url.go b/common_util/feishu_util/url.go new file mode 100644 index 0000000..d82ecfd --- /dev/null +++ b/common_util/feishu_util/url.go @@ -0,0 +1,51 @@ +package feishu_util + +import ( + "fmt" + "net/url" + "strings" +) + +// SpreadsheetRef 解析后的飞书表格定位信息。 +type SpreadsheetRef struct { + // RawURL 原始链接 + RawURL string + // WikiToken 知识库节点 token(URL 中 /wiki/ 后一段);非 wiki 链接为空 + WikiToken string + // SpreadsheetToken 电子表格 token;wiki 链接需再调接口换取 obj_token + SpreadsheetToken string + // SheetID URL 查询参数 sheet= 的值(可选) + SheetID string +} + +// ParseSpreadsheetURL 解析飞书表格/知识库链接。 +// 支持: +// - https://xxx.feishu.cn/wiki/{wikiToken}?sheet={sheetId} +// - https://xxx.feishu.cn/sheets/{spreadsheetToken}?sheet={sheetId} +func ParseSpreadsheetURL(raw string) (*SpreadsheetRef, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("表格 URL 为空") + } + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("解析 URL 失败: %w", err) + } + + ref := &SpreadsheetRef{RawURL: raw, SheetID: u.Query().Get("sheet")} + path := strings.Trim(u.Path, "/") + parts := strings.Split(path, "/") + if len(parts) < 2 { + return nil, fmt.Errorf("无法从 URL 解析 token: %s", raw) + } + + switch parts[0] { + case "wiki": + ref.WikiToken = parts[1] + case "sheets": + ref.SpreadsheetToken = parts[1] + default: + return nil, fmt.Errorf("不支持的飞书链接类型 %q,仅支持 /wiki/ 或 /sheets/", parts[0]) + } + return ref, nil +} diff --git a/common_util/feishu_util/url_test.go b/common_util/feishu_util/url_test.go new file mode 100644 index 0000000..23c0c52 --- /dev/null +++ b/common_util/feishu_util/url_test.go @@ -0,0 +1,25 @@ +package feishu_util + +import "testing" + +func TestParseSpreadsheetURL_Wiki(t *testing.T) { + ref, err := ParseSpreadsheetURL(DefaultOptimizationIssueSheetURL) + if err != nil { + t.Fatal(err) + } + if ref.WikiToken != "IbCUwR4bvil4E1k9k2LcCiwSn8t" { + t.Fatalf("wiki token = %s", ref.WikiToken) + } + if ref.SheetID != "QyDfOp" { + t.Fatalf("sheet id = %s", ref.SheetID) + } +} + +func TestColIndexToName(t *testing.T) { + cases := map[int]string{1: "A", 26: "Z", 27: "AA", 52: "AZ", 53: "BA"} + for n, want := range cases { + if got := colIndexToName(n); got != want { + t.Fatalf("colIndexToName(%d)=%s want %s", n, got, want) + } + } +} diff --git a/common_util/feishu_util/wiki.go b/common_util/feishu_util/wiki.go new file mode 100644 index 0000000..689cd61 --- /dev/null +++ b/common_util/feishu_util/wiki.go @@ -0,0 +1,83 @@ +package feishu_util + +import ( + "context" + "fmt" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + larkwiki "github.com/larksuite/oapi-sdk-go/v3/service/wiki/v2" +) + +// WikiNode 知识库节点解析结果。 +type WikiNode struct { + SpaceID string + NodeToken string + ObjToken string + ObjType string + Title string +} + +// ResolveWikiNode 通过知识库 node_token 获取实际云文档 obj_token。 +// 知识库 URL 中的 token 不是 spreadsheet_token,读写表格内容前必须先转换。 +func (c *Client) ResolveWikiNode(ctx context.Context, wikiToken string) (*WikiNode, error) { + if wikiToken == "" { + return nil, fmt.Errorf("wikiToken 为空") + } + req := larkwiki.NewGetNodeSpaceReqBuilder(). + Token(wikiToken). + Build() + resp, err := c.Lark.Wiki.V2.Space.GetNode(ctx, req) + if err != nil { + return nil, fmt.Errorf("获取知识库节点失败: %w", err) + } + if !resp.Success() { + return nil, fmt.Errorf("获取知识库节点失败: code=%d msg=%s log_id=%s detail=%s", + resp.Code, resp.Msg, resp.RequestId(), larkcore.Prettify(resp.CodeError)) + } + if resp.Data == nil || resp.Data.Node == nil { + return nil, fmt.Errorf("获取知识库节点返回空数据") + } + n := resp.Data.Node + node := &WikiNode{} + if n.SpaceId != nil { + node.SpaceID = *n.SpaceId + } + if n.NodeToken != nil { + node.NodeToken = *n.NodeToken + } + if n.ObjToken != nil { + node.ObjToken = *n.ObjToken + } + if n.ObjType != nil { + node.ObjType = *n.ObjType + } + if n.Title != nil { + node.Title = *n.Title + } + if node.ObjToken == "" { + return nil, fmt.Errorf("知识库节点未返回 obj_token") + } + return node, nil +} + +// ResolveSpreadsheetToken 将 SpreadsheetRef 解析为可调用 Sheets API 的 spreadsheet_token。 +func (c *Client) ResolveSpreadsheetToken(ctx context.Context, ref *SpreadsheetRef) (string, error) { + if ref == nil { + return "", fmt.Errorf("SpreadsheetRef 为空") + } + if ref.SpreadsheetToken != "" { + return ref.SpreadsheetToken, nil + } + if ref.WikiToken == "" { + return "", fmt.Errorf("既无 SpreadsheetToken 也无 WikiToken") + } + node, err := c.ResolveWikiNode(ctx, ref.WikiToken) + if err != nil { + return "", err + } + if node.ObjType != "" && node.ObjType != "sheet" { + return "", fmt.Errorf("知识库节点类型为 %s,不是电子表格(sheet)", node.ObjType) + } + ref.SpreadsheetToken = node.ObjToken + return node.ObjToken, nil +} diff --git a/config/config.example.yaml b/config/config.example.yaml new file mode 100644 index 0000000..3d9fa02 --- /dev/null +++ b/config/config.example.yaml @@ -0,0 +1,8 @@ +# 配置模板(可入库)。复制为 config/config.dev.yaml 后填入真实值。 +# 本地:config/config.dev.yaml(已 gitignore) +feishu: + app_id: "" # 飞书应用 App ID,如 cli_xxx + app_secret: "" # 飞书应用 App Secret,勿提交仓库 +hisense_geo: + base_url: "https://geo-api.hisense.com" + token: "" # 生产环境 x-token,勿提交仓库 diff --git a/config/load.go b/config/load.go new file mode 100644 index 0000000..89bef1c --- /dev/null +++ b/config/load.go @@ -0,0 +1,91 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// Config 项目根目录 config/config.{env}.yaml(敏感项已 gitignore)。 +type Config struct { + Feishu FeishuConfig `yaml:"feishu"` + HisenseGeo HisenseGeoConfig `yaml:"hisense_geo"` +} + +// FeishuConfig 飞书应用凭证。 +type FeishuConfig struct { + AppID string `yaml:"app_id"` + AppSecret string `yaml:"app_secret"` +} + +// HisenseGeoConfig 海信 GEO 生产环境接口配置。 +type HisenseGeoConfig struct { + BaseURL string `yaml:"base_url"` // 默认 https://geo-api.hisense.com + Token string `yaml:"token"` // x-token,勿提交仓库 +} + +// Load 按 APP_ENV 加载 config 目录下配置,默认 dev。 +// 查找顺序: +// 1. CONFIG_PATH / FEISHU_CONFIG_PATH +// 2. 当前工作目录 ./config/config.{env}.yaml +// 3. 向上查找含 config/ 目录的项目根下的 config/config.{env}.yaml +func Load() (*Config, error) { + env := os.Getenv("APP_ENV") + if env == "" { + env = "dev" + } + name := "config." + env + ".yaml" + rel := filepath.Join("config", name) + + candidates := []string{} + if p := os.Getenv("CONFIG_PATH"); p != "" { + candidates = append(candidates, p) + } + if p := os.Getenv("FEISHU_CONFIG_PATH"); p != "" { + candidates = append(candidates, p) + } + candidates = append(candidates, rel) + if root := FindProjectRoot(); root != "" { + candidates = append(candidates, filepath.Join(root, rel)) + } + + var lastErr error + for _, path := range candidates { + data, err := os.ReadFile(path) + if err != nil { + lastErr = err + continue + } + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("解析配置失败 (%s): %w", path, err) + } + if cfg.HisenseGeo.BaseURL == "" { + cfg.HisenseGeo.BaseURL = "https://geo-api.hisense.com" + } + return &cfg, nil + } + return nil, fmt.Errorf("未找到配置文件 %s(放在 config/ 目录,或设置 CONFIG_PATH): %w", rel, lastErr) +} + +// FindProjectRoot 向上查找包含 config/ 目录的项目根。 +func FindProjectRoot() string { + wd, err := os.Getwd() + if err != nil { + return "" + } + dir := wd + for { + cfgDir := filepath.Join(dir, "config") + if st, err := os.Stat(cfgDir); err == nil && st.IsDir() { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + return "" + } + dir = parent + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2711790 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module ai_script + +go 1.24.8 + +require ( + github.com/larksuite/oapi-sdk-go/v3 v3.12.0 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5ef658d --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/larksuite/oapi-sdk-go/v3 v3.12.0 h1:H8NP6YIgfEX0RBhKse25npdeZoiaDv8mrw9nCnCVFRc= +github.com/larksuite/oapi-sdk-go/v3 v3.12.0/go.mod h1:F1nwLfYBSKvD8mS/OFw90Gr+V4RIugPBR44m/ThA5jg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/haoxian_hisense/agentic_project/check_agentic_project_name/README.md b/haoxian_hisense/agentic_project/check_agentic_project_name/README.md new file mode 100644 index 0000000..5b755a4 --- /dev/null +++ b/haoxian_hisense/agentic_project/check_agentic_project_name/README.md @@ -0,0 +1,61 @@ +# check_agentic_project_name + +刷数脚本:检查项目名称、产业/品类、产品名称、漏出词等数据一致性。 + +## 数据源 + +1. 飞书表格「9月各产业优化问题汇总表」 + `https://mi8elatzm1x.feishu.cn/wiki/IbCUwR4bvil4E1k9k2LcCiwSn8t?sheet=QyDfOp` +2. 生产接口 + `GET https://geo-api.hisense.com/api/agentic-project/project?page=1&page_size=999&listed=all` + Header: `x-token` + +## 匹配主键(跟接口保持一致) + +| 飞书 | 接口 | +|------|------| +| 优化问题 | `project_name` | +| 产业(= 品牌 + 品类) | `brand` + `category`(直接拼接) | + +接口侧额外过滤(必须同时满足): + +- `listed = 1` +- `status = 'running'` +- `end_month = '2026-09'` + +匹配规则: + +1. 先按上述过滤筛接口项目 +2. 常规:`产业 == brand+category`(如 `海信中央空调`) +3. 简写兼容:`产业 == brand` 且 `品类 == category`(如 ASKO / 古洛尼) +4. 同名+同产业仍多条时:优先飞书【产品名称】/【特征露出词】与接口一致 + +`品类` ↔ `category` 仅作校验罗列,不阻断主键匹配。 + +## 比对项 + +1. 飞书【优化问题】在接口中找不到 +2. 飞书【产业】无法按规则匹配接口【brand+category】(匹配失败,打印完整对照) +3. 飞书【产业】与接口【brand+category】字面不一致(主键已匹配,如简写 `ASKO` vs `ASKO全品类`) +4. 飞书【品类】与接口【category】不一致(主键已匹配) +5. 飞书【产品名称】与接口 `product_name` 不一致 → 输出更新 SQL +6. 飞书【特征露出词】与接口 `exposure_word` 不一致 → 输出更新 SQL + +## 运行 + +```bash +go run ./haoxian_hisense/agentic_project/check_agentic_project_name +``` + +## 配置 + +`config/config.dev.yaml`(已 gitignore): + +```yaml +feishu: + app_id: "" + app_secret: "" +hisense_geo: + base_url: "https://geo-api.hisense.com" + token: "" # x-token,勿提交仓库 +``` diff --git a/haoxian_hisense/agentic_project/check_agentic_project_name/main.go b/haoxian_hisense/agentic_project/check_agentic_project_name/main.go new file mode 100644 index 0000000..655c79a --- /dev/null +++ b/haoxian_hisense/agentic_project/check_agentic_project_name/main.go @@ -0,0 +1,585 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "sort" + "strings" + "time" + "unicode" + + "ai_script/common_util/feishu_util" + "ai_script/config" +) + +func main() { + // 加载项目根目录 config/config.{env}.yaml(含飞书凭证与 GEO Token) + cfg, err := config.Load() + if err != nil { + log.Fatalf("加载配置失败: %v", err) + } + + ctx := context.Background() + + // --------------------------------------------------------------------------- + // 1、拉取飞书表格中「9月各产业优化问题汇总表」sheet 数据 + // https://mi8elatzm1x.feishu.cn/wiki/IbCUwR4bvil4E1k9k2LcCiwSn8t?sheet=QyDfOp + // --------------------------------------------------------------------------- + feishuRows, err := fetchFeishuOptimizationRows(ctx, cfg) + if err != nil { + log.Fatalf("拉取飞书表格失败: %v", err) + } + log.Printf("[1] 飞书表格有效行数: %d", len(feishuRows)) + + // --------------------------------------------------------------------------- + // 2、拉取生产环境项目表数据 + // GET {base_url}/api/agentic-project/project?page=1&page_size=999&listed=all + // Header: x-token + // --------------------------------------------------------------------------- + projects, err := fetchProdProjects(ctx, cfg) + if err != nil { + log.Fatalf("拉取生产环境项目失败: %v", err) + } + log.Printf("[2] 生产环境项目数: %d", len(projects)) + + // 按 project_name 分组,匹配时用 产业 ↔ brand+category(与接口保持一致) + projectsByName := indexProjectsByName(projects) + + // --------------------------------------------------------------------------- + // 3、比对差异点 + // 匹配主键:飞书【优化问题+产业】 ↔ 接口【project_name+(brand+category)】 + // 品类 ↔ category 仅作校验罗列,不阻断主键匹配 + // --------------------------------------------------------------------------- + compareAndLogDiffs(feishuRows, projectsByName) +} + +// feishuRow 飞书「9月各产业优化问题汇总表」解析后的一行。 +type feishuRow struct { + RowNum int // 表格中的物理行号(1-based,含表头) + Industry string // 产业 = 品牌+品类 → 对齐接口 brand+category + Category string // 品类 → 对齐接口 category + ProductName string // 产品名称 + ExposureWord string // 特征露出词 + OptimQuestion string // 优化问题 → 对齐接口 project_name +} + +// prodProject 生产环境 agentic_project 列表项。 +type prodProject struct { + ID string + CompanyID string // company_id + ProjectName string // project_name + Brand string // brand + Category string // category + ProductName string // product_name(数组拼接) + ExposureWord string // exposure_word + Status string // status: running / completed / ... + Listed int // listed + EndMonth string // end_month,如 2026-09 +} + +// 匹配时额外过滤条件(跟业务月度表对齐) +const ( + requiredListed = 1 + requiredStatus = "running" + requiredEndMonth = "2026-09" +) + +// eligibleProject 是否满足 listed/status/end_month 过滤。 +func eligibleProject(p prodProject) bool { + return p.Listed == requiredListed && + strings.EqualFold(p.Status, requiredStatus) && + p.EndMonth == requiredEndMonth +} + +// apiIndustry 接口侧「产业」等价字段:brand + category(无连接符)。 +func (p prodProject) apiIndustry() string { + return p.Brand + p.Category +} + +// industryMatched 飞书产业与接口是否对齐(跟接口保持一致): +// 1) 产业 == brand+category(常规:海信中央空调) +// 2) 产业 == brand 且 品类 == category(简写:ASKO / 古洛尼) +func industryMatched(row feishuRow, api prodProject) bool { + if foldText(row.Industry) == foldText(api.apiIndustry()) { + return true + } + if foldText(row.Industry) == foldText(api.Brand) && foldText(row.Category) == foldText(api.Category) { + return true + } + return false +} + +// findMatchedProject 按优化问题找同名项目,再按产业规则对齐接口。 +// 仅考虑 listed=1 AND status=running AND end_month=2026-09; +// 同名+同产业仍多条时,优先产品名称/露出词一致。 +func findMatchedProject(row feishuRow, projectsByName map[string][]prodProject) (prodProject, bool) { + cands := projectsByName[foldText(row.OptimQuestion)] + matched := make([]prodProject, 0, 2) + for _, api := range cands { + if !eligibleProject(api) { + continue + } + if industryMatched(row, api) { + matched = append(matched, api) + } + } + if len(matched) == 0 { + return prodProject{}, false + } + if len(matched) == 1 { + return matched[0], true + } + + best := matched[0] + bestScore := matchScore(row, best) + for _, api := range matched[1:] { + if s := matchScore(row, api); s > bestScore { + best = api + bestScore = s + } + } + ids := make([]string, 0, len(matched)) + for _, m := range matched { + ids = append(ids, m.ID) + } + log.Printf("[匹配消歧] 优化问题=%q 产业=%q 候选id=%v,选用 id=%s score=%d(飞书产品=%q 露出词=%q)", + row.OptimQuestion, row.Industry, ids, best.ID, bestScore, row.ProductName, row.ExposureWord) + return best, true +} + +// matchScore 同名同产业多候选时的打分(越高越优先)。 +func matchScore(row feishuRow, api prodProject) int { + score := 0 + if sameDelimitedText(row.ProductName, api.ProductName) { + score += 100 + } + if sameDelimitedText(row.ExposureWord, api.ExposureWord) { + score += 50 + } + return score +} + +// fetchFeishuOptimizationRows 步骤1:读取飞书 sheet 并解析为结构化行。 +func fetchFeishuOptimizationRows(ctx context.Context, cfg *config.Config) ([]feishuRow, error) { + client, err := feishu_util.NewClientFromConfig(cfg) + if err != nil { + return nil, err + } + values, err := client.ReadSpreadsheetURL(ctx, + feishu_util.DefaultOptimizationIssueSheetURL, + feishu_util.DefaultOptimizationIssueSheetTitle, + ) + if err != nil { + return nil, err + } + return parseFeishuSheet(values.Values) +} + +// parseFeishuSheet 根据表头定位列并解析数据行。 +func parseFeishuSheet(values [][]interface{}) ([]feishuRow, error) { + headerIdx := -1 + colIndustry, colCategory := -1, -1 + colProduct, colExposure, colQuestion := -1, -1, -1 + + for i, row := range values { + cells := rowToStrings(row) + for j, cell := range cells { + switch strings.TrimSpace(cell) { + case "产业": + colIndustry = j + case "品类": + colCategory = j + case "产品名称": + colProduct = j + case "特征露出词": + colExposure = j + case "优化问题": + colQuestion = j + } + } + if colIndustry >= 0 && colCategory >= 0 && colProduct >= 0 && colExposure >= 0 && colQuestion >= 0 { + headerIdx = i + break + } + colIndustry, colCategory, colProduct, colExposure, colQuestion = -1, -1, -1, -1, -1 + } + if headerIdx < 0 { + return nil, fmt.Errorf("未在飞书表格中找到表头列:产业 / 品类 / 产品名称 / 特征露出词 / 优化问题") + } + + out := make([]feishuRow, 0) + for i := headerIdx + 1; i < len(values); i++ { + cells := rowToStrings(values[i]) + question := cellAt(cells, colQuestion) + if question == "" { + // 空行 / 产业分隔空行跳过 + continue + } + out = append(out, feishuRow{ + RowNum: i + 1, + Industry: cellAt(cells, colIndustry), + Category: cellAt(cells, colCategory), + ProductName: cellAt(cells, colProduct), + ExposureWord: cellAt(cells, colExposure), + OptimQuestion: question, + }) + } + return out, nil +} + +// fetchProdProjects 步骤2:拉取 agentic-project 列表(含 brand/category/exposure_word)。 +func fetchProdProjects(ctx context.Context, cfg *config.Config) ([]prodProject, error) { + if cfg.HisenseGeo.Token == "" { + return nil, fmt.Errorf("配置缺少 hisense_geo.token") + } + base := strings.TrimRight(cfg.HisenseGeo.BaseURL, "/") + url := fmt.Sprintf("%s/api/agentic-project/project?page=1&page_size=999&listed=all", base) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Origin", "https://geo.hisense.com") + req.Header.Set("Referer", "https://geo.hisense.com/") + req.Header.Set("x-token", cfg.HisenseGeo.Token) + + httpClient := &http.Client{Timeout: 60 * time.Second} + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("agentic-project 接口 HTTP %d: %s", resp.StatusCode, truncate(string(body), 300)) + } + + var raw struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + Total int `json:"total"` + List []struct { + ID json.Number `json:"id"` + CompanyID json.Number `json:"company_id"` + ProjectName string `json:"project_name"` + Brand string `json:"brand"` + Category string `json:"category"` + ProductName []string `json:"product_name"` + ExposureWord string `json:"exposure_word"` + Status string `json:"status"` + Listed int `json:"listed"` + EndMonth string `json:"end_month"` + } `json:"list"` + } `json:"data"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("解析 agentic-project 响应失败: %w", err) + } + if raw.Code != 0 { + return nil, fmt.Errorf("agentic-project 接口业务错误 code=%d message=%s", raw.Code, raw.Message) + } + + out := make([]prodProject, 0, len(raw.Data.List)) + nonCompany13 := 0 + eligible := 0 + for _, w := range raw.Data.List { + companyID := w.CompanyID.String() + if companyID == "" { + companyID = "13" + } + if companyID != "13" { + nonCompany13++ + } + p := prodProject{ + ID: w.ID.String(), + CompanyID: companyID, + ProjectName: strings.TrimSpace(w.ProjectName), + Brand: strings.TrimSpace(w.Brand), + Category: strings.TrimSpace(w.Category), + ProductName: joinProductNames(w.ProductName), + ExposureWord: strings.TrimSpace(w.ExposureWord), + Status: strings.TrimSpace(w.Status), + Listed: w.Listed, + EndMonth: strings.TrimSpace(w.EndMonth), + } + if eligibleProject(p) { + eligible++ + } + out = append(out, p) + } + log.Printf("[2] 满足 listed=%d AND status=%s AND end_month=%s 的项目: %d / %d", + requiredListed, requiredStatus, requiredEndMonth, eligible, len(out)) + if nonCompany13 > 0 { + log.Printf("[2] 警告: 存在 company_id != 13 的项目 %d 条", nonCompany13) + } else { + log.Printf("[2] 全部项目 company_id = 13") + } + if raw.Data.Total > len(out) { + log.Printf("[2] 警告: 接口 total=%d,本页仅返回 %d 条,可能未拉全", raw.Data.Total, len(out)) + } + return out, nil +} + +func indexProjectsByName(projects []prodProject) map[string][]prodProject { + m := make(map[string][]prodProject) + for _, p := range projects { + key := foldText(p.ProjectName) + if key == "" { + continue + } + m[key] = append(m[key], p) + } + return m +} + +type industryCategoryMismatch struct { + row feishuRow + apiList []prodProject // 同名 project_name 下的候选项目 +} + +type matchedPair struct { + row feishuRow + api prodProject +} + +// compareAndLogDiffs 按接口语义匹配,并输出差异与更新 SQL。 +func compareAndLogDiffs(rows []feishuRow, projectsByName map[string][]prodProject) { + var ( + missing []feishuRow + fieldMismatch []industryCategoryMismatch + matched []matchedPair + categoryOnly []matchedPair // 主键已匹配,但品类 ≠ category + ) + + var industryLiteralDiff []matchedPair // 主键已匹配,但飞书产业 ≠ brand+category(多为简写兼容) + + for _, row := range rows { + if api, ok := findMatchedProject(row, projectsByName); ok { + matched = append(matched, matchedPair{row: row, api: api}) + if foldText(row.Industry) != foldText(api.apiIndustry()) { + industryLiteralDiff = append(industryLiteralDiff, matchedPair{row: row, api: api}) + } + if foldText(row.Category) != foldText(api.Category) { + categoryOnly = append(categoryOnly, matchedPair{row: row, api: api}) + } + continue + } + cands := projectsByName[foldText(row.OptimQuestion)] + if len(cands) == 0 { + missing = append(missing, row) + continue + } + fieldMismatch = append(fieldMismatch, industryCategoryMismatch{row: row, apiList: cands}) + } + + // ---------- 3.0 主键匹配概况 ---------- + log.Printf("========== 主键匹配概况 ==========") + log.Printf("匹配主键: 飞书【优化问题+产业】 ↔ 接口【project_name+(brand+category)】") + log.Printf("接口过滤: listed=%d AND status=%s AND end_month=%s", requiredListed, requiredStatus, requiredEndMonth) + log.Printf("说明: 飞书【产业】应对齐 brand+category;简写兼容(产业=brand)时会列入「产业字面不一致」") + log.Printf("飞书有效行=%d, 主键匹配=%d, 产业匹配失败=%d, 产业字面不一致(已匹配)=%d, 优化问题缺失=%d, 品类不一致(已匹配)=%d", + len(rows), len(matched), len(fieldMismatch), len(industryLiteralDiff), len(missing), len(categoryOnly)) + if len(missing) == 0 && len(fieldMismatch) == 0 { + log.Printf("结论: 主键(优化问题+产业)全部对应上") + } + + // ---------- 3.1 优化问题在接口中找不到 ---------- + log.Printf("========== 3.1 飞书【优化问题】在接口中找不到(共 %d 条)==========", len(missing)) + for _, row := range missing { + log.Printf("[缺失] 行=%d 优化问题=%q 产业=%q 品类=%q", + row.RowNum, row.OptimQuestion, row.Industry, row.Category) + } + + // ---------- 3.1b 产业匹配失败(同名存在但无法按规则对齐) ---------- + log.Printf("========== 3.1b 飞书【产业】无法匹配接口【brand+category】(共 %d 条)==========", len(fieldMismatch)) + for _, d := range fieldMismatch { + log.Printf("[产业匹配失败] 行=%d 优化问题=%q", d.row.RowNum, d.row.OptimQuestion) + log.Printf(" 飞书: 产业=%q 品类=%q", d.row.Industry, d.row.Category) + for _, api := range d.apiList { + log.Printf(" 接口: id=%s project_name=%q brand=%q category=%q → brand+category=%q product_name=%q exposure_word=%q", + api.ID, api.ProjectName, api.Brand, api.Category, api.apiIndustry(), api.ProductName, api.ExposureWord) + } + } + + // ---------- 3.1b2 主键已匹配,但飞书产业字面 ≠ brand+category ---------- + log.Printf("========== 3.1b2 飞书【产业】与接口【brand+category】字面不一致(主键已匹配,共 %d 条)==========", len(industryLiteralDiff)) + for _, d := range industryLiteralDiff { + log.Printf("[产业字面不一致] 行=%d id=%s 优化问题=%q", d.row.RowNum, d.api.ID, d.row.OptimQuestion) + log.Printf(" 飞书: 产业=%q 品类=%q", d.row.Industry, d.row.Category) + log.Printf(" 接口: brand=%q category=%q → brand+category=%q", + d.api.Brand, d.api.Category, d.api.apiIndustry()) + } + + // ---------- 3.1c 品类不一致(主键已匹配) ---------- + log.Printf("========== 3.1c 飞书【品类】与接口【category】不一致(主键已匹配,共 %d 条)==========", len(categoryOnly)) + for _, d := range categoryOnly { + log.Printf("[品类不一致] 行=%d 优化问题=%q id=%s", d.row.RowNum, d.row.OptimQuestion, d.api.ID) + log.Printf(" 飞书: 产业=%q 品类=%q", d.row.Industry, d.row.Category) + log.Printf(" 接口: brand=%q category=%q → brand+category=%q", + d.api.Brand, d.api.Category, d.api.apiIndustry()) + } + + // ---------- 3.2 产品名称不一致 + 更新 SQL ---------- + var productDiff []matchedPair + for _, m := range matched { + if !sameDelimitedText(m.row.ProductName, m.api.ProductName) { + productDiff = append(productDiff, m) + } + } + log.Printf("========== 3.2 飞书【产品名称】与接口 product_name 不一致(共 %d 条)==========", len(productDiff)) + for _, d := range productDiff { + log.Printf("[产品名称不一致] 行=%d id=%s company_id=%s 优化问题=%q 产业=%q 品类=%q\n 飞书=%q\n 接口=%q", + d.row.RowNum, d.api.ID, d.api.CompanyID, d.row.OptimQuestion, d.row.Industry, d.row.Category, + d.row.ProductName, d.api.ProductName) + } + log.Printf("---------- 3.2 产品名称更新 SQL(共 %d 条)----------", len(productDiff)) + for _, d := range productDiff { + fmt.Println(buildUpdateSQL("product_name", d.row.ProductName, d.api.ID, d.api.CompanyID)) + } + + // ---------- 3.3 特征露出词不一致 + 更新 SQL ---------- + var exposureDiff []matchedPair + for _, m := range matched { + if !sameDelimitedText(m.row.ExposureWord, m.api.ExposureWord) { + exposureDiff = append(exposureDiff, m) + } + } + log.Printf("========== 3.3 飞书【特征露出词】与接口 exposure_word 不一致(共 %d 条)==========", len(exposureDiff)) + for _, d := range exposureDiff { + log.Printf("[特征露出词不一致] 行=%d id=%s company_id=%s 优化问题=%q 产业=%q 品类=%q\n 飞书=%q\n 接口=%q", + d.row.RowNum, d.api.ID, d.api.CompanyID, d.row.OptimQuestion, d.row.Industry, d.row.Category, + d.row.ExposureWord, d.api.ExposureWord) + } + log.Printf("---------- 3.3 特征露出词更新 SQL(共 %d 条)----------", len(exposureDiff)) + for _, d := range exposureDiff { + fmt.Println(buildUpdateSQL("exposure_word", d.row.ExposureWord, d.api.ID, d.api.CompanyID)) + } + + log.Printf("比对完成: 飞书行=%d, 主键匹配=%d, 产业匹配失败=%d, 产业字面不一致=%d, 品类不一致=%d, 优化问题缺失=%d, 产品名称不一致=%d, 特征露出词不一致=%d", + len(rows), len(matched), len(fieldMismatch), len(industryLiteralDiff), len(categoryOnly), len(missing), len(productDiff), len(exposureDiff)) +} + +// buildUpdateSQL 生成以飞书值为准的回写 SQL(带 company_id 条件)。 +func buildUpdateSQL(column, value, id, companyID string) string { + if companyID == "" { + companyID = "13" + } + return fmt.Sprintf("update `agentic`.agentic_project set %s = '%s' where id = %s and company_id = %s", + column, escapeSQLString(value), id, companyID) +} + +func escapeSQLString(s string) string { + return strings.ReplaceAll(s, "'", "''") +} + +func joinProductNames(names []string) string { + parts := make([]string, 0, len(names)) + for _, n := range names { + n = strings.TrimSpace(n) + if n != "" { + parts = append(parts, n) + } + } + return strings.Join(parts, "|") +} + +// sameDelimitedText 比较以 |/| 分隔的多值文本(忽略顺序与首尾空白)。 +func sameDelimitedText(a, b string) bool { + return delimitedSetKey(a) == delimitedSetKey(b) +} + +func delimitedSetKey(s string) string { + s = strings.ReplaceAll(s, "|", "|") + parts := strings.Split(s, "|") + norm := make([]string, 0, len(parts)) + seen := make(map[string]struct{}) + for _, p := range parts { + p = normalizeText(p) + if p == "" { + continue + } + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + norm = append(norm, p) + } + sort.Strings(norm) + return strings.Join(norm, "|") +} + +func normalizeText(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + var b strings.Builder + b.Grow(len(s)) + prevSpace := false + for _, r := range s { + if unicode.IsSpace(r) { + if !prevSpace { + b.WriteRune(' ') + prevSpace = true + } + continue + } + prevSpace = false + b.WriteRune(r) + } + return strings.TrimSpace(b.String()) +} + +// foldText 归一化后转小写,避免 Vidda/VIdda 等大小写差异影响匹配。 +func foldText(s string) string { + return strings.ToLower(normalizeText(s)) +} + +func rowToStrings(row []interface{}) []string { + out := make([]string, len(row)) + for i, v := range row { + out[i] = stringifyCell(v) + } + return out +} + +func cellAt(cells []string, idx int) string { + if idx < 0 || idx >= len(cells) { + return "" + } + return strings.TrimSpace(cells[idx]) +} + +func stringifyCell(v interface{}) string { + if v == nil { + return "" + } + switch t := v.(type) { + case string: + return t + case float64: + if t == float64(int64(t)) { + return fmt.Sprintf("%d", int64(t)) + } + return fmt.Sprintf("%v", t) + default: + return strings.TrimSpace(fmt.Sprint(t)) + } +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/haoxian_hisense/local/月度优化问题汇总管理表 - 9月各产业优化问题汇总表.xlsx b/haoxian_hisense/local/月度优化问题汇总管理表 - 9月各产业优化问题汇总表.xlsx new file mode 100644 index 0000000..779c573 Binary files /dev/null and b/haoxian_hisense/local/月度优化问题汇总管理表 - 9月各产业优化问题汇总表.xlsx differ