Files
cmautobuy/admin/service/ai_match_batch_test.go

152 lines
6.1 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
func TestCreateAIMatchBatch_允许200条且拒绝201条(t *testing.T) {
db := newTestDB(t)
ids := make([]string, 0, MaxAIMatchBatchItems)
firstID := "SYB-LIMIT-001"
actor := seedAIMatchContext(t, db, firstID)
ids = append(ids, firstID)
placeholders := make([]string, 0, MaxAIMatchBatchItems-1)
args := make([]any, 0, (MaxAIMatchBatchItems-1)*10)
now := model.NowISO()
for index := 2; index <= MaxAIMatchBatchItems; index++ {
id := fmt.Sprintf("SYB-LIMIT-%03d", index)
ids = append(ids, id)
placeholders = append(placeholders, "(?,?,?,?,?,?,?,?,?,?)")
args = append(args, id, "ORDER-"+id, "测试商品", "SP-AI", "黑色,M", "黑色, M", 1, `{}`, now, now)
}
if _, err := db.Exec(`INSERT INTO syb_orders
(syb_id,order_no,title,shopee_goods_id,product_spec,spec_key,quantity,syb_data,created_at,updated_at)
VALUES `+strings.Join(placeholders, ","), args...); err != nil {
t.Fatalf("批量准备 200 条 AI 匹配测试明细失败: %v", err)
}
snapshot := testAIMatchSnapshot(&fakeAIModelClient{})
snapshot.Provider.TimeoutSeconds = 66
snapshot.Provider.MaxConcurrency = 4
batch, err := CreateAIMatchBatch(db, &actor, ids, snapshot, time.Now())
if err != nil {
t.Fatalf("200 条应允许创建 AI 批次: %v", err)
}
if batch.TotalCount != MaxAIMatchBatchItems {
t.Fatalf("AI 批次数量=%d,期望 %d", batch.TotalCount, MaxAIMatchBatchItems)
}
tooMany := append(append([]string(nil), ids...), "SYB-LIMIT-201")
if _, err := CreateAIMatchBatch(db, &actor, tooMany, snapshot, time.Now()); err == nil ||
!strings.Contains(err.Error(), "一次最多匹配 200 条商品") {
t.Fatalf("201 条应在建批次前明确拒绝,实际 %v", err)
}
}
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)
}
}