diff --git a/admin/handler/web/ai_match.go b/admin/handler/web/ai_match.go new file mode 100644 index 0000000..f644115 --- /dev/null +++ b/admin/handler/web/ai_match.go @@ -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()) +} diff --git a/admin/handler/web/others.go b/admin/handler/web/others.go index d95840c..2e279f2 100644 --- a/admin/handler/web/others.go +++ b/admin/handler/web/others.go @@ -179,6 +179,46 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, 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() switch { case cfgErr != nil: @@ -238,6 +278,19 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, "AssignableClients": purchaseClients.Rows, "PurchaseClientCount": purchaseClients.SelectableCount, "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) + }(), })) } diff --git a/admin/handler/web/web.go b/admin/handler/web/web.go index 3d7f2ef..0b1daab 100644 --- a/admin/handler/web/web.go +++ b/admin/handler/web/web.go @@ -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-batch", h.SybCollectPddBatch) 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/delete", h.SybDelete) pages.GET("/syb/shops", AdminRequired(), func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/shops") }) diff --git a/admin/main.go b/admin/main.go index e45d0e4..8ca9f9e 100644 --- a/admin/main.go +++ b/admin/main.go @@ -71,6 +71,13 @@ func main() { if interrupted > 0 { 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("数据库已就绪") // 3. Web 引擎 diff --git a/admin/main_test.go b/admin/main_test.go index e831f4d..b9dbb3b 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -132,15 +132,16 @@ func TestPurchaseModal_未选规格行保持隐藏且不提交(t *testing.T) { func TestSybBulkActions_按服务端动作能力隔离勾选项(t *testing.T) { files := map[string][]string{ "templates/syb/list.html": { - `data-need-checked="collect"`, `data-need-checked="purchase"`, - `data-select-action="{{if .CanCollect}}collect{{else if .CanPurchase}}purchase{{end}}"`, + `data-need-checked="collect"`, `data-need-checked="purchase"`, `data-ai-match-open`, + `data-select-action="{{.SelectionActions}}"`, `{{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": { `supportsSelectAction(box, action)`, `checkedCount(action)`, `supportsSelectAction(box, "purchase")`, + `supportsSelectAction(box, "ai")`, `getAttribute("data-collect-action")`, }, "handler/web/web.go": { @@ -338,7 +339,7 @@ func TestSybPage_紧凑筛选和可见横向滚动(t *testing.T) { `class="table-wrap syb-table-wrap"`, `class="syb-col-shop"`, `class="syb-col-spec"`, - `colspan="11"`, + `colspan="12"`, } { if !strings.Contains(pageText, want) { t.Errorf("顺运宝紧凑列表缺少 %q", want) diff --git a/admin/model/model.go b/admin/model/model.go index 37119eb..3ea8423 100644 --- a/admin/model/model.go +++ b/admin/model/model.go @@ -407,6 +407,26 @@ type AISpecMatchDecision struct { 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 区分采集任务和采购任务。 diff --git a/admin/repository/ai_match_batch.go b/admin/repository/ai_match_batch.go new file mode 100644 index 0000000..b9603af --- /dev/null +++ b/admin/repository/ai_match_batch.go @@ -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) +} diff --git a/admin/repository/mysql_db.go b/admin/repository/mysql_db.go index 49c79b4..5248d70 100644 --- a/admin/repository/mysql_db.go +++ b/admin/repository/mysql_db.go @@ -20,7 +20,7 @@ import ( "cmautobuy/admin/spec" ) -const mysqlSchemaVersion = 21 +const mysqlSchemaVersion = 22 // OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。 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 { 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) } +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 决策审计。 func migrateMySQLV21(db *sql.DB) error { columns := []struct{ name, ddl string }{ @@ -1944,6 +2009,7 @@ func CheckMySQLSchema(db *sql.DB) error { "syb_allowed_shops", "ai_provider_configs", "ai_provider_audits", "ai_spec_match_decisions", + "ai_match_batches", "ai_match_batch_items", } if err := checkMySQLSchema(db, mysqlRequiredTables); err != nil { return err @@ -2002,7 +2068,37 @@ func CheckMySQLSchema(db *sql.DB) error { if err := checkMySQLV20Shape(db); err != nil { 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 { diff --git a/admin/repository/syb.go b/admin/repository/syb.go index 5c4b6a3..4d54f60 100644 --- a/admin/repository/syb.go +++ b/admin/repository/syb.go @@ -590,6 +590,7 @@ type SybOrderContext struct { MappingProviderID string MappingModel string MappingConfidenceBPS int + MappingConfidenceSet bool MappingReason string MappingSourceVersion string MappingContextVersion string @@ -639,6 +640,7 @@ func scanSybOrderContext(s rowScanner) (SybOrderContext, error) { c.MappingProviderID = mappingProviderID.String c.MappingModel = mappingModel.String c.MappingConfidenceBPS = int(mappingConfidence.Int64) + c.MappingConfidenceSet = mappingConfidence.Valid c.MappingReason = mappingReason.String c.MappingSourceVersion = mappingSourceVersion.String c.MappingContextVersion = mappingContextVersion.String diff --git a/admin/service/ai_match_batch.go b/admin/service/ai_match_batch.go new file mode 100644 index 0000000..04af51e --- /dev/null +++ b/admin/service/ai_match_batch.go @@ -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] +} diff --git a/admin/service/ai_match_batch_test.go b/admin/service/ai_match_batch_test.go new file mode 100644 index 0000000..5b9f460 --- /dev/null +++ b/admin/service/ai_match_batch_test.go @@ -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) + } +} diff --git a/admin/service/ai_specmatch.go b/admin/service/ai_specmatch.go index f1f55aa..f9e0d01 100644 --- a/admin/service/ai_specmatch.go +++ b/admin/service/ai_specmatch.go @@ -33,6 +33,7 @@ type AISpecMatchResult struct { Source string OptionKey string ConfidenceBPS int + ConfidenceSet bool ContextVersion string ModelCalled bool } @@ -98,7 +99,8 @@ func MatchSybSpecWithAI(ctx context.Context, db *sql.DB, actor *model.User, snap source = "manual" } 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) if err != nil { @@ -289,7 +291,8 @@ func saveAutomaticMapping(db *sql.DB, actor model.User, original repository.SybO return AISpecMatchResult{}, err } 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.ChosenOptionKey = latestChoice.Key @@ -301,7 +304,8 @@ func saveAutomaticMapping(db *sql.DB, actor model.User, original repository.SybO return AISpecMatchResult{}, err } 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) { @@ -319,7 +323,7 @@ func recordAIMatchWithoutSave(db *sql.DB, decision model.AISpecMatchDecision, ou return AISpecMatchResult{}, err } 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 { diff --git a/admin/service/auth.go b/admin/service/auth.go index e03e6fa..965728d 100644 --- a/admin/service/auth.go +++ b/admin/service/auth.go @@ -27,6 +27,7 @@ const ( var ( ErrInvalidCredentials = errors.New("用户名或密码错误") ErrUnauthenticated = errors.New("未登录或登录已过期") + ErrForbidden = errors.New("没有权限执行此操作") // 用户不存在时也跑一次 bcrypt,避免响应时间直接泄露“这个用户名存在”。 dummyPasswordHash, _ = bcrypt.GenerateFromPassword([]byte("not-a-real-password"), bcrypt.DefaultCost) diff --git a/admin/service/syb.go b/admin/service/syb.go index a6d6de0..0922691 100644 --- a/admin/service/syb.go +++ b/admin/service/syb.go @@ -1064,7 +1064,13 @@ type SybOrderView struct { NeedsAttention bool CanCollect bool CanPurchase bool + CanAIMatch bool + SelectionActions string SelectionHint string + MappingSource string + MappingSourceText string + MappingSourceClass string + MappingSourceHelp string DefaultUnitPrice string DefaultTotalPrice string MappedPddChoice string @@ -1081,6 +1087,7 @@ const ( SybStagePddCollectingStale = "pdd_collecting_stale" SybStagePddFailed = "pdd_failed" SybStageMappingPending = "mapping_pending" + SybStageAIMatched = "ai_matched" SybStagePurchaseReady = "purchase_ready" SybStageTaskCreated = "task_created" SybStagePurchaseCompleted = "purchase_completed" @@ -1104,6 +1111,7 @@ func SybStageOptions() []SybStageOption { {Value: SybStagePddCollectingStale, Text: "PDD 采集中(超时)"}, {Value: SybStagePddFailed, Text: "PDD 采集失败"}, {Value: SybStageMappingPending, Text: "规格待匹配"}, + {Value: SybStageAIMatched, Text: "AI规格匹配"}, {Value: SybStagePurchaseReady, Text: "可创建采购任务"}, {Value: SybStageTaskCreated, 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 || stage == SybStageMappingPending || stage == SybStagePurchaseReady || + stage == SybStageAIMatched || stage == SybStageTaskCreated || stage == SybStagePurchaseCompleted || stage == SybStagePurchaseReview || stage == SybStagePurchaseBlocked { 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) for _, context := range contexts { 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) } } @@ -1378,14 +1391,46 @@ func sybOrderViewFor(context repository.SybOrderContext) SybOrderView { v.CanCollect = v.Stage == SybStagePddPending || v.Stage == SybStagePddFailed || v.Stage == SybStagePddCollectingStale 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 { - case v.CanCollect: - v.SelectionHint = "可批量创建 PDD 采集任务" - case v.CanPurchase: - v.SelectionHint = "可批量创建采购任务" + case len(actions) > 0: + labels := make([]string, 0, len(actions)) + if v.CanCollect { + labels = append(labels, "创建采集") + } + if v.CanPurchase { + labels = append(labels, "创建采购") + } + if v.CanAIMatch { + labels = append(labels, "AI规格匹配") + } + v.SelectionHint = "可用于:" + strings.Join(labels, "、") default: 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 { v.MappingOptionKey = context.MappingOptionKey v.ContextVersion = mappingContextVersion(context) @@ -1451,6 +1496,10 @@ type SybProcessingDetail struct { RecommendationNotice string MappingValid bool HasActiveTask bool + MappingSource string + MappingSourceText string + MappingSourceClass string + MappingSourceHelp string } 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 == SybStagePddCollectingStale) 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 != "" { choices, keys, names, parseErr := pddOptionChoices(context.PddSkusJSON) if parseErr == nil { diff --git a/admin/service/syb_workflow_test.go b/admin/service/syb_workflow_test.go index 4e42659..892b73e 100644 --- a/admin/service/syb_workflow_test.go +++ b/admin/service/syb_workflow_test.go @@ -162,8 +162,12 @@ func TestSybStageFor_全部筛选阶段与逐行推导一致(t *testing.T) { {SybStagePurchaseBlocked, "核对采购数量", copyWith(func(c *repository.SybOrderContext) { c.Order.Quantity = 0 })}, {SybStagePurchaseReady, "创建采购任务", ready}, } - if got := len(SybStageOptions()) - 1; got != len(cases) { - t.Fatalf("筛选项有 %d 个业务阶段,但测试只覆盖 %d 个", got, len(cases)) + // AI 规格匹配是映射来源筛选,不会取代订单当前的业务阶段。 + 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 { stage, text, _, action := sybStageFor(tc.context) diff --git a/admin/static/css/app.css b/admin/static/css/app.css index 0bc4007..a0afba4 100644 --- a/admin/static/css/app.css +++ b/admin/static/css/app.css @@ -208,8 +208,14 @@ body.syb-page-body > .page { min-width: 0; } .syb-toolbar .syb-filter-form select { - width: 138px; - min-width: 138px; + width: 125px; + 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%。 @@ -609,6 +615,14 @@ input.wide { width: 100%; } } .status-active { color: #1a7f37; background: #f0fff4; } .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 { padding: 5px 8px; diff --git a/admin/static/js/app.js b/admin/static/js/app.js index 1d00b07..49a7a1d 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -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 () { setupModuleNavigationState(); document.querySelectorAll("table").forEach(setupCheckAll); @@ -644,6 +762,8 @@ setupSybSyncFeedback(); setupSybHistoryRefresh(); setupSybOrderSearch(); + setupAIMatchCreate(); + setupAIMatchBatch(); syncButtons(); }); diff --git a/admin/syb_interaction_template_test.go b/admin/syb_interaction_template_test.go index ec6f306..5368f83 100644 --- a/admin/syb_interaction_template_test.go +++ b/admin/syb_interaction_template_test.go @@ -20,7 +20,8 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) { SybID: "SYB-READY", OrderNo: "ORDER-READY", Title: "可采购商品", ImageURL: "https://example.test/original.jpg", Stage: service.SybStagePurchaseReady, 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: "已有任务商品", @@ -47,7 +48,7 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) { SybID: "SYB-COLLECT", OrderNo: "ORDER-COLLECT", Title: "待采集商品", Stage: service.SybStagePddPending, StageText: "PDD 待采集", StageHelp: "尚未创建采集任务", ActionText: "创建采集任务", - CanCollect: true, SelectionHint: "可批量创建 PDD 采集任务", + CanCollect: true, SelectionActions: "collect", SelectionHint: "可批量创建 PDD 采集任务", }, } var output bytes.Buffer @@ -82,8 +83,10 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) { `action="/syb/collect-pdd-batch"`, `data-need-checked="collect"`, `data-need-checked="purchase"`, + `data-ai-match-open`, `data-collect-action="collect"`, `data-select-action="collect"`, + `data-select-action="purchase ai"`, `form="syb-collect-form"`, `id="syb-collect-modal"`, `aria-label="创建采集任务">创建采集`, diff --git a/admin/templates/syb/detail_modal.html b/admin/templates/syb/detail_modal.html index 1540e19..2da4083 100644 --- a/admin/templates/syb/detail_modal.html +++ b/admin/templates/syb/detail_modal.html @@ -6,7 +6,9 @@
{{.StageText}}
+{{.StageText}} + {{if .MappingSourceText}}{{.MappingSourceText}}{{end}} +
{{.StageHelp}}