管理后台上传集成API | 首页素材装修追加提词/附件
This commit is contained in:
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
@@ -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 ###
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
@@ -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: "后台公共上传",
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 的附件引用对象。")
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user