Commit Graph

6 Commits

Author SHA1 Message Date
0b87a72a41 feat(audit): 级联操作聚合 —— 一次业务点击只产生一条审计
问题:BOM 归档/启停这类接口一次请求改动整组数据,ORM 监听器逐行写审计,
审计页被刷屏。**实测问题范围远大于 BOM**(同一秒、同接口、同操作人的条数):

    /api/v1/inbound/stock/draft/start-new             924 条
    /api/v1/inbound/stock/stocktake/generate-missing  898 条
    /api/v1/permissions/assign                        381 条
    /api/v1/outbound                                  110 条
    /api/v1/bom/save                                   83 条
    /api/v1/bom/archive                                58 条

全库共 311 次「单请求 ≥20 条」的突发,累计 4 万余行。

实现:URL 白名单 + 同事务合并(方案 A 的收敛版)
  · AGGREGATE_PATH_MARKERS 按**路径段前缀**匹配(不是子串):
    /api/v1/bom_draft_x 不会命中 bom,/api/v1/somebom/thing 也不会。
  · 命中白名单的变更在 flush 期间累加到 session.info,由 Session
    after_flush 钩子合并成**一条** AuditLog。
  · details = 各行的**深度公共子集** + aggregate{count, targets}。

★ 为什么用白名单而不是全局默认合并:合并会改变审计的**语义粒度**
  (日报里「修改 354 条」可能变成几十条),全局改会让业务方以为日志坏了。
  白名单的失败模式也更安全 —— 新接口忘了登记只是"仍然刷屏",
  而不会把两个不相干的业务动作错误合并(错误合并 = 把 A 的改动记到 B 头上,
  是审计里最危险的一类错)。

★ 只取公共子集,不用某一行的值代表整批 —— 那是编造。BOM 的级联批次各行
  改动完全一致(实测 /bom/archive 400 条只有 3 种签名、349 条同一个),
  故合并**无损**;各行不一致时 changes 只留共同字段,其余由 targets 交代
  "动过哪些对象"。

★★ 钩子必须挂 after_flush,不能挂 before_commit —— 踩过的坑:
   Session.commit() 的顺序是 before_commit → flush → after_flush → COMMIT,
   而累加发生在 flush **期间**。挂 before_commit 时钩子跑在累加之前,
   缓冲还是空的;等 flush 填满后没人再写 —— 结果是**审计整批丢失**
   (实测归档请求产出 0 条日志,业务却已提交,正是最危险的"改了但没记录")。
   首版就是这个错,靠真实请求打 /bom/archive 数日志条数才抓出来。

★ 为什么不用 after_request/teardown:那跑在业务事务之外,业务回滚也会留下
  一条"成功"的审计 —— 假账。after_flush 仍属同一事务,聚合日志与业务改动
  同生共死(实测回滚后不留日志)。

消费端:
  · changes_summary 优先识别 aggregate,读作
    「批量更新 58 条;是否启用:是→否;是否归档:否→是(来源:/bom/archive)」
  · SNAPSHOT_VIEWS 增加 aggregate,抽屉据此渲染「变更次数 + 受影响对象表」
  · 前端把「变更对比」与「快照/汇总」从二选一改为各自独立渲染 ——
    聚合日志两者都有,原先的 v-else 会让汇总块显示不出来

验证(26 + 18 项断言全过):
  · 真实请求 POST /api/v1/bom/archive 打一个 58 行的 BOM:
    归档前 0 条 → 归档后**恰好 1 条**,count=58、targets 58 条、
    共同变更无损、业务改动同时生效(58/58 行已归档)
  · 回滚后不留下聚合日志(59996 → 59996)
  · 路径匹配 15 个边界(含 bom_draft_x / somebom 两个反向用例)
  · 非白名单接口行为不变;日报附件 490.5K/193.4K/92.3K 与 6 列结构未变;
    导出口径与列表一致;权限与凭据过滤未松动;字典 181 键零缺失
  · vue-tsc 与 vite build exit=0

