feat: 添加新的模型,删除后端数据库

This commit is contained in:
Joey
2026-08-19 22:37:20 +08:00
parent 82cb694837
commit 6732cb178a
17 changed files with 411 additions and 918 deletions
@@ -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 正常
+15 -3
View File
@@ -9,7 +9,7 @@ Chrome 插件 + Python 后端:采集 Ozon / 1688 / 淘宝 / 天猫 商品页
## 架构 ## 架构
``` ```
Chrome 插件(WXT + React + antd Python 后端(FastAPI + SQLite Chrome 插件(WXT + React + antd Python 后端(FastAPI,无数据库
┌────────────────────────────┐ ┌──────────────────────────────┐ ┌────────────────────────────┐ ┌──────────────────────────────┐
│ 页内悬浮面板 │ │ POST /api/plan │ │ 页内悬浮面板 │ │ POST /api/plan │
│ ① 扫描商品页(四站点) │ ─规划──▶ │ → DeepSeek 出图方案 │ │ ① 扫描商品页(四站点) │ ─规划──▶ │ → 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 ### 采集引擎(extension/src
- 声明式 `SiteProfile`(选择器 + srcProps + 去重/排除规则),加站点只需加一个 profile: - 声明式 `SiteProfile`(选择器 + srcProps + 去重/排除规则),加站点只需加一个 profile:
@@ -36,12 +47,13 @@ Chrome 插件(WXT + React + antd Python 后端(FastAPI + SQLite
- `prompt.py`:7 种图类型 × 5 套风格模板,公共组件 QUALITY / PRODUCT_REF_LOCK(商品一致性锁)/ TEXT_RENDER - `prompt.py`:7 种图类型 × 5 套风格模板,公共组件 QUALITY / PRODUCT_REF_LOCK(商品一致性锁)/ TEXT_RENDER
- 图类型:白底主图 / 核心卖点图 / 卖点图 / 材质图 / 场景展示图 / 多场景拼图 / 电商详情图 - 图类型:白底主图 / 核心卖点图 / 卖点图 / 材质图 / 场景展示图 / 多场景拼图 / 电商详情图
- 风格:经典商拍 / 生活杂志 / 极简高冷 / 活力爆款 / 暗调质感 - 风格:北欧极简 / 清新明亮 / 高级感深色 / 暖调生活 / 纯净棚拍
- 卖点从采集的参数表/卖点文本自动提炼 - 卖点从采集的参数表/卖点文本自动提炼
- `generator.py`:图像 provider(图生图,参考图 = 采集主图) - `generator.py`:图像 provider(图生图,参考图 = 采集主图)
- `doubao`:火山方舟 Seedream(默认,`ARK_API_KEY` - `doubao`:火山方舟 Seedream(默认,`ARK_API_KEY`
- `tongyi`:通义万相/千问(`DASHSCOPE_API_KEY`wan* 异步轮询 / qwen* 同步) - `tongyi`:通义万相/千问(`DASHSCOPE_API_KEY`wan* 异步轮询 / qwen* 同步)
- `rightapi`gpt-image-2OpenAI 兼容中转 `RIGHTAPI_API_KEY`edits 参考图 / generations 同步) - `rightapi`gpt-image-2 / gpt-image-2-vip / nano-banana / nano-banana-2 / nano-banana-2-lite / nano-banana-proOpenAI 兼容中转 `RIGHTAPI_API_KEY`edits 参考图 / generations 同步)
- ⚠️ `gpt-image-2-vip` 为官逆通道:不透传保真参数、参考图被弱化,商品还原度不稳定(生成前有警示);正式出图用 `gpt-image-2`
- 插件只传模型名,服务端按模型名自动路由到对应 provider - 插件只传模型名,服务端按模型名自动路由到对应 provider
## 快速开始 ## 快速开始
+45 -6
View File
@@ -15,7 +15,7 @@ import { SettingOutlined, DownloadOutlined, ThunderboltOutlined, UploadOutlined,
import type { ScanResult, ImageMaterial } from '../../src/collector/scan'; import type { ScanResult, ImageMaterial } from '../../src/collector/scan';
import { import {
buildGeneratePayload, suiteZipUrl, uploadImage, 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, type PlanItem, type SuiteInfo,
} from '../../src/api/client'; } from '../../src/api/client';
import { loadSettings, saveSettings, type BackendSettings } from '../../src/storage/settings'; import { loadSettings, saveSettings, type BackendSettings } from '../../src/storage/settings';
@@ -280,10 +280,24 @@ const App: React.FC = () => {
const pollSuite = useCallback((suiteId: string) => { const pollSuite = useCallback((suiteId: string) => {
stopPolling(); stopPolling();
const startedAt = Date.now();
let failCount = 0; // 连续轮询失败计数(容忍瞬时抖动,连续 3 次才判定后端不可达)
let timeoutWarned = false;
pollRef.current = setInterval(async () => { pollRef.current = setInterval(async () => {
try { try {
const s = await send<SuiteInfo>('getSuite', { baseUrl: settings.baseUrl, token: settings.token, suiteId }); const s = await send<SuiteInfo>('getSuite', { baseUrl: settings.baseUrl, token: settings.token, suiteId });
setSuite(s); 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)) { if (['done', 'partial', 'failed'].includes(s.status)) {
stopPolling(); stopPolling();
setGenerating(false); setGenerating(false);
@@ -291,8 +305,16 @@ const App: React.FC = () => {
if (s.status === 'failed') modal.error({ title: '生成失败', content: s.error || '未知错误', okText: '知道了' }); if (s.status === 'failed') modal.error({ title: '生成失败', content: s.error || '未知错误', okText: '知道了' });
} }
} catch (e) { } catch (e) {
failCount += 1;
if (failCount >= 3) {
stopPolling(); stopPolling();
setGenerating(false); setGenerating(false);
modal.error({
title: '无法连接后端',
content: '连续查询生成进度失败,已停止跟踪。若刚重启过后端(./start.command),任务已被中断,请重新生成。',
okText: '知道了',
});
}
} }
}, 3000); }, 3000);
}, [settings.baseUrl, settings.token, modal]); }, [settings.baseUrl, settings.token, modal]);
@@ -396,7 +418,8 @@ const App: React.FC = () => {
const totalPlanned = plan.reduce((s, i) => s + i.count, 0); const totalPlanned = plan.reduce((s, i) => s + i.count, 0);
const doneCount = suite?.images.filter(i => i.status === 'ok').length ?? 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] const currentStylePrompt = stylePrompts[styleSet]
?? STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.prompt ?? ''; ?? STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.prompt ?? '';
@@ -408,7 +431,21 @@ const App: React.FC = () => {
const spec = PLATFORM_SPECS[platform]; const spec = PLATFORM_SPECS[platform];
modal.confirm({ modal.confirm({
title: '生成电商套图', title: '生成电商套图',
content: `目标平台「${spec.label}」(${spec.lang}文案 · ${spec.ratio}),模型「${model}」,风格「${STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.label}」,共 ${totalPlanned} 张、参考图 ${selectedKeys.size} 张。生成需要几分钟,可在下方查看进度。`, content: (
<div>
<div>
{spec.label}{spec.lang} · {spec.ratio}{model}
{STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.label} {totalPlanned} {' '}
{selectedKeys.size}
</div>
{WEAK_FIDELITY_MODELS.has(model) && (
<div style={{ marginTop: 8, color: '#d4380d', fontWeight: 600 }}>
{model} 1
gpt-image-2
</div>
)}
</div>
),
okText: '开始生成', cancelText: '取消', okText: '开始生成', cancelText: '取消',
onOk: () => startGenerate(), onOk: () => startGenerate(),
}); });
@@ -796,10 +833,11 @@ const App: React.FC = () => {
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 4 }}>
{generating && ( {generating && (
<div style={{ flex: 1 }}> <div style={{ flex: 6 }}>
{/* 进度条与右侧弹性占位 6:1,长度 = 剩余空间的 6/7(两次加长后的累积比例) */}
<Progress <Progress
percent={suiteTotal ? Math.round(doneCount / suiteTotal * 100) : 0} percent={suiteTotal ? Math.round(doneCount / suiteTotal * 100) : 0}
size="small" status="active" format={() => `${doneCount}/${suiteTotal}`} size={['100%', 10]} status="active" format={() => `${doneCount}/${suiteTotal}`}
/> />
</div> </div>
)} )}
@@ -822,6 +860,7 @@ const App: React.FC = () => {
</Select> </Select>
<button <button
className="btn btn-primary btn-main" className="btn btn-primary btn-main"
style={{ alignSelf: 'stretch' }}
disabled={!result || generating || selectedKeys.size === 0 || totalPlanned === 0} disabled={!result || generating || selectedKeys.size === 0 || totalPlanned === 0}
onClick={handleGenerate} onClick={handleGenerate}
> >
@@ -852,7 +891,7 @@ const App: React.FC = () => {
</b> </b>
{' '}· {PLATFORM_SPECS[suite.platform]?.label ?? suite.platform} {' '}· {PLATFORM_SPECS[suite.platform]?.label ?? suite.platform}
{' '}· {PLATFORM_SPECS[suite.platform]?.lang ?? suite.lang} · {suite.ratio} {' '}· {PLATFORM_SPECS[suite.platform]?.lang ?? suite.lang} · {suite.ratio}
{' '}· {STYLE_SET_OPTIONS.find(s => s.value === suite.style_set)?.label} {' '}· {STYLE_SET_OPTIONS.find(s => s.value === suite.style_set)?.label ?? '自定义'}
</div> </div>
<div className="result-grid"> <div className="result-grid">
{suite.images.map(img => ( {suite.images.map(img => (
+44 -109
View File
@@ -1,29 +1,8 @@
/** /**
* 后端 HTTP 客户端 —— 仅 background 使用(有 host_permissions,不受 CORS 约束)。 * 后端 HTTP 客户端 —— 仅 background 使用(有 host_permissions,不受 CORS 约束)。
* 契约对齐 server 端 /api/materials 与 /api/suites。 * 契约对齐 server 端 /api/plan、/api/generate 与 /api/suites。
*/ */
import type { ScanResult, ImageMaterial } from '../collector/scan'; import type { 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;
}
/** 服务端支持的套图类型(与 server/services/prompt.py 保持一致) */ /** 服务端支持的套图类型(与 server/services/prompt.py 保持一致) */
export const SUITE_TYPE_OPTIONS = [ 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 = [ export const STYLE_SET_OPTIONS = [
{ {
value: 1, value: 1,
label: '高级质感大片', label: '北欧极简',
prompt: '高端电商大片质感,柔和的方向性棚拍光,背景带细腻的浅渐变,材质纹理清晰可见,色彩层次高级克制,商业画册级品质,构图干净、留白充足', prompt: '北欧极简风:浅灰或米白背景,柔和漫射光,低饱和色调,画面留白充足,构图克制干净',
}, },
{ {
value: 2, value: 2,
label: '清新生活场景', label: '清新明亮',
prompt: '明亮通透的生活场景摄影,自然窗光,柔和的低饱和居家环境,浅景深虚化,真实自然的氛围感,绿植与暖色织物点缀,温馨有人气', prompt: '清新明亮风:明亮的白色到浅蓝渐变背景,高调光线,色彩明快通透,整体轻盈干净',
}, },
{ {
value: 3, value: 3,
label: '极简白底规范', label: '高级感深色',
prompt: '极简棚拍风格,纯净无缝的浅色背景,柔和均匀的无影布光,以商品为中心的严谨构图,安静的高级感,画面只保留轻微的自然接触投影', prompt: '高级质感风:深灰或炭黑背景,戏剧性侧光打光,突出商品材质与光泽,沉稳高级',
}, },
{ {
value: 4, value: 4,
label: '炫彩促销风', label: '暖调生活',
prompt: '高能量促销风格,高饱和度色块背景搭配动感几何图形,强对比,节日大促海报氛围,构图抢眼、视觉冲击力强', prompt: '温暖生活风:暖米色背景,暖色灯光氛围,温馨的家居质感,亲和力强',
}, },
{ {
value: 5, value: 5,
label: '暗调轻奢', label: '纯净棚拍',
prompt: '暗调轻奢质感,深炭灰色背景,轮廓光勾勒商品边缘,材质细节丰富,带轻微雾感,如美术馆展陈般的呈现', prompt: '标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出',
},
{
value: 6,
label: '俄式风情',
prompt: '俄式风情电商大片,浓郁温暖的色调,红与金的传统配色点缀,冬日节庆氛围,深色木质与毛毡织物背景,如暖炉烛光般的柔和光晕,厚重扎实的质感,带一丝巴洛克式的华丽细节,适合俄语区市场',
},
{
value: 7,
label: '北欧极简',
prompt: '北欧极简风格,白色与浅灰的原木空间,大量自然漫射光,干净利落的线条,浅色木质背景点缀少量绿植,克制的中性配色,画面通透轻盈,舒适宁静的氛围',
}, },
] as const; ] as const;
/**
* 官逆/弱保真通道模型:链路不透传保真参数(input_fidelity 等)、参考图被弱化,
* 商品还原度不稳定,生成前需警示用户。链路性质决定,prompt 只能缓解不能根除。
*/
export const WEAK_FIDELITY_MODELS = new Set(['gpt-image-2-vip']);
/** 目标平台(决定文案语言 + 图片比例):Ozon/Wildberries → 俄文 3:4,中文 → 中文 1:1 */ /** 目标平台(决定文案语言 + 图片比例):Ozon/Wildberries → 俄文 3:4,中文 → 中文 1:1 */
export const PLATFORM_OPTIONS = [ export const PLATFORM_OPTIONS = [
{ value: 'ozon', label: 'Ozon' }, { value: 'ozon', label: 'Ozon' },
@@ -129,6 +104,31 @@ export const IMAGE_MODEL_OPTIONS = [
label: 'gpt-image-2', label: 'gpt-image-2',
desc: 'GPT 图像模型,构图与图内文案渲染最强,参考图高保真,单张 1-5 分钟', 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; ] as const;
export const PLATFORM_SPECS: Record<string, { lang: string; ratio: string; label: string }> = { export const PLATFORM_SPECS: Record<string, { lang: string; ratio: string; label: string }> = {
@@ -147,86 +147,21 @@ export interface SuiteImageInfo {
export interface SuiteInfo { export interface SuiteInfo {
id: string; id: string;
product_id: string;
status: 'pending' | 'running' | 'done' | 'partial' | 'failed'; status: 'pending' | 'running' | 'done' | 'partial' | 'failed';
style_set: number; style_set: number;
platform: string; platform: string;
lang: string; lang: string;
ratio: string; ratio: string;
types: string[];
provider: string; provider: string;
total?: number; // 计划生成总张数(后端返回;images 逐张追加,过程中 length < total
images: SuiteImageInfo[]; images: SuiteImageInfo[];
error?: string | null; error?: string | null;
} }
/** 用(可能已二次修改的)文本 + 已勾选图片,组装 /api/materials 请求体 */
export function buildMaterialsPayload(
result: ScanResult,
selectedKeys: Set<string>,
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<string, string> { function authHeaders(token: string): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {}; 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 { export interface GeneratePayload {
texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }>; texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }>;
-166
View File
@@ -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)}
+13 -18
View File
@@ -1,11 +1,9 @@
"""无状态套图生成:请求自带采集数据,不落商品库""" """无状态套图生成:请求自带采集数据,任务存进程内注册表"""
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, HTTPException
from config import get_settings from config import get_settings
from db import get_db
from models import Suite
from schemas import ( from schemas import (
GenerateRequest, PLATFORM_SPECS, SUPPORTED_TYPES, TONGYI_MODELS, RIGHTAPI_MODELS, GenerateRequest, PLATFORM_SPECS, SUPPORTED_TYPES, TONGYI_MODELS, RIGHTAPI_MODELS,
SuiteCreateResponse, TextMaterial, resolve_provider, SuiteCreateResponse, TextMaterial, resolve_provider,
@@ -14,6 +12,7 @@ from schemas import (
from services.generator import run_suite from services.generator import run_suite
from services.planner import generate_plan from services.planner import generate_plan
from services.prompt import type_name from services.prompt import type_name
from services.tasks import create_task
router = APIRouter(prefix="/api", tags=["generate"]) router = APIRouter(prefix="/api", tags=["generate"])
@@ -48,7 +47,6 @@ def texts_to_raw(texts: list[TextMaterial]) -> dict:
async def generate_suite( async def generate_suite(
req: GenerateRequest, req: GenerateRequest,
background: BackgroundTasks, background: BackgroundTasks,
db=Depends(get_db),
) -> SuiteCreateResponse: ) -> SuiteCreateResponse:
if not req.images: if not req.images:
raise HTTPException(status_code=400, detail="未勾选任何图片,无法生成") raise HTTPException(status_code=400, detail="未勾选任何图片,无法生成")
@@ -72,7 +70,6 @@ async def generate_suite(
}) })
if not jobs: if not jobs:
raise HTTPException(status_code=400, detail="方案中所有项的数量都是 0") raise HTTPException(status_code=400, detail="方案中所有项的数量都是 0")
types = list(dict.fromkeys(j["kind"] for j in jobs))
else: else:
types = req.types or ["white_bg", "key_features", "lifestyle", "multi_scene"] types = req.types or ["white_bg", "key_features", "lifestyle", "multi_scene"]
bad = [t for t in types if t not in SUPPORTED_TYPES] 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}") raise HTTPException(status_code=400, detail=f"不支持的模型: {model}tongyi 支持: {TONGYI_MODELS}")
if provider_name == "rightapi" and model and model not in RIGHTAPI_MODELS: if provider_name == "rightapi" and model and model not in RIGHTAPI_MODELS:
raise HTTPException(status_code=400, detail=f"不支持的模型: {model}rightapi 支持: {RIGHTAPI_MODELS}") raise HTTPException(status_code=400, detail=f"不支持的模型: {model}rightapi 支持: {RIGHTAPI_MODELS}")
suite = Suite(
product_id=None, task = create_task(
style_set=req.style_set, status="pending",
style_prompt=req.style_prompt,
requirements=req.requirements,
platform=req.platform, platform=req.platform,
lang=spec["lang"], lang=spec["lang"],
ratio=spec["ratio"], ratio=spec["ratio"],
types=types, style_set=req.style_set,
plan=jobs, style_prompt=req.style_prompt,
requirements=req.requirements,
provider=provider_name, provider=provider_name,
model=model, model=model,
total=len(jobs),
context=texts_to_raw(req.texts), context=texts_to_raw(req.texts),
plan=jobs,
# 参考图池:main 组优先,其余组按序补充(variant 绑定靠 variant_name 匹配) # 参考图池:main 组优先,其余组按序补充(variant 绑定靠 variant_name 匹配)
ref_images=[ 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) 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)) background.add_task(run_suite, task)
return SuiteCreateResponse(suite_id=str(suite.id)) return SuiteCreateResponse(suite_id=task.id)
@router.post("/plan", response_model=PlanResponse) @router.post("/plan", response_model=PlanResponse)
-71
View File
@@ -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,
)
+29 -112
View File
@@ -1,143 +1,60 @@
"""套图生成 API创建任务 / 查询状态 / 导出 ZIP""" """套图任务 API轮询进度 / 导出 ZIP(进程内内存任务表)"""
from __future__ import annotations from __future__ import annotations
import io import io
import zipfile import zipfile
from uuid import UUID
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import get_settings from schemas import SuiteImageOut, SuiteOut
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 services import storage 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"]) router = APIRouter(prefix="/api", tags=["suites"])
async def _suite_out(db: AsyncSession, suite: Suite) -> SuiteOut: def _task_out(task: Task) -> SuiteOut:
images = (await db.scalars(
select(SuiteImage).where(SuiteImage.suite_id == suite.id)
.order_by(SuiteImage.created_at)
)).all()
return SuiteOut( return SuiteOut(
id=str(suite.id), id=task.id,
product_id=str(suite.product_id), status=task.status,
status=suite.status, style_set=task.style_set,
style_set=suite.style_set, platform=task.platform,
platform=suite.platform, lang=task.lang,
lang=suite.lang, ratio=task.ratio,
ratio=suite.ratio, provider=task.provider,
types=list(suite.types or []), model=task.model,
provider=suite.provider, total=task.total,
model=suite.model,
images=[ images=[
SuiteImageOut( 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, 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()
# 模型名优先路由:已知模型直接定位 providergpt-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) @router.get("/suites/{suite_id}", response_model=SuiteOut)
async def get_suite(suite_id: str, db: AsyncSession = Depends(get_db)): async def get_suite(suite_id: str):
suite = await db.get(Suite, UUID(suite_id)) task = get_task(suite_id)
if suite is None: if task is None:
raise HTTPException(status_code=404, detail="任务不存在") raise HTTPException(status_code=404, detail="任务不存在(服务可能已重启),请重新生成")
return await _suite_out(db, suite) return _task_out(task)
@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]
@router.get("/suites/{suite_id}/zip") @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(中文文件名)。""" """把任务内所有成功图打包成 ZIP(中文文件名)。"""
suite = await db.get(Suite, UUID(suite_id)) task = get_task(suite_id)
if suite is None: if task is None:
raise HTTPException(status_code=404, detail="任务不存在") 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()
buf = io.BytesIO() buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
seen: set[str] = set() seen: set[str] = set()
for i, img in enumerate(images): for i, img in enumerate([i for i in task.images if i.status == IMG_OK]):
path = storage.local_path(img.stored_url or "") path = storage.local_path(img.url or "")
if path is None: if path is None:
continue continue
filename = img.name or img.type_id 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( return StreamingResponse(
buf, buf,
media_type="application/zip", media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="suite-{suite_id}.zip"'}, headers={"Content-Disposition": f"attachment; filename=\"suite-{suite_id}.zip\""},
) )
-59
View File
@@ -1,59 +0,0 @@
"""数据库:SQLiteaiosqlite+ 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)
+4 -15
View File
@@ -2,27 +2,18 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles 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 config import get_settings
from db import init_db
from services.storage import media_root from services.storage import media_root
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
app = FastAPI(title="电商套图工作台", version="0.1.0")
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
app = FastAPI(title="电商套图工作台", version="0.1.0", lifespan=lifespan)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -31,14 +22,12 @@ app.add_middleware(
allow_headers=["*"], 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(generate.router)
app.include_router(suites.router)
app.include_router(proxy.router) app.include_router(proxy.router)
app.include_router(upload.router) app.include_router(upload.router)
# 静态托管生成的图片/转存素材 # 静态托管生成的图片
app.mount("/media", StaticFiles(directory=str(media_root())), name="media") app.mount("/media", StaticFiles(directory=str(media_root())), name="media")
-119
View File
@@ -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())
-2
View File
@@ -1,7 +1,5 @@
fastapi>=0.110 fastapi>=0.110
uvicorn[standard]>=0.29 uvicorn[standard]>=0.29
sqlalchemy[asyncio]>=2.0
aiosqlite>=0.20
pydantic>=2.6 pydantic>=2.6
pydantic-settings>=2.2 pydantic-settings>=2.2
httpx>=0.27 httpx>=0.27
+12 -76
View File
@@ -12,8 +12,15 @@ SUPPORTED_TYPES = [
# 通义(DashScope)生图模型白名单:插件下拉可选的模型 # 通义(DashScope)生图模型白名单:插件下拉可选的模型
TONGYI_MODELS = ["qwen-image-3.0-pro", "wan2.7-image-pro", "wan2.6-image", "wan2.6-t2i"] TONGYI_MODELS = ["qwen-image-3.0-pro", "wan2.7-image-pro", "wan2.6-image", "wan2.6-t2i"]
# RightAPI 生图模型白名单 # RightAPI 生图模型白名单gpt-image 系列 + Google nano-banana 系列,同一中转)
RIGHTAPI_MODELS = ["gpt-image-2"] RIGHTAPI_MODELS = [
"gpt-image-2",
"gpt-image-2-vip",
"nano-banana",
"nano-banana-2",
"nano-banana-2-lite",
"nano-banana-pro",
]
# 模型 → provider 推断表:插件只传模型名,服务端据此路由(模型名优先于 provider 字段) # 模型 → provider 推断表:插件只传模型名,服务端据此路由(模型名优先于 provider 字段)
MODEL_PROVIDERS: dict[str, str] = { MODEL_PROVIDERS: dict[str, str] = {
@@ -29,14 +36,7 @@ def resolve_provider(model: str | None, requested: str | None, default: str) ->
return requested or default 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): class TextMaterial(BaseModel):
kind: str = Field(..., description="title | params | selling_point | desc | price | brand") 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}] 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="覆盖默认 providerdoubao | 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): class GenerateImageItem(BaseModel):
@@ -108,7 +75,7 @@ class PlanItem(BaseModel):
class GenerateRequest(BaseModel): class GenerateRequest(BaseModel):
texts: list[TextMaterial] = Field(default_factory=list, description="采集的文本素材") texts: list[TextMaterial] = Field(default_factory=list, description="采集的文本素材")
images: list[GenerateImageItem] = 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 模板)") style_prompt: str | None = Field(default=None, description="用户改写的风格提示词(覆盖 style_set 模板)")
requirements: str | None = Field(default=None, description="生图要求(最高优先级,强制约束,覆盖其他设定)") requirements: str | None = Field(default=None, description="生图要求(最高优先级,强制约束,覆盖其他设定)")
types: list[str] = Field(default_factory=list, description="旧参数:无方案时按类型生成") types: list[str] = Field(default_factory=list, description="旧参数:无方案时按类型生成")
@@ -156,45 +123,14 @@ class SuiteImageOut(BaseModel):
class SuiteOut(BaseModel): class SuiteOut(BaseModel):
id: str id: str
product_id: str
status: str status: str
style_set: int style_set: int
platform: str platform: str
lang: str lang: str
ratio: str ratio: str
types: list[str]
provider: str provider: str
model: str | None = None model: str | None = None
total: int = 0 # 计划生成总张数(进度分母;images 是逐张追加,过程中 length < total
images: list[SuiteImageOut] images: list[SuiteImageOut]
error: str | None = None 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]
+44 -102
View File
@@ -11,16 +11,13 @@ import base64
import logging import logging
import mimetypes import mimetypes
import re import re
from uuid import UUID
import httpx import httpx
from sqlalchemy import select
from config import get_settings 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 import storage
from services.prompt import build_prompt, build_context, type_name, wrap_prompt_for_gpt_edits 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") 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 # 可重试的状态码:中转限流/网关抖动(该中转限流时返回 Cloudflare 502 而非 429
RETRYABLE_STATUS = {429, 500, 502, 503, 504} RETRYABLE_STATUS = {429, 500, 502, 503, 504}
# 中转对 input_fidelity 参数的支持探测:None=未探测,True=支持,False=不支持(已降级) # 中转对 input_fidelity 参数的支持探测:按模型记忆不支持该参数的模型(gpt-image 系列支持,
_rightapi_fidelity_supported: bool | None = None # nano-banana 系列可能不认;降级只影响触发过的模型,不牵连其他模型)
_rightapi_fidelity_unsupported: set[str] = set()
async def _rightapi_request(s, prompt: str, ref_images: list[str], size: str, model: str) -> bytes: async def _rightapi_request(s, prompt: str, ref_images: list[str], size: str, model: str) -> bytes:
"""gpt-image 系列:有参考图走 /v1/images/editsmultipart),无参考图走 /v1/images/generations。 """RightAPI 各模型:有参考图走 /v1/images/editsmultipart),无参考图走 /v1/images/generations。
OpenAI 兼容协议:响应固定 b64_json(不支持 response_format 参数,传了报 400); OpenAI 兼容协议:响应固定 b64_json(不支持 response_format 参数,传了报 400);
同步调用无任务轮询,高质量档单张 1-5 分钟,超时按文档建议兜底 600s。 同步调用无任务轮询,高质量档单张 1-5 分钟,超时按文档建议兜底 600s。
input_fidelity=high 强制高保真保留输入图细节(商品一致性关键参数,仅 edits 端点); input_fidelity=high 是 gpt-image-1 的 edits 保真参数(gpt-image-2 官方已移除、默认高保真,
中转若不认该参数(400),自动去掉重试并记住,后续请求不再带。 官逆通道更是不识别);带上是为了兼容按 gpt-image-1 语义实现的中转,中转不认(400)则按模型
自动去掉重试并记住,该模型后续请求不再带。
""" """
global _rightapi_fidelity_supported
base = s.rightapi_base_url.rstrip("/") base = s.rightapi_base_url.rstrip("/")
headers = {"Authorization": f"Bearer {s.rightapi_api_key}"} 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: async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client:
common = { 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) data, mime = await _resolve_ref_bytes(u)
files.append(("image[]", (f"ref-{i + 1}.{mime.split('/')[-1]}", data, mime))) 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) 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: if resp.status_code == 400 and use_fidelity and "input_fidelity" in resp.text:
_rightapi_fidelity_supported = False _rightapi_fidelity_unsupported.add(model)
log.warning("RightAPI 不支持 input_fidelity 参数,已自动去掉并降级(后续请求不再带)") log.warning("RightAPI 模型 %s 不支持 input_fidelity 参数,已自动去掉并降级(该模型后续请求不再带)", model)
common.pop("input_fidelity", None) common.pop("input_fidelity", None)
resp = await client.post(f"{base}/v1/images/edits", headers=headers, files=files, data=common) 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: else:
resp = await client.post( resp = await client.post(
f"{base}/v1/images/generations", 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", "")) return _order_refs(pool, job.get("kind", ""))
async def _select_ref_images(db, product_id: UUID, type_id: str) -> list[str]: # 串行生成队列:所有用户共享同一批 API key,并发生成会触发中转限流
"""商品路径:主图组前几张。转存完成的用本地文件,未完成的直接用源站 URL。""" # rightapi 同 key 分钟级冷却);同一时间只跑一个任务,其余保持 pending 排队。
assets = (await db.scalars( _GEN_LOCK = asyncio.Lock()
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)
async def run_suite(suite_id: str) -> None: async def run_suite(task: Task) -> None:
"""后台执行套图任务:逐张生成 → 落盘 → 记录;单张失败不中断。 """后台执行套图任务:排队 → 逐张生成 → 落盘 → 更新内存状态;单张失败不中断。"""
两条路径:
- 无状态(product_id 为空):上下文与参考图来自请求自带的 context / ref_images
- 商品路径(兼容旧流程):从 product + product_assets 取
"""
settings = get_settings() settings = get_settings()
async with get_session_factory()() as db: provider_name = task.provider or settings.image_provider
suite = await db.get(Suite, UUID(suite_id))
if suite is None:
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) generator = GENERATORS.get(provider_name)
if generator is None: if generator is None:
suite.status = SUITE_FAILED task.status = TASK_FAILED
suite.error = f"未知 provider: {provider_name}" task.error = f"未知 provider: {provider_name}"
await db.commit()
return return
raw = suite.context if not product else (product.raw or {}) ctx = build_context(task.context or {}, fallback_name="")
ctx = build_context(raw or {}, fallback_name=product.name if product else "") model = task.model or {
model = suite.model or {
"tongyi": settings.dashscope_model, "tongyi": settings.dashscope_model,
"rightapi": settings.rightapi_image_model, "rightapi": settings.rightapi_image_model,
}.get(provider_name, settings.ark_image_model) }.get(provider_name, settings.ark_image_model)
is_wan = provider_name == "tongyi" and _is_wan_model(model) is_wan = provider_name == "tongyi" and _is_wan_model(model)
size = _image_size(provider_name, suite.ratio, is_wan=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]
# 任务列表:方案(逐张)优先,旧路径按 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 [])
]
async with _GEN_LOCK:
task.status = TASK_RUNNING
ok, failed = 0, 0 ok, failed = 0, 0
failures: list[str] = [] failures: list[str] = []
for job in jobs: for job in jobs:
type_id = job["kind"] type_id = job["kind"]
image_row = SuiteImage( image = TaskImage(type_id=type_id, name=job.get("title") or type_name(type_id))
suite_id=suite.id, task.images.append(image)
type_id=type_id,
name=job.get("title") or type_name(type_id),
status=STATUS_FAILED,
)
db.add(image_row)
await db.flush()
try: try:
prompt = build_prompt( prompt = build_prompt(
type_id, ctx, suite.style_set, suite.lang, type_id, ctx, task.style_set, task.lang,
extra=job, style_prompt=suite.style_prompt, requirements=suite.requirements, extra=job, style_prompt=task.style_prompt, requirements=task.requirements,
) )
# gpt-image edits 语义:商品冻结契约前置,防止风格词改商品 # gpt-image edits 语义:商品冻结契约前置(含商品文字锚定),防止风格词改商品
if provider_name == "rightapi": if provider_name == "rightapi":
prompt = wrap_prompt_for_gpt_edits(prompt) prompt = wrap_prompt_for_gpt_edits(prompt, ctx)
if product: refs = _refs_for_job(list(task.ref_images or []), job)
refs = await _select_ref_images(db, product.id, type_id)
else:
refs = _refs_for_job(list(suite.ref_images or []), job)
data = await generator(prompt, refs, size=size, model=model) data = await generator(prompt, refs, size=size, model=model)
# 部分中转不遵守 output_format(要 jpeg 回 PNG),按魔数定扩展名 # 部分中转不遵守 output_format(要 jpeg 回 PNG),按魔数定扩展名
ext = ".png" if data[:8] == b"\x89PNG\r\n\x1a\n" else ".jpg" 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) key = storage.write_bytes(data, key_prefix=f"suites/{task.id}", ext=ext)
image_row.stored_url = storage.public_url(key) image.url = storage.public_url(key)
image_row.status = STATUS_OK image.status = IMG_OK
ok += 1 ok += 1
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.exception("套图 %s 类型 %s 生成失败", suite_id, type_id) log.exception("套图 %s 类型 %s 生成失败", task.id, type_id)
err = str(exc)[:500] image.error = str(exc)[:500]
image_row.error = err failures.append(f"{job.get('title') or type_name(type_id)}{str(exc)[:200]}")
failures.append(f"{job.get('title') or type_name(type_id)}{err[:200]}")
failed += 1 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: if failed:
uniq = list(dict.fromkeys(failures)) # 去重保序 uniq = list(dict.fromkeys(failures)) # 去重保序
detail = "".join(uniq[:6]) detail = "".join(uniq[:6])
if len(uniq) > 6: if len(uniq) > 6:
detail += f";…等共 {failed} 张失败" detail += f";…等共 {failed} 张失败"
if ok == 0: if ok == 0:
suite.error = f"全部生成失败。{detail}" task.error = f"全部生成失败。{detail}"
else: else:
suite.error = f"部分生成失败({failed} 张)。{detail}" task.error = f"部分生成失败({failed} 张)。{detail}"
from datetime import datetime, timezone
suite.finished_at = datetime.now(timezone.utc)
if product:
product.stage = "generated" # 商品路径才有的阶段升级
await db.commit()
+44 -47
View File
@@ -17,47 +17,28 @@ import re
STYLE_SETS: dict[int, dict] = { STYLE_SETS: dict[int, dict] = {
1: { 1: {
"name": "高级质感大片", "name": "北欧极简",
"tone": "高端电商大片质感,柔和的方向性棚拍光,背景带细腻的浅渐变,材质纹理清晰可见," "tone": "北欧极简风:浅灰或米白背景,柔和漫射光,低饱和色调,画面留白充足,构图克制干净",
"色彩层次高级克制,商业画册级品质,构图干净、留白充足",
"bg": "", "bg": "",
}, },
2: { 2: {
"name": "清新生活场景", "name": "清新明亮",
"tone": "明亮通透的生活场景摄影,自然窗光,柔和的低饱和居家环境,浅景深虚化," "tone": "清新明亮风:明亮的白色到浅蓝渐变背景,高调光线,色彩明快通透,整体轻盈干净",
"真实自然的氛围感,绿植与暖色织物点缀,温馨有人气",
"bg": "", "bg": "",
}, },
3: { 3: {
"name": "极简白底规范", "name": "高级感深色",
"tone": "极简棚拍风格,纯净无缝的浅色背景,柔和均匀的无影布光,以商品为中心的严谨构图," "tone": "高级质感风:深灰或炭黑背景,戏剧性侧光打光,突出商品材质与光泽,沉稳高级",
"安静的高级感,画面只保留轻微的自然接触投影",
"bg": "", "bg": "",
}, },
4: { 4: {
"name": "炫彩促销风", "name": "暖调生活",
"tone": "高能量促销风格,高饱和度色块背景搭配动感几何图形,强对比,节日大促海报氛围," "tone": "温暖生活风:暖米色背景,暖色灯光氛围,温馨的家居质感,亲和力强",
"构图抢眼、视觉冲击力强",
"bg": "", "bg": "",
}, },
5: { 5: {
"name": "暗调轻奢", "name": "纯净棚拍",
"tone": "暗调轻奢质感,深炭灰色背景,轮廓光勾勒商品边缘,材质细节丰富,带轻微雾感," "tone": "标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出",
"如美术馆展陈般的呈现",
"bg": "",
},
6: {
"name": "俄式风情",
"tone": "俄式风情电商大片,浓郁温暖的色调,红与金的传统配色点缀,冬日节庆氛围,"
"深色木质与毛毡织物背景,如暖炉烛光般的柔和光晕,厚重扎实的质感,"
"带一丝巴洛克式的华丽细节,适合俄语区市场",
"bg": "",
},
7: {
"name": "北欧极简",
"tone": "北欧极简风格,白色与浅灰的原木空间,大量自然漫射光,干净利落的线条,"
"浅色木质背景点缀少量绿植,克制的中性配色,画面通透轻盈,"
"舒适宁静的氛围",
"bg": "", "bg": "",
}, },
} }
@@ -121,33 +102,49 @@ DEFAULT_NEGATIVE_INTENT = (
# "主体参考"),风格词会被字面执行到商品上。按 OpenAI 官方提示词指南的编辑模式: # "主体参考"),风格词会被字面执行到商品上。按 OpenAI 官方提示词指南的编辑模式:
# 按序号说明输入图、PRESERVE/MAY CHANGE 分列、首尾重申不变量、文案逐字渲染。 # 按序号说明输入图、PRESERVE/MAY CHANGE 分列、首尾重申不变量、文案逐字渲染。
GPT_EDITS_CONTRACT = ( def gpt_edits_contract(ctx: dict) -> str:
"INPUT IMAGES: Image 1 (and Image 2 if present) are reference photos of ONE product " """gpt-image edits 语义契约(放开头,指令权重最高处)。
"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, " 官逆通道(gpt-image-2-vip 等)会把参考图当对话附件弱化处理,
"hardware and every design detail. The product in the output must be the same physical " input_fidelity 类 API 参数不生效,此时商品文字描述是保真的唯一兜底。
"item as in the input images, merely photographed in a new setting.\n" """
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 " "MAY CHANGE: background, scene, props, camera angle, lighting, composition "
"and in-image marketing typography.\n" "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 " "STYLE SCOPE: all style, mood, color-palette and decoration instructions below describe "
"the SCENE AND BACKGROUND ONLY — never apply them to the product itself. " "the SCENE AND BACKGROUND ONLY — never apply them to the product. "
"Do not restyle, recolor, re-pattern or redecorate the product. " "When any style instruction conflicts with product fidelity, product fidelity always wins."
"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."
) )
GPT_EDITS_FINAL_CHECK = ( GPT_EDITS_FINAL_CHECK = (
"FINAL CHECK before output: if the product in your result differs from the input product in any " "FINAL CHECK before output: if the product in your result differs from the input product "
"design detail (shape, color, pattern, material, logo), the image is rejected. " "or the product description above in any design detail (shape, color, pattern, material, "
"Render any listed marketing copy / headlines exactly as written (verbatim, no extra characters, " "logo), the image is rejected. Render any listed marketing copy / headlines exactly as "
"no paraphrasing)." "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 语义适配:契约放开头(指令权重最高处),终检放结尾。""" """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}"
# ── 商品上下文提炼 ──────────────────────────────────────────────────────── # ── 商品上下文提炼 ────────────────────────────────────────────────────────
+68
View File
@@ -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)
+32 -1
View File
@@ -1,8 +1,38 @@
#!/bin/zsh #!/bin/zsh
# 双击本文件,或在终端执行:./start.command # 双击本文件,或在终端执行:./start.command
# 启动电商套图工作台后端(http://127.0.0.1:3300 # 流程:停掉占用 3300 的旧后端 → 重新 build 插件 → 启动后端(http://127.0.0.1:3300
cd "$(dirname "$0")" 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 if [[ ! -d server/.venv ]]; then
echo "未找到 server/.venv,正在创建并安装依赖…" echo "未找到 server/.venv,正在创建并安装依赖…"
python3 -m venv server/.venv || exit 1 python3 -m venv server/.venv || exit 1
@@ -17,6 +47,7 @@ if [[ ! -f .env ]]; then
exit 1 exit 1
fi fi
echo
echo "启动中:http://127.0.0.1:3300 (插件保持默认后端地址即可)" echo "启动中:http://127.0.0.1:3300 (插件保持默认后端地址即可)"
echo "按 Ctrl+C 可停止服务" echo "按 Ctrl+C 可停止服务"
echo echo