Merge branch 'main' of https://gitee.com/wg123/video-gen
This commit is contained in:
Vendored
+75
-75
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-OStxKvM2.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BJj5fRkV.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -37,6 +37,7 @@ import AdminHomeMaterials from './pages/AdminHomeMaterials';
|
||||
import AdminPreTestTemplates from './pages/AdminPreTestTemplates';
|
||||
import AdminOAuthList from './pages/AdminOAuthList';
|
||||
import AdminMaterialList from './pages/AdminMaterialList';
|
||||
import AdminPrivatePortraitProjects from './pages/AdminPrivatePortraitProjects';
|
||||
|
||||
import { useAdminStore } from './store';
|
||||
|
||||
@@ -115,6 +116,7 @@ const App = () => {
|
||||
<Route path="pretest-templates" element={<AdminPreTestTemplates />} />
|
||||
<Route path="oauth-list" element={<AdminOAuthList />} />
|
||||
<Route path="material-list" element={<AdminMaterialList />} />
|
||||
<Route path="private-portrait-projects" element={<AdminPrivatePortraitProjects />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
AdminCreditRecordListResponse, AdminCreditRecordQueryParams,
|
||||
ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut,
|
||||
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
|
||||
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
|
||||
} from '../types';
|
||||
|
||||
import type {
|
||||
@@ -180,6 +181,19 @@ export async function deleteAdminTeam(id: string): Promise<void> {
|
||||
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> {
|
||||
await api.post(`/admin/users/${userId}/credits`, { amount, description });
|
||||
}
|
||||
@@ -434,7 +448,7 @@ export async function deleteMenuConfig(id: string): Promise<void> {
|
||||
|
||||
// ── User Creation ───────────────────────────────────────
|
||||
|
||||
export async function createUser(data: { username?: string; password: string; email?: string; phone?: string; credits: number; user_type: string; frontend_user_kind?: string; allowed_menus?: string[] | null }): 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);
|
||||
}
|
||||
|
||||
@@ -983,4 +997,52 @@ export async function getMaterialList(params: MaterialListParams): Promise<any>
|
||||
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/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 /> },
|
||||
consume: { text: '消费', color: 'red', icon: <ArrowDownOutlined /> },
|
||||
refund: { text: '回退', color: 'blue', icon: <RollbackOutlined /> },
|
||||
team_internal: { text: '团队内部', color: 'cyan', icon: <WalletOutlined /> },
|
||||
};
|
||||
|
||||
const userScopeOptions = [
|
||||
@@ -49,6 +50,7 @@ const recordTypeOptions = [
|
||||
{ value: 'recharge', label: '充值' },
|
||||
{ value: 'consume', label: '消费' },
|
||||
{ value: 'refund', label: '回退' },
|
||||
{ value: 'team_internal', label: '团队内部' },
|
||||
];
|
||||
|
||||
const creditSubjectOptions = [
|
||||
@@ -59,6 +61,7 @@ const creditSubjectOptions = [
|
||||
{ value: 'analysis', label: '分析积分' },
|
||||
{ value: 'split', label: '切片积分' },
|
||||
{ value: 'admin_adjust', label: '管理员调整' },
|
||||
{ value: 'team_internal', label: '团队内部转移' },
|
||||
{ value: 'recharge', label: '充值积分' },
|
||||
{ value: 'unknown', label: '历史未知' },
|
||||
];
|
||||
@@ -79,6 +82,7 @@ const chargeKindOptions = [
|
||||
{ value: 'video_analysis', label: '视频分析' },
|
||||
{ value: 'video_split', label: '视频切片' },
|
||||
{ value: 'admin_adjust', label: '管理员调整' },
|
||||
{ value: 'team_internal', label: '团队内部转移' },
|
||||
];
|
||||
|
||||
const sourceModuleOptions = [
|
||||
@@ -89,6 +93,7 @@ const sourceModuleOptions = [
|
||||
{ value: 'shot_replicate', label: '拆镜复刻' },
|
||||
{ value: 'admin', label: '后台管理' },
|
||||
{ value: 'payment', label: '支付充值' },
|
||||
{ value: 'team', label: '团队管理' },
|
||||
{ value: 'unknown', label: '历史未知' },
|
||||
];
|
||||
|
||||
@@ -127,6 +132,7 @@ const billingSceneOptions = [
|
||||
{ value: 'recharge', label: '充值' },
|
||||
{ value: 'admin_adjust', label: '管理员调整' },
|
||||
{ value: 'refund', label: '回退' },
|
||||
{ value: 'team_internal_transfer', label: '团队内部转账' },
|
||||
{ value: 'unknown', label: '历史未知' },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Input, Select, Table, Pagination, Tag, Typography } from 'antd';
|
||||
import { FileTextOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { Button, Input, Select, Table, Pagination, Tag, Typography, Tooltip } from 'antd';
|
||||
import { FileTextOutlined } from '@ant-design/icons';
|
||||
import { getMaterialList } from '../api';
|
||||
import PreResultDisplay from '../components/PreResultDisplay';
|
||||
const formatDateTime = (dateStr: string) => {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
@@ -48,6 +49,16 @@ const AdminMaterialList: React.FC = () => {
|
||||
title: '备注',
|
||||
dataIndex: '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',
|
||||
@@ -58,6 +69,8 @@ const AdminMaterialList: React.FC = () => {
|
||||
title: '预测试结果',
|
||||
dataIndex: 'preResult',
|
||||
key: 'preResult',
|
||||
width: 120,
|
||||
render: (text: string) => <PreResultDisplay preResult={text} />,
|
||||
},
|
||||
{
|
||||
title: '预测试模板ID',
|
||||
@@ -94,7 +107,15 @@ const AdminMaterialList: React.FC = () => {
|
||||
title: '上传ID',
|
||||
dataIndex: '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: '资源类型',
|
||||
@@ -145,13 +166,13 @@ const AdminMaterialList: React.FC = () => {
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
});
|
||||
const data = res.items || [];
|
||||
const data = res.data || [];
|
||||
const tableData = data.map((item: any, index: number) => ({
|
||||
...item,
|
||||
index: (currentPage - 1) * pageSize + index + 1,
|
||||
}));
|
||||
setTableData(tableData);
|
||||
setTotal(res.total || 0);
|
||||
setTotal(res.pagination?.total || 0);
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error);
|
||||
} finally {
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Input, Select, Table, Pagination, Tag, Typography } from 'antd';
|
||||
import { FileTextOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
|
||||
import { Button, Input, Table, Pagination, Tag, Typography, DatePicker } from 'antd';
|
||||
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 AdminPreTestTemplates: React.FC = () => {
|
||||
const [tableData, setTableData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [searchPlatform, setSearchPlatform] = useState('');
|
||||
const [searchName, setSearchName] = useState('');
|
||||
|
||||
const [searchId, setSearchId] = useState('');
|
||||
const [searchPhone, setSearchPhone] = useState('');
|
||||
const [searchCreatedAt, setSearchCreatedAt] = useState<[dayjs.Dayjs, dayjs.Dayjs] | undefined>();
|
||||
const columns = [
|
||||
{
|
||||
title: '序号',
|
||||
@@ -22,92 +33,180 @@ const AdminPreTestTemplates: React.FC = () => {
|
||||
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',
|
||||
key: 'name',
|
||||
width: 200,
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '投放平台',
|
||||
title: '名称',
|
||||
dataIndex: 'nobid',
|
||||
key: 'nobid',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'note',
|
||||
key: 'note',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '平台',
|
||||
dataIndex: 'platform',
|
||||
key: 'platform',
|
||||
width: 120,
|
||||
render: (text: string) => (
|
||||
<Tag color={text === 'AD' ? 'blue' : text === 'QIANCHUAN' ? 'green' : 'orange'}>
|
||||
{text === 'AD' ? 'AD' : text === 'QIANCHUAN' ? '千川' : '本地推'}
|
||||
</Tag>
|
||||
),
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
title: '定价类型',
|
||||
dataIndex: 'pricingType',
|
||||
key: 'pricingType',
|
||||
width: 100,
|
||||
render: (text: string) => (
|
||||
<Tag color={text === 'active' ? 'green' : 'red'}>
|
||||
{text === 'active' ? '启用' : '禁用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '目标成本',
|
||||
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: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{text}</span>,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
key: 'updatedAt',
|
||||
width: 180,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{text}</span>,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
const loadRecords = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(currentPage));
|
||||
params.set('page_size', String(pageSize));
|
||||
if (searchPlatform) params.set('platform', searchPlatform);
|
||||
if (searchName) params.set('name', searchName);
|
||||
const mockData = {
|
||||
items: Array.from({ length: pageSize }, (_, i) => ({
|
||||
id: `${currentPage}-${i}`,
|
||||
index: (currentPage - 1) * pageSize + i + 1,
|
||||
name: `前测模板${(currentPage - 1) * pageSize + i + 1}`,
|
||||
platform: ['AD', 'QIANCHUAN', 'LOCAL'][i % 3],
|
||||
status: i % 5 === 0 ? 'inactive' : 'active',
|
||||
createdAt: '2026-07-01 10:00:00',
|
||||
updatedAt: '2026-07-02 14:30:00',
|
||||
})),
|
||||
total: 50,
|
||||
};
|
||||
setTableData(mockData.items);
|
||||
setTotal(mockData.total);
|
||||
const res = await getPreTestTemplateList({
|
||||
id: searchId || undefined,
|
||||
phone: searchPhone || undefined,
|
||||
created_at: searchCreatedAt ? [searchCreatedAt[0].format('YYYY-MM-DD'), searchCreatedAt[1].format('YYYY-MM-DD')] : undefined,
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
});
|
||||
const data = res.data || [];
|
||||
const tableData = data.map((item: any, index: number) => ({
|
||||
...item,
|
||||
index: (currentPage - 1) * pageSize + index + 1,
|
||||
}));
|
||||
setTableData(tableData);
|
||||
setTotal(res.pagination.total || 0);
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRecords();
|
||||
}, [currentPage, pageSize]);
|
||||
|
||||
const handlePageChange = (page: number, size: number) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(size);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
loadRecords();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
@@ -115,26 +214,34 @@ const AdminPreTestTemplates: React.FC = () => {
|
||||
<Text strong style={{ fontSize: 16 }}>素材前测模板</Text>
|
||||
</div>
|
||||
<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
|
||||
placeholder="模板名称"
|
||||
value={searchName}
|
||||
onChange={(e) => setSearchName(e.target.value)}
|
||||
style={{ width: 180 }}
|
||||
placeholder="ID"
|
||||
value={searchId}
|
||||
onChange={(e) => setSearchId(e.target.value)}
|
||||
style={{ width: 160 }}
|
||||
allowClear
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<Select
|
||||
placeholder="投放平台"
|
||||
value={searchPlatform}
|
||||
onChange={(value) => setSearchPlatform(value)}
|
||||
style={{ width: 140 }}
|
||||
<Input
|
||||
placeholder="手机号"
|
||||
value={searchPhone}
|
||||
onChange={(e) => setSearchPhone(e.target.value)}
|
||||
style={{ width: 160 }}
|
||||
allowClear
|
||||
options={[
|
||||
{ value: 'AD', label: 'AD' },
|
||||
{ value: 'QIANCHUAN', label: '千川' },
|
||||
{ value: 'LOCAL', label: '本地推' },
|
||||
]}
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
value={searchCreatedAt}
|
||||
onChange={(dates) => {
|
||||
if (dates && dates[0] && dates[1]) {
|
||||
setSearchCreatedAt([dates[0], dates[1]]);
|
||||
} else {
|
||||
setSearchCreatedAt(undefined);
|
||||
}
|
||||
}}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -187,5 +294,4 @@ const AdminPreTestTemplates: React.FC = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
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 { deleteAdminTeam, getAdminTeams, saveAdminTeam } from '../api';
|
||||
import type { AdminTeam } from '../types';
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, TeamOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { deleteAdminTeam, getAdminTeams, getTeamMembersForAdmin, saveAdminTeam, setTeamManager } from '../api';
|
||||
import type { AdminTeam, AdminUser } from '../types';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
|
||||
const statusOptions = [
|
||||
@@ -34,6 +34,17 @@ const AdminTeams: React.FC = () => {
|
||||
const [modal, setModal] = useState<{ open: boolean; item: AdminTeam | null }>({ open: false, item: null });
|
||||
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(() => ({
|
||||
page,
|
||||
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 = [
|
||||
{
|
||||
title: '团队名称',
|
||||
@@ -136,6 +189,16 @@ const AdminTeams: React.FC = () => {
|
||||
width: 100,
|
||||
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: '排序',
|
||||
dataIndex: 'sortOrder',
|
||||
@@ -157,10 +220,12 @@ const AdminTeams: React.FC = () => {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 160,
|
||||
width: 230,
|
||||
fixed: 'right' as const,
|
||||
render: (_: any, r: AdminTeam) => (
|
||||
<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>
|
||||
<Popconfirm
|
||||
title="确定删除该团队?"
|
||||
@@ -239,6 +304,73 @@ const AdminTeams: React.FC = () => {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,10 +3,12 @@ import {
|
||||
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography,
|
||||
} from 'antd';
|
||||
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';
|
||||
import {
|
||||
adjustCredits,
|
||||
adminGetPrivatePortraitConfig,
|
||||
adminUpdatePrivatePortraitConfig,
|
||||
createUser,
|
||||
deleteUserResourceCapacity,
|
||||
getAdminUsers,
|
||||
@@ -22,7 +24,7 @@ import {
|
||||
updateSystemConfig,
|
||||
updateUserMenus,
|
||||
} 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';
|
||||
|
||||
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 [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 [portraitModal, setPortraitModal] = useState<{ open: boolean; user: AdminUser | null; config: PrivatePortraitConfig | null }>({ open: false, user: null, config: null });
|
||||
const [capacityLoading, setCapacityLoading] = useState(false);
|
||||
const [capacitySaving, setCapacitySaving] = useState(false);
|
||||
const [teamSaving, setTeamSaving] = useState(false);
|
||||
const [portraitLoading, setPortraitLoading] = useState(false);
|
||||
const [portraitSaving, setPortraitSaving] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [resetPwdForm] = Form.useForm();
|
||||
const [capacityForm] = Form.useForm();
|
||||
const [teamForm] = Form.useForm();
|
||||
const [portraitForm] = Form.useForm();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
@@ -183,6 +189,7 @@ const AdminUsers: React.FC = () => {
|
||||
credits: values.credits || 0,
|
||||
user_type: userType,
|
||||
frontend_user_kind: values.frontend_user_kind || 'external',
|
||||
private_portrait_image_limit: userType === 'frontend' ? Number(values.private_portrait_image_limit ?? 5) : 0,
|
||||
});
|
||||
message.success('用户创建成功');
|
||||
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) => {
|
||||
teamForm.setFieldsValue({ teamId: user.teamId || '' });
|
||||
setTeamModal({ open: true, user });
|
||||
@@ -380,6 +423,13 @@ const AdminUsers: React.FC = () => {
|
||||
title: '团队', dataIndex: 'teamName', width: 140,
|
||||
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 ? [{
|
||||
title: '资源容量', dataIndex: 'resourceCapacity', width: 230,
|
||||
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>,
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 460, fixed: 'right' as const,
|
||||
title: '操作', key: 'action', width: 540, fixed: 'right' as const,
|
||||
render: (_: any, r: AdminUser) => (
|
||||
<Space size={4} wrap>
|
||||
{!isAdminTab && (
|
||||
@@ -440,6 +490,12 @@ const AdminUsers: React.FC = () => {
|
||||
容量设置
|
||||
</Button>
|
||||
)}
|
||||
{!isAdminTab && (
|
||||
<Button type="link" size="small" icon={<PictureOutlined />}
|
||||
onClick={() => openPortraitModal(r)}>
|
||||
真人素材
|
||||
</Button>
|
||||
)}
|
||||
{!isAdminTab && (
|
||||
<Button type="link" size="small" icon={<TeamOutlined />}
|
||||
onClick={() => openTeamModal(r)}>
|
||||
@@ -727,6 +783,37 @@ const AdminUsers: React.FC = () => {
|
||||
</div>
|
||||
</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
|
||||
title={<Space><UserOutlined />创建用户</Space>}
|
||||
open={createModal}
|
||||
@@ -768,6 +855,17 @@ const AdminUsers: React.FC = () => {
|
||||
<InputNumber min={0} style={{ width: '100%' }} size="large" />
|
||||
</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="邮箱">
|
||||
<Input placeholder="选填" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface User {
|
||||
userType: string;
|
||||
allowedMenus?: string[] | null;
|
||||
resourceCapacity?: ResourceCapacityUsage | null;
|
||||
privatePortraitImageLimit: number;
|
||||
}
|
||||
|
||||
export interface CreditRecord {
|
||||
@@ -146,6 +147,8 @@ export interface AdminTeam {
|
||||
memberCount: number;
|
||||
createdAt: string;
|
||||
updatedAt?: string | null;
|
||||
managerId?: string | null;
|
||||
managerName?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminTeamOption {
|
||||
@@ -191,6 +194,7 @@ export interface AdminUser {
|
||||
lastLoginAt?: string;
|
||||
allowedMenus?: string[] | null;
|
||||
resourceCapacity?: ResourceCapacityUsage | null;
|
||||
privatePortraitImageLimit: number;
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
@@ -1117,3 +1121,96 @@ export interface HomeMaterialTextWatermarkPreviewResponse {
|
||||
height: number;
|
||||
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 @@
|
||||
{"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 ###
|
||||
@@ -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.team import router as team_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.include_router(video_prompt_schema_config_router)
|
||||
router.include_router(resource_capacity_router)
|
||||
router.include_router(team_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)
|
||||
@@ -2,18 +2,57 @@ from __future__ import annotations
|
||||
|
||||
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 app.dependencies import get_admin_user, get_db
|
||||
from app.models.team import Team
|
||||
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.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
|
||||
|
||||
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)
|
||||
async def list_admin_teams(
|
||||
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)
|
||||
|
||||
|
||||
@router.post("", response_model=TeamOut)
|
||||
@router.post("", )
|
||||
async def create_admin_team(
|
||||
req: TeamCreate,
|
||||
admin: User = Depends(get_admin_user),
|
||||
@@ -51,20 +90,10 @@ async def create_admin_team(
|
||||
"/admin/teams",
|
||||
detail=json.dumps({"after": req.model_dump()}, ensure_ascii=False),
|
||||
)
|
||||
return {
|
||||
"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,
|
||||
}
|
||||
return await _team_detail_payload(db, team)
|
||||
|
||||
|
||||
@router.put("/{team_id}", response_model=TeamOut)
|
||||
@router.put("/{team_id}", )
|
||||
async def update_admin_team(
|
||||
team_id: str,
|
||||
req: TeamUpdate,
|
||||
@@ -81,17 +110,35 @@ async def update_admin_team(
|
||||
f"/admin/teams/{team_id}",
|
||||
detail=json.dumps({"before": before, "after": after}, ensure_ascii=False),
|
||||
)
|
||||
return {
|
||||
"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,
|
||||
}
|
||||
return await _team_detail_payload(db, team)
|
||||
|
||||
|
||||
@router.put("/{team_id}/manager", )
|
||||
async def set_team_manager_endpoint(
|
||||
team_id: str,
|
||||
req: SetManagerRequest = Body(...),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
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}")
|
||||
|
||||
@@ -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.resources_material import router as resources_material_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.admin import router as admin_module_router
|
||||
from app.api.v1.material_admin import router as material_admin_router
|
||||
from app.api.v1.private_portrait import router as private_portrait_router
|
||||
|
||||
api_router = APIRouter()
|
||||
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(resources_material_router)
|
||||
api_router.include_router(contact_router)
|
||||
api_router.include_router(team_router)
|
||||
api_router.include_router(home_materials_router)
|
||||
api_router.include_router(admin_module_router)
|
||||
api_router.include_router(material_admin_router)
|
||||
api_router.include_router(material_admin_router)
|
||||
api_router.include_router(private_portrait_router)
|
||||
@@ -173,11 +173,34 @@ async def create_user(
|
||||
user_type=req.user_type,
|
||||
frontend_user_kind=req.frontend_user_kind if req.user_type == "frontend" else FrontendUserKind.EXTERNAL.value,
|
||||
allowed_menus=req.allowed_menus,
|
||||
private_portrait_image_limit=req.private_portrait_image_limit,
|
||||
)
|
||||
user.credits = round(user.credits, 2)
|
||||
db.add(user)
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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.credits = round(current_user.credits, 2)
|
||||
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(
|
||||
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)
|
||||
@@ -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}"},
|
||||
)
|
||||
@@ -16,3 +16,5 @@ from app.enums.team import *
|
||||
from app.enums.home_material import *
|
||||
from app.enums.celery_queue import *
|
||||
from app.enums.audio_reference import *
|
||||
|
||||
from app.enums.private_portrait import *
|
||||
|
||||
@@ -6,6 +6,7 @@ class CeleryQueue(str, Enum):
|
||||
GEN_PROVIDER_POLL = "gen_provider_poll"
|
||||
GEN_RESULT_DOWNLOAD = "gen_result_download"
|
||||
GEN_RECOVERY = "gen_recovery"
|
||||
GEN_PRIVATE_PORTRAIT = "gen_private_portrait"
|
||||
DEFAULT = "default"
|
||||
|
||||
|
||||
@@ -19,3 +20,8 @@ class CeleryTaskName(str, Enum):
|
||||
STARTUP_RECOVERY = "recovery.startup_recovery_once"
|
||||
MODULE_ASYNC_RECOVERY = "module_async.recover_module_async_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"
|
||||
|
||||
@@ -5,6 +5,7 @@ class CreditRecordType(str, Enum):
|
||||
RECHARGE = "recharge"
|
||||
CONSUME = "consume"
|
||||
REFUND = "refund"
|
||||
TEAM_INTERNAL = "team_internal" # 团队内部积分流转(管理人分配)
|
||||
|
||||
|
||||
class CreditRecordOwnerType(str, Enum):
|
||||
@@ -30,6 +31,7 @@ class CreditRecordChargeKind(str, Enum):
|
||||
RECHARGE = "recharge"
|
||||
REFUND = "refund"
|
||||
ADMIN_ADJUST = "admin_adjust"
|
||||
TEAM_INTERNAL = "team_internal"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@@ -42,6 +44,7 @@ class CreditRecordSubject(str, Enum):
|
||||
RECHARGE = "recharge"
|
||||
REFUND = "refund"
|
||||
ADMIN_ADJUST = "admin_adjust"
|
||||
TEAM_INTERNAL = "team_internal"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@@ -62,6 +65,7 @@ class CreditRecordSourceModule(str, Enum):
|
||||
SHOT_REPLICATE = "shot_replicate"
|
||||
PAYMENT = "payment"
|
||||
ADMIN = "admin"
|
||||
TEAM = "team"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@@ -104,6 +108,7 @@ class CreditRecordBillingScene(str, Enum):
|
||||
RECHARGE = "recharge"
|
||||
ADMIN_ADJUST = "admin_adjust"
|
||||
REFUND = "refund"
|
||||
TEAM_INTERNAL_TRANSFER = "team_internal_transfer"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@@ -111,6 +116,7 @@ CREDIT_RECORD_TYPE_LABELS = {
|
||||
CreditRecordType.RECHARGE.value: "充值",
|
||||
CreditRecordType.CONSUME.value: "消费",
|
||||
CreditRecordType.REFUND.value: "回退",
|
||||
CreditRecordType.TEAM_INTERNAL.value: "团队内部",
|
||||
}
|
||||
|
||||
CREDIT_RECORD_SUBJECT_LABELS = {
|
||||
@@ -122,6 +128,7 @@ CREDIT_RECORD_SUBJECT_LABELS = {
|
||||
CreditRecordSubject.RECHARGE.value: "充值积分",
|
||||
CreditRecordSubject.REFUND.value: "回退积分",
|
||||
CreditRecordSubject.ADMIN_ADJUST.value: "管理员调整",
|
||||
CreditRecordSubject.TEAM_INTERNAL.value: "团队内部转移",
|
||||
CreditRecordSubject.UNKNOWN.value: "历史未知",
|
||||
}
|
||||
|
||||
@@ -136,6 +143,7 @@ CREDIT_RECORD_CHARGE_KIND_LABELS = {
|
||||
CreditRecordChargeKind.RECHARGE.value: "充值",
|
||||
CreditRecordChargeKind.REFUND.value: "回退",
|
||||
CreditRecordChargeKind.ADMIN_ADJUST.value: "管理员调整",
|
||||
CreditRecordChargeKind.TEAM_INTERNAL.value: "团队内部转移",
|
||||
CreditRecordChargeKind.UNKNOWN.value: "历史未知",
|
||||
}
|
||||
|
||||
@@ -151,6 +159,7 @@ CREDIT_RECORD_SOURCE_MODULE_LABELS = {
|
||||
CreditRecordSourceModule.SHOT_REPLICATE.value: "拆镜复刻",
|
||||
CreditRecordSourceModule.PAYMENT.value: "支付充值",
|
||||
CreditRecordSourceModule.ADMIN.value: "后台管理",
|
||||
CreditRecordSourceModule.TEAM.value: "团队管理",
|
||||
CreditRecordSourceModule.UNKNOWN.value: "历史未知",
|
||||
}
|
||||
|
||||
@@ -189,5 +198,6 @@ CREDIT_RECORD_BILLING_SCENE_LABELS = {
|
||||
CreditRecordBillingScene.RECHARGE.value: "充值",
|
||||
CreditRecordBillingScene.ADMIN_ADJUST.value: "管理员调整",
|
||||
CreditRecordBillingScene.REFUND.value: "回退",
|
||||
CreditRecordBillingScene.TEAM_INTERNAL_TRANSFER.value: "团队内部转账",
|
||||
CreditRecordBillingScene.UNKNOWN.value: "历史未知",
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -1,6 +1,8 @@
|
||||
from app.models.base import Base, TimestampMixin, SoftDeleteMixin, engine, async_session, init_database, close_database
|
||||
from app.models.user import User
|
||||
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.generation_record import GenerationRecord
|
||||
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.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
|
||||
from app.models.contact_request import ContactRequest
|
||||
from app.models.private_portrait import PrivatePortraitProject, PrivatePortraitValidateSession, PrivatePortraitAssetGroup, PrivatePortraitAsset
|
||||
|
||||
__all__ = [
|
||||
"Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session",
|
||||
"init_database", "close_database",
|
||||
"User", "Team", "Project", "GenerationRecord", "CreditRecord",
|
||||
"User", "Team", "TeamInvitation", "TeamJoinRequest", "Project", "GenerationRecord", "CreditRecord",
|
||||
"ModelConfig", "SystemConfig", "Notification", "PaymentOrder",
|
||||
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
|
||||
"MenuConfig", "RechargePackage", "OperationLog", "ContactRequest",
|
||||
@@ -47,4 +50,6 @@ __all__ = [
|
||||
"ShotReplicateTaskSet", "ShotReplicateSegment",
|
||||
"UserOAuth", "UserOAuthAccount", "UserOAuthApp",
|
||||
"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)
|
||||
@@ -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 app.enums.team import TeamStatus
|
||||
@@ -32,3 +32,6 @@ class Team(Base, TimestampMixin, SoftDeleteMixin):
|
||||
index=True,
|
||||
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)
|
||||
@@ -1,6 +1,6 @@
|
||||
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 app.enums.user import FrontendUserKind
|
||||
@@ -39,6 +39,11 @@ class User(Base, TimestampMixin):
|
||||
)
|
||||
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
|
||||
def must_set_password(self) -> bool:
|
||||
return self.user_type == "frontend" and not self.hashed_password
|
||||
|
||||
@@ -58,6 +58,7 @@ class AdminUserOut(BaseModel):
|
||||
last_login_at: NaiveDatetimeOptional = None
|
||||
allowed_menus: list | None = None
|
||||
resource_capacity: ResourceCapacityUsageOut | None = None
|
||||
private_portrait_image_limit: int = 5
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -71,6 +72,7 @@ class CreateUserRequest(BaseModel):
|
||||
user_type: str = Field(default="frontend", pattern="^(frontend|admin)$")
|
||||
frontend_user_kind: str = Field(default="external", pattern="^(internal|external)$")
|
||||
allowed_menus: list | None = None
|
||||
private_portrait_image_limit: int = Field(default=5, ge=0, le=9999)
|
||||
|
||||
|
||||
class UpdateFrontendUserKindRequest(BaseModel):
|
||||
|
||||
@@ -41,6 +41,21 @@ class GenerationAIReference(BaseModel):
|
||||
description="参考素材时长(秒)。type=video/audio 时使用,用于视频/音频素材时长校验",
|
||||
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):
|
||||
|
||||
@@ -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
|
||||
@@ -29,6 +29,8 @@ class TeamOut(TeamBase):
|
||||
member_count: int = 0
|
||||
created_at: NaiveDatetime
|
||||
updated_at: NaiveDatetimeOptional = None
|
||||
manager_id: str | None = None
|
||||
manager_name: str | None = None
|
||||
|
||||
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
|
||||
@@ -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}
|
||||
@@ -15,5 +15,9 @@ class UserOut(BaseModel):
|
||||
allowed_menus: list | None = None
|
||||
must_set_password: bool = False
|
||||
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}
|
||||
|
||||
@@ -282,7 +282,7 @@ async def list_admin_credit_records(
|
||||
|
||||
summary_query = select(
|
||||
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.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))),
|
||||
|
||||
@@ -179,12 +179,14 @@ async def deduct_credits(
|
||||
biz_key: str | None = None,
|
||||
refund_for_biz_key: str | None = None,
|
||||
record_meta: CreditRecordMeta | dict | None = None,
|
||||
record_type: str = "consume",
|
||||
) -> User:
|
||||
"""扣减用户积分,并写入消费流水。
|
||||
|
||||
并发安全点:
|
||||
- 先用 SELECT ... FOR UPDATE 锁住 users 行,避免余额覆盖。
|
||||
- biz_key 不为空时,作为正式业务幂等键;重复调用直接返回当前用户,不重复扣。
|
||||
- record_type: 流水类型,默认 "consume";团队内部流转传 "team_internal"。
|
||||
"""
|
||||
amount = round(float(amount or 0), 2)
|
||||
if amount <= 0:
|
||||
@@ -218,7 +220,7 @@ async def deduct_credits(
|
||||
record = CreditRecord(
|
||||
id=generate_id(),
|
||||
user_id=user_id,
|
||||
type="consume",
|
||||
type=record_type,
|
||||
amount=-amount,
|
||||
balance_after=user.credits,
|
||||
description=description,
|
||||
|
||||
@@ -54,6 +54,7 @@ from app.services.generation_history_meta_service import (
|
||||
build_empty_history_meta,
|
||||
)
|
||||
from app.services.resource_capacity_service import assert_user_resource_capacity_available
|
||||
from app.services.private_portrait.reference_resolver import resolve_private_portrait_references
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
IMAGE_DEFAULT_SIZE = "2K"
|
||||
@@ -247,6 +248,12 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req
|
||||
return existing
|
||||
|
||||
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)
|
||||
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.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
|
||||
|
||||
|
||||
@@ -82,6 +83,12 @@ async def create_chat_generation_task_for_module(
|
||||
task_id = generate_id()
|
||||
now = datetime.now(timezone.utc)
|
||||
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(
|
||||
generation_mode=generation_mode,
|
||||
gen_type=gen_type,
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from volcenginesdkarkruntime import AsyncArk
|
||||
|
||||
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.services.log_config import is_enabled, LOG_DIR, LOG_DATE_FORMAT, encrypt_data
|
||||
from app.services.generation_provider_types import (
|
||||
@@ -97,7 +98,7 @@ def _resolve_url(url: str) -> str:
|
||||
# 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 f"{settings.BASE_URL.rstrip('/')}/{url.lstrip('/')}"
|
||||
|
||||
|
||||
@@ -1,70 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from app.services.log_config import LOG_DATE_FORMAT, LOG_DIR, is_enabled
|
||||
|
||||
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
|
||||
from app.services.operation_log_service import build_exception_detail, log_operation_error, log_operation_event
|
||||
|
||||
|
||||
def log_module_event_file(
|
||||
@@ -78,24 +16,19 @@ def log_module_event_file(
|
||||
detail: dict[str, Any] | None = None,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
"""记录模块流程事件到 JSONL 文件。
|
||||
|
||||
统一落盘目录:log/ModuleGeneration/{module}/YYYY-MM-DD.log
|
||||
不再写 module_generation_events 表。
|
||||
"""
|
||||
entry = {
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"log_type": "module_event",
|
||||
"module": module,
|
||||
"event_type": event_type,
|
||||
"project_id": project_id,
|
||||
"step_id": step_id,
|
||||
"user_id": user_id,
|
||||
"message": message,
|
||||
"detail": _safe_dump_value(detail or {}),
|
||||
"error": error,
|
||||
}
|
||||
_append_module_log(module, entry)
|
||||
log_operation_event(
|
||||
domain="module_generation",
|
||||
module=module,
|
||||
event_type=event_type,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
user_id=user_id,
|
||||
message=message,
|
||||
detail=detail,
|
||||
error=error,
|
||||
event_status="failed" if error else "success",
|
||||
source="service",
|
||||
)
|
||||
|
||||
|
||||
def log_module_prompt_event(
|
||||
@@ -111,22 +44,19 @@ def log_module_prompt_event(
|
||||
token_usage: dict[str, Any] | None = None,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
"""记录模块 AI 提词/分析请求和响应到 JSONL 文件。"""
|
||||
entry = {
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"log_type": "module_prompt",
|
||||
"module": module,
|
||||
"event_type": event_type,
|
||||
"prompt_type": prompt_type,
|
||||
"project_id": project_id,
|
||||
"step_id": step_id,
|
||||
"user_id": user_id,
|
||||
"request": _safe_dump_value(request or {}),
|
||||
"response": _safe_dump_value(response or {}),
|
||||
"token_usage": _safe_dump_value(token_usage or {}),
|
||||
"error": error,
|
||||
}
|
||||
_append_module_log(module, entry)
|
||||
log_operation_event(
|
||||
domain="module_generation",
|
||||
module=module,
|
||||
event_type=event_type,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
user_id=user_id,
|
||||
message=f"模块 AI 请求:{prompt_type}",
|
||||
detail={"prompt_type": prompt_type, "request": request or {}, "response": response or {}, "token_usage": token_usage or {}},
|
||||
error=error,
|
||||
event_status="failed" if error else "success",
|
||||
source="service",
|
||||
)
|
||||
|
||||
|
||||
def log_module_error(
|
||||
@@ -141,23 +71,16 @@ def log_module_error(
|
||||
error: str | None = None,
|
||||
exc: BaseException | None = None,
|
||||
) -> None:
|
||||
"""记录模块异常日志。
|
||||
|
||||
- 兼容原有 detail/error 参数。
|
||||
- 新增 exc 后自动记录 exception_type、message、traceback。
|
||||
- 日志写入失败会被底层吞掉,不影响主流程。
|
||||
"""
|
||||
merged_detail = build_exception_detail(exc, detail)
|
||||
entry = {
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"log_type": "module_error",
|
||||
"module": module,
|
||||
"event_type": event_type,
|
||||
"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)
|
||||
log_operation_error(
|
||||
domain="module_generation",
|
||||
module=module,
|
||||
event_type=event_type,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
user_id=user_id,
|
||||
message=message,
|
||||
detail=detail,
|
||||
error=error if error is not None else (str(exc) if exc else None),
|
||||
exc=exc,
|
||||
source="service",
|
||||
)
|
||||
|
||||
@@ -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 来自数据库默认值或 onupdate,flush 后可能处于 expired 状态。
|
||||
# 在 async SQLAlchemy 下,响应转换时同步读取 expired 字段会触发 MissingGreenlet。
|
||||
await db.refresh(session)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_SESSION_CREATE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, session_id=session.id, detail={"remote_project_name": project.remote_project_name})
|
||||
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 来自数据库默认值或 onupdate,flush 后可能处于 expired 状态。
|
||||
# 在 async SQLAlchemy 下,响应转换时同步读取 expired 字段会触发 MissingGreenlet。
|
||||
await db.refresh(asset)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, detail={"remote_asset_id": remote_asset_id, "remote_project_name": project.remote_project_name})
|
||||
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),
|
||||
)
|
||||
@@ -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 {
|
||||
"id": team.id,
|
||||
"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),
|
||||
"created_at": team.created_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)
|
||||
)
|
||||
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 {
|
||||
"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,
|
||||
}
|
||||
|
||||
@@ -164,6 +183,7 @@ async def create_team(db: AsyncSession, req: TeamCreate) -> Team:
|
||||
)
|
||||
db.add(team)
|
||||
await db.flush()
|
||||
await db.refresh(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.sort_order = req.sort_order or 0
|
||||
await db.flush()
|
||||
await db.refresh(team)
|
||||
|
||||
after = {
|
||||
"id": team.id,
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from volcenginesdkarkruntime import AsyncArk
|
||||
|
||||
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.services.log_config import is_enabled, LOG_DIR, LOG_DATE_FORMAT, encrypt_data
|
||||
from app.services.generation_provider_types import (
|
||||
@@ -98,7 +99,7 @@ def _resolve_url(url: str) -> str:
|
||||
# with open(file_path, "rb") as f:
|
||||
# b64 = base64.b64encode(f.read()).decode()
|
||||
# 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 f"{settings.BASE_URL.rstrip('/')}/{url.lstrip('/')}"
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ CELERY_TASK_IMPORTS = (
|
||||
"app.tasks.module_async_recovery_tasks",
|
||||
"app.tasks.user_oauth_tasks",
|
||||
"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:
|
||||
if not bool(getattr(settings, "POLL_DUE_DISPATCH_ENABLED", True)):
|
||||
return {}
|
||||
return {
|
||||
"dispatch-due-poll-tasks-every-minute": {
|
||||
schedule: dict = {}
|
||||
if bool(getattr(settings, "POLL_DUE_DISPATCH_ENABLED", True)):
|
||||
schedule["dispatch-due-poll-tasks-every-minute"] = {
|
||||
"task": CeleryTaskName.DISPATCH_DUE_POLL.value,
|
||||
"schedule": max(1, int(settings.POLL_DUE_DISPATCH_INTERVAL_SECONDS or 60)),
|
||||
"options": {
|
||||
@@ -52,7 +52,17 @@ def _beat_schedule() -> dict:
|
||||
"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 "")
|
||||
@@ -116,6 +126,11 @@ if broker_url:
|
||||
CeleryTaskName.MODULE_ASYNC_RECOVERY.value: {"queue": RECOVERY_QUEUE},
|
||||
"user_oauth.update_oauth_accounts": {"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:
|
||||
|
||||
@@ -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:
|
||||
logger.error(f"刷新巨量引擎token失败: oauth_id={oauth.id}, 错误信息: {data}")
|
||||
# refresh_token已失效
|
||||
if data.get("code") in [40103, 40107]:
|
||||
# refresh_token已失效或刷新失败,统一清理token
|
||||
if data.get("code") in [40103, 40107, 40000]:
|
||||
where_cond = UserOAuth.deleted_at.is_(None)
|
||||
if 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 = [row[0] for row in related_oauth_ids.all()]
|
||||
# 【改动2:失效直接删除Redis缓存,不再写入空值】
|
||||
for related_id in related_oauth_ids:
|
||||
await _delete_redis_token(related_id)
|
||||
return
|
||||
|
||||
@@ -136,7 +136,13 @@ class DouyinRequest:
|
||||
now_beijing = datetime.now(tz=BEIJING_TZ)
|
||||
|
||||
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)
|
||||
if oauth_data.appid:
|
||||
where_cond = where_cond & (UserOAuth.appid == oauth_data.appid)
|
||||
@@ -191,6 +197,11 @@ class DouyinRequest:
|
||||
return token
|
||||
|
||||
# =========【核心业务规则实现:判断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:
|
||||
# RefreshToken过期 → 授权彻底失效,清理Redis脏缓存
|
||||
await self._delete_redis_token(oauth_id)
|
||||
@@ -242,6 +253,7 @@ class DouyinRequest:
|
||||
|
||||
code = data.get('code', 0)
|
||||
if code != 0:
|
||||
#刷新token
|
||||
raise ValueError(f"刷新access_token失败,接口返回:{data}")
|
||||
|
||||
data = data.get('data', {})
|
||||
|
||||
-505
File diff suppressed because one or more lines are too long
+505
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-CclRZKjR.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DzBPiPEO.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -32,6 +32,8 @@ import ConsumePage from './pages/ConsumePage';
|
||||
import AuthorizationWaitingPage from './pages/AuthorizationWaitingPage';
|
||||
import PopularPage from './pages/PopularPage';
|
||||
import CreativePlazaPage from './pages/CreativePlazaPage';
|
||||
import TeamManagementPage from './pages/TeamManagementPage';
|
||||
import JoinTeamPage from './pages/JoinTeamPage';
|
||||
import { useAuthStore } from './store/useAuthStore';
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { user, loading, checkAuth } = useAuthStore();
|
||||
@@ -124,6 +126,8 @@ const App = () => {
|
||||
<Route path="popular" element={<PopularPage />} />
|
||||
<Route path="authacc" element={<AuthAccountPage />} />
|
||||
<Route path="creativeplaza" element={<CreativePlazaPage />} />
|
||||
<Route path="team-management" element={<TeamManagementPage />} />
|
||||
<Route path="join-team" element={<JoinTeamPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/projects" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -7,6 +7,8 @@ import * as mock from './mock';
|
||||
import type {
|
||||
User, CreditRecord, Project, GenerationRecord, OptimizeParams, GenerateParams, OptimizeResult,
|
||||
Industry, IndustryConfig, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification,
|
||||
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitProject, PrivatePortraitValidateSession,
|
||||
PrivatePortraitAssetListOut, PrivatePortraitAsset, PrivatePortraitSelectableAssetListOut,
|
||||
} from '../types';
|
||||
const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true';
|
||||
// ── Auth ──────────────────────────────────────────────────
|
||||
@@ -738,3 +740,147 @@ export async function getHomeCaseButton(id: string,limit:number=5): Promise<any>
|
||||
export async function deleteResourcesMaterial(params:any): Promise<any> {
|
||||
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: 'credits', icon: <WalletOutlined style={{ color: '#c9a96e' }} />, label: `积分: ${user?.credits ?? 0}`, disabled: true },
|
||||
{ type: 'divider' as const },
|
||||
...(user?.isTeamManager ? [{ key: 'teamManagement' as const, icon: <TeamOutlined style={{ color: '#6366f1' }} />, label: '团队管理' }] : []),
|
||||
{ key: 'myCredits', icon: <WalletOutlined />, label: '积分明细' },
|
||||
{ key: 'orderRecords', icon: <FileTextOutlined />, label: '订单记录' },
|
||||
{ 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 === 'messages') { navigate('/messages'); }
|
||||
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 === 'orderRecords') { navigate('/user-center?tab=orders'); }
|
||||
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;
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
} from '../api';
|
||||
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { PrivatePortraitAssetPicker } from '../components/privatePortrait';
|
||||
import type { PrivatePortraitSelectableAsset } from '../types';
|
||||
|
||||
import {
|
||||
PlusOutlined,
|
||||
@@ -73,6 +75,9 @@ interface MediaReference {
|
||||
duration?: number;
|
||||
role?: string;
|
||||
label?: string;
|
||||
source?: string;
|
||||
private_asset_id?: string;
|
||||
remote_asset_id?: string;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
@@ -169,6 +174,7 @@ const AIChatPage: React.FC = () => {
|
||||
const [lastFrame, setLastFrame] = useState<MediaReference | null>(null);
|
||||
const [uploadTarget, setUploadTarget] = useState<'first' | 'last' | null>(null);
|
||||
const [referenceModeDropdownVisible, setReferenceModeDropdownVisible] = useState(false);
|
||||
const [privateAssetPickerOpen, setPrivateAssetPickerOpen] = useState(false);
|
||||
const [mediaStackHovered, setMediaStackHovered] = useState(false);
|
||||
const mediaStackCloseTimerRef = useRef<number | null>(null);
|
||||
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 newList = currentMedia.filter((_, i) => i !== index);
|
||||
const labels = generateMediaLabels(newList);
|
||||
@@ -2547,6 +2588,15 @@ const AIChatPage: React.FC = () => {
|
||||
</div>
|
||||
</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 && (
|
||||
@@ -2572,7 +2622,7 @@ const AIChatPage: React.FC = () => {
|
||||
<div style={{ position: 'relative', width: 52, height: 60 }}>
|
||||
{media.type === 'image' ? (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
|
||||
src={buildPreviewUrl(media.url)}
|
||||
alt={media.name}
|
||||
onClick={() => {
|
||||
setAttachmentPreviewUrl(media.url);
|
||||
@@ -2584,7 +2634,7 @@ const AIChatPage: React.FC = () => {
|
||||
/>
|
||||
) : media.type === 'video' ? (
|
||||
<video
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
|
||||
src={buildPreviewUrl(media.url)}
|
||||
muted
|
||||
onClick={() => {
|
||||
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' }}
|
||||
>
|
||||
{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' }} />
|
||||
) : (
|
||||
<AudioOutlined style={{ fontSize: 20, color: '#fff' }} />
|
||||
@@ -2718,6 +2768,34 @@ const AIChatPage: React.FC = () => {
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
@@ -4087,20 +4165,28 @@ const AIChatPage: React.FC = () => {
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
|
||||
{attachmentPreviewType === 'image' ? (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${attachmentPreviewUrl}`}
|
||||
src={buildPreviewUrl(attachmentPreviewUrl)}
|
||||
alt="预览"
|
||||
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
ref={attachmentPreviewVideoRef}
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${attachmentPreviewUrl}`}
|
||||
src={buildPreviewUrl(attachmentPreviewUrl)}
|
||||
controls
|
||||
style={{ maxWidth: '100%', maxHeight: '400px' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
<PrivatePortraitAssetPicker
|
||||
open={privateAssetPickerOpen}
|
||||
onClose={() => setPrivateAssetPickerOpen(false)}
|
||||
onSelect={handlePrivatePortraitAssetsSelected}
|
||||
selectedIds={currentMedia.map((m) => m.private_asset_id).filter(Boolean) as string[]}
|
||||
maxCount={maxImage}
|
||||
/>
|
||||
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
PlayCircleOutlined,
|
||||
DeleteOutlined,
|
||||
LoadingOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { PrivatePortraitLibraryPanel } from '../components/privatePortrait';
|
||||
import { gethistory, gethistoryItems, getOAuthList, asyncBatchUploadMaterial, updateFilename, getUploadHistory, getAllOAuthAccountList, getOpenTypeAll, getPreTestList, getDefaultPreTest, deleteHistory, deleteResourcesMaterial } from '../api';
|
||||
|
||||
const { Search } = Input;
|
||||
@@ -48,7 +50,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
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 [recordlist, setRecordList] = useState<any[]>([]);
|
||||
const [Pagebreak, setPagebreak] = useState<any>({
|
||||
@@ -754,6 +756,11 @@ const GeneratedRecord: React.FC = () => {
|
||||
});
|
||||
};
|
||||
const loadRecordList = () => {
|
||||
if (filterType === 'private_portrait') {
|
||||
setLoading(false);
|
||||
setRecordList([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
let historySource = '';
|
||||
if (filterType === 'project') {
|
||||
@@ -1003,8 +1010,29 @@ const GeneratedRecord: React.FC = () => {
|
||||
>
|
||||
拆镜复刻
|
||||
</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>
|
||||
</div>
|
||||
{filterType !== 'private_portrait' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||
{/* 多选模式按钮 */}
|
||||
{isSelectionMode ? (
|
||||
@@ -1085,7 +1113,12 @@ const GeneratedRecord: React.FC = () => {
|
||||
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{filterType === 'private_portrait' ? (
|
||||
<PrivatePortraitLibraryPanel />
|
||||
) : (
|
||||
<>
|
||||
{/* Second row filter: 视频 / 图片 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
@@ -1501,6 +1534,8 @@ const GeneratedRecord: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* 推送任务历史弹窗 */}
|
||||
<Modal
|
||||
title="推送任务历史"
|
||||
|
||||
@@ -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;
|
||||
@@ -32,7 +32,7 @@ interface AppState {
|
||||
};
|
||||
enginesele: any;
|
||||
inputValue: string;
|
||||
currentMedia: { name: string; type: 'image' | 'video' | 'audio'; url: string; label: string; duration?: number }[];
|
||||
currentMedia: MediaReference[];
|
||||
|
||||
fetchProjects: () => Promise<void>;
|
||||
createProject: (name: string, industry: Industry) => Promise<Project>;
|
||||
@@ -59,7 +59,7 @@ interface AppState {
|
||||
setVideoResolution: (resolution: string) => void;
|
||||
setEnginesele: (enginesele: any) => 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,61 @@ export interface User {
|
||||
allowedMenus?: string[] | null;
|
||||
mustSetPassword?: boolean;
|
||||
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 {
|
||||
@@ -73,6 +128,12 @@ export interface MediaReference {
|
||||
url: string;
|
||||
type: 'image' | 'video' | 'audio';
|
||||
name: string;
|
||||
label?: string;
|
||||
duration?: number;
|
||||
role?: string;
|
||||
source?: string;
|
||||
private_asset_id?: string;
|
||||
remote_asset_id?: string;
|
||||
}
|
||||
|
||||
export interface GenerationRecord {
|
||||
@@ -188,3 +249,97 @@ export interface AdminNotification {
|
||||
isRead: boolean;
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user