13 Commits

Author SHA1 Message Date
192c8ee9cc feat(audit): 扩大 GET 采集范围 + 修正动作文案
【扩大采集:核心业务数据的「查看详情」】
新增 _TRACKED_READ_PREFIXES(notifications / tasks / orders /
products / records)—— products 是补的:GET /products/scan/{sn}(扫码查询)
是整个车间最高频的读操作,不采它等于没采"活跃度"。

_should_audit 的 GET 分支改为三级判断:
  敏感读(export/download/print) → 受跟踪前缀且非裸列表 → 否则不采

用 _is_bare_list 跳过「拉整个列表」:
  · 列表接口被前端高频轮询(消息、任务列表尤其明显),逐条留痕会让
    audit_logs 迅速膨胀,真正有价值的操作反而被淹没;
  · 只有「查看详情」(/tasks/{id}) 才代表用户真的点开了某条业务数据。
  判定用「去掉末尾斜杠后是否恰好等于某前缀」,天然排除查询串。

【修正文案】
- "read": "查询" → "查看详情"(前者易被误解成"随便搜了一下")
- 新增 "mark_read": "标为已读",并在 _SEGMENT_ACTION 补 "read" 映射 ——
  否则 PUT /notifications/{id}/read 会回退到 _METHOD_ACTION(PUT→update),
  把"点开一条通知"记成"修改了某样东西"
- "refresh": "刷新令牌" → "上线"(token 2 小时一换,业务上视作一次上线)

⚠️ 两点须知:
1. notifications / orders 目录下【只有列表路由】,而列表按规则不采集,
   故这两个模块不会产生查看记录 —— 后端没有"查看单条消息"的接口。
   且消息列表被前端轮询,采集它反而会造成日志爆炸,跳过是正确的。
2. products 被纳入后,工人每扫一次码就多一条记录(50 人 × 每天数百次
   ≈ 上万条/天)。若嫌吵,去掉该前缀一行即可。

注:读接口鉴权已在上一提交补齐,故这些"查看详情"记录能正确挂上操作人。
2026-09-21 13:47:44 +08:00
3f34652b07 fix(security): 核心读接口补齐鉴权(P0 — 无 token 可直接拉取业务数据)
现象:不带任何 Token 请求 /api/v1/products/scan/{sn} 等接口直接 200,
业务数据(产品、任务、订单)可被匿名读取。

同时这也是审计「操作人恒为未认证」的根因:
这些路由只挂了 Depends(get_db),JWT 依赖不执行 →
request.state.audit_user 从未写入 → 中间件只能记成「未认证」。

修复:为 3 个文件共 9 条 GET 路由统一补上 Depends(get_current_user)

  products.py  GET /qrcode/{serial_number}
               GET /scan/{serial_number}      ← 匿名可读产品数据
               GET /                          ← 列表
               GET /{product_id}
               GET /{product_id}/messages
  tasks.py     GET /
               GET /{task_id}
               GET /by-product/{product_id}
  orders.py    GET /

未改动:notifications.py 本就有鉴权;records.py 无 GET 路由。
三个文件原本已 import get_current_user,未新增导入。

落地前已排查「是否有意免登」:
- /products/qrcode/{sn} 是 PC 打印标签用,非免登场景
- 外部系统调用走独立通道 /external/products/lookup(X-API-Key 鉴权),
  与内部 /products/* 完全分离
故内部读接口本就应要求登录。

⚠️ 连带影响:二维码标签编码的是前端页面地址(/sn/{序列号}),
   此前任何人用手机相机扫码即可查看产品状态,现在会要求登录。
   若业务需要免登查询,应走带 API-Key 的 /external/products/lookup,
   而不是让内部接口裸奔。
2026-09-21 13:47:37 +08:00
df3f914eb1 fix(audit): 修复「退出登录」无日志(PC + 移动端)
现象:审计日志里 logout 记录数恒为 0,退出动作完全不可见。

根因有两处,缺一不可:

1. 移动端(App)根本没有发起过上报
   settings.vue 的 handleLogout 只做 removeStorageSync + reLaunch,
   一个请求都没发。而 uni.reLaunch 会销毁页面上下文、直接掐断未完成的
   uni.request —— 所以必须「先 await 上报、再清 token 与跳转」。

2. PC 端存在时序竞态
   axios 的请求拦截器在微任务里执行、现读 localStorage 取 token;
   而原实现同步清空 localStorage 并立刻 navigate,拦截器跑到时 token
   已经没了 → 请求不带 Authorization → 后端只能记成「未认证」。
   注:只改 AuthContext 不够,调用方 AdminLayout / ProfilePage 原来是
   `logout(); navigate(...)`,不等就跳转照样会掐断请求 —— 三处都得改。

改动:
- 移动端 handleLogout 改 async,await post("/auth/logout") 后再清 token
- AuthContext.logout 改 async(类型同步为 () => Promise<void>),
  先 await 上报再清 token;失败静默,绝不阻断退出
- AdminLayout / ProfilePage 两个调用点补 await
- 全部用 try-catch 兜住:断网/超时也照退不误(JWT 无状态,
  服务端本就不需要它成功)
2026-09-21 13:47:31 +08:00
c3667fe00d fix(audit): 刷新令牌记录不再显示「未认证」
问题:审计列表里 POST /auth/refresh 的操作人恒为「未认证」。
根因:本接口刻意不挂 get_current_user —— 能用到这里,正是因为 access token
已过期、请求里没有 Authorization 头,JWT 依赖不执行,request.state 里
从未写入操作人。而"谁在何时尝试刷新"恰恰是该留痕的信息。

修复:
- core/security.py 新增 peek_token_identity(),与 decode_token 的唯一区别是
  关闭过期校验(刷新场景令牌本就过期,若因过期解不出来还是会漏记)。
  签名校验照常进行,伪造令牌解不出任何东西。
  ⚠️ docstring 中明确:该函数只许用于写审计字段,鉴权一律走 get_current_user
- refresh 接口解码 refresh token 取得 sub/username/display_name/role 写入 state。
  refresh token 的载荷与 access token 完全一致,只有 type 字段不同。

附带效果:活动打点读的正是 request.state.audit_user,修复后刷新请求也会
被计为一次活动 —— 语义正确(会刷新说明用户正在使用)。

验证(7/7):正常刷新记到中文人名与角色;过期令牌虽被拒 401 但仍能记到人;
伪造令牌不认人、显示未认证。历史记录不追溯。
2026-09-21 13:05:41 +08:00
39697ca3ad feat(audit): 每日活动打点表 —— 修正日活「上线/下线时间」口径
【问题】
上线/下线时间此前取自审计记录(写操作)时间,当天只翻看、没做写操作的人
会被整条漏掉;而取登录时间更错(Refresh Token 有效期 7 天,用户不必每天登录,
会出现「登录次数 0 却操作 35 次」的自相矛盾报表)。

【方案 C+:一天一人一行的小状态表】
- 新增 user_daily_seen(user_id, day, first_seen_at, last_seen_at),
  迁移 k1l2m3n4o5p6(紧接 j1k2l3m4n5o6)
- 打点挂在 RequestContextMiddleware —— 它是最外层,能覆盖【所有】请求,
  含不被审计的普通 GET。挂审计中间件没用:那里只记写操作,正是漏人的原因
- 节流:进程内缓存,同一用户 2 分钟内只落盘一次,把"每请求一次写库"
  压到"每人每 2 分钟一次";代价是末次活动时间最多落后 2 分钟
- UPSERT on_conflict_do_update 只刷 last_seen_at,first_seen_at 保持当天首次值
- 打点失败全部吞掉并记日志,绝不影响业务请求

【为什么不复用 audit_logs】
「末次活动」是需要不断 UPDATE 的状态,而审计流水必须只增不改 ——
能改的审计记录等于没有审计价值。写进审计表还会让表随访问量线性膨胀。

【查询合并】
get_daily_usage 改为「活动表 ∪ 审计表」并集:只有活动记录的(只看不操作)
和只有审计记录的(本表上线前的历史数据)都会出现。
上线/下线时间取两者的【最早 / 最晚】而非"活动表优先"——打点有 2 分钟节流、
跨零点或写库失败时可能晚于当天第一次写操作,取 min/max 后结果永不劣于任一来源。

零前端改动、零移动端发版:打点在服务端,PC 与移动端同一套口径、同一张表。
2026-09-21 13:05:35 +08:00
1fea30b03b feat(audit): 日活使用统计 + 双 CSV 导出(审计/上下线,列可自定义)
【日活统计:GET /audit/daily-usage】
按【北京时间自然日 × 操作人】聚合,单个 GROUP BY 完成(count(*) FILTER),
不用窗口函数。指标:上线/下线时间、操作次数、登录/登出次数。

⚠️ 上线/下线时间取【当天首次/末次活动】,刻意不取登录时间:
   Refresh Token 有效期 7 天,用户不必每天重新登录。按登录算会出现
   「登录次数 0、上线时间空,但操作次数 35」——报表自相矛盾。
   时间一律按 +08:00 分日与渲染,否则早班(00:00~08:00)操作会掉到前一天。

【CSV 导出:两个端点 + 列自定义】
- /audit/logs/export        整体审计导出,筛选维度与列表页完全一致
- /audit/daily-usage/export 上下线导出,每人一行
- 列清单由后端统一维护并经 /audit/options 下发(log_export_columns /
  usage_export_columns),前端不硬编码表头,避免两端漂移
- ⚠️ 响应带 UTF-8 BOM:Excel 靠它识别编码,否则中文表头全乱码
- 单次上限 5 万行,超出经 X-Export-Truncated 头告知前端明确提示
  (静默截断比报错更危险)
- list_audit_logs 与 export_audit_logs 共用 _log_filters,
  保证「看到的」与「导出的」永远是同一批数据

【前端】
- 审计页页头新增「人员统计」「导出 CSV」两个按钮,现有表格与筛选零改动
- 人员统计走抽屉(AuditUsagePanel):日期范围+快捷键、日活表格、
  上下线次数彩色标签、北京时间渲染、导出前弹列勾选面板
- ExportColumnsModal 为两处导出共用,默认全选
2026-09-21 12:06:43 +08:00
5290d83463 feat: MOM 撤回出库强制回滚通道
MOM 把误点出库的设备物理回滚到仓库时,Track 被动跟随 MOM 的权威物理状态。

【撤回信号识别】
- action / event 里的 revoke / rollback / revert / cancel 子串匹配
  (MOM 侧字段命名尚未冻结,刻意宽松,避免对方改词就整条链路失联)
- 无显式标记但产品正处于「已出库」时,按"已发货设备收到入库回调 = 货回来了"
  隐式判定

【匹配放宽】
- 常规入库仍严格要求 current_location_id == virtual_warehouse
- 撤回信号、或 payload 带 serial 时才放行「已出库」产品 —— 出库回调已把
  location 置为 None,不放宽则撤回必然失配、静默返回 matched=False
- 刻意不给 sku 兜底也无条件放宽:同型号可能多台,放宽会误标到别的设备

【特权通道】
- 强制覆写 overall_status=已入库 + 位置回滚 virtual_warehouse,优先级高于
  task_service 的【绝对物理终态保护】。两者方向刻意相反:那套约束的是
  "车间内部流转不许用工序名抹掉物理终态",而本接口是物理事实的权威来源。
  代码内已留醒目注释,防止后续维护者误加状态互斥校验
- 改用 sync_product_status 统一双字段同步(原先硬编码 status="ARCHIVED"
  绕过了 lifecycle.py 的约定),并把 status 变化一并计入 changed,
  避免"状态与位置本就正确时纠偏不提交"

【撤回留痕】
- 追加「撤回出库(重新入库)」主线节点。名称里的「入库」二字是必须保留的契约:
  product_service._has_warehouse_task 用子串判定仓库节点,若只有"出库"会让
  location==virtual_warehouse 的产品被注入假的「待仓库收货」虚拟节点,
  出现"已入库却在等收货"的自相矛盾

【重构】
- 抽出 _pick_warehouse_log_task / _match_inbound_product 复用,出库回调同步简化
2026-09-21 11:21:03 +08:00
17b2fab5ca fix(audit): 补齐「退出」留痕 + 登录记录显示中文操作人
【问题①:退出动作在审计里完全不可见】
根因:后端没有 logout 接口。前端「退出」只清本地 localStorage、不产生任何
请求,中间件自然无从采集(中间件里早已预留 "logout" 动作映射,但端点没做)。

- 新增 POST /auth/logout,【仅用于留痕】。JWT 无状态,服务端本就没有可吊销
  的会话,该接口不做任何令牌失效动作 —— 它存在的唯一目的,是让审计记下
  「谁在何时退出了系统」。这点在 docstring 里写明,免得日后被误当安全边界。
- 挂 Depends(get_current_user),让 JWT 依赖把操作人写进 request.state,
  从而记录到真实姓名而非「未认证」
- 前端 logout 改为【先上报、后清 token】。顺序不能反:api.ts 的请求拦截器
  是从 localStorage 取 token 的,清掉后就发不出这个请求了。
  刻意不 await、失败也不阻断 —— 用户点「退出」必须退得掉。

【问题②:登录记录的「操作人」显示英文账号而非中文名】
根因:登录接口只写了 request.state.audit_user(账号),没写 audit_display_name
—— 那一刻还不知道显示名。前端按 display_name || user_id 渲染,于是退化成账号。

- 改为登录成功后补写 display_name / role。可行的原因是中间件在 call_next
  返回【之后】才落库,此刻写 request.state 依然能被采集到。
- 失败登录走不到这一步,保持「只有账号可追责」(密码绝不落库),语义不变。
2026-09-21 11:20:59 +08:00
42a67bfa3a docs: 新增 AGENTS.md —— 记录本地起环境、测试踩坑与已知待修问题
刻意只写验证过的事实:
- 本机装 PG + 必须覆盖 DATABASE_URL/MOM_DB_*/SECRET_KEY 才能起服务
- TestClient 与异步引擎跨事件循环冲突会伪装成随机 500,
  改用 httpx.AsyncClient + ASGITransport 单循环
