diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 4c47b2e..2b6d76e 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -1,3 +1,11 @@ + +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: 61a6effd4badcf94d9c4745521c4f3945a7be3ef +synchronized_at: 2026-08-29T03:46:32Z + + generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map @@ -248,6 +256,13 @@ Android Portal/Agent - 历史采购执行身份继续以 `PDDGoodsIDSnapshot` / `PDDURLSnapshot` 为准;当前采购查询未发现按可变 `pdd_product_id` 聚合历史数据的实现,因此本工单无需改写统计 SQL。 +## 创建采购任务异步规格匹配(#148) + +- `purchase_spec_match_work_item` 是采购独立持久工作项;模型在 `server/app/goauto/models/purchase.go`,迁移由 `migrations.MigratedModels` 统一创建。 +- `server/app/goauto/purchase/match_worker.go` 负责原子领取、租约、30 秒/2 分钟/10 分钟退避、启动恢复、严格自动确认和输入指纹重校验;只写采购任务快照,不写虾皮长期映射。 +- `service.go` 在需要 provider 时把 `pending` 任务与工作项同事务创建并立即返回;`lifecycle.go` 在 `Next`、`Claim`、`Start` 三个入口检查工作项,防止未完成匹配的任务执行。 +- `match_admin.go`、`admin_query.go` 和 `/purchase-tasks/{taskId}/matching*` 提供状态、重新入队和人工候选选择;Admin 页面在列表和详情展示匹配状态与恢复操作。 + ## PDD 颜色图片采集与存储(#133) ```text diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 88788b8..b8ab2aa 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -1,3 +1,11 @@ + +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: 71d8707aba33d07f329e1cc86a087383e555738d +synchronized_at: 2026-08-29T03:47:02Z + + generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary @@ -324,6 +332,13 @@ synchronized_at: 2026-08-29T01:39:44Z - 自动确认必须保存来源、实际置信度和脱敏限长原因;人工确认完成后同步对应分项及主表总体状态。worker 不得覆盖已由人工改变的映射。 - 纠错不能简单执行 B→C;必须把原 A→B 记录置为 `superseded`,建立 A→C,并只处理原记录冻结的虾皮商品影响集合。 +## 创建采购任务时的异步规格匹配(#148) + +- 创建任务继续同步使用已确认映射和唯一确定性 `exact_match`;只有需要外部 AI 时,任务与 `purchase_spec_match_work_item` 在同一事务创建,接口立即返回。活动工作项或 `manual_required` 未处理前,`Next`、`Claim`、`Start` 均拒绝任务。 +- AI 结果只固化到采购任务快照,不自动写回虾皮商品长期规格映射。`ai_match` 必须达到设置阈值、严格属于当前候选、原因非空且写入前输入指纹未变化;否则转人工,Agent 不猜测。 +- provider 网络、超时或 5xx 按 30 秒、2 分钟、10 分钟退避,最多尝试 3 次;预算内恢复会自动继续,耗尽后转 `manual_required`。Admin 可重新入队或从当前候选人工选择。 +- 异步 provider 单次调用最长 60 秒,即 `min(配置超时, 60 秒)`;设置页测试连接仍使用完整配置超时。取消任务会同时终结活动工作项,输入变化会关闭旧工作项并要求重新入队。 + ## Agent 手动采集替代商品(#130) - 只有同一设备上的失败采集或失败采购任务,且错误码逐字等于 `PDD_LINK_INVALID` 或 `PDD_GOODS_SOLD_OUT` 时,Agent 详情才显示“采集替代商品”;Android 不自行推断资格。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index e623232..d72a1b5 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -1,3 +1,11 @@ + +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: 9f04eb9bb7284e525a58309b9c47cdb593a1e09c +synchronized_at: 2026-08-29T03:49:41Z + + generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Android-Agent-API-Contract @@ -510,6 +518,8 @@ Admin 列表与详情由 #35 实现;#67 增加 `shopeeOrderNoSnapshot` 的列 | `POST` | `/api/agent/v1/purchase-tasks/{taskId}/order-submit-started` | 创建订单前先落不可逆标记;演练任务永远拒绝 | | `POST` | `/api/agent/v1/purchase-tasks/{taskId}/result` | 请求体携带 `taskAttemptId` 和 `requestId`;幂等提交演练、规格探测、订单或失败结果 | +创建阶段需要外部 AI 的任务会先以 `pending` 与持久匹配工作项同事务创建并立即返回 Admin。工作项处于 `pending`、`running`、`retry_wait` 或 `manual_required` 时,`next` 不返回该任务,直接调用 `claim` 或 `start` 也会返回状态冲突。匹配完成后 Agent 仍只接收服务端固化的精确 PDD 原始标签;Android 接口和请求体不新增 AI、候选或人工决策字段。 + 结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。慢路径第一趟提交规格后释放设备与已知账号租约,任务进入 `spec_probe_pending`;服务端先使用已确认人工映射,否则对实时/档案可选规格做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。第二趟只会收到服务端已固化的精确 PDD 原始标签;Agent 只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整文字精确点击,连续没有新证据或达到上限即停止。若第二趟仍提交 `spec_probe_completed`,服务端将任务和当前 attempt 明确标记失败、释放租约并保留第一次规格决策,不再进入 `spec_probe_pending` 或再次派发。无匹配、候选不完整、歧义或 Provider 异常同样使任务失败。`order_result_unknown` 只允许管理员或采购员人工解除,永不自动重派。 `openSpecPanel.textAliases` 是可选的候选过滤条件,不是原始页面文本选择器。省略该字段时,Agent 使用语义安全的规格入口或底部购买入口;提供时也只能与这些安全候选取交集,匹配不到即返回 `RULE_NOT_MATCHED`。 diff --git a/server/app/goauto/access/purchaser.go b/server/app/goauto/access/purchaser.go index 5e9a519..b1aa36b 100644 --- a/server/app/goauto/access/purchaser.go +++ b/server/app/goauto/access/purchaser.go @@ -89,6 +89,9 @@ var AdminAPIs = []APIPermission{ {"查看采购任务详情", "/api/admin/v1/purchase-tasks/:taskId", "GET", true}, {"创建采购任务", "/api/admin/v1/purchase-tasks", "POST", true}, {"处理采购规格", "/api/admin/v1/purchase-tasks/:taskId/spec-decision", "POST", true}, + {"查看采购规格匹配", "/api/admin/v1/purchase-tasks/:taskId/matching", "GET", true}, + {"重新尝试采购规格匹配", "/api/admin/v1/purchase-tasks/:taskId/matching/requeue", "POST", true}, + {"人工选择采购规格", "/api/admin/v1/purchase-tasks/:taskId/matching/manual", "POST", true}, {"授权重新采购", "/api/admin/v1/purchase-tasks/:taskId/authorize-repurchase", "POST", true}, {"复核支付状态", "/api/admin/v1/purchase-tasks/:taskId/payment-review", "POST", true}, {"选择回填候选", "/api/admin/v1/purchase-tasks/:taskId/writeback-candidate", "POST", true}, diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go index 4a00f3b..7f075d1 100644 --- a/server/app/goauto/migrations/migrate.go +++ b/server/app/goauto/migrations/migrate.go @@ -48,6 +48,7 @@ func MigratedModels() []any { &models.PDDAccount{}, &models.PurchaseTask{}, &models.PurchaseTaskAttempt{}, + &models.PurchaseSpecMatchWorkItem{}, &models.CollectionRule{}, &models.AgentManualCollectionSetting{}, &models.CollectionTask{}, diff --git a/server/app/goauto/models/purchase.go b/server/app/goauto/models/purchase.go index b329f62..2d0c5dd 100644 --- a/server/app/goauto/models/purchase.go +++ b/server/app/goauto/models/purchase.go @@ -47,6 +47,15 @@ const ( PurchaseWritebackStatusFailed = "failed" ) +const ( + PurchaseMatchPending = "pending" + PurchaseMatchRunning = "running" + PurchaseMatchRetryWait = "retry_wait" + PurchaseMatchMatched = "matched" + PurchaseMatchManualRequired = "manual_required" + PurchaseMatchCancelled = "cancelled" +) + // PDDAccount is an optional operator-maintained reference used only for // account-level scheduling. Android cannot reliably read the logged-in PDD // account, so purchase tasks never require this relation and this table never @@ -162,6 +171,27 @@ type PurchaseTask struct { func (PurchaseTask) TableName() string { return "purchase_task" } +type PurchaseSpecMatchWorkItem struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + PurchaseTaskID uint64 `json:"purchaseTaskId" gorm:"not null;uniqueIndex:ux_purchase_match_task"` + PurchaseTask PurchaseTask `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"` + Status string `json:"status" gorm:"size:24;not null;index;check:ck_purchase_match_status,status IN ('pending','running','retry_wait','matched','manual_required','cancelled')"` + AttemptCount int `json:"attemptCount" gorm:"not null;default:0"` + NextAttemptAt *time.Time `json:"nextAttemptAt" 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"` + LastErrorAt *time.Time `json:"lastErrorAt,omitempty"` + Reason string `json:"reason" gorm:"size:500;not null;default:''"` + InputFingerprint string `json:"-" gorm:"size:64;not null"` + InputSnapshotJSON string `json:"-" gorm:"type:json;not null"` + CompletedAt *time.Time `json:"completedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (PurchaseSpecMatchWorkItem) TableName() string { return "purchase_spec_match_work_item" } + func (task *PurchaseTask) BeforeCreate(_ *gorm.DB) error { if task.StatusVersion == 0 { task.StatusVersion = 1 diff --git a/server/app/goauto/purchase/admin_query.go b/server/app/goauto/purchase/admin_query.go index a5a29f9..1f71686 100644 --- a/server/app/goauto/purchase/admin_query.go +++ b/server/app/goauto/purchase/admin_query.go @@ -2,6 +2,7 @@ package purchase import ( "context" + "encoding/json" "errors" "strings" "time" @@ -24,56 +25,57 @@ type AdminListRequest struct { } type AdminTaskItem struct { - ID uint64 `json:"id"` - TaskType string `json:"taskType"` - ExecutionMode string `json:"executionMode"` - Status string `json:"status"` - SYBProductID *uint64 `json:"sybProductId,omitempty"` - ShopeeProductID *uint64 `json:"shopeeProductId,omitempty"` - PDDProductID uint64 `json:"pddProductId"` - DeviceID *uint64 `json:"deviceId,omitempty"` - DeviceName string `json:"deviceName,omitempty"` - ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot"` - ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot"` - ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot"` - ShopeeShopNameSnapshot string `json:"shopeeShopNameSnapshot"` - PDDGoodsIDSnapshot string `json:"pddGoodsIdSnapshot"` - PDDTitleSnapshot string `json:"pddTitleSnapshot"` - TargetColorSnapshot string `json:"targetColorSnapshot"` - TargetSizeSnapshot string `json:"targetSizeSnapshot"` - MappedColorSnapshot string `json:"mappedColorSnapshot"` - MappedSizeSnapshot string `json:"mappedSizeSnapshot"` - SpecSource string `json:"specSource"` - Quantity int64 `json:"quantity"` - ReferenceUnitPriceCent int64 `json:"referenceUnitPriceCent"` - MinUnitPriceCent int64 `json:"minUnitPriceCent"` - MaxUnitPriceCent int64 `json:"maxUnitPriceCent"` - Currency string `json:"currency"` - PDDAccountRefSnapshot string `json:"pddAccountRefSnapshot"` - AddressSuffix string `json:"addressSuffix"` - PDDOrderNo *string `json:"pddOrderNo,omitempty"` - OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"` - IrreversibleAt *time.Time `json:"irreversibleAt,omitempty"` - PaymentReviewStatus string `json:"paymentReviewStatus"` - PaymentReviewedAt *time.Time `json:"paymentReviewedAt,omitempty"` - TrackingNo *string `json:"trackingNo,omitempty"` - TrackingCollectedAt *time.Time `json:"trackingCollectedAt,omitempty"` - LogisticsStatus string `json:"logisticsStatus"` - WritebackStatus string `json:"writebackStatus"` - WritebackAt *time.Time `json:"writebackAt,omitempty"` - RePurchaseAuthorizedAt *time.Time `json:"rePurchaseAuthorizedAt,omitempty"` - RePurchaseConsumedAt *time.Time `json:"rePurchaseConsumedAt,omitempty"` - CancelledAt *time.Time `json:"cancelledAt,omitempty"` - CancelReason *string `json:"cancelReason,omitempty"` - ErrorCode *string `json:"errorCode,omitempty"` - ErrorMessage *string `json:"errorMessage,omitempty"` - Retryable bool `json:"retryable"` - RetryDisabledCode string `json:"retryDisabledCode,omitempty"` - RetryDisabledReason string `json:"retryDisabledReason,omitempty"` - StatusVersion uint64 `json:"statusVersion"` - StatusChangedAt time.Time `json:"statusChangedAt"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID uint64 `json:"id"` + TaskType string `json:"taskType"` + ExecutionMode string `json:"executionMode"` + Status string `json:"status"` + SYBProductID *uint64 `json:"sybProductId,omitempty"` + ShopeeProductID *uint64 `json:"shopeeProductId,omitempty"` + PDDProductID uint64 `json:"pddProductId"` + DeviceID *uint64 `json:"deviceId,omitempty"` + DeviceName string `json:"deviceName,omitempty"` + ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot"` + ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot"` + ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot"` + ShopeeShopNameSnapshot string `json:"shopeeShopNameSnapshot"` + PDDGoodsIDSnapshot string `json:"pddGoodsIdSnapshot"` + PDDTitleSnapshot string `json:"pddTitleSnapshot"` + TargetColorSnapshot string `json:"targetColorSnapshot"` + TargetSizeSnapshot string `json:"targetSizeSnapshot"` + MappedColorSnapshot string `json:"mappedColorSnapshot"` + MappedSizeSnapshot string `json:"mappedSizeSnapshot"` + SpecSource string `json:"specSource"` + Quantity int64 `json:"quantity"` + ReferenceUnitPriceCent int64 `json:"referenceUnitPriceCent"` + MinUnitPriceCent int64 `json:"minUnitPriceCent"` + MaxUnitPriceCent int64 `json:"maxUnitPriceCent"` + Currency string `json:"currency"` + PDDAccountRefSnapshot string `json:"pddAccountRefSnapshot"` + AddressSuffix string `json:"addressSuffix"` + PDDOrderNo *string `json:"pddOrderNo,omitempty"` + OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"` + IrreversibleAt *time.Time `json:"irreversibleAt,omitempty"` + PaymentReviewStatus string `json:"paymentReviewStatus"` + PaymentReviewedAt *time.Time `json:"paymentReviewedAt,omitempty"` + TrackingNo *string `json:"trackingNo,omitempty"` + TrackingCollectedAt *time.Time `json:"trackingCollectedAt,omitempty"` + LogisticsStatus string `json:"logisticsStatus"` + WritebackStatus string `json:"writebackStatus"` + WritebackAt *time.Time `json:"writebackAt,omitempty"` + RePurchaseAuthorizedAt *time.Time `json:"rePurchaseAuthorizedAt,omitempty"` + RePurchaseConsumedAt *time.Time `json:"rePurchaseConsumedAt,omitempty"` + CancelledAt *time.Time `json:"cancelledAt,omitempty"` + CancelReason *string `json:"cancelReason,omitempty"` + ErrorCode *string `json:"errorCode,omitempty"` + ErrorMessage *string `json:"errorMessage,omitempty"` + Retryable bool `json:"retryable"` + RetryDisabledCode string `json:"retryDisabledCode,omitempty"` + RetryDisabledReason string `json:"retryDisabledReason,omitempty"` + StatusVersion uint64 `json:"statusVersion"` + StatusChangedAt time.Time `json:"statusChangedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Matching MatchingView `json:"matching"` } type AdminAttemptItem struct { @@ -101,6 +103,7 @@ type AdminListResponse struct { type AdminDetailResponse struct { Task AdminTaskItem `json:"task"` Attempts []AdminAttemptItem `json:"attempts"` + Matching MatchingView `json:"matching"` } func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminListResponse, error) { @@ -156,9 +159,15 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis if err != nil { return AdminListResponse{}, err } + matching, err := s.matchingViews(ctx, tasks) + if err != nil { + return AdminListResponse{}, err + } items := make([]AdminTaskItem, 0, len(tasks)) for _, task := range tasks { - items = append(items, adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))) + item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true)) + item.Matching = matching[task.ID] + items = append(items, item) } return AdminListResponse{Items: items, Total: total, Page: req.Page, PageSize: req.PageSize}, nil } @@ -188,7 +197,13 @@ func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailRe StartedAt: attempt.StartedAt, FinishedAt: attempt.FinishedAt, CreatedAt: attempt.CreatedAt, }) } - return AdminDetailResponse{Task: adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true)), Attempts: items}, nil + matching, matchErr := s.MatchingDetail(ctx, task.ID) + if matchErr != nil { + return AdminDetailResponse{}, matchErr + } + item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true)) + item.Matching = matching + return AdminDetailResponse{Task: item, Attempts: items, Matching: matching}, nil } func loadDeviceNames(db *gorm.DB, tasks []models.PurchaseTask) (map[uint64]string, error) { @@ -255,3 +270,74 @@ func validPurchaseStatus(status string) bool { return false } } + +func (s *Service) matchingViews(ctx context.Context, tasks []models.PurchaseTask) (map[uint64]MatchingView, error) { + result := make(map[uint64]MatchingView, len(tasks)) + ids := make([]uint64, 0, len(tasks)) + for _, task := range tasks { + ids = append(ids, task.ID) + result[task.ID] = resolvedMatchingView(task) + } + if len(ids) == 0 { + return result, nil + } + var works []models.PurchaseSpecMatchWorkItem + if err := s.DB.WithContext(ctx).Where("purchase_task_id IN ?", ids).Find(&works).Error; err != nil { + return nil, internal(err) + } + for _, work := range works { + view, err := matchingViewFromWork(work) + if err != nil { + return nil, internal(err) + } + result[work.PurchaseTaskID] = view + } + return result, nil +} + +func (s *Service) MatchingDetail(ctx context.Context, taskID uint64) (MatchingView, error) { + var task models.PurchaseTask + if err := s.DB.WithContext(ctx).First(&task, taskID).Error; err != nil { + return MatchingView{}, purchaseNotFound(err) + } + var work models.PurchaseSpecMatchWorkItem + if err := s.DB.WithContext(ctx).Where("purchase_task_id = ?", taskID).First(&work).Error; errors.Is(err, gorm.ErrRecordNotFound) { + return resolvedMatchingView(task), nil + } else if err != nil { + return MatchingView{}, internal(err) + } + view, err := matchingViewFromWork(work) + if err != nil { + return MatchingView{}, internal(err) + } + return view, nil +} + +func resolvedMatchingView(task models.PurchaseTask) MatchingView { + if task.SpecSource == "unresolved" { + return MatchingView{Status: "unresolved", Executable: true, Reason: "任务执行时需要规格探测"} + } + return MatchingView{Status: models.PurchaseMatchMatched, Executable: true, Reason: "规格已匹配"} +} + +func matchingViewFromWork(work models.PurchaseSpecMatchWorkItem) (MatchingView, error) { + var snapshot matchFingerprint + if err := json.Unmarshal([]byte(work.InputSnapshotJSON), &snapshot); err != nil { + return MatchingView{}, err + } + view := MatchingView{Status: work.Status, Executable: work.Status == models.PurchaseMatchMatched, Reason: work.Reason, NextAttemptAt: work.NextAttemptAt, TargetColor: snapshot.TargetColor, TargetSize: snapshot.TargetSize, Colors: snapshot.Colors, Sizes: snapshot.Sizes} + if work.LastErrorCode != nil { + view.ReasonCode = *work.LastErrorCode + } + switch work.Status { + case models.PurchaseMatchPending, models.PurchaseMatchRunning: + view.NextAction = "wait" + case models.PurchaseMatchRetryWait: + view.NextAction = "wait_retry" + case models.PurchaseMatchManualRequired: + view.NextAction = "manual_or_retry" + case models.PurchaseMatchCancelled: + view.NextAction = "requeue" + } + return view, nil +} diff --git a/server/app/goauto/purchase/handler.go b/server/app/goauto/purchase/handler.go index 8164f5a..6622f33 100644 --- a/server/app/goauto/purchase/handler.go +++ b/server/app/goauto/purchase/handler.go @@ -312,6 +312,58 @@ func (h Handler) SpecDecision(c *gin.Context) { } writeAdminReplay(c, p, replayed) } +func (h Handler) MatchingDetail(c *gin.Context) { + if !allowedOperator(c) { + return + } + id, ok := pathID(c) + if !ok { + return + } + service, serviceOK := h.service(c) + if !serviceOK { + return + } + view, err := service.MatchingDetail(c.Request.Context(), id) + if err != nil { + writeError(c, err) + return + } + writeAdminData(c, view) +} + +func (h Handler) RequeueMatching(c *gin.Context) { h.matchingAction(c, (*Service).RequeueMatching) } +func (h Handler) ManualMatching(c *gin.Context) { h.matchingAction(c, (*Service).ManualMatching) } + +func (h Handler) matchingAction(c *gin.Context, fn func(*Service, context.Context, uint64, MatchingActionRequest) (MatchingView, error)) { + if !allowedOperator(c) { + return + } + id, ok := pathID(c) + if !ok { + return + } + var req MatchingActionRequest + if !decode(c, &req) { + return + } + req.OperatorID = operatorID(c) + if req.OperatorID == 0 { + writeError(c, fail(CodeInvalidRequest, "无法识别当前操作人")) + return + } + service, serviceOK := h.service(c) + if !serviceOK { + return + } + view, err := fn(service, c.Request.Context(), id, req) + if err != nil { + writeError(c, err) + return + } + writeAdminData(c, view) +} + func (h Handler) AuthorizeRePurchase(c *gin.Context) { h.manual(c, (*Service).AuthorizeRePurchase) } func (h Handler) ReviewPayment(c *gin.Context) { h.manual(c, (*Service).ReviewPayment) } func (h Handler) SelectWriteback(c *gin.Context) { h.manual(c, (*Service).SelectWriteback) } diff --git a/server/app/goauto/purchase/lifecycle.go b/server/app/goauto/purchase/lifecycle.go index 100b1f3..2897d29 100644 --- a/server/app/goauto/purchase/lifecycle.go +++ b/server/app/goauto/purchase/lifecycle.go @@ -41,6 +41,11 @@ func (s *Service) Next(ctx context.Context, token string) (*TaskPayload, error) if t.Status == models.PurchaseTaskStatusSpecProbePending && t.MappedColorSnapshot == "" && t.MappedSizeSnapshot == "" { continue } + if active, er := activePurchaseMatch(s.DB.WithContext(ctx), t.ID); er != nil { + return nil, er + } else if active { + continue + } required, er := decodeStrings(t.RequiredCapabilitiesJSON) if er != nil { return nil, internal(er) @@ -77,6 +82,11 @@ func (s *Service) Claim(ctx context.Context, taskID uint64, req ActionRequest, t if t.Status != models.PurchaseTaskStatusPending && t.Status != models.PurchaseTaskStatusSpecProbePending { return fail(CodeStateConflict, "任务当前状态不能领取") } + if active, e := activePurchaseMatch(tx, t.ID); e != nil { + return e + } else if active { + return fail(CodeStateConflict, "任务规格匹配尚未完成") + } if t.DeviceID != nil && *t.DeviceID != d.ID { return fail(CodeStateConflict, "任务已指定给其他设备") } @@ -150,6 +160,11 @@ func (s *Service) Start(ctx context.Context, taskID uint64, req ActionRequest, t if t.Status != models.PurchaseTaskStatusPending && t.Status != models.PurchaseTaskStatusSpecProbePending { return fail(CodeStateConflict, "任务当前状态不能开始") } + if active, e := activePurchaseMatch(tx, t.ID); e != nil { + return e + } else if active { + return fail(CodeStateConflict, "任务规格匹配尚未完成") + } if t.DeviceID == nil || *t.DeviceID != d.ID { return fail(CodeStateConflict, "任务不属于当前设备") } @@ -612,3 +627,11 @@ func (s *Service) lease() time.Duration { } return s.LeaseDuration } + +func activePurchaseMatch(db *gorm.DB, taskID uint64) (bool, error) { + var count int64 + if err := db.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ? AND status IN ?", taskID, []string{models.PurchaseMatchPending, models.PurchaseMatchRunning, models.PurchaseMatchRetryWait, models.PurchaseMatchManualRequired}).Count(&count).Error; err != nil { + return false, internal(err) + } + return count > 0, nil +} diff --git a/server/app/goauto/purchase/manual.go b/server/app/goauto/purchase/manual.go index cd6b649..acbd205 100644 --- a/server/app/goauto/purchase/manual.go +++ b/server/app/goauto/purchase/manual.go @@ -160,6 +160,12 @@ func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, appl if e := apply(&out); e != nil { return e } + if out.Status == models.PurchaseTaskStatusCancelled { + now := s.Now() + if e := tx.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ? AND status IN ?", out.ID, []string{models.PurchaseMatchPending, models.PurchaseMatchRunning, models.PurchaseMatchRetryWait, models.PurchaseMatchManualRequired}).Updates(map[string]any{"status": models.PurchaseMatchCancelled, "completed_at": now, "lease_owner": "", "lease_expires_at": nil, "reason": "采购任务已取消"}).Error; e != nil { + return internal(e) + } + } return tx.Save(&out).Error }) return out, replayed, err diff --git a/server/app/goauto/purchase/match_admin.go b/server/app/goauto/purchase/match_admin.go new file mode 100644 index 0000000..992ce58 --- /dev/null +++ b/server/app/goauto/purchase/match_admin.go @@ -0,0 +1,122 @@ +package purchase + +import ( + "context" + "encoding/json" + "errors" + "strings" + + "go-admin/app/goauto/aimatching" + "go-admin/app/goauto/models" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func (s *Service) RequeueMatching(ctx context.Context, taskID uint64, req MatchingActionRequest) (MatchingView, error) { + if strings.TrimSpace(req.RequestID) == "" || req.OperatorID == 0 { + return MatchingView{}, fail(CodeInvalidRequest, "requestId 或操作人无效") + } + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var task models.PurchaseTask + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&task, taskID).Error; err != nil { + return purchaseNotFound(err) + } + if task.Status != models.PurchaseTaskStatusPending || task.SpecSource != "unresolved" || task.LeaseExpiresAt != nil { + return fail(CodeStateConflict, "任务当前不能重新匹配") + } + var work models.PurchaseSpecMatchWorkItem + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("purchase_task_id = ?", taskID).First(&work).Error; err != nil { + return internal(err) + } + if work.Status != models.PurchaseMatchManualRequired && work.Status != models.PurchaseMatchCancelled { + return fail(CodeStateConflict, "当前匹配状态不能重新入队") + } + fingerprint, snapshot, err := currentPurchaseMatchInput(tx, task) + if err != nil { + return err + } + return tx.Model(&models.PurchaseSpecMatchWorkItem{}).Where("id = ?", work.ID).Updates(map[string]any{"status": models.PurchaseMatchPending, "attempt_count": 0, "next_attempt_at": nil, "lease_owner": "", "lease_expires_at": nil, "input_fingerprint": fingerprint, "input_snapshot_json": snapshot, "completed_at": nil}).Error + }) + if err != nil { + return MatchingView{}, err + } + if s.StartMatching != nil { + s.StartMatching(s.DB) + } + return s.MatchingDetail(ctx, taskID) +} + +func (s *Service) ManualMatching(ctx context.Context, taskID uint64, req MatchingActionRequest) (MatchingView, error) { + if strings.TrimSpace(req.RequestID) == "" || req.OperatorID == 0 { + return MatchingView{}, fail(CodeInvalidRequest, "requestId 或操作人无效") + } + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var task models.PurchaseTask + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&task, taskID).Error; err != nil { + return purchaseNotFound(err) + } + var work models.PurchaseSpecMatchWorkItem + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("purchase_task_id = ?", taskID).First(&work).Error; err != nil { + return internal(err) + } + if task.Status != models.PurchaseTaskStatusPending || task.SpecSource != "unresolved" || task.LeaseExpiresAt != nil || work.Status != models.PurchaseMatchManualRequired { + return fail(CodeStateConflict, "任务当前不能人工选择规格") + } + fingerprint, _, err := currentPurchaseMatchInput(tx, task) + if err != nil || fingerprint != work.InputFingerprint { + return fail(CodeMappingRequired, "候选规格已变化,请重新入队") + } + var snapshot matchFingerprint + if err := json.Unmarshal([]byte(work.InputSnapshotJSON), &snapshot); err != nil { + return internal(err) + } + request := aimatching.MatchRequest{TargetColor: snapshot.TargetColor, TargetSize: snapshot.TargetSize, Colors: snapshot.Colors, Sizes: snapshot.Sizes} + if !matchCandidateValid(request.TargetColor, req.MappedColor, request.Colors) || !matchCandidateValid(request.TargetSize, req.MappedSize, request.Sizes) { + return fail(CodeMappingRequired, "人工选择必须属于当前候选规格") + } + decision := aimatching.RecordedMatch(request, "manual_mapping", strings.TrimSpace(req.MappedColor), strings.TrimSpace(req.MappedSize), "管理员人工选择规格") + raw, err := json.Marshal(decision.Decision) + if err != nil { + return internal(err) + } + now := s.Now() + if err := tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ? AND status = ? AND spec_source = ?", task.ID, models.PurchaseTaskStatusPending, "unresolved").Updates(map[string]any{"mapped_color_snapshot": req.MappedColor, "mapped_size_snapshot": req.MappedSize, "spec_source": "manual_mapping", "spec_decision_snapshot": string(raw), "spec_decision_request_id": req.RequestID, "spec_decision_by": req.OperatorID, "status_version": gorm.Expr("status_version + 1"), "status_changed_at": now}).Error; err != nil { + return internal(err) + } + return tx.Model(&models.PurchaseSpecMatchWorkItem{}).Where("id = ? AND status = ?", work.ID, models.PurchaseMatchManualRequired).Updates(map[string]any{"status": models.PurchaseMatchMatched, "completed_at": now, "lease_owner": "", "lease_expires_at": nil, "reason": "管理员人工选择规格"}).Error + }) + if err != nil { + return MatchingView{}, err + } + return s.MatchingDetail(ctx, taskID) +} + +func currentPurchaseMatchInput(tx *gorm.DB, task models.PurchaseTask) (string, string, error) { + if task.SYBProductID == nil || task.ShopeeProductID == nil { + return "", "", fail(CodeMappingRequired, "规格匹配输入已失效") + } + var syb models.SYBProduct + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&syb, *task.SYBProductID).Error; err != nil { + return "", "", err + } + var shopee models.ShopeeProduct + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&shopee, *task.ShopeeProductID).Error; err != nil { + return "", "", err + } + var pdd models.PDDProduct + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pdd, task.PDDProductID).Error; err != nil { + return "", "", err + } + if shopee.PDDProductID == nil || *shopee.PDDProductID != pdd.ID || pdd.Status != "active" { + return "", "", fail(CodeMappingRequired, "商品关联或状态已变化") + } + candidates, usable := archiveCandidates(pdd.SpecsJSON, task.TargetColorSnapshot, task.TargetSizeSnapshot) + if !usable { + return "", "", fail(CodeMappingRequired, "候选规格已失效") + } + request := aimatching.MatchRequest{TargetColor: task.TargetColorSnapshot, TargetSize: task.TargetSizeSnapshot, Colors: candidates.Colors, Sizes: candidates.Sizes} + return creationMatchInput(syb, shopee, pdd, request) +} + +var _ = errors.Is diff --git a/server/app/goauto/purchase/match_worker.go b/server/app/goauto/purchase/match_worker.go new file mode 100644 index 0000000..29d0703 --- /dev/null +++ b/server/app/goauto/purchase/match_worker.go @@ -0,0 +1,240 @@ +package purchase + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "go-admin/app/goauto/aimatching" + "go-admin/app/goauto/models" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ( + purchaseMatchAttempts = 3 + purchaseMatchLease = 90 * time.Second + purchaseMatchTimeout = 60 * time.Second +) + +var purchaseMatchBackoff = [...]time.Duration{30 * time.Second, 2 * time.Minute, 10 * time.Minute} + +type PurchaseMatchWorker struct { + DB *gorm.DB + Matcher SpecMatcher + OwnerID string + Now func() time.Time +} + +func NewPurchaseMatchWorker(db *gorm.DB) *PurchaseMatchWorker { + return &PurchaseMatchWorker{DB: db, Matcher: aimatching.NewService(db), OwnerID: uuid.NewString(), Now: func() time.Time { return time.Now().UTC() }} +} + +func StartPurchaseMatching(db *gorm.DB) { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + _ = NewPurchaseMatchWorker(db).RunPending(ctx) + }() +} + +func RecoverPurchaseMatching(db *gorm.DB) { + go func() { + worker := NewPurchaseMatchWorker(db) + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + _ = worker.RunPending(context.Background()) + <-ticker.C + } + }() +} + +func (worker *PurchaseMatchWorker) RunPending(ctx context.Context) error { + for { + processed, err := worker.RunOnce(ctx) + if err != nil || !processed { + return err + } + } +} + +func (worker *PurchaseMatchWorker) RunOnce(ctx context.Context) (bool, error) { + if worker.Matcher == nil { + worker.Matcher = aimatching.NewService(worker.DB) + } + if worker.OwnerID == "" { + worker.OwnerID = uuid.NewString() + } + if worker.Now == nil { + worker.Now = func() time.Time { return time.Now().UTC() } + } + now := worker.Now() + var item models.PurchaseSpecMatchWorkItem + err := worker.DB.WithContext(ctx). + Where("status IN ? AND (next_attempt_at IS NULL OR next_attempt_at <= ?) AND (lease_expires_at IS NULL OR lease_expires_at <= ?)", []string{models.PurchaseMatchPending, models.PurchaseMatchRetryWait, models.PurchaseMatchRunning}, now, now). + Order("created_at, id").First(&item).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + if err != nil { + return false, internal(err) + } + lease := now.Add(purchaseMatchLease) + claimed := worker.DB.WithContext(ctx).Model(&models.PurchaseSpecMatchWorkItem{}). + Where("id = ? AND status IN ? AND attempt_count = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?)", item.ID, []string{models.PurchaseMatchPending, models.PurchaseMatchRetryWait, models.PurchaseMatchRunning}, item.AttemptCount, now). + Updates(map[string]any{"status": models.PurchaseMatchRunning, "attempt_count": gorm.Expr("attempt_count + 1"), "lease_owner": worker.OwnerID, "lease_expires_at": lease, "next_attempt_at": nil}) + if claimed.Error != nil { + return false, internal(claimed.Error) + } + if claimed.RowsAffected != 1 { + return true, nil + } + item.AttemptCount++ + item.Status, item.LeaseOwner, item.LeaseExpiresAt = models.PurchaseMatchRunning, worker.OwnerID, &lease + return true, worker.process(ctx, item) +} + +func (worker *PurchaseMatchWorker) process(ctx context.Context, item models.PurchaseSpecMatchWorkItem) error { + var snapshot matchFingerprint + if err := json.Unmarshal([]byte(item.InputSnapshotJSON), &snapshot); err != nil { + return worker.finishFailure(ctx, item, "MATCH_INPUT_INVALID", "匹配输入快照无效", false) + } + request := aimatching.MatchRequest{TargetColor: snapshot.TargetColor, TargetSize: snapshot.TargetSize, Colors: snapshot.Colors, Sizes: snapshot.Sizes} + callCtx, cancel := context.WithTimeout(ctx, purchaseMatchTimeout) + defer cancel() + matched, err := worker.Matcher.Resolve(callCtx, request) + if err != nil { + return worker.finishFailure(ctx, item, matchProviderCode(err), compactMatchReason(err.Error()), retryablePurchaseMatch(err)) + } + settings, err := aimatching.NewService(worker.DB).Settings(ctx) + if err != nil { + return worker.finishFailure(ctx, item, "AI_SETTING_READ_FAILED", "读取 AI 匹配设置失败", true) + } + if matched.Source != aimatching.SourceExact && matched.Source != aimatching.SourceAI { + return worker.finishFailure(ctx, item, "MATCH_SOURCE_INVALID", "匹配来源无效", false) + } + if !matchCandidateValid(request.TargetColor, matched.MappedColor, request.Colors) || !matchCandidateValid(request.TargetSize, matched.MappedSize, request.Sizes) { + return worker.finishFailure(ctx, item, "MATCH_OUTSIDE_CANDIDATES", "匹配结果不在当前候选集中", false) + } + if matched.Source == aimatching.SourceAI { + confidence := matched.Decision.Confidence + if confidence == nil || *confidence < settings.AutoConfirmMinConfidence || strings.TrimSpace(matched.Decision.Reason) == "" { + return worker.finishFailure(ctx, item, "AI_AUTO_CONFIRM_REJECTED", "AI 结果未达到自动确认条件", false) + } + } + return worker.finishMatched(ctx, item, matched) +} + +func (worker *PurchaseMatchWorker) finishMatched(ctx context.Context, item models.PurchaseSpecMatchWorkItem, matched aimatching.MatchResult) error { + return worker.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var task models.PurchaseTask + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&task, item.PurchaseTaskID).Error; err != nil { + return internal(err) + } + var current models.PurchaseSpecMatchWorkItem + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, item.ID).Error; err != nil { + return internal(err) + } + if current.Status != models.PurchaseMatchRunning || current.LeaseOwner != worker.OwnerID || task.Status != models.PurchaseTaskStatusPending || task.SpecSource != "unresolved" || task.LeaseExpiresAt != nil { + return nil + } + fingerprint, err := currentPurchaseMatchFingerprint(tx, task) + if err != nil || fingerprint != current.InputFingerprint { + return worker.cancelInputChanged(tx, current) + } + decision, err := json.Marshal(matched.Decision) + if err != nil { + return internal(err) + } + now := worker.Now() + if err := tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ? AND status = ? AND spec_source = ?", task.ID, models.PurchaseTaskStatusPending, "unresolved").Updates(map[string]any{"mapped_color_snapshot": matched.MappedColor, "mapped_size_snapshot": matched.MappedSize, "spec_source": matched.Source, "spec_decision_snapshot": string(decision), "status_version": gorm.Expr("status_version + 1"), "status_changed_at": now}).Error; err != nil { + return internal(err) + } + return tx.Model(&models.PurchaseSpecMatchWorkItem{}).Where("id = ? AND status = ? AND lease_owner = ?", current.ID, models.PurchaseMatchRunning, worker.OwnerID).Updates(map[string]any{"status": models.PurchaseMatchMatched, "completed_at": now, "lease_owner": "", "lease_expires_at": nil, "last_error_code": nil, "reason": compactMatchReason(matched.Decision.Reason)}).Error + }) +} + +func (worker *PurchaseMatchWorker) finishFailure(ctx context.Context, item models.PurchaseSpecMatchWorkItem, code, reason string, retryable bool) error { + return worker.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var current models.PurchaseSpecMatchWorkItem + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, item.ID).Error; err != nil { + return internal(err) + } + if current.Status != models.PurchaseMatchRunning || current.LeaseOwner != worker.OwnerID { + return nil + } + now := worker.Now() + status := models.PurchaseMatchManualRequired + var next *time.Time + if retryable && current.AttemptCount < purchaseMatchAttempts { + status = models.PurchaseMatchRetryWait + value := now.Add(purchaseMatchBackoff[current.AttemptCount-1]) + next = &value + } + return tx.Model(&models.PurchaseSpecMatchWorkItem{}).Where("id = ? AND status = ? AND lease_owner = ?", current.ID, models.PurchaseMatchRunning, worker.OwnerID).Updates(map[string]any{"status": status, "next_attempt_at": next, "lease_owner": "", "lease_expires_at": nil, "last_error_code": code, "last_error_at": now, "reason": compactMatchReason(reason)}).Error + }) +} + +func (worker *PurchaseMatchWorker) cancelInputChanged(tx *gorm.DB, item models.PurchaseSpecMatchWorkItem) error { + now := worker.Now() + return tx.Model(&models.PurchaseSpecMatchWorkItem{}).Where("id = ?", item.ID).Updates(map[string]any{"status": models.PurchaseMatchCancelled, "completed_at": now, "lease_owner": "", "lease_expires_at": nil, "last_error_code": "INPUT_CHANGED", "last_error_at": now, "reason": "商品关联或候选规格已变化"}).Error +} + +func currentPurchaseMatchFingerprint(tx *gorm.DB, task models.PurchaseTask) (string, error) { + if task.SYBProductID == nil || task.ShopeeProductID == nil { + return "", fail(CodeMappingRequired, "规格匹配输入已失效") + } + var syb models.SYBProduct + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&syb, *task.SYBProductID).Error; err != nil { + return "", err + } + var shopee models.ShopeeProduct + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&shopee, *task.ShopeeProductID).Error; err != nil { + return "", err + } + var pdd models.PDDProduct + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pdd, task.PDDProductID).Error; err != nil { + return "", err + } + candidates, _ := archiveCandidates(pdd.SpecsJSON, task.TargetColorSnapshot, task.TargetSizeSnapshot) + request := aimatching.MatchRequest{TargetColor: task.TargetColorSnapshot, TargetSize: task.TargetSizeSnapshot, Colors: candidates.Colors, Sizes: candidates.Sizes} + return creationMatchFingerprint(syb, shopee, pdd, request) +} + +func matchCandidateValid(target, value string, candidates []string) bool { + if strings.TrimSpace(target) == "" { + return strings.TrimSpace(value) == "" + } + for _, candidate := range candidates { + if candidate == value { + return true + } + } + return false +} + +func retryablePurchaseMatch(err error) bool { + var matchErr *aimatching.Error + return errors.As(err, &matchErr) && matchErr.Code == aimatching.CodeProviderUnavailable +} + +func matchProviderCode(err error) string { + var matchErr *aimatching.Error + if errors.As(err, &matchErr) { + return matchErr.Code + } + return aimatching.CodeProviderUnavailable +} + +func compactMatchReason(value string) string { + value = strings.TrimSpace(value) + if len([]rune(value)) > 500 { + return string([]rune(value)[:500]) + } + return value +} diff --git a/server/app/goauto/purchase/match_worker_test.go b/server/app/goauto/purchase/match_worker_test.go new file mode 100644 index 0000000..43523e5 --- /dev/null +++ b/server/app/goauto/purchase/match_worker_test.go @@ -0,0 +1,109 @@ +package purchase + +import ( + "context" + "errors" + "testing" + "time" + + "go-admin/app/goauto/aimatching" + "go-admin/app/goauto/models" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type workerMatcher struct { + result aimatching.MatchResult + err error + calls int +} + +func (matcher *workerMatcher) Resolve(context.Context, aimatching.MatchRequest) (aimatching.MatchResult, error) { + matcher.calls++ + return matcher.result, matcher.err +} + +func queuedMatchTask(t *testing.T) (*gorm.DB, fixture, models.PurchaseTask) { + t.Helper() + db := testDB(t) + f := seed(t, db, liveCaps(), false) + if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true,"priceCent":2000},{"name":"米白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙白").Error; err != nil { + t.Fatal(err) + } + service := testService(db) + service.StartMatching = func(*gorm.DB) {} + task, _, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive, SYBProductID: &f.syb.ID, DeviceID: &f.device.ID, MinUnitPriceCent: 400, MaxUnitPriceCent: 3000, RuleSnapshot: liveRule(true)}) + if err != nil { + t.Fatal(err) + } + return db, f, task +} + +func TestPurchaseMatchWorkerConfirmsStrictAIResult(t *testing.T) { + db, _, task := queuedMatchTask(t) + confidence := 0.95 + request := aimatching.MatchRequest{TargetColor: "象牙白", TargetSize: "XL", Colors: []string{"白色", "米白色"}, Sizes: []string{"XL"}} + result := aimatching.RecordedMatch(request, aimatching.SourceAI, "白色", "XL", "唯一可靠候选") + result.Decision.Confidence = &confidence + matcher := &workerMatcher{result: result} + worker := NewPurchaseMatchWorker(db) + worker.Matcher = matcher + if processed, err := worker.RunOnce(context.Background()); err != nil || !processed { + t.Fatalf("processed=%v err=%v", processed, err) + } + var saved models.PurchaseTask + var work models.PurchaseSpecMatchWorkItem + _ = db.First(&saved, task.ID).Error + _ = db.Where("purchase_task_id = ?", task.ID).First(&work).Error + if saved.SpecSource != aimatching.SourceAI || saved.MappedColorSnapshot != "白色" || work.Status != models.PurchaseMatchMatched || matcher.calls != 1 { + t.Fatalf("task=%+v work=%+v calls=%d", saved, work, matcher.calls) + } +} + +func TestPurchaseMatchWorkerRetriesThenRequiresManual(t *testing.T) { + db, _, task := queuedMatchTask(t) + matcher := &workerMatcher{err: &aimatching.Error{Code: aimatching.CodeProviderUnavailable, Message: "down", Cause: errors.New("temporary")}} + now := time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC) + worker := NewPurchaseMatchWorker(db) + worker.Matcher = matcher + worker.Now = func() time.Time { return now } + for attempt := 1; attempt <= 3; attempt++ { + if processed, err := worker.RunOnce(context.Background()); err != nil || !processed { + t.Fatalf("attempt=%d processed=%v err=%v", attempt, processed, err) + } + var work models.PurchaseSpecMatchWorkItem + _ = db.Where("purchase_task_id = ?", task.ID).First(&work).Error + if attempt < 3 { + if work.Status != models.PurchaseMatchRetryWait || work.NextAttemptAt == nil { + t.Fatalf("attempt=%d work=%+v", attempt, work) + } + now = work.NextAttemptAt.Add(time.Second) + } else if work.Status != models.PurchaseMatchManualRequired { + t.Fatalf("terminal work=%+v", work) + } + } + if matcher.calls != 3 { + t.Fatalf("calls=%d", matcher.calls) + } +} + +func TestManualMatchingUsesCurrentCandidatesAndUnblocksTask(t *testing.T) { + db, f, task := queuedMatchTask(t) + if err := db.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ?", task.ID).Update("status", models.PurchaseMatchManualRequired).Error; err != nil { + t.Fatal(err) + } + service := testService(db) + view, err := service.ManualMatching(context.Background(), task.ID, MatchingActionRequest{RequestID: uuid.NewString(), OperatorID: 1, MappedColor: "白色", MappedSize: "XL"}) + if err != nil || !view.Executable || view.Status != models.PurchaseMatchMatched { + t.Fatalf("view=%+v err=%v", view, err) + } + if _, err := service.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil { + t.Fatalf("matched task remained blocked: %v", err) + } +} + +func queuedFixtureToken(*testing.T, *gorm.DB, models.PurchaseTask) string { return "invalid" } diff --git a/server/app/goauto/purchase/router.go b/server/app/goauto/purchase/router.go index 592bf10..2f15d87 100644 --- a/server/app/goauto/purchase/router.go +++ b/server/app/goauto/purchase/router.go @@ -33,6 +33,9 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { admin.GET("/:taskId", h.AdminDetail) admin.POST("", h.AdminCreate) admin.POST("/:taskId/spec-decision", h.SpecDecision) + admin.GET("/:taskId/matching", h.MatchingDetail) + admin.POST("/:taskId/matching/requeue", h.RequeueMatching) + admin.POST("/:taskId/matching/manual", h.ManualMatching) admin.POST("/:taskId/authorize-repurchase", h.AuthorizeRePurchase) admin.POST("/:taskId/payment-review", h.ReviewPayment) admin.POST("/:taskId/writeback-candidate", h.SelectWriteback) diff --git a/server/app/goauto/purchase/service.go b/server/app/goauto/purchase/service.go index 40d545f..60d1873 100644 --- a/server/app/goauto/purchase/service.go +++ b/server/app/goauto/purchase/service.go @@ -41,10 +41,11 @@ type Service struct { Now func() time.Time LeaseDuration time.Duration Matcher SpecMatcher + StartMatching func(*gorm.DB) } func NewService(db *gorm.DB) *Service { - return &Service{DB: db, Now: func() time.Time { return time.Now().UTC() }, LeaseDuration: DefaultLeaseDuration, Matcher: aimatching.NewService(db)} + return &Service{DB: db, Now: func() time.Time { return time.Now().UTC() }, LeaseDuration: DefaultLeaseDuration, Matcher: aimatching.NewService(db), StartMatching: StartPurchaseMatching} } // SpecMatcher is server-only. Android receives only the final exact PDD labels @@ -55,7 +56,7 @@ type SpecMatcher interface { type externalMatchPlan struct { fingerprint string - result aimatching.MatchResult + snapshot string } type matchFingerprint struct { @@ -133,18 +134,14 @@ func (s *Service) prepareExternalMatch(ctx context.Context, req CreateRequest) ( if _, deterministic := aimatching.DeterministicMatch(request); deterministic { return nil, nil } - fingerprint, err := creationMatchFingerprint(syb, shopee, pdd, request) + fingerprint, snapshot, err := creationMatchInput(syb, shopee, pdd, request) if err != nil { return nil, internal(err) } - match, err := s.matcher().Resolve(ctx, request) - if err != nil { - return nil, purchaseMatchError(err) - } - return &externalMatchPlan{fingerprint: fingerprint, result: match}, nil + return &externalMatchPlan{fingerprint: fingerprint, snapshot: snapshot}, nil } -func creationMatchFingerprint(syb models.SYBProduct, shopee models.ShopeeProduct, pdd models.PDDProduct, request aimatching.MatchRequest) (string, error) { +func creationMatchInput(syb models.SYBProduct, shopee models.ShopeeProduct, pdd models.PDDProduct, request aimatching.MatchRequest) (string, string, error) { canonical := func(raw string) (json.RawMessage, error) { var value any if err := json.Unmarshal([]byte(raw), &value); err != nil { @@ -154,18 +151,23 @@ func creationMatchFingerprint(syb models.SYBProduct, shopee models.ShopeeProduct } shopeeSpecs, err := canonical(shopee.SpecsJSON) if err != nil { - return "", err + return "", "", err } pddSpecs, err := canonical(pdd.SpecsJSON) if err != nil { - return "", err + return "", "", err } payload, err := json.Marshal(matchFingerprint{SYBProductID: syb.ID, TargetColor: request.TargetColor, TargetSize: request.TargetSize, Quantity: syb.Quantity, ShopeeProductID: shopee.ID, ShopeeSpecs: shopeeSpecs, PDDProductID: pdd.ID, PDDStatus: pdd.Status, PDDSpecs: pddSpecs, Colors: request.Colors, Sizes: request.Sizes}) if err != nil { - return "", err + return "", "", err } digest := sha256.Sum256(payload) - return hex.EncodeToString(digest[:]), nil + return hex.EncodeToString(digest[:]), string(payload), nil +} + +func creationMatchFingerprint(syb models.SYBProduct, shopee models.ShopeeProduct, pdd models.PDDProduct, request aimatching.MatchRequest) (string, error) { + fingerprint, _, err := creationMatchInput(syb, shopee, pdd, request) + return fingerprint, err } func (s *Service) create(ctx context.Context, req CreateRequest) (models.PurchaseTask, bool, error) { @@ -361,27 +363,19 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas decisionSnapshot = string(decision) } else if !archiveUsable { mappedColor, mappedSize, specSource = "", "", "unresolved" + } else if externalPlan != nil { + mappedColor, mappedSize, specSource = "", "", "unresolved" } else { - if externalPlan != nil { - // The complete input was revalidated above before selecting a branch. - mappedColor, mappedSize, specSource = externalPlan.result.MappedColor, externalPlan.result.MappedSize, externalPlan.result.Source - decision, marshalErr := json.Marshal(externalPlan.result.Decision) - if marshalErr != nil { - return internal(marshalErr) - } - decisionSnapshot = string(decision) - } else { - match, matchErr := s.matcher().Resolve(ctx, matchRequest) - if matchErr != nil { - return purchaseMatchError(matchErr) - } - mappedColor, mappedSize, specSource = match.MappedColor, match.MappedSize, match.Source - decision, marshalErr := json.Marshal(match.Decision) - if marshalErr != nil { - return internal(marshalErr) - } - decisionSnapshot = string(decision) + match, matched := aimatching.DeterministicMatch(matchRequest) + if !matched { + return fail(CodeMappingRequired, "规格映射不完整,需要异步规格匹配") } + mappedColor, mappedSize, specSource = match.MappedColor, match.MappedSize, match.Source + decision, marshalErr := json.Marshal(match.Decision) + if marshalErr != nil { + return internal(marshalErr) + } + decisionSnapshot = string(decision) } if specSource == "unresolved" && !containsString(rule.RequiredCapabilities, purchasecontract.CapabilitySpecProbeV1) { return fail(CodeMappingRequired, "规格映射不完整,所选规则不支持规格探测") @@ -409,8 +403,17 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas if err := tx.Model(&out).Update("address_suffix", out.AddressSuffix).Error; err != nil { return internal(err) } + if externalPlan != nil { + work := models.PurchaseSpecMatchWorkItem{PurchaseTaskID: out.ID, Status: models.PurchaseMatchPending, InputFingerprint: externalPlan.fingerprint, InputSnapshotJSON: externalPlan.snapshot} + if err := tx.Create(&work).Error; err != nil { + return internal(err) + } + } return nil }) + if err == nil && !replayed && externalPlan != nil && s.StartMatching != nil { + s.StartMatching(s.DB) + } return out, replayed, err } diff --git a/server/app/goauto/purchase/service_test.go b/server/app/goauto/purchase/service_test.go index bbc7a60..8b8f462 100644 --- a/server/app/goauto/purchase/service_test.go +++ b/server/app/goauto/purchase/service_test.go @@ -6,11 +6,9 @@ import ( "errors" "fmt" "strings" - "sync" "testing" "time" - "go-admin/app/goauto/aimatching" "go-admin/app/goauto/device" "go-admin/app/goauto/migrations" "go-admin/app/goauto/models" @@ -23,34 +21,6 @@ import ( "gorm.io/gorm/logger" ) -type blockingMatch struct { - started chan struct{} - release chan struct{} - calls int - mu sync.Mutex -} - -func (matcher *blockingMatch) Resolve(ctx context.Context, request aimatching.MatchRequest) (aimatching.MatchResult, error) { - matcher.mu.Lock() - matcher.calls++ - if matcher.calls == 1 { - close(matcher.started) - } - matcher.mu.Unlock() - select { - case <-ctx.Done(): - return aimatching.MatchResult{}, ctx.Err() - case <-matcher.release: - return aimatching.RecordedMatch(request, aimatching.SourceAI, "白色", "XL", "唯一候选"), nil - } -} - -func (matcher *blockingMatch) count() int { - matcher.mu.Lock() - defer matcher.mu.Unlock() - return matcher.calls -} - type fixture struct { syb models.SYBProduct shopee models.ShopeeProduct @@ -409,89 +379,45 @@ func TestCreateDoesNotTrustMappedLabelsFromRequest(t *testing.T) { } } -func TestCreateRejectsUsableArchiveWhenNeitherMatcherFindsSpec(t *testing.T) { +func TestCreateQueuesUsableArchiveWhenDeterministicMatchFails(t *testing.T) { db := testDB(t) f := seed(t, db, liveCaps(), false) if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"L","selectable":true}]}]`).Error; err != nil { t.Fatal(err) } - _, err := createLive(t, testService(db), f) - if code(err) != CodeMappingRequired || !strings.Contains(err.Error(), "AI") { - t.Fatalf("unmatched active archive should be rejected before dispatch: %v", err) - } -} - -func TestCreateWaitsForMatcherBeforeOpeningWriteTransactionAndDeduplicatesConcurrentRequest(t *testing.T) { - db := testDB(t) - f := seed(t, db, liveCaps(), false) - if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true,"priceCent":2000},{"name":"米白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil { - t.Fatal(err) - } - if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙白").Error; err != nil { - t.Fatal(err) - } - matcher := &blockingMatch{started: make(chan struct{}), release: make(chan struct{})} service := testService(db) - service.Matcher = matcher - requestID := uuid.NewString() - request := CreateRequest{RequestID: requestID, ExecutionMode: models.PurchaseExecutionModeLive, SYBProductID: &f.syb.ID, DeviceID: &f.device.ID, MinUnitPriceCent: 400, MaxUnitPriceCent: 3000, RuleSnapshot: liveRule(true)} - type result struct { - task models.PurchaseTask - replayed bool - err error - } - results := make(chan result, 2) - go func() { - task, replayed, err := service.Create(context.Background(), request) - results <- result{task, replayed, err} - }() - <-matcher.started - if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("product_title", "等待期间可更新").Error; err != nil { - t.Fatalf("matcher wait held database transaction: %v", err) - } - go func() { - task, replayed, err := service.Create(context.Background(), request) - results <- result{task, replayed, err} - }() - close(matcher.release) - first, second := <-results, <-results - if first.err != nil || second.err != nil || first.task.ID == 0 || first.task.ID != second.task.ID { - t.Fatalf("concurrent result mismatch: first=%+v second=%+v", first, second) - } - if matcher.count() != 1 || (!first.replayed && !second.replayed) { - t.Fatalf("calls=%d replayed=%v/%v", matcher.count(), first.replayed, second.replayed) - } -} - -func TestCreateRejectsExternalMatchWhenInputChanges(t *testing.T) { - db := testDB(t) - f := seed(t, db, liveCaps(), false) - if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true,"priceCent":2000},{"name":"米白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil { - t.Fatal(err) - } - if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙白").Error; err != nil { - t.Fatal(err) - } - matcher := &blockingMatch{started: make(chan struct{}), release: make(chan struct{})} - service := testService(db) - service.Matcher = matcher - done := make(chan error, 1) - go func() { - _, _, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive, SYBProductID: &f.syb.ID, DeviceID: &f.device.ID, MinUnitPriceCent: 400, MaxUnitPriceCent: 3000, RuleSnapshot: liveRule(true)}) - done <- err - }() - <-matcher.started - if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("status", "disabled").Error; err != nil { - t.Fatal(err) - } - close(matcher.release) - if err := <-done; code(err) != CodeMappingRequired { - t.Fatalf("changed input code=%s err=%v", code(err), err) + service.StartMatching = func(*gorm.DB) {} + task, err := createLive(t, service, f) + if err != nil || task.SpecSource != "unresolved" { + t.Fatalf("task=%+v err=%v", task, err) } var count int64 - db.Model(&models.PurchaseTask{}).Count(&count) - if count != 0 { - t.Fatalf("stale match created %d tasks", count) + if err := db.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ?", task.ID).Count(&count).Error; err != nil || count != 1 { + t.Fatalf("work count=%d err=%v", count, err) + } +} + +func TestCreateQueuesExternalMatchingWithoutCallingProvider(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), false) + if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true,"priceCent":2000},{"name":"米白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙白").Error; err != nil { + t.Fatal(err) + } + service := testService(db) + service.StartMatching = func(*gorm.DB) {} + task, _, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive, SYBProductID: &f.syb.ID, DeviceID: &f.device.ID, MinUnitPriceCent: 400, MaxUnitPriceCent: 3000, RuleSnapshot: liveRule(true)}) + if err != nil || task.ID == 0 || task.SpecSource != "unresolved" { + t.Fatalf("queued task=%+v err=%v", task, err) + } + var work models.PurchaseSpecMatchWorkItem + if err := db.Where("purchase_task_id = ?", task.ID).First(&work).Error; err != nil || work.Status != models.PurchaseMatchPending || work.InputFingerprint == "" { + t.Fatalf("work=%+v err=%v", work, err) + } + if next, err := service.Next(context.Background(), f.token); err != nil || next != nil { + t.Fatalf("matching task dispatched: %+v %v", next, err) } } diff --git a/server/app/goauto/purchase/types.go b/server/app/goauto/purchase/types.go index db95f2b..bb23fcf 100644 --- a/server/app/goauto/purchase/types.go +++ b/server/app/goauto/purchase/types.go @@ -82,6 +82,26 @@ type StockCreateRequest struct { MaxUnitPriceCent int64 `json:"maxUnitPriceCent"` } +type MatchingView struct { + Status string `json:"status"` + Executable bool `json:"executable"` + ReasonCode string `json:"reasonCode,omitempty"` + Reason string `json:"reason,omitempty"` + NextAction string `json:"nextAction,omitempty"` + NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty"` + TargetColor string `json:"targetColor,omitempty"` + TargetSize string `json:"targetSize,omitempty"` + Colors []string `json:"colors,omitempty"` + Sizes []string `json:"sizes,omitempty"` +} + +type MatchingActionRequest struct { + RequestID string `json:"requestId"` + MappedColor string `json:"mappedColor,omitempty"` + MappedSize string `json:"mappedSize,omitempty"` + OperatorID uint64 `json:"-"` +} + type ActionRequest struct { RequestID string `json:"requestId"` } diff --git a/server/cmd/api/server.go b/server/cmd/api/server.go index 769d1f3..8268938 100644 --- a/server/cmd/api/server.go +++ b/server/cmd/api/server.go @@ -21,6 +21,7 @@ import ( "go-admin/app/admin/models" "go-admin/app/admin/router" goautodevice "go-admin/app/goauto/device" + goautopurchase "go-admin/app/goauto/purchase" goautoreplacement "go-admin/app/goauto/replacement" goautosybimport "go-admin/app/goauto/sybimport" goautosybinnercode "go-admin/app/goauto/sybinnercode" @@ -101,6 +102,7 @@ func run() error { return fmt.Errorf("recover interrupted SYB inner-code writes: %w", err) } goautoreplacement.RecoverMatching(db) + goautopurchase.RecoverPurchaseMatching(db) goautotask.RecoverReplacementActivations(db) } offlineMonitorContext, stopOfflineMonitors := context.WithCancel(context.Background()) diff --git a/web/src/api/goauto/purchase-tasks.js b/web/src/api/goauto/purchase-tasks.js index 3f80349..6ea52ef 100644 --- a/web/src/api/goauto/purchase-tasks.js +++ b/web/src/api/goauto/purchase-tasks.js @@ -7,6 +7,9 @@ export function createPurchaseTasksBatch(data) { return request({ url: '/api/adm export function retryPurchaseTasksBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-retry', method: 'post', data, timeout: 60000 }) } export function createStockPurchaseTask(data, options = {}) { return request({ url: '/api/admin/v1/purchase-tasks/stock', method: 'post', data, ...options }) } export function createPurchaseTask(data) { return request({ url: '/api/admin/v1/purchase-tasks', method: 'post', data, timeout: 60000 }) } +export function getPurchaseMatching(taskId) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}/matching`, method: 'get' }) } +export function requeuePurchaseMatching(taskId, data) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}/matching/requeue`, method: 'post', data }) } +export function choosePurchaseMatching(taskId, data) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}/matching/manual`, method: 'post', data }) } export function authorizeRepurchase(taskId, data) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}/authorize-repurchase`, method: 'post', data }) } export function reviewPurchasePayment(taskId, data) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}/payment-review`, method: 'post', data }) } export function selectPurchaseWriteback(taskId, data) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}/writeback-candidate`, method: 'post', data }) } diff --git a/web/src/views/goauto/purchase-tasks/index.vue b/web/src/views/goauto/purchase-tasks/index.vue index bafad54..f34cf48 100644 --- a/web/src/views/goauto/purchase-tasks/index.vue +++ b/web/src/views/goauto/purchase-tasks/index.vue @@ -24,6 +24,7 @@ + @@ -49,6 +50,11 @@ {{ detail.task.deviceName || '尚未分派' }}{{ detail.task.addressSuffix || '未生成' }} +

规格匹配

+ + {{ specText(detail.matching.targetColor, detail.matching.targetSize) }}{{ formatTime(detail.matching.nextAttemptAt) }}{{ (detail.matching.colors || []).join('、') || '—' }}{{ (detail.matching.sizes || []).join('、') || '—' }} +
重新尝试 AI人工选择规格
+

订单与人工处理

订单{{ detail.task.pddOrderNo || '尚未取得订单号' }}{{ detail.task.orderSubmittedAt ? `下单时间 ${formatTime(detail.task.orderSubmittedAt)}` : '获取订单号失败即采购失败' }}
支付复核{{ paymentLabel(detail.task.paymentReviewStatus) }}{{ detail.task.paymentReviewedAt ? formatTime(detail.task.paymentReviewedAt) : '仅人工记录,系统不支付' }}
@@ -73,6 +79,12 @@
+ + + {{ specText(detail.matching?.targetColor, detail.matching?.targetSize) }} + + + @@ -121,7 +133,7 @@