639 lines
39 KiB
Go
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
|
|
}
|