Compare commits

...

4 Commits

Author SHA1 Message Date
edd43fec29 fix(webhook): MomOutboundPayload 补 outbound_type,与 LICA 实例解析一致
MOM 一直在载荷里发 outbound_type(出库类型:SALES 销售 / USE 领用 /
PRODUCTION 生产),本实例此前没声明该字段,被 Pydantic 静默丢弃。

当前没有任何代码读它,补上不影响行为;目的是让两个实例对同一载荷的解析结果
一致 —— 否则将来谁写了读这个字段的代码,会在 LICA 拿到值、在本实例拿到 None,
而且这种不一致是静默的,不会报错。
2026-09-22 13:46:11 +08:00
3c94faf078 feat(audit): MOM 回调归因到实际操作人,不再显示「未认证」
外部回调走 X-API-Key 鉴权、没有 JWT,JWT 依赖不执行,审计中间件读到的
request.state.audit_user 永远是空 —— 操作审计里就出现一堆没有归属的
「外部系统对接」记录,看不出是谁扫的码。

MOM 载荷里本来就带着实际操作人(operator,即 MOM 侧扫码的那位),写进
request.state 即可让审计归因到人;顺手解析中文姓名(查不到也不影响审计,
前端会回退显示账号)。

⚠️ 调用位置必须在 X-API-Key 校验【之后】:密钥不对说明载荷本身就不可信,
   此时把 operator 写进审计等于允许伪造人。放在部门校验之后同样有意为之 ——
   被拦下的外来消息不该留下任何归属痕迹。

取不到操作人时写 "MOM系统" 而非留空:「MOM系统」至少说明这是一次机器回调,
比继续显示「未认证」(读起来像"一个匿名的人")更准确。

实测:MOM 回调后审计记录显示实际操作人姓名;无 operator 时显示「MOM系统」。
2026-09-22 13:44:05 +08:00
817183062d feat(webhook): MOM 回调的部门归属分流 —— 只放行 IRIS 与空白值
MOM 现在同时对接 IRIS 与 LICA 两个 Track 实例,按载荷里的 company_name 分流。

实现:
  · MomInboundPayload / MomOutboundPayload 补 company_name 字段
    (不补的话会被 Pydantic 静默丢弃,分流无从谈起)
  · 新增 _is_foreign_company(),在**鉴权之后、匹配产品之前**拦截
  · 拦截时返回 200 + matched=False + reason="ignored_company" —— 与「未命中」
    保持同一契约,避免 MOM 侧把它当成故障反复重推

⚠️ 判定刻意做成「只排除已知的外来公司」(黑名单),而非「白名单只认 IRIS」:
   MOM 在无法确定公司归属时会回落到扁平配置,该配置指向本实例 —— 这类消息的
   company_name 会是空 / 缺失。若按白名单把空白也拒掉,它们就彻底丢了:MOM
   那边已收到 200、认为投递成功,不会再重推。同理,未见过的新值也一律照常处理。

reason 的取值 "ignored_company" 与 LICA 实例(~/track-lica)保持一致 —— MOM 侧
不解析它,但排查时两边日志要对着看,字段名不一致会白白浪费时间。

实测:
  · LICA 载荷      → {"ok":true,"matched":false,"reason":"ignored_company"}
  · IRIS/空串/缺失 → 照常处理
  · 无 X-API-Key 仍返回 401(鉴权未被绕过)
2026-09-22 13:43:52 +08:00
42f6e242b4 fix(qrcode): 二维码接口去鉴权,并把 qrcode 路径排除出审计
前端以 <img src="/api/v1/products/qrcode/{sn}"> 引用该端点,而 <img> 无法携带
Authorization 头 —— 加了鉴权会让所有二维码图片加载失败(页面显示成破图),
并在审计里刷出大量 401。

去鉴权是安全的:本函数不查数据库,只校验长度并把这个字符串渲染成二维码,
没有任何业务数据泄露面(序列号本身就是调用方提供的)。也刻意不支持 ?token=
兜底 —— 把 JWT 放进 URL 会渗进访问日志、浏览器历史与 Referer,比它想解决的
问题更糟。

随之而来的副作用必须一并处理:qrcode 路径命中 _TRACKED_READ_PREFIXES 里的
/api/v1/products 前缀、又不是 bare list,会被判成「查看详情」逐条留痕。列表页
一次渲染就并发拉几十张图,逐条留痕会把审计日志塞满,真正有价值的操作反而被
淹没。故把 /api/v1/products/qrcode 加进 _IGNORED_PREFIXES。

实测:二维码正常加载;审计中不再出现 qrcode 记录。
2026-09-22 13:43:36 +08:00
3 changed files with 104 additions and 6 deletions

View File

