This commit is contained in:
sjy
2026-07-07 10:27:07 +08:00
86 changed files with 7549 additions and 834 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-OStxKvM2.js"></script> <script type="module" crossorigin src="/assets/index-BJj5fRkV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css"> <link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head> </head>
<body> <body>
+2
View File
@@ -37,6 +37,7 @@ import AdminHomeMaterials from './pages/AdminHomeMaterials';
import AdminPreTestTemplates from './pages/AdminPreTestTemplates'; import AdminPreTestTemplates from './pages/AdminPreTestTemplates';
import AdminOAuthList from './pages/AdminOAuthList'; import AdminOAuthList from './pages/AdminOAuthList';
import AdminMaterialList from './pages/AdminMaterialList'; import AdminMaterialList from './pages/AdminMaterialList';
import AdminPrivatePortraitProjects from './pages/AdminPrivatePortraitProjects';
import { useAdminStore } from './store'; import { useAdminStore } from './store';
@@ -115,6 +116,7 @@ const App = () => {
<Route path="pretest-templates" element={<AdminPreTestTemplates />} /> <Route path="pretest-templates" element={<AdminPreTestTemplates />} />
<Route path="oauth-list" element={<AdminOAuthList />} /> <Route path="oauth-list" element={<AdminOAuthList />} />
<Route path="material-list" element={<AdminMaterialList />} /> <Route path="material-list" element={<AdminMaterialList />} />
<Route path="private-portrait-projects" element={<AdminPrivatePortraitProjects />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
+63 -1
View File
@@ -15,6 +15,7 @@ import type {
AdminCreditRecordListResponse, AdminCreditRecordQueryParams, AdminCreditRecordListResponse, AdminCreditRecordQueryParams,
ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut, ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut,
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams, AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
} from '../types'; } from '../types';
import type { import type {
@@ -180,6 +181,19 @@ export async function deleteAdminTeam(id: string): Promise<void> {
await api.delete(`/admin/teams/${id}`); await api.delete(`/admin/teams/${id}`);
} }
export async function setTeamManager(teamId: string, userId: string | null): Promise<AdminTeam> {
return api.put(`/admin/teams/${teamId}/manager`, { user_id: userId });
}
export async function getTeamMembersForAdmin(teamId: string, page = 1, pageSize = 50): Promise<{ items: AdminUser[]; total: number }> {
const params = new URLSearchParams();
params.set('page', String(page));
params.set('page_size', String(pageSize));
params.set('user_type', 'frontend');
params.set('team_id', teamId);
return api.get(`/admin/users?${params.toString()}`);
}
export async function adjustCredits(userId: string, amount: number, description: string): Promise<void> { export async function adjustCredits(userId: string, amount: number, description: string): Promise<void> {
await api.post(`/admin/users/${userId}/credits`, { amount, description }); await api.post(`/admin/users/${userId}/credits`, { amount, description });
} }
@@ -434,7 +448,7 @@ export async function deleteMenuConfig(id: string): Promise<void> {
// ── User Creation ─────────────────────────────────────── // ── User Creation ───────────────────────────────────────
export async function createUser(data: { username?: string; password: string; email?: string; phone?: string; credits: number; user_type: string; frontend_user_kind?: string; allowed_menus?: string[] | null }): 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_image_limit?: number }): Promise<any> {
return api.post('/admin/users', data); return api.post('/admin/users', data);
} }
@@ -984,3 +998,51 @@ export async function getMaterialList(params: MaterialListParams): Promise<any>
if (params.page_size !== undefined) query.set('page_size', String(params.page_size)); if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
return api.get(`/material-admin/material-list?${query.toString()}`); return api.get(`/material-admin/material-list?${query.toString()}`);
} }
export interface PreTestTemplateListParams {
id?: string;
phone?: string;
created_at?: [string, string];
page?: number;
page_size?: number;
}
export async function getPreTestTemplateList(params: PreTestTemplateListParams): Promise<any> {
const query = new URLSearchParams();
if (params.id) query.set('id', params.id);
if (params.phone) query.set('phone', params.phone);
if (params.created_at !== undefined) query.set('created_at', params.created_at[0] + ',' + params.created_at[1]);
if (params.page !== undefined) query.set('page', String(params.page));
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
return api.get(`/material-admin/pre-test-template-list?${query.toString()}`);
}
// ── Private Portrait Admin ────────────────────────────────
export async function adminGetPrivatePortraitProjects(params: { userId?: 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.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> {
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.keyword) query.set('keyword', params.keyword);
if (params.status) query.set('status', params.status);
return api.get<PrivatePortraitAssetListOut>(`/admin/private-portrait/assets?${query.toString()}`);
}
export async function adminGetPrivatePortraitConfig(userId: string): Promise<PrivatePortraitConfig> {
return api.get<PrivatePortraitConfig>(`/admin/private-portrait/users/${userId}/config`);
}
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 });
}
@@ -0,0 +1,180 @@
import React, { useState } from 'react';
import { Popover, Tag, List, Descriptions, Typography } from 'antd';
interface PreResultData {
video_id: string;
advertiser_id: number;
material_id: string;
is_ad_high_quality_material: string;
is_ecp_high_quality_material: string;
is_inefficient_material: string;
is_first_publish_material: string;
is_local_high_quality_material: string;
not_ad_high_quality_reason: string[] | null;
not_ecp_high_quality_reason: string[] | null;
}
interface PreResultDisplayProps {
preResult: string;
}
const fieldConfig = [
{ key: 'is_ad_high_quality_material', label: 'AD优质', noLabel: 'AD非优质素材', unknownLabel: 'AD未知', yesColor: 'green', noColor: 'red', unknownColor: 'default' },
{ key: 'is_ecp_high_quality_material', label: '千川优质', noLabel: '千川非优质素材', unknownLabel: '千川未知', yesColor: 'green', noColor: 'red', unknownColor: 'default' },
{ key: 'is_local_high_quality_material', label: '本地推优质', noLabel: '本地推非优质', unknownLabel: '本地推未知', yesColor: 'green', noColor: 'red', unknownColor: 'default' },
{ key: 'is_inefficient_material', label: '低效', noLabel: '非低效', unknownLabel: '是否低效未知', yesColor: 'red', noColor: 'green', unknownColor: 'default' },
{ key: 'is_first_publish_material', label: '首发', noLabel: '非首发', unknownLabel: '是否首发未知', yesColor: 'blue', noColor: 'default', unknownColor: 'default' },
];
const PreResultDisplay: React.FC<PreResultDisplayProps> = ({ preResult }) => {
const [parsedData, setParsedData] = useState<PreResultData | null>(null);
const [hasError, setHasError] = useState(false);
React.useEffect(() => {
if (!preResult) {
setParsedData(null);
setHasError(false);
return;
}
try {
const data = JSON.parse(preResult);
setParsedData(data);
setHasError(false);
} catch {
setParsedData(null);
setHasError(true);
}
}, [preResult]);
if (!preResult || hasError || !parsedData) {
return <span style={{ color: '#64748b' }}>-</span>;
}
const getIndicatorDisplay = (key: string) => {
const config = fieldConfig.find(c => c.key === key);
if (!config) return null;
const value = parsedData[key as keyof PreResultData];
const isYes = value === 'YES';
const isNo = value === 'NO';
const isUnknown = value === 'UNKNOWN';
if (isUnknown) return null;
let displayLabel, displayColor;
if (isYes) {
displayLabel = config.label;
displayColor = config.yesColor;
} else if (isNo) {
displayLabel = config.noLabel;
displayColor = config.noColor;
}
return {
tag: (
<Tag
key={key}
color={displayColor}
style={{ marginRight: 6, fontSize: 12, borderRadius: 4 }}
>
{displayLabel}
</Tag>
),
detail: {
label: config.label.replace('优质', '优质素材'),
value: displayLabel,
color: displayColor,
},
};
};
const content = (
<div style={{ maxWidth: 500, padding: '8px 0' }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, color: '#1e293b' }}></Typography.Text>
<Descriptions column={1} size="small" style={{ marginBottom: 12 }}>
<Descriptions.Item label="视频ID" labelStyle={{ fontWeight: 500, color: '#64748b' }} contentStyle={{ color: '#1e293b' }}>{parsedData.video_id}</Descriptions.Item>
<Descriptions.Item label="广告主ID" labelStyle={{ fontWeight: 500, color: '#64748b' }} contentStyle={{ color: '#1e293b' }}>{parsedData.advertiser_id}</Descriptions.Item>
<Descriptions.Item label="素材ID" labelStyle={{ fontWeight: 500, color: '#64748b' }} contentStyle={{ color: '#1e293b' }}>{parsedData.material_id}</Descriptions.Item>
</Descriptions>
<Typography.Text strong style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 8 }}></Typography.Text>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
{fieldConfig.map(config => {
const value = parsedData[config.key as keyof PreResultData];
const isYes = value === 'YES';
const isNo = value === 'NO';
const isUnknown = value === 'UNKNOWN';
if (isUnknown) return null;
let displayLabel, displayColor;
if (isYes) {
displayLabel = config.label;
displayColor = config.yesColor;
} else if (isNo) {
displayLabel = config.noLabel;
displayColor = config.noColor;
}
return (
<Tag
key={config.key}
color={displayColor}
style={{ fontSize: 12, borderRadius: 4 }}
>
{displayLabel}
</Tag>
);
})}
</div>
{parsedData.not_ad_high_quality_reason && parsedData.not_ad_high_quality_reason.length > 0 && (
<div style={{ marginBottom: 12 }}>
<Typography.Text strong style={{ fontSize: 12, color: '#ef4444', display: 'block', marginBottom: 8 }}>
AD非优质原因
</Typography.Text>
<List
dataSource={parsedData.not_ad_high_quality_reason}
renderItem={(item, index) => (
<List.Item key={index} style={{ padding: '4px 0', fontSize: 12, color: '#64748b' }}>
{index + 1}. {item}
</List.Item>
)}
size="small"
/>
</div>
)}
{parsedData.not_ecp_high_quality_reason && parsedData.not_ecp_high_quality_reason.length > 0 && (
<div>
<Typography.Text strong style={{ fontSize: 12, color: '#ef4444', display: 'block', marginBottom: 8 }}>
</Typography.Text>
<List
dataSource={parsedData.not_ecp_high_quality_reason}
renderItem={(item, index) => (
<List.Item key={index} style={{ padding: '4px 0', fontSize: 12, color: '#64748b' }}>
{index + 1}. {item}
</List.Item>
)}
size="small"
/>
</div>
)}
</div>
);
return (
<Popover
content={content}
title={null}
trigger="hover"
placement="topLeft"
overlayStyle={{ borderRadius: 12, boxShadow: '0 8px 24px rgba(0,0,0,0.12)' }}
>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 4}}>
{fieldConfig.map(config => {
const display = getIndicatorDisplay(config.key);
return display?.tag;
})}
</div>
</Popover>
);
};
export default PreResultDisplay;
@@ -35,6 +35,7 @@ const RECORD_TYPE_MAP: Record<string, { text: string; color: string; icon: React
recharge: { text: '充值', color: 'green', icon: <ArrowUpOutlined /> }, recharge: { text: '充值', color: 'green', icon: <ArrowUpOutlined /> },
consume: { text: '消费', color: 'red', icon: <ArrowDownOutlined /> }, consume: { text: '消费', color: 'red', icon: <ArrowDownOutlined /> },
refund: { text: '回退', color: 'blue', icon: <RollbackOutlined /> }, refund: { text: '回退', color: 'blue', icon: <RollbackOutlined /> },
team_internal: { text: '团队内部', color: 'cyan', icon: <WalletOutlined /> },
}; };
const userScopeOptions = [ const userScopeOptions = [
@@ -49,6 +50,7 @@ const recordTypeOptions = [
{ value: 'recharge', label: '充值' }, { value: 'recharge', label: '充值' },
{ value: 'consume', label: '消费' }, { value: 'consume', label: '消费' },
{ value: 'refund', label: '回退' }, { value: 'refund', label: '回退' },
{ value: 'team_internal', label: '团队内部' },
]; ];
const creditSubjectOptions = [ const creditSubjectOptions = [
@@ -59,6 +61,7 @@ const creditSubjectOptions = [
{ value: 'analysis', label: '分析积分' }, { value: 'analysis', label: '分析积分' },
{ value: 'split', label: '切片积分' }, { value: 'split', label: '切片积分' },
{ value: 'admin_adjust', label: '管理员调整' }, { value: 'admin_adjust', label: '管理员调整' },
{ value: 'team_internal', label: '团队内部转移' },
{ value: 'recharge', label: '充值积分' }, { value: 'recharge', label: '充值积分' },
{ value: 'unknown', label: '历史未知' }, { value: 'unknown', label: '历史未知' },
]; ];
@@ -79,6 +82,7 @@ const chargeKindOptions = [
{ value: 'video_analysis', label: '视频分析' }, { value: 'video_analysis', label: '视频分析' },
{ value: 'video_split', label: '视频切片' }, { value: 'video_split', label: '视频切片' },
{ value: 'admin_adjust', label: '管理员调整' }, { value: 'admin_adjust', label: '管理员调整' },
{ value: 'team_internal', label: '团队内部转移' },
]; ];
const sourceModuleOptions = [ const sourceModuleOptions = [
@@ -89,6 +93,7 @@ const sourceModuleOptions = [
{ value: 'shot_replicate', label: '拆镜复刻' }, { value: 'shot_replicate', label: '拆镜复刻' },
{ value: 'admin', label: '后台管理' }, { value: 'admin', label: '后台管理' },
{ value: 'payment', label: '支付充值' }, { value: 'payment', label: '支付充值' },
{ value: 'team', label: '团队管理' },
{ value: 'unknown', label: '历史未知' }, { value: 'unknown', label: '历史未知' },
]; ];
@@ -127,6 +132,7 @@ const billingSceneOptions = [
{ value: 'recharge', label: '充值' }, { value: 'recharge', label: '充值' },
{ value: 'admin_adjust', label: '管理员调整' }, { value: 'admin_adjust', label: '管理员调整' },
{ value: 'refund', label: '回退' }, { value: 'refund', label: '回退' },
{ value: 'team_internal_transfer', label: '团队内部转账' },
{ value: 'unknown', label: '历史未知' }, { value: 'unknown', label: '历史未知' },
]; ];
@@ -1,7 +1,8 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Input, Select, Table, Pagination, Tag, Typography } from 'antd'; import { Button, Input, Select, Table, Pagination, Tag, Typography, Tooltip } from 'antd';
import { FileTextOutlined, SearchOutlined } from '@ant-design/icons'; import { FileTextOutlined } from '@ant-design/icons';
import { getMaterialList } from '../api'; import { getMaterialList } from '../api';
import PreResultDisplay from '../components/PreResultDisplay';
const formatDateTime = (dateStr: string) => { const formatDateTime = (dateStr: string) => {
if (!dateStr) return ''; if (!dateStr) return '';
const date = new Date(dateStr); const date = new Date(dateStr);
@@ -48,6 +49,16 @@ const AdminMaterialList: React.FC = () => {
title: '备注', title: '备注',
dataIndex: 'note', dataIndex: 'note',
key: 'note', key: 'note',
width: 200,
render: (text: string) => (
<Input.TextArea
value={text || ''}
readOnly
autoSize={{ minRows: 1, maxRows: 4 }}
style={{ color: '#94a3b8', resize: 'none', border: 'none', background: 'transparent', padding: 0 }}
placeholder="-"
/>
),
}, },
{ {
title: '授权ID', title: '授权ID',
@@ -58,6 +69,8 @@ const AdminMaterialList: React.FC = () => {
title: '预测试结果', title: '预测试结果',
dataIndex: 'preResult', dataIndex: 'preResult',
key: 'preResult', key: 'preResult',
width: 120,
render: (text: string) => <PreResultDisplay preResult={text} />,
}, },
{ {
title: '预测试模板ID', title: '预测试模板ID',
@@ -94,7 +107,15 @@ const AdminMaterialList: React.FC = () => {
title: '上传ID', title: '上传ID',
dataIndex: 'uploadId', dataIndex: 'uploadId',
key: 'uploadId', key: 'uploadId',
width: 160, render: (v: string) => {
if (!v) return '-';
const short = v.length > 12 ? `${v.slice(0, 6)}...${v.slice(-4)}` : v;
return (
<Tooltip title={v} placement="topLeft">
<Typography.Text style={{ fontSize: 12 }}>{short}</Typography.Text>
</Tooltip>
);
},
}, },
{ {
title: '资源类型', title: '资源类型',
@@ -145,13 +166,13 @@ const AdminMaterialList: React.FC = () => {
page: currentPage, page: currentPage,
page_size: pageSize, page_size: pageSize,
}); });
const data = res.items || []; const data = res.data || [];
const tableData = data.map((item: any, index: number) => ({ const tableData = data.map((item: any, index: number) => ({
...item, ...item,
index: (currentPage - 1) * pageSize + index + 1, index: (currentPage - 1) * pageSize + index + 1,
})); }));
setTableData(tableData); setTableData(tableData);
setTotal(res.total || 0); setTotal(res.pagination?.total || 0);
} catch (error) { } catch (error) {
console.error('加载数据失败:', error); console.error('加载数据失败:', error);
} finally { } finally {
@@ -1,18 +1,29 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Input, Select, Table, Pagination, Tag, Typography } from 'antd'; import { Button, Input, Table, Pagination, Tag, Typography, DatePicker } from 'antd';
import { FileTextOutlined, SearchOutlined } from '@ant-design/icons'; import { FileTextOutlined } from '@ant-design/icons';
import { getPreTestTemplateList } from '../api';
import dayjs from 'dayjs';
const formatDateTime = (dateStr: string) => {
if (!dateStr) return '';
const date = new Date(dateStr);
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 { Text } = Typography; const { Text } = Typography;
const AdminPreTestTemplates: React.FC = () => { const AdminPreTestTemplates: React.FC = () => {
const [tableData, setTableData] = useState<any[]>([]); const [tableData, setTableData] = useState<any[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10); const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [searchPlatform, setSearchPlatform] = useState(''); const [searchId, setSearchId] = useState('');
const [searchName, setSearchName] = useState(''); const [searchPhone, setSearchPhone] = useState('');
const [searchCreatedAt, setSearchCreatedAt] = useState<[dayjs.Dayjs, dayjs.Dayjs] | undefined>();
const columns = [ const columns = [
{ {
title: '序号', title: '序号',
@@ -22,92 +33,180 @@ const AdminPreTestTemplates: React.FC = () => {
render: (text: number) => <span style={{ color: '#64748b' }}>{text}</span>, render: (text: number) => <span style={{ color: '#64748b' }}>{text}</span>,
}, },
{ {
title: '模板名称', title: 'ID',
dataIndex: 'id',
key: 'id',
},
{
title: '手机号',
dataIndex: 'phone',
key: 'phone',
width: 150,
},
{
title: '受众年龄',
dataIndex: 'audienceAge',
key: 'audienceAge',
width: 100,
},
{
title: '受众性别',
dataIndex: 'audienceGender',
key: 'audienceGender',
width: 100,
},
{
title: '受众网络',
dataIndex: 'audienceNetwork',
key: 'audienceNetwork',
width: 100,
},
{
title: '受众区域',
dataIndex: 'audienceRegion',
key: 'audienceRegion',
width: 100,
},
{
title: '预算',
dataIndex: 'budget',
key: 'budget',
width: 100,
},
{
title: '成本上限',
dataIndex: 'costCap',
key: 'costCap',
width: 100,
},
{
title: 'CPA预算',
dataIndex: 'cpaBid',
key: 'cpaBid',
width: 100,
},
{
title: 'CPC预算',
dataIndex: 'cpcBid',
key: 'cpcBid',
width: 100,
},
{
title: '客户名称',
dataIndex: 'cusName',
key: 'cusName',
width: 150,
},
{
title: '外部操作',
dataIndex: 'externalAction',
key: 'externalAction',
width: 100,
},
{
title: '名称',
dataIndex: 'name', dataIndex: 'name',
key: 'name', key: 'name',
width: 200, width: 150,
}, },
{ {
title: '投放平台', title: '名称',
dataIndex: 'nobid',
key: 'nobid',
width: 150,
},
{
title: '备注',
dataIndex: 'note',
key: 'note',
width: 150,
},
{
title: '平台',
dataIndex: 'platform', dataIndex: 'platform',
key: 'platform', key: 'platform',
width: 120, width: 100,
render: (text: string) => (
<Tag color={text === 'AD' ? 'blue' : text === 'QIANCHUAN' ? 'green' : 'orange'}>
{text === 'AD' ? 'AD' : text === 'QIANCHUAN' ? '千川' : '本地推'}
</Tag>
),
}, },
{ {
title: '状态', title: '定价类型',
dataIndex: 'status', dataIndex: 'pricingType',
key: 'status', key: 'pricingType',
width: 100, width: 100,
render: (text: string) => ( },
<Tag color={text === 'active' ? 'green' : 'red'}> {
{text === 'active' ? '启用' : '禁用'} title: '目标成本',
</Tag> dataIndex: 'targetCost',
), key: 'targetCost',
width: 100,
},
{
title: '用户ID',
dataIndex: 'userId',
key: 'userId',
width: 100,
},
{
title: '用户手机号',
dataIndex: 'userPhone',
key: 'userPhone',
width: 150,
},
{
title: '是否默认',
dataIndex: 'isDefault',
key: 'isDefault',
width: 100,
render: (text: boolean) => <Tag color={text ? 'green' : 'red'}>{text ? '是' : '否'}</Tag>,
}, },
{ {
title: '创建时间', title: '创建时间',
dataIndex: 'createdAt', dataIndex: 'createdAt',
key: 'createdAt', key: 'createdAt',
width: 180, width: 180,
render: (text: string) => <span style={{ color: '#64748b' }}>{text}</span>, render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
}, },
{ {
title: '更新时间', title: '更新时间',
dataIndex: 'updatedAt', dataIndex: 'updatedAt',
key: 'updatedAt', key: 'updatedAt',
width: 180, width: 180,
render: (text: string) => <span style={{ color: '#64748b' }}>{text}</span>, render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
}, },
]; ];
const loadRecords = async () => { const loadRecords = async () => {
setLoading(true); setLoading(true);
try { try {
const params = new URLSearchParams(); const res = await getPreTestTemplateList({
params.set('page', String(currentPage)); id: searchId || undefined,
params.set('page_size', String(pageSize)); phone: searchPhone || undefined,
if (searchPlatform) params.set('platform', searchPlatform); created_at: searchCreatedAt ? [searchCreatedAt[0].format('YYYY-MM-DD'), searchCreatedAt[1].format('YYYY-MM-DD')] : undefined,
if (searchName) params.set('name', searchName); page: currentPage,
const mockData = { page_size: pageSize,
items: Array.from({ length: pageSize }, (_, i) => ({ });
id: `${currentPage}-${i}`, const data = res.data || [];
index: (currentPage - 1) * pageSize + i + 1, const tableData = data.map((item: any, index: number) => ({
name: `前测模板${(currentPage - 1) * pageSize + i + 1}`, ...item,
platform: ['AD', 'QIANCHUAN', 'LOCAL'][i % 3], index: (currentPage - 1) * pageSize + index + 1,
status: i % 5 === 0 ? 'inactive' : 'active', }));
createdAt: '2026-07-01 10:00:00', setTableData(tableData);
updatedAt: '2026-07-02 14:30:00', setTotal(res.pagination.total || 0);
})),
total: 50,
};
setTableData(mockData.items);
setTotal(mockData.total);
} catch (error) { } catch (error) {
console.error('加载数据失败:', error); console.error('加载数据失败:', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
useEffect(() => { useEffect(() => {
loadRecords(); loadRecords();
}, [currentPage, pageSize]); }, [currentPage, pageSize]);
const handlePageChange = (page: number, size: number) => { const handlePageChange = (page: number, size: number) => {
setCurrentPage(page); setCurrentPage(page);
setPageSize(size); setPageSize(size);
}; };
const handleSearch = () => { const handleSearch = () => {
setCurrentPage(1); setCurrentPage(1);
loadRecords(); loadRecords();
}; };
return ( return (
<div> <div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
@@ -115,26 +214,34 @@ const AdminPreTestTemplates: React.FC = () => {
<Text strong style={{ fontSize: 16 }}></Text> <Text strong style={{ fontSize: 16 }}></Text>
</div> </div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<Input <Input
placeholder="模板名称" placeholder="ID"
value={searchName} value={searchId}
onChange={(e) => setSearchName(e.target.value)} onChange={(e) => setSearchId(e.target.value)}
style={{ width: 180 }} style={{ width: 160 }}
allowClear allowClear
onPressEnter={handleSearch} onPressEnter={handleSearch}
/> />
<Select <Input
placeholder="投放平台" placeholder="手机号"
value={searchPlatform} value={searchPhone}
onChange={(value) => setSearchPlatform(value)} onChange={(e) => setSearchPhone(e.target.value)}
style={{ width: 140 }} style={{ width: 160 }}
allowClear allowClear
options={[ onPressEnter={handleSearch}
{ value: 'AD', label: 'AD' }, />
{ value: 'QIANCHUAN', label: '千川' }, <DatePicker.RangePicker
{ value: 'LOCAL', label: '本地推' }, placeholder={['开始日期', '结束日期']}
]} value={searchCreatedAt}
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
setSearchCreatedAt([dates[0], dates[1]]);
} else {
setSearchCreatedAt(undefined);
}
}}
style={{ width: 260 }}
/> />
<Button <Button
type="primary" type="primary"
@@ -187,5 +294,4 @@ const AdminPreTestTemplates: React.FC = () => {
</div> </div>
); );
}; };
export default AdminPreTestTemplates; export default AdminPreTestTemplates;
@@ -0,0 +1,133 @@
import React, { useEffect, useState } from 'react';
import { Button, Card, Input, Space, Table, Tag, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { adminGetPrivatePortraitAssets, adminGetPrivatePortraitProjects } from '../api';
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../types';
const { Title, Text } = Typography;
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?.includes('deleted')) return 'default';
return 'default';
};
const AdminPrivatePortraitProjects: React.FC = () => {
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
const [projectTotal, setProjectTotal] = useState(0);
const [assetTotal, setAssetTotal] = useState(0);
const [loadingProjects, setLoadingProjects] = useState(false);
const [loadingAssets, setLoadingAssets] = useState(false);
const [keyword, setKeyword] = useState('');
const [selectedProjectId, setSelectedProjectId] = useState<string | undefined>();
const [projectPage, setProjectPage] = useState(1);
const [assetPage, setAssetPage] = useState(1);
const loadProjects = async () => {
setLoadingProjects(true);
try {
const res = await adminGetPrivatePortraitProjects({ keyword: keyword.trim() || undefined, page: projectPage, pageSize: 20 });
setProjects(res.items || []);
setProjectTotal(res.total || 0);
} catch (err: any) {
message.error(err?.message || '加载真人素材项目失败');
} finally {
setLoadingProjects(false);
}
};
const loadAssets = async () => {
setLoadingAssets(true);
try {
const res = await adminGetPrivatePortraitAssets({ projectId: selectedProjectId, keyword: keyword.trim() || undefined, page: assetPage, pageSize: 20 });
setAssets(res.items || []);
setAssetTotal(res.total || 0);
} catch (err: any) {
message.error(err?.message || '加载真人素材失败');
} finally {
setLoadingAssets(false);
}
};
useEffect(() => { loadProjects(); }, [projectPage]);
useEffect(() => { loadAssets(); }, [selectedProjectId, assetPage]);
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 || '-' },
];
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 || '-' },
];
return (
<div style={{ padding: 24 }}>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}>
<div>
<Title level={3} style={{ margin: 0 }}></Title>
<Text type="secondary">Asset Group Asset </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 }}
/>
<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 }}
/>
</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>
</div>
);
};
export default AdminPrivatePortraitProjects;
+136 -4
View File
@@ -1,8 +1,8 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd'; import { Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd';
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, TeamOutlined } from '@ant-design/icons'; import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, TeamOutlined, UserOutlined } from '@ant-design/icons';
import { deleteAdminTeam, getAdminTeams, saveAdminTeam } from '../api'; import { deleteAdminTeam, getAdminTeams, getTeamMembersForAdmin, saveAdminTeam, setTeamManager } from '../api';
import type { AdminTeam } from '../types'; import type { AdminTeam, AdminUser } from '../types';
import { formatDate } from '../utils/formatDate'; import { formatDate } from '../utils/formatDate';
const statusOptions = [ const statusOptions = [
@@ -34,6 +34,17 @@ const AdminTeams: React.FC = () => {
const [modal, setModal] = useState<{ open: boolean; item: AdminTeam | null }>({ open: false, item: null }); const [modal, setModal] = useState<{ open: boolean; item: AdminTeam | null }>({ open: false, item: null });
const [form] = Form.useForm(); const [form] = Form.useForm();
// 管理人弹窗状态
const [managerModal, setManagerModal] = useState<{ open: boolean; team: AdminTeam | null }>({ open: false, team: null });
const [managerMembers, setManagerMembers] = useState<AdminUser[]>([]);
const [managerLoading, setManagerLoading] = useState(false);
const [selectedManagerId, setSelectedManagerId] = useState<string | null>(null);
// 查看成员弹窗状态
const [membersModal, setMembersModal] = useState<{ open: boolean; team: AdminTeam | null }>({ open: false, team: null });
const [teamMembers, setTeamMembers] = useState<AdminUser[]>([]);
const [membersLoading, setMembersLoading] = useState(false);
const query = useMemo(() => ({ const query = useMemo(() => ({
page, page,
pageSize, pageSize,
@@ -107,6 +118,48 @@ const AdminTeams: React.FC = () => {
} }
}; };
const loadTeamMembers = async (teamId: string): Promise<AdminUser[]> => {
try {
const res = await getTeamMembersForAdmin(teamId);
return res.items || [];
} catch (e: any) {
message.error(e?.message || '加载成员失败');
return [];
}
};
const openManagerModal = async (team: AdminTeam) => {
setManagerModal({ open: true, team });
setSelectedManagerId(team.managerId || null);
setManagerLoading(true);
const data = await loadTeamMembers(team.id);
setManagerMembers(data);
setManagerLoading(false);
};
const openMembersModal = async (team: AdminTeam) => {
setMembersModal({ open: true, team });
setMembersLoading(true);
const data = await loadTeamMembers(team.id);
setTeamMembers(data);
setMembersLoading(false);
};
const handleSetManager = async () => {
if (!managerModal.team) return;
try {
setManagerLoading(true);
await setTeamManager(managerModal.team.id, selectedManagerId);
message.success(selectedManagerId ? '已设置管理人' : '已取消管理人');
setManagerModal({ open: false, team: null });
load();
} catch (e: any) {
message.error(e?.message || '设置失败');
} finally {
setManagerLoading(false);
}
};
const columns = [ const columns = [
{ {
title: '团队名称', title: '团队名称',
@@ -136,6 +189,16 @@ const AdminTeams: React.FC = () => {
width: 100, width: 100,
render: (v: number) => <Typography.Text strong>{Number(v || 0).toLocaleString()}</Typography.Text>, render: (v: number) => <Typography.Text strong>{Number(v || 0).toLocaleString()}</Typography.Text>,
}, },
{
title: '管理人',
key: 'manager',
width: 140,
render: (_: any, r: AdminTeam) => (
<Typography.Text style={{ fontSize: 13 }}>
{r.managerName || <span style={{ color: '#94a3b8' }}></span>}
</Typography.Text>
),
},
{ {
title: '排序', title: '排序',
dataIndex: 'sortOrder', dataIndex: 'sortOrder',
@@ -157,10 +220,12 @@ const AdminTeams: React.FC = () => {
{ {
title: '操作', title: '操作',
key: 'action', key: 'action',
width: 160, width: 230,
fixed: 'right' as const, fixed: 'right' as const,
render: (_: any, r: AdminTeam) => ( render: (_: any, r: AdminTeam) => (
<Space size={4}> <Space size={4}>
<Button type="link" size="small" icon={<UserOutlined />} onClick={() => openMembersModal(r)}></Button>
<Button type="link" size="small" icon={<SettingOutlined />} onClick={() => openManagerModal(r)}></Button>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}></Button> <Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}></Button>
<Popconfirm <Popconfirm
title="确定删除该团队?" title="确定删除该团队?"
@@ -239,6 +304,73 @@ const AdminTeams: React.FC = () => {
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
{/* 设置管理人弹窗 */}
<Modal
title={<Space><UserOutlined /></Space>}
open={managerModal.open}
confirmLoading={managerLoading}
onOk={handleSetManager}
onCancel={() => { setManagerModal({ open: false, team: null }); setSelectedManagerId(null); }}
okText="保存"
cancelText="取消"
width={480}
>
<div style={{ marginTop: 16 }}>
<Typography.Text style={{ fontSize: 13, color: '#64748b', display: 'block', marginBottom: 8 }}>
{managerModal.team?.name || '-'}
</Typography.Text>
<Typography.Text style={{ fontSize: 13, color: '#64748b', display: 'block', marginBottom: 12 }}>
</Typography.Text>
<Select
style={{ width: '100%' }}
placeholder="选择管理人(可清空取消)"
value={selectedManagerId}
onChange={(v) => setSelectedManagerId(v || null)}
allowClear
loading={managerLoading}
optionFilterProp="label"
options={managerMembers.map((m) => ({
value: m.id,
label: `${m.username}${m.phone ? ` (${m.phone})` : ''}`,
}))}
showSearch
/>
</div>
</Modal>
{/* 查看成员弹窗 */}
<Modal
title={<Space><UserOutlined /> - {membersModal.team?.name}</Space>}
open={membersModal.open}
onCancel={() => setMembersModal({ open: false, team: null })}
footer={null}
width={600}
>
<div style={{ marginTop: 8 }}>
{membersModal.team && (
<Table
size="small"
rowKey="id"
loading={membersLoading}
dataSource={teamMembers}
pagination={false}
scroll={{ y: 400 }}
columns={[
{ title: '用户名', dataIndex: 'username', width: 140, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
{ title: '手机号', dataIndex: 'phone', width: 130, render: (v: string) => v || '-' },
{ title: '积分', dataIndex: 'credits', width: 100, render: (v: number) => <Typography.Text style={{ color: '#6366f1' }}>{(v ?? 0).toFixed(2)}</Typography.Text> },
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '启用' : '禁用'}</Tag>,
},
]}
locale={{ emptyText: '该团队暂无成员' }}
/>
)}
</div>
</Modal>
</div> </div>
); );
}; };
+101 -3
View File
@@ -3,10 +3,12 @@ import {
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography, Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography,
} from 'antd'; } from 'antd';
import { import {
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import {
adjustCredits, adjustCredits,
adminGetPrivatePortraitConfig,
adminUpdatePrivatePortraitConfig,
createUser, createUser,
deleteUserResourceCapacity, deleteUserResourceCapacity,
getAdminUsers, getAdminUsers,
@@ -22,7 +24,7 @@ import {
updateSystemConfig, updateSystemConfig,
updateUserMenus, updateUserMenus,
} from '../api'; } from '../api';
import type { AdminTeamOption, AdminUser, AdminUserResourceCapacityOut, ResourceCapacityUnit, ResourceCapacityUsage, SystemConfig } from '../types'; import type { AdminTeamOption, AdminUser, AdminUserResourceCapacityOut, PrivatePortraitConfig, ResourceCapacityUnit, ResourceCapacityUsage, SystemConfig } from '../types';
import { formatDate } from '../utils/formatDate'; import { formatDate } from '../utils/formatDate';
const TEAM_UNASSIGNED_VALUE = '__none__'; const TEAM_UNASSIGNED_VALUE = '__none__';
@@ -71,14 +73,18 @@ const AdminUsers: React.FC = () => {
const [resetPwdModal, setResetPwdModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null }); const [resetPwdModal, setResetPwdModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [capacityModal, setCapacityModal] = useState<{ open: boolean; user: AdminUser | null; detail: AdminUserResourceCapacityOut | null }>({ open: false, user: null, detail: null }); const [capacityModal, setCapacityModal] = useState<{ open: boolean; user: AdminUser | null; detail: AdminUserResourceCapacityOut | null }>({ open: false, user: null, detail: null });
const [teamModal, setTeamModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null }); const [teamModal, setTeamModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [portraitModal, setPortraitModal] = useState<{ open: boolean; user: AdminUser | null; config: PrivatePortraitConfig | null }>({ open: false, user: null, config: null });
const [capacityLoading, setCapacityLoading] = useState(false); const [capacityLoading, setCapacityLoading] = useState(false);
const [capacitySaving, setCapacitySaving] = useState(false); const [capacitySaving, setCapacitySaving] = useState(false);
const [teamSaving, setTeamSaving] = useState(false); const [teamSaving, setTeamSaving] = useState(false);
const [portraitLoading, setPortraitLoading] = useState(false);
const [portraitSaving, setPortraitSaving] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
const [resetPwdForm] = Form.useForm(); const [resetPwdForm] = Form.useForm();
const [capacityForm] = Form.useForm(); const [capacityForm] = Form.useForm();
const [teamForm] = Form.useForm(); const [teamForm] = Form.useForm();
const [portraitForm] = Form.useForm();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20); const [pageSize, setPageSize] = useState(20);
@@ -183,6 +189,7 @@ const AdminUsers: React.FC = () => {
credits: values.credits || 0, credits: values.credits || 0,
user_type: userType, user_type: userType,
frontend_user_kind: values.frontend_user_kind || 'external', frontend_user_kind: values.frontend_user_kind || 'external',
private_portrait_image_limit: userType === 'frontend' ? Number(values.private_portrait_image_limit ?? 5) : 0,
}); });
message.success('用户创建成功'); message.success('用户创建成功');
setCreateModal(false); setCreateModal(false);
@@ -266,6 +273,42 @@ const AdminUsers: React.FC = () => {
} }
}; };
const openPortraitModal = async (user: AdminUser) => {
setPortraitLoading(true);
setPortraitModal({ open: true, user, config: null });
portraitForm.setFieldsValue({ privatePortraitImageLimit: user.privatePortraitImageLimit ?? 5 });
try {
const config = await adminGetPrivatePortraitConfig(user.id);
portraitForm.setFieldsValue({ privatePortraitImageLimit: config.imageLimit });
setPortraitModal({ open: true, user, config });
} catch (e: any) {
message.error(e?.message || '加载真人素材库配置失败');
setPortraitModal({ open: false, user: null, config: null });
} finally {
setPortraitLoading(false);
}
};
const handleSavePortraitConfig = async () => {
const { user } = portraitModal;
if (!user) return;
try {
const values = await portraitForm.validateFields();
const limit = Number(values.privatePortraitImageLimit ?? 0);
setPortraitSaving(true);
const config = await adminUpdatePrivatePortraitConfig(user.id, 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 || '保存真人素材库配置失败');
} finally {
setPortraitSaving(false);
}
};
const openTeamModal = (user: AdminUser) => { const openTeamModal = (user: AdminUser) => {
teamForm.setFieldsValue({ teamId: user.teamId || '' }); teamForm.setFieldsValue({ teamId: user.teamId || '' });
setTeamModal({ open: true, user }); setTeamModal({ open: true, user });
@@ -380,6 +423,13 @@ const AdminUsers: React.FC = () => {
title: '团队', dataIndex: 'teamName', width: 140, title: '团队', dataIndex: 'teamName', width: 140,
render: (v: string | null | undefined) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary"></Typography.Text>, render: (v: string | null | undefined) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary"></Typography.Text>,
}] : []), }] : []),
...(!isAdminTab ? [{
title: '真人素材库', dataIndex: 'privatePortraitImageLimit', width: 150,
render: (v: number) => {
const limit = Number(v || 0);
return limit > 0 ? <Tag color="purple">{limit} </Tag> : <Tag></Tag>;
},
}] : []),
...(!isAdminTab ? [{ ...(!isAdminTab ? [{
title: '资源容量', dataIndex: 'resourceCapacity', width: 230, title: '资源容量', dataIndex: 'resourceCapacity', width: 230,
render: (capacity: ResourceCapacityUsage | null | undefined) => { render: (capacity: ResourceCapacityUsage | null | undefined) => {
@@ -425,7 +475,7 @@ const AdminUsers: React.FC = () => {
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>, render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
}, },
{ {
title: '操作', key: 'action', width: 460, fixed: 'right' as const, title: '操作', key: 'action', width: 540, fixed: 'right' as const,
render: (_: any, r: AdminUser) => ( render: (_: any, r: AdminUser) => (
<Space size={4} wrap> <Space size={4} wrap>
{!isAdminTab && ( {!isAdminTab && (
@@ -440,6 +490,12 @@ const AdminUsers: React.FC = () => {
</Button> </Button>
)} )}
{!isAdminTab && (
<Button type="link" size="small" icon={<PictureOutlined />}
onClick={() => openPortraitModal(r)}>
</Button>
)}
{!isAdminTab && ( {!isAdminTab && (
<Button type="link" size="small" icon={<TeamOutlined />} <Button type="link" size="small" icon={<TeamOutlined />}
onClick={() => openTeamModal(r)}> onClick={() => openTeamModal(r)}>
@@ -727,6 +783,37 @@ const AdminUsers: React.FC = () => {
</div> </div>
</Modal> </Modal>
<Modal
title={<Space><PictureOutlined /> - {portraitModal.user?.username}</Space>}
open={portraitModal.open}
confirmLoading={portraitSaving}
onOk={handleSavePortraitConfig}
onCancel={() => { setPortraitModal({ open: false, user: null, config: null }); portraitForm.resetFields(); }}
okText="保存" cancelText="取消" width={520}
>
<Card loading={portraitLoading} variant="outlined" style={{ marginBottom: 16 }}>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Typography.Text>
{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}
</Typography.Text>
</Space>
</Card>
<Form form={portraitForm} layout="vertical">
<Form.Item
name="privatePortraitImageLimit"
label="真人素材库图片上限"
extra="0 表示关闭真人素材库;大于 0 表示开启,并限制该用户所有真人素材图片总量。"
rules={[{ required: true, message: '请输入真人素材库图片上限' }]}
>
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" />
</Form.Item>
</Form>
</Modal>
<Modal <Modal
title={<Space><UserOutlined /></Space>} title={<Space><UserOutlined /></Space>}
open={createModal} open={createModal}
@@ -768,6 +855,17 @@ const AdminUsers: React.FC = () => {
<InputNumber min={0} style={{ width: '100%' }} size="large" /> <InputNumber min={0} style={{ width: '100%' }} size="large" />
</Form.Item> </Form.Item>
)} )}
{createType === 'frontend' && (
<Form.Item
name="private_portrait_image_limit"
label="真人素材库图片上限"
initialValue={5}
extra="0 表示关闭真人素材库;大于 0 表示开启并限制该用户所有真人素材图片总量。"
rules={[{ required: true, message: '请输入真人素材库图片上限' }]}
>
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" />
</Form.Item>
)}
<Form.Item name="email" label="邮箱"> <Form.Item name="email" label="邮箱">
<Input placeholder="选填" size="large" /> <Input placeholder="选填" size="large" />
</Form.Item> </Form.Item>
+97
View File
@@ -8,6 +8,7 @@ export interface User {
userType: string; userType: string;
allowedMenus?: string[] | null; allowedMenus?: string[] | null;
resourceCapacity?: ResourceCapacityUsage | null; resourceCapacity?: ResourceCapacityUsage | null;
privatePortraitImageLimit: number;
} }
export interface CreditRecord { export interface CreditRecord {
@@ -146,6 +147,8 @@ export interface AdminTeam {
memberCount: number; memberCount: number;
createdAt: string; createdAt: string;
updatedAt?: string | null; updatedAt?: string | null;
managerId?: string | null;
managerName?: string | null;
} }
export interface AdminTeamOption { export interface AdminTeamOption {
@@ -191,6 +194,7 @@ export interface AdminUser {
lastLoginAt?: string; lastLoginAt?: string;
allowedMenus?: string[] | null; allowedMenus?: string[] | null;
resourceCapacity?: ResourceCapacityUsage | null; resourceCapacity?: ResourceCapacityUsage | null;
privatePortraitImageLimit: number;
} }
export interface AdminStats { export interface AdminStats {
@@ -1117,3 +1121,96 @@ export interface HomeMaterialTextWatermarkPreviewResponse {
height: number; height: number;
previewLayerDataUrl: string; previewLayerDataUrl: string;
} }
export interface PrivatePortraitConfig {
enabled: boolean;
imageLimit: number;
usedImageCount: number;
remainingImageCount: number;
}
export interface PrivatePortraitProject {
id: string;
userId?: string | null;
name: string;
nameSlug?: string | null;
remoteProjectName?: string | null;
description?: string | null;
status: string;
assetGroupCount: number;
assetCount: number;
activeAssetCount: number;
lastUsedAt?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface PrivatePortraitProjectListOut {
items: PrivatePortraitProject[];
total: number;
page: number;
pageSize: number;
}
export interface PrivatePortraitValidateSession {
id: string;
projectId: string;
bytedToken?: string | null;
h5Link?: string | null;
callbackUrl?: string | null;
resultCode?: string | null;
status: string;
remoteGroupId?: string | null;
errorMessage?: string | null;
expiredAt?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface PrivatePortraitAsset {
id: string;
userId?: string | null;
projectId: string;
projectName?: string | null;
groupId: string;
remoteGroupId: string;
remoteAssetId?: string | null;
remoteProjectName?: string | null;
assetType: string;
name?: string | null;
sourceUrl: string;
previewUrl?: string | null;
remoteUrl?: string | null;
status: string;
pollCount: number;
remoteDeleteStatus: string;
errorMessage?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface PrivatePortraitAssetListOut {
items: PrivatePortraitAsset[];
total: number;
page: number;
pageSize: number;
}
export interface PrivatePortraitSelectableAsset {
id: string;
projectId: string;
projectName: string;
name?: string | null;
assetType: string;
previewUrl?: string | null;
status: string;
createdAt?: string | null;
}
export interface PrivatePortraitSelectableAssetListOut {
items: PrivatePortraitSelectableAsset[];
total: number;
page: number;
pageSize: number;
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/resourceurl.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"} {"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/resourceurl.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
@@ -0,0 +1,82 @@
"""add team manager and invite system
Revision ID: 20260706_20260706
Revises: 054735900c23
Create Date: 2026-07-06 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '20260706_20260706'
down_revision: Union[str, None] = '054735900c23'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# teams 表新增 manager_id 列
op.add_column('teams', sa.Column('manager_id', sa.String(length=32), nullable=True))
op.create_index(op.f('ix_teams_manager_id'), 'teams', ['manager_id'], unique=False)
op.create_foreign_key('fk_teams_manager_id', 'teams', 'users', ['manager_id'], ['id'])
# 邀请码表
op.create_table('team_invitations',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('team_id', sa.String(length=32), nullable=False),
sa.Column('code', sa.String(length=64), nullable=False),
sa.Column('created_by', sa.String(length=32), nullable=False),
sa.Column('status', sa.String(length=16), server_default='active', nullable=False),
sa.Column('max_uses', sa.Integer(), nullable=True),
sa.Column('use_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('code', name='uq_team_invitations_code')
)
op.create_index(op.f('ix_team_invitations_team_id'), 'team_invitations', ['team_id'], unique=False)
op.create_index(op.f('ix_team_invitations_code'), 'team_invitations', ['code'], unique=False)
op.create_index('ix_team_invitations_team', 'team_invitations', ['team_id', 'status'], unique=False)
# 加入申请表
op.create_table('team_join_requests',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('team_id', sa.String(length=32), nullable=False),
sa.Column('user_id', sa.String(length=32), nullable=False),
sa.Column('invitation_id', sa.String(length=32), nullable=True),
sa.Column('status', sa.String(length=16), server_default='pending', nullable=False),
sa.Column('note', sa.String(length=256), nullable=True),
sa.Column('handled_by', sa.String(length=32), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['invitation_id'], ['team_invitations.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_team_join_requests_team_id'), 'team_join_requests', ['team_id'], unique=False)
op.create_index(op.f('ix_team_join_requests_user_id'), 'team_join_requests', ['user_id'], unique=False)
op.create_index('ix_tjr_team_status', 'team_join_requests', ['team_id', 'status'], unique=False)
def downgrade() -> None:
op.drop_index('ix_tjr_team_status', table_name='team_join_requests')
op.drop_index(op.f('ix_team_join_requests_user_id'), table_name='team_join_requests')
op.drop_index(op.f('ix_team_join_requests_team_id'), table_name='team_join_requests')
op.drop_table('team_join_requests')
op.drop_index('ix_team_invitations_team', table_name='team_invitations')
op.drop_index(op.f('ix_team_invitations_code'), table_name='team_invitations')
op.drop_index(op.f('ix_team_invitations_team_id'), table_name='team_invitations')
op.drop_table('team_invitations')
op.drop_constraint('fk_teams_manager_id', 'teams', type_='foreignkey')
op.drop_index(op.f('ix_teams_manager_id'), table_name='teams')
op.drop_column('teams', 'manager_id')
@@ -0,0 +1,228 @@
"""add private portrait assets
Revision ID: 6fc75582f6f9
Revises: 054735900c23
Create Date: 2026-07-06 14:48:50.946515
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '6fc75582f6f9'
down_revision: Union[str, None] = '20260706_20260706'
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_table('private_portrait_projects',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('user_id', sa.String(length=32), nullable=False),
sa.Column('name', sa.String(length=128), nullable=False, comment='用户展示项目名'),
sa.Column('name_slug', sa.String(length=128), nullable=False, comment='项目名安全 slug'),
sa.Column('remote_project_name', sa.String(length=256), nullable=False, comment='火山 ProjectName 快照'),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('status', sa.String(length=32), server_default='active', nullable=False),
sa.Column('asset_group_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('asset_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('active_asset_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_private_portrait_projects_remote_project_name', 'private_portrait_projects', ['remote_project_name'], unique=False)
op.create_index('idx_private_portrait_projects_user_deleted', 'private_portrait_projects', ['user_id', 'deleted_at'], unique=False, postgresql_where=sa.text('deleted_at IS NULL'))
op.create_index('idx_private_portrait_projects_user_status_created', 'private_portrait_projects', ['user_id', 'status', 'created_at'], unique=False)
op.create_index(op.f('ix_private_portrait_projects_deleted_at'), 'private_portrait_projects', ['deleted_at'], unique=False)
op.create_index(op.f('ix_private_portrait_projects_name'), 'private_portrait_projects', ['name'], unique=False)
op.create_index(op.f('ix_private_portrait_projects_name_slug'), 'private_portrait_projects', ['name_slug'], unique=False)
op.create_index(op.f('ix_private_portrait_projects_remote_project_name'), 'private_portrait_projects', ['remote_project_name'], unique=False)
op.create_index(op.f('ix_private_portrait_projects_status'), 'private_portrait_projects', ['status'], unique=False)
op.create_index(op.f('ix_private_portrait_projects_user_id'), 'private_portrait_projects', ['user_id'], unique=False)
op.create_table('private_portrait_asset_groups',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('user_id', sa.String(length=32), nullable=False),
sa.Column('project_id', sa.String(length=32), nullable=False),
sa.Column('remote_group_id', sa.String(length=128), nullable=False),
sa.Column('remote_group_name', sa.String(length=128), nullable=True),
sa.Column('remote_project_name', sa.String(length=256), nullable=False),
sa.Column('group_type', sa.String(length=32), server_default='LivenessFace', nullable=False),
sa.Column('status', sa.String(length=32), server_default='active', nullable=False),
sa.Column('remote_delete_status', sa.String(length=32), server_default='none', nullable=False),
sa.Column('remote_deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('remote_delete_error', sa.Text(), nullable=True),
sa.Column('raw_response_json', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['project_id'], ['private_portrait_projects.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_private_portrait_asset_groups_project_status', 'private_portrait_asset_groups', ['project_id', 'status'], unique=False)
op.create_index('idx_private_portrait_asset_groups_remote_delete_status', 'private_portrait_asset_groups', ['remote_delete_status'], unique=False)
op.create_index('idx_private_portrait_asset_groups_remote_project_name', 'private_portrait_asset_groups', ['remote_project_name'], unique=False)
op.create_index('idx_private_portrait_asset_groups_user_project', 'private_portrait_asset_groups', ['user_id', 'project_id'], unique=False)
op.create_index(op.f('ix_private_portrait_asset_groups_deleted_at'), 'private_portrait_asset_groups', ['deleted_at'], unique=False)
op.create_index(op.f('ix_private_portrait_asset_groups_project_id'), 'private_portrait_asset_groups', ['project_id'], unique=False)
op.create_index(op.f('ix_private_portrait_asset_groups_remote_delete_status'), 'private_portrait_asset_groups', ['remote_delete_status'], unique=False)
op.create_index(op.f('ix_private_portrait_asset_groups_remote_group_id'), 'private_portrait_asset_groups', ['remote_group_id'], unique=False)
op.create_index(op.f('ix_private_portrait_asset_groups_remote_group_name'), 'private_portrait_asset_groups', ['remote_group_name'], unique=False)
op.create_index(op.f('ix_private_portrait_asset_groups_remote_project_name'), 'private_portrait_asset_groups', ['remote_project_name'], unique=False)
op.create_index(op.f('ix_private_portrait_asset_groups_status'), 'private_portrait_asset_groups', ['status'], unique=False)
op.create_index(op.f('ix_private_portrait_asset_groups_user_id'), 'private_portrait_asset_groups', ['user_id'], unique=False)
op.create_index('uq_private_portrait_asset_groups_remote_group_id', 'private_portrait_asset_groups', ['remote_group_id'], unique=True)
op.create_table('private_portrait_validate_sessions',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('user_id', sa.String(length=32), nullable=False),
sa.Column('project_id', sa.String(length=32), nullable=False),
sa.Column('byted_token', sa.String(length=256), nullable=True),
sa.Column('h5_link', sa.Text(), nullable=True),
sa.Column('callback_url', sa.Text(), nullable=True),
sa.Column('result_code', sa.String(length=32), nullable=True),
sa.Column('algorithm_base_resp_code', sa.String(length=64), nullable=True),
sa.Column('verify_type', sa.String(length=32), nullable=True),
sa.Column('status', sa.String(length=32), server_default='created', nullable=False),
sa.Column('remote_group_id', sa.String(length=128), nullable=True),
sa.Column('remote_project_name', sa.String(length=256), nullable=False),
sa.Column('expired_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('raw_callback_json', sa.Text(), nullable=True),
sa.Column('raw_response_json', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['private_portrait_projects.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_private_portrait_validate_sessions_byted_token', 'private_portrait_validate_sessions', ['byted_token'], unique=False)
op.create_index('idx_private_portrait_validate_sessions_status_created', 'private_portrait_validate_sessions', ['status', 'created_at'], unique=False)
op.create_index('idx_private_portrait_validate_sessions_user_project', 'private_portrait_validate_sessions', ['user_id', 'project_id'], unique=False)
op.create_index(op.f('ix_private_portrait_validate_sessions_byted_token'), 'private_portrait_validate_sessions', ['byted_token'], unique=False)
op.create_index(op.f('ix_private_portrait_validate_sessions_project_id'), 'private_portrait_validate_sessions', ['project_id'], unique=False)
op.create_index(op.f('ix_private_portrait_validate_sessions_remote_group_id'), 'private_portrait_validate_sessions', ['remote_group_id'], unique=False)
op.create_index(op.f('ix_private_portrait_validate_sessions_remote_project_name'), 'private_portrait_validate_sessions', ['remote_project_name'], unique=False)
op.create_index(op.f('ix_private_portrait_validate_sessions_result_code'), 'private_portrait_validate_sessions', ['result_code'], unique=False)
op.create_index(op.f('ix_private_portrait_validate_sessions_status'), 'private_portrait_validate_sessions', ['status'], unique=False)
op.create_index(op.f('ix_private_portrait_validate_sessions_user_id'), 'private_portrait_validate_sessions', ['user_id'], unique=False)
op.create_table('private_portrait_assets',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('user_id', sa.String(length=32), nullable=False),
sa.Column('project_id', sa.String(length=32), nullable=False),
sa.Column('group_id', sa.String(length=32), nullable=False),
sa.Column('remote_group_id', sa.String(length=128), nullable=False),
sa.Column('remote_asset_id', sa.String(length=128), nullable=True),
sa.Column('remote_project_name', sa.String(length=256), nullable=False),
sa.Column('asset_type', sa.String(length=16), nullable=False),
sa.Column('name', sa.String(length=128), nullable=True),
sa.Column('source_url', sa.Text(), nullable=False),
sa.Column('preview_url', sa.Text(), nullable=True),
sa.Column('remote_url', sa.Text(), nullable=True),
sa.Column('remote_url_expired_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('status', sa.String(length=32), server_default='creating', nullable=False),
sa.Column('moderation_json', sa.Text(), nullable=True),
sa.Column('last_poll_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('next_poll_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('poll_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('remote_delete_status', sa.String(length=32), server_default='none', nullable=False),
sa.Column('remote_deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('remote_delete_error', sa.Text(), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('raw_response_json', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['group_id'], ['private_portrait_asset_groups.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['project_id'], ['private_portrait_projects.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_private_portrait_assets_group_status_created', 'private_portrait_assets', ['group_id', 'status', 'created_at'], unique=False)
op.create_index('idx_private_portrait_assets_next_poll_status', 'private_portrait_assets', ['next_poll_at', 'status'], unique=False, postgresql_where=sa.text('deleted_at IS NULL AND next_poll_at IS NOT NULL'))
op.create_index('idx_private_portrait_assets_project_status_created', 'private_portrait_assets', ['project_id', 'status', 'created_at'], unique=False)
op.create_index('idx_private_portrait_assets_remote_delete_status', 'private_portrait_assets', ['remote_delete_status'], unique=False)
op.create_index('idx_private_portrait_assets_remote_project_name', 'private_portrait_assets', ['remote_project_name'], unique=False)
op.create_index('idx_private_portrait_assets_user_status_created', 'private_portrait_assets', ['user_id', 'status', 'created_at'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_asset_type'), 'private_portrait_assets', ['asset_type'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_deleted_at'), 'private_portrait_assets', ['deleted_at'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_group_id'), 'private_portrait_assets', ['group_id'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_name'), 'private_portrait_assets', ['name'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_next_poll_at'), 'private_portrait_assets', ['next_poll_at'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_project_id'), 'private_portrait_assets', ['project_id'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_remote_asset_id'), 'private_portrait_assets', ['remote_asset_id'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_remote_delete_status'), 'private_portrait_assets', ['remote_delete_status'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_remote_group_id'), 'private_portrait_assets', ['remote_group_id'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_remote_project_name'), 'private_portrait_assets', ['remote_project_name'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_status'), 'private_portrait_assets', ['status'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_user_id'), 'private_portrait_assets', ['user_id'], unique=False)
op.create_index('uq_private_portrait_assets_remote_asset_id', 'private_portrait_assets', ['remote_asset_id'], unique=True)
op.add_column('users', sa.Column('private_portrait_image_limit', sa.Integer(), server_default='5', nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'private_portrait_image_limit')
op.drop_index('uq_private_portrait_assets_remote_asset_id', table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_user_id'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_status'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_remote_project_name'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_remote_group_id'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_remote_delete_status'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_remote_asset_id'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_project_id'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_next_poll_at'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_name'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_group_id'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_deleted_at'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_asset_type'), table_name='private_portrait_assets')
op.drop_index('idx_private_portrait_assets_user_status_created', table_name='private_portrait_assets')
op.drop_index('idx_private_portrait_assets_remote_project_name', table_name='private_portrait_assets')
op.drop_index('idx_private_portrait_assets_remote_delete_status', table_name='private_portrait_assets')
op.drop_index('idx_private_portrait_assets_project_status_created', table_name='private_portrait_assets')
op.drop_index('idx_private_portrait_assets_next_poll_status', table_name='private_portrait_assets', postgresql_where=sa.text('deleted_at IS NULL AND next_poll_at IS NOT NULL'))
op.drop_index('idx_private_portrait_assets_group_status_created', table_name='private_portrait_assets')
op.drop_table('private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_validate_sessions_user_id'), table_name='private_portrait_validate_sessions')
op.drop_index(op.f('ix_private_portrait_validate_sessions_status'), table_name='private_portrait_validate_sessions')
op.drop_index(op.f('ix_private_portrait_validate_sessions_result_code'), table_name='private_portrait_validate_sessions')
op.drop_index(op.f('ix_private_portrait_validate_sessions_remote_project_name'), table_name='private_portrait_validate_sessions')
op.drop_index(op.f('ix_private_portrait_validate_sessions_remote_group_id'), table_name='private_portrait_validate_sessions')
op.drop_index(op.f('ix_private_portrait_validate_sessions_project_id'), table_name='private_portrait_validate_sessions')
op.drop_index(op.f('ix_private_portrait_validate_sessions_byted_token'), table_name='private_portrait_validate_sessions')
op.drop_index('idx_private_portrait_validate_sessions_user_project', table_name='private_portrait_validate_sessions')
op.drop_index('idx_private_portrait_validate_sessions_status_created', table_name='private_portrait_validate_sessions')
op.drop_index('idx_private_portrait_validate_sessions_byted_token', table_name='private_portrait_validate_sessions')
op.drop_table('private_portrait_validate_sessions')
op.drop_index('uq_private_portrait_asset_groups_remote_group_id', table_name='private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_asset_groups_user_id'), table_name='private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_asset_groups_status'), table_name='private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_asset_groups_remote_project_name'), table_name='private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_asset_groups_remote_group_name'), table_name='private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_asset_groups_remote_group_id'), table_name='private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_asset_groups_remote_delete_status'), table_name='private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_asset_groups_project_id'), table_name='private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_asset_groups_deleted_at'), table_name='private_portrait_asset_groups')
op.drop_index('idx_private_portrait_asset_groups_user_project', table_name='private_portrait_asset_groups')
op.drop_index('idx_private_portrait_asset_groups_remote_project_name', table_name='private_portrait_asset_groups')
op.drop_index('idx_private_portrait_asset_groups_remote_delete_status', table_name='private_portrait_asset_groups')
op.drop_index('idx_private_portrait_asset_groups_project_status', table_name='private_portrait_asset_groups')
op.drop_table('private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_projects_user_id'), table_name='private_portrait_projects')
op.drop_index(op.f('ix_private_portrait_projects_status'), table_name='private_portrait_projects')
op.drop_index(op.f('ix_private_portrait_projects_remote_project_name'), table_name='private_portrait_projects')
op.drop_index(op.f('ix_private_portrait_projects_name_slug'), table_name='private_portrait_projects')
op.drop_index(op.f('ix_private_portrait_projects_name'), table_name='private_portrait_projects')
op.drop_index(op.f('ix_private_portrait_projects_deleted_at'), table_name='private_portrait_projects')
op.drop_index('idx_private_portrait_projects_user_status_created', table_name='private_portrait_projects')
op.drop_index('idx_private_portrait_projects_user_deleted', table_name='private_portrait_projects', postgresql_where=sa.text('deleted_at IS NULL'))
op.drop_index('idx_private_portrait_projects_remote_project_name', table_name='private_portrait_projects')
op.drop_table('private_portrait_projects')
# ### end Alembic commands ###
+2
View File
@@ -4,9 +4,11 @@ from app.api.admin.video_prompt_schema_config import router as video_prompt_sche
from app.api.admin.resource_capacity import router as resource_capacity_router from app.api.admin.resource_capacity import router as resource_capacity_router
from app.api.admin.team import router as team_router from app.api.admin.team import router as team_router
from app.api.admin.home_material import router as home_material_router from app.api.admin.home_material import router as home_material_router
from app.api.admin.private_portrait import router as private_portrait_router
router = APIRouter() router = APIRouter()
router.include_router(video_prompt_schema_config_router) router.include_router(video_prompt_schema_config_router)
router.include_router(resource_capacity_router) router.include_router(resource_capacity_router)
router.include_router(team_router) router.include_router(team_router)
router.include_router(home_material_router) router.include_router(home_material_router)
router.include_router(private_portrait_router)
@@ -0,0 +1,136 @@
from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.user import User
from app.schemas.private_portrait import (
PrivatePortraitAdminConfigUpdate,
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
router = APIRouter(prefix="/admin/private-portrait", tags=["admin-private-portrait"])
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),
):
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")}),
)
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),
):
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)
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"),
}
),
)
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("/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)
+73 -26
View File
@@ -2,18 +2,57 @@ from __future__ import annotations
import json import json
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Body, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db from app.dependencies import get_admin_user, get_db
from app.models.team import Team
from app.models.user import User from app.models.user import User
from app.schemas.team import TeamCreate, TeamListOut, TeamOptionOut, TeamOut, TeamUpdate from app.schemas.team import TeamCreate, TeamListOut, TeamOptionOut, TeamUpdate
from app.schemas.team_manager import SetManagerRequest
from app.services.operation_log import log_operation from app.services.operation_log import log_operation
from app.services.team_manager_service import set_team_manager
from app.services.team_service import create_team, list_team_options, list_teams, soft_delete_team, update_team from app.services.team_service import create_team, list_team_options, list_teams, soft_delete_team, update_team
router = APIRouter(prefix="/admin/teams", tags=["admin-teams"]) router = APIRouter(prefix="/admin/teams", tags=["admin-teams"])
async def _team_detail_payload(db: AsyncSession, team: Team) -> dict:
"""构造返回团队详情,包含 manager_name。"""
payload = {
"id": team.id,
"name": team.name,
"code": getattr(team, "code", None),
"description": getattr(team, "description", None),
"status": getattr(team, "status", "active"),
"sort_order": getattr(team, "sort_order", 0) or 0,
"member_count": 0,
"created_at": team.created_at,
"updated_at": team.updated_at,
"manager_id": getattr(team, "manager_id", None),
"manager_name": None,
}
# 查询成员数和管理人用户名
from sqlalchemy import func
from app.enums.user import UserType
member_count = (await db.execute(
select(func.count(User.id)).where(
User.user_type == UserType.FRONTEND.value,
User.team_id == team.id,
)
)).scalar() or 0
payload["member_count"] = int(member_count)
if getattr(team, "manager_id", None):
mgr = await db.execute(
select(User.username).where(User.id == team.manager_id).limit(1)
)
payload["manager_name"] = mgr.scalar_one_or_none()
return payload
@router.get("", response_model=TeamListOut) @router.get("", response_model=TeamListOut)
async def list_admin_teams( async def list_admin_teams(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
@@ -35,7 +74,7 @@ async def list_admin_team_options(
return await list_team_options(db, include_disabled=include_disabled) return await list_team_options(db, include_disabled=include_disabled)
@router.post("", response_model=TeamOut) @router.post("", )
async def create_admin_team( async def create_admin_team(
req: TeamCreate, req: TeamCreate,
admin: User = Depends(get_admin_user), admin: User = Depends(get_admin_user),
@@ -51,20 +90,10 @@ async def create_admin_team(
"/admin/teams", "/admin/teams",
detail=json.dumps({"after": req.model_dump()}, ensure_ascii=False), detail=json.dumps({"after": req.model_dump()}, ensure_ascii=False),
) )
return { return await _team_detail_payload(db, team)
"id": team.id,
"name": team.name,
"code": team.code,
"description": team.description,
"status": team.status,
"sort_order": team.sort_order,
"member_count": 0,
"created_at": team.created_at,
"updated_at": team.updated_at,
}
@router.put("/{team_id}", response_model=TeamOut) @router.put("/{team_id}", )
async def update_admin_team( async def update_admin_team(
team_id: str, team_id: str,
req: TeamUpdate, req: TeamUpdate,
@@ -81,17 +110,35 @@ async def update_admin_team(
f"/admin/teams/{team_id}", f"/admin/teams/{team_id}",
detail=json.dumps({"before": before, "after": after}, ensure_ascii=False), detail=json.dumps({"before": before, "after": after}, ensure_ascii=False),
) )
return { return await _team_detail_payload(db, team)
"id": team.id,
"name": team.name,
"code": team.code, @router.put("/{team_id}/manager", )
"description": team.description, async def set_team_manager_endpoint(
"status": team.status, team_id: str,
"sort_order": team.sort_order, req: SetManagerRequest = Body(...),
"member_count": 0, admin: User = Depends(get_admin_user),
"created_at": team.created_at, db: AsyncSession = Depends(get_db),
"updated_at": team.updated_at, ):
} team = await set_team_manager(db, team_id, req.user_id)
manager_name = None
# 使用 req.user_id 避免访问 team.manager_id 触发懒加载
if req.user_id:
mgr = await db.execute(
select(User.username).where(User.id == req.user_id).limit(1)
)
manager_name = mgr.scalar_one_or_none()
team_name = team.name
await log_operation(
db,
admin.id,
admin.username,
f"设置团队管理人 {team_name}: {manager_name or '取消'}",
"PUT",
f"/admin/teams/{team_id}/manager",
detail=json.dumps({"manager_id": req.user_id}, ensure_ascii=False),
)
return await _team_detail_payload(db, team)
@router.delete("/{team_id}") @router.delete("/{team_id}")
+4
View File
@@ -28,9 +28,11 @@ from app.api.v1.material_consumption import router as material_consumption_route
from app.api.v1.open_type import router as open_type_router from app.api.v1.open_type import router as open_type_router
from app.api.v1.resources_material import router as resources_material_router from app.api.v1.resources_material import router as resources_material_router
from app.api.v1.contact import router as contact_router from app.api.v1.contact import router as contact_router
from app.api.v1.team import router as team_router
from app.api.v1.home_materials import router as home_materials_router from app.api.v1.home_materials import router as home_materials_router
from app.api.admin import router as admin_module_router from app.api.admin import router as admin_module_router
from app.api.v1.material_admin import router as material_admin_router from app.api.v1.material_admin import router as material_admin_router
from app.api.v1.private_portrait import router as private_portrait_router
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth_router) api_router.include_router(auth_router)
@@ -61,6 +63,8 @@ api_router.include_router(material_consumption_router)
api_router.include_router(open_type_router) api_router.include_router(open_type_router)
api_router.include_router(resources_material_router) api_router.include_router(resources_material_router)
api_router.include_router(contact_router) api_router.include_router(contact_router)
api_router.include_router(team_router)
api_router.include_router(home_materials_router) api_router.include_router(home_materials_router)
api_router.include_router(admin_module_router) api_router.include_router(admin_module_router)
api_router.include_router(material_admin_router) api_router.include_router(material_admin_router)
api_router.include_router(private_portrait_router)
+24 -1
View File
@@ -173,11 +173,34 @@ async def create_user(
user_type=req.user_type, user_type=req.user_type,
frontend_user_kind=req.frontend_user_kind if req.user_type == "frontend" else FrontendUserKind.EXTERNAL.value, frontend_user_kind=req.frontend_user_kind if req.user_type == "frontend" else FrontendUserKind.EXTERNAL.value,
allowed_menus=req.allowed_menus, allowed_menus=req.allowed_menus,
private_portrait_image_limit=req.private_portrait_image_limit,
) )
user.credits = round(user.credits, 2) user.credits = round(user.credits, 2)
db.add(user) db.add(user)
await db.flush() await db.flush()
await log_operation(db, admin.id, admin.username, f"创建用户 {username}", "POST", "/admin/users", ip=None) await log_operation(
db,
admin.id,
admin.username,
f"创建用户 {username}",
"POST",
"/admin/users",
detail=json.dumps(
{
"user_id": user.id,
"username": username,
"user_type": req.user_type,
"frontend_user_kind": user.frontend_user_kind,
"private_portrait_image_limit": user.private_portrait_image_limit,
"credits": user.credits,
"phone": user.phone,
"email": user.email,
},
ensure_ascii=False,
default=str,
),
ip=None,
)
return user return user
+17 -1
View File
@@ -256,8 +256,24 @@ async def get_me(
current_user.username = "用户"+current_user.username[-4:] if current_user.username == current_user.phone else current_user.username current_user.username = "用户"+current_user.username[-4:] if current_user.username == current_user.phone else current_user.username
current_user.credits = round(current_user.credits, 2) current_user.credits = round(current_user.credits, 2)
resource_capacity = await get_user_resource_capacity_usage(db, current_user.id) resource_capacity = await get_user_resource_capacity_usage(db, current_user.id)
# 计算 is_team_manager 和 team_name
is_team_manager = False
team_name = None
if current_user.team_id:
from app.services.team_manager_service import is_team_manager
from app.services.team_service import batch_get_team_name_map
is_team_manager = await is_team_manager(db, current_user.id, current_user.team_id)
name_map = await batch_get_team_name_map(db, [current_user.team_id])
team_name = name_map.get(current_user.team_id)
return UserOut.model_validate(current_user).model_copy( return UserOut.model_validate(current_user).model_copy(
update={"resource_capacity": resource_capacity} update={
"resource_capacity": resource_capacity,
"is_team_manager": is_team_manager,
"team_id": current_user.team_id,
"team_name": team_name,
}
) )
@@ -0,0 +1,233 @@
from __future__ import annotations
from urllib.parse import unquote
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import RedirectResponse
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,
PrivatePortraitRemoteDeleteStatus,
)
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitProject
from app.models.user import User
from app.schemas.private_portrait import (
PrivatePortraitAssetCreate,
PrivatePortraitAssetListOut,
PrivatePortraitDeleteOut,
PrivatePortraitConfigOut,
PrivatePortraitProjectCreate,
PrivatePortraitProjectListOut,
PrivatePortraitProjectOut,
PrivatePortraitProjectUpdate,
PrivatePortraitSelectableAssetListOut,
PrivatePortraitValidateSessionCreate,
PrivatePortraitValidateSessionOut,
)
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,
list_assets,
list_selectable_assets,
soft_delete_asset,
sync_asset_status,
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,
)
router = APIRouter(tags=["private-portrait"])
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/config", response_model=PrivatePortraitConfigOut)
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)
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)
await db.commit()
return out
@router.get("/private-portrait/projects", response_model=PrivatePortraitProjectListOut)
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),
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)
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)
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)
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))
@router.put("/private-portrait/projects/{project_id}", response_model=PrivatePortraitProjectOut)
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)
out = project_to_out(project)
await db.commit()
return out
@router.delete("/private-portrait/projects/{project_id}", response_model=PrivatePortraitDeleteOut)
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_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/projects/{project_id}/validate-sessions", response_model=PrivatePortraitValidateSessionOut)
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)
out = validate_session_to_out(session)
await db.commit()
return out
@router.get("/private-portrait/validate-sessions/{session_id}", response_model=PrivatePortraitValidateSessionOut)
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")
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 ""
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)
return response
@router.post("/private-portrait/projects/{project_id}/assets")
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_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/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)
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}")
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()
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/assets/{asset_id}/sync")
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)
out = asset_to_out(asset)
await db.commit()
return out
@router.delete("/private-portrait/assets/{asset_id}", response_model=PrivatePortraitDeleteOut)
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_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/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)
return PrivatePortraitSelectableAssetListOut(items=items, total=total, page=page, page_size=page_size)
+381
View File
@@ -0,0 +1,381 @@
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
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.models.team import Team
from app.models.team_invitation import TeamInvitation
from app.models.team_join_request import TeamJoinRequest
from app.models.user import User
from app.schemas.team_invitation import TeamInvitationCreate, TeamInvitationOut
from app.schemas.team_join_request import (
JoinByCodeRequest,
JoinRequestHandle,
JoinRequestOut,
JoinTeamInfoOut,
)
from app.schemas.team_manager import (
ManagedTeamOut,
ManagerTransferRequest,
SetManagerRequest,
TeamMemberOut,
)
from app.services import team_invitation_service
from app.services.team_manager_service import (
get_managed_team,
get_team_members,
is_team_manager,
transfer_credits_to_member,
)
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(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=404, detail="您不是任何团队的管理人")
from sqlalchemy import func
from app.enums.user import UserType
member_count = (await db.execute(
select(func.count(User.id)).where(
User.user_type == UserType.FRONTEND.value,
User.team_id == team.id,
)
)).scalar() or 0
return {
"id": team.id,
"name": team.name,
"code": team.code,
"description": team.description,
"status": team.status,
"member_count": int(member_count),
"manager_id": team.manager_id,
"manager_name": current_user.username,
}
# ── 团队成员列表 ──────────────────────────────────────
@router.get("/members")
async def list_team_members(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
return await get_team_members(db, team.id, page=page, page_size=page_size)
# ── 转账积分给成员 ────────────────────────────────────
@router.post("/members/{member_id}/credits")
async def transfer_credits(
member_id: str,
req: ManagerTransferRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await transfer_credits_to_member(
db,
current_user.id,
req.target_user_id,
req.amount,
req.direction or "increase",
req.description,
)
return {"message": "ok"}
# ── 邀请码管理 ────────────────────────────────────────
@router.post("/invitations", )
async def create_invitation(
req: TeamInvitationCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可创建邀请码")
expires_at = None
if req.expires_at:
try:
expires_at = datetime.fromisoformat(req.expires_at)
except ValueError:
raise HTTPException(status_code=400, detail="过期时间格式错误")
invitation = await team_invitation_service.create_invitation(
db, team.id, current_user.id, req.max_uses, expires_at
)
return {
"id": invitation.id,
"team_id": invitation.team_id,
"code": invitation.code,
"status": invitation.status,
"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,
}
@router.get("/invitations", response_model=list[TeamInvitationOut])
async def list_invitations(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
invitations = await team_invitation_service.get_invitations_for_team(db, team.id)
return [
{
"id": inv.id,
"team_id": inv.team_id,
"code": inv.code,
"status": inv.status,
"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
]
@router.delete("/invitations/{invitation_id}")
async def revoke_invitation(
invitation_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await team_invitation_service.revoke_invitation(db, invitation_id, current_user.id)
return {"message": "ok"}
# ── 加入申请 ──────────────────────────────────────────
@router.post("/join")
async def join_by_code(
req: JoinByCodeRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await team_invitation_service.create_join_request(db, current_user.id, req.invitation_code)
return {"message": "申请已提交,请等待团队管理人审批"}
@router.get("/join-info", )
async def get_join_info(
code: str = Query(...),
current_user: User = Depends(get_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)
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
return JoinTeamInfoOut(
team_name=team_name,
team_id=invitation.team_id,
valid=True,
already_in_team=already_in_team,
)
@router.get("/join-requests", )
async def list_join_requests(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
requests = await team_invitation_service.get_pending_requests(db, team.id)
# 获取团队名
team_name_result = await db.execute(
select(Team.name).where(Team.id == team.id).limit(1)
)
team_name = team_name_result.scalar_one_or_none() or ""
return [
JoinRequestOut(
id=r["id"],
team_id=r["team_id"],
team_name=team_name,
user_id=r["user_id"],
username=r["username"],
phone=r.get("phone"),
status=r["status"],
note=r.get("note"),
created_at=r.get("created_at"),
)
for r in requests
]
@router.post("/join-requests/{request_id}")
async def handle_join_request(
request_id: str,
req: JoinRequestHandle,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await team_invitation_service.handle_join_request(
db, request_id, current_user.id, req.action, req.note
)
return {"message": "ok"}
# ── 团队积分变动记录 ────────────────────────────────────
@router.get("/credit-records")
async def list_team_credit_records(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user_id: str | None = Query(None),
phone: str | None = Query(None, description="按手机号搜索"),
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal)$", description="流水类型"),
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD"),
end_date: str | None = Query(None, description="截止日期 YYYY-MM-DD"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""查看团队所有成员的积分变动记录(仅管理人)。"""
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
from app.services.admin_credit_record_service import list_admin_credit_records
# 如果传了 phone,先找到对应的 user_id
resolved_user_id = user_id
if phone and not user_id:
phone_result = await db.execute(
select(User.id).where(
User.team_id == team.id,
User.phone == phone,
User.is_active.is_(True),
).limit(1)
)
resolved_user_id = phone_result.scalar_one_or_none()
if not resolved_user_id:
return {"items": [], "total": 0, "summary": {}}
return await list_admin_credit_records(
db,
page=page,
page_size=page_size,
team_id=team.id,
user_id=resolved_user_id,
record_type=record_type,
start_date=start_date,
end_date=end_date,
)
# ── 团队积分导出 Excel ──────────────────────────────────
@router.get("/credit-records/export")
async def export_team_credit_records(
user_id: str | None = Query(None),
phone: str | None = Query(None),
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal)$", description="流水类型"),
start_date: str | None = Query(None),
end_date: str | None = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""导出团队积分变动记录为 Excel(仅管理人)。"""
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
from app.services.admin_credit_record_service import list_admin_credit_records
resolved_user_id = user_id
if phone and not user_id:
phone_result = await db.execute(
select(User.id).where(
User.team_id == team.id,
User.phone == phone,
User.is_active.is_(True),
).limit(1)
)
resolved_user_id = phone_result.scalar_one_or_none()
# 拉取全部记录(不分页)
result = await list_admin_credit_records(
db,
page=1,
page_size=10000,
team_id=team.id,
user_id=resolved_user_id,
record_type=record_type,
start_date=start_date,
end_date=end_date,
)
# 生成 CSV(兼容 Excel 打开)
import csv
import io
def _format_dt(val):
if val is None:
return "-"
return str(datetime.fromtimestamp(val).strftime("%Y-%m-%d %H:%M:%S"))
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["用户名", "手机号", "类型", "积分变动", "余额", "说明", "时间"])
for item in result.get("items", []):
writer.writerow([
item.get("username") or "-",
item.get("phone") or "-",
item.get("record_type_label") or item.get("type") or "-",
item.get("amount", 0),
item.get("balance_after", 0),
item.get("description") or "-",
_format_dt(item.get("created_at")),
])
from starlette.responses import StreamingResponse
from urllib.parse import quote
output.seek(0)
safe_team_name = team.name or "team"
filename = f"团队积分_{safe_team_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
encoded_filename = quote(filename)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv; charset=utf-8-sig",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"},
)
+2
View File
@@ -16,3 +16,5 @@ from app.enums.team import *
from app.enums.home_material import * from app.enums.home_material import *
from app.enums.celery_queue import * from app.enums.celery_queue import *
from app.enums.audio_reference import * from app.enums.audio_reference import *
from app.enums.private_portrait import *
+6
View File
@@ -6,6 +6,7 @@ class CeleryQueue(str, Enum):
GEN_PROVIDER_POLL = "gen_provider_poll" GEN_PROVIDER_POLL = "gen_provider_poll"
GEN_RESULT_DOWNLOAD = "gen_result_download" GEN_RESULT_DOWNLOAD = "gen_result_download"
GEN_RECOVERY = "gen_recovery" GEN_RECOVERY = "gen_recovery"
GEN_PRIVATE_PORTRAIT = "gen_private_portrait"
DEFAULT = "default" DEFAULT = "default"
@@ -19,3 +20,8 @@ class CeleryTaskName(str, Enum):
STARTUP_RECOVERY = "recovery.startup_recovery_once" STARTUP_RECOVERY = "recovery.startup_recovery_once"
MODULE_ASYNC_RECOVERY = "module_async.recover_module_async_tasks_once" MODULE_ASYNC_RECOVERY = "module_async.recover_module_async_tasks_once"
SHOT_SPLIT_RECOVERY = "shot_replicate.recover_split_tasks_once" SHOT_SPLIT_RECOVERY = "shot_replicate.recover_split_tasks_once"
PRIVATE_PORTRAIT_POLL_ASSET = "private_portrait.poll_asset_status"
PRIVATE_PORTRAIT_SYNC_DUE_ASSETS = "private_portrait.sync_due_assets"
PRIVATE_PORTRAIT_DELETE_ASSET = "private_portrait.delete_asset_remote"
PRIVATE_PORTRAIT_DELETE_PROJECT = "private_portrait.delete_project_remote"
PRIVATE_PORTRAIT_RECOVER_REMOTE_DELETES = "private_portrait.recover_remote_deletes"
+10
View File
@@ -5,6 +5,7 @@ class CreditRecordType(str, Enum):
RECHARGE = "recharge" RECHARGE = "recharge"
CONSUME = "consume" CONSUME = "consume"
REFUND = "refund" REFUND = "refund"
TEAM_INTERNAL = "team_internal" # 团队内部积分流转(管理人分配)
class CreditRecordOwnerType(str, Enum): class CreditRecordOwnerType(str, Enum):
@@ -30,6 +31,7 @@ class CreditRecordChargeKind(str, Enum):
RECHARGE = "recharge" RECHARGE = "recharge"
REFUND = "refund" REFUND = "refund"
ADMIN_ADJUST = "admin_adjust" ADMIN_ADJUST = "admin_adjust"
TEAM_INTERNAL = "team_internal"
UNKNOWN = "unknown" UNKNOWN = "unknown"
@@ -42,6 +44,7 @@ class CreditRecordSubject(str, Enum):
RECHARGE = "recharge" RECHARGE = "recharge"
REFUND = "refund" REFUND = "refund"
ADMIN_ADJUST = "admin_adjust" ADMIN_ADJUST = "admin_adjust"
TEAM_INTERNAL = "team_internal"
UNKNOWN = "unknown" UNKNOWN = "unknown"
@@ -62,6 +65,7 @@ class CreditRecordSourceModule(str, Enum):
SHOT_REPLICATE = "shot_replicate" SHOT_REPLICATE = "shot_replicate"
PAYMENT = "payment" PAYMENT = "payment"
ADMIN = "admin" ADMIN = "admin"
TEAM = "team"
UNKNOWN = "unknown" UNKNOWN = "unknown"
@@ -104,6 +108,7 @@ class CreditRecordBillingScene(str, Enum):
RECHARGE = "recharge" RECHARGE = "recharge"
ADMIN_ADJUST = "admin_adjust" ADMIN_ADJUST = "admin_adjust"
REFUND = "refund" REFUND = "refund"
TEAM_INTERNAL_TRANSFER = "team_internal_transfer"
UNKNOWN = "unknown" UNKNOWN = "unknown"
@@ -111,6 +116,7 @@ CREDIT_RECORD_TYPE_LABELS = {
CreditRecordType.RECHARGE.value: "充值", CreditRecordType.RECHARGE.value: "充值",
CreditRecordType.CONSUME.value: "消费", CreditRecordType.CONSUME.value: "消费",
CreditRecordType.REFUND.value: "回退", CreditRecordType.REFUND.value: "回退",
CreditRecordType.TEAM_INTERNAL.value: "团队内部",
} }
CREDIT_RECORD_SUBJECT_LABELS = { CREDIT_RECORD_SUBJECT_LABELS = {
@@ -122,6 +128,7 @@ CREDIT_RECORD_SUBJECT_LABELS = {
CreditRecordSubject.RECHARGE.value: "充值积分", CreditRecordSubject.RECHARGE.value: "充值积分",
CreditRecordSubject.REFUND.value: "回退积分", CreditRecordSubject.REFUND.value: "回退积分",
CreditRecordSubject.ADMIN_ADJUST.value: "管理员调整", CreditRecordSubject.ADMIN_ADJUST.value: "管理员调整",
CreditRecordSubject.TEAM_INTERNAL.value: "团队内部转移",
CreditRecordSubject.UNKNOWN.value: "历史未知", CreditRecordSubject.UNKNOWN.value: "历史未知",
} }
@@ -136,6 +143,7 @@ CREDIT_RECORD_CHARGE_KIND_LABELS = {
CreditRecordChargeKind.RECHARGE.value: "充值", CreditRecordChargeKind.RECHARGE.value: "充值",
CreditRecordChargeKind.REFUND.value: "回退", CreditRecordChargeKind.REFUND.value: "回退",
CreditRecordChargeKind.ADMIN_ADJUST.value: "管理员调整", CreditRecordChargeKind.ADMIN_ADJUST.value: "管理员调整",
CreditRecordChargeKind.TEAM_INTERNAL.value: "团队内部转移",
CreditRecordChargeKind.UNKNOWN.value: "历史未知", CreditRecordChargeKind.UNKNOWN.value: "历史未知",
} }
@@ -151,6 +159,7 @@ CREDIT_RECORD_SOURCE_MODULE_LABELS = {
CreditRecordSourceModule.SHOT_REPLICATE.value: "拆镜复刻", CreditRecordSourceModule.SHOT_REPLICATE.value: "拆镜复刻",
CreditRecordSourceModule.PAYMENT.value: "支付充值", CreditRecordSourceModule.PAYMENT.value: "支付充值",
CreditRecordSourceModule.ADMIN.value: "后台管理", CreditRecordSourceModule.ADMIN.value: "后台管理",
CreditRecordSourceModule.TEAM.value: "团队管理",
CreditRecordSourceModule.UNKNOWN.value: "历史未知", CreditRecordSourceModule.UNKNOWN.value: "历史未知",
} }
@@ -189,5 +198,6 @@ CREDIT_RECORD_BILLING_SCENE_LABELS = {
CreditRecordBillingScene.RECHARGE.value: "充值", CreditRecordBillingScene.RECHARGE.value: "充值",
CreditRecordBillingScene.ADMIN_ADJUST.value: "管理员调整", CreditRecordBillingScene.ADMIN_ADJUST.value: "管理员调整",
CreditRecordBillingScene.REFUND.value: "回退", CreditRecordBillingScene.REFUND.value: "回退",
CreditRecordBillingScene.TEAM_INTERNAL_TRANSFER.value: "团队内部转账",
CreditRecordBillingScene.UNKNOWN.value: "历史未知", CreditRecordBillingScene.UNKNOWN.value: "历史未知",
} }
+184
View File
@@ -0,0 +1,184 @@
from __future__ import annotations
from enum import Enum
# 用户真人素材图片默认上限。users.private_portrait_image_limit = 0 表示关闭模块;>0 表示启用并限制总量。
PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT = 5
# 火山 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_VERIFY_TYPE = "real_time"
PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE = "10000"
PRIVATE_PORTRAIT_ASSET_URI_PREFIX = "asset://"
ARK_PRIVATE_PORTRAIT_SERVICE_NAME = "ark"
ARK_PRIVATE_PORTRAIT_VERSION = "2024-01-01"
ARK_PRIVATE_PORTRAIT_REGION = "cn-beijing"
ARK_PRIVATE_PORTRAIT_HOST = "ark.cn-beijing.volcengineapi.com"
PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS = 20
PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT = 60
PRIVATE_PORTRAIT_ASSET_POLL_BATCH_SIZE = 50
PRIVATE_PORTRAIT_REMOTE_DELETE_RECOVERY_BATCH_SIZE = 50
PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES = 30
# 系统侧保守限流;火山文档 GetAsset 是 100 QPS,这里预留余量为 80。
PRIVATE_PORTRAIT_ACTION_QPS_LIMITS: dict[str, int] = {
"CreateVisualValidateSession": 3,
"GetVisualValidateResult": 3,
"CreateAsset": 1,
"ListAssetGroups": 10,
"ListAssets": 10,
"GetAsset": 80,
"GetAssetGroup": 10,
"UpdateAsset": 10,
"UpdateAssetGroup": 10,
"DeleteAsset": 10,
"DeleteAssetGroup": 5,
}
class ArkPrivatePortraitAction(str, Enum):
CREATE_VISUAL_VALIDATE_SESSION = "CreateVisualValidateSession"
GET_VISUAL_VALIDATE_RESULT = "GetVisualValidateResult"
CREATE_ASSET = "CreateAsset"
GET_ASSET = "GetAsset"
LIST_ASSETS = "ListAssets"
LIST_ASSET_GROUPS = "ListAssetGroups"
GET_ASSET_GROUP = "GetAssetGroup"
UPDATE_ASSET = "UpdateAsset"
UPDATE_ASSET_GROUP = "UpdateAssetGroup"
DELETE_ASSET = "DeleteAsset"
DELETE_ASSET_GROUP = "DeleteAssetGroup"
class PrivatePortraitProjectStatus(str, Enum):
ACTIVE = "active"
DELETED = "deleted"
class PrivatePortraitValidateSessionStatus(str, Enum):
CREATED = "created"
CALLBACK_SUCCESS = "callback_success"
CALLBACK_FAILED = "callback_failed"
GROUP_ACTIVE = "group_active"
EXPIRED = "expired"
FAILED = "failed"
class PrivatePortraitAssetGroupStatus(str, Enum):
ACTIVE = "active"
LOCAL_DELETED = "local_deleted"
REMOTE_DELETED = "remote_deleted"
DELETE_FAILED = "delete_failed"
FAILED = "failed"
class PrivatePortraitAssetStatus(str, Enum):
CREATING = "creating"
PROCESSING = "Processing"
ACTIVE = "Active"
FAILED = "Failed"
LOCAL_DELETED = "local_deleted"
REMOTE_DELETED = "remote_deleted"
DELETE_FAILED = "delete_failed"
class PrivatePortraitAssetType(str, Enum):
IMAGE = "Image"
VIDEO = "Video"
AUDIO = "Audio"
class PrivatePortraitRemoteDeleteStatus(str, Enum):
NONE = "none"
PENDING = "pending"
SUCCESS = "success"
FAILED = "failed"
SKIPPED = "skipped"
class PrivatePortraitReferenceSource(str, Enum):
PRIVATE_PORTRAIT_ASSET = "private_portrait_asset"
class PrivatePortraitEventStatus(str, Enum):
SUCCESS = "success"
FAILED = "failed"
PENDING = "pending"
SKIPPED = "skipped"
WARNING = "warning"
class PrivatePortraitEventSource(str, Enum):
API = "api"
SERVICE = "service"
CALLBACK = "callback"
CELERY = "celery"
ADMIN = "admin"
SYSTEM = "system"
class PrivatePortraitEventType(str, Enum):
PROJECT_CREATE = "PROJECT_CREATE"
PROJECT_UPDATE = "PROJECT_UPDATE"
PROJECT_DELETE = "PROJECT_DELETE"
VALIDATE_SESSION_CREATE = "VALIDATE_SESSION_CREATE"
VALIDATE_SESSION_CREATE_FAILED = "VALIDATE_SESSION_CREATE_FAILED"
VALIDATE_CALLBACK_RECEIVED = "VALIDATE_CALLBACK_RECEIVED"
VALIDATE_CALLBACK_SUCCESS = "VALIDATE_CALLBACK_SUCCESS"
VALIDATE_CALLBACK_FAILED = "VALIDATE_CALLBACK_FAILED"
VALIDATE_GET_RESULT_START = "VALIDATE_GET_RESULT_START"
VALIDATE_GET_RESULT_SUCCESS = "VALIDATE_GET_RESULT_SUCCESS"
VALIDATE_GET_RESULT_FAILED = "VALIDATE_GET_RESULT_FAILED"
ASSET_GROUP_UPDATE_REMOTE_START = "ASSET_GROUP_UPDATE_REMOTE_START"
ASSET_GROUP_UPDATE_REMOTE_SUCCESS = "ASSET_GROUP_UPDATE_REMOTE_SUCCESS"
ASSET_GROUP_UPDATE_REMOTE_FAILED = "ASSET_GROUP_UPDATE_REMOTE_FAILED"
ASSET_CREATE_REJECT_DISABLED = "ASSET_CREATE_REJECT_DISABLED"
ASSET_CREATE_REJECT_MAX_LIMIT = "ASSET_CREATE_REJECT_MAX_LIMIT"
ASSET_CREATE_START = "ASSET_CREATE_START"
ASSET_CREATE_SUCCESS = "ASSET_CREATE_SUCCESS"
ASSET_CREATE_FAILED = "ASSET_CREATE_FAILED"
ASSET_SYNC_START = "ASSET_SYNC_START"
ASSET_SYNC_SUCCESS = "ASSET_SYNC_SUCCESS"
ASSET_SYNC_FAILED = "ASSET_SYNC_FAILED"
ASSET_POLL_START = "ASSET_POLL_START"
ASSET_POLL_SUCCESS = "ASSET_POLL_SUCCESS"
ASSET_POLL_FAILED = "ASSET_POLL_FAILED"
ASSET_POLL_TIMEOUT = "ASSET_POLL_TIMEOUT"
ASSET_DELETE_LOCAL = "ASSET_DELETE_LOCAL"
ASSET_DELETE_REMOTE_START = "ASSET_DELETE_REMOTE_START"
ASSET_DELETE_REMOTE_SUCCESS = "ASSET_DELETE_REMOTE_SUCCESS"
ASSET_DELETE_REMOTE_FAILED = "ASSET_DELETE_REMOTE_FAILED"
PROJECT_DELETE_REMOTE_START = "PROJECT_DELETE_REMOTE_START"
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"
SYNC_DUE_ASSETS_START = "SYNC_DUE_ASSETS_START"
SYNC_DUE_ASSETS_DONE = "SYNC_DUE_ASSETS_DONE"
SYNC_DUE_ASSETS_FAILED = "SYNC_DUE_ASSETS_FAILED"
REMOTE_DELETE_RECOVERY_START = "REMOTE_DELETE_RECOVERY_START"
REMOTE_DELETE_RECOVERY_DONE = "REMOTE_DELETE_RECOVERY_DONE"
REMOTE_DELETE_RECOVERY_FAILED = "REMOTE_DELETE_RECOVERY_FAILED"
REFERENCE_RESOLVE_START = "REFERENCE_RESOLVE_START"
REFERENCE_RESOLVE_SUCCESS = "REFERENCE_RESOLVE_SUCCESS"
REFERENCE_RESOLVE_FAILED = "REFERENCE_RESOLVE_FAILED"
ARK_API_CALL_START = "ARK_API_CALL_START"
ARK_API_CALL_SUCCESS = "ARK_API_CALL_SUCCESS"
ARK_API_CALL_FAILED = "ARK_API_CALL_FAILED"
ARK_API_RATE_LIMIT_WAIT = "ARK_API_RATE_LIMIT_WAIT"
ARK_API_RATE_LIMIT_REJECT = "ARK_API_RATE_LIMIT_REJECT"
+6 -1
View File
@@ -1,6 +1,8 @@
from app.models.base import Base, TimestampMixin, SoftDeleteMixin, engine, async_session, init_database, close_database from app.models.base import Base, TimestampMixin, SoftDeleteMixin, engine, async_session, init_database, close_database
from app.models.user import User from app.models.user import User
from app.models.team import Team from app.models.team import Team
from app.models.team_invitation import TeamInvitation
from app.models.team_join_request import TeamJoinRequest
from app.models.project import Project from app.models.project import Project
from app.models.generation_record import GenerationRecord from app.models.generation_record import GenerationRecord
from app.models.credit_record import CreditRecord from app.models.credit_record import CreditRecord
@@ -32,11 +34,12 @@ from app.models.user_oauth_account import UserOAuthAccount
from app.models.user_oauth_app import UserOAuthApp from app.models.user_oauth_app import UserOAuthApp
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
from app.models.contact_request import ContactRequest from app.models.contact_request import ContactRequest
from app.models.private_portrait import PrivatePortraitProject, PrivatePortraitValidateSession, PrivatePortraitAssetGroup, PrivatePortraitAsset
__all__ = [ __all__ = [
"Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session", "Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session",
"init_database", "close_database", "init_database", "close_database",
"User", "Team", "Project", "GenerationRecord", "CreditRecord", "User", "Team", "TeamInvitation", "TeamJoinRequest", "Project", "GenerationRecord", "CreditRecord",
"ModelConfig", "SystemConfig", "Notification", "PaymentOrder", "ModelConfig", "SystemConfig", "Notification", "PaymentOrder",
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio", "TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
"MenuConfig", "RechargePackage", "OperationLog", "ContactRequest", "MenuConfig", "RechargePackage", "OperationLog", "ContactRequest",
@@ -47,4 +50,6 @@ __all__ = [
"ShotReplicateTaskSet", "ShotReplicateSegment", "ShotReplicateTaskSet", "ShotReplicateSegment",
"UserOAuth", "UserOAuthAccount", "UserOAuthApp", "UserOAuth", "UserOAuthAccount", "UserOAuthApp",
"HomeMaterialAsset", "HomeMaterialCategory", "HomeMaterialWatermark", "HomeMaterialAsset", "HomeMaterialCategory", "HomeMaterialWatermark",
"PrivatePortraitProject", "PrivatePortraitValidateSession",
"PrivatePortraitAssetGroup", "PrivatePortraitAsset",
] ]
@@ -0,0 +1,11 @@
from app.models.private_portrait.project import PrivatePortraitProject
from app.models.private_portrait.validate_session import PrivatePortraitValidateSession
from app.models.private_portrait.asset_group import PrivatePortraitAssetGroup
from app.models.private_portrait.asset import PrivatePortraitAsset
__all__ = [
"PrivatePortraitProject",
"PrivatePortraitValidateSession",
"PrivatePortraitAssetGroup",
"PrivatePortraitAsset",
]
@@ -0,0 +1,69 @@
from __future__ import annotations
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 (
PrivatePortraitAssetStatus,
PrivatePortraitAssetType,
PrivatePortraitRemoteDeleteStatus,
)
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
class PrivatePortraitAsset(Base, TimestampMixin, SoftDeleteMixin):
"""火山 Asset 本地映射。生成时只信任本地 id,再批量解析成 asset://remote_asset_id。"""
__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_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_next_poll_status",
"next_poll_at",
"status",
postgresql_where=text("deleted_at IS NULL AND next_poll_at IS NOT NULL"),
),
Index("idx_private_portrait_assets_remote_delete_status", "remote_delete_status"),
)
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)
group_id: Mapped[str] = mapped_column(String(32), ForeignKey("private_portrait_asset_groups.id", ondelete="CASCADE"), nullable=False, index=True)
remote_group_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
remote_asset_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
remote_project_name: Mapped[str] = mapped_column(String(256), nullable=False, index=True)
asset_type: Mapped[str] = mapped_column(String(16), nullable=False, default=PrivatePortraitAssetType.IMAGE.value, index=True)
name: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
source_url: Mapped[str] = mapped_column(Text, nullable=False)
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)
status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default=PrivatePortraitAssetStatus.CREATING.value,
server_default=PrivatePortraitAssetStatus.CREATING.value,
index=True,
)
moderation_json: Mapped[str | None] = mapped_column(Text, nullable=True)
last_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
next_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
poll_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
remote_delete_status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default=PrivatePortraitRemoteDeleteStatus.NONE.value,
server_default=PrivatePortraitRemoteDeleteStatus.NONE.value,
index=True,
)
remote_deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
remote_delete_error: Mapped[str | None] = mapped_column(Text, nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
raw_response_json: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -0,0 +1,51 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.private_portrait import (
PRIVATE_PORTRAIT_GROUP_TYPE,
PrivatePortraitAssetGroupStatus,
PrivatePortraitRemoteDeleteStatus,
)
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
class PrivatePortraitAssetGroup(Base, TimestampMixin, SoftDeleteMixin):
"""本地项目组与火山 Asset Group 的映射。"""
__tablename__ = "private_portrait_asset_groups"
__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_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"),
)
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)
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)
status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default=PrivatePortraitAssetGroupStatus.ACTIVE.value,
server_default=PrivatePortraitAssetGroupStatus.ACTIVE.value,
index=True,
)
remote_delete_status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default=PrivatePortraitRemoteDeleteStatus.NONE.value,
server_default=PrivatePortraitRemoteDeleteStatus.NONE.value,
index=True,
)
remote_deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
remote_delete_error: Mapped[str | None] = mapped_column(Text, nullable=True)
raw_response_json: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -0,0 +1,43 @@
from __future__ import annotations
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.models.base import Base, SoftDeleteMixin, TimestampMixin
class PrivatePortraitProject(Base, TimestampMixin, SoftDeleteMixin):
"""用户本地真人素材项目组。remote_project_name 是火山 ProjectName 快照。"""
__tablename__ = "private_portrait_projects"
__table_args__ = (
Index("idx_private_portrait_projects_user_status_created", "user_id", "status", "created_at"),
Index(
"idx_private_portrait_projects_user_deleted",
"user_id",
"deleted_at",
postgresql_where=text("deleted_at IS NULL"),
),
Index("idx_private_portrait_projects_remote_project_name", "remote_project_name"),
)
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)
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 快照")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default=PrivatePortraitProjectStatus.ACTIVE.value,
server_default=PrivatePortraitProjectStatus.ACTIVE.value,
index=True,
)
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")
active_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)
@@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.private_portrait import PrivatePortraitValidateSessionStatus
from app.models.base import Base, TimestampMixin
class PrivatePortraitValidateSession(Base, TimestampMixin):
"""火山真人认证 H5 会话。"""
__tablename__ = "private_portrait_validate_sessions"
__table_args__ = (
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"),
)
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)
byted_token: Mapped[str | None] = mapped_column(String(256), nullable=True, index=True)
h5_link: Mapped[str | None] = mapped_column(Text, nullable=True)
callback_url: Mapped[str | None] = mapped_column(Text, nullable=True)
result_code: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
algorithm_base_resp_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
verify_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default=PrivatePortraitValidateSessionStatus.CREATED.value,
server_default=PrivatePortraitValidateSessionStatus.CREATED.value,
index=True,
)
remote_group_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)
expired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
raw_callback_json: Mapped[str | None] = mapped_column(Text, nullable=True)
raw_response_json: Mapped[str | None] = mapped_column(Text, nullable=True)
+4 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Index, Integer, String from sqlalchemy import ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.enums.team import TeamStatus from app.enums.team import TeamStatus
@@ -32,3 +32,6 @@ class Team(Base, TimestampMixin, SoftDeleteMixin):
index=True, index=True,
comment="排序值,越小越靠前", comment="排序值,越小越靠前",
) )
manager_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("users.id"), nullable=True, index=True, comment="团队管理人ID"
)
@@ -0,0 +1,22 @@
from datetime import datetime
from sqlalchemy import ForeignKey, Index, Integer, String, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
class TeamInvitation(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "team_invitations"
__table_args__ = (
Index("ix_team_invitations_team", "team_id", "status"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
team_id: Mapped[str] = mapped_column(String(32), ForeignKey("teams.id"), index=True, nullable=False)
code: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
created_by: Mapped[str] = mapped_column(String(32), ForeignKey("users.id"), nullable=False)
status: Mapped[str] = mapped_column(String(16), default="active", server_default="active", nullable=False)
max_uses: Mapped[int | None] = mapped_column(Integer, nullable=True)
use_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -0,0 +1,19 @@
from sqlalchemy import ForeignKey, Index, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class TeamJoinRequest(Base, TimestampMixin):
__tablename__ = "team_join_requests"
__table_args__ = (
Index("ix_tjr_team_status", "team_id", "status"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
team_id: Mapped[str] = mapped_column(String(32), ForeignKey("teams.id"), index=True, nullable=False)
user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id"), index=True, nullable=False)
invitation_id: Mapped[str | None] = mapped_column(String(32), ForeignKey("team_invitations.id"), nullable=True)
status: Mapped[str] = mapped_column(String(16), default="pending", server_default="pending", nullable=False)
note: Mapped[str | None] = mapped_column(String(256), nullable=True)
handled_by: Mapped[str | None] = mapped_column(String(32), nullable=True)
+6 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, String, JSON from sqlalchemy import Boolean, DateTime, Float, Integer, String, JSON
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.enums.user import FrontendUserKind from app.enums.user import FrontendUserKind
@@ -39,6 +39,11 @@ class User(Base, TimestampMixin):
) )
allowed_menus: Mapped[list | None] = mapped_column(JSON, nullable=True) allowed_menus: Mapped[list | None] = mapped_column(JSON, nullable=True)
# 真人素材库图片总量限制。0 表示关闭真人素材模块;>0 表示启用并限制用户所有真人素材图片总量。
private_portrait_image_limit: Mapped[int] = mapped_column(
Integer, default=5, server_default="5", nullable=False
)
@property @property
def must_set_password(self) -> bool: def must_set_password(self) -> bool:
return self.user_type == "frontend" and not self.hashed_password return self.user_type == "frontend" and not self.hashed_password
+2
View File
@@ -58,6 +58,7 @@ class AdminUserOut(BaseModel):
last_login_at: NaiveDatetimeOptional = None last_login_at: NaiveDatetimeOptional = None
allowed_menus: list | None = None allowed_menus: list | None = None
resource_capacity: ResourceCapacityUsageOut | None = None resource_capacity: ResourceCapacityUsageOut | None = None
private_portrait_image_limit: int = 5
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -71,6 +72,7 @@ class CreateUserRequest(BaseModel):
user_type: str = Field(default="frontend", pattern="^(frontend|admin)$") user_type: str = Field(default="frontend", pattern="^(frontend|admin)$")
frontend_user_kind: str = Field(default="external", pattern="^(internal|external)$") frontend_user_kind: str = Field(default="external", pattern="^(internal|external)$")
allowed_menus: list | None = None allowed_menus: list | None = None
private_portrait_image_limit: int = Field(default=5, ge=0, le=9999)
class UpdateFrontendUserKindRequest(BaseModel): class UpdateFrontendUserKindRequest(BaseModel):
@@ -41,6 +41,21 @@ class GenerationAIReference(BaseModel):
description="参考素材时长(秒)。type=video/audio 时使用,用于视频/音频素材时长校验", description="参考素材时长(秒)。type=video/audio 时使用,用于视频/音频素材时长校验",
examples=[5.0], examples=[5.0],
) )
source: str | None = Field(
None,
description="参考素材来源。private_portrait_asset=真人素材库;为空表示普通上传文件",
examples=["private_portrait_asset"],
)
private_asset_id: str | None = Field(
None,
description="真人素材库本地素材ID。source=private_portrait_asset 时必填,后端据此解析 remote_asset_id",
examples=["0019fxxx"],
)
remote_asset_id: str | None = Field(
None,
description="后端回填的火山 Asset ID。前端传入时不可信,创建任务时以后端查库为准",
examples=["asset-20260318071009-xxxxx"],
)
class GenerationAITaskCreate(BaseModel): class GenerationAITaskCreate(BaseModel):
@@ -0,0 +1,172 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field, field_validator
from app.enums.private_portrait import PrivatePortraitAssetType
from app.schemas.common import NaiveDatetimeOptional
class PrivatePortraitConfigOut(BaseModel):
enabled: bool
image_limit: int
used_image_count: int
remaining_image_count: int
class PrivatePortraitAdminConfigUpdate(BaseModel):
private_portrait_image_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)
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
class PrivatePortraitProjectOut(BaseModel):
id: str
user_id: str | None = None
name: str
name_slug: str | None = None
remote_project_name: str | None = None
description: str | None = None
status: str
asset_group_count: int = 0
asset_count: int = 0
active_asset_count: int = 0
last_used_at: NaiveDatetimeOptional = None
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
model_config = {"from_attributes": True}
class PrivatePortraitProjectListOut(BaseModel):
items: list[PrivatePortraitProjectOut]
total: int
page: int
page_size: int
class PrivatePortraitValidateSessionCreate(BaseModel):
callback_redirect_url: str | None = Field(None, description="认证完成后前端要跳转的页面。为空时使用后端默认回调页。")
class PrivatePortraitValidateSessionOut(BaseModel):
id: str
user_id: str | None = None
project_id: str
byted_token: str | None = None
h5_link: str | None = None
callback_url: str | None = None
result_code: str | None = None
algorithm_base_resp_code: str | None = None
verify_type: str | None = None
status: str
remote_group_id: str | None = None
remote_project_name: str | None = None
expired_at: NaiveDatetimeOptional = None
error_message: str | None = None
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
model_config = {"from_attributes": True}
class PrivatePortraitAssetGroupOut(BaseModel):
id: str
user_id: str | None = None
project_id: str
remote_group_id: str
remote_group_name: str | None = None
remote_project_name: str
group_type: str
status: str
remote_delete_status: str
remote_deleted_at: NaiveDatetimeOptional = None
remote_delete_error: str | None = None
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
model_config = {"from_attributes": True}
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)
@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")
return value
class PrivatePortraitAssetOut(BaseModel):
id: str
user_id: str | None = None
project_id: str
project_name: str | None = None
group_id: str
remote_group_id: str
remote_asset_id: str | None = None
remote_project_name: str | None = None
asset_type: str
name: str | None = None
source_url: str
preview_url: str | None = None
remote_url: str | None = None
remote_url_expired_at: NaiveDatetimeOptional = None
status: str
moderation: dict[str, Any] | None = None
last_poll_at: NaiveDatetimeOptional = None
next_poll_at: NaiveDatetimeOptional = None
poll_count: int = 0
remote_delete_status: str
remote_deleted_at: NaiveDatetimeOptional = None
remote_delete_error: str | None = None
error_message: str | None = None
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
model_config = {"from_attributes": True}
class PrivatePortraitAssetListOut(BaseModel):
items: list[PrivatePortraitAssetOut]
total: int
page: int
page_size: int
class PrivatePortraitSelectableAssetOut(BaseModel):
id: str
project_id: str
project_name: str
name: str | None = None
asset_type: str
preview_url: str | None = None
status: str
created_at: NaiveDatetimeOptional = None
class PrivatePortraitSelectableAssetListOut(BaseModel):
items: list[PrivatePortraitSelectableAssetOut]
total: int
page: int
page_size: int
class PrivatePortraitDeleteOut(BaseModel):
success: bool = True
remote_delete_status: str | None = None
+2
View File
@@ -29,6 +29,8 @@ class TeamOut(TeamBase):
member_count: int = 0 member_count: int = 0
created_at: NaiveDatetime created_at: NaiveDatetime
updated_at: NaiveDatetimeOptional = None updated_at: NaiveDatetimeOptional = None
manager_id: str | None = None
manager_name: str | None = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -0,0 +1,22 @@
from pydantic import BaseModel, Field
from app.schemas.common import NaiveDatetimeOptional
class TeamInvitationCreate(BaseModel):
max_uses: int | None = Field(None, ge=1, description="最大使用次数,null 表示不限")
expires_at: str | None = Field(None, description="过期时间 ISO 格式,null 表示永不过期")
class TeamInvitationOut(BaseModel):
id: str
team_id: str
code: str
status: str
max_uses: int | None = None
use_count: int = 0
expires_at: NaiveDatetimeOptional = None
invite_link: str = ""
created_at: NaiveDatetimeOptional = None
model_config = {"from_attributes": True}
@@ -0,0 +1,33 @@
from pydantic import BaseModel, Field
from app.schemas.common import NaiveDatetimeOptional
class JoinByCodeRequest(BaseModel):
invitation_code: str = Field(..., description="邀请码")
class JoinRequestHandle(BaseModel):
action: str = Field(..., pattern="^(approve|reject)$", description="approve 通过 / reject 拒绝")
note: str | None = Field(None, max_length=256, description="拒绝原因")
class JoinRequestOut(BaseModel):
id: str
team_id: str
team_name: str
user_id: str
username: str
phone: str | None = None
status: str
note: str | None = None
created_at: NaiveDatetimeOptional = None
model_config = {"from_attributes": True}
class JoinTeamInfoOut(BaseModel):
team_name: str
team_id: str
valid: bool
already_in_team: bool = False
+38
View File
@@ -0,0 +1,38 @@
from pydantic import BaseModel, Field
from app.schemas.common import NaiveDatetime
class SetManagerRequest(BaseModel):
user_id: str | None = Field(None, description="设为管理人的前台用户ID;传 null 表示取消管理人")
class TeamMemberOut(BaseModel):
id: str
username: str
phone: str | None = None
credits: float
is_active: bool = True
joined_at: NaiveDatetime
model_config = {"from_attributes": True}
class ManagerTransferRequest(BaseModel):
target_user_id: str = Field(..., description="接收积分的成员用户ID")
amount: float = Field(gt=0, description="转账积分数量(正数)")
direction: str = Field(default="increase", pattern="^(increase|decrease)$", description="increase=管理人转给成员;decrease=从成员扣减回管理人")
description: str | None = Field(None, max_length=256, description="转账说明")
class ManagedTeamOut(BaseModel):
id: str
name: str
code: str | None = None
description: str | None = None
status: str
member_count: int = 0
manager_id: str | None = None
manager_name: str | None = None
model_config = {"from_attributes": True}
+4
View File
@@ -15,5 +15,9 @@ class UserOut(BaseModel):
allowed_menus: list | None = None allowed_menus: list | None = None
must_set_password: bool = False must_set_password: bool = False
resource_capacity: ResourceCapacityUsageOut | None = None resource_capacity: ResourceCapacityUsageOut | None = None
private_portrait_image_limit: int = 5
team_id: str | None = None
team_name: str | None = None
is_team_manager: bool = False
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -282,7 +282,7 @@ async def list_admin_credit_records(
summary_query = select( summary_query = select(
func.coalesce(func.sum(case((CreditRecord.type == "recharge", CreditRecord.amount), else_=0)), 0), func.coalesce(func.sum(case((CreditRecord.type == "recharge", CreditRecord.amount), else_=0)), 0),
func.coalesce(func.sum(case((CreditRecord.type == "consume", func.abs(CreditRecord.amount)), else_=0)), 0), func.coalesce(func.sum(case((CreditRecord.type.in_(["consume", "team_internal"]), func.abs(CreditRecord.amount)), else_=0)), 0),
func.coalesce(func.sum(case((CreditRecord.type == "refund", CreditRecord.amount), else_=0)), 0), func.coalesce(func.sum(case((CreditRecord.type == "refund", CreditRecord.amount), else_=0)), 0),
func.count(CreditRecord.id), func.count(CreditRecord.id),
func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.type == "consume"), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))), func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.type == "consume"), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))),
+3 -1
View File
@@ -179,12 +179,14 @@ async def deduct_credits(
biz_key: str | None = None, biz_key: str | None = None,
refund_for_biz_key: str | None = None, refund_for_biz_key: str | None = None,
record_meta: CreditRecordMeta | dict | None = None, record_meta: CreditRecordMeta | dict | None = None,
record_type: str = "consume",
) -> User: ) -> User:
"""扣减用户积分,并写入消费流水。 """扣减用户积分,并写入消费流水。
并发安全点: 并发安全点:
- 先用 SELECT ... FOR UPDATE 锁住 users 行,避免余额覆盖。 - 先用 SELECT ... FOR UPDATE 锁住 users 行,避免余额覆盖。
- biz_key 不为空时,作为正式业务幂等键;重复调用直接返回当前用户,不重复扣。 - biz_key 不为空时,作为正式业务幂等键;重复调用直接返回当前用户,不重复扣。
- record_type: 流水类型,默认 "consume";团队内部流转传 "team_internal"
""" """
amount = round(float(amount or 0), 2) amount = round(float(amount or 0), 2)
if amount <= 0: if amount <= 0:
@@ -218,7 +220,7 @@ async def deduct_credits(
record = CreditRecord( record = CreditRecord(
id=generate_id(), id=generate_id(),
user_id=user_id, user_id=user_id,
type="consume", type=record_type,
amount=-amount, amount=-amount,
balance_after=user.credits, balance_after=user.credits,
description=description, description=description,
@@ -54,6 +54,7 @@ from app.services.generation_history_meta_service import (
build_empty_history_meta, build_empty_history_meta,
) )
from app.services.resource_capacity_service import assert_user_resource_capacity_available from app.services.resource_capacity_service import assert_user_resource_capacity_available
from app.services.private_portrait.reference_resolver import resolve_private_portrait_references
from app.utils.id_gen import generate_id from app.utils.id_gen import generate_id
IMAGE_DEFAULT_SIZE = "2K" IMAGE_DEFAULT_SIZE = "2K"
@@ -247,6 +248,12 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req
return existing return existing
refs = [r.model_dump(exclude_none=True) for r in (req.media_references or [])] refs = [r.model_dump(exclude_none=True) for r in (req.media_references or [])]
refs = await resolve_private_portrait_references(
db,
user_id=current_user.id,
media_references=refs,
gen_type=gen_type,
)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
task_id = generate_id() task_id = generate_id()
@@ -27,6 +27,7 @@ from app.services.generation_ai_service import (
) )
from app.services.generation_billing_service import OWNER_CHAT_GENERATION_TASK, charge_generation_media_by_params from app.services.generation_billing_service import OWNER_CHAT_GENERATION_TASK, charge_generation_media_by_params
from app.services.resource_capacity_service import assert_user_resource_capacity_available from app.services.resource_capacity_service import assert_user_resource_capacity_available
from app.services.private_portrait.reference_resolver import resolve_private_portrait_references
from app.utils.id_gen import generate_id from app.utils.id_gen import generate_id
@@ -82,6 +83,12 @@ async def create_chat_generation_task_for_module(
task_id = generate_id() task_id = generate_id()
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
refs = media_references or [] refs = media_references or []
refs = await resolve_private_portrait_references(
db,
user_id=current_user.id,
media_references=refs,
gen_type=gen_type,
)
backend_idempotency_key = _build_backend_idempotency_key( backend_idempotency_key = _build_backend_idempotency_key(
generation_mode=generation_mode, generation_mode=generation_mode,
gen_type=gen_type, gen_type=gen_type,
+2 -1
View File
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from volcenginesdkarkruntime import AsyncArk from volcenginesdkarkruntime import AsyncArk
from app.config import settings from app.config import settings
from app.enums.private_portrait import PRIVATE_PORTRAIT_ASSET_URI_PREFIX
from app.models.image_engine import ImageEngine from app.models.image_engine import ImageEngine
from app.services.log_config import is_enabled, LOG_DIR, LOG_DATE_FORMAT, encrypt_data from app.services.log_config import is_enabled, LOG_DIR, LOG_DATE_FORMAT, encrypt_data
from app.services.generation_provider_types import ( from app.services.generation_provider_types import (
@@ -97,7 +98,7 @@ def _resolve_url(url: str) -> str:
# return f"data:{mime};base64,{b64}" # return f"data:{mime};base64,{b64}"
if url.startswith(("http://", "https://", "data:")): if url.startswith(("http://", "https://", "data:", PRIVATE_PORTRAIT_ASSET_URI_PREFIX)):
return url return url
return f"{settings.BASE_URL.rstrip('/')}/{url.lstrip('/')}" return f"{settings.BASE_URL.rstrip('/')}/{url.lstrip('/')}"
@@ -1,70 +1,8 @@
from __future__ import annotations from __future__ import annotations
import json
import os
import re
import traceback
from datetime import datetime
from typing import Any from typing import Any
from app.services.log_config import LOG_DATE_FORMAT, LOG_DIR, is_enabled from app.services.operation_log_service import build_exception_detail, log_operation_error, log_operation_event
MAX_LOG_FIELD_LENGTH = 20000
MAX_TRACEBACK_LENGTH = 12000
MODULE_LOG_ROOT = os.path.join(os.path.dirname(LOG_DIR), "ModuleGeneration")
def _safe_module_name(module: str | None) -> str:
value = str(module or "unknown_module").strip() or "unknown_module"
value = re.sub(r"[^a-zA-Z0-9_.-]+", "_", value)
return value[:120] or "unknown_module"
def _safe_dump_value(value: Any) -> Any:
"""限制单字段长度,避免超长 base64 / 响应体把日志打爆。"""
if value is None:
return None
if isinstance(value, str):
if len(value) > MAX_LOG_FIELD_LENGTH:
return value[:MAX_LOG_FIELD_LENGTH] + f"...<truncated:{len(value) - MAX_LOG_FIELD_LENGTH}>"
return value
if isinstance(value, dict):
return {str(k): _safe_dump_value(v) for k, v in value.items()}
if isinstance(value, list):
return [_safe_dump_value(v) for v in value]
return value
def build_exception_detail(exc: BaseException | None, extra: dict[str, Any] | None = None) -> dict[str, Any]:
"""构造统一异常日志 detail。日志方法必须吞异常,业务不能被日志影响。"""
detail: dict[str, Any] = dict(extra or {})
if exc is not None:
tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
if len(tb) > MAX_TRACEBACK_LENGTH:
tb = tb[:MAX_TRACEBACK_LENGTH] + f"...<traceback_truncated:{len(tb) - MAX_TRACEBACK_LENGTH}>"
detail.update(
{
"exception_type": type(exc).__name__,
"exception_message": str(exc),
"traceback": tb,
}
)
return detail
def _append_module_log(module: str, entry: dict[str, Any]) -> None:
if not is_enabled():
return
try:
module_dir = os.path.join(MODULE_LOG_ROOT, _safe_module_name(module))
os.makedirs(module_dir, exist_ok=True)
today = datetime.now().strftime(LOG_DATE_FORMAT)
log_file = os.path.join(module_dir, f"{today}.log")
with open(log_file, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False, default=str) + "\n")
except Exception:
# 日志失败绝不能影响业务主流程。
pass
def log_module_event_file( def log_module_event_file(
@@ -78,24 +16,19 @@ def log_module_event_file(
detail: dict[str, Any] | None = None, detail: dict[str, Any] | None = None,
error: str | None = None, error: str | None = None,
) -> None: ) -> None:
"""记录模块流程事件到 JSONL 文件。 log_operation_event(
domain="module_generation",
统一落盘目录:log/ModuleGeneration/{module}/YYYY-MM-DD.log module=module,
不再写 module_generation_events 表。 event_type=event_type,
""" project_id=project_id,
entry = { step_id=step_id,
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), user_id=user_id,
"log_type": "module_event", message=message,
"module": module, detail=detail,
"event_type": event_type, error=error,
"project_id": project_id, event_status="failed" if error else "success",
"step_id": step_id, source="service",
"user_id": user_id, )
"message": message,
"detail": _safe_dump_value(detail or {}),
"error": error,
}
_append_module_log(module, entry)
def log_module_prompt_event( def log_module_prompt_event(
@@ -111,22 +44,19 @@ def log_module_prompt_event(
token_usage: dict[str, Any] | None = None, token_usage: dict[str, Any] | None = None,
error: str | None = None, error: str | None = None,
) -> None: ) -> None:
"""记录模块 AI 提词/分析请求和响应到 JSONL 文件。""" log_operation_event(
entry = { domain="module_generation",
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), module=module,
"log_type": "module_prompt", event_type=event_type,
"module": module, project_id=project_id,
"event_type": event_type, step_id=step_id,
"prompt_type": prompt_type, user_id=user_id,
"project_id": project_id, message=f"模块 AI 请求:{prompt_type}",
"step_id": step_id, detail={"prompt_type": prompt_type, "request": request or {}, "response": response or {}, "token_usage": token_usage or {}},
"user_id": user_id, error=error,
"request": _safe_dump_value(request or {}), event_status="failed" if error else "success",
"response": _safe_dump_value(response or {}), source="service",
"token_usage": _safe_dump_value(token_usage or {}), )
"error": error,
}
_append_module_log(module, entry)
def log_module_error( def log_module_error(
@@ -141,23 +71,16 @@ def log_module_error(
error: str | None = None, error: str | None = None,
exc: BaseException | None = None, exc: BaseException | None = None,
) -> None: ) -> None:
"""记录模块异常日志。 log_operation_error(
domain="module_generation",
- 兼容原有 detail/error 参数。 module=module,
- 新增 exc 后自动记录 exception_type、message、traceback。 event_type=event_type,
- 日志写入失败会被底层吞掉,不影响主流程。 project_id=project_id,
""" step_id=step_id,
merged_detail = build_exception_detail(exc, detail) user_id=user_id,
entry = { message=message,
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), detail=detail,
"log_type": "module_error", error=error if error is not None else (str(exc) if exc else None),
"module": module, exc=exc,
"event_type": event_type, source="service",
"project_id": project_id, )
"step_id": step_id,
"user_id": user_id,
"message": message,
"detail": _safe_dump_value(merged_detail),
"error": error if error is not None else (str(exc) if exc is not None else None),
}
_append_module_log(module, entry)
@@ -0,0 +1,198 @@
from __future__ import annotations
import json
import os
import re
import traceback
from datetime import datetime
from typing import Any
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from app.services.log_config import LOG_DATE_FORMAT, LOG_DIR, is_enabled
MAX_LOG_FIELD_LENGTH = 20000
MAX_TRACEBACK_LENGTH = 12000
OPERATION_LOG_ROOT = os.path.join(os.path.dirname(LOG_DIR), "OperationLogs")
SENSITIVE_KEY_PATTERNS = (
"secret",
"token",
"authorization",
"cookie",
"credential",
"signature",
"accesskey",
"access_key",
"api_key",
"apikey",
"security-token",
"x-tos-security-token",
)
def _safe_name(value: str | None, default: str = "unknown") -> str:
text = str(value or default).strip() or default
text = re.sub(r"[^a-zA-Z0-9_.-]+", "_", text)
return text[:120] or default
def _mask_string(value: str) -> str:
if len(value) <= 8:
return "***"
return f"{value[:4]}***{value[-4:]}"
def _is_sensitive_key(key: str) -> bool:
lower = str(key).replace("-", "_").lower()
return any(pattern in lower for pattern in SENSITIVE_KEY_PATTERNS)
def _sanitize_url(value: str) -> str:
try:
parts = urlsplit(value)
if not parts.scheme or not parts.netloc:
return value
query = []
for k, v in parse_qsl(parts.query, keep_blank_values=True):
query.append((k, _mask_string(v) if _is_sensitive_key(k) else v))
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
except Exception:
return value
def sanitize_log_value(value: Any) -> Any:
if value is None:
return None
if isinstance(value, str):
text = _sanitize_url(value) if value.startswith(("http://", "https://")) else value
if len(text) > MAX_LOG_FIELD_LENGTH:
return text[:MAX_LOG_FIELD_LENGTH] + f"...<truncated:{len(text) - MAX_LOG_FIELD_LENGTH}>"
return text
if isinstance(value, dict):
output: dict[str, Any] = {}
for k, v in value.items():
key = str(k)
output[key] = "***" if _is_sensitive_key(key) else sanitize_log_value(v)
return output
if isinstance(value, list):
return [sanitize_log_value(v) for v in value]
return value
def build_exception_detail(exc: BaseException | None, extra: dict[str, Any] | None = None) -> dict[str, Any]:
detail: dict[str, Any] = dict(extra or {})
if exc is not None:
tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
if len(tb) > MAX_TRACEBACK_LENGTH:
tb = tb[:MAX_TRACEBACK_LENGTH] + f"...<traceback_truncated:{len(tb) - MAX_TRACEBACK_LENGTH}>"
detail.update(
{
"exception_type": type(exc).__name__,
"exception_message": str(exc),
"traceback": tb,
}
)
return detail
def _append_operation_log(domain: str, entry: dict[str, Any]) -> None:
if not is_enabled():
return
try:
domain_dir = os.path.join(OPERATION_LOG_ROOT, _safe_name(domain, "default"))
os.makedirs(domain_dir, exist_ok=True)
today = datetime.now().strftime(LOG_DATE_FORMAT)
with open(os.path.join(domain_dir, f"{today}.log"), "a", encoding="utf-8") as f:
f.write(json.dumps(sanitize_log_value(entry), ensure_ascii=False, default=str) + "\n")
except Exception:
pass
def log_operation_event(
*,
domain: str,
event_type: str,
module: str | None = None,
event_status: str = "success",
source: str | None = None,
trace_id: str | None = None,
request_id: str | None = None,
user_id: str | None = None,
project_id: str | None = None,
session_id: str | None = None,
group_id: str | None = None,
asset_id: str | None = None,
task_id: str | None = None,
step_id: str | None = None,
remote_action: str | None = None,
remote_request_id: str | None = None,
message: str | None = None,
detail: dict[str, Any] | None = None,
error: str | None = None,
) -> None:
_append_operation_log(
domain,
{
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"log_type": "operation_event",
"domain": domain,
"module": module or domain,
"event_type": event_type,
"event_status": event_status,
"source": source,
"trace_id": trace_id,
"request_id": request_id,
"user_id": user_id,
"project_id": project_id,
"session_id": session_id,
"group_id": group_id,
"asset_id": asset_id,
"task_id": task_id,
"step_id": step_id,
"remote_action": remote_action,
"remote_request_id": remote_request_id,
"message": message,
"detail": detail or {},
"error": error,
},
)
def log_operation_error(*, domain: str, event_type: str, exc: BaseException | None = None, detail: dict[str, Any] | None = None, **kwargs: Any) -> None:
kwargs.setdefault("event_status", "failed")
kwargs["detail"] = build_exception_detail(exc, detail)
kwargs.setdefault("error", str(exc) if exc is not None else None)
log_operation_event(domain=domain, event_type=event_type, **kwargs)
def log_remote_api_event(
*,
domain: str,
remote_action: str,
event_type: str,
event_status: str,
request: dict[str, Any] | None = None,
response: dict[str, Any] | None = None,
remote_request_id: str | None = None,
remote_code: str | None = None,
remote_message: str | None = None,
**kwargs: Any,
) -> None:
detail = dict(kwargs.pop("detail", {}) or {})
if request is not None:
detail["request"] = request
if response is not None:
detail["response"] = response
if remote_code is not None:
detail["remote_code"] = remote_code
if remote_message is not None:
detail["remote_message"] = remote_message
log_operation_event(
domain=domain,
event_type=event_type,
event_status=event_status,
remote_action=remote_action,
remote_request_id=remote_request_id,
detail=detail,
error=remote_message if event_status == "failed" else None,
**kwargs,
)
@@ -0,0 +1 @@
# 私域真人人像素材库领域服务包。
@@ -0,0 +1,278 @@
from __future__ import annotations
import asyncio
import json
from typing import Any
from fastapi import HTTPException
from app.config import settings
from app.enums.private_portrait import (
ARK_PRIVATE_PORTRAIT_HOST,
ARK_PRIVATE_PORTRAIT_REGION,
ARK_PRIVATE_PORTRAIT_SERVICE_NAME,
ARK_PRIVATE_PORTRAIT_VERSION,
ArkPrivatePortraitAction,
PrivatePortraitEventSource,
PrivatePortraitEventStatus,
PrivatePortraitEventType,
)
from app.services.operation_log_service import log_remote_api_event
from app.services.private_portrait.rate_limiter import acquire_private_portrait_action_token
DOMAIN = "private_portrait"
class ArkPrivateAssetClientError(RuntimeError):
pass
class ArkPrivateAssetRemoteError(ArkPrivateAssetClientError):
def __init__(self, *, action: str, code: str, message: str, request_id: str | None = None, raw: dict[str, Any] | None = None):
self.action = action
self.code = code
self.message = message
self.request_id = request_id
self.raw = raw or {}
super().__init__(f"{action} 调用失败:{code} {message}" + (f" RequestId={request_id}" if request_id else ""))
def _remote_error_http_status(code: str) -> int:
normalized = (code or "").lower()
if "notfound" in normalized or normalized.startswith("not_found"):
return 404
if "invalid" in normalized or "missing" in normalized or "unsupported" in normalized or "limit" in normalized:
return 400
if "unauthorized" in normalized or "forbidden" in normalized or "permission" in normalized or "denied" in normalized:
return 403
if "throttl" in normalized or "rate" in normalized:
return 429
return 502
class ArkPrivateAssetClient:
"""火山 Ark 私域真人人像素材 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
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")
async def create_visual_validate_session(self, *, project_name: str, callback_url: str) -> dict[str, Any]:
return await self._call(ArkPrivatePortraitAction.CREATE_VISUAL_VALIDATE_SESSION, {"CallbackURL": callback_url, "ProjectName": project_name})
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(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:
payload["Name"] = name
return await self._call(ArkPrivatePortraitAction.CREATE_ASSET, payload)
async def get_asset(self, *, project_name: str, asset_id: str) -> dict[str, Any]:
return await self._call(ArkPrivatePortraitAction.GET_ASSET, {"Id": asset_id, "ProjectName": project_name})
async def list_assets(self, *, project_name: str, filter_payload: dict[str, Any] | None = None, page_number: int = 1, page_size: int = 20) -> dict[str, Any]:
payload = {"Filter": filter_payload or {}, "PageNumber": page_number, "PageSize": page_size, "ProjectName": project_name}
return await self._call(ArkPrivatePortraitAction.LIST_ASSETS, payload)
async def list_asset_groups(self, *, project_name: str, filter_payload: dict[str, Any] | None = None, page_number: int = 1, page_size: int = 20) -> dict[str, Any]:
payload = {"Filter": filter_payload or {}, "PageNumber": page_number, "PageSize": page_size, "ProjectName": project_name}
return await self._call(ArkPrivatePortraitAction.LIST_ASSET_GROUPS, payload)
async def get_asset_group(self, *, project_name: str, group_id: str) -> dict[str, Any]:
return await self._call(ArkPrivatePortraitAction.GET_ASSET_GROUP, {"Id": group_id, "ProjectName": project_name})
async def update_asset_group(self, *, project_name: str, group_id: str, name: str | None = None, title: str | None = None, description: str | None = None) -> dict[str, Any]:
payload: dict[str, Any] = {"Id": group_id, "ProjectName": project_name}
if name is not None:
payload["Name"] = name
if title is not None:
payload["Title"] = title
if description is not None:
payload["Description"] = description
return await self._call(ArkPrivatePortraitAction.UPDATE_ASSET_GROUP, payload)
async def update_asset(self, *, project_name: str, asset_id: str, name: str | None = None) -> dict[str, Any]:
payload: dict[str, Any] = {"Id": asset_id, "ProjectName": project_name}
if name is not None:
payload["Name"] = name
return await self._call(ArkPrivatePortraitAction.UPDATE_ASSET, payload)
async def delete_asset(self, *, project_name: str, asset_id: str) -> dict[str, Any]:
return await self._call(ArkPrivatePortraitAction.DELETE_ASSET, {"Id": asset_id, "ProjectName": project_name})
async def delete_asset_group(self, *, project_name: str, group_id: str) -> dict[str, Any]:
return await self._call(ArkPrivatePortraitAction.DELETE_ASSET_GROUP, {"Id": group_id, "ProjectName": project_name})
async def _call(self, action: ArkPrivatePortraitAction, payload: dict[str, Any]) -> dict[str, Any]:
action_value = action.value
await acquire_private_portrait_action_token(action=action_value, wait_timeout_seconds=2.0, for_celery=self.for_celery)
log_remote_api_event(
domain=DOMAIN,
remote_action=action_value,
event_type=PrivatePortraitEventType.ARK_API_CALL_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.CELERY.value if self.for_celery else PrivatePortraitEventSource.SERVICE.value,
request=payload,
)
try:
result = await asyncio.to_thread(self._call_sync, action, payload)
log_remote_api_event(
domain=DOMAIN,
remote_action=action_value,
event_type=PrivatePortraitEventType.ARK_API_CALL_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.CELERY.value if self.for_celery else PrivatePortraitEventSource.SERVICE.value,
request=payload,
response=result,
remote_request_id=result.get("RequestId") or result.get("request_id"),
)
return result
except ArkPrivateAssetRemoteError as exc:
log_remote_api_event(
domain=DOMAIN,
remote_action=action_value,
event_type=PrivatePortraitEventType.ARK_API_CALL_FAILED.value,
event_status=PrivatePortraitEventStatus.FAILED.value,
source=PrivatePortraitEventSource.CELERY.value if self.for_celery else PrivatePortraitEventSource.SERVICE.value,
request=payload,
response=exc.raw,
remote_request_id=exc.request_id,
remote_code=exc.code,
remote_message=exc.message,
)
if self.for_celery:
raise
raise HTTPException(status_code=_remote_error_http_status(exc.code), detail={"message": exc.message, "code": exc.code, "request_id": exc.request_id}) from exc
except HTTPException:
raise
except Exception as exc:
log_remote_api_event(
domain=DOMAIN,
remote_action=action_value,
event_type=PrivatePortraitEventType.ARK_API_CALL_FAILED.value,
event_status=PrivatePortraitEventStatus.FAILED.value,
source=PrivatePortraitEventSource.CELERY.value if self.for_celery else PrivatePortraitEventSource.SERVICE.value,
request=payload,
remote_message=str(exc),
)
if self.for_celery:
raise
raise HTTPException(status_code=502, detail=f"火山私域素材接口调用失败:{self._exception_message(exc)}") from exc
def _call_sync(self, action: ArkPrivatePortraitAction, payload: dict[str, Any]) -> dict[str, Any]:
try:
from volcengine.ApiInfo import ApiInfo
from volcengine.Credentials import Credentials
from volcengine.ServiceInfo import ServiceInfo
from volcengine.base.Service import Service
except Exception as exc:
raise ArkPrivateAssetClientError("缺少火山 volcengine Python SDK。请确认线上环境已安装 volcengine。") from exc
credentials = Credentials(self.ak, self.sk, ARK_PRIVATE_PORTRAIT_SERVICE_NAME, ARK_PRIVATE_PORTRAIT_REGION)
service_info = ServiceInfo(
ARK_PRIVATE_PORTRAIT_HOST,
{"Content-Type": "application/json", "Accept": "application/json"},
credentials,
10,
60,
"https",
)
# volcengine SDK 的 ApiInfo.query 必须是 dict,不能传 "Action=xxx&Version=xxx" 字符串。
# volcengine SDK 签名时要求 body 是 bytes。
# ServiceInfo 默认 scheme='http',这里必须显式传 https,避免请求走 http://host:80。
api_info = {
action.value: ApiInfo(
"POST",
"/",
{"Action": action.value, "Version": ARK_PRIVATE_PORTRAIT_VERSION},
{},
{},
)
}
service = Service(service_info, api_info)
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
try:
raw = service.json(action.value, {}, body)
except Exception as exc:
resp = self._extract_response_from_exception(exc)
if resp:
self._raise_remote_error_if_any(action.value, resp)
message = self._exception_message(exc)
if "ConnectTimeout" in message or "timed out" in message or "Connection" in message:
raise ArkPrivateAssetClientError(f"{action.value} 连接火山私域素材接口失败:{message}") from exc
raise ArkPrivateAssetClientError(f"{action.value} 请求火山私域素材接口异常:{message}") from exc
resp = self._normalize_response(raw)
self._raise_remote_error_if_any(action.value, resp)
metadata = resp.get("ResponseMetadata") if isinstance(resp, dict) else None
request_id = metadata.get("RequestId") if isinstance(metadata, dict) else None
if isinstance(resp, dict) and isinstance(resp.get("Result"), dict):
result = dict(resp["Result"])
if request_id:
result["RequestId"] = request_id
return result
if isinstance(resp, dict):
if request_id:
resp.setdefault("RequestId", request_id)
return resp
return {"raw": resp, "RequestId": request_id}
@classmethod
def _raise_remote_error_if_any(cls, action: str, resp: dict[str, Any]) -> None:
metadata = resp.get("ResponseMetadata") if isinstance(resp, dict) else None
error = metadata.get("Error") if isinstance(metadata, dict) else None
if not error:
return
request_id = metadata.get("RequestId") if isinstance(metadata, dict) else None
code = error.get("Code") or "ArkPrivateAssetError"
message = error.get("Message") or str(error)
raise ArkPrivateAssetRemoteError(action=action, code=code, message=message, request_id=request_id, raw=resp)
@classmethod
def _extract_response_from_exception(cls, exc: Exception) -> dict[str, Any] | None:
# volcengine SDK 在 HTTP 非 2xx 时会 raise Exception(resp.text.encode("utf-8")),这里把 bytes JSON 还原,避免业务错误变 500。
if not exc.args:
return None
raw = exc.args[0]
if isinstance(raw, (bytes, bytearray)):
raw = raw.decode("utf-8", errors="ignore")
if not isinstance(raw, str):
return None
raw = raw.strip()
if not raw or not raw.startswith("{"):
return None
try:
obj = json.loads(raw)
except json.JSONDecodeError:
return None
return obj if isinstance(obj, dict) else None
@staticmethod
def _exception_message(exc: Exception) -> str:
if exc.args:
raw = exc.args[0]
if isinstance(raw, (bytes, bytearray)):
return raw.decode("utf-8", errors="ignore")
return str(raw)
return str(exc)
@staticmethod
def _normalize_response(raw: Any) -> dict[str, Any]:
if raw is None:
return {}
if isinstance(raw, dict):
return raw
if isinstance(raw, (bytes, bytearray)):
raw = raw.decode("utf-8", errors="ignore")
if isinstance(raw, str):
try:
obj = json.loads(raw)
return obj if isinstance(obj, dict) else {"raw": obj}
except json.JSONDecodeError:
return {"raw": raw}
return {"raw": raw}
@@ -0,0 +1,696 @@
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from typing import Any
from urllib.parse import urlencode
from fastapi import HTTPException
from sqlalchemy import and_, func, select, update
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_SUCCESS_RESULT_CODE,
PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES,
PrivatePortraitAssetGroupStatus,
PrivatePortraitAssetStatus,
PrivatePortraitAssetType,
PrivatePortraitEventSource,
PrivatePortraitEventStatus,
PrivatePortraitEventType,
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.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.utils.id_gen import generate_id
DOMAIN = "private_portrait"
def _json(data: Any) -> str | None:
if data is None:
return None
return json.dumps(data, ensure_ascii=False, default=str)
def _loads(data: str | None) -> Any:
if not data:
return None
try:
return json.loads(data)
except Exception:
return None
def _exception_message(exc: Exception) -> str:
if isinstance(exc, HTTPException):
detail = exc.detail
if isinstance(detail, dict):
message = detail.get("message") or detail.get("detail") or detail
return str(message)
return str(detail)
return str(exc)
def _public_url(url: str) -> str:
if url.startswith(("http://", "https://")):
return url
return f"{settings.BASE_URL.rstrip('/')}/{url.lstrip('/')}"
def _callback_url(session_id: str, callback_redirect_url: str | None = None) -> str:
base = f"{settings.BASE_URL.rstrip('/')}/api/private-portrait/validate-callback"
params = {"session_id": session_id}
if callback_redirect_url:
params["redirect_url"] = callback_redirect_url
return f"{base}?{urlencode(params)}"
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"{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)
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
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)
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 validate_session_to_out(session: PrivatePortraitValidateSession, *, include_user: bool = False) -> PrivatePortraitValidateSessionOut:
return PrivatePortraitValidateSessionOut(
id=session.id,
user_id=session.user_id if include_user else None,
project_id=session.project_id,
byted_token=session.byted_token,
h5_link=session.h5_link,
callback_url=session.callback_url,
result_code=session.result_code,
algorithm_base_resp_code=session.algorithm_base_resp_code,
verify_type=session.verify_type,
status=session.status,
remote_group_id=session.remote_group_id,
remote_project_name=session.remote_project_name,
expired_at=session.expired_at,
error_message=session.error_message,
created_at=session.created_at,
updated_at=session.updated_at,
)
def asset_to_out(asset: PrivatePortraitAsset, *, project_name: str | None = None, include_user: bool = False) -> PrivatePortraitAssetOut:
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,
remote_group_id=asset.remote_group_id,
remote_asset_id=asset.remote_asset_id,
remote_project_name=asset.remote_project_name,
asset_type=asset.asset_type,
name=asset.name,
source_url=asset.source_url,
preview_url=asset.preview_url,
remote_url=asset.remote_url,
remote_url_expired_at=asset.remote_url_expired_at,
status=asset.status,
moderation=_loads(asset.moderation_json),
last_poll_at=asset.last_poll_at,
next_poll_at=asset.next_poll_at,
poll_count=asset.poll_count or 0,
remote_delete_status=asset.remote_delete_status,
remote_deleted_at=asset.remote_deleted_at,
remote_delete_error=asset.remote_delete_error,
error_message=asset.error_message,
created_at=asset.created_at,
updated_at=asset.updated_at,
)
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)
session = PrivatePortraitValidateSession(
id=generate_id(),
user_id=user_id,
project_id=project.id,
status=PrivatePortraitValidateSessionStatus.CREATED.value,
remote_project_name=project.remote_project_name,
expired_at=datetime.now(timezone.utc) + timedelta(minutes=PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES),
)
session.callback_url = _callback_url(session.id, callback_redirect_url)
db.add(session)
await db.flush()
try:
resp = await ArkPrivateAssetClient().create_visual_validate_session(project_name=project.remote_project_name, callback_url=session.callback_url)
session.byted_token = resp.get("BytedToken") or resp.get("bytedToken")
session.h5_link = resp.get("H5Link") or resp.get("h5Link")
session.raw_response_json = _json(resp)
await db.flush()
# created_at / updated_at 来自数据库默认值或 onupdateflush 后可能处于 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})
return session
except Exception as exc:
session.status = PrivatePortraitValidateSessionStatus.FAILED.value
session.error_message = _exception_message(exc)
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
async def get_validate_session(db: AsyncSession, *, user_id: str | None, session_id: str) -> PrivatePortraitValidateSession:
filters = [PrivatePortraitValidateSession.id == session_id]
if user_id is not None:
filters.append(PrivatePortraitValidateSession.user_id == user_id)
session = (await db.execute(select(PrivatePortraitValidateSession).where(*filters).limit(1))).scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="真人认证会话不存在")
return 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)
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
session.verify_type = str(query_params.get("verify_type") or query_params.get("verifyType") or "") or None
token = query_params.get("bytedToken") or query_params.get("byted_token") or session.byted_token
if token:
session.byted_token = str(token)
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_CALLBACK_RECEIVED.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, detail={"query_params": query_params, "remote_project_name": session.remote_project_name})
if session.result_code != PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE:
session.status = PrivatePortraitValidateSessionStatus.CALLBACK_FAILED.value
session.error_message = f"真人认证失败:resultCode={session.result_code}"
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
session.status = PrivatePortraitValidateSessionStatus.CALLBACK_SUCCESS.value
if not session.byted_token:
session.status = PrivatePortraitValidateSessionStatus.FAILED.value
session.error_message = "Callback 未返回 BytedToken"
await db.flush()
raise HTTPException(status_code=400, detail=session.error_message)
try:
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")
if not group_id:
raise RuntimeError("GetVisualValidateResult 未返回 GroupId")
session.remote_group_id = group_id
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()
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,
remote_group_id=group_id,
remote_group_name=remote_group_name,
remote_project_name=session.remote_project_name,
group_type=PRIVATE_PORTRAIT_GROUP_TYPE,
status=PrivatePortraitAssetGroupStatus.ACTIVE.value,
raw_response_json=_json(resp),
)
db.add(group)
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)
except Exception as exc:
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_GROUP_UPDATE_REMOTE_FAILED.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, group_id=group.id, exc=exc)
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})
return session
except Exception as exc:
session.status = PrivatePortraitValidateSessionStatus.FAILED.value
session.error_message = _exception_message(exc)
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))
group = result.scalar_one_or_none()
if not group:
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} 张图片,请删除已有素材后再上传")
group = await get_project_active_group(db, user_id=user_id, project_id=project.id)
public_url = _public_url(payload.url)
asset = PrivatePortraitAsset(
id=generate_id(),
user_id=user_id,
project_id=project.id,
group_id=group.id,
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,
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})
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")
if not remote_asset_id:
raise RuntimeError("CreateAsset 未返回素材 ID")
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.raw_response_json = _json(remote_resp)
await refresh_project_counters(db, [project.id])
await db.flush()
# created_at / updated_at 来自数据库默认值或 onupdateflush 后可能处于 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})
return asset
except Exception as exc:
asset.status = PrivatePortraitAssetStatus.FAILED.value
asset.error_message = _exception_message(exc)
await db.flush()
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, exc=exc)
raise
async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id: str) -> PrivatePortraitAsset:
filters = [PrivatePortraitAsset.id == asset_id]
if user_id is not None:
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="真人素材不存在")
if asset.deleted_at is not None:
raise HTTPException(status_code=400, detail="真人素材已删除")
if not asset.remote_asset_id:
raise HTTPException(status_code=400, detail="真人素材尚未创建远程 Asset")
source = PrivatePortraitEventSource.CELERY.value if user_id is None else PrivatePortraitEventSource.API.value
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_SYNC_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=source,
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,
},
)
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)
status = remote_resp.get("Status") or remote_resp.get("status")
now = datetime.now(timezone.utc)
asset.last_poll_at = now
asset.poll_count = int(asset.poll_count or 0) + 1
asset.raw_response_json = _json(remote_resp)
if status:
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"))
if asset.status == PrivatePortraitAssetStatus.PROCESSING.value and asset.poll_count >= PRIVATE_PORTRAIT_ASSET_POLL_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,
)
elif asset.status == PrivatePortraitAssetStatus.PROCESSING.value:
asset.next_poll_at = now + timedelta(seconds=PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS)
else:
asset.next_poll_at = None
if asset.status == PrivatePortraitAssetStatus.FAILED.value and not asset.error_message:
asset.error_message = remote_resp.get("ErrorMessage") or remote_resp.get("error_message") or "素材入库失败"
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},
)
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]]:
page = max(1, page)
page_size = min(max(1, page_size), 100)
filters = [PrivatePortraitAsset.deleted_at.is_(None)]
if user_id:
filters.append(PrivatePortraitAsset.user_id == user_id)
if project_id:
filters.append(PrivatePortraitAsset.project_id == project_id)
if status:
filters.append(PrivatePortraitAsset.status == status)
if keyword:
filters.append(PrivatePortraitAsset.name.ilike(f"%{keyword.strip()}%"))
total = (await db.execute(select(func.count(PrivatePortraitAsset.id)).where(*filters))).scalar_one()
result = await db.execute(select(PrivatePortraitAsset).where(*filters).order_by(PrivatePortraitAsset.created_at.desc()).offset((page - 1) * page_size).limit(page_size))
assets = list(result.scalars().all())
project_ids = list({asset.project_id for asset in assets})
project_name_map: dict[str, str] = {}
if project_ids:
rows = await db.execute(select(PrivatePortraitProject.id, PrivatePortraitProject.name).where(PrivatePortraitProject.id.in_(project_ids)))
project_name_map = {pid: name for pid, name in rows.all()}
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 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()
if not asset:
raise HTTPException(status_code=404, detail="真人素材不存在")
now = datetime.now(timezone.utc)
asset.deleted_at = now
asset.status = PrivatePortraitAssetStatus.LOCAL_DELETED.value
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.PENDING.value
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})
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="远程删除跳过:本地素材不存在",
)
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",
)
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},
)
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},
)
except Exception as exc:
asset.status = PrivatePortraitAssetStatus.DELETE_FAILED.value
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value
asset.remote_delete_error = str(exc)
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_FAILED.value, source=PrivatePortraitEventSource.CELERY.value, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, exc=exc)
await db.flush()
async def _delete_asset_group_remote(db: AsyncSession, *, group: PrivatePortraitAssetGroup, client: ArkPrivateAssetClient | None = None) -> None:
if not group.remote_group_id:
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",
)
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},
)
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},
)
except Exception as exc:
group.status = PrivatePortraitAssetGroupStatus.DELETE_FAILED.value
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value
group.remote_delete_error = str(exc)
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_FAILED.value, source=PrivatePortraitEventSource.CELERY.value, user_id=group.user_id, project_id=group.project_id, group_id=group.id, exc=exc)
await db.flush()
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="开始远程删除真人素材项目资源",
)
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)
groups = await db.execute(select(PrivatePortraitAssetGroup).where(PrivatePortraitAssetGroup.project_id == project_id))
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="远程删除真人素材项目资源完成",
)
await db.flush()
async def poll_due_assets_once(db: AsyncSession, *, limit: int) -> int:
now = datetime.now(timezone.utc)
rows = await db.execute(
select(PrivatePortraitAsset.id)
.where(
PrivatePortraitAsset.deleted_at.is_(None),
PrivatePortraitAsset.status == PrivatePortraitAssetStatus.PROCESSING.value,
PrivatePortraitAsset.next_poll_at.is_not(None),
PrivatePortraitAsset.next_poll_at <= now,
)
.order_by(PrivatePortraitAsset.next_poll_at.asc())
.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)},
)
success_count = 0
failed_count = 0
for asset_id in ids:
try:
await sync_asset_status(db, user_id=None, asset_id=asset_id)
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},
)
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},
)
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_ids = [row[0] for row in asset_rows.all()]
for asset_id in asset_ids:
await delete_asset_remote(db, asset_id=asset_id)
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)
)
client = ArkPrivateAssetClient(for_celery=True)
groups = list(group_rows.scalars().all())
group_count = len(groups)
for group in groups:
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,
)
return result
@@ -0,0 +1,187 @@
from __future__ import annotations
import re
from datetime import datetime, timezone
from fastapi import HTTPException
from sqlalchemy import case, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.private_portrait import (
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
PrivatePortraitAssetGroupStatus,
PrivatePortraitAssetStatus,
PrivatePortraitEventSource,
PrivatePortraitEventStatus,
PrivatePortraitEventType,
PrivatePortraitProjectStatus,
PrivatePortraitRemoteDeleteStatus,
)
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject, PrivatePortraitValidateSession
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
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 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,
name=project.name,
name_slug=project.name_slug,
remote_project_name=project.remote_project_name,
description=project.description,
status=project.status,
asset_group_count=project.asset_group_count or 0,
asset_count=project.asset_count or 0,
active_asset_count=project.active_asset_count 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)
)
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="真人素材项目不存在")
return project
async def create_project(db: AsyncSession, *, user_id: str, payload: PrivatePortraitProjectCreate) -> PrivatePortraitProject:
slug = _safe_slug(payload.name)
project = PrivatePortraitProject(
id=generate_id(),
user_id=user_id,
name=payload.name.strip(),
name_slug=slug,
remote_project_name=PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
description=payload.description,
status=PrivatePortraitProjectStatus.ACTIVE.value,
)
db.add(project)
await db.flush()
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_CREATE.value,
event_status=PrivatePortraitEventStatus.SUCCESS.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},
)
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)
before = {
"name": project.name,
"name_slug": project.name_slug,
"remote_project_name": project.remote_project_name,
"description": project.description,
"status": project.status,
}
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}:
raise HTTPException(status_code=400, detail="项目状态不支持")
project.status = payload.status
await db.flush()
after = {
"name": project.name,
"name_slug": project.name_slug,
"remote_project_name": project.remote_project_name,
"description": project.description,
"status": project.status,
}
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_UPDATE.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.API.value,
user_id=user_id,
project_id=project.id,
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]:
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 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))
return list(result.scalars().all()), int(total or 0)
async def refresh_project_counters(db: AsyncSession, project_ids: list[str]) -> None:
project_ids = list({pid for pid in project_ids if pid})
if not project_ids:
return
group_rows = await db.execute(
select(PrivatePortraitAssetGroup.project_id, func.count(PrivatePortraitAssetGroup.id))
.where(PrivatePortraitAssetGroup.project_id.in_(project_ids), PrivatePortraitAssetGroup.deleted_at.is_(None))
.group_by(PrivatePortraitAssetGroup.project_id)
)
asset_rows = await db.execute(
select(
PrivatePortraitAsset.project_id,
func.count(PrivatePortraitAsset.id),
func.sum(case((PrivatePortraitAsset.status == PrivatePortraitAssetStatus.ACTIVE.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))
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))
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)
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.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})
return project
@@ -0,0 +1,61 @@
from __future__ import annotations
import asyncio
import time
from typing import Any
from app.enums.private_portrait import PRIVATE_PORTRAIT_ACTION_QPS_LIMITS, PrivatePortraitEventSource, PrivatePortraitEventStatus, PrivatePortraitEventType
from app.services.operation_log_service import log_operation_event
from app.utils.redis import get_redis
DOMAIN = "private_portrait"
class PrivatePortraitRateLimitExceeded(RuntimeError):
pass
async def acquire_private_portrait_action_token(
*,
action: str,
wait_timeout_seconds: float = 2.0,
for_celery: bool = False,
) -> bool:
"""Redis 分布式 QPS 限制。Redis 不可用时降级放行,避免影响主功能。"""
limit = int(PRIVATE_PORTRAIT_ACTION_QPS_LIMITS.get(action, 1))
if limit <= 0:
return True
deadline = time.monotonic() + max(0.0, wait_timeout_seconds)
while True:
ok = await _try_take(action, limit)
if ok:
return True
if time.monotonic() >= deadline:
event_type = PrivatePortraitEventType.ARK_API_RATE_LIMIT_WAIT.value if for_celery else PrivatePortraitEventType.ARK_API_RATE_LIMIT_REJECT.value
log_operation_event(
domain=DOMAIN,
event_type=event_type,
event_status=PrivatePortraitEventStatus.SKIPPED.value if for_celery else PrivatePortraitEventStatus.FAILED.value,
source=PrivatePortraitEventSource.CELERY.value if for_celery else PrivatePortraitEventSource.API.value,
remote_action=action,
detail={"limit": limit, "wait_timeout_seconds": wait_timeout_seconds},
message="火山私域真人素材 API 触发本地 QPS 限制",
)
if for_celery:
return False
raise PrivatePortraitRateLimitExceeded("请求过于频繁,请稍后再试")
await asyncio.sleep(0.05)
async def _try_take(action: str, limit: int) -> bool:
client = get_redis()
if client is None:
return True
key = f"private_portrait:qps:{action}:{int(time.time())}"
try:
count = await client.incr(key)
if count == 1:
await client.expire(key, 2)
return int(count) <= limit
except Exception:
return True
@@ -0,0 +1,153 @@
from __future__ import annotations
from copy import deepcopy
from typing import Any
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.private_portrait import (
PRIVATE_PORTRAIT_ASSET_URI_PREFIX,
PrivatePortraitAssetStatus,
PrivatePortraitAssetType,
PrivatePortraitEventSource,
PrivatePortraitEventStatus,
PrivatePortraitEventType,
PrivatePortraitReferenceSource,
)
from app.models.private_portrait import PrivatePortraitAsset
from app.services.operation_log_service import log_operation_error, log_operation_event
DOMAIN = "private_portrait"
_ASSET_TYPE_TO_REFERENCE_TYPE = {
PrivatePortraitAssetType.IMAGE.value: "image",
PrivatePortraitAssetType.VIDEO.value: "video",
PrivatePortraitAssetType.AUDIO.value: "audio",
}
_SUPPORTED_GEN_TYPES = {"image", "video"}
def _ref_get(ref: Any, key: str) -> Any:
if isinstance(ref, dict):
return ref.get(key)
return getattr(ref, key, None)
def _ref_set(ref: Any, key: str, value: Any) -> None:
if isinstance(ref, dict):
ref[key] = value
else:
setattr(ref, key, value)
def _normalize_gen_type(gen_type: str | None) -> str | None:
value = (gen_type or "").strip().lower()
if not value:
return None
if value not in _SUPPORTED_GEN_TYPES:
raise HTTPException(status_code=400, detail="gen_type 仅支持 image 或 video")
return value
def _normalize_ref_type(value: Any) -> str:
return str(value or "").strip().lower()
async def resolve_private_portrait_references(
db: AsyncSession,
*,
user_id: str,
media_references: list[Any] | None,
gen_type: str | None = None,
) -> list[Any] | None:
"""Resolve private portrait references before dispatching a generation task.
generation_ai_service.py 和 generation_task_factory_service.py 都会传 gen_type。
这里保留该参数用于兼容调用方,并做基础校验,避免接口因函数签名不一致直接 500。
"""
normalized_gen_type = _normalize_gen_type(gen_type)
if not media_references:
return media_references
refs = deepcopy(media_references)
ids = [
str(_ref_get(ref, "private_asset_id"))
for ref in refs
if _ref_get(ref, "source") == PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value
and _ref_get(ref, "private_asset_id")
]
ids = list(dict.fromkeys(ids))
if not ids:
return refs
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.REFERENCE_RESOLVE_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.SERVICE.value,
user_id=user_id,
detail={"private_asset_ids": ids, "gen_type": normalized_gen_type},
)
try:
rows = await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id.in_(ids)))
asset_map = {asset.id: asset for asset in rows.scalars().all()}
for ref in refs:
if _ref_get(ref, "source") != PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value:
continue
asset_id = str(_ref_get(ref, "private_asset_id") or "")
if not 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="真人素材不存在")
if asset.user_id != user_id:
raise HTTPException(status_code=403, detail="真人素材不属于当前用户")
if asset.deleted_at is not None:
raise HTTPException(status_code=400, detail="真人素材已删除")
if asset.status != PrivatePortraitAssetStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail=f"真人素材状态为 {asset.status}Active 后才可用于生成")
if not asset.remote_asset_id:
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}")
_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}")
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)
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.REFERENCE_RESOLVE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.SERVICE.value,
user_id=user_id,
detail={"count": len(ids), "gen_type": normalized_gen_type},
)
return refs
except Exception as exc:
log_operation_error(
domain=DOMAIN,
event_type=PrivatePortraitEventType.REFERENCE_RESOLVE_FAILED.value,
source=PrivatePortraitEventSource.SERVICE.value,
user_id=user_id,
detail={"private_asset_ids": ids, "gen_type": normalized_gen_type},
exc=exc,
)
raise
@@ -0,0 +1,236 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.team import Team
from app.models.team_invitation import TeamInvitation
from app.models.team_join_request import TeamJoinRequest
from app.models.user import User
from app.utils.id_gen import generate_id
import secrets
def _generate_invite_code() -> str:
"""生成唯一邀请码。"""
return secrets.token_urlsafe(12)
async def _get_team_or_404(db: AsyncSession, team_id: str) -> Team:
result = await db.execute(
select(Team).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1)
)
team = result.scalar_one_or_none()
if not team:
raise HTTPException(status_code=404, detail="团队不存在")
return team
async def _assert_is_manager(db: AsyncSession, user_id: str, team_id: str) -> None:
team = await _get_team_or_404(db, team_id)
if team.manager_id != user_id:
raise HTTPException(status_code=403, detail="只有团队管理人才能操作")
async def create_invitation(
db: AsyncSession,
team_id: str,
created_by: str,
max_uses: int | None = None,
expires_at: datetime | None = None,
) -> TeamInvitation:
"""创建邀请码(仅团队管理人)。"""
await _assert_is_manager(db, created_by, team_id)
code = _generate_invite_code()
invitation = TeamInvitation(
id=generate_id(),
team_id=team_id,
code=code,
created_by=created_by,
status="active",
max_uses=max_uses,
expires_at=expires_at,
)
db.add(invitation)
await db.flush()
await db.refresh(invitation)
return invitation
async def get_invitation_by_code(db: AsyncSession, code: str) -> TeamInvitation | None:
"""根据邀请码获取邀请记录(验证有效)。"""
result = await db.execute(
select(TeamInvitation).where(
TeamInvitation.code == code,
TeamInvitation.deleted_at.is_(None),
).limit(1)
)
invitation = result.scalar_one_or_none()
if not invitation or invitation.status != "active":
return None
if invitation.expires_at and invitation.expires_at < datetime.now(timezone.utc):
return None
if invitation.max_uses and invitation.use_count >= invitation.max_uses:
return None
return invitation
async def revoke_invitation(db: AsyncSession, invitation_id: str, revoked_by: str) -> None:
"""撤销邀请码。"""
result = await db.execute(
select(TeamInvitation).where(
TeamInvitation.id == invitation_id,
TeamInvitation.deleted_at.is_(None),
).limit(1)
)
invitation = result.scalar_one_or_none()
if not invitation:
raise HTTPException(status_code=404, detail="邀请码不存在")
await _assert_is_manager(db, revoked_by, invitation.team_id)
invitation.status = "revoked"
await db.flush()
async def create_join_request(
db: AsyncSession,
user_id: str,
invitation_code: str,
) -> TeamJoinRequest:
"""通过邀请码创建加入申请。"""
invitation = await get_invitation_by_code(db, invitation_code)
if not invitation:
raise HTTPException(status_code=400, detail="邀请码无效或已过期/已用完")
# 验证用户存在
user_result = await db.execute(
select(User).where(User.id == user_id, User.deleted_at.is_(None)).limit(1)
)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# 检查是否已在该团队
if user.team_id == invitation.team_id:
raise HTTPException(status_code=400, detail="您已在此团队中")
# 检查是否已有待审批的申请
existing = await db.execute(
select(TeamJoinRequest).where(
TeamJoinRequest.user_id == user_id,
TeamJoinRequest.team_id == invitation.team_id,
TeamJoinRequest.status == "pending",
).limit(1)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="您已提交申请,请等待审批")
# 检查是否已有待审批的其他团队申请
existing_any = await db.execute(
select(TeamJoinRequest).where(
TeamJoinRequest.user_id == user_id,
TeamJoinRequest.status == "pending",
).limit(1)
)
if existing_any.scalar_one_or_none():
raise HTTPException(status_code=400, detail="您已有一个待审批的团队申请,请先等待处理")
request = TeamJoinRequest(
id=generate_id(),
team_id=invitation.team_id,
user_id=user_id,
invitation_id=invitation.id,
status="pending",
)
db.add(request)
# 增加邀请码使用次数
invitation.use_count = (invitation.use_count or 0) + 1
if invitation.max_uses and invitation.use_count >= invitation.max_uses:
invitation.status = "revoked"
await db.flush()
return request
async def handle_join_request(
db: AsyncSession,
request_id: str,
manager_id: str,
action: str,
note: str | None = None,
) -> None:
"""审批/拒绝加入申请(仅团队管理人)。"""
result = await db.execute(
select(TeamJoinRequest).where(TeamJoinRequest.id == request_id).limit(1)
)
request = result.scalar_one_or_none()
if not request:
raise HTTPException(status_code=404, detail="申请不存在")
if request.status != "pending":
raise HTTPException(status_code=400, detail="该申请已处理")
await _assert_is_manager(db, manager_id, request.team_id)
if action == "approve":
# 检查用户是否已在其他团队
user_result = await db.execute(
select(User).where(User.id == request.user_id, User.deleted_at.is_(None)).limit(1)
)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user.team_id and user.team_id != request.team_id:
raise HTTPException(status_code=400, detail="用户已在其他团队中,无法加入")
user.team_id = request.team_id
request.status = "approved"
elif action == "reject":
request.status = "rejected"
request.note = note
else:
raise HTTPException(status_code=400, detail="无效操作")
request.handled_by = manager_id
await db.flush()
async def get_pending_requests(db: AsyncSession, team_id: str) -> list[dict[str, Any]]:
"""获取团队待审批的申请列表。"""
result = await db.execute(
select(TeamJoinRequest, User.username, User.phone)
.join(User, User.id == TeamJoinRequest.user_id)
.where(
TeamJoinRequest.team_id == team_id,
TeamJoinRequest.status == "pending",
)
.order_by(TeamJoinRequest.created_at.asc())
)
rows = result.all()
return [
{
"id": req.id,
"team_id": req.team_id,
"user_id": req.user_id,
"username": username,
"phone": phone,
"status": req.status,
"note": req.note,
"created_at": req.created_at,
}
for req, username, phone in rows
]
async def get_invitations_for_team(db: AsyncSession, team_id: str) -> list[TeamInvitation]:
"""获取团队的所有邀请码。"""
result = await db.execute(
select(TeamInvitation).where(
TeamInvitation.team_id == team_id,
TeamInvitation.deleted_at.is_(None),
).order_by(TeamInvitation.created_at.desc())
)
return list(result.scalars().all())
@@ -0,0 +1,242 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_record import (
CreditRecordBillingScene,
CreditRecordChargeKind,
CreditRecordSubject,
CreditRecordSourceModule,
)
from app.enums.team import TeamStatus
from app.enums.user import UserType
from app.models.team import Team
from app.models.user import User
from app.services.credits import add_credits, deduct_credits
from app.services.credit_record_meta_service import CreditRecordMeta
from app.utils.id_gen import generate_id
async def set_team_manager(db: AsyncSession, team_id: str, user_id: str | None) -> Team:
"""设置团队管理人。user_id 为 None 表示取消管理人。"""
result = await db.execute(
select(Team).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1)
)
team = result.scalar_one_or_none()
if not team:
raise HTTPException(status_code=404, detail="团队不存在")
if user_id is None:
team.manager_id = None
await db.flush()
await db.refresh(team)
return team
# 验证用户存在、是前台用户、属于该团队
user_result = await db.execute(
select(User).where(User.id == user_id, User.is_active.is_(True)).limit(1)
)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user.user_type != UserType.FRONTEND.value:
raise HTTPException(status_code=400, detail="仅前台用户可设为团队管理人")
if user.team_id != team_id:
raise HTTPException(status_code=400, detail="用户不属于该团队,请先将其加入团队")
team.manager_id = user_id
await db.flush()
await db.refresh(team)
return team
async def is_team_manager(db: AsyncSession, user_id: str, team_id: str | None) -> bool:
"""判断用户是否是指定团队的管理人。"""
if not team_id:
return False
result = await db.execute(
select(Team.manager_id).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1)
)
manager_id = result.scalar_one_or_none()
return manager_id == user_id
async def get_managed_team(db: AsyncSession, user_id: str) -> Team | None:
"""获取用户管理的团队。"""
result = await db.execute(
select(Team).where(Team.manager_id == user_id, Team.deleted_at.is_(None)).limit(1)
)
return result.scalar_one_or_none()
async def get_team_members(
db: AsyncSession,
team_id: str,
*,
page: int = 1,
page_size: int = 20,
) -> dict[str, Any]:
"""列出团队成员(仅前台用户)。"""
page = max(int(page or 1), 1)
page_size = min(max(int(page_size or 20), 1), 100)
# 验证团队存在
team_result = await db.execute(
select(Team).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1)
)
if not team_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="团队不存在")
# 总数
from sqlalchemy import func
total = (await db.execute(
select(func.count(User.id)).where(
User.user_type == UserType.FRONTEND.value,
User.team_id == team_id,
User.is_active.is_(True),
)
)).scalar() or 0
# 列表
result = await db.execute(
select(User).where(
User.user_type == UserType.FRONTEND.value,
User.team_id == team_id,
User.is_active.is_(True),
)
.order_by(User.created_at.asc())
.offset((page - 1) * page_size)
.limit(page_size)
)
members = list(result.scalars().all())
return {
"items": [
{
"id": m.id,
"username": m.username,
"phone": m.phone,
"credits": m.credits,
"is_active": m.is_active,
"joined_at": m.created_at,
}
for m in members
],
"total": total,
}
async def transfer_credits_to_member(
db: AsyncSession,
manager_id: str,
target_member_id: str,
amount: float,
direction: str = "increase", # "increase" 管理人→成员; "decrease" 成员扣减
description: str | None = None,
) -> None:
"""管理人为团队成员增加或扣减积分。
direction:
- "increase": 管理人从自己余额转积分给成员(管理人减少,成员增加)
- "decrease": 从成员扣积分回到管理人(成员减少,管理人增加)
"""
if amount <= 0:
raise HTTPException(status_code=400, description="积分数量必须大于0")
if direction not in ("increase", "decrease"):
raise HTTPException(status_code=400, description="无效操作方向")
# 获取管理人
manager_result = await db.execute(
select(User).where(User.id == manager_id, User.is_active.is_(True)).limit(1)
)
manager = manager_result.scalar_one_or_none()
if not manager:
raise HTTPException(status_code=404, detail="管理员不存在")
# 获取目标成员
member_result = await db.execute(
select(User).where(User.id == target_member_id, User.is_active.is_(True)).limit(1)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="成员不存在")
# 验证管理人是该团队的管理人且目标是同团队成员
if not manager.team_id:
raise HTTPException(status_code=400, detail="您不在任何团队中")
if member.team_id != manager.team_id:
raise HTTPException(status_code=400, detail="只能操作同团队成员")
team_result = await db.execute(
select(Team).where(Team.id == manager.team_id, Team.deleted_at.is_(None)).limit(1)
)
team = team_result.scalar_one_or_none()
if not team or team.manager_id != manager_id:
raise HTTPException(status_code=403, detail="只有团队管理人才能分配积分")
# 禁止管理人给自己转积分
if target_member_id == manager_id:
raise HTTPException(status_code=400, detail="不能给自己调整积分")
desc = description or ("团队积分发放" if direction == "increase" else "团队积分扣减")
xfer_id = generate_id()
# 构建团队内部转账的 meta,确保 team_id_snapshot 等字段被正确设置
def _build_team_transfer_meta(uid: str) -> CreditRecordMeta:
meta = CreditRecordMeta(
owner_type="team_internal_transfer",
owner_id=xfer_id,
charge_kind=CreditRecordChargeKind.TEAM_INTERNAL.value,
credit_subject=CreditRecordSubject.TEAM_INTERNAL.value,
source_module=CreditRecordSourceModule.TEAM.value,
billing_scene=CreditRecordBillingScene.TEAM_INTERNAL_TRANSFER.value,
)
return meta
if direction == "increase":
# 管理人扣减
await deduct_credits(
db,
manager_id,
amount,
f"分配给成员 {member.username}: {desc}",
record_type="team_internal",
biz_key=f"mgr_xfer_out:{manager_id}:{target_member_id}:{xfer_id}",
record_meta=_build_team_transfer_meta(manager_id),
)
# 成员增加
await add_credits(
db,
target_member_id,
amount,
f"来自团队管理人: {desc}",
record_type="team_internal",
biz_key=f"mgr_xfer_in:{manager_id}:{target_member_id}:{xfer_id}",
record_meta=_build_team_transfer_meta(target_member_id),
)
else:
# 成员扣减
await deduct_credits(
db,
target_member_id,
amount,
f"扣减给团队管理人: {desc}",
record_type="team_internal",
biz_key=f"mgr_deduct_out:{manager_id}:{target_member_id}:{xfer_id}",
record_meta=_build_team_transfer_meta(target_member_id),
)
# 管理人增加
await add_credits(
db,
manager_id,
amount,
f"来自成员 {member.username}: {desc}",
record_type="team_internal",
biz_key=f"mgr_deduct_in:{manager_id}:{target_member_id}:{xfer_id}",
record_meta=_build_team_transfer_meta(manager_id),
)
+23 -2
View File
@@ -31,7 +31,7 @@ def _team_snapshot(team: Team | None) -> dict[str, Any]:
} }
def _team_out_payload(team: Team, member_count: int = 0) -> dict[str, Any]: def _team_out_payload(team: Team, member_count: int = 0, manager_name: str | None = None) -> dict[str, Any]:
return { return {
"id": team.id, "id": team.id,
"name": team.name, "name": team.name,
@@ -42,6 +42,8 @@ def _team_out_payload(team: Team, member_count: int = 0) -> dict[str, Any]:
"member_count": int(member_count or 0), "member_count": int(member_count or 0),
"created_at": team.created_at, "created_at": team.created_at,
"updated_at": team.updated_at, "updated_at": team.updated_at,
"manager_id": getattr(team, "manager_id", None),
"manager_name": manager_name,
} }
@@ -112,8 +114,25 @@ async def list_teams(
.group_by(User.team_id) .group_by(User.team_id)
) )
member_map = {row[0]: int(row[1] or 0) for row in member_result.all()} member_map = {row[0]: int(row[1] or 0) for row in member_result.all()}
# 批量获取管理人用户名
manager_ids = [getattr(t, "manager_id", None) for t in teams if getattr(t, "manager_id", None)]
manager_name_map: dict[str, str] = {}
if manager_ids:
mgr_result = await db.execute(
select(User.id, User.username).where(User.id.in_(manager_ids))
)
manager_name_map = {row[0]: row[1] for row in mgr_result.all()}
return { return {
"items": [_team_out_payload(team, member_map.get(team.id, 0)) for team in teams], "items": [
_team_out_payload(
team,
member_map.get(team.id, 0),
manager_name_map.get(getattr(team, "manager_id", None)),
)
for team in teams
],
"total": total, "total": total,
} }
@@ -164,6 +183,7 @@ async def create_team(db: AsyncSession, req: TeamCreate) -> Team:
) )
db.add(team) db.add(team)
await db.flush() await db.flush()
await db.refresh(team)
return team return team
@@ -190,6 +210,7 @@ async def update_team(db: AsyncSession, team_id: str, req: TeamUpdate) -> tuple[
team.status = req.status or TeamStatus.ACTIVE.value team.status = req.status or TeamStatus.ACTIVE.value
team.sort_order = req.sort_order or 0 team.sort_order = req.sort_order or 0
await db.flush() await db.flush()
await db.refresh(team)
after = { after = {
"id": team.id, "id": team.id,
+2 -1
View File
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from volcenginesdkarkruntime import AsyncArk from volcenginesdkarkruntime import AsyncArk
from app.config import settings from app.config import settings
from app.enums.private_portrait import PRIVATE_PORTRAIT_ASSET_URI_PREFIX
from app.models.video_engine import VideoEngine from app.models.video_engine import VideoEngine
from app.services.log_config import is_enabled, LOG_DIR, LOG_DATE_FORMAT, encrypt_data from app.services.log_config import is_enabled, LOG_DIR, LOG_DATE_FORMAT, encrypt_data
from app.services.generation_provider_types import ( from app.services.generation_provider_types import (
@@ -98,7 +99,7 @@ def _resolve_url(url: str) -> str:
# with open(file_path, "rb") as f: # with open(file_path, "rb") as f:
# b64 = base64.b64encode(f.read()).decode() # b64 = base64.b64encode(f.read()).decode()
# return f"data:{mime};base64,{b64}" # return f"data:{mime};base64,{b64}"
if url.startswith(("http://", "https://", "data:")): if url.startswith(("http://", "https://", "data:", PRIVATE_PORTRAIT_ASSET_URI_PREFIX)):
return url return url
return f"{settings.BASE_URL.rstrip('/')}/{url.lstrip('/')}" return f"{settings.BASE_URL.rstrip('/')}/{url.lstrip('/')}"
+19 -4
View File
@@ -24,6 +24,7 @@ CELERY_TASK_IMPORTS = (
"app.tasks.module_async_recovery_tasks", "app.tasks.module_async_recovery_tasks",
"app.tasks.user_oauth_tasks", "app.tasks.user_oauth_tasks",
"app.tasks.cleanup", "app.tasks.cleanup",
"app.tasks.private_portrait_asset_tasks",
) )
@@ -41,10 +42,9 @@ def _derive_redis_db(url: str, db_no: int) -> str:
def _beat_schedule() -> dict: def _beat_schedule() -> dict:
if not bool(getattr(settings, "POLL_DUE_DISPATCH_ENABLED", True)): schedule: dict = {}
return {} if bool(getattr(settings, "POLL_DUE_DISPATCH_ENABLED", True)):
return { schedule["dispatch-due-poll-tasks-every-minute"] = {
"dispatch-due-poll-tasks-every-minute": {
"task": CeleryTaskName.DISPATCH_DUE_POLL.value, "task": CeleryTaskName.DISPATCH_DUE_POLL.value,
"schedule": max(1, int(settings.POLL_DUE_DISPATCH_INTERVAL_SECONDS or 60)), "schedule": max(1, int(settings.POLL_DUE_DISPATCH_INTERVAL_SECONDS or 60)),
"options": { "options": {
@@ -52,7 +52,17 @@ def _beat_schedule() -> dict:
"priority": settings.DOWNLOAD_TASK_PRIORITY_RECOVER, "priority": settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
}, },
} }
schedule["private-portrait-sync-due-assets-every-minute"] = {
"task": CeleryTaskName.PRIVATE_PORTRAIT_SYNC_DUE_ASSETS.value,
"schedule": 60,
"options": {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
} }
schedule["private-portrait-recover-remote-deletes-every-5-minutes"] = {
"task": CeleryTaskName.PRIVATE_PORTRAIT_RECOVER_REMOTE_DELETES.value,
"schedule": 300,
"options": {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
}
return schedule
broker_url = settings.CELERY_BROKER_URL or (_derive_redis_db(settings.REDIS_URL, 1) if settings.REDIS_URL else "") broker_url = settings.CELERY_BROKER_URL or (_derive_redis_db(settings.REDIS_URL, 1) if settings.REDIS_URL else "")
@@ -116,6 +126,11 @@ if broker_url:
CeleryTaskName.MODULE_ASYNC_RECOVERY.value: {"queue": RECOVERY_QUEUE}, CeleryTaskName.MODULE_ASYNC_RECOVERY.value: {"queue": RECOVERY_QUEUE},
"user_oauth.update_oauth_accounts": {"queue": CeleryQueue.DEFAULT.value}, "user_oauth.update_oauth_accounts": {"queue": CeleryQueue.DEFAULT.value},
"app.tasks.cleanup.*": {"queue": CeleryQueue.DEFAULT.value}, "app.tasks.cleanup.*": {"queue": CeleryQueue.DEFAULT.value},
CeleryTaskName.PRIVATE_PORTRAIT_POLL_ASSET.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
CeleryTaskName.PRIVATE_PORTRAIT_SYNC_DUE_ASSETS.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
CeleryTaskName.PRIVATE_PORTRAIT_DELETE_ASSET.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
CeleryTaskName.PRIVATE_PORTRAIT_DELETE_PROJECT.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
CeleryTaskName.PRIVATE_PORTRAIT_RECOVER_REMOTE_DELETES.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
}, },
) )
else: else:
@@ -0,0 +1,153 @@
from __future__ import annotations
import logging
from typing import Any
from app.enums.private_portrait import (
PRIVATE_PORTRAIT_ASSET_POLL_BATCH_SIZE,
PRIVATE_PORTRAIT_REMOTE_DELETE_RECOVERY_BATCH_SIZE,
PrivatePortraitEventSource,
PrivatePortraitEventType,
)
from app.models import async_session
from app.services.operation_log_service import log_operation_error
from app.services.private_portrait.asset_service import (
DOMAIN,
delete_asset_remote,
delete_project_remote,
poll_due_assets_once,
recover_remote_deletes_once,
sync_asset_status,
)
from app.tasks.async_runner import run_async
from app.tasks.celery_app import celery_app
logger = logging.getLogger(__name__)
def _retry_countdown(retries: int) -> int:
return min(300, 30 * (2 ** max(0, retries)))
async def _rollback_and_reraise(db, *, event_type: str, exc: BaseException, detail: dict[str, Any] | None = None, **kwargs: Any):
await db.rollback()
log_operation_error(
domain=DOMAIN,
event_type=event_type,
source=PrivatePortraitEventSource.CELERY.value,
exc=exc,
detail=detail,
**kwargs,
)
raise exc
@celery_app.task(name="private_portrait.poll_asset_status", queue="gen_private_portrait", bind=True, max_retries=5, default_retry_delay=30)
def poll_private_portrait_asset_status(self, asset_id: str) -> None:
async def _inner():
async with async_session() as db:
try:
await sync_asset_status(db, user_id=None, asset_id=asset_id)
await db.commit()
except Exception as exc:
logger.exception("poll private portrait asset failed: %s", asset_id)
await _rollback_and_reraise(
db,
event_type=PrivatePortraitEventType.ASSET_POLL_FAILED.value,
exc=exc,
asset_id=asset_id,
detail={"celery_task": "private_portrait.poll_asset_status"},
)
try:
run_async(_inner())
except Exception as exc:
raise self.retry(exc=exc, countdown=_retry_countdown(self.request.retries))
@celery_app.task(name="private_portrait.sync_due_assets", queue="gen_private_portrait", bind=True, max_retries=3, default_retry_delay=60)
def sync_private_portrait_due_assets(self) -> int:
async def _inner() -> int:
async with async_session() as db:
try:
count = await poll_due_assets_once(db, limit=PRIVATE_PORTRAIT_ASSET_POLL_BATCH_SIZE)
await db.commit()
return count
except Exception as exc:
logger.exception("sync private portrait due assets failed")
await _rollback_and_reraise(
db,
event_type=PrivatePortraitEventType.SYNC_DUE_ASSETS_FAILED.value,
exc=exc,
detail={"celery_task": "private_portrait.sync_due_assets"},
)
try:
return run_async(_inner())
except Exception as exc:
raise self.retry(exc=exc, countdown=_retry_countdown(self.request.retries))
@celery_app.task(name="private_portrait.delete_asset_remote", queue="gen_private_portrait", bind=True, max_retries=3, default_retry_delay=60)
def delete_private_portrait_asset_remote(self, asset_id: str) -> None:
async def _inner():
async with async_session() as db:
try:
await delete_asset_remote(db, asset_id=asset_id)
await db.commit()
except Exception as exc:
logger.exception("delete private portrait asset remote failed: %s", asset_id)
await _rollback_and_reraise(
db,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_FAILED.value,
exc=exc,
asset_id=asset_id,
detail={"celery_task": "private_portrait.delete_asset_remote"},
)
try:
run_async(_inner())
except Exception as exc:
raise self.retry(exc=exc, countdown=_retry_countdown(self.request.retries))
@celery_app.task(name="private_portrait.delete_project_remote", queue="gen_private_portrait", bind=True, max_retries=3, default_retry_delay=60)
def delete_private_portrait_project_remote(self, project_id: str) -> None:
async def _inner():
async with async_session() as db:
try:
await delete_project_remote(db, project_id=project_id)
await db.commit()
except Exception as exc:
logger.exception("delete private portrait project remote failed: %s", project_id)
await _rollback_and_reraise(
db,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_FAILED.value,
exc=exc,
project_id=project_id,
detail={"celery_task": "private_portrait.delete_project_remote"},
)
try:
run_async(_inner())
except Exception as exc:
raise self.retry(exc=exc, countdown=_retry_countdown(self.request.retries))
@celery_app.task(name="private_portrait.recover_remote_deletes", queue="gen_private_portrait", bind=True, max_retries=3, default_retry_delay=60)
def recover_private_portrait_remote_deletes(self) -> dict[str, int]:
async def _inner() -> dict[str, int]:
async with async_session() as db:
try:
result = await recover_remote_deletes_once(db, limit=PRIVATE_PORTRAIT_REMOTE_DELETE_RECOVERY_BATCH_SIZE)
await db.commit()
return result
except Exception as exc:
logger.exception("recover private portrait remote deletes failed")
await _rollback_and_reraise(
db,
event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_FAILED.value,
exc=exc,
detail={"celery_task": "private_portrait.recover_remote_deletes"},
)
try:
return run_async(_inner())
except Exception as exc:
raise self.retry(exc=exc, countdown=_retry_countdown(self.request.retries))
@@ -73,8 +73,8 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
if data.get("code") != 0: if data.get("code") != 0:
logger.error(f"刷新巨量引擎token失败: oauth_id={oauth.id}, 错误信息: {data}") logger.error(f"刷新巨量引擎token失败: oauth_id={oauth.id}, 错误信息: {data}")
# refresh_token已失效 # refresh_token已失效或刷新失败,统一清理token
if data.get("code") in [40103, 40107]: if data.get("code") in [40103, 40107, 40000]:
where_cond = UserOAuth.deleted_at.is_(None) where_cond = UserOAuth.deleted_at.is_(None)
if oauth.appid: if oauth.appid:
where_cond = where_cond & (UserOAuth.appid == oauth.appid) where_cond = where_cond & (UserOAuth.appid == oauth.appid)
@@ -95,7 +95,6 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
related_oauth_ids = await db.execute(select(UserOAuth.id).where(where_cond)) related_oauth_ids = await db.execute(select(UserOAuth.id).where(where_cond))
related_oauth_ids = [row[0] for row in related_oauth_ids.all()] related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
# 【改动2:失效直接删除Redis缓存,不再写入空值】
for related_id in related_oauth_ids: for related_id in related_oauth_ids:
await _delete_redis_token(related_id) await _delete_redis_token(related_id)
return return
+13 -1
View File
@@ -136,7 +136,13 @@ class DouyinRequest:
now_beijing = datetime.now(tz=BEIJING_TZ) now_beijing = datetime.now(tz=BEIJING_TZ)
if force_refresh: if force_refresh:
# 强制刷新:清空同条件下所有账号缓存与数据库token # 强制刷新:先检查refresh_token是否为空
if not oauth_data.refresh_token:
# RefreshToken为空 → 授权已失效,清理Redis脏缓存
await self._delete_redis_token(oauth_id)
raise ValueError("授权已过期,请重新授权登录")
# 清空同条件下所有账号缓存与数据库token
where_cond = UserOAuth.deleted_at.is_(None) where_cond = UserOAuth.deleted_at.is_(None)
if oauth_data.appid: if oauth_data.appid:
where_cond = where_cond & (UserOAuth.appid == oauth_data.appid) where_cond = where_cond & (UserOAuth.appid == oauth_data.appid)
@@ -191,6 +197,11 @@ class DouyinRequest:
return token return token
# =========【核心业务规则实现:判断RefreshToken是否过期】========= # =========【核心业务规则实现:判断RefreshToken是否过期】=========
if not oauth_data.refresh_token:
# RefreshToken为空 → 授权已失效,清理Redis脏缓存
await self._delete_redis_token(oauth_id)
raise ValueError("授权已过期,请重新授权登录")
if oauth_data.refresh_token_expired and oauth_data.refresh_token_expired < now_beijing: if oauth_data.refresh_token_expired and oauth_data.refresh_token_expired < now_beijing:
# RefreshToken过期 → 授权彻底失效,清理Redis脏缓存 # RefreshToken过期 → 授权彻底失效,清理Redis脏缓存
await self._delete_redis_token(oauth_id) await self._delete_redis_token(oauth_id)
@@ -242,6 +253,7 @@ class DouyinRequest:
code = data.get('code', 0) code = data.get('code', 0)
if code != 0: if code != 0:
#刷新token
raise ValueError(f"刷新access_token失败,接口返回:{data}") raise ValueError(f"刷新access_token失败,接口返回:{data}")
data = data.get('data', {}) data = data.get('data', {})
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-CclRZKjR.js"></script> <script type="module" crossorigin src="/assets/index-DzBPiPEO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css"> <link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css">
</head> </head>
<body> <body>
+4
View File
@@ -32,6 +32,8 @@ import ConsumePage from './pages/ConsumePage';
import AuthorizationWaitingPage from './pages/AuthorizationWaitingPage'; import AuthorizationWaitingPage from './pages/AuthorizationWaitingPage';
import PopularPage from './pages/PopularPage'; import PopularPage from './pages/PopularPage';
import CreativePlazaPage from './pages/CreativePlazaPage'; import CreativePlazaPage from './pages/CreativePlazaPage';
import TeamManagementPage from './pages/TeamManagementPage';
import JoinTeamPage from './pages/JoinTeamPage';
import { useAuthStore } from './store/useAuthStore'; import { useAuthStore } from './store/useAuthStore';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { user, loading, checkAuth } = useAuthStore(); const { user, loading, checkAuth } = useAuthStore();
@@ -124,6 +126,8 @@ const App = () => {
<Route path="popular" element={<PopularPage />} /> <Route path="popular" element={<PopularPage />} />
<Route path="authacc" element={<AuthAccountPage />} /> <Route path="authacc" element={<AuthAccountPage />} />
<Route path="creativeplaza" element={<CreativePlazaPage />} /> <Route path="creativeplaza" element={<CreativePlazaPage />} />
<Route path="team-management" element={<TeamManagementPage />} />
<Route path="join-team" element={<JoinTeamPage />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/projects" replace />} /> <Route path="*" element={<Navigate to="/projects" replace />} />
</Routes> </Routes>
+146
View File
@@ -7,6 +7,8 @@ import * as mock from './mock';
import type { import type {
User, CreditRecord, Project, GenerationRecord, OptimizeParams, GenerateParams, OptimizeResult, User, CreditRecord, Project, GenerationRecord, OptimizeParams, GenerateParams, OptimizeResult,
Industry, IndustryConfig, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification, Industry, IndustryConfig, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification,
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitProject, PrivatePortraitValidateSession,
PrivatePortraitAssetListOut, PrivatePortraitAsset, PrivatePortraitSelectableAssetListOut,
} from '../types'; } from '../types';
const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true'; const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true';
// ── Auth ────────────────────────────────────────────────── // ── Auth ──────────────────────────────────────────────────
@@ -738,3 +740,147 @@ export async function getHomeCaseButton(id: string,limit:number=5): Promise<any>
export async function deleteResourcesMaterial(params:any): Promise<any> { export async function deleteResourcesMaterial(params:any): Promise<any> {
return api.delete(`/generation-ai/history/batch`, params); return api.delete(`/generation-ai/history/batch`, params);
} }
export async function getPrivatePortraitConfig(): Promise<PrivatePortraitConfig> {
return api.get<PrivatePortraitConfig>('/private-portrait/config');
}
// ── Private Portrait Library ──────────────────────────────
export async function getPrivatePortraitProjects(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/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 updatePrivatePortraitProject(projectId: string, payload: { name?: string; description?: string | null; status?: string }): Promise<PrivatePortraitProject> {
return api.put<PrivatePortraitProject>(`/private-portrait/projects/${projectId}`, payload);
}
export async function deletePrivatePortraitProject(projectId: string): Promise<void> {
await api.delete(`/private-portrait/projects/${projectId}`);
}
export async function createPrivatePortraitValidateSession(projectId: string, callbackRedirectUrl?: string): Promise<PrivatePortraitValidateSession> {
return api.post<PrivatePortraitValidateSession>(`/private-portrait/projects/${projectId}/validate-sessions`, { callback_redirect_url: callbackRedirectUrl || null });
}
export async function getPrivatePortraitValidateSession(sessionId: string): Promise<PrivatePortraitValidateSession> {
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 getPrivatePortraitAssets(projectId: string, params: { page?: number; pageSize?: number; status?: string; keyword?: 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);
return api.get<PrivatePortraitAssetListOut>(`/private-portrait/projects/${projectId}/assets?${query.toString()}`);
}
export async function syncPrivatePortraitAsset(assetId: string): Promise<PrivatePortraitAsset> {
return api.post<PrivatePortraitAsset>(`/private-portrait/assets/${assetId}/sync`);
}
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> {
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);
return api.get<PrivatePortraitSelectableAssetListOut>(`/private-portrait/selectable-assets?${query.toString()}`);
}
// ── Team Management APIs ──────────────────────────────
export async function getManagedTeam(): Promise<any> {
return api.get('/team/managed');
}
export async function getTeamMembers(page = 1, pageSize = 20): Promise<any> {
const params = new URLSearchParams();
params.set('page', String(page));
params.set('page_size', String(pageSize));
return api.get(`/team/members?${params.toString()}`);
}
export async function transferCredits(memberId: string, amount: number, direction: string = "increase", description?: string): Promise<void> {
await api.post(`/team/members/${memberId}/credits`, { target_user_id: memberId, amount, direction, description: description || null });
}
export async function getTeamInvitations(): Promise<any[]> {
return api.get('/team/invitations');
}
export async function createTeamInvitation(maxUses?: number, expiresAt?: string): Promise<any> {
return api.post('/team/invitations', { max_uses: maxUses || null, expires_at: expiresAt || null });
}
export async function revokeInvitation(invitationId: string): Promise<void> {
await api.delete(`/team/invitations/${invitationId}`);
}
export async function getPendingJoinRequests(): Promise<any[]> {
return api.get('/team/join-requests');
}
export async function handleJoinRequest(requestId: string, action: 'approve' | 'reject', note?: string): Promise<void> {
await api.post(`/team/join-requests/${requestId}`, { action, note: note || null });
}
export async function getJoinTeamInfo(code: string): Promise<any> {
return api.get(`/team/join-info?code=${encodeURIComponent(code)}`);
}
export async function submitJoinRequest(code: string): Promise<void> {
await api.post('/team/join', { invitation_code: code });
}
export async function getTeamCreditRecords(params: {
page?: number;
pageSize?: number;
userId?: string;
phone?: string;
recordType?: string;
startDate?: string;
endDate?: string;
}): Promise<any> {
const p = new URLSearchParams();
if (params.page) p.set('page', String(params.page));
if (params.pageSize) p.set('page_size', String(params.pageSize));
if (params.userId) p.set('user_id', params.userId);
if (params.phone) p.set('phone', params.phone);
if (params.recordType) p.set('record_type', params.recordType);
if (params.startDate) p.set('start_date', params.startDate);
if (params.endDate) p.set('end_date', params.endDate);
return api.get(`/team/credit-records?${p.toString()}`);
}
export function getTeamCreditExportUrl(params: {
phone?: string;
recordType?: string;
startDate?: string;
endDate?: string;
}): string {
const p = new URLSearchParams();
if (params.phone) p.set('phone', params.phone);
if (params.recordType) p.set('record_type', params.recordType);
if (params.startDate) p.set('start_date', params.startDate);
if (params.endDate) p.set('end_date', params.endDate);
const base = (import.meta as any).env?.VITE_API_BASE || 'http://localhost:8000';
return `${base}/api/team/credit-records/export?${p.toString()}`;
}
@@ -597,6 +597,7 @@ const AppLayout: React.FC = () => {
{ key: 'profile', icon: <UserOutlined />, label: `账号: ${user?.username}`, disabled: true }, { key: 'profile', icon: <UserOutlined />, label: `账号: ${user?.username}`, disabled: true },
{ key: 'credits', icon: <WalletOutlined style={{ color: '#c9a96e' }} />, label: `积分: ${user?.credits ?? 0}`, disabled: true }, { key: 'credits', icon: <WalletOutlined style={{ color: '#c9a96e' }} />, label: `积分: ${user?.credits ?? 0}`, disabled: true },
{ type: 'divider' as const }, { type: 'divider' as const },
...(user?.isTeamManager ? [{ key: 'teamManagement' as const, icon: <TeamOutlined style={{ color: '#6366f1' }} />, label: '团队管理' }] : []),
{ key: 'myCredits', icon: <WalletOutlined />, label: '积分明细' }, { key: 'myCredits', icon: <WalletOutlined />, label: '积分明细' },
{ key: 'orderRecords', icon: <FileTextOutlined />, label: '订单记录' }, { key: 'orderRecords', icon: <FileTextOutlined />, label: '订单记录' },
{ key: 'messages', icon: <BellOutlined />, label: `消息中心${unreadCount > 0 ? `(${unreadCount})` : ''}` }, { key: 'messages', icon: <BellOutlined />, label: `消息中心${unreadCount > 0 ? `(${unreadCount})` : ''}` },
@@ -612,6 +613,7 @@ const AppLayout: React.FC = () => {
else if (key === 'changePwd') { setPwdModalOpen(true); } else if (key === 'changePwd') { setPwdModalOpen(true); }
else if (key === 'messages') { navigate('/messages'); } else if (key === 'messages') { navigate('/messages'); }
else if (key === 'recharge') { setRechargeModalOpen(true); } else if (key === 'recharge') { setRechargeModalOpen(true); }
else if (key === 'teamManagement') { navigate('/team-management'); }
else if (key === 'myCredits') { navigate('/user-center?tab=credits'); } else if (key === 'myCredits') { navigate('/user-center?tab=credits'); }
else if (key === 'orderRecords') { navigate('/user-center?tab=orders'); } else if (key === 'orderRecords') { navigate('/user-center?tab=orders'); }
else if (key === 'manual') { window.open(operationManualUrl, '_blank'); } else if (key === 'manual') { window.open(operationManualUrl, '_blank'); }
@@ -0,0 +1,2 @@
export { default as PrivatePortraitLibraryPanel } from './library/LibraryPanel';
export { default as PrivatePortraitAssetPicker } from './picker/AssetPicker';
@@ -0,0 +1,59 @@
import React from 'react';
import { Button, Empty, Popconfirm, Space, Tag, Tooltip } from 'antd';
import { DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
import type { PrivatePortraitAsset } from '../../../types';
const statusColor: Record<string, string> = {
Active: 'green',
Processing: 'processing',
Failed: 'red',
local_deleted: 'default',
remote_deleted: 'default',
delete_failed: 'red',
};
interface Props {
items: PrivatePortraitAsset[];
loading?: boolean;
onSync: (assetId: string) => void;
onDelete: (assetId: string) => void;
}
const buildPreviewUrl = (url?: string | null) => {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url;
const base = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
return `${base}${url}`;
};
const PrivatePortraitAssetGrid: React.FC<Props> = ({ items, onSync, onDelete }) => {
if (!items.length) return <Empty description="暂无真人素材" />;
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 14 }}>
{items.map((item) => (
<div key={item.id} style={{ border: '1px solid #e2e8f0', borderRadius: 12, overflow: 'hidden', background: '#fff' }}>
<div style={{ height: 150, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{item.previewUrl || item.remoteUrl ? (
<img src={buildPreviewUrl(item.previewUrl || item.remoteUrl)} alt={item.name || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : <span style={{ color: '#94a3b8' }}></span>}
</div>
<div style={{ padding: 10 }}>
<Tooltip title={item.name || item.remoteAssetId}>
<div style={{ fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.name || item.remoteAssetId}</div>
</Tooltip>
<div style={{ marginTop: 8 }}><Tag color={statusColor[item.status] || 'default'}>{item.status}</Tag></div>
{item.errorMessage && <div style={{ color: '#ef4444', fontSize: 12, marginTop: 6 }}>{item.errorMessage}</div>}
<Space style={{ marginTop: 10 }} size={6}>
<Button size="small" icon={<ReloadOutlined />} onClick={() => onSync(item.id)}></Button>
<Popconfirm title="确认删除这个素材吗?" onConfirm={() => onDelete(item.id)}>
<Button size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
</div>
</div>
))}
</div>
);
};
export default PrivatePortraitAssetGrid;
@@ -0,0 +1,61 @@
import React, { useState } from 'react';
import { Button, Input, Modal, Upload, message } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
import { createPrivatePortraitAsset, uploadImage } from '../../../api';
interface Props {
projectId: string;
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
const PrivatePortraitAssetUpload: React.FC<Props> = ({ projectId, open, onClose, onSuccess }) => {
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [name, setName] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
const file = fileList[0]?.originFileObj as File | undefined;
if (!file) {
message.warning('请先选择图片素材');
return;
}
setLoading(true);
try {
const uploaded = await uploadImage(file);
await createPrivatePortraitAsset(projectId, { url: uploaded.url, assetType: 'Image', name: name || file.name });
message.success('素材已提交入库,处理中');
setFileList([]);
setName('');
onSuccess();
onClose();
} catch (e: any) {
message.error(e?.message || '上传素材失败');
} finally {
setLoading(false);
}
};
return (
<Modal title="上传真人素材" open={open} onCancel={onClose} onOk={handleSubmit} confirmLoading={loading} okText="提交入库">
<div style={{ display: 'grid', gap: 12 }}>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="素材名称,默认使用文件名" />
<Upload
accept="image/*"
maxCount={1}
fileList={fileList}
beforeUpload={() => false}
onChange={({ fileList }) => setFileList(fileList)}
listType="picture"
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
<div style={{ color: '#64748b', fontSize: 12 }}>Active AI </div>
</div>
</Modal>
);
};
export default PrivatePortraitAssetUpload;
@@ -0,0 +1,85 @@
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 PrivatePortraitProjectList from './ProjectList';
import PrivatePortraitProjectDetail from './ProjectDetail';
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 [form] = Form.useForm();
const loadProjects = async () => {
setLoading(true);
try {
const res = await getPrivatePortraitProjects({ pageSize: 100 });
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) {
message.error(e?.message || '加载真人素材项目组失败');
} finally {
setLoading(false);
}
};
useEffect(() => { loadProjects(); }, []);
const handleCreate = async () => {
const values = await form.validateFields();
try {
const project = await createPrivatePortraitProject(values);
message.success('项目组已创建');
setCreateOpen(false);
form.resetFields();
await loadProjects();
setSelected(project);
} catch (e: any) {
message.error(e?.message || '创建项目组失败');
}
};
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>
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={loadProjects} loading={loading}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}></Button>
</Space>
</div>
<Row gutter={16}>
<Col xs={24} md={7} lg={6}>
<Card title="项目组" style={{ borderRadius: 12 }}>
<PrivatePortraitProjectList items={projects} selectedId={selected?.id} onSelect={setSelected} />
</Card>
</Col>
<Col xs={24} md={17} lg={18}>
{selected ? (
<PrivatePortraitProjectDetail project={selected} onDeleted={() => { setSelected(null); loadProjects(); }} onChanged={loadProjects} />
) : (
<Card style={{ borderRadius: 12, textAlign: 'center', color: '#94a3b8' }}></Card>
)}
</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>
</div>
);
};
export default PrivatePortraitLibraryPanel;
@@ -0,0 +1,91 @@
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 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;
onDeleted: () => void;
onChanged: () => void;
}
const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onChanged }) => {
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);
try {
const res = await getPrivatePortraitAssets(project.id, { pageSize: 100 });
setAssets(res.items);
} catch (e: any) {
message.error(e?.message || '加载素材失败');
} finally {
setLoading(false);
}
};
useEffect(() => { loadAssets(); }, [project.id]);
const handleSync = async (assetId: string) => {
try {
await syncPrivatePortraitAsset(assetId);
await loadAssets();
onChanged();
message.success('素材状态已刷新');
} catch (e: any) {
message.error(e?.message || '刷新失败');
}
};
const handleDeleteAsset = async (assetId: string) => {
try {
await deletePrivatePortraitAsset(assetId);
await loadAssets();
onChanged();
message.success('素材已删除');
} catch (e: any) {
message.error(e?.message || '删除素材失败');
}
};
const handleDeleteProject = async () => {
try {
await deletePrivatePortraitProject(project.id);
message.success('项目组已删除');
onDeleted();
} catch (e: any) {
message.error(e?.message || '删除项目组失败');
}
};
return (
<Card
title={<span>{project.name}</span>}
extra={(
<Space>
<Button icon={<SafetyCertificateOutlined />} onClick={() => setValidateOpen(true)}></Button>
<Button type="primary" icon={<UploadOutlined />} onClick={() => setUploadOpen(true)}></Button>
<Button icon={<ReloadOutlined />} onClick={loadAssets} loading={loading}></Button>
<Popconfirm title="确认删除这个真人素材项目组吗?" onConfirm={handleDeleteProject}>
<Button danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
)}
style={{ borderRadius: 12 }}
>
<Typography.Paragraph style={{ color: '#64748b' }}>{project.description || '暂无描述'}</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>
);
};
export default PrivatePortraitProjectDetail;
@@ -0,0 +1,35 @@
import React from 'react';
import { Button, Empty, List, Tag } from 'antd';
import type { PrivatePortraitProject } from '../../../types';
interface Props {
items: PrivatePortraitProject[];
selectedId?: string | null;
onSelect: (project: PrivatePortraitProject) => void;
}
const PrivatePortraitProjectList: React.FC<Props> = ({ items, selectedId, onSelect }) => {
if (!items.length) return <Empty description="暂无项目组" />;
return (
<List
dataSource={items}
renderItem={(item) => (
<List.Item style={{ padding: 0, marginBottom: 8 }}>
<Button
block
onClick={() => onSelect(item)}
style={{ height: 'auto', padding: 12, textAlign: 'left', borderColor: selectedId === item.id ? '#8b5cf6' : '#e2e8f0' }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
<strong>{item.name}</strong>
<Tag color={item.activeAssetCount > 0 ? 'green' : 'default'}>{item.activeAssetCount}/{item.assetCount}</Tag>
</div>
{item.description && <div style={{ color: '#64748b', fontSize: 12, marginTop: 4 }}>{item.description}</div>}
</Button>
</List.Item>
)}
/>
);
};
export default PrivatePortraitProjectList;
@@ -0,0 +1,49 @@
import React, { useState } from 'react';
import { Button, Modal, Space, Typography, message } from 'antd';
import { createPrivatePortraitValidateSession } from '../../../api';
import type { PrivatePortraitValidateSession } from '../../../types';
interface Props {
projectId: string;
open: boolean;
onClose: () => void;
onCreated: (session: PrivatePortraitValidateSession) => void;
}
const PrivatePortraitValidateModal: React.FC<Props> = ({ projectId, open, onClose, onCreated }) => {
const [loading, setLoading] = useState(false);
const [session, setSession] = useState<PrivatePortraitValidateSession | null>(null);
const handleCreate = async () => {
setLoading(true);
try {
const redirect = `${window.location.origin}${window.location.pathname}#/private-portrait-authorized`;
const next = await createPrivatePortraitValidateSession(projectId, redirect);
setSession(next);
onCreated(next);
if (next.h5Link) window.open(next.h5Link, '_blank');
} catch (e: any) {
message.error(e?.message || '创建真人授权链接失败');
} finally {
setLoading(false);
}
};
return (
<Modal title="真人授权认证" open={open} onCancel={onClose} footer={null} width={640}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Typography.Paragraph style={{ color: '#475569' }}>
H5 Asset Group
</Typography.Paragraph>
<Button type="primary" loading={loading} onClick={handleCreate}></Button>
{session?.h5Link && (
<div style={{ padding: 12, border: '1px solid #e2e8f0', borderRadius: 8, wordBreak: 'break-all' }}>
<Typography.Text copyable>{session.h5Link}</Typography.Text>
</div>
)}
</Space>
</Modal>
);
};
export default PrivatePortraitValidateModal;
@@ -0,0 +1,222 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Button, Empty, Input, List, Modal, Space, Spin, Tag, Typography, message } from 'antd';
import { CheckOutlined, PictureOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { getPrivatePortraitProjects, getPrivatePortraitSelectableAssets } from '../../../api';
import type { PrivatePortraitProject, PrivatePortraitSelectableAsset } from '../../../types';
const { Text } = Typography;
interface PrivatePortraitAssetPickerProps {
open: boolean;
selectedIds?: string[];
maxCount?: number;
onClose: () => void;
onSelect: (assets: PrivatePortraitSelectableAsset[]) => void;
}
const getPreviewUrl = (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 PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
open,
selectedIds = [],
maxCount = 20,
onClose,
onSelect,
}) => {
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [projectId, setProjectId] = useState<string | undefined>();
const [keyword, setKeyword] = useState('');
const [assets, setAssets] = useState<PrivatePortraitSelectableAsset[]>([]);
const [checked, setChecked] = useState<Set<string>>(new Set(selectedIds));
const [loadingProjects, setLoadingProjects] = useState(false);
const [loadingAssets, setLoadingAssets] = useState(false);
const selectedMap = useMemo(() => {
const map = new Map<string, PrivatePortraitSelectableAsset>();
assets.forEach((item) => {
if (checked.has(item.id)) map.set(item.id, item);
});
return map;
}, [assets, checked]);
const loadProjects = async () => {
setLoadingProjects(true);
try {
const res = await getPrivatePortraitProjects({ page: 1, pageSize: 100, status: 'active' });
setProjects(res.items || []);
if (!projectId && res.items?.length) {
setProjectId(res.items[0].id);
}
} catch (err: any) {
message.error(err?.message || '加载真人素材项目失败');
} finally {
setLoadingProjects(false);
}
};
const loadAssets = async () => {
setLoadingAssets(true);
try {
const res = await getPrivatePortraitSelectableAssets({ projectId, keyword: keyword.trim() || undefined, page: 1, pageSize: 100 });
setAssets(res.items || []);
} catch (err: any) {
message.error(err?.message || '加载真人素材失败');
} finally {
setLoadingAssets(false);
}
};
useEffect(() => {
if (!open) return;
setChecked(new Set(selectedIds));
loadProjects();
}, [open]);
useEffect(() => {
if (!open) return;
loadAssets();
}, [open, projectId]);
const toggle = (asset: PrivatePortraitSelectableAsset) => {
setChecked((prev) => {
const next = new Set(prev);
if (next.has(asset.id)) {
next.delete(asset.id);
return next;
}
if (next.size >= maxCount) {
message.warning(`最多选择 ${maxCount} 个参考素材`);
return prev;
}
next.add(asset.id);
return next;
});
};
const confirm = () => {
const selected = assets.filter((item) => checked.has(item.id));
if (!selected.length) {
message.warning('请选择至少一个真人素材');
return;
}
onSelect(selected);
onClose();
};
return (
<Modal
title="选择真人素材库"
open={open}
onCancel={onClose}
width={920}
footer={[
<Button key="cancel" onClick={onClose}></Button>,
<Button key="ok" type="primary" onClick={confirm} style={{ background: '#8b5cf6' }}>
{checked.size}
</Button>,
]}
>
<div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 16, minHeight: 480 }}>
<div style={{ border: '1px solid #eef0f4', borderRadius: 12, padding: 12, background: '#fafafa' }}>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 12 }}>
<Text strong></Text>
<Button size="small" icon={<ReloadOutlined />} onClick={loadProjects} loading={loadingProjects} />
</Space>
<Spin spinning={loadingProjects}>
<List
dataSource={projects}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无项目组" /> }}
renderItem={(item) => (
<List.Item
onClick={() => setProjectId(item.id)}
style={{
cursor: 'pointer',
padding: '10px 12px',
borderRadius: 10,
marginBottom: 6,
border: projectId === item.id ? '1px solid #8b5cf6' : '1px solid transparent',
background: projectId === item.id ? '#f5f3ff' : '#fff',
}}
>
<div style={{ width: '100%' }}>
<Text strong ellipsis style={{ display: 'block' }}>{item.name}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>Active {item.activeAssetCount || 0}</Text>
</div>
</List.Item>
)}
/>
</Spin>
</div>
<div>
<Space style={{ width: '100%', marginBottom: 12 }}>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="搜索素材名称"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={loadAssets}
/>
<Button icon={<ReloadOutlined />} onClick={loadAssets} loading={loadingAssets}></Button>
</Space>
<Spin spinning={loadingAssets}>
{assets.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无可选 Active 真人素材" style={{ marginTop: 120 }} />
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 12, maxHeight: 420, overflowY: 'auto', paddingRight: 4 }}>
{assets.map((asset) => {
const active = checked.has(asset.id);
return (
<div
key={asset.id}
onClick={() => toggle(asset)}
style={{
cursor: 'pointer',
border: active ? '2px solid #8b5cf6' : '1px solid #edf0f5',
borderRadius: 12,
overflow: 'hidden',
background: '#fff',
boxShadow: active ? '0 8px 20px rgba(139,92,246,0.18)' : '0 4px 12px rgba(15,23,42,0.04)',
position: 'relative',
}}
>
<div style={{ aspectRatio: '1 / 1', background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{asset.previewUrl ? (
<img src={getPreviewUrl(asset.previewUrl)} alt={asset.name || '真人素材'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<PictureOutlined style={{ fontSize: 32, color: '#94a3b8' }} />
)}
</div>
{active && (
<div style={{ position: 'absolute', top: 8, right: 8, width: 24, height: 24, borderRadius: 12, background: '#8b5cf6', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CheckOutlined />
</div>
)}
<div style={{ padding: 10 }}>
<Text strong ellipsis style={{ display: 'block' }}>{asset.name || '未命名素材'}</Text>
<Space style={{ marginTop: 6 }}>
<Tag color="green">Active</Tag>
<Tag>{asset.projectName}</Tag>
</Space>
</div>
</div>
);
})}
</div>
)}
</Spin>
</div>
</div>
</Modal>
);
};
export default PrivatePortraitAssetPicker;
+91 -5
View File
@@ -32,6 +32,8 @@ import {
} from '../api'; } from '../api';
import { useAppStore } from '../store/useAppStore'; import { useAppStore } from '../store/useAppStore';
import { PrivatePortraitAssetPicker } from '../components/privatePortrait';
import type { PrivatePortraitSelectableAsset } from '../types';
import { import {
PlusOutlined, PlusOutlined,
@@ -73,6 +75,9 @@ interface MediaReference {
duration?: number; duration?: number;
role?: string; role?: string;
label?: string; label?: string;
source?: string;
private_asset_id?: string;
remote_asset_id?: string;
} }
interface Message { interface Message {
@@ -169,6 +174,7 @@ const AIChatPage: React.FC = () => {
const [lastFrame, setLastFrame] = useState<MediaReference | null>(null); const [lastFrame, setLastFrame] = useState<MediaReference | null>(null);
const [uploadTarget, setUploadTarget] = useState<'first' | 'last' | null>(null); const [uploadTarget, setUploadTarget] = useState<'first' | 'last' | null>(null);
const [referenceModeDropdownVisible, setReferenceModeDropdownVisible] = useState(false); const [referenceModeDropdownVisible, setReferenceModeDropdownVisible] = useState(false);
const [privateAssetPickerOpen, setPrivateAssetPickerOpen] = useState(false);
const [mediaStackHovered, setMediaStackHovered] = useState(false); const [mediaStackHovered, setMediaStackHovered] = useState(false);
const mediaStackCloseTimerRef = useRef<number | null>(null); const mediaStackCloseTimerRef = useRef<number | null>(null);
const openMediaStackTray = useCallback(() => { const openMediaStackTray = useCallback(() => {
@@ -1413,6 +1419,41 @@ const AIChatPage: React.FC = () => {
} }
}; };
const handlePrivatePortraitAssetsSelected = (assets: PrivatePortraitSelectableAsset[]) => {
if (mediaType !== 'video') {
message.warning('真人素材库第一版仅支持视频创作参考');
return;
}
const imageCount = currentMedia.filter((m) => m.type === 'image').length;
const available = Math.max(0, maxImage - imageCount);
if (assets.length > available) {
message.warning(`当前引擎最多还能添加 ${available} 张图片参考`);
return;
}
const added: MediaReference[] = assets.map((asset) => ({
name: asset.name || '真人素材',
type: 'image',
url: asset.previewUrl || '',
source: 'private_portrait_asset',
private_asset_id: asset.id,
label: '',
}));
const newList = [...currentMedia, ...added];
const labels = generateMediaLabels(newList);
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
message.success(`已添加 ${assets.length} 个真人素材参考`);
};
const buildPreviewUrl = (url: string) => {
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 || '').replace(/\/$/, '');
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
};
const handleRemoveMedia = (index: number) => { const handleRemoveMedia = (index: number) => {
const newList = currentMedia.filter((_, i) => i !== index); const newList = currentMedia.filter((_, i) => i !== index);
const labels = generateMediaLabels(newList); const labels = generateMediaLabels(newList);
@@ -2547,6 +2588,15 @@ const AIChatPage: React.FC = () => {
</div> </div>
</UploadSelector> </UploadSelector>
)} )}
{mediaType === 'video' && currentMedia.length === 0 && (
<Button
size="small"
onClick={() => setPrivateAssetPickerOpen(true)}
style={{ marginTop: 8, borderRadius: 8, color: '#8b5cf6', borderColor: '#ddd6fe', background: '#fff' }}
>
</Button>
)}
{/* 层叠附件展示 - 鼠标移入向右排列展开 */} {/* 层叠附件展示 - 鼠标移入向右排列展开 */}
{currentMedia.length > 0 && ( {currentMedia.length > 0 && (
@@ -2572,7 +2622,7 @@ const AIChatPage: React.FC = () => {
<div style={{ position: 'relative', width: 52, height: 60 }}> <div style={{ position: 'relative', width: 52, height: 60 }}>
{media.type === 'image' ? ( {media.type === 'image' ? (
<img <img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`} src={buildPreviewUrl(media.url)}
alt={media.name} alt={media.name}
onClick={() => { onClick={() => {
setAttachmentPreviewUrl(media.url); setAttachmentPreviewUrl(media.url);
@@ -2584,7 +2634,7 @@ const AIChatPage: React.FC = () => {
/> />
) : media.type === 'video' ? ( ) : media.type === 'video' ? (
<video <video
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`} src={buildPreviewUrl(media.url)}
muted muted
onClick={() => { onClick={() => {
setAttachmentPreviewUrl(media.url); setAttachmentPreviewUrl(media.url);
@@ -2602,7 +2652,7 @@ const AIChatPage: React.FC = () => {
}} }}
style={{ width: 52, height: 60, objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '1px solid rgba(255,255,255,0.98)', boxShadow: '0 4px 12px rgba(31,41,55,0.15)', background: 'linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center' }} style={{ width: 52, height: 60, objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '1px solid rgba(255,255,255,0.98)', boxShadow: '0 4px 12px rgba(31,41,55,0.15)', background: 'linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
> >
{playingAudioUrl === (media.url.startsWith('http') ? media.url : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`) ? ( {playingAudioUrl === (buildPreviewUrl(media.url)) ? (
<PauseOutlined style={{ fontSize: 20, color: '#fff' }} /> <PauseOutlined style={{ fontSize: 20, color: '#fff' }} />
) : ( ) : (
<AudioOutlined style={{ fontSize: 20, color: '#fff' }} /> <AudioOutlined style={{ fontSize: 20, color: '#fff' }} />
@@ -2718,6 +2768,34 @@ const AIChatPage: React.FC = () => {
</div> </div>
</UploadSelector> </UploadSelector>
)} )}
{mediaType === 'video' && currentMedia.length > 0 && (
<Tooltip title="选择真人素材库">
<div
onClick={(e) => { e.stopPropagation(); setPrivateAssetPickerOpen(true); }}
style={{
position: 'absolute',
right: -32,
bottom: 0,
width: 28,
height: 28,
borderRadius: 50,
background: '#fff',
border: '1px solid #ddd6fe',
color: '#8b5cf6',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: 12,
fontWeight: 800,
boxShadow: '0 2px 8px rgba(47, 52, 64, 0.08)',
zIndex: 100,
}}
>
</div>
</Tooltip>
)}
</div> </div>
)} )}
</> </>
@@ -4087,20 +4165,28 @@ const AIChatPage: React.FC = () => {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
{attachmentPreviewType === 'image' ? ( {attachmentPreviewType === 'image' ? (
<img <img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${attachmentPreviewUrl}`} src={buildPreviewUrl(attachmentPreviewUrl)}
alt="预览" alt="预览"
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain' }} style={{ width: '100%', maxHeight: '400px', objectFit: 'contain' }}
/> />
) : ( ) : (
<video <video
ref={attachmentPreviewVideoRef} ref={attachmentPreviewVideoRef}
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${attachmentPreviewUrl}`} src={buildPreviewUrl(attachmentPreviewUrl)}
controls controls
style={{ maxWidth: '100%', maxHeight: '400px' }} style={{ maxWidth: '100%', maxHeight: '400px' }}
/> />
)} )}
</div> </div>
</Modal> </Modal>
<PrivatePortraitAssetPicker
open={privateAssetPickerOpen}
onClose={() => setPrivateAssetPickerOpen(false)}
onSelect={handlePrivatePortraitAssetsSelected}
selectedIds={currentMedia.map((m) => m.private_asset_id).filter(Boolean) as string[]}
maxCount={maxImage}
/>
</Layout> </Layout>
); );
}; };
+36 -1
View File
@@ -16,7 +16,9 @@ import {
PlayCircleOutlined, PlayCircleOutlined,
DeleteOutlined, DeleteOutlined,
LoadingOutlined, LoadingOutlined,
UserOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { PrivatePortraitLibraryPanel } from '../components/privatePortrait';
import { gethistory, gethistoryItems, getOAuthList, asyncBatchUploadMaterial, updateFilename, getUploadHistory, getAllOAuthAccountList, getOpenTypeAll, getPreTestList, getDefaultPreTest, deleteHistory, deleteResourcesMaterial } from '../api'; import { gethistory, gethistoryItems, getOAuthList, asyncBatchUploadMaterial, updateFilename, getUploadHistory, getAllOAuthAccountList, getOpenTypeAll, getPreTestList, getDefaultPreTest, deleteHistory, deleteResourcesMaterial } from '../api';
const { Search } = Input; const { Search } = Input;
@@ -48,7 +50,7 @@ const GeneratedRecord: React.FC = () => {
setIsPageLoaded(true); setIsPageLoaded(true);
}); });
const [filterType, setFilterType] = useState<'project' | 'creation' | 'hot_opening_replicate' | 'shot_replicate'>('project'); const [filterType, setFilterType] = useState<'project' | 'creation' | 'hot_opening_replicate' | 'shot_replicate' | 'private_portrait'>('project');
const [filterMedia, setFilterMedia] = useState<'video' | 'image'>('video'); const [filterMedia, setFilterMedia] = useState<'video' | 'image'>('video');
const [recordlist, setRecordList] = useState<any[]>([]); const [recordlist, setRecordList] = useState<any[]>([]);
const [Pagebreak, setPagebreak] = useState<any>({ const [Pagebreak, setPagebreak] = useState<any>({
@@ -754,6 +756,11 @@ const GeneratedRecord: React.FC = () => {
}); });
}; };
const loadRecordList = () => { const loadRecordList = () => {
if (filterType === 'private_portrait') {
setLoading(false);
setRecordList([]);
return;
}
setLoading(true); setLoading(true);
let historySource = ''; let historySource = '';
if (filterType === 'project') { if (filterType === 'project') {
@@ -1003,8 +1010,29 @@ const GeneratedRecord: React.FC = () => {
> >
</Button> </Button>
<Button
type={filterType === 'private_portrait' ? 'primary' : 'default'}
onClick={() => {
setFilterType('private_portrait');
setIsSelectionMode(false);
setSelectedItems(new Set());
}}
style={{
borderRadius: 8,
background: filterType === 'private_portrait'
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: '#f8f9fc',
border: filterType === 'private_portrait' ? 'none' : '1px solid #e2e8f0',
color: filterType === 'private_portrait' ? '#fff' : '#64748b',
fontWeight: 600,
}}
icon={<UserOutlined />}
>
</Button>
</Space> </Space>
</div> </div>
{filterType !== 'private_portrait' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
{/* 多选模式按钮 */} {/* 多选模式按钮 */}
{isSelectionMode ? ( {isSelectionMode ? (
@@ -1085,7 +1113,12 @@ const GeneratedRecord: React.FC = () => {
)} )}
</div> </div>
)}
</div> </div>
{filterType === 'private_portrait' ? (
<PrivatePortraitLibraryPanel />
) : (
<>
{/* Second row filter: 视频 / 图片 */} {/* Second row filter: 视频 / 图片 */}
<div style={{ <div style={{
display: 'flex', display: 'flex',
@@ -1501,6 +1534,8 @@ const GeneratedRecord: React.FC = () => {
)} )}
</div> </div>
)} )}
</>
)}
{/* 推送任务历史弹窗 */} {/* 推送任务历史弹窗 */}
<Modal <Modal
title="推送任务历史" title="推送任务历史"
+121
View File
@@ -0,0 +1,121 @@
import React, { useEffect, useState } from 'react';
import { Button, Card, Result, Spin, Typography, Modal, message } from 'antd';
import { CheckCircleOutlined, TeamOutlined } from '@ant-design/icons';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { getJoinTeamInfo, submitJoinRequest } from '../api';
import type { JoinTeamInfo } from '../types';
const JoinTeamPage: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const code = searchParams.get('code') || '';
const [loading, setLoading] = useState(true);
const [info, setInfo] = useState<JoinTeamInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (!code) {
setLoading(false);
return;
}
getJoinTeamInfo(code)
.then((data) => setInfo(data))
.catch(() => setInfo(null))
.finally(() => setLoading(false));
}, [code]);
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);
}
},
});
};
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
<Spin size="large" />
</div>
);
}
if (submitted) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<Result
status="success"
icon={<CheckCircleOutlined style={{ color: '#6366f1' }} />}
title="申请已提交"
subTitle="您的加入申请已提交,请等待团队管理人审批。审批通过后将自动加入团队。"
extra={<Button type="primary" onClick={() => navigate('/projects')}></Button>}
/>
</div>
);
}
if (!code || !info?.valid) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<Result
status="error"
title="邀请链接无效"
subTitle="该邀请链接可能已过期或不存在,请联系团队管理人重新获取。"
extra={<Button type="primary" onClick={() => navigate('/projects')}></Button>}
/>
</div>
);
}
if (info.alreadyInTeam) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<Result
status="info"
title="您已在此团队中"
subTitle={`您已经是「${info.teamName}」的成员了,无需再次加入。`}
extra={<Button type="primary" onClick={() => navigate('/projects')}></Button>}
/>
</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>
);
};
export default JoinTeamPage;
@@ -0,0 +1,619 @@
import React, { useEffect, useState, useCallback } 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,
} from '@ant-design/icons';
const { RangePicker } = DatePicker;
import {
createTeamInvitation, getJoinTeamInfo, getManagedTeam, getPendingJoinRequests,
getTeamCreditExportUrl, getTeamCreditRecords, getTeamInvitations, getTeamMembers, handleJoinRequest, revokeInvitation, submitJoinRequest, transferCredits,
} from '../api';
import type { ManagedTeam, TeamInvitation, TeamJoinRequest, TeamMember } from '../types';
import { useAuthStore } from '../store/useAuthStore';
/* ── 工具函数 ────────────────────────────────────────── */
function formatDateTime(value: any): string {
if (!value) return '-';
try {
return new Date(value).toLocaleString('zh-CN', { hour12: false });
} catch {
return '-';
}
}
const RECORD_TYPE_CONFIG: Record<string, { color: string; label: string }> = {
recharge: { color: 'green', label: '充值' },
consume: { color: 'red', label: '消费' },
refund: { color: 'orange', label: '退款' },
team_internal: { color: 'blue', label: '团队内部' },
};
/* ── 主组件 ──────────────────────────────────────────── */
const TeamManagementPage: React.FC = () => {
const [team, setTeam] = useState<ManagedTeam | null>(null);
const [teamLoading, setTeamLoading] = useState(false);
const loadTeam = useCallback(async () => {
setTeamLoading(true);
try {
const data = await getManagedTeam();
setTeam(data);
} catch (e: any) {
message.error(e?.message || '获取团队信息失败');
} finally {
setTeamLoading(false);
}
}, []);
useEffect(() => { loadTeam(); }, [loadTeam]);
// ── Tab 1: 成员 ──
const [members, setMembers] = useState<TeamMember[]>([]);
const [membersTotal, setMembersTotal] = useState(0);
const [membersLoading, setMembersLoading] = useState(false);
const [membersPage, setMembersPage] = useState(1);
const loadMembers = useCallback(async () => {
if (!team) return;
setMembersLoading(true);
try {
const res = await getTeamMembers(membersPage);
setMembers(res.items || []);
setMembersTotal(res.total || 0);
} catch (e: any) {
message.error(e?.message || '加载成员失败');
} finally {
setMembersLoading(false);
}
}, [team, membersPage]);
useEffect(() => { loadMembers(); }, [loadMembers]);
// ── 调整积分弹窗 ──
const [creditModal, setCreditModal] = useState<{ open: boolean; member: TeamMember | null }>({ open: false, member: null });
const [creditForm] = Form.useForm();
const [creditSaving, setCreditSaving] = useState(false);
const handleTransfer = async () => {
if (!creditModal.member) return;
try {
const values = await creditForm.validateFields();
// 二次校验:确保是正数
const amount = Number(values.amount);
if (!amount || amount <= 0 || amount > 9999999) {
message.error('请输入有效的正数积分数量');
return;
}
setCreditSaving(true);
await transferCredits(
creditModal.member.id,
amount,
values.direction || 'increase',
values.description,
);
message.success(values.direction === 'decrease' ? '积分扣减成功' : '积分增加成功');
setCreditModal({ open: false, member: null });
creditForm.resetFields();
loadMembers();
loadCreditRecords();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '操作失败');
} finally {
setCreditSaving(false);
}
};
// ── Tab 2: 邀请码 ──
const [invitations, setInvitations] = useState<TeamInvitation[]>([]);
const [invLoading, setInvLoading] = useState(false);
const [invModal, setInvModal] = useState(false);
const [invForm] = Form.useForm();
const [invSaving, setInvSaving] = useState(false);
const loadInvitations = useCallback(async () => {
setInvLoading(true);
try {
const data = await getTeamInvitations();
setInvitations(data || []);
} catch (e: any) {
message.error(e?.message || '加载邀请码失败');
} finally {
setInvLoading(false);
}
}, []);
useEffect(() => { loadInvitations(); }, [loadInvitations]);
const handleCreateInvitation = async () => {
try {
const values = await invForm.validateFields();
setInvSaving(true);
const expiresAt = values.expiresAt ? new Date(values.expiresAt).toISOString() : null;
await createTeamInvitation(values.maxUses || null, expiresAt);
message.success('邀请码已生成');
setInvModal(false);
invForm.resetFields();
loadInvitations();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '创建失败');
} finally {
setInvSaving(false);
}
};
const handleRevoke = async (invId: string) => {
try {
await revokeInvitation(invId);
message.success('已撤销');
loadInvitations();
} catch (e: any) {
message.error(e?.message || '撤销失败');
}
};
const copyInviteLink = (link: string) => {
navigator.clipboard.writeText(link).then(() => {
message.success('邀请链接已复制');
}).catch(() => {
message.warning('复制失败,请手动复制');
});
};
// ── Tab 3: 加入申请 ──
const [requests, setRequests] = useState<TeamJoinRequest[]>([]);
const [reqLoading, setReqLoading] = useState(false);
const loadRequests = useCallback(async () => {
setReqLoading(true);
try {
const data = await getPendingJoinRequests();
setRequests(data || []);
} catch (e: any) {
message.error(e?.message || '加载申请失败');
} finally {
setReqLoading(false);
}
}, []);
useEffect(() => { loadRequests(); }, [loadRequests]);
const handleRequest = async (requestId: string, action: 'approve' | 'reject', note?: string) => {
try {
await handleJoinRequest(requestId, action, note);
message.success(action === 'approve' ? '已通过' : '已拒绝');
loadRequests();
loadMembers();
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
// ── Tab 4: 团队积分变动 ──
const [creditRecords, setCreditRecords] = useState<any[]>([]);
const [creditTotal, setCreditTotal] = useState(0);
const [creditSummary, setCreditSummary] = useState<any>(null);
const [creditLoading, setCreditLoading] = useState(false);
const [creditPage, setCreditPage] = useState(1);
const [creditFilterType, setCreditFilterType] = useState<string>('');
const [creditFilterPhone, setCreditFilterPhone] = useState<string>('');
const [creditDateRange, setCreditDateRange] = useState<[string, string] | null>(() => {
const today = dayjs().format('YYYY-MM-DD');
return [today, today];
});
const loadCreditRecords = useCallback(async () => {
setCreditLoading(true);
try {
const res = await getTeamCreditRecords({
page: creditPage,
pageSize: 10,
phone: creditFilterPhone || undefined,
recordType: creditFilterType || undefined,
startDate: creditDateRange?.[0] || undefined,
endDate: creditDateRange?.[1] || undefined,
});
setCreditRecords(res.items || []);
setCreditTotal(res.total || 0);
setCreditSummary(res.summary || null);
} catch (e: any) {
message.error(e?.message || '加载积分记录失败');
} finally {
setCreditLoading(false);
}
}, [creditPage, creditFilterType, creditFilterPhone, creditDateRange]);
useEffect(() => { loadCreditRecords(); }, [loadCreditRecords]);
const resetCreditFilters = () => {
setCreditFilterType('');
setCreditFilterPhone('');
const today = dayjs().format('YYYY-MM-DD');
setCreditDateRange([today, today]);
setCreditPage(1);
};
const handleExportCredits = () => {
const url = getTeamCreditExportUrl({
phone: creditFilterPhone || undefined,
recordType: creditFilterType || undefined,
startDate: creditDateRange?.[0] || undefined,
endDate: creditDateRange?.[1] || undefined,
});
const token = localStorage.getItem('auth_token');
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
fetch(url, { headers })
.then((res) => res.blob())
.then((blob) => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `团队积分_${dayjs().format('YYYYMMDD_HHmmss')}.csv`;
a.click();
URL.revokeObjectURL(a.href);
})
.catch(() => message.error('导出失败'));
};
/* ── 表格列定义 ──────────────────────────────────────── */
const memberColumns = [
{ title: '用户名', dataIndex: 'username', width: 140, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
{ title: '手机号', dataIndex: 'phone', width: 130, render: (v: string) => v || '-' },
{ title: '积分', dataIndex: 'credits', width: 100, render: (v: number) => <Typography.Text style={{ color: '#6366f1' }}>{(v ?? 0).toFixed(2)}</Typography.Text> },
{ title: '状态', dataIndex: 'isActive', width: 80, render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '启用' : '禁用'}</Tag> },
{ title: '加入时间', dataIndex: 'joinedAt', width: 170, render: (v: string) => formatDateTime(v) },
{
title: '操作', key: 'action', width: 100,
render: (_: any, r: TeamMember) => {
const currentUserId = useAuthStore.getState().user?.id;
const isSelf = r.id === currentUserId;
return isSelf ? (
<Tooltip title="不能给自己调整积分">
<Button size="small" type="link" style={{ padding: 0, color: '#999', cursor: 'not-allowed' }} disabled></Button>
</Tooltip>
) : (
<Button size="small" type="link" style={{ padding: 0 }} onClick={() => { setCreditModal({ open: true, member: r }); creditForm.resetFields(); }}></Button>
);
},
},
];
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: 'status', width: 80, render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{v === 'active' ? '有效' : '已撤销'}</Tag> },
{ title: '使用次数', key: 'uses', width: 100, render: (_: any, r: TeamInvitation) => `${r.useCount}${r.maxUses ? `/${r.maxUses}` : ''}` },
{ title: '过期时间', dataIndex: 'expiresAt', width: 170, render: (v: string) => v ? formatDateTime(v) : '永不过期' },
{
title: '操作', key: 'action', width: 80,
render: (_: any, r: TeamInvitation) => r.status === 'active' ? (
<Button size="small" type="link" danger onClick={() => handleRevoke(r.id)}></Button>
) : null,
},
];
const reqColumns = [
{ title: '申请人', dataIndex: 'username', width: 140, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
{ title: '手机号', dataIndex: 'phone', width: 130, render: (v: string) => v || '-' },
{ title: '申请时间', dataIndex: 'createdAt', width: 170, render: (v: string) => formatDateTime(v) },
{
title: '操作', key: 'action', width: 160,
render: (_: any, r: TeamJoinRequest) => (
<Space size={4}>
<Button size="small" type="link" style={{ color: '#16a34a', padding: 0 }} onClick={() => handleRequest(r.id, 'approve')}></Button>
<Button size="small" type="link" danger style={{ padding: 0 }} onClick={() => {
Modal.confirm({
title: '拒绝申请',
content: (
<Form layout="vertical" style={{ marginTop: 12 }}>
<Form.Item name="note" label="拒绝原因(可选)">
<Input.TextArea rows={2} placeholder="选填" id="reject-note-input" />
</Form.Item>
</Form>
),
onOk: () => {
const note = (document.getElementById('reject-note-input') as HTMLTextAreaElement)?.value || undefined;
handleRequest(r.id, 'reject', note);
},
});
}}></Button>
</Space>
),
},
];
const creditColumns = [
{ title: '用户名', dataIndex: 'username', width: 120, render: (v: string) => <Typography.Text strong>{v || '-'}</Typography.Text> },
{ title: '手机号', dataIndex: 'phone', width: 120, render: (v: string) => v || '-' },
{
title: '类型', dataIndex: 'type', width: 100,
render: (_: any, r: any) => {
const cfg = RECORD_TYPE_CONFIG[r.type] || { color: 'default', label: r.type || '-' };
return <Tag color={cfg.color}>{cfg.label}</Tag>;
},
},
{
title: '变动积分', dataIndex: 'amount', width: 110, align: 'right' as const,
render: (_: any, r: any) => (
<Typography.Text strong style={{ color: r.amount >= 0 ? '#10b981' : '#ef4444', fontSize: 14 }}>
{r.amount >= 0 ? '+' : ''}{(r.amount ?? 0).toFixed(2)}
</Typography.Text>
),
},
{ title: '余额', dataIndex: 'balanceAfter', width: 100, align: 'right' as const, render: (v: number) => (v ?? 0).toFixed(2) },
{ title: '说明', dataIndex: 'description', ellipsis: true, minWidth: 160, render: (v: string) => v || '-' },
{ title: '时间', dataIndex: 'createdAt', width: 170, render: (v: string) => formatDateTime(v) },
];
/* ── Tab 配置 ────────────────────────────────────────── */
const tableWrapper: React.CSSProperties = { borderRadius: 16, background: '#fff', border: '1px solid #f0f0f5', overflow: 'hidden' };
const paginationStyle: React.CSSProperties = { padding: '16px', textAlign: 'right' };
const tabItems = [
{
key: 'members',
label: <Space><UserOutlined />{membersTotal > 0 && <span style={{ color: '#94a3b8', fontSize: 12 }}>({membersTotal})</span>}</Space>,
children: (
<div style={tableWrapper}>
<Table
columns={memberColumns}
dataSource={members}
rowKey="id"
loading={membersLoading}
pagination={false}
bordered={false}
scroll={{ x: 800 }}
locale={{ emptyText: <Empty description="暂无成员" /> }}
/>
{membersTotal > 0 && (
<div style={paginationStyle}>
<Pagination current={membersPage} pageSize={20} total={membersTotal} onChange={(p) => setMembersPage(p)} size="small" />
</div>
)}
</div>
),
},
{
key: 'credits',
label: <Space><WalletOutlined /></Space>,
children: (
<div>
{/* 搜索栏 */}
<div style={{ marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<Select
value={creditFilterType || undefined}
onChange={(v) => { setCreditFilterType(v || ''); setCreditPage(1); }}
allowClear
placeholder="交易类型"
style={{ width: 130 }}
options={[
{ value: 'recharge', label: '充值' },
{ value: 'consume', label: '消费' },
{ value: 'team_internal', label: '团队内部' },
{ value: 'refund', label: '退款' },
]}
/>
<Input
placeholder="搜索手机号"
value={creditFilterPhone}
onChange={(e) => { setCreditFilterPhone(e.target.value); setCreditPage(1); }}
style={{ width: 160 }}
allowClear
/>
<RangePicker
value={creditDateRange ? [dayjs(creditDateRange[0]), dayjs(creditDateRange[1])] : undefined}
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
setCreditDateRange([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]);
} else {
setCreditDateRange(null);
}
setCreditPage(1);
}}
/>
<Button onClick={resetCreditFilters}></Button>
<Button type="primary" icon={<DownloadOutlined />} onClick={handleExportCredits}> Excel</Button>
</div>
{/* 汇总统计 */}
<div style={{ marginBottom: 12, padding: '8px 16px', background: '#f8f9fc', borderRadius: 8, display: 'flex', gap: 24, flexWrap: 'wrap', fontSize: 13 }}>
<span><strong style={{ color: '#ef4444', fontSize: 15 }}>{creditSummary?.totalConsume ?? 0}</strong></span>
</div>
<div style={tableWrapper}>
<Table
rowKey="id"
loading={creditLoading}
dataSource={creditRecords}
pagination={false}
bordered={false}
scroll={{ x: 950 }}
columns={creditColumns}
locale={{ emptyText: <Empty description="暂无积分记录" /> }}
/>
{creditTotal > 0 && (
<div style={paginationStyle}>
<Pagination current={creditPage} pageSize={10} total={creditTotal} onChange={(p) => setCreditPage(p)} size="small" showTotal={(t) => `${t}`} />
</div>
)}
</div>
</div>
),
},
{
key: 'invitations',
label: <Space><CopyOutlined /></Space>,
children: (
<div style={tableWrapper}>
<div style={{ padding: 16, paddingBottom: 0 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setInvModal(true)}></Button>
</div>
<Table
columns={invColumns}
dataSource={invitations}
rowKey="id"
loading={invLoading}
pagination={false}
bordered={false}
scroll={{ x: 900 }}
locale={{ emptyText: <Empty description="暂无邀请码" /> }}
/>
</div>
),
},
{
key: 'requests',
label: <Space><HistoryOutlined />{requests.length > 0 && <Tag color="red">{requests.length}</Tag>}</Space>,
children: (
<div style={tableWrapper}>
<Table
columns={reqColumns}
dataSource={requests}
rowKey="id"
loading={reqLoading}
pagination={false}
bordered={false}
scroll={{ x: 600 }}
locale={{ emptyText: <Empty description="暂无待审批申请" /> }}
/>
</div>
),
},
];
/* ── 渲染 ────────────────────────────────────────────── */
return (
<div style={{ padding: 24 }}>
{/* 顶部团队信息 */}
<div style={{ marginBottom: 24 }}>
{teamLoading ? (
<Typography.Text type="secondary">...</Typography.Text>
) : team ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>{team.name}</Typography.Title>
<Typography.Text type="secondary">: {team.code || '-'} : {team.memberCount} </Typography.Text>
</div>
<Button icon={<ReloadOutlined />} onClick={() => { loadTeam(); loadMembers(); loadInvitations(); loadRequests(); loadCreditRecords(); }}></Button>
</div>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</div>
{/* 标签页 */}
<Tabs items={tabItems} defaultActiveKey="members" size="large" />
{/* 调整积分弹窗 */}
<Modal
title={<Space><UserOutlined /> - {creditModal.member?.username}</Space>}
open={creditModal.open}
confirmLoading={creditSaving}
onOk={handleTransfer}
onCancel={() => setCreditModal({ open: false, member: null })}
okText="确认"
width={480}
>
<Form form={creditForm} layout="vertical" style={{ marginTop: 16 }} initialValues={{ direction: 'increase' }}>
{/* 显示管理人当前积分 */}
<div style={{ marginBottom: 16, padding: '10px 16px', background: '#f0f4ff', borderRadius: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text strong style={{ fontSize: 20, color: '#6366f1' }}>
{useAuthStore.getState().user?.credits?.toFixed(2) ?? '0.00'}
</Typography.Text>
</div>
<Form.Item name="direction" label="操作类型" rules={[{ required: true, message: '请选择操作类型' }]}>
<Radio.Group buttonStyle="solid" size="large" style={{ width: '100%' }}>
<Radio.Button value="increase" style={{ width: '50%', textAlign: 'center' }}></Radio.Button>
<Radio.Button value="decrease" style={{ width: '50%', textAlign: 'center' }}></Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item
name="amount"
label="积分数量"
required
rules={[
{ required: true, message: '请输入积分数量' },
{ type: 'number', min: 0.01, message: '必须大于 0' },
{ type: 'number', max: 9999999, message: '单次不能超过 9999999' },
]}
validateTrigger={['onChange', 'onBlur']}
>
<InputNumber
style={{ width: '100%' }}
step={1}
min={0.01}
max={9999999}
precision={2}
placeholder="请输入正数积分数量"
size="large"
formatter={(value) => {
if (!value) return '';
let str = `${value}`.replace(/[^0-9.]/g, '');
str = str.replace(/^0+(?=\d)/, '');
return str;
}}
parser={(str) => {
if (!str || str === '.') return '' as any;
let num = parseFloat(str);
if (isNaN(num) || num <= 0) return '' as any;
return Math.min(num, 9999999) as any;
}}
onKeyDown={(e) => {
// 禁止输入负号、e、E
if (e.key === '-' || e.key === 'e' || e.key === 'E') {
e.preventDefault();
}
}}
onChange={(val) => {
if (val === null || val === undefined) {
creditForm.validateFields(['amount']);
}
}}
/>
</Form.Item>
<Form.Item name="description" label="备注">
<Input.TextArea rows={2} maxLength={256} placeholder="选填,例如:活动奖励" />
</Form.Item>
</Form>
</Modal>
{/* 生成邀请码弹窗 */}
<Modal
title={<Space><CopyOutlined /></Space>}
open={invModal}
confirmLoading={invSaving}
onOk={handleCreateInvitation}
onCancel={() => { setInvModal(false); invForm.resetFields(); }}
okText="生成"
width={480}
>
<Form form={invForm} layout="vertical" style={{ marginTop: 16 }}>
<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>
</Form>
</Modal>
</div>
);
};
export default TeamManagementPage;
+2 -2
View File
@@ -32,7 +32,7 @@ interface AppState {
}; };
enginesele: any; enginesele: any;
inputValue: string; inputValue: string;
currentMedia: { name: string; type: 'image' | 'video' | 'audio'; url: string; label: string; duration?: number }[]; currentMedia: MediaReference[];
fetchProjects: () => Promise<void>; fetchProjects: () => Promise<void>;
createProject: (name: string, industry: Industry) => Promise<Project>; createProject: (name: string, industry: Industry) => Promise<Project>;
@@ -59,7 +59,7 @@ interface AppState {
setVideoResolution: (resolution: string) => void; setVideoResolution: (resolution: string) => void;
setEnginesele: (enginesele: any) => void; setEnginesele: (enginesele: any) => void;
setInputValue: (inputValue: string) => void; setInputValue: (inputValue: string) => void;
setCurrentMedia: (currentMedia: { name: string; type: 'image' | 'video' | 'audio'; url: string; label: string; duration?: number }[]) => void; setCurrentMedia: (currentMedia: MediaReference[]) => void;
resetGenerationConfig: () => void; resetGenerationConfig: () => void;
} }
+155
View File
@@ -22,6 +22,61 @@ export interface User {
allowedMenus?: string[] | null; allowedMenus?: string[] | null;
mustSetPassword?: boolean; mustSetPassword?: boolean;
resourceCapacity?: ResourceCapacity; resourceCapacity?: ResourceCapacity;
teamId?: string | null;
teamName?: string | null;
isTeamManager?: boolean;
}
// ── Team Management Types ─────────────────────────────
export interface TeamMember {
id: string;
username: string;
phone?: string | null;
credits: number;
isActive: boolean;
joinedAt: string;
}
export interface TeamInvitation {
id: string;
code: string;
status: string;
maxUses?: number | null;
useCount: number;
expiresAt?: string | null;
inviteLink: string;
createdAt?: string | null;
}
export interface TeamJoinRequest {
id: string;
teamId: string;
teamName: string;
userId: string;
username: string;
phone?: string | null;
status: string;
note?: string | null;
createdAt?: string | null;
}
export interface ManagedTeam {
id: string;
name: string;
code?: string | null;
description?: string | null;
status: string;
memberCount: number;
managerId?: string | null;
managerName?: string | null;
}
export interface JoinTeamInfo {
teamName: string;
teamId: string;
valid: boolean;
alreadyInTeam: boolean;
} }
export interface CreditRecord { export interface CreditRecord {
@@ -73,6 +128,12 @@ export interface MediaReference {
url: string; url: string;
type: 'image' | 'video' | 'audio'; type: 'image' | 'video' | 'audio';
name: string; name: string;
label?: string;
duration?: number;
role?: string;
source?: string;
private_asset_id?: string;
remote_asset_id?: string;
} }
export interface GenerationRecord { export interface GenerationRecord {
@@ -188,3 +249,97 @@ export interface AdminNotification {
isRead: boolean; isRead: boolean;
createdAt: string; createdAt: string;
} }
export interface PrivatePortraitConfig {
enabled: boolean;
imageLimit: number;
usedImageCount: number;
remainingImageCount: number;
}
export interface PrivatePortraitProject {
id: string;
userId?: string | null;
name: string;
nameSlug?: string | null;
remoteProjectName?: string | null;
description?: string | null;
status: string;
assetGroupCount: number;
assetCount: number;
activeAssetCount: number;
lastUsedAt?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface PrivatePortraitProjectListOut {
items: PrivatePortraitProject[];
total: number;
page: number;
pageSize: number;
}
export interface PrivatePortraitValidateSession {
id: string;
projectId: string;
bytedToken?: string | null;
h5Link?: string | null;
callbackUrl?: string | null;
resultCode?: string | null;
status: string;
remoteGroupId?: string | null;
remoteProjectName?: string | null;
errorMessage?: string | null;
expiredAt?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface PrivatePortraitAsset {
id: string;
userId?: string | null;
projectId: string;
projectName?: string | null;
groupId: string;
remoteGroupId: string;
remoteAssetId?: string | null;
remoteProjectName?: string | null;
assetType: string;
name?: string | null;
sourceUrl: string;
previewUrl?: string | null;
remoteUrl?: string | null;
status: string;
pollCount: number;
remoteDeleteStatus: string;
errorMessage?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface PrivatePortraitAssetListOut {
items: PrivatePortraitAsset[];
total: number;
page: number;
pageSize: number;
}
export interface PrivatePortraitSelectableAsset {
id: string;
projectId: string;
projectName: string;
name?: string | null;
assetType: string;
previewUrl?: string | null;
status: string;
createdAt?: string | null;
}
export interface PrivatePortraitSelectableAssetListOut {
items: PrivatePortraitSelectableAsset[];
total: number;
page: number;
pageSize: number;
}