feat: 扩展分页和 AI 匹配到 200 条 (#308)
This commit is contained in:
@@ -285,6 +285,7 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
|
||||
"EnabledSyncShopCount": enabledShopCount,
|
||||
"AIMatchBatch": aiBatch,
|
||||
"AIMatchError": strings.TrimSpace(c.Query("ai_error")),
|
||||
"AIMatchMaxItems": service.MaxAIMatchBatchItems,
|
||||
"AIMatchBatchFetchURL": func() string {
|
||||
if aiBatch == nil {
|
||||
return ""
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"cmautobuy/admin/spec"
|
||||
)
|
||||
|
||||
const mysqlSchemaVersion = 30
|
||||
const mysqlSchemaVersion = 31
|
||||
|
||||
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
||||
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
|
||||
@@ -781,9 +781,49 @@ func MigrateMySQL(db *sql.DB) error {
|
||||
return fmt.Errorf("记录 MySQL schema v30 失败: %w", err)
|
||||
}
|
||||
}
|
||||
if current < 31 {
|
||||
if err := migrateMySQLV31(db); err != nil {
|
||||
return fmt.Errorf("执行 MySQL schema v31 失败: %w", err)
|
||||
}
|
||||
if err := checkMySQLV31Shape(db); err != nil {
|
||||
return fmt.Errorf("MySQL schema v31 自检失败,未记录版本: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 31, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
return fmt.Errorf("记录 MySQL schema v31 失败: %w", err)
|
||||
}
|
||||
}
|
||||
return CheckMySQLSchema(db)
|
||||
}
|
||||
|
||||
// migrateMySQLV31 把顺运宝 AI 规格匹配单批总量从 100 放宽到 200。
|
||||
// MySQL DDL 会隐式提交,因此先读当前表达式:已经正确就直接返回;约束缺失或仍为
|
||||
// 旧值时重建。即使删除后新增失败,下次启动也能从“约束缺失”继续补齐。
|
||||
func migrateMySQLV31(db *sql.DB) error {
|
||||
clause, exists, err := mysqlCheckConstraintClause(db, "ai_match_batches", "chk_ai_match_batch_total")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists && aiMatchBatchTotalConstraintIsV31(clause) {
|
||||
return nil
|
||||
}
|
||||
if exists {
|
||||
if _, err := db.Exec(`ALTER TABLE ai_match_batches DROP CHECK chk_ai_match_batch_total`); err != nil {
|
||||
return fmt.Errorf("删除 AI 匹配批次旧总量约束失败: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := db.Exec(`ALTER TABLE ai_match_batches ADD CONSTRAINT chk_ai_match_batch_total
|
||||
CHECK (total_count BETWEEN 1 AND 200)`); err != nil {
|
||||
return fmt.Errorf("增加 AI 匹配批次 200 条总量约束失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func aiMatchBatchTotalConstraintIsV31(clause string) bool {
|
||||
normalized := strings.ToLower(clause)
|
||||
normalized = strings.NewReplacer("`", "", " ", "", "\t", "", "\r", "", "\n", "", "(", "", ")", "").Replace(normalized)
|
||||
return normalized == "total_countbetween1and200"
|
||||
}
|
||||
|
||||
// migrateMySQLV30 建立商品级颜色映射当前态和追加式审计。
|
||||
// PDD 商品 ID 属于映射身份的一部分,换商品后旧映射仍保留但不会被当前链路读取。
|
||||
func migrateMySQLV30(db *sql.DB) error {
|
||||
@@ -2468,7 +2508,24 @@ func CheckMySQLSchema(db *sql.DB) error {
|
||||
if err := checkMySQLV29Shape(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkMySQLV30Shape(db)
|
||||
if err := checkMySQLV30Shape(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkMySQLV31Shape(db)
|
||||
}
|
||||
|
||||
func checkMySQLV31Shape(db *sql.DB) error {
|
||||
clause, exists, err := mysqlCheckConstraintClause(db, "ai_match_batches", "chk_ai_match_batch_total")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("AI 匹配批次总量约束 chk_ai_match_batch_total 缺失")
|
||||
}
|
||||
if !aiMatchBatchTotalConstraintIsV31(clause) {
|
||||
return fmt.Errorf("AI 匹配批次总量约束表达式不正确: %s", clause)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkMySQLV30Shape(db *sql.DB) error {
|
||||
|
||||
@@ -1304,6 +1304,63 @@ func TestMySQLMigrate_V29升级V30建立商品颜色映射与审计(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIMatchBatchTotalConstraintIsV31_兼容MySQL表达式格式(t *testing.T) {
|
||||
for _, clause := range []string{
|
||||
"(`total_count` between 1 and 200)",
|
||||
"((TOTAL_COUNT BETWEEN 1 AND 200))",
|
||||
" total_count\nBETWEEN\t1 AND 200 ",
|
||||
} {
|
||||
if !aiMatchBatchTotalConstraintIsV31(clause) {
|
||||
t.Fatalf("应识别为 v31 约束: %q", clause)
|
||||
}
|
||||
}
|
||||
for _, clause := range []string{
|
||||
"total_count between 1 and 100",
|
||||
"total_count between 0 and 200",
|
||||
"total_count between 1 and 201",
|
||||
"total_count between 1 and 200 or total_count=999",
|
||||
} {
|
||||
if aiMatchBatchTotalConstraintIsV31(clause) {
|
||||
t.Fatalf("不应识别为 v31 约束: %q", clause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLMigrate_V30升级V31放宽AI匹配批次到200条(t *testing.T) {
|
||||
db := openMySQLMigrationTestDB(t)
|
||||
defer db.Close()
|
||||
cleanMySQLTestSchema(t, db)
|
||||
defer cleanMySQLTestSchema(t, db)
|
||||
if err := MigrateMySQL(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustExec(t, db, `ALTER TABLE ai_match_batches DROP CHECK chk_ai_match_batch_total`)
|
||||
mustExec(t, db, `ALTER TABLE ai_match_batches ADD CONSTRAINT chk_ai_match_batch_total CHECK (total_count BETWEEN 1 AND 100)`)
|
||||
mustExec(t, db, `DELETE FROM schema_migrations WHERE version=31`)
|
||||
if err := MigrateMySQL(db); err != nil {
|
||||
t.Fatalf("v30 升级 v31 失败: %v", err)
|
||||
}
|
||||
if err := MigrateMySQL(db); err != nil {
|
||||
t.Fatalf("v31 重复迁移失败: %v", err)
|
||||
}
|
||||
if err := checkMySQLV31Shape(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := "2026-08-26T08:00:00Z"
|
||||
mustExec(t, db, `INSERT INTO users(user_id,username,password_hash,role,status,password_changed_at,created_at,updated_at)
|
||||
VALUES('ai-limit-user','ai-limit-user','hash','purchaser','active',?,?,?)`, now, now, now)
|
||||
insertBatch := `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(?,'queued','ai-limit-user',?,'provider','测试服务商','https://example.test','model',66,4,
|
||||
8000,?,'rules-v1','prompt-v1',?)`
|
||||
mustExec(t, db, insertBatch, "AIM-LIMIT-200", 200, strings.Repeat("a", 64), now)
|
||||
if _, err := db.Exec(insertBatch, "AIM-LIMIT-201", 201, strings.Repeat("b", 64), now); err == nil {
|
||||
t.Fatal("v31 数据库约束必须拒绝 201 条 AI 匹配批次")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertInnerCodeImportRow_软删除记录按状态安全恢复(t *testing.T) {
|
||||
db := openMySQLMigrationTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
const MaxAIMatchBatchItems = 100
|
||||
const MaxAIMatchBatchItems = 200
|
||||
|
||||
type AIMatchBatchView struct {
|
||||
BatchID string `json:"batch_id"`
|
||||
|
||||
@@ -3,6 +3,8 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -10,6 +12,44 @@ import (
|
||||
"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"},
|
||||
|
||||
@@ -14,11 +14,12 @@ import (
|
||||
|
||||
// DefaultPageSize 是主列表没有指定 page_size 时使用的每页条数。
|
||||
//
|
||||
// 采购员通常需要在同一页批量勾选较多记录,因此默认显示白名单中的最大值 100;
|
||||
// 采购员通常需要在同一页批量勾选较多记录,因此默认显示 100;200 只作为
|
||||
// 主动选择的高密度档位,避免所有页面默认承担双倍渲染开销。
|
||||
// 实际页面请求仍必须通过 ParsePageSize 白名单解析,不能把任意输入交给 SQL。
|
||||
const DefaultPageSize = 100
|
||||
|
||||
var allowedPageSizes = [...]int{20, 50, 100}
|
||||
var allowedPageSizes = [...]int{20, 50, 100, 200}
|
||||
|
||||
// ParsePage 解析 URL 上的 page 查询参数。
|
||||
//
|
||||
@@ -33,7 +34,7 @@ func ParsePage(s string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// ParsePageSize 解析 URL 上的 page_size,只接受界面提供的三个白名单值。
|
||||
// ParsePageSize 解析 URL 上的 page_size,只接受界面提供的四个白名单值。
|
||||
// 非法值回退为默认 100;SQL 查询仍只可能使用固定白名单值。
|
||||
func ParsePageSize(s string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
|
||||
@@ -21,13 +21,13 @@ func TestParsePage_合法值原样返回(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParsePageSize_只接受白名单(t *testing.T) {
|
||||
for _, in := range []string{"20", "50", "100", " 50 "} {
|
||||
want := map[string]int{"20": 20, "50": 50, "100": 100, " 50 ": 50}[in]
|
||||
for _, in := range []string{"20", "50", "100", "200", " 50 "} {
|
||||
want := map[string]int{"20": 20, "50": 50, "100": 100, "200": 200, " 50 ": 50}[in]
|
||||
if got := ParsePageSize(in); got != want {
|
||||
t.Errorf("ParsePageSize(%q) = %d,想要 %d", in, got, want)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"", "0", "21", "200", "abc"} {
|
||||
for _, in := range []string{"", "0", "21", "201", "abc"} {
|
||||
if got := ParsePageSize(in); got != DefaultPageSize {
|
||||
t.Errorf("ParsePageSize(%q) = %d,非法值应回退 %d", in, got, DefaultPageSize)
|
||||
}
|
||||
@@ -68,6 +68,23 @@ func TestTotalPages_按选择的每页条数计算(t *testing.T) {
|
||||
if got := TotalPages(101, 100); got != 2 {
|
||||
t.Errorf("TotalPages(101, 100) = %d,想要 2", got)
|
||||
}
|
||||
if got := TotalPages(201, 200); got != 2 {
|
||||
t.Errorf("TotalPages(201, 200) = %d,想要 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPaginationView_提供200条且切换容量仍只保留筛选(t *testing.T) {
|
||||
v := NewPaginationView(1, 200, 3, "status=ready&page=2&page_size=100")
|
||||
if v.PageSize != 200 || len(v.PageSizeOptions) != 4 || !v.PageSizeOptions[3].Selected ||
|
||||
v.PageSizeOptions[3].Value != 200 {
|
||||
t.Fatalf("200 条分页选项不正确: %+v", v)
|
||||
}
|
||||
if len(v.FilterFields) != 1 || v.FilterFields[0].Name != "status" || v.FilterFields[0].Value != "ready" {
|
||||
t.Fatalf("切换到 200 条应移除旧 page/page_size 并保留筛选: %+v", v.FilterFields)
|
||||
}
|
||||
if v.NextURL != "?page_size=200&status=ready&page=2" {
|
||||
t.Fatalf("200 条导航链接没有保留归一化容量: %q", v.NextURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampPage_越界兜到最后一页(t *testing.T) {
|
||||
|
||||
+14
-2
@@ -93,7 +93,7 @@
|
||||
}
|
||||
|
||||
/* ── 每页条数立即生效 ─────────────────────────
|
||||
采购员选择 20/50/100 后直接提交 GET 表单。select 不能禁用,否则浏览器
|
||||
采购员选择 20/50/100/200 后直接提交 GET 表单。select 不能禁用,否则浏览器
|
||||
提交时会丢掉 page_size。 */
|
||||
function setupPageSizeForms() {
|
||||
document.querySelectorAll("[data-page-size-form]").forEach(function (form) {
|
||||
@@ -807,6 +807,8 @@
|
||||
if (!openButton || !form || !modal) return;
|
||||
var count = modal.querySelector("[data-ai-match-count]");
|
||||
var submit = modal.querySelector("[data-ai-match-submit]");
|
||||
var limitError = modal.querySelector("[data-ai-match-limit-error]");
|
||||
var maxItems = Number(modal.getAttribute("data-ai-match-max-items"));
|
||||
|
||||
openButton.addEventListener("click", function () {
|
||||
form.querySelectorAll("input[data-ai-match-dynamic]").forEach(function (input) { input.remove(); });
|
||||
@@ -823,7 +825,17 @@
|
||||
form.appendChild(input);
|
||||
});
|
||||
if (count) count.textContent = String(selected.length);
|
||||
if (submit) submit.disabled = selected.length === 0 || selected.length > 100;
|
||||
var overLimit = !Number.isInteger(maxItems) || maxItems < 1 || selected.length > maxItems;
|
||||
if (limitError) {
|
||||
if (selected.length > maxItems && Number.isInteger(maxItems) && maxItems > 0) {
|
||||
limitError.textContent = "已选择 " + selected.length + " 条,单批最多 " + maxItems + " 条;请取消部分选择后重试。";
|
||||
limitError.hidden = false;
|
||||
} else {
|
||||
limitError.textContent = "";
|
||||
limitError.hidden = true;
|
||||
}
|
||||
}
|
||||
if (submit) submit.disabled = selected.length === 0 || overLimit;
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function () {
|
||||
|
||||
@@ -58,6 +58,7 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) {
|
||||
"DetailURL": "/syb/detail", "StageOptions": service.SybStageOptions(),
|
||||
"SyncDateFrom": "2026-08-12", "SyncDateTo": "2026-08-13",
|
||||
"CurrentPageSize": 20,
|
||||
"AIMatchMaxItems": service.MaxAIMatchBatchItems,
|
||||
"Keyword": "ORDER-READY,ORDER-MISSING",
|
||||
"OrderNoFeedback": "已输入 2 个订单号;当前筛选命中 1 个订单、1 条商品明细。 当前筛选未命中:ORDER-MISSING",
|
||||
}
|
||||
@@ -85,6 +86,9 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) {
|
||||
`data-need-checked="collect"`,
|
||||
`data-need-checked="purchase"`,
|
||||
`data-ai-match-open`,
|
||||
`data-ai-match-max-items="200"`,
|
||||
`单批最多 200 条`,
|
||||
`data-ai-match-limit-error role="alert" hidden`,
|
||||
`data-collect-action="collect"`,
|
||||
`data-select-action="collect"`,
|
||||
`data-select-action="purchase ai"`,
|
||||
@@ -198,6 +202,26 @@ func TestSybListTemplate_AI匹配失败自动弹窗且转义(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSybAIMatchScript_从服务端属性读取200条上限并提供恢复提示(t *testing.T) {
|
||||
raw, err := staticFS.ReadFile("static/js/app.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := string(raw)
|
||||
for _, want := range []string{
|
||||
`modal.getAttribute("data-ai-match-max-items")`,
|
||||
`selected.length > maxItems`,
|
||||
`"已选择 " + selected.length + " 条,单批最多 " + maxItems + " 条;请取消部分选择后重试。"`,
|
||||
} {
|
||||
if !strings.Contains(source, want) {
|
||||
t.Errorf("AI 匹配脚本缺少服务端上限或恢复提示 %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(source, "selected.length > 100") || strings.Contains(source, "selected.length > 200") {
|
||||
t.Fatal("AI 匹配脚本不得重新硬编码批次上限")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSybDetailTemplate_单条采集可选执行客户端(t *testing.T) {
|
||||
tmpl, err := template.ParseFS(templateFS, "templates/*/*.html")
|
||||
if err != nil {
|
||||
|
||||
@@ -218,14 +218,16 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="modal-backdrop" id="syb-ai-match-create-modal" hidden>
|
||||
<div class="modal-backdrop" id="syb-ai-match-create-modal"
|
||||
data-ai-match-max-items="{{.AIMatchMaxItems}}" 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>将处理已选择的 <strong data-ai-match-count>0</strong> 条顺运宝商品,单批最多 {{.AIMatchMaxItems}} 条。</p>
|
||||
<p class="missing" data-ai-match-limit-error role="alert" hidden></p>
|
||||
<p class="hint">相同商品、SYB 规格和 PDD 上下文只调用一次;已有有效映射直接复用。条件不足或不确定的条目保留给人工处理。</p>
|
||||
<p class="hint">此操作只保存规格映射,不创建采集任务、采购任务,也不会触发 Client 下单。</p>
|
||||
</div>
|
||||
|
||||
@@ -414,7 +414,7 @@ Admin 本地时区,付款状态只是 Client 核单上报时的快照,Admin
|
||||
- API Key 不进入数据库、`data/`、日志或 HTTP 响应,保存后只显示固定掩码和尾四位。
|
||||
- 模型只能选择服务端提供的当前可购买候选;不确定、超时或硬校验失败时保持人工处理。
|
||||
- AI 匹配不会创建采购任务,不改变 Client 接口,也不放宽真实采购门禁。
|
||||
- 采购员在顺运宝列表勾选最多 100 条可匹配明细后创建批次;批次固定使用创建时的服务商、
|
||||
- 采购员在顺运宝列表勾选最多 200 条可匹配明细后创建批次;批次固定使用创建时的服务商、
|
||||
模型和运行参数快照。相同蝦皮商品、顺运宝规格及 PDD 候选上下文在一个批次内只调用一次,
|
||||
其余明细复用结果。
|
||||
- 页面持续显示批次总数、已处理、成功、复用、待人工、失败和逐条原因。Admin 重启时把未完成
|
||||
|
||||
@@ -1016,12 +1016,16 @@ CREATE UNIQUE INDEX idx_client_assignment_current
|
||||
名和非敏感状态。两张表都没有 API Key、Token 或 Secret 列;API Key 只存在部署指定的
|
||||
独立密钥文件。
|
||||
|
||||
## 16. AI 规格匹配批次(MySQL v22)
|
||||
## 16. AI 规格匹配批次(MySQL v22,批次上限 v31)
|
||||
|
||||
`ai_match_batches` 保存一次批量操作的创建人、状态、计数以及创建时的非敏感服务商配置
|
||||
快照。状态为 `queued/running/partial/succeeded/failed/interrupted`,单批最多 100 条。
|
||||
快照。状态为 `queued/running/partial/succeeded/failed/interrupted`,单批最多 200 条。
|
||||
快照包含服务商名称、Base URL、模型、超时、并发、阈值和配置指纹,但绝不包含 API Key。
|
||||
|
||||
MySQL v31 只把 `chk_ai_match_batch_total` 的范围从 1~100 调整为 1~200,不改变字段、
|
||||
索引、执行并发或单条匹配规则。迁移读取并核对约束表达式;删除旧约束后即使新增失败,
|
||||
下次启动也能从约束缺失状态继续补齐,自检通过后才记录版本。
|
||||
|
||||
`ai_match_batch_items` 每条对应一个顺运宝明细,保存上下文版本、同批去重身份、负责人明细、
|
||||
处理状态、结果来源、选项键、置信度和简短原因。`(batch_id, syb_id)` 唯一,保证一条明细在
|
||||
同一批次内只出现一次。相同身份只有负责人调用规则/模型,跟随项读取已保存的共享映射并记为
|
||||
|
||||
@@ -100,9 +100,9 @@
|
||||
### 3.2 分页
|
||||
|
||||
`[必须]` 使用公共分页的列表(蝦皮、PDD、顺运宝、采集采购、客户端、用户、
|
||||
商品目录导入记录、档口入库码)统一提供 **20 / 50 / 100 条**三档,默认 100。
|
||||
默认 100 便于集中查看和批量勾选;20 和 50 用于降低单页密度,最大值
|
||||
固定为 100,不提供“全部”或任意自定义数量。顺运宝同步记录是紧凑弹窗,继续固定
|
||||
商品目录导入记录、档口入库码)统一提供 **20 / 50 / 100 / 200 条**四档,默认 100。
|
||||
默认 100 便于集中查看和批量勾选;20 和 50 用于降低单页密度,200 只在操作员主动
|
||||
选择时启用,最大值固定为 200,不提供“全部”或任意自定义数量。顺运宝同步记录是紧凑弹窗,继续固定
|
||||
每页 10 条;顺运宝上游接口的抓取页容量也不是这里的界面设置。
|
||||
|
||||
蝦皮数据页(§4)是全项目第一个做分页的页面,本节是它定下的通用规则,
|
||||
@@ -115,11 +115,11 @@
|
||||
而且不会报错、不容易发现(#19 已经踩过一次)。
|
||||
- `[必须]` 页码参数是 `?page=N`,从 **1** 开始,不是 0——给操作员看的东西
|
||||
不要 0-based。
|
||||
- `[必须]` 每页条数参数是 `?page_size=N`,只接受 20、50、100;缺失、非数字或
|
||||
- `[必须]` 每页条数参数是 `?page_size=N`,只接受 20、50、100、200;缺失、非数字或
|
||||
其他值都回退为 100,不能把用户输入直接交给 SQL `LIMIT`。
|
||||
- `[必须]` 修改每页条数时回到第 1 页;筛选、翻页、写操作跳转、F5、浏览器
|
||||
前进后退和模块状态恢复都保留归一化后的 `page_size`。
|
||||
- `[必须]` 选择 20、50、100 后立即提交 GET 表单并刷新,不显示重复的“应用”按钮。
|
||||
- `[必须]` 选择 20、50、100、200 后立即提交 GET 表单并刷新,不显示重复的“应用”按钮。
|
||||
自动提交要显示加载状态并防重复;JavaScript 不可用时仍可使用已有分页链接,
|
||||
只是不能切换每页条数。
|
||||
- `[必须]` 越界要兜住:
|
||||
@@ -943,7 +943,9 @@ API Key 使用密码输入框,只允许替换或清除。已保存值显示固
|
||||
|
||||
顺运宝工具条提供“AI匹配”和“匹配记录”(无障碍名称使用完整业务名称)。只有处于规格待匹配或采购就绪、且 PDD
|
||||
已经采集的行可用于该动作;复选框可以同时支持采购与 AI 匹配,按钮按各自能力单独计数。
|
||||
确认弹窗显示本次条数、100 条上限和“不创建采购/不触发 Client”的边界。
|
||||
确认弹窗显示本次条数、200 条上限和“不创建采购/不触发 Client”的边界。上限由服务端
|
||||
统一传入模板和 JavaScript,不在前端另写一个数字;超过上限时显示已选数量、允许数量和
|
||||
取消部分选择的恢复动作,不能只让提交按钮失效。
|
||||
|
||||
提交后打开进度弹窗,使用文字和进度条同时显示状态,并列出成功、复用、待人工、失败计数
|
||||
及逐条原因。运行中自动轮询,另提供“刷新进度”和“刷新列表”;明细表限制高度并可滚动,
|
||||
|
||||
Reference in New Issue
Block a user