logs.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  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. # 先统计要删除的日志数量
  224. log_count = db.query(APILog).filter(APILog.is_deleted == 0).count()
  225. from datetime import datetime
  226. db.query(APILog).filter(APILog.is_deleted == 0).update({"is_deleted": 1, "updated_at": beijing_now()})
  227. # 记录操作日志
  228. from app.utils.history import create_operation_log
  229. client_ip = request.client.host if request.client else None
  230. create_operation_log(
  231. db=db,
  232. admin_id=current_admin.id,
  233. module="日志管理",
  234. action="清空日志",
  235. description=f"清空API日志,共删除{log_count}条日志记录",
  236. old_data={"log_count": log_count},
  237. ip_address=client_ip
  238. )
  239. # 一次性提交所有更改
  240. db.commit()
  241. return {"code": 200, "msg": "清空成功"}
  242. except Exception as e:
  243. try:
  244. db.rollback()
  245. except Exception:
  246. pass
  247. logger.exception("清空日志失败")
  248. return {"code": 500, "msg": "系统异常,操作未完成,请稍后重试;若持续发生请联系客服"}
  249. @router.get("/export/csv")
  250. async def export_logs_csv(
  251. request: Request,
  252. keyword: str = Query(None),
  253. user_id: int = Query(None),
  254. endpoint: str = Query(None),
  255. method: str = Query(None),
  256. response_code: int = Query(None),
  257. start_time: str = Query(None),
  258. end_time: str = Query(None),
  259. db: Session = Depends(get_db),
  260. current_admin = Depends(get_current_admin)
  261. ):
  262. """导出日志为CSV"""
  263. query = db.query(APILog)
  264. # 应用相同的筛选条件
  265. if keyword:
  266. query = query.filter(APILog.endpoint.like(f"%{keyword}%"))
  267. if user_id is not None:
  268. query = query.filter(APILog.user_id == user_id)
  269. if endpoint:
  270. query = query.filter(APILog.endpoint.like(f"%{endpoint}%"))
  271. if method:
  272. query = query.filter(APILog.method == method)
  273. if response_code is not None:
  274. query = query.filter(APILog.response_code == response_code)
  275. if start_time:
  276. try:
  277. start_dt = datetime.fromisoformat(start_time.replace('Z', '+00:00'))
  278. query = query.filter(APILog.created_at >= start_dt)
  279. except:
  280. pass
  281. if end_time:
  282. try:
  283. end_dt = datetime.fromisoformat(end_time.replace('Z', '+00:00'))
  284. query = query.filter(APILog.created_at <= end_dt)
  285. except:
  286. pass
  287. logs = query.order_by(APILog.created_at.desc()).all()
  288. # 记录操作日志
  289. from app.utils.history import create_operation_log
  290. client_ip = request.client.host if request.client else None
  291. create_operation_log(
  292. db=db,
  293. admin_id=current_admin.id,
  294. module="日志管理",
  295. action="导出日志CSV",
  296. description=f"导出API日志为CSV,共导出{len(logs)}条日志记录",
  297. new_data={
  298. "export_count": len(logs),
  299. "export_format": "CSV",
  300. "filters": {
  301. "keyword": keyword,
  302. "user_id": user_id,
  303. "endpoint": endpoint,
  304. "method": method,
  305. "response_code": response_code,
  306. "start_time": start_time,
  307. "end_time": end_time
  308. }
  309. },
  310. ip_address=client_ip
  311. )
  312. db.commit()
  313. # 生成CSV
  314. output = io.StringIO()
  315. writer = csv.writer(output)
  316. writer.writerow(['ID', '用户ID', '接口', '方法', 'IP地址', '用户代理', '状态码', '执行时间(ms)', '创建时间'])
  317. for log in logs:
  318. # 清理字段中的换行符,保留其他字符(不要手动对引号做二次转义,交给csv模块处理)
  319. def clean_field(value):
  320. if value is None:
  321. return ''
  322. # 转换为字符串,清理换行符
  323. return str(value).replace('\n', ' ').replace('\r', ' ')
  324. writer.writerow([
  325. log.id,
  326. log.user_id or '',
  327. log.endpoint,
  328. log.method,
  329. log.ip_address or '',
  330. clean_field(log.user_agent),
  331. log.response_code or '',
  332. log.execution_time or '',
  333. log.created_at.isoformat() if log.created_at else ''
  334. ])
  335. output.seek(0)
  336. # 为了让 Excel 正确识别 UTF-8 编码并按列分割,添加 UTF-8 BOM 并以 bytes 返回
  337. csv_bytes = output.getvalue().encode('utf-8-sig')
  338. return StreamingResponse(
  339. iter([csv_bytes]),
  340. media_type="text/csv; charset=utf-8",
  341. headers={"Content-Disposition": f"attachment; filename=logs_{beijing_now().strftime('%Y%m%d_%H%M%S')}.csv"}
  342. )
  343. @router.get("/export/excel")
  344. async def export_logs(
  345. request: Request,
  346. keyword: str = Query(None),
  347. user_id: int = Query(None),
  348. endpoint: str = Query(None),
  349. method: str = Query(None),
  350. response_code: int = Query(None),
  351. start_time: str = Query(None),
  352. end_time: str = Query(None),
  353. db: Session = Depends(get_db),
  354. current_admin = Depends(get_current_admin)
  355. ):
  356. """导出日志为Excel"""
  357. # 记录操作日志(在调用CSV导出前记录,因为CSV导出函数中也会记录)
  358. from app.utils.history import create_operation_log
  359. client_ip = request.client.host if request.client else None
  360. # 统计导出数量
  361. query = db.query(APILog)
  362. if keyword:
  363. query = query.filter(APILog.endpoint.like(f"%{keyword}%"))
  364. if user_id is not None:
  365. query = query.filter(APILog.user_id == user_id)
  366. if endpoint:
  367. query = query.filter(APILog.endpoint.like(f"%{endpoint}%"))
  368. if method:
  369. query = query.filter(APILog.method == method)
  370. if response_code is not None:
  371. query = query.filter(APILog.response_code == response_code)
  372. if start_time:
  373. try:
  374. start_dt = datetime.fromisoformat(start_time.replace('Z', '+00:00'))
  375. query = query.filter(APILog.created_at >= start_dt)
  376. except:
  377. pass
  378. if end_time:
  379. try:
  380. end_dt = datetime.fromisoformat(end_time.replace('Z', '+00:00'))
  381. query = query.filter(APILog.created_at <= end_dt)
  382. except:
  383. pass
  384. export_count = query.count()
  385. create_operation_log(
  386. db=db,
  387. admin_id=current_admin.id,
  388. module="日志管理",
  389. action="导出日志Excel",
  390. description=f"导出API日志为Excel,共导出{export_count}条日志记录",
  391. new_data={
  392. "export_count": export_count,
  393. "export_format": "Excel",
  394. "filters": {
  395. "keyword": keyword,
  396. "user_id": user_id,
  397. "endpoint": endpoint,
  398. "method": method,
  399. "response_code": response_code,
  400. "start_time": start_time,
  401. "end_time": end_time
  402. }
  403. },
  404. ip_address=client_ip
  405. )
  406. db.commit()
  407. # 暂时使用CSV格式,如果需要Excel格式可以安装openpyxl
  408. return await export_logs_csv(
  409. request=request,
  410. keyword=keyword,
  411. user_id=user_id,
  412. endpoint=endpoint,
  413. method=method,
  414. response_code=response_code,
  415. start_time=start_time,
  416. end_time=end_time,
  417. db=db,
  418. current_admin=current_admin
  419. )