- 实测确认读接口大面积未鉴权(含匿名可导出个人工时台账)
- 「管理员角色」规则的后端唯一事实来源是 app/core/roles.py
2026-09-21 02:28:47 +00:00
14f707461d feat(admin): 新增「操作审计」页面 + 收敛前端角色判断
- pages/admin/AdminAuditLogPage.tsx:审计日志查询页。支持操作人/模块/动作/
  结果状态/日期区间筛选,表格按时间倒序,行内「详情」抽屉展示完整 URL、
  UA、request_id、错误信息与变更详情。
  中文标签(模块/动作)由服务端下发,前端不维护枚举映射 —— 与
  constants/task.ts 里状态映射的既有做法一致,避免两端各写一份开始漂移。
- services/auditApi.ts:审计接口客户端与类型定义。
- 路由 /admin/audit + 侧边栏「操作审计」入口。
- AdminProductsPage 里手写的角色判断改为复用 isAdminRole():这是同一份
  「管理员角色」规则的第 4 处副本,本次一并对齐(另一处 TaskFlowView 已在用)。
  constants/task.ts 的注释同步指向后端新位置 core/roles.py。

验证:tsc --noEmit 通过;vite build 通过(产出独立 chunk
AdminAuditLogPage-*.js);对接真实后端校验响应字段与 TS 接口定义逐字段一致。
2026-09-21 02:28:17 +00:00
7635802a42 feat(audit): 新增操作审计日志(表/中间件/查询接口)+ 角色常量收敛
背景:系统此前没有操作审计。task_logs 的 task_id 是 NOT NULL 外键,只能挂在
任务上,且全项目仅 4 处写入点 —— 登录、导出、产品增删改、收编完全不留痕。
需求方整理的问题清单里「无审计日志查看页」正源于此:不是没有页面,是没数据。

设计参考 MOM(KCGL) 的 audit_logs / audit_listener,但按 Track 栈做了取舍:

1) 写入时机:MOM 用 SQLAlchemy event listener + 同事务写入,优点是零侵入,
   缺点是**业务回滚时审计一起消失**,而失败/被拒的操作(越权尝试、参数错误)
   恰恰最需要留痕。Track 改为响应生成后用**独立 session** 写入:
   - 业务回滚不影响审计(已验证 422/401 失败操作同样落库)
   - 审计写入失败也不影响业务(全包裹 try/except)
   - 代价:非原子提交,响应后进程立即被 kill 可能丢一条(已注释说明取舍)

2) 采集方式:中间件自动采集写操作 + 导出/下载/打印这类「读但敏感」的 GET。
   路径段推导 module/action/target_id。不做手写埋点,因为手写必然漏 ——
   task_logs 只有 4 处写入点就是前车之鉴。

3) 增量价值:新增 request_id 字段,与 core/logging.py 的结构化日志打通,
   凭一个 ID 就能从审计记录直接跳到那一次接口日志。MOM 无此字段。

4) 敏感信息:details 经 sanitize_details 递归剔除 password/token/secret 等键;
   中间件不读请求体,登录明文密码不会落库(已断言表内无密码痕迹)。

配套改动:
- core/roles.py:角色常量与 is_admin 收敛为单一事实来源。此前同一份
  「管理员角色」规则散在 task_service、products.py 内联判断和前端
  constants/task.ts 三处,已因此发生过「移动端漏判 SUPERVISOR 误挡主管」。
  task_service 改为从 core.roles 导入同名常量,保持既有引用可用。
- core/deps.py:抽出 require_roles/require_admin 可复用依赖,替代内联判断。
- main.py:500 响应显式补 X-Request-ID 头 —— 该响应由 ServerErrorMiddleware
  生成,位于 RequestContextMiddleware 外层,中间件没机会写头。
- auth.py:登录校验前把「尝试的账号」写入 request.state,使登录事件
  (含失败登录)可归属到人,可用于追踪暴力破解。

验证:本地起 PostgreSQL 17 + 迁移后跑端到端测试,32/32 通过
(TestClient 每个请求新建事件循环,与模块级 asyncpg 连接池冲突会报
 "got Future attached to a different loop",故改用 httpx.AsyncClient +
 ASGITransport 单循环;生产 uvicorn 单循环无此问题)。
2026-09-21 02:23:19 +00:00
04eb87b091 fix: 访问日志的 user 字段恒为 null —— 改用 request.state 跨 task 传递
上一提交(4454047)的 RequestContextMiddleware 通过 contextvar 读取 user,
但 Starlette 的 BaseHTTPMiddleware 用 anyio start_soon 把下游放进新 task
执行,而 asyncio 每个 Task 创建时会复制 context —— 路由内 set 的
contextvar 不会回流到中间件,导致访问日志的 user 永远是 null。

修复:
- get_current_user 同时写 contextvar(供请求任务内业务日志用)与
  request.state(由 ASGI scope 承载,跨 task 可见),并带上
  display_name / role 备用。
