feat: improve audit exports and admin analytics

This commit is contained in:
2026-08-25 18:49:56 +08:00
parent 910e67bd89
commit 5740bd26e7
55 changed files with 1654 additions and 269 deletions

View File

@@ -10,7 +10,9 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.core.time_utils import as_utc_naive, business_day_boundary
from app.models.admin import Admin
from app.models.chat import ChatMessage
from app.models.knowledge import (
HumanAttentionHistory,
HumanAttentionRecord,
@@ -95,7 +97,9 @@ def _cleanup_before(payload: dict) -> datetime:
if not raw:
raise HTTPException(status_code=400, detail="必须指定清理日期")
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).replace(tzinfo=None)
if len(raw) == 10:
return business_day_boundary(datetime.fromisoformat(raw).date())
return as_utc_naive(datetime.fromisoformat(raw.replace("Z", "+00:00")))
except ValueError as exc:
raise HTTPException(status_code=400, detail="清理日期格式错误") from exc
@@ -151,21 +155,46 @@ def _knowledge_type_label(value: str) -> str:
@router.get("/attention/list")
def attention_list(
priority: str = Query(default=""),
statusValue: str = Query(default="", alias="status"),
priority: str = Query(default="", pattern="^(|urgent|important|normal)$"),
statusValue: str = Query(default="", alias="status", pattern="^(|pending|processing|resolved|ignored)$"),
dateFrom: datetime | None = Query(default=None),
dateTo: datetime | None = Query(default=None),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
dateFrom = as_utc_naive(dateFrom) if dateFrom is not None else None
dateTo = as_utc_naive(dateTo) if dateTo is not None else None
if dateFrom is not None and dateTo is not None and dateFrom > dateTo:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="开始时间不能晚于结束时间")
query = select(HumanAttentionRecord)
if priority:
query = query.where(HumanAttentionRecord.priority == priority)
if statusValue:
query = query.where(HumanAttentionRecord.status == statusValue)
if dateFrom is not None:
query = query.where(HumanAttentionRecord.created_at >= dateFrom)
if dateTo is not None:
query = query.where(HumanAttentionRecord.created_at <= dateTo)
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
rows = db.scalars(query.order_by(HumanAttentionRecord.id.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
return api_success(page_result([_attention_dict(item) for item in rows], total=total, page=page, page_size=pageSize))
message_keys = {
(message_id, session_id)
for message_id, session_id in db.execute(
select(ChatMessage.id, ChatMessage.session_id).where(
ChatMessage.id.in_([item.message_id for item in rows])
)
).all()
} if rows else set()
items = [
_attention_dict(
item,
target_available=(item.message_id, item.session_id) in message_keys,
)
for item in rows
]
return api_success(page_result(items, total=total, page=page, page_size=pageSize))
@router.put("/attention/{attention_id}")
@@ -251,8 +280,8 @@ def _retrieval_dict(item: KnowledgeRetrievalLog, *, detail: bool) -> dict:
return data
def _attention_dict(item: HumanAttentionRecord) -> dict:
return {
def _attention_dict(item: HumanAttentionRecord, *, target_available: bool | None = None) -> dict:
data = {
"id": item.id,
"sessionId": item.session_id,
"messageId": item.message_id,
@@ -267,6 +296,9 @@ def _attention_dict(item: HumanAttentionRecord) -> dict:
"createdAt": item.created_at,
"updatedAt": item.updated_at,
}
if target_available is not None:
data["targetAvailable"] = target_available
return data
def _json(raw: str | None):

View File

@@ -18,6 +18,7 @@ from app.services.admin_service import AdminDashboardService
from app.models.logs import StorageSnapshot
from app.services.redis_client import get_sync_redis_client
from app.services.request_traffic_service import RequestTrafficService
from app.core.time_utils import business_day_boundary
router = APIRouter()
@@ -29,12 +30,8 @@ def dashboard(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
start_dt = datetime.strptime(start, "%Y-%m-%d") if start else None
if end:
end_dt = datetime.strptime(end, "%Y-%m-%d")
end_dt = end_dt.replace(hour=23, minute=59, second=59)
else:
end_dt = None
start_dt = business_day_boundary(datetime.strptime(start, "%Y-%m-%d").date()) if start else None
end_dt = business_day_boundary(datetime.strptime(end, "%Y-%m-%d").date(), end_exclusive=True) if end else None
stats = AdminDashboardService.stats(db, start_dt, end_dt)
return api_success(DashboardStats.model_validate(stats).model_dump())

View File

@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session, load_only
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.core.time_utils import as_utc_naive, to_business_naive
from app.models.admin import Admin
from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.growth import ShareDraft, TeacherHelpCard, TopicSummary
@@ -27,6 +28,7 @@ from app.services.question_insight_export_service import QuestionInsightExportSe
from app.services.growth_profile_service import topic_dict, topic_summary_dict
from app.services.help_card_service import help_card_dict
from app.services.share_draft_service import share_draft_dict
from app.services.chat_export_service import ChatExportService
router = APIRouter()
@@ -45,6 +47,8 @@ def chat_list(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
dateFrom = as_utc_naive(dateFrom) if dateFrom is not None else None
dateTo = as_utc_naive(dateTo) if dateTo is not None else None
query = _chat_query(
keyword=keyword,
user_id=userId,
@@ -72,6 +76,8 @@ def export_chats(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> Response:
dateFrom = as_utc_naive(dateFrom) if dateFrom is not None else None
dateTo = as_utc_naive(dateTo) if dateTo is not None else None
rows = db.execute(
_chat_query(
keyword=keyword,
@@ -98,8 +104,8 @@ def export_chats(
source_client.name if source_client else "千问千答直接访问",
session.title,
session.message_count,
session.last_message_at,
session.updated_at,
to_business_naive(session.last_message_at),
to_business_naive(session.updated_at),
]
)
content = "\ufeff" + output.getvalue()
@@ -132,11 +138,53 @@ def chat_messages(
return api_success(page_result([_message_dict(item) for item in messages], total=total, page=page, page_size=pageSize))
@router.get("/chat/{session_id}/export")
def export_chat_detail(
session_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> StreamingResponse:
session_row = db.execute(
select(ChatSession, User, SsoClient)
.join(User, User.id == ChatSession.user_id, isouter=True)
.join(SsoClient, SsoClient.id == ChatSession.source_client_id, isouter=True)
.where(ChatSession.id == session_id)
).first()
if session_row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在")
session, user, source_client = session_row
messages = db.scalars(
select(ChatMessage)
.where(ChatMessage.session_id == session_id)
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
).all()
stream = ChatExportService.build(
session=session,
user=user,
source_name=source_client.name if source_client else "千问千答直接访问",
messages=messages,
)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="records",
action="export_chat_detail",
target_id=session.id,
)
db.commit()
return StreamingResponse(
stream,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="chat_session_{session.id}.xlsx"'},
)
@router.get("/chat/{session_id}")
def chat_detail(
session_id: int,
messagePage: int = Query(default=1, ge=1),
messagePageSize: int = Query(default=20, ge=10, le=100),
focusMessageId: int | None = Query(default=None, gt=0),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
@@ -154,6 +202,28 @@ def chat_detail(
session, user, source_client = session_row
message_query = select(ChatMessage).where(ChatMessage.session_id == session_id)
message_total = db.scalar(select(func.count()).select_from(message_query.subquery())) or 0
if focusMessageId is not None:
focused_message = db.scalar(
select(ChatMessage).where(
ChatMessage.id == focusMessageId,
ChatMessage.session_id == session_id,
)
)
if focused_message is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="触发消息不属于该会话或已不存在")
focused_position = db.scalar(
select(func.count()).select_from(ChatMessage).where(
ChatMessage.session_id == session_id,
or_(
ChatMessage.created_at < focused_message.created_at,
(
(ChatMessage.created_at == focused_message.created_at)
& (ChatMessage.id <= focused_message.id)
),
),
)
) or 1
messagePage = (focused_position - 1) // messagePageSize + 1
messages = db.scalars(
message_query
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
@@ -198,6 +268,7 @@ def chat_detail(
"topics": topics,
"helpCards": [help_card_dict(item) for item in help_cards],
"shareDrafts": [share_draft_dict(item) for item in share_drafts],
"focusedMessageId": focusMessageId,
}
)
@@ -288,6 +359,8 @@ def question_insights(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
dateFrom = as_utc_naive(dateFrom) if dateFrom is not None else None
dateTo = as_utc_naive(dateTo) if dateTo is not None else None
return api_success(
QuestionInsightService.summarize(
db,
@@ -309,6 +382,8 @@ def refresh_question_insights(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
dateFrom = as_utc_naive(dateFrom) if dateFrom is not None else None
dateTo = as_utc_naive(dateTo) if dateTo is not None else None
result = QuestionInsightService.refresh(
db,
date_from=dateFrom,
@@ -334,6 +409,8 @@ def export_question_insights(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> StreamingResponse:
dateFrom = as_utc_naive(dateFrom) if dateFrom is not None else None
dateTo = as_utc_naive(dateTo) if dateTo is not None else None
if dateFrom is not None and dateTo is not None and dateFrom > dateTo:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="开始时间不能晚于结束时间")
result = QuestionInsightService.summarize(
@@ -387,9 +464,9 @@ def _chat_query(
if source_client_id is not None:
query = query.where(ChatSession.source_type == "sso", ChatSession.source_client_id == source_client_id)
if date_from is not None:
query = query.where(ChatSession.updated_at >= date_from.replace(tzinfo=None))
query = query.where(ChatSession.updated_at >= date_from)
if date_to is not None:
query = query.where(ChatSession.updated_at <= date_to.replace(tzinfo=None))
query = query.where(ChatSession.updated_at <= date_to)
if status:
query = query.where(
exists()

View File

@@ -3,12 +3,15 @@ from __future__ import annotations
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import Response
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.services.admin_service import OperationLogService
from app.services.user_behavior_export_service import UserBehaviorExportService
from app.services.user_behavior_service import UserBehaviorService
@@ -30,6 +33,8 @@ def behavior_users(
start: date | None = Query(default=None),
end: date | None = Query(default=None),
keyword: str = Query(default="", max_length=50),
sortBy: str = Query(default="lastEventAt", pattern="^(userName|phone|eventCount|lastEventAt)$"),
sortOrder: str = Query(default="desc", pattern="^(asc|desc)$"),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=5, le=100),
db: Session = Depends(get_db),
@@ -43,10 +48,42 @@ def behavior_users(
keyword=keyword,
page=page,
page_size=pageSize,
sort_by=sortBy,
sort_order=sortOrder,
)
)
@router.get("/user-behavior/export")
def export_behavior_report(
start: date | None = Query(default=None),
end: date | None = Query(default=None),
keyword: str = Query(default="", max_length=50),
sortBy: str = Query(default="lastEventAt", pattern="^(userName|phone|eventCount|lastEventAt)$"),
sortOrder: str = Query(default="desc", pattern="^(asc|desc)$"),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> Response:
stream = UserBehaviorExportService.build_workbook(
db,
start=start,
end=end,
keyword=keyword,
sort_by=sortBy,
sort_order=sortOrder,
)
OperationLogService.write(db, admin_id=current_admin.id, module="behavior", action="export")
db.commit()
start_label = start.isoformat() if start else "default"
end_label = end.isoformat() if end else "today"
filename = f"user_behavior_report_{start_label}_{end_label}.xlsx"
return Response(
content=stream.getvalue(),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/user-behavior/user/{user_id}/timeline")
def behavior_timeline(
user_id: int,

View File

@@ -17,6 +17,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.core.time_utils import as_utc_naive, business_datetime_to_utc_naive, business_day_boundary
from app.models.admin import Admin
from app.models.ai_config import SystemConfig
from app.models.entitlement import EntitlementPlan, UserEntitlement
@@ -475,7 +476,7 @@ def update_user(
if payload.dailyChatLimit is not None:
user.daily_chat_limit = payload.dailyChatLimit
if payload.expiredAt is not None:
user.expired_at = payload.expiredAt.replace(tzinfo=None)
user.expired_at = as_utc_naive(payload.expiredAt)
db.add(user)
if payload.entitlementPlanId is not None:
EntitlementService.assign_user_plan(
@@ -698,7 +699,7 @@ def _apply_user_payload(
user.nickname = payload.nickname.strip() if payload.nickname else None
user.status = payload.status
user.daily_chat_limit = payload.dailyChatLimit if payload.dailyChatLimit is not None else default_daily_limit
user.expired_at = payload.expiredAt.replace(tzinfo=None) if payload.expiredAt is not None else None
user.expired_at = as_utc_naive(payload.expiredAt) if payload.expiredAt is not None else None
def _default_daily_chat_limit(db: Session) -> int:
@@ -763,14 +764,17 @@ def _parse_optional_datetime(value: object | None) -> datetime | None:
if value is None or _cell_text(value) == "":
return None
if isinstance(value, datetime):
return value.replace(tzinfo=None)
return business_datetime_to_utc_naive(value)
if isinstance(value, date):
return datetime.combine(value, datetime.min.time())
return business_day_boundary(value, end_exclusive=True) - timedelta(microseconds=1)
text = _cell_text(value)
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d %H:%M:%S", "%Y/%m/%d"):
try:
return datetime.strptime(text, fmt)
parsed = datetime.strptime(text, fmt)
if "%H" not in fmt:
return business_day_boundary(parsed.date(), end_exclusive=True) - timedelta(microseconds=1)
return business_datetime_to_utc_naive(parsed)
except ValueError:
continue
raise ValueError("有效期格式应为 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss")

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import UTC, date, datetime, time, timedelta
from datetime import UTC, date, datetime, timedelta
from io import BytesIO
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -18,6 +18,7 @@ from app.api.pagination import page_result
from app.core.database import get_db
from app.core.dependencies import get_current_admin, get_current_user
from app.core.responses import api_success
from app.core.time_utils import business_day_boundary, to_business_naive
from app.models.admin import Admin
from app.models.chat import ChatMessage, ChatSession
from app.models.feedback import MessageFeedback
@@ -136,9 +137,9 @@ def _feedback_query(read_status: str, start_date: date | None, end_date: date |
if read_status != "all":
query = query.where(MessageFeedback.is_read == (1 if read_status == "read" else 0))
if start_date is not None:
query = query.where(MessageFeedback.created_at >= datetime.combine(start_date, time.min))
query = query.where(MessageFeedback.created_at >= business_day_boundary(start_date))
if end_date is not None:
query = query.where(MessageFeedback.created_at < datetime.combine(end_date + timedelta(days=1), time.min))
query = query.where(MessageFeedback.created_at < business_day_boundary(end_date, end_exclusive=True))
return query
@@ -159,8 +160,8 @@ def _feedback_workbook(rows: list[tuple]) -> Workbook:
_excel_safe_text(session.title),
message.id,
_excel_safe_text(ensure_ai_generated_notice(message.content)),
feedback.created_at,
feedback.read_at,
to_business_naive(feedback.created_at),
to_business_naive(feedback.read_at),
])
header_fill = PatternFill("solid", fgColor="1F6F5F")
for cell in sheet[1]: