feat: 增加顺运宝批量 AI 规格匹配 (#202)
This commit is contained in:
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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") })
|
||||
|
||||
@@ -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 引擎
|
||||
|
||||
+5
-4
@@ -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)
|
||||
|
||||
@@ -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 区分采集任务和采购任务。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
+66
-5
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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="创建采集任务">创建采集</button>`,
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<dl class="detail">
|
||||
<dt>明细 ID</dt><dd>{{.SybID}}</dd>
|
||||
|
||||
@@ -23,6 +23,20 @@
|
||||
|
||||
<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>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" 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>
|
||||
</tr>
|
||||
@@ -121,9 +136,9 @@
|
||||
<td class="col-check">
|
||||
<span title="{{.SelectionHint}}">
|
||||
<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 not (or .CanCollect .CanPurchase)}}disabled{{end}}
|
||||
{{if not .SelectionActions}}disabled{{end}}
|
||||
aria-label="选择货运单明细 {{.SybID}}:{{.SelectionHint}}">
|
||||
</span>
|
||||
</td>
|
||||
@@ -147,6 +162,7 @@
|
||||
</button>
|
||||
{{else}}—{{end}}
|
||||
</td>
|
||||
<td>{{if .MappingSourceText}}<span class="status-pill mapping-source {{.MappingSourceClass}}" title="{{.MappingSourceHelp}}">{{.MappingSourceText}}</span>{{else}}—{{end}}</td>
|
||||
<td>
|
||||
{{if eq .Stage "purchase_ready"}}
|
||||
<button type="button" data-modal-open="purchase-modal" data-purchase-open
|
||||
@@ -163,7 +179,7 @@
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr class="empty">
|
||||
<td colspan="11">
|
||||
<td colspan="12">
|
||||
{{if .IsFiltered}}
|
||||
当前筛选条件下没有货运单明细。<br>
|
||||
<small>换个店铺、订单号或清空筛选条件再试。<a href="/syb">查看全部</a></small>
|
||||
@@ -178,6 +194,59 @@
|
||||
</table>
|
||||
</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" role="dialog" aria-modal="true" aria-labelledby="syb-collect-modal-title">
|
||||
<div class="modal-head">
|
||||
|
||||
@@ -144,7 +144,7 @@ syb:
|
||||
|---|---|
|
||||
| 蝦皮数据 | 导入蝦皮商品报表,填 PDD 链接,发起采集 |
|
||||
| PDD 商品 | 维护拼多多商品档案,发起采集,查看采回来的规格价格。这个页面不依赖蝦皮和顺运宝的任何数据,单独就能跑通"建商品 → 建采集任务 → 领走执行 → 提交结果 → 显示已采集"这条闭环,见 [05 界面规范](05-ui-specification.md) §5 |
|
||||
| 顺运宝数据 | 同步货运单(需要先配置 `config.yaml`,见上一节)。规格匹配和生成采购任务是后续工单的范围,本页暂不提供 |
|
||||
| 顺运宝数据 | 同步货运单、关联/采集 PDD、人工或批量 AI 规格匹配,并创建采购任务;顺运宝账号配置见上一节,AI 配置只由管理员维护 |
|
||||
| 采集采购 | 看采集和采购任务执行到哪一步了 |
|
||||
| 客户端列表 | 看哪些客户端在干活 |
|
||||
|
||||
|
||||
@@ -407,6 +407,13 @@ Admin 本地时区,付款状态只是 Client 核单上报时的快照,Admin
|
||||
- API Key 不进入数据库、`data/`、日志或 HTTP 响应,保存后只显示固定掩码和尾四位。
|
||||
- 模型只能选择服务端提供的当前可购买候选;不确定、超时或硬校验失败时保持人工处理。
|
||||
- AI 匹配不会创建采购任务,不改变 Client 接口,也不放宽真实采购门禁。
|
||||
- 采购员在顺运宝列表勾选最多 100 条可匹配明细后创建批次;批次固定使用创建时的服务商、
|
||||
模型和运行参数快照。相同蝦皮商品、顺运宝规格及 PDD 候选上下文在一个批次内只调用一次,
|
||||
其余明细复用结果。
|
||||
- 页面持续显示批次总数、已处理、成功、复用、待人工、失败和逐条原因。Admin 重启时把未完成
|
||||
批次标为中断,已经保存的映射不回滚;操作员可以重新勾选未成功条目安全重试。
|
||||
- “AI规格匹配”是当前有效映射的来源筛选,不替代“可创建采购任务”等业务阶段。人工修改后
|
||||
当前来源立即变为人工,但历史 AI 决策和批次记录继续保留。
|
||||
|
||||
## 5. 创建采购任务的校验
|
||||
|
||||
|
||||
@@ -268,6 +268,11 @@ AI 服务商的普通配置和非敏感审计由 `repository` 写入 MySQL。API
|
||||
唯一确定的规则结果不调用模型;有效人工映射始终优先。保存前重新计算上下文版本并检查
|
||||
当前可购买候选,防止 PDD 重采集或人工并发修改后写入过期结果。
|
||||
|
||||
批量入口先在事务中创建 `ai_match_batches` 和逐条 `ai_match_batch_items`,再由 Admin 进程内
|
||||
的有界工作池执行。工作池按业务上下文哈希归并相同明细,运行中持续写入逐条状态和汇总计数;
|
||||
浏览器只轮询批次状态接口,不持有 API Key,也不承担匹配判断。Admin 启动时把遗留的
|
||||
`queued/running` 批次和未完成明细标记为 `interrupted`,成功映射保持不变。
|
||||
|
||||
## 10. 相关文档
|
||||
|
||||
- [上手指南](00-getting-started.md)
|
||||
|
||||
@@ -1006,3 +1006,18 @@ CREATE UNIQUE INDEX idx_client_assignment_current
|
||||
`ai_provider_audits` 追加记录创建、修改、测试、启停以及密钥替换/清除动作,只保存字段
|
||||
名和非敏感状态。两张表都没有 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` 不回滚。
|
||||
|
||||
@@ -917,6 +917,20 @@ API Key 使用密码输入框,只允许替换或清除。已保存值显示固
|
||||
复制或下载明文的入口。修改普通配置或密钥后明确提示“需要重新测试”;测试失败保留上一
|
||||
个已启用服务商,错误消息只说明原因和恢复动作,不展示响应正文。
|
||||
|
||||
### 8.5 顺运宝批量 AI 规格匹配
|
||||
|
||||
顺运宝工具条提供“AI匹配”和“匹配记录”(无障碍名称使用完整业务名称)。只有处于规格待匹配或采购就绪、且 PDD
|
||||
已经采集的行可用于该动作;复选框可以同时支持采购与 AI 匹配,按钮按各自能力单独计数。
|
||||
确认弹窗显示本次条数、100 条上限和“不创建采购/不触发 Client”的边界。
|
||||
|
||||
提交后打开进度弹窗,使用文字和进度条同时显示状态,并列出成功、复用、待人工、失败计数
|
||||
及逐条原因。运行中自动轮询,另提供“刷新进度”和“刷新列表”;明细表限制高度并可滚动,
|
||||
1366×768 下操作按钮仍可见。采购员只能查看自己创建的记录,管理员可以查看全部。
|
||||
|
||||
列表增加“匹配来源”文字徽标:人工匹配、规则匹配、AI匹配。颜色只作辅助,来源必须有文字;
|
||||
映射失效时追加“已失效”。“AI规格匹配”筛选只命中当前有效、来源为 AI、且仍处于采购就绪
|
||||
阶段的明细,后续进入采购任务阶段后不再命中,但行和详情中的来源标记继续显示。
|
||||
|
||||
## 9. 反馈方式
|
||||
|
||||
| 场景 | 怎么反馈 |
|
||||
|
||||
@@ -270,3 +270,9 @@ CMAutoBuyAdmin/
|
||||
- 测试失败、超时和非 2xx 响应不得记录 Authorization、完整请求或完整响应。
|
||||
- 规格匹配请求只包含商品标题、规格文本和候选短编号,不包含订单号、店铺账号、用户、地址或 Client 信息。
|
||||
- 模型返回值必须经过严格 JSON 结构、候选白名单、颜色冲突、额外维度、置信度和上下文版本校验;模型自报置信度不能替代硬门禁。
|
||||
- 批量匹配用服务端限制单批 100 条和配置的有界并发;相同业务上下文只调用一次,跟随项必须
|
||||
复用数据库中的当前有效映射,不能只复制模型文本结果。
|
||||
- 批次和逐条状态必须持久化。Admin 异常退出或重启后,遗留 `queued/running` 状态改为
|
||||
`interrupted`;成功映射不回滚,未完成条目允许重新勾选,且不能因为重试覆盖人工映射。
|
||||
- 批次状态接口按创建人隔离,管理员除外;响应和日志只含批次号、计数和脱敏原因,不含密钥、
|
||||
完整模型请求/响应或订单隐私数据。
|
||||
|
||||
Reference in New Issue
Block a user