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 │
|
│ 页内悬浮面板 │ │ 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-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
|
- 插件只传模型名,服务端按模型名自动路由到对应 provider
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|||||||
@@ -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
@@ -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 }>;
|
||||||
|
|||||||
@@ -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 __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)
|
||||||
|
|||||||
@@ -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
|
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()
|
|
||||||
# 模型名优先路由:已知模型直接定位 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)
|
@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\""},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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
@@ -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="覆盖默认 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):
|
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
@@ -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/edits(multipart),无参考图走 /v1/images/generations。
|
"""RightAPI 各模型:有参考图走 /v1/images/edits(multipart),无参考图走 /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
@@ -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}"
|
||||||
|
|
||||||
# ── 商品上下文提炼 ────────────────────────────────────────────────────────
|
# ── 商品上下文提炼 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
#!/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
|
||||||
|
|||||||
Reference in New Issue
Block a user