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:
@ -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()
|
||||
|
||||
Reference in New Issue
Block a user