Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7247ca2da | |||
| 8b54f7ed1e |
@@ -37,6 +37,8 @@ ozon-seller-kit/
|
||||
./start.command # server: http://127.0.0.1:8800 + studio: http://localhost:8900
|
||||
```
|
||||
|
||||
`start.command` 以单实例方式运行;再次启动时会先停止此前由它管理的同项目进程,再启动新实例。
|
||||
|
||||
**2. 构建/加载采集插件**:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 更新:2026-08-31
|
||||
> 读者:下一个接手工作的 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)(后端结构)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -110,6 +110,8 @@ V2(原计划) V2.1(现在)
|
||||
| [`HANDOFF.md`](./HANDOFF.md) | **工作交接说明**:当前进度、启动/构建命令、已知坑、下一步(新会话先读) | 接手工作时先读 |
|
||||
| [`collect.md`](./collect.md) | 采集方案:扩展并入 `extensions/collector/` + 上报开关 + 自动打开试算页(已实施) | 做插件改动时读 |
|
||||
| [`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 生图、水印、生成图回写与导出 | 做图片功能时读 |
|
||||
| [`background-jobs.md`](./background-jobs.md) | **后台任务中心**:统一任务模型、Worker、状态、恢复、已完成阶段和后续路线图 | 改生图、水印或任何耗时任务前先读 |
|
||||
| [`image-asset-lifecycle.md`](./image-asset-lifecycle.md) | 图片原图、水印当前版、对象路径与删除回收流程 | 改图片、水印或存储前先读 |
|
||||
|
||||
@@ -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 的价格?
|
||||
@@ -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;它们会直接影响表结构、导出格式和后续上品方式。
|
||||
@@ -3,6 +3,7 @@
|
||||
> 状态:Phase A 前端开发中(本次交付前端,服务端接口见 api.md)
|
||||
> 参照:`web/ozonSeller.html` + `web/js/app.js`(v1 工具台,冻结只读)。试算页 = v1 流水线的 studio 化 + 数据落库。
|
||||
> 前端代码:`studio/src/pages/trial/`
|
||||
> 多 SKU 的后续扩展、采集契约和迁移方案见 [`multi-sku-pricing.md`](./multi-sku-pricing.md)。本文以下计价字段以当前单 SKU 实现为准。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+12
-1
@@ -13,6 +13,7 @@ import re
|
||||
import tempfile
|
||||
import zipfile
|
||||
from collections.abc import Iterator
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
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))
|
||||
|
||||
|
||||
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]:
|
||||
"""本地 media 文件直读磁盘;远程 URL(源站 CDN / 七牛)带 Referer 下载。"""
|
||||
path = local_path(url)
|
||||
@@ -190,5 +201,5 @@ async def export_image(img: ExportImageItem):
|
||||
return Response(
|
||||
content=data,
|
||||
media_type=ctype,
|
||||
headers={"Content-Disposition": f'attachment; filename="{base}{ext}"'},
|
||||
headers={"Content-Disposition": _download_disposition(f"{base}{ext}")},
|
||||
)
|
||||
|
||||
@@ -190,6 +190,11 @@ async def process_item(claim: ClaimedItem) -> None:
|
||||
if result.data is None:
|
||||
if not result.provider_task_id or not result.provider_task_kind:
|
||||
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)
|
||||
return
|
||||
|
||||
|
||||
+16
-1
@@ -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:
|
||||
"""先持久化外部任务 ID,再安排下一次查询,是避免重启重复扣费的关键提交点。"""
|
||||
"""持久化外部任务 ID 并安排下一次查询。
|
||||
|
||||
取消可能恰好发生在 Worker 轮询供应商的网络请求期间。这里在同一事务内
|
||||
再次检查父任务,避免已取消任务被写回 waiting_provider 后永远不再被领取。
|
||||
"""
|
||||
async with get_session_factory()() as db:
|
||||
item = await db.get(BackgroundJobItem, uuid.UUID(item_id))
|
||||
if item is None:
|
||||
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_kind = provider_task_kind
|
||||
item.status = ITEM_WAITING_PROVIDER
|
||||
|
||||
+154
-15
@@ -27,6 +27,16 @@ ALLOWED_KINDS = [
|
||||
# 「生图要求」明确写了「N 张」时按用户指定的来,上限放宽到 24。
|
||||
PLAN_SAFETY_MAX = 15
|
||||
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:
|
||||
@@ -47,12 +57,12 @@ SYSTEM_PROMPT = """你是一名资深电商视觉策划。根据商品信息规
|
||||
5. variant_name 只能从「SKU规格」列表原样照抄;没有绑定就输出 null。
|
||||
|
||||
## 规划规则
|
||||
1. 先保证 SKU 主图(规则 2),其余类型(场景/细节/尺寸/合集等)按商品特点自行取舍并分配张数,追求信息效率,不为凑数出图。
|
||||
2. SKU 主图:每个带图 SKU 出 1 张独立主图(kind=white_bg),variant_name 填对应规格名;单 SKU 出 1 张(variant_name=null)。
|
||||
3. 场景图(kind=lifestyle):按核心使用场景出 1-3 张,每张聚焦一个场景。
|
||||
4. 细节图(kind=material 或 custom):按关键细节/材质/结构出 1-2 张,每张聚焦一个卖点。
|
||||
5. 尺寸标注图(kind=size_chart):参数含长宽高/尺寸时出 1 张。
|
||||
6. SKU 合集图(kind=sku_collection):SKU >1 时出 1 张。
|
||||
1. 套图必须均衡覆盖:白底主图、场景图、细节图;参数含可靠的长宽高/尺寸时还必须有尺寸标注图。按这个顺序先预留张数,再安排其他类型。
|
||||
2. 白底主图(kind=white_bg)通常只出 1 张;有多个视觉差异明显的 SKU 时最多选 2 个代表款各出 1 张,variant_name 填对应规格名。**禁止因为 SKU 多而为每个 SKU 都规划主图**。仅当「生图要求」明确要求每个/全部 SKU 分别出图时才可例外。
|
||||
3. 场景图(kind=lifestyle):至少 1 张,按核心使用场景出 1-2 张,每张聚焦一个场景。
|
||||
4. 细节图(kind=material / key_features / selling_pt / ecommerce_detail):至少 1 张,展示关键细节、材质或结构;通常 1-2 张,每张聚焦一个卖点。
|
||||
5. 尺寸标注图(kind=size_chart):参数含长宽高/尺寸时必须出 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。
|
||||
8. title 用中文短语(如「主图·粉色」「浴室壁挂」);detail 中文说明这张图展示什么;prompt_hint 用英文描述构图要点。
|
||||
|
||||
@@ -114,6 +124,141 @@ def _normalize_items(raw_items: list, sku_variants: list[str]) -> list[dict]:
|
||||
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:
|
||||
"""截断修复:从最后一个完整的 '}' 处截断,剥尾逗号后按括号配平补全闭合。
|
||||
|
||||
@@ -234,16 +379,10 @@ async def generate_plan(
|
||||
|
||||
items = _normalize_items(data.get("items") or [], sku_variants)
|
||||
if items:
|
||||
# 总量保护:只防跑飞不干预正常规划。生图要求指定「N 张」时按 N 放宽上限
|
||||
# 先压缩多 SKU 的重复主图、补齐核心视觉类别,再执行总量保护。
|
||||
# 生图要求指定「N 张」时按 N 放宽上限,并始终不突破该预算。
|
||||
budget = _total_budget(requirements)
|
||||
total = sum(i["count"] for i in items)
|
||||
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)
|
||||
items = _balanced_items(items, sku_variants, product_info, requirements, budget)
|
||||
return {"summary": str(data.get("summary") or "").strip()[:100], "items": items}
|
||||
|
||||
last_error = RuntimeError("规划器未返回有效方案项")
|
||||
|
||||
@@ -76,7 +76,7 @@ async def append_generated_asset(
|
||||
sort_order: int | None = None
|
||||
if after_asset_id:
|
||||
try:
|
||||
anchor = await db.get(ProductAsset, UUID(after_asset_id))
|
||||
anchor = await db.get(ProductAsset, uuid.UUID(after_asset_id))
|
||||
except ValueError:
|
||||
anchor = None
|
||||
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,
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
).all()
|
||||
for follower in followers:
|
||||
follower.sort_order += 1
|
||||
sort_order = anchor.sort_order + 1
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -1,11 +1,112 @@
|
||||
#!/bin/zsh
|
||||
# 双击本文件,或在终端执行:./start.command
|
||||
cd "$(dirname "$0")"
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
PROJECT_DIR="$PWD"
|
||||
|
||||
BACKEND_HOST="127.0.0.1"
|
||||
BACKEND_PORT="8800"
|
||||
BACKEND_URL="http://${BACKEND_HOST}:${BACKEND_PORT}"
|
||||
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
|
||||
echo "未找到 .venv,正在创建…"
|
||||
@@ -32,7 +133,6 @@ fi
|
||||
|
||||
# ---- 采集插件(extensions/collector):启动时构建一次,之后监听代码改动自动重建 ----
|
||||
# 产物 .output/chrome-mv3 由 Chrome 以解压方式加载;重建后仍需去 chrome://extensions 点刷新
|
||||
COLLECTOR_WATCH_PID=""
|
||||
COLLECTOR_STAMP="$(mktemp -u /tmp/osk-collector-stamp.XXXXXX)"
|
||||
watch_collector() {
|
||||
touch "$COLLECTOR_STAMP"
|
||||
@@ -68,29 +168,6 @@ else
|
||||
fi
|
||||
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 因配置/网络等未捕获错误退出时,2 秒后自动拉起;否则任务会长期停在「排队中」。
|
||||
supervise_worker() {
|
||||
@@ -124,8 +201,11 @@ echo
|
||||
echo "按 Ctrl+C 可停止全部服务"
|
||||
echo
|
||||
|
||||
# --app-dir server 让 uvicorn 在 server/ 下找 main:app,工作目录仍是仓库根(.env 在此)
|
||||
uvicorn main:app --app-dir server --reload --reload-include '*.yaml' --host "$BACKEND_HOST" --port "$BACKEND_PORT"
|
||||
# --app-dir server 让 uvicorn 在 server/ 下找 main:app,工作目录仍是仓库根(.env 在此)。
|
||||
# 后端改为后台子进程 + 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 "服务已停止。按回车关闭窗口…"
|
||||
|
||||
Reference in New Issue
Block a user