修复chat生成参数提交错误BUG|模型引擎积分设置关联开发优化成功|视频/图片生成引擎API开发完成
This commit is contained in:
@@ -140,4 +140,5 @@ temp/
|
||||
# =========================
|
||||
*.bak
|
||||
*.swp
|
||||
*.swo
|
||||
*.swo
|
||||
*.zip
|
||||
-329
File diff suppressed because one or more lines are too long
Vendored
+13
-13
@@ -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>
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+249
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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生成任务详情响应体。"""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user