- 中间件 _log_access 改为优先读 request.state.audit_user。

验证(TestClient + 解析最终 JSON 输出,而非读 record 属性):
- track.access 日志 user=zhangsan01(经 request.state)
- 请求任务内 track.service 日志 user=zhangsan01(经 contextvar)
- 两者 request_id 一致;X-Request-ID 透传正常
2026-09-21 02:11:41 +00:00
4454047ce3 fix: 修复任务分页总数错误 + 补齐可观测性基建
分页总数(#5):
- get_all_tasks 的 total 原为 len(flat_tasks)(当前页条数),移动端
  「我的任务」用 tasks.length < total 判断 hasMore,首页满员时恒为
  false,列表永远停在第一页 20 条。改为独立 COUNT 查询(与
  notifications.py 已有写法保持一致)。

可观测性(#9):
- 新增 core/logging.py:单行 JSON 结构化日志 + request_id/user 上下文
  注入;零第三方依赖;接管 uvicorn 自带 handler 避免格式绕过。
- 新增 core/middleware.py:RequestContextMiddleware 生成/透传
  X-Request-ID 并回写响应头,输出含耗时/用户的结构化访问日志。
- 新增 core/health.py:拆分存活/就绪探针。/health/live 不触依赖;
  /health/ready 探主库,不可用返回 503;MOM 挂掉仅降级不摘流量。
- main.py:全局异常处理器只把堆栈写日志,响应体仅回 request_id;
  接入可选 Sentry(未装 SDK 时静默跳过)。
- auth_service:解析 Token 后写入 user 上下文,日志自动带操作人。

注:异常处理器由 ServerErrorMiddleware 调用,此时 contextvar 已被重置,
故 request_id 同时写入 request.state(由 ASGI scope 承载)再读取。

已用 TestClient 验证:探针状态码/检查项、X-Request-ID 透传与生成、
500 响应携带可对账的 request_id 且不泄露堆栈。
2026-09-21 01:57:20 +00:00
38 changed files with 2935 additions and 39 deletions

63
AGENTS.md Normal file
View File

@ -0,0 +1,63 @@
# AGENTS.md
本仓库(Track 生产流转系统)的工作笔记。仅在验证过之后才写入,避免传谣。
## 架构速览
- `backend/` FastAPI + SQLAlchemy 2.x(async) + Alembic,PostgreSQL。
- `frontend/` React 19 + Vite + antd + Tailwind。路由见 `src/App.tsx`,
管理端菜单见 `src/components/layout/AdminLayout.tsx`(`MENU` 数组)。
- 登录不走 Track 自己的用户表,而是**只读** MOM(KCGL) 的 `sys_user`:
`sys_user.username` 存 `"真实姓名/登录账号"`,`login()` 用
`WHERE username LIKE '%/<账号>'` 匹配,`display_name` 由 `/` 拆解得到。
MOM 连接配置在 `app/core/mom_database.py`(同步 psycopg2 引擎)。
## 本地起环境(关键,踩过的坑都在这)
1. **本机没有 Postgres 时需要先装**(容器内 `sudo` 可用):
`sudo -n apt-get install -y --fix-missing postgresql postgresql-contrib`
然后 `sudo -n pg_ctlcluster 17 main start`。
本仓库不使用 pgvector,无需额外扩展。
2. **数据库端口与生产默认值不同**,必须用环境变量覆盖:
- `DATABASE_URL=postgresql+asyncpg://track:track_prod_2026@127.0.0.1:5432/track_production`
- `MOM_DB_HOST=127.0.0.1`、`MOM_DB_PORT=5432`
- `SECRET_KEY=<≥32 字符>`:`DEBUG=false` 时配置项会**拒绝**默认 SECRET_KEY
(见 `app/core/config.py` 的校验),不设会直接 import 失败。
3. 迁移:`cd backend && python3 -m alembic upgrade head`(没有全局 `alembic` 命令,
要用 `python3 -m alembic`)。校验纯 SQL 用 `alembic upgrade head --sql`。
4. 前端 proxy 指向 Docker 服务名 `backend:8000`。本机跑要么把
`127.0.0.1 backend` 写进 `/etc/hosts`,要么直接给
`VITE_API_BASE_URL=http://localhost:<port>/api/v1` 绕过 proxy。
注意 dev server 由 `basicSsl` 起 HTTPS,跨域需要后端
`CORS_ORIGINS` 加上 `https://localhost:1420`。
## 测试的坑(重要)
- **不要用 `starlette.testclient.TestClient` 测异步 SQLAlchemy 应用。**
它每个请求新建事件循环,而引擎是模块级单例、池里挂着 asyncpg 连接,
跨循环复用会报 `got Future attached to a different loop`,表现为随机 500。
正确做法:`httpx.AsyncClient(transport=httpx.ASGITransport(app=app))`
并在单个 `asyncio.run()` 里跑完全部请求。生产 uvicorn 单循环无此问题。
- 仓库目前**没有** pytest 基建,也没有前端测试脚本。
## 已知的待修问题(截至 1.0应用 分支)
- **读接口大面积未鉴权**(已实测,非推测):无 token 直接 200 的包括
`/api/v1/users/`、全部 `/api/v1/dashboard/*`(含
`people-history/export` —— 匿名即可批量导出个人工时台账)、
`/api/v1/analytics/*`、`/api/v1/screen/*`、`/api/v1/orders/`。
写操作和 `/api/v1/tasks`、`/api/v1/products` 是有鉴权的。
新增接口请统一用 `app/core/deps.py` 的 `require_admin` / `require_roles`。
- 「管理员角色」这份规则此前散在 4 处(后端 `task_service`、`products.py` 内联、
前端 `constants/task.ts`、`AdminProductsPage` 内联),已因此出过事故。
**后端唯一事实来源是 `app/core/roles.py`,前端用 `constants/task.ts::isAdminRole`。**
新增判断不要手写 `===` 比较。
- `task_logs.task_id` 是 NOT NULL 外键,只能挂任务,不是通用审计。通用审计是
`audit_logs`(本轮新增,由 `app/core/audit_middleware.py` 自动采集)。
## 约定
- 时间统一北京时间(`app/core/time_utils.py`),库里存 timestamptz。
- 中文枚举标签尽量由服务端下发(如审计接口的 `module_label`/`action_label`),
避免前端再抄一份映射开始漂移。
- 本仓库的提交信息用中文,说明「为什么」而非「改了什么」。

View File

@ -3,3 +3,11 @@ SECRET_KEY=change-me-to-a-random-secret-key-in-production
ACCESS_TOKEN_EXPIRE_MINUTES=30
DEBUG=true
CORS_ORIGINS='["http://localhost:1420", "tauri://localhost"]'
# 日志:LOG_JSON=true 输出单行 JSON(便于采集),本地调试可设 false 换可读格式
LOG_LEVEL=INFO
LOG_JSON=true
# 错误追踪(可选):填了 DSN 且已安装 sentry-sdk 才会启用,否则自动跳过
# SENTRY_DSN=
# SENTRY_TRACES_SAMPLE_RATE=0.1

View File

@ -0,0 +1,81 @@
"""add_audit_logs
Revision ID: j1k2l3m4n5o6
Revises: i1j2k3l4m5n6
Create Date: 2026-09-21
操作审计日志表(audit_logs)
--------------------------
新增一张独立的审计表,用于记录 task_logs 覆盖不到的操作:
登录、导出、产品增删改、收口、权限/配置变更等与单个任务无关的动作。
为什么另起一张表而不复用 task_logs:
task_logs.task_id 是 NOT NULL 外键,只能挂在任务上,无法表达「张三导出了
产品清单」这类动作;且缺少来源 IP / UA / 结果状态等审计必需字段。
存量数据无需回填(本表从上线时刻开始记录)。
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "j1k2l3m4n5o6"
down_revision: Union[str, None] = "i1j2k3l4m5n6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"audit_logs",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False),
# 操作人
sa.Column("user_id", sa.String(64), nullable=True, comment="操作人账号(逻辑外键→MOM)"),
sa.Column("display_name", sa.String(100), nullable=True, comment="操作人显示名"),
sa.Column("role", sa.String(50), nullable=True, comment="操作时角色快照"),
# 业务语义
sa.Column("action", sa.String(50), nullable=False, comment="动作"),
sa.Column("module", sa.String(50), nullable=False, comment="业务模块"),
sa.Column("target_type", sa.String(50), nullable=True),
sa.Column("target_id", sa.String(100), nullable=True),
sa.Column("target_name", sa.String(200), nullable=True),
sa.Column("details", postgresql.JSONB(), nullable=True, comment="变更详情"),
# 请求上下文
sa.Column("ip_address", sa.String(50), nullable=True),
sa.Column("user_agent", sa.String(500), nullable=True),
sa.Column("method", sa.String(10), nullable=True),
sa.Column("url", sa.String(500), nullable=True),
sa.Column("status_code", sa.Integer(), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
# 与结构化日志对账
sa.Column("request_id", sa.String(64), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
# 索引:按审计页最常用的检索维度建
op.create_index("ix_audit_logs_created_at", "audit_logs", ["created_at"])
op.create_index("ix_audit_logs_user_id", "audit_logs", ["user_id"])
op.create_index("ix_audit_logs_module", "audit_logs", ["module"])
op.create_index("ix_audit_logs_action", "audit_logs", ["action"])
op.create_index("ix_audit_logs_target_id", "audit_logs", ["target_id"])
op.create_index("ix_audit_logs_request_id", "audit_logs", ["request_id"])
# 组合索引:审计页默认「按时间倒序 + 按模块/动作过滤」
op.create_index("ix_audit_logs_module_created", "audit_logs", ["module", "created_at"])
def downgrade() -> None:
op.drop_index("ix_audit_logs_module_created", table_name="audit_logs")
op.drop_index("ix_audit_logs_request_id", table_name="audit_logs")
op.drop_index("ix_audit_logs_target_id", table_name="audit_logs")
op.drop_index("ix_audit_logs_action", table_name="audit_logs")
op.drop_index("ix_audit_logs_module", table_name="audit_logs")
op.drop_index("ix_audit_logs_user_id", table_name="audit_logs")
op.drop_index("ix_audit_logs_created_at", table_name="audit_logs")
op.drop_table("audit_logs")

View 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")

View File

@ -0,0 +1,292 @@
"""审计日志 API —— 查看系统操作审计记录
与 MOM(KCGL) /audit/logs 的接口保持同构的筛选维度(操作人/模块/动作/目标/
时间区间),便于两端运维习惯统一;额外提供 request_id 筛选,可凭它直接跳到
结构化日志里的那一次请求。
另提供两个 CSV 导出端点(审计明细 / 日活统计),均支持按列导出。
"""
from __future__ import annotations
import csv
import io
from datetime import datetime, time, timedelta
from typing import Any, Callable
from fastapi import APIRouter, Depends, Query, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.deps import require_admin
from app.core.time_utils import BEIJING_TZ, get_beijing_time
from app.schemas.audit import (
AuditLogListResponse,
AuditLogResponse,
AuditOption,
AuditOptionsResponse,
DailyUsageResponse,
DailyUsageRow,
)
from app.services import audit_service
from app.services.audit_service import ACTION_LABELS, MODULE_LABELS
router = APIRouter(prefix="/audit", tags=["审计日志"])
def _parse_day(value: str | None, *, end_of_day: bool = False) -> datetime | None:
"""解析 YYYY-MM-DD 为北京时间。
结束日期取次日 00:00 作为上界(配合 < 判断)—— 直接取当天 23:59:59 会
漏掉该秒内的记录,是日期区间筛选最常见的差一错误。
"""
if not value:
return None
try:
day = datetime.strptime(value, "%Y-%m-%d").date()
except ValueError:
return None
if end_of_day:
return datetime.combine(day + timedelta(days=1), time.min, tzinfo=BEIJING_TZ)
return datetime.combine(day, time.min, tzinfo=BEIJING_TZ)
@router.get("/logs", response_model=AuditLogListResponse)
async def get_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(含当天)"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
) -> AuditLogListResponse:
"""审计日志分页查询(按时间倒序)"""
start = _parse_day(start_date)
# 结束日期用「次日 00:00」作为开区间上界,避免漏掉当天最后几条
end_exclusive = _parse_day(end_date, end_of_day=True)
rows, total = await audit_service.list_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,
skip=(page - 1) * page_size,
limit=page_size,
)
items = []
for row in rows:
item = AuditLogResponse.model_validate(row)
# 中文标签由服务端补,避免前端为每个枚举再维护一份映射
item.module_label = MODULE_LABELS.get(row.module, row.module)
item.action_label = ACTION_LABELS.get(row.action, row.action)
items.append(item)
return AuditLogListResponse(items=items, total=total)
@router.get("/options", response_model=AuditOptionsResponse)
async def get_audit_options(
current_user: dict = Depends(require_admin),
) -> AuditOptionsResponse:
"""筛选项:模块与动作的中文下拉;顺带下发导出可选列"""
return AuditOptionsResponse(
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()],
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),
)

View File

@ -1,5 +1,5 @@
"""认证 API — 对接 MOM sys_user + 双 Token 刷新"""
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Request
from app.schemas.user import (
LoginRequest,
LoginResponse,
@ -7,23 +7,68 @@ from app.schemas.user import (
RefreshResponse,
UserResponse,
)
from app.core.security import peek_token_identity
from app.services.auth_service import login, refresh_access_token, get_current_user
router = APIRouter(prefix="/auth", tags=["认证"])
@router.post("/login", response_model=LoginResponse)
def login_endpoint(data: LoginRequest):
def login_endpoint(data: LoginRequest, request: Request):
"""登录 — 验证 MOM sys_user 表,返回 Access + Refresh 双 Token"""
return login(data.username, data.password)
# 登录请求本身尚未认证,中间件拿不到操作人。但「谁在尝试登录、失败了多少次」
# 恰恰是审计里最该有的信息,所以在校验之前就把尝试的账号写进 state:
# 登录失败时同样留痕,且能按账号追踪暴力破解。
# 注意:绝不把 data.password 写进 state / 审计,密码不落库。
request.state.audit_user = data.username
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)
def refresh_endpoint(data: RefreshRequest):
def refresh_endpoint(data: RefreshRequest, request: Request):
"""刷新 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)
@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)
def get_me(current_user: dict = Depends(get_current_user)):
"""获取当前用户信息(从 Access Token 解析)"""

