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

639 lines
39 KiB
Go

package models
import (
"fmt"
"time"
"gorm.io/gorm"
)
const (
DeviceStatusOnline = "online"
DeviceStatusOffline = "offline"
DeviceStatusDisabled = "disabled"
TaskStatusPending = "pending"
TaskStatusRunning = "running"
TaskStatusCompleted = "completed"
TaskStatusCompletedPartial = "completed_partial"
TaskStatusFailed = "failed"
CollectionTaskSourceAdmin = "admin"
CollectionTaskSourceAgentCurrentPage = "agent_current_page"
)
// AgentDevice identifies one Agent installation. InstallID is generated by the
// Android client and is deliberately independent from hardware identifiers.
// Only an irreversible digest of the bearer token is persisted.
type AgentDevice struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
InstallID string `json:"installId" gorm:"size:64;not null;uniqueIndex:ux_agent_device_install_id"`
Name string `json:"name" gorm:"size:100;not null"`
Manufacturer string `json:"manufacturer" gorm:"size:100;not null"`
Model string `json:"model" gorm:"size:100;not null"`
AndroidVersion string `json:"androidVersion" gorm:"size:32;not null"`
AgentVersion string `json:"agentVersion" gorm:"size:32;not null"`
PDDVersion string `json:"pddVersion" gorm:"size:32;not null"`
CapabilitiesJSON string `json:"-" gorm:"size:4096;not null;default:'[]'"`
Status string `json:"status" gorm:"size:16;not null;index;check:ck_agent_device_status,status IN ('online','offline','disabled')"`
TokenDigest string `json:"-" gorm:"size:64;not null;uniqueIndex:ux_agent_device_token_digest"`
TokenIssuedAt time.Time `json:"tokenIssuedAt" gorm:"not null"`
TokenRevokedAt *time.Time `json:"tokenRevokedAt" gorm:"index"`
RecoveryCodeDigest *string `json:"-" gorm:"size:64;index"`
RecoveryExpiresAt *time.Time `json:"-" gorm:"index"`
RecoveryUsedAt *time.Time `json:"-"`
LastRegisterRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_agent_device_last_register_request_id"`
LastHeartbeatRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_agent_device_last_heartbeat_request_id"`
LastHeartbeatAt *time.Time `json:"lastHeartbeatAt" gorm:"index"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (AgentDevice) TableName() string { return "agent_device" }
type AgentAppRelease struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
VersionCode int64 `json:"versionCode" gorm:"not null;uniqueIndex:ux_agent_app_release_version_code"`
VersionName string `json:"versionName" gorm:"size:64;not null"`
FilePath string `json:"-" gorm:"type:text;not null"`
SHA256 string `json:"sha256" gorm:"size:64;not null"`
ByteSize int64 `json:"byteSize" gorm:"not null"`
ReleaseNotes string `json:"releaseNotes" gorm:"type:text;not null"`
CreatedBy uint64 `json:"createdBy" gorm:"not null"`
CreatedAt time.Time `json:"createdAt"`
}
func (AgentAppRelease) TableName() string { return "agent_app_release" }
type AgentAppReleaseSetting struct {
ID uint8 `json:"id" gorm:"primaryKey;autoIncrement:false"`
ReleaseID uint64 `json:"releaseId" gorm:"not null;index"`
Release AgentAppRelease `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
LastUpdateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_agent_app_release_setting_request_id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (AgentAppReleaseSetting) TableName() string { return "agent_app_release_setting" }
type PDDProduct struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
GoodsID string `json:"goodsId" gorm:"size:32;not null;uniqueIndex:ux_pdd_product_goods_id"`
URL string `json:"url" gorm:"type:text;not null"`
Title string `json:"title" gorm:"size:500;not null;default:''"`
ShopName string `json:"shopName" gorm:"size:255;not null;default:''"`
SalesCount *int64 `json:"salesCount" gorm:"check:ck_pdd_product_sales_count,sales_count IS NULL OR sales_count >= 0"`
ReviewCount *int64 `json:"reviewCount" gorm:"check:ck_pdd_product_review_count,review_count IS NULL OR review_count >= 0"`
Status string `json:"status" gorm:"size:16;not null;default:pending;index;check:ck_pdd_product_status,status IN ('pending','active','disabled')"`
SpecsJSON string `json:"-" gorm:"type:json;not null"`
LastCreateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_pdd_product_create_request_id"`
LastUpdateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_pdd_product_update_request_id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (PDDProduct) TableName() string { return "pdd_product" }
// AIMatchingSetting is the single server-side configuration for the optional
// OpenAI-compatible fallback used only to choose an already-observed PDD
// specification. By the #62 internal-deployment exception, the API key is
// stored in this dedicated table as plain text and returned only by the
// administrator settings endpoint. It must never be added to task records,
// Agent payloads, purchaser responses, logs, code, tickets, or Wiki pages.
type AIMatchingSetting struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement:false"`
Enabled bool `json:"enabled" gorm:"not null;default:false"`
Provider string `json:"provider" gorm:"size:32;not null;default:openai_compatible"`
BaseURL string `json:"baseUrl" gorm:"type:text;not null"`
Model string `json:"model" gorm:"size:255;not null;default:''"`
APIKey string `json:"apiKey" gorm:"column:api_key;type:text;not null"`
TimeoutSeconds int `json:"timeoutSeconds" gorm:"not null;default:15"`
AutoConfirmMinConfidence float64 `json:"autoConfirmMinConfidence" gorm:"not null;default:0.9;check:ck_ai_matching_auto_confirm_confidence,auto_confirm_min_confidence >= 0 AND auto_confirm_min_confidence <= 1"`
UpdatedBy *uint64 `json:"updatedBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (AIMatchingSetting) TableName() string { return "ai_matching_setting" }
func (product *PDDProduct) BeforeCreate(_ *gorm.DB) error {
if product.SpecsJSON == "" {
product.SpecsJSON = "[]"
}
if product.Status == "" {
product.Status = "pending"
}
return nil
}
type CollectionRule struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"size:120;not null"`
ContentJSON string `json:"content" gorm:"type:text;not null"`
LastCreateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_rule_create_request_id"`
LastUpdateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_rule_update_request_id"`
LastDeleteRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_rule_delete_request_id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
func (CollectionRule) TableName() string { return "collection_rule" }
// PurchaseRule stores an administrator-reviewed purchase execution contract.
// Tasks copy ContentJSON into their immutable rule snapshot at creation time.
type PurchaseRule struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"size:120;not null"`
ContentJSON string `json:"content" gorm:"type:text;not null"`
LastCreateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_purchase_rule_create_request_id"`
LastUpdateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_purchase_rule_update_request_id"`
LastDeleteRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_purchase_rule_delete_request_id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
func (PurchaseRule) TableName() string { return "purchase_rule" }
// PurchaseRuleSetting selects the single rule used by newly created live
// purchase tasks. There is deliberately no built-in fallback at runtime.
type PurchaseRuleSetting struct {
ID uint8 `json:"id" gorm:"primaryKey;autoIncrement:false"`
RuleID uint64 `json:"ruleId" gorm:"not null;index"`
Rule PurchaseRule `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
LastUpdateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_purchase_rule_setting_request_id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (PurchaseRuleSetting) TableName() string { return "purchase_rule_setting" }
// CollectionTask stores both task lifecycle and result summary. ActiveSlot and
// DeviceRunSlot are nullable guard columns: NULL permits multiple terminal rows,
// while value 1 makes the composite unique indexes enforce active-task limits
// consistently on SQLite, MySQL and PostgreSQL.
type CollectionTask struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
PDDProductID *uint64 `json:"pddProductId" gorm:"uniqueIndex:ux_collection_task_active_product,priority:1"`
PDDProduct *PDDProduct `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
RuleID uint64 `json:"ruleId" gorm:"not null;index"`
Rule CollectionRule `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
DeviceID *uint64 `json:"deviceId" gorm:"index;uniqueIndex:ux_collection_task_running_device,priority:1"`
Device *AgentDevice `json:"-"`
Source string `json:"source" gorm:"size:32;not null;default:admin;index;check:ck_collection_task_source,source IN ('admin','agent_current_page')"`
Status string `json:"status" gorm:"size:24;not null;index;check:ck_collection_task_status,status IN ('pending','running','completed','completed_partial','failed')"`
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_collection_task_active_product,priority:2;check:ck_collection_task_active_slot,(status IN ('pending','running') AND active_slot = 1) OR (status NOT IN ('pending','running') AND active_slot IS NULL)"`
DeviceRunSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_collection_task_running_device,priority:2;check:ck_collection_task_device_run_slot,(status = 'running' AND device_id IS NOT NULL AND device_run_slot = 1) OR (status <> 'running' AND device_run_slot IS NULL)"`
URLSnapshot string `json:"urlSnapshot" gorm:"type:text;not null"`
GoodsIDSnapshot string `json:"goodsIdSnapshot" gorm:"size:32;not null;index"`
RuleSnapshot string `json:"ruleSnapshot" gorm:"type:text;not null"`
AttemptNumber int `json:"attemptNumber" gorm:"not null;default:1;check:ck_collection_task_attempt_number,attempt_number >= 1"`
LeaseExpiresAt *time.Time `json:"leaseExpiresAt" gorm:"index"`
LeaseVersion uint64 `json:"leaseVersion" gorm:"not null;default:0"`
ClaimRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_claim_request_id"`
StartRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_start_request_id"`
CreateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_create_request_id"`
IdentifyRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_identify_request_id"`
ResetRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_reset_request_id"`
DeleteRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_delete_request_id"`
FailRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_fail_request_id"`
Title *string `json:"title" gorm:"size:500"`
ShopName *string `json:"shopName" gorm:"size:255"`
SalesText *string `json:"salesText" gorm:"size:120"`
ReviewCount *int64 `json:"reviewCount"`
MissingJSON *string `json:"missing" gorm:"type:text"`
ResultRequestID *string `json:"resultRequestId" gorm:"size:64;uniqueIndex:ux_collection_task_result_request_id"`
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"`
IdentityResolvedAt *time.Time `json:"identityResolvedAt"`
ReplacementOriginType *string `json:"-" gorm:"size:16;index:ix_collection_replacement_origin,priority:1;check:ck_collection_replacement_origin_type,replacement_origin_type IS NULL OR replacement_origin_type IN ('collection','purchase')"`
ReplacementOriginTaskID *uint64 `json:"-" gorm:"index:ix_collection_replacement_origin,priority:2"`
ReplacementCorrectionID *uint64 `json:"-" gorm:"index"`
ReplacementActivationStatus *string `json:"replacementActivationStatus,omitempty" gorm:"size:16;index;check:ck_collection_replacement_activation_status,replacement_activation_status IS NULL OR replacement_activation_status IN ('pending','activated','failed')"`
ReplacementID *uint64 `json:"replacementId,omitempty" gorm:"index"`
ReplacementActivationErrorCode *string `json:"replacementActivationErrorCode,omitempty" gorm:"size:64"`
ReplacementActivationErrorMessage *string `json:"replacementActivationErrorMessage,omitempty" gorm:"size:500"`
ReplacementActivatedAt *time.Time `json:"replacementActivatedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
func (CollectionTask) TableName() string { return "collection_task" }
func (task *CollectionTask) BeforeCreate(_ *gorm.DB) error {
if task.Source == "" {
task.Source = CollectionTaskSourceAdmin
}
if task.AttemptNumber == 0 {
task.AttemptNumber = 1
}
return task.syncGuardSlots()
}
// BeforeSave derives the nullable uniqueness guards from the state instead of
// relying on every caller to keep them synchronized.
func (task *CollectionTask) BeforeSave(_ *gorm.DB) error {
return task.syncGuardSlots()
}
// AgentManualCollectionSetting stores the one collection rule selected for
// current-page collection. A singleton row keeps this operational choice out
// of task payloads while every created task still receives an immutable rule
// snapshot.
type AgentManualCollectionSetting struct {
ID uint8 `json:"id" gorm:"primaryKey;autoIncrement:false"`
RuleID uint64 `json:"ruleId" gorm:"not null;index"`
Rule CollectionRule `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
LastUpdateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_agent_manual_collection_setting_request_id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (AgentManualCollectionSetting) TableName() string { return "agent_manual_collection_setting" }
// SetStatus changes state and synchronizes guard slots. Callers performing a
// column-scoped update must persist status, active_slot and device_run_slot in
// the same statement; the database check constraints reject partial updates.
func (task *CollectionTask) SetStatus(status string) error {
task.Status = status
return task.syncGuardSlots()
}
func (task *CollectionTask) syncGuardSlots() error {
one := uint8(1)
switch task.Status {
case TaskStatusPending:
task.ActiveSlot = &one
task.DeviceRunSlot = nil
case TaskStatusRunning:
if task.DeviceID == nil {
return fmt.Errorf("running task requires device_id")
}
task.ActiveSlot = &one
task.DeviceRunSlot = &one
case TaskStatusCompleted, TaskStatusCompletedPartial, TaskStatusFailed:
task.ActiveSlot = nil
task.DeviceRunSlot = nil
default:
return fmt.Errorf("unsupported collection task status %q", task.Status)
}
return nil
}
// CollectionTaskAttempt archives one terminal execution before the same
// collection task is reset. It deliberately stores only structured task
// facts; raw accessibility trees, screenshots and device secrets are never
// persisted here.
type CollectionTaskAttempt struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
TaskID uint64 `json:"taskId" gorm:"not null;index;uniqueIndex:ux_collection_attempt_number,priority:1"`
Task CollectionTask `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
AttemptNumber int `json:"attemptNumber" gorm:"not null;uniqueIndex:ux_collection_attempt_number,priority:2;check:ck_collection_attempt_number,attempt_number >= 1"`
Source string `json:"source" gorm:"size:32;not null"`
DeviceID *uint64 `json:"deviceId,omitempty" gorm:"index"`
RuleID uint64 `json:"ruleId" gorm:"not null;index"`
RuleSnapshot string `json:"ruleSnapshot" gorm:"type:text;not null"`
Status string `json:"status" gorm:"size:24;not null;index"`
ErrorCode *string `json:"errorCode,omitempty" gorm:"size:64"`
ErrorMessage *string `json:"errorMessage,omitempty" gorm:"size:1000"`
ResultSummaryJSON string `json:"-" gorm:"type:text;not null"`
StartedAt *time.Time `json:"startedAt,omitempty"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
ArchivedByResetRequestID string `json:"-" gorm:"size:36;not null;uniqueIndex:ux_collection_attempt_reset_request"`
ArchivedAt time.Time `json:"archivedAt" gorm:"not null"`
CreatedAt time.Time `json:"createdAt"`
}
func (CollectionTaskAttempt) TableName() string { return "collection_task_attempt" }
type CollectionDimension struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
TaskID uint64 `json:"taskId" gorm:"not null;index;uniqueIndex:ux_collection_dimension_task_key,priority:1"`
Task CollectionTask `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Key string `json:"key" gorm:"size:64;not null;uniqueIndex:ux_collection_dimension_task_key,priority:2"`
Name string `json:"name" gorm:"size:120;not null"`
SortOrder int `json:"sortOrder" gorm:"not null;default:0"`
CreatedAt time.Time `json:"createdAt"`
}
func (CollectionDimension) TableName() string { return "collection_dimension" }
type CollectionDimensionValue struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
DimensionID uint64 `json:"dimensionId" gorm:"not null;index;uniqueIndex:ux_collection_dimension_value,priority:1"`
Dimension CollectionDimension `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Value string `json:"value" gorm:"size:255;not null;uniqueIndex:ux_collection_dimension_value,priority:2"`
SortOrder int `json:"sortOrder" gorm:"not null;default:0"`
CreatedAt time.Time `json:"createdAt"`
}
func (CollectionDimensionValue) TableName() string { return "collection_dimension_value" }
type CollectionColorPrice struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
TaskID uint64 `json:"taskId" gorm:"not null;index;uniqueIndex:ux_collection_color_price,priority:1"`
Task CollectionTask `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Color string `json:"color" gorm:"size:255;not null;uniqueIndex:ux_collection_color_price,priority:2"`
PriceCent int64 `json:"priceCent" gorm:"not null;check:ck_collection_color_price,price_cent >= 0"`
CreatedAt time.Time `json:"createdAt"`
}
func (CollectionColorPrice) TableName() string { return "collection_color_price" }
// PDDProductColorImage keeps only the latest optional cropped product image
// for one product/color pair. The source task and device preserve the audit
// link to the immutable task rule snapshot without storing a full screenshot.
type PDDProductColorImage struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
PDDProductID uint64 `json:"pddProductId" gorm:"not null;uniqueIndex:ux_pdd_product_color_image,priority:1"`
PDDProduct PDDProduct `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Color string `json:"color" gorm:"size:255;not null;uniqueIndex:ux_pdd_product_color_image,priority:2"`
ImagePath string `json:"imagePath" gorm:"size:512;not null"`
ContentType string `json:"contentType" gorm:"size:32;not null"`
ByteSize int64 `json:"byteSize" gorm:"not null;check:ck_pdd_product_color_image_size,byte_size > 0"`
Width int `json:"width" gorm:"not null;check:ck_pdd_product_color_image_width,width > 0"`
Height int `json:"height" gorm:"not null;check:ck_pdd_product_color_image_height,height > 0"`
SourceTaskID uint64 `json:"sourceTaskId" gorm:"not null;index"`
SourceTask CollectionTask `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
SourceDeviceID uint64 `json:"sourceDeviceId" gorm:"not null;index"`
SourceDevice AgentDevice `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (PDDProductColorImage) TableName() string { return "pdd_product_color_image" }
type CollectionSKU struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
TaskID uint64 `json:"taskId" gorm:"not null;index;uniqueIndex:ux_collection_sku_task_spec,priority:1"`
Task CollectionTask `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
SpecKey string `json:"specKey" gorm:"size:700;not null;uniqueIndex:ux_collection_sku_task_spec,priority:2"`
PriceCent int64 `json:"priceCent" gorm:"not null;check:ck_collection_sku_price,price_cent >= 0"`
Available bool `json:"available" gorm:"not null"`
Complete bool `json:"complete" gorm:"not null"`
CreatedAt time.Time `json:"createdAt"`
}
func (CollectionSKU) TableName() string { return "collection_sku" }
type CollectionSKUValue struct {
SKUID uint64 `json:"skuId" gorm:"primaryKey;autoIncrement:false"`
SKU CollectionSKU `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
DimensionValueID uint64 `json:"dimensionValueId" gorm:"primaryKey;autoIncrement:false;index"`
DimensionValue CollectionDimensionValue `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
func (CollectionSKUValue) TableName() string { return "collection_sku_value" }
// ShopeeProduct is the independent Shopee (虾皮) product archive. It is owned by
// the product domain and never stores purchase task, order or logistics fields.
//
// shopee_item_id must stay unique among live records while soft delete is
// supported. A composite unique index on (shopee_item_id, deleted_at) does NOT
// work with GORM's standard nullable DeletedAt: unique indexes treat every NULL
// as distinct in SQLite, MySQL and PostgreSQL alike, so two live rows (both
// deleted_at = NULL) would never collide and the constraint would be silently
// inert. This was caught by TestShopeeItemIDUniqueAmongLiveRowsOnly before it
// reached production.
//
// Fix: DeletedFlag is an auxiliary NOT NULL column used only by the composite
// unique index. It stays 0 while the row is live, so all live rows compare
// equal on this column and the uniqueness constraint applies for real. On soft
// delete the write path sets DeletedFlag to the row's own ID, which is already
// unique per row, so any number of deleted rows sharing the same
// shopee_item_id never collide with each other or block a new live row.
// DeletedAt keeps its normal GORM semantics (nullable, automatic query
// scoping); DeletedFlag is not queried directly.
type ShopeeProduct struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
ShopeeItemID string `json:"shopeeItemId" gorm:"size:64;not null;uniqueIndex:ux_shopee_product_item_id,priority:1"`
Title string `json:"title" gorm:"size:500;not null;default:''"`
ShopName string `json:"shopName" gorm:"size:255;not null;default:''"`
// PDDProductID stays nullable and must NOT be unique: one Shopee product maps
// to at most one PDD product, but several Shopee products may share the same
// PDD product.
PDDProductID *uint64 `json:"pddProductId" gorm:"index"`
// ImageURL holds the SYB-provided reference image URL. It is written by the
// #41 import and may be overridden manually; the product domain never joins
// syb_products at read time.
// MySQL rejects a literal DEFAULT on TEXT/BLOB/JSON columns, so this stays
// NOT NULL without a DB-level default; Go's zero value ("") is inserted
// explicitly on every create, which satisfies NOT NULL without needing one.
ImageURL string `json:"imageUrl" gorm:"type:text;not null"`
// SalePriceCent is the reference selling price in integer cents, consistent
// with the priceCent convention from #31. It is the value seen at the latest
// import or manual edit, not an authoritative transaction price.
SalePriceCent *int64 `json:"salePriceCent" gorm:"check:ck_shopee_product_sale_price,sale_price_cent IS NULL OR sale_price_cent >= 0"`
// Currency is an ISO 4217 code, never a display symbol. Shopee is a
// cross-border platform, so an amount without a currency is unusable.
Currency string `json:"currency" gorm:"size:3;not null;default:''"`
// SpecsJSON stores Shopee spec values and their mapping to PDD spec values.
SpecsJSON string `json:"-" gorm:"type:json;not null"`
LastCreateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_shopee_product_create_request_id"`
LastUpdateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_shopee_product_update_request_id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
// DeletedFlag: see the type-level comment above. 0 means live.
DeletedFlag uint64 `json:"-" gorm:"not null;default:0;uniqueIndex:ux_shopee_product_item_id,priority:2"`
DeletedBy *uint64 `json:"-"`
}
func (ShopeeProduct) TableName() string { return "shopee_product" }
func (product *ShopeeProduct) BeforeCreate(_ *gorm.DB) error {
if product.SpecsJSON == "" {
product.SpecsJSON = "[]"
}
return nil
}
// Parse status for a SYBProduct's productSpec extraction. See sybimport.Parse.
const (
SYBParseStatusSuccess = "success"
SYBParseStatusUncertain = "uncertain"
SYBParseStatusFailed = "failed"
)
// SYBSession caches one SYB ERP login session so a server restart does not
// force a fresh captcha round-trip. It stores only the session cookies, never
// the account password (#48: 顺云宝账号、密码不得写入代码、日志、工单和文档;
// the password is supplied through GOAUTO_SYB_PASSWORD at call time).
//
// One row per SYB account. Sessions are replaced wholesale on re-login rather
// than merged, so a stale cookie can never survive a successful login.
type SYBSession struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
// Username is the SYB account this session belongs to. It is the business
// key: re-logging in as the same account overwrites the same row.
Username string `json:"username" gorm:"size:128;not null;uniqueIndex:ux_syb_session_username"`
// UserID is SYB's own numeric account id, returned by login. It must be
// cached alongside the cookies: session validation calls
// /am/user/get?id=<userID>, and that endpoint rejects a wrong id with a
// business error rather than a "not logged in" one — so without the real
// id a restored session can never be validated and the cache is useless.
UserID int64 `json:"userId" gorm:"not null;default:0"`
// CookiesJSON is the cookie jar serialised by sybclient.Client.ExportCookiesJSON.
// It is a credential-equivalent secret: never log it, never return it over HTTP.
CookiesJSON string `json:"-" gorm:"type:text;not null"`
// ExpiresAt is min(JWT exp, now+24h) as computed at login time
// (docs/12-syb-erp-interface.md §3.3). A session at or past this instant is
// treated as absent; SYB does not roll the window forward on use (§3.4).
ExpiresAt time.Time `json:"expiresAt" gorm:"not null;index"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// SYBProduct is one SYB (顺云宝 ERP) shipment detail line: one order can carry
// several Shopee product lines, and the same Shopee product can appear more
// than once within one order at different colors/sizes/quantities — each such
// line is its own row here (#41). This table never stores purchase task,
// order or logistics fields; it only records what SYB reported.
//
// Uniqueness is the order code plus the source detail id, not a single global
// id: SYB's detail id shape has only been observed within one order in the
// available sample data, so a global-uniqueness assumption is unverified.
type SYBProduct struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
// OrderCode is SYB's source `code` and represents the Shopee order number
// shown to procurement users (e.g. 260728TB95MJTQ).
OrderCode string `json:"orderCode" gorm:"size:64;not null;uniqueIndex:ux_syb_product_order_detail,priority:1"`
// DetailID is `details[].id` from SYB, e.g. 145306175.
DetailID uint64 `json:"detailId" gorm:"not null;uniqueIndex:ux_syb_product_order_detail,priority:2"`
// StockID is the shipment order's own internal id (o.id, e.g. 75104587),
// kept for reference when calling back into SYB.
StockID uint64 `json:"stockId" gorm:"not null"`
ShopName string `json:"shopName" gorm:"size:255;not null;default:''"`
// ShopeeItemID is `details[].productId` as a string, matching
// shopee_product.shopee_item_id's type. ShopeeProductID links to the
// archive once found, revived or created by the import.
ShopeeItemID string `json:"shopeeItemId" gorm:"size:64;not null;index"`
ShopeeProductID *uint64 `json:"shopeeProductId" gorm:"index"`
ProductTitle string `json:"productTitle" gorm:"size:500;not null;default:''"`
// TargetColor/TargetSize hold the parser's output. Both stay empty when
// ParseStatus is failed; Uncertain may still populate one or both with a
// candidate that needs human review (see sybimport.Parse).
TargetColor string `json:"targetColor" gorm:"size:255;not null;default:''"`
TargetSize string `json:"targetSize" gorm:"size:255;not null;default:''"`
Quantity int64 `json:"quantity" gorm:"not null;check:ck_syb_product_quantity,quantity >= 1"`
// UnitPriceCent is productPrice converted to integer cents, consistent
// with the priceCent/salePriceCent convention (#31, #40).
UnitPriceCent int64 `json:"unitPriceCent" gorm:"not null;check:ck_syb_product_unit_price,unit_price_cent >= 0"`
ImageURL string `json:"imageUrl" gorm:"type:text;not null"`
ParseStatus string `json:"parseStatus" gorm:"size:16;not null;index;check:ck_syb_product_parse_status,parse_status IN ('success','uncertain','failed')"`
ParseNote string `json:"parseNote" gorm:"size:500;not null;default:''"`
// ManuallyConfirmed marks that TargetColor/TargetSize came from a human
// correction, not the parser. A batch reparse skips these rows by default
// so a rule-change rerun never silently overwrites a human decision (#41
// prototype: 人工已修正的明细默认跳过,可勾选强制覆盖). ParseStatus keeps
// recording the parser's own last output for audit even after a manual
// correction; it is not overwritten by the correction itself.
ManuallyConfirmed bool `json:"manuallyConfirmed" gorm:"not null;default:false"`
// AIConfirmed is independent of ParseStatus: ParseStatus remains the
// deterministic parser's audit result, while these fields record a closed-
// candidate, high-confidence AI decision. Human correction always clears
// and supersedes this decision.
AIConfirmed bool `json:"aiConfirmed" gorm:"not null;default:false;index"`
AIConfidence *float64 `json:"aiConfidence,omitempty"`
AIReason string `json:"aiReason,omitempty" gorm:"size:500;not null;default:''"`
AIConfirmedAt *time.Time `json:"aiConfirmedAt,omitempty"`
AIInputFingerprint string `json:"-" gorm:"size:64;not null;default:'';index"`
// RawJSON is the untouched `details[]` element as SYB returned it. It is
// what reparse (#41: "适用于解析规则更新后批量重跑,只读取已保存的原始
// JSON,不请求货运宝接口") reads from; it must never be rewritten by a
// parse-rule change, only the derived fields above may change.
RawJSON string `json:"-" gorm:"type:json;not null"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (SYBProduct) TableName() string { return "syb_product" }
func (SYBSession) TableName() string { return "syb_session" }
// SYBShop is the list of SYB shops whose shipment orders are imported (#49).
//
// A SYB account carries every shop's orders; only shops enabled here are
// written to the archive. The list is deliberately small and hand-curated, but
// names are discovered from real sync data rather than typed, because matching
// is by string: a rename in SYB produces no error, just silently missing data.
//
// NormalizedName is the matching key, not DisplayName. It is DisplayName with
// surrounding whitespace removed, full-width characters folded to half-width
// and letters lowercased, so " ABC店 ", "ABC店" and "abc店" are one shop
// rather than three. The unique index is on the normalized form for that
// reason; DisplayName is only ever shown to people.
//
// Soft delete uses the same sentinel technique as ShopeeProduct: a unique index
// including a nullable deleted_at is silently inert, because unique indexes
// treat every NULL as distinct. DeletedFlag is 0 while live so live rows really
// do collide, and becomes the row's own id on delete so any number of deleted
// rows may share a name.
type SYBShop struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
// DisplayName is what people read and edit. Never used for matching.
DisplayName string `json:"displayName" gorm:"size:255;not null"`
// NormalizedName is the matching key against SYB's shopName.
NormalizedName string `json:"normalizedName" gorm:"size:255;not null;uniqueIndex:ux_syb_shop_normalized_name,priority:1"`
// Enabled decides whether this shop's orders are imported. Disabling stops
// future imports and never touches data already imported.
Enabled bool `json:"enabled" gorm:"not null;default:true;index"`
// LastSeenAt is when this shop last appeared in SYB sync data, and
// LastSeenOrderCount how many shipment orders it had in that run. Both are
// written by the sync and are nil until #50 records a run.
LastSeenAt *time.Time `json:"lastSeenAt"`
LastSeenOrderCount *int `json:"lastSeenOrderCount"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
DeletedFlag uint64 `json:"-" gorm:"not null;default:0;uniqueIndex:ux_syb_shop_normalized_name,priority:2"`
}
func (SYBShop) TableName() string { return "syb_shop" }
// SYBSyncRun records one asynchronous SYB import. ActiveSlot is set only while
// a run is active; its unique index is the database-level single-flight guard
// across processes and is cleared when the run reaches a terminal state.
type SYBSyncRun struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
DateFrom string `json:"dateFrom" gorm:"size:10;not null"`
DateTo string `json:"dateTo" gorm:"size:10;not null"`
Status string `json:"status" gorm:"size:16;not null;index"`
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_syb_sync_run_active_slot"`
DaysTotal int `json:"daysTotal" gorm:"not null;default:0"`
DaysProcessed int `json:"daysProcessed" gorm:"not null;default:0"`
OrderCount int `json:"orderCount" gorm:"not null;default:0"`
DetailCount int `json:"detailCount" gorm:"not null;default:0"`
AcceptedCount int `json:"acceptedCount" gorm:"not null;default:0"`
ShopSkipped int `json:"shopSkipped" gorm:"not null;default:0"`
Created int `json:"created" gorm:"column:created_count;not null;default:0"`
Updated int `json:"updated" gorm:"column:updated_count;not null;default:0"`
ShopFilterHash string `json:"shopFilterHash" gorm:"size:64;not null"`
ShopBreakdownJSON string `json:"-" gorm:"type:text;not null"`
ErrorMessage string `json:"errorMessage" gorm:"size:1000;not null;default:''"`
OperatorID uint64 `json:"operatorId" gorm:"not null;default:0"`
OperatorName string `json:"operatorName" gorm:"size:128;not null;default:''"`
StartedAt time.Time `json:"startedAt" gorm:"not null"`
FinishedAt *time.Time `json:"finishedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (SYBSyncRun) TableName() string { return "syb_sync_run" }
func (product *SYBProduct) BeforeCreate(_ *gorm.DB) error {
if product.RawJSON == "" {
product.RawJSON = "{}"
}
return nil
}