311 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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):
"""构造并执行 INSERTMariaDB %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,
}