add:脚本初始化

This commit is contained in:
lily 2026-09-17 18:49:22 +08:00
commit 226224162f
15 changed files with 1283 additions and 0 deletions

17
.gitignore vendored Normal file
View File

@ -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

View File

@ -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)
}

View File

@ -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
}

View File

@ -0,0 +1,7 @@
package feishu_util
// 月度优化问题汇总管理表(知识库挂载的电子表格)
const (
DefaultOptimizationIssueSheetURL = "https://mi8elatzm1x.feishu.cn/wiki/IbCUwR4bvil4E1k9k2LcCiwSn8t?sheet=QyDfOp"
DefaultOptimizationIssueSheetTitle = "9月各产业优化问题汇总表"
)

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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
}

View File

@ -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,勿提交仓库

91
config/load.go Normal file
View File

@ -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
}
}

8
go.mod Normal file
View File

@ -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
)

6
go.sum Normal file
View File

@ -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=

View File

@ -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,勿提交仓库
```

View File

@ -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] + "..."
}