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:
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
234
backend/app/core/audit_middleware.py
Normal file
234
backend/app/core/audit_middleware.py
Normal 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_ACTION(PUT → 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("审计采集失败(已忽略)")
|
||||
60
backend/app/core/config.py
Normal file
60
backend/app/core/config.py
Normal 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 仓储系统回调 Webhook(Track 作为接收方,验签用) ----
|
||||
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()
|
||||
33
backend/app/core/database.py
Normal file
33
backend/app/core/database.py
Normal 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
35
backend/app/core/deps.py
Normal 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)
|
||||
93
backend/app/core/health.py
Normal file
93
backend/app/core/health.py
Normal 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,
|
||||
)
|
||||
196
backend/app/core/lifecycle.py
Normal file
196
backend/app/core/lifecycle.py
Normal 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
|
||||
89
backend/app/core/logging.py
Normal file
89
backend/app/core/logging.py
Normal file
@ -0,0 +1,89 @@
|
||||
"""结构化日志 — 单行 JSON 输出 + 请求上下文注入
|
||||
|
||||
设计要点:
|
||||
1. 零第三方依赖,只用 stdlib(logging + 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()
|
||||
# 清空既有 handler:uvicorn --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
|
||||
103
backend/app/core/middleware.py
Normal file
103
backend/app/core/middleware.py
Normal 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,
|
||||
}
|
||||
},
|
||||
)
|
||||
24
backend/app/core/mom_database.py
Normal file
24
backend/app/core/mom_database.py
Normal 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
31
backend/app/core/roles.py
Normal 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
|
||||
71
backend/app/core/security.py
Normal file
71
backend/app/core/security.py
Normal 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 Token(2 小时有效)"""
|
||||
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 Token(7 天有效,仅用于刷新 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)
|
||||
55
backend/app/core/time_utils.py
Normal file
55
backend/app/core/time_utils.py
Normal 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 实存 UTC,SQLAlchemy 读出常为 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)
|
||||
Reference in New Issue
Block a user