chore: fork from IRIS track 供 LICA 部门独立运行

- 复制来源: /home/yueli/track @ 192c8ee (feature/ai-audit-update)
- 组织隔离目标: LICA
- 端口规划: 前端 8030 / 后端 8031 / 数据库 8032
- 已排除 deploy.sh、deploy_full.sh、docker-compose.prod.yml(IRIS 生产发布脚本)
- 已排除工作区未提交改动,取干净的 192c8ee 状态
This commit is contained in:
2026-09-21 15:56:52 +08:00
commit 3286a11bc7
212 changed files with 44060 additions and 0 deletions

View File

View File

@ -0,0 +1,234 @@
"""审计采集中间件
在响应生成后,把「谁 / 何时 / 从哪来 / 调了哪个接口 / 做了什么 / 结果如何」
落进 audit_logs。
为什么用中间件自动采集,而不是在每个业务函数里手写 record_audit
------------------------------------------------------------------
1. 手写必然漏。新加的端点很容易忘记补审计,而审计的价值恰恰建立在「完整」上。
现状可佐证task_logs 全项目只有 4 处写入点,凡是不挂在任务上的动作
(登录、导出、改产品)全都没有留痕。
2. 中间件能拿到业务函数拿不到的事实:真实来源 IP、UA、最终状态码、
以及与结构化日志对齐的 request_id。
3. 业务语义module / target由路径推导不如手写精确但对「谁动了什么」
的追责场景已经够用;关键动作后续可再调 record_audit 补 details 做增强。
采集范围
--------
- 所有写操作POST/PUT/PATCH/DELETE
- 少数**读但敏感**的操作:导出、下载、打印(本项目 GET /people-history/export
就是导出,只按方法过滤会漏掉)
明确不采集GET /health*、/docs、/openapi.json —— 探针与文档的噪声没有审计价值。
"""
from __future__ import annotations
import logging
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import Response
from app.services.audit_service import record_audit
logger = logging.getLogger("track.audit")
# 写操作一律采集
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
# 读操作里需要留痕的(导出/下载/打印属于「读」,但把数据带出了系统)
_SENSITIVE_READ_KEYWORDS = frozenset({"export", "download", "print"})
# 核心业务模块 —— 这些前缀下的「查看详情」GET 也采集,
# 用于回答「谁在什么时候看过哪条业务数据」,而不只是「谁改过」。
#
# ⚠️ 只覆盖【核心业务实体】:
# products —— 移动端扫码查询 GET /products/scan/{sn} 是车间最高频的读操作
# tasks —— 查看任务详情 /tasks/{id}
# records —— 任务记录
# notifications / orders —— 见下方 _is_bare_list 的说明
_TRACKED_READ_PREFIXES = (
"/api/v1/notifications",
"/api/v1/tasks",
"/api/v1/orders",
"/api/v1/products",
"/api/v1/records",
)
# 永久忽略的路径前缀
_IGNORED_PREFIXES = ("/health", "/docs", "/redoc", "/openapi.json")
# 路径段 → 审计模块
_PATH_MODULE: dict[str, str] = {
"products": "product",
"tasks": "task",
"orders": "order",
"records": "record",
"print": "print",
"materials": "material",
"users": "user",
"upload": "upload",
"notifications": "notification",
"app-version": "app",
"analytics": "analytics",
"dashboard": "dashboard",
"holidays": "holiday",
"screen": "screen",
"webhooks": "external",
"external": "external",
"audit": "audit",
"auth": "auth",
}
# 路径段 → 动作(优先于按 HTTP 方法推断)
_SEGMENT_ACTION: dict[str, str] = {
"login": "login",
"logout": "logout",
"refresh": "refresh",
"export": "export",
"download": "export",
"print": "print",
"upload": "upload",
"finalize": "finalize",
"receive": "receive",
"transfer": "transfer",
"reject": "reject",
"recall": "recall",
"spawn": "spawn",
"complete": "complete",
"end": "end",
# 消息已读PUT /notifications/{id}/read。
# 没有这一条时会回退到 _METHOD_ACTIONPUT → update → "修改"
# 把"点开一条通知"记成"修改了某样东西",语义完全走样。
"read": "mark_read",
}
_METHOD_ACTION: dict[str, str] = {
"POST": "create",
"PUT": "update",
"PATCH": "update",
"DELETE": "delete",
"GET": "read",
}
# 不可能是业务 ID 的路径段,避免把动作词误当成 target_id
_NON_ID_SEGMENTS = frozenset(
set(_SEGMENT_ACTION) | {"api", "v1", "me", "options", "export", "lookup", "batch"}
)
def _is_bare_list(path: str) -> bool:
"""判断是否只是「拉整个列表」(如 GET /api/v1/tasks/)。
这类请求【不采集】,理由:
· 列表接口被前端高频轮询(消息、任务列表尤其明显),逐条留痕会让
audit_logs 迅速膨胀,真正有价值的操作反而被淹没;
· 「查看详情」(/tasks/{id}) 才代表用户真的点开了某条业务数据。
判定用「去掉末尾斜杠后是否恰好等于某个受跟踪前缀」,
比正则更直观也天然把查询串排除在外request.url.path 不含 ?query
"""
return path.rstrip("/") in _TRACKED_READ_PREFIXES
def _derive_module_and_action(path: str, method: str) -> tuple[str, str, str | None]:
"""由请求路径与 HTTP 方法推导 (module, action, target_id)"""
parts = [p for p in path.split("/") if p]
module = "other"
module_idx = -1
for i, seg in enumerate(parts):
if seg in _PATH_MODULE:
module = _PATH_MODULE[seg]
module_idx = i
break
action = None
for seg in reversed(parts):
if seg in _SEGMENT_ACTION:
action = _SEGMENT_ACTION[seg]
break
if action is None:
action = _METHOD_ACTION.get(method, method.lower())
target_id = None
if module_idx >= 0 and module_idx + 1 < len(parts):
candidate = parts[module_idx + 1]
if candidate not in _NON_ID_SEGMENTS:
target_id = candidate
return module, action, target_id
class AuditMiddleware(BaseHTTPMiddleware):
"""写操作审计采集。
必须注册在 RequestContextMiddleware **内层**,因为它依赖后者写入
request.state 的 request_id 才能与结构化日志对账。
"""
def _should_audit(self, request: Request) -> bool:
path = request.url.path
if path.startswith(_IGNORED_PREFIXES):
return False
if request.method in _MUTATING_METHODS:
return True
if request.method == "GET":
lowered = path.lower()
if any(kw in lowered for kw in _SENSITIVE_READ_KEYWORDS):
return True
# 核心业务数据的「查看详情」也留痕(证明用户在真的使用系统)
if path.startswith(_TRACKED_READ_PREFIXES):
return not _is_bare_list(path)
return False
return False
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
if not self._should_audit(request):
return await call_next(request)
status_code = 500
error_message: str | None = None
try:
response = await call_next(request)
status_code = response.status_code
return response
except Exception as exc:
# 异常最终由 ServerErrorMiddleware 转成 500这里先标记
# 保证「失败的操作也有审计」——这正是选用独立 session 的目的
error_message = f"{type(exc).__name__}: {exc}"[:1000]
raise
finally:
await self._write(request, status_code, error_message)
async def _write(
self, request: Request, status_code: int, error_message: str | None
) -> None:
try:
module, action, target_id = _derive_module_and_action(
request.url.path, request.method
)
client = request.client
await record_audit(
action=action,
module=module,
user_id=getattr(request.state, "audit_user", None),
display_name=getattr(request.state, "audit_display_name", None),
role=getattr(request.state, "audit_role", None),
target_type=module,
target_id=target_id,
# 对产品而言路径里的 ID 就是身份证号,本身即人可读的标识
target_name=target_id if module == "product" else None,
ip_address=client.host if client else None,
user_agent=request.headers.get("user-agent"),
method=request.method,
url=request.url.path,
status_code=status_code,
error_message=error_message,
request_id=getattr(request.state, "request_id", None),
)
except Exception:
# record_audit 内部已兜底;这里再兜一层,确保审计绝不冒泡成 500
logger.exception("审计采集失败(已忽略)")

