2 Commits

Author SHA1 Message Date
joey5628 d7247ca2da docs: 多规格商品试算编辑 2026-08-31 22:38:59 +08:00
joey5628 8b54f7ed1e fix: 修改一下生图的 bug 2026-08-31 21:56:48 +08:00
15 changed files with 810 additions and 47 deletions
+2
View File
@@ -37,6 +37,8 @@ ozon-seller-kit/
./start.command # server: http://127.0.0.1:8800 + studio: http://localhost:8900 ./start.command # server: http://127.0.0.1:8800 + studio: http://localhost:8900
``` ```
`start.command` 以单实例方式运行;再次启动时会先停止此前由它管理的同项目进程,再启动新实例。
**2. 构建/加载采集插件**: **2. 构建/加载采集插件**:
```bash ```bash
+1 -1
View File
@@ -2,7 +2,7 @@
> 更新:2026-08-31 > 更新:2026-08-31
> 读者:下一个接手工作的 AI / 开发者(工具无关)。先读本文再动手,避免重复探索。 > 读者:下一个接手工作的 AI / 开发者(工具无关)。先读本文再动手,避免重复探索。
> 配套文档:[`README`](../../README.md)(项目总览)、[`collect.md`](./collect.md)(采集)、[`trial-page.md`](./trial-page.md)(试算页)、[`image-suite.md`](./image-suite.md)(生图)、[`background-jobs.md`](./background-jobs.md)(**后台任务中心方案、实施状态与路线图**)、[`image-asset-lifecycle.md`](./image-asset-lifecycle.md)(图片与水印存储规则)、[`api.md`](./api.md)(契约)、[`backend-structure.md`](./backend-structure.md)(后端结构) > 配套文档:[`README`](../../README.md)(项目总览)、[`collect.md`](./collect.md)(采集)、[`trial-page.md`](./trial-page.md)(试算页)、[`multi-sku-pricing.md`](./multi-sku-pricing.md)(多 SKU 试算与导出方案)、[`multi-sku-measurements.md`](./multi-sku-measurements.md)(**实物规格与包装物流规格的双口径设计**)、[`image-suite.md`](./image-suite.md)(生图)、[`background-jobs.md`](./background-jobs.md)(**后台任务中心方案、实施状态与路线图**)、[`image-asset-lifecycle.md`](./image-asset-lifecycle.md)(图片与水印存储规则)、[`api.md`](./api.md)(契约)、[`backend-structure.md`](./backend-structure.md)(后端结构)
--- ---
+2
View File
@@ -110,6 +110,8 @@ V2(原计划) V2.1(现在)
| [`HANDOFF.md`](./HANDOFF.md) | **工作交接说明**:当前进度、启动/构建命令、已知坑、下一步(新会话先读) | 接手工作时先读 | | [`HANDOFF.md`](./HANDOFF.md) | **工作交接说明**:当前进度、启动/构建命令、已知坑、下一步(新会话先读) | 接手工作时先读 |
| [`collect.md`](./collect.md) | 采集方案:扩展并入 `extensions/collector/` + 上报开关 + 自动打开试算页(已实施) | 做插件改动时读 | | [`collect.md`](./collect.md) | 采集方案:扩展并入 `extensions/collector/` + 上报开关 + 自动打开试算页(已实施) | 做插件改动时读 |
| [`trial-page.md`](./trial-page.md) | 商品试算页:页面结构、计价公式与校验、文案、采购地址、入库数据结构、CSV/组合码导出 | 做试算页前后端时读 | | [`trial-page.md`](./trial-page.md) | 商品试算页:页面结构、计价公式与校验、文案、采购地址、入库数据结构、CSV/组合码导出 | 做试算页前后端时读 |
| [`multi-sku-pricing.md`](./multi-sku-pricing.md) | 多 SKU:采集契约、父商品/SKU 数据模型、试算页明细、CSV 导出与分阶段迁移方案 | 扩展规格级计价、导出或采集时读 |
| [`multi-sku-measurements.md`](./multi-sku-measurements.md) | **多 SKU 双规格**:实物规格与包装物流规格的字段、采集、试算、尺寸图、导出和迁移设计 | 改 SKU、尺寸、重量、试算或尺寸图前必读 |
| [`image-suite.md`](./image-suite.md) | 图片生成:套图规划→一键生成、模型路由、单张 AI 生图、水印、生成图回写与导出 | 做图片功能时读 | | [`image-suite.md`](./image-suite.md) | 图片生成:套图规划→一键生成、模型路由、单张 AI 生图、水印、生成图回写与导出 | 做图片功能时读 |
| [`background-jobs.md`](./background-jobs.md) | **后台任务中心**:统一任务模型、Worker、状态、恢复、已完成阶段和后续路线图 | 改生图、水印或任何耗时任务前先读 | | [`background-jobs.md`](./background-jobs.md) | **后台任务中心**:统一任务模型、Worker、状态、恢复、已完成阶段和后续路线图 | 改生图、水印或任何耗时任务前先读 |
| [`image-asset-lifecycle.md`](./image-asset-lifecycle.md) | 图片原图、水印当前版、对象路径与删除回收流程 | 改图片、水印或存储前先读 | | [`image-asset-lifecycle.md`](./image-asset-lifecycle.md) | 图片原图、水印当前版、对象路径与删除回收流程 | 改图片、水印或存储前先读 |
+172
View File
@@ -0,0 +1,172 @@
# 多 SKU 的实物规格与包装物流规格设计
> 状态:调研与设计结论,尚未实施。
> 关联:[多 SKU 商品处理与试算页扩展方案](./multi-sku-pricing.md)。本文补充其“尺寸与重量”的双口径模型。
> 核心原则:每个 SKU 可以有独立的实物规格和包装物流规格;两者绝不混用。
---
## 1. 问题与结论
尺寸与重量有两种不同的业务口径:
| 口径 | 数据 | 用途 |
|---|---|---|
| **实物规格** | 商品本体长、宽、高、净重 | 商品规格展示、俄文文案、AI 尺寸标注图 |
| **包装物流规格** | 发货包装长、宽、高、含包装毛重 | 物流尺寸限制、物流费、价格试算、CSV 上品 |
同一个商品还可能有多个 SKU。容量、尺寸、套装数等 SKU 往往在两组数据上都不同;仅颜色不同的 SKU 则通常可继承父商品的两组默认值。
因此目标模型不是“商品加一张 SKU 价格表”,而是:
```text
商品(SPU / 父记录)
├─ 共用:标题、描述、采购链接、图片、文案、计价默认值
├─ 默认实物规格:长宽高、净重
├─ 默认包装物流规格:长宽高、包装毛重
├─ SKU A:规格、两组规格覆盖、采购价、试算结果、货号
├─ SKU B:规格、两组规格覆盖、采购价、试算结果、货号
└─ SKU C:规格、两组规格覆盖、采购价、试算结果、货号
```
SKU 的某个值为空代表继承父商品默认值,不代表零。采集不到或来源未说明口径的“尺寸/重量”必须标记为待确认,不能猜测写入任意一组。
## 2. 当前实现风险
当前 `/trial/:id` 将 `price`、`old_price`、`weight`、`depth/width/height`、`offer_id` 放在单一 `products` 记录中:
- `TrialPricingPanel` 使用重量尺寸做物流费和尺寸校验,且会回填商品价格;一个 SKU 会覆盖另一个 SKU。
- AI 套图规划从采集参数表寻找“尺寸”,无法确认取到的是实物尺寸还是包装尺寸。
- `/api/materials` 的 `_apply_weight_dims` 以“包装重量/重量”“包装长度/长度”等泛关键词解析,通用“产品尺寸”“重量”可能被当成物流规格。
- 图片资产仅保存 `variant_name`,显示名能重复或改名,无法稳定绑定 SKU。
- CSV 目前按商品一行导出,无法表达 SKU 的独立包装数据和报价。
因此不能只给当前价格卡增加输入框,必须先建立 SKU 与双规格的持久化模型。
## 3. 数据模型
### 3.1 命名、单位和继承
数据库统一使用 `mm`、`g`;前端用 `cm`、`g` 编辑。新功能不得再用裸 `weight`、`depth`、`width`、`height` 表达业务规格。
父商品和 `product_variants` 均使用相同、口径明确的字段:
| 字段组 | 字段 | 使用者 |
|---|---|---|
| 实物规格 | `physical_length_mm`、`physical_width_mm`、`physical_height_mm`、`net_weight_g` | 尺寸图、规格展示、文案 |
| 包装物流规格 | `package_length_mm`、`package_width_mm`、`package_height_mm`、`shipping_weight_g` | 试算、物流校验、导出 |
| SKU 身份 | `source_variant_key`、`variant_name`、`specs` | 平台原始 SKU ID、展示规格、颜色/尺寸/容量 |
| 销售与计价 | `source_price`、`purchase_price`、`offer_id`、`barcode`、`logistics_level`、`pricing` | SKU 独立的输入、结果和上品标识 |
| 审计 | `measurements_meta`、`enabled`、时间戳 | 来源、原始文本、确认状态、是否参与导出 |
`measurements_meta` 至少记录两组数据各自的 `status`(`confirmed` / `ambiguous` / `missing`)、来源、原始文本和更新时间。采集提供的数据不是人工确认的事实时,不应伪装为已确认。
SKU 的有效值按“SKU 覆盖 → 父商品默认值”逐字段合并。实物规格与包装物流规格独立继承:实物尺寸相同不代表包装尺寸也相同。
### 3.2 关联和旧字段
- 新增持久化 `product_variants` 表,而不是长期把 SKU 数组放进 `product.pricing` JSON。
- `product_assets` 增加可空 `product_variant_id`;现有 `variant_name` 仅保留展示兼容。
- 父商品 `pricing` 只保存共享默认参数,如汇率、利润率、贴单费和默认物流等级;SKU `pricing` 保存输入快照、有效包装规格、计算结果和计算时间。
- 当前 `products.depth/width/height/weight` 在迁移期间仅是兼容读写别名;双规格字段是新功能唯一事实来源。
## 4. 采集和后端接口
`POST /api/materials` 保持旧契约兼容,并新增可选 `variants`:
```jsonc
{
"source": { "platform": "1688", "itemId": "...", "url": "..." },
"variants": [
{
"source_variant_key": "平台原始 SKU ID",
"variant_name": "白色 / 33 cm",
"specs": { "颜色": "白色", "尺寸": "33 cm" },
"source_price": 28.5,
"physical_measurements": {
"length_mm": 150, "width_mm": 150, "height_mm": 330, "net_weight_g": null,
"status": "confirmed"
},
"package_measurements": {
"length_mm": 180, "width_mm": 180, "height_mm": 380, "shipping_weight_g": 850,
"status": "confirmed"
},
"image_url": "..."
}
]
}
```
规则:
- `source_variant_key` 优先使用来源平台稳定 ID;展示名不能作为幂等键。
- 未采到的字段传 `null`;无法判断口径时保留原始参数、设 `status: "ambiguous"`。
- 只有明确“包装/外箱/发货”口径的参数可写入包装物流规格;明确“商品/产品/本体”的参数可写入实物规格;泛称参数不自动落入任何组。
- 服务端按来源 SKU ID 幂等更新,并把 SKU 图片关联至 `product_variant_id`。
- SKU 使用独立接口,例如 `GET/POST /api/products/{id}/variants`、`PATCH/DELETE /api/products/{id}/variants/{variant_id}`;不要用整份 `ProductUpdate` 承担 SKU 并发编辑。
服务端新增统一的 `PricingService`:前端可即时预览,但保存与导出时必须按“有效包装物流规格”重新计算,避免浏览器传入结果被篡改或前后端公式漂移。
## 5. 前端设计
页面拆成两个清晰区域:
1. **商品规格与尺寸图**:维护实物长宽高和净重;多 SKU 时可维护商品默认值,或为一个 SKU 覆盖。尺寸标注图唯一读取这里的已确认实物尺寸。
2. **价格试算**:维护包装长宽高、包装毛重、进货价、利润率、物流等级、贴单费、汇率;物流校验与价格结果只在此区域展示。
多 SKU 时,价格区使用明细表,列为“规格 / 实物规格摘要 / 包装规格摘要 / 进货价 / 销售价 / 状态”。点击一行打开抽屉或展开编辑器,而不是把八项字段强塞进表格:
```text
实物规格(用于尺寸图) 包装物流规格(用于试算)
长 / 宽 / 高 / 净重 长 / 宽 / 高 / 包装毛重
状态:SKU 覆盖 / 继承 / 待确认 状态:SKU 覆盖 / 继承 / 待确认
```
交互约束:
- 每个字段可以“恢复继承”;空值不是 `0`。
- 修改父商品默认值只影响未覆盖字段;可以将某一组默认规格批量应用给选中的 SKU。
- 包装物流规格缺失/无效时,该 SKU 不能试算和导出,但其他 SKU 不受影响。
- 实物规格缺失时仍可试算/导出,但不能生成有数值的尺寸标注图。
- 父商品列表不应任选一个 SKU 回写 `product.price`;应展示最低价、价格区间,或显式选择代表 SKU。
## 6. AI 尺寸图、导出与迁移
### AI 尺寸图
尺寸标注图是实物信息,不是物流信息。规划器、提示词和任务上下文必须接收显式 `physical_*` 字段,不能从混合原始参数表兜底。
- 多 SKU 实物尺寸不同:生成前必须选择 SKU;任务保存 `product_variant_id` 与实物规格快照。
- 多 SKU 实物尺寸相同:可以使用父商品默认规格生成一张通用图。
- 实物规格为 `missing` 或 `ambiguous`:规划器不安排尺寸图,也不能提交带数值的尺寸图任务。
- 规格修改后,历史图依其快照标记为“基于旧规格生成”。
### CSV 和上品
CSV 从“每商品一行”改为“每启用 SKU 一行”。进货价、包装毛重、包装尺寸和物流等级来自合并后的 SKU 有效值;实物尺寸绝不作为物流尺寸导出。导出前逐 SKU 校验货号、价格和包装物流规格,并列出失败原因。
每 SKU 一个 Ozon offer、只上代表 SKU、或未来平台变体合并,仍是待确认的商业策略;未确认前系统只生成可审阅的 SKU 级 CSV。
### 历史数据迁移
历史商品无 SKU 时视为一个隐式 SKU。把旧 `pricing`、`weight`、`depth/width/height`、`offer_id` 迁为默认 SKU 的**包装物流规格**,因为它们原本用于价格试算;实物规格一律为 `missing`,不得依据旧字段自动生成尺寸图。
## 7. 实施顺序与验收
| 阶段 | 内容 | 验收标准 |
|---|---|---|
| 0. 业务确认 | 明确 Ozon offer 策略、货号规则、尺寸图是否逐 SKU 生成 | 形成 CSV/上品规则 |
| 1. 数据和采集 | SKU 表、父/SKU 双规格字段、迁移、`variants` 契约;收窄泛规格解析 | 未知口径不被伪造成实物或包装数据 |
| 2. 服务端计算 | 继承合并、统一计算/校验、SKU 级保存和查询接口 | 两 SKU、两组规格不相互覆盖 |
| 3. 试算页 | 实物规格区、包装物流试算区、SKU 表和编辑抽屉 | 刷新后继承、状态、价格和告警一致 |
| 4. 导出和图片 | SKU 行 CSV;尺寸图上下文绑定 SKU 与实物快照 | 包装尺寸绝不进入尺寸图 |
至少覆盖:单 SKU 回归、多 SKU 不同价格、仅颜色不同继承两组规格、实物相同包装不同、包装相同实物不同、泛称规格待确认、一个 SKU 导出失败但其他可导出、尺寸图只使用实物规格、SKU 改名但稳定 ID 不变、历史商品迁移。
## 8. 待确认决策
1. Ozon 中多 SKU 是否一律拆成独立 offer,还是要保留变体合并能力?
2. 货号由父商品自动派生,还是必须人工逐 SKU 维护?
3. 哪些采集来源的实物/包装字段可信,哪些只能作为待确认原始信息?
4. 尺寸图是否必须逐 SKU 生成;AI 主图和场景图由谁指定代表 SKU?
5. 父商品在采集箱中展示最低价、价格区间,还是代表 SKU 的价格?
+172
View File
@@ -0,0 +1,172 @@
# 多 SKU 商品处理与试算页扩展方案
> 状态:设计方案,尚未实施。
> 范围:采集 → 采集箱商品 → `/trial/:id` 价格试算 → CSV 导出;Ozon API 直传仍处于冻结状态。
> 目标:同一商品的不同规格可以独立试算,避免以一个 SKU 的价格、重量和尺寸覆盖全部规格。
> 尺寸/重量的实物与包装物流双口径设计见 [`multi-sku-measurements.md`](./multi-sku-measurements.md);该文补充并约束本方案。
---
## 1. 问题与目标
采集到的商品可能是多 SKU 商品:不同颜色、容量、套装、尺寸或型号往往有不同的进货价、包装重量和长宽高。当前试算页将这些字段保存在 `products` 的单一商品记录中,因此只能计算一个结果;若直接在现有页面逐个修改,会覆盖前一个 SKU 的数据,导出也无法还原各规格的价格。
本方案的目标是把商品拆分为「共享商品信息」和「可独立销售的 SKU」,并保持单 SKU 商品的现有操作不变:
```text
商品(SPU / 父记录)
├─ 共用:标题、俄文文案、采购地址、描述、图片、统一计价默认值
├─ SKU A:规格、采购价、重量、尺寸、独立试算结果、货号
├─ SKU B:规格、采购价、重量、尺寸、独立试算结果、货号
└─ SKU C:规格、采购价、重量、尺寸、独立试算结果、货号
```
不应从图片名或规格文案中猜测重量、尺寸和价格。采集不到的值必须保留为空、继承共用默认值,或要求人工补齐。
## 2. 当前现状与限制
目前的 `/trial/:id` 以一条 `products` 记录作为一个可售单元:
- `price`、`old_price`、`weight`、`depth/width/height`、`offer_id` 各只有一个值;
- `product.pricing` 保存一份试算输入和结果,修改后会回填上述商品字段;
- CSV 导出按商品生成一行,无法表达同一商品的多个独立报价;
- 采集侧已能识别 SKU 名称和 SKU 图片;1688 采集状态中也可能含 SKU 价格或规格信息,但当前上报 `/api/materials` 时尚未稳定传递 SKU 级的价格、重量和尺寸。
因此,不能只在现有「02 价格试算」中添加一张前端表格:那样数据仍会落回一份商品字段,刷新、导出和后续上架都会丢失区分。
## 3. 业务边界
### 3.1 父商品与 SKU 的职责
| 层级 | 保存内容 | 原则 |
|---|---|---|
| 商品(父记录) | 标题、描述、采购链接、参数、共用图片、AI 套图、文案、计价默认值 | 一处维护,供所有 SKU 继承 |
| SKU(子记录) | 规格名/规格值、来源 SKU 标识、SKU 图片、进货价、重量、尺寸、物流等级覆盖、货号、独立计价结果、启用状态 | 每个可单独售卖的规格一条 |
仅颜色不同且包装、进货价相同的 SKU 可以完全继承父商品参数;容量、套装数或尺寸不同的 SKU 通常需要各自维护价格和包装数据。
### 3.2 不在本阶段解决的问题
- 不恢复 Ozon Seller API 自动发布;V2.1 当前仍以 CSV / 组合码辅助人工上品为主。
- 不强行将平台的变体关系映射为 Ozon 的变体关系。是否将每个 SKU 建成独立 Ozon offer,需要在导出/上架阶段单独确认。
- 不把 AI 套图按 SKU 数量机械复制。图片规划应以商品卖点和规格差异为依据;多 SKU 图片选择规则另行细化。
## 4. 推荐数据模型
推荐新增持久化的 `product_variants`(或语义等价的 `product_skus`)表,而不是长期将全部 SKU 塞入 `product.pricing` JSON。独立表可以支持单行导出、筛选、校验、后续独立 offer 和稳定的外部 SKU 映射。
建议字段如下:
| 字段 | 用途 |
|---|---|
| `id`、`product_id` | SKU 主键及所属父商品 |
| `source_variant_key` | 来源平台的稳定 SKU 标识;不能只用展示名称匹配 |
| `variant_name`、`specs` | 规格展示名和结构化规格值,如颜色/尺寸/容量 |
| `source_price` / `purchase_price` | 采集到的价格与人工确认后的进货价 |
| `weight_g`、`length_cm`、`width_cm`、`height_cm` | SKU 包装数据;为空时继承父商品默认值 |
| `logistics_level`、`td_price`、`profit_rate` 等覆盖字段 | 仅保存与父商品默认值不同的项 |
| `pricing` | 该 SKU 的计算快照、告警和 `calculated_at` |
| `offer_id`、`barcode` | 导出/人工上品所需的 SKU 级标识 |
| `image_asset_id`、`enabled`、时间戳 | SKU 主图、是否参与试算/导出和审计 |
父商品的 `pricing` 应转为「默认计价参数」,例如汇率、默认利润率、贴单费和物流等级。SKU 计算时按「SKU 覆盖值 → 父商品默认值」合并,再复用现有 `calculatePricing` 和尺寸校验逻辑。
### 为什么不建议仅扩展 JSON
短期可在 `product.pricing.variants` 中暂存数组,但这会让查询、校验、并发更新、CSV 行导出和后续 offer 对应关系都更复杂。若只是验证交互可暂用 JSON;只要计划实际导出多个 SKU,就应迁移到独立表。
## 5. 采集与接口契约
采集上报需要在保留现有单 SKU 字段的同时,可选地提交 `variants`:
```jsonc
{
"source_url": "...",
"title": "...",
"variants": [
{
"source_variant_key": "平台原始 SKU ID",
"variant_name": "白色 / 33 cm",
"specs": { "颜色": "白色", "尺寸": "33 cm" },
"source_price": 28.5,
"weight_g": null,
"dimensions_cm": { "l": null, "w": null, "h": null },
"image_url": "..."
}
]
}
```
约束:
- `source_variant_key` 优先使用平台原始 ID;显示名称可能重复,不能作为唯一键。
- 不同平台可分步补齐字段。未采到的值传 `null`,不伪造默认值。
- 服务端根据来源 SKU 标识做幂等更新;没有 `variants` 的历史采集商品继续按单 SKU 处理。
- 采集图与 SKU 的关系应单独保存,避免仅靠图名在后续页面猜测归属。
## 6. 试算页交互
现有「02 价格试算」建议扩展为两层,单 SKU 商品默认保持当前单卡片体验:
1. **通用参数**:汇率、默认净利率、贴单费、默认物流等级、划线价倍数等,修改后可批量应用到选中的 SKU。
2. **SKU 明细**:每行显示规格、SKU 图片、进货价、重量、长宽高、物流等级、销售价 ¥/₽、划线价、利润和校验告警;可单行编辑、恢复继承、批量填充或只计算已选 SKU。
建议交互规则:
- 值为空表示「继承商品默认值」,而不是零;界面须明确显示继承来源。
- 修改父级默认值只影响未覆盖该字段的 SKU。
- 某 SKU 尺寸/价格无效时,只阻止该 SKU 的结果和导出,不应影响其他 SKU。
- SKU 可被停用;停用项保留历史但不参与默认计算和导出。
- 父商品顶部不应再把任意一个 SKU 的报价写回 `product.price`。过渡期可展示「¥最低价起」或显式指定一个代表 SKU;具体语义必须固定,避免误导。
## 7. 计算、导出与人工上品
每个启用 SKU 独立调用既有计价公式与尺寸校验,使用同一汇率快照。计算结果应与输入一起持久化,便于追溯导出时的报价来源。
CSV 导出应从「每商品一行」改为「每启用 SKU 一行」:
| CSV 字段 | 来源 |
|---|---|
| 商品共用标题、描述、采购地址 | 父商品 |
| 规格、货号、条码 | SKU |
| 进货价、重量、尺寸、物流等级 | SKU 覆盖值或继承后的有效值 |
| 销售价、划线价、利润、告警状态 | SKU 最新试算结果 |
导出前需校验每个启用 SKU 都有货号、有效价格和有效包装数据;失败项应列出原因,不能静默使用父商品或其他 SKU 的结果。
对于 Ozon 人工上品,必须先确认商业策略:
- **每个 SKU 一个 Ozon offer**:CSV 每个 SKU 一行,最易与独立价格和物流数据对应;
- **只上一个代表 SKU**:在试算页显式选择代表 SKU,其余 SKU 仅作采购参考;
- **平台变体合并**:需要重新启用并完成 Ozon 变体属性映射,不属于 V2.1 当前导出主链路。
在策略确认前,系统只应生成可审阅的 SKU 级试算与 CSV,不自动假定平台变体关系。
## 8. 兼容与迁移
- 既有商品无 `product_variants` 时视为一个隐式 SKU,继续使用当前页面和导出逻辑。
- 新采集的多 SKU 商品创建父商品及 SKU 子记录;可选地将第一个/最低价 SKU 作为界面初始代表,但不覆盖其余数据。
- 迁移时把历史 `product.pricing`、重量、尺寸、`offer_id` 复制为一条默认 SKU;保留父商品字段直到所有读取路径完成切换。
- 试算接口和前端应同时兼容单 SKU、隐式 SKU 和已迁移多 SKU,避免一次性迁移造成历史商品不可用。
## 9. 分阶段实施与验收
| 阶段 | 内容 | 验收标准 |
|---|---|---|
| 0. 业务确认 | 确认 Ozon 上品策略、货号规则、何时需要 SKU 独立包装数据 | 形成明确的 offer/CSV 规则 |
| 1. 数据与采集 | 增加 SKU 表、迁移与 `/api/materials` 的 `variants` 契约;1688 优先接入 | 多 SKU 采集后能稳定落库,缺失字段不被伪造 |
| 2. 试算页 | 增加通用参数和 SKU 明细表,逐行计算/校验/保存 | 两个 SKU 互不覆盖;刷新后数据和告警一致 |
| 3. 导出 | CSV 按启用 SKU 出行,提供失败明细 | 多 SKU 商品导出行数、货号、价格和尺寸与明细一致 |
| 4. 后续发布 | 如恢复 API 发布,再设计 Ozon 变体/offer 映射 | 不影响 V2.1 的人工 CSV 主流程 |
至少覆盖以下测试:单 SKU 回归、多 SKU 不同价格、仅颜色不同且继承包装、缺重量/尺寸、一个 SKU 校验失败但其他可导出、来源 SKU 改名但稳定 ID 未变、历史商品迁移。
## 10. 待确认决策
1. Ozon 后台中,多 SKU 是否一律拆成独立 offer,还是需要保留变体合并能力?
2. 货号由父商品后缀自动派生,还是必须人工逐 SKU 录入?
3. 哪些采集来源可视为 SKU 级重量/尺寸可信,哪些仅作为商品级参考?
4. 多 SKU 的 AI 套图是否要指定代表 SKU,或按 SKU 分组生成?
5. 父商品列表中价格展示为最低价、价格区间,还是指定代表 SKU 的价格?
这些决策确定后,再开始阶段 1;它们会直接影响表结构、导出格式和后续上品方式。
+1
View File
@@ -3,6 +3,7 @@
> 状态:Phase A 前端开发中(本次交付前端,服务端接口见 api.md) > 状态:Phase A 前端开发中(本次交付前端,服务端接口见 api.md)
> 参照:`web/ozonSeller.html` + `web/js/app.js`(v1 工具台,冻结只读)。试算页 = v1 流水线的 studio 化 + 数据落库。 > 参照:`web/ozonSeller.html` + `web/js/app.js`(v1 工具台,冻结只读)。试算页 = v1 流水线的 studio 化 + 数据落库。
> 前端代码:`studio/src/pages/trial/` > 前端代码:`studio/src/pages/trial/`
> 多 SKU 的后续扩展、采集契约和迁移方案见 [`multi-sku-pricing.md`](./multi-sku-pricing.md)。本文以下计价字段以当前单 SKU 实现为准。
--- ---
+12 -1
View File
@@ -13,6 +13,7 @@ import re
import tempfile import tempfile
import zipfile import zipfile
from collections.abc import Iterator from collections.abc import Iterator
from urllib.parse import quote
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import Response, StreamingResponse from fastapi.responses import Response, StreamingResponse
@@ -80,6 +81,16 @@ def _is_image(url: str, ctype: str) -> bool:
return bool(re.search(r"\.(jpe?g|png|webp|gif|bmp)(\?|$)", url, re.IGNORECASE)) return bool(re.search(r"\.(jpe?g|png|webp|gif|bmp)(\?|$)", url, re.IGNORECASE))
def _download_disposition(filename: str) -> str:
"""生成兼容中文文件名的下载响应头。
Starlette 会按 Latin-1 编码 HTTP 头;直接将中文放进 filename 会抛 UnicodeEncodeError
并返回 500。filename 提供 ASCII 回退,filename* 使用 RFC 5987 的 UTF-8 百分号编码。
"""
fallback = re.sub(r"[^\x20-\x7e]", "_", filename) or "image"
return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quote(filename, safe='')}"
async def _download(url: str) -> tuple[bytes, str]: async def _download(url: str) -> tuple[bytes, str]:
"""本地 media 文件直读磁盘;远程 URL(源站 CDN / 七牛)带 Referer 下载。""" """本地 media 文件直读磁盘;远程 URL(源站 CDN / 七牛)带 Referer 下载。"""
path = local_path(url) path = local_path(url)
@@ -190,5 +201,5 @@ async def export_image(img: ExportImageItem):
return Response( return Response(
content=data, content=data,
media_type=ctype, media_type=ctype,
headers={"Content-Disposition": f'attachment; filename="{base}{ext}"'}, headers={"Content-Disposition": _download_disposition(f"{base}{ext}")},
) )
+5
View File
@@ -190,6 +190,11 @@ async def process_item(claim: ClaimedItem) -> None:
if result.data is None: if result.data is None:
if not result.provider_task_id or not result.provider_task_kind: if not result.provider_task_id or not result.provider_task_kind:
raise RuntimeError("异步生成未返回可恢复的 provider task id") raise RuntimeError("异步生成未返回可恢复的 provider task id")
# 轮询供应商期间用户可能刚好取消任务。此时不能再写回
# waiting_provider,否则父任务已取消、子项却会永久显示“进行中”。
if not await job_can_write(claim.job_id):
await mark_item_cancelled(claim.item_id)
return
await mark_waiting_provider(claim.item_id, result.provider_task_id, result.provider_task_kind) await mark_waiting_provider(claim.item_id, result.provider_task_id, result.provider_task_kind)
return return
+16 -1
View File
@@ -206,11 +206,26 @@ async def claim_next_item(owner: str, lease_seconds: int = 90) -> ClaimedItem |
async def mark_waiting_provider(item_id: str, provider_task_id: str, provider_task_kind: str, delay_seconds: int = 3) -> None: async def mark_waiting_provider(item_id: str, provider_task_id: str, provider_task_kind: str, delay_seconds: int = 3) -> None:
"""先持久化外部任务 ID,再安排下一次查询,是避免重启重复扣费的关键提交点。""" """持久化外部任务 ID 并安排下一次查询。
取消可能恰好发生在 Worker 轮询供应商的网络请求期间。这里在同一事务内
再次检查父任务,避免已取消任务被写回 waiting_provider 后永远不再被领取。
"""
async with get_session_factory()() as db: async with get_session_factory()() as db:
item = await db.get(BackgroundJobItem, uuid.UUID(item_id)) item = await db.get(BackgroundJobItem, uuid.UUID(item_id))
if item is None: if item is None:
return return
job = await db.get(BackgroundJob, item.job_id)
if job is None or job.cancel_requested:
item.status = ITEM_CANCELLED
item.lease_owner = None
item.lease_expires_at = None
item.next_run_at = None
item.completed_at = utcnow()
if job is not None:
await _refresh_job_status(db, job)
await db.commit()
return
item.provider_task_id = provider_task_id item.provider_task_id = provider_task_id
item.provider_task_kind = provider_task_kind item.provider_task_kind = provider_task_kind
item.status = ITEM_WAITING_PROVIDER item.status = ITEM_WAITING_PROVIDER
+154 -15
View File
@@ -27,6 +27,16 @@ ALLOWED_KINDS = [
# 「生图要求」明确写了「N 张」时按用户指定的来,上限放宽到 24。 # 「生图要求」明确写了「N 张」时按用户指定的来,上限放宽到 24。
PLAN_SAFETY_MAX = 15 PLAN_SAFETY_MAX = 15
PLAN_REQUIREMENTS_MAX = 24 PLAN_REQUIREMENTS_MAX = 24
MAX_VARIANT_MAIN_IMAGES = 2
# 规划器的图类归并。不同模型会把“细节图”写成材质图、卖点图或详情图,
# 因此均视作同一类覆盖,而不是强迫模型只使用 material。
SCENE_KINDS = {"lifestyle", "multi_scene"}
DETAIL_KINDS = {"material", "key_features", "selling_pt", "ecommerce_detail"}
_DIMENSION_PATTERN = re.compile(
r"尺寸|长宽高|直径|厚度|(?:长|宽|高)\s*[::×x*]?\s*\d+|\d+(?:\.\d+)?\s*(?:厘米|毫米|cm|mm|inch|in)\b",
re.IGNORECASE,
)
def _total_budget(requirements: str | None) -> int: def _total_budget(requirements: str | None) -> int:
@@ -47,12 +57,12 @@ SYSTEM_PROMPT = """你是一名资深电商视觉策划。根据商品信息规
5. variant_name 只能从「SKU规格」列表原样照抄;没有绑定就输出 null。 5. variant_name 只能从「SKU规格」列表原样照抄;没有绑定就输出 null。
## 规划规则 ## 规划规则
1. 先保证 SKU 主图(规则 2),其余类型(场景/细节/尺寸/合集等)按商品特点自行取舍并分配张数,追求信息效率,不为凑数出图。 1. 套图必须均衡覆盖:白底主图、场景图、细节图;参数含可靠的长宽高/尺寸时还必须有尺寸标注图。按这个顺序先预留张数,再安排其他类型。
2. SKU 主图:每个带图 SKU 出 1 张独立主图(kind=white_bg),variant_name 填对应规格名;单 SKU 出 1 张(variant_name=null)。 2. 白底主图(kind=white_bg)通常只出 1 张;有多个视觉差异明显的 SKU 时最多选 2 个代表款各出 1 张,variant_name 填对应规格名。**禁止因为 SKU 多而为每个 SKU 都规划主图**。仅当「生图要求」明确要求每个/全部 SKU 分别出图时才可例外。
3. 场景图(kind=lifestyle):按核心使用场景出 1-3 张,每张聚焦一个场景。 3. 场景图(kind=lifestyle):至少 1 张,按核心使用场景出 1-2 张,每张聚焦一个场景。
4. 细节图(kind=material 或 custom):按关键细节/材质/结构出 1-2 张,每张聚焦一个卖点。 4. 细节图(kind=material / key_features / selling_pt / ecommerce_detail):至少 1 张,展示关键细节、材质或结构;通常 1-2 张,每张聚焦一个卖点。
5. 尺寸标注图(kind=size_chart):参数含长宽高/尺寸时出 1 张。 5. 尺寸标注图(kind=size_chart):参数含长宽高/尺寸时必须出 1 张;无可靠尺寸参数时不要虚构尺寸。
6. SKU 合集图(kind=sku_collection):SKU >1 时出 1 张。 6. SKU 合集图(kind=sku_collection):仅在 SKU >1 且确有必要展示差异时出 1 张,优先级低于上述四类。
7. kind 枚举:white_bg / key_features / selling_pt / material / lifestyle / multi_scene / ecommerce_detail / size_chart / sku_collection / custom。 7. kind 枚举:white_bg / key_features / selling_pt / material / lifestyle / multi_scene / ecommerce_detail / size_chart / sku_collection / custom。
8. title 用中文短语(如「主图·粉色」「浴室壁挂」);detail 中文说明这张图展示什么;prompt_hint 用英文描述构图要点。 8. title 用中文短语(如「主图·粉色」「浴室壁挂」);detail 中文说明这张图展示什么;prompt_hint 用英文描述构图要点。
@@ -114,6 +124,141 @@ def _normalize_items(raw_items: list, sku_variants: list[str]) -> list[dict]:
return items return items
def _has_dimensions(product_info: dict) -> bool:
"""仅在已采集到尺寸信息时规划尺寸图,避免模型编造数值。"""
for param in product_info.get("params") or []:
if not isinstance(param, dict):
continue
text = f"{param.get('key') or ''} {param.get('value') or ''}"
if _DIMENSION_PATTERN.search(text):
return True
return False
def _requires_every_sku(requirements: str | None) -> bool:
"""用户显式要求逐 SKU 出图时,保留其优先级,不施加代表款上限。"""
if not requirements:
return False
return bool(re.search(
r"(?:每个|全部|所有|逐个|分别).{0,10}(?:sku|SKU|规格|款式|颜色|变体)",
requirements,
))
def _coverage_group(kind: str) -> str:
if kind == "white_bg":
return "main"
if kind in SCENE_KINDS:
return "scene"
if kind in DETAIL_KINDS:
return "detail"
if kind == "size_chart":
return "size"
return "other"
def _total(items: list[dict]) -> int:
return sum(item["count"] for item in items)
def _group_total(items: list[dict], group: str) -> int:
return sum(item["count"] for item in items if _coverage_group(item["kind"]) == group)
def _remove_one(items: list[dict], predicate) -> bool:
"""从后往前删除一个生成槽位,优先保留模型较早规划的构图。"""
for index in range(len(items) - 1, -1, -1):
if not predicate(items[index]):
continue
if items[index]["count"] > 1:
items[index]["count"] -= 1
else:
items.pop(index)
return True
return False
def _make_room(items: list[dict], budget: int, minimums: dict[str, int]) -> bool:
"""为必需图类腾出一张,先去除非核心图或某类的冗余张数。"""
candidates: list[tuple[int, int]] = []
for index, item in enumerate(items):
group = _coverage_group(item["kind"])
if _group_total(items, group) <= minimums.get(group, 0):
continue
# other 最低优先;同类冗余时从后面的项开始删。
priority = 0 if group == "other" else 1
candidates.append((priority, -index))
if not candidates:
return False
_, neg_index = min(candidates)
index = -neg_index
item = items[index]
if item["count"] > 1:
item["count"] -= 1
else:
items.pop(index)
return True
def _balanced_items(
items: list[dict],
sku_variants: list[str],
product_info: dict,
requirements: str | None,
budget: int,
) -> list[dict]:
"""将 LLM 方案约束为信息均衡的商品套图。
提示词能改善规划倾向,但不能保证每次输出。这里把 SKU 主图总量限制为
代表款数量,并在预算允许时补齐核心图类,避免 SKU 多时挤掉场景/细节/尺寸图。
"""
balanced = [dict(item) for item in items if item["count"] > 0]
if not _requires_every_sku(requirements):
max_mains = 1 if len(sku_variants) <= 1 else MAX_VARIANT_MAIN_IMAGES
while sum(item["count"] for item in balanced if item["kind"] == "white_bg") > max_mains:
_remove_one(balanced, lambda item: item["kind"] == "white_bg")
required: list[tuple[str, dict]] = [
("main", {
"kind": "white_bg", "title": "白底主图", "detail": "商品白底主视觉",
"prompt_hint": "front view on pure white background", "count": 1,
"variant_name": sku_variants[0] if sku_variants else None,
}),
("scene", {
"kind": "lifestyle", "title": "使用场景", "detail": "展示商品实际使用场景",
"prompt_hint": "product in a natural everyday use setting", "count": 1,
"variant_name": None,
}),
("detail", {
"kind": "material", "title": "细节展示", "detail": "突出材质与结构细节",
"prompt_hint": "close-up of authentic material and construction details", "count": 1,
"variant_name": None,
}),
]
if _has_dimensions(product_info):
required.append(("size", {
"kind": "size_chart", "title": "尺寸标注", "detail": "展示已采集的商品尺寸",
"prompt_hint": "clear size chart using known product dimensions", "count": 1,
"variant_name": None,
}))
# 用户明确要求较小张数时,优先保留前面的核心图类;不突破其指定预算。
required = required[:budget]
minimums = {group: 1 for group, _ in required}
for group, fallback in required:
if _group_total(balanced, group) > 0:
continue
while _total(balanced) >= budget and _make_room(balanced, budget, minimums):
pass
if _total(balanced) < budget:
balanced.append(fallback)
while _total(balanced) > budget and _make_room(balanced, budget, minimums):
pass
return balanced
def _repair_truncated(s: str) -> dict | None: def _repair_truncated(s: str) -> dict | None:
"""截断修复:从最后一个完整的 '}' 处截断,剥尾逗号后按括号配平补全闭合。 """截断修复:从最后一个完整的 '}' 处截断,剥尾逗号后按括号配平补全闭合。
@@ -234,16 +379,10 @@ async def generate_plan(
items = _normalize_items(data.get("items") or [], sku_variants) items = _normalize_items(data.get("items") or [], sku_variants)
if items: if items:
# 总量保护:只防跑飞不干预正常规划。生图要求指定「N 张」时按 N 放宽上限 # 先压缩多 SKU 的重复主图、补齐核心视觉类别,再执行总量保护。
# 生图要求指定「N 张」时按 N 放宽上限,并始终不突破该预算。
budget = _total_budget(requirements) budget = _total_budget(requirements)
total = sum(i["count"] for i in items) items = _balanced_items(items, sku_variants, product_info, requirements, budget)
while total > budget and items:
last = items[-1]
if last["count"] > 1:
last["count"] -= 1
else:
items.pop()
total = sum(i["count"] for i in items)
return {"summary": str(data.get("summary") or "").strip()[:100], "items": items} return {"summary": str(data.get("summary") or "").strip()[:100], "items": items}
last_error = RuntimeError("规划器未返回有效方案项") last_error = RuntimeError("规划器未返回有效方案项")
+2 -2
View File
@@ -76,7 +76,7 @@ async def append_generated_asset(
sort_order: int | None = None sort_order: int | None = None
if after_asset_id: if after_asset_id:
try: try:
anchor = await db.get(ProductAsset, UUID(after_asset_id)) anchor = await db.get(ProductAsset, uuid.UUID(after_asset_id))
except ValueError: except ValueError:
anchor = None anchor = None
if anchor is not None and anchor.product_id == pid and anchor.group_key == "generated": if anchor is not None and anchor.product_id == pid and anchor.group_key == "generated":
@@ -89,7 +89,7 @@ async def append_generated_asset(
ProductAsset.sort_order > anchor.sort_order, ProductAsset.sort_order > anchor.sort_order,
) )
) )
).scalars().all() ).all()
for follower in followers: for follower in followers:
follower.sort_order += 1 follower.sort_order += 1
sort_order = anchor.sort_order + 1 sort_order = anchor.sort_order + 1
+32
View File
@@ -0,0 +1,32 @@
"""单图导出响应头回归测试。"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from api.export import ExportImageItem, export_image
class ExportImageTests(unittest.IsolatedAsyncioTestCase):
async def test_chinese_filename_uses_rfc5987_header(self) -> None:
image = ExportImageItem(
url="https://example.com/image.png",
key="尺寸标注图",
variantName="蓝色",
)
with patch("api.export._download", new=AsyncMock(return_value=(b"image", "image/png"))):
response = await export_image(image)
disposition = response.headers["content-disposition"]
self.assertIn('filename="_____-__.png"', disposition)
self.assertIn("filename*=UTF-8''%E5%B0%BA%E5%AF%B8%E6%A0%87%E6%B3%A8%E5%9B%BE-%E8%93%9D%E8%89%B2.png", disposition)
if __name__ == "__main__":
unittest.main()
+63
View File
@@ -0,0 +1,63 @@
"""后台生图 Worker 的取消竞态回归测试。"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from services.generator import GenerationAdvance
from services.job_worker import process_item
from services.jobs import ClaimedItem
class WorkerCancellationTests(unittest.IsolatedAsyncioTestCase):
async def test_cancelled_task_is_not_returned_to_provider_waiting_queue(self) -> None:
claim = ClaimedItem(
job_id="00000000-0000-0000-0000-000000000001",
item_id="00000000-0000-0000-0000-000000000002",
job_kind="image_edit",
product_id=None,
provider="pcore",
model="gpt-image-2",
platform="ozon",
lang="ru",
ratio="3:4",
style_set=5,
request_payload={"context": {"title": "test"}, "ref_images": []},
item_payload={"kind": "custom", "title": "AI生图"},
provider_task_id="provider-task",
provider_task_kind="edits",
)
settings = SimpleNamespace(
image_provider="pcore",
dashscope_model="wan2.7-image-pro",
rightapi_image_model="gpt-image-2",
ark_image_model="doubao-seedream-4-5-251128",
)
with (
patch("services.job_worker.get_settings", return_value=settings),
patch("services.job_worker.build_context", return_value={}),
patch("services.job_worker.build_prompt", return_value="prompt"),
patch("services.job_worker._refs_for_job", return_value=[]),
patch("services.job_worker.advance_generation", new=AsyncMock(return_value=GenerationAdvance(
provider_task_id="provider-task", provider_task_kind="edits",
))),
patch("services.job_worker.job_can_write", new=AsyncMock(return_value=False)),
patch("services.job_worker.mark_item_cancelled", new=AsyncMock()) as cancelled,
patch("services.job_worker.mark_waiting_provider", new=AsyncMock()) as waiting,
):
await process_item(claim)
cancelled.assert_awaited_once_with(claim.item_id)
waiting.assert_not_awaited()
if __name__ == "__main__":
unittest.main()
+69
View File
@@ -0,0 +1,69 @@
"""AI 套图规划的均衡覆盖回归测试。"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from services.planner import _balanced_items
def item(kind: str, variant_name: str | None = None) -> dict:
return {
"kind": kind,
"title": kind,
"detail": "",
"prompt_hint": "",
"count": 1,
"variant_name": variant_name,
}
class BalancedPlanTests(unittest.TestCase):
def test_many_skus_do_not_crowd_out_core_image_types(self) -> None:
variants = ["黑色", "白色", "蓝色", "粉色", "绿色"]
plan = _balanced_items(
[item("white_bg", variant) for variant in variants],
variants,
{"params": [{"key": "尺寸", "value": "30 × 20 × 10 cm"}]},
None,
budget=15,
)
self.assertLessEqual(sum(p["count"] for p in plan if p["kind"] == "white_bg"), 2)
self.assertTrue(any(p["kind"] == "lifestyle" for p in plan))
self.assertTrue(any(p["kind"] == "material" for p in plan))
self.assertTrue(any(p["kind"] == "size_chart" for p in plan))
def test_does_not_add_size_chart_without_dimensions(self) -> None:
plan = _balanced_items(
[item("white_bg", "黑色")],
["黑色", "白色"],
{"params": [{"key": "规格", "value": "标准款"}, {"key": "材质", "value": "涤纶"}]},
None,
budget=15,
)
self.assertFalse(any(p["kind"] == "size_chart" for p in plan))
self.assertTrue(any(p["kind"] == "lifestyle" for p in plan))
self.assertTrue(any(p["kind"] == "material" for p in plan))
def test_explicit_every_sku_requirement_is_preserved(self) -> None:
variants = ["黑色", "白色", "蓝色"]
plan = _balanced_items(
[item("white_bg", variant) for variant in variants],
variants,
{},
"每个 SKU 都单独出一张白底图",
budget=15,
)
self.assertEqual(sum(p["count"] for p in plan if p["kind"] == "white_bg"), 3)
if __name__ == "__main__":
unittest.main()
+107 -27
View File
@@ -1,11 +1,112 @@
#!/bin/zsh #!/bin/zsh
# 双击本文件,或在终端执行:./start.command # 双击本文件,或在终端执行:./start.command
cd "$(dirname "$0")" cd "$(dirname "$0")" || exit 1
PROJECT_DIR="$PWD"
BACKEND_HOST="127.0.0.1" BACKEND_HOST="127.0.0.1"
BACKEND_PORT="8800" BACKEND_PORT="8800"
BACKEND_URL="http://${BACKEND_HOST}:${BACKEND_PORT}" BACKEND_URL="http://${BACKEND_HOST}:${BACKEND_PORT}"
STUDIO_URL="http://localhost:8900" STUDIO_URL="http://localhost:8900"
RUNTIME_DIR="$PROJECT_DIR/data/runtime"
INSTANCE_PID_FILE="$RUNTIME_DIR/start-command.pid"
# 由当前 start.command 管理的子进程。它们都在清理函数里按 PID 树结束,
# 不扫描或误杀其他项目的 Python / Node 进程。
BACKEND_PID=""
STUDIO_PID=""
WORKER_SUPERVISOR_PID=""
COLLECTOR_WATCH_PID=""
COLLECTOR_STAMP=""
CLEANUP_DONE=false
stop_process_tree() {
local pid="$1"
local child
[[ "$pid" == <-> ]] || return 0
kill -0 "$pid" 2>/dev/null || return 0
# 先停止子进程,避免 Worker 守护循环在父进程退出前重新拉起 Worker。
for child in $(pgrep -P "$pid" 2>/dev/null); do
stop_process_tree "$child"
done
kill -TERM "$pid" 2>/dev/null || true
}
cleanup() {
[[ "$CLEANUP_DONE" == true ]] && return
CLEANUP_DONE=true
stop_process_tree "$BACKEND_PID"
stop_process_tree "$STUDIO_PID"
stop_process_tree "$WORKER_SUPERVISOR_PID"
stop_process_tree "$COLLECTOR_WATCH_PID"
[[ -n "$COLLECTOR_STAMP" ]] && rm -f "$COLLECTOR_STAMP" 2>/dev/null
if [[ -f "$INSTANCE_PID_FILE" ]] && [[ "$(<"$INSTANCE_PID_FILE")" == "$$" ]]; then
rm -f "$INSTANCE_PID_FILE"
fi
}
on_signal() {
exit 0
}
stop_previous_instance() {
[[ -f "$INSTANCE_PID_FILE" ]] || return 0
local previous_pid command cwd
previous_pid="$(<"$INSTANCE_PID_FILE")"
if [[ "$previous_pid" != <-> ]] || ! kill -0 "$previous_pid" 2>/dev/null; then
rm -f "$INSTANCE_PID_FILE"
return 0
fi
command="$(ps -p "$previous_pid" -o command= 2>/dev/null)"
cwd="$(lsof -a -p "$previous_pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1)"
if [[ "$command" != *"start.command"* || "$cwd" != "$PROJECT_DIR" ]]; then
echo "⚠️ 发现过期的启动记录,未匹配到本项目进程,已忽略。"
rm -f "$INSTANCE_PID_FILE"
return 0
fi
echo "检测到已有工具台实例(PID: $previous_pid),正在停止后重新启动…"
stop_process_tree "$previous_pid"
for _ in {1..50}; do
kill -0 "$previous_pid" 2>/dev/null || break
sleep 0.1
done
if kill -0 "$previous_pid" 2>/dev/null; then
echo "⚠️ 旧实例未能停止;为避免启动重复 Worker,本次不启动新实例。"
exit 1
fi
rm -f "$INSTANCE_PID_FILE"
}
stop_unmanaged_project_workers() {
# PID 文件只能管理此前由 start.command 拉起的实例;这里额外收敛曾被手动
# 启动、但 cwd 明确属于当前项目的 Worker。绝不按 Python 名称做全局清理。
local pid parent_pid command cwd parent_command parent_cwd
for pid in $(pgrep -f 'server/worker\.py' 2>/dev/null); do
command="$(ps -p "$pid" -o command= 2>/dev/null)"
cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1)"
[[ "$command" == *"server/worker.py"* && "$cwd" == "$PROJECT_DIR" ]] || continue
parent_pid="$(ps -p "$pid" -o ppid= 2>/dev/null | tr -d ' ')"
parent_command="$(ps -p "$parent_pid" -o command= 2>/dev/null)"
parent_cwd="$(lsof -a -p "$parent_pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1)"
echo "检测到遗留 Worker(PID: $pid),正在停止…"
if [[ "$parent_command" == *"start.command"* && "$parent_cwd" == "$PROJECT_DIR" ]]; then
# 这是脱离 PID 文件的旧守护循环;连同父进程停掉,避免它 2 秒后重拉 Worker。
stop_process_tree "$parent_pid"
else
# 手动启动的 Worker 只停止自身,不触碰其可能属于其他工具的父终端。
kill -TERM "$pid" 2>/dev/null || true
fi
done
}
mkdir -p "$RUNTIME_DIR" || exit 1
stop_previous_instance
stop_unmanaged_project_workers
print -r -- "$$" > "$INSTANCE_PID_FILE"
trap cleanup EXIT
trap on_signal INT TERM HUP
if [[ ! -d .venv ]]; then if [[ ! -d .venv ]]; then
echo "未找到 .venv,正在创建…" echo "未找到 .venv,正在创建…"
@@ -32,7 +133,6 @@ fi
# ---- 采集插件(extensions/collector):启动时构建一次,之后监听代码改动自动重建 ---- # ---- 采集插件(extensions/collector):启动时构建一次,之后监听代码改动自动重建 ----
# 产物 .output/chrome-mv3 由 Chrome 以解压方式加载;重建后仍需去 chrome://extensions 点刷新 # 产物 .output/chrome-mv3 由 Chrome 以解压方式加载;重建后仍需去 chrome://extensions 点刷新
COLLECTOR_WATCH_PID=""
COLLECTOR_STAMP="$(mktemp -u /tmp/osk-collector-stamp.XXXXXX)" COLLECTOR_STAMP="$(mktemp -u /tmp/osk-collector-stamp.XXXXXX)"
watch_collector() { watch_collector() {
touch "$COLLECTOR_STAMP" touch "$COLLECTOR_STAMP"
@@ -68,29 +168,6 @@ else
fi fi
echo echo
# ---- 同步并启动前端(studio:Vite 开发服务器,/api 代理到后端 8800)----
STUDIO_PID=""
WORKER_SUPERVISOR_PID=""
cleanup() {
if [[ -n "$WORKER_SUPERVISOR_PID" ]] && kill -0 "$WORKER_SUPERVISOR_PID" 2>/dev/null; then
# 先终止 Worker 子进程,再结束守护循环;避免退出 start.command 后残留处理器。
pkill -P "$WORKER_SUPERVISOR_PID" 2>/dev/null
kill "$WORKER_SUPERVISOR_PID" 2>/dev/null
fi
if [[ -n "$STUDIO_PID" ]] && kill -0 "$STUDIO_PID" 2>/dev/null; then
kill "$STUDIO_PID" 2>/dev/null
sleep 0.2
# 兜底:确保 vite 及其子进程退出
lsof -ti :8900 | xargs kill 2>/dev/null
fi
if [[ -n "$COLLECTOR_WATCH_PID" ]] && kill -0 "$COLLECTOR_WATCH_PID" 2>/dev/null; then
pkill -P "$COLLECTOR_WATCH_PID" 2>/dev/null # 可能正在跑的 build 子进程
kill "$COLLECTOR_WATCH_PID" 2>/dev/null
fi
rm -f "$COLLECTOR_STAMP" 2>/dev/null
}
trap cleanup EXIT
# ---- 持久化后台任务 Worker(与 uvicorn 独立,服务重启不会中断正在轮询的模型任务)---- # ---- 持久化后台任务 Worker(与 uvicorn 独立,服务重启不会中断正在轮询的模型任务)----
# Worker 因配置/网络等未捕获错误退出时,2 秒后自动拉起;否则任务会长期停在「排队中」。 # Worker 因配置/网络等未捕获错误退出时,2 秒后自动拉起;否则任务会长期停在「排队中」。
supervise_worker() { supervise_worker() {
@@ -124,8 +201,11 @@ echo
echo "按 Ctrl+C 可停止全部服务" echo "按 Ctrl+C 可停止全部服务"
echo echo
# --app-dir server 让 uvicorn 在 server/ 下找 main:app,工作目录仍是仓库根(.env 在此) # --app-dir server 让 uvicorn 在 server/ 下找 main:app,工作目录仍是仓库根(.env 在此)。
uvicorn main:app --app-dir server --reload --reload-include '*.yaml' --host "$BACKEND_HOST" --port "$BACKEND_PORT" # 后端改为后台子进程 + wait:start.command 本身始终存活,PID 文件和 EXIT 清理才能可靠生效。
uvicorn main:app --app-dir server --reload --reload-include '*.yaml' --host "$BACKEND_HOST" --port "$BACKEND_PORT" &
BACKEND_PID=$!
wait "$BACKEND_PID"
echo echo
echo "服务已停止。按回车关闭窗口…" echo "服务已停止。按回车关闭窗口…"