⚠️ 仅对**改动之后**的请求生效,历史 4 万行突发数据不变。
⚠️ 验证过程在库里留下 4 条真实审计记录(id 60278/60279 等,均是本人对
   SF-9000-9 V2.2 的归档/取消归档操作,业务数据已精确还原为原状)。
   审计记录未删除 —— 删审计要单独决策。
2026-09-28 11:07:10 +08:00
956d4acb5f feat(audit): 快照嵌套分层渲染 + 凭据字段从接口剥离(安全修复)
起因:借库管理等单据的详情页仍显示原始 JSON 代码块。
根因:details 有**第三种**存法 {'payload': {...}},且内部嵌着对象数组
(items 明细行),而旧实现只认平铺的 created/deleted_snapshot,
遇到非平铺结构就退化成原始 JSON。

1. 快照键收敛(后端)
   created / deleted_snapshot / payload 三种键与展示标题收进
   audit_labels.SNAPSHOT_VIEWS,经 /audit/labels 下发 snapshotViews,
   前端不再硬编键名 —— 将来加第四种只改一处。
   audit_export_service 与 daily_report_service 改为引用同一份。
   changes_summary 也改用 snapshot_of_any:payload 型记录的新增摘要
   此前整列为空,现在有内容了(如「新增(借库管理):物料明细 1 项、
   备注、借用人、签名、预计归还时间」)。

2. 分层渲染(前端)
   · 标量字段 → el-descriptions(保留字典翻译与空值/技术字段过滤)
   · 数组字段 → el-table:列取**所有元素键的并集**(同一数组内元素键
     并不完全一致,只取首个元素会漏字段),跳过技术字段,列头走 fieldLabel
   · 标量数组(arrival_photo 等图片 URL 列表)→ 单列表格
   · details / payload 本身是数组或标量的情况也一并处理
   · 只有确实无可识别结构时才回落到原始 JSON

   实测全库 58728 条:**0 条**会退化到原始 JSON 兜底
   (payload 1495 条全部拆出结构)。

3. ★ 安全修复:审计快照里存有**明文密码**,本次从所有出口剥离
   实测 `用户管理/新增` 的 payload 快照记着哈希前的原始密码(27 条,
   形如 '123456'、'shili0823'),因为写入路径把请求体整包记进了审计。

   · sanitize_details():递归剥掉凭据类字段,应用于列表与详情接口
   · changes_of / snapshot_of 在**源头**滤掉凭据:逐个出口去补必然漏掉
     某一个,而漏掉的那个就是泄漏点(日报附件、导出、摘要都走这两个函数)
   · 凭据判定用**关键词**(password/passwd/secret/token/private_key)
     而非逐个登记 —— 今天漏的是 payload 里的 password,明天可能是
     reset_token。新表加凭据字段也自动被挡住。

   ★ 第一版我只做了前端隐藏(hiddenFields),被测试抓出来:原始响应里
     密码照样在,打开 devtools 就读得到。**要挡的数据必须在接口出口剥掉,
     前端隐藏不是防线。**

验证:14 + 15 项断言全过,含「列表/详情响应无密码原文与 password 键」
「全量导出的表头与单元格均无凭据」「日报三天附件无凭据」
「sanitize_details 递归进嵌套数组且不原地改原对象」。
6 列结构与附件大小未变(490.5K / 193.4K / 10.3K);vue-tsc 与 vite build exit=0。

⚠️ 数据库中仍存有明文密码(16 条非空),本次只挡展示与导出,**未改数据** ——
   改数据不可逆,需单独立项决定。
2026-09-28 10:40:00 +08:00
46aec0b954 feat(audit): 提升可读性 —— 操作对象补全物料名、详情降噪、字段字典补齐
业务方反馈两点:操作对象太干瘪、详情快照噪音太多。

