前台页面/conversation修改积分计算逻辑

1、后台页面/credit-ratios,需要选择视频,上边是模型价格,下边增加传入视频价格,也要输入对应的倍率、基础积分、每秒积分
2、前台/conversation获取积分逻辑的接口也要增加对应的传入视频的比例
3、然后前台根据用户上传视频自动获取对应的比例加上对应模型选择的积分计算总积分
4、后台提交也要验证对应的积分是否正确
5、多个视频总时长需限制15s,最低视频时长2s
This commit is contained in:
2026-07-01 19:37:51 +08:00
parent 5e70064751
commit 5302d7f531
18 changed files with 657 additions and 1662 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-CbFNd7rH.js"></script>
<script type="module" crossorigin src="/assets/index-B_ZbbEyL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head>
<body>
@@ -22,6 +22,9 @@ interface CreditRatio {
ratio: number;
baseCredits: number;
perSecondCredits: number;
inputVideoRatio: number;
inputVideoBaseCredits: number;
inputVideoPerSecondCredits: number;
}
interface CreditRatioFormValues {
@@ -31,6 +34,9 @@ interface CreditRatioFormValues {
ratio: number;
baseCredits: number;
perSecondCredits?: number;
inputVideoRatio?: number;
inputVideoBaseCredits?: number;
inputVideoPerSecondCredits?: number;
}
const DEFAULT_IMAGE_SIZES = ['2K', '4K'];
@@ -117,7 +123,7 @@ const AdminCreditRatios: React.FC = () => {
const handleSave = async () => {
try {
const values = await form.validateFields();
const payload = {
const payload: any = {
model_config_id: values.modelConfigId,
gen_type: values.genType,
resolution: values.resolution,
@@ -125,6 +131,11 @@ const AdminCreditRatios: React.FC = () => {
base_credits: values.baseCredits,
per_second_credits: values.genType === 'image' ? 0 : (values.perSecondCredits || 0),
};
if (values.genType === 'video') {
payload.input_video_ratio = values.inputVideoRatio ?? 1.0;
payload.input_video_base_credits = values.inputVideoBaseCredits ?? 0;
payload.input_video_per_second_credits = values.inputVideoPerSecondCredits ?? 0;
}
if (modal.ratio) {
await saveCreditRatio({ id: modal.ratio.id, ...payload });
message.success('已更新');
@@ -174,10 +185,21 @@ const AdminCreditRatios: React.FC = () => {
ratio: ratio.ratio,
baseCredits: ratio.baseCredits,
perSecondCredits: ratio.perSecondCredits,
inputVideoRatio: ratio.inputVideoRatio,
inputVideoBaseCredits: ratio.inputVideoBaseCredits,
inputVideoPerSecondCredits: ratio.inputVideoPerSecondCredits,
});
} else {
form.resetFields();
form.setFieldsValue({ genType: 'video', ratio: 1.0, baseCredits: 60, perSecondCredits: 2 });
form.setFieldsValue({
genType: 'video',
ratio: 1.0,
baseCredits: 60,
perSecondCredits: 2,
inputVideoRatio: 1.0,
inputVideoBaseCredits: 0,
inputVideoPerSecondCredits: 0.5,
});
}
};
@@ -216,13 +238,18 @@ const AdminCreditRatios: React.FC = () => {
),
},
{
title: '示例计算', key: 'example', width: 120,
title: '示例计算', key: 'example', width: 140,
render: (_: any, r: CreditRatio) => {
let total: number;
if (r.genType === 'image') {
total = Math.round(r.baseCredits * r.ratio);
} else {
total = Math.round((r.baseCredits + r.perSecondCredits * 15) * r.ratio);
if (r.inputVideoRatio && r.inputVideoPerSecondCredits) {
total += Math.round(
(r.inputVideoBaseCredits + r.inputVideoPerSecondCredits * 10) * r.inputVideoRatio
);
}
}
return <Typography.Text strong style={{ color: '#6366f1' }}>{total} </Typography.Text>;
},
@@ -349,6 +376,31 @@ const AdminCreditRatios: React.FC = () => {
</Form.Item>
)}
</div>
{genType !== 'image' && (
<div style={{
marginTop: 8,
padding: '12px 16px',
backgroundColor: '#f5f5ff',
borderRadius: 8,
border: '1px solid #e0e0ff',
}}>
<Typography.Text strong style={{ display: 'block', marginBottom: 8, color: '#4f46e5' }}>
</Typography.Text>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="inputVideoRatio" label="倍率" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入视频倍率' }]}>
<InputNumber min={0} max={10} step={0.1} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="inputVideoBaseCredits" label="基础积分" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入视频基础积分' }]}>
<InputNumber min={0} max={500} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="inputVideoPerSecondCredits" label="每秒积分" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入视频每秒积分' }]}>
<InputNumber min={0} max={50} style={{ width: '100%' }} size="large" />
</Form.Item>
</div>
</div>
)}
</Form>
</Modal>
</div>
@@ -0,0 +1,37 @@
"""6idufv2q1c_add_credits_ratio_增加上传视频积分规则
Revision ID: a1b2c3d4e5f7
Revises: 6idufv2q1c
Create Date: 2026-07-01 00:00:00.000000
该文件包含 2026-07-01 的数据库迁移内容:
1. 积分规则表增加传入视频计费字段(input_video_ratio, input_video_base_credits, input_video_per_second_credits
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'a1b2c3d4e5f7'
down_revision: Union[str, None] = '6idufv2q1c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ========================================
# 2026-07-01 - 积分规则表增加传入视频计费字段
# ========================================
op.add_column('credit_ratios', sa.Column('input_video_ratio', sa.Float(), server_default='1.0', nullable=False))
op.add_column('credit_ratios', sa.Column('input_video_base_credits', sa.Float(), server_default='0.0', nullable=False))
op.add_column('credit_ratios', sa.Column('input_video_per_second_credits', sa.Float(), server_default='0.5', nullable=False))
def downgrade() -> None:
# ========================================
# 2026-07-01 - 积分规则表增加传入视频计费字段(回滚)
# ========================================
op.drop_column('credit_ratios', 'input_video_per_second_credits')
op.drop_column('credit_ratios', 'input_video_base_credits')
op.drop_column('credit_ratios', 'input_video_ratio')
+3
View File
@@ -22,3 +22,6 @@ class CreditRatio(Base, TimestampMixin):
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)
input_video_ratio: Mapped[float] = mapped_column(Float, default=1.0)
input_video_base_credits: Mapped[float] = mapped_column(Float, default=0.0)
input_video_per_second_credits: Mapped[float] = mapped_column(Float, default=0.5)
+18
View File
@@ -43,6 +43,24 @@ class CreditRatioCreate(BaseModel):
description="视频每秒积分。图片规则通常为 0",
examples=[2.0],
)
input_video_ratio: float = Field(
default=1.0,
ge=0,
description="传入视频积分倍率。视频生成时,用户上传参考视频的额外积分倍率",
examples=[1.0],
)
input_video_base_credits: float = Field(
default=0.0,
ge=0,
description="传入视频基础积分。视频生成时,用户上传参考视频的基础积分",
examples=[0.0],
)
input_video_per_second_credits: float = Field(
default=0.5,
ge=0,
description="传入视频每秒积分。视频生成时,用户上传参考视频每秒消耗的积分",
examples=[0.5],
)
class CreditRatioOut(CreditRatioCreate):
@@ -14,6 +14,7 @@ class GenerationAIReference(BaseModel):
"url": "https://example.com/reference.png",
"type": "image",
"name": "参考图.png",
"duration": 5.0,
}
}
)
@@ -33,6 +34,12 @@ class GenerationAIReference(BaseModel):
description="参考素材名称,前端展示用,可为空",
examples=["参考图.png"],
)
duration: float | None = Field(
None,
ge=0,
description="视频素材时长(秒)。type=video 时使用,用于视频素材计费和时长校验",
examples=[5.0],
)
class GenerationAITaskCreate(BaseModel):
+14 -4
View File
@@ -65,6 +65,7 @@ async def calc_video_credits(
duration: int,
resolution: str,
engine_id: str | None = None,
input_video_duration: float | None = None,
) -> float:
"""Calculate video credits using CreditRatio table, with fallback to hardcoded.
@@ -72,8 +73,9 @@ async def calc_video_credits(
1. gen_type=video + engine_id + resolution 精确规则;
2. gen_type=video + resolution 下 base_credits/per_second_credits 最高规则;
3. 原硬编码默认算法。
input_video_duration: 用户上传的参考视频总时长(秒),不为空时额外计费
"""
# 如果engine_id为空,默认查询权重最高的视频引擎积分规则
if not engine_id:
video_engines_result = await db.execute(
select(VideoEngine.id)
@@ -89,13 +91,21 @@ async def calc_video_credits(
engine_id=engine_id,
)
if ratio:
return round((ratio.base_credits + ratio.per_second_credits * duration) * ratio.ratio, 2)
base_cost = (ratio.base_credits + ratio.per_second_credits * duration) * ratio.ratio
if input_video_duration and input_video_duration > 0:
input_video_cost = (
ratio.input_video_base_credits + ratio.input_video_per_second_credits * input_video_duration
) * ratio.input_video_ratio
base_cost += input_video_cost
return round(base_cost, 2)
# Fallback
base = 60.0
duration_cost = duration * 2.0
multiplier = {"480p": 1, "1080p": 2, "720p": 1.5}.get(resolution, 1.0)
return round((base + duration_cost) * multiplier, 2)
total = (base + duration_cost) * multiplier
if input_video_duration and input_video_duration > 0:
total += input_video_duration * 0.5 * multiplier
return round(total, 2)
def calc_credits(duration: int, resolution: str) -> float:
@@ -301,6 +301,18 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req
raise HTTPException(status_code=400, detail=f"视频时长不支持: {duration}")
if engine.max_duration and duration > engine.max_duration:
raise HTTPException(status_code=400, detail=f"视频时长不能超过 {engine.max_duration}")
input_video_duration = 0.0
if refs:
video_refs = [r for r in refs if r.get("type") == "video"]
for ref in video_refs:
ref_duration = float(ref.get("duration") or 0)
if ref_duration < 2:
raise HTTPException(status_code=400, detail=f"视频素材最短不能少于 2 秒")
input_video_duration += ref_duration
if input_video_duration > 15:
raise HTTPException(status_code=400, detail=f"所有视频素材总时长不能超过 15 秒,当前 {input_video_duration:.1f}")
media_billing = await charge_generation_media_by_params(
db,
user_id=current_user.id,
@@ -309,6 +321,7 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req
duration=duration,
resolution=resolution,
engine_id=engine.id,
input_video_duration=input_video_duration if input_video_duration > 0 else None,
project_name="AI生成任务",
description_prefix="AI创作-",
owner_type=OWNER_CHAT_GENERATION_TASK,
@@ -460,6 +460,7 @@ async def charge_generation_media_by_params(
duration: int | None = None,
resolution: str | None = None,
engine_id: str | None = None,
input_video_duration: float | None = None,
project_name: str | None = None,
description_prefix: str = "AI创作-",
owner_type: str = OWNER_CHAT_GENERATION_TASK,
@@ -522,7 +523,11 @@ async def charge_generation_media_by_params(
)
)
elif gen_type == "video":
amount = await calc_video_credits(db, duration or 5, resolution or "720p", engine_id=engine_id)
amount = await calc_video_credits(
db, duration or 5, resolution or "720p",
engine_id=engine_id,
input_video_duration=input_video_duration,
)
items.append(
await deduct_credits_locked_once(
db,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -28,8 +28,8 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-B5pqnZWD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BhPFzWLH.css">
<script type="module" crossorigin src="/assets/index-lZSCkINs.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bi8mcSs8.css">
</head>
<body>
<div id="root"></div>
+55 -4
View File
@@ -63,6 +63,7 @@ interface MediaReference {
name: string;
type: 'image' | 'video';
url: string;
duration?: number;
}
interface Message {
@@ -280,6 +281,9 @@ const AIChatPage: React.FC = () => {
perSecondCredits: 2,
baseCredits: 60,
ratio: 1,
inputVideoRatio: 1,
inputVideoBaseCredits: 0,
inputVideoPerSecondCredits: 0.5,
};
if (videoResolution === '1080p') {
config.ratio = 2;
@@ -304,13 +308,21 @@ const AIChatPage: React.FC = () => {
}
}
// 根据配置计算积分
if (mediaType === 'video') {
// 视频:(秒数 × perSecondCredits + baseCredits) × ratio
return Math.round((videoDuration * config.perSecondCredits + config.baseCredits) * config.ratio);
let total = Math.round((videoDuration * config.perSecondCredits + config.baseCredits) * config.ratio);
// 传入视频积分
const inputVideoDuration = currentMedia
.filter((m) => m.type === 'video')
.reduce((sum, m) => sum + (m.duration || 0), 0);
if (inputVideoDuration > 0) {
const inputVideoCost = Math.round(
((config.inputVideoBaseCredits || 0) + (config.inputVideoPerSecondCredits || 0) * inputVideoDuration) * (config.inputVideoRatio || 1)
);
total += inputVideoCost;
}
return total;
} else {
// 图片:baseCredits × ratio
return config.baseCredits * config.ratio;
@@ -836,6 +848,22 @@ const AIChatPage: React.FC = () => {
}
};
const getVideoDuration = (file: File): Promise<number> => {
return new Promise((resolve, reject) => {
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
window.URL.revokeObjectURL(video.src);
resolve(video.duration);
};
video.onerror = () => {
window.URL.revokeObjectURL(video.src);
reject(new Error('无法获取视频时长'));
};
video.src = URL.createObjectURL(file);
});
};
const handleUpload = async (file: File) => {
// 验证文件类型
const isImage = file.type.startsWith('image/');
@@ -879,6 +907,28 @@ const AIChatPage: React.FC = () => {
return false;
}
// 获取视频时长并验证
let videoDuration = 0;
if (isVideo) {
try {
videoDuration = await getVideoDuration(file);
if (videoDuration < 2) {
message.error('视频素材最短不能少于 2 秒');
return false;
}
const existingVideoDuration = currentMedia
.filter((m) => m.type === 'video')
.reduce((sum, m) => sum + (m.duration || 0), 0);
if (existingVideoDuration + videoDuration > 15) {
message.error(`所有视频素材总时长不能超过 15 秒,当前 ${(existingVideoDuration + videoDuration).toFixed(1)}`);
return false;
}
} catch {
message.error('无法获取视频时长');
return false;
}
}
// 设置上传状态
setUploading(true);
@@ -893,6 +943,7 @@ const AIChatPage: React.FC = () => {
type: mediaType,
url: res.url,
label: '',
...(isVideo && { duration: videoDuration }),
}];
const labels = generateMediaLabels(newList);
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
+2 -2
View File
@@ -32,7 +32,7 @@ interface AppState {
};
enginesele: any;
inputValue: string;
currentMedia: { name: string; type: 'image' | 'video'; url: string; label: string }[];
currentMedia: { name: string; type: 'image' | 'video'; url: string; label: string; duration?: number }[];
fetchProjects: () => Promise<void>;
createProject: (name: string, industry: Industry) => Promise<Project>;
@@ -59,7 +59,7 @@ interface AppState {
setVideoResolution: (resolution: string) => void;
setEnginesele: (enginesele: any) => void;
setInputValue: (inputValue: string) => void;
setCurrentMedia: (currentMedia: { name: string; type: 'image' | 'video'; url: string; label: string }[]) => void;
setCurrentMedia: (currentMedia: { name: string; type: 'image' | 'video'; url: string; label: string; duration?: number }[]) => void;
resetGenerationConfig: () => void;
}