305 lines
17 KiB
Go
305 lines
17 KiB
Go
package models
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
PurchaseTaskTypeSYBOrder = "syb_order"
|
|
PurchaseTaskTypeStock = "stock"
|
|
|
|
PurchaseExecutionModeRehearsal = "rehearsal"
|
|
PurchaseExecutionModeLive = "live"
|
|
|
|
PurchaseTaskStatusPending = "pending"
|
|
PurchaseTaskStatusSpecProbePending = "spec_probe_pending"
|
|
PurchaseTaskStatusRunning = "running"
|
|
PurchaseTaskStatusRehearsalCompleted = "rehearsal_completed"
|
|
PurchaseTaskStatusOrderSubmitStarted = "order_submit_started"
|
|
PurchaseTaskStatusOrderCreated = "order_created"
|
|
PurchaseTaskStatusOrderResultUnknown = "order_result_unknown"
|
|
PurchaseTaskStatusFailed = "failed"
|
|
PurchaseTaskStatusCancelled = "cancelled"
|
|
|
|
PurchaseAttemptPhaseSpecProbe = "spec_probe"
|
|
PurchaseAttemptPhasePurchase = "purchase"
|
|
|
|
PurchaseAttemptStatusPending = "pending"
|
|
PurchaseAttemptStatusRunning = "running"
|
|
PurchaseAttemptStatusCompleted = "completed"
|
|
PurchaseAttemptStatusFailed = "failed"
|
|
|
|
PurchasePaymentReviewPending = "pending"
|
|
PurchasePaymentReviewPaid = "paid"
|
|
PurchasePaymentReviewUnpaid = "unpaid"
|
|
|
|
PurchaseLogisticsStatusPending = "pending"
|
|
PurchaseLogisticsStatusCollected = "collected"
|
|
PurchaseLogisticsStatusFailed = "failed"
|
|
|
|
PurchaseWritebackStatusNotSelected = "not_selected"
|
|
PurchaseWritebackStatusPending = "pending"
|
|
PurchaseWritebackStatusRunning = "running"
|
|
PurchaseWritebackStatusSucceeded = "succeeded"
|
|
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
|
|
// stores credentials, cookies, phone numbers or other account secrets.
|
|
type PDDAccount struct {
|
|
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
|
Name string `json:"name" gorm:"size:100;not null;uniqueIndex:ux_pdd_account_name"`
|
|
Status string `json:"status" gorm:"size:16;not null;default:active;check:ck_pdd_account_status,status IN ('active','disabled')"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
}
|
|
|
|
func (PDDAccount) TableName() string { return "pdd_account" }
|
|
|
|
// PurchaseTask is the server-side business fact for either one SYB order line
|
|
// or one direct stock purchase, and at most one PDD order. Re-purchase creates
|
|
// another row; old rows and orders are never overwritten or deleted.
|
|
type PurchaseTask struct {
|
|
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
|
TaskType string `json:"taskType" gorm:"size:16;not null;default:syb_order;index;check:ck_purchase_task_type,task_type IN ('syb_order','stock')"`
|
|
|
|
// MySQL 8.4 rejects a column used by both a foreign key with referential
|
|
// actions and a cross-field CHECK. The live-mode requirement is therefore
|
|
// enforced by the model hook and the #34 creation service, not a DB CHECK.
|
|
SYBProductID *uint64 `json:"sybProductId" gorm:"index;uniqueIndex:ux_purchase_task_active_syb,priority:1"`
|
|
SYBProduct *SYBProduct `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
|
|
ShopeeProductID *uint64 `json:"shopeeProductId" gorm:"index"`
|
|
ShopeeProduct *ShopeeProduct `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
|
|
PDDProductID uint64 `json:"pddProductId" gorm:"not null;index"`
|
|
PDDProduct PDDProduct `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
|
|
DeviceID *uint64 `json:"deviceId" gorm:"index;uniqueIndex:ux_purchase_task_running_device,priority:1"`
|
|
Device *AgentDevice `json:"-"`
|
|
PDDAccountID *uint64 `json:"pddAccountId" gorm:"index;uniqueIndex:ux_purchase_task_running_account,priority:1"`
|
|
PDDAccount *PDDAccount `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
|
|
|
|
ExecutionMode string `json:"executionMode" gorm:"size:16;not null;check:ck_purchase_task_execution_mode,execution_mode IN ('rehearsal','live')"`
|
|
Status string `json:"status" gorm:"size:32;not null;index;check:ck_purchase_task_status,status IN ('pending','spec_probe_pending','running','rehearsal_completed','order_submit_started','order_created','order_result_unknown','failed','cancelled')"`
|
|
// Nullable guard columns make uniqueness portable to SQLite and MySQL.
|
|
// ActiveSlot limits one non-terminal live task per SYB line. DeviceRunSlot
|
|
// and AccountRunSlot protect only the period in which Agent actions run.
|
|
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_purchase_task_active_syb,priority:2"`
|
|
DeviceRunSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_purchase_task_running_device,priority:2"`
|
|
AccountRunSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_purchase_task_running_account,priority:2"`
|
|
|
|
ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot" gorm:"size:64;not null"`
|
|
ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot" gorm:"size:64;not null;default:'';index"`
|
|
ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot" gorm:"size:500;not null;default:''"`
|
|
ShopeeShopNameSnapshot string `json:"shopeeShopNameSnapshot" gorm:"size:255;not null;default:''"`
|
|
PDDURLSnapshot string `json:"pddUrlSnapshot" gorm:"type:text;not null"`
|
|
PDDGoodsIDSnapshot string `json:"pddGoodsIdSnapshot" gorm:"size:32;not null;index"`
|
|
PDDTitleSnapshot string `json:"pddTitleSnapshot" gorm:"size:500;not null;default:''"`
|
|
TargetColorSnapshot string `json:"targetColorSnapshot" gorm:"size:255;not null;default:''"`
|
|
TargetSizeSnapshot string `json:"targetSizeSnapshot" gorm:"size:255;not null;default:''"`
|
|
MappedColorSnapshot string `json:"mappedColorSnapshot" gorm:"size:255;not null;default:''"`
|
|
MappedSizeSnapshot string `json:"mappedSizeSnapshot" gorm:"size:255;not null;default:''"`
|
|
SpecSource string `json:"specSource" gorm:"size:32;not null;default:unresolved;check:ck_purchase_task_spec_source,spec_source IN ('unresolved','manual_mapping','exact_match','ai_match','direct_select')"`
|
|
SpecDecisionSnapshot string `json:"-" gorm:"type:json;not null"`
|
|
SpecDecisionRequestID *string `json:"-" gorm:"size:64;uniqueIndex:ux_purchase_task_spec_decision_request_id"`
|
|
SpecDecisionBy *uint64 `json:"specDecisionBy"`
|
|
|
|
Quantity int64 `json:"quantity" gorm:"not null;check:ck_purchase_task_quantity,quantity >= 1"`
|
|
ReferenceUnitPriceCent int64 `json:"referenceUnitPriceCent" gorm:"not null;check:ck_purchase_task_reference_price,reference_unit_price_cent >= 0"`
|
|
ActualUnitPriceCent *int64 `json:"actualUnitPriceCent,omitempty" gorm:"check:ck_purchase_task_actual_price,actual_unit_price_cent IS NULL OR actual_unit_price_cent >= 0"`
|
|
MinUnitPriceCent int64 `json:"minUnitPriceCent" gorm:"not null;check:ck_purchase_task_min_price,min_unit_price_cent >= 0"`
|
|
MaxUnitPriceCent int64 `json:"maxUnitPriceCent" gorm:"not null;check:ck_purchase_task_max_price,max_unit_price_cent >= min_unit_price_cent"`
|
|
Currency string `json:"currency" gorm:"size:3;not null"`
|
|
|
|
RuleType string `json:"ruleType" gorm:"size:32;not null"`
|
|
RuleSchemaVersion int `json:"ruleSchemaVersion" gorm:"not null"`
|
|
RequiredCapabilitiesJSON string `json:"-" gorm:"type:json;not null"`
|
|
RuleSnapshot string `json:"-" gorm:"type:json;not null"`
|
|
PDDAccountRefSnapshot string `json:"pddAccountRefSnapshot" gorm:"size:100;not null;default:''"`
|
|
AddressSuffix string `json:"addressSuffix" gorm:"size:32;not null;default:''"`
|
|
|
|
LeaseExpiresAt *time.Time `json:"leaseExpiresAt" gorm:"index"`
|
|
LeaseVersion uint64 `json:"leaseVersion" gorm:"not null;default:0"`
|
|
CreateRequestID string `json:"-" gorm:"size:36;not null;uniqueIndex:ux_purchase_task_create_request_id"`
|
|
ClaimRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_purchase_task_claim_request_id"`
|
|
OrderSubmitRequestID *string `json:"-" gorm:"size:64;uniqueIndex:ux_purchase_task_order_submit_request_id"`
|
|
StatusVersion uint64 `json:"statusVersion" gorm:"not null;default:1"`
|
|
StatusChangedAt time.Time `json:"statusChangedAt" gorm:"not null"`
|
|
|
|
PDDOrderNo *string `json:"pddOrderNo" gorm:"size:100;index"`
|
|
OrderSubmittedAt *time.Time `json:"orderSubmittedAt"`
|
|
IrreversibleAt *time.Time `json:"irreversibleAt"`
|
|
PaymentReviewStatus string `json:"paymentReviewStatus" gorm:"size:16;not null;default:pending;check:ck_purchase_task_payment_review,payment_review_status IN ('pending','paid','unpaid')"`
|
|
PaymentReviewedAt *time.Time `json:"paymentReviewedAt"`
|
|
PaymentReviewedBy *uint64 `json:"paymentReviewedBy"`
|
|
|
|
TrackingNo *string `json:"trackingNo" gorm:"size:120"`
|
|
TrackingCollectedAt *time.Time `json:"trackingCollectedAt"`
|
|
LogisticsStatus string `json:"logisticsStatus" gorm:"size:16;not null;default:pending;check:ck_purchase_task_logistics_status,logistics_status IN ('pending','collected','failed')"`
|
|
WritebackStatus string `json:"writebackStatus" gorm:"size:20;not null;default:not_selected;check:ck_purchase_task_writeback_status,writeback_status IN ('not_selected','pending','running','succeeded','failed')"`
|
|
WritebackRequestID *string `json:"-" gorm:"size:64;uniqueIndex:ux_purchase_task_writeback_request_id"`
|
|
WritebackAt *time.Time `json:"writebackAt"`
|
|
|
|
RePurchaseAuthorizedAt *time.Time `json:"rePurchaseAuthorizedAt"`
|
|
RePurchaseAuthorizedBy *uint64 `json:"rePurchaseAuthorizedBy"`
|
|
RePurchaseConsumedAt *time.Time `json:"rePurchaseConsumedAt"`
|
|
RePurchaseRequestID *string `json:"-" gorm:"size:64;uniqueIndex:ux_purchase_task_repurchase_request_id"`
|
|
PaymentReviewRequestID *string `json:"-" gorm:"size:64;uniqueIndex:ux_purchase_task_payment_review_request_id"`
|
|
WritebackSelectRequestID *string `json:"-" gorm:"size:64;uniqueIndex:ux_purchase_task_writeback_select_request_id"`
|
|
CancelRequestID *string `json:"-" gorm:"size:64;uniqueIndex:ux_purchase_task_cancel_request_id"`
|
|
UnknownResolveRequestID *string `json:"-" gorm:"size:64;uniqueIndex:ux_purchase_task_unknown_resolve_request_id"`
|
|
CancelledAt *time.Time `json:"cancelledAt"`
|
|
CancelledBy *uint64 `json:"cancelledBy"`
|
|
CancelReason *string `json:"cancelReason" gorm:"size:500"`
|
|
ErrorCode *string `json:"errorCode" gorm:"size:64;index"`
|
|
ErrorMessage *string `json:"errorMessage" gorm:"size:1000"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
}
|
|
|
|
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
|
|
}
|
|
if task.StatusChangedAt.IsZero() {
|
|
task.StatusChangedAt = time.Now().UTC()
|
|
}
|
|
return task.syncPurchaseGuardSlots()
|
|
}
|
|
|
|
func (task *PurchaseTask) BeforeSave(_ *gorm.DB) error { return task.syncPurchaseGuardSlots() }
|
|
|
|
func (task *PurchaseTask) SetStatus(status string) error {
|
|
task.Status = status
|
|
return task.syncPurchaseGuardSlots()
|
|
}
|
|
|
|
func (task *PurchaseTask) syncPurchaseGuardSlots() error {
|
|
if task.TaskType == "" {
|
|
task.TaskType = PurchaseTaskTypeSYBOrder
|
|
}
|
|
if task.TaskType != PurchaseTaskTypeSYBOrder && task.TaskType != PurchaseTaskTypeStock {
|
|
return fmt.Errorf("unsupported purchase task type %q", task.TaskType)
|
|
}
|
|
one := uint8(1)
|
|
active := false
|
|
running := false
|
|
switch task.Status {
|
|
case PurchaseTaskStatusPending, PurchaseTaskStatusSpecProbePending:
|
|
active = true
|
|
case PurchaseTaskStatusRunning, PurchaseTaskStatusOrderSubmitStarted:
|
|
active, running = true, true
|
|
case PurchaseTaskStatusOrderCreated:
|
|
// A successful order occupies the SYB slot until a one-time re-purchase
|
|
// authorization is consumed. After consumption the old order remains a
|
|
// historical fact and future edits must not reclaim the new task's slot.
|
|
active = task.RePurchaseConsumedAt == nil
|
|
case PurchaseTaskStatusOrderResultUnknown:
|
|
active = true
|
|
case PurchaseTaskStatusRehearsalCompleted, PurchaseTaskStatusFailed, PurchaseTaskStatusCancelled:
|
|
case "":
|
|
return fmt.Errorf("purchase task status is required")
|
|
default:
|
|
return fmt.Errorf("unsupported purchase task status %q", task.Status)
|
|
}
|
|
if task.ExecutionMode != PurchaseExecutionModeRehearsal && task.ExecutionMode != PurchaseExecutionModeLive {
|
|
return fmt.Errorf("unsupported purchase execution mode %q", task.ExecutionMode)
|
|
}
|
|
if task.TaskType == PurchaseTaskTypeSYBOrder && task.ExecutionMode == PurchaseExecutionModeLive && task.SYBProductID == nil {
|
|
return fmt.Errorf("live purchase task requires syb_product_id")
|
|
}
|
|
if task.TaskType == PurchaseTaskTypeStock && (task.SYBProductID != nil || task.ShopeeProductID != nil) {
|
|
return fmt.Errorf("stock purchase task cannot reference syb or shopee product")
|
|
}
|
|
if running && task.DeviceID == nil {
|
|
return fmt.Errorf("running purchase task requires device_id")
|
|
}
|
|
if active && task.TaskType == PurchaseTaskTypeSYBOrder && task.SYBProductID != nil {
|
|
task.ActiveSlot = &one
|
|
} else {
|
|
task.ActiveSlot = nil
|
|
}
|
|
if running {
|
|
task.DeviceRunSlot = &one
|
|
if task.PDDAccountID != nil {
|
|
task.AccountRunSlot = &one
|
|
} else {
|
|
task.AccountRunSlot = nil
|
|
}
|
|
} else {
|
|
task.DeviceRunSlot = nil
|
|
task.AccountRunSlot = nil
|
|
}
|
|
if task.SpecDecisionSnapshot == "" {
|
|
task.SpecDecisionSnapshot = "{}"
|
|
}
|
|
if task.RequiredCapabilitiesJSON == "" {
|
|
task.RequiredCapabilitiesJSON = "[]"
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PurchaseTaskAttempt gives each Agent execution a stable idempotency key.
|
|
// The Agent may keep a matching Room row and Outbox entry, while this server
|
|
// row remains the final business fact. No accessibility tree or screenshot is
|
|
// stored here.
|
|
type PurchaseTaskAttempt struct {
|
|
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
|
TaskID uint64 `json:"taskId" gorm:"not null;index;uniqueIndex:ux_purchase_attempt_number,priority:1"`
|
|
Task *PurchaseTask `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
|
|
AttemptID string `json:"attemptId" gorm:"size:36;not null;uniqueIndex:ux_purchase_attempt_id"`
|
|
AttemptNumber int `json:"attemptNumber" gorm:"not null;uniqueIndex:ux_purchase_attempt_number,priority:2;check:ck_purchase_attempt_number,attempt_number >= 1"`
|
|
Phase string `json:"phase" gorm:"size:16;not null;check:ck_purchase_attempt_phase,phase IN ('spec_probe','purchase')"`
|
|
Status string `json:"status" gorm:"size:16;not null;index;check:ck_purchase_attempt_status,status IN ('pending','running','completed','failed')"`
|
|
DeviceID *uint64 `json:"deviceId" gorm:"index"`
|
|
RuleSnapshotHash string `json:"ruleSnapshotHash" gorm:"size:64;not null"`
|
|
SpecDecisionSnapshot string `json:"-" gorm:"type:json;not null"`
|
|
ResultRequestID *string `json:"-" gorm:"size:64;uniqueIndex:ux_purchase_attempt_result_request_id"`
|
|
StartRequestID *string `json:"-" gorm:"size:64;uniqueIndex:ux_purchase_attempt_start_request_id"`
|
|
ResultHash *string `json:"-" gorm:"size:64"`
|
|
ResultType *string `json:"-" gorm:"size:32"`
|
|
ErrorCode *string `json:"errorCode" gorm:"size:64;index"`
|
|
ErrorMessage *string `json:"errorMessage" gorm:"size:1000"`
|
|
StartedAt *time.Time `json:"startedAt"`
|
|
FinishedAt *time.Time `json:"finishedAt"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
}
|
|
|
|
func (PurchaseTaskAttempt) TableName() string { return "purchase_task_attempt" }
|