解决app/api代码合并冲突

This commit is contained in:
Lrd
2026-06-11 17:06:28 +08:00
83 changed files with 8358 additions and 2858 deletions
+1
View File
@@ -22,3 +22,4 @@ video-gen-api/dist/
# 忽略特定类型文件但保留目录
# *.pyc
# !dir/*.pycnode_modules/
*.tmp.*
+14
View File
@@ -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. 配置环境变量
+4 -3
View File
@@ -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": {
+1
View File
@@ -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",
+2
View File
@@ -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 />} />
+37
View File
@@ -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;
+23
View File
@@ -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;
@@ -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 ###
+6
View File
@@ -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)
+240
View File
@@ -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}")
+143 -26
View File
@@ -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}
+1 -1
View File
@@ -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}
+124
View File
@@ -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": "删除成功"}
+9 -2
View File
@@ -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()
+3
View File
@@ -0,0 +1,3 @@
from app.enums.common import *
from app.enums.hot_opening_replicate import *
from app.enums.video_prompt_schema import *
+57
View File
@@ -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视频生成提词结构"
+40
View File
@@ -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)
+7
View File
@@ -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)
+58
View File
@@ -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="授权账户iduser_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 示例")
+2
View File
@@ -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}
+29
View File
@@ -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)
+891 -34
View File
@@ -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
+1
View File
@@ -10,6 +10,7 @@ try:
generation_poll_tasks,
generation_download_tasks,
generation_recovery_tasks,
hot_opening_replicate_tasks
)
except Exception:
pass
+2
View File
@@ -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()
+9 -4
View File
@@ -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}"
+6
View File
@@ -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",
+1 -1
View File
@@ -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": {
+20 -1
View File
@@ -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": "爆款开头复刻"
}
}
+2
View File
@@ -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 />} />
+21
View File
@@ -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');
}
+270 -48
View File
@@ -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>
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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' }}>
+419
View File
@@ -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="请输入文图理解提示词,例如:&#10;你是一位专业的电商视频文案专家,擅长将产品卖点转化为视觉语言" 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;