feat(feedback): implement user feedback collection system with email reporting

Backend:
- Add user_feedback table with RLS policy
- Create feedback submission API (multipart/form-data)
- Implement xlsx report generation with embedded images
- Add scheduled email delivery via Feishu SMTP
- Create HTML email templates (daily_report, no_feedback)

Frontend:
- Add feedback screen with type selection and image picker
- Support anonymous submission via skipAuth flag
- Collect device info and app version

Protocol:
- Document feedback API contract and error codes
- Update http-error-codes.md with FEEDBACK_* codes
This commit is contained in:
qzl
2026-04-20 12:49:54 +08:00
parent 913ed26f8d
commit 6a2a9d2c87
46 changed files with 4768 additions and 9 deletions
+25
View File
@@ -153,8 +153,13 @@ class StorageSettings(BaseModel):
bucket: str = Field(default="avatars", min_length=3, max_length=63)
max_size_mb: int = Field(default=2, ge=1, le=10)
class FeedbackSettings(BaseModel):
bucket: str = Field(default="feedback-images", min_length=3, max_length=63)
max_size_mb: int = Field(default=5, ge=1, le=20)
attachment: AttachmentSettings = Field(default_factory=AttachmentSettings)
avatar: AvatarSettings = Field(default_factory=AvatarSettings)
feedback: FeedbackSettings = Field(default_factory=FeedbackSettings)
class LlmSettings(BaseModel):
@@ -235,6 +240,22 @@ def _resolve_env_file() -> str:
PROJECT_ROOT = _resolve_project_root()
class FeedbackReportSettings(BaseModel):
email: str = Field(default="support@example.com", description="客服邮箱")
cron: str = Field(default="0 10 * * *", description="报告生成cron表达式")
enabled: bool = Field(default=False, description="是否启用报告推送")
class EmailSettings(BaseModel):
host: str = Field(default="smtp.feishu.cn", description="SMTP 服务器地址")
port: int = Field(default=465, ge=1, le=65535, description="SMTP 端口")
username: str = Field(default="", description="SMTP 用户名")
password: SecretStr = Field(default=SecretStr(""), description="SMTP 密码")
use_ssl: bool = Field(default=True, description="是否使用 SSL")
from_address: str = Field(default="noreply@example.com", description="发件人地址")
from_name: str = Field(default="Eryao Feedback", description="发件人显示名称")
class Settings(BaseSettings):
runtime: RuntimeSettings = RuntimeSettings()
cors: CorsSettings = CorsSettings()
@@ -250,6 +271,10 @@ class Settings(BaseSettings):
taskiq: TaskiqSettings = Field(default_factory=TaskiqSettings)
agent_runtime: AgentRuntimeSettings = Field(default_factory=AgentRuntimeSettings)
points_policy: PointsPolicySettings = Field(default_factory=PointsPolicySettings)
feedback_report: FeedbackReportSettings = Field(
default_factory=FeedbackReportSettings
)
email: EmailSettings = Field(default_factory=EmailSettings)
@computed_field
@property
View File
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from dataclasses import dataclass, field
from email import encoders
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import aiosmtplib
from structlog import get_logger
from core.config.settings import config
logger = get_logger("core.email.sender")
@dataclass
class EmailAttachment:
filename: str
content: bytes
content_type: str = "application/octet-stream"
@dataclass
class EmailMessage:
to: str
subject: str
body_html: str
attachments: list[EmailAttachment] = field(default_factory=list)
class EmailSender:
def __init__(self) -> None:
self._settings = config.email
async def send(self, message: EmailMessage) -> bool:
msg = MIMEMultipart()
msg["From"] = f"{self._settings.from_name} <{self._settings.from_address}>"
msg["To"] = message.to
msg["Subject"] = message.subject
msg.attach(MIMEText(message.body_html, "html", "utf-8"))
for attachment in message.attachments:
part = MIMEBase("application", "octet-stream")
part.set_payload(attachment.content)
encoders.encode_base64(part)
part.add_header(
"Content-Disposition",
f"attachment; filename*=UTF-8''{attachment.filename}",
)
msg.attach(part)
try:
await aiosmtplib.send(
msg,
hostname=self._settings.host,
port=self._settings.port,
username=self._settings.username,
password=self._settings.password.get_secret_value(),
use_tls=self._settings.use_ssl,
)
logger.info("Email sent", to=message.to, subject=message.subject)
return True
except Exception as e:
logger.error(
"Failed to send email",
to=message.to,
subject=message.subject,
error=str(e),
)
raise
email_sender = EmailSender()
+16
View File
@@ -0,0 +1,16 @@
from __future__ import annotations
from pathlib import Path
from string import Template
from structlog import get_logger
logger = get_logger("core.email.template_loader")
_TEMPLATES_DIR = Path(__file__).parent / "templates"
def load_template(category: str, name: str) -> Template:
template_path = _TEMPLATES_DIR / category / name
content = template_path.read_text(encoding="utf-8")
return Template(content)
@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; background-color: #f4f5f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f5f7; padding: 32px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<tr>
<td style="background-color: #4472C4; padding: 24px 32px;">
<h1 style="margin: 0; color: #ffffff; font-size: 20px; font-weight: 600;">
用户反馈日报
</h1>
</td>
</tr>
<tr>
<td style="padding: 24px 32px;">
<p style="margin: 0 0 16px; color: #333; font-size: 14px; line-height: 1.6;">
您好,
</p>
<p style="margin: 0 0 16px; color: #333; font-size: 14px; line-height: 1.6;">
以下是 <strong>${start_date} ${start_hour}:00</strong>
<strong>${end_date} ${end_hour}:00</strong> 期间的用户反馈汇总。
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0;">
<tr>
<td style="padding: 16px; background-color: #f0f4ff; border-radius: 6px; text-align: center; width: 33%;">
<p style="margin: 0; font-size: 28px; font-weight: 700; color: #4472C4;">${total_count}</p>
<p style="margin: 4px 0 0; font-size: 12px; color: #666;">反馈总数</p>
</td>
<td style="padding: 16px; background-color: #fff3e0; border-radius: 6px; text-align: center; width: 33%;">
<p style="margin: 0; font-size: 28px; font-weight: 700; color: #ed6c02;">${bug_count}</p>
<p style="margin: 4px 0 0; font-size: 12px; color: #666;">问题反馈</p>
</td>
<td style="padding: 16px; background-color: #e8f5e9; border-radius: 6px; text-align: center; width: 33%;">
<p style="margin: 0; font-size: 28px; font-weight: 700; color: #2e7d32;">${suggestion_count}</p>
<p style="margin: 4px 0 0; font-size: 12px; color: #666;">功能建议</p>
</td>
</tr>
</table>
<p style="margin: 16px 0 0; color: #666; font-size: 13px; line-height: 1.6;">
详细反馈内容及截图请查看附件中的 xlsx 报告。
</p>
</td>
</tr>
<tr>
<td style="padding: 16px 32px; border-top: 1px solid #eee; background-color: #fafafa;">
<p style="margin: 0; font-size: 11px; color: #999; text-align: center;">
此邮件由 Eryao 反馈系统自动发送,请勿直接回复。<br>
报告生成时间:${generated_at}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; background-color: #f4f5f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f5f7; padding: 32px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<tr>
<td style="background-color: #95a5a6; padding: 24px 32px;">
<h1 style="margin: 0; color: #ffffff; font-size: 20px; font-weight: 600;">
用户反馈日报
</h1>
</td>
</tr>
<tr>
<td style="padding: 32px;">
<p style="margin: 0 0 16px; color: #333; font-size: 14px; line-height: 1.6;">
您好,
</p>
<p style="margin: 0 0 16px; color: #333; font-size: 14px; line-height: 1.6;">
<strong>${start_date} ${start_hour}:00</strong>
<strong>${end_date} ${end_hour}:00</strong> 期间暂无用户反馈。
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0;">
<tr>
<td style="padding: 24px; background-color: #f8f9fa; border-radius: 6px; text-align: center;">
<p style="margin: 0; font-size: 32px;">📭</p>
<p style="margin: 8px 0 0; font-size: 14px; color: #999;">
今日无反馈数据
</p>
</td>
</tr>
</table>
<p style="margin: 16px 0 0; color: #999; font-size: 12px; line-height: 1.6;">
如有反馈,系统将在下一个报告周期自动推送。
</p>
</td>
</tr>
<tr>
<td style="padding: 16px 32px; border-top: 1px solid #eee; background-color: #fafafa;">
<p style="margin: 0; font-size: 11px; color: #999; text-align: center;">
此邮件由 Eryao 反馈系统自动发送,请勿直接回复。<br>
报告生成时间:${generated_at}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
import uuid
from sqlalchemy import Index, String, Text, text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from core.db.base import Base, TimestampMixin
class UserFeedback(TimestampMixin, Base):
__tablename__ = "user_feedback"
__table_args__ = (
Index("ix_user_feedback_user_id", "user_id"),
Index("ix_user_feedback_created_at", "created_at"),
Index("ix_user_feedback_status", "status"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
server_default=text("gen_random_uuid()"),
primary_key=True,
)
user_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
feedback_type: Mapped[str] = mapped_column(
String(20), nullable=False, server_default="other"
)
content: Mapped[str] = mapped_column(Text, nullable=False)
images: Mapped[list[str]] = mapped_column(
JSONB, nullable=False, server_default=text("'[]'::jsonb"), default=list
)
device_info: Mapped[dict] = mapped_column(
JSONB, nullable=False, server_default=text("'{}'::jsonb"), default=dict
)
app_version: Mapped[str] = mapped_column(String(20), nullable=False)
os_version: Mapped[str] = mapped_column(String(50), nullable=False)
status: Mapped[str] = mapped_column(
String(20), nullable=False, server_default="pending"
)
+2
View File
@@ -110,6 +110,7 @@ class SupabaseService(BaseServiceProvider):
buckets = [
(config.storage.attachment.bucket, False),
(config.storage.avatar.bucket, True),
(config.storage.feedback.bucket, False),
]
def _check_and_create() -> None:
@@ -170,6 +171,7 @@ class SupabaseService(BaseServiceProvider):
allowed_buckets = {
config.storage.attachment.bucket,
config.storage.avatar.bucket,
config.storage.feedback.bucket,
}
if bucket not in allowed_buckets:
raise RuntimeError("Invalid storage bucket")
View File
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
import asyncio
from typing import Annotated
from uuid import UUID
from fastapi import Depends, Header
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth.models import CurrentUser
from core.db import get_db
from services.base.supabase import supabase_service
from v1.feedback.repository import FeedbackRepository
from v1.feedback.service import FeedbackService
async def get_optional_user(
authorization: str | None = Header(default=None),
) -> CurrentUser | None:
if not authorization:
return None
scheme, _, token = authorization.partition(" ")
if scheme.lower() != "bearer" or not token:
return None
try:
client = supabase_service.get_client()
response = await asyncio.to_thread(client.auth.get_user, token)
user = getattr(response, "user", None)
user_id = getattr(user, "id", None)
if not isinstance(user_id, str) or not user_id:
return None
return CurrentUser(
id=UUID(user_id),
email=getattr(user, "email", None),
role=getattr(user, "role", None),
)
except Exception:
return None
def get_feedback_service(
session: Annotated[AsyncSession, Depends(get_db)],
) -> FeedbackService:
return FeedbackService(
repository=FeedbackRepository(session=session),
storage=supabase_service,
)
+220
View File
@@ -0,0 +1,220 @@
from __future__ import annotations
from datetime import datetime, timezone
from io import BytesIO
from pathlib import Path
from tempfile import gettempdir
from openpyxl import Workbook
from openpyxl.drawing.image import Image as XLImage
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from structlog import get_logger
from core.config.settings import config
from models.user_feedback import UserFeedback
from services.base.supabase import supabase_service
logger = get_logger("v1.feedback.report")
IMAGE_PREVIEW_WIDTH_PX = 100
IMAGE_PREVIEW_HEIGHT_PX = 100
IMAGE_COL_WIDTH = 15
IMAGE_ROW_HEIGHT = 80
async def download_image_from_storage(storage_path: str) -> bytes:
bucket_name = config.storage.feedback.bucket
try:
return await supabase_service.download_bytes(
bucket=bucket_name, path=storage_path
)
except Exception as e:
logger.warning("Failed to download image", path=storage_path, error=str(e))
raise
def _embed_image_in_cell(ws, img_data: bytes, row: int, col: int) -> bool:
try:
img_buffer = BytesIO(img_data)
xl_img = XLImage(img_buffer)
xl_img.width = IMAGE_PREVIEW_WIDTH_PX
xl_img.height = IMAGE_PREVIEW_HEIGHT_PX
xl_img.anchor = f"{get_column_letter(col)}{row}"
ws.add_image(xl_img)
return True
except Exception as e:
logger.warning("Failed to embed image", row=row, col=col, error=str(e))
return False
async def generate_feedback_report(
feedbacks: list[UserFeedback],
*,
output_dir: Path | None = None,
filename_prefix: str = "feedback_report",
) -> Path:
wb = Workbook()
ws = wb.active
assert ws is not None
ws.title = "用户反馈"
header_font = Font(bold=True, color="FFFFFF", size=11)
header_fill = PatternFill(
start_color="4472C4", end_color="4472C4", fill_type="solid"
)
header_alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
thin_border = Border(
left=Side(style="thin", color="D9D9D9"),
right=Side(style="thin", color="D9D9D9"),
top=Side(style="thin", color="D9D9D9"),
bottom=Side(style="thin", color="D9D9D9"),
)
center_alignment = Alignment(horizontal="center", vertical="center")
headers = [
"序号",
"提交时间",
"反馈类型",
"用户身份",
"设备信息",
"App版本",
"系统版本",
"反馈内容",
"图片数量",
"状态",
"截图1",
"截图2",
"截图3",
]
col_widths = [
6,
18,
12,
12,
25,
10,
14,
50,
10,
10,
IMAGE_COL_WIDTH,
IMAGE_COL_WIDTH,
IMAGE_COL_WIDTH,
]
for col, (header, width) in enumerate(zip(headers, col_widths), start=1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = thin_border
ws.column_dimensions[get_column_letter(col)].width = width
ws.row_dimensions[1].height = 25
ws.freeze_panes = "A2"
type_colors = {
"bug": PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid"),
"suggestion": PatternFill(
start_color="E2EFDA", end_color="E2EFDA", fill_type="solid"
),
"other": PatternFill(
start_color="FFF2CC", end_color="FFF2CC", fill_type="solid"
),
}
for row_idx, fb in enumerate(feedbacks, start=2):
ws.cell(row=row_idx, column=1, value=row_idx - 1).alignment = center_alignment
ws.cell(row=row_idx, column=2, value=fb.created_at.strftime("%Y-%m-%d %H:%M"))
type_cell = ws.cell(row=row_idx, column=3, value=fb.feedback_type)
type_cell.alignment = center_alignment
type_cell.fill = type_colors.get(fb.feedback_type, PatternFill())
if fb.user_id:
ws.cell(row=row_idx, column=4, value=f"用户:{str(fb.user_id)[:8]}...")
else:
ws.cell(row=row_idx, column=4, value="匿名").alignment = center_alignment
device_info = fb.device_info or {}
ws.cell(
row=row_idx,
column=5,
value=f"{device_info.get('platform', '-')} / {device_info.get('model', '-')}",
)
ws.cell(
row=row_idx, column=6, value=fb.app_version
).alignment = center_alignment
ws.cell(row=row_idx, column=7, value=fb.os_version)
content_cell = ws.cell(row=row_idx, column=8, value=fb.content)
content_cell.alignment = Alignment(vertical="top", wrap_text=True)
ws.cell(
row=row_idx, column=9, value=len(fb.images) if fb.images else 0
).alignment = center_alignment
status_cell = ws.cell(row=row_idx, column=10, value=fb.status)
status_cell.alignment = center_alignment
if fb.status == "pending":
status_cell.fill = PatternFill(
start_color="FFF2CC", end_color="FFF2CC", fill_type="solid"
)
elif fb.status == "processed":
status_cell.fill = PatternFill(
start_color="C6EFCE", end_color="C6EFCE", fill_type="solid"
)
ws.row_dimensions[row_idx].height = IMAGE_ROW_HEIGHT
images = fb.images or []
for img_idx in range(3):
img_col = 11 + img_idx
img_cell = ws.cell(row=row_idx, column=img_col, value="")
img_cell.border = thin_border
if img_idx < len(images):
try:
img_data = await download_image_from_storage(images[img_idx])
success = _embed_image_in_cell(ws, img_data, row_idx, img_col)
if not success:
img_cell.value = "加载失败"
img_cell.alignment = center_alignment
except Exception:
img_cell.value = "加载失败"
img_cell.alignment = center_alignment
else:
img_cell.value = "-"
img_cell.alignment = center_alignment
for col in range(1, 11):
ws.cell(row=row_idx, column=col).border = thin_border
ws.auto_filter.ref = (
f"A1:{get_column_letter(len(headers))}{max(2, len(feedbacks) + 1)}"
)
if output_dir is None:
output_dir = Path(gettempdir())
output_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
report_path = output_dir / f"{filename_prefix}_{timestamp}.xlsx"
wb.save(report_path)
logger.info(
"Feedback report generated", path=str(report_path), count=len(feedbacks)
)
return report_path
async def cleanup_report_file(report_path: Path) -> None:
try:
if report_path.exists():
report_path.unlink()
logger.info("Report file cleaned up", path=str(report_path))
except Exception as e:
logger.warning(
"Failed to cleanup report file", path=str(report_path), error=str(e)
)
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from models.user_feedback import UserFeedback
@dataclass
class FeedbackRepository:
session: AsyncSession
async def create_feedback(
self,
*,
user_id: UUID | None,
feedback_type: str,
content: str,
images: list[str],
device_info: dict,
app_version: str,
os_version: str,
) -> UserFeedback:
feedback = UserFeedback(
user_id=user_id,
feedback_type=feedback_type,
content=content,
images=images,
device_info=device_info,
app_version=app_version,
os_version=os_version,
)
self.session.add(feedback)
await self.session.flush()
return feedback
async def save(self) -> None:
await self.session.commit()
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
import json
from typing import Annotated
from fastapi import APIRouter, Depends, File, Form, UploadFile
from core.auth.models import CurrentUser
from core.http.errors import ApiProblemError, problem_payload
from v1.feedback.dependencies import get_feedback_service, get_optional_user
from v1.feedback.schemas import FeedbackCreateResponse
from v1.feedback.service import FeedbackService
router = APIRouter(prefix="/feedback", tags=["feedback"])
@router.post("", response_model=FeedbackCreateResponse, status_code=201)
async def create_feedback(
feedback_type: Annotated[str, Form(...)],
content: Annotated[str, Form(...)],
device_info: Annotated[str, Form(...)],
app_version: Annotated[str, Form(...)],
os_version: Annotated[str, Form(...)],
images: list[UploadFile] = File(default=[]),
user: CurrentUser | None = Depends(get_optional_user),
service: FeedbackService = Depends(get_feedback_service),
) -> FeedbackCreateResponse:
if len(images) > 3:
raise ApiProblemError(
status_code=400,
detail=problem_payload(
code="FEEDBACK_TOO_MANY_IMAGES",
detail="Maximum 3 images allowed",
),
)
try:
device_info_dict = json.loads(device_info)
except (json.JSONDecodeError, TypeError) as exc:
raise ApiProblemError(
status_code=422,
detail=problem_payload(
code="REQUEST_VALIDATION_ERROR",
detail="Invalid device_info JSON",
),
) from exc
user_id = user.id if isinstance(user, CurrentUser) else None
return await service.submit_feedback(
feedback_type=feedback_type,
content=content,
device_info=device_info_dict,
app_version=app_version,
os_version=os_version,
images=images,
user_id=user_id,
)
+15
View File
@@ -0,0 +1,15 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, ConfigDict
FeedbackType = Literal["bug", "suggestion", "other"]
FeedbackStatus = Literal["pending", "processed"]
class FeedbackCreateResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str
created_at: str
+165
View File
@@ -0,0 +1,165 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from uuid import UUID
from fastapi import UploadFile
from structlog import get_logger
from core.config.settings import config
from core.http.errors import ApiProblemError, problem_payload
from services.base.supabase import SupabaseService
from v1.feedback.repository import FeedbackRepository
from v1.feedback.schemas import FeedbackCreateResponse
logger = get_logger("v1.feedback.service")
_MAX_IMAGES = 3
_ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png"}
_ALLOWED_FEEDBACK_TYPES = {"bug", "suggestion", "other"}
@dataclass
class FeedbackService:
repository: FeedbackRepository
storage: SupabaseService
async def submit_feedback(
self,
*,
feedback_type: str,
content: str,
device_info: dict,
app_version: str,
os_version: str,
images: list[UploadFile],
user_id: UUID | None,
) -> FeedbackCreateResponse:
self._validate_feedback_type(feedback_type)
self._validate_content(content)
self._validate_images(images)
image_paths: list[str] = []
if images:
image_paths = await self._upload_images(images)
feedback = await self.repository.create_feedback(
user_id=user_id,
feedback_type=feedback_type,
content=content,
images=image_paths,
device_info=device_info,
app_version=app_version,
os_version=os_version,
)
await self.repository.save()
logger.info(
"Feedback submitted",
feedback_id=str(feedback.id),
user_id=str(user_id) if user_id else "anonymous",
image_count=len(image_paths),
)
return FeedbackCreateResponse(
id=str(feedback.id),
created_at=feedback.created_at.isoformat(),
)
def _validate_feedback_type(self, feedback_type: str) -> None:
if feedback_type not in _ALLOWED_FEEDBACK_TYPES:
raise ApiProblemError(
status_code=422,
detail=problem_payload(
code="REQUEST_VALIDATION_ERROR",
detail=f"Invalid feedback_type: {feedback_type}",
),
)
def _validate_content(self, content: str) -> None:
stripped = content.strip()
if not stripped:
raise ApiProblemError(
status_code=400,
detail=problem_payload(
code="FEEDBACK_CONTENT_EMPTY",
detail="Feedback content must not be empty",
),
)
if len(stripped) > 500:
raise ApiProblemError(
status_code=400,
detail=problem_payload(
code="FEEDBACK_CONTENT_TOO_LONG",
detail="Feedback content exceeds 500 characters",
),
)
def _validate_images(self, images: list[UploadFile]) -> None:
if len(images) > _MAX_IMAGES:
raise ApiProblemError(
status_code=400,
detail=problem_payload(
code="FEEDBACK_TOO_MANY_IMAGES",
detail="Maximum 3 images allowed",
),
)
for image in images:
content_type = (image.content_type or "").lower().strip()
if content_type and content_type not in _ALLOWED_CONTENT_TYPES:
raise ApiProblemError(
status_code=400,
detail=problem_payload(
code="FEEDBACK_INVALID_IMAGE_TYPE",
detail=f"Unsupported image type: {content_type}",
),
)
async def _upload_images(self, images: list[UploadFile]) -> list[str]:
bucket = config.storage.feedback.bucket
max_bytes = config.storage.feedback.max_size_mb * 1024 * 1024
timestamp_prefix = datetime.now(timezone.utc).strftime("%Y-%m-%d")
paths: list[str] = []
for i, image in enumerate(images):
content = await image.read()
if len(content) > max_bytes:
raise ApiProblemError(
status_code=400,
detail=problem_payload(
code="FEEDBACK_IMAGE_TOO_LARGE",
detail=f"Image too large: {image.filename}",
),
)
content_type = image.content_type or "image/jpeg"
ext = "jpg" if content_type == "image/jpeg" else "png"
storage_path = (
f"{timestamp_prefix}/{datetime.now(timezone.utc).timestamp()}_{i}.{ext}"
)
try:
await self.storage.upload_bytes(
bucket=bucket,
path=storage_path,
content=content,
content_type=content_type,
)
except Exception as exc:
logger.exception(
"Feedback image upload failed",
path=storage_path,
filename=image.filename,
)
raise ApiProblemError(
status_code=500,
detail=problem_payload(
code="FEEDBACK_SUBMIT_FAILED",
detail="Failed to upload feedback images",
),
) from exc
paths.append(storage_path)
return paths
+226
View File
@@ -0,0 +1,226 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from pathlib import Path
from sqlalchemy import select
from structlog import get_logger
from taskiq_redis import RedisScheduleSource
from core.config.settings import config
from core.db.session import AsyncSessionLocal
from core.email.sender import EmailAttachment, EmailMessage, EmailSender
from core.email.template_loader import load_template
from core.taskiq.app import worker_general_broker
from models.user_feedback import UserFeedback
from v1.feedback.report import generate_feedback_report
logger = get_logger("v1.feedback.tasks")
async def _fetch_pending_feedbacks_by_time_range(
start_time: datetime, end_time: datetime
) -> list[UserFeedback]:
async with AsyncSessionLocal() as session:
stmt = (
select(UserFeedback)
.where(UserFeedback.created_at >= start_time)
.where(UserFeedback.created_at < end_time)
.where(UserFeedback.status == "pending")
.order_by(UserFeedback.created_at.desc())
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def _mark_feedbacks_processed(feedback_ids: list) -> None:
if not feedback_ids:
return
async with AsyncSessionLocal() as session:
stmt = select(UserFeedback).where(UserFeedback.id.in_(feedback_ids))
result = await session.execute(stmt)
for fb in result.scalars().all():
fb.status = "processed"
fb.updated_at = datetime.now(timezone.utc)
await session.commit()
logger.info("Feedbacks marked as processed", count=len(feedback_ids))
def _build_report_email_html(
feedbacks: list[UserFeedback],
start_time: datetime,
end_time: datetime,
push_hour: int,
) -> str:
template = load_template("feedback", "daily_report.html")
return template.substitute(
start_date=start_time.strftime("%Y-%m-%d"),
start_hour=str(push_hour),
end_date=end_time.strftime("%Y-%m-%d"),
end_hour=str(push_hour),
total_count=len(feedbacks),
bug_count=sum(1 for fb in feedbacks if fb.feedback_type == "bug"),
suggestion_count=sum(1 for fb in feedbacks if fb.feedback_type == "suggestion"),
generated_at=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
)
def _build_no_feedback_email_html(
start_time: datetime,
end_time: datetime,
push_hour: int,
) -> str:
template = load_template("feedback", "no_feedback.html")
return template.substitute(
start_date=start_time.strftime("%Y-%m-%d"),
start_hour=str(push_hour),
end_date=end_time.strftime("%Y-%m-%d"),
end_hour=str(push_hour),
generated_at=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
)
async def _send_feedback_email(
feedbacks: list[UserFeedback],
start_time: datetime,
end_time: datetime,
push_hour: int,
report_path: Path | None = None,
) -> bool:
sender = EmailSender()
if feedbacks:
body_html = _build_report_email_html(feedbacks, start_time, end_time, push_hour)
subject = f"用户反馈日报 - {start_time.strftime('%Y-%m-%d')}"
else:
body_html = _build_no_feedback_email_html(start_time, end_time, push_hour)
subject = f"用户反馈日报(无反馈)- {start_time.strftime('%Y-%m-%d')}"
attachments: list[EmailAttachment] = []
if report_path is not None and report_path.exists():
attachments.append(
EmailAttachment(
filename=report_path.name,
content=report_path.read_bytes(),
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
)
message = EmailMessage(
to=config.feedback_report.email,
subject=subject,
body_html=body_html,
attachments=attachments,
)
await sender.send(message)
logger.info(
"Feedback report email sent",
to=config.feedback_report.email,
has_feedback=len(feedbacks) > 0,
has_attachment=len(attachments) > 0,
)
return True
# type: ignore reportArgumentType for taskiq decorator
@worker_general_broker.on_event("startup") # type: ignore[arg-type]
async def _register_feedback_report_schedule() -> None:
if not config.feedback_report.enabled:
logger.info("Feedback report scheduling disabled")
return
schedule_source = RedisScheduleSource(
url=config.taskiq_broker_url,
prefix="schedule:feedback",
)
await schedule_source.startup()
await generate_daily_feedback_report.schedule_by_cron(
source=schedule_source,
cron=config.feedback_report.cron,
)
logger.info(
"Feedback report cron registered",
cron=config.feedback_report.cron,
)
@worker_general_broker.task(task_name="tasks.feedback.generate_daily_report")
async def generate_daily_feedback_report() -> str | None:
if not config.feedback_report.enabled:
logger.info("Feedback report is disabled, skipping")
return None
now = datetime.now(timezone.utc)
push_hour = 10
end_time = now.replace(hour=push_hour, minute=0, second=0, microsecond=0)
start_time = end_time - timedelta(days=1)
feedbacks = await _fetch_pending_feedbacks_by_time_range(start_time, end_time)
logger.info(
"Feedback query result",
count=len(feedbacks),
start=start_time,
end=end_time,
)
report_path: Path | None = None
try:
if feedbacks:
reports_dir = Path(config.runtime.log_dir).parent / "reports"
report_path = await generate_feedback_report(
feedbacks, output_dir=reports_dir
)
logger.info("Report generated", path=str(report_path))
await _mark_feedbacks_processed([fb.id for fb in feedbacks])
await _send_feedback_email(
feedbacks=feedbacks,
start_time=start_time,
end_time=end_time,
push_hour=push_hour,
report_path=report_path,
)
finally:
if report_path and report_path.exists():
report_path.unlink()
logger.info("Report file cleaned up", path=str(report_path))
return str(report_path) if report_path else None
async def generate_all_feedback_report() -> Path:
async with AsyncSessionLocal() as session:
stmt = select(UserFeedback).order_by(UserFeedback.created_at.desc())
result = await session.execute(stmt)
feedbacks = list(result.scalars().all())
if not feedbacks:
raise ValueError("No feedbacks to report")
logger.info("Generating all feedback report", count=len(feedbacks))
reports_dir = Path(config.runtime.log_dir).parent / "reports"
report_path = await generate_feedback_report(feedbacks, output_dir=reports_dir)
await _mark_feedbacks_processed([fb.id for fb in feedbacks])
return report_path
async def send_feedback_report_email(
feedbacks: list[UserFeedback],
start_time: datetime,
end_time: datetime,
push_hour: int,
report_path: Path | None = None,
) -> bool:
return await _send_feedback_email(
feedbacks=feedbacks,
start_time=start_time,
end_time=end_time,
push_hour=push_hour,
report_path=report_path,
)
+2
View File
@@ -4,6 +4,7 @@ from fastapi import APIRouter
from v1.agent.router import router as agent_router
from v1.auth.router import router as auth_router
from v1.feedback.router import router as feedback_router
from v1.invite.router import router as invite_router
from v1.notifications.router import router as notifications_router
from v1.points.router import router as points_router
@@ -13,6 +14,7 @@ from v1.users.router import router as users_router
router = APIRouter(prefix="/api/v1")
router.include_router(auth_router)
router.include_router(agent_router)
router.include_router(feedback_router)
router.include_router(invite_router)
router.include_router(notifications_router)
router.include_router(points_router)