|
| 1 | +# Style Pack Marketplace — 规划文档 |
| 2 | + |
| 3 | +**状态**:规划中(API 已预留 stub,未实装) |
| 4 | +**起草日期**:2026-05-14 |
| 5 | +**owner**:待定 |
| 6 | + |
| 7 | +## 1. 目标 |
| 8 | + |
| 9 | +把现在「ZIP 包本地导入 / 导出」的体验扩展成一个公开的风格包市场: |
| 10 | + |
| 11 | +- 用户可以把自己调好的风格包**上传**到云端,附带名称、描述、作者署名、标签、效果示例 |
| 12 | +- 其他用户可以**浏览 / 搜索 / 下载**别人的风格包,一键安装到本地 |
| 13 | +- 后期支持**版本升级提醒**、**收藏 / 评分**等基础社交属性 |
| 14 | + |
| 15 | +非目标(v1 不做): |
| 16 | +- 付费 / 抽成 |
| 17 | +- 风格包内嵌外部 prompt 注入 / 跨域 fetch(安全考虑,风格包始终是纯文本 prompt) |
| 18 | +- 多人协作编辑 / fork |
| 19 | + |
| 20 | +## 2. 架构概览 |
| 21 | + |
| 22 | +``` |
| 23 | +┌──────────────────┐ HTTPS ┌─────────────────────┐ |
| 24 | +│ OpenLess client │ ◄──────────────────► │ marketplace API │ |
| 25 | +│ (Tauri 2) │ JSON over TLS │ (TBD: Cloudflare │ |
| 26 | +│ │ │ Workers / D1 / │ |
| 27 | +│ Rust IPC → │ │ R2 for blobs) │ |
| 28 | +│ reqwest client │ │ │ |
| 29 | +└──────────────────┘ └─────────────────────┘ |
| 30 | + │ │ |
| 31 | + │ local cache (~/Library/Application │ |
| 32 | + │ Support/OpenLess/market_cache/) │ |
| 33 | + ▼ ▼ |
| 34 | + StylePackStore Postgres / D1 |
| 35 | + (existing local listings + R2 blobs |
| 36 | + persistence layer) |
| 37 | +``` |
| 38 | + |
| 39 | +**关键约束**: |
| 40 | +- 客户端只能上传 / 下载 ZIP **bundle**(不直接传 JSON),保持跟现有 ZIP import/export 同构 |
| 41 | +- 服务端 ZIP 验证:解压后必须能反序列化成 `StylePack`、`prompt.chars().count() <= 50_000`、没有可执行附件 |
| 42 | +- 风格包 ID 上传后由服务端分配(`{author_slug}-{name_slug}-{version}`),跟本地 ID 解耦 |
| 43 | +- 客户端始终拿 ZIP 走现有 `import_style_pack_from_zip` 路径入库 —— 不另开一条「从市场直接写 Pack」的代码路径,避免双入口 |
| 44 | + |
| 45 | +## 3. HTTP API 规约 |
| 46 | + |
| 47 | +Base URL(待定):`https://api.openless.app/v1/marketplace/` |
| 48 | + |
| 49 | +所有响应统一信封: |
| 50 | +```json |
| 51 | +{ |
| 52 | + "ok": true, |
| 53 | + "data": <T> | null, |
| 54 | + "error": null | { "code": "ERR_XXX", "message": "..." } |
| 55 | +} |
| 56 | +``` |
| 57 | + |
| 58 | +### 3.1 GET `/packs` — 列表 / 搜索 |
| 59 | + |
| 60 | +Query: |
| 61 | +| 参数 | 类型 | 默认 | 说明 | |
| 62 | +|---|---|---|---| |
| 63 | +| `q` | string | `""` | 关键词(名称 / 描述 / 标签) | |
| 64 | +| `tag` | string | `""` | 单标签筛选 | |
| 65 | +| `sort` | `recent` \| `popular` \| `name` | `recent` | 排序 | |
| 66 | +| `cursor` | string | `null` | 分页游标 | |
| 67 | +| `limit` | int (1-100) | `20` | 每页条数 | |
| 68 | + |
| 69 | +Response data: |
| 70 | +```typescript |
| 71 | +{ |
| 72 | + packs: MarketPackListing[]; |
| 73 | + next_cursor: string | null; |
| 74 | +} |
| 75 | +``` |
| 76 | + |
| 77 | +`MarketPackListing`: |
| 78 | +```typescript |
| 79 | +{ |
| 80 | + id: string; // server-assigned, e.g. "alice-formal-v2.1" |
| 81 | + name: string; |
| 82 | + description: string; |
| 83 | + author: string; |
| 84 | + version: string; // semver |
| 85 | + tags: string[]; |
| 86 | + base_mode: "raw" | "light" | "structured" | "professional"; |
| 87 | + recommended_model: string | null; |
| 88 | + compatible_app_version: string | null; |
| 89 | + downloads: number; |
| 90 | + rating_avg: number | null; |
| 91 | + rating_count: number; |
| 92 | + updated_at: string; // ISO8601 |
| 93 | + zip_size_bytes: number; |
| 94 | + zip_sha256: string; // 客户端下载后校验 |
| 95 | +} |
| 96 | +``` |
| 97 | + |
| 98 | +### 3.2 GET `/packs/{id}` — 详情 |
| 99 | + |
| 100 | +Response data:`MarketPackListing` + 额外字段: |
| 101 | +```typescript |
| 102 | +{ |
| 103 | + ...listing, |
| 104 | + examples: StylePackExample[]; // 解压 ZIP 前的预览 |
| 105 | + changelog: string | null; |
| 106 | + homepage_url: string | null; |
| 107 | +} |
| 108 | +``` |
| 109 | + |
| 110 | +### 3.3 GET `/packs/{id}/download` — 下载 ZIP |
| 111 | + |
| 112 | +Response:`application/zip` 二进制流,带 `X-Pack-SHA256` header 用于校验。 |
| 113 | + |
| 114 | +服务端通过 redirect 直接指向 R2 / S3 预签 URL,避免代理流量。 |
| 115 | + |
| 116 | +### 3.4 POST `/packs` — 上传(需鉴权) |
| 117 | + |
| 118 | +Headers:`Authorization: Bearer <api_key>` |
| 119 | +Body:`multipart/form-data` with field `pack=@xxx.zip` |
| 120 | + |
| 121 | +Response data:`MarketPackListing`(含新分配 id) |
| 122 | + |
| 123 | +错误码: |
| 124 | +- `ERR_INVALID_ZIP` — ZIP 解压失败 / 不是合法 StylePack JSON |
| 125 | +- `ERR_PROMPT_TOO_LARGE` — prompt 字数超 50k |
| 126 | +- `ERR_DUPLICATE_VERSION` — 同 author+name+version 已存在 |
| 127 | +- `ERR_RATE_LIMITED` — 触发限频 |
| 128 | + |
| 129 | +### 3.5 DELETE `/packs/{id}` — 撤回(需鉴权 + 必须是上传者) |
| 130 | + |
| 131 | +### 3.6 POST `/packs/{id}/rate` — 评分(需鉴权) |
| 132 | + |
| 133 | +Body:`{ score: 1..5, comment?: string }` |
| 134 | + |
| 135 | +## 4. IPC 契约(Rust ↔ TS) |
| 136 | + |
| 137 | +在 `src-tauri/src/commands.rs` 新增以下 stub(暂返回 `Err("not implemented yet")`,等服务端落地后实装): |
| 138 | + |
| 139 | +```rust |
| 140 | +// 列表 / 搜索 |
| 141 | +#[tauri::command] |
| 142 | +pub async fn market_list_packs( |
| 143 | + query: Option<String>, |
| 144 | + tag: Option<String>, |
| 145 | + sort: Option<String>, |
| 146 | + cursor: Option<String>, |
| 147 | + limit: Option<u32>, |
| 148 | +) -> Result<MarketListResponse, String>; |
| 149 | + |
| 150 | +// 详情 |
| 151 | +#[tauri::command] |
| 152 | +pub async fn market_get_pack(id: String) -> Result<MarketPackDetail, String>; |
| 153 | + |
| 154 | +// 下载 + 自动调用现有的 import_style_pack_from_zip 入库 |
| 155 | +#[tauri::command] |
| 156 | +pub async fn market_download_pack( |
| 157 | + coord: CoordinatorState<'_>, |
| 158 | + app: AppHandle, |
| 159 | + id: String, |
| 160 | +) -> Result<StylePack, String>; |
| 161 | + |
| 162 | +// 上传(dirty 字段 = 已编辑、未保存) |
| 163 | +#[tauri::command] |
| 164 | +pub async fn market_upload_pack( |
| 165 | + coord: CoordinatorState<'_>, |
| 166 | + pack_id: String, |
| 167 | + api_key: String, |
| 168 | +) -> Result<MarketPackListing, String>; |
| 169 | + |
| 170 | +// 撤回 |
| 171 | +#[tauri::command] |
| 172 | +pub async fn market_delete_pack(id: String, api_key: String) -> Result<(), String>; |
| 173 | + |
| 174 | +// 评分 |
| 175 | +#[tauri::command] |
| 176 | +pub async fn market_rate_pack( |
| 177 | + id: String, |
| 178 | + api_key: String, |
| 179 | + score: u8, |
| 180 | + comment: Option<String>, |
| 181 | +) -> Result<(), String>; |
| 182 | +``` |
| 183 | + |
| 184 | +DTO(在 `types.rs` 新增): |
| 185 | +```rust |
| 186 | +#[derive(Debug, Serialize, Deserialize, Clone)] |
| 187 | +pub struct MarketPackListing { |
| 188 | + pub id: String, |
| 189 | + pub name: String, |
| 190 | + pub description: String, |
| 191 | + pub author: String, |
| 192 | + pub version: String, |
| 193 | + pub tags: Vec<String>, |
| 194 | + pub base_mode: PolishMode, |
| 195 | + pub recommended_model: Option<String>, |
| 196 | + pub compatible_app_version: Option<String>, |
| 197 | + pub downloads: u64, |
| 198 | + pub rating_avg: Option<f32>, |
| 199 | + pub rating_count: u32, |
| 200 | + pub updated_at: String, |
| 201 | + pub zip_size_bytes: u64, |
| 202 | + pub zip_sha256: String, |
| 203 | +} |
| 204 | + |
| 205 | +#[derive(Debug, Serialize, Deserialize, Clone)] |
| 206 | +pub struct MarketPackDetail { |
| 207 | + #[serde(flatten)] |
| 208 | + pub listing: MarketPackListing, |
| 209 | + pub examples: Vec<StylePackExample>, |
| 210 | + pub changelog: Option<String>, |
| 211 | + pub homepage_url: Option<String>, |
| 212 | +} |
| 213 | + |
| 214 | +#[derive(Debug, Serialize, Deserialize, Clone)] |
| 215 | +pub struct MarketListResponse { |
| 216 | + pub packs: Vec<MarketPackListing>, |
| 217 | + pub next_cursor: Option<String>, |
| 218 | +} |
| 219 | +``` |
| 220 | + |
| 221 | +TS wrappers(`src/lib/ipc.ts`): |
| 222 | +```typescript |
| 223 | +export interface MarketPackListing { /* same shape */ } |
| 224 | +export interface MarketPackDetail extends MarketPackListing { /* + examples, changelog, homepage_url */ } |
| 225 | +export interface MarketListResponse { packs: MarketPackListing[]; next_cursor: string | null; } |
| 226 | + |
| 227 | +export function marketListPacks(opts: { |
| 228 | + query?: string; tag?: string; sort?: 'recent' | 'popular' | 'name'; |
| 229 | + cursor?: string; limit?: number; |
| 230 | +}): Promise<MarketListResponse>; |
| 231 | +export function marketGetPack(id: string): Promise<MarketPackDetail>; |
| 232 | +export function marketDownloadPack(id: string): Promise<StylePack>; |
| 233 | +export function marketUploadPack(packId: string, apiKey: string): Promise<MarketPackListing>; |
| 234 | +export function marketDeletePack(id: string, apiKey: string): Promise<void>; |
| 235 | +export function marketRatePack(id: string, apiKey: string, score: number, comment?: string): Promise<void>; |
| 236 | +``` |
| 237 | + |
| 238 | +## 5. 鉴权模型 |
| 239 | + |
| 240 | +**v1 简化方案**: |
| 241 | +- 用户在设置页输入个人 API key(服务端发放) |
| 242 | +- API key 存到 OS Keychain,账户名 `com.openless.app.market_api_key` |
| 243 | +- 客户端在 Header 加 `Authorization: Bearer <key>` |
| 244 | +- 服务端校验 + 限频(每小时 60 次写、600 次读) |
| 245 | + |
| 246 | +**v2 升级路径**(暂不做): |
| 247 | +- OAuth via GitHub / Google |
| 248 | +- 上传时自动签名 ZIP,下载端校验签名 |
| 249 | + |
| 250 | +## 6. 缓存与版本检查 |
| 251 | + |
| 252 | +本地缓存目录:`<app_data>/market_cache/` |
| 253 | +- `listings.json` — 上次拉的 listings(带 ETag) |
| 254 | +- `packs/{id}.zip` — 已下载的 ZIP(按需保留,30 天自动清理) |
| 255 | + |
| 256 | +版本升级提示: |
| 257 | +- 启动时(带 dev-cap 24h 节流)调用 `/packs?ids=<已安装的 market_id...>` 拉对比 |
| 258 | +- 本地包记录 `installed_market_id` 和 `installed_market_version` 字段,新建 `StylePack` 时填,本地从 ZIP 安装也填 |
| 259 | +- 发现新版本 → 在 Style 页该包卡片角标显示 `New version: 2.3.0 →` |
| 260 | + |
| 261 | +## 7. 客户端 UI 入口(v1 不做,先留位) |
| 262 | + |
| 263 | +- Style 页头部加一个 tab:`本地 / 市场` |
| 264 | +- 市场页:搜索栏 + tag 过滤 + 卡片列表 + 详情抽屉 |
| 265 | +- 上传:编辑某个本地包时,"导出 ZIP" 按钮旁边出现 "上传到市场"(需要先在设置里填 API key) |
| 266 | + |
| 267 | +## 8. 安全 / 滥用对策 |
| 268 | + |
| 269 | +- ZIP 解压走 streaming,限制最大解压后大小 5 MB |
| 270 | +- prompt 字段过滤明显的 prompt injection / 越狱(关键词预扫描 + 异步内容审核) |
| 271 | +- 每用户每天上传上限 10 包,单包大小 ≤ 2 MB |
| 272 | +- 上传后挂 24h 公开延迟(防恶意刷榜) |
| 273 | + |
| 274 | +## 9. 实装 TODO(按优先级) |
| 275 | + |
| 276 | +- [ ] 服务端选型(CF Workers + D1 + R2 vs Supabase vs 自托管 FastAPI) |
| 277 | +- [ ] 服务端实装 + 部署环境(dev / staging / prod) |
| 278 | +- [ ] 客户端 `types.rs` 加 DTO |
| 279 | +- [ ] `commands.rs` 加 6 个 stub(**已完成**,返回 `not implemented yet`) |
| 280 | +- [ ] `lib/ipc.ts` 加 wrapper(**已完成**) |
| 281 | +- [ ] 实装 `market_download_pack`(先做单条路径打通:URL → 下载 → 走现有 import_style_pack_from_zip) |
| 282 | +- [ ] 加凭据存储(Keychain 复用现有 `CredentialsVault`) |
| 283 | +- [ ] UI:本地 / 市场 tab |
| 284 | +- [ ] UI:搜索 + 卡片 |
| 285 | +- [ ] UI:详情面板 |
| 286 | +- [ ] UI:上传流程 |
| 287 | +- [ ] 升级提醒 badge |
| 288 | +- [ ] 缓存清理 + ETag |
| 289 | + |
| 290 | +## 10. 决策 / 风险记录 |
| 291 | + |
| 292 | +| 项 | 决策 | Why | |
| 293 | +|---|---|---| |
| 294 | +| ZIP 而非 JSON 上传 | 用 ZIP | 跟现有 import/export 同构;prompt 长文 + examples 用 ZIP 包压缩 | |
| 295 | +| 服务端分配 ID | 是 | 防本地 ID 碰撞、用户重命名包不影响订阅 | |
| 296 | +| 上传立刻可见 vs 审核 | 24h 公开延迟 | 防刷榜 + 给审核留空间 | |
| 297 | +| API key vs OAuth | 先 API key | 简化 v1;登录态可 v2 升级 | |
| 298 | +| 客户端缓存策略 | listings ETag + 已下载 ZIP 30 天 | 平衡流量和体验 | |
| 299 | +| 国际化 / 跨境 | API 全英文 + 客户端 i18n | 服务端不存翻译,名称/描述支持任意 UTF-8 | |
0 commit comments