Files
track-LICA/AGENTS.md
duxingchen 9778b8e4b2 feat(audit): MOM 回调归因到实际操作人,不再显示「未认证」
外部回调走 X-API-Key 鉴权、没有 JWT,JWT 依赖不执行,审计中间件读到的
request.state.audit_user 永远是空 —— 操作审计里就出现一堆没有归属的
「外部系统对接」记录,看不出是谁扫的码。

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

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

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

本函数与 IRIS 实例(~/track)代码体逐行一致,只差 docstring —— 两侧审计口径
必须一样,否则排查时日志对不上。约定已记入 AGENTS.md。

实测:MOM 回调后审计记录显示实际操作人姓名;无 operator 时显示「MOM系统」。
2026-09-22 13:45:00 +08:00

12 KiB
Raw Blame History

AGENTS.md

本仓库Track 生产流转系统)的工作笔记。仅在验证过之后才写入,避免传谣。

⚠️ 本仓库是 LICA 部门实例

派生自 IRIS 实例(git.iris-rs.cn/duxingchen/track.gitfeature/ai-audit-update 分支 @ 192c8ee)。两套系统在同一台机器上并行 运行、共用 MOM 主数据,但人员、物料、业务数据完全隔离

IRIS 实例 LICA 实例(本仓库)
代码目录 /home/yueli/track /home/yueli/track-lica
容器 track_db / track_backend / track_frontend lica_db / lica_backend / lica_frontend
compose 项目 track track-lica
PC 管理端 8010 8030
后端 API 8011 8031
数据库 8012 8032
数据卷 track_pgdata lica_pgdata
部门过滤 IRIS LICA

两条红线:

  1. 永远不要在 /home/yueli/track 目录下执行 docker compose down -v —— 那会删掉 IRIS 的生产数据。两个卷相互独立,在 track-lica 下执行不影响 IRIS。
  2. 改后端地址时别只改一半PC 端走 docker-compose.ymlCORS_ORIGINS 移动端走 track-uniapp/src/utils/config.jsuni-app 地址的唯一来源track-uniapp/.env。只改一处会出现「接口通了但图片/上传 404」。

架构速览

  • backend/ FastAPI + SQLAlchemy 2.x(async) + AlembicPostgreSQL。

  • frontend/ React 19 + Vite + antd + Tailwind。路由见 src/App.tsx 管理端菜单见 src/components/layout/AdminLayout.tsxMENU 数组)。

  • 登录不走 Track 自己的用户表,而是只读 MOM(KCGL) 的 sys_user sys_user.username"真实姓名/登录账号"login()WHERE username LIKE '%/<账号>' 匹配,display_name/ 拆解得到。 MOM 连接配置在 app/core/mom_database.py(同步 psycopg2 引擎)。

  • 组织隔离:本实例只服务 LICA 部门,唯一开关是 app/core/config.pyORG_DEPARTMENT(对应 MOM sys_user.department)与 MATERIAL_CATEGORY_PREFIX(对应 MOM material_base.category 前缀)。 过滤点共 4 处:登录 auth_service.login、人员列表 endpoints/users.py、 物料 endpoints/materials.pygroups/items、 人员操作统计 dashboard_service.get_user_operations

    • 登录是四个过滤点里唯一区分角色的普通角色INBOUND / SUPERVISOR / WAREHOUSE_MGR / SALES必须严格属于本部门否则一律 401 SUPER_ADMIN 跨部门放行 —— IRIS 的超管也能登 LICA 实例, 供运维/管理员在两个实例之间切换。角色常量取自 app/core/roles.py 不要写裸字符串 'SUPER_ADMIN'。 (这条曾经被改错过一次:把豁免去掉导致 IRIS 超管登不进 LICA。
    • 人员与物料都是服务端钉死部门,客户端传什么部门参数都不采纳 (这样 uni-app 里写死的 dept 不会造成跨部门影响)。
    • 刻意不做「查询失败退回全表」的降级 —— 那是跨部门数据泄漏, 宁可查不出,不可查过头。
    • ⚠️ 物料必须用前缀 category LIKE 'LICA/%'不能用 ILIKE '%LICA%' MOM 里存在 171 条 IRIS/成品/LICA/...(无人机/野外便携/高塔监测等), 模糊匹配会把 IRIS 的物料漏给 LICA。已实测前缀匹配命中 795 条 / 5 个分组。
  • 业务分组数据权限2026-09 新增,是部门隔离之内的第二层) LICA 部门内部再分组(生产大组 / 维修大组,各自可建小组),组对应 lifecycle_phase,成员只看本组阶段的数据。组存在 Track 库(business_groups / business_group_phases / business_group_members),人在 MOM。

    唯一判定来源app/services/data_scope_service.pyDataScope

    • scope.product_where() —— 主体是 Product 的语句用
    • scope.task_where() —— 主体是 Task 的语句用,前置条件:调用方必须已 join(Product, Task.product_id == Product.id)tasks 表没有 lifecycle_phase
    • ⚠️ 业务代码里不准再手写 lifecycle_phase 过滤。评审检查: grep -rn "lifecycle_phase.in_" backend/app/ 应只命中 data_scope_service.py
    • ⚠️ 两条红线:None(不限) 与 frozenset()(空) 语义相反,不共用哨兵值; 空集合必须显式 false() —— SQLAlchemy 对 in_(()) 生成 IN (NULL) 一旦退化成不过滤就是全量泄漏

    解析优先级(resolve_data_scope

    1. SUPER_ADMIN → 永远全厂(硬编码,不可被分组覆盖)
    2. 被显式分进组 → 该组生效(分组优先于角色SUPERVISOR 被分组也会受限)
    3. 未分组的 SUPERVISOR → 默认全厂
    4. 未分组的普通用户 → 按 DATA_SCOPE_UNGROUPED(见下)

    过渡开关 config.DATA_SCOPE_UNGROUPED

    • "ALL"(当前默认)= 未分组用户先放行,同时打 WARNING 记录「谁还没分组」
    • "NONE" = 目标态:未分组 = 看不到任何数据(列表返回 [],不是 403
    • 直接上 NONE 会让所有未分组工人当场看不到自己的任务、现场停摆。 推荐节奏ALL 上线 → 看日志收集名单 → 建组配人 → 改 NONE 重启。

    其它约定

    • 扫码 get_product_by_serial 刻意不过滤(现场要能判断设备走没走错流程), 理由写死在该函数 docstring 里;「不能操作」由 _check_permission 保证。
    • 分组管理SUPER_ADMINrequire_roles(SUPER_ADMIN),不能用 require_admin)。原因:被分组的主管若能把人移出组,就等于获得提权路径。
    • 组支持两级(parent_id),子组不配范围则继承父组。
    • 删组仅限空组 —— 级联删是静默的批量权限变更。
    • 统计接口dashboard/analytics/screen 共 20 个路由)已从匿名可访问变为需登录 并受范围过滤。注意:大屏挂满 7 天后 refresh token 过期会跳登录页。
  • MOM 回执的部门校验endpoints/webhooks.py2026-09 新增) MOM 同时对接 IRIS 与 LICA 两个 Track 实例,按载荷里的 company_name 分流。 本实例LICA采取严格白名单

    company_name 本实例行为
    "LICA"strip 后) 正常处理
    空白 / 缺失 / "IRIS" / 未知值 忽略,返回 reason: "ignored_company"

    ⚠️ 这与 IRIS 实例的策略刻意相反,不要"统一" IRIS 对空白值要放行 —— MOM 判定不出公司时会回落到指向 IRIS 的扁平配置, 不收就彻底丢了LICA 没有兜底角色,空白值本就该由 IRIS 兜。 LICA 的原则是宁可漏、不可误收:误收会改掉别的部门的设备状态。

    ⚠️ 拦截时返回 200 + matched=False 而非 4xx —— 与"未命中"保持同一契约, 避免 MOM 侧当成故障去重试。reason: "ignored_company" 这个取值与 IRIS 实例 保持一致MOM 不解析它,但排查时两边日志要对着看)。

    排查口径:reason = 被部门校验拦下;无 reason = 通过了校验、只是产品没匹配上。

  • MOM 回执的审计归因endpoints/webhooks.py2026-09 新增) 外部回调走 X-API-Key、没有 JWT审计中间件读到的 request.state.audit_user 永远是空 —— 操作审计里会出现一堆没有归属的「外部系统对接」记录,看不出是谁 扫的码。现在用载荷里的 operatorMOM 侧实际扫码的那位)写进 request.state 归因到人;取不到操作人时写 "MOM系统",而不是继续显示「未认证」。

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

    该函数与 IRIS 实例(~/track)代码体逐行一致,只差 docstring —— 改动请两边 同步,否则两个实例的审计口径会对不上。