View File

@ -0,0 +1,60 @@
"""核心配置 — Pydantic Settings 自动从 .env 读取"""
import json
from pydantic import model_validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# ---- 数据库 ----
DATABASE_URL: str = "postgresql+asyncpg://track:track_prod_2026@localhost:5433/track_production"
# ---- JWT ----
SECRET_KEY: str = "change-me-in-production"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 120 # Access Token: 2 小时
REFRESH_TOKEN_EXPIRE_DAYS: int = 7 # Refresh Token: 7 天
# ---- 调试 ----
DEBUG: bool = True
# ---- 应用元信息 ----
APP_VERSION: str = "1.0.0"
# ---- 日志 ----
LOG_LEVEL: str = "INFO"
LOG_JSON: bool = True # 生产保持 True便于采集本地调试可设 False 换可读格式
# ---- 错误追踪(可选,不装 sentry-sdk 则自动跳过)----
SENTRY_DSN: str | None = None
SENTRY_TRACES_SAMPLE_RATE: float = 0.0
# ---- CORS 跨域白名单JSON 数组字符串,直接从 .env 的 CORS_ORIGINS 读取) ----
CORS_ORIGINS: str = '["http://localhost:1420", "tauri://localhost"]'
# ---- MOM 仓储系统回调 WebhookTrack 作为接收方,验签用) ----
TRACK_WEBHOOK_KEY: str | None = None # MOM 回调 POST 时 Header X-API-Key 须等于此值
@property
def CORS_ORIGINS_LIST(self) -> list[str]:
"""将 JSON 字符串解析为 Python list供 CORSMiddleware 使用"""
try:
return json.loads(self.CORS_ORIGINS)
except (json.JSONDecodeError, TypeError):
return ["http://localhost:1420", "tauri://localhost"]
@model_validator(mode="after")
def _validate_production_secret(self):
"""生产环境强制校验SECRET_KEY 禁止使用默认值"""
if not self.DEBUG and self.SECRET_KEY == "change-me-in-production":
raise ValueError(
"生产环境 (DEBUG=False) 禁止使用默认 SECRET_KEY。"
"请在 .env 中设置 SECRET_KEY 为至少 32 字符的随机值。"
"示例: python -c \"import secrets; print(secrets.token_urlsafe(32))\""
)
return self
class Config:
env_file = ".env"
extra = "ignore"
settings = Settings()

