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
+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'