feat: 增加顺运宝批量 AI 规格匹配 (#202)

This commit is contained in:
chengma
2026-08-14 10:30:08 +08:00
parent 0201dab98c
commit d64579e9b3
26 changed files with 1289 additions and 26 deletions
+70
View File
@@ -0,0 +1,70 @@
package web
import (
"context"
"errors"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"cmautobuy/admin/service"
)
func (h *Handler) SybAIMatchCreate(c *gin.Context) {
actor := currentUser(c)
snapshot, err := service.LoadActiveAIMatchSnapshot(c.Request.Context(), h.db, h.aiSecrets, h.aiPolicy)
if err != nil {
h.sybRedirect(c, "AI 规格匹配未开始:"+err.Error())
return
}
batch, err := service.CreateAIMatchBatch(h.db, actor, c.PostFormArray("ids"), snapshot, time.Now())
if err != nil {
message := "AI 规格匹配批次未创建,数据没有被改动。"
if service.IsValidationError(err) {
message = "AI 规格匹配未开始:" + err.Error()
}
h.sybRedirect(c, message)
return
}
actorCopy := *actor
go func() {
if runErr := service.RunAIMatchBatch(context.Background(), h.db, actorCopy, batch.BatchID, snapshot); runErr != nil {
log.Printf("ai_match_batch_failed batch_id=%s error=%v", batch.BatchID, runErr)
if interruptErr := service.MarkAIMatchBatchInterrupted(h.db, batch.BatchID); interruptErr != nil {
log.Printf("ai_match_batch_interrupt_failed batch_id=%s error=%v", batch.BatchID, interruptErr)
}
}
}()
h.sybRedirectAIMatch(c, batch.BatchID, "AI 规格匹配已开始,可在进度窗口查看逐条结果")
}
func (h *Handler) SybAIMatchStatus(c *gin.Context) {
view, err := service.GetAIMatchBatchView(h.db, currentUser(c), c.Param("batch_id"))
if errors.Is(err, service.ErrForbidden) {
c.JSON(http.StatusForbidden, gin.H{"error": "没有权限查看这个 AI 匹配批次"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取 AI 匹配进度失败,请稍后重试"})
return
}
if view == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "AI 匹配批次不存在"})
return
}
c.JSON(http.StatusOK, view)
}
func (h *Handler) sybRedirectAIMatch(c *gin.Context, batchID, message string) {
params := url.Values{}
appendSybFormState(params, c)
params.Set("ai_batch_id", strings.TrimSpace(batchID))
if message != "" {
params.Set("msg", message)
}
c.Redirect(http.StatusSeeOther, "/syb?"+params.Encode())
}
+53
View File
@@ -179,6 +179,46 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
rangeWarning = defaults.Warning rangeWarning = defaults.Warning
} }
actor := currentUser(c)
aiBatchID := strings.TrimSpace(c.Query("ai_batch_id"))
var aiBatch *service.AIMatchBatchView
if aiBatchID != "" {
aiBatch, err = service.GetAIMatchBatchView(h.db, actor, aiBatchID)
if errors.Is(err, service.ErrForbidden) {
fail(c, http.StatusForbidden, "没有权限查看这个 AI 匹配批次。")
return
}
if err != nil {
fail(c, http.StatusInternalServerError, "读取 AI 匹配批次失败,请刷新页面后重试。")
return
}
if aiBatch == nil {
fail(c, http.StatusNotFound, "AI 匹配批次不存在,可能已被清理。")
return
}
}
latestAIBatchID, err := service.LatestAIMatchBatchID(h.db, actor)
if err != nil {
fail(c, http.StatusInternalServerError, "读取最近 AI 匹配批次失败,请刷新页面后重试。")
return
}
aiBatchPageURL := func(id string) string {
query := url.Values{"ai_batch_id": {id}}
if keyword != "" {
query.Set("order_no", keyword)
}
if shop != "" {
query.Set("shop", shop)
}
if stage != "" {
query.Set("stage", stage)
}
query.Set("page", strconv.Itoa(result.Page))
query.Set("date_from", dateFrom)
query.Set("date_to", dateTo)
return "/syb?" + query.Encode()
}
cfg, cfgErr := config.Load() cfg, cfgErr := config.Load()
switch { switch {
case cfgErr != nil: case cfgErr != nil:
@@ -238,6 +278,19 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
"AssignableClients": purchaseClients.Rows, "AssignableClients": purchaseClients.Rows,
"PurchaseClientCount": purchaseClients.SelectableCount, "PurchaseClientCount": purchaseClients.SelectableCount,
"EnabledSyncShopCount": enabledShopCount, "EnabledSyncShopCount": enabledShopCount,
"AIMatchBatch": aiBatch,
"AIMatchBatchFetchURL": func() string {
if aiBatch == nil {
return ""
}
return "/syb/ai-match/" + url.PathEscape(aiBatch.BatchID)
}(),
"LatestAIMatchBatchURL": func() string {
if latestAIBatchID == "" {
return ""
}
return aiBatchPageURL(latestAIBatchID)
}(),
})) }))
} }
+2
View File
@@ -89,6 +89,8 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration, aiSecret
pages.POST("/syb/collect-pdd", h.SybCollectPdd) pages.POST("/syb/collect-pdd", h.SybCollectPdd)
pages.POST("/syb/collect-pdd-batch", h.SybCollectPddBatch) pages.POST("/syb/collect-pdd-batch", h.SybCollectPddBatch)
pages.POST("/syb/match", h.SybMatch) pages.POST("/syb/match", h.SybMatch)
pages.POST("/syb/ai-match", h.SybAIMatchCreate)
pages.GET("/syb/ai-match/:batch_id", h.SybAIMatchStatus)
pages.POST("/syb/create-task", h.SybCreateTask) pages.POST("/syb/create-task", h.SybCreateTask)
pages.POST("/syb/delete", h.SybDelete) pages.POST("/syb/delete", h.SybDelete)
pages.GET("/syb/shops", AdminRequired(), func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/shops") }) pages.GET("/syb/shops", AdminRequired(), func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/shops") })
+7
View File
@@ -71,6 +71,13 @@ func main() {
if interrupted > 0 { if interrupted > 0 {
log.Printf("已把 %d 条上次进程遗留的顺运宝同步记录标记为中断", interrupted) log.Printf("已把 %d 条上次进程遗留的顺运宝同步记录标记为中断", interrupted)
} }
aiInterrupted, err := service.InterruptRunningAIMatchBatches(db, time.Now())
if err != nil {
log.Fatalf("收敛上次进程遗留的 AI 匹配批次失败: %v", err)
}
if aiInterrupted > 0 {
log.Printf("已把 %d 个上次进程遗留的 AI 匹配批次标记为中断", aiInterrupted)
}
log.Printf("数据库已就绪") log.Printf("数据库已就绪")
// 3. Web 引擎 // 3. Web 引擎
+5 -4
View File
@@ -132,15 +132,16 @@ func TestPurchaseModal_未选规格行保持隐藏且不提交(t *testing.T) {
func TestSybBulkActions_按服务端动作能力隔离勾选项(t *testing.T) { func TestSybBulkActions_按服务端动作能力隔离勾选项(t *testing.T) {
files := map[string][]string{ files := map[string][]string{
"templates/syb/list.html": { "templates/syb/list.html": {
`data-need-checked="collect"`, `data-need-checked="purchase"`, `data-need-checked="collect"`, `data-need-checked="purchase"`, `data-ai-match-open`,
`data-select-action="{{if .CanCollect}}collect{{else if .CanPurchase}}purchase{{end}}"`, `data-select-action="{{.SelectionActions}}"`,
`{{if .CanCollect}}form="syb-collect-form"{{end}}`, `{{if .CanCollect}}form="syb-collect-form"{{end}}`,
`action="/syb/collect-pdd-batch"`, `id="syb-collect-modal"`, `action="/syb/collect-pdd-batch"`, `action="/syb/ai-match"`, `id="syb-collect-modal"`,
}, },
"static/js/app.js": { "static/js/app.js": {
`supportsSelectAction(box, action)`, `supportsSelectAction(box, action)`,
`checkedCount(action)`, `checkedCount(action)`,
`supportsSelectAction(box, "purchase")`, `supportsSelectAction(box, "purchase")`,
`supportsSelectAction(box, "ai")`,
`getAttribute("data-collect-action")`, `getAttribute("data-collect-action")`,
}, },
"handler/web/web.go": { "handler/web/web.go": {
@@ -338,7 +339,7 @@ func TestSybPage_紧凑筛选和可见横向滚动(t *testing.T) {
`class="table-wrap syb-table-wrap"`, `class="table-wrap syb-table-wrap"`,
`class="syb-col-shop"`, `class="syb-col-shop"`,
`class="syb-col-spec"`, `class="syb-col-spec"`,
`colspan="11"`, `colspan="12"`,
} { } {
if !strings.Contains(pageText, want) { if !strings.Contains(pageText, want) {
t.Errorf("顺运宝紧凑列表缺少 %q", want) t.Errorf("顺运宝紧凑列表缺少 %q", want)
+20
View File
@@ -407,6 +407,26 @@ type AISpecMatchDecision struct {
DecidedBy, DecidedAt string DecidedBy, DecidedAt string
} }
type AIMatchBatch struct {
BatchID, Status, CreatedByUserID string
TotalCount, ProcessedCount, SuccessCount, ReusedCount int
ManualCount, FailedCount int
ProviderID, ProviderName, ProviderBaseURL, Model string
TimeoutSeconds, MaxConcurrency, ConfidenceThresholdBPS int
ConfigFingerprint, RulesVersion, PromptVersion, ErrorMessage string
CreatedAt, StartedAt, FinishedAt string
}
type AIMatchBatchItem struct {
ItemID, BatchID, SybID, IdentityHash, LeaderSybID, ContextVersion string
Position int
Status, Outcome, Message, MappingSource, OptionKey string
ConfidenceBPS int
ConfidenceSet bool
ModelCalled bool
StartedAt, FinishedAt string
}
// ---------- 任务 ---------- // ---------- 任务 ----------
// TaskType 区分采集任务和采购任务。 // TaskType 区分采集任务和采购任务。
+230
View File
@@ -0,0 +1,230 @@
package repository
import (
"database/sql"
"errors"
"fmt"
"cmautobuy/admin/model"
)
func InsertAIMatchBatch(q Execer, batch model.AIMatchBatch, items []model.AIMatchBatchItem) error {
_, err := q.Exec(`INSERT INTO ai_match_batches(batch_id,status,created_by_user_id,total_count,
provider_id,provider_name,provider_base_url,model,timeout_seconds,max_concurrency,
confidence_threshold_bps,config_fingerprint,rules_version,prompt_version,created_at)
VALUES(?,?,?, ?,?,?,?,?,?,?,?,?,?,?,?)`, batch.BatchID, batch.Status, batch.CreatedByUserID,
batch.TotalCount, batch.ProviderID, batch.ProviderName, batch.ProviderBaseURL, batch.Model,
batch.TimeoutSeconds, batch.MaxConcurrency, batch.ConfidenceThresholdBPS,
batch.ConfigFingerprint, batch.RulesVersion, batch.PromptVersion, batch.CreatedAt)
if err != nil {
return fmt.Errorf("创建 AI 匹配批次失败: %w", err)
}
for _, item := range items {
if _, err := q.Exec(`INSERT INTO ai_match_batch_items(item_id,batch_id,syb_id,identity_hash,
leader_syb_id,context_version,position,status) VALUES(?,?,?,?,?,?,?,?)`, item.ItemID,
item.BatchID, item.SybID, item.IdentityHash, item.LeaderSybID, item.ContextVersion,
item.Position, item.Status); err != nil {
return fmt.Errorf("创建 AI 匹配批次明细失败: %w", err)
}
}
return nil
}
func MarkAIMatchBatchRunning(q Execer, batchID, startedAt string) error {
result, err := q.Exec(`UPDATE ai_match_batches SET status='running',started_at=?
WHERE batch_id=? AND status='queued'`, startedAt, batchID)
if err != nil {
return fmt.Errorf("启动 AI 匹配批次失败: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return fmt.Errorf("AI 匹配批次不是待运行状态")
}
return nil
}
func ListAIMatchLeaderItems(q Execer, batchID string) ([]model.AIMatchBatchItem, error) {
rows, err := q.Query(`SELECT item_id,batch_id,syb_id,identity_hash,leader_syb_id,
context_version,position,status,outcome,message,mapping_source,option_key,confidence_bps,
model_called,started_at,finished_at FROM ai_match_batch_items
WHERE batch_id=? AND syb_id=leader_syb_id AND status='queued' ORDER BY position`, batchID)
if err != nil {
return nil, fmt.Errorf("读取 AI 匹配批次工作项失败: %w", err)
}
defer rows.Close()
return scanAIMatchItems(rows)
}
func ListAIMatchIdentityItems(q Execer, batchID, identityHash string) ([]model.AIMatchBatchItem, error) {
rows, err := q.Query(`SELECT item_id,batch_id,syb_id,identity_hash,leader_syb_id,
context_version,position,status,outcome,message,mapping_source,option_key,confidence_bps,
model_called,started_at,finished_at FROM ai_match_batch_items
WHERE batch_id=? AND identity_hash=? ORDER BY position`, batchID, identityHash)
if err != nil {
return nil, fmt.Errorf("读取 AI 匹配批次同规格明细失败: %w", err)
}
defer rows.Close()
return scanAIMatchItems(rows)
}
func ListAIMatchBatchItems(q Execer, batchID string) ([]model.AIMatchBatchItem, error) {
rows, err := q.Query(`SELECT item_id,batch_id,syb_id,identity_hash,leader_syb_id,
context_version,position,status,outcome,message,mapping_source,option_key,confidence_bps,
model_called,started_at,finished_at FROM ai_match_batch_items WHERE batch_id=? ORDER BY position`, batchID)
if err != nil {
return nil, fmt.Errorf("读取 AI 匹配批次明细失败: %w", err)
}
defer rows.Close()
return scanAIMatchItems(rows)
}
func scanAIMatchItems(rows *sql.Rows) ([]model.AIMatchBatchItem, error) {
var result []model.AIMatchBatchItem
for rows.Next() {
var item model.AIMatchBatchItem
var outcome, message, source, optionKey, startedAt, finishedAt sql.NullString
var confidence sql.NullInt64
var called int
if err := rows.Scan(&item.ItemID, &item.BatchID, &item.SybID, &item.IdentityHash,
&item.LeaderSybID, &item.ContextVersion, &item.Position, &item.Status, &outcome,
&message, &source, &optionKey, &confidence, &called, &startedAt, &finishedAt); err != nil {
return nil, err
}
item.Outcome, item.Message, item.MappingSource, item.OptionKey = outcome.String, message.String, source.String, optionKey.String
item.ConfidenceBPS, item.ConfidenceSet = int(confidence.Int64), confidence.Valid
item.ModelCalled, item.StartedAt, item.FinishedAt = called == 1, startedAt.String, finishedAt.String
result = append(result, item)
}
return result, rows.Err()
}
func MarkAIMatchItemRunning(q Execer, itemID, startedAt string) error {
_, err := q.Exec(`UPDATE ai_match_batch_items SET status='running',started_at=? WHERE item_id=? AND status='queued'`, startedAt, itemID)
if err != nil {
return fmt.Errorf("标记 AI 匹配明细运行失败: %w", err)
}
return nil
}
func FinishAIMatchItem(q Execer, item model.AIMatchBatchItem) error {
_, err := q.Exec(`UPDATE ai_match_batch_items SET status=?,outcome=?,message=?,mapping_source=?,
option_key=?,confidence_bps=?,model_called=?,finished_at=? WHERE item_id=? AND status IN ('queued','running')`,
item.Status, nullableText(item.Outcome), nullableText(item.Message), nullableText(item.MappingSource),
nullableText(item.OptionKey), nullableInt(item.ConfidenceBPS, item.ConfidenceSet), item.ModelCalled,
item.FinishedAt, item.ItemID)
if err != nil {
return fmt.Errorf("完成 AI 匹配明细失败: %w", err)
}
return nil
}
type AIMatchBatchCounts struct{ Processed, Success, Reused, Manual, Failed int }
func CountAIMatchBatchItems(q Execer, batchID string) (AIMatchBatchCounts, error) {
var result AIMatchBatchCounts
err := q.QueryRow(`SELECT
SUM(status NOT IN ('queued','running')),
SUM(status='succeeded'),SUM(status='reused'),SUM(status IN ('manual','stale')),
SUM(status IN ('failed','interrupted')) FROM ai_match_batch_items WHERE batch_id=?`, batchID).
Scan(&result.Processed, &result.Success, &result.Reused, &result.Manual, &result.Failed)
if err != nil {
return result, fmt.Errorf("统计 AI 匹配批次失败: %w", err)
}
return result, nil
}
func FinishAIMatchBatch(q Execer, batchID, status, errorMessage, finishedAt string, counts AIMatchBatchCounts) error {
_, err := q.Exec(`UPDATE ai_match_batches SET status=?,processed_count=?,success_count=?,reused_count=?,
manual_count=?,failed_count=?,error_message=?,finished_at=? WHERE batch_id=?`, status,
counts.Processed, counts.Success, counts.Reused, counts.Manual, counts.Failed,
nullableText(errorMessage), finishedAt, batchID)
if err != nil {
return fmt.Errorf("完成 AI 匹配批次失败: %w", err)
}
return nil
}
func UpdateAIMatchBatchCounts(q Execer, batchID string, counts AIMatchBatchCounts) error {
_, err := q.Exec(`UPDATE ai_match_batches SET processed_count=?,success_count=?,reused_count=?,
manual_count=?,failed_count=? WHERE batch_id=? AND status='running'`, counts.Processed,
counts.Success, counts.Reused, counts.Manual, counts.Failed, batchID)
if err != nil {
return fmt.Errorf("更新 AI 匹配批次进度失败: %w", err)
}
return nil
}
func GetAIMatchBatch(q Execer, batchID string) (*model.AIMatchBatch, error) {
var batch model.AIMatchBatch
var errorMessage, startedAt, finishedAt sql.NullString
err := q.QueryRow(`SELECT batch_id,status,created_by_user_id,total_count,processed_count,
success_count,reused_count,manual_count,failed_count,provider_id,provider_name,
provider_base_url,model,timeout_seconds,max_concurrency,confidence_threshold_bps,
config_fingerprint,rules_version,prompt_version,error_message,created_at,started_at,finished_at
FROM ai_match_batches WHERE batch_id=?`, batchID).Scan(&batch.BatchID, &batch.Status,
&batch.CreatedByUserID, &batch.TotalCount, &batch.ProcessedCount, &batch.SuccessCount,
&batch.ReusedCount, &batch.ManualCount, &batch.FailedCount, &batch.ProviderID,
&batch.ProviderName, &batch.ProviderBaseURL, &batch.Model, &batch.TimeoutSeconds,
&batch.MaxConcurrency, &batch.ConfidenceThresholdBPS, &batch.ConfigFingerprint,
&batch.RulesVersion, &batch.PromptVersion, &errorMessage, &batch.CreatedAt, &startedAt, &finishedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("读取 AI 匹配批次失败: %w", err)
}
batch.ErrorMessage, batch.StartedAt, batch.FinishedAt = errorMessage.String, startedAt.String, finishedAt.String
return &batch, nil
}
func GetLatestAIMatchBatchID(q Execer, userID string, admin bool) (string, error) {
query := `SELECT batch_id FROM ai_match_batches`
var args []any
if !admin {
query += ` WHERE created_by_user_id=?`
args = append(args, userID)
}
query += ` ORDER BY created_at DESC,batch_id DESC LIMIT 1`
var batchID string
err := q.QueryRow(query, args...).Scan(&batchID)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("读取最近 AI 匹配批次失败: %w", err)
}
return batchID, nil
}
func ListUnfinishedAIMatchBatchIDs(q Execer) ([]string, error) {
rows, err := q.Query(`SELECT batch_id FROM ai_match_batches WHERE status IN ('queued','running') ORDER BY created_at,batch_id`)
if err != nil {
return nil, err
}
defer rows.Close()
var result []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
result = append(result, id)
}
return result, rows.Err()
}
func InterruptAIMatchBatch(q Execer, batchID, finishedAt string) error {
if _, err := q.Exec(`UPDATE ai_match_batch_items SET status='interrupted',outcome='failed',
message='Admin 在批次完成前退出,可重新勾选未成功条目安全重试',finished_at=?
WHERE batch_id=? AND status IN ('queued','running')`, finishedAt, batchID); err != nil {
return err
}
counts, err := CountAIMatchBatchItems(q, batchID)
if err != nil {
return err
}
return FinishAIMatchBatch(q, batchID, "interrupted", "Admin 在批次完成前退出", finishedAt, counts)
}
+98 -2
View File
@@ -20,7 +20,7 @@ import (
"cmautobuy/admin/spec" "cmautobuy/admin/spec"
) )
const mysqlSchemaVersion = 21 const mysqlSchemaVersion = 22
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。 // OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) { func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
@@ -676,10 +676,75 @@ func MigrateMySQL(db *sql.DB) error {
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 21, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { 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 fmt.Errorf("记录 MySQL schema v21 失败: %w", err)
} }
current = 21
}
if current < 22 {
if err := migrateMySQLV22(db); err != nil {
return fmt.Errorf("执行 MySQL schema v22 失败: %w", err)
}
if err := checkMySQLV22Shape(db); err != nil {
return fmt.Errorf("MySQL schema v22 自检失败,未记录版本: %w", err)
}
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 22, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("记录 MySQL schema v22 失败: %w", err)
}
} }
return CheckMySQLSchema(db) return CheckMySQLSchema(db)
} }
func migrateMySQLV22(db *sql.DB) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS ai_match_batches (
batch_id VARCHAR(191) COLLATE utf8mb4_bin PRIMARY KEY,
status VARCHAR(16) NOT NULL,
created_by_user_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
total_count INT NOT NULL,processed_count INT NOT NULL DEFAULT 0,
success_count INT NOT NULL DEFAULT 0,reused_count INT NOT NULL DEFAULT 0,
manual_count INT NOT NULL DEFAULT 0,failed_count INT NOT NULL DEFAULT 0,
provider_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
provider_name VARCHAR(191) NOT NULL,provider_base_url VARCHAR(2048) NOT NULL,
model VARCHAR(191) NOT NULL,timeout_seconds INT NOT NULL,max_concurrency INT NOT NULL,
confidence_threshold_bps INT NOT NULL,config_fingerprint CHAR(64) COLLATE utf8mb4_bin NOT NULL,
rules_version VARCHAR(32) NOT NULL,prompt_version VARCHAR(32) NOT NULL,
error_message VARCHAR(500),created_at VARCHAR(35) NOT NULL,
started_at VARCHAR(35),finished_at VARCHAR(35),
KEY idx_ai_match_batch_user (created_by_user_id,created_at DESC,batch_id),
KEY idx_ai_match_batch_status (status,created_at,batch_id),
CONSTRAINT fk_ai_match_batch_user FOREIGN KEY (created_by_user_id) REFERENCES users(user_id),
CONSTRAINT chk_ai_match_batch_status CHECK (status IN ('queued','running','partial','succeeded','failed','interrupted')),
CONSTRAINT chk_ai_match_batch_total CHECK (total_count BETWEEN 1 AND 100),
CONSTRAINT chk_ai_match_batch_counts CHECK (processed_count BETWEEN 0 AND total_count AND success_count>=0 AND reused_count>=0 AND manual_count>=0 AND failed_count>=0),
CONSTRAINT chk_ai_match_batch_runtime CHECK (timeout_seconds BETWEEN 1 AND 120 AND max_concurrency BETWEEN 1 AND 16 AND confidence_threshold_bps BETWEEN 0 AND 10000)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS ai_match_batch_items (
item_id VARCHAR(191) COLLATE utf8mb4_bin PRIMARY KEY,
batch_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
syb_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
identity_hash CHAR(64) COLLATE utf8mb4_bin NOT NULL,
leader_syb_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
context_version CHAR(64) COLLATE utf8mb4_bin NOT NULL,
position INT NOT NULL,status VARCHAR(16) NOT NULL,outcome VARCHAR(32),
message VARCHAR(500),mapping_source VARCHAR(16),option_key VARCHAR(191) COLLATE utf8mb4_bin,
confidence_bps INT,model_called TINYINT NOT NULL DEFAULT 0,
started_at VARCHAR(35),finished_at VARCHAR(35),
UNIQUE KEY uq_ai_match_batch_syb (batch_id,syb_id),
KEY idx_ai_match_batch_work (batch_id,status,leader_syb_id,position),
KEY idx_ai_match_batch_identity (batch_id,identity_hash,position),
CONSTRAINT fk_ai_match_item_batch FOREIGN KEY (batch_id) REFERENCES ai_match_batches(batch_id) ON DELETE CASCADE,
CONSTRAINT chk_ai_match_item_status CHECK (status IN ('queued','running','succeeded','reused','manual','failed','stale','interrupted')),
CONSTRAINT chk_ai_match_item_source CHECK (mapping_source IS NULL OR mapping_source IN ('manual','rule','ai')),
CONSTRAINT chk_ai_match_item_confidence CHECK (confidence_bps IS NULL OR confidence_bps BETWEEN 0 AND 10000),
CONSTRAINT chk_ai_match_item_called CHECK (model_called IN (0,1))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
}
for _, statement := range statements {
if _, err := db.Exec(statement); err != nil {
return err
}
}
return nil
}
// migrateMySQLV21 给当前有效规格映射增加来源,并建立只追加的 AI 决策审计。 // migrateMySQLV21 给当前有效规格映射增加来源,并建立只追加的 AI 决策审计。
func migrateMySQLV21(db *sql.DB) error { func migrateMySQLV21(db *sql.DB) error {
columns := []struct{ name, ddl string }{ columns := []struct{ name, ddl string }{
@@ -1944,6 +2009,7 @@ func CheckMySQLSchema(db *sql.DB) error {
"syb_allowed_shops", "syb_allowed_shops",
"ai_provider_configs", "ai_provider_audits", "ai_provider_configs", "ai_provider_audits",
"ai_spec_match_decisions", "ai_spec_match_decisions",
"ai_match_batches", "ai_match_batch_items",
} }
if err := checkMySQLSchema(db, mysqlRequiredTables); err != nil { if err := checkMySQLSchema(db, mysqlRequiredTables); err != nil {
return err return err
@@ -2002,7 +2068,37 @@ func CheckMySQLSchema(db *sql.DB) error {
if err := checkMySQLV20Shape(db); err != nil { if err := checkMySQLV20Shape(db); err != nil {
return err return err
} }
return checkMySQLV21Shape(db) if err := checkMySQLV21Shape(db); err != nil {
return err
}
return checkMySQLV22Shape(db)
}
func checkMySQLV22Shape(db *sql.DB) error {
if err := checkMySQLSchema(db, []string{"ai_match_batches", "ai_match_batch_items"}); err != nil {
return err
}
for _, item := range []struct{ kind, table, name string }{
{"constraint", "ai_match_batches", "chk_ai_match_batch_status"},
{"constraint", "ai_match_batches", "chk_ai_match_batch_counts"},
{"constraint", "ai_match_batches", "fk_ai_match_batch_user"},
{"constraint", "ai_match_batch_items", "chk_ai_match_item_status"},
{"constraint", "ai_match_batch_items", "fk_ai_match_item_batch"},
{"index", "ai_match_batch_items", "uq_ai_match_batch_syb"},
{"index", "ai_match_batch_items", "idx_ai_match_batch_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 checkMySQLV21Shape(db *sql.DB) error { func checkMySQLV21Shape(db *sql.DB) error {
+2
View File
@@ -590,6 +590,7 @@ type SybOrderContext struct {
MappingProviderID string MappingProviderID string
MappingModel string MappingModel string
MappingConfidenceBPS int MappingConfidenceBPS int
MappingConfidenceSet bool
MappingReason string MappingReason string
MappingSourceVersion string MappingSourceVersion string
MappingContextVersion string MappingContextVersion string
@@ -639,6 +640,7 @@ func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
c.MappingProviderID = mappingProviderID.String c.MappingProviderID = mappingProviderID.String
c.MappingModel = mappingModel.String c.MappingModel = mappingModel.String
c.MappingConfidenceBPS = int(mappingConfidence.Int64) c.MappingConfidenceBPS = int(mappingConfidence.Int64)
c.MappingConfidenceSet = mappingConfidence.Valid
c.MappingReason = mappingReason.String c.MappingReason = mappingReason.String
c.MappingSourceVersion = mappingSourceVersion.String c.MappingSourceVersion = mappingSourceVersion.String
c.MappingContextVersion = mappingContextVersion.String c.MappingContextVersion = mappingContextVersion.String
+346
View File
@@ -0,0 +1,346 @@
package service
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"unicode/utf8"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
const MaxAIMatchBatchItems = 100
type AIMatchBatchView struct {
BatchID string `json:"batch_id"`
Status string `json:"status"`
StatusText string `json:"status_text"`
ProviderName string `json:"provider_name"`
Model string `json:"model"`
ErrorMessage string `json:"error_message"`
Total int `json:"total"`
Processed int `json:"processed"`
Success int `json:"success"`
Reused int `json:"reused"`
Manual int `json:"manual"`
Failed int `json:"failed"`
CreatedAt string `json:"created_at"`
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
Running bool `json:"running"`
Items []AIMatchBatchItemView `json:"items"`
}
type AIMatchBatchItemView struct {
SybID string `json:"syb_id"`
Status string `json:"status"`
StatusText string `json:"status_text"`
Outcome string `json:"outcome"`
Message string `json:"message"`
SourceText string `json:"source_text"`
OptionKey string `json:"option_key"`
ConfidenceText string `json:"confidence_text"`
FinishedAt string `json:"finished_at"`
}
func CreateAIMatchBatch(db *sql.DB, actor *model.User, sybIDs []string, snapshot AIMatchSnapshot, now time.Time) (*model.AIMatchBatch, error) {
if actor == nil || !actor.IsActive() {
return nil, ErrUnauthenticated
}
ids := make([]string, 0, len(sybIDs))
seen := map[string]bool{}
for _, raw := range sybIDs {
id := strings.TrimSpace(raw)
if id == "" || seen[id] {
continue
}
if utf8.RuneCountInString(id) > 191 {
return nil, invalidFieldInput("ids", "货运单明细编号过长")
}
seen[id] = true
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, invalidFieldInput("ids", "请先勾选要匹配的顺运宝商品")
}
if len(ids) > MaxAIMatchBatchItems {
return nil, invalidFieldInput("ids", "一次最多匹配 %d 条商品", MaxAIMatchBatchItems)
}
if snapshot.Provider.ProviderID == "" || snapshot.Client == nil || snapshot.Secret == "" {
return nil, fmt.Errorf("AI 服务商运行快照不可用")
}
batchID, err := randomID("AIM-", 16)
if err != nil {
return nil, err
}
createdAt := now.UTC().Format(model.TimeLayout)
batch := &model.AIMatchBatch{BatchID: batchID, Status: "queued", CreatedByUserID: actor.UserID,
TotalCount: len(ids), ProviderID: snapshot.Provider.ProviderID, ProviderName: snapshot.Provider.Name,
ProviderBaseURL: snapshot.Provider.BaseURL, Model: snapshot.Provider.Model,
TimeoutSeconds: snapshot.Provider.TimeoutSeconds, MaxConcurrency: snapshot.Provider.MaxConcurrency,
ConfidenceThresholdBPS: snapshot.Provider.ConfidenceThresholdBPS,
ConfigFingerprint: snapshot.ConfigFingerprint, RulesVersion: SpecMatchRulesVersion,
PromptVersion: AISpecMatchPromptVersion, CreatedAt: createdAt}
tx, err := db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
leaders := map[string]string{}
items := make([]model.AIMatchBatchItem, 0, len(ids))
for position, sybID := range ids {
orderContext, err := repository.GetSybOrderContext(tx, sybID)
if err != nil {
return nil, err
}
if orderContext == nil {
return nil, invalidFieldInput("ids", "顺运宝明细 %s 已不存在,请刷新页面后重试", sybID)
}
identity := aiSpecMatchIdentity(*orderContext)
leader := leaders[identity]
if leader == "" {
leader, leaders[identity] = sybID, sybID
}
itemID, err := randomID("AII-", 16)
if err != nil {
return nil, err
}
items = append(items, model.AIMatchBatchItem{ItemID: itemID, BatchID: batchID, SybID: sybID,
IdentityHash: identity, LeaderSybID: leader, ContextVersion: mappingContextVersion(*orderContext),
Position: position + 1, Status: "queued"})
}
if err := repository.InsertAIMatchBatch(tx, *batch, items); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return batch, nil
}
func aiSpecMatchIdentity(c repository.SybOrderContext) string {
payload, _ := json.Marshal([]string{c.Order.ShopeeGoodsID, c.Order.SpecKey, c.Order.ProductSpec,
c.PddGoodsID, c.PddUpdatedAt, c.PddSkusJSON, SpecMatchRulesVersion})
return fmt.Sprintf("%x", sha256.Sum256(payload))
}
func RunAIMatchBatch(ctx context.Context, db *sql.DB, actor model.User, batchID string, snapshot AIMatchSnapshot) error {
if err := repository.MarkAIMatchBatchRunning(db, batchID, model.NowISO()); err != nil {
return err
}
leaders, err := repository.ListAIMatchLeaderItems(db, batchID)
if err != nil {
return err
}
concurrency := snapshot.Provider.MaxConcurrency
if concurrency < 1 {
concurrency = 1
}
if concurrency > 16 {
concurrency = 16
}
jobs := make(chan model.AIMatchBatchItem)
var wg sync.WaitGroup
var firstErr error
var errMu sync.Mutex
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for leader := range jobs {
if err := runAIMatchIdentity(ctx, db, actor, batchID, leader, snapshot); err != nil {
errMu.Lock()
if firstErr == nil {
firstErr = err
}
errMu.Unlock()
}
}
}()
}
for _, leader := range leaders {
jobs <- leader
}
close(jobs)
wg.Wait()
if firstErr != nil {
return firstErr
}
counts, err := repository.CountAIMatchBatchItems(db, batchID)
if err != nil {
return err
}
batch, err := repository.GetAIMatchBatch(db, batchID)
if err != nil || batch == nil {
return fmt.Errorf("完成 AI 匹配批次前无法读取批次: %w", err)
}
status := "succeeded"
if counts.Failed == batch.TotalCount {
status = "failed"
} else if counts.Manual > 0 || counts.Failed > 0 || counts.Processed < batch.TotalCount {
status = "partial"
}
return repository.FinishAIMatchBatch(db, batchID, status, "", model.NowISO(), counts)
}
func runAIMatchIdentity(ctx context.Context, db *sql.DB, actor model.User, batchID string, leader model.AIMatchBatchItem, snapshot AIMatchSnapshot) error {
startedAt := model.NowISO()
if err := repository.MarkAIMatchItemRunning(db, leader.ItemID, startedAt); err != nil {
return err
}
result, matchErr := MatchSybSpecWithAI(ctx, db, &actor, snapshot, leader.SybID, leader.ContextVersion)
leaderResult := batchItemFromMatch(leader, result, matchErr)
leaderResult.StartedAt, leaderResult.FinishedAt = startedAt, model.NowISO()
if err := repository.FinishAIMatchItem(db, leaderResult); err != nil {
return err
}
items, err := repository.ListAIMatchIdentityItems(db, batchID, leader.IdentityHash)
if err != nil {
return err
}
for _, item := range items {
if item.ItemID == leader.ItemID || item.Status != "queued" {
continue
}
follower := reuseAIMatchLeaderResult(db, item, leaderResult)
follower.FinishedAt = model.NowISO()
if err := repository.FinishAIMatchItem(db, follower); err != nil {
return err
}
}
counts, err := repository.CountAIMatchBatchItems(db, batchID)
if err != nil {
return err
}
return repository.UpdateAIMatchBatchCounts(db, batchID, counts)
}
func reuseAIMatchLeaderResult(db *sql.DB, item, leader model.AIMatchBatchItem) model.AIMatchBatchItem {
current, err := repository.GetSybOrderContext(db, item.SybID)
if err != nil || current == nil || aiSpecMatchIdentity(*current) != item.IdentityHash {
item.Status, item.Outcome, item.Message = "stale", "stale", "同规格处理期间数据已变化,请刷新后重试"
return item
}
item.Status, item.Outcome = leader.Status, leader.Outcome
item.Message = "同一商品规格复用批次结果:" + leader.Message
item.MappingSource, item.OptionKey = leader.MappingSource, leader.OptionKey
item.ConfidenceBPS, item.ConfidenceSet = leader.ConfidenceBPS, leader.ConfidenceSet
if leader.Status == "succeeded" || leader.Status == "reused" {
if !mappingIsValid(*current) {
item.Status, item.Outcome, item.Message = "stale", "stale", "批次结果保存后当前映射已失效,请重试"
item.MappingSource, item.OptionKey, item.ConfidenceSet = "", "", false
return item
}
item.Status, item.Outcome = "reused", "reused"
item.MappingSource, item.OptionKey = current.MappingSource, current.MappingOptionKey
if item.MappingSource == "" {
item.MappingSource = "manual"
}
item.ConfidenceBPS, item.ConfidenceSet = current.MappingConfidenceBPS, current.MappingConfidenceSet
}
return item
}
func batchItemFromMatch(item model.AIMatchBatchItem, result AISpecMatchResult, err error) model.AIMatchBatchItem {
item.Outcome, item.Message = result.Outcome, truncateRunes(result.Message, 500)
item.MappingSource, item.OptionKey = result.Source, result.OptionKey
item.ConfidenceBPS, item.ConfidenceSet = result.ConfidenceBPS, result.ConfidenceSet
item.ModelCalled = result.ModelCalled
if err != nil {
item.Status, item.Outcome, item.Message = "failed", "failed", truncateRunes(err.Error(), 500)
return item
}
switch result.Outcome {
case "ai_saved", "rule_saved":
item.Status = "succeeded"
case "reused", "manual_exists":
item.Status = "reused"
case "stale":
item.Status = "stale"
case "failed":
item.Status = "failed"
default:
item.Status = "manual"
}
return item
}
func InterruptRunningAIMatchBatches(db *sql.DB, now time.Time) (int, error) {
ids, err := repository.ListUnfinishedAIMatchBatchIDs(db)
if err != nil {
return 0, err
}
for _, id := range ids {
if err := repository.InterruptAIMatchBatch(db, id, now.UTC().Format(model.TimeLayout)); err != nil {
return 0, err
}
}
return len(ids), nil
}
func MarkAIMatchBatchInterrupted(db *sql.DB, batchID string) error {
return repository.InterruptAIMatchBatch(db, batchID, model.NowISO())
}
func GetAIMatchBatchView(db *sql.DB, actor *model.User, batchID string) (*AIMatchBatchView, error) {
if actor == nil || !actor.IsActive() {
return nil, ErrUnauthenticated
}
batch, err := repository.GetAIMatchBatch(db, strings.TrimSpace(batchID))
if err != nil || batch == nil {
return nil, err
}
if !actor.IsAdmin() && batch.CreatedByUserID != actor.UserID {
return nil, ErrForbidden
}
items, err := repository.ListAIMatchBatchItems(db, batch.BatchID)
if err != nil {
return nil, err
}
view := &AIMatchBatchView{BatchID: batch.BatchID, Status: batch.Status,
StatusText: aiMatchBatchStatusText(batch.Status), ProviderName: batch.ProviderName, Model: batch.Model,
ErrorMessage: batch.ErrorMessage, Total: batch.TotalCount, Processed: batch.ProcessedCount,
Success: batch.SuccessCount, Reused: batch.ReusedCount, Manual: batch.ManualCount,
Failed: batch.FailedCount, CreatedAt: batch.CreatedAt, StartedAt: batch.StartedAt,
FinishedAt: batch.FinishedAt, Running: batch.Status == "queued" || batch.Status == "running",
Items: make([]AIMatchBatchItemView, 0, len(items))}
for _, item := range items {
confidence := ""
if item.ConfidenceSet {
confidence = fmt.Sprintf("%.2f%%", float64(item.ConfidenceBPS)/100)
}
view.Items = append(view.Items, AIMatchBatchItemView{SybID: item.SybID, Status: item.Status,
StatusText: aiMatchItemStatusText(item.Status), Outcome: item.Outcome,
Message: item.Message, SourceText: mappingSourceText(item.MappingSource), OptionKey: item.OptionKey,
ConfidenceText: confidence, FinishedAt: item.FinishedAt})
}
return view, nil
}
func LatestAIMatchBatchID(db *sql.DB, actor *model.User) (string, error) {
if actor == nil || !actor.IsActive() {
return "", ErrUnauthenticated
}
return repository.GetLatestAIMatchBatchID(db, actor.UserID, actor.IsAdmin())
}
func aiMatchBatchStatusText(status string) string {
return map[string]string{"queued": "等待中", "running": "匹配中", "partial": "部分完成",
"succeeded": "已完成", "failed": "失败", "interrupted": "已中断"}[status]
}
func aiMatchItemStatusText(status string) string {
return map[string]string{"queued": "等待中", "running": "匹配中", "succeeded": "已保存",
"reused": "已复用", "manual": "需人工", "failed": "失败", "stale": "数据已变化",
"interrupted": "已中断"}[status]
}
func mappingSourceText(source string) string {
return map[string]string{"manual": "人工匹配", "rule": "规则匹配", "ai": "AI匹配"}[source]
}
+111
View File
@@ -0,0 +1,111 @@
package service
import (
"context"
"errors"
"testing"
"time"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
func TestAISpecMatchIdentity_相同业务上下文去重且数据变化后失效(t *testing.T) {
base := repository.SybOrderContext{
Order: model.SybOrder{SybID: "SYB-1", ShopeeGoodsID: "SP-1", SpecKey: "黑色,M", ProductSpec: "黑色,M"},
PddGoodsID: "737116531267", PddUpdatedAt: "2026-08-14T01:00:00Z", PddSkusJSON: collectedAIChoices,
}
same := base
same.Order.SybID = "SYB-2"
same.Order.OrderNo = "ORDER-2"
if aiSpecMatchIdentity(base) != aiSpecMatchIdentity(same) {
t.Fatal("同一蝦皮商品、来源规格和 PDD 上下文应归为同一个批次调用")
}
changed := same
changed.PddSkusJSON = collectedRuleChoices
if aiSpecMatchIdentity(base) == aiSpecMatchIdentity(changed) {
t.Fatal("PDD 候选变化后不得复用旧批次结果")
}
}
func TestBatchItemFromMatch_结果分类和置信度零值(t *testing.T) {
item := model.AIMatchBatchItem{ItemID: "AII-1"}
got := batchItemFromMatch(item, AISpecMatchResult{Outcome: "rejected", Message: "需要人工", ConfidenceBPS: 0}, nil)
if got.Status != "manual" || got.ConfidenceSet {
t.Fatalf("拒绝结果应进入人工队列且未提供置信度: %+v", got)
}
got = batchItemFromMatch(item, AISpecMatchResult{Outcome: "ai_saved", Source: "ai", ConfidenceBPS: 0, ConfidenceSet: true}, nil)
if got.Status != "succeeded" || !got.ConfidenceSet || got.ConfidenceBPS != 0 {
t.Fatalf("模型明确返回的 0%% 置信度也应被准确记录: %+v", got)
}
got = batchItemFromMatch(item, AISpecMatchResult{}, errors.New("provider failed"))
if got.Status != "failed" || got.Outcome != "failed" {
t.Fatalf("调用错误应记为失败: %+v", got)
}
}
func TestAIMatchBatch_同规格只调用一次并记录逐条结果(t *testing.T) {
db := newTestDB(t)
actor := seedAIMatchContext(t, db, "SYB-BATCH-1")
seedWorkflowOrder(t, db, "SYB-BATCH-2", "SP-AI", "黑色,M")
fake := &fakeAIModelClient{response: AIModelMatchResponse{
Conclusion: "match", CandidateID: "C01", ConfidenceBPS: 9200, Reason: "颜色和尺码一致",
}}
snapshot := testAIMatchSnapshot(fake)
snapshot.Provider.TimeoutSeconds = 30
snapshot.Provider.MaxConcurrency = 2
batch, err := CreateAIMatchBatch(db, &actor, []string{"SYB-BATCH-1", "SYB-BATCH-2"}, snapshot, time.Now())
if err != nil {
t.Fatal(err)
}
if err := RunAIMatchBatch(context.Background(), db, actor, batch.BatchID, snapshot); err != nil {
t.Fatal(err)
}
if fake.calls != 1 {
t.Fatalf("相同业务上下文应只调用模型一次,实际 %d 次", fake.calls)
}
view, err := GetAIMatchBatchView(db, &actor, batch.BatchID)
if err != nil {
t.Fatal(err)
}
if view.Status != "succeeded" || view.Processed != 2 || view.Success != 1 || view.Reused != 1 {
t.Fatalf("批次汇总错误: %+v", view)
}
if len(view.Items) != 2 || view.Items[0].SourceText != "AI匹配" || view.Items[1].Status != "reused" {
t.Fatalf("逐条结果或来源错误: %+v", view.Items)
}
}
func TestAIMatchBatch_重启中断和查看权限(t *testing.T) {
db := newTestDB(t)
actor := seedAIMatchContext(t, db, "SYB-BATCH-INTERRUPT")
snapshot := testAIMatchSnapshot(&fakeAIModelClient{})
snapshot.Provider.TimeoutSeconds = 30
snapshot.Provider.MaxConcurrency = 1
batch, err := CreateAIMatchBatch(db, &actor, []string{"SYB-BATCH-INTERRUPT"}, snapshot, time.Now())
if err != nil {
t.Fatal(err)
}
if count, err := InterruptRunningAIMatchBatches(db, time.Now()); err != nil || count != 1 {
t.Fatalf("中断未完成批次失败: count=%d err=%v", count, err)
}
view, err := GetAIMatchBatchView(db, &actor, batch.BatchID)
if err != nil || view.Status != "interrupted" || view.Failed != 1 || view.Items[0].Status != "interrupted" {
t.Fatalf("中断状态错误: view=%+v err=%v", view, err)
}
other := model.User{UserID: "USR-OTHER", Username: "other", PasswordHash: "test-hash", Role: model.RolePurchaser,
Status: model.UserActive, PasswordChangedAt: model.NowISO(), CreatedAt: model.NowISO(), UpdatedAt: model.NowISO()}
if err := repository.CreateUser(db, other); err != nil {
t.Fatal(err)
}
if _, err := GetAIMatchBatchView(db, &other, batch.BatchID); !errors.Is(err, ErrForbidden) {
t.Fatalf("其他采购员不应查看本批次: %v", err)
}
admin := other
admin.Role = model.RoleAdmin
if _, err := GetAIMatchBatchView(db, &admin, batch.BatchID); err != nil {
t.Fatalf("管理员应可查看任意批次: %v", err)
}
}
+8 -4
View File
@@ -33,6 +33,7 @@ type AISpecMatchResult struct {
Source string Source string
OptionKey string OptionKey string
ConfidenceBPS int ConfidenceBPS int
ConfidenceSet bool
ContextVersion string ContextVersion string
ModelCalled bool ModelCalled bool
} }
@@ -98,7 +99,8 @@ func MatchSybSpecWithAI(ctx context.Context, db *sql.DB, actor *model.User, snap
source = "manual" source = "manual"
} }
return AISpecMatchResult{Outcome: "reused", Message: "已复用当前有效规格映射", Source: source, return AISpecMatchResult{Outcome: "reused", Message: "已复用当前有效规格映射", Source: source,
OptionKey: orderContext.MappingOptionKey, ConfidenceBPS: orderContext.MappingConfidenceBPS, ContextVersion: version}, nil OptionKey: orderContext.MappingOptionKey, ConfidenceBPS: orderContext.MappingConfidenceBPS,
ConfidenceSet: orderContext.MappingConfidenceSet, ContextVersion: version}, nil
} }
choices, keys, names, err := pddOptionChoices(orderContext.PddSkusJSON) choices, keys, names, err := pddOptionChoices(orderContext.PddSkusJSON)
if err != nil { if err != nil {
@@ -289,7 +291,8 @@ func saveAutomaticMapping(db *sql.DB, actor model.User, original repository.SybO
return AISpecMatchResult{}, err return AISpecMatchResult{}, err
} }
return AISpecMatchResult{Outcome: outcome, Message: decision.Reason, Source: actualSource, return AISpecMatchResult{Outcome: outcome, Message: decision.Reason, Source: actualSource,
OptionKey: actual.PddOptionKey, ConfidenceBPS: actual.ConfidenceBPS, ContextVersion: decision.ContextVersion}, nil OptionKey: actual.PddOptionKey, ConfidenceBPS: actual.ConfidenceBPS,
ConfidenceSet: actual.ConfidenceSet, ContextVersion: decision.ContextVersion}, nil
} }
decision.Outcome = source + "_saved" decision.Outcome = source + "_saved"
decision.ChosenOptionKey = latestChoice.Key decision.ChosenOptionKey = latestChoice.Key
@@ -301,7 +304,8 @@ func saveAutomaticMapping(db *sql.DB, actor model.User, original repository.SybO
return AISpecMatchResult{}, err return AISpecMatchResult{}, err
} }
return AISpecMatchResult{Outcome: decision.Outcome, Message: "规格映射已保存", Source: source, return AISpecMatchResult{Outcome: decision.Outcome, Message: "规格映射已保存", Source: source,
OptionKey: latestChoice.Key, ConfidenceBPS: decision.ConfidenceBPS, ContextVersion: decision.ContextVersion}, nil OptionKey: latestChoice.Key, ConfidenceBPS: decision.ConfidenceBPS,
ConfidenceSet: decision.ConfidenceSet, ContextVersion: decision.ContextVersion}, nil
} }
func recordAIMatchWithoutSave(db *sql.DB, decision model.AISpecMatchDecision, outcome, reason string) (AISpecMatchResult, error) { func recordAIMatchWithoutSave(db *sql.DB, decision model.AISpecMatchDecision, outcome, reason string) (AISpecMatchResult, error) {
@@ -319,7 +323,7 @@ func recordAIMatchWithoutSave(db *sql.DB, decision model.AISpecMatchDecision, ou
return AISpecMatchResult{}, err return AISpecMatchResult{}, err
} }
return AISpecMatchResult{Outcome: outcome, Message: decision.Reason, ConfidenceBPS: decision.ConfidenceBPS, return AISpecMatchResult{Outcome: outcome, Message: decision.Reason, ConfidenceBPS: decision.ConfidenceBPS,
ContextVersion: decision.ContextVersion}, nil ConfidenceSet: decision.ConfidenceSet, ContextVersion: decision.ContextVersion}, nil
} }
func newAISpecDecision(c repository.SybOrderContext, actor model.User, snapshot AIMatchSnapshot, version string) model.AISpecMatchDecision { func newAISpecDecision(c repository.SybOrderContext, actor model.User, snapshot AIMatchSnapshot, version string) model.AISpecMatchDecision {
+1
View File
@@ -27,6 +27,7 @@ const (
var ( var (
ErrInvalidCredentials = errors.New("用户名或密码错误") ErrInvalidCredentials = errors.New("用户名或密码错误")
ErrUnauthenticated = errors.New("未登录或登录已过期") ErrUnauthenticated = errors.New("未登录或登录已过期")
ErrForbidden = errors.New("没有权限执行此操作")
// 用户不存在时也跑一次 bcrypt,避免响应时间直接泄露“这个用户名存在”。 // 用户不存在时也跑一次 bcrypt,避免响应时间直接泄露“这个用户名存在”。
dummyPasswordHash, _ = bcrypt.GenerateFromPassword([]byte("not-a-real-password"), bcrypt.DefaultCost) dummyPasswordHash, _ = bcrypt.GenerateFromPassword([]byte("not-a-real-password"), bcrypt.DefaultCost)
+66 -5
View File
@@ -1064,7 +1064,13 @@ type SybOrderView struct {
NeedsAttention bool NeedsAttention bool
CanCollect bool CanCollect bool
CanPurchase bool CanPurchase bool
CanAIMatch bool
SelectionActions string
SelectionHint string SelectionHint string
MappingSource string
MappingSourceText string
MappingSourceClass string
MappingSourceHelp string
DefaultUnitPrice string DefaultUnitPrice string
DefaultTotalPrice string DefaultTotalPrice string
MappedPddChoice string MappedPddChoice string
@@ -1081,6 +1087,7 @@ const (
SybStagePddCollectingStale = "pdd_collecting_stale" SybStagePddCollectingStale = "pdd_collecting_stale"
SybStagePddFailed = "pdd_failed" SybStagePddFailed = "pdd_failed"
SybStageMappingPending = "mapping_pending" SybStageMappingPending = "mapping_pending"
SybStageAIMatched = "ai_matched"
SybStagePurchaseReady = "purchase_ready" SybStagePurchaseReady = "purchase_ready"
SybStageTaskCreated = "task_created" SybStageTaskCreated = "task_created"
SybStagePurchaseCompleted = "purchase_completed" SybStagePurchaseCompleted = "purchase_completed"
@@ -1104,6 +1111,7 @@ func SybStageOptions() []SybStageOption {
{Value: SybStagePddCollectingStale, Text: "PDD 采集中(超时)"}, {Value: SybStagePddCollectingStale, Text: "PDD 采集中(超时)"},
{Value: SybStagePddFailed, Text: "PDD 采集失败"}, {Value: SybStagePddFailed, Text: "PDD 采集失败"},
{Value: SybStageMappingPending, Text: "规格待匹配"}, {Value: SybStageMappingPending, Text: "规格待匹配"},
{Value: SybStageAIMatched, Text: "AI规格匹配"},
{Value: SybStagePurchaseReady, Text: "可创建采购任务"}, {Value: SybStagePurchaseReady, Text: "可创建采购任务"},
{Value: SybStageTaskCreated, Text: "已创建采购任务"}, {Value: SybStageTaskCreated, Text: "已创建采购任务"},
{Value: SybStagePurchaseCompleted, Text: "采购完成"}, {Value: SybStagePurchaseCompleted, Text: "采购完成"},
@@ -1253,6 +1261,7 @@ func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybL
} }
if stage == SybStagePddCollecting || stage == SybStagePddCollectingStale || if stage == SybStagePddCollecting || stage == SybStagePddCollectingStale ||
stage == SybStageMappingPending || stage == SybStagePurchaseReady || stage == SybStageMappingPending || stage == SybStagePurchaseReady ||
stage == SybStageAIMatched ||
stage == SybStageTaskCreated || stage == SybStagePurchaseCompleted || stage == SybStageTaskCreated || stage == SybStagePurchaseCompleted ||
stage == SybStagePurchaseReview || stage == SybStagePurchaseBlocked { stage == SybStagePurchaseReview || stage == SybStagePurchaseBlocked {
return listAdvancedSybStage(db, orderNos, shop, stage, page) return listAdvancedSybStage(db, orderNos, shop, stage, page)
@@ -1311,7 +1320,11 @@ func listAdvancedSybStage(db *sql.DB, orderNos []string, shop, stage string, pag
filtered := make([]repository.SybOrderContext, 0) filtered := make([]repository.SybOrderContext, 0)
for _, context := range contexts { for _, context := range contexts {
value, _, _, _ := sybStageFor(context) value, _, _, _ := sybStageFor(context)
if value == stage { matches := value == stage
if stage == SybStageAIMatched {
matches = value == SybStagePurchaseReady && context.MappingSource == "ai" && mappingIsValid(context)
}
if matches {
filtered = append(filtered, context) filtered = append(filtered, context)
} }
} }
@@ -1378,14 +1391,46 @@ func sybOrderViewFor(context repository.SybOrderContext) SybOrderView {
v.CanCollect = v.Stage == SybStagePddPending || v.Stage == SybStagePddFailed || v.CanCollect = v.Stage == SybStagePddPending || v.Stage == SybStagePddFailed ||
v.Stage == SybStagePddCollectingStale v.Stage == SybStagePddCollectingStale
v.CanPurchase = v.Stage == SybStagePurchaseReady v.CanPurchase = v.Stage == SybStagePurchaseReady
v.CanAIMatch = v.Stage == SybStageMappingPending || v.Stage == SybStagePurchaseReady
actions := make([]string, 0, 3)
if v.CanCollect {
actions = append(actions, "collect")
}
if v.CanPurchase {
actions = append(actions, "purchase")
}
if v.CanAIMatch {
actions = append(actions, "ai")
}
v.SelectionActions = strings.Join(actions, " ")
switch { switch {
case v.CanCollect: case len(actions) > 0:
v.SelectionHint = "可批量创建 PDD 采集任务" labels := make([]string, 0, len(actions))
case v.CanPurchase: if v.CanCollect {
v.SelectionHint = "可批量创建采购任务" labels = append(labels, "创建采集")
}
if v.CanPurchase {
labels = append(labels, "创建采购")
}
if v.CanAIMatch {
labels = append(labels, "AI规格匹配")
}
v.SelectionHint = "可用于:" + strings.Join(labels, "、")
default: default:
v.SelectionHint = "当前阶段不能批量操作:" + v.StageHelp v.SelectionHint = "当前阶段不能批量操作:" + v.StageHelp
} }
if context.MappingOptionKey != "" {
v.MappingSource = context.MappingSource
if v.MappingSource == "" {
v.MappingSource = "manual"
}
v.MappingSourceText = mappingSourceText(v.MappingSource)
v.MappingSourceClass = "source-" + v.MappingSource
v.MappingSourceHelp = context.MappingReason
if !mappingIsValid(context) {
v.MappingSourceText += "(已失效)"
}
}
if v.CanPurchase { if v.CanPurchase {
v.MappingOptionKey = context.MappingOptionKey v.MappingOptionKey = context.MappingOptionKey
v.ContextVersion = mappingContextVersion(context) v.ContextVersion = mappingContextVersion(context)
@@ -1451,6 +1496,10 @@ type SybProcessingDetail struct {
RecommendationNotice string RecommendationNotice string
MappingValid bool MappingValid bool
HasActiveTask bool HasActiveTask bool
MappingSource string
MappingSourceText string
MappingSourceClass string
MappingSourceHelp string
} }
func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, error) { func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, error) {
@@ -1472,6 +1521,18 @@ func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, err
(d.Stage == SybStagePddPending || d.Stage == SybStagePddFailed || (d.Stage == SybStagePddPending || d.Stage == SybStagePddFailed ||
d.Stage == SybStagePddCollectingStale) d.Stage == SybStagePddCollectingStale)
d.HasActiveTask = context.HasActiveTask d.HasActiveTask = context.HasActiveTask
if context.MappingOptionKey != "" {
d.MappingSource = context.MappingSource
if d.MappingSource == "" {
d.MappingSource = "manual"
}
d.MappingSourceText = mappingSourceText(d.MappingSource)
d.MappingSourceClass = "source-" + d.MappingSource
d.MappingSourceHelp = context.MappingReason
if !mappingIsValid(*context) {
d.MappingSourceText += "(已失效)"
}
}
if context.PddCollectStatus == string(model.CollectCollected) && context.PddSkusJSON != "" { if context.PddCollectStatus == string(model.CollectCollected) && context.PddSkusJSON != "" {
choices, keys, names, parseErr := pddOptionChoices(context.PddSkusJSON) choices, keys, names, parseErr := pddOptionChoices(context.PddSkusJSON)
if parseErr == nil { if parseErr == nil {
+6 -2
View File
@@ -162,8 +162,12 @@ func TestSybStageFor_全部筛选阶段与逐行推导一致(t *testing.T) {
{SybStagePurchaseBlocked, "核对采购数量", copyWith(func(c *repository.SybOrderContext) { c.Order.Quantity = 0 })}, {SybStagePurchaseBlocked, "核对采购数量", copyWith(func(c *repository.SybOrderContext) { c.Order.Quantity = 0 })},
{SybStagePurchaseReady, "创建采购任务", ready}, {SybStagePurchaseReady, "创建采购任务", ready},
} }
if got := len(SybStageOptions()) - 1; got != len(cases) { // AI 规格匹配是映射来源筛选,不会取代订单当前的业务阶段。
t.Fatalf("筛选项有 %d 个业务阶段,但测试只覆盖 %d 个", got, len(cases)) if got := len(SybStageOptions()) - 1; got != len(cases)+1 {
t.Fatalf("筛选项有 %d 个业务阶段或来源筛选,但测试覆盖 %d 个业务阶段", got, len(cases))
}
if ParseSybStage(SybStageAIMatched) != SybStageAIMatched || SybStageLabel(SybStageAIMatched) != "AI规格匹配" {
t.Fatal("AI 规格匹配筛选项未正确注册")
} }
for _, tc := range cases { for _, tc := range cases {
stage, text, _, action := sybStageFor(tc.context) stage, text, _, action := sybStageFor(tc.context)
+16 -2
View File
@@ -208,8 +208,14 @@ body.syb-page-body > .page {
min-width: 0; min-width: 0;
} }
.syb-toolbar .syb-filter-form select { .syb-toolbar .syb-filter-form select {
width: 138px; width: 125px;
min-width: 138px; min-width: 125px;
}
.syb-toolbar input[type="text"].syb-search-input,
.syb-toolbar textarea.syb-search-input {
flex-basis: 110px;
width: 110px;
min-width: 110px;
} }
} }
/* 蝦皮工具条同时放商品和店铺两个搜索框,各占剩余工具条的约 20%。 /* 蝦皮工具条同时放商品和店铺两个搜索框,各占剩余工具条的约 20%。
@@ -609,6 +615,14 @@ input.wide { width: 100%; }
} }
.status-active { color: #1a7f37; background: #f0fff4; } .status-active { color: #1a7f37; background: #f0fff4; }
.status-disabled { color: #777; background: #f3f4f6; } .status-disabled { color: #777; background: #f3f4f6; }
.mapping-source { white-space: nowrap; font-size: 12px; }
.source-ai { color: #6f42c1; background: #f7f0ff; }
.source-rule { color: #0969da; background: #eef6ff; }
.source-manual { color: #57606a; background: #f6f8fa; }
.ai-match-result-modal progress { width: 100%; height: 16px; }
.ai-match-summary { margin: 0; }
.ai-match-items-wrap { max-height: min(420px, 52vh); overflow: auto; }
.ai-match-items-wrap th { position: sticky; top: 0; z-index: 1; }
select { select {
padding: 5px 8px; padding: 5px 8px;
+120
View File
@@ -626,6 +626,124 @@
}); });
} }
function setupAIMatchCreate() {
var openButton = document.querySelector("[data-ai-match-open]");
var form = document.getElementById("syb-ai-match-form");
var modal = document.getElementById("syb-ai-match-create-modal");
if (!openButton || !form || !modal) return;
var count = modal.querySelector("[data-ai-match-count]");
var submit = modal.querySelector("[data-ai-match-submit]");
openButton.addEventListener("click", function () {
form.querySelectorAll("input[data-ai-match-dynamic]").forEach(function (input) { input.remove(); });
var selected = [];
document.querySelectorAll("tbody input[name=ids]:checked:not(:disabled)").forEach(function (box) {
if (supportsSelectAction(box, "ai")) selected.push(box.value);
});
selected.forEach(function (id) {
var input = document.createElement("input");
input.type = "hidden";
input.name = "ids";
input.value = id;
input.setAttribute("data-ai-match-dynamic", "true");
form.appendChild(input);
});
if (count) count.textContent = String(selected.length);
if (submit) submit.disabled = selected.length === 0 || selected.length > 100;
});
form.addEventListener("submit", function () {
if (!submit) return;
submit.disabled = true;
submit.setAttribute("aria-busy", "true");
submit.textContent = "正在创建批次…";
});
}
function setupAIMatchBatch() {
var modal = document.querySelector("[data-ai-match-batch]");
if (!modal) return;
var fetchURL = modal.getAttribute("data-ai-match-fetch-url");
var refresh = modal.querySelector("[data-ai-match-refresh]");
var pageRefresh = modal.querySelector("[data-ai-match-page-refresh]");
var status = modal.querySelector("[data-ai-batch-status]");
var progress = modal.querySelector("[data-ai-batch-progress]");
var itemBody = modal.querySelector("[data-ai-batch-items]");
var errorBox = modal.querySelector("[data-ai-batch-error]");
var timer = null;
function setCount(name, value) {
var target = modal.querySelector('[data-ai-count="' + name + '"]');
if (target) target.textContent = String(value);
}
function render(data) {
if (status) status.textContent = data.status_text || data.status || "未知状态";
if (progress) {
progress.max = Math.max(1, Number(data.total) || 1);
progress.value = Number(data.processed) || 0;
progress.textContent = String(data.processed || 0) + " / " + String(data.total || 0);
}
["total", "processed", "success", "reused", "manual", "failed"].forEach(function (name) {
setCount(name, Number(data[name]) || 0);
});
if (errorBox) {
errorBox.textContent = data.error_message || "";
errorBox.hidden = !data.error_message;
}
if (itemBody) {
itemBody.textContent = "";
(data.items || []).forEach(function (item) {
var row = document.createElement("tr");
[item.syb_id, item.status_text, item.source_text || "—", item.confidence_text || "—", item.message || "—"].forEach(function (value) {
var cell = document.createElement("td");
cell.textContent = value || "—";
row.appendChild(cell);
});
itemBody.appendChild(row);
});
}
if (data.running) {
timer = window.setTimeout(load, 1500);
}
}
function load() {
if (!fetchURL || modal.getAttribute("aria-busy") === "true") return;
modal.setAttribute("aria-busy", "true");
if (refresh) {
refresh.disabled = true;
refresh.textContent = "刷新中…";
}
fetch(fetchURL, { cache: "no-store", headers: { "Accept": "application/json" } })
.then(function (response) {
if (!response.ok) throw new Error("HTTP " + response.status);
return response.json();
})
.then(render)
.catch(function (error) {
if (errorBox) {
errorBox.textContent = "刷新 AI 匹配进度失败:" + error.message + "。可以稍后重试。";
errorBox.hidden = false;
}
})
.finally(function () {
modal.removeAttribute("aria-busy");
if (refresh) {
refresh.disabled = false;
refresh.textContent = "刷新进度";
}
});
}
if (refresh) refresh.addEventListener("click", function () {
if (timer !== null) window.clearTimeout(timer);
load();
});
if (pageRefresh) pageRefresh.addEventListener("click", function () { window.location.reload(); });
load();
}
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
setupModuleNavigationState(); setupModuleNavigationState();
document.querySelectorAll("table").forEach(setupCheckAll); document.querySelectorAll("table").forEach(setupCheckAll);
@@ -644,6 +762,8 @@
setupSybSyncFeedback(); setupSybSyncFeedback();
setupSybHistoryRefresh(); setupSybHistoryRefresh();
setupSybOrderSearch(); setupSybOrderSearch();
setupAIMatchCreate();
setupAIMatchBatch();
syncButtons(); syncButtons();
}); });
+5 -2
View File
@@ -20,7 +20,8 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) {
SybID: "SYB-READY", OrderNo: "ORDER-READY", Title: "可采购商品", SybID: "SYB-READY", OrderNo: "ORDER-READY", Title: "可采购商品",
ImageURL: "https://example.test/original.jpg", Stage: service.SybStagePurchaseReady, ImageURL: "https://example.test/original.jpg", Stage: service.SybStagePurchaseReady,
StageText: "可创建采购任务", StageHelp: "规格映射有效", ActionText: "创建采购任务", StageText: "可创建采购任务", StageHelp: "规格映射有效", ActionText: "创建采购任务",
CanPurchase: true, Quantity: 2, DefaultUnitPrice: "11.80", DefaultTotalPrice: "23.60", CanPurchase: true, CanAIMatch: true, SelectionActions: "purchase ai",
Quantity: 2, DefaultUnitPrice: "11.80", DefaultTotalPrice: "23.60",
}, },
{ {
SybID: "SYB-TASK", OrderNo: "ORDER-TASK", Title: "已有任务商品", SybID: "SYB-TASK", OrderNo: "ORDER-TASK", Title: "已有任务商品",
@@ -47,7 +48,7 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) {
SybID: "SYB-COLLECT", OrderNo: "ORDER-COLLECT", Title: "待采集商品", SybID: "SYB-COLLECT", OrderNo: "ORDER-COLLECT", Title: "待采集商品",
Stage: service.SybStagePddPending, StageText: "PDD 待采集", Stage: service.SybStagePddPending, StageText: "PDD 待采集",
StageHelp: "尚未创建采集任务", ActionText: "创建采集任务", StageHelp: "尚未创建采集任务", ActionText: "创建采集任务",
CanCollect: true, SelectionHint: "可批量创建 PDD 采集任务", CanCollect: true, SelectionActions: "collect", SelectionHint: "可批量创建 PDD 采集任务",
}, },
} }
var output bytes.Buffer var output bytes.Buffer
@@ -82,8 +83,10 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) {
`action="/syb/collect-pdd-batch"`, `action="/syb/collect-pdd-batch"`,
`data-need-checked="collect"`, `data-need-checked="collect"`,
`data-need-checked="purchase"`, `data-need-checked="purchase"`,
`data-ai-match-open`,
`data-collect-action="collect"`, `data-collect-action="collect"`,
`data-select-action="collect"`, `data-select-action="collect"`,
`data-select-action="purchase ai"`,
`form="syb-collect-form"`, `form="syb-collect-form"`,
`id="syb-collect-modal"`, `id="syb-collect-modal"`,
`aria-label="创建采集任务">创建采集</button>`, `aria-label="创建采集任务">创建采集</button>`,
+3 -1
View File
@@ -6,7 +6,9 @@
</div> </div>
<div class="modal-body form-stack"> <div class="modal-body form-stack">
<p><span class="status-pill">{{.StageText}}</span></p> <p><span class="status-pill">{{.StageText}}</span>
{{if .MappingSourceText}}<span class="status-pill mapping-source {{.MappingSourceClass}}" title="{{.MappingSourceHelp}}">{{.MappingSourceText}}</span>{{end}}
</p>
<p class="hint">{{.StageHelp}}</p> <p class="hint">{{.StageHelp}}</p>
<dl class="detail"> <dl class="detail">
<dt>明细 ID</dt><dd>{{.SybID}}</dd> <dt>明细 ID</dt><dd>{{.SybID}}</dd>
+72 -3
View File
@@ -23,6 +23,20 @@
<button type="button" data-modal-open="sync-history-modal">同步记录</button> <button type="button" data-modal-open="sync-history-modal">同步记录</button>
<form id="syb-ai-match-form" method="post" action="/syb/ai-match" hidden>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="order_no" value="{{.Keyword}}">
<input type="hidden" name="shop" value="{{.ShopFilter}}">
<input type="hidden" name="stage" value="{{.StageFilter}}">
<input type="hidden" name="page" value="{{.CurrentPage}}">
<input type="hidden" name="date_from" value="{{.SyncDateFrom}}">
<input type="hidden" name="date_to" value="{{.SyncDateTo}}">
</form>
<button type="button" data-modal-open="syb-ai-match-create-modal" data-ai-match-open
data-need-checked="ai" aria-label="批量 AI 规格匹配">AI匹配</button>
{{if .LatestAIMatchBatchURL}}<a class="button-link" href="{{.LatestAIMatchBatchURL}}"
aria-label="查看 AI 规格匹配记录">匹配记录</a>{{end}}
<form id="syb-collect-form" method="post" action="/syb/collect-pdd-batch" hidden></form> <form id="syb-collect-form" method="post" action="/syb/collect-pdd-batch" hidden></form>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" form="syb-collect-form"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}" form="syb-collect-form">
<input type="hidden" name="order_no" value="{{.Keyword}}" form="syb-collect-form"> <input type="hidden" name="order_no" value="{{.Keyword}}" form="syb-collect-form">
@@ -111,6 +125,7 @@
<th>数量</th> <th>数量</th>
<th>价格</th> <th>价格</th>
<th>图片</th> <th>图片</th>
<th>匹配来源</th>
<th>下一步</th> <th>下一步</th>
<th>更新时间</th> <th>更新时间</th>
</tr> </tr>
@@ -121,9 +136,9 @@
<td class="col-check"> <td class="col-check">
<span title="{{.SelectionHint}}"> <span title="{{.SelectionHint}}">
<input type="checkbox" value="{{.SybID}}" name="ids" <input type="checkbox" value="{{.SybID}}" name="ids"
data-select-action="{{if .CanCollect}}collect{{else if .CanPurchase}}purchase{{end}}" data-select-action="{{.SelectionActions}}"
{{if .CanCollect}}form="syb-collect-form"{{end}} {{if .CanCollect}}form="syb-collect-form"{{end}}
{{if not (or .CanCollect .CanPurchase)}}disabled{{end}} {{if not .SelectionActions}}disabled{{end}}
aria-label="选择货运单明细 {{.SybID}}:{{.SelectionHint}}"> aria-label="选择货运单明细 {{.SybID}}:{{.SelectionHint}}">
</span> </span>
</td> </td>
@@ -147,6 +162,7 @@
</button> </button>
{{else}}—{{end}} {{else}}—{{end}}
</td> </td>
<td>{{if .MappingSourceText}}<span class="status-pill mapping-source {{.MappingSourceClass}}" title="{{.MappingSourceHelp}}">{{.MappingSourceText}}</span>{{else}}—{{end}}</td>
<td> <td>
{{if eq .Stage "purchase_ready"}} {{if eq .Stage "purchase_ready"}}
<button type="button" data-modal-open="purchase-modal" data-purchase-open <button type="button" data-modal-open="purchase-modal" data-purchase-open
@@ -163,7 +179,7 @@
</tr> </tr>
{{else}} {{else}}
<tr class="empty"> <tr class="empty">
<td colspan="11"> <td colspan="12">
{{if .IsFiltered}} {{if .IsFiltered}}
当前筛选条件下没有货运单明细。<br> 当前筛选条件下没有货运单明细。<br>
<small>换个店铺、订单号或清空筛选条件再试。<a href="/syb">查看全部</a></small> <small>换个店铺、订单号或清空筛选条件再试。<a href="/syb">查看全部</a></small>
@@ -178,6 +194,59 @@
</table> </table>
</div> </div>
<div class="modal-backdrop" id="syb-ai-match-create-modal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="syb-ai-match-create-title">
<div class="modal-head">
<h2 id="syb-ai-match-create-title">批量 AI 规格匹配</h2>
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
</div>
<div class="modal-body form-stack">
<p>将处理已选择的 <strong data-ai-match-count>0</strong> 条顺运宝商品,单批最多 100 条。</p>
<p class="hint">相同商品、SYB 规格和 PDD 上下文只调用一次;已有有效映射直接复用。条件不足或不确定的条目保留给人工处理。</p>
<p class="hint">此操作只保存规格映射,不创建采集任务、采购任务,也不会触发 Client 下单。</p>
</div>
<div class="modal-foot">
<button type="button" data-modal-close>取消</button>
<button type="submit" form="syb-ai-match-form" class="primary" data-ai-match-submit>开始匹配</button>
</div>
</div>
</div>
{{with .AIMatchBatch}}
<div class="modal-backdrop" id="syb-ai-match-result-modal" data-ai-match-batch
data-ai-match-fetch-url="{{$.AIMatchBatchFetchURL}}">
<div class="modal modal-wide ai-match-result-modal" role="dialog" aria-modal="true" aria-labelledby="syb-ai-match-result-title">
<div class="modal-head">
<h2 id="syb-ai-match-result-title">AI 规格匹配进度</h2>
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
</div>
<div class="modal-body form-stack">
<p><strong data-ai-batch-status>{{.StatusText}}</strong> · {{.ProviderName}} / {{.Model}}</p>
<progress data-ai-batch-progress max="{{.Total}}" value="{{.Processed}}">{{.Processed}} / {{.Total}}</progress>
<p class="ai-match-summary" aria-live="polite">
已处理 <strong data-ai-count="processed">{{.Processed}}</strong> / <span data-ai-count="total">{{.Total}}</span>;
成功 <strong data-ai-count="success">{{.Success}}</strong>;复用 <strong data-ai-count="reused">{{.Reused}}</strong>;
待人工 <strong data-ai-count="manual">{{.Manual}}</strong>;失败 <strong data-ai-count="failed">{{.Failed}}</strong>
</p>
<p class="missing" data-ai-batch-error {{if not .ErrorMessage}}hidden{{end}}>{{.ErrorMessage}}</p>
<div class="table-wrap ai-match-items-wrap">
<table>
<thead><tr><th>明细 ID</th><th>结果</th><th>来源</th><th>置信度</th><th>原因</th></tr></thead>
<tbody data-ai-batch-items>
{{range .Items}}<tr><td>{{.SybID}}</td><td>{{.StatusText}}</td><td>{{if .SourceText}}{{.SourceText}}{{else}}—{{end}}</td><td>{{if .ConfidenceText}}{{.ConfidenceText}}{{else}}—{{end}}</td><td>{{.Message}}</td></tr>{{end}}
</tbody>
</table>
</div>
</div>
<div class="modal-foot">
<button type="button" data-ai-match-refresh>刷新进度</button>
<button type="button" data-ai-match-page-refresh>刷新列表</button>
<button type="button" data-modal-close>关闭</button>
</div>
</div>
</div>
{{end}}
<div class="modal-backdrop" id="syb-collect-modal" hidden> <div class="modal-backdrop" id="syb-collect-modal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="syb-collect-modal-title"> <div class="modal" role="dialog" aria-modal="true" aria-labelledby="syb-collect-modal-title">
<div class="modal-head"> <div class="modal-head">
+1 -1
View File
@@ -144,7 +144,7 @@ syb:
|---|---| |---|---|
| 蝦皮数据 | 导入蝦皮商品报表,填 PDD 链接,发起采集 | | 蝦皮数据 | 导入蝦皮商品报表,填 PDD 链接,发起采集 |
| PDD 商品 | 维护拼多多商品档案,发起采集,查看采回来的规格价格。这个页面不依赖蝦皮和顺运宝的任何数据,单独就能跑通"建商品 → 建采集任务 → 领走执行 → 提交结果 → 显示已采集"这条闭环,见 [05 界面规范](05-ui-specification.md) §5 | | PDD 商品 | 维护拼多多商品档案,发起采集,查看采回来的规格价格。这个页面不依赖蝦皮和顺运宝的任何数据,单独就能跑通"建商品 → 建采集任务 → 领走执行 → 提交结果 → 显示已采集"这条闭环,见 [05 界面规范](05-ui-specification.md) §5 |
| 顺运宝数据 | 同步货运单(需要先配置 `config.yaml`,见上一节)。规格匹配和生成采购任务是后续工单的范围,本页暂不提供 | | 顺运宝数据 | 同步货运单、关联/采集 PDD、人工或批量 AI 规格匹配,并创建采购任务;顺运宝账号配置见上一节,AI 配置只由管理员维护 |
| 采集采购 | 看采集和采购任务执行到哪一步了 | | 采集采购 | 看采集和采购任务执行到哪一步了 |
| 客户端列表 | 看哪些客户端在干活 | | 客户端列表 | 看哪些客户端在干活 |
+7
View File
@@ -407,6 +407,13 @@ Admin 本地时区,付款状态只是 Client 核单上报时的快照,Admin
- API Key 不进入数据库、`data/`、日志或 HTTP 响应,保存后只显示固定掩码和尾四位。 - API Key 不进入数据库、`data/`、日志或 HTTP 响应,保存后只显示固定掩码和尾四位。
- 模型只能选择服务端提供的当前可购买候选;不确定、超时或硬校验失败时保持人工处理。 - 模型只能选择服务端提供的当前可购买候选;不确定、超时或硬校验失败时保持人工处理。
- AI 匹配不会创建采购任务,不改变 Client 接口,也不放宽真实采购门禁。 - AI 匹配不会创建采购任务,不改变 Client 接口,也不放宽真实采购门禁。
- 采购员在顺运宝列表勾选最多 100 条可匹配明细后创建批次;批次固定使用创建时的服务商、
模型和运行参数快照。相同蝦皮商品、顺运宝规格及 PDD 候选上下文在一个批次内只调用一次,
其余明细复用结果。
- 页面持续显示批次总数、已处理、成功、复用、待人工、失败和逐条原因。Admin 重启时把未完成
批次标为中断,已经保存的映射不回滚;操作员可以重新勾选未成功条目安全重试。
- “AI规格匹配”是当前有效映射的来源筛选,不替代“可创建采购任务”等业务阶段。人工修改后
当前来源立即变为人工,但历史 AI 决策和批次记录继续保留。
## 5. 创建采购任务的校验 ## 5. 创建采购任务的校验
+5
View File
@@ -268,6 +268,11 @@ AI 服务商的普通配置和非敏感审计由 `repository` 写入 MySQL。API
唯一确定的规则结果不调用模型;有效人工映射始终优先。保存前重新计算上下文版本并检查 唯一确定的规则结果不调用模型;有效人工映射始终优先。保存前重新计算上下文版本并检查
当前可购买候选,防止 PDD 重采集或人工并发修改后写入过期结果。 当前可购买候选,防止 PDD 重采集或人工并发修改后写入过期结果。
批量入口先在事务中创建 `ai_match_batches` 和逐条 `ai_match_batch_items`,再由 Admin 进程内
的有界工作池执行。工作池按业务上下文哈希归并相同明细,运行中持续写入逐条状态和汇总计数;
浏览器只轮询批次状态接口,不持有 API Key,也不承担匹配判断。Admin 启动时把遗留的
`queued/running` 批次和未完成明细标记为 `interrupted`,成功映射保持不变。
## 10. 相关文档 ## 10. 相关文档
- [上手指南](00-getting-started.md) - [上手指南](00-getting-started.md)
+15
View File
@@ -1006,3 +1006,18 @@ CREATE UNIQUE INDEX idx_client_assignment_current
`ai_provider_audits` 追加记录创建、修改、测试、启停以及密钥替换/清除动作,只保存字段 `ai_provider_audits` 追加记录创建、修改、测试、启停以及密钥替换/清除动作,只保存字段
名和非敏感状态。两张表都没有 API Key、Token 或 Secret 列;API Key 只存在部署指定的 名和非敏感状态。两张表都没有 API Key、Token 或 Secret 列;API Key 只存在部署指定的
独立密钥文件。 独立密钥文件。
## 16. AI 规格匹配批次(MySQL v22)
`ai_match_batches` 保存一次批量操作的创建人、状态、计数以及创建时的非敏感服务商配置
快照。状态为 `queued/running/partial/succeeded/failed/interrupted`,单批最多 100 条。
快照包含服务商名称、Base URL、模型、超时、并发、阈值和配置指纹,但绝不包含 API Key。
`ai_match_batch_items` 每条对应一个顺运宝明细,保存上下文版本、同批去重身份、负责人明细、
处理状态、结果来源、选项键、置信度和简短原因。`(batch_id, syb_id)` 唯一,保证一条明细在
同一批次内只出现一次。相同身份只有负责人调用规则/模型,跟随项读取已保存的共享映射并记为
复用;上下文变化时记为 `stale`,不得套用旧结果。
批次记录用于进度、故障恢复和操作审计;具体模型决策证据仍只追加到
`ai_spec_match_decisions`。Admin 重启把未完成明细改为 `interrupted` 并完成批次计数,已经
成功写入的 `spec_mappings` 不回滚。
+14
View File
@@ -917,6 +917,20 @@ API Key 使用密码输入框,只允许替换或清除。已保存值显示固
复制或下载明文的入口。修改普通配置或密钥后明确提示“需要重新测试”;测试失败保留上一 复制或下载明文的入口。修改普通配置或密钥后明确提示“需要重新测试”;测试失败保留上一
个已启用服务商,错误消息只说明原因和恢复动作,不展示响应正文。 个已启用服务商,错误消息只说明原因和恢复动作,不展示响应正文。
### 8.5 顺运宝批量 AI 规格匹配
顺运宝工具条提供“AI匹配”和“匹配记录”(无障碍名称使用完整业务名称)。只有处于规格待匹配或采购就绪、且 PDD
已经采集的行可用于该动作;复选框可以同时支持采购与 AI 匹配,按钮按各自能力单独计数。
确认弹窗显示本次条数、100 条上限和“不创建采购/不触发 Client”的边界。
提交后打开进度弹窗,使用文字和进度条同时显示状态,并列出成功、复用、待人工、失败计数
及逐条原因。运行中自动轮询,另提供“刷新进度”和“刷新列表”;明细表限制高度并可滚动,
1366×768 下操作按钮仍可见。采购员只能查看自己创建的记录,管理员可以查看全部。
列表增加“匹配来源”文字徽标:人工匹配、规则匹配、AI匹配。颜色只作辅助,来源必须有文字;
映射失效时追加“已失效”。“AI规格匹配”筛选只命中当前有效、来源为 AI、且仍处于采购就绪
阶段的明细,后续进入采购任务阶段后不再命中,但行和详情中的来源标记继续显示。
## 9. 反馈方式 ## 9. 反馈方式
| 场景 | 怎么反馈 | | 场景 | 怎么反馈 |
+6
View File
@@ -270,3 +270,9 @@ CMAutoBuyAdmin/
- 测试失败、超时和非 2xx 响应不得记录 Authorization、完整请求或完整响应。 - 测试失败、超时和非 2xx 响应不得记录 Authorization、完整请求或完整响应。
- 规格匹配请求只包含商品标题、规格文本和候选短编号,不包含订单号、店铺账号、用户、地址或 Client 信息。 - 规格匹配请求只包含商品标题、规格文本和候选短编号,不包含订单号、店铺账号、用户、地址或 Client 信息。
- 模型返回值必须经过严格 JSON 结构、候选白名单、颜色冲突、额外维度、置信度和上下文版本校验;模型自报置信度不能替代硬门禁。 - 模型返回值必须经过严格 JSON 结构、候选白名单、颜色冲突、额外维度、置信度和上下文版本校验;模型自报置信度不能替代硬门禁。
- 批量匹配用服务端限制单批 100 条和配置的有界并发;相同业务上下文只调用一次,跟随项必须
复用数据库中的当前有效映射,不能只复制模型文本结果。
- 批次和逐条状态必须持久化。Admin 异常退出或重启后,遗留 `queued/running` 状态改为
`interrupted`;成功映射不回滚,未完成条目允许重新勾选,且不能因为重试覆盖人工映射。
- 批次状态接口按创建人隔离,管理员除外;响应和日志只含批次号、计数和脱敏原因,不含密钥、
完整模型请求/响应或订单隐私数据。