View File

@ -0,0 +1,33 @@
"""数据库连接 — 异步引擎 + 连接池"""
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from app.core.config import settings
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
pool_size=20, # 连接池常驻连接数
max_overflow=10, # 超出 pool_size 时最多再创建的连接数
pool_recycle=3600, # 连接回收时间(秒),防止 MySQL 8 小时断连
pool_pre_ping=True, # 每次取出连接前先 ping 检测可用性
connect_args={
"server_settings": {"TimeZone": "Asia/Shanghai"}, # PG 会话级北京时间
},
)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
async def get_db() -> AsyncSession:
"""FastAPI 依赖注入:每次请求获取一个数据库会话(带事务安全兜底)"""
async with AsyncSessionLocal() as session:
try:
yield session
except Exception:
await session.rollback()
raise
finally:
await session.close()

35
backend/app/core/deps.py Normal file
View File

@ -0,0 +1,35 @@
"""通用 FastAPI 依赖"""
from __future__ import annotations
from fastapi import Depends, HTTPException, status
from app.core.roles import ADMIN_ROLES
from app.services.auth_service import get_current_user
def require_roles(*roles: str):
"""生成「限定角色」依赖,避免同一个内联判断被复制到每个端点。
用法::
@router.get("/x")
async def x(current_user: dict = Depends(require_admin)):
...
失败一律 403 且不透露允许的角色集合(避免给探测者提供线索)。
"""
allowed = frozenset(roles)
async def _guard(current_user: dict = Depends(get_current_user)) -> dict:
if (current_user or {}).get("role") not in allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="当前角色无权访问该接口",
)
return current_user
return _guard
# 审计日志等高权限接口复用同一实例
require_admin = require_roles(*ADMIN_ROLES)

View File

@ -0,0 +1,93 @@
"""健康检查 — 存活探针与就绪探针分离
为什么必须拆开:
- 存活探针liveness只回答「进程还活着吗」绝不能探测外部依赖。
否则数据库抖一下,编排系统会判定进程已死并反复重启容器,
把一次依赖故障放大成全站雪崩。
- 就绪探针readiness回答「现在能对外服务吗」。依赖不可用时返回 503
由负载均衡把该实例摘掉,依赖恢复后自动回来。
"""
from __future__ import annotations
import logging
import anyio
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from sqlalchemy import text
from app.core.config import settings
from app.core.database import AsyncSessionLocal
from app.core.mom_database import mom_engine
logger = logging.getLogger("track.health")
router = APIRouter(tags=["健康检查"])
async def _probe_primary_db() -> bool:
"""主库探活 — 业务强依赖,失败即不就绪"""
try:
async with AsyncSessionLocal() as session:
await session.execute(text("SELECT 1"))
return True
except Exception:
logger.exception("主库探活失败")
return False
def _probe_mom_db_sync() -> bool:
try:
with mom_engine.connect() as conn:
conn.execute(text("SELECT 1"))
return True
except Exception:
logger.exception("MOM 库探活失败")
return False
async def _probe_mom_db() -> bool:
# MOM 用的是同步引擎,放线程池执行,避免阻塞事件循环
return await anyio.to_thread.run_sync(_probe_mom_db_sync)
async def _collect() -> tuple[bool, dict[str, str]]:
primary_ok = await _probe_primary_db()
mom_ok = await _probe_mom_db()
checks = {
"database": "ok" if primary_ok else "fail",
# MOM 是外部只读依赖:挂掉时登录/选料降级,但扫码、流转、看板仍可用。
# 因此只标记 degraded、不摘流量 —— 否则 MOM 一抖就让在产车间全线停摆。
"mom_database": "ok" if mom_ok else "degraded",
}
return primary_ok, checks
@router.get("/health/live", include_in_schema=False)
async def liveness() -> dict:
"""存活探针:不触碰任何依赖,恒定快速返回"""
return {"status": "ok"}
@router.get("/health/ready", include_in_schema=False)
async def readiness() -> JSONResponse:
"""就绪探针:主库不可用时返回 503让负载均衡摘流量"""
ready, checks = await _collect()
return JSONResponse(
{"status": "ready" if ready else "not_ready", "checks": checks},
status_code=200 if ready else 503,
)
@router.get("/health", include_in_schema=False)
async def health() -> JSONResponse:
"""兼容旧监控脚本:语义等同就绪探针,并附带版本号"""
ready, checks = await _collect()
return JSONResponse(
{
"status": "ok" if ready else "unavailable",
"version": settings.APP_VERSION,
"checks": checks,
},
status_code=200 if ready else 503,
)

