feat: improve async feedback and export workflow
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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": "删除管理员"}]},
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user