Files
goauto/server/app/goauto/models/purchase.go
T

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" }