diff --git a/admin/model/model.go b/admin/model/model.go index 867126e..37119eb 100644 --- a/admin/model/model.go +++ b/admin/model/model.go @@ -370,14 +370,22 @@ type ShopChannelAlias struct { // // 附带好处:A 的映射还留着。A 补货换回去时,之前的匹配成果直接复用。 type SpecMapping struct { - ShopeeGoodsID string - SpecKey string // 由 spec.SpecKey 生成,存和查必须使用同一实现 - SpecRaw string // 保存当时的顺运宝原文,供人工核对 - PddGoodsID string // 这条映射属于哪个 PDD 商品 - PddOptionKey string // 规范化后的组合键,见 service.OptionKey - PddOptions string // 原始 options 对象 JSON,显示用 - MappedAt string - MappedBy string + ShopeeGoodsID string + SpecKey string // 由 spec.SpecKey 生成,存和查必须使用同一实现 + SpecRaw string // 保存当时的顺运宝原文,供人工核对 + PddGoodsID string // 这条映射属于哪个 PDD 商品 + PddOptionKey string // 规范化后的组合键,见 service.OptionKey + PddOptions string // 原始 options 对象 JSON,显示用 + MappedAt string + MappedBy string + Source string + SourceProviderID string + SourceModel string + ConfidenceBPS int + ConfidenceSet bool + SourceReason string + SourceVersion string + ContextVersion string } type SpecMappingDecision struct { @@ -387,6 +395,18 @@ type SpecMappingDecision struct { DecidedBy, DecidedAt string } +// AISpecMatchDecision 是不含完整提示词、模型响应和订单信息的 AI 规格匹配审计。 +type AISpecMatchDecision struct { + ShopeeGoodsID, SpecKey, PddGoodsID string + ContextVersion, RulesVersion, PromptVersion string + ProviderID, ProviderName, Model, ConfigFingerprint string + CandidatesJSON, ChosenCandidateID, ChosenOptionKey string + ConfidenceBPS int + ConfidenceSet bool + Outcome, Reason, ConflictDimensionsJSON, MissingDimensionsJSON string + DecidedBy, DecidedAt string +} + // ---------- 任务 ---------- // TaskType 区分采集任务和采购任务。 diff --git a/admin/repository/ai_config.go b/admin/repository/ai_config.go index 173352f..387ce2a 100644 --- a/admin/repository/ai_config.go +++ b/admin/repository/ai_config.go @@ -47,6 +47,22 @@ func GetAIProvider(q Execer, providerID string) (*model.AIProviderConfig, error) return &item, nil } +func GetEnabledAIProvider(q Execer) (*model.AIProviderConfig, error) { + item, err := scanAIProvider(func(dest ...any) error { + return q.QueryRow(`SELECT provider_id,name,base_url,model,timeout_seconds,max_concurrency, + confidence_threshold_bps,enabled,last_test_status,last_test_message,last_tested_at, + last_test_fingerprint,created_by_user_id,updated_by_user_id,created_at,updated_at + FROM ai_provider_configs WHERE enabled=1`).Scan(dest...) + }) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("查询当前 AI 服务商失败: %w", err) + } + return &item, nil +} + type scanValues func(dest ...any) error func scanAIProvider(scan scanValues) (model.AIProviderConfig, error) { diff --git a/admin/repository/mapping.go b/admin/repository/mapping.go index 35261ed..8dcff59 100644 --- a/admin/repository/mapping.go +++ b/admin/repository/mapping.go @@ -11,15 +11,18 @@ import ( // GetSpecMapping 读取某个蝦皮商品的顺运宝规格在指定 PDD 商品下的映射。 func GetSpecMapping(q Execer, shopeeGoodsID, specKey, pddGoodsID string) (*model.SpecMapping, error) { var m model.SpecMapping - var mappedBy sql.NullString - err := q.QueryRow(` + var mappedBy, providerID, sourceModel, reason, sourceVersion, contextVersion sql.NullString + var confidence sql.NullInt64 + query := ` SELECT shopee_goods_id, spec_key, pdd_goods_id, pdd_option_key, - pdd_options, spec_raw, mapped_at, mapped_by + pdd_options, spec_raw, mapped_at, mapped_by,source,source_provider_id, + source_model,confidence_bps,source_reason,source_version,context_version FROM spec_mappings - WHERE shopee_goods_id = ? AND spec_key = ? AND pdd_goods_id = ?`, - shopeeGoodsID, specKey, pddGoodsID).Scan( + WHERE shopee_goods_id = ? AND spec_key = ? AND pdd_goods_id = ?` + err := q.QueryRow(query, shopeeGoodsID, specKey, pddGoodsID).Scan( &m.ShopeeGoodsID, &m.SpecKey, &m.PddGoodsID, &m.PddOptionKey, - &m.PddOptions, &m.SpecRaw, &m.MappedAt, &mappedBy) + &m.PddOptions, &m.SpecRaw, &m.MappedAt, &mappedBy, &m.Source, &providerID, + &sourceModel, &confidence, &reason, &sourceVersion, &contextVersion) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -27,30 +30,108 @@ func GetSpecMapping(q Execer, shopeeGoodsID, specKey, pddGoodsID string) (*model return nil, fmt.Errorf("查询规格映射失败: %w", err) } m.MappedBy = mappedBy.String + m.SourceProviderID = providerID.String + m.SourceModel = sourceModel.String + m.ConfidenceBPS = int(confidence.Int64) + m.ConfidenceSet = confidence.Valid + m.SourceReason = reason.String + m.SourceVersion = sourceVersion.String + m.ContextVersion = contextVersion.String return &m, nil } // UpsertSpecMapping 保存可复用规格映射,唯一键包含当前 PDD 商品。 func UpsertSpecMapping(q Execer, m model.SpecMapping) error { + if m.Source == "" { + m.Source = "manual" + } _, err := q.Exec(` INSERT INTO spec_mappings (shopee_goods_id, spec_key, pdd_goods_id, pdd_option_key, - pdd_options, spec_raw, mapped_at, mapped_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + pdd_options, spec_raw, mapped_at, mapped_by,source,source_provider_id, + source_model,confidence_bps,source_reason,source_version,context_version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE pdd_option_key = VALUES(pdd_option_key), pdd_options = VALUES(pdd_options), spec_raw = VALUES(spec_raw), mapped_at = VALUES(mapped_at), - mapped_by = VALUES(mapped_by)`, + mapped_by = VALUES(mapped_by), + source = VALUES(source), + source_provider_id = VALUES(source_provider_id), + source_model = VALUES(source_model), + confidence_bps = VALUES(confidence_bps), + source_reason = VALUES(source_reason), + source_version = VALUES(source_version), + context_version = VALUES(context_version)`, m.ShopeeGoodsID, m.SpecKey, m.PddGoodsID, m.PddOptionKey, - m.PddOptions, m.SpecRaw, m.MappedAt, nullableText(m.MappedBy)) + m.PddOptions, m.SpecRaw, m.MappedAt, nullableText(m.MappedBy), m.Source, + nullableText(m.SourceProviderID), nullableText(m.SourceModel), nullableInt(m.ConfidenceBPS, m.ConfidenceSet), + nullableText(m.SourceReason), nullableText(m.SourceVersion), nullableText(m.ContextVersion)) if err != nil { return fmt.Errorf("保存规格映射失败: %w", err) } return nil } +// UpsertAutomaticSpecMapping 只在没有人工映射且上下文版本变化时写入自动结果。 +// 同一上下文的并发 AI 请求由先写入者获胜;人工保存无论先后都不会被自动结果覆盖。 +func UpsertAutomaticSpecMapping(q Execer, m model.SpecMapping) error { + _, err := q.Exec(` + INSERT INTO spec_mappings + (shopee_goods_id,spec_key,pdd_goods_id,pdd_option_key,pdd_options,spec_raw, + mapped_at,mapped_by,source,source_provider_id,source_model,confidence_bps, + source_reason,source_version,context_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE + pdd_option_key=IF(source<>'manual' AND COALESCE(context_version,'')<>VALUES(context_version),VALUES(pdd_option_key),pdd_option_key), + pdd_options=IF(source<>'manual' AND COALESCE(context_version,'')<>VALUES(context_version),VALUES(pdd_options),pdd_options), + spec_raw=IF(source<>'manual' AND COALESCE(context_version,'')<>VALUES(context_version),VALUES(spec_raw),spec_raw), + mapped_at=IF(source<>'manual' AND COALESCE(context_version,'')<>VALUES(context_version),VALUES(mapped_at),mapped_at), + mapped_by=IF(source<>'manual' AND COALESCE(context_version,'')<>VALUES(context_version),VALUES(mapped_by),mapped_by), + source_provider_id=IF(source<>'manual' AND COALESCE(context_version,'')<>VALUES(context_version),VALUES(source_provider_id),source_provider_id), + source_model=IF(source<>'manual' AND COALESCE(context_version,'')<>VALUES(context_version),VALUES(source_model),source_model), + confidence_bps=IF(source<>'manual' AND COALESCE(context_version,'')<>VALUES(context_version),VALUES(confidence_bps),confidence_bps), + source_reason=IF(source<>'manual' AND COALESCE(context_version,'')<>VALUES(context_version),VALUES(source_reason),source_reason), + source_version=IF(source<>'manual' AND COALESCE(context_version,'')<>VALUES(context_version),VALUES(source_version),source_version), + source=IF(source<>'manual' AND COALESCE(context_version,'')<>VALUES(context_version),VALUES(source),source), + context_version=IF(source<>'manual' AND COALESCE(context_version,'')<>VALUES(context_version),VALUES(context_version),context_version)`, + m.ShopeeGoodsID, m.SpecKey, m.PddGoodsID, m.PddOptionKey, m.PddOptions, m.SpecRaw, + m.MappedAt, nullableText(m.MappedBy), m.Source, nullableText(m.SourceProviderID), + nullableText(m.SourceModel), nullableInt(m.ConfidenceBPS, m.ConfidenceSet), nullableText(m.SourceReason), + nullableText(m.SourceVersion), nullableText(m.ContextVersion)) + if err != nil { + return fmt.Errorf("保存自动规格映射失败: %w", err) + } + return nil +} + +func InsertAISpecMatchDecision(q Execer, d model.AISpecMatchDecision) error { + _, err := q.Exec(`INSERT INTO ai_spec_match_decisions + (shopee_goods_id,spec_key,pdd_goods_id,context_version,rules_version,prompt_version, + provider_id,provider_name,model,config_fingerprint,candidates_json,chosen_candidate_id, + chosen_option_key,confidence_bps,outcome,reason,conflict_dimensions_json, + missing_dimensions_json,decided_by,decided_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, d.ShopeeGoodsID, d.SpecKey, + d.PddGoodsID, d.ContextVersion, d.RulesVersion, d.PromptVersion, + nullableText(d.ProviderID), nullableText(d.ProviderName), nullableText(d.Model), + nullableText(d.ConfigFingerprint), d.CandidatesJSON, nullableText(d.ChosenCandidateID), + nullableText(d.ChosenOptionKey), nullableInt(d.ConfidenceBPS, d.ConfidenceSet), d.Outcome, + nullableText(d.Reason), d.ConflictDimensionsJSON, d.MissingDimensionsJSON, + nullableText(d.DecidedBy), d.DecidedAt) + if err != nil { + return fmt.Errorf("写入 AI 规格匹配审计失败: %w", err) + } + return nil +} + +func nullableInt(value int, valid bool) any { + if !valid { + return nil + } + return value +} + func InsertSpecMappingDecision(q Execer, d model.SpecMappingDecision) error { _, err := q.Exec(`INSERT INTO spec_mapping_decisions (shopee_goods_id,spec_key,pdd_goods_id,rules_version,suggested_option_key, diff --git a/admin/repository/mysql_db.go b/admin/repository/mysql_db.go index c126b72..49c79b4 100644 --- a/admin/repository/mysql_db.go +++ b/admin/repository/mysql_db.go @@ -20,7 +20,7 @@ import ( "cmautobuy/admin/spec" ) -const mysqlSchemaVersion = 20 +const mysqlSchemaVersion = 21 // OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。 func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) { @@ -664,10 +664,89 @@ func MigrateMySQL(db *sql.DB) error { if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 20, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { return fmt.Errorf("记录 MySQL schema v20 失败: %w", err) } + current = 20 + } + if current < 21 { + if err := migrateMySQLV21(db); err != nil { + return fmt.Errorf("执行 MySQL schema v21 失败: %w", err) + } + if err := checkMySQLV21Shape(db); err != nil { + return fmt.Errorf("MySQL schema v21 自检失败,未记录版本: %w", err) + } + if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 21, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { + return fmt.Errorf("记录 MySQL schema v21 失败: %w", err) + } } return CheckMySQLSchema(db) } +// migrateMySQLV21 给当前有效规格映射增加来源,并建立只追加的 AI 决策审计。 +func migrateMySQLV21(db *sql.DB) error { + columns := []struct{ name, ddl string }{ + {"source", `ALTER TABLE spec_mappings ADD COLUMN source VARCHAR(16) NOT NULL DEFAULT 'manual' AFTER mapped_by`}, + {"source_provider_id", `ALTER TABLE spec_mappings ADD COLUMN source_provider_id VARCHAR(191) COLLATE utf8mb4_bin NULL AFTER source`}, + {"source_model", `ALTER TABLE spec_mappings ADD COLUMN source_model VARCHAR(191) NULL AFTER source_provider_id`}, + {"confidence_bps", `ALTER TABLE spec_mappings ADD COLUMN confidence_bps INT NULL AFTER source_model`}, + {"source_reason", `ALTER TABLE spec_mappings ADD COLUMN source_reason VARCHAR(500) NULL AFTER confidence_bps`}, + {"source_version", `ALTER TABLE spec_mappings ADD COLUMN source_version VARCHAR(64) NULL AFTER source_reason`}, + {"context_version", `ALTER TABLE spec_mappings ADD COLUMN context_version CHAR(64) COLLATE utf8mb4_bin NULL AFTER source_version`}, + } + for _, column := range columns { + exists, err := mysqlColumnExists(db, "spec_mappings", column.name) + if err != nil { + return err + } + if !exists { + if _, err := db.Exec(column.ddl); err != nil { + return err + } + } + } + for _, item := range []struct{ name, ddl string }{ + {"chk_spec_mappings_source", `ALTER TABLE spec_mappings ADD CONSTRAINT chk_spec_mappings_source CHECK (source IN ('manual','rule','ai'))`}, + {"chk_spec_mappings_confidence", `ALTER TABLE spec_mappings ADD CONSTRAINT chk_spec_mappings_confidence CHECK (confidence_bps IS NULL OR confidence_bps BETWEEN 0 AND 10000)`}, + } { + exists, err := mysqlConstraintExists(db, "spec_mappings", item.name) + if err != nil { + return err + } + if !exists { + if _, err := db.Exec(item.ddl); err != nil { + return err + } + } + } + _, err := db.Exec(`CREATE TABLE IF NOT EXISTS ai_spec_match_decisions ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + shopee_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + spec_key VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + pdd_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + context_version CHAR(64) COLLATE utf8mb4_bin NOT NULL, + rules_version VARCHAR(32) NOT NULL, + prompt_version VARCHAR(32) NOT NULL, + provider_id VARCHAR(191) COLLATE utf8mb4_bin, + provider_name VARCHAR(191), + model VARCHAR(191), + config_fingerprint CHAR(64) COLLATE utf8mb4_bin, + candidates_json LONGTEXT NOT NULL, + chosen_candidate_id VARCHAR(32) COLLATE utf8mb4_bin, + chosen_option_key VARCHAR(191) COLLATE utf8mb4_bin, + confidence_bps INT, + outcome VARCHAR(32) NOT NULL, + reason VARCHAR(500), + conflict_dimensions_json LONGTEXT NOT NULL, + missing_dimensions_json LONGTEXT NOT NULL, + decided_by VARCHAR(191) COLLATE utf8mb4_bin, + decided_at VARCHAR(35) NOT NULL, + KEY idx_ai_match_identity (shopee_goods_id,spec_key,pdd_goods_id,decided_at DESC,id DESC), + KEY idx_ai_match_outcome (outcome,decided_at DESC,id DESC), + CONSTRAINT fk_ai_match_actor FOREIGN KEY (decided_by) REFERENCES users(user_id), + CONSTRAINT chk_ai_match_confidence CHECK (confidence_bps IS NULL OR confidence_bps BETWEEN 0 AND 10000), + CONSTRAINT chk_ai_match_outcome CHECK (outcome IN ('rule_saved','ai_saved','manual_exists','reused','rejected','failed','stale')) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`) + return err +} + // migrateMySQLV20 保存 AI 服务商的非敏感配置和配置审计。 // API Key 只存在独立密钥文件中,不在这两张表里留列。 func migrateMySQLV20(db *sql.DB) error { @@ -1864,6 +1943,7 @@ func CheckMySQLSchema(db *sql.DB) error { "task_sequences", "syb_allowed_shops", "ai_provider_configs", "ai_provider_audits", + "ai_spec_match_decisions", } if err := checkMySQLSchema(db, mysqlRequiredTables); err != nil { return err @@ -1919,7 +1999,43 @@ func CheckMySQLSchema(db *sql.DB) error { if err := checkMySQLV19Shape(db); err != nil { return err } - return checkMySQLV20Shape(db) + if err := checkMySQLV20Shape(db); err != nil { + return err + } + return checkMySQLV21Shape(db) +} + +func checkMySQLV21Shape(db *sql.DB) error { + if err := checkMySQLSchema(db, []string{"spec_mappings", "ai_spec_match_decisions"}); err != nil { + return err + } + for _, name := range []string{"source", "source_provider_id", "source_model", "confidence_bps", + "source_reason", "source_version", "context_version"} { + exists, err := mysqlColumnExists(db, "spec_mappings", name) + if err != nil || !exists { + return fmt.Errorf("规格映射来源字段 %s 缺失: %v", name, err) + } + } + for _, item := range []struct{ kind, table, name string }{ + {"constraint", "spec_mappings", "chk_spec_mappings_source"}, + {"constraint", "spec_mappings", "chk_spec_mappings_confidence"}, + {"constraint", "ai_spec_match_decisions", "chk_ai_match_confidence"}, + {"constraint", "ai_spec_match_decisions", "chk_ai_match_outcome"}, + {"constraint", "ai_spec_match_decisions", "fk_ai_match_actor"}, + {"index", "ai_spec_match_decisions", "idx_ai_match_identity"}, + } { + var exists bool + var err error + if item.kind == "index" { + exists, err = mysqlIndexExists(db, item.table, item.name) + } else { + exists, err = mysqlConstraintExists(db, item.table, item.name) + } + if err != nil || !exists { + return fmt.Errorf("AI 规格审计%s %s.%s 缺失: %v", item.kind, item.table, item.name, err) + } + } + return nil } func checkMySQLV20Shape(db *sql.DB) error { diff --git a/admin/repository/mysql_db_integration_test.go b/admin/repository/mysql_db_integration_test.go index dd244c9..f7155d8 100644 --- a/admin/repository/mysql_db_integration_test.go +++ b/admin/repository/mysql_db_integration_test.go @@ -985,6 +985,51 @@ func TestMySQLV20_AI配置首建重放与唯一启用(t *testing.T) { } } +func TestMySQLV21_AI规格来源升级重放与约束(t *testing.T) { + db := openMySQLMigrationTestDB(t) + defer db.Close() + cleanMySQLTestSchema(t, db) + defer cleanMySQLTestSchema(t, db) + if err := MigrateMySQL(db); err != nil { + t.Fatal(err) + } + // 在隔离库中还原成 v20 形状,验证存量人工映射升级后仍是 manual。 + mustExec(t, db, `ALTER TABLE spec_mappings DROP CHECK chk_spec_mappings_source`) + mustExec(t, db, `ALTER TABLE spec_mappings DROP CHECK chk_spec_mappings_confidence`) + for _, column := range []string{"context_version", "source_version", "source_reason", "confidence_bps", "source_model", "source_provider_id", "source"} { + mustExec(t, db, `ALTER TABLE spec_mappings DROP COLUMN `+column) + } + mustExec(t, db, `DROP TABLE ai_spec_match_decisions`) + mustExec(t, db, `DELETE FROM schema_migrations WHERE version=21`) + mustExec(t, db, `INSERT INTO spec_mappings(shopee_goods_id,spec_key,pdd_goods_id,pdd_option_key, + pdd_options,spec_raw,mapped_at,mapped_by) VALUES('S','黑色,M','P','{}','{}','黑色,M','2026-08-14T00:00:00Z','U')`) + if err := MigrateMySQL(db); err != nil { + t.Fatalf("v20→v21 失败: %v", err) + } + if err := MigrateMySQL(db); err != nil { + t.Fatalf("v21 重放失败: %v", err) + } + var source string + if err := db.QueryRow(`SELECT source FROM spec_mappings WHERE shopee_goods_id='S'`).Scan(&source); err != nil || source != "manual" { + t.Fatalf("存量映射来源=%q err=%v", source, err) + } + if _, err := db.Exec(`UPDATE spec_mappings SET source='unknown' WHERE shopee_goods_id='S'`); err == nil { + t.Fatal("映射来源 CHECK 必须拒绝未知值") + } + mustExec(t, db, `INSERT INTO users(user_id,username,password_hash,role,status,password_changed_at,created_at,updated_at) + VALUES('U-AI','u-ai','hash','purchaser','active','2026-08-14T00:00:00Z','2026-08-14T00:00:00Z','2026-08-14T00:00:00Z')`) + mustExec(t, db, `INSERT INTO ai_spec_match_decisions(shopee_goods_id,spec_key,pdd_goods_id, + context_version,rules_version,prompt_version,candidates_json,confidence_bps,outcome, + conflict_dimensions_json,missing_dimensions_json,decided_by,decided_at) + VALUES('S','黑色,M','P',REPEAT('a',64),'rules_v1','spec_prompt_v1','[]',9000,'ai_saved','[]','[]','U-AI','2026-08-14T00:00:00Z')`) + if _, err := db.Exec(`UPDATE ai_spec_match_decisions SET outcome='unknown'`); err == nil { + t.Fatal("AI 决策 outcome CHECK 必须拒绝未知值") + } + if err := CheckMySQLSchema(db); err != nil { + t.Fatalf("v21 自检失败: %v", err) + } +} + func prepareMySQLV2(t *testing.T, db *sql.DB) { t.Helper() mustExec(t, db, `CREATE TABLE schema_migrations (version INT PRIMARY KEY, applied_at VARCHAR(35) NOT NULL) ENGINE=InnoDB`) diff --git a/admin/repository/syb.go b/admin/repository/syb.go index a9afb5b..5c4b6a3 100644 --- a/admin/repository/syb.go +++ b/admin/repository/syb.go @@ -586,6 +586,13 @@ type SybOrderContext struct { PddUpdatedAt string MappingOptionKey string MappingOptions string + MappingSource string + MappingProviderID string + MappingModel string + MappingConfidenceBPS int + MappingReason string + MappingSourceVersion string + MappingContextVersion string HasSucceededPurchaseTask bool HasManualReviewPurchaseTask bool HasActiveTask bool @@ -598,14 +605,18 @@ func scanSybOrderContext(s rowScanner) (SybOrderContext, error) { var priceCent sql.NullInt64 var shopeeExists int var pddGoodsID, pddGoodsURL, collectStatus, collectMsg, skusJSON, pddUpdatedAt sql.NullString - var mappingKey, mappingOptions sql.NullString + var mappingKey, mappingOptions, mappingSource, mappingProviderID, mappingModel sql.NullString + var mappingReason, mappingSourceVersion, mappingContextVersion sql.NullString + var mappingConfidence sql.NullInt64 var hasSucceededTask, hasManualReviewTask, hasActiveTask, hasActiveCollectTask int err := s.Scan( &c.Order.SybID, &c.Order.OrderNo, &shopName, &title, &productSpec, &specKey, &shopeeGoodsID, &c.Order.Quantity, &priceCent, &imageURL, &c.Order.SybData, &c.Order.CreatedAt, &c.Order.UpdatedAt, &shopeeExists, &pddGoodsID, &pddGoodsURL, &collectStatus, &collectMsg, &skusJSON, &pddUpdatedAt, - &mappingKey, &mappingOptions, &hasSucceededTask, &hasManualReviewTask, + &mappingKey, &mappingOptions, &mappingSource, &mappingProviderID, &mappingModel, + &mappingConfidence, &mappingReason, &mappingSourceVersion, &mappingContextVersion, + &hasSucceededTask, &hasManualReviewTask, &hasActiveTask, &hasActiveCollectTask, ) c.Order.ShopName = shopName.String @@ -624,6 +635,13 @@ func scanSybOrderContext(s rowScanner) (SybOrderContext, error) { c.PddUpdatedAt = pddUpdatedAt.String c.MappingOptionKey = mappingKey.String c.MappingOptions = mappingOptions.String + c.MappingSource = mappingSource.String + c.MappingProviderID = mappingProviderID.String + c.MappingModel = mappingModel.String + c.MappingConfidenceBPS = int(mappingConfidence.Int64) + c.MappingReason = mappingReason.String + c.MappingSourceVersion = mappingSourceVersion.String + c.MappingContextVersion = mappingContextVersion.String c.HasSucceededPurchaseTask = hasSucceededTask != 0 c.HasManualReviewPurchaseTask = hasManualReviewTask != 0 c.HasActiveTask = hasActiveTask != 0 @@ -641,6 +659,8 @@ func ListSybOrderContexts(q Execer, filter SybOrderFilter, limit, offset int) ([ CASE WHEN sp.goods_id IS NULL THEN 0 ELSE 1 END, sp.pdd_goods_id, sp.pdd_goods_url, pp.collect_status, pp.collect_msg, pp.skus_json, pp.updated_at, sm.pdd_option_key, sm.pdd_options, + sm.source,sm.source_provider_id,sm.source_model,sm.confidence_bps, + sm.source_reason,sm.source_version,sm.context_version, EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase' AND t.syb_id = so.syb_id AND t.status = 'succeeded'), EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase' @@ -682,6 +702,8 @@ func GetSybOrderContext(q Execer, sybID string) (*SybOrderContext, error) { CASE WHEN sp.goods_id IS NULL THEN 0 ELSE 1 END, sp.pdd_goods_id, sp.pdd_goods_url, pp.collect_status, pp.collect_msg, pp.skus_json, pp.updated_at, sm.pdd_option_key, sm.pdd_options, + sm.source,sm.source_provider_id,sm.source_model,sm.confidence_bps, + sm.source_reason,sm.source_version,sm.context_version, EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase' AND t.syb_id = so.syb_id AND t.status = 'succeeded'), EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase' diff --git a/admin/service/ai_specmatch.go b/admin/service/ai_specmatch.go new file mode 100644 index 0000000..f1f55aa --- /dev/null +++ b/admin/service/ai_specmatch.go @@ -0,0 +1,476 @@ +package service + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + "unicode/utf8" + + "cmautobuy/admin/model" + "cmautobuy/admin/repository" + "cmautobuy/admin/spec" +) + +const ( + AISpecMatchPromptVersion = "spec_prompt_v1" + maxAIModelCandidates = 24 +) + +type AIMatchSnapshot struct { + Provider model.AIProviderConfig + ConfigFingerprint string + Secret string + Client AIModelClient +} + +type AISpecMatchResult struct { + Outcome string + Message string + Source string + OptionKey string + ConfidenceBPS int + ContextVersion string + ModelCalled bool +} + +type aiCandidateBinding struct { + ID string + Choice PddOptionChoice +} + +// LoadActiveAIMatchSnapshot 固定新匹配动作使用的服务商配置;调用方可在整个批次复用它。 +func LoadActiveAIMatchSnapshot(ctx context.Context, db *sql.DB, secrets AISecretStore, policy AIEndpointPolicy) (AIMatchSnapshot, error) { + provider, err := repository.GetEnabledAIProvider(db) + if err != nil { + return AIMatchSnapshot{}, err + } + if provider == nil { + return AIMatchSnapshot{}, fmt.Errorf("尚未启用 AI 服务商,请管理员先完成连接测试并启用") + } + fingerprint := aiProviderFingerprint(*provider) + if provider.LastTestStatus != "succeeded" || provider.LastTestFingerprint != fingerprint { + return AIMatchSnapshot{}, fmt.Errorf("当前 AI 服务商配置未通过有效连接测试") + } + secret, err := secrets.Get(provider.ProviderID) + if err != nil { + return AIMatchSnapshot{}, err + } + if secret == "" { + return AIMatchSnapshot{}, fmt.Errorf("当前 AI 服务商未配置 API Key") + } + if err := policy.ValidateResolved(ctx, provider.BaseURL); err != nil { + return AIMatchSnapshot{}, err + } + client := NewOpenAICompatibleModelClient(NewSafeAIHTTPClient(policy, time.Duration(provider.TimeoutSeconds)*time.Second)) + return AIMatchSnapshot{Provider: *provider, ConfigFingerprint: fingerprint, Secret: secret, Client: client}, nil +} + +// MatchSybSpecWithAI 对一条 SYB 商品执行规则优先、AI 补充的候选匹配。 +func MatchSybSpecWithAI(ctx context.Context, db *sql.DB, actor *model.User, snapshot AIMatchSnapshot, sybID, expectedVersion string) (AISpecMatchResult, error) { + if actor == nil || !actor.IsActive() { + return AISpecMatchResult{}, ErrUnauthenticated + } + orderContext, err := repository.GetSybOrderContext(db, strings.TrimSpace(sybID)) + if err != nil { + return AISpecMatchResult{}, err + } + if orderContext == nil { + return AISpecMatchResult{}, fmt.Errorf("顺运宝明细不存在") + } + version := mappingContextVersion(*orderContext) + if expectedVersion != "" && expectedVersion != version { + return AISpecMatchResult{Outcome: "stale", Message: "数据已变化,请刷新后重试", ContextVersion: version}, nil + } + key, err := spec.SpecKey(orderContext.Order.ProductSpec) + if err != nil || orderContext.Order.SpecKey == "" || key != orderContext.Order.SpecKey { + return AISpecMatchResult{Outcome: "rejected", Message: "顺运宝未提供有效规格", ContextVersion: version}, nil + } + if orderContext.PddGoodsID == "" || orderContext.PddCollectStatus != string(model.CollectCollected) || strings.TrimSpace(orderContext.PddSkusJSON) == "" { + return AISpecMatchResult{Outcome: "rejected", Message: "当前 PDD 商品尚未完成采集", ContextVersion: version}, nil + } + if mappingIsValid(*orderContext) { + source := orderContext.MappingSource + if source == "" { + source = "manual" + } + return AISpecMatchResult{Outcome: "reused", Message: "已复用当前有效规格映射", Source: source, + OptionKey: orderContext.MappingOptionKey, ConfidenceBPS: orderContext.MappingConfidenceBPS, ContextVersion: version}, nil + } + choices, keys, names, err := pddOptionChoices(orderContext.PddSkusJSON) + if err != nil { + return AISpecMatchResult{}, fmt.Errorf("读取 PDD 规格失败: %w", err) + } + baseDecision := newAISpecDecision(*orderContext, *actor, snapshot, version) + if len(choices) == 0 { + return recordAIMatchWithoutSave(db, baseDecision, "rejected", "PDD 没有当前可购买规格") + } + match := rankSpecChoices(orderContext.Order.ProductSpec, choices, keys, names) + if match.PreselectOptionKey != "" { + choice := choiceByKey(match.Choices, match.PreselectOptionKey) + baseDecision.CandidatesJSON = candidateAuditJSON(bindAICandidates(match.Choices)) + if utf8.RuneCountInString(choice.Key) > 191 { + return recordAIMatchWithoutSave(db, baseDecision, "rejected", "规则候选规格键超过可保存长度") + } + baseDecision.ChosenOptionKey, baseDecision.ConfidenceBPS, baseDecision.ConfidenceSet = choice.Key, 10000, true + baseDecision.Reason = choice.RecommendationReason + return saveAutomaticMapping(db, *actor, *orderContext, choice, "rule", baseDecision) + } + resolved, resolveReason := resolveExtraDimensionSignals(orderContext.Order.ProductSpec, match.Choices, keys, names) + if resolveReason != "" { + baseDecision.CandidatesJSON = candidateAuditJSON(bindAICandidates(match.Choices)) + return recordAIMatchWithoutSave(db, baseDecision, "rejected", resolveReason) + } + match = rankSpecChoices(orderContext.Order.ProductSpec, resolved, keys, names) + if match.PreselectOptionKey != "" { + choice := choiceByKey(match.Choices, match.PreselectOptionKey) + baseDecision.CandidatesJSON = candidateAuditJSON(bindAICandidates(match.Choices)) + if utf8.RuneCountInString(choice.Key) > 191 { + return recordAIMatchWithoutSave(db, baseDecision, "rejected", "规则候选规格键超过可保存长度") + } + baseDecision.ChosenOptionKey, baseDecision.ConfidenceBPS, baseDecision.ConfidenceSet = choice.Key, 10000, true + baseDecision.Reason = choice.RecommendationReason + return saveAutomaticMapping(db, *actor, *orderContext, choice, "rule", baseDecision) + } + eligible := make([]PddOptionChoice, 0, len(match.Choices)) + for _, choice := range match.Choices { + if choice.MatchLevel != "冲突" && utf8.RuneCountInString(choice.Key) <= 191 { + eligible = append(eligible, choice) + } + } + if len(eligible) == 0 { + baseDecision.CandidatesJSON = `[]` + return recordAIMatchWithoutSave(db, baseDecision, "rejected", "全部候选都与顺运宝规格明确冲突") + } + if len(eligible) > maxAIModelCandidates { + baseDecision.CandidatesJSON = candidateAuditJSON(bindAICandidates(eligible)) + return recordAIMatchWithoutSave(db, baseDecision, "rejected", "可购买候选过多,请先人工缩小范围") + } + bindings := bindAICandidates(eligible) + baseDecision.CandidatesJSON = candidateAuditJSON(bindings) + if snapshot.Client == nil || snapshot.Secret == "" || snapshot.Provider.ProviderID == "" { + return recordAIMatchWithoutSave(db, baseDecision, "failed", "AI 服务商运行快照不可用") + } + request := AIModelMatchRequest{ProductTitle: truncateRunes(orderContext.Order.Title, 300), SourceSpec: truncateRunes(orderContext.Order.ProductSpec, 300)} + for _, binding := range bindings { + request.Candidates = append(request.Candidates, AIModelCandidate{ID: binding.ID, + Label: truncateRunes(binding.Choice.Label, 300), Options: boundedAIOptions(binding.Choice.Options)}) + } + response, modelErr := snapshot.Client.Match(ctx, snapshot.Provider, snapshot.Secret, request) + if modelErr != nil { + result, auditErr := recordAIMatchWithoutSave(db, baseDecision, "failed", safeAIError(modelErr)) + result.ModelCalled = true + return result, auditErr + } + if err := validateAIModelMatchResponse(response); err != nil { + result, auditErr := recordAIMatchWithoutSave(db, baseDecision, "failed", err.Error()) + result.ModelCalled = true + return result, auditErr + } + baseDecision.ChosenCandidateID = response.CandidateID + baseDecision.ConfidenceBPS = response.ConfidenceBPS + baseDecision.ConfidenceSet = true + baseDecision.Reason = response.Reason + baseDecision.ConflictDimensionsJSON = stringArrayJSON(response.ConflictDimensions) + baseDecision.MissingDimensionsJSON = stringArrayJSON(response.MissingDimensions) + if response.Conclusion != "match" { + result, auditErr := recordAIMatchWithoutSave(db, baseDecision, "rejected", response.Reason) + result.ModelCalled = true + return result, auditErr + } + selected, found := bindingByID(bindings, response.CandidateID) + if !found { + result, auditErr := recordAIMatchWithoutSave(db, baseDecision, "rejected", "模型返回了不在候选白名单中的编号") + result.ModelCalled = true + return result, auditErr + } + baseDecision.ChosenOptionKey = selected.Choice.Key + if len(response.ConflictDimensions) > 0 || len(response.MissingDimensions) > 0 { + result, auditErr := recordAIMatchWithoutSave(db, baseDecision, "rejected", "模型报告仍有冲突或缺失维度") + result.ModelCalled = true + return result, auditErr + } + if response.ConfidenceBPS < snapshot.Provider.ConfidenceThresholdBPS { + result, auditErr := recordAIMatchWithoutSave(db, baseDecision, "rejected", "模型置信度低于管理员设置的自动写入阈值") + result.ModelCalled = true + return result, auditErr + } + result, err := saveAutomaticMapping(db, *actor, *orderContext, selected.Choice, "ai", baseDecision) + result.ModelCalled = true + return result, err +} + +func saveAutomaticMapping(db *sql.DB, actor model.User, original repository.SybOrderContext, selected PddOptionChoice, source string, decision model.AISpecMatchDecision) (AISpecMatchResult, error) { + tx, err := db.Begin() + if err != nil { + return AISpecMatchResult{}, err + } + defer tx.Rollback() + current, err := repository.GetSybOrderContext(tx, original.Order.SybID) + if err != nil { + return AISpecMatchResult{}, err + } + if current == nil || mappingContextVersion(*current) != decision.ContextVersion { + decision.Outcome, decision.Reason = "stale", "保存前数据或规则已变化" + if err := repository.InsertAISpecMatchDecision(tx, decision); err != nil { + return AISpecMatchResult{}, err + } + if err := tx.Commit(); err != nil { + return AISpecMatchResult{}, err + } + return AISpecMatchResult{Outcome: "stale", Message: decision.Reason, ContextVersion: decision.ContextVersion}, nil + } + latestChoice, err := findPddChoice(current.PddSkusJSON, selected.Key) + if err != nil { + return AISpecMatchResult{}, err + } + if latestChoice == nil { + decision.Outcome, decision.Reason = "stale", "所选 PDD 规格已不存在或不可购买" + if err := repository.InsertAISpecMatchDecision(tx, decision); err != nil { + return AISpecMatchResult{}, err + } + if err := tx.Commit(); err != nil { + return AISpecMatchResult{}, err + } + return AISpecMatchResult{Outcome: "stale", Message: decision.Reason, ContextVersion: decision.ContextVersion}, nil + } + if source == "ai" { + choices, keys, names, parseErr := pddOptionChoices(current.PddSkusJSON) + if parseErr != nil { + return AISpecMatchResult{}, parseErr + } + match := rankSpecChoices(current.Order.ProductSpec, choices, keys, names) + resolved, reason := resolveExtraDimensionSignals(current.Order.ProductSpec, match.Choices, keys, names) + if reason != "" || !eligibleChoiceContains(current.Order.ProductSpec, resolved, keys, names, selected.Key) { + decision.Outcome, decision.Reason = "stale", "保存前候选硬校验不再通过" + if err := repository.InsertAISpecMatchDecision(tx, decision); err != nil { + return AISpecMatchResult{}, err + } + if err := tx.Commit(); err != nil { + return AISpecMatchResult{}, err + } + return AISpecMatchResult{Outcome: "stale", Message: decision.Reason, ContextVersion: decision.ContextVersion}, nil + } + } + mappedAt := model.NowISO() + mapping := model.SpecMapping{ShopeeGoodsID: current.Order.ShopeeGoodsID, SpecKey: current.Order.SpecKey, + PddGoodsID: current.PddGoodsID, PddOptionKey: latestChoice.Key, PddOptions: latestChoice.OptionsJSON, + SpecRaw: current.Order.ProductSpec, MappedAt: mappedAt, MappedBy: actor.UserID, Source: source, + ConfidenceBPS: decision.ConfidenceBPS, ConfidenceSet: true, SourceReason: truncateRunes(decision.Reason, 500), + SourceVersion: SpecMatchRulesVersion, ContextVersion: decision.ContextVersion} + if source == "ai" { + mapping.SourceProviderID, mapping.SourceModel = decision.ProviderID, decision.Model + mapping.SourceVersion = decision.PromptVersion + "+" + decision.RulesVersion + } + if err := repository.UpsertAutomaticSpecMapping(tx, mapping); err != nil { + return AISpecMatchResult{}, err + } + actual, err := repository.GetSpecMapping(tx, mapping.ShopeeGoodsID, mapping.SpecKey, mapping.PddGoodsID) + if err != nil { + return AISpecMatchResult{}, err + } + if actual == nil { + return AISpecMatchResult{}, fmt.Errorf("自动规格映射保存后无法读取") + } + if actual.Source != source || actual.ContextVersion != mapping.ContextVersion || actual.PddOptionKey != mapping.PddOptionKey { + outcome := "reused" + actualSource := actual.Source + if actualSource == "manual" || actualSource == "" { + outcome, actualSource = "manual_exists", "manual" + } + decision.Outcome, decision.Reason, decision.ChosenOptionKey = outcome, "保存时发现已有映射,自动结果未覆盖", actual.PddOptionKey + if err := repository.InsertAISpecMatchDecision(tx, decision); err != nil { + return AISpecMatchResult{}, err + } + if err := tx.Commit(); err != nil { + return AISpecMatchResult{}, err + } + return AISpecMatchResult{Outcome: outcome, Message: decision.Reason, Source: actualSource, + OptionKey: actual.PddOptionKey, ConfidenceBPS: actual.ConfidenceBPS, ContextVersion: decision.ContextVersion}, nil + } + decision.Outcome = source + "_saved" + decision.ChosenOptionKey = latestChoice.Key + decision.DecidedAt = mappedAt + if err := repository.InsertAISpecMatchDecision(tx, decision); err != nil { + return AISpecMatchResult{}, err + } + if err := tx.Commit(); err != nil { + return AISpecMatchResult{}, err + } + return AISpecMatchResult{Outcome: decision.Outcome, Message: "规格映射已保存", Source: source, + OptionKey: latestChoice.Key, ConfidenceBPS: decision.ConfidenceBPS, ContextVersion: decision.ContextVersion}, nil +} + +func recordAIMatchWithoutSave(db *sql.DB, decision model.AISpecMatchDecision, outcome, reason string) (AISpecMatchResult, error) { + decision.Outcome, decision.Reason = outcome, truncateRunes(reason, 500) + if decision.CandidatesJSON == "" { + decision.CandidatesJSON = `[]` + } + if decision.ConflictDimensionsJSON == "" { + decision.ConflictDimensionsJSON = `[]` + } + if decision.MissingDimensionsJSON == "" { + decision.MissingDimensionsJSON = `[]` + } + if err := repository.InsertAISpecMatchDecision(db, decision); err != nil { + return AISpecMatchResult{}, err + } + return AISpecMatchResult{Outcome: outcome, Message: decision.Reason, ConfidenceBPS: decision.ConfidenceBPS, + ContextVersion: decision.ContextVersion}, nil +} + +func newAISpecDecision(c repository.SybOrderContext, actor model.User, snapshot AIMatchSnapshot, version string) model.AISpecMatchDecision { + return model.AISpecMatchDecision{ShopeeGoodsID: c.Order.ShopeeGoodsID, SpecKey: c.Order.SpecKey, + PddGoodsID: c.PddGoodsID, ContextVersion: version, RulesVersion: SpecMatchRulesVersion, + PromptVersion: AISpecMatchPromptVersion, ProviderID: snapshot.Provider.ProviderID, + ProviderName: snapshot.Provider.Name, Model: snapshot.Provider.Model, + ConfigFingerprint: snapshot.ConfigFingerprint, CandidatesJSON: `[]`, + ConflictDimensionsJSON: `[]`, MissingDimensionsJSON: `[]`, DecidedBy: actor.UserID, DecidedAt: model.NowISO()} +} + +func bindAICandidates(choices []PddOptionChoice) []aiCandidateBinding { + result := make([]aiCandidateBinding, 0, len(choices)) + for i, choice := range choices { + result = append(result, aiCandidateBinding{ID: fmt.Sprintf("C%02d", i+1), Choice: choice}) + } + return result +} + +func bindingByID(bindings []aiCandidateBinding, id string) (aiCandidateBinding, bool) { + for _, binding := range bindings { + if binding.ID == id { + return binding, true + } + } + return aiCandidateBinding{}, false +} + +func candidateAuditJSON(bindings []aiCandidateBinding) string { + type item struct { + ID string `json:"id"` + OptionKey string `json:"option_key"` + } + values := make([]item, 0, len(bindings)) + for _, binding := range bindings { + values = append(values, item{ID: binding.ID, OptionKey: binding.Choice.Key}) + } + raw, _ := json.Marshal(values) + return string(raw) +} + +func resolveExtraDimensionSignals(raw string, choices []PddOptionChoice, keys, names []string) ([]PddOptionChoice, string) { + colorKey, sizeKey, ambiguous, _ := identifyDimensions(choices, keys, names) + if ambiguous { + return nil, "颜色或尺码维度定义不明确,请人工核对" + } + filtered := append([]PddOptionChoice(nil), choices...) + normalizedRaw := normalizeDimensionSignal(raw) + for _, key := range keys { + if key == colorKey || key == sizeKey { + continue + } + values := map[string]bool{} + for _, choice := range filtered { + values[choice.Options[key]] = true + } + if len(values) <= 1 { + continue + } + var matched []string + for value := range values { + normalizedValue := normalizeDimensionSignal(value) + if normalizedValue != "" && strings.Contains(normalizedRaw, normalizedValue) { + matched = append(matched, value) + } + } + sort.Strings(matched) + if len(matched) != 1 { + return nil, "存在无法从顺运宝规格确定的额外规格维度,请人工核对" + } + selectedValue := matched[0] + next := filtered[:0] + for _, choice := range filtered { + if choice.Options[key] == selectedValue { + next = append(next, choice) + } + } + filtered = append([]PddOptionChoice(nil), next...) + } + return filtered, "" +} + +func normalizeDimensionSignal(raw string) string { + return strings.ToLower(strings.Join(strings.Fields(simplifyExplicit(raw)), "")) +} + +func boundedAIOptions(options map[string]string) map[string]string { + result := make(map[string]string, len(options)) + for key, value := range options { + result[truncateRunes(key, 64)] = truncateRunes(value, 128) + } + return result +} + +func eligibleChoiceContains(raw string, choices []PddOptionChoice, keys, names []string, optionKey string) bool { + match := rankSpecChoices(raw, choices, keys, names) + for _, choice := range match.Choices { + if choice.Key == optionKey && choice.MatchLevel != "冲突" { + return true + } + } + return false +} + +func choiceByKey(choices []PddOptionChoice, key string) PddOptionChoice { + for _, choice := range choices { + if choice.Key == key { + return choice + } + } + return PddOptionChoice{} +} + +func validateAIModelMatchResponse(response AIModelMatchResponse) error { + if response.Conclusion != "match" && response.Conclusion != "uncertain" && response.Conclusion != "conflict" { + return fmt.Errorf("AI 模型结论字段无效") + } + if response.ConfidenceBPS < 0 || response.ConfidenceBPS > 10000 { + return fmt.Errorf("AI 模型置信度超出范围") + } + if response.Conclusion == "match" && strings.TrimSpace(response.CandidateID) == "" { + return fmt.Errorf("AI 模型没有返回候选编号") + } + if strings.TrimSpace(response.Reason) == "" || utf8.RuneCountInString(response.Reason) > 500 { + return fmt.Errorf("AI 模型理由为空或过长") + } + for _, values := range [][]string{response.ConflictDimensions, response.MissingDimensions} { + if len(values) > 8 { + return fmt.Errorf("AI 模型返回的维度列表过长") + } + for _, value := range values { + if strings.TrimSpace(value) == "" || utf8.RuneCountInString(value) > 64 { + return fmt.Errorf("AI 模型返回的维度名称无效") + } + } + } + return nil +} + +func stringArrayJSON(values []string) string { + if values == nil { + values = []string{} + } + raw, _ := json.Marshal(values) + return string(raw) +} + +func truncateRunes(value string, max int) string { + runes := []rune(strings.TrimSpace(value)) + if len(runes) > max { + runes = runes[:max] + } + return string(runes) +} diff --git a/admin/service/ai_specmatch_client.go b/admin/service/ai_specmatch_client.go new file mode 100644 index 0000000..01adf03 --- /dev/null +++ b/admin/service/ai_specmatch_client.go @@ -0,0 +1,109 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "cmautobuy/admin/model" +) + +const maxAIModelResponseBytes = 64 << 10 + +type AIModelCandidate struct { + ID string `json:"id"` + Label string `json:"label"` + Options map[string]string `json:"options"` +} + +type AIModelMatchRequest struct { + ProductTitle string `json:"product_title"` + SourceSpec string `json:"source_spec"` + Candidates []AIModelCandidate `json:"candidates"` +} + +type AIModelMatchResponse struct { + Conclusion string `json:"conclusion"` + CandidateID string `json:"candidate_id"` + ConfidenceBPS int `json:"confidence_bps"` + Reason string `json:"reason"` + ConflictDimensions []string `json:"conflict_dimensions"` + MissingDimensions []string `json:"missing_dimensions"` +} + +type AIModelClient interface { + Match(context.Context, model.AIProviderConfig, string, AIModelMatchRequest) (AIModelMatchResponse, error) +} + +type OpenAICompatibleModelClient struct{ doer AIHTTPDoer } + +func NewOpenAICompatibleModelClient(doer AIHTTPDoer) *OpenAICompatibleModelClient { + return &OpenAICompatibleModelClient{doer: doer} +} + +func (c *OpenAICompatibleModelClient) Match(ctx context.Context, provider model.AIProviderConfig, secret string, input AIModelMatchRequest) (AIModelMatchResponse, error) { + if c == nil || c.doer == nil { + return AIModelMatchResponse{}, fmt.Errorf("AI 模型客户端未初始化") + } + inputJSON, err := json.Marshal(input) + if err != nil { + return AIModelMatchResponse{}, fmt.Errorf("准备 AI 规格候选失败") + } + system := `你是商品规格候选选择器。只能从 candidates 的 id 中选择,不能生成新候选。` + + `只返回 JSON:conclusion(match/uncertain/conflict)、candidate_id、confidence_bps(0-10000)、` + + `reason、conflict_dimensions、missing_dimensions。信息不足时 conclusion 必须是 uncertain。` + payload, err := json.Marshal(map[string]any{ + "model": provider.Model, + "messages": []map[string]string{{"role": "system", "content": system}, {"role": "user", "content": string(inputJSON)}}, + "temperature": 0, "max_tokens": 300, + "response_format": map[string]string{"type": "json_object"}, + }) + if err != nil { + return AIModelMatchResponse{}, fmt.Errorf("准备 AI 模型请求失败") + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, + strings.TrimRight(provider.BaseURL, "/")+"/chat/completions", bytes.NewReader(payload)) + if err != nil { + return AIModelMatchResponse{}, fmt.Errorf("准备 AI 模型请求失败") + } + request.Header.Set("Authorization", "Bearer "+secret) + request.Header.Set("Content-Type", "application/json") + response, err := c.doer.Do(request) + if err != nil { + return AIModelMatchResponse{}, fmt.Errorf("AI 模型请求失败") + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxAIModelResponseBytes)) + return AIModelMatchResponse{}, fmt.Errorf("AI 模型返回 HTTP %d", response.StatusCode) + } + raw, err := io.ReadAll(io.LimitReader(response.Body, maxAIModelResponseBytes+1)) + if err != nil || len(raw) > maxAIModelResponseBytes { + return AIModelMatchResponse{}, fmt.Errorf("AI 模型响应无法读取或过大") + } + var outer struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := json.Unmarshal(raw, &outer); err != nil || len(outer.Choices) == 0 { + return AIModelMatchResponse{}, fmt.Errorf("AI 模型响应格式不正确") + } + decoder := json.NewDecoder(strings.NewReader(outer.Choices[0].Message.Content)) + decoder.DisallowUnknownFields() + var result AIModelMatchResponse + if err := decoder.Decode(&result); err != nil { + return AIModelMatchResponse{}, fmt.Errorf("AI 模型结论不是有效 JSON") + } + var extra any + if decoder.Decode(&extra) != io.EOF { + return AIModelMatchResponse{}, fmt.Errorf("AI 模型结论包含多余内容") + } + return result, nil +} diff --git a/admin/service/ai_specmatch_client_test.go b/admin/service/ai_specmatch_client_test.go new file mode 100644 index 0000000..02dd642 --- /dev/null +++ b/admin/service/ai_specmatch_client_test.go @@ -0,0 +1,60 @@ +package service + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "cmautobuy/admin/model" +) + +type staticAIResponseDoer struct { + status int + body string + seen *http.Request +} + +func (d *staticAIResponseDoer) Do(request *http.Request) (*http.Response, error) { + d.seen = request + return &http.Response{StatusCode: d.status, Body: io.NopCloser(strings.NewReader(d.body))}, nil +} + +func TestOpenAICompatibleModelClient_解析严格JSON且不把密钥放进正文(t *testing.T) { + doer := &staticAIResponseDoer{status: 200, body: `{"choices":[{"message":{"content":"{\"conclusion\":\"match\",\"candidate_id\":\"C01\",\"confidence_bps\":9300,\"reason\":\"规格一致\",\"conflict_dimensions\":[],\"missing_dimensions\":[]}"}}]}`} + client := NewOpenAICompatibleModelClient(doer) + const fakeSecret = "fake-secret-not-production" + result, err := client.Match(context.Background(), model.AIProviderConfig{BaseURL: "https://api.example.com/v1", Model: "fake"}, fakeSecret, + AIModelMatchRequest{ProductTitle: "测试商品", SourceSpec: "黑色,M", Candidates: []AIModelCandidate{{ID: "C01", Label: "黑色/M"}}}) + if err != nil || result.CandidateID != "C01" || result.ConfidenceBPS != 9300 { + t.Fatalf("解析结果=%+v err=%v", result, err) + } + body, _ := io.ReadAll(doer.seen.Body) + if strings.Contains(string(body), fakeSecret) { + t.Fatal("API Key 只能放 Authorization,不能进入请求正文") + } + if got := doer.seen.Header.Get("Authorization"); got != "Bearer "+fakeSecret { + t.Fatalf("Authorization=%q", got) + } +} + +func TestOpenAICompatibleModelClient_拒绝无效或多余模型字段(t *testing.T) { + for _, content := range []string{ + `not-json`, + `{"conclusion":"match","candidate_id":"C01","confidence_bps":9000,"reason":"x","conflict_dimensions":[],"missing_dimensions":[],"option_key":"forged"}`, + } { + doer := &staticAIResponseDoer{status: 200, body: `{"choices":[{"message":{"content":` + quoteJSONString(content) + `}}]}`} + client := NewOpenAICompatibleModelClient(doer) + if _, err := client.Match(context.Background(), model.AIProviderConfig{BaseURL: "https://api.example.com/v1", Model: "fake"}, "fake-secret", + AIModelMatchRequest{Candidates: []AIModelCandidate{{ID: "C01"}}}); err == nil { + t.Fatalf("无效结论应被拒绝: %s", content) + } + } +} + +func quoteJSONString(value string) string { + value = strings.ReplaceAll(value, `\`, `\\`) + value = strings.ReplaceAll(value, `"`, `\"`) + return `"` + value + `"` +} diff --git a/admin/service/ai_specmatch_test.go b/admin/service/ai_specmatch_test.go new file mode 100644 index 0000000..c49570b --- /dev/null +++ b/admin/service/ai_specmatch_test.go @@ -0,0 +1,200 @@ +package service + +import ( + "context" + "database/sql" + "errors" + "testing" + + "cmautobuy/admin/model" + "cmautobuy/admin/repository" +) + +type fakeAIModelClient struct { + response AIModelMatchResponse + err error + calls int + last AIModelMatchRequest + beforeReturn func() +} + +func (f *fakeAIModelClient) Match(_ context.Context, _ model.AIProviderConfig, _ string, request AIModelMatchRequest) (AIModelMatchResponse, error) { + f.calls++ + f.last = request + if f.beforeReturn != nil { + f.beforeReturn() + } + return f.response, f.err +} + +func TestValidateAIModelMatchResponse_严格结构(t *testing.T) { + valid := AIModelMatchResponse{Conclusion: "match", CandidateID: "C01", ConfidenceBPS: 9000, Reason: "颜色和尺码一致"} + if err := validateAIModelMatchResponse(valid); err != nil { + t.Fatal(err) + } + for _, invalid := range []AIModelMatchResponse{ + {Conclusion: "yes", Reason: "x"}, + {Conclusion: "match", ConfidenceBPS: 9000, Reason: "x"}, + {Conclusion: "uncertain", ConfidenceBPS: 10001, Reason: "x"}, + {Conclusion: "uncertain", ConfidenceBPS: 1}, + } { + if err := validateAIModelMatchResponse(invalid); err == nil { + t.Fatalf("无效模型结论应被拒绝: %+v", invalid) + } + } +} + +func TestBindingByID_伪造候选不能映射到真实选项(t *testing.T) { + bindings := bindAICandidates([]PddOptionChoice{{Key: `{"color":"黑色"}`}}) + if _, ok := bindingByID(bindings, "C99"); ok { + t.Fatal("伪造候选编号不能命中服务端白名单") + } + if got, ok := bindingByID(bindings, "C01"); !ok || got.Choice.Key == "" { + t.Fatal("服务端生成的候选编号应能还原真实选项") + } +} + +func TestResolveExtraDimensionSignals_额外维度必须有确定信号(t *testing.T) { + first := map[string]string{"color": "黑色", "size": "M", "style": "常规"} + second := map[string]string{"color": "黑色", "size": "M", "style": "加绒"} + firstKey, _ := OptionKey(first) + secondKey, _ := OptionKey(second) + choices := []PddOptionChoice{{Key: firstKey, Label: "黑色 M 常规", Options: first}, {Key: secondKey, Label: "黑色 M 加绒", Options: second}} + keys, names := []string{"color", "size", "style"}, []string{"颜色", "尺码", "款式"} + if _, reason := resolveExtraDimensionSignals("黑色,M", choices, keys, names); reason == "" { + t.Fatal("没有款式信号时不能交给 AI 猜额外维度") + } + got, reason := resolveExtraDimensionSignals("黑色,M,加绒", choices, keys, names) + if reason != "" || len(got) != 1 || got[0].Options["style"] != "加绒" { + t.Fatalf("明确额外维度应缩小候选: got=%+v reason=%q", got, reason) + } +} + +func TestMatchSybSpecWithAI_候选白名单低置信度和人工优先(t *testing.T) { + t.Run("合格AI结果直接保存", func(t *testing.T) { + db := newTestDB(t) + actor := seedAIMatchContext(t, db, "SYB-AI-SAVE") + fake := &fakeAIModelClient{response: AIModelMatchResponse{Conclusion: "match", CandidateID: "C01", ConfidenceBPS: 9200, Reason: "主色和尺码一致"}} + result, err := MatchSybSpecWithAI(context.Background(), db, &actor, testAIMatchSnapshot(fake), "SYB-AI-SAVE", "") + if err != nil || result.Outcome != "ai_saved" || result.Source != "ai" || fake.calls != 1 { + t.Fatalf("AI 保存结果=%+v calls=%d err=%v", result, fake.calls, err) + } + var source string + if err := db.QueryRow(`SELECT source FROM spec_mappings WHERE shopee_goods_id='SP-AI'`).Scan(&source); err != nil || source != "ai" { + t.Fatalf("当前映射来源=%q err=%v", source, err) + } + if err := SaveSybMapping(db, "SYB-AI-SAVE", result.OptionKey, actor.UserID); err != nil { + t.Fatalf("人工覆盖 AI 映射失败: %v", err) + } + var auditCount int + if err := db.QueryRow(`SELECT source FROM spec_mappings WHERE shopee_goods_id='SP-AI'`).Scan(&source); err != nil || source != "manual" { + t.Fatalf("人工覆盖后来源=%q err=%v", source, err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM ai_spec_match_decisions WHERE shopee_goods_id='SP-AI' AND outcome='ai_saved'`).Scan(&auditCount); err != nil || auditCount != 1 { + t.Fatalf("人工覆盖不得删除 AI 审计: count=%d err=%v", auditCount, err) + } + }) + + t.Run("伪造候选不写映射", func(t *testing.T) { + db := newTestDB(t) + actor := seedAIMatchContext(t, db, "SYB-AI-FORGE") + fake := &fakeAIModelClient{response: AIModelMatchResponse{Conclusion: "match", CandidateID: "C99", ConfidenceBPS: 9900, Reason: "尝试越界"}} + result, err := MatchSybSpecWithAI(context.Background(), db, &actor, testAIMatchSnapshot(fake), "SYB-AI-FORGE", "") + if err != nil || result.Outcome != "rejected" { + t.Fatalf("伪造候选结果=%+v err=%v", result, err) + } + assertNoAIMapping(t, db) + }) + + t.Run("低置信度不写映射", func(t *testing.T) { + db := newTestDB(t) + actor := seedAIMatchContext(t, db, "SYB-AI-LOW") + fake := &fakeAIModelClient{response: AIModelMatchResponse{Conclusion: "match", CandidateID: "C01", ConfidenceBPS: 7999, Reason: "信号偏弱"}} + result, err := MatchSybSpecWithAI(context.Background(), db, &actor, testAIMatchSnapshot(fake), "SYB-AI-LOW", "") + if err != nil || result.Outcome != "rejected" { + t.Fatalf("低置信度结果=%+v err=%v", result, err) + } + assertNoAIMapping(t, db) + }) + + t.Run("已有人工映射不调用模型", func(t *testing.T) { + db := newTestDB(t) + actor := seedAIMatchContext(t, db, "SYB-AI-MANUAL") + key, _ := OptionKey(map[string]string{"color": "黑色", "size": "M"}) + if err := SaveSybMapping(db, "SYB-AI-MANUAL", key, actor.UserID); err != nil { + t.Fatal(err) + } + fake := &fakeAIModelClient{err: errors.New("不应调用")} + result, err := MatchSybSpecWithAI(context.Background(), db, &actor, testAIMatchSnapshot(fake), "SYB-AI-MANUAL", "") + if err != nil || result.Outcome != "reused" || result.Source != "manual" || fake.calls != 0 { + t.Fatalf("人工复用结果=%+v calls=%d err=%v", result, fake.calls, err) + } + }) + + t.Run("唯一确定规则结果不调用模型", func(t *testing.T) { + db := newTestDB(t) + actor := seedAIMatchContextWithData(t, db, "SYB-AI-RULE", "灰色-小個子,L建議53-57公斤", collectedRuleChoices) + fake := &fakeAIModelClient{err: errors.New("不应调用")} + result, err := MatchSybSpecWithAI(context.Background(), db, &actor, testAIMatchSnapshot(fake), "SYB-AI-RULE", "") + if err != nil || result.Outcome != "rule_saved" || result.Source != "rule" || fake.calls != 0 { + t.Fatalf("规则保存结果=%+v calls=%d err=%v", result, fake.calls, err) + } + }) + + t.Run("模型调用期间人工保存仍然优先", func(t *testing.T) { + db := newTestDB(t) + actor := seedAIMatchContext(t, db, "SYB-AI-RACE") + key, _ := OptionKey(map[string]string{"color": "黑色", "size": "M"}) + fake := &fakeAIModelClient{response: AIModelMatchResponse{Conclusion: "match", CandidateID: "C01", ConfidenceBPS: 9500, Reason: "规格一致"}} + fake.beforeReturn = func() { + if err := SaveSybMapping(db, "SYB-AI-RACE", key, actor.UserID); err != nil { + t.Fatalf("并发人工保存失败: %v", err) + } + } + result, err := MatchSybSpecWithAI(context.Background(), db, &actor, testAIMatchSnapshot(fake), "SYB-AI-RACE", "") + if err != nil || result.Outcome != "manual_exists" || result.Source != "manual" { + t.Fatalf("人工并发优先结果=%+v err=%v", result, err) + } + var source string + if err := db.QueryRow(`SELECT source FROM spec_mappings WHERE shopee_goods_id='SP-AI'`).Scan(&source); err != nil || source != "manual" { + t.Fatalf("并发后来源=%q err=%v", source, err) + } + }) +} + +const collectedAIChoices = `{"goods_id":"737116531267","price_granularity":"sku","dimensions":[{"key":"color","name":"颜色"},{"key":"size","name":"尺码"}],"skus":[{"options":{"color":"黑色","size":"M"},"price_cent":1180,"available":true},{"options":{"color":"白色","size":"L"},"price_cent":1280,"available":true}]}` +const collectedRuleChoices = `{"goods_id":"737116531267","price_granularity":"sku","dimensions":[{"key":"color","name":"颜色"},{"key":"size","name":"尺码"}],"skus":[{"options":{"color":"灰色中长款","size":"L(106-114斤)"},"price_cent":1180,"available":true},{"options":{"color":"黑色","size":"L(106-114斤)"},"price_cent":1280,"available":true}]}` + +func seedAIMatchContext(t *testing.T, db *sql.DB, sybID string) model.User { + return seedAIMatchContextWithData(t, db, sybID, "黑色,M", collectedAIChoices) +} + +func seedAIMatchContextWithData(t *testing.T, db *sql.DB, sybID, rawSpec, collected string) model.User { + t.Helper() + actor := model.User{UserID: "USR-AI", Username: "ai-buyer", PasswordHash: "test-hash", + Role: model.RolePurchaser, Status: model.UserActive, PasswordChangedAt: model.NowISO(), CreatedAt: model.NowISO(), UpdatedAt: model.NowISO()} + if err := repository.CreateUser(db, actor); err != nil { + t.Fatal(err) + } + seedWorkflowOrder(t, db, sybID, "SP-AI", rawSpec) + if _, err := AssociateShopeePdd(db, "SP-AI", pddURLA, false); err != nil { + t.Fatal(err) + } + if err := repository.SetCollectResult(db, "737116531267", "PDD 测试商品", "测试店铺", collected); err != nil { + t.Fatal(err) + } + return actor +} + +func testAIMatchSnapshot(client AIModelClient) AIMatchSnapshot { + provider := model.AIProviderConfig{ProviderID: "AIP-TEST", Name: "假模型", Model: "fake-model", ConfidenceThresholdBPS: 8000} + return AIMatchSnapshot{Provider: provider, ConfigFingerprint: "test-fingerprint", Secret: "fake-secret-only-test", Client: client} +} + +func assertNoAIMapping(t *testing.T, db *sql.DB) { + t.Helper() + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM spec_mappings WHERE shopee_goods_id='SP-AI'`).Scan(&count); err != nil || count != 0 { + t.Fatalf("不应写映射: count=%d err=%v", count, err) + } +} diff --git a/admin/service/purchase_workflow.go b/admin/service/purchase_workflow.go index 390f74d..988cc6b 100644 --- a/admin/service/purchase_workflow.go +++ b/admin/service/purchase_workflow.go @@ -128,11 +128,13 @@ func SaveSybMapping(db *sql.DB, sybID, optionKey, operator string, expectedVersi if utf8.RuneCountInString(choice.Key) > 191 || utf8.RuneCountInString(match.SuggestedOptionKey) > 191 || utf8.RuneCountInString(SpecMatchRulesVersion) > 32 { return fmt.Errorf("规格选项键或规则版本超过数据库列宽,未保存") } + mappedAt := model.NowISO() if err := repository.UpsertSpecMapping(tx, model.SpecMapping{ ShopeeGoodsID: context.Order.ShopeeGoodsID, SpecKey: key, SpecRaw: context.Order.ProductSpec, PddGoodsID: context.PddGoodsID, PddOptionKey: choice.Key, PddOptions: choice.OptionsJSON, - MappedAt: model.NowISO(), MappedBy: strings.TrimSpace(operator), + MappedAt: mappedAt, MappedBy: strings.TrimSpace(operator), Source: "manual", + ContextVersion: expectedContextVersion, }); err != nil { return err } @@ -140,7 +142,7 @@ func SaveSybMapping(db *sql.DB, sybID, optionKey, operator string, expectedVersi ShopeeGoodsID: context.Order.ShopeeGoodsID, SpecKey: key, PddGoodsID: context.PddGoodsID, RulesVersion: SpecMatchRulesVersion, SuggestedOptionKey: match.SuggestedOptionKey, ChosenOptionKey: choice.Key, Accepted: match.SuggestedOptionKey != "" && match.SuggestedOptionKey == choice.Key, - DecidedBy: strings.TrimSpace(operator), DecidedAt: model.NowISO(), + DecidedBy: strings.TrimSpace(operator), DecidedAt: mappedAt, }); err != nil { return err } diff --git a/docs/admin/01-requirements.md b/docs/admin/01-requirements.md index 9efcf3e..220f25c 100644 --- a/docs/admin/01-requirements.md +++ b/docs/admin/01-requirements.md @@ -531,7 +531,7 @@ MVP 之后: - 不做面向外部的公网服务,只在内网/本机运行。 - 不直接对接蝦皮和拼多多的接口。 -- 不自动决定买哪个商品——PDD 链接和规格匹配都由人确认。 +- 不由模型选择或更换 PDD 商品链接;规格自动匹配只能从后端当前可购买候选中选择,硬校验不通过时仍由人确认。 - 不自动付款。 - 不开放用户自助注册;第一个管理员只能通过首次初始化创建。 - 不做复杂 RBAC、细粒度页面权限、单点登录和第三方登录。 diff --git a/docs/admin/02-architecture.md b/docs/admin/02-architecture.md index f7f4300..05db224 100644 --- a/docs/admin/02-architecture.md +++ b/docs/admin/02-architecture.md @@ -263,6 +263,11 @@ AI 服务商的普通配置和非敏感审计由 `repository` 写入 MySQL。API 本地、云元数据和未显式允许的私网地址;重定向和实际拨号也执行相同检查。部署级 `allowed_hosts` 是私有模型端点的唯一例外入口,网页不能修改。 +规格匹配按“确定性规则 → 有限候选 → 模型选择 → 服务端硬校验 → 事务写入”的顺序执行。 +模型只看到商品标题、SYB 规格文本和后端生成的候选短编号,不能生成真实 PDD 选项键。 +唯一确定的规则结果不调用模型;有效人工映射始终优先。保存前重新计算上下文版本并检查 +当前可购买候选,防止 PDD 重采集或人工并发修改后写入过期结果。 + ## 10. 相关文档 - [上手指南](00-getting-started.md) diff --git a/docs/admin/03-data-model.md b/docs/admin/03-data-model.md index 30dab49..136d4df 100644 --- a/docs/admin/03-data-model.md +++ b/docs/admin/03-data-model.md @@ -583,12 +583,24 @@ CREATE TABLE spec_mappings ( spec_raw TEXT NOT NULL, mapped_at VARCHAR(35) NOT NULL, mapped_by VARCHAR(191), + source VARCHAR(16) NOT NULL DEFAULT 'manual', -- manual / rule / ai + source_provider_id VARCHAR(191), + source_model VARCHAR(191), + confidence_bps INT, + source_reason VARCHAR(500), + source_version VARCHAR(64), + context_version CHAR(64), PRIMARY KEY (shopee_goods_id, spec_key, pdd_goods_id), KEY idx_spec_mappings_goods (shopee_goods_id), KEY idx_spec_mappings_pdd (pdd_goods_id) ); ``` +v21 增加的来源字段描述当前生效映射。人工在详情页保存时,`source` 固定改为 `manual` +并清空 AI 元数据;规则唯一确定的结果使用 `rule`;通过模型和服务端硬门禁的结果使用 +`ai`。这些字段不改变映射唯一键。PDD 重新采集后如果 `pdd_option_key` 已不存在,三种 +来源都按同一规则判为失效,不能进入采购任务。 + `[必须]` 不加外键。顺运宝明细可能比蝦皮报表更早到达,同步要先创建 skeleton 商品; PDD 商品还可软删除,映射作为审计和可恢复数据必须保留。 @@ -679,6 +691,15 @@ SELECT rules_version, COUNT(*) AS 无建议决策数 FROM spec_mapping_decisions WHERE suggested_option_key IS NULL GROUP BY rules_version; ``` +### 6.4 `ai_spec_match_decisions` AI 决策审计(MySQL v21) + +本表只追加,记录一次 AI 匹配动作使用的上下文版本、规则/提示词版本、服务商和模型快照、 +候选短编号到真实选项键的映射、置信度、结论和简短原因。它不保存 API Key、完整提示词、 +完整请求/响应、顺运宝订单号、店铺账号、用户资料、地址或 Client 信息。 + +合格映射和成功决策在同一事务提交。低置信度、伪造候选、模型拒绝、超时和格式错误也 +追加脱敏结论,但不写 `spec_mappings`。人工后来覆盖当前映射时不会删除本表历史。 + ## 7. `tasks` 任务 采集任务和采购任务共用一张表,用 `task_type` 区分。 diff --git a/docs/admin/06-quality-security.md b/docs/admin/06-quality-security.md index 060f208..00bd617 100644 --- a/docs/admin/06-quality-security.md +++ b/docs/admin/06-quality-security.md @@ -268,3 +268,5 @@ CMAutoBuyAdmin/ - 连接测试只发送模型名和最小无业务文本,不发送订单号、店铺账号、用户、地址或 Client 信息。 - Base URL、DNS 解析、实际拨号和重定向都要执行 SSRF 校验;私有地址只能由部署级允许列表开放。 - 测试失败、超时和非 2xx 响应不得记录 Authorization、完整请求或完整响应。 +- 规格匹配请求只包含商品标题、规格文本和候选短编号,不包含订单号、店铺账号、用户、地址或 Client 信息。 +- 模型返回值必须经过严格 JSON 结构、候选白名单、颜色冲突、额外维度、置信度和上下文版本校验;模型自报置信度不能替代硬门禁。