diff --git a/README.md b/README.md index 10009f1..f17f738 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ ozon-seller-kit/ ./start.command # server: http://127.0.0.1:8800 + studio: http://localhost:8900 ``` +`start.command` 以单实例方式运行;再次启动时会先停止此前由它管理的同项目进程,再启动新实例。 + **2. 构建/加载采集插件**: ```bash diff --git a/docs/v2.1/HANDOFF.md b/docs/v2.1/HANDOFF.md index 8302da0..5a2874f 100644 --- a/docs/v2.1/HANDOFF.md +++ b/docs/v2.1/HANDOFF.md @@ -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)(后端结构) --- diff --git a/docs/v2.1/README.md b/docs/v2.1/README.md index 47aebbb..cf14c53 100644 --- a/docs/v2.1/README.md +++ b/docs/v2.1/README.md @@ -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) | 图片原图、水印当前版、对象路径与删除回收流程 | 改图片、水印或存储前先读 | diff --git a/docs/v2.1/multi-sku-pricing.md b/docs/v2.1/multi-sku-pricing.md new file mode 100644 index 0000000..bd940f8 --- /dev/null +++ b/docs/v2.1/multi-sku-pricing.md @@ -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;它们会直接影响表结构、导出格式和后续上品方式。 diff --git a/docs/v2.1/trial-page.md b/docs/v2.1/trial-page.md index f922d63..524c603 100644 --- a/docs/v2.1/trial-page.md +++ b/docs/v2.1/trial-page.md @@ -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 实现为准。 --- diff --git a/server/api/export.py b/server/api/export.py index 93741a2..4badf3c 100644 --- a/server/api/export.py +++ b/server/api/export.py @@ -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}")}, ) diff --git a/server/services/job_worker.py b/server/services/job_worker.py index 4579a18..e0b1e88 100644 --- a/server/services/job_worker.py +++ b/server/services/job_worker.py @@ -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 diff --git a/server/services/jobs.py b/server/services/jobs.py index c552e7a..b4b06a7 100644 --- a/server/services/jobs.py +++ b/server/services/jobs.py @@ -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 diff --git a/server/services/planner.py b/server/services/planner.py index 11fad23..0009889 100644 --- a/server/services/planner.py +++ b/server/services/planner.py @@ -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("规划器未返回有效方案项") diff --git a/server/services/suite_service.py b/server/services/suite_service.py index f29ea08..6ccbe46 100644 --- a/server/services/suite_service.py +++ b/server/services/suite_service.py @@ -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 diff --git a/server/tests/test_export.py b/server/tests/test_export.py new file mode 100644 index 0000000..60b69f1 --- /dev/null +++ b/server/tests/test_export.py @@ -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() diff --git a/server/tests/test_job_worker.py b/server/tests/test_job_worker.py new file mode 100644 index 0000000..7feb86b --- /dev/null +++ b/server/tests/test_job_worker.py @@ -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() diff --git a/server/tests/test_planner.py b/server/tests/test_planner.py new file mode 100644 index 0000000..dd9f250 --- /dev/null +++ b/server/tests/test_planner.py @@ -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() diff --git a/start.command b/start.command index 7cb88fe..ba3c8d7 100755 --- a/start.command +++ b/start.command @@ -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 "服务已停止。按回车关闭窗口…"