View File

@ -19,6 +19,7 @@ async def list_orders(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
result = await db.execute(
select(ProductionOrder).offset(skip).limit(limit).order_by(ProductionOrder.created_at.desc())

View File

@ -26,7 +26,10 @@ router = APIRouter(prefix="/products", tags=["产品管理"])
# ============================================================
@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 图片)。
内容为 16 位序列号,扫描后可调用 /scan/{serial_number} 查询产品。
@ -50,6 +53,7 @@ async def get_product_qrcode(serial_number: str):
async def scan_product(
serial_number: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
扫码接口:根据 16 位序列号查询产品及其当前进度。
@ -69,6 +73,7 @@ async def list_products(
keyword: str | None = Query(None, description="多维搜索: 产品身份证/订单号/规格型号"),
status: str | None = Query(None, description="产品状态筛选: PENDING/WIP/COMPLETED/ARCHIVED"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""获取产品列表 — 支持 keyword 搜索 + 状态筛选"""
return await product_service.get_all_products(
@ -80,6 +85,7 @@ async def list_products(
async def get_product(
product_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""获取单个产品详情"""
import uuid
@ -196,6 +202,7 @@ class MessageCreate(BaseModel):
async def get_product_messages(
product_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""获取某产品的所有留言(按时间正序)"""
result = await db.execute(

View File

@ -38,6 +38,7 @@ async def list_tasks(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""获取任务列表,可按产品/负责人筛选(只返回顶层任务)"""
pid = uuid.UUID(product_id) if product_id else None
@ -48,6 +49,7 @@ async def list_tasks(
async def get_task(
task_id: str,
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(
product_id: str,
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))

View File

@ -17,6 +17,7 @@ from app.api.v1.endpoints.holidays import router as holidays_router
from app.api.v1.endpoints.webhooks import router as webhooks_router
from app.api.v1.endpoints.external_products import router as external_products_router
from app.api.v1.endpoints.screen import router as screen_router
from app.api.v1.endpoints.audit import router as audit_router
api_router = APIRouter()
@ -37,3 +38,4 @@ api_router.include_router(holidays_router)
api_router.include_router(webhooks_router)
api_router.include_router(external_products_router)
api_router.include_router(screen_router)
api_router.include_router(audit_router)

View File

@ -0,0 +1,234 @@
"""审计采集中间件
在响应生成后,把「谁 / 何时 / 从哪来 / 调了哪个接口 / 做了什么 / 结果如何」
落进 audit_logs。
为什么用中间件自动采集,而不是在每个业务函数里手写 record_audit
------------------------------------------------------------------
1. 手写必然漏。新加的端点很容易忘记补审计,而审计的价值恰恰建立在「完整」上。
现状可佐证:task_logs 全项目只有 4 处写入点,凡是不挂在任务上的动作
(登录、导出、改产品)全都没有留痕。
2. 中间件能拿到业务函数拿不到的事实:真实来源 IP、UA、最终状态码、
以及与结构化日志对齐的 request_id。
3. 业务语义(module / target)由路径推导,不如手写精确,但对「谁动了什么」
的追责场景已经够用;关键动作后续可再调 record_audit 补 details 做增强。
采集范围
--------
- 所有写操作(POST/PUT/PATCH/DELETE)
- 少数**读但敏感**的操作:导出、下载、打印(本项目 GET /people-history/export
就是导出,只按方法过滤会漏掉)
明确不采集:GET /health*、/docs、/openapi.json —— 探针与文档的噪声没有审计价值。
"""
from __future__ import annotations
import logging
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import Response
from app.services.audit_service import record_audit
logger = logging.getLogger("track.audit")
# 写操作一律采集
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
# 读操作里需要留痕的(导出/下载/打印属于「读」,但把数据带出了系统)
_SENSITIVE_READ_KEYWORDS = frozenset({"export", "download", "print"})
# 核心业务模块 —— 这些前缀下的「查看详情」GET 也采集,
# 用于回答「谁在什么时候看过哪条业务数据」,而不只是「谁改过」。
#
# ⚠️ 只覆盖【核心业务实体】:
# products —— 移动端扫码查询 GET /products/scan/{sn} 是车间最高频的读操作
# tasks —— 查看任务详情 /tasks/{id}
# records —— 任务记录
# notifications / orders —— 见下方 _is_bare_list 的说明
_TRACKED_READ_PREFIXES = (
"/api/v1/notifications",
"/api/v1/tasks",
"/api/v1/orders",
"/api/v1/products",
"/api/v1/records",
)
# 永久忽略的路径前缀
_IGNORED_PREFIXES = ("/health", "/docs", "/redoc", "/openapi.json")
# 路径段 → 审计模块
_PATH_MODULE: dict[str, str] = {
"products": "product",
"tasks": "task",
"orders": "order",
"records": "record",
"print": "print",
"materials": "material",
"users": "user",
"upload": "upload",
"notifications": "notification",
"app-version": "app",
"analytics": "analytics",
"dashboard": "dashboard",
"holidays": "holiday",
"screen": "screen",
"webhooks": "external",
"external": "external",
"audit": "audit",
"auth": "auth",
}
# 路径段 → 动作(优先于按 HTTP 方法推断)
_SEGMENT_ACTION: dict[str, str] = {
"login": "login",
"logout": "logout",
"refresh": "refresh",
"export": "export",
"download": "export",
"print": "print",
"upload": "upload",
"finalize": "finalize",
"receive": "receive",
"transfer": "transfer",
"reject": "reject",
"recall": "recall",
"spawn": "spawn",
"complete": "complete",
"end": "end",
# 消息已读:PUT /notifications/{id}/read。
# 没有这一条时会回退到 _METHOD_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("审计采集失败(已忽略)")

View File

@ -16,6 +16,17 @@ class Settings(BaseSettings):
# ---- 调试 ----
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"]'

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

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

View File

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

View File

@ -0,0 +1,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

View File

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

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

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

View File

@ -39,6 +39,28 @@ def decode_token(token: str) -> dict:
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)

View File

@ -1,22 +1,64 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.core.config import settings
from app.core.audit_middleware import AuditMiddleware
from app.core.health import router as health_router
from app.core.logging import request_id_var, setup_logging
from app.core.middleware import RequestContextMiddleware
from app.api.v1.router import api_router
# 日志必须在任何模块开始产日志之前配置好,故放模块顶层而非 lifespan 内
setup_logging(level=settings.LOG_LEVEL, json_output=settings.LOG_JSON)
logger = logging.getLogger("track.main")
def _init_error_tracking() -> None:
"""可选错误追踪:未配置 DSN,或未安装 sentry-sdk 时静默跳过"""
if not settings.SENTRY_DSN:
return
try:
import sentry_sdk
except ImportError:
logger.warning(
"已配置 SENTRY_DSN 但未安装 sentry-sdk,错误追踪未启用;"
"需要时执行 pip install sentry-sdk"
)
return
sentry_sdk.init(
dsn=settings.SENTRY_DSN,
traces_sample_rate=settings.SENTRY_TRACES_SAMPLE_RATE,
environment="production" if not settings.DEBUG else "development",
release=settings.APP_VERSION,
)
logger.info("错误追踪已启用 (Sentry)")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期:启动时初始化连接,关闭时释放资源"""
# 启动:验证数据库连接等
_init_error_tracking()
logger.info(
"服务启动",
extra={
"extra_fields": {
"version": settings.APP_VERSION,
"debug": settings.DEBUG,
"cors_origins": settings.CORS_ORIGINS_LIST,
}
},
)
yield
# 关闭:清理资源
logger.info("服务关闭")
app = FastAPI(
title="Track Production API",
description="工厂生产流转管理系统 API",
version="0.1.0",
version=settings.APP_VERSION,
lifespan=lifespan,
)
@ -27,12 +69,45 @@ app.add_middleware(
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
# 暴露给浏览器 JS 读取:前端报错时才能把 request_id 一起带上便于对账
expose_headers=["X-Request-ID"],
)
# Starlette 的 add_middleware 是「后添加者在外层」。执行顺序(由外到内):
# RequestContextMiddleware -> AuditMiddleware -> CORS -> 路由
# AuditMiddleware 必须在 RequestContext 内层,才能读到后者写入 request.state
# 的 request_id,从而把审计记录与结构化日志对上。
app.add_middleware(AuditMiddleware)
app.add_middleware(RequestContextMiddleware)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""兜底异常处理。
完整堆栈只进日志;响应体仅返回 request_id —— 既不把内部实现泄露给客户端,
又让用户报障时能凭这个 ID 在日志里精确定位到本次失败。
"""
# 优先取 request.state(见 RequestContextMiddleware 的说明):
# 本处理器由 ServerErrorMiddleware 调用,此时 contextvar 已被重置
request_id = getattr(request.state, "request_id", None) or request_id_var.get()
logger.exception(
"未处理异常: %s %s",
request.method,
request.url.path,
extra={"extra_fields": {"method": request.method, "path": request.url.path}},
)
return JSONResponse(
status_code=500,
content={"detail": "服务器内部错误", "request_id": request_id},
# 该响应由 ServerErrorMiddleware(位于 RequestContextMiddleware 外层)
# 生成,中间件没机会再往响应头写 X-Request-ID,故在此显式补上,
# 保证报障时前端从响应头就能拿到可对账的 ID。
headers={"X-Request-ID": request_id} if request_id else None,
)
# ---- 注册路由 ----
app.include_router(api_router, prefix="/api/v1")
@app.get("/health")
async def health_check():
return {"status": "ok", "version": "0.1.0"}
# 健康检查挂在根路径(/health*),运维探针不经过 /api/v1
app.include_router(health_router)

View File

@ -8,6 +8,8 @@ from app.models.notification import Notification
from app.models.app_version import AppVersion
from app.models.message import ProductMessage
from app.models.holiday import Holiday
from app.models.audit_log import AuditLog
from app.models.user_daily_seen import UserDailySeen
__all__ = [
"Base",
"ProductionOrder",
@ -19,4 +21,6 @@ __all__ = [
"AppVersion",
"ProductMessage",
"Holiday",
"AuditLog",
"UserDailySeen",
]

View File

@ -0,0 +1,83 @@
"""操作审计日志模型
设计参考 MOM(KCGL) 的 audit_logs,但按 Track 的技术栈与诉求做了取舍:
- 主键用 UUID(与库内其它表一致),而非 MOM 的自增 int。
- 增加 request_id:与 core/logging.py 的结构化日志打通 —— 凭一个 ID 就能把
「接口访问日志」和「审计记录」对上,排障时不用再猜。MOM 无此字段。
- 保留 module / action / target_* 的业务语义,使审计能按业务维度检索,
而不是只能按时间翻。
- 绝不记录请求体:登录等接口 body 含明文密码,一旦落库就成了长期泄露面。
与既有 task_logs 的分工:task_logs 是「任务流转轨迹」(有 task_id 非空约束,
只能挂在任务上,供流转树渲染);本表是「操作审计」,覆盖登录、导出、
产品增删改、权限变更等与单个任务无关的动作,且额外记录来源 IP / UA / 耗时结果。
"""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
from app.core.time_utils import get_beijing_time
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
)
# ---- 操作人(逻辑外键 → MOM sys_user,仅存账号,无物理约束)----
user_id: Mapped[str | None] = mapped_column(
String(64), nullable=True, index=True, comment="操作人账号(逻辑外键→MOM)",
)
display_name: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="操作人显示名",
)
role: Mapped[str | None] = mapped_column(
String(50), nullable=True, comment="操作时角色快照",
)
# ---- 业务语义 ----
action: Mapped[str] = mapped_column(
String(50), nullable=False, index=True, comment="动作: create/update/delete/export/login/...",
)
module: Mapped[str] = mapped_column(
String(50), nullable=False, index=True, comment="业务模块: product/task/order/auth/print/...",
)
target_type: Mapped[str | None] = mapped_column(
String(50), nullable=True, comment="目标类型(表名或实体名)",
)
target_id: Mapped[str | None] = mapped_column(
String(100), nullable=True, index=True, comment="目标ID",
)
target_name: Mapped[str | None] = mapped_column(
String(200), nullable=True, comment="目标显示名(如产品身份证/工单号)",
)
details: Mapped[dict | None] = mapped_column(
JSONB, nullable=True, comment="变更详情 {old:{}, new:{}};禁止写入密码等敏感字段",
)
# ---- 请求上下文(由中间件自动填充)----
ip_address: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="来源IP")
user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="浏览器UA")
method: Mapped[str | None] = mapped_column(String(10), nullable=True, comment="HTTP方法")
url: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="请求路径")
status_code: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="响应状态码")
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment="错误信息(如有)")
# ---- 与结构化日志对账用 ----
request_id: Mapped[str | None] = mapped_column(
String(64), nullable=True, index=True, comment="关联 core/logging 的 request_id",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=get_beijing_time, index=True, comment="操作时间",
)
def __repr__(self) -> str:
return f"<AuditLog {self.action} {self.module} by {self.user_id}>"

