feat: 实现 AI 规格候选匹配与审计 (#201)
This commit is contained in:
@@ -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) {
|
||||
|
||||
+91
-10
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`)
|
||||
|
||||
+24
-2
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user