551 lines
18 KiB
Markdown
551 lines
18 KiB
Markdown
# 本地模块合约
|
||
|
||
> 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-root>\<store-id>`,因此可以先事务创建店铺再补 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 |
|
||
| 恢复 | 验证后替换数据库并保留回滚副本 | 未确认覆盖、删除外部素材 |
|