diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 1da0c41..94a0e3b 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.- -wiki_revision: 2864a22f9e0f54a0663a56ab27d4b84052be3aac -synchronized_at: 2026-09-01T15:31:13Z +wiki_revision: 568d40fd882dc520e247226c4dd4d5c6b03122d9 +synchronized_at: 2026-09-02T13:23:01Z @@ -326,3 +326,10 @@ PddProductDetailCollector - `shopee_spec_auto_match_work_item` 按蝦皮商品唯一保存输入指纹、尝试次数、下次尝试时间和逐商品租约。已完成或低置信度/无结果的相同输入不重复调用 AI;规格、关联或 AI 设置更新时间变化后才允许重新处理。 - 迁移 `1788290000000_shopee_spec_auto_match.go` 幂等创建两张表并写入调用目标 `GoAutoShopeeSpecAutoMatch`。系统任务默认关闭,默认 Cron 为每小时第 15 分钟、每批最多 20 个商品。 - Admin 蝦皮商品列表通过异步手动接口启动同一批次并轮询最近运行摘要;该入口仅管理员可用,不创建采购任务、订单,不调用 Android Agent,也不执行付款。 + +## SYB 异常规格 AI 定时解析(#198) + +- `server/app/goauto/sybimport/ai_parse_batch.go` 以 SYB 明细为单位执行“确定性重解析 → 封闭候选 AI 解析”,与 #195 的“蝦皮规格 → PDD 规格”任务保持独立。 +- `syb_spec_ai_parse_run` 保存全局批次、活动槽、租约与结构化计数;`syb_spec_ai_parse_work_item` 按 `syb_product_id` 唯一保存输入指纹、尝试次数、冷却和逐行租约。 +- `syb_product` 的 `ai_confirmed`、置信度、限长理由、确认时间和隐藏输入指纹记录 AI 确认事实;`parse_status` 仍只记录确定性解析器结果,`manually_confirmed` 仍只代表人工决定。 +- Provider 只接收单条 `productSpec` 和关联蝦皮商品的颜色/尺码候选;返回值必须逐字属于对应候选。采购可信门禁接受仍有效的 AI 确认,但候选消失后立即 fail-closed。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 72e971a..86af231 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.- -wiki_revision: 0e1df62c48aeba32a7d5e4956783f0af68e8e658 -synchronized_at: 2026-09-01T15:31:24Z +wiki_revision: a60c48f56ec7cad6f886df78a644fbabb92d288f +synchronized_at: 2026-09-02T13:23:12Z # 业务规则与术语 @@ -401,3 +401,11 @@ synchronized_at: 2026-09-01T15:31:24Z - 定时与管理员手动执行共用全局活动槽和逐商品工作状态。单批默认最多 20 个商品;同一规格上下文与 AI 设置更新时间未变化时,已完成、低置信度或无结果商品不重复调用 AI。 - Provider 临时失败最多尝试 3 次,间隔至少 60 分钟;输入变化后重新计算指纹并允许重新处理。运行记录只保存结构化计数和脱敏限长错误,不保存 API Key、Provider 原始响应、商品原始 JSON 或个人数据。 - 系统定时任务迁移后默认关闭,须由管理员明确启用。该能力仅维护蝦皮与 PDD 颜色/尺码映射,不创建采购任务、PDD 订单,不触发 Agent,也不执行付款。 + +## SYB 异常采购规格 AI 解析(#198) + +- SYB 采购规格采用两阶段流程:先把货运单明细 `productSpec` 解析为目标颜色/尺码,再由 #195 把蝦皮规格匹配到 PDD 规格;两阶段不得混为同一匹配事实。 +- 定时任务只处理未人工确认且确定性解析为 `uncertain` / `failed` 的明细。每条先重跑确定性解析;空 `productSpec`、无关联蝦皮商品、无颜色/尺码候选或同一角色存在多个候选维度时不调用 AI,继续人工处理。 +- AI 只在关联蝦皮商品的封闭候选集合中返回原始颜色/尺码,并必须提供达到当前自动确认阈值的置信度和非空理由;集合外值、缺失角色、低置信度、歧义、无结果和输入漂移都不得确认。 +- `parse_status` 保留确定性解析器结论;AI 与人工确认分别记录,人工优先级最高。重复同步不得覆盖人工值;完全相同输入保留 AI 值,来源或关联变化会清除旧 AI 确认。 +- 同一输入的低置信度或无结果不重复调用 Provider;临时故障至少 60 分钟后重试,最多 3 次。任务不创建采集/采购任务、订单,不执行 Android 动作或付款。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index d060723..69ebcac 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Android-Agent-API-Contract wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.- -wiki_revision: 87219820eec3964ee5379f1edba6a62e94bddd2e -synchronized_at: 2026-09-01T15:32:52Z +wiki_revision: 75cebdfb2408d35e59d05d5755068b6d8cd126f5 +synchronized_at: 2026-09-02T13:24:29Z # MVP 共享 API 契约 @@ -862,3 +862,9 @@ Agent 只比较整数 `versionCode`。设备有活动任务时禁止检查、下 返回 `data.run`;从未执行时为 `null`。运行摘要包含 `id/requestId/trigger/status/batchLimit/scannedCount/eligibleCount/processedCount/confirmedCount/unmatchedCount/failedCount/startedAt/finishedAt` 和可选脱敏 `errorSummary`,不得返回 API Key、Provider 原始响应或商品原始 JSON。 `status` 当前为 `running`、`completed`、`completed_partial` 或 `failed`。页面只轮询最近摘要;查询本身不调用 AI。批次只更新蝦皮商品规格映射,不创建采购任务、订单或付款动作。 + +## SYB 异常规格 AI 解析字段(#198) + +既有 SYB 商品列表与详情响应增加以下只读字段:`aiConfirmed`、可空 `aiConfidence`、可空 `aiReason`、可空 `aiConfirmedAt`。`aiInputFingerprint` 仅用于服务端并发和漂移校验,禁止通过 API 返回。`parseStatus` / `parseNote` 继续表示确定性解析器结果,`manuallyConfirmed` 继续只表示人工确认。 + +采购预检把仍命中关联蝦皮当前候选的 `aiConfirmed=true` 视为可信目标规格;候选消失、关联变化或来源重导入使其失效,并返回重新解析/人工修正原因。该能力不新增 Android 接口或 Agent 字段,不创建采购任务或订单。 diff --git a/docs/13-deployment-and-operations.md b/docs/13-deployment-and-operations.md index bf55727..272739d 100644 --- a/docs/13-deployment-and-operations.md +++ b/docs/13-deployment-and-operations.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Deployment-and-Operations wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.- -wiki_revision: 929a9496d2e4aa25769351e08075dce6bd993b2d -synchronized_at: 2026-09-02T09:40:46Z +wiki_revision: 6b6458ea30bc2140ad39dbf0c28c0d5896ee7618 +synchronized_at: 2026-09-02T13:23:38Z # 部署与运维 @@ -56,3 +56,10 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、 - 定时和手动触发共享数据库唯一活动槽。看到“已有自动匹配正在运行”属于正常互斥结果;运行租约超过 30 分钟会在下次触发时安全释放并把旧运行标记失败。处理每个商品前会续租。 - Provider 临时失败的商品至少 60 分钟后重试,最多 3 次;低置信度或无结果在输入指纹不变时不会重复调用。排错优先查看 `shopee_spec_auto_match_run` 的结构化计数与脱敏错误,以及 `shopee_spec_auto_match_work_item` 的状态、尝试次数、下次尝试时间和租约;不得输出或复制 API Key、Provider 原始响应、商品原始 JSON 或个人数据。 - 紧急回退只需关闭系统任务并停止使用 Admin 手动入口;保留运行记录和既有已确认映射用于审计,不删除数据,也不回滚 #194。 + +## SYB 异常规格 AI 定时任务(#198) + +- 追加迁移 `1788354164329_syb_spec_ai_parse.go` 创建 AI 确认字段、`syb_spec_ai_parse_run`、`syb_spec_ai_parse_work_item`,并幂等写入 `sys_job.invoke_target=GoAutoSYBSpecAIParse`。 +- 系统任务默认关闭,默认 Cron 为每小时第 5 分钟、参数 `{"batchLimit":20}`;迁移重跑不得覆盖管理员后续修改的 Cron、参数或启停状态。建议先运行该任务,再由默认第 15 分钟的 #195 完成蝦皮到 PDD 的规格匹配。 +- 多实例通过可空唯一活动槽和租约保证全局单批运行;逐行工作项按输入指纹去重。Provider 临时失败至少 60 分钟后重试且最多 3 次,低置信度/无结果在输入不变时不重复调用。 +- 上线顺序为:备份数据库、执行追加迁移、发布服务并保持任务关闭、核验表/字段/任务种子,再由管理员决定是否启用。排错只查看运行计数、工作状态和脱敏错误,不输出 API Key、Provider 原始响应、完整 RawJSON 或订单数据。 diff --git a/server/app/goauto/aimatching/service.go b/server/app/goauto/aimatching/service.go index 7f8794b..dccc430 100644 --- a/server/app/goauto/aimatching/service.go +++ b/server/app/goauto/aimatching/service.go @@ -195,6 +195,58 @@ func (s *Service) Resolve(ctx context.Context, request MatchRequest) (MatchResul return result, nil } +// ResolveSYBSpec parses one SYB productSpec into the linked Shopee product's +// exact color/size labels. Unlike Resolve, this operation does not map to PDD: +// every non-empty answer must be an exact member of the supplied Shopee set. +func (s *Service) ResolveSYBSpec(ctx context.Context, request SYBSpecParseRequest) (SYBSpecParseResult, error) { + request.ProductSpec = strings.TrimSpace(request.ProductSpec) + request.Colors = usableCandidates(request.Colors) + request.Sizes = usableCandidates(request.Sizes) + if request.ProductSpec == "" || (len(request.Colors) == 0 && len(request.Sizes) == 0) { + return SYBSpecParseResult{}, fail(CodeNoMatch, "SYB 采购规格缺少可判断的原文或蝦皮候选") + } + setting, apiKey, err := s.activeSetting(ctx) + if err != nil { + return SYBSpecParseResult{}, err + } + payload := openAIChatRequest{Model: setting.Model, Temperature: 0, Messages: []openAIMessage{ + {Role: "system", Content: "你只负责把一条 SYB 商品规格原文解析成给定蝦皮候选中的原始颜色和尺码。不得猜测、不得改写候选、不得返回候选外文本。只返回 JSON:{\"color\":\"颜色候选原文或空\",\"size\":\"尺码候选原文或空\",\"reason\":\"简短原因\",\"confidence\":0到1}。提供了某角色候选时必须唯一可靠地选择一个,否则对应字段留空。"}, + {Role: "user", Content: sybSpecParsePrompt(request)}, + }} + body, err := json.Marshal(payload) + if err != nil { + return SYBSpecParseResult{}, &Error{Code: CodeProviderUnavailable, Message: "SYB 规格 AI 解析请求生成失败", Cause: err} + } + ctx, cancel := context.WithTimeout(ctx, time.Duration(setting.TimeoutSeconds)*time.Second) + defer cancel() + httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint(setting.BaseURL, "chat/completions"), bytes.NewReader(body)) + if err != nil { + return SYBSpecParseResult{}, fail(CodeInvalidSetting, "AI 服务地址无效") + } + httpRequest.Header.Set("Authorization", "Bearer "+apiKey) + httpRequest.Header.Set("Content-Type", "application/json") + response, err := s.httpClient().Do(httpRequest) + if err != nil { + return SYBSpecParseResult{}, &Error{Code: CodeProviderUnavailable, Message: "SYB 规格 AI 解析服务暂时不可用", Cause: err} + } + defer response.Body.Close() + responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + if readErr != nil || response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return SYBSpecParseResult{}, fail(CodeProviderUnavailable, "SYB 规格 AI 解析服务暂时不可用") + } + choice, err := parseProviderChoice(responseBody) + if err != nil || !validClosedChoice(choice.Color, request.Colors) || !validClosedChoice(choice.Size, request.Sizes) { + return SYBSpecParseResult{}, fail(CodeNoMatch, "AI 未能在蝦皮候选中唯一解析采购规格") + } + if choice.Confidence == nil || *choice.Confidence < 0 || *choice.Confidence > 1 || strings.TrimSpace(choice.Reason) == "" { + return SYBSpecParseResult{}, fail(CodeNoMatch, "AI 解析结果缺少有效置信度或理由") + } + return SYBSpecParseResult{ + Color: choice.Color, Size: choice.Size, Provider: ProviderOpenAICompatible, + Model: setting.Model, Reason: safeReason(choice.Reason), Confidence: choice.Confidence, + }, nil +} + func (s *Service) activeSetting(ctx context.Context) (models.AIMatchingSetting, string, error) { setting, err := s.setting(ctx) if errors.Is(err, gorm.ErrRecordNotFound) || !setting.Enabled { @@ -310,6 +362,22 @@ func validChoice(target, selected string, candidates []string) bool { return false } +func validClosedChoice(selected string, candidates []string) bool { + selected = strings.TrimSpace(selected) + if len(candidates) == 0 { + return selected == "" + } + if selected == "" { + return false + } + for _, candidate := range candidates { + if candidate == selected { + return true + } + } + return false +} + func safeReason(reason string) string { reason = strings.TrimSpace(reason) if reason == "" { @@ -332,6 +400,16 @@ func matchPrompt(request MatchRequest) string { return string(raw) } +func sybSpecParsePrompt(request SYBSpecParseRequest) string { + payload := struct { + ProductSpec string `json:"productSpec"` + Colors []string `json:"shopeeColorCandidates,omitempty"` + Sizes []string `json:"shopeeSizeCandidates,omitempty"` + }{request.ProductSpec, request.Colors, request.Sizes} + raw, _ := json.Marshal(payload) + return string(raw) +} + type openAIMessage struct { Role string `json:"role"` Content string `json:"content"` diff --git a/server/app/goauto/aimatching/service_test.go b/server/app/goauto/aimatching/service_test.go index 0238445..1c99746 100644 --- a/server/app/goauto/aimatching/service_test.go +++ b/server/app/goauto/aimatching/service_test.go @@ -2,6 +2,7 @@ package aimatching import ( "context" + "encoding/json" "errors" "io" "net/http" @@ -135,3 +136,61 @@ func TestAutoConfirmThresholdDefaultsPersistsAndValidates(t *testing.T) { t.Fatalf("invalid threshold err=%v", err) } } + +func TestResolveSYBSpecUsesOnlyProductSpecAndClosedShopeeCandidates(t *testing.T) { + service := matcherTestService(t) + if _, err := service.SaveSettings(context.Background(), SaveSettingsRequest{Enabled: true, BaseURL: "https://provider.example/v1", Model: "test-model", APIKey: "test-secret", TimeoutSeconds: 8}, 7); err != nil { + t.Fatal(err) + } + var sent map[string]any + service.HTTPClient = &http.Client{Transport: roundTripper(func(request *http.Request) (*http.Response, error) { + raw, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &sent); err != nil { + t.Fatal(err) + } + body := `{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"原文对应唯一候选\",\"confidence\":0.95}"}}]}` + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: request}, nil + })} + result, err := service.ResolveSYBSpec(context.Background(), SYBSpecParseRequest{ProductSpec: "黑色 XL【备注】", Colors: []string{"黑色", "白色"}, Sizes: []string{"L", "XL"}}) + if err != nil || result.Color != "黑色" || result.Size != "XL" || result.Confidence == nil || *result.Confidence != 0.95 { + t.Fatalf("result=%+v err=%v", result, err) + } + encoded, _ := json.Marshal(sent) + for _, forbidden := range []string{"orderCode", "address", "rawJson", "price", "test-secret"} { + if strings.Contains(string(encoded), forbidden) { + t.Fatalf("provider payload leaked forbidden field %q: %s", forbidden, encoded) + } + } + for _, required := range []string{"productSpec", "shopeeColorCandidates", "shopeeSizeCandidates"} { + if !strings.Contains(string(encoded), required) { + t.Fatalf("provider payload missing %q: %s", required, encoded) + } + } +} + +func TestResolveSYBSpecRejectsCandidateOutsideClosedSetAndMissingConfidence(t *testing.T) { + service := matcherTestService(t) + if _, err := service.SaveSettings(context.Background(), SaveSettingsRequest{Enabled: true, BaseURL: "https://provider.example/v1", Model: "test-model", APIKey: "test-secret", TimeoutSeconds: 8}, 7); err != nil { + t.Fatal(err) + } + responses := []string{ + `{"choices":[{"message":{"content":"{\"color\":\"灰色\",\"size\":\"XL\",\"reason\":\"猜测\",\"confidence\":0.99}"}}]}`, + `{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"候选\"}"}}]}`, + } + service.HTTPClient = &http.Client{Transport: roundTripper(func(request *http.Request) (*http.Response, error) { + body := responses[0] + responses = responses[1:] + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: request}, nil + })} + request := SYBSpecParseRequest{ProductSpec: "黑 XL", Colors: []string{"黑色"}, Sizes: []string{"XL"}} + for i := 0; i < 2; i++ { + _, err := service.ResolveSYBSpec(context.Background(), request) + var target *Error + if !errors.As(err, &target) || target.Code != CodeNoMatch { + t.Fatalf("attempt %d err=%v", i, err) + } + } +} diff --git a/server/app/goauto/aimatching/types.go b/server/app/goauto/aimatching/types.go index 0c10e31..419ee40 100644 --- a/server/app/goauto/aimatching/types.go +++ b/server/app/goauto/aimatching/types.go @@ -29,6 +29,24 @@ type MatchRequest struct { Sizes []string } +// SYBSpecParseRequest contains the only source text and closed Shopee +// candidate sets that may leave GoAuto for an AI-assisted SYB parse. It must +// never contain the shipment/order, account, address, price or full raw JSON. +type SYBSpecParseRequest struct { + ProductSpec string + Colors []string + Sizes []string +} + +type SYBSpecParseResult struct { + Color string + Size string + Provider string + Model string + Reason string + Confidence *float64 +} + type CandidateSnapshot struct { Colors []string `json:"colors,omitempty"` Sizes []string `json:"sizes,omitempty"` diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go index 85eaf54..8289c27 100644 --- a/server/app/goauto/migrations/migrate.go +++ b/server/app/goauto/migrations/migrate.go @@ -37,6 +37,8 @@ func MigratedModels() []any { &models.ShopeeSpecAutoMatchRun{}, &models.ShopeeSpecAutoMatchWorkItem{}, &models.SYBProduct{}, + &models.SYBSpecAIParseRun{}, + &models.SYBSpecAIParseWorkItem{}, &models.SYBSession{}, &models.SYBShop{}, &models.SYBSyncRun{}, diff --git a/server/app/goauto/models/schema.go b/server/app/goauto/models/schema.go index fdc0bc6..51776d9 100644 --- a/server/app/goauto/models/schema.go +++ b/server/app/goauto/models/schema.go @@ -533,6 +533,15 @@ type SYBProduct struct { // recording the parser's own last output for audit even after a manual // correction; it is not overwritten by the correction itself. ManuallyConfirmed bool `json:"manuallyConfirmed" gorm:"not null;default:false"` + // AIConfirmed is independent of ParseStatus: ParseStatus remains the + // deterministic parser's audit result, while these fields record a closed- + // candidate, high-confidence AI decision. Human correction always clears + // and supersedes this decision. + AIConfirmed bool `json:"aiConfirmed" gorm:"not null;default:false;index"` + AIConfidence *float64 `json:"aiConfidence,omitempty"` + AIReason string `json:"aiReason,omitempty" gorm:"size:500;not null;default:''"` + AIConfirmedAt *time.Time `json:"aiConfirmedAt,omitempty"` + AIInputFingerprint string `json:"-" gorm:"size:64;not null;default:'';index"` // RawJSON is the untouched `details[]` element as SYB returned it. It is // what reparse (#41: "适用于解析规则更新后批量重跑,只读取已保存的原始 diff --git a/server/app/goauto/models/syb_spec_ai_parse.go b/server/app/goauto/models/syb_spec_ai_parse.go new file mode 100644 index 0000000..37e6343 --- /dev/null +++ b/server/app/goauto/models/syb_spec_ai_parse.go @@ -0,0 +1,48 @@ +package models + +import "time" + +// SYBSpecAIParseRun is one globally serialized scheduled batch. +type SYBSpecAIParseRun struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + RequestID string `json:"requestId" gorm:"size:36;not null;uniqueIndex:ux_syb_spec_ai_parse_run_request"` + Trigger string `json:"trigger" gorm:"size:16;not null;index"` + Status string `json:"status" gorm:"size:24;not null;index"` + ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_syb_spec_ai_parse_run_active"` + LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"` + LeaseExpiresAt *time.Time `json:"-" gorm:"index"` + BatchLimit int `json:"batchLimit" gorm:"not null;default:20"` + ScannedCount int `json:"scannedCount" gorm:"not null;default:0"` + EligibleCount int `json:"eligibleCount" gorm:"not null;default:0"` + ProcessedCount int `json:"processedCount" gorm:"not null;default:0"` + ConfirmedCount int `json:"confirmedCount" gorm:"not null;default:0"` + UnmatchedCount int `json:"unmatchedCount" gorm:"not null;default:0"` + FailedCount int `json:"failedCount" gorm:"not null;default:0"` + ErrorSummary string `json:"errorSummary,omitempty" gorm:"size:500;not null;default:''"` + StartedAt time.Time `json:"startedAt" gorm:"not null"` + FinishedAt *time.Time `json:"finishedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (SYBSpecAIParseRun) TableName() string { return "syb_spec_ai_parse_run" } + +// SYBSpecAIParseWorkItem prevents unchanged ambiguous input from repeatedly +// spending provider calls and owns the per-row recovery lease. +type SYBSpecAIParseWorkItem struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + SYBProductID uint64 `json:"sybProductId" gorm:"not null;uniqueIndex:ux_syb_spec_ai_parse_work_product"` + RunID *uint64 `json:"runId,omitempty" gorm:"index"` + InputFingerprint string `json:"inputFingerprint" gorm:"size:64;not null;default:'';index"` + Status string `json:"status" gorm:"size:24;not null;index"` + AttemptCount int `json:"attemptCount" gorm:"not null;default:0"` + NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty" gorm:"index"` + LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"` + LeaseExpiresAt *time.Time `json:"-" gorm:"index"` + LastErrorCode string `json:"lastErrorCode,omitempty" gorm:"size:64;not null;default:''"` + LastError string `json:"lastError,omitempty" gorm:"size:500;not null;default:''"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (SYBSpecAIParseWorkItem) TableName() string { return "syb_spec_ai_parse_work_item" } diff --git a/server/app/goauto/purchase/ai_match_eligibility.go b/server/app/goauto/purchase/ai_match_eligibility.go index a3ef3d3..a3607e8 100644 --- a/server/app/goauto/purchase/ai_match_eligibility.go +++ b/server/app/goauto/purchase/ai_match_eligibility.go @@ -32,7 +32,41 @@ type skuCombinationRow struct { } func sybSpecsTrusted(syb models.SYBProduct) bool { - return syb.ParseStatus == models.SYBParseStatusSuccess || syb.ManuallyConfirmed + return syb.ParseStatus == models.SYBParseStatusSuccess || syb.ManuallyConfirmed || syb.AIConfirmed +} + +func aiConfirmedSpecsCurrent(syb models.SYBProduct, shopee models.ShopeeProduct) bool { + if !syb.AIConfirmed { + return true + } + specs, err := shopeeproduct.Unmarshal(shopee.SpecsJSON) + if err != nil { + return false + } + wanted := map[string]string{shopeeproduct.RoleColor: strings.TrimSpace(syb.TargetColor), shopeeproduct.RoleSize: strings.TrimSpace(syb.TargetSize)} + foundAny := false + for role, target := range wanted { + if target == "" { + continue + } + foundAny = true + found := false + for _, dimension := range specs { + if dimension.Role != role { + continue + } + for _, value := range dimension.Values { + if value.Name == target { + found = true + break + } + } + } + if !found { + return false + } + } + return foundAny } func (s *Service) loadLatestSKUCombinations(ctx context.Context, pddIDs []uint64, dataset *batchPreviewDataset) error { diff --git a/server/app/goauto/purchase/batch.go b/server/app/goauto/purchase/batch.go index f5d7370..926a3e7 100644 --- a/server/app/goauto/purchase/batch.go +++ b/server/app/goauto/purchase/batch.go @@ -406,6 +406,10 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat item.ReasonCode, item.Reason, item.NextAction = "SHOPEE_NOT_FOUND", "关联的蝦皮商品不存在,请先处理商品档案", "open_shopee" return item } + if !aiConfirmedSpecsCurrent(syb, shopee) { + item.ReasonCode, item.Reason, item.NextAction = "SYB_PARSE_FAILED", "AI 解析依据已变化,请等待重新解析或人工修正", "reparse" + return item + } if shopee.PDDProductID == nil { item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_LINKED", "尚未关联 PDD 商品,请先关联", processActionOpenPDDLink return item diff --git a/server/app/goauto/purchase/batch_test.go b/server/app/goauto/purchase/batch_test.go index b074866..bf77281 100644 --- a/server/app/goauto/purchase/batch_test.go +++ b/server/app/goauto/purchase/batch_test.go @@ -44,6 +44,28 @@ func TestBatchPreviewUsesPDDPriceAndExplainsIneligibleRows(t *testing.T) { } } +func TestSYBSpecsTrustedAcceptsAIWithoutCallingItManual(t *testing.T) { + row := models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain, AIConfirmed: true} + if !sybSpecsTrusted(row) { + t.Fatal("a valid AI-confirmed parse must pass the purchase parse gate") + } + if row.ManuallyConfirmed { + t.Fatal("AI confirmation must not be represented as human confirmation") + } +} + +func TestAIConfirmedSpecsBecomeUntrustedWhenShopeeCandidateDisappears(t *testing.T) { + row := models.SYBProduct{TargetColor: "黑色", TargetSize: "XL", ParseStatus: models.SYBParseStatusUncertain, AIConfirmed: true} + product := models.ShopeeProduct{SpecsJSON: `[{"name":"颜色","role":"color","values":[{"name":"白色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"XL","source":"import"}]}]`} + if aiConfirmedSpecsCurrent(row, product) { + t.Fatal("removed Shopee candidate must invalidate the AI parse gate") + } + product.SpecsJSON = `[{"name":"颜色","role":"color","values":[{"name":"黑色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"XL","source":"import"}]}]` + if !aiConfirmedSpecsCurrent(row, product) { + t.Fatal("unchanged exact Shopee candidates should keep AI parse valid") + } +} + func TestBatchPreviewExposesIndependentCollectionEligibility(t *testing.T) { db := testDB(t) fixture := seed(t, db, liveCaps(), true) diff --git a/server/app/goauto/purchase/process_stage.go b/server/app/goauto/purchase/process_stage.go index 0415496..e401825 100644 --- a/server/app/goauto/purchase/process_stage.go +++ b/server/app/goauto/purchase/process_stage.go @@ -137,7 +137,7 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat if !ok { return stage(ProcessStageManualAction, "SYB 商品不存在或已删除", "refresh") } - if syb.ParseStatus == models.SYBParseStatusFailed { + if syb.ParseStatus == models.SYBParseStatusFailed && !syb.ManuallyConfirmed && !syb.AIConfirmed { return stage(ProcessStageManualAction, "解析失败,请先处理", "reparse") } if syb.ShopeeProductID == nil { diff --git a/server/app/goauto/sybimport/ai_parse_batch.go b/server/app/goauto/sybimport/ai_parse_batch.go new file mode 100644 index 0000000..be815a9 --- /dev/null +++ b/server/app/goauto/sybimport/ai_parse_batch.go @@ -0,0 +1,489 @@ +package sybimport + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "go-admin/app/goauto/aimatching" + "go-admin/app/goauto/models" + "go-admin/app/goauto/shopeeproduct" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +const ( + SpecAIParseInvokeTarget = "GoAutoSYBSpecAIParse" + defaultAIParseBatchLimit = 20 + aiParseLeaseDuration = 30 * time.Minute + aiParseRetryDelay = time.Hour + maxAIParseAttempts = 3 +) + +var ( + errAIParseWorkNotClaimed = errors.New("syb spec ai parse work not claimed") + errAIParseInputChanged = errors.New("syb spec ai parse input changed") +) + +type aiParseInput struct { + ProductSpec string + Colors []string + Sizes []string + Fingerprint string +} + +func StartSpecAIParseRun(ctx context.Context, db *gorm.DB, requestID string, batchLimit int) (models.SYBSpecAIParseRun, bool, error) { + if _, err := uuid.Parse(strings.TrimSpace(requestID)); err != nil { + return models.SYBSpecAIParseRun{}, false, fmt.Errorf("requestId 必须是 UUID") + } + if batchLimit <= 0 { + batchLimit = defaultAIParseBatchLimit + } + if batchLimit > 100 { + return models.SYBSpecAIParseRun{}, false, fmt.Errorf("batchLimit 不能超过 100") + } + now := time.Now().UTC() + lease := now.Add(aiParseLeaseDuration) + one := uint8(1) + owner := uuid.NewString() + var run models.SYBSpecAIParseRun + created := false + err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&models.SYBSpecAIParseRun{}). + Where("status = ? AND active_slot = ? AND lease_expires_at < ?", "running", 1, now). + Updates(map[string]any{"status": "failed", "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "error_summary": "上次运行租约过期,已安全释放", "finished_at": now}).Error; err != nil { + return err + } + if err := tx.Where("request_id = ?", requestID).First(&run).Error; err == nil { + return nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if err := tx.Where("status = ? AND active_slot = ?", "running", 1).First(&run).Error; err == nil { + return nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + run = models.SYBSpecAIParseRun{ + RequestID: requestID, Trigger: "scheduled", Status: "running", ActiveSlot: &one, + LeaseOwner: owner, LeaseExpiresAt: &lease, BatchLimit: batchLimit, StartedAt: now, + } + if err := tx.Create(&run).Error; err != nil { + return err + } + created = true + return nil + }) + if err != nil { + if findErr := db.WithContext(ctx).Where("status = ? AND active_slot = ?", "running", 1).First(&run).Error; findErr == nil { + return run, false, nil + } + return models.SYBSpecAIParseRun{}, false, err + } + return run, created, nil +} + +func ProcessSpecAIParseRun(ctx context.Context, db *gorm.DB, runID uint64) error { + var run models.SYBSpecAIParseRun + if err := db.WithContext(ctx).First(&run, runID).Error; err != nil { + return err + } + if run.Status != "running" || run.ActiveSlot == nil || *run.ActiveSlot != 1 { + return nil + } + limit := run.BatchLimit + if limit <= 0 || limit > 100 { + limit = defaultAIParseBatchLimit + } + queryLimit := limit * 25 + if queryLimit < 100 { + queryLimit = 100 + } + if queryLimit > 1000 { + queryLimit = 1000 + } + var candidates []models.SYBProduct + if err := db.WithContext(ctx). + Where("manually_confirmed = ?", false). + Where("parse_status IN ?", []string{models.SYBParseStatusUncertain, models.SYBParseStatusFailed}). + Order("updated_at ASC, id ASC").Limit(queryLimit).Find(&candidates).Error; err != nil { + finishSpecAIParseRun(db, run, "failed", 0, 0, 0, 0, 0, 1, "扫描异常规格失败") + return err + } + + eligible, processed, confirmed, unmatched, failed := 0, 0, 0, 0, 0 + firstError := "" + for _, candidate := range candidates { + if processed >= limit { + break + } + if candidate.AIConfirmed { + current, currentErr := aiConfirmationTargetsCurrent(ctx, db, candidate) + if currentErr != nil { + failed++ + continue + } + if current { + continue + } + if err := db.WithContext(ctx).Model(&models.SYBProduct{}). + Where("id = ? AND manually_confirmed = ?", candidate.ID, false). + Updates(map[string]any{"ai_confirmed": false, "ai_confidence": nil, "ai_reason": "", "ai_confirmed_at": nil, "ai_input_fingerprint": ""}).Error; err != nil { + failed++ + continue + } + candidate.AIConfirmed = false + } + outcome, err := Reparse(ctx, db, candidate.ID, false) + if err != nil { + failed++ + if firstError == "" { + firstError = "确定性重新解析失败" + } + continue + } + if outcome.NewStatus == models.SYBParseStatusSuccess { + processed++ + confirmed++ + continue + } + if err := db.WithContext(ctx).First(&candidate, candidate.ID).Error; err != nil { + failed++ + continue + } + input, ok, err := buildAIParseInput(ctx, db, candidate) + if err != nil { + failed++ + if firstError == "" { + firstError = "读取 AI 解析上下文失败" + } + continue + } + if !ok { + continue + } + eligible++ + work, claimed, err := claimSpecAIParseWork(ctx, db, run, candidate.ID, input.Fingerprint) + if err != nil { + failed++ + continue + } + if !claimed { + continue + } + processed++ + renewSpecAIParseRun(db, run) + matcher := aimatching.NewService(db) + result, matchErr := matcher.ResolveSYBSpec(ctx, aimatching.SYBSpecParseRequest{ + ProductSpec: input.ProductSpec, Colors: input.Colors, Sizes: input.Sizes, + }) + if matchErr == nil { + settings, settingsErr := matcher.Settings(ctx) + if settingsErr != nil { + matchErr = settingsErr + } else if result.Confidence == nil || *result.Confidence < settings.AutoConfirmMinConfidence || strings.TrimSpace(result.Reason) == "" { + unmatched++ + completeSpecAIParseWork(db, work, input.Fingerprint, false, nil) + continue + } else { + matchErr = applyAIParseResult(ctx, db, candidate.ID, input.Fingerprint, result) + } + } + if matchErr != nil { + if isNoAIParseMatch(matchErr) || errors.Is(matchErr, errAIParseInputChanged) { + unmatched++ + completeSpecAIParseWork(db, work, input.Fingerprint, false, nil) + continue + } + failed++ + if firstError == "" { + firstError = safeAIParseError(matchErr) + } + completeSpecAIParseWork(db, work, input.Fingerprint, false, matchErr) + continue + } + confirmed++ + completeSpecAIParseWork(db, work, input.Fingerprint, true, nil) + } + status := "completed" + if failed > 0 { + status = "completed_partial" + } + return finishSpecAIParseRun(db, run, status, len(candidates), eligible, processed, confirmed, unmatched, failed, firstError) +} + +func buildAIParseInput(ctx context.Context, db *gorm.DB, record models.SYBProduct) (aiParseInput, bool, error) { + if record.ManuallyConfirmed || (record.ParseStatus != models.SYBParseStatusUncertain && record.ParseStatus != models.SYBParseStatusFailed) || record.ShopeeProductID == nil { + return aiParseInput{}, false, nil + } + var raw rawDetailSpec + if err := json.Unmarshal([]byte(record.RawJSON), &raw); err != nil { + return aiParseInput{}, false, err + } + raw.ProductSpec = strings.TrimSpace(raw.ProductSpec) + if raw.ProductSpec == "" { + return aiParseInput{}, false, nil + } + var product models.ShopeeProduct + if err := db.WithContext(ctx).First(&product, *record.ShopeeProductID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return aiParseInput{}, false, nil + } + return aiParseInput{}, false, err + } + specs, err := shopeeproduct.Unmarshal(product.SpecsJSON) + if err != nil { + return aiParseInput{}, false, err + } + colors, sizes, ambiguous := closedShopeeCandidates(specs) + if ambiguous || (len(colors) == 0 && len(sizes) == 0) { + return aiParseInput{}, false, nil + } + var setting models.AIMatchingSetting + if err := db.WithContext(ctx).First(&setting, 1).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return aiParseInput{}, false, nil + } + return aiParseInput{}, false, err + } + if !setting.Enabled || strings.TrimSpace(setting.APIKey) == "" { + return aiParseInput{}, false, nil + } + fingerprintPayload := struct { + ProductSpec string + ShopeeProductID uint64 + ShopeeSpecsJSON string + SettingUpdatedAt string + }{raw.ProductSpec, product.ID, product.SpecsJSON, setting.UpdatedAt.UTC().Format(time.RFC3339Nano)} + encoded, _ := json.Marshal(fingerprintPayload) + hash := sha256.Sum256(encoded) + return aiParseInput{ProductSpec: raw.ProductSpec, Colors: colors, Sizes: sizes, Fingerprint: hex.EncodeToString(hash[:])}, true, nil +} + +func aiConfirmationTargetsCurrent(ctx context.Context, db *gorm.DB, record models.SYBProduct) (bool, error) { + if !record.AIConfirmed || record.ShopeeProductID == nil { + return false, nil + } + var product models.ShopeeProduct + if err := db.WithContext(ctx).First(&product, *record.ShopeeProductID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, err + } + specs, err := shopeeproduct.Unmarshal(product.SpecsJSON) + if err != nil { + return false, err + } + colors, sizes, ambiguous := closedShopeeCandidates(specs) + if ambiguous { + return false, nil + } + return closedCandidateContains(record.TargetColor, colors) && closedCandidateContains(record.TargetSize, sizes) && + (strings.TrimSpace(record.TargetColor) != "" || strings.TrimSpace(record.TargetSize) != ""), nil +} + +func closedCandidateContains(value string, candidates []string) bool { + value = strings.TrimSpace(value) + if len(candidates) == 0 { + return value == "" + } + for _, candidate := range candidates { + if candidate == value { + return true + } + } + return false +} + +func closedShopeeCandidates(specs []shopeeproduct.SpecDimension) (colors, sizes []string, ambiguous bool) { + roleDimensions := map[string]int{} + for _, dimension := range specs { + if dimension.Role != shopeeproduct.RoleColor && dimension.Role != shopeeproduct.RoleSize { + continue + } + values := make([]string, 0, len(dimension.Values)) + seen := map[string]bool{} + for _, value := range dimension.Values { + name := strings.TrimSpace(value.Name) + if name != "" && !seen[name] { + seen[name] = true + values = append(values, name) + } + } + if len(values) == 0 { + continue + } + roleDimensions[dimension.Role]++ + if roleDimensions[dimension.Role] > 1 { + return nil, nil, true + } + if dimension.Role == shopeeproduct.RoleColor { + colors = values + } else { + sizes = values + } + } + return colors, sizes, false +} + +func applyAIParseResult(ctx context.Context, db *gorm.DB, id uint64, fingerprint string, result aimatching.SYBSpecParseResult) error { + now := time.Now().UTC() + return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var record models.SYBProduct + if err := tx.First(&record, id).Error; err != nil { + return err + } + input, ok, err := buildAIParseInput(ctx, tx, record) + if err != nil { + return err + } + if !ok || input.Fingerprint != fingerprint { + return errAIParseInputChanged + } + if result.Confidence == nil || strings.TrimSpace(result.Reason) == "" { + return errAIParseInputChanged + } + write := tx.Model(&models.SYBProduct{}).Where("id = ? AND manually_confirmed = ? AND ai_confirmed = ?", id, false, false).Updates(map[string]any{ + "target_color": result.Color, "target_size": result.Size, + "ai_confirmed": true, "ai_confidence": *result.Confidence, "ai_reason": truncateAIParseText(result.Reason), + "ai_confirmed_at": now, "ai_input_fingerprint": fingerprint, + }) + if write.Error != nil { + return write.Error + } + if write.RowsAffected != 1 { + return errAIParseInputChanged + } + return mergeParsedSpec(tx, *record.ShopeeProductID, ParseResult{Color: result.Color, Size: result.Size, Status: models.SYBParseStatusSuccess}) + }) +} + +func claimSpecAIParseWork(ctx context.Context, db *gorm.DB, run models.SYBSpecAIParseRun, productID uint64, fingerprint string) (models.SYBSpecAIParseWorkItem, bool, error) { + now := time.Now().UTC() + lease := now.Add(aiParseLeaseDuration) + var work models.SYBSpecAIParseWorkItem + err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + err := tx.Where("syb_product_id = ?", productID).First(&work).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + work = models.SYBSpecAIParseWorkItem{SYBProductID: productID, RunID: &run.ID, InputFingerprint: fingerprint, Status: "running", AttemptCount: 1, LeaseOwner: run.LeaseOwner, LeaseExpiresAt: &lease} + return tx.Create(&work).Error + } + if err != nil { + return err + } + if work.InputFingerprint == fingerprint { + if work.Status == "completed" || work.Status == "unmatched" || work.AttemptCount >= maxAIParseAttempts || (work.NextAttemptAt != nil && work.NextAttemptAt.After(now)) || (work.Status == "running" && work.LeaseExpiresAt != nil && work.LeaseExpiresAt.After(now)) { + return errAIParseWorkNotClaimed + } + } else { + work.AttemptCount = 0 + } + updates := map[string]any{"run_id": run.ID, "input_fingerprint": fingerprint, "status": "running", "attempt_count": work.AttemptCount + 1, "next_attempt_at": nil, "lease_owner": run.LeaseOwner, "lease_expires_at": lease, "last_error_code": "", "last_error": ""} + if err := tx.Model(&models.SYBSpecAIParseWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error; err != nil { + return err + } + return tx.First(&work, work.ID).Error + }) + if errors.Is(err, errAIParseWorkNotClaimed) { + return work, false, nil + } + return work, err == nil, err +} + +func completeSpecAIParseWork(db *gorm.DB, work models.SYBSpecAIParseWorkItem, fingerprint string, confirmed bool, parseErr error) { + now := time.Now().UTC() + updates := map[string]any{"input_fingerprint": fingerprint, "lease_owner": "", "lease_expires_at": nil} + if parseErr == nil { + if confirmed { + updates["status"] = "completed" + } else { + updates["status"] = "unmatched" + } + updates["next_attempt_at"], updates["last_error_code"], updates["last_error"] = nil, "", "" + } else { + code := aiParseErrorCode(parseErr) + updates["status"], updates["last_error_code"], updates["last_error"] = "failed", code, safeAIParseError(parseErr) + if code == aimatching.CodeProviderUnavailable && work.AttemptCount < maxAIParseAttempts { + next := now.Add(aiParseRetryDelay) + updates["next_attempt_at"] = next + } else { + updates["next_attempt_at"] = nil + } + } + _ = db.Model(&models.SYBSpecAIParseWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error +} + +func renewSpecAIParseRun(db *gorm.DB, run models.SYBSpecAIParseRun) { + lease := time.Now().UTC().Add(aiParseLeaseDuration) + _ = db.Model(&models.SYBSpecAIParseRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Update("lease_expires_at", lease).Error +} + +func finishSpecAIParseRun(db *gorm.DB, run models.SYBSpecAIParseRun, status string, scanned, eligible, processed, confirmed, unmatched, failed int, summary string) error { + now := time.Now().UTC() + return db.Model(&models.SYBSpecAIParseRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Updates(map[string]any{ + "status": status, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, + "scanned_count": scanned, "eligible_count": eligible, "processed_count": processed, + "confirmed_count": confirmed, "unmatched_count": unmatched, "failed_count": failed, + "error_summary": truncateAIParseText(summary), "finished_at": now, + }).Error +} + +func isNoAIParseMatch(err error) bool { return aiParseErrorCode(err) == aimatching.CodeNoMatch } + +func aiParseErrorCode(err error) string { + var target *aimatching.Error + if errors.As(err, &target) { + return target.Code + } + return CodeInternal +} + +func safeAIParseError(err error) string { + var target *aimatching.Error + if errors.As(err, &target) { + return truncateAIParseText(target.Message) + } + return "服务端处理失败" +} + +func truncateAIParseText(value string) string { + runes := []rune(strings.TrimSpace(value)) + if len(runes) > 500 { + runes = runes[:500] + } + return string(runes) +} + +type scheduledSpecAIParseArgs struct { + BatchLimit int `json:"batchLimit"` +} + +type ScheduledSpecAIParseJob struct{} + +func (ScheduledSpecAIParseJob) Exec(_ interface{}) error { + return errors.New("SYB 规格 AI 解析定时任务缺少数据库连接") +} + +func (ScheduledSpecAIParseJob) ExecWithDB(db *gorm.DB, arg interface{}) error { + args := scheduledSpecAIParseArgs{BatchLimit: defaultAIParseBatchLimit} + if raw, ok := arg.(string); ok && strings.TrimSpace(raw) != "" { + if err := json.Unmarshal([]byte(raw), &args); err != nil { + return fmt.Errorf("SYB 规格 AI 解析参数不是合法 JSON: %w", err) + } + } + if args.BatchLimit < 1 || args.BatchLimit > 100 { + return errors.New("SYB 规格 AI 解析 batchLimit 必须在 1 到 100 之间") + } + run, created, err := StartSpecAIParseRun(context.Background(), db, uuid.NewString(), args.BatchLimit) + if err != nil || !created { + return err + } + return ProcessSpecAIParseRun(context.Background(), db, run.ID) +} diff --git a/server/app/goauto/sybimport/ai_parse_batch_test.go b/server/app/goauto/sybimport/ai_parse_batch_test.go new file mode 100644 index 0000000..cdbdc8f --- /dev/null +++ b/server/app/goauto/sybimport/ai_parse_batch_test.go @@ -0,0 +1,204 @@ +package sybimport_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/shopeeproduct" + "go-admin/app/goauto/sybimport" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +func seedAIParseCandidate(t *testing.T, db *gorm.DB, serverURL string, detailID uint64, productSpec string) models.SYBProduct { + t.Helper() + detail := realDetailA() + detail.ID = detailID + detail.ProductSpec = productSpec + detail.Raw = []byte(fmt.Sprintf(`{"id":%d,"productSpec":%q}`, detail.ID, productSpec)) + applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), detail) + if err != nil { + t.Fatal(err) + } + specs, err := shopeeproduct.Marshal([]shopeeproduct.SpecDimension{ + {Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "黑色", Source: shopeeproduct.ValueSourceImport}, {Name: "白色", Source: shopeeproduct.ValueSourceImport}}}, + {Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "L", Source: shopeeproduct.ValueSourceImport}, {Name: "XL", Source: shopeeproduct.ValueSourceImport}}}, + }) + if err != nil { + t.Fatal(err) + } + if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", *applied.SYBProduct.ShopeeProductID).Update("specs_json", specs).Error; err != nil { + t.Fatal(err) + } + setting := models.AIMatchingSetting{ID: 1, Enabled: true, Provider: "openai_compatible", BaseURL: serverURL, Model: "test-model", APIKey: "test-key", TimeoutSeconds: 5, AutoConfirmMinConfidence: 0.9} + if err := db.Save(&setting).Error; err != nil { + t.Fatal(err) + } + return applied.SYBProduct +} + +func TestScheduledAIParseConfirmsClosedCandidatesAndDoesNotRepeat(t *testing.T) { + var calls atomic.Int32 + provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + calls.Add(1) + response.Header().Set("Content-Type", "application/json") + _, _ = response.Write([]byte(`{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"原文对应唯一候选\",\"confidence\":0.95}"}}]}`)) + })) + defer provider.Close() + db := openTestDB(t) + record := seedAIParseCandidate(t, db, provider.URL, 19801, "黑色 XL") + rawBefore := record.RawJSON + run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20) + if err != nil || !created { + t.Fatalf("start run: created=%v err=%v", created, err) + } + if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil { + t.Fatal(err) + } + if err := db.First(&record, record.ID).Error; err != nil { + t.Fatal(err) + } + if !record.AIConfirmed || record.ManuallyConfirmed || record.ParseStatus != models.SYBParseStatusUncertain || record.TargetColor != "黑色" || record.TargetSize != "XL" || record.AIConfidence == nil || *record.AIConfidence != 0.95 || record.AIReason == "" || record.AIInputFingerprint == "" { + t.Fatalf("unexpected confirmed record: %+v", record) + } + if record.RawJSON != rawBefore { + t.Fatal("AI confirmation must not rewrite RawJSON") + } + var finished models.SYBSpecAIParseRun + if err := db.First(&finished, run.ID).Error; err != nil { + t.Fatal(err) + } + if finished.Status != "completed" || finished.ConfirmedCount != 1 || finished.ProcessedCount != 1 { + t.Fatalf("unexpected run: %+v", finished) + } + second, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20) + if err != nil || !created { + t.Fatalf("second start: created=%v err=%v", created, err) + } + if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, second.ID); err != nil { + t.Fatal(err) + } + if calls.Load() != 1 { + t.Fatalf("unchanged confirmed input called provider %d times", calls.Load()) + } +} + +func TestScheduledAIParseLeavesLowConfidenceUnmatchedForSameFingerprint(t *testing.T) { + var calls atomic.Int32 + provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + calls.Add(1) + _, _ = response.Write([]byte(`{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"仍有歧义\",\"confidence\":0.4}"}}]}`)) + })) + defer provider.Close() + db := openTestDB(t) + record := seedAIParseCandidate(t, db, provider.URL, 19802, "黑色 XL") + for i := 0; i < 2; i++ { + run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20) + if err != nil || !created { + t.Fatalf("start %d: created=%v err=%v", i, created, err) + } + if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil { + t.Fatal(err) + } + } + if err := db.First(&record, record.ID).Error; err != nil { + t.Fatal(err) + } + if record.AIConfirmed || calls.Load() != 1 { + t.Fatalf("low confidence must remain unconfirmed and not repeat: confirmed=%v calls=%d", record.AIConfirmed, calls.Load()) + } + var work models.SYBSpecAIParseWorkItem + if err := db.Where("syb_product_id = ?", record.ID).First(&work).Error; err != nil { + t.Fatal(err) + } + if work.Status != "unmatched" { + t.Fatalf("work status=%s", work.Status) + } +} + +func TestScheduledAIParseSkipsEmptySourceAndManualConfirmation(t *testing.T) { + var calls atomic.Int32 + provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + calls.Add(1) + response.WriteHeader(http.StatusInternalServerError) + })) + defer provider.Close() + db := openTestDB(t) + empty := seedAIParseCandidate(t, db, provider.URL, 19803, "") + manual := seedAIParseCandidate(t, db, provider.URL, 19804, "黑色 XL") + if _, err := sybimport.ManualCorrect(context.Background(), db, manual.ID, "黑色", "XL"); err != nil { + t.Fatal(err) + } + run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20) + if err != nil || !created { + t.Fatalf("start: created=%v err=%v", created, err) + } + if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil { + t.Fatal(err) + } + if calls.Load() != 0 { + t.Fatalf("ineligible rows called provider %d times", calls.Load()) + } + var workCount int64 + if err := db.Model(&models.SYBSpecAIParseWorkItem{}).Where("syb_product_id IN ?", []uint64{empty.ID, manual.ID}).Count(&workCount).Error; err != nil { + t.Fatal(err) + } + if workCount != 0 { + t.Fatalf("ineligible rows created %d work items", workCount) + } +} + +func TestSpecAIParseRunHasSingleGlobalActiveSlot(t *testing.T) { + db := openTestDB(t) + first, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20) + if err != nil || !created { + t.Fatalf("first: created=%v err=%v", created, err) + } + second, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20) + if err != nil || created || second.ID != first.ID { + t.Fatalf("second must reuse active run: first=%d second=%d created=%v err=%v", first.ID, second.ID, created, err) + } +} + +func TestScheduledAIParseRetriesProviderFailureAtMostThreeTimes(t *testing.T) { + var calls atomic.Int32 + provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + calls.Add(1) + response.WriteHeader(http.StatusBadGateway) + })) + defer provider.Close() + db := openTestDB(t) + record := seedAIParseCandidate(t, db, provider.URL, 19805, "黑色 XL") + for attempt := 1; attempt <= 4; attempt++ { + run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20) + if err != nil || !created { + t.Fatalf("start %d: created=%v err=%v", attempt, created, err) + } + if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil { + t.Fatal(err) + } + if attempt < 3 { + past := time.Now().UTC().Add(-time.Minute) + if err := db.Model(&models.SYBSpecAIParseWorkItem{}).Where("syb_product_id = ?", record.ID).Update("next_attempt_at", past).Error; err != nil { + t.Fatal(err) + } + } + } + if calls.Load() != 3 { + t.Fatalf("provider calls=%d, want 3", calls.Load()) + } + var work models.SYBSpecAIParseWorkItem + if err := db.Where("syb_product_id = ?", record.ID).First(&work).Error; err != nil { + t.Fatal(err) + } + if work.AttemptCount != 3 || work.NextAttemptAt != nil { + t.Fatalf("retry state=%+v", work) + } +} diff --git a/server/app/goauto/sybimport/apply.go b/server/app/goauto/sybimport/apply.go index ed27d0a..8e18a2b 100644 --- a/server/app/goauto/sybimport/apply.go +++ b/server/app/goauto/sybimport/apply.go @@ -130,13 +130,37 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta result.Outcome = OutcomeCreated case err == nil: record.ID = existing.ID - if err := tx.Model(&models.SYBProduct{}).Where("id = ?", existing.ID).Updates(map[string]any{ + // Human-confirmed target values are authoritative and survive every + // source re-import. ParseStatus/ParseNote below still record what the + // current deterministic parser observed for audit. + if existing.ManuallyConfirmed { + record.TargetColor, record.TargetSize = existing.TargetColor, existing.TargetSize + record.ManuallyConfirmed = true + } + updates := map[string]any{ "stock_id": record.StockID, "shop_name": record.ShopName, "shopee_item_id": record.ShopeeItemID, "shopee_product_id": record.ShopeeProductID, "product_title": record.ProductTitle, "target_color": record.TargetColor, "target_size": record.TargetSize, "quantity": record.Quantity, "unit_price_cent": record.UnitPriceCent, "image_url": record.ImageURL, "parse_status": record.ParseStatus, "parse_note": record.ParseNote, "raw_json": record.RawJSON, - }).Error; err != nil { + } + // An identical re-import keeps a valid AI decision. Changed source, + // link, or a newly deterministic parse invalidates it atomically. + preserveAI := existing.AIConfirmed && !existing.ManuallyConfirmed && parsed.Status != models.SYBParseStatusSuccess && + existing.RawJSON == record.RawJSON && sameOptionalID(existing.ShopeeProductID, record.ShopeeProductID) + if preserveAI { + record.TargetColor, record.TargetSize = existing.TargetColor, existing.TargetSize + updates["target_color"], updates["target_size"] = existing.TargetColor, existing.TargetSize + record.AIConfirmed, record.AIConfidence, record.AIReason = true, existing.AIConfidence, existing.AIReason + record.AIConfirmedAt, record.AIInputFingerprint = existing.AIConfirmedAt, existing.AIInputFingerprint + } else { + updates["ai_confirmed"], updates["ai_confidence"], updates["ai_reason"] = false, nil, "" + updates["ai_confirmed_at"], updates["ai_input_fingerprint"] = nil, "" + } + if existing.ManuallyConfirmed { + updates["target_color"], updates["target_size"] = existing.TargetColor, existing.TargetSize + } + if err := tx.Model(&models.SYBProduct{}).Where("id = ?", existing.ID).Updates(updates).Error; err != nil { return err } result.Outcome = OutcomeUpdated @@ -161,6 +185,13 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta return result, nil } +func sameOptionalID(left, right *uint64) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + return *left == *right +} + // findOrCreateShopeeProduct implements #40's revival rule: a live match wins, // a soft-deleted match is revived (keeping its prior mapping), and only when // neither exists does the import create a minimal archive. On an existing diff --git a/server/app/goauto/sybimport/reparse.go b/server/app/goauto/sybimport/reparse.go index 4ade256..195056d 100644 --- a/server/app/goauto/sybimport/reparse.go +++ b/server/app/goauto/sybimport/reparse.go @@ -28,7 +28,7 @@ type rawDetailSpec struct { // result page's per-line feedback. type ReparseOutcome struct { SYBProductID uint64 `json:"sybProductId"` - Outcome string `json:"outcome"` // reparsed | skipped_manual | unchanged + Outcome string `json:"outcome"` // reparsed | skipped_manual | skipped_ai | unchanged OldStatus string `json:"oldStatus"` NewStatus string `json:"newStatus"` } @@ -36,6 +36,7 @@ type ReparseOutcome struct { const ( ReparseOutcomeReparsed = "reparsed" ReparseOutcomeSkippedManual = "skipped_manual" + ReparseOutcomeSkippedAI = "skipped_ai" ReparseOutcomeUnchanged = "unchanged" ) @@ -65,6 +66,11 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool) outcome.NewStatus = record.ParseStatus return nil } + if record.AIConfirmed && !force { + outcome.Outcome = ReparseOutcomeSkippedAI + outcome.NewStatus = record.ParseStatus + return nil + } var raw rawDetailSpec if err := json.Unmarshal([]byte(record.RawJSON), &raw); err != nil { @@ -76,8 +82,11 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool) if parsed.Color == record.TargetColor && parsed.Size == record.TargetSize && parsed.Status == record.ParseStatus { outcome.Outcome = ReparseOutcomeUnchanged if force { - record.ManuallyConfirmed = false - if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Update("manually_confirmed", false).Error; err != nil { + record.ManuallyConfirmed, record.AIConfirmed = false, false + if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Updates(map[string]any{ + "manually_confirmed": false, "ai_confirmed": false, "ai_confidence": nil, + "ai_reason": "", "ai_confirmed_at": nil, "ai_input_fingerprint": "", + }).Error; err != nil { return err } } @@ -87,6 +96,8 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool) updates := map[string]any{ "target_color": parsed.Color, "target_size": parsed.Size, "parse_status": parsed.Status, "parse_note": parsed.Note, "manually_confirmed": false, + "ai_confirmed": false, "ai_confidence": nil, "ai_reason": "", + "ai_confirmed_at": nil, "ai_input_fingerprint": "", } if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Updates(updates).Error; err != nil { return err @@ -140,8 +151,12 @@ func ManualCorrect(ctx context.Context, db *gorm.DB, sybProductID uint64, color, return err } record.TargetColor, record.TargetSize, record.ManuallyConfirmed = color, size, true + record.AIConfirmed, record.AIConfidence, record.AIReason = false, nil, "" + record.AIConfirmedAt, record.AIInputFingerprint = nil, "" if err := tx.Model(&models.SYBProduct{}).Where("id = ?", sybProductID).Updates(map[string]any{ "target_color": color, "target_size": size, "manually_confirmed": true, + "ai_confirmed": false, "ai_confidence": nil, "ai_reason": "", + "ai_confirmed_at": nil, "ai_input_fingerprint": "", }).Error; err != nil { return err } diff --git a/server/app/goauto/sybimport/reparse_test.go b/server/app/goauto/sybimport/reparse_test.go index 8f9c712..ebd5b97 100644 --- a/server/app/goauto/sybimport/reparse_test.go +++ b/server/app/goauto/sybimport/reparse_test.go @@ -60,6 +60,35 @@ func TestReparseSkipsManuallyConfirmedRowByDefault(t *testing.T) { } } +func TestReparseSkipsAIConfirmationUnlessForced(t *testing.T) { + db := openTestDB(t) + applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailB()) + if err != nil { + t.Fatal(err) + } + confidence := 0.95 + if err := db.Model(&models.SYBProduct{}).Where("id = ?", applied.SYBProduct.ID).Updates(map[string]any{ + "target_color": "AI颜色", "target_size": "AI尺码", "ai_confirmed": true, + "ai_confidence": confidence, "ai_reason": "AI 结果", "ai_input_fingerprint": strings.Repeat("c", 64), + }).Error; err != nil { + t.Fatal(err) + } + outcome, err := sybimport.Reparse(context.Background(), db, applied.SYBProduct.ID, false) + if err != nil || outcome.Outcome != sybimport.ReparseOutcomeSkippedAI { + t.Fatalf("unforced outcome=%+v err=%v", outcome, err) + } + if _, err := sybimport.Reparse(context.Background(), db, applied.SYBProduct.ID, true); err != nil { + t.Fatal(err) + } + var record models.SYBProduct + if err := db.First(&record, applied.SYBProduct.ID).Error; err != nil { + t.Fatal(err) + } + if record.AIConfirmed || record.AIConfidence != nil || record.AIReason != "" || record.AIInputFingerprint != "" { + t.Fatalf("forced reparse retained AI state: %+v", record) + } +} + // 可勾选强制覆盖. func TestReparseWithForceOverridesManualCorrection(t *testing.T) { db := openTestDB(t) @@ -151,6 +180,83 @@ func TestManualCorrectMergesIntoArchiveLikeASuccessfulParse(t *testing.T) { } } +func TestManualCorrectSupersedesAIConfirmation(t *testing.T) { + db := openTestDB(t) + applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailB()) + if err != nil { + t.Fatal(err) + } + confidence := 0.96 + if err := db.Model(&models.SYBProduct{}).Where("id = ?", applied.SYBProduct.ID).Updates(map[string]any{ + "ai_confirmed": true, "ai_confidence": confidence, "ai_reason": "旧 AI 结果", "ai_input_fingerprint": strings.Repeat("a", 64), + }).Error; err != nil { + t.Fatal(err) + } + corrected, err := sybimport.ManualCorrect(context.Background(), db, applied.SYBProduct.ID, "人工颜色", "人工尺码") + if err != nil { + t.Fatal(err) + } + if !corrected.ManuallyConfirmed || corrected.AIConfirmed || corrected.AIConfidence != nil || corrected.AIReason != "" || corrected.AIInputFingerprint != "" { + t.Fatalf("human correction did not supersede AI state: %+v", corrected) + } +} + +func TestReimportPreservesIdenticalAIInputAndInvalidatesChangedSource(t *testing.T) { + db := openTestDB(t) + order, detail := realOrder(), realDetailB() + first, err := sybimport.ApplyDetail(context.Background(), db, order, detail) + if err != nil { + t.Fatal(err) + } + confidence := 0.95 + if err := db.Model(&models.SYBProduct{}).Where("id = ?", first.SYBProduct.ID).Updates(map[string]any{ + "target_color": "AI颜色", "target_size": "AI尺码", "ai_confirmed": true, + "ai_confidence": confidence, "ai_reason": "已确认", "ai_input_fingerprint": strings.Repeat("b", 64), + }).Error; err != nil { + t.Fatal(err) + } + same, err := sybimport.ApplyDetail(context.Background(), db, order, detail) + if err != nil { + t.Fatal(err) + } + if !same.SYBProduct.AIConfirmed { + t.Fatal("identical re-import must preserve AI confirmation") + } + if same.SYBProduct.TargetColor != "AI颜色" || same.SYBProduct.TargetSize != "AI尺码" { + t.Fatal("identical re-import must preserve AI-confirmed target values") + } + detail.ProductSpec += " 新备注" + detail.Raw = []byte(`{"productSpec":"changed"}`) + changed, err := sybimport.ApplyDetail(context.Background(), db, order, detail) + if err != nil { + t.Fatal(err) + } + if changed.SYBProduct.AIConfirmed || changed.SYBProduct.AIConfidence != nil || changed.SYBProduct.AIReason != "" { + t.Fatalf("changed source retained stale AI state: %+v", changed.SYBProduct) + } +} + +func TestReimportNeverOverwritesManualTargetValues(t *testing.T) { + db := openTestDB(t) + order, detail := realOrder(), realDetailB() + first, err := sybimport.ApplyDetail(context.Background(), db, order, detail) + if err != nil { + t.Fatal(err) + } + if _, err := sybimport.ManualCorrect(context.Background(), db, first.SYBProduct.ID, "人工颜色", "人工尺码"); err != nil { + t.Fatal(err) + } + detail.ProductSpec = "来源新颜色,来源新尺码" + detail.Raw = []byte(`{"productSpec":"来源新颜色,来源新尺码"}`) + updated, err := sybimport.ApplyDetail(context.Background(), db, order, detail) + if err != nil { + t.Fatal(err) + } + if !updated.SYBProduct.ManuallyConfirmed || updated.SYBProduct.TargetColor != "人工颜色" || updated.SYBProduct.TargetSize != "人工尺码" { + t.Fatalf("re-import overwrote human decision: %+v", updated.SYBProduct) + } +} + // Regression test: ReparseOutcome originally had no json tags at all, so Go's // default marshaling produced PascalCase keys ("SYBProductID", "OldStatus") // instead of the camelCase the rest of this API and the admin frontend use. diff --git a/server/app/jobs/examples.go b/server/app/jobs/examples.go index dfd2696..8036b15 100644 --- a/server/app/jobs/examples.go +++ b/server/app/jobs/examples.go @@ -15,6 +15,7 @@ func InitJob() { jobList = map[string]JobExec{ "ExamplesOne": ExamplesOne{}, sybimport.HourlySyncInvokeTarget: sybimport.HourlySyncJob{}, + sybimport.SpecAIParseInvokeTarget: sybimport.ScheduledSpecAIParseJob{}, shopeeproduct.SpecAutoMatchInvokeTarget: shopeeproduct.ScheduledAutoMatchJob{}, // ... } diff --git a/server/cmd/migrate/migration/version-local/1788354164329_syb_spec_ai_parse.go b/server/cmd/migrate/migration/version-local/1788354164329_syb_spec_ai_parse.go new file mode 100644 index 0000000..40936df --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1788354164329_syb_spec_ai_parse.go @@ -0,0 +1,47 @@ +package version_local + +import ( + "errors" + "runtime" + + goautomigrations "go-admin/app/goauto/migrations" + "go-admin/app/goauto/sybimport" + jobsmodels "go-admin/app/jobs/models" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + + "gorm.io/gorm" +) + +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSYBSpecAIParse) +} + +func migrateSYBSpecAIParse(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := goautomigrations.Migrate(tx); err != nil { + return err + } + if err := ensureSYBSpecAIParseJob(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} + +func ensureSYBSpecAIParseJob(db *gorm.DB) error { + var existing jobsmodels.SysJob + err := db.Where("invoke_target = ?", sybimport.SpecAIParseInvokeTarget).First(&existing).Error + if err == nil { + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + return db.Create(&jobsmodels.SysJob{ + JobName: "SYB 异常规格 AI 解析", JobGroup: "GoAuto", JobType: 2, + CronExpression: "0 5 * * * *", InvokeTarget: sybimport.SpecAIParseInvokeTarget, + Args: `{"batchLimit":20}`, MisfirePolicy: 1, Concurrent: 1, Status: 1, + }).Error +} diff --git a/server/cmd/migrate/migration/version-local/1788354164329_syb_spec_ai_parse_test.go b/server/cmd/migrate/migration/version-local/1788354164329_syb_spec_ai_parse_test.go new file mode 100644 index 0000000..f77c2f1 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1788354164329_syb_spec_ai_parse_test.go @@ -0,0 +1,50 @@ +package version_local + +import ( + "testing" + + "go-admin/app/goauto/sybimport" + jobsmodels "go-admin/app/jobs/models" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestEnsureSYBSpecAIParseJobIsDisabledIdempotentAndPreservesChanges(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&jobsmodels.SysJob{}); err != nil { + t.Fatal(err) + } + if err := ensureSYBSpecAIParseJob(db); err != nil { + t.Fatal(err) + } + var job jobsmodels.SysJob + if err := db.Where("invoke_target = ?", sybimport.SpecAIParseInvokeTarget).First(&job).Error; err != nil { + t.Fatal(err) + } + if job.Status != 1 || job.CronExpression != "0 5 * * * *" || job.Args != `{"batchLimit":20}` { + t.Fatalf("unexpected initial job: %+v", job) + } + if err := db.Model(&job).Updates(map[string]any{"status": 2, "cron_expression": "0 7 * * * *", "args": `{"batchLimit":7}`}).Error; err != nil { + t.Fatal(err) + } + if err := ensureSYBSpecAIParseJob(db); err != nil { + t.Fatal(err) + } + var count int64 + if err := db.Model(&jobsmodels.SysJob{}).Where("invoke_target = ?", sybimport.SpecAIParseInvokeTarget).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("job count=%d, want 1", count) + } + if err := db.First(&job, job.JobId).Error; err != nil { + t.Fatal(err) + } + if job.Status != 2 || job.CronExpression != "0 7 * * * *" || job.Args != `{"batchLimit":7}` { + t.Fatalf("existing administrator changes were overwritten: %+v", job) + } +}