feat(发票): 支持多附件上传和邮件发送功能

refactor(用户管理): 优化用户列表查询和备注字段处理

feat(估值): 评估报告和证书URL改为数组类型并添加下载地址

docs: 添加交易管理与用户备注功能增强实施计划

fix(邮件): 修复邮件发送接口的多附件支持问题

style: 清理注释代码和格式化文件
This commit is contained in:
邹方成 2025-11-25 20:09:50 +08:00
parent 01cdcec0b4
commit 552c02516a
26 changed files with 441 additions and 89 deletions

View File

@ -0,0 +1,133 @@
## 目标概述
* 加强“交易管理-发票附件与发送邮箱”能力,完善数据记录与事务保障。
* 改进“开票弹窗附件上传”支持多文件上传(后端存储结构)。
* 优化“用户管理备注与操作记录”,区分备注维度并完善日志。
* 覆盖单元/集成测试、数据库迁移、API文档与审计日志。
## 数据库与模型变更
* EmailSendLog
* 新增:`extra: JSONField(null=True)` 完整记录发送邮箱的请求数据(收件人、主题、正文、附件列表、重试信息等)。
* Invoice或与开票弹窗相关的业务模型
* 新增:`attachments: JSONField(null=True)` 支持多个附件URL与弹窗上传对应
<br />
* <br />
* 迁移脚本:创建/修改上述字段;保留历史数据不丢失。
## 事务与原子性
* 发送邮箱流程(交易管理)
* 封装在 `tortoise.transactions.in_transaction()`邮件发送、EmailSendLog写入含attachments/extra原子提交失败回滚。
* extra写入内容完整`SendEmailRequest`收件人、主题、正文、附件URL/文件名、重试次数、客户端UA等
* 多文件上传至发票附件(开票弹窗)
* 更新发票的 `attachments` 字段在同一事务内写入如任一URL校验失败则回滚。
## 后端接口改造
* 上传组件(后端)
* 新增:`POST /api/v1/upload/files` 接收 `files: List[UploadFile]`,统一返回 `urls: string[]`;保留现有单文件接口向后兼容。
* 交易管理(管理员)
* `POST /api/v1/transactions/send-email` 扩展:
* 入参支持 `file_urls: string[]`(与/或单文件),服务端聚合附件;
* 在事务中记录 EmailSendLog含attachments与extra返回log\_id与状态
* 回显接口(详情/列表)新增 `extra` 字段完整展示发送记录。
* 开票弹窗附件(管理员/或对应端)
* 新增/改造:`PUT /api/v1/invoice/{id}/attachments` 入参 `urls: string[]`,更新发票 `attachments`
* 列表/详情回显 `attachments: string[]`
* 用户管理备注优化(管理员端)
* 新接口:`PUT /api/v1/app-user-admin/{user_id}/notes`
* 入参:`system_notes?: string``user_notes?: string`(可选择性更新)
* 逻辑:仅更新提供的字段;不影响其他字段。
* 修复:`POST /api/v1/app-user-admin/quota` 仅调整次数,不再自动写入 `user.notes`
* 操作日志:在调整配额、更新备注、停用启用等操作时写入 `AppUserOperationLog`
## 前端改造(要点)
* 多文件上传组件
* 改为多选/拖拽支持;对每个文件显示上传进度与失败重试;
* 成功后收集URL数组写入发票 `attachments` 或作为邮件附件来源;
* 兼容旧接口若后端仅返回单URL前端仍正常显示降级为单文件模式
* 开票弹窗
* 支持附件列表预览与移除;提交时调用 `PUT /invoice/{id}/attachments`
* 邮件发送弹窗
* 选择附件来源(已上传的附件/本地文件再上传);提交后在详情页面完整回显 `extra`(含附件清单与正文等)。
## 审计与日志
* 关键操作:邮件发送、发票附件更新、用户备注更新、配额调整
* 统一调用审计记录(路由中间件已存在,补充结构化日志:`logger.info()` + DB审计表/操作日志表写入)。
## 测试方案
* 单元测试
* Email发送控制器事务成功/失败回滚(模拟抛错)
* 多文件上传文件类型校验、URL数组返回、尾随空白处理
* 备注更新:选择性字段更新、不影响其他字段
* 集成测试FastAPI + httpx.AsyncClient
* 发送邮箱请求→持久化校验attachments/extra→回显接口校验
* 附件上传:批量上传、更新发票、列表/详情回显
* 用户备注接口调用→DB值校验→操作日志存在
## 迁移与兼容
* 使用现有迁移工具如Aerich生成并应用迁移新增JSON/Text字段
* 前端保留旧接口兼容若上传仍走单文件后端返回数组长度1
* API文档OpenAPI
* 补充/更新以上端点的schema说明、示例请求/响应、错误码约定;
## 实施步骤与交付
1. 数据模型与迁移脚本编写与应用EmailSendLog、Invoice、AppUser/OperationLog
2. 后端接口改造与事务封装(邮件发送、多文件上传、发票附件更新、备注接口)。
3. 前端组件与弹窗改造多文件上传、进度与错误处理、回显extra
4. 审计日志与结构化日志补充。
5. 单元与集成测试编写,覆盖核心路径。
6. 更新接口文档与部署回归验证。