本地起环境(关键,踩过的坑都在这)

  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://lica:<密码>@127.0.0.1:8032/lica_production
    • MOM_DB_HOST=inventory_dbMOM_DB_PORT=5432(容器内走 projects_default 网络的服务名;从宿主机连则用 127.0.0.1:5435
    • 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 服务名 lica_backend:8000(本实例的服务名刻意不叫 backend,避免在 projects_default 网络上与 IRIS 实例重名,否则将来任何一方 写 http://backend:8000 会随机打到另一个部门)。本机跑要么把 127.0.0.1 lica_backend 写进 /etc/hosts,要么直接给 VITE_API_BASE_URL=http://localhost:<port>/api/v1 绕过 proxy。 注意 dev server 由 basicSsl 起 HTTPS —— 必须用 https:// 访问, 用 http:// 会直接连不上curl 报 000。跨域需要后端 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 基建,也没有前端测试脚本。

已知的待修问题(继承自 IRIS 侧 192c8ee,本仓库未修

  • 读接口大面积未鉴权(已实测,非推测):无 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.pyrequire_admin / require_roles
    • 对 LICA 的影响面/api/v1/users/ 虽无鉴权,但部门过滤在 SQL 层钉死, 实测无 token 也只能拿到 19 个 LICA 人员,不会泄露 IRIS 的人 其余匿名接口读的是本实例自己的库,跨不到 IRIS 库。真正的风险是 「本部门内部」的越权(如普通操作员能看管理端统计),不是跨部门。
    • AdminLayout 只判登录不判角色LICA 的 15 个 INBOUND 账号能进 PC 管理端。
  • dashboard_service.py 查 tasks 时用小写 pending/in_progress/completedTask.status 实际存大写(PENDING/WIP/COMPLETED 导致管理端「待接收/进行中/已完成」三个数字恒为 0。 (注意 Product.status 确实是小写,修的时候别一起改。)
  • 「管理员角色」这份规则此前散在 4 处(后端 task_serviceproducts.py 内联、 前端 constants/task.tsAdminProductsPage 内联),已因此出过事故。 后端唯一事实来源是 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 避免前端再抄一份映射开始漂移。
  • 本仓库的提交信息用中文,说明「为什么」而非「改了什么」。