This commit is contained in:
2026-08-10 16:29:26 +08:00
parent e364c46ce4
commit d1e8eb7316
18 changed files with 1335 additions and 819 deletions
@@ -0,0 +1,65 @@
"""发票抬头表迁移
创建 invoice_headers(发票抬头表),用于用户管理常用发票抬头。
Revision ID: 20260811_20260811
Revises: 20260810_20260810
Create Date: 2026-08-11 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '20260811_20260811'
down_revision: Union[str, None] = '20260810_20260810'
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:
op.create_table(
'invoice_headers',
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('type', sa.String(16), nullable=False),
sa.Column('name', sa.String(128), nullable=False),
sa.Column('tax_no', sa.String(32), nullable=True),
sa.Column('register_address', sa.String(256), nullable=True),
sa.Column('register_phone', sa.String(32), nullable=True),
sa.Column('bank_name', sa.String(128), nullable=True),
sa.Column('bank_account', sa.String(64), nullable=True),
sa.Column('email', sa.String(128), nullable=True),
sa.Column('is_default', sa.Boolean, nullable=False, server_default='false'),
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_headers_user', 'invoice_headers', ['user_id'])
# 表注释和字段注释
_comment_table('invoice_headers', '发票抬头表')
_comment_column('invoice_headers', 'id', '主键')
_comment_column('invoice_headers', 'user_id', '用户ID')
_comment_column('invoice_headers', 'type', '抬头类型: personal/company')
_comment_column('invoice_headers', 'name', '抬头名称')
_comment_column('invoice_headers', 'tax_no', '税号')
_comment_column('invoice_headers', 'register_address', '注册地址')
_comment_column('invoice_headers', 'register_phone', '注册电话')
_comment_column('invoice_headers', 'bank_name', '开户行')
_comment_column('invoice_headers', 'bank_account', '银行账号')
_comment_column('invoice_headers', 'email', '接收邮箱')
_comment_column('invoice_headers', 'is_default', '是否默认')
def downgrade() -> None:
op.drop_table('invoice_headers')
+2
View File
@@ -36,6 +36,7 @@ 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
from app.api.v1.invoice_headers import router as invoice_headers_router
api_router = APIRouter()
api_router.include_router(auth_router)
@@ -74,3 +75,4 @@ 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)
api_router.include_router(invoice_headers_router)
+10
View File
@@ -2210,6 +2210,8 @@ async def admin_list_generation_records(
status: str | None = Query(None),
engine_id: str | None = Query(None),
include_media_references: bool | None = Query(None),
start_date: str | None = Query(None, description="创建时间起始,格式 YYYY-MM-DD"),
end_date: str | None = Query(None, description="创建时间结束,格式 YYYY-MM-DD"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500),
admin: User = Depends(get_admin_user),
@@ -2232,6 +2234,10 @@ async def admin_list_generation_records(
query = query.where(GenerationRecord.engine_id == engine_id)
if include_media_references is not None:
query = query.where(GenerationRecord.include_media_references.is_(include_media_references))
if start_date:
query = query.where(GenerationRecord.created_at >= datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=CST))
if end_date:
query = query.where(GenerationRecord.created_at < (datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)).replace(tzinfo=CST))
# Count total
count_query = (
@@ -2247,6 +2253,10 @@ async def admin_list_generation_records(
count_query = count_query.where(GenerationRecord.engine_id == engine_id)
if include_media_references is not None:
count_query = count_query.where(GenerationRecord.include_media_references.is_(include_media_references))
if start_date:
count_query = count_query.where(GenerationRecord.created_at >= datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=CST))
if end_date:
count_query = count_query.where(GenerationRecord.created_at < (datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)).replace(tzinfo=CST))
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
@@ -0,0 +1,96 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.schemas.invoice import InvoiceHeaderCreate, InvoiceHeaderOut, InvoiceHeaderUpdate
from app.services.invoice_header import (
create_header,
delete_header,
get_user_headers,
set_default_header,
update_header,
)
logger = logging.getLogger("videogen")
router = APIRouter(prefix="/invoice-headers", tags=["invoice-headers"])
def _header_to_out(header) -> dict:
return {
"id": header.id,
"userId": header.user_id,
"type": header.type,
"name": header.name,
"taxNo": header.tax_no,
"registerAddress": header.register_address,
"registerPhone": header.register_phone,
"bankName": header.bank_name,
"bankAccount": header.bank_account,
"email": header.email,
"isDefault": header.is_default,
"createdAt": header.created_at.isoformat() if header.created_at else None,
"updatedAt": header.updated_at.isoformat() if header.updated_at else None,
}
@router.post("", response_model=InvoiceHeaderOut)
async def create_invoice_header(
req: InvoiceHeaderCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""创建发票抬头。"""
header = await create_header(db, current_user.id, req)
await db.commit()
return _header_to_out(header)
@router.get("")
async def list_invoice_headers(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取当前用户的所有发票抬头。"""
headers = await get_user_headers(db, current_user.id)
return {"items": [_header_to_out(h) for h in headers]}
@router.put("/{header_id}", response_model=InvoiceHeaderOut)
async def update_invoice_header(
header_id: str,
req: InvoiceHeaderUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""更新发票抬头。"""
header = await update_header(db, header_id, current_user.id, req)
await db.commit()
return _header_to_out(header)
@router.delete("/{header_id}")
async def delete_invoice_header(
header_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""删除发票抬头。"""
await delete_header(db, header_id, current_user.id)
await db.commit()
return {"success": True}
@router.put("/{header_id}/set-default", response_model=InvoiceHeaderOut)
async def set_default_invoice_header(
header_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""设置默认发票抬头。"""
header = await set_default_header(db, header_id, current_user.id)
await db.commit()
return _header_to_out(header)
+2 -1
View File
@@ -37,6 +37,7 @@ 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.invoice_header import InvoiceHeader
from app.models.private_portrait import PrivatePortraitProject, PrivatePortraitValidateSession, PrivatePortraitAssetGroup, PrivatePortraitAsset
from app.models.api import ApiKey, ApiGenerationTask, ApiUsageLog, ApiKeyUpscaleConfig, ApiUpscaleLink
@@ -58,5 +59,5 @@ __all__ = [
"PrivatePortraitAssetGroup", "PrivatePortraitAsset",
"ApiKey", "ApiGenerationTask", "ApiUsageLog", "ApiKeyUpscaleConfig", "ApiUpscaleLink",
"ApiModelPricing",
"Invoice", "InvoiceOrder",
"Invoice", "InvoiceOrder", "InvoiceHeader",
]
@@ -0,0 +1,35 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Index
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class InvoiceHeader(Base):
"""发票抬头表"""
__tablename__ = "invoice_headers"
id: Mapped[str] = mapped_column(String(32), primary_key=True, comment="主键")
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="用户ID"
)
type: Mapped[str] = mapped_column(String(16), nullable=False, comment="抬头类型: personal/company")
name: Mapped[str] = mapped_column(String(128), nullable=False, comment="抬头名称")
tax_no: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="税号")
register_address: Mapped[str | None] = mapped_column(String(256), nullable=True, comment="注册地址")
register_phone: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="注册电话")
bank_name: Mapped[str | None] = mapped_column(String(128), nullable=True, comment="开户行")
bank_account: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="银行账号")
email: Mapped[str | None] = mapped_column(String(128), nullable=True, comment="接收邮箱")
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, comment="是否默认")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, comment="创建时间"
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, comment="更新时间"
)
__table_args__ = (
Index('idx_invoice_headers_user', 'user_id'),
)
+52
View File
@@ -81,3 +81,55 @@ class InvoiceOut(BaseModel):
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
orders: list[InvoiceOrderOut] = []
# ── 发票抬头 ──────────────────────────────────────────────
class InvoiceHeaderCreate(BaseModel):
"""创建发票抬头请求。"""
type: str = Field(..., pattern="^(personal|company)$", description="抬头类型")
name: str = Field(..., min_length=1, max_length=128, description="抬头名称")
tax_no: str | None = Field(None, max_length=32, description="税号")
register_address: str | None = Field(None, max_length=256, description="注册地址")
register_phone: str | None = Field(None, max_length=32, description="注册电话")
bank_name: str | None = Field(None, max_length=128, description="开户行")
bank_account: str | None = Field(None, max_length=64, description="银行账号")
email: str | None = Field(None, max_length=128, description="接收邮箱")
is_default: bool = Field(False, description="是否设为默认")
@model_validator(mode="after")
def validate_company_fields(self) -> "InvoiceHeaderCreate":
if self.type == "company" and not self.tax_no:
raise ValueError("企业抬头必须填写税号")
return self
class InvoiceHeaderUpdate(BaseModel):
"""更新发票抬头请求。"""
name: str | None = Field(None, min_length=1, max_length=128, description="抬头名称")
tax_no: str | None = Field(None, max_length=32, description="税号")
register_address: str | None = Field(None, max_length=256, description="注册地址")
register_phone: str | None = Field(None, max_length=32, description="注册电话")
bank_name: str | None = Field(None, max_length=128, description="开户行")
bank_account: str | None = Field(None, max_length=64, description="银行账号")
email: str | None = Field(None, max_length=128, description="接收邮箱")
is_default: bool | None = Field(None, description="是否设为默认")
class InvoiceHeaderOut(BaseModel):
"""发票抬头响应体。"""
model_config = {"from_attributes": True}
id: str
user_id: str
type: str
name: str
tax_no: str | None = None
register_address: str | None = None
register_phone: str | None = None
bank_name: str | None = None
bank_account: str | None = None
email: str | None = None
is_default: bool = False
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
@@ -0,0 +1,128 @@
import logging
from datetime import datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.invoice_header import InvoiceHeader
from app.schemas.invoice import InvoiceHeaderCreate, InvoiceHeaderUpdate
from app.utils.id import generate_id
logger = logging.getLogger("videogen")
async def create_header(db: AsyncSession, user_id: str, data: InvoiceHeaderCreate) -> InvoiceHeader:
"""创建发票抬头。"""
now = datetime.now(timezone.utc)
header = InvoiceHeader(
id=generate_id(),
user_id=user_id,
type=data.type,
name=data.name,
tax_no=data.tax_no,
register_address=data.register_address,
register_phone=data.register_phone,
bank_name=data.bank_name,
bank_account=data.bank_account,
email=data.email,
is_default=data.is_default,
created_at=now,
updated_at=now,
)
# 如果设为默认,先将其他抬头取消默认
if data.is_default:
await db.execute(
update(InvoiceHeader)
.where(InvoiceHeader.user_id == user_id)
.values(is_default=False, updated_at=now)
)
db.add(header)
await db.flush()
return header
async def get_user_headers(db: AsyncSession, user_id: str) -> list[InvoiceHeader]:
"""获取用户的所有发票抬头。"""
result = await db.execute(
select(InvoiceHeader)
.where(InvoiceHeader.user_id == user_id)
.order_by(InvoiceHeader.is_default.desc(), InvoiceHeader.created_at.desc())
)
return list(result.scalars().all())
async def get_header_by_id(db: AsyncSession, header_id: str, user_id: str) -> InvoiceHeader | None:
"""获取指定发票抬头(仅限本人)。"""
result = await db.execute(
select(InvoiceHeader).where(
InvoiceHeader.id == header_id,
InvoiceHeader.user_id == user_id,
)
)
return result.scalar_one_or_none()
async def update_header(
db: AsyncSession, header_id: str, user_id: str, data: InvoiceHeaderUpdate
) -> InvoiceHeader:
"""更新发票抬头。"""
header = await get_header_by_id(db, header_id, user_id)
if not header:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="发票抬头不存在")
update_data = {}
for field, value in data.model_dump(exclude_unset=True).items():
update_data[field] = value
if update_data:
update_data["updated_at"] = datetime.now(timezone.utc)
await db.execute(
update(InvoiceHeader)
.where(InvoiceHeader.id == header_id)
.values(**update_data)
)
# 如果设为默认,先将其他抬头取消默认
if data.is_default:
now = datetime.now(timezone.utc)
await db.execute(
update(InvoiceHeader)
.where(InvoiceHeader.user_id == user_id, InvoiceHeader.id != header_id)
.values(is_default=False, updated_at=now)
)
await db.refresh(header)
return header
async def delete_header(db: AsyncSession, header_id: str, user_id: str) -> None:
"""删除发票抬头。"""
header = await get_header_by_id(db, header_id, user_id)
if not header:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="发票抬头不存在")
await db.delete(header)
await db.flush()
async def set_default_header(db: AsyncSession, header_id: str, user_id: str) -> InvoiceHeader:
"""设置默认发票抬头。"""
header = await get_header_by_id(db, header_id, user_id)
if not header:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="发票抬头不存在")
now = datetime.now(timezone.utc)
# 先取消其他默认
await db.execute(
update(InvoiceHeader)
.where(InvoiceHeader.user_id == user_id, InvoiceHeader.id != header_id)
.values(is_default=False, updated_at=now)
)
# 设置当前为默认
header.is_default = True
header.updated_at = now
await db.flush()
return header