docs: establish ShopHelm harness coding baseline
This commit is contained in:
+550
@@ -0,0 +1,550 @@
|
||||
# 本地模块合约
|
||||
|
||||
> 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 |
|
||||
| 恢复 | 验证后替换数据库并保留回滚副本 | 未确认覆盖、删除外部素材 |
|
||||
Reference in New Issue
Block a user