feat: 实现 AI 规格候选匹配与审计 (#201)
This commit is contained in:
+28
-8
@@ -370,14 +370,22 @@ type ShopChannelAlias struct {
|
|||||||
//
|
//
|
||||||
// 附带好处:A 的映射还留着。A 补货换回去时,之前的匹配成果直接复用。
|
// 附带好处:A 的映射还留着。A 补货换回去时,之前的匹配成果直接复用。
|
||||||
type SpecMapping struct {
|
type SpecMapping struct {
|
||||||
ShopeeGoodsID string
|
ShopeeGoodsID string
|
||||||
SpecKey string // 由 spec.SpecKey 生成,存和查必须使用同一实现
|
SpecKey string // 由 spec.SpecKey 生成,存和查必须使用同一实现
|
||||||
SpecRaw string // 保存当时的顺运宝原文,供人工核对
|
SpecRaw string // 保存当时的顺运宝原文,供人工核对
|
||||||
PddGoodsID string // 这条映射属于哪个 PDD 商品
|
PddGoodsID string // 这条映射属于哪个 PDD 商品
|
||||||
PddOptionKey string // 规范化后的组合键,见 service.OptionKey
|
PddOptionKey string // 规范化后的组合键,见 service.OptionKey
|
||||||
PddOptions string // 原始 options 对象 JSON,显示用
|
PddOptions string // 原始 options 对象 JSON,显示用
|
||||||
MappedAt string
|
MappedAt string
|
||||||
MappedBy string
|
MappedBy string
|
||||||
|
Source string
|
||||||
|
SourceProviderID string
|
||||||
|
SourceModel string
|
||||||
|
ConfidenceBPS int
|
||||||
|
ConfidenceSet bool
|
||||||
|
SourceReason string
|
||||||
|
SourceVersion string
|
||||||
|
ContextVersion string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SpecMappingDecision struct {
|
type SpecMappingDecision struct {
|
||||||
@@ -387,6 +395,18 @@ type SpecMappingDecision struct {
|
|||||||
DecidedBy, DecidedAt string
|
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 区分采集任务和采购任务。
|
// TaskType 区分采集任务和采购任务。
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ func GetAIProvider(q Execer, providerID string) (*model.AIProviderConfig, error)
|
|||||||
return &item, nil
|
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
|
type scanValues func(dest ...any) error
|
||||||
|
|
||||||
func scanAIProvider(scan scanValues) (model.AIProviderConfig, error) {
|
func scanAIProvider(scan scanValues) (model.AIProviderConfig, error) {
|
||||||
|
|||||||
+91
-10
@@ -11,15 +11,18 @@ import (
|
|||||||
// GetSpecMapping 读取某个蝦皮商品的顺运宝规格在指定 PDD 商品下的映射。
|
// GetSpecMapping 读取某个蝦皮商品的顺运宝规格在指定 PDD 商品下的映射。
|
||||||
func GetSpecMapping(q Execer, shopeeGoodsID, specKey, pddGoodsID string) (*model.SpecMapping, error) {
|
func GetSpecMapping(q Execer, shopeeGoodsID, specKey, pddGoodsID string) (*model.SpecMapping, error) {
|
||||||
var m model.SpecMapping
|
var m model.SpecMapping
|
||||||
var mappedBy sql.NullString
|
var mappedBy, providerID, sourceModel, reason, sourceVersion, contextVersion sql.NullString
|
||||||
err := q.QueryRow(`
|
var confidence sql.NullInt64
|
||||||
|
query := `
|
||||||
SELECT shopee_goods_id, spec_key, pdd_goods_id, pdd_option_key,
|
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
|
FROM spec_mappings
|
||||||
WHERE shopee_goods_id = ? AND spec_key = ? AND pdd_goods_id = ?`,
|
WHERE shopee_goods_id = ? AND spec_key = ? AND pdd_goods_id = ?`
|
||||||
shopeeGoodsID, specKey, pddGoodsID).Scan(
|
err := q.QueryRow(query, shopeeGoodsID, specKey, pddGoodsID).Scan(
|
||||||
&m.ShopeeGoodsID, &m.SpecKey, &m.PddGoodsID, &m.PddOptionKey,
|
&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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -27,30 +30,108 @@ func GetSpecMapping(q Execer, shopeeGoodsID, specKey, pddGoodsID string) (*model
|
|||||||
return nil, fmt.Errorf("查询规格映射失败: %w", err)
|
return nil, fmt.Errorf("查询规格映射失败: %w", err)
|
||||||
}
|
}
|
||||||
m.MappedBy = mappedBy.String
|
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
|
return &m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpsertSpecMapping 保存可复用规格映射,唯一键包含当前 PDD 商品。
|
// UpsertSpecMapping 保存可复用规格映射,唯一键包含当前 PDD 商品。
|
||||||
func UpsertSpecMapping(q Execer, m model.SpecMapping) error {
|
func UpsertSpecMapping(q Execer, m model.SpecMapping) error {
|
||||||
|
if m.Source == "" {
|
||||||
|
m.Source = "manual"
|
||||||
|
}
|
||||||
_, err := q.Exec(`
|
_, err := q.Exec(`
|
||||||
INSERT INTO spec_mappings
|
INSERT INTO spec_mappings
|
||||||
(shopee_goods_id, spec_key, pdd_goods_id, pdd_option_key,
|
(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,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
source_model,confidence_bps,source_reason,source_version,context_version)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
pdd_option_key = VALUES(pdd_option_key),
|
pdd_option_key = VALUES(pdd_option_key),
|
||||||
pdd_options = VALUES(pdd_options),
|
pdd_options = VALUES(pdd_options),
|
||||||
spec_raw = VALUES(spec_raw),
|
spec_raw = VALUES(spec_raw),
|
||||||
mapped_at = VALUES(mapped_at),
|
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.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 {
|
if err != nil {
|
||||||
return fmt.Errorf("保存规格映射失败: %w", err)
|
return fmt.Errorf("保存规格映射失败: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
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 {
|
func InsertSpecMappingDecision(q Execer, d model.SpecMappingDecision) error {
|
||||||
_, err := q.Exec(`INSERT INTO spec_mapping_decisions
|
_, err := q.Exec(`INSERT INTO spec_mapping_decisions
|
||||||
(shopee_goods_id,spec_key,pdd_goods_id,rules_version,suggested_option_key,
|
(shopee_goods_id,spec_key,pdd_goods_id,rules_version,suggested_option_key,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
"cmautobuy/admin/spec"
|
"cmautobuy/admin/spec"
|
||||||
)
|
)
|
||||||
|
|
||||||
const mysqlSchemaVersion = 20
|
const mysqlSchemaVersion = 21
|
||||||
|
|
||||||
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
||||||
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
|
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 {
|
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)
|
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)
|
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 服务商的非敏感配置和配置审计。
|
// migrateMySQLV20 保存 AI 服务商的非敏感配置和配置审计。
|
||||||
// API Key 只存在独立密钥文件中,不在这两张表里留列。
|
// API Key 只存在独立密钥文件中,不在这两张表里留列。
|
||||||
func migrateMySQLV20(db *sql.DB) error {
|
func migrateMySQLV20(db *sql.DB) error {
|
||||||
@@ -1864,6 +1943,7 @@ func CheckMySQLSchema(db *sql.DB) error {
|
|||||||
"task_sequences",
|
"task_sequences",
|
||||||
"syb_allowed_shops",
|
"syb_allowed_shops",
|
||||||
"ai_provider_configs", "ai_provider_audits",
|
"ai_provider_configs", "ai_provider_audits",
|
||||||
|
"ai_spec_match_decisions",
|
||||||
}
|
}
|
||||||
if err := checkMySQLSchema(db, mysqlRequiredTables); err != nil {
|
if err := checkMySQLSchema(db, mysqlRequiredTables); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1919,7 +1999,43 @@ func CheckMySQLSchema(db *sql.DB) error {
|
|||||||
if err := checkMySQLV19Shape(db); err != nil {
|
if err := checkMySQLV19Shape(db); err != nil {
|
||||||
return err
|
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 {
|
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) {
|
func prepareMySQLV2(t *testing.T, db *sql.DB) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
mustExec(t, db, `CREATE TABLE schema_migrations (version INT PRIMARY KEY, applied_at VARCHAR(35) NOT NULL) ENGINE=InnoDB`)
|
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
|
PddUpdatedAt string
|
||||||
MappingOptionKey string
|
MappingOptionKey string
|
||||||
MappingOptions string
|
MappingOptions string
|
||||||
|
MappingSource string
|
||||||
|
MappingProviderID string
|
||||||
|
MappingModel string
|
||||||
|
MappingConfidenceBPS int
|
||||||
|
MappingReason string
|
||||||
|
MappingSourceVersion string
|
||||||
|
MappingContextVersion string
|
||||||
HasSucceededPurchaseTask bool
|
HasSucceededPurchaseTask bool
|
||||||
HasManualReviewPurchaseTask bool
|
HasManualReviewPurchaseTask bool
|
||||||
HasActiveTask bool
|
HasActiveTask bool
|
||||||
@@ -598,14 +605,18 @@ func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
|
|||||||
var priceCent sql.NullInt64
|
var priceCent sql.NullInt64
|
||||||
var shopeeExists int
|
var shopeeExists int
|
||||||
var pddGoodsID, pddGoodsURL, collectStatus, collectMsg, skusJSON, pddUpdatedAt sql.NullString
|
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
|
var hasSucceededTask, hasManualReviewTask, hasActiveTask, hasActiveCollectTask int
|
||||||
err := s.Scan(
|
err := s.Scan(
|
||||||
&c.Order.SybID, &c.Order.OrderNo, &shopName, &title, &productSpec, &specKey, &shopeeGoodsID,
|
&c.Order.SybID, &c.Order.OrderNo, &shopName, &title, &productSpec, &specKey, &shopeeGoodsID,
|
||||||
&c.Order.Quantity, &priceCent, &imageURL, &c.Order.SybData,
|
&c.Order.Quantity, &priceCent, &imageURL, &c.Order.SybData,
|
||||||
&c.Order.CreatedAt, &c.Order.UpdatedAt, &shopeeExists,
|
&c.Order.CreatedAt, &c.Order.UpdatedAt, &shopeeExists,
|
||||||
&pddGoodsID, &pddGoodsURL, &collectStatus, &collectMsg, &skusJSON, &pddUpdatedAt,
|
&pddGoodsID, &pddGoodsURL, &collectStatus, &collectMsg, &skusJSON, &pddUpdatedAt,
|
||||||
&mappingKey, &mappingOptions, &hasSucceededTask, &hasManualReviewTask,
|
&mappingKey, &mappingOptions, &mappingSource, &mappingProviderID, &mappingModel,
|
||||||
|
&mappingConfidence, &mappingReason, &mappingSourceVersion, &mappingContextVersion,
|
||||||
|
&hasSucceededTask, &hasManualReviewTask,
|
||||||
&hasActiveTask, &hasActiveCollectTask,
|
&hasActiveTask, &hasActiveCollectTask,
|
||||||
)
|
)
|
||||||
c.Order.ShopName = shopName.String
|
c.Order.ShopName = shopName.String
|
||||||
@@ -624,6 +635,13 @@ func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
|
|||||||
c.PddUpdatedAt = pddUpdatedAt.String
|
c.PddUpdatedAt = pddUpdatedAt.String
|
||||||
c.MappingOptionKey = mappingKey.String
|
c.MappingOptionKey = mappingKey.String
|
||||||
c.MappingOptions = mappingOptions.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.HasSucceededPurchaseTask = hasSucceededTask != 0
|
||||||
c.HasManualReviewPurchaseTask = hasManualReviewTask != 0
|
c.HasManualReviewPurchaseTask = hasManualReviewTask != 0
|
||||||
c.HasActiveTask = hasActiveTask != 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,
|
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,
|
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,
|
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'
|
EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase'
|
||||||
AND t.syb_id = so.syb_id AND t.status = 'succeeded'),
|
AND t.syb_id = so.syb_id AND t.status = 'succeeded'),
|
||||||
EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase'
|
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,
|
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,
|
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,
|
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'
|
EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase'
|
||||||
AND t.syb_id = so.syb_id AND t.status = 'succeeded'),
|
AND t.syb_id = so.syb_id AND t.status = 'succeeded'),
|
||||||
EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase'
|
EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase'
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 + `"`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
if utf8.RuneCountInString(choice.Key) > 191 || utf8.RuneCountInString(match.SuggestedOptionKey) > 191 || utf8.RuneCountInString(SpecMatchRulesVersion) > 32 {
|
||||||
return fmt.Errorf("规格选项键或规则版本超过数据库列宽,未保存")
|
return fmt.Errorf("规格选项键或规则版本超过数据库列宽,未保存")
|
||||||
}
|
}
|
||||||
|
mappedAt := model.NowISO()
|
||||||
if err := repository.UpsertSpecMapping(tx, model.SpecMapping{
|
if err := repository.UpsertSpecMapping(tx, model.SpecMapping{
|
||||||
ShopeeGoodsID: context.Order.ShopeeGoodsID, SpecKey: key,
|
ShopeeGoodsID: context.Order.ShopeeGoodsID, SpecKey: key,
|
||||||
SpecRaw: context.Order.ProductSpec, PddGoodsID: context.PddGoodsID,
|
SpecRaw: context.Order.ProductSpec, PddGoodsID: context.PddGoodsID,
|
||||||
PddOptionKey: choice.Key, PddOptions: choice.OptionsJSON,
|
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 {
|
}); err != nil {
|
||||||
return err
|
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,
|
ShopeeGoodsID: context.Order.ShopeeGoodsID, SpecKey: key, PddGoodsID: context.PddGoodsID,
|
||||||
RulesVersion: SpecMatchRulesVersion, SuggestedOptionKey: match.SuggestedOptionKey,
|
RulesVersion: SpecMatchRulesVersion, SuggestedOptionKey: match.SuggestedOptionKey,
|
||||||
ChosenOptionKey: choice.Key, Accepted: match.SuggestedOptionKey != "" && match.SuggestedOptionKey == choice.Key,
|
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 {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -531,7 +531,7 @@ MVP 之后:
|
|||||||
|
|
||||||
- 不做面向外部的公网服务,只在内网/本机运行。
|
- 不做面向外部的公网服务,只在内网/本机运行。
|
||||||
- 不直接对接蝦皮和拼多多的接口。
|
- 不直接对接蝦皮和拼多多的接口。
|
||||||
- 不自动决定买哪个商品——PDD 链接和规格匹配都由人确认。
|
- 不由模型选择或更换 PDD 商品链接;规格自动匹配只能从后端当前可购买候选中选择,硬校验不通过时仍由人确认。
|
||||||
- 不自动付款。
|
- 不自动付款。
|
||||||
- 不开放用户自助注册;第一个管理员只能通过首次初始化创建。
|
- 不开放用户自助注册;第一个管理员只能通过首次初始化创建。
|
||||||
- 不做复杂 RBAC、细粒度页面权限、单点登录和第三方登录。
|
- 不做复杂 RBAC、细粒度页面权限、单点登录和第三方登录。
|
||||||
|
|||||||
@@ -263,6 +263,11 @@ AI 服务商的普通配置和非敏感审计由 `repository` 写入 MySQL。API
|
|||||||
本地、云元数据和未显式允许的私网地址;重定向和实际拨号也执行相同检查。部署级
|
本地、云元数据和未显式允许的私网地址;重定向和实际拨号也执行相同检查。部署级
|
||||||
`allowed_hosts` 是私有模型端点的唯一例外入口,网页不能修改。
|
`allowed_hosts` 是私有模型端点的唯一例外入口,网页不能修改。
|
||||||
|
|
||||||
|
规格匹配按“确定性规则 → 有限候选 → 模型选择 → 服务端硬校验 → 事务写入”的顺序执行。
|
||||||
|
模型只看到商品标题、SYB 规格文本和后端生成的候选短编号,不能生成真实 PDD 选项键。
|
||||||
|
唯一确定的规则结果不调用模型;有效人工映射始终优先。保存前重新计算上下文版本并检查
|
||||||
|
当前可购买候选,防止 PDD 重采集或人工并发修改后写入过期结果。
|
||||||
|
|
||||||
## 10. 相关文档
|
## 10. 相关文档
|
||||||
|
|
||||||
- [上手指南](00-getting-started.md)
|
- [上手指南](00-getting-started.md)
|
||||||
|
|||||||
@@ -583,12 +583,24 @@ CREATE TABLE spec_mappings (
|
|||||||
spec_raw TEXT NOT NULL,
|
spec_raw TEXT NOT NULL,
|
||||||
mapped_at VARCHAR(35) NOT NULL,
|
mapped_at VARCHAR(35) NOT NULL,
|
||||||
mapped_by VARCHAR(191),
|
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),
|
PRIMARY KEY (shopee_goods_id, spec_key, pdd_goods_id),
|
||||||
KEY idx_spec_mappings_goods (shopee_goods_id),
|
KEY idx_spec_mappings_goods (shopee_goods_id),
|
||||||
KEY idx_spec_mappings_pdd (pdd_goods_id)
|
KEY idx_spec_mappings_pdd (pdd_goods_id)
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
v21 增加的来源字段描述当前生效映射。人工在详情页保存时,`source` 固定改为 `manual`
|
||||||
|
并清空 AI 元数据;规则唯一确定的结果使用 `rule`;通过模型和服务端硬门禁的结果使用
|
||||||
|
`ai`。这些字段不改变映射唯一键。PDD 重新采集后如果 `pdd_option_key` 已不存在,三种
|
||||||
|
来源都按同一规则判为失效,不能进入采购任务。
|
||||||
|
|
||||||
`[必须]` 不加外键。顺运宝明细可能比蝦皮报表更早到达,同步要先创建 skeleton 商品;
|
`[必须]` 不加外键。顺运宝明细可能比蝦皮报表更早到达,同步要先创建 skeleton 商品;
|
||||||
PDD 商品还可软删除,映射作为审计和可恢复数据必须保留。
|
PDD 商品还可软删除,映射作为审计和可恢复数据必须保留。
|
||||||
|
|
||||||
@@ -679,6 +691,15 @@ SELECT rules_version, COUNT(*) AS 无建议决策数
|
|||||||
FROM spec_mapping_decisions WHERE suggested_option_key IS NULL GROUP BY rules_version;
|
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` 任务
|
## 7. `tasks` 任务
|
||||||
|
|
||||||
采集任务和采购任务共用一张表,用 `task_type` 区分。
|
采集任务和采购任务共用一张表,用 `task_type` 区分。
|
||||||
|
|||||||
@@ -268,3 +268,5 @@ CMAutoBuyAdmin/
|
|||||||
- 连接测试只发送模型名和最小无业务文本,不发送订单号、店铺账号、用户、地址或 Client 信息。
|
- 连接测试只发送模型名和最小无业务文本,不发送订单号、店铺账号、用户、地址或 Client 信息。
|
||||||
- Base URL、DNS 解析、实际拨号和重定向都要执行 SSRF 校验;私有地址只能由部署级允许列表开放。
|
- Base URL、DNS 解析、实际拨号和重定向都要执行 SSRF 校验;私有地址只能由部署级允许列表开放。
|
||||||
- 测试失败、超时和非 2xx 响应不得记录 Authorization、完整请求或完整响应。
|
- 测试失败、超时和非 2xx 响应不得记录 Authorization、完整请求或完整响应。
|
||||||
|
- 规格匹配请求只包含商品标题、规格文本和候选短编号,不包含订单号、店铺账号、用户、地址或 Client 信息。
|
||||||
|
- 模型返回值必须经过严格 JSON 结构、候选白名单、颜色冲突、额外维度、置信度和上下文版本校验;模型自报置信度不能替代硬门禁。
|
||||||
|
|||||||
Reference in New Issue
Block a user