# 本地模块合约 > ShopHelm 首发没有 HTTP 后端。本文定义 Gio UI 可调用的 application service、平台 port、事件和错误合约。实现可以拆分文件,但不得另起一套语义不兼容的接口。 ## 一、通用约定 - 所有可能 I/O 的方法第一个参数是 `context.Context`。 - application service 输入输出使用 domain/application DTO,不把 Gio widget 或 SQL row 暴露出去。 - ID 使用 `int64`,`0` 表示尚未持久化,外部输入不接受负数。 - 时间在 service 边界使用 `time.Time`,持久化时转 UTC RFC3339。 - 金额输入先按货币精度解析成整数最小单位;比例和汇率使用十进制字符串或 `big.Rat`,不直接用二进制浮点作为业务事实。 - 列表默认 `limit=50`,最大 `200`;必须有稳定的 ID 二级排序。 - 写操作成功后返回持久化后的完整 view。 - 错误使用稳定 code;底层 cause 只供日志,不直接显示给用户。 ## 二、通用类型 目标形状: ```go type SortDirection string const ( SortAsc SortDirection = "asc" SortDesc SortDirection = "desc" ) type PageRequest struct { Offset int Limit int SortBy string Direction SortDirection } type Page[T any] struct { Items []T Total int Offset int Limit int } type ErrorCode string type AppError struct { Code ErrorCode Message string FieldErrors map[string]string Retryable bool Cause error } ``` `Message` 是安全的默认用户提示;UI 可以按 `Code` 替换为更具体中文,但不得展示 `Cause`。 ## 三、店铺服务 ### 3.1 输入与视图 ```go type StoreInput struct { Platform string CountryCode string Name string AccountLabel string Owner string Tags []string Status string Note string Profile BrowserProfileInput Proxy ProxyInput } type BrowserProfileInput struct { PathKind string // managed | external ProfileDir string ChromePath string StartURL string } type ProxyInput struct { Enabled bool Scheme string // http | https | socks4 | socks5 Host string Port int } type StoreFilter struct { Query string Platforms []string Countries []string Owners []string Tags []string Statuses []string IncludeArchived bool Page PageRequest } type StoreView struct { Store domain.Store Profile domain.BrowserProfile Proxy domain.ProxyConfig Tags []string Runtime BrowserRuntimeView ShortcutCount int PendingTodoCount int } ``` `StoreInput` 没有密码、Cookie、token 或代理认证字段。MVP 期间不得添加这些字段的“临时字符串”替代品。 ### 3.2 服务接口 ```go type StoreService interface { List(ctx context.Context, filter StoreFilter) (Page[StoreView], error) Get(ctx context.Context, id int64) (StoreView, error) Create(ctx context.Context, input StoreInput) (StoreView, error) Update(ctx context.Context, id int64, input StoreInput) (StoreView, error) Archive(ctx context.Context, id int64) error Restore(ctx context.Context, id int64) (StoreView, error) } ``` 规则: - `Create` 对 managed profile 生成 `\`,因此可以先事务创建店铺再补 profile;任一步失败整体回滚。 - external profile 必须已存在且是目录;managed profile 可由服务创建。 - `Archive` 只写 `archived_at`,不关闭 Chrome,不删目录。 - `(platform, country, name)` 在未归档记录中重复时返回 `conflict`。 ## 四、快捷入口和待办 ```go type StoreShortcutService interface { List(ctx context.Context, storeID int64) ([]domain.StoreShortcut, error) Create(ctx context.Context, storeID int64, input ShortcutInput) (domain.StoreShortcut, error) Update(ctx context.Context, id int64, input ShortcutInput) (domain.StoreShortcut, error) Delete(ctx context.Context, id int64) error } type TodoService interface { List(ctx context.Context, filter TodoFilter) (Page[domain.Todo], error) Create(ctx context.Context, input TodoInput) (domain.Todo, error) Update(ctx context.Context, id int64, input TodoInput) (domain.Todo, error) SetStatus(ctx context.Context, id int64, status string) (domain.Todo, error) Delete(ctx context.Context, id int64) error } ``` 快捷链接只接受绝对 `http` 或 `https` URL。`javascript:`、`file:` 和命令协议必须拒绝。 ## 五、Chrome 与 profile 合约 ### 5.1 Application service ```go type OpenStoreRequest struct { StoreID int64 ShortcutID *int64 } type LaunchResult struct { StoreID int64 PID int TargetURL string StartedAt time.Time } type BrowserRuntimeView struct { StoreID int64 Status string PID int ExitCode *int LastError *AppError ChangedAt time.Time } type BrowserLaunchService interface { Open(ctx context.Context, request OpenStoreRequest) (LaunchResult, error) Runtime(ctx context.Context, storeID int64) (BrowserRuntimeView, error) ListRuntime(ctx context.Context) ([]BrowserRuntimeView, error) } ``` `Open` 的副作用: - 可能创建 managed profile 目录。 - 启动外部 Chrome。 - 成功后更新 `last_opened_at` 和最后 PID。 - 发布 `browser.status.changed`。 `Open` 不等待 Chrome 退出。应用退出时默认不结束已打开的 Chrome。 ### 5.2 Platform ports ```go type LaunchSpec struct { Executable string ProfileDir string TargetURL string ProxyURL string } type ProcessHandle interface { PID() int Wait(ctx context.Context) (exitCode int, err error) } type ChromeLauncher interface { Start(ctx context.Context, spec LaunchSpec) (ProcessHandle, error) } type ProfileInspector interface { Inspect(ctx context.Context, profileDir string) (ProfileUse, error) } type ProfileUse struct { Occupied bool PID int Source string // shophelm_registry | chrome_lock | unknown } ``` `ChromeLauncher` 的单元测试必须能注入 fake executable/command runner。只有 platform adapter 可构造 Chrome 参数。 ## 六、商品服务 ```go type ProductInput struct { StoreID int64 Name string SKU string Cost string SalePrice string Currency string Stock *int ProductURL string Status string Note string } type ProductFilter struct { Query string StoreIDs []int64 Statuses []string IncludeArchived bool Page PageRequest } type ProductService interface { List(ctx context.Context, filter ProductFilter) (Page[domain.Product], error) Get(ctx context.Context, id int64) (domain.Product, error) Create(ctx context.Context, input ProductInput) (domain.Product, error) Update(ctx context.Context, id int64, input ProductInput) (domain.Product, error) Archive(ctx context.Context, id int64) error Restore(ctx context.Context, id int64) (domain.Product, error) } type ContentTemplateService interface { List(ctx context.Context, filter TemplateFilter) (Page[domain.ContentTemplate], error) Create(ctx context.Context, input ContentTemplateInput) (domain.ContentTemplate, error) Update(ctx context.Context, id int64, input ContentTemplateInput) (domain.ContentTemplate, error) Archive(ctx context.Context, id int64) error } type ProductChecklistService interface { List(ctx context.Context, productID int64) ([]domain.ProductCheckItem, error) SeedDefaults(ctx context.Context, productID int64) ([]domain.ProductCheckItem, error) SetChecked(ctx context.Context, itemID int64, checked bool) (domain.ProductCheckItem, error) } ``` 商品 URL 可以为空;非空时只接受绝对 HTTP(S) URL。SKU 可为空,但同一店铺非空 SKU 重复时 UI 必须警告;是否阻止保存由 T-301 根据用户确认定稿并同步 schema。 ## 七、价格计算 ```go type PriceCalculationInput struct { SalePrice string ProductCost string ShippingCost string OtherCost string PlatformFeeRate string ExchangeRate string Currency string CostCurrency string } type PriceCalculationResult struct { Inputs PriceCalculationInput PlatformFee string TotalCost string ReferenceMargin string ReferenceMarginRate string Warnings []string } type PriceCalculator interface { Calculate(input PriceCalculationInput) (PriceCalculationResult, error) } ``` 规则: - 空费率、汇率或费用不能偷偷套默认值;返回校验错误或明确的零值警告。 - 除数为零时返回 `validation_failed`。 - 输出必须回显输入,界面称其为“基础利润参考”,不称财务报表。 ## 八、商品文件导入导出 ```go type ImportFormat string // csv | xlsx type ImportStrategy string // all_or_nothing | valid_rows_only type ProductImportPreview struct { SourcePath string Fingerprint string Format ImportFormat Sheet string ValidRows []ProductImportRow Errors []ImportRowError TotalRows int } type ProductImportCommit struct { SourcePath string Fingerprint string Strategy ImportStrategy DefaultStoreID int64 } type ImportResult struct { Created int Updated int Skipped int } type ProductImportExportService interface { PreviewImport(ctx context.Context, path string, format ImportFormat) (ProductImportPreview, error) CommitImport(ctx context.Context, request ProductImportCommit) (ImportResult, error) Export(ctx context.Context, filter ProductFilter, destination string, format ImportFormat) (FileResult, error) } ``` 提交时重新计算文件 SHA-256;与 preview fingerprint 不同则返回 `source_changed`,要求重新预览。 ## 九、图片素材与合成 ### 9.1 素材服务 ```go type ImageAssetInput struct { StoreID *int64 ProductID *int64 AssetType string SourcePath string CopyToLibrary bool Note string } type ImageAssetService interface { List(ctx context.Context, filter ImageAssetFilter) (Page[domain.ImageAsset], error) Add(ctx context.Context, input ImageAssetInput) (domain.ImageAsset, error) Update(ctx context.Context, id int64, input ImageAssetInput) (domain.ImageAsset, error) Remove(ctx context.Context, id int64) error RefreshMetadata(ctx context.Context, id int64) (domain.ImageAsset, error) } ``` `Remove` 删除数据库关联;仅当文件是 app-managed 且用户明确勾选删除文件时,才能进入独立确认流程。默认不删文件。 ### 9.2 合成规格 ```go type CompositionSpec struct { BaseAssetID int64 OverlayAssetID int64 CanvasWidth int CanvasHeight int BaseFit string // contain | cover CenterX float64 // normalized canvas coordinate CenterY float64 WidthRatio float64 Opacity float64 BackgroundRGBA uint32 } type CompositionPlan struct { Canvas image.Rectangle BaseSource image.Rectangle BaseTarget image.Rectangle OverlaySource image.Rectangle OverlayTarget image.Rectangle Opacity uint8 } type ExportImageRequest struct { StoreID *int64 ProductID *int64 Spec CompositionSpec Format string // png | jpg Destination string Overwrite bool } type ImageComposerService interface { Plan(ctx context.Context, spec CompositionSpec) (CompositionPlan, error) RenderPreview(ctx context.Context, spec CompositionSpec, maxWidth, maxHeight int) (image.Image, error) Export(ctx context.Context, request ExportImageRequest) (domain.ImageExport, error) } ``` 约束: - `CanvasWidth`、`CanvasHeight` 必须为正,并受实现中明确的像素/内存上限约束。 - `WidthRatio` 大于 0;`Opacity` 在 `0..1`。 - `Plan` 是预览和导出的唯一几何算法。 - `Overwrite=false` 且目标存在时返回 `destination_exists`。 - `Overwrite=true` 仍不得覆盖任一输入素材文件。 - 导出成功前不写 `image_exports`;数据库记录失败时删除本次新建输出或报告可恢复的部分失败。 ## 十、客服服务 ```go type ReplyTemplateInput struct { Category string Language string Title string Content string } type ReplyTemplateService interface { List(ctx context.Context, filter ReplyTemplateFilter) (Page[domain.ReplyTemplate], error) Create(ctx context.Context, input ReplyTemplateInput) (domain.ReplyTemplate, error) Update(ctx context.Context, id int64, input ReplyTemplateInput) (domain.ReplyTemplate, error) Archive(ctx context.Context, id int64) error Copy(ctx context.Context, id int64) (CopyResult, error) } type CustomerFollowupInput struct { StoreID int64 BuyerLabel string OrderRef string IssueType string Status string NextFollowupAt *time.Time Note string } type CustomerFollowupService interface { List(ctx context.Context, filter FollowupFilter) (Page[domain.CustomerFollowup], error) Create(ctx context.Context, input CustomerFollowupInput) (domain.CustomerFollowup, error) Update(ctx context.Context, id int64, input CustomerFollowupInput) (domain.CustomerFollowup, error) Archive(ctx context.Context, id int64) error } ``` `Copy` 成功写入系统剪贴板后才增加 `usage_count`。剪贴板失败时不增加计数。 ## 十一、工作台 ```go type DashboardQuery struct { Now time.Time RecentStoreLimit int RecentExportLimit int } type DashboardView struct { RecentStores []StoreView DueTodos []domain.Todo DueFollowups []domain.CustomerFollowup PendingProducts []domain.Product RecentExports []domain.ImageExport } type DashboardService interface { Load(ctx context.Context, query DashboardQuery) (DashboardView, error) } ``` Dashboard 允许各分区分别失败并显示局部错误,但不得把失败分区伪装为空数据。 ## 十二、设置、备份与恢复 ```go type SettingsService interface { Get(ctx context.Context) (domain.Settings, error) Update(ctx context.Context, input SettingsInput) (domain.Settings, error) } type BackupService interface { Create(ctx context.Context, destination string) (BackupResult, error) Inspect(ctx context.Context, source string) (BackupManifest, error) Restore(ctx context.Context, source string, confirmation RestoreConfirmation) (RestoreResult, error) } ``` `RestoreConfirmation` 必须包含 UI 展示过的备份 fingerprint 和用户确认标志。恢复期间 application 进入 maintenance 状态,其他写操作返回 `maintenance_mode`。 ## 十三、应用事件 | 事件 | 触发时机 | 负载 | UI 结果 | | --- | --- | --- | --- | | `operation.started` | 异步 intent 接受 | request ID、operation、entity | 显示 busy,禁用重复提交 | | `operation.completed` | 操作成功 | request ID、result summary | 更新页面并反馈成功 | | `operation.failed` | 操作失败 | request ID、AppError | 保留输入,显示可行动错误 | | `browser.status.changed` | Chrome 启动、运行或退出 | BrowserRuntimeView | 更新店铺行状态 | | `data.restored` | 恢复并重新打开数据库成功 | backup ID、schema version | 清空页面缓存并重载 | | `preview.ready` | 最新图片预览完成 | request ID、preview image | 仅接收当前 request ID 的结果 | 旧请求的预览事件到达时,UI 根据 request ID 丢弃,避免快速拖动后显示过期画面。 ## 十四、错误码 | Code | 含义 | 是否可重试 | | --- | --- | --- | | `validation_failed` | 字段或组合不合法 | 修正输入后 | | `not_found` | 资源不存在或已归档 | 否 | | `conflict` | 唯一约束或状态冲突 | 修改输入后 | | `profile_in_use` | Chrome profile 已占用 | 关闭对应进程后 | | `chrome_not_found` | Chrome 路径无效 | 配置后 | | `chrome_start_failed` | 进程启动失败 | 视 cause | | `proxy_invalid` | 代理格式无效 | 修正配置后 | | `source_missing` | 素材或导入文件不存在 | 重新选择 | | `source_changed` | 导入 preview 后文件已变化 | 重新预览 | | `unsupported_format` | 文件或图片格式不支持 | 选择支持格式 | | `destination_exists` | 输出已存在且未允许覆盖 | 改名或确认覆盖 | | `file_access_denied` | 文件权限不足 | 修改目录权限后 | | `database_busy` | SQLite 超时仍被占用 | 是 | | `database_corrupt` | 完整性检查失败 | 使用有效备份 | | `backup_incompatible` | schema 版本不支持 | 升级应用或选其他备份 | | `maintenance_mode` | 恢复期间拒绝写入 | 恢复完成后 | | `cancelled` | 用户取消或 context 结束 | 可重新发起 | | `internal` | 未分类内部错误 | 视日志 | 新增错误码必须同步 UI 映射和测试,不得把底层错误字符串当稳定合约。 ## 十五、副作用边界 | 操作 | 允许的副作用 | 禁止的副作用 | | --- | --- | --- | | 保存店铺 | SQLite 写入;创建 managed profile 目录 | 登录平台、删除已有 profile | | 打开店铺 | 启动 Chrome;记录状态 | 注入凭证、开启 CDP、操作网页 | | 导入商品 | 读取用户文件;确认后事务写 SQLite | 修改源文件、静默跳过错误 | | 添加素材 | 读取图片;可选复制到素材库 | 修改原图 | | 导出图片 | 创建新输出;写导出记录 | 用 UI 截图导出、默认覆盖 | | 复制话术 | 写系统剪贴板;增加使用次数 | 自动发送到平台 | | 备份 | 创建一致性数据库副本 | 复制全部 Chrome profile | | 恢复 | 验证后替换数据库并保留回滚副本 | 未确认覆盖、删除外部素材 |