View File

@ -0,0 +1,196 @@
"""生命周期阶段Lifecycle Phase词汇表与选项隔离 — 单一事实来源
生产制造PRODUCTION与售后回流AFTER_SALES各自拥有一套合法的
宏观状态 / 工序名。回流设备绝不允许被重新排产回「备货 / 生产」等前期环节,
本模块集中定义两套词表并提供校验,前端各端复制同一份口径即可。
设计要点
--------
1. 售后阶段使用**独立工序名**(发货测试 / 售后维修),不复用生产阶段的
「测试 / 维修」。这样报表、看板、导出无需 JOIN lifecycle_phase 就能区分,
也不会出现"同一个词两种含义"
2. 选定售后专属工序 = 设备进入售后生命周期,单向不可回退。
这条规则同时覆盖两类设备:
- 已出库后被重新派发任务(由 task_service 的 outbound 判定捕获)
- 无任何历史记录、直接走售后流程的老设备(首次选定售后工序即判定)
3. 「待确认」与仓库虚拟节点是建单占位符,不参与阶段校验,否则会卡死建单流程。
"""
from __future__ import annotations
# ============================================================
# 生命周期阶段
# ============================================================
LIFECYCLE_PRODUCTION = "PRODUCTION" # 生产制造阶段(发货前)
LIFECYCLE_AFTER_SALES = "AFTER_SALES" # 已出库后再次回流返厂(售后)
PHASE_LABELS = {
LIFECYCLE_PRODUCTION: "生产制造阶段",
LIFECYCLE_AFTER_SALES: "售后回流阶段",
}
# ============================================================
# 售后专属工序名
# ============================================================
STEP_SHIP_TEST = "发货测试" # 出厂 / 发货前测试
STEP_AFTER_SALES_REPAIR = "售后维修" # 售后返修本体
# 售后区独立成列所用的两个工序名(前端分列 / 大屏返厂统计仍以它为准)
AFTER_SALES_ONLY_STEPS = frozenset({STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR})
# 🔧 工序语义细分2026-09-17——「售后专属」不等于「回流返厂」
# · 出厂质检(发货测试):设备可能压根没回厂,只是补做发货前测试。
# 它【不】构成回流信号,选中它不应把设备永久烙进售后生命周期。
# · 明确修机指令(售后维修):设备确实是回厂返修 → 判定回流。
# 历史缺陷:两者混为一谈,导致「已出库 + 发货测试」被打上不可回退的
# AFTER_SALES 烙印(售后死锁),而「已出库」的物理终态也被抹掉。
OUTBOUND_QC_STEPS = frozenset({STEP_SHIP_TEST})
AFTER_SALES_REPAIR_STEPS = frozenset({STEP_AFTER_SALES_REPAIR})
# ============================================================
# 各阶段合法的工序名 / 宏观状态名
# ============================================================
# ── 生产制造阶段 ──
# 🔧 2026-09-17 补入「发货测试」:出厂质检现在被允许在生产阶段执行
# (设备的 lifecycle_phase 不再因选中它而翻成售后)。若不补,
# _enforce_step_isolation 会把「已出库设备的发货测试」直接 400 掉。
PRODUCTION_TASK_STEPS = ("备货", "生产", "测试", "维修", "在库")
PRODUCTION_OVERALL_STEPS = (
"备货", "生产", "测试", "维修", "在库", "待仓库收货", "已入库", "已出库",
STEP_SHIP_TEST,
)
# ── 售后回流阶段:只保留「发货测试 / 售后维修 / 入库出库」──
AFTER_SALES_TASK_STEPS = (STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库")
AFTER_SALES_OVERALL_STEPS = (
STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库", "待仓库收货", "已入库", "已出库",
)
# 仅属于生产制造阶段的工序名 — 售后设备出现即视为"跨阶段误排"。
# 注意:「在库 / 已入库 / 已出库」是两阶段通用的,不在此列。
PRODUCTION_ONLY_STEPS = frozenset({"备货", "生产", "测试", "维修"})
# 建单占位工序名 — 建单时为「待确认」,接收时才由操作员选定真实工序
PLACEHOLDER_STEPS = frozenset({"待确认"})
# 仓库虚拟节点标记(转交入库时作为 assignee 传递)
VIRTUAL_WAREHOUSE = "virtual_warehouse"
# 两阶段并集 — 用于"完全非法取值"的第一道粗筛
ALL_OVERALL_STEPS = frozenset(PRODUCTION_OVERALL_STEPS) | frozenset(AFTER_SALES_OVERALL_STEPS)
_ALLOWED_BY_PHASE = {
LIFECYCLE_PRODUCTION: frozenset(PRODUCTION_OVERALL_STEPS),
LIFECYCLE_AFTER_SALES: frozenset(AFTER_SALES_OVERALL_STEPS),
}
# ============================================================
# 查询 / 校验
# ============================================================
def phase_label(phase: str | None) -> str:
"""阶段的中文名(用于报错文案)"""
return PHASE_LABELS.get(phase or "", PHASE_LABELS[LIFECYCLE_PRODUCTION])
def allowed_steps(phase: str | None) -> tuple[str, ...]:
"""该阶段合法的全部工序/状态名"""
if (phase or "") == LIFECYCLE_AFTER_SALES:
return AFTER_SALES_OVERALL_STEPS
return PRODUCTION_OVERALL_STEPS
def is_placeholder_step(step: str | None) -> bool:
"""建单占位符(待确认)/ 仓库虚拟节点 — 不做阶段校验
否则 create_task 会被「待确认」卡住,转交入库会被
「🏭 入库 (virtual_warehouse)」卡住,整条流程直接断掉。
"""
if not step:
return True
return step in PLACEHOLDER_STEPS or VIRTUAL_WAREHOUSE in step
def resolve_phase_for_step(current_phase: str | None, step: str | None) -> str:
"""选定【明确修机指令】→ 设备进入售后生命周期(单向,不可回退)
⚠️ 只有「售后维修」触发,出厂质检(发货测试)【不】触发。
「已出库的设备补做发货测试」不代表它回厂返修;若在此烙印,
设备会被永久打成售后(该标志单向不可回退),这就是售后死锁的第二个入口。
这是"无历史记录的老设备"进入售后阶段的入口:首次选定「售后维修」即判定回流。
"""
if step and step.strip() in AFTER_SALES_REPAIR_STEPS:
return LIFECYCLE_AFTER_SALES
return current_phase or LIFECYCLE_PRODUCTION
def is_step_allowed(phase: str | None, step: str | None) -> bool:
"""工序名在该生命周期阶段下是否合法"""
if is_placeholder_step(step):
return True
allowed = _ALLOWED_BY_PHASE.get(
phase or LIFECYCLE_PRODUCTION, _ALLOWED_BY_PHASE[LIFECYCLE_PRODUCTION],
)
return step.strip() in allowed
# 老数据兼容:售后设备的工序曾沿用生产阶段的「测试 / 维修」写法
# (售后独立工序名是后加的)。统计/看板展示时折算到售后区的独立工序名,
# 否则售后设备的历史工序会混进生产区的同名工序,导致统计失真。
AFTER_SALES_STEP_ALIAS = {
"测试": STEP_SHIP_TEST,
"维修": STEP_AFTER_SALES_REPAIR,
}
def normalize_after_sales_step(phase: str | None, step: str | None) -> str | None:
"""售后回流设备的历史工序名归一 —— 折算到售后区的独立工序名。
仅对 AFTER_SALES 生效;生产设备的「测试 / 维修」原样保留。
统计口径WIP 矩阵 / 下钻)必须与展示口径一致,故集中在模块内。
"""
if (phase or "") != LIFECYCLE_AFTER_SALES or not step:
return step
return AFTER_SALES_STEP_ALIAS.get(step.strip(), step)
# ============================================================
# overall_status中文宏观状态 ⇄ product.status英文状态码
# ============================================================
# 这两个字段必须始终同步。历史缺陷receive_task / transfer_task 只改
# overall_status 不改 status导致设备出库回流后 status 永远停在 OUTBOUND
# 统计口径被污染WIP 矩阵曾因此把正在做「发货测试」的设备吞进「已出库」列)。
# 所有改写 overall_status 的地方一律调 sync_product_status()。
# 终态映射;不在表内的(备货/生产/测试/维修/发货测试/售后维修…)都是活跃工序 → WIP
OVERALL_TO_PRODUCT_STATUS = {
"已入库": "ARCHIVED",
"在库": "ARCHIVED",
"已出库": "OUTBOUND",
"待仓库收货": "COMPLETED",
}
DEFAULT_PRODUCT_STATUS = "WIP"
def overall_to_product_status(overall: str | None) -> str:
"""中文宏观状态 → product.status 状态码。
活跃工序(含售后「发货测试 / 售后维修」)不在映射表里,统一落到 WIP ——
这正是消除「status 残留在 OUTBOUND」的关键。
"""
if not overall:
return DEFAULT_PRODUCT_STATUS
return OVERALL_TO_PRODUCT_STATUS.get(overall.strip(), DEFAULT_PRODUCT_STATUS)
def sync_product_status(product) -> str:
"""把 product.status 与 overall_status 对齐,返回落定的状态码。
刻意用 duck-typing 而非导入 ORM 模型,避免 core 层反向依赖 models。
调用点:任何改写 product.overall_status 之后都必须调一次。
"""
product.status = overall_to_product_status(getattr(product, "overall_status", None))
return product.status

View File

@ -0,0 +1,89 @@
"""结构化日志 — 单行 JSON 输出 + 请求上下文注入
设计要点:
1. 零第三方依赖,只用 stdliblogging + json + contextvars
2. 业务代码通过 `extra={"extra_fields": {...}}` 附加结构化字段,
不要把可检索的字段拼进 msg 字符串 —— 拼进去就只能靠正则捞了。
3. request_id / user 走 contextvar。contextvar 在 asyncio 下按任务隔离,
并发请求之间不会串号;由 RequestContextMiddleware 与 get_current_user 写入。
"""
from __future__ import annotations
import json
import logging
import sys
from contextvars import ContextVar
from datetime import datetime, timezone
# 请求级上下文
request_id_var: ContextVar[str | None] = ContextVar("request_id", default=None)
user_var: ContextVar[str | None] = ContextVar("user", default=None)
class _ContextFilter(logging.Filter):
"""把 contextvar 注入每条 record使 JSON 自带 request_id / user"""
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = request_id_var.get()
record.user = user_var.get()
return True
class JsonFormatter(logging.Formatter):
"""单行 JSON — 便于 Loki / ELK / CloudWatch 直接解析,无需正则"""
def format(self, record: logging.LogRecord) -> str:
payload: dict = {
"ts": datetime.fromtimestamp(record.created, timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
}
if getattr(record, "request_id", None):
payload["request_id"] = record.request_id
if getattr(record, "user", None):
payload["user"] = record.user
payload.update(getattr(record, "extra_fields", None) or {})
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False, default=str)
class TextFormatter(logging.Formatter):
"""本地开发可读格式LOG_JSON=false 时启用)"""
def format(self, record: logging.LogRecord) -> str:
line = (
f"{self.formatTime(record, '%H:%M:%S')} "
f"{record.levelname:<5} {record.name} - {record.getMessage()}"
)
extras = getattr(record, "extra_fields", None)
if extras:
line += " | " + " ".join(f"{k}={v}" for k, v in extras.items())
if record.exc_info:
line += "\n" + self.formatException(record.exc_info)
return line
def setup_logging(level: str = "INFO", json_output: bool = True) -> None:
"""配置根 logger。必须在应用启动前调用一次。"""
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter() if json_output else TextFormatter())
handler.addFilter(_ContextFilter())
root = logging.getLogger()
# 清空既有 handleruvicorn --reload / 多 worker 下模块可能被重复导入,
# 不清会看到每条日志打印 N 遍
root.handlers.clear()
root.addHandler(handler)
root.setLevel(level.upper())
# uvicorn 自带 handler 会绕过上面的 formatter必须清掉并让它向根传播
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
lg = logging.getLogger(name)
lg.handlers.clear()
lg.propagate = True
# 访问日志统一由 RequestContextMiddleware 输出(含耗时 / 用户 / request_id
# 故关闭 uvicorn 自带的访问日志,避免重复
logging.getLogger("uvicorn.access").disabled = True

