Merge branch 'main' of https://gitee.com/wg123/video-gen
This commit is contained in:
Vendored
+110
-110
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</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">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -448,7 +448,7 @@ export async function deleteMenuConfig(id: string): Promise<void> {
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
@@ -1017,22 +1017,25 @@ export async function getPreTestTemplateList(params: PreTestTemplateListParams):
|
||||
}
|
||||
|
||||
// ── 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();
|
||||
query.set('page', String(params.page || 1));
|
||||
query.set('page_size', String(params.pageSize || 20));
|
||||
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.status) query.set('status', params.status);
|
||||
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();
|
||||
query.set('page', String(params.page || 1));
|
||||
query.set('page_size', String(params.pageSize || 20));
|
||||
if (params.userId) query.set('user_id', params.userId);
|
||||
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.status) query.set('status', params.status);
|
||||
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> {
|
||||
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 { Button, Card, Input, Space, Table, Tag, Typography, message } from 'antd';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Card, Image, Input, Select, Space, Statistic, Table, Tag, Tooltip, Typography, message } from 'antd';
|
||||
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 type { PrivatePortraitAsset, PrivatePortraitProject } from '../types';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
import { apiUrl } from '../utils/resourceUrl';
|
||||
|
||||
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) => {
|
||||
if (status === 'Active' || status === 'active') return 'green';
|
||||
if (status === 'Processing') return 'blue';
|
||||
if (status === 'Failed' || status === 'failed' || status === 'delete_failed') return 'red';
|
||||
if (status === 'Processing' || status === 'creating_remote_group' || status === 'validating' || status === 'creating') return 'blue';
|
||||
if (status === 'Failed' || status === 'failed' || status === 'delete_failed' || status === 'validate_failed' || status === 'create_group_failed') return 'red';
|
||||
if (status?.includes('deleted')) 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 [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
|
||||
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
|
||||
@@ -23,18 +113,36 @@ const AdminPrivatePortraitProjects: React.FC = () => {
|
||||
const [loadingProjects, setLoadingProjects] = useState(false);
|
||||
const [loadingAssets, setLoadingAssets] = useState(false);
|
||||
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 [projectPage, setProjectPage] = 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 () => {
|
||||
setLoadingProjects(true);
|
||||
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 || []);
|
||||
setProjectTotal(res.total || 0);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '加载真人素材项目失败');
|
||||
message.error(err?.message || '加载私域人像素材项目失败');
|
||||
} finally {
|
||||
setLoadingProjects(false);
|
||||
}
|
||||
@@ -43,88 +151,111 @@ const AdminPrivatePortraitProjects: React.FC = () => {
|
||||
const loadAssets = async () => {
|
||||
setLoadingAssets(true);
|
||||
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 || []);
|
||||
setAssetTotal(res.total || 0);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '加载真人素材失败');
|
||||
message.error(err?.message || '加载私域人像素材失败');
|
||||
} finally {
|
||||
setLoadingAssets(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadProjects(); }, [projectPage]);
|
||||
useEffect(() => { loadAssets(); }, [selectedProjectId, assetPage]);
|
||||
useEffect(() => { loadProjects(); }, [projectPage, filters.libraryType, filters.projectStatus]);
|
||||
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> = [
|
||||
{ title: '项目名称', dataIndex: 'name', width: 180, 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: 'status', width: 100, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> },
|
||||
{ title: 'ProjectName', dataIndex: 'remoteProjectName', width: 240, render: (v) => <Text code copyable>{v || '-'}</Text> },
|
||||
{ title: '素材数', dataIndex: 'assetCount', width: 100 },
|
||||
{ title: 'Active', dataIndex: 'activeAssetCount', width: 100 },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v) => v || '-' },
|
||||
{ title: '项目名称', dataIndex: 'name', width: 200, fixed: 'left', render: (v, r) => <Button type="link" onClick={() => { setSelectedProjectId(r.id); setAssetPage(1); }}>{v}</Button> },
|
||||
{ title: '类型', dataIndex: 'libraryType', width: 110, render: (v) => <Tag color={libraryColor(v)}>{libraryLabel(v)}</Tag> },
|
||||
{ title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code copyable={!!v}>{v || '-'}</Text> },
|
||||
{ title: '状态', dataIndex: 'status', width: 130, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> },
|
||||
{ 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: 90 },
|
||||
{ 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> = [
|
||||
{ title: '素材', dataIndex: 'name', width: 180, render: (v, r) => v || r.remoteAssetId || '-' },
|
||||
{ title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code>{v || '-'}</Text> },
|
||||
{ title: '本地项目', dataIndex: 'projectName', width: 160 },
|
||||
{ title: 'AssetId', dataIndex: 'remoteAssetId', width: 240, render: (v) => <Text code copyable>{v}</Text> },
|
||||
{ title: 'GroupId', dataIndex: 'remoteGroupId', width: 240, render: (v) => <Text code copyable>{v}</Text> },
|
||||
{ title: 'ProjectName', dataIndex: 'remoteProjectName', width: 240, render: (v) => <Text code copyable>{v || '-'}</Text> },
|
||||
{ title: '类型', dataIndex: 'assetType', width: 90 },
|
||||
{ title: '状态', dataIndex: 'status', width: 110, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> },
|
||||
{ title: '错误', dataIndex: 'errorMessage', width: 240, ellipsis: true, render: (v) => v || '-' },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v) => v || '-' },
|
||||
{ title: '预览', width: 100, fixed: 'left', render: (_, r) => <PreviewCell asset={r} /> },
|
||||
{ title: '素材名称', dataIndex: 'name', width: 180, render: (v, r) => <div><Text strong>{v || '未命名素材'}</Text><br /><Text type="secondary">{assetTypeLabel(r.assetType)}</Text></div> },
|
||||
{ title: '库类型', dataIndex: 'libraryType', width: 110, render: (v) => <Tag color={libraryColor(v)}>{libraryLabel(v)}</Tag> },
|
||||
{ title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code copyable={!!v}>{v || '-'}</Text> },
|
||||
{ title: '本地项目', dataIndex: 'projectName', width: 160, render: (v) => v || '-' },
|
||||
{ title: '状态', dataIndex: 'status', width: 120, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> },
|
||||
{ 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: '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: '轮询', width: 160, render: (_, r) => <div><Text>次数:{r.pollCount || 0}</Text><br /><Text type="secondary">下次:{formatDate((r as any).nextPollAt)}</Text></div> },
|
||||
{ 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 (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div style={{ padding: 24, background: '#f7f7fb', minHeight: '100%' }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} align="start">
|
||||
<div>
|
||||
<Title level={3} style={{ margin: 0 }}>真人素材库</Title>
|
||||
<Text type="secondary">只读排查页:查看用户项目组、Asset Group 与 Asset 状态。</Text>
|
||||
<Title level={3} style={{ margin: 0 }}>私域人像素材库</Title>
|
||||
<Text type="secondary">统一查看真人认证素材与虚拟人像素材,支持图片/视频资产状态排查。</Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索项目/素材"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onPressEnter={() => { setProjectPage(1); setAssetPage(1); loadProjects(); loadAssets(); }}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
<Space wrap>
|
||||
<Input allowClear prefix={<SearchOutlined />} placeholder="搜索项目/素材" value={keyword} onChange={(e) => setKeyword(e.target.value)} onPressEnter={searchAll} style={{ width: 240 }} />
|
||||
<Select value={libraryType} onChange={(v) => { setLibraryType(v); setProjectPage(1); setAssetPage(1); }} options={LIBRARY_OPTIONS} style={{ width: 150 }} />
|
||||
<Select value={assetType} onChange={(v) => { setAssetType(v); setAssetPage(1); }} options={ASSET_TYPE_OPTIONS} style={{ width: 130 }} />
|
||||
<Button icon={<SearchOutlined />} type="primary" onClick={searchAll}>查询</Button>
|
||||
<Button onClick={resetFilters}>重置</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(); }}>刷新</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Card title="真人素材项目组" style={{ marginBottom: 16 }}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={projectColumns}
|
||||
dataSource={projects}
|
||||
loading={loadingProjects}
|
||||
pagination={{ current: projectPage, pageSize: 20, total: projectTotal, onChange: setProjectPage }}
|
||||
size="small"
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
<Space size={16} wrap style={{ marginBottom: 16 }}>
|
||||
<Card style={{ width: 180 }}><Statistic title="项目数" value={projectTotal} /></Card>
|
||||
<Card style={{ width: 180 }}><Statistic title="当前页素材" value={projectSummary.asset} /></Card>
|
||||
<Card style={{ width: 180 }}><Statistic title="图片" value={projectSummary.image} /></Card>
|
||||
<Card style={{ width: 180 }}><Statistic title="视频" value={projectSummary.video} /></Card>
|
||||
<Card style={{ width: 180 }}><Statistic title="Active" value={projectSummary.active} /></Card>
|
||||
</Space>
|
||||
|
||||
<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
|
||||
title={selectedProjectId ? '项目素材明细' : '全部素材明细'}
|
||||
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 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>}>
|
||||
<Table rowKey="id" columns={assetColumns} dataSource={assets} loading={loadingAssets} pagination={{ current: assetPage, pageSize: PAGE_SIZE, total: assetTotal, onChange: setAssetPage }} size="small" scroll={{ x: 1700 }} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -189,7 +189,7 @@ const AdminUsers: React.FC = () => {
|
||||
credits: values.credits || 0,
|
||||
user_type: userType,
|
||||
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('用户创建成功');
|
||||
setCreateModal(false);
|
||||
@@ -276,13 +276,13 @@ const AdminUsers: React.FC = () => {
|
||||
const openPortraitModal = async (user: AdminUser) => {
|
||||
setPortraitLoading(true);
|
||||
setPortraitModal({ open: true, user, config: null });
|
||||
portraitForm.setFieldsValue({ privatePortraitImageLimit: user.privatePortraitImageLimit ?? 5 });
|
||||
portraitForm.setFieldsValue({ privatePortraitAssetLimit: user.privatePortraitAssetLimit ?? 5 });
|
||||
try {
|
||||
const config = await adminGetPrivatePortraitConfig(user.id);
|
||||
portraitForm.setFieldsValue({ privatePortraitImageLimit: config.imageLimit });
|
||||
portraitForm.setFieldsValue({ privatePortraitAssetLimit: config.assetLimit });
|
||||
setPortraitModal({ open: true, user, config });
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载真人素材库配置失败');
|
||||
message.error(e?.message || '加载私域人像素材库配置失败');
|
||||
setPortraitModal({ open: false, user: null, config: null });
|
||||
} finally {
|
||||
setPortraitLoading(false);
|
||||
@@ -294,16 +294,16 @@ const AdminUsers: React.FC = () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const values = await portraitForm.validateFields();
|
||||
const limit = Number(values.privatePortraitImageLimit ?? 0);
|
||||
const limit = Number(values.privatePortraitAssetLimit ?? 0);
|
||||
setPortraitSaving(true);
|
||||
const config = await adminUpdatePrivatePortraitConfig(user.id, limit);
|
||||
message.success(limit > 0 ? `真人素材库已开启,限制 ${limit} 张` : '真人素材库已关闭');
|
||||
message.success(limit > 0 ? `私域人像素材库已开启,限制 ${limit} 个` : '私域人像素材库已关闭');
|
||||
setPortraitModal({ open: false, user: null, config });
|
||||
portraitForm.resetFields();
|
||||
load();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e?.message || '保存真人素材库配置失败');
|
||||
message.error(e?.message || '保存私域人像素材库配置失败');
|
||||
} finally {
|
||||
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>,
|
||||
}] : []),
|
||||
...(!isAdminTab ? [{
|
||||
title: '真人素材库', dataIndex: 'privatePortraitImageLimit', width: 150,
|
||||
title: '私域人像素材库', dataIndex: 'privatePortraitAssetLimit', width: 150,
|
||||
render: (v: number) => {
|
||||
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 ? [{
|
||||
@@ -493,7 +493,7 @@ const AdminUsers: React.FC = () => {
|
||||
{!isAdminTab && (
|
||||
<Button type="link" size="small" icon={<PictureOutlined />}
|
||||
onClick={() => openPortraitModal(r)}>
|
||||
真人素材
|
||||
私域人像素材
|
||||
</Button>
|
||||
)}
|
||||
{!isAdminTab && (
|
||||
@@ -784,7 +784,7 @@ const AdminUsers: React.FC = () => {
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={<Space><PictureOutlined />真人素材库设置 - {portraitModal.user?.username}</Space>}
|
||||
title={<Space><PictureOutlined />私域人像素材库设置 - {portraitModal.user?.username}</Space>}
|
||||
open={portraitModal.open}
|
||||
confirmLoading={portraitSaving}
|
||||
onOk={handleSavePortraitConfig}
|
||||
@@ -797,17 +797,17 @@ const AdminUsers: React.FC = () => {
|
||||
当前状态:{portraitModal.config?.enabled ? <Tag color="purple">已开启</Tag> : <Tag>未开启</Tag>}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
已用:{portraitModal.config?.usedImageCount ?? '-'} 张;
|
||||
剩余:{portraitModal.config?.enabled ? portraitModal.config.remainingImageCount : 0} 张
|
||||
已用:{portraitModal.config?.usedAssetCount ?? '-'} 个;
|
||||
剩余:{portraitModal.config?.enabled ? portraitModal.config.remainingAssetCount : 0} 个
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Card>
|
||||
<Form form={portraitForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="privatePortraitImageLimit"
|
||||
label="真人素材库图片上限"
|
||||
extra="0 表示关闭真人素材库;大于 0 表示开启,并限制该用户所有真人素材图片总量。"
|
||||
rules={[{ required: true, message: '请输入真人素材库图片上限' }]}
|
||||
name="privatePortraitAssetLimit"
|
||||
label="私域人像素材总量上限"
|
||||
extra="0 表示关闭私域人像素材库;大于 0 表示开启,并限制该用户所有私域人像素材总量。"
|
||||
rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
|
||||
>
|
||||
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
@@ -857,11 +857,11 @@ const AdminUsers: React.FC = () => {
|
||||
)}
|
||||
{createType === 'frontend' && (
|
||||
<Form.Item
|
||||
name="private_portrait_image_limit"
|
||||
label="真人素材库图片上限"
|
||||
name="private_portrait_asset_limit"
|
||||
label="私域人像素材总量上限"
|
||||
initialValue={5}
|
||||
extra="0 表示关闭真人素材库;大于 0 表示开启并限制该用户所有真人素材图片总量。"
|
||||
rules={[{ required: true, message: '请输入真人素材库图片上限' }]}
|
||||
extra="0 表示关闭私域人像素材库;大于 0 表示开启并限制该用户所有私域人像素材总量。"
|
||||
rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
|
||||
>
|
||||
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface User {
|
||||
userType: string;
|
||||
allowedMenus?: string[] | null;
|
||||
resourceCapacity?: ResourceCapacityUsage | null;
|
||||
privatePortraitImageLimit: number;
|
||||
privatePortraitAssetLimit: number;
|
||||
}
|
||||
|
||||
export interface CreditRecord {
|
||||
@@ -194,7 +194,7 @@ export interface AdminUser {
|
||||
lastLoginAt?: string;
|
||||
allowedMenus?: string[] | null;
|
||||
resourceCapacity?: ResourceCapacityUsage | null;
|
||||
privatePortraitImageLimit: number;
|
||||
privatePortraitAssetLimit: number;
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
@@ -1125,14 +1125,20 @@ export interface HomeMaterialTextWatermarkPreviewResponse {
|
||||
|
||||
export interface PrivatePortraitConfig {
|
||||
enabled: boolean;
|
||||
imageLimit: number;
|
||||
usedImageCount: number;
|
||||
remainingImageCount: number;
|
||||
assetLimit: number;
|
||||
usedAssetCount: number;
|
||||
remainingAssetCount: number;
|
||||
supportedAssetTypes?: string[];
|
||||
unsupportedAssetTypes?: string[];
|
||||
imageLimit?: number;
|
||||
usedImageCount?: number;
|
||||
remainingImageCount?: number;
|
||||
}
|
||||
|
||||
export interface PrivatePortraitProject {
|
||||
id: string;
|
||||
userId?: string | null;
|
||||
libraryType: string;
|
||||
name: string;
|
||||
nameSlug?: string | null;
|
||||
remoteProjectName?: string | null;
|
||||
@@ -1140,7 +1146,11 @@ export interface PrivatePortraitProject {
|
||||
status: string;
|
||||
assetGroupCount: number;
|
||||
assetCount: number;
|
||||
imageAssetCount?: number;
|
||||
videoAssetCount?: number;
|
||||
activeAssetCount: number;
|
||||
activeImageAssetCount?: number;
|
||||
activeVideoAssetCount?: number;
|
||||
lastUsedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
@@ -1174,6 +1184,7 @@ export interface PrivatePortraitAsset {
|
||||
projectId: string;
|
||||
projectName?: string | null;
|
||||
groupId: string;
|
||||
libraryType: string;
|
||||
remoteGroupId: string;
|
||||
remoteAssetId?: string | null;
|
||||
remoteProjectName?: string | null;
|
||||
@@ -1181,7 +1192,13 @@ export interface PrivatePortraitAsset {
|
||||
name?: string | null;
|
||||
sourceUrl: string;
|
||||
previewUrl?: string | null;
|
||||
displayUrl?: string | null;
|
||||
providerUrl?: string | null;
|
||||
remoteUrl?: string | null;
|
||||
videoDuration?: number | null;
|
||||
videoCoverUrl?: string | null;
|
||||
fileSize?: number | null;
|
||||
mimeType?: string | null;
|
||||
status: string;
|
||||
pollCount: number;
|
||||
remoteDeleteStatus: string;
|
||||
@@ -1201,9 +1218,14 @@ export interface PrivatePortraitSelectableAsset {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
libraryType: string;
|
||||
name?: string | null;
|
||||
assetType: string;
|
||||
previewUrl?: string | null;
|
||||
displayUrl?: string | null;
|
||||
providerUrl?: string | null;
|
||||
videoDuration?: number | null;
|
||||
videoCoverUrl?: string | null;
|
||||
status: string;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ VIDEO_COVER_WIDTH=600
|
||||
VIDEO_COVER_TIMEOUT_SECONDS=15
|
||||
VIDEO_COVER_FORMAT=png
|
||||
|
||||
# VOLC
|
||||
VOLC_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
|
||||
VOLC_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ==
|
||||
|
||||
# VOLC_SMS
|
||||
VOLC_SMS_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
|
||||
VOLC_SMS_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ==
|
||||
|
||||
+202
@@ -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 ###
|
||||
@@ -10,127 +10,59 @@ from app.dependencies import get_admin_user, get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.private_portrait import (
|
||||
PrivatePortraitAdminConfigUpdate,
|
||||
PrivatePortraitAdminStatsOut,
|
||||
PrivatePortraitAssetListOut,
|
||||
PrivatePortraitConfigOut,
|
||||
PrivatePortraitProjectListOut,
|
||||
)
|
||||
from app.services.operation_log import log_operation
|
||||
from app.services.private_portrait.asset_service import (
|
||||
asset_to_out,
|
||||
get_user_private_portrait_config,
|
||||
list_assets,
|
||||
set_user_private_portrait_limit,
|
||||
)
|
||||
from app.services.private_portrait.project_service import list_projects, project_to_out
|
||||
from app.services.private_portrait.admin.asset_query_service import admin_list_assets
|
||||
from app.services.private_portrait.admin.project_query_service import admin_list_projects
|
||||
from app.services.private_portrait.admin.stats_query_service import admin_get_private_portrait_stats
|
||||
from app.services.private_portrait.quota_service import get_user_private_portrait_config, set_user_private_portrait_limit
|
||||
|
||||
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:
|
||||
return json.dumps(data, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/config", response_model=PrivatePortraitConfigOut)
|
||||
async def admin_get_private_portrait_config(
|
||||
user_id: str,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@router.get("/users/{user_id}/config", response_model=PrivatePortraitConfigOut, summary="管理后台:查看用户私域人像素材额度")
|
||||
async def admin_get_private_portrait_config(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)
|
||||
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")}),
|
||||
)
|
||||
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")}))
|
||||
return config
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/config", response_model=PrivatePortraitConfigOut)
|
||||
async def admin_update_private_portrait_config(
|
||||
user_id: str,
|
||||
payload: PrivatePortraitAdminConfigUpdate,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@router.put("/users/{user_id}/config", response_model=PrivatePortraitConfigOut, summary="管理后台:设置用户私域人像素材总量")
|
||||
async def admin_update_private_portrait_config(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)
|
||||
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)
|
||||
await log_operation(
|
||||
db,
|
||||
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"),
|
||||
}
|
||||
),
|
||||
)
|
||||
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")}))
|
||||
await db.commit()
|
||||
return after
|
||||
|
||||
|
||||
@router.get("/projects", response_model=PrivatePortraitProjectListOut)
|
||||
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),
|
||||
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("/stats", response_model=PrivatePortraitAdminStatsOut, summary="管理后台:私域人像素材统计")
|
||||
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)):
|
||||
stats = await admin_get_private_portrait_stats(db, user_id=user_id, library_type=library_type)
|
||||
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")}))
|
||||
return stats
|
||||
|
||||
|
||||
@router.get("/assets", response_model=PrivatePortraitAssetListOut)
|
||||
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),
|
||||
keyword: str | None = Query(None),
|
||||
status: str | None = Query(None),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
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, "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)
|
||||
@router.get("/projects", response_model=PrivatePortraitProjectListOut, summary="管理后台:查询私域人像项目列表")
|
||||
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)):
|
||||
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)
|
||||
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)}))
|
||||
await db.commit()
|
||||
return PrivatePortraitProjectListOut(items=items, total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/assets", response_model=PrivatePortraitAssetListOut, summary="管理后台:查询私域人像素材列表")
|
||||
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)
|
||||
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 db.commit()
|
||||
return PrivatePortraitAssetListOut(items=items, total=total, page=page, page_size=page_size)
|
||||
|
||||
@@ -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.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_virtual import router as private_portrait_virtual_router
|
||||
|
||||
api_router = APIRouter()
|
||||
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(admin_module_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)
|
||||
|
||||
@@ -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.auth import hash_password, verify_password
|
||||
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.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
|
||||
@@ -173,7 +174,7 @@ async def create_user(
|
||||
user_type=req.user_type,
|
||||
frontend_user_kind=req.frontend_user_kind if req.user_type == "frontend" else FrontendUserKind.EXTERNAL.value,
|
||||
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)
|
||||
db.add(user)
|
||||
@@ -191,7 +192,7 @@ async def create_user(
|
||||
"username": username,
|
||||
"user_type": req.user_type,
|
||||
"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,
|
||||
"phone": user.phone,
|
||||
"email": user.email,
|
||||
@@ -1449,15 +1450,15 @@ async def admin_list_generation_records(
|
||||
query = query.offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
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 = []
|
||||
for record, username, project_name, industry, industry_label in rows:
|
||||
refs = None
|
||||
if record.media_references:
|
||||
try:
|
||||
refs = json.loads(record.media_references)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
refs = None
|
||||
refs = refs_map.get(record.id)
|
||||
items.append({
|
||||
"id": record.id,
|
||||
"user_id": record.user_id,
|
||||
|
||||
@@ -33,6 +33,7 @@ from app.services.resource_accounting_service import (
|
||||
record_generation_record_generated_resource,
|
||||
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_capacity_service import assert_user_resource_capacity_available
|
||||
from app.services.generation_billing_service import (
|
||||
@@ -59,9 +60,9 @@ router = APIRouter(prefix="/generation-records", tags=["generation"])
|
||||
logger = logging.getLogger("videogen")
|
||||
|
||||
|
||||
def _record_to_out(record: GenerationRecord, project_name: str) -> GenerationRecordOut:
|
||||
refs = None
|
||||
if record.media_references:
|
||||
def _record_to_out(record: GenerationRecord, project_name: str, refs_override: list[dict] | None = None) -> GenerationRecordOut:
|
||||
refs = refs_override
|
||||
if refs is None and record.media_references:
|
||||
try:
|
||||
refs = json.loads(record.media_references)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
@@ -191,13 +192,18 @@ async def list_records(
|
||||
|
||||
result = await db.execute(query)
|
||||
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 {
|
||||
"total": int(total),
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"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
|
||||
],
|
||||
}
|
||||
@@ -240,11 +246,12 @@ async def optimize(
|
||||
row = existing.first()
|
||||
if 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(
|
||||
optimized_prompt=record.optimized_prompt or "",
|
||||
text_credits_cost=record.text_credits_cost or 0.00,
|
||||
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
|
||||
@@ -370,11 +377,12 @@ async def optimize(
|
||||
record.text_tokens_used = token_usage["total_tokens"]
|
||||
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(
|
||||
optimized_prompt=optimized,
|
||||
text_credits_cost=round(text_credits, 2),
|
||||
# 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()
|
||||
|
||||
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")
|
||||
@@ -578,7 +587,8 @@ async def retry_generation(
|
||||
)
|
||||
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")
|
||||
|
||||
@@ -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_log_service import log_task_event
|
||||
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.tasks.celery_app import celery_app
|
||||
|
||||
@@ -173,7 +174,8 @@ async def create_task(
|
||||
await db.commit()
|
||||
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(
|
||||
@@ -280,7 +282,12 @@ async def list_tasks(
|
||||
)
|
||||
else:
|
||||
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(
|
||||
@@ -521,7 +528,8 @@ async def get_task(
|
||||
task = result.scalar_one_or_none()
|
||||
if not task:
|
||||
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(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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.responses import RedirectResponse
|
||||
@@ -9,9 +9,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.enums.private_portrait import (
|
||||
PrivatePortraitAssetType,
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
PrivatePortraitEventType,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitProjectStatus,
|
||||
PrivatePortraitRemoteDeleteStatus,
|
||||
)
|
||||
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitProject
|
||||
@@ -19,22 +22,24 @@ from app.models.user import User
|
||||
from app.schemas.private_portrait import (
|
||||
PrivatePortraitAssetCreate,
|
||||
PrivatePortraitAssetListOut,
|
||||
PrivatePortraitAssetOut,
|
||||
PrivatePortraitDeleteOut,
|
||||
PrivatePortraitConfigOut,
|
||||
PrivatePortraitProjectCreate,
|
||||
PrivatePortraitProjectCreateWithValidateOut,
|
||||
PrivatePortraitProjectListOut,
|
||||
PrivatePortraitProjectOut,
|
||||
PrivatePortraitProjectUpdate,
|
||||
PrivatePortraitSelectableAssetListOut,
|
||||
PrivatePortraitValidateSessionCreate,
|
||||
PrivatePortraitValidateSessionOut,
|
||||
PrivatePortraitEnumMetaOut,
|
||||
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,
|
||||
create_asset,
|
||||
create_validate_session,
|
||||
get_user_private_portrait_config,
|
||||
get_validate_session,
|
||||
handle_validate_callback,
|
||||
@@ -45,89 +50,102 @@ from app.services.private_portrait.asset_service import (
|
||||
validate_session_to_out,
|
||||
)
|
||||
from app.services.private_portrait.project_service import (
|
||||
create_project,
|
||||
get_user_project,
|
||||
list_projects,
|
||||
project_to_out,
|
||||
refresh_project_counters,
|
||||
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:
|
||||
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},
|
||||
)
|
||||
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},
|
||||
)
|
||||
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/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)):
|
||||
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)):
|
||||
project = await create_project(db, user_id=current_user.id, payload=payload)
|
||||
out = project_to_out(project)
|
||||
project = await create_real_person_project(db, user_id=current_user.id, payload=payload)
|
||||
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()
|
||||
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(
|
||||
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),
|
||||
page: int = Query(1, ge=1, description="页码,从 1 开始。"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量,最大 100。"),
|
||||
keyword: str | None = Query(None, description="项目名称模糊搜索。"),
|
||||
status: str | None = Query(None, description="项目状态,不传默认 active。"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
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 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)
|
||||
|
||||
|
||||
@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)):
|
||||
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)):
|
||||
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)
|
||||
await db.commit()
|
||||
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)):
|
||||
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
|
||||
await db.commit()
|
||||
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)
|
||||
|
||||
|
||||
@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)):
|
||||
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)
|
||||
await db.commit()
|
||||
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)):
|
||||
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)):
|
||||
params = dict(request.query_params)
|
||||
params.pop("session_id", None)
|
||||
params.pop("redirect_url", None)
|
||||
session = await handle_validate_callback(db, session_id=session_id, query_params=params)
|
||||
redirect_session_id = session.id
|
||||
redirect_status = session.status
|
||||
redirect_result_code = session.result_code or ""
|
||||
redirect_params = {"session_id": session.id, "status": session.status, "resultCode": session.result_code or ""}
|
||||
if session.remote_group_id:
|
||||
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}
|
||||
await db.commit()
|
||||
if redirect_url:
|
||||
sep = "&" if "?" in redirect_url else "?"
|
||||
url = f"{unquote(redirect_url)}{sep}session_id={redirect_session_id}&status={redirect_status}&resultCode={redirect_result_code}"
|
||||
return RedirectResponse(url=url)
|
||||
base_url = unquote(redirect_url)
|
||||
sep = "&" if "?" in base_url else "?"
|
||||
return RedirectResponse(url=f"{base_url}{sep}{urlencode(redirect_params)}")
|
||||
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)):
|
||||
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
|
||||
project_id_snapshot = asset.project_id
|
||||
out = asset_to_out(asset)
|
||||
@@ -188,32 +206,34 @@ async def create_private_portrait_asset(project_id: str, payload: PrivatePortrai
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/private-portrait/projects/{project_id}/assets", response_model=PrivatePortraitAssetListOut)
|
||||
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)):
|
||||
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)
|
||||
@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), 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.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)
|
||||
|
||||
|
||||
@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)):
|
||||
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:
|
||||
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()
|
||||
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)):
|
||||
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)
|
||||
await db.commit()
|
||||
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)):
|
||||
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
|
||||
project_id_snapshot = asset.project_id
|
||||
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)
|
||||
|
||||
|
||||
@router.get("/private-portrait/selectable-assets", response_model=PrivatePortraitSelectableAssetListOut)
|
||||
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)):
|
||||
items, total = await list_selectable_assets(db, user_id=current_user.id, project_id=project_id, keyword=keyword, page=page, page_size=page_size)
|
||||
@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), 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.REAL_PERSON.value, asset_type=asset_type)
|
||||
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="创建本地虚拟人像项目,并同步调用火山 CreateAssetGroup,GroupType=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)
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
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_invitation import TeamInvitation
|
||||
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"])
|
||||
|
||||
|
||||
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")
|
||||
async def get_managed_team_info(
|
||||
@@ -135,7 +130,6 @@ async def create_invitation(
|
||||
"max_uses": invitation.max_uses,
|
||||
"use_count": invitation.use_count,
|
||||
"expires_at": invitation.expires_at,
|
||||
"invite_link": _build_invite_link(invitation.code),
|
||||
"created_at": invitation.created_at,
|
||||
}
|
||||
|
||||
@@ -158,7 +152,6 @@ async def list_invitations(
|
||||
"max_uses": inv.max_uses,
|
||||
"use_count": inv.use_count,
|
||||
"expires_at": inv.expires_at,
|
||||
"invite_link": _build_invite_link(inv.code),
|
||||
"created_at": inv.created_at,
|
||||
}
|
||||
for inv in invitations
|
||||
@@ -189,29 +182,67 @@ async def join_by_code(
|
||||
@router.get("/join-info", )
|
||||
async def get_join_info(
|
||||
code: str = Query(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""验证邀请码并返回团队信息(用于加入页面展示)。"""
|
||||
invitation = await team_invitation_service.get_invitation_by_code(db, code)
|
||||
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(
|
||||
select(Team.name).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
|
||||
)
|
||||
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(
|
||||
team_name=team_name,
|
||||
team_id=invitation.team_id,
|
||||
valid=True,
|
||||
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", )
|
||||
async def list_join_requests(
|
||||
current_user: User = Depends(get_current_user),
|
||||
|
||||
@@ -37,6 +37,10 @@ class Settings(BaseSettings):
|
||||
SMS_TEMPLATE_CODE: str = "SMS_001"
|
||||
SMS_MOCK: bool = True
|
||||
|
||||
# 火山引擎配置。
|
||||
VOLC_ACCESS_KEY_ID: str = ""
|
||||
VOLC_SECRET_ACCESS_KEY: str = ""
|
||||
|
||||
# 火山引擎短信配置。
|
||||
# SmsAccount=消息组ID,TemplateID=模板ID,Sign=短信签名内容。
|
||||
VOLC_SMS_ACCESS_KEY_ID: str = ""
|
||||
|
||||
@@ -70,6 +70,31 @@ async def get_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(
|
||||
current_user: User = Depends(get_current_user_allow_password_pending),
|
||||
) -> User:
|
||||
|
||||
@@ -2,14 +2,18 @@ from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
# 用户真人素材图片默认上限。users.private_portrait_image_limit = 0 表示关闭模块;>0 表示启用并限制总量。
|
||||
PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT = 5
|
||||
# 用户私域人像素材默认上限。users.private_portrait_asset_limit = 0 表示关闭模块;>0 表示启用并限制总素材量。
|
||||
# 统计口径:真人 + 虚拟;图片 + 视频。音频当前业务暂不开放。
|
||||
PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT = 5
|
||||
|
||||
# 火山 Ark 私域真人素材 ProjectName:火山侧项目空间固定使用 default,并快照到各业务表 remote_project_name。
|
||||
# 火山 Ark 私域素材 ProjectName:火山侧项目空间固定使用 default,并快照到各业务表 remote_project_name。
|
||||
# 用户/项目隔离依赖本地 project_id 和火山返回的 Asset Group ID,不再动态拼接 ProjectName。
|
||||
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_SUCCESS_RESULT_CODE = "10000"
|
||||
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"
|
||||
|
||||
PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS = 20
|
||||
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_INTERVAL_SECONDS = 30
|
||||
PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT = 60
|
||||
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_MAX_COUNT = 120
|
||||
PRIVATE_PORTRAIT_ASSET_POLL_BATCH_SIZE = 50
|
||||
PRIVATE_PORTRAIT_REMOTE_DELETE_RECOVERY_BATCH_SIZE = 50
|
||||
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] = {
|
||||
"CreateVisualValidateSession": 3,
|
||||
"GetVisualValidateResult": 3,
|
||||
"CreateAssetGroup": 10,
|
||||
"CreateAsset": 1,
|
||||
"ListAssetGroups": 10,
|
||||
"ListAssets": 10,
|
||||
@@ -44,6 +51,7 @@ PRIVATE_PORTRAIT_ACTION_QPS_LIMITS: dict[str, int] = {
|
||||
class ArkPrivatePortraitAction(str, Enum):
|
||||
CREATE_VISUAL_VALIDATE_SESSION = "CreateVisualValidateSession"
|
||||
GET_VISUAL_VALIDATE_RESULT = "GetVisualValidateResult"
|
||||
CREATE_ASSET_GROUP = "CreateAssetGroup"
|
||||
CREATE_ASSET = "CreateAsset"
|
||||
GET_ASSET = "GetAsset"
|
||||
LIST_ASSETS = "ListAssets"
|
||||
@@ -55,8 +63,17 @@ class ArkPrivatePortraitAction(str, Enum):
|
||||
DELETE_ASSET_GROUP = "DeleteAssetGroup"
|
||||
|
||||
|
||||
class PrivatePortraitLibraryType(str, Enum):
|
||||
REAL_PERSON = "real_person"
|
||||
AIGC_VIRTUAL = "aigc_virtual"
|
||||
|
||||
|
||||
class PrivatePortraitProjectStatus(str, Enum):
|
||||
VALIDATING = "validating"
|
||||
ACTIVE = "active"
|
||||
VALIDATE_FAILED = "validate_failed"
|
||||
CREATING_REMOTE_GROUP = "creating_remote_group"
|
||||
CREATE_GROUP_FAILED = "create_group_failed"
|
||||
DELETED = "deleted"
|
||||
|
||||
|
||||
@@ -70,6 +87,7 @@ class PrivatePortraitValidateSessionStatus(str, Enum):
|
||||
|
||||
|
||||
class PrivatePortraitAssetGroupStatus(str, Enum):
|
||||
CREATING = "creating"
|
||||
ACTIVE = "active"
|
||||
LOCAL_DELETED = "local_deleted"
|
||||
REMOTE_DELETED = "remote_deleted"
|
||||
@@ -90,7 +108,13 @@ class PrivatePortraitAssetStatus(str, Enum):
|
||||
class PrivatePortraitAssetType(str, Enum):
|
||||
IMAGE = "Image"
|
||||
VIDEO = "Video"
|
||||
AUDIO = "Audio"
|
||||
AUDIO = "Audio" # 火山支持,但当前业务暂不开放。
|
||||
|
||||
|
||||
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES = {
|
||||
PrivatePortraitAssetType.IMAGE.value,
|
||||
PrivatePortraitAssetType.VIDEO.value,
|
||||
}
|
||||
|
||||
|
||||
class PrivatePortraitRemoteDeleteStatus(str, Enum):
|
||||
@@ -123,9 +147,19 @@ class PrivatePortraitEventSource(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_UPDATE = "PROJECT_UPDATE"
|
||||
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_FAILED = "VALIDATE_SESSION_CREATE_FAILED"
|
||||
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_FAILED = "PROJECT_DELETE_REMOTE_FAILED"
|
||||
|
||||
|
||||
TASK_DISPATCH_SUCCESS = "TASK_DISPATCH_SUCCESS"
|
||||
TASK_DISPATCH_FAILED = "TASK_DISPATCH_FAILED"
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@ from __future__ import annotations
|
||||
|
||||
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 app.enums.private_portrait import (
|
||||
PrivatePortraitAssetStatus,
|
||||
PrivatePortraitAssetType,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitRemoteDeleteStatus,
|
||||
)
|
||||
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||
@@ -19,10 +20,13 @@ class PrivatePortraitAsset(Base, TimestampMixin, SoftDeleteMixin):
|
||||
__tablename__ = "private_portrait_assets"
|
||||
__table_args__ = (
|
||||
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_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_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(
|
||||
"idx_private_portrait_assets_next_poll_status",
|
||||
"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)
|
||||
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)
|
||||
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_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)
|
||||
@@ -45,6 +56,10 @@ class PrivatePortraitAsset(Base, TimestampMixin, SoftDeleteMixin):
|
||||
preview_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)
|
||||
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(
|
||||
String(32),
|
||||
nullable=False,
|
||||
|
||||
@@ -2,12 +2,13 @@ from __future__ import annotations
|
||||
|
||||
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 app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_GROUP_TYPE,
|
||||
PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE,
|
||||
PrivatePortraitAssetGroupStatus,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitRemoteDeleteStatus,
|
||||
)
|
||||
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||
@@ -20,18 +21,33 @@ class PrivatePortraitAssetGroup(Base, TimestampMixin, SoftDeleteMixin):
|
||||
__table_args__ = (
|
||||
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_library", "user_id", "library_type"),
|
||||
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_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)
|
||||
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)
|
||||
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_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)
|
||||
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(
|
||||
String(32),
|
||||
nullable=False,
|
||||
|
||||
@@ -5,15 +5,16 @@ from datetime import datetime
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
|
||||
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
|
||||
|
||||
|
||||
class PrivatePortraitProject(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""用户本地真人素材项目组。remote_project_name 是火山 ProjectName 快照。"""
|
||||
"""用户本地私域人像素材项目组。remote_project_name 是火山 ProjectName 快照。"""
|
||||
|
||||
__tablename__ = "private_portrait_projects"
|
||||
__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_deleted",
|
||||
@@ -22,10 +23,19 @@ class PrivatePortraitProject(Base, TimestampMixin, SoftDeleteMixin):
|
||||
postgresql_where=text("deleted_at IS NULL"),
|
||||
),
|
||||
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)
|
||||
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_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 快照")
|
||||
@@ -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_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_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)
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
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 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_byted_token", "byted_token"),
|
||||
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)
|
||||
|
||||
@@ -39,8 +39,8 @@ class User(Base, TimestampMixin):
|
||||
)
|
||||
allowed_menus: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
# 真人素材库图片总量限制。0 表示关闭真人素材模块;>0 表示启用并限制用户所有真人素材图片总量。
|
||||
private_portrait_image_limit: Mapped[int] = mapped_column(
|
||||
# 私域人像素材总量限制。0 表示关闭模块;>0 表示启用并限制真人/虚拟、图片/视频素材总量。
|
||||
private_portrait_asset_limit: Mapped[int] = mapped_column(
|
||||
Integer, default=5, server_default="5", nullable=False
|
||||
)
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ class AdminUserOut(BaseModel):
|
||||
last_login_at: NaiveDatetimeOptional = None
|
||||
allowed_menus: list | None = None
|
||||
resource_capacity: ResourceCapacityUsageOut | None = None
|
||||
private_portrait_image_limit: int = 5
|
||||
private_portrait_asset_limit: int = 5
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -72,7 +72,7 @@ class CreateUserRequest(BaseModel):
|
||||
user_type: str = Field(default="frontend", pattern="^(frontend|admin)$")
|
||||
frontend_user_kind: str = Field(default="external", pattern="^(internal|external)$")
|
||||
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):
|
||||
|
||||
@@ -56,6 +56,21 @@ class GenerationAIReference(BaseModel):
|
||||
description="后端回填的火山 Asset ID。前端传入时不可信,创建任务时以后端查库为准",
|
||||
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):
|
||||
|
||||
@@ -4,35 +4,67 @@ from typing import Any
|
||||
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
enabled: bool
|
||||
image_limit: int
|
||||
used_image_count: int
|
||||
remaining_image_count: int
|
||||
enabled: bool = Field(..., description="是否启用私域人像素材库。asset_limit > 0 表示启用。")
|
||||
asset_limit: int = Field(..., description="私域人像素材总量限制:真人/虚拟共用,图片/视频共用,0 表示关闭。")
|
||||
used_asset_count: int = Field(..., description="当前占用额度的素材数量。统计 creating/Processing/Active 的 Image/Video。")
|
||||
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):
|
||||
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):
|
||||
name: str = Field(..., min_length=1, max_length=128)
|
||||
description: str | None = Field(None, max_length=2000)
|
||||
name: str = Field(..., min_length=1, max_length=128, description="项目组名称。")
|
||||
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):
|
||||
name: str | None = Field(None, min_length=1, max_length=128)
|
||||
description: str | None = Field(None, max_length=2000)
|
||||
status: str | None = None
|
||||
name: str | None = Field(None, min_length=1, max_length=128, description="项目组名称。")
|
||||
description: str | None = Field(None, max_length=2000, description="项目组描述。")
|
||||
status: str | None = Field(None, description="项目状态。普通前端不建议手动变更,仅管理/排查使用。")
|
||||
|
||||
|
||||
class PrivatePortraitProjectOut(BaseModel):
|
||||
id: str
|
||||
user_id: str | None = None
|
||||
library_type: str = Field(default=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
name: str
|
||||
name_slug: str | None = None
|
||||
remote_project_name: str | None = None
|
||||
@@ -40,7 +72,11 @@ class PrivatePortraitProjectOut(BaseModel):
|
||||
status: str
|
||||
asset_group_count: int = 0
|
||||
asset_count: int = 0
|
||||
image_asset_count: int = 0
|
||||
video_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
|
||||
created_at: NaiveDatetimeOptional = None
|
||||
updated_at: NaiveDatetimeOptional = None
|
||||
@@ -80,10 +116,17 @@ class PrivatePortraitValidateSessionOut(BaseModel):
|
||||
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):
|
||||
id: str
|
||||
user_id: str | None = None
|
||||
project_id: str
|
||||
library_type: str
|
||||
remote_group_id: str
|
||||
remote_group_name: str | None = None
|
||||
remote_project_name: str
|
||||
@@ -99,16 +142,22 @@ class PrivatePortraitAssetGroupOut(BaseModel):
|
||||
|
||||
|
||||
class PrivatePortraitAssetCreate(BaseModel):
|
||||
url: str = Field(..., min_length=1, description="已上传到本系统且可公网访问的素材 URL")
|
||||
asset_type: str = Field(default=PrivatePortraitAssetType.IMAGE.value)
|
||||
name: str | None = Field(None, max_length=256)
|
||||
url: str = Field(..., min_length=1, description="已上传到本系统且可公网访问的素材 URL。支持图片/视频,后端会转换公网地址后调用火山 CreateAsset。")
|
||||
asset_type: str = Field(default=PrivatePortraitAssetType.IMAGE.value, description="素材类型。当前业务仅开放 Image / Video,Audio 暂不开放。")
|
||||
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")
|
||||
@classmethod
|
||||
def validate_asset_type(cls, v: str) -> str:
|
||||
value = v or PrivatePortraitAssetType.IMAGE.value
|
||||
if value not in {item.value for item in PrivatePortraitAssetType}:
|
||||
raise ValueError("asset_type 仅支持 Image/Video/Audio")
|
||||
raise ValueError("asset_type 仅支持 Image/Video,Audio 暂未开放")
|
||||
if value not in PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES:
|
||||
raise ValueError("Audio 暂未开放,当前仅支持 Image/Video")
|
||||
return value
|
||||
|
||||
|
||||
@@ -118,6 +167,7 @@ class PrivatePortraitAssetOut(BaseModel):
|
||||
project_id: str
|
||||
project_name: str | None = None
|
||||
group_id: str
|
||||
library_type: str
|
||||
remote_group_id: str
|
||||
remote_asset_id: str | None = None
|
||||
remote_project_name: str | None = None
|
||||
@@ -125,10 +175,16 @@ class PrivatePortraitAssetOut(BaseModel):
|
||||
name: str | None = None
|
||||
source_url: str
|
||||
preview_url: str | None = None
|
||||
display_url: str | None = None
|
||||
provider_url: str | None = None
|
||||
remote_url: str | None = 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
|
||||
moderation: dict[str, Any] | None = None
|
||||
moderation: Any = None
|
||||
last_poll_at: NaiveDatetimeOptional = None
|
||||
next_poll_at: NaiveDatetimeOptional = None
|
||||
poll_count: int = 0
|
||||
@@ -153,10 +209,15 @@ class PrivatePortraitSelectableAssetOut(BaseModel):
|
||||
id: str
|
||||
project_id: str
|
||||
project_name: str
|
||||
library_type: str
|
||||
name: str | None = None
|
||||
asset_type: str
|
||||
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
|
||||
|
||||
|
||||
@@ -169,4 +230,32 @@ class PrivatePortraitSelectableAssetListOut(BaseModel):
|
||||
|
||||
class PrivatePortraitDeleteOut(BaseModel):
|
||||
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
|
||||
valid: bool
|
||||
already_in_team: bool = False
|
||||
has_pending_request: bool = False
|
||||
|
||||
@@ -15,7 +15,7 @@ class UserOut(BaseModel):
|
||||
allowed_menus: list | None = None
|
||||
must_set_password: bool = False
|
||||
resource_capacity: ResourceCapacityUsageOut | None = None
|
||||
private_portrait_image_limit: int = 5
|
||||
private_portrait_asset_limit: int = 5
|
||||
team_id: str | None = None
|
||||
team_name: str | None = None
|
||||
is_team_manager: bool = False
|
||||
|
||||
@@ -54,7 +54,7 @@ from app.services.generation_history_meta_service import (
|
||||
build_empty_history_meta,
|
||||
)
|
||||
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
|
||||
|
||||
IMAGE_DEFAULT_SIZE = "2K"
|
||||
@@ -88,6 +88,22 @@ def _parse_json(text: str | 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:
|
||||
query = select(ImageEngine).where(ImageEngine.is_active == True)
|
||||
if engine_id:
|
||||
@@ -462,8 +478,9 @@ def record_to_out(
|
||||
generated_resource_id: str | None = None,
|
||||
file_name: str | None = None,
|
||||
history_meta: GenerationHistoryMeta | None = None,
|
||||
media_references: list[dict] | None = None,
|
||||
) -> 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))
|
||||
|
||||
source = GenerationHistorySourceEnum.CHAT_TASK
|
||||
@@ -688,8 +705,9 @@ def generation_record_to_history_out(
|
||||
project_name: str | None = None,
|
||||
generated_resource_id: str | None = None,
|
||||
file_name: str | None = None,
|
||||
media_references: list[dict] | None = None,
|
||||
) -> GenerationAIRecordHistoryItemOut:
|
||||
refs = _parse_json(record.media_references)
|
||||
refs = media_references if media_references is not None else _parse_json(record.media_references)
|
||||
return GenerationAIRecordHistoryItemOut(
|
||||
id=record.id,
|
||||
source_type="generation_record",
|
||||
@@ -814,6 +832,8 @@ async def list_generation_record_history_grouped_days(
|
||||
source_ids=all_record_ids,
|
||||
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 = [
|
||||
{
|
||||
@@ -825,6 +845,7 @@ async def list_generation_record_history_grouped_days(
|
||||
project_name,
|
||||
generated_resource_id=resource_info_map.get(record.id, {}).get("resource_id"),
|
||||
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
|
||||
],
|
||||
@@ -887,6 +908,7 @@ async def list_generation_record_history_day_items(
|
||||
source_ids=[record.id for record, _project_name in rows],
|
||||
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 {
|
||||
"generated_date": target_day.strftime("%Y-%m-%d"),
|
||||
@@ -899,6 +921,7 @@ async def list_generation_record_history_day_items(
|
||||
project_name,
|
||||
generated_resource_id=resource_info_map.get(record.id, {}).get("resource_id"),
|
||||
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
|
||||
],
|
||||
@@ -989,6 +1012,8 @@ async def list_generation_history_grouped_days(
|
||||
source=source,
|
||||
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 = [
|
||||
{
|
||||
@@ -1000,6 +1025,7 @@ async def list_generation_history_grouped_days(
|
||||
generated_resource_id=resource_info_map.get(task.id, {}).get("resource_id"),
|
||||
file_name=resource_info_map.get(task.id, {}).get("file_name"),
|
||||
history_meta=history_meta_map.get(task.id),
|
||||
media_references=reference_display_map.get(task.id),
|
||||
)
|
||||
for task in tasks
|
||||
],
|
||||
@@ -1082,6 +1108,7 @@ async def list_generation_history_day_items(
|
||||
source=source,
|
||||
chat_task_ids=task_ids,
|
||||
)
|
||||
reference_display_map = await _resolve_task_reference_display_map(db, tasks, user_id=user_id)
|
||||
|
||||
return {
|
||||
"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"),
|
||||
file_name=resource_info_map.get(task.id, {}).get("file_name"),
|
||||
history_meta=history_meta_map.get(task.id),
|
||||
media_references=reference_display_map.get(task.id),
|
||||
)
|
||||
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:
|
||||
"""火山 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):
|
||||
self.ak = ak or settings.VOLC_SMS_ACCESS_KEY_ID
|
||||
self.sk = sk or settings.VOLC_SMS_SECRET_ACCESS_KEY
|
||||
def __init__(self, *, for_celery: bool = False):
|
||||
self.ak = settings.VOLC_ACCESS_KEY_ID
|
||||
self.sk = settings.VOLC_SECRET_ACCESS_KEY
|
||||
self.for_celery = for_celery
|
||||
if not self.ak or not self.sk:
|
||||
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]:
|
||||
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]:
|
||||
payload: dict[str, Any] = {"GroupId": group_id, "URL": url, "AssetType": asset_type, "ProjectName": project_name}
|
||||
if name:
|
||||
|
||||
@@ -6,32 +6,42 @@ from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import and_, func, select, update
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS,
|
||||
PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT,
|
||||
PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT,
|
||||
PRIVATE_PORTRAIT_GROUP_TYPE,
|
||||
PRIVATE_PORTRAIT_ASSET_URI_PREFIX,
|
||||
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
|
||||
PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE,
|
||||
PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE,
|
||||
PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES,
|
||||
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_INTERVAL_SECONDS,
|
||||
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_MAX_COUNT,
|
||||
PrivatePortraitAssetGroupStatus,
|
||||
PrivatePortraitAssetStatus,
|
||||
PrivatePortraitAssetType,
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
PrivatePortraitEventType,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitProjectStatus,
|
||||
PrivatePortraitRemoteDeleteStatus,
|
||||
PrivatePortraitValidateSessionStatus,
|
||||
)
|
||||
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject, PrivatePortraitValidateSession
|
||||
from app.models.user import User
|
||||
from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitAssetOut, PrivatePortraitConfigOut, PrivatePortraitSelectableAssetOut, PrivatePortraitValidateSessionOut
|
||||
from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitAssetOut, PrivatePortraitSelectableAssetOut, PrivatePortraitValidateSessionOut
|
||||
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.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
|
||||
|
||||
DOMAIN = "private_portrait"
|
||||
@@ -52,7 +62,6 @@ def _loads(data: str | None) -> Any:
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def _exception_message(exc: Exception) -> str:
|
||||
if isinstance(exc, HTTPException):
|
||||
detail = exc.detail
|
||||
@@ -64,7 +73,7 @@ def _exception_message(exc: Exception) -> 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 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]
|
||||
|
||||
|
||||
async def get_user_private_portrait_config(db: AsyncSession, *, user_id: str) -> PrivatePortraitConfigOut:
|
||||
user = (await db.execute(select(User).where(User.id == user_id).limit(1))).scalar_one_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)
|
||||
def _asset_display_url(asset: PrivatePortraitAsset) -> str | None:
|
||||
return asset.preview_url or asset.remote_url or asset.source_url or None
|
||||
|
||||
|
||||
async def set_user_private_portrait_limit(db: AsyncSession, *, user_id: str, limit: int) -> User:
|
||||
user = (await db.execute(select(User).where(User.id == user_id).limit(1))).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
user.private_portrait_image_limit = max(0, int(limit))
|
||||
await db.flush()
|
||||
return user
|
||||
def _provider_url(asset: PrivatePortraitAsset) -> str | None:
|
||||
return f"{PRIVATE_PORTRAIT_ASSET_URI_PREFIX}{asset.remote_asset_id}" if asset.remote_asset_id else None
|
||||
|
||||
|
||||
async def count_user_counting_image_assets(db: AsyncSession, *, user_id: str) -> int:
|
||||
statuses = [PrivatePortraitAssetStatus.CREATING.value, PrivatePortraitAssetStatus.PROCESSING.value, PrivatePortraitAssetStatus.ACTIVE.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 int(total or 0)
|
||||
def _poll_interval_seconds(asset_type: str) -> int:
|
||||
if asset_type == PrivatePortraitAssetType.VIDEO.value:
|
||||
return PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_INTERVAL_SECONDS
|
||||
return PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS
|
||||
|
||||
|
||||
async def _lock_user_for_upload(db: AsyncSession, *, user_id: str) -> User:
|
||||
# 锁 users 行,避免并发绕过用户总量限制。SQLite 会忽略 FOR UPDATE,不影响本地开发。
|
||||
user = (await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1))).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return user
|
||||
def _poll_max_count(asset_type: str) -> int:
|
||||
if asset_type == PrivatePortraitAssetType.VIDEO.value:
|
||||
return PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_MAX_COUNT
|
||||
return PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT
|
||||
|
||||
|
||||
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/Video,Audio 暂未开放")
|
||||
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:
|
||||
@@ -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:
|
||||
display_url = _asset_display_url(asset)
|
||||
return PrivatePortraitAssetOut(
|
||||
id=asset.id,
|
||||
user_id=asset.user_id if include_user else None,
|
||||
project_id=asset.project_id,
|
||||
project_name=project_name,
|
||||
group_id=asset.group_id,
|
||||
library_type=asset.library_type,
|
||||
remote_group_id=asset.remote_group_id,
|
||||
remote_asset_id=asset.remote_asset_id,
|
||||
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,
|
||||
source_url=asset.source_url,
|
||||
preview_url=asset.preview_url,
|
||||
display_url=display_url,
|
||||
provider_url=_provider_url(asset),
|
||||
remote_url=asset.remote_url,
|
||||
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,
|
||||
moderation=_loads(asset.moderation_json),
|
||||
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:
|
||||
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(
|
||||
id=generate_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.raw_response_json = _json(resp)
|
||||
await db.flush()
|
||||
# created_at / updated_at 来自数据库默认值或 onupdate,flush 后可能处于 expired 状态。
|
||||
# 在 async SQLAlchemy 下,响应转换时同步读取 expired 字段会触发 MissingGreenlet。
|
||||
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
|
||||
except Exception as exc:
|
||||
session.status = PrivatePortraitValidateSessionStatus.FAILED.value
|
||||
session.error_message = _exception_message(exc)
|
||||
project.status = PrivatePortraitProjectStatus.VALIDATE_FAILED.value
|
||||
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)
|
||||
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:
|
||||
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.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
|
||||
@@ -218,9 +293,13 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
|
||||
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})
|
||||
|
||||
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:
|
||||
session.status = PrivatePortraitValidateSessionStatus.CALLBACK_FAILED.value
|
||||
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()
|
||||
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
|
||||
@@ -229,10 +308,22 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
|
||||
if not session.byted_token:
|
||||
session.status = PrivatePortraitValidateSessionStatus.FAILED.value
|
||||
session.error_message = "Callback 未返回 BytedToken"
|
||||
if project and project.status != PrivatePortraitProjectStatus.ACTIVE.value:
|
||||
project.status = PrivatePortraitProjectStatus.VALIDATE_FAILED.value
|
||||
await db.flush()
|
||||
raise HTTPException(status_code=400, detail=session.error_message)
|
||||
|
||||
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})
|
||||
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")
|
||||
@@ -242,20 +333,23 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
|
||||
session.status = PrivatePortraitValidateSessionStatus.GROUP_ACTIVE.value
|
||||
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)
|
||||
group = PrivatePortraitAssetGroup(
|
||||
id=generate_id(),
|
||||
user_id=session.user_id,
|
||||
project_id=session.project_id,
|
||||
library_type=PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||
remote_group_id=group_id,
|
||||
remote_group_name=remote_group_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,
|
||||
raw_response_json=_json(resp),
|
||||
)
|
||||
db.add(group)
|
||||
project.status = PrivatePortraitProjectStatus.ACTIVE.value
|
||||
await db.flush()
|
||||
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)
|
||||
@@ -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 db.flush()
|
||||
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
|
||||
except Exception as exc:
|
||||
session.status = PrivatePortraitValidateSessionStatus.FAILED.value
|
||||
session.error_message = _exception_message(exc)
|
||||
if project and project.status != PrivatePortraitProjectStatus.ACTIVE.value:
|
||||
project.status = PrivatePortraitProjectStatus.VALIDATE_FAILED.value
|
||||
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)
|
||||
raise
|
||||
|
||||
|
||||
async def get_project_active_group(db: AsyncSession, *, user_id: str, project_id: str) -> 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))
|
||||
async def get_project_active_group(db: AsyncSession, *, user_id: str, project_id: str, library_type: str | None = None) -> PrivatePortraitAssetGroup:
|
||||
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()
|
||||
if not group:
|
||||
raise HTTPException(status_code=400, detail="请先完成真人授权认证,再上传素材")
|
||||
raise HTTPException(status_code=400, detail="项目没有可用的远程素材组")
|
||||
return group
|
||||
|
||||
|
||||
async def create_asset(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitAssetCreate) -> PrivatePortraitAsset:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id)
|
||||
if payload.asset_type != PrivatePortraitAssetType.IMAGE.value:
|
||||
raise HTTPException(status_code=400, detail="第一版真人素材库仅开放 Image 图片素材")
|
||||
user = await _lock_user_for_upload(db, user_id=user_id)
|
||||
limit = int(getattr(user, "private_portrait_image_limit", PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT) or 0)
|
||||
if limit <= 0:
|
||||
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="用户真人素材模块未启用")
|
||||
raise HTTPException(status_code=403, detail="真人素材库未启用")
|
||||
current_count = await count_user_counting_image_assets(db, user_id=user_id)
|
||||
if current_count >= limit:
|
||||
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=f"你的真人素材库最多可上传 {limit} 张图片,请删除已有素材后再上传")
|
||||
async def create_asset(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
payload: PrivatePortraitAssetCreate,
|
||||
library_type: str | None = None,
|
||||
) -> PrivatePortraitAsset:
|
||||
_assert_enabled_asset_type(payload.asset_type)
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
|
||||
if project.status != PrivatePortraitProjectStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail="项目未激活,不能上传素材")
|
||||
|
||||
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)
|
||||
asset = PrivatePortraitAsset(
|
||||
id=generate_id(),
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
group_id=group.id,
|
||||
library_type=project.library_type,
|
||||
remote_group_id=group.remote_group_id,
|
||||
remote_project_name=project.remote_project_name,
|
||||
asset_type=payload.asset_type,
|
||||
name=payload.name,
|
||||
source_url=public_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,
|
||||
)
|
||||
db.add(asset)
|
||||
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:
|
||||
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")
|
||||
@@ -322,14 +438,12 @@ async def create_asset(db: AsyncSession, *, user_id: str, project_id: str, paylo
|
||||
now = datetime.now(timezone.utc)
|
||||
asset.remote_asset_id = remote_asset_id
|
||||
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)
|
||||
await refresh_project_counters(db, [project.id])
|
||||
await db.flush()
|
||||
# created_at / updated_at 来自数据库默认值或 onupdate,flush 后可能处于 expired 状态。
|
||||
# 在 async SQLAlchemy 下,响应转换时同步读取 expired 字段会触发 MissingGreenlet。
|
||||
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
|
||||
except Exception as exc:
|
||||
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)
|
||||
asset = (await db.execute(select(PrivatePortraitAsset).where(*filters).limit(1))).scalar_one_or_none()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="真人素材不存在")
|
||||
raise HTTPException(status_code=404, detail="私域人像素材不存在")
|
||||
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:
|
||||
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
|
||||
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,
|
||||
project_id=asset.project_id,
|
||||
asset_id=asset.id,
|
||||
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,
|
||||
},
|
||||
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},
|
||||
)
|
||||
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)
|
||||
@@ -378,24 +487,15 @@ async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id:
|
||||
asset.status = status
|
||||
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"))
|
||||
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.error_message = "素材入库轮询超时"
|
||||
asset.next_poll_at = None
|
||||
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": PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT, "remote_asset_id": asset.remote_asset_id},
|
||||
error=asset.error_message,
|
||||
)
|
||||
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)
|
||||
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:
|
||||
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 db.flush()
|
||||
await db.refresh(asset)
|
||||
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},
|
||||
)
|
||||
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})
|
||||
return asset
|
||||
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)
|
||||
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_size = min(max(1, page_size), 100)
|
||||
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)
|
||||
if 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:
|
||||
filters.append(PrivatePortraitAsset.status == status)
|
||||
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
|
||||
|
||||
|
||||
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]:
|
||||
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)
|
||||
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
|
||||
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, 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, ""),
|
||||
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:
|
||||
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()
|
||||
async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str, library_type: str | None = None) -> PrivatePortraitAsset:
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="真人素材不存在")
|
||||
raise HTTPException(status_code=404, detail="私域人像素材不存在")
|
||||
now = datetime.now(timezone.utc)
|
||||
asset.deleted_at = now
|
||||
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 db.flush()
|
||||
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
|
||||
|
||||
|
||||
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()
|
||||
if not asset:
|
||||
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="远程删除跳过:本地素材不存在",
|
||||
)
|
||||
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="远程删除跳过:本地素材不存在")
|
||||
return
|
||||
if not asset.remote_asset_id:
|
||||
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value
|
||||
asset.remote_delete_error = None
|
||||
await db.flush()
|
||||
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",
|
||||
)
|
||||
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")
|
||||
return
|
||||
now = datetime.now(timezone.utc)
|
||||
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},
|
||||
)
|
||||
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})
|
||||
try:
|
||||
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.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
|
||||
asset.remote_deleted_at = now
|
||||
asset.remote_delete_error = None
|
||||
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},
|
||||
)
|
||||
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})
|
||||
except Exception as exc:
|
||||
asset.status = PrivatePortraitAssetStatus.DELETE_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_error = None
|
||||
await db.flush()
|
||||
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",
|
||||
)
|
||||
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")
|
||||
return
|
||||
client = client or ArkPrivateAssetClient(for_celery=True)
|
||||
now = datetime.now(timezone.utc)
|
||||
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},
|
||||
)
|
||||
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})
|
||||
try:
|
||||
await client.delete_asset_group(project_name=group.remote_project_name, group_id=group.remote_group_id)
|
||||
group.status = PrivatePortraitAssetGroupStatus.REMOTE_DELETED.value
|
||||
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
|
||||
group.remote_deleted_at = now
|
||||
group.remote_delete_error = None
|
||||
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},
|
||||
)
|
||||
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})
|
||||
except Exception as exc:
|
||||
group.status = PrivatePortraitAssetGroupStatus.DELETE_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:
|
||||
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="开始远程删除真人素材项目资源",
|
||||
)
|
||||
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="开始远程删除私域人像素材项目资源")
|
||||
rows = await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.project_id == project_id))
|
||||
for asset in rows.scalars().all():
|
||||
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)
|
||||
for group in groups.scalars().all():
|
||||
await _delete_asset_group_remote(db, group=group, client=client)
|
||||
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="远程删除真人素材项目资源完成",
|
||||
)
|
||||
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="远程删除私域人像素材项目资源完成")
|
||||
await db.flush()
|
||||
|
||||
|
||||
@@ -618,13 +669,7 @@ async def poll_due_assets_once(db: AsyncSession, *, limit: int) -> int:
|
||||
.limit(limit)
|
||||
)
|
||||
ids = [row[0] for row in rows.all()]
|
||||
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)},
|
||||
)
|
||||
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)})
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
for asset_id in ids:
|
||||
@@ -633,38 +678,15 @@ async def poll_due_assets_once(db: AsyncSession, *, limit: int) -> int:
|
||||
success_count += 1
|
||||
except Exception as exc:
|
||||
failed_count += 1
|
||||
log_operation_error(
|
||||
domain=DOMAIN,
|
||||
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},
|
||||
)
|
||||
log_operation_error(domain=DOMAIN, 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)
|
||||
|
||||
|
||||
async def recover_remote_deletes_once(db: AsyncSession, *, limit: int) -> dict[str, int]:
|
||||
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},
|
||||
)
|
||||
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})
|
||||
statuses = [PrivatePortraitRemoteDeleteStatus.PENDING.value, PrivatePortraitRemoteDeleteStatus.FAILED.value]
|
||||
asset_rows = await db.execute(
|
||||
select(PrivatePortraitAsset.id)
|
||||
.where(PrivatePortraitAsset.remote_delete_status.in_(statuses))
|
||||
.order_by(PrivatePortraitAsset.updated_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
asset_rows = await db.execute(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()]
|
||||
for asset_id in asset_ids:
|
||||
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))
|
||||
group_count = 0
|
||||
if remaining > 0:
|
||||
group_rows = await db.execute(
|
||||
select(PrivatePortraitAssetGroup)
|
||||
.where(PrivatePortraitAssetGroup.remote_delete_status.in_(statuses))
|
||||
.order_by(PrivatePortraitAssetGroup.updated_at.asc())
|
||||
.limit(remaining)
|
||||
)
|
||||
group_rows = await db.execute(select(PrivatePortraitAssetGroup).where(PrivatePortraitAssetGroup.remote_delete_status.in_(statuses)).order_by(PrivatePortraitAssetGroup.updated_at.asc()).limit(remaining))
|
||||
client = ArkPrivateAssetClient(for_celery=True)
|
||||
groups = list(group_rows.scalars().all())
|
||||
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)
|
||||
|
||||
result = {"asset_count": len(asset_ids), "group_count": group_count, "total_count": len(asset_ids) + group_count}
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_DONE.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
detail=result,
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_DONE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CELERY.value, detail=result)
|
||||
return result
|
||||
|
||||
|
||||
@@ -11,13 +11,15 @@ from app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
|
||||
PrivatePortraitAssetGroupStatus,
|
||||
PrivatePortraitAssetStatus,
|
||||
PrivatePortraitAssetType,
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
PrivatePortraitEventType,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitProjectStatus,
|
||||
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.services.operation_log_service import log_operation_event
|
||||
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:
|
||||
value = (value or "").strip().lower()
|
||||
# 先保留常见英文数字连字符;中文等字符统一转 _,仅用于本地项目 slug。
|
||||
value = re.sub(r"[^a-z0-9_-]+", "_", value)
|
||||
value = re.sub(r"_+", "_", value).strip("_-")
|
||||
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:
|
||||
return PrivatePortraitProjectOut(
|
||||
id=project.id,
|
||||
user_id=project.user_id if include_user else None,
|
||||
library_type=project.library_type,
|
||||
name=project.name,
|
||||
name_slug=project.name_slug,
|
||||
remote_project_name=project.remote_project_name,
|
||||
@@ -44,72 +54,100 @@ def project_to_out(project: PrivatePortraitProject, *, include_user: bool = Fals
|
||||
status=project.status,
|
||||
asset_group_count=project.asset_group_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_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,
|
||||
created_at=project.created_at,
|
||||
updated_at=project.updated_at,
|
||||
)
|
||||
|
||||
|
||||
async def get_user_project(db: AsyncSession, *, user_id: str, project_id: str) -> PrivatePortraitProject:
|
||||
result = await db.execute(
|
||||
select(PrivatePortraitProject).where(
|
||||
PrivatePortraitProject.id == project_id,
|
||||
PrivatePortraitProject.user_id == user_id,
|
||||
PrivatePortraitProject.deleted_at.is_(None),
|
||||
).limit(1)
|
||||
)
|
||||
async def get_user_project(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
library_type: str | None = None,
|
||||
) -> 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()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="真人素材项目不存在")
|
||||
raise HTTPException(status_code=404, detail="私域人像素材项目不存在")
|
||||
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)
|
||||
project = PrivatePortraitProject(
|
||||
id=generate_id(),
|
||||
user_id=user_id,
|
||||
library_type=library_type,
|
||||
name=payload.name.strip(),
|
||||
name_slug=slug,
|
||||
remote_project_name=PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
|
||||
remote_project_name=remote_project_name,
|
||||
description=payload.description,
|
||||
status=PrivatePortraitProjectStatus.ACTIVE.value,
|
||||
status=status or _status_for_created_project(library_type),
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.PROJECT_CREATE.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
message="创建真人素材项目",
|
||||
detail={"name": project.name, "remote_project_name": project.remote_project_name},
|
||||
message="创建私域人像素材项目",
|
||||
detail={"name": project.name, "library_type": library_type, "remote_project_name": project.remote_project_name, "status": project.status},
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
async def update_project(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitProjectUpdate) -> PrivatePortraitProject:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id)
|
||||
async def update_project(
|
||||
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 = {
|
||||
"name": project.name,
|
||||
"name_slug": project.name_slug,
|
||||
"remote_project_name": project.remote_project_name,
|
||||
"description": project.description,
|
||||
"status": project.status,
|
||||
"library_type": project.library_type,
|
||||
}
|
||||
if payload.name is not None:
|
||||
new_name = payload.name.strip()
|
||||
if new_name and new_name != project.name:
|
||||
project.name = new_name
|
||||
project.name_slug = _safe_slug(new_name)
|
||||
project.remote_project_name = PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME
|
||||
if payload.description is not None:
|
||||
project.description = payload.description
|
||||
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="项目状态不支持")
|
||||
project.status = payload.status
|
||||
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,
|
||||
"description": project.description,
|
||||
"status": project.status,
|
||||
"library_type": project.library_type,
|
||||
}
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
@@ -127,24 +166,41 @@ async def update_project(db: AsyncSession, *, user_id: str, project_id: str, pay
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
message="更新真人素材项目",
|
||||
message="更新私域人像素材项目",
|
||||
detail={"before": before, "after": after},
|
||||
)
|
||||
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_size = min(max(1, page_size), 100)
|
||||
filters = [PrivatePortraitProject.deleted_at.is_(None)]
|
||||
if user_id:
|
||||
filters.append(PrivatePortraitProject.user_id == user_id)
|
||||
if library_type:
|
||||
filters.append(PrivatePortraitProject.library_type == library_type)
|
||||
if keyword:
|
||||
filters.append(PrivatePortraitProject.name.ilike(f"%{keyword.strip()}%"))
|
||||
if status:
|
||||
filters.append(PrivatePortraitProject.status == status)
|
||||
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)
|
||||
|
||||
|
||||
@@ -161,27 +217,74 @@ async def refresh_project_counters(db: AsyncSession, project_ids: list[str]) ->
|
||||
select(
|
||||
PrivatePortraitAsset.project_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) & (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))
|
||||
.group_by(PrivatePortraitAsset.project_id)
|
||||
)
|
||||
group_count_map = {pid: int(count or 0) for pid, count in group_rows.all()}
|
||||
asset_count_map: dict[str, tuple[int, int]] = {}
|
||||
for pid, total, active_total in asset_rows.all():
|
||||
asset_count_map[pid] = (int(total or 0), int(active_total or 0))
|
||||
asset_count_map: dict[str, tuple[int, int, int, int, int, int]] = {}
|
||||
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(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:
|
||||
total, active_total = asset_count_map.get(pid, (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))
|
||||
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,
|
||||
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:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id)
|
||||
async def soft_delete_project(
|
||||
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)
|
||||
project.deleted_at = now
|
||||
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(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.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(
|
||||
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()
|
||||
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
|
||||
|
||||
@@ -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 fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_ASSET_URI_PREFIX,
|
||||
PrivatePortraitAssetStatus,
|
||||
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
|
||||
PrivatePortraitAssetType,
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
@@ -25,7 +26,6 @@ DOMAIN = "private_portrait"
|
||||
_ASSET_TYPE_TO_REFERENCE_TYPE = {
|
||||
PrivatePortraitAssetType.IMAGE.value: "image",
|
||||
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()
|
||||
|
||||
|
||||
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 返回的远程资源 URL;source_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(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -104,29 +260,37 @@ async def resolve_private_portrait_references(
|
||||
|
||||
asset_id = str(_ref_get(ref, "private_asset_id") or "")
|
||||
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)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=400, detail="真人素材不存在")
|
||||
raise HTTPException(status_code=400, detail="私域人像素材不存在")
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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)
|
||||
ref_type = _normalize_ref_type(_ref_get(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, "private_asset_id", 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:
|
||||
_ref_set(ref, "type", expected_ref_type)
|
||||
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 datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
@@ -46,6 +46,8 @@ async def create_invitation(
|
||||
"""创建邀请码(仅团队管理人)。"""
|
||||
await _assert_is_manager(db, created_by, team_id)
|
||||
code = _generate_invite_code()
|
||||
if expires_at is None:
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=1)
|
||||
invitation = TeamInvitation(
|
||||
id=generate_id(),
|
||||
team_id=team_id,
|
||||
@@ -107,7 +109,7 @@ async def create_join_request(
|
||||
|
||||
# 验证用户存在
|
||||
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()
|
||||
if not user:
|
||||
|
||||
+97
-96
File diff suppressed because one or more lines are too long
Vendored
+36
-36
@@ -1,37 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<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.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>民众智创</title>
|
||||
<script>
|
||||
(function() {
|
||||
var cached = localStorage.getItem('siteInfo');
|
||||
if (cached) {
|
||||
try {
|
||||
var info = JSON.parse(cached);
|
||||
if (info.siteName) {
|
||||
document.title = info.siteName;
|
||||
}
|
||||
if (info.siteLogo) {
|
||||
var link = document.querySelector('link[rel="icon"]');
|
||||
if (link) {
|
||||
link.href = info.siteLogo;
|
||||
link.type = 'image/png';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-DzBPiPEO.js"></script>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<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.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>民众智创</title>
|
||||
<script>
|
||||
(function() {
|
||||
var cached = localStorage.getItem('siteInfo');
|
||||
if (cached) {
|
||||
try {
|
||||
var info = JSON.parse(cached);
|
||||
if (info.siteName) {
|
||||
document.title = info.siteName;
|
||||
}
|
||||
if (info.siteLogo) {
|
||||
var link = document.querySelector('link[rel="icon"]');
|
||||
if (link) {
|
||||
link.href = info.siteLogo;
|
||||
link.type = 'image/png';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-DjHXCPu7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>␍
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -34,6 +34,8 @@ import PopularPage from './pages/PopularPage';
|
||||
import CreativePlazaPage from './pages/CreativePlazaPage';
|
||||
import TeamManagementPage from './pages/TeamManagementPage';
|
||||
import JoinTeamPage from './pages/JoinTeamPage';
|
||||
import PrivatePortraitAuthorizeResult from './pages/PrivatePortraitAuthorizeResult';
|
||||
import PrivatePortraitVirtualMaterialPage from './pages/PrivatePortraitVirtualMaterialPage';
|
||||
import { useAuthStore } from './store/useAuthStore';
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { user, loading, checkAuth } = useAuthStore();
|
||||
@@ -92,6 +94,7 @@ const App = () => {
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/join-team" element={<JoinTeamPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
@@ -122,12 +125,12 @@ const App = () => {
|
||||
<Route path="authorization" element={<AuthorizationPage />} />
|
||||
<Route path="authoriza-waiting" element={<AuthorizationWaitingPage />} />
|
||||
<Route path="materials" element={<MaterialListPage />} />
|
||||
<Route path="materials/private-portrait-virtual" element={<PrivatePortraitVirtualMaterialPage />} />
|
||||
<Route path="consume" element={<ConsumePage />} />
|
||||
<Route path="popular" element={<PopularPage />} />
|
||||
<Route path="authacc" element={<AuthAccountPage />} />
|
||||
<Route path="creativeplaza" element={<CreativePlazaPage />} />
|
||||
<Route path="team-management" element={<TeamManagementPage />} />
|
||||
<Route path="join-team" element={<JoinTeamPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/projects" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -55,7 +55,7 @@ async function tryDecrypt(data: string): Promise<string | null> {
|
||||
}
|
||||
|
||||
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> = {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
User, CreditRecord, Project, GenerationRecord, OptimizeParams, GenerateParams, OptimizeResult,
|
||||
Industry, IndustryConfig, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification,
|
||||
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitProject, PrivatePortraitValidateSession,
|
||||
PrivatePortraitAssetListOut, PrivatePortraitAsset, PrivatePortraitSelectableAssetListOut,
|
||||
PrivatePortraitProjectCreateWithValidateOut, PrivatePortraitAssetListOut, PrivatePortraitAsset, PrivatePortraitSelectableAssetListOut,
|
||||
} from '../types';
|
||||
const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true';
|
||||
// ── Auth ──────────────────────────────────────────────────
|
||||
@@ -756,8 +756,12 @@ export async function getPrivatePortraitProjects(params: { page?: number; pageSi
|
||||
return api.get<PrivatePortraitProjectListOut>(`/private-portrait/projects?${query.toString()}`);
|
||||
}
|
||||
|
||||
export async function createPrivatePortraitProject(payload: { name: string; description?: string | null }): Promise<PrivatePortraitProject> {
|
||||
return api.post<PrivatePortraitProject>('/private-portrait/projects', payload);
|
||||
export async function createPrivatePortraitProject(payload: { name: string; description?: string | null; callbackRedirectUrl?: string | null }): Promise<PrivatePortraitProjectCreateWithValidateOut> {
|
||||
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> {
|
||||
@@ -776,16 +780,25 @@ export async function getPrivatePortraitValidateSession(sessionId: string): Prom
|
||||
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> {
|
||||
return api.post<PrivatePortraitAsset>(`/private-portrait/projects/${projectId}/assets`, { url: payload.url, asset_type: payload.assetType || 'Image', name: payload.name || null });
|
||||
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,
|
||||
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();
|
||||
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/projects/${projectId}/assets?${query.toString()}`);
|
||||
}
|
||||
|
||||
@@ -797,15 +810,90 @@ export async function deletePrivatePortraitAsset(assetId: string): Promise<void>
|
||||
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();
|
||||
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/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 ──────────────────────────────
|
||||
export async function getManagedTeam(): Promise<any> {
|
||||
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> {
|
||||
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> {
|
||||
|
||||
@@ -1,22 +1,47 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Card, Col, Form, Input, Modal, Row, Space, Typography, message } from 'antd';
|
||||
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import type { PrivatePortraitProject } from '../../../types';
|
||||
import { createPrivatePortraitProject, getPrivatePortraitProjects } from '../../../api';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Card, Col, Form, Input, Modal, QRCode, Row, Space, Spin, Typography, message } from 'antd';
|
||||
import { CheckCircleOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import type { PrivatePortraitProject, PrivatePortraitValidateSession } from '../../../types';
|
||||
import { createPrivatePortraitProject, getPrivatePortraitProjects, getPrivatePortraitValidateSession } from '../../../api';
|
||||
import PrivatePortraitProjectList from './ProjectList';
|
||||
import PrivatePortraitProjectDetail from './ProjectDetail';
|
||||
|
||||
const VALIDATE_SUCCESS_STATUS = 'group_active';
|
||||
const POLL_INTERVAL_FALLBACK = 2000;
|
||||
|
||||
const PrivatePortraitLibraryPanel: React.FC = () => {
|
||||
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
|
||||
const [selected, setSelected] = useState<PrivatePortraitProject | null>(null);
|
||||
const [loading, setLoading] = 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 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 () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getPrivatePortraitProjects({ pageSize: 100 });
|
||||
const res = await getPrivatePortraitProjects({ pageSize: 100, status: 'active' });
|
||||
setProjects(res.items);
|
||||
setSelected((prev) => prev ? (res.items.find((item) => item.id === prev.id) || res.items[0] || null) : (res.items[0] || null));
|
||||
} catch (e: any) {
|
||||
@@ -27,27 +52,75 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
|
||||
};
|
||||
|
||||
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 values = await form.validateFields();
|
||||
setCreating(true);
|
||||
try {
|
||||
const project = await createPrivatePortraitProject(values);
|
||||
message.success('项目组已创建');
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
await loadProjects();
|
||||
setSelected(project);
|
||||
const callbackRedirectUrl = `${window.location.origin}/private-portrait-authorized`;
|
||||
const res = await createPrivatePortraitProject({ ...values, callbackRedirectUrl });
|
||||
setCreatedProject(res.project);
|
||||
setValidateSession(res.validateSession);
|
||||
message.success('请使用手机扫码完成人脸认证');
|
||||
if (res.validateSession?.id) {
|
||||
startPolling(res.validateSession.id, res.project.id, res.pollIntervalMs || POLL_INTERVAL_FALLBACK);
|
||||
}
|
||||
} 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 (
|
||||
<div style={{ padding: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>真人素材库</Typography.Title>
|
||||
<Typography.Text type="secondary">管理真人授权项目组和已入库 Active 素材,AI 创作添加参考内容时可直接选择。</Typography.Text>
|
||||
<Typography.Text type="secondary">创建项目组时先完成真人认证,认证成功后项目组才会正式创建并可上传素材。</Typography.Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={loadProjects} loading={loading}>刷新</Button>
|
||||
@@ -68,15 +141,46 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
<Modal title="新建真人素材项目组" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={handleCreate} okText="创建">
|
||||
<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>
|
||||
</Form>
|
||||
<Modal
|
||||
title="新建真人素材项目组"
|
||||
open={createOpen}
|
||||
onCancel={resetCreateModal}
|
||||
footer={validateSession ? [<Button key="close" onClick={resetCreateModal}>关闭</Button>] : undefined}
|
||||
onOk={validateSession ? undefined : handleCreate}
|
||||
okText="开始认证并创建"
|
||||
confirmLoading={creating}
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Card, Popconfirm, Space, Typography, message } from 'antd';
|
||||
import { DeleteOutlined, ReloadOutlined, SafetyCertificateOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import { Button, Card, Popconfirm, Space, Tag, Typography, message } from 'antd';
|
||||
import { DeleteOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../types';
|
||||
import { deletePrivatePortraitAsset, deletePrivatePortraitProject, getPrivatePortraitAssets, syncPrivatePortraitAsset } from '../../../api';
|
||||
import PrivatePortraitAssetGrid from './AssetGrid';
|
||||
import PrivatePortraitAssetUpload from './AssetUpload';
|
||||
import PrivatePortraitValidateModal from './ValidateModal';
|
||||
|
||||
interface Props {
|
||||
project: PrivatePortraitProject;
|
||||
@@ -17,7 +16,6 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
|
||||
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [validateOpen, setValidateOpen] = useState(false);
|
||||
|
||||
const loadAssets = async () => {
|
||||
setLoading(true);
|
||||
@@ -65,13 +63,14 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
|
||||
}
|
||||
};
|
||||
|
||||
const canUpload = project.status === 'active';
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={<span>{project.name}</span>}
|
||||
title={<Space><span>{project.name}</span><Tag color={canUpload ? 'green' : 'processing'}>{project.status}</Tag></Space>}
|
||||
extra={(
|
||||
<Space>
|
||||
<Button icon={<SafetyCertificateOutlined />} onClick={() => setValidateOpen(true)}>真人授权</Button>
|
||||
<Button type="primary" icon={<UploadOutlined />} onClick={() => setUploadOpen(true)}>上传素材</Button>
|
||||
<Button type="primary" icon={<UploadOutlined />} disabled={!canUpload} onClick={() => setUploadOpen(true)}>上传素材</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={loadAssets} loading={loading}>刷新</Button>
|
||||
<Popconfirm title="确认删除这个真人素材项目组吗?" onConfirm={handleDeleteProject}>
|
||||
<Button danger icon={<DeleteOutlined />}>删除项目组</Button>
|
||||
@@ -81,9 +80,13 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
|
||||
style={{ borderRadius: 12 }}
|
||||
>
|
||||
<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} />
|
||||
<PrivatePortraitAssetUpload projectId={project.id} open={uploadOpen} onClose={() => setUploadOpen(false)} onSuccess={() => { loadAssets(); onChanged(); }} />
|
||||
<PrivatePortraitValidateModal projectId={project.id} open={validateOpen} onClose={() => setValidateOpen(false)} onCreated={() => onChanged()} />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,54 +1,77 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Card, Result, Spin, Typography, Modal, message } from 'antd';
|
||||
import { CheckCircleOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
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 { getJoinTeamInfo, submitJoinRequest } from '../api';
|
||||
import { getJoinTeamInfo, getJoinTeamInfoPublic, submitJoinRequest } from '../api';
|
||||
import type { JoinTeamInfo } from '../types';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
|
||||
const JoinTeamPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
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 [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!code) {
|
||||
setLoading(false);
|
||||
setInfoLoading(false);
|
||||
return;
|
||||
}
|
||||
getJoinTeamInfo(code)
|
||||
.then((data) => setInfo(data))
|
||||
if (authLoading) return;
|
||||
|
||||
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))
|
||||
.finally(() => setLoading(false));
|
||||
}, [code]);
|
||||
.finally(() => setInfoLoading(false));
|
||||
}, [code, user, authLoading]);
|
||||
|
||||
const handleJoin = async () => {
|
||||
if (!code) return;
|
||||
Modal.confirm({
|
||||
title: '确认加入团队',
|
||||
icon: <TeamOutlined style={{ color: '#6366f1' }} />,
|
||||
content: info?.teamName ? `您确定要加入团队「${info.teamName}」吗?提交后需等待团队管理人审批。` : '您确定要加入该团队吗?',
|
||||
okText: '确认加入',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await submitJoinRequest(code);
|
||||
setSubmitted(true);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '申请失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await submitJoinRequest(code);
|
||||
setSubmitted(true);
|
||||
setConfirmModalOpen(false);
|
||||
message.success('申请已提交');
|
||||
} 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 (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
|
||||
<Spin size="large" />
|
||||
@@ -64,7 +87,14 @@ const JoinTeamPage: React.FC = () => {
|
||||
icon={<CheckCircleOutlined style={{ color: '#6366f1' }} />}
|
||||
title="申请已提交"
|
||||
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>
|
||||
);
|
||||
@@ -90,31 +120,113 @@ const JoinTeamPage: React.FC = () => {
|
||||
status="info"
|
||||
title="您已在此团队中"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 16, maxWidth: 480, width: '100%', textAlign: 'center' }}>
|
||||
<TeamOutlined style={{ fontSize: 48, color: '#6366f1', marginBottom: 16 }} />
|
||||
<Typography.Title level={3}>加入团队</Typography.Title>
|
||||
<Typography.Text style={{ fontSize: 16, color: '#475569', display: 'block', marginBottom: 8 }}>
|
||||
您被邀请加入团队
|
||||
</Typography.Text>
|
||||
<Typography.Title level={4} style={{ color: '#6366f1', margin: '16px 0 24px' }}>
|
||||
「{info.teamName}」
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 24 }}>
|
||||
加入后团队管理人可以为您分配积分、查看您的积分使用情况。
|
||||
</Typography.Text>
|
||||
<Button type="primary" size="large" block loading={submitting} onClick={handleJoin}>
|
||||
申请加入
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
|
||||
<Card
|
||||
variant="outlined"
|
||||
style={{ borderRadius: 16, maxWidth: 480, width: '100%', textAlign: 'center' }}
|
||||
>
|
||||
<TeamOutlined style={{ fontSize: 48, color: '#6366f1', marginBottom: 16 }} />
|
||||
<Typography.Title level={3}>加入团队</Typography.Title>
|
||||
<Typography.Text style={{ fontSize: 16, color: '#475569', display: 'block', marginBottom: 8 }}>
|
||||
您被邀请加入团队
|
||||
</Typography.Text>
|
||||
<Typography.Title level={4} style={{ color: '#6366f1', margin: '16px 0 24px' }}>
|
||||
「{info.teamName}」
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 24 }}>
|
||||
加入后团队管理人可以为您分配积分、查看您的积分使用情况。
|
||||
</Typography.Text>
|
||||
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
PlayCircleOutlined, BulbOutlined, HistoryOutlined,
|
||||
MobileOutlined, SafetyOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
import { sendSms,phonelogin, getSiteInfo, register } from '../api';
|
||||
import './LoginPage.css';
|
||||
@@ -17,6 +17,9 @@ const LoginPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mode, setMode] = useState<'password' | 'phone' | 'register'>('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 [regCountdown, setRegCountdown] = useState(0);
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
@@ -62,6 +65,23 @@ const LoginPage: React.FC = () => {
|
||||
}).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 => {
|
||||
if (!agreed) {
|
||||
message.warning('请先阅读并同意用户协议及隐私政策');
|
||||
@@ -77,7 +97,7 @@ const LoginPage: React.FC = () => {
|
||||
await login(values.phone, values.password, undefined, values.rememberMe);
|
||||
message.success('登录成功,欢迎回来');
|
||||
await checkAuth();
|
||||
navigate('/home');
|
||||
goToRedirect();
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
|
||||
message.error(errorMsg);
|
||||
@@ -97,7 +117,7 @@ const LoginPage: React.FC = () => {
|
||||
await phonelogin(values.phone, values.code);
|
||||
message.success('登录成功,欢迎回来');
|
||||
await checkAuth();
|
||||
navigate('/home');
|
||||
goToRedirect();
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
|
||||
message.error(errorMsg);
|
||||
@@ -114,7 +134,7 @@ const LoginPage: React.FC = () => {
|
||||
const user = await register(values.phone, values.regCode, values.password);
|
||||
message.success('注册成功');
|
||||
await checkAuth();
|
||||
navigate('/home');
|
||||
goToRedirect();
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '注册失败';
|
||||
message.error(errorMsg);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Table, Tag, Input, Pagination, Typography, Select, App, Modal } from 'antd';
|
||||
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 PreResultDisplay from '../components/PreResultDisplay';
|
||||
|
||||
@@ -436,6 +436,19 @@ const MaterialListPage: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
<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
|
||||
type="primary"
|
||||
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;
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Button, Empty, Form, Input, InputNumber, message, Modal, Pagination, Radio, Select, Space, Table, Tabs, Tag, Tooltip, Typography,
|
||||
} from 'antd';
|
||||
import { DatePicker } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
CopyOutlined, DownloadOutlined, PlusOutlined, ReloadOutlined, UserOutlined, HistoryOutlined, WalletOutlined,
|
||||
CopyOutlined, DownloadOutlined, PlusOutlined, ReloadOutlined, UserOutlined, HistoryOutlined, WalletOutlined, BellOutlined, ClockCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
@@ -134,8 +134,7 @@ const TeamManagementPage: React.FC = () => {
|
||||
try {
|
||||
const values = await invForm.validateFields();
|
||||
setInvSaving(true);
|
||||
const expiresAt = values.expiresAt ? new Date(values.expiresAt).toISOString() : null;
|
||||
await createTeamInvitation(values.maxUses || null, expiresAt);
|
||||
await createTeamInvitation(values.maxUses || null, null);
|
||||
message.success('邀请码已生成');
|
||||
setInvModal(false);
|
||||
invForm.resetFields();
|
||||
@@ -159,22 +158,80 @@ const TeamManagementPage: React.FC = () => {
|
||||
};
|
||||
|
||||
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('邀请链接已复制');
|
||||
}).catch(() => {
|
||||
} catch {
|
||||
message.warning('复制失败,请手动复制');
|
||||
});
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
};
|
||||
|
||||
// ── Tab 3: 加入申请 ──
|
||||
const [requests, setRequests] = useState<TeamJoinRequest[]>([]);
|
||||
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);
|
||||
try {
|
||||
const data = await getPendingJoinRequests();
|
||||
const currentCount = data?.length || 0;
|
||||
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) {
|
||||
message.error(e?.message || '加载申请失败');
|
||||
} 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) => {
|
||||
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 = [
|
||||
{ title: '邀请码', dataIndex: 'code', width: 200, render: (v: string) => <Typography.Text copyable style={{ fontFamily: 'monospace' }}>{v}</Typography.Text> },
|
||||
{
|
||||
title: '邀请链接', dataIndex: 'inviteLink', ellipsis: true,
|
||||
render: (v: string) => (
|
||||
<Space>
|
||||
<Typography.Text ellipsis style={{ maxWidth: 250, fontSize: 12 }}>{v}</Typography.Text>
|
||||
<Tooltip title="复制链接">
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyInviteLink(v)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
title: '邀请链接', dataIndex: 'code',
|
||||
render: (v: string) => {
|
||||
const link = buildInviteLink(v);
|
||||
return (
|
||||
<Space style={{ width: '100%' }}>
|
||||
<Typography.Text
|
||||
style={{
|
||||
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: '使用次数', key: 'uses', width: 100, render: (_: any, r: TeamInvitation) => `${r.useCount}${r.maxUses ? `/${r.maxUses}` : ''}` },
|
||||
@@ -517,7 +607,7 @@ const TeamManagementPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* 标签页 */}
|
||||
<Tabs items={tabItems} defaultActiveKey="members" size="large" />
|
||||
<Tabs items={tabItems} activeKey={activeTab} onChange={setActiveTab} size="large" />
|
||||
|
||||
{/* 调整积分弹窗 */}
|
||||
<Modal
|
||||
@@ -607,9 +697,19 @@ const TeamManagementPage: React.FC = () => {
|
||||
<Form.Item name="maxUses" label="最大使用次数">
|
||||
<InputNumber style={{ width: '100%' }} min={1} placeholder="留空表示不限" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="expiresAt" label="过期时间">
|
||||
<Input type="datetime-local" style={{ width: '100%' }} placeholder="留空表示永不过期" size="large" />
|
||||
</Form.Item>
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: '#eef2ff',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
color: '#4f46e5',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}>
|
||||
<ClockCircleOutlined />
|
||||
<span>邀请码自生成起 <strong>24 小时</strong> 内有效,过期自动失效。</span>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface JoinTeamInfo {
|
||||
teamId: string;
|
||||
valid: boolean;
|
||||
alreadyInTeam: boolean;
|
||||
hasPendingRequest: boolean;
|
||||
}
|
||||
|
||||
export interface CreditRecord {
|
||||
@@ -134,6 +135,9 @@ export interface MediaReference {
|
||||
source?: string;
|
||||
private_asset_id?: string;
|
||||
remote_asset_id?: string;
|
||||
providerUrl?: string;
|
||||
displayUrl?: string;
|
||||
previewUrl?: string;
|
||||
}
|
||||
|
||||
export interface GenerationRecord {
|
||||
@@ -253,14 +257,23 @@ export interface AdminNotification {
|
||||
|
||||
export interface PrivatePortraitConfig {
|
||||
enabled: boolean;
|
||||
imageLimit: number;
|
||||
usedImageCount: number;
|
||||
remainingImageCount: number;
|
||||
assetLimit: number;
|
||||
usedAssetCount: 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 {
|
||||
id: string;
|
||||
userId?: string | null;
|
||||
libraryType?: PrivatePortraitLibraryType | string;
|
||||
name: string;
|
||||
nameSlug?: string | null;
|
||||
remoteProjectName?: string | null;
|
||||
@@ -268,7 +281,11 @@ export interface PrivatePortraitProject {
|
||||
status: string;
|
||||
assetGroupCount: number;
|
||||
assetCount: number;
|
||||
imageAssetCount?: number;
|
||||
videoAssetCount?: number;
|
||||
activeAssetCount: number;
|
||||
activeImageAssetCount?: number;
|
||||
activeVideoAssetCount?: number;
|
||||
lastUsedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
@@ -297,23 +314,42 @@ export interface PrivatePortraitValidateSession {
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface PrivatePortraitProjectCreateWithValidateOut {
|
||||
project: PrivatePortraitProject;
|
||||
validateSession: PrivatePortraitValidateSession;
|
||||
pollIntervalMs: number;
|
||||
}
|
||||
|
||||
export interface PrivatePortraitAsset {
|
||||
id: string;
|
||||
userId?: string | null;
|
||||
projectId: string;
|
||||
projectName?: string | null;
|
||||
groupId: string;
|
||||
libraryType?: PrivatePortraitLibraryType | string;
|
||||
remoteGroupId: string;
|
||||
remoteAssetId?: string | null;
|
||||
remoteProjectName?: string | null;
|
||||
assetType: string;
|
||||
assetType: PrivatePortraitAssetType | string;
|
||||
name?: string | null;
|
||||
sourceUrl: string;
|
||||
previewUrl?: string | null;
|
||||
displayUrl?: string | null;
|
||||
providerUrl?: string | null;
|
||||
remoteUrl?: string | null;
|
||||
remoteUrlExpiredAt?: string | null;
|
||||
videoDuration?: number | null;
|
||||
videoCoverUrl?: string | null;
|
||||
fileSize?: number | null;
|
||||
mimeType?: string | null;
|
||||
status: string;
|
||||
moderation?: unknown;
|
||||
lastPollAt?: string | null;
|
||||
nextPollAt?: string | null;
|
||||
pollCount: number;
|
||||
remoteDeleteStatus: string;
|
||||
remoteDeletedAt?: string | null;
|
||||
remoteDeleteError?: string | null;
|
||||
errorMessage?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
@@ -330,9 +366,14 @@ export interface PrivatePortraitSelectableAsset {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
libraryType?: PrivatePortraitLibraryType | string;
|
||||
name?: string | null;
|
||||
assetType: string;
|
||||
assetType: PrivatePortraitAssetType | string;
|
||||
previewUrl?: string | null;
|
||||
displayUrl?: string | null;
|
||||
providerUrl?: string | null;
|
||||
videoDuration?: number | null;
|
||||
videoCoverUrl?: string | null;
|
||||
status: string;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
@@ -343,3 +384,4 @@ export interface PrivatePortraitSelectableAssetListOut {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user