# -*- coding: utf-8 -*- """ hr-system 审计底座(T09 / F12)核心实现。 需求基线:SRS §2.2-5 / F12(全 4 项验收)。 - 全量记录增删改/导入导出/审批动作/登录,记录操作前后值 JSON、操作人、IP、时间 - 按操作时间/操作类型/操作人筛选查询 - 详情前后值对比展示 - 敏感字段脱敏:身份证前 3 后 3 / 手机前 3 后 4 横切公共入口(其他模块直接调用,签名对齐 api-design.md): write_audit_log(module, target_type, target_id, operation, before=None, after=None, **kw) """ import json import uuid from datetime import datetime # AUDIT_OP 操作类型字典(与 models/hr_system/sys_audit_log.json 的 codes 对齐) AUDIT_OP = ( "create", # 新增 "update", # 修改 "delete", # 删除 "import", # 导入 "export", # 导出 "approve", # 审批通过 "reject", # 审批驳回 "login", # 登录 "logout", # 登出 ) # 敏感字段命名提示(自动识别需要脱敏的字段,可按需扩展) _SENSITIVE_HINTS = ( "id_number", "id_card", "idcard", "id_no", "identity", "credential", "mobile", "phone", "tel", "telephone", "contact", ) # --------------------------------------------------------------------------- # 脱敏规则 # --------------------------------------------------------------------------- def mask_id_number(value): """身份证/证件号脱敏:保留前 3 后 3,中间打星。""" s = str(value or "") if len(s) >= 7: return s[:3] + "*" * (len(s) - 6) + s[-3:] if len(s) > 3: return s[:3] + "*" * (len(s) - 3) return "*" * len(s) if s else s def mask_mobile(value): """手机号脱敏:保留前 3 后 4,中间打星。""" s = str(value or "") if len(s) >= 8: return s[:3] + "*" * (len(s) - 7) + s[-4:] if len(s) > 3: return s[:3] + "*" * (len(s) - 3) return "*" * len(s) if s else s def _is_sensitive_key(key): k = str(key).lower().replace("-", "_").replace(" ", "_") return any(hint in k for hint in _SENSITIVE_HINTS) def mask_value(key, value): """按字段语义脱敏单个值(非敏感字段原样返回)。""" if value is None: return None if isinstance(value, (list, tuple)): return [mask_value(key, v) for v in value] if not _is_sensitive_key(key): return value s = str(value) k = str(key).lower() if any(h in k for h in ("mobile", "phone", "tel")): return mask_mobile(s) # 证件类及其他敏感字段默认前 3 后 3 return mask_id_number(s) def mask_json(obj): """递归脱敏 dict/list 结构,返回新对象(不修改入参)。""" if isinstance(obj, dict): return { k: (mask_json(v) if isinstance(v, (dict, list, tuple)) else mask_value(k, v)) for k, v in obj.items() } if isinstance(obj, (list, tuple)): return [mask_json(v) for v in obj] return obj def _dump(obj): """把 before/after 快照序列化为 JSON 字符串,并应用脱敏。""" if obj is None: return None if isinstance(obj, str): s = obj.strip() if not s: return None try: parsed = json.loads(s) except (ValueError, TypeError): # 纯文本快照原样保存(无结构化字段可脱敏) return s return json.dumps(mask_json(parsed), ensure_ascii=False, separators=(",", ":")) if isinstance(obj, (dict, list, tuple)): return json.dumps(mask_json(obj), ensure_ascii=False, separators=(",", ":")) return json.dumps(obj, ensure_ascii=False) # --------------------------------------------------------------------------- # 执行上下文辅助(兼容 ahserver ServerEnv / 直接传 db) # --------------------------------------------------------------------------- def _db_from(env): if env is None: return None return getattr(env, "db", None) or getattr(env, "dbh", None) def _current_user_id(env): if env is None: return "" u = getattr(env, "user", None) if u is None: return "" if isinstance(u, dict): return u.get("id") or u.get("user_id") or "" return getattr(u, "id", None) or getattr(u, "user_id", None) or "" def _current_user_name(env): if env is None: return "" u = getattr(env, "user", None) if u is None: return "" if isinstance(u, dict): return u.get("name") or u.get("user_name") or "" return getattr(u, "name", None) or getattr(u, "user_name", None) or "" def _current_ip(env): if env is None: return "" return ( getattr(env, "request_ip", None) or getattr(env, "client_ip", None) or getattr(env, "remote_addr", None) or "" ) def _execute_insert(db, table, row): """构造并执行 INSERT(MariaDB %s 占位符)。""" cols = ", ".join("`%s`" % k for k in row) placeholders = ", ".join(["%s"] * len(row)) sql = "INSERT INTO %s (%s) VALUES (%s)" % (table, cols, placeholders) if hasattr(db, "execute"): db.execute(sql, list(row.values())) elif hasattr(db, "run"): db.run(sql, list(row.values())) else: # pragma: no cover - 兜底 raise AttributeError("db 对象需提供 execute/run 方法") return row.get("id") # --------------------------------------------------------------------------- # 公共入口:write_audit_log # --------------------------------------------------------------------------- def write_audit_log(module, target_type, target_id, operation, before=None, after=None, **kw): """全量写操作审计(横切公共入口)。 位置参数(前 6 个对齐 api-design.md 签名): module 模块(hr-org / hr-roster / hr-flow / hr-contract / hr-report / hr-system) target_type 对象类型(roster_employee / org_unit / ...) target_id 对象 ID operation 操作类型(AUDIT_OP 字典) before 变更前快照(dict / list / JSON 字符串,可选) after 变更后快照(dict / list / JSON 字符串,可选) 关键字参数(缺省自动从执行上下文取): operator_id 操作人 ID operator_name 操作人姓名 request_ip 请求 IP db 数据库连接(sqlor/ahserver 执行上下文对象) env ahserver ServerEnv 执行上下文 返回:写入的日志行 dict(含生成的 id、脱敏后的 before_json/after_json)。 """ env = kw.get("env") db = kw.get("db") or _db_from(env) operator_id = kw.get("operator_id") or _current_user_id(env) operator_name = kw.get("operator_name") or _current_user_name(env) request_ip = kw.get("request_ip") or _current_ip(env) row = { "id": uuid.uuid4().hex, "module": module or "", "target_type": target_type or "", "target_id": str(target_id) if target_id is not None else "", "operation": operation, "operator_id": operator_id or "", "operator_name": operator_name or "", "before_json": _dump(before), "after_json": _dump(after), "request_ip": request_ip or "", "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } if db is not None: _execute_insert(db, "sys_audit_log", row) else: row["_pending"] = True # 离线/单测场景标记未落库 return row # --------------------------------------------------------------------------- # 查询端点 # --------------------------------------------------------------------------- def _build_where(params): conds, vals = [], [] mapping = { "module": "module = %s", "operation": "operation = %s", "operator_id": "operator_id = %s", "target_type": "target_type = %s", "target_id": "target_id = %s", } for key, tmpl in mapping.items(): v = params.get(key) if v not in (None, ""): conds.append(tmpl) vals.append(v) if params.get("date_from") not in (None, ""): conds.append("created_at >= %s") vals.append(params["date_from"]) if params.get("date_to") not in (None, ""): conds.append("created_at <= %s") vals.append(params["date_to"]) where = (" WHERE " + " AND ".join(conds)) if conds else "" return where, vals def _scalar(db, sql, vals): if hasattr(db, "scalar"): return db.scalar(sql, vals) if hasattr(db, "query_one"): r = db.query_one(sql, vals) return list(r.values())[0] if r else 0 return 0 def audit_log_list(params=None, env=None): """操作日志查询(按时间/类型/操作人/模块筛选,分页)。 返回:{"rows": [...], "total": n} """ params = params or {} kw_db = params.get("db") if isinstance(params, dict) else None db = kw_db or _db_from(env) where, vals = _build_where(params) page = int(params.get("page") or 1) size = int(params.get("page_size") or params.get("size") or 20) size = min(max(size, 1), 200) offset = (page - 1) * size if db is None: return {"rows": [], "total": 0} total = _scalar(db, "SELECT COUNT(*) FROM sys_audit_log" + where, vals) or 0 sql = ( "SELECT id, module, target_type, target_id, operation, operator_id, " "operator_name, request_ip, created_at " "FROM sys_audit_log" + where + " ORDER BY created_at DESC LIMIT %s OFFSET %s" ) rows = db.query(sql, vals + [size, offset]) if hasattr(db, "query") else [] return {"rows": rows, "total": total} def audit_log_detail(params=None, env=None): """操作日志详情(含前后值 JSON,已脱敏,供前后值对比展示)。""" params = params or {} db = params.get("db") if isinstance(params, dict) else None db = db or _db_from(env) log_id = params.get("id") if not log_id: return {"error": "MISSING_ID"} if db is None: return {"error": "NO_DB"} row = db.query_one("SELECT * FROM sys_audit_log WHERE id = %s", [log_id]) if not row: return {"error": "NOT_FOUND"} return row # 端点注册表(供 __init__.register 使用) HANDLERS = { "audit_log_list": audit_log_list, "audit_log_detail": audit_log_detail, }