From 0208dc6b7670eed86d4e81eebeda569c74d9fd79 Mon Sep 17 00:00:00 2001 From: chengma Date: Fri, 14 Aug 2026 17:45:45 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=A2=9E=E5=8A=A0=20AI=20=E5=8C=B9?= =?UTF-8?q?=E9=85=8D=E5=A4=B1=E8=B4=A5=E5=BC=B9=E7=AA=97=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E9=83=A8=E7=BD=B2=E9=85=8D=E7=BD=AE=20(#229)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/config.example.yaml | 2 +- admin/deploy/cmautobuy-admin.service | 4 ++-- admin/handler/web/ai_match.go | 15 +++++++++++-- admin/handler/web/others.go | 1 + admin/handler/web/others_test.go | 25 +++++++++++++++++++++ admin/static/js/app.js | 5 +++++ admin/syb_interaction_template_test.go | 30 ++++++++++++++++++++++++++ admin/templates/syb/list.html | 19 ++++++++++++++++ docs/admin/02-architecture.md | 2 +- docs/admin/05-ui-specification.md | 4 ++++ docs/admin/06-quality-security.md | 4 ++++ 11 files changed, 105 insertions(+), 6 deletions(-) diff --git a/admin/config.example.yaml b/admin/config.example.yaml index d14030f..0232452 100644 --- a/admin/config.example.yaml +++ b/admin/config.example.yaml @@ -46,7 +46,7 @@ integrations: ai: # AI API Key 只能写入这个独立密钥文件。生产建议通过 - # CMAUTOBUY_AI_SECRETS_PATH 指向 /etc/cmautobuy/ai-secrets.yaml,权限 600。 + # CMAUTOBUY_AI_SECRETS_PATH 指向 /etc/cmautobuy/secrets/ai-secrets.yaml,权限 600。 # 留空时 Admin 正常启动,但不能保存密钥或测试 AI 连接。 secrets_path: "" diff --git a/admin/deploy/cmautobuy-admin.service b/admin/deploy/cmautobuy-admin.service index d808cbb..e17c965 100644 --- a/admin/deploy/cmautobuy-admin.service +++ b/admin/deploy/cmautobuy-admin.service @@ -18,9 +18,9 @@ UMask=0077 NoNewPrivileges=true PrivateTmp=true -ProtectSystem=strict +ProtectSystem=true ProtectHome=true -ReadWritePaths=/opt/cmautobuy/data +ReadWriteDirectories=/opt/cmautobuy/data /etc/cmautobuy/secrets RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 [Install] diff --git a/admin/handler/web/ai_match.go b/admin/handler/web/ai_match.go index f644115..5187b34 100644 --- a/admin/handler/web/ai_match.go +++ b/admin/handler/web/ai_match.go @@ -18,7 +18,7 @@ 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()) + h.sybRedirectAIMatchError(c, "AI 规格匹配未开始:"+err.Error()) return } batch, err := service.CreateAIMatchBatch(h.db, actor, c.PostFormArray("ids"), snapshot, time.Now()) @@ -27,7 +27,7 @@ func (h *Handler) SybAIMatchCreate(c *gin.Context) { if service.IsValidationError(err) { message = "AI 规格匹配未开始:" + err.Error() } - h.sybRedirect(c, message) + h.sybRedirectAIMatchError(c, message) return } actorCopy := *actor @@ -42,6 +42,17 @@ func (h *Handler) SybAIMatchCreate(c *gin.Context) { h.sybRedirectAIMatch(c, batch.BatchID, "AI 规格匹配已开始,可在进度窗口查看逐条结果") } +// sybRedirectAIMatchError 使用独立参数返回列表,让页面明确弹出 AI 失败提示。 +// 普通列表状态仍使用 msg,避免同步、采集等操作结果被误当成 AI 错误。 +func (h *Handler) sybRedirectAIMatchError(c *gin.Context, message string) { + params := url.Values{} + appendSybFormState(params, c) + if message != "" { + params.Set("ai_error", strings.TrimSpace(message)) + } + c.Redirect(http.StatusSeeOther, "/syb?"+params.Encode()) +} + 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) { diff --git a/admin/handler/web/others.go b/admin/handler/web/others.go index 2e279f2..22a7e7c 100644 --- a/admin/handler/web/others.go +++ b/admin/handler/web/others.go @@ -279,6 +279,7 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, "PurchaseClientCount": purchaseClients.SelectableCount, "EnabledSyncShopCount": enabledShopCount, "AIMatchBatch": aiBatch, + "AIMatchError": strings.TrimSpace(c.Query("ai_error")), "AIMatchBatchFetchURL": func() string { if aiBatch == nil { return "" diff --git a/admin/handler/web/others_test.go b/admin/handler/web/others_test.go index da48df4..cafca58 100644 --- a/admin/handler/web/others_test.go +++ b/admin/handler/web/others_test.go @@ -87,6 +87,31 @@ func TestSybRedirectAfterStart_清除本次日期范围(t *testing.T) { } } +func TestSybRedirectAIMatchError_独立错误参数并保留列表状态(t *testing.T) { + context, recorder := sybPostContext(t, url.Values{ + "order_no": {"ORDER-1"}, "shop": {"店铺A"}, "stage": {"mapping_pending"}, + "page": {"2"}, "date_from": {"2026-08-13"}, "date_to": {"2026-08-14"}, + }) + (&Handler{}).sybRedirectAIMatchError(context, "AI 规格匹配未开始:密钥存储不可用") + location, err := url.Parse(recorder.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + query := location.Query() + for name, want := range map[string]string{ + "order_no": "ORDER-1", "shop": "店铺A", "stage": "mapping_pending", "page": "2", + "date_from": "2026-08-13", "date_to": "2026-08-14", + "ai_error": "AI 规格匹配未开始:密钥存储不可用", + } { + if got := query.Get(name); got != want { + t.Errorf("AI 错误跳转丢失 %s:got=%q want=%q Location=%s", name, got, want, location.String()) + } + } + if query.Has("msg") { + t.Fatalf("AI 错误不能混入普通状态消息:%s", location.String()) + } +} + func TestSybHistoryPagination_保留筛选并重开弹窗(t *testing.T) { view := sybHistoryPagination(2, 3, "ORDER-1", "店铺A", "pdd_missing", "2026-08-01", "2026-08-09") for _, raw := range []string{view.FirstURL, view.PrevURL, view.NextURL, view.LastURL} { diff --git a/admin/static/js/app.js b/admin/static/js/app.js index 49a7a1d..0c2acb2 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -405,6 +405,11 @@ var open = document.querySelectorAll(".modal-backdrop:not([hidden])"); if (open.length > 0) closeModal(open[open.length - 1]); }); + + /* 服务端确认需要立即提醒的错误,页面加载后使用同一套弹窗焦点和关闭规则。 */ + document.querySelectorAll("[data-auto-open-modal]").forEach(function (modal) { + openModal(modal); + }); } /* ── 双击行打开详情弹窗 ──────────────────── diff --git a/admin/syb_interaction_template_test.go b/admin/syb_interaction_template_test.go index 5368f83..370ade3 100644 --- a/admin/syb_interaction_template_test.go +++ b/admin/syb_interaction_template_test.go @@ -167,6 +167,36 @@ func TestSybListTemplate_订单号错误就地显示(t *testing.T) { } } +func TestSybListTemplate_AI匹配失败自动弹窗且转义(t *testing.T) { + tmpl, err := template.ParseFS(templateFS, "templates/*/*.html") + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + data := map[string]any{ + "Title": "顺运宝数据", "Active": "syb", "CSRFToken": "test-csrf", + "AIMatchError": ``, + } + if err := tmpl.ExecuteTemplate(&output, "syb/list", data); err != nil { + t.Fatalf("渲染 AI 匹配错误弹窗失败: %v", err) + } + body := output.String() + for _, want := range []string{ + `id="syb-ai-match-error-modal" data-auto-open-modal hidden`, + `role="alertdialog"`, + `id="syb-ai-match-error-message"`, + `<script>alert("secret")</script>`, + `规格映射和采购任务都没有被改动`, + } { + if !strings.Contains(body, want) { + t.Errorf("AI 匹配错误弹窗缺少 %q", want) + } + } + if strings.Contains(body, ``) { + t.Fatal("AI 匹配错误未经 html/template 转义") + } +} + func TestSybDetailTemplate_单条采集可选执行客户端(t *testing.T) { tmpl, err := template.ParseFS(templateFS, "templates/*/*.html") if err != nil { diff --git a/admin/templates/syb/list.html b/admin/templates/syb/list.html index e0b6720..38120a0 100644 --- a/admin/templates/syb/list.html +++ b/admin/templates/syb/list.html @@ -194,6 +194,25 @@ +{{if .AIMatchError}} + +{{end}} +