This commit is contained in:
sjy
2026-07-07 14:21:42 +08:00
59 changed files with 3619 additions and 979 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-BJj5fRkV.js"></script> <script type="module" crossorigin src="/assets/index-W_zV5GBi.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css"> <link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head> </head>
<body> <body>
+7 -4
View File
@@ -448,7 +448,7 @@ export async function deleteMenuConfig(id: string): Promise<void> {
// ── User Creation ─────────────────────────────────────── // ── User Creation ───────────────────────────────────────
export async function createUser(data: { username?: string; password: string; email?: string; phone?: string; credits: number; user_type: string; frontend_user_kind?: string; allowed_menus?: string[] | null; private_portrait_image_limit?: number }): Promise<any> { export async function createUser(data: { username?: string; password: string; email?: string; phone?: string; credits: number; user_type: string; frontend_user_kind?: string; allowed_menus?: string[] | null; private_portrait_asset_limit?: number }): Promise<any> {
return api.post('/admin/users', data); return api.post('/admin/users', data);
} }
@@ -1017,22 +1017,25 @@ export async function getPreTestTemplateList(params: PreTestTemplateListParams):
} }
// ── Private Portrait Admin ──────────────────────────────── // ── Private Portrait Admin ────────────────────────────────
export async function adminGetPrivatePortraitProjects(params: { userId?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitProjectListOut> { export async function adminGetPrivatePortraitProjects(params: { userId?: string; libraryType?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitProjectListOut> {
const query = new URLSearchParams(); const query = new URLSearchParams();
query.set('page', String(params.page || 1)); query.set('page', String(params.page || 1));
query.set('page_size', String(params.pageSize || 20)); query.set('page_size', String(params.pageSize || 20));
if (params.userId) query.set('user_id', params.userId); if (params.userId) query.set('user_id', params.userId);
if (params.libraryType) query.set('library_type', params.libraryType);
if (params.keyword) query.set('keyword', params.keyword); if (params.keyword) query.set('keyword', params.keyword);
if (params.status) query.set('status', params.status); if (params.status) query.set('status', params.status);
return api.get<PrivatePortraitProjectListOut>(`/admin/private-portrait/projects?${query.toString()}`); return api.get<PrivatePortraitProjectListOut>(`/admin/private-portrait/projects?${query.toString()}`);
} }
export async function adminGetPrivatePortraitAssets(params: { userId?: string; projectId?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitAssetListOut> { export async function adminGetPrivatePortraitAssets(params: { userId?: string; projectId?: string; libraryType?: string; assetType?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitAssetListOut> {
const query = new URLSearchParams(); const query = new URLSearchParams();
query.set('page', String(params.page || 1)); query.set('page', String(params.page || 1));
query.set('page_size', String(params.pageSize || 20)); query.set('page_size', String(params.pageSize || 20));
if (params.userId) query.set('user_id', params.userId); if (params.userId) query.set('user_id', params.userId);
if (params.projectId) query.set('project_id', params.projectId); if (params.projectId) query.set('project_id', params.projectId);
if (params.libraryType) query.set('library_type', params.libraryType);
if (params.assetType) query.set('asset_type', params.assetType);
if (params.keyword) query.set('keyword', params.keyword); if (params.keyword) query.set('keyword', params.keyword);
if (params.status) query.set('status', params.status); if (params.status) query.set('status', params.status);
return api.get<PrivatePortraitAssetListOut>(`/admin/private-portrait/assets?${query.toString()}`); return api.get<PrivatePortraitAssetListOut>(`/admin/private-portrait/assets?${query.toString()}`);
@@ -1044,5 +1047,5 @@ export async function adminGetPrivatePortraitConfig(userId: string): Promise<Pri
} }
export async function adminUpdatePrivatePortraitConfig(userId: string, limit: number): Promise<PrivatePortraitConfig> { export async function adminUpdatePrivatePortraitConfig(userId: string, limit: number): Promise<PrivatePortraitConfig> {
return api.put<PrivatePortraitConfig>(`/admin/private-portrait/users/${userId}/config`, { private_portrait_image_limit: limit }); return api.put<PrivatePortraitConfig>(`/admin/private-portrait/users/${userId}/config`, { private_portrait_asset_limit: limit });
} }
@@ -1,20 +1,110 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Button, Card, Input, Space, Table, Tag, Typography, message } from 'antd'; import { Button, Card, Image, Input, Select, Space, Statistic, Table, Tag, Tooltip, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'; import { CopyOutlined, PlayCircleOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { adminGetPrivatePortraitAssets, adminGetPrivatePortraitProjects } from '../api'; import { adminGetPrivatePortraitAssets, adminGetPrivatePortraitProjects } from '../api';
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../types'; import type { PrivatePortraitAsset, PrivatePortraitProject } from '../types';
import { formatDate } from '../utils/formatDate';
import { apiUrl } from '../utils/resourceUrl';
const { Title, Text } = Typography; const { Title, Text } = Typography;
const PAGE_SIZE = 20;
const LIBRARY_OPTIONS = [
{ label: '全部素材库', value: '' },
{ label: '真人认证', value: 'real_person' },
{ label: '虚拟人像', value: 'aigc_virtual' },
];
const ASSET_TYPE_OPTIONS = [
{ label: '全部类型', value: '' },
{ label: '图片', value: 'Image' },
{ label: '视频', value: 'Video' },
];
const PROJECT_STATUS_OPTIONS = [
{ label: '全部项目状态', value: '' },
{ label: '可用', value: 'active' },
{ label: '认证中', value: 'validating' },
{ label: '认证失败', value: 'validate_failed' },
{ label: '创建远端组中', value: 'creating_remote_group' },
{ label: '创建远端组失败', value: 'create_group_failed' },
{ label: '已删除', value: 'deleted' },
];
const ASSET_STATUS_OPTIONS = [
{ label: '全部素材状态', value: '' },
{ label: '创建中', value: 'creating' },
{ label: '处理中', value: 'Processing' },
{ label: '可用', value: 'Active' },
{ label: '失败', value: 'Failed' },
{ label: '本地已删', value: 'local_deleted' },
{ label: '远端已删', value: 'remote_deleted' },
{ label: '删除失败', value: 'delete_failed' },
];
const libraryLabel = (value?: string | null) => {
if (value === 'real_person') return '真人认证';
if (value === 'aigc_virtual') return '虚拟人像';
return value || '-';
};
const libraryColor = (value?: string | null) => {
if (value === 'real_person') return 'purple';
if (value === 'aigc_virtual') return 'cyan';
return 'default';
};
const statusColor = (status?: string) => { const statusColor = (status?: string) => {
if (status === 'Active' || status === 'active') return 'green'; if (status === 'Active' || status === 'active') return 'green';
if (status === 'Processing') return 'blue'; if (status === 'Processing' || status === 'creating_remote_group' || status === 'validating' || status === 'creating') return 'blue';
if (status === 'Failed' || status === 'failed' || status === 'delete_failed') return 'red'; if (status === 'Failed' || status === 'failed' || status === 'delete_failed' || status === 'validate_failed' || status === 'create_group_failed') return 'red';
if (status?.includes('deleted')) return 'default'; if (status?.includes('deleted')) return 'default';
return 'default'; return 'default';
}; };
const assetTypeLabel = (value?: string | null) => {
if (value === 'Image') return '图片';
if (value === 'Video') return '视频';
return value || '-';
};
const shortId = (value?: string | null, keep = 10) => {
if (!value) return '-';
if (value.length <= keep * 2 + 3) return value;
return `${value.slice(0, keep)}...${value.slice(-keep)}`;
};
const copyText = async (value?: string | null) => {
if (!value) return;
await navigator.clipboard?.writeText(value);
message.success('已复制');
};
const toPreviewUrl = (value?: string | null) => {
if (!value) return '';
const trimmed = String(value).trim();
if (!trimmed || trimmed.startsWith('asset://')) return '';
return apiUrl(trimmed);
};
const PreviewCell: React.FC<{ asset: PrivatePortraitAsset }> = ({ asset }) => {
const url = toPreviewUrl(asset.displayUrl || asset.previewUrl || asset.remoteUrl || asset.sourceUrl);
const cover = toPreviewUrl(asset.videoCoverUrl || asset.previewUrl || asset.displayUrl || asset.sourceUrl);
if (asset.assetType === 'Video') {
return (
<div style={{ width: 72, height: 52, borderRadius: 10, overflow: 'hidden', background: '#f5f3ff', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
{cover ? <img src={cover} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : <PlayCircleOutlined style={{ fontSize: 24, color: '#8b5cf6' }} />}
{url ? <a href={url} target="_blank" rel="noreferrer" style={{ position: 'absolute', inset: 0 }} /> : null}
<PlayCircleOutlined style={{ position: 'absolute', color: '#fff', fontSize: 22, filter: 'drop-shadow(0 1px 3px rgba(0,0,0,.45))' }} />
</div>
);
}
if (url) return <Image width={72} height={52} src={url} style={{ objectFit: 'cover', borderRadius: 10 }} />;
return <div style={{ width: 72, height: 52, borderRadius: 10, background: '#f5f5f5' }} />;
};
const AdminPrivatePortraitProjects: React.FC = () => { const AdminPrivatePortraitProjects: React.FC = () => {
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]); const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]); const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
@@ -23,18 +113,36 @@ const AdminPrivatePortraitProjects: React.FC = () => {
const [loadingProjects, setLoadingProjects] = useState(false); const [loadingProjects, setLoadingProjects] = useState(false);
const [loadingAssets, setLoadingAssets] = useState(false); const [loadingAssets, setLoadingAssets] = useState(false);
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [libraryType, setLibraryType] = useState('');
const [assetType, setAssetType] = useState('');
const [projectStatus, setProjectStatus] = useState('');
const [assetStatus, setAssetStatus] = useState('');
const [selectedProjectId, setSelectedProjectId] = useState<string | undefined>(); const [selectedProjectId, setSelectedProjectId] = useState<string | undefined>();
const [projectPage, setProjectPage] = useState(1); const [projectPage, setProjectPage] = useState(1);
const [assetPage, setAssetPage] = useState(1); const [assetPage, setAssetPage] = useState(1);
const filters = useMemo(() => ({
keyword: keyword.trim() || undefined,
libraryType: libraryType || undefined,
assetType: assetType || undefined,
projectStatus: projectStatus || undefined,
assetStatus: assetStatus || undefined,
}), [keyword, libraryType, assetType, projectStatus, assetStatus]);
const loadProjects = async () => { const loadProjects = async () => {
setLoadingProjects(true); setLoadingProjects(true);
try { try {
const res = await adminGetPrivatePortraitProjects({ keyword: keyword.trim() || undefined, page: projectPage, pageSize: 20 }); const res = await adminGetPrivatePortraitProjects({
keyword: filters.keyword,
libraryType: filters.libraryType,
status: filters.projectStatus,
page: projectPage,
pageSize: PAGE_SIZE,
});
setProjects(res.items || []); setProjects(res.items || []);
setProjectTotal(res.total || 0); setProjectTotal(res.total || 0);
} catch (err: any) { } catch (err: any) {
message.error(err?.message || '加载真人素材项目失败'); message.error(err?.message || '加载私域人像素材项目失败');
} finally { } finally {
setLoadingProjects(false); setLoadingProjects(false);
} }
@@ -43,88 +151,111 @@ const AdminPrivatePortraitProjects: React.FC = () => {
const loadAssets = async () => { const loadAssets = async () => {
setLoadingAssets(true); setLoadingAssets(true);
try { try {
const res = await adminGetPrivatePortraitAssets({ projectId: selectedProjectId, keyword: keyword.trim() || undefined, page: assetPage, pageSize: 20 }); const res = await adminGetPrivatePortraitAssets({
projectId: selectedProjectId,
keyword: filters.keyword,
libraryType: filters.libraryType,
assetType: filters.assetType,
status: filters.assetStatus,
page: assetPage,
pageSize: PAGE_SIZE,
});
setAssets(res.items || []); setAssets(res.items || []);
setAssetTotal(res.total || 0); setAssetTotal(res.total || 0);
} catch (err: any) { } catch (err: any) {
message.error(err?.message || '加载真人素材失败'); message.error(err?.message || '加载私域人像素材失败');
} finally { } finally {
setLoadingAssets(false); setLoadingAssets(false);
} }
}; };
useEffect(() => { loadProjects(); }, [projectPage]); useEffect(() => { loadProjects(); }, [projectPage, filters.libraryType, filters.projectStatus]);
useEffect(() => { loadAssets(); }, [selectedProjectId, assetPage]); useEffect(() => { loadAssets(); }, [selectedProjectId, assetPage, filters.libraryType, filters.assetType, filters.assetStatus]);
const searchAll = () => {
setProjectPage(1);
setAssetPage(1);
setTimeout(() => { loadProjects(); loadAssets(); }, 0);
};
const resetFilters = () => {
setKeyword('');
setLibraryType('');
setAssetType('');
setProjectStatus('');
setAssetStatus('');
setSelectedProjectId(undefined);
setProjectPage(1);
setAssetPage(1);
};
const projectSummary = useMemo(() => {
return projects.reduce((acc, item) => {
acc.asset += item.assetCount || 0;
acc.image += item.imageAssetCount || 0;
acc.video += item.videoAssetCount || 0;
acc.active += item.activeAssetCount || 0;
return acc;
}, { asset: 0, image: 0, video: 0, active: 0 });
}, [projects]);
const projectColumns: ColumnsType<PrivatePortraitProject> = [ const projectColumns: ColumnsType<PrivatePortraitProject> = [
{ title: '项目名称', dataIndex: 'name', width: 180, render: (v, r) => <Button type="link" onClick={() => { setSelectedProjectId(r.id); setAssetPage(1); }}>{v}</Button> }, { title: '项目名称', dataIndex: 'name', width: 200, fixed: 'left', render: (v, r) => <Button type="link" onClick={() => { setSelectedProjectId(r.id); setAssetPage(1); }}>{v}</Button> },
{ title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code>{v || '-'}</Text> }, { title: '类型', dataIndex: 'libraryType', width: 110, render: (v) => <Tag color={libraryColor(v)}>{libraryLabel(v)}</Tag> },
{ title: '状态', dataIndex: 'status', width: 100, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> }, { title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code copyable={!!v}>{v || '-'}</Text> },
{ title: 'ProjectName', dataIndex: 'remoteProjectName', width: 240, render: (v) => <Text code copyable>{v || '-'}</Text> }, { title: '状态', dataIndex: 'status', width: 130, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> },
{ title: '素材', dataIndex: 'assetCount', width: 100 }, { title: '素材', width: 170, render: (_, r) => <Space size={4}><Tag> {r.assetCount || 0}</Tag><Tag color="blue"> {r.imageAssetCount || 0}</Tag><Tag color="geekblue"> {r.videoAssetCount || 0}</Tag></Space> },
{ title: 'Active', dataIndex: 'activeAssetCount', width: 100 }, { title: 'Active', dataIndex: 'activeAssetCount', width: 90 },
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v) => v || '-' }, { title: 'ProjectName', dataIndex: 'remoteProjectName', width: 180, render: (v) => <Text code copyable={!!v}>{v || '-'}</Text> },
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: formatDate },
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: formatDate },
]; ];
const assetColumns: ColumnsType<PrivatePortraitAsset> = [ const assetColumns: ColumnsType<PrivatePortraitAsset> = [
{ title: '素材', dataIndex: 'name', width: 180, render: (v, r) => v || r.remoteAssetId || '-' }, { title: '预览', width: 100, fixed: 'left', render: (_, r) => <PreviewCell asset={r} /> },
{ title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code>{v || '-'}</Text> }, { title: '素材名称', dataIndex: 'name', width: 180, render: (v, r) => <div><Text strong>{v || '未命名素材'}</Text><br /><Text type="secondary">{assetTypeLabel(r.assetType)}</Text></div> },
{ title: '本地项目', dataIndex: 'projectName', width: 160 }, { title: '库类型', dataIndex: 'libraryType', width: 110, render: (v) => <Tag color={libraryColor(v)}>{libraryLabel(v)}</Tag> },
{ title: 'AssetId', dataIndex: 'remoteAssetId', width: 240, render: (v) => <Text code copyable>{v}</Text> }, { title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code copyable={!!v}>{v || '-'}</Text> },
{ title: 'GroupId', dataIndex: 'remoteGroupId', width: 240, render: (v) => <Text code copyable>{v}</Text> }, { title: '本地项目', dataIndex: 'projectName', width: 160, render: (v) => v || '-' },
{ title: 'ProjectName', dataIndex: 'remoteProjectName', width: 240, render: (v) => <Text code copyable>{v || '-'}</Text> }, { title: '状态', dataIndex: 'status', width: 120, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> },
{ title: '类型', dataIndex: 'assetType', width: 90 }, { title: 'AssetId', dataIndex: 'remoteAssetId', width: 190, render: (v) => <Space><Text code>{shortId(v)}</Text>{v ? <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyText(v)} /> : null}</Space> },
{ title: '状态', dataIndex: 'status', width: 110, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> }, { title: 'GroupId', dataIndex: 'remoteGroupId', width: 190, render: (v) => <Space><Text code>{shortId(v)}</Text>{v ? <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyText(v)} /> : null}</Space> },
{ title: '错误', dataIndex: 'errorMessage', width: 240, ellipsis: true, render: (v) => v || '-' }, { title: '轮询', width: 160, render: (_, r) => <div><Text>{r.pollCount || 0}</Text><br /><Text type="secondary">{formatDate((r as any).nextPollAt)}</Text></div> },
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v) => v || '-' }, { title: '错误', dataIndex: 'errorMessage', width: 240, ellipsis: true, render: (v) => v ? <Tooltip title={v}><Text type="danger">{v}</Text></Tooltip> : '-' },
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: formatDate },
]; ];
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24, background: '#f7f7fb', minHeight: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}> <Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} align="start">
<div> <div>
<Title level={3} style={{ margin: 0 }}></Title> <Title level={3} style={{ margin: 0 }}></Title>
<Text type="secondary">Asset Group Asset </Text> <Text type="secondary">/</Text>
</div> </div>
<Space> <Space wrap>
<Input <Input allowClear prefix={<SearchOutlined />} placeholder="搜索项目/素材" value={keyword} onChange={(e) => setKeyword(e.target.value)} onPressEnter={searchAll} style={{ width: 240 }} />
allowClear <Select value={libraryType} onChange={(v) => { setLibraryType(v); setProjectPage(1); setAssetPage(1); }} options={LIBRARY_OPTIONS} style={{ width: 150 }} />
prefix={<SearchOutlined />} <Select value={assetType} onChange={(v) => { setAssetType(v); setAssetPage(1); }} options={ASSET_TYPE_OPTIONS} style={{ width: 130 }} />
placeholder="搜索项目/素材" <Button icon={<SearchOutlined />} type="primary" onClick={searchAll}></Button>
value={keyword} <Button onClick={resetFilters}></Button>
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={() => { setProjectPage(1); setAssetPage(1); loadProjects(); loadAssets(); }}
style={{ width: 260 }}
/>
<Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(); }}></Button> <Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(); }}></Button>
</Space> </Space>
</Space> </Space>
<Card title="真人素材项目组" style={{ marginBottom: 16 }}> <Space size={16} wrap style={{ marginBottom: 16 }}>
<Table <Card style={{ width: 180 }}><Statistic title="项目数" value={projectTotal} /></Card>
rowKey="id" <Card style={{ width: 180 }}><Statistic title="当前页素材" value={projectSummary.asset} /></Card>
columns={projectColumns} <Card style={{ width: 180 }}><Statistic title="图片" value={projectSummary.image} /></Card>
dataSource={projects} <Card style={{ width: 180 }}><Statistic title="视频" value={projectSummary.video} /></Card>
loading={loadingProjects} <Card style={{ width: 180 }}><Statistic title="Active" value={projectSummary.active} /></Card>
pagination={{ current: projectPage, pageSize: 20, total: projectTotal, onChange: setProjectPage }} </Space>
size="small"
scroll={{ x: 900 }} <Card title="项目组" style={{ marginBottom: 16, borderRadius: 14 }} extra={<Select value={projectStatus} onChange={(v) => { setProjectStatus(v); setProjectPage(1); }} options={PROJECT_STATUS_OPTIONS} style={{ width: 180 }} />}>
/> <Table rowKey="id" columns={projectColumns} dataSource={projects} loading={loadingProjects} pagination={{ current: projectPage, pageSize: PAGE_SIZE, total: projectTotal, onChange: setProjectPage }} size="small" scroll={{ x: 1350 }} />
</Card> </Card>
<Card <Card title={selectedProjectId ? '项目素材明细' : '全部素材明细'} style={{ borderRadius: 14 }} extra={<Space><Select value={assetStatus} onChange={(v) => { setAssetStatus(v); setAssetPage(1); }} options={ASSET_STATUS_OPTIONS} style={{ width: 170 }} />{selectedProjectId ? <Button size="small" onClick={() => setSelectedProjectId(undefined)}></Button> : null}</Space>}>
title={selectedProjectId ? '项目素材明细' : '全部素材明细'} <Table rowKey="id" columns={assetColumns} dataSource={assets} loading={loadingAssets} pagination={{ current: assetPage, pageSize: PAGE_SIZE, total: assetTotal, onChange: setAssetPage }} size="small" scroll={{ x: 1700 }} />
extra={selectedProjectId ? <Button size="small" onClick={() => setSelectedProjectId(undefined)}></Button> : null}
>
<Table
rowKey="id"
columns={assetColumns}
dataSource={assets}
loading={loadingAssets}
pagination={{ current: assetPage, pageSize: 20, total: assetTotal, onChange: setAssetPage }}
size="small"
scroll={{ x: 1600 }}
/>
</Card> </Card>
</div> </div>
); );
+21 -21
View File
@@ -189,7 +189,7 @@ const AdminUsers: React.FC = () => {
credits: values.credits || 0, credits: values.credits || 0,
user_type: userType, user_type: userType,
frontend_user_kind: values.frontend_user_kind || 'external', frontend_user_kind: values.frontend_user_kind || 'external',
private_portrait_image_limit: userType === 'frontend' ? Number(values.private_portrait_image_limit ?? 5) : 0, private_portrait_asset_limit: userType === 'frontend' ? Number(values.private_portrait_asset_limit ?? 5) : 0,
}); });
message.success('用户创建成功'); message.success('用户创建成功');
setCreateModal(false); setCreateModal(false);
@@ -276,13 +276,13 @@ const AdminUsers: React.FC = () => {
const openPortraitModal = async (user: AdminUser) => { const openPortraitModal = async (user: AdminUser) => {
setPortraitLoading(true); setPortraitLoading(true);
setPortraitModal({ open: true, user, config: null }); setPortraitModal({ open: true, user, config: null });
portraitForm.setFieldsValue({ privatePortraitImageLimit: user.privatePortraitImageLimit ?? 5 }); portraitForm.setFieldsValue({ privatePortraitAssetLimit: user.privatePortraitAssetLimit ?? 5 });
try { try {
const config = await adminGetPrivatePortraitConfig(user.id); const config = await adminGetPrivatePortraitConfig(user.id);
portraitForm.setFieldsValue({ privatePortraitImageLimit: config.imageLimit }); portraitForm.setFieldsValue({ privatePortraitAssetLimit: config.assetLimit });
setPortraitModal({ open: true, user, config }); setPortraitModal({ open: true, user, config });
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '加载真人素材库配置失败'); message.error(e?.message || '加载私域人像素材库配置失败');
setPortraitModal({ open: false, user: null, config: null }); setPortraitModal({ open: false, user: null, config: null });
} finally { } finally {
setPortraitLoading(false); setPortraitLoading(false);
@@ -294,16 +294,16 @@ const AdminUsers: React.FC = () => {
if (!user) return; if (!user) return;
try { try {
const values = await portraitForm.validateFields(); const values = await portraitForm.validateFields();
const limit = Number(values.privatePortraitImageLimit ?? 0); const limit = Number(values.privatePortraitAssetLimit ?? 0);
setPortraitSaving(true); setPortraitSaving(true);
const config = await adminUpdatePrivatePortraitConfig(user.id, limit); const config = await adminUpdatePrivatePortraitConfig(user.id, limit);
message.success(limit > 0 ? `真人素材库已开启,限制 ${limit} ` : '真人素材库已关闭'); message.success(limit > 0 ? `私域人像素材库已开启,限制 ${limit} ` : '私域人像素材库已关闭');
setPortraitModal({ open: false, user: null, config }); setPortraitModal({ open: false, user: null, config });
portraitForm.resetFields(); portraitForm.resetFields();
load(); load();
} catch (e: any) { } catch (e: any) {
if (e?.errorFields) return; if (e?.errorFields) return;
message.error(e?.message || '保存真人素材库配置失败'); message.error(e?.message || '保存私域人像素材库配置失败');
} finally { } finally {
setPortraitSaving(false); setPortraitSaving(false);
} }
@@ -424,10 +424,10 @@ const AdminUsers: React.FC = () => {
render: (v: string | null | undefined) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary"></Typography.Text>, render: (v: string | null | undefined) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary"></Typography.Text>,
}] : []), }] : []),
...(!isAdminTab ? [{ ...(!isAdminTab ? [{
title: '真人素材库', dataIndex: 'privatePortraitImageLimit', width: 150, title: '私域人像素材库', dataIndex: 'privatePortraitAssetLimit', width: 150,
render: (v: number) => { render: (v: number) => {
const limit = Number(v || 0); const limit = Number(v || 0);
return limit > 0 ? <Tag color="purple">{limit} </Tag> : <Tag></Tag>; return limit > 0 ? <Tag color="purple">{limit} </Tag> : <Tag></Tag>;
}, },
}] : []), }] : []),
...(!isAdminTab ? [{ ...(!isAdminTab ? [{
@@ -493,7 +493,7 @@ const AdminUsers: React.FC = () => {
{!isAdminTab && ( {!isAdminTab && (
<Button type="link" size="small" icon={<PictureOutlined />} <Button type="link" size="small" icon={<PictureOutlined />}
onClick={() => openPortraitModal(r)}> onClick={() => openPortraitModal(r)}>
</Button> </Button>
)} )}
{!isAdminTab && ( {!isAdminTab && (
@@ -784,7 +784,7 @@ const AdminUsers: React.FC = () => {
</Modal> </Modal>
<Modal <Modal
title={<Space><PictureOutlined /> - {portraitModal.user?.username}</Space>} title={<Space><PictureOutlined /> - {portraitModal.user?.username}</Space>}
open={portraitModal.open} open={portraitModal.open}
confirmLoading={portraitSaving} confirmLoading={portraitSaving}
onOk={handleSavePortraitConfig} onOk={handleSavePortraitConfig}
@@ -797,17 +797,17 @@ const AdminUsers: React.FC = () => {
{portraitModal.config?.enabled ? <Tag color="purple"></Tag> : <Tag></Tag>} {portraitModal.config?.enabled ? <Tag color="purple"></Tag> : <Tag></Tag>}
</Typography.Text> </Typography.Text>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
{portraitModal.config?.usedImageCount ?? '-'} {portraitModal.config?.usedAssetCount ?? '-'}
{portraitModal.config?.enabled ? portraitModal.config.remainingImageCount : 0} {portraitModal.config?.enabled ? portraitModal.config.remainingAssetCount : 0}
</Typography.Text> </Typography.Text>
</Space> </Space>
</Card> </Card>
<Form form={portraitForm} layout="vertical"> <Form form={portraitForm} layout="vertical">
<Form.Item <Form.Item
name="privatePortraitImageLimit" name="privatePortraitAssetLimit"
label="真人素材库图片上限" label="私域人像素材总量上限"
extra="0 表示关闭真人素材库;大于 0 表示开启,并限制该用户所有真人素材图片总量。" extra="0 表示关闭私域人像素材库;大于 0 表示开启,并限制该用户所有私域人像素材总量。"
rules={[{ required: true, message: '请输入真人素材库图片上限' }]} rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
> >
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" /> <InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" />
</Form.Item> </Form.Item>
@@ -857,11 +857,11 @@ const AdminUsers: React.FC = () => {
)} )}
{createType === 'frontend' && ( {createType === 'frontend' && (
<Form.Item <Form.Item
name="private_portrait_image_limit" name="private_portrait_asset_limit"
label="真人素材库图片上限" label="私域人像素材总量上限"
initialValue={5} initialValue={5}
extra="0 表示关闭真人素材库;大于 0 表示开启并限制该用户所有真人素材图片总量。" extra="0 表示关闭私域人像素材库;大于 0 表示开启并限制该用户所有私域人像素材总量。"
rules={[{ required: true, message: '请输入真人素材库图片上限' }]} rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
> >
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" /> <InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" />
</Form.Item> </Form.Item>
+27 -5
View File
@@ -8,7 +8,7 @@ export interface User {
userType: string; userType: string;
allowedMenus?: string[] | null; allowedMenus?: string[] | null;
resourceCapacity?: ResourceCapacityUsage | null; resourceCapacity?: ResourceCapacityUsage | null;
privatePortraitImageLimit: number; privatePortraitAssetLimit: number;
} }
export interface CreditRecord { export interface CreditRecord {
@@ -194,7 +194,7 @@ export interface AdminUser {
lastLoginAt?: string; lastLoginAt?: string;
allowedMenus?: string[] | null; allowedMenus?: string[] | null;
resourceCapacity?: ResourceCapacityUsage | null; resourceCapacity?: ResourceCapacityUsage | null;
privatePortraitImageLimit: number; privatePortraitAssetLimit: number;
} }
export interface AdminStats { export interface AdminStats {
@@ -1125,14 +1125,20 @@ export interface HomeMaterialTextWatermarkPreviewResponse {
export interface PrivatePortraitConfig { export interface PrivatePortraitConfig {
enabled: boolean; enabled: boolean;
imageLimit: number; assetLimit: number;
usedImageCount: number; usedAssetCount: number;
remainingImageCount: number; remainingAssetCount: number;
supportedAssetTypes?: string[];
unsupportedAssetTypes?: string[];
imageLimit?: number;
usedImageCount?: number;
remainingImageCount?: number;
} }
export interface PrivatePortraitProject { export interface PrivatePortraitProject {
id: string; id: string;
userId?: string | null; userId?: string | null;
libraryType: string;
name: string; name: string;
nameSlug?: string | null; nameSlug?: string | null;
remoteProjectName?: string | null; remoteProjectName?: string | null;
@@ -1140,7 +1146,11 @@ export interface PrivatePortraitProject {
status: string; status: string;
assetGroupCount: number; assetGroupCount: number;
assetCount: number; assetCount: number;
imageAssetCount?: number;
videoAssetCount?: number;
activeAssetCount: number; activeAssetCount: number;
activeImageAssetCount?: number;
activeVideoAssetCount?: number;
lastUsedAt?: string | null; lastUsedAt?: string | null;
createdAt?: string | null; createdAt?: string | null;
updatedAt?: string | null; updatedAt?: string | null;
@@ -1174,6 +1184,7 @@ export interface PrivatePortraitAsset {
projectId: string; projectId: string;
projectName?: string | null; projectName?: string | null;
groupId: string; groupId: string;
libraryType: string;
remoteGroupId: string; remoteGroupId: string;
remoteAssetId?: string | null; remoteAssetId?: string | null;
remoteProjectName?: string | null; remoteProjectName?: string | null;
@@ -1181,7 +1192,13 @@ export interface PrivatePortraitAsset {
name?: string | null; name?: string | null;
sourceUrl: string; sourceUrl: string;
previewUrl?: string | null; previewUrl?: string | null;
displayUrl?: string | null;
providerUrl?: string | null;
remoteUrl?: string | null; remoteUrl?: string | null;
videoDuration?: number | null;
videoCoverUrl?: string | null;
fileSize?: number | null;
mimeType?: string | null;
status: string; status: string;
pollCount: number; pollCount: number;
remoteDeleteStatus: string; remoteDeleteStatus: string;
@@ -1201,9 +1218,14 @@ export interface PrivatePortraitSelectableAsset {
id: string; id: string;
projectId: string; projectId: string;
projectName: string; projectName: string;
libraryType: string;
name?: string | null; name?: string | null;
assetType: string; assetType: string;
previewUrl?: string | null; previewUrl?: string | null;
displayUrl?: string | null;
providerUrl?: string | null;
videoDuration?: number | null;
videoCoverUrl?: string | null;
status: string; status: string;
createdAt?: string | null; createdAt?: string | null;
} }
+4
View File
@@ -57,6 +57,10 @@ VIDEO_COVER_WIDTH=600
VIDEO_COVER_TIMEOUT_SECONDS=15 VIDEO_COVER_TIMEOUT_SECONDS=15
VIDEO_COVER_FORMAT=png VIDEO_COVER_FORMAT=png
# VOLC
VOLC_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
VOLC_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ==
# VOLC_SMS # VOLC_SMS
VOLC_SMS_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM VOLC_SMS_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
VOLC_SMS_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ== VOLC_SMS_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ==
@@ -0,0 +1,202 @@
"""rename private portrait asset limit and add library type
Revision ID: cd7688999204
Revises: e7038d02c355
Create Date: 2026-07-07 13:21:49.612465
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "cd7688999204"
down_revision: Union[str, None] = "e7038d02c355"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. users 字段改名:必须 rename,保留原有用户额度数据
op.alter_column(
"users",
"private_portrait_image_limit",
new_column_name="private_portrait_asset_limit",
existing_type=sa.Integer(),
existing_nullable=False,
existing_server_default=sa.text("5"),
comment="私域人像素材总量限制,真人/虚拟共用,图片/视频共用,0 表示关闭",
existing_comment="私域真人图片素材数量限制,0表示关闭",
)
# 2. private_portrait_asset_groups
op.add_column(
"private_portrait_asset_groups",
sa.Column(
"library_type",
sa.String(length=32),
server_default="real_person",
nullable=False,
comment="素材库类型:real_person 真人认证;aigc_virtual 虚拟人像",
),
)
op.create_index(
"idx_private_portrait_asset_groups_library_type",
"private_portrait_asset_groups",
["library_type"],
unique=False,
)
op.create_index(
"idx_private_portrait_asset_groups_user_library",
"private_portrait_asset_groups",
["user_id", "library_type"],
unique=False,
)
# 3. private_portrait_assets
op.add_column(
"private_portrait_assets",
sa.Column(
"library_type",
sa.String(length=32),
server_default="real_person",
nullable=False,
comment="素材库类型:real_person 真人认证;aigc_virtual 虚拟人像",
),
)
op.add_column(
"private_portrait_assets",
sa.Column("video_duration", sa.Float(), nullable=True, comment="视频素材时长,秒"),
)
op.add_column(
"private_portrait_assets",
sa.Column("video_cover_url", sa.Text(), nullable=True, comment="视频素材封面预览地址"),
)
op.add_column(
"private_portrait_assets",
sa.Column("file_size", sa.Integer(), nullable=True, comment="素材文件大小,字节"),
)
op.add_column(
"private_portrait_assets",
sa.Column("mime_type", sa.String(length=128), nullable=True, comment="素材 MIME 类型"),
)
# 如果 private_portrait_assets.asset_type 已经存在索引,这条要删除
op.create_index(
"idx_private_portrait_assets_asset_type",
"private_portrait_assets",
["asset_type"],
unique=False,
)
op.create_index(
"idx_private_portrait_assets_library_type",
"private_portrait_assets",
["library_type"],
unique=False,
)
op.create_index(
"idx_private_portrait_assets_user_library_status_created",
"private_portrait_assets",
["user_id", "library_type", "status", "created_at"],
unique=False,
)
# 4. private_portrait_projects
op.add_column(
"private_portrait_projects",
sa.Column(
"library_type",
sa.String(length=32),
server_default="real_person",
nullable=False,
comment="素材库类型:real_person 真人认证;aigc_virtual 虚拟人像",
),
)
op.add_column(
"private_portrait_projects",
sa.Column("image_asset_count", sa.Integer(), server_default="0", nullable=False, comment="图片素材数量"),
)
op.add_column(
"private_portrait_projects",
sa.Column("video_asset_count", sa.Integer(), server_default="0", nullable=False, comment="视频素材数量"),
)
op.add_column(
"private_portrait_projects",
sa.Column("active_image_asset_count", sa.Integer(), server_default="0", nullable=False, comment="可用图片素材数量"),
)
op.add_column(
"private_portrait_projects",
sa.Column("active_video_asset_count", sa.Integer(), server_default="0", nullable=False, comment="可用视频素材数量"),
)
op.create_index(
"idx_private_portrait_projects_library_type",
"private_portrait_projects",
["library_type"],
unique=False,
)
op.create_index(
"idx_private_portrait_projects_user_library_status_created",
"private_portrait_projects",
["user_id", "library_type", "status", "created_at"],
unique=False,
)
def downgrade() -> None:
# 1. users 字段反向改名:保留数据
op.alter_column(
"users",
"private_portrait_asset_limit",
new_column_name="private_portrait_image_limit",
existing_type=sa.Integer(),
existing_nullable=False,
existing_server_default=sa.text("5"),
comment="私域真人图片素材数量限制,0表示关闭",
existing_comment="私域人像素材总量限制,真人/虚拟共用,图片/视频共用,0 表示关闭",
)
# 2. private_portrait_projects
op.drop_index(
"idx_private_portrait_projects_user_library_status_created",
table_name="private_portrait_projects",
)
op.drop_index(
"idx_private_portrait_projects_library_type",
table_name="private_portrait_projects",
)
op.drop_column("private_portrait_projects", "active_video_asset_count")
op.drop_column("private_portrait_projects", "active_image_asset_count")
op.drop_column("private_portrait_projects", "video_asset_count")
op.drop_column("private_portrait_projects", "image_asset_count")
op.drop_column("private_portrait_projects", "library_type")
# 3. private_portrait_assets
op.drop_index(
"idx_private_portrait_assets_user_library_status_created",
table_name="private_portrait_assets",
)
op.drop_index(
"idx_private_portrait_assets_library_type",
table_name="private_portrait_assets",
)
op.drop_index(
"idx_private_portrait_assets_asset_type",
table_name="private_portrait_assets",
)
op.drop_column("private_portrait_assets", "mime_type")
op.drop_column("private_portrait_assets", "file_size")
op.drop_column("private_portrait_assets", "video_cover_url")
op.drop_column("private_portrait_assets", "video_duration")
op.drop_column("private_portrait_assets", "library_type")
# 4. private_portrait_asset_groups
op.drop_index(
"idx_private_portrait_asset_groups_user_library",
table_name="private_portrait_asset_groups",
)
op.drop_index(
"idx_private_portrait_asset_groups_library_type",
table_name="private_portrait_asset_groups",
)
op.drop_column("private_portrait_asset_groups", "library_type")
@@ -0,0 +1,31 @@
"""private portrait validate once
Revision ID: e7038d02c355
Revises: 6fc75582f6f9
Create Date: 2026-07-07 10:47:00.436752
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e7038d02c355'
down_revision: Union[str, None] = '6fc75582f6f9'
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.create_index('uq_private_portrait_asset_groups_one_active_project', 'private_portrait_asset_groups', ['project_id'], unique=True, postgresql_where=sa.text("deleted_at IS NULL AND status = 'active'"))
op.create_index('uq_private_portrait_validate_sessions_one_group_active_project', 'private_portrait_validate_sessions', ['project_id'], unique=True, postgresql_where=sa.text("status = 'group_active'"))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('uq_private_portrait_validate_sessions_one_group_active_project', table_name='private_portrait_validate_sessions', postgresql_where=sa.text("status = 'group_active'"))
op.drop_index('uq_private_portrait_asset_groups_one_active_project', table_name='private_portrait_asset_groups', postgresql_where=sa.text("deleted_at IS NULL AND status = 'active'"))
# ### end Alembic commands ###
+33 -101
View File
@@ -10,127 +10,59 @@ from app.dependencies import get_admin_user, get_db
from app.models.user import User from app.models.user import User
from app.schemas.private_portrait import ( from app.schemas.private_portrait import (
PrivatePortraitAdminConfigUpdate, PrivatePortraitAdminConfigUpdate,
PrivatePortraitAdminStatsOut,
PrivatePortraitAssetListOut, PrivatePortraitAssetListOut,
PrivatePortraitConfigOut, PrivatePortraitConfigOut,
PrivatePortraitProjectListOut, PrivatePortraitProjectListOut,
) )
from app.services.operation_log import log_operation from app.services.operation_log import log_operation
from app.services.private_portrait.asset_service import ( from app.services.private_portrait.admin.asset_query_service import admin_list_assets
asset_to_out, from app.services.private_portrait.admin.project_query_service import admin_list_projects
get_user_private_portrait_config, from app.services.private_portrait.admin.stats_query_service import admin_get_private_portrait_stats
list_assets, from app.services.private_portrait.quota_service import get_user_private_portrait_config, set_user_private_portrait_limit
set_user_private_portrait_limit,
)
from app.services.private_portrait.project_service import list_projects, project_to_out
router = APIRouter(prefix="/admin/private-portrait", tags=["admin-private-portrait"]) router = APIRouter(prefix="/admin/private-portrait", tags=["管理后台-私域人像素材库"])
def _json_detail(data: dict[str, Any]) -> str: def _json_detail(data: dict[str, Any]) -> str:
return json.dumps(data, ensure_ascii=False, default=str) return json.dumps(data, ensure_ascii=False, default=str)
@router.get("/users/{user_id}/config", response_model=PrivatePortraitConfigOut) @router.get("/users/{user_id}/config", response_model=PrivatePortraitConfigOut, summary="管理后台:查看用户私域人像素材额度")
async def admin_get_private_portrait_config( async def admin_get_private_portrait_config(user_id: str, admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
user_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
config = await get_user_private_portrait_config(db, user_id=user_id) config = await get_user_private_portrait_config(db, user_id=user_id)
await log_operation( await log_operation(db, admin.id, admin.username, "查看私域人像素材库配置", "GET", f"/admin/private-portrait/users/{user_id}/config", detail=_json_detail({"target_user_id": user_id, "config": config.model_dump(mode="json")}))
db,
admin.id,
admin.username,
"查看真人素材库配置",
"GET",
f"/admin/private-portrait/users/{user_id}/config",
detail=_json_detail({"target_user_id": user_id, "config": config.model_dump(mode="json")}),
)
return config return config
@router.put("/users/{user_id}/config", response_model=PrivatePortraitConfigOut) @router.put("/users/{user_id}/config", response_model=PrivatePortraitConfigOut, summary="管理后台:设置用户私域人像素材总量")
async def admin_update_private_portrait_config( async def admin_update_private_portrait_config(user_id: str, payload: PrivatePortraitAdminConfigUpdate, admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
user_id: str,
payload: PrivatePortraitAdminConfigUpdate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
before = await get_user_private_portrait_config(db, user_id=user_id) before = await get_user_private_portrait_config(db, user_id=user_id)
await set_user_private_portrait_limit(db, user_id=user_id, limit=payload.private_portrait_image_limit) await set_user_private_portrait_limit(db, user_id=user_id, limit=payload.private_portrait_asset_limit)
after = await get_user_private_portrait_config(db, user_id=user_id) after = await get_user_private_portrait_config(db, user_id=user_id)
await log_operation( await log_operation(db, admin.id, admin.username, f"设置私域人像素材总量限制:{before.asset_limit} -> {after.asset_limit}", "PUT", f"/admin/private-portrait/users/{user_id}/config", detail=_json_detail({"target_user_id": user_id, "before": before.model_dump(mode="json"), "after": after.model_dump(mode="json")}))
db, await db.commit()
admin.id,
admin.username,
f"设置真人素材库数量限制:{before.image_limit} -> {after.image_limit}",
"PUT",
f"/admin/private-portrait/users/{user_id}/config",
detail=_json_detail(
{
"target_user_id": user_id,
"before": before.model_dump(mode="json"),
"after": after.model_dump(mode="json"),
}
),
)
return after return after
@router.get("/projects", response_model=PrivatePortraitProjectListOut) @router.get("/stats", response_model=PrivatePortraitAdminStatsOut, summary="管理后台:私域人像素材统计")
async def admin_list_private_portrait_projects( async def admin_get_private_portrait_stats_api(user_id: str | None = Query(None), library_type: str | None = Query(None), admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
page: int = Query(1, ge=1), stats = await admin_get_private_portrait_stats(db, user_id=user_id, library_type=library_type)
page_size: int = Query(20, ge=1, le=100), await log_operation(db, admin.id, admin.username, "查看私域人像素材统计", "GET", "/admin/private-portrait/stats", detail=_json_detail({"filters": {"user_id": user_id, "library_type": library_type}, "stats": stats.model_dump(mode="json")}))
user_id: str | None = Query(None), return stats
keyword: str | None = Query(None),
status: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
items, total = await list_projects(db, user_id=user_id, page=page, page_size=page_size, keyword=keyword, status=status)
await log_operation(
db,
admin.id,
admin.username,
"查看真人素材项目列表",
"GET",
"/admin/private-portrait/projects",
detail=_json_detail(
{
"filters": {"user_id": user_id, "keyword": keyword, "status": status, "page": page, "page_size": page_size},
"total": total,
"returned_count": len(items),
}
),
)
return PrivatePortraitProjectListOut(items=[project_to_out(item, include_user=True) for item in items], total=total, page=page, page_size=page_size)
@router.get("/assets", response_model=PrivatePortraitAssetListOut) @router.get("/projects", response_model=PrivatePortraitProjectListOut, summary="管理后台:查询私域人像项目列表")
async def admin_list_private_portrait_assets( async def admin_list_private_portrait_projects(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), user_id: str | None = Query(None), library_type: str | None = Query(None), keyword: str | None = Query(None), status: str | None = Query(None), admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
page: int = Query(1, ge=1), items, total = await admin_list_projects(db, user_id=user_id, library_type=library_type, page=page, page_size=page_size, keyword=keyword, status=status)
page_size: int = Query(20, ge=1, le=100), await log_operation(db, admin.id, admin.username, "查看私域人像素材项目列表", "GET", "/admin/private-portrait/projects", detail=_json_detail({"filters": {"user_id": user_id, "library_type": library_type, "keyword": keyword, "status": status, "page": page, "page_size": page_size}, "total": total, "returned_count": len(items)}))
user_id: str | None = Query(None), await db.commit()
project_id: str | None = Query(None), return PrivatePortraitProjectListOut(items=items, total=total, page=page, page_size=page_size)
keyword: str | None = Query(None),
status: str | None = Query(None),
admin: User = Depends(get_admin_user), @router.get("/assets", response_model=PrivatePortraitAssetListOut, summary="管理后台:查询私域人像素材列表")
db: AsyncSession = Depends(get_db), async def admin_list_private_portrait_assets(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), user_id: str | None = Query(None), project_id: str | None = Query(None), library_type: str | None = Query(None), asset_type: str | None = Query(None), keyword: str | None = Query(None), status: str | None = Query(None), admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
): items, total = await admin_list_assets(db, user_id=user_id, project_id=project_id, library_type=library_type, asset_type=asset_type, status=status, keyword=keyword, page=page, page_size=page_size)
assets, total, project_name_map = await list_assets(db, user_id=user_id, project_id=project_id, status=status, keyword=keyword, page=page, page_size=page_size) await log_operation(db, admin.id, admin.username, "查看私域人像素材列表", "GET", "/admin/private-portrait/assets", detail=_json_detail({"filters": {"user_id": user_id, "project_id": project_id, "library_type": library_type, "asset_type": asset_type, "keyword": keyword, "status": status, "page": page, "page_size": page_size}, "total": total, "returned_count": len(items)}))
await log_operation( await db.commit()
db, return PrivatePortraitAssetListOut(items=items, total=total, page=page, page_size=page_size)
admin.id,
admin.username,
"查看真人素材列表",
"GET",
"/admin/private-portrait/assets",
detail=_json_detail(
{
"filters": {"user_id": user_id, "project_id": project_id, "keyword": keyword, "status": status, "page": page, "page_size": page_size},
"total": total,
"returned_count": len(assets),
}
),
)
return PrivatePortraitAssetListOut(items=[asset_to_out(asset, project_name=project_name_map.get(asset.project_id), include_user=True) for asset in assets], total=total, page=page, page_size=page_size)
+3 -1
View File
@@ -33,6 +33,7 @@ from app.api.v1.home_materials import router as home_materials_router
from app.api.admin import router as admin_module_router from app.api.admin import router as admin_module_router
from app.api.v1.material_admin import router as material_admin_router from app.api.v1.material_admin import router as material_admin_router
from app.api.v1.private_portrait import router as private_portrait_router from app.api.v1.private_portrait import router as private_portrait_router
from app.api.v1.private_portrait_virtual import router as private_portrait_virtual_router
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth_router) api_router.include_router(auth_router)
@@ -67,4 +68,5 @@ api_router.include_router(team_router)
api_router.include_router(home_materials_router) api_router.include_router(home_materials_router)
api_router.include_router(admin_module_router) api_router.include_router(admin_module_router)
api_router.include_router(material_admin_router) api_router.include_router(material_admin_router)
api_router.include_router(private_portrait_router) api_router.include_router(private_portrait_router)
api_router.include_router(private_portrait_virtual_router)
+9 -8
View File
@@ -49,6 +49,7 @@ from app.services.admin_credit_record_service import list_admin_credit_records
from app.services.notification import create_notification from app.services.notification import create_notification
from app.services.auth import hash_password, verify_password from app.services.auth import hash_password, verify_password
from app.services.operation_log import log_operation from app.services.operation_log import log_operation
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls
from app.services.resource_signed_url_service import build_resource_signed_url from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.payment import sync_pending_orders, process_refund from app.services.payment import sync_pending_orders, process_refund
from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage
@@ -173,7 +174,7 @@ async def create_user(
user_type=req.user_type, user_type=req.user_type,
frontend_user_kind=req.frontend_user_kind if req.user_type == "frontend" else FrontendUserKind.EXTERNAL.value, frontend_user_kind=req.frontend_user_kind if req.user_type == "frontend" else FrontendUserKind.EXTERNAL.value,
allowed_menus=req.allowed_menus, allowed_menus=req.allowed_menus,
private_portrait_image_limit=req.private_portrait_image_limit, private_portrait_asset_limit=req.private_portrait_asset_limit,
) )
user.credits = round(user.credits, 2) user.credits = round(user.credits, 2)
db.add(user) db.add(user)
@@ -191,7 +192,7 @@ async def create_user(
"username": username, "username": username,
"user_type": req.user_type, "user_type": req.user_type,
"frontend_user_kind": user.frontend_user_kind, "frontend_user_kind": user.frontend_user_kind,
"private_portrait_image_limit": user.private_portrait_image_limit, "private_portrait_asset_limit": user.private_portrait_asset_limit,
"credits": user.credits, "credits": user.credits,
"phone": user.phone, "phone": user.phone,
"email": user.email, "email": user.email,
@@ -1449,15 +1450,15 @@ async def admin_list_generation_records(
query = query.offset(offset).limit(page_size) query = query.offset(offset).limit(page_size)
result = await db.execute(query) result = await db.execute(query)
rows = result.all() rows = result.all()
refs_map = await batch_resolve_private_portrait_reference_display_urls(
db,
{record.id: json.loads(record.media_references) if record.media_references else None for record, _username, _project_name, _industry, _industry_label in rows},
user_id=user_id,
)
items = [] items = []
for record, username, project_name, industry, industry_label in rows: for record, username, project_name, industry, industry_label in rows:
refs = None refs = refs_map.get(record.id)
if record.media_references:
try:
refs = json.loads(record.media_references)
except (json.JSONDecodeError, TypeError):
refs = None
items.append({ items.append({
"id": record.id, "id": record.id,
"user_id": record.user_id, "user_id": record.user_id,
+18 -8
View File
@@ -33,6 +33,7 @@ from app.services.resource_accounting_service import (
record_generation_record_generated_resource, record_generation_record_generated_resource,
safe_file_size, safe_file_size,
) )
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls
from app.services.resource_signed_url_service import build_resource_signed_url from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.resource_capacity_service import assert_user_resource_capacity_available from app.services.resource_capacity_service import assert_user_resource_capacity_available
from app.services.generation_billing_service import ( from app.services.generation_billing_service import (
@@ -59,9 +60,9 @@ router = APIRouter(prefix="/generation-records", tags=["generation"])
logger = logging.getLogger("videogen") logger = logging.getLogger("videogen")
def _record_to_out(record: GenerationRecord, project_name: str) -> GenerationRecordOut: def _record_to_out(record: GenerationRecord, project_name: str, refs_override: list[dict] | None = None) -> GenerationRecordOut:
refs = None refs = refs_override
if record.media_references: if refs is None and record.media_references:
try: try:
refs = json.loads(record.media_references) refs = json.loads(record.media_references)
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
@@ -191,13 +192,18 @@ async def list_records(
result = await db.execute(query) result = await db.execute(query)
rows = result.all() rows = result.all()
refs_map = await batch_resolve_private_portrait_reference_display_urls(
db,
{record.id: json.loads(record.media_references) if record.media_references else None for record, _project_name in rows},
user_id=current_user.id,
)
return { return {
"total": int(total), "total": int(total),
"page": page, "page": page,
"page_size": page_size, "page_size": page_size,
"items": [ "items": [
_record_to_out(record, project_name) _record_to_out(record, project_name, refs_override=refs_map.get(record.id))
for record, project_name in rows for record, project_name in rows
], ],
} }
@@ -240,11 +246,12 @@ async def optimize(
row = existing.first() row = existing.first()
if row: if row:
record, project_name = row record, project_name = row
refs = await resolve_private_portrait_reference_display_urls(db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id)
return OptimizeResult( return OptimizeResult(
optimized_prompt=record.optimized_prompt or "", optimized_prompt=record.optimized_prompt or "",
text_credits_cost=record.text_credits_cost or 0.00, text_credits_cost=record.text_credits_cost or 0.00,
text_tokens_used=record.text_tokens_used or 0, text_tokens_used=record.text_tokens_used or 0,
record=_record_to_out(record, project_name), record=_record_to_out(record, project_name, refs_override=refs),
) )
# Check project exists and belongs to user # Check project exists and belongs to user
@@ -370,11 +377,12 @@ async def optimize(
record.text_tokens_used = token_usage["total_tokens"] record.text_tokens_used = token_usage["total_tokens"]
await db.flush() await db.flush()
refs = await resolve_private_portrait_reference_display_urls(db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id)
return OptimizeResult( return OptimizeResult(
optimized_prompt=optimized, optimized_prompt=optimized,
text_credits_cost=round(text_credits, 2), text_credits_cost=round(text_credits, 2),
# text_tokens_used=token_usage["total_tokens"], # text_tokens_used=token_usage["total_tokens"],
record=_record_to_out(record, project.name), record=_record_to_out(record, project.name, refs_override=refs),
) )
@@ -502,7 +510,8 @@ async def generate(
) )
await db.flush() await db.flush()
return _record_to_out(record, project_name) refs = await resolve_private_portrait_reference_display_urls(db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id)
return _record_to_out(record, project_name, refs_override=refs)
@router.post("/{record_id}/retry") @router.post("/{record_id}/retry")
@@ -578,7 +587,8 @@ async def retry_generation(
) )
await db.flush() await db.flush()
return _record_to_out(record, project_name) refs = await resolve_private_portrait_reference_display_urls(db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id)
return _record_to_out(record, project_name, refs_override=refs)
@router.put("/{record_id}/prompt") @router.put("/{record_id}/prompt")
+11 -3
View File
@@ -36,6 +36,7 @@ from app.services.generation_billing_service import (
from app.services.generation_history_delete_service import batch_delete_generation_history_items from app.services.generation_history_delete_service import batch_delete_generation_history_items
from app.services.generation_log_service import log_task_event from app.services.generation_log_service import log_task_event
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls
from app.services.resource_capacity_service import assert_user_resource_capacity_available from app.services.resource_capacity_service import assert_user_resource_capacity_available
from app.tasks.celery_app import celery_app from app.tasks.celery_app import celery_app
@@ -173,7 +174,8 @@ async def create_task(
await db.commit() await db.commit()
raise HTTPException(status_code=503, detail="任务队列投递失败,请稍后重试") raise HTTPException(status_code=503, detail="任务队列投递失败,请稍后重试")
return record_to_out(task) refs = await resolve_private_portrait_reference_display_urls(db, record_to_out(task).media_references, user_id=current_user.id)
return record_to_out(task, media_references=refs)
@router.get( @router.get(
@@ -280,7 +282,12 @@ async def list_tasks(
) )
else: else:
items_sorted = items items_sorted = items
return GenerationAITaskListOut(total=total, items=[record_to_out(task=i, is_admin=is_admin) for i in items_sorted]) refs_map = await batch_resolve_private_portrait_reference_display_urls(
db,
{item.id: record_to_out(task=item, is_admin=is_admin).media_references for item in items_sorted},
user_id=None if is_admin else current_user.id,
)
return GenerationAITaskListOut(total=total, items=[record_to_out(task=i, is_admin=is_admin, media_references=refs_map.get(i.id)) for i in items_sorted])
@router.get( @router.get(
@@ -521,7 +528,8 @@ async def get_task(
task = result.scalar_one_or_none() task = result.scalar_one_or_none()
if not task: if not task:
raise HTTPException(status_code=404, detail="任务不存在") raise HTTPException(status_code=404, detail="任务不存在")
return record_to_out(task) refs = await resolve_private_portrait_reference_display_urls(db, record_to_out(task).media_references, user_id=current_user.id)
return record_to_out(task, media_references=refs)
@router.delete( @router.delete(
+87 -67
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from urllib.parse import unquote from urllib.parse import urlencode, unquote
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
@@ -9,9 +9,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db from app.dependencies import get_current_user, get_db
from app.enums.private_portrait import ( from app.enums.private_portrait import (
PrivatePortraitAssetType,
PrivatePortraitEventSource, PrivatePortraitEventSource,
PrivatePortraitEventStatus, PrivatePortraitEventStatus,
PrivatePortraitEventType, PrivatePortraitEventType,
PrivatePortraitLibraryType,
PrivatePortraitProjectStatus,
PrivatePortraitRemoteDeleteStatus, PrivatePortraitRemoteDeleteStatus,
) )
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitProject from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitProject
@@ -19,22 +22,24 @@ from app.models.user import User
from app.schemas.private_portrait import ( from app.schemas.private_portrait import (
PrivatePortraitAssetCreate, PrivatePortraitAssetCreate,
PrivatePortraitAssetListOut, PrivatePortraitAssetListOut,
PrivatePortraitAssetOut,
PrivatePortraitDeleteOut, PrivatePortraitDeleteOut,
PrivatePortraitConfigOut, PrivatePortraitConfigOut,
PrivatePortraitProjectCreate, PrivatePortraitProjectCreate,
PrivatePortraitProjectCreateWithValidateOut,
PrivatePortraitProjectListOut, PrivatePortraitProjectListOut,
PrivatePortraitProjectOut, PrivatePortraitProjectOut,
PrivatePortraitProjectUpdate, PrivatePortraitProjectUpdate,
PrivatePortraitSelectableAssetListOut, PrivatePortraitSelectableAssetListOut,
PrivatePortraitValidateSessionCreate, PrivatePortraitValidateSessionCreate,
PrivatePortraitValidateSessionOut, PrivatePortraitValidateSessionOut,
PrivatePortraitEnumMetaOut,
build_private_portrait_enum_meta,
) )
from app.services.operation_log_service import log_operation_error, log_operation_event from app.services.operation_log_service import log_operation_error, log_operation_event
from app.services.private_portrait.asset_service import ( from app.services.private_portrait.asset_service import (
DOMAIN, DOMAIN,
asset_to_out, asset_to_out,
create_asset,
create_validate_session,
get_user_private_portrait_config, get_user_private_portrait_config,
get_validate_session, get_validate_session,
handle_validate_callback, handle_validate_callback,
@@ -45,89 +50,102 @@ from app.services.private_portrait.asset_service import (
validate_session_to_out, validate_session_to_out,
) )
from app.services.private_portrait.project_service import ( from app.services.private_portrait.project_service import (
create_project,
get_user_project, get_user_project,
list_projects, list_projects,
project_to_out, project_to_out,
refresh_project_counters, refresh_project_counters,
soft_delete_project, soft_delete_project,
update_project, )
from app.services.private_portrait.real_person.service import (
create_real_person_asset,
create_real_person_project,
create_real_person_validate_session,
update_real_person_project,
) )
router = APIRouter(tags=["private-portrait"]) router = APIRouter(tags=["私域真人素材库"])
def _log_task_dispatch_failed(*, task_name: str, user_id: str | None = None, project_id: str | None = None, asset_id: str | None = None, exc: BaseException) -> None: def _log_task_dispatch_failed(*, task_name: str, user_id: str | None = None, project_id: str | None = None, asset_id: str | None = None, exc: BaseException) -> None:
log_operation_error( log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.TASK_DISPATCH_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, asset_id=asset_id, exc=exc, detail={"task_name": task_name})
domain=DOMAIN,
event_type=PrivatePortraitEventType.TASK_DISPATCH_FAILED.value,
source=PrivatePortraitEventSource.API.value,
user_id=user_id,
project_id=project_id,
asset_id=asset_id,
exc=exc,
detail={"task_name": task_name},
)
def _log_task_dispatch_success(*, task_name: str, user_id: str | None = None, project_id: str | None = None, asset_id: str | None = None) -> None: def _log_task_dispatch_success(*, task_name: str, user_id: str | None = None, project_id: str | None = None, asset_id: str | None = None) -> None:
log_operation_event( log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.TASK_DISPATCH_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, asset_id=asset_id, detail={"task_name": task_name})
domain=DOMAIN,
event_type=PrivatePortraitEventType.TASK_DISPATCH_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.API.value,
user_id=user_id,
project_id=project_id,
asset_id=asset_id,
detail={"task_name": task_name},
)
@router.get("/private-portrait/config", response_model=PrivatePortraitConfigOut) @router.get(
"/private-portrait/config",
response_model=PrivatePortraitConfigOut,
summary="获取当前用户私域人像素材额度配置",
description="返回私域人像素材总量限制。额度由真人认证素材库与虚拟人像素材库共用,图片和视频共用,Audio 暂未开放。",
)
async def get_my_private_portrait_config(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): async def get_my_private_portrait_config(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
return await get_user_private_portrait_config(db, user_id=current_user.id) return await get_user_private_portrait_config(db, user_id=current_user.id)
@router.post("/private-portrait/projects", response_model=PrivatePortraitProjectOut) @router.get(
"/private-portrait/meta/enums",
response_model=PrivatePortraitEnumMetaOut,
summary="获取私域人像素材库枚举说明",
description="给前端展示状态、类型、素材库类型使用。Audio 仅作为火山支持项展示,当前业务不开放上传。",
)
async def get_private_portrait_enum_meta():
return build_private_portrait_enum_meta()
@router.post(
"/private-portrait/projects",
response_model=PrivatePortraitProjectCreateWithValidateOut,
summary="创建真人认证素材项目并生成认证会话",
description="创建本地真人素材项目,随后调用火山 CreateVisualValidateSession 返回 H5Link。用户完成认证后,回调会创建本地 Asset Group 映射。",
)
async def create_private_portrait_project(payload: PrivatePortraitProjectCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): async def create_private_portrait_project(payload: PrivatePortraitProjectCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await create_project(db, user_id=current_user.id, payload=payload) project = await create_real_person_project(db, user_id=current_user.id, payload=payload)
out = project_to_out(project) session = await create_real_person_validate_session(db, user_id=current_user.id, project_id=project.id, callback_redirect_url=payload.callback_redirect_url)
out = PrivatePortraitProjectCreateWithValidateOut(project=project_to_out(project), validate_session=validate_session_to_out(session), poll_interval_ms=2000)
await db.commit() await db.commit()
return out return out
@router.get("/private-portrait/projects", response_model=PrivatePortraitProjectListOut) @router.get(
"/private-portrait/projects",
response_model=PrivatePortraitProjectListOut,
summary="查询当前用户真人认证素材项目列表",
description="只返回 library_type=real_person 的项目。默认查询 active 项目,可用 status 覆盖。",
)
async def list_private_portrait_projects( async def list_private_portrait_projects(
page: int = Query(1, ge=1), page: int = Query(1, ge=1, description="页码,从 1 开始。"),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100, description="每页数量,最大 100。"),
keyword: str | None = Query(None), keyword: str | None = Query(None, description="项目名称模糊搜索。"),
status: str | None = Query(None), status: str | None = Query(None, description="项目状态,不传默认 active。"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=status) query_status = status or PrivatePortraitProjectStatus.ACTIVE.value
items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=query_status, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
await refresh_project_counters(db, [item.id for item in items]) await refresh_project_counters(db, [item.id for item in items])
await db.commit() await db.commit()
items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=status) items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=query_status, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
return PrivatePortraitProjectListOut(items=[project_to_out(item) for item in items], total=total, page=page, page_size=page_size) return PrivatePortraitProjectListOut(items=[project_to_out(item) for item in items], total=total, page=page, page_size=page_size)
@router.get("/private-portrait/projects/{project_id}", response_model=PrivatePortraitProjectOut) @router.get("/private-portrait/projects/{project_id}", response_model=PrivatePortraitProjectOut, summary="获取真人认证素材项目详情")
async def get_private_portrait_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): async def get_private_portrait_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
return project_to_out(await get_user_project(db, user_id=current_user.id, project_id=project_id)) return project_to_out(await get_user_project(db, user_id=current_user.id, project_id=project_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value))
@router.put("/private-portrait/projects/{project_id}", response_model=PrivatePortraitProjectOut) @router.put("/private-portrait/projects/{project_id}", response_model=PrivatePortraitProjectOut, summary="更新真人认证素材项目")
async def update_private_portrait_project(project_id: str, payload: PrivatePortraitProjectUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): async def update_private_portrait_project(project_id: str, payload: PrivatePortraitProjectUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await update_project(db, user_id=current_user.id, project_id=project_id, payload=payload) project = await update_real_person_project(db, user_id=current_user.id, project_id=project_id, payload=payload)
out = project_to_out(project) out = project_to_out(project)
await db.commit() await db.commit()
return out return out
@router.delete("/private-portrait/projects/{project_id}", response_model=PrivatePortraitDeleteOut) @router.delete("/private-portrait/projects/{project_id}", response_model=PrivatePortraitDeleteOut, summary="删除真人认证素材项目")
async def delete_private_portrait_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): async def delete_private_portrait_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await soft_delete_project(db, user_id=current_user.id, project_id=project_id) project = await soft_delete_project(db, user_id=current_user.id, project_id=project_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
project_id_snapshot = project.id project_id_snapshot = project.id
await db.commit() await db.commit()
try: try:
@@ -140,40 +158,40 @@ async def delete_private_portrait_project(project_id: str, current_user: User =
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value) return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
@router.post("/private-portrait/projects/{project_id}/validate-sessions", response_model=PrivatePortraitValidateSessionOut) @router.post("/private-portrait/projects/{project_id}/validate-sessions", response_model=PrivatePortraitValidateSessionOut, summary="重新创建真人认证会话")
async def create_private_portrait_validate_session(project_id: str, payload: PrivatePortraitValidateSessionCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): async def create_private_portrait_validate_session(project_id: str, payload: PrivatePortraitValidateSessionCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
session = await create_validate_session(db, user_id=current_user.id, project_id=project_id, callback_redirect_url=payload.callback_redirect_url) session = await create_real_person_validate_session(db, user_id=current_user.id, project_id=project_id, callback_redirect_url=payload.callback_redirect_url)
out = validate_session_to_out(session) out = validate_session_to_out(session)
await db.commit() await db.commit()
return out return out
@router.get("/private-portrait/validate-sessions/{session_id}", response_model=PrivatePortraitValidateSessionOut) @router.get("/private-portrait/validate-sessions/{session_id}", response_model=PrivatePortraitValidateSessionOut, summary="查询真人认证会话状态")
async def get_private_portrait_validate_session(session_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): async def get_private_portrait_validate_session(session_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
return validate_session_to_out(await get_validate_session(db, user_id=current_user.id, session_id=session_id)) return validate_session_to_out(await get_validate_session(db, user_id=current_user.id, session_id=session_id))
@router.get("/private-portrait/validate-callback") @router.get("/private-portrait/validate-callback", summary="火山真人认证回调入口")
async def private_portrait_validate_callback(session_id: str, request: Request, redirect_url: str | None = None, db: AsyncSession = Depends(get_db)): async def private_portrait_validate_callback(session_id: str, request: Request, redirect_url: str | None = None, db: AsyncSession = Depends(get_db)):
params = dict(request.query_params) params = dict(request.query_params)
params.pop("session_id", None) params.pop("session_id", None)
params.pop("redirect_url", None) params.pop("redirect_url", None)
session = await handle_validate_callback(db, session_id=session_id, query_params=params) session = await handle_validate_callback(db, session_id=session_id, query_params=params)
redirect_session_id = session.id redirect_params = {"session_id": session.id, "status": session.status, "resultCode": session.result_code or ""}
redirect_status = session.status if session.remote_group_id:
redirect_result_code = session.result_code or "" redirect_params["remote_group_id"] = session.remote_group_id
response = {"session_id": session.id, "status": session.status, "resultCode": session.result_code, "remote_group_id": session.remote_group_id} response = {"session_id": session.id, "status": session.status, "resultCode": session.result_code, "remote_group_id": session.remote_group_id}
await db.commit() await db.commit()
if redirect_url: if redirect_url:
sep = "&" if "?" in redirect_url else "?" base_url = unquote(redirect_url)
url = f"{unquote(redirect_url)}{sep}session_id={redirect_session_id}&status={redirect_status}&resultCode={redirect_result_code}" sep = "&" if "?" in base_url else "?"
return RedirectResponse(url=url) return RedirectResponse(url=f"{base_url}{sep}{urlencode(redirect_params)}")
return response return response
@router.post("/private-portrait/projects/{project_id}/assets") @router.post("/private-portrait/projects/{project_id}/assets", response_model=PrivatePortraitAssetOut, summary="上传真人认证素材", description="当前支持 Image / Video。Audio 暂不开放。CreateAsset 是异步接口,返回后需要轮询到 Active 才可用于生成。")
async def create_private_portrait_asset(project_id: str, payload: PrivatePortraitAssetCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): async def create_private_portrait_asset(project_id: str, payload: PrivatePortraitAssetCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
asset = await create_asset(db, user_id=current_user.id, project_id=project_id, payload=payload) asset = await create_real_person_asset(db, user_id=current_user.id, project_id=project_id, payload=payload)
asset_id_snapshot = asset.id asset_id_snapshot = asset.id
project_id_snapshot = asset.project_id project_id_snapshot = asset.project_id
out = asset_to_out(asset) out = asset_to_out(asset)
@@ -188,32 +206,34 @@ async def create_private_portrait_asset(project_id: str, payload: PrivatePortrai
return out return out
@router.get("/private-portrait/projects/{project_id}/assets", response_model=PrivatePortraitAssetListOut) @router.get("/private-portrait/projects/{project_id}/assets", response_model=PrivatePortraitAssetListOut, summary="查询真人认证素材列表")
async def list_private_portrait_assets(project_id: str, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), status: str | None = Query(None), keyword: str | None = Query(None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): async def list_private_portrait_assets(project_id: str, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), status: str | None = Query(None), keyword: str | None = Query(None), asset_type: str | None = Query(None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
assets, total, project_name_map = await list_assets(db, user_id=current_user.id, project_id=project_id, status=status, keyword=keyword, page=page, page_size=page_size) assets, total, project_name_map = await list_assets(db, user_id=current_user.id, project_id=project_id, status=status, keyword=keyword, page=page, page_size=page_size, library_type=PrivatePortraitLibraryType.REAL_PERSON.value, asset_type=asset_type)
return PrivatePortraitAssetListOut(items=[asset_to_out(asset, project_name=project_name_map.get(asset.project_id)) for asset in assets], total=total, page=page, page_size=page_size) return PrivatePortraitAssetListOut(items=[asset_to_out(asset, project_name=project_name_map.get(asset.project_id)) for asset in assets], total=total, page=page, page_size=page_size)
@router.get("/private-portrait/assets/{asset_id}") @router.get("/private-portrait/assets/{asset_id}", summary="获取真人认证素材详情")
async def get_private_portrait_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): async def get_private_portrait_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id, PrivatePortraitAsset.user_id == current_user.id).limit(1))).scalar_one_or_none() asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id, PrivatePortraitAsset.user_id == current_user.id, PrivatePortraitAsset.library_type == PrivatePortraitLibraryType.REAL_PERSON.value).limit(1))).scalar_one_or_none()
if not asset: if not asset:
raise HTTPException(status_code=404, detail="真人素材不存在") raise HTTPException(status_code=404, detail="私域人像素材不存在")
project = (await db.execute(select(PrivatePortraitProject).where(PrivatePortraitProject.id == asset.project_id).limit(1))).scalar_one_or_none() project = (await db.execute(select(PrivatePortraitProject).where(PrivatePortraitProject.id == asset.project_id).limit(1))).scalar_one_or_none()
return asset_to_out(asset, project_name=project.name if project else None) return asset_to_out(asset, project_name=project.name if project else None)
@router.post("/private-portrait/assets/{asset_id}/sync") @router.post("/private-portrait/assets/{asset_id}/sync", summary="同步真人认证素材状态")
async def sync_private_portrait_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): async def sync_private_portrait_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
asset = await sync_asset_status(db, user_id=current_user.id, asset_id=asset_id) asset = await sync_asset_status(db, user_id=current_user.id, asset_id=asset_id)
if asset.library_type != PrivatePortraitLibraryType.REAL_PERSON.value:
raise HTTPException(status_code=404, detail="私域人像素材不存在")
out = asset_to_out(asset) out = asset_to_out(asset)
await db.commit() await db.commit()
return out return out
@router.delete("/private-portrait/assets/{asset_id}", response_model=PrivatePortraitDeleteOut) @router.delete("/private-portrait/assets/{asset_id}", response_model=PrivatePortraitDeleteOut, summary="删除真人认证素材")
async def delete_private_portrait_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): async def delete_private_portrait_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
asset = await soft_delete_asset(db, user_id=current_user.id, asset_id=asset_id) asset = await soft_delete_asset(db, user_id=current_user.id, asset_id=asset_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
asset_id_snapshot = asset.id asset_id_snapshot = asset.id
project_id_snapshot = asset.project_id project_id_snapshot = asset.project_id
await db.commit() await db.commit()
@@ -227,7 +247,7 @@ async def delete_private_portrait_asset(asset_id: str, current_user: User = Depe
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value) return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
@router.get("/private-portrait/selectable-assets", response_model=PrivatePortraitSelectableAssetListOut) @router.get("/private-portrait/selectable-assets", response_model=PrivatePortraitSelectableAssetListOut, summary="查询可用于生成的真人认证素材")
async def list_private_portrait_selectable_assets(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), project_id: str | None = Query(None), keyword: str | None = Query(None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): async def list_private_portrait_selectable_assets(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), project_id: str | None = Query(None), keyword: str | None = Query(None), asset_type: str | None = Query(None, description="Image 或 Video,不传查全部。"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
items, total = await list_selectable_assets(db, user_id=current_user.id, project_id=project_id, keyword=keyword, page=page, page_size=page_size) items, total = await list_selectable_assets(db, user_id=current_user.id, project_id=project_id, keyword=keyword, page=page, page_size=page_size, library_type=PrivatePortraitLibraryType.REAL_PERSON.value, asset_type=asset_type)
return PrivatePortraitSelectableAssetListOut(items=items, total=total, page=page, page_size=page_size) return PrivatePortraitSelectableAssetListOut(items=items, total=total, page=page, page_size=page_size)
@@ -0,0 +1,179 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.enums.private_portrait import (
PrivatePortraitEventSource,
PrivatePortraitEventStatus,
PrivatePortraitEventType,
PrivatePortraitLibraryType,
PrivatePortraitProjectStatus,
PrivatePortraitRemoteDeleteStatus,
)
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitProject
from app.models.user import User
from app.schemas.private_portrait import (
PrivatePortraitAssetCreate,
PrivatePortraitAssetListOut,
PrivatePortraitAssetOut,
PrivatePortraitConfigOut,
PrivatePortraitDeleteOut,
PrivatePortraitEnumMetaOut,
PrivatePortraitProjectListOut,
PrivatePortraitProjectOut,
PrivatePortraitProjectUpdate,
PrivatePortraitSelectableAssetListOut,
PrivatePortraitVirtualProjectCreate,
build_private_portrait_enum_meta,
)
from app.services.operation_log_service import log_operation_error, log_operation_event
from app.services.private_portrait.asset_service import (
DOMAIN,
asset_to_out,
get_user_private_portrait_config,
list_assets,
list_selectable_assets,
soft_delete_asset,
sync_asset_status,
)
from app.services.private_portrait.project_service import (
get_user_project,
list_projects,
project_to_out,
refresh_project_counters,
soft_delete_project,
)
from app.services.private_portrait.virtual.service import create_virtual_asset, create_virtual_project, update_virtual_project
router = APIRouter(tags=["私域虚拟人像素材库"])
def _log_task_dispatch_failed(*, task_name: str, user_id: str | None = None, project_id: str | None = None, asset_id: str | None = None, exc: BaseException) -> None:
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.TASK_DISPATCH_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, asset_id=asset_id, exc=exc, detail={"task_name": task_name})
def _log_task_dispatch_success(*, task_name: str, user_id: str | None = None, project_id: str | None = None, asset_id: str | None = None) -> None:
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.TASK_DISPATCH_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, asset_id=asset_id, detail={"task_name": task_name})
@router.get("/private-portrait/virtual/config", response_model=PrivatePortraitConfigOut, summary="获取虚拟人像素材库额度配置", description="额度与真人认证素材库共用;图片/视频共用;Audio 暂不开放。")
async def get_my_virtual_private_portrait_config(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
return await get_user_private_portrait_config(db, user_id=current_user.id)
@router.get("/private-portrait/virtual-meta/enums", response_model=PrivatePortraitEnumMetaOut, summary="获取虚拟人像素材库枚举说明")
async def get_virtual_private_portrait_enum_meta():
return build_private_portrait_enum_meta()
@router.post("/private-portrait/virtual-projects", response_model=PrivatePortraitProjectOut, summary="创建虚拟人像项目组", description="创建本地虚拟人像项目,并同步调用火山 CreateAssetGroupGroupType=AIGC。ProjectName 必须与后续生成 API Key 所属项目一致,默认使用 default。")
async def create_private_portrait_virtual_project(payload: PrivatePortraitVirtualProjectCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await create_virtual_project(db, user_id=current_user.id, payload=payload)
out = project_to_out(project)
await db.commit()
return out
@router.get("/private-portrait/virtual-projects", response_model=PrivatePortraitProjectListOut, summary="查询当前用户虚拟人像项目列表")
async def list_private_portrait_virtual_projects(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), keyword: str | None = Query(None), status: str | None = Query(None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
query_status = status or PrivatePortraitProjectStatus.ACTIVE.value
items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=query_status, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
await refresh_project_counters(db, [item.id for item in items])
await db.commit()
items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=query_status, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
return PrivatePortraitProjectListOut(items=[project_to_out(item) for item in items], total=total, page=page, page_size=page_size)
@router.get("/private-portrait/virtual-projects/{project_id}", response_model=PrivatePortraitProjectOut, summary="获取虚拟人像项目详情")
async def get_private_portrait_virtual_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
return project_to_out(await get_user_project(db, user_id=current_user.id, project_id=project_id, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value))
@router.put("/private-portrait/virtual-projects/{project_id}", response_model=PrivatePortraitProjectOut, summary="更新虚拟人像项目")
async def update_private_portrait_virtual_project(project_id: str, payload: PrivatePortraitProjectUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await update_virtual_project(db, user_id=current_user.id, project_id=project_id, payload=payload)
out = project_to_out(project)
await db.commit()
return out
@router.delete("/private-portrait/virtual-projects/{project_id}", response_model=PrivatePortraitDeleteOut, summary="删除虚拟人像项目")
async def delete_private_portrait_virtual_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await soft_delete_project(db, user_id=current_user.id, project_id=project_id, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
project_id_snapshot = project.id
await db.commit()
try:
from app.tasks.private_portrait_asset_tasks import delete_private_portrait_project_remote
delete_private_portrait_project_remote.delay(project_id_snapshot)
_log_task_dispatch_success(task_name="private_portrait.delete_project_remote", user_id=current_user.id, project_id=project_id_snapshot)
except Exception as exc:
_log_task_dispatch_failed(task_name="private_portrait.delete_project_remote", user_id=current_user.id, project_id=project_id_snapshot, exc=exc)
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
@router.post("/private-portrait/virtual-projects/{project_id}/assets", response_model=PrivatePortraitAssetOut, summary="上传虚拟人像素材", description="当前支持 Image / Video。Audio 暂不开放。CreateAsset 是异步接口,返回后需要轮询到 Active 才可用于生成。")
async def create_private_portrait_virtual_asset(project_id: str, payload: PrivatePortraitAssetCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
asset = await create_virtual_asset(db, user_id=current_user.id, project_id=project_id, payload=payload)
asset_id_snapshot = asset.id
project_id_snapshot = asset.project_id
out = asset_to_out(asset)
await db.commit()
try:
from app.tasks.private_portrait_asset_tasks import poll_private_portrait_asset_status
poll_private_portrait_asset_status.delay(asset_id_snapshot)
_log_task_dispatch_success(task_name="private_portrait.poll_asset_status", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot)
except Exception as exc:
_log_task_dispatch_failed(task_name="private_portrait.poll_asset_status", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot, exc=exc)
return out
@router.get("/private-portrait/virtual-projects/{project_id}/assets", response_model=PrivatePortraitAssetListOut, summary="查询虚拟人像素材列表")
async def list_private_portrait_virtual_assets(project_id: str, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), status: str | None = Query(None), keyword: str | None = Query(None), asset_type: str | None = Query(None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
assets, total, project_name_map = await list_assets(db, user_id=current_user.id, project_id=project_id, status=status, keyword=keyword, page=page, page_size=page_size, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value, asset_type=asset_type)
return PrivatePortraitAssetListOut(items=[asset_to_out(asset, project_name=project_name_map.get(asset.project_id)) for asset in assets], total=total, page=page, page_size=page_size)
@router.get("/private-portrait/virtual-assets/{asset_id}", response_model=PrivatePortraitAssetOut, summary="获取虚拟人像素材详情")
async def get_private_portrait_virtual_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id, PrivatePortraitAsset.user_id == current_user.id, PrivatePortraitAsset.library_type == PrivatePortraitLibraryType.AIGC_VIRTUAL.value).limit(1))).scalar_one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="虚拟人像素材不存在")
project = (await db.execute(select(PrivatePortraitProject).where(PrivatePortraitProject.id == asset.project_id).limit(1))).scalar_one_or_none()
return asset_to_out(asset, project_name=project.name if project else None)
@router.post("/private-portrait/virtual-assets/{asset_id}/sync", response_model=PrivatePortraitAssetOut, summary="同步虚拟人像素材状态")
async def sync_private_portrait_virtual_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
asset = await sync_asset_status(db, user_id=current_user.id, asset_id=asset_id)
if asset.library_type != PrivatePortraitLibraryType.AIGC_VIRTUAL.value:
raise HTTPException(status_code=404, detail="虚拟人像素材不存在")
out = asset_to_out(asset)
await db.commit()
return out
@router.delete("/private-portrait/virtual-assets/{asset_id}", response_model=PrivatePortraitDeleteOut, summary="删除虚拟人像素材")
async def delete_private_portrait_virtual_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
asset = await soft_delete_asset(db, user_id=current_user.id, asset_id=asset_id, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
asset_id_snapshot = asset.id
project_id_snapshot = asset.project_id
await db.commit()
try:
from app.tasks.private_portrait_asset_tasks import delete_private_portrait_asset_remote
delete_private_portrait_asset_remote.delay(asset_id_snapshot)
_log_task_dispatch_success(task_name="private_portrait.delete_asset_remote", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot)
except Exception as exc:
_log_task_dispatch_failed(task_name="private_portrait.delete_asset_remote", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot, exc=exc)
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
@router.get("/private-portrait/virtual-selectable-assets", response_model=PrivatePortraitSelectableAssetListOut, summary="查询可用于生成的虚拟人像素材")
async def list_private_portrait_virtual_selectable_assets(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), project_id: str | None = Query(None), keyword: str | None = Query(None), asset_type: str | None = Query(None, description="Image 或 Video,不传查全部。"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
items, total = await list_selectable_assets(db, user_id=current_user.id, project_id=project_id, keyword=keyword, page=page, page_size=page_size, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value, asset_type=asset_type)
return PrivatePortraitSelectableAssetListOut(items=items, total=total, page=page, page_size=page_size)
+42 -11
View File
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import StreamingResponse from starlette.responses import StreamingResponse
from app.config import settings from app.config import settings
from app.dependencies import get_current_user, get_db from app.dependencies import get_current_user, get_db, get_optional_current_user
from app.models.team import Team from app.models.team import Team
from app.models.team_invitation import TeamInvitation from app.models.team_invitation import TeamInvitation
from app.models.team_join_request import TeamJoinRequest from app.models.team_join_request import TeamJoinRequest
@@ -37,11 +37,6 @@ from app.services.team_manager_service import (
router = APIRouter(prefix="/team", tags=["team"]) router = APIRouter(prefix="/team", tags=["team"])
def _build_invite_link(code: str) -> str:
base = getattr(settings, "FRONTEND_URL", "") or getattr(settings, "BASE_URL", "")
return f"{base}/join-team?code={code}"
# ── 获取当前用户管理的团队 ────────────────────────────── # ── 获取当前用户管理的团队 ──────────────────────────────
@router.get("/managed") @router.get("/managed")
async def get_managed_team_info( async def get_managed_team_info(
@@ -135,7 +130,6 @@ async def create_invitation(
"max_uses": invitation.max_uses, "max_uses": invitation.max_uses,
"use_count": invitation.use_count, "use_count": invitation.use_count,
"expires_at": invitation.expires_at, "expires_at": invitation.expires_at,
"invite_link": _build_invite_link(invitation.code),
"created_at": invitation.created_at, "created_at": invitation.created_at,
} }
@@ -158,7 +152,6 @@ async def list_invitations(
"max_uses": inv.max_uses, "max_uses": inv.max_uses,
"use_count": inv.use_count, "use_count": inv.use_count,
"expires_at": inv.expires_at, "expires_at": inv.expires_at,
"invite_link": _build_invite_link(inv.code),
"created_at": inv.created_at, "created_at": inv.created_at,
} }
for inv in invitations for inv in invitations
@@ -189,29 +182,67 @@ async def join_by_code(
@router.get("/join-info", ) @router.get("/join-info", )
async def get_join_info( async def get_join_info(
code: str = Query(...), code: str = Query(...),
current_user: User = Depends(get_current_user), current_user: User | None = Depends(get_optional_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""验证邀请码并返回团队信息(用于加入页面展示)。""" """验证邀请码并返回团队信息(用于加入页面展示)。"""
invitation = await team_invitation_service.get_invitation_by_code(db, code) invitation = await team_invitation_service.get_invitation_by_code(db, code)
if not invitation: if not invitation:
return JoinTeamInfoOut(team_name="", team_id="", valid=False) return JoinTeamInfoOut(team_name="", team_id="", valid=False, already_in_team=False, has_pending_request=False)
team = await db.execute( team = await db.execute(
select(Team.name).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1) select(Team.name).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
) )
team_name = team.scalar_one_or_none() or "" team_name = team.scalar_one_or_none() or ""
already_in_team = current_user.team_id == invitation.team_id already_in_team = current_user and current_user.team_id == invitation.team_id
has_pending_request = False
if current_user:
from app.models.team_join_request import TeamJoinRequest
pending = await db.execute(
select(TeamJoinRequest).where(
TeamJoinRequest.user_id == current_user.id,
TeamJoinRequest.team_id == invitation.team_id,
TeamJoinRequest.status == "pending",
).limit(1)
)
has_pending = pending.scalar_one_or_none()
has_pending_request = has_pending is not None
return JoinTeamInfoOut( return JoinTeamInfoOut(
team_name=team_name, team_name=team_name,
team_id=invitation.team_id, team_id=invitation.team_id,
valid=True, valid=True,
already_in_team=already_in_team, already_in_team=already_in_team,
has_pending_request=has_pending_request,
) )
@router.get("/join-info/public", )
async def get_join_info_public(
code: str = Query(...),
db: AsyncSession = Depends(get_db),
):
"""公开接口:验证邀请码并返回团队信息(无需登录)。"""
invitation = await team_invitation_service.get_invitation_by_code(db, code)
if not invitation:
return {"team_name": "", "team_id": "", "valid": False, "already_in_team": False, "has_pending_request": False}
team = await db.execute(
select(Team.name).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
)
team_name = team.scalar_one_or_none() or ""
return {
"team_name": team_name,
"team_id": invitation.team_id,
"valid": True,
"already_in_team": False,
"has_pending_request": False,
}
@router.get("/join-requests", ) @router.get("/join-requests", )
async def list_join_requests( async def list_join_requests(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
+4
View File
@@ -37,6 +37,10 @@ class Settings(BaseSettings):
SMS_TEMPLATE_CODE: str = "SMS_001" SMS_TEMPLATE_CODE: str = "SMS_001"
SMS_MOCK: bool = True SMS_MOCK: bool = True
# 火山引擎配置。
VOLC_ACCESS_KEY_ID: str = ""
VOLC_SECRET_ACCESS_KEY: str = ""
# 火山引擎短信配置。 # 火山引擎短信配置。
# SmsAccount=消息组IDTemplateID=模板IDSign=短信签名内容。 # SmsAccount=消息组IDTemplateID=模板IDSign=短信签名内容。
VOLC_SMS_ACCESS_KEY_ID: str = "" VOLC_SMS_ACCESS_KEY_ID: str = ""
+25
View File
@@ -70,6 +70,31 @@ async def get_current_user(
return current_user return current_user
async def get_optional_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_db),
) -> User | None:
if not credentials:
return None
user_id = decode_access_token(credentials.credentials)
if not user_id:
return None
if user_id.startswith("captcha:"):
return None
result = await db.execute(select(User).where(User.id == user_id).limit(1))
user = result.scalar_one_or_none()
if not user or not user.is_active:
return None
if user_must_set_password(user):
return None
return user
async def get_admin_user( async def get_admin_user(
current_user: User = Depends(get_current_user_allow_password_pending), current_user: User = Depends(get_current_user_allow_password_pending),
) -> User: ) -> User:
+39 -6
View File
@@ -2,14 +2,18 @@ from __future__ import annotations
from enum import Enum from enum import Enum
# 用户真人素材图片默认上限。users.private_portrait_image_limit = 0 表示关闭模块;>0 表示启用并限制总量。 # 用户私域人像素材默认上限。users.private_portrait_asset_limit = 0 表示关闭模块;>0 表示启用并限制总素材量。
PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT = 5 # 统计口径:真人 + 虚拟;图片 + 视频。音频当前业务暂不开放。
PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT = 5
# 火山 Ark 私域真人素材 ProjectName:火山侧项目空间固定使用 default,并快照到各业务表 remote_project_name。 # 火山 Ark 私域素材 ProjectName:火山侧项目空间固定使用 default,并快照到各业务表 remote_project_name。
# 用户/项目隔离依赖本地 project_id 和火山返回的 Asset Group ID,不再动态拼接 ProjectName。 # 用户/项目隔离依赖本地 project_id 和火山返回的 Asset Group ID,不再动态拼接 ProjectName。
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME = "default" PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME = "default"
PRIVATE_PORTRAIT_GROUP_TYPE = "LivenessFace" PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE = "LivenessFace"
PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE = "AIGC"
# 兼容旧代码导入,默认代表真人认证素材组类型。
PRIVATE_PORTRAIT_GROUP_TYPE = PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE
PRIVATE_PORTRAIT_VERIFY_TYPE = "real_time" PRIVATE_PORTRAIT_VERIFY_TYPE = "real_time"
PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE = "10000" PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE = "10000"
PRIVATE_PORTRAIT_ASSET_URI_PREFIX = "asset://" PRIVATE_PORTRAIT_ASSET_URI_PREFIX = "asset://"
@@ -20,7 +24,9 @@ ARK_PRIVATE_PORTRAIT_REGION = "cn-beijing"
ARK_PRIVATE_PORTRAIT_HOST = "ark.cn-beijing.volcengineapi.com" ARK_PRIVATE_PORTRAIT_HOST = "ark.cn-beijing.volcengineapi.com"
PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS = 20 PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS = 20
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_INTERVAL_SECONDS = 30
PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT = 60 PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT = 60
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_MAX_COUNT = 120
PRIVATE_PORTRAIT_ASSET_POLL_BATCH_SIZE = 50 PRIVATE_PORTRAIT_ASSET_POLL_BATCH_SIZE = 50
PRIVATE_PORTRAIT_REMOTE_DELETE_RECOVERY_BATCH_SIZE = 50 PRIVATE_PORTRAIT_REMOTE_DELETE_RECOVERY_BATCH_SIZE = 50
PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES = 30 PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES = 30
@@ -29,6 +35,7 @@ PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES = 30
PRIVATE_PORTRAIT_ACTION_QPS_LIMITS: dict[str, int] = { PRIVATE_PORTRAIT_ACTION_QPS_LIMITS: dict[str, int] = {
"CreateVisualValidateSession": 3, "CreateVisualValidateSession": 3,
"GetVisualValidateResult": 3, "GetVisualValidateResult": 3,
"CreateAssetGroup": 10,
"CreateAsset": 1, "CreateAsset": 1,
"ListAssetGroups": 10, "ListAssetGroups": 10,
"ListAssets": 10, "ListAssets": 10,
@@ -44,6 +51,7 @@ PRIVATE_PORTRAIT_ACTION_QPS_LIMITS: dict[str, int] = {
class ArkPrivatePortraitAction(str, Enum): class ArkPrivatePortraitAction(str, Enum):
CREATE_VISUAL_VALIDATE_SESSION = "CreateVisualValidateSession" CREATE_VISUAL_VALIDATE_SESSION = "CreateVisualValidateSession"
GET_VISUAL_VALIDATE_RESULT = "GetVisualValidateResult" GET_VISUAL_VALIDATE_RESULT = "GetVisualValidateResult"
CREATE_ASSET_GROUP = "CreateAssetGroup"
CREATE_ASSET = "CreateAsset" CREATE_ASSET = "CreateAsset"
GET_ASSET = "GetAsset" GET_ASSET = "GetAsset"
LIST_ASSETS = "ListAssets" LIST_ASSETS = "ListAssets"
@@ -55,8 +63,17 @@ class ArkPrivatePortraitAction(str, Enum):
DELETE_ASSET_GROUP = "DeleteAssetGroup" DELETE_ASSET_GROUP = "DeleteAssetGroup"
class PrivatePortraitLibraryType(str, Enum):
REAL_PERSON = "real_person"
AIGC_VIRTUAL = "aigc_virtual"
class PrivatePortraitProjectStatus(str, Enum): class PrivatePortraitProjectStatus(str, Enum):
VALIDATING = "validating"
ACTIVE = "active" ACTIVE = "active"
VALIDATE_FAILED = "validate_failed"
CREATING_REMOTE_GROUP = "creating_remote_group"
CREATE_GROUP_FAILED = "create_group_failed"
DELETED = "deleted" DELETED = "deleted"
@@ -70,6 +87,7 @@ class PrivatePortraitValidateSessionStatus(str, Enum):
class PrivatePortraitAssetGroupStatus(str, Enum): class PrivatePortraitAssetGroupStatus(str, Enum):
CREATING = "creating"
ACTIVE = "active" ACTIVE = "active"
LOCAL_DELETED = "local_deleted" LOCAL_DELETED = "local_deleted"
REMOTE_DELETED = "remote_deleted" REMOTE_DELETED = "remote_deleted"
@@ -90,7 +108,13 @@ class PrivatePortraitAssetStatus(str, Enum):
class PrivatePortraitAssetType(str, Enum): class PrivatePortraitAssetType(str, Enum):
IMAGE = "Image" IMAGE = "Image"
VIDEO = "Video" VIDEO = "Video"
AUDIO = "Audio" AUDIO = "Audio" # 火山支持,但当前业务暂不开放。
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES = {
PrivatePortraitAssetType.IMAGE.value,
PrivatePortraitAssetType.VIDEO.value,
}
class PrivatePortraitRemoteDeleteStatus(str, Enum): class PrivatePortraitRemoteDeleteStatus(str, Enum):
@@ -123,9 +147,19 @@ class PrivatePortraitEventSource(str, Enum):
class PrivatePortraitEventType(str, Enum): class PrivatePortraitEventType(str, Enum):
QUOTA_CHECK_START = "QUOTA_CHECK_START"
QUOTA_CHECK_PASS = "QUOTA_CHECK_PASS"
QUOTA_CHECK_DENY = "QUOTA_CHECK_DENY"
PROJECT_CREATE = "PROJECT_CREATE" PROJECT_CREATE = "PROJECT_CREATE"
PROJECT_UPDATE = "PROJECT_UPDATE" PROJECT_UPDATE = "PROJECT_UPDATE"
PROJECT_DELETE = "PROJECT_DELETE" PROJECT_DELETE = "PROJECT_DELETE"
VIRTUAL_PROJECT_CREATE_START = "VIRTUAL_PROJECT_CREATE_START"
VIRTUAL_ASSET_GROUP_CREATE_REMOTE_START = "VIRTUAL_ASSET_GROUP_CREATE_REMOTE_START"
VIRTUAL_ASSET_GROUP_CREATE_REMOTE_SUCCESS = "VIRTUAL_ASSET_GROUP_CREATE_REMOTE_SUCCESS"
VIRTUAL_ASSET_GROUP_CREATE_REMOTE_FAILED = "VIRTUAL_ASSET_GROUP_CREATE_REMOTE_FAILED"
VALIDATE_SESSION_CREATE = "VALIDATE_SESSION_CREATE" VALIDATE_SESSION_CREATE = "VALIDATE_SESSION_CREATE"
VALIDATE_SESSION_CREATE_FAILED = "VALIDATE_SESSION_CREATE_FAILED" VALIDATE_SESSION_CREATE_FAILED = "VALIDATE_SESSION_CREATE_FAILED"
VALIDATE_CALLBACK_RECEIVED = "VALIDATE_CALLBACK_RECEIVED" VALIDATE_CALLBACK_RECEIVED = "VALIDATE_CALLBACK_RECEIVED"
@@ -161,7 +195,6 @@ class PrivatePortraitEventType(str, Enum):
PROJECT_DELETE_REMOTE_SUCCESS = "PROJECT_DELETE_REMOTE_SUCCESS" PROJECT_DELETE_REMOTE_SUCCESS = "PROJECT_DELETE_REMOTE_SUCCESS"
PROJECT_DELETE_REMOTE_FAILED = "PROJECT_DELETE_REMOTE_FAILED" PROJECT_DELETE_REMOTE_FAILED = "PROJECT_DELETE_REMOTE_FAILED"
TASK_DISPATCH_SUCCESS = "TASK_DISPATCH_SUCCESS" TASK_DISPATCH_SUCCESS = "TASK_DISPATCH_SUCCESS"
TASK_DISPATCH_FAILED = "TASK_DISPATCH_FAILED" TASK_DISPATCH_FAILED = "TASK_DISPATCH_FAILED"
@@ -2,12 +2,13 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.enums.private_portrait import ( from app.enums.private_portrait import (
PrivatePortraitAssetStatus, PrivatePortraitAssetStatus,
PrivatePortraitAssetType, PrivatePortraitAssetType,
PrivatePortraitLibraryType,
PrivatePortraitRemoteDeleteStatus, PrivatePortraitRemoteDeleteStatus,
) )
from app.models.base import Base, SoftDeleteMixin, TimestampMixin from app.models.base import Base, SoftDeleteMixin, TimestampMixin
@@ -19,10 +20,13 @@ class PrivatePortraitAsset(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "private_portrait_assets" __tablename__ = "private_portrait_assets"
__table_args__ = ( __table_args__ = (
Index("uq_private_portrait_assets_remote_asset_id", "remote_asset_id", unique=True), Index("uq_private_portrait_assets_remote_asset_id", "remote_asset_id", unique=True),
Index("idx_private_portrait_assets_user_library_status_created", "user_id", "library_type", "status", "created_at"),
Index("idx_private_portrait_assets_user_status_created", "user_id", "status", "created_at"), Index("idx_private_portrait_assets_user_status_created", "user_id", "status", "created_at"),
Index("idx_private_portrait_assets_project_status_created", "project_id", "status", "created_at"), Index("idx_private_portrait_assets_project_status_created", "project_id", "status", "created_at"),
Index("idx_private_portrait_assets_group_status_created", "group_id", "status", "created_at"), Index("idx_private_portrait_assets_group_status_created", "group_id", "status", "created_at"),
Index("idx_private_portrait_assets_remote_project_name", "remote_project_name"), Index("idx_private_portrait_assets_remote_project_name", "remote_project_name"),
Index("idx_private_portrait_assets_library_type", "library_type"),
Index("idx_private_portrait_assets_asset_type", "asset_type"),
Index( Index(
"idx_private_portrait_assets_next_poll_status", "idx_private_portrait_assets_next_poll_status",
"next_poll_at", "next_poll_at",
@@ -36,6 +40,13 @@ class PrivatePortraitAsset(Base, TimestampMixin, SoftDeleteMixin):
user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
project_id: Mapped[str] = mapped_column(String(32), ForeignKey("private_portrait_projects.id", ondelete="CASCADE"), nullable=False, index=True) project_id: Mapped[str] = mapped_column(String(32), ForeignKey("private_portrait_projects.id", ondelete="CASCADE"), nullable=False, index=True)
group_id: Mapped[str] = mapped_column(String(32), ForeignKey("private_portrait_asset_groups.id", ondelete="CASCADE"), nullable=False, index=True) group_id: Mapped[str] = mapped_column(String(32), ForeignKey("private_portrait_asset_groups.id", ondelete="CASCADE"), nullable=False, index=True)
library_type: Mapped[str] = mapped_column(
String(32),
nullable=False,
default=PrivatePortraitLibraryType.REAL_PERSON.value,
server_default=PrivatePortraitLibraryType.REAL_PERSON.value,
index=True,
)
remote_group_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True) remote_group_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
remote_asset_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) remote_asset_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
remote_project_name: Mapped[str] = mapped_column(String(256), nullable=False, index=True) remote_project_name: Mapped[str] = mapped_column(String(256), nullable=False, index=True)
@@ -45,6 +56,10 @@ class PrivatePortraitAsset(Base, TimestampMixin, SoftDeleteMixin):
preview_url: Mapped[str | None] = mapped_column(Text, nullable=True) preview_url: Mapped[str | None] = mapped_column(Text, nullable=True)
remote_url: Mapped[str | None] = mapped_column(Text, nullable=True) remote_url: Mapped[str | None] = mapped_column(Text, nullable=True)
remote_url_expired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) remote_url_expired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
video_duration: Mapped[float | None] = mapped_column(Float, nullable=True, comment="视频素材时长,秒")
video_cover_url: Mapped[str | None] = mapped_column(Text, nullable=True, comment="视频素材封面预览地址")
file_size: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="素材文件大小,字节")
mime_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
status: Mapped[str] = mapped_column( status: Mapped[str] = mapped_column(
String(32), String(32),
nullable=False, nullable=False,
@@ -2,12 +2,13 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, Text from sqlalchemy import DateTime, ForeignKey, Index, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.enums.private_portrait import ( from app.enums.private_portrait import (
PRIVATE_PORTRAIT_GROUP_TYPE, PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE,
PrivatePortraitAssetGroupStatus, PrivatePortraitAssetGroupStatus,
PrivatePortraitLibraryType,
PrivatePortraitRemoteDeleteStatus, PrivatePortraitRemoteDeleteStatus,
) )
from app.models.base import Base, SoftDeleteMixin, TimestampMixin from app.models.base import Base, SoftDeleteMixin, TimestampMixin
@@ -20,18 +21,33 @@ class PrivatePortraitAssetGroup(Base, TimestampMixin, SoftDeleteMixin):
__table_args__ = ( __table_args__ = (
Index("uq_private_portrait_asset_groups_remote_group_id", "remote_group_id", unique=True), Index("uq_private_portrait_asset_groups_remote_group_id", "remote_group_id", unique=True),
Index("idx_private_portrait_asset_groups_user_project", "user_id", "project_id"), Index("idx_private_portrait_asset_groups_user_project", "user_id", "project_id"),
Index("idx_private_portrait_asset_groups_user_library", "user_id", "library_type"),
Index("idx_private_portrait_asset_groups_project_status", "project_id", "status"), Index("idx_private_portrait_asset_groups_project_status", "project_id", "status"),
Index("idx_private_portrait_asset_groups_remote_delete_status", "remote_delete_status"), Index("idx_private_portrait_asset_groups_remote_delete_status", "remote_delete_status"),
Index("idx_private_portrait_asset_groups_remote_project_name", "remote_project_name"), Index("idx_private_portrait_asset_groups_remote_project_name", "remote_project_name"),
Index("idx_private_portrait_asset_groups_library_type", "library_type"),
Index(
"uq_private_portrait_asset_groups_one_active_project",
"project_id",
unique=True,
postgresql_where=text("deleted_at IS NULL AND status = 'active'"),
),
) )
id: Mapped[str] = mapped_column(String(32), primary_key=True) id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
project_id: Mapped[str] = mapped_column(String(32), ForeignKey("private_portrait_projects.id", ondelete="CASCADE"), nullable=False, index=True) project_id: Mapped[str] = mapped_column(String(32), ForeignKey("private_portrait_projects.id", ondelete="CASCADE"), nullable=False, index=True)
library_type: Mapped[str] = mapped_column(
String(32),
nullable=False,
default=PrivatePortraitLibraryType.REAL_PERSON.value,
server_default=PrivatePortraitLibraryType.REAL_PERSON.value,
index=True,
)
remote_group_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True) remote_group_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
remote_group_name: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) remote_group_name: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
remote_project_name: Mapped[str] = mapped_column(String(256), nullable=False, index=True) remote_project_name: Mapped[str] = mapped_column(String(256), nullable=False, index=True)
group_type: Mapped[str] = mapped_column(String(32), nullable=False, default=PRIVATE_PORTRAIT_GROUP_TYPE, server_default=PRIVATE_PORTRAIT_GROUP_TYPE) group_type: Mapped[str] = mapped_column(String(32), nullable=False, default=PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE, server_default=PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE)
status: Mapped[str] = mapped_column( status: Mapped[str] = mapped_column(
String(32), String(32),
nullable=False, nullable=False,
@@ -5,15 +5,16 @@ from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.enums.private_portrait import PrivatePortraitProjectStatus from app.enums.private_portrait import PrivatePortraitLibraryType, PrivatePortraitProjectStatus
from app.models.base import Base, SoftDeleteMixin, TimestampMixin from app.models.base import Base, SoftDeleteMixin, TimestampMixin
class PrivatePortraitProject(Base, TimestampMixin, SoftDeleteMixin): class PrivatePortraitProject(Base, TimestampMixin, SoftDeleteMixin):
"""用户本地真人素材项目组。remote_project_name 是火山 ProjectName 快照。""" """用户本地私域人像素材项目组。remote_project_name 是火山 ProjectName 快照。"""
__tablename__ = "private_portrait_projects" __tablename__ = "private_portrait_projects"
__table_args__ = ( __table_args__ = (
Index("idx_private_portrait_projects_user_library_status_created", "user_id", "library_type", "status", "created_at"),
Index("idx_private_portrait_projects_user_status_created", "user_id", "status", "created_at"), Index("idx_private_portrait_projects_user_status_created", "user_id", "status", "created_at"),
Index( Index(
"idx_private_portrait_projects_user_deleted", "idx_private_portrait_projects_user_deleted",
@@ -22,10 +23,19 @@ class PrivatePortraitProject(Base, TimestampMixin, SoftDeleteMixin):
postgresql_where=text("deleted_at IS NULL"), postgresql_where=text("deleted_at IS NULL"),
), ),
Index("idx_private_portrait_projects_remote_project_name", "remote_project_name"), Index("idx_private_portrait_projects_remote_project_name", "remote_project_name"),
Index("idx_private_portrait_projects_library_type", "library_type"),
) )
id: Mapped[str] = mapped_column(String(32), primary_key=True) id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
library_type: Mapped[str] = mapped_column(
String(32),
nullable=False,
default=PrivatePortraitLibraryType.REAL_PERSON.value,
server_default=PrivatePortraitLibraryType.REAL_PERSON.value,
index=True,
comment="素材库类型:real_person 真人认证;aigc_virtual 虚拟人像",
)
name: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="用户展示项目名") name: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="用户展示项目名")
name_slug: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="项目名安全 slug") name_slug: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="项目名安全 slug")
remote_project_name: Mapped[str] = mapped_column(String(256), nullable=False, index=True, comment="火山 ProjectName 快照") remote_project_name: Mapped[str] = mapped_column(String(256), nullable=False, index=True, comment="火山 ProjectName 快照")
@@ -39,5 +49,9 @@ class PrivatePortraitProject(Base, TimestampMixin, SoftDeleteMixin):
) )
asset_group_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") asset_group_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
image_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
video_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
active_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") active_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
active_image_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
active_video_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, Text from sqlalchemy import DateTime, ForeignKey, Index, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.enums.private_portrait import PrivatePortraitValidateSessionStatus from app.enums.private_portrait import PrivatePortraitValidateSessionStatus
@@ -17,6 +17,12 @@ class PrivatePortraitValidateSession(Base, TimestampMixin):
Index("idx_private_portrait_validate_sessions_user_project", "user_id", "project_id"), Index("idx_private_portrait_validate_sessions_user_project", "user_id", "project_id"),
Index("idx_private_portrait_validate_sessions_byted_token", "byted_token"), Index("idx_private_portrait_validate_sessions_byted_token", "byted_token"),
Index("idx_private_portrait_validate_sessions_status_created", "status", "created_at"), Index("idx_private_portrait_validate_sessions_status_created", "status", "created_at"),
Index(
"uq_private_portrait_validate_sessions_one_group_active_project",
"project_id",
unique=True,
postgresql_where=text("status = 'group_active'"),
),
) )
id: Mapped[str] = mapped_column(String(32), primary_key=True) id: Mapped[str] = mapped_column(String(32), primary_key=True)
+2 -2
View File
@@ -39,8 +39,8 @@ class User(Base, TimestampMixin):
) )
allowed_menus: Mapped[list | None] = mapped_column(JSON, nullable=True) allowed_menus: Mapped[list | None] = mapped_column(JSON, nullable=True)
# 真人素材库图片总量限制。0 表示关闭真人素材模块;>0 表示启用并限制用户所有真人素材图片总量。 # 私域人像素材总量限制。0 表示关闭模块;>0 表示启用并限制真人/虚拟、图片/视频素材总量。
private_portrait_image_limit: Mapped[int] = mapped_column( private_portrait_asset_limit: Mapped[int] = mapped_column(
Integer, default=5, server_default="5", nullable=False Integer, default=5, server_default="5", nullable=False
) )
+2 -2
View File
@@ -58,7 +58,7 @@ class AdminUserOut(BaseModel):
last_login_at: NaiveDatetimeOptional = None last_login_at: NaiveDatetimeOptional = None
allowed_menus: list | None = None allowed_menus: list | None = None
resource_capacity: ResourceCapacityUsageOut | None = None resource_capacity: ResourceCapacityUsageOut | None = None
private_portrait_image_limit: int = 5 private_portrait_asset_limit: int = 5
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -72,7 +72,7 @@ class CreateUserRequest(BaseModel):
user_type: str = Field(default="frontend", pattern="^(frontend|admin)$") user_type: str = Field(default="frontend", pattern="^(frontend|admin)$")
frontend_user_kind: str = Field(default="external", pattern="^(internal|external)$") frontend_user_kind: str = Field(default="external", pattern="^(internal|external)$")
allowed_menus: list | None = None allowed_menus: list | None = None
private_portrait_image_limit: int = Field(default=5, ge=0, le=9999) private_portrait_asset_limit: int = Field(default=5, ge=0, le=9999, description="私域人像素材总量限制,真人/虚拟、图片/视频共用,0 表示关闭")
class UpdateFrontendUserKindRequest(BaseModel): class UpdateFrontendUserKindRequest(BaseModel):
@@ -56,6 +56,21 @@ class GenerationAIReference(BaseModel):
description="后端回填的火山 Asset ID。前端传入时不可信,创建任务时以后端查库为准", description="后端回填的火山 Asset ID。前端传入时不可信,创建任务时以后端查库为准",
examples=["asset-20260318071009-xxxxx"], examples=["asset-20260318071009-xxxxx"],
) )
provider_url: str | None = Field(
None,
description="供应商专用素材地址。真人素材生成时通常为 asset://remote_asset_id;仅用于后端排查和生成链路,不用于前端预览",
examples=["asset://asset-20260318071009-xxxxx"],
)
display_url: str | None = Field(
None,
description="前端展示用素材地址。真人素材历史响应会回填为可预览图片/视频/音频 URL",
examples=["/uploads/images/2026/07/06/demo.jpg"],
)
preview_url: str | None = Field(
None,
description="前端预览用素材地址;通常与 display_url 一致",
examples=["/uploads/images/2026/07/06/demo.jpg"],
)
class GenerationAITaskCreate(BaseModel): class GenerationAITaskCreate(BaseModel):
+107 -18
View File
@@ -4,35 +4,67 @@ from typing import Any
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from app.enums.private_portrait import PrivatePortraitAssetType from app.enums.private_portrait import (
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
PrivatePortraitAssetStatus,
PrivatePortraitAssetType,
PrivatePortraitLibraryType,
PrivatePortraitProjectStatus,
)
from app.schemas.common import NaiveDatetimeOptional from app.schemas.common import NaiveDatetimeOptional
class PrivatePortraitEnumItem(BaseModel):
value: str
label: str
description: str | None = None
class PrivatePortraitEnumMetaOut(BaseModel):
library_types: list[PrivatePortraitEnumItem]
asset_types: list[PrivatePortraitEnumItem]
project_statuses: list[PrivatePortraitEnumItem]
asset_statuses: list[PrivatePortraitEnumItem]
class PrivatePortraitConfigOut(BaseModel): class PrivatePortraitConfigOut(BaseModel):
enabled: bool enabled: bool = Field(..., description="是否启用私域人像素材库。asset_limit > 0 表示启用。")
image_limit: int asset_limit: int = Field(..., description="私域人像素材总量限制:真人/虚拟共用,图片/视频共用,0 表示关闭。")
used_image_count: int used_asset_count: int = Field(..., description="当前占用额度的素材数量。统计 creating/Processing/Active 的 Image/Video。")
remaining_image_count: int remaining_asset_count: int = Field(..., description="剩余可上传素材数量。")
supported_asset_types: list[str] = Field(default_factory=lambda: [PrivatePortraitAssetType.IMAGE.value, PrivatePortraitAssetType.VIDEO.value], description="当前业务开放的素材类型。")
unsupported_asset_types: list[str] = Field(default_factory=lambda: [PrivatePortraitAssetType.AUDIO.value], description="火山支持但当前业务暂不开放的素材类型。")
# 兼容旧前端,后续确认无引用后可移除。
image_limit: int | None = Field(None, description="兼容旧字段:请改用 asset_limit。")
used_image_count: int | None = Field(None, description="兼容旧字段:请改用 used_asset_count。")
remaining_image_count: int | None = Field(None, description="兼容旧字段:请改用 remaining_asset_count。")
class PrivatePortraitAdminConfigUpdate(BaseModel): class PrivatePortraitAdminConfigUpdate(BaseModel):
private_portrait_image_limit: int = Field(..., ge=0, le=9999, description="0 表示关闭真人素材模块;>0 表示启用并限制图片总量") private_portrait_asset_limit: int = Field(..., ge=0, le=9999, description="私域人像素材总量限制。0 表示关闭;>0 表示启用并限制真人/虚拟、图片/视频素材总量")
class PrivatePortraitProjectCreate(BaseModel): class PrivatePortraitProjectCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=128) name: str = Field(..., min_length=1, max_length=128, description="项目组名称。")
description: str | None = Field(None, max_length=2000) description: str | None = Field(None, max_length=2000, description="项目组描述。")
callback_redirect_url: str | None = Field(None, description="仅真人认证使用:认证完成后的手机端提示页地址。")
class PrivatePortraitVirtualProjectCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=128, description="虚拟人像素材项目组名称。创建后会同步创建火山 Asset Group。")
description: str | None = Field(None, max_length=2000, description="虚拟人像素材项目组描述,会同步到火山 Asset Group。")
class PrivatePortraitProjectUpdate(BaseModel): class PrivatePortraitProjectUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=128) name: str | None = Field(None, min_length=1, max_length=128, description="项目组名称。")
description: str | None = Field(None, max_length=2000) description: str | None = Field(None, max_length=2000, description="项目组描述。")
status: str | None = None status: str | None = Field(None, description="项目状态。普通前端不建议手动变更,仅管理/排查使用。")
class PrivatePortraitProjectOut(BaseModel): class PrivatePortraitProjectOut(BaseModel):
id: str id: str
user_id: str | None = None user_id: str | None = None
library_type: str = Field(default=PrivatePortraitLibraryType.REAL_PERSON.value)
name: str name: str
name_slug: str | None = None name_slug: str | None = None
remote_project_name: str | None = None remote_project_name: str | None = None
@@ -40,7 +72,11 @@ class PrivatePortraitProjectOut(BaseModel):
status: str status: str
asset_group_count: int = 0 asset_group_count: int = 0
asset_count: int = 0 asset_count: int = 0
image_asset_count: int = 0
video_asset_count: int = 0
active_asset_count: int = 0 active_asset_count: int = 0
active_image_asset_count: int = 0
active_video_asset_count: int = 0
last_used_at: NaiveDatetimeOptional = None last_used_at: NaiveDatetimeOptional = None
created_at: NaiveDatetimeOptional = None created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None updated_at: NaiveDatetimeOptional = None
@@ -80,10 +116,17 @@ class PrivatePortraitValidateSessionOut(BaseModel):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class PrivatePortraitProjectCreateWithValidateOut(BaseModel):
project: PrivatePortraitProjectOut
validate_session: PrivatePortraitValidateSessionOut
poll_interval_ms: int = Field(default=2000, description="PC 端轮询认证状态的建议间隔,单位毫秒。")
class PrivatePortraitAssetGroupOut(BaseModel): class PrivatePortraitAssetGroupOut(BaseModel):
id: str id: str
user_id: str | None = None user_id: str | None = None
project_id: str project_id: str
library_type: str
remote_group_id: str remote_group_id: str
remote_group_name: str | None = None remote_group_name: str | None = None
remote_project_name: str remote_project_name: str
@@ -99,16 +142,22 @@ class PrivatePortraitAssetGroupOut(BaseModel):
class PrivatePortraitAssetCreate(BaseModel): class PrivatePortraitAssetCreate(BaseModel):
url: str = Field(..., min_length=1, description="已上传到本系统且可公网访问的素材 URL") url: str = Field(..., min_length=1, description="已上传到本系统且可公网访问的素材 URL。支持图片/视频,后端会转换公网地址后调用火山 CreateAsset。")
asset_type: str = Field(default=PrivatePortraitAssetType.IMAGE.value) asset_type: str = Field(default=PrivatePortraitAssetType.IMAGE.value, description="素材类型。当前业务仅开放 Image / VideoAudio 暂不开放。")
name: str | None = Field(None, max_length=256) name: str | None = Field(None, max_length=256, description="素材名称,仅用于检索和管理。")
video_duration: float | None = Field(None, ge=0, description="视频素材时长,单位秒。图片可为空。")
video_cover_url: str | None = Field(None, description="视频封面预览地址。图片可为空。")
file_size: int | None = Field(None, ge=0, description="文件大小,字节。")
mime_type: str | None = Field(None, max_length=128, description="素材 MIME 类型。")
@field_validator("asset_type") @field_validator("asset_type")
@classmethod @classmethod
def validate_asset_type(cls, v: str) -> str: def validate_asset_type(cls, v: str) -> str:
value = v or PrivatePortraitAssetType.IMAGE.value value = v or PrivatePortraitAssetType.IMAGE.value
if value not in {item.value for item in PrivatePortraitAssetType}: if value not in {item.value for item in PrivatePortraitAssetType}:
raise ValueError("asset_type 仅支持 Image/Video/Audio") raise ValueError("asset_type 仅支持 Image/VideoAudio 暂未开放")
if value not in PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES:
raise ValueError("Audio 暂未开放,当前仅支持 Image/Video")
return value return value
@@ -118,6 +167,7 @@ class PrivatePortraitAssetOut(BaseModel):
project_id: str project_id: str
project_name: str | None = None project_name: str | None = None
group_id: str group_id: str
library_type: str
remote_group_id: str remote_group_id: str
remote_asset_id: str | None = None remote_asset_id: str | None = None
remote_project_name: str | None = None remote_project_name: str | None = None
@@ -125,10 +175,16 @@ class PrivatePortraitAssetOut(BaseModel):
name: str | None = None name: str | None = None
source_url: str source_url: str
preview_url: str | None = None preview_url: str | None = None
display_url: str | None = None
provider_url: str | None = None
remote_url: str | None = None remote_url: str | None = None
remote_url_expired_at: NaiveDatetimeOptional = None remote_url_expired_at: NaiveDatetimeOptional = None
video_duration: float | None = None
video_cover_url: str | None = None
file_size: int | None = None
mime_type: str | None = None
status: str status: str
moderation: dict[str, Any] | None = None moderation: Any = None
last_poll_at: NaiveDatetimeOptional = None last_poll_at: NaiveDatetimeOptional = None
next_poll_at: NaiveDatetimeOptional = None next_poll_at: NaiveDatetimeOptional = None
poll_count: int = 0 poll_count: int = 0
@@ -153,10 +209,15 @@ class PrivatePortraitSelectableAssetOut(BaseModel):
id: str id: str
project_id: str project_id: str
project_name: str project_name: str
library_type: str
name: str | None = None name: str | None = None
asset_type: str asset_type: str
preview_url: str | None = None preview_url: str | None = None
status: str display_url: str | None = None
provider_url: str | None = None
video_duration: float | None = None
video_cover_url: str | None = None
status: str = PrivatePortraitAssetStatus.ACTIVE.value
created_at: NaiveDatetimeOptional = None created_at: NaiveDatetimeOptional = None
@@ -169,4 +230,32 @@ class PrivatePortraitSelectableAssetListOut(BaseModel):
class PrivatePortraitDeleteOut(BaseModel): class PrivatePortraitDeleteOut(BaseModel):
success: bool = True success: bool = True
remote_delete_status: str | None = None remote_delete_status: str
class PrivatePortraitAdminStatsOut(BaseModel):
total_projects: int = 0
total_assets: int = 0
image_assets: int = 0
video_assets: int = 0
active_assets: int = 0
processing_assets: int = 0
failed_assets: int = 0
real_person_assets: int = 0
virtual_assets: int = 0
def build_private_portrait_enum_meta() -> PrivatePortraitEnumMetaOut:
return PrivatePortraitEnumMetaOut(
library_types=[
PrivatePortraitEnumItem(value=PrivatePortraitLibraryType.REAL_PERSON.value, label="真人认证素材库", description="需要用户扫码完成真人授权认证后才能上传素材。"),
PrivatePortraitEnumItem(value=PrivatePortraitLibraryType.AIGC_VIRTUAL.value, label="私域虚拟人像素材库", description="通过火山 CreateAssetGroup/CreateAsset 入库的虚拟人像素材。"),
],
asset_types=[
PrivatePortraitEnumItem(value=PrivatePortraitAssetType.IMAGE.value, label="图片", description="当前开放。"),
PrivatePortraitEnumItem(value=PrivatePortraitAssetType.VIDEO.value, label="视频", description="当前开放,处理时间通常比图片更长。"),
PrivatePortraitEnumItem(value=PrivatePortraitAssetType.AUDIO.value, label="音频", description="火山支持但当前业务暂不开放。"),
],
project_statuses=[PrivatePortraitEnumItem(value=item.value, label=item.value) for item in PrivatePortraitProjectStatus],
asset_statuses=[PrivatePortraitEnumItem(value=item.value, label=item.value) for item in PrivatePortraitAssetStatus],
)
@@ -31,3 +31,4 @@ class JoinTeamInfoOut(BaseModel):
team_id: str team_id: str
valid: bool valid: bool
already_in_team: bool = False already_in_team: bool = False
has_pending_request: bool = False
+1 -1
View File
@@ -15,7 +15,7 @@ class UserOut(BaseModel):
allowed_menus: list | None = None allowed_menus: list | None = None
must_set_password: bool = False must_set_password: bool = False
resource_capacity: ResourceCapacityUsageOut | None = None resource_capacity: ResourceCapacityUsageOut | None = None
private_portrait_image_limit: int = 5 private_portrait_asset_limit: int = 5
team_id: str | None = None team_id: str | None = None
team_name: str | None = None team_name: str | None = None
is_team_manager: bool = False is_team_manager: bool = False
@@ -54,7 +54,7 @@ from app.services.generation_history_meta_service import (
build_empty_history_meta, build_empty_history_meta,
) )
from app.services.resource_capacity_service import assert_user_resource_capacity_available from app.services.resource_capacity_service import assert_user_resource_capacity_available
from app.services.private_portrait.reference_resolver import resolve_private_portrait_references from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls, resolve_private_portrait_references
from app.utils.id_gen import generate_id from app.utils.id_gen import generate_id
IMAGE_DEFAULT_SIZE = "2K" IMAGE_DEFAULT_SIZE = "2K"
@@ -88,6 +88,22 @@ def _parse_json(text: str | None):
return None return None
async def _resolve_task_reference_display_map(db: AsyncSession, tasks: list[ChatGenerationTask], *, user_id: str | None = None) -> dict[str, list[dict] | None]:
return await batch_resolve_private_portrait_reference_display_urls(
db,
{task.id: _parse_json(task.media_references) for task in tasks},
user_id=user_id,
)
async def _resolve_generation_record_reference_display_map(db: AsyncSession, records: list[GenerationRecord], *, user_id: str | None = None) -> dict[str, list[dict] | None]:
return await batch_resolve_private_portrait_reference_display_urls(
db,
{record.id: _parse_json(record.media_references) for record in records},
user_id=user_id,
)
async def _get_image_engine(db: AsyncSession, engine_id: str | None) -> ImageEngine: async def _get_image_engine(db: AsyncSession, engine_id: str | None) -> ImageEngine:
query = select(ImageEngine).where(ImageEngine.is_active == True) query = select(ImageEngine).where(ImageEngine.is_active == True)
if engine_id: if engine_id:
@@ -462,8 +478,9 @@ def record_to_out(
generated_resource_id: str | None = None, generated_resource_id: str | None = None,
file_name: str | None = None, file_name: str | None = None,
history_meta: GenerationHistoryMeta | None = None, history_meta: GenerationHistoryMeta | None = None,
media_references: list[dict] | None = None,
) -> GenerationAITaskOut: ) -> GenerationAITaskOut:
refs = _parse_json(task.media_references) refs = media_references if media_references is not None else _parse_json(task.media_references)
snapshot = engine_snapshot_out(_parse_json(task.engine_snapshot_json)) snapshot = engine_snapshot_out(_parse_json(task.engine_snapshot_json))
source = GenerationHistorySourceEnum.CHAT_TASK source = GenerationHistorySourceEnum.CHAT_TASK
@@ -688,8 +705,9 @@ def generation_record_to_history_out(
project_name: str | None = None, project_name: str | None = None,
generated_resource_id: str | None = None, generated_resource_id: str | None = None,
file_name: str | None = None, file_name: str | None = None,
media_references: list[dict] | None = None,
) -> GenerationAIRecordHistoryItemOut: ) -> GenerationAIRecordHistoryItemOut:
refs = _parse_json(record.media_references) refs = media_references if media_references is not None else _parse_json(record.media_references)
return GenerationAIRecordHistoryItemOut( return GenerationAIRecordHistoryItemOut(
id=record.id, id=record.id,
source_type="generation_record", source_type="generation_record",
@@ -814,6 +832,8 @@ async def list_generation_record_history_grouped_days(
source_ids=all_record_ids, source_ids=all_record_ids,
resource_type=gen_type, resource_type=gen_type,
) )
all_records = [record for _generated_day, _day_total, rows in raw_groups for record, _project_name in rows]
reference_display_map = await _resolve_generation_record_reference_display_map(db, all_records, user_id=user_id)
groups = [ groups = [
{ {
@@ -825,6 +845,7 @@ async def list_generation_record_history_grouped_days(
project_name, project_name,
generated_resource_id=resource_info_map.get(record.id, {}).get("resource_id"), generated_resource_id=resource_info_map.get(record.id, {}).get("resource_id"),
file_name=resource_info_map.get(record.id, {}).get("file_name"), file_name=resource_info_map.get(record.id, {}).get("file_name"),
media_references=reference_display_map.get(record.id),
) )
for record, project_name in rows for record, project_name in rows
], ],
@@ -887,6 +908,7 @@ async def list_generation_record_history_day_items(
source_ids=[record.id for record, _project_name in rows], source_ids=[record.id for record, _project_name in rows],
resource_type=gen_type, resource_type=gen_type,
) )
reference_display_map = await _resolve_generation_record_reference_display_map(db, [record for record, _project_name in rows], user_id=user_id)
return { return {
"generated_date": target_day.strftime("%Y-%m-%d"), "generated_date": target_day.strftime("%Y-%m-%d"),
@@ -899,6 +921,7 @@ async def list_generation_record_history_day_items(
project_name, project_name,
generated_resource_id=resource_info_map.get(record.id, {}).get("resource_id"), generated_resource_id=resource_info_map.get(record.id, {}).get("resource_id"),
file_name=resource_info_map.get(record.id, {}).get("file_name"), file_name=resource_info_map.get(record.id, {}).get("file_name"),
media_references=reference_display_map.get(record.id),
) )
for record, project_name in rows for record, project_name in rows
], ],
@@ -989,6 +1012,8 @@ async def list_generation_history_grouped_days(
source=source, source=source,
chat_task_ids=all_task_ids, chat_task_ids=all_task_ids,
) )
all_tasks = [task for _generated_day, _day_total, tasks in raw_groups for task in tasks]
reference_display_map = await _resolve_task_reference_display_map(db, all_tasks, user_id=user_id)
groups = [ groups = [
{ {
@@ -1000,6 +1025,7 @@ async def list_generation_history_grouped_days(
generated_resource_id=resource_info_map.get(task.id, {}).get("resource_id"), generated_resource_id=resource_info_map.get(task.id, {}).get("resource_id"),
file_name=resource_info_map.get(task.id, {}).get("file_name"), file_name=resource_info_map.get(task.id, {}).get("file_name"),
history_meta=history_meta_map.get(task.id), history_meta=history_meta_map.get(task.id),
media_references=reference_display_map.get(task.id),
) )
for task in tasks for task in tasks
], ],
@@ -1082,6 +1108,7 @@ async def list_generation_history_day_items(
source=source, source=source,
chat_task_ids=task_ids, chat_task_ids=task_ids,
) )
reference_display_map = await _resolve_task_reference_display_map(db, tasks, user_id=user_id)
return { return {
"generated_date": target_day.strftime("%Y-%m-%d"), "generated_date": target_day.strftime("%Y-%m-%d"),
@@ -1094,6 +1121,7 @@ async def list_generation_history_day_items(
generated_resource_id=resource_info_map.get(task.id, {}).get("resource_id"), generated_resource_id=resource_info_map.get(task.id, {}).get("resource_id"),
file_name=resource_info_map.get(task.id, {}).get("file_name"), file_name=resource_info_map.get(task.id, {}).get("file_name"),
history_meta=history_meta_map.get(task.id), history_meta=history_meta_map.get(task.id),
media_references=reference_display_map.get(task.id),
) )
for task in tasks for task in tasks
], ],
@@ -0,0 +1,21 @@
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.private_portrait.asset_service import asset_to_out, list_assets
async def admin_list_assets(
db: AsyncSession,
*,
user_id: str | None = None,
project_id: str | None = None,
library_type: str | None = None,
asset_type: str | None = None,
keyword: str | None = None,
status: str | None = None,
page: int = 1,
page_size: int = 20,
):
assets, total, project_name_map = await list_assets(db, user_id=user_id, project_id=project_id, status=status, keyword=keyword, page=page, page_size=page_size, library_type=library_type, asset_type=asset_type)
return [asset_to_out(asset, project_name=project_name_map.get(asset.project_id), include_user=True) for asset in assets], total
@@ -0,0 +1,54 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.private_portrait import PrivatePortraitProject
from app.services.private_portrait.project_service import list_projects, project_to_out, refresh_project_counters
async def _reload_projects_by_ids(db: AsyncSession, project_ids: list[str]) -> list[PrivatePortraitProject]:
"""Reload projects after counter refresh to avoid async expired-attribute lazy load.
refresh_project_counters() uses bulk UPDATE. In SQLAlchemy async ORM, previously
loaded ORM instances may become expired after a bulk update. Accessing expired
scalar attributes outside greenlet context triggers MissingGreenlet. Reloading
with populate_existing=True refreshes the identity-map objects during the awaited
execute call, and keeps the original pagination order.
"""
if not project_ids:
return []
result = await db.execute(
select(PrivatePortraitProject)
.where(PrivatePortraitProject.id.in_(project_ids))
.execution_options(populate_existing=True)
)
project_map = {project.id: project for project in result.scalars().all()}
return [project_map[project_id] for project_id in project_ids if project_id in project_map]
async def admin_list_projects(
db: AsyncSession,
*,
user_id: str | None = None,
library_type: str | None = None,
keyword: str | None = None,
status: str | None = None,
page: int = 1,
page_size: int = 20,
):
items, total = await list_projects(
db,
user_id=user_id,
page=page,
page_size=page_size,
keyword=keyword,
status=status,
library_type=library_type,
)
project_ids = [item.id for item in items]
await refresh_project_counters(db, project_ids)
# Bulk UPDATE may expire loaded ORM instances. Re-query before DTO conversion.
items = await _reload_projects_by_ids(db, project_ids)
return [project_to_out(item, include_user=True) for item in items], total
@@ -0,0 +1,45 @@
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.private_portrait import PrivatePortraitAssetStatus, PrivatePortraitAssetType, PrivatePortraitLibraryType
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitProject
from app.schemas.private_portrait import PrivatePortraitAdminStatsOut
async def admin_get_private_portrait_stats(db: AsyncSession, *, user_id: str | None = None, library_type: str | None = None) -> PrivatePortraitAdminStatsOut:
project_filters = [PrivatePortraitProject.deleted_at.is_(None)]
asset_filters = [PrivatePortraitAsset.deleted_at.is_(None)]
if user_id:
project_filters.append(PrivatePortraitProject.user_id == user_id)
asset_filters.append(PrivatePortraitAsset.user_id == user_id)
if library_type:
project_filters.append(PrivatePortraitProject.library_type == library_type)
asset_filters.append(PrivatePortraitAsset.library_type == library_type)
total_projects = (await db.execute(select(func.count(PrivatePortraitProject.id)).where(*project_filters))).scalar_one()
rows = await db.execute(
select(PrivatePortraitAsset.library_type, PrivatePortraitAsset.asset_type, PrivatePortraitAsset.status, func.count(PrivatePortraitAsset.id))
.where(*asset_filters)
.group_by(PrivatePortraitAsset.library_type, PrivatePortraitAsset.asset_type, PrivatePortraitAsset.status)
)
stats = PrivatePortraitAdminStatsOut(total_projects=int(total_projects or 0))
for lib, asset_type, status, count in rows.all():
n = int(count or 0)
stats.total_assets += n
if asset_type == PrivatePortraitAssetType.IMAGE.value:
stats.image_assets += n
elif asset_type == PrivatePortraitAssetType.VIDEO.value:
stats.video_assets += n
if status == PrivatePortraitAssetStatus.ACTIVE.value:
stats.active_assets += n
elif status == PrivatePortraitAssetStatus.PROCESSING.value:
stats.processing_assets += n
elif status == PrivatePortraitAssetStatus.FAILED.value:
stats.failed_assets += n
if lib == PrivatePortraitLibraryType.REAL_PERSON.value:
stats.real_person_assets += n
elif lib == PrivatePortraitLibraryType.AIGC_VIRTUAL.value:
stats.virtual_assets += n
return stats
@@ -51,11 +51,11 @@ def _remote_error_http_status(code: str) -> int:
class ArkPrivateAssetClient: class ArkPrivateAssetClient:
"""火山 Ark 私域真人人像素材 API Client。只做 AK/SK 鉴权调用与响应标准化。""" """火山 Ark 私域可信素材 Asset API Client。只做 AK/SK 鉴权调用与响应标准化。"""
def __init__(self, *, ak: str | None = None, sk: str | None = None, for_celery: bool = False): def __init__(self, *, for_celery: bool = False):
self.ak = ak or settings.VOLC_SMS_ACCESS_KEY_ID self.ak = settings.VOLC_ACCESS_KEY_ID
self.sk = sk or settings.VOLC_SMS_SECRET_ACCESS_KEY self.sk = settings.VOLC_SECRET_ACCESS_KEY
self.for_celery = for_celery self.for_celery = for_celery
if not self.ak or not self.sk: if not self.ak or not self.sk:
raise ArkPrivateAssetClientError("火山 AK/SK 未配置:VOLC_SMS_ACCESS_KEY_ID / VOLC_SMS_SECRET_ACCESS_KEY") raise ArkPrivateAssetClientError("火山 AK/SK 未配置:VOLC_SMS_ACCESS_KEY_ID / VOLC_SMS_SECRET_ACCESS_KEY")
@@ -66,6 +66,12 @@ class ArkPrivateAssetClient:
async def get_visual_validate_result(self, *, project_name: str, byted_token: str) -> dict[str, Any]: async def get_visual_validate_result(self, *, project_name: str, byted_token: str) -> dict[str, Any]:
return await self._call(ArkPrivatePortraitAction.GET_VISUAL_VALIDATE_RESULT, {"BytedToken": byted_token, "ProjectName": project_name}) return await self._call(ArkPrivatePortraitAction.GET_VISUAL_VALIDATE_RESULT, {"BytedToken": byted_token, "ProjectName": project_name})
async def create_asset_group(self, *, project_name: str, name: str, description: str | None = None, group_type: str = "AIGC") -> dict[str, Any]:
payload: dict[str, Any] = {"Name": name, "GroupType": group_type, "ProjectName": project_name}
if description:
payload["Description"] = description
return await self._call(ArkPrivatePortraitAction.CREATE_ASSET_GROUP, payload)
async def create_asset(self, *, project_name: str, group_id: str, url: str, asset_type: str, name: str | None = None) -> dict[str, Any]: async def create_asset(self, *, project_name: str, group_id: str, url: str, asset_type: str, name: str | None = None) -> dict[str, Any]:
payload: dict[str, Any] = {"GroupId": group_id, "URL": url, "AssetType": asset_type, "ProjectName": project_name} payload: dict[str, Any] = {"GroupId": group_id, "URL": url, "AssetType": asset_type, "ProjectName": project_name}
if name: if name:
@@ -6,32 +6,42 @@ from typing import Any
from urllib.parse import urlencode from urllib.parse import urlencode
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import and_, func, select, update from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
from app.enums.private_portrait import ( from app.enums.private_portrait import (
PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS, PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS,
PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT, PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT,
PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT, PRIVATE_PORTRAIT_ASSET_URI_PREFIX,
PRIVATE_PORTRAIT_GROUP_TYPE, PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE,
PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE, PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE,
PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES, PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES,
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_INTERVAL_SECONDS,
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_MAX_COUNT,
PrivatePortraitAssetGroupStatus, PrivatePortraitAssetGroupStatus,
PrivatePortraitAssetStatus, PrivatePortraitAssetStatus,
PrivatePortraitAssetType, PrivatePortraitAssetType,
PrivatePortraitEventSource, PrivatePortraitEventSource,
PrivatePortraitEventStatus, PrivatePortraitEventStatus,
PrivatePortraitEventType, PrivatePortraitEventType,
PrivatePortraitLibraryType,
PrivatePortraitProjectStatus,
PrivatePortraitRemoteDeleteStatus, PrivatePortraitRemoteDeleteStatus,
PrivatePortraitValidateSessionStatus, PrivatePortraitValidateSessionStatus,
) )
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject, PrivatePortraitValidateSession from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject, PrivatePortraitValidateSession
from app.models.user import User from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitAssetOut, PrivatePortraitSelectableAssetOut, PrivatePortraitValidateSessionOut
from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitAssetOut, PrivatePortraitConfigOut, PrivatePortraitSelectableAssetOut, PrivatePortraitValidateSessionOut
from app.services.operation_log_service import log_operation_error, log_operation_event from app.services.operation_log_service import log_operation_error, log_operation_event
from app.services.private_portrait.ark_client import ArkPrivateAssetClient from app.services.private_portrait.ark_client import ArkPrivateAssetClient
from app.services.private_portrait.project_service import get_user_project, refresh_project_counters from app.services.private_portrait.project_service import get_user_project, refresh_project_counters
from app.services.private_portrait.quota_service import (
count_user_counting_assets,
ensure_private_portrait_asset_quota_available,
get_user_private_portrait_config,
set_user_private_portrait_limit,
)
from app.utils.id_gen import generate_id from app.utils.id_gen import generate_id
DOMAIN = "private_portrait" DOMAIN = "private_portrait"
@@ -52,7 +62,6 @@ def _loads(data: str | None) -> Any:
return None return None
def _exception_message(exc: Exception) -> str: def _exception_message(exc: Exception) -> str:
if isinstance(exc, HTTPException): if isinstance(exc, HTTPException):
detail = exc.detail detail = exc.detail
@@ -64,7 +73,7 @@ def _exception_message(exc: Exception) -> str:
def _public_url(url: str) -> str: def _public_url(url: str) -> str:
if url.startswith(("http://", "https://")): if url.startswith(("http://", "https://", PRIVATE_PORTRAIT_ASSET_URI_PREFIX)):
return url return url
return f"{settings.BASE_URL.rstrip('/')}/{url.lstrip('/')}" return f"{settings.BASE_URL.rstrip('/')}/{url.lstrip('/')}"
@@ -82,36 +91,31 @@ def _remote_group_name(user_id: str, project_name: str) -> str:
return f"{user_id}-{safe_name}"[:128] return f"{user_id}-{safe_name}"[:128]
async def get_user_private_portrait_config(db: AsyncSession, *, user_id: str) -> PrivatePortraitConfigOut: def _asset_display_url(asset: PrivatePortraitAsset) -> str | None:
user = (await db.execute(select(User).where(User.id == user_id).limit(1))).scalar_one_or_none() return asset.preview_url or asset.remote_url or asset.source_url or None
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
limit = int(getattr(user, "private_portrait_image_limit", PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT) or 0)
used = await count_user_counting_image_assets(db, user_id=user_id)
return PrivatePortraitConfigOut(enabled=limit > 0, image_limit=limit, used_image_count=used, remaining_image_count=max(0, limit - used) if limit > 0 else 0)
async def set_user_private_portrait_limit(db: AsyncSession, *, user_id: str, limit: int) -> User: def _provider_url(asset: PrivatePortraitAsset) -> str | None:
user = (await db.execute(select(User).where(User.id == user_id).limit(1))).scalar_one_or_none() return f"{PRIVATE_PORTRAIT_ASSET_URI_PREFIX}{asset.remote_asset_id}" if asset.remote_asset_id else None
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
user.private_portrait_image_limit = max(0, int(limit))
await db.flush()
return user
async def count_user_counting_image_assets(db: AsyncSession, *, user_id: str) -> int: def _poll_interval_seconds(asset_type: str) -> int:
statuses = [PrivatePortraitAssetStatus.CREATING.value, PrivatePortraitAssetStatus.PROCESSING.value, PrivatePortraitAssetStatus.ACTIVE.value] if asset_type == PrivatePortraitAssetType.VIDEO.value:
total = (await db.execute(select(func.count(PrivatePortraitAsset.id)).where(PrivatePortraitAsset.user_id == user_id, PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.IMAGE.value, PrivatePortraitAsset.deleted_at.is_(None), PrivatePortraitAsset.status.in_(statuses)))).scalar_one() return PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_INTERVAL_SECONDS
return int(total or 0) return PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS
async def _lock_user_for_upload(db: AsyncSession, *, user_id: str) -> User: def _poll_max_count(asset_type: str) -> int:
# 锁 users 行,避免并发绕过用户总量限制。SQLite 会忽略 FOR UPDATE,不影响本地开发。 if asset_type == PrivatePortraitAssetType.VIDEO.value:
user = (await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1))).scalar_one_or_none() return PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_MAX_COUNT
if not user: return PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT
raise HTTPException(status_code=404, detail="用户不存在")
return user
def _assert_enabled_asset_type(asset_type: str) -> None:
if asset_type not in {item.value for item in PrivatePortraitAssetType}:
raise HTTPException(status_code=400, detail="asset_type 仅支持 Image/VideoAudio 暂未开放")
if asset_type not in PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES:
raise HTTPException(status_code=400, detail="Audio 暂未开放,当前仅支持 Image/Video")
def validate_session_to_out(session: PrivatePortraitValidateSession, *, include_user: bool = False) -> PrivatePortraitValidateSessionOut: def validate_session_to_out(session: PrivatePortraitValidateSession, *, include_user: bool = False) -> PrivatePortraitValidateSessionOut:
@@ -136,12 +140,14 @@ def validate_session_to_out(session: PrivatePortraitValidateSession, *, include_
def asset_to_out(asset: PrivatePortraitAsset, *, project_name: str | None = None, include_user: bool = False) -> PrivatePortraitAssetOut: def asset_to_out(asset: PrivatePortraitAsset, *, project_name: str | None = None, include_user: bool = False) -> PrivatePortraitAssetOut:
display_url = _asset_display_url(asset)
return PrivatePortraitAssetOut( return PrivatePortraitAssetOut(
id=asset.id, id=asset.id,
user_id=asset.user_id if include_user else None, user_id=asset.user_id if include_user else None,
project_id=asset.project_id, project_id=asset.project_id,
project_name=project_name, project_name=project_name,
group_id=asset.group_id, group_id=asset.group_id,
library_type=asset.library_type,
remote_group_id=asset.remote_group_id, remote_group_id=asset.remote_group_id,
remote_asset_id=asset.remote_asset_id, remote_asset_id=asset.remote_asset_id,
remote_project_name=asset.remote_project_name, remote_project_name=asset.remote_project_name,
@@ -149,8 +155,14 @@ def asset_to_out(asset: PrivatePortraitAsset, *, project_name: str | None = None
name=asset.name, name=asset.name,
source_url=asset.source_url, source_url=asset.source_url,
preview_url=asset.preview_url, preview_url=asset.preview_url,
display_url=display_url,
provider_url=_provider_url(asset),
remote_url=asset.remote_url, remote_url=asset.remote_url,
remote_url_expired_at=asset.remote_url_expired_at, remote_url_expired_at=asset.remote_url_expired_at,
video_duration=asset.video_duration,
video_cover_url=asset.video_cover_url,
file_size=asset.file_size,
mime_type=asset.mime_type,
status=asset.status, status=asset.status,
moderation=_loads(asset.moderation_json), moderation=_loads(asset.moderation_json),
last_poll_at=asset.last_poll_at, last_poll_at=asset.last_poll_at,
@@ -165,8 +177,69 @@ def asset_to_out(asset: PrivatePortraitAsset, *, project_name: str | None = None
) )
async def _get_existing_active_group(db: AsyncSession, *, project_id: str, library_type: str | None = None) -> PrivatePortraitAssetGroup | None:
filters = [
PrivatePortraitAssetGroup.project_id == project_id,
PrivatePortraitAssetGroup.status == PrivatePortraitAssetGroupStatus.ACTIVE.value,
PrivatePortraitAssetGroup.deleted_at.is_(None),
]
if library_type:
filters.append(PrivatePortraitAssetGroup.library_type == library_type)
return (
await db.execute(
select(PrivatePortraitAssetGroup)
.where(*filters)
.order_by(PrivatePortraitAssetGroup.created_at.desc())
.limit(1)
)
).scalar_one_or_none()
async def _ensure_project_can_validate(db: AsyncSession, *, project: PrivatePortraitProject) -> PrivatePortraitValidateSession | None:
if project.library_type != PrivatePortraitLibraryType.REAL_PERSON.value:
raise HTTPException(status_code=400, detail="虚拟人像项目不支持真人认证")
active_group = await _get_existing_active_group(db, project_id=project.id, library_type=project.library_type)
if active_group or project.status == PrivatePortraitProjectStatus.ACTIVE.value:
raise HTTPException(status_code=409, detail="该真人素材项目已完成认证,不能重复认证")
success_session = (
await db.execute(
select(PrivatePortraitValidateSession)
.where(
PrivatePortraitValidateSession.project_id == project.id,
PrivatePortraitValidateSession.status == PrivatePortraitValidateSessionStatus.GROUP_ACTIVE.value,
)
.order_by(PrivatePortraitValidateSession.updated_at.desc())
.limit(1)
)
).scalar_one_or_none()
if success_session:
raise HTTPException(status_code=409, detail="该真人素材项目已完成认证,不能重复认证")
now = datetime.now(timezone.utc)
pending_session = (
await db.execute(
select(PrivatePortraitValidateSession)
.where(
PrivatePortraitValidateSession.project_id == project.id,
PrivatePortraitValidateSession.status.in_([PrivatePortraitValidateSessionStatus.CREATED.value, PrivatePortraitValidateSessionStatus.CALLBACK_SUCCESS.value]),
PrivatePortraitValidateSession.expired_at.is_not(None),
PrivatePortraitValidateSession.expired_at > now,
)
.order_by(PrivatePortraitValidateSession.created_at.desc())
.limit(1)
)
).scalar_one_or_none()
return pending_session
async def create_validate_session(db: AsyncSession, *, user_id: str, project_id: str, callback_redirect_url: str | None = None) -> PrivatePortraitValidateSession: async def create_validate_session(db: AsyncSession, *, user_id: str, project_id: str, callback_redirect_url: str | None = None) -> PrivatePortraitValidateSession:
project = await get_user_project(db, user_id=user_id, project_id=project_id) project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
reusable_session = await _ensure_project_can_validate(db, project=project)
if reusable_session:
return reusable_session
project.status = PrivatePortraitProjectStatus.VALIDATING.value
session = PrivatePortraitValidateSession( session = PrivatePortraitValidateSession(
id=generate_id(), id=generate_id(),
user_id=user_id, user_id=user_id,
@@ -184,14 +257,13 @@ async def create_validate_session(db: AsyncSession, *, user_id: str, project_id:
session.h5_link = resp.get("H5Link") or resp.get("h5Link") session.h5_link = resp.get("H5Link") or resp.get("h5Link")
session.raw_response_json = _json(resp) session.raw_response_json = _json(resp)
await db.flush() await db.flush()
# created_at / updated_at 来自数据库默认值或 onupdateflush 后可能处于 expired 状态。
# 在 async SQLAlchemy 下,响应转换时同步读取 expired 字段会触发 MissingGreenlet。
await db.refresh(session) await db.refresh(session)
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_SESSION_CREATE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, session_id=session.id, detail={"remote_project_name": project.remote_project_name}) log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_SESSION_CREATE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, session_id=session.id, detail={"remote_project_name": project.remote_project_name, "library_type": project.library_type})
return session return session
except Exception as exc: except Exception as exc:
session.status = PrivatePortraitValidateSessionStatus.FAILED.value session.status = PrivatePortraitValidateSessionStatus.FAILED.value
session.error_message = _exception_message(exc) session.error_message = _exception_message(exc)
project.status = PrivatePortraitProjectStatus.VALIDATE_FAILED.value
await db.flush() await db.flush()
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_SESSION_CREATE_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, session_id=session.id, exc=exc) log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_SESSION_CREATE_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, session_id=session.id, exc=exc)
raise raise
@@ -209,6 +281,9 @@ async def get_validate_session(db: AsyncSession, *, user_id: str | None, session
async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_params: dict[str, Any]) -> PrivatePortraitValidateSession: async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_params: dict[str, Any]) -> PrivatePortraitValidateSession:
session = await get_validate_session(db, user_id=None, session_id=session_id) session = await get_validate_session(db, user_id=None, session_id=session_id)
if session.status == PrivatePortraitValidateSessionStatus.GROUP_ACTIVE.value:
return session
session.raw_callback_json = _json(query_params) session.raw_callback_json = _json(query_params)
session.result_code = str(query_params.get("resultCode") or query_params.get("result_code") or "") or None session.result_code = str(query_params.get("resultCode") or query_params.get("result_code") or "") or None
session.algorithm_base_resp_code = str(query_params.get("algorithmBaseRespCode") or query_params.get("algorithm_base_resp_code") or "") or None session.algorithm_base_resp_code = str(query_params.get("algorithmBaseRespCode") or query_params.get("algorithm_base_resp_code") or "") or None
@@ -218,9 +293,13 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
session.byted_token = str(token) session.byted_token = str(token)
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_CALLBACK_RECEIVED.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, detail={"query_params": query_params, "remote_project_name": session.remote_project_name}) log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_CALLBACK_RECEIVED.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, detail={"query_params": query_params, "remote_project_name": session.remote_project_name})
project = (await db.execute(select(PrivatePortraitProject).where(PrivatePortraitProject.id == session.project_id).limit(1))).scalar_one_or_none()
if session.result_code != PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE: if session.result_code != PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE:
session.status = PrivatePortraitValidateSessionStatus.CALLBACK_FAILED.value session.status = PrivatePortraitValidateSessionStatus.CALLBACK_FAILED.value
session.error_message = f"真人认证失败:resultCode={session.result_code}" session.error_message = f"真人认证失败:resultCode={session.result_code}"
if project and project.status != PrivatePortraitProjectStatus.ACTIVE.value:
project.status = PrivatePortraitProjectStatus.VALIDATE_FAILED.value
await db.flush() await db.flush()
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_CALLBACK_FAILED.value, event_status=PrivatePortraitEventStatus.FAILED.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, error=session.error_message) log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_CALLBACK_FAILED.value, event_status=PrivatePortraitEventStatus.FAILED.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, error=session.error_message)
return session return session
@@ -229,10 +308,22 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
if not session.byted_token: if not session.byted_token:
session.status = PrivatePortraitValidateSessionStatus.FAILED.value session.status = PrivatePortraitValidateSessionStatus.FAILED.value
session.error_message = "Callback 未返回 BytedToken" session.error_message = "Callback 未返回 BytedToken"
if project and project.status != PrivatePortraitProjectStatus.ACTIVE.value:
project.status = PrivatePortraitProjectStatus.VALIDATE_FAILED.value
await db.flush() await db.flush()
raise HTTPException(status_code=400, detail=session.error_message) raise HTTPException(status_code=400, detail=session.error_message)
try: try:
existing_group = await _get_existing_active_group(db, project_id=session.project_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
if existing_group:
session.remote_group_id = existing_group.remote_group_id
session.status = PrivatePortraitValidateSessionStatus.GROUP_ACTIVE.value
if project:
project.status = PrivatePortraitProjectStatus.ACTIVE.value
await db.flush()
await db.refresh(session)
return session
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, detail={"remote_project_name": session.remote_project_name}) log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, detail={"remote_project_name": session.remote_project_name})
resp = await ArkPrivateAssetClient().get_visual_validate_result(project_name=session.remote_project_name, byted_token=session.byted_token) resp = await ArkPrivateAssetClient().get_visual_validate_result(project_name=session.remote_project_name, byted_token=session.byted_token)
group_id = resp.get("GroupId") or resp.get("groupId") group_id = resp.get("GroupId") or resp.get("groupId")
@@ -242,20 +333,23 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
session.status = PrivatePortraitValidateSessionStatus.GROUP_ACTIVE.value session.status = PrivatePortraitValidateSessionStatus.GROUP_ACTIVE.value
session.raw_response_json = _json(resp) session.raw_response_json = _json(resp)
project = (await db.execute(select(PrivatePortraitProject).where(PrivatePortraitProject.id == session.project_id).limit(1))).scalar_one() if not project:
raise RuntimeError("真人素材项目不存在")
remote_group_name = _remote_group_name(session.user_id, project.name) remote_group_name = _remote_group_name(session.user_id, project.name)
group = PrivatePortraitAssetGroup( group = PrivatePortraitAssetGroup(
id=generate_id(), id=generate_id(),
user_id=session.user_id, user_id=session.user_id,
project_id=session.project_id, project_id=session.project_id,
library_type=PrivatePortraitLibraryType.REAL_PERSON.value,
remote_group_id=group_id, remote_group_id=group_id,
remote_group_name=remote_group_name, remote_group_name=remote_group_name,
remote_project_name=session.remote_project_name, remote_project_name=session.remote_project_name,
group_type=PRIVATE_PORTRAIT_GROUP_TYPE, group_type=PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE,
status=PrivatePortraitAssetGroupStatus.ACTIVE.value, status=PrivatePortraitAssetGroupStatus.ACTIVE.value,
raw_response_json=_json(resp), raw_response_json=_json(resp),
) )
db.add(group) db.add(group)
project.status = PrivatePortraitProjectStatus.ACTIVE.value
await db.flush() await db.flush()
try: try:
await ArkPrivateAssetClient().update_asset_group(project_name=session.remote_project_name, group_id=group_id, name=remote_group_name, title=remote_group_name, description=project.description) await ArkPrivateAssetClient().update_asset_group(project_name=session.remote_project_name, group_id=group_id, name=remote_group_name, title=remote_group_name, description=project.description)
@@ -264,56 +358,78 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
await refresh_project_counters(db, [session.project_id]) await refresh_project_counters(db, [session.project_id])
await db.flush() await db.flush()
await db.refresh(session) await db.refresh(session)
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, group_id=group.id, detail={"remote_group_id": group_id, "remote_project_name": session.remote_project_name}) log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, group_id=group.id, detail={"remote_group_id": group_id, "remote_project_name": session.remote_project_name, "library_type": PrivatePortraitLibraryType.REAL_PERSON.value})
return session return session
except Exception as exc: except Exception as exc:
session.status = PrivatePortraitValidateSessionStatus.FAILED.value session.status = PrivatePortraitValidateSessionStatus.FAILED.value
session.error_message = _exception_message(exc) session.error_message = _exception_message(exc)
if project and project.status != PrivatePortraitProjectStatus.ACTIVE.value:
project.status = PrivatePortraitProjectStatus.VALIDATE_FAILED.value
await db.flush() await db.flush()
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_FAILED.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, exc=exc) log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_FAILED.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, exc=exc)
raise raise
async def get_project_active_group(db: AsyncSession, *, user_id: str, project_id: str) -> PrivatePortraitAssetGroup: async def get_project_active_group(db: AsyncSession, *, user_id: str, project_id: str, library_type: str | None = None) -> PrivatePortraitAssetGroup:
result = await db.execute(select(PrivatePortraitAssetGroup).where(PrivatePortraitAssetGroup.user_id == user_id, PrivatePortraitAssetGroup.project_id == project_id, PrivatePortraitAssetGroup.status == PrivatePortraitAssetGroupStatus.ACTIVE.value, PrivatePortraitAssetGroup.deleted_at.is_(None)).order_by(PrivatePortraitAssetGroup.created_at.desc()).limit(1)) project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
if project.status != PrivatePortraitProjectStatus.ACTIVE.value:
detail = "请先完成真人授权认证,再上传素材" if project.library_type == PrivatePortraitLibraryType.REAL_PERSON.value else "虚拟人像素材组尚未创建成功,不能上传素材"
raise HTTPException(status_code=400, detail=detail)
result = await db.execute(
select(PrivatePortraitAssetGroup)
.where(
PrivatePortraitAssetGroup.user_id == user_id,
PrivatePortraitAssetGroup.project_id == project_id,
PrivatePortraitAssetGroup.library_type == project.library_type,
PrivatePortraitAssetGroup.status == PrivatePortraitAssetGroupStatus.ACTIVE.value,
PrivatePortraitAssetGroup.deleted_at.is_(None),
)
.order_by(PrivatePortraitAssetGroup.created_at.desc())
.limit(1)
)
group = result.scalar_one_or_none() group = result.scalar_one_or_none()
if not group: if not group:
raise HTTPException(status_code=400, detail="请先完成真人授权认证,再上传素材") raise HTTPException(status_code=400, detail="项目没有可用的远程素材")
return group return group
async def create_asset(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitAssetCreate) -> PrivatePortraitAsset: async def create_asset(
project = await get_user_project(db, user_id=user_id, project_id=project_id) db: AsyncSession,
if payload.asset_type != PrivatePortraitAssetType.IMAGE.value: *,
raise HTTPException(status_code=400, detail="第一版真人素材库仅开放 Image 图片素材") user_id: str,
user = await _lock_user_for_upload(db, user_id=user_id) project_id: str,
limit = int(getattr(user, "private_portrait_image_limit", PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT) or 0) payload: PrivatePortraitAssetCreate,
if limit <= 0: library_type: str | None = None,
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_REJECT_DISABLED.value, event_status=PrivatePortraitEventStatus.FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, message="用户真人素材模块未启用") ) -> PrivatePortraitAsset:
raise HTTPException(status_code=403, detail="真人素材库未启用") _assert_enabled_asset_type(payload.asset_type)
current_count = await count_user_counting_image_assets(db, user_id=user_id) project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
if current_count >= limit: if project.status != PrivatePortraitProjectStatus.ACTIVE.value:
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_REJECT_MAX_LIMIT.value, event_status=PrivatePortraitEventStatus.FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, detail={"current_count": current_count, "limit": limit}) raise HTTPException(status_code=400, detail="项目未激活,不能上传素材")
raise HTTPException(status_code=400, detail=f"你的真人素材库最多可上传 {limit} 张图片,请删除已有素材后再上传")
group = await get_project_active_group(db, user_id=user_id, project_id=project.id) limit, current_count = await ensure_private_portrait_asset_quota_available(db, user_id=user_id, project_id=project_id, library_type=project.library_type, asset_type=payload.asset_type)
group = await get_project_active_group(db, user_id=user_id, project_id=project.id, library_type=project.library_type)
public_url = _public_url(payload.url) public_url = _public_url(payload.url)
asset = PrivatePortraitAsset( asset = PrivatePortraitAsset(
id=generate_id(), id=generate_id(),
user_id=user_id, user_id=user_id,
project_id=project.id, project_id=project.id,
group_id=group.id, group_id=group.id,
library_type=project.library_type,
remote_group_id=group.remote_group_id, remote_group_id=group.remote_group_id,
remote_project_name=project.remote_project_name, remote_project_name=project.remote_project_name,
asset_type=payload.asset_type, asset_type=payload.asset_type,
name=payload.name, name=payload.name,
source_url=public_url, source_url=public_url,
preview_url=payload.url, preview_url=payload.url,
video_duration=payload.video_duration,
video_cover_url=payload.video_cover_url,
file_size=payload.file_size,
mime_type=payload.mime_type,
status=PrivatePortraitAssetStatus.CREATING.value, status=PrivatePortraitAssetStatus.CREATING.value,
) )
db.add(asset) db.add(asset)
await db.flush() await db.flush()
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, detail={"limit": limit, "current_count": current_count, "remote_project_name": project.remote_project_name}) log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, detail={"asset_limit": limit, "used_asset_count": current_count, "library_type": project.library_type, "asset_type": payload.asset_type, "remote_project_name": project.remote_project_name})
try: try:
remote_resp = await ArkPrivateAssetClient().create_asset(project_name=project.remote_project_name, group_id=group.remote_group_id, url=public_url, asset_type=payload.asset_type, name=payload.name) remote_resp = await ArkPrivateAssetClient().create_asset(project_name=project.remote_project_name, group_id=group.remote_group_id, url=public_url, asset_type=payload.asset_type, name=payload.name)
remote_asset_id = remote_resp.get("Id") or remote_resp.get("AssetId") or remote_resp.get("assetId") remote_asset_id = remote_resp.get("Id") or remote_resp.get("AssetId") or remote_resp.get("assetId")
@@ -322,14 +438,12 @@ async def create_asset(db: AsyncSession, *, user_id: str, project_id: str, paylo
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
asset.remote_asset_id = remote_asset_id asset.remote_asset_id = remote_asset_id
asset.status = PrivatePortraitAssetStatus.PROCESSING.value asset.status = PrivatePortraitAssetStatus.PROCESSING.value
asset.next_poll_at = now + timedelta(seconds=PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS) asset.next_poll_at = now + timedelta(seconds=_poll_interval_seconds(asset.asset_type))
asset.raw_response_json = _json(remote_resp) asset.raw_response_json = _json(remote_resp)
await refresh_project_counters(db, [project.id]) await refresh_project_counters(db, [project.id])
await db.flush() await db.flush()
# created_at / updated_at 来自数据库默认值或 onupdateflush 后可能处于 expired 状态。
# 在 async SQLAlchemy 下,响应转换时同步读取 expired 字段会触发 MissingGreenlet。
await db.refresh(asset) await db.refresh(asset)
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, detail={"remote_asset_id": remote_asset_id, "remote_project_name": project.remote_project_name}) log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, detail={"remote_asset_id": remote_asset_id, "remote_project_name": project.remote_project_name, "library_type": project.library_type, "asset_type": asset.asset_type})
return asset return asset
except Exception as exc: except Exception as exc:
asset.status = PrivatePortraitAssetStatus.FAILED.value asset.status = PrivatePortraitAssetStatus.FAILED.value
@@ -345,11 +459,11 @@ async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id:
filters.append(PrivatePortraitAsset.user_id == user_id) filters.append(PrivatePortraitAsset.user_id == user_id)
asset = (await db.execute(select(PrivatePortraitAsset).where(*filters).limit(1))).scalar_one_or_none() asset = (await db.execute(select(PrivatePortraitAsset).where(*filters).limit(1))).scalar_one_or_none()
if not asset: if not asset:
raise HTTPException(status_code=404, detail="真人素材不存在") raise HTTPException(status_code=404, detail="私域人像素材不存在")
if asset.deleted_at is not None: if asset.deleted_at is not None:
raise HTTPException(status_code=400, detail="真人素材已删除") raise HTTPException(status_code=400, detail="私域人像素材已删除")
if not asset.remote_asset_id: if not asset.remote_asset_id:
raise HTTPException(status_code=400, detail="真人素材尚未创建远程 Asset") raise HTTPException(status_code=400, detail="私域人像素材尚未创建远程 Asset")
source = PrivatePortraitEventSource.CELERY.value if user_id is None else PrivatePortraitEventSource.API.value source = PrivatePortraitEventSource.CELERY.value if user_id is None else PrivatePortraitEventSource.API.value
log_operation_event( log_operation_event(
@@ -360,12 +474,7 @@ async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id:
user_id=asset.user_id, user_id=asset.user_id,
project_id=asset.project_id, project_id=asset.project_id,
asset_id=asset.id, asset_id=asset.id,
detail={ detail={"status": asset.status, "poll_count": int(asset.poll_count or 0), "remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type, "asset_type": asset.asset_type},
"status": asset.status,
"poll_count": int(asset.poll_count or 0),
"remote_asset_id": asset.remote_asset_id,
"remote_project_name": asset.remote_project_name,
},
) )
try: try:
remote_resp = await ArkPrivateAssetClient(for_celery=(user_id is None)).get_asset(project_name=asset.remote_project_name, asset_id=asset.remote_asset_id) remote_resp = await ArkPrivateAssetClient(for_celery=(user_id is None)).get_asset(project_name=asset.remote_project_name, asset_id=asset.remote_asset_id)
@@ -378,24 +487,15 @@ async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id:
asset.status = status asset.status = status
asset.remote_url = remote_resp.get("URL") or remote_resp.get("url") or asset.remote_url asset.remote_url = remote_resp.get("URL") or remote_resp.get("url") or asset.remote_url
asset.moderation_json = _json(remote_resp.get("Moderation") or remote_resp.get("moderation")) asset.moderation_json = _json(remote_resp.get("Moderation") or remote_resp.get("moderation"))
max_count = _poll_max_count(asset.asset_type)
if asset.status == PrivatePortraitAssetStatus.PROCESSING.value and asset.poll_count >= PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT: if asset.status == PrivatePortraitAssetStatus.PROCESSING.value and asset.poll_count >= max_count:
asset.status = PrivatePortraitAssetStatus.FAILED.value asset.status = PrivatePortraitAssetStatus.FAILED.value
asset.error_message = "素材入库轮询超时" asset.error_message = "素材入库轮询超时"
asset.next_poll_at = None asset.next_poll_at = None
log_operation_event( log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_POLL_TIMEOUT.value, event_status=PrivatePortraitEventStatus.FAILED.value, source=source, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, detail={"poll_count": asset.poll_count, "max_count": max_count, "remote_asset_id": asset.remote_asset_id, "library_type": asset.library_type, "asset_type": asset.asset_type}, error=asset.error_message)
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_POLL_TIMEOUT.value,
event_status=PrivatePortraitEventStatus.FAILED.value,
source=source,
user_id=asset.user_id,
project_id=asset.project_id,
asset_id=asset.id,
detail={"poll_count": asset.poll_count, "max_count": PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT, "remote_asset_id": asset.remote_asset_id},
error=asset.error_message,
)
elif asset.status == PrivatePortraitAssetStatus.PROCESSING.value: elif asset.status == PrivatePortraitAssetStatus.PROCESSING.value:
asset.next_poll_at = now + timedelta(seconds=PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS) asset.next_poll_at = now + timedelta(seconds=_poll_interval_seconds(asset.asset_type))
else: else:
asset.next_poll_at = None asset.next_poll_at = None
@@ -404,23 +504,25 @@ async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id:
await refresh_project_counters(db, [asset.project_id]) await refresh_project_counters(db, [asset.project_id])
await db.flush() await db.flush()
await db.refresh(asset) await db.refresh(asset)
log_operation_event( log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_SYNC_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=source, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, detail={"status": asset.status, "remote_asset_id": asset.remote_asset_id, "next_poll_at": asset.next_poll_at, "poll_count": asset.poll_count, "library_type": asset.library_type, "asset_type": asset.asset_type})
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_SYNC_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=source,
user_id=asset.user_id,
project_id=asset.project_id,
asset_id=asset.id,
detail={"status": asset.status, "remote_asset_id": asset.remote_asset_id, "next_poll_at": asset.next_poll_at, "poll_count": asset.poll_count},
)
return asset return asset
except Exception as exc: except Exception as exc:
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_SYNC_FAILED.value, source=source, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, exc=exc) log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_SYNC_FAILED.value, source=source, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, exc=exc)
raise raise
async def list_assets(db: AsyncSession, *, user_id: str | None, project_id: str | None = None, status: str | None = None, keyword: str | None = None, page: int = 1, page_size: int = 20) -> tuple[list[PrivatePortraitAsset], int, dict[str, str]]: async def list_assets(
db: AsyncSession,
*,
user_id: str | None,
project_id: str | None = None,
status: str | None = None,
keyword: str | None = None,
page: int = 1,
page_size: int = 20,
library_type: str | None = None,
asset_type: str | None = None,
) -> tuple[list[PrivatePortraitAsset], int, dict[str, str]]:
page = max(1, page) page = max(1, page)
page_size = min(max(1, page_size), 100) page_size = min(max(1, page_size), 100)
filters = [PrivatePortraitAsset.deleted_at.is_(None)] filters = [PrivatePortraitAsset.deleted_at.is_(None)]
@@ -428,6 +530,10 @@ async def list_assets(db: AsyncSession, *, user_id: str | None, project_id: str
filters.append(PrivatePortraitAsset.user_id == user_id) filters.append(PrivatePortraitAsset.user_id == user_id)
if project_id: if project_id:
filters.append(PrivatePortraitAsset.project_id == project_id) filters.append(PrivatePortraitAsset.project_id == project_id)
if library_type:
filters.append(PrivatePortraitAsset.library_type == library_type)
if asset_type:
filters.append(PrivatePortraitAsset.asset_type == asset_type)
if status: if status:
filters.append(PrivatePortraitAsset.status == status) filters.append(PrivatePortraitAsset.status == status)
if keyword: if keyword:
@@ -443,15 +549,35 @@ async def list_assets(db: AsyncSession, *, user_id: str | None, project_id: str
return assets, int(total or 0), project_name_map return assets, int(total or 0), project_name_map
async def list_selectable_assets(db: AsyncSession, *, user_id: str, project_id: str | None = None, keyword: str | None = None, page: int = 1, page_size: int = 20) -> tuple[list[PrivatePortraitSelectableAssetOut], int]: async def list_selectable_assets(db: AsyncSession, *, user_id: str, project_id: str | None = None, keyword: str | None = None, page: int = 1, page_size: int = 20, library_type: str | None = None, asset_type: str | None = None) -> tuple[list[PrivatePortraitSelectableAssetOut], int]:
assets, total, project_name_map = await list_assets(db, user_id=user_id, project_id=project_id, status=PrivatePortraitAssetStatus.ACTIVE.value, keyword=keyword, page=page, page_size=page_size) assets, total, project_name_map = await list_assets(db, user_id=user_id, project_id=project_id, status=PrivatePortraitAssetStatus.ACTIVE.value, keyword=keyword, page=page, page_size=page_size, library_type=library_type, asset_type=asset_type)
return [PrivatePortraitSelectableAssetOut(id=asset.id, project_id=asset.project_id, project_name=project_name_map.get(asset.project_id, ""), name=asset.name, asset_type=asset.asset_type, preview_url=asset.preview_url or asset.remote_url, status=asset.status, created_at=asset.created_at) for asset in assets], total return [
PrivatePortraitSelectableAssetOut(
id=asset.id,
project_id=asset.project_id,
project_name=project_name_map.get(asset.project_id, ""),
library_type=asset.library_type,
name=asset.name,
asset_type=asset.asset_type,
preview_url=asset.preview_url or asset.remote_url,
display_url=_asset_display_url(asset),
provider_url=_provider_url(asset),
video_duration=asset.video_duration,
video_cover_url=asset.video_cover_url,
status=asset.status,
created_at=asset.created_at,
)
for asset in assets
], total
async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str) -> PrivatePortraitAsset: async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str, library_type: str | None = None) -> PrivatePortraitAsset:
asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id, PrivatePortraitAsset.user_id == user_id, PrivatePortraitAsset.deleted_at.is_(None)).limit(1))).scalar_one_or_none() filters = [PrivatePortraitAsset.id == asset_id, PrivatePortraitAsset.user_id == user_id, PrivatePortraitAsset.deleted_at.is_(None)]
if library_type:
filters.append(PrivatePortraitAsset.library_type == library_type)
asset = (await db.execute(select(PrivatePortraitAsset).where(*filters).limit(1))).scalar_one_or_none()
if not asset: if not asset:
raise HTTPException(status_code=404, detail="真人素材不存在") raise HTTPException(status_code=404, detail="私域人像素材不存在")
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
asset.deleted_at = now asset.deleted_at = now
asset.status = PrivatePortraitAssetStatus.LOCAL_DELETED.value asset.status = PrivatePortraitAssetStatus.LOCAL_DELETED.value
@@ -459,64 +585,30 @@ async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str) ->
await refresh_project_counters(db, [asset.project_id]) await refresh_project_counters(db, [asset.project_id])
await db.flush() await db.flush()
await db.refresh(asset) await db.refresh(asset)
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_LOCAL.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name}) log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_LOCAL.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type, "asset_type": asset.asset_type})
return asset return asset
async def delete_asset_remote(db: AsyncSession, *, asset_id: str) -> None: async def delete_asset_remote(db: AsyncSession, *, asset_id: str) -> None:
asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id).limit(1))).scalar_one_or_none() asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id).limit(1))).scalar_one_or_none()
if not asset: if not asset:
log_operation_event( log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value, event_status=PrivatePortraitEventStatus.SKIPPED.value, source=PrivatePortraitEventSource.CELERY.value, asset_id=asset_id, message="远程删除跳过:本地素材不存在")
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.SKIPPED.value,
source=PrivatePortraitEventSource.CELERY.value,
asset_id=asset_id,
message="远程删除跳过:本地素材不存在",
)
return return
if not asset.remote_asset_id: if not asset.remote_asset_id:
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value
asset.remote_delete_error = None asset.remote_delete_error = None
await db.flush() await db.flush()
log_operation_event( log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SKIPPED.value, source=PrivatePortraitEventSource.CELERY.value, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, message="远程删除跳过:素材没有 remote_asset_id")
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SKIPPED.value,
source=PrivatePortraitEventSource.CELERY.value,
user_id=asset.user_id,
project_id=asset.project_id,
asset_id=asset.id,
message="远程删除跳过:素材没有 remote_asset_id",
)
return return
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
log_operation_event( log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type, "asset_type": asset.asset_type})
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.CELERY.value,
user_id=asset.user_id,
project_id=asset.project_id,
asset_id=asset.id,
detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name},
)
try: try:
await ArkPrivateAssetClient(for_celery=True).delete_asset(project_name=asset.remote_project_name, asset_id=asset.remote_asset_id) await ArkPrivateAssetClient(for_celery=True).delete_asset(project_name=asset.remote_project_name, asset_id=asset.remote_asset_id)
asset.status = PrivatePortraitAssetStatus.REMOTE_DELETED.value asset.status = PrivatePortraitAssetStatus.REMOTE_DELETED.value
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
asset.remote_deleted_at = now asset.remote_deleted_at = now
asset.remote_delete_error = None asset.remote_delete_error = None
log_operation_event( log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CELERY.value, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type})
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.CELERY.value,
user_id=asset.user_id,
project_id=asset.project_id,
asset_id=asset.id,
detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name},
)
except Exception as exc: except Exception as exc:
asset.status = PrivatePortraitAssetStatus.DELETE_FAILED.value asset.status = PrivatePortraitAssetStatus.DELETE_FAILED.value
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value
@@ -530,45 +622,18 @@ async def _delete_asset_group_remote(db: AsyncSession, *, group: PrivatePortrait
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value
group.remote_delete_error = None group.remote_delete_error = None
await db.flush() await db.flush()
log_operation_event( log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SKIPPED.value, source=PrivatePortraitEventSource.CELERY.value, user_id=group.user_id, project_id=group.project_id, group_id=group.id, message="远程删除跳过:素材组没有 remote_group_id")
domain=DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SKIPPED.value,
source=PrivatePortraitEventSource.CELERY.value,
user_id=group.user_id,
project_id=group.project_id,
group_id=group.id,
message="远程删除跳过:素材组没有 remote_group_id",
)
return return
client = client or ArkPrivateAssetClient(for_celery=True) client = client or ArkPrivateAssetClient(for_celery=True)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
log_operation_event( log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, user_id=group.user_id, project_id=group.project_id, group_id=group.id, detail={"remote_group_id": group.remote_group_id, "remote_project_name": group.remote_project_name, "library_type": group.library_type})
domain=DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.CELERY.value,
user_id=group.user_id,
project_id=group.project_id,
group_id=group.id,
detail={"remote_group_id": group.remote_group_id, "remote_project_name": group.remote_project_name},
)
try: try:
await client.delete_asset_group(project_name=group.remote_project_name, group_id=group.remote_group_id) await client.delete_asset_group(project_name=group.remote_project_name, group_id=group.remote_group_id)
group.status = PrivatePortraitAssetGroupStatus.REMOTE_DELETED.value group.status = PrivatePortraitAssetGroupStatus.REMOTE_DELETED.value
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
group.remote_deleted_at = now group.remote_deleted_at = now
group.remote_delete_error = None group.remote_delete_error = None
log_operation_event( log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CELERY.value, user_id=group.user_id, project_id=group.project_id, group_id=group.id, detail={"remote_group_id": group.remote_group_id, "remote_project_name": group.remote_project_name, "library_type": group.library_type})
domain=DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.CELERY.value,
user_id=group.user_id,
project_id=group.project_id,
group_id=group.id,
detail={"remote_group_id": group.remote_group_id, "remote_project_name": group.remote_project_name},
)
except Exception as exc: except Exception as exc:
group.status = PrivatePortraitAssetGroupStatus.DELETE_FAILED.value group.status = PrivatePortraitAssetGroupStatus.DELETE_FAILED.value
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value
@@ -578,14 +643,7 @@ async def _delete_asset_group_remote(db: AsyncSession, *, group: PrivatePortrait
async def delete_project_remote(db: AsyncSession, *, project_id: str) -> None: async def delete_project_remote(db: AsyncSession, *, project_id: str) -> None:
log_operation_event( log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, project_id=project_id, message="开始远程删除私域人像素材项目资源")
domain=DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.CELERY.value,
project_id=project_id,
message="开始远程删除真人素材项目资源",
)
rows = await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.project_id == project_id)) rows = await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.project_id == project_id))
for asset in rows.scalars().all(): for asset in rows.scalars().all():
await delete_asset_remote(db, asset_id=asset.id) await delete_asset_remote(db, asset_id=asset.id)
@@ -593,14 +651,7 @@ async def delete_project_remote(db: AsyncSession, *, project_id: str) -> None:
client = ArkPrivateAssetClient(for_celery=True) client = ArkPrivateAssetClient(for_celery=True)
for group in groups.scalars().all(): for group in groups.scalars().all():
await _delete_asset_group_remote(db, group=group, client=client) await _delete_asset_group_remote(db, group=group, client=client)
log_operation_event( log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CELERY.value, project_id=project_id, message="远程删除私域人像素材项目资源完成")
domain=DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.CELERY.value,
project_id=project_id,
message="远程删除真人素材项目资源完成",
)
await db.flush() await db.flush()
@@ -618,13 +669,7 @@ async def poll_due_assets_once(db: AsyncSession, *, limit: int) -> int:
.limit(limit) .limit(limit)
) )
ids = [row[0] for row in rows.all()] ids = [row[0] for row in rows.all()]
log_operation_event( log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.SYNC_DUE_ASSETS_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, detail={"limit": limit, "matched_count": len(ids)})
domain=DOMAIN,
event_type=PrivatePortraitEventType.SYNC_DUE_ASSETS_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.CELERY.value,
detail={"limit": limit, "matched_count": len(ids)},
)
success_count = 0 success_count = 0
failed_count = 0 failed_count = 0
for asset_id in ids: for asset_id in ids:
@@ -633,38 +678,15 @@ async def poll_due_assets_once(db: AsyncSession, *, limit: int) -> int:
success_count += 1 success_count += 1
except Exception as exc: except Exception as exc:
failed_count += 1 failed_count += 1
log_operation_error( log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_POLL_FAILED.value, source=PrivatePortraitEventSource.CELERY.value, asset_id=asset_id, exc=exc)
domain=DOMAIN, log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.SYNC_DUE_ASSETS_DONE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value if failed_count == 0 else PrivatePortraitEventStatus.WARNING.value, source=PrivatePortraitEventSource.CELERY.value, detail={"matched_count": len(ids), "success_count": success_count, "failed_count": failed_count})
event_type=PrivatePortraitEventType.ASSET_POLL_FAILED.value,
source=PrivatePortraitEventSource.CELERY.value,
asset_id=asset_id,
exc=exc,
)
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.SYNC_DUE_ASSETS_DONE.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value if failed_count == 0 else PrivatePortraitEventStatus.WARNING.value,
source=PrivatePortraitEventSource.CELERY.value,
detail={"matched_count": len(ids), "success_count": success_count, "failed_count": failed_count},
)
return len(ids) return len(ids)
async def recover_remote_deletes_once(db: AsyncSession, *, limit: int) -> dict[str, int]: async def recover_remote_deletes_once(db: AsyncSession, *, limit: int) -> dict[str, int]:
log_operation_event( log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, detail={"limit": limit})
domain=DOMAIN,
event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.CELERY.value,
detail={"limit": limit},
)
statuses = [PrivatePortraitRemoteDeleteStatus.PENDING.value, PrivatePortraitRemoteDeleteStatus.FAILED.value] statuses = [PrivatePortraitRemoteDeleteStatus.PENDING.value, PrivatePortraitRemoteDeleteStatus.FAILED.value]
asset_rows = await db.execute( asset_rows = await db.execute(select(PrivatePortraitAsset.id).where(PrivatePortraitAsset.remote_delete_status.in_(statuses)).order_by(PrivatePortraitAsset.updated_at.asc()).limit(limit))
select(PrivatePortraitAsset.id)
.where(PrivatePortraitAsset.remote_delete_status.in_(statuses))
.order_by(PrivatePortraitAsset.updated_at.asc())
.limit(limit)
)
asset_ids = [row[0] for row in asset_rows.all()] asset_ids = [row[0] for row in asset_rows.all()]
for asset_id in asset_ids: for asset_id in asset_ids:
await delete_asset_remote(db, asset_id=asset_id) await delete_asset_remote(db, asset_id=asset_id)
@@ -672,12 +694,7 @@ async def recover_remote_deletes_once(db: AsyncSession, *, limit: int) -> dict[s
remaining = max(0, limit - len(asset_ids)) remaining = max(0, limit - len(asset_ids))
group_count = 0 group_count = 0
if remaining > 0: if remaining > 0:
group_rows = await db.execute( group_rows = await db.execute(select(PrivatePortraitAssetGroup).where(PrivatePortraitAssetGroup.remote_delete_status.in_(statuses)).order_by(PrivatePortraitAssetGroup.updated_at.asc()).limit(remaining))
select(PrivatePortraitAssetGroup)
.where(PrivatePortraitAssetGroup.remote_delete_status.in_(statuses))
.order_by(PrivatePortraitAssetGroup.updated_at.asc())
.limit(remaining)
)
client = ArkPrivateAssetClient(for_celery=True) client = ArkPrivateAssetClient(for_celery=True)
groups = list(group_rows.scalars().all()) groups = list(group_rows.scalars().all())
group_count = len(groups) group_count = len(groups)
@@ -685,12 +702,5 @@ async def recover_remote_deletes_once(db: AsyncSession, *, limit: int) -> dict[s
await _delete_asset_group_remote(db, group=group, client=client) await _delete_asset_group_remote(db, group=group, client=client)
result = {"asset_count": len(asset_ids), "group_count": group_count, "total_count": len(asset_ids) + group_count} result = {"asset_count": len(asset_ids), "group_count": group_count, "total_count": len(asset_ids) + group_count}
log_operation_event( log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_DONE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CELERY.value, detail=result)
domain=DOMAIN,
event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_DONE.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.CELERY.value,
detail=result,
)
return result return result
@@ -11,13 +11,15 @@ from app.enums.private_portrait import (
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME, PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
PrivatePortraitAssetGroupStatus, PrivatePortraitAssetGroupStatus,
PrivatePortraitAssetStatus, PrivatePortraitAssetStatus,
PrivatePortraitAssetType,
PrivatePortraitEventSource, PrivatePortraitEventSource,
PrivatePortraitEventStatus, PrivatePortraitEventStatus,
PrivatePortraitEventType, PrivatePortraitEventType,
PrivatePortraitLibraryType,
PrivatePortraitProjectStatus, PrivatePortraitProjectStatus,
PrivatePortraitRemoteDeleteStatus, PrivatePortraitRemoteDeleteStatus,
) )
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject, PrivatePortraitValidateSession from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject
from app.schemas.private_portrait import PrivatePortraitProjectCreate, PrivatePortraitProjectOut, PrivatePortraitProjectUpdate from app.schemas.private_portrait import PrivatePortraitProjectCreate, PrivatePortraitProjectOut, PrivatePortraitProjectUpdate
from app.services.operation_log_service import log_operation_event from app.services.operation_log_service import log_operation_event
from app.utils.id_gen import generate_id from app.utils.id_gen import generate_id
@@ -27,16 +29,24 @@ DOMAIN = "private_portrait"
def _safe_slug(value: str, *, max_length: int = 80) -> str: def _safe_slug(value: str, *, max_length: int = 80) -> str:
value = (value or "").strip().lower() value = (value or "").strip().lower()
# 先保留常见英文数字连字符;中文等字符统一转 _,仅用于本地项目 slug。
value = re.sub(r"[^a-z0-9_-]+", "_", value) value = re.sub(r"[^a-z0-9_-]+", "_", value)
value = re.sub(r"_+", "_", value).strip("_-") value = re.sub(r"_+", "_", value).strip("_-")
return (value[:max_length] or "project") return (value[:max_length] or "project")
def _status_for_created_project(library_type: str) -> str:
if library_type == PrivatePortraitLibraryType.REAL_PERSON.value:
return PrivatePortraitProjectStatus.VALIDATING.value
if library_type == PrivatePortraitLibraryType.AIGC_VIRTUAL.value:
return PrivatePortraitProjectStatus.CREATING_REMOTE_GROUP.value
raise HTTPException(status_code=400, detail="library_type 不支持")
def project_to_out(project: PrivatePortraitProject, *, include_user: bool = False) -> PrivatePortraitProjectOut: def project_to_out(project: PrivatePortraitProject, *, include_user: bool = False) -> PrivatePortraitProjectOut:
return PrivatePortraitProjectOut( return PrivatePortraitProjectOut(
id=project.id, id=project.id,
user_id=project.user_id if include_user else None, user_id=project.user_id if include_user else None,
library_type=project.library_type,
name=project.name, name=project.name,
name_slug=project.name_slug, name_slug=project.name_slug,
remote_project_name=project.remote_project_name, remote_project_name=project.remote_project_name,
@@ -44,72 +54,100 @@ def project_to_out(project: PrivatePortraitProject, *, include_user: bool = Fals
status=project.status, status=project.status,
asset_group_count=project.asset_group_count or 0, asset_group_count=project.asset_group_count or 0,
asset_count=project.asset_count or 0, asset_count=project.asset_count or 0,
image_asset_count=getattr(project, "image_asset_count", 0) or 0,
video_asset_count=getattr(project, "video_asset_count", 0) or 0,
active_asset_count=project.active_asset_count or 0, active_asset_count=project.active_asset_count or 0,
active_image_asset_count=getattr(project, "active_image_asset_count", 0) or 0,
active_video_asset_count=getattr(project, "active_video_asset_count", 0) or 0,
last_used_at=project.last_used_at, last_used_at=project.last_used_at,
created_at=project.created_at, created_at=project.created_at,
updated_at=project.updated_at, updated_at=project.updated_at,
) )
async def get_user_project(db: AsyncSession, *, user_id: str, project_id: str) -> PrivatePortraitProject: async def get_user_project(
result = await db.execute( db: AsyncSession,
select(PrivatePortraitProject).where( *,
PrivatePortraitProject.id == project_id, user_id: str,
PrivatePortraitProject.user_id == user_id, project_id: str,
PrivatePortraitProject.deleted_at.is_(None), library_type: str | None = None,
).limit(1) ) -> PrivatePortraitProject:
) filters = [
PrivatePortraitProject.id == project_id,
PrivatePortraitProject.user_id == user_id,
PrivatePortraitProject.deleted_at.is_(None),
]
if library_type:
filters.append(PrivatePortraitProject.library_type == library_type)
result = await db.execute(select(PrivatePortraitProject).where(*filters).limit(1))
project = result.scalar_one_or_none() project = result.scalar_one_or_none()
if not project: if not project:
raise HTTPException(status_code=404, detail="真人素材项目不存在") raise HTTPException(status_code=404, detail="私域人像素材项目不存在")
return project return project
async def create_project(db: AsyncSession, *, user_id: str, payload: PrivatePortraitProjectCreate) -> PrivatePortraitProject: async def create_project(
db: AsyncSession,
*,
user_id: str,
payload: PrivatePortraitProjectCreate,
library_type: str = PrivatePortraitLibraryType.REAL_PERSON.value,
status: str | None = None,
remote_project_name: str = PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
) -> PrivatePortraitProject:
slug = _safe_slug(payload.name) slug = _safe_slug(payload.name)
project = PrivatePortraitProject( project = PrivatePortraitProject(
id=generate_id(), id=generate_id(),
user_id=user_id, user_id=user_id,
library_type=library_type,
name=payload.name.strip(), name=payload.name.strip(),
name_slug=slug, name_slug=slug,
remote_project_name=PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME, remote_project_name=remote_project_name,
description=payload.description, description=payload.description,
status=PrivatePortraitProjectStatus.ACTIVE.value, status=status or _status_for_created_project(library_type),
) )
db.add(project) db.add(project)
await db.flush() await db.flush()
log_operation_event( log_operation_event(
domain=DOMAIN, domain=DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_CREATE.value, event_type=PrivatePortraitEventType.PROJECT_CREATE.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value, event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.API.value, source=PrivatePortraitEventSource.API.value,
user_id=user_id, user_id=user_id,
project_id=project.id, project_id=project.id,
message="创建真人素材项目", message="创建私域人像素材项目",
detail={"name": project.name, "remote_project_name": project.remote_project_name}, detail={"name": project.name, "library_type": library_type, "remote_project_name": project.remote_project_name, "status": project.status},
) )
return project return project
async def update_project(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitProjectUpdate) -> PrivatePortraitProject: async def update_project(
project = await get_user_project(db, user_id=user_id, project_id=project_id) db: AsyncSession,
*,
user_id: str,
project_id: str,
payload: PrivatePortraitProjectUpdate,
library_type: str | None = None,
) -> PrivatePortraitProject:
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
before = { before = {
"name": project.name, "name": project.name,
"name_slug": project.name_slug, "name_slug": project.name_slug,
"remote_project_name": project.remote_project_name, "remote_project_name": project.remote_project_name,
"description": project.description, "description": project.description,
"status": project.status, "status": project.status,
"library_type": project.library_type,
} }
if payload.name is not None: if payload.name is not None:
new_name = payload.name.strip() new_name = payload.name.strip()
if new_name and new_name != project.name: if new_name and new_name != project.name:
project.name = new_name project.name = new_name
project.name_slug = _safe_slug(new_name) project.name_slug = _safe_slug(new_name)
project.remote_project_name = PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME
if payload.description is not None: if payload.description is not None:
project.description = payload.description project.description = payload.description
if payload.status is not None: if payload.status is not None:
if payload.status not in {PrivatePortraitProjectStatus.ACTIVE.value}: allowed_statuses = {item.value for item in PrivatePortraitProjectStatus if item != PrivatePortraitProjectStatus.DELETED}
if payload.status not in allowed_statuses:
raise HTTPException(status_code=400, detail="项目状态不支持") raise HTTPException(status_code=400, detail="项目状态不支持")
project.status = payload.status project.status = payload.status
await db.flush() await db.flush()
@@ -119,6 +157,7 @@ async def update_project(db: AsyncSession, *, user_id: str, project_id: str, pay
"remote_project_name": project.remote_project_name, "remote_project_name": project.remote_project_name,
"description": project.description, "description": project.description,
"status": project.status, "status": project.status,
"library_type": project.library_type,
} }
log_operation_event( log_operation_event(
domain=DOMAIN, domain=DOMAIN,
@@ -127,24 +166,41 @@ async def update_project(db: AsyncSession, *, user_id: str, project_id: str, pay
source=PrivatePortraitEventSource.API.value, source=PrivatePortraitEventSource.API.value,
user_id=user_id, user_id=user_id,
project_id=project.id, project_id=project.id,
message="更新真人素材项目", message="更新私域人像素材项目",
detail={"before": before, "after": after}, detail={"before": before, "after": after},
) )
return project return project
async def list_projects(db: AsyncSession, *, user_id: str | None, page: int = 1, page_size: int = 20, keyword: str | None = None, status: str | None = None) -> tuple[list[PrivatePortraitProject], int]: async def list_projects(
db: AsyncSession,
*,
user_id: str | None,
page: int = 1,
page_size: int = 20,
keyword: str | None = None,
status: str | None = None,
library_type: str | None = None,
) -> tuple[list[PrivatePortraitProject], int]:
page = max(1, page) page = max(1, page)
page_size = min(max(1, page_size), 100) page_size = min(max(1, page_size), 100)
filters = [PrivatePortraitProject.deleted_at.is_(None)] filters = [PrivatePortraitProject.deleted_at.is_(None)]
if user_id: if user_id:
filters.append(PrivatePortraitProject.user_id == user_id) filters.append(PrivatePortraitProject.user_id == user_id)
if library_type:
filters.append(PrivatePortraitProject.library_type == library_type)
if keyword: if keyword:
filters.append(PrivatePortraitProject.name.ilike(f"%{keyword.strip()}%")) filters.append(PrivatePortraitProject.name.ilike(f"%{keyword.strip()}%"))
if status: if status:
filters.append(PrivatePortraitProject.status == status) filters.append(PrivatePortraitProject.status == status)
total = (await db.execute(select(func.count(PrivatePortraitProject.id)).where(*filters))).scalar_one() total = (await db.execute(select(func.count(PrivatePortraitProject.id)).where(*filters))).scalar_one()
result = await db.execute(select(PrivatePortraitProject).where(*filters).order_by(PrivatePortraitProject.created_at.desc()).offset((page - 1) * page_size).limit(page_size)) result = await db.execute(
select(PrivatePortraitProject)
.where(*filters)
.order_by(PrivatePortraitProject.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
return list(result.scalars().all()), int(total or 0) return list(result.scalars().all()), int(total or 0)
@@ -161,27 +217,74 @@ async def refresh_project_counters(db: AsyncSession, project_ids: list[str]) ->
select( select(
PrivatePortraitAsset.project_id, PrivatePortraitAsset.project_id,
func.count(PrivatePortraitAsset.id), func.count(PrivatePortraitAsset.id),
func.sum(case((PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.IMAGE.value, 1), else_=0)),
func.sum(case((PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.VIDEO.value, 1), else_=0)),
func.sum(case((PrivatePortraitAsset.status == PrivatePortraitAssetStatus.ACTIVE.value, 1), else_=0)), func.sum(case((PrivatePortraitAsset.status == PrivatePortraitAssetStatus.ACTIVE.value, 1), else_=0)),
func.sum(case(((PrivatePortraitAsset.status == PrivatePortraitAssetStatus.ACTIVE.value) & (PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.IMAGE.value), 1), else_=0)),
func.sum(case(((PrivatePortraitAsset.status == PrivatePortraitAssetStatus.ACTIVE.value) & (PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.VIDEO.value), 1), else_=0)),
) )
.where(PrivatePortraitAsset.project_id.in_(project_ids), PrivatePortraitAsset.deleted_at.is_(None)) .where(PrivatePortraitAsset.project_id.in_(project_ids), PrivatePortraitAsset.deleted_at.is_(None))
.group_by(PrivatePortraitAsset.project_id) .group_by(PrivatePortraitAsset.project_id)
) )
group_count_map = {pid: int(count or 0) for pid, count in group_rows.all()} group_count_map = {pid: int(count or 0) for pid, count in group_rows.all()}
asset_count_map: dict[str, tuple[int, int]] = {} asset_count_map: dict[str, tuple[int, int, int, int, int, int]] = {}
for pid, total, active_total in asset_rows.all(): for pid, total, image_total, video_total, active_total, active_image_total, active_video_total in asset_rows.all():
asset_count_map[pid] = (int(total or 0), int(active_total or 0)) asset_count_map[pid] = (
int(total or 0),
int(image_total or 0),
int(video_total or 0),
int(active_total or 0),
int(active_image_total or 0),
int(active_video_total or 0),
)
for pid in project_ids: for pid in project_ids:
total, active_total = asset_count_map.get(pid, (0, 0)) total, image_total, video_total, active_total, active_image_total, active_video_total = asset_count_map.get(pid, (0, 0, 0, 0, 0, 0))
await db.execute(update(PrivatePortraitProject).where(PrivatePortraitProject.id == pid).values(asset_group_count=group_count_map.get(pid, 0), asset_count=total, active_asset_count=active_total)) await db.execute(
update(PrivatePortraitProject)
.where(PrivatePortraitProject.id == pid)
.values(
asset_group_count=group_count_map.get(pid, 0),
asset_count=total,
image_asset_count=image_total,
video_asset_count=video_total,
active_asset_count=active_total,
active_image_asset_count=active_image_total,
active_video_asset_count=active_video_total,
)
.execution_options(synchronize_session=False)
)
async def soft_delete_project(db: AsyncSession, *, user_id: str, project_id: str) -> PrivatePortraitProject: async def soft_delete_project(
project = await get_user_project(db, user_id=user_id, project_id=project_id) db: AsyncSession,
*,
user_id: str,
project_id: str,
library_type: str | None = None,
) -> PrivatePortraitProject:
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
project.deleted_at = now project.deleted_at = now
project.status = PrivatePortraitProjectStatus.DELETED.value project.status = PrivatePortraitProjectStatus.DELETED.value
await db.execute(update(PrivatePortraitAsset).where(PrivatePortraitAsset.project_id == project_id, PrivatePortraitAsset.deleted_at.is_(None)).values(deleted_at=now, status=PrivatePortraitAssetStatus.LOCAL_DELETED.value, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)) await db.execute(
await db.execute(update(PrivatePortraitAssetGroup).where(PrivatePortraitAssetGroup.project_id == project_id, PrivatePortraitAssetGroup.deleted_at.is_(None)).values(deleted_at=now, status=PrivatePortraitAssetGroupStatus.LOCAL_DELETED.value, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)) update(PrivatePortraitAsset)
.where(PrivatePortraitAsset.project_id == project_id, PrivatePortraitAsset.deleted_at.is_(None))
.values(deleted_at=now, status=PrivatePortraitAssetStatus.LOCAL_DELETED.value, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
)
await db.execute(
update(PrivatePortraitAssetGroup)
.where(PrivatePortraitAssetGroup.project_id == project_id, PrivatePortraitAssetGroup.deleted_at.is_(None))
.values(deleted_at=now, status=PrivatePortraitAssetGroupStatus.LOCAL_DELETED.value, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
)
await db.flush() await db.flush()
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, message="本地软删真人素材项目", detail={"remote_project_name": project.remote_project_name}) log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.API.value,
user_id=user_id,
project_id=project.id,
message="本地软删私域人像素材项目",
detail={"library_type": project.library_type, "remote_project_name": project.remote_project_name},
)
return project return project
@@ -0,0 +1,141 @@
from __future__ import annotations
from fastapi import HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.private_portrait import (
PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT,
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
PrivatePortraitAssetStatus,
PrivatePortraitEventSource,
PrivatePortraitEventStatus,
PrivatePortraitEventType,
PrivatePortraitLibraryType,
)
from app.models.private_portrait import PrivatePortraitAsset
from app.models.user import User
from app.schemas.private_portrait import PrivatePortraitConfigOut
from app.services.operation_log_service import log_operation_event
DOMAIN = "private_portrait"
_COUNTING_STATUSES = {
PrivatePortraitAssetStatus.CREATING.value,
PrivatePortraitAssetStatus.PROCESSING.value,
PrivatePortraitAssetStatus.ACTIVE.value,
}
_COUNTING_LIBRARY_TYPES = {
PrivatePortraitLibraryType.REAL_PERSON.value,
PrivatePortraitLibraryType.AIGC_VIRTUAL.value,
}
async def get_user_or_404(db: AsyncSession, *, user_id: str, for_update: bool = False) -> User:
stmt = select(User).where(User.id == user_id).limit(1)
if for_update:
stmt = stmt.with_for_update()
user = (await db.execute(stmt)).scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
return user
def get_user_asset_limit_value(user: User) -> int:
return int(getattr(user, "private_portrait_asset_limit", PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT) or 0)
async def count_user_counting_assets(db: AsyncSession, *, user_id: str) -> int:
total = (
await db.execute(
select(func.count(PrivatePortraitAsset.id)).where(
PrivatePortraitAsset.user_id == user_id,
PrivatePortraitAsset.library_type.in_(_COUNTING_LIBRARY_TYPES),
PrivatePortraitAsset.asset_type.in_(PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES),
PrivatePortraitAsset.deleted_at.is_(None),
PrivatePortraitAsset.status.in_(_COUNTING_STATUSES),
)
)
).scalar_one()
return int(total or 0)
async def get_user_private_portrait_config(db: AsyncSession, *, user_id: str) -> PrivatePortraitConfigOut:
user = await get_user_or_404(db, user_id=user_id)
limit = get_user_asset_limit_value(user)
used = await count_user_counting_assets(db, user_id=user_id)
remaining = max(0, limit - used) if limit > 0 else 0
return PrivatePortraitConfigOut(
enabled=limit > 0,
asset_limit=limit,
used_asset_count=used,
remaining_asset_count=remaining,
image_limit=limit,
used_image_count=used,
remaining_image_count=remaining,
)
async def set_user_private_portrait_limit(db: AsyncSession, *, user_id: str, limit: int) -> User:
user = await get_user_or_404(db, user_id=user_id)
user.private_portrait_asset_limit = max(0, int(limit))
await db.flush()
return user
async def ensure_private_portrait_asset_quota_available(
db: AsyncSession,
*,
user_id: str,
project_id: str | None = None,
library_type: str | None = None,
asset_type: str | None = None,
) -> tuple[int, int]:
user = await get_user_or_404(db, user_id=user_id, for_update=True)
limit = get_user_asset_limit_value(user)
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.QUOTA_CHECK_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.SERVICE.value,
user_id=user_id,
project_id=project_id,
detail={"asset_limit": limit, "library_type": library_type, "asset_type": asset_type},
)
if limit <= 0:
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.QUOTA_CHECK_DENY.value,
event_status=PrivatePortraitEventStatus.FAILED.value,
source=PrivatePortraitEventSource.SERVICE.value,
user_id=user_id,
project_id=project_id,
detail={"asset_limit": limit, "library_type": library_type, "asset_type": asset_type, "reason": "disabled"},
message="用户私域人像素材库未启用",
)
raise HTTPException(status_code=403, detail="私域人像素材库未启用")
used = await count_user_counting_assets(db, user_id=user_id)
if used >= limit:
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.QUOTA_CHECK_DENY.value,
event_status=PrivatePortraitEventStatus.FAILED.value,
source=PrivatePortraitEventSource.SERVICE.value,
user_id=user_id,
project_id=project_id,
detail={"asset_limit": limit, "used_asset_count": used, "library_type": library_type, "asset_type": asset_type, "reason": "max_limit"},
message="用户私域人像素材总量已达上限",
)
raise HTTPException(status_code=400, detail=f"你的私域人像素材库最多可上传 {limit} 个素材,请删除已有素材后再上传")
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.QUOTA_CHECK_PASS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.SERVICE.value,
user_id=user_id,
project_id=project_id,
detail={"asset_limit": limit, "used_asset_count": used, "remaining_asset_count": max(0, limit - used), "library_type": library_type, "asset_type": asset_type},
)
return limit, used
@@ -0,0 +1 @@
from app.services.private_portrait.real_person.service import *
@@ -0,0 +1,24 @@
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.private_portrait import PrivatePortraitLibraryType
from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitProjectCreate, PrivatePortraitProjectUpdate
from app.services.private_portrait.asset_service import create_asset, create_validate_session
from app.services.private_portrait.project_service import create_project, update_project
async def create_real_person_project(db: AsyncSession, *, user_id: str, payload: PrivatePortraitProjectCreate):
return await create_project(db, user_id=user_id, payload=payload, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
async def update_real_person_project(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitProjectUpdate):
return await update_project(db, user_id=user_id, project_id=project_id, payload=payload, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
async def create_real_person_validate_session(db: AsyncSession, *, user_id: str, project_id: str, callback_redirect_url: str | None = None):
return await create_validate_session(db, user_id=user_id, project_id=project_id, callback_redirect_url=callback_redirect_url)
async def create_real_person_asset(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitAssetCreate):
return await create_asset(db, user_id=user_id, project_id=project_id, payload=payload, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
@@ -4,12 +4,13 @@ from copy import deepcopy
from typing import Any from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import select from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.private_portrait import ( from app.enums.private_portrait import (
PRIVATE_PORTRAIT_ASSET_URI_PREFIX, PRIVATE_PORTRAIT_ASSET_URI_PREFIX,
PrivatePortraitAssetStatus, PrivatePortraitAssetStatus,
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
PrivatePortraitAssetType, PrivatePortraitAssetType,
PrivatePortraitEventSource, PrivatePortraitEventSource,
PrivatePortraitEventStatus, PrivatePortraitEventStatus,
@@ -25,7 +26,6 @@ DOMAIN = "private_portrait"
_ASSET_TYPE_TO_REFERENCE_TYPE = { _ASSET_TYPE_TO_REFERENCE_TYPE = {
PrivatePortraitAssetType.IMAGE.value: "image", PrivatePortraitAssetType.IMAGE.value: "image",
PrivatePortraitAssetType.VIDEO.value: "video", PrivatePortraitAssetType.VIDEO.value: "video",
PrivatePortraitAssetType.AUDIO.value: "audio",
} }
@@ -58,6 +58,162 @@ def _normalize_ref_type(value: Any) -> str:
return str(value or "").strip().lower() return str(value or "").strip().lower()
def _remote_asset_id_from_asset_uri(url: Any) -> str | None:
value = str(url or "").strip()
if not value.startswith(PRIVATE_PORTRAIT_ASSET_URI_PREFIX):
return None
remote_asset_id = value[len(PRIVATE_PORTRAIT_ASSET_URI_PREFIX):].strip()
return remote_asset_id or None
def _asset_display_url(asset: PrivatePortraitAsset) -> str | None:
# preview_url 是本地上传预览,remote_url 是火山 GetAsset 返回的远程资源 URLsource_url 是兜底公网上传地址。
return asset.preview_url or asset.remote_url or asset.source_url or None
def _fill_private_portrait_reference_display_fields(ref: Any, asset: PrivatePortraitAsset) -> None:
provider_url = str(_ref_get(ref, "provider_url") or _ref_get(ref, "url") or "").strip()
if provider_url.startswith(PRIVATE_PORTRAIT_ASSET_URI_PREFIX):
_ref_set(ref, "provider_url", provider_url)
display_url = _asset_display_url(asset)
if display_url:
# 返回给前端的 url 必须可预览;供应商专用 asset:// 保留到 provider_url,避免管理后台和客户端展示黑图。
_ref_set(ref, "url", display_url)
_ref_set(ref, "display_url", display_url)
_ref_set(ref, "preview_url", display_url)
_ref_set(ref, "source", PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value)
_ref_set(ref, "private_asset_id", asset.id)
if asset.remote_asset_id:
_ref_set(ref, "remote_asset_id", asset.remote_asset_id)
expected_ref_type = _ASSET_TYPE_TO_REFERENCE_TYPE.get(asset.asset_type)
if expected_ref_type:
_ref_set(ref, "type", expected_ref_type)
if not _ref_get(ref, "name") and asset.name:
_ref_set(ref, "name", asset.name)
async def resolve_private_portrait_reference_display_urls(
db: AsyncSession,
media_references: list[Any] | None,
*,
user_id: str | None = None,
) -> list[Any] | None:
"""把历史响应里的 asset:// 引用补成前端可预览 URL。
生成任务入库时 url 使用 asset://remote_asset_id 传给供应商;但客户端/管理后台展示不能直接用
asset://。这里批量根据 private_asset_id 或 asset://remote_asset_id 查本地素材,并把响应中的 url
改成 preview_url/remote_url/source_url,同时保留 provider_url=asset://... 供排查。
"""
if not media_references:
return media_references
refs = deepcopy(media_references)
private_asset_ids: list[str] = []
remote_asset_ids: list[str] = []
for ref in refs:
source = _ref_get(ref, "source")
private_asset_id = _ref_get(ref, "private_asset_id")
remote_asset_id = _ref_get(ref, "remote_asset_id") or _remote_asset_id_from_asset_uri(_ref_get(ref, "url"))
if source == PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value or remote_asset_id:
if private_asset_id:
private_asset_ids.append(str(private_asset_id))
if remote_asset_id:
remote_asset_ids.append(str(remote_asset_id))
private_asset_ids = list(dict.fromkeys(private_asset_ids))
remote_asset_ids = list(dict.fromkeys(remote_asset_ids))
if not private_asset_ids and not remote_asset_ids:
return refs
filters = []
if private_asset_ids:
filters.append(PrivatePortraitAsset.id.in_(private_asset_ids))
if remote_asset_ids:
filters.append(PrivatePortraitAsset.remote_asset_id.in_(remote_asset_ids))
stmt = select(PrivatePortraitAsset).where(or_(*filters))
if user_id is not None:
stmt = stmt.where(PrivatePortraitAsset.user_id == user_id)
rows = await db.execute(stmt)
assets = list(rows.scalars().all())
by_id = {asset.id: asset for asset in assets}
by_remote_id = {asset.remote_asset_id: asset for asset in assets if asset.remote_asset_id}
for ref in refs:
private_asset_id = str(_ref_get(ref, "private_asset_id") or "").strip()
remote_asset_id = str(_ref_get(ref, "remote_asset_id") or _remote_asset_id_from_asset_uri(_ref_get(ref, "url")) or "").strip()
asset = by_id.get(private_asset_id) or by_remote_id.get(remote_asset_id)
if not asset:
continue
_fill_private_portrait_reference_display_fields(ref, asset)
return refs
async def batch_resolve_private_portrait_reference_display_urls(
db: AsyncSession,
references_by_key: dict[Any, list[Any] | None],
*,
user_id: str | None = None,
) -> dict[Any, list[Any] | None]:
if not references_by_key:
return {}
copied: dict[Any, list[Any] | None] = {
key: deepcopy(refs) if refs else refs
for key, refs in references_by_key.items()
}
private_asset_ids: list[str] = []
remote_asset_ids: list[str] = []
for refs in copied.values():
if not refs:
continue
for ref in refs:
source = _ref_get(ref, "source")
private_asset_id = _ref_get(ref, "private_asset_id")
remote_asset_id = _ref_get(ref, "remote_asset_id") or _remote_asset_id_from_asset_uri(_ref_get(ref, "url"))
if source == PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value or remote_asset_id:
if private_asset_id:
private_asset_ids.append(str(private_asset_id))
if remote_asset_id:
remote_asset_ids.append(str(remote_asset_id))
private_asset_ids = list(dict.fromkeys(private_asset_ids))
remote_asset_ids = list(dict.fromkeys(remote_asset_ids))
if not private_asset_ids and not remote_asset_ids:
return copied
filters = []
if private_asset_ids:
filters.append(PrivatePortraitAsset.id.in_(private_asset_ids))
if remote_asset_ids:
filters.append(PrivatePortraitAsset.remote_asset_id.in_(remote_asset_ids))
stmt = select(PrivatePortraitAsset).where(or_(*filters))
if user_id is not None:
stmt = stmt.where(PrivatePortraitAsset.user_id == user_id)
rows = await db.execute(stmt)
assets = list(rows.scalars().all())
by_id = {asset.id: asset for asset in assets}
by_remote_id = {asset.remote_asset_id: asset for asset in assets if asset.remote_asset_id}
for refs in copied.values():
if not refs:
continue
for ref in refs:
private_asset_id = str(_ref_get(ref, "private_asset_id") or "").strip()
remote_asset_id = str(_ref_get(ref, "remote_asset_id") or _remote_asset_id_from_asset_uri(_ref_get(ref, "url")) or "").strip()
asset = by_id.get(private_asset_id) or by_remote_id.get(remote_asset_id)
if not asset:
continue
_fill_private_portrait_reference_display_fields(ref, asset)
return copied
async def resolve_private_portrait_references( async def resolve_private_portrait_references(
db: AsyncSession, db: AsyncSession,
*, *,
@@ -104,29 +260,37 @@ async def resolve_private_portrait_references(
asset_id = str(_ref_get(ref, "private_asset_id") or "") asset_id = str(_ref_get(ref, "private_asset_id") or "")
if not asset_id: if not asset_id:
raise HTTPException(status_code=400, detail="真人素材引用缺少 private_asset_id") raise HTTPException(status_code=400, detail="私域人像素材引用缺少 private_asset_id")
asset = asset_map.get(asset_id) asset = asset_map.get(asset_id)
if not asset: if not asset:
raise HTTPException(status_code=400, detail="真人素材不存在") raise HTTPException(status_code=400, detail="私域人像素材不存在")
if asset.user_id != user_id: if asset.user_id != user_id:
raise HTTPException(status_code=403, detail="真人素材不属于当前用户") raise HTTPException(status_code=403, detail="私域人像素材不属于当前用户")
if asset.deleted_at is not None: if asset.deleted_at is not None:
raise HTTPException(status_code=400, detail="真人素材已删除") raise HTTPException(status_code=400, detail="私域人像素材已删除")
if asset.asset_type not in PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES:
raise HTTPException(status_code=400, detail="Audio 暂未开放,当前仅支持 Image/Video 私域素材")
if asset.status != PrivatePortraitAssetStatus.ACTIVE.value: if asset.status != PrivatePortraitAssetStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail=f"真人素材状态为 {asset.status}Active 后才可用于生成") raise HTTPException(status_code=400, detail=f"私域人像素材状态为 {asset.status}Active 后才可用于生成")
if not asset.remote_asset_id: if not asset.remote_asset_id:
raise HTTPException(status_code=400, detail="真人素材缺少远程 AssetId") raise HTTPException(status_code=400, detail="私域人像素材缺少远程 AssetId")
expected_ref_type = _ASSET_TYPE_TO_REFERENCE_TYPE.get(asset.asset_type) expected_ref_type = _ASSET_TYPE_TO_REFERENCE_TYPE.get(asset.asset_type)
ref_type = _normalize_ref_type(_ref_get(ref, "type")) ref_type = _normalize_ref_type(_ref_get(ref, "type"))
if expected_ref_type and ref_type and ref_type != expected_ref_type: if expected_ref_type and ref_type and ref_type != expected_ref_type:
raise HTTPException(status_code=400, detail=f"真人素材类型不匹配:引用为 {ref_type},素材为 {expected_ref_type}") raise HTTPException(status_code=400, detail=f"私域人像素材类型不匹配:引用为 {ref_type},素材为 {expected_ref_type}")
provider_url = f"{PRIVATE_PORTRAIT_ASSET_URI_PREFIX}{asset.remote_asset_id}"
_ref_set(ref, "source", PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value) _ref_set(ref, "source", PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value)
_ref_set(ref, "private_asset_id", asset.id) _ref_set(ref, "private_asset_id", asset.id)
_ref_set(ref, "remote_asset_id", asset.remote_asset_id) _ref_set(ref, "remote_asset_id", asset.remote_asset_id)
_ref_set(ref, "url", f"{PRIVATE_PORTRAIT_ASSET_URI_PREFIX}{asset.remote_asset_id}") _ref_set(ref, "url", provider_url)
_ref_set(ref, "provider_url", provider_url)
display_url = _asset_display_url(asset)
if display_url:
_ref_set(ref, "display_url", display_url)
_ref_set(ref, "preview_url", display_url)
if expected_ref_type: if expected_ref_type:
_ref_set(ref, "type", expected_ref_type) _ref_set(ref, "type", expected_ref_type)
if not _ref_get(ref, "name") and asset.name: if not _ref_get(ref, "name") and asset.name:
@@ -0,0 +1 @@
from app.services.private_portrait.virtual.service import *
@@ -0,0 +1,126 @@
from __future__ import annotations
import json
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.private_portrait import (
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE,
PrivatePortraitAssetGroupStatus,
PrivatePortraitEventSource,
PrivatePortraitEventStatus,
PrivatePortraitEventType,
PrivatePortraitLibraryType,
PrivatePortraitProjectStatus,
)
from app.models.private_portrait import PrivatePortraitAssetGroup
from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitProjectUpdate, PrivatePortraitVirtualProjectCreate
from app.services.operation_log_service import log_operation_error, log_operation_event
from app.services.private_portrait.ark_client import ArkPrivateAssetClient
from app.services.private_portrait.asset_service import create_asset
from app.services.private_portrait.project_service import create_project, refresh_project_counters, update_project
from app.utils.id_gen import generate_id
DOMAIN = "private_portrait"
def _json(data) -> str | None:
if data is None:
return None
return json.dumps(data, ensure_ascii=False, default=str)
def _remote_group_name(user_id: str, project_name: str) -> str:
safe_name = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in project_name.strip())[:80]
return f"virtual-{user_id}-{safe_name}"[:128]
async def create_virtual_project(db: AsyncSession, *, user_id: str, payload: PrivatePortraitVirtualProjectCreate):
project = await create_project(
db,
user_id=user_id,
payload=payload, # type: ignore[arg-type]
library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value,
status=PrivatePortraitProjectStatus.CREATING_REMOTE_GROUP.value,
remote_project_name=PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
)
remote_group_name = _remote_group_name(user_id, project.name)
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_GROUP_CREATE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.API.value,
user_id=user_id,
project_id=project.id,
detail={"remote_group_name": remote_group_name, "remote_project_name": project.remote_project_name, "group_type": PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE},
)
try:
resp = await ArkPrivateAssetClient().create_asset_group(
project_name=project.remote_project_name,
name=remote_group_name,
description=project.description,
group_type=PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE,
)
remote_group_id = resp.get("Id") or resp.get("GroupId") or resp.get("groupId")
if not remote_group_id:
raise RuntimeError("CreateAssetGroup 未返回素材组 ID")
group = PrivatePortraitAssetGroup(
id=generate_id(),
user_id=user_id,
project_id=project.id,
library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value,
remote_group_id=remote_group_id,
remote_group_name=remote_group_name,
remote_project_name=project.remote_project_name,
group_type=PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE,
status=PrivatePortraitAssetGroupStatus.ACTIVE.value,
raw_response_json=_json(resp),
)
db.add(group)
project.status = PrivatePortraitProjectStatus.ACTIVE.value
await refresh_project_counters(db, [project.id])
await db.flush()
await db.refresh(project)
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_GROUP_CREATE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.API.value,
user_id=user_id,
project_id=project.id,
group_id=group.id,
detail={"remote_group_id": remote_group_id, "remote_group_name": remote_group_name, "remote_project_name": project.remote_project_name},
)
return project
except Exception as exc:
project.status = PrivatePortraitProjectStatus.CREATE_GROUP_FAILED.value
await db.flush()
log_operation_error(
domain=DOMAIN,
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_GROUP_CREATE_REMOTE_FAILED.value,
source=PrivatePortraitEventSource.API.value,
user_id=user_id,
project_id=project.id,
exc=exc,
)
raise HTTPException(status_code=502, detail=f"创建火山虚拟人像素材组失败:{exc}") from exc
async def update_virtual_project(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitProjectUpdate):
project = await update_project(db, user_id=user_id, project_id=project_id, payload=payload, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
# 远程同步失败不影响本地更新,记录日志便于排查。
try:
# 只同步当前激活组。
from app.services.private_portrait.asset_service import get_project_active_group
group = await get_project_active_group(db, user_id=user_id, project_id=project_id, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
await ArkPrivateAssetClient().update_asset_group(project_name=project.remote_project_name, group_id=group.remote_group_id, name=group.remote_group_name, title=project.name, description=project.description)
except Exception as exc:
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_GROUP_UPDATE_REMOTE_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, exc=exc)
return project
async def create_virtual_asset(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitAssetCreate):
return await create_asset(db, user_id=user_id, project_id=project_id, payload=payload, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
@@ -46,6 +46,8 @@ async def create_invitation(
"""创建邀请码(仅团队管理人)。""" """创建邀请码(仅团队管理人)。"""
await _assert_is_manager(db, created_by, team_id) await _assert_is_manager(db, created_by, team_id)
code = _generate_invite_code() code = _generate_invite_code()
if expires_at is None:
expires_at = datetime.now(timezone.utc) + timedelta(days=1)
invitation = TeamInvitation( invitation = TeamInvitation(
id=generate_id(), id=generate_id(),
team_id=team_id, team_id=team_id,
@@ -107,7 +109,7 @@ async def create_join_request(
# 验证用户存在 # 验证用户存在
user_result = await db.execute( user_result = await db.execute(
select(User).where(User.id == user_id, User.deleted_at.is_(None)).limit(1) select(User).where(User.id == user_id, User.is_active == True).limit(1)
) )
user = user_result.scalar_one_or_none() user = user_result.scalar_one_or_none()
if not user: if not user:
File diff suppressed because one or more lines are too long
+36 -36
View File
@@ -1,37 +1,37 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>民众智创</title> <title>民众智创</title>
<script> <script>
(function() { (function() {
var cached = localStorage.getItem('siteInfo'); var cached = localStorage.getItem('siteInfo');
if (cached) { if (cached) {
try { try {
var info = JSON.parse(cached); var info = JSON.parse(cached);
if (info.siteName) { if (info.siteName) {
document.title = info.siteName; document.title = info.siteName;
} }
if (info.siteLogo) { if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]'); var link = document.querySelector('link[rel="icon"]');
if (link) { if (link) {
link.href = info.siteLogo; link.href = info.siteLogo;
link.type = 'image/png'; link.type = 'image/png';
} }
} }
} catch (e) {} } catch (e) {}
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-DzBPiPEO.js"></script> <script type="module" crossorigin src="/assets/index-DjHXCPu7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css"> <link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
</body> </body>
</html> </html>
+4 -1
View File
@@ -34,6 +34,8 @@ import PopularPage from './pages/PopularPage';
import CreativePlazaPage from './pages/CreativePlazaPage'; import CreativePlazaPage from './pages/CreativePlazaPage';
import TeamManagementPage from './pages/TeamManagementPage'; import TeamManagementPage from './pages/TeamManagementPage';
import JoinTeamPage from './pages/JoinTeamPage'; import JoinTeamPage from './pages/JoinTeamPage';
import PrivatePortraitAuthorizeResult from './pages/PrivatePortraitAuthorizeResult';
import PrivatePortraitVirtualMaterialPage from './pages/PrivatePortraitVirtualMaterialPage';
import { useAuthStore } from './store/useAuthStore'; import { useAuthStore } from './store/useAuthStore';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { user, loading, checkAuth } = useAuthStore(); const { user, loading, checkAuth } = useAuthStore();
@@ -92,6 +94,7 @@ const App = () => {
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/join-team" element={<JoinTeamPage />} />
<Route <Route
path="/" path="/"
element={ element={
@@ -122,12 +125,12 @@ const App = () => {
<Route path="authorization" element={<AuthorizationPage />} /> <Route path="authorization" element={<AuthorizationPage />} />
<Route path="authoriza-waiting" element={<AuthorizationWaitingPage />} /> <Route path="authoriza-waiting" element={<AuthorizationWaitingPage />} />
<Route path="materials" element={<MaterialListPage />} /> <Route path="materials" element={<MaterialListPage />} />
<Route path="materials/private-portrait-virtual" element={<PrivatePortraitVirtualMaterialPage />} />
<Route path="consume" element={<ConsumePage />} /> <Route path="consume" element={<ConsumePage />} />
<Route path="popular" element={<PopularPage />} /> <Route path="popular" element={<PopularPage />} />
<Route path="authacc" element={<AuthAccountPage />} /> <Route path="authacc" element={<AuthAccountPage />} />
<Route path="creativeplaza" element={<CreativePlazaPage />} /> <Route path="creativeplaza" element={<CreativePlazaPage />} />
<Route path="team-management" element={<TeamManagementPage />} /> <Route path="team-management" element={<TeamManagementPage />} />
<Route path="join-team" element={<JoinTeamPage />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/projects" replace />} /> <Route path="*" element={<Navigate to="/projects" replace />} />
</Routes> </Routes>
+1 -1
View File
@@ -55,7 +55,7 @@ async function tryDecrypt(data: string): Promise<string | null> {
} }
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> { export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = 'GET', body, auth = true, encryptBody = USE_ENCRYPTION, signal } = options; const { method = 'GET', body, auth = true, encryptBody = USE_ENCRYPTION, signal, skipAuthRedirect } = options;
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
+100 -8
View File
@@ -8,7 +8,7 @@ import type {
User, CreditRecord, Project, GenerationRecord, OptimizeParams, GenerateParams, OptimizeResult, User, CreditRecord, Project, GenerationRecord, OptimizeParams, GenerateParams, OptimizeResult,
Industry, IndustryConfig, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification, Industry, IndustryConfig, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification,
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitProject, PrivatePortraitValidateSession, PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitProject, PrivatePortraitValidateSession,
PrivatePortraitAssetListOut, PrivatePortraitAsset, PrivatePortraitSelectableAssetListOut, PrivatePortraitProjectCreateWithValidateOut, PrivatePortraitAssetListOut, PrivatePortraitAsset, PrivatePortraitSelectableAssetListOut,
} from '../types'; } from '../types';
const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true'; const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true';
// ── Auth ────────────────────────────────────────────────── // ── Auth ──────────────────────────────────────────────────
@@ -756,8 +756,12 @@ export async function getPrivatePortraitProjects(params: { page?: number; pageSi
return api.get<PrivatePortraitProjectListOut>(`/private-portrait/projects?${query.toString()}`); return api.get<PrivatePortraitProjectListOut>(`/private-portrait/projects?${query.toString()}`);
} }
export async function createPrivatePortraitProject(payload: { name: string; description?: string | null }): Promise<PrivatePortraitProject> { export async function createPrivatePortraitProject(payload: { name: string; description?: string | null; callbackRedirectUrl?: string | null }): Promise<PrivatePortraitProjectCreateWithValidateOut> {
return api.post<PrivatePortraitProject>('/private-portrait/projects', payload); return api.post<PrivatePortraitProjectCreateWithValidateOut>('/private-portrait/projects', {
name: payload.name,
description: payload.description || null,
callback_redirect_url: payload.callbackRedirectUrl || null,
});
} }
export async function updatePrivatePortraitProject(projectId: string, payload: { name?: string; description?: string | null; status?: string }): Promise<PrivatePortraitProject> { export async function updatePrivatePortraitProject(projectId: string, payload: { name?: string; description?: string | null; status?: string }): Promise<PrivatePortraitProject> {
@@ -776,16 +780,25 @@ export async function getPrivatePortraitValidateSession(sessionId: string): Prom
return api.get<PrivatePortraitValidateSession>(`/private-portrait/validate-sessions/${sessionId}`); return api.get<PrivatePortraitValidateSession>(`/private-portrait/validate-sessions/${sessionId}`);
} }
export async function createPrivatePortraitAsset(projectId: string, payload: { url: string; assetType?: string; name?: string | null }): Promise<PrivatePortraitAsset> { export async function createPrivatePortraitAsset(projectId: string, payload: { url: string; assetType?: string; name?: string | null; videoDuration?: number | null; videoCoverUrl?: string | null; fileSize?: number | null; mimeType?: string | null }): Promise<PrivatePortraitAsset> {
return api.post<PrivatePortraitAsset>(`/private-portrait/projects/${projectId}/assets`, { url: payload.url, asset_type: payload.assetType || 'Image', name: payload.name || null }); return api.post<PrivatePortraitAsset>(`/private-portrait/projects/${projectId}/assets`, {
url: payload.url,
asset_type: payload.assetType || 'Image',
name: payload.name || null,
video_duration: payload.videoDuration ?? null,
video_cover_url: payload.videoCoverUrl || null,
file_size: payload.fileSize ?? null,
mime_type: payload.mimeType || null,
});
} }
export async function getPrivatePortraitAssets(projectId: string, params: { page?: number; pageSize?: number; status?: string; keyword?: string } = {}): Promise<PrivatePortraitAssetListOut> { export async function getPrivatePortraitAssets(projectId: string, params: { page?: number; pageSize?: number; status?: string; keyword?: string; assetType?: string } = {}): Promise<PrivatePortraitAssetListOut> {
const query = new URLSearchParams(); const query = new URLSearchParams();
query.set('page', String(params.page || 1)); query.set('page', String(params.page || 1));
query.set('page_size', String(params.pageSize || 20)); query.set('page_size', String(params.pageSize || 20));
if (params.status) query.set('status', params.status); if (params.status) query.set('status', params.status);
if (params.keyword) query.set('keyword', params.keyword); if (params.keyword) query.set('keyword', params.keyword);
if (params.assetType) query.set('asset_type', params.assetType);
return api.get<PrivatePortraitAssetListOut>(`/private-portrait/projects/${projectId}/assets?${query.toString()}`); return api.get<PrivatePortraitAssetListOut>(`/private-portrait/projects/${projectId}/assets?${query.toString()}`);
} }
@@ -797,15 +810,90 @@ export async function deletePrivatePortraitAsset(assetId: string): Promise<void>
await api.delete(`/private-portrait/assets/${assetId}`); await api.delete(`/private-portrait/assets/${assetId}`);
} }
export async function getPrivatePortraitSelectableAssets(params: { projectId?: string; keyword?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitSelectableAssetListOut> { export async function getPrivatePortraitSelectableAssets(params: { projectId?: string; keyword?: string; page?: number; pageSize?: number; assetType?: string } = {}): Promise<PrivatePortraitSelectableAssetListOut> {
const query = new URLSearchParams(); const query = new URLSearchParams();
query.set('page', String(params.page || 1)); query.set('page', String(params.page || 1));
query.set('page_size', String(params.pageSize || 20)); query.set('page_size', String(params.pageSize || 20));
if (params.projectId) query.set('project_id', params.projectId); if (params.projectId) query.set('project_id', params.projectId);
if (params.keyword) query.set('keyword', params.keyword); if (params.keyword) query.set('keyword', params.keyword);
if (params.assetType) query.set('asset_type', params.assetType);
return api.get<PrivatePortraitSelectableAssetListOut>(`/private-portrait/selectable-assets?${query.toString()}`); return api.get<PrivatePortraitSelectableAssetListOut>(`/private-portrait/selectable-assets?${query.toString()}`);
} }
// ── Private Portrait Virtual Library ─────────────────────
export async function getPrivatePortraitVirtualConfig(): Promise<PrivatePortraitConfig> {
return api.get<PrivatePortraitConfig>('/private-portrait/virtual/config');
}
export async function getPrivatePortraitVirtualProjects(params: { page?: number; pageSize?: number; keyword?: string; status?: string } = {}): Promise<PrivatePortraitProjectListOut> {
const query = new URLSearchParams();
query.set('page', String(params.page || 1));
query.set('page_size', String(params.pageSize || 20));
if (params.keyword) query.set('keyword', params.keyword);
if (params.status) query.set('status', params.status);
return api.get<PrivatePortraitProjectListOut>(`/private-portrait/virtual-projects?${query.toString()}`);
}
export async function createPrivatePortraitVirtualProject(payload: { name: string; description?: string | null }): Promise<PrivatePortraitProject> {
return api.post<PrivatePortraitProject>('/private-portrait/virtual-projects', {
name: payload.name,
description: payload.description || null,
});
}
export async function updatePrivatePortraitVirtualProject(projectId: string, payload: { name?: string; description?: string | null; status?: string }): Promise<PrivatePortraitProject> {
return api.put<PrivatePortraitProject>(`/private-portrait/virtual-projects/${projectId}`, payload);
}
export async function deletePrivatePortraitVirtualProject(projectId: string): Promise<void> {
await api.delete(`/private-portrait/virtual-projects/${projectId}`);
}
export async function createPrivatePortraitVirtualAsset(projectId: string, payload: { url: string; assetType?: string; name?: string | null; videoDuration?: number | null; videoCoverUrl?: string | null; fileSize?: number | null; mimeType?: string | null }): Promise<PrivatePortraitAsset> {
return api.post<PrivatePortraitAsset>(`/private-portrait/virtual-projects/${projectId}/assets`, {
url: payload.url,
asset_type: payload.assetType || 'Image',
name: payload.name || null,
video_duration: payload.videoDuration ?? null,
video_cover_url: payload.videoCoverUrl || null,
file_size: payload.fileSize ?? null,
mime_type: payload.mimeType || null,
});
}
export async function getPrivatePortraitVirtualAssets(projectId: string, params: { page?: number; pageSize?: number; status?: string; keyword?: string; assetType?: string } = {}): Promise<PrivatePortraitAssetListOut> {
const query = new URLSearchParams();
query.set('page', String(params.page || 1));
query.set('page_size', String(params.pageSize || 20));
if (params.status) query.set('status', params.status);
if (params.keyword) query.set('keyword', params.keyword);
if (params.assetType) query.set('asset_type', params.assetType);
return api.get<PrivatePortraitAssetListOut>(`/private-portrait/virtual-projects/${projectId}/assets?${query.toString()}`);
}
export async function getPrivatePortraitVirtualAsset(assetId: string): Promise<PrivatePortraitAsset> {
return api.get<PrivatePortraitAsset>(`/private-portrait/virtual-assets/${assetId}`);
}
export async function syncPrivatePortraitVirtualAsset(assetId: string): Promise<PrivatePortraitAsset> {
return api.post<PrivatePortraitAsset>(`/private-portrait/virtual-assets/${assetId}/sync`);
}
export async function deletePrivatePortraitVirtualAsset(assetId: string): Promise<void> {
await api.delete(`/private-portrait/virtual-assets/${assetId}`);
}
export async function getPrivatePortraitVirtualSelectableAssets(params: { projectId?: string; keyword?: string; page?: number; pageSize?: number; assetType?: string } = {}): Promise<PrivatePortraitSelectableAssetListOut> {
const query = new URLSearchParams();
query.set('page', String(params.page || 1));
query.set('page_size', String(params.pageSize || 20));
if (params.projectId) query.set('project_id', params.projectId);
if (params.keyword) query.set('keyword', params.keyword);
if (params.assetType) query.set('asset_type', params.assetType);
return api.get<PrivatePortraitSelectableAssetListOut>(`/private-portrait/virtual-selectable-assets?${query.toString()}`);
}
// ── Team Management APIs ────────────────────────────── // ── Team Management APIs ──────────────────────────────
export async function getManagedTeam(): Promise<any> { export async function getManagedTeam(): Promise<any> {
return api.get('/team/managed'); return api.get('/team/managed');
@@ -843,7 +931,11 @@ export async function handleJoinRequest(requestId: string, action: 'approve' | '
} }
export async function getJoinTeamInfo(code: string): Promise<any> { export async function getJoinTeamInfo(code: string): Promise<any> {
return api.get(`/team/join-info?code=${encodeURIComponent(code)}`); return api.get(`/team/join-info?code=${encodeURIComponent(code)}`, { auth: true, skipAuthRedirect: true });
}
export async function getJoinTeamInfoPublic(code: string): Promise<any> {
return api.get(`/team/join-info/public?code=${encodeURIComponent(code)}`, false);
} }
export async function submitJoinRequest(code: string): Promise<void> { export async function submitJoinRequest(code: string): Promise<void> {
@@ -1,22 +1,47 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Button, Card, Col, Form, Input, Modal, Row, Space, Typography, message } from 'antd'; import { Button, Card, Col, Form, Input, Modal, QRCode, Row, Space, Spin, Typography, message } from 'antd';
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons'; import { CheckCircleOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import type { PrivatePortraitProject } from '../../../types'; import type { PrivatePortraitProject, PrivatePortraitValidateSession } from '../../../types';
import { createPrivatePortraitProject, getPrivatePortraitProjects } from '../../../api'; import { createPrivatePortraitProject, getPrivatePortraitProjects, getPrivatePortraitValidateSession } from '../../../api';
import PrivatePortraitProjectList from './ProjectList'; import PrivatePortraitProjectList from './ProjectList';
import PrivatePortraitProjectDetail from './ProjectDetail'; import PrivatePortraitProjectDetail from './ProjectDetail';
const VALIDATE_SUCCESS_STATUS = 'group_active';
const POLL_INTERVAL_FALLBACK = 2000;
const PrivatePortraitLibraryPanel: React.FC = () => { const PrivatePortraitLibraryPanel: React.FC = () => {
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]); const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [selected, setSelected] = useState<PrivatePortraitProject | null>(null); const [selected, setSelected] = useState<PrivatePortraitProject | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [createdProject, setCreatedProject] = useState<PrivatePortraitProject | null>(null);
const [validateSession, setValidateSession] = useState<PrivatePortraitValidateSession | null>(null);
const [polling, setPolling] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
const timerRef = useRef<number | null>(null);
const clearPollTimer = () => {
if (timerRef.current) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
};
const resetCreateModal = () => {
clearPollTimer();
setCreateOpen(false);
setCreating(false);
setPolling(false);
setCreatedProject(null);
setValidateSession(null);
form.resetFields();
};
const loadProjects = async () => { const loadProjects = async () => {
setLoading(true); setLoading(true);
try { try {
const res = await getPrivatePortraitProjects({ pageSize: 100 }); const res = await getPrivatePortraitProjects({ pageSize: 100, status: 'active' });
setProjects(res.items); setProjects(res.items);
setSelected((prev) => prev ? (res.items.find((item) => item.id === prev.id) || res.items[0] || null) : (res.items[0] || null)); setSelected((prev) => prev ? (res.items.find((item) => item.id === prev.id) || res.items[0] || null) : (res.items[0] || null));
} catch (e: any) { } catch (e: any) {
@@ -27,27 +52,75 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
}; };
useEffect(() => { loadProjects(); }, []); useEffect(() => { loadProjects(); }, []);
useEffect(() => () => clearPollTimer(), []);
const finishCreateSuccess = async (projectId: string) => {
clearPollTimer();
setPolling(false);
message.success('真人认证完成,项目组已创建成功');
setCreateOpen(false);
setValidateSession(null);
setCreatedProject(null);
form.resetFields();
const res = await getPrivatePortraitProjects({ pageSize: 100, status: 'active' });
setProjects(res.items);
setSelected(res.items.find((item) => item.id === projectId) || res.items[0] || null);
};
const startPolling = (sessionId: string, projectId: string, intervalMs: number) => {
clearPollTimer();
setPolling(true);
const run = async () => {
try {
const next = await getPrivatePortraitValidateSession(sessionId);
setValidateSession(next);
if (next.status === VALIDATE_SUCCESS_STATUS) {
await finishCreateSuccess(projectId);
return;
}
if (['callback_failed', 'failed', 'expired'].includes(next.status)) {
clearPollTimer();
setPolling(false);
message.error(next.errorMessage || '真人认证未完成,请重新创建项目组');
}
} catch (e: any) {
clearPollTimer();
setPolling(false);
message.error(e?.message || '轮询真人认证状态失败');
}
};
timerRef.current = window.setInterval(run, Math.max(1000, intervalMs || POLL_INTERVAL_FALLBACK));
void run();
};
const handleCreate = async () => { const handleCreate = async () => {
const values = await form.validateFields(); const values = await form.validateFields();
setCreating(true);
try { try {
const project = await createPrivatePortraitProject(values); const callbackRedirectUrl = `${window.location.origin}/private-portrait-authorized`;
message.success('项目组已创建'); const res = await createPrivatePortraitProject({ ...values, callbackRedirectUrl });
setCreateOpen(false); setCreatedProject(res.project);
form.resetFields(); setValidateSession(res.validateSession);
await loadProjects(); message.success('请使用手机扫码完成人脸认证');
setSelected(project); if (res.validateSession?.id) {
startPolling(res.validateSession.id, res.project.id, res.pollIntervalMs || POLL_INTERVAL_FALLBACK);
}
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '创建项目组失败'); message.error(e?.message || '创建项目组认证二维码失败');
} finally {
setCreating(false);
} }
}; };
const h5Link = validateSession?.h5Link || '';
const isSuccess = validateSession?.status === VALIDATE_SUCCESS_STATUS;
return ( return (
<div style={{ padding: 16 }}> <div style={{ padding: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div> <div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"> Active AI </Typography.Text> <Typography.Text type="secondary"></Typography.Text>
</div> </div>
<Space> <Space>
<Button icon={<ReloadOutlined />} onClick={loadProjects} loading={loading}></Button> <Button icon={<ReloadOutlined />} onClick={loadProjects} loading={loading}></Button>
@@ -68,15 +141,46 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
)} )}
</Col> </Col>
</Row> </Row>
<Modal title="新建真人素材项目组" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={handleCreate} okText="创建"> <Modal
<Form form={form} layout="vertical"> title="新建真人素材项目组"
<Form.Item label="项目组名称" name="name" rules={[{ required: true, message: '请输入项目组名称' }]}> open={createOpen}
<Input placeholder="例如:达人A、客户B、张三人像" /> onCancel={resetCreateModal}
</Form.Item> footer={validateSession ? [<Button key="close" onClick={resetCreateModal}></Button>] : undefined}
<Form.Item label="描述" name="description"> onOk={validateSession ? undefined : handleCreate}
<Input.TextArea rows={3} placeholder="可选" /> okText="开始认证并创建"
</Form.Item> confirmLoading={creating}
</Form> maskClosable={!polling}
>
{!validateSession ? (
<Form form={form} layout="vertical">
<Form.Item label="项目组名称" name="name" rules={[{ required: true, message: '请输入项目组名称' }]}>
<Input placeholder="例如:达人A、客户B、张三人像" />
</Form.Item>
<Form.Item label="描述" name="description">
<Input.TextArea rows={3} placeholder="可选" />
</Form.Item>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
</Typography.Paragraph>
</Form>
) : (
<Space direction="vertical" align="center" size={16} style={{ width: '100%' }}>
{isSuccess ? (
<CheckCircleOutlined style={{ fontSize: 54, color: '#22c55e' }} />
) : h5Link ? (
<QRCode value={h5Link} size={220} />
) : (
<Spin />
)}
<div style={{ textAlign: 'center' }}>
<Typography.Title level={5} style={{ marginBottom: 8 }}>{createdProject?.name || '真人素材项目组'}</Typography.Title>
<Typography.Text type={isSuccess ? 'success' : 'secondary'}>
{isSuccess ? '认证成功,项目组正在刷新' : '请使用手机扫码完成人脸认证,成功后回到电脑端查看项目组。'}
</Typography.Text>
</div>
{h5Link && !isSuccess && <Typography.Text copyable style={{ wordBreak: 'break-all' }}>{h5Link}</Typography.Text>}
</Space>
)}
</Modal> </Modal>
</div> </div>
); );
@@ -1,11 +1,10 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Card, Popconfirm, Space, Typography, message } from 'antd'; import { Button, Card, Popconfirm, Space, Tag, Typography, message } from 'antd';
import { DeleteOutlined, ReloadOutlined, SafetyCertificateOutlined, UploadOutlined } from '@ant-design/icons'; import { DeleteOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons';
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../types'; import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../types';
import { deletePrivatePortraitAsset, deletePrivatePortraitProject, getPrivatePortraitAssets, syncPrivatePortraitAsset } from '../../../api'; import { deletePrivatePortraitAsset, deletePrivatePortraitProject, getPrivatePortraitAssets, syncPrivatePortraitAsset } from '../../../api';
import PrivatePortraitAssetGrid from './AssetGrid'; import PrivatePortraitAssetGrid from './AssetGrid';
import PrivatePortraitAssetUpload from './AssetUpload'; import PrivatePortraitAssetUpload from './AssetUpload';
import PrivatePortraitValidateModal from './ValidateModal';
interface Props { interface Props {
project: PrivatePortraitProject; project: PrivatePortraitProject;
@@ -17,7 +16,6 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]); const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false); const [uploadOpen, setUploadOpen] = useState(false);
const [validateOpen, setValidateOpen] = useState(false);
const loadAssets = async () => { const loadAssets = async () => {
setLoading(true); setLoading(true);
@@ -65,13 +63,14 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
} }
}; };
const canUpload = project.status === 'active';
return ( return (
<Card <Card
title={<span>{project.name}</span>} title={<Space><span>{project.name}</span><Tag color={canUpload ? 'green' : 'processing'}>{project.status}</Tag></Space>}
extra={( extra={(
<Space> <Space>
<Button icon={<SafetyCertificateOutlined />} onClick={() => setValidateOpen(true)}></Button> <Button type="primary" icon={<UploadOutlined />} disabled={!canUpload} onClick={() => setUploadOpen(true)}></Button>
<Button type="primary" icon={<UploadOutlined />} onClick={() => setUploadOpen(true)}></Button>
<Button icon={<ReloadOutlined />} onClick={loadAssets} loading={loading}></Button> <Button icon={<ReloadOutlined />} onClick={loadAssets} loading={loading}></Button>
<Popconfirm title="确认删除这个真人素材项目组吗?" onConfirm={handleDeleteProject}> <Popconfirm title="确认删除这个真人素材项目组吗?" onConfirm={handleDeleteProject}>
<Button danger icon={<DeleteOutlined />}></Button> <Button danger icon={<DeleteOutlined />}></Button>
@@ -81,9 +80,13 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
style={{ borderRadius: 12 }} style={{ borderRadius: 12 }}
> >
<Typography.Paragraph style={{ color: '#64748b' }}>{project.description || '暂无描述'}</Typography.Paragraph> <Typography.Paragraph style={{ color: '#64748b' }}>{project.description || '暂无描述'}</Typography.Paragraph>
{!canUpload && (
<Typography.Paragraph style={{ color: '#f97316' }}>
</Typography.Paragraph>
)}
<PrivatePortraitAssetGrid items={assets} loading={loading} onSync={handleSync} onDelete={handleDeleteAsset} /> <PrivatePortraitAssetGrid items={assets} loading={loading} onSync={handleSync} onDelete={handleDeleteAsset} />
<PrivatePortraitAssetUpload projectId={project.id} open={uploadOpen} onClose={() => setUploadOpen(false)} onSuccess={() => { loadAssets(); onChanged(); }} /> <PrivatePortraitAssetUpload projectId={project.id} open={uploadOpen} onClose={() => setUploadOpen(false)} onSuccess={() => { loadAssets(); onChanged(); }} />
<PrivatePortraitValidateModal projectId={project.id} open={validateOpen} onClose={() => setValidateOpen(false)} onCreated={() => onChanged()} />
</Card> </Card>
); );
}; };
+160 -48
View File
@@ -1,54 +1,77 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Card, Result, Spin, Typography, Modal, message } from 'antd'; import {
import { CheckCircleOutlined, TeamOutlined } from '@ant-design/icons'; Button, Card, Result, Spin, Typography, Modal, message, Space,
} from 'antd';
import {
CheckCircleOutlined, TeamOutlined, LoginOutlined, UserAddOutlined,
} from '@ant-design/icons';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { getJoinTeamInfo, submitJoinRequest } from '../api'; import { getJoinTeamInfo, getJoinTeamInfoPublic, submitJoinRequest } from '../api';
import type { JoinTeamInfo } from '../types'; import type { JoinTeamInfo } from '../types';
import { useAuthStore } from '../store/useAuthStore';
const JoinTeamPage: React.FC = () => { const JoinTeamPage: React.FC = () => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const code = searchParams.get('code') || ''; const code = searchParams.get('code') || '';
const { user, loading: authLoading, checkAuth } = useAuthStore();
const [loading, setLoading] = useState(true); const [infoLoading, setInfoLoading] = useState(true);
const [info, setInfo] = useState<JoinTeamInfo | null>(null); const [info, setInfo] = useState<JoinTeamInfo | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
useEffect(() => {
checkAuth();
}, [checkAuth]);
useEffect(() => { useEffect(() => {
if (!code) { if (!code) {
setLoading(false); setInfoLoading(false);
return; return;
} }
getJoinTeamInfo(code) if (authLoading) return;
.then((data) => setInfo(data))
setInfoLoading(true);
const fetchInfo = user ? getJoinTeamInfo(code) : getJoinTeamInfoPublic(code);
fetchInfo
.then((data) => {
setInfo(data);
if (data?.valid && user && !data.alreadyInTeam && !data.hasPendingRequest) {
setConfirmModalOpen(true);
}
})
.catch(() => setInfo(null)) .catch(() => setInfo(null))
.finally(() => setLoading(false)); .finally(() => setInfoLoading(false));
}, [code]); }, [code, user, authLoading]);
const handleJoin = async () => { const handleJoin = async () => {
if (!code) return; if (!code) return;
Modal.confirm({ try {
title: '确认加入团队', setSubmitting(true);
icon: <TeamOutlined style={{ color: '#6366f1' }} />, await submitJoinRequest(code);
content: info?.teamName ? `您确定要加入团队「${info.teamName}」吗?提交后需等待团队管理人审批。` : '您确定要加入该团队吗?', setSubmitted(true);
okText: '确认加入', setConfirmModalOpen(false);
cancelText: '取消', message.success('申请已提交');
onOk: async () => { } catch (e: any) {
try { message.error(e?.message || '申请失败');
setSubmitting(true); } finally {
await submitJoinRequest(code); setSubmitting(false);
setSubmitted(true); }
} catch (e: any) {
message.error(e?.message || '申请失败');
} finally {
setSubmitting(false);
}
},
});
}; };
if (loading) { const handleLogin = () => {
const redirect = encodeURIComponent(window.location.pathname + window.location.search);
navigate(`/login?redirect=${redirect}`);
};
const handleRegister = () => {
const redirect = encodeURIComponent(window.location.pathname + window.location.search);
navigate(`/login?tab=register&redirect=${redirect}`);
};
if (authLoading || infoLoading) {
return ( return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}> <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
<Spin size="large" /> <Spin size="large" />
@@ -64,7 +87,14 @@ const JoinTeamPage: React.FC = () => {
icon={<CheckCircleOutlined style={{ color: '#6366f1' }} />} icon={<CheckCircleOutlined style={{ color: '#6366f1' }} />}
title="申请已提交" title="申请已提交"
subTitle="您的加入申请已提交,请等待团队管理人审批。审批通过后将自动加入团队。" subTitle="您的加入申请已提交,请等待团队管理人审批。审批通过后将自动加入团队。"
extra={<Button type="primary" onClick={() => navigate('/projects')}></Button>} extra={
<Space>
<Button type="primary" onClick={() => navigate('/projects')}></Button>
{user && (
<Button onClick={() => navigate('/team-management')}></Button>
)}
</Space>
}
/> />
</div> </div>
); );
@@ -90,31 +120,113 @@ const JoinTeamPage: React.FC = () => {
status="info" status="info"
title="您已在此团队中" title="您已在此团队中"
subTitle={`您已经是「${info.teamName}」的成员了,无需再次加入。`} subTitle={`您已经是「${info.teamName}」的成员了,无需再次加入。`}
extra={<Button type="primary" onClick={() => navigate('/projects')}></Button>} extra={
<Space>
<Button type="primary" onClick={() => navigate('/projects')}></Button>
</Space>
}
/>
</div>
);
}
if (info.hasPendingRequest) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<Result
status="info"
title="申请待审批"
subTitle={`您已提交加入「${info.teamName}」的申请,请等待团队管理人审批。`}
extra={
<Space>
<Button type="primary" onClick={() => navigate('/projects')}></Button>
<Button onClick={() => navigate('/team-management')}></Button>
</Space>
}
/> />
</div> </div>
); );
} }
return ( return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}> <>
<Card variant="outlined" style={{ borderRadius: 16, maxWidth: 480, width: '100%', textAlign: 'center' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<TeamOutlined style={{ fontSize: 48, color: '#6366f1', marginBottom: 16 }} /> <Card
<Typography.Title level={3}></Typography.Title> variant="outlined"
<Typography.Text style={{ fontSize: 16, color: '#475569', display: 'block', marginBottom: 8 }}> style={{ borderRadius: 16, maxWidth: 480, width: '100%', textAlign: 'center' }}
>
</Typography.Text> <TeamOutlined style={{ fontSize: 48, color: '#6366f1', marginBottom: 16 }} />
<Typography.Title level={4} style={{ color: '#6366f1', margin: '16px 0 24px' }}> <Typography.Title level={3}></Typography.Title>
{info.teamName} <Typography.Text style={{ fontSize: 16, color: '#475569', display: 'block', marginBottom: 8 }}>
</Typography.Title>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 24 }}> </Typography.Text>
使 <Typography.Title level={4} style={{ color: '#6366f1', margin: '16px 0 24px' }}>
</Typography.Text> {info.teamName}
<Button type="primary" size="large" block loading={submitting} onClick={handleJoin}> </Typography.Title>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 24 }}>
</Button> 使
</Card> </Typography.Text>
</div>
{user ? (
<Button
type="primary"
size="large"
block
loading={submitting}
onClick={() => setConfirmModalOpen(true)}
>
</Button>
) : (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Button
type="primary"
size="large"
block
icon={<LoginOutlined />}
onClick={handleLogin}
>
</Button>
<Button
size="large"
block
icon={<UserAddOutlined />}
onClick={handleRegister}
>
</Button>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
</Space>
)}
</Card>
</div>
<Modal
title={
<Space>
<TeamOutlined style={{ color: '#6366f1' }} />
</Space>
}
open={confirmModalOpen}
onOk={handleJoin}
onCancel={() => setConfirmModalOpen(false)}
okText="确认加入"
cancelText="取消"
confirmLoading={submitting}
width={420}
>
<p style={{ marginBottom: 0 }}>
<strong style={{ color: '#6366f1' }}>{info.teamName}</strong>
</p>
<p style={{ marginTop: 8, color: '#64748b', fontSize: 13 }}>
</p>
</Modal>
</>
); );
}; };
+24 -4
View File
@@ -5,7 +5,7 @@ import {
PlayCircleOutlined, BulbOutlined, HistoryOutlined, PlayCircleOutlined, BulbOutlined, HistoryOutlined,
MobileOutlined, SafetyOutlined, MobileOutlined, SafetyOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuthStore } from '../store/useAuthStore'; import { useAuthStore } from '../store/useAuthStore';
import { sendSms,phonelogin, getSiteInfo, register } from '../api'; import { sendSms,phonelogin, getSiteInfo, register } from '../api';
import './LoginPage.css'; import './LoginPage.css';
@@ -17,6 +17,9 @@ const LoginPage: React.FC = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [mode, setMode] = useState<'password' | 'phone' | 'register'>('password'); const [mode, setMode] = useState<'password' | 'phone' | 'register'>('password');
const [tab, setTab] = useState<'password' | 'phone'>('password'); const [tab, setTab] = useState<'password' | 'phone'>('password');
const [searchParams] = useSearchParams();
const redirect = searchParams.get('redirect');
const tabParam = searchParams.get('tab');
const [countdown, setCountdown] = useState(0); const [countdown, setCountdown] = useState(0);
const [regCountdown, setRegCountdown] = useState(0); const [regCountdown, setRegCountdown] = useState(0);
const [agreed, setAgreed] = useState(false); const [agreed, setAgreed] = useState(false);
@@ -62,6 +65,23 @@ const LoginPage: React.FC = () => {
}).catch(() => {}); }).catch(() => {});
}, []); }, []);
useEffect(() => {
if (tabParam === 'register') {
setMode('register');
}
}, [tabParam]);
const goToRedirect = () => {
if (redirect) {
try {
const decoded = decodeURIComponent(redirect);
navigate(decoded);
return;
} catch {}
}
navigate('/home');
};
const checkAgreed = (): boolean => { const checkAgreed = (): boolean => {
if (!agreed) { if (!agreed) {
message.warning('请先阅读并同意用户协议及隐私政策'); message.warning('请先阅读并同意用户协议及隐私政策');
@@ -77,7 +97,7 @@ const LoginPage: React.FC = () => {
await login(values.phone, values.password, undefined, values.rememberMe); await login(values.phone, values.password, undefined, values.rememberMe);
message.success('登录成功,欢迎回来'); message.success('登录成功,欢迎回来');
await checkAuth(); await checkAuth();
navigate('/home'); goToRedirect();
} catch (error: any) { } catch (error: any) {
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败'; const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
message.error(errorMsg); message.error(errorMsg);
@@ -97,7 +117,7 @@ const LoginPage: React.FC = () => {
await phonelogin(values.phone, values.code); await phonelogin(values.phone, values.code);
message.success('登录成功,欢迎回来'); message.success('登录成功,欢迎回来');
await checkAuth(); await checkAuth();
navigate('/home'); goToRedirect();
} catch (error: any) { } catch (error: any) {
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败'; const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
message.error(errorMsg); message.error(errorMsg);
@@ -114,7 +134,7 @@ const LoginPage: React.FC = () => {
const user = await register(values.phone, values.regCode, values.password); const user = await register(values.phone, values.regCode, values.password);
message.success('注册成功'); message.success('注册成功');
await checkAuth(); await checkAuth();
navigate('/home'); goToRedirect();
} catch (error: any) { } catch (error: any) {
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '注册失败'; const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '注册失败';
message.error(errorMsg); message.error(errorMsg);
+14 -1
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Table, Tag, Input, Pagination, Typography, Select, App, Modal } from 'antd'; import { Button, Table, Tag, Input, Pagination, Typography, Select, App, Modal } from 'antd';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { FolderOpenOutlined, EyeOutlined } from '@ant-design/icons'; import { FolderOpenOutlined, EyeOutlined, RobotOutlined } from '@ant-design/icons';
import { getResourcesMaterialList, getPreTestList, submitPreTest, getDefaultPreTest } from '../api'; import { getResourcesMaterialList, getPreTestList, submitPreTest, getDefaultPreTest } from '../api';
import PreResultDisplay from '../components/PreResultDisplay'; import PreResultDisplay from '../components/PreResultDisplay';
@@ -436,6 +436,19 @@ const MaterialListPage: React.FC = () => {
</Button> </Button>
</div> </div>
<div style={{ display: 'flex', gap: 12 }}> <div style={{ display: 'flex', gap: 12 }}>
<Button
icon={<RobotOutlined />}
onClick={() => navigate('/materials/private-portrait-virtual')}
style={{
borderRadius: 12,
fontSize: 14,
borderColor: '#8b5cf6',
color: '#7c3aed',
background: '#f5f3ff',
}}
>
</Button>
<Button <Button
type="primary" type="primary"
loading={pushTemplatesLoading} loading={pushTemplatesLoading}
@@ -0,0 +1,31 @@
import React, { useMemo } from 'react';
import { Button, Card, Result, Typography } from 'antd';
const successStatuses = new Set(['group_active', 'callback_success']);
const PrivatePortraitAuthorizeResult: React.FC = () => {
const params = useMemo(() => new URLSearchParams(window.location.search), []);
const status = params.get('status') || '';
const resultCode = params.get('resultCode') || '';
const isSuccess = successStatuses.has(status) || resultCode === '10000';
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f8fafc', padding: 20 }}>
<Card style={{ width: '100%', maxWidth: 520, borderRadius: 18 }}>
<Result
status={isSuccess ? 'success' : 'error'}
title={isSuccess ? '真人认证已完成' : '真人认证未完成'}
subTitle={isSuccess ? '请回到电脑端查看,项目组已创建成功。' : '请回到电脑端重新发起创建项目组。'}
extra={[
<Button key="close" type="primary" onClick={() => window.close()}></Button>,
]}
/>
<Typography.Paragraph type="secondary" style={{ textAlign: 'center', marginBottom: 0 }}>
{status || '-'}{resultCode || '-'}
</Typography.Paragraph>
</Card>
</div>
);
};
export default PrivatePortraitAuthorizeResult;
@@ -0,0 +1,599 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
App,
Button,
Card,
Col,
Empty,
Form,
Input,
Modal,
Pagination,
Popconfirm,
Row,
Select,
Space,
Spin,
Tag,
Tooltip,
Typography,
Upload,
} from 'antd';
import type { UploadFile } from 'antd/es/upload/interface';
import {
ArrowLeftOutlined,
CloudSyncOutlined,
DeleteOutlined,
EyeOutlined,
PictureOutlined,
PlusOutlined,
ReloadOutlined,
UploadOutlined,
VideoCameraOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import {
createPrivatePortraitVirtualAsset,
createPrivatePortraitVirtualProject,
deletePrivatePortraitVirtualAsset,
deletePrivatePortraitVirtualProject,
getPrivatePortraitVirtualAssets,
getPrivatePortraitVirtualConfig,
getPrivatePortraitVirtualProjects,
syncPrivatePortraitVirtualAsset,
uploadImage,
uploadVideo,
} from '../api';
import type { PrivatePortraitAsset, PrivatePortraitConfig, PrivatePortraitProject } from '../types';
const { Text, Title, Paragraph } = Typography;
type AssetTypeFilter = 'Image' | 'Video' | undefined;
const statusConfig: Record<string, { label: string; color: string }> = {
creating: { label: '本地创建中', color: 'processing' },
Processing: { label: '火山处理中', color: 'processing' },
Active: { label: '可用于生成', color: 'success' },
Failed: { label: '入库失败', color: 'error' },
local_deleted: { label: '本地已删', color: 'default' },
remote_deleted: { label: '远端已删', color: 'default' },
delete_failed: { label: '远端删除失败', color: 'error' },
};
const assetTypeConfig: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
Image: { label: '图片', color: 'green', icon: <PictureOutlined /> },
Video: { label: '视频', color: 'blue', icon: <VideoCameraOutlined /> },
};
const formatDateTime = (dateStr?: string | null) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
if (Number.isNaN(date.getTime())) return '-';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
const formatSize = (size?: number | null) => {
const value = Number(size || 0);
if (!value) return '-';
if (value >= 1024 * 1024 * 1024) return `${(value / 1024 / 1024 / 1024).toFixed(2)} GB`;
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(2)} MB`;
if (value >= 1024) return `${(value / 1024).toFixed(2)} KB`;
return `${value} B`;
};
const buildPreviewUrl = (url?: string | null) => {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:') || url.startsWith('blob:')) return url;
const base = (import.meta.env.VITE_API_BASE || 'http://localhost:8000').replace(/\/$/, '');
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
};
const getAssetPreviewUrl = (asset: PrivatePortraitAsset) => {
return buildPreviewUrl(asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.remoteUrl || asset.sourceUrl);
};
const guessAssetType = (file?: File | null): 'Image' | 'Video' => {
if (!file) return 'Image';
if (file.type.startsWith('video/')) return 'Video';
const name = file.name.toLowerCase();
if (/\.(mp4|mov|webm|m4v|avi|mkv)$/.test(name)) return 'Video';
return 'Image';
};
const getVideoDuration = (file: File): Promise<number | null> => {
return new Promise((resolve) => {
if (!file.type.startsWith('video/')) {
resolve(null);
return;
}
const url = URL.createObjectURL(file);
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
const duration = Number.isFinite(video.duration) ? video.duration : null;
URL.revokeObjectURL(url);
resolve(duration);
};
video.onerror = () => {
URL.revokeObjectURL(url);
resolve(null);
};
video.src = url;
});
};
const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
const value = status || '-';
const config = statusConfig[value];
return <Tag color={config?.color || 'default'}>{config?.label || value}</Tag>;
};
const TypeTag: React.FC<{ type?: string | null }> = ({ type }) => {
const value = type || '-';
const config = assetTypeConfig[value];
return <Tag color={config?.color || 'default'} icon={config?.icon}>{config?.label || value}</Tag>;
};
const PrivatePortraitVirtualMaterialPage: React.FC = () => {
const { message } = App.useApp();
const navigate = useNavigate();
const [config, setConfig] = useState<PrivatePortraitConfig | null>(null);
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string>();
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
const [projectLoading, setProjectLoading] = useState(false);
const [assetLoading, setAssetLoading] = useState(false);
const [assetPage, setAssetPage] = useState(1);
const [assetPageSize, setAssetPageSize] = useState(20);
const [assetTotal, setAssetTotal] = useState(0);
const [keyword, setKeyword] = useState('');
const [assetStatus, setAssetStatus] = useState<string>();
const [assetType, setAssetType] = useState<AssetTypeFilter>();
const [createOpen, setCreateOpen] = useState(false);
const [creatingProject, setCreatingProject] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false);
const [uploading, setUploading] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [assetName, setAssetName] = useState('');
const [previewOpen, setPreviewOpen] = useState(false);
const [previewUrl, setPreviewUrl] = useState('');
const [previewType, setPreviewType] = useState<'Image' | 'Video'>('Image');
const [createForm] = Form.useForm<{ name: string; description?: string }>();
const selectedProject = useMemo(
() => projects.find((item) => item.id === selectedProjectId) || null,
[projects, selectedProjectId],
);
const quotaText = useMemo(() => {
if (!config) return '额度加载中';
return `已用 ${config.usedAssetCount || 0} / ${config.assetLimit || 0} 个素材,剩余 ${config.remainingAssetCount || 0}`;
}, [config]);
const loadConfig = async () => {
try {
const next = await getPrivatePortraitVirtualConfig();
setConfig(next);
} catch (err: any) {
message.error(err?.message || '加载私域素材额度失败');
}
};
const loadProjects = async () => {
setProjectLoading(true);
try {
const res = await getPrivatePortraitVirtualProjects({ page: 1, pageSize: 100, status: 'active' });
const next = res.items || [];
setProjects(next);
setSelectedProjectId((prev) => prev && next.some((item) => item.id === prev) ? prev : next[0]?.id);
} catch (err: any) {
message.error(err?.message || '加载虚拟人像项目组失败');
} finally {
setProjectLoading(false);
}
};
const loadAssets = async (page = assetPage, pageSize = assetPageSize) => {
if (!selectedProjectId) {
setAssets([]);
setAssetTotal(0);
return;
}
setAssetLoading(true);
try {
const res = await getPrivatePortraitVirtualAssets(selectedProjectId, {
page,
pageSize,
keyword: keyword.trim() || undefined,
status: assetStatus,
assetType,
});
setAssets(res.items || []);
setAssetTotal(res.total || 0);
setAssetPage(page);
setAssetPageSize(pageSize);
} catch (err: any) {
message.error(err?.message || '加载虚拟人像素材失败');
} finally {
setAssetLoading(false);
}
};
const reloadAll = async () => {
await Promise.all([loadConfig(), loadProjects()]);
};
useEffect(() => {
reloadAll();
}, []);
useEffect(() => {
if (selectedProjectId) loadAssets(1, assetPageSize);
}, [selectedProjectId]);
const handleCreateProject = async () => {
const values = await createForm.validateFields();
setCreatingProject(true);
try {
const project = await createPrivatePortraitVirtualProject(values);
message.success('虚拟人像项目组已创建');
setCreateOpen(false);
createForm.resetFields();
await loadProjects();
setSelectedProjectId(project.id);
} catch (err: any) {
message.error(err?.message || '创建项目组失败');
} finally {
setCreatingProject(false);
}
};
const handleUpload = async () => {
if (!selectedProjectId) {
message.warning('请先创建或选择项目组');
return;
}
const file = fileList[0]?.originFileObj as File | undefined;
if (!file) {
message.warning('请先选择图片或视频素材');
return;
}
const currentType = guessAssetType(file);
setUploading(true);
try {
const uploaded = currentType === 'Video' ? await uploadVideo(file) : await uploadImage(file);
const duration = currentType === 'Video' ? await getVideoDuration(file) : null;
await createPrivatePortraitVirtualAsset(selectedProjectId, {
url: uploaded.url,
assetType: currentType,
name: assetName.trim() || file.name,
videoDuration: duration,
fileSize: file.size,
mimeType: file.type || null,
});
message.success(currentType === 'Video' ? '视频素材已提交入库,处理中' : '图片素材已提交入库,处理中');
setUploadOpen(false);
setFileList([]);
setAssetName('');
await Promise.all([loadConfig(), loadProjects(), loadAssets(1, assetPageSize)]);
} catch (err: any) {
message.error(err?.message || '上传素材失败');
} finally {
setUploading(false);
}
};
const handleSyncAsset = async (assetId: string) => {
try {
await syncPrivatePortraitVirtualAsset(assetId);
message.success('素材状态已刷新');
await Promise.all([loadConfig(), loadProjects(), loadAssets(assetPage, assetPageSize)]);
} catch (err: any) {
message.error(err?.message || '刷新素材状态失败');
}
};
const handleDeleteAsset = async (assetId: string) => {
try {
await deletePrivatePortraitVirtualAsset(assetId);
message.success('素材已删除,远端删除将异步执行');
await Promise.all([loadConfig(), loadProjects(), loadAssets(assetPage, assetPageSize)]);
} catch (err: any) {
message.error(err?.message || '删除素材失败');
}
};
const handleDeleteProject = async () => {
if (!selectedProjectId) return;
try {
await deletePrivatePortraitVirtualProject(selectedProjectId);
message.success('项目组已删除,远端资产组将异步删除');
setSelectedProjectId(undefined);
await reloadAll();
} catch (err: any) {
message.error(err?.message || '删除项目组失败');
}
};
const openPreview = (asset: PrivatePortraitAsset) => {
const url = getAssetPreviewUrl(asset);
if (!url) {
message.warning('暂无可预览地址');
return;
}
setPreviewUrl(url);
setPreviewType(asset.assetType === 'Video' ? 'Video' : 'Image');
setPreviewOpen(true);
};
const renderAssetCard = (asset: PrivatePortraitAsset) => {
const preview = getAssetPreviewUrl(asset);
const isVideo = asset.assetType === 'Video';
return (
<Card
key={asset.id}
hoverable
bodyStyle={{ padding: 12 }}
style={{ borderRadius: 16, overflow: 'hidden', borderColor: '#eef2f7' }}
cover={(
<div style={{ height: 170, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
{preview && !isVideo ? (
<img src={preview} alt={asset.name || '虚拟人像素材'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : preview && isVideo && asset.videoCoverUrl ? (
<img src={preview} alt={asset.name || '视频封面'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : isVideo ? (
<VideoCameraOutlined style={{ fontSize: 42, color: '#64748b' }} />
) : (
<PictureOutlined style={{ fontSize: 42, color: '#64748b' }} />
)}
{isVideo && <Tag color="blue" style={{ position: 'absolute', left: 10, top: 10 }}></Tag>}
<Button size="small" shape="circle" icon={<EyeOutlined />} style={{ position: 'absolute', right: 10, top: 10 }} onClick={() => openPreview(asset)} />
</div>
)}
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Tooltip title={asset.name || asset.remoteAssetId || asset.id}>
<Text strong ellipsis style={{ display: 'block' }}>{asset.name || asset.remoteAssetId || '未命名素材'}</Text>
</Tooltip>
<Space wrap size={4}>
<TypeTag type={asset.assetType} />
<StatusTag status={asset.status} />
</Space>
<div style={{ color: '#64748b', fontSize: 12, lineHeight: 1.7 }}>
<div>{formatSize(asset.fileSize)}</div>
<div>{asset.pollCount || 0} </div>
<div>{formatDateTime(asset.createdAt)}</div>
</div>
{asset.errorMessage && <div style={{ color: '#ef4444', fontSize: 12 }}>{asset.errorMessage}</div>}
<Space size={6} wrap>
<Button size="small" icon={<CloudSyncOutlined />} onClick={() => handleSyncAsset(asset.id)}></Button>
<Popconfirm title="确认删除这个虚拟人像素材吗?" onConfirm={() => handleDeleteAsset(asset.id)}>
<Button size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
</Space>
</Card>
);
};
return (
<div style={{ minHeight: '94vh' }}>
<Space style={{ marginBottom: 16 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/materials')}></Button>
<Title level={4} style={{ margin: 0 }}></Title>
</Space>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} md={8}>
<Card style={{ borderRadius: 16, background: 'linear-gradient(135deg,#f5f3ff,#fff)' }}>
<Text type="secondary"></Text>
<div style={{ fontSize: 24, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}</div>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>//</Paragraph>
</Card>
</Col>
<Col xs={24} md={8}>
<Card style={{ borderRadius: 16 }}>
<Text type="secondary"></Text>
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{projects.length}</div>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}> AIGC Asset Group</Paragraph>
</Card>
</Col>
<Col xs={24} md={8}>
<Card style={{ borderRadius: 16 }}>
<Text type="secondary"></Text>
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{selectedProject?.assetCount || 0}</div>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}> Active AI </Paragraph>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} lg={7}>
<Card
title="虚拟人像项目组"
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}></Button>}
style={{ borderRadius: 16, minHeight: 520 }}
>
<Spin spinning={projectLoading}>
{projects.length === 0 ? (
<Empty description="暂无虚拟人像项目组" />
) : (
<Space direction="vertical" style={{ width: '100%' }} size={10}>
{projects.map((project) => {
const active = selectedProjectId === project.id;
return (
<div
key={project.id}
onClick={() => setSelectedProjectId(project.id)}
style={{
padding: 14,
borderRadius: 14,
cursor: 'pointer',
border: active ? '1px solid #8b5cf6' : '1px solid #e2e8f0',
background: active ? '#f5f3ff' : '#fff',
}}
>
<Space style={{ width: '100%', justifyContent: 'space-between' }} align="start">
<div style={{ minWidth: 0 }}>
<Text strong ellipsis style={{ display: 'block' }}>{project.name}</Text>
{project.description && <Text type="secondary" ellipsis style={{ display: 'block', fontSize: 12 }}>{project.description}</Text>}
</div>
<StatusTag status={project.status} />
</Space>
<Space wrap size={4} style={{ marginTop: 10 }}>
<Tag> {project.assetCount || 0}</Tag>
<Tag color="green"> {project.imageAssetCount || 0}</Tag>
<Tag color="blue"> {project.videoAssetCount || 0}</Tag>
<Tag color="success">Active {project.activeAssetCount || 0}</Tag>
</Space>
</div>
);
})}
</Space>
)}
</Spin>
</Card>
</Col>
<Col xs={24} lg={17}>
<Card
title={selectedProject ? selectedProject.name : '素材资产'}
extra={(
<Space wrap>
<Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(assetPage, assetPageSize); }} loading={assetLoading}></Button>
<Button type="primary" icon={<UploadOutlined />} disabled={!selectedProjectId} onClick={() => setUploadOpen(true)}>/</Button>
{selectedProjectId && (
<Popconfirm title="确认删除当前虚拟人像项目组吗?" onConfirm={handleDeleteProject}>
<Button danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
)}
</Space>
)}
style={{ borderRadius: 16, minHeight: 520 }}
>
<Space style={{ width: '100%', marginBottom: 16 }} wrap>
<Input.Search
allowClear
placeholder="搜索素材名称"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onSearch={() => loadAssets(1, assetPageSize)}
style={{ width: 240 }}
/>
<Select
allowClear
placeholder="素材状态"
value={assetStatus}
onChange={(value) => setAssetStatus(value)}
style={{ width: 150 }}
options={[
{ value: 'Processing', label: '处理中' },
{ value: 'Active', label: '可用' },
{ value: 'Failed', label: '失败' },
]}
/>
<Select
allowClear
placeholder="素材类型"
value={assetType}
onChange={(value) => setAssetType(value)}
style={{ width: 130 }}
options={[
{ value: 'Image', label: '图片' },
{ value: 'Video', label: '视频' },
]}
/>
<Button onClick={() => loadAssets(1, assetPageSize)}></Button>
</Space>
<Spin spinning={assetLoading}>
{!selectedProjectId ? (
<Empty description="请先创建或选择项目组" style={{ marginTop: 80 }} />
) : assets.length === 0 ? (
<Empty description="暂无素材,上传图片/视频后会异步入库" style={{ marginTop: 80 }} />
) : (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 14 }}>
{assets.map(renderAssetCard)}
</div>
<div style={{ textAlign: 'right', marginTop: 16 }}>
<Pagination
current={assetPage}
pageSize={assetPageSize}
total={assetTotal}
showSizeChanger
showTotal={(value) => `${value} 个素材`}
onChange={(page, size) => loadAssets(page, size)}
/>
</div>
</>
)}
</Spin>
</Card>
</Col>
</Row>
<Modal
title="创建虚拟人像项目组"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={handleCreateProject}
confirmLoading={creatingProject}
okText="创建并同步火山 Asset Group"
>
<Form form={createForm} layout="vertical">
<Form.Item name="name" label="项目组名称" rules={[{ required: true, message: '请输入项目组名称' }]}>
<Input placeholder="例如:虚拟主播A / 品牌代言人B" maxLength={128} />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea placeholder="可填写人物设定、服装风格、素材要求等" maxLength={2000} rows={4} />
</Form.Item>
</Form>
</Modal>
<Modal
title="上传虚拟人像素材"
open={uploadOpen}
onCancel={() => setUploadOpen(false)}
onOk={handleUpload}
confirmLoading={uploading}
okText="提交入库"
>
<Space direction="vertical" style={{ width: '100%' }} size={14}>
<Input value={assetName} onChange={(e) => setAssetName(e.target.value)} placeholder="素材名称,默认使用文件名" maxLength={256} />
<Upload
accept="image/*,video/*"
maxCount={1}
fileList={fileList}
beforeUpload={() => false}
onChange={({ fileList: next }) => setFileList(next)}
listType="picture"
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
<div style={{ padding: 12, background: '#f8fafc', borderRadius: 12, color: '#64748b', fontSize: 13 }}>
CreateAsset Active AI
</div>
</Space>
</Modal>
<Modal title="素材预览" open={previewOpen} onCancel={() => setPreviewOpen(false)} footer={null} width={760} destroyOnClose>
<div style={{ minHeight: 420, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0f172a', borderRadius: 12, overflow: 'hidden' }}>
{previewType === 'Video' ? (
<video src={previewUrl} controls autoPlay style={{ maxWidth: '100%', maxHeight: 520 }} />
) : (
<img src={previewUrl} alt="素材预览" style={{ maxWidth: '100%', maxHeight: 520, objectFit: 'contain' }} />
)}
</div>
</Modal>
</div>
);
};
export default PrivatePortraitVirtualMaterialPage;
+122 -22
View File
@@ -1,11 +1,11 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import { import {
Button, Empty, Form, Input, InputNumber, message, Modal, Pagination, Radio, Select, Space, Table, Tabs, Tag, Tooltip, Typography, Button, Empty, Form, Input, InputNumber, message, Modal, Pagination, Radio, Select, Space, Table, Tabs, Tag, Tooltip, Typography,
} from 'antd'; } from 'antd';
import { DatePicker } from 'antd'; import { DatePicker } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { import {
CopyOutlined, DownloadOutlined, PlusOutlined, ReloadOutlined, UserOutlined, HistoryOutlined, WalletOutlined, CopyOutlined, DownloadOutlined, PlusOutlined, ReloadOutlined, UserOutlined, HistoryOutlined, WalletOutlined, BellOutlined, ClockCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
@@ -134,8 +134,7 @@ const TeamManagementPage: React.FC = () => {
try { try {
const values = await invForm.validateFields(); const values = await invForm.validateFields();
setInvSaving(true); setInvSaving(true);
const expiresAt = values.expiresAt ? new Date(values.expiresAt).toISOString() : null; await createTeamInvitation(values.maxUses || null, null);
await createTeamInvitation(values.maxUses || null, expiresAt);
message.success('邀请码已生成'); message.success('邀请码已生成');
setInvModal(false); setInvModal(false);
invForm.resetFields(); invForm.resetFields();
@@ -159,22 +158,80 @@ const TeamManagementPage: React.FC = () => {
}; };
const copyInviteLink = (link: string) => { const copyInviteLink = (link: string) => {
navigator.clipboard.writeText(link).then(() => { if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(link).then(() => {
message.success('邀请链接已复制');
}).catch(() => {
fallbackCopy(link);
});
} else {
fallbackCopy(link);
}
};
const fallbackCopy = (text: string) => {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
textArea.style.top = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
message.success('邀请链接已复制'); message.success('邀请链接已复制');
}).catch(() => { } catch {
message.warning('复制失败,请手动复制'); message.warning('复制失败,请手动复制');
}); }
document.body.removeChild(textArea);
}; };
// ── Tab 3: 加入申请 ── // ── Tab 3: 加入申请 ──
const [requests, setRequests] = useState<TeamJoinRequest[]>([]); const [requests, setRequests] = useState<TeamJoinRequest[]>([]);
const [reqLoading, setReqLoading] = useState(false); const [reqLoading, setReqLoading] = useState(false);
const [activeTab, setActiveTab] = useState('members');
const initialNoticeShownRef = useRef(false);
const lastRequestCountRef = useRef(0);
const loadRequests = useCallback(async () => { const loadRequests = useCallback(async (isInitial = false) => {
setReqLoading(true); setReqLoading(true);
try { try {
const data = await getPendingJoinRequests(); const data = await getPendingJoinRequests();
const currentCount = data?.length || 0;
setRequests(data || []); setRequests(data || []);
if (currentCount > 0) {
if (isInitial && !initialNoticeShownRef.current) {
initialNoticeShownRef.current = true;
Modal.confirm({
title: (
<Space>
<BellOutlined style={{ color: '#f59e0b' }} />
</Space>
),
content: (
<div>
<p> <strong style={{ color: '#ef4444' }}>{currentCount}</strong> </p>
<p style={{ color: '#64748b', fontSize: 13, marginBottom: 0 }}></p>
</div>
),
okText: '立即处理',
cancelText: '稍后处理',
onOk: () => {
setActiveTab('requests');
},
});
} else if (!isInitial && currentCount > lastRequestCountRef.current) {
message.info({
content: `${currentCount - lastRequestCountRef.current} 条新的加入申请待处理`,
duration: 5,
});
}
}
lastRequestCountRef.current = currentCount;
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '加载申请失败'); message.error(e?.message || '加载申请失败');
} finally { } finally {
@@ -182,7 +239,17 @@ const TeamManagementPage: React.FC = () => {
} }
}, []); }, []);
useEffect(() => { loadRequests(); }, [loadRequests]); useEffect(() => {
loadRequests(true);
}, [loadRequests]);
useEffect(() => {
if (!team) return;
const interval = setInterval(() => {
loadRequests(false);
}, 60000);
return () => clearInterval(interval);
}, [team, loadRequests]);
const handleRequest = async (requestId: string, action: 'approve' | 'reject', note?: string) => { const handleRequest = async (requestId: string, action: 'approve' | 'reject', note?: string) => {
try { try {
@@ -283,18 +350,41 @@ const TeamManagementPage: React.FC = () => {
}, },
]; ];
const buildInviteLink = (code: string) => {
const base = window.location.origin;
return `${base}/join-team?code=${code}`;
};
const invColumns = [ const invColumns = [
{ title: '邀请码', dataIndex: 'code', width: 200, render: (v: string) => <Typography.Text copyable style={{ fontFamily: 'monospace' }}>{v}</Typography.Text> }, { title: '邀请码', dataIndex: 'code', width: 200, render: (v: string) => <Typography.Text copyable style={{ fontFamily: 'monospace' }}>{v}</Typography.Text> },
{ {
title: '邀请链接', dataIndex: 'inviteLink', ellipsis: true, title: '邀请链接', dataIndex: 'code',
render: (v: string) => ( render: (v: string) => {
<Space> const link = buildInviteLink(v);
<Typography.Text ellipsis style={{ maxWidth: 250, fontSize: 12 }}>{v}</Typography.Text> return (
<Tooltip title="复制链接"> <Space style={{ width: '100%' }}>
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyInviteLink(v)} /> <Typography.Text
</Tooltip> style={{
</Space> flex: 1,
), fontSize: 12,
wordBreak: 'break-all',
fontFamily: 'monospace',
color: '#64748b',
}}
>
{link}
</Typography.Text>
<Tooltip title="复制链接">
<Button
size="small"
type="text"
icon={<CopyOutlined />}
onClick={() => copyInviteLink(link)}
/>
</Tooltip>
</Space>
);
},
}, },
{ title: '状态', dataIndex: 'status', width: 80, render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{v === 'active' ? '有效' : '已撤销'}</Tag> }, { title: '状态', dataIndex: 'status', width: 80, render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{v === 'active' ? '有效' : '已撤销'}</Tag> },
{ title: '使用次数', key: 'uses', width: 100, render: (_: any, r: TeamInvitation) => `${r.useCount}${r.maxUses ? `/${r.maxUses}` : ''}` }, { title: '使用次数', key: 'uses', width: 100, render: (_: any, r: TeamInvitation) => `${r.useCount}${r.maxUses ? `/${r.maxUses}` : ''}` },
@@ -517,7 +607,7 @@ const TeamManagementPage: React.FC = () => {
</div> </div>
{/* 标签页 */} {/* 标签页 */}
<Tabs items={tabItems} defaultActiveKey="members" size="large" /> <Tabs items={tabItems} activeKey={activeTab} onChange={setActiveTab} size="large" />
{/* 调整积分弹窗 */} {/* 调整积分弹窗 */}
<Modal <Modal
@@ -607,9 +697,19 @@ const TeamManagementPage: React.FC = () => {
<Form.Item name="maxUses" label="最大使用次数"> <Form.Item name="maxUses" label="最大使用次数">
<InputNumber style={{ width: '100%' }} min={1} placeholder="留空表示不限" size="large" /> <InputNumber style={{ width: '100%' }} min={1} placeholder="留空表示不限" size="large" />
</Form.Item> </Form.Item>
<Form.Item name="expiresAt" label="过期时间"> <div style={{
<Input type="datetime-local" style={{ width: '100%' }} placeholder="留空表示永不过期" size="large" /> padding: '12px 16px',
</Form.Item> background: '#eef2ff',
borderRadius: 8,
fontSize: 13,
color: '#4f46e5',
display: 'flex',
alignItems: 'center',
gap: 8,
}}>
<ClockCircleOutlined />
<span> <strong>24 </strong> </span>
</div>
</Form> </Form>
</Modal> </Modal>
</div> </div>
+47 -5
View File
@@ -77,6 +77,7 @@ export interface JoinTeamInfo {
teamId: string; teamId: string;
valid: boolean; valid: boolean;
alreadyInTeam: boolean; alreadyInTeam: boolean;
hasPendingRequest: boolean;
} }
export interface CreditRecord { export interface CreditRecord {
@@ -134,6 +135,9 @@ export interface MediaReference {
source?: string; source?: string;
private_asset_id?: string; private_asset_id?: string;
remote_asset_id?: string; remote_asset_id?: string;
providerUrl?: string;
displayUrl?: string;
previewUrl?: string;
} }
export interface GenerationRecord { export interface GenerationRecord {
@@ -253,14 +257,23 @@ export interface AdminNotification {
export interface PrivatePortraitConfig { export interface PrivatePortraitConfig {
enabled: boolean; enabled: boolean;
imageLimit: number; assetLimit: number;
usedImageCount: number; usedAssetCount: number;
remainingImageCount: number; remainingAssetCount: number;
supportedAssetTypes?: string[];
unsupportedAssetTypes?: string[];
imageLimit?: number;
usedImageCount?: number;
remainingImageCount?: number;
} }
export type PrivatePortraitLibraryType = 'real_person' | 'aigc_virtual';
export type PrivatePortraitAssetType = 'Image' | 'Video' | 'Audio';
export interface PrivatePortraitProject { export interface PrivatePortraitProject {
id: string; id: string;
userId?: string | null; userId?: string | null;
libraryType?: PrivatePortraitLibraryType | string;
name: string; name: string;
nameSlug?: string | null; nameSlug?: string | null;
remoteProjectName?: string | null; remoteProjectName?: string | null;
@@ -268,7 +281,11 @@ export interface PrivatePortraitProject {
status: string; status: string;
assetGroupCount: number; assetGroupCount: number;
assetCount: number; assetCount: number;
imageAssetCount?: number;
videoAssetCount?: number;
activeAssetCount: number; activeAssetCount: number;
activeImageAssetCount?: number;
activeVideoAssetCount?: number;
lastUsedAt?: string | null; lastUsedAt?: string | null;
createdAt?: string | null; createdAt?: string | null;
updatedAt?: string | null; updatedAt?: string | null;
@@ -297,23 +314,42 @@ export interface PrivatePortraitValidateSession {
updatedAt?: string | null; updatedAt?: string | null;
} }
export interface PrivatePortraitProjectCreateWithValidateOut {
project: PrivatePortraitProject;
validateSession: PrivatePortraitValidateSession;
pollIntervalMs: number;
}
export interface PrivatePortraitAsset { export interface PrivatePortraitAsset {
id: string; id: string;
userId?: string | null; userId?: string | null;
projectId: string; projectId: string;
projectName?: string | null; projectName?: string | null;
groupId: string; groupId: string;
libraryType?: PrivatePortraitLibraryType | string;
remoteGroupId: string; remoteGroupId: string;
remoteAssetId?: string | null; remoteAssetId?: string | null;
remoteProjectName?: string | null; remoteProjectName?: string | null;
assetType: string; assetType: PrivatePortraitAssetType | string;
name?: string | null; name?: string | null;
sourceUrl: string; sourceUrl: string;
previewUrl?: string | null; previewUrl?: string | null;
displayUrl?: string | null;
providerUrl?: string | null;
remoteUrl?: string | null; remoteUrl?: string | null;
remoteUrlExpiredAt?: string | null;
videoDuration?: number | null;
videoCoverUrl?: string | null;
fileSize?: number | null;
mimeType?: string | null;
status: string; status: string;
moderation?: unknown;
lastPollAt?: string | null;
nextPollAt?: string | null;
pollCount: number; pollCount: number;
remoteDeleteStatus: string; remoteDeleteStatus: string;
remoteDeletedAt?: string | null;
remoteDeleteError?: string | null;
errorMessage?: string | null; errorMessage?: string | null;
createdAt?: string | null; createdAt?: string | null;
updatedAt?: string | null; updatedAt?: string | null;
@@ -330,9 +366,14 @@ export interface PrivatePortraitSelectableAsset {
id: string; id: string;
projectId: string; projectId: string;
projectName: string; projectName: string;
libraryType?: PrivatePortraitLibraryType | string;
name?: string | null; name?: string | null;
assetType: string; assetType: PrivatePortraitAssetType | string;
previewUrl?: string | null; previewUrl?: string | null;
displayUrl?: string | null;
providerUrl?: string | null;
videoDuration?: number | null;
videoCoverUrl?: string | null;
status: string; status: string;
createdAt?: string | null; createdAt?: string | null;
} }
@@ -343,3 +384,4 @@ export interface PrivatePortraitSelectableAssetListOut {
page: number; page: number;
pageSize: number; pageSize: number;
} }