fix: 修改一下生图的 bug

This commit is contained in:
2026-08-31 21:56:48 +08:00
parent 2a667b4f3b
commit 8b54f7ed1e
14 changed files with 636 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` 以单实例方式运行;再次启动时会先停止此前由它管理的同项目进程,再启动新实例。
**2. 构建/加载采集插件**:
```bash
+1 -1
View File
@@ -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 试算与导出方案)、[`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)(后端结构)
---
+1
View File
@@ -110,6 +110,7 @@ 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 导出与分阶段迁移方案 | 扩展规格级计价、导出或采集时读 |
| [`image-suite.md`](./image-suite.md) | 图片生成:套图规划→一键生成、模型路由、单张 AI 生图、水印、生成图回写与导出 | 做图片功能时读 |
| [`background-jobs.md`](./background-jobs.md) | **后台任务中心**:统一任务模型、Worker、状态、恢复、已完成阶段和后续路线图 | 改生图、水印或任何耗时任务前先读 |
| [`image-asset-lifecycle.md`](./image-asset-lifecycle.md) | 图片原图、水印当前版、对象路径与删除回收流程 | 改图片、水印或存储前先读 |
+171
View File
@@ -0,0 +1,171 @@
# 多 SKU 商品处理与试算页扩展方案
> 状态:设计方案,尚未实施。
> 范围:采集 → 采集箱商品 → `/trial/:id` 价格试算 → CSV 导出;Ozon API 直传仍处于冻结状态。
> 目标:同一商品的不同规格可以独立试算,避免以一个 SKU 的价格、重量和尺寸覆盖全部规格。
---
## 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)
> 参照:`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
View File
@@ -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}")},
)
+5
View File
@@ -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
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:
"""先持久化外部任务 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
View File
@@ -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("规划器未返回有效方案项")
+2 -2
View File
@@ -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
+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
# 双击本文件,或在终端执行:./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 "服务已停止。按回车关闭窗口…"