audit_query_service.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. """B7审计日志和统计查询;本模块不写入审计表。"""
  2. from __future__ import annotations
  3. from datetime import UTC, datetime, time, timedelta
  4. from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
  5. from flask import current_app
  6. from sqlalchemy import case, func, or_, select
  7. from werkzeug.datastructures import MultiDict
  8. from dms.common.enums import AuditAction, AuditResult, AuditTarget
  9. from dms.common.errors import InternalError, InvalidArgumentError
  10. from dms.common.pagination import PageRequest, page_result
  11. from dms.common.response import serialize_id
  12. from dms.extensions import db
  13. from dms.models import AuditLog, Document
  14. AUDIT_LIST_PARAMETERS = {
  15. "keyword",
  16. "createdFrom",
  17. "createdTo",
  18. "userId",
  19. "organizationId",
  20. "actionType",
  21. "operationResult",
  22. "targetType",
  23. "targetId",
  24. "page",
  25. "pageSize",
  26. "sortDirection",
  27. }
  28. TIME_PARAMETERS = {"createdFrom", "createdTo"}
  29. def _validate_parameters(params: MultiDict, allowed: set[str]) -> None:
  30. unknown = set(params) - allowed
  31. if unknown:
  32. raise InvalidArgumentError(f"不支持的查询参数:{sorted(unknown)[0]}")
  33. duplicate = next(
  34. (key for key in params if len(params.getlist(key)) != 1),
  35. None,
  36. )
  37. if duplicate is not None:
  38. raise InvalidArgumentError(f"查询参数{duplicate}不能重复")
  39. def _positive_integer(
  40. params: MultiDict,
  41. name: str,
  42. *,
  43. default: int | None = None,
  44. maximum: int | None = None,
  45. ) -> int | None:
  46. raw = params.get(name)
  47. if raw is None:
  48. return default
  49. if not raw.isdecimal() or int(raw) < 1:
  50. raise InvalidArgumentError(f"{name}必须是正整数字符串")
  51. value = int(raw)
  52. if maximum is not None and value > maximum:
  53. raise InvalidArgumentError(f"{name}不得大于{maximum}")
  54. return value
  55. def _enum_value(params: MultiDict, name: str, enum_type) -> str | None:
  56. raw = params.get(name)
  57. if raw is None:
  58. return None
  59. try:
  60. return enum_type(raw).value
  61. except ValueError as exc:
  62. raise InvalidArgumentError(f"{name}不是有效枚举值") from exc
  63. def _utc_parameter(params: MultiDict, name: str) -> datetime | None:
  64. raw = params.get(name)
  65. if raw is None:
  66. return None
  67. if not raw or not raw.endswith("Z"):
  68. raise InvalidArgumentError(f"{name}必须是带Z的ISO 8601 UTC时间")
  69. try:
  70. parsed = datetime.fromisoformat(f"{raw[:-1]}+00:00")
  71. except ValueError as exc:
  72. raise InvalidArgumentError(f"{name}必须是带Z的ISO 8601 UTC时间") from exc
  73. if parsed.tzinfo is None or parsed.utcoffset() != timedelta(0):
  74. raise InvalidArgumentError(f"{name}必须是带Z的ISO 8601 UTC时间")
  75. return parsed.astimezone(UTC).replace(tzinfo=None)
  76. def _time_filters(params: MultiDict) -> list:
  77. created_from = _utc_parameter(params, "createdFrom")
  78. created_to = _utc_parameter(params, "createdTo")
  79. if (
  80. created_from is not None
  81. and created_to is not None
  82. and created_from >= created_to
  83. ):
  84. raise InvalidArgumentError("createdFrom必须早于createdTo")
  85. filters = []
  86. if created_from is not None:
  87. filters.append(AuditLog.created_at >= created_from)
  88. if created_to is not None:
  89. filters.append(AuditLog.created_at < created_to)
  90. return filters
  91. def _format_utc(value: datetime) -> str:
  92. return (
  93. value.replace(tzinfo=UTC)
  94. .isoformat(timespec="milliseconds")
  95. .replace("+00:00", "Z")
  96. )
  97. def _escape_like(value: str) -> str:
  98. return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
  99. def _audit_summary(item: AuditLog) -> dict:
  100. return {
  101. "id": serialize_id(item.id),
  102. "username": item.username,
  103. "realName": item.real_name,
  104. "organizationName": item.organization_name,
  105. "actionType": item.action_type,
  106. "targetType": item.target_type,
  107. "targetId": serialize_id(item.target_id),
  108. "targetName": item.target_name,
  109. "operationResult": item.operation_result,
  110. "failureReason": item.failure_reason,
  111. "clientIp": item.client_ip,
  112. "requestId": item.request_id,
  113. "createdAt": _format_utc(item.created_at),
  114. }
  115. def audit_log_page(params: MultiDict) -> dict:
  116. _validate_parameters(params, AUDIT_LIST_PARAMETERS)
  117. page = _positive_integer(params, "page", default=1)
  118. page_size = _positive_integer(
  119. params, "pageSize", default=20, maximum=100
  120. )
  121. assert page is not None and page_size is not None
  122. page_request = PageRequest(page=page, page_size=page_size)
  123. sort_direction = params.get("sortDirection", "desc")
  124. if sort_direction not in {"asc", "desc"}:
  125. raise InvalidArgumentError("sortDirection只允许asc或desc")
  126. filters = _time_filters(params)
  127. for parameter, column in (
  128. ("userId", AuditLog.user_id),
  129. ("organizationId", AuditLog.organization_id),
  130. ("targetId", AuditLog.target_id),
  131. ):
  132. value = _positive_integer(params, parameter)
  133. if value is not None:
  134. filters.append(column == value)
  135. for parameter, enum_type, column in (
  136. ("actionType", AuditAction, AuditLog.action_type),
  137. ("operationResult", AuditResult, AuditLog.operation_result),
  138. ("targetType", AuditTarget, AuditLog.target_type),
  139. ):
  140. value = _enum_value(params, parameter, enum_type)
  141. if value is not None:
  142. filters.append(column == value)
  143. keyword = (params.get("keyword") or "").strip()
  144. if keyword:
  145. pattern = f"%{_escape_like(keyword)}%"
  146. filters.append(
  147. or_(
  148. *[
  149. column.like(pattern, escape="\\")
  150. for column in (
  151. AuditLog.username,
  152. AuditLog.real_name,
  153. AuditLog.organization_name,
  154. AuditLog.target_name,
  155. AuditLog.failure_reason,
  156. AuditLog.request_id,
  157. )
  158. ]
  159. )
  160. )
  161. total = db.session.scalar(
  162. select(func.count(AuditLog.id)).where(*filters)
  163. ) or 0
  164. direction = (
  165. (AuditLog.created_at.asc(), AuditLog.id.asc())
  166. if sort_direction == "asc"
  167. else (AuditLog.created_at.desc(), AuditLog.id.desc())
  168. )
  169. items = db.session.scalars(
  170. select(AuditLog)
  171. .where(*filters)
  172. .order_by(*direction)
  173. .offset(page_request.offset)
  174. .limit(page_request.page_size)
  175. ).all()
  176. return page_result(
  177. [_audit_summary(item) for item in items],
  178. page=page_request.page,
  179. page_size=page_request.page_size,
  180. total=int(total),
  181. )
  182. def _utc_now() -> datetime:
  183. return datetime.now(UTC)
  184. def _business_day_utc_range() -> tuple[datetime, datetime]:
  185. timezone_name = current_app.config["DMS_BUSINESS_TIMEZONE"]
  186. try:
  187. business_timezone = ZoneInfo(timezone_name)
  188. except ZoneInfoNotFoundError as exc:
  189. raise InternalError("业务时区配置无效") from exc
  190. local_now = _utc_now().astimezone(business_timezone)
  191. start_local = datetime.combine(
  192. local_now.date(), time.min, tzinfo=business_timezone
  193. )
  194. end_local = start_local + timedelta(days=1)
  195. return (
  196. start_local.astimezone(UTC).replace(tzinfo=None),
  197. end_local.astimezone(UTC).replace(tzinfo=None),
  198. )
  199. def document_statistics(params: MultiDict) -> dict:
  200. _validate_parameters(params, set())
  201. counts = db.session.execute(
  202. select(
  203. func.coalesce(
  204. func.sum(case((Document.document_type == "MAIN", 1), else_=0)),
  205. 0,
  206. ),
  207. func.coalesce(
  208. func.sum(
  209. case((Document.document_type == "SUB_PLAN", 1), else_=0)
  210. ),
  211. 0,
  212. ),
  213. func.coalesce(
  214. func.sum(
  215. case((Document.document_type == "ATTACHMENT", 1), else_=0)
  216. ),
  217. 0,
  218. ),
  219. ).where(Document.is_deleted.is_(False))
  220. ).one()
  221. start_utc, end_utc = _business_day_utc_range()
  222. today_viewers = db.session.scalar(
  223. select(func.count(func.distinct(AuditLog.user_id))).where(
  224. AuditLog.action_type == AuditAction.VIEW_DOCUMENT.value,
  225. AuditLog.operation_result == AuditResult.SUCCESS.value,
  226. AuditLog.user_id.is_not(None),
  227. AuditLog.created_at >= start_utc,
  228. AuditLog.created_at < end_utc,
  229. )
  230. ) or 0
  231. main_count, sub_count, attachment_count = map(int, counts)
  232. return {
  233. "planDocumentCount": main_count + sub_count,
  234. "mainPlanCount": main_count,
  235. "subPlanCount": sub_count,
  236. "attachmentCount": attachment_count,
  237. "todayViewerCount": int(today_viewers),
  238. }
  239. def _period_expression(granularity: str):
  240. if granularity == "DAY":
  241. return func.date_format(AuditLog.created_at, "%Y-%m-%d")
  242. if granularity == "MONTH":
  243. return func.date_format(AuditLog.created_at, "%Y-%m")
  244. monday = func.from_days(
  245. func.to_days(AuditLog.created_at) - func.weekday(AuditLog.created_at)
  246. )
  247. return func.date_format(monday, "%Y-%m-%d")
  248. def trend_statistics(params: MultiDict) -> dict:
  249. _validate_parameters(params, TIME_PARAMETERS | {"granularity"})
  250. granularity = params.get("granularity")
  251. if granularity not in {"DAY", "WEEK", "MONTH"}:
  252. raise InvalidArgumentError("granularity只允许DAY、WEEK或MONTH且为必填")
  253. filters = _time_filters(params)
  254. period = _period_expression(granularity).label("period")
  255. operation_count = func.count(AuditLog.id).label("operation_count")
  256. rows = db.session.execute(
  257. select(period, operation_count)
  258. .where(*filters)
  259. .group_by(period)
  260. .order_by(period.asc())
  261. ).all()
  262. return {
  263. "items": [
  264. {"period": row.period, "operationCount": int(row.operation_count)}
  265. for row in rows
  266. ]
  267. }
  268. def action_statistics(params: MultiDict) -> dict:
  269. _validate_parameters(params, TIME_PARAMETERS)
  270. filters = _time_filters(params)
  271. count = func.count(AuditLog.id).label("action_count")
  272. rows = db.session.execute(
  273. select(AuditLog.action_type, count)
  274. .where(*filters)
  275. .group_by(AuditLog.action_type)
  276. .order_by(count.desc(), AuditLog.action_type.asc())
  277. ).all()
  278. return {
  279. "items": [
  280. {"actionType": row.action_type, "count": int(row.action_count)}
  281. for row in rows
  282. ]
  283. }
  284. def active_user_statistics(params: MultiDict) -> dict:
  285. _validate_parameters(params, TIME_PARAMETERS | {"limit"})
  286. limit = _positive_integer(params, "limit", default=10, maximum=100)
  287. assert limit is not None
  288. filters = _time_filters(params)
  289. ranked = (
  290. select(
  291. AuditLog.user_id.label("user_id"),
  292. AuditLog.real_name.label("real_name"),
  293. AuditLog.organization_name.label("organization_name"),
  294. func.count()
  295. .over(partition_by=AuditLog.user_id)
  296. .label("operation_count"),
  297. func.row_number()
  298. .over(
  299. partition_by=AuditLog.user_id,
  300. order_by=(AuditLog.created_at.desc(), AuditLog.id.desc()),
  301. )
  302. .label("snapshot_rank"),
  303. )
  304. .where(AuditLog.user_id.is_not(None), *filters)
  305. .subquery()
  306. )
  307. rows = db.session.execute(
  308. select(
  309. ranked.c.user_id,
  310. ranked.c.real_name,
  311. ranked.c.organization_name,
  312. ranked.c.operation_count,
  313. )
  314. .where(ranked.c.snapshot_rank == 1)
  315. .order_by(ranked.c.operation_count.desc(), ranked.c.user_id.asc())
  316. .limit(limit)
  317. ).all()
  318. return {
  319. "items": [
  320. {
  321. "userId": serialize_id(row.user_id),
  322. "realName": row.real_name,
  323. "organizationName": row.organization_name,
  324. "operationCount": int(row.operation_count),
  325. }
  326. for row in rows
  327. ]
  328. }