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>