add:脚本初始化
This commit is contained in:
commit
226224162f
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal 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
|
||||||
49
cmd/check_sheet_permission/main.go
Normal file
49
cmd/check_sheet_permission/main.go
Normal 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)
|
||||||
|
}
|
||||||
56
common_util/feishu_util/client.go
Normal file
56
common_util/feishu_util/client.go
Normal 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
|
||||||
|
}
|
||||||
7
common_util/feishu_util/defaults.go
Normal file
7
common_util/feishu_util/defaults.go
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
package feishu_util
|
||||||
|
|
||||||
|
// 月度优化问题汇总管理表(知识库挂载的电子表格)
|
||||||
|
const (
|
||||||
|
DefaultOptimizationIssueSheetURL = "https://mi8elatzm1x.feishu.cn/wiki/IbCUwR4bvil4E1k9k2LcCiwSn8t?sheet=QyDfOp"
|
||||||
|
DefaultOptimizationIssueSheetTitle = "9月各产业优化问题汇总表"
|
||||||
|
)
|
||||||
236
common_util/feishu_util/sheet.go
Normal file
236
common_util/feishu_util/sheet.go
Normal 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
|
||||||
|
}
|
||||||
51
common_util/feishu_util/url.go
Normal file
51
common_util/feishu_util/url.go
Normal 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
|
||||||
|
}
|
||||||
25
common_util/feishu_util/url_test.go
Normal file
25
common_util/feishu_util/url_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
common_util/feishu_util/wiki.go
Normal file
83
common_util/feishu_util/wiki.go
Normal 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
|
||||||
|
}
|
||||||
8
config/config.example.yaml
Normal file
8
config/config.example.yaml
Normal 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
91
config/load.go
Normal 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
8
go.mod
Normal 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
6
go.sum
Normal 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=
|
||||||
@ -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,勿提交仓库
|
||||||
|
```
|
||||||
@ -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] + "..."
|
||||||
|
}
|
||||||
BIN
haoxian_hisense/local/月度优化问题汇总管理表 - 9月各产业优化问题汇总表.xlsx
Normal file
BIN
haoxian_hisense/local/月度优化问题汇总管理表 - 9月各产业优化问题汇总表.xlsx
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user