Files
KCGL/inventory-backend/app/utils/job_lock.py
yueli ded5dfd7b3 feat(report): MOM 系统日报(纯模板,不接 AI)
每天 17:30(北京时间)自动汇总当日 新增/修改/删除、出库、借库,
渲染为纯文本邮件发送。

为什么不用 AI
--------------
此前那份日报由 Dify(外部 AI 服务,见 api/v1/ai_proxy.py)生成,
好处是能写人话,代价是:
  · 外部依赖一断,整份日报就没了 —— 这正是"AI 坏了"之后发生的事;
  · 按次计费、走网络、要管密钥;
  · **会在报表里编数字**,而日报是给人做决策用的,数字必须可信。
日报是固定格式的结构化统计(计数 + 清单),本来也不需要创造力。
数据全在库里:audit_logs / trans_outbound / trans_borrow。

口径与审计页保持一致
--------------------
  · action 经 canon_action() 归一化(历史数据里 CREATE 与「新增」混用,
    不归一会漏掉早期数据);
  · 默认排除 system 占位账号(与审计页默认的"真实用户"视图一致)。
  · 凡有上限处一律显式写明「另有 N 条」,不静默截断。
  · 变更值全部翻译成人类可读:状态码按模块查表、审批人/物料 ID → 名称、
    布尔 → 是/否、人名串规整。翻不动就原样显示 —— 报表里的错译比裸数字
    更难被发现,也更危险。

★ 顺带修掉两个既有缺陷(日报每天都会走这条路径,不修会一直中招):

1) 定时任务被 8 个 worker 重复执行
   gunicorn.conf.py 配了 8 worker,而 run.py 在**模块级**启动 APScheduler
   —— gunicorn 未开 preload_app,每个 worker 独立 import 一次 run.py,
   于是每个 worker 各起一份调度器,同一 cron 任务被并发执行 8 次。
   现有库存预警邮件因此每天重复发 8 封。
   新增 app/utils/job_lock.py:用 PostgreSQL 会话级咨询锁做跨进程互斥,
   抢到锁的 worker 才执行,其余静默跳过。不引入新组件(compose 里没有
   redis,redis_client 恒为 None,相关装饰器全程 fail-open,指望不上)。
   实测 8 线程并发抢锁恰好 1 个成功。

2) 邮箱授权码被打印进日志
   email_service.py 的 print(f"[DEBUG send_email] cfg = {cfg}") 会把含
   password 的整个配置打到 stdout → docker logs。改为只打非敏感字段。

新增配置:MAIL_DAILY_REPORT_RECIPIENTS(逗号分隔,默认 duxingchen@iris-rs.cn)
2026-09-23 17:16:26 +08:00

71 lines
2.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
跨进程的定时任务互斥锁。
为什么需要这个
--------------
`gunicorn.conf.py` 配了 8 个 worker(`workers = min(cpu*2+1, 8)`),而 `run.py`
是在**模块级**启动 APScheduler 的 —— gunicorn 未开 preload_app,每个 worker
都会独立 import 一次 `run.py`,于是**每个 worker 各起一份调度器**,同一个
cron 任务在相同时刻被并发执行 8 次。
实测:容器里跑着 8 个 worker 进程,`create_app()` 与"调度器已启动"两条日志
的出现次数完全相等 —— 每个 worker 都配了一份。
后果:库存预警邮件每天实际会重复发 8 封。
APScheduler 自带的 `max_instances` 只在**单个调度器实例内**生效,管不了
跨进程;`replace_existing` 同理。这里用 PostgreSQL 的**会话级咨询锁**
(`pg_try_advisory_lock`)做真正的跨进程互斥:抢到锁的那个 worker 才执行,
其余直接跳过。好处是不用引入 Redis 之类的新组件(compose 里也没有 redis,
`redis_client` 恒为 None,相关装饰器全程 fail-open,指望不上)。
★ 会话级锁绑定在**连接**上,必须在同一条连接上解锁 —— 所以这里显式取一条
专用连接,用完归还,不复用请求上下文里的 session。
"""
import logging
from contextlib import contextmanager
from sqlalchemy import text
from app.extensions import db
logger = logging.getLogger(__name__)
# 锁标识:PostgreSQL 咨询锁是 bigint,取固定值便于排查(不要用随机数)。
LOCK_INVENTORY_WARNING = 891001001 # 库存预警每日邮件
LOCK_DAILY_REPORT = 891001002 # MOM 系统日报
@contextmanager
def advisory_lock(key):
"""
尝试获取会话级咨询锁,产出「是否抢到」。
用法::
with advisory_lock(LOCK_DAILY_REPORT) as acquired:
if not acquired:
return # 别的 worker 正在跑,本轮跳过
...实际干活...
★ 抢不到锁是**正常路径**,不是错误 —— 说明另一个 worker 正在执行同一任务。
调用方应当静默跳过,而不是报警。
★ 拿锁失败(数据库不可用等)会让异常向上抛:宁可让调度器记一次失败,
也不要"没抢到锁"和"抢锁时数据库挂了"两种截然不同的情况被混为一谈。
"""
conn = db.engine.connect()
acquired = False
try:
acquired = bool(
conn.execute(text("SELECT pg_try_advisory_lock(:k)"), {"k": key}).scalar()
)
yield acquired
finally:
if acquired:
try:
conn.execute(text("SELECT pg_advisory_unlock(:k)"), {"k": key})
except Exception as e: # noqa: BLE001
# 解锁失败不应盖过业务异常;连接关闭时锁也会随之释放
logger.warning(f"[JobLock] 释放咨询锁 {key} 失败: {e}")
conn.close()