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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user