feat: improve async feedback and export workflow

This commit is contained in:
2026-08-11 18:45:39 +08:00
parent ff4ba1148b
commit 83b259af2d
11 changed files with 248 additions and 28 deletions

View File

@@ -1,8 +1,13 @@
from __future__ import annotations
from datetime import UTC, datetime
from datetime import UTC, date, datetime, time, timedelta
from io import BytesIO
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.worksheet.table import Table, TableStyleInfo
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
@@ -50,22 +55,50 @@ def create_feedback(payload: FeedbackCreate, db: Session = Depends(get_db), user
@router.get("/admin/list")
def feedback_list(readStatus: str = Query(default="all", pattern="^(all|read|unread)$"), page: int = Query(default=1, ge=1), pageSize: int = Query(default=20, ge=10, le=100), db: Session = Depends(get_db), _admin: Admin = Depends(get_current_admin)) -> dict:
def feedback_list(readStatus: str = Query(default="all", pattern="^(all|read|unread)$"), startDate: date | None = None, endDate: date | None = None, page: int = Query(default=1, ge=1), pageSize: int = Query(default=20, ge=10, le=100), db: Session = Depends(get_db), _admin: Admin = Depends(get_current_admin)) -> dict:
require_permission(_admin, "feedback.view")
query = select(MessageFeedback, User, ChatMessage).join(User, User.id == MessageFeedback.user_id).join(ChatMessage, ChatMessage.id == MessageFeedback.message_id)
if readStatus != "all":
query = query.where(MessageFeedback.is_read == (1 if readStatus == "read" else 0))
query = _feedback_query(readStatus, startDate, endDate)
total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
rows = db.execute(query.order_by(MessageFeedback.created_at.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
return api_success(page_result([_summary(*row) for row in rows], total=total, page=page, page_size=pageSize))
return api_success(page_result([_summary(row[0], row[1], row[2]) for row in rows], total=total, page=page, page_size=pageSize))
@router.get("/admin/export")
def export_feedback(
startDate: date,
endDate: date,
readStatus: str = Query(default="all", pattern="^(all|read|unread)$"),
db: Session = Depends(get_db),
admin: Admin = Depends(get_current_admin),
) -> StreamingResponse:
require_permission(admin, "feedback.export")
if endDate < startDate:
raise HTTPException(status_code=400, detail="结束日期不能早于开始日期")
if endDate - startDate > timedelta(days=366):
raise HTTPException(status_code=400, detail="单次导出时间范围不能超过 366 天")
rows = db.execute(_feedback_query(readStatus, startDate, endDate).order_by(MessageFeedback.created_at.desc()).limit(100001)).all()
if len(rows) > 100000:
raise HTTPException(status_code=400, detail="导出数据超过 10 万条,请缩小时间范围")
workbook = _feedback_workbook(rows)
stream = BytesIO()
workbook.save(stream)
stream.seek(0)
OperationLogService.write(db, admin_id=admin.id, module="feedback", action="export")
db.commit()
filename = f"feedback_{startDate:%Y%m%d}_{endDate:%Y%m%d}.xlsx"
return StreamingResponse(
stream,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/admin/{feedback_id}")
def feedback_detail(feedback_id: int, db: Session = Depends(get_db), admin: Admin = Depends(get_current_admin)) -> dict:
require_permission(admin, "feedback.detail")
row = db.execute(select(MessageFeedback, User, ChatMessage, ChatSession).join(User, User.id == MessageFeedback.user_id).join(ChatMessage, ChatMessage.id == MessageFeedback.message_id).join(ChatSession, ChatSession.id == MessageFeedback.session_id).where(MessageFeedback.id == feedback_id)).first()
if row is None:
raise HTTPException(status_code=404, detail="反馈不存在")
require_permission(admin, "feedback.view")
feedback, user, target, session = row
if not feedback.is_read:
feedback.is_read = 1
@@ -90,3 +123,66 @@ def delete_feedback(feedback_id: int, db: Session = Depends(get_db), admin: Admi
def _summary(item: MessageFeedback, user: User, message: ChatMessage) -> dict:
return {"id": item.id, "userId": user.id, "userName": user.name, "userPhone": user.phone, "messageId": message.id, "messageContent": message.content, "content": item.content, "isRead": bool(item.is_read), "createdAt": item.created_at}
def _feedback_query(read_status: str, start_date: date | None, end_date: date | None):
query = (
select(MessageFeedback, User, ChatMessage, ChatSession)
.join(User, User.id == MessageFeedback.user_id)
.join(ChatMessage, ChatMessage.id == MessageFeedback.message_id)
.join(ChatSession, ChatSession.id == MessageFeedback.session_id)
)
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))
if end_date is not None:
query = query.where(MessageFeedback.created_at < datetime.combine(end_date + timedelta(days=1), time.min))
return query
def _feedback_workbook(rows: list[tuple]) -> Workbook:
workbook = Workbook()
sheet = workbook.active
sheet.title = "反馈记录"
sheet.sheet_view.showGridLines = False
headers = ["序号", "状态", "用户姓名", "手机号", "反馈内容", "会话标题", "消息ID", "对应AI回答", "提交时间", "阅读时间"]
sheet.append(headers)
for index, (feedback, user, message, session) in enumerate(rows, start=1):
sheet.append([
index,
"已读" if feedback.is_read else "未读",
_excel_safe_text(user.name),
_excel_safe_text(user.phone),
_excel_safe_text(feedback.content),
_excel_safe_text(session.title),
message.id,
_excel_safe_text(message.content),
feedback.created_at,
feedback.read_at,
])
header_fill = PatternFill("solid", fgColor="1F6F5F")
for cell in sheet[1]:
cell.fill = header_fill
cell.font = Font(color="FFFFFF", bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center")
sheet.freeze_panes = "A2"
sheet.auto_filter.ref = f"A1:J{max(1, sheet.max_row)}"
widths = [8, 10, 16, 16, 38, 24, 12, 70, 20, 20]
for index, width in enumerate(widths, start=1):
sheet.column_dimensions[chr(64 + index)].width = width
for row in sheet.iter_rows(min_row=2):
row[4].alignment = Alignment(vertical="top", wrap_text=True)
row[7].alignment = Alignment(vertical="top", wrap_text=True)
for cell in (row[8], row[9]):
cell.number_format = "yyyy-mm-dd hh:mm:ss"
if sheet.max_row > 1:
table = Table(displayName="FeedbackRecords", ref=f"A1:J{sheet.max_row}")
table.tableStyleInfo = TableStyleInfo(name="TableStyleMedium2", showRowStripes=True, showFirstColumn=False, showLastColumn=False)
sheet.add_table(table)
return workbook
def _excel_safe_text(value: object | None) -> str:
text_value = str(value or "")
return f"'{text_value}" if text_value.startswith(("=", "+", "-", "@")) else text_value

View File

@@ -20,7 +20,7 @@ PERMISSION_TREE = [
{"code": "records", "name": "记录审计", "children": [{"code": "records.view", "name": "查看/导出记录"}]},
{"code": "retrievals", "name": "检索日志", "children": [{"code": "retrievals.view", "name": "查看检索日志"}]},
{"code": "attention", "name": "人工关注", "children": [{"code": "attention.view", "name": "查看关注项"}, {"code": "attention.edit", "name": "处理关注项"}]},
{"code": "feedback", "name": "反馈管理", "children": [{"code": "feedback.view", "name": "查看反馈"}, {"code": "feedback.delete", "name": "删除反馈"}]},
{"code": "feedback", "name": "反馈管理", "children": [{"code": "feedback.view", "name": "查看反馈列表/筛选分页"}, {"code": "feedback.detail", "name": "查看详情/标记已读"}, {"code": "feedback.export", "name": "导出反馈"}, {"code": "feedback.delete", "name": "删除反馈"}]},
{"code": "admins", "name": "管理员与权限", "superOnly": True, "children": [{"code": "admins.view", "name": "查看管理员"}, {"code": "admins.edit", "name": "新增/编辑管理员"}, {"code": "admins.delete", "name": "删除管理员"}]},
]