diff --git a/.trae/documents/交易管理与用户备注功能增强实施计划.md b/.trae/documents/交易管理与用户备注功能增强实施计划.md
new file mode 100644
index 0000000..d9e4cca
--- /dev/null
+++ b/.trae/documents/交易管理与用户备注功能增强实施计划.md
@@ -0,0 +1,133 @@
+## 目标概述
+
+* 加强“交易管理-发票附件与发送邮箱”能力,完善数据记录与事务保障。
+
+* 改进“开票弹窗附件上传”支持多文件上传(后端存储结构)。
+
+* 优化“用户管理备注与操作记录”,区分备注维度并完善日志。
+
+* 覆盖单元/集成测试、数据库迁移、API文档与审计日志。
+
+## 数据库与模型变更
+
+* EmailSendLog
+
+ * 新增:`extra: JSONField(null=True)` 完整记录发送邮箱的请求数据(收件人、主题、正文、附件列表、重试信息等)。
+
+* Invoice(或与开票弹窗相关的业务模型)
+
+ * 新增:`attachments: JSONField(null=True)` 支持多个附件URL(与弹窗上传对应)。
+
+
+
+*
+
+* 迁移脚本:创建/修改上述字段;保留历史数据不丢失。
+
+## 事务与原子性
+
+* 发送邮箱流程(交易管理)
+
+ * 封装在 `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. 更新接口文档与部署回归验证。
+
diff --git a/app/api/v1/app_invoices/app_invoices.py b/app/api/v1/app_invoices/app_invoices.py
index cfbf0e7..56adea2 100644
--- a/app/api/v1/app_invoices/app_invoices.py
+++ b/app/api/v1/app_invoices/app_invoices.py
@@ -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="获取成功")
diff --git a/app/api/v1/app_users/admin_manage.py b/app/api/v1/app_users/admin_manage.py
index a251305..8b0bede 100644
--- a/app/api/v1/app_users/admin_manage.py
+++ b/app/api/v1/app_users/admin_manage.py
@@ -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 "",
diff --git a/app/api/v1/invoice/invoice.py b/app/api/v1/invoice/invoice.py
index c054854..0dc9603 100644
--- a/app/api/v1/invoice/invoice.py
+++ b/app/api/v1/invoice/invoice.py
@@ -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="获取成功")
diff --git a/app/api/v1/transactions/transactions.py b/app/api/v1/transactions/transactions.py
index fc698bb..620908c 100644
--- a/app/api/v1/transactions/transactions.py
+++ b/app/api/v1/transactions/transactions.py
@@ -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)
- r.raise_for_status()
- file_bytes = r.content
- file_name = data.file_url.split("/")[-1]
+ for u in urls:
+ r = await client.get(u)
+ r.raise_for_status()
+ 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 "发送失败")
@@ -128,4 +143,4 @@ async def smtp_config_status():
"tls": settings.SMTP_TLS,
"configured": configured,
}
- return Success(data=data, msg="OK")
\ No newline at end of file
+ return Success(data=data, msg="OK")
diff --git a/app/controllers/app_user.py b/app/controllers/app_user.py
index c753f64..f1eb690 100644
--- a/app/controllers/app_user.py
+++ b/app/controllers/app_user.py
@@ -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:
diff --git a/app/controllers/invoice.py b/app/controllers/invoice.py
index eceb208..f4251ef 100644
--- a/app/controllers/invoice.py
+++ b/app/controllers/invoice.py
@@ -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,
diff --git a/app/controllers/valuation.py b/app/controllers/valuation.py
index a94bc7e..545f77c 100644
--- a/app/controllers/valuation.py
+++ b/app/controllers/valuation.py
@@ -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
diff --git a/app/models/invoice.py b/app/models/invoice.py
index 008227c..845abde 100644
--- a/app/models/invoice.py
+++ b/app/models/invoice.py
@@ -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"
@@ -61,4 +63,4 @@ class EmailSendLog(BaseModel, TimestampMixin):
class Meta:
table = "email_send_log"
- table_description = "邮件发送日志"
\ No newline at end of file
+ table_description = "邮件发送日志"
diff --git a/app/schemas/app_user.py b/app/schemas/app_user.py
index 6f37749..a9cbb61 100644
--- a/app/schemas/app_user.py
+++ b/app/schemas/app_user.py
@@ -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):
@@ -113,4 +114,4 @@ class AppUserRegisterOut(BaseModel):
class TokenValidateOut(BaseModel):
"""Token 校验结果"""
user_id: int = Field(..., description="用户ID")
- phone: str = Field(..., description="手机号")
\ No newline at end of file
+ phone: str = Field(..., description="手机号")
diff --git a/app/schemas/invoice.py b/app/schemas/invoice.py
index 2699bfd..31cc2d4 100644
--- a/app/schemas/invoice.py
+++ b/app/schemas/invoice.py
@@ -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
diff --git a/app/schemas/transactions.py b/app/schemas/transactions.py
index 7b2ed27..43d7b82 100644
--- a/app/schemas/transactions.py
+++ b/app/schemas/transactions.py
@@ -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):
@@ -22,4 +27,8 @@ class EmailSendLogOut(BaseModel):
body_summary: Optional[str]
file_name: Optional[str]
file_url: Optional[str]
- status: str
\ No newline at end of file
+ status: str
+
+
+class SendEmailBody(BaseModel):
+ data: SendEmailRequest
diff --git a/app/schemas/valuation.py b/app/schemas/valuation.py
index 5165f69..fd3b33b 100644
--- a/app/schemas/valuation.py
+++ b/app/schemas/valuation.py
@@ -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):
"""用户端估值评估列表模型"""
diff --git a/app/services/email_client.py b/app/services/email_client.py
index 685d1b2..8ae2ea4 100644
--- a/app/services/email_client.py
+++ b/app/services/email_client.py
@@ -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()
\ No newline at end of file
+
+email_client = EmailClient()
diff --git a/app/static/images/5oguGT9c0sZG8d191a99ee489636f5d20470f06c5bdd.jpg b/app/static/images/5oguGT9c0sZG8d191a99ee489636f5d20470f06c5bdd.jpg
new file mode 100644
index 0000000..3024920
Binary files /dev/null and b/app/static/images/5oguGT9c0sZG8d191a99ee489636f5d20470f06c5bdd.jpg differ
diff --git a/app/static/images/6biCNRo4pUO28d191a99ee489636f5d20470f06c5bdd.jpg b/app/static/images/6biCNRo4pUO28d191a99ee489636f5d20470f06c5bdd.jpg
new file mode 100644
index 0000000..3024920
Binary files /dev/null and b/app/static/images/6biCNRo4pUO28d191a99ee489636f5d20470f06c5bdd.jpg differ
diff --git a/app/static/images/7-icon.png b/app/static/images/7-icon.png
new file mode 100644
index 0000000..358a349
Binary files /dev/null and b/app/static/images/7-icon.png differ
diff --git a/app/static/images/HV7BfqKI6qsj8d191a99ee489636f5d20470f06c5bdd.jpg b/app/static/images/HV7BfqKI6qsj8d191a99ee489636f5d20470f06c5bdd.jpg
new file mode 100644
index 0000000..3024920
Binary files /dev/null and b/app/static/images/HV7BfqKI6qsj8d191a99ee489636f5d20470f06c5bdd.jpg differ
diff --git a/app/static/images/S4w2qPihkWp9fb88cf23c8c222a2611214d0322c2d17.jpg b/app/static/images/S4w2qPihkWp9fb88cf23c8c222a2611214d0322c2d17.jpg
new file mode 100644
index 0000000..c5e7db8
Binary files /dev/null and b/app/static/images/S4w2qPihkWp9fb88cf23c8c222a2611214d0322c2d17.jpg differ
diff --git a/app/static/images/ZszPmmxEk5sC8d191a99ee489636f5d20470f06c5bdd.jpg b/app/static/images/ZszPmmxEk5sC8d191a99ee489636f5d20470f06c5bdd.jpg
new file mode 100644
index 0000000..3024920
Binary files /dev/null and b/app/static/images/ZszPmmxEk5sC8d191a99ee489636f5d20470f06c5bdd.jpg differ
diff --git a/app/static/images/bkk7Az43xFx68d191a99ee489636f5d20470f06c5bdd.jpg b/app/static/images/bkk7Az43xFx68d191a99ee489636f5d20470f06c5bdd.jpg
new file mode 100644
index 0000000..3024920
Binary files /dev/null and b/app/static/images/bkk7Az43xFx68d191a99ee489636f5d20470f06c5bdd.jpg differ
diff --git a/app/static/images/gTu8GD0bwt7v8d191a99ee489636f5d20470f06c5bdd.jpg b/app/static/images/gTu8GD0bwt7v8d191a99ee489636f5d20470f06c5bdd.jpg
new file mode 100644
index 0000000..3024920
Binary files /dev/null and b/app/static/images/gTu8GD0bwt7v8d191a99ee489636f5d20470f06c5bdd.jpg differ
diff --git a/app/static/images/kzxymV8LkcEo8d191a99ee489636f5d20470f06c5bdd.jpg b/app/static/images/kzxymV8LkcEo8d191a99ee489636f5d20470f06c5bdd.jpg
new file mode 100644
index 0000000..3024920
Binary files /dev/null and b/app/static/images/kzxymV8LkcEo8d191a99ee489636f5d20470f06c5bdd.jpg differ
diff --git a/app/static/images/zPysP52EDlnZ8d191a99ee489636f5d20470f06c5bdd.jpg b/app/static/images/zPysP52EDlnZ8d191a99ee489636f5d20470f06c5bdd.jpg
new file mode 100644
index 0000000..3024920
Binary files /dev/null and b/app/static/images/zPysP52EDlnZ8d191a99ee489636f5d20470f06c5bdd.jpg differ
diff --git a/deploy/web.conf b/deploy/web.conf
index 280bff6..d0fb9e4 100644
--- a/deploy/web.conf
+++ b/deploy/web.conf
@@ -21,6 +21,15 @@ server {
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;
proxy_set_header Host $host;
diff --git a/web1/dist.zip b/web1/dist.zip
new file mode 100644
index 0000000..b22bf61
Binary files /dev/null and b/web1/dist.zip differ