Compare commits
8 Commits
42a67bfa3a
...
192c8ee9cc
| Author | SHA1 | Date | |
|---|---|---|---|
| 192c8ee9cc | |||
| 3f34652b07 | |||
| df3f914eb1 | |||
| c3667fe00d | |||
| 39697ca3ad | |||
| 1fea30b03b | |||
| 5290d83463 | |||
| 17b2fab5ca |
53
backend/alembic/versions/k1l2m3n4o5p6_add_user_daily_seen.py
Normal file
53
backend/alembic/versions/k1l2m3n4o5p6_add_user_daily_seen.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
"""add_user_daily_seen
|
||||||
|
|
||||||
|
Revision ID: k1l2m3n4o5p6
|
||||||
|
Revises: j1k2l3m4n5o6
|
||||||
|
Create Date: 2026-09-21
|
||||||
|
|
||||||
|
每日用户活动表(user_daily_seen)
|
||||||
|
--------------------------------
|
||||||
|
一天一人一行,记录当天首次 / 末次活动时刻,供日活报表计算
|
||||||
|
「上线时间 / 下线时间」。
|
||||||
|
|
||||||
|
为什么不复用 audit_logs:
|
||||||
|
· 上线/下线时间不能取登录时间 —— Refresh Token 有效期 7 天,用户不必每天
|
||||||
|
重新登录,「登录次数 0 却操作 35 次」的报表没有意义。
|
||||||
|
· 也不能只取写操作时间 —— 审计中间件只记写操作,普通 GET 不入账,
|
||||||
|
当天只翻看的人会被漏掉。
|
||||||
|
· 更不能把活动写进审计表 —— 「末次活动」是需要不断 UPDATE 的状态,
|
||||||
|
而审计流水必须只增不改;能改的审计记录等于没有审计价值。
|
||||||
|
|
||||||
|
存量数据无需回填:本表从上线时刻开始记录;日活接口对更早的日期会自动
|
||||||
|
回退到审计表的写操作时间去推算。
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "k1l2m3n4o5p6"
|
||||||
|
down_revision: Union[str, None] = "j1k2l3m4n5o6"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"user_daily_seen",
|
||||||
|
sa.Column("user_id", sa.String(64), primary_key=True,
|
||||||
|
comment="操作人账号(逻辑外键→MOM)"),
|
||||||
|
sa.Column("day", sa.Date(), primary_key=True,
|
||||||
|
comment="北京时间自然日"),
|
||||||
|
sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
comment="当天首次活动时刻"),
|
||||||
|
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
comment="当天末次活动时刻"),
|
||||||
|
)
|
||||||
|
# 日活查询按日期区间扫,给 day 单独建索引。
|
||||||
|
# (主键是 (user_id, day),前缀是 user_id,按 day 过滤用不上,故需补一条)
|
||||||
|
op.create_index("ix_user_daily_seen_day", "user_daily_seen", ["day"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_user_daily_seen_day", table_name="user_daily_seen")
|
||||||
|
op.drop_table("user_daily_seen")
|
||||||
@ -3,22 +3,29 @@
|
|||||||
与 MOM(KCGL) /audit/logs 的接口保持同构的筛选维度(操作人/模块/动作/目标/
|
与 MOM(KCGL) /audit/logs 的接口保持同构的筛选维度(操作人/模块/动作/目标/
|
||||||
时间区间),便于两端运维习惯统一;额外提供 request_id 筛选,可凭它直接跳到
|
时间区间),便于两端运维习惯统一;额外提供 request_id 筛选,可凭它直接跳到
|
||||||
结构化日志里的那一次请求。
|
结构化日志里的那一次请求。
|
||||||
|
|
||||||
|
另提供两个 CSV 导出端点(审计明细 / 日活统计),均支持按列导出。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
from datetime import datetime, time, timedelta
|
from datetime import datetime, time, timedelta
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query, Response
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.deps import require_admin
|
from app.core.deps import require_admin
|
||||||
from app.core.time_utils import BEIJING_TZ
|
from app.core.time_utils import BEIJING_TZ, get_beijing_time
|
||||||
from app.schemas.audit import (
|
from app.schemas.audit import (
|
||||||
AuditLogListResponse,
|
AuditLogListResponse,
|
||||||
AuditLogResponse,
|
AuditLogResponse,
|
||||||
AuditOption,
|
AuditOption,
|
||||||
AuditOptionsResponse,
|
AuditOptionsResponse,
|
||||||
|
DailyUsageResponse,
|
||||||
|
DailyUsageRow,
|
||||||
)
|
)
|
||||||
from app.services import audit_service
|
from app.services import audit_service
|
||||||
from app.services.audit_service import ACTION_LABELS, MODULE_LABELS
|
from app.services.audit_service import ACTION_LABELS, MODULE_LABELS
|
||||||
@ -92,8 +99,194 @@ async def get_audit_logs(
|
|||||||
async def get_audit_options(
|
async def get_audit_options(
|
||||||
current_user: dict = Depends(require_admin),
|
current_user: dict = Depends(require_admin),
|
||||||
) -> AuditOptionsResponse:
|
) -> AuditOptionsResponse:
|
||||||
"""筛选项:模块与动作的中文下拉"""
|
"""筛选项:模块与动作的中文下拉;顺带下发导出可选列"""
|
||||||
return AuditOptionsResponse(
|
return AuditOptionsResponse(
|
||||||
modules=[AuditOption(value=k, label=v) for k, v in MODULE_LABELS.items()],
|
modules=[AuditOption(value=k, label=v) for k, v in MODULE_LABELS.items()],
|
||||||
actions=[AuditOption(value=k, label=v) for k, v in ACTION_LABELS.items()],
|
actions=[AuditOption(value=k, label=v) for k, v in ACTION_LABELS.items()],
|
||||||
|
log_export_columns=[
|
||||||
|
AuditOption(value=k, label=v[0]) for k, v in _AUDIT_LOG_COLUMNS.items()
|
||||||
|
],
|
||||||
|
usage_export_columns=[
|
||||||
|
AuditOption(value=k, label=v[0]) for k, v in _DAILY_USAGE_COLUMNS.items()
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# CSV 导出
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _bj(dt: datetime | None) -> str:
|
||||||
|
"""时间列统一按北京时间输出(与列表页、日活分日口径一致)。
|
||||||
|
|
||||||
|
直接输出 UTC 会让导出文件里 01:00 的操作显示成前一天 17:00,
|
||||||
|
与网页上看到的对不上 —— 导出与页面不一致是最容易被质疑的那种问题。
|
||||||
|
"""
|
||||||
|
if dt is None:
|
||||||
|
return ""
|
||||||
|
return dt.astimezone(BEIJING_TZ).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
def _actor(log) -> str:
|
||||||
|
"""操作人:优先中文名,退化为账号(与列表页的展示规则一致)"""
|
||||||
|
if not log.user_id and not log.display_name:
|
||||||
|
return "未认证"
|
||||||
|
return f"{log.display_name}({log.user_id})" if log.display_name else (log.user_id or "")
|
||||||
|
|
||||||
|
|
||||||
|
# 列定义:key → (表头, 取值函数)。
|
||||||
|
# 前端只传 key 列表,中文表头与取值口径都由后端统一维护,
|
||||||
|
# 避免两端各写一份导致"导出的列和页面上的对不上"。
|
||||||
|
_AUDIT_LOG_COLUMNS: dict[str, tuple[str, Callable[[Any], Any]]] = {
|
||||||
|
"time": ("时间", lambda r: _bj(r.created_at)),
|
||||||
|
"user": ("操作人", _actor),
|
||||||
|
"role": ("角色", lambda r: r.role or ""),
|
||||||
|
"module": ("模块", lambda r: MODULE_LABELS.get(r.module, r.module)),
|
||||||
|
"action": ("动作", lambda r: ACTION_LABELS.get(r.action, r.action)),
|
||||||
|
"method": ("方法", lambda r: r.method or ""),
|
||||||
|
"url": ("请求路径", lambda r: r.url or ""),
|
||||||
|
"status": ("结果", lambda r: r.status_code if r.status_code is not None else ""),
|
||||||
|
"ip": ("来源IP", lambda r: r.ip_address or ""),
|
||||||
|
"target": ("目标", lambda r: f"{r.target_type or ''}:{r.target_id or ''}".strip(":")),
|
||||||
|
"error": ("错误信息", lambda r: r.error_message or ""),
|
||||||
|
"request_id": ("请求ID", lambda r: r.request_id or ""),
|
||||||
|
"user_agent": ("User-Agent", lambda r: r.user_agent or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
_DAILY_USAGE_COLUMNS: dict[str, tuple[str, Callable[[dict], Any]]] = {
|
||||||
|
"day": ("日期", lambda r: r["day"]),
|
||||||
|
"user": ("操作人", lambda r: f"{r['display_name']}({r['user_id']})" if r["display_name"] else (r["user_id"] or "")),
|
||||||
|
"role": ("角色", lambda r: r["role"] or ""),
|
||||||
|
# 上线/下线时间 = 当天首次/末次活动(非登录时间),
|
||||||
|
# 登录/登出次数单独成列,两者不再混为一谈
|
||||||
|
"first_active": ("上线时间", lambda r: _bj(r["first_active_at"])),
|
||||||
|
"last_active": ("下线时间", lambda r: _bj(r["last_active_at"])),
|
||||||
|
"login_count": ("登录次数", lambda r: r["login_count"]),
|
||||||
|
"logout_count": ("登出次数", lambda r: r["logout_count"]),
|
||||||
|
"op_count": ("操作次数", lambda r: r["op_count"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_response(
|
||||||
|
columns: dict[str, tuple[str, Callable]], keys: list[str], rows: list, filename: str,
|
||||||
|
) -> Response:
|
||||||
|
"""把行数据渲染成 CSV 响应。
|
||||||
|
|
||||||
|
⚠️ 必须带 UTF-8 BOM:Excel 靠它识别编码,否则中文表头与内容全是乱码。
|
||||||
|
这是 CSV 导出最常见、也最容易被忽略的坑。
|
||||||
|
"""
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow([columns[k][0] for k in keys])
|
||||||
|
for row in rows:
|
||||||
|
writer.writerow([columns[k][1](row) for k in keys])
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=b"\xef\xbb\xbf" + buf.getvalue().encode("utf-8"),
|
||||||
|
media_type="text/csv; charset=utf-8",
|
||||||
|
# 文件名用纯 ASCII:中文文件名要走 RFC 5987,各浏览器行为不一致,
|
||||||
|
# 内部系统没必要为它引入兼容成本。
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_keys(raw: str | None, columns: dict) -> list[str]:
|
||||||
|
"""解析前端传来的列 key。缺省 = 全部列;未知 key 直接忽略(不报错)。"""
|
||||||
|
if not raw:
|
||||||
|
return list(columns)
|
||||||
|
keys = [k.strip() for k in raw.split(",") if k.strip() in columns]
|
||||||
|
return keys or list(columns)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs/export")
|
||||||
|
async def export_audit_logs(
|
||||||
|
user_id: str | None = Query(None, description="操作人账号(模糊匹配)"),
|
||||||
|
module: str | None = Query(None, description="业务模块"),
|
||||||
|
action: str | None = Query(None, description="动作类型"),
|
||||||
|
target_id: str | None = Query(None, description="目标ID"),
|
||||||
|
request_id: str | None = Query(None, description="请求ID"),
|
||||||
|
status_code: int | None = Query(None, description="响应状态码"),
|
||||||
|
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD"),
|
||||||
|
end_date: str | None = Query(None, description="结束日期 YYYY-MM-DD(含当天)"),
|
||||||
|
columns: str | None = Query(None, description="导出列,逗号分隔;缺省=全部"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
) -> Response:
|
||||||
|
"""审计明细 CSV 导出 —— 筛选维度与 /logs 完全一致,保证"看到什么就能导出什么"。"""
|
||||||
|
start = _parse_day(start_date)
|
||||||
|
end_exclusive = _parse_day(end_date, end_of_day=True)
|
||||||
|
|
||||||
|
rows, truncated = await audit_service.export_audit_logs(
|
||||||
|
db,
|
||||||
|
user_id=user_id, module=module, action=action, target_id=target_id,
|
||||||
|
request_id=request_id, status_code=status_code,
|
||||||
|
start=start,
|
||||||
|
end=end_exclusive - timedelta(microseconds=1) if end_exclusive else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
keys = _resolve_keys(columns, _AUDIT_LOG_COLUMNS)
|
||||||
|
resp = _csv_response(_AUDIT_LOG_COLUMNS, keys, rows, "audit_logs.csv")
|
||||||
|
if truncated:
|
||||||
|
# 用响应头传递"已截断",前端据此提示用户收窄筛选条件
|
||||||
|
resp.headers["X-Export-Truncated"] = "1"
|
||||||
|
resp.headers["X-Export-Max-Rows"] = str(audit_service.EXPORT_MAX_ROWS)
|
||||||
|
resp.headers["Access-Control-Expose-Headers"] = "X-Export-Truncated, X-Export-Max-Rows"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/daily-usage/export")
|
||||||
|
async def export_daily_usage(
|
||||||
|
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD(北京时间),默认今天"),
|
||||||
|
end_date: str | None = Query(None, description="结束日期 YYYY-MM-DD(北京时间),默认同起始日"),
|
||||||
|
columns: str | None = Query(None, description="导出列,逗号分隔;缺省=全部"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
) -> Response:
|
||||||
|
"""日活统计 CSV 导出 —— 每人一行:上线/下线次数与时间、操作次数。"""
|
||||||
|
start = _parse_day(start_date) or datetime.combine(
|
||||||
|
get_beijing_time().date(), time.min, tzinfo=BEIJING_TZ,
|
||||||
|
)
|
||||||
|
end = _parse_day(end_date, end_of_day=True) or (start + timedelta(days=1))
|
||||||
|
|
||||||
|
items = await audit_service.get_daily_usage(db, start=start, end=end)
|
||||||
|
keys = _resolve_keys(columns, _DAILY_USAGE_COLUMNS)
|
||||||
|
return _csv_response(_DAILY_USAGE_COLUMNS, keys, items, "daily_usage.csv")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/daily-usage", response_model=DailyUsageResponse)
|
||||||
|
async def get_daily_usage(
|
||||||
|
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD(北京时间),默认今天"),
|
||||||
|
end_date: str | None = Query(None, description="结束日期 YYYY-MM-DD(北京时间),默认同起始日"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
) -> DailyUsageResponse:
|
||||||
|
"""日活 / 使用统计 —— 按【北京时间自然日 × 操作人】聚合。
|
||||||
|
|
||||||
|
回答的是「每天有哪些人用了系统、用了多少」:
|
||||||
|
· 上线时间 / 下线时间:当天**首次 / 末次活动**时间(任意审计记录)
|
||||||
|
· 操作次数:当天该用户的全部审计记录数(使用深度)
|
||||||
|
· 登录次数 / 登出次数:真实的手动登录 / 登出行为计数
|
||||||
|
|
||||||
|
⚠️ 上线时间【不取登录时间】:token 有效期内(refresh 7 天)用户不重新登录,
|
||||||
|
按登录算会让「周一登录、周二继续用」的周二变成"登录次数 0、上线时间空,
|
||||||
|
但操作次数 35"——报表自相矛盾。改用活动口径后,当天的第一次操作即上线时间。
|
||||||
|
|
||||||
|
⚠️ 登出次数天然小于登录次数:用户直接关浏览器、断网、token 过期都不会
|
||||||
|
产生登出记录。这是真实情况,不做任何"补齐"推算。
|
||||||
|
"""
|
||||||
|
# 起始日:未传则取北京的今天。_parse_day 返回的是北京时间当日 00:00。
|
||||||
|
start = _parse_day(start_date) or datetime.combine(
|
||||||
|
get_beijing_time().date(), time.min, tzinfo=BEIJING_TZ,
|
||||||
|
)
|
||||||
|
# 结束日:_parse_day(end_of_day=True) 已给出「次日 00:00」,正好当作半开上界。
|
||||||
|
# 未传则默认单日查询(= 起始日当天)。
|
||||||
|
end = _parse_day(end_date, end_of_day=True) or (start + timedelta(days=1))
|
||||||
|
|
||||||
|
items = await audit_service.get_daily_usage(db, start=start, end=end)
|
||||||
|
|
||||||
|
return DailyUsageResponse(
|
||||||
|
start_date=start.astimezone(BEIJING_TZ).strftime("%Y-%m-%d"),
|
||||||
|
end_date=(end - timedelta(days=1)).astimezone(BEIJING_TZ).strftime("%Y-%m-%d"),
|
||||||
|
items=[DailyUsageRow(**row) for row in items],
|
||||||
|
total=len(items),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -7,6 +7,7 @@ from app.schemas.user import (
|
|||||||
RefreshResponse,
|
RefreshResponse,
|
||||||
UserResponse,
|
UserResponse,
|
||||||
)
|
)
|
||||||
|
from app.core.security import peek_token_identity
|
||||||
from app.services.auth_service import login, refresh_access_token, get_current_user
|
from app.services.auth_service import login, refresh_access_token, get_current_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||||
@ -20,15 +21,54 @@ def login_endpoint(data: LoginRequest, request: Request):
|
|||||||
# 登录失败时同样留痕,且能按账号追踪暴力破解。
|
# 登录失败时同样留痕,且能按账号追踪暴力破解。
|
||||||
# 注意:绝不把 data.password 写进 state / 审计,密码不落库。
|
# 注意:绝不把 data.password 写进 state / 审计,密码不落库。
|
||||||
request.state.audit_user = data.username
|
request.state.audit_user = data.username
|
||||||
return login(data.username, data.password)
|
result = login(data.username, data.password)
|
||||||
|
|
||||||
|
# 登录成功后补上显示名 / 角色 —— 否则审计里这条记录的「操作人」会退化成账号
|
||||||
|
# (前端按 display_name || user_id 渲染,见 AdminAuditLogPage)。
|
||||||
|
# 能在这里补的原因:中间件是在 call_next 返回【之后】才落库的,此刻写入
|
||||||
|
# request.state 依然会被采集到。
|
||||||
|
# 失败登录走不到这里,保持「只有账号可追责」——这正是想要的语义。
|
||||||
|
if result.user:
|
||||||
|
request.state.audit_display_name = result.user.display_name
|
||||||
|
request.state.audit_role = result.user.role
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refresh", response_model=RefreshResponse)
|
@router.post("/refresh", response_model=RefreshResponse)
|
||||||
def refresh_endpoint(data: RefreshRequest):
|
def refresh_endpoint(data: RefreshRequest, request: Request):
|
||||||
"""刷新 Access Token — 使用 Refresh Token 换取新的 Access Token"""
|
"""刷新 Access Token — 使用 Refresh Token 换取新的 Access Token"""
|
||||||
|
# 本接口刻意不挂 get_current_user:能用到这里,正是因为 access token 已经
|
||||||
|
# 过期/缺失,请求里没有 Authorization 头,JWT 依赖不会执行 → 审计拿不到操作人,
|
||||||
|
# 记录只能显示「未认证」。
|
||||||
|
# 但 refresh token 里本来就带着完整身份(sub/username/display_name/role),
|
||||||
|
# 解出来写进 state,审计才能记到人 —— 而"谁在何时尝试刷新"正是要留痕的。
|
||||||
|
# 注意 peek 只用于审计标注,鉴权判断一律走 get_current_user。
|
||||||
|
identity = peek_token_identity(data.refresh_token)
|
||||||
|
if identity:
|
||||||
|
request.state.audit_user = identity.get("username") or identity.get("sub")
|
||||||
|
request.state.audit_display_name = identity.get("display_name") or ""
|
||||||
|
request.state.audit_role = identity.get("role") or ""
|
||||||
return refresh_access_token(data.refresh_token)
|
return refresh_access_token(data.refresh_token)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def logout_endpoint(current_user: dict = Depends(get_current_user)):
|
||||||
|
"""登出 —— 仅用于审计留痕。
|
||||||
|
|
||||||
|
JWT 是无状态的,服务端没有可吊销的会话,因此本接口**不做任何令牌失效**
|
||||||
|
(客户端清掉本地 token 即为登出),返回体也没有实际语义。
|
||||||
|
|
||||||
|
它存在的唯一目的:让审计中间件记下「谁在何时退出了系统」。
|
||||||
|
没有这个端点时,前端「退出」只清本地存储、不产生任何请求,
|
||||||
|
退出动作在审计里完全不可见 —— 而"谁在什么时候退掉了系统"
|
||||||
|
在追责场景下和"谁登录了"同等重要。
|
||||||
|
|
||||||
|
挂 Depends(get_current_user) 是为了让 JWT 依赖把操作人写进 request.state
|
||||||
|
(见 auth_service.get_current_user),记录到真实姓名而非「未认证」。
|
||||||
|
"""
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserResponse)
|
@router.get("/me", response_model=UserResponse)
|
||||||
def get_me(current_user: dict = Depends(get_current_user)):
|
def get_me(current_user: dict = Depends(get_current_user)):
|
||||||
"""获取当前用户信息(从 Access Token 解析)"""
|
"""获取当前用户信息(从 Access Token 解析)"""
|
||||||
|
|||||||
@ -19,6 +19,7 @@ async def list_orders(
|
|||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(50, ge=1, le=200),
|
limit: int = Query(50, ge=1, le=200),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ProductionOrder).offset(skip).limit(limit).order_by(ProductionOrder.created_at.desc())
|
select(ProductionOrder).offset(skip).limit(limit).order_by(ProductionOrder.created_at.desc())
|
||||||
|
|||||||
@ -26,7 +26,10 @@ router = APIRouter(prefix="/products", tags=["产品管理"])
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
@router.get("/qrcode/{serial_number}")
|
@router.get("/qrcode/{serial_number}")
|
||||||
async def get_product_qrcode(serial_number: str):
|
async def get_product_qrcode(
|
||||||
|
serial_number: str,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
生成产品二维码(PNG 图片)。
|
生成产品二维码(PNG 图片)。
|
||||||
内容为 16 位序列号,扫描后可调用 /scan/{serial_number} 查询产品。
|
内容为 16 位序列号,扫描后可调用 /scan/{serial_number} 查询产品。
|
||||||
@ -50,6 +53,7 @@ async def get_product_qrcode(serial_number: str):
|
|||||||
async def scan_product(
|
async def scan_product(
|
||||||
serial_number: str,
|
serial_number: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
扫码接口:根据 16 位序列号查询产品及其当前进度。
|
扫码接口:根据 16 位序列号查询产品及其当前进度。
|
||||||
@ -69,6 +73,7 @@ async def list_products(
|
|||||||
keyword: str | None = Query(None, description="多维搜索: 产品身份证/订单号/规格型号"),
|
keyword: str | None = Query(None, description="多维搜索: 产品身份证/订单号/规格型号"),
|
||||||
status: str | None = Query(None, description="产品状态筛选: PENDING/WIP/COMPLETED/ARCHIVED"),
|
status: str | None = Query(None, description="产品状态筛选: PENDING/WIP/COMPLETED/ARCHIVED"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""获取产品列表 — 支持 keyword 搜索 + 状态筛选"""
|
"""获取产品列表 — 支持 keyword 搜索 + 状态筛选"""
|
||||||
return await product_service.get_all_products(
|
return await product_service.get_all_products(
|
||||||
@ -80,6 +85,7 @@ async def list_products(
|
|||||||
async def get_product(
|
async def get_product(
|
||||||
product_id: str,
|
product_id: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""获取单个产品详情"""
|
"""获取单个产品详情"""
|
||||||
import uuid
|
import uuid
|
||||||
@ -196,6 +202,7 @@ class MessageCreate(BaseModel):
|
|||||||
async def get_product_messages(
|
async def get_product_messages(
|
||||||
product_id: str,
|
product_id: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""获取某产品的所有留言(按时间正序)"""
|
"""获取某产品的所有留言(按时间正序)"""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
|
|||||||
@ -38,6 +38,7 @@ async def list_tasks(
|
|||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(50, ge=1, le=200),
|
limit: int = Query(50, ge=1, le=200),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""获取任务列表,可按产品/负责人筛选(只返回顶层任务)"""
|
"""获取任务列表,可按产品/负责人筛选(只返回顶层任务)"""
|
||||||
pid = uuid.UUID(product_id) if product_id else None
|
pid = uuid.UUID(product_id) if product_id else None
|
||||||
@ -48,6 +49,7 @@ async def list_tasks(
|
|||||||
async def get_task(
|
async def get_task(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
获取任务详情 — 递归包含所有层级的子任务。
|
获取任务详情 — 递归包含所有层级的子任务。
|
||||||
@ -299,6 +301,7 @@ async def create_subtask_endpoint(
|
|||||||
async def get_tasks_by_product(
|
async def get_tasks_by_product(
|
||||||
product_id: str,
|
product_id: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""获取指定产品的顶层任务列表(不含子任务嵌套)"""
|
"""获取指定产品的顶层任务列表(不含子任务嵌套)"""
|
||||||
return await task_service.get_top_level_tasks(db, uuid.UUID(product_id))
|
return await task_service.get_top_level_tasks(db, uuid.UUID(product_id))
|
||||||
|
|||||||
@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
MOM 仓储系统确认接收产品入库后,回调本接口,将 Track 中该产品的状态
|
MOM 仓储系统确认接收产品入库后,回调本接口,将 Track 中该产品的状态
|
||||||
真正标记为"已入库闭环"(更新宏观状态 + 记录 task_logs 证明仓库已接收)。
|
真正标记为"已入库闭环"(更新宏观状态 + 记录 task_logs 证明仓库已接收)。
|
||||||
|
|
||||||
|
同一条入站通道还承担【撤回出库】的强制回滚:MOM 把误点出库的设备物理
|
||||||
|
回滚到仓库时,Track 必须被动跟随 MOM 的权威物理状态(详见
|
||||||
|
_mom_inbound_revoke 上方的特权通道说明)。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -14,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.core.lifecycle import sync_product_status
|
||||||
from app.models.product import Product
|
from app.models.product import Product
|
||||||
from app.models.task import Task, TaskRecord
|
from app.models.task import Task, TaskRecord
|
||||||
from app.models.task_log import TaskLog
|
from app.models.task_log import TaskLog
|
||||||
@ -22,11 +27,101 @@ router = APIRouter(prefix="/external/webhooks", tags=["外部回调"])
|
|||||||
|
|
||||||
|
|
||||||
class MomInboundPayload(BaseModel):
|
class MomInboundPayload(BaseModel):
|
||||||
"""MOM 仓储系统确认接收入库的回调载荷"""
|
"""MOM 仓储系统确认接收入库 / 撤回出库的回调载荷"""
|
||||||
serial_number: str | None = None # 产品 16 位身份证(可空,优先匹配)
|
serial_number: str | None = None # 产品 16 位身份证(可空,优先匹配)
|
||||||
sku: str | None = None # 规格型号 spec_model(serial 缺失时的兜底匹配)
|
sku: str | None = None # 规格型号 spec_model(serial 缺失时的兜底匹配)
|
||||||
operator: str | None = None # 入库操作人(写入 task_logs.operator_id)
|
operator: str | None = None # 入库操作人(写入 task_logs.operator_id)
|
||||||
inbound_time: datetime | None = None # 入库确认时间
|
inbound_time: datetime | None = None # 入库确认时间
|
||||||
|
# ↓ MOM 侧一直在发、此前被 Pydantic 静默丢弃的字段。撤回信号靠它们识别。
|
||||||
|
event: str | None = None # 事件名,如 inbound.created / outbound.revoked
|
||||||
|
action: str | None = None # 显式动作指令,如 revoke_outbound
|
||||||
|
source_table: str | None = None # stock_product / stock_semi
|
||||||
|
|
||||||
|
|
||||||
|
# 「撤回出库」信号词 —— 只在 action / event 里做子串匹配。
|
||||||
|
# MOM 侧的字段命名尚未冻结,故刻意宽松:revoke_outbound / outbound.revoked /
|
||||||
|
# rollback_outbound 都能命中,避免因对方改个词就整条链路失联。
|
||||||
|
_OUTBOUND_REVOKE_TOKENS = ("revoke", "rollback", "revert", "cancel")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_outbound_revoke(payload: MomInboundPayload) -> bool:
|
||||||
|
"""payload 是否携带**显式**的撤回出库信号。
|
||||||
|
|
||||||
|
注意:返回 False 不代表「不是撤回」——MOM 也可能不加任何标记、直接以
|
||||||
|
常规 inbound.created 重推。那种隐式信号由调用方用「产品此刻是否处于
|
||||||
|
已出库」兜底判定(见 mom_inbound_webhook 里的 was_outbound)。
|
||||||
|
"""
|
||||||
|
for raw in (payload.action, payload.event):
|
||||||
|
token = (raw or "").strip().lower()
|
||||||
|
if token and any(word in token for word in _OUTBOUND_REVOKE_TOKENS):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _pick_warehouse_log_task(db: AsyncSession, product: Product) -> Task | None:
|
||||||
|
"""挑一条挂日志的任务:优先「在库」任务,其次该产品最新任务,都没有则 None。"""
|
||||||
|
task = (
|
||||||
|
await db.execute(
|
||||||
|
select(Task)
|
||||||
|
.where(Task.product_id == product.id, Task.task_name.ilike("%在库%"))
|
||||||
|
.order_by(Task.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
).scalars().first()
|
||||||
|
if task is None:
|
||||||
|
task = (
|
||||||
|
await db.execute(
|
||||||
|
select(Task)
|
||||||
|
.where(Task.product_id == product.id)
|
||||||
|
.order_by(Task.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
).scalars().first()
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
async def _match_inbound_product(
|
||||||
|
db: AsyncSession, payload: MomInboundPayload, *, allow_outbound: bool,
|
||||||
|
) -> Product | None:
|
||||||
|
"""按 serial_number(优先)或 sku 匹配产品。
|
||||||
|
|
||||||
|
allow_outbound=False:只认「当前挂在虚拟仓库池」的产品(常规入库的既有语义)。
|
||||||
|
allow_outbound=True :额外放行「已出库」产品 —— 出库回调会把 current_location_id
|
||||||
|
置为 None,若仍用原条件,撤回信号必然失配并静默 return matched=False,
|
||||||
|
造成 MOM 认为货已回库、Track 却永远停在「已出库」的数据脑裂。
|
||||||
|
"""
|
||||||
|
location_cond = Product.current_location_id == "virtual_warehouse"
|
||||||
|
where_cond = (
|
||||||
|
or_(
|
||||||
|
location_cond,
|
||||||
|
Product.overall_status == "已出库",
|
||||||
|
Product.status == "OUTBOUND",
|
||||||
|
)
|
||||||
|
if allow_outbound
|
||||||
|
else location_cond
|
||||||
|
)
|
||||||
|
|
||||||
|
if payload.serial_number:
|
||||||
|
return (
|
||||||
|
await db.execute(
|
||||||
|
select(Product).where(
|
||||||
|
or_(
|
||||||
|
Product.serial_number == payload.serial_number,
|
||||||
|
Product.external_serial == payload.serial_number,
|
||||||
|
),
|
||||||
|
where_cond,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if payload.sku:
|
||||||
|
return (
|
||||||
|
await db.execute(
|
||||||
|
select(Product)
|
||||||
|
.where(Product.spec_model == payload.sku, where_cond)
|
||||||
|
.order_by(Product.created_at.desc())
|
||||||
|
)
|
||||||
|
).scalars().first()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/mom-inbound")
|
@router.post("/mom-inbound")
|
||||||
@ -35,92 +130,127 @@ async def mom_inbound_webhook(
|
|||||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""MOM 仓储系统确认接收产品入库后回调本接口。
|
"""MOM 确认接收入库 / 撤回出库后回调本接口。
|
||||||
|
|
||||||
- 鉴权:Header X-API-Key 必须等于环境变量 TRACK_WEBHOOK_KEY。
|
- 鉴权:Header X-API-Key 必须等于环境变量 TRACK_WEBHOOK_KEY。
|
||||||
- 用 serial_number(优先)或 sku 查询当前位于 virtual_warehouse 的产品;
|
- 常规入库:用 serial_number(优先)或 sku 匹配「当前位于 virtual_warehouse」
|
||||||
命中则标记"已实收"(overall_status=已入库 + 记录 task_logs)。
|
的产品,命中则标记"已实收"(overall_status=已入库 + 记录 task_logs)。
|
||||||
- 未命中返回 200(MOM 可能入库了非 Track 生产的物料,直接忽略)。
|
- 撤回出库:MOM 把误出库的设备物理回滚到仓库 → 本接口强制执行特权回滚。
|
||||||
|
- 未命中返回 200(MOM 可能操作了非 Track 生产的物料,直接忽略)。
|
||||||
"""
|
"""
|
||||||
# ── 鉴权 ──
|
# ── 鉴权 ──
|
||||||
if not settings.TRACK_WEBHOOK_KEY or x_api_key != settings.TRACK_WEBHOOK_KEY:
|
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")
|
raise HTTPException(status_code=401, detail="Unauthorized: invalid X-API-Key")
|
||||||
|
|
||||||
# ── 按 serial_number / external_serial(双字段联合)或 sku 匹配"当前位于仓库"的产品 ──
|
explicit_revoke = _is_outbound_revoke(payload)
|
||||||
product = None
|
|
||||||
if payload.serial_number:
|
# ── 匹配产品 ──
|
||||||
product = (
|
# 常规入库保持严格匹配;撤回(显式标记,或带 serial 可精确定位)才放宽到已出库产品。
|
||||||
await db.execute(
|
# 刻意不给 sku 兜底也无条件放宽:同型号可能有多台,放宽后可能误标到别的设备。
|
||||||
select(Product).where(
|
product = await _match_inbound_product(
|
||||||
or_(
|
db, payload, allow_outbound=explicit_revoke or bool(payload.serial_number),
|
||||||
Product.serial_number == payload.serial_number,
|
)
|
||||||
Product.external_serial == payload.serial_number,
|
|
||||||
),
|
|
||||||
Product.current_location_id == "virtual_warehouse",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
elif payload.sku:
|
|
||||||
product = (
|
|
||||||
await db.execute(
|
|
||||||
select(Product)
|
|
||||||
.where(
|
|
||||||
Product.spec_model == payload.sku,
|
|
||||||
Product.current_location_id == "virtual_warehouse",
|
|
||||||
)
|
|
||||||
.order_by(Product.created_at.desc())
|
|
||||||
)
|
|
||||||
).scalars().first()
|
|
||||||
|
|
||||||
# ── 未命中:可能是非 Track 生产的物料,直接忽略 ──
|
# ── 未命中:可能是非 Track 生产的物料,直接忽略 ──
|
||||||
if product is None:
|
if product is None:
|
||||||
return {"ok": True, "matched": False}
|
return {"ok": True, "matched": False}
|
||||||
|
|
||||||
# ── 标记"已实收"闭环 ──
|
# 隐式撤回:payload 没带任何标记,但产品此刻正处于「已出库」。
|
||||||
|
# 对一台已发货的设备来说,任何入库回调都只能意味着「货回来了」。
|
||||||
|
was_outbound = (
|
||||||
|
(product.overall_status or "").strip() == "已出库"
|
||||||
|
or (product.status or "").strip().upper() == "OUTBOUND"
|
||||||
|
)
|
||||||
|
is_revoke = explicit_revoke or was_outbound
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# ★ 特权通道 — MOM 的物理状态同步优先级最高,强制覆写、不受任何内部守卫约束
|
||||||
|
#
|
||||||
|
# 与 task_service.py 的【绝对物理终态保护】(PHYSICAL_TERMINAL_OVERALL,
|
||||||
|
# task_service.py:115-127) 方向刻意相反:那套保护约束的是「车间内部流转
|
||||||
|
# 不许用工序名抹掉物理终态」;而本接口是物理事实的**权威来源**——MOM 说
|
||||||
|
# 货已回到仓库,Track 必须无条件跟随。
|
||||||
|
#
|
||||||
|
# ⚠️ 后续维护者:不要在此处添加 _is_physical_terminal / 状态互斥 / 仅当
|
||||||
|
# 状态为 X 才允许覆写 之类的校验。那会让设备永远卡在「已出库」,
|
||||||
|
# 与 MOM 账面对不上——正是本次要消灭的数据脑裂。
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
changed = False
|
changed = False
|
||||||
|
|
||||||
|
# 1) 宏观状态强制覆写为「已入库」(撤回时从「已出库」拉回)
|
||||||
if product.overall_status != "已入库":
|
if product.overall_status != "已入库":
|
||||||
product.overall_status = "已入库"
|
product.overall_status = "已入库"
|
||||||
# 双字段同步:整体状态与产品状态保持一致(前端徽标依赖 status)
|
|
||||||
product.status = "ARCHIVED"
|
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
# 记录仓库接收日志(优先"在库"任务,其次该产品最新任务;无任务则仅更新状态)
|
# 2) 物理位置强制回滚到虚拟仓库池(出库回调曾把它置为 None)
|
||||||
inbound_task = (
|
if product.current_location_id != "virtual_warehouse":
|
||||||
await db.execute(
|
product.current_location_id = "virtual_warehouse"
|
||||||
select(Task)
|
changed = True
|
||||||
.where(Task.product_id == product.id, Task.task_name.ilike("%在库%"))
|
|
||||||
.order_by(Task.created_at.desc())
|
# 3) 双字段同步:lifecycle.py 约定凡改写 overall_status 必调一次。
|
||||||
.limit(1)
|
# (原实现在这里硬编码 product.status="ARCHIVED",绕过了约定,一并纠正)
|
||||||
)
|
# ⚠️ 必须把 status 的变化也计入 changed:否则当 overall_status / location
|
||||||
).scalars().first()
|
# 本来就已经正确时,这一处纠偏会因为 changed 保持 False 而永远不提交。
|
||||||
if inbound_task is None:
|
prev_status = product.status
|
||||||
inbound_task = (
|
sync_product_status(product)
|
||||||
await db.execute(
|
if product.status != prev_status:
|
||||||
select(Task)
|
changed = True
|
||||||
.where(Task.product_id == product.id)
|
|
||||||
.order_by(Task.created_at.desc())
|
# ── 记录日志(优先"在库"任务,其次该产品最新任务;无任务则仅更新状态) ──
|
||||||
.limit(1)
|
log_task = await _pick_warehouse_log_task(db, product)
|
||||||
)
|
if log_task is not None:
|
||||||
).scalars().first()
|
if is_revoke:
|
||||||
|
signal = payload.action or payload.event or "inbound.created(隐式)"
|
||||||
|
remark = (
|
||||||
|
f"MOM 撤回出库 → 强制回滚:宏观状态已入库、"
|
||||||
|
f"位置已回到 virtual_warehouse(信号: {signal})"
|
||||||
|
)
|
||||||
|
action_type = "warehouse_outbound_revoked"
|
||||||
|
else:
|
||||||
|
time_str = payload.inbound_time.isoformat() if payload.inbound_time else "—"
|
||||||
|
remark = f"MOM 仓储系统确认接收入库(inbound_time: {time_str})"
|
||||||
|
action_type = "warehouse_inbound"
|
||||||
|
|
||||||
if inbound_task is not None:
|
|
||||||
time_str = payload.inbound_time.isoformat() if payload.inbound_time else "—"
|
|
||||||
db.add(TaskLog(
|
db.add(TaskLog(
|
||||||
task_id=inbound_task.id,
|
task_id=log_task.id,
|
||||||
operator_id=(payload.operator or "virtual_warehouse")[:64],
|
operator_id=(payload.operator or "virtual_warehouse")[:64],
|
||||||
action_type="warehouse_inbound",
|
action_type=action_type,
|
||||||
remark=f"MOM 仓储系统确认接收入库(inbound_time: {time_str})",
|
remark=remark,
|
||||||
))
|
))
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
# ── 动态生成"扫码入库"主线任务节点 + 操作日志(流转树最底部长出入库节点) ──
|
# ── 动态生成主线任务节点 + 操作日志(流转树最底部长出节点) ──
|
||||||
if await _append_warehouse_task(db, product, "扫码入库", "通过 MOM 系统扫码入库完成"):
|
if is_revoke:
|
||||||
|
# 撤回必须留痕:否则流转树末节点仍是「扫码出库」,而产品徽标已是
|
||||||
|
# 「已入库」,这种可见的自相矛盾会让车间不敢信这套数据。
|
||||||
|
#
|
||||||
|
# ⚠️ 节点名里的「(重新入库)」不是装饰,是 [必须保留] 的契约:
|
||||||
|
# product_service.py:166-174 的 _has_warehouse_task() 用**子串**判定
|
||||||
|
# 仓库节点("在库" in task_name or "入库" in task_name)。而
|
||||||
|
# 「撤回出库」四个字里只有"出库"、不含"入库",会让它判定为"无仓库任务",
|
||||||
|
# 进而给 location==virtual_warehouse 的产品注入一个假的「已完成 /
|
||||||
|
# 待仓库扫码」虚拟节点(product_service.py:237-242 的情况 A)——
|
||||||
|
# 该设备明明已入库且在仓库里,树尾却显示待收货。
|
||||||
|
# 补上「重新入库」后关键字命中,虚拟节点不再注入。
|
||||||
|
appended = await _append_warehouse_task(
|
||||||
|
db, product, "撤回出库(重新入库)", "MOM 撤回出库,设备已物理回滚至仓库",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
appended = await _append_warehouse_task(
|
||||||
|
db, product, "扫码入库", "通过 MOM 系统扫码入库完成",
|
||||||
|
)
|
||||||
|
if appended:
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return {"ok": True, "matched": True, "serial_number": product.serial_number}
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"matched": True,
|
||||||
|
"serial_number": product.serial_number,
|
||||||
|
"revoked": is_revoke,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _append_warehouse_task(
|
async def _append_warehouse_task(
|
||||||
@ -265,23 +395,7 @@ async def mom_outbound_webhook(
|
|||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
# 记录出库日志(优先"在库"任务,其次该产品最新任务)
|
# 记录出库日志(优先"在库"任务,其次该产品最新任务)
|
||||||
outbound_task = (
|
outbound_task = await _pick_warehouse_log_task(db, product)
|
||||||
await db.execute(
|
|
||||||
select(Task)
|
|
||||||
.where(Task.product_id == product.id, Task.task_name.ilike("%在库%"))
|
|
||||||
.order_by(Task.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
).scalars().first()
|
|
||||||
if outbound_task is None:
|
|
||||||
outbound_task = (
|
|
||||||
await db.execute(
|
|
||||||
select(Task)
|
|
||||||
.where(Task.product_id == product.id)
|
|
||||||
.order_by(Task.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
).scalars().first()
|
|
||||||
|
|
||||||
if outbound_task is not None:
|
if outbound_task is not None:
|
||||||
time_str = payload.outbound_time.isoformat() if payload.outbound_time else "—"
|
time_str = payload.outbound_time.isoformat() if payload.outbound_time else "—"
|
||||||
|
|||||||
@ -39,6 +39,22 @@ _MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
|||||||
# 读操作里需要留痕的(导出/下载/打印属于「读」,但把数据带出了系统)
|
# 读操作里需要留痕的(导出/下载/打印属于「读」,但把数据带出了系统)
|
||||||
_SENSITIVE_READ_KEYWORDS = frozenset({"export", "download", "print"})
|
_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")
|
_IGNORED_PREFIXES = ("/health", "/docs", "/redoc", "/openapi.json")
|
||||||
|
|
||||||
@ -81,6 +97,10 @@ _SEGMENT_ACTION: dict[str, str] = {
|
|||||||
"spawn": "spawn",
|
"spawn": "spawn",
|
||||||
"complete": "complete",
|
"complete": "complete",
|
||||||
"end": "end",
|
"end": "end",
|
||||||
|
# 消息已读:PUT /notifications/{id}/read。
|
||||||
|
# 没有这一条时会回退到 _METHOD_ACTION(PUT → update → "修改"),
|
||||||
|
# 把"点开一条通知"记成"修改了某样东西",语义完全走样。
|
||||||
|
"read": "mark_read",
|
||||||
}
|
}
|
||||||
|
|
||||||
_METHOD_ACTION: dict[str, str] = {
|
_METHOD_ACTION: dict[str, str] = {
|
||||||
@ -97,6 +117,20 @@ _NON_ID_SEGMENTS = frozenset(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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]:
|
def _derive_module_and_action(path: str, method: str) -> tuple[str, str, str | None]:
|
||||||
"""由请求路径与 HTTP 方法推导 (module, action, target_id)"""
|
"""由请求路径与 HTTP 方法推导 (module, action, target_id)"""
|
||||||
parts = [p for p in path.split("/") if p]
|
parts = [p for p in path.split("/") if p]
|
||||||
@ -141,7 +175,12 @@ class AuditMiddleware(BaseHTTPMiddleware):
|
|||||||
return True
|
return True
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
lowered = path.lower()
|
lowered = path.lower()
|
||||||
return any(kw in lowered for kw in _SENSITIVE_READ_KEYWORDS)
|
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
|
return False
|
||||||
|
|
||||||
async def dispatch(
|
async def dispatch(
|
||||||
|
|||||||
@ -9,6 +9,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
from app.core.logging import request_id_var, user_var
|
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")
|
access_log = logging.getLogger("track.access")
|
||||||
|
|
||||||
@ -42,14 +43,29 @@ class RequestContextMiddleware(BaseHTTPMiddleware):
|
|||||||
response.headers["X-Request-ID"] = request_id
|
response.headers["X-Request-ID"] = request_id
|
||||||
self._log_access(request, status_code, started)
|
self._log_access(request, status_code, started)
|
||||||
logged = True
|
logged = True
|
||||||
|
await self._touch_activity(request)
|
||||||
return response
|
return response
|
||||||
finally:
|
finally:
|
||||||
# 异常路径也要留下访问记录,否则接口 500 时日志里反而没有痕迹
|
# 异常路径也要留下访问记录,否则接口 500 时日志里反而没有痕迹
|
||||||
if not logged:
|
if not logged:
|
||||||
self._log_access(request, status_code, started)
|
self._log_access(request, status_code, started)
|
||||||
|
await self._touch_activity(request)
|
||||||
request_id_var.reset(rid_token)
|
request_id_var.reset(rid_token)
|
||||||
user_var.reset(user_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:
|
def _log_access(self, request: Request, status_code: int, started: float) -> None:
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
duration_ms = round((time.perf_counter() - started) * 1000, 1)
|
duration_ms = round((time.perf_counter() - started) * 1000, 1)
|
||||||
|
|||||||
@ -39,6 +39,28 @@ def decode_token(token: str) -> dict:
|
|||||||
return jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
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:
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
"""验证明文密码 vs 哈希密码"""
|
"""验证明文密码 vs 哈希密码"""
|
||||||
return pwd_context.verify(plain_password, hashed_password)
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|||||||
@ -9,6 +9,7 @@ from app.models.app_version import AppVersion
|
|||||||
from app.models.message import ProductMessage
|
from app.models.message import ProductMessage
|
||||||
from app.models.holiday import Holiday
|
from app.models.holiday import Holiday
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.user_daily_seen import UserDailySeen
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
"ProductionOrder",
|
"ProductionOrder",
|
||||||
@ -21,4 +22,5 @@ __all__ = [
|
|||||||
"ProductMessage",
|
"ProductMessage",
|
||||||
"Holiday",
|
"Holiday",
|
||||||
"AuditLog",
|
"AuditLog",
|
||||||
|
"UserDailySeen",
|
||||||
]
|
]
|
||||||
|
|||||||
47
backend/app/models/user_daily_seen.py
Normal file
47
backend/app/models/user_daily_seen.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
"""每日用户活动表 —— 一天一人一行,只记录"今天来过"这件事。
|
||||||
|
|
||||||
|
为什么需要它(而不是复用 audit_logs)
|
||||||
|
--------------------------------------
|
||||||
|
日活报表要的「上线时间 / 下线时间」,两个都不能从审计表直接得出:
|
||||||
|
|
||||||
|
1. **上线/下线时间不能取登录时间**:Refresh Token 有效期 7 天,用户不必每天
|
||||||
|
重新登录。按登录算会出现「登录次数 0、上线时间空,但操作次数 35」的
|
||||||
|
自相矛盾报表。
|
||||||
|
|
||||||
|
2. **也不能只取写操作时间**:审计中间件只记录写操作(及导出/打印这类敏感读),
|
||||||
|
普通 GET 不入账。当天只翻看、没做写操作的人会被整条漏掉。
|
||||||
|
|
||||||
|
3. **更不能把活动记录写进 audit_logs**:
|
||||||
|
· 「上线时间」是**事件**(INSERT 一次即可),但「下线时间」是**状态**
|
||||||
|
(每次活动都要刷新同一个值)。往审计流水里做 UPDATE,等于承认审计记录
|
||||||
|
可以被改写 —— 那审计本身就失去可信度了。
|
||||||
|
· 若改为每个请求 INSERT 一条,表会随访问量线性膨胀。
|
||||||
|
|
||||||
|
于是单开一张"可变的小状态表":一人一天一行,首见 INSERT、其后只
|
||||||
|
UPDATE last_seen_at。50 人 × 365 天 ≈ 1.8 万行/年,可忽略。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Date, DateTime, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class UserDailySeen(Base):
|
||||||
|
"""用户在某个北京时间自然日的首末活动时刻"""
|
||||||
|
|
||||||
|
__tablename__ = "user_daily_seen"
|
||||||
|
|
||||||
|
# 联合主键即 UPSERT 的冲突目标,也是"一天一人一行"的保证
|
||||||
|
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
day: Mapped[date] = mapped_column(Date, primary_key=True, comment="北京时间自然日")
|
||||||
|
|
||||||
|
first_seen_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, comment="当天首次活动时刻",
|
||||||
|
)
|
||||||
|
last_seen_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, comment="当天末次活动时刻",
|
||||||
|
)
|
||||||
@ -45,6 +45,32 @@ class AuditLogListResponse(BaseModel):
|
|||||||
total: int
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class DailyUsageRow(BaseModel):
|
||||||
|
"""某个操作人在某一天的用量汇总(北京时间自然日)"""
|
||||||
|
day: str # YYYY-MM-DD(北京时间)
|
||||||
|
user_id: str | None = None
|
||||||
|
display_name: str | None = None
|
||||||
|
role: str | None = None
|
||||||
|
|
||||||
|
login_count: int = 0 # 登录次数(当天成功登录)
|
||||||
|
logout_count: int = 0 # 登出次数(当天成功登出)
|
||||||
|
op_count: int = 0 # 操作次数(当天全部审计记录数)
|
||||||
|
|
||||||
|
# ⚠️ 上线/下线时间取【当天首次/末次活动】,不是登录/登出时间:
|
||||||
|
# token 有效期内(refresh 7 天)用户不会重新登录,按登录算会导致
|
||||||
|
# 「登录次数 0 但操作 35 次」这种自相矛盾。
|
||||||
|
first_active_at: datetime | None = None # 上线时间(当天首次活动)
|
||||||
|
last_active_at: datetime | None = None # 下线时间(当天末次活动)
|
||||||
|
|
||||||
|
|
||||||
|
class DailyUsageResponse(BaseModel):
|
||||||
|
"""日活 / 使用统计"""
|
||||||
|
start_date: str
|
||||||
|
end_date: str
|
||||||
|
items: list[DailyUsageRow]
|
||||||
|
total: int # 行数(= 天数 × 人数),不是审计记录数
|
||||||
|
|
||||||
|
|
||||||
class AuditOption(BaseModel):
|
class AuditOption(BaseModel):
|
||||||
"""筛选项(value/label 结构,直接喂给前端下拉)"""
|
"""筛选项(value/label 结构,直接喂给前端下拉)"""
|
||||||
value: str
|
value: str
|
||||||
@ -55,3 +81,8 @@ class AuditOptionsResponse(BaseModel):
|
|||||||
"""筛选项集合"""
|
"""筛选项集合"""
|
||||||
modules: list[AuditOption]
|
modules: list[AuditOption]
|
||||||
actions: list[AuditOption]
|
actions: list[AuditOption]
|
||||||
|
# 导出可选的列(value=后端列 key,label=中文表头)。
|
||||||
|
# 由后端下发而非前端硬编码:列的中文名与取值口径都在后端,
|
||||||
|
# 两端各写一份迟早会出现"导出的列和页面上的对不上"。
|
||||||
|
log_export_columns: list[AuditOption] = []
|
||||||
|
usage_export_columns: list[AuditOption] = []
|
||||||
|
|||||||
@ -15,14 +15,17 @@ Track 改为:响应生成后,用**独立 session** 写入审计。
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import and_, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.database import AsyncSessionLocal
|
from app.core.database import AsyncSessionLocal
|
||||||
|
from app.core.time_utils import get_beijing_time
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.user_daily_seen import UserDailySeen
|
||||||
|
|
||||||
logger = logging.getLogger("track.audit")
|
logger = logging.getLogger("track.audit")
|
||||||
|
|
||||||
@ -59,11 +62,16 @@ ACTION_LABELS: dict[str, str] = {
|
|||||||
"create": "新增",
|
"create": "新增",
|
||||||
"update": "修改",
|
"update": "修改",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"read": "查询",
|
# 只用于被采集的 GET(核心业务详情 / 敏感读)。
|
||||||
|
# 叫「查看详情」而不是「查询」:前者说明用户确实点开了某条业务数据,
|
||||||
|
# 后者容易被误解成"随便搜了一下"。
|
||||||
|
"read": "查看详情",
|
||||||
"export": "导出",
|
"export": "导出",
|
||||||
"login": "登录",
|
"login": "登录",
|
||||||
"logout": "登出",
|
"logout": "登出",
|
||||||
"refresh": "刷新令牌",
|
# 刷新令牌 = 用户重新开始使用系统(token 2 小时一换,7 天免登录),
|
||||||
|
# 业务上视作一次「上线」,比"刷新令牌"这种技术词更贴近车间口径
|
||||||
|
"refresh": "上线",
|
||||||
"print": "打印",
|
"print": "打印",
|
||||||
"upload": "上传",
|
"upload": "上传",
|
||||||
"finalize": "收口",
|
"finalize": "收口",
|
||||||
@ -74,6 +82,7 @@ ACTION_LABELS: dict[str, str] = {
|
|||||||
"spawn": "派发",
|
"spawn": "派发",
|
||||||
"end": "结束分支",
|
"end": "结束分支",
|
||||||
"complete": "完结",
|
"complete": "完结",
|
||||||
|
"mark_read": "标为已读",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -165,6 +174,54 @@ async def list_audit_logs(
|
|||||||
|
|
||||||
真实总数走独立 COUNT —— 前端分页器依赖它,不能用 len(当前页)。
|
真实总数走独立 COUNT —— 前端分页器依赖它,不能用 len(当前页)。
|
||||||
"""
|
"""
|
||||||
|
filters = _log_filters(
|
||||||
|
user_id=user_id, module=module, action=action, target_id=target_id,
|
||||||
|
request_id=request_id, status_code=status_code, start=start, end=end,
|
||||||
|
)
|
||||||
|
|
||||||
|
total = await db.scalar(
|
||||||
|
select(func.count()).select_from(AuditLog).where(*filters)
|
||||||
|
) or 0
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(AuditLog)
|
||||||
|
.where(*filters)
|
||||||
|
.order_by(AuditLog.created_at.desc())
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
return list(rows), total
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 导出
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# 单次导出的行数上限。审计表只增不减,全量导出迟早会撑爆内存与浏览器,
|
||||||
|
# 故设硬上限;超出时向上层返回 truncated=True,由前端明确提示「已截断」——
|
||||||
|
# 静默截断会让使用者以为导全了,比报错更危险。
|
||||||
|
EXPORT_MAX_ROWS = 50000
|
||||||
|
|
||||||
|
|
||||||
|
def _log_filters(
|
||||||
|
*,
|
||||||
|
user_id: str | None = None,
|
||||||
|
module: str | None = None,
|
||||||
|
action: str | None = None,
|
||||||
|
target_id: str | None = None,
|
||||||
|
request_id: str | None = None,
|
||||||
|
status_code: int | None = None,
|
||||||
|
start: datetime | None = None,
|
||||||
|
end: datetime | None = None,
|
||||||
|
) -> list:
|
||||||
|
"""审计日志的筛选条件 —— list_audit_logs 与 export_audit_logs 共用。
|
||||||
|
|
||||||
|
抽出来的唯一目的:保证「列表看到的」和「导出出去的」永远是同一批数据。
|
||||||
|
两处各写一份迟早会漂移,而导出与列表不一致是最让人不信任的那种 bug。
|
||||||
|
"""
|
||||||
filters = []
|
filters = []
|
||||||
if user_id:
|
if user_id:
|
||||||
filters.append(AuditLog.user_id.ilike(f"%{user_id}%"))
|
filters.append(AuditLog.user_id.ilike(f"%{user_id}%"))
|
||||||
@ -182,19 +239,220 @@ async def list_audit_logs(
|
|||||||
filters.append(AuditLog.created_at >= start)
|
filters.append(AuditLog.created_at >= start)
|
||||||
if end:
|
if end:
|
||||||
filters.append(AuditLog.created_at <= end)
|
filters.append(AuditLog.created_at <= end)
|
||||||
|
return filters
|
||||||
|
|
||||||
total = await db.scalar(
|
|
||||||
select(func.count()).select_from(AuditLog).where(*filters)
|
|
||||||
) or 0
|
|
||||||
|
|
||||||
|
async def export_audit_logs(
|
||||||
|
db: AsyncSession, *, limit: int = EXPORT_MAX_ROWS, **kwargs,
|
||||||
|
) -> tuple[list[AuditLog], bool]:
|
||||||
|
"""导出用:按筛选条件取全部记录(不分页)。返回 (rows, truncated)。
|
||||||
|
|
||||||
|
多取一行来判断是否被截断 —— 比再跑一次 COUNT 便宜。
|
||||||
|
"""
|
||||||
rows = (
|
rows = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(AuditLog)
|
select(AuditLog)
|
||||||
.where(*filters)
|
.where(*_log_filters(**kwargs))
|
||||||
.order_by(AuditLog.created_at.desc())
|
.order_by(AuditLog.created_at.desc())
|
||||||
.offset(skip)
|
.limit(limit + 1)
|
||||||
.limit(limit)
|
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
|
truncated = len(rows) > limit
|
||||||
|
return list(rows[:limit]), truncated
|
||||||
|
|
||||||
return list(rows), total
|
|
||||||
|
# ============================================================
|
||||||
|
# 每日活动打点(日活报表的「上线时间 / 下线时间」来源)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# 同一用户两次落盘之间的最小间隔(秒)。
|
||||||
|
#
|
||||||
|
# 打点挂在「每个请求」上,但不希望每个请求都写一次数据库 —— 那会把
|
||||||
|
# user_daily_seen 变成热点。这里用进程内缓存做节流:同一用户 2 分钟内
|
||||||
|
# 只落盘一次。代价是「末次活动时间」最多落后真实值 2 分钟,
|
||||||
|
# 对"日活统计"这个精度要求完全够用。
|
||||||
|
#
|
||||||
|
# 多 worker 部署时每个进程各持一份缓存,实际写库频率最多放大到 worker 数倍
|
||||||
|
# (4 worker × 每人每 2 分钟 1 次),依然可忽略。
|
||||||
|
_TOUCH_INTERVAL_S = 120.0
|
||||||
|
_touch_cache: dict[str, float] = {}
|
||||||
|
|
||||||
|
# 缓存只增不减会缓慢泄漏(键是 user_id,量级 = 用户数,实际很小)。
|
||||||
|
# 超过阈值就整体清空 —— 代价只是多写几次库,换来内存有界。
|
||||||
|
_TOUCH_CACHE_MAX = 5000
|
||||||
|
|
||||||
|
|
||||||
|
async def touch_daily_seen(user_id: str | None) -> None:
|
||||||
|
"""记录「该用户此刻活动过」。首次 INSERT、其后只刷新 last_seen_at。
|
||||||
|
|
||||||
|
唯一的消费方是日活报表的上线/下线时间(见 get_daily_usage)。
|
||||||
|
刻意不写进 audit_logs:那是只增不改的审计流水,而本表是需要不断
|
||||||
|
UPDATE 的状态(详见 UserDailySeen 模型注释)。
|
||||||
|
|
||||||
|
任何异常都吞掉 —— 活动打点失败绝不能影响业务请求本身。
|
||||||
|
"""
|
||||||
|
if not user_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
now_mono = time.monotonic()
|
||||||
|
last = _touch_cache.get(user_id)
|
||||||
|
if last is not None and now_mono - last < _TOUCH_INTERVAL_S:
|
||||||
|
return # 节流窗口内,跳过
|
||||||
|
if len(_touch_cache) > _TOUCH_CACHE_MAX:
|
||||||
|
_touch_cache.clear()
|
||||||
|
# 先占位再写库:同一用户的并发请求不会同时打进来
|
||||||
|
_touch_cache[user_id] = now_mono
|
||||||
|
|
||||||
|
try:
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
|
||||||
|
now = get_beijing_time()
|
||||||
|
day = now.date() # 北京时间自然日(与报表分日口径一致)
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
await db.execute(
|
||||||
|
pg_insert(UserDailySeen)
|
||||||
|
.values(user_id=user_id, day=day, first_seen_at=now, last_seen_at=now)
|
||||||
|
# 冲突时只刷新 last_seen_at,first_seen_at 保持当天首次值不变
|
||||||
|
.on_conflict_do_update(
|
||||||
|
index_elements=["user_id", "day"],
|
||||||
|
set_={"last_seen_at": now},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception: # noqa: BLE001 —— 打点失败不影响业务
|
||||||
|
logger.exception("记录每日活动失败(已忽略)")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 日活 / 使用统计
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# 成功 = 2xx/3xx。登录失败(401)也要留痕,但不应计入"上线次数"。
|
||||||
|
_OK_STATUS_UPPER = 400
|
||||||
|
|
||||||
|
|
||||||
|
async def get_daily_usage(
|
||||||
|
db: AsyncSession, *, start: datetime, end: datetime,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""按【北京时间自然日 × 操作人】聚合用量 —— 日活报表的数据源。
|
||||||
|
|
||||||
|
start/end 为半开区间 [start, end),调用方按北京时间日界传入。
|
||||||
|
|
||||||
|
全部指标由**一个 GROUP BY 查询**算出,不用窗口函数:
|
||||||
|
· 登录/登出次数 = 成功登录 / 成功登出数(最终凭证是 login_count,不是"上线次数")
|
||||||
|
· 操作频次 = 当天该用户的全部审计记录数(代表系统使用深度)
|
||||||
|
· 登录/登出次数 = 成功登录 / 成功登出数
|
||||||
|
· 上线/下线时间 = 当天**首次 / 末次活动**(优先取 user_daily_seen)
|
||||||
|
|
||||||
|
⚠️ 上线/下线时间【不能】取登录/登出时间。
|
||||||
|
Access/Refresh Token 有效期内(refresh 7 天)用户无需重新登录,
|
||||||
|
于是"周一登录、周二到周日继续用"会导致周二~周日:
|
||||||
|
登录次数=0、登录时间=空,但操作次数却是几十 —— 报表自相矛盾。
|
||||||
|
|
||||||
|
⚠️ 也不能只取审计表的写操作时间:审计中间件只记写操作,普通 GET 不入账,
|
||||||
|
当天只翻看、没做写操作的人会被整条漏掉。
|
||||||
|
故上线/下线时间优先取 user_daily_seen(挂在每个请求上打点),
|
||||||
|
仅对本表上线前的历史数据回退到审计表的写操作时间。
|
||||||
|
|
||||||
|
为什么用 `count(*) FILTER (WHERE ...)`:分组内一次扫描同时算出多个条件计数,
|
||||||
|
比多次子查询或 UNION 简单得多,且语义一眼可读。Postgres 原生支持。
|
||||||
|
|
||||||
|
⚠️ 按【北京时间】分日:created_at 是 timestamptz(实存 UTC),
|
||||||
|
直接按 UTC 分日会让 00:00~08:00 的早班操作掉到前一天。
|
||||||
|
"""
|
||||||
|
day_col = func.date(func.timezone("Asia/Shanghai", AuditLog.created_at))
|
||||||
|
|
||||||
|
login_ok = and_(
|
||||||
|
AuditLog.action == "login", AuditLog.status_code < _OK_STATUS_UPPER,
|
||||||
|
)
|
||||||
|
logout_ok = and_(
|
||||||
|
AuditLog.action == "logout", AuditLog.status_code < _OK_STATUS_UPPER,
|
||||||
|
)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
day_col.label("day"),
|
||||||
|
AuditLog.user_id.label("user_id"),
|
||||||
|
# 同一用户的 display_name / role 是一致的,取 max 只是为了
|
||||||
|
# 在 GROUP BY 下拿到一个非空代表值(避免再套一层 DISTINCT ON)
|
||||||
|
func.max(AuditLog.display_name).label("display_name"),
|
||||||
|
func.max(AuditLog.role).label("role"),
|
||||||
|
func.count().filter(login_ok).label("login_count"),
|
||||||
|
func.count().filter(logout_ok).label("logout_count"),
|
||||||
|
func.count().label("op_count"),
|
||||||
|
# 上线/下线时间取「任意记录」的首末,而不是登录/登出的首末(原因见 docstring)
|
||||||
|
func.min(AuditLog.created_at).label("first_active_at"),
|
||||||
|
func.max(AuditLog.created_at).label("last_active_at"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
AuditLog.created_at >= start,
|
||||||
|
AuditLog.created_at < end,
|
||||||
|
# 只统计"人":未认证请求(如登录前的探测、refresh)没有操作人,
|
||||||
|
# 混进来会让"日活人数"虚高。若要排查匿名异常流量,走日志列表页按
|
||||||
|
# 结果/来源 IP 过滤更合适。
|
||||||
|
AuditLog.user_id.isnot(None),
|
||||||
|
)
|
||||||
|
.group_by(day_col, AuditLog.user_id)
|
||||||
|
.order_by(day_col.desc(), func.count().desc())
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = (await db.execute(stmt)).all()
|
||||||
|
|
||||||
|
# ── 活动表:当天首次/末次活动(覆盖"只翻看不操作"的人)──
|
||||||
|
# day 列是北京时间 DATE,与上面的 day_col 口径一致,可直接按 (user_id, day) 对齐。
|
||||||
|
# 取 start.date() ~ end.date()(end 是次日 00:00 的半开上界,故用 <)。
|
||||||
|
seen_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
UserDailySeen.user_id, UserDailySeen.day,
|
||||||
|
UserDailySeen.first_seen_at, UserDailySeen.last_seen_at,
|
||||||
|
).where(
|
||||||
|
UserDailySeen.day >= start.date(),
|
||||||
|
UserDailySeen.day < end.date(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
seen = {
|
||||||
|
(s.user_id, s.day.strftime("%Y-%m-%d")): (s.first_seen_at, s.last_seen_at)
|
||||||
|
for s in seen_rows
|
||||||
|
}
|
||||||
|
|
||||||
|
audit = {
|
||||||
|
(r.user_id, r.day.strftime("%Y-%m-%d") if hasattr(r.day, "strftime") else str(r.day)): r
|
||||||
|
for r in rows
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 合并 ──
|
||||||
|
# 并集:只有审计记录的人(本表上线前的历史数据)和只有活动记录的人
|
||||||
|
# (当天只翻看、没做写操作)都要出现,各自缺的部分留空/计 0。
|
||||||
|
items: list[dict] = []
|
||||||
|
for key in set(audit) | set(seen):
|
||||||
|
user_id, day = key
|
||||||
|
a = audit.get(key)
|
||||||
|
first_seen, last_seen = seen.get(key, (None, None))
|
||||||
|
|
||||||
|
# 取「两者的最早/最晚」,而不是简单地"活动表优先":
|
||||||
|
# 活动表靠请求触发且有 2 分钟节流,极端情况(跨零点被节流、
|
||||||
|
# 打点写库失败被吞掉)可能晚于当天第一次写操作。
|
||||||
|
# 取 min/max 后,结果永远不会比任一来源更差,也不需要为兜底写分支逻辑。
|
||||||
|
audit_first = a.first_active_at if a else None
|
||||||
|
audit_last = a.last_active_at if a else None
|
||||||
|
first_candidates = [t for t in (first_seen, audit_first) if t is not None]
|
||||||
|
last_candidates = [t for t in (last_seen, audit_last) if t is not None]
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
"day": day,
|
||||||
|
"user_id": user_id,
|
||||||
|
# 姓名字段只有审计记录里有(活动表为了轻量刻意不冗余存)
|
||||||
|
"display_name": a.display_name if a else None,
|
||||||
|
"role": a.role if a else None,
|
||||||
|
"login_count": (a.login_count or 0) if a else 0,
|
||||||
|
"logout_count": (a.logout_count or 0) if a else 0,
|
||||||
|
"op_count": (a.op_count or 0) if a else 0,
|
||||||
|
"first_active_at": min(first_candidates) if first_candidates else None,
|
||||||
|
"last_active_at": max(last_candidates) if last_candidates else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 与 SQL 里的排序保持一致:日期倒序 → 操作次数倒序
|
||||||
|
items.sort(key=lambda x: (x["day"], x["op_count"]), reverse=True)
|
||||||
|
return items
|
||||||
|
|||||||
75
frontend/src/components/admin/ExportColumnsModal.tsx
Normal file
75
frontend/src/components/admin/ExportColumnsModal.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* 导出列选择弹窗 —— 勾选要写进 CSV 的列。
|
||||||
|
*
|
||||||
|
* 列清单由后端 /audit/options 下发(value=后端列 key,label=中文表头),
|
||||||
|
* 前端不硬编码表头:否则两端各维护一份,迟早出现「导出的列和页面对不上」。
|
||||||
|
*
|
||||||
|
* 默认全选 —— 大多数人只是想"全部导出来",不该逼他们先勾一遍。
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Modal, Checkbox, Button } from "antd";
|
||||||
|
import type { AuditOption } from "../../services/auditApi";
|
||||||
|
|
||||||
|
export default function ExportColumnsModal({
|
||||||
|
open,
|
||||||
|
columns,
|
||||||
|
submitting,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
columns: AuditOption[];
|
||||||
|
submitting?: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
/** 传出当前勾选的列 key(顺序 = 后端下发顺序,保证表头稳定) */
|
||||||
|
onConfirm: (keys: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const [checked, setChecked] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 每次打开都重置为全选:上一次的勾选残留会让用户莫名少导几列
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setChecked(columns.map((c) => c.value));
|
||||||
|
}, [open, columns]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="选择导出列"
|
||||||
|
onCancel={onCancel}
|
||||||
|
width={520}
|
||||||
|
footer={
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="small" onClick={() => setChecked(columns.map((c) => c.value))}>
|
||||||
|
全选
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={() => setChecked([])}>
|
||||||
|
全不选
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={onCancel}>取消</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={submitting}
|
||||||
|
disabled={checked.length === 0}
|
||||||
|
onClick={() => onConfirm(checked)}
|
||||||
|
>
|
||||||
|
导出 ({checked.length} 列)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{checked.length === 0 && (
|
||||||
|
<p className="mb-2 text-xs text-amber-600">至少勾选一列才能导出。</p>
|
||||||
|
)}
|
||||||
|
<Checkbox.Group
|
||||||
|
value={checked}
|
||||||
|
onChange={(v) => setChecked(v as string[])}
|
||||||
|
className="grid grid-cols-3 gap-y-2"
|
||||||
|
options={columns.map((c) => ({ value: c.value, label: c.label }))}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -72,8 +72,10 @@ export default function AdminLayout() {
|
|||||||
return <Navigate to="/admin/login" replace />;
|
return <Navigate to="/admin/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
async function handleLogout() {
|
||||||
logout();
|
// 必须 await:logout() 要先完成审计上报再清 token,
|
||||||
|
// 提前 navigate 会把请求掐断,退出就留不下痕
|
||||||
|
await logout();
|
||||||
navigate("/admin/login", { replace: true });
|
navigate("/admin/login", { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { login as loginApi, getMe } from "../services/authApi";
|
import { login as loginApi, getMe, logout as logoutApi } from "../services/authApi";
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 类型
|
// 类型
|
||||||
@ -28,7 +28,8 @@ interface AuthState {
|
|||||||
|
|
||||||
interface AuthContextValue extends AuthState {
|
interface AuthContextValue extends AuthState {
|
||||||
login: (username: string, password: string) => Promise<void>;
|
login: (username: string, password: string) => Promise<void>;
|
||||||
logout: () => void;
|
/** 登出。async 是因为必须先 await 审计上报、再清 token —— 顺序反了会丢日志 */
|
||||||
|
logout: () => Promise<void>;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,7 +114,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setState({ user, token: accessToken, loading: false });
|
setState({ user, token: accessToken, loading: false });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(async () => {
|
||||||
|
// ⚠️ 必须【先 await 上报、再清 token】。两边顺序反了或不等,退出就留不下痕:
|
||||||
|
// 1) axios 的请求拦截器是在微任务里执行的,它去 localStorage 读 token 时,
|
||||||
|
// 同步的 logoutInternal() 早已把 token 清掉 → 请求不带 Authorization
|
||||||
|
// → 后端只能记成「未认证」,退出归因不到人;
|
||||||
|
// 2) 调用方点完退出还会立刻 navigate 到登录页,进一步压缩执行窗口。
|
||||||
|
// 所以这里(async) + 调用方(await) 两处都得改,只改一处等于没改。
|
||||||
|
// 失败绝不影响退出:JWT 无状态,服务端本就不需要它成功。
|
||||||
|
try {
|
||||||
|
await logoutApi();
|
||||||
|
} catch {
|
||||||
|
/* 静默:断网/超时也照退不误 */
|
||||||
|
}
|
||||||
logoutInternal();
|
logoutInternal();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@ -15,8 +15,10 @@ export default function ProfilePage() {
|
|||||||
const displayName = user?.display_name || user?.username || "未知用户";
|
const displayName = user?.display_name || user?.username || "未知用户";
|
||||||
const avatarChar = displayName.charAt(0);
|
const avatarChar = displayName.charAt(0);
|
||||||
|
|
||||||
function handleLogout() {
|
async function handleLogout() {
|
||||||
logout();
|
// 必须 await:logout() 要先完成审计上报再清 token,
|
||||||
|
// 提前 navigate 会把请求掐断,退出就留不下痕
|
||||||
|
await logout();
|
||||||
navigate("/admin/login", { replace: true });
|
navigate("/admin/login", { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,17 @@
|
|||||||
/** 操作审计日志 — 谁 / 何时 / 从哪 / 对什么 / 做了什么事 / 结果如何 */
|
/** 操作审计日志 — 谁 / 何时 / 从哪 / 对什么 / 做了什么事 / 结果如何 */
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { ScrollText, Loader2, AlertCircle, RefreshCw, Search, X } from "lucide-react";
|
import { ScrollText, Loader2, AlertCircle, RefreshCw, Search, X, Download, BarChart3 } from "lucide-react";
|
||||||
import { Table, Tag, Input, Select, DatePicker, Button, Tooltip, Drawer, Descriptions } from "antd";
|
import { Table, Tag, Input, Select, DatePicker, Button, Tooltip, Drawer, Descriptions } from "antd";
|
||||||
import type { ColumnsType } from "antd/es/table";
|
import type { ColumnsType } from "antd/es/table";
|
||||||
import dayjs, { type Dayjs } from "dayjs";
|
import dayjs, { type Dayjs } from "dayjs";
|
||||||
import { fetchAuditLogs, fetchAuditOptions, type AuditLogItem, type AuditOption } from "../../services/auditApi";
|
import {
|
||||||
|
fetchAuditLogs, fetchAuditOptions, exportAuditLogsCsv,
|
||||||
|
type AuditLogItem, type AuditOption,
|
||||||
|
} from "../../services/auditApi";
|
||||||
import { extractErrorMessage } from "../../utils/errorMessage";
|
import { extractErrorMessage } from "../../utils/errorMessage";
|
||||||
|
import { useToast } from "../../components/ui/Toast";
|
||||||
|
import AuditUsagePanel from "./AuditUsagePanel";
|
||||||
|
import ExportColumnsModal from "../../components/admin/ExportColumnsModal";
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
@ -36,8 +42,15 @@ export default function AdminAuditLogPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [detail, setDetail] = useState<AuditLogItem | null>(null);
|
const [detail, setDetail] = useState<AuditLogItem | null>(null);
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
const [modules, setModules] = useState<AuditOption[]>([]);
|
const [modules, setModules] = useState<AuditOption[]>([]);
|
||||||
const [actions, setActions] = useState<AuditOption[]>([]);
|
const [actions, setActions] = useState<AuditOption[]>([]);
|
||||||
|
/** 导出可选列 —— 由后端下发,前端不硬编码表头 */
|
||||||
|
const [logColumns, setLogColumns] = useState<AuditOption[]>([]);
|
||||||
|
|
||||||
|
const [usageOpen, setUsageOpen] = useState(false); // 人员统计抽屉
|
||||||
|
const [exportPickerOpen, setExportPickerOpen] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
// 筛选条件(user_id 用受控输入,其余即时生效)
|
// 筛选条件(user_id 用受控输入,其余即时生效)
|
||||||
const [userInput, setUserInput] = useState("");
|
const [userInput, setUserInput] = useState("");
|
||||||
@ -81,12 +94,43 @@ export default function AdminAuditLogPage() {
|
|||||||
.then((o) => {
|
.then((o) => {
|
||||||
setModules(o.modules);
|
setModules(o.modules);
|
||||||
setActions(o.actions);
|
setActions(o.actions);
|
||||||
|
setLogColumns(o.log_export_columns || []);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* 筛选项拉取失败不影响列表本身 */
|
/* 筛选项拉取失败不影响列表本身 */
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出当前筛选条件下的明细。
|
||||||
|
* 刻意复用与列表完全相同的筛选参数 —— 导出与"看到的"必须是同一批数据,
|
||||||
|
* 否则使用者会怀疑到底哪份才是真的。
|
||||||
|
*/
|
||||||
|
async function handleExport(columns: string[]) {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const truncated = await exportAuditLogsCsv({
|
||||||
|
user_id: userId || undefined,
|
||||||
|
module,
|
||||||
|
action,
|
||||||
|
status_code: statusCode,
|
||||||
|
start_date: range?.[0]?.format("YYYY-MM-DD"),
|
||||||
|
end_date: range?.[1]?.format("YYYY-MM-DD"),
|
||||||
|
columns,
|
||||||
|
});
|
||||||
|
setExportPickerOpen(false);
|
||||||
|
// 截断必须显式告知:静默少几万行比报错更危险
|
||||||
|
toast(
|
||||||
|
truncated ? "已导出,但数据超上限已被截断,请收窄筛选条件" : "已导出 CSV",
|
||||||
|
truncated ? "error" : "success",
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
toast(extractErrorMessage(e, "导出失败"), "error");
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const hasFilter = !!(userId || module || action || statusCode || range);
|
const hasFilter = !!(userId || module || action || statusCode || range);
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
@ -185,9 +229,23 @@ export default function AdminAuditLogPage() {
|
|||||||
所有写操作(含被拒绝的请求)自动留痕,共 {total} 条
|
所有写操作(含被拒绝的请求)自动留痕,共 {total} 条
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button icon={<RefreshCw className="h-4 w-4" />} onClick={() => void load()} loading={loading}>
|
<div className="flex items-center gap-2">
|
||||||
刷新
|
{/* 人员统计刻意做成抽屉而不是标签页:两个视图的粒度不同
|
||||||
</Button>
|
(一行一次操作 vs 一人一天一行),并列成 Tab 会让筛选状态互相干扰 */}
|
||||||
|
<Button icon={<BarChart3 className="h-4 w-4" />} onClick={() => setUsageOpen(true)}>
|
||||||
|
人员统计
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<Download className="h-4 w-4" />}
|
||||||
|
disabled={total === 0}
|
||||||
|
onClick={() => setExportPickerOpen(true)}
|
||||||
|
>
|
||||||
|
导出 CSV
|
||||||
|
</Button>
|
||||||
|
<Button icon={<RefreshCw className="h-4 w-4" />} onClick={() => void load()} loading={loading}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 筛选区 */}
|
{/* 筛选区 */}
|
||||||
@ -350,6 +408,17 @@ export default function AdminAuditLogPage() {
|
|||||||
</Descriptions>
|
</Descriptions>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
{/* 人员统计:独立抽屉,本页表格与筛选完全不受影响 */}
|
||||||
|
<AuditUsagePanel open={usageOpen} onClose={() => setUsageOpen(false)} />
|
||||||
|
|
||||||
|
<ExportColumnsModal
|
||||||
|
open={exportPickerOpen}
|
||||||
|
columns={logColumns}
|
||||||
|
submitting={exporting}
|
||||||
|
onCancel={() => setExportPickerOpen(false)}
|
||||||
|
onConfirm={handleExport}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
207
frontend/src/pages/admin/AuditUsagePanel.tsx
Normal file
207
frontend/src/pages/admin/AuditUsagePanel.tsx
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* 人员统计(日活报表)—— 以抽屉形式挂在操作审计页旁边。
|
||||||
|
*
|
||||||
|
* 回答的是「每天有哪些人用了系统、用了多少」:
|
||||||
|
* 上线次数 / 上线时间、下线次数 / 下线时间、操作次数。
|
||||||
|
*
|
||||||
|
* 刻意不复用审计明细页的表格:两者的粒度不同(一个是一行一次操作,
|
||||||
|
* 一个是一人一天一行),合在一起筛选状态会互相干扰。
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Drawer, Table, DatePicker, Button, Alert, Tag, Empty } from "antd";
|
||||||
|
import type { ColumnsType } from "antd/es/table";
|
||||||
|
import { Download, Loader2, RefreshCw } from "lucide-react";
|
||||||
|
import dayjs, { type Dayjs } from "dayjs";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
import {
|
||||||
|
fetchDailyUsage, fetchAuditOptions, exportDailyUsageCsv,
|
||||||
|
type DailyUsageRow, type AuditOption,
|
||||||
|
} from "../../services/auditApi";
|
||||||
|
import { extractErrorMessage } from "../../utils/errorMessage";
|
||||||
|
import { useToast } from "../../components/ui/Toast";
|
||||||
|
import ExportColumnsModal from "../../components/admin/ExportColumnsModal";
|
||||||
|
|
||||||
|
// 后端返回的是 UTC,而统计按【北京时间自然日】分组。
|
||||||
|
// 必须显式按 +08:00 渲染 —— 依赖浏览器本地时区的话,一旦有人机器不在东八区,
|
||||||
|
// 时间就会和「日期」列对不上(比如显示 17:00 而日期是次日)。
|
||||||
|
dayjs.extend(utc);
|
||||||
|
const BJ_OFFSET_MIN = 8 * 60;
|
||||||
|
|
||||||
|
function bjTime(v: string | null): string {
|
||||||
|
if (!v) return "—";
|
||||||
|
return dayjs.utc(v).utcOffset(BJ_OFFSET_MIN).format("HH:mm");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「上线 vs 下线」次数配色:有记录就显眼,0 就淡化 */
|
||||||
|
function countTag(n: number, cls: string) {
|
||||||
|
if (!n) return <span className="text-gray-300">0</span>;
|
||||||
|
return <Tag className={`${cls} border-0 font-semibold`}>{n}</Tag>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditUsagePanel({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [rows, setRows] = useState<DailyUsageRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]);
|
||||||
|
|
||||||
|
const [usageColumns, setUsageColumns] = useState<AuditOption[]>([]);
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetchDailyUsage({
|
||||||
|
start_date: range[0].format("YYYY-MM-DD"),
|
||||||
|
end_date: range[1].format("YYYY-MM-DD"),
|
||||||
|
});
|
||||||
|
setRows(res.items);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(extractErrorMessage(err, "加载使用统计失败"));
|
||||||
|
setRows([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) load();
|
||||||
|
}, [open, load]);
|
||||||
|
|
||||||
|
// 列清单只需拉一次;失败不阻断表格本身
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || usageColumns.length) return;
|
||||||
|
fetchAuditOptions()
|
||||||
|
.then((o) => setUsageColumns(o.usage_export_columns || []))
|
||||||
|
.catch(() => { /* 拉不到列清单只影响导出,不影响查看 */ });
|
||||||
|
}, [open, usageColumns.length]);
|
||||||
|
|
||||||
|
async function handleExport(columns: string[]) {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const truncated = await exportDailyUsageCsv({
|
||||||
|
start_date: range[0].format("YYYY-MM-DD"),
|
||||||
|
end_date: range[1].format("YYYY-MM-DD"),
|
||||||
|
columns,
|
||||||
|
});
|
||||||
|
setPickerOpen(false);
|
||||||
|
toast(truncated ? "已导出(数据超上限,已截断)" : "已导出 CSV", truncated ? "error" : "success");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast(extractErrorMessage(err, "导出失败"), "error");
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const multiDay = range[0].format("YYYY-MM-DD") !== range[1].format("YYYY-MM-DD");
|
||||||
|
|
||||||
|
const columns: ColumnsType<DailyUsageRow> = [
|
||||||
|
// 单日查询时日期列是冗余的,自动隐藏,少一列噪音
|
||||||
|
...(multiDay
|
||||||
|
? [{ title: "日期", dataIndex: "day", width: 110,
|
||||||
|
sorter: (a: DailyUsageRow, b: DailyUsageRow) => a.day.localeCompare(b.day) }]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
title: "操作人", dataIndex: "display_name", width: 160,
|
||||||
|
render: (_: unknown, r: DailyUsageRow) => (
|
||||||
|
<div className="leading-tight">
|
||||||
|
<div className="text-gray-900">{r.display_name || r.user_id || "—"}</div>
|
||||||
|
{r.display_name && <div className="text-xs text-gray-400">{r.user_id}</div>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// 上线/下线时间 = 当天首次/末次【活动】。token 有效期内用户不重新登录,
|
||||||
|
// 若取登录时间会得出"登录 0 次却操作 35 次"的矛盾数据(见后端 docstring)
|
||||||
|
{ title: "上线时间", dataIndex: "first_active_at", width: 100, align: "center",
|
||||||
|
render: (v: string | null) => <span className="font-mono text-gray-700">{bjTime(v)}</span> },
|
||||||
|
{ title: "下线时间", dataIndex: "last_active_at", width: 100, align: "center",
|
||||||
|
render: (v: string | null) => <span className="font-mono text-gray-700">{bjTime(v)}</span> },
|
||||||
|
{ title: "操作次数", dataIndex: "op_count", width: 110, align: "center",
|
||||||
|
render: (v: number) => <span className="font-bold text-blue-600">{v}</span>,
|
||||||
|
sorter: (a: DailyUsageRow, b: DailyUsageRow) => a.op_count - b.op_count,
|
||||||
|
defaultSortOrder: "descend" as const },
|
||||||
|
// 登录/登出次数是真实的手动行为计数,与上面的活动时间并列展示,不混为一谈
|
||||||
|
{ title: "登录次数", dataIndex: "login_count", width: 100, align: "center",
|
||||||
|
render: (v: number) => countTag(v, "bg-emerald-100 text-emerald-700"),
|
||||||
|
sorter: (a: DailyUsageRow, b: DailyUsageRow) => a.login_count - b.login_count },
|
||||||
|
{ title: "登出次数", dataIndex: "logout_count", width: 100, align: "center",
|
||||||
|
render: (v: number) => countTag(v, "bg-blue-100 text-blue-700"),
|
||||||
|
sorter: (a: DailyUsageRow, b: DailyUsageRow) => a.logout_count - b.logout_count },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
width={1000}
|
||||||
|
title="📊 人员统计(日活)"
|
||||||
|
extra={
|
||||||
|
<Button icon={<RefreshCw className="h-3.5 w-3.5" />} onClick={load} disabled={loading}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
<DatePicker.RangePicker
|
||||||
|
value={range}
|
||||||
|
allowClear={false}
|
||||||
|
onChange={(v) => { if (v?.[0] && v?.[1]) setRange([v[0], v[1]]); }}
|
||||||
|
presets={[
|
||||||
|
{ label: "今天", value: [dayjs(), dayjs()] },
|
||||||
|
{ label: "昨天", value: [dayjs().subtract(1, "day"), dayjs().subtract(1, "day")] },
|
||||||
|
{ label: "近 7 天", value: [dayjs().subtract(6, "day"), dayjs()] },
|
||||||
|
{ label: "本月", value: [dayjs().startOf("month"), dayjs()] },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<Download className="h-3.5 w-3.5" />}
|
||||||
|
disabled={rows.length === 0}
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
>
|
||||||
|
导出 CSV
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{rows.length > 0 && `共 ${rows.length} 人·天`} | 时间均为北京时间
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert type="error" showIcon className="mb-3" message={error} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 两处口径容易被误读,直接写在表格上方 */}
|
||||||
|
<p className="mb-3 text-xs text-gray-400">
|
||||||
|
ⓘ 「上线/下线时间」= 当天首次/末次<strong>活动</strong>时间,不是登录时间 ——
|
||||||
|
登录状态可保持 7 天,当天不登录也会正常统计。
|
||||||
|
「登录/登出次数」是真实的手动登录行为计数,登出通常少于登录(关浏览器、断网不产生登出记录)。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Table<DailyUsageRow>
|
||||||
|
rowKey={(r) => `${r.day}|${r.user_id ?? ""}`}
|
||||||
|
size="small"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={rows}
|
||||||
|
loading={{ spinning: loading, indicator: <Loader2 className="h-5 w-5 animate-spin text-blue-500" /> }}
|
||||||
|
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `共 ${t} 条` }}
|
||||||
|
locale={{ emptyText: <Empty description="该时段没有使用记录" /> }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExportColumnsModal
|
||||||
|
open={pickerOpen}
|
||||||
|
columns={usageColumns}
|
||||||
|
submitting={exporting}
|
||||||
|
onCancel={() => setPickerOpen(false)}
|
||||||
|
onConfirm={handleExport}
|
||||||
|
/>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -44,6 +44,34 @@ export interface AuditOption {
|
|||||||
export interface AuditOptionsResponse {
|
export interface AuditOptionsResponse {
|
||||||
modules: AuditOption[];
|
modules: AuditOption[];
|
||||||
actions: AuditOption[];
|
actions: AuditOption[];
|
||||||
|
/** 导出可选列(value=后端列 key,label=中文表头)—— 由后端下发,前端不再硬编码 */
|
||||||
|
log_export_columns: AuditOption[];
|
||||||
|
usage_export_columns: AuditOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 日活统计的单行(某人在某一天的用量) */
|
||||||
|
export interface DailyUsageRow {
|
||||||
|
day: string;
|
||||||
|
user_id: string | null;
|
||||||
|
display_name: string | null;
|
||||||
|
role: string | null;
|
||||||
|
login_count: number;
|
||||||
|
logout_count: number;
|
||||||
|
op_count: number;
|
||||||
|
/**
|
||||||
|
* 上线 / 下线时间 = 当天首次 / 末次【活动】时间(不是登录时间)。
|
||||||
|
* token 有效期内用户不重新登录,按登录算会得出"登录 0 次却操作 35 次"的矛盾数据。
|
||||||
|
* ISO(UTC),展示前必须转北京时间,否则会和 day 列对不上。
|
||||||
|
*/
|
||||||
|
first_active_at: string | null;
|
||||||
|
last_active_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyUsageResponse {
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
items: DailyUsageRow[];
|
||||||
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditLogQuery {
|
export interface AuditLogQuery {
|
||||||
@ -70,8 +98,74 @@ export async function fetchAuditLogs(q: AuditLogQuery = {}): Promise<AuditLogLis
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取模块/动作筛选项 */
|
/** 获取模块/动作筛选项(含导出可选列) */
|
||||||
export async function fetchAuditOptions(): Promise<AuditOptionsResponse> {
|
export async function fetchAuditOptions(): Promise<AuditOptionsResponse> {
|
||||||
const { data } = await api.get<AuditOptionsResponse>("/audit/options");
|
const { data } = await api.get<AuditOptionsResponse>("/audit/options");
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 日活 / 使用统计 —— 按【北京时间自然日 × 操作人】聚合 */
|
||||||
|
export async function fetchDailyUsage(params: {
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
} = {}): Promise<DailyUsageResponse> {
|
||||||
|
const { data } = await api.get<DailyUsageResponse>("/audit/daily-usage", { params });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发浏览器下载一个 CSV。
|
||||||
|
*
|
||||||
|
* ⚠️ 不能直接用 <a href="/api/..."> 或 window.open:本项目是 Bearer Token 鉴权
|
||||||
|
* (token 在 localStorage,不在 Cookie),普通链接带不上 Authorization 头,
|
||||||
|
* 后端会直接 401。必须先经 axios 取回 blob 再本地落盘。
|
||||||
|
*
|
||||||
|
* @returns 是否因超出后端行数上限而被截断(调用方据此提示用户,不要静默)
|
||||||
|
*/
|
||||||
|
async function downloadCsv(
|
||||||
|
path: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
filename: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const resp = await api.get(path, { params, responseType: "blob" });
|
||||||
|
const url = URL.createObjectURL(resp.data as Blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
return resp.headers["x-export-truncated"] === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出审计明细(列可自定义,columns 为后端列 key 数组;不传=全部列) */
|
||||||
|
export function exportAuditLogsCsv(
|
||||||
|
q: AuditLogQuery & { columns?: string[] },
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { columns, ...rest } = q;
|
||||||
|
return downloadCsv(
|
||||||
|
"/audit/logs/export",
|
||||||
|
{ ...clean(rest), columns: columns?.join(",") },
|
||||||
|
"audit_logs.csv",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出日活统计(每人一行,列可自定义) */
|
||||||
|
export function exportDailyUsageCsv(
|
||||||
|
params: { start_date?: string; end_date?: string; columns?: string[] },
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { columns, ...rest } = params;
|
||||||
|
return downloadCsv(
|
||||||
|
"/audit/daily-usage/export",
|
||||||
|
{ ...clean(rest), columns: columns?.join(",") },
|
||||||
|
"daily_usage.csv",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 去掉 undefined / null / 空串,避免拼出 ?a=&b= 这类空参数 */
|
||||||
|
function clean(o: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
/** 认证 API — 登录、刷新 Token、获取用户信息 */
|
/** 认证 API — 登录、刷新 Token、获取用户信息、登出留痕 */
|
||||||
|
import api from "./api";
|
||||||
import type { UserInfo } from "../contexts/AuthContext";
|
import type { UserInfo } from "../contexts/AuthContext";
|
||||||
import { extractErrorMessage } from "../utils/errorMessage";
|
import { extractErrorMessage } from "../utils/errorMessage";
|
||||||
|
|
||||||
@ -52,3 +53,18 @@ export async function getMe(token: string): Promise<UserInfo> {
|
|||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登出上报 —— 唯一目的是【审计留痕】。
|
||||||
|
*
|
||||||
|
* JWT 无状态,服务端不会(也无法)吊销令牌,本地清 token 就是登出。
|
||||||
|
* 但没有这个请求,前端的「退出」动作在审计里完全不可见,所以必须上报一次。
|
||||||
|
*
|
||||||
|
* ⚠️ 调用方必须 **await 本函数之后**才清 localStorage 与跳转:
|
||||||
|
* axios 的请求拦截器在微任务里执行、需要现读 localStorage 取 token。
|
||||||
|
* 若不等就同步清空并 navigate,请求会不带 Authorization(或直接被掐断),
|
||||||
|
* 后端只能记成「未认证」,退出归因不到人 —— 实测审计里 logout 记录为 0。
|
||||||
|
*/
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
await api.post("/auth/logout");
|
||||||
|
}
|
||||||
|
|||||||
@ -38,6 +38,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { checkAppUpdate } from "../../utils/ota";
|
import { checkAppUpdate } from "../../utils/ota";
|
||||||
|
import { post } from "../../utils/request";
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 缓存清理策略 —— 黑名单式:只删「明确登记过的业务缓存」
|
// 缓存清理策略 —— 黑名单式:只删「明确登记过的业务缓存」
|
||||||
@ -167,15 +168,31 @@ async function handleCheckUpdate() {
|
|||||||
await checkAppUpdate({ manual: true });
|
await checkAppUpdate({ manual: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
async function handleLogout() {
|
||||||
// 退出是不可逆的(要重新输账号密码),按车间使用场景加一道确认防误触
|
// 退出是不可逆的(要重新输账号密码),按车间使用场景加一道确认防误触
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: "退出登录",
|
title: "退出登录",
|
||||||
content: "退出后需要重新输入账号密码,确定退出吗?",
|
content: "退出后需要重新输入账号密码,确定退出吗?",
|
||||||
confirmText: "退出",
|
confirmText: "退出",
|
||||||
cancelText: "取消",
|
cancelText: "取消",
|
||||||
success: (res) => {
|
success: async (res) => {
|
||||||
if (!res.confirm) return;
|
if (!res.confirm) return;
|
||||||
|
|
||||||
|
// 🔴 必须【先 await 上报、再清 token】——两者顺序反了或不等,退出就留不下痕:
|
||||||
|
// 1) uni.reLaunch 会销毁页面上下文,直接掐断尚未发出的 uni.request;
|
||||||
|
// 2) 而 request.js 是在发送时才从 storage 读 access_token,
|
||||||
|
// 先清 storage 的话请求会不带 Authorization,后端只能记成「未认证」。
|
||||||
|
// 这里刻意 try/catch 兜住:上报失败(断网/超时)也绝不能挡住用户退出。
|
||||||
|
uni.showLoading({ title: "退出中...", mask: true });
|
||||||
|
try {
|
||||||
|
await post("/auth/logout");
|
||||||
|
} catch (e) {
|
||||||
|
// 静默:JWT 无状态,服务端本就不需要它成功
|
||||||
|
console.warn("[logout] 上报失败(不影响退出)", e);
|
||||||
|
} finally {
|
||||||
|
uni.hideLoading();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
uni.removeStorageSync("token");
|
uni.removeStorageSync("token");
|
||||||
uni.removeStorageSync("access_token");
|
uni.removeStorageSync("access_token");
|
||||||
|
|||||||
Reference in New Issue
Block a user