feat(#148): match purchase specs asynchronously
This commit is contained in:
@@ -1,3 +1,11 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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 不自行推断资格。
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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`。
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -48,6 +48,7 @@ func MigratedModels() []any {
|
||||
&models.PDDAccount{},
|
||||
&models.PurchaseTask{},
|
||||
&models.PurchaseTaskAttempt{},
|
||||
&models.PurchaseSpecMatchWorkItem{},
|
||||
&models.CollectionRule{},
|
||||
&models.AgentManualCollectionSetting{},
|
||||
&models.CollectionTask{},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 }) }
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<el-table-column label="采购规格 / 数量" min-width="190"><template #default="{ row }"><div>{{ specText(row.mappedColorSnapshot, row.mappedSizeSnapshot) }}</div><div class="muted">目标:{{ specText(row.targetColorSnapshot, row.targetSizeSnapshot) }} · {{ row.quantity }} 件</div></template></el-table-column>
|
||||
<el-table-column label="设备" min-width="140"><template #default="{ row }"><div>{{ row.deviceName || '尚未分派' }}</div><div class="muted">账号:{{ row.pddAccountRefSnapshot || '不强制记录' }}</div></template></el-table-column>
|
||||
<el-table-column label="状态" width="150" align="center"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag><el-tooltip v-if="row.status === 'failed' && !row.retryable" :content="row.retryDisabledReason || '当前任务不能重试'" placement="top"><span class="retry-disabled" tabindex="0">不可重试</span></el-tooltip></template></el-table-column>
|
||||
<el-table-column label="规格匹配" min-width="150"><template #default="{ row }"><el-tag :type="matchingType(row.matching?.status)" effect="plain">{{ matchingLabel(row.matching?.status) }}</el-tag><div v-if="row.matching && !row.matching.executable" class="muted">{{ row.matching.reason || '完成前不可执行' }}</div></template></el-table-column>
|
||||
<el-table-column label="订单 / 支付" min-width="170"><template #default="{ row }"><div>{{ row.pddOrderNo || '尚未取得订单号' }}</div><div class="muted">{{ paymentLabel(row.paymentReviewStatus) }}</div></template></el-table-column>
|
||||
<el-table-column label="创建时间" width="170"><template #default="{ row }">{{ formatTime(row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row)">详情</el-button></template></el-table-column>
|
||||
@@ -49,6 +50,11 @@
|
||||
<el-descriptions-item label="设备">{{ detail.task.deviceName || '尚未分派' }}</el-descriptions-item><el-descriptions-item label="地址后缀">{{ detail.task.addressSuffix || '未生成' }}</el-descriptions-item>
|
||||
</el-descriptions></section>
|
||||
|
||||
<section v-if="detail.matching"><h3>规格匹配</h3>
|
||||
<el-alert :title="matchingLabel(detail.matching.status)" :description="detail.matching.reason || matchingDescription(detail.matching)" :type="matchingType(detail.matching.status)" :closable="false" show-icon class="notice" />
|
||||
<el-descriptions :column="2" border><el-descriptions-item label="目标规格">{{ specText(detail.matching.targetColor, detail.matching.targetSize) }}</el-descriptions-item><el-descriptions-item label="下次重试">{{ formatTime(detail.matching.nextAttemptAt) }}</el-descriptions-item><el-descriptions-item label="颜色候选">{{ (detail.matching.colors || []).join('、') || '—' }}</el-descriptions-item><el-descriptions-item label="尺码候选">{{ (detail.matching.sizes || []).join('、') || '—' }}</el-descriptions-item></el-descriptions>
|
||||
<div v-if="['manual_required','cancelled'].includes(detail.matching.status)" class="matching-actions"><el-button :loading="matchingAction.saving" @click="requeueMatching">重新尝试 AI</el-button><el-button v-if="detail.matching.status === 'manual_required'" type="primary" @click="openMatchingDialog">人工选择规格</el-button></div>
|
||||
</section>
|
||||
<section><h3>订单与人工处理</h3><div class="state-grid">
|
||||
<div class="state-card"><span>订单</span><strong>{{ detail.task.pddOrderNo || '尚未取得订单号' }}</strong><small>{{ detail.task.orderSubmittedAt ? `下单时间 ${formatTime(detail.task.orderSubmittedAt)}` : '获取订单号失败即采购失败' }}</small></div>
|
||||
<div class="state-card"><span>支付复核</span><strong>{{ paymentLabel(detail.task.paymentReviewStatus) }}</strong><small>{{ detail.task.paymentReviewedAt ? formatTime(detail.task.paymentReviewedAt) : '仅人工记录,系统不支付' }}</small></div>
|
||||
@@ -73,6 +79,12 @@
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="matchingDialog.open" title="人工选择 PDD 规格" width="620px" :close-on-click-modal="false">
|
||||
<el-alert title="只能从当前候选中选择;提交前服务端会重新校验任务和商品规格。" type="info" :closable="false" show-icon class="notice" />
|
||||
<el-form label-position="top"><el-form-item label="目标规格">{{ specText(detail.matching?.targetColor, detail.matching?.targetSize) }}</el-form-item><el-form-item v-if="detail.matching?.targetColor" label="PDD 颜色(必选)"><el-select v-model="matchingDialog.color" style="width:100%"><el-option v-for="value in detail.matching.colors || []" :key="value" :label="value" :value="value" /></el-select></el-form-item><el-form-item v-if="detail.matching?.targetSize" label="PDD 尺码(必选)"><el-select v-model="matchingDialog.size" style="width:100%"><el-option v-for="value in detail.matching.sizes || []" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-form>
|
||||
<template #footer><el-button :disabled="matchingDialog.saving" @click="matchingDialog.open=false">取消</el-button><el-button type="primary" :disabled="!canSubmitMatching" :loading="matchingDialog.saving" @click="submitMatching">确认选择</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="repurchase.open" title="允许重新采购" width="560px" :close-on-click-modal="false">
|
||||
<el-alert title="这是一次性授权。后续从 SYB 商品重新创建采购任务时会自动消耗;本操作不会立即创建订单。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-form label-position="top"><el-form-item label="授权原因(必填)"><el-input v-model="repurchase.reason" type="textarea" :rows="3" maxlength="300" show-word-limit placeholder="说明为什么需要再次采购" /></el-form-item><el-form-item label="输入“允许重新采购”确认"><el-input v-model="repurchase.confirmText" autocomplete="off" /></el-form-item></el-form>
|
||||
@@ -121,7 +133,7 @@
|
||||
<script>
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import { authorizeRepurchase, cancelPurchaseTask, getPurchaseTask, listPurchaseTasks, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback } from '@/api/goauto/purchase-tasks'
|
||||
import { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback } from '@/api/goauto/purchase-tasks'
|
||||
|
||||
const statusOptions = [
|
||||
['pending', '待执行'], ['spec_probe_pending', '待探测规格'], ['running', '执行中'], ['rehearsal_completed', '演练完成'],
|
||||
@@ -135,13 +147,14 @@ export default {
|
||||
return {
|
||||
loading: false, loadError: '', tasks: [], total: 0, statuses: statusOptions,
|
||||
query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' },
|
||||
detail: { open: false, loading: false, task: null, attempts: [] },
|
||||
retrySelection: [],
|
||||
retryDialog: { open: false, saving: false, items: [] },
|
||||
retryResult: { open: false, items: [], createdCount: 0, failedCount: 0 },
|
||||
repurchase: { open: false, saving: false, reason: '', confirmText: '' },
|
||||
cancelDialog: { open: false, saving: false, reason: '' },
|
||||
unknown: { open: false, saving: false, status: 'order_created', pddOrderNo: '', orderSubmittedAt: '', reason: '' }
|
||||
detail: { open: false, loading: false, task: null, attempts: [], matching: null },
|
||||
matchingAction: { saving: false }, matchingDialog: { open: false, saving: false, color: '', size: '' },
|
||||
retrySelection: [],
|
||||
retryDialog: { open: false, saving: false, items: [] },
|
||||
retryResult: { open: false, items: [], createdCount: 0, failedCount: 0 },
|
||||
repurchase: { open: false, saving: false, reason: '', confirmText: '' },
|
||||
cancelDialog: { open: false, saving: false, reason: '' },
|
||||
unknown: { open: false, saving: false, status: 'order_created', pddOrderNo: '', orderSubmittedAt: '', reason: '' }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -158,6 +171,7 @@ export default {
|
||||
canCancel() { const t = this.detail.task; return t && !['running', 'order_submit_started', 'cancelled'].includes(t.status) },
|
||||
canSubmitRepurchase() { return this.repurchase.reason.trim() && this.repurchase.confirmText === '允许重新采购' },
|
||||
canSubmitUnknown() { return this.unknown.status === 'order_created' ? this.unknown.pddOrderNo.trim() && this.unknown.orderSubmittedAt : this.unknown.reason.trim() },
|
||||
canSubmitMatching() { const m = this.detail.matching || {}; return (!m.targetColor || this.matchingDialog.color) && (!m.targetSize || this.matchingDialog.size) },
|
||||
firstRetriedTask() { return this.retryResult.items.find(item => item.created && item.taskId) || null }
|
||||
},
|
||||
created() {
|
||||
@@ -177,10 +191,16 @@ export default {
|
||||
cleanQuery() { return Object.fromEntries(Object.entries(this.query).filter(([, value]) => value !== '')) },
|
||||
search() { this.query.page = 1; this.load() },
|
||||
reset() { this.query = { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' }; this.load() },
|
||||
async openDetail(row) { this.detail = { open: true, loading: true, task: null, attempts: [] }; try { await this.refreshDetail(row.id) } finally { this.detail.loading = false } },
|
||||
async refreshDetail(id = this.detail.task?.id) { const r = await getPurchaseTask(id); this.detail.task = r.data.task; this.detail.attempts = r.data.attempts || [] },
|
||||
async openDetail(row) { this.detail = { open: true, loading: true, task: null, attempts: [], matching: null }; try { await this.refreshDetail(row.id) } finally { this.detail.loading = false } },
|
||||
async refreshDetail(id = this.detail.task?.id) { const r = await getPurchaseTask(id); this.detail.task = r.data.task; this.detail.attempts = r.data.attempts || []; this.detail.matching = r.data.matching || r.data.task?.matching || null },
|
||||
statusLabel(value) { return statusOptions.find(item => item.value === value)?.label || value || '—' },
|
||||
statusType(value) { return { pending: 'info', spec_probe_pending: 'warning', running: 'warning', rehearsal_completed: 'success', order_submit_started: 'warning', order_created: 'success', order_result_unknown: 'danger', failed: 'danger', cancelled: 'info' }[value] || 'info' },
|
||||
matchingLabel(value) { return { pending: '等待匹配', running: '匹配中', retry_wait: '等待重试', matched: '已匹配', manual_required: '需要人工处理', cancelled: '输入已失效', unresolved: '执行时探测' }[value] || '未记录' },
|
||||
matchingType(value) { return { pending: 'info', running: 'warning', retry_wait: 'warning', matched: 'success', manual_required: 'warning', cancelled: 'danger', unresolved: 'info' }[value] || 'info' },
|
||||
matchingDescription(matching) { return matching.executable ? '规格已就绪。' : '规格匹配完成前任务不可被设备领取或执行。' },
|
||||
async requeueMatching() { this.matchingAction.saving = true; try { await requeuePurchaseMatching(this.detail.task.id, { requestId: this.requestId() }); ElMessage.success('已重新加入匹配队列'); await this.refreshDetail(); await this.load() } finally { this.matchingAction.saving = false } },
|
||||
openMatchingDialog() { this.matchingDialog = { open: true, saving: false, color: '', size: '' } },
|
||||
async submitMatching() { this.matchingDialog.saving = true; try { await choosePurchaseMatching(this.detail.task.id, { requestId: this.requestId(), mappedColor: this.matchingDialog.color, mappedSize: this.matchingDialog.size }); ElMessage.success('规格选择已保存'); this.matchingDialog.open = false; await this.refreshDetail(); await this.load() } finally { this.matchingDialog.saving = false } },
|
||||
taskTypeLabel(value) { return value === 'stock' ? '备货采购' : '订单采购' },
|
||||
taskTypeTag(value) { return value === 'stock' ? 'warning' : 'primary' },
|
||||
modeLabel(value) { return value === 'live' ? '正式' : '演练' },
|
||||
|
||||
Reference in New Issue
Block a user