feat: 更新邮件客户端和评估状态处理逻辑

修复邮件发送时的收件方地址验证问题,添加域名解析检查
更新评估状态字段值从"approved"为"pending"以保持一致性
修改发票创建接口以支持无凭证上传的情况
添加用户管理接口的时间范围查询功能
更新SMTP和短信服务的默认配置
This commit is contained in:
邹方成 2025-11-27 15:04:37 +08:00
parent c7e191f096
commit 5ca0152c55
15 changed files with 75 additions and 30 deletions

View File

@ -116,9 +116,13 @@ async def create_with_receipt(payload: AppCreateInvoiceWithReceipt, current_user
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="创建并上传成功")
if payload.receipt_url:
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="创建并上传成功")
else:
out = await invoice_controller.get_out(inv.id)
return Success(data=out.model_dump() if out else {}, 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

View File

@ -17,6 +17,8 @@ async def list_app_users(
phone: Optional[str] = Query(None),
wechat: Optional[str] = Query(None),
id: Optional[str] = Query(None),
created_start: Optional[str] = Query(None),
created_end: 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),
@ -28,7 +30,28 @@ async def list_app_users(
qs = qs.filter(phone__icontains=phone)
if wechat:
qs = qs.filter(alias__icontains=wechat)
if created_at and len(created_at) == 2:
if created_start or created_end:
def _parse_dt(s: Optional[str]):
if not s:
return None
s = s.replace('+', ' ').strip()
try:
return datetime.strptime(s, "%Y-%m-%d %H:%M:%S")
except Exception:
try:
ms = float(s)
return datetime.fromtimestamp(ms / 1000)
except Exception:
return None
start_dt = _parse_dt(created_start)
end_dt = _parse_dt(created_end)
if start_dt and end_dt:
qs = qs.filter(created_at__gte=start_dt, created_at__lte=end_dt)
elif start_dt:
qs = qs.filter(created_at__gte=start_dt)
elif end_dt:
qs = qs.filter(created_at__lte=end_dt)
elif 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)

View File

@ -74,6 +74,15 @@ async def send_email(payload: SendEmailRequest = Body(...)):
attachments = []
urls = []
try:
domain = payload.email.split("@")[-1]
import dns.resolver
try:
dns.resolver.resolve(domain, "MX")
except Exception:
dns.resolver.resolve(domain, "A")
except Exception:
raise HTTPException(status_code=400, detail="收件方地址域名不可用或未正确解析")
if payload.file_urls:
urls.extend([u.strip().strip('`') for u in payload.file_urls if isinstance(u, str)])
if urls:
@ -113,14 +122,14 @@ async def send_email(payload: SendEmailRequest = Body(...)):
else:
logger.error("transactions.email_send_fail email={} err={}", payload.email, error)
if status == "OK" and data.receipt_id:
if status == "OK" and payload.receipt_id:
try:
r = await PaymentReceipt.filter(id=data.receipt_id).first()
r = await PaymentReceipt.filter(id=payload.receipt_id).first()
if r:
r.extra = (r.extra or {}) | data.model_dump()
r.extra = (r.extra or {}) | payload.model_dump()
await r.save()
except Exception as e:
logger.error("transactions.email_extra_save_fail id={} err={}", data.receipt_id, str(e))
logger.error("transactions.email_extra_save_fail id={} err={}", payload.receipt_id, str(e))
return Success(data={"status": status, "log_id": log.id, "error": error}, msg="发送成功" if status == "OK" else "发送失败")

View File

@ -120,7 +120,8 @@ async def get_valuations(
audited_start: Optional[str] = Query(None, description="审核时间开始证书修改时间毫秒或ISO"),
audited_end: Optional[str] = Query(None, description="审核时间结束证书修改时间毫秒或ISO"),
page: int = Query(1, ge=1, description="页码"),
size: int = Query(10, ge=1, le=100, description="每页数量")
size: int = Query(10, ge=1, le=100, description="每页数量"),
page_size: Optional[int] = Query(None, alias="page_size", ge=1, le=100, description="每页数量")
):
"""获取估值评估列表,支持筛选和分页"""
query = ValuationAssessmentQuery(
@ -136,7 +137,7 @@ async def get_valuations(
audited_start=audited_start,
audited_end=audited_end,
page=page,
size=size
size=page_size if page_size is not None else size
)
result = await valuation_controller.get_list(query)
import json
@ -155,10 +156,11 @@ async def get_valuations(
async def search_valuations(
keyword: str = Query(..., description="搜索关键词"),
page: int = Query(1, ge=1, description="页码"),
size: int = Query(10, ge=1, le=100, description="每页数量")
size: int = Query(10, ge=1, le=100, description="每页数量"),
page_size: Optional[int] = Query(None, alias="page_size", ge=1, le=100, description="每页数量")
):
"""根据关键词搜索估值评估记录"""
result = await valuation_controller.search(keyword, page, size)
result = await valuation_controller.search(keyword, page, page_size if page_size is not None else size)
import json
items = [json.loads(item.model_dump_json()) for item in result.items]
return SuccessExtra(

View File

@ -490,6 +490,11 @@ class ValuationController:
update_data['audited_at'] = datetime.now()
await valuation.update_from_dict(update_data)
await valuation.save()
from datetime import datetime
valuation.status = "success"
if not getattr(valuation, "audited_at", None):
valuation.audited_at = datetime.now()
await valuation.save()
out = ValuationAssessmentOut.model_validate(valuation)
return await self._attach_user_phone(out)
@ -643,7 +648,7 @@ class ValuationController:
return None
from datetime import datetime
update_data = {"status": "approved", "audited_at": datetime.now()}
update_data = {"status": "pending", "audited_at": datetime.now()}
if admin_notes:
update_data["admin_notes"] = admin_notes

View File

@ -82,7 +82,7 @@ class ValuationAssessment(Model):
# 系统字段
user = fields.ForeignKeyField("models.AppUser", related_name="valuations", description="提交用户")
status = fields.CharField(max_length=20, default="success", description="评估状态: pending(待审核), success(已通过), fail(已拒绝)")
status = fields.CharField(max_length=20, default="pending", description="评估状态: pending(待审核), success(已通过), rejected(已拒绝)")
admin_notes = fields.TextField(null=True, description="管理员备注")
created_at = fields.DatetimeField(auto_now_add=True, description="创建时间")
updated_at = fields.DatetimeField(auto_now=True, description="更新时间")

View File

@ -126,7 +126,7 @@ class AppCreateInvoiceWithReceipt(BaseModel):
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)
receipt_url: Optional[str] = Field(None, max_length=512)
note: Optional[str] = Field(None, max_length=256)
@field_validator('ticket_type', mode='before')
@ -143,7 +143,7 @@ class AppCreateInvoiceWithReceipt(BaseModel):
s = v.strip()
if s.startswith('`') and s.endswith('`'):
s = s[1:-1].strip()
return s
return s or None
return v
@model_validator(mode='after')

View File

@ -1,10 +1,10 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, EmailStr
from typing import Optional, List, Union
class SendEmailRequest(BaseModel):
receipt_id: Optional[int] = Field(None, description="付款凭证ID")
email: str = Field(..., description="邮箱地址")
email: EmailStr = Field(..., description="邮箱地址")
subject: Optional[str] = Field(None, description="邮件主题")
body: str = Field(..., description="文案内容")
file_urls: Optional[List[str]] = Field(None, description="附件URL列表")

View File

@ -362,7 +362,7 @@ class ValuationAssessmentQuery(BaseModel):
institution: Optional[str] = Field(None, description="所属机构")
industry: Optional[str] = Field(None, description="所属行业")
heritage_level: Optional[str] = Field(None, description="非遗等级")
status: Optional[str] = Field(None, description="评估状态: pending(待审核), approved(已通过), rejected(已拒绝)")
status: Optional[str] = Field(None, description="评估状态: pending(待审核), success(已通过), rejected(已拒绝)")
is_active: Optional[bool] = Field(None, description="是否激活")
phone: Optional[str] = Field(None, description="手机号模糊查询")
submitted_start: Optional[str] = Field(None, description="提交时间开始毫秒时间戳或ISO字符串")

View File

@ -51,6 +51,8 @@ class EmailClient:
server.quit()
except Exception:
pass
if isinstance(e, smtplib.SMTPRecipientsRefused):
return {"status": "FAIL", "error": "收件方地址不存在或暂时不可用"}
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:

View File

@ -86,7 +86,7 @@ class SMSClient:
返回体映射字典
"""
key = settings.ALIYUN_SMS_TEMPLATE_PARAM_CODE_KEY or "code"
template = settings.ALIYUN_SMS_TEMPLATE_CODE_VERIFY or "SMS_498190229"
template = settings.ALIYUN_SMS_TEMPLATE_CODE_VERIFY
logger.info("sms.send_code using key={} template={} phone={}", key, template, phone)
return self.send_by_template(phone, template, {key: code})
@ -99,7 +99,7 @@ class SMSClient:
Returns:
返回体映射字典
"""
template = settings.ALIYUN_SMS_TEMPLATE_CODE_REPORT or "SMS_498140213"
template = settings.ALIYUN_SMS_TEMPLATE_CODE_REPORT
logger.info("sms.send_report using template={} phone={}", template, phone)
return self.send_by_template(phone, template, {})

View File

@ -100,18 +100,18 @@ class Settings(BaseSettings):
ALIYUN_SMS_SIGN_NAME: typing.Optional[str] = "成都文化产权交易所"
ALIYUN_SMS_ENDPOINT: str = "dysmsapi.aliyuncs.com"
ALIYUN_SMS_TEMPLATE_CODE_VERIFY: typing.Optional[str] = "SMS_498140213"
ALIYUN_SMS_TEMPLATE_CODE_REPORT: typing.Optional[str] = "SMS_49190229"
ALIYUN_SMS_TEMPLATE_CODE_REPORT: typing.Optional[str] = "SMS_498190229"
SMS_CODE_DIGITS: int = 6
SMS_DEBUG_LOG_CODE: bool = True
ALIYUN_USE_DEFAULT_CREDENTIALS: bool = False
ALIYUN_SMS_TEMPLATE_PARAM_CODE_KEY: typing.Optional[str] = "code"
SMTP_HOST: typing.Optional[str] = None
SMTP_PORT: typing.Optional[int] = None
SMTP_USERNAME: typing.Optional[str] = None
SMTP_PASSWORD: typing.Optional[str] = None
SMTP_TLS: bool = True
SMTP_FROM: typing.Optional[str] = None
SMTP_HOST: typing.Optional[str] = "smtp.qiye.aliyun.com"
SMTP_PORT: typing.Optional[int] = 465
SMTP_USERNAME: typing.Optional[str] = "value@cdcee.net"
SMTP_PASSWORD: typing.Optional[str] = "PPXbILdGlRCn2VOx"
SMTP_TLS: bool = False
SMTP_FROM: typing.Optional[str] = "value@cdcee.net"
settings = Settings()

Binary file not shown.

0
smtp_test_output.txt Normal file
View File

View File

@ -37,8 +37,8 @@
export DOCKER_DEFAULT_PLATFORM=linux/amd64
docker build -t zfc931912343/guzhi-fastapi-admin:v1.8 .
docker push zfc931912343/guzhi-fastapi-admin:v1.8
docker build -t zfc931912343/guzhi-fastapi-admin:v2.0 .
docker push zfc931912343/guzhi-fastapi-admin:v2.0
# 运行容器