// Package model 只放数据结构,不导入 Gin,也不导入数据库驱动。 // // 这样领域概念才能被单元测试直接使用,不用起服务器、不用连数据库。 // 字段含义的权威定义在 docs/admin/03-data-model.md。 package model import ( "fmt" "time" ) // ---------- 时间 ---------- // TimeLayout 是全项目统一的时间格式:带时区的 ISO 8601。 // 库里存 UTC,页面上再转本地时区显示。 const TimeLayout = time.RFC3339 // NowISO 返回当前 UTC 时间的字符串形式。 // 所有写库的时间戳都要用它,不要各写各的格式。 // // `[必须]` 有代码依赖它产出**定宽 UTC 格式**(如 "2026-08-07T06:10:19Z"): // repository.MarkCollecting 和 service 里判断"采集是否超时", // 靠的是直接用字符串比较 `updated_at < ?`,不解析成时间再比。 // 定宽 + 同一时区(UTC)+ 补零,字符串的字典序才等于时间先后顺序。 // 改成带时区偏移的本地时间(比如 "+08:00")之后,这个比较会**静默失效** // ——不会报错,但超时判断会全错,见 #24。 func NowISO() string { return time.Now().UTC().Format(TimeLayout) } // CollectStaleAfter 是采集任务多久没动静就当它死了。 // // 采集本身几十秒到两分钟;加上排队等客户端来领,15 分钟足够宽裕。 // 宁可短也不要长:采集是**只读**操作,多采一次没有任何副作用, // 而卡死的代价是这个商品永久报废、只能改数据库救,见 #24。 // // 定义成有名字的常量,不要把 15 分钟当魔数散落在各处判断里。 const CollectStaleAfter = 15 * time.Minute // ParseISO 解析库里存的时间字符串。解析不了返回零值和 false。 func ParseISO(s string) (time.Time, bool) { if s == "" { return time.Time{}, false } ts, err := time.Parse(TimeLayout, s) if err != nil { return time.Time{}, false } return ts, true } // ---------- Admin 用户与网页登录 ---------- // UserRole 是 Admin 网页账号的固定角色。本阶段不做可配置 RBAC。 type UserRole string const ( RoleAdmin UserRole = "admin" RolePurchaser UserRole = "purchaser" ) // UserStatus 表示账号是否允许登录。 type UserStatus string const ( UserActive UserStatus = "active" UserDisabled UserStatus = "disabled" ) // User 是一个 Admin 网页账号。PasswordHash 只在认证服务内部使用, // 模板和日志都不得输出它。 type User struct { UserID string Username string PasswordHash string Role UserRole Status UserStatus LastLoginAt string PasswordChangedAt string CreatedAt string UpdatedAt string } // IsAdmin 供路由权限判断和模板决定是否显示用户管理入口。 func (u *User) IsAdmin() bool { return u != nil && u.Role == RoleAdmin } // IsActive 供模板决定显示“启用”还是“禁用”操作。 func (u User) IsActive() bool { return u.Status == UserActive } // RoleLabel 返回给操作员看的中文角色名。 func (u User) RoleLabel() string { if u.Role == RoleAdmin { return "管理员" } return "采购员" } // StatusLabel 返回给操作员看的中文账号状态。 func (u User) StatusLabel() string { if u.Status == UserActive { return "启用" } return "禁用" } // AIProviderConfig 是一个 OpenAI 兼容服务商的非敏感配置。 // API Key 不属于这个结构,也绝不能进入数据库。 type AIProviderConfig struct { ProviderID string Name string BaseURL string Model string TimeoutSeconds int MaxConcurrency int ConfidenceThresholdBPS int Enabled bool LastTestStatus string LastTestMessage string LastTestedAt string LastTestFingerprint string CreatedByUserID string UpdatedByUserID string CreatedAt string UpdatedAt string SecretConfigured bool SecretSuffix string } func (c AIProviderConfig) StatusLabel() string { if c.Enabled { return "已启用" } return "已停用" } func (c AIProviderConfig) TestStatusLabel() string { switch c.LastTestStatus { case "succeeded": return "连接正常" case "failed": return "连接失败" default: return "尚未测试" } } func (c AIProviderConfig) ConfidencePercent() string { return fmt.Sprintf("%d.%02d", c.ConfidenceThresholdBPS/100, c.ConfidenceThresholdBPS%100) } // AIProviderAudit 只记录非敏感配置变更;DetailsJSON 不得含 API Key。 type AIProviderAudit struct { ID int64 ProviderID string Action string DetailsJSON string ActorUserID string CreatedAt string } // WebSession 是服务端保存的网页登录状态。SessionHash 是浏览器随机 Token // 的 SHA-256,不是 Token 原文。 type WebSession struct { SessionHash string UserID string ExpiresAt string CreatedAt string LastSeenAt string } // ---------- 蝦皮 ---------- // CollectStatus 是一个 **PDD 商品**的采集进度。 // // 注意它挂在 PddProduct 上,不在 ShopeeProduct 上——被采集的是 PDD 商品。 // 两个蝦皮商品指向同一个 PDD 链接时,状态只有一份,不会各记一份还对不上。 // // 这里**没有"未填链接"**:pdd_products 里有这一行,就说明链接已经填了。 // "未填链接"是蝦皮侧的状态(ShopeeProduct.PddGoodsID 为空)。 // 界面上仍然显示 5 种,只是数据来源不同,见 docs/admin/01-requirements.md §6.1。 type CollectStatus string const ( CollectPending CollectStatus = "pending" // 已填链接,未发起采集 CollectCollecting CollectStatus = "collecting" // 采集中,不允许再建任务 CollectCollected CollectStatus = "collected" // 已采集 CollectFailed CollectStatus = "failed" // 采集失败,可重新采集 ) // PddProduct 是一个拼多多商品。 // // 它和蝦皮商品是**两个独立的东西**:蝦皮链接相对稳定, // 而 PDD 商品下架换代很频繁——A 买不到了就得换 B。 // 所以两者的对应关系放在 ShopeeProduct.PddGoodsID 上,随时可改。 type PddProduct struct { ID int64 GoodsID string // 从 URL 解析,UNIQUE,防重靠它 URL string // 操作员填的链接原文 Title string // 采集回来,人工核对用 ShopName string // 采集回来的店铺名,可能为空 SkusJSON string // schema_version + dimensions + skus CollectStatus CollectStatus CollectMsg string // 失败原因 ArtifactRef string // 诊断产物位置,例如 client-001:artifacts/PDD-0001/xxx/ CollectedAt string DeletedAt string // 软删除;非空表示已删除,但记录还在 CreatedAt string UpdatedAt string } // IsDeleted 判断这条记录是不是已经被软删除了。 func (p PddProduct) IsDeleted() bool { return p.DeletedAt != "" } // ShopeeProduct 是蝦皮商品(商品级)。 // // PddGoodsURL 和 PddGoodsID 是我们自己维护的,蝦皮报表里没有, // Excel 导入时**绝不能覆盖**,见 docs/admin/03-data-model.md §3.3。 // // 采集结果和采集状态**不在这里**——它们属于 PDD 商品,见 PddProduct。 type ShopeeProduct struct { GoodsID string ShopID string // 关联店铺管理中的稳定店铺;来源名称仍单独保留 Title string ShopeeStatus string MainSKUCode string ImageURL string ShopeeShopName string ImageSource, ImageObservedAt string ImageIsManual bool ShopNameSource, ShopNameObservedAt string ShopNameIsManual bool Source string // report=蝦皮报表完整行;syb=顺运宝同步补建的商品骨架 PddGoodsURL string PddGoodsID string // 指向 PddProduct.GoodsID,为空表示还没填链接 DeletedAt string DeletedByUserID string CreatedAt string UpdatedAt string } // IsDeleted 判断蝦皮商品是否在回收状态。 func (p ShopeeProduct) IsDeleted() bool { return p.DeletedAt != "" } // ShopeeSKU 是蝦皮的一个规格(SKU 级)。 // // SpecRaw 是报表里的规格原文,例如「黑色,M【建議40-50公斤】」, // **永远原样保留**。Color/Size/Advice 是尽力解析的结果, // 解析失败时留空并把 ParseOK 置 false,不要瞎猜。 type ShopeeSKU struct { RecordID string // 系统内部记录主键 SKUID string // 可空的真实蝦皮 SKU ID,不得用内部主键冒充 GoodsID string SpecRaw string Color string Size string Advice string // 建议体重,如「40-50公斤」 ParseOK bool SKUCode string IsManual bool // 人工新增的,后续导入不得删除 CreatedAt string UpdatedAt string } // ---------- 顺运宝 ---------- // SybOrder 是一张顺运宝货运单里的**一个商品明细行**(不是一张货运单, // 一张货运单可以有多个商品,各占一行)。 // // PriceTwdCent 是**台币分**,跟采购任务的人民币订单总价上限没有换算关系, // 不要互相赋值,见 docs/admin/01-requirements.md §7。 // // ShopeeSKUID 是 #88 之前的蝦皮规格识别结果。数据库列因只追加迁移保留, // 新同步和采购主链路均不再读写它。 type SybOrder struct { SybID string OrderNo string ShopID string // 由 SYB 店铺名精确解析,无法识别时为空 ShopName string // 顺运宝货运单级 shopName;同一货运单的商品明细相同 Title string ProductSpec string // 规格原文,顺运宝 productSpec,原样保留,对应 shopee_skus.spec_raw SpecKey string // ProductSpec 的稳定身份键;空规格保持 SQL NULL,读出时为空字符串 ShopeeGoodsID string ShopeeSKUID string // 历史兼容字段;新主链路不使用 Quantity int PriceTwdCent int64 ImageURL string SybData string // 完整货运单+明细 JSON,原样保留,审计用 CreatedAt string UpdatedAt string } // SybSyncRunStatus 是一次顺运宝同步记录的持久化状态。 type SybSyncRunStatus string const ( SybSyncRunning SybSyncRunStatus = "running" SybSyncSucceeded SybSyncRunStatus = "succeeded" SybSyncFailed SybSyncRunStatus = "failed" SybSyncInterrupted SybSyncRunStatus = "interrupted" ) // SybSyncRun 记录一次同步的范围、操作人和最终结果。 // 这里只保存审计所需的统计和错误摘要,不保存 Cookie、验证码或原始响应。 type SybSyncRun struct { RunID string UserID string Username string DateFrom string DateTo string Status SybSyncRunStatus StockCount int AcceptedCount int ShopSkipped int ShopFilterHash string DetailCount int Created int Updated int Skipped int ErrorMessage string CursorAdvanced bool StartedAt string FinishedAt string } // Shop 是 SYB 与蝦皮共用的店铺配置。 // DisplayName 是唯一面向用户的店铺名称;各业务表仍保留来源系统返回的原始名称。 type Shop struct { ShopID string DisplayName string NormalizedName string Enabled bool ShopeeProductCount int CreatedByUserID string CreatedAt string UpdatedAt string } // ShopChannelAlias 是 v18 渠道名称结构的兼容记录;v19 起完全由 Shop 派生。 type ShopChannelAlias struct { AliasID string ShopID string Channel string // syb / shopee AliasName string NormalizedAlias string Enabled bool CreatedAt string UpdatedAt string } // SpecMapping 是「这个蝦皮商品的顺运宝规格 = 拼多多的那个规格」。 // // # 为什么主键要带上 PddGoodsID // // PDD 商品下架换代很频繁。假设蝦皮商品 X 原来对应 PDD 商品 A, // 操作员匹配好了"黑色/M → 黑色/M码";后来 A 下架,换成了 B。 // // 如果映射不带 PddGoodsID,那条旧映射还在,但它描述的是 **A 的规格**: // // - 运气好:B 没有"黑色/M码",建任务时找不到会报错,还算安全; // - 运气坏:B 恰好也有"黑色/M码",但完全是另一件衣服 // —— **静默买错,而且事后查不出来**。 // // 把 PddGoodsID 放进主键后,查映射永远带上"当前对应的 PDD 商品"这个条件, // 换成 B 就自然查不到 A 的映射,界面显示"待匹配"。 // 不需要在换商品时记得去删旧数据——靠查询条件天然隔离,忘不了。 // // 附带好处:A 的映射还留着。A 补货换回去时,之前的匹配成果直接复用。 type SpecMapping struct { ShopeeGoodsID string SpecKey string // 由 spec.SpecKey 生成,存和查必须使用同一实现 SpecRaw string // 保存当时的顺运宝原文,供人工核对 PddGoodsID string // 这条映射属于哪个 PDD 商品 PddOptionKey string // 规范化后的组合键,见 service.OptionKey PddOptions string // 原始 options 对象 JSON,显示用 MappedAt string MappedBy string Source string SourceProviderID string SourceModel string ConfidenceBPS int ConfidenceSet bool SourceReason string SourceVersion string ContextVersion string } // ProductColorMapping 保存一个蝦皮商品颜色到当前 PDD 商品颜色值的人工映射。 // // 尺码不属于这里:采购仍以完整 spec_mappings 为准。PDD 商品发生替换时, // PddGoodsID 会把旧映射自然隔离并保留,避免误用到新商品。 type ProductColorMapping struct { ShopeeGoodsID string ShopeeColorKey string ShopeeColorRaw string PddGoodsID string PddDimensionKey string PddColorValue string ContextVersion string MappedBy string MappedAt string } // ProductColorMappingAudit 是颜色映射的追加式审计记录。 // 清除映射只删除当前态,旧值仍保留在审计表中。 type ProductColorMappingAudit struct { ID int64 ShopeeGoodsID string ShopeeColorKey string ShopeeColorRaw string PddGoodsID string Action string OldDimensionKey string OldColorValue string NewDimensionKey string NewColorValue string ContextVersion string ActorUserID string CreatedAt string } type SpecMappingDecision struct { ShopeeGoodsID, SpecKey, PddGoodsID string RulesVersion, SuggestedOptionKey, ChosenOptionKey string Accepted bool DecidedBy, DecidedAt string } // AISpecMatchDecision 是不含完整提示词、模型响应和订单信息的 AI 规格匹配审计。 type AISpecMatchDecision struct { ShopeeGoodsID, SpecKey, PddGoodsID string ContextVersion, RulesVersion, PromptVersion string ProviderID, ProviderName, Model, ConfigFingerprint string CandidatesJSON, ChosenCandidateID, ChosenOptionKey string ConfidenceBPS int ConfidenceSet bool Outcome, Reason, ConflictDimensionsJSON, MissingDimensionsJSON string DecidedBy, DecidedAt string } // PurchaseSpecResolutionOutcome 是一次采购运行时规格解析的持久化结论。 // pending 只表示候选观察已经落库,尚未完成规则或 AI 决策。 type PurchaseSpecResolutionOutcome string const ( PurchaseSpecResolutionPending PurchaseSpecResolutionOutcome = "pending" PurchaseSpecResolutionMatched PurchaseSpecResolutionOutcome = "matched" PurchaseSpecResolutionUncertain PurchaseSpecResolutionOutcome = "uncertain" PurchaseSpecResolutionRejected PurchaseSpecResolutionOutcome = "rejected" PurchaseSpecResolutionFailed PurchaseSpecResolutionOutcome = "failed" ) // PurchaseSpecResolutionSource 记录最终结论由哪条受控路径产生。 type PurchaseSpecResolutionSource string const ( PurchaseSpecResolutionRule PurchaseSpecResolutionSource = "rule" PurchaseSpecResolutionAI PurchaseSpecResolutionSource = "ai" PurchaseSpecResolutionReused PurchaseSpecResolutionSource = "reused" ) // PurchaseSpecResolution 同时保存 Client 的候选观察和 Admin 的最终决策。 // JSON 字段只允许保存接口契约定义的规格数据,不得写入控件树、订单或凭据。 type PurchaseSpecResolution struct { ResolutionID, TaskID, AttemptID, ClientID string TaskVersion int64 PddGoodsID, OriginalOptionsJSON, SelectedColor string TargetSize, CandidatesJSON, CandidateSnapshotHash string ObservedAt, RequestHash string Outcome PurchaseSpecResolutionOutcome DecisionSource PurchaseSpecResolutionSource ChosenCandidateID, ResolvedOptionsJSON string ConfidenceBPS int ConfidenceSet bool Reason, ProviderID, SourceModel, ConfigFingerprint string RulesVersion, PromptVersion, CreatedAt, DecidedAt string } // PurchaseSpecResolutionDecision 是 Repository 完成一次解析记录时需要的最小字段。 type PurchaseSpecResolutionDecision struct { ResolutionID string Outcome PurchaseSpecResolutionOutcome DecisionSource PurchaseSpecResolutionSource ChosenCandidateID, ResolvedOptionsJSON string ConfidenceBPS int ConfidenceSet bool Reason, ProviderID, SourceModel, ConfigFingerprint string RulesVersion, PromptVersion, DecidedAt string } type AIMatchBatch struct { BatchID, Status, CreatedByUserID string TotalCount, ProcessedCount, SuccessCount, ReusedCount int ManualCount, FailedCount int ProviderID, ProviderName, ProviderBaseURL, Model string TimeoutSeconds, MaxConcurrency, ConfidenceThresholdBPS int ConfigFingerprint, RulesVersion, PromptVersion, ErrorMessage string CreatedAt, StartedAt, FinishedAt string } type AIMatchBatchItem struct { ItemID, BatchID, SybID, IdentityHash, LeaderSybID, ContextVersion string Position int Status, Outcome, Message, MappingSource, OptionKey string ConfidenceBPS int ConfidenceSet bool ModelCalled bool StartedAt, FinishedAt string } // ---------- 任务 ---------- // TaskType 区分采集任务和采购任务。 type TaskType string const ( TaskCollect TaskType = "collect" TaskPurchase TaskType = "purchase" ) // TaskStatus 是 **Admin 侧**的任务状态。 // // 注意它和 Client 本地的 8 个状态是两套,不要混。 // Admin 看不到客户端执行到哪一步(没有心跳,是有意的), // claimed 之后就只能等结果。 type TaskStatus string const ( TaskPending TaskStatus = "pending" // 待分配 TaskAssigned TaskStatus = "assigned" // 待领取 TaskClaimed TaskStatus = "claimed" // 已领取,正在执行 TaskSucceeded TaskStatus = "succeeded" // 成功 TaskManualReview TaskStatus = "manual_review" // 需人工 TaskFailed TaskStatus = "failed" // 失败 TaskCancelled TaskStatus = "cancelled" // 已取消 ) // TaskExecutionMode 区分只演练流程和会在拼多多创建未付款订单的真实流程。 // 模式在任务创建时确定,后续取消、重派和结果回传都不得修改。 type TaskExecutionMode string const ( TaskExecutionDryRun TaskExecutionMode = "dry_run" TaskExecutionLive TaskExecutionMode = "live" ) // Task 是发给 Client 执行的一个任务。 // // PddGoodsURL 必填——Client 那边是 NOT NULL,空了它执行不了。 // 采购任务的 Quantity 和 MaxPriceCent 也必填,这是价格保护, // 见 docs/client/04-admin-api-contract.md §4。 type Task struct { TaskID string TaskType TaskType Status TaskStatus ExecutionMode TaskExecutionMode Version int Priority int AssignedClient string ClaimedAt string SybID string OrderNo string GoodsID string ShopeeSKUID string PddGoodsURL string PddGoodsID string PddOptions string // JSON,采购任务的目标规格 Quantity int MaxPriceCent int64 // 人民币订单总价上限,单位分 ResultData string ErrorCode string ErrorMessage string FinishedAt string LiveConfirmedBy string LiveConfirmedAt string CreatedByUserID string CreatedAt string UpdatedAt string } // ---------- 客户端 ---------- // Client 是一台执行任务的客户端。 // // 注意**没有 Status 字段**:在线状态是算出来的(见 IsOnline), // 存成字段会和真实情况不同步。 type Client struct { ClientID string Name string DeviceAddress string Platform string PddPackage string Capabilities string LastSeenAt string CreatedAt string UpdatedAt string } // ClientUserAssignment 记录客户端在一段时间内由哪位采购员负责。 // EndedAt 为空表示当前归属;转交和解绑只结束旧记录,不删除历史。 type ClientUserAssignment struct { AssignmentID string ClientID string UserID string StartedAt string EndedAt string AssignedByUserID string EndedByUserID string EndReason string } // IsOnline 判断客户端此刻算不算在线。 // // 规则很简单:最近活动时间在 threshold 之内就算在线。 // **没有心跳是有意的**,所以客户端执行长任务期间不调接口, // 可能显示成离线——这是已知且接受的取舍, // 见 docs/admin/04-client-api.md §3。 // // LastSeenAt 解析不了时一律当离线,不要当在线。 func (c Client) IsOnline(now time.Time, threshold time.Duration) bool { seen, ok := ParseISO(c.LastSeenAt) if !ok { return false } return now.Sub(seen) < threshold } // StatusText 返回给页面显示的中文状态。 // 状态不能只靠颜色区分,必须有文字。 func (c Client) StatusText(now time.Time, threshold time.Duration) string { if c.IsOnline(now, threshold) { return "在线" } return "离线" }