View File

@ -0,0 +1,103 @@
"""请求上下文中间件 — request_id 生成/透传 + 结构化访问日志"""
from __future__ import annotations
import logging
import time
import uuid
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from app.core.logging import request_id_var, user_var
from app.services.audit_service import touch_daily_seen
access_log = logging.getLogger("track.access")
# 探针被高频轮询,降级为 DEBUG 避免把有价值的信息淹掉
_QUIET_PATHS = frozenset({"/health", "/health/live", "/health/ready"})
class RequestContextMiddleware(BaseHTTPMiddleware):
"""为每个请求建立可追踪上下文。
- request_id优先沿用上游网关传来的 X-Request-ID实现全链路追踪
没有就生成一个。响应头回写该 ID前端报错时可直接带上
运维拿 ID 就能在日志里精确定位到这一次请求。
- 访问日志method / path / status / duration_ms / client / user。
"""
async def dispatch(self, request: Request, call_next):
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex
# 同时写入 request.state它由 ASGI scope 承载,作用域比 contextvar 更长。
# FastAPI 把 Exception 处理器交给 ServerErrorMiddleware位于本中间件外层
# 异常传播到那里时 contextvar 已在 finally 中被重置,只有 state 还留着 ID。
request.state.request_id = request_id
rid_token = request_id_var.set(request_id)
user_token = user_var.set(None)
started = time.perf_counter()
logged = False
status_code = 500
try:
response = await call_next(request)
status_code = response.status_code
response.headers["X-Request-ID"] = request_id
self._log_access(request, status_code, started)
logged = True
await self._touch_activity(request)
return response
finally:
# 异常路径也要留下访问记录,否则接口 500 时日志里反而没有痕迹
if not logged:
self._log_access(request, status_code, started)
await self._touch_activity(request)
request_id_var.reset(rid_token)
user_var.reset(user_token)
async def _touch_activity(self, request: Request) -> None:
"""记录「该用户今天活动过」,供日活报表算上线/下线时间。
为什么挂在这一层:本中间件是最外层,能覆盖**所有**请求 ——
包括不被审计的普通 GET。而审计中间件只记写操作当天只翻看、
没做写操作的人会被日活完全漏掉。
user 同样只能从 request.state 取:本中间件在独立 task 中执行,
路由内写的 contextvar 不会回流(详见 _log_access 的说明)。
未认证请求取不到 user自然跳过。
"""
await touch_daily_seen(getattr(request.state, "audit_user", None))
def _log_access(self, request: Request, status_code: int, started: float) -> None:
path = request.url.path
duration_ms = round((time.perf_counter() - started) * 1000, 1)
# user 必须从 request.state 取:本中间件在独立 task 中执行,路由内
# 写入的 contextvar 不会回流到这里(详见 get_current_user 的说明)。
user = getattr(request.state, "audit_user", None) or user_var.get()
if status_code >= 500:
level = logging.ERROR
elif status_code >= 400:
level = logging.WARNING
elif path in _QUIET_PATHS:
level = logging.DEBUG
else:
level = logging.INFO
access_log.log(
level,
"%s %s -> %s (%.1fms)",
request.method,
path,
status_code,
duration_ms,
extra={
"extra_fields": {
"method": request.method,
"path": path,
"status": status_code,
"duration_ms": duration_ms,
"client": request.client.host if request.client else None,
"user": user,
}
},
)