1. 操作对象补全为「SKU - 物料名称 (规格型号)」

   库里 target_name 大多只存了 SKU('0000002270'),入库类甚至存的是内部
   标识('stock_buy ID:1667'),业务人员完全看不懂。

   补全走两条路径,**正确性优先**:
     · 快照里的 base_id(CREATE/DELETE 的库存行)—— 权威,零歧义
     · target_id 唯一命中一张股票表 —— 实测与上一条 1230/1230 完全一致

   ★ 撞号一律放弃:target_id 同时命中 2 张股票表时解出的 base_id
     24/27 是错的、命中 3 张时 44/44 全错。补一个**错的**物料名比不补更糟 ——
     那是"看起来完全可信的错误答案",业务方会照着它去找不相干的物料。
     实测 300 条撞号行 0 条被补,300 条唯一命中行全部补上。

   target_name 原值保留(target_keyword 搜索仍按它匹配),新增 target_display
   供展示。前端去掉灰显的 #target_id —— 业务人员不需要看数据库主键。

2. 详情快照降噪

   · 前端过滤空值:null / '' / [] / {} / '-'。判据**严格**:false 和 0
     不算空(`is_returned: false`、`quantity: 0` 是明确的业务事实,
     用真值判断会把它们一起吃掉,那是在篡改数据)。
   · 过滤纯技术字段:id / created_at / updated_at / target_id / module_name
     及 pgvector 的 `*embedding`(单条可达数 KB)。清单由后端下发
     (labels.hiddenFields),前端不硬编 —— 它会随新表增长,再存一份必然漂移。
     embedding 类按后缀拦截,新表加向量列不必改代码。
   · 变更对比表过滤「等于没改」的行(null ↔ 空串)。

3. 字段字典补齐:168 项

   实测快照里出现过但字典没有的字段全部补上(buyer_email / currency /
   in_date / exchange_rate / dosage / loss_rate / child / parent /
   production_* / return_* / *_threshold 等 60+ 个)。
   现在快照字段缺中文名的数量为 **0** —— 详情页不会再裸露英文。

4. 摘要优化

   · changes_of 丢掉无意义变更(null ↔ 空串)。库里 98 条记录带这种变更,
     写进摘要就是「备注:空→空」,纯噪音还挤占截断长度。
   · CREATE/DELETE 摘要改为核心属性**按槽位取值**:
         新增(入库):入库数量 10 件、库位 ZZTEST
     而不是「新增:SKU、base、状态… 等 29 个字段」。
   · 槽位只有数量与位置两个,**不含物料** —— 物料由「操作对象」列承担,
     同一屏里再来一遍是重复。数量字段也按槽位只取一个:
     in_quantity / stock_quantity / available_quantity 值往往相同,
     取三个会得到三个一样的数字。
   · 数字去掉无意义的 .0(10.0 → 10);单位取自物料主数据,
     纯数字的占位单位(库里有一批 unit='1')丢弃。

验证:33 + 16 项断言全过,含「补全的物料与快照 base_id 零冲突」「撞号行
一律不补」「快照字段缺中文名 0 个」「摘要无空→空」「导出口径与列表一致」;
vue-tsc --noEmit 与 vite build 均 exit=0。
日报回归:三天附件 490.5K / 193.4K / 10.3K,6 列结构未变。
2026-09-28 10:19:58 +08:00
6a9e41f53c feat(audit-ui): 审计页 UI 重构 —— 干净模块下拉、变更摘要、抽屉详情、触发来源
后端:GET /audit/modules 与 /logs 的 modules 改为下发最终选项 [{value,label}]
  · 历史命名(入库管理/采购入库/成品入库/库存管理)折叠进「入库」一项且
    **不再单独列出**,下拉里看不到历史包袱。展开仍只由后端 expand_modules
    负责 —— 前端若自己再拼一份成员表,将来后端加成员会静默失效(本项目
    已经踩过两次这种漂移)。
  · 英文历史 module(image_embeddings 2719 条 / purchase_request 75 条 /
    sys_element 6 条)在这里翻成中文 label,value 保留英文原值否则筛选
    匹配不上。映射表 MODULE_LABELS 从**前端** AuditLog.vue 的 moduleMap
    收进 audit_labels.py —— 那份硬编副本历史上已经漂移过一次。
  · /audit/labels 新增 moduleDisplay {原始值 → 展示名},同时覆盖聚合成员与
    英文历史值:只覆盖后者会出现「下拉显示『入库』、表格显示『库存管理』」,
    用户会以为筛选没生效。

