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>
|
||||
Reference in New Issue
Block a user