View 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="当天末次活动时刻",
)

View File

@ -0,0 +1,88 @@
"""审计日志 Pydantic Schema"""
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel
class AuditLogResponse(BaseModel):
"""单条审计记录"""
id: uuid.UUID
user_id: str | None = None
display_name: str | None = None
role: str | None = None
action: str
action_label: str | None = None # 服务端补的中文标签,避免前端各处硬编码
module: str
module_label: str | None = None
target_type: str | None = None
target_id: str | None = None
target_name: str | None = None
details: dict | None = None
ip_address: str | None = None
user_agent: str | None = None
method: str | None = None
url: str | None = None
status_code: int | None = None
error_message: str | None = None
# 与结构化日志对账用:拿着它就能捞到对应的接口日志
request_id: str | None = None
created_at: datetime
model_config = {"from_attributes": True}
class AuditLogListResponse(BaseModel):
"""审计日志分页列表"""
items: list[AuditLogResponse]
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):
"""筛选项(value/label 结构,直接喂给前端下拉)"""
value: str
label: str
class AuditOptionsResponse(BaseModel):
"""筛选项集合"""
modules: list[AuditOption]
actions: list[AuditOption]
# 导出可选的列(value=后端列 key,label=中文表头)。
# 由后端下发而非前端硬编码:列的中文名与取值口径都在后端,
# 两端各写一份迟早会出现"导出的列和页面上的对不上"。
log_export_columns: list[AuditOption] = []
usage_export_columns: list[AuditOption] = []