@ -26,12 +26,21 @@ router = APIRouter(prefix="/products", tags=["产品管理"])
# ============================================================
@router.get("/qrcode/{serial_number}")
async def get_product_qrcode(
serial_number: str,
current_user: dict = Depends(get_current_user),
):
async def get_product_qrcode(serial_number: str):
"""
生成产品二维码PNG 图片)。
⚠️ 本接口【刻意不加鉴权】:
前端以 `<img src="/api/v1/products/qrcode/{sn}">` 引用它,而 <img>
无法携带 Authorization 头 —— 加了鉴权会让所有二维码图片加载失败,
并在审计里刷出大量 401。
不加鉴权是安全的:本函数**不查数据库**,只校验长度并把这个字符串渲染成
二维码,没有任何业务数据泄露面(序列号本身就是调用方提供的)。
也刻意不支持 ?token= 兜底:把 JWT 放进 URL 会渗进访问日志、浏览器历史
与 Referer比它想解决的问题更糟。
内容为 16 位序列号,扫描后可调用 /scan/{serial_number} 查询产品。
尺寸300×300 px用于 PC 端打印或嵌入标签。
"""

View File

@ -11,7 +11,7 @@ from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, Header, HTTPException
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
@ -36,6 +36,7 @@ class MomInboundPayload(BaseModel):
event: str | None = None # 事件名,如 inbound.created / outbound.revoked
action: str | None = None # 显式动作指令,如 revoke_outbound
source_table: str | None = None # stock_product / stock_semi
company_name: str | None = None # 目标公司IRIS / LICAMOM 据此分流到不同 Track 实例
# 「撤回出库」信号词 —— 只在 action / event 里做子串匹配。
@ -44,6 +45,56 @@ class MomInboundPayload(BaseModel):
_OUTBOUND_REVOKE_TOKENS = ("revoke", "rollback", "revert", "cancel")
# ── 公司归属分流 ──────────────────────────────────────────────────────────
# MOM 现在会在载荷里带 company_name同一套物理库可能同时向多个 Track 实例
# IRIS / LICA回调。本实例服务的是 IRIS故只放行 IRIS 与空白值。
#
# ⚠️ 判定刻意做成「只排除已知的外来公司」,而非「白名单只认 IRIS」
# MOM 在无法确定公司归属时会回落到扁平配置,该配置指向本实例 —— 这类
# 消息的 company_name 会是空 / 缺失。若此处按白名单把空白也拒掉,它们
# 就彻底丢了MOM 那边已收到 200、认为投递成功不会再重推。
# 同理,未见过的新值(不是 IRIS 也不是 LICA也一律照常处理。
_FOREIGN_COMPANIES = {"LICA"}
def _is_foreign_company(company_name: str | None) -> bool:
"""载荷是否属于本实例不该处理的其它公司。
返回 True 表示应原样忽略(仍回 200避免 MOM 反复重推)。
"""
# 大小写 / 首尾空白都容忍MOM 侧常量书写方式未必冻结,误判的代价是
# 一条消息被错误地当成本公司处理(有唯一匹配约束,最坏是 matched=False
return (company_name or "").strip().upper() in _FOREIGN_COMPANIES
def _attribute_audit_to_mom_operator(request: Request, operator: str | None) -> None:
"""把外部回调归因到 MOM 侧的实际操作人。
外部回调走 X-API-Key 鉴权、没有 JWT所以 JWT 依赖不执行,
审计中间件读到的 request.state.audit_user 永远是空 ——
操作审计里就出现一堆没有归属的「外部系统对接」记录。
但 MOM 载荷里本来就带着实际操作人operator即 MOM 侧扫码的那位),
写进 request.state 即可让审计归因到人。
⚠️ 必须在 X-API-Key 校验【之后】调用:密钥不对说明载荷本身就不可信,
此时把 operator 写进审计等于允许伪造人。
"""
who = (operator or "").strip()
if not who:
# 取不到操作人时留一个明确的系统标记,而不是继续显示「未认证」——
# 「MOM系统」至少说明这是一次机器回调不是"一个匿名的人"。
request.state.audit_user = "MOM系统"
return
request.state.audit_user = who
try:
# 尽力而为:查不到中文名也不影响审计(前端会回退显示账号)
from app.services.mom_cache import get_display_names
request.state.audit_display_name = get_display_names([who]).get(who) or ""
except Exception: # noqa: BLE001 —— 姓名解析失败绝不能影响回调处理
pass
def _is_outbound_revoke(payload: MomInboundPayload) -> bool:
"""payload 是否携带**显式**的撤回出库信号。
@ -127,6 +178,7 @@ async def _match_inbound_product(
@router.post("/mom-inbound")
async def mom_inbound_webhook(
payload: MomInboundPayload,
request: Request,
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
db: AsyncSession = Depends(get_db),
) -> dict:
@ -136,12 +188,23 @@ async def mom_inbound_webhook(
- 常规入库:用 serial_number优先或 sku 匹配「当前位于 virtual_warehouse」
的产品,命中则标记"已实收"overall_status=已入库 + 记录 task_logs
- 撤回出库MOM 把误出库的设备物理回滚到仓库 → 本接口强制执行特权回滚。
- 公司归属company_name 明确写着其它公司LICA时原样忽略空白 / 缺失
一律照常处理(见 _is_foreign_company 的说明)。
- 未命中返回 200MOM 可能操作了非 Track 生产的物料,直接忽略)。
"""
# ── 鉴权 ──
if not settings.TRACK_WEBHOOK_KEY or x_api_key != settings.TRACK_WEBHOOK_KEY:
raise HTTPException(status_code=401, detail="Unauthorized: invalid X-API-Key")
# ── 公司归属:不是本实例的消息原样忽略(仍回 200避免 MOM 当作失败而重推) ──
# ⚠️ 键名 reason / 取值 "ignored_company" 与 LICA 实例(~/track-lica保持一致
# MOM 侧不解析它,但排查时两边日志对着看,字段名不一致会白白浪费时间。
if _is_foreign_company(payload.company_name):
return {"ok": True, "matched": False, "reason": "ignored_company"}
# 归因到 MOM 侧实际扫码的人(必须在鉴权通过之后,见函数注释)
_attribute_audit_to_mom_operator(request, payload.operator)
explicit_revoke = _is_outbound_revoke(payload)
# ── 匹配产品 ──
@ -329,11 +392,17 @@ class MomOutboundPayload(BaseModel):
sku: str | None = None # 规格型号 spec_modelserial 缺失时的兜底匹配)
operator: str | None = None # 出库操作人(写入 task_logs.operator_id
outbound_time: datetime | None = None # 出库时间
company_name: str | None = None # 目标公司IRIS / LICAMOM 据此分流到不同 Track 实例
# ↓ 2026-09 新增MOM 一直在发、此前被 Pydantic 静默丢弃。当前无人读取,
# 先接住是为了与 LICA 实例(~/track-lica对同一载荷的解析结果保持一致 ——
# 否则将来谁写了读这个字段的代码,会在 LICA 拿到值、在本实例拿到 None。
outbound_type: str | None = None # SALES / USE / PRODUCTION
@router.post("/mom-outbound")
async def mom_outbound_webhook(
payload: MomOutboundPayload,
request: Request,
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
db: AsyncSession = Depends(get_db),
) -> dict:
@ -342,12 +411,23 @@ async def mom_outbound_webhook(
- 鉴权Header X-API-Key 必须等于环境变量 TRACK_WEBHOOK_KEY。
- 用 serial_number优先或 sku 匹配"在仓库/已入库"的产品;
命中则标记"已出库"overall_status=已出库 + status=OUTBOUND + 记录 task_logs
- 公司归属company_name 明确写着其它公司LICA时原样忽略空白 / 缺失
一律照常处理(见 _is_foreign_company 的说明)。
- 未命中返回 200MOM 出库的可能是非 Track 生产的物料,直接忽略)。
"""
# ── 鉴权 ──
if not settings.TRACK_WEBHOOK_KEY or x_api_key != settings.TRACK_WEBHOOK_KEY:
raise HTTPException(status_code=401, detail="Unauthorized: invalid X-API-Key")
# ── 公司归属:不是本实例的消息原样忽略(仍回 200避免 MOM 当作失败而重推) ──
# ⚠️ 键名 reason / 取值 "ignored_company" 与 LICA 实例(~/track-lica保持一致
# MOM 侧不解析它,但排查时两边日志对着看,字段名不一致会白白浪费时间。
if _is_foreign_company(payload.company_name):
return {"ok": True, "matched": False, "reason": "ignored_company"}
# 归因到 MOM 侧实际出库的人(必须在鉴权通过之后,见函数注释)
_attribute_audit_to_mom_operator(request, payload.operator)
# ── 按 serial_number优先或 sku 匹配"在仓库/已入库"的产品 ──
product = None
where_cond = or_(

View File

@ -56,7 +56,16 @@ _TRACKED_READ_PREFIXES = (
)
# 永久忽略的路径前缀
_IGNORED_PREFIXES = ("/health", "/docs", "/redoc", "/openapi.json")
#
# 两类内容:
# 1. 探针与文档(/health、/docs…—— 噪声没有审计价值
# 2. 图片类端点(/api/v1/products/qrcode—— 走 <img src> 加载,
# 一次列表页渲染就会并发拉几十张图,逐条留痕会把审计日志塞满,
# 真正有价值的操作反而被淹没。它也不含业务数据(只渲染二维码图片)。
_IGNORED_PREFIXES = (
"/health", "/docs", "/redoc", "/openapi.json",
"/api/v1/products/qrcode",
)
# 路径段 → 审计模块
_PATH_MODULE: dict[str, str] = {