311 lines
10 KiB
Python
311 lines
10 KiB
Python
# -*- 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,
|
||
}
|