加载中...
+ ) : currentDetail && (
+
+ {/* 基本信息 */}
+
+
+ {currentDetail.invoiceNo}
+ {statusTag(currentDetail.status)}
+
+
+ 用户ID:
+ {currentDetail.userId}
+ 抬头类型:
+ {currentDetail.headerType === 'company' ? '企业' : '个人'}
+ 抬头名称:
+ {currentDetail.headerName}
+ {currentDetail.headerTaxNo && (
+ <>
+ 税号:
+ {currentDetail.headerTaxNo}
+ >
+ )}
+ 接收邮箱:
+ {currentDetail.email}
+ 总金额:
+ ¥{currentDetail.totalAmount.toFixed(2)}
+ 创建时间:
+ {formatDate(currentDetail.createdAt)}
+ {currentDetail.issuedAt && (
+ <>
+ 开票时间:
+ {formatDate(currentDetail.issuedAt)}
+ >
+ )}
+ {currentDetail.failureReason && (
+ <>
+ 失败原因:
+ {currentDetail.failureReason}
+ >
+ )}
+
+
+
+ {/* 关联订单 */}
+ {currentDetail.orders && currentDetail.orders.length > 0 && (
+
+
+ 关联订单({currentDetail.orders.length} 笔)
+
+
{v},
+ },
+ {
+ title: '金额',
+ dataIndex: 'amount',
+ key: 'amount',
+ align: 'right' as const,
+ render: (v: number) => `¥${v.toFixed(2)}`,
+ },
+ {
+ title: '积分',
+ dataIndex: 'credits',
+ key: 'credits',
+ align: 'right' as const,
+ },
+ ]}
+ rowKey="id"
+ pagination={false}
+ size="small"
+ />
+
+ )}
+
+ )}
+
+
+ {/* 失败原因弹窗 */}
+ { setFailModalOpen(false); setFailTargetId(null); setFailReason(''); }}
+ onOk={handleConfirmFail}
+ okText="确认"
+ cancelText="取消"
+ confirmLoading={failSubmitting}
+ >
+
+ 请填写失败原因(用户可见):
+
+ setFailReason(e.target.value)}
+ placeholder="例如:抬头信息有误,请重新提交"
+ rows={3}
+ maxLength={500}
+ showCount
+ />
+
+
+ );
+};
+
+export default AdminInvoices;
diff --git a/video-gen-admin/src/types/index.ts b/video-gen-admin/src/types/index.ts
index dad13673..12836d37 100644
--- a/video-gen-admin/src/types/index.ts
+++ b/video-gen-admin/src/types/index.ts
@@ -1442,3 +1442,52 @@ export interface VideoUpscaleConfigSavePayload {
}>;
};
}
+
+// ── Invoice Types ───────────────────────────────────────
+
+export interface InvoiceItem {
+ id: string;
+ invoiceNo: string;
+ userId: string;
+ username: string;
+ phone: string;
+ headerType: string;
+ headerName: string;
+ email: string;
+ totalAmount: number;
+ totalCredits: number;
+ orderCount: number;
+ status: string;
+ failureReason: string | null;
+ issuedAt: string | null;
+ createdAt: string | null;
+}
+
+export interface InvoiceOrder {
+ id: string;
+ orderNo: string;
+ amount: number;
+ credits: number;
+}
+
+export interface InvoiceDetail {
+ id: string;
+ invoiceNo: string;
+ userId: string;
+ headerType: string;
+ headerName: string;
+ headerTaxNo: string | null;
+ headerRegisterAddress: string | null;
+ headerRegisterPhone: string | null;
+ headerBankName: string | null;
+ headerBankAccount: string | null;
+ email: string;
+ totalAmount: number;
+ totalCredits: number;
+ status: string;
+ failureReason: string | null;
+ issuedAt: string | null;
+ createdAt: string | null;
+ updatedAt: string | null;
+ orders: InvoiceOrder[];
+}
diff --git a/video-gen-api/alembic/versions/20260810_20260810_发票管理表.py b/video-gen-api/alembic/versions/20260810_20260810_发票管理表.py
new file mode 100644
index 00000000..44041230
--- /dev/null
+++ b/video-gen-api/alembic/versions/20260810_20260810_发票管理表.py
@@ -0,0 +1,109 @@
+"""发票管理表迁移
+
+创建 invoices(发票主表)和 invoice_orders(发票-订单关联表)。
+
+Revision ID: 20260810_20260810
+Revises: 2026080601
+Create Date: 2026-08-10 00:00:00.000000
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = '20260810_20260810'
+down_revision: Union[str, None] = '2026080601'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def _comment_table(table_name: str, comment: str) -> None:
+ op.execute(f"COMMENT ON TABLE {table_name} IS '{comment}'")
+
+
+def _comment_column(table_name: str, column_name: str, comment: str) -> None:
+ escaped = comment.replace("'", "''")
+ op.execute(f"COMMENT ON COLUMN {table_name}.{column_name} IS '{escaped}'")
+
+
+def upgrade() -> None:
+ # ============================================================
+ # 1. 创建 invoices 表
+ # ============================================================
+ op.create_table(
+ 'invoices',
+ sa.Column('id', sa.String(32), primary_key=True),
+ sa.Column('user_id', sa.String(32), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
+ sa.Column('invoice_no', sa.String(32), nullable=False, unique=True),
+ sa.Column('header_type', sa.String(16), nullable=False),
+ sa.Column('header_name', sa.String(128), nullable=False),
+ sa.Column('header_tax_no', sa.String(32), nullable=True),
+ sa.Column('header_register_address', sa.String(256), nullable=True),
+ sa.Column('header_register_phone', sa.String(32), nullable=True),
+ sa.Column('header_bank_name', sa.String(128), nullable=True),
+ sa.Column('header_bank_account', sa.String(64), nullable=True),
+ sa.Column('email', sa.String(128), nullable=False),
+ sa.Column('total_amount', sa.Float, nullable=False, server_default='0'),
+ sa.Column('total_credits', sa.Float, nullable=False, server_default='0'),
+ sa.Column('status', sa.String(16), nullable=False, server_default='processing'),
+ sa.Column('failure_reason', sa.Text, nullable=True),
+ sa.Column('issued_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ )
+ op.create_index('idx_invoices_user_created', 'invoices', ['user_id', 'created_at'])
+ op.create_index('idx_invoices_status_created', 'invoices', ['status', 'created_at'])
+ op.create_index('idx_invoices_invoice_no', 'invoices', ['invoice_no'], unique=True)
+
+ # ============================================================
+ # 2. 创建 invoice_orders 表
+ # ============================================================
+ op.create_table(
+ 'invoice_orders',
+ sa.Column('id', sa.String(32), primary_key=True),
+ sa.Column('invoice_id', sa.String(32), sa.ForeignKey('invoices.id', ondelete='CASCADE'), nullable=False),
+ sa.Column('order_id', sa.String(32), sa.ForeignKey('payment_orders.id', ondelete='CASCADE'), nullable=False),
+ sa.Column('order_no', sa.String(64), nullable=False),
+ sa.Column('amount', sa.Float, nullable=False, server_default='0'),
+ sa.Column('credits', sa.Float, nullable=False, server_default='0'),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ )
+ op.create_index('idx_invoice_orders_invoice', 'invoice_orders', ['invoice_id'])
+ op.create_index('idx_invoice_orders_order', 'invoice_orders', ['order_id'])
+ op.create_unique_constraint('uq_invoice_orders', 'invoice_orders', ['invoice_id', 'order_id'])
+
+ # ============================================================
+ # 3. 表注释和字段注释
+ # ============================================================
+ _comment_table('invoices', '发票主表')
+ _comment_column('invoices', 'id', '主键')
+ _comment_column('invoices', 'user_id', '申请用户ID')
+ _comment_column('invoices', 'invoice_no', '发票编号')
+ _comment_column('invoices', 'header_type', '抬头类型: personal/company')
+ _comment_column('invoices', 'header_name', '抬头名称')
+ _comment_column('invoices', 'header_tax_no', '税号')
+ _comment_column('invoices', 'header_register_address', '注册地址')
+ _comment_column('invoices', 'header_register_phone', '注册电话')
+ _comment_column('invoices', 'header_bank_name', '开户行')
+ _comment_column('invoices', 'header_bank_account', '银行账号')
+ _comment_column('invoices', 'email', '电子邮箱(必填)')
+ _comment_column('invoices', 'total_amount', '开票总金额')
+ _comment_column('invoices', 'total_credits', '总积分')
+ _comment_column('invoices', 'status', '状态: processing/success/failed')
+ _comment_column('invoices', 'failure_reason', '失败原因')
+ _comment_column('invoices', 'issued_at', '开票成功时间')
+
+ _comment_table('invoice_orders', '发票-订单关联表')
+ _comment_column('invoice_orders', 'id', '主键')
+ _comment_column('invoice_orders', 'invoice_id', '发票ID')
+ _comment_column('invoice_orders', 'order_id', '订单ID')
+ _comment_column('invoice_orders', 'order_no', '订单号(冗余)')
+ _comment_column('invoice_orders', 'amount', '订单金额(冗余)')
+ _comment_column('invoice_orders', 'credits', '订单积分(冗余)')
+
+
+def downgrade() -> None:
+ op.drop_table('invoice_orders')
+ op.drop_table('invoices')
diff --git a/video-gen-api/app/api/v1/__init__.py b/video-gen-api/app/api/v1/__init__.py
index bd89d179..af1610cc 100644
--- a/video-gen-api/app/api/v1/__init__.py
+++ b/video-gen-api/app/api/v1/__init__.py
@@ -35,6 +35,7 @@ from app.api.v1.material_admin import router as material_admin_router
from app.api.v1.private_portrait import router as private_portrait_router
from app.api.v1.private_portrait_virtual import router as private_portrait_virtual_router
from app.api.v1.upload_resource import router as upload_resource_router
+from app.api.v1.invoices import router as invoices_router
api_router = APIRouter()
api_router.include_router(auth_router)
@@ -72,3 +73,4 @@ api_router.include_router(material_admin_router)
api_router.include_router(private_portrait_router)
api_router.include_router(private_portrait_virtual_router)
api_router.include_router(upload_resource_router)
+api_router.include_router(invoices_router)
diff --git a/video-gen-api/app/api/v1/admin.py b/video-gen-api/app/api/v1/admin.py
index 8c37a1b9..7e3097b5 100644
--- a/video-gen-api/app/api/v1/admin.py
+++ b/video-gen-api/app/api/v1/admin.py
@@ -60,6 +60,7 @@ from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.payment import process_refund
from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage
from app.services.team_service import batch_get_team_name_map, set_frontend_user_team
+from app.schemas.invoice import InvoiceStatusUpdateRequest
from app.utils.id_gen import generate_id
@@ -1973,11 +1974,10 @@ async def get_stats(
)
# ── 每日各模块积分消耗(始终返回选中日期往前7天,便于图表展示)
- # 把 timestamptz 按东八区(业务时区)偏移后再转 DATE,
- # 直接手动 +8 小时再 CAST 成日期,简单稳妥,不依赖数据库时区名配置。
- # 与代码中 CST = timezone(timedelta(hours=8)) 保持一致。
+ # created_at 为 timestamptz,数据库 session 时区已是东八区(CST),
+ # 读取出来的时间值即为北京时间,直接 CAST 成日期即可,无需再 +8 小时。
from sqlalchemy import Date, cast as sa_cast
- _day_expr = sa_cast(CreditRecord.created_at + timedelta(hours=8), Date)
+ _day_expr = sa_cast(CreditRecord.created_at, Date)
# 图表固定展示 [date_end - 6天, date_end] 共7天
_chart_end_dt = date_end
_chart_start_dt = datetime(
@@ -2487,6 +2487,118 @@ async def upload_login_video(
return {"url": url}
-# ── Payment Stats ────────────────────────────────────────
+# ── Invoice Management ───────────────────────────────────
+
+
+@router.get("/invoices")
+async def admin_list_invoices(
+ page: int = Query(1, ge=1),
+ page_size: int = Query(20, ge=1, le=500),
+ status: str | None = Query(None),
+ phone: str | None = Query(None, description="按用户手机号模糊搜索"),
+ start_date: str | None = Query(None),
+ end_date: str | None = Query(None),
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """后台发票列表(分页+筛选)。"""
+ from app.services.invoice import get_admin_invoices
+
+ items, total = await get_admin_invoices(
+ db, page, page_size,
+ status_filter=status,
+ phone=phone,
+ start_date=start_date,
+ end_date=end_date,
+ )
+ return {"items": items, "total": total, "page": page, "page_size": page_size}
+
+
+@router.get("/invoices/{invoice_id}")
+async def admin_get_invoice(
+ invoice_id: str,
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """后台获取发票详情(含关联订单)。"""
+ from app.services.invoice import get_invoice_with_orders
+
+ detail = await get_invoice_with_orders(db, invoice_id)
+ if not detail:
+ raise HTTPException(status_code=404, detail="发票不存在")
+
+ invoice = detail["invoice"]
+ orders = detail["orders"]
+ return {
+ "id": invoice.id,
+ "invoiceNo": invoice.invoice_no,
+ "userId": invoice.user_id,
+ "headerType": invoice.header_type,
+ "headerName": invoice.header_name,
+ "headerTaxNo": invoice.header_tax_no,
+ "headerRegisterAddress": invoice.header_register_address,
+ "headerRegisterPhone": invoice.header_register_phone,
+ "headerBankName": invoice.header_bank_name,
+ "headerBankAccount": invoice.header_bank_account,
+ "email": invoice.email,
+ "totalAmount": round(float(invoice.total_amount), 2),
+ "totalCredits": round(float(invoice.total_credits), 2),
+ "status": invoice.status,
+ "failureReason": invoice.failure_reason,
+ "issuedAt": invoice.issued_at.isoformat() if invoice.issued_at else None,
+ "createdAt": invoice.created_at.isoformat() if invoice.created_at else None,
+ "updatedAt": invoice.updated_at.isoformat() if invoice.updated_at else None,
+ "orders": [
+ {
+ "id": o.id,
+ "orderNo": o.order_no,
+ "amount": round(float(o.amount), 2),
+ "credits": round(float(o.credits), 2),
+ }
+ for o in orders
+ ],
+ }
+
+
+@router.put("/invoices/{invoice_id}/status")
+async def admin_update_invoice_status(
+ invoice_id: str,
+ req: InvoiceStatusUpdateRequest,
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """更新发票状态(success/failed)。"""
+ from app.services.invoice import update_invoice_status
+
+ invoice, old_status = await update_invoice_status(db, invoice_id, req, admin.id)
+ await db.flush()
+
+ await log_operation(
+ db,
+ admin.id,
+ admin.username,
+ f"发票状态变更: {invoice.invoice_no} {old_status} → {req.status}",
+ "PUT",
+ f"/admin/invoices/{invoice_id}/status",
+ detail=json.dumps(
+ {
+ "invoice_id": invoice_id,
+ "invoice_no": invoice.invoice_no,
+ "old_status": old_status,
+ "new_status": req.status,
+ "failure_reason": req.failure_reason,
+ },
+ ensure_ascii=False,
+ ),
+ )
+ await db.commit()
+
+ return {
+ "id": invoice.id,
+ "invoiceNo": invoice.invoice_no,
+ "status": invoice.status,
+ "failureReason": invoice.failure_reason,
+ "issuedAt": invoice.issued_at.isoformat() if invoice.issued_at else None,
+ }
diff --git a/video-gen-api/app/api/v1/invoices.py b/video-gen-api/app/api/v1/invoices.py
new file mode 100644
index 00000000..2516f218
--- /dev/null
+++ b/video-gen-api/app/api/v1/invoices.py
@@ -0,0 +1,110 @@
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.dependencies import get_db, get_current_user
+from app.models.invoice import Invoice, InvoiceOrder
+from app.models.user import User
+from app.schemas.invoice import InvoiceCreateRequest, InvoiceOut, InvoiceOrderOut
+from app.services.invoice import (
+ create_invoice,
+ get_user_invoices,
+ get_invoice_by_id,
+ get_invoice_with_orders,
+)
+
+logger = logging.getLogger("videogen")
+
+router = APIRouter(prefix="/invoices", tags=["invoices"])
+
+
+@router.post("", response_model=InvoiceOut)
+async def create_invoice_endpoint(
+ req: InvoiceCreateRequest,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """创建发票申请。"""
+ invoice = await create_invoice(db, current_user.id, req)
+ await db.commit()
+
+ # 重新查询以获取关联订单
+ detail = await get_invoice_with_orders(db, invoice.id)
+ return _invoice_to_out(detail["invoice"], detail["orders"])
+
+
+@router.get("")
+async def list_invoices(
+ page: int = Query(1, ge=1),
+ page_size: int = Query(20, ge=1, le=100),
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """获取当前用户的发票列表(分页)。"""
+ invoices, total = await get_user_invoices(db, current_user.id, page, page_size)
+
+ # 加载每个发票的关联订单
+ items = []
+ for inv in invoices:
+ result = await db.execute(
+ select(InvoiceOrder).where(InvoiceOrder.invoice_id == inv.id)
+ )
+ orders = result.scalars().all()
+ items.append(_invoice_to_out(inv, list(orders)))
+
+ return {"items": items, "total": total, "page": page, "page_size": page_size}
+
+
+@router.get("/{invoice_id}")
+async def get_invoice(
+ invoice_id: str,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """获取发票详情(含关联订单)。"""
+ detail = await get_invoice_with_orders(db, invoice_id)
+ if not detail:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="发票不存在")
+
+ invoice = detail["invoice"]
+ if invoice.user_id != current_user.id:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权查看该发票")
+
+ return _invoice_to_out(invoice, detail["orders"])
+
+
+def _invoice_to_out(invoice: Invoice, orders: list[InvoiceOrder]) -> dict:
+ """将 Invoice ORM 对象转换为响应 dict。"""
+ return {
+ "id": invoice.id,
+ "userId": invoice.user_id,
+ "invoiceNo": invoice.invoice_no,
+ "headerType": invoice.header_type,
+ "headerName": invoice.header_name,
+ "headerTaxNo": invoice.header_tax_no,
+ "headerRegisterAddress": invoice.header_register_address,
+ "headerRegisterPhone": invoice.header_register_phone,
+ "headerBankName": invoice.header_bank_name,
+ "headerBankAccount": invoice.header_bank_account,
+ "email": invoice.email,
+ "totalAmount": round(float(invoice.total_amount), 2),
+ "totalCredits": round(float(invoice.total_credits), 2),
+ "status": invoice.status,
+ "failureReason": invoice.failure_reason,
+ "issuedAt": invoice.issued_at.isoformat() if invoice.issued_at else None,
+ "createdAt": invoice.created_at.isoformat() if invoice.created_at else None,
+ "updatedAt": invoice.updated_at.isoformat() if invoice.updated_at else None,
+ "orders": [
+ {
+ "id": o.id,
+ "invoiceId": o.invoice_id,
+ "orderId": o.order_id,
+ "orderNo": o.order_no,
+ "amount": round(float(o.amount), 2),
+ "credits": round(float(o.credits), 2),
+ }
+ for o in orders
+ ],
+ }
diff --git a/video-gen-api/app/models/__init__.py b/video-gen-api/app/models/__init__.py
index 71fd765d..0f1fbc1b 100644
--- a/video-gen-api/app/models/__init__.py
+++ b/video-gen-api/app/models/__init__.py
@@ -36,6 +36,7 @@ from app.models.user_oauth_account import UserOAuthAccount
from app.models.user_oauth_app import UserOAuthApp
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
from app.models.contact_request import ContactRequest
+from app.models.invoice import Invoice, InvoiceOrder
from app.models.private_portrait import PrivatePortraitProject, PrivatePortraitValidateSession, PrivatePortraitAssetGroup, PrivatePortraitAsset
from app.models.api import ApiKey, ApiGenerationTask, ApiUsageLog, ApiKeyUpscaleConfig, ApiUpscaleLink
@@ -57,4 +58,5 @@ __all__ = [
"PrivatePortraitAssetGroup", "PrivatePortraitAsset",
"ApiKey", "ApiGenerationTask", "ApiUsageLog", "ApiKeyUpscaleConfig", "ApiUpscaleLink",
"ApiModelPricing",
+ "Invoice", "InvoiceOrder",
]
diff --git a/video-gen-api/app/models/invoice.py b/video-gen-api/app/models/invoice.py
new file mode 100644
index 00000000..fbd68b66
--- /dev/null
+++ b/video-gen-api/app/models/invoice.py
@@ -0,0 +1,57 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, Float, ForeignKey, String, Text, UniqueConstraint, Index
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.models.base import Base, TimestampMixin
+
+
+class Invoice(Base, TimestampMixin):
+ __tablename__ = "invoices"
+
+ 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
+ )
+ invoice_no: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
+ header_type: Mapped[str] = mapped_column(String(16), nullable=False)
+ header_name: Mapped[str] = mapped_column(String(128), nullable=False)
+ header_tax_no: Mapped[str | None] = mapped_column(String(32), nullable=True)
+ header_register_address: Mapped[str | None] = mapped_column(String(256), nullable=True)
+ header_register_phone: Mapped[str | None] = mapped_column(String(32), nullable=True)
+ header_bank_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
+ header_bank_account: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ email: Mapped[str] = mapped_column(String(128), nullable=False)
+ total_amount: Mapped[float] = mapped_column(Float, nullable=False, default=0)
+ total_credits: Mapped[float] = mapped_column(Float, nullable=False, default=0)
+ status: Mapped[str] = mapped_column(String(16), nullable=False, default="processing")
+ failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
+ issued_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True
+ )
+
+ __table_args__ = (
+ Index('idx_invoices_user_created', 'user_id', 'created_at'),
+ Index('idx_invoices_status_created', 'status', 'created_at'),
+ )
+
+
+class InvoiceOrder(Base, TimestampMixin):
+ __tablename__ = "invoice_orders"
+
+ id: Mapped[str] = mapped_column(String(32), primary_key=True)
+ invoice_id: Mapped[str] = mapped_column(
+ String(32), ForeignKey("invoices.id", ondelete="CASCADE"), index=True
+ )
+ order_id: Mapped[str] = mapped_column(
+ String(32), ForeignKey("payment_orders.id", ondelete="CASCADE"), index=True
+ )
+ order_no: Mapped[str] = mapped_column(String(64), nullable=False)
+ amount: Mapped[float] = mapped_column(Float, nullable=False, default=0)
+ credits: Mapped[float] = mapped_column(Float, nullable=False, default=0)
+
+ __table_args__ = (
+ UniqueConstraint('invoice_id', 'order_id', name='uq_invoice_orders'),
+ Index('idx_invoice_orders_invoice', 'invoice_id'),
+ Index('idx_invoice_orders_order', 'order_id'),
+ )
diff --git a/video-gen-api/app/schemas/invoice.py b/video-gen-api/app/schemas/invoice.py
new file mode 100644
index 00000000..e811e734
--- /dev/null
+++ b/video-gen-api/app/schemas/invoice.py
@@ -0,0 +1,83 @@
+import re
+from typing import Any
+
+from pydantic import BaseModel, Field, model_validator
+
+from app.schemas.common import NaiveDatetimeOptional
+
+
+_EMAIL_REGEX = re.compile(r"^[\w.\-]+@[\w.\-]+\.\w+$")
+
+
+class InvoiceCreateRequest(BaseModel):
+ """创建发票请求。"""
+ header_type: str = Field(..., pattern="^(personal|company)$", description="抬头类型")
+ header_name: str = Field(..., min_length=1, max_length=128, description="抬头名称")
+ header_tax_no: str | None = Field(None, max_length=32, description="税号")
+ header_register_address: str | None = Field(None, max_length=256, description="注册地址")
+ header_register_phone: str | None = Field(None, max_length=32, description="注册电话")
+ header_bank_name: str | None = Field(None, max_length=128, description="开户行")
+ header_bank_account: str | None = Field(None, max_length=64, description="银行账号")
+ email: str = Field(..., max_length=128, description="电子邮箱(必填)")
+ order_ids: list[str] = Field(..., min_length=1, description="订单ID列表")
+
+ @model_validator(mode="after")
+ def validate_email(self) -> "InvoiceCreateRequest":
+ if not _EMAIL_REGEX.match(self.email):
+ raise ValueError("邮箱格式不正确")
+ return self
+
+ @model_validator(mode="after")
+ def validate_company_fields(self) -> "InvoiceCreateRequest":
+ if self.header_type == "company" and not self.header_tax_no:
+ raise ValueError("企业抬头必须填写税号")
+ return self
+
+
+class InvoiceStatusUpdateRequest(BaseModel):
+ """更新发票状态请求。"""
+ status: str = Field(..., pattern="^(success|failed)$", description="目标状态")
+ failure_reason: str | None = Field(None, max_length=500, description="失败原因")
+
+ @model_validator(mode="after")
+ def validate_failure_reason(self) -> "InvoiceStatusUpdateRequest":
+ if self.status == "failed" and not self.failure_reason:
+ raise ValueError("开具失败时必须填写失败原因")
+ return self
+
+
+class InvoiceOrderOut(BaseModel):
+ """发票关联订单响应。"""
+ model_config = {"from_attributes": True}
+
+ id: str
+ invoice_id: str
+ order_id: str
+ order_no: str
+ amount: float
+ credits: float
+
+
+class InvoiceOut(BaseModel):
+ """发票响应体。"""
+ model_config = {"from_attributes": True}
+
+ id: str
+ user_id: str
+ invoice_no: str
+ header_type: str
+ header_name: str
+ header_tax_no: str | None = None
+ header_register_address: str | None = None
+ header_register_phone: str | None = None
+ header_bank_name: str | None = None
+ header_bank_account: str | None = None
+ email: str
+ total_amount: float
+ total_credits: float
+ status: str
+ failure_reason: str | None = None
+ issued_at: NaiveDatetimeOptional = None
+ created_at: NaiveDatetimeOptional = None
+ updated_at: NaiveDatetimeOptional = None
+ orders: list[InvoiceOrderOut] = []
diff --git a/video-gen-api/app/services/invoice.py b/video-gen-api/app/services/invoice.py
new file mode 100644
index 00000000..8164519c
--- /dev/null
+++ b/video-gen-api/app/services/invoice.py
@@ -0,0 +1,292 @@
+import logging
+import random
+from datetime import datetime, timedelta, timezone
+
+from fastapi import HTTPException, status
+from sqlalchemy import select, func
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.invoice import Invoice, InvoiceOrder
+from app.models.payment_order import PaymentOrder
+from app.schemas.invoice import InvoiceCreateRequest, InvoiceStatusUpdateRequest
+from app.utils.id_gen import generate_id
+
+logger = logging.getLogger("videogen")
+
+CST = timezone(timedelta(hours=8))
+
+
+def _generate_invoice_no() -> str:
+ """生成发票编号:FP + YYYYMMDD + 5位随机数。"""
+ now = datetime.now(CST)
+ date_str = now.strftime("%Y%m%d")
+ random_part = str(random.randint(10000, 99999))
+ return f"FP{date_str}{random_part}"
+
+
+async def check_orders_available(
+ db: AsyncSession,
+ order_ids: list[str],
+ exclude_invoice_id: str | None = None,
+) -> list[dict]:
+ """检查订单是否已被其他 processing/success 发票占用。
+
+ 返回被占用的订单列表,每项包含 order_id、order_no、invoice_no。
+ """
+ stmt = (
+ select(InvoiceOrder.order_id, InvoiceOrder.order_no, Invoice.invoice_no)
+ .join(Invoice, InvoiceOrder.invoice_id == Invoice.id)
+ .where(
+ InvoiceOrder.order_id.in_(order_ids),
+ Invoice.status.in_(["processing", "success"]),
+ )
+ )
+ if exclude_invoice_id:
+ stmt = stmt.where(Invoice.id != exclude_invoice_id)
+
+ result = await db.execute(stmt)
+ rows = result.all()
+
+ return [
+ {"order_id": row.order_id, "order_no": row.order_no, "invoice_no": row.invoice_no}
+ for row in rows
+ ]
+
+
+async def create_invoice(
+ db: AsyncSession,
+ user_id: str,
+ data: InvoiceCreateRequest,
+) -> Invoice:
+ """创建发票。校验订单归属、订单唯一性,创建主表+关联表。"""
+ # 1. 查询订单并校验归属
+ result = await db.execute(
+ select(PaymentOrder).where(PaymentOrder.id.in_(data.order_ids))
+ )
+ orders = result.scalars().all()
+
+ if len(orders) != len(data.order_ids):
+ found_ids = {o.id for o in orders}
+ missing = [oid for oid in data.order_ids if oid not in found_ids]
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"订单不存在: {', '.join(missing)}",
+ )
+
+ for order in orders:
+ if order.user_id != user_id:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"订单 {order.order_no} 不属于当前用户",
+ )
+ if order.status != "paid":
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"订单 {order.order_no} 未支付,无法开票",
+ )
+
+ # 2. 检查订单唯一性
+ occupied = await check_orders_available(db, data.order_ids)
+ if occupied:
+ details = "; ".join(
+ f"订单 {o['order_no']} 已被发票 {o['invoice_no']} 占用"
+ for o in occupied
+ )
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=details,
+ )
+
+ # 3. 创建发票
+ total_amount = sum(float(o.amount) for o in orders)
+ total_credits = sum(float(o.credits) for o in orders)
+
+ invoice = Invoice(
+ id=generate_id(),
+ user_id=user_id,
+ invoice_no=_generate_invoice_no(),
+ header_type=data.header_type,
+ header_name=data.header_name,
+ header_tax_no=data.header_tax_no,
+ header_register_address=data.header_register_address,
+ header_register_phone=data.header_register_phone,
+ header_bank_name=data.header_bank_name,
+ header_bank_account=data.header_bank_account,
+ email=data.email,
+ total_amount=round(total_amount, 2),
+ total_credits=round(total_credits, 2),
+ status="processing",
+ )
+ db.add(invoice)
+ await db.flush()
+
+ # 4. 创建关联表
+ for order in orders:
+ io = InvoiceOrder(
+ id=generate_id(),
+ invoice_id=invoice.id,
+ order_id=order.id,
+ order_no=order.order_no,
+ amount=round(float(order.amount), 2),
+ credits=round(float(order.credits), 2),
+ )
+ db.add(io)
+
+ await db.flush()
+ return invoice
+
+
+async def get_user_invoices(
+ db: AsyncSession,
+ user_id: str,
+ page: int = 1,
+ page_size: int = 20,
+) -> tuple[list[Invoice], int]:
+ """获取用户发票列表。"""
+ count_query = select(func.count(Invoice.id)).where(Invoice.user_id == user_id)
+ total = (await db.execute(count_query)).scalar() or 0
+
+ result = await db.execute(
+ select(Invoice)
+ .where(Invoice.user_id == user_id)
+ .order_by(Invoice.created_at.desc())
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ )
+ invoices = result.scalars().all()
+ return list(invoices), total
+
+
+async def get_invoice_by_id(
+ db: AsyncSession,
+ invoice_id: str,
+) -> Invoice | None:
+ """获取发票详情。"""
+ result = await db.execute(
+ select(Invoice).where(Invoice.id == invoice_id).limit(1)
+ )
+ return result.scalar_one_or_none()
+
+
+async def get_invoice_with_orders(
+ db: AsyncSession,
+ invoice_id: str,
+) -> dict | None:
+ """获取发票+关联订单详情。"""
+ invoice = await get_invoice_by_id(db, invoice_id)
+ if not invoice:
+ return None
+
+ result = await db.execute(
+ select(InvoiceOrder)
+ .where(InvoiceOrder.invoice_id == invoice_id)
+ .order_by(InvoiceOrder.created_at.asc())
+ )
+ orders = result.scalars().all()
+
+ return {
+ "invoice": invoice,
+ "orders": list(orders),
+ }
+
+
+async def update_invoice_status(
+ db: AsyncSession,
+ invoice_id: str,
+ data: InvoiceStatusUpdateRequest,
+ admin_id: str,
+) -> Invoice:
+ """更新发票状态,记录审计日志。"""
+ invoice = await get_invoice_by_id(db, invoice_id)
+ if not invoice:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="发票不存在",
+ )
+
+ # 终态校验
+ if invoice.status in ("success", "failed"):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"发票已终结({invoice.status}),无法变更",
+ )
+
+ old_status = invoice.status
+ invoice.status = data.status
+
+ if data.status == "success":
+ invoice.issued_at = datetime.now(CST)
+ invoice.failure_reason = None
+ elif data.status == "failed":
+ invoice.failure_reason = data.failure_reason
+ invoice.issued_at = None
+
+ await db.flush()
+ return invoice, old_status
+
+
+async def get_admin_invoices(
+ db: AsyncSession,
+ page: int = 1,
+ page_size: int = 20,
+ status_filter: str | None = None,
+ phone: str | None = None,
+ start_date: str | None = None,
+ end_date: str | None = None,
+) -> tuple[list[dict], int]:
+ """后台获取发票列表(含用户信息)。"""
+ from app.models.user import User
+
+ query = select(Invoice, User.username, User.phone).join(User, Invoice.user_id == User.id)
+ count_query = select(func.count(Invoice.id))
+
+ filters = []
+ if status_filter:
+ filters.append(Invoice.status == status_filter)
+ if phone:
+ filters.append(User.phone.ilike(f"%{phone.strip()}%"))
+ if start_date:
+ filters.append(Invoice.created_at >= datetime.fromisoformat(start_date).replace(tzinfo=CST))
+ if end_date:
+ filters.append(
+ Invoice.created_at < (datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST)
+ )
+
+ for f in filters:
+ query = query.where(f)
+ count_query = count_query.where(f)
+
+ total = (await db.execute(count_query)).scalar() or 0
+
+ result = await db.execute(
+ query.order_by(Invoice.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
+ )
+ rows = result.all()
+
+ items = []
+ for invoice, username, user_phone in rows:
+ # 获取关联订单数
+ order_count_result = await db.execute(
+ select(func.count(InvoiceOrder.id)).where(InvoiceOrder.invoice_id == invoice.id)
+ )
+ order_count = order_count_result.scalar() or 0
+
+ items.append({
+ "id": invoice.id,
+ "invoiceNo": invoice.invoice_no,
+ "userId": invoice.user_id,
+ "username": username,
+ "phone": user_phone,
+ "headerType": invoice.header_type,
+ "headerName": invoice.header_name,
+ "email": invoice.email,
+ "totalAmount": round(float(invoice.total_amount), 2),
+ "totalCredits": round(float(invoice.total_credits), 2),
+ "orderCount": order_count,
+ "status": invoice.status,
+ "failureReason": invoice.failure_reason,
+ "issuedAt": invoice.issued_at.isoformat() if invoice.issued_at else None,
+ "createdAt": invoice.created_at.isoformat() if invoice.created_at else None,
+ })
+
+ return items, total
diff --git a/video-gen-app/src/api/index.ts b/video-gen-app/src/api/index.ts
index 0c0df5ad..3c2db39e 100644
--- a/video-gen-app/src/api/index.ts
+++ b/video-gen-app/src/api/index.ts
@@ -460,6 +460,43 @@ export async function cancelPaymentOrder(orderNo: string): Promise {
return api.post(`/payments/orders/${orderNo}/cancel`);
}
+// ── Invoices ──────────────────────────────────────────────
+
+export async function createInvoice(data: {
+ headerType: 'personal' | 'company';
+ headerName: string;
+ headerTaxNo?: string;
+ headerRegisterAddress?: string;
+ headerRegisterPhone?: string;
+ headerBankName?: string;
+ headerBankAccount?: string;
+ email: string;
+ orderIds: string[];
+}): Promise {
+ return api.post('/invoices', {
+ header_type: data.headerType,
+ header_name: data.headerName,
+ header_tax_no: data.headerTaxNo,
+ header_register_address: data.headerRegisterAddress,
+ header_register_phone: data.headerRegisterPhone,
+ header_bank_name: data.headerBankName,
+ header_bank_account: data.headerBankAccount,
+ email: data.email,
+ order_ids: data.orderIds,
+ });
+}
+
+export async function getInvoices(params?: { page?: number; pageSize?: number }): Promise<{ items: any[]; total: number }> {
+ const qs = new URLSearchParams();
+ if (params?.page) qs.set('page', String(params.page));
+ if (params?.pageSize) qs.set('page_size', String(params.pageSize));
+ return api.get(`/invoices?${qs.toString()}`);
+}
+
+export async function getInvoiceDetail(id: string): Promise {
+ return api.get(`/invoices/${id}`);
+}
+
export async function getCreditRatios(): Promise {
return api.get('/credits/ratios');
}
diff --git a/video-gen-app/src/pages/InvoicePage.tsx b/video-gen-app/src/pages/InvoicePage.tsx
index 0493fe37..5c0b18e6 100644
--- a/video-gen-app/src/pages/InvoicePage.tsx
+++ b/video-gen-app/src/pages/InvoicePage.tsx
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { Table, Tag, Empty, Spin, Pagination, Button, Typography, Modal, Form, Input, Radio, message, Space } from 'antd';
import { FileTextOutlined, PlusOutlined, CloseOutlined, AlipayCircleOutlined, WechatOutlined } from '@ant-design/icons';
-import { getPaymentOrders } from '../api';
+import { getPaymentOrders, createInvoice, getInvoices, getInvoiceDetail } from '../api';
interface InvoiceRecord {
id: string;
@@ -37,6 +37,7 @@ const mockHeaders: InvoiceHeader[] = [
const InvoicePage: React.FC = () => {
const [records, setRecords] = useState([]);
+ const [recordsLoading, setRecordsLoading] = useState(false);
const [headers, setHeaders] = useState([]);
const [headerModalOpen, setHeaderModalOpen] = useState(false);
const [addModalOpen, setAddModalOpen] = useState(false);
@@ -47,6 +48,8 @@ const InvoicePage: React.FC = () => {
const [selectedOrdersMap, setSelectedOrdersMap] = useState