feat: 添加新的模型,删除后端数据库
This commit is contained in:
@@ -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 正常
|
||||
@@ -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
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -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<SuiteInfo>('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) {
|
||||
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: (
|
||||
<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: '取消',
|
||||
onOk: () => startGenerate(),
|
||||
});
|
||||
@@ -796,10 +833,11 @@ const App: React.FC = () => {
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 4 }}>
|
||||
{generating && (
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ flex: 6 }}>
|
||||
{/* 进度条与右侧弹性占位 6:1,长度 = 剩余空间的 6/7(两次加长后的累积比例) */}
|
||||
<Progress
|
||||
percent={suiteTotal ? Math.round(doneCount / suiteTotal * 100) : 0}
|
||||
size="small" status="active" format={() => `${doneCount}/${suiteTotal}`}
|
||||
size={['100%', 10]} status="active" format={() => `${doneCount}/${suiteTotal}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -822,6 +860,7 @@ const App: React.FC = () => {
|
||||
</Select>
|
||||
<button
|
||||
className="btn btn-primary btn-main"
|
||||
style={{ alignSelf: 'stretch' }}
|
||||
disabled={!result || generating || selectedKeys.size === 0 || totalPlanned === 0}
|
||||
onClick={handleGenerate}
|
||||
>
|
||||
@@ -852,7 +891,7 @@ const App: React.FC = () => {
|
||||
</b>
|
||||
{' '}· {PLATFORM_SPECS[suite.platform]?.label ?? suite.platform}
|
||||
{' '}· {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 className="result-grid">
|
||||
{suite.images.map(img => (
|
||||
|
||||
+44
-109
@@ -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<string, { lang: string; ratio: string; label: string }> = {
|
||||
@@ -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<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> {
|
||||
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 }>;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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\""},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
+4
-15
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
+12
-76
@@ -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]
|
||||
|
||||
+44
-102
@@ -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
|
||||
|
||||
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
|
||||
provider_name = task.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()
|
||||
task.status = TASK_FAILED
|
||||
task.error = f"未知 provider: {provider_name}"
|
||||
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 {
|
||||
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, 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 [])
|
||||
]
|
||||
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}"
|
||||
|
||||
+45
-48
@@ -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"
|
||||
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.\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 "
|
||||
"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."
|
||||
)
|
||||
"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}"
|
||||
|
||||
# ── 商品上下文提炼 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user