后端:新增「触发来源」
  · 摘要追加 (来源:/outbound/request) 形式的 URL 尾段(去掉 /api/vN 前缀)。
    解决一个具体误解:业务方看到「入库/库存管理」里有大量非库管人员的
    UPDATE,以为越权改库存,实际是出库/借还/盘点等单据流转触发的自动扣减。
    实测该类 UPDATE 的来源:outbound 1582、outbound/request 424、
    borrow/dispatch 102、stocktake/update-quantity 95 —— 光看"谁改的"永远
    解释不清,必须能看出"哪个流程触发的"。
  · 来源拼在**截断之后**,保证这条关键信息永远不会被截掉。
  · 列表与导出共用 changes_summary,故 Excel 台账同样带来源。

后端:其余
  · 新增 target_keyword(ilike 同时匹配 target_id / target_name),
    放进共用的 _build_audit_query,导出自动支持。
  · action 在响应里归一化为 CREATE/UPDATE/DELETE(复用 audit_labels
    .canon_action,覆盖批量删除/分配/归还等全部历史别名)。库里还有 1573 条
    中文 action,归一后前端不必再为每种历史写法兜底。
  · 新增 summary 字段。不算在模型 @property 上:摘要要把 user_id/base_id
    翻成人名/物料名,需要查库,模型属性里发查询就是 N+1;改为整页一次
    load_ref_maps 批量解析。
  · 过滤对象 repr 脏值(<MaterialBase 3030>)。快照收集早期漏跳关系属性,
    库里留了 1529 条这种值 —— 不是业务数据,真正的值在对应 _id 字段里。
    判据收窄到「<类名 空格 内容>」,避免误伤备注里的「<急件>」。

前端 AuditLog.vue
  · 模块下拉改平铺 [{value,label}],删除硬编的 moduleMap
  · 新增「操作对象」模糊搜索
  · 合并「操作人」「姓名」两列(判据 username==='system' —— 响应里没有
    operator_type 字段,那是请求参数名,照搬会永远不显示系统标签)
  · 新增「变更摘要」列;操作对象显示为「名称 #id」
  · 操作时间改为恒显示北京时间,不再按浏览器时区换算,与数据库/日报/导出一致
  · 详情弹窗改 el-drawer,按 action 分支渲染:UPDATE 出对比表,
    CREATE/DELETE 出快照属性表,并跳过对象 repr
  · 抽屉顶部高亮展示触发来源(METHOD + URL 告警条)

验证:后端 18 + 22 + 10 项断言全过(模块选项无历史名且「入库」=四值之和、
英文 value 仍可筛选、url_source 边界、来源不被截断、导出口径与列表一致、
权限 401/403/200 矩阵);vue-tsc --noEmit 与 vite build 均 exit=0。
日报回归:三天附件 490.5K/193.5K/10.3K,6 列结构与改动前一致。
2026-09-28 10:10:05 +08:00
7d203b20c1 feat(audit): 按条件导出审计日志,并修 module 口径断裂与详情接口权限漏洞
需求是"想看某天或某段时间的入库的修改,支持下载或当场查看"。
核查后:筛选与查看审计页早已具备,缺的是导出 —— 但直接加导出会撞上两个
既有问题,故一并处理。

1. 「入库」的 module 口径在 2026-09-10 断过一次

   那天旧的全局监听器 app/utils/audit_events.py 被现行的
   app/core/audit_listener.py 取代(旧模块现已无人 import,是死代码),
   新监听器把入库三表 stock_buy/stock_semi/stock_product 统一归为
   「库存管理」,不再产出「入库管理」:

       入库管理  16118 条  2026-04-22 ~ 2026-09-10
       采购入库   1196 条  2026-03-18 ~ 2026-04-20
       成品入库      8 条  2026-03-23 ~ 2026-04-17
       库存管理   1371 条  2026-09-10 ~

   后果:按单个 module 值筛「入库」会正好在那天断掉,现象是"入库记录突然
   没了"。解法是**查询期别名展开**(audit_labels.MODULE_GROUPS),不迁移
   历史数据 —— 改数据不可逆,而聚合查询无损。

