解决app/api代码合并冲突
This commit is contained in:
@@ -22,3 +22,4 @@ video-gen-api/dist/
|
||||
# 忽略特定类型文件但保留目录
|
||||
# *.pyc
|
||||
# !dir/*.pycnode_modules/
|
||||
*.tmp.*
|
||||
@@ -21,6 +21,8 @@ video_item/
|
||||
| PostgreSQL | >= 14 | 推荐 16 |
|
||||
| Redis | >= 6 | 可选,推荐用于限流/验证码/Celery |
|
||||
| FFmpeg | 任意 | 可选,用于视频封面截帧 |
|
||||
| alipay-sdk-python | >=3.7.1160 | 可选,用于支付 |
|
||||
| ca-certificates | 任意 | **必须**,HTTPS 请求需要(新服务器/容器常缺) |
|
||||
|
||||
---
|
||||
|
||||
@@ -29,6 +31,12 @@ video_item/
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
# ⚠️ 新服务器/容器必须先装 CA 证书,否则 HTTPS 请求(支付宝/火山等)全部失败
|
||||
# CentOS/RHEL
|
||||
sudo yum install -y ca-certificates
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install -y ca-certificates
|
||||
|
||||
cd video-gen-api
|
||||
|
||||
# 创建虚拟环境
|
||||
@@ -46,6 +54,12 @@ pip install -e ".[pg,redis]"
|
||||
|
||||
# 如需 Celery 异步任务(ChatAPI 生成流水线)
|
||||
pip install -e ".[pg,redis,celery]"
|
||||
|
||||
#安装阿里支付sdk
|
||||
pip install -e ".[pg,redis,celery,alipay]"
|
||||
|
||||
#安装火山sdk
|
||||
pip install -e ".[pg,redis,celery,alipay,volc]"
|
||||
```
|
||||
|
||||
### 2. 配置环境变量
|
||||
|
||||
Generated
+4
-3
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.2.2",
|
||||
"antd": "^6.3.7",
|
||||
"dayjs": "^1.11.21",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.15.0",
|
||||
@@ -1333,9 +1334,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.20",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
|
||||
"integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
|
||||
"version": "1.11.21",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
|
||||
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.2.2",
|
||||
"antd": "^6.3.7",
|
||||
"dayjs": "^1.11.21",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.15.0",
|
||||
|
||||
@@ -11,6 +11,7 @@ import AdminSettings from './pages/AdminSettings';
|
||||
import AdminNotificationManager from './pages/AdminNotificationManager';
|
||||
import AdminCreditRecords from './pages/AdminCreditRecords';
|
||||
import AdminPaymentConfig from './pages/AdminPaymentConfig';
|
||||
import AdminPaymentStats from './pages/AdminPaymentStats';
|
||||
import AdminIndustries from './pages/AdminIndustries';
|
||||
import AdminVideoEngines from './pages/AdminVideoEngines';
|
||||
import AdminImageEngines from './pages/AdminImageEngines';
|
||||
@@ -76,6 +77,7 @@ const App = () => {
|
||||
<Route path="menu-configs" element={<AdminMenuConfig />} />
|
||||
<Route path="recharge-packages" element={<AdminRechargePackages />} />
|
||||
<Route path="payment" element={<AdminPaymentConfig />} />
|
||||
<Route path="payment-stats" element={<AdminPaymentStats />} />
|
||||
<Route path="settings" element={<AdminSettings />} />
|
||||
<Route path="notifications" element={<AdminNotificationManager />} />
|
||||
<Route path="oauthapp-list" element={<AdminOauthAppList />} />
|
||||
|
||||
@@ -207,6 +207,43 @@ export async function updatePaymentConfig(id: string, value: string): Promise<vo
|
||||
await api.put(`/admin/payment-configs/${id}`, { value });
|
||||
}
|
||||
|
||||
export async function batchUpdatePaymentConfigs(configs: Record<string, string>): Promise<void> {
|
||||
await api.put('/admin/payment-configs/batch', configs);
|
||||
}
|
||||
|
||||
export async function getPaymentStats(params?: {
|
||||
paymentMethod?: string;
|
||||
status?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<{
|
||||
byStatus: Record<string, { count: number; amount: number }>;
|
||||
today: { paidCount: number; paidAmount: number };
|
||||
month: { paidCount: number; paidAmount: number };
|
||||
recent: any[];
|
||||
}> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.paymentMethod) searchParams.set('payment_method', params.paymentMethod);
|
||||
if (params?.status) searchParams.set('status', params.status);
|
||||
if (params?.startDate) searchParams.set('start_date', params.startDate);
|
||||
if (params?.endDate) searchParams.set('end_date', params.endDate);
|
||||
const queryString = searchParams.toString();
|
||||
const url = queryString ? `/admin/payment-stats?${queryString}` : '/admin/payment-stats';
|
||||
return api.get(url);
|
||||
}
|
||||
|
||||
export async function getAdminPaymentOrders(params?: { method?: string; status?: string }): Promise<{ items: any[] }> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.method) qs.set('method', params.method);
|
||||
if (params?.status) qs.set('status', params.status);
|
||||
const suffix = qs.toString() ? `?${qs.toString()}` : '';
|
||||
return api.get(`/admin/payment-orders${suffix}`);
|
||||
}
|
||||
|
||||
export async function refundPaymentOrder(orderNo: string): Promise<void> {
|
||||
await api.post(`/admin/payment-orders/${orderNo}/refund`);
|
||||
}
|
||||
|
||||
export async function getAdminNotifications(): Promise<{ total: number; items: any[] }> {
|
||||
return api.get('/admin/notifications');
|
||||
}
|
||||
|
||||
@@ -1,32 +1,25 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, message, Switch, Typography,
|
||||
Button, Card, Form, Input, message, Switch, Typography, InputNumber,
|
||||
} from 'antd';
|
||||
import {
|
||||
SaveOutlined, WechatOutlined, AlipayCircleOutlined, DollarOutlined,
|
||||
SaveOutlined, WechatOutlined, AlipayCircleOutlined, DollarOutlined, ClockCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getPaymentConfigs, updatePaymentConfig } from '../api';
|
||||
|
||||
interface PaymentConfig {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
}
|
||||
import { getPaymentConfigs, batchUpdatePaymentConfigs } from '../api';
|
||||
|
||||
const AdminPaymentConfig: React.FC = () => {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [configs, setConfigs] = useState<PaymentConfig[]>([]);
|
||||
const [wechatEnabled, setWechatEnabled] = useState(false);
|
||||
const [alipayEnabled, setAlipayEnabled] = useState(false);
|
||||
const [mockMode, setMockMode] = useState(false);
|
||||
const [orderTimeout, setOrderTimeout] = useState(180);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await getPaymentConfigs();
|
||||
setConfigs(data);
|
||||
const map: Record<string, string> = {};
|
||||
data.forEach((c: PaymentConfig) => { map[c.key] = c.value; });
|
||||
data.forEach((c: any) => { map[c.key] = c.value; });
|
||||
form.setFieldsValue({
|
||||
wechat_mch_id: map['payment_wechat_mch_id'] || '',
|
||||
wechat_api_key: map['payment_wechat_api_key'] || '',
|
||||
@@ -36,9 +29,13 @@ const AdminPaymentConfig: React.FC = () => {
|
||||
alipay_private_key: map['payment_alipay_private_key'] || '',
|
||||
alipay_public_key: map['payment_alipay_public_key'] || '',
|
||||
alipay_notify_url: map['payment_alipay_notify_url'] || '',
|
||||
alipay_gateway: map['payment_alipay_gateway'] || '',
|
||||
order_timeout: map['payment_order_timeout'] || '180',
|
||||
});
|
||||
setWechatEnabled(map['payment_wechat_enabled'] === 'true');
|
||||
setAlipayEnabled(map['payment_alipay_enabled'] === 'true');
|
||||
setMockMode(map['payment_mock'] === 'true');
|
||||
setOrderTimeout(parseInt(map['payment_order_timeout'] || '180', 10));
|
||||
} catch {
|
||||
message.error('加载支付配置失败');
|
||||
}
|
||||
@@ -50,24 +47,21 @@ const AdminPaymentConfig: React.FC = () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
const updates: [string, string][] = [
|
||||
['payment_wechat_enabled', String(wechatEnabled)],
|
||||
['payment_wechat_mch_id', values.wechat_mch_id || ''],
|
||||
['payment_wechat_api_key', values.wechat_api_key || ''],
|
||||
['payment_wechat_cert_path', values.wechat_cert_path || ''],
|
||||
['payment_wechat_notify_url', values.wechat_notify_url || ''],
|
||||
['payment_alipay_enabled', String(alipayEnabled)],
|
||||
['payment_alipay_app_id', values.alipay_app_id || ''],
|
||||
['payment_alipay_private_key', values.alipay_private_key || ''],
|
||||
['payment_alipay_public_key', values.alipay_public_key || ''],
|
||||
['payment_alipay_notify_url', values.alipay_notify_url || ''],
|
||||
];
|
||||
for (const [key, value] of updates) {
|
||||
const cfg = configs.find(c => c.key === key);
|
||||
if (cfg) {
|
||||
await updatePaymentConfig(cfg.id, value);
|
||||
}
|
||||
}
|
||||
await batchUpdatePaymentConfigs({
|
||||
payment_mock: String(mockMode),
|
||||
payment_wechat_enabled: String(wechatEnabled),
|
||||
payment_wechat_mch_id: values.wechat_mch_id || '',
|
||||
payment_wechat_api_key: values.wechat_api_key || '',
|
||||
payment_wechat_cert_path: values.wechat_cert_path || '',
|
||||
payment_wechat_notify_url: values.wechat_notify_url || '',
|
||||
payment_alipay_enabled: String(alipayEnabled),
|
||||
payment_alipay_app_id: values.alipay_app_id || '',
|
||||
payment_alipay_private_key: values.alipay_private_key || '',
|
||||
payment_alipay_public_key: values.alipay_public_key || '',
|
||||
payment_alipay_notify_url: values.alipay_notify_url || '',
|
||||
payment_alipay_gateway: values.alipay_gateway || '',
|
||||
payment_order_timeout: String(values.order_timeout || 180),
|
||||
});
|
||||
message.success('支付配置已保存');
|
||||
load();
|
||||
} catch {
|
||||
@@ -79,6 +73,61 @@ const AdminPaymentConfig: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720 }}>
|
||||
{/* 通用设置 */}
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: 'rgba(99,102,241,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 22, color: '#6366f1',
|
||||
}}><ClockCircleOutlined /></div>
|
||||
<div>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>通用设置</Typography.Title>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>订单超时和测试模式配置</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="order_timeout"
|
||||
label={<span style={{ fontWeight: 500 }}>订单超时时间</span>}
|
||||
extra="订单创建后超过此时间未支付将自动取消(秒)"
|
||||
>
|
||||
<InputNumber
|
||||
min={30}
|
||||
max={86400}
|
||||
placeholder="180"
|
||||
style={{ width: '100%' }}
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{/* Mock Mode Toggle */}
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16, background: mockMode ? '#fff7e6' : '#fafbff' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: mockMode ? 'rgba(245,158,11,0.15)' : 'rgba(99,102,241,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 22, color: mockMode ? '#f59e0b' : '#6366f1',
|
||||
}}><DollarOutlined /></div>
|
||||
<div>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>模拟支付模式</Typography.Title>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{mockMode ? '⚠️ 开启后所有充值会直接成功(仅用于测试)' : '关闭 - 使用真实支付渠道'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={mockMode} onChange={setMockMode} checkedChildren="已开启" unCheckedChildren="已关闭" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* WeChat Pay */}
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
@@ -141,7 +190,10 @@ const AdminPaymentConfig: React.FC = () => {
|
||||
<Form.Item name="alipay_public_key" label="支付宝公钥">
|
||||
<Input.TextArea rows={3} placeholder="支付宝公钥" disabled={!alipayEnabled} />
|
||||
</Form.Item>
|
||||
<Form.Item name="alipay_notify_url" label="回调地址">
|
||||
<Form.Item name="alipay_gateway" label="网关地址" extra="正式环境: https://openapi.alipay.com/gateway.do 沙箱环境: https://openapi-sandbox.dl.alipaydev.com/gateway.do">
|
||||
<Input placeholder="https://openapi.alipay.com/gateway.do" size="large" disabled={!alipayEnabled} />
|
||||
</Form.Item>
|
||||
<Form.Item name="alipay_notify_url" label="回调地址" extra="用户支付成功后,支付宝会主动通知此地址,服务器收到通知后给用户加积分">
|
||||
<Input placeholder="https://yourdomain.com/api/payments/alipay/callback" size="large" disabled={!alipayEnabled} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Card, Col, Row, Space, Table, Tag, Typography, Statistic, message, Select, DatePicker, Button, ConfigProvider, Popconfirm
|
||||
} from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import {
|
||||
DollarOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, ReloadOutlined, UndoOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { getPaymentStats, refundPaymentOrder } from '../api';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Option } = Select;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const AdminPaymentStats: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [filters, setFilters] = useState<{
|
||||
paymentMethod?: string;
|
||||
status?: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}>({
|
||||
startDate: dayjs().format('YYYY-MM-DD'),
|
||||
endDate: dayjs().format('YYYY-MM-DD'),
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getPaymentStats(filters);
|
||||
setStats(data);
|
||||
} catch {
|
||||
message.error('加载支付统计失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [filters]);
|
||||
|
||||
const handleReset = () => {
|
||||
setFilters({
|
||||
startDate: dayjs().format('YYYY-MM-DD'),
|
||||
endDate: dayjs().format('YYYY-MM-DD'),
|
||||
});
|
||||
};
|
||||
|
||||
const handleRefund = async (orderNo: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await refundPaymentOrder(orderNo);
|
||||
message.success('退款成功');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.detail || '退款失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateChange = (dates: any) => {
|
||||
if (dates && dates.length === 2) {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
startDate: dates[0].format('YYYY-MM-DD'),
|
||||
endDate: dates[1].format('YYYY-MM-DD'),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const statusConfig: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
|
||||
paid: { color: 'green', label: '已支付', icon: <CheckCircleOutlined /> },
|
||||
pending: { color: 'gold', label: '待支付', icon: <ClockCircleOutlined /> },
|
||||
cancelled: { color: 'default', label: '已取消', icon: <CloseCircleOutlined /> },
|
||||
refunded: { color: 'red', label: '已退款', icon: <UndoOutlined /> },
|
||||
};
|
||||
|
||||
const methodConfig: Record<string, { color: string; label: string }> = {
|
||||
alipay: { color: 'blue', label: '支付宝' },
|
||||
wechat: { color: 'green', label: '微信' },
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '订单号', dataIndex: 'orderNo', key: 'orderNo', width: 200 },
|
||||
{ title: '用户', dataIndex: 'username', key: 'username', width: 120 },
|
||||
{
|
||||
title: '支付方式', dataIndex: 'paymentMethod', key: 'paymentMethod', width: 100,
|
||||
render: (m: string) => {
|
||||
const c = methodConfig[m] || { color: 'default', label: m };
|
||||
return <Tag color={c.color}>{c.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '金额', dataIndex: 'amount', key: 'amount', width: 100,
|
||||
render: (a: number) => <Typography.Text strong style={{ color: '#10b981' }}>¥{a.toFixed(2)}</Typography.Text>,
|
||||
},
|
||||
{ title: '积分', dataIndex: 'credits', key: 'credits', width: 80 },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', key: 'status', width: 100,
|
||||
render: (s: string) => {
|
||||
const c = statusConfig[s] || { color: 'default', label: s, icon: null };
|
||||
return <Tag color={c.color} icon={c.icon}>{c.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '支付宝交易号', dataIndex: 'tradeNo', key: 'tradeNo', width: 200, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 160,
|
||||
render: (d: string) => <span className="date-display" style={{ color: '#94a3b8' }}>{d ? formatDate(d) : '-'}</span>,
|
||||
},
|
||||
{
|
||||
title: '支付时间', dataIndex: 'paidAt', key: 'paidAt', width: 160,
|
||||
render: (d: string) => <span className="date-display" style={{ color: '#94a3b8' }}>{d ? formatDate(d) : '-'}</span>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
render: (_: any, record: any) => {
|
||||
if (record.status === 'paid') {
|
||||
return (
|
||||
<Popconfirm
|
||||
title="确认要退款该订单吗?"
|
||||
description="退款后积分会扣除,金额会原路返回"
|
||||
onConfirm={() => handleRefund(record.orderNo)}
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" danger size="small" icon={<UndoOutlined />}>
|
||||
退款
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (!stats) {
|
||||
return <div style={{ padding: 24, color: '#94a3b8' }}>加载中…</div>;
|
||||
}
|
||||
|
||||
const paidInfo = stats.byStatus?.paid || { count: 0, amount: 0 };
|
||||
const pendingInfo = stats.byStatus?.pending || { count: 0, amount: 0 };
|
||||
const cancelledInfo = stats.byStatus?.cancelled || { count: 0, amount: 0 };
|
||||
const refundedInfo = stats.byStatus?.refunded || { count: 0, amount: 0 };
|
||||
const totalOrders = paidInfo.count + pendingInfo.count + cancelledInfo.count + refundedInfo.count;
|
||||
const monthInfo = stats.month || { count: 0, amount: 0 };
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<div>
|
||||
{/* Summary cards */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={12} lg={12}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Statistic
|
||||
title="今日收入"
|
||||
value={stats.today.paidAmount}
|
||||
precision={2}
|
||||
prefix={<DollarOutlined style={{ color: '#10b981' }} />}
|
||||
suffix="元"
|
||||
valueStyle={{ color: '#10b981', fontWeight: 700 }}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{stats.today.paidCount} 笔订单
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={12}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Statistic
|
||||
title="本月累计"
|
||||
value={monthInfo.paidAmount}
|
||||
precision={2}
|
||||
prefix={<DollarOutlined style={{ color: '#6366f1' }} />}
|
||||
suffix="元"
|
||||
valueStyle={{ color: '#6366f1', fontWeight: 700 }}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{monthInfo.paidCount} 笔订单
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Status breakdown */}
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}
|
||||
title={<Space><DollarOutlined />订单状态分布</Space>}>
|
||||
<Row gutter={16}>
|
||||
{['paid', 'pending', 'cancelled', 'refunded'].map(s => {
|
||||
const info = stats.byStatus?.[s] || { count: 0, amount: 0 };
|
||||
const c = statusConfig[s];
|
||||
const pct = totalOrders > 0 ? ((info.count / totalOrders) * 100).toFixed(1) : '0.0';
|
||||
return (
|
||||
<Col span={6} key={s}>
|
||||
<div style={{
|
||||
padding: 16, borderRadius: 10,
|
||||
background: '#fafbff', border: '1px solid #f0f0f5',
|
||||
}}>
|
||||
<Space>
|
||||
<Tag color={c.color} icon={c.icon}>{c.label}</Tag>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{pct}%</Typography.Text>
|
||||
</Space>
|
||||
<div style={{ marginTop: 8, fontSize: 20, fontWeight: 700 }}>
|
||||
{info.count} <span style={{ fontSize: 13, color: '#94a3b8', fontWeight: 400 }}>笔</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#64748b', marginTop: 4 }}>
|
||||
¥{info.amount.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Recent orders table */}
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}
|
||||
title={<Space><DollarOutlined />订单列表</Space>}>
|
||||
{/* Filters */}
|
||||
<Row gutter={[16, 16]} align="middle" style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<span style={{ marginRight: 8 }}>支付方式:</span>
|
||||
<Select
|
||||
placeholder="全部"
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
value={filters.paymentMethod}
|
||||
onChange={(value) => setFilters(prev => ({ ...prev, paymentMethod: value }))}
|
||||
>
|
||||
<Option value="alipay">支付宝</Option>
|
||||
<Option value="wechat">微信</Option>
|
||||
</Select>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<span style={{ marginRight: 8 }}>状态:</span>
|
||||
<Select
|
||||
placeholder="全部"
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
value={filters.status}
|
||||
onChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
|
||||
>
|
||||
<Option value="paid">已支付</Option>
|
||||
<Option value="pending">待支付</Option>
|
||||
<Option value="cancelled">已取消</Option>
|
||||
<Option value="refunded">已退款</Option>
|
||||
</Select>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<span style={{ marginRight: 8 }}>日期范围:</span>
|
||||
<RangePicker
|
||||
value={[
|
||||
dayjs(filters.startDate),
|
||||
dayjs(filters.endDate),
|
||||
]}
|
||||
onChange={handleDateChange}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={4}>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={stats.recent}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 10, size: 'small' }}
|
||||
scroll={{ x: 1000 }}
|
||||
size="middle"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPaymentStats;
|
||||
@@ -117,6 +117,29 @@ export interface AdminStats {
|
||||
creditsConsumedToday: number;
|
||||
}
|
||||
|
||||
export interface PaymentStats {
|
||||
byStatus: Record<string, { count: number; amount: number }>;
|
||||
today: { paidCount: number; paidAmount: number };
|
||||
month: { paidCount: number; paidAmount: number };
|
||||
recent: PaymentOrder[];
|
||||
}
|
||||
|
||||
export interface PaymentOrder {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
userId: string;
|
||||
username?: string;
|
||||
amount: number;
|
||||
credits: number;
|
||||
paymentMethod: string;
|
||||
status: string;
|
||||
tradeNo?: string;
|
||||
paidAt?: string;
|
||||
createdAt: string;
|
||||
refundedAt?: string;
|
||||
refundAmount?: number;
|
||||
}
|
||||
|
||||
export interface ModelConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
"""add module generation project and hot opening replicate
|
||||
|
||||
Revision ID: 150fc6da855f
|
||||
Revises: 476b259992de
|
||||
Create Date: 2026-06-10 11:56:22.146017
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '150fc6da855f'
|
||||
down_revision: Union[str, None] = '476b259992de'
|
||||
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('module_generation_projects',
|
||||
sa.Column('id', sa.String(length=32), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=32), nullable=False),
|
||||
sa.Column('module', sa.String(length=64), nullable=False),
|
||||
sa.Column('title', sa.String(length=160), nullable=True),
|
||||
sa.Column('status', sa.String(length=32), nullable=False),
|
||||
sa.Column('current_step_code', sa.String(length=64), nullable=True),
|
||||
sa.Column('final_image_url', sa.String(length=512), nullable=True),
|
||||
sa.Column('final_video_url', sa.String(length=512), nullable=True),
|
||||
sa.Column('final_video_cover_url', sa.String(length=512), nullable=True),
|
||||
sa.Column('error_message', sa.Text(), nullable=True),
|
||||
sa.Column('idempotency_key', sa.String(length=64), nullable=True),
|
||||
sa.Column('completed_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_module_generation_projects_status', 'module_generation_projects', ['module', 'status'], unique=False)
|
||||
op.create_index('idx_module_generation_projects_user_module', 'module_generation_projects', ['user_id', 'module'], unique=False)
|
||||
op.create_index(op.f('ix_module_generation_projects_current_step_code'), 'module_generation_projects', ['current_step_code'], unique=False)
|
||||
op.create_index(op.f('ix_module_generation_projects_deleted_at'), 'module_generation_projects', ['deleted_at'], unique=False)
|
||||
op.create_index(op.f('ix_module_generation_projects_idempotency_key'), 'module_generation_projects', ['idempotency_key'], unique=False)
|
||||
op.create_index(op.f('ix_module_generation_projects_module'), 'module_generation_projects', ['module'], unique=False)
|
||||
op.create_index(op.f('ix_module_generation_projects_status'), 'module_generation_projects', ['status'], unique=False)
|
||||
op.create_index(op.f('ix_module_generation_projects_user_id'), 'module_generation_projects', ['user_id'], unique=False)
|
||||
op.create_index('uq_module_generation_projects_user_module_idempotency', 'module_generation_projects', ['user_id', 'module', 'idempotency_key'], unique=True, postgresql_where=sa.text('deleted_at IS NULL AND idempotency_key IS NOT NULL'))
|
||||
op.create_table('module_generation_steps',
|
||||
sa.Column('id', sa.String(length=32), nullable=False),
|
||||
sa.Column('project_id', sa.String(length=32), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=32), nullable=False),
|
||||
sa.Column('module', sa.String(length=64), nullable=False),
|
||||
sa.Column('step_index', sa.Integer(), nullable=False),
|
||||
sa.Column('step_code', sa.String(length=64), nullable=False),
|
||||
sa.Column('status', sa.String(length=32), nullable=False),
|
||||
sa.Column('version', sa.Integer(), nullable=False),
|
||||
sa.Column('is_current', sa.Boolean(), nullable=False),
|
||||
sa.Column('parent_step_id', sa.String(length=32), nullable=True),
|
||||
sa.Column('source_step_id', sa.String(length=32), nullable=True),
|
||||
sa.Column('chat_task_id', sa.String(length=32), nullable=True),
|
||||
sa.Column('input_json', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True),
|
||||
sa.Column('output_json', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True),
|
||||
sa.Column('error_message', sa.Text(), nullable=True),
|
||||
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('completed_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(['chat_task_id'], ['chat_generation_tasks.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['module_generation_projects.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_module_generation_steps_chat_task', 'module_generation_steps', ['chat_task_id'], unique=False)
|
||||
op.create_index('idx_module_generation_steps_project_code', 'module_generation_steps', ['project_id', 'step_code', 'is_current'], unique=False)
|
||||
op.create_index('idx_module_generation_steps_project_current', 'module_generation_steps', ['project_id', 'is_current', 'deleted_at'], unique=False)
|
||||
op.create_index(op.f('ix_module_generation_steps_chat_task_id'), 'module_generation_steps', ['chat_task_id'], unique=False)
|
||||
op.create_index(op.f('ix_module_generation_steps_deleted_at'), 'module_generation_steps', ['deleted_at'], unique=False)
|
||||
op.create_index(op.f('ix_module_generation_steps_is_current'), 'module_generation_steps', ['is_current'], unique=False)
|
||||
op.create_index(op.f('ix_module_generation_steps_module'), 'module_generation_steps', ['module'], unique=False)
|
||||
op.create_index(op.f('ix_module_generation_steps_parent_step_id'), 'module_generation_steps', ['parent_step_id'], unique=False)
|
||||
op.create_index(op.f('ix_module_generation_steps_project_id'), 'module_generation_steps', ['project_id'], unique=False)
|
||||
op.create_index(op.f('ix_module_generation_steps_source_step_id'), 'module_generation_steps', ['source_step_id'], unique=False)
|
||||
op.create_index(op.f('ix_module_generation_steps_status'), 'module_generation_steps', ['status'], unique=False)
|
||||
op.create_index(op.f('ix_module_generation_steps_step_code'), 'module_generation_steps', ['step_code'], unique=False)
|
||||
op.create_index(op.f('ix_module_generation_steps_step_index'), 'module_generation_steps', ['step_index'], unique=False)
|
||||
op.create_index(op.f('ix_module_generation_steps_user_id'), 'module_generation_steps', ['user_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_module_generation_steps_user_id'), table_name='module_generation_steps')
|
||||
op.drop_index(op.f('ix_module_generation_steps_step_index'), table_name='module_generation_steps')
|
||||
op.drop_index(op.f('ix_module_generation_steps_step_code'), table_name='module_generation_steps')
|
||||
op.drop_index(op.f('ix_module_generation_steps_status'), table_name='module_generation_steps')
|
||||
op.drop_index(op.f('ix_module_generation_steps_source_step_id'), table_name='module_generation_steps')
|
||||
op.drop_index(op.f('ix_module_generation_steps_project_id'), table_name='module_generation_steps')
|
||||
op.drop_index(op.f('ix_module_generation_steps_parent_step_id'), table_name='module_generation_steps')
|
||||
op.drop_index(op.f('ix_module_generation_steps_module'), table_name='module_generation_steps')
|
||||
op.drop_index(op.f('ix_module_generation_steps_is_current'), table_name='module_generation_steps')
|
||||
op.drop_index(op.f('ix_module_generation_steps_deleted_at'), table_name='module_generation_steps')
|
||||
op.drop_index(op.f('ix_module_generation_steps_chat_task_id'), table_name='module_generation_steps')
|
||||
op.drop_index('idx_module_generation_steps_project_current', table_name='module_generation_steps')
|
||||
op.drop_index('idx_module_generation_steps_project_code', table_name='module_generation_steps')
|
||||
op.drop_index('idx_module_generation_steps_chat_task', table_name='module_generation_steps')
|
||||
op.drop_table('module_generation_steps')
|
||||
op.drop_index('uq_module_generation_projects_user_module_idempotency', table_name='module_generation_projects', postgresql_where=sa.text('deleted_at IS NULL AND idempotency_key IS NOT NULL'))
|
||||
op.drop_index(op.f('ix_module_generation_projects_user_id'), table_name='module_generation_projects')
|
||||
op.drop_index(op.f('ix_module_generation_projects_status'), table_name='module_generation_projects')
|
||||
op.drop_index(op.f('ix_module_generation_projects_module'), table_name='module_generation_projects')
|
||||
op.drop_index(op.f('ix_module_generation_projects_idempotency_key'), table_name='module_generation_projects')
|
||||
op.drop_index(op.f('ix_module_generation_projects_deleted_at'), table_name='module_generation_projects')
|
||||
op.drop_index(op.f('ix_module_generation_projects_current_step_code'), table_name='module_generation_projects')
|
||||
op.drop_index('idx_module_generation_projects_user_module', table_name='module_generation_projects')
|
||||
op.drop_index('idx_module_generation_projects_status', table_name='module_generation_projects')
|
||||
op.drop_table('module_generation_projects')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,31 @@
|
||||
"""user_oauth表新增归属公司应用,新增授权链接字段
|
||||
|
||||
Revision ID: 6101ba8d5761
|
||||
Revises: 9ac2212e1b8e
|
||||
Create Date: 2026-06-10 16:34:38.842582
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '6101ba8d5761'
|
||||
down_revision: Union[str, None] = '9ac2212e1b8e'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('user_oauth_app', sa.Column('auth_url', sa.String(length=256), nullable=True, comment='应用授权链接'))
|
||||
op.add_column('user_oauth_app', sa.Column('company', sa.String(length=256), nullable=True, comment='应用归属公司名称'))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('user_oauth_app', 'company')
|
||||
op.drop_column('user_oauth_app', 'auth_url')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,29 @@
|
||||
"""user_oauth表新增account_userid授权登录id,用来判断不同账号授权
|
||||
|
||||
Revision ID: 8922eafcd8b0
|
||||
Revises: 6101ba8d5761
|
||||
Create Date: 2026-06-11 16:21:17.617777
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '8922eafcd8b0'
|
||||
down_revision: Union[str, None] = '6101ba8d5761'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('user_oauth', sa.Column('account_userid', sa.String(length=128), nullable=True, comment='授权账户登录userid,同一个用户不同的授权账户token不一样'))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('user_oauth', 'account_userid')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,29 @@
|
||||
"""user_oauth表新增可授权数量字段
|
||||
|
||||
Revision ID: 9ac2212e1b8e
|
||||
Revises: ed3823a24b8e
|
||||
Create Date: 2026-06-10 15:43:47.389442
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '9ac2212e1b8e'
|
||||
down_revision: Union[str, None] = 'ed3823a24b8e'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('user_oauth_app', sa.Column('count', sa.BigInteger(), nullable=False, comment='应用最大可以授权多少个用户'))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('user_oauth_app', 'count')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add_refund_fields_to_payment_orders
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: ed59aefc83da
|
||||
Create Date: 2026-06-10 12:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a1b2c3d4e5f6'
|
||||
down_revision: Union[str, None] = 'ed59aefc83da'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('payment_orders', sa.Column('refund_trade_no', sa.String(length=128), nullable=True))
|
||||
op.add_column('payment_orders', sa.Column('refunded_at', sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column('payment_orders', sa.Column('refund_amount', sa.Float(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('payment_orders', 'refund_amount')
|
||||
op.drop_column('payment_orders', 'refunded_at')
|
||||
op.drop_column('payment_orders', 'refund_trade_no')
|
||||
@@ -0,0 +1,25 @@
|
||||
"""merge changes from remote
|
||||
|
||||
Revision ID: ed3823a24b8e
|
||||
Revises: 150fc6da855f, a1b2c3d4e5f6
|
||||
Create Date: 2026-06-10 15:12:49.885803
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'ed3823a24b8e'
|
||||
down_revision: Union[str, None] = ('150fc6da855f', 'a1b2c3d4e5f6')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,29 @@
|
||||
"""描述改动内容
|
||||
|
||||
Revision ID: ed59aefc83da
|
||||
Revises: 476b259992de
|
||||
Create Date: 2026-06-10 11:28:49.178706
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'ed59aefc83da'
|
||||
down_revision: Union[str, None] = '476b259992de'
|
||||
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! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
@@ -15,7 +15,10 @@ from app.api.v1.recharge_packages import router as recharge_packages_router
|
||||
from app.api.v1.video_engines import router as video_engines_router
|
||||
from app.api.v1.image_engines import router as image_engines_router
|
||||
from app.api.v1.generation_ai import router as generation_ai_router
|
||||
from app.api.v1.hot_opening_replicate import router as hot_opening_replicate_router
|
||||
from app.api.v1.test import router as test_router
|
||||
from app.api.v1.user_oauth import router as user_oauth_router
|
||||
from app.api.v1.user_oauth_app import router as user_oauth_app_router
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(auth_router)
|
||||
@@ -33,4 +36,7 @@ api_router.include_router(recharge_packages_router)
|
||||
api_router.include_router(video_engines_router)
|
||||
api_router.include_router(image_engines_router)
|
||||
api_router.include_router(generation_ai_router)
|
||||
api_router.include_router(hot_opening_replicate_router)
|
||||
api_router.include_router(test_router)
|
||||
api_router.include_router(user_oauth_router)
|
||||
api_router.include_router(user_oauth_app_router)
|
||||
|
||||
@@ -43,6 +43,7 @@ from app.services.notification import create_notification
|
||||
from app.services.auth import hash_password, verify_password
|
||||
from app.services.operation_log import log_operation
|
||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||
from app.services.payment import sync_pending_orders, process_refund
|
||||
|
||||
from app.services.generation_billing_service import (
|
||||
OWNER_GENERATION_RECORD,
|
||||
@@ -412,6 +413,227 @@ async def list_payment_configs(
|
||||
]
|
||||
|
||||
|
||||
@router.put("/payment-configs/batch")
|
||||
async def batch_update_payment_configs(
|
||||
req: dict[str, str],
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Batch upsert payment configs. Creates missing keys, updates existing ones."""
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
for key, value in req.items():
|
||||
if not key.startswith("payment_"):
|
||||
continue
|
||||
result = await db.execute(
|
||||
select(SystemConfig).where(SystemConfig.key == key).limit(1)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if config:
|
||||
config.value = value
|
||||
else:
|
||||
db.add(SystemConfig(
|
||||
id=generate_id(),
|
||||
key=key,
|
||||
value=value,
|
||||
))
|
||||
await db.flush()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/payment-stats")
|
||||
async def get_payment_stats(
|
||||
payment_method: str | None = Query(None),
|
||||
status: str | None = Query(None),
|
||||
start_date: str | None = Query(None),
|
||||
end_date: str | None = Query(None),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return payment statistics for admin dashboard with filters."""
|
||||
from sqlalchemy import func
|
||||
|
||||
# Ensure by_status has all expected statuses with defaults
|
||||
by_status = {
|
||||
"pending": {"count": 0, "amount": 0.0},
|
||||
"paid": {"count": 0, "amount": 0.0},
|
||||
"cancelled": {"count": 0, "amount": 0.0},
|
||||
"refunded": {"count": 0, "amount": 0.0},
|
||||
}
|
||||
|
||||
# Parse dates and build base query filters
|
||||
now_cst = datetime.now(CST)
|
||||
today_start = now_cst.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_end = today_start + timedelta(days=1)
|
||||
|
||||
# Default to today if no date range provided
|
||||
query_start = today_start
|
||||
query_end = today_end
|
||||
|
||||
if start_date:
|
||||
query_start = datetime.fromisoformat(start_date).replace(tzinfo=CST)
|
||||
if end_date:
|
||||
query_end = (datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST)
|
||||
|
||||
# Build filter list for status breakdown
|
||||
breakdown_filters = []
|
||||
if payment_method:
|
||||
breakdown_filters.append(PaymentOrder.payment_method == payment_method)
|
||||
if status:
|
||||
breakdown_filters.append(PaymentOrder.status == status)
|
||||
# Always apply date range to breakdown
|
||||
breakdown_filters.append(PaymentOrder.created_at >= query_start)
|
||||
breakdown_filters.append(PaymentOrder.created_at < query_end)
|
||||
|
||||
# Status breakdown
|
||||
status_result = await db.execute(
|
||||
select(
|
||||
PaymentOrder.status,
|
||||
func.count().label("count"),
|
||||
func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"),
|
||||
)
|
||||
.where(*breakdown_filters)
|
||||
.group_by(PaymentOrder.status)
|
||||
)
|
||||
for row in status_result.all():
|
||||
if row.status in by_status:
|
||||
by_status[row.status] = {
|
||||
"count": row.count,
|
||||
"amount": round(float(row.amount), 2)
|
||||
}
|
||||
else:
|
||||
# Map any unexpected status to cancelled
|
||||
by_status["cancelled"]["count"] += row.count
|
||||
by_status["cancelled"]["amount"] += round(float(row.amount), 2)
|
||||
|
||||
# Today's stats (CST time zone) - independent of filter
|
||||
today_result = await db.execute(
|
||||
select(
|
||||
func.count().label("paid_count"),
|
||||
func.coalesce(func.sum(PaymentOrder.amount), 0).label("paid_amount"),
|
||||
).where(
|
||||
PaymentOrder.status == "paid",
|
||||
PaymentOrder.paid_at >= today_start,
|
||||
PaymentOrder.paid_at < today_end,
|
||||
)
|
||||
)
|
||||
today_row = today_result.one()
|
||||
|
||||
# Monthly cumulative stats
|
||||
month_start = now_cst.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
month_end = (month_start + timedelta(days=32)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
month_result = await db.execute(
|
||||
select(
|
||||
func.count().label("paid_count"),
|
||||
func.coalesce(func.sum(PaymentOrder.amount), 0).label("paid_amount"),
|
||||
).where(
|
||||
PaymentOrder.status == "paid",
|
||||
PaymentOrder.paid_at >= month_start,
|
||||
PaymentOrder.paid_at < month_end,
|
||||
)
|
||||
)
|
||||
month_row = month_result.one()
|
||||
|
||||
# Recent orders with filters
|
||||
recent_filters = []
|
||||
if payment_method:
|
||||
recent_filters.append(PaymentOrder.payment_method == payment_method)
|
||||
if status:
|
||||
recent_filters.append(PaymentOrder.status == status)
|
||||
recent_filters.append(PaymentOrder.created_at >= query_start)
|
||||
recent_filters.append(PaymentOrder.created_at < query_end)
|
||||
|
||||
recent_result = await db.execute(
|
||||
select(PaymentOrder, User)
|
||||
.join(User, PaymentOrder.user_id == User.id)
|
||||
.where(*recent_filters)
|
||||
.order_by(PaymentOrder.created_at.desc())
|
||||
.limit(50)
|
||||
)
|
||||
recent_data = recent_result.all()
|
||||
|
||||
return {
|
||||
"by_status": by_status,
|
||||
"today": {
|
||||
"paid_count": today_row.paid_count,
|
||||
"paid_amount": round(float(today_row.paid_amount), 2),
|
||||
},
|
||||
"month": {
|
||||
"paid_count": month_row.paid_count,
|
||||
"paid_amount": round(float(month_row.paid_amount), 2),
|
||||
},
|
||||
"recent": [
|
||||
{
|
||||
"id": o.id,
|
||||
"order_no": o.order_no,
|
||||
"user_id": o.user_id,
|
||||
"username": u.username,
|
||||
"amount": round(o.amount, 2),
|
||||
"credits": round(o.credits, 2),
|
||||
"payment_method": o.payment_method,
|
||||
"status": o.status if o.status in ("pending", "paid", "cancelled", "refunded") else "cancelled",
|
||||
"trade_no": o.trade_no,
|
||||
"paid_at": _iso(o.paid_at),
|
||||
"created_at": _iso(o.created_at),
|
||||
}
|
||||
for o, u in recent_data
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/payment-orders")
|
||||
async def get_admin_payment_orders(
|
||||
method: str | None = None,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return paginated payment orders for admin."""
|
||||
query = select(PaymentOrder)
|
||||
if method:
|
||||
query = query.where(PaymentOrder.payment_method == method)
|
||||
if status:
|
||||
query = query.where(PaymentOrder.status == status)
|
||||
|
||||
# Count total
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(query.subquery())
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# Paginated results
|
||||
result = await db.execute(
|
||||
query.order_by(PaymentOrder.created_at.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
orders = result.scalars().all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"items": [
|
||||
{
|
||||
"id": o.id,
|
||||
"order_no": o.order_no,
|
||||
"user_id": o.user_id,
|
||||
"amount": round(o.amount, 2),
|
||||
"credits": round(o.credits, 2),
|
||||
"payment_method": o.payment_method,
|
||||
"status": o.status if o.status in ("pending", "paid", "cancelled", "refunded") else "cancelled",
|
||||
"trade_no": o.trade_no,
|
||||
"paid_at": _iso(o.paid_at),
|
||||
"created_at": _iso(o.created_at),
|
||||
}
|
||||
for o in orders
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.put("/payment-configs/{config_id}")
|
||||
async def update_payment_config(
|
||||
config_id: str,
|
||||
@@ -440,6 +662,19 @@ async def update_payment_config(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/payment-orders/{order_no}/refund")
|
||||
async def refund_payment_order(
|
||||
order_no: str,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Refund a paid payment order."""
|
||||
result = await process_refund(db, order_no)
|
||||
if not result.get("success"):
|
||||
raise HTTPException(status_code=400, detail=result.get("message", "退款失败"))
|
||||
return result
|
||||
|
||||
|
||||
# ── Industry Config ──────────────────────────────────────
|
||||
|
||||
def _serialize_industry(ind: IndustryConfig) -> dict:
|
||||
@@ -1222,3 +1457,8 @@ async def admin_generate_video(
|
||||
await db.flush()
|
||||
|
||||
return {"message": "ok", "record_id": record_id}
|
||||
|
||||
|
||||
# ── Payment Stats ────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.hot_opening_replicate import (
|
||||
HotOpeningActionOut,
|
||||
HotOpeningDeleteOut,
|
||||
HotOpeningGenerateImagePromptRequest,
|
||||
HotOpeningGenerateImageRequest,
|
||||
HotOpeningGenerateVideoPromptRequest,
|
||||
HotOpeningGenerateVideoRequest,
|
||||
HotOpeningImagePromptUpdateRequest,
|
||||
HotOpeningMaterialUpdateRequest,
|
||||
HotOpeningSpecOut,
|
||||
HotOpeningTaskCreate,
|
||||
HotOpeningTaskDetailOut,
|
||||
HotOpeningTaskListOut,
|
||||
HotOpeningVideoPromptSchemaUpdateRequest,
|
||||
)
|
||||
from app.services.hot_opening_replicate_service import (
|
||||
_get_project_for_user,
|
||||
create_hot_opening_project,
|
||||
delete_hot_opening_project,
|
||||
generate_image_from_prompt,
|
||||
generate_video_from_prompt,
|
||||
list_hot_opening_projects,
|
||||
mark_hot_opening_step_dispatch_failed,
|
||||
project_to_detail_out,
|
||||
submit_image_prompt_optimize,
|
||||
submit_video_prompt_optimize,
|
||||
update_hot_opening_image_prompt,
|
||||
update_hot_opening_material_input,
|
||||
update_hot_opening_video_prompt_schema,
|
||||
)
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/hot-opening-replications",
|
||||
tags=["hot-opening-replications"],
|
||||
)
|
||||
|
||||
|
||||
async def _reload_project_detail(
|
||||
db: AsyncSession,
|
||||
current_user: User,
|
||||
project_id: str,
|
||||
) -> HotOpeningTaskDetailOut:
|
||||
"""提交事务后统一重新查询详情,避免继续访问 commit 前 ORM 对象。"""
|
||||
project = await _get_project_for_user(
|
||||
db,
|
||||
project_id=project_id,
|
||||
user=current_user,
|
||||
for_update=False,
|
||||
populate_existing=True,
|
||||
)
|
||||
return await project_to_detail_out(db, project)
|
||||
|
||||
|
||||
async def _mark_dispatch_failed_and_raise(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
project_id: str,
|
||||
step_id: str | None,
|
||||
message: str,
|
||||
) -> None:
|
||||
"""Celery 投递失败后,数据库事务已提交,单独标记步骤失败,避免一直 processing。"""
|
||||
if step_id:
|
||||
try:
|
||||
await mark_hot_opening_step_dispatch_failed(
|
||||
db,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
error_message=message,
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=503, detail=message)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/spec",
|
||||
response_model=HotOpeningSpecOut,
|
||||
summary="查询爆款开头复刻模块状态枚举和步骤 JSON 结构说明",
|
||||
description="返回总任务状态、子任务状态、5个固定步骤编码以及每个步骤 input_json/output_json 的统一结构示例,方便前端和排查人员对照。",
|
||||
)
|
||||
async def get_spec():
|
||||
return HotOpeningSpecOut()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tasks",
|
||||
response_model=HotOpeningTaskDetailOut,
|
||||
summary="创建爆款开头复刻总任务项目",
|
||||
description=(
|
||||
"创建爆款开头复刻总任务项目。总任务表 id 就是项目ID,不再传 project_id。"
|
||||
"接口只同步创建第1个素材输入子任务,保存素材视频链接、素材图片链接、视频素材内容项目名称、生成项目名称和50字核心内容点。"
|
||||
"后端不开发上传接口,也不校验素材文件时长、大小、格式,直接使用前端已有上传接口返回的链接。"
|
||||
"创建后不会自动生成第2步图片 AI 提词,需要前端手动调用 generate-image-prompt。"
|
||||
),
|
||||
)
|
||||
async def create_task(
|
||||
req: HotOpeningTaskCreate = Body(..., description="爆款开头复刻创建参数,只包含素材链接和项目描述,不包含图片/视频引擎参数"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
project = await create_hot_opening_project(db, current_user, req)
|
||||
project_id_value = str(project.id)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"创建爆款开头复刻项目失败: {exc}")
|
||||
|
||||
return await _reload_project_detail(db, current_user, project_id_value)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tasks",
|
||||
response_model=HotOpeningTaskListOut,
|
||||
summary="查询爆款开头复刻总任务项目列表",
|
||||
description="分页查询爆款开头复刻总任务项目列表。普通用户只能查看自己的项目,管理员可查看全部。",
|
||||
)
|
||||
async def list_tasks(
|
||||
status: str | None = Query(None, description="总任务状态筛选,例如 waiting_user、processing、completed、failed;为空不过滤"),
|
||||
page: int = Query(1, ge=1, description="分页页码,从1开始"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量,范围1-100"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await list_hot_opening_projects(db, current_user=current_user, status=status, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tasks/{project_id}",
|
||||
response_model=HotOpeningTaskDetailOut,
|
||||
summary="获取爆款开头复刻总任务项目详情",
|
||||
description=(
|
||||
"获取爆款开头复刻总任务详情。详情会聚合返回第1步素材信息、第2步图片提词、第3步图片引擎和参数、"
|
||||
"第4步视频提词 JSON schema、第5步视频引擎和参数、最终图片、最终视频和完整子任务列表。"
|
||||
),
|
||||
)
|
||||
async def get_task(
|
||||
project_id: str = Path(..., description="总任务项目ID,即 module_generation_projects.id"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await _reload_project_detail(db, current_user, project_id)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/tasks/{project_id}/material",
|
||||
response_model=HotOpeningActionOut,
|
||||
summary="修改第1步素材输入并重建第1步新版本",
|
||||
description=(
|
||||
"反复修改爆款开头复刻第1步素材输入。"
|
||||
"接口会软删除旧第1步以及第2、3、4、5步当前有效子任务,联动软删除关联 ChatGenerationTask,"
|
||||
"然后新建第1步 material_input 的 version+1,项目回到 waiting_user 状态。"
|
||||
),
|
||||
)
|
||||
async def update_material(
|
||||
project_id: str = Path(..., description="总任务项目ID"),
|
||||
req: HotOpeningMaterialUpdateRequest = Body(..., description="第1步素材输入修改参数,至少传一个字段"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
project_id_value, step_id_value = await update_hot_opening_material_input(
|
||||
db,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
req=req,
|
||||
)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"修改素材输入失败: {exc}")
|
||||
|
||||
return HotOpeningActionOut(
|
||||
message="素材输入已修改,旧步骤已软删除,请重新生成图片 AI 提词",
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
detail=await _reload_project_detail(db, current_user, project_id_value),
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/tasks/{project_id}/steps/{step_id}/image-prompt",
|
||||
response_model=HotOpeningActionOut,
|
||||
summary="直接修改第2步图片 AI 优化提词",
|
||||
description=(
|
||||
"直接修改第2步图片 AI 优化提词,不调用 AI、不扣积分。"
|
||||
"保存后会软删除第3、4、5步当前有效任务和关联 ChatGenerationTask,"
|
||||
"清空旧图片/视频结果,让用户从图片生成开始重新执行。"
|
||||
),
|
||||
)
|
||||
async def update_image_prompt(
|
||||
project_id: str = Path(..., description="总任务项目ID"),
|
||||
step_id: str = Path(..., description="第2步图片 AI 提词子任务ID"),
|
||||
req: HotOpeningImagePromptUpdateRequest = Body(..., description="图片 AI 优化提词修改参数"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
project, step = await update_hot_opening_image_prompt(
|
||||
db,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
req=req,
|
||||
)
|
||||
project_id_value = str(project.id)
|
||||
step_id_value = str(step.id)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"修改图片 AI 提词失败: {exc}")
|
||||
|
||||
return HotOpeningActionOut(
|
||||
message="图片 AI 提词已修改,后续步骤已软删除,请重新生成图片",
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
detail=await _reload_project_detail(db, current_user, project_id_value),
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/tasks/{project_id}/steps/{step_id}/video-prompt-schema",
|
||||
response_model=HotOpeningActionOut,
|
||||
summary="修改第4步视频 AI 提词 JSON schema",
|
||||
description=(
|
||||
"修改第4步视频 AI 提词 JSON schema,不调用 AI、不扣积分。"
|
||||
"前端提交的 schema 只作为 patch,服务端会锁定视频时长、比例、清晰度、帧率、推荐分辨率、"
|
||||
"动作/镜头/动态时间规划数组长度和时间段、输出规格、质量控制、合规控制、schema_version、schema_usage。"
|
||||
"最终提示词允许修改,但会清洗秒数、比例、分辨率、帧率等视频参数。保存后软删除第5步视频生成任务。"
|
||||
),
|
||||
)
|
||||
async def update_video_prompt_schema(
|
||||
project_id: str = Path(..., description="总任务项目ID"),
|
||||
step_id: str = Path(..., description="第4步视频 AI 提词子任务ID"),
|
||||
req: HotOpeningVideoPromptSchemaUpdateRequest = Body(..., description="视频 AI 提词 schema 修改参数"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
project, step = await update_hot_opening_video_prompt_schema(
|
||||
db,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
req=req,
|
||||
)
|
||||
project_id_value = str(project.id)
|
||||
step_id_value = str(step.id)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"修改视频 AI 提词 schema 失败: {exc}")
|
||||
|
||||
return HotOpeningActionOut(
|
||||
message="视频 AI 提词 schema 已修改,第5步视频生成任务已软删除,请重新生成视频",
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
detail=await _reload_project_detail(db, current_user, project_id_value),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tasks/{project_id}/steps/{step_id}/generate-image-prompt",
|
||||
response_model=HotOpeningActionOut,
|
||||
summary="基于素材输入手动生成图片 AI 提词",
|
||||
description=(
|
||||
"基于第1步素材输入子任务手动生成第2步图片 AI 提词。"
|
||||
"如果已存在旧的第2、3、4、5步,会先软删除旧步骤,再创建新的第2步。"
|
||||
),
|
||||
)
|
||||
async def generate_image_prompt(
|
||||
project_id: str = Path(..., description="总任务项目ID"),
|
||||
step_id: str = Path(..., description="第1步素材输入子任务ID"),
|
||||
req: HotOpeningGenerateImagePromptRequest = Body(default_factory=HotOpeningGenerateImagePromptRequest, description="图片提词生成参数,当前无需传参,额外字段会忽略"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = req
|
||||
if celery_app is None:
|
||||
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
|
||||
|
||||
try:
|
||||
project, step = await submit_image_prompt_optimize(db, current_user=current_user, project_id=project_id, material_step_id=step_id)
|
||||
project_id_value = str(project.id)
|
||||
step_id_value = str(step.id)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"图片提词任务创建失败: {exc}")
|
||||
|
||||
from app.tasks.hot_opening_replicate_tasks import start_image_prompt_optimize
|
||||
|
||||
try:
|
||||
start_image_prompt_optimize.delay(project_id_value, step_id_value)
|
||||
except Exception as exc:
|
||||
await _mark_dispatch_failed_and_raise(
|
||||
db,
|
||||
current_user=current_user,
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
message=f"图片提词任务投递失败: {exc}",
|
||||
)
|
||||
|
||||
return HotOpeningActionOut(
|
||||
message="图片 AI 提词任务已提交",
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
detail=await _reload_project_detail(db, current_user, project_id_value),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tasks/{project_id}/steps/{step_id}/generate-image",
|
||||
response_model=HotOpeningActionOut,
|
||||
summary="基于图片 AI 提词生成新项目图片",
|
||||
description=(
|
||||
"基于第2步图片 AI 提词生成新项目图片。调用时传入图片生成引擎和图片生成参数。"
|
||||
"后端会创建第3步图片生成子任务,ChatGenerationTask 幂等键由后端按任务ID自动生成,不再使用前端幂等键。"
|
||||
"图片生成媒体积分在创建 ChatGenerationTask 时扣除,生成失败走媒体积分退款。"
|
||||
"如果已存在旧的第3、4、5步,会先软删除旧步骤,再创建新的第3步。"
|
||||
),
|
||||
)
|
||||
async def generate_image(
|
||||
project_id: str = Path(..., description="总任务项目ID"),
|
||||
step_id: str = Path(..., description="第2步图片 AI 提词子任务ID"),
|
||||
req: HotOpeningGenerateImageRequest = Body(..., description="图片生成引擎和参数"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if celery_app is None:
|
||||
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
|
||||
|
||||
try:
|
||||
project, step = await generate_image_from_prompt(db, current_user=current_user, project_id=project_id, prompt_step_id=step_id, req=req)
|
||||
project_id_value = str(project.id)
|
||||
step_id_value = str(step.id)
|
||||
chat_task_id_value = step.chat_task_id
|
||||
if not chat_task_id_value:
|
||||
raise HTTPException(status_code=500, detail="图片生成任务创建失败:chat_task_id为空")
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"图片生成任务创建失败: {exc}")
|
||||
|
||||
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
||||
|
||||
try:
|
||||
chatapi_create_generation_task.delay(chat_task_id_value)
|
||||
except Exception as exc:
|
||||
await _mark_dispatch_failed_and_raise(
|
||||
db,
|
||||
current_user=current_user,
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
message=f"图片生成任务投递失败: {exc}",
|
||||
)
|
||||
|
||||
return HotOpeningActionOut(
|
||||
message="图片生成任务已提交",
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
detail=await _reload_project_detail(db, current_user, project_id_value),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tasks/{project_id}/steps/{step_id}/generate-video-prompt",
|
||||
response_model=HotOpeningActionOut,
|
||||
summary="基于图片结果手动生成视频 AI 提词",
|
||||
description=(
|
||||
"基于第3步图片生成子任务手动生成第4步视频 AI 提词 JSON schema。"
|
||||
"视频时长、比例、分辨率集中在本步骤确定并写入 step.output_json.payload.params_used_for_prompt。"
|
||||
"视频 AI 提词成功后按文本 token 扣积分;该文本积分不参与后续视频生成失败退款。"
|
||||
"如果已存在旧的第4、5步,会先软删除旧步骤,再创建新的第4步。"
|
||||
),
|
||||
)
|
||||
async def generate_video_prompt(
|
||||
project_id: str = Path(..., description="总任务项目ID"),
|
||||
step_id: str = Path(..., description="第3步图片生成子任务ID"),
|
||||
req: HotOpeningGenerateVideoPromptRequest = Body(..., description="视频提词生成参数,用于读取接口配置并规划视频时长、比例、分辨率"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if celery_app is None:
|
||||
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
|
||||
|
||||
try:
|
||||
project, step = await submit_video_prompt_optimize(db, current_user=current_user, project_id=project_id, image_step_id=step_id, req=req)
|
||||
project_id_value = str(project.id)
|
||||
step_id_value = str(step.id)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"视频提词任务创建失败: {exc}")
|
||||
|
||||
from app.tasks.hot_opening_replicate_tasks import start_video_prompt_optimize
|
||||
|
||||
try:
|
||||
start_video_prompt_optimize.delay(project_id_value, step_id_value)
|
||||
except Exception as exc:
|
||||
await _mark_dispatch_failed_and_raise(
|
||||
db,
|
||||
current_user=current_user,
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
message=f"视频提词任务投递失败: {exc}",
|
||||
)
|
||||
|
||||
return HotOpeningActionOut(
|
||||
message="视频 AI 提词任务已提交",
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
detail=await _reload_project_detail(db, current_user, project_id_value),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tasks/{project_id}/steps/{step_id}/generate-video",
|
||||
response_model=HotOpeningActionOut,
|
||||
summary="基于视频 AI 提词生成最终视频",
|
||||
description=(
|
||||
"基于第4步视频 AI 提词生成最终视频。请求体只需要选择视频生成引擎 engine_id。"
|
||||
"视频时长、比例、分辨率从第4步视频提词优化结果读取,不再由本接口动态传入。"
|
||||
"关联 ChatGenerationTask 的 original_prompt 和 optimized_prompt 都使用第4步生成的 prompt_schema JSON 字符串。"
|
||||
"ChatGenerationTask 幂等键由后端按任务ID自动生成;视频生成媒体积分失败时走退款。"
|
||||
"如果已存在旧的第5步,会先软删除旧步骤,再创建新的第5步。"
|
||||
),
|
||||
)
|
||||
async def generate_video(
|
||||
project_id: str = Path(..., description="总任务项目ID"),
|
||||
step_id: str = Path(..., description="第4步视频 AI 提词子任务ID"),
|
||||
req: HotOpeningGenerateVideoRequest = Body(..., description="视频生成参数:只传 engine_id,其它视频参数继承第4步视频提词结果"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if celery_app is None:
|
||||
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
|
||||
|
||||
try:
|
||||
project, step = await generate_video_from_prompt(db, current_user=current_user, project_id=project_id, prompt_step_id=step_id, req=req)
|
||||
project_id_value = str(project.id)
|
||||
step_id_value = str(step.id)
|
||||
chat_task_id_value = step.chat_task_id
|
||||
if not chat_task_id_value:
|
||||
raise HTTPException(status_code=500, detail="视频生成任务创建失败:chat_task_id为空")
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"视频生成任务创建失败: {exc}")
|
||||
|
||||
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
||||
|
||||
try:
|
||||
chatapi_create_generation_task.delay(chat_task_id_value)
|
||||
except Exception as exc:
|
||||
await _mark_dispatch_failed_and_raise(
|
||||
db,
|
||||
current_user=current_user,
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
message=f"视频生成任务投递失败: {exc}",
|
||||
)
|
||||
|
||||
return HotOpeningActionOut(
|
||||
message="视频生成任务已提交",
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
detail=await _reload_project_detail(db, current_user, project_id_value),
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/tasks/{project_id}",
|
||||
response_model=HotOpeningDeleteOut,
|
||||
summary="删除爆款开头复刻总任务项目",
|
||||
description="软删除爆款开头复刻总任务项目,并联动软删除当前有效子任务和关联的 ChatGenerationTask。",
|
||||
)
|
||||
async def delete_task(
|
||||
project_id: str = Path(..., description="总任务项目ID"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
result = await delete_hot_opening_project(db, current_user=current_user, project_id=project_id)
|
||||
await db.commit()
|
||||
return result
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"删除爆款开头复刻项目失败: {exc}")
|
||||
@@ -1,23 +1,61 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger("payment")
|
||||
|
||||
from app.dependencies import get_db, get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.payment_order import PaymentOrder
|
||||
from app.models.recharge_package import RechargePackage
|
||||
from app.schemas.payment import RechargeRequest, PaymentOrderOut
|
||||
from app.services.payment import create_recharge_order, verify_wechat_callback, verify_alipay_callback, process_payment_success
|
||||
from app.services.payment import (
|
||||
create_recharge_order,
|
||||
verify_wechat_callback,
|
||||
verify_alipay_callback,
|
||||
process_payment_success_by_order_no,
|
||||
process_refund,
|
||||
_get_payment_configs,
|
||||
_close_alipay_order,
|
||||
_get_order_expire_seconds,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/payments", tags=["payments"])
|
||||
|
||||
|
||||
@router.get("/methods")
|
||||
async def get_payment_methods(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Return which payment methods are enabled (from admin config)."""
|
||||
from app.services.payment import _get_payment_configs
|
||||
configs = await _get_payment_configs(db)
|
||||
return {
|
||||
"alipay": configs.get("payment_alipay_enabled", "").lower() == "true",
|
||||
"wechat": configs.get("payment_wechat_enabled", "").lower() == "true",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/recharge", response_model=PaymentOrderOut)
|
||||
async def recharge(
|
||||
req: RechargeRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if req.method not in ("wechat", "alipay"):
|
||||
raise HTTPException(status_code=400, detail="不支持的支付方式")
|
||||
|
||||
# Check if the selected payment method is enabled in admin config
|
||||
from app.services.payment import _get_payment_configs, _is_mock_mode
|
||||
configs = await _get_payment_configs(db)
|
||||
if not _is_mock_mode(configs):
|
||||
enabled_key = f"payment_{req.method}_enabled"
|
||||
if configs.get(enabled_key, "").lower() != "true":
|
||||
raise HTTPException(status_code=400, detail="该支付方式未启用")
|
||||
|
||||
result = await db.execute(
|
||||
select(RechargePackage).where(
|
||||
RechargePackage.id == req.plan,
|
||||
@@ -28,44 +66,60 @@ async def recharge(
|
||||
pkg = result.scalar_one_or_none()
|
||||
if not pkg:
|
||||
raise HTTPException(status_code=400, detail="无效的套餐")
|
||||
order = await create_recharge_order(
|
||||
db,
|
||||
current_user.id,
|
||||
credits=pkg.credits,
|
||||
price=pkg.price,
|
||||
label=pkg.name,
|
||||
bonus_credits=pkg.bonus_credits,
|
||||
)
|
||||
try:
|
||||
order = await create_recharge_order(
|
||||
db,
|
||||
current_user.id,
|
||||
credits=pkg.credits,
|
||||
price=pkg.price,
|
||||
label=pkg.name,
|
||||
bonus_credits=pkg.bonus_credits,
|
||||
method=req.method,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return order
|
||||
|
||||
|
||||
@router.post("/wechat/callback")
|
||||
async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
data = await request.json()
|
||||
if not await verify_wechat_callback(data):
|
||||
if not await verify_wechat_callback(data, db):
|
||||
raise HTTPException(status_code=400, detail="签名验证失败")
|
||||
order_no = data.get("out_trade_no")
|
||||
result = await db.execute(
|
||||
select(PaymentOrder).where(PaymentOrder.order_no == order_no).limit(1)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if order:
|
||||
await process_payment_success(db, order.id)
|
||||
if order_no:
|
||||
await process_payment_success_by_order_no(db, order_no)
|
||||
return {"code": "SUCCESS", "message": "OK"}
|
||||
|
||||
|
||||
@router.post("/alipay/callback")
|
||||
async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
data = await request.form()
|
||||
if not await verify_alipay_callback(dict(data)):
|
||||
raise HTTPException(status_code=400, detail="签名验证失败")
|
||||
order_no = data.get("out_trade_no")
|
||||
result = await db.execute(
|
||||
select(PaymentOrder).where(PaymentOrder.order_no == order_no).limit(1)
|
||||
form_data = await request.form()
|
||||
data = dict(form_data)
|
||||
|
||||
logger.info(
|
||||
f"ALIPAY_CALLBACK order_no={data.get('out_trade_no')} "
|
||||
f"data={data}"
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if order:
|
||||
await process_payment_success(db, order.id)
|
||||
|
||||
# Verify signature first
|
||||
if not await verify_alipay_callback(data, db):
|
||||
raise HTTPException(status_code=400, detail="签名验证失败")
|
||||
|
||||
# Check trade_status – only "TRADE_SUCCESS" and "TRADE_FINISHED" mean paid
|
||||
trade_status = data.get("trade_status", "")
|
||||
if trade_status not in ("TRADE_SUCCESS", "TRADE_FINISHED"):
|
||||
logger.info(f"Alipay callback trade_status={trade_status}, ignoring")
|
||||
return "success"
|
||||
|
||||
order_no = data.get("out_trade_no")
|
||||
trade_no = data.get("trade_no", "")
|
||||
total_amount_str = data.get("total_amount", "")
|
||||
total_amount = float(total_amount_str) if total_amount_str else None
|
||||
|
||||
if order_no:
|
||||
await process_payment_success_by_order_no(db, order_no, trade_no, total_amount)
|
||||
|
||||
return "success"
|
||||
|
||||
|
||||
@@ -74,9 +128,72 @@ async def list_orders(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# Auto-expire stale pending orders before returning
|
||||
from app.services.payment import _check_and_expire_order
|
||||
result = await db.execute(
|
||||
select(PaymentOrder)
|
||||
.where(PaymentOrder.user_id == current_user.id)
|
||||
.order_by(PaymentOrder.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
orders = result.scalars().all()
|
||||
for o in orders:
|
||||
await _check_and_expire_order(db, o)
|
||||
return orders
|
||||
|
||||
|
||||
@router.get("/orders/{order_no}", response_model=PaymentOrderOut)
|
||||
async def get_order(
|
||||
order_no: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from app.services.payment import _check_and_expire_order
|
||||
result = await db.execute(
|
||||
select(PaymentOrder)
|
||||
.where(
|
||||
PaymentOrder.order_no == order_no,
|
||||
PaymentOrder.user_id == current_user.id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="订单不存在")
|
||||
# Auto-expire if needed
|
||||
await _check_and_expire_order(db, order)
|
||||
return order
|
||||
|
||||
|
||||
@router.post("/orders/{order_no}/cancel")
|
||||
async def cancel_order(
|
||||
order_no: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Cancel a pending order. Only the order owner can cancel, only if still pending."""
|
||||
result = await db.execute(
|
||||
select(PaymentOrder).where(
|
||||
PaymentOrder.order_no == order_no,
|
||||
PaymentOrder.user_id == current_user.id,
|
||||
).limit(1)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="订单不存在")
|
||||
if order.status != "pending":
|
||||
raise HTTPException(status_code=400, detail=f"订单状态为{order.status},无法取消")
|
||||
|
||||
# If it's an Alipay order, call close API first
|
||||
if order.payment_method == "alipay":
|
||||
db_configs = await _get_payment_configs(db)
|
||||
try:
|
||||
await _close_alipay_order(db, order, db_configs)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to close Alipay order {order_no}: {e}")
|
||||
|
||||
order.status = "cancelled"
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"ORDER_CANCELLED order_no={order_no} user={current_user.id} amount={order.amount}"
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
@@ -5,6 +5,6 @@ router = APIRouter(prefix="/test", tags=["test"])
|
||||
|
||||
|
||||
@router.get("/index")
|
||||
async def test(req: Request):
|
||||
async def test():
|
||||
return {"message": "test","code":200}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.user_oauth import RequestOAuthRequest, RequestOAuthResponse, UserOAuthOut
|
||||
from app.services.user_oauth_service import (
|
||||
build_oauth_url,
|
||||
get_account_info_by_type,
|
||||
get_token_by_type,
|
||||
save_oauth_token,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/user-oauth", tags=["user-oauth"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/request_oauth",
|
||||
summary="获取授权链接",
|
||||
description="用户提交oauth_type,返回对应的第三方授权链接",
|
||||
response_model=RequestOAuthResponse,
|
||||
)
|
||||
async def request_oauth(
|
||||
req: RequestOAuthRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
try:
|
||||
auth_url = await build_oauth_url(req.oauth_type, current_user.id)
|
||||
return {"auth_url": auth_url}
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/juliang_callback",
|
||||
summary="巨量授权回调",
|
||||
description="巨量引擎授权回调地址,接收code和state参数,获取token并保存",
|
||||
)
|
||||
async def juliang_callback(
|
||||
auth_code: str = Query(..., description="第三方返回的授权码"),
|
||||
state: str = Query(..., description="请求时传递的自定义参数"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
parts = state.split(":")
|
||||
if len(parts) != 4:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无效的state参数",
|
||||
)
|
||||
|
||||
oauth_type = int(parts[0])
|
||||
user_id = parts[1]
|
||||
app_id = parts[2]
|
||||
app_type = parts[3]
|
||||
|
||||
token = await get_token_by_type(auth_code, oauth_type, app_id, app_type)
|
||||
account_info = await get_account_info_by_type(token, oauth_type, app_type)
|
||||
user_oauth = await save_oauth_token(db, user_id, oauth_type, token, account_info, app_id)
|
||||
|
||||
return {
|
||||
"message": "授权成功",
|
||||
"code": 0,
|
||||
"data": UserOAuthOut.model_validate(user_oauth),
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"授权失败: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/callback",
|
||||
summary="通用授权回调",
|
||||
description="其他平台授权回调地址,接收code和state参数",
|
||||
)
|
||||
async def oauth_callback(
|
||||
code: str = Query(..., description="第三方返回的授权码"),
|
||||
state: str = Query(..., description="请求时传递的自定义参数"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
parts = state.split(":")
|
||||
if len(parts) != 4:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无效的state参数",
|
||||
)
|
||||
|
||||
oauth_type = int(parts[0])
|
||||
user_id = parts[1]
|
||||
app_id = parts[2]
|
||||
app_type = parts[3]
|
||||
|
||||
token = await get_token_by_type(code, oauth_type, app_id, app_type)
|
||||
account_info = await get_account_info_by_type(token, oauth_type, app_type)
|
||||
user_oauth = await save_oauth_token(db, user_id, oauth_type, token, account_info, app_id)
|
||||
|
||||
return {
|
||||
"message": "授权成功",
|
||||
"code": 0,
|
||||
"data": UserOAuthOut.model_validate(user_oauth),
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"授权失败: {str(e)}",
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_admin_user, get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.user_oauth_app import UserOAuthAppCreate, UserOAuthAppOut, UserOAuthAppUpdate
|
||||
from app.services.user_oauth_app_service import (
|
||||
create_user_oauth_app,
|
||||
delete_user_oauth_app,
|
||||
get_user_oauth_app_by_id,
|
||||
list_user_oauth_apps,
|
||||
update_user_oauth_app,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/admin/user-oauth-apps", tags=["oauth"])
|
||||
|
||||
|
||||
@router.get("/list", summary="获取用户授权应用列表")
|
||||
async def list_apps(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
open_type: int | None = Query(None, ge=1, le=10, description="开户方式"),
|
||||
status: int | None = Query(None, ge=1, le=2, description="应用状态,1=正常,2=禁用"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
app_id: str | None = Query(None, max_length=255, description="应用id"),
|
||||
):
|
||||
result = await list_user_oauth_apps(db, page, page_size, open_type, status, admin.id, app_id)
|
||||
return {
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"page_size": result["page_size"],
|
||||
"items": [UserOAuthAppOut.model_validate(item) for item in result["items"]],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/create", summary="创建用户授权应用")
|
||||
async def create_app(
|
||||
req: UserOAuthAppCreate,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
app = await create_user_oauth_app(db, req.app_id, req.secret, req.open_type, admin.id, req.count, req.auth_url, req.company)
|
||||
return UserOAuthAppOut.model_validate(app)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/read/{id}", summary="获取用户授权应用详情")
|
||||
async def get_app(
|
||||
id: str,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
app = await get_user_oauth_app_by_id(db, id)
|
||||
if not app:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="应用不存在",
|
||||
)
|
||||
return UserOAuthAppOut.model_validate(app)
|
||||
|
||||
|
||||
@router.post("/update/{id}", summary="更新用户授权应用")
|
||||
async def update_app(
|
||||
id: str,
|
||||
req: UserOAuthAppUpdate,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
app = await update_user_oauth_app(db, id, req.secret, req.open_type, req.status, req.count, req.auth_url, req.company, admin.id)
|
||||
if not app:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="应用不存在",
|
||||
)
|
||||
return UserOAuthAppOut.model_validate(app)
|
||||
|
||||
|
||||
@router.get("/delete/{id}", summary="删除用户授权应用")
|
||||
async def delete_app(
|
||||
id: str,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
success = await delete_user_oauth_app(db, id, admin.id)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="应用不存在",
|
||||
)
|
||||
return {"message": "删除成功"}
|
||||
@@ -56,7 +56,8 @@ class Settings(BaseSettings):
|
||||
ALIPAY_APP_ID: str = ""
|
||||
ALIPAY_PRIVATE_KEY: str = ""
|
||||
ALIPAY_PUBLIC_KEY: str = ""
|
||||
PAYMENT_MOCK: bool = True
|
||||
ALIPAY_NOTIFY_URL: str = ""
|
||||
PAYMENT_MOCK: bool = False # Default off; use admin panel to enable for testing
|
||||
|
||||
STORAGE_TYPE: str = "local"
|
||||
STORAGE_LOCAL_PATH: str = "./storage/generate/videos"
|
||||
@@ -86,7 +87,7 @@ class Settings(BaseSettings):
|
||||
# ChatAPI async generation pipeline settings
|
||||
CELERY_BROKER_URL: str = ""
|
||||
CELERY_RESULT_BACKEND: str = ""
|
||||
CHATAPI_REQUEST_TIMEOUT_SECONDS: int = 120
|
||||
CHATAPI_REQUEST_TIMEOUT_SECONDS: int = 180
|
||||
CHATAPI_VIDEO_FPS: float = 0.5
|
||||
CHATAPI_ASYNC_MAX_RETRIES: int = 3
|
||||
CHATAPI_ASYNC_RETRY_BACKOFF_SECONDS: int = 30
|
||||
@@ -127,5 +128,11 @@ class Settings(BaseSettings):
|
||||
RESOURCE_SIGN_ARG_EXPIRE: str = "exp"
|
||||
RESOURCE_SIGN_ARG_SIGNATURE: str = "sign"
|
||||
|
||||
# 爆款开头复刻默认配置。素材校验由前端完成,后端只接收已有上传接口返回的链接。
|
||||
HOT_OPENING_DEFAULT_VIDEO_DURATION: int = 4
|
||||
HOT_OPENING_DEFAULT_VIDEO_RATIO: str = "9:16"
|
||||
HOT_OPENING_DEFAULT_VIDEO_RESOLUTION: str = "480p"
|
||||
HOT_OPENING_DEFAULT_TARGET_PLATFORM: str = "抖音"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.enums.common import *
|
||||
from app.enums.hot_opening_replicate import *
|
||||
from app.enums.video_prompt_schema import *
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class ModuleProjectStatusEnum(StrEnum):
|
||||
"""通用模块项目状态。"""
|
||||
|
||||
PENDING = "pending"
|
||||
WAITING_USER = "waiting_user"
|
||||
PROCESSING = "processing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class ModuleStepStatusEnum(StrEnum):
|
||||
"""通用模块子任务状态。"""
|
||||
|
||||
PENDING = "pending"
|
||||
WAITING_USER = "waiting_user"
|
||||
PROCESSING = "processing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class ModuleEventTypeEnum(StrEnum):
|
||||
"""通用模块事件类型。"""
|
||||
|
||||
PROJECT_CREATED = "PROJECT_CREATED"
|
||||
PROJECT_DELETED = "PROJECT_DELETED"
|
||||
STEP_CREATED = "STEP_CREATED"
|
||||
STEP_UPDATED = "STEP_UPDATED"
|
||||
SOFT_DELETE_STEPS = "SOFT_DELETE_STEPS"
|
||||
IMAGE_PROMPT_SUBMITTED = "IMAGE_PROMPT_SUBMITTED"
|
||||
IMAGE_PROMPT_SUCCESS = "IMAGE_PROMPT_SUCCESS"
|
||||
IMAGE_PROMPT_FAILED = "IMAGE_PROMPT_FAILED"
|
||||
IMAGE_GENERATE_SUBMITTED = "IMAGE_GENERATE_SUBMITTED"
|
||||
IMAGE_GENERATE_SUCCESS = "IMAGE_GENERATE_SUCCESS"
|
||||
VIDEO_PROMPT_SUBMITTED = "VIDEO_PROMPT_SUBMITTED"
|
||||
VIDEO_PROMPT_SUCCESS = "VIDEO_PROMPT_SUCCESS"
|
||||
VIDEO_PROMPT_FAILED = "VIDEO_PROMPT_FAILED"
|
||||
VIDEO_GENERATE_SUBMITTED = "VIDEO_GENERATE_SUBMITTED"
|
||||
VIDEO_GENERATE_SUCCESS = "VIDEO_GENERATE_SUCCESS"
|
||||
CHAT_TASK_FAILED = "CHAT_TASK_FAILED"
|
||||
CHAT_TASK_CANCELLED = "CHAT_TASK_CANCELLED"
|
||||
MEDIA_REFUND = "MEDIA_REFUND"
|
||||
PROMPT_BILLING_SUCCESS = "PROMPT_BILLING_SUCCESS"
|
||||
PROMPT_BILLING_FAILED = "PROMPT_BILLING_FAILED"
|
||||
|
||||
|
||||
class ModulePromptTypeEnum(StrEnum):
|
||||
"""通用模块提词结果类型。"""
|
||||
|
||||
IMAGE_PROMPT = "image_prompt"
|
||||
VIDEO_PROMPT = "video_prompt"
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class ModuleCodeEnum(StrEnum):
|
||||
"""可复用模块编码。"""
|
||||
|
||||
HOT_OPENING_REPLICATE = "hot_opening_replicate"
|
||||
|
||||
|
||||
class HotOpeningStepCodeEnum(StrEnum):
|
||||
"""爆款开头复刻子任务步骤编码。"""
|
||||
|
||||
MATERIAL_INPUT = "material_input"
|
||||
IMAGE_PROMPT_OPTIMIZE = "image_prompt_optimize"
|
||||
IMAGE_GENERATE = "image_generate"
|
||||
VIDEO_PROMPT_OPTIMIZE = "video_prompt_optimize"
|
||||
VIDEO_GENERATE = "video_generate"
|
||||
|
||||
|
||||
class HotOpeningGenerationModeEnum(StrEnum):
|
||||
"""复用 ChatGenerationTask 时使用的 generation_mode。"""
|
||||
|
||||
HOT_OPENING_REPLICATE = "hot_opening_replicate"
|
||||
|
||||
|
||||
|
||||
class HotOpeningStepIOSchemaVersionEnum(StrEnum):
|
||||
"""爆款开头复刻子任务 input_json/output_json 结构版本。"""
|
||||
|
||||
V1 = "hot_opening_step_io_v1"
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class PromptSchemaVersionEnum(StrEnum):
|
||||
"""视频提词 schema 版本。"""
|
||||
|
||||
CLIENT_V1 = "video_prompt_schema_client_v1"
|
||||
|
||||
|
||||
class VideoPromptSchemaUsageEnum(StrEnum):
|
||||
"""视频提词 schema 用途。"""
|
||||
|
||||
CLIENT_DISPLAY = "客户端可展示的AI视频生成提词结构"
|
||||
@@ -35,13 +35,53 @@ async def lifespan(app: FastAPI):
|
||||
from app.services.video_queue import task_queue
|
||||
await task_queue.recover()
|
||||
queue_task = asyncio.create_task(task_queue.run())
|
||||
|
||||
# Background task: auto-expire pending payment orders and sync status
|
||||
async def _order_expiry_loop():
|
||||
from app.services.payment import expire_all_pending_orders, sync_pending_orders
|
||||
from logging import getLogger
|
||||
bg_logger = getLogger("payment")
|
||||
while True:
|
||||
try:
|
||||
async with async_session() as db:
|
||||
# 同步待支付订单状态(检查支付宝实际支付状态
|
||||
sync_count = await sync_pending_orders(db)
|
||||
if sync_count > 0:
|
||||
bg_logger.info(f"Synced {sync_count} pending payment order(s)")
|
||||
|
||||
# 自动过期订单
|
||||
n = await expire_all_pending_orders(db)
|
||||
if n > 0:
|
||||
bg_logger.info(f"Auto-expired {n} pending payment order(s)")
|
||||
except Exception as e:
|
||||
bg_logger.error(f"Order expiry loop error: {e}")
|
||||
await asyncio.sleep(60) # check every minute
|
||||
|
||||
expiry_task = asyncio.create_task(_order_expiry_loop())
|
||||
|
||||
# 启动时立即同步一次未支付订单
|
||||
asyncio.create_task(asyncio.sleep(5)) # 等待5秒后再同步,让系统完全启动
|
||||
async def startup_sync():
|
||||
await asyncio.sleep(5)
|
||||
from app.services.payment import sync_pending_orders
|
||||
from logging import getLogger
|
||||
bg_logger = getLogger("payment")
|
||||
try:
|
||||
async with async_session() as db:
|
||||
sync_count = await sync_pending_orders(db)
|
||||
if sync_count > 0:
|
||||
bg_logger.info(f"Startup: Synced {sync_count} pending payment order(s)")
|
||||
except Exception as e:
|
||||
bg_logger.error(f"Startup sync error: {e}")
|
||||
asyncio.create_task(startup_sync())
|
||||
|
||||
app.state.db_session_factory = async_session
|
||||
|
||||
yield
|
||||
|
||||
task_queue.stop()
|
||||
await queue_task
|
||||
expiry_task.cancel()
|
||||
await close_database()
|
||||
await close_redis()
|
||||
|
||||
|
||||
@@ -42,6 +42,11 @@ class RequestEncryptMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(
|
||||
self, request: Request, call_next: RequestResponseEndpoint
|
||||
) -> Response:
|
||||
# 白名单:支付回调接口不需要加密/解密
|
||||
path = request.url.path
|
||||
if "/payments/alipay/callback" in path or "/payments/wechat/callback" in path:
|
||||
return await call_next(request)
|
||||
|
||||
encrypted = request.headers.get("X-Encrypted", "").lower() == "true"
|
||||
if not encrypted:
|
||||
return await call_next(request)
|
||||
|
||||
@@ -21,6 +21,11 @@ from app.models.chat_provider_call_log import ChatProviderCallLog
|
||||
from app.models.generated_resource import GeneratedResource
|
||||
from app.models.user_resource_month_stat import UserResourceMonthStat
|
||||
from app.models.user_resource_total_stat import UserResourceTotalStat
|
||||
from app.models.module_generation_project import ModuleGenerationProject
|
||||
from app.models.module_generation_step import ModuleGenerationStep
|
||||
from app.models.user_oauth import UserOAuth
|
||||
from app.models.user_oauth_account import UserOAuthAccount
|
||||
from app.models.user_oauth_app import UserOAuthApp
|
||||
|
||||
__all__ = [
|
||||
"Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session",
|
||||
@@ -31,4 +36,6 @@ __all__ = [
|
||||
"MenuConfig", "RechargePackage", "OperationLog",
|
||||
"ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog",
|
||||
"GeneratedResource", "UserResourceMonthStat", "UserResourceTotalStat",
|
||||
"ModuleGenerationProject", "ModuleGenerationStep",
|
||||
"UserOAuth", "UserOAuthAccount", "UserOAuthApp",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, TimestampMixin, SoftDeleteMixin
|
||||
@@ -18,7 +18,14 @@ class ChatGenerationTask(Base, TimestampMixin, SoftDeleteMixin):
|
||||
__table_args__ = (
|
||||
# 防止前端按钮连点/网络重试时同一个 idempotency_key 并发创建多条任务。
|
||||
# nullable unique 兼容不传 idempotency_key 的普通请求。
|
||||
Index("uq_chat_generation_tasks_user_mode_idempotency", "user_id", "generation_mode", "idempotency_key", unique=True),
|
||||
Index(
|
||||
"uq_chat_generation_tasks_user_mode_idempotency",
|
||||
"user_id",
|
||||
"generation_mode",
|
||||
"idempotency_key",
|
||||
unique=True,
|
||||
postgresql_where=text("deleted_at IS NULL AND idempotency_key IS NOT NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
class ModuleGenerationProject(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""通用模块生成项目/总任务表。
|
||||
|
||||
说明:
|
||||
- 本表的 id 就是前端理解的“项目ID/总任务ID”,不再额外保存 project_id。
|
||||
- 通过 module 区分业务模块,后续其它功能也可以复用这张总任务项目表。
|
||||
- 爆款开头复刻使用 module=hot_opening_replicate。
|
||||
"""
|
||||
|
||||
__tablename__ = "module_generation_projects"
|
||||
__table_args__ = (
|
||||
Index("idx_module_generation_projects_user_module", "user_id", "module"),
|
||||
Index("idx_module_generation_projects_status", "module", "status"),
|
||||
Index(
|
||||
"uq_module_generation_projects_user_module_idempotency",
|
||||
"user_id",
|
||||
"module",
|
||||
"idempotency_key",
|
||||
unique=True,
|
||||
postgresql_where=text("deleted_at IS NULL AND idempotency_key IS NOT NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
module: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
title: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
|
||||
current_step_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
final_image_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
final_video_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
final_video_cover_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
idempotency_key: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
_STEP_JSON_TYPE = JSON().with_variant(JSONB, "postgresql")
|
||||
|
||||
|
||||
class ModuleGenerationStep(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""通用模块生成步骤表。
|
||||
|
||||
爆款开头复刻固定步骤:
|
||||
1 material_input
|
||||
2 image_prompt_optimize
|
||||
3 image_generate
|
||||
4 video_prompt_optimize
|
||||
5 video_generate
|
||||
|
||||
input_json / output_json 使用 JSON/JSONB 存储。
|
||||
建议结构:
|
||||
input_json = {
|
||||
"schema_version": "hot_opening_step_io_v1",
|
||||
"step_code": "...",
|
||||
"source": {...},
|
||||
"payload": {...},
|
||||
"context": {...}
|
||||
}
|
||||
output_json = {
|
||||
"schema_version": "hot_opening_step_io_v1",
|
||||
"step_code": "...",
|
||||
"status": "completed|failed|...",
|
||||
"payload": {...},
|
||||
"result": {...},
|
||||
"usage": {...},
|
||||
"error": {...}
|
||||
}
|
||||
"""
|
||||
|
||||
__tablename__ = "module_generation_steps"
|
||||
__table_args__ = (
|
||||
Index("idx_module_generation_steps_project_current", "project_id", "is_current", "deleted_at"),
|
||||
Index("idx_module_generation_steps_project_code", "project_id", "step_code", "is_current"),
|
||||
Index("idx_module_generation_steps_chat_task", "chat_task_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("module_generation_projects.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
module: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
step_index: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
|
||||
step_code: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
|
||||
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
is_current: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
parent_step_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
source_step_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
chat_task_id: Mapped[str | None] = mapped_column(
|
||||
String(32), ForeignKey("chat_generation_tasks.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
input_json: Mapped[dict[str, Any] | list[Any] | None] = mapped_column(_STEP_JSON_TYPE, nullable=True)
|
||||
output_json: Mapped[dict[str, Any] | list[Any] | None] = mapped_column(_STEP_JSON_TYPE, nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
@@ -22,3 +22,9 @@ class PaymentOrder(Base, TimestampMixin):
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# Refund fields
|
||||
refund_trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
refunded_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
refund_amount: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, TimestampMixin, SoftDeleteMixin
|
||||
|
||||
|
||||
class UserOAuth(Base, TimestampMixin, SoftDeleteMixin):
|
||||
__tablename__ = "user_oauth"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(32), primary_key=True, comment="主键"
|
||||
)
|
||||
account_id: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, index=True, comment="授权账户id"
|
||||
)
|
||||
account_name: Mapped[str] = mapped_column(
|
||||
String(128), nullable=False, comment="授权账户name"
|
||||
)
|
||||
account_role: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, comment="授权账户角色"
|
||||
)
|
||||
account_username: Mapped[str | None] = mapped_column(
|
||||
String(128), nullable=True, comment="授权账户登录账号"
|
||||
)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, index=True,
|
||||
comment="用户id"
|
||||
)
|
||||
open_type: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False,
|
||||
comment="开户方式(1=千川,2=广告,3=本地推,4=星图,5=快手代理商,6=巨量星图,7=巨量服务单,8=腾讯服务单,9=腾讯营销K2,10=腾讯营销K3)"
|
||||
)
|
||||
port_type: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False,
|
||||
comment="平台端口(1=巨量,2=磁力,3=巨量星图,4=服务单,5=腾讯)"
|
||||
)
|
||||
appid: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, comment="授权应用id"
|
||||
)
|
||||
access_token: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True, comment="授权token"
|
||||
)
|
||||
access_token_expired: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
comment="token过期时间"
|
||||
)
|
||||
refresh_token: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True, comment="授权刷新token"
|
||||
)
|
||||
refresh_token_expired: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
comment="刷新token过期时间"
|
||||
)
|
||||
material_auth_status: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, comment="是否敏感物料授权(true=是,false=否)"
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, TimestampMixin, SoftDeleteMixin
|
||||
|
||||
|
||||
class UserOAuthAccount(Base, TimestampMixin, SoftDeleteMixin):
|
||||
__tablename__ = "user_oauth_account"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(32), primary_key=True, comment="主键"
|
||||
)
|
||||
account_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False, index=True, comment="授权账户id(user_oauth表中同一个)"
|
||||
)
|
||||
advertiser_id: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, index=True, comment="广告账户id"
|
||||
)
|
||||
advertiser_name: Mapped[str | None] = mapped_column(
|
||||
String(128), nullable=True, comment="广告账户名"
|
||||
)
|
||||
advertiser_role: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, comment="广告账户类型"
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
from sqlalchemy import BigInteger, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, TimestampMixin, SoftDeleteMixin
|
||||
|
||||
|
||||
class UserOAuthApp(Base, TimestampMixin, SoftDeleteMixin):
|
||||
__tablename__ = "user_oauth_app"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(32), primary_key=True, comment="主键"
|
||||
)
|
||||
app_id: Mapped[str] = mapped_column(
|
||||
String(64), unique=True, nullable=False, index=True, comment="应用id"
|
||||
)
|
||||
secret: Mapped[str] = mapped_column(
|
||||
String(256), nullable=False, comment="应用密钥"
|
||||
)
|
||||
status: Mapped[int] = mapped_column(
|
||||
BigInteger, nullable=False, default=1, comment="状态,1=正常,2=禁用"
|
||||
)
|
||||
count: Mapped[int] = mapped_column(
|
||||
BigInteger, nullable=False, default=100, comment="应用最大可以授权多少个用户"
|
||||
)
|
||||
auth_url: Mapped[str] = mapped_column(
|
||||
String(256), nullable=True, comment="应用授权链接"
|
||||
)
|
||||
company: Mapped[str] = mapped_column(
|
||||
String(256), nullable=True, comment="应用归属公司名称"
|
||||
)
|
||||
open_type: Mapped[int] = mapped_column(
|
||||
BigInteger, nullable=False, index=True, comment="开户方式(1=千川,2=广告,3=本地推,4=星图,5=快手代理商,6=巨量星图,7=巨量服务单,8=腾讯服务单,9=腾讯营销K2,10=腾讯营销K3)"
|
||||
)
|
||||
create_by: Mapped[str | None] = mapped_column(
|
||||
String(32), nullable=True, comment="创建者"
|
||||
)
|
||||
@@ -0,0 +1,485 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.schemas.common import NaiveDatetimeOptional
|
||||
|
||||
HOT_OPENING_PROJECT_STATUS_DESCRIPTIONS: dict[str, str] = {
|
||||
"pending": "已创建但未进入流程",
|
||||
"waiting_user": "等待用户手动触发下一步",
|
||||
"processing": "当前有步骤处理中",
|
||||
"completed": "总任务完成",
|
||||
"failed": "总任务失败",
|
||||
"cancelled": "总任务取消",
|
||||
}
|
||||
|
||||
HOT_OPENING_STEP_STATUS_DESCRIPTIONS: dict[str, str] = {
|
||||
"pending": "子任务待处理",
|
||||
"waiting_user": "等待用户确认或触发",
|
||||
"processing": "子任务处理中",
|
||||
"completed": "子任务完成",
|
||||
"failed": "子任务失败",
|
||||
"cancelled": "子任务取消",
|
||||
}
|
||||
|
||||
HOT_OPENING_STEP_DESCRIPTIONS: list[dict[str, Any]] = [
|
||||
{"step_index": 1, "step_code": "material_input", "name": "素材输入"},
|
||||
{"step_index": 2, "step_code": "image_prompt_optimize", "name": "图片 AI 提词"},
|
||||
{"step_index": 3, "step_code": "image_generate", "name": "图片生成"},
|
||||
{"step_index": 4, "step_code": "video_prompt_optimize", "name": "视频 AI 提词 JSON schema"},
|
||||
{"step_index": 5, "step_code": "video_generate", "name": "视频生成"},
|
||||
]
|
||||
|
||||
HOT_OPENING_STEP_IO_SCHEMA_VERSION = "hot_opening_step_io_v1"
|
||||
|
||||
HOT_OPENING_STEP_IO_EXAMPLES: dict[str, dict[str, Any]] = {
|
||||
"material_input": {
|
||||
"input_json": {
|
||||
"schema_version": HOT_OPENING_STEP_IO_SCHEMA_VERSION,
|
||||
"step_code": "material_input",
|
||||
"source": {"source_step_id": None, "parent_step_id": None},
|
||||
"payload": {
|
||||
"material_video_url": "https://example.com/source.mp4",
|
||||
"material_image_url": "https://example.com/product.png",
|
||||
"source_project_name": "参考素材项目名称",
|
||||
"target_project_name": "新项目名称",
|
||||
"core_content_point": "50字以内核心内容点",
|
||||
},
|
||||
"context": {},
|
||||
},
|
||||
"output_json": {
|
||||
"schema_version": HOT_OPENING_STEP_IO_SCHEMA_VERSION,
|
||||
"step_code": "material_input",
|
||||
"status": "completed",
|
||||
"payload": {},
|
||||
"result": {"accepted": True, "message": "素材输入已提交", "next_step_code": "image_prompt_optimize"},
|
||||
"usage": {},
|
||||
"error": {},
|
||||
},
|
||||
},
|
||||
"image_prompt_optimize": {
|
||||
"input_json": {
|
||||
"schema_version": HOT_OPENING_STEP_IO_SCHEMA_VERSION,
|
||||
"step_code": "image_prompt_optimize",
|
||||
"source": {"source_step_id": "第1步素材输入ID", "parent_step_id": "第1步素材输入ID"},
|
||||
"payload": {"source_step_id": "第1步素材输入ID"},
|
||||
"context": {},
|
||||
},
|
||||
"output_json": {
|
||||
"schema_version": HOT_OPENING_STEP_IO_SCHEMA_VERSION,
|
||||
"step_code": "image_prompt_optimize",
|
||||
"status": "completed",
|
||||
"payload": {
|
||||
"optimized_prompt": "图片生成提示词",
|
||||
"prompt": "兼容字段,同 optimized_prompt",
|
||||
"original_prompt": "后端拼接的图片提词原始需求",
|
||||
"references": [{"type": "video|image", "url": "...", "name": "..."}],
|
||||
},
|
||||
"result": {},
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
"text_credits_cost": 0,
|
||||
"credit_biz_key": "module_generation_step:{step_id}:attempt:1:text_prompt:charge",
|
||||
},
|
||||
"error": {},
|
||||
},
|
||||
},
|
||||
"image_generate": {
|
||||
"input_json": {
|
||||
"schema_version": HOT_OPENING_STEP_IO_SCHEMA_VERSION,
|
||||
"step_code": "image_generate",
|
||||
"source": {"source_step_id": "第2步图片提词ID", "parent_step_id": "第2步图片提词ID"},
|
||||
"payload": {
|
||||
"engine_id": "图片引擎ID",
|
||||
"params": {"image_size": "2K", "image_proportion": "1:1", "image_px": "2048x2048"},
|
||||
"prompt": "图片生成提示词",
|
||||
"media_references": [{"type": "image", "url": "新产品图片", "name": "新产品图片"}],
|
||||
},
|
||||
"context": {},
|
||||
},
|
||||
"output_json": {
|
||||
"schema_version": HOT_OPENING_STEP_IO_SCHEMA_VERSION,
|
||||
"step_code": "image_generate",
|
||||
"status": "completed",
|
||||
"payload": {},
|
||||
"result": {"result_image_url": "/generate/images/xxx.png", "chat_task_id": "ChatGenerationTask ID"},
|
||||
"usage": {},
|
||||
"error": {},
|
||||
},
|
||||
},
|
||||
"video_prompt_optimize": {
|
||||
"input_json": {
|
||||
"schema_version": HOT_OPENING_STEP_IO_SCHEMA_VERSION,
|
||||
"step_code": "video_prompt_optimize",
|
||||
"source": {"source_step_id": "第3步图片生成ID", "parent_step_id": "第3步图片生成ID"},
|
||||
"payload": {
|
||||
"source_step_id": "第3步图片生成ID",
|
||||
"video_config": {"engine_id": "视频引擎ID", "duration": 8, "aspect_ratio": "9:16", "resolution": "1080p"},
|
||||
"target_platform": "抖音",
|
||||
},
|
||||
"context": {},
|
||||
},
|
||||
"output_json": {
|
||||
"schema_version": HOT_OPENING_STEP_IO_SCHEMA_VERSION,
|
||||
"step_code": "video_prompt_optimize",
|
||||
"status": "completed",
|
||||
"payload": {
|
||||
"prompt_schema": {"任务基础信息": {}, "最终提示词": {}},
|
||||
"final_prompt": "展示用最终视频提示词",
|
||||
"params_used_for_prompt": {"duration": 8, "aspect_ratio": "9:16", "resolution": "1080p"},
|
||||
"target_platform": "抖音",
|
||||
},
|
||||
"result": {},
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
"text_credits_cost": 0,
|
||||
"credit_biz_key": "module_generation_step:{step_id}:attempt:1:text_prompt:charge",
|
||||
},
|
||||
"error": {},
|
||||
},
|
||||
},
|
||||
"video_generate": {
|
||||
"input_json": {
|
||||
"schema_version": HOT_OPENING_STEP_IO_SCHEMA_VERSION,
|
||||
"step_code": "video_generate",
|
||||
"source": {"source_step_id": "第4步视频提词ID", "parent_step_id": "第4步视频提词ID"},
|
||||
"payload": {
|
||||
"engine_id": "视频引擎ID",
|
||||
"params": {"duration": 8, "aspect_ratio": "9:16", "resolution": "1080p"},
|
||||
"prompt_schema": {"任务基础信息": {}, "最终提示词": {}},
|
||||
"final_prompt": "展示用最终提示词",
|
||||
"media_references": [{"type": "image", "url": "第3步生成图片", "name": "新项目图片"}],
|
||||
},
|
||||
"context": {},
|
||||
},
|
||||
"output_json": {
|
||||
"schema_version": HOT_OPENING_STEP_IO_SCHEMA_VERSION,
|
||||
"step_code": "video_generate",
|
||||
"status": "completed",
|
||||
"payload": {},
|
||||
"result": {
|
||||
"result_video_url": "/generate/videos/xxx.mp4",
|
||||
"result_video_cover_url": "/generate/covers/xxx.jpg",
|
||||
"chat_task_id": "ChatGenerationTask ID",
|
||||
},
|
||||
"usage": {},
|
||||
"error": {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class HotOpeningTaskCreate(BaseModel):
|
||||
"""创建爆款开头复刻总任务项目请求体。"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"material_video_url": "https://example.com/source.mp4",
|
||||
"material_image_url": "https://example.com/product.png",
|
||||
"source_project_name": "参考素材项目名称",
|
||||
"target_project_name": "新项目名称",
|
||||
"core_content_point": "突出产品能帮助用户认识附近新朋友",
|
||||
"idempotency_key": "frontend-submit-uuid-001",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
material_video_url: str = Field(..., min_length=1, description="素材视频链接,参考素材,1份。由项目已有上传接口返回,本接口不负责上传,不做后端素材校验")
|
||||
material_image_url: str = Field(..., min_length=1, description="素材图片链接,新产品图片,1份。由项目已有上传接口返回,本接口不负责上传,不做后端素材校验")
|
||||
source_project_name: str = Field(..., min_length=1, max_length=20, description="视频素材内容项目名称")
|
||||
target_project_name: str = Field(..., min_length=1, max_length=20, description="生成项目名称")
|
||||
core_content_point: str = Field(..., min_length=1, max_length=50, description="生成的项目核心内容点,最多50字")
|
||||
idempotency_key: str | None = Field(None, max_length=64, description="创建总任务幂等键。只用于 module_generation_projects,不用于 ChatGenerationTask")
|
||||
|
||||
@field_validator("material_video_url", "material_image_url", "source_project_name", "target_project_name", "core_content_point")
|
||||
@classmethod
|
||||
def _strip_required(cls, value: str) -> str:
|
||||
value = str(value or "").strip()
|
||||
if not value:
|
||||
raise ValueError("字段不能为空")
|
||||
return value
|
||||
|
||||
|
||||
class HotOpeningMaterialUpdateRequest(BaseModel):
|
||||
"""修改爆款开头复刻第1步素材输入请求体。"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"material_video_url": "https://example.com/new-source.mp4",
|
||||
"material_image_url": "https://example.com/new-product.png",
|
||||
"source_project_name": "新的参考素材项目名称",
|
||||
"target_project_name": "新的生成项目名称",
|
||||
"core_content_point": "新的50字以内核心内容点",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
material_video_url: str | None = Field(None, min_length=1, description="素材视频链接,未传则沿用旧值")
|
||||
material_image_url: str | None = Field(None, min_length=1, description="素材图片链接,未传则沿用旧值")
|
||||
source_project_name: str | None = Field(None, min_length=1, max_length=20, description="视频素材内容项目名称,未传则沿用旧值")
|
||||
target_project_name: str | None = Field(None, min_length=1, max_length=20, description="生成项目名称,未传则沿用旧值")
|
||||
core_content_point: str | None = Field(None, min_length=1, max_length=50, description="生成项目核心内容点,最多50字,未传则沿用旧值")
|
||||
|
||||
@field_validator("material_video_url", "material_image_url", "source_project_name", "target_project_name", "core_content_point", mode="before")
|
||||
@classmethod
|
||||
def _strip_optional(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = str(value).strip()
|
||||
if not value:
|
||||
raise ValueError("字段不能为空字符串")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _require_at_least_one(self) -> "HotOpeningMaterialUpdateRequest":
|
||||
if not any(getattr(self, field) is not None for field in ("material_video_url", "material_image_url", "source_project_name", "target_project_name", "core_content_point")):
|
||||
raise ValueError("至少需要传入一个需要修改的字段")
|
||||
return self
|
||||
|
||||
|
||||
class HotOpeningStepUpdate(BaseModel):
|
||||
"""修改爆款开头复刻子任务请求体。"""
|
||||
|
||||
material_video_url: str | None = Field(None, description="修改第1步素材视频链接")
|
||||
material_image_url: str | None = Field(None, description="修改第1步素材图片链接")
|
||||
source_project_name: str | None = Field(None, max_length=20, description="修改第1步视频素材内容项目名称")
|
||||
target_project_name: str | None = Field(None, max_length=20, description="修改第1步生成项目名称")
|
||||
core_content_point: str | None = Field(None, max_length=50, description="修改第1步生成项目核心内容点,最多50字")
|
||||
prompt: str | None = Field(None, description="修改第2步图片提词或第4步视频最终提词")
|
||||
prompt_schema: dict[str, Any] | None = Field(None, description="修改第4步视频提词 JSON schema。只对视频提词步骤有意义")
|
||||
input_json: dict[str, Any] | None = Field(None, description="高级用法:合并修改当前步骤 input_json.payload")
|
||||
output_json: dict[str, Any] | None = Field(None, description="高级用法:合并修改当前步骤 output_json.payload")
|
||||
|
||||
|
||||
class HotOpeningImagePromptUpdateRequest(BaseModel):
|
||||
"""直接修改第2步图片 AI 优化提词请求体。
|
||||
|
||||
本接口不调用 AI、不扣积分;保存后会软删除第3、4、5步当前有效任务。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
extra="ignore",
|
||||
json_schema_extra={"example": {"prompt": "用户手动修改后的图片生成提示词"}},
|
||||
)
|
||||
|
||||
prompt: str = Field(..., min_length=1, description="用户手动修改后的图片生成提示词,不能为空")
|
||||
|
||||
@field_validator("prompt", mode="before")
|
||||
@classmethod
|
||||
def _strip_prompt(cls, value: str) -> str:
|
||||
value = str(value or "").strip()
|
||||
if not value:
|
||||
raise ValueError("图片提示词不能为空")
|
||||
return value
|
||||
|
||||
|
||||
class HotOpeningVideoPromptSchemaUpdateRequest(BaseModel):
|
||||
"""修改第4步视频 AI 提词 JSON schema 请求体。
|
||||
|
||||
前端提交的 prompt_schema 只作为 patch:服务端会锁定视频时长、比例、清晰度、帧率、推荐分辨率、
|
||||
动作/镜头/动态时间规划数组长度和时间段、输出规格限制、质量控制、合规控制、schema_version、schema_usage。
|
||||
最终提示词允许修改,但保存前会清洗视频参数。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
extra="ignore",
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"prompt_schema": {
|
||||
"业务属性": {"产品名称": "脱单交友APP", "行动引导": "立即下载"},
|
||||
"最终提示词": {"主提示词": "脱单交友APP推广短视频,突出认识附近新朋友和高效匹配"},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
prompt_schema: dict[str, Any] = Field(..., description="前端修改后的视频提词 JSON schema。后端只按白名单回填允许修改字段")
|
||||
|
||||
@field_validator("prompt_schema")
|
||||
@classmethod
|
||||
def _validate_schema(cls, value: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(value, dict) or not value:
|
||||
raise ValueError("prompt_schema 必须是非空 JSON 对象")
|
||||
return value
|
||||
|
||||
|
||||
class HotOpeningGenerateImagePromptRequest(BaseModel):
|
||||
"""手动生成第2步图片 AI 提词请求体。当前无需请求参数。"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
class HotOpeningGenerateImageRequest(BaseModel):
|
||||
"""根据图片提词生成新项目图片请求体。
|
||||
|
||||
ChatGenerationTask.idempotency_key 由后端自动生成,接口不再接收前端幂等键。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
extra="ignore",
|
||||
json_schema_extra={"example": {"engine_id": "image_engine_xxx", "image_size": "2K", "image_proportion": "1:1", "image_px": "2048x2048"}},
|
||||
)
|
||||
|
||||
engine_id: str | None = Field(None, description="图片生成引擎ID。为空则使用当前启用且优先级最高的图片引擎")
|
||||
image_size: str | None = Field(None, description="图片分辨率档位,例如 1K、2K。为空使用引擎默认值")
|
||||
image_proportion: str | None = Field(None, description="图片比例,例如 1:1、16:9、9:16。为空使用默认值")
|
||||
image_px: str | None = Field(None, description="图片像素尺寸,例如 2048x2048。为空时按引擎支持尺寸自动匹配")
|
||||
|
||||
|
||||
class HotOpeningGenerateVideoPromptRequest(BaseModel):
|
||||
"""手动生成第4步视频 AI 提词请求体。
|
||||
|
||||
视频时长、比例、分辨率集中在本步骤确定;第5步生成视频只选择视频引擎。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
extra="ignore",
|
||||
json_schema_extra={"example": {"engine_id": "video_engine_xxx", "duration": 8, "aspect_ratio": "9:16", "resolution": "1080p", "target_platform": "抖音"}},
|
||||
)
|
||||
|
||||
engine_id: str | None = Field(None, description="视频引擎ID。用于读取该引擎支持的视频时长、比例、分辨率配置;为空使用最高优先级启用引擎")
|
||||
duration: int | None = Field(None, ge=1, description="希望用于视频提词规划的视频时长,单位秒。为空时优先使用 HOT_OPENING_DEFAULT_VIDEO_DURATION")
|
||||
aspect_ratio: str | None = Field(None, description="希望用于视频提词规划的视频比例。为空时优先使用 HOT_OPENING_DEFAULT_VIDEO_RATIO")
|
||||
resolution: str | None = Field(None, description="希望用于视频提词规划的视频分辨率。为空时优先使用 HOT_OPENING_DEFAULT_VIDEO_RESOLUTION")
|
||||
target_platform: str | None = Field(None, max_length=64, description="目标平台,例如抖音/快手/小红书。为空时使用 HOT_OPENING_DEFAULT_TARGET_PLATFORM")
|
||||
|
||||
|
||||
class HotOpeningGenerateVideoRequest(BaseModel):
|
||||
"""根据视频提词生成最终视频请求体。
|
||||
|
||||
只选择视频生成引擎。duration / aspect_ratio / resolution 从第4步视频提词优化结果读取。
|
||||
ChatGenerationTask.original_prompt / optimized_prompt 都写入第4步生成的 prompt_schema JSON 字符串。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore", json_schema_extra={"example": {"engine_id": "video_engine_xxx"}})
|
||||
|
||||
engine_id: str | None = Field(None, description="视频生成引擎ID。为空优先使用第4步视频提词时选择的 engine_id,再为空使用最高优先级启用视频引擎")
|
||||
|
||||
|
||||
class HotOpeningStepOut(BaseModel):
|
||||
id: str = Field(..., description="子任务ID")
|
||||
project_id: str = Field(..., description="总任务项目ID,即 module_generation_projects.id")
|
||||
module: str = Field(..., description="模块标识,例如 hot_opening_replicate")
|
||||
step_index: int = Field(..., description="步骤序号:1素材输入、2图片提词、3图片生成、4视频提词、5视频生成")
|
||||
step_code: str = Field(..., description="步骤编码:material_input/image_prompt_optimize/image_generate/video_prompt_optimize/video_generate")
|
||||
status: str = Field(..., description="步骤状态:pending/waiting_user/processing/completed/failed/cancelled")
|
||||
version: int = Field(..., description="步骤版本号。重新生成或修改上游步骤后 version+1")
|
||||
is_current: bool = Field(..., description="是否当前有效步骤。旧步骤会软删除且 is_current=false")
|
||||
parent_step_id: str | None = Field(None, description="上一个步骤ID")
|
||||
source_step_id: str | None = Field(None, description="当前步骤基于哪个上游步骤生成")
|
||||
chat_task_id: str | None = Field(None, description="关联的 ChatGenerationTask ID。第3步图片生成、第5步视频生成有值")
|
||||
input: dict[str, Any] | None = Field(None, description=f"步骤输入 JSON,统一 schema_version={HOT_OPENING_STEP_IO_SCHEMA_VERSION}")
|
||||
output: dict[str, Any] | None = Field(None, description=f"步骤输出 JSON,统一 schema_version={HOT_OPENING_STEP_IO_SCHEMA_VERSION}")
|
||||
error_message: str | None = Field(None, description="步骤错误信息")
|
||||
created_at: NaiveDatetimeOptional = Field(None, description="创建时间")
|
||||
updated_at: NaiveDatetimeOptional = Field(None, description="更新时间")
|
||||
completed_at: NaiveDatetimeOptional = Field(None, description="完成时间")
|
||||
|
||||
|
||||
class HotOpeningMaterialOut(BaseModel):
|
||||
material_step_id: str | None = Field(None, description="第1步素材输入子任务ID")
|
||||
material_video_url: str | None = Field(None, description="素材视频链接")
|
||||
material_image_url: str | None = Field(None, description="素材图片链接")
|
||||
source_project_name: str | None = Field(None, description="视频素材内容项目名称")
|
||||
target_project_name: str | None = Field(None, description="生成项目名称")
|
||||
core_content_point: str | None = Field(None, description="生成项目核心内容点")
|
||||
|
||||
|
||||
class HotOpeningImageGenerationOut(BaseModel):
|
||||
prompt_step_id: str | None = Field(None, description="第2步图片 AI 提词子任务ID")
|
||||
generate_step_id: str | None = Field(None, description="第3步图片生成子任务ID")
|
||||
prompt: str | None = Field(None, description="图片优化提词")
|
||||
engine_id: str | None = Field(None, description="图片生成引擎ID")
|
||||
engine_name: str | None = Field(None, description="图片生成引擎名称")
|
||||
params: dict[str, Any] | None = Field(None, description="图片生成参数")
|
||||
chat_task_id: str | None = Field(None, description="图片生成 ChatGenerationTask ID")
|
||||
status: str | None = Field(None, description="图片生成状态")
|
||||
result_image_url: str | None = Field(None, description="新项目图片 URL")
|
||||
error_message: str | None = Field(None, description="图片生成错误信息")
|
||||
|
||||
|
||||
class HotOpeningVideoGenerationOut(BaseModel):
|
||||
prompt_step_id: str | None = Field(None, description="第4步视频 AI 提词子任务ID")
|
||||
generate_step_id: str | None = Field(None, description="第5步视频生成子任务ID")
|
||||
prompt_schema: dict[str, Any] | None = Field(None, description="视频提词 JSON schema。第5步 ChatGenerationTask 原始提词会使用该 JSON 字符串")
|
||||
final_prompt: str | None = Field(None, description="视频最终提词,仅用于前端展示")
|
||||
prompt_params: dict[str, Any] | None = Field(None, description="第4步生成视频提词时使用的视频配置,例如 duration、aspect_ratio、resolution")
|
||||
engine_id: str | None = Field(None, description="视频生成引擎ID")
|
||||
engine_name: str | None = Field(None, description="视频生成引擎名称")
|
||||
params: dict[str, Any] | None = Field(None, description="视频生成实际参数。第5步只传 engine_id,其它参数继承第4步")
|
||||
chat_task_id: str | None = Field(None, description="视频生成 ChatGenerationTask ID")
|
||||
status: str | None = Field(None, description="视频生成状态")
|
||||
result_video_url: str | None = Field(None, description="最终视频 URL")
|
||||
result_video_cover_url: str | None = Field(None, description="最终视频封面 URL")
|
||||
error_message: str | None = Field(None, description="视频生成错误信息")
|
||||
|
||||
|
||||
class HotOpeningTaskDetailOut(BaseModel):
|
||||
id: str = Field(..., description="总任务项目ID。这个ID就是前端项目ID")
|
||||
project_id: str = Field(..., description="兼容前端命名,等同于 id")
|
||||
module: str = Field(..., description="模块标识,爆款开头复刻固定为 hot_opening_replicate")
|
||||
title: str | None = Field(None, description="项目标题,默认取生成项目名称")
|
||||
status: str = Field(..., description="总任务状态:pending/waiting_user/processing/completed/failed/cancelled")
|
||||
current_step_code: str | None = Field(None, description="当前所处步骤编码")
|
||||
final_image_url: str | None = Field(None, description="最终新项目图片 URL")
|
||||
final_video_url: str | None = Field(None, description="最终视频 URL")
|
||||
final_video_cover_url: str | None = Field(None, description="最终视频封面 URL")
|
||||
error_message: str | None = Field(None, description="总任务错误信息")
|
||||
material: HotOpeningMaterialOut = Field(default_factory=HotOpeningMaterialOut, description="素材和项目描述信息")
|
||||
image_generation: HotOpeningImageGenerationOut = Field(default_factory=HotOpeningImageGenerationOut, description="图片提词、图片引擎参数和图片结果")
|
||||
video_generation: HotOpeningVideoGenerationOut = Field(default_factory=HotOpeningVideoGenerationOut, description="视频提词、视频引擎参数和视频结果")
|
||||
steps: list[HotOpeningStepOut] = Field(default_factory=list, description="当前有效子任务列表")
|
||||
created_at: NaiveDatetimeOptional = Field(None, description="创建时间")
|
||||
updated_at: NaiveDatetimeOptional = Field(None, description="更新时间")
|
||||
completed_at: NaiveDatetimeOptional = Field(None, description="完成时间")
|
||||
|
||||
|
||||
class HotOpeningTaskListItemOut(BaseModel):
|
||||
id: str = Field(..., description="总任务项目ID。这个ID就是前端项目ID")
|
||||
project_id: str = Field(..., description="兼容前端命名,等同于 id")
|
||||
module: str = Field(..., description="模块标识")
|
||||
title: str | None = Field(None, description="项目标题")
|
||||
status: str = Field(..., description="总任务状态")
|
||||
current_step_code: str | None = Field(None, description="当前步骤")
|
||||
target_project_name: str | None = Field(None, description="生成项目名称,来源于第1步素材输入")
|
||||
final_image_url: str | None = Field(None, description="最终图片 URL")
|
||||
final_video_url: str | None = Field(None, description="最终视频 URL")
|
||||
error_message: str | None = Field(None, description="错误信息")
|
||||
created_at: NaiveDatetimeOptional = Field(None, description="创建时间")
|
||||
updated_at: NaiveDatetimeOptional = Field(None, description="更新时间")
|
||||
completed_at: NaiveDatetimeOptional = Field(None, description="完成时间")
|
||||
|
||||
|
||||
class HotOpeningTaskListOut(BaseModel):
|
||||
total: int = Field(..., description="总数量")
|
||||
items: list[HotOpeningTaskListItemOut] = Field(default_factory=list, description="列表数据")
|
||||
|
||||
|
||||
class HotOpeningActionOut(BaseModel):
|
||||
message: str = Field(..., description="操作结果提示")
|
||||
project_id: str = Field(..., description="总任务项目ID")
|
||||
step_id: str | None = Field(None, description="本次创建或修改的子任务ID")
|
||||
next_step_id: str | None = Field(None, description="兼容字段:当前接口不自动生成下下个任务,一般为空")
|
||||
detail: HotOpeningTaskDetailOut | None = Field(None, description="操作后的总任务详情")
|
||||
|
||||
|
||||
class HotOpeningDeleteOut(BaseModel):
|
||||
message: str = Field(..., description="删除结果提示")
|
||||
project_id: str = Field(..., description="被软删除的总任务项目ID")
|
||||
deleted: bool = Field(..., description="是否已软删除")
|
||||
|
||||
|
||||
class HotOpeningSpecOut(BaseModel):
|
||||
project_statuses: dict[str, str] = Field(default_factory=lambda: HOT_OPENING_PROJECT_STATUS_DESCRIPTIONS, description="总任务状态说明")
|
||||
step_statuses: dict[str, str] = Field(default_factory=lambda: HOT_OPENING_STEP_STATUS_DESCRIPTIONS, description="子任务状态说明")
|
||||
steps: list[dict[str, Any]] = Field(default_factory=lambda: HOT_OPENING_STEP_DESCRIPTIONS, description="5个固定步骤说明")
|
||||
step_io_schema_version: str = Field(default=HOT_OPENING_STEP_IO_SCHEMA_VERSION, description="步骤 input_json/output_json 结构版本")
|
||||
step_io_examples: dict[str, dict[str, Any]] = Field(default_factory=lambda: HOT_OPENING_STEP_IO_EXAMPLES, description="每个步骤 input_json/output_json 示例")
|
||||
@@ -3,6 +3,7 @@ from pydantic import BaseModel
|
||||
|
||||
class RechargeRequest(BaseModel):
|
||||
plan: str # package id
|
||||
method: str = "wechat" # "wechat" or "alipay"
|
||||
|
||||
|
||||
class PaymentOrderOut(BaseModel):
|
||||
@@ -12,5 +13,6 @@ class PaymentOrderOut(BaseModel):
|
||||
credits: float
|
||||
payment_method: str
|
||||
status: str
|
||||
qr_url: str | None = None # Alipay QR code URL (transient, not persisted)
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RequestOAuthRequest(BaseModel):
|
||||
oauth_type: int = Field(
|
||||
...,
|
||||
description="开户方式(1=巨量广告,2=巨量千川,3=快手,4=腾讯)",
|
||||
)
|
||||
|
||||
|
||||
class RequestOAuthResponse(BaseModel):
|
||||
auth_url: str = Field(..., description="第三方授权链接")
|
||||
|
||||
|
||||
class UserOAuthOut(BaseModel):
|
||||
id: str = Field(..., description="主键")
|
||||
account_id: str = Field(..., description="授权账户id")
|
||||
account_name: str = Field(..., description="授权账户name")
|
||||
account_role: str | None = Field(None, description="授权账户角色")
|
||||
account_username: str | None = Field(None, description="授权账户登录账号")
|
||||
user_id: str = Field(..., description="用户id")
|
||||
open_type: int = Field(..., description="开户方式")
|
||||
port_type: int = Field(..., description="平台端口")
|
||||
appid: str | None = Field(None, description="授权应用id")
|
||||
material_auth_status: bool = Field(False, description="是否敏感物料授权")
|
||||
created_at: datetime = Field(..., description="创建时间")
|
||||
updated_at: datetime = Field(..., description="更新时间")
|
||||
@@ -0,0 +1,47 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.common import NaiveDatetime
|
||||
|
||||
|
||||
class UserOAuthAppCreate(BaseModel):
|
||||
app_id: str = Field(..., max_length=64, description="应用id")
|
||||
secret: str = Field(..., max_length=256, description="应用密钥")
|
||||
open_type: int = Field(
|
||||
...,
|
||||
ge=1,
|
||||
le=10,
|
||||
description="开户方式(1=千川,2=广告,3=本地推,4=星图,5=快手代理商,6=巨量星图,7=巨量服务单,8=腾讯服务单,9=腾讯营销K2,10=腾讯营销K3)",
|
||||
)
|
||||
count: int = Field(100, ge=1, description="应用最大可以授权多少个用户")
|
||||
auth_url: str | None = Field(None, max_length=256, description="应用授权链接")
|
||||
company: str | None = Field(None, max_length=256, description="应用归属公司名称")
|
||||
|
||||
|
||||
class UserOAuthAppUpdate(BaseModel):
|
||||
secret: str | None = Field(None, max_length=256, description="应用密钥")
|
||||
open_type: int | None = Field(
|
||||
None,
|
||||
ge=1,
|
||||
le=10,
|
||||
description="开户方式(1=千川,2=广告,3=本地推,4=星图,5=快手代理商,6=巨量星图,7=巨量服务单,8=腾讯服务单,9=腾讯营销K2,10=腾讯营销K3)",
|
||||
)
|
||||
status: int | None = Field(None, ge=1, le=2, description="应用状态(1=正常,2=禁用)")
|
||||
count: int | None = Field(None, ge=1, description="应用最大可以授权多少个用户")
|
||||
auth_url: str | None = Field(None, max_length=256, description="应用授权链接")
|
||||
company: str | None = Field(None, max_length=256, description="应用归属公司名称")
|
||||
|
||||
|
||||
class UserOAuthAppOut(BaseModel):
|
||||
id: str = Field(..., description="主键")
|
||||
app_id: str = Field(..., description="应用id")
|
||||
secret: str = Field(..., description="应用密钥")
|
||||
status: int = Field(..., description="状态,1=正常,2=禁用")
|
||||
count: int = Field(..., description="应用最大可以授权多少个用户")
|
||||
open_type: int = Field(..., description="开户方式")
|
||||
auth_url: str | None = Field(None, description="应用授权链接")
|
||||
company: str | None = Field(None, description="应用归属公司名称")
|
||||
create_by: str | None = Field(None, description="创建者")
|
||||
created_at: NaiveDatetime = Field(..., description="创建时间")
|
||||
updated_at: NaiveDatetime = Field(..., description="更新时间")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -20,6 +20,7 @@ CHARGE_MEDIA = "media"
|
||||
|
||||
OWNER_GENERATION_RECORD = "generation_record"
|
||||
OWNER_CHAT_GENERATION_TASK = "chat_generation_task"
|
||||
OWNER_MODULE_GENERATION_STEP = "module_generation_step"
|
||||
|
||||
_BIZ_KEY_PATTERN = re.compile(
|
||||
r"^(?P<owner_type>[^:]+):(?P<owner_id>[^:]+):attempt:(?P<attempt_no>\d+):(?P<charge_kind>[^:]+):(?P<action>charge|refund)$"
|
||||
@@ -287,6 +288,45 @@ async def charge_chatapi_prompt_usage(
|
||||
return BillingSummary(record_id=record.id, user_id=record.user_id, items=items)
|
||||
|
||||
|
||||
async def charge_module_prompt_usage(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
step_id: str,
|
||||
usage: Mapping[str, Any],
|
||||
description: str,
|
||||
attempt_no: int = 1,
|
||||
) -> BillingSummary:
|
||||
"""爆款开头复刻模块图片/视频 AI 提词扣文本积分。
|
||||
|
||||
文本提词属于已经发生的 LLM 消费:
|
||||
- 调用成功后按 input_tokens + output_tokens 扣费。
|
||||
- 不参与后续图片/视频媒体生成失败退款。
|
||||
- 通过 module_generation_step:{step_id}:attempt:1:text_prompt:charge 幂等。
|
||||
"""
|
||||
input_tokens = _safe_int(usage.get("input_tokens"))
|
||||
output_tokens = _safe_int(usage.get("output_tokens"))
|
||||
text_credits = await calc_text_credits(db, input_tokens, output_tokens)
|
||||
biz_key = build_credit_biz_key(
|
||||
owner_type=OWNER_MODULE_GENERATION_STEP,
|
||||
owner_id=step_id,
|
||||
attempt_no=attempt_no,
|
||||
charge_kind=CHARGE_TEXT_PROMPT,
|
||||
action="charge",
|
||||
)
|
||||
item = await deduct_credits_locked_once(
|
||||
db,
|
||||
user_id=user_id,
|
||||
amount=text_credits,
|
||||
description=description,
|
||||
related_id=step_id,
|
||||
charge_key=CHARGE_TEXT_PROMPT,
|
||||
biz_key=biz_key,
|
||||
attempt_no=attempt_no,
|
||||
)
|
||||
return BillingSummary(record_id=step_id, user_id=user_id, items=[item])
|
||||
|
||||
|
||||
async def charge_generation_media_by_params(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.chat_generation_task import ChatGenerationTask
|
||||
|
||||
|
||||
async def notify_chat_generation_task_finished(db: AsyncSession, task: ChatGenerationTask) -> None:
|
||||
"""通知业务模块 ChatGenerationTask 已进入终态。
|
||||
|
||||
当前用于爆款开头复刻:
|
||||
- image_generate 完成后自动进入 video_prompt_optimize
|
||||
- video_generate 完成后总任务完成
|
||||
"""
|
||||
if not task:
|
||||
return
|
||||
if task.generation_mode == "hot_opening_replicate":
|
||||
from app.services.hot_opening_replicate_service import (
|
||||
handle_chat_generation_task_completed,
|
||||
handle_chat_generation_task_failed,
|
||||
)
|
||||
if task.status == "completed":
|
||||
await handle_chat_generation_task_completed(db, task)
|
||||
elif task.status == "failed":
|
||||
await handle_chat_generation_task_failed(db, task)
|
||||
@@ -17,10 +17,13 @@ from app.services.celery_download_recovery_service import (
|
||||
remove_download_active,
|
||||
)
|
||||
from app.services.generation_log_service import log_task_event
|
||||
from app.services.generation_module_hook_service import notify_chat_generation_task_finished
|
||||
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
|
||||
|
||||
logger = logging.getLogger("video_gen")
|
||||
|
||||
ALLOWED_GENERATION_MODES = {"chatapi_async", "hot_opening_replicate"}
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
@@ -73,7 +76,7 @@ async def recover_one_download_task(
|
||||
|
||||
if not task:
|
||||
return "skip_missing_task"
|
||||
if task.generation_mode != "chatapi_async":
|
||||
if task.generation_mode not in ALLOWED_GENERATION_MODES:
|
||||
await remove_download_active(task.id)
|
||||
return "clean_invalid_mode"
|
||||
if _is_final_task_state(task):
|
||||
@@ -218,7 +221,7 @@ async def recover_download_tasks_once(db: AsyncSession) -> dict[str, Any]:
|
||||
select(ChatGenerationTask)
|
||||
.where(
|
||||
ChatGenerationTask.deleted_at.is_(None),
|
||||
ChatGenerationTask.generation_mode == "chatapi_async",
|
||||
ChatGenerationTask.generation_mode.in_(["chatapi_async", "hot_opening_replicate"]),
|
||||
ChatGenerationTask.status == "generating",
|
||||
ChatGenerationTask.remote_result_url.is_not(None),
|
||||
ChatGenerationTask.pipeline_stage.in_(
|
||||
@@ -263,7 +266,7 @@ async def recover_generation_tasks_once(db: AsyncSession) -> dict[str, Any]:
|
||||
select(ChatGenerationTask)
|
||||
.where(
|
||||
ChatGenerationTask.deleted_at.is_(None),
|
||||
ChatGenerationTask.generation_mode == "chatapi_async",
|
||||
ChatGenerationTask.generation_mode.in_(["chatapi_async", "hot_opening_replicate"]),
|
||||
ChatGenerationTask.status == "generating",
|
||||
ChatGenerationTask.pipeline_stage.in_(
|
||||
[
|
||||
@@ -290,6 +293,7 @@ async def recover_generation_tasks_once(db: AsyncSession) -> dict[str, Any]:
|
||||
error_message="任务超时",
|
||||
pipeline_stage="timeout",
|
||||
)
|
||||
await notify_chat_generation_task_finished(db, task)
|
||||
await db.commit()
|
||||
await log_task_event(
|
||||
task,
|
||||
|
||||
@@ -180,7 +180,7 @@ async def mark_chat_generation_task_failed_and_refund_once(
|
||||
select(ChatGenerationTask)
|
||||
.where(
|
||||
ChatGenerationTask.id == task_id,
|
||||
ChatGenerationTask.generation_mode == "chatapi_async",
|
||||
ChatGenerationTask.generation_mode.in_(["chatapi_async", "hot_opening_replicate"]),
|
||||
ChatGenerationTask.deleted_at.is_(None),
|
||||
)
|
||||
.with_for_update()
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.chat_generation_task import ChatGenerationTask
|
||||
from app.models.user import User
|
||||
from app.schemas.generation_ai import GenerationAIReference, GenerationAITaskCreate
|
||||
from app.services.generation_ai_service import (
|
||||
IMAGE_DEFAULT_PROPORTION,
|
||||
IMAGE_DEFAULT_PX,
|
||||
IMAGE_DEFAULT_SIZE,
|
||||
VIDEO_DEFAULT_RATIO,
|
||||
VIDEO_DEFAULT_RESOLUTION,
|
||||
_build_image_snapshot,
|
||||
_build_video_snapshot,
|
||||
_get_image_engine,
|
||||
_get_video_engine,
|
||||
_image_supported_sizes,
|
||||
_parse_list,
|
||||
normalize_px,
|
||||
)
|
||||
from app.services.generation_billing_service import OWNER_CHAT_GENERATION_TASK, charge_generation_media_by_params
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
|
||||
def _json(data: Any) -> str | None:
|
||||
if data is None:
|
||||
return None
|
||||
return json.dumps(data, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _build_backend_idempotency_key(*, generation_mode: str, gen_type: str, task_id: str) -> str:
|
||||
"""模块生成关联 ChatGenerationTask 的幂等键由后端生成。
|
||||
|
||||
不再接收前端透传,避免 user_id + generation_mode + idempotency_key
|
||||
唯一索引被前端固定 key 或重复 key 拦截。
|
||||
"""
|
||||
return f"{generation_mode}:{gen_type}:{task_id}"[:64]
|
||||
|
||||
|
||||
async def create_chat_generation_task_for_module(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
generation_mode: str,
|
||||
gen_type: str,
|
||||
original_prompt: str,
|
||||
optimized_prompt: str | None = None,
|
||||
engine_id: str | None = None,
|
||||
media_references: list[dict[str, Any]] | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
image_size: str | None = None,
|
||||
image_proportion: str | None = None,
|
||||
image_px: str | None = None,
|
||||
duration: int | None = None,
|
||||
aspect_ratio: str | None = None,
|
||||
resolution: str | None = None,
|
||||
billing_project_name: str = "模块生成任务",
|
||||
billing_description_prefix: str = "模块生成-",
|
||||
) -> ChatGenerationTask:
|
||||
"""创建可复用的 ChatGenerationTask 子任务。
|
||||
|
||||
和 /generation-ai 普通任务不同,generation_mode 由业务模块传入,
|
||||
但仍复用同一套引擎校验、扣费、Celery 创建/轮询/下载逻辑。
|
||||
"""
|
||||
gen_type = gen_type.lower().strip()
|
||||
if gen_type not in ("image", "video"):
|
||||
raise HTTPException(status_code=400, detail="gen_type 仅支持 image 或 video")
|
||||
|
||||
task_id = generate_id()
|
||||
now = datetime.now(timezone.utc)
|
||||
refs = media_references or []
|
||||
backend_idempotency_key = _build_backend_idempotency_key(
|
||||
generation_mode=generation_mode,
|
||||
gen_type=gen_type,
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
if gen_type == "image":
|
||||
engine = await _get_image_engine(db, engine_id)
|
||||
sizes = _image_supported_sizes(engine)
|
||||
size = image_size or engine.default_size or IMAGE_DEFAULT_SIZE
|
||||
proportion = image_proportion or IMAGE_DEFAULT_PROPORTION
|
||||
px = normalize_px(image_px)
|
||||
if sizes:
|
||||
if size not in sizes:
|
||||
raise HTTPException(status_code=400, detail=f"图片分辨率档位不支持: {size}")
|
||||
if proportion not in sizes.get(size, {}):
|
||||
raise HTTPException(status_code=400, detail=f"图片比例不支持: {proportion}")
|
||||
px = px or normalize_px((sizes.get(size) or {}).get(proportion))
|
||||
px = px or IMAGE_DEFAULT_PX
|
||||
media_billing = await charge_generation_media_by_params(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
record_id=task_id,
|
||||
gen_type="image",
|
||||
image_size=size,
|
||||
engine_id=engine.id,
|
||||
project_name=billing_project_name,
|
||||
description_prefix=billing_description_prefix,
|
||||
owner_type=OWNER_CHAT_GENERATION_TASK,
|
||||
attempt_no=1,
|
||||
)
|
||||
snapshot = _build_image_snapshot(engine, size, proportion, px)
|
||||
task = ChatGenerationTask(
|
||||
id=task_id,
|
||||
user_id=current_user.id,
|
||||
original_prompt=original_prompt,
|
||||
optimized_prompt=optimized_prompt,
|
||||
gen_type="image",
|
||||
image_size=size,
|
||||
image_proportion=proportion,
|
||||
image_px=px,
|
||||
status="generating",
|
||||
generation_mode=generation_mode,
|
||||
pipeline_stage="queued",
|
||||
engine_id=engine.id,
|
||||
engine_snapshot_json=_json(snapshot),
|
||||
media_references=_json(refs) if refs else None,
|
||||
credits_cost=round(media_billing.total_charged, 2),
|
||||
idempotency_key=backend_idempotency_key,
|
||||
deadline_at=now + timedelta(minutes=settings.CHATAPI_ASYNC_IMAGE_DEADLINE_MINUTES),
|
||||
)
|
||||
else:
|
||||
engine = await _get_video_engine(db, engine_id)
|
||||
ratio = aspect_ratio or VIDEO_DEFAULT_RATIO
|
||||
selected_resolution = resolution or VIDEO_DEFAULT_RESOLUTION
|
||||
selected_duration = duration or 4
|
||||
ratios = _parse_list(engine.supported_ratios, [])
|
||||
resolutions = _parse_list(engine.supported_resolutions, [])
|
||||
durations = _parse_list(engine.supported_durations, [])
|
||||
if ratios and ratio not in ratios:
|
||||
raise HTTPException(status_code=400, detail=f"视频比例不支持: {ratio}")
|
||||
if resolutions and selected_resolution not in resolutions:
|
||||
raise HTTPException(status_code=400, detail=f"视频分辨率不支持: {selected_resolution}")
|
||||
if durations and selected_duration not in durations:
|
||||
raise HTTPException(status_code=400, detail=f"视频时长不支持: {selected_duration}")
|
||||
if engine.max_duration and selected_duration > engine.max_duration:
|
||||
raise HTTPException(status_code=400, detail=f"视频时长不能超过 {engine.max_duration} 秒")
|
||||
media_billing = await charge_generation_media_by_params(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
record_id=task_id,
|
||||
gen_type="video",
|
||||
duration=selected_duration,
|
||||
resolution=selected_resolution,
|
||||
engine_id=engine.id,
|
||||
project_name=billing_project_name,
|
||||
description_prefix=billing_description_prefix,
|
||||
owner_type=OWNER_CHAT_GENERATION_TASK,
|
||||
attempt_no=1,
|
||||
)
|
||||
snapshot = _build_video_snapshot(engine, ratio, selected_resolution, selected_duration)
|
||||
task = ChatGenerationTask(
|
||||
id=task_id,
|
||||
user_id=current_user.id,
|
||||
original_prompt=original_prompt,
|
||||
optimized_prompt=optimized_prompt,
|
||||
gen_type="video",
|
||||
duration=selected_duration,
|
||||
aspect_ratio=ratio,
|
||||
resolution=selected_resolution,
|
||||
image_size=image_size or IMAGE_DEFAULT_SIZE,
|
||||
image_proportion=image_proportion or IMAGE_DEFAULT_PROPORTION,
|
||||
image_px=normalize_px(image_px) or IMAGE_DEFAULT_PX,
|
||||
status="generating",
|
||||
generation_mode=generation_mode,
|
||||
pipeline_stage="queued",
|
||||
engine_id=engine.id,
|
||||
engine_snapshot_json=_json(snapshot),
|
||||
media_references=_json(refs) if refs else None,
|
||||
credits_cost=round(media_billing.total_charged, 2),
|
||||
idempotency_key=backend_idempotency_key,
|
||||
deadline_at=now + timedelta(minutes=settings.CHATAPI_ASYNC_VIDEO_DEADLINE_MINUTES),
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
return task
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,707 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.enums.video_prompt_schema import PromptSchemaVersionEnum, VideoPromptSchemaUsageEnum
|
||||
from app.models.model_config import ModelConfig
|
||||
from app.models.token_usage import TokenUsage
|
||||
from app.utils.id_gen import generate_id
|
||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||
|
||||
DEFAULT_FRAME_RATE = "30fps"
|
||||
DEFAULT_REFERENCE_VIDEO_FPS = 1
|
||||
|
||||
CLIENT_SCHEMA_V1: dict[str, Any] = {
|
||||
"schema_version": PromptSchemaVersionEnum.CLIENT_V1.value,
|
||||
"schema_usage": VideoPromptSchemaUsageEnum.CLIENT_DISPLAY.value,
|
||||
"基础分类": {
|
||||
"生成类型": "文生视频/图生视频/视频生视频/数字人视频/无",
|
||||
"视频大类": "产品广告视频/电商带货视频/口播讲解视频/剧情视频/教程视频/风景旅行视频/美食视频/宠物视频/动漫卡通视频/游戏视频/企业宣传视频/新闻资讯视频/直播切片视频/图文快闪视频/音乐舞蹈视频/运动健身视频/无",
|
||||
"视频子类": "产品推广短视频/口播讲解/电商带货/剧情演绎/操作教程/旅行风景/美食展示/宠物互动/二次元动画/游戏宣传/企业介绍/资讯播报/直播高光/图文快闪/无",
|
||||
"视频用途": "广告投放/社媒发布/产品展示/课程教学/品牌宣传/娱乐内容/信息科普/无",
|
||||
"目标平台": "抖音/快手/小红书/微信视频号/B站/TikTok/YouTube Shorts/Instagram Reels/无",
|
||||
},
|
||||
"素材理解": {
|
||||
"是否有参考图片": "是/否",
|
||||
"是否有参考视频": "是/否",
|
||||
"参考视频用途": "动作参考/镜头参考/风格参考/运镜参考/节奏参考/无",
|
||||
"需要保留": [],
|
||||
"允许改动": [],
|
||||
"禁止改动": [],
|
||||
},
|
||||
"业务属性": {
|
||||
"产品类型": "APP/实物商品/食品/服饰/美妆/电子产品/汽车/房产/课程/服务/无",
|
||||
"产品名称": "无",
|
||||
"品牌名称": "无",
|
||||
"核心卖点": [],
|
||||
"目标受众": "无",
|
||||
"核心表达目标": "无",
|
||||
"内容风格": "无",
|
||||
"行动引导": "立即体验/立即下载/立即购买/点击了解/预约咨询/无",
|
||||
},
|
||||
"画面属性": {
|
||||
"视频时长": "无",
|
||||
"视频比例": "无",
|
||||
"清晰度": "无",
|
||||
"帧率": "无",
|
||||
"主体描述": "无",
|
||||
"主体数量": "无",
|
||||
"主体位置": "无",
|
||||
"主体占比": "无",
|
||||
"场景描述": "无",
|
||||
"构图方式": "无",
|
||||
"画面风格": "无",
|
||||
"光影色彩": "无",
|
||||
},
|
||||
"动作流程": [],
|
||||
"镜头流程": [],
|
||||
"字幕与口播": {
|
||||
"是否需要字幕": "是/否",
|
||||
"字幕内容": [],
|
||||
"字幕位置": "无",
|
||||
"字幕样式": "无",
|
||||
"是否口播": "是/否",
|
||||
"口播内容": "无",
|
||||
"口播语气": "无",
|
||||
"口播语速": "无",
|
||||
"是否需要口型同步": "是/否/无",
|
||||
},
|
||||
"音频与节奏": {
|
||||
"背景音乐": "无",
|
||||
"音乐风格": "无",
|
||||
"音乐节奏": "无",
|
||||
"环境音": "无",
|
||||
"动作音效": "无",
|
||||
"整体节奏": "慢节奏/中等节奏/快节奏/卡点节奏/无",
|
||||
},
|
||||
"合规控制": {
|
||||
"是否广告": "是/否",
|
||||
"风险等级": "低/中/高",
|
||||
"安全表达": "无",
|
||||
"禁用词": [],
|
||||
"合规说明": "无",
|
||||
},
|
||||
"质量控制": {
|
||||
"主体一致性": "低/中/高/无",
|
||||
"产品一致性": "低/中/高/无",
|
||||
"动作自然度": "低/中/高/无",
|
||||
"镜头稳定性": "低/中/高/无",
|
||||
"字幕准确性": "低/中/高/无",
|
||||
},
|
||||
"最终提示词": {
|
||||
"主提示词": "无",
|
||||
"动作提示词": "无",
|
||||
"镜头提示词": "无",
|
||||
"字幕提示词": "无",
|
||||
"音频提示词": "无",
|
||||
"风格提示词": "无",
|
||||
"负面提示词": "无",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _safe_list(value: Any) -> list[Any]:
|
||||
return value if isinstance(value, list) else []
|
||||
|
||||
|
||||
def _format_options(options: list[Any], fallback: str = "无") -> str:
|
||||
values = [str(item) for item in _safe_list(options) if str(item).strip()]
|
||||
return "/".join(values) if values else fallback
|
||||
|
||||
|
||||
def get_recommended_resolution(video_ratio: str, resolution: str) -> str:
|
||||
"""按比例和清晰度粗略计算推荐像素,不在服务内维护固定比例/分辨率白名单。"""
|
||||
try:
|
||||
width_ratio, height_ratio = [float(x) for x in str(video_ratio).split(":", 1)]
|
||||
short_edge = int(str(resolution).lower().replace("p", ""))
|
||||
if width_ratio >= height_ratio:
|
||||
height = short_edge
|
||||
width = round(short_edge * width_ratio / height_ratio)
|
||||
else:
|
||||
width = short_edge
|
||||
height = round(short_edge * height_ratio / width_ratio)
|
||||
return f"{width}x{height}"
|
||||
except Exception:
|
||||
return "无"
|
||||
|
||||
|
||||
def _build_scaled_bounds(duration: int, ratios: list[float]) -> list[int]:
|
||||
duration = max(1, int(duration))
|
||||
raw = [0]
|
||||
acc = 0.0
|
||||
for ratio in ratios[:-1]:
|
||||
acc += ratio
|
||||
raw.append(max(raw[-1] + 1, min(duration - 1, round(duration * acc))))
|
||||
raw.append(duration)
|
||||
for index in range(1, len(raw)):
|
||||
if raw[index] <= raw[index - 1]:
|
||||
raw[index] = min(duration, raw[index - 1] + 1)
|
||||
raw[-1] = duration
|
||||
return raw
|
||||
|
||||
|
||||
def _bounds_to_plan(bounds: list[int], stages: list[tuple[str, str]]) -> list[dict[str, str]]:
|
||||
plan: list[dict[str, str]] = []
|
||||
for idx, (stage, desc) in enumerate(stages):
|
||||
start = bounds[idx]
|
||||
end = bounds[idx + 1]
|
||||
plan.append({"时间段": f"{start}-{end}秒", "阶段": stage, "说明": desc})
|
||||
return plan
|
||||
|
||||
|
||||
def build_time_plan(duration: int) -> list[dict[str, str]]:
|
||||
duration = max(1, int(duration))
|
||||
if duration <= 5:
|
||||
return _bounds_to_plan(
|
||||
_build_scaled_bounds(duration, [0.2, 0.4, 0.4]),
|
||||
[
|
||||
("开场吸引", "快速建立主体、产品和画面风格"),
|
||||
("核心展示", "展示主体动作、核心卖点或主要视觉内容"),
|
||||
("行动引导", "强化记忆点并给出转化引导"),
|
||||
],
|
||||
)
|
||||
if duration <= 8:
|
||||
return _bounds_to_plan(
|
||||
_build_scaled_bounds(duration, [0.15, 0.3, 0.35, 0.2]),
|
||||
[
|
||||
("开场吸引", "快速吸引注意力"),
|
||||
("主体展示", "展示主体和产品关系"),
|
||||
("核心卖点", "突出新项目核心内容点"),
|
||||
("收尾引导", "给出行动引导并稳定落版"),
|
||||
],
|
||||
)
|
||||
return _bounds_to_plan(
|
||||
_build_scaled_bounds(duration, [0.13, 0.2, 0.27, 0.25, 0.15]),
|
||||
[
|
||||
("爆款开头", "复刻参考素材开头节奏和视觉吸引点"),
|
||||
("主体建立", "明确新项目主体和产品信息"),
|
||||
("卖点放大", "围绕核心内容点展开动作和镜头"),
|
||||
("情绪推进", "用动作、字幕或镜头变化强化记忆"),
|
||||
("转化收尾", "给出清晰行动引导"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def build_dynamic_schema(video_config: dict[str, Any]) -> dict[str, Any]:
|
||||
schema = copy.deepcopy(CLIENT_SCHEMA_V1)
|
||||
duration = int(video_config["duration"])
|
||||
video_ratio = str(video_config["aspect_ratio"])
|
||||
resolution = str(video_config["resolution"])
|
||||
frame_rate = str(video_config.get("frame_rate") or DEFAULT_FRAME_RATE)
|
||||
recommended_resolution = get_recommended_resolution(video_ratio, resolution)
|
||||
|
||||
schema["画面属性"].update(
|
||||
{
|
||||
"视频时长": f"{duration}秒",
|
||||
"视频比例": video_ratio,
|
||||
"清晰度": resolution,
|
||||
"推荐分辨率": recommended_resolution,
|
||||
"帧率": frame_rate,
|
||||
}
|
||||
)
|
||||
schema["动态时间规划"] = build_time_plan(duration)
|
||||
schema["输出规格限制"] = {
|
||||
"支持时长": _safe_list(video_config.get("supported_durations")),
|
||||
"支持比例": _safe_list(video_config.get("supported_ratios")),
|
||||
"支持分辨率": _safe_list(video_config.get("supported_resolutions")),
|
||||
"当前推荐分辨率": recommended_resolution,
|
||||
}
|
||||
return schema
|
||||
|
||||
|
||||
def infer_generation_type(references: list[dict[str, str]] | None) -> str:
|
||||
has_image = any(item.get("type") == "image" for item in references or [])
|
||||
has_video = any(item.get("type") == "video" for item in references or [])
|
||||
if has_image and has_video:
|
||||
return "图生视频/视频生视频"
|
||||
if has_image:
|
||||
return "图生视频"
|
||||
if has_video:
|
||||
return "视频生视频"
|
||||
return "文生视频"
|
||||
|
||||
|
||||
def infer_video_category(text: str) -> tuple[str, str, list[str]]:
|
||||
text = text or ""
|
||||
if any(key in text for key in ["APP", "应用", "下载", "社交", "脱单", "附近"]):
|
||||
return "产品广告视频", "产品推广短视频", ["产品推广", "用户转化", "核心卖点展示"]
|
||||
return "产品广告视频", "产品推广短视频", ["产品展示", "视觉吸引", "行动引导"]
|
||||
|
||||
|
||||
def build_system_prompt() -> str:
|
||||
return (
|
||||
"你是专业短视频广告导演和AI视频提示词工程师。"
|
||||
"你必须只输出一个合法 JSON 对象,不能输出 Markdown。"
|
||||
"输出必须严格遵循用户提供的 schema 顶层结构。"
|
||||
"所有未知、无法判断或不适用的字段填写'无',数组字段可填写 []。"
|
||||
"必须根据参考视频复刻爆款开头的节奏、构图、动作和镜头语言,但不能照抄品牌、水印、字幕或侵权元素。"
|
||||
)
|
||||
|
||||
|
||||
def build_user_text(
|
||||
*,
|
||||
source_project_name: str,
|
||||
target_project_name: str,
|
||||
core_content_point: str,
|
||||
target_platform: str,
|
||||
references: list[dict[str, str]],
|
||||
video_config: dict[str, Any],
|
||||
client_schema: dict[str, Any],
|
||||
) -> str:
|
||||
duration = int(video_config["duration"])
|
||||
video_ratio = str(video_config["aspect_ratio"])
|
||||
resolution = str(video_config["resolution"])
|
||||
frame_rate = str(video_config.get("frame_rate") or DEFAULT_FRAME_RATE)
|
||||
category, sub_category, default_points = infer_video_category(" ".join([source_project_name, target_project_name, core_content_point]))
|
||||
return json.dumps(
|
||||
{
|
||||
"任务": "基于参考素材视频和新项目图片,生成可用于AI视频生成的中文结构化提示词JSON",
|
||||
"业务输入": {
|
||||
"视频素材内容项目名称": source_project_name,
|
||||
"生成项目名称": target_project_name,
|
||||
"生成项目核心内容点": core_content_point,
|
||||
"目标平台": target_platform,
|
||||
"默认视频大类": category,
|
||||
"默认视频子类": sub_category,
|
||||
"建议核心卖点": default_points,
|
||||
},
|
||||
"视频规格": {
|
||||
"视频时长": f"{duration}秒",
|
||||
"视频比例": video_ratio,
|
||||
"清晰度": resolution,
|
||||
"帧率": frame_rate,
|
||||
"支持时长": _safe_list(video_config.get("supported_durations")),
|
||||
"支持比例": _safe_list(video_config.get("supported_ratios")),
|
||||
"支持分辨率": _safe_list(video_config.get("supported_resolutions")),
|
||||
"推荐分辨率": get_recommended_resolution(video_ratio, resolution),
|
||||
},
|
||||
"参考素材": references,
|
||||
"输出要求": {
|
||||
"生成类型": infer_generation_type(references),
|
||||
"必须填充动态时间规划": build_time_plan(duration),
|
||||
"必须填充动作流程": "动作流程时间段必须覆盖完整视频时长",
|
||||
"必须填充镜头流程": "镜头流程时间段必须覆盖完整视频时长",
|
||||
"最终提示词限制": "最终提示词下所有字段都不能写入视频时长、秒数、视频比例、清晰度、分辨率、帧率、推荐像素、竖屏、横屏等视频规格参数,这些规格只能写在画面属性/动态时间规划/输出规格限制。",
|
||||
"禁止": ["输出 Markdown", "输出 schema 之外的解释文字", "照抄参考素材品牌水印", "生成违法违规内容", "在最终提示词中写入秒数/比例/分辨率/帧率"],
|
||||
},
|
||||
"必须按此schema输出": client_schema,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def build_user_message(user_content: str, references: list[dict[str, str]], reference_video_fps: int) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
content_parts: list[dict[str, Any]] = [{"type": "text", "text": user_content}]
|
||||
log_content_parts: list[dict[str, Any]] = [{"type": "text", "text": user_content}]
|
||||
for ref in references:
|
||||
ref_type = ref.get("type")
|
||||
ref_url = ref.get("url")
|
||||
if not ref_url:
|
||||
continue
|
||||
if ref_type == "image":
|
||||
content_parts.append({"type": "image_url", "image_url": {"url": ref_url}})
|
||||
log_content_parts.append({"type": "image_url", "image_url": {"url": ref_url}})
|
||||
elif ref_type == "video":
|
||||
content_parts.append({"type": "video_url", "video_url": {"url": ref_url, "fps": reference_video_fps}})
|
||||
log_content_parts.append({"type": "video_url", "video_url": {"url": ref_url, "fps": reference_video_fps}})
|
||||
return {"role": "user", "content": content_parts}, {"role": "user", "content": log_content_parts}
|
||||
|
||||
|
||||
def strip_json_code_fence(text: str) -> str:
|
||||
text = (text or "").strip()
|
||||
if text.startswith("```"):
|
||||
text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I)
|
||||
text = re.sub(r"\s*```$", "", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def parse_model_json(content: str) -> dict[str, Any]:
|
||||
content = strip_json_code_fence(content)
|
||||
data = json.loads(content)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("视频提词优化返回值不是 JSON 对象")
|
||||
return data
|
||||
|
||||
|
||||
def fill_none_with_wu(value: Any) -> Any:
|
||||
if value is None or value == "":
|
||||
return "无"
|
||||
if isinstance(value, dict):
|
||||
return {k: fill_none_with_wu(v) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [fill_none_with_wu(v) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
def ensure_top_keys(result: dict[str, Any]) -> dict[str, Any]:
|
||||
schema = copy.deepcopy(CLIENT_SCHEMA_V1)
|
||||
for key, default_value in schema.items():
|
||||
if key not in result:
|
||||
result[key] = default_value
|
||||
elif isinstance(default_value, dict) and isinstance(result.get(key), dict):
|
||||
merged = copy.deepcopy(default_value)
|
||||
merged.update(result[key])
|
||||
result[key] = merged
|
||||
return result
|
||||
|
||||
|
||||
def ensure_flow_matches_time_plan(result: dict[str, Any], duration: int) -> dict[str, Any]:
|
||||
plan = build_time_plan(duration)
|
||||
if not isinstance(result.get("动作流程"), list) or not result["动作流程"]:
|
||||
result["动作流程"] = [
|
||||
{"时间段": item["时间段"], "动作内容": item["说明"]}
|
||||
for item in plan
|
||||
]
|
||||
if not isinstance(result.get("镜头流程"), list) or not result["镜头流程"]:
|
||||
result["镜头流程"] = [
|
||||
{"时间段": item["时间段"], "镜头内容": item["说明"]}
|
||||
for item in plan
|
||||
]
|
||||
result["动作流程"] = _align_flow_time_ranges(result["动作流程"], plan, "动作内容")
|
||||
result["镜头流程"] = _align_flow_time_ranges(result["镜头流程"], plan, "镜头内容")
|
||||
result["动态时间规划"] = plan
|
||||
return result
|
||||
|
||||
|
||||
def ensure_negative_prompt(result: dict[str, Any]) -> dict[str, Any]:
|
||||
final = result.setdefault("最终提示词", {})
|
||||
if not isinstance(final, dict):
|
||||
final = {}
|
||||
result["最终提示词"] = final
|
||||
if not final.get("负面提示词") or final.get("负面提示词") == "无":
|
||||
final["负面提示词"] = "画面模糊、主体畸变、手指畸形、脸部崩坏、字幕乱码、产品变形、镜头抖动、画面闪烁"
|
||||
return result
|
||||
|
||||
|
||||
def build_final_video_prompt(result: dict[str, Any]) -> str:
|
||||
final = result.get("最终提示词", {}) if isinstance(result.get("最终提示词"), dict) else {}
|
||||
parts = [
|
||||
final.get("主提示词"),
|
||||
final.get("动作提示词"),
|
||||
final.get("镜头提示词"),
|
||||
final.get("字幕提示词"),
|
||||
final.get("音频提示词"),
|
||||
final.get("风格提示词"),
|
||||
]
|
||||
return "\n".join(str(item).strip() for item in parts if item and str(item).strip() != "无")
|
||||
|
||||
|
||||
VIDEO_SPEC_PROMPT_KEYS = ("主提示词", "动作提示词", "镜头提示词", "字幕提示词", "音频提示词", "风格提示词", "负面提示词")
|
||||
|
||||
|
||||
def _normalize_prompt_text(text: str) -> str:
|
||||
text = re.sub(r"[,、,;;::]\s*([,、,;;::])", r"\1", text)
|
||||
text = re.sub(r"\s{2,}", " ", text)
|
||||
text = re.sub(r"^[,、,;;::\s]+", "", text)
|
||||
text = re.sub(r"[,、,;;::\s]+$", "", text)
|
||||
return text.strip() or "无"
|
||||
|
||||
|
||||
def clean_video_spec_from_prompt_text(text: Any, video_config: dict[str, Any] | None = None) -> str:
|
||||
"""清洗最终提示词里的视频规格参数。
|
||||
|
||||
视频时长、比例、清晰度、分辨率、帧率属于接口参数和锁定字段,
|
||||
不能混入最终提示词,避免用户绕过扣费参数或与第5步视频生成参数冲突。
|
||||
"""
|
||||
if text is None:
|
||||
return "无"
|
||||
value = str(text).strip()
|
||||
if not value or value == "无":
|
||||
return "无"
|
||||
|
||||
cfg = video_config or {}
|
||||
exact_values = {
|
||||
str(cfg.get("aspect_ratio") or "").strip(),
|
||||
str(cfg.get("resolution") or "").strip(),
|
||||
str(cfg.get("frame_rate") or "").strip(),
|
||||
}
|
||||
try:
|
||||
if cfg.get("duration") is not None:
|
||||
exact_values.add(f"{int(cfg.get('duration'))}秒")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if cfg.get("aspect_ratio") and cfg.get("resolution"):
|
||||
exact_values.add(get_recommended_resolution(str(cfg.get("aspect_ratio")), str(cfg.get("resolution"))))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for item in sorted((v for v in exact_values if v and v != "无"), key=len, reverse=True):
|
||||
value = value.replace(item, "")
|
||||
|
||||
patterns = [
|
||||
r"\d+\s*秒",
|
||||
r"\b\d+\s*[sS]\b",
|
||||
r"\d+\s*[::]\s*\d+",
|
||||
r"\d{3,4}\s*[pP]",
|
||||
r"\d{2,4}\s*[xX×]\s*\d{2,4}",
|
||||
r"\d+\s*(?:fps|FPS|帧)",
|
||||
r"(?:竖屏|横屏|方屏|超清|高清|标清|蓝光|4K|8K)",
|
||||
r"(?:视频时长|时长|视频比例|画面比例|比例|分辨率|清晰度|帧率|推荐分辨率)\s*[::]?\s*",
|
||||
]
|
||||
for pattern in patterns:
|
||||
value = re.sub(pattern, "", value, flags=re.I)
|
||||
return _normalize_prompt_text(value)
|
||||
|
||||
|
||||
def clean_final_prompt_specs(schema: dict[str, Any], video_config: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
final = schema.setdefault("最终提示词", {})
|
||||
if not isinstance(final, dict):
|
||||
final = {}
|
||||
schema["最终提示词"] = final
|
||||
for key in VIDEO_SPEC_PROMPT_KEYS:
|
||||
final[key] = clean_video_spec_from_prompt_text(final.get(key), video_config)
|
||||
return schema
|
||||
|
||||
|
||||
def _align_flow_time_ranges(flow: Any, plan: list[dict[str, str]], default_content_key: str) -> list[dict[str, Any]]:
|
||||
source = flow if isinstance(flow, list) else []
|
||||
aligned: list[dict[str, Any]] = []
|
||||
for index, plan_item in enumerate(plan):
|
||||
old_item = source[index] if index < len(source) and isinstance(source[index], dict) else {}
|
||||
item = dict(old_item)
|
||||
item["时间段"] = plan_item["时间段"]
|
||||
if not any(k in item and str(item.get(k)).strip() for k in (default_content_key, "动作", "镜头", "说明", "内容")):
|
||||
item[default_content_key] = plan_item["说明"]
|
||||
aligned.append(item)
|
||||
return aligned
|
||||
|
||||
|
||||
def _merge_editable_dict_fields(base: dict[str, Any], patch: dict[str, Any], allowed_keys: set[str]) -> None:
|
||||
for key in allowed_keys:
|
||||
if key in patch:
|
||||
base[key] = fill_none_with_wu(patch.get(key))
|
||||
|
||||
|
||||
def _merge_flow_patch(base_flow: Any, patch_flow: Any) -> list[dict[str, Any]]:
|
||||
base = [dict(item) for item in base_flow] if isinstance(base_flow, list) else []
|
||||
patch = patch_flow if isinstance(patch_flow, list) else []
|
||||
result: list[dict[str, Any]] = []
|
||||
for index, base_item in enumerate(base):
|
||||
merged = dict(base_item)
|
||||
patch_item = patch[index] if index < len(patch) and isinstance(patch[index], dict) else {}
|
||||
original_time_range = merged.get("时间段")
|
||||
for key, value in patch_item.items():
|
||||
if key == "时间段":
|
||||
continue
|
||||
merged[key] = fill_none_with_wu(value)
|
||||
merged["时间段"] = original_time_range
|
||||
result.append(merged)
|
||||
return result
|
||||
|
||||
|
||||
def apply_locked_video_schema_fields(schema: dict[str, Any], video_config: dict[str, Any]) -> dict[str, Any]:
|
||||
duration = int(video_config["duration"])
|
||||
video_ratio = str(video_config["aspect_ratio"])
|
||||
resolution = str(video_config["resolution"])
|
||||
frame_rate = str(video_config.get("frame_rate") or DEFAULT_FRAME_RATE)
|
||||
recommended_resolution = get_recommended_resolution(video_ratio, resolution)
|
||||
dynamic_schema = build_dynamic_schema(video_config)
|
||||
plan = build_time_plan(duration)
|
||||
|
||||
schema["schema_version"] = PromptSchemaVersionEnum.CLIENT_V1.value
|
||||
schema["schema_usage"] = VideoPromptSchemaUsageEnum.CLIENT_DISPLAY.value
|
||||
|
||||
frame = schema.setdefault("画面属性", {})
|
||||
if not isinstance(frame, dict):
|
||||
frame = {}
|
||||
schema["画面属性"] = frame
|
||||
frame.update(
|
||||
{
|
||||
"视频时长": f"{duration}秒",
|
||||
"视频比例": video_ratio,
|
||||
"清晰度": resolution,
|
||||
"帧率": frame_rate,
|
||||
"推荐分辨率": recommended_resolution,
|
||||
}
|
||||
)
|
||||
|
||||
schema["动态时间规划"] = plan
|
||||
schema["输出规格限制"] = dynamic_schema.get("输出规格限制", {})
|
||||
|
||||
schema["动作流程"] = _align_flow_time_ranges(schema.get("动作流程"), plan, "动作内容")
|
||||
schema["镜头流程"] = _align_flow_time_ranges(schema.get("镜头流程"), plan, "镜头内容")
|
||||
|
||||
# 合规和质量控制不能被前端降低;AI 返回缺失时使用服务端默认结构补齐。
|
||||
default_schema = copy.deepcopy(CLIENT_SCHEMA_V1)
|
||||
if not isinstance(schema.get("合规控制"), dict):
|
||||
schema["合规控制"] = default_schema["合规控制"]
|
||||
if not isinstance(schema.get("质量控制"), dict):
|
||||
schema["质量控制"] = default_schema["质量控制"]
|
||||
|
||||
return clean_final_prompt_specs(schema, video_config)
|
||||
|
||||
|
||||
def normalize_video_prompt_schema_from_ai(result: dict[str, Any], video_config: dict[str, Any]) -> dict[str, Any]:
|
||||
duration = int(video_config["duration"])
|
||||
normalized = ensure_top_keys(fill_none_with_wu(result if isinstance(result, dict) else {}))
|
||||
normalized = ensure_flow_matches_time_plan(normalized, duration)
|
||||
normalized = ensure_negative_prompt(normalized)
|
||||
return apply_locked_video_schema_fields(normalized, video_config)
|
||||
|
||||
|
||||
def patch_video_prompt_schema_from_client(
|
||||
*,
|
||||
server_schema: dict[str, Any],
|
||||
client_schema: dict[str, Any],
|
||||
video_config: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""以前端 JSON 作为 patch,回填到服务端已有 schema。
|
||||
|
||||
禁止整包覆盖:数组长度、时间段、视频规格、输出规格、质量控制、合规控制、schema 协议字段均以服务端为准。
|
||||
"""
|
||||
base = ensure_top_keys(fill_none_with_wu(copy.deepcopy(server_schema if isinstance(server_schema, dict) else {})))
|
||||
patch = client_schema if isinstance(client_schema, dict) else {}
|
||||
|
||||
for key in ("基础分类", "素材理解", "业务属性", "字幕与口播", "音频与节奏"):
|
||||
if isinstance(base.get(key), dict) and isinstance(patch.get(key), dict):
|
||||
base[key].update(fill_none_with_wu(patch[key]))
|
||||
|
||||
if isinstance(base.get("画面属性"), dict) and isinstance(patch.get("画面属性"), dict):
|
||||
_merge_editable_dict_fields(
|
||||
base["画面属性"],
|
||||
patch["画面属性"],
|
||||
{"主体描述", "主体数量", "主体位置", "主体占比", "场景描述", "构图方式", "画面风格", "光影色彩"},
|
||||
)
|
||||
|
||||
if isinstance(patch.get("动作流程"), list):
|
||||
base["动作流程"] = _merge_flow_patch(base.get("动作流程"), patch.get("动作流程"))
|
||||
if isinstance(patch.get("镜头流程"), list):
|
||||
base["镜头流程"] = _merge_flow_patch(base.get("镜头流程"), patch.get("镜头流程"))
|
||||
|
||||
# 动态时间规划保持服务端数组长度和时间段,只允许保留原值;不接受客户端 patch。
|
||||
|
||||
if isinstance(base.get("最终提示词"), dict) and isinstance(patch.get("最终提示词"), dict):
|
||||
for key in VIDEO_SPEC_PROMPT_KEYS:
|
||||
if key in patch["最终提示词"]:
|
||||
base["最终提示词"][key] = fill_none_with_wu(patch["最终提示词"].get(key))
|
||||
|
||||
return apply_locked_video_schema_fields(base, video_config)
|
||||
|
||||
|
||||
def _mock_result(video_config: dict[str, Any], target_platform: str) -> dict[str, Any]:
|
||||
duration = int(video_config["duration"])
|
||||
video_ratio = str(video_config["aspect_ratio"])
|
||||
resolution = str(video_config["resolution"])
|
||||
schema = build_dynamic_schema(video_config)
|
||||
schema["基础分类"].update({"生成类型": "图生视频/视频生视频", "视频大类": "产品广告视频", "视频子类": "产品推广短视频", "视频用途": "社媒发布", "目标平台": target_platform})
|
||||
schema["素材理解"].update({"是否有参考图片": "是", "是否有参考视频": "是", "参考视频用途": "动作参考/镜头参考/风格参考/节奏参考"})
|
||||
schema["业务属性"].update({"产品类型": "APP", "核心表达目标": "突出新项目核心内容点", "内容风格": "轻快、活泼、广告感适中", "行动引导": "立即体验"})
|
||||
schema["动作流程"] = [{"时间段": item["时间段"], "动作": item["阶段"], "说明": item["说明"]} for item in build_time_plan(duration)]
|
||||
schema["镜头流程"] = [{"时间段": item["时间段"], "镜头": item["阶段"], "说明": item["说明"]} for item in build_time_plan(duration)]
|
||||
schema["最终提示词"] = {
|
||||
"主提示词": f"生成一段{duration}秒、{video_ratio}、{resolution}的产品推广短视频,参考素材视频的爆款开头节奏,结合新项目图片进行自然展示。",
|
||||
"动作提示词": "主体动作自然,产品展示稳定,节奏轻快。",
|
||||
"镜头提示词": "镜头稳定,开头快速吸引注意,后续平滑推进。",
|
||||
"字幕提示词": "字幕简洁清晰,突出核心内容点。",
|
||||
"音频提示词": "轻快背景音乐,节奏自然。",
|
||||
"风格提示词": "年轻化、明亮、真实、广告感适中。",
|
||||
"负面提示词": "画面模糊、主体畸变、手指畸形、脸部崩坏、字幕乱码、产品变形、镜头抖动、画面闪烁",
|
||||
}
|
||||
return schema
|
||||
|
||||
|
||||
async def _select_model_config(db: AsyncSession) -> ModelConfig | None:
|
||||
result = await db.execute(select(ModelConfig).where(ModelConfig.is_active == True).order_by(ModelConfig.priority.desc()).limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def optimize_hot_opening_video_prompt(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
source_project_name: str,
|
||||
target_project_name: str,
|
||||
core_content_point: str,
|
||||
material_video_url: str,
|
||||
generated_image_url: str,
|
||||
video_config: dict[str, Any],
|
||||
target_platform: str = "抖音",
|
||||
) -> tuple[dict[str, Any], str, dict[str, Any]]:
|
||||
duration = int(video_config["duration"])
|
||||
references = [
|
||||
{"type": "video", "url": material_video_url},
|
||||
{"type": "image", "url": _build_file_url_or_data_uri(generated_image_url)},
|
||||
]
|
||||
client_schema = build_dynamic_schema(video_config)
|
||||
reference_video_fps = int(video_config.get("reference_video_fps") or DEFAULT_REFERENCE_VIDEO_FPS)
|
||||
|
||||
# if settings.LLM_MOCK:
|
||||
# result = _mock_result(video_config, target_platform)
|
||||
# result = ensure_negative_prompt(ensure_flow_matches_time_plan(ensure_top_keys(fill_none_with_wu(result)), duration))
|
||||
# return result, build_final_video_prompt(result), {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
|
||||
|
||||
config = await _select_model_config(db)
|
||||
if not config:
|
||||
result = normalize_video_prompt_schema_from_ai(_mock_result(video_config, target_platform), video_config)
|
||||
return result, build_final_video_prompt(result), {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
|
||||
|
||||
user_text = build_user_text(
|
||||
source_project_name=source_project_name,
|
||||
target_project_name=target_project_name,
|
||||
core_content_point=core_content_point,
|
||||
target_platform=target_platform,
|
||||
references=references,
|
||||
video_config=video_config,
|
||||
client_schema=client_schema,
|
||||
)
|
||||
user_message, log_user_message = build_user_message(user_text, references, reference_video_fps)
|
||||
request_data = {
|
||||
"model": config.model_name,
|
||||
"messages": [{"role": "system", "content": build_system_prompt()}, user_message],
|
||||
"max_tokens": 6000,
|
||||
"temperature": 0.15,
|
||||
"response_format": {"type": "json_object"},
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=int(settings.CHATAPI_REQUEST_TIMEOUT_SECONDS or 180)) as client:
|
||||
response = await client.post(
|
||||
f"{config.api_base.rstrip('/')}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {config.api_key}", "Content-Type": "application/json"},
|
||||
json=request_data,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise RuntimeError(f"视频提词优化失败 HTTP {response.status_code}: {response.text}")
|
||||
|
||||
data = response.json()
|
||||
content = data["choices"][0]["message"]["content"].strip()
|
||||
usage = data.get("usage", {}) or {}
|
||||
token_usage = {
|
||||
"input_tokens": int(usage.get("prompt_tokens") or 0),
|
||||
"output_tokens": int(usage.get("completion_tokens") or 0),
|
||||
"total_tokens": int(usage.get("total_tokens") or 0),
|
||||
"log_user_message": log_user_message,
|
||||
}
|
||||
db.add(
|
||||
TokenUsage(
|
||||
id=generate_id(),
|
||||
model_config_id=config.id,
|
||||
user_id=user_id,
|
||||
input_tokens=token_usage["input_tokens"],
|
||||
output_tokens=token_usage["output_tokens"],
|
||||
total_tokens=token_usage["total_tokens"],
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
result = parse_model_json(content)
|
||||
result = normalize_video_prompt_schema_from_ai(result, video_config)
|
||||
return result, build_final_video_prompt(result), token_usage
|
||||
|
||||
def _build_file_url_or_data_uri(file_url: str) -> str:
|
||||
"""
|
||||
Convert local upload path to base64 data URI.
|
||||
Keep remote http/https/data URLs as-is.
|
||||
"""
|
||||
if file_url.startswith(("http://", "https://", "data:")):
|
||||
return file_url
|
||||
file_url_sign = build_resource_signed_url(resource_url=file_url, expire_seconds=86400)
|
||||
return f"{settings.BASE_URL}{file_url_sign}"
|
||||
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
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
|
||||
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 _append_module_log(module: str, entry: dict[str, Any]) -> None:
|
||||
if not is_enabled():
|
||||
return
|
||||
try:
|
||||
module_dir = os.path.join(MODULE_LOG_ROOT, _safe_module_name(module))
|
||||
os.makedirs(module_dir, exist_ok=True)
|
||||
today = datetime.now().strftime(LOG_DATE_FORMAT)
|
||||
log_file = os.path.join(module_dir, f"{today}.log")
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry, ensure_ascii=False, default=str) + "\n")
|
||||
except Exception:
|
||||
# 日志失败绝不能影响业务主流程。
|
||||
pass
|
||||
|
||||
|
||||
def log_module_event_file(
|
||||
*,
|
||||
module: str,
|
||||
event_type: str,
|
||||
project_id: str | None = None,
|
||||
step_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
message: str | None = None,
|
||||
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)
|
||||
|
||||
|
||||
def log_module_prompt_event(
|
||||
*,
|
||||
event_type: str,
|
||||
project_id: str,
|
||||
step_id: str,
|
||||
user_id: str,
|
||||
module: str,
|
||||
prompt_type: str,
|
||||
request: dict[str, Any] | None = None,
|
||||
response: dict[str, Any] | None = None,
|
||||
token_usage: dict[str, Any] | None = None,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
"""记录模块 AI 提词请求/响应到 JSONL 文件。
|
||||
|
||||
与模块事件共用同一个服务,但按 module 分目录,方便按模块排查。
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
def log_module_error(
|
||||
*,
|
||||
module: str,
|
||||
event_type: str,
|
||||
project_id: str | None = None,
|
||||
step_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
message: str | None = None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
"""记录模块异常日志。"""
|
||||
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(detail or {}),
|
||||
"error": error,
|
||||
}
|
||||
_append_module_log(module, entry)
|
||||
@@ -1,14 +1,244 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# 尝试设置 SSL 证书路径
|
||||
try:
|
||||
import certifi
|
||||
os.environ["SSL_CERT_FILE"] = certifi.where()
|
||||
os.environ["REQUESTS_CA_BUNDLE"] = certifi.where()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.payment_order import PaymentOrder
|
||||
from app.services.credits import add_credits
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.services.credits import add_credits, deduct_credits
|
||||
from app.utils.id_gen import generate_id, generate_order_no
|
||||
|
||||
logger = logging.getLogger("videogen")
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payment logger → log/payment/YYYY-MM-DD.log (one file per day, no cleanup)
|
||||
# ---------------------------------------------------------------------------
|
||||
import time as _time
|
||||
|
||||
logger = logging.getLogger("payment")
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
_log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "log", "payment")
|
||||
os.makedirs(_log_dir, exist_ok=True)
|
||||
|
||||
|
||||
class DailyFileHandler(logging.FileHandler):
|
||||
"""Write to a file named by date, e.g. log/payment/2026-06-10.log"""
|
||||
|
||||
def __init__(self, directory, encoding="utf-8"):
|
||||
self._directory = directory
|
||||
self._current_date = ""
|
||||
self._file_handler = None
|
||||
super().__init__(self._make_path(), mode="a", encoding=encoding, delay=False)
|
||||
|
||||
def _make_path(self):
|
||||
date_str = _time.strftime("%Y-%m-%d")
|
||||
self._current_date = date_str
|
||||
return os.path.join(self._directory, f"{date_str}.log")
|
||||
|
||||
def emit(self, record):
|
||||
date_str = _time.strftime("%Y-%m-%d")
|
||||
if date_str != self._current_date:
|
||||
# Day rolled over — switch to a new file
|
||||
if self._file_handler:
|
||||
self._file_handler.close()
|
||||
self.baseFilename = self._make_path()
|
||||
self._file_handler = logging.FileHandler(
|
||||
self.baseFilename, mode="a", encoding=self.encoding
|
||||
)
|
||||
self._file_handler.setFormatter(self.formatter)
|
||||
self._current_date = date_str
|
||||
self.stream = self._file_handler.stream
|
||||
super().emit(record)
|
||||
|
||||
|
||||
_handler = DailyFileHandler(_log_dir)
|
||||
_handler.setFormatter(logging.Formatter(
|
||||
"[%(asctime)s] %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
|
||||
))
|
||||
if not logger.handlers:
|
||||
logger.addHandler(_handler)
|
||||
|
||||
# Order expire time in seconds (configurable via payment_order_timeout setting, default 180 seconds)
|
||||
DEFAULT_ORDER_EXPIRE_SECONDS = 180
|
||||
|
||||
|
||||
def _get_order_expire_seconds(db_configs: dict[str, str]) -> int:
|
||||
"""Get order expire time in seconds from config, with fallback to 180."""
|
||||
try:
|
||||
val = db_configs.get("payment_order_timeout", str(DEFAULT_ORDER_EXPIRE_SECONDS))
|
||||
return int(val) if val.strip() else DEFAULT_ORDER_EXPIRE_SECONDS
|
||||
except ValueError:
|
||||
return DEFAULT_ORDER_EXPIRE_SECONDS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Monkey-patch alipay-sdk-python WebUtils.do_post to fix bytes concatenation bug
|
||||
# The SDK's error handling does: '...' + response.read()
|
||||
# but response.read() returns bytes, causing TypeError on Python 3
|
||||
# ---------------------------------------------------------------------------
|
||||
def _patch_alipay_webutils():
|
||||
try:
|
||||
from alipay.aop.api.util import WebUtils
|
||||
_original_do_post = WebUtils.do_post
|
||||
|
||||
def _patched_do_post(url, query_string, headers, params, charset, timeout=30):
|
||||
try:
|
||||
return _original_do_post(url, query_string, headers, params, charset, timeout)
|
||||
except TypeError as e:
|
||||
if "can only concatenate str (not 'bytes') to str" in str(e):
|
||||
# Decode bytes response to string and retry
|
||||
import http.client as _http
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
parsed = _urlparse(url)
|
||||
conn = _http.HTTPSConnection(parsed.hostname)
|
||||
conn.request("POST", parsed.path + "?" + query_string, params, headers)
|
||||
resp = conn.getresponse()
|
||||
body = resp.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"Alipay API error (status {resp.status}): {body}") from e
|
||||
raise
|
||||
|
||||
WebUtils.do_post = _patched_do_post
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
_patch_alipay_webutils()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config helpers – read from system_configs table (admin panel)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _get_payment_configs(db: AsyncSession) -> dict[str, str]:
|
||||
"""Read all payment_* configs from the database, return as a dict."""
|
||||
result = await db.execute(
|
||||
select(SystemConfig).where(SystemConfig.key.like("payment_%"))
|
||||
)
|
||||
return {c.key: c.value for c in result.scalars().all()}
|
||||
|
||||
|
||||
async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool:
|
||||
"""If a pending order has passed its expiry, mark it cancelled.
|
||||
Returns True if the order was expired.
|
||||
"""
|
||||
if order.status != "pending":
|
||||
return False
|
||||
db_configs = await _get_payment_configs(db)
|
||||
expire_seconds = _get_order_expire_seconds(db_configs)
|
||||
expiry = order.created_at + timedelta(seconds=expire_seconds)
|
||||
if datetime.now(order.created_at.tzinfo) >= expiry:
|
||||
order.status = "cancelled"
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"ORDER_EXPIRED order_no={order.order_no} user={order.user_id} "
|
||||
f"amount={order.amount} created_at={order.created_at.isoformat()}"
|
||||
)
|
||||
# Also call Alipay close API if it was an Alipay order
|
||||
if order.payment_method == "alipay":
|
||||
try:
|
||||
await _close_alipay_order(db, order, db_configs)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to close Alipay order {order.order_no}: {e}")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def expire_all_pending_orders(db: AsyncSession) -> int:
|
||||
"""Background task: mark all expired pending orders as cancelled.
|
||||
Returns the number of orders expired.
|
||||
"""
|
||||
db_configs = await _get_payment_configs(db)
|
||||
expire_seconds = _get_order_expire_seconds(db_configs)
|
||||
threshold = datetime.now() - timedelta(seconds=expire_seconds)
|
||||
result = await db.execute(
|
||||
select(PaymentOrder).where(
|
||||
PaymentOrder.status == "pending",
|
||||
PaymentOrder.created_at <= threshold,
|
||||
)
|
||||
)
|
||||
orders = result.scalars().all()
|
||||
expired_count = 0
|
||||
for o in orders:
|
||||
o.status = "cancelled"
|
||||
expired_count += 1
|
||||
logger.info(
|
||||
f"ORDER_EXPIRED order_no={o.order_no} user={o.user_id} amount={o.amount}"
|
||||
)
|
||||
# Also call Alipay close API if it was an Alipay order
|
||||
if o.payment_method == "alipay":
|
||||
try:
|
||||
await _close_alipay_order(db, o, db_configs)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to close Alipay order {o.order_no}: {e}")
|
||||
if orders:
|
||||
await db.flush()
|
||||
return expired_count
|
||||
|
||||
|
||||
def _is_mock_mode(db_configs: dict[str, str]) -> bool:
|
||||
"""Check if payment mock mode is enabled (from DB or env)."""
|
||||
db_val = db_configs.get("payment_mock", "")
|
||||
if db_val:
|
||||
return db_val.lower() in ("true", "1", "yes")
|
||||
return settings.PAYMENT_MOCK
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alipay client (lazy singleton, recreated when config changes)
|
||||
# ---------------------------------------------------------------------------
|
||||
_alipay_client = None
|
||||
_alipay_client_app_id = None
|
||||
|
||||
|
||||
def _get_alipay_client(app_id: str, private_key: str, public_key: str, gateway: str = ""):
|
||||
"""Get or create an Alipay client. Recreated if app_id changes."""
|
||||
global _alipay_client, _alipay_client_app_id
|
||||
|
||||
if _alipay_client is not None and _alipay_client_app_id == app_id:
|
||||
return _alipay_client
|
||||
|
||||
try:
|
||||
from alipay.aop.api.AlipayClientConfig import AlipayClientConfig
|
||||
from alipay.aop.api.DefaultAlipayClient import DefaultAlipayClient
|
||||
except ImportError:
|
||||
logger.error(
|
||||
"alipay-sdk-python is not installed. "
|
||||
"Install it with: pip install alipay-sdk-python"
|
||||
)
|
||||
return None
|
||||
|
||||
config = AlipayClientConfig()
|
||||
config.server_url = gateway or "https://openapi.alipay.com/gateway.do"
|
||||
config.app_id = app_id
|
||||
config.app_private_key = private_key
|
||||
config.alipay_public_key = public_key
|
||||
config.sign_type = "RSA2"
|
||||
config.charset = "utf-8"
|
||||
|
||||
try:
|
||||
_alipay_client = DefaultAlipayClient(config, logger)
|
||||
_alipay_client_app_id = app_id
|
||||
except Exception:
|
||||
logger.exception("Failed to initialize Alipay client")
|
||||
_alipay_client = None
|
||||
_alipay_client_app_id = None
|
||||
|
||||
return _alipay_client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Create recharge order
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def create_recharge_order(
|
||||
@@ -20,7 +250,28 @@ async def create_recharge_order(
|
||||
bonus_credits: float = 0.0,
|
||||
method: str = "wechat",
|
||||
) -> PaymentOrder:
|
||||
"""Create a payment order. In mock mode, immediately completes payment."""
|
||||
"""Create a payment order.
|
||||
|
||||
Reads payment config from the database (admin panel).
|
||||
Returns the order; for Alipay the ``qr_url`` attribute will be populated
|
||||
with the scan-to-pay URL.
|
||||
"""
|
||||
# Read config from database first
|
||||
db_configs = await _get_payment_configs(db)
|
||||
mock_mode = _is_mock_mode(db_configs)
|
||||
|
||||
# In real mode, validate that the payment method is enabled and configured
|
||||
if not mock_mode:
|
||||
enabled_key = f"payment_{method}_enabled"
|
||||
if db_configs.get(enabled_key, "").lower() != "true":
|
||||
raise ValueError("该支付方式未启用,请联系管理员")
|
||||
if method == "alipay":
|
||||
if not db_configs.get("payment_alipay_app_id") or not db_configs.get("payment_alipay_private_key"):
|
||||
raise ValueError("支付宝支付未完成配置,请联系管理员")
|
||||
elif method == "wechat":
|
||||
if not db_configs.get("payment_wechat_mch_id") or not db_configs.get("payment_wechat_api_key"):
|
||||
raise ValueError("微信支付未完成配置,请联系管理员")
|
||||
|
||||
total_credits = credits + bonus_credits
|
||||
order = PaymentOrder(
|
||||
id=generate_id(),
|
||||
@@ -33,8 +284,12 @@ async def create_recharge_order(
|
||||
)
|
||||
db.add(order)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"ORDER_CREATED order_no={order.order_no} user={user_id} "
|
||||
f"amount={price} credits={total_credits} method={method} mock={mock_mode}"
|
||||
)
|
||||
|
||||
if settings.PAYMENT_MOCK:
|
||||
if mock_mode:
|
||||
# Mock: immediately complete payment
|
||||
order.status = "paid"
|
||||
order.paid_at = datetime.now()
|
||||
@@ -52,59 +307,464 @@ async def create_recharge_order(
|
||||
else:
|
||||
# Real payment: delegate to WeChat or Alipay
|
||||
if method == "wechat":
|
||||
_create_wechat_order(order)
|
||||
_create_wechat_order(order, db_configs)
|
||||
elif method == "alipay":
|
||||
_create_alipay_order(order)
|
||||
qr_url = _create_alipay_order(order, db_configs)
|
||||
if qr_url:
|
||||
# Attach QR URL to the order instance (transient, not persisted)
|
||||
order.qr_url = qr_url # type: ignore[attr-defined]
|
||||
else:
|
||||
# Precreate failed — do not leave a pending order that can never be paid
|
||||
raise ValueError("支付宝预下单失败,请检查配置或稍后重试")
|
||||
|
||||
return order
|
||||
|
||||
|
||||
def _create_wechat_order(order: PaymentOrder) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
# WeChat (stub)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_wechat_order(order: PaymentOrder, db_configs: dict[str, str]) -> None:
|
||||
"""Create a WeChat Pay order. Stub for real integration."""
|
||||
if not settings.WECHAT_MCH_ID or not settings.WECHAT_API_KEY:
|
||||
logger.warning("WeChat payment config missing (WECHAT_MCH_ID / WECHAT_API_KEY)")
|
||||
mch_id = db_configs.get("payment_wechat_mch_id", "")
|
||||
api_key = db_configs.get("payment_wechat_api_key", "")
|
||||
if not mch_id or not api_key:
|
||||
logger.warning("WeChat payment config missing in database")
|
||||
return
|
||||
logger.info(
|
||||
f"WeChat order created: mch_id={settings.WECHAT_MCH_ID}, "
|
||||
f"WeChat order created: mch_id={mch_id}, "
|
||||
f"order_no={order.order_no}, amount={order.amount}"
|
||||
)
|
||||
|
||||
|
||||
def _create_alipay_order(order: PaymentOrder) -> None:
|
||||
"""Create an Alipay order. Stub for real integration."""
|
||||
if not settings.ALIPAY_APP_ID or not settings.ALIPAY_PRIVATE_KEY:
|
||||
logger.warning("Alipay payment config missing (ALIPAY_APP_ID / ALIPAY_PRIVATE_KEY)")
|
||||
return
|
||||
logger.info(
|
||||
f"Alipay order created: app_id={settings.ALIPAY_APP_ID}, "
|
||||
f"order_no={order.order_no}, amount={order.amount}"
|
||||
)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alipay – trade.precreate (当面付 预下单)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def verify_wechat_callback(data: dict) -> bool:
|
||||
def _create_alipay_order(order: PaymentOrder, db_configs: dict[str, str]) -> str | None:
|
||||
"""Call Alipay ``trade.precreate`` to obtain a QR code URL.
|
||||
|
||||
Reads all Alipay config from the database (admin panel).
|
||||
Returns the ``qr_code`` URL on success, or ``None`` on failure.
|
||||
"""
|
||||
app_id = db_configs.get("payment_alipay_app_id", "")
|
||||
private_key = db_configs.get("payment_alipay_private_key", "")
|
||||
public_key = db_configs.get("payment_alipay_public_key", "")
|
||||
gateway = db_configs.get("payment_alipay_gateway", "")
|
||||
notify_url = db_configs.get("payment_alipay_notify_url", "")
|
||||
|
||||
if not app_id or not private_key:
|
||||
logger.warning("Alipay config missing in database (app_id / private_key)")
|
||||
return None
|
||||
|
||||
client = _get_alipay_client(app_id, private_key, public_key, gateway)
|
||||
if client is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
from alipay.aop.api.domain.AlipayTradePrecreateModel import (
|
||||
AlipayTradePrecreateModel,
|
||||
)
|
||||
from alipay.aop.api.request.AlipayTradePrecreateRequest import (
|
||||
AlipayTradePrecreateRequest,
|
||||
)
|
||||
from alipay.aop.api.response.AlipayTradePrecreateResponse import (
|
||||
AlipayTradePrecreateResponse,
|
||||
)
|
||||
|
||||
# 构造业务参数
|
||||
model = AlipayTradePrecreateModel()
|
||||
model.out_trade_no = order.order_no
|
||||
model.total_amount = f"{order.amount:.2f}"
|
||||
model.subject = f"充值订单 {order.order_no}"
|
||||
model.product_code = "QR_CODE_OFFLINE"
|
||||
|
||||
body_parts = []
|
||||
if order.credits > 0:
|
||||
body_parts.append(f"{order.credits}积分")
|
||||
if body_parts:
|
||||
model.body = " ".join(body_parts)
|
||||
|
||||
# 构造请求
|
||||
request = AlipayTradePrecreateRequest(biz_model=model)
|
||||
|
||||
# 设置 notify_url 在 request 上
|
||||
if notify_url:
|
||||
try:
|
||||
if hasattr(request, 'set_notify_url'):
|
||||
request.set_notify_url(notify_url)
|
||||
elif hasattr(request, 'notify_url'):
|
||||
request.notify_url = notify_url
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to set notify_url: {e}")
|
||||
|
||||
# 执行API调用
|
||||
response_content = client.execute(request)
|
||||
if not response_content:
|
||||
logger.error(f"Alipay precreate failed: empty response, order_no={order.order_no}")
|
||||
return None
|
||||
|
||||
# 解析响应结果
|
||||
response = AlipayTradePrecreateResponse()
|
||||
response.parse_response_content(response_content)
|
||||
|
||||
if response.is_success():
|
||||
qr_url = response.qr_code
|
||||
return qr_url
|
||||
else:
|
||||
logger.error(
|
||||
f"Alipay precreate failed: code={response.code}, "
|
||||
f"msg={response.msg}, sub_code={response.sub_code}, "
|
||||
f"sub_msg={response.sub_msg}, order_no={order.order_no}"
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
# 处理 SDK 内部的 bytes/str 错误
|
||||
if "TypeError" in str(e) and ("bytes" in str(e) or "str" in str(e)):
|
||||
logger.error(
|
||||
f"Alipay SDK TypeError (bytes/str issue): order_no={order.order_no}, "
|
||||
f"error={str(e)}"
|
||||
)
|
||||
logger.exception(f"Alipay precreate exception: order_no={order.order_no}")
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alipay order close
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _close_alipay_order(db: AsyncSession, order: PaymentOrder, db_configs: dict[str, str]) -> bool:
|
||||
"""Call Alipay trade.close API to close an unpaid order.
|
||||
Returns True if the order was closed successfully.
|
||||
"""
|
||||
app_id = db_configs.get("payment_alipay_app_id", "")
|
||||
private_key = db_configs.get("payment_alipay_private_key", "")
|
||||
public_key = db_configs.get("payment_alipay_public_key", "")
|
||||
gateway = db_configs.get("payment_alipay_gateway", "")
|
||||
|
||||
client = _get_alipay_client(app_id, private_key, public_key, gateway)
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
mock_mode = _is_mock_mode(db_configs)
|
||||
if mock_mode:
|
||||
logger.info(f"Mock mode: skipping close_alipay_order for {order.order_no}")
|
||||
return True
|
||||
|
||||
try:
|
||||
from alipay.aop.api.domain.AlipayTradeCloseModel import AlipayTradeCloseModel
|
||||
from alipay.aop.api.request.AlipayTradeCloseRequest import AlipayTradeCloseRequest
|
||||
from alipay.aop.api.response.AlipayTradeCloseResponse import AlipayTradeCloseResponse
|
||||
|
||||
model = AlipayTradeCloseModel()
|
||||
model.out_trade_no = order.order_no
|
||||
|
||||
request = AlipayTradeCloseRequest(biz_model=model)
|
||||
|
||||
response_content = client.execute(request)
|
||||
if not response_content:
|
||||
logger.error(f"Alipay close failed: empty response, order_no={order.order_no}")
|
||||
return False
|
||||
|
||||
response = AlipayTradeCloseResponse()
|
||||
response.parse_response_content(response_content)
|
||||
|
||||
if response.is_success():
|
||||
logger.info(f"Alipay order closed: order_no={order.order_no}")
|
||||
return True
|
||||
else:
|
||||
logger.error(
|
||||
f"Alipay close failed: code={response.code}, "
|
||||
f"msg={response.msg}, sub_code={response.sub_code}, "
|
||||
f"sub_msg={response.sub_msg}, order_no={order.order_no}"
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
if "TypeError" in str(e) and ("bytes" in str(e) or "str" in str(e)):
|
||||
logger.error(
|
||||
f"Alipay SDK TypeError (bytes/str issue) during close: order_no={order.order_no}, "
|
||||
f"error={str(e)}"
|
||||
)
|
||||
logger.exception(f"Alipay close exception: order_no={order.order_no}")
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alipay order query
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _query_alipay_order(db: AsyncSession, order: PaymentOrder, db_configs: dict[str, str]) -> dict | None:
|
||||
"""Call Alipay trade.query API to check order status.
|
||||
Returns the response data if successful, None otherwise.
|
||||
"""
|
||||
app_id = db_configs.get("payment_alipay_app_id", "")
|
||||
private_key = db_configs.get("payment_alipay_private_key", "")
|
||||
public_key = db_configs.get("payment_alipay_public_key", "")
|
||||
gateway = db_configs.get("payment_alipay_gateway", "")
|
||||
|
||||
client = _get_alipay_client(app_id, private_key, public_key, gateway)
|
||||
if client is None:
|
||||
return None
|
||||
|
||||
mock_mode = _is_mock_mode(db_configs)
|
||||
if mock_mode:
|
||||
logger.info(f"Mock mode: skipping query_alipay_order for {order.order_no}")
|
||||
return {"trade_status": "TRADE_FINISHED"}
|
||||
|
||||
try:
|
||||
from alipay.aop.api.domain.AlipayTradeQueryModel import AlipayTradeQueryModel
|
||||
from alipay.aop.api.request.AlipayTradeQueryRequest import AlipayTradeQueryRequest
|
||||
from alipay.aop.api.response.AlipayTradeQueryResponse import AlipayTradeQueryResponse
|
||||
|
||||
model = AlipayTradeQueryModel()
|
||||
model.out_trade_no = order.order_no
|
||||
|
||||
request = AlipayTradeQueryRequest(biz_model=model)
|
||||
|
||||
response_content = client.execute(request)
|
||||
if not response_content:
|
||||
logger.error(f"Alipay query failed: empty response, order_no={order.order_no}")
|
||||
return None
|
||||
|
||||
response = AlipayTradeQueryResponse()
|
||||
response.parse_response_content(response_content)
|
||||
|
||||
if response.is_success():
|
||||
logger.info(f"Alipay query succeeded: order_no={order.order_no}, trade_status={response.trade_status}")
|
||||
return {
|
||||
"trade_no": response.trade_no,
|
||||
"trade_status": response.trade_status,
|
||||
"total_amount": response.total_amount,
|
||||
"receipt_amount": response.receipt_amount,
|
||||
}
|
||||
else:
|
||||
logger.error(
|
||||
f"Alipay query failed: code={response.code}, "
|
||||
f"msg={response.msg}, sub_code={response.sub_code}, "
|
||||
f"sub_msg={response.sub_msg}, order_no={order.order_no}"
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
if "TypeError" in str(e) and ("bytes" in str(e) or "str" in str(e)):
|
||||
logger.error(
|
||||
f"Alipay SDK TypeError (bytes/str issue) during query: order_no={order.order_no}, "
|
||||
f"error={str(e)}"
|
||||
)
|
||||
logger.exception(f"Alipay query exception: order_no={order.order_no}")
|
||||
return None
|
||||
|
||||
|
||||
async def sync_pending_orders(db: AsyncSession) -> int:
|
||||
"""Check pending orders via Alipay query and update status.
|
||||
Returns the number of orders updated.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(PaymentOrder).where(
|
||||
PaymentOrder.status == "pending",
|
||||
)
|
||||
)
|
||||
orders = result.scalars().all()
|
||||
updated_count = 0
|
||||
|
||||
db_configs = await _get_payment_configs(db)
|
||||
|
||||
for order in orders:
|
||||
if order.payment_method != "alipay":
|
||||
continue
|
||||
|
||||
try:
|
||||
data = await _query_alipay_order(db, order, db_configs)
|
||||
if data:
|
||||
trade_status = data.get("trade_status")
|
||||
if trade_status in ("TRADE_SUCCESS", "TRADE_FINISHED"):
|
||||
# Order was paid but we missed the callback
|
||||
trade_no = data.get("trade_no", "")
|
||||
await process_payment_success_by_order_no(db, order.order_no, trade_no)
|
||||
updated_count += 1
|
||||
elif trade_status in ("TRADE_CLOSED", "TRADE_CANCELLED"):
|
||||
# Order was closed on Alipay side
|
||||
order.status = "cancelled"
|
||||
await db.flush()
|
||||
updated_count += 1
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to sync order {order.order_no}: {e}")
|
||||
|
||||
if updated_count > 0:
|
||||
await db.flush()
|
||||
return updated_count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alipay callback verification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def verify_alipay_callback(data: dict, db: AsyncSession) -> bool:
|
||||
"""Verify Alipay payment callback (async notify) signature.
|
||||
|
||||
Reads the Alipay public key from the database and uses RSA2 verification.
|
||||
"""
|
||||
db_configs = await _get_payment_configs(db)
|
||||
mock_mode = _is_mock_mode(db_configs)
|
||||
if mock_mode:
|
||||
logger.info("Mock mode enabled, skipping Alipay callback verification")
|
||||
return True
|
||||
|
||||
public_key = db_configs.get("payment_alipay_public_key", "")
|
||||
if not public_key:
|
||||
logger.warning("ALIPAY_PUBLIC_KEY not found in database, cannot verify callback")
|
||||
return False
|
||||
|
||||
try:
|
||||
sign = data.get("sign")
|
||||
if not sign:
|
||||
logger.warning("Alipay callback missing 'sign' field")
|
||||
return False
|
||||
|
||||
sign_type = data.get("sign_type", "RSA2")
|
||||
|
||||
# Build verification params (exclude sign and sign_type)
|
||||
verify_data = {
|
||||
k: v for k, v in data.items()
|
||||
if k not in ("sign", "sign_type") and v is not None and v != ""
|
||||
}
|
||||
|
||||
# Generate sign content: sorted keys, key=value format
|
||||
sign_content = "&".join(
|
||||
f"{k}={v}" for k, v in sorted(verify_data.items())
|
||||
)
|
||||
|
||||
# logger.info(f"Verifying Alipay callback sign_content: {sign_content[:100]}...")
|
||||
# logger.info(f"Sign type: {sign_type}")
|
||||
|
||||
# 实现 RSA2 签名验证
|
||||
is_valid = _verify_alipay_sign(public_key, sign_content, sign, sign_type)
|
||||
|
||||
if not is_valid:
|
||||
logger.warning("Alipay callback signature verification FAILED")
|
||||
else:
|
||||
logger.info("Alipay callback signature verification SUCCESS")
|
||||
|
||||
return is_valid
|
||||
|
||||
except Exception:
|
||||
logger.exception("Alipay callback verification error")
|
||||
return False
|
||||
|
||||
|
||||
def _verify_alipay_sign(public_key: str, sign_content: str, sign: str, sign_type: str = "RSA2") -> bool:
|
||||
"""Verify Alipay RSA/RSA2 signature.
|
||||
|
||||
Args:
|
||||
public_key: Alipay public key (PEM format, with or without headers)
|
||||
sign_content: Original content to verify
|
||||
sign: Base64 encoded signature
|
||||
sign_type: "RSA" (SHA1) or "RSA2" (SHA256)
|
||||
|
||||
Returns:
|
||||
True if signature is valid
|
||||
"""
|
||||
try:
|
||||
import base64
|
||||
from hashlib import sha1, sha256
|
||||
|
||||
# 处理公钥,确保有正确的格式
|
||||
pub_key = public_key.strip()
|
||||
if not pub_key.startswith("-----BEGIN"):
|
||||
pub_key = "-----BEGIN PUBLIC KEY-----\n" + pub_key + "\n-----END PUBLIC KEY-----"
|
||||
|
||||
try:
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
# 加载公钥
|
||||
public_key_obj = serialization.load_pem_public_key(
|
||||
pub_key.encode("utf-8"),
|
||||
backend=default_backend()
|
||||
)
|
||||
|
||||
# 选择哈希算法
|
||||
if sign_type == "RSA2":
|
||||
hash_alg = hashes.SHA256()
|
||||
else:
|
||||
hash_alg = hashes.SHA1()
|
||||
|
||||
# 验证签名
|
||||
public_key_obj.verify(
|
||||
base64.b64decode(sign),
|
||||
sign_content.encode("utf-8"),
|
||||
padding.PKCS1v15(),
|
||||
hash_alg
|
||||
)
|
||||
return True
|
||||
|
||||
except ImportError:
|
||||
# 如果没有 cryptography,尝试使用 rsa 库
|
||||
try:
|
||||
import rsa
|
||||
|
||||
# 加载公钥
|
||||
pub_key_obj = rsa.PublicKey.load_pkcs1_openssl_pem(pub_key.encode("utf-8"))
|
||||
|
||||
# 选择哈希算法
|
||||
if sign_type == "RSA2":
|
||||
hash_func = 'SHA-256'
|
||||
else:
|
||||
hash_func = 'SHA-1'
|
||||
|
||||
# 验证签名
|
||||
rsa.verify(
|
||||
sign_content.encode("utf-8"),
|
||||
base64.b64decode(sign),
|
||||
pub_key_obj,
|
||||
hash_func
|
||||
)
|
||||
return True
|
||||
|
||||
except ImportError:
|
||||
logger.error("Neither cryptography nor rsa library installed, cannot verify signature")
|
||||
# 如果没有任何加密库,在生产环境应该返回 False,但这里我们记录警告并继续
|
||||
logger.warning("Skipping signature verification due to missing crypto libraries")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Signature verification failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WeChat callback verification (stub)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def verify_wechat_callback(data: dict, db: AsyncSession) -> bool:
|
||||
"""Verify WeChat payment callback signature."""
|
||||
if settings.PAYMENT_MOCK:
|
||||
db_configs = await _get_payment_configs(db)
|
||||
mock_mode = _is_mock_mode(db_configs)
|
||||
if mock_mode:
|
||||
return True
|
||||
# Real verification would use WECHAT_API_KEY to verify signature
|
||||
logger.info("WeChat callback verification (real mode not implemented)")
|
||||
return True
|
||||
|
||||
|
||||
async def verify_alipay_callback(data: dict) -> bool:
|
||||
"""Verify Alipay payment callback signature."""
|
||||
if settings.PAYMENT_MOCK:
|
||||
return True
|
||||
# Real verification would use ALIPAY_PUBLIC_KEY to verify signature
|
||||
logger.info("Alipay callback verification (real mode not implemented)")
|
||||
return True
|
||||
# ---------------------------------------------------------------------------
|
||||
# Process successful payment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def process_payment_success(db: AsyncSession, order_id: str):
|
||||
"""Process successful payment: update order and add credits."""
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(
|
||||
select(PaymentOrder).where(PaymentOrder.id == order_id).limit(1)
|
||||
select(PaymentOrder).where(PaymentOrder.id == order_id).with_for_update().limit(1)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if not order or order.status != "pending":
|
||||
@@ -119,4 +779,201 @@ async def process_payment_success(db: AsyncSession, order_id: str):
|
||||
f"充值成功({order.credits}积分)",
|
||||
related_id=order.id,
|
||||
)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def process_payment_success_by_order_no(
|
||||
db: AsyncSession,
|
||||
order_no: str,
|
||||
trade_no: str = "",
|
||||
total_amount: float | None = None
|
||||
):
|
||||
"""Process successful payment by order_no (used by Alipay/WeChat callbacks).
|
||||
|
||||
Args:
|
||||
db: async database session
|
||||
order_no: the merchant order number (out_trade_no)
|
||||
trade_no: the Alipay trade number (trade_no), optional
|
||||
total_amount: the payment amount from the gateway, for consistency check
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(PaymentOrder).where(PaymentOrder.order_no == order_no).with_for_update().limit(1)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
|
||||
if not order:
|
||||
logger.info(f"Order {order_no} not found, skipping")
|
||||
return
|
||||
|
||||
if order.status == "paid":
|
||||
logger.info(f"Order {order_no} already processed, skipping")
|
||||
return
|
||||
|
||||
if order.status != "pending":
|
||||
logger.info(f"Order {order_no} is in {order.status} state, cannot process")
|
||||
return
|
||||
|
||||
# 金额一致性校验
|
||||
if total_amount is not None and abs(total_amount - order.amount) > 0.01:
|
||||
logger.error(
|
||||
f"Amount mismatch: order amount {order.amount}, gateway amount {total_amount}"
|
||||
)
|
||||
return
|
||||
|
||||
# 幂等性检查:如果trade_no已存在且相同,则跳过
|
||||
if trade_no and order.trade_no and order.trade_no == trade_no:
|
||||
logger.info(f"Trade no {trade_no} already processed, skipping")
|
||||
return
|
||||
|
||||
order.status = "paid"
|
||||
order.paid_at = datetime.now()
|
||||
if trade_no:
|
||||
order.trade_no = trade_no
|
||||
|
||||
await add_credits(
|
||||
db,
|
||||
order.user_id,
|
||||
order.credits,
|
||||
f"充值成功({order.credits}积分)",
|
||||
related_id=order.id,
|
||||
)
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"PAYMENT_SUCCESS order_no={order_no} user={order.user_id} "
|
||||
f"amount={order.amount} credits={order.credits} trade_no={trade_no}"
|
||||
)
|
||||
|
||||
|
||||
async def process_refund(
|
||||
db: AsyncSession,
|
||||
order_no: str,
|
||||
refund_amount: float | None = None,
|
||||
refund_reason: str = "管理员退款"
|
||||
) -> dict:
|
||||
"""Process a refund for a paid order.
|
||||
|
||||
Args:
|
||||
db: async database session
|
||||
order_no: merchant order number
|
||||
refund_amount: amount to refund (defaults to full order amount)
|
||||
refund_reason: reason for refund
|
||||
|
||||
Returns:
|
||||
dict with refund result
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(PaymentOrder).where(PaymentOrder.order_no == order_no).with_for_update().limit(1)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
|
||||
if not order:
|
||||
return {"success": False, "message": "订单不存在"}
|
||||
|
||||
if order.status != "paid":
|
||||
return {"success": False, "message": f"订单状态为{order.status},无法退款"}
|
||||
|
||||
if order.refunded_at is not None:
|
||||
return {"success": False, "message": "订单已退款"}
|
||||
|
||||
refund_amount = refund_amount or order.amount
|
||||
|
||||
# 金额校验
|
||||
if refund_amount > order.amount:
|
||||
return {"success": False, "message": "退款金额超过订单金额"}
|
||||
|
||||
# 如果是支付宝订单,调用支付宝退款API
|
||||
db_configs = await _get_payment_configs(db)
|
||||
if order.payment_method == "alipay":
|
||||
refund_result = await _refund_alipay_order(
|
||||
db, order, refund_amount, refund_reason, db_configs
|
||||
)
|
||||
if not refund_result.get("success"):
|
||||
return refund_result
|
||||
|
||||
# 扣除积分
|
||||
try:
|
||||
await deduct_credits(
|
||||
db,
|
||||
order.user_id,
|
||||
order.credits,
|
||||
refund_reason,
|
||||
related_id=order.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to deduct credits for refund: {e}")
|
||||
return {"success": False, "message": "积分扣除失败"}
|
||||
|
||||
# 更新订单状态
|
||||
order.status = "refunded"
|
||||
order.refund_amount = refund_amount
|
||||
order.refunded_at = datetime.now()
|
||||
if order.payment_method == "alipay":
|
||||
order.refund_trade_no = db_configs.get("refund_trade_no", "")
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"REFUND_SUCCESS order_no={order_no} user={order.user_id} "
|
||||
f"refund_amount={refund_amount}"
|
||||
)
|
||||
return {"success": True, "message": "退款成功"}
|
||||
|
||||
|
||||
async def _refund_alipay_order(
|
||||
db: AsyncSession,
|
||||
order: PaymentOrder,
|
||||
refund_amount: float,
|
||||
refund_reason: str,
|
||||
db_configs: dict[str, str]
|
||||
) -> dict:
|
||||
"""Call Alipay refund API."""
|
||||
app_id = db_configs.get("payment_alipay_app_id", "")
|
||||
private_key = db_configs.get("payment_alipay_private_key", "")
|
||||
public_key = db_configs.get("payment_alipay_public_key", "")
|
||||
gateway = db_configs.get("payment_alipay_gateway", "")
|
||||
|
||||
client = _get_alipay_client(app_id, private_key, public_key, gateway)
|
||||
if client is None:
|
||||
return {"success": False, "message": "支付宝客户端初始化失败"}
|
||||
|
||||
mock_mode = _is_mock_mode(db_configs)
|
||||
if mock_mode:
|
||||
logger.info(f"Mock mode: skipping alipay refund for {order.order_no}")
|
||||
return {"success": True}
|
||||
|
||||
try:
|
||||
from alipay.aop.api.domain.AlipayTradeRefundModel import AlipayTradeRefundModel
|
||||
from alipay.aop.api.request.AlipayTradeRefundRequest import AlipayTradeRefundRequest
|
||||
from alipay.aop.api.response.AlipayTradeRefundResponse import AlipayTradeRefundResponse
|
||||
|
||||
model = AlipayTradeRefundModel()
|
||||
model.out_trade_no = order.order_no
|
||||
model.refund_amount = f"{refund_amount:.2f}"
|
||||
model.refund_reason = refund_reason
|
||||
model.out_request_no = f"{order.order_no}_refund_{int(datetime.now().timestamp())}"
|
||||
|
||||
request = AlipayTradeRefundRequest(biz_model=model)
|
||||
response_content = client.execute(request)
|
||||
|
||||
if not response_content:
|
||||
logger.error(f"Alipay refund failed: empty response, order_no={order.order_no}")
|
||||
return {"success": False, "message": "支付宝退款响应为空"}
|
||||
|
||||
response = AlipayTradeRefundResponse()
|
||||
response.parse_response_content(response_content)
|
||||
|
||||
if response.is_success():
|
||||
logger.info(f"Alipay refund succeeded: order_no={order.order_no}")
|
||||
return {"success": True, "trade_no": response.trade_no}
|
||||
else:
|
||||
logger.error(
|
||||
f"Alipay refund failed: code={response.code}, "
|
||||
f"msg={response.msg}, sub_code={response.sub_code}, "
|
||||
f"sub_msg={response.sub_msg}, order_no={order.order_no}"
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"支付宝退款失败: {response.sub_msg or response.msg}"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception(f"Alipay refund exception: order_no={order.order_no}, {e}")
|
||||
return {"success": False, "message": f"支付宝退款异常: {str(e)}"}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user_oauth_app import UserOAuthApp
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
|
||||
async def list_user_oauth_apps(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
open_type: int | None = None,
|
||||
status: int | None = None,
|
||||
create_by: str | None = None,
|
||||
app_id: str | None = None,
|
||||
) -> dict:
|
||||
query = select(UserOAuthApp).where(UserOAuthApp.deleted_at.is_(None)).order_by(UserOAuthApp.created_at.desc())
|
||||
|
||||
if open_type is not None:
|
||||
query = query.where(UserOAuthApp.open_type == open_type)
|
||||
|
||||
if status is not None:
|
||||
query = query.where(UserOAuthApp.status == status)
|
||||
|
||||
if create_by is not None:
|
||||
query = query.where(UserOAuthApp.create_by == create_by)
|
||||
|
||||
if app_id is not None:
|
||||
query = query.where(UserOAuthApp.app_id.like(f"%{app_id}%"))
|
||||
|
||||
total_result = await db.execute(select(func.count(UserOAuthApp.id)).where(UserOAuthApp.deleted_at.is_(None)))
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
|
||||
items = result.scalars().all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
async def get_user_oauth_app_by_id(db: AsyncSession, id: str) -> UserOAuthApp | None:
|
||||
result = await db.execute(
|
||||
select(UserOAuthApp).where(UserOAuthApp.id == id, UserOAuthApp.deleted_at.is_(None)).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_oauth_app_by_app_id(db: AsyncSession, app_id: str) -> UserOAuthApp | None:
|
||||
result = await db.execute(
|
||||
select(UserOAuthApp).where(UserOAuthApp.app_id == app_id, UserOAuthApp.deleted_at.is_(None)).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_user_oauth_app(
|
||||
db: AsyncSession,
|
||||
app_id: str,
|
||||
secret: str,
|
||||
open_type: int,
|
||||
create_by: str | None = None,
|
||||
count: int = 100,
|
||||
auth_url: str | None = None,
|
||||
company: str | None = None,
|
||||
) -> UserOAuthApp:
|
||||
existing = await get_user_oauth_app_by_app_id(db, app_id)
|
||||
if existing:
|
||||
raise ValueError("应用id已存在")
|
||||
|
||||
app = UserOAuthApp(
|
||||
id=generate_id(),
|
||||
app_id=app_id,
|
||||
secret=secret,
|
||||
open_type=open_type,
|
||||
count=count,
|
||||
auth_url=auth_url,
|
||||
company=company,
|
||||
create_by=create_by,
|
||||
)
|
||||
db.add(app)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
await db.refresh(app)
|
||||
return app
|
||||
|
||||
|
||||
async def update_user_oauth_app(
|
||||
db: AsyncSession,
|
||||
id: str,
|
||||
secret: str | None = None,
|
||||
open_type: int | None = None,
|
||||
status: int | None = None,
|
||||
count: int | None = None,
|
||||
auth_url: str | None = None,
|
||||
company: str | None = None,
|
||||
create_by: str | None = None,
|
||||
) -> UserOAuthApp | None:
|
||||
app = await get_user_oauth_app_by_id(db, id)
|
||||
if not app:
|
||||
return None
|
||||
|
||||
if secret is not None:
|
||||
app.secret = secret
|
||||
if open_type is not None:
|
||||
app.open_type = open_type
|
||||
if status is not None:
|
||||
app.status = status
|
||||
if count is not None:
|
||||
app.count = count
|
||||
if auth_url is not None:
|
||||
app.auth_url = auth_url
|
||||
if company is not None:
|
||||
app.company = company
|
||||
if create_by is not None:
|
||||
app.create_by = create_by
|
||||
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
await db.refresh(app)
|
||||
return app
|
||||
|
||||
|
||||
async def delete_user_oauth_app(db: AsyncSession, id: str, create_by: str | None = None) -> bool:
|
||||
app = await get_user_oauth_app_by_id(db, id)
|
||||
if not app:
|
||||
return False
|
||||
|
||||
app.deleted_at = func.now()
|
||||
if create_by is not None:
|
||||
app.create_by = create_by
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
async def get_apps_by_open_type(db: AsyncSession, open_type: int) -> list[UserOAuthApp]:
|
||||
result = await db.execute(
|
||||
select(UserOAuthApp).where(UserOAuthApp.open_type == open_type, UserOAuthApp.deleted_at.is_(None))
|
||||
)
|
||||
return result.scalars().all()
|
||||
@@ -0,0 +1,353 @@
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.user_oauth import UserOAuth
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
|
||||
OAUTH_TYPE_CONFIG = {
|
||||
1: {"port_type": 1, "name": "千川", "app_type": "juliang_qianchuan"},
|
||||
2: {"port_type": 1, "name": "广告", "app_type": "juliang_ad"},
|
||||
3: {"port_type": 1, "name": "本地推", "app_type": "juliang_ad"},
|
||||
4: {"port_type": 1, "name": "星图", "app_type": "juliang_ad"},
|
||||
5: {"port_type": 2, "name": "快手代理商", "app_type": "kuaishou"},
|
||||
6: {"port_type": 3, "name": "巨量星图", "app_type": "juliang_ad"},
|
||||
7: {"port_type": 4, "name": "巨量服务单", "app_type": "juliang_ad"},
|
||||
8: {"port_type": 4, "name": "腾讯服务单", "app_type": "tencent"},
|
||||
9: {"port_type": 5, "name": "腾讯营销K2", "app_type": "tencent"},
|
||||
10: {"port_type": 5, "name": "腾讯营销K3", "app_type": "tencent"},
|
||||
}
|
||||
|
||||
|
||||
async def get_available_app(app_type: str, db: AsyncSession) -> dict:
|
||||
if app_type == "juliang_ad":
|
||||
apps = settings.JULIANG_AD_APPS
|
||||
elif app_type == "juliang_qianchuan":
|
||||
apps = settings.JULIANG_QIANCHUAN_APPS
|
||||
elif app_type == "kuaishou":
|
||||
apps = settings.KUAISHOU_APPS
|
||||
elif app_type == "tencent":
|
||||
apps = settings.TENCENT_APPS
|
||||
else:
|
||||
raise ValueError(f"不支持的应用类型: {app_type}")
|
||||
|
||||
if not apps:
|
||||
raise ValueError(f"{app_type}未配置应用")
|
||||
|
||||
available_apps = []
|
||||
for app in apps:
|
||||
app_id = app.get("app_id")
|
||||
if not app_id:
|
||||
continue
|
||||
|
||||
result = await db.execute(
|
||||
select(func.count(UserOAuth.id)).where(UserOAuth.appid == app_id)
|
||||
)
|
||||
count = result.scalar() or 0
|
||||
|
||||
if count < 5000:
|
||||
available_apps.append(app)
|
||||
|
||||
if not available_apps:
|
||||
raise ValueError("所有应用授权已超过最大数量")
|
||||
|
||||
return random.choice(available_apps)
|
||||
|
||||
|
||||
async def build_oauth_url(oauth_type: int, user_id: str) -> str:
|
||||
if oauth_type == 1:
|
||||
return await _build_juliang_oauth_url(oauth_type, user_id, app_type)
|
||||
elif oauth_type == 2:
|
||||
return await _build_kuaishou_oauth_url(oauth_type, user_id)
|
||||
elif oauth_type == 3:
|
||||
return await _build_tencent_oauth_url(oauth_type, user_id)
|
||||
elif oauth_type == 4:
|
||||
return await _build_tencent_oauth_url(oauth_type, user_id)
|
||||
else:
|
||||
raise ValueError(f"不支持的应用类型: {app_type}")
|
||||
|
||||
|
||||
async def _build_juliang_oauth_url(oauth_type: int, user_id: str, app_type: str) -> str:
|
||||
async with AsyncSession() as db:
|
||||
app = await get_available_app(app_type, db)
|
||||
app_id = app.get("app_id")
|
||||
|
||||
redirect_uri = "https://open.oceanengine.com/audit/oauth.html"
|
||||
rid = "ktm0cl7napb"
|
||||
if oauth_type == 1:
|
||||
redirect_uri = "https://qianchuan.jinritemai.com/openapi/qc/audit/oauth.html"
|
||||
rid = "vr7kclvmvs9"
|
||||
|
||||
params = {
|
||||
"app_id": app_id,
|
||||
"state": f"{oauth_type}:{user_id}:{app_id}:{app_type}",
|
||||
"material_auth": 1,
|
||||
"rid": rid,
|
||||
}
|
||||
query_string = "&".join(f"{k}={v}" for k, v in params.items())
|
||||
return f"{redirect_uri}?{query_string}"
|
||||
|
||||
|
||||
async def _build_kuaishou_oauth_url(oauth_type: int, user_id: str) -> str:
|
||||
async with AsyncSession() as db:
|
||||
app = await get_available_app("kuaishou", db)
|
||||
app_id = app.get("app_id")
|
||||
|
||||
redirect_uri = f"{settings.BASE_URL}/api/user-oauth/callback"
|
||||
params = {
|
||||
"app_id": app_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": "basic",
|
||||
"state": f"{oauth_type}:{user_id}:{app_id}:kuaishou",
|
||||
}
|
||||
query_string = "&".join(f"{k}={v}" for k, v in params.items())
|
||||
return f"https://open.kuaishou.com/oauth2/authorize?{query_string}"
|
||||
|
||||
|
||||
async def _build_tencent_oauth_url(oauth_type: int, user_id: str) -> str:
|
||||
async with AsyncSession() as db:
|
||||
app = await get_available_app("tencent", db)
|
||||
app_id = app.get("app_id")
|
||||
|
||||
redirect_uri = f"{settings.BASE_URL}/api/user-oauth/callback"
|
||||
params = {
|
||||
"app_id": app_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": "get_user_info",
|
||||
"state": f"{oauth_type}:{user_id}:{app_id}:tencent",
|
||||
}
|
||||
query_string = "&".join(f"{k}={v}" for k, v in params.items())
|
||||
return f"https://api.e.qq.com/oauth/authorize?{query_string}"
|
||||
|
||||
|
||||
async def get_token_by_type(code: str, oauth_type: int, app_id: str, app_type: str) -> dict:
|
||||
if app_type in ("juliang_ad", "juliang_qianchuan"):
|
||||
return await get_juliang_token(code, oauth_type, app_id, app_type)
|
||||
elif app_type == "kuaishou":
|
||||
return await get_kuaishou_token(code, oauth_type, app_id)
|
||||
elif app_type == "tencent":
|
||||
return await get_tencent_token(code, oauth_type, app_id)
|
||||
else:
|
||||
raise ValueError(f"不支持的应用类型: {app_type}")
|
||||
|
||||
|
||||
async def get_juliang_token(code: str, oauth_type: int, app_id: str, app_type: str) -> dict:
|
||||
url = "https://api.oceanengine.com/open_api/oauth2/access_token/"
|
||||
|
||||
if app_type == "juliang_ad":
|
||||
apps = settings.JULIANG_AD_APPS
|
||||
else:
|
||||
apps = settings.JULIANG_QIANCHUAN_APPS
|
||||
|
||||
app = next((a for a in apps if a.get("app_id") == app_id), None)
|
||||
if not app:
|
||||
raise ValueError("应用配置不存在")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
data={
|
||||
"app_id": app_id,
|
||||
"secret": app.get("secret"),
|
||||
"auth_code": code,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
content = response.json()
|
||||
if content.get("code") != 0:
|
||||
raise ValueError(content.get("message", "获取token失败"))
|
||||
return content.get("data", {})
|
||||
|
||||
|
||||
async def get_kuaishou_token(code: str, oauth_type: int, app_id: str) -> dict:
|
||||
url = "https://open.kuaishou.com/oauth2/token"
|
||||
redirect_uri = f"{settings.BASE_URL}/api/user-oauth/callback"
|
||||
|
||||
app = next((a for a in settings.KUAISHOU_APPS if a.get("app_id") == app_id), None)
|
||||
if not app:
|
||||
raise ValueError("应用配置不存在")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
data={
|
||||
"app_id": app_id,
|
||||
"secret": app.get("secret"),
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": redirect_uri,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
async def get_tencent_token(code: str, oauth_type: int, app_id: str) -> dict:
|
||||
url = "https://api.e.qq.com/oauth/token"
|
||||
redirect_uri = f"{settings.BASE_URL}/api/user-oauth/callback"
|
||||
|
||||
app = next((a for a in settings.TENCENT_APPS if a.get("app_id") == app_id), None)
|
||||
if not app:
|
||||
raise ValueError("应用配置不存在")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
data={
|
||||
"app_id": app_id,
|
||||
"secret": app.get("secret"),
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": redirect_uri,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
async def get_account_info_by_type(token: dict, oauth_type: int, app_type: str) -> dict:
|
||||
if app_type in ("juliang_ad", "juliang_qianchuan"):
|
||||
return await _get_juliang_account_info(token)
|
||||
elif app_type == "kuaishou":
|
||||
return await _get_kuaishou_account_info(token)
|
||||
elif app_type == "tencent":
|
||||
return await _get_tencent_account_info(token)
|
||||
else:
|
||||
raise ValueError(f"不支持的应用类型: {app_type}")
|
||||
|
||||
|
||||
async def _get_juliang_account_info(token: dict) -> dict:
|
||||
access_token = token.get("access_token")
|
||||
url = "https://ad.oceanengine.com/openapi/oauth/user/info/"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
url,
|
||||
headers={"Access-Token": access_token},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise ValueError(data.get("message", "获取账户信息失败"))
|
||||
data = data.get("data", {})
|
||||
return {
|
||||
"account_id": data.get("advertiser_id", data.get("account_id", "")),
|
||||
"account_name": data.get("advertiser_name", data.get("account_name", "")),
|
||||
"account_role": data.get("role", ""),
|
||||
"account_username": data.get("username", ""),
|
||||
}
|
||||
|
||||
|
||||
async def _get_kuaishou_account_info(token: dict) -> dict:
|
||||
access_token = token.get("access_token")
|
||||
url = "https://open.kuaishou.com/api/user/info"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return {
|
||||
"account_id": data.get("account_id", ""),
|
||||
"account_name": data.get("account_name", ""),
|
||||
"account_role": data.get("role", ""),
|
||||
"account_username": data.get("username", ""),
|
||||
}
|
||||
|
||||
|
||||
async def _get_tencent_account_info(token: dict) -> dict:
|
||||
access_token = token.get("access_token")
|
||||
url = "https://api.e.qq.com/user/info"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return {
|
||||
"account_id": data.get("account_id", ""),
|
||||
"account_name": data.get("account_name", ""),
|
||||
"account_role": data.get("role", ""),
|
||||
"account_username": data.get("username", ""),
|
||||
}
|
||||
|
||||
|
||||
async def save_oauth_token(
|
||||
db: AsyncSession,
|
||||
user_id: str,
|
||||
oauth_type: int,
|
||||
token: dict,
|
||||
account_info: dict,
|
||||
app_id: str,
|
||||
) -> UserOAuth:
|
||||
config = OAUTH_TYPE_CONFIG.get(oauth_type)
|
||||
if not config:
|
||||
raise ValueError(f"不支持的oauth_type: {oauth_type}")
|
||||
|
||||
access_token = token.get("access_token")
|
||||
access_token_expired = token.get("expires_in")
|
||||
refresh_token = token.get("refresh_token")
|
||||
refresh_token_expired = token.get("refresh_token_expires_in")
|
||||
|
||||
expires_at = None
|
||||
if access_token_expired:
|
||||
expires_at = datetime.now().timestamp() + int(access_token_expired)
|
||||
expires_at = datetime.fromtimestamp(expires_at)
|
||||
|
||||
refresh_expires_at = None
|
||||
if refresh_token_expired:
|
||||
refresh_expires_at = datetime.now().timestamp() + int(refresh_token_expired)
|
||||
refresh_expires_at = datetime.fromtimestamp(refresh_expires_at)
|
||||
|
||||
existing = await db.execute(
|
||||
select(UserOAuth).where(
|
||||
UserOAuth.user_id == user_id,
|
||||
UserOAuth.open_type == oauth_type,
|
||||
UserOAuth.account_id == account_info.get("account_id", ""),
|
||||
).limit(1)
|
||||
)
|
||||
existing_oauth = existing.scalar_one_or_none()
|
||||
|
||||
if existing_oauth:
|
||||
existing_oauth.access_token = access_token
|
||||
existing_oauth.access_token_expired = expires_at
|
||||
existing_oauth.refresh_token = refresh_token
|
||||
existing_oauth.refresh_token_expired = refresh_expires_at
|
||||
existing_oauth.account_name = account_info.get("account_name", "")
|
||||
existing_oauth.account_role = account_info.get("account_role", "")
|
||||
existing_oauth.account_username = account_info.get("account_username", "")
|
||||
existing_oauth.appid = app_id
|
||||
await db.flush()
|
||||
return existing_oauth
|
||||
|
||||
user_oauth = UserOAuth(
|
||||
id=generate_id(),
|
||||
account_id=account_info.get("account_id", ""),
|
||||
account_name=account_info.get("account_name", ""),
|
||||
account_role=account_info.get("account_role", ""),
|
||||
account_username=account_info.get("account_username", ""),
|
||||
user_id=user_id,
|
||||
open_type=oauth_type,
|
||||
port_type=config["port_type"],
|
||||
appid=app_id,
|
||||
access_token=access_token,
|
||||
access_token_expired=expires_at,
|
||||
refresh_token=refresh_token,
|
||||
refresh_token_expired=refresh_expires_at,
|
||||
material_auth_status=True,
|
||||
)
|
||||
|
||||
db.add(user_oauth)
|
||||
await db.flush()
|
||||
return user_oauth
|
||||
@@ -10,6 +10,7 @@ try:
|
||||
generation_poll_tasks,
|
||||
generation_download_tasks,
|
||||
generation_recovery_tasks,
|
||||
hot_opening_replicate_tasks
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -49,6 +49,8 @@ if broker_url:
|
||||
"generation.chatapi_create_generation_task": {"queue": "gen_chatapi_create"},
|
||||
"generation.poll_generation_task": {"queue": "gen_provider_poll"},
|
||||
"generation.download_generation_result_task": {"queue": "gen_result_download"},
|
||||
"hot_opening.start_image_prompt_optimize": {"queue": "gen_chatapi_create"},
|
||||
"hot_opening.start_video_prompt_optimize": {"queue": "gen_chatapi_create"},
|
||||
"generation.recover_download_tasks_once": {"queue": "gen_result_download"},
|
||||
"generation.recover_generation_tasks_once": {"queue": "gen_result_download"},
|
||||
"app.tasks.cleanup.*": {"queue": "default"},
|
||||
|
||||
@@ -13,6 +13,8 @@ from app.services.generation_refund_service import mark_chat_generation_task_fai
|
||||
from app.services.generation_provider_service import create_provider_task
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
ALLOWED_GENERATION_MODES = {"chatapi_async", "hot_opening_replicate"}
|
||||
|
||||
|
||||
def _get_first_value(obj: Any, *field_names: str) -> Optional[Any]:
|
||||
"""
|
||||
@@ -63,6 +65,14 @@ def _build_optimized_prompt_by_params(task: ChatGenerationTask) -> str:
|
||||
base_prompt = original_prompt.rstrip(",,。;; \n\t")
|
||||
|
||||
gen_type = (_to_clean_str(getattr(task, "gen_type", None)) or "").lower()
|
||||
generation_mode = _to_clean_str(getattr(task, "generation_mode", None)) or ""
|
||||
|
||||
# 爆款开头复刻第5步的视频生成,original_prompt 已经是视频提词 JSON schema。
|
||||
# 不能再追加“时长/比例/分辨率”中文参数,否则会污染 schema。
|
||||
if generation_mode == "hot_opening_replicate" and gen_type == "video":
|
||||
stripped = base_prompt.strip()
|
||||
if stripped.startswith("{") or stripped.startswith("["):
|
||||
return base_prompt
|
||||
|
||||
duration = _get_first_value(task, "duration")
|
||||
aspect_ratio = _get_first_value(task, "aspect_ratio")
|
||||
@@ -113,7 +123,7 @@ async def _run(task_id: str):
|
||||
).with_for_update().limit(1))
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task or task.generation_mode != "chatapi_async":
|
||||
if not task or task.generation_mode not in ALLOWED_GENERATION_MODES:
|
||||
return
|
||||
|
||||
if task.status != "generating":
|
||||
@@ -128,6 +138,9 @@ async def _run(task_id: str):
|
||||
)
|
||||
await db.commit()
|
||||
await log_task_event(task, event_type="TASK_TIMEOUT", to_status="failed", to_stage="timeout")
|
||||
from app.services.generation_module_hook_service import notify_chat_generation_task_finished
|
||||
await notify_chat_generation_task_finished(db, task)
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
if task.pipeline_stage not in ("queued", "preparing", "creating_provider_task"):
|
||||
@@ -253,6 +266,9 @@ async def _run(task_id: str):
|
||||
)
|
||||
await db.commit()
|
||||
await log_task_event(task, event_type="TASK_FAILED", message=task.error_message)
|
||||
from app.services.generation_module_hook_service import notify_chat_generation_task_finished
|
||||
await notify_chat_generation_task_finished(db, task)
|
||||
await db.commit()
|
||||
|
||||
|
||||
if celery_app:
|
||||
|
||||
@@ -21,6 +21,8 @@ from app.services.generation_refund_service import mark_chat_generation_task_fai
|
||||
from app.services.resource_accounting_service import record_chat_task_generated_resource
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
ALLOWED_GENERATION_MODES = {"chatapi_async", "hot_opening_replicate"}
|
||||
|
||||
DOWNLOAD_QUEUE = "gen_result_download"
|
||||
DOWNLOAD_STAGE_QUEUED = "download_queued"
|
||||
DOWNLOAD_STAGE_DOWNLOADING = "downloading"
|
||||
@@ -108,7 +110,7 @@ async def enqueue_download_task(
|
||||
countdown: int | None = None,
|
||||
) -> str | None:
|
||||
"""统一投递图片/视频下载任务,并同步 DB + Redis active 注册表。"""
|
||||
if not task or task.generation_mode != "chatapi_async":
|
||||
if not task or task.generation_mode not in ALLOWED_GENERATION_MODES:
|
||||
return None
|
||||
if task.status != "generating":
|
||||
return None
|
||||
@@ -159,7 +161,7 @@ async def _reload_task(db: AsyncSession, task_id: str) -> ChatGenerationTask | N
|
||||
async def _claim_download_lease(db: AsyncSession, task: ChatGenerationTask) -> bool:
|
||||
now = _now()
|
||||
|
||||
if not task or task.generation_mode != "chatapi_async":
|
||||
if not task or task.generation_mode not in ALLOWED_GENERATION_MODES:
|
||||
return False
|
||||
if task.status != "generating":
|
||||
return False
|
||||
@@ -310,6 +312,10 @@ async def _run(task_id: str):
|
||||
await db.commit()
|
||||
await remove_download_active(task.id)
|
||||
|
||||
from app.services.generation_module_hook_service import notify_chat_generation_task_finished
|
||||
await notify_chat_generation_task_finished(db, task)
|
||||
await db.commit()
|
||||
|
||||
await log_task_event(
|
||||
task,
|
||||
event_type="DOWNLOAD_SUCCESS",
|
||||
@@ -348,6 +354,10 @@ async def _run(task_id: str):
|
||||
|
||||
await remove_download_active(task.id)
|
||||
|
||||
from app.services.generation_module_hook_service import notify_chat_generation_task_finished
|
||||
await notify_chat_generation_task_finished(db, task)
|
||||
await db.commit()
|
||||
|
||||
await log_task_event(
|
||||
task,
|
||||
event_type="DOWNLOAD_FAILED",
|
||||
|
||||
@@ -13,6 +13,8 @@ from app.services.generation_refund_service import mark_chat_generation_task_fai
|
||||
from app.services.generation_provider_service import poll_provider_task
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
ALLOWED_GENERATION_MODES = {"chatapi_async", "hot_opening_replicate"}
|
||||
|
||||
|
||||
def _is_success(status: str) -> bool:
|
||||
return status in ("succeeded", "success", "completed", "done")
|
||||
@@ -29,6 +31,12 @@ def _engine_snapshot(task: ChatGenerationTask) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
async def _notify_finished(db, task: ChatGenerationTask) -> None:
|
||||
from app.services.generation_module_hook_service import notify_chat_generation_task_finished
|
||||
|
||||
await notify_chat_generation_task_finished(db, task)
|
||||
|
||||
|
||||
async def _reload_task(db, task_id: str) -> ChatGenerationTask | None:
|
||||
"""
|
||||
rollback 后重新查询任务对象。
|
||||
@@ -54,7 +62,7 @@ async def _run(task_id: str):
|
||||
ChatGenerationTask.deleted_at.is_(None),
|
||||
).with_for_update().limit(1))
|
||||
task = result.scalar_one_or_none()
|
||||
if not task or task.generation_mode != "chatapi_async":
|
||||
if not task or task.generation_mode not in ALLOWED_GENERATION_MODES:
|
||||
return
|
||||
|
||||
# 只处理正在生成,且处于远程等待/轮询中的任务。
|
||||
@@ -68,6 +76,7 @@ async def _run(task_id: str):
|
||||
error_message="任务轮询超时",
|
||||
pipeline_stage="timeout",
|
||||
)
|
||||
await _notify_finished(db, task)
|
||||
await db.commit()
|
||||
await log_task_event(task, event_type="TASK_TIMEOUT", to_status="failed", to_stage="timeout")
|
||||
return
|
||||
@@ -79,6 +88,7 @@ async def _run(task_id: str):
|
||||
error_message="缺少外部任务ID",
|
||||
pipeline_stage="failed",
|
||||
)
|
||||
await _notify_finished(db, task)
|
||||
await db.commit()
|
||||
await log_task_event(task, event_type="POLL_FAILED", message=task.error_message)
|
||||
return
|
||||
@@ -130,6 +140,7 @@ async def _run(task_id: str):
|
||||
error_message="供应商任务成功但未返回结果URL",
|
||||
pipeline_stage="failed",
|
||||
)
|
||||
await _notify_finished(db, task)
|
||||
await db.commit()
|
||||
await log_task_event(task, event_type="POLL_FAILED", message=task.error_message)
|
||||
return
|
||||
@@ -153,6 +164,7 @@ async def _run(task_id: str):
|
||||
error_message=poll_result.get("error") or f"供应商任务失败: {status}",
|
||||
pipeline_stage="failed",
|
||||
)
|
||||
await _notify_finished(db, task)
|
||||
await db.commit()
|
||||
await log_task_event(task, event_type="POLL_FAILED", message=task.error_message, detail=poll_result)
|
||||
return
|
||||
@@ -194,6 +206,7 @@ async def _run(task_id: str):
|
||||
error_message=error_message,
|
||||
pipeline_stage="failed",
|
||||
)
|
||||
await _notify_finished(db, task)
|
||||
await db.commit()
|
||||
await log_task_event(task, event_type="POLL_FAILED", message=task.error_message)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.models.base import async_session
|
||||
from app.services.hot_opening_replicate_service import run_image_prompt_optimize, run_video_prompt_optimize
|
||||
from app.tasks.async_runner import run_async
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
|
||||
async def _run_image_prompt(project_id: str, step_id: str | None = None):
|
||||
async with async_session() as db:
|
||||
await run_image_prompt_optimize(db, project_id=project_id, step_id=step_id)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _run_video_prompt(project_id: str, step_id: str | None = None):
|
||||
async with async_session() as db:
|
||||
await run_video_prompt_optimize(db, project_id=project_id, step_id=step_id)
|
||||
await db.commit()
|
||||
|
||||
|
||||
if celery_app:
|
||||
@celery_app.task(name="hot_opening.start_image_prompt_optimize", bind=True, max_retries=3, default_retry_delay=30)
|
||||
def start_image_prompt_optimize(self, project_id: str, step_id: str | None = None):
|
||||
"""手动触发后的图片 AI 提词任务。
|
||||
|
||||
该任务路由到现有 gen_chatapi_create 队列,不需要新增 hot_opening worker。
|
||||
"""
|
||||
return run_async(_run_image_prompt(project_id, step_id))
|
||||
|
||||
@celery_app.task(name="hot_opening.start_video_prompt_optimize", bind=True, max_retries=3, default_retry_delay=30)
|
||||
def start_video_prompt_optimize(self, project_id: str, step_id: str | None = None):
|
||||
"""手动触发后的视频 AI 提词任务。
|
||||
|
||||
该任务路由到现有 gen_chatapi_create 队列,不需要新增 hot_opening worker。
|
||||
"""
|
||||
return run_async(_run_video_prompt(project_id, step_id))
|
||||
else:
|
||||
class _DisabledTask:
|
||||
def delay(self, *args, **kwargs):
|
||||
raise RuntimeError("Celery is disabled")
|
||||
|
||||
def apply_async(self, *args, **kwargs):
|
||||
raise RuntimeError("Celery is disabled")
|
||||
|
||||
start_image_prompt_optimize = _DisabledTask()
|
||||
start_video_prompt_optimize = _DisabledTask()
|
||||
@@ -1,5 +1,7 @@
|
||||
import time
|
||||
import random
|
||||
from datetime import datetime
|
||||
import secrets
|
||||
|
||||
|
||||
def generate_id() -> str:
|
||||
@@ -10,7 +12,10 @@ def generate_id() -> str:
|
||||
|
||||
|
||||
def generate_order_no() -> str:
|
||||
"""Generate a human-readable order number."""
|
||||
timestamp = int(time.time())
|
||||
randomness = random.randint(1000, 9999)
|
||||
return f"VG{timestamp}{randomness}"
|
||||
"""Generate a human-readable order number with yyyymmddhhmmss format."""
|
||||
# 格式化为 yyyymmddhhmmss 格式的时间戳
|
||||
now = datetime.now()
|
||||
timestamp = now.strftime("%Y%m%d%H%M%S")
|
||||
# 使用密码学安全的随机数生成 8位纯数字,防止并发冲突
|
||||
random_part = ''.join(str(secrets.randbelow(10)) for _ in range(8))
|
||||
return f"MZZC{timestamp}{random_part}"
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# ⚠️ 系统依赖(需手动安装):
|
||||
# ca-certificates — HTTPS 请求必须(新服务器/容器常缺)
|
||||
# FFmpeg — 视频封面截帧(可选)
|
||||
|
||||
[project]
|
||||
name = "videogen-api"
|
||||
version = "1.0.0"
|
||||
@@ -22,6 +26,8 @@ dependencies = [
|
||||
pg = ["asyncpg>=0.30.0"]
|
||||
redis = ["redis>=5.2.0"]
|
||||
celery = ["celery>=5.4.0", "redis>=5.2.0"]
|
||||
alipay = ["alipay-sdk-python>=3.7.1160"]
|
||||
volc = ["volcengine-python-sdk>=1.1.0"]
|
||||
dev = [
|
||||
"pytest>=8.3.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
|
||||
Generated
+1
-1
@@ -4000,7 +4000,7 @@
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1 +1,20 @@
|
||||
{}
|
||||
{
|
||||
"src/pages/GenerateConver.tsx": {
|
||||
"description": "ai创建"
|
||||
},
|
||||
"src/pages/GeneratedRecord.tsx": {
|
||||
"description": "生成历史"
|
||||
},
|
||||
"src/pages/GeneratePage.tsx": {
|
||||
"description": "生成记录"
|
||||
},
|
||||
"src/pages/ProjectsPage.tsx": {
|
||||
"description": "我的项目"
|
||||
},
|
||||
"src/pages/RemoveLens.tsx": {
|
||||
"description": "拆镜复刻"
|
||||
},
|
||||
"src/pages/InitialReplication.tsx": {
|
||||
"description": "爆款开头复刻"
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import InitialInfo from './pages/InitialInfo';
|
||||
import RemoveLens from './pages/RemoveLens';
|
||||
import GeneratedRecord from './pages/GeneratedRecord';
|
||||
import AuthorizationPage from './pages/AuthorizationPage';
|
||||
import RemoveInfo from './pages/RemoveInfo';
|
||||
|
||||
|
||||
|
||||
@@ -96,6 +97,7 @@ const App = () => {
|
||||
<Route path="initial" element={<InitialReplication />} />
|
||||
<Route path="initial/:creatID/initialinfo" element={<InitialInfo />} />
|
||||
<Route path="removelens" element={<RemoveLens />} />
|
||||
<Route path="removelens/:creatID/removeinfo" element={<RemoveInfo />} />
|
||||
<Route path="generated" element={<GeneratedRecord />} />
|
||||
<Route path="authorization" element={<AuthorizationPage />} />
|
||||
|
||||
|
||||
@@ -262,6 +262,27 @@ export async function getMenuConfigs(): Promise<any[]> {
|
||||
export async function getRechargePackages(): Promise<any[]> {
|
||||
return api.get('/recharge-packages');
|
||||
}
|
||||
|
||||
export async function getPaymentMethods(): Promise<{ alipay: boolean; wechat: boolean }> {
|
||||
return api.get('/payments/methods');
|
||||
}
|
||||
|
||||
export async function createRechargeOrder(planId: string, method: string = 'wechat'): Promise<any> {
|
||||
return api.post('/payments/recharge', { plan: planId, method });
|
||||
}
|
||||
|
||||
export async function getPaymentOrders(): Promise<any[]> {
|
||||
return api.get('/payments/orders');
|
||||
}
|
||||
|
||||
export async function getPaymentOrder(orderNo: string): Promise<any> {
|
||||
return api.get(`/payments/orders/${orderNo}`);
|
||||
}
|
||||
|
||||
export async function cancelPaymentOrder(orderNo: string): Promise<void> {
|
||||
return api.post(`/payments/orders/${orderNo}/cancel`);
|
||||
}
|
||||
|
||||
export async function getCreditRatios(): Promise<any[]> {
|
||||
return api.get('/credits/ratios');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography } from 'antd';
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography, Radio } from 'antd';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
@@ -21,12 +21,13 @@ import {
|
||||
FireFilled,
|
||||
CrownFilled,
|
||||
BankFilled,
|
||||
QrcodeOutlined,
|
||||
CloseOutlined,
|
||||
WechatOutlined,
|
||||
AlipayCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../store/useAuthStore';
|
||||
import { getMenuConfigs, getRechargePackages, getNotifications, markNotificationRead, getSiteInfo } from '../../api';
|
||||
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getNotifications, markNotificationRead, getSiteInfo } from '../../api';
|
||||
import NotificationPopup from '../NotificationPopup';
|
||||
|
||||
interface MenuConfig {
|
||||
@@ -88,7 +89,17 @@ const AppLayout: React.FC = () => {
|
||||
const [siteName, setSiteName] = useState('VideoGen.AI');
|
||||
const [siteLogo, setSiteLogo] = useState('');
|
||||
const [qrCodeModalOpen, setQrCodeModalOpen] = useState(false);
|
||||
const [currentPaymentInfo, setCurrentPaymentInfo] = useState<{ price: number; credits: number; qrCode: string } | null>(null);
|
||||
const [currentPaymentInfo, setCurrentPaymentInfo] = useState<{ price: number; credits: number; qrCode: string; method: string } | null>(null);
|
||||
const [paymentMethod, setPaymentMethod] = useState<string>('alipay');
|
||||
const [paying, setPaying] = useState(false);
|
||||
const [countdown, setCountdown] = useState(180); // 默认180秒超时
|
||||
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const countdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const currentOrderNoRef = useRef<string | null>(null);
|
||||
const [enabledMethods, setEnabledMethods] = useState<{ alipay: boolean; wechat: boolean }>({ alipay: false, wechat: false });
|
||||
|
||||
// LocalStorage keys
|
||||
const PENDING_ORDER_KEY = 'pending_payment_order';
|
||||
|
||||
// 监听预览弹窗状态,关闭浮动按钮
|
||||
useEffect(() => {
|
||||
@@ -114,6 +125,57 @@ const AppLayout: React.FC = () => {
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
// 检查并恢复待处理的支付订单
|
||||
useEffect(() => {
|
||||
const checkPendingOrder = async () => {
|
||||
const savedOrderStr = localStorage.getItem(PENDING_ORDER_KEY);
|
||||
if (savedOrderStr) {
|
||||
try {
|
||||
const savedOrder = JSON.parse(savedOrderStr);
|
||||
// 查询订单状态
|
||||
const order = await getPaymentOrder(savedOrder.orderNo);
|
||||
if (order.status === 'pending') {
|
||||
// 订单仍然待支付,恢复弹窗
|
||||
setCurrentPaymentInfo({
|
||||
price: savedOrder.price,
|
||||
credits: savedOrder.credits,
|
||||
qrCode: savedOrder.qrCode,
|
||||
method: savedOrder.method,
|
||||
});
|
||||
currentOrderNoRef.current = savedOrder.orderNo;
|
||||
// 计算剩余时间
|
||||
const now = Date.now();
|
||||
const createdAt = new Date(savedOrder.createdAt).getTime();
|
||||
const timeoutSeconds = savedOrder.timeoutSeconds || 180;
|
||||
const elapsedSeconds = Math.floor((now - createdAt) / 1000);
|
||||
const remainingSeconds = Math.max(0, timeoutSeconds - elapsedSeconds);
|
||||
|
||||
if (remainingSeconds > 0) {
|
||||
setQrCodeModalOpen(true);
|
||||
startPolling(savedOrder.orderNo, remainingSeconds);
|
||||
} else {
|
||||
// 已超时,清除
|
||||
localStorage.removeItem(PENDING_ORDER_KEY);
|
||||
}
|
||||
} else if (order.status === 'paid') {
|
||||
// 已支付
|
||||
message.success('支付成功!积分已到账');
|
||||
useAuthStore.getState().refreshUser();
|
||||
localStorage.removeItem(PENDING_ORDER_KEY);
|
||||
} else {
|
||||
// 订单已取消或其他状态,清除
|
||||
localStorage.removeItem(PENDING_ORDER_KEY);
|
||||
}
|
||||
} catch {
|
||||
// 查询失败,清除
|
||||
localStorage.removeItem(PENDING_ORDER_KEY);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkPendingOrder();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getMenuConfigs().then(data => {
|
||||
let items = data.filter((m: any) => m.is_active !== false && m.isActive !== false);
|
||||
@@ -136,6 +198,12 @@ const AppLayout: React.FC = () => {
|
||||
getRechargePackages().then(data => {
|
||||
setRechargeOptions(data.filter((p: any) => p.is_active !== false && p.isActive !== false));
|
||||
}).catch(() => {});
|
||||
getPaymentMethods().then(data => {
|
||||
setEnabledMethods(data);
|
||||
// Auto-select the first enabled method
|
||||
if (data.alipay) setPaymentMethod('alipay');
|
||||
else if (data.wechat) setPaymentMethod('wechat');
|
||||
}).catch(() => {});
|
||||
loadNotifications();
|
||||
}, [user]);
|
||||
|
||||
@@ -173,6 +241,68 @@ const AppLayout: React.FC = () => {
|
||||
setRechargeModalOpen(true);
|
||||
};
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollingTimerRef.current) {
|
||||
clearInterval(pollingTimerRef.current);
|
||||
pollingTimerRef.current = null;
|
||||
}
|
||||
if (countdownTimerRef.current) {
|
||||
clearInterval(countdownTimerRef.current);
|
||||
countdownTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startPolling = useCallback((orderNo: string, timeoutSeconds: number = 180) => {
|
||||
stopPolling();
|
||||
setCountdown(timeoutSeconds);
|
||||
|
||||
// 订单状态轮询(每2秒查询一次,只查询当前订单
|
||||
const pollingTimer = setInterval(async () => {
|
||||
try {
|
||||
const order = await getPaymentOrder(orderNo);
|
||||
if (order.status === 'paid') {
|
||||
stopPolling();
|
||||
currentOrderNoRef.current = null;
|
||||
localStorage.removeItem(PENDING_ORDER_KEY);
|
||||
message.success('支付成功!积分已到账');
|
||||
useAuthStore.getState().refreshUser();
|
||||
setQrCodeModalOpen(false);
|
||||
setCurrentPaymentInfo(null);
|
||||
setSelectedPlan(null);
|
||||
} else if (order.status === 'cancelled') {
|
||||
stopPolling();
|
||||
currentOrderNoRef.current = null;
|
||||
localStorage.removeItem(PENDING_ORDER_KEY);
|
||||
}
|
||||
} catch {
|
||||
// ignore polling errors
|
||||
}
|
||||
}, 2000);
|
||||
pollingTimerRef.current = pollingTimer;
|
||||
|
||||
// 倒计时
|
||||
const countdownTimer = setInterval(() => {
|
||||
setCountdown(prev => {
|
||||
if (prev <= 1) {
|
||||
// 超时自动取消
|
||||
stopPolling();
|
||||
if (currentOrderNoRef.current) {
|
||||
cancelPaymentOrder(currentOrderNoRef.current).catch(() => {});
|
||||
currentOrderNoRef.current = null;
|
||||
}
|
||||
localStorage.removeItem(PENDING_ORDER_KEY);
|
||||
message.warning('订单已超时,请重新充值');
|
||||
setQrCodeModalOpen(false);
|
||||
setCurrentPaymentInfo(null);
|
||||
setSelectedPlan(null);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
countdownTimerRef.current = countdownTimer;
|
||||
}, [stopPolling]);
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
{/* Desktop Sidebar */}
|
||||
@@ -516,28 +646,90 @@ const AppLayout: React.FC = () => {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ marginTop: 20, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
|
||||
{/* Payment method selection */}
|
||||
{(!enabledMethods.alipay && !enabledMethods.wechat) ? (
|
||||
<div style={{ marginTop: 20, marginBottom: 8, padding: 16, background: '#fef2f2', borderRadius: 12, border: '1px solid #fecaca' }}>
|
||||
<Typography.Text style={{ color: '#dc2626', fontSize: 13 }}>
|
||||
⚠️ 暂无可用的支付方式,请联系管理员开启支付功能
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: 20, marginBottom: 8 }}>
|
||||
<Typography.Text style={{ color: '#64748b', fontSize: 13, marginBottom: 8, display: 'block' }}>选择支付方式</Typography.Text>
|
||||
<Radio.Group value={paymentMethod} onChange={(e) => setPaymentMethod(e.target.value)}
|
||||
style={{ display: 'flex', gap: 12 }}>
|
||||
{enabledMethods.alipay && (
|
||||
<Radio.Button value="alipay" style={{
|
||||
flex: 1, textAlign: 'center', borderRadius: 10, height: 44, lineHeight: '42px',
|
||||
borderColor: paymentMethod === 'alipay' ? '#1677ff' : undefined,
|
||||
color: paymentMethod === 'alipay' ? '#1677ff' : undefined,
|
||||
}}>
|
||||
<AlipayCircleOutlined style={{ fontSize: 16, marginRight: 6 }} />
|
||||
支付宝
|
||||
</Radio.Button>
|
||||
)}
|
||||
{enabledMethods.wechat && (
|
||||
<Radio.Button value="wechat" style={{
|
||||
flex: 1, textAlign: 'center', borderRadius: 10, height: 44, lineHeight: '42px',
|
||||
borderColor: paymentMethod === 'wechat' ? '#07c160' : undefined,
|
||||
color: paymentMethod === 'wechat' ? '#07c160' : undefined,
|
||||
}}>
|
||||
<WechatOutlined style={{ fontSize: 16, marginRight: 6 }} />
|
||||
微信支付
|
||||
</Radio.Button>
|
||||
)}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button size="large" onClick={() => { setRechargeModalOpen(false); setSelectedPlan(null); }} style={{ borderRadius: 10, marginRight: 12 }}>取消</Button>
|
||||
<Button type="primary" size="large" disabled={!selectedPlan}
|
||||
onClick={() => {
|
||||
<Button type="primary" size="large" disabled={!selectedPlan || (!enabledMethods.alipay && !enabledMethods.wechat)} loading={paying}
|
||||
onClick={async () => {
|
||||
const plan = rechargeOptions.find((opt: any) => opt.id === selectedPlan);
|
||||
if (plan) {
|
||||
const totalCredits = (plan.credits || 0) + (plan.bonus_credits || plan.bonusCredits || 0);
|
||||
// 生成随机支付内容(模拟微信支付订单号)
|
||||
const orderId = `WX${Date.now()}${Math.random().toString(36).substr(2, 6).toUpperCase()}`;
|
||||
const paymentContent = JSON.stringify({
|
||||
orderId,
|
||||
amount: plan.price,
|
||||
credits: totalCredits,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
setCurrentPaymentInfo({
|
||||
price: plan.price,
|
||||
credits: totalCredits,
|
||||
qrCode: paymentContent
|
||||
});
|
||||
setRechargeModalOpen(false);
|
||||
setQrCodeModalOpen(true);
|
||||
if (!plan) return;
|
||||
const totalCredits = (plan.credits || 0) + (plan.bonus_credits || plan.bonusCredits || 0);
|
||||
try {
|
||||
setPaying(true);
|
||||
const order = await createRechargeOrder(plan.id, paymentMethod);
|
||||
if (order.paymentMethod === 'alipay' && order.qrUrl) {
|
||||
// Alipay: show the real QR code URL from the backend
|
||||
const paymentInfo = {
|
||||
price: plan.price,
|
||||
credits: totalCredits,
|
||||
qrCode: order.qrUrl,
|
||||
method: 'alipay',
|
||||
};
|
||||
setCurrentPaymentInfo(paymentInfo);
|
||||
setRechargeModalOpen(false);
|
||||
setQrCodeModalOpen(true);
|
||||
currentOrderNoRef.current = order.orderNo;
|
||||
|
||||
// 保存到 localStorage
|
||||
localStorage.setItem(PENDING_ORDER_KEY, JSON.stringify({
|
||||
orderNo: order.orderNo,
|
||||
price: plan.price,
|
||||
credits: totalCredits,
|
||||
qrCode: order.qrUrl,
|
||||
method: 'alipay',
|
||||
createdAt: order.createdAt || new Date().toISOString(),
|
||||
timeoutSeconds: 180,
|
||||
}));
|
||||
|
||||
// Start polling for payment status
|
||||
startPolling(order.orderNo);
|
||||
} else {
|
||||
// WeChat or mock mode (mock auto-completes, no QR needed)
|
||||
message.success('充值成功!积分已到账');
|
||||
useAuthStore.getState().refreshUser();
|
||||
setRechargeModalOpen(false);
|
||||
setSelectedPlan(null);
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '创建订单失败,请重试');
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
@@ -554,7 +746,17 @@ const AppLayout: React.FC = () => {
|
||||
{/* QR Code Payment Modal */}
|
||||
<Modal
|
||||
open={qrCodeModalOpen}
|
||||
onCancel={() => { setQrCodeModalOpen(false); setCurrentPaymentInfo(null); }}
|
||||
onCancel={async () => {
|
||||
stopPolling();
|
||||
// Mark order as cancelled if it's still pending
|
||||
if (currentOrderNoRef.current) {
|
||||
try { await cancelPaymentOrder(currentOrderNoRef.current); } catch {}
|
||||
currentOrderNoRef.current = null;
|
||||
}
|
||||
localStorage.removeItem(PENDING_ORDER_KEY);
|
||||
setQrCodeModalOpen(false);
|
||||
setCurrentPaymentInfo(null);
|
||||
}}
|
||||
footer={null}
|
||||
width={400}
|
||||
closable={false}
|
||||
@@ -567,15 +769,25 @@ const AppLayout: React.FC = () => {
|
||||
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
<div style={{
|
||||
width: 48, height: 48,
|
||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||
background: currentPaymentInfo?.method === 'alipay'
|
||||
? 'linear-gradient(135deg, #1677ff, #0958d9)'
|
||||
: 'linear-gradient(135deg, #07c160, #06ae56)',
|
||||
borderRadius: 16,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
margin: '0 auto 12px',
|
||||
}}>
|
||||
<QrcodeOutlined style={{ fontSize: 24, color: '#fff' }} />
|
||||
{currentPaymentInfo?.method === 'alipay'
|
||||
? <AlipayCircleOutlined style={{ fontSize: 24, color: '#fff' }} />
|
||||
: <WechatOutlined style={{ fontSize: 24, color: '#fff' }} />}
|
||||
</div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>微信支付</Typography.Title>
|
||||
<Typography.Text style={{ color: '#94a3b8', fontSize: 13 }}>请使用微信扫描二维码完成支付</Typography.Text>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
{currentPaymentInfo?.method === 'alipay' ? '支付宝支付' : '微信支付'}
|
||||
</Typography.Title>
|
||||
<Typography.Text style={{ color: '#94a3b8', fontSize: 13 }}>
|
||||
{currentPaymentInfo?.method === 'alipay'
|
||||
? '请使用支付宝扫描二维码完成支付'
|
||||
: '请使用微信扫描二维码完成支付'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{/* QR Code */}
|
||||
@@ -622,6 +834,23 @@ const AppLayout: React.FC = () => {
|
||||
}}>
|
||||
购买 {currentPaymentInfo?.credits || 0} 积分
|
||||
</div>
|
||||
{/* 倒计时显示 */}
|
||||
<div style={{
|
||||
marginTop: 12,
|
||||
padding: '8px 16px',
|
||||
background: countdown <= 30 ? '#fef2f2' : '#f0f9ff',
|
||||
borderRadius: 8,
|
||||
border: countdown <= 30 ? '1px solid #fecaca' : '1px solid #bae6fd',
|
||||
display: 'inline-block',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: countdown <= 30 ? '#dc2626' : '#0284c7',
|
||||
}}>
|
||||
订单将在 <span style={{ fontSize: 16, fontWeight: 800 }}>{countdown}</span> 秒后关闭
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -640,31 +869,24 @@ const AppLayout: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Footer Buttons */}
|
||||
<div style={{ marginTop: 20, display: 'flex', gap: 12 }}>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<Button
|
||||
size="large"
|
||||
onClick={() => { setQrCodeModalOpen(false); setCurrentPaymentInfo(null); }}
|
||||
style={{ flex: 1, borderRadius: 10 }}
|
||||
>
|
||||
取消支付
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={() => {
|
||||
message.success('支付成功!积分已到账');
|
||||
block
|
||||
onClick={async () => {
|
||||
stopPolling();
|
||||
if (currentOrderNoRef.current) {
|
||||
try { await cancelPaymentOrder(currentOrderNoRef.current); } catch {}
|
||||
currentOrderNoRef.current = null;
|
||||
}
|
||||
localStorage.removeItem(PENDING_ORDER_KEY);
|
||||
setQrCodeModalOpen(false);
|
||||
setCurrentPaymentInfo(null);
|
||||
setSelectedPlan(null);
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: 10,
|
||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||
border: 'none',
|
||||
}}
|
||||
style={{ borderRadius: 10 }}
|
||||
>
|
||||
已完成支付
|
||||
取消支付
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1066,10 +1066,10 @@ const GeneratedRecord: React.FC = () => {
|
||||
<div>
|
||||
<Button
|
||||
|
||||
style={{width:'100%', borderRadius: 8 ,marginTop:20,color:'#4c49cc'}}
|
||||
style={{ width: '100%', borderRadius: 8, marginTop: 20, color: '#4c49cc' }}
|
||||
>
|
||||
|
||||
推送巨量引擎后台
|
||||
推送媒体后台
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ function InitialInfo() {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div style={{ height: '94vh', background: '#f8fafc' }}>
|
||||
<div style={{ height: 'calc(94vh)', padding: 24, }}>
|
||||
<div style={{ height: 'calc(94vh)', }}>
|
||||
<div style={{ height: '100%', display: 'flex', justifyContent: 'space-between', gap: '2%' }}>
|
||||
<div style={{ width: '70%', background: '#fff', borderRadius: 12, overflowY: 'auto' }}>
|
||||
<div style={{ borderBottom: '1px solid #e2e8f0', width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px', boxSizing: 'border-box' }}>
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Button, Table, Tag, Drawer, Input, Upload, message } from 'antd';
|
||||
import { ArrowLeftOutlined, PlayCircleOutlined, XOutlined, PlusOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
function RemoveInfo() {
|
||||
const { creatID } = useParams<{ creatID: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||
const [currentSegment, setCurrentSegment] = useState<number | null>(null);
|
||||
const [productName, setProductName] = useState('');
|
||||
const [productSellingPoint, setProductSellingPoint] = useState('');
|
||||
const [productImage, setProductImage] = useState('');
|
||||
const [detailImage, setDetailImage] = useState('');
|
||||
|
||||
const handleGenerate = (segmentId: number) => {
|
||||
setCurrentSegment(segmentId);
|
||||
setDrawerVisible(true);
|
||||
};
|
||||
|
||||
const handleCloseDrawer = () => {
|
||||
setDrawerVisible(false);
|
||||
setCurrentSegment(null);
|
||||
setProductName('');
|
||||
setProductSellingPoint('');
|
||||
setProductImage('');
|
||||
setDetailImage('');
|
||||
};
|
||||
|
||||
const handleProductImageChange: any = (info: any) => {
|
||||
if (info.fileList.length > 0) {
|
||||
const file = info.fileList[0];
|
||||
if (file.originFileObj) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
setProductImage(e.target?.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file.originFileObj);
|
||||
}
|
||||
} else {
|
||||
setProductImage('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDetailImageChange: any = (info: any) => {
|
||||
if (info.fileList.length > 0) {
|
||||
const file = info.fileList[0];
|
||||
if (file.originFileObj) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
setDetailImage(e.target?.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file.originFileObj);
|
||||
}
|
||||
} else {
|
||||
setDetailImage('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualGenerate = () => {
|
||||
// 必填校验
|
||||
if (!productImage) {
|
||||
message.warning('请上传产品图');
|
||||
return;
|
||||
}
|
||||
if (!productName.trim()) {
|
||||
message.warning('请输入产品名称');
|
||||
return;
|
||||
}
|
||||
if (!productSellingPoint.trim()) {
|
||||
message.warning('请输入产品卖点');
|
||||
return;
|
||||
}
|
||||
|
||||
// 输出内容
|
||||
console.log('手动生成 - 片段', currentSegment);
|
||||
console.log('产品图:', productImage);
|
||||
console.log('细节图:', detailImage);
|
||||
console.log('产品名称:', productName);
|
||||
console.log('产品卖点:', productSellingPoint);
|
||||
|
||||
message.success(`手动生成成功!片段: ${currentSegment}`);
|
||||
};
|
||||
|
||||
const mockData = {
|
||||
productName: '返回',
|
||||
uploadTime: '2026-06-09 09:01:16',
|
||||
sellingPoints: ['一键匹配', '连麦聊天'],
|
||||
audience: '123123',
|
||||
audienceAnalysis: '123123',
|
||||
videoUrl: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=320&h=180&fit=crop',
|
||||
segments: [
|
||||
{
|
||||
key: '1',
|
||||
id: 1,
|
||||
timeRange: '00:00 - 00:03',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=120&h=80&fit=crop',
|
||||
content: '11111',
|
||||
lines: 'qqqqqqqqqqq',
|
||||
contentStrategy: '展示礼盒'
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
id: 2,
|
||||
timeRange: '00:03 - 00:06',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=120&h=80&fit=crop',
|
||||
content: '1231231231',
|
||||
lines: 'qqqqqqqqqqq',
|
||||
contentStrategy: '开箱展示'
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
id: 3,
|
||||
timeRange: '00:06 - 00:09',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=120&h=80&fit=crop',
|
||||
content: '123123',
|
||||
lines: 'qqqqqqqqqqq',
|
||||
contentStrategy: '取出产品'
|
||||
},
|
||||
]
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '片段',
|
||||
width: 100,
|
||||
render: (text: any, record: any) => (
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: '#333' }}>片段{record.id}</div>
|
||||
<div style={{ fontSize: 12, color: '#999' }}>{record.timeRange}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '片段视频',
|
||||
width: 120,
|
||||
render: (text: any, record: any) => (
|
||||
<div style={{ position: 'relative', width: 120, height: 80, borderRadius: 6, overflow: 'hidden' }}>
|
||||
<img
|
||||
src={record.thumbnail}
|
||||
alt={`片段${record.id}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 28,
|
||||
height: 28,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<PlayCircleOutlined style={{ fontSize: 16, color: '#fff' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '画面内容',
|
||||
width: 250,
|
||||
|
||||
render: (text: any, record: any) => (
|
||||
<div style={{ fontSize: 14, color: '#333', lineHeight: 1.6 }}>
|
||||
{record.content}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '台词',
|
||||
width: 250,
|
||||
|
||||
render: (text: any, record: any) => (
|
||||
<div style={{ fontSize: 14, color: '#333', lineHeight: 1.6 }}>
|
||||
{record.lines}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '内容策略',
|
||||
width: 120,
|
||||
align: 'left' as const,
|
||||
render: (text: any, record: any) => (
|
||||
<div style={{ fontSize: 14, color: '#333', fontWeight: 500 }}>
|
||||
{record.contentStrategy || '-'}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '素材',
|
||||
width: 140,
|
||||
align: 'center' as const,
|
||||
render: (text: any, record: any) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, color: '#999', padding: '12px 24px', border: '1px dashed #ddd', borderRadius: 4 }}>
|
||||
等待生成
|
||||
</div>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => handleGenerate(record.id)}
|
||||
style={{ color: '#656efa', fontSize: 12, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
视频生成
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div style={{ minHeight: '94vh', background: '#f5f5f5' }}>
|
||||
<div style={{ background: '#fff', padding: '16px 24px 0 24px', boxShadow: '0 2px 8px rgba(0,0,0,0.06)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate(-1)}
|
||||
style={{ fontSize: 16, color: '#666' }}
|
||||
/>
|
||||
<span style={{ fontSize: 18, fontWeight: 600 }}>{mockData.productName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ }}>
|
||||
<div style={{ background: '#fff', borderRadius: 12, padding: '10px 20px 20px 20px', marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 30, marginBottom: 12 }}>
|
||||
<div style={{ position: 'relative', width: 280, height: 160, borderRadius: 8, overflow: 'hidden', flexShrink: 0 }}>
|
||||
<img
|
||||
src={mockData.videoUrl}
|
||||
alt="视频缩略图"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 48,
|
||||
height: 48,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<PlayCircleOutlined style={{ fontSize: 28, color: '#fff' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 600, marginBottom: 16 }}>视频总结</h2>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<span style={{ color: '#999', fontSize: 12 }}>上传时间: {mockData.uploadTime}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div style={{ display: 'flex', marginBottom: 12 }}>
|
||||
<span style={{ color: '#999', fontSize: 14, marginRight: 12 }}>产品名称:</span>
|
||||
<span style={{ color: '#333', fontWeight: 500 }}>{mockData.productName}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', marginBottom: 12 }}>
|
||||
<span style={{ color: '#999', fontSize: 14, marginRight: 12 }}>卖点词:</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{mockData.sellingPoints.map((point, index) => (
|
||||
<Tag key={index} color="purple" style={{ fontSize: 12 }}>
|
||||
{point}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', marginBottom: 12 }}>
|
||||
<span style={{ color: '#999', fontSize: 14, marginRight: 12 }}>受众群体:</span>
|
||||
<span style={{ color: '#333' }}>{mockData.audience}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<span style={{ color: '#999', fontSize: 14, marginRight: 12 }}>受众分析:</span>
|
||||
<span style={{ color: '#333' }}>{mockData.audienceAnalysis}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: '#fff', borderRadius: 12, overflow: 'hidden' }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={mockData.segments}
|
||||
pagination={false}
|
||||
bordered={false}
|
||||
rowKey="key"
|
||||
scroll={{ y: 520 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
||||
<span>
|
||||
<span style={{ color: '#6366f1' }}>智能视频复刻</span>
|
||||
<span style={{ color: '#6366f1' }}>-片段{currentSegment}</span>
|
||||
</span>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<XOutlined />}
|
||||
onClick={handleCloseDrawer}
|
||||
style={{ padding: 0 }}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
placement="right"
|
||||
closable={false}
|
||||
onClose={handleCloseDrawer}
|
||||
open={drawerVisible}
|
||||
width={480}
|
||||
bodyStyle={{ padding: '24px' }}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<label style={{ fontWeight: 400, color: '#333', marginBottom: 12, display: 'block' }}>
|
||||
产品白底图 <span style={{ color: '#ff4d4f' }}>*</span>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
onChange={handleProductImageChange}
|
||||
maxCount={1}
|
||||
accept="image/*"
|
||||
style={{ width: 140, height: 140 }}
|
||||
>
|
||||
{!productImage && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
|
||||
<PlusOutlined style={{ fontSize: 24, color: '#999' }} />
|
||||
<span style={{ fontSize: 12, color: '#999' }}>产品图 *</span>
|
||||
</div>
|
||||
)}
|
||||
</Upload>
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
onChange={handleDetailImageChange}
|
||||
maxCount={1}
|
||||
accept="image/*"
|
||||
style={{ width: 140, height: 140 }}
|
||||
>
|
||||
{!detailImage && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
|
||||
<PlusOutlined style={{ fontSize: 24, color: '#999' }} />
|
||||
<span style={{ fontSize: 12, color: '#999' }}>细节图</span>
|
||||
</div>
|
||||
)}
|
||||
</Upload>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontWeight: 500, color: '#333', marginBottom: 12, display: 'block' }}>
|
||||
产品名称
|
||||
</label>
|
||||
<Input
|
||||
value={productName}
|
||||
onChange={(e) => setProductName(e.target.value)}
|
||||
placeholder="请输入产品名称"
|
||||
style={{ height: 48, borderRadius: 8 }}
|
||||
maxLength={10}
|
||||
showCount
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontWeight: 500, color: '#333', marginBottom: 12, display: 'block' }}>
|
||||
产品卖点
|
||||
</label>
|
||||
<TextArea
|
||||
value={productSellingPoint}
|
||||
onChange={(e) => setProductSellingPoint(e.target.value)}
|
||||
placeholder="请输入产品卖点"
|
||||
style={{ borderRadius: 8 }}
|
||||
maxLength={100}
|
||||
showCount
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, marginTop: 24 }}>
|
||||
<Button
|
||||
type="default"
|
||||
onClick={handleManualGenerate}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 48,
|
||||
borderRadius: 8,
|
||||
borderColor: '#6366f1',
|
||||
color: '#6366f1',
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
手动生成
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export default RemoveInfo;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,175 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
CalculatorOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
interface CreditRatio {
|
||||
id: string;
|
||||
modelName: string;
|
||||
resolution: string;
|
||||
ratio: number;
|
||||
baseCredits: number;
|
||||
perSecondCredits: number;
|
||||
}
|
||||
|
||||
const MOCK_RATIOS: CreditRatio[] = [
|
||||
{ id: 'cr-1', modelName: 'GPT-4o', resolution: '720p', ratio: 1.0, baseCredits: 60, perSecondCredits: 2 },
|
||||
{ id: 'cr-2', modelName: 'GPT-4o', resolution: '1080p', ratio: 1.5, baseCredits: 90, perSecondCredits: 3 },
|
||||
{ id: 'cr-3', modelName: 'GPT-4o', resolution: '4K', ratio: 2.5, baseCredits: 150, perSecondCredits: 5 },
|
||||
{ id: 'cr-4', modelName: 'DeepSeek-V3', resolution: '720p', ratio: 0.8, baseCredits: 48, perSecondCredits: 2 },
|
||||
{ id: 'cr-5', modelName: 'DeepSeek-V3', resolution: '1080p', ratio: 1.2, baseCredits: 72, perSecondCredits: 3 },
|
||||
{ id: 'cr-6', modelName: 'DeepSeek-V3', resolution: '4K', ratio: 2.0, baseCredits: 120, perSecondCredits: 4 },
|
||||
{ id: 'cr-7', modelName: '通用', resolution: '720p', ratio: 1.0, baseCredits: 60, perSecondCredits: 2 },
|
||||
{ id: 'cr-8', modelName: '通用', resolution: '1080p', ratio: 1.5, baseCredits: 90, perSecondCredits: 3 },
|
||||
{ id: 'cr-9', modelName: '通用', resolution: '4K', ratio: 2.5, baseCredits: 150, perSecondCredits: 5 },
|
||||
];
|
||||
|
||||
const AdminCreditRatios: React.FC = () => {
|
||||
const [ratios, setRatios] = useState<CreditRatio[]>(MOCK_RATIOS);
|
||||
const [modal, setModal] = useState<{ open: boolean; ratio: CreditRatio | null }>({ open: false, ratio: null });
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (modal.ratio) {
|
||||
setRatios(prev => prev.map(r => r.id === modal.ratio!.id ? { ...r, ...values } : r));
|
||||
message.success('已更新');
|
||||
} else {
|
||||
setRatios(prev => [...prev, { id: `cr-${Date.now()}`, ...values }]);
|
||||
message.success('已添加');
|
||||
}
|
||||
setModal({ open: false, ratio: null });
|
||||
form.resetFields();
|
||||
} catch { /* validation */ }
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setRatios(prev => prev.filter(r => r.id !== id));
|
||||
message.success('已删除');
|
||||
};
|
||||
|
||||
const openEdit = (ratio?: CreditRatio) => {
|
||||
setModal({ open: true, ratio: ratio || null });
|
||||
if (ratio) form.setFieldsValue(ratio);
|
||||
else { form.resetFields(); form.setFieldsValue({ ratio: 1.0, baseCredits: 60, perSecondCredits: 2 }); }
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '模型', dataIndex: 'modelName', width: 150,
|
||||
render: (v: string) => <Tag color="purple">{v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '分辨率', dataIndex: 'resolution', width: 100,
|
||||
render: (v: string) => {
|
||||
const colors: Record<string, string> = { '720p': 'default', '1080p': 'blue', '4K': 'gold' };
|
||||
return <Tag color={colors[v] || 'default'}>{v}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '倍率', dataIndex: 'ratio', width: 100, sorter: (a: CreditRatio, b: CreditRatio) => a.ratio - b.ratio,
|
||||
render: (v: number) => (
|
||||
<Typography.Text strong style={{ color: v >= 2 ? '#ef4444' : v >= 1.5 ? '#f59e0b' : '#10b981' }}>
|
||||
x{v}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '基础积分', dataIndex: 'baseCredits', width: 100,
|
||||
render: (v: number) => <Typography.Text>{v} 积分</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '每秒积分', dataIndex: 'perSecondCredits', width: 100,
|
||||
render: (v: number) => <Typography.Text>{v} 积分/秒</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '示例计算 (15秒)', key: 'example', width: 120,
|
||||
render: (_: any, r: CreditRatio) => {
|
||||
const total = Math.round((r.baseCredits + r.perSecondCredits * 15) * r.ratio);
|
||||
return <Typography.Text strong style={{ color: '#6366f1' }}>{total} 积分</Typography.Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
|
||||
render: (_: any, r: CreditRatio) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
|
||||
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<CalculatorOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>积分比例配置</Typography.Text>
|
||||
<Tag color="purple">{ratios.length} 条规则</Tag>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
|
||||
添加比例
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16, fontSize: 13 }}>
|
||||
积分计算公式:(基础积分 + 每秒积分 x 视频时长) x 模型倍率
|
||||
</Typography.Text>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={ratios}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
scroll={{ x: 800 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={<Space><CalculatorOutlined />{modal.ratio ? '编辑比例' : '添加比例'}</Space>}
|
||||
open={modal.open}
|
||||
onOk={handleSave}
|
||||
onCancel={() => { setModal({ open: false, ratio: null }); form.resetFields(); }}
|
||||
okText="保存" cancelText="取消" width={480}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="modelName" label="模型" rules={[{ required: true }]}>
|
||||
<Select size="large" options={[
|
||||
{ value: 'GPT-4o', label: 'GPT-4o' },
|
||||
{ value: 'DeepSeek-V3', label: 'DeepSeek-V3' },
|
||||
{ value: '通用', label: '通用 (默认)' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="resolution" label="分辨率" rules={[{ required: true }]}>
|
||||
<Select size="large" options={[
|
||||
{ value: '720p', label: '720p' },
|
||||
{ value: '1080p', label: '1080p' },
|
||||
{ value: '4K', label: '4K' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="ratio" label="倍率" style={{ flex: 1 }} rules={[{ required: true }]}>
|
||||
<InputNumber min={0.1} max={10} step={0.1} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="baseCredits" label="基础积分" style={{ flex: 1 }} rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={1000} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="perSecondCredits" label="每秒积分" style={{ flex: 1 }} rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={100} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminCreditRatios;
|
||||
@@ -1,143 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, DatePicker, Select, Space, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
WalletOutlined, ArrowUpOutlined, ArrowDownOutlined, SearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
interface CreditRecord {
|
||||
id: string;
|
||||
username: string;
|
||||
type: 'recharge' | 'consume';
|
||||
amount: number;
|
||||
balanceAfter: number;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const MOCK_RECORDS: CreditRecord[] = [
|
||||
{ id: 'cr-1', username: 'videomaker', type: 'recharge', amount: 3000, balanceAfter: 3000, description: '会员充值赠送', createdAt: '2026-04-28 10:00:00' },
|
||||
{ id: 'cr-2', username: 'videomaker', type: 'consume', amount: -120, balanceAfter: 2880, description: '提示词优化 - 电商广告视频', createdAt: '2026-04-29 14:22:00' },
|
||||
{ id: 'cr-3', username: 'designer', type: 'recharge', amount: 2000, balanceAfter: 2000, description: '进阶包充值', createdAt: '2026-04-29 16:00:00' },
|
||||
{ id: 'cr-4', username: 'videomaker', type: 'consume', amount: -80, balanceAfter: 2800, description: '提示词优化 - 教育课程视频', createdAt: '2026-04-30 09:15:00' },
|
||||
{ id: 'cr-5', username: 'designer', type: 'consume', amount: -100, balanceAfter: 1900, description: '提示词优化 - 品牌故事视频', createdAt: '2026-05-01 11:30:00' },
|
||||
{ id: 'cr-6', username: 'marketer', type: 'recharge', amount: 5000, balanceAfter: 5000, description: '专业包充值', createdAt: '2026-05-02 08:00:00' },
|
||||
{ id: 'cr-7', username: 'videomaker', type: 'recharge', amount: 500, balanceAfter: 3300, description: '活动赠送积分', createdAt: '2026-05-02 11:00:00' },
|
||||
{ id: 'cr-8', username: 'marketer', type: 'consume', amount: -120, balanceAfter: 4880, description: '提示词优化 - 产品宣传视频', createdAt: '2026-05-03 15:20:00' },
|
||||
{ id: 'cr-9', username: 'editor', type: 'recharge', amount: 1500, balanceAfter: 1500, description: '体验包充值', createdAt: '2026-05-04 10:00:00' },
|
||||
{ id: 'cr-10', username: 'designer', type: 'consume', amount: -200, balanceAfter: 1700, description: '提示词优化 - 游戏预告片', createdAt: '2026-05-05 14:45:00' },
|
||||
];
|
||||
|
||||
const AdminCreditRecords: React.FC = () => {
|
||||
const [records, setRecords] = useState<CreditRecord[]>(MOCK_RECORDS);
|
||||
const [typeFilter, setTypeFilter] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const filtered = typeFilter ? records.filter(r => r.type === typeFilter) : records;
|
||||
|
||||
const totalRecharge = records.filter(r => r.type === 'recharge').reduce((s, r) => s + r.amount, 0);
|
||||
const totalConsume = records.filter(r => r.type === 'consume').reduce((s, r) => s + Math.abs(r.amount), 0);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '用户', dataIndex: 'username', width: 120,
|
||||
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '类型', dataIndex: 'type', width: 100,
|
||||
render: (v: string) => (
|
||||
<Tag color={v === 'recharge' ? 'green' : 'red'} icon={v === 'recharge' ? <ArrowUpOutlined /> : <ArrowDownOutlined />}>
|
||||
{v === 'recharge' ? '充值' : '消费'}
|
||||
</Tag>
|
||||
),
|
||||
filters: [
|
||||
{ text: '充值', value: 'recharge' },
|
||||
{ text: '消费', value: 'consume' },
|
||||
],
|
||||
onFilter: (value: any, record: CreditRecord) => record.type === value,
|
||||
},
|
||||
{
|
||||
title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: CreditRecord, b: CreditRecord) => a.amount - b.amount,
|
||||
render: (v: number) => (
|
||||
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>
|
||||
{v > 0 ? '+' : ''}{v.toLocaleString()}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '变动后余额', dataIndex: 'balanceAfter', width: 120,
|
||||
render: (v: number) => <Typography.Text type="secondary">{v.toLocaleString()}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '说明', dataIndex: 'description', ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '时间', dataIndex: 'createdAt', width: 160,
|
||||
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{v}</Typography.Text>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Summary Cards */}
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
|
||||
<Card bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: 'rgba(16,185,129,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20, color: '#10b981',
|
||||
}}><ArrowUpOutlined /></div>
|
||||
<div>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>总充值</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{totalRecharge.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: 'rgba(239,68,68,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20, color: '#ef4444',
|
||||
}}><ArrowDownOutlined /></div>
|
||||
<div>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>总消费</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{totalConsume.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: 'rgba(99,102,241,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20, color: '#6366f1',
|
||||
}}><WalletOutlined /></div>
|
||||
<div>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>交易笔数</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#1a1a2e' }}>{records.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filtered}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 10, showTotal: (t) => `共 ${t} 条记录` }}
|
||||
scroll={{ x: 800 }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminCreditRecords;
|
||||
@@ -1,116 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Col, Row, Statistic, Typography, Table, Tag } from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
ProjectOutlined,
|
||||
PlayCircleOutlined,
|
||||
DollarOutlined,
|
||||
ThunderboltOutlined,
|
||||
ArrowUpOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getAdminStats } from '../../api';
|
||||
import type { AdminStats } from '../../types';
|
||||
|
||||
const AdminDashboard: React.FC = () => {
|
||||
const [stats, setStats] = useState<AdminStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
const data = await getAdminStats();
|
||||
setStats(data);
|
||||
setLoading(false);
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const statCards = stats ? [
|
||||
{ title: '总用户数', value: stats.totalUsers, icon: <UserOutlined />, color: '#6366f1', bg: 'rgba(99,102,241,0.08)' },
|
||||
{ title: '总项目数', value: stats.totalProjects, icon: <ProjectOutlined />, color: '#06b6d4', bg: 'rgba(6,182,212,0.08)' },
|
||||
{ title: '总生成次数', value: stats.totalGenerations, icon: <PlayCircleOutlined />, color: '#10b981', bg: 'rgba(16,185,129,0.08)' },
|
||||
{ title: '总收入(元)', value: stats.totalRevenue, icon: <DollarOutlined />, color: '#f59e0b', bg: 'rgba(245,158,11,0.08)', prefix: '¥' },
|
||||
{ title: '今日消耗积分', value: stats.creditsConsumedToday, icon: <ThunderboltOutlined />, color: '#ef4444', bg: 'rgba(239,68,68,0.08)' },
|
||||
] : [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Stats Cards */}
|
||||
<Row gutter={[16, 16]}>
|
||||
{statCards.map((s, i) => (
|
||||
<Col xs={12} sm={8} lg={i < 4 ? 6 : 24} key={s.title}>
|
||||
<Card bordered={false} loading={loading}
|
||||
style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: s.bg, display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20, color: s.color, flexShrink: 0,
|
||||
}}>
|
||||
{s.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12, marginBottom: 2 }}>{s.title}</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#1a1a2e' }}>
|
||||
{s.prefix}{typeof s.value === 'number' ? s.value.toLocaleString() : s.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{/* Quick Info */}
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="系统信息" bordered={false}
|
||||
style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{[
|
||||
{ label: '平台名称', value: 'VideoGen.AI' },
|
||||
{ label: 'API版本', value: 'v1.0.0' },
|
||||
{ label: '数据库', value: 'SQLite (本地开发)' },
|
||||
{ label: 'LLM模式', value: 'Mock (模拟)' },
|
||||
{ label: '视频引擎', value: 'Seedance 2.0' },
|
||||
].map(item => (
|
||||
<div key={item.label} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid #f5f6fa' }}>
|
||||
<Typography.Text type="secondary">{item.label}</Typography.Text>
|
||||
<Typography.Text strong>{item.value}</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="充值套餐" bordered={false}
|
||||
style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{[
|
||||
{ name: '体验包', credits: 500, price: 49, color: '#f59e0b' },
|
||||
{ name: '进阶包', credits: 2000, price: 168, color: '#6366f1', hot: true },
|
||||
{ name: '专业包', credits: 5000, price: 388, color: '#06b6d4' },
|
||||
{ name: '企业包', credits: 20000, price: 1280, color: '#10b981' },
|
||||
].map(p => (
|
||||
<div key={p.name} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid #f5f6fa' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: p.color }} />
|
||||
<Typography.Text strong>{p.name}</Typography.Text>
|
||||
{p.hot && <Tag color="purple" style={{ fontSize: 10, lineHeight: '16px' }}>热门</Tag>}
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text strong style={{ color: p.color }}>¥{p.price}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>{p.credits.toLocaleString()}积分</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboard;
|
||||
@@ -1,336 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
AppstoreOutlined, PlusOutlined, EditOutlined, DeleteOutlined, MinusCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
interface OptionGroup {
|
||||
name: string;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
interface IndustryItem {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
skills: string[];
|
||||
optionGroups: OptionGroup[];
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
const MOCK_INDUSTRIES: IndustryItem[] = [
|
||||
{
|
||||
id: 'ind-1', key: 'ecommerce', label: '电商', description: '电商直播、产品展示、促销活动',
|
||||
skills: ['你是一位专业的电商视频文案专家,擅长将产品卖点转化为视觉语言,注重画面节奏和消费者心理'],
|
||||
optionGroups: [
|
||||
{ name: '视频风格', options: ['实拍展示', '3D动画', '混剪快闪', '沉浸体验'] },
|
||||
{ name: '目标受众', options: ['年轻女性', '家庭用户', '商务人士', '学生群体'] },
|
||||
],
|
||||
isActive: true, sortOrder: 1,
|
||||
},
|
||||
{
|
||||
id: 'ind-2', key: 'education', label: '教育', description: '在线课程、知识付费、培训',
|
||||
skills: ['你是一位专业的教育视频策划专家,擅长将复杂知识点转化为生动易懂的视觉叙事'],
|
||||
optionGroups: [
|
||||
{ name: '课程类型', options: ['知识讲解', '操作演示', '故事叙事', '互动问答'] },
|
||||
],
|
||||
isActive: true, sortOrder: 2,
|
||||
},
|
||||
{
|
||||
id: 'ind-3', key: 'gaming', label: '游戏', description: '游戏预告、赛事宣传、角色展示',
|
||||
skills: ['你是一位专业的游戏视频创意专家,擅长打造震撼视觉体验和沉浸式叙事'],
|
||||
optionGroups: [
|
||||
{ name: '游戏类型', options: ['RPG', 'FPS', 'MOBA', '休闲'] },
|
||||
{ name: '视频类型', options: ['预告片', '宣传片', '教程', '赛事回顾'] },
|
||||
],
|
||||
isActive: true, sortOrder: 3,
|
||||
},
|
||||
{
|
||||
id: 'ind-4', key: 'medical', label: '医疗', description: '医疗健康、药品宣传、科普',
|
||||
skills: ['你是一位专业的医疗健康视频文案专家,擅长将医学知识转化为通俗易懂的视觉内容'],
|
||||
optionGroups: [],
|
||||
isActive: true, sortOrder: 4,
|
||||
},
|
||||
{
|
||||
id: 'ind-5', key: 'finance', label: '金融', description: '理财产品、保险、银行服务',
|
||||
skills: ['你是一位专业的金融视频文案专家,擅长将复杂的金融产品转化为易于理解的视觉表达'],
|
||||
optionGroups: [],
|
||||
isActive: true, sortOrder: 5,
|
||||
},
|
||||
{
|
||||
id: 'ind-6', key: 'realestate', label: '房产', description: '楼盘展示、户型介绍、周边配套',
|
||||
skills: ['你是一位专业的房产视频策划专家,擅长通过镜头语言展现空间美感和生活场景'],
|
||||
optionGroups: [
|
||||
{ name: '展示方式', options: ['航拍全景', '室内漫游', '样板间', '周边实景'] },
|
||||
],
|
||||
isActive: true, sortOrder: 6,
|
||||
},
|
||||
{
|
||||
id: 'ind-7', key: 'food', label: '餐饮', description: '美食制作、餐厅宣传、食材展示',
|
||||
skills: ['你是一位专业的美食视频创意专家,擅长用镜头捕捉食物的色香味,营造食欲感'],
|
||||
optionGroups: [
|
||||
{ name: '拍摄风格', options: ['特写慢放', '制作过程', '美食探店', '食材溯源'] },
|
||||
],
|
||||
isActive: true, sortOrder: 7,
|
||||
},
|
||||
{
|
||||
id: 'ind-8', key: 'travel', label: '旅游', description: '景点宣传、酒店推荐、旅行攻略',
|
||||
skills: ['你是一位专业的旅游视频文案专家,擅长用镜头语言展现目的地魅力和旅行体验'],
|
||||
optionGroups: [
|
||||
{ name: '内容形式', options: ['Vlog', '攻略指南', '风景大片', '人文记录'] },
|
||||
],
|
||||
isActive: true, sortOrder: 8,
|
||||
},
|
||||
{
|
||||
id: 'ind-9', key: 'tech', label: '科技', description: '科技产品、SaaS服务、AI应用',
|
||||
skills: ['你是一位专业的科技视频策划专家,擅长将技术概念转化为直观的视觉演示'],
|
||||
optionGroups: [
|
||||
{ name: '演示方式', options: ['产品演示', '对比评测', '概念解析', '场景模拟'] },
|
||||
],
|
||||
isActive: true, sortOrder: 9,
|
||||
},
|
||||
{
|
||||
id: 'ind-10', key: 'other', label: '其他', description: '通用行业',
|
||||
skills: ['你是一位专业的视频导演和文案专家,擅长将主题转化为富有感染力的视觉叙事'],
|
||||
optionGroups: [],
|
||||
isActive: true, sortOrder: 10,
|
||||
},
|
||||
];
|
||||
|
||||
const AdminIndustries: React.FC = () => {
|
||||
const [industries, setIndustries] = useState<IndustryItem[]>(MOCK_INDUSTRIES);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [modal, setModal] = useState<{ open: boolean; item: IndustryItem | null }>({ open: false, item: null });
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
const skills = values.skills_wentutujie?.trim() ? [values.skills_wentutujie.trim()] : [];
|
||||
const optionGroups: OptionGroup[] = (values.optionGroups || [])
|
||||
.filter((g: any) => g?.name?.trim())
|
||||
.map((g: any) => ({
|
||||
name: g.name.trim(),
|
||||
options: (g.options || []).filter((o: string) => o?.trim()),
|
||||
}))
|
||||
.filter((g: OptionGroup) => g.options.length > 0);
|
||||
|
||||
if (modal.item) {
|
||||
setIndustries(prev => prev.map(i => i.id === modal.item!.id ? { ...i, ...values, skills, optionGroups } : i));
|
||||
message.success('已更新');
|
||||
} else {
|
||||
const newItem: IndustryItem = {
|
||||
id: `ind-${Date.now()}`,
|
||||
key: values.key,
|
||||
label: values.label,
|
||||
description: values.description || '',
|
||||
skills,
|
||||
optionGroups,
|
||||
isActive: values.isActive !== false,
|
||||
sortOrder: industries.length + 1,
|
||||
};
|
||||
setIndustries(prev => [...prev, newItem]);
|
||||
message.success('已添加');
|
||||
}
|
||||
setModal({ open: false, item: null });
|
||||
form.resetFields();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e?.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setIndustries(prev => prev.filter(i => i.id !== id));
|
||||
message.success('已删除');
|
||||
};
|
||||
|
||||
const openEdit = (item?: IndustryItem) => {
|
||||
setModal({ open: true, item: item || null });
|
||||
if (item) {
|
||||
form.setFieldsValue({
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
description: item.description,
|
||||
skills_wentutujie: item.skills[0] || '',
|
||||
optionGroups: item.optionGroups.length > 0 ? item.optionGroups : [{ name: '', options: [] }],
|
||||
isActive: item.isActive,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ isActive: true, optionGroups: [{ name: '', options: [] }] });
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '行业', key: 'industry', width: 160,
|
||||
render: (_: any, r: IndustryItem) => (
|
||||
<div>
|
||||
<Typography.Text strong>{r.label}</Typography.Text>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.key}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '描述', dataIndex: 'description', ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '选项配置', key: 'optionGroups', width: 260,
|
||||
render: (_: any, r: IndustryItem) => {
|
||||
if (!r.optionGroups || r.optionGroups.length === 0) {
|
||||
return <Typography.Text style={{ fontSize: 12, color: '#cbd5e1' }}>未配置</Typography.Text>;
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{r.optionGroups.map((g, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Tag color="blue" style={{ margin: 0, fontSize: 11 }}>{g.name}</Tag>
|
||||
<Typography.Text style={{ fontSize: 11, color: '#64748b' }}>
|
||||
{g.options.slice(0, 3).join('、')}{g.options.length > 3 ? `...${g.options.length}项` : ''}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '文图理解提示词', dataIndex: 'skills', width: 200,
|
||||
render: (skills: string[]) => (
|
||||
<Typography.Text ellipsis style={{ fontSize: 12 }}>
|
||||
{skills[0] || '-'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'isActive', width: 80,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
|
||||
render: (_: any, r: IndustryItem) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
|
||||
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<AppstoreOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>行业与技能配置</Typography.Text>
|
||||
<Tag color="purple">{industries.length} 个行业</Tag>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
|
||||
添加行业
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={industries}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={<Space><AppstoreOutlined />{modal.item ? '编辑行业' : '添加行业'}</Space>}
|
||||
open={modal.open}
|
||||
onOk={handleSave}
|
||||
onCancel={() => { setModal({ open: false, item: null }); form.resetFields(); }}
|
||||
okText="保存" cancelText="取消" width={640}
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="key" label="行业标识" style={{ flex: 1 }}
|
||||
rules={[{ required: true, message: '请输入标识' }]}>
|
||||
<Input placeholder="例如:ecommerce" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="label" label="行业名称" style={{ flex: 1 }}
|
||||
rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="例如:电商" size="large" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="description" label="行业描述">
|
||||
<Input.TextArea rows={2} placeholder="行业描述" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="skills_wentutujie" label="文图理解提示词" extra="用于LLM优化提示词的系统指令,根据行业特点引导AI理解文案与画面的关系">
|
||||
<Input.TextArea rows={3} placeholder="请输入文图理解提示词,例如: 你是一位专业的电商视频文案专家,擅长将产品卖点转化为视觉语言" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
{/* Option Groups */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Typography.Text strong style={{ fontSize: 13 }}>行业选项配置</Typography.Text>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', marginLeft: 8 }}>
|
||||
添加选项组,每组包含名称和多个选项,前台将显示为下拉选择
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Form.List name="optionGroups">
|
||||
{(fields, { add, remove }) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<div key={key} style={{
|
||||
display: 'flex', gap: 8, alignItems: 'flex-start',
|
||||
padding: '10px 12px', borderRadius: 10,
|
||||
background: '#f8f9fc', border: '1px solid #f0f0f5',
|
||||
}}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Form.Item {...restField} name={[name, 'name']} label="选项名称" style={{ marginBottom: 0 }}
|
||||
rules={[{ required: true, message: '请输入选项名称' }]}>
|
||||
<Input placeholder="例如:视频风格" size="middle" style={{ borderRadius: 8 }} />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'options']} label="选项内容" style={{ marginBottom: 0 }}>
|
||||
<Select
|
||||
mode="tags"
|
||||
size="middle"
|
||||
placeholder="输入选项后回车添加"
|
||||
style={{ borderRadius: 8 }}
|
||||
tokenSeparators={[',', ',', '、']}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<MinusCircleOutlined
|
||||
onClick={() => remove(name)}
|
||||
style={{ color: '#ef4444', fontSize: 16, marginTop: 34, cursor: 'pointer', flexShrink: 0 }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="dashed" onClick={() => add()} block
|
||||
icon={<PlusOutlined />}
|
||||
style={{ borderRadius: 8, height: 36 }}
|
||||
>
|
||||
添加选项组
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
|
||||
<Form.Item name="isActive" label="启用状态" valuePropName="checked" initialValue={true}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminIndustries;
|
||||
@@ -1,163 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Layout, Menu, Avatar, Typography, Space, Dropdown, Spin } from 'antd';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
RobotOutlined,
|
||||
SettingOutlined,
|
||||
BellOutlined,
|
||||
ThunderboltOutlined,
|
||||
LogoutOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
WalletOutlined,
|
||||
CalculatorOutlined,
|
||||
DollarOutlined,
|
||||
AppstoreOutlined,
|
||||
PlayCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Outlet, useNavigate, useLocation, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../store/useAuthStore';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
|
||||
const AdminLayout: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, loading, logout } = useAuthStore();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/admin/login" replace />;
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/admin', icon: <DashboardOutlined />, label: '数据概览' },
|
||||
{ key: '/admin/users', icon: <UserOutlined />, label: '用户管理' },
|
||||
{ key: '/admin/credit-records', icon: <WalletOutlined />, label: '交易流水' },
|
||||
{ key: '/admin/models', icon: <RobotOutlined />, label: '模型配置' },
|
||||
{ key: '/admin/credit-ratios', icon: <CalculatorOutlined />, label: '积分比例' },
|
||||
{ key: '/admin/video-engines', icon: <PlayCircleOutlined />, label: '视频引擎' },
|
||||
{ key: '/admin/industries', icon: <AppstoreOutlined />, label: '行业配置' },
|
||||
{ key: '/admin/payment', icon: <DollarOutlined />, label: '支付配置' },
|
||||
{ key: '/admin/settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||
{ key: '/admin/notifications', icon: <BellOutlined />, label: '消息推送' },
|
||||
];
|
||||
|
||||
const selectedKey = location.pathname;
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
onCollapse={setCollapsed}
|
||||
width={220}
|
||||
theme="dark"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, #0f0f23 0%, #1a1a35 100%)',
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div style={{
|
||||
height: 64, display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', gap: 10,
|
||||
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<ThunderboltOutlined style={{ fontSize: 16, color: '#fff' }} />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span style={{ color: '#f1f5f9', fontSize: 15, fontWeight: 700 }}>
|
||||
管理后台
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Menu */}
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
items={menuItems}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
style={{ background: 'transparent', borderRight: 0, marginTop: 8 }}
|
||||
theme="dark"
|
||||
/>
|
||||
|
||||
{/* User block */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 48, left: 0, right: 0,
|
||||
padding: collapsed ? '12px 8px' : '12px 16px',
|
||||
borderTop: '1px solid rgba(255,255,255,0.06)',
|
||||
}}>
|
||||
<Dropdown menu={{
|
||||
items: [
|
||||
{ key: 'front', icon: <ThunderboltOutlined />, label: '返回前台' },
|
||||
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', danger: true },
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'front') navigate('/projects');
|
||||
else if (key === 'logout') { logout(); navigate('/admin/login'); }
|
||||
},
|
||||
}} placement="topRight" arrow>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center',
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
gap: 10, padding: '8px 10px', borderRadius: 10,
|
||||
cursor: 'pointer', background: 'rgba(255,255,255,0.04)',
|
||||
transition: 'background 0.2s',
|
||||
}}>
|
||||
<Avatar size={28} icon={<UserOutlined />}
|
||||
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }} />
|
||||
{!collapsed && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: '#e2e8f0', fontSize: 12, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{user?.username}
|
||||
</div>
|
||||
<div style={{ color: 'rgba(148,163,184,0.5)', fontSize: 10 }}>管理员</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</Sider>
|
||||
|
||||
<Layout>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
height: 56, background: '#fff', borderBottom: '1px solid #f0f0f5',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '0 24px',
|
||||
}}>
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>
|
||||
{menuItems.find(m => m.key === selectedKey)?.label || '管理后台'}
|
||||
</Typography.Text>
|
||||
<Space>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{user?.username}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<Content style={{ padding: 24, background: '#f5f6fa', overflow: 'auto' }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLayout;
|
||||
@@ -1,78 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Card, Form, Input, message, Typography } from 'antd';
|
||||
import { UserOutlined, LockOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../store/useAuthStore';
|
||||
|
||||
const AdminLoginPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuthStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleLogin = async (values: { username: string; password: string }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(values.username, values.password);
|
||||
message.success('登录成功');
|
||||
navigate('/admin');
|
||||
} catch {
|
||||
message.error('登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a35 50%, #0f0f23 100%)',
|
||||
}}>
|
||||
<Card bordered={false} style={{
|
||||
width: 420, borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
{/* Logo */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<div style={{
|
||||
width: 56, height: 56, borderRadius: 14, margin: '0 auto 16px',
|
||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: '0 8px 24px rgba(99,102,241,0.3)',
|
||||
}}>
|
||||
<ThunderboltOutlined style={{ fontSize: 26, color: '#fff' }} />
|
||||
</div>
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||
VideoGen<span style={{ color: '#6366f1' }}>.AI</span>
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">管理后台</Typography.Text>
|
||||
</div>
|
||||
|
||||
<Form onFinish={handleLogin} layout="vertical" initialValues={{ username: 'admin', password: 'admin123' }}>
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input placeholder="用户名" size="large" prefix={<UserOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password placeholder="密码" size="large" prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} />
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 8 }}>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block size="large"
|
||||
style={{
|
||||
borderRadius: 10, fontWeight: 600, height: 44,
|
||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||
border: 'none',
|
||||
}}>
|
||||
登录管理后台
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
演示账号: admin / admin123
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLoginPage;
|
||||
@@ -1,222 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
RobotOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getModelConfigs, saveModelConfig, deleteModelConfig } from '../../api';
|
||||
import type { ModelConfig } from '../../types';
|
||||
|
||||
const AdminModels: React.FC = () => {
|
||||
const [models, setModels] = useState<ModelConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modal, setModal] = useState<{ open: boolean; model: ModelConfig | null }>({ open: false, model: null });
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
const data = await getModelConfigs();
|
||||
setModels(data);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
await saveModelConfig({
|
||||
...modal.model,
|
||||
...values,
|
||||
id: modal.model?.id,
|
||||
});
|
||||
message.success(modal.model?.id ? '模型配置已更新' : '模型配置已添加');
|
||||
setModal({ open: false, model: null });
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch { /* validation */ }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await deleteModelConfig(id);
|
||||
message.success('模型配置已删除');
|
||||
load();
|
||||
};
|
||||
|
||||
const openEdit = (model?: ModelConfig) => {
|
||||
setModal({ open: true, model: model || null });
|
||||
if (model) {
|
||||
form.setFieldsValue(model);
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
provider: 'sdk',
|
||||
weight: 1,
|
||||
maxTokens: 4096,
|
||||
temperature: 0.7,
|
||||
isActive: true,
|
||||
priority: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '模型名称', dataIndex: 'name', width: 150,
|
||||
render: (v: string, r: ModelConfig) => (
|
||||
<Space>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: r.isActive
|
||||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||||
: 'linear-gradient(135deg, #94a3b8, #cbd5e1)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', fontSize: 14,
|
||||
}}>
|
||||
<RobotOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{v}</div>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.modelName}</div>
|
||||
</div>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '提供商', dataIndex: 'provider', width: 140,
|
||||
render: (v: string) => {
|
||||
const labelMap: Record<string, string> = {
|
||||
sdk: 'SDK模式',
|
||||
openai_compatible: 'OpenAI兼容',
|
||||
mock: 'Mock模式',
|
||||
};
|
||||
return <Tag color={v === 'mock' ? 'default' : 'blue'}>{labelMap[v] || v}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'API地址', dataIndex: 'apiBase', width: 200,
|
||||
render: (v: string) => (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>
|
||||
{v || '-'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '权重', dataIndex: 'weight', width: 80, sorter: (a: ModelConfig, b: ModelConfig) => a.weight - b.weight,
|
||||
},
|
||||
{
|
||||
title: 'Max Tokens', dataIndex: 'maxTokens', width: 100,
|
||||
},
|
||||
{
|
||||
title: 'Temperature', dataIndex: 'temperature', width: 100,
|
||||
render: (v: number) => v.toFixed(1),
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'isActive', width: 80,
|
||||
render: (v: boolean) => (
|
||||
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
|
||||
render: (_: any, r: ModelConfig) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" size="small" icon={<EditOutlined />}
|
||||
onClick={() => openEdit(r)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确定删除该模型配置?" onConfirm={() => handleDelete(r.id)}>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary">
|
||||
共 {models.length} 个模型配置,按权重进行加权随机调度
|
||||
</Typography.Text>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}
|
||||
style={{ borderRadius: 8 }}>
|
||||
添加模型
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={models}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal
|
||||
title={<Space><RobotOutlined />{modal.model?.id ? '编辑模型' : '添加模型'}</Space>}
|
||||
open={modal.open}
|
||||
onOk={handleSave}
|
||||
onCancel={() => { setModal({ open: false, model: null }); form.resetFields(); }}
|
||||
okText="确认" cancelText="取消" width={560}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="显示名称"
|
||||
rules={[{ required: true, message: '请输入模型名称' }]}>
|
||||
<Input placeholder="例如:GPT-4o" size="large" />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="provider" label="提供商" style={{ flex: 1 }}
|
||||
rules={[{ required: true }]}>
|
||||
<Select size="large" options={[
|
||||
{ value: 'sdk', label: 'SDK模式' },
|
||||
{ value: 'openai_compatible', label: 'OpenAI兼容' },
|
||||
{ value: 'mock', label: 'Mock模式' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="modelName" label="模型标识" style={{ flex: 1 }}
|
||||
rules={[{ required: true, message: '请输入模型标识' }]}>
|
||||
<Input placeholder="例如:gpt-4o" size="large" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="apiBase" label="API地址">
|
||||
<Input placeholder="https://api.openai.com/v1" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="apiKey" label="API Key">
|
||||
<Input.Password placeholder="sk-****" size="large" />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="weight" label="权重" style={{ flex: 1 }}
|
||||
rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={10} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="maxTokens" label="Max Tokens" style={{ flex: 1 }}
|
||||
rules={[{ required: true }]}>
|
||||
<InputNumber min={256} max={128000} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="temperature" label="Temperature" style={{ flex: 1 }}
|
||||
rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={2} step={0.1} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="priority" label="优先级" style={{ flex: 1 }}
|
||||
rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={10} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="isActive" label="启用" valuePropName="checked" style={{ flex: 1, paddingTop: 30 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminModels;
|
||||
@@ -1,166 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import {
|
||||
BellOutlined, PlusOutlined, DeleteOutlined, SendOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
interface NotificationRecord {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
type: string;
|
||||
target: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const MOCK_NOTIFICATIONS: NotificationRecord[] = [
|
||||
{ id: 'n-1', title: '系统上线通知', content: 'VideoGen.AI 平台正式上线!', type: 'system', target: '全部用户', createdAt: '2026-05-01 09:00:00' },
|
||||
{ id: 'n-2', title: '积分充值优惠', content: '限时活动:充值进阶包额外赠送200积分', type: 'credit', target: '全部用户', createdAt: '2026-05-03 10:00:00' },
|
||||
{ id: 'n-3', title: '账户审核通过', content: '您的账户已通过实名审核', type: 'system', target: 'videomaker', createdAt: '2026-05-05 14:00:00' },
|
||||
];
|
||||
|
||||
const MOCK_USERS = [
|
||||
{ id: 'u-001', username: 'videomaker' },
|
||||
{ id: 'u-002', username: 'designer' },
|
||||
{ id: 'u-003', username: 'marketer' },
|
||||
{ id: 'u-004', username: 'editor' },
|
||||
];
|
||||
|
||||
const AdminNotificationManager: React.FC = () => {
|
||||
const [notifications, setNotifications] = useState<NotificationRecord[]>(MOCK_NOTIFICATIONS);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleSend = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const newRecord: NotificationRecord = {
|
||||
id: `n-${Date.now()}`,
|
||||
title: values.title,
|
||||
content: values.content,
|
||||
type: values.type,
|
||||
target: values.target_user_id
|
||||
? MOCK_USERS.find(u => u.id === values.target_user_id)?.username || '指定用户'
|
||||
: '全部用户',
|
||||
createdAt: new Date().toLocaleString('zh-CN'),
|
||||
};
|
||||
setNotifications(prev => [newRecord, ...prev]);
|
||||
message.success('消息已发送');
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
} catch { /* validation */ }
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setNotifications(prev => prev.filter(n => n.id !== id));
|
||||
message.success('已删除');
|
||||
};
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'system': return 'blue';
|
||||
case 'credit': return 'orange';
|
||||
case 'promo': return 'purple';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '标题', dataIndex: 'title', width: 200,
|
||||
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '内容', dataIndex: 'content', ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '类型', dataIndex: 'type', width: 80,
|
||||
render: (v: string) => {
|
||||
const labels: Record<string, string> = { system: '系统', credit: '积分', promo: '活动' };
|
||||
return <Tag color={getTypeColor(v)}>{labels[v] || v}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '发送目标', dataIndex: 'target', width: 120,
|
||||
render: (v: string) => (
|
||||
<Tag color={v === '全部用户' ? 'green' : 'blue'}>{v}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '发送时间', dataIndex: 'createdAt', width: 160,
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 80,
|
||||
render: (_: any, r: NotificationRecord) => (
|
||||
<Popconfirm title="确定删除该消息?" onConfirm={() => handleDelete(r.id)}>
|
||||
<Button type="link" danger size="small" icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<BellOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>消息推送管理</Typography.Text>
|
||||
<Tag color="purple">{notifications.length} 条消息</Tag>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}
|
||||
style={{ borderRadius: 8 }}>
|
||||
发送新消息
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={notifications}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 10, showTotal: (t) => `共 ${t} 条消息` }}
|
||||
scroll={{ x: 800 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Send Notification Modal */}
|
||||
<Modal
|
||||
title={<Space><SendOutlined />发送消息</Space>}
|
||||
open={modalOpen}
|
||||
onOk={handleSend}
|
||||
onCancel={() => { setModalOpen(false); form.resetFields(); }}
|
||||
okText="发送" cancelText="取消" width={520}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="title" label="消息标题"
|
||||
rules={[{ required: true, message: '请输入标题' }]}>
|
||||
<Input placeholder="请输入消息标题" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="消息内容"
|
||||
rules={[{ required: true, message: '请输入内容' }]}>
|
||||
<Input.TextArea rows={4} placeholder="请输入消息内容" size="large" />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="type" label="消息类型" style={{ flex: 1 }}
|
||||
initialValue="system" rules={[{ required: true }]}>
|
||||
<Select size="large" options={[
|
||||
{ value: 'system', label: '系统通知' },
|
||||
{ value: 'credit', label: '积分通知' },
|
||||
{ value: 'promo', label: '活动通知' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="target_user_id" label="发送目标" style={{ flex: 1 }}
|
||||
extra="留空则发送给全部用户">
|
||||
<Select size="large" allowClear placeholder="全部用户"
|
||||
options={MOCK_USERS.map(u => ({ value: u.id, label: u.username }))} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminNotificationManager;
|
||||
@@ -1,114 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Empty, Space, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
BellOutlined, CheckOutlined, InfoCircleOutlined, CreditCardOutlined, ExclamationCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getNotifications } from '../../api';
|
||||
import type { AdminNotification } from '../../types';
|
||||
|
||||
const AdminNotifications: React.FC = () => {
|
||||
const [notifications, setNotifications] = useState<AdminNotification[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
const data = await getNotifications();
|
||||
setNotifications(data);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'system': return <InfoCircleOutlined style={{ color: '#6366f1' }} />;
|
||||
case 'credit': return <CreditCardOutlined style={{ color: '#f59e0b' }} />;
|
||||
default: return <ExclamationCircleOutlined style={{ color: '#94a3b8' }} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'system': return <Tag color="blue">系统</Tag>;
|
||||
case 'credit': return <Tag color="orange">积分</Tag>;
|
||||
default: return <Tag>其他</Tag>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<BellOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>消息通知</Typography.Text>
|
||||
<Tag color="purple">{notifications.filter(n => !n.isRead).length} 条未读</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<Empty description="暂无通知" />
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{notifications.map(n => (
|
||||
<Card
|
||||
key={n.id}
|
||||
size="small"
|
||||
bordered
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
borderColor: n.isRead ? '#f0f0f5' : '#e0e7ff',
|
||||
background: n.isRead ? '#fff' : '#fafbff',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 14 }}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 10, flexShrink: 0,
|
||||
background: n.isRead ? '#f8fafc' : 'rgba(99,102,241,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 18,
|
||||
}}>
|
||||
{getTypeIcon(n.type)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14 }}>
|
||||
{n.title}
|
||||
</Typography.Text>
|
||||
{getTypeLabel(n.type)}
|
||||
{!n.isRead && (
|
||||
<Tag color="red" style={{ fontSize: 10 }}>未读</Tag>
|
||||
)}
|
||||
</div>
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 13, marginBottom: 4, lineHeight: 1.6 }}>
|
||||
{n.content}
|
||||
</Typography.Paragraph>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{n.createdAt}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{!n.isRead && (
|
||||
<Button type="text" size="small" icon={<CheckOutlined />}
|
||||
style={{ flexShrink: 0, color: '#6366f1' }}
|
||||
onClick={() => {
|
||||
setNotifications(prev =>
|
||||
prev.map(item => item.id === n.id ? { ...item, isRead: true } : item)
|
||||
);
|
||||
}}>
|
||||
标记已读
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminNotifications;
|
||||
@@ -1,149 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, message, Switch, Typography, Divider,
|
||||
} from 'antd';
|
||||
import {
|
||||
SaveOutlined, WechatOutlined, AlipayCircleOutlined, DollarOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
interface PaymentSetting {
|
||||
key: string;
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
secret?: boolean;
|
||||
}
|
||||
|
||||
const AdminPaymentConfig: React.FC = () => {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [wechatEnabled, setWechatEnabled] = useState(false);
|
||||
const [alipayEnabled, setAlipayEnabled] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
message.success('支付配置已保存');
|
||||
setSaving(false);
|
||||
} catch { setSaving(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720 }}>
|
||||
{/* WeChat Pay */}
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: 'rgba(7,193,96,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 22, color: '#07c160',
|
||||
}}><WechatOutlined /></div>
|
||||
<div>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>微信支付</Typography.Title>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>微信商户号支付配置</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={wechatEnabled} onChange={setWechatEnabled} checkedChildren="已启用" unCheckedChildren="未启用" />
|
||||
</div>
|
||||
|
||||
<Form form={form} layout="vertical" initialValues={{
|
||||
wechat_mch_id: '',
|
||||
wechat_api_key: '',
|
||||
wechat_cert_path: '',
|
||||
wechat_notify_url: '',
|
||||
}}>
|
||||
<Form.Item name="wechat_mch_id" label="商户号 (MchID)">
|
||||
<Input placeholder="微信支付商户号" size="large" disabled={!wechatEnabled} />
|
||||
</Form.Item>
|
||||
<Form.Item name="wechat_api_key" label="API密钥">
|
||||
<Input.Password placeholder="微信支付API密钥" size="large" disabled={!wechatEnabled} />
|
||||
</Form.Item>
|
||||
<Form.Item name="wechat_cert_path" label="证书路径">
|
||||
<Input placeholder="apiclient_cert.pem 路径" size="large" disabled={!wechatEnabled} />
|
||||
</Form.Item>
|
||||
<Form.Item name="wechat_notify_url" label="回调地址">
|
||||
<Input placeholder="https://yourdomain.com/api/payments/wechat/callback" size="large" disabled={!wechatEnabled} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{/* Alipay */}
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: 'rgba(0,122,255,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 22, color: '#007aff',
|
||||
}}><AlipayCircleOutlined /></div>
|
||||
<div>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>支付宝</Typography.Title>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>支付宝应用支付配置</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={alipayEnabled} onChange={setAlipayEnabled} checkedChildren="已启用" unCheckedChildren="未启用" />
|
||||
</div>
|
||||
|
||||
<Form form={form} layout="vertical" initialValues={{
|
||||
alipay_app_id: '',
|
||||
alipay_private_key: '',
|
||||
alipay_public_key: '',
|
||||
alipay_notify_url: '',
|
||||
}}>
|
||||
<Form.Item name="alipay_app_id" label="AppID">
|
||||
<Input placeholder="支付宝应用AppID" size="large" disabled={!alipayEnabled} />
|
||||
</Form.Item>
|
||||
<Form.Item name="alipay_private_key" label="应用私钥">
|
||||
<Input.TextArea rows={3} placeholder="支付宝应用私钥 (PKCS8格式)" disabled={!alipayEnabled} />
|
||||
</Form.Item>
|
||||
<Form.Item name="alipay_public_key" label="支付宝公钥">
|
||||
<Input.TextArea rows={3} placeholder="支付宝公钥" disabled={!alipayEnabled} />
|
||||
</Form.Item>
|
||||
<Form.Item name="alipay_notify_url" label="回调地址">
|
||||
<Input placeholder="https://yourdomain.com/api/payments/alipay/callback" size="large" disabled={!alipayEnabled} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{/* Recharge Packages */}
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}
|
||||
title={<span><DollarOutlined style={{ marginRight: 8 }} />充值套餐</span>}>
|
||||
{[
|
||||
{ name: '体验包', credits: 500, price: 49, color: '#f59e0b' },
|
||||
{ name: '进阶包', credits: 2000, price: 168, color: '#6366f1' },
|
||||
{ name: '专业包', credits: 5000, price: 388, color: '#06b6d4' },
|
||||
{ name: '企业包', credits: 20000, price: 1280, color: '#10b981' },
|
||||
].map(p => (
|
||||
<div key={p.name} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '12px 0', borderBottom: '1px solid #f5f6fa',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: p.color }} />
|
||||
<Typography.Text strong>{p.name}</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text strong style={{ color: p.color, fontSize: 16 }}>¥{p.price}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>{p.credits.toLocaleString()} 积分</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11, marginLeft: 4 }}>({(p.price / p.credits * 100).toFixed(1)}元/百积分)</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}
|
||||
size="large" style={{ borderRadius: 8, minWidth: 140 }}>
|
||||
保存配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPaymentConfig;
|
||||
@@ -1,128 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, message, Space, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
SettingOutlined, SaveOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getSystemConfigs, updateSystemConfig } from '../../api';
|
||||
import type { SystemConfig } from '../../types';
|
||||
|
||||
const AdminSettings: React.FC = () => {
|
||||
const [configs, setConfigs] = useState<SystemConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
const data = await getSystemConfigs();
|
||||
setConfigs(data);
|
||||
const formValues: Record<string, string> = {};
|
||||
data.forEach(c => { formValues[c.key] = c.value; });
|
||||
form.setFieldsValue(formValues);
|
||||
setLoading(false);
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
for (const config of configs) {
|
||||
const newVal = values[config.key];
|
||||
if (newVal !== config.value) {
|
||||
await updateSystemConfig(config.id, newVal);
|
||||
}
|
||||
}
|
||||
message.success('系统配置已保存');
|
||||
const data = await getSystemConfigs();
|
||||
setConfigs(data);
|
||||
setSaving(false);
|
||||
} catch {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const groupedConfigs: Record<string, SystemConfig[]> = {
|
||||
'站点信息': configs.filter(c => c.key.startsWith('site_')),
|
||||
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')),
|
||||
};
|
||||
|
||||
const getFieldDescription = (config: SystemConfig): string => {
|
||||
const descMap: Record<string, string> = {
|
||||
site_name: '平台显示名称,将展示在页面标题和导航栏',
|
||||
site_logo: '平台Logo图片URL,建议尺寸 200x40px',
|
||||
seo_title: '搜索引擎结果中显示的标题',
|
||||
seo_description: '搜索引擎结果中显示的描述文字,建议150字以内',
|
||||
seo_keywords: '用逗号分隔的关键词列表',
|
||||
};
|
||||
return descMap[config.key] || config.description || '';
|
||||
};
|
||||
|
||||
const getFieldComponent = (config: SystemConfig) => {
|
||||
if (config.key === 'seo_description') {
|
||||
return <Input.TextArea rows={3} placeholder={config.description} size="large" />;
|
||||
}
|
||||
if (config.key === 'seo_keywords') {
|
||||
return <Input placeholder="关键词1, 关键词2, 关键词3" size="large" />;
|
||||
}
|
||||
return <Input placeholder={config.description} size="large" />;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Card loading bordered={false} style={{ borderRadius: 12 }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720 }}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: 'rgba(99,102,241,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20, color: '#6366f1',
|
||||
}}>
|
||||
<SettingOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>系统设置</Typography.Title>
|
||||
<Typography.Text type="secondary">管理站点基础信息和SEO配置</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form form={form} layout="vertical">
|
||||
{Object.entries(groupedConfigs).map(([group, items]) => (
|
||||
<div key={group} style={{ marginBottom: 24 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||
{group}
|
||||
</Typography.Text>
|
||||
{items.map(config => (
|
||||
<Form.Item
|
||||
key={config.id}
|
||||
name={config.key}
|
||||
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
|
||||
extra={getFieldDescription(config)}
|
||||
>
|
||||
{getFieldComponent(config)}
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}
|
||||
size="large" style={{ borderRadius: 8, minWidth: 140 }}>
|
||||
保存配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminSettings;
|
||||
@@ -1,182 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Space, Switch, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getAdminUsers, adjustCredits, toggleUserStatus } from '../../api';
|
||||
import type { AdminUser } from '../../types';
|
||||
|
||||
const AdminUsers: React.FC = () => {
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [creditModal, setCreditModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
const data = await getAdminUsers(search || undefined);
|
||||
setUsers(data);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleSearch = () => load();
|
||||
|
||||
const handleAdjustCredits = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const { user } = creditModal;
|
||||
if (!user) return;
|
||||
await adjustCredits(user.id, values.amount, values.reason);
|
||||
message.success(`已${values.amount > 0 ? '增加' : '扣除'} ${Math.abs(values.amount)} 积分`);
|
||||
setCreditModal({ open: false, user: null });
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch { /* validation */ }
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (user: AdminUser) => {
|
||||
await toggleUserStatus(user.id, !user.isActive);
|
||||
message.success(user.isActive ? '已禁用该用户' : '已启用该用户');
|
||||
load();
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '用户', key: 'user', width: 200,
|
||||
render: (_: any, r: AdminUser) => (
|
||||
<Space>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: r.isAdmin ? 'linear-gradient(135deg, #f59e0b, #f97316)' : 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', fontSize: 13, fontWeight: 700,
|
||||
}}>
|
||||
{r.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
{r.username}
|
||||
{r.isAdmin && <Tag color="orange" style={{ marginLeft: 6, fontSize: 10 }}>管理员</Tag>}
|
||||
</div>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.email}</div>
|
||||
</div>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '积分余额', dataIndex: 'credits', width: 120, sorter: (a: AdminUser, b: AdminUser) => a.credits - b.credits,
|
||||
render: (v: number) => (
|
||||
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>
|
||||
{v.toLocaleString()}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '手机号', dataIndex: 'phone', width: 130,
|
||||
render: (v: string) => <Typography.Text type="secondary">{v || '-'}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'isActive', width: 80,
|
||||
render: (v: boolean) => (
|
||||
<Tag color={v ? 'green' : 'red'}>{v ? '正常' : '禁用'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '注册时间', dataIndex: 'createdAt', width: 120,
|
||||
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '最后登录', dataIndex: 'lastLoginAt', width: 140,
|
||||
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{v || '-'}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 200, fixed: 'right' as const,
|
||||
render: (_: any, r: AdminUser) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" size="small" icon={<WalletOutlined />}
|
||||
onClick={() => { setCreditModal({ open: true, user: r }); form.resetFields(); }}>
|
||||
调整积分
|
||||
</Button>
|
||||
{!r.isAdmin && (
|
||||
<Popconfirm
|
||||
title={r.isActive ? '确定禁用该用户?' : '确定启用该用户?'}
|
||||
onConfirm={() => handleToggleStatus(r)}
|
||||
>
|
||||
<Button type="link" size="small" danger={r.isActive}
|
||||
icon={r.isActive ? <StopOutlined /> : <CheckCircleOutlined />}>
|
||||
{r.isActive ? '禁用' : '启用'}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
{/* Search bar */}
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
|
||||
<Input
|
||||
placeholder="搜索用户名或邮箱"
|
||||
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
style={{ width: 280, borderRadius: 8 }}
|
||||
allowClear
|
||||
/>
|
||||
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>搜索</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 10, showTotal: (t) => `共 ${t} 个用户` }}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Adjust Credits Modal */}
|
||||
<Modal
|
||||
title={<Space><WalletOutlined />调整积分 - {creditModal.user?.username}</Space>}
|
||||
open={creditModal.open}
|
||||
onOk={handleAdjustCredits}
|
||||
onCancel={() => { setCreditModal({ open: false, user: null }); form.resetFields(); }}
|
||||
okText="确认" cancelText="取消" width={440}
|
||||
>
|
||||
<div style={{ marginBottom: 16, padding: '12px 16px', background: '#f8fafc', borderRadius: 8 }}>
|
||||
<span style={{ color: '#64748b' }}>当前积分:</span>
|
||||
<span style={{ fontWeight: 800, fontSize: 18, color: '#6366f1' }}>
|
||||
{creditModal.user?.credits.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="amount" label="积分变动"
|
||||
rules={[{ required: true, message: '请输入积分数量' }]}>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
size="large"
|
||||
placeholder="正数增加,负数扣除"
|
||||
formatter={v => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="reason" label="原因"
|
||||
rules={[{ required: true, message: '请输入调整原因' }]}>
|
||||
<Input.TextArea rows={2} placeholder="请输入调整原因" size="large" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUsers;
|
||||
@@ -1,214 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlayCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
interface VideoEngine {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
apiBase: string;
|
||||
apiKey: string;
|
||||
modelName: string;
|
||||
supportedRatios: string[];
|
||||
supportedResolutions: string[];
|
||||
maxDuration: number;
|
||||
isActive: boolean;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
const MOCK_ENGINES: VideoEngine[] = [
|
||||
{
|
||||
id: 've-1', name: 'Seedance 2.0', provider: 'seedance',
|
||||
apiBase: 'https://ark.cn-beijing.volces.com/api/v3',
|
||||
apiKey: 'sk-****', modelName: 'seedance-2.0',
|
||||
supportedRatios: ['16:9', '9:16', '1:1', '4:3'],
|
||||
supportedResolutions: ['720p', '1080p', '4K'],
|
||||
maxDuration: 60, isActive: true, priority: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const AdminVideoEngines: React.FC = () => {
|
||||
const [engines, setEngines] = useState<VideoEngine[]>(MOCK_ENGINES);
|
||||
const [modal, setModal] = useState<{ open: boolean; engine: VideoEngine | null }>({ open: false, engine: null });
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (modal.engine) {
|
||||
setEngines(prev => prev.map(e => e.id === modal.engine!.id ? { ...e, ...values } : e));
|
||||
message.success('已更新');
|
||||
} else {
|
||||
const newEngine: VideoEngine = {
|
||||
id: `ve-${Date.now()}`,
|
||||
...values,
|
||||
};
|
||||
setEngines(prev => [...prev, newEngine]);
|
||||
message.success('已添加');
|
||||
}
|
||||
setModal({ open: false, engine: null });
|
||||
form.resetFields();
|
||||
} catch { /* validation */ }
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setEngines(prev => prev.filter(e => e.id !== id));
|
||||
message.success('已删除');
|
||||
};
|
||||
|
||||
const openEdit = (engine?: VideoEngine) => {
|
||||
setModal({ open: true, engine: engine || null });
|
||||
if (engine) {
|
||||
form.setFieldsValue(engine);
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
isActive: true, priority: 0, maxDuration: 60,
|
||||
supportedRatios: ['16:9', '9:16', '1:1'],
|
||||
supportedResolutions: ['720p', '1080p'],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '引擎名称', key: 'name', width: 180,
|
||||
render: (_: any, r: VideoEngine) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 8,
|
||||
background: r.isActive
|
||||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||||
: 'linear-gradient(135deg, #94a3b8, #cbd5e1)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', fontSize: 16,
|
||||
}}><PlayCircleOutlined /></div>
|
||||
<div>
|
||||
<Typography.Text strong>{r.name}</Typography.Text>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.provider}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'API地址', dataIndex: 'apiBase', width: 250,
|
||||
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '支持比例', dataIndex: 'supportedRatios', width: 180,
|
||||
render: (ratios: string[]) => ratios.map(r => <Tag key={r}>{r}</Tag>),
|
||||
},
|
||||
{
|
||||
title: '支持分辨率', dataIndex: 'supportedResolutions', width: 150,
|
||||
render: (res: string[]) => res.map(r => <Tag key={r} color="blue">{r}</Tag>),
|
||||
},
|
||||
{
|
||||
title: '最大时长', dataIndex: 'maxDuration', width: 80,
|
||||
render: (v: number) => `${v}s`,
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'isActive', width: 80,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
|
||||
render: (_: any, r: VideoEngine) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
|
||||
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<PlayCircleOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>视频引擎配置</Typography.Text>
|
||||
<Tag color="purple">{engines.length} 个引擎</Tag>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
|
||||
添加引擎
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={engines}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={<Space><PlayCircleOutlined />{modal.engine ? '编辑引擎' : '添加引擎'}</Space>}
|
||||
open={modal.open}
|
||||
onOk={handleSave}
|
||||
onCancel={() => { setModal({ open: false, engine: null }); form.resetFields(); }}
|
||||
okText="保存" cancelText="取消" width={560}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="name" label="引擎名称" style={{ flex: 1 }}
|
||||
rules={[{ required: true }]}>
|
||||
<Input placeholder="Seedance 2.0" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="provider" label="提供商" style={{ flex: 1 }}
|
||||
rules={[{ required: true }]}>
|
||||
<Select size="large" options={[
|
||||
{ value: 'seedance', label: 'Seedance (火山引擎)' },
|
||||
{ value: 'kling', label: 'Kling (快手)' },
|
||||
{ value: 'runway', label: 'Runway' },
|
||||
{ value: 'pika', label: 'Pika' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="apiBase" label="API地址" rules={[{ required: true }]}>
|
||||
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="apiKey" label="API Key">
|
||||
<Input.Password placeholder="sk-****" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="modelName" label="模型名称">
|
||||
<Input placeholder="seedance-2.0" size="large" />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="supportedRatios" label="支持比例" style={{ flex: 1 }}>
|
||||
<Select mode="multiple" size="large" options={[
|
||||
{ value: '16:9' }, { value: '9:16' }, { value: '1:1' }, { value: '4:3' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="supportedResolutions" label="支持分辨率" style={{ flex: 1 }}>
|
||||
<Select mode="multiple" size="large" options={[
|
||||
{ value: '720p' }, { value: '1080p' }, { value: '4K' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="maxDuration" label="最大时长(秒)" style={{ flex: 1 }}>
|
||||
<InputNumber min={5} max={300} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="priority" label="优先级" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={10} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="isActive" label="启用" valuePropName="checked" style={{ paddingTop: 30 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminVideoEngines;
|
||||
Reference in New Issue
Block a user