"""系统使用统计服务实现。""" from __future__ import annotations from datetime import datetime from typing import Any from sqlalchemy import text from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException from fastapi_modules.fastapi_leaudit.domian.vo.usageStatsVo import ( UsageStatsAreaItemVO, UsageStatsAreaPageVO, UsageStatsDepartmentItemVO, UsageStatsDepartmentPageVO, UsageStatsDetailItemVO, UsageStatsDetailPageVO, UsageStatsOverviewVO, UsageStatsTrendItemVO, UsageStatsTrendVO, UsageStatsUserItemVO, UsageStatsUserPageVO, ) from fastapi_modules.fastapi_leaudit.services.impl.ssoUserCompat import SsoUserCompat from fastapi_modules.fastapi_leaudit.services.impl.tenantResolver import TenantResolver from fastapi_modules.fastapi_leaudit.services.usageStatsService import IUsageStatsService class UsageStatsServiceImpl(IUsageStatsService): """系统使用统计服务实现。""" def __init__(self, TenantResolverService: TenantResolver | None = None) -> None: self.TenantResolver = TenantResolverService or TenantResolver() async def RecordLoginEvent( self, *, UserInfo: dict[str, Any] | None, Sub: str | None, LoginResult: str, LoginType: str, IpAddress: str | None, UserAgent: str | None, FailureReason: str | None = None, TokenJti: str | None = None, ) -> None: async with GetAsyncSession() as session: await self._ensure_usage_stats_schema(session) user_id = self._to_int(UserInfo.get("user_id")) if UserInfo else None username = str((UserInfo or {}).get("username") or "") or None nick_name = str((UserInfo or {}).get("nick_name") or "") or None dep_name = str((UserInfo or {}).get("dep_name") or "") or None ou_id = str((UserInfo or {}).get("ou_id") or "") or None ou_name = str((UserInfo or {}).get("ou_name") or "") or None area = str((UserInfo or {}).get("area") or "") or None tenant_code = str((UserInfo or {}).get("tenant_code") or "") or None tenant_name = str((UserInfo or {}).get("tenant_name") or "") or None client_type = self._detect_client_type(UserAgent) resolved_tenant = await self.TenantResolver.ResolveUserContext( Area=area, TenantCode=tenant_code, TenantName=tenant_name, Source="usage_login_event", ) normalized_failure_reason = self._normalize_failure_reason(FailureReason) await session.execute( text( """ INSERT INTO usage_login_events ( user_id, sub, username_snapshot, nick_name_snapshot, department_name_snapshot, ou_id_snapshot, ou_name_snapshot, tenant_code_snapshot, tenant_name_snapshot, area_snapshot, login_time, login_result, login_type, ip_address, user_agent, client_type, token_jti, failure_reason, extra, created_at, updated_at, deleted_at ) VALUES ( :user_id, :sub, :username_snapshot, :nick_name_snapshot, :department_name_snapshot, :ou_id_snapshot, :ou_name_snapshot, :tenant_code_snapshot, :tenant_name_snapshot, :area_snapshot, NOW(), :login_result, :login_type, :ip_address, :user_agent, :client_type, :token_jti, :failure_reason, CAST(:extra AS jsonb), NOW(), NOW(), NULL ) """ ), { "user_id": user_id, "sub": Sub, "username_snapshot": username, "nick_name_snapshot": nick_name, "department_name_snapshot": dep_name, "ou_id_snapshot": ou_id, "ou_name_snapshot": ou_name, "tenant_code_snapshot": resolved_tenant.tenant_code or tenant_code, "tenant_name_snapshot": resolved_tenant.tenant_name or tenant_name or area, "area_snapshot": resolved_tenant.tenant_name or area, "login_result": LoginResult, "login_type": LoginType, "ip_address": IpAddress, "user_agent": UserAgent, "client_type": client_type, "token_jti": TokenJti, "failure_reason": normalized_failure_reason, "extra": "{}", }, ) if LoginResult == "success" and user_id is not None: await session.execute( text("UPDATE sso_users SET last_login_at = NOW(), updated_at = NOW() WHERE id = :user_id"), {"user_id": user_id}, ) await session.commit() async def GetOverview(self, CurrentUserId: int, Filters: dict[str, Any]) -> UsageStatsOverviewVO: context = await self._get_current_user_context(CurrentUserId) self._assert_stats_access(context) async with GetAsyncSession() as session: await self._ensure_usage_stats_schema(session) login_where, login_params = self._build_login_filters(context, Filters) doc_where, doc_params = self._build_document_filters(context, Filters, alias_prefix="d", file_alias="f", user_alias="u") audit_where, audit_params = self._build_audit_filters(context, Filters) login_row = ( await session.execute( text( f""" SELECT COUNT(*)::int AS login_count, COUNT(DISTINCT user_id)::int AS login_user_count, COALESCE(MAX(login_time), NOW()) AS last_updated_at FROM usage_login_events e WHERE {login_where} AND e.login_result = 'success' """ ), login_params, ) ).mappings().first() upload_row = ( await session.execute( text( f""" SELECT COUNT(*) FILTER (WHERE f.file_role = 'primary')::int AS upload_document_count, COUNT(*) FILTER (WHERE f.file_role = 'attachment')::int AS upload_attachment_count FROM leaudit_document_files f JOIN leaudit_documents d ON d.id = f.document_id LEFT JOIN sso_users u ON u.id = f.created_by LEFT JOIN leaudit_document_types dt ON dt.id = d.type_id LEFT JOIN leaudit_entry_modules em ON em.id = dt.entry_module_id WHERE {doc_where} """ ), doc_params, ) ).mappings().first() audit_row = ( await session.execute( text( f""" SELECT COUNT(*)::int AS audit_run_count, COUNT(*) FILTER (WHERE ar.status = 'completed')::int AS audit_completed_count, COUNT(*) FILTER (WHERE ar.status = 'failed')::int AS audit_failed_count FROM leaudit_audit_runs ar JOIN leaudit_documents d ON d.id = ar.document_id LEFT JOIN sso_users u ON u.id = ar.trigger_user_id LEFT JOIN leaudit_document_types dt ON dt.id = d.type_id LEFT JOIN leaudit_entry_modules em ON em.id = dt.entry_module_id WHERE {audit_where} """ ), audit_params, ) ).mappings().first() active_users = ( await session.execute( text( f""" WITH active_users AS ( SELECT DISTINCT e.user_id AS uid FROM usage_login_events e WHERE {login_where} AND e.login_result = 'success' AND e.user_id IS NOT NULL UNION SELECT DISTINCT f.created_by AS uid FROM leaudit_document_files f JOIN leaudit_documents d ON d.id = f.document_id LEFT JOIN sso_users u ON u.id = f.created_by LEFT JOIN leaudit_document_types dt ON dt.id = d.type_id LEFT JOIN leaudit_entry_modules em ON em.id = dt.entry_module_id WHERE {doc_where} AND f.created_by IS NOT NULL UNION SELECT DISTINCT ar.trigger_user_id AS uid FROM leaudit_audit_runs ar JOIN leaudit_documents d ON d.id = ar.document_id LEFT JOIN sso_users u ON u.id = ar.trigger_user_id LEFT JOIN leaudit_document_types dt ON dt.id = d.type_id LEFT JOIN leaudit_entry_modules em ON em.id = dt.entry_module_id WHERE {audit_where} AND ar.trigger_user_id IS NOT NULL ) SELECT COUNT(*)::int AS active_user_count FROM active_users """ ), {**login_params, **doc_params, **audit_params}, ) ).mappings().first() return UsageStatsOverviewVO( loginUserCount=int((login_row or {}).get("login_user_count") or 0), loginCount=int((login_row or {}).get("login_count") or 0), activeUserCount=int((active_users or {}).get("active_user_count") or 0), uploadDocumentCount=int((upload_row or {}).get("upload_document_count") or 0), uploadAttachmentCount=int((upload_row or {}).get("upload_attachment_count") or 0), auditRunCount=int((audit_row or {}).get("audit_run_count") or 0), auditCompletedCount=int((audit_row or {}).get("audit_completed_count") or 0), auditFailedCount=int((audit_row or {}).get("audit_failed_count") or 0), lastUpdatedAt=self._format_datetime((login_row or {}).get("last_updated_at")), ) async def GetTrends(self, CurrentUserId: int, Filters: dict[str, Any]) -> UsageStatsTrendVO: context = await self._get_current_user_context(CurrentUserId) self._assert_stats_access(context) granularity = str(Filters.get("granularity") or "day").strip().lower() if granularity not in {"day", "week", "month"}: granularity = "day" metric = str(Filters.get("metric") or "audit").strip().lower() date_expr = {"day": "YYYY-MM-DD", "week": 'IYYY-"W"IW', "month": "YYYY-MM"}[granularity] async with GetAsyncSession() as session: await self._ensure_usage_stats_schema(session) items: list[UsageStatsTrendItemVO] = [] if metric == "login": where_clause, params = self._build_login_filters(context, Filters) rows = ( await session.execute( text( f""" SELECT to_char(date_trunc('{granularity}', e.login_time), '{date_expr}') AS label, COUNT(*)::int AS value FROM usage_login_events e WHERE {where_clause} AND e.login_result = 'success' GROUP BY 1 ORDER BY MIN(date_trunc('{granularity}', e.login_time)) ASC """ ), params, ) ).mappings().all() items = [UsageStatsTrendItemVO(label=str(row["label"]), value=int(row["value"] or 0)) for row in rows] elif metric == "upload": where_clause, params = self._build_document_filters(context, Filters, alias_prefix="d", file_alias="f", user_alias="u") rows = ( await session.execute( text( f""" SELECT to_char(date_trunc('{granularity}', f.created_at), '{date_expr}') AS label, COUNT(*) FILTER (WHERE f.file_role = 'primary')::int AS value FROM leaudit_document_files f JOIN leaudit_documents d ON d.id = f.document_id LEFT JOIN sso_users u ON u.id = f.created_by LEFT JOIN leaudit_document_types dt ON dt.id = d.type_id LEFT JOIN leaudit_entry_modules em ON em.id = dt.entry_module_id WHERE {where_clause} GROUP BY 1 ORDER BY MIN(date_trunc('{granularity}', f.created_at)) ASC """ ), params, ) ).mappings().all() items = [UsageStatsTrendItemVO(label=str(row["label"]), value=int(row["value"] or 0)) for row in rows] else: where_clause, params = self._build_audit_filters(context, Filters) rows = ( await session.execute( text( f""" SELECT to_char(date_trunc('{granularity}', COALESCE(ar.started_at, ar.created_at)), '{date_expr}') AS label, COUNT(*)::int AS value FROM leaudit_audit_runs ar JOIN leaudit_documents d ON d.id = ar.document_id LEFT JOIN sso_users u ON u.id = ar.trigger_user_id LEFT JOIN leaudit_document_types dt ON dt.id = d.type_id LEFT JOIN leaudit_entry_modules em ON em.id = dt.entry_module_id WHERE {where_clause} GROUP BY 1 ORDER BY MIN(date_trunc('{granularity}', COALESCE(ar.started_at, ar.created_at))) ASC """ ), params, ) ).mappings().all() items = [UsageStatsTrendItemVO(label=str(row["label"]), value=int(row["value"] or 0)) for row in rows] return UsageStatsTrendVO(granularity=granularity, metric=metric, items=items) async def GetUsers(self, CurrentUserId: int, Filters: dict[str, Any]) -> UsageStatsUserPageVO: context = await self._get_current_user_context(CurrentUserId) self._assert_stats_access(context) page, page_size, offset = self._pagination(Filters) async with GetAsyncSession() as session: await self._ensure_usage_stats_schema(session) sso_user_columns = await SsoUserCompat.get_columns(session) tenant_code_select = SsoUserCompat.optional_coalesce_as( sso_user_columns, alias="u", column="tenant_code", fallback_sql="''", ) tenant_name_select = SsoUserCompat.optional_coalesce_as( sso_user_columns, alias="u", column="tenant_name", fallback_sql="''", ) area_condition, params = self._build_user_scope_condition(context, Filters, user_alias="u") if Filters.get("keyword"): params["keyword"] = f"%{str(Filters['keyword']).strip()}%" area_condition += " AND (u.username ILIKE :keyword OR u.nick_name ILIKE :keyword)" if Filters.get("departmentName"): params["department_name"] = str(Filters["departmentName"]).strip() area_condition += " AND COALESCE(u.dep_name, '') = :department_name" if Filters.get("userId") is not None: params["requested_user_id"] = int(Filters["userId"]) area_condition += " AND u.id = :requested_user_id" login_where, login_params = self._build_login_filters(context, Filters) upload_where, upload_params = self._build_document_filters(context, Filters, alias_prefix="d", file_alias="f", user_alias="u") audit_where, audit_params = self._build_audit_filters(context, Filters) query = text( f""" WITH base_users AS ( SELECT u.id, u.username, COALESCE(u.nick_name, '') AS nick_name, COALESCE(u.dep_name, '') AS department_name, COALESCE(u.area, '') AS area, {tenant_code_select}, {tenant_name_select}, u.last_login_at FROM sso_users u WHERE u.deleted_at IS NULL AND u.status = 0 AND {area_condition} ), login_stats AS ( SELECT e.user_id, COUNT(*)::int AS login_count, MAX(e.login_time) AS last_login_time FROM usage_login_events e WHERE {login_where} AND e.login_result = 'success' AND e.user_id IS NOT NULL GROUP BY e.user_id ), upload_stats AS ( SELECT f.created_by AS user_id, COUNT(*) FILTER (WHERE f.file_role = 'primary')::int AS upload_document_count, COUNT(*) FILTER (WHERE f.file_role = 'attachment')::int AS upload_attachment_count FROM leaudit_document_files f JOIN leaudit_documents d ON d.id = f.document_id LEFT JOIN sso_users u ON u.id = f.created_by LEFT JOIN leaudit_document_types dt ON dt.id = d.type_id LEFT JOIN leaudit_entry_modules em ON em.id = dt.entry_module_id WHERE {upload_where} AND f.created_by IS NOT NULL GROUP BY f.created_by ), audit_stats AS ( SELECT ar.trigger_user_id AS user_id, COUNT(*)::int AS audit_run_count, COUNT(*) FILTER (WHERE ar.status = 'completed')::int AS audit_completed_count, COUNT(*) FILTER (WHERE ar.status = 'failed')::int AS audit_failed_count FROM leaudit_audit_runs ar JOIN leaudit_documents d ON d.id = ar.document_id LEFT JOIN sso_users u ON u.id = ar.trigger_user_id LEFT JOIN leaudit_document_types dt ON dt.id = d.type_id LEFT JOIN leaudit_entry_modules em ON em.id = dt.entry_module_id WHERE {audit_where} AND ar.trigger_user_id IS NOT NULL GROUP BY ar.trigger_user_id ) SELECT b.id AS user_id, b.username, b.nick_name, b.department_name, b.area, b.tenant_code, b.tenant_name, COALESCE(ls.login_count, 0) AS login_count, COALESCE(us.upload_document_count, 0) AS upload_document_count, COALESCE(us.upload_attachment_count, 0) AS upload_attachment_count, COALESCE(aus.audit_run_count, 0) AS audit_run_count, COALESCE(aus.audit_completed_count, 0) AS audit_completed_count, COALESCE(aus.audit_failed_count, 0) AS audit_failed_count, COALESCE(ls.last_login_time, b.last_login_at) AS last_login_time FROM base_users b LEFT JOIN login_stats ls ON ls.user_id = b.id LEFT JOIN upload_stats us ON us.user_id = b.id LEFT JOIN audit_stats aus ON aus.user_id = b.id ORDER BY b.id DESC LIMIT :limit OFFSET :offset """ ) count_query = text( f"SELECT COUNT(*)::int AS total FROM sso_users u WHERE u.deleted_at IS NULL AND u.status = 0 AND {area_condition}" ) merged_params = { **params, **login_params, **upload_params, **audit_params, "limit": page_size, "offset": offset, } total = int((await session.execute(count_query, params)).scalar_one() or 0) rows = (await session.execute(query, merged_params)).mappings().all() items = [ UsageStatsUserItemVO( userId=int(row["user_id"]), username=str(row["username"] or ""), nickName=str(row["nick_name"] or ""), departmentName=row["department_name"] or None, area=row["area"] or None, tenantCode=row.get("tenant_code") or None, tenantName=row.get("tenant_name") or row.get("area") or None, loginCount=int(row["login_count"] or 0), uploadDocumentCount=int(row["upload_document_count"] or 0), uploadAttachmentCount=int(row["upload_attachment_count"] or 0), auditRunCount=int(row["audit_run_count"] or 0), auditCompletedCount=int(row["audit_completed_count"] or 0), auditFailedCount=int(row["audit_failed_count"] or 0), lastLoginTime=self._format_datetime(row.get("last_login_time")), ) for row in rows ] return UsageStatsUserPageVO(total=total, page=page, pageSize=page_size, items=items) async def GetDepartments(self, CurrentUserId: int, Filters: dict[str, Any]) -> UsageStatsDepartmentPageVO: context = await self._get_current_user_context(CurrentUserId) self._assert_stats_access(context) page, page_size, offset = self._pagination(Filters) by_users = await self.GetUsers(CurrentUserId, {**Filters, "page": 1, "pageSize": 5000}) grouped: dict[str, UsageStatsDepartmentItemVO] = {} user_ids_by_dept: dict[str, set[int]] = {} login_user_ids_by_dept: dict[str, set[int]] = {} for item in by_users.items: dept = item.departmentName or "未分配部门" if dept not in grouped: grouped[dept] = UsageStatsDepartmentItemVO(departmentName=dept) user_ids_by_dept[dept] = set() login_user_ids_by_dept[dept] = set() row = grouped[dept] user_ids_by_dept[dept].add(item.userId) if item.loginCount > 0: login_user_ids_by_dept[dept].add(item.userId) row.loginCount += item.loginCount row.uploadDocumentCount += item.uploadDocumentCount row.uploadAttachmentCount += item.uploadAttachmentCount row.auditRunCount += item.auditRunCount row.auditCompletedCount += item.auditCompletedCount row.auditFailedCount += item.auditFailedCount items = [] for dept, row in grouped.items(): row.userCount = len(user_ids_by_dept[dept]) row.loginUserCount = len(login_user_ids_by_dept[dept]) items.append(row) items.sort(key=lambda item: (item.auditRunCount, item.uploadDocumentCount, item.loginCount), reverse=True) total = len(items) sliced = items[offset: offset + page_size] return UsageStatsDepartmentPageVO(total=total, page=page, pageSize=page_size, items=sliced) async def GetAreas(self, CurrentUserId: int, Filters: dict[str, Any]) -> UsageStatsAreaPageVO: context = await self._get_current_user_context(CurrentUserId) self._assert_stats_access(context) page, page_size, offset = self._pagination(Filters) area_scope = str(Filters.get("areaScope") or "user").strip().lower() if area_scope not in {"user", "document"}: area_scope = "user" async with GetAsyncSession() as session: await self._ensure_usage_stats_schema(session) sso_user_columns = await SsoUserCompat.get_columns(session) user_tenant_code_expr = SsoUserCompat.raw_optional_column( sso_user_columns, alias="u", column="tenant_code", ) user_tenant_name_expr = SsoUserCompat.raw_optional_column( sso_user_columns, alias="u", column="tenant_name", ) login_where, login_params = self._build_login_filters(context, Filters) if area_scope == "document": upload_area_expr = "COALESCE(d.region, '')" audit_area_expr = "COALESCE(d.region, '')" upload_tenant_code_expr = "COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL)" upload_tenant_name_expr = self._document_tenant_name_sql("d") audit_tenant_code_expr = "COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL)" audit_tenant_name_expr = self._document_tenant_name_sql("d") else: upload_area_expr = "COALESCE(u.area, '')" audit_area_expr = "COALESCE(u.area, '')" upload_tenant_code_expr = f"COALESCE(NULLIF(BTRIM({user_tenant_code_expr}), ''), NULL)" upload_tenant_name_expr = ( f"COALESCE(NULLIF(BTRIM({user_tenant_name_expr}), ''), NULLIF(BTRIM({upload_area_expr}), ''), '未分配地区')" ) audit_tenant_code_expr = f"COALESCE(NULLIF(BTRIM({user_tenant_code_expr}), ''), NULL)" audit_tenant_name_expr = ( f"COALESCE(NULLIF(BTRIM({user_tenant_name_expr}), ''), NULLIF(BTRIM({audit_area_expr}), ''), '未分配地区')" ) doc_where, doc_params = self._build_document_filters(context, Filters, alias_prefix="d", file_alias="f", user_alias="u") audit_where, audit_params = self._build_audit_filters(context, Filters) login_rows = ( await session.execute( text( f""" SELECT COALESCE(e.area_snapshot, '未分配地区') AS area, COALESCE(NULLIF(BTRIM(e.tenant_code_snapshot), ''), NULL) AS tenant_code, COALESCE(NULLIF(BTRIM(e.tenant_name_snapshot), ''), COALESCE(e.area_snapshot, '未分配地区')) AS tenant_name, COUNT(*)::int AS login_count, COUNT(DISTINCT e.user_id)::int AS login_user_count FROM usage_login_events e WHERE {login_where} AND e.login_result = 'success' GROUP BY 1, 2, 3 """ ), login_params, ) ).mappings().all() upload_rows = ( await session.execute( text( f""" SELECT {upload_area_expr} AS area, {upload_tenant_code_expr} AS tenant_code, {upload_tenant_name_expr} AS tenant_name, COUNT(*) FILTER (WHERE f.file_role = 'primary')::int AS upload_document_count, COUNT(*) FILTER (WHERE f.file_role = 'attachment')::int AS upload_attachment_count FROM leaudit_document_files f JOIN leaudit_documents d ON d.id = f.document_id LEFT JOIN sso_users u ON u.id = f.created_by LEFT JOIN leaudit_document_types dt ON dt.id = d.type_id LEFT JOIN leaudit_entry_modules em ON em.id = dt.entry_module_id WHERE {doc_where} GROUP BY 1, 2, 3 """ ), doc_params, ) ).mappings().all() audit_rows = ( await session.execute( text( f""" SELECT {audit_area_expr} AS area, {audit_tenant_code_expr} AS tenant_code, {audit_tenant_name_expr} AS tenant_name, COUNT(*)::int AS audit_run_count, COUNT(*) FILTER (WHERE ar.status = 'completed')::int AS audit_completed_count, COUNT(*) FILTER (WHERE ar.status = 'failed')::int AS audit_failed_count FROM leaudit_audit_runs ar JOIN leaudit_documents d ON d.id = ar.document_id LEFT JOIN sso_users u ON u.id = ar.trigger_user_id LEFT JOIN leaudit_document_types dt ON dt.id = d.type_id LEFT JOIN leaudit_entry_modules em ON em.id = dt.entry_module_id WHERE {audit_where} GROUP BY 1, 2, 3 """ ), audit_params, ) ).mappings().all() grouped: dict[tuple[str | None, str], UsageStatsAreaItemVO] = {} for row in login_rows: area = str(row["area"] or "未分配地区") tenant_code = row.get("tenant_code") or None group_key = (tenant_code, area) grouped.setdefault( group_key, UsageStatsAreaItemVO(area=area, tenantCode=tenant_code, tenantName=row.get("tenant_name") or area), ) grouped[group_key].loginCount += int(row["login_count"] or 0) grouped[group_key].loginUserCount += int(row["login_user_count"] or 0) for row in upload_rows: area = str(row["area"] or "未分配地区") tenant_code = row.get("tenant_code") or None group_key = (tenant_code, area) grouped.setdefault( group_key, UsageStatsAreaItemVO(area=area, tenantCode=tenant_code, tenantName=row.get("tenant_name") or area), ) grouped[group_key].uploadDocumentCount += int(row["upload_document_count"] or 0) grouped[group_key].uploadAttachmentCount += int(row["upload_attachment_count"] or 0) for row in audit_rows: area = str(row["area"] or "未分配地区") tenant_code = row.get("tenant_code") or None group_key = (tenant_code, area) grouped.setdefault( group_key, UsageStatsAreaItemVO(area=area, tenantCode=tenant_code, tenantName=row.get("tenant_name") or area), ) grouped[group_key].auditRunCount += int(row["audit_run_count"] or 0) grouped[group_key].auditCompletedCount += int(row["audit_completed_count"] or 0) grouped[group_key].auditFailedCount += int(row["audit_failed_count"] or 0) items = list(grouped.values()) items.sort(key=lambda item: (item.auditRunCount, item.uploadDocumentCount, item.loginCount), reverse=True) total = len(items) return UsageStatsAreaPageVO(total=total, page=page, pageSize=page_size, areaScope=area_scope, items=items[offset: offset + page_size]) async def GetDetails(self, CurrentUserId: int, Filters: dict[str, Any]) -> UsageStatsDetailPageVO: context = await self._get_current_user_context(CurrentUserId) self._assert_stats_access(context) page, page_size, offset = self._pagination(Filters) data_type = str(Filters.get("dataType") or "audit").strip().lower() if data_type not in {"login", "upload", "audit"}: data_type = "audit" area_scope = str(Filters.get("areaScope") or "user").strip().lower() if area_scope not in {"user", "document"}: area_scope = "user" async with GetAsyncSession() as session: await self._ensure_usage_stats_schema(session) sso_user_columns = await SsoUserCompat.get_columns(session) user_tenant_code_expr = SsoUserCompat.raw_optional_column( sso_user_columns, alias="u", column="tenant_code", ) user_tenant_name_expr = SsoUserCompat.raw_optional_column( sso_user_columns, alias="u", column="tenant_name", ) if area_scope == "document": detail_area_expr = "COALESCE(d.region, '')" detail_tenant_code_select = "COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL) AS tenant_code" detail_tenant_name_select = f"{self._document_tenant_name_sql('d')} AS tenant_name" else: detail_area_expr = "COALESCE(u.area, '')" detail_tenant_code_select = f"COALESCE(NULLIF(BTRIM({user_tenant_code_expr}), ''), NULL) AS tenant_code" detail_tenant_name_select = ( f"COALESCE(NULLIF(BTRIM({user_tenant_name_expr}), ''), NULLIF(BTRIM(COALESCE(u.area, '')), ''), NULL) AS tenant_name" ) if data_type == "login": where_clause, params = self._build_login_filters(context, Filters) count_sql = text(f"SELECT COUNT(*)::int FROM usage_login_events e WHERE {where_clause}") list_sql = text( f""" SELECT e.login_time, e.user_id, e.username_snapshot, e.nick_name_snapshot, e.department_name_snapshot, e.area_snapshot, e.tenant_code_snapshot, e.tenant_name_snapshot, e.login_result, e.failure_reason, e.login_type FROM usage_login_events e WHERE {where_clause} ORDER BY e.login_time DESC LIMIT :limit OFFSET :offset """ ) total = int((await session.execute(count_sql, params)).scalar_one() or 0) rows = (await session.execute(list_sql, {**params, "limit": page_size, "offset": offset})).mappings().all() items = [ UsageStatsDetailItemVO( time=self._format_datetime(row["login_time"]), dataType="login", userId=self._to_int(row.get("user_id")), username=str(row.get("username_snapshot") or ""), nickName=str(row.get("nick_name_snapshot") or ""), departmentName=row.get("department_name_snapshot") or None, area=row.get("area_snapshot") or None, tenantCode=row.get("tenant_code_snapshot") or None, tenantName=row.get("tenant_name_snapshot") or row.get("area_snapshot") or None, status=str(row.get("login_result") or ""), extra={"failureReason": row.get("failure_reason"), "loginType": row.get("login_type")}, ) for row in rows ] elif data_type == "upload": where_clause, params = self._build_document_filters(context, Filters, alias_prefix="d", file_alias="f", user_alias="u") count_sql = text( f""" SELECT COUNT(*)::int FROM leaudit_document_files f JOIN leaudit_documents d ON d.id = f.document_id LEFT JOIN sso_users u ON u.id = f.created_by LEFT JOIN leaudit_document_types dt ON dt.id = d.type_id LEFT JOIN leaudit_entry_modules em ON em.id = dt.entry_module_id WHERE {where_clause} """ ) list_sql = text( f""" SELECT f.created_at, f.created_by, COALESCE(u.username, '') AS username, COALESCE(u.nick_name, '') AS nick_name, COALESCE(u.dep_name, '') AS department_name, {detail_area_expr} AS area, {detail_tenant_code_select}, {detail_tenant_name_select}, d.id AS document_id, f.file_name, dt.id AS document_type_id, dt.name AS document_type_name, em.id AS entry_module_id, em.name AS entry_module_name, f.file_role FROM leaudit_document_files f JOIN leaudit_documents d ON d.id = f.document_id LEFT JOIN sso_users u ON u.id = f.created_by LEFT JOIN leaudit_document_types dt ON dt.id = d.type_id LEFT JOIN leaudit_entry_modules em ON em.id = dt.entry_module_id WHERE {where_clause} ORDER BY f.created_at DESC LIMIT :limit OFFSET :offset """ ) total = int((await session.execute(count_sql, params)).scalar_one() or 0) rows = (await session.execute(list_sql, {**params, "limit": page_size, "offset": offset})).mappings().all() items = [ UsageStatsDetailItemVO( time=self._format_datetime(row["created_at"]), dataType="upload", userId=self._to_int(row.get("created_by")), username=str(row.get("username") or ""), nickName=str(row.get("nick_name") or ""), departmentName=row.get("department_name") or None, area=row.get("area") or None, tenantCode=row.get("tenant_code") or None, tenantName=row.get("tenant_name") or row.get("area") or None, documentId=self._to_int(row.get("document_id")), documentName=row.get("file_name"), documentTypeId=self._to_int(row.get("document_type_id")), documentTypeName=row.get("document_type_name"), entryModuleId=self._to_int(row.get("entry_module_id")), entryModuleName=row.get("entry_module_name"), status=row.get("file_role"), ) for row in rows ] else: where_clause, params = self._build_audit_filters(context, Filters) count_sql = text( f""" SELECT COUNT(*)::int FROM leaudit_audit_runs ar JOIN leaudit_documents d ON d.id = ar.document_id LEFT JOIN sso_users u ON u.id = ar.trigger_user_id LEFT JOIN leaudit_document_types dt ON dt.id = d.type_id LEFT JOIN leaudit_entry_modules em ON em.id = dt.entry_module_id LEFT JOIN leaudit_document_files f ON f.document_id = d.id AND f.file_role = 'primary' AND f.is_active = true WHERE {where_clause} """ ) list_sql = text( f""" SELECT COALESCE(ar.started_at, ar.created_at) AS event_time, ar.trigger_user_id, COALESCE(u.username, '') AS username, COALESCE(u.nick_name, '') AS nick_name, COALESCE(u.dep_name, '') AS department_name, {detail_area_expr} AS area, {detail_tenant_code_select}, {detail_tenant_name_select}, d.id AS document_id, COALESCE(f.file_name, d.normalized_name, '') AS document_name, dt.id AS document_type_id, dt.name AS document_type_name, em.id AS entry_module_id, em.name AS entry_module_name, ar.status, ar.id AS run_id, ar.result_status FROM leaudit_audit_runs ar JOIN leaudit_documents d ON d.id = ar.document_id LEFT JOIN sso_users u ON u.id = ar.trigger_user_id LEFT JOIN leaudit_document_types dt ON dt.id = d.type_id LEFT JOIN leaudit_entry_modules em ON em.id = dt.entry_module_id LEFT JOIN leaudit_document_files f ON f.document_id = d.id AND f.file_role = 'primary' AND f.is_active = true WHERE {where_clause} ORDER BY COALESCE(ar.started_at, ar.created_at) DESC LIMIT :limit OFFSET :offset """ ) total = int((await session.execute(count_sql, params)).scalar_one() or 0) rows = (await session.execute(list_sql, {**params, "limit": page_size, "offset": offset})).mappings().all() items = [ UsageStatsDetailItemVO( time=self._format_datetime(row["event_time"]), dataType="audit", userId=self._to_int(row.get("trigger_user_id")), username=str(row.get("username") or ""), nickName=str(row.get("nick_name") or ""), departmentName=row.get("department_name") or None, area=row.get("area") or None, tenantCode=row.get("tenant_code") or None, tenantName=row.get("tenant_name") or row.get("area") or None, documentId=self._to_int(row.get("document_id")), documentName=row.get("document_name"), documentTypeId=self._to_int(row.get("document_type_id")), documentTypeName=row.get("document_type_name"), entryModuleId=self._to_int(row.get("entry_module_id")), entryModuleName=row.get("entry_module_name"), status=row.get("status"), extra={"runId": self._to_int(row.get("run_id")), "resultStatus": row.get("result_status")}, ) for row in rows ] return UsageStatsDetailPageVO(total=total, page=page, pageSize=page_size, items=items) async def _ensure_usage_stats_schema(self, session) -> None: await session.execute(text("ALTER TABLE sso_users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ NULL")) await session.execute( text( """ CREATE TABLE IF NOT EXISTS usage_login_events ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NULL, sub VARCHAR(128) NULL, username_snapshot VARCHAR(128) NULL, nick_name_snapshot VARCHAR(128) NULL, department_name_snapshot VARCHAR(255) NULL, ou_id_snapshot VARCHAR(128) NULL, ou_name_snapshot VARCHAR(255) NULL, tenant_code_snapshot VARCHAR(64) NULL, tenant_name_snapshot VARCHAR(128) NULL, area_snapshot VARCHAR(64) NULL, login_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), login_result VARCHAR(16) NOT NULL, login_type VARCHAR(32) NOT NULL, ip_address VARCHAR(64) NULL, user_agent VARCHAR(1024) NULL, client_type VARCHAR(32) NULL, token_jti VARCHAR(128) NULL, failure_reason VARCHAR(255) NULL, extra JSONB NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ NULL ) """ ) ) await session.execute(text("ALTER TABLE usage_login_events ADD COLUMN IF NOT EXISTS tenant_code_snapshot VARCHAR(64) NULL")) await session.execute(text("ALTER TABLE usage_login_events ADD COLUMN IF NOT EXISTS tenant_name_snapshot VARCHAR(128) NULL")) await session.execute(text("CREATE INDEX IF NOT EXISTS idx_usage_login_events_login_time ON usage_login_events(login_time DESC)")) await session.execute(text("CREATE INDEX IF NOT EXISTS idx_usage_login_events_user_id ON usage_login_events(user_id, login_time DESC)")) await session.execute(text("CREATE INDEX IF NOT EXISTS idx_usage_login_events_department ON usage_login_events(department_name_snapshot, login_time DESC)")) await session.execute(text("CREATE INDEX IF NOT EXISTS idx_usage_login_events_area ON usage_login_events(area_snapshot, login_time DESC)")) await session.execute(text("CREATE INDEX IF NOT EXISTS idx_usage_login_events_tenant_code ON usage_login_events(tenant_code_snapshot, login_time DESC)")) async def _get_current_user_context(self, current_user_id: int) -> dict[str, Any]: async with GetAsyncSession() as session: sso_user_columns = await SsoUserCompat.get_columns(session) tenant_code_select = SsoUserCompat.optional_coalesce_as( sso_user_columns, alias="u", column="tenant_code", fallback_sql="''", ) tenant_name_select = SsoUserCompat.optional_coalesce_as( sso_user_columns, alias="u", column="tenant_name", fallback_sql="''", ) row = ( await session.execute( text( f""" SELECT u.id, COALESCE(u.area, '') AS area, {tenant_code_select}, {tenant_name_select}, COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin')), FALSE) AS is_global, COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin', 'admin')), FALSE) AS can_manage, COALESCE(bool_or(r.role_key = 'super_admin'), FALSE) AS is_super_admin FROM sso_users u LEFT JOIN user_role ur ON ur.user_id = u.id LEFT JOIN roles r ON r.id = ur.role_id WHERE u.id = :user_id GROUP BY u.id, u.area """ ), {"user_id": current_user_id}, ) ).mappings().first() if not row: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "当前用户不存在") tenant = await self.TenantResolver.ResolveUserContext( Area=str(row["area"] or ""), TenantCode=str(row["tenant_code"] or "") or None, TenantName=str(row["tenant_name"] or "") or None, Source="usage_stats_user_context", ) return { "area": tenant.tenant_name or tenant.normalized_value or str(row["area"] or ""), "tenant_code": tenant.tenant_code or str(row["tenant_code"] or "") or None, "tenant_name": tenant.tenant_name or str(row["tenant_name"] or "") or str(row["area"] or "") or None, "tenant_scope_value": tenant.tenant_name or tenant.normalized_value or str(row["area"] or ""), "is_global": bool(row["is_global"]), "can_manage": bool(row["can_manage"]), "is_super_admin": bool(row["is_super_admin"]), } def _assert_stats_access(self, context: dict[str, Any]) -> None: if context["is_global"] or context["can_manage"]: return raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "仅管理员可查看系统使用统计") def _build_user_scope_condition(self, context: dict[str, Any], filters: dict[str, Any], *, user_alias: str) -> tuple[str, dict[str, Any]]: conditions = ["1 = 1"] params: dict[str, Any] = {} requested_area = str(filters.get("area") or "").strip() requested_tenant_code = str(filters.get("tenantCode") or filters.get("tenant_code") or "").strip() requested_tenant_name = self._resolve_requested_tenant_name(requested_tenant_code, requested_area, context) if context["is_global"]: if requested_tenant_code: conditions.extend(self._user_tenant_filter_sql(user_alias, params, requested_tenant_code, requested_tenant_name, "requested")) elif requested_tenant_name: conditions.append(f"COALESCE({user_alias}.area, '') = :requested_area") params["requested_area"] = requested_tenant_name else: scope_value = str(context.get("tenant_scope_value") or context.get("area") or "").strip() scope_tenant_code = str(context.get("tenant_code") or "").strip() if not scope_value and not scope_tenant_code: conditions.append("1 = 0") elif requested_tenant_code: if scope_tenant_code and requested_tenant_code != scope_tenant_code: conditions.append("1 = 0") elif not scope_tenant_code and requested_tenant_name and requested_tenant_name != scope_value: conditions.append("1 = 0") else: conditions.extend(self._user_tenant_filter_sql(user_alias, params, requested_tenant_code, requested_tenant_name, "requested")) elif requested_tenant_name and requested_tenant_name != scope_value: conditions.append("1 = 0") else: conditions.extend(self._user_tenant_filter_sql(user_alias, params, scope_tenant_code or None, scope_value or None, "scope")) return " AND ".join(conditions), params def _build_login_filters(self, context: dict[str, Any], filters: dict[str, Any]) -> tuple[str, dict[str, Any]]: conditions = ["e.deleted_at IS NULL"] params: dict[str, Any] = {} requested_area = str(filters.get("area") or "").strip() requested_tenant_code = str(filters.get("tenantCode") or filters.get("tenant_code") or "").strip() requested_tenant_name = self._resolve_requested_tenant_name(requested_tenant_code, requested_area, context) scope_tenant_code = str(context.get("tenant_code") or "").strip() scope_tenant_name = str(context.get("tenant_scope_value") or context.get("area") or "").strip() if context["is_global"]: if requested_tenant_code: conditions.extend(self._login_tenant_filter_sql(params, requested_tenant_code, requested_tenant_name, "requested")) elif requested_tenant_name: conditions.append("COALESCE(e.area_snapshot, '') = :requested_area") params["requested_area"] = requested_tenant_name else: if not scope_tenant_code and not scope_tenant_name: conditions.append("1 = 0") elif requested_tenant_code: if scope_tenant_code and requested_tenant_code != scope_tenant_code: conditions.append("1 = 0") elif not scope_tenant_code and requested_tenant_name and requested_tenant_name != scope_tenant_name: conditions.append("1 = 0") else: conditions.extend(self._login_tenant_filter_sql(params, requested_tenant_code, requested_tenant_name, "requested")) elif requested_tenant_name and requested_tenant_name != scope_tenant_name: conditions.append("1 = 0") else: conditions.extend(self._login_tenant_filter_sql(params, scope_tenant_code or None, scope_tenant_name or None, "scope")) if filters.get("userId") is not None: conditions.append("e.user_id = :user_id") params["user_id"] = int(filters["userId"]) if filters.get("departmentName"): conditions.append("COALESCE(e.department_name_snapshot, '') = :department_name") params["department_name"] = str(filters["departmentName"]).strip() if filters.get("dateFrom"): conditions.append("e.login_time >= :date_from") params["date_from"] = self._normalize_date(filters["dateFrom"]) if filters.get("dateTo"): conditions.append("e.login_time < (CAST(:date_to AS date) + INTERVAL '1 day')") params["date_to"] = self._normalize_date(filters["dateTo"]) return " AND ".join(conditions), params def _build_document_filters(self, context: dict[str, Any], filters: dict[str, Any], *, alias_prefix: str, file_alias: str, user_alias: str) -> tuple[str, dict[str, Any]]: conditions = [f"{file_alias}.deleted_at IS NULL", f"{alias_prefix}.deleted_at IS NULL"] params: dict[str, Any] = {} area_scope = str(filters.get("areaScope") or "user").strip().lower() requested_area = str(filters.get("area") or "").strip() requested_tenant_code = str(filters.get("tenantCode") or filters.get("tenant_code") or "").strip() requested_tenant_name = self._resolve_requested_tenant_name(requested_tenant_code, requested_area, context) if area_scope == "document": if context["is_global"]: if requested_tenant_code: conditions.extend(self._document_tenant_filter_sql(alias_prefix, params, requested_tenant_code, requested_tenant_name, "requested")) elif requested_tenant_name: conditions.append(f"COALESCE({alias_prefix}.region, '') = :requested_area") params["requested_area"] = requested_tenant_name else: scope_value = str(context.get("tenant_scope_value") or context.get("area") or "").strip() scope_tenant_code = str(context.get("tenant_code") or "").strip() if not scope_value and not scope_tenant_code: conditions.append("1 = 0") elif requested_tenant_code: if scope_tenant_code and requested_tenant_code != scope_tenant_code: conditions.append("1 = 0") elif not scope_tenant_code and requested_tenant_name and requested_tenant_name != scope_value: conditions.append("1 = 0") else: conditions.extend(self._document_tenant_filter_sql(alias_prefix, params, requested_tenant_code, requested_tenant_name, "requested")) elif requested_tenant_name and requested_tenant_name != scope_value: conditions.append("1 = 0") else: conditions.extend(self._document_tenant_filter_sql(alias_prefix, params, scope_tenant_code or None, scope_value or None, "scope")) else: user_scope_cond, user_scope_params = self._build_user_scope_condition(context, filters, user_alias=user_alias) conditions.append(user_scope_cond) params.update(user_scope_params) if filters.get("userId") is not None: conditions.append(f"{file_alias}.created_by = :user_id") params["user_id"] = int(filters["userId"]) if filters.get("departmentName"): conditions.append(f"COALESCE({user_alias}.dep_name, '') = :department_name") params["department_name"] = str(filters["departmentName"]).strip() if filters.get("entryModuleId") is not None: conditions.append("em.id = :entry_module_id") params["entry_module_id"] = int(filters["entryModuleId"]) if filters.get("documentTypeId") is not None: conditions.append("dt.id = :document_type_id") params["document_type_id"] = int(filters["documentTypeId"]) if filters.get("dateFrom"): conditions.append(f"{file_alias}.created_at >= :date_from") params["date_from"] = self._normalize_date(filters["dateFrom"]) if filters.get("dateTo"): conditions.append(f"{file_alias}.created_at < (CAST(:date_to AS date) + INTERVAL '1 day')") params["date_to"] = self._normalize_date(filters["dateTo"]) return " AND ".join(conditions), params def _build_audit_filters(self, context: dict[str, Any], filters: dict[str, Any]) -> tuple[str, dict[str, Any]]: conditions = ["d.deleted_at IS NULL"] params: dict[str, Any] = {} area_scope = str(filters.get("areaScope") or "user").strip().lower() requested_area = str(filters.get("area") or "").strip() requested_tenant_code = str(filters.get("tenantCode") or filters.get("tenant_code") or "").strip() requested_tenant_name = self._resolve_requested_tenant_name(requested_tenant_code, requested_area, context) if area_scope == "document": if context["is_global"]: if requested_tenant_code: conditions.extend(self._document_tenant_filter_sql("d", params, requested_tenant_code, requested_tenant_name, "requested")) elif requested_tenant_name: conditions.append("COALESCE(d.region, '') = :requested_area") params["requested_area"] = requested_tenant_name else: scope_value = str(context.get("tenant_scope_value") or context.get("area") or "").strip() scope_tenant_code = str(context.get("tenant_code") or "").strip() if not scope_value and not scope_tenant_code: conditions.append("1 = 0") elif requested_tenant_code: if scope_tenant_code and requested_tenant_code != scope_tenant_code: conditions.append("1 = 0") elif not scope_tenant_code and requested_tenant_name and requested_tenant_name != scope_value: conditions.append("1 = 0") else: conditions.extend(self._document_tenant_filter_sql("d", params, requested_tenant_code, requested_tenant_name, "requested")) elif requested_tenant_name and requested_tenant_name != scope_value: conditions.append("1 = 0") else: conditions.extend(self._document_tenant_filter_sql("d", params, scope_tenant_code or None, scope_value or None, "scope")) else: user_scope_cond, user_scope_params = self._build_user_scope_condition(context, filters, user_alias="u") conditions.append(user_scope_cond) params.update(user_scope_params) if filters.get("userId") is not None: conditions.append("ar.trigger_user_id = :user_id") params["user_id"] = int(filters["userId"]) if filters.get("departmentName"): conditions.append("COALESCE(u.dep_name, '') = :department_name") params["department_name"] = str(filters["departmentName"]).strip() if filters.get("entryModuleId") is not None: conditions.append("em.id = :entry_module_id") params["entry_module_id"] = int(filters["entryModuleId"]) if filters.get("documentTypeId") is not None: conditions.append("dt.id = :document_type_id") params["document_type_id"] = int(filters["documentTypeId"]) if filters.get("dateFrom"): conditions.append("COALESCE(ar.started_at, ar.created_at) >= :date_from") params["date_from"] = self._normalize_date(filters["dateFrom"]) if filters.get("dateTo"): conditions.append("COALESCE(ar.started_at, ar.created_at) < (CAST(:date_to AS date) + INTERVAL '1 day')") params["date_to"] = self._normalize_date(filters["dateTo"]) return " AND ".join(conditions), params def _build_range_clause(self, column: str, filters: dict[str, Any], *, prefix: str) -> tuple[str, dict[str, Any]]: clauses: list[str] = [] params: dict[str, Any] = {} if filters.get("dateFrom"): clauses.append(f" AND {column} >= :{prefix}_date_from") params[f"{prefix}_date_from"] = self._normalize_date(filters["dateFrom"]) if filters.get("dateTo"): clauses.append(f" AND {column} < (CAST(:{prefix}_date_to AS date) + INTERVAL '1 day')") params[f"{prefix}_date_to"] = self._normalize_date(filters["dateTo"]) return "".join(clauses), params def _resolve_requested_tenant_name( self, requested_tenant_code: str | None, requested_area: str | None, context: dict[str, Any], ) -> str: tenant_code = str(requested_tenant_code or "").strip() if tenant_code: if tenant_code == str(context.get("tenant_code") or "").strip(): return str(context.get("tenant_scope_value") or context.get("tenant_name") or context.get("area") or "").strip() if tenant_code == "PUBLIC": return "公共" if tenant_code == "PROVINCIAL": return "省级" return str(requested_area or "").strip() @staticmethod def _document_tenant_name_sql(document_alias: str) -> str: return ( "CASE " f"WHEN NULLIF(BTRIM({document_alias}.tenant_code), '') = 'PUBLIC' THEN '公共' " f"WHEN NULLIF(BTRIM({document_alias}.tenant_code), '') = 'PROVINCIAL' THEN '省级' " f"ELSE COALESCE(NULLIF(BTRIM({document_alias}.region), ''), '未分配地区') " "END" ) def _user_tenant_filter_sql( self, user_alias: str, params: dict[str, Any], tenant_code: str | None, tenant_name: str | None, prefix: str, ) -> list[str]: normalized_tenant_code = str(tenant_code or "").strip() normalized_tenant_name = str(tenant_name or "").strip() if normalized_tenant_code: params[f"{prefix}_tenant_code"] = normalized_tenant_code return [f"{user_alias}.tenant_code = :{prefix}_tenant_code"] if normalized_tenant_name: params[f"{prefix}_tenant_name"] = normalized_tenant_name return [f"COALESCE({user_alias}.area, '') = :{prefix}_tenant_name"] return ["1 = 0"] def _login_tenant_filter_sql( self, params: dict[str, Any], tenant_code: str | None, tenant_name: str | None, prefix: str, ) -> list[str]: normalized_tenant_code = str(tenant_code or "").strip() normalized_tenant_name = str(tenant_name or "").strip() if normalized_tenant_code: params[f"{prefix}_tenant_code"] = normalized_tenant_code return [f"e.tenant_code_snapshot = :{prefix}_tenant_code"] if normalized_tenant_name: params[f"{prefix}_tenant_name"] = normalized_tenant_name return [f"COALESCE(e.area_snapshot, '') = :{prefix}_tenant_name"] return ["1 = 0"] def _document_tenant_filter_sql( self, alias_prefix: str, params: dict[str, Any], tenant_code: str | None, tenant_name: str | None, prefix: str, ) -> list[str]: normalized_tenant_code = str(tenant_code or "").strip() normalized_tenant_name = str(tenant_name or "").strip() if normalized_tenant_code: params[f"{prefix}_tenant_code"] = normalized_tenant_code return [f"{alias_prefix}.tenant_code = :{prefix}_tenant_code"] if normalized_tenant_name: params[f"{prefix}_tenant_name"] = normalized_tenant_name return [f"COALESCE({alias_prefix}.region, '') = :{prefix}_tenant_name"] return ["1 = 0"] @staticmethod def _normalize_date(value: Any): from datetime import date as date_type if isinstance(value, date_type): return value return date_type.fromisoformat(str(value).strip()) @staticmethod def _detect_client_type(user_agent: str | None) -> str: ua = str(user_agent or "").lower() if any(token in ua for token in ["iphone", "android", "mobile", "ipad"]): return "mobile" if ua: return "pc" return "other" @staticmethod def _normalize_failure_reason(value: str | None, limit: int = 255) -> str | None: """裁剪登录失败原因,避免审计表二次写入失败。""" if value is None: return None normalized = " ".join(str(value).split()).strip() if not normalized: return None if len(normalized) <= limit: return normalized if limit <= 3: return normalized[:limit] return f"{normalized[: limit - 3]}..." @staticmethod def _to_int(value: Any) -> int | None: try: return int(value) if value is not None and str(value) != "" else None except (TypeError, ValueError): return None @staticmethod def _format_datetime(value: Any) -> str | None: if not value: return None if isinstance(value, datetime): return value.strftime("%Y-%m-%d %H:%M:%S") return str(value) @staticmethod def _pagination(filters: dict[str, Any]) -> tuple[int, int, int]: page = max(int(filters.get("page") or 1), 1) page_size = max(1, min(int(filters.get("pageSize") or 20), 200)) return page, page_size, (page - 1) * page_size