# inventory-backend/app/api/v1/audit.py import io from datetime import datetime, timedelta from flask import Blueprint, current_app, jsonify, request, send_file from flask_jwt_extended import jwt_required from sqlalchemy import func, or_ from app.extensions import db from app.models.audit import AuditLog from app.models.system import SysUser from app.services.audit_export_service import ( SYSTEM_USERNAME, build_audit_workbook, changes_summary, format_target_display, load_child_lookup, load_material_context, load_ref_maps, sanitize_details, ) from app.utils.decorators import get_current_company_filter, permission_required audit_bp = Blueprint('audit', __name__) # ============================================================================= # 操作类型归一化 / 中文化标签 # # ★ 映射表已统一收敛到 app/utils/audit_labels.py(**唯一来源**)。 # 本模块与日报服务共用同一份,前端经 GET /audit/labels 拉取同一份 —— # 改一处即可,不会再出现"两边手工同步、改一边漏一边"的漂移。 # # 历史问题(迁移前):前端 AuditLog.vue 自带一份 fieldMap,后端这里自带一份 # ACTION_ALIASES,两份副本各自演化。 # ============================================================================= from app.utils.audit_labels import ( # noqa: E402 ACTION_ALIASES, canon_action, expand_modules, labels_payload, module_options, ) # 列表页「变更摘要」列的截断长度。太长会把表格撑变形且扫读性差 —— # 完整逐字段对比在详情抽屉里,摘要只负责"一眼看出改了啥"。 SUMMARY_LIMIT = 120 # 单次导出的记录数上限。 # # ★ 这是**熔断**,不是"为了让报表好看"的截断: # · Excel 单表硬上限 1,048,576 行,长表展开后会成倍放大; # · 同步请求跑太久会超时,用户只看到"失败",不知道是数据量的问题。 # 实测当前全库共 5.8 万条、全量导出 3.3 秒 / 2MB,正常情况下永远碰不到这个值。 # 触顶时会**显式告知**(写进文件名和汇总工作表),不静默丢弃 —— # 报表的读法就是"看到的就是全部",偷偷砍掉比报错更危险。 EXPORT_LIMIT = 100000 # ============================================================================= # 查询构造 —— /logs 与 /logs/export 的**唯一**入口 # ============================================================================= def _build_audit_query(args): """ 按请求参数构造审计日志查询,返回 (query, company_limit)。 ★ 列表与导出共用本函数。两处各写一份筛选逻辑,迟早会出现 "页面上看到 300 条、导出来 280 条"这种对不上的情况 —— 而报表对不上,比没有报表更糟(人会照着错的数字做决定)。 这正是 audit_labels.py 开头警告过的"两份手工同步的副本必然漂移"。 ★ 可多选的参数用逗号分隔(与仓库既有惯例一致:semi.vue 的 statuses 用 join(',') 传参、inbound/product.py 用 split(',') 接收)。 """ query = AuditLog.query # ---------- 操作来源:all(默认) / user(仅真实用户) / system(仅系统) ---------- # 背景:改造前的全局监听器没有请求上下文守卫,系统初始化与后台定时任务 # 产生了大量 username='system' 的日志(历史存量约 1.8 万条),会把列表刷屏。 operator_type = (args.get('operator_type') or 'all').strip().lower() if operator_type not in ('all', 'user', 'system'): operator_type = 'all' if operator_type == 'user': query = query.filter(AuditLog.username != SYSTEM_USERNAME) elif operator_type == 'system': query = query.filter(AuditLog.username == SYSTEM_USERNAME) # ---------- 操作人(精确匹配)---------- # ★ 由模糊(LIKE %值%)改为精确:前端的操作人已从自由输入改为**下拉选择**, # 选出来的是完整账号,再模糊匹配就是错的 —— 将来出现 `gaoxue` 与 # `gaoxue2` 两个账号时,选前者会连带把后者的记录一起查出来。 # 部分匹配的需求由下拉的 filterable(在选项里搜)承担,不需要落到 SQL。 username = (args.get('username') or '').strip() if username: query = query.filter(AuditLog.username == username) # ---------- 模块(支持多选 + 聚合别名)---------- # ★ 经 expand_modules 展开:「入库(全部)」会变成它名下的全部成员值。 # 这一步是必需的 —— 审计的 module 在 2026-09-10 换过一次口径 # (旧的「入库管理」→ 新的「库存管理」),不展开就会在换口径那天 # 断掉,用户看到"入库记录突然没了"。 # 详见 audit_labels.MODULE_GROUPS 的说明。 modules = [m for m in (args.get('module') or '').split(',') if m.strip()] if modules: query = query.filter(AuditLog.module.in_(expand_modules(modules))) # ---------- 操作类型(支持多选)---------- actions = [a for a in (args.get('action') or '').split(',') if a.strip()] if actions: # ★ 逐个归一化再合并别名集合,不能只处理第一个: # 历史数据里 CREATE 与「新增」混用,多选时若只对首个值展开别名, # 其余选项就搜不到早期数据(4 月前的中文 action)。 aliases = set() for a in actions: canon = canon_action(a) aliases.update(ACTION_ALIASES.get(canon, (a,))) query = query.filter(AuditLog.action.in_(aliases)) target_id = (args.get('target_id') or '').strip() if target_id: query = query.filter(AuditLog.target_id == target_id) # ---------- 操作对象模糊搜索 ---------- # 与 target_id 的分工:target_id 是**精确**匹配(程序化调用、追单条记录用), # target_keyword 是给人在搜索框里用的模糊匹配,同时命中单号/编码/名称。 # # ★ 用 ILIKE '%kw%' 走不了 btree 索引,会顺序扫描。当前全库 5.8 万条实测 # 毫秒级,可接受;若将来量级上去了,再加 pg_trgm 的 GIN 索引即可 # (不需要改这里的写法)。 target_keyword = (args.get('target_keyword') or '').strip() if target_keyword: query = query.filter(or_( AuditLog.target_id.ilike(f'%{target_keyword}%'), AuditLog.target_name.ilike(f'%{target_keyword}%'), )) # ---------- 日期区间 ---------- # 语义是**闭区间**:end_date 当天 23:59:59 的数据也要包含进来, # 故上界取次日 00:00 的严格小于。写成 <= end_date 会静默丢掉当天。 start_date = (args.get('start_date') or '').strip() if start_date: try: query = query.filter( AuditLog.created_at >= datetime.strptime(start_date, '%Y-%m-%d')) except ValueError: pass end_date = (args.get('end_date') or '').strip() if end_date: try: query = query.filter( AuditLog.created_at < datetime.strptime(end_date, '%Y-%m-%d') + timedelta(days=1)) except ValueError: pass # ---------- 【行级数据隔离】---------- # AuditLog 表**没有** company_name 字段,公司信息只能经 SysUser.department 反查。 # 审计日志的 username 存的是账号('gaoxue'),而 sys_user.username 存的是 # '高雪/gaoxue',故取 '/' 之后的账号部分去 join。 # # ★ 导出接口必须带同样的隔离,否则"导出"就成了绕过数据隔离的后门 —— # 页面按公司过滤、导出却给全量,这个洞比页面越权更隐蔽。 company_limit = get_current_company_filter() if company_limit is not None: query = query.join( SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username ).filter(SysUser.department == company_limit) return query, company_limit def _filter_summary_rows(args): """ 把本次生效的筛选条件写成汇总表的几行。 ★ 收到 Excel 的人看不到页面上的筛选框,没有这段说明就无从判断 "这份表是全量还是某个子集"。 """ rows = [] operator_type = (args.get('operator_type') or '').strip().lower() if operator_type: rows.append(('筛选条件', '操作来源', {'user': '仅真实用户', 'system': '仅系统操作'}.get(operator_type, '全部'))) labels = [ ('username', '操作人(模糊匹配)'), ('module', '模块'), ('action', '操作类型'), ('target_id', '目标ID'), ('target_keyword', '操作对象(模糊匹配)'), ] for key, label in labels: val = (args.get(key) or '').strip() if val: rows.append(('筛选条件', label, val)) start_date = (args.get('start_date') or '').strip() end_date = (args.get('end_date') or '').strip() if start_date or end_date: rows.append(('筛选条件', '日期区间', f"{start_date or '不限'} ~ {end_date or '不限'}")) return rows def _serialize_logs(rows): """ 审计记录 → 响应 dict(列表与详情共用)。 在 to_dict() 之上补两件事: ★ **action 归一化**。历史数据里 CREATE 与「新增」混用(早期装饰器写中文, 现行监听器写英文)。归一后前端不必再为每种历史写法兜底 —— 否则每个 新消费者都要记得再兜一次,漏一个就静默显示英文/中文原值。 归一用 audit_labels.canon_action(**唯一来源**,覆盖全部历史别名), 不在模型上另建一份映射:那份只认识 3 个中文词,`批量删除`/`分配`/`归还` 这类别名会漏掉。 ★ **变更摘要**。这里算而不是做成模型 @property:摘要要把 user_id / base_id 翻成人名 / 物料名,需要查库;模型属性里发查询就是 N+1。 改为整页一次性批量解析(load_ref_maps 按 ref 类型各查一次), 50 条的页面对应 2~3 次查询,与逐行查库是两回事。 """ if not rows: return [] ref_maps = load_ref_maps(rows) # ★ 操作对象补全:库里 target_name 大多只存了 SKU('0000002270')甚至内部 # 标识('stock_buy ID:1667'),业务人员看不懂。补成 # 「SKU - 物料名称 (规格型号)」;解不出物料的行不补,前端回落原始值。 # 见 load_material_context —— 撞号的 target_id 一律放弃,宁可留空不补错。 materials = load_material_context(rows) # ★ 父子关系记录(BOM)的子件:批量解析,无 N+1。见 load_child_lookup。 children = load_child_lookup(rows) out = [] for r in rows: d = r.to_dict() # ★ 在**接口出口**剥掉凭据类字段。只靠前端隐藏不够 —— # 原始响应里照样有明文密码,打开 devtools 就读得到。 # 见 audit_export_service.sanitize_details。 d['details'] = sanitize_details(d.get('details')) d['action'] = canon_action(d.get('action')) mat = materials.get(r.id) # 保留原始 target_name(搜索仍按它匹配),展示用 target_display d['target_display'] = format_target_display(mat) d['summary'] = changes_summary( r, ref_maps, limit=SUMMARY_LIMIT, material=mat, child=children.get(r.id)) out.append(d) return out def _operator_options(company_limit): """ 操作人下拉选项:[{'value': 账号, 'label': '中文名(账号)'}],按记录数降序。 ★ 从 **audit_logs** 取而不是从 sys_user:审计里记的是操作发生时的账号, 用户被删/改名后 sys_user 就查不到了,而历史审计仍需要能按他筛选。 实测 32 个操作人里有 1 个在 sys_user 中已不存在。 ★ display_name 本来就在审计行上('杜邢宸(duxingchen)'),不必 join。 ★ 按记录数降序:最活跃的人排最前,下拉一打开就是常用的那几个。 ★ 排除 system:它不是人,且「操作来源」那组单选已经专门管它 (真实用户 / 系统操作 / 全部)。混进来只会让两个控件语义打架。 """ q = db.session.query( AuditLog.username, func.max(AuditLog.display_name), func.count().label('n'), ).filter(AuditLog.username != SYSTEM_USERNAME) if company_limit is not None: q = q.join(SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username) \ .filter(SysUser.department == company_limit) q = q.group_by(AuditLog.username).order_by(func.count().desc()) out = [] for name, display, _n in q.all(): if not name: continue dn = (display or '').strip() # display_name 库里存法不统一('高雪(gaoxue)' / '高闯/gaochuang' / 空), # 统一成「名(账号)」;拿不到名字就只显示账号。 label = name for sep in ('(', '/'): if sep in dn: dn = dn.split(sep)[0].strip() if dn and dn != name: label = f"{dn}({name})" out.append({'value': name, 'label': label}) return out def _distinct_with_company(column, company_limit): """ 取某列的去重值(带公司隔离)。 ★ 下拉选项也要隔离:不带的话,A 公司的用户在筛选框里能看到 B 公司 才有的模块名,属于低危但确实存在的信息泄露。 """ q = db.session.query(column).distinct() if company_limit is not None: q = q.join(SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username) \ .filter(SysUser.department == company_limit) return [v[0] for v in q.all() if v[0]] # ============================================================================= # 接口 # ============================================================================= @audit_bp.route('/logs', methods=['GET']) @jwt_required() @permission_required('system_audit') def get_audit_logs(): """获取审计日志列表(分页)""" try: page = request.args.get('page', 1, type=int) page_size = request.args.get('pageSize', 50, type=int) query, company_limit = _build_audit_query(request.args) # 排序 query = query.order_by(AuditLog.created_at.desc()) # 分页 pagination = query.paginate(page=page, per_page=page_size, error_out=False) data = _serialize_logs(pagination.items) # 获取可用的模块和操作类型(同公司范围内) raw_modules = _distinct_with_company(AuditLog.module, company_limit) actions_raw = _distinct_with_company(AuditLog.action, company_limit) # ★ action 下拉项归一化:把 CREATE/create/新增 等别名合并为一个规范值, # 避免下拉框出现「CREATE」与「新增」两个语义重复的选项。 actions = sorted({canon_action(a) for a in actions_raw}) return jsonify({ 'code': 200, 'msg': '获取成功', 'data': { 'list': data, 'total': pagination.total, 'page': page, 'pageSize': page_size, # ★ [{value,label}],历史命名已折叠进聚合项且不再单独列出 —— # 规则由后端 module_options() 统一决定,前端零硬编。 'modules': module_options(raw_modules), 'actions': actions, # 操作人下拉选项(与 /operators 同源,顺带回一份省一次请求) 'operators': _operator_options(company_limit), } }), 200 except Exception as e: current_app.logger.error(f"获取审计日志失败: {str(e)}") return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500 @audit_bp.route('/logs/export', methods=['GET']) @jwt_required() @permission_required('system_audit') def export_audit_logs(): """ 按筛选条件导出审计日志为 Excel(同步返回文件流)。 ★ 筛选条件与 GET /logs **完全一致**(共用 _build_audit_query), 且**忽略分页参数** —— 导的是符合条件的全量,不是当前页。 「只导当前页 50 条」是导出功能最常见的误解,故这里明确不支持。 ★ 为什么同步返回而不是走 export_service 那套异步任务: 实测全库 5.8 万条导出耗时 3.3 秒、2MB,同步完全够用; 而异步那套要落盘(uploads/exports/)且**没有清理机制**, 日积月累会把磁盘吃掉 —— 见 export_service/excel_task.py。 """ try: query, _ = _build_audit_query(request.args) # 台账按时间倒序:导出的人多半想看"最近的"(与列表页一致) query = query.order_by(AuditLog.created_at.desc()) total = query.count() truncated = total > EXPORT_LIMIT rows = query.limit(EXPORT_LIMIT).all() if truncated else query.all() note = None if truncated: note = (f"匹配记录共 {total} 条,超过单次导出上限 {EXPORT_LIMIT} 条," f"本次仅导出前 {EXPORT_LIMIT} 条。请缩小日期范围后分批导出。") data = build_audit_workbook( rows, summary_rows=_filter_summary_rows(request.args), note=note, ) stamp = datetime.now().strftime('%Y%m%d_%H%M%S') # 文件名里带上「部分」—— 用户不解压也能看出这不是全量 name = (f"审计日志_部分{EXPORT_LIMIT}条_{stamp}.xlsx" if truncated else f"审计日志_{stamp}.xlsx") return send_file( io.BytesIO(data), mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', as_attachment=True, download_name=name, ) except Exception as e: current_app.logger.error(f"导出审计日志失败: {str(e)}") return jsonify({'code': 500, 'msg': f'导出失败: {str(e)}'}), 500 @audit_bp.route('/logs/', methods=['GET']) @jwt_required() @permission_required('system_audit') def get_audit_log_detail(log_id): """ 获取单条审计日志详情。 ★ 补权限码与公司隔离。此前这里**只有 @jwt_required()**,任何登录用户 改一下 URL 里的 id 就能读到全部审计明细(含 details 里的完整快照, 那是被操作记录的原始字段值)。列表接口有 system_audit 把关, 详情接口提供的信息是列表的超集,没道理比列表更宽松。 ★ 越权与不存在**统一返回 404**,不区分二者 —— 区分开来就等于告诉 探测者"这个 id 是存在的,只是你没权限",反而泄露了数据规模。 """ try: query = AuditLog.query.filter(AuditLog.id == log_id) # 与列表用同一个公司判据(SysUser.department 反查),保证两处一致: # 列表里看不到的记录,详情也不该看得到。 company_limit = get_current_company_filter() if company_limit is not None: query = query.join( SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username ).filter(SysUser.department == company_limit) log = query.first() if not log: return jsonify({'code': 404, 'msg': '日志不存在'}), 404 return jsonify({ 'code': 200, 'msg': '获取成功', # 与列表同一套序列化:action 归一 + 摘要(详情页也用得上同一条摘要) 'data': _serialize_logs([log])[0] }), 200 except Exception as e: current_app.logger.error(f"获取审计日志详情失败: {str(e)}") return jsonify({'code': 500, 'msg': str(e)}), 500 @audit_bp.route('/modules', methods=['GET']) @jwt_required() def get_modules(): """ 获取模块下拉选项(用于筛选)。 返回 [{value, label}] 而不是裸的 module 字符串 —— 历史命名已被折叠进 「入库」聚合项,规则由 audit_labels.module_options() 统一决定。 ★ 只挂 @jwt_required()、不加权限码:这里返回的是模块**名称**(不含任何 业务记录),且已做公司隔离;审计页本身由 /logs 的权限码把关, 与 /labels 的处理一致。 """ try: company_limit = get_current_company_filter() raw_modules = _distinct_with_company(AuditLog.module, company_limit) return jsonify({'code': 200, 'data': module_options(raw_modules)}), 200 except Exception as e: current_app.logger.error(f"获取模块列表失败: {str(e)}") return jsonify({'code': 500, 'msg': str(e)}), 500 @audit_bp.route('/operators', methods=['GET']) @jwt_required() @permission_required('system_audit') def get_operators(): """ 获取操作人下拉选项(用于筛选)。 返回 [{value: 账号, label: '名(账号)'}],按记录数降序,已排除 system。 ★ 加权限码,与 /modules(仅 JWT)**故意不同**:/modules 给的是模块名, 这个给的是**人员账号清单**。它的唯一消费者就是审计页,而审计页本身 要 system_audit —— 没道理让人绕开页面直接拉全员名单。 """ try: company_limit = get_current_company_filter() return jsonify({'code': 200, 'data': _operator_options(company_limit)}), 200 except Exception as e: current_app.logger.error(f"获取操作人列表失败: {str(e)}") return jsonify({'code': 500, 'msg': str(e)}), 500 @audit_bp.route('/labels', methods=['GET']) @jwt_required() def get_labels(): """ 下发操作类型与字段名的中文映射。 ★ 后端是**唯一来源**,前端不再自带副本 —— 否则两份手工同步的映射必然漂移。 前端拉到之前(或拉取失败时)对未命中字段原样显示字段名, 是可控的降级,不会让页面崩掉。 免权限码:纯静态标签,不含任何业务数据;审计页本身已由 @permission_required('system_audit') 把关(见 /logs)。 """ return jsonify({'code': 200, 'data': labels_payload()}), 200