This commit is contained in:
2026-06-29 13:51:32 +08:00
23 changed files with 562 additions and 341 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ __pycache__/
.vscode/ .vscode/
.trae/ .trae/
# video-gen-app/dist/ # video-gen-app/dist/
video-gen-api/dist/ #video-gen-api/dist/
bak/ bak/
# 使用通配符 # 使用通配符
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-DSXie0ty.js"></script> <script type="module" crossorigin src="/assets/index-CGll6VYQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css"> <link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head> </head>
<body> <body>
+1 -1
View File
@@ -7,7 +7,7 @@ import AdminAuthoriz from './pages/AdminAuthoriz';
import AdminConsume from './pages/AdminConsume'; import AdminConsume from './pages/AdminConsume';
import AdminLoginPage from './pages/AdminLoginPage'; import AdminLoginPage from './pages/AdminLoginPage';
import AdminDashboard from './pages/AdminDashboard'; import AdminDashboard from './pages/AdminDashboard';
import AdminPlatform from './pages/AdminPlatform'; import AdminPlatform from './pages/Adminplatform';
import AdminUsers from './pages/AdminUsers'; import AdminUsers from './pages/AdminUsers';
import AdminModels from './pages/AdminModels'; import AdminModels from './pages/AdminModels';
import AdminSettings from './pages/AdminSettings'; import AdminSettings from './pages/AdminSettings';
+12 -5
View File
@@ -83,7 +83,14 @@ const AdminSettings: React.FC = () => {
const res = await uploadPdf(file, configKey); const res = await uploadPdf(file, configKey);
setConfigs(prev => prev.map(c => c.key === configKey ? { ...c, value: res.url } : c)); setConfigs(prev => prev.map(c => c.key === configKey ? { ...c, value: res.url } : c));
form.setFieldsValue({ [configKey]: res.url }); form.setFieldsValue({ [configKey]: res.url });
message.success('PDF上传成功');
// Find the config and update to database
const config = configs.find(c => c.key === configKey);
if (config) {
await updateSystemConfig(config.id, res.url);
}
message.success('PDF上传成功并已保存');
} catch { } catch {
message.error('上传失败'); message.error('上传失败');
} finally { } finally {
@@ -109,7 +116,7 @@ const AdminSettings: React.FC = () => {
const groupedConfigs: Record<string, SystemConfig[]> = { const groupedConfigs: Record<string, SystemConfig[]> = {
'站点信息': configs.filter(c => c.key.startsWith('site_')), '站点信息': configs.filter(c => c.key.startsWith('site_')),
'协议配置': configs.filter(c => c.key === 'user_agreement_url' || c.key === 'privacy_policy_url'), '协议配置': configs.filter(c => c.key === 'user_agreement_privacy_url'),
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')), 'SEO 设置': configs.filter(c => c.key.startsWith('seo_')),
'用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')), '用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')),
}; };
@@ -118,8 +125,8 @@ const AdminSettings: React.FC = () => {
const descMap: Record<string, string> = { const descMap: Record<string, string> = {
site_name: '平台显示名称,将展示在页面标题和导航栏', site_name: '平台显示名称,将展示在页面标题和导航栏',
site_logo: '平台Logo图片URL,建议尺寸 200x40px', site_logo: '平台Logo图片URL,建议尺寸 200x40px',
user_agreement_url: '用户注册/登录时需同意的用户协议PDF文件', site_copyright: '显示在前台登录页底部的版权信息,例如:© 2024 民众智创 版权所有',
privacy_policy_url: '用户注册/登录时需同意的隐私政策PDF文件', user_agreement_privacy_url: '用户登录时需同意的用户协议及隐私政策PDF文件',
seo_title: '搜索引擎结果中显示的标题', seo_title: '搜索引擎结果中显示的标题',
seo_description: '搜索引擎结果中显示的描述文字,建议150字以内', seo_description: '搜索引擎结果中显示的描述文字,建议150字以内',
seo_keywords: '用逗号分隔的关键词列表', seo_keywords: '用逗号分隔的关键词列表',
@@ -196,7 +203,7 @@ const AdminSettings: React.FC = () => {
}; };
const PdfUploadField: React.FC<{ config: SystemConfig }> = ({ config }) => { const PdfUploadField: React.FC<{ config: SystemConfig }> = ({ config }) => {
const label = config.key === 'user_agreement_url' ? '用户协议' : '隐私政策'; const label = config.key === 'user_agreement_privacy_url' ? '用户协议及隐私政策' : '协议文件';
const hasFile = config.value && config.value.startsWith('/uploads/'); const hasFile = config.value && config.value.startsWith('/uploads/');
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000'; const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
return ( return (
+1 -1
View File
@@ -1,7 +1,7 @@
# App # App
APP_NAME=VideoGen API APP_NAME=VideoGen API
APP_VERSION=1.0.0 APP_VERSION=1.0.0
DEBUG=true DEBUG=false
SECRET_KEY=local-dev-secret-key-not-for-production SECRET_KEY=local-dev-secret-key-not-for-production
# Database (PostgreSQL) # Database (PostgreSQL)
@@ -1,8 +1,8 @@
"""add notification indexes for query optimization """add core business indexes and contact submit_date
Revision ID: n123456789ab Revision ID: n123456789ab
Revises: c72a6f69e641 Revises: c72a6f69e641
Create Date: 2026-06-29 12:00:00.000000 Create Date: 2026-06-29 14:00:00.000000
""" """
from typing import Sequence, Union from typing import Sequence, Union
@@ -10,7 +10,6 @@ from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "n123456789ab" revision: str = "n123456789ab"
down_revision: Union[str, None] = "c72a6f69e641" down_revision: Union[str, None] = "c72a6f69e641"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
@@ -18,7 +17,120 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
# Composite index for personal notifications: (user_id, is_read, created_at) # 1. Add submit_date column to contact_requests
op.add_column(
"contact_requests",
sa.Column("submit_date", sa.String(10), nullable=True)
)
# Backfill submit_date from created_at for existing records
op.execute(
"UPDATE contact_requests SET submit_date = TO_CHAR(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD') WHERE submit_date IS NULL"
)
# Set NOT NULL after backfill
op.alter_column("contact_requests", "submit_date", nullable=False)
# Create index on submit_date
op.create_index(
"ix_contact_requests_submit_date",
"contact_requests",
["submit_date"],
unique=False,
)
# 2. Contact requests unique constraint: (user_id, submit_date)
op.create_unique_constraint(
"uq_contact_user_date",
"contact_requests",
["user_id", "submit_date"],
)
# 3. Contact requests composite indexes
op.create_index(
"idx_contact_handled_created",
"contact_requests",
["is_handled", "created_at"],
unique=False,
)
op.create_index(
"idx_contact_user_date_created",
"contact_requests",
["user_id", "submit_date", "created_at"],
unique=False,
)
# 4. Generation records composite indexes
op.create_index(
"idx_genrec_user_status_created",
"generation_records",
["user_id", "status", "created_at"],
unique=False,
)
op.create_index(
"idx_genrec_project_status",
"generation_records",
["project_id", "status"],
unique=False,
)
# 5. Payment orders composite indexes
op.create_index(
"idx_payorder_user_status_created",
"payment_orders",
["user_id", "status", "created_at"],
unique=False,
)
op.create_index(
"idx_payorder_status_created",
"payment_orders",
["status", "created_at"],
unique=False,
)
# 6. Upload task composite indexes
op.create_index(
"idx_upload_user_status_created",
"upload_task",
["user_id", "status", "created_at"],
unique=False,
)
op.create_index(
"idx_upload_oauth_status",
"upload_task",
["oauth_id", "status"],
unique=False,
)
# 7. Menu configs indexes
op.create_index(
op.f("ix_menu_configs_parent_id"),
"menu_configs",
["parent_id"],
unique=False,
)
op.create_index(
"idx_menu_target_active_sort",
"menu_configs",
["menu_target", "is_active", "sort_order"],
unique=False,
)
# 8. Operation logs composite indexes
op.create_index(
"idx_oplog_user_created",
"operation_logs",
["user_id", "created_at"],
unique=False,
)
op.create_index(
"idx_oplog_created",
"operation_logs",
["created_at"],
unique=False,
)
# 9. Notification indexes (from previous optimization)
op.create_index( op.create_index(
"idx_notif_user_isread_created", "idx_notif_user_isread_created",
"notifications", "notifications",
@@ -26,16 +138,13 @@ def upgrade() -> None:
unique=False, unique=False,
) )
# Composite index for notification_reads lookups # 10. Notification reads indexes
op.create_index( op.create_index(
"idx_notif_read_user_notif", "idx_notif_read_user_notif",
"notification_reads", "notification_reads",
["user_id", "notification_id"], ["user_id", "notification_id"],
unique=False, unique=False,
) )
# Unique constraint to prevent duplicate read records
# Check if constraint already exists first (in case of partial migration)
op.create_unique_constraint( op.create_unique_constraint(
"uq_notif_read_user", "uq_notif_read_user",
"notification_reads", "notification_reads",
@@ -47,3 +156,18 @@ def downgrade() -> None:
op.drop_constraint("uq_notif_read_user", "notification_reads", type_="unique") op.drop_constraint("uq_notif_read_user", "notification_reads", type_="unique")
op.drop_index("idx_notif_read_user_notif", table_name="notification_reads") op.drop_index("idx_notif_read_user_notif", table_name="notification_reads")
op.drop_index("idx_notif_user_isread_created", table_name="notifications") op.drop_index("idx_notif_user_isread_created", table_name="notifications")
op.drop_index("idx_oplog_created", table_name="operation_logs")
op.drop_index("idx_oplog_user_created", table_name="operation_logs")
op.drop_index("idx_menu_target_active_sort", table_name="menu_configs")
op.drop_index(op.f("ix_menu_configs_parent_id"), table_name="menu_configs")
op.drop_index("idx_upload_oauth_status", table_name="upload_task")
op.drop_index("idx_upload_user_status_created", table_name="upload_task")
op.drop_index("idx_payorder_status_created", table_name="payment_orders")
op.drop_index("idx_payorder_user_status_created", table_name="payment_orders")
op.drop_index("idx_genrec_project_status", table_name="generation_records")
op.drop_index("idx_genrec_user_status_created", table_name="generation_records")
op.drop_index("idx_contact_user_date_created", table_name="contact_requests")
op.drop_index("idx_contact_handled_created", table_name="contact_requests")
op.drop_constraint("uq_contact_user_date", "contact_requests", type_="unique")
op.drop_index("ix_contact_requests_submit_date", table_name="contact_requests")
op.drop_column("contact_requests", "submit_date")
+4 -4
View File
@@ -308,10 +308,10 @@ async def change_password(
@router.get("/site-info") @router.get("/site-info")
async def get_site_info(db: AsyncSession = Depends(get_db)): async def get_site_info(db: AsyncSession = Depends(get_db)):
"""Public endpoint returning site name and logo.""" """Public endpoint returning site name, logo, agreement and copyright info."""
result = await db.execute( result = await db.execute(
select(SystemConfig).where(SystemConfig.key.in_([ select(SystemConfig).where(SystemConfig.key.in_([
"site_name", "site_logo", "user_agreement_url", "privacy_policy_url" "site_name", "site_logo", "user_agreement_privacy_url", "site_copyright"
])) ]))
) )
configs = result.scalars().all() configs = result.scalars().all()
@@ -331,8 +331,8 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
return { return {
"site_name": info.get("site_name", "VideoGen.AI"), "site_name": info.get("site_name", "VideoGen.AI"),
"site_logo": to_full_url(info.get("site_logo")), "site_logo": to_full_url(info.get("site_logo")),
"user_agreement_url": to_full_url(info.get("user_agreement_url")), "user_agreement_privacy_url": to_full_url(info.get("user_agreement_privacy_url")),
"privacy_policy_url": to_full_url(info.get("privacy_policy_url")), "site_copyright": info.get("site_copyright", "© 2024 民众智创 版权所有"),
} }
+42 -27
View File
@@ -2,6 +2,7 @@ from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user from app.dependencies import get_db, get_current_user
@@ -19,37 +20,48 @@ async def create_contact_request(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
): ):
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
today_end = today_start + timedelta(days=1)
count = await db.execute( async with db.begin_nested():
select(func.count(ContactRequest.id)) user_result = await db.execute(
.where(ContactRequest.user_id == user.id) select(User).where(User.id == user.id).with_for_update().limit(1)
.where(ContactRequest.created_at >= today_start) )
.where(ContactRequest.created_at < today_end) locked_user = user_result.scalar_one_or_none()
) if not locked_user:
daily_count = count.scalar_one() raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
if daily_count >= 1: existing = await db.execute(
select(ContactRequest.id)
.where(ContactRequest.user_id == user.id)
.where(ContactRequest.submit_date == today_str)
.limit(1)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="每个账号每天只能提交一次联系我们"
)
try:
contact_request = ContactRequest(
id=generate_id(),
user_id=user.id,
phone=request.phone,
company_name=request.company_name,
industry=request.industry,
name=request.name,
message=request.message,
submit_date=today_str,
)
db.add(contact_request)
await db.commit()
except IntegrityError:
await db.rollback()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="每个账号每天只能提交一次联系我们" detail="每个账号每天只能提交一次联系我们"
) )
contact_request = ContactRequest(
id=generate_id(),
user_id=user.id,
phone=request.phone,
company_name=request.company_name,
industry=request.industry,
name=request.name,
message=request.message,
)
db.add(contact_request)
await db.commit()
await db.refresh(contact_request)
return {"message": "提交成功,我们会尽快与您联系"} return {"message": "提交成功,我们会尽快与您联系"}
@@ -64,17 +76,20 @@ async def get_contact_requests(
if not user.is_admin: if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
query = select(ContactRequest).order_by(ContactRequest.created_at.desc()) query = select(ContactRequest)
count_query = select(func.count(ContactRequest.id))
if is_handled is not None: if is_handled is not None:
query = query.where(ContactRequest.is_handled == is_handled) query = query.where(ContactRequest.is_handled == is_handled)
count_query = count_query.where(ContactRequest.is_handled == is_handled)
query = query.order_by(ContactRequest.created_at.desc())
offset = (page - 1) * page_size offset = (page - 1) * page_size
result = await db.execute(query.offset(offset).limit(page_size)) result = await db.execute(query.offset(offset).limit(page_size))
items = result.scalars().all() items = result.scalars().all()
count_result = await db.execute(select(func.count(ContactRequest.id))) total = (await db.execute(count_query)).scalar_one()
total = count_result.scalar_one()
return {"items": items, "total": total} return {"items": items, "total": total}
+24 -22
View File
@@ -127,30 +127,32 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
signature = headers.get("wechatpay-signature", "") signature = headers.get("wechatpay-signature", "")
serial_no = headers.get("wechatpay-serial", "") serial_no = headers.get("wechatpay-serial", "")
# 安全要求:非mock模式下必须验证签名,配置缺失直接拒绝
if not public_key:
logger.error("WeChat platform public key not configured, cannot verify callback signature")
return {"code": "FAIL", "message": "Platform public key not configured"}
if not serial_no:
logger.error("Wechatpay-Serial header missing in callback")
return {"code": "FAIL", "message": "Missing Wechatpay-Serial header"}
if not timestamp or not nonce or not signature:
logger.error("WeChat callback missing required signature headers")
return {"code": "FAIL", "message": "Missing signature headers"}
# 验证签名:使用平台公钥验证 # 验证签名:使用平台公钥验证
if public_key and serial_no: try:
try: is_verified = rsa_verify(
# 构造签名串:timestamp + "\n" + nonce + "\n" + body + "\n" timestamp=timestamp,
# 符合微信支付官方文档规范:https://pay.weixin.qq.com/doc/v3/merchant/4013053249 nonce=nonce,
is_verified = rsa_verify( body=body_str,
timestamp=timestamp, signature=signature,
nonce=nonce, public_key=load_public_key(public_key)
body=body_str, )
signature=signature, if not is_verified:
public_key=load_public_key(public_key) logger.warning(f"WeChat callback signature verification failed: serial={serial_no}")
) return {"code": "FAIL", "message": "Signature verification failed"}
if not is_verified: except Exception as e:
logger.warning(f"WeChat callback signature verification failed: serial={serial_no}") logger.warning(f"WeChat signature verification error: {e}, serial={serial_no}")
raise HTTPException(status_code=400, detail="签名验证失败") return {"code": "FAIL", "message": "Signature verification error"}
except Exception as e:
logger.warning(f"WeChat signature verification error: {e}, serial={serial_no}")
raise HTTPException(status_code=400, detail="签名验证失败")
else:
if not public_key:
logger.warning("WeChat platform public key not configured, skipping signature verification")
if not serial_no:
logger.warning("Wechatpay-Serial header missing, skipping signature verification")
# 解密回调数据:使用 API v3 key # 解密回调数据:使用 API v3 key
# 官方文档:https://pay.weixin.qq.com/doc/v3/merchant/4012071382 # 官方文档:https://pay.weixin.qq.com/doc/v3/merchant/4012071382
+9 -4
View File
@@ -164,12 +164,12 @@ async def _seed_data():
configs = [ configs = [
("site_name", "民众智创", "网站名称"), ("site_name", "民众智创", "网站名称"),
("site_logo", "", "网站Logo URL"), ("site_logo", "", "网站Logo URL"),
("site_copyright", "© 2024 民众智创 版权所有", "网站底部版权信息"),
("seo_title", "民众智创 - AI视频生成平台", "SEO标题"), ("seo_title", "民众智创 - AI视频生成平台", "SEO标题"),
("seo_description", "专业的AI视频生成服务", "SEO描述"), ("seo_description", "专业的AI视频生成服务", "SEO描述"),
("seo_keywords", "AI视频,视频生成,人工智能", "SEO关键词"), ("seo_keywords", "AI视频,视频生成,人工智能", "SEO关键词"),
# Agreement configs # Agreement config
("user_agreement_url", "", "用户协议PDF"), ("user_agreement_privacy_url", "", "用户协议及隐私政策PDF"),
("privacy_policy_url", "", "隐私政策PDF"),
# Payment configs # Payment configs
("payment_wechat_enabled", "false", "微信支付启用"), ("payment_wechat_enabled", "false", "微信支付启用"),
("payment_wechat_mch_id", "", "微信商户号"), ("payment_wechat_mch_id", "", "微信商户号"),
@@ -188,12 +188,17 @@ async def _seed_data():
existing = await db.execute( existing = await db.execute(
select(SystemConfig).where(SystemConfig.key == key).limit(1) select(SystemConfig).where(SystemConfig.key == key).limit(1)
) )
if not existing.scalar_one_or_none(): existing_config = existing.scalar_one_or_none()
if not existing_config:
db.add( db.add(
SystemConfig( SystemConfig(
id=generate_id(), key=key, value=value, description=desc id=generate_id(), key=key, value=value, description=desc
) )
) )
elif not existing_config.description and desc:
# Update description if not set
existing_config.description = desc
db.add(existing_config)
# Seed video engine # Seed video engine
existing_engine = await db.execute( existing_engine = await db.execute(
+8 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Boolean, ForeignKey, String, Text from sqlalchemy import Boolean, ForeignKey, String, Text, UniqueConstraint, Index
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin
@@ -15,3 +15,10 @@ class ContactRequest(Base, TimestampMixin):
name: Mapped[str] = mapped_column(String(64)) name: Mapped[str] = mapped_column(String(64))
message: Mapped[str | None] = mapped_column(Text, nullable=True) message: Mapped[str | None] = mapped_column(Text, nullable=True)
is_handled: Mapped[bool] = mapped_column(Boolean, default=False) is_handled: Mapped[bool] = mapped_column(Boolean, default=False)
submit_date: Mapped[str] = mapped_column(String(10), index=True)
__table_args__ = (
UniqueConstraint('user_id', 'submit_date', name='uq_contact_user_date'),
Index('idx_contact_handled_created', 'is_handled', 'created_at'),
Index('idx_contact_user_date_created', 'user_id', 'submit_date', 'created_at'),
)
@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, Float from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, Float, Index
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, SoftDeleteMixin from app.models.base import Base, TimestampMixin, SoftDeleteMixin
@@ -45,3 +45,8 @@ class GenerationRecord(Base, TimestampMixin, SoftDeleteMixin):
) )
error_message: Mapped[str | None] = mapped_column(Text, 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) idempotency_key: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
__table_args__ = (
Index('idx_genrec_user_status_created', 'user_id', 'status', 'created_at'),
Index('idx_genrec_project_status', 'project_id', 'status'),
)
+9 -5
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Boolean, Integer, String, ForeignKey from sqlalchemy import Boolean, Integer, String, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin
@@ -13,7 +13,11 @@ class MenuConfig(Base, TimestampMixin):
icon: Mapped[str] = mapped_column(String(64), default="") icon: Mapped[str] = mapped_column(String(64), default="")
sort_order: Mapped[int] = mapped_column(Integer, default=0) sort_order: Mapped[int] = mapped_column(Integer, default=0)
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
parent_id: Mapped[str | None] = mapped_column(String(32), ForeignKey("menu_configs.id"), nullable=True) parent_id: Mapped[str | None] = mapped_column(String(32), ForeignKey("menu_configs.id"), nullable=True, index=True)
menu_type: Mapped[str] = mapped_column(String(16), default="page") # page / group menu_type: Mapped[str] = mapped_column(String(16), default="page")
menu_target: Mapped[str] = mapped_column(String(16), default="frontend") # frontend / admin / both menu_target: Mapped[str] = mapped_column(String(16), default="frontend")
is_default: Mapped[bool] = mapped_column(Boolean, default=False) # default show for new users is_default: Mapped[bool] = mapped_column(Boolean, default=False)
__table_args__ = (
Index('idx_menu_target_active_sort', 'menu_target', 'is_active', 'sort_order'),
)
+9 -4
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Integer, String, Text from sqlalchemy import Integer, String, Text, Index
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin
@@ -10,8 +10,13 @@ class OperationLog(Base, TimestampMixin):
id: Mapped[str] = mapped_column(String(32), primary_key=True) id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(String(32), index=True) user_id: Mapped[str] = mapped_column(String(32), index=True)
username: Mapped[str] = mapped_column(String(64)) username: Mapped[str] = mapped_column(String(64))
action: Mapped[str] = mapped_column(String(128)) # e.g. "创建用户", "修改菜单" action: Mapped[str] = mapped_column(String(128))
method: Mapped[str] = mapped_column(String(10)) # POST/PUT/DELETE method: Mapped[str] = mapped_column(String(10))
path: Mapped[str] = mapped_column(String(256)) path: Mapped[str] = mapped_column(String(256))
detail: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON detail detail: Mapped[str | None] = mapped_column(Text, nullable=True)
ip: Mapped[str | None] = mapped_column(String(64), nullable=True) ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
__table_args__ = (
Index('idx_oplog_user_created', 'user_id', 'created_at'),
Index('idx_oplog_created', 'created_at'),
)
+6 -2
View File
@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Index
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin
@@ -22,9 +22,13 @@ class PaymentOrder(Base, TimestampMixin):
DateTime(timezone=True), nullable=True DateTime(timezone=True), nullable=True
) )
trade_no: Mapped[str | None] = mapped_column(String(128), 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) refund_trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
refunded_at: Mapped[datetime | None] = mapped_column( refunded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True DateTime(timezone=True), nullable=True
) )
refund_amount: Mapped[float | None] = mapped_column(Float, nullable=True) refund_amount: Mapped[float | None] = mapped_column(Float, nullable=True)
__table_args__ = (
Index('idx_payorder_user_status_created', 'user_id', 'status', 'created_at'),
Index('idx_payorder_status_created', 'status', 'created_at'),
)
+6 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import String, Text, Integer from sqlalchemy import String, Text, Integer, Index
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, SoftDeleteMixin from app.models.base import Base, TimestampMixin, SoftDeleteMixin
@@ -31,3 +31,8 @@ class UploadTask(Base, TimestampMixin, SoftDeleteMixin):
other_info: Mapped[str | None] = mapped_column( other_info: Mapped[str | None] = mapped_column(
String(500), nullable=True, comment="其他信息" String(500), nullable=True, comment="其他信息"
) )
__table_args__ = (
Index('idx_upload_user_status_created', 'user_id', 'status', 'created_at'),
Index('idx_upload_oauth_status', 'oauth_id', 'status'),
)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -28,8 +28,8 @@
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-BHOoZIhk.js"></script> <script type="module" crossorigin src="/assets/index-C9YKdZ8m.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-xCZbcxht.css"> <link rel="stylesheet" crossorigin href="/assets/index-DgpxHlJ1.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+2 -2
View File
@@ -174,8 +174,8 @@ export async function verifyCaptcha(captchaId: string, x: number): Promise<strin
return res.token; return res.token;
} }
// ── Site Info ───────────────────────────────────────────── // ── Site Info ─────────────────────────────────────────────
export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementUrl: string; privacyPolicyUrl: string }> { export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementPrivacyUrl: string; siteCopyright: string }> {
if (USE_MOCK) return { siteName: 'VideoGen.AI', siteLogo: '', userAgreementUrl: '', privacyPolicyUrl: '' }; if (USE_MOCK) return { siteName: 'VideoGen.AI', siteLogo: '', userAgreementPrivacyUrl: '', siteCopyright: '© 2024 民众智创 版权所有' };
return api.get('/auth/site-info', false); return api.get('/auth/site-info', false);
} }
// ── Video Engines ───────────────────────────────────────── // ── Video Engines ─────────────────────────────────────────
+29 -3
View File
@@ -1,5 +1,6 @@
.login-page { .login-page {
min-height: 100vh; height: 100vh;
max-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-image: url(/backimage.png); background-image: url(/backimage.png);
@@ -8,7 +9,7 @@
background-repeat: no-repeat; background-repeat: no-repeat;
background-attachment: fixed; background-attachment: fixed;
position: relative; position: relative;
overflow-x: hidden; overflow: hidden;
} }
@media (min-width: 900px) { @media (min-width: 900px) {
@@ -180,10 +181,35 @@
.login-right-section { .login-right-section {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1; z-index: 1;
padding: 16px 16px 32px; padding: 16px 16px 60px;
position: relative;
}
.login-right-section-inner {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 440px;
}
.login-copyright-wrapper {
position: absolute;
bottom: 24px;
left: 0;
right: 0;
text-align: center;
}
.login-copyright {
color: #666;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.5px;
} }
@media (min-width: 900px) { @media (min-width: 900px) {
+191 -179
View File
@@ -44,8 +44,8 @@ const LoginPage: React.FC = () => {
const initialInfo = getInitialSiteInfo(); const initialInfo = getInitialSiteInfo();
const [siteName, setSiteName] = useState(initialInfo.siteName); const [siteName, setSiteName] = useState(initialInfo.siteName);
const [siteLogo, setSiteLogo] = useState(initialInfo.siteLogo); const [siteLogo, setSiteLogo] = useState(initialInfo.siteLogo);
const [agreementUrl, setAgreementUrl] = useState(''); const [agreementPrivacyUrl, setAgreementPrivacyUrl] = useState('');
const [policyUrl, setPolicyUrl] = useState(''); const [siteCopyright, setSiteCopyright] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
const { login } = useAuthStore(); const { login } = useAuthStore();
@@ -57,14 +57,14 @@ const LoginPage: React.FC = () => {
getSiteInfo().then(info => { getSiteInfo().then(info => {
setSiteName(info.siteName); setSiteName(info.siteName);
setSiteLogo(info.siteLogo); setSiteLogo(info.siteLogo);
setAgreementUrl(info.userAgreementUrl); setAgreementPrivacyUrl(info.userAgreementPrivacyUrl);
setPolicyUrl(info.privacyPolicyUrl); setSiteCopyright(info.siteCopyright);
}).catch(() => {}); }).catch(() => {});
}, []); }, []);
const checkAgreed = (): boolean => { const checkAgreed = (): boolean => {
if (!agreed) { if (!agreed) {
message.warning('请先阅读并同意用户协议隐私政策'); message.warning('请先阅读并同意用户协议隐私政策');
return false; return false;
} }
return true; return true;
@@ -259,7 +259,15 @@ const LoginPage: React.FC = () => {
}; };
const openPdf = (url: string) => { const openPdf = (url: string) => {
if (url) window.open(`${API_BASE.replace(/\/api$/, '')}${url}`, '_blank'); if (!url) {
message.warning('暂未上传协议文件');
return;
}
if (url.startsWith('http://') || url.startsWith('https://')) {
window.open(url, '_blank');
} else {
window.open(`${API_BASE.replace(/\/api$/, '')}${url}`, '_blank');
}
}; };
return ( return (
@@ -310,182 +318,186 @@ const LoginPage: React.FC = () => {
</div> </div>
<div className="login-right-section"> <div className="login-right-section">
<Card className="login-card" styles={{ body: { padding: '36px 28px' } }}> <div className="login-right-section-inner">
<Typography.Title level={3} className="login-card-title"> <Card className="login-card" styles={{ body: { padding: '36px 28px' } }}>
{mode === 'register' ? '创建账号' : '欢迎回来'} <Typography.Title level={3} className="login-card-title">
</Typography.Title> {mode === 'register' ? '创建账号' : '欢迎回来'}
<Typography.Text className="login-card-subtitle"> </Typography.Title>
{mode === 'register' ? '注册新账号,开始创作视频' : '登录您的账号,开始创作视频'} <Typography.Text className="login-card-subtitle">
</Typography.Text> {mode === 'register' ? '注册新账号,开始创作视频' : '登录您的账号,开始创作视频'}
</Typography.Text>
{mode !== 'register' && ( {mode !== 'register' && (
<div className="login-tabs"> <div className="login-tabs">
{(['password', 'phone'] as const).map((t) => ( {(['password', 'phone'] as const).map((t) => (
<div key={t} onClick={() => switchTab(t)} <div key={t} onClick={() => switchTab(t)}
className={`login-tab ${tab === t ? 'login-tab-active' : ''}`}> className={`login-tab ${tab === t ? 'login-tab-active' : ''}`}>
{t === 'password' ? '密码登录' : '验证码登录'} {t === 'password' ? '密码登录' : '验证码登录'}
</div> </div>
))} ))}
</div> </div>
)}
{mode === 'password' && (
<Form form={pwdForm} size="large" layout="vertical" onFinish={handlePasswordLogin}>
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
<Input prefix={<MobileOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入手机号" style={inputStyle} />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入密码" style={inputStyle} />
</Form.Item>
<Form.Item name="rememberMe" valuePropName="checked" style={{ marginBottom: 12 }}>
<Checkbox></Checkbox>
</Form.Item>
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" htmlType="submit" loading={loading} block className="login-submit-btn"> </Button>
</Form.Item>
</Form>
)}
{mode === 'phone' && (
<Form form={phoneForm} size="large" layout="vertical">
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
<Input prefix={<MobileOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入手机号" maxLength={11} style={inputStyle} />
</Form.Item>
<Form.Item name="code" rules={[{ required: true, message: '请输入验证码' }]}>
<Space.Compact style={{ width: '100%' }}>
<Input
prefix={<SafetyOutlined style={{ color: '#94a3b8', marginRight: 8 }} />}
placeholder="请输入验证码"
maxLength={6}
disabled={!loginSliderVerified}
style={{ ...inputStyle, borderRadius: '10px 0 0 10px', flex: 1 }} />
<Button disabled={countdown > 0}
onClick={() => {
if (loginShowResend) {
setLoginSliderVerified(false);
setShowSliderVerify(true);
setSliderKey(prev => prev + 1);
} else {
handleSendCode(phoneForm.getFieldValue('phone'));
}
}}
className="login-code-btn">
{countdown > 0 ? `${countdown}s` : (loginShowResend ? '重新发送' : '获取验证码')}
</Button>
</Space.Compact>
</Form.Item>
{showSliderVerify && (
<Form.Item style={{ marginBottom: 12 }} key={sliderKey}>
<SliderVerify
onSuccess={handleSliderSuccess}
isVerified={loginSliderVerified}
/>
</Form.Item>
)}
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" onClick={handlePhoneLogin} loading={loading} block className="login-submit-btn"> </Button>
</Form.Item>
</Form>
)}
{mode === 'register' && (
<Form form={regForm} size="large" layout="vertical">
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
<Input prefix={<MobileOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入手机号" maxLength={11} style={inputStyle} />
</Form.Item>
<Form.Item name="regCode" rules={[{ required: true, message: '请输入验证码' }]}>
<Space.Compact style={{ width: '100%' }}>
<Input
prefix={<SafetyOutlined style={{ color: '#94a3b8', marginRight: 8 }} />}
placeholder="请输入验证码"
maxLength={6}
disabled={!sliderVerified}
style={{ ...inputStyle, borderRadius: '10px 0 0 10px', flex: 1 }} />
<Button disabled={regCountdown > 0}
onClick={() => {
if (showResend) {
setSliderVerified(false);
setShowSliderVerify(true);
setSliderKey(prev => prev + 1);
} else {
handleSendCode(regForm.getFieldValue('phone'), true);
}
}}
className="login-code-btn">
{regCountdown > 0 ? `${regCountdown}s` : (showResend ? '重新发送' : '获取验证码')}
</Button>
</Space.Compact>
</Form.Item>
{showSliderVerify && (
<Form.Item style={{ marginBottom: 12 }} key={sliderKey}>
<SliderVerify
onSuccess={handleSliderSuccess}
isVerified={sliderVerified}
/>
</Form.Item>
)}
<Form.Item name="password" rules={[{ required: true, message: '请设置密码' }, { min: 6, message: '密码至少6位' }]}>
<Input.Password prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请设置密码(至少6位)" style={inputStyle} />
</Form.Item>
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" onClick={handleRegister} loading={loading} block className="login-submit-btn"> </Button>
</Form.Item>
</Form>
)}
<div className="login-agreement">
<Checkbox checked={agreed} onChange={e => setAgreed(e.target.checked)}>
<span className="login-agreement-text">
<span
onClick={e => { e.stopPropagation(); openPdf(agreementUrl); }}
className="login-link"
></span>
<span
onClick={e => { e.stopPropagation(); openPdf(policyUrl); }}
className="login-link"
></span>
</span>
</Checkbox>
</div>
<div className="login-footer">
{mode === 'register' ? (
<Typography.Text
className="login-switch-btn"
onClick={() => {
setMode('password');
setTab('password');
setShowResend(false);
setLoginShowResend(false);
setSliderVerified(false);
setShowSliderVerify(false);
setSliderKey(prev => prev + 1);
regForm.resetFields();
}}
>
</Typography.Text>
) : (
<Typography.Text
className="login-switch-btn"
onClick={() => {
setMode('register');
setShowResend(false);
setLoginShowResend(false);
setSliderVerified(false);
setShowSliderVerify(false);
setSliderKey(prev => prev + 1);
}}
>
</Typography.Text>
)} )}
{mode === 'password' && (
<Form form={pwdForm} size="large" layout="vertical" onFinish={handlePasswordLogin}>
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
<Input prefix={<MobileOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入手机号" style={inputStyle} />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入密码" style={inputStyle} />
</Form.Item>
<Form.Item name="rememberMe" valuePropName="checked" style={{ marginBottom: 12 }}>
<Checkbox></Checkbox>
</Form.Item>
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" htmlType="submit" loading={loading} block className="login-submit-btn"> </Button>
</Form.Item>
</Form>
)}
{mode === 'phone' && (
<Form form={phoneForm} size="large" layout="vertical">
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
<Input prefix={<MobileOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入手机号" maxLength={11} style={inputStyle} />
</Form.Item>
<Form.Item name="code" rules={[{ required: true, message: '请输入验证码' }]}>
<Space.Compact style={{ width: '100%' }}>
<Input
prefix={<SafetyOutlined style={{ color: '#94a3b8', marginRight: 8 }} />}
placeholder="请输入验证码"
maxLength={6}
disabled={!loginSliderVerified}
style={{ ...inputStyle, borderRadius: '10px 0 0 10px', flex: 1 }} />
<Button disabled={countdown > 0}
onClick={() => {
if (loginShowResend) {
setLoginSliderVerified(false);
setShowSliderVerify(true);
setSliderKey(prev => prev + 1);
} else {
handleSendCode(phoneForm.getFieldValue('phone'));
}
}}
className="login-code-btn">
{countdown > 0 ? `${countdown}s` : (loginShowResend ? '重新发送' : '获取验证码')}
</Button>
</Space.Compact>
</Form.Item>
{showSliderVerify && (
<Form.Item style={{ marginBottom: 12 }} key={sliderKey}>
<SliderVerify
onSuccess={handleSliderSuccess}
isVerified={loginSliderVerified}
/>
</Form.Item>
)}
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" onClick={handlePhoneLogin} loading={loading} block className="login-submit-btn"> </Button>
</Form.Item>
</Form>
)}
{mode === 'register' && (
<Form form={regForm} size="large" layout="vertical">
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
<Input prefix={<MobileOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入手机号" maxLength={11} style={inputStyle} />
</Form.Item>
<Form.Item name="regCode" rules={[{ required: true, message: '请输入验证码' }]}>
<Space.Compact style={{ width: '100%' }}>
<Input
prefix={<SafetyOutlined style={{ color: '#94a3b8', marginRight: 8 }} />}
placeholder="请输入验证码"
maxLength={6}
disabled={!sliderVerified}
style={{ ...inputStyle, borderRadius: '10px 0 0 10px', flex: 1 }} />
<Button disabled={regCountdown > 0}
onClick={() => {
if (showResend) {
setSliderVerified(false);
setShowSliderVerify(true);
setSliderKey(prev => prev + 1);
} else {
handleSendCode(regForm.getFieldValue('phone'), true);
}
}}
className="login-code-btn">
{regCountdown > 0 ? `${regCountdown}s` : (showResend ? '重新发送' : '获取验证码')}
</Button>
</Space.Compact>
</Form.Item>
{showSliderVerify && (
<Form.Item style={{ marginBottom: 12 }} key={sliderKey}>
<SliderVerify
onSuccess={handleSliderSuccess}
isVerified={sliderVerified}
/>
</Form.Item>
)}
<Form.Item name="password" rules={[{ required: true, message: '请设置密码' }, { min: 6, message: '密码至少6位' }]}>
<Input.Password prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请设置密码(至少6位)" style={inputStyle} />
</Form.Item>
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" onClick={handleRegister} loading={loading} block className="login-submit-btn"> </Button>
</Form.Item>
</Form>
)}
<div className="login-agreement">
<Checkbox checked={agreed} onChange={e => setAgreed(e.target.checked)}>
<span className="login-agreement-text">
<span
onClick={e => { e.stopPropagation(); openPdf(agreementPrivacyUrl); }}
className="login-link"
></span>
</span>
</Checkbox>
</div>
<div className="login-footer">
{mode === 'register' ? (
<Typography.Text
className="login-switch-btn"
onClick={() => {
setMode('password');
setTab('password');
setShowResend(false);
setLoginShowResend(false);
setSliderVerified(false);
setShowSliderVerify(false);
setSliderKey(prev => prev + 1);
regForm.resetFields();
}}
>
</Typography.Text>
) : (
<Typography.Text
className="login-switch-btn"
onClick={() => {
setMode('register');
setShowResend(false);
setLoginShowResend(false);
setSliderVerified(false);
setShowSliderVerify(false);
setSliderKey(prev => prev + 1);
}}
>
</Typography.Text>
)}
</div>
</Card>
</div>
{siteCopyright && (
<div className="login-copyright-wrapper">
<div className="login-copyright">
{siteCopyright}
</div>
</div> </div>
</Card> )}
</div> </div>
</div> </div>
); );