feat: 实现 AI 规格候选匹配与审计 (#201)

This commit is contained in:
chengma
2026-08-14 10:08:32 +08:00
parent 2f51126e1d
commit aac289a4d3
15 changed files with 1200 additions and 25 deletions
+28 -8
View File
@@ -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 区分采集任务和采购任务。
+16
View File
@@ -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
View File
@@ -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,
+118 -2
View File
@@ -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
View File
@@ -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'
+476
View File
@@ -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)
}
+109
View File
@@ -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
}
+60
View File
@@ -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 + `"`
}
+200
View File
@@ -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)
}
}
+4 -2
View File
@@ -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
}
+1 -1
View File
@@ -531,7 +531,7 @@ MVP 之后:
- 不做面向外部的公网服务,只在内网/本机运行。
- 不直接对接蝦皮和拼多多的接口。
- 不自动决定买哪个商品——PDD 链接和规格匹配都由人确认。
- 不由模型选择或更换 PDD 商品链接;规格自动匹配只能从后端当前可购买候选中选择,硬校验不通过时仍由人确认。
- 不自动付款。
- 不开放用户自助注册;第一个管理员只能通过首次初始化创建。
- 不做复杂 RBAC、细粒度页面权限、单点登录和第三方登录。
+5
View File
@@ -263,6 +263,11 @@ AI 服务商的普通配置和非敏感审计由 `repository` 写入 MySQL。API
本地、云元数据和未显式允许的私网地址;重定向和实际拨号也执行相同检查。部署级
`allowed_hosts` 是私有模型端点的唯一例外入口,网页不能修改。
规格匹配按“确定性规则 → 有限候选 → 模型选择 → 服务端硬校验 → 事务写入”的顺序执行。
模型只看到商品标题、SYB 规格文本和后端生成的候选短编号,不能生成真实 PDD 选项键。
唯一确定的规则结果不调用模型;有效人工映射始终优先。保存前重新计算上下文版本并检查
当前可购买候选,防止 PDD 重采集或人工并发修改后写入过期结果。
## 10. 相关文档
- [上手指南](00-getting-started.md)
+21
View File
@@ -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` 区分。
+2
View File
@@ -268,3 +268,5 @@ CMAutoBuyAdmin/
- 连接测试只发送模型名和最小无业务文本,不发送订单号、店铺账号、用户、地址或 Client 信息。
- Base URL、DNS 解析、实际拨号和重定向都要执行 SSRF 校验;私有地址只能由部署级允许列表开放。
- 测试失败、超时和非 2xx 响应不得记录 Authorization、完整请求或完整响应。
- 规格匹配请求只包含商品标题、规格文本和候选短编号,不包含订单号、店铺账号、用户、地址或 Client 信息。
- 模型返回值必须经过严格 JSON 结构、候选白名单、颜色冲突、额外维度、置信度和上下文版本校验;模型自报置信度不能替代硬门禁。