View File

@ -2,10 +2,11 @@ from fastapi import APIRouter, Query, Depends
from typing import Optional
from app.schemas.base import Success, SuccessExtra, BasicResponse, PageResponse
from app.schemas.invoice import InvoiceOut, InvoiceHeaderOut, InvoiceHeaderCreate, InvoiceHeaderUpdate
from app.schemas.invoice import InvoiceOut, InvoiceHeaderOut, InvoiceHeaderCreate, InvoiceHeaderUpdate, PaymentReceiptCreate, AppCreateInvoiceWithReceipt, InvoiceCreate
from app.controllers.invoice import invoice_controller
from app.utils.app_user_jwt import get_current_app_user
from app.models.user import AppUser
from app.models.invoice import InvoiceHeader
app_invoices_router = APIRouter(tags=["app-发票管理"])
@ -75,3 +76,56 @@ async def delete_my_header(id: int, current_user: AppUser = Depends(get_current_
return Success(data={"deleted": False}, msg="未找到")
ok = await invoice_controller.delete_header(id)
return Success(data={"deleted": ok}, msg="删除成功" if ok else "未找到")
@app_invoices_router.post("/receipts/{id}", summary="上传我的付款凭证", response_model=BasicResponse[dict])
async def upload_my_receipt(id: int, data: PaymentReceiptCreate, current_user: AppUser = Depends(get_current_app_user)):
inv = await invoice_controller.model.filter(id=id, app_user_id=current_user.id).first()
if not inv:
return Success(data={}, msg="未找到")
receipt = await invoice_controller.create_receipt(id, data)
detail = await invoice_controller.get_receipt_by_id(receipt.id)
return Success(data=detail, msg="上传成功")
@app_invoices_router.post("/create-with-receipt", summary="创建我的发票并上传付款凭证", response_model=BasicResponse[dict])
async def create_with_receipt(payload: AppCreateInvoiceWithReceipt, current_user: AppUser = Depends(get_current_app_user)):
header = await InvoiceHeader.filter(id=payload.header_id, app_user_id=current_user.id).first()
if not header:
return Success(data={}, msg="抬头未找到")
ticket_type = payload.ticket_type or "electronic"
invoice_type = payload.invoice_type
if not invoice_type:
mapping = {"0": "normal", "1": "special"}
invoice_type = mapping.get(str(payload.invoiceTypeIndex)) if payload.invoiceTypeIndex is not None else None
if not invoice_type:
invoice_type = "normal"
inv_data = InvoiceCreate(
ticket_type=ticket_type,
invoice_type=invoice_type,
phone=current_user.phone,
email=header.email,
company_name=header.company_name,
tax_number=header.tax_number,
register_address=header.register_address,
register_phone=header.register_phone,
bank_name=header.bank_name,
bank_account=header.bank_account,
app_user_id=current_user.id,
header_id=header.id,
wechat=getattr(current_user, "alias", None),
)
inv = await invoice_controller.create(inv_data)
receipt = await invoice_controller.create_receipt(inv.id, PaymentReceiptCreate(url=payload.receipt_url, note=payload.note))
detail = await invoice_controller.get_receipt_by_id(receipt.id)
return Success(data=detail, msg="创建并上传成功")
@app_invoices_router.get("/headers/list", summary="我的抬头列表(分页)", response_model=PageResponse[InvoiceHeaderOut])
async def get_my_headers_paged(page: int = Query(1, ge=1), page_size: int = Query(10, ge=1, le=100), current_user: AppUser = Depends(get_current_app_user)):
qs = invoice_controller.model_header.filter(app_user_id=current_user.id) if hasattr(invoice_controller, "model_header") else None
# Fallback when controller没有暴露model_header
from app.models.invoice import InvoiceHeader
qs = InvoiceHeader.filter(app_user_id=current_user.id)
total = await qs.count()
rows = await qs.order_by("-created_at").offset((page - 1) * page_size).limit(page_size)
items = [InvoiceHeaderOut.model_validate(r) for r in rows]
return SuccessExtra(data=[i.model_dump() for i in items], total=total, page=page, page_size=page_size, msg="获取成功")

View File

@ -1,5 +1,6 @@
from fastapi import APIRouter, Query, Depends, HTTPException
from typing import Optional
from typing import Optional, List
from datetime import datetime
from app.schemas.base import Success, SuccessExtra, BasicResponse, PageResponse
from app.schemas.app_user import AppUserQuotaUpdateSchema, AppUserQuotaLogOut, AppUserUpdateSchema
@ -15,18 +16,27 @@ admin_app_users_router = APIRouter(dependencies=[DependAuth, DependPermission],
async def list_app_users(
phone: Optional[str] = Query(None),
wechat: Optional[str] = Query(None),
id: Optional[str] = Query(None),
created_at: Optional[List[int]] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
):
qs = AppUser.filter()
if id is not None and id.strip().isdigit():
qs = qs.filter(id=int(id.strip()))
if phone:
qs = qs.filter(phone__icontains=phone)
if wechat:
qs = qs.filter(alias__icontains=wechat)
if created_at and len(created_at) == 2:
start_dt = datetime.fromtimestamp(created_at[0] / 1000)
end_dt = datetime.fromtimestamp(created_at[1] / 1000)
qs = qs.filter(created_at__gte=start_dt, created_at__lte=end_dt)
total = await qs.count()
rows = await qs.order_by("-created_at").offset((page - 1) * page_size).limit(page_size)
items = []
for u in rows:
last_log = await AppUserQuotaLog.filter(app_user_id=u.id).order_by("-created_at").first()
items.append({
"id": u.id,
"phone": u.phone,
@ -34,7 +44,7 @@ async def list_app_users(
"created_at": u.created_at.isoformat() if u.created_at else "",
"notes": getattr(u, "notes", "") or "",
"remaining_count": int(getattr(u, "remaining_quota", 0) or 0),
"user_type": None,
"user_type": getattr(last_log, "op_type", None),
})
return SuccessExtra(data=items, total=total, page=page, page_size=page_size, msg="获取成功")
@ -94,6 +104,7 @@ async def update_app_user(user_id: int, data: AppUserUpdateSchema):
"company_contact": getattr(user, "company_contact", None),
"company_phone": getattr(user, "company_phone", None),
"company_email": getattr(user, "company_email", None),
"notes": getattr(user, "notes", None),
"is_active": user.is_active,
"created_at": user.created_at.isoformat() if user.created_at else "",
"updated_at": user.updated_at.isoformat() if user.updated_at else "",

View File

@ -10,6 +10,7 @@ from app.schemas.invoice import (
InvoiceHeaderCreate,
InvoiceHeaderUpdate,
PaymentReceiptCreate,
AppCreateInvoiceWithReceipt,
InvoiceOut,
InvoiceList,
InvoiceHeaderOut,
@ -19,6 +20,7 @@ from app.controllers.invoice import invoice_controller
from app.utils.app_user_jwt import get_current_app_user
from app.core.dependency import DependAuth, DependPermission
from app.models.user import AppUser
from app.models.invoice import InvoiceHeader
invoice_router = APIRouter(tags=["发票管理"])
@ -168,36 +170,3 @@ async def delete_invoice_header(id: int):
async def update_invoice_header(id: int, data: InvoiceHeaderUpdate):
header = await invoice_controller.update_header(id, data)
return Success(data=header or {}, msg="更新成功" if header else "未找到")
# 用户端我的发票列表使用App用户token
@invoice_router.get("/app-list", summary="我的发票列表", response_model=PageResponse[InvoiceOut])
async def list_my_invoices(
status: Optional[str] = Query(None),
ticket_type: Optional[str] = Query(None),
invoice_type: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
current_user: AppUser = Depends(get_current_app_user),
):
result = await invoice_controller.list(
page=page,
page_size=page_size,
status=status,
ticket_type=ticket_type,
invoice_type=invoice_type,
app_user_id=current_user.id,
)
return SuccessExtra(
data=result.items,
total=result.total,
page=result.page,
page_size=result.page_size,
msg="获取成功",
)
# 用户端我的发票抬头使用App用户token
@invoice_router.get("/app-headers", summary="我的发票抬头", response_model=BasicResponse[list[InvoiceHeaderOut]])
async def get_my_invoice_headers(current_user: AppUser = Depends(get_current_app_user)):
headers = await invoice_controller.get_headers(user_id=current_user.id)
return Success(data=headers, msg="获取成功")

View File

@ -4,6 +4,8 @@ from typing import Optional
from app.schemas.base import Success, SuccessExtra, PageResponse, BasicResponse
from app.schemas.invoice import PaymentReceiptOut
from app.controllers.invoice import invoice_controller
from app.models.invoice import PaymentReceipt
from fastapi import Body
from app.schemas.transactions import SendEmailRequest, SendEmailResponse
from app.services.email_client import email_client
from app.models.invoice import EmailSendLog
@ -65,47 +67,60 @@ async def get_receipt_detail(id: int):
return Success(data=data or {}, msg="获取成功" if data else "未找到")
@transactions_router.post("/send-email", summary="发送邮件", response_model=BasicResponse[SendEmailResponse])
async def send_email(data: SendEmailRequest, file: Optional[UploadFile] = File(None)):
if not data.email or "@" not in data.email:
raise HTTPException(status_code=422, detail="邮箱格式不正确")
if not data.body:
raise HTTPException(status_code=422, detail="文案内容不能为空")
file_bytes = None
file_name = None
if file is not None:
file_bytes = await file.read()
file_name = file.filename
elif data.file_url:
@transactions_router.post("/send-email", summary="发送邮件", response_model=BasicResponse[SendEmailResponse])
async def send_email(payload: SendEmailRequest = Body(...)):
attachments = []
urls = []
if payload.file_urls:
urls.extend([u.strip().strip('`') for u in payload.file_urls if isinstance(u, str)])
if urls:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(data.file_url)
for u in urls:
r = await client.get(u)
r.raise_for_status()
file_bytes = r.content
file_name = data.file_url.split("/")[-1]
attachments.append((r.content, u.split("/")[-1]))
except Exception as e:
raise HTTPException(status_code=400, detail=f"附件下载失败: {e}")
logger.info("transactions.email_send_start email={} subject={}", data.email, data.subject or "")
result = email_client.send(data.email, data.subject, data.body, file_bytes, file_name, getattr(file, "content_type", None))
logger.info("transactions.email_send_start email={} subject={}", payload.email, payload.subject or "")
try:
result = email_client.send_many(payload.email, payload.subject, payload.body, attachments)
except RuntimeError as e:
result = {"status": "FAIL", "error": str(e)}
except Exception as e:
result = {"status": "FAIL", "error": str(e)}
body_summary = data.body[:500]
body_summary = payload.body[:500]
status = result.get("status")
error = result.get("error")
first_name = attachments[0][1] if attachments else None
first_url = urls[0] if urls else None
log = await EmailSendLog.create(
email=data.email,
subject=data.subject,
email=payload.email,
subject=payload.subject,
body_summary=body_summary,
file_name=file_name,
file_url=data.file_url,
file_name=first_name,
file_url=first_url,
status=status,
error=error,
)
if status == "OK":
logger.info("transactions.email_send_ok email={}", data.email)
logger.info("transactions.email_send_ok email={}", payload.email)
else:
logger.error("transactions.email_send_fail email={} err={}", data.email, error)
logger.error("transactions.email_send_fail email={} err={}", payload.email, error)
if status == "OK" and data.receipt_id:
try:
r = await PaymentReceipt.filter(id=data.receipt_id).first()
if r:
r.extra = (r.extra or {}) | data.model_dump()
await r.save()
except Exception as e:
logger.error("transactions.email_extra_save_fail id={} err={}", data.receipt_id, str(e))
return Success(data={"status": status, "log_id": log.id, "error": error}, msg="发送成功" if status == "OK" else "发送失败")

View File

@ -116,6 +116,9 @@ class AppUserController(CRUDBase[AppUser, AppUserRegisterSchema, AppUserUpdateSc
op_type=op_type,
remark=remark,
)
# if remark is not None:
# user.notes = remark
# await user.save()
return user
async def change_password(self, user_id: int, old_password: str, new_password: str) -> bool:

View File

@ -33,7 +33,14 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
返回:
InvoiceHeaderOut: 抬头输出对象
"""
header = await InvoiceHeader.create(app_user_id=user_id, **data.model_dump())
payload = data.model_dump()
for k in ["register_address", "register_phone", "bank_name", "bank_account"]:
if payload.get(k) is None:
payload[k] = ""
if payload.get("is_default"):
if user_id is not None:
await InvoiceHeader.filter(app_user_id=user_id).update(is_default=False)
header = await InvoiceHeader.create(app_user_id=user_id, **payload)
return InvoiceHeaderOut.model_validate(header)
async def get_headers(self, user_id: Optional[int] = None) -> List[InvoiceHeaderOut]:
@ -74,6 +81,9 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
return None
update_data = data.model_dump(exclude_unset=True)
if update_data:
if update_data.get("is_default"):
if header.app_user_id is not None:
await InvoiceHeader.filter(app_user_id=header.app_user_id).exclude(id=header.id).update(is_default=False)
await header.update_from_dict(update_data).save()
return InvoiceHeaderOut.model_validate(header)
@ -178,6 +188,7 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
note=receipt.note,
verified=receipt.verified,
created_at=receipt.created_at.isoformat() if receipt.created_at else "",
extra=receipt.extra,
)
async def list_receipts(self, page: int = 1, page_size: int = 10, **filters) -> dict:
@ -254,6 +265,7 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
"invoice_id": getattr(inv, "id", None),
"submitted_at": r.created_at.isoformat() if r.created_at else "",
"receipt_uploaded_at": r.updated_at.isoformat() if getattr(r, "updated_at", None) else "",
"extra": r.extra,
"receipts": [
{
"id": r.id,
@ -295,6 +307,7 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
"invoice_id": getattr(inv, "id", None),
"submitted_at": r.created_at.isoformat() if r.created_at else "",
"receipt_uploaded_at": r.updated_at.isoformat() if getattr(r, "updated_at", None) else "",
"extra": r.extra,
"receipts": [
{
"id": r.id,

View File

@ -190,9 +190,9 @@ class ValuationController:
a_s_dt = _parse_time(getattr(query, 'audited_start', None))
a_e_dt = _parse_time(getattr(query, 'audited_end', None))
if a_s_dt:
queryset = queryset.filter(audited_at__isnull=False, audited_at__gte=a_s_dt)
queryset = queryset.filter(updated_at__gte=a_s_dt)
if a_e_dt:
queryset = queryset.filter(audited_at__isnull=False, audited_at__lte=a_e_dt)
queryset = queryset.filter(updated_at__lte=a_e_dt)
return queryset

View File

@ -12,6 +12,7 @@ class InvoiceHeader(BaseModel, TimestampMixin):
bank_name = fields.CharField(max_length=128, description="开户银行")
bank_account = fields.CharField(max_length=64, description="银行账号")
email = fields.CharField(max_length=128, description="接收邮箱")
is_default = fields.BooleanField(default=False, description="是否默认抬头", index=True)
class Meta:
table = "invoice_header"
@ -29,7 +30,7 @@ class Invoice(BaseModel, TimestampMixin):
register_phone = fields.CharField(max_length=32, description="注册电话")
bank_name = fields.CharField(max_length=128, description="开户银行")
bank_account = fields.CharField(max_length=64, description="银行账号")
status = fields.CharField(max_length=16, description="状态: pending|invoiced|rejected|refunded", index=True)
status = fields.CharField(max_length=16, description="状态: pending|invoiced|rejected|refunded", index=True, default="pending")
app_user_id = fields.IntField(null=True, description="App用户ID", index=True)
header = fields.ForeignKeyField("models.InvoiceHeader", related_name="invoices", null=True, description="抬头关联")
wechat = fields.CharField(max_length=64, null=True, description="微信号", index=True)
@ -44,6 +45,7 @@ class PaymentReceipt(BaseModel, TimestampMixin):
url = fields.CharField(max_length=512, description="付款凭证图片地址")
note = fields.CharField(max_length=256, null=True, description="备注")
verified = fields.BooleanField(default=False, description="是否已核验")
extra = fields.JSONField(null=True, description="额外信息:邮件发送相关")
class Meta:
table = "payment_receipt"

View File

@ -62,6 +62,7 @@ class AppUserUpdateSchema(BaseModel):
company_contact: Optional[str] = Field(None, description="公司联系人")
company_phone: Optional[str] = Field(None, description="公司电话")
company_email: Optional[str] = Field(None, description="公司邮箱")
notes: Optional[str] = Field(None, description="备注")
class AppUserChangePasswordSchema(BaseModel):

View File

@ -1,15 +1,16 @@
from typing import Optional, List
from pydantic import BaseModel, Field, EmailStr
from pydantic import BaseModel, Field, EmailStr, field_validator, model_validator
class InvoiceHeaderCreate(BaseModel):
company_name: str = Field(..., min_length=1, max_length=128)
tax_number: str = Field(..., min_length=1, max_length=32)
register_address: str = Field(..., min_length=1, max_length=256)
register_phone: str = Field(..., min_length=1, max_length=32)
bank_name: str = Field(..., min_length=1, max_length=128)
bank_account: str = Field(..., min_length=1, max_length=64)
register_address: Optional[str] = Field(None, min_length=1, max_length=256)
register_phone: Optional[str] = Field(None, min_length=1, max_length=32)
bank_name: Optional[str] = Field(None, min_length=1, max_length=128)
bank_account: Optional[str] = Field(None, min_length=1, max_length=64)
email: EmailStr
is_default: Optional[bool] = False
class InvoiceHeaderOut(BaseModel):
@ -24,16 +25,18 @@ class InvoiceHeaderOut(BaseModel):
email: EmailStr
class Config:
from_attributes = True
is_default: Optional[bool] = False
class InvoiceHeaderUpdate(BaseModel):
company_name: Optional[str] = Field(None, min_length=1, max_length=128)
tax_number: Optional[str] = Field(None, min_length=1, max_length=32)
register_address: Optional[str] = Field(None, min_length=1, max_length=256)
register_phone: Optional[str] = Field(None, min_length=1, max_length=32)
bank_name: Optional[str] = Field(None, min_length=1, max_length=128)
bank_account: Optional[str] = Field(None, min_length=1, max_length=64)
register_address: Optional[str] = Field(None, max_length=256)
register_phone: Optional[str] = Field(None, max_length=32)
bank_name: Optional[str] = Field(None, max_length=128)
bank_account: Optional[str] = Field(None, max_length=64)
email: Optional[EmailStr] = None
is_default: Optional[bool] = None
class InvoiceCreate(BaseModel):
@ -105,6 +108,7 @@ class UpdateType(BaseModel):
class PaymentReceiptCreate(BaseModel):
url: str = Field(..., min_length=1, max_length=512)
note: Optional[str] = Field(None, max_length=256)
extra: Optional[dict] = None
class PaymentReceiptOut(BaseModel):
@ -113,3 +117,41 @@ class PaymentReceiptOut(BaseModel):
note: Optional[str]
verified: bool
created_at: str
extra: Optional[dict] = None
class AppCreateInvoiceWithReceipt(BaseModel):
header_id: int
ticket_type: Optional[str] = Field(None, pattern=r"^(electronic|paper)$")
invoice_type: Optional[str] = Field(None, pattern=r"^(special|normal)$")
# 兼容前端索引字段:"0"→normal"1"→special
invoiceTypeIndex: Optional[str] = None
receipt_url: str = Field(..., min_length=1, max_length=512)
note: Optional[str] = Field(None, max_length=256)
@field_validator('ticket_type', mode='before')
@classmethod
def _default_ticket_type(cls, v):
return v or 'electronic'
@field_validator('receipt_url', mode='before')
@classmethod
def _clean_receipt_url(cls, v):
if isinstance(v, list) and v:
v = v[0]
if isinstance(v, str):
s = v.strip()
if s.startswith('`') and s.endswith('`'):
s = s[1:-1].strip()
return s
return v
@model_validator(mode='after')
def _coerce_invoice_type(self):
if not self.invoice_type and self.invoiceTypeIndex is not None:
mapping = {'0': 'normal', '1': 'special'}
self.invoice_type = mapping.get(str(self.invoiceTypeIndex))
# 若仍为空,默认 normal
if not self.invoice_type:
self.invoice_type = 'normal'
return self

View File

@ -1,12 +1,17 @@
from pydantic import BaseModel, Field
from typing import Optional
from typing import Optional, List, Union
class SendEmailRequest(BaseModel):
receipt_id: Optional[int] = Field(None, description="付款凭证ID")
email: str = Field(..., description="邮箱地址")
subject: Optional[str] = Field(None, description="邮件主题")
body: str = Field(..., description="文案内容")
file_url: Optional[str] = Field(None, description="附件URL")
file_urls: Optional[List[str]] = Field(None, description="附件URL列表")
class SendEmailBody(BaseModel):
data: SendEmailRequest
class SendEmailResponse(BaseModel):
@ -23,3 +28,7 @@ class EmailSendLogOut(BaseModel):
file_name: Optional[str]
file_url: Optional[str]
status: str
class SendEmailBody(BaseModel):
data: SendEmailRequest

View File

@ -1,6 +1,6 @@
from datetime import datetime
from typing import List, Optional, Any, Dict, Union
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, field_validator, model_validator
from decimal import Decimal
@ -149,12 +149,15 @@ class ValuationAssessmentOut(ValuationAssessmentBase):
id: int = Field(..., description="主键ID")
user_id: int = Field(..., description="用户ID")
user_phone: Optional[str] = Field(None, description="用户手机号")
report_url: Optional[str] = Field(None, description="评估报告URL")
certificate_url: Optional[str] = Field(None, description="证书URL")
report_url: List[str] = Field(default_factory=list, description="评估报告URL列表")
certificate_url: List[str] = Field(default_factory=list, description="证书URL列表")
report_download_urls: List[str] = Field(default_factory=list, description="评估报告下载地址列表")
certificate_download_urls: List[str] = Field(default_factory=list, description="证书下载地址列表")
status: str = Field(..., description="评估状态")
admin_notes: Optional[str] = Field(None, description="管理员备注")
created_at: datetime = Field(..., description="创建时间")
updated_at: datetime = Field(..., description="更新时间")
audited_at: Optional[datetime] = Field(None, description="审核时间")
is_active: bool = Field(..., description="是否激活")
class Config:
@ -165,6 +168,29 @@ class ValuationAssessmentOut(ValuationAssessmentBase):
# 确保所有字段都被序列化包括None值
exclude_none = False
@field_validator('report_url', 'certificate_url', mode='before')
@classmethod
def _to_list(cls, v):
def clean(s: str) -> str:
s = s.strip()
if s.startswith('`') and s.endswith('`'):
s = s[1:-1].strip()
return s
if v is None:
return []
if isinstance(v, list):
return [clean(str(i)) for i in v if i is not None and str(i).strip() != ""]
if isinstance(v, str):
s = clean(v)
return [s] if s else []
return []
@model_validator(mode='after')
def _fill_downloads(self):
self.report_download_urls = list(self.report_url or [])
self.certificate_download_urls = list(self.certificate_url or [])
return self
# 用户端专用模式
class UserValuationCreate(ValuationAssessmentBase):
@ -176,8 +202,10 @@ class UserValuationOut(ValuationAssessmentBase):
"""用户端估值评估输出模型"""
id: int = Field(..., description="主键ID")
user_id: Optional[int] = Field(None, description="用户ID")
report_url: Optional[str] = Field(None, description="评估报告URL")
certificate_url: Optional[str] = Field(None, description="证书URL")
report_url: List[str] = Field(default_factory=list, description="评估报告URL列表")
certificate_url: List[str] = Field(default_factory=list, description="证书URL列表")
report_download_urls: List[str] = Field(default_factory=list, description="评估报告下载地址列表")
certificate_download_urls: List[str] = Field(default_factory=list, description="证书下载地址列表")
status: str = Field(..., description="评估状态")
admin_notes: Optional[str] = Field(None, description="管理员备注")
created_at: datetime = Field(..., description="创建时间")
@ -191,12 +219,37 @@ class UserValuationOut(ValuationAssessmentBase):
}
exclude_none = False
@field_validator('report_url', 'certificate_url', mode='before')
@classmethod
def _to_list(cls, v):
def clean(s: str) -> str:
s = s.strip()
if s.startswith('`') and s.endswith('`'):
s = s[1:-1].strip()
return s
if v is None:
return []
if isinstance(v, list):
return [clean(str(i)) for i in v if i is not None and str(i).strip() != ""]
if isinstance(v, str):
s = clean(v)
return [s] if s else []
return []
@model_validator(mode='after')
def _fill_downloads(self):
self.report_download_urls = list(self.report_url or [])
self.certificate_download_urls = list(self.certificate_url or [])
return self
class UserValuationDetail(ValuationAssessmentBase):
"""用户端详细估值评估模型"""
id: int = Field(..., description="主键ID")
report_url: Optional[str] = Field(None, description="评估报告URL")
certificate_url: Optional[str] = Field(None, description="证书URL")
report_url: List[str] = Field(default_factory=list, description="评估报告URL列表")
certificate_url: List[str] = Field(default_factory=list, description="证书URL列表")
report_download_urls: List[str] = Field(default_factory=list, description="评估报告下载地址列表")
certificate_download_urls: List[str] = Field(default_factory=list, description="证书下载地址列表")
status: str = Field(..., description="评估状态")
admin_notes: Optional[str] = Field(None, description="管理员备注")
created_at: datetime = Field(..., description="创建时间")
@ -208,6 +261,29 @@ class UserValuationDetail(ValuationAssessmentBase):
datetime: lambda v: v.isoformat()
}
@field_validator('report_url', 'certificate_url', mode='before')
@classmethod
def _to_list(cls, v):
def clean(s: str) -> str:
s = s.strip()
if s.startswith('`') and s.endswith('`'):
s = s[1:-1].strip()
return s
if v is None:
return []
if isinstance(v, list):
return [clean(str(i)) for i in v if i is not None and str(i).strip() != ""]
if isinstance(v, str):
s = clean(v)
return [s] if s else []
return []
@model_validator(mode='after')
def _fill_downloads(self):
self.report_download_urls = list(self.report_url or [])
self.certificate_download_urls = list(self.certificate_url or [])
return self
class UserValuationList(BaseModel):
"""用户端估值评估列表模型"""

View File

@ -3,7 +3,7 @@ from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email import encoders
from typing import Optional
from typing import Optional, List, Tuple
import httpx
from app.settings.config import settings
@ -27,6 +27,14 @@ class EmailClient:
part.add_header("Content-Disposition", f"attachment; filename=\"{file_name}\"")
msg.attach(part)
if hasattr(self, "_extra_attachments") and isinstance(self._extra_attachments, list):
for fb, fn in self._extra_attachments:
part = MIMEBase("application", "octet-stream")
part.set_payload(fb)
encoders.encode_base64(part)
part.add_header("Content-Disposition", f"attachment; filename=\"{fn}\"")
msg.attach(part)
if settings.SMTP_TLS:
server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=30)
server.starttls()
@ -45,5 +53,12 @@ class EmailClient:
pass
return {"status": "FAIL", "error": str(e)}
def send_many(self, to_email: str, subject: Optional[str], body: str, attachments: Optional[List[Tuple[bytes, str]]] = None) -> dict:
self._extra_attachments = attachments or []
try:
return self.send(to_email, subject, body, None, None, None)
finally:
self._extra_attachments = []
email_client = EmailClient()

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@ -20,6 +20,15 @@ server {
root /opt/vue-fastapi-admin/web/dist;
index index.html index.htm;
try_files $uri /index.html;
}
# PC 前端(/pc/ 前缀)
location = /pc {
return 302 /pc/;
}
location ^~ /pc/ {
alias /opt/vue-fastapi-admin/web1/dist/;
index index.html;
try_files $uri $uri/ /index.html;
}
location ^~ /api/ {
proxy_pass http://127.0.0.1:9999;

BIN
web1/dist.zip Normal file

Binary file not shown.