From 6732cb178aabb05ce0640069c628bf8e60cab504 Mon Sep 17 00:00:00 2001 From: Joey Date: Wed, 19 Aug 2026 22:37:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=96=B0=E7=9A=84?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=EF=BC=8C=E5=88=A0=E9=99=A4=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ss_21b5fc7d-daa9-480a-854b-be36b67412a8.md | 49 ++++++ README.md | 18 +- extension/entrypoints/sidepanel/App.tsx | 55 +++++- extension/src/api/client.ts | 153 +++++----------- server/api/collection.py | 166 ------------------ server/api/generate.py | 31 ++-- server/api/products.py | 71 -------- server/api/suites.py | 141 +++------------ server/db.py | 59 ------- server/main.py | 19 +- server/models.py | 119 ------------- server/requirements.txt | 2 - server/schemas.py | 88 ++-------- server/services/generator.py | 160 ++++++----------- server/services/prompt.py | 97 +++++----- server/services/tasks.py | 68 +++++++ start.command | 33 +++- 17 files changed, 411 insertions(+), 918 deletions(-) create mode 100644 .zcode/plans/plan-sess_21b5fc7d-daa9-480a-854b-be36b67412a8.md delete mode 100644 server/api/collection.py delete mode 100644 server/api/products.py delete mode 100644 server/db.py delete mode 100644 server/models.py create mode 100644 server/services/tasks.py diff --git a/.zcode/plans/plan-sess_21b5fc7d-daa9-480a-854b-be36b67412a8.md b/.zcode/plans/plan-sess_21b5fc7d-daa9-480a-854b-be36b67412a8.md new file mode 100644 index 0000000..441d8bb --- /dev/null +++ b/.zcode/plans/plan-sess_21b5fc7d-daa9-480a-854b-be36b67412a8.md @@ -0,0 +1,49 @@ +# 移除数据库,纯内存任务表 + 串行生成队列(无恢复功能,无鉴权) + +确认结论:不做"恢复进行中任务"则数据库无不可替代用途 —— 轮询用进程内任务表,历史记录功能不存在,重启时任务本来就会死(数据库只是把提示从"任务中断"换成"生成失败")。多用户并发使用单后端实例不受影响。 + +## A. 新增 `server/services/tasks.py` — 内存任务注册表 + +- `@dataclass TaskImage`(type_id/name/status/url/error) +- `@dataclass Task`:id、status(pending|running|done|partial|failed)、platform/lang/ratio、style_set、style_prompt、requirements、provider、model、total、images、error,以及执行参数 context/plan/ref_images +- 模块级 `_TASKS: dict[str, Task]`;asyncio 单事件循环内读写,无并发问题 +- `total` 语义保持:计划总张数;`images` 逐张追加(前端进度 x/y 依赖) + +## B. 改造 `server/services/generator.py` + +- `run_suite(task: Task)` 接收内存任务,不再查库;每张生成后 `task.images.append(...)`;`storage.write_bytes`(文件系统)不动 +- **串行生成队列**:模块级 `asyncio.Lock`,拿锁后才置 running;多用户同时提交时后续任务保持 pending(前端已显示"排队中"),避免共享 API key 触发 rightapi 同 key 分钟级冷却 +- 删除:商品路径分支(product 加载、`_select_ref_images`)、SuiteImage/Suite 读写、`fail_stale_suites`(重启后内存为空,轮询自然 404,前端已有"任务已中断"提示) + +## C. 改造 `server/api/generate.py` + +- `POST /api/generate`:原 Suite 构建逻辑(texts_to_raw、plan 展开、ref_images 排序、模型路由校验)平移到 Task 对象,存入 `_TASKS`,`background.add_task(run_suite, task)` +- `POST /api/plan` 不动(本就不碰数据库) + +## D. 改造 `server/api/suites.py` + +- 保留 `GET /api/suites/{id}`(读内存,不存在 404「任务不存在(服务可能已重启)」)、`GET /api/suites/{id}/zip`(从 Task.images 打包成功图) +- 删除两个商品挂载端点 + +## E. 删除文件与依赖 + +- 删除:`server/db.py`、`server/models.py`(4 张表)、`server/api/collection.py`、`server/api/products.py` +- `server/main.py`:去掉 lifespan/init_db/fail_stale_suites 与对应路由 +- `server/schemas.py`:删除商品路径与 materials 类型(SuiteCreateRequest、MaterialsRequest/Response、ProductOut/AssetOut/ProductListOut);保留 TextMaterial、GenerateRequest、SuiteOut 契约(前端零改动) +- `server/requirements.txt`:删 `sqlalchemy[asyncio]`、`aiosqlite` +- 前端 `client.ts`:删除 materials 死代码(buildMaterialsPayload/uploadMaterials 及类型) + +## F. README + +架构说明更新:进程内任务表、重启即新会话(进行中任务中断,前端有提示)、数据目录只剩 media/;标注接口暂无鉴权,公网暴露前需内网/反代白名单,登录鉴权后续版本补充;将来若需恢复任务/历史记录/多实例,再引入数据库(任务表结构简单,迁移成本低) + +## 不改的部分 + +- 前端交互/UI、生图 provider、prompt 逻辑、`data/media/` 图片文件 +- `data/app.db` 数据文件保留(不再被使用,可自行删除) + +## 验证 + +1. `py_compile` 后端改动文件;`tsc --noEmit` + 前端 build +2. 零成本链路测试(count=0 的 plan,不实际生图):提交 → 轮询 done/0 张 → 不存在的 id 返回 404 +3. `start.command` 重启,health 正常 \ No newline at end of file diff --git a/README.md b/README.md index e8e7a1d..dd0f084 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Chrome 插件 + Python 后端:采集 Ozon / 1688 / 淘宝 / 天猫 商品页 ## 架构 ``` -Chrome 插件(WXT + React + antd) Python 后端(FastAPI + SQLite) +Chrome 插件(WXT + React + antd) Python 后端(FastAPI,无数据库) ┌────────────────────────────┐ ┌──────────────────────────────┐ │ 页内悬浮面板 │ │ POST /api/plan │ │ ① 扫描商品页(四站点) │ ─规划──▶ │ → DeepSeek 出图方案 │ @@ -20,6 +20,17 @@ Chrome 插件(WXT + React + antd) Python 后端(FastAPI + SQLite └──────────────────────────────┘ ``` +### 任务与存储(无数据库设计) + +- 生成任务存**进程内内存注册表**(`services/tasks.py`):轮询/导出只服务当前会话正在跟踪的任务, + 重启即新会话(进行中任务中断,前端会提示"任务已中断,请重新生成")—— 前端没有历史记录功能, + 任务状态无需跨进程持久化 +- **串行生成队列**:所有用户共享同一批 API key,同一时间只跑一个任务,其余排队(pending), + 避免触发中转限流;多用户并发提交互不干扰(任务按 id 隔离,单实例部署) +- 图片本体全部落文件系统 `data/media/`(`/media` 静态托管),ZIP 导出直接读文件 +- 接口暂无鉴权:公网暴露前需内网/反代白名单限制,登录鉴权后续版本补充; + 将来若需任务恢复/历史记录/多实例部署,再引入数据库(任务表结构简单,迁移成本低) + ### 采集引擎(extension/src) - 声明式 `SiteProfile`(选择器 + srcProps + 去重/排除规则),加站点只需加一个 profile: @@ -36,12 +47,13 @@ Chrome 插件(WXT + React + antd) Python 后端(FastAPI + SQLite - `prompt.py`:7 种图类型 × 5 套风格模板,公共组件 QUALITY / PRODUCT_REF_LOCK(商品一致性锁)/ TEXT_RENDER - 图类型:白底主图 / 核心卖点图 / 卖点图 / 材质图 / 场景展示图 / 多场景拼图 / 电商详情图 - - 风格:经典商拍 / 生活杂志 / 极简高冷 / 活力爆款 / 暗调质感 + - 风格:北欧极简 / 清新明亮 / 高级感深色 / 暖调生活 / 纯净棚拍 - 卖点从采集的参数表/卖点文本自动提炼 - `generator.py`:图像 provider(图生图,参考图 = 采集主图) - `doubao`:火山方舟 Seedream(默认,`ARK_API_KEY`) - `tongyi`:通义万相/千问(`DASHSCOPE_API_KEY`,wan* 异步轮询 / qwen* 同步) - - `rightapi`:gpt-image-2(OpenAI 兼容中转 `RIGHTAPI_API_KEY`,edits 参考图 / generations 同步) + - `rightapi`:gpt-image-2 / gpt-image-2-vip / nano-banana / nano-banana-2 / nano-banana-2-lite / nano-banana-pro(OpenAI 兼容中转 `RIGHTAPI_API_KEY`,edits 参考图 / generations 同步) + - ⚠️ `gpt-image-2-vip` 为官逆通道:不透传保真参数、参考图被弱化,商品还原度不稳定(生成前有警示);正式出图用 `gpt-image-2` - 插件只传模型名,服务端按模型名自动路由到对应 provider ## 快速开始 diff --git a/extension/entrypoints/sidepanel/App.tsx b/extension/entrypoints/sidepanel/App.tsx index 07d5ec4..98f729b 100644 --- a/extension/entrypoints/sidepanel/App.tsx +++ b/extension/entrypoints/sidepanel/App.tsx @@ -15,7 +15,7 @@ import { SettingOutlined, DownloadOutlined, ThunderboltOutlined, UploadOutlined, import type { ScanResult, ImageMaterial } from '../../src/collector/scan'; import { buildGeneratePayload, suiteZipUrl, uploadImage, - DEFAULT_PLAN, IMAGE_MODEL_OPTIONS, PLATFORM_OPTIONS, PLATFORM_SPECS, STYLE_SET_OPTIONS, + DEFAULT_PLAN, IMAGE_MODEL_OPTIONS, PLATFORM_OPTIONS, PLATFORM_SPECS, STYLE_SET_OPTIONS, WEAK_FIDELITY_MODELS, type PlanItem, type SuiteInfo, } from '../../src/api/client'; import { loadSettings, saveSettings, type BackendSettings } from '../../src/storage/settings'; @@ -280,10 +280,24 @@ const App: React.FC = () => { const pollSuite = useCallback((suiteId: string) => { stopPolling(); + const startedAt = Date.now(); + let failCount = 0; // 连续轮询失败计数(容忍瞬时抖动,连续 3 次才判定后端不可达) + let timeoutWarned = false; pollRef.current = setInterval(async () => { try { const s = await send('getSuite', { baseUrl: settings.baseUrl, token: settings.token, suiteId }); setSuite(s); + failCount = 0; + // 超时兜底:单张最长约 5 分钟,超预算仍 running 多半是任务已中断(如后端重启) + const budgetMs = ((s.total ?? 1) * 5 + 10) * 60_000; + if (!timeoutWarned && ['running', 'pending'].includes(s.status) && Date.now() - startedAt > budgetMs) { + timeoutWarned = true; + modal.warning({ + title: '任务耗时异常', + content: '生成耗时远超预期,任务可能已中断。可稍等片刻观察,或刷新页面后重新生成。', + okText: '知道了', + }); + } if (['done', 'partial', 'failed'].includes(s.status)) { stopPolling(); setGenerating(false); @@ -291,8 +305,16 @@ const App: React.FC = () => { if (s.status === 'failed') modal.error({ title: '生成失败', content: s.error || '未知错误', okText: '知道了' }); } } catch (e) { - stopPolling(); - setGenerating(false); + failCount += 1; + if (failCount >= 3) { + stopPolling(); + setGenerating(false); + modal.error({ + title: '无法连接后端', + content: '连续查询生成进度失败,已停止跟踪。若刚重启过后端(./start.command),任务已被中断,请重新生成。', + okText: '知道了', + }); + } } }, 3000); }, [settings.baseUrl, settings.token, modal]); @@ -396,7 +418,8 @@ const App: React.FC = () => { const totalPlanned = plan.reduce((s, i) => s + i.count, 0); const doneCount = suite?.images.filter(i => i.status === 'ok').length ?? 0; - const suiteTotal = suite?.images.length ?? totalPlanned; + // 分母用后端返回的计划总张数:images 是逐张落库的,生成中 length 永远等于已完成数 + const suiteTotal = suite?.total ?? totalPlanned; /** 当前风格生效的提示词:用户改写值 > 该风格默认值 */ const currentStylePrompt = stylePrompts[styleSet] ?? STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.prompt ?? ''; @@ -408,7 +431,21 @@ const App: React.FC = () => { const spec = PLATFORM_SPECS[platform]; modal.confirm({ title: '生成电商套图', - content: `目标平台「${spec.label}」(${spec.lang}文案 · ${spec.ratio}),模型「${model}」,风格「${STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.label}」,共 ${totalPlanned} 张、参考图 ${selectedKeys.size} 张。生成需要几分钟,可在下方查看进度。`, + content: ( +
+
+ 目标平台「{spec.label}」({spec.lang}文案 · {spec.ratio}),模型「{model}」,风格「 + {STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.label}」,共 {totalPlanned} 张、参考图{' '} + {selectedKeys.size} 张。生成需要几分钟,可在下方查看进度。 +
+ {WEAK_FIDELITY_MODELS.has(model) && ( +
+ ⚠️ 「{model}」为官逆通道,商品还原度不稳定,可能生成与原商品不符的图片。建议先只出 1 + 张确认效果,正式套图请改用 gpt-image-2。 +
+ )} +
+ ), okText: '开始生成', cancelText: '取消', onOk: () => startGenerate(), }); @@ -796,10 +833,11 @@ const App: React.FC = () => {
{generating && ( -
+
+ {/* 进度条与右侧弹性占位 6:1,长度 = 剩余空间的 6/7(两次加长后的累积比例) */} `${doneCount}/${suiteTotal}`} + size={['100%', 10]} status="active" format={() => `${doneCount}/${suiteTotal}`} />
)} @@ -822,6 +860,7 @@ const App: React.FC = () => {
{suite.images.map(img => ( diff --git a/extension/src/api/client.ts b/extension/src/api/client.ts index 12d4a6c..cbb5f7e 100644 --- a/extension/src/api/client.ts +++ b/extension/src/api/client.ts @@ -1,29 +1,8 @@ /** * 后端 HTTP 客户端 —— 仅 background 使用(有 host_permissions,不受 CORS 约束)。 - * 契约对齐 server 端 /api/materials 与 /api/suites。 + * 契约对齐 server 端 /api/plan、/api/generate 与 /api/suites。 */ -import type { ScanResult, ImageMaterial } from '../collector/scan'; - -export interface MaterialsPayload { - product_id: string | null; - source: { - platform: string; - itemId: string | null; - url: string; - collectedAt: number; - }; - texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }>; - images: Array<{ - groupKey: string; - groupName: string; - variantName?: string | null; - url: string; - index: number; - type: string; - dedupeKey?: string | null; - }>; - refererOrigin?: string; -} +import type { ImageMaterial } from '../collector/scan'; /** 服务端支持的套图类型(与 server/services/prompt.py 保持一致) */ export const SUITE_TYPE_OPTIONS = [ @@ -58,41 +37,37 @@ export const DEFAULT_PLAN: PlanItem[] = SUITE_TYPE_OPTIONS.slice(0, 7).map(t => export const STYLE_SET_OPTIONS = [ { value: 1, - label: '高级质感大片', - prompt: '高端电商大片质感,柔和的方向性棚拍光,背景带细腻的浅渐变,材质纹理清晰可见,色彩层次高级克制,商业画册级品质,构图干净、留白充足', + label: '北欧极简', + prompt: '北欧极简风:浅灰或米白背景,柔和漫射光,低饱和色调,画面留白充足,构图克制干净', }, { value: 2, - label: '清新生活场景', - prompt: '明亮通透的生活场景摄影,自然窗光,柔和的低饱和居家环境,浅景深虚化,真实自然的氛围感,绿植与暖色织物点缀,温馨有人气', + label: '清新明亮', + prompt: '清新明亮风:明亮的白色到浅蓝渐变背景,高调光线,色彩明快通透,整体轻盈干净', }, { value: 3, - label: '极简白底规范', - prompt: '极简棚拍风格,纯净无缝的浅色背景,柔和均匀的无影布光,以商品为中心的严谨构图,安静的高级感,画面只保留轻微的自然接触投影', + label: '高级感深色', + prompt: '高级质感风:深灰或炭黑背景,戏剧性侧光打光,突出商品材质与光泽,沉稳高级', }, { value: 4, - label: '炫彩促销风', - prompt: '高能量促销风格,高饱和度色块背景搭配动感几何图形,强对比,节日大促海报氛围,构图抢眼、视觉冲击力强', + label: '暖调生活', + prompt: '温暖生活风:暖米色背景,暖色灯光氛围,温馨的家居质感,亲和力强', }, { value: 5, - label: '暗调轻奢', - prompt: '暗调轻奢质感,深炭灰色背景,轮廓光勾勒商品边缘,材质细节丰富,带轻微雾感,如美术馆展陈般的呈现', - }, - { - value: 6, - label: '俄式风情', - prompt: '俄式风情电商大片,浓郁温暖的色调,红与金的传统配色点缀,冬日节庆氛围,深色木质与毛毡织物背景,如暖炉烛光般的柔和光晕,厚重扎实的质感,带一丝巴洛克式的华丽细节,适合俄语区市场', - }, - { - value: 7, - label: '北欧极简', - prompt: '北欧极简风格,白色与浅灰的原木空间,大量自然漫射光,干净利落的线条,浅色木质背景点缀少量绿植,克制的中性配色,画面通透轻盈,舒适宁静的氛围', + label: '纯净棚拍', + prompt: '标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出', }, ] as const; +/** + * 官逆/弱保真通道模型:链路不透传保真参数(input_fidelity 等)、参考图被弱化, + * 商品还原度不稳定,生成前需警示用户。链路性质决定,prompt 只能缓解不能根除。 + */ +export const WEAK_FIDELITY_MODELS = new Set(['gpt-image-2-vip']); + /** 目标平台(决定文案语言 + 图片比例):Ozon/Wildberries → 俄文 3:4,中文 → 中文 1:1 */ export const PLATFORM_OPTIONS = [ { value: 'ozon', label: 'Ozon' }, @@ -129,6 +104,31 @@ export const IMAGE_MODEL_OPTIONS = [ label: 'gpt-image-2', desc: 'GPT 图像模型,构图与图内文案渲染最强,参考图高保真,单张 1-5 分钟', }, + { + value: 'gpt-image-2-vip', + label: 'gpt-image-2-vip', + desc: 'GPT 官逆低价通道,构图与文字渲染强,但商品还原不稳定(官逆链路限制),试错可用,正式出图建议 gpt-image-2', + }, + { + value: 'nano-banana', + label: 'nano-banana', + desc: 'Google Gemini 图像模型,出图极快,图像编辑与风格迁移强,多图融合自然', + }, + { + value: 'nano-banana-2', + label: 'nano-banana-2', + desc: 'Google 新一代图像模型,画质与文字渲染大幅提升,日常生成与改图的综合首选', + }, + { + value: 'nano-banana-2-lite', + label: 'nano-banana-2-lite', + desc: 'nano-banana-2 轻量版,约 4 秒/张、成本极低,适合大批量出图与快速试错', + }, + { + value: 'nano-banana-pro', + label: 'nano-banana-pro', + desc: 'Google 最高保真旗舰,细节最强、支持 4K 输出,适合商业级精修大片', + }, ] as const; export const PLATFORM_SPECS: Record = { @@ -147,86 +147,21 @@ export interface SuiteImageInfo { export interface SuiteInfo { id: string; - product_id: string; status: 'pending' | 'running' | 'done' | 'partial' | 'failed'; style_set: number; platform: string; lang: string; ratio: string; - types: string[]; provider: string; + total?: number; // 计划生成总张数(后端返回;images 逐张追加,过程中 length < total) images: SuiteImageInfo[]; error?: string | null; } -/** 用(可能已二次修改的)文本 + 已勾选图片,组装 /api/materials 请求体 */ -export function buildMaterialsPayload( - result: ScanResult, - selectedKeys: Set, - edits?: { title?: string; desc?: string }, -): MaterialsPayload { - const orig = (kind: string) => result.texts.find((t) => t.kind === kind); - - const texts: MaterialsPayload['texts'] = []; - const title = edits?.title ?? orig('title')?.content ?? ''; - const price = orig('price')?.content ?? ''; - const brand = orig('brand')?.content ?? ''; - const params = orig('params')?.pairs ?? []; - const sellingPoints = orig('selling_point')?.content ?? ''; - const desc = edits?.desc ?? orig('desc')?.content ?? ''; - - if (title) texts.push({ kind: 'title', content: title }); - if (price) texts.push({ kind: 'price', content: price }); - if (brand) texts.push({ kind: 'brand', content: brand }); - if (params.length) texts.push({ kind: 'params', content: '', pairs: params }); - if (sellingPoints) texts.push({ kind: 'selling_point', content: sellingPoints }); - if (desc) texts.push({ kind: 'desc', content: desc }); - - const images = result.images - .filter((img) => selectedKeys.has(img.key)) - .map((img) => ({ - groupKey: img.groupKey, - groupName: img.groupName, - variantName: img.variantName ?? null, - url: img.url, - index: img.index, - type: img.type, - dedupeKey: img.url, - })); - - return { - product_id: null, - source: { - platform: result.platform, - itemId: result.itemId, - url: result.url, - collectedAt: result.scannedAt, - }, - texts, - images, - refererOrigin: undefined, - }; -} - function authHeaders(token: string): Record { return token ? { Authorization: `Bearer ${token}` } : {}; } -export async function uploadMaterials( - baseUrl: string, - token: string, - payload: MaterialsPayload, -): Promise<{ product_id: string; assets_queued: number; assets_skipped: number }> { - const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/materials`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...authHeaders(token) }, - body: JSON.stringify(payload), - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data?.detail || `上传失败 HTTP ${res.status}`); - return data; -} - /** 无状态生成请求体:采集数据 + 勾选图片 + 出图方案,一次携带 */ export interface GeneratePayload { texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }>; diff --git a/server/api/collection.py b/server/api/collection.py deleted file mode 100644 index 8f6a731..0000000 --- a/server/api/collection.py +++ /dev/null @@ -1,166 +0,0 @@ -"""采集入库:插件上传文本 + 图片 URL,落库后异步转存。""" -from __future__ import annotations - -import re -from uuid import UUID - -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from db import get_db, get_session_factory -from models import ( - Product, ProductAsset, - STATUS_PENDING, STATUS_DOWNLOADING, STATUS_OK, STATUS_FAILED, STAGE_COLLECTED, -) -from schemas import MaterialsRequest, MaterialsResponse, TextMaterial -from services import storage - -router = APIRouter(prefix="/api", tags=["collection"]) - - -def _parse_number(text: str | None) -> float | None: - """'1 290 ₽' / '¥36.80' → 1290.0 / 36.8""" - if not text: - return None - m = re.search(r"(\d+(?:[.,]\d+)?)", text.replace(" ", "").replace(",", ".")) - return float(m.group(1)) if m else None - - -def _apply_texts(product: Product, texts: list[TextMaterial]) -> None: - raw = dict(product.raw or {}) - raw_texts: list[dict] = list(raw.get("texts") or []) - for t in texts: - raw_texts.append({"kind": t.kind, "content": t.content, "pairs": t.pairs}) - if t.kind == "title" and t.content and not product.name: - product.name = t.content - raw["title"] = t.content - elif t.kind == "price": - raw["price"] = t.content - num = _parse_number(t.content) - if num is not None and (product.price is None or product.price == 0): - product.price = num - elif t.kind == "params" and t.pairs: - # 与已有参数按 key 并集合并(跨页追加时同一参数不重复) - merged = {p["key"]: p["value"] for p in (raw.get("params") or [])} - for p in t.pairs: - merged.setdefault(p["key"], p["value"]) - raw["params"] = [{"key": k, "value": v} for k, v in merged.items()] - elif t.kind == "selling_point": - raw["sellingPoints"] = t.content - elif t.kind == "desc": - raw["desc"] = t.content - if not product.description: - product.description = t.content - elif t.kind == "brand": - raw["brand"] = t.content - raw["texts"] = raw_texts - product.raw = raw - - -async def _get_or_create_product(db: AsyncSession, req: MaterialsRequest) -> Product: - if req.product_id: - product = await db.get(Product, UUID(req.product_id)) - if product is None: - raise HTTPException(status_code=404, detail="商品不存在") - return product - product = Product( - stage=STAGE_COLLECTED, - source_platform=req.source.platform, - source_item_id=req.source.itemId, - source_url=req.source.url, - ) - db.add(product) - await db.flush() - return product - - -@router.post("/materials", response_model=MaterialsResponse) -async def create_materials( - req: MaterialsRequest, - background: BackgroundTasks, - db: AsyncSession = Depends(get_db), -) -> MaterialsResponse: - product = await _get_or_create_product(db, req) - _apply_texts(product, req.texts) - - if not product.source_url: - product.source_url = req.source.url - if not product.source_platform: - product.source_platform = req.source.platform - - # 去重 + 建素材 - existing: set[str] = set() - if req.images: - rows = (await db.execute( - select(ProductAsset.dedupe_key).where( - ProductAsset.product_id == product.id, - ProductAsset.dedupe_key.isnot(None), - ) - )).scalars().all() - existing = {k for k in rows if k} - - queued, skipped = 0, 0 - for img in req.images: - if img.dedupeKey and img.dedupeKey in existing: - skipped += 1 - continue - db.add(ProductAsset( - product_id=product.id, - group_key=img.groupKey, - variant_name=img.variantName, - sort_order=img.index, - type=img.type, - source_url=img.url, - status=STATUS_PENDING, - dedupe_key=img.dedupeKey, - )) - if img.dedupeKey: - existing.add(img.dedupeKey) - queued += 1 - - # 更新分组计数 - counts: dict = {} - for a in await db.scalars(select(ProductAsset).where(ProductAsset.product_id == product.id)): - counts[a.group_key] = counts.get(a.group_key, 0) + 1 - product.asset_counts = counts - - await db.commit() - await db.refresh(product) - - if queued: - background.add_task(process_product_assets, str(product.id)) - return MaterialsResponse(product_id=str(product.id), assets_queued=queued, assets_skipped=skipped) - - -async def process_product_assets(product_id: str) -> None: - """后台:下载 pending 素材 → 转存本地 media。失败逐张标记,不中断。""" - async with get_session_factory()() as db: - assets = (await db.scalars( - select(ProductAsset).where( - ProductAsset.product_id == UUID(product_id), - ProductAsset.status == STATUS_PENDING, - ProductAsset.type == "img", - ) - )).all() - for a in assets: - a.status = STATUS_DOWNLOADING - await db.commit() - try: - a.stored_url = await storage.save_from_url(a.source_url, key_prefix="assets") - a.status = STATUS_OK - except Exception as exc: # noqa: BLE001 - a.status = STATUS_FAILED - a.error = str(exc)[:500] - await db.commit() - - -@router.get("/collected") -async def is_collected(platform: str, itemId: str, db: AsyncSession = Depends(get_db)): - rows = (await db.execute( - select(Product.id).where( - Product.source_platform == platform, - Product.source_item_id == itemId, - ) - )).scalars().all() - return {"collected": len(rows) > 0, "count": len(rows)} diff --git a/server/api/generate.py b/server/api/generate.py index a994090..31c9977 100644 --- a/server/api/generate.py +++ b/server/api/generate.py @@ -1,11 +1,9 @@ -"""无状态套图生成:请求自带采集数据,不落商品库。""" +"""无状态套图生成:请求自带采集数据,任务存进程内注册表。""" from __future__ import annotations -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from fastapi import APIRouter, BackgroundTasks, HTTPException from config import get_settings -from db import get_db -from models import Suite from schemas import ( GenerateRequest, PLATFORM_SPECS, SUPPORTED_TYPES, TONGYI_MODELS, RIGHTAPI_MODELS, SuiteCreateResponse, TextMaterial, resolve_provider, @@ -14,6 +12,7 @@ from schemas import ( from services.generator import run_suite from services.planner import generate_plan from services.prompt import type_name +from services.tasks import create_task router = APIRouter(prefix="/api", tags=["generate"]) @@ -48,7 +47,6 @@ def texts_to_raw(texts: list[TextMaterial]) -> dict: async def generate_suite( req: GenerateRequest, background: BackgroundTasks, - db=Depends(get_db), ) -> SuiteCreateResponse: if not req.images: raise HTTPException(status_code=400, detail="未勾选任何图片,无法生成") @@ -72,7 +70,6 @@ async def generate_suite( }) if not jobs: raise HTTPException(status_code=400, detail="方案中所有项的数量都是 0") - types = list(dict.fromkeys(j["kind"] for j in jobs)) else: types = req.types or ["white_bg", "key_features", "lifestyle", "multi_scene"] bad = [t for t in types if t not in SUPPORTED_TYPES] @@ -92,19 +89,20 @@ async def generate_suite( raise HTTPException(status_code=400, detail=f"不支持的模型: {model}(tongyi 支持: {TONGYI_MODELS})") if provider_name == "rightapi" and model and model not in RIGHTAPI_MODELS: raise HTTPException(status_code=400, detail=f"不支持的模型: {model}(rightapi 支持: {RIGHTAPI_MODELS})") - suite = Suite( - product_id=None, - style_set=req.style_set, - style_prompt=req.style_prompt, - requirements=req.requirements, + + task = create_task( + status="pending", platform=req.platform, lang=spec["lang"], ratio=spec["ratio"], - types=types, - plan=jobs, + style_set=req.style_set, + style_prompt=req.style_prompt, + requirements=req.requirements, provider=provider_name, model=model, + total=len(jobs), context=texts_to_raw(req.texts), + plan=jobs, # 参考图池:main 组优先,其余组按序补充(variant 绑定靠 variant_name 匹配) ref_images=[ { @@ -115,12 +113,9 @@ async def generate_suite( for i in sorted(req.images, key=lambda x: 0 if x.group_key == "main" else 1) ], ) - db.add(suite) - await db.commit() - await db.refresh(suite) - background.add_task(run_suite, str(suite.id)) - return SuiteCreateResponse(suite_id=str(suite.id)) + background.add_task(run_suite, task) + return SuiteCreateResponse(suite_id=task.id) @router.post("/plan", response_model=PlanResponse) diff --git a/server/api/products.py b/server/api/products.py deleted file mode 100644 index 86874bf..0000000 --- a/server/api/products.py +++ /dev/null @@ -1,71 +0,0 @@ -"""商品查询 API。""" -from __future__ import annotations - -from uuid import UUID - -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from db import get_db -from models import Product, ProductAsset -from schemas import AssetOut, ProductListOut, ProductOut -from services import storage - -router = APIRouter(prefix="/api", tags=["products"]) - - -def _asset_out(a: ProductAsset) -> AssetOut: - return AssetOut( - id=str(a.id), - group_key=a.group_key, - variant_name=a.variant_name, - type=a.type, - source_url=a.source_url, - url=a.stored_url, - status=a.status, - ) - - -@router.get("/products", response_model=ProductListOut) -async def list_products( - q: str = "", - page: int = 1, - page_size: int = 20, - db: AsyncSession = Depends(get_db), -): - cond = [] - if q: - cond.append(Product.name.contains(q)) - total = (await db.scalar(select(func.count()).select_from(Product).where(*cond))) or 0 - rows = (await db.scalars( - select(Product).where(*cond).order_by(Product.created_at.desc()) - .offset((page - 1) * page_size).limit(page_size) - )).all() - return ProductListOut(total=total, items=[ - ProductOut( - id=str(p.id), stage=p.stage, source_platform=p.source_platform, - source_item_id=p.source_item_id, source_url=p.source_url, - name=p.name, description=p.description, price=p.price, - asset_counts=p.asset_counts, - created_at=p.created_at.isoformat() if p.created_at else None, - ) for p in rows - ]) - - -@router.get("/products/{product_id}", response_model=ProductOut) -async def get_product(product_id: str, db: AsyncSession = Depends(get_db)): - p = await db.get(Product, UUID(product_id)) - if p is None: - raise HTTPException(status_code=404, detail="商品不存在") - assets = (await db.scalars( - select(ProductAsset).where(ProductAsset.product_id == p.id) - .order_by(ProductAsset.sort_order) - )).all() - return ProductOut( - id=str(p.id), stage=p.stage, source_platform=p.source_platform, - source_item_id=p.source_item_id, source_url=p.source_url, - name=p.name, description=p.description, price=p.price, - asset_counts=p.asset_counts, assets=[_asset_out(a) for a in assets], - created_at=p.created_at.isoformat() if p.created_at else None, - ) diff --git a/server/api/suites.py b/server/api/suites.py index 0b728c2..3236c26 100644 --- a/server/api/suites.py +++ b/server/api/suites.py @@ -1,143 +1,60 @@ -"""套图生成 API:创建任务 / 查询状态 / 导出 ZIP。""" +"""套图任务 API:轮询进度 / 导出 ZIP(进程内内存任务表)。""" from __future__ import annotations import io import zipfile -from uuid import UUID -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession -from config import get_settings -from db import get_db -from models import Product, ProductAsset, Suite, SuiteImage, STATUS_OK -from schemas import ( - PLATFORM_SPECS, SUPPORTED_TYPES, TONGYI_MODELS, RIGHTAPI_MODELS, - SuiteCreateRequest, SuiteCreateResponse, SuiteImageOut, SuiteOut, resolve_provider, -) +from schemas import SuiteImageOut, SuiteOut from services import storage -from services.generator import run_suite +from services.tasks import IMG_OK, Task, get_task router = APIRouter(prefix="/api", tags=["suites"]) -async def _suite_out(db: AsyncSession, suite: Suite) -> SuiteOut: - images = (await db.scalars( - select(SuiteImage).where(SuiteImage.suite_id == suite.id) - .order_by(SuiteImage.created_at) - )).all() +def _task_out(task: Task) -> SuiteOut: return SuiteOut( - id=str(suite.id), - product_id=str(suite.product_id), - status=suite.status, - style_set=suite.style_set, - platform=suite.platform, - lang=suite.lang, - ratio=suite.ratio, - types=list(suite.types or []), - provider=suite.provider, - model=suite.model, + id=task.id, + status=task.status, + style_set=task.style_set, + platform=task.platform, + lang=task.lang, + ratio=task.ratio, + provider=task.provider, + model=task.model, + total=task.total, images=[ SuiteImageOut( - type_id=i.type_id, name=i.name, url=i.stored_url or "", + type_id=i.type_id, name=i.name, url=i.url, status=i.status, error=i.error, - ) for i in images + ) for i in task.images ], - error=suite.error, + error=task.error, ) -@router.post("/products/{product_id}/suites", response_model=SuiteCreateResponse) -async def create_suite( - product_id: str, - req: SuiteCreateRequest, - background: BackgroundTasks, - db: AsyncSession = Depends(get_db), -): - product = await db.get(Product, UUID(product_id)) - if product is None: - raise HTTPException(status_code=404, detail="商品不存在") - - # 主图组至少一张图(不要求转存完成:生图可直接用源站 URL 代理解析) - ok_assets = (await db.scalars( - select(ProductAsset.id).where( - ProductAsset.product_id == product.id, - ProductAsset.group_key == "main", - ProductAsset.type == "img", - ) - )).all() - if not ok_assets: - raise HTTPException(status_code=400, detail="商品没有主图,无法生成") - - bad = [t for t in req.types if t not in SUPPORTED_TYPES] - if bad: - raise HTTPException(status_code=400, detail=f"不支持的图类型: {bad}") - if req.platform not in PLATFORM_SPECS: - raise HTTPException(status_code=400, detail=f"不支持的目标平台: {req.platform}(ozon | wb | cn)") - spec = PLATFORM_SPECS[req.platform] - - settings = get_settings() - # 模型名优先路由:已知模型直接定位 provider(gpt-image-2 → rightapi) - provider_name = resolve_provider(req.model, req.provider, settings.image_provider) - if provider_name == "tongyi" and req.model and req.model not in TONGYI_MODELS: - raise HTTPException(status_code=400, detail=f"不支持的模型: {req.model}(tongyi 支持: {TONGYI_MODELS})") - if provider_name == "rightapi" and req.model and req.model not in RIGHTAPI_MODELS: - raise HTTPException(status_code=400, detail=f"不支持的模型: {req.model}(rightapi 支持: {RIGHTAPI_MODELS})") - suite = Suite( - product_id=product.id, - style_set=req.style_set, - requirements=req.requirements, - platform=req.platform, - lang=spec["lang"], - ratio=spec["ratio"], - types=req.types, - provider=provider_name, - model=req.model, - ) - db.add(suite) - await db.commit() - await db.refresh(suite) - - background.add_task(run_suite, str(suite.id)) - return SuiteCreateResponse(suite_id=str(suite.id)) - - @router.get("/suites/{suite_id}", response_model=SuiteOut) -async def get_suite(suite_id: str, db: AsyncSession = Depends(get_db)): - suite = await db.get(Suite, UUID(suite_id)) - if suite is None: - raise HTTPException(status_code=404, detail="任务不存在") - return await _suite_out(db, suite) - - -@router.get("/products/{product_id}/suites") -async def list_suites(product_id: str, db: AsyncSession = Depends(get_db)): - suites = (await db.scalars( - select(Suite).where(Suite.product_id == UUID(product_id)) - .order_by(Suite.created_at.desc()) - )).all() - return [await _suite_out(db, s) for s in suites] +async def get_suite(suite_id: str): + task = get_task(suite_id) + if task is None: + raise HTTPException(status_code=404, detail="任务不存在(服务可能已重启),请重新生成") + return _task_out(task) @router.get("/suites/{suite_id}/zip") -async def download_suite_zip(suite_id: str, db: AsyncSession = Depends(get_db)): +async def download_suite_zip(suite_id: str): """把任务内所有成功图打包成 ZIP(中文文件名)。""" - suite = await db.get(Suite, UUID(suite_id)) - if suite is None: - raise HTTPException(status_code=404, detail="任务不存在") - images = (await db.scalars( - select(SuiteImage).where( - SuiteImage.suite_id == suite.id, SuiteImage.status == STATUS_OK, - ).order_by(SuiteImage.created_at) - )).all() + task = get_task(suite_id) + if task is None: + raise HTTPException(status_code=404, detail="任务不存在(服务可能已重启)") buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: seen: set[str] = set() - for i, img in enumerate(images): - path = storage.local_path(img.stored_url or "") + for i, img in enumerate([i for i in task.images if i.status == IMG_OK]): + path = storage.local_path(img.url or "") if path is None: continue filename = img.name or img.type_id @@ -149,5 +66,5 @@ async def download_suite_zip(suite_id: str, db: AsyncSession = Depends(get_db)): return StreamingResponse( buf, media_type="application/zip", - headers={"Content-Disposition": f'attachment; filename="suite-{suite_id}.zip"'}, + headers={"Content-Disposition": f"attachment; filename=\"suite-{suite_id}.zip\""}, ) diff --git a/server/db.py b/server/db.py deleted file mode 100644 index d7fb1ee..0000000 --- a/server/db.py +++ /dev/null @@ -1,59 +0,0 @@ -"""数据库:SQLite(aiosqlite)+ SQLAlchemy async。""" -from __future__ import annotations - -from collections.abc import AsyncGenerator - -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine -from sqlalchemy.orm import DeclarativeBase - -from config import get_settings - - -class Base(DeclarativeBase): - pass - - -_engine = None -_session_factory: async_sessionmaker[AsyncSession] | None = None - - -def get_engine(): - global _engine, _session_factory - if _engine is None: - settings = get_settings() - db_path = f"{settings.data_dir}/app.db" - _engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", echo=False) - _session_factory = async_sessionmaker(_engine, expire_on_commit=False) - return _engine - - -def get_session_factory() -> async_sessionmaker[AsyncSession]: - get_engine() - assert _session_factory is not None - return _session_factory - - -async def get_db() -> AsyncGenerator[AsyncSession, None]: - async with get_session_factory()() as session: - yield session - - -async def _migrate(conn) -> None: - """给旧库补充新增列(create_all 不会修改已存在的表)。""" - rows = await conn.execute(text("PRAGMA table_info(suites)")) - cols = {row[1] for row in rows} - if "model" not in cols: - await conn.execute(text("ALTER TABLE suites ADD COLUMN model VARCHAR(64)")) - if "requirements" not in cols: - await conn.execute(text("ALTER TABLE suites ADD COLUMN requirements TEXT")) - - -async def init_db() -> None: - """启动时建表 + 轻量列迁移(MVP 不引 Alembic)。""" - import models # noqa: F401 确保模型注册 - - engine = get_engine() - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - await _migrate(conn) diff --git a/server/main.py b/server/main.py index 8e84991..0a83b55 100644 --- a/server/main.py +++ b/server/main.py @@ -2,27 +2,18 @@ from __future__ import annotations import logging -from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from api import collection, generate, products, proxy, suites, upload +from api import generate, proxy, suites, upload from config import get_settings -from db import init_db from services.storage import media_root logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") - -@asynccontextmanager -async def lifespan(app: FastAPI): - await init_db() - yield - - -app = FastAPI(title="电商套图工作台", version="0.1.0", lifespan=lifespan) +app = FastAPI(title="电商套图工作台", version="0.1.0") app.add_middleware( CORSMiddleware, @@ -31,14 +22,12 @@ app.add_middleware( allow_headers=["*"], ) -app.include_router(collection.router) -app.include_router(products.router) -app.include_router(suites.router) app.include_router(generate.router) +app.include_router(suites.router) app.include_router(proxy.router) app.include_router(upload.router) -# 静态托管生成的图片/转存素材 +# 静态托管生成的图片 app.mount("/media", StaticFiles(directory=str(media_root())), name="media") diff --git a/server/models.py b/server/models.py deleted file mode 100644 index f99dcb4..0000000 --- a/server/models.py +++ /dev/null @@ -1,119 +0,0 @@ -"""数据模型:Product(商品)/ ProductAsset(采集素材)/ Suite(套图任务)/ SuiteImage(生成图)。""" -from __future__ import annotations - -import uuid -from datetime import datetime - -from sqlalchemy import DateTime, Float, ForeignKey, Integer, JSON, String, Text, Uuid, func -from sqlalchemy.orm import Mapped, mapped_column - -from db import Base - -# 产品阶段 -STAGE_COLLECTED = "collected" -STAGE_GENERATED = "generated" - -# 素材/生成图状态 -STATUS_PENDING = "pending" -STATUS_DOWNLOADING = "downloading" -STATUS_OK = "ok" -STATUS_FAILED = "failed" - -# 套图任务状态 -SUITE_PENDING = "pending" -SUITE_RUNNING = "running" -SUITE_DONE = "done" -SUITE_PARTIAL = "partial" -SUITE_FAILED = "failed" - - -class Product(Base): - __tablename__ = "products" - - id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) - stage: Mapped[str] = mapped_column(String(16), default=STAGE_COLLECTED, index=True) - - # 采集溯源 - source_platform: Mapped[str | None] = mapped_column(String(16), nullable=True) # ozon | 1688 | taobao - source_item_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) - source_url: Mapped[str | None] = mapped_column(Text, nullable=True) - - name: Mapped[str] = mapped_column(Text, default="") - description: Mapped[str] = mapped_column(Text, default="") - price: Mapped[float | None] = mapped_column(Float, nullable=True) - - # 采集原文:{title, price, brand, params: [...], sellingPoints, desc, texts: [...]} - raw: Mapped[dict | None] = mapped_column(JSON, nullable=True) - asset_counts: Mapped[dict | None] = mapped_column(JSON, nullable=True) - - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), index=True - ) - - -class ProductAsset(Base): - """采集素材(源站图片,转存到本地 media)。""" - - __tablename__ = "product_assets" - - id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) - product_id: Mapped[uuid.UUID] = mapped_column( - Uuid(as_uuid=True), ForeignKey("products.id", ondelete="CASCADE"), index=True - ) - group_key: Mapped[str] = mapped_column(String(16), default="main") # main/sku/detail/video - variant_name: Mapped[str | None] = mapped_column(String(128), nullable=True) - sort_order: Mapped[int] = mapped_column(Integer, default=0) - type: Mapped[str] = mapped_column(String(8), default="img") # img / video - source_url: Mapped[str] = mapped_column(Text, default="") - stored_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 本地 media key 或公网 URL - status: Mapped[str] = mapped_column(String(16), default=STATUS_PENDING, index=True) - dedupe_key: Mapped[str | None] = mapped_column(String(512), nullable=True, index=True) - error: Mapped[str | None] = mapped_column(Text, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - - -class Suite(Base): - """一次套图生成任务(无状态:直接携带采集数据,不依赖商品库)。""" - - __tablename__ = "suites" - - id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) - # 兼容旧的商品挂载路径;工具化流程为空 - product_id: Mapped[uuid.UUID | None] = mapped_column( - Uuid(as_uuid=True), ForeignKey("products.id", ondelete="CASCADE"), nullable=True, index=True - ) - status: Mapped[str] = mapped_column(String(16), default=SUITE_PENDING, index=True) - style_set: Mapped[int] = mapped_column(Integer, default=1) # 风格模板 1-5 - style_prompt: Mapped[str | None] = mapped_column(Text, nullable=True) # 用户改写的风格提示词(覆盖模板) - requirements: Mapped[str | None] = mapped_column(Text, nullable=True) # 生图要求(最高优先级,强制约束) - platform: Mapped[str] = mapped_column(String(8), default="cn") # 目标平台 ozon | wb | cn - lang: Mapped[str] = mapped_column(String(4), default="zh") # ru / zh(由平台推导) - ratio: Mapped[str] = mapped_column(String(8), default="1:1") # 图片比例(由平台推导) - types: Mapped[list | None] = mapped_column(JSON, nullable=True) # 图类型 id 列表(旧) - plan: Mapped[list | None] = mapped_column(JSON, nullable=True) # 出图方案(展开后的逐张任务) - provider: Mapped[str] = mapped_column(String(16), default="doubao") - model: Mapped[str | None] = mapped_column(String(64), nullable=True) # 生图模型名(覆盖 provider 默认) - # 工具化流程:请求自带的数据(生图上下文 + 参考图 URL 列表) - context: Mapped[dict | None] = mapped_column(JSON, nullable=True) - ref_images: Mapped[list | None] = mapped_column(JSON, nullable=True) - error: Mapped[str | None] = mapped_column(Text, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - - -class SuiteImage(Base): - """任务里单张生成图。""" - - __tablename__ = "suite_images" - - id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) - suite_id: Mapped[uuid.UUID] = mapped_column( - Uuid(as_uuid=True), ForeignKey("suites.id", ondelete="CASCADE"), index=True - ) - type_id: Mapped[str] = mapped_column(String(32)) # white_bg / key_features / ... - name: Mapped[str] = mapped_column(String(64), default="") # 中文名(文件名) - stored_url: Mapped[str | None] = mapped_column(Text, nullable=True) - status: Mapped[str] = mapped_column(String(16), default=STATUS_PENDING) - error: Mapped[str | None] = mapped_column(Text, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/server/requirements.txt b/server/requirements.txt index a23b9eb..8efcfab 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -1,7 +1,5 @@ fastapi>=0.110 uvicorn[standard]>=0.29 -sqlalchemy[asyncio]>=2.0 -aiosqlite>=0.20 pydantic>=2.6 pydantic-settings>=2.2 httpx>=0.27 diff --git a/server/schemas.py b/server/schemas.py index bcf6e38..7bd03ba 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -12,8 +12,15 @@ SUPPORTED_TYPES = [ # 通义(DashScope)生图模型白名单:插件下拉可选的模型 TONGYI_MODELS = ["qwen-image-3.0-pro", "wan2.7-image-pro", "wan2.6-image", "wan2.6-t2i"] -# RightAPI 生图模型白名单 -RIGHTAPI_MODELS = ["gpt-image-2"] +# RightAPI 生图模型白名单(gpt-image 系列 + Google nano-banana 系列,同一中转) +RIGHTAPI_MODELS = [ + "gpt-image-2", + "gpt-image-2-vip", + "nano-banana", + "nano-banana-2", + "nano-banana-2-lite", + "nano-banana-pro", +] # 模型 → provider 推断表:插件只传模型名,服务端据此路由(模型名优先于 provider 字段) MODEL_PROVIDERS: dict[str, str] = { @@ -29,14 +36,7 @@ def resolve_provider(model: str | None, requested: str | None, default: str) -> return requested or default -# ── 采集上传 ── - -class SourceInfo(BaseModel): - platform: str = Field(..., description="ozon | 1688 | taobao") - itemId: str | None = None - url: str = "" - collectedAt: int | None = None # epoch 毫秒 - +# ── 文本素材(规划 / 生成共用)── class TextMaterial(BaseModel): kind: str = Field(..., description="title | params | selling_point | desc | price | brand") @@ -44,30 +44,6 @@ class TextMaterial(BaseModel): pairs: list[dict] | None = None # [{key, value}] -class ImageMaterial(BaseModel): - groupKey: str = Field(..., description="main | sku | detail | video") - groupName: str = "" - variantName: str | None = None - url: str = Field(..., description="源站原图 URL") - index: int = 0 - type: str = "img" - dedupeKey: str | None = None - - -class MaterialsRequest(BaseModel): - product_id: str | None = Field(default=None, description="传了=追加到已有商品") - source: SourceInfo - texts: list[TextMaterial] = Field(default_factory=list) - images: list[ImageMaterial] = Field(default_factory=list) - refererOrigin: str | None = None - - -class MaterialsResponse(BaseModel): - product_id: str - assets_queued: int - assets_skipped: int = 0 - - # ── 套图生成 ── # 目标平台 → 文案语言 + 图片比例(平台决定规格,不再单独选语言) @@ -78,15 +54,6 @@ PLATFORM_SPECS: dict[str, dict] = { } -class SuiteCreateRequest(BaseModel): - style_set: int = Field(default=1, ge=1, le=7, description="风格模板 1-7") - types: list[str] = Field(default_factory=lambda: ["white_bg", "key_features", "lifestyle", "multi_scene"]) - platform: str = Field(default="cn", description="目标平台:ozon | wb | cn") - provider: str | None = Field(default=None, description="覆盖默认 provider(doubao | tongyi)") - model: str | None = Field(default=None, description="覆盖默认生图模型(tongyi: qwen-image-3.0-pro / wan2.7-image-pro)") - requirements: str | None = Field(default=None, description="生图要求(最高优先级,强制约束)") - - # ── 无状态套图生成(工具流程:请求自带采集数据)── class GenerateImageItem(BaseModel): @@ -108,7 +75,7 @@ class PlanItem(BaseModel): class GenerateRequest(BaseModel): texts: list[TextMaterial] = Field(default_factory=list, description="采集的文本素材") images: list[GenerateImageItem] = Field(default_factory=list, description="勾选的参考图") - style_set: int = Field(default=1, ge=1, le=7) + style_set: int = Field(default=1, ge=1, le=5) style_prompt: str | None = Field(default=None, description="用户改写的风格提示词(覆盖 style_set 模板)") requirements: str | None = Field(default=None, description="生图要求(最高优先级,强制约束,覆盖其他设定)") types: list[str] = Field(default_factory=list, description="旧参数:无方案时按类型生成") @@ -156,45 +123,14 @@ class SuiteImageOut(BaseModel): class SuiteOut(BaseModel): id: str - product_id: str status: str style_set: int platform: str lang: str ratio: str - types: list[str] provider: str model: str | None = None + total: int = 0 # 计划生成总张数(进度分母;images 是逐张追加,过程中 length < total) images: list[SuiteImageOut] error: str | None = None - -# ── 商品 ── - -class AssetOut(BaseModel): - id: str - group_key: str - variant_name: str | None = None - type: str - source_url: str - url: str | None = None - status: str - - -class ProductOut(BaseModel): - id: str - stage: str - source_platform: str | None = None - source_item_id: str | None = None - source_url: str | None = None - name: str - description: str - price: float | None = None - asset_counts: dict | None = None - assets: list[AssetOut] = Field(default_factory=list) - created_at: str | None = None - - -class ProductListOut(BaseModel): - total: int - items: list[ProductOut] diff --git a/server/services/generator.py b/server/services/generator.py index bd45de9..a3656e6 100644 --- a/server/services/generator.py +++ b/server/services/generator.py @@ -11,16 +11,13 @@ import base64 import logging import mimetypes import re -from uuid import UUID import httpx -from sqlalchemy import select from config import get_settings -from db import get_session_factory -from models import Product, ProductAsset, Suite, SuiteImage, SUITE_RUNNING, SUITE_DONE, SUITE_PARTIAL, SUITE_FAILED, STATUS_OK, STATUS_FAILED from services import storage from services.prompt import build_prompt, build_context, type_name, wrap_prompt_for_gpt_edits +from services.tasks import Task, TaskImage, TASK_FAILED, TASK_RUNNING, TASK_DONE, TASK_PARTIAL, IMG_OK log = logging.getLogger("suite.generator") @@ -242,22 +239,25 @@ async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048* # 可重试的状态码:中转限流/网关抖动(该中转限流时返回 Cloudflare 502 而非 429) RETRYABLE_STATUS = {429, 500, 502, 503, 504} -# 中转对 input_fidelity 参数的支持探测:None=未探测,True=支持,False=不支持(已降级) -_rightapi_fidelity_supported: bool | None = None +# 中转对 input_fidelity 参数的支持探测:按模型记忆不支持该参数的模型(gpt-image 系列支持, +# nano-banana 系列可能不认;降级只影响触发过的模型,不牵连其他模型) +_rightapi_fidelity_unsupported: set[str] = set() async def _rightapi_request(s, prompt: str, ref_images: list[str], size: str, model: str) -> bytes: - """gpt-image 系列:有参考图走 /v1/images/edits(multipart),无参考图走 /v1/images/generations。 + """RightAPI 各模型:有参考图走 /v1/images/edits(multipart),无参考图走 /v1/images/generations。 OpenAI 兼容协议:响应固定 b64_json(不支持 response_format 参数,传了报 400); 同步调用无任务轮询,高质量档单张 1-5 分钟,超时按文档建议兜底 600s。 - input_fidelity=high 强制高保真保留输入图细节(商品一致性关键参数,仅 edits 端点); - 中转若不认该参数(400),自动去掉重试并记住,后续请求不再带。 + input_fidelity=high 是 gpt-image-1 的 edits 保真参数(gpt-image-2 官方已移除、默认高保真, + 官逆通道更是不识别);带上是为了兼容按 gpt-image-1 语义实现的中转,中转不认(400)则按模型 + 自动去掉重试并记住,该模型后续请求不再带。 """ - global _rightapi_fidelity_supported base = s.rightapi_base_url.rstrip("/") headers = {"Authorization": f"Bearer {s.rightapi_api_key}"} - use_fidelity = bool(ref_images) and s.rightapi_input_fidelity and _rightapi_fidelity_supported is not False + use_fidelity = ( + bool(ref_images) and s.rightapi_input_fidelity and model not in _rightapi_fidelity_unsupported + ) async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client: common = { @@ -276,14 +276,12 @@ async def _rightapi_request(s, prompt: str, ref_images: list[str], size: str, mo data, mime = await _resolve_ref_bytes(u) files.append(("image[]", (f"ref-{i + 1}.{mime.split('/')[-1]}", data, mime))) resp = await client.post(f"{base}/v1/images/edits", headers=headers, files=files, data=common) - # 中转不认 input_fidelity:去掉参数重试一次(仅一次探测) + # 中转不认 input_fidelity:去掉参数重试一次(仅一次探测),降级只记到当前模型 if resp.status_code == 400 and use_fidelity and "input_fidelity" in resp.text: - _rightapi_fidelity_supported = False - log.warning("RightAPI 不支持 input_fidelity 参数,已自动去掉并降级(后续请求不再带)") + _rightapi_fidelity_unsupported.add(model) + log.warning("RightAPI 模型 %s 不支持 input_fidelity 参数,已自动去掉并降级(该模型后续请求不再带)", model) common.pop("input_fidelity", None) resp = await client.post(f"{base}/v1/images/edits", headers=headers, files=files, data=common) - elif resp.is_success and use_fidelity: - _rightapi_fidelity_supported = True else: resp = await client.post( f"{base}/v1/images/generations", @@ -365,123 +363,67 @@ def _refs_for_job(images: list[dict], job: dict) -> list[str]: return _order_refs(pool, job.get("kind", "")) -async def _select_ref_images(db, product_id: UUID, type_id: str) -> list[str]: - """商品路径:主图组前几张。转存完成的用本地文件,未完成的直接用源站 URL。""" - assets = (await db.scalars( - select(ProductAsset).where( - ProductAsset.product_id == product_id, - ProductAsset.group_key == "main", - ProductAsset.type == "img", - ).order_by(ProductAsset.sort_order) - )).all() - refs = [a.stored_url or a.source_url for a in assets if (a.stored_url or a.source_url)] - if not refs: - raise RuntimeError("商品没有可用参考图(未采集主图)") - return _order_refs(refs, type_id) +# 串行生成队列:所有用户共享同一批 API key,并发生成会触发中转限流 +# (rightapi 同 key 分钟级冷却);同一时间只跑一个任务,其余保持 pending 排队。 +_GEN_LOCK = asyncio.Lock() -async def run_suite(suite_id: str) -> None: - """后台执行套图任务:逐张生成 → 落盘 → 记录;单张失败不中断。 - - 两条路径: - - 无状态(product_id 为空):上下文与参考图来自请求自带的 context / ref_images - - 商品路径(兼容旧流程):从 product + product_assets 取 - """ +async def run_suite(task: Task) -> None: + """后台执行套图任务:排队 → 逐张生成 → 落盘 → 更新内存状态;单张失败不中断。""" settings = get_settings() - async with get_session_factory()() as db: - suite = await db.get(Suite, UUID(suite_id)) - if suite is None: - return + provider_name = task.provider or settings.image_provider + generator = GENERATORS.get(provider_name) + if generator is None: + task.status = TASK_FAILED + task.error = f"未知 provider: {provider_name}" + return - product = None - if suite.product_id: - product = await db.get(Product, suite.product_id) - if product is None: - suite.status = SUITE_FAILED - suite.error = "商品不存在" - await db.commit() - return - - suite.status = SUITE_RUNNING - await db.commit() - - provider_name = suite.provider or settings.image_provider - generator = GENERATORS.get(provider_name) - if generator is None: - suite.status = SUITE_FAILED - suite.error = f"未知 provider: {provider_name}" - await db.commit() - return - - raw = suite.context if not product else (product.raw or {}) - ctx = build_context(raw or {}, fallback_name=product.name if product else "") - model = suite.model or { - "tongyi": settings.dashscope_model, - "rightapi": settings.rightapi_image_model, - }.get(provider_name, settings.ark_image_model) - is_wan = provider_name == "tongyi" and _is_wan_model(model) - size = _image_size(provider_name, suite.ratio, is_wan=is_wan, model=model) - - # 任务列表:方案(逐张)优先,旧路径按 types - if suite.plan: - jobs = [dict(j) for j in suite.plan] - else: - jobs = [ - {"kind": t, "title": type_name(t), "detail": "", "prompt_hint": "", "variant_name": None} - for t in (suite.types or []) - ] + ctx = build_context(task.context or {}, fallback_name="") + model = task.model or { + "tongyi": settings.dashscope_model, + "rightapi": settings.rightapi_image_model, + }.get(provider_name, settings.ark_image_model) + is_wan = provider_name == "tongyi" and _is_wan_model(model) + size = _image_size(provider_name, task.ratio, is_wan=is_wan, model=model) + jobs = [dict(j) for j in task.plan] + async with _GEN_LOCK: + task.status = TASK_RUNNING ok, failed = 0, 0 failures: list[str] = [] for job in jobs: type_id = job["kind"] - image_row = SuiteImage( - suite_id=suite.id, - type_id=type_id, - name=job.get("title") or type_name(type_id), - status=STATUS_FAILED, - ) - db.add(image_row) - await db.flush() + image = TaskImage(type_id=type_id, name=job.get("title") or type_name(type_id)) + task.images.append(image) try: prompt = build_prompt( - type_id, ctx, suite.style_set, suite.lang, - extra=job, style_prompt=suite.style_prompt, requirements=suite.requirements, + type_id, ctx, task.style_set, task.lang, + extra=job, style_prompt=task.style_prompt, requirements=task.requirements, ) - # gpt-image edits 语义:商品冻结契约前置,防止风格词改商品 + # gpt-image edits 语义:商品冻结契约前置(含商品文字锚定),防止风格词改商品 if provider_name == "rightapi": - prompt = wrap_prompt_for_gpt_edits(prompt) - if product: - refs = await _select_ref_images(db, product.id, type_id) - else: - refs = _refs_for_job(list(suite.ref_images or []), job) + prompt = wrap_prompt_for_gpt_edits(prompt, ctx) + refs = _refs_for_job(list(task.ref_images or []), job) data = await generator(prompt, refs, size=size, model=model) # 部分中转不遵守 output_format(要 jpeg 回 PNG),按魔数定扩展名 ext = ".png" if data[:8] == b"\x89PNG\r\n\x1a\n" else ".jpg" - key = storage.write_bytes(data, key_prefix=f"suites/{suite.id}", ext=ext) - image_row.stored_url = storage.public_url(key) - image_row.status = STATUS_OK + key = storage.write_bytes(data, key_prefix=f"suites/{task.id}", ext=ext) + image.url = storage.public_url(key) + image.status = IMG_OK ok += 1 except Exception as exc: # noqa: BLE001 - log.exception("套图 %s 类型 %s 生成失败", suite_id, type_id) - err = str(exc)[:500] - image_row.error = err - failures.append(f"{job.get('title') or type_name(type_id)}:{err[:200]}") + log.exception("套图 %s 类型 %s 生成失败", task.id, type_id) + image.error = str(exc)[:500] + failures.append(f"{job.get('title') or type_name(type_id)}:{str(exc)[:200]}") failed += 1 - await db.commit() - suite.status = SUITE_DONE if failed == 0 else (SUITE_PARTIAL if ok > 0 else SUITE_FAILED) + task.status = TASK_DONE if failed == 0 else (TASK_PARTIAL if ok > 0 else TASK_FAILED) if failed: uniq = list(dict.fromkeys(failures)) # 去重保序 detail = ";".join(uniq[:6]) if len(uniq) > 6: detail += f";…等共 {failed} 张失败" if ok == 0: - suite.error = f"全部生成失败。{detail}" + task.error = f"全部生成失败。{detail}" else: - suite.error = f"部分生成失败({failed} 张)。{detail}" - from datetime import datetime, timezone - suite.finished_at = datetime.now(timezone.utc) - if product: - product.stage = "generated" # 商品路径才有的阶段升级 - await db.commit() + task.error = f"部分生成失败({failed} 张)。{detail}" diff --git a/server/services/prompt.py b/server/services/prompt.py index cf1a3f8..d7ecf08 100644 --- a/server/services/prompt.py +++ b/server/services/prompt.py @@ -17,47 +17,28 @@ import re STYLE_SETS: dict[int, dict] = { 1: { - "name": "高级质感大片", - "tone": "高端电商大片质感,柔和的方向性棚拍光,背景带细腻的浅渐变,材质纹理清晰可见," - "色彩层次高级克制,商业画册级品质,构图干净、留白充足", + "name": "北欧极简", + "tone": "北欧极简风:浅灰或米白背景,柔和漫射光,低饱和色调,画面留白充足,构图克制干净", "bg": "", }, 2: { - "name": "清新生活场景", - "tone": "明亮通透的生活场景摄影,自然窗光,柔和的低饱和居家环境,浅景深虚化," - "真实自然的氛围感,绿植与暖色织物点缀,温馨有人气", + "name": "清新明亮", + "tone": "清新明亮风:明亮的白色到浅蓝渐变背景,高调光线,色彩明快通透,整体轻盈干净", "bg": "", }, 3: { - "name": "极简白底规范", - "tone": "极简棚拍风格,纯净无缝的浅色背景,柔和均匀的无影布光,以商品为中心的严谨构图," - "安静的高级感,画面只保留轻微的自然接触投影", + "name": "高级感深色", + "tone": "高级质感风:深灰或炭黑背景,戏剧性侧光打光,突出商品材质与光泽,沉稳高级", "bg": "", }, 4: { - "name": "炫彩促销风", - "tone": "高能量促销风格,高饱和度色块背景搭配动感几何图形,强对比,节日大促海报氛围," - "构图抢眼、视觉冲击力强", + "name": "暖调生活", + "tone": "温暖生活风:暖米色背景,暖色灯光氛围,温馨的家居质感,亲和力强", "bg": "", }, 5: { - "name": "暗调轻奢", - "tone": "暗调轻奢质感,深炭灰色背景,轮廓光勾勒商品边缘,材质细节丰富,带轻微雾感," - "如美术馆展陈般的呈现", - "bg": "", - }, - 6: { - "name": "俄式风情", - "tone": "俄式风情电商大片,浓郁温暖的色调,红与金的传统配色点缀,冬日节庆氛围," - "深色木质与毛毡织物背景,如暖炉烛光般的柔和光晕,厚重扎实的质感," - "带一丝巴洛克式的华丽细节,适合俄语区市场", - "bg": "", - }, - 7: { - "name": "北欧极简", - "tone": "北欧极简风格,白色与浅灰的原木空间,大量自然漫射光,干净利落的线条," - "浅色木质背景点缀少量绿植,克制的中性配色,画面通透轻盈," - "舒适宁静的氛围", + "name": "纯净棚拍", + "tone": "标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出", "bg": "", }, } @@ -121,33 +102,49 @@ DEFAULT_NEGATIVE_INTENT = ( # "主体参考"),风格词会被字面执行到商品上。按 OpenAI 官方提示词指南的编辑模式: # 按序号说明输入图、PRESERVE/MAY CHANGE 分列、首尾重申不变量、文案逐字渲染。 -GPT_EDITS_CONTRACT = ( - "INPUT IMAGES: Image 1 (and Image 2 if present) are reference photos of ONE product " - "from different angles. Use them ONLY as the source of the product's true appearance.\n" - "PRESERVE (frozen, never change): the product itself — silhouette, proportions, colors, " - "print/pattern (keep stripes / logos / labels exactly), materials, texture, stitching, " - "hardware and every design detail. The product in the output must be the same physical " - "item as in the input images, merely photographed in a new setting.\n" - "MAY CHANGE: background, scene, props, camera angle, lighting, composition " - "and in-image marketing typography.\n" - "STYLE SCOPE: all style, mood, color-palette and decoration instructions below describe " - "the SCENE AND BACKGROUND ONLY — never apply them to the product itself. " - "Do not restyle, recolor, re-pattern or redecorate the product. " - "You may relight the product so it sits naturally in the new scene " - "(matched shadows and color temperature), but never change its design, colors or pattern." -) +def gpt_edits_contract(ctx: dict) -> str: + """gpt-image edits 语义契约(放开头,指令权重最高处)。 + + 除通用锁定条款外,注入商品文字锚定(标题 + 关键参数 + 描述): + 官逆通道(gpt-image-2-vip 等)会把参考图当对话附件弱化处理, + input_fidelity 类 API 参数不生效,此时商品文字描述是保真的唯一兜底。 + """ + anchor = f" The product is: \"{ctx['title']}\"" + if ctx.get("params_line"): + anchor += f" (key specs: {ctx['params_line']})" + if ctx.get("desc"): + anchor += f". {ctx['desc']}" + return ( + "TASK: Edit the attached product photos — re-photograph THE SAME physical product " + "in a new setting. This is an edit of the input images, NOT a new product design.\n" + "INPUT IMAGES: Image 1 = product front view (PRIMARY source of truth for the product's " + f"true appearance); Image 2 (if present) = product back / detail view.{anchor}\n" + "PRODUCT LOCK (highest priority, overrides everything else in this prompt): exactly " + "preserve the product's shape, silhouette, proportions, colors, label text, logos, " + "print/pattern, materials and texture. Do not redesign, restyle, recolor, re-pattern " + "or substitute the product with a similar one. The output must show the very product " + "from the input images AND match the product description above; if the generated " + "product differs from either in any design detail, the image is rejected.\n" + "MAY CHANGE: background, scene, props, camera angle, lighting, composition " + "and in-image marketing typography only. You may relight the product so it sits " + "naturally in the new scene (matched shadows and color temperature).\n" + "STYLE SCOPE: all style, mood, color-palette and decoration instructions below describe " + "the SCENE AND BACKGROUND ONLY — never apply them to the product. " + "When any style instruction conflicts with product fidelity, product fidelity always wins." + ) + GPT_EDITS_FINAL_CHECK = ( - "FINAL CHECK before output: if the product in your result differs from the input product in any " - "design detail (shape, color, pattern, material, logo), the image is rejected. " - "Render any listed marketing copy / headlines exactly as written (verbatim, no extra characters, " - "no paraphrasing)." + "FINAL CHECK before output: if the product in your result differs from the input product " + "or the product description above in any design detail (shape, color, pattern, material, " + "logo), the image is rejected. Render any listed marketing copy / headlines exactly as " + "written (verbatim, no extra characters, no paraphrasing)." ) -def wrap_prompt_for_gpt_edits(prompt: str) -> str: +def wrap_prompt_for_gpt_edits(prompt: str, ctx: dict) -> str: """gpt-image edits 语义适配:契约放开头(指令权重最高处),终检放结尾。""" - return f"{GPT_EDITS_CONTRACT}\n\n{prompt}\n\n{GPT_EDITS_FINAL_CHECK}" + return f"{gpt_edits_contract(ctx)}\n\n{prompt}\n\n{GPT_EDITS_FINAL_CHECK}" # ── 商品上下文提炼 ──────────────────────────────────────────────────────── diff --git a/server/services/tasks.py b/server/services/tasks.py new file mode 100644 index 0000000..93a7f41 --- /dev/null +++ b/server/services/tasks.py @@ -0,0 +1,68 @@ +"""内存任务注册表:套图生成任务的生命周期与进程一致(重启即新会话)。 + +轮询/导出只服务「当前会话正在跟踪的任务」——前端没有历史记录功能, +任务状态无需跨进程持久化;重启后轮询自然 404,前端提示任务已中断。 +""" +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field + +# 任务状态 +TASK_PENDING = "pending" +TASK_RUNNING = "running" +TASK_DONE = "done" +TASK_PARTIAL = "partial" +TASK_FAILED = "failed" + +# 任务内单张图状态 +IMG_OK = "ok" +IMG_FAILED = "failed" + + +@dataclass +class TaskImage: + """任务里单张生成图:完成一张追加一条(前端进度 x/y 依赖此语义)。""" + + type_id: str + name: str + status: str = IMG_FAILED # 循环里先建后跑,成功后改为 ok + url: str = "" + error: str | None = None + + +@dataclass +class Task: + """一次套图生成任务:轮询可见字段 + 仅供 run_suite 消费的执行参数。""" + + id: str + status: str = TASK_PENDING + platform: str = "cn" + lang: str = "zh" + ratio: str = "1:1" + style_set: int = 1 + style_prompt: str | None = None + requirements: str | None = None + provider: str = "" + model: str | None = None + total: int = 0 # 计划总张数(进度分母) + images: list[TaskImage] = field(default_factory=list) + error: str | None = None + # ── 执行参数(不进轮询响应)── + context: dict = field(default_factory=dict) # 采集文本素材(build_context 的输入) + plan: list[dict] = field(default_factory=list) # 展开后的逐张任务 + ref_images: list[dict] = field(default_factory=list) # 参考图池(main 优先) + + +# 进程内任务表:asyncio 单事件循环读写,无并发问题;不做淘汰(单会话量级很小) +_TASKS: dict[str, Task] = {} + + +def create_task(**kwargs) -> Task: + task = Task(id=uuid.uuid4().hex, **kwargs) + _TASKS[task.id] = task + return task + + +def get_task(task_id: str) -> Task | None: + return _TASKS.get(task_id) diff --git a/start.command b/start.command index dd3eb81..33b7443 100755 --- a/start.command +++ b/start.command @@ -1,8 +1,38 @@ #!/bin/zsh # 双击本文件,或在终端执行:./start.command -# 启动电商套图工作台后端(http://127.0.0.1:3300) +# 流程:停掉占用 3300 的旧后端 → 重新 build 插件 → 启动后端(http://127.0.0.1:3300) cd "$(dirname "$0")" +# ── 1) 停掉占用 3300 端口的旧后端(上次残留的服务会导致 address already in use)── +listen_pids() { lsof -tnP -iTCP:3300 -sTCP:LISTEN 2>/dev/null; } +if [[ -n "$(listen_pids)" ]]; then + echo "端口 3300 被旧服务占用(PID: $(listen_pids | tr '\n' ' ')),正在停止…" + listen_pids | xargs kill 2>/dev/null + for _ in {1..10}; do # 等待优雅退出,最多 5s + [[ -z "$(listen_pids)" ]] && break + sleep 0.5 + done + if [[ -n "$(listen_pids)" ]]; then + echo "旧服务未响应退出信号,强制结束…" + listen_pids | xargs kill -9 2>/dev/null + sleep 1 + fi + echo "端口 3300 已释放" +fi + +# ── 2) 重新 build 插件(产物在 extension/.output/chrome-mv3)── +if command -v pnpm >/dev/null 2>&1; then + echo "正在重新 build 插件…" + ( + cd extension || exit 1 + [[ -d node_modules ]] || pnpm install + pnpm run build + ) || echo "⚠️ 插件 build 失败,后端照常启动(可稍后手动执行:cd extension && pnpm run build)" + echo "提示:build 后需在 chrome://extensions 重新加载插件,并刷新已打开的商品页" +else + echo "⚠️ 未找到 pnpm,跳过插件 build" +fi + if [[ ! -d server/.venv ]]; then echo "未找到 server/.venv,正在创建并安装依赖…" python3 -m venv server/.venv || exit 1 @@ -17,6 +47,7 @@ if [[ ! -f .env ]]; then exit 1 fi +echo echo "启动中:http://127.0.0.1:3300 (插件保持默认后端地址即可)" echo "按 Ctrl+C 可停止服务" echo