logs.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. """
  2. 日志管理接口
  3. """
  4. from fastapi import APIRouter, Depends, Query, Request
  5. from fastapi.responses import StreamingResponse
  6. from sqlalchemy.orm import Session
  7. from sqlalchemy import or_, and_
  8. from app.database import get_db
  9. from app.api.deps import get_current_admin
  10. from app.models.api_log import APILog
  11. from app.models.user import User
  12. from app.models.admin import Admin
  13. from datetime import datetime, timedelta
  14. from app.utils.timezone import beijing_now
  15. import csv
  16. import io
  17. router = APIRouter()
  18. @router.get("")
  19. async def get_logs(
  20. page: int = Query(1, ge=1),
  21. page_size: int = Query(10, ge=1, le=100),
  22. keyword: str = Query(None, description="搜索关键词(接口路径)"),
  23. # 支持后端的 user_id,也兼容前端传 username 字段
  24. user_id: int = Query(None, description="用户ID筛选"),
  25. username: str = Query(None, description="用户名筛选(兼容前端)"),
  26. # 支持后端的 endpoint,也兼容前端传 action 字段
  27. endpoint: str = Query(None, description="接口筛选"),
  28. action: str = Query(None, description="接口筛选(兼容前端)"),
  29. method: str = Query(None, description="方法筛选"),
  30. # 支持后端的 response_code,也兼容前端传 level(ERROR/WARN/INFO)
  31. response_code: int = Query(None, description="状态码筛选"),
  32. level: str = Query(None, description="级别筛选(ERROR/WARN/INFO)"),
  33. # 支持 start_time/end_time 和 前端的 start_date/end_date(YYYY-MM-DD)
  34. start_time: str = Query(None, description="开始时间"),
  35. end_time: str = Query(None, description="结束时间"),
  36. start_date: str = Query(None, description="开始日期(YYYY-MM-DD,兼容前端)"),
  37. end_date: str = Query(None, description="结束日期(YYYY-MM-DD,兼容前端)"),
  38. db: Session = Depends(get_db),
  39. current_admin = Depends(get_current_admin)
  40. ):
  41. """获取日志列表"""
  42. offset = (page - 1) * page_size
  43. query = db.query(APILog).filter(APILog.is_deleted == 0)
  44. # 搜索
  45. if keyword:
  46. query = query.filter(APILog.endpoint.like(f"%{keyword}%"))
  47. # 兼容前端:如果前端传了 username,则解析出 user_id(先查 users,再查 admins)
  48. if username:
  49. user = db.query(User).filter(User.username == username).first()
  50. if user:
  51. user_id = user.id
  52. else:
  53. admin_user = db.query(Admin).filter(Admin.username == username).first()
  54. if admin_user:
  55. # 这里用 admin 的 id 去匹配历史日志中记录的 user_id(历史数据中可能写入了 admin.id)
  56. user_id = admin_user.id
  57. else:
  58. # 用户不存在,返回空结果
  59. return {"code": 200, "msg": "成功", "data": [], "total": 0, "page": page, "page_size": page_size}
  60. # 筛选(优先使用前端 action 参数)
  61. if user_id is not None:
  62. query = query.filter(APILog.user_id == user_id)
  63. if action:
  64. query = query.filter(APILog.endpoint.like(f"%{action}%"))
  65. elif endpoint:
  66. query = query.filter(APILog.endpoint.like(f"%{endpoint}%"))
  67. if method:
  68. query = query.filter(APILog.method == method)
  69. # 兼容前端的 level 过滤:ERROR/WARN/INFO -> response_code 区间
  70. if level:
  71. lvl = level.upper()
  72. if lvl == "ERROR":
  73. query = query.filter(APILog.response_code >= 500)
  74. elif lvl == "WARN":
  75. query = query.filter(APILog.response_code >= 400, APILog.response_code < 500)
  76. elif lvl == "INFO":
  77. query = query.filter((APILog.response_code < 400) | (APILog.response_code == None))
  78. elif response_code is not None:
  79. query = query.filter(APILog.response_code == response_code)
  80. # 支持前端的 start_date/end_date(YYYY-MM-DD)
  81. if start_date and not start_time:
  82. start_time = start_date
  83. if end_date and not end_time:
  84. end_time = end_date
  85. if start_time:
  86. try:
  87. start_dt = datetime.fromisoformat(start_time.replace('Z', '+00:00'))
  88. query = query.filter(APILog.created_at >= start_dt)
  89. except:
  90. pass
  91. if end_time:
  92. try:
  93. end_dt = datetime.fromisoformat(end_time.replace('Z', '+00:00'))
  94. query = query.filter(APILog.created_at <= end_dt)
  95. except:
  96. pass
  97. # 排序和分页
  98. total = query.count()
  99. logs = query.order_by(APILog.created_at.desc()).offset(offset).limit(page_size).all()
  100. # resolve usernames for logs with user_id and admin_id to match frontend expectations
  101. user_ids = list({l.user_id for l in logs if l.user_id})
  102. admin_ids = list({l.admin_id for l in logs if getattr(l, "admin_id", None)})
  103. users_map = {}
  104. admins_map = {}
  105. if user_ids:
  106. users = db.query(User).filter(User.id.in_(user_ids)).all()
  107. users_map = {u.id: u.username for u in users}
  108. # admins map for admin_id (explicit) and fallback for historical user_id values that were admin ids
  109. if admin_ids:
  110. admins = db.query(Admin).filter(Admin.id.in_(admin_ids)).all()
  111. admins_map = {a.id: a.username for a in admins}
  112. # also attempt to resolve any user_ids that point to admins (historical)
  113. missing_user_ids = [uid for uid in (user_ids or []) if uid not in users_map]
  114. if missing_user_ids:
  115. admins = db.query(Admin).filter(Admin.id.in_(missing_user_ids)).all()
  116. for a in admins:
  117. users_map[a.id] = a.username
  118. def map_level(response_code: int) -> str:
  119. try:
  120. if response_code is None:
  121. return "INFO"
  122. if response_code >= 500:
  123. return "ERROR"
  124. if response_code >= 400:
  125. return "WARN"
  126. return "INFO"
  127. except:
  128. return "INFO"
  129. return {
  130. "code": 200,
  131. "msg": "成功",
  132. "data": [
  133. {
  134. "id": l.id,
  135. "user_id": l.user_id,
  136. "username": users_map.get(l.user_id) if l.user_id else None,
  137. "admin_id": getattr(l, "admin_id", None),
  138. "admin_username": admins_map.get(getattr(l, "admin_id", None)) if getattr(l, "admin_id", None) else None,
  139. # frontend expects 'action' field for interface path
  140. "action": l.endpoint,
  141. "method": l.method,
  142. "ip_address": l.ip_address,
  143. "user_agent": l.user_agent,
  144. # map response/request data to 'message' for frontend display
  145. "message": l.response_data or l.request_data,
  146. "level": map_level(l.response_code),
  147. "response_code": l.response_code,
  148. "execution_time": l.execution_time,
  149. "created_at": l.created_at.isoformat() if l.created_at else None
  150. }
  151. for l in logs
  152. ],
  153. "total": total,
  154. "page": page,
  155. "page_size": page_size
  156. }
  157. @router.get("/{log_id}")
  158. async def get_log_detail(
  159. log_id: int,
  160. db: Session = Depends(get_db),
  161. current_admin = Depends(get_current_admin)
  162. ):
  163. """获取日志详情"""
  164. log = db.query(APILog).filter(APILog.id == log_id).first()
  165. if not log:
  166. return {"code": 404, "msg": "日志不存在"}
  167. # resolve username if available (try users then admins as fallback)
  168. username = None
  169. admin_username = None
  170. if log.user_id:
  171. user = db.query(User).filter(User.id == log.user_id).first()
  172. if user:
  173. username = user.username
  174. else:
  175. admin_user = db.query(Admin).filter(Admin.id == log.user_id).first()
  176. if admin_user:
  177. username = admin_user.username
  178. if getattr(log, "admin_id", None):
  179. adm = db.query(Admin).filter(Admin.id == log.admin_id).first()
  180. if adm:
  181. admin_username = adm.username
  182. def map_level(response_code: int) -> str:
  183. try:
  184. if response_code is None:
  185. return "INFO"
  186. if response_code >= 500:
  187. return "ERROR"
  188. if response_code >= 400:
  189. return "WARN"
  190. return "INFO"
  191. except:
  192. return "INFO"
  193. return {
  194. "code": 200,
  195. "msg": "成功",
  196. "data": {
  197. "id": log.id,
  198. "user_id": log.user_id,
  199. "username": username,
  200. "admin_id": getattr(log, "admin_id", None),
  201. "admin_username": admin_username,
  202. "action": log.endpoint,
  203. "method": log.method,
  204. "message": log.response_data or log.request_data,
  205. "request_data": log.request_data,
  206. "response_data": log.response_data,
  207. "ip_address": log.ip_address,
  208. "user_agent": log.user_agent,
  209. "level": map_level(log.response_code),
  210. "response_code": log.response_code,
  211. "execution_time": log.execution_time,
  212. "created_at": log.created_at.isoformat() if log.created_at else None
  213. }
  214. }
  215. @router.delete("")
  216. async def clear_logs(
  217. request: Request,
  218. db: Session = Depends(get_db),
  219. current_admin = Depends(get_current_admin)
  220. ):
  221. """清空日志(软删除)"""
  222. try:
  223. from app.utils.history import create_api_log_history, create_operation_log
  224. # 先写历史表,再改/删:查出即将被软删除的日志,逐条写入 api_logs_his
  225. logs_to_clear = db.query(APILog).filter(APILog.is_deleted == 0).all()
  226. log_count = len(logs_to_clear)
  227. for log in logs_to_clear:
  228. create_api_log_history(db, log, "DELETE")
  229. db.query(APILog).filter(APILog.is_deleted == 0).update({"is_deleted": 1, "updated_at": beijing_now()})
  230. # 记录操作日志
  231. client_ip = request.client.host if request.client else None
  232. create_operation_log(
  233. db=db,
  234. admin_id=current_admin.id,
  235. module="日志管理",
  236. action="清空日志",
  237. description=f"清空API日志,共删除{log_count}条日志记录",
  238. old_data={"log_count": log_count},
  239. ip_address=client_ip
  240. )
  241. # 一次性提交所有更改
  242. db.commit()
  243. return {"code": 200, "msg": "清空成功"}
  244. except Exception as e:
  245. try:
  246. db.rollback()
  247. except Exception:
  248. pass
  249. logger.exception("清空日志失败")
  250. return {"code": 500, "msg": "系统异常,操作未完成,请稍后重试;若持续发生请联系客服"}
  251. @router.get("/export/csv")
  252. async def export_logs_csv(
  253. request: Request,
  254. keyword: str = Query(None),
  255. user_id: int = Query(None),
  256. endpoint: str = Query(None),
  257. method: str = Query(None),
  258. response_code: int = Query(None),
  259. start_time: str = Query(None),
  260. end_time: str = Query(None),
  261. db: Session = Depends(get_db),
  262. current_admin = Depends(get_current_admin)
  263. ):
  264. """导出日志为CSV"""
  265. query = db.query(APILog)
  266. # 应用相同的筛选条件
  267. if keyword:
  268. query = query.filter(APILog.endpoint.like(f"%{keyword}%"))
  269. if user_id is not None:
  270. query = query.filter(APILog.user_id == user_id)
  271. if endpoint:
  272. query = query.filter(APILog.endpoint.like(f"%{endpoint}%"))
  273. if method:
  274. query = query.filter(APILog.method == method)
  275. if response_code is not None:
  276. query = query.filter(APILog.response_code == response_code)
  277. if start_time:
  278. try:
  279. start_dt = datetime.fromisoformat(start_time.replace('Z', '+00:00'))
  280. query = query.filter(APILog.created_at >= start_dt)
  281. except:
  282. pass
  283. if end_time:
  284. try:
  285. end_dt = datetime.fromisoformat(end_time.replace('Z', '+00:00'))
  286. query = query.filter(APILog.created_at <= end_dt)
  287. except:
  288. pass
  289. logs = query.order_by(APILog.created_at.desc()).all()
  290. # 记录操作日志
  291. from app.utils.history import create_operation_log
  292. client_ip = request.client.host if request.client else None
  293. create_operation_log(
  294. db=db,
  295. admin_id=current_admin.id,
  296. module="日志管理",
  297. action="导出日志CSV",
  298. description=f"导出API日志为CSV,共导出{len(logs)}条日志记录",
  299. new_data={
  300. "export_count": len(logs),
  301. "export_format": "CSV",
  302. "filters": {
  303. "keyword": keyword,
  304. "user_id": user_id,
  305. "endpoint": endpoint,
  306. "method": method,
  307. "response_code": response_code,
  308. "start_time": start_time,
  309. "end_time": end_time
  310. }
  311. },
  312. ip_address=client_ip
  313. )
  314. db.commit()
  315. # 生成CSV
  316. output = io.StringIO()
  317. writer = csv.writer(output)
  318. writer.writerow(['ID', '用户ID', '接口', '方法', 'IP地址', '用户代理', '状态码', '执行时间(ms)', '创建时间'])
  319. for log in logs:
  320. # 清理字段中的换行符,保留其他字符(不要手动对引号做二次转义,交给csv模块处理)
  321. def clean_field(value):
  322. if value is None:
  323. return ''
  324. # 转换为字符串,清理换行符
  325. return str(value).replace('\n', ' ').replace('\r', ' ')
  326. writer.writerow([
  327. log.id,
  328. log.user_id or '',
  329. log.endpoint,
  330. log.method,
  331. log.ip_address or '',
  332. clean_field(log.user_agent),
  333. log.response_code or '',
  334. log.execution_time or '',
  335. log.created_at.isoformat() if log.created_at else ''
  336. ])
  337. output.seek(0)
  338. # 为了让 Excel 正确识别 UTF-8 编码并按列分割,添加 UTF-8 BOM 并以 bytes 返回
  339. csv_bytes = output.getvalue().encode('utf-8-sig')
  340. return StreamingResponse(
  341. iter([csv_bytes]),
  342. media_type="text/csv; charset=utf-8",
  343. headers={"Content-Disposition": f"attachment; filename=logs_{beijing_now().strftime('%Y%m%d_%H%M%S')}.csv"}
  344. )
  345. @router.get("/export/excel")
  346. async def export_logs(
  347. request: Request,
  348. keyword: str = Query(None),
  349. user_id: int = Query(None),
  350. endpoint: str = Query(None),
  351. method: str = Query(None),
  352. response_code: int = Query(None),
  353. start_time: str = Query(None),
  354. end_time: str = Query(None),
  355. db: Session = Depends(get_db),
  356. current_admin = Depends(get_current_admin)
  357. ):
  358. """导出日志为Excel"""
  359. # 记录操作日志(在调用CSV导出前记录,因为CSV导出函数中也会记录)
  360. from app.utils.history import create_operation_log
  361. client_ip = request.client.host if request.client else None
  362. # 统计导出数量
  363. query = db.query(APILog)
  364. if keyword:
  365. query = query.filter(APILog.endpoint.like(f"%{keyword}%"))
  366. if user_id is not None:
  367. query = query.filter(APILog.user_id == user_id)
  368. if endpoint:
  369. query = query.filter(APILog.endpoint.like(f"%{endpoint}%"))
  370. if method:
  371. query = query.filter(APILog.method == method)
  372. if response_code is not None:
  373. query = query.filter(APILog.response_code == response_code)
  374. if start_time:
  375. try:
  376. start_dt = datetime.fromisoformat(start_time.replace('Z', '+00:00'))
  377. query = query.filter(APILog.created_at >= start_dt)
  378. except:
  379. pass
  380. if end_time:
  381. try:
  382. end_dt = datetime.fromisoformat(end_time.replace('Z', '+00:00'))
  383. query = query.filter(APILog.created_at <= end_dt)
  384. except:
  385. pass
  386. export_count = query.count()
  387. create_operation_log(
  388. db=db,
  389. admin_id=current_admin.id,
  390. module="日志管理",
  391. action="导出日志Excel",
  392. description=f"导出API日志为Excel,共导出{export_count}条日志记录",
  393. new_data={
  394. "export_count": export_count,
  395. "export_format": "Excel",
  396. "filters": {
  397. "keyword": keyword,
  398. "user_id": user_id,
  399. "endpoint": endpoint,
  400. "method": method,
  401. "response_code": response_code,
  402. "start_time": start_time,
  403. "end_time": end_time
  404. }
  405. },
  406. ip_address=client_ip
  407. )
  408. db.commit()
  409. # 暂时使用CSV格式,如果需要Excel格式可以安装openpyxl
  410. return await export_logs_csv(
  411. request=request,
  412. keyword=keyword,
  413. user_id=user_id,
  414. endpoint=endpoint,
  415. method=method,
  416. response_code=response_code,
  417. start_time=start_time,
  418. end_time=end_time,
  419. db=db,
  420. current_admin=current_admin
  421. )