管理后台上传集成API | 首页素材装修追加提词/附件

This commit is contained in:
2026-07-09 10:40:23 +08:00
parent 465ed9fa04
commit 0148bea66a
22 changed files with 1296 additions and 63 deletions
+23 -40
View File
@@ -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<AdminU
return api.delete(`/admin/users/${userId}/resource-capacity`);
}
export async function uploadPdf(file: File, configKey: string): Promise<{ url: string }> {
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<AdminUploadFileResult> {
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<AdminUploadFileResult>('/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<any> {
}
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') {
+5 -1
View File
@@ -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 {
@@ -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<string, string> = {
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 <Typography.Text type="secondary"></Typography.Text>;
return (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
{list.map((ref, index) => {
const displayUrl = apiUrl(ref.displayUrl || ref.url);
const previewUrl = apiUrl(ref.previewUrl || ref.displayUrl || ref.url);
return (
<div key={`${ref.url}-${index}`} style={{ border: '1px solid #f0f0f5', borderRadius: 8, padding: 12, display: 'flex', gap: 12 }}>
<div style={{ width: 140, minHeight: 80, flexShrink: 0, background: '#f6f7fb', borderRadius: 8, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{ref.type === 'image' && <Image width={140} height={80} style={{ objectFit: 'cover' }} src={previewUrl} />}
{ref.type === 'video' && <video src={displayUrl} controls style={{ width: 140, height: 80, objectFit: 'cover', background: '#000' }} />}
{ref.type === 'audio' && <audio src={displayUrl} controls style={{ width: 132 }} />}
</div>
<Space direction="vertical" size={6} style={{ flex: 1, minWidth: 0 }}>
<Space wrap>
<Tag>{mediaTypeText[ref.type] || ref.type}</Tag>
{ref.duration !== undefined && ref.duration !== null && <Tag>{ref.duration}s</Tag>}
{ref.role && <Tag>{ref.role}</Tag>}
</Space>
<Typography.Text strong>{ref.name || '未命名附件'}</Typography.Text>
<Typography.Text copyable ellipsis style={{ maxWidth: 620 }}>{ref.url}</Typography.Text>
</Space>
</div>
);
})}
</Space>
);
}
const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloadKey }) => {
const [items, setItems] = useState<HomeMaterialAsset[]>([]);
const [total, setTotal] = useState(0);
@@ -103,6 +149,10 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
const [regen, setRegen] = useState<HomeMaterialAsset | null>(null);
const [regenConfig, setRegenConfig] = useState<HomeMaterialWatermarkConfig>(defaultConfig);
const [regenSubmitting, setRegenSubmitting] = useState(false);
const [editingConfig, setEditingConfig] = useState<HomeMaterialAsset | null>(null);
const [editPrompt, setEditPrompt] = useState('');
const [editRefs, setEditRefs] = useState<HomeMaterialMediaReference[]>([]);
const [editSubmitting, setEditSubmitting] = useState(false);
const load = async () => {
setLoading(true);
@@ -133,6 +183,12 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ categories, watermarks, reloa
{ title: '行业', dataIndex: 'categoryName' },
{ title: '类型', dataIndex: 'mediaType', render: (v: string) => v === 'image' ? '图片' : '视频' },
{ title: '状态', dataIndex: 'status', render: (v: string) => <Tag color={statusMap[v]?.color}>{statusMap[v]?.text || v}</Tag> },
{
title: '生成提词',
width: 210,
render: (_: unknown, row: HomeMaterialAsset) => row.generationPrompt ? (
<Tooltip title={row.generationPrompt}><Typography.Text>{shortText(row.generationPrompt)}</Typography.Text></Tooltip>
) : <Typography.Text type="secondary"></Typography.Text>,
},
{
title: '附件',
width: 90,
render: (_: unknown, row: HomeMaterialAsset) => <Tag color={(row.mediaReferences?.length || 0) > 0 ? 'blue' : 'default'}>{row.mediaReferences?.length || 0}</Tag>,
},
{
title: '水印',
render: (_: unknown, row: HomeMaterialAsset) => {
@@ -267,7 +355,7 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
{ title: '错误', dataIndex: 'errorMessage', ellipsis: true },
{
title: '操作',
width: 150,
width: 170,
align: 'center' as const,
render: (_: unknown, row: HomeMaterialAsset) => (
<Space size={8}>
@@ -276,10 +364,12 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
trigger={['click']}
menu={{
items: [
{ key: 'generation_config', icon: <EditOutlined />, label: '编辑生成配置' },
{ key: 'regenerate', icon: <ReloadOutlined />, label: '重新生成水印' },
{ key: 'delete', icon: <DeleteOutlined />, 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<Props> = ({ categories, watermarks, reloa
},
]}
/>
<Modal title="素材详情" open={!!preview} onCancel={() => setPreview(null)} footer={null} width={900} destroyOnHidden>
<Modal title="素材详情" open={!!preview} onCancel={() => setPreview(null)} footer={null} width={960} destroyOnHidden>
{preview && (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<div><b></b>{preview.originalUrl ? apiUrl(preview.originalUrl) : '-'}</div>
<div><b></b>{preview.watermarkedUrl ? apiUrl(preview.watermarkedUrl) : '-'}</div>
<div>
<b></b>
<div style={{ marginTop: 8, padding: 12, borderRadius: 8, background: '#f8fafc', whiteSpace: 'pre-wrap' }}>
{preview.generationPrompt || '未设置'}
</div>
</div>
<div>
<b> / </b>
<div style={{ marginTop: 8 }}>{renderMediaReferences(preview.mediaReferences)}</div>
</div>
{preview.mediaType === 'image' ? (
<Image src={apiUrl(preview.watermarkedUrl || preview.originalUrl)} />
) : (
@@ -305,6 +405,38 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
</Space>
)}
</Modal>
<Modal
title="编辑生成配置"
open={!!editingConfig}
onCancel={() => !editSubmitting && setEditingConfig(null)}
onOk={submitGenerationConfig}
confirmLoading={editSubmitting}
cancelButtonProps={{ disabled: editSubmitting }}
width={920}
destroyOnHidden
>
<Space direction="vertical" style={{ width: '100%' }} size={16}>
<div>
<Typography.Text strong></Typography.Text>
<Input.TextArea
rows={6}
maxLength={8000}
showCount
value={editPrompt}
disabled={editSubmitting}
placeholder="请输入用于生成该素材的提示词"
onChange={(e) => setEditPrompt(e.target.value)}
style={{ marginTop: 8 }}
/>
</div>
<div>
<Typography.Text strong> / </Typography.Text>
<div style={{ marginTop: 8 }}>
<MediaReferencesEditor value={editRefs} onChange={setEditRefs} disabled={editSubmitting} />
</div>
</div>
</Space>
</Modal>
<Modal
title="重新生成水印"
open={!!regen}
@@ -2,8 +2,9 @@ import React, { useEffect, useMemo, useState } from 'react';
import { Form, Input, InputNumber, Modal, Select, Switch, Upload, Button, message } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import { uploadHomeMaterialAsset } from '../../api';
import type { HomeMaterialCategory, HomeMaterialMediaType, HomeMaterialWatermark, HomeMaterialWatermarkConfig } from '../../types';
import type { HomeMaterialCategory, HomeMaterialMediaReference, HomeMaterialMediaType, HomeMaterialWatermark, HomeMaterialWatermarkConfig } from '../../types';
import WatermarkEditor from './WatermarkEditor';
import MediaReferencesEditor from './MediaReferencesEditor';
interface Props {
open: boolean;
@@ -50,6 +51,7 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
const [config, setConfig] = useState<HomeMaterialWatermarkConfig>(defaultConfig);
const [submitting, setSubmitting] = useState(false);
const [mediaObjectUrl, setMediaObjectUrl] = useState<string>('');
const [mediaReferences, setMediaReferences] = useState<HomeMaterialMediaReference[]>([]);
const resetState = () => {
setFile(null);
@@ -57,6 +59,7 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
setMediaType('image');
setMediaObjectUrl('');
setSubmitting(false);
setMediaReferences([]);
};
useEffect(() => {
@@ -64,7 +67,7 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ open, onClose, categories, w
</Upload>
</Form.Item>
<Form.Item name="title" label="素材标题" extra="选填;不填时后台显示“未命名素材”,不会再自动使用文件名作为标题。"><Input disabled={submitting} /></Form.Item>
<Form.Item name="generation_prompt" label="生成提词" extra="选填;会随前台素材接口返回。">
<Input.TextArea rows={5} maxLength={8000} showCount disabled={submitting} placeholder="请输入用于生成该素材的提示词" />
</Form.Item>
<Form.Item label="附件 / 参考素材">
<MediaReferencesEditor value={mediaReferences} onChange={setMediaReferences} disabled={submitting} />
</Form.Item>
<Form.Item name="sort_order" label="排序"><InputNumber min={0} style={{ width: '100%' }} disabled={submitting} /></Form.Item>
<Form.Item name="is_active" label="前台展示" valuePropName="checked"><Switch disabled={submitting} /></Form.Item>
</Form>
@@ -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<string, string> = {
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<number | null> {
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<Props> = ({ value, onChange, disabled }) => {
const refs = useMemo(() => value || [], [value]);
const [uploading, setUploading] = useState(false);
const updateItem = (index: number, patch: Partial<HomeMaterialMediaReference>) => {
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 (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Upload beforeUpload={(file) => uploadFile(file as File)} showUploadList={false} disabled={disabled || uploading} accept="image/*,video/*,audio/*">
<Button icon={<UploadOutlined />} loading={uploading} disabled={disabled || uploading}></Button>
</Upload>
{refs.length === 0 ? (
<div style={{ color: '#999', fontSize: 13 }}></div>
) : (
<Space direction="vertical" style={{ width: '100%' }} size={10}>
{refs.map((ref, index) => {
const previewUrl = apiUrl(ref.previewUrl || ref.displayUrl || ref.url);
const displayUrl = apiUrl(ref.displayUrl || ref.url);
return (
<Card key={`${ref.url}-${index}`} size="small" bodyStyle={{ padding: 12 }}>
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
<div style={{ width: 120, minHeight: 72, flexShrink: 0, borderRadius: 8, overflow: 'hidden', background: '#f6f7fb', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{ref.type === 'image' && <Image width={120} height={72} style={{ objectFit: 'cover' }} src={previewUrl} />}
{ref.type === 'video' && <video src={displayUrl} style={{ width: 120, height: 72, objectFit: 'cover', background: '#000' }} controls />}
{ref.type === 'audio' && <audio src={displayUrl} controls style={{ width: 112 }} />}
</div>
<Space direction="vertical" style={{ flex: 1, minWidth: 0 }} size={8}>
<Space wrap>
<Tag color={ref.type === 'image' ? 'blue' : ref.type === 'video' ? 'purple' : 'green'}>{mediaTypeText[ref.type] || ref.type}</Tag>
{ref.uploadResourceId && <Tag>ID{ref.uploadResourceId}</Tag>}
</Space>
<Input
size="small"
value={ref.name || ''}
placeholder="附件名称"
disabled={disabled}
onChange={(e) => updateItem(index, { name: e.target.value || null })}
/>
<Input
size="small"
value={ref.role || ''}
placeholder="附件角色,如 reference_image"
disabled={disabled}
onChange={(e) => updateItem(index, { role: e.target.value || null })}
/>
{(ref.type === 'video' || ref.type === 'audio') && (
<InputNumber
size="small"
min={0}
precision={3}
style={{ width: 180 }}
value={ref.duration ?? null}
placeholder="时长(秒)"
disabled={disabled}
onChange={(v) => updateItem(index, { duration: v === null ? null : Number(v) })}
/>
)}
<Input size="small" value={ref.url} disabled />
</Space>
<Button danger size="small" icon={<DeleteOutlined />} disabled={disabled} onClick={() => removeItem(index)}></Button>
</div>
</Card>
);
})}
</Space>
)}
</Space>
);
};
export default MediaReferencesEditor;
+57
View File
@@ -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<string, unknown> | 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<string, unknown> | 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;
@@ -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 ###
+2
View File
@@ -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)
+6 -2
View File
@@ -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),
+41
View File
@@ -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_logoPDF使用 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
+55
View File
@@ -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: "后台公共上传",
}
+16
View File
@@ -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": "行业素材案例",
@@ -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: "文件",
}
@@ -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,
+30
View File
@@ -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 的附件引用对象。")
+47 -3
View File
@@ -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="提示消息。")
@@ -0,0 +1,3 @@
from app.services.admin_upload.service import admin_upload_service
__all__ = ["admin_upload_service"]
@@ -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()
@@ -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
@@ -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,
)
)
@@ -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,
)
@@ -18,6 +18,8 @@ COMMON_AUDIO_RE = re.compile(r"^audios/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>
MODULE_RE = re.compile(r"^(?P<module>hot_opening_replicate|shot_replicate)/(?P<kind>images|videos)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<prefix>video_img|video_ref)_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
SHOT_SEGMENT_RE = re.compile(r"^shot_segments/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<segment_id>[^/]+)\.mp4$")
LEGACY_GEN_RE = re.compile(r"^(?P<kind>images|videos)/gen_(?P<user_id>[^_]+)_(?P<rand>[0-9a-zA-Z]+)\.(?P<ext>[^/]+)$")
ADMIN_UPLOAD_RE = re.compile(r"^admin_uploads/(?P<scene>system_logo|system_pdf|open_type_thumb|common)/(?P<kind>images|videos|audios|files)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<prefix>admin_img|admin_video|admin_audio|admin_pdf|admin_file)_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
HOME_MATERIAL_REFERENCE_RE = re.compile(r"^home_materials/references/(?P<kind>images|videos|audios)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<prefix>admin_img|admin_video|admin_audio)_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
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(