diff --git a/video-gen-admin/src/api/index.ts b/video-gen-admin/src/api/index.ts index 981ea553..f09b68ce 100644 --- a/video-gen-admin/src/api/index.ts +++ b/video-gen-admin/src/api/index.ts @@ -16,6 +16,7 @@ import type { ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut, AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams, PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut, + AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene, } from '../types'; import type { @@ -251,39 +252,28 @@ export async function deleteUserResourceCapacity(userId: string): Promise { - const formData = new FormData(); - formData.append('file', file); - formData.append('config_key', configKey); - const token = localStorage.getItem('auth_token'); - const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000'; - const res = await fetch(`${baseUrl}/api/admin/upload-pdf`, { - method: 'POST', - headers: token ? { 'Authorization': `Bearer ${token}` } : {}, - body: formData, - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(text || '上传失败'); +export async function uploadAdminFile( + file: File, + options: { scene: AdminUploadScene; resourceType: AdminUploadResourceType; durationSeconds?: number | null }, +): Promise { + const form = new FormData(); + form.append('file', file); + form.append('scene', options.scene); + form.append('resource_type', options.resourceType); + if (options.durationSeconds !== undefined && options.durationSeconds !== null) { + form.append('duration_seconds', String(options.durationSeconds)); } - return res.json(); + return api.post('/admin/uploads/files', form); +} + +export async function uploadPdf(file: File, _configKey?: string): Promise<{ url: string }> { + const res = await uploadAdminFile(file, { scene: 'system_pdf', resourceType: 'pdf' }); + return { url: res.url }; } export async function uploadLogo(file: File): Promise<{ url: string }> { - const formData = new FormData(); - formData.append('file', file); - const token = localStorage.getItem('auth_token'); - const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000'; - const res = await fetch(`${baseUrl}/api/admin/upload-logo`, { - method: 'POST', - headers: token ? { 'Authorization': `Bearer ${token}` } : {}, - body: formData, - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(text || '上传失败'); - } - return res.json(); + const res = await uploadAdminFile(file, { scene: 'system_logo', resourceType: 'image' }); + return { url: res.url }; } function setMaybe(params: URLSearchParams, key: string, value: unknown): void { @@ -757,17 +747,8 @@ export async function getOAuthList(params: OAuthListParams): Promise { } export async function uploadImage(file: File): Promise<{ url: string; filename: string }> { - const form = new FormData(); - form.append('file', file); - const token = localStorage.getItem('auth_token'); - const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/upload-image`, { - method: 'POST', - headers: token ? { Authorization: `Bearer ${token}` } : {}, - body: form, - }); - if (!res.ok) throw new Error('图片上传失败'); - const data = await res.json(); - return { url: data.url, filename: data.filename }; + const res = await uploadAdminFile(file, { scene: 'open_type_thumb', resourceType: 'image' }); + return { url: res.url, filename: res.originalFileName || res.fileName }; } // 自定义表头字段 @@ -900,6 +881,8 @@ export async function uploadHomeMaterialAsset(payload: HomeMaterialUploadAssetPa form.append('file', payload.file); form.append('media_type', payload.mediaType); if (payload.title && payload.title.trim()) form.append('title', payload.title.trim()); + if (payload.generationPrompt && payload.generationPrompt.trim()) form.append('generation_prompt', payload.generationPrompt.trim()); + if (payload.mediaReferences && payload.mediaReferences.length > 0) form.append('media_references_json', JSON.stringify(payload.mediaReferences)); form.append('watermark_type', payload.watermarkType); if (payload.watermarkType === 'image') { diff --git a/video-gen-admin/src/pages/AdminSettings.tsx b/video-gen-admin/src/pages/AdminSettings.tsx index b63117f6..3afe6226 100644 --- a/video-gen-admin/src/pages/AdminSettings.tsx +++ b/video-gen-admin/src/pages/AdminSettings.tsx @@ -105,7 +105,11 @@ const AdminSettings: React.FC = () => { const res = await uploadLogo(file); setConfigs(prev => prev.map(c => c.key === 'site_logo' ? { ...c, value: res.url } : c)); form.setFieldsValue({ site_logo: res.url }); - message.success('Logo上传成功'); + const config = configs.find(c => c.key === 'site_logo'); + if (config) { + await updateSystemConfig(config.id, res.url); + } + message.success('Logo上传成功并已保存'); } catch { message.error('上传失败'); } finally { diff --git a/video-gen-admin/src/pages/homeMaterials/HomeMaterialAssetTable.tsx b/video-gen-admin/src/pages/homeMaterials/HomeMaterialAssetTable.tsx index 8dd8caa9..b5263b37 100644 --- a/video-gen-admin/src/pages/homeMaterials/HomeMaterialAssetTable.tsx +++ b/video-gen-admin/src/pages/homeMaterials/HomeMaterialAssetTable.tsx @@ -1,22 +1,25 @@ import React, { useEffect, useState } from 'react'; -import { Button, Dropdown, Image, Modal, Select, Space, Table, Tag, message } from 'antd'; -import { DeleteOutlined, MoreOutlined, ReloadOutlined } from '@ant-design/icons'; +import { Button, Dropdown, Image, Input, Modal, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd'; +import { DeleteOutlined, EditOutlined, MoreOutlined, ReloadOutlined } from '@ant-design/icons'; import { deleteHomeMaterialAsset, getHomeMaterialAssetStatus, getHomeMaterialAssets, regenerateHomeMaterialWatermark, + updateHomeMaterialAsset, } from '../../api'; import type { HomeMaterialAsset, HomeMaterialAssetQueryParams, HomeMaterialCategory, + HomeMaterialMediaReference, HomeMaterialWatermark, HomeMaterialWatermarkConfig, HomeMaterialTextWatermarkConfig, } from '../../types'; import { apiUrl } from '../../utils/resourceUrl'; import WatermarkEditor from './WatermarkEditor'; +import MediaReferencesEditor from './MediaReferencesEditor'; interface Props { categories: HomeMaterialCategory[]; @@ -57,6 +60,12 @@ const defaultConfig: HomeMaterialWatermarkConfig = { textWatermark: defaultTextWatermark, }; +const mediaTypeText: Record = { + image: '图片', + video: '视频', + audio: '音频', +}; + function normalizeTextConfig(raw: any): HomeMaterialTextWatermarkConfig { return { text: raw?.text || defaultTextWatermark.text, @@ -94,6 +103,43 @@ function isValidConfig(config: HomeMaterialWatermarkConfig): boolean { return !!config.watermarkId; } +function shortText(value?: string | null, max = 48): string { + const text = (value || '').trim(); + if (!text) return ''; + return text.length > max ? `${text.slice(0, max)}...` : text; +} + +function renderMediaReferences(refs?: HomeMaterialMediaReference[] | null) { + const list = refs || []; + if (list.length === 0) return 暂无附件; + return ( + + {list.map((ref, index) => { + const displayUrl = apiUrl(ref.displayUrl || ref.url); + const previewUrl = apiUrl(ref.previewUrl || ref.displayUrl || ref.url); + return ( +
+
+ {ref.type === 'image' && } + {ref.type === 'video' &&
+ + + {mediaTypeText[ref.type] || ref.type} + {ref.duration !== undefined && ref.duration !== null && {ref.duration}s} + {ref.role && {ref.role}} + + {ref.name || '未命名附件'} + {ref.url} + +
+ ); + })} +
+ ); +} + const HomeMaterialAssetTable: React.FC = ({ categories, watermarks, reloadKey }) => { const [items, setItems] = useState([]); const [total, setTotal] = useState(0); @@ -103,6 +149,10 @@ const HomeMaterialAssetTable: React.FC = ({ categories, watermarks, reloa const [regen, setRegen] = useState(null); const [regenConfig, setRegenConfig] = useState(defaultConfig); const [regenSubmitting, setRegenSubmitting] = useState(false); + const [editingConfig, setEditingConfig] = useState(null); + const [editPrompt, setEditPrompt] = useState(''); + const [editRefs, setEditRefs] = useState([]); + const [editSubmitting, setEditSubmitting] = useState(false); const load = async () => { setLoading(true); @@ -133,6 +183,12 @@ const HomeMaterialAssetTable: React.FC = ({ categories, watermarks, reloa setRegenConfig(normalizeConfig(row.watermarkConfig, row.watermarkId)); }; + const openGenerationConfig = (row: HomeMaterialAsset) => { + setEditingConfig(row); + setEditPrompt(row.generationPrompt || ''); + setEditRefs(row.mediaReferences || []); + }; + const getPreviewImageUrl = (row: HomeMaterialAsset) => { if (row.mediaType === 'image') return apiUrl(row.watermarkedUrl || row.originalUrl); return apiUrl(row.coverUrl || ''); @@ -155,6 +211,26 @@ const HomeMaterialAssetTable: React.FC = ({ categories, watermarks, reloa }); }; + const submitGenerationConfig = async () => { + if (!editingConfig || editSubmitting) return; + setEditSubmitting(true); + try { + await updateHomeMaterialAsset(editingConfig.id, { + category_id: editingConfig.categoryId, + title: editingConfig.title || null, + is_active: editingConfig.isActive, + sort_order: editingConfig.sortOrder, + generation_prompt: editPrompt.trim() || null, + media_references: editRefs, + }); + message.success('生成配置已保存'); + setEditingConfig(null); + await load(); + } finally { + setEditSubmitting(false); + } + }; + const submitRegenerate = async () => { if (!regen || regenSubmitting) return; if (!isValidConfig(regenConfig)) { @@ -229,7 +305,7 @@ const HomeMaterialAssetTable: React.FC = ({ categories, watermarks, reloa loading={loading} dataSource={items} pagination={{ current: filters.page, pageSize: filters.pageSize, total, onChange: (page, pageSize) => setFilters(f => ({ ...f, page, pageSize })) }} - scroll={{ x: 1180 }} + scroll={{ x: 1380 }} columns={[ { title: '预览', @@ -256,6 +332,18 @@ const HomeMaterialAssetTable: React.FC = ({ categories, watermarks, reloa { title: '行业', dataIndex: 'categoryName' }, { title: '类型', dataIndex: 'mediaType', render: (v: string) => v === 'image' ? '图片' : '视频' }, { title: '状态', dataIndex: 'status', render: (v: string) => {statusMap[v]?.text || v} }, + { + title: '生成提词', + width: 210, + render: (_: unknown, row: HomeMaterialAsset) => row.generationPrompt ? ( + {shortText(row.generationPrompt)} + ) : 未设置, + }, + { + title: '附件', + width: 90, + render: (_: unknown, row: HomeMaterialAsset) => 0 ? 'blue' : 'default'}>{row.mediaReferences?.length || 0}, + }, { title: '水印', render: (_: unknown, row: HomeMaterialAsset) => { @@ -267,7 +355,7 @@ const HomeMaterialAssetTable: React.FC = ({ categories, watermarks, reloa { title: '错误', dataIndex: 'errorMessage', ellipsis: true }, { title: '操作', - width: 150, + width: 170, align: 'center' as const, render: (_: unknown, row: HomeMaterialAsset) => ( @@ -276,10 +364,12 @@ const HomeMaterialAssetTable: React.FC = ({ categories, watermarks, reloa trigger={['click']} menu={{ items: [ + { key: 'generation_config', icon: , label: '编辑生成配置' }, { key: 'regenerate', icon: , label: '重新生成水印' }, { key: 'delete', icon: , label: '删除素材', danger: true }, ], onClick: ({ key }) => { + if (key === 'generation_config') openGenerationConfig(row); if (key === 'regenerate') openRegenerate(row); if (key === 'delete') confirmDelete(row); }, @@ -292,11 +382,21 @@ const HomeMaterialAssetTable: React.FC = ({ categories, watermarks, reloa }, ]} /> - setPreview(null)} footer={null} width={900} destroyOnHidden> + setPreview(null)} footer={null} width={960} destroyOnHidden> {preview && (
原素材:{preview.originalUrl ? apiUrl(preview.originalUrl) : '-'}
水印素材:{preview.watermarkedUrl ? apiUrl(preview.watermarkedUrl) : '-'}
+
+ 生成提词: +
+ {preview.generationPrompt || '未设置'} +
+
+
+ 附件 / 参考素材: +
{renderMediaReferences(preview.mediaReferences)}
+
{preview.mediaType === 'image' ? ( ) : ( @@ -305,6 +405,38 @@ const HomeMaterialAssetTable: React.FC = ({ categories, watermarks, reloa
)}
+ !editSubmitting && setEditingConfig(null)} + onOk={submitGenerationConfig} + confirmLoading={editSubmitting} + cancelButtonProps={{ disabled: editSubmitting }} + width={920} + destroyOnHidden + > + +
+ 生成提词 + setEditPrompt(e.target.value)} + style={{ marginTop: 8 }} + /> +
+
+ 附件 / 参考素材 +
+ +
+
+
+
= ({ open, onClose, categories, w const [config, setConfig] = useState(defaultConfig); const [submitting, setSubmitting] = useState(false); const [mediaObjectUrl, setMediaObjectUrl] = useState(''); + const [mediaReferences, setMediaReferences] = useState([]); const resetState = () => { setFile(null); @@ -57,6 +59,7 @@ const HomeMaterialUploadModal: React.FC = ({ open, onClose, categories, w setMediaType('image'); setMediaObjectUrl(''); setSubmitting(false); + setMediaReferences([]); }; useEffect(() => { @@ -64,7 +67,7 @@ const HomeMaterialUploadModal: React.FC = ({ open, onClose, categories, w resetState(); const defaultWatermark = watermarks.find(w => w.isDefault) || watermarks[0]; setConfig({ ...defaultConfig, watermarkId: defaultWatermark?.id || null }); - form.setFieldsValue({ media_type: 'image', is_active: true, sort_order: 0, title: undefined }); + form.setFieldsValue({ media_type: 'image', is_active: true, sort_order: 0, title: undefined, generation_prompt: undefined }); } }, [open, watermarks, form]); @@ -136,6 +139,8 @@ const HomeMaterialUploadModal: React.FC = ({ open, onClose, categories, w file, mediaType, title: values.title?.trim() || null, + generationPrompt: values.generation_prompt?.trim() || null, + mediaReferences, watermarkType: config.watermarkType, watermarkId: config.watermarkType === 'image' ? config.watermarkId : null, opacityLevel: config.opacityLevel, @@ -197,6 +202,12 @@ const HomeMaterialUploadModal: React.FC = ({ open, onClose, categories, w + + + + + + diff --git a/video-gen-admin/src/pages/homeMaterials/MediaReferencesEditor.tsx b/video-gen-admin/src/pages/homeMaterials/MediaReferencesEditor.tsx new file mode 100644 index 00000000..22e75ee1 --- /dev/null +++ b/video-gen-admin/src/pages/homeMaterials/MediaReferencesEditor.tsx @@ -0,0 +1,167 @@ +import React, { useMemo, useState } from 'react'; +import { Button, Card, Image, Input, InputNumber, Space, Tag, Upload, message } from 'antd'; +import { DeleteOutlined, UploadOutlined } from '@ant-design/icons'; +import { uploadAdminFile } from '../../api'; +import type { AdminUploadResourceType, HomeMaterialMediaReference } from '../../types'; +import { apiUrl } from '../../utils/resourceUrl'; + +interface Props { + value?: HomeMaterialMediaReference[] | null; + onChange: (value: HomeMaterialMediaReference[]) => void; + disabled?: boolean; +} + +const mediaTypeText: Record = { + image: '图片', + video: '视频', + audio: '音频', +}; + +function detectResourceType(file: File): AdminUploadResourceType | null { + if (file.type.startsWith('image/')) return 'image'; + if (file.type.startsWith('video/')) return 'video'; + if (file.type.startsWith('audio/')) return 'audio'; + const name = file.name.toLowerCase(); + if (/\.(jpg|jpeg|png|webp|gif)$/.test(name)) return 'image'; + if (/\.(mp4|mov|m4v|webm)$/.test(name)) return 'video'; + if (/\.(mp3|wav|m4a|aac)$/.test(name)) return 'audio'; + return null; +} + +function readDuration(file: File, resourceType: AdminUploadResourceType): Promise { + if (resourceType !== 'video' && resourceType !== 'audio') return Promise.resolve(null); + return new Promise(resolve => { + const url = URL.createObjectURL(file); + const el = document.createElement(resourceType === 'video' ? 'video' : 'audio'); + el.preload = 'metadata'; + el.onloadedmetadata = () => { + const duration = Number.isFinite(el.duration) ? Number(el.duration.toFixed(3)) : null; + URL.revokeObjectURL(url); + resolve(duration); + }; + el.onerror = () => { + URL.revokeObjectURL(url); + resolve(null); + }; + el.src = url; + }); +} + +const MediaReferencesEditor: React.FC = ({ value, onChange, disabled }) => { + const refs = useMemo(() => value || [], [value]); + const [uploading, setUploading] = useState(false); + + const updateItem = (index: number, patch: Partial) => { + onChange(refs.map((item, idx) => idx === index ? { ...item, ...patch } : item)); + }; + + const removeItem = (index: number) => { + onChange(refs.filter((_, idx) => idx !== index)); + }; + + const uploadFile = async (file: File) => { + const resourceType = detectResourceType(file); + if (!resourceType || !['image', 'video', 'audio'].includes(resourceType)) { + message.warning('附件仅支持图片、视频、音频'); + return Upload.LIST_IGNORE; + } + setUploading(true); + try { + const durationSeconds = await readDuration(file, resourceType); + const res = await uploadAdminFile(file, { + scene: 'home_material_reference', + resourceType, + durationSeconds, + }); + const ref = res.mediaReference; + if (!ref || !['image', 'video', 'audio'].includes(ref.type)) { + throw new Error('上传返回附件格式异常'); + } + onChange([ + ...refs, + { + url: ref.url, + type: ref.type as 'image' | 'video' | 'audio', + name: ref.name || file.name, + duration: ref.duration ?? durationSeconds, + source: ref.source, + uploadResourceId: ref.uploadResourceId, + displayUrl: ref.displayUrl || ref.url, + previewUrl: ref.previewUrl || ref.displayUrl || ref.url, + role: ref.role || `reference_${ref.type}`, + }, + ]); + message.success('附件上传成功'); + } catch (e: any) { + message.error(e?.message || '附件上传失败'); + } finally { + setUploading(false); + } + return Upload.LIST_IGNORE; + }; + + return ( + + uploadFile(file as File)} showUploadList={false} disabled={disabled || uploading} accept="image/*,video/*,audio/*"> + + + {refs.length === 0 ? ( +
暂无附件。可上传图片、视频、音频,保存后会随前台素材接口返回。
+ ) : ( + + {refs.map((ref, index) => { + const previewUrl = apiUrl(ref.previewUrl || ref.displayUrl || ref.url); + const displayUrl = apiUrl(ref.displayUrl || ref.url); + return ( + +
+
+ {ref.type === 'image' && } + {ref.type === 'video' &&
+ + + {mediaTypeText[ref.type] || ref.type} + {ref.uploadResourceId && 资源ID:{ref.uploadResourceId}} + + updateItem(index, { name: e.target.value || null })} + /> + updateItem(index, { role: e.target.value || null })} + /> + {(ref.type === 'video' || ref.type === 'audio') && ( + updateItem(index, { duration: v === null ? null : Number(v) })} + /> + )} + + + +
+
+ ); + })} +
+ )} +
+ ); +}; + +export default MediaReferencesEditor; diff --git a/video-gen-admin/src/types/index.ts b/video-gen-admin/src/types/index.ts index 5e4df066..ca5682b4 100644 --- a/video-gen-admin/src/types/index.ts +++ b/video-gen-admin/src/types/index.ts @@ -176,6 +176,8 @@ export interface AdminTeamPayload { description?: string | null; status: 'active' | 'disabled'; sort_order: number; + generation_prompt?: string | null; + media_references?: HomeMaterialMediaReference[] | null; } export interface AdminUser { @@ -877,6 +879,53 @@ export interface AdminCreditRecordQueryParams { // ── 首页素材行业装修 ────────────────────────────────────── + +export type AdminUploadScene = + | 'system_logo' + | 'system_pdf' + | 'open_type_thumb' + | 'home_material_reference' + | 'admin_common'; + +export type AdminUploadResourceType = 'image' | 'video' | 'audio' | 'pdf' | 'file'; + +export interface HomeMaterialMediaReference { + url: string; + type: 'image' | 'video' | 'audio'; + name?: string | null; + duration?: number | null; + source?: string | null; + uploadResourceId?: string | null; + displayUrl?: string | null; + previewUrl?: string | null; + role?: string | null; +} + +export interface AdminUploadMediaReference { + url: string; + type: AdminUploadResourceType; + name?: string | null; + duration?: number | null; + source: string; + uploadResourceId?: string | null; + displayUrl?: string | null; + previewUrl?: string | null; + role?: string | null; +} + +export interface AdminUploadFileResult { + resourceId: string; + scene: AdminUploadScene; + module: string; + resourceType: AdminUploadResourceType; + url: string; + fileName: string; + originalFileName?: string | null; + fileSizeBytes: number; + durationSeconds?: number | null; + mediaReference?: AdminUploadMediaReference | null; +} + export type HomeMaterialMediaType = 'image' | 'video'; export type HomeMaterialAssetStatus = 'draft' | 'processing' | 'success' | 'failed'; export type HomeMaterialWatermarkType = 'image' | 'repeated_text'; @@ -998,6 +1047,8 @@ export interface HomeMaterialAsset { watermarkId?: string | null; watermarkName?: string | null; watermarkConfig?: HomeMaterialWatermarkConfig | Record | null; + generationPrompt?: string | null; + mediaReferences?: HomeMaterialMediaReference[] | null; width?: number | null; height?: number | null; durationSeconds?: number | string | null; @@ -1016,6 +1067,8 @@ export interface HomeMaterialAssetUpdatePayload { title?: string | null; is_active: boolean; sort_order: number; + generation_prompt?: string | null; + media_references?: HomeMaterialMediaReference[] | null; } export interface HomeMaterialAssetStatusOut { @@ -1038,6 +1091,8 @@ export interface HomeMaterialUploadResult { watermarkedUrl?: string | null; coverUrl?: string | null; watermarkConfig?: HomeMaterialWatermarkConfig | Record | null; + generationPrompt?: string | null; + mediaReferences?: HomeMaterialMediaReference[] | null; message: string; } @@ -1074,6 +1129,8 @@ export interface HomeMaterialUploadAssetParams { file: File; mediaType: HomeMaterialMediaType; title?: string | null; + generationPrompt?: string | null; + mediaReferences?: HomeMaterialMediaReference[] | null; watermarkType: HomeMaterialWatermarkType; watermarkId?: string | null; watermarkFile?: File | null; diff --git a/video-gen-api/alembic/versions/6c1aaf036f43_add_home_material_generation_config.py b/video-gen-api/alembic/versions/6c1aaf036f43_add_home_material_generation_config.py new file mode 100644 index 00000000..fa30236f --- /dev/null +++ b/video-gen-api/alembic/versions/6c1aaf036f43_add_home_material_generation_config.py @@ -0,0 +1,31 @@ +"""add home material generation config + +Revision ID: 6c1aaf036f43 +Revises: 1475d11b1d74 +Create Date: 2026-07-09 10:28:05.067550 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '6c1aaf036f43' +down_revision: Union[str, None] = '1475d11b1d74' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('home_material_assets', sa.Column('generation_prompt', sa.Text(), nullable=True, comment='生成提词')) + op.add_column('home_material_assets', sa.Column('media_references_json', sa.Text(), nullable=True, comment='附件/参考素材JSON字符串')) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('home_material_assets', 'media_references_json') + op.drop_column('home_material_assets', 'generation_prompt') + # ### end Alembic commands ### diff --git a/video-gen-api/app/api/admin/__init__.py b/video-gen-api/app/api/admin/__init__.py index f7d1db0e..359841ab 100644 --- a/video-gen-api/app/api/admin/__init__.py +++ b/video-gen-api/app/api/admin/__init__.py @@ -7,6 +7,7 @@ from app.api.admin.home_material import router as home_material_router from app.api.admin.private_portrait import router as private_portrait_router from app.api.admin.recharge_package import router as recharge_package_router from app.api.admin.menu_config import router as menu_config_router +from app.api.admin.upload import router as admin_upload_router router = APIRouter() router.include_router(video_prompt_schema_config_router) @@ -16,3 +17,4 @@ router.include_router(home_material_router) router.include_router(private_portrait_router) router.include_router(recharge_package_router) router.include_router(menu_config_router) +router.include_router(admin_upload_router) diff --git a/video-gen-api/app/api/admin/home_material.py b/video-gen-api/app/api/admin/home_material.py index 5ae9ebc1..f93d73e5 100644 --- a/video-gen-api/app/api/admin/home_material.py +++ b/video-gen-api/app/api/admin/home_material.py @@ -336,6 +336,8 @@ async def upload_home_material_asset( file: UploadFile = File(..., description="图片或视频素材文件。"), media_type: HomeMaterialMediaType = Form(..., description="素材类型:image图片,video视频。"), title: str | None = Form(None, description="素材标题。为空时不再自动回填文件名。"), + generation_prompt: str | None = Form(None, description="生成提词,可为空。"), + media_references_json: str | None = Form(None, description="附件/参考素材JSON字符串,格式参考 generation_ai.create_task 的 media_references。"), watermark_type: HomeMaterialWatermarkType = Form(HomeMaterialWatermarkType.IMAGE, description="水印类型:image 图片水印;repeated_text 重复文字水印。"), watermark_id: str | None = Form(None, description="图片水印ID,可选。watermark_type=image 时使用。"), watermark_file: UploadFile | None = File(None, description="临时图片水印,可选。watermark_type=image 时使用。"), @@ -395,6 +397,8 @@ async def upload_home_material_asset( file=file, media_type=media_type, title=title, + generation_prompt=generation_prompt, + media_references_json=media_references_json, watermark_id=watermark_id, watermark_file=watermark_file, watermark_config=config, @@ -452,7 +456,7 @@ async def get_home_material_asset_status( "/assets/{asset_id}", response_model=HomeMaterialAssetOut, summary="修改首页素材展示信息", - description="只修改行业、标题、启用状态、排序,不重新生成水印。", + description="修改行业、标题、启用状态、排序、生成提词和附件JSON,不重新生成水印。", ) async def update_home_material_asset( req: HomeMaterialAssetUpdate, @@ -465,7 +469,7 @@ async def update_home_material_asset( db, admin.id, admin.username, - HomeMaterialOperationEnum.ASSET_UPDATE.value, + (HomeMaterialOperationEnum.ASSET_GENERATION_CONFIG_UPDATE.value if (before.get("generation_prompt") != after.get("generation_prompt") or before.get("media_references_count") != after.get("media_references_count")) else HomeMaterialOperationEnum.ASSET_UPDATE.value), "PUT", f"/admin/home-material/assets/{asset_id}", detail=_detail(before=before, after=after), diff --git a/video-gen-api/app/api/admin/upload.py b/video-gen-api/app/api/admin/upload.py new file mode 100644 index 00000000..342767ee --- /dev/null +++ b/video-gen-api/app/api/admin/upload.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, File, Form, UploadFile +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies import get_admin_user, get_db +from app.enums.admin_upload import AdminUploadResourceTypeEnum, AdminUploadSceneEnum +from app.models.user import User +from app.schemas.admin_upload import AdminUploadFileOut +from app.services.admin_upload import admin_upload_service + +router = APIRouter(prefix="/admin/uploads", tags=["admin-upload"]) + + +@router.post( + "/files", + response_model=AdminUploadFileOut, + summary="管理后台全局文件上传", + description=( + "管理后台纯文件上传统一入口。通过 scene 区分业务场景并落到独立目录;" + "首页素材附件使用 scene=home_material_reference,系统Logo使用 system_logo,PDF使用 system_pdf,开户缩略图使用 open_type_thumb。" + ), +) +async def upload_admin_file( + file: UploadFile = File(..., description="上传文件。"), + scene: AdminUploadSceneEnum = Form(..., description="上传场景。"), + resource_type: AdminUploadResourceTypeEnum = Form(..., description="资源类型:image/video/audio/pdf/file。"), + duration_seconds: float | None = Form(None, ge=0, description="音视频时长,单位秒。"), + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +): + result = await admin_upload_service.upload_file( + db, + file=file, + scene=scene, + resource_type=resource_type, + duration_seconds=duration_seconds, + admin=admin, + ) + await db.commit() + return result diff --git a/video-gen-api/app/enums/admin_upload.py b/video-gen-api/app/enums/admin_upload.py new file mode 100644 index 00000000..c5b6afab --- /dev/null +++ b/video-gen-api/app/enums/admin_upload.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from enum import StrEnum + + +class AdminUploadSceneEnum(StrEnum): + """管理后台全局上传场景。""" + + SYSTEM_LOGO = "system_logo" + SYSTEM_PDF = "system_pdf" + OPEN_TYPE_THUMB = "open_type_thumb" + HOME_MATERIAL_REFERENCE = "home_material_reference" + ADMIN_COMMON = "admin_common" + + +class AdminUploadResourceTypeEnum(StrEnum): + """管理后台全局上传资源类型。""" + + IMAGE = "image" + VIDEO = "video" + AUDIO = "audio" + PDF = "pdf" + FILE = "file" + + +class AdminUploadLogEventEnum(StrEnum): + """管理后台上传步骤日志事件。""" + + VALIDATE_STARTED = "ADMIN_UPLOAD_VALIDATE_STARTED" + VALIDATE_SUCCESS = "ADMIN_UPLOAD_VALIDATE_SUCCESS" + SAVE_STARTED = "ADMIN_UPLOAD_SAVE_STARTED" + SAVE_SUCCESS = "ADMIN_UPLOAD_SAVE_SUCCESS" + DB_RECORD_SUCCESS = "ADMIN_UPLOAD_DB_RECORD_SUCCESS" + FAILED = "ADMIN_UPLOAD_FAILED" + + +ADMIN_UPLOAD_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif"} +ADMIN_UPLOAD_VIDEO_EXTENSIONS = {".mp4", ".mov", ".m4v", ".webm"} +ADMIN_UPLOAD_AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac"} +ADMIN_UPLOAD_PDF_EXTENSIONS = {".pdf"} + +ADMIN_UPLOAD_IMAGE_MAX_BYTES = 20 * 1024 * 1024 +ADMIN_UPLOAD_VIDEO_MAX_BYTES = 300 * 1024 * 1024 +ADMIN_UPLOAD_AUDIO_MAX_BYTES = 30 * 1024 * 1024 +ADMIN_UPLOAD_PDF_MAX_BYTES = 20 * 1024 * 1024 +ADMIN_UPLOAD_FILE_MAX_BYTES = 100 * 1024 * 1024 +ADMIN_UPLOAD_FILE_NAME_MAX_LEN = 255 + +ADMIN_UPLOAD_SCENE_LABELS: dict[str, str] = { + AdminUploadSceneEnum.SYSTEM_LOGO.value: "系统Logo", + AdminUploadSceneEnum.SYSTEM_PDF.value: "系统PDF", + AdminUploadSceneEnum.OPEN_TYPE_THUMB.value: "开户方式缩略图", + AdminUploadSceneEnum.HOME_MATERIAL_REFERENCE.value: "首页素材附件", + AdminUploadSceneEnum.ADMIN_COMMON.value: "后台公共上传", +} diff --git a/video-gen-api/app/enums/home_material.py b/video-gen-api/app/enums/home_material.py index d914a4d4..c004f321 100644 --- a/video-gen-api/app/enums/home_material.py +++ b/video-gen-api/app/enums/home_material.py @@ -63,6 +63,17 @@ class HomeMaterialPublicResponseMode(StrEnum): FLAT = "flat" +class HomeMaterialLogEventEnum(StrEnum): + """首页素材步骤日志事件。""" + + GENERATION_CONFIG_VALIDATE_STARTED = "HOME_MATERIAL_GENERATION_CONFIG_VALIDATE_STARTED" + GENERATION_CONFIG_VALIDATE_SUCCESS = "HOME_MATERIAL_GENERATION_CONFIG_VALIDATE_SUCCESS" + GENERATION_CONFIG_UPDATE_STARTED = "HOME_MATERIAL_GENERATION_CONFIG_UPDATE_STARTED" + GENERATION_CONFIG_UPDATE_SUCCESS = "HOME_MATERIAL_GENERATION_CONFIG_UPDATE_SUCCESS" + GENERATION_CONFIG_UPDATE_FAILED = "HOME_MATERIAL_GENERATION_CONFIG_UPDATE_FAILED" + MEDIA_REFERENCES_PARSE_FAILED = "HOME_MATERIAL_MEDIA_REFERENCES_PARSE_FAILED" + + class HomeMaterialOperationEnum(StrEnum): """后台操作日志 action 枚举。""" @@ -75,6 +86,7 @@ class HomeMaterialOperationEnum(StrEnum): WATERMARK_DELETE = "删除首页素材水印" ASSET_UPLOAD = "上传首页素材" ASSET_UPDATE = "修改首页素材" + ASSET_GENERATION_CONFIG_UPDATE = "修改首页素材生成配置" ASSET_DELETE = "删除首页素材" ASSET_REGENERATE_WATERMARK = "重新生成首页素材水印" @@ -83,6 +95,10 @@ HOME_MATERIAL_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} HOME_MATERIAL_VIDEO_EXTENSIONS = {".mp4", ".mov", ".m4v", ".webm"} HOME_MATERIAL_WATERMARK_EXTENSIONS = {".png", ".webp", ".jpg", ".jpeg"} +HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN = 8000 +HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT = 20 +HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN = 20000 + HOME_MATERIAL_DEFAULT_CONFIG = { "enabled": False, "title": "行业素材案例", diff --git a/video-gen-api/app/enums/upload_resource.py b/video-gen-api/app/enums/upload_resource.py index db2cb24a..e337dbbe 100644 --- a/video-gen-api/app/enums/upload_resource.py +++ b/video-gen-api/app/enums/upload_resource.py @@ -7,6 +7,8 @@ class UploadResourceModuleEnum(StrEnum): """上传资源所属业务模块。""" COMMON = "common" + ADMIN_UPLOAD = "admin_upload" + HOME_MATERIAL = "home_material" HOT_OPENING_REPLICATE = "hot_opening_replicate" SHOT_REPLICATE = "shot_replicate" @@ -18,6 +20,8 @@ class UploadResourceTypeEnum(StrEnum): VIDEO = "video" AUDIO = "audio" SHOT_SEGMENT = "shot_segment" + PDF = "pdf" + FILE = "file" class UploadResourceBindStatusEnum(StrEnum): @@ -76,6 +80,9 @@ class UploadResourceSourceModelEnum(StrEnum): MODULE_GENERATION_PROJECT = "ModuleGenerationProject" SHOT_REPLICATE_TASK_SET = "ShotReplicateTaskSet" SHOT_REPLICATE_SEGMENT = "ShotReplicateSegment" + HOME_MATERIAL_ASSET = "HomeMaterialAsset" + SYSTEM_CONFIG = "SystemConfig" + OPEN_TYPE = "OpenType" class UploadResourceEventEnum(StrEnum): @@ -155,6 +162,8 @@ class UploadResourceEventEnum(StrEnum): UPLOAD_RESOURCE_MODULE_LABELS: dict[str, str] = { UploadResourceModuleEnum.COMMON.value: "普通上传", + UploadResourceModuleEnum.ADMIN_UPLOAD.value: "管理后台上传", + UploadResourceModuleEnum.HOME_MATERIAL.value: "首页素材", UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value: "爆款开头复刻", UploadResourceModuleEnum.SHOT_REPLICATE.value: "拆镜复刻", } @@ -164,4 +173,6 @@ UPLOAD_RESOURCE_TYPE_LABELS: dict[str, str] = { UploadResourceTypeEnum.VIDEO.value: "视频", UploadResourceTypeEnum.AUDIO.value: "音频", UploadResourceTypeEnum.SHOT_SEGMENT.value: "拆镜切片", + UploadResourceTypeEnum.PDF.value: "PDF", + UploadResourceTypeEnum.FILE.value: "文件", } diff --git a/video-gen-api/app/models/home_material/asset.py b/video-gen-api/app/models/home_material/asset.py index 2d333269..b1eac0e5 100644 --- a/video-gen-api/app/models/home_material/asset.py +++ b/video-gen-api/app/models/home_material/asset.py @@ -40,6 +40,8 @@ class HomeMaterialAsset(Base, TimestampMixin, SoftDeleteMixin): cover_storage_path: Mapped[str | None] = mapped_column(Text, nullable=True, comment="视频封面本地路径") watermark_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="水印图片ID") watermark_config_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="水印配置快照JSON") + generation_prompt: Mapped[str | None] = mapped_column(Text, nullable=True, comment="生成提词") + media_references_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="附件/参考素材JSON字符串") status: Mapped[str] = mapped_column( String(32), default=HomeMaterialAssetStatus.DRAFT.value, diff --git a/video-gen-api/app/schemas/admin_upload.py b/video-gen-api/app/schemas/admin_upload.py new file mode 100644 index 00000000..ff1bcc2b --- /dev/null +++ b/video-gen-api/app/schemas/admin_upload.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field + +from app.enums.admin_upload import AdminUploadResourceTypeEnum, AdminUploadSceneEnum + + +class AdminUploadMediaReferenceOut(BaseModel): + url: str = Field(..., description="附件URL。") + type: str = Field(..., description="附件类型:image/video/audio/pdf/file。") + name: str | None = Field(None, description="附件名称。") + duration: float | None = Field(None, description="音视频时长,单位秒。") + source: str = Field("admin_upload", description="来源标识。") + upload_resource_id: str | None = Field(None, description="UploadResource资源ID。") + display_url: str | None = Field(None, description="展示URL。") + preview_url: str | None = Field(None, description="预览URL。") + role: str | None = Field(None, description="参考素材角色。") + + +class AdminUploadFileOut(BaseModel): + resource_id: str = Field(..., description="UploadResource资源ID。") + scene: AdminUploadSceneEnum = Field(..., description="上传场景。") + module: str = Field(..., description="上传资源模块。") + resource_type: AdminUploadResourceTypeEnum = Field(..., description="资源类型。") + url: str = Field(..., description="文件URL。") + file_name: str = Field(..., description="保存后的文件名。") + original_file_name: str | None = Field(None, description="原始文件名。") + file_size_bytes: int = Field(0, description="文件大小,单位字节。") + duration_seconds: float | None = Field(None, description="音视频时长,单位秒。") + media_reference: AdminUploadMediaReferenceOut | None = Field(None, description="兼容生成任务 media_references 的附件引用对象。") diff --git a/video-gen-api/app/schemas/home_material.py b/video-gen-api/app/schemas/home_material.py index 62f139c9..df77e930 100644 --- a/video-gen-api/app/schemas/home_material.py +++ b/video-gen-api/app/schemas/home_material.py @@ -3,9 +3,11 @@ from __future__ import annotations from decimal import Decimal from typing import Any -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator, model_validator from app.enums.home_material import ( + HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN, + HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT, HomeMaterialAssetStatus, HomeMaterialMediaType, HomeMaterialPublicResponseMode, @@ -92,6 +94,40 @@ class HomeMaterialWatermarkListOut(BaseModel): total: int = Field(0, description="符合条件的水印总数。") + + +class HomeMaterialMediaReference(BaseModel): + url: str = Field(..., min_length=1, description="附件URL。") + type: str = Field(..., pattern=r"^(image|video|audio)$", description="附件类型:image/video/audio。") + name: str | None = Field(None, max_length=255, description="附件名称。") + duration: float | None = Field(None, ge=0, description="视频/音频时长,单位秒。") + source: str | None = Field("admin_upload", max_length=64, description="来源标识。") + upload_resource_id: str | None = Field(None, max_length=32, validation_alias=AliasChoices("upload_resource_id", "uploadResourceId"), description="UploadResource资源ID。") + display_url: str | None = Field(None, validation_alias=AliasChoices("display_url", "displayUrl"), description="展示URL。") + preview_url: str | None = Field(None, validation_alias=AliasChoices("preview_url", "previewUrl"), description="预览URL。") + role: str | None = Field(None, max_length=64, description="参考素材角色。") + + model_config = ConfigDict(populate_by_name=True) + + @field_validator("url", "display_url", "preview_url", mode="before") + @classmethod + def clean_url(cls, value: str | None) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + @model_validator(mode="after") + def fill_urls_and_role(self) -> "HomeMaterialMediaReference": + if not self.display_url: + self.display_url = self.url + if not self.preview_url: + self.preview_url = self.display_url or self.url + if not self.role: + self.role = f"reference_{self.type}" + return self + + class HomeMaterialTextWatermarkConfig(BaseModel): """重复文字水印配置。字体固定由后端 HOME_MATERIAL_TEXT_WATERMARK_FONT 指定,不允许前端传字体,避免版权和一致性问题。""" @@ -180,6 +216,8 @@ class HomeMaterialAssetOut(BaseModel): watermark_id: str | None = Field(None, description="图片水印ID。重复文字水印素材为空。") watermark_name: str | None = Field(None, description="图片水印名称,列表接口批量查询回填。重复文字水印显示为空。") watermark_config: HomeMaterialWatermarkConfig | dict[str, Any] | None = Field(None, description="水印配置快照。") + generation_prompt: str | None = Field(None, description="生成提词。") + media_references: list[HomeMaterialMediaReference] = Field(default_factory=list, description="附件/参考素材列表。") width: int | None = Field(None, description="素材宽度。") height: int | None = Field(None, description="素材高度。") duration_seconds: Decimal | float | None = Field(None, description="视频时长,图片为空。") @@ -205,10 +243,12 @@ class HomeMaterialAssetUpdate(BaseModel): title: str | None = Field(None, max_length=128, description="素材标题。") is_active: bool = Field(True, description="是否前台展示。") sort_order: int = Field(0, ge=0, le=999999, description="排序值。") + generation_prompt: str | None = Field(None, max_length=HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN, description="生成提词。") + media_references: list[HomeMaterialMediaReference] | None = Field(None, max_length=HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT, description="附件/参考素材列表。") - @field_validator("title") + @field_validator("title", "generation_prompt") @classmethod - def clean_title(cls, value: str | None) -> str | None: + def clean_text(cls, value: str | None) -> str | None: value = (value or "").strip() return value or None @@ -256,6 +296,8 @@ class HomeMaterialPublicAssetOut(BaseModel): width: int | None = Field(None, description="宽度。") height: int | None = Field(None, description="高度。") duration_seconds: Decimal | float | None = Field(None, description="视频时长。") + generation_prompt: str | None = Field(None, description="生成提词。") + media_references: list[HomeMaterialMediaReference] = Field(default_factory=list, description="附件/参考素材列表。") sort_order: int = Field(0, description="排序。") @@ -303,4 +345,6 @@ class HomeMaterialUploadResultOut(BaseModel): watermarked_url: str | None = Field(None, description="水印素材URL。") cover_url: str | None = Field(None, description="视频封面URL。") watermark_config: HomeMaterialWatermarkConfig | dict[str, Any] | None = Field(None, description="水印配置快照。") + generation_prompt: str | None = Field(None, description="生成提词。") + media_references: list[HomeMaterialMediaReference] = Field(default_factory=list, description="附件/参考素材列表。") message: str = Field("", description="提示消息。") diff --git a/video-gen-api/app/services/admin_upload/__init__.py b/video-gen-api/app/services/admin_upload/__init__.py new file mode 100644 index 00000000..eace37d8 --- /dev/null +++ b/video-gen-api/app/services/admin_upload/__init__.py @@ -0,0 +1,3 @@ +from app.services.admin_upload.service import admin_upload_service + +__all__ = ["admin_upload_service"] diff --git a/video-gen-api/app/services/admin_upload/service.py b/video-gen-api/app/services/admin_upload/service.py new file mode 100644 index 00000000..800f5609 --- /dev/null +++ b/video-gen-api/app/services/admin_upload/service.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import UploadFile +from sqlalchemy.ext.asyncio import AsyncSession + +from app.enums.admin_upload import AdminUploadLogEventEnum, AdminUploadResourceTypeEnum, AdminUploadSceneEnum +from app.enums.common import LogEventStatusEnum, LogSourceEnum +from app.enums.upload_resource import ( + UploadResourceBindStatusEnum, + UploadResourceCreatedByEnum, + UploadResourceDeletePolicyEnum, + UploadResourceDurationSourceEnum, + UploadResourceModuleEnum, +) +from app.models.user import User +from app.schemas.admin_upload import AdminUploadFileOut, AdminUploadMediaReferenceOut +from app.services.admin_upload.storage import save_upload_file +from app.services.operation_log_service import build_exception_detail, log_operation_event +from app.services.upload_resource.core_service import record_external_upload_resource + + +def _duration(value: float | None) -> float | None: + if value is None: + return None + try: + v = float(value) + return round(v, 3) if v >= 0 else None + except Exception: + return None + + +def _module_for_scene(scene: AdminUploadSceneEnum) -> str: + if scene == AdminUploadSceneEnum.HOME_MATERIAL_REFERENCE: + return UploadResourceModuleEnum.HOME_MATERIAL.value + return UploadResourceModuleEnum.ADMIN_UPLOAD.value + + +def _role_for_type(resource_type: AdminUploadResourceTypeEnum) -> str | None: + if resource_type == AdminUploadResourceTypeEnum.IMAGE: + return "reference_image" + if resource_type == AdminUploadResourceTypeEnum.VIDEO: + return "reference_video" + if resource_type == AdminUploadResourceTypeEnum.AUDIO: + return "reference_audio" + return None + + +def _media_reference(*, url: str, resource_type: AdminUploadResourceTypeEnum, name: str | None, duration_seconds: float | None, resource_id: str) -> AdminUploadMediaReferenceOut: + return AdminUploadMediaReferenceOut( + url=url, + type=resource_type.value, + name=name, + duration=duration_seconds, + source="admin_upload", + upload_resource_id=resource_id, + display_url=url, + preview_url=url, + role=_role_for_type(resource_type), + ) + + +def _log(event_type: str, *, admin: User, scene: str, resource_type: str, status: str = LogEventStatusEnum.SUCCESS.value, message: str | None = None, detail: dict[str, Any] | None = None, error: str | None = None) -> None: + log_operation_event( + domain="admin_upload", + module="admin_upload", + event_type=event_type, + event_status=status, + source=LogSourceEnum.API.value, + user_id=admin.id, + message=message, + detail={"scene": scene, "resource_type": resource_type, **(detail or {})}, + error=error, + ) + + +class AdminUploadService: + async def upload_file( + self, + db: AsyncSession, + *, + file: UploadFile, + scene: AdminUploadSceneEnum, + resource_type: AdminUploadResourceTypeEnum, + admin: User, + duration_seconds: float | None = None, + ) -> AdminUploadFileOut: + duration = _duration(duration_seconds) + _log( + AdminUploadLogEventEnum.VALIDATE_STARTED.value, + admin=admin, + scene=scene.value, + resource_type=resource_type.value, + status=LogEventStatusEnum.STARTED.value, + detail={"filename": file.filename, "content_type": file.content_type}, + ) + stored = None + try: + _log( + AdminUploadLogEventEnum.VALIDATE_SUCCESS.value, + admin=admin, + scene=scene.value, + resource_type=resource_type.value, + detail={"filename": file.filename, "duration_seconds": duration}, + ) + _log( + AdminUploadLogEventEnum.SAVE_STARTED.value, + admin=admin, + scene=scene.value, + resource_type=resource_type.value, + status=LogEventStatusEnum.STARTED.value, + detail={"filename": file.filename}, + ) + stored = await save_upload_file(file, scene=scene, resource_type=resource_type, admin_id=admin.id) + _log( + AdminUploadLogEventEnum.SAVE_SUCCESS.value, + admin=admin, + scene=scene.value, + resource_type=resource_type.value, + detail={"url": stored.file_url, "storage_path": stored.storage_path, "file_size_bytes": stored.file_size_bytes}, + ) + module = _module_for_scene(scene) + resource = await record_external_upload_resource( + db, + user_id=admin.id, + module=module, + resource_type=resource_type.value, + resource_url=stored.file_url, + storage_path=stored.storage_path, + file_size_bytes=stored.file_size_bytes, + file_name=stored.file_name, + mime_type=file.content_type, + duration_seconds=duration, + duration_source=UploadResourceDurationSourceEnum.CLIENT.value if duration is not None else None, + bind_status=UploadResourceBindStatusEnum.PENDING.value, + delete_policy=UploadResourceDeletePolicyEnum.USER_DELETABLE.value, + created_by=UploadResourceCreatedByEnum.API.value, + metadata={ + "scene": scene.value, + "original_filename": stored.original_file_name, + "client_duration_seconds": duration_seconds, + "source": "admin_upload", + }, + ) + await db.refresh(resource) + _log( + AdminUploadLogEventEnum.DB_RECORD_SUCCESS.value, + admin=admin, + scene=scene.value, + resource_type=resource_type.value, + detail={"resource_id": resource.id, "url": stored.file_url, "module": module}, + ) + media_reference = _media_reference( + url=stored.file_url, + resource_type=resource_type, + name=stored.original_file_name or stored.file_name, + duration_seconds=resource.duration_seconds, + resource_id=resource.id, + ) + return AdminUploadFileOut( + resource_id=resource.id, + scene=scene, + module=module, + resource_type=resource_type, + url=stored.file_url, + file_name=stored.file_name, + original_file_name=stored.original_file_name, + file_size_bytes=stored.file_size_bytes, + duration_seconds=resource.duration_seconds, + media_reference=media_reference, + ) + except Exception as exc: + _log( + AdminUploadLogEventEnum.FAILED.value, + admin=admin, + scene=scene.value, + resource_type=resource_type.value, + status=LogEventStatusEnum.FAILED.value, + detail=build_exception_detail(exc, {"filename": file.filename}), + error=str(exc), + ) + raise + + +admin_upload_service = AdminUploadService() diff --git a/video-gen-api/app/services/admin_upload/storage.py b/video-gen-api/app/services/admin_upload/storage.py new file mode 100644 index 00000000..1b32bcd0 --- /dev/null +++ b/video-gen-api/app/services/admin_upload/storage.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import os +import re +import shutil +import tempfile +import uuid +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +from fastapi import HTTPException, UploadFile + +from app.config import settings +from app.enums.admin_upload import ( + ADMIN_UPLOAD_AUDIO_EXTENSIONS, + ADMIN_UPLOAD_AUDIO_MAX_BYTES, + ADMIN_UPLOAD_FILE_MAX_BYTES, + ADMIN_UPLOAD_FILE_NAME_MAX_LEN, + ADMIN_UPLOAD_IMAGE_EXTENSIONS, + ADMIN_UPLOAD_IMAGE_MAX_BYTES, + ADMIN_UPLOAD_PDF_EXTENSIONS, + ADMIN_UPLOAD_PDF_MAX_BYTES, + ADMIN_UPLOAD_VIDEO_EXTENSIONS, + ADMIN_UPLOAD_VIDEO_MAX_BYTES, + AdminUploadResourceTypeEnum, + AdminUploadSceneEnum, +) + +CHUNK_SIZE = 1024 * 1024 + + +@dataclass(slots=True) +class StoredAdminUploadFile: + storage_path: str + file_url: str + file_name: str + original_file_name: str | None + file_size_bytes: int + + +def _upload_root() -> Path: + return Path(settings.UPLOAD_LOCAL_PATH).resolve() + + +def _safe_original_name(filename: str | None) -> str | None: + if not filename: + return None + name = Path(filename).name.strip() + name = re.sub(r"[\\/\r\n\t]+", "_", name) + return name[:ADMIN_UPLOAD_FILE_NAME_MAX_LEN] or None + + +def _ext(filename: str | None) -> str: + return Path(filename or "").suffix.lower() + + +def _allowed_extensions(resource_type: AdminUploadResourceTypeEnum) -> set[str] | None: + if resource_type == AdminUploadResourceTypeEnum.IMAGE: + return ADMIN_UPLOAD_IMAGE_EXTENSIONS + if resource_type == AdminUploadResourceTypeEnum.VIDEO: + return ADMIN_UPLOAD_VIDEO_EXTENSIONS + if resource_type == AdminUploadResourceTypeEnum.AUDIO: + return ADMIN_UPLOAD_AUDIO_EXTENSIONS + if resource_type == AdminUploadResourceTypeEnum.PDF: + return ADMIN_UPLOAD_PDF_EXTENSIONS + return None + + +def _max_bytes(resource_type: AdminUploadResourceTypeEnum) -> int: + if resource_type == AdminUploadResourceTypeEnum.IMAGE: + return ADMIN_UPLOAD_IMAGE_MAX_BYTES + if resource_type == AdminUploadResourceTypeEnum.VIDEO: + return ADMIN_UPLOAD_VIDEO_MAX_BYTES + if resource_type == AdminUploadResourceTypeEnum.AUDIO: + return ADMIN_UPLOAD_AUDIO_MAX_BYTES + if resource_type == AdminUploadResourceTypeEnum.PDF: + return ADMIN_UPLOAD_PDF_MAX_BYTES + return ADMIN_UPLOAD_FILE_MAX_BYTES + + +def validate_file_name(filename: str | None, resource_type: AdminUploadResourceTypeEnum) -> str: + safe_name = _safe_original_name(filename) + if not safe_name: + raise HTTPException(status_code=400, detail="请选择文件") + allowed = _allowed_extensions(resource_type) + ext = _ext(safe_name) + if allowed is not None and ext not in allowed: + if resource_type == AdminUploadResourceTypeEnum.IMAGE: + raise HTTPException(status_code=400, detail="仅支持 jpg/jpeg/png/webp/gif 图片") + if resource_type == AdminUploadResourceTypeEnum.VIDEO: + raise HTTPException(status_code=400, detail="仅支持 mp4/mov/m4v/webm 视频") + if resource_type == AdminUploadResourceTypeEnum.AUDIO: + raise HTTPException(status_code=400, detail="仅支持 mp3/wav/m4a/aac 音频") + if resource_type == AdminUploadResourceTypeEnum.PDF: + raise HTTPException(status_code=400, detail="仅支持 PDF 文件") + return safe_name + + +def _kind_dir(resource_type: AdminUploadResourceTypeEnum) -> str: + if resource_type == AdminUploadResourceTypeEnum.IMAGE: + return "images" + if resource_type == AdminUploadResourceTypeEnum.VIDEO: + return "videos" + if resource_type == AdminUploadResourceTypeEnum.AUDIO: + return "audios" + return "files" + + +def _scene_base_dir(scene: AdminUploadSceneEnum, resource_type: AdminUploadResourceTypeEnum) -> Path: + if scene == AdminUploadSceneEnum.HOME_MATERIAL_REFERENCE: + if resource_type not in {AdminUploadResourceTypeEnum.IMAGE, AdminUploadResourceTypeEnum.VIDEO, AdminUploadResourceTypeEnum.AUDIO}: + raise HTTPException(status_code=400, detail="首页素材附件仅支持图片、视频、音频") + return Path("home_materials") / "references" / _kind_dir(resource_type) + if scene == AdminUploadSceneEnum.SYSTEM_LOGO: + if resource_type != AdminUploadResourceTypeEnum.IMAGE: + raise HTTPException(status_code=400, detail="系统Logo仅支持图片") + return Path("admin_uploads") / "system_logo" / "images" + if scene == AdminUploadSceneEnum.SYSTEM_PDF: + if resource_type != AdminUploadResourceTypeEnum.PDF: + raise HTTPException(status_code=400, detail="系统PDF仅支持PDF文件") + return Path("admin_uploads") / "system_pdf" / "files" + if scene == AdminUploadSceneEnum.OPEN_TYPE_THUMB: + if resource_type != AdminUploadResourceTypeEnum.IMAGE: + raise HTTPException(status_code=400, detail="开户方式缩略图仅支持图片") + return Path("admin_uploads") / "open_type_thumb" / "images" + return Path("admin_uploads") / "common" / _kind_dir(resource_type) + + +def build_target(scene: AdminUploadSceneEnum, resource_type: AdminUploadResourceTypeEnum, admin_id: str, original_filename: str | None) -> tuple[Path, str, str]: + safe_original = validate_file_name(original_filename, resource_type) + now = datetime.now() + date_dir = now.strftime("%Y/%m/%d") + timestamp = now.strftime("%Y%m%d_%H%M%S") + suffix = uuid.uuid4().hex[:8] + ext = _ext(safe_original) + prefix = { + AdminUploadResourceTypeEnum.IMAGE: "admin_img", + AdminUploadResourceTypeEnum.VIDEO: "admin_video", + AdminUploadResourceTypeEnum.AUDIO: "admin_audio", + AdminUploadResourceTypeEnum.PDF: "admin_pdf", + }.get(resource_type, "admin_file") + file_name = f"{prefix}_{admin_id}_{timestamp}_{suffix}{ext}" + rel_dir = _scene_base_dir(scene, resource_type) / date_dir + storage_path = _upload_root() / rel_dir / file_name + file_url = f"/uploads/{(rel_dir / file_name).as_posix()}" + return storage_path, file_url, file_name + + +async def save_upload_file(file: UploadFile, *, scene: AdminUploadSceneEnum, resource_type: AdminUploadResourceTypeEnum, admin_id: str) -> StoredAdminUploadFile: + original_file_name = validate_file_name(file.filename, resource_type) + max_bytes = _max_bytes(resource_type) + fd, temp_path = tempfile.mkstemp(prefix="admin_upload_", suffix=".tmp") + total = 0 + final_path: Path | None = None + try: + with os.fdopen(fd, "wb") as out: + while True: + chunk = await file.read(CHUNK_SIZE) + if not chunk: + break + total += len(chunk) + if total > max_bytes: + raise HTTPException(status_code=400, detail=f"文件大小不能超过 {max_bytes // 1024 // 1024}MB") + out.write(chunk) + final_path, file_url, file_name = build_target(scene, resource_type, admin_id, original_file_name) + final_path.parent.mkdir(parents=True, exist_ok=True) + shutil.move(temp_path, final_path) + temp_path = "" + return StoredAdminUploadFile( + storage_path=str(final_path), + file_url=file_url, + file_name=file_name, + original_file_name=original_file_name, + file_size_bytes=total, + ) + except Exception: + if temp_path: + try: + os.remove(temp_path) + except OSError: + pass + if final_path and final_path.exists(): + try: + final_path.unlink() + except OSError: + pass + raise diff --git a/video-gen-api/app/services/home_material/query.py b/video-gen-api/app/services/home_material/query.py index 2233cb07..899f71f6 100644 --- a/video-gen-api/app/services/home_material/query.py +++ b/video-gen-api/app/services/home_material/query.py @@ -16,6 +16,7 @@ from app.enums.home_material import ( from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark from app.schemas.home_material import ( HomeMaterialAssetOut, + HomeMaterialMediaReference, HomeMaterialCategoryOut, HomeMaterialPublicAssetOut, HomeMaterialPublicCategoryGroupOut, @@ -39,6 +40,24 @@ def _load_json(value: str | None) -> dict[str, Any] | None: return None +def _load_media_references(value: str | None) -> list[HomeMaterialMediaReference]: + if not value: + return [] + try: + data = json.loads(value) + if not isinstance(data, list): + return [] + refs: list[HomeMaterialMediaReference] = [] + for item in data: + try: + refs.append(HomeMaterialMediaReference(**item)) + except Exception: + continue + return refs + except Exception: + return [] + + class HomeMaterialQueryService: """首页素材高性能查询组装层:列表查询 → ID 去重 → 批量查询 → map 组装。""" @@ -152,6 +171,8 @@ class HomeMaterialQueryService: watermark_id=asset.watermark_id, watermark_name=watermark.name if watermark else None, watermark_config=_load_json(asset.watermark_config_json), + generation_prompt=asset.generation_prompt, + media_references=_load_media_references(asset.media_references_json), width=asset.width, height=asset.height, duration_seconds=asset.duration_seconds, @@ -340,6 +361,8 @@ class HomeMaterialQueryService: width=a.width, height=a.height, duration_seconds=a.duration_seconds, + generation_prompt=a.generation_prompt, + media_references=_load_media_references(a.media_references_json), sort_order=a.sort_order, ) for a in assets @@ -403,6 +426,8 @@ class HomeMaterialQueryService: width=asset.width, height=asset.height, duration_seconds=asset.duration_seconds, + generation_prompt=asset.generation_prompt, + media_references=_load_media_references(asset.media_references_json), sort_order=asset.sort_order, ) ) diff --git a/video-gen-api/app/services/home_material/service.py b/video-gen-api/app/services/home_material/service.py index b51e45e7..f53992c3 100644 --- a/video-gen-api/app/services/home_material/service.py +++ b/video-gen-api/app/services/home_material/service.py @@ -12,14 +12,20 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.enums.home_material import ( HOME_MATERIAL_DEFAULT_CONFIG, + HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN, + HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN, + HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT, HomeMaterialAssetStatus, HomeMaterialConfigKeyEnum, + HomeMaterialLogEventEnum, HomeMaterialMediaType, HomeMaterialPublicResponseMode, HomeMaterialWatermarkPosition, HomeMaterialWatermarkSizeMode, HomeMaterialWatermarkType, ) +from app.enums.common import LogEventStatusEnum, LogSourceEnum +from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceSourceModelEnum from app.models.base import async_session from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark from app.models.system_config import SystemConfig @@ -36,6 +42,7 @@ from app.schemas.home_material import ( HomeMaterialConfigUpdate, HomeMaterialPublicCategoryListOut, HomeMaterialPublicFlatOut, + HomeMaterialMediaReference, HomeMaterialPublicGroupedOut, HomeMaterialRegenerateWatermarkRequest, HomeMaterialTextWatermarkPreviewRequest, @@ -49,6 +56,8 @@ from app.schemas.home_material import ( from app.services.home_material.query import query_service from app.services.home_material.storage import storage_service from app.services.home_material.watermark_processor import watermark_processor +from app.services.operation_log_service import build_exception_detail, log_operation_event +from app.services.upload_resource.bind_service import bind_upload_resources from app.utils.id_gen import generate_id @@ -66,11 +75,99 @@ def _json_loads(value: str | None) -> dict[str, Any] | None: return None +def _json_loads_list(value: str | None) -> list[Any]: + if not value: + return [] + try: + data = json.loads(value) + return data if isinstance(data, list) else [] + except Exception as exc: + log_operation_event( + domain="home_material", + module="home_material_asset", + event_type=HomeMaterialLogEventEnum.MEDIA_REFERENCES_PARSE_FAILED.value, + event_status=LogEventStatusEnum.WARNING.value, + source=LogSourceEnum.SERVICE.value, + detail={"raw_prefix": value[:500]}, + error=str(exc), + ) + return [] + + def _clean_title(value: str | None) -> str | None: value = (value or "").strip() return value or None +def _clean_generation_prompt(value: str | None) -> str | None: + text = (value or "").strip() + if not text: + return None + if len(text) > HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"生成提词不能超过 {HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN} 个字符") + return text + + +def _media_references_from_json(value: str | None) -> list[HomeMaterialMediaReference]: + if not value or not value.strip(): + return [] + if len(value) > HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"附件JSON不能超过 {HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN} 个字符") + try: + raw = json.loads(value) + except Exception as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件JSON格式错误") from exc + if raw is None: + return [] + if not isinstance(raw, list): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件必须是数组格式") + return _normalize_media_references(raw) + + +def _normalize_media_references(value: list[Any] | None) -> list[HomeMaterialMediaReference]: + if not value: + return [] + if len(value) > HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"附件最多 {HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT} 个") + refs: list[HomeMaterialMediaReference] = [] + for item in value: + try: + ref = item if isinstance(item, HomeMaterialMediaReference) else HomeMaterialMediaReference(**item) + except Exception as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"附件格式错误:{exc}") from exc + refs.append(ref) + return refs + + +def _dump_media_references_json(refs: list[HomeMaterialMediaReference] | None) -> str | None: + items = [r.model_dump(mode="json", exclude_none=True) for r in (refs or [])] + if not items: + return None + text = _json_dumps(items) + if len(text) > HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"附件JSON不能超过 {HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN} 个字符") + return text + + +def _media_references_out(value: str | None) -> list[HomeMaterialMediaReference]: + return _normalize_media_references(_json_loads_list(value)) + + +def _media_reference_resource_ids(refs: list[HomeMaterialMediaReference]) -> list[str]: + return [str(r.upload_resource_id).strip() for r in refs if r.upload_resource_id and str(r.upload_resource_id).strip()] + + +def _media_reference_urls(refs: list[HomeMaterialMediaReference]) -> list[str]: + return [str(r.url).strip() for r in refs if r.url and str(r.url).strip()] + + +def _media_reference_type_counts(refs: list[HomeMaterialMediaReference]) -> dict[str, int]: + counts: dict[str, int] = {} + for ref in refs: + counts[ref.type] = counts.get(ref.type, 0) + 1 + return counts + + def _normalize_watermark_config_dict(value: dict[str, Any] | None, fallback_watermark_id: str | None = None) -> dict[str, Any]: """兼容旧水印配置。旧数据没有 watermark_type 时按 image 处理。""" data = dict(value or {}) @@ -108,6 +205,8 @@ def _asset_snapshot(asset: HomeMaterialAsset | None) -> dict[str, Any] | None: "cover_url": asset.cover_url, "watermark_id": asset.watermark_id, "watermark_config": _json_loads(asset.watermark_config_json), + "generation_prompt": asset.generation_prompt, + "media_references_count": len(_media_references_out(asset.media_references_json)), "is_active": asset.is_active, "sort_order": asset.sort_order, "deleted_at": asset.deleted_at, @@ -142,6 +241,31 @@ def _watermark_snapshot(watermark: HomeMaterialWatermark | None) -> dict[str, An } + +def _log_generation_config_event( + event_type: str, + *, + admin_id: str | None, + asset_id: str | None = None, + event_status: str = LogEventStatusEnum.SUCCESS.value, + message: str | None = None, + detail: dict[str, Any] | None = None, + error: str | None = None, +) -> None: + log_operation_event( + domain="home_material", + module="home_material_asset", + event_type=event_type, + event_status=event_status, + source=LogSourceEnum.API.value, + user_id=admin_id, + asset_id=asset_id, + message=message, + detail=detail or {}, + error=error, + ) + + async def _flush_refresh(db: AsyncSession, obj: Any) -> None: """ 写入后立即返回 ORM 对象前必须显式刷新。 @@ -372,10 +496,24 @@ class HomeMaterialService: watermark_config: HomeMaterialWatermarkConfig, is_active: bool, sort_order: int, + generation_prompt: str | None = None, + media_references_json: str | None = None, admin_id: str | None, ) -> tuple[HomeMaterialUploadResultOut, dict[str, Any]]: await self._get_category(db, category_id, active_only=False) clean_title = _clean_title(title) + clean_prompt = _clean_generation_prompt(generation_prompt) + refs = _media_references_from_json(media_references_json) + refs_json = _dump_media_references_json(refs) + _log_generation_config_event( + HomeMaterialLogEventEnum.GENERATION_CONFIG_VALIDATE_SUCCESS.value, + admin_id=admin_id, + detail={ + "generation_prompt_length": len(clean_prompt or ""), + "media_references_count": len(refs), + "media_reference_types": _media_reference_type_counts(refs), + }, + ) watermark_type = watermark_config.watermark_type final_watermark_id: str | None = None if watermark_type == HomeMaterialWatermarkType.IMAGE: @@ -415,6 +553,8 @@ class HomeMaterialService: original_storage_path=stored.storage_path, watermark_id=final_watermark_id, watermark_config_json=_json_dumps(cfg.model_dump(mode="json")), + generation_prompt=clean_prompt, + media_references_json=refs_json, status=HomeMaterialAssetStatus.PROCESSING.value, width=probe.width, height=probe.height, @@ -427,7 +567,10 @@ class HomeMaterialService: ) db.add(asset) await _flush_refresh(db, asset) - return self._upload_result(asset, message="素材已上传,水印处理中"), _asset_snapshot(asset) or {} + bind_stats = await self._bind_media_reference_resources(db, asset_id=asset.id, admin_id=admin_id, refs=refs) + after = _asset_snapshot(asset) or {} + after["media_reference_bind_stats"] = bind_stats + return self._upload_result(asset, message="素材已上传,水印处理中"), after async def list_assets( self, @@ -466,15 +609,67 @@ class HomeMaterialService: asset = await self._get_asset(db, asset_id) await self._get_category(db, req.category_id) before = _asset_snapshot(asset) or {} - asset.category_id = req.category_id - asset.title = req.title - asset.is_active = req.is_active - asset.sort_order = req.sort_order - asset.updated_by = admin_id - db.add(asset) - await _flush_refresh(db, asset) - after = _asset_snapshot(asset) or {} - return await self.get_asset_detail(db, asset_id), before, after + try: + _log_generation_config_event( + HomeMaterialLogEventEnum.GENERATION_CONFIG_VALIDATE_STARTED.value, + admin_id=admin_id, + asset_id=asset_id, + event_status=LogEventStatusEnum.STARTED.value, + ) + clean_prompt = _clean_generation_prompt(req.generation_prompt) + refs = _normalize_media_references(req.media_references) + refs_json = _dump_media_references_json(refs) + _log_generation_config_event( + HomeMaterialLogEventEnum.GENERATION_CONFIG_VALIDATE_SUCCESS.value, + admin_id=admin_id, + asset_id=asset_id, + detail={ + "generation_prompt_length": len(clean_prompt or ""), + "media_references_count": len(refs), + "media_reference_types": _media_reference_type_counts(refs), + }, + ) + _log_generation_config_event( + HomeMaterialLogEventEnum.GENERATION_CONFIG_UPDATE_STARTED.value, + admin_id=admin_id, + asset_id=asset_id, + event_status=LogEventStatusEnum.STARTED.value, + ) + asset.category_id = req.category_id + asset.title = req.title + asset.is_active = req.is_active + asset.sort_order = req.sort_order + asset.generation_prompt = clean_prompt + asset.media_references_json = refs_json + asset.updated_by = admin_id + db.add(asset) + await _flush_refresh(db, asset) + bind_stats = await self._bind_media_reference_resources(db, asset_id=asset.id, admin_id=admin_id, refs=refs) + after = _asset_snapshot(asset) or {} + after["media_reference_bind_stats"] = bind_stats + _log_generation_config_event( + HomeMaterialLogEventEnum.GENERATION_CONFIG_UPDATE_SUCCESS.value, + admin_id=admin_id, + asset_id=asset_id, + detail={ + "changed_fields": [k for k in ("generation_prompt", "media_references_count") if before.get(k) != after.get(k)], + "generation_prompt_length": len(clean_prompt or ""), + "media_references_count": len(refs), + "media_reference_types": _media_reference_type_counts(refs), + "bind_stats": bind_stats, + }, + ) + return await self.get_asset_detail(db, asset_id), before, after + except Exception as exc: + _log_generation_config_event( + HomeMaterialLogEventEnum.GENERATION_CONFIG_UPDATE_FAILED.value, + admin_id=admin_id, + asset_id=asset_id, + event_status=LogEventStatusEnum.FAILED.value, + detail=build_exception_detail(exc, {"before": before}), + error=str(exc), + ) + raise async def prepare_regenerate( self, @@ -509,6 +704,28 @@ class HomeMaterialService: db.add(asset) return asset, before + + async def _bind_media_reference_resources( + self, + db: AsyncSession, + *, + asset_id: str, + admin_id: str | None, + refs: list[HomeMaterialMediaReference], + ) -> dict[str, int]: + if not admin_id or not refs: + return {"matched": 0, "bound": 0, "skipped": 0, "conflict": 0} + return await bind_upload_resources( + db, + user_id=admin_id, + module=UploadResourceModuleEnum.HOME_MATERIAL.value, + source_model=UploadResourceSourceModelEnum.HOME_MATERIAL_ASSET.value, + source_id=asset_id, + resource_ids=_media_reference_resource_ids(refs), + urls=_media_reference_urls(refs), + allow_common_migrate=False, + ) + async def get_asset_status(self, db: AsyncSession, asset_id: str) -> HomeMaterialAssetStatusOut: asset = await self._get_asset(db, asset_id) return HomeMaterialAssetStatusOut( @@ -541,6 +758,8 @@ class HomeMaterialService: watermarked_url=asset.watermarked_url, cover_url=asset.cover_url, watermark_config=_json_loads(asset.watermark_config_json), + generation_prompt=asset.generation_prompt, + media_references=_media_references_out(asset.media_references_json), message=message, ) diff --git a/video-gen-api/app/services/upload_resource/path_resolver.py b/video-gen-api/app/services/upload_resource/path_resolver.py index fd950fb3..22aeda7c 100644 --- a/video-gen-api/app/services/upload_resource/path_resolver.py +++ b/video-gen-api/app/services/upload_resource/path_resolver.py @@ -18,6 +18,8 @@ COMMON_AUDIO_RE = re.compile(r"^audios/(?P\d{4})/(?P\d{2})/(?P MODULE_RE = re.compile(r"^(?Phot_opening_replicate|shot_replicate)/(?Pimages|videos)/(?P\d{4})/(?P\d{2})/(?P\d{2})/(?Pvideo_img|video_ref)_(?P[^_]+)_(?P\d{8})_(?P\d{6})_(?P[0-9a-fA-F]{8})\.(?P[^/]+)$") SHOT_SEGMENT_RE = re.compile(r"^shot_segments/(?P\d{4})/(?P\d{2})/(?P\d{2})/(?P[^/]+)\.mp4$") LEGACY_GEN_RE = re.compile(r"^(?Pimages|videos)/gen_(?P[^_]+)_(?P[0-9a-zA-Z]+)\.(?P[^/]+)$") +ADMIN_UPLOAD_RE = re.compile(r"^admin_uploads/(?Psystem_logo|system_pdf|open_type_thumb|common)/(?Pimages|videos|audios|files)/(?P\d{4})/(?P\d{2})/(?P\d{2})/(?Padmin_img|admin_video|admin_audio|admin_pdf|admin_file)_(?P[^_]+)_(?P\d{8})_(?P\d{6})_(?P[0-9a-fA-F]{8})\.(?P[^/]+)$") +HOME_MATERIAL_REFERENCE_RE = re.compile(r"^home_materials/references/(?Pimages|videos|audios)/(?P\d{4})/(?P\d{2})/(?P\d{2})/(?Padmin_img|admin_video|admin_audio)_(?P[^_]+)_(?P\d{8})_(?P\d{6})_(?P[0-9a-fA-F]{8})\.(?P[^/]+)$") IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".svg"} VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv"} @@ -115,6 +117,26 @@ def parse_upload_path(path: str | os.PathLike[str], *, include_legacy: bool = Fa except ValueError: return None + for pattern, module in ( + (ADMIN_UPLOAD_RE, UploadResourceModuleEnum.ADMIN_UPLOAD.value), + (HOME_MATERIAL_REFERENCE_RE, UploadResourceModuleEnum.HOME_MATERIAL.value), + ): + m = pattern.match(rel) + if m: + d = m.groupdict() + kind = d.get("kind") + if kind == "images": + rtype = UploadResourceTypeEnum.IMAGE.value + elif kind == "videos": + rtype = UploadResourceTypeEnum.VIDEO.value + elif kind == "audios": + rtype = UploadResourceTypeEnum.AUDIO.value + elif d.get("prefix") == "admin_pdf": + rtype = UploadResourceTypeEnum.PDF.value + else: + rtype = UploadResourceTypeEnum.FILE.value + return _base(abs_path, module, rtype, d.get("user_id"), _parse_created_at(d)) + ignored_prefixes = ("home_materials/",) if rel in {"site_logo.png"} or rel.startswith(ignored_prefixes) or rel.startswith("pdf_"): return ParsedUploadPath(