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