feat(daily-report): 正文保持摘要,新增 Excel 附件给全量明细

日报此前把明细压到极简(修改只列变更字段≥3 的记录、新增只给模块计数),
"199 条新增只看到 3 个模块名",查不到某条具体记录改了什么。

但也不能全塞进正文:实测某日 354 条修改展开近 3000 行,客户端渲染卡顿,
部分企业邮件网关会把超长自动邮件判为垃圾或截断 —— 发件方照样收到 250,
表面看一切正常,这比"信息少"更难排查。

故拆成两路:正文保持摘要(render),附件给全量明细(render_excel),
六个工作表不设 DETAIL_MIN_FIELDS 之类的阈值。

- email_service: send_email/send_email_async 支持 attachments。数据是内存
  字节、不落盘;中文文件名走 RFC 2231;.xlsx 用官方 MIME 类型,写错会让
  Outlook 当成未知二进制、附件无法直接打开。
- audit_export_service: 新增。审计日志 → 可读值/Excel 的共用层。
  「同一条「实际审批人ID: 7」一边翻了人名一边没翻」这类漂移,比不翻译更
  难发现,故取值/翻译/排版逻辑集中在此,由日报与审计页导出共用。
- daily_report_service: 私有副本改为消费上述共用层(纯重构)。
  附件大小与重构前逐字节一致(490.5K / 193.9K / 10.3K),行为未变。

附件生成失败时降级为纯文本正文继续发送,但记 error 级日志 —— 降级后邮件
看起来完全正常,不留显眼日志的话附件静默丢失可以持续几个月没人发现。
This commit is contained in:
yueli
2026-09-28 09:46:25 +08:00
parent 0614597dbe
commit 1d6beaa376
3 changed files with 1051 additions and 275 deletions

View File

@ -7,6 +7,7 @@ import os
import smtplib
import ssl
import logging
from email.mime.application import MIMEApplication
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.header import Header
@ -15,6 +16,39 @@ from typing import List, Union
logger = logging.getLogger(__name__)
# 附件扩展名 → MIME 子类型。
#
# ★ .xlsx 必须用官方类型,不能图省事写 'xlsx':那样 Content-Type 会变成
# application/xlsx,Outlook 等客户端会当成未知二进制,附件无法双击直接
# 打开(得先存盘再手动改后缀),用户只会反馈"附件打不开"。
_XLSX_SUBTYPE = 'vnd.openxmlformats-officedocument.spreadsheetml.sheet'
_ATTACHMENT_SUBTYPES = {
'.xlsx': _XLSX_SUBTYPE,
'.xls': 'vnd.ms-excel',
'.csv': 'vnd.ms-excel',
'.pdf': 'pdf',
'.zip': 'zip',
}
def _attachment_subtype(filename: str) -> str:
"""按扩展名给出 MIME 子类型;未登记的回落到 octet-stream(即默认值)"""
ext = os.path.splitext(filename or '')[1].lower()
return _ATTACHMENT_SUBTYPES.get(ext, 'octet-stream')
def _normalize_attachment(att):
"""
附件入参 → (文件名, 字节)。接受两种写法,避免调用方纠结:
{'filename': '日报.xlsx', 'data': b'...'}
('日报.xlsx', b'...')
"""
if isinstance(att, dict):
return att.get('filename') or 'attachment', att.get('data') or b''
filename, data = att
return filename or 'attachment', data or b''
def _get_config():
"""
@ -46,15 +80,19 @@ def _get_config():
}
def send_email(to_email: Union[str, List[str]], subject: str, content: str, cfg: dict = None):
def send_email(to_email: Union[str, List[str]], subject: str, content: str,
cfg: dict = None, attachments: list = None):
"""
通用邮件发送函数
Args:
to_email: 收件人,单个邮箱字符串或列表
subject: 邮件主题
content: 邮件正文(纯文本)
cfg: 可选,预先获取的配置字典(用于异步线程传参,避免丢失 Flask context)
to_email: 收件人,单个邮箱字符串或列表
subject: 邮件主题
content: 邮件正文(纯文本)
cfg: 可选,预先获取的配置字典(用于异步线程传参,避免丢失 Flask context)
attachments: 可选,附件列表。每项为
{'filename': '日报.xlsx', 'data': b'...'} 或 ('日报.xlsx', b'...')。
数据是**字节**而非路径 —— 调用方在内存里生成,不落盘。
发送失败时打印日志,不抛出异常
"""
@ -91,7 +129,9 @@ def send_email(to_email: Union[str, List[str]], subject: str, content: str, cfg:
return
try:
msg = MIMEMultipart()
# ★ 显式 'mixed':虽然 MIMEMultipart() 默认就是 mixed,但正文+附件
# 的语义要求就在这里,写出来才不会被后人"顺手"改成 related/alternative。
msg = MIMEMultipart('mixed')
msg['From'] = cfg['sender']
msg['To'] = ', '.join(recipients)
msg['Subject'] = Header(subject, 'utf-8')
@ -104,7 +144,26 @@ def send_email(to_email: Union[str, List[str]], subject: str, content: str, cfg:
msg['Message-ID'] = make_msgid(domain=(cfg['sender'].split('@')[-1] or None))
msg.attach(MIMEText(content, 'plain', 'utf-8'))
print(f"DEBUG: 准备向服务器提交发信请求,收件人: {recipients} 发件人: {cfg['username']}")
# 附件:内容已在内存里,直接 base64 编码挂上,不经过文件系统
attached = 0
for att in (attachments or []):
filename, data = _normalize_attachment(att)
if not data:
logger.warning(f"[Email] 附件 {filename} 内容为空,已跳过")
continue
part = MIMEApplication(data, _subtype=_attachment_subtype(filename))
# ★ 中文文件名必须走 RFC 2231(('utf-8','',name) 三元组),
# 直接塞原始中文会生成非法的 Content-Disposition 头,
# 客户端显示成乱码或一串 =?utf-8?b?...?= 原文。
part.add_header('Content-Disposition', 'attachment',
filename=('utf-8', '', filename))
msg.attach(part)
attached += 1
print(
f"DEBUG: 准备向服务器提交发信请求,收件人: {recipients} "
f"发件人: {cfg['username']} 附件: {attached} 个"
)
if cfg.get('use_ssl'):
context = ssl.create_default_context()
@ -136,7 +195,8 @@ def send_email(to_email: Union[str, List[str]], subject: str, content: str, cfg:
logger.error(f"[Email] 发送邮件时发生未知异常: {e}")
def send_email_async(to_email: Union[str, List[str]], subject: str, content: str):
def send_email_async(to_email: Union[str, List[str]], subject: str, content: str,
attachments: list = None):
"""
异步发送邮件(守护线程,不阻塞主请求线程)。
@ -149,7 +209,7 @@ def send_email_async(to_email: Union[str, List[str]], subject: str, content: str
t = threading.Thread(
target=send_email,
args=(to_email, subject, content),
kwargs={'cfg': cfg},
kwargs={'cfg': cfg, 'attachments': attachments},
daemon=True
)
t.start()