View File

@ -0,0 +1,24 @@
"""MOM 系统数据库 — 只读连接,用于登录验证 sys_user 表"""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy.pool import NullPool
# MOM 数据库连接(同步引擎,仅用于登录验证)
import os
# Docker 容器内用 host.docker.internal 访问宿主机
MOM_HOST = os.environ.get("MOM_DB_HOST", "host.docker.internal")
MOM_PORT = os.environ.get("MOM_DB_PORT", "5435")
MOM_DATABASE_URL = f"postgresql://prod_user:StrongPassword123!@{MOM_HOST}:{MOM_PORT}/inventory_system"
mom_engine = create_engine(
MOM_DATABASE_URL,
echo=False,
poolclass=NullPool, # 登录频率低,不用连接池
)
MomSessionLocal = sessionmaker(
mom_engine,
class_=Session,
autocommit=False,
autoflush=False,
)

31
backend/app/core/roles.py Normal file
View File

@ -0,0 +1,31 @@
"""角色定义与管理员判定 —— 单一事实来源
背景:角色字符串此前散落在至少三处 —— task_service.ADMIN_ROLES、
products.py 的内联判断、以及前端 constants/task.ts。同一份规则抄多份的后果
已经发生过:前端 constants/task.ts:233 的注释记录了一次「移动端只判了
SUPER_ADMIN、漏了 SUPERVISOR导致主管被误挡」的事故。
本模块把**角色常量与管理员判定**先收敛到一处,供后端统一引用。
完整的「角色 × 权限点」可配置矩阵是后续工作;但任何推进都应从这里出发,
不要再新增第四份副本。
"""
from __future__ import annotations
SUPER_ADMIN = "SUPER_ADMIN"
SUPERVISOR = "SUPERVISOR"
# 注意MOM 登录返回的默认角色是小写 operator见 auth_service.login
OPERATOR = "OPERATOR"
# 管理员角色:可执行收口、审计查看等高权限动作
ADMIN_ROLES: frozenset[str] = frozenset({SUPER_ADMIN, SUPERVISOR})
ROLE_LABELS: dict[str, str] = {
SUPER_ADMIN: "超级管理员",
SUPERVISOR: "主管",
OPERATOR: "操作员",
}
def is_admin(role: str | None) -> bool:
"""role 为 None / 未知值一律视为无权限fail-closed不做兜底放行"""
return role in ADMIN_ROLES

