修复chat生成参数提交错误BUG|模型引擎积分设置关联开发优化成功|视频/图片生成引擎API开发完成

This commit is contained in:
2026-05-28 13:00:27 +08:00
parent 5021cfd35b
commit edef601f7c
17 changed files with 890 additions and 483 deletions
+2 -1
View File
@@ -140,4 +140,5 @@ temp/
# =========================
*.bak
*.swp
*.swo
*.swo
*.zip
File diff suppressed because one or more lines are too long
+13 -13
View File
@@ -1,13 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VideoGen.AI 管理后台</title>
<script type="module" crossorigin src="/assets/index-D5S5oe4i.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VideoGen.AI 管理后台</title>
<script type="module" crossorigin src="/assets/index-CKd9mNbJ.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
+7
View File
@@ -6,6 +6,7 @@ import { api, setToken, clearToken } from './client';
import type {
User, CreditRecord, Project, GenerationRecord, GenerationParams,
Industry, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification,
GenerationAiEnginesResponse,
} from '../types';
// ── Auth ──────────────────────────────────────────────────
@@ -302,3 +303,9 @@ export async function adminGenerateVideo(
): Promise<void> {
await api.post(`/admin/generation-records/${recordId}/generate`, { aspect_ratio: aspectRatio, resolution, image_size });
}
// ── Generation AI Engines (Admin) ─────────────────────────────
export async function getGenerationAiEngines(): Promise<GenerationAiEnginesResponse> {
return api.get<GenerationAiEnginesResponse>(`/generation-ai/engines`);
}
+113 -39
View File
@@ -1,44 +1,74 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import {
Button, Card, Form, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography,
} from 'antd';
import {
CalculatorOutlined, PlusOutlined, EditOutlined, DeleteOutlined, FontSizeOutlined,
} from '@ant-design/icons';
import { getCreditRatios, saveCreditRatio, deleteCreditRatio, getModelConfigs, getSystemConfigs, updateSystemConfig } from '../api';
import type { ModelConfig } from '../types';
import {
getCreditRatios, saveCreditRatio,
deleteCreditRatio, getSystemConfigs,
updateSystemConfig, getGenerationAiEngines,
} from '../api';
import type { GenerationAiEngineOption } from '../types';
type CreditGenType = 'image' | 'video';
interface CreditRatio {
id: string;
modelConfigId: string;
genType: string;
genType: CreditGenType | string;
resolution: string;
ratio: number;
baseCredits: number;
perSecondCredits: number;
}
interface CreditRatioFormValues {
modelConfigId: string;
genType: CreditGenType;
resolution: string;
ratio: number;
baseCredits: number;
perSecondCredits?: number;
}
const DEFAULT_IMAGE_SIZES = ['2K', '4K'];
const DEFAULT_VIDEO_RESOLUTIONS = ['480p', '720p', '1080p'];
const AdminCreditRatios: React.FC = () => {
const [ratios, setRatios] = useState<CreditRatio[]>([]);
const [models, setModels] = useState<ModelConfig[]>([]);
const [engines, setEngines] = useState<GenerationAiEngineOption[]>([]);
const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; ratio: CreditRatio | null }>({ open: false, ratio: null });
const [form] = Form.useForm();
const [form] = Form.useForm<CreditRatioFormValues>();
const [textRate, setTextRate] = useState<number>(10);
const [textRateConfig, setTextRateConfig] = useState<{ id: string } | null>(null);
const [savingTextRate, setSavingTextRate] = useState(false);
const genType = Form.useWatch('genType', form);
const genType = Form.useWatch('genType', form) || 'video';
const selectedEngineId = Form.useWatch('modelConfigId', form);
const load = async () => {
setLoading(true);
try {
const [ratioData, modelData, sysConfigs] = await Promise.all([
const [ratioData, sysConfigs, enginesData] = await Promise.all([
getCreditRatios(),
getModelConfigs(),
getSystemConfigs(),
getGenerationAiEngines(),
]);
const imageEngines: GenerationAiEngineOption[] = (enginesData?.engine?.image || []).map(engine => ({
...engine,
genType: 'image' as const,
}));
const videoEngines: GenerationAiEngineOption[] = (enginesData?.engine?.video || []).map(engine => ({
...engine,
genType: 'video' as const,
}));
setRatios(ratioData);
setModels(modelData);
setEngines([...videoEngines, ...imageEngines]);
const textCfg = sysConfigs.find((c: any) => c.key === 'text_credits_per_1000_tokens');
if (textCfg) {
setTextRate(Number(textCfg.value) || 10);
@@ -53,7 +83,36 @@ const AdminCreditRatios: React.FC = () => {
useEffect(() => { load(); }, []);
const modelNameOf = (id: string) => models.find(m => m.id === id)?.name || id;
const currentEngineOptions = useMemo(() => (
engines
.filter(engine => engine.genType === genType)
.map(engine => ({
value: engine.id,
label: `${engine.name}${engine.modelName ? `${engine.modelName}` : ''}`,
}))
), [engines, genType]);
const selectedEngine = useMemo(() => (
engines.find(engine => engine.id === selectedEngineId && engine.genType === genType)
), [engines, genType, selectedEngineId]);
const resolutionOptions = useMemo(() => {
if (genType === 'image') {
const sizes = selectedEngine?.supportedSizes ? Object.keys(selectedEngine.supportedSizes) : [];
return (sizes.length ? sizes : DEFAULT_IMAGE_SIZES).map(size => ({ value: size, label: size }));
}
const resolutions = selectedEngine?.supportedResolutions || [];
return (resolutions.length ? resolutions : DEFAULT_VIDEO_RESOLUTIONS).map(resolution => ({
value: resolution,
label: resolution,
}));
}, [genType, selectedEngine]);
const engineNameOf = (id: string, rowGenType?: string) => {
const engine = engines.find(item => item.id === id && (!rowGenType || item.genType === rowGenType));
return engine?.name || id;
};
const handleSave = async () => {
try {
@@ -64,7 +123,7 @@ const AdminCreditRatios: React.FC = () => {
resolution: values.resolution,
ratio: values.ratio,
base_credits: values.baseCredits,
per_second_credits: values.genType === 'image' ? 0 : values.perSecondCredits,
per_second_credits: values.genType === 'image' ? 0 : (values.perSecondCredits || 0),
};
if (modal.ratio) {
await saveCreditRatio({ id: modal.ratio.id, ...payload });
@@ -108,7 +167,14 @@ const AdminCreditRatios: React.FC = () => {
const openEdit = (ratio?: CreditRatio) => {
setModal({ open: true, ratio: ratio || null });
if (ratio) {
form.setFieldsValue(ratio);
form.setFieldsValue({
modelConfigId: ratio.modelConfigId,
genType: ratio.genType === 'image' ? 'image' : 'video',
resolution: ratio.resolution,
ratio: ratio.ratio,
baseCredits: ratio.baseCredits,
perSecondCredits: ratio.perSecondCredits,
});
} else {
form.resetFields();
form.setFieldsValue({ genType: 'video', ratio: 1.0, baseCredits: 60, perSecondCredits: 2 });
@@ -121,8 +187,8 @@ const AdminCreditRatios: React.FC = () => {
render: (v: string) => <Tag color={v === 'image' ? 'cyan' : 'orange'}>{v === 'image' ? '图片' : '视频'}</Tag>,
},
{
title: '模型', dataIndex: 'modelConfigId', width: 150,
render: (v: string) => <Tag color="purple">{modelNameOf(v)}</Tag>,
title: '引擎', dataIndex: 'modelConfigId', width: 180,
render: (v: string, r: CreditRatio) => <Tag color={r.genType === 'image' ? 'cyan' : 'purple'}>{engineNameOf(v, r.genType)}</Tag>,
},
{
title: '分辨率/尺寸', dataIndex: 'resolution', width: 110,
@@ -145,7 +211,9 @@ const AdminCreditRatios: React.FC = () => {
},
{
title: '每秒积分', dataIndex: 'perSecondCredits', width: 100,
render: (v: number) => <Typography.Text>{v} /</Typography.Text>,
render: (v: number, r: CreditRatio) => (
<Typography.Text>{r.genType === 'image' ? '-' : `${v} 积分/秒`}</Typography.Text>
),
},
{
title: '示例计算', key: 'example', width: 120,
@@ -206,7 +274,7 @@ const AdminCreditRatios: React.FC = () => {
</div>
</Card>
{/* Video Credit Ratios */}
{/* Generation Credit Ratios */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
@@ -220,7 +288,7 @@ const AdminCreditRatios: React.FC = () => {
</div>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16, fontSize: 13 }}>
( + x ) x
( + x ) x x
</Typography.Text>
<Table
@@ -229,7 +297,7 @@ const AdminCreditRatios: React.FC = () => {
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 800 }}
scroll={{ x: 860 }}
/>
</Card>
@@ -238,37 +306,43 @@ const AdminCreditRatios: React.FC = () => {
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, ratio: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={480}
okText="保存" cancelText="取消" width={520}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="modelConfigId" label="型" rules={[{ required: true, message: '请选择型' }]}>
<Select size="large" options={models.map(m => ({ value: m.id, label: m.name }))} />
<Form.Item name="genType" label="生成类型" rules={[{ required: true, message: '请选择生成类型' }]}>
<Select
size="large"
options={[
{ value: 'video', label: '视频' },
{ value: 'image', label: '图片' },
]}
onChange={() => {
form.setFieldsValue({ modelConfigId: undefined as any, resolution: undefined as any });
}}
/>
</Form.Item>
<Form.Item name="genType" label="生成类型" rules={[{ required: true }]}>
<Select size="large" options={[
{ value: 'video', label: '视频' },
{ value: 'image', label: '图片' },
]} />
<Form.Item name="modelConfigId" label="引擎" rules={[{ required: true, message: '请选择引擎' }]}>
<Select
size="large"
placeholder="请选择引擎"
options={currentEngineOptions}
showSearch
optionFilterProp="label"
onChange={() => form.setFieldsValue({ resolution: undefined as any })}
/>
</Form.Item>
<Form.Item name="resolution" label={genType === 'image' ? '图片尺寸' : '分辨率'} rules={[{ required: true }]}>
<Select size="large" options={genType === 'image' ? [
{ value: '2K', label: '2K' },
{ value: '4K', label: '4K' },
] : [
{ value: '480p', label: '480p' },
{ value: '720p', label: '720p' },
{ value: '1080p', label: '1080p' },
]} />
<Form.Item name="resolution" label={genType === 'image' ? '图片尺寸' : '分辨率'} rules={[{ required: true, message: genType === 'image' ? '请选择图片尺寸' : '请选择分辨率' }]}>
<Select size="large" placeholder={genType === 'image' ? '请选择图片尺寸' : '请选择分辨率'} options={resolutionOptions} />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="ratio" label="倍率" style={{ flex: 1 }} rules={[{ required: true }]}>
<Form.Item name="ratio" label="倍率" style={{ flex: 1 }} rules={[{ required: true, message: '请输入倍率' }]}>
<InputNumber min={0.1} max={10} step={0.1} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="baseCredits" label="基础积分" style={{ flex: 1 }} rules={[{ required: true }]}>
<Form.Item name="baseCredits" label="基础积分" style={{ flex: 1 }} rules={[{ required: true, message: '请输入基础积分' }]}>
<InputNumber min={0} max={1000} style={{ width: '100%' }} size="large" />
</Form.Item>
{genType !== 'image' && (
<Form.Item name="perSecondCredits" label="每秒积分" style={{ flex: 1 }} rules={[{ required: true }]}>
<Form.Item name="perSecondCredits" label="每秒积分" style={{ flex: 1 }} rules={[{ required: true, message: '请输入每秒积分' }]}>
<InputNumber min={0} max={100} style={{ width: '100%' }} size="large" />
</Form.Item>
)}
+50
View File
@@ -147,6 +147,56 @@ export interface AdminNotification {
createdAt: string;
}
export type GenerationAiGenType = 'image' | 'video';
export interface GenerationAiImageEngine {
id: string;
name: string;
provider: string;
modelName: string;
supportedModels: string[];
supportedSizes: Record<string, Record<string, string>>;
defaultSize: string;
priority: number;
}
export interface GenerationAiVideoEngine {
id: string;
name: string;
provider: string;
modelName: string;
supportedModels: string[];
supportedRatios: string[];
supportedResolutions: string[];
supportedDurations: number[];
maxDuration: number;
priority: number;
}
export interface GenerationAiEnginesResponse {
engine: {
image: GenerationAiImageEngine[];
video: GenerationAiVideoEngine[];
};
}
export interface GenerationAiEngineOption {
id: string;
name: string;
provider: string;
modelName: string;
supportedModels?: string[];
supportedRatios?: string[];
supportedResolutions?: string[];
supportedDurations?: number[];
supportedSizes?: Record<string, Record<string, string>>;
defaultSize?: string;
maxDuration?: number;
priority: number;
genType: GenerationAiGenType;
}
export interface AdminGenerationRecord {
id: string;
userId: string;
@@ -0,0 +1,249 @@
"""drop credit ratio model config foreign key and add engine indexes
Revision ID: 6846d43389b6
Revises: c310d7193c7f
Create Date: 2026-05-28 11:04:37.270041
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "6846d43389b6"
down_revision: Union[str, None] = "c310d7193c7f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
OLD_FK_NAME = "credit_ratios_model_config_id_fkey"
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
return any(index.get("name") == index_name for index in inspector.get_indexes(table_name))
def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None:
if _index_exists(bind, table_name, index_name):
op.drop_index(index_name, table_name=table_name)
def _create_index_if_missing(
bind,
index_name: str,
table_name: str,
columns: list[str],
unique: bool = False,
) -> None:
if not _index_exists(bind, table_name, index_name):
op.create_index(index_name, table_name, columns, unique=unique)
def _get_credit_ratio_model_config_fk_name(bind) -> str | None:
"""兼容不同数据库/命名规则,查找 credit_ratios.model_config_id -> model_configs.id 的外键名。"""
inspector = sa.inspect(bind)
for fk in inspector.get_foreign_keys("credit_ratios"):
constrained_columns = fk.get("constrained_columns") or []
referred_table = fk.get("referred_table")
referred_columns = fk.get("referred_columns") or []
fk_name = fk.get("name")
if (
constrained_columns == ["model_config_id"]
and referred_table == "model_configs"
and referred_columns == ["id"]
):
return fk_name
return None
def _table_has_rows(bind, table_name: str, where_sql: str | None = None) -> bool:
sql = f"SELECT COUNT(*) FROM {table_name}"
if where_sql:
sql += f" WHERE {where_sql}"
count = bind.execute(sa.text(sql)).scalar()
return bool(count)
def _get_default_video_engine_id(bind) -> str | None:
"""获取默认视频引擎ID:优先启用状态 priority 降序第一;没有启用时取全部 priority 降序第一。"""
engine_id = bind.execute(
sa.text(
"""
SELECT id
FROM video_engines
WHERE is_active IS TRUE
ORDER BY priority DESC, id ASC
LIMIT 1
"""
)
).scalar()
if engine_id:
return engine_id
return bind.execute(
sa.text(
"""
SELECT id
FROM video_engines
ORDER BY priority DESC, id ASC
LIMIT 1
"""
)
).scalar()
def _get_default_image_engine_id(bind) -> str | None:
"""获取默认图片引擎ID:优先启用状态 priority 降序第一;没有启用时取全部 priority 降序第一。"""
engine_id = bind.execute(
sa.text(
"""
SELECT id
FROM image_engines
WHERE is_active IS TRUE
ORDER BY priority DESC, id ASC
LIMIT 1
"""
)
).scalar()
if engine_id:
return engine_id
return bind.execute(
sa.text(
"""
SELECT id
FROM image_engines
ORDER BY priority DESC, id ASC
LIMIT 1
"""
)
).scalar()
def upgrade() -> None:
bind = op.get_bind()
# 1. 删除 credit_ratios.model_config_id -> model_configs.id 外键。
# 删除后 model_config_id 保留旧字段名,但业务含义变更为:
# - gen_type=video 时保存 video_engines.id
# - gen_type=image 时保存 image_engines.id
fk_name = _get_credit_ratio_model_config_fk_name(bind)
if fk_name:
op.drop_constraint(fk_name, "credit_ratios", type_="foreignkey")
# 2. 规范历史 gen_type,避免 Video / IMAGE / 空格 影响后续数据更新。
bind.execute(
sa.text(
"""
UPDATE credit_ratios
SET gen_type = LOWER(TRIM(gen_type))
WHERE gen_type IS NOT NULL
"""
)
)
# 3. 将当前 CreditRatio 内原来绑定 ModelConfig 的 model_config_id
# 改为不同类型对应的默认引擎ID。
#
# gen_type=video -> video_engines 中 priority 权重降序第一的 ID
# gen_type=image -> image_engines 中 priority 权重降序第一的 ID
#
# 这里不建备份表,降级也不回滚这部分业务数据。
default_video_engine_id = _get_default_video_engine_id(bind)
default_image_engine_id = _get_default_image_engine_id(bind)
has_video_ratios = _table_has_rows(bind, "credit_ratios", "gen_type = 'video'")
has_image_ratios = _table_has_rows(bind, "credit_ratios", "gen_type = 'image'")
if has_video_ratios and not default_video_engine_id:
raise RuntimeError(
"迁移失败:credit_ratios 中存在 gen_type=video 的积分规则,"
"但 video_engines 表没有可用视频引擎,无法将 model_config_id 改为默认视频引擎ID。"
)
if has_image_ratios and not default_image_engine_id:
raise RuntimeError(
"迁移失败:credit_ratios 中存在 gen_type=image 的积分规则,"
"但 image_engines 表没有可用图片引擎,无法将 model_config_id 改为默认图片引擎ID。"
)
if default_video_engine_id:
bind.execute(
sa.text(
"""
UPDATE credit_ratios
SET model_config_id = :engine_id
WHERE gen_type = 'video'
"""
),
{"engine_id": default_video_engine_id},
)
if default_image_engine_id:
bind.execute(
sa.text(
"""
UPDATE credit_ratios
SET model_config_id = :engine_id
WHERE gen_type = 'image'
"""
),
{"engine_id": default_image_engine_id},
)
# 4. 创建当前模型层需要的索引。
_create_index_if_missing(
bind,
"ix_credit_ratios_gen_type",
"credit_ratios",
["gen_type"],
)
_create_index_if_missing(
bind,
"ix_credit_ratios_model_config_id",
"credit_ratios",
["model_config_id"],
)
_create_index_if_missing(
bind,
"ix_credit_ratios_resolution",
"credit_ratios",
["resolution"],
)
_create_index_if_missing(
bind,
"ix_credit_ratios_gen_type_engine_resolution",
"credit_ratios",
["gen_type", "model_config_id", "resolution"],
)
_create_index_if_missing(
bind,
"ix_credit_ratios_gen_type_resolution",
"credit_ratios",
["gen_type", "resolution"],
)
def downgrade() -> None:
bind = op.get_bind()
# 业务需求:降级不回滚 credit_ratios.model_config_id 数据。
# 当前 model_config_id 已经是 image_engines.id / video_engines.id
# 因此这里也不恢复 credit_ratios.model_config_id -> model_configs.id 外键。
#
# 否则会出现两种问题:
# 1. 字段里是引擎ID,不是 model_configs.id,恢复外键会失败;
# 2. 如果为了恢复外键强行回填 model_configs.id,就违背“降级不回滚业务数据”的要求。
#
# 所以 downgrade 只撤销本次新增索引,保留业务数据和取消外键后的结构。
_drop_index_if_exists(bind, "ix_credit_ratios_gen_type_resolution", "credit_ratios")
_drop_index_if_exists(bind, "ix_credit_ratios_gen_type_engine_resolution", "credit_ratios")
_drop_index_if_exists(bind, "ix_credit_ratios_resolution", "credit_ratios")
_drop_index_if_exists(bind, "ix_credit_ratios_model_config_id", "credit_ratios")
_drop_index_if_exists(bind, "ix_credit_ratios_gen_type", "credit_ratios")
+34 -2
View File
@@ -651,6 +651,30 @@ async def delete_image_engine(
return {"message": "ok"}
async def _validate_credit_ratio_engine(db: AsyncSession, req: CreditRatioCreate) -> None:
"""校验积分规则绑定的引擎是否存在。
CreditRatio.model_config_id 为兼容旧字段名,当前实际保存引擎ID:
- gen_type=image 时对应 image_engines.id
- gen_type=video 时对应 video_engines.id
"""
gen_type = (req.gen_type or "").lower().strip()
engine_id = (req.model_config_id or "").strip()
if gen_type not in ("image", "video"):
raise HTTPException(status_code=400, detail="gen_type 仅支持 image 或 video")
if not engine_id:
raise HTTPException(status_code=400, detail="model_config_id 不能为空,当前字段用于保存图片/视频引擎ID")
model = ImageEngine if gen_type == "image" else VideoEngine
result = await db.execute(select(model).where(model.id == engine_id).limit(1))
engine = result.scalar_one_or_none()
if not engine:
detail = "图片积分规则绑定的图片引擎不存在" if gen_type == "image" else "视频积分规则绑定的视频引擎不存在"
raise HTTPException(status_code=400, detail=detail)
# ── Credit Ratio ─────────────────────────────────────────
@router.get("/credit-ratios", response_model=list[CreditRatioOut])
@@ -668,7 +692,11 @@ async def create_credit_ratio(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
ratio = CreditRatio(id=generate_id(), **req.model_dump())
await _validate_credit_ratio_engine(db, req)
data = req.model_dump()
data["gen_type"] = data["gen_type"].lower().strip()
data["model_config_id"] = data["model_config_id"].strip()
ratio = CreditRatio(id=generate_id(), **data)
db.add(ratio)
await db.flush()
return ratio
@@ -687,7 +715,11 @@ async def update_credit_ratio(
ratio = result.scalar_one_or_none()
if not ratio:
raise HTTPException(status_code=404, detail="积分比例不存在")
for k, v in req.model_dump().items():
await _validate_credit_ratio_engine(db, req)
data = req.model_dump()
data["gen_type"] = data["gen_type"].lower().strip()
data["model_config_id"] = data["model_config_id"].strip()
for k, v in data.items():
setattr(ratio, k, v)
await db.flush()
return ratio
+66
View File
@@ -6,6 +6,7 @@ from app.dependencies import get_current_user, get_db
from app.models.chat_generation_task import ChatGenerationTask
from app.models.user import User
from app.schemas.generation_ai import (
GenerationAIEngineOptionsOut,
GenerationAIHistoryDayItemsOut,
GenerationAIHistoryGroupedOut,
GenerationAIRetryOut,
@@ -15,6 +16,7 @@ from app.schemas.generation_ai import (
)
from app.services.generation_ai_service import (
create_async_generation_task,
list_generation_ai_engine_options,
list_async_generation_tasks,
list_generation_history_day_items,
list_generation_history_grouped_days,
@@ -29,6 +31,70 @@ router = APIRouter(
)
@router.get(
"/engines",
response_model=GenerationAIEngineOptionsOut,
summary="获取AI图片/视频可用引擎列表",
description=(
"获取当前启用状态的图片生成引擎和视频生成引擎。"
"返回格式为 engine.image 和 engine.video 两个数组。"
"前端创建 /generation-ai/tasks 任务时,可以把对应引擎 id 作为 engine_id 传入。"
"该接口只返回前端需要展示和选择的模型能力信息,不返回 api_key 等敏感配置。"
),
responses={
200: {
"description": "查询成功,返回当前启用的图片/视频生成引擎列表",
"content": {
"application/json": {
"example": {
"engine": {
"image": [
{
"id": "image_engine_xxx",
"name": "豆包文生图",
"provider": "ark",
"model_name": "doubao-seedream-5-0-260128",
"supported_models": ["doubao-seedream-5-0-260128"],
"supported_sizes": {
"2K": {
"1:1": "2048x2048",
"16:9": "2560x1440",
}
},
"default_size": "2K",
"priority": 10,
}
],
"video": [
{
"id": "video_engine_xxx",
"name": "Seedance 2.0",
"provider": "ark",
"model_name": "doubao-seedance-2-0-260128",
"supported_ratios": ["16:9", "4:3", "1:1", "3:4", "9:16", "21:9"],
"supported_resolutions": ["480p", "720p", "1080p"],
"supported_durations": [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
"max_duration": 15,
"priority": 10,
}
],
}
}
}
},
},
401: {
"description": "未登录或 Token 无效",
},
},
)
async def list_engines(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await list_generation_ai_engine_options(db)
@router.post(
"/tasks",
response_model=GenerationAITaskOut,
+61 -17
View File
@@ -208,26 +208,70 @@ async def _seed_data():
)
)
# Seed credit ratios - use first model config if available
model_result = await db.execute(select(ModelConfig).limit(1))
model = model_result.scalar_one_or_none()
if model:
existing_ratio = await db.execute(
select(CreditRatio).where(CreditRatio.model_config_id == model.id).limit(1)
)
if not existing_ratio.scalars().first():
for gen_type, resolution, ratio_val, base, per_sec in [
("video", "480p", 1.0, 60, 2),
("video", "720p", 1.0, 80, 2),
("video", "1080p", 1.5, 120, 3),
("image", "2K", 1.0, 4, 0),
("image", "4K", 1.0, 6, 0),
]:
# Seed credit ratios - model_config_id is kept as a compatible field name,
# but now stores the actual engine id:
# - gen_type=video -> video_engines.id
# - gen_type=image -> image_engines.id
await db.flush()
default_video_engine_result = await db.execute(
select(VideoEngine)
.where(VideoEngine.is_active == True)
.order_by(VideoEngine.priority.desc(), VideoEngine.id.desc())
.limit(1)
)
default_video_engine = default_video_engine_result.scalar_one_or_none()
if default_video_engine:
for resolution, ratio_val, base, per_sec in [
("480p", 1.0, 60, 2),
("720p", 1.0, 80, 2),
("1080p", 1.5, 120, 3),
]:
existing_ratio = await db.execute(
select(CreditRatio)
.where(CreditRatio.model_config_id == default_video_engine.id)
.where(CreditRatio.gen_type == "video")
.where(CreditRatio.resolution == resolution)
.limit(1)
)
if not existing_ratio.scalar_one_or_none():
db.add(
CreditRatio(
id=generate_id(),
model_config_id=model.id,
gen_type=gen_type,
model_config_id=default_video_engine.id,
gen_type="video",
resolution=resolution,
ratio=ratio_val,
base_credits=base,
per_second_credits=per_sec,
)
)
default_image_engine_result = await db.execute(
select(ImageEngine)
.where(ImageEngine.is_active == True)
.order_by(ImageEngine.priority.desc(), ImageEngine.id.desc())
.limit(1)
)
default_image_engine = default_image_engine_result.scalar_one_or_none()
if default_image_engine:
for resolution, ratio_val, base, per_sec in [
("2K", 1.0, 4, 0),
("4K", 1.0, 6, 0),
]:
existing_ratio = await db.execute(
select(CreditRatio)
.where(CreditRatio.model_config_id == default_image_engine.id)
.where(CreditRatio.gen_type == "image")
.where(CreditRatio.resolution == resolution)
.limit(1)
)
if not existing_ratio.scalar_one_or_none():
db.add(
CreditRatio(
id=generate_id(),
model_config_id=default_image_engine.id,
gen_type="image",
resolution=resolution,
ratio=ratio_val,
base_credits=base,
+12 -6
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Float, ForeignKey, Integer, String
from sqlalchemy import Float, Index, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
@@ -6,13 +6,19 @@ from app.models.base import Base, TimestampMixin
class CreditRatio(Base, TimestampMixin):
__tablename__ = "credit_ratios"
__table_args__ = (
Index("ix_credit_ratios_gen_type_engine_resolution", "gen_type", "model_config_id", "resolution"),
Index("ix_credit_ratios_gen_type_resolution", "gen_type", "resolution"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
model_config_id: Mapped[str] = mapped_column(
String(32), ForeignKey("model_configs.id")
)
gen_type: Mapped[str] = mapped_column(String(16), default="video")
resolution: Mapped[str] = mapped_column(String(16), nullable=False)
# 兼容旧字段名:
# gen_type=image 时,该字段保存 image_engines.id
# gen_type=video 时,该字段保存 video_engines.id。
# 不再通过数据库外键绑定 model_configs.id,避免同一字段无法同时关联图片/视频引擎表。
model_config_id: Mapped[str] = mapped_column(String(32), index=True)
gen_type: Mapped[str] = mapped_column(String(16), default="video", index=True)
resolution: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
ratio: Mapped[float] = mapped_column(Float, nullable=False)
base_credits: Mapped[float] = mapped_column(Float, default=80.0)
per_second_credits: Mapped[float] = mapped_column(Float, default=2.0)
+43 -10
View File
@@ -1,19 +1,52 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from app.schemas.common import NaiveDatetime
class CreditRatioCreate(BaseModel):
model_config_id: str = Field(..., max_length=32)
gen_type: str = Field(default="video", max_length=16)
resolution: str = Field(..., max_length=16)
ratio: float = Field(..., gt=0)
base_credits: float = Field(default=80.0, ge=0)
per_second_credits: float = Field(default=2.0, ge=0)
model_config_id: str = Field(
...,
max_length=32,
description=(
"引擎ID,兼容旧字段名。gen_type=image 时填写 image_engines.id"
"gen_type=video 时填写 video_engines.id。当前不再绑定 model_configs.id 外键"
),
examples=["engine_xxx"],
)
gen_type: str = Field(
default="video",
max_length=16,
description="生成类型:image=图片积分规则,video=视频积分规则",
examples=["video"],
)
resolution: str = Field(
...,
max_length=16,
description="计费参数。视频为分辨率,例如 480p/720p/1080p;图片为分辨率档位,例如 2K/4K",
examples=["720p"],
)
ratio: float = Field(
...,
gt=0,
description="积分倍率。最终扣费会乘以该倍率",
examples=[1.0],
)
base_credits: float = Field(
default=80.0,
ge=0,
description="基础积分。视频按 base_credits + per_second_credits * duration 后再乘 ratio;图片按 base_credits * ratio",
examples=[80.0],
)
per_second_credits: float = Field(
default=2.0,
ge=0,
description="视频每秒积分。图片规则通常为 0",
examples=[2.0],
)
class CreditRatioOut(CreditRatioCreate):
id: str
created_at: NaiveDatetime
id: str = Field(..., description="积分规则ID")
created_at: NaiveDatetime = Field(..., description="创建时间")
model_config = {"from_attributes": True}
model_config = ConfigDict(from_attributes=True)
@@ -152,6 +152,96 @@ class GenerationAITaskCreate(BaseModel):
)
class GenerationAIImageEngineOptionOut(BaseModel):
"""AI图片生成可用引擎响应项。"""
id: str = Field(..., description="图片引擎ID,创建图片任务时传入 engine_id")
name: str = Field(..., description="图片引擎名称,前端展示用")
provider: str = Field(..., description="服务商标识,例如 ark")
model_name: str | None = Field(None, description="服务商模型名称")
supported_models: list[str] = Field(default_factory=list, description="该图片引擎支持的模型名称列表")
supported_sizes: dict = Field(
default_factory=dict,
description="支持的图片尺寸映射。第一层为分辨率档位,例如 2K/4K;第二层为画布比例,例如 1:1/16:9;值为像素尺寸",
)
default_size: str | None = Field(None, description="默认图片分辨率档位,例如 2K")
priority: int = Field(0, description="引擎优先级,数值越大越优先")
class GenerationAIVideoEngineOptionOut(BaseModel):
"""AI视频生成可用引擎响应项。"""
id: str = Field(..., description="视频引擎ID,创建视频任务时传入 engine_id")
name: str = Field(..., description="视频引擎名称,前端展示用")
provider: str = Field(..., description="服务商标识,例如 ark")
model_name: str | None = Field(None, description="服务商模型名称")
supported_ratios: list[str] = Field(default_factory=list, description="支持的视频画面比例,例如 16:9、9:16、1:1")
supported_resolutions: list[str] = Field(default_factory=list, description="支持的视频分辨率,例如 480p、720p、1080p")
supported_durations: list[int] = Field(default_factory=list, description="支持的视频时长列表,单位秒")
max_duration: int | None = Field(None, description="最大视频时长,单位秒")
priority: int = Field(0, description="引擎优先级,数值越大越优先")
class GenerationAIEngineGroupOut(BaseModel):
"""AI图片/视频引擎分组。"""
image: list[GenerationAIImageEngineOptionOut] = Field(
default_factory=list,
description="当前启用的图片生成引擎列表",
)
video: list[GenerationAIVideoEngineOptionOut] = Field(
default_factory=list,
description="当前启用的视频生成引擎列表",
)
class GenerationAIEngineOptionsOut(BaseModel):
"""AI图片/视频生成引擎列表响应体。"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"engine": {
"image": [
{
"id": "image_engine_xxx",
"name": "豆包文生图",
"provider": "ark",
"model_name": "doubao-seedream-5-0-260128",
"supported_models": ["doubao-seedream-5-0-260128"],
"supported_sizes": {
"2K": {
"1:1": "2048x2048",
"16:9": "2560x1440",
}
},
"default_size": "2K",
"priority": 10,
}
],
"video": [
{
"id": "video_engine_xxx",
"name": "Seedance 2.0",
"provider": "ark",
"model_name": "doubao-seedance-2-0-260128",
"supported_ratios": ["16:9", "4:3", "1:1", "3:4", "9:16", "21:9"],
"supported_resolutions": ["480p", "720p", "1080p"],
"supported_durations": [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
"max_duration": 15,
"priority": 10,
}
],
}
}
}
)
engine: GenerationAIEngineGroupOut = Field(..., description="图片/视频可用引擎分组")
class GenerationAITaskOut(BaseModel):
"""AI生成任务详情响应体。"""
+71 -9
View File
@@ -22,14 +22,63 @@ async def calc_text_credits(db: AsyncSession, input_tokens: int, output_tokens:
return round(total_tokens * rate / 1000, 2)
async def calc_video_credits(db: AsyncSession, duration: int, resolution: str) -> float:
"""Calculate video credits using CreditRatio table, with fallback to hardcoded."""
async def _get_credit_ratio(
db: AsyncSession,
*,
gen_type: str,
resolution: str,
engine_id: str | None = None,
) -> CreditRatio | None:
"""按引擎精确规则优先获取积分规则;找不到时回退到同类型同参数最高规则。"""
gen_type = (gen_type or "").lower().strip()
resolution = (resolution or "").strip()
engine_id = (engine_id or "").strip() or None
if engine_id:
result = await db.execute(
select(CreditRatio)
.where(CreditRatio.gen_type == gen_type)
.where(CreditRatio.model_config_id == engine_id)
.where(CreditRatio.resolution == resolution)
.order_by(CreditRatio.base_credits.desc(), CreditRatio.per_second_credits.desc())
.limit(1)
)
ratio = result.scalar_one_or_none()
if ratio:
return ratio
result = await db.execute(
select(CreditRatio).where(CreditRatio.resolution == resolution).limit(1)
select(CreditRatio)
.where(CreditRatio.gen_type == gen_type)
.where(CreditRatio.resolution == resolution)
.order_by(CreditRatio.base_credits.desc(), CreditRatio.per_second_credits.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def calc_video_credits(
db: AsyncSession,
duration: int,
resolution: str,
engine_id: str | None = None,
) -> float:
"""Calculate video credits using CreditRatio table, with fallback to hardcoded.
查询优先级:
1. gen_type=video + engine_id + resolution 精确规则;
2. gen_type=video + resolution 下 base_credits/per_second_credits 最高规则;
3. 原硬编码默认算法。
"""
ratio = await _get_credit_ratio(
db,
gen_type="video",
resolution=resolution,
engine_id=engine_id,
)
ratio = result.scalar_one_or_none()
if ratio:
return round((ratio.base_credits + ratio.per_second_credits * duration) * ratio.ratio, 2)
# Fallback
base = 60.0
duration_cost = duration * 2.0
@@ -45,14 +94,27 @@ def calc_credits(duration: int, resolution: str) -> float:
return round((base + duration_cost) * multiplier, 2)
async def calc_image_credits(db: AsyncSession, image_size: str) -> float:
"""Calculate image credits using CreditRatio table, with fallback to hardcoded."""
result = await db.execute(
select(CreditRatio).where(CreditRatio.gen_type == "image").where(CreditRatio.resolution == image_size).limit(1)
async def calc_image_credits(
db: AsyncSession,
image_size: str,
engine_id: str | None = None,
) -> float:
"""Calculate image credits using CreditRatio table, with fallback to hardcoded.
查询优先级:
1. gen_type=image + engine_id + image_size 精确规则;
2. gen_type=image + image_size 下 base_credits/per_second_credits 最高规则;
3. 原硬编码默认算法。
"""
ratio = await _get_credit_ratio(
db,
gen_type="image",
resolution=image_size,
engine_id=engine_id,
)
ratio = result.scalar_one_or_none()
if ratio:
return round(ratio.base_credits * ratio.ratio, 2)
# Fallback
multiplier = {"4K": 2.0, "2K": 1.0}.get(image_size, 1.0)
base_cost = 4.0
@@ -16,9 +16,13 @@ from app.models.image_engine import ImageEngine
from app.models.user import User
from app.models.video_engine import VideoEngine
from app.schemas.generation_ai import (
GenerationAIEngineGroupOut,
GenerationAIEngineOptionsOut,
GenerationAIImageEngineOptionOut,
GenerationAIRecordHistoryItemOut,
GenerationAITaskCreate,
GenerationAITaskOut,
GenerationAIVideoEngineOptionOut,
)
from app.services.generation_billing_service import charge_generation_media_by_params
from app.utils.id_gen import generate_id
@@ -135,6 +139,52 @@ def _build_video_snapshot(engine: VideoEngine, ratio: str, resolution: str, dura
}
async def list_generation_ai_engine_options(db: AsyncSession) -> GenerationAIEngineOptionsOut:
"""获取当前启用的图片/视频生成引擎,供前端创建任务时选择 engine_id。"""
image_result = await db.execute(
select(ImageEngine)
.where(ImageEngine.is_active == True)
.order_by(ImageEngine.priority.desc())
)
video_result = await db.execute(
select(VideoEngine)
.where(VideoEngine.is_active == True)
.order_by(VideoEngine.priority.desc())
)
image_items = [
GenerationAIImageEngineOptionOut(
id=engine.id,
name=engine.name,
provider=engine.provider,
model_name=engine.model_name,
supported_models=_parse_list(engine.supported_models, []),
supported_sizes=_image_supported_sizes(engine),
default_size=engine.default_size,
priority=engine.priority or 0,
)
for engine in image_result.scalars().all()
]
video_items = [
GenerationAIVideoEngineOptionOut(
id=engine.id,
name=engine.name,
provider=engine.provider,
model_name=engine.model_name,
supported_ratios=_parse_list(engine.supported_ratios, []),
supported_resolutions=_parse_list(engine.supported_resolutions, []),
supported_durations=_parse_list(engine.supported_durations, []),
max_duration=engine.max_duration,
priority=engine.priority or 0,
)
for engine in video_result.scalars().all()
]
return GenerationAIEngineOptionsOut(
engine=GenerationAIEngineGroupOut(image=image_items, video=video_items)
)
async def create_async_generation_task(db: AsyncSession, current_user: User, req: GenerationAITaskCreate) -> ChatGenerationTask:
"""Create a project-independent chat generation task.
@@ -180,6 +230,7 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req
record_id=task_id,
gen_type="image",
image_size=size,
engine_id=engine.id,
project_name="AI生成任务",
description_prefix="Chat任务",
)
@@ -225,6 +276,7 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req
gen_type="video",
duration=duration,
resolution=resolution,
engine_id=engine.id,
project_name="AI生成任务",
description_prefix="Chat任务",
)
@@ -262,6 +262,7 @@ async def charge_generation_media_by_params(
image_size: str | None = None,
duration: int | None = None,
resolution: str | None = None,
engine_id: str | None = None,
project_name: str | None = None,
description_prefix: str = "ChatAPI异步",
) -> BillingSummary:
@@ -272,7 +273,7 @@ async def charge_generation_media_by_params(
if gen_type == "image":
size = image_size or "2K"
amount = await calc_image_credits(db, size)
amount = await calc_image_credits(db, size, engine_id=engine_id)
items.append(
await deduct_credits_locked_once(
db,
@@ -284,7 +285,7 @@ async def charge_generation_media_by_params(
)
)
elif gen_type == "video":
amount = await calc_video_credits(db, duration or 5, resolution or "720p")
amount = await calc_video_credits(db, duration or 5, resolution or "720p", engine_id=engine_id)
items.append(
await deduct_credits_locked_once(
db,
@@ -63,68 +63,37 @@ def _build_optimized_prompt_by_params(task: ChatGenerationTask) -> str:
gen_type = (_to_clean_str(getattr(task, "gen_type", None)) or "").lower()
# 常见字段名兼容:
# duration / duration_seconds / video_duration
# aspect_ratio / ratio
# resolution
# image_size / size / pixel_size
duration = _get_first_value(task, "duration", "duration_seconds", "video_duration")
aspect_ratio = _get_first_value(task, "aspect_ratio", "ratio")
duration = _get_first_value(task, "duration")
aspect_ratio = _get_first_value(task, "aspect_ratio")
resolution = _get_first_value(task, "resolution")
image_size = _get_first_value(task, "image_size", "size", "pixel_size")
image_size = _get_first_value(task, "image_size")
image_px = _get_first_value(task, "image_px")
image_proportion = _get_first_value(task, "image_proportion")
parts = []
if gen_type == "video":
duration_text = _format_duration(duration)
aspect_ratio_text = _to_clean_str(aspect_ratio)
resolution_text = _to_clean_str(resolution)
if duration_text:
parts.append(f"时长:{duration_text}")
if aspect_ratio_text:
parts.append(f"画面比例:{aspect_ratio_text}")
if resolution_text:
parts.append(f"分辨率:{resolution_text}")
# 时长:4秒,画面比例:16:9,分辨率:480p
if duration:
parts.append(f"时长:{duration}")
parts.append(f"画面比例:{aspect_ratio}")
parts.append(f"分辨率:{resolution}")
else:
parts.append(f"时长:4秒")
parts.append(f"画面比例:16:9")
parts.append(f"分辨率:480p")
elif gen_type == "image":
resolution_text = _to_clean_str(resolution)
aspect_ratio_text = _to_clean_str(aspect_ratio)
image_size_text = _to_clean_str(image_size)
if resolution_text:
if resolution_text.startswith("分辨率"):
parts.append(resolution_text)
else:
parts.append(f"分辨率{resolution_text}")
if aspect_ratio_text:
if aspect_ratio_text.startswith("画布比例"):
parts.append(aspect_ratio_text)
else:
parts.append(f"画布比例{aspect_ratio_text}")
if image_size_text:
if image_size_text.startswith("像素尺寸"):
parts.append(image_size_text)
else:
parts.append(f"像素尺寸{image_size_text}")
if image_size :
parts.append(f"分辨率:{image_size}")
parts.append(f"画布比例:{image_proportion}")
parts.append(f"像素尺寸:{image_px}")
else:
parts.append(f"分辨率2K")
parts.append(f"画布比例:1:1")
parts.append(f"像素尺寸:2048x2048")
else:
# 未知类型时尽量保守拼接已有参数,避免直接丢失生成参数。
duration_text = _format_duration(duration)
aspect_ratio_text = _to_clean_str(aspect_ratio)
resolution_text = _to_clean_str(resolution)
image_size_text = _to_clean_str(image_size)
if duration_text:
parts.append(f"时长:{duration_text}")
if aspect_ratio_text:
parts.append(f"画面比例:{aspect_ratio_text}")
if resolution_text:
parts.append(f"分辨率:{resolution_text}")
if image_size_text:
parts.append(f"像素尺寸{image_size_text}")
# 未知类型时返回原始字符
return base_prompt
suffix = "".join(parts)