管理后台上传集成API | 首页素材装修追加提词/附件
This commit is contained in:
@@ -16,6 +16,7 @@ import type {
|
|||||||
ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut,
|
ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut,
|
||||||
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
|
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
|
||||||
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
|
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
|
||||||
|
AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -251,39 +252,28 @@ export async function deleteUserResourceCapacity(userId: string): Promise<AdminU
|
|||||||
return api.delete(`/admin/users/${userId}/resource-capacity`);
|
return api.delete(`/admin/users/${userId}/resource-capacity`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadPdf(file: File, configKey: string): Promise<{ url: string }> {
|
export async function uploadAdminFile(
|
||||||
const formData = new FormData();
|
file: File,
|
||||||
formData.append('file', file);
|
options: { scene: AdminUploadScene; resourceType: AdminUploadResourceType; durationSeconds?: number | null },
|
||||||
formData.append('config_key', configKey);
|
): Promise<AdminUploadFileResult> {
|
||||||
const token = localStorage.getItem('auth_token');
|
const form = new FormData();
|
||||||
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
form.append('file', file);
|
||||||
const res = await fetch(`${baseUrl}/api/admin/upload-pdf`, {
|
form.append('scene', options.scene);
|
||||||
method: 'POST',
|
form.append('resource_type', options.resourceType);
|
||||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
if (options.durationSeconds !== undefined && options.durationSeconds !== null) {
|
||||||
body: formData,
|
form.append('duration_seconds', String(options.durationSeconds));
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(text || '上传失败');
|
|
||||||
}
|
}
|
||||||
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 }> {
|
export async function uploadLogo(file: File): Promise<{ url: string }> {
|
||||||
const formData = new FormData();
|
const res = await uploadAdminFile(file, { scene: 'system_logo', resourceType: 'image' });
|
||||||
formData.append('file', file);
|
return { url: res.url };
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMaybe(params: URLSearchParams, key: string, value: unknown): void {
|
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 }> {
|
export async function uploadImage(file: File): Promise<{ url: string; filename: string }> {
|
||||||
const form = new FormData();
|
const res = await uploadAdminFile(file, { scene: 'open_type_thumb', resourceType: 'image' });
|
||||||
form.append('file', file);
|
return { url: res.url, filename: res.originalFileName || res.fileName };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自定义表头字段
|
// 自定义表头字段
|
||||||
@@ -900,6 +881,8 @@ export async function uploadHomeMaterialAsset(payload: HomeMaterialUploadAssetPa
|
|||||||
form.append('file', payload.file);
|
form.append('file', payload.file);
|
||||||
form.append('media_type', payload.mediaType);
|
form.append('media_type', payload.mediaType);
|
||||||
if (payload.title && payload.title.trim()) form.append('title', payload.title.trim());
|
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);
|
form.append('watermark_type', payload.watermarkType);
|
||||||
|
|
||||||
if (payload.watermarkType === 'image') {
|
if (payload.watermarkType === 'image') {
|
||||||
|
|||||||
@@ -105,7 +105,11 @@ const AdminSettings: React.FC = () => {
|
|||||||
const res = await uploadLogo(file);
|
const res = await uploadLogo(file);
|
||||||
setConfigs(prev => prev.map(c => c.key === 'site_logo' ? { ...c, value: res.url } : c));
|
setConfigs(prev => prev.map(c => c.key === 'site_logo' ? { ...c, value: res.url } : c));
|
||||||
form.setFieldsValue({ site_logo: res.url });
|
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 {
|
} catch {
|
||||||
message.error('上传失败');
|
message.error('上传失败');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Button, Dropdown, Image, Modal, Select, Space, Table, Tag, message } from 'antd';
|
import { Button, Dropdown, Image, Input, Modal, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
|
||||||
import { DeleteOutlined, MoreOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, EditOutlined, MoreOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
deleteHomeMaterialAsset,
|
deleteHomeMaterialAsset,
|
||||||
getHomeMaterialAssetStatus,
|
getHomeMaterialAssetStatus,
|
||||||
getHomeMaterialAssets,
|
getHomeMaterialAssets,
|
||||||
regenerateHomeMaterialWatermark,
|
regenerateHomeMaterialWatermark,
|
||||||
|
updateHomeMaterialAsset,
|
||||||
} from '../../api';
|
} from '../../api';
|
||||||
import type {
|
import type {
|
||||||
HomeMaterialAsset,
|
HomeMaterialAsset,
|
||||||
HomeMaterialAssetQueryParams,
|
HomeMaterialAssetQueryParams,
|
||||||
HomeMaterialCategory,
|
HomeMaterialCategory,
|
||||||
|
HomeMaterialMediaReference,
|
||||||
HomeMaterialWatermark,
|
HomeMaterialWatermark,
|
||||||
HomeMaterialWatermarkConfig,
|
HomeMaterialWatermarkConfig,
|
||||||
HomeMaterialTextWatermarkConfig,
|
HomeMaterialTextWatermarkConfig,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
import { apiUrl } from '../../utils/resourceUrl';
|
import { apiUrl } from '../../utils/resourceUrl';
|
||||||
import WatermarkEditor from './WatermarkEditor';
|
import WatermarkEditor from './WatermarkEditor';
|
||||||
|
import MediaReferencesEditor from './MediaReferencesEditor';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
categories: HomeMaterialCategory[];
|
categories: HomeMaterialCategory[];
|
||||||
@@ -57,6 +60,12 @@ const defaultConfig: HomeMaterialWatermarkConfig = {
|
|||||||
textWatermark: defaultTextWatermark,
|
textWatermark: defaultTextWatermark,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mediaTypeText: Record<string, string> = {
|
||||||
|
image: '图片',
|
||||||
|
video: '视频',
|
||||||
|
audio: '音频',
|
||||||
|
};
|
||||||
|
|
||||||
function normalizeTextConfig(raw: any): HomeMaterialTextWatermarkConfig {
|
function normalizeTextConfig(raw: any): HomeMaterialTextWatermarkConfig {
|
||||||
return {
|
return {
|
||||||
text: raw?.text || defaultTextWatermark.text,
|
text: raw?.text || defaultTextWatermark.text,
|
||||||
@@ -94,6 +103,43 @@ function isValidConfig(config: HomeMaterialWatermarkConfig): boolean {
|
|||||||
return !!config.watermarkId;
|
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 HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloadKey }) => {
|
||||||
const [items, setItems] = useState<HomeMaterialAsset[]>([]);
|
const [items, setItems] = useState<HomeMaterialAsset[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
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 [regen, setRegen] = useState<HomeMaterialAsset | null>(null);
|
||||||
const [regenConfig, setRegenConfig] = useState<HomeMaterialWatermarkConfig>(defaultConfig);
|
const [regenConfig, setRegenConfig] = useState<HomeMaterialWatermarkConfig>(defaultConfig);
|
||||||
const [regenSubmitting, setRegenSubmitting] = useState(false);
|
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 () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -133,6 +183,12 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
|||||||
setRegenConfig(normalizeConfig(row.watermarkConfig, row.watermarkId));
|
setRegenConfig(normalizeConfig(row.watermarkConfig, row.watermarkId));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openGenerationConfig = (row: HomeMaterialAsset) => {
|
||||||
|
setEditingConfig(row);
|
||||||
|
setEditPrompt(row.generationPrompt || '');
|
||||||
|
setEditRefs(row.mediaReferences || []);
|
||||||
|
};
|
||||||
|
|
||||||
const getPreviewImageUrl = (row: HomeMaterialAsset) => {
|
const getPreviewImageUrl = (row: HomeMaterialAsset) => {
|
||||||
if (row.mediaType === 'image') return apiUrl(row.watermarkedUrl || row.originalUrl);
|
if (row.mediaType === 'image') return apiUrl(row.watermarkedUrl || row.originalUrl);
|
||||||
return apiUrl(row.coverUrl || '');
|
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 () => {
|
const submitRegenerate = async () => {
|
||||||
if (!regen || regenSubmitting) return;
|
if (!regen || regenSubmitting) return;
|
||||||
if (!isValidConfig(regenConfig)) {
|
if (!isValidConfig(regenConfig)) {
|
||||||
@@ -229,7 +305,7 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
dataSource={items}
|
dataSource={items}
|
||||||
pagination={{ current: filters.page, pageSize: filters.pageSize, total, onChange: (page, pageSize) => setFilters(f => ({ ...f, page, pageSize })) }}
|
pagination={{ current: filters.page, pageSize: filters.pageSize, total, onChange: (page, pageSize) => setFilters(f => ({ ...f, page, pageSize })) }}
|
||||||
scroll={{ x: 1180 }}
|
scroll={{ x: 1380 }}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: '预览',
|
title: '预览',
|
||||||
@@ -256,6 +332,18 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
|||||||
{ title: '行业', dataIndex: 'categoryName' },
|
{ title: '行业', dataIndex: 'categoryName' },
|
||||||
{ title: '类型', dataIndex: 'mediaType', render: (v: string) => v === 'image' ? '图片' : '视频' },
|
{ 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: '状态', 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: '水印',
|
title: '水印',
|
||||||
render: (_: unknown, row: HomeMaterialAsset) => {
|
render: (_: unknown, row: HomeMaterialAsset) => {
|
||||||
@@ -267,7 +355,7 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
|||||||
{ title: '错误', dataIndex: 'errorMessage', ellipsis: true },
|
{ title: '错误', dataIndex: 'errorMessage', ellipsis: true },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 150,
|
width: 170,
|
||||||
align: 'center' as const,
|
align: 'center' as const,
|
||||||
render: (_: unknown, row: HomeMaterialAsset) => (
|
render: (_: unknown, row: HomeMaterialAsset) => (
|
||||||
<Space size={8}>
|
<Space size={8}>
|
||||||
@@ -276,10 +364,12 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
|||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
menu={{
|
menu={{
|
||||||
items: [
|
items: [
|
||||||
|
{ key: 'generation_config', icon: <EditOutlined />, label: '编辑生成配置' },
|
||||||
{ key: 'regenerate', icon: <ReloadOutlined />, label: '重新生成水印' },
|
{ key: 'regenerate', icon: <ReloadOutlined />, label: '重新生成水印' },
|
||||||
{ key: 'delete', icon: <DeleteOutlined />, label: '删除素材', danger: true },
|
{ key: 'delete', icon: <DeleteOutlined />, label: '删除素材', danger: true },
|
||||||
],
|
],
|
||||||
onClick: ({ key }) => {
|
onClick: ({ key }) => {
|
||||||
|
if (key === 'generation_config') openGenerationConfig(row);
|
||||||
if (key === 'regenerate') openRegenerate(row);
|
if (key === 'regenerate') openRegenerate(row);
|
||||||
if (key === 'delete') confirmDelete(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 && (
|
{preview && (
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||||
<div><b>原素材:</b>{preview.originalUrl ? apiUrl(preview.originalUrl) : '-'}</div>
|
<div><b>原素材:</b>{preview.originalUrl ? apiUrl(preview.originalUrl) : '-'}</div>
|
||||||
<div><b>水印素材:</b>{preview.watermarkedUrl ? apiUrl(preview.watermarkedUrl) : '-'}</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' ? (
|
{preview.mediaType === 'image' ? (
|
||||||
<Image src={apiUrl(preview.watermarkedUrl || preview.originalUrl)} />
|
<Image src={apiUrl(preview.watermarkedUrl || preview.originalUrl)} />
|
||||||
) : (
|
) : (
|
||||||
@@ -305,6 +405,38 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
|||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</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
|
<Modal
|
||||||
title="重新生成水印"
|
title="重新生成水印"
|
||||||
open={!!regen}
|
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 { Form, Input, InputNumber, Modal, Select, Switch, Upload, Button, message } from 'antd';
|
||||||
import { UploadOutlined } from '@ant-design/icons';
|
import { UploadOutlined } from '@ant-design/icons';
|
||||||
import { uploadHomeMaterialAsset } from '../../api';
|
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 WatermarkEditor from './WatermarkEditor';
|
||||||
|
import MediaReferencesEditor from './MediaReferencesEditor';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -50,6 +51,7 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
|
|||||||
const [config, setConfig] = useState<HomeMaterialWatermarkConfig>(defaultConfig);
|
const [config, setConfig] = useState<HomeMaterialWatermarkConfig>(defaultConfig);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [mediaObjectUrl, setMediaObjectUrl] = useState<string>('');
|
const [mediaObjectUrl, setMediaObjectUrl] = useState<string>('');
|
||||||
|
const [mediaReferences, setMediaReferences] = useState<HomeMaterialMediaReference[]>([]);
|
||||||
|
|
||||||
const resetState = () => {
|
const resetState = () => {
|
||||||
setFile(null);
|
setFile(null);
|
||||||
@@ -57,6 +59,7 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
|
|||||||
setMediaType('image');
|
setMediaType('image');
|
||||||
setMediaObjectUrl('');
|
setMediaObjectUrl('');
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
setMediaReferences([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -64,7 +67,7 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
|
|||||||
resetState();
|
resetState();
|
||||||
const defaultWatermark = watermarks.find(w => w.isDefault) || watermarks[0];
|
const defaultWatermark = watermarks.find(w => w.isDefault) || watermarks[0];
|
||||||
setConfig({ ...defaultConfig, watermarkId: defaultWatermark?.id || null });
|
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]);
|
}, [open, watermarks, form]);
|
||||||
|
|
||||||
@@ -136,6 +139,8 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
|
|||||||
file,
|
file,
|
||||||
mediaType,
|
mediaType,
|
||||||
title: values.title?.trim() || null,
|
title: values.title?.trim() || null,
|
||||||
|
generationPrompt: values.generation_prompt?.trim() || null,
|
||||||
|
mediaReferences,
|
||||||
watermarkType: config.watermarkType,
|
watermarkType: config.watermarkType,
|
||||||
watermarkId: config.watermarkType === 'image' ? config.watermarkId : null,
|
watermarkId: config.watermarkType === 'image' ? config.watermarkId : null,
|
||||||
opacityLevel: config.opacityLevel,
|
opacityLevel: config.opacityLevel,
|
||||||
@@ -197,6 +202,12 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
|
|||||||
</Upload>
|
</Upload>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="title" label="素材标题" extra="选填;不填时后台显示“未命名素材”,不会再自动使用文件名作为标题。"><Input disabled={submitting} /></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="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.Item name="is_active" label="前台展示" valuePropName="checked"><Switch disabled={submitting} /></Form.Item>
|
||||||
</Form>
|
</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;
|
description?: string | null;
|
||||||
status: 'active' | 'disabled';
|
status: 'active' | 'disabled';
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
|
generation_prompt?: string | null;
|
||||||
|
media_references?: HomeMaterialMediaReference[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUser {
|
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 HomeMaterialMediaType = 'image' | 'video';
|
||||||
export type HomeMaterialAssetStatus = 'draft' | 'processing' | 'success' | 'failed';
|
export type HomeMaterialAssetStatus = 'draft' | 'processing' | 'success' | 'failed';
|
||||||
export type HomeMaterialWatermarkType = 'image' | 'repeated_text';
|
export type HomeMaterialWatermarkType = 'image' | 'repeated_text';
|
||||||
@@ -998,6 +1047,8 @@ export interface HomeMaterialAsset {
|
|||||||
watermarkId?: string | null;
|
watermarkId?: string | null;
|
||||||
watermarkName?: string | null;
|
watermarkName?: string | null;
|
||||||
watermarkConfig?: HomeMaterialWatermarkConfig | Record<string, unknown> | null;
|
watermarkConfig?: HomeMaterialWatermarkConfig | Record<string, unknown> | null;
|
||||||
|
generationPrompt?: string | null;
|
||||||
|
mediaReferences?: HomeMaterialMediaReference[] | null;
|
||||||
width?: number | null;
|
width?: number | null;
|
||||||
height?: number | null;
|
height?: number | null;
|
||||||
durationSeconds?: number | string | null;
|
durationSeconds?: number | string | null;
|
||||||
@@ -1016,6 +1067,8 @@ export interface HomeMaterialAssetUpdatePayload {
|
|||||||
title?: string | null;
|
title?: string | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
|
generation_prompt?: string | null;
|
||||||
|
media_references?: HomeMaterialMediaReference[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HomeMaterialAssetStatusOut {
|
export interface HomeMaterialAssetStatusOut {
|
||||||
@@ -1038,6 +1091,8 @@ export interface HomeMaterialUploadResult {
|
|||||||
watermarkedUrl?: string | null;
|
watermarkedUrl?: string | null;
|
||||||
coverUrl?: string | null;
|
coverUrl?: string | null;
|
||||||
watermarkConfig?: HomeMaterialWatermarkConfig | Record<string, unknown> | null;
|
watermarkConfig?: HomeMaterialWatermarkConfig | Record<string, unknown> | null;
|
||||||
|
generationPrompt?: string | null;
|
||||||
|
mediaReferences?: HomeMaterialMediaReference[] | null;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1074,6 +1129,8 @@ export interface HomeMaterialUploadAssetParams {
|
|||||||
file: File;
|
file: File;
|
||||||
mediaType: HomeMaterialMediaType;
|
mediaType: HomeMaterialMediaType;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
|
generationPrompt?: string | null;
|
||||||
|
mediaReferences?: HomeMaterialMediaReference[] | null;
|
||||||
watermarkType: HomeMaterialWatermarkType;
|
watermarkType: HomeMaterialWatermarkType;
|
||||||
watermarkId?: string | null;
|
watermarkId?: string | null;
|
||||||
watermarkFile?: File | 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.private_portrait import router as private_portrait_router
|
||||||
from app.api.admin.recharge_package import router as recharge_package_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.menu_config import router as menu_config_router
|
||||||
|
from app.api.admin.upload import router as admin_upload_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(video_prompt_schema_config_router)
|
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(private_portrait_router)
|
||||||
router.include_router(recharge_package_router)
|
router.include_router(recharge_package_router)
|
||||||
router.include_router(menu_config_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="图片或视频素材文件。"),
|
file: UploadFile = File(..., description="图片或视频素材文件。"),
|
||||||
media_type: HomeMaterialMediaType = Form(..., description="素材类型:image图片,video视频。"),
|
media_type: HomeMaterialMediaType = Form(..., description="素材类型:image图片,video视频。"),
|
||||||
title: str | None = Form(None, description="素材标题。为空时不再自动回填文件名。"),
|
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_type: HomeMaterialWatermarkType = Form(HomeMaterialWatermarkType.IMAGE, description="水印类型:image 图片水印;repeated_text 重复文字水印。"),
|
||||||
watermark_id: str | None = Form(None, description="图片水印ID,可选。watermark_type=image 时使用。"),
|
watermark_id: str | None = Form(None, description="图片水印ID,可选。watermark_type=image 时使用。"),
|
||||||
watermark_file: UploadFile | None = File(None, description="临时图片水印,可选。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,
|
file=file,
|
||||||
media_type=media_type,
|
media_type=media_type,
|
||||||
title=title,
|
title=title,
|
||||||
|
generation_prompt=generation_prompt,
|
||||||
|
media_references_json=media_references_json,
|
||||||
watermark_id=watermark_id,
|
watermark_id=watermark_id,
|
||||||
watermark_file=watermark_file,
|
watermark_file=watermark_file,
|
||||||
watermark_config=config,
|
watermark_config=config,
|
||||||
@@ -452,7 +456,7 @@ async def get_home_material_asset_status(
|
|||||||
"/assets/{asset_id}",
|
"/assets/{asset_id}",
|
||||||
response_model=HomeMaterialAssetOut,
|
response_model=HomeMaterialAssetOut,
|
||||||
summary="修改首页素材展示信息",
|
summary="修改首页素材展示信息",
|
||||||
description="只修改行业、标题、启用状态、排序,不重新生成水印。",
|
description="修改行业、标题、启用状态、排序、生成提词和附件JSON,不重新生成水印。",
|
||||||
)
|
)
|
||||||
async def update_home_material_asset(
|
async def update_home_material_asset(
|
||||||
req: HomeMaterialAssetUpdate,
|
req: HomeMaterialAssetUpdate,
|
||||||
@@ -465,7 +469,7 @@ async def update_home_material_asset(
|
|||||||
db,
|
db,
|
||||||
admin.id,
|
admin.id,
|
||||||
admin.username,
|
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",
|
"PUT",
|
||||||
f"/admin/home-material/assets/{asset_id}",
|
f"/admin/home-material/assets/{asset_id}",
|
||||||
detail=_detail(before=before, after=after),
|
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"
|
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):
|
class HomeMaterialOperationEnum(StrEnum):
|
||||||
"""后台操作日志 action 枚举。"""
|
"""后台操作日志 action 枚举。"""
|
||||||
|
|
||||||
@@ -75,6 +86,7 @@ class HomeMaterialOperationEnum(StrEnum):
|
|||||||
WATERMARK_DELETE = "删除首页素材水印"
|
WATERMARK_DELETE = "删除首页素材水印"
|
||||||
ASSET_UPLOAD = "上传首页素材"
|
ASSET_UPLOAD = "上传首页素材"
|
||||||
ASSET_UPDATE = "修改首页素材"
|
ASSET_UPDATE = "修改首页素材"
|
||||||
|
ASSET_GENERATION_CONFIG_UPDATE = "修改首页素材生成配置"
|
||||||
ASSET_DELETE = "删除首页素材"
|
ASSET_DELETE = "删除首页素材"
|
||||||
ASSET_REGENERATE_WATERMARK = "重新生成首页素材水印"
|
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_VIDEO_EXTENSIONS = {".mp4", ".mov", ".m4v", ".webm"}
|
||||||
HOME_MATERIAL_WATERMARK_EXTENSIONS = {".png", ".webp", ".jpg", ".jpeg"}
|
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 = {
|
HOME_MATERIAL_DEFAULT_CONFIG = {
|
||||||
"enabled": False,
|
"enabled": False,
|
||||||
"title": "行业素材案例",
|
"title": "行业素材案例",
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ class UploadResourceModuleEnum(StrEnum):
|
|||||||
"""上传资源所属业务模块。"""
|
"""上传资源所属业务模块。"""
|
||||||
|
|
||||||
COMMON = "common"
|
COMMON = "common"
|
||||||
|
ADMIN_UPLOAD = "admin_upload"
|
||||||
|
HOME_MATERIAL = "home_material"
|
||||||
HOT_OPENING_REPLICATE = "hot_opening_replicate"
|
HOT_OPENING_REPLICATE = "hot_opening_replicate"
|
||||||
SHOT_REPLICATE = "shot_replicate"
|
SHOT_REPLICATE = "shot_replicate"
|
||||||
|
|
||||||
@@ -18,6 +20,8 @@ class UploadResourceTypeEnum(StrEnum):
|
|||||||
VIDEO = "video"
|
VIDEO = "video"
|
||||||
AUDIO = "audio"
|
AUDIO = "audio"
|
||||||
SHOT_SEGMENT = "shot_segment"
|
SHOT_SEGMENT = "shot_segment"
|
||||||
|
PDF = "pdf"
|
||||||
|
FILE = "file"
|
||||||
|
|
||||||
|
|
||||||
class UploadResourceBindStatusEnum(StrEnum):
|
class UploadResourceBindStatusEnum(StrEnum):
|
||||||
@@ -76,6 +80,9 @@ class UploadResourceSourceModelEnum(StrEnum):
|
|||||||
MODULE_GENERATION_PROJECT = "ModuleGenerationProject"
|
MODULE_GENERATION_PROJECT = "ModuleGenerationProject"
|
||||||
SHOT_REPLICATE_TASK_SET = "ShotReplicateTaskSet"
|
SHOT_REPLICATE_TASK_SET = "ShotReplicateTaskSet"
|
||||||
SHOT_REPLICATE_SEGMENT = "ShotReplicateSegment"
|
SHOT_REPLICATE_SEGMENT = "ShotReplicateSegment"
|
||||||
|
HOME_MATERIAL_ASSET = "HomeMaterialAsset"
|
||||||
|
SYSTEM_CONFIG = "SystemConfig"
|
||||||
|
OPEN_TYPE = "OpenType"
|
||||||
|
|
||||||
|
|
||||||
class UploadResourceEventEnum(StrEnum):
|
class UploadResourceEventEnum(StrEnum):
|
||||||
@@ -155,6 +162,8 @@ class UploadResourceEventEnum(StrEnum):
|
|||||||
|
|
||||||
UPLOAD_RESOURCE_MODULE_LABELS: dict[str, str] = {
|
UPLOAD_RESOURCE_MODULE_LABELS: dict[str, str] = {
|
||||||
UploadResourceModuleEnum.COMMON.value: "普通上传",
|
UploadResourceModuleEnum.COMMON.value: "普通上传",
|
||||||
|
UploadResourceModuleEnum.ADMIN_UPLOAD.value: "管理后台上传",
|
||||||
|
UploadResourceModuleEnum.HOME_MATERIAL.value: "首页素材",
|
||||||
UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value: "爆款开头复刻",
|
UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value: "爆款开头复刻",
|
||||||
UploadResourceModuleEnum.SHOT_REPLICATE.value: "拆镜复刻",
|
UploadResourceModuleEnum.SHOT_REPLICATE.value: "拆镜复刻",
|
||||||
}
|
}
|
||||||
@@ -164,4 +173,6 @@ UPLOAD_RESOURCE_TYPE_LABELS: dict[str, str] = {
|
|||||||
UploadResourceTypeEnum.VIDEO.value: "视频",
|
UploadResourceTypeEnum.VIDEO.value: "视频",
|
||||||
UploadResourceTypeEnum.AUDIO.value: "音频",
|
UploadResourceTypeEnum.AUDIO.value: "音频",
|
||||||
UploadResourceTypeEnum.SHOT_SEGMENT.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="视频封面本地路径")
|
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_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")
|
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(
|
status: Mapped[str] = mapped_column(
|
||||||
String(32),
|
String(32),
|
||||||
default=HomeMaterialAssetStatus.DRAFT.value,
|
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 decimal import Decimal
|
||||||
from typing import Any
|
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 (
|
from app.enums.home_material import (
|
||||||
|
HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN,
|
||||||
|
HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT,
|
||||||
HomeMaterialAssetStatus,
|
HomeMaterialAssetStatus,
|
||||||
HomeMaterialMediaType,
|
HomeMaterialMediaType,
|
||||||
HomeMaterialPublicResponseMode,
|
HomeMaterialPublicResponseMode,
|
||||||
@@ -92,6 +94,40 @@ class HomeMaterialWatermarkListOut(BaseModel):
|
|||||||
total: int = Field(0, description="符合条件的水印总数。")
|
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):
|
class HomeMaterialTextWatermarkConfig(BaseModel):
|
||||||
"""重复文字水印配置。字体固定由后端 HOME_MATERIAL_TEXT_WATERMARK_FONT 指定,不允许前端传字体,避免版权和一致性问题。"""
|
"""重复文字水印配置。字体固定由后端 HOME_MATERIAL_TEXT_WATERMARK_FONT 指定,不允许前端传字体,避免版权和一致性问题。"""
|
||||||
|
|
||||||
@@ -180,6 +216,8 @@ class HomeMaterialAssetOut(BaseModel):
|
|||||||
watermark_id: str | None = Field(None, description="图片水印ID。重复文字水印素材为空。")
|
watermark_id: str | None = Field(None, description="图片水印ID。重复文字水印素材为空。")
|
||||||
watermark_name: str | None = Field(None, description="图片水印名称,列表接口批量查询回填。重复文字水印显示为空。")
|
watermark_name: str | None = Field(None, description="图片水印名称,列表接口批量查询回填。重复文字水印显示为空。")
|
||||||
watermark_config: HomeMaterialWatermarkConfig | dict[str, Any] | 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="素材宽度。")
|
width: int | None = Field(None, description="素材宽度。")
|
||||||
height: int | None = Field(None, description="素材高度。")
|
height: int | None = Field(None, description="素材高度。")
|
||||||
duration_seconds: Decimal | float | 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="素材标题。")
|
title: str | None = Field(None, max_length=128, description="素材标题。")
|
||||||
is_active: bool = Field(True, description="是否前台展示。")
|
is_active: bool = Field(True, description="是否前台展示。")
|
||||||
sort_order: int = Field(0, ge=0, le=999999, 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
|
@classmethod
|
||||||
def clean_title(cls, value: str | None) -> str | None:
|
def clean_text(cls, value: str | None) -> str | None:
|
||||||
value = (value or "").strip()
|
value = (value or "").strip()
|
||||||
return value or None
|
return value or None
|
||||||
|
|
||||||
@@ -256,6 +296,8 @@ class HomeMaterialPublicAssetOut(BaseModel):
|
|||||||
width: int | None = Field(None, description="宽度。")
|
width: int | None = Field(None, description="宽度。")
|
||||||
height: int | None = Field(None, description="高度。")
|
height: int | None = Field(None, description="高度。")
|
||||||
duration_seconds: Decimal | float | 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="排序。")
|
sort_order: int = Field(0, description="排序。")
|
||||||
|
|
||||||
|
|
||||||
@@ -303,4 +345,6 @@ class HomeMaterialUploadResultOut(BaseModel):
|
|||||||
watermarked_url: str | None = Field(None, description="水印素材URL。")
|
watermarked_url: str | None = Field(None, description="水印素材URL。")
|
||||||
cover_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="水印配置快照。")
|
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="提示消息。")
|
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.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
|
||||||
from app.schemas.home_material import (
|
from app.schemas.home_material import (
|
||||||
HomeMaterialAssetOut,
|
HomeMaterialAssetOut,
|
||||||
|
HomeMaterialMediaReference,
|
||||||
HomeMaterialCategoryOut,
|
HomeMaterialCategoryOut,
|
||||||
HomeMaterialPublicAssetOut,
|
HomeMaterialPublicAssetOut,
|
||||||
HomeMaterialPublicCategoryGroupOut,
|
HomeMaterialPublicCategoryGroupOut,
|
||||||
@@ -39,6 +40,24 @@ def _load_json(value: str | None) -> dict[str, Any] | None:
|
|||||||
return 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:
|
class HomeMaterialQueryService:
|
||||||
"""首页素材高性能查询组装层:列表查询 → ID 去重 → 批量查询 → map 组装。"""
|
"""首页素材高性能查询组装层:列表查询 → ID 去重 → 批量查询 → map 组装。"""
|
||||||
|
|
||||||
@@ -152,6 +171,8 @@ class HomeMaterialQueryService:
|
|||||||
watermark_id=asset.watermark_id,
|
watermark_id=asset.watermark_id,
|
||||||
watermark_name=watermark.name if watermark else None,
|
watermark_name=watermark.name if watermark else None,
|
||||||
watermark_config=_load_json(asset.watermark_config_json),
|
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,
|
width=asset.width,
|
||||||
height=asset.height,
|
height=asset.height,
|
||||||
duration_seconds=asset.duration_seconds,
|
duration_seconds=asset.duration_seconds,
|
||||||
@@ -340,6 +361,8 @@ class HomeMaterialQueryService:
|
|||||||
width=a.width,
|
width=a.width,
|
||||||
height=a.height,
|
height=a.height,
|
||||||
duration_seconds=a.duration_seconds,
|
duration_seconds=a.duration_seconds,
|
||||||
|
generation_prompt=a.generation_prompt,
|
||||||
|
media_references=_load_media_references(a.media_references_json),
|
||||||
sort_order=a.sort_order,
|
sort_order=a.sort_order,
|
||||||
)
|
)
|
||||||
for a in assets
|
for a in assets
|
||||||
@@ -403,6 +426,8 @@ class HomeMaterialQueryService:
|
|||||||
width=asset.width,
|
width=asset.width,
|
||||||
height=asset.height,
|
height=asset.height,
|
||||||
duration_seconds=asset.duration_seconds,
|
duration_seconds=asset.duration_seconds,
|
||||||
|
generation_prompt=asset.generation_prompt,
|
||||||
|
media_references=_load_media_references(asset.media_references_json),
|
||||||
sort_order=asset.sort_order,
|
sort_order=asset.sort_order,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,14 +12,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.enums.home_material import (
|
from app.enums.home_material import (
|
||||||
HOME_MATERIAL_DEFAULT_CONFIG,
|
HOME_MATERIAL_DEFAULT_CONFIG,
|
||||||
|
HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN,
|
||||||
|
HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN,
|
||||||
|
HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT,
|
||||||
HomeMaterialAssetStatus,
|
HomeMaterialAssetStatus,
|
||||||
HomeMaterialConfigKeyEnum,
|
HomeMaterialConfigKeyEnum,
|
||||||
|
HomeMaterialLogEventEnum,
|
||||||
HomeMaterialMediaType,
|
HomeMaterialMediaType,
|
||||||
HomeMaterialPublicResponseMode,
|
HomeMaterialPublicResponseMode,
|
||||||
HomeMaterialWatermarkPosition,
|
HomeMaterialWatermarkPosition,
|
||||||
HomeMaterialWatermarkSizeMode,
|
HomeMaterialWatermarkSizeMode,
|
||||||
HomeMaterialWatermarkType,
|
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.base import async_session
|
||||||
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
|
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
|
||||||
from app.models.system_config import SystemConfig
|
from app.models.system_config import SystemConfig
|
||||||
@@ -36,6 +42,7 @@ from app.schemas.home_material import (
|
|||||||
HomeMaterialConfigUpdate,
|
HomeMaterialConfigUpdate,
|
||||||
HomeMaterialPublicCategoryListOut,
|
HomeMaterialPublicCategoryListOut,
|
||||||
HomeMaterialPublicFlatOut,
|
HomeMaterialPublicFlatOut,
|
||||||
|
HomeMaterialMediaReference,
|
||||||
HomeMaterialPublicGroupedOut,
|
HomeMaterialPublicGroupedOut,
|
||||||
HomeMaterialRegenerateWatermarkRequest,
|
HomeMaterialRegenerateWatermarkRequest,
|
||||||
HomeMaterialTextWatermarkPreviewRequest,
|
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.query import query_service
|
||||||
from app.services.home_material.storage import storage_service
|
from app.services.home_material.storage import storage_service
|
||||||
from app.services.home_material.watermark_processor import watermark_processor
|
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
|
from app.utils.id_gen import generate_id
|
||||||
|
|
||||||
|
|
||||||
@@ -66,11 +75,99 @@ def _json_loads(value: str | None) -> dict[str, Any] | None:
|
|||||||
return 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:
|
def _clean_title(value: str | None) -> str | None:
|
||||||
value = (value or "").strip()
|
value = (value or "").strip()
|
||||||
return value or None
|
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]:
|
def _normalize_watermark_config_dict(value: dict[str, Any] | None, fallback_watermark_id: str | None = None) -> dict[str, Any]:
|
||||||
"""兼容旧水印配置。旧数据没有 watermark_type 时按 image 处理。"""
|
"""兼容旧水印配置。旧数据没有 watermark_type 时按 image 处理。"""
|
||||||
data = dict(value or {})
|
data = dict(value or {})
|
||||||
@@ -108,6 +205,8 @@ def _asset_snapshot(asset: HomeMaterialAsset | None) -> dict[str, Any] | None:
|
|||||||
"cover_url": asset.cover_url,
|
"cover_url": asset.cover_url,
|
||||||
"watermark_id": asset.watermark_id,
|
"watermark_id": asset.watermark_id,
|
||||||
"watermark_config": _json_loads(asset.watermark_config_json),
|
"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,
|
"is_active": asset.is_active,
|
||||||
"sort_order": asset.sort_order,
|
"sort_order": asset.sort_order,
|
||||||
"deleted_at": asset.deleted_at,
|
"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:
|
async def _flush_refresh(db: AsyncSession, obj: Any) -> None:
|
||||||
"""
|
"""
|
||||||
写入后立即返回 ORM 对象前必须显式刷新。
|
写入后立即返回 ORM 对象前必须显式刷新。
|
||||||
@@ -372,10 +496,24 @@ class HomeMaterialService:
|
|||||||
watermark_config: HomeMaterialWatermarkConfig,
|
watermark_config: HomeMaterialWatermarkConfig,
|
||||||
is_active: bool,
|
is_active: bool,
|
||||||
sort_order: int,
|
sort_order: int,
|
||||||
|
generation_prompt: str | None = None,
|
||||||
|
media_references_json: str | None = None,
|
||||||
admin_id: str | None,
|
admin_id: str | None,
|
||||||
) -> tuple[HomeMaterialUploadResultOut, dict[str, Any]]:
|
) -> tuple[HomeMaterialUploadResultOut, dict[str, Any]]:
|
||||||
await self._get_category(db, category_id, active_only=False)
|
await self._get_category(db, category_id, active_only=False)
|
||||||
clean_title = _clean_title(title)
|
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
|
watermark_type = watermark_config.watermark_type
|
||||||
final_watermark_id: str | None = None
|
final_watermark_id: str | None = None
|
||||||
if watermark_type == HomeMaterialWatermarkType.IMAGE:
|
if watermark_type == HomeMaterialWatermarkType.IMAGE:
|
||||||
@@ -415,6 +553,8 @@ class HomeMaterialService:
|
|||||||
original_storage_path=stored.storage_path,
|
original_storage_path=stored.storage_path,
|
||||||
watermark_id=final_watermark_id,
|
watermark_id=final_watermark_id,
|
||||||
watermark_config_json=_json_dumps(cfg.model_dump(mode="json")),
|
watermark_config_json=_json_dumps(cfg.model_dump(mode="json")),
|
||||||
|
generation_prompt=clean_prompt,
|
||||||
|
media_references_json=refs_json,
|
||||||
status=HomeMaterialAssetStatus.PROCESSING.value,
|
status=HomeMaterialAssetStatus.PROCESSING.value,
|
||||||
width=probe.width,
|
width=probe.width,
|
||||||
height=probe.height,
|
height=probe.height,
|
||||||
@@ -427,7 +567,10 @@ class HomeMaterialService:
|
|||||||
)
|
)
|
||||||
db.add(asset)
|
db.add(asset)
|
||||||
await _flush_refresh(db, 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(
|
async def list_assets(
|
||||||
self,
|
self,
|
||||||
@@ -466,15 +609,67 @@ class HomeMaterialService:
|
|||||||
asset = await self._get_asset(db, asset_id)
|
asset = await self._get_asset(db, asset_id)
|
||||||
await self._get_category(db, req.category_id)
|
await self._get_category(db, req.category_id)
|
||||||
before = _asset_snapshot(asset) or {}
|
before = _asset_snapshot(asset) or {}
|
||||||
asset.category_id = req.category_id
|
try:
|
||||||
asset.title = req.title
|
_log_generation_config_event(
|
||||||
asset.is_active = req.is_active
|
HomeMaterialLogEventEnum.GENERATION_CONFIG_VALIDATE_STARTED.value,
|
||||||
asset.sort_order = req.sort_order
|
admin_id=admin_id,
|
||||||
asset.updated_by = admin_id
|
asset_id=asset_id,
|
||||||
db.add(asset)
|
event_status=LogEventStatusEnum.STARTED.value,
|
||||||
await _flush_refresh(db, asset)
|
)
|
||||||
after = _asset_snapshot(asset) or {}
|
clean_prompt = _clean_generation_prompt(req.generation_prompt)
|
||||||
return await self.get_asset_detail(db, asset_id), before, after
|
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(
|
async def prepare_regenerate(
|
||||||
self,
|
self,
|
||||||
@@ -509,6 +704,28 @@ class HomeMaterialService:
|
|||||||
db.add(asset)
|
db.add(asset)
|
||||||
return asset, before
|
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:
|
async def get_asset_status(self, db: AsyncSession, asset_id: str) -> HomeMaterialAssetStatusOut:
|
||||||
asset = await self._get_asset(db, asset_id)
|
asset = await self._get_asset(db, asset_id)
|
||||||
return HomeMaterialAssetStatusOut(
|
return HomeMaterialAssetStatusOut(
|
||||||
@@ -541,6 +758,8 @@ class HomeMaterialService:
|
|||||||
watermarked_url=asset.watermarked_url,
|
watermarked_url=asset.watermarked_url,
|
||||||
cover_url=asset.cover_url,
|
cover_url=asset.cover_url,
|
||||||
watermark_config=_json_loads(asset.watermark_config_json),
|
watermark_config=_json_loads(asset.watermark_config_json),
|
||||||
|
generation_prompt=asset.generation_prompt,
|
||||||
|
media_references=_media_references_out(asset.media_references_json),
|
||||||
message=message,
|
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>[^/]+)$")
|
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$")
|
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>[^/]+)$")
|
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"}
|
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".svg"}
|
||||||
VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv"}
|
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:
|
except ValueError:
|
||||||
return None
|
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/",)
|
ignored_prefixes = ("home_materials/",)
|
||||||
if rel in {"site_logo.png"} or rel.startswith(ignored_prefixes) or rel.startswith("pdf_"):
|
if rel in {"site_logo.png"} or rel.startswith(ignored_prefixes) or rel.startswith("pdf_"):
|
||||||
return ParsedUploadPath(
|
return ParsedUploadPath(
|
||||||
|
|||||||
Reference in New Issue
Block a user