""" 知识库操作审计日志服务 action 枚举值 ------------- upload 上传文件到知识库 download 下载/查看文件(含 URL 预览) delete 删除文件 archive 归档文件(预留) create_kb 创建知识库 delete_kb 删除知识库 permission_change 修改下级用户权限(如关闭/开启上传权限) """ from typing import Any, Dict, List, Optional, Tuple import asyncpg from logger.logging import get_logger logger = get_logger(__name__) VALID_ACTIONS = frozenset({ "upload", "download", "delete", "archive", "create_kb", "delete_kb", "permission_change", }) class AuditService: @staticmethod async def write( conn: asyncpg.Connection, *, enterprise_id: int, actor_id: int, action: str, target_user_id: Optional[int] = None, department_id: Optional[int] = None, kb_id: Optional[int] = None, file_id: Optional[int] = None, ip: Optional[str] = None, user_agent: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> None: """写入一条审计日志(非阻塞,异常仅记录不抛出)。""" try: import json meta_json = json.dumps(metadata, ensure_ascii=False) if metadata else None await conn.execute( """ INSERT INTO kb_audit_log (enterprise_id, actor_id, action, target_user_id, department_id, kb_id, file_id, ip, user_agent, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb) """, enterprise_id, actor_id, action, target_user_id, department_id, kb_id, file_id, ip, user_agent, meta_json, ) except Exception as e: logger.warning(f"写入审计日志失败(不影响主流程): {e}") @staticmethod async def list_logs( conn: asyncpg.Connection, enterprise_id: int, *, department_ids: Optional[List[int]] = None, # None 表示不过滤(admin 全量) actor_id: Optional[int] = None, kb_id: Optional[int] = None, action: Optional[str] = None, page: int = 1, page_size: int = 20, ) -> Tuple[List[dict], int]: """ 查询审计日志。 - department_ids=None -> 全企业(admin 用) - department_ids=[...] -> 限制在给定部门内(leader 用,按 actor 所在部门过滤) """ offset = (page - 1) * page_size conds = ["l.enterprise_id = $1"] params: list = [enterprise_id] if department_ids is not None: if not department_ids: return [], 0 conds.append(f"l.department_id = ANY(${len(params)+1}::int[])") params.append(department_ids) if actor_id is not None: conds.append(f"l.actor_id = ${len(params)+1}") params.append(actor_id) if kb_id is not None: conds.append(f"l.kb_id = ${len(params)+1}") params.append(kb_id) if action is not None: conds.append(f"l.action = ${len(params)+1}") params.append(action) where = " AND ".join(conds) count_sql = f"SELECT COUNT(*) FROM kb_audit_log l WHERE {where}" total = await conn.fetchval(count_sql, *params) lim_ph = len(params) + 1 off_ph = len(params) + 2 params.extend([page_size, offset]) rows = await conn.fetch( f""" SELECT l.id, l.enterprise_id, l.actor_id, l.action, l.target_user_id, l.department_id, l.kb_id, l.file_id, l.ip, l.metadata, l.created_at, COALESCE(NULLIF(TRIM(u.display_name),''), u.username) AS actor_name, COALESCE(NULLIF(TRIM(tu.display_name),''), tu.username) AS target_name, d.name AS department_name, kb.name AS kb_name, kf.file_name FROM kb_audit_log l LEFT JOIN user_list u ON u.id = l.actor_id LEFT JOIN user_list tu ON tu.id = l.target_user_id LEFT JOIN department d ON d.id = l.department_id LEFT JOIN knowledge_base kb ON kb.id = l.kb_id LEFT JOIN knowledge_base_file kf ON kf.id = l.file_id WHERE {where} ORDER BY l.created_at DESC LIMIT ${lim_ph} OFFSET ${off_ph} """, *params, ) import json as _json result = [] for r in rows: row = dict(r) meta = row.get("metadata") if isinstance(meta, str): try: row["metadata"] = _json.loads(meta) except Exception: row["metadata"] = None result.append(row) return result, int(total or 0)