View File

@ -0,0 +1,458 @@
"""审计服务 — 写入与检索
写入方案的取舍(与 MOM/KCGL 不同,理由如下)
--------------------------------------------------
MOM 用 SQLAlchemy event listener + **同事务**写入:优点是全自动、业务代码零改动;
缺点是业务事务回滚时审计记录一起被回滚掉 —— 而失败/被拒的操作恰恰是最需要
留痕的(比如越权尝试、参数错误导致的 4xx)。
Track 改为:响应生成后,用**独立 session** 写入审计。
- 业务回滚不影响审计,失败操作照样留痕
- 审计写入失败也不影响业务(全包裹 try/except,仅记日志)
- 代价:审计与业务不是原子提交,极端情况(响应后进程立即被 kill)可能丢一条。
对内部系统的操作审计,这个取舍划算。
"""
from __future__ import annotations
import logging
import time
import uuid
from datetime import datetime
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
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.user_daily_seen import UserDailySeen
logger = logging.getLogger("track.audit")
# 绝不落库的敏感字段名(命中即替换为 ***)
# 登录请求体含明文密码,一旦进审计表就成了长期泄露面
_SENSITIVE_KEYS = frozenset(
{"password", "passwd", "pwd", "token", "access_token", "refresh_token",
"secret", "api_key", "authorization", "password_hash"}
)
# 模块 / 动作 的中文标签(前端下拉与列表展示用)
MODULE_LABELS: dict[str, str] = {
"auth": "认证登录",
"product": "产品管理",
"task": "任务流转",
"order": "订单管理",
"record": "任务记录",
"print": "标签打印",
"material": "物料",
"user": "用户",
"notification": "消息通知",
"upload": "文件上传",
"dashboard": "看板统计",
"analytics": "效能分析",
"screen": "数据大屏",
"holiday": "节假日配置",
"app": "App版本",
"external": "外部系统对接",
"audit": "审计日志",
"other": "其它",
}
ACTION_LABELS: dict[str, str] = {
"create": "新增",
"update": "修改",
"delete": "删除",
# 只用于被采集的 GET(核心业务详情 / 敏感读)。
# 叫「查看详情」而不是「查询」:前者说明用户确实点开了某条业务数据,
# 后者容易被误解成"随便搜了一下"。
"read": "查看详情",
"export": "导出",
"login": "登录",
"logout": "登出",
# 刷新令牌 = 用户重新开始使用系统(token 2 小时一换,7 天免登录),
# 业务上视作一次「上线」,比"刷新令牌"这种技术词更贴近车间口径
"refresh": "上线",
"print": "打印",
"upload": "上传",
"finalize": "收口",
"receive": "接收",
"transfer": "转交",
"reject": "驳回",
"recall": "撤回",
"spawn": "派发",
"end": "结束分支",
"complete": "完结",
"mark_read": "标为已读",
}
def sanitize_details(details: dict | None) -> dict | None:
"""递归剔除敏感字段,避免密码/令牌落库"""
if not details:
return details
def _clean(value):
if isinstance(value, dict):
return {
k: ("***" if str(k).lower() in _SENSITIVE_KEYS else _clean(v))
for k, v in value.items()
}
if isinstance(value, list):
return [_clean(v) for v in value]
return value
return _clean(details)
async def record_audit(
*,
action: str,
module: str,
user_id: str | None = None,
display_name: str | None = None,
role: str | None = None,
target_type: str | None = None,
target_id: str | None = None,
target_name: str | None = None,
details: dict | None = None,
ip_address: str | None = None,
user_agent: str | None = None,
method: str | None = None,
url: str | None = None,
status_code: int | None = None,
error_message: str | None = None,
request_id: str | None = None,
) -> None:
"""写入一条审计记录。**绝不抛异常**:审计失败不能影响业务。"""
try:
async with AsyncSessionLocal() as session:
session.add(
AuditLog(
id=uuid.uuid4(),
user_id=user_id,
display_name=display_name,
role=role,
action=action,
module=module,
target_type=target_type,
target_id=str(target_id) if target_id is not None else None,
target_name=target_name,
details=sanitize_details(details),
ip_address=ip_address,
user_agent=user_agent[:500] if user_agent else None,
method=method,
url=url[:500] if url else None,
status_code=status_code,
error_message=error_message,
request_id=request_id,
)
)
await session.commit()
except Exception:
# 用 exception 级别但吞掉异常:保证调用方业务流程不受影响
logger.exception(
"审计写入失败(已忽略,不影响业务)",
extra={"extra_fields": {"action": action, "module": module, "url": url}},
)
async def list_audit_logs(
db: AsyncSession,
*,
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,
skip: int = 0,
limit: int = 50,
) -> tuple[list[AuditLog], int]:
"""审计日志检索(按时间倒序)。返回 (当前页, 真实总数)。
真实总数走独立 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 = []
if user_id:
filters.append(AuditLog.user_id.ilike(f"%{user_id}%"))
if module:
filters.append(AuditLog.module == module)
if action:
filters.append(AuditLog.action == action)
if target_id:
filters.append(AuditLog.target_id == target_id)
if request_id:
filters.append(AuditLog.request_id == request_id)
if status_code is not None:
filters.append(AuditLog.status_code == status_code)
if start:
filters.append(AuditLog.created_at >= start)
if end:
filters.append(AuditLog.created_at <= end)
return filters
async def export_audit_logs(
db: AsyncSession, *, limit: int = EXPORT_MAX_ROWS, **kwargs,
) -> tuple[list[AuditLog], bool]:
"""导出用:按筛选条件取全部记录(不分页)。返回 (rows, truncated)。
多取一行来判断是否被截断 —— 比再跑一次 COUNT 便宜。
"""
rows = (
await db.execute(
select(AuditLog)
.where(*_log_filters(**kwargs))
.order_by(AuditLog.created_at.desc())
.limit(limit + 1)
)
).scalars().all()
truncated = len(rows) > limit
return list(rows[:limit]), truncated
# ============================================================
# 每日活动打点(日活报表的「上线时间 / 下线时间」来源)
# ============================================================
# 同一用户两次落盘之间的最小间隔(秒)。
#
# 打点挂在「每个请求」上,但不希望每个请求都写一次数据库 —— 那会把
# 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

View File

@ -1,5 +1,5 @@
"""认证服务 — 对接 MOM 系统 sys_user 表 + Track 自有 JWT(双 Token 架构)"""
from fastapi import HTTPException, status, Depends
from fastapi import HTTPException, status, Depends, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt
from werkzeug.security import check_password_hash
@ -14,6 +14,7 @@ from app.core.security import (
TOKEN_TYPE_REFRESH,
)
from app.core.mom_database import MomSessionLocal
from app.core.logging import user_var
from app.schemas.user import LoginResponse, UserResponse
security = HTTPBearer()
@ -112,6 +113,7 @@ def refresh_access_token(refresh_token: str) -> dict:
async def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
"""从 Bearer Token 解析当前用户(仅接受 Access Token)"""
@ -129,6 +131,19 @@ async def get_current_user(
detail="请使用 Access Token 访问 API,Refresh Token 仅用于刷新",
)
# 操作人身份要写两处,用途不同,缺一不可:
# 1) contextvar —— 供本请求任务内的业务/service 日志使用;
# 2) request.state —— 中间件在独立 task 中执行(Starlette 的
# BaseHTTPMiddleware 用 anyio start_soon 起新 task,而 asyncio
# 每个 Task 会复制 context),因此中间件读不到路由内改的
# contextvar,只能通过 ASGI scope 承载的 state 拿到。
# username 即 assignee_id 口径,比数字 id 直观得多。
user_label = payload.get("username") or user_id
user_var.set(user_label)
request.state.audit_user = user_label
request.state.audit_display_name = payload.get("display_name") or ""
request.state.audit_role = payload.get("role") or ""
return payload
except JWTError:
raise HTTPException(status_code=401, detail="无效的 Token")

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import json
import uuid
from fastapi import HTTPException, status
from sqlalchemy import select, delete
from sqlalchemy import select, delete, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@ -22,6 +22,7 @@ from app.core.lifecycle import (
)
from app.models.product import Product
from app.models.task_log import TaskLog
from app.core.roles import ADMIN_ROLES
from app.schemas.task import (
TaskCreate,
TaskUpdate,
@ -42,7 +43,8 @@ from app.schemas.task import (
VIRTUAL_WAREHOUSE = "virtual_warehouse"
# 管理员/主管角色白名单 — 拥有上帝视角操作权限
ADMIN_ROLES = {"SUPER_ADMIN", "SUPERVISOR"}
# 定义已收敛到 app.core.roles(单一事实来源);本模块继续以同名导出,
# 兼容 products.py 等处 `from app.services.task_service import ADMIN_ROLES` 的既有引用
async def _recalc_product_location(
@ -457,22 +459,37 @@ async def get_all_tasks(
assignee_id: str | None = None, skip: int = 0, limit: int = 50
) -> TaskListResponse:
"""获取任务列表,可按产品/负责人筛选"""
stmt = select(Task).options(
selectinload(Task.records),
selectinload(Task.product),
)
filters = []
if product_id:
stmt = stmt.where(Task.product_id == product_id)
filters.append(Task.product_id == product_id)
if assignee_id:
stmt = stmt.where(Task.assignee_id == assignee_id)
stmt = stmt.offset(skip).limit(limit).order_by(Task.created_at.desc())
filters.append(Task.assignee_id == assignee_id)
# 总数必须独立 COUNT:移动端「我的任务」用 total 判断 hasMore
# (tasks.length < total),若 total 取当前页条数,首页满员时
# hasMore 恒为 false,列表永远停在第一页。
total = await db.scalar(
select(func.count()).select_from(Task).where(*filters)
) or 0
stmt = (
select(Task)
.options(
selectinload(Task.records),
selectinload(Task.product),
)
.where(*filters)
.offset(skip)
.limit(limit)
.order_by(Task.created_at.desc())
)
result = await db.execute(stmt)
tasks = result.scalars().all()
# 返回扁平列表(不递归 children,避免 MissingGreenlet)
flat_tasks = [_to_flat_response(t) for t in tasks]
return TaskListResponse(tasks=flat_tasks, total=len(flat_tasks))
return TaskListResponse(tasks=flat_tasks, total=total)
# ============================================================

View File

@ -41,6 +41,7 @@ const AdminProductsPage = lazy(() => import("./pages/admin/AdminProductsPage"));
const AdminTasksPage = lazy(() => import("./pages/admin/AdminTasksPage"));
const AdminPeoplePage = lazy(() => import("./pages/admin/AdminPeoplePage"));
const AdminPrintConfigPage = lazy(() => import("./pages/admin/AdminPrintConfigPage"));
const AdminAuditLogPage = lazy(() => import("./pages/admin/AdminAuditLogPage"));
const AnalyticsDashboard = lazy(() => import("./pages/admin/AnalyticsDashboard"));
const MatrixBoard = lazy(() => import("./pages/MatrixBoard"));
const ScreenDashboard = lazy(() => import("./pages/admin/ScreenDashboard"));
@ -79,6 +80,7 @@ export default function App() {
<Route path="/admin/print-config" element={<AdminPrintConfigPage />} />
<Route path="/admin/analytics" element={<AnalyticsDashboard />} />
<Route path="/admin/matrix" element={<MatrixBoard />} />
<Route path="/admin/audit" element={<AdminAuditLogPage />} />
</Route>
</Routes>
</Suspense>

View 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>
);
}

View File

@ -1,5 +1,5 @@
import { NavLink, Outlet, useLocation, useNavigate, Navigate } from "react-router-dom";
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users, BarChart3, Table2, Tv } from "lucide-react";
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users, BarChart3, Table2, Tv, ScrollText } from "lucide-react";
import { useAuth } from "../../contexts/AuthContext";
const MENU = [
@ -39,6 +39,12 @@ const MENU = [
icon: Table2,
description: "规格型号 × 人员/工序 在制品透视表",
},
{
title: "操作审计",
path: "/admin/audit",
icon: ScrollText,
description: "谁在何时操作了什么 · 含失败与被拒请求",
},
{
title: "管理层大屏",
path: "/admin/screen",
@ -66,8 +72,10 @@ export default function AdminLayout() {
return <Navigate to="/admin/login" replace />;
}
function handleLogout() {
logout();
async function handleLogout() {
// 必须 await:logout() 要先完成审计上报再清 token,
// 提前 navigate 会把请求掐断,退出就留不下痕
await logout();
navigate("/admin/login", { replace: true });
}

View File

@ -227,7 +227,8 @@ export function overallOptionsFor(
/** 列表筛选枚举 — 两阶段并集(用于筛选,不是录入项) */
/**
* 管理角色 — 必须与后端 task_service.ADMIN_ROLES 保持一致。
* 管理角色 — 必须与后端 app/core/roles.py 的 ADMIN_ROLES 保持一致
* (后端那份已从 task_service 收敛到 core.roles,是全项目唯一事实来源)。
*
* ⚠️ 收敛到这里的理由:此前这段判断散落在多处(TaskFlowView / AdminProductsPage),
* 而移动端那份只判了 SUPER_ADMIN、漏了 SUPERVISOR,导致主管被前端误挡。

View File

@ -7,7 +7,7 @@ import {
useEffect,
type ReactNode,
} 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 {
login: (username: string, password: string) => Promise<void>;
logout: () => void;
/** 登出。async 是因为必须先 await 审计上报、再清 token —— 顺序反了会丢日志 */
logout: () => Promise<void>;
isAuthenticated: boolean;
}
@ -113,7 +114,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
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();
}, []);

View File

@ -15,8 +15,10 @@ export default function ProfilePage() {
const displayName = user?.display_name || user?.username || "未知用户";
const avatarChar = displayName.charAt(0);
function handleLogout() {
logout();
async function handleLogout() {
// 必须 await:logout() 要先完成审计上报再清 token,
// 提前 navigate 会把请求掐断,退出就留不下痕
await logout();
navigate("/admin/login", { replace: true });
}

View File

@ -0,0 +1,424 @@
/** 操作审计日志 — 谁 / 何时 / 从哪 / 对什么 / 做了什么事 / 结果如何 */
import { useCallback, useEffect, useState } from "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 type { ColumnsType } from "antd/es/table";
import dayjs, { type Dayjs } from "dayjs";
import {
fetchAuditLogs, fetchAuditOptions, exportAuditLogsCsv,
type AuditLogItem, type AuditOption,
} from "../../services/auditApi";
import { extractErrorMessage } from "../../utils/errorMessage";
import { useToast } from "../../components/ui/Toast";
import AuditUsagePanel from "./AuditUsagePanel";
import ExportColumnsModal from "../../components/admin/ExportColumnsModal";
const { RangePicker } = DatePicker;
/** HTTP 方法配色 —— 让「这是读还是写」一眼可辨 */
const METHOD_CLS: Record<string, string> = {
GET: "bg-slate-100 text-slate-600",
POST: "bg-emerald-100 text-emerald-700",
PUT: "bg-amber-100 text-amber-700",
PATCH: "bg-amber-100 text-amber-700",
DELETE: "bg-red-100 text-red-700",
};
/** 结果状态:2xx 正常 / 4xx 被拒 / 5xx 服务异常 */
function statusCls(code: number | null): string {
if (code === null) return "bg-slate-100 text-slate-500";
if (code >= 500) return "bg-red-100 text-red-700";
if (code >= 400) return "bg-orange-100 text-orange-700";
return "bg-emerald-100 text-emerald-700";
}
const PAGE_SIZE = 50;
export default function AdminAuditLogPage() {
const [rows, setRows] = useState<AuditLogItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<AuditLogItem | null>(null);
const { toast } = useToast();
const [modules, setModules] = 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 用受控输入,其余即时生效)
const [userInput, setUserInput] = useState("");
const [userId, setUserId] = useState("");
const [module, setModule] = useState<string | undefined>();
const [action, setAction] = useState<string | undefined>();
const [statusCode, setStatusCode] = useState<number | undefined>();
const [range, setRange] = useState<[Dayjs, Dayjs] | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetchAuditLogs({
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"),
page,
page_size: PAGE_SIZE,
});
setRows(res.items);
// total 取自后端 count 查询的真实总数,而非当前页条数
setTotal(res.total);
} catch (e) {
setError(extractErrorMessage(e));
} finally {
setLoading(false);
}
}, [userId, module, action, statusCode, range, page]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
fetchAuditOptions()
.then((o) => {
setModules(o.modules);
setActions(o.actions);
setLogColumns(o.log_export_columns || []);
})
.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 resetFilters = () => {
setUserInput("");
setUserId("");
setModule(undefined);
setAction(undefined);
setStatusCode(undefined);
setRange(null);
setPage(1);
};
const columns: ColumnsType<AuditLogItem> = [
{
title: "时间",
dataIndex: "created_at",
width: 165,
render: (v: string) => (
<span className="whitespace-nowrap text-gray-600">{dayjs(v).format("YYYY-MM-DD HH:mm:ss")}</span>
),
},
{
title: "操作人",
dataIndex: "user_id",
width: 140,
render: (_, r) =>
r.user_id ? (
<div className="leading-tight">
<div className="text-gray-900">{r.display_name || r.user_id}</div>
<div className="text-xs text-gray-400">{r.user_id}</div>
</div>
) : (
<span className="text-gray-400">未认证</span>
),
},
{
title: "模块",
dataIndex: "module_label",
width: 110,
render: (v, r) => <span>{v || r.module}</span>,
},
{
title: "动作",
dataIndex: "action_label",
width: 100,
render: (v, r) => <Tag color="blue">{v || r.action}</Tag>,
},
{
title: "请求",
dataIndex: "method",
width: 210,
render: (_, r) => (
<div className="flex items-center gap-1.5">
<span className={`rounded px-1.5 py-0.5 text-xs font-mono ${METHOD_CLS[r.method || ""] || "bg-slate-100 text-slate-600"}`}>
{r.method}
</span>
<span className="truncate font-mono text-xs text-gray-500" title={r.url || ""}>
{r.url}
</span>
</div>
),
},
{
title: "结果",
dataIndex: "status_code",
width: 80,
render: (v: number | null) => <span className={`rounded px-2 py-0.5 text-xs font-mono ${statusCls(v)}`}>{v ?? "-"}</span>,
},
{
title: "来源 IP",
dataIndex: "ip_address",
width: 130,
render: (v: string | null) => <span className="font-mono text-xs text-gray-500">{v || "-"}</span>,
},
{
title: "",
key: "op",
width: 70,
render: (_, r) => (
<Button type="link" size="small" onClick={() => setDetail(r)}>
详情
</Button>
),
},
];
return (
<div className="p-6">
<div className="mb-4 flex items-center justify-between">
<div>
<h1 className="flex items-center gap-2 text-xl font-semibold text-gray-900">
<ScrollText className="h-5 w-5 text-blue-600" />
操作审计
</h1>
<p className="mt-1 text-sm text-gray-500">
所有写操作(含被拒绝的请求)自动留痕,共 {total} 条
</p>
</div>
<div className="flex items-center gap-2">
{/* 人员统计刻意做成抽屉而不是标签页:两个视图的粒度不同
(一行一次操作 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 className="mb-4 flex flex-wrap items-center gap-2">
<Input
placeholder="操作人账号"
prefix={<Search className="h-4 w-4 text-gray-400" />}
value={userInput}
allowClear
style={{ width: 180 }}
onChange={(e) => setUserInput(e.target.value)}
onPressEnter={() => {
setPage(1);
setUserId(userInput.trim());
}}
onBlur={() => {
setPage(1);
setUserId(userInput.trim());
}}
/>
<Select
placeholder="模块"
allowClear
style={{ width: 140 }}
value={module}
options={modules}
onChange={(v) => {
setPage(1);
setModule(v);
}}
/>
<Select
placeholder="动作"
allowClear
style={{ width: 130 }}
value={action}
options={actions}
onChange={(v) => {
setPage(1);
setAction(v);
}}
/>
<Select
placeholder="结果"
allowClear
style={{ width: 130 }}
value={statusCode}
options={[
{ value: 200, label: "成功 (2xx/3xx)" },
{ value: 401, label: "未认证 401" },
{ value: 403, label: "无权限 403" },
{ value: 422, label: "参数错误 422" },
{ value: 500, label: "服务异常 500" },
]}
onChange={(v) => {
setPage(1);
setStatusCode(v);
}}
/>
<RangePicker
value={range}
onChange={(v) => {
setPage(1);
setRange(v as [Dayjs, Dayjs] | null);
}}
/>
{hasFilter && (
<Button icon={<X className="h-4 w-4" />} onClick={resetFilters}>
清空
</Button>
)}
</div>
{error && (
<div className="mb-4 flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
<Table<AuditLogItem>
rowKey="id"
columns={columns}
dataSource={rows}
loading={loading && { indicator: <Loader2 className="h-5 w-5 animate-spin text-blue-600" /> }}
size="small"
scroll={{ x: 1000 }}
pagination={{
current: page,
pageSize: PAGE_SIZE,
total,
showSizeChanger: false,
showTotal: (t) => `共 ${t} 条`,
onChange: setPage,
}}
/>
{/* 详情抽屉:完整 URL / UA / request_id / error_message 都在这里 */}
<Drawer
title="审计详情"
width={560}
open={!!detail}
onClose={() => setDetail(null)}
>
{detail && (
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="时间">
{dayjs(detail.created_at).format("YYYY-MM-DD HH:mm:ss")}
</Descriptions.Item>
<Descriptions.Item label="操作人">
{detail.user_id ? `${detail.display_name || ""} (${detail.user_id})` : "未认证"}
</Descriptions.Item>
<Descriptions.Item label="角色">{detail.role || "-"}</Descriptions.Item>
<Descriptions.Item label="模块 / 动作">
{detail.module_label || detail.module} / {detail.action_label || detail.action}
</Descriptions.Item>
<Descriptions.Item label="目标">
{detail.target_id ? (
<>
{detail.target_name || detail.target_id}
<span className="ml-1 text-xs text-gray-400">({detail.target_type})</span>
</>
) : (
"-"
)}
</Descriptions.Item>
<Descriptions.Item label="请求">
<span className="font-mono text-xs">
{detail.method} {detail.url}
</span>
</Descriptions.Item>
<Descriptions.Item label="结果">{detail.status_code ?? "-"}</Descriptions.Item>
{detail.error_message && (
<Descriptions.Item label="错误">
<span className="break-all font-mono text-xs text-red-600">{detail.error_message}</span>
</Descriptions.Item>
)}
<Descriptions.Item label="来源 IP">
<span className="font-mono text-xs">{detail.ip_address || "-"}</span>
</Descriptions.Item>
<Descriptions.Item label="User-Agent">
<span className="break-all text-xs text-gray-500">{detail.user_agent || "-"}</span>
</Descriptions.Item>
<Descriptions.Item label="Request ID">
{detail.request_id ? (
<Tooltip title="可在后端结构化日志中用它定位同一次请求">
<span className="break-all font-mono text-xs">{detail.request_id}</span>
</Tooltip>
) : (
"-"
)}
</Descriptions.Item>
{detail.details && (
<Descriptions.Item label="变更详情">
<pre className="max-h-60 overflow-auto rounded bg-gray-50 p-2 text-xs">
{JSON.stringify(detail.details, null, 2)}
</pre>
</Descriptions.Item>
)}
</Descriptions>
)}
</Drawer>
{/* 人员统计:独立抽屉,本页表格与筛选完全不受影响 */}
<AuditUsagePanel open={usageOpen} onClose={() => setUsageOpen(false)} />
<ExportColumnsModal
open={exportPickerOpen}
columns={logColumns}
submitting={exporting}
onCancel={() => setExportPickerOpen(false)}
onConfirm={handleExport}
/>
</div>
);
}

View File

@ -13,7 +13,7 @@ import {
} from "../../services/printApi";
import { useToast } from "../../components/ui/Toast";
import { useAuth } from "../../contexts/AuthContext";
import { getStatusConfig, lifecycleBadge } from "../../constants/task";
import { getStatusConfig, lifecycleBadge, isAdminRole } from "../../constants/task";
import { extractErrorMessage } from "../../utils/errorMessage";
const QR_BASE = "/api/v1/products/qrcode";
@ -31,7 +31,7 @@ interface ProductGroup { groupKey: string; products: ProductResponse[]; allInWar
export default function AdminProductsPage() {
const { toast } = useToast();
const { user: authUser } = useAuth();
const isAdmin = authUser?.role === "SUPER_ADMIN" || authUser?.role === "SUPERVISOR";
const isAdmin = isAdminRole(authUser?.role);
const [products, setProducts] = useState<ProductResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

View 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>
);
}

View File

@ -0,0 +1,171 @@
/** 操作审计日志 API */
import api from "./api";
export interface AuditLogItem {
id: string;
user_id: string | null;
display_name: string | null;
role: string | null;
action: string;
/** 服务端补的中文标签,前端不再各自维护枚举映射 */
action_label: string | null;
module: string;
module_label: string | null;
target_type: string | null;
target_id: string | null;
target_name: string | null;
details: Record<string, unknown> | null;
ip_address: string | null;
user_agent: string | null;
method: string | null;
url: string | null;
status_code: number | null;
error_message: string | null;
/** 拿着它可在后端结构化日志中定位同一次请求 */
request_id: string | null;
created_at: string;
}
export interface AuditLogListResponse {
items: AuditLogItem[];
total: number;
}
export interface AuditOption {
value: string;
label: string;
}
export interface AuditOptionsResponse {
modules: 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 {
user_id?: string;
module?: string;
action?: string;
target_id?: string;
request_id?: string;
status_code?: number;
/** YYYY-MM-DD */
start_date?: string;
/** YYYY-MM-DD(含当天) */
end_date?: string;
page?: number;
page_size?: number;
}
/** 分页查询审计日志(按时间倒序) */
export async function fetchAuditLogs(q: AuditLogQuery = {}): Promise<AuditLogListResponse> {
const params = Object.fromEntries(
Object.entries(q).filter(([, v]) => v !== undefined && v !== null && v !== "")
);
const { data } = await api.get<AuditLogListResponse>("/audit/logs", { params });
return data;
}
/** 获取模块/动作筛选项(含导出可选列) */
export async function fetchAuditOptions(): Promise<AuditOptionsResponse> {
const { data } = await api.get<AuditOptionsResponse>("/audit/options");
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 !== "")
);
}

View File

@ -1,4 +1,5 @@
/** 认证 API — 登录、刷新 Token、获取用户信息 */
/** 认证 API — 登录、刷新 Token、获取用户信息、登出留痕 */
import api from "./api";
import type { UserInfo } from "../contexts/AuthContext";
import { extractErrorMessage } from "../utils/errorMessage";
@ -52,3 +53,18 @@ export async function getMe(token: string): Promise<UserInfo> {
}
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");
}

View File

@ -38,6 +38,7 @@
<script setup>
import { ref, computed, onMounted } from "vue";
import { checkAppUpdate } from "../../utils/ota";
import { post } from "../../utils/request";
// ============================================================
// 缓存清理策略 —— 黑名单式:只删「明确登记过的业务缓存」
@ -167,15 +168,31 @@ async function handleCheckUpdate() {
await checkAppUpdate({ manual: true });
}
function handleLogout() {
async function handleLogout() {
// 退出是不可逆的(要重新输账号密码),按车间使用场景加一道确认防误触
uni.showModal({
title: "退出登录",
content: "退出后需要重新输入账号密码,确定退出吗?",
confirmText: "退出",
cancelText: "取消",
success: (res) => {
success: async (res) => {
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 {
uni.removeStorageSync("token");
uni.removeStorageSync("access_token");