View File

@ -0,0 +1,71 @@
"""安全模块 — JWT Token 生成与验证(双 Token 架构)"""
from datetime import timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import settings
from app.core.time_utils import get_beijing_time
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
# Token 类型声明
TOKEN_TYPE_ACCESS = "access"
TOKEN_TYPE_REFRESH = "refresh"
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
"""生成 JWT Access Token2 小时有效)"""
to_encode = data.copy()
expire = get_beijing_time() + (
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire, "type": TOKEN_TYPE_ACCESS})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
def create_refresh_token(data: dict, expires_delta: timedelta | None = None) -> str:
"""生成 JWT Refresh Token7 天有效,仅用于刷新 Access Token"""
to_encode = data.copy()
expire = get_beijing_time() + (
expires_delta or timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
)
to_encode.update({"exp": expire, "type": TOKEN_TYPE_REFRESH})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
def decode_token(token: str) -> dict:
"""解码并验证 JWT Token返回 payload"""
return jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
def peek_token_identity(token: str) -> dict | None:
"""读出令牌里的用户身份 —— **仅供审计标注,绝不可用于授权**。
与 decode_token 的唯一区别:**关闭过期校验**。
为什么需要它:刷新令牌接口正是"access token 过期了才来"的场景,
请求里不带 Authorization 头JWT 依赖根本不执行,审计只能记成
「未认证」—— 而"谁在什么时候尝试刷新"恰恰是该留痕的信息。
签名校验照常进行,伪造的令牌解不出任何东西。
⚠️ 返回值只允许写进 request.state 的审计字段;
任何鉴权判断一律走 get_current_user不要用本函数。
"""
try:
return jwt.decode(
token, settings.SECRET_KEY, algorithms=[ALGORITHM],
options={"verify_exp": False},
)
except JWTError:
return None
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证明文密码 vs 哈希密码"""
return pwd_context.verify(plain_password, hashed_password)
def hash_password(password: str) -> str:
"""对明文密码进行哈希"""
return pwd_context.hash(password)

View File

@ -0,0 +1,55 @@
"""全局北京时间 (UTC+8) 与工作日时长计算"""
from datetime import datetime, date, time, timedelta, timezone
from zoneinfo import ZoneInfo
BEIJING_TZ = ZoneInfo("Asia/Shanghai")
def get_beijing_time() -> datetime:
"""返回当前北京时间"""
return datetime.now(BEIJING_TZ)
def to_beijing(dt: datetime | None) -> datetime | None:
"""将任意 datetime 统一转为北京时间 aware。
- naive 时间按 UTC 处理(数据库 timestamptz 实存 UTCSQLAlchemy 读出常为 naive
- 带时区时间直接 astimezone 到北京
"""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc).astimezone(BEIJING_TZ)
return dt.astimezone(BEIJING_TZ)
def working_duration_hours(
start: datetime | None,
end: datetime | None,
holidays: set[date] | None = None,
) -> float:
"""计算 start~end 之间排除周末与节假日的工作小时数。
- 周末(周六/周日)整天排除
- holidays 中配置的放假日期整天排除
- 其余日期按 24 小时连续计(一天内的时间都算)
- start/end 可为 naive按 UTC 转)或 aware 北京时间
"""
if not holidays:
holidays = set()
s = to_beijing(start)
e = to_beijing(end)
if s is None or e is None or e <= s:
return 0.0
total = 0.0
day = s.date()
last = e.date()
while day <= last:
if day.weekday() < 5 and day not in holidays:
seg_start = max(s, datetime.combine(day, time.min, tzinfo=BEIJING_TZ))
seg_end = min(e, datetime.combine(day, time.max, tzinfo=BEIJING_TZ))
if seg_end > seg_start:
total += (seg_end - seg_start).total_seconds() / 3600
day += timedelta(days=1)
return round(total, 1)