2. GET /audit/logs/<id> 缺权限校验,也没有公司隔离

   此前只有 @jwt_required(),任何登录用户改一下 URL 里的 id 就能读到全部
   审计明细(含 details 里的完整快照)。列表接口有 system_audit 把关,
   详情提供的信息是列表的超集,没道理比列表更宽松。
   现补权限码 + 与列表同一个公司判据;越权与不存在**统一返回 404**,
   区分开来等于告诉探测者"这个 id 存在,只是你没权限"。

3. 筛选逻辑只写一份

   抽出 _build_audit_query(),/logs 与 /logs/export 共用。两处各写一份迟早
   出现"页面上 300 条、导出来 280 条",而报表对不上比没有报表更糟。

导出实现:
- GET /audit/logs/export,权限码同 /logs;忽略分页参数,导全量。
- 三个工作表:汇总(含本次生效的筛选条件,收件人看不到页面上的筛选框)、
  审计日志(一行一条)、变更明细(一行一个变更字段,可对「字段」列筛选)。
- EXPORT_LIMIT=100000 作熔断。实测全库 5.8 万条导出 2.5MB / 约 3 秒,
  故走同步返回,不引入 export_service 那套异步框架(它还会落盘且无清理)。
  触顶时在文件名与汇总表显式写明,不静默丢弃。
- 顺带补 /audit/modules 的公司隔离(下拉选项此前会跨公司)。

前端(AuditLog.vue / api/audit.ts):
- 模块下拉分「业务聚合 / 具体模块」两组,聚合项由后端下发,前端不硬编成员;
- 操作类型改多选,提交时 join(',')(后端 split(',') 接收);
- 导出按钮沿用仓库既有的 blob 下载写法,文件名前端自定。

验证:后端 21 项断言全过(口径断裂、多选别名归一、日期闭区间、公司隔离、
401/403/200 权限矩阵、导出口径与列表 total 一致、附件为合法 xlsx);
vue-tsc --noEmit 与 vite build 均 exit=0。
2026-09-28 09:46:34 +08:00
ad2d27cd72 refactor(audit): 审计中文映射收敛为后端唯一来源
问题:同一套「字段名 → 中文」映射存了三份手工同步的副本 ——
前端 AuditLog.vue 的 fieldMap(约 95 个字段)、后端 api/v1/audit.py 的
ACTION_ALIASES,另有若干内联的 status 映射散落在 service 层。
改一边漏一边就会漂移,页面上冒出英文列名或对不上的中文。

改动:
  · 新增 app/utils/audit_labels.py 作为**唯一来源**,统一提供
    操作类型归一化(canon_action)、字段名中文化(field_label)、
    码值中文化(enum_label / bool_label / person_name_label)、
    ID 指向登记(id_ref_of)。
  · api/v1/audit.py 改为从该模块 import,删掉本地副本。
  · 新增 GET /audit/labels 下发映射(静态标签,免权限码;审计页本身
    已由 system_audit 把关)。
  · 前端 AuditLog.vue 删除本地 95 行 fieldMap,改从接口拉取;
    拉取失败时未命中项原样显示,是可控降级。

★ 码值必须**按模块**翻译:同名 status 在出库/借还/报废里含义完全不同
  (出库的 3 是「已出库」,报废的 3 是「已执行(已报废)」),
  不区分模块会把报废单显示成已出库。借还模块下还混着字符串状态
  (borrowed/returned),因其两张表共用 status 列名。
  故映射按 (模块, 字段) 匹配,未登记再退回字段名。

★ ID 字段同理:parent_id 在「系统管理」里是菜单的上级菜单,在「BOM管理」
  里是物料节点 —— 指向完全不同的表。

验证:前端 vue-tsc 改前改后均为 565 个错误、去除行号后**报错集合完全相同**,
未引入新错误;GET /audit/labels 实测返回 200。
2026-09-23 17:16:20 +08:00