1693 lines
60 KiB
Markdown
1693 lines
60 KiB
Markdown
|
|
# PRD: 用户反馈投送功能
|
|||
|
|
|
|||
|
|
## 1. 需求概述
|
|||
|
|
|
|||
|
|
### 1.1 功能目标
|
|||
|
|
|
|||
|
|
实现用户反馈投送功能,为用户提供便捷的问题反馈和建议提交渠道:
|
|||
|
|
- **Phase 1(当前)**:前端反馈表单 + 后端数据存储 + 图片上传
|
|||
|
|
- **Phase 2(后续)**:定时报告生成 + 邮件推送
|
|||
|
|
|
|||
|
|
### 1.2 业务价值
|
|||
|
|
|
|||
|
|
- 提升用户体验:用户可快速反馈问题和建议
|
|||
|
|
- 收集产品改进方向:结构化反馈数据便于分析
|
|||
|
|
- Bug 排查:用户反馈可关联设备信息、截图、操作步骤,帮助定位问题
|
|||
|
|
- 合规要求:App Store 要求提供用户反馈渠道
|
|||
|
|
|
|||
|
|
## 2. 用户故事
|
|||
|
|
|
|||
|
|
### 2.1 提交反馈
|
|||
|
|
|
|||
|
|
**作为** 用户
|
|||
|
|
**我想要** 在 App 内快速提交问题反馈或建议,并可附带截图
|
|||
|
|
**以便于** 开发团队能及时了解并解决我的问题
|
|||
|
|
|
|||
|
|
**验收标准**:
|
|||
|
|
- 用户可在设置页找到"意见反馈"入口
|
|||
|
|
- 点击后进入反馈表单页面
|
|||
|
|
- 可选择反馈类型(问题反馈 / 功能建议 / 其他)
|
|||
|
|
- 可输入反馈内容(必填,最多 500 字)
|
|||
|
|
- 可上传最多 3 张图片(截图)
|
|||
|
|
- 可勾选"不上传我的个人信息"(勾选后不采集 user_id)
|
|||
|
|
- 提交后显示成功提示
|
|||
|
|
|
|||
|
|
## 3. 功能清单
|
|||
|
|
|
|||
|
|
### 3.1 前端功能
|
|||
|
|
|
|||
|
|
| 功能 | 描述 | 优先级 |
|
|||
|
|
|------|------|--------|
|
|||
|
|
| 反馈入口 | 设置页添加"意见反馈"菜单项 | P0 |
|
|||
|
|
| 反馈表单页 | 包含类型选择、内容输入、图片上传、隐私勾选 | P0 |
|
|||
|
|
| 隐私勾选框 | "不上传我的个人信息",勾选后不采集 user_id | P0 |
|
|||
|
|
| 表单验证 | 内容必填、字数限制、图片数量限制 | P0 |
|
|||
|
|
| 图片上传 | 上传到 Supabase Storage,最多 3 张 | P0 |
|
|||
|
|
| 提交反馈 | 调用 API 提交,显示结果 | P0 |
|
|||
|
|
| 加载状态 | 提交时显示 loading | P1 |
|
|||
|
|
| 错误处理 | 网络错误、服务端错误提示 | P1 |
|
|||
|
|
|
|||
|
|
### 3.2 后端功能
|
|||
|
|
|
|||
|
|
| 功能 | 描述 | 优先级 |
|
|||
|
|
|------|------|--------|
|
|||
|
|
| 反馈提交 API | POST /api/v1/feedback | P0 |
|
|||
|
|
| 数据库存储 | user_feedback 表 | P0 |
|
|||
|
|
| 图片存储 | Supabase Storage(自动创建 bucket) | P0 |
|
|||
|
|
| Bucket 自动创建 | 复用 SupabaseService._ensure_storage_bucket() | P0 |
|
|||
|
|
| 报告生成(Phase 2) | xlsx 格式,根据匿名状态决定是否包含用户数据 | P1 |
|
|||
|
|
| 定时任务(Phase 2) | worker-general 进程,每日整理报告并发送邮件 | P1 |
|
|||
|
|
| 邮件发送(Phase 2) | 发送到客服邮箱 | P1 |
|
|||
|
|
|
|||
|
|
## 4. 技术方案
|
|||
|
|
|
|||
|
|
### 4.0 图片上传流程
|
|||
|
|
|
|||
|
|
**完整流程**(前端上传文件 → 后端处理):
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
1. 用户从相册选择图片(最多3张)
|
|||
|
|
↓
|
|||
|
|
2. 前端通过 multipart/form-data 上传所有数据(图片 + 表单)
|
|||
|
|
↓
|
|||
|
|
3. 后端接收文件,上传到 Supabase Storage
|
|||
|
|
↓
|
|||
|
|
4. 后端保存图片路径到数据库(非公开 URL)
|
|||
|
|
↓
|
|||
|
|
5. 返回提交成功
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**关键点**:
|
|||
|
|
- 前端使用 `image_picker` 选择图片,通过 `multipart/form-data` 上传
|
|||
|
|
- 后端统一处理文件上传,无需前端操作 Storage
|
|||
|
|
- 图片存储为私有路径,不需要公开访问(用户不看自己的反馈)
|
|||
|
|
- 只有生成报告时才需要访问图片
|
|||
|
|
|
|||
|
|
**Storage Bucket 配置**:
|
|||
|
|
- Bucket 名称:通过环境变量 `ERYAO_STORAGE__FEEDBACK__BUCKET` 配置
|
|||
|
|
- 默认值:`feedback-images`
|
|||
|
|
- 权限:私有(不需要公开访问)
|
|||
|
|
|
|||
|
|
### 4.1 数据库表设计
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- backend/alembic/versions/YYYYMMDD_N_create_user_feedback.py
|
|||
|
|
CREATE TABLE user_feedback (
|
|||
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|||
|
|
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, -- NULL 表示匿名(勾选"不上传我的个人信息")
|
|||
|
|
feedback_type VARCHAR(20) NOT NULL DEFAULT 'other',
|
|||
|
|
content TEXT NOT NULL,
|
|||
|
|
images JSONB DEFAULT '[]', -- 图片路径列表,如 ["feedback/2026-04-17/img1.jpg"]
|
|||
|
|
device_info JSONB NOT NULL DEFAULT '{}', -- 设备信息照样采集(不涉及隐私)
|
|||
|
|
app_version VARCHAR(20) NOT NULL,
|
|||
|
|
os_version VARCHAR(50) NOT NULL,
|
|||
|
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
|||
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|||
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
CREATE INDEX ix_user_feedback_user_id ON user_feedback(user_id);
|
|||
|
|
CREATE INDEX ix_user_feedback_created_at ON user_feedback(created_at);
|
|||
|
|
CREATE INDEX ix_user_feedback_status ON user_feedback(status);
|
|||
|
|
|
|||
|
|
COMMENT ON TABLE user_feedback IS '用户反馈表';
|
|||
|
|
COMMENT ON COLUMN user_feedback.user_id IS '用户ID,NULL表示匿名(勾选"不上传我的个人信息"),报告生成时根据此字段决定是否包含用户数据';
|
|||
|
|
COMMENT ON COLUMN user_feedback.feedback_type IS '反馈类型: bug/suggestion/other';
|
|||
|
|
COMMENT ON COLUMN user_feedback.content IS '反馈内容,最多500字';
|
|||
|
|
COMMENT ON COLUMN user_feedback.images IS '图片Storage路径列表,最多3张';
|
|||
|
|
COMMENT ON COLUMN user_feedback.device_info IS '设备信息JSON,匿名时照样采集(不涉及隐私)';
|
|||
|
|
COMMENT ON COLUMN user_feedback.status IS '处理状态: pending/processed';
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.2 Pydantic Schema 定义
|
|||
|
|
|
|||
|
|
#### 后端 Schema(强约束)
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# backend/src/v1/feedback/schemas.py
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from pydantic import BaseModel, Field
|
|||
|
|
from typing import Literal
|
|||
|
|
|
|||
|
|
# ===== 枚举类型 =====
|
|||
|
|
|
|||
|
|
FeedbackType = Literal["bug", "suggestion", "other"]
|
|||
|
|
FeedbackStatus = Literal["pending", "processed"]
|
|||
|
|
|
|||
|
|
# ===== 设备信息 Schema =====
|
|||
|
|
|
|||
|
|
class DeviceInfo(BaseModel):
|
|||
|
|
"""设备信息,前端自动收集"""
|
|||
|
|
platform: Literal["ios", "android"] = Field(..., description="平台")
|
|||
|
|
model: str = Field(..., min_length=1, max_length=100, description="设备型号")
|
|||
|
|
|
|||
|
|
model_config = {"extra": "forbid"}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ===== 响应 Schema =====
|
|||
|
|
|
|||
|
|
class FeedbackCreateResponse(BaseModel):
|
|||
|
|
"""反馈提交响应"""
|
|||
|
|
id: str = Field(..., description="反馈ID")
|
|||
|
|
created_at: str = Field(..., description="创建时间ISO8601")
|
|||
|
|
|
|||
|
|
model_config = {"extra": "forbid"}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ===== 数据库模型 =====
|
|||
|
|
|
|||
|
|
class FeedbackInDB(BaseModel):
|
|||
|
|
"""数据库中的反馈记录"""
|
|||
|
|
id: str
|
|||
|
|
user_id: str | None
|
|||
|
|
feedback_type: FeedbackType
|
|||
|
|
content: str
|
|||
|
|
images: list[str] # Storage 路径列表
|
|||
|
|
device_info: dict
|
|||
|
|
app_version: str
|
|||
|
|
os_version: str
|
|||
|
|
status: FeedbackStatus
|
|||
|
|
created_at: str
|
|||
|
|
updated_at: str
|
|||
|
|
|
|||
|
|
model_config = {"extra": "forbid"}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**注意**:请求通过 `multipart/form-data` 接收,不需要 Pydantic Schema 验证表单字段,使用 FastAPI 的 `Form` 和 `UploadFile`。
|
|||
|
|
|
|||
|
|
class FeedbackInDB(BaseModel):
|
|||
|
|
"""数据库中的反馈记录"""
|
|||
|
|
id: str
|
|||
|
|
user_id: str | None
|
|||
|
|
feedback_type: FeedbackType
|
|||
|
|
content: str
|
|||
|
|
images: list[str]
|
|||
|
|
device_info: dict
|
|||
|
|
app_version: str
|
|||
|
|
os_version: str
|
|||
|
|
status: FeedbackStatus
|
|||
|
|
created_at: str
|
|||
|
|
updated_at: str
|
|||
|
|
|
|||
|
|
model_config = {"extra": "forbid"}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 前端 Schema(Dart)
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// apps/lib/features/settings/data/models/feedback.dart
|
|||
|
|
enum FeedbackType { bug, suggestion, other }
|
|||
|
|
|
|||
|
|
class DeviceInfo {
|
|||
|
|
final String platform; // 'ios' | 'android'
|
|||
|
|
final String model;
|
|||
|
|
|
|||
|
|
const DeviceInfo({
|
|||
|
|
required this.platform,
|
|||
|
|
required this.model,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
String toJson() => '$platform|$model'; // 序列化为字符串传输
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.3 API 接口设计
|
|||
|
|
|
|||
|
|
#### 提交反馈
|
|||
|
|
|
|||
|
|
使用 `multipart/form-data` 上传图片和表单数据:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
POST /api/v1/feedback
|
|||
|
|
Authorization: Bearer {token} # 可选,不传或无效则 user_id 为 NULL(匿名)
|
|||
|
|
Content-Type: multipart/form-data
|
|||
|
|
|
|||
|
|
Form Fields:
|
|||
|
|
- feedback_type: "bug" | "suggestion" | "other" (required)
|
|||
|
|
- content: string (required, max 500 chars)
|
|||
|
|
- device_info: string (JSON格式,如 '{"platform":"ios","model":"iPhone 14 Pro"}')
|
|||
|
|
- app_version: string (required)
|
|||
|
|
- os_version: string (required)
|
|||
|
|
|
|||
|
|
Files:
|
|||
|
|
- images: File[] (optional, max 3 files)
|
|||
|
|
|
|||
|
|
Response 201:
|
|||
|
|
{
|
|||
|
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|||
|
|
"created_at": "2026-04-17T10:30:00Z"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Error Codes:
|
|||
|
|
- FEEDBACK_CONTENT_EMPTY (400): 内容为空
|
|||
|
|
- FEEDBACK_CONTENT_TOO_LONG (400): 内容超过500字
|
|||
|
|
- FEEDBACK_TOO_MANY_IMAGES (400): 图片超过3张
|
|||
|
|
- FEEDBACK_IMAGE_TOO_LARGE (400): 图片超过5MB
|
|||
|
|
- FEEDBACK_INVALID_IMAGE_TYPE (400): 图片类型不支持(仅支持 jpg/png)
|
|||
|
|
- FEEDBACK_SUBMIT_FAILED (500): 提交失败
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 后端路由实现
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# backend/src/v1/feedback/router.py
|
|||
|
|
from fastapi import APIRouter, Form, File, UploadFile, Depends
|
|||
|
|
from typing import Annotated
|
|||
|
|
|
|||
|
|
router = APIRouter(prefix="/feedback", tags=["feedback"])
|
|||
|
|
|
|||
|
|
@router.post("", response_model=FeedbackCreateResponse)
|
|||
|
|
async def create_feedback(
|
|||
|
|
feedback_type: Annotated[str, Form(...)],
|
|||
|
|
content: Annotated[str, Form(...)],
|
|||
|
|
device_info: Annotated[str, Form(...)], # JSON 字符串
|
|||
|
|
app_version: Annotated[str, Form(...)],
|
|||
|
|
os_version: Annotated[str, Form(...)],
|
|||
|
|
images: Annotated[list[UploadFile], File(default=[])],
|
|||
|
|
user_id: str | None = Depends(get_optional_user_id),
|
|||
|
|
service: FeedbackService = Depends(get_feedback_service),
|
|||
|
|
) -> FeedbackCreateResponse:
|
|||
|
|
"""
|
|||
|
|
提交用户反馈(支持匿名)
|
|||
|
|
|
|||
|
|
- feedback_type: bug/suggestion/other
|
|||
|
|
- content: 反馈内容,最多500字
|
|||
|
|
- device_info: JSON格式设备信息
|
|||
|
|
- app_version: App版本
|
|||
|
|
- os_version: 系统版本
|
|||
|
|
- images: 图片文件,最多3张
|
|||
|
|
- user_id: 可选,前端不传或传空则匿名
|
|||
|
|
|
|||
|
|
匿名说明:
|
|||
|
|
- 前端勾选"不上传我的个人信息"时,不传 Authorization 或传空 user_id
|
|||
|
|
- user_id 为 NULL 就是匿名,不需要额外的 is_anonymous 字段
|
|||
|
|
"""
|
|||
|
|
# 验证图片数量
|
|||
|
|
if len(images) > 3:
|
|||
|
|
raise ApiProblemError(
|
|||
|
|
status_code=400,
|
|||
|
|
code="FEEDBACK_TOO_MANY_IMAGES",
|
|||
|
|
detail="Maximum 3 images allowed",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 解析 device_info JSON
|
|||
|
|
device_info_dict = json.loads(device_info)
|
|||
|
|
|
|||
|
|
# 调用 service 处理(user_id 为 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, # 前端不传就是 None
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.4 前端实现方案
|
|||
|
|
|
|||
|
|
#### 目录结构
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
apps/lib/features/settings/
|
|||
|
|
├── data/
|
|||
|
|
│ ├── apis/
|
|||
|
|
│ │ └── feedback_api.dart # 新增
|
|||
|
|
│ ├── models/
|
|||
|
|
│ │ └── feedback.dart # 新增
|
|||
|
|
│ └── repositories/
|
|||
|
|
│ └── feedback_repository.dart # 新增
|
|||
|
|
└── presentation/
|
|||
|
|
└── screens/
|
|||
|
|
└── feedback_screen.dart # 新增
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 组件设计
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// feedback_screen.dart
|
|||
|
|
class FeedbackScreen extends StatefulWidget {
|
|||
|
|
@override
|
|||
|
|
Widget build(BuildContext context) {
|
|||
|
|
return Scaffold(
|
|||
|
|
appBar: AppBar(title: Text(l10n.feedbackTitle)),
|
|||
|
|
body: Form(
|
|||
|
|
child: Column(
|
|||
|
|
children: [
|
|||
|
|
// 1. 反馈类型选择(SegmentedButton)
|
|||
|
|
SegmentedButton<FeedbackType>(
|
|||
|
|
segments: [
|
|||
|
|
ButtonSegment(value: FeedbackType.bug, label: Text(l10n.feedbackTypeBug)),
|
|||
|
|
ButtonSegment(value: FeedbackType.suggestion, label: Text(l10n.feedbackTypeSuggestion)),
|
|||
|
|
ButtonSegment(value: FeedbackType.other, label: Text(l10n.feedbackTypeOther)),
|
|||
|
|
],
|
|||
|
|
selected: {_selectedType},
|
|||
|
|
onSelectionChanged: (Set<FeedbackType> newSelection) {
|
|||
|
|
setState(() => _selectedType = newSelection.first);
|
|||
|
|
},
|
|||
|
|
),
|
|||
|
|
|
|||
|
|
// 2. 反馈内容输入(TextField, maxLength: 500)
|
|||
|
|
TextField(
|
|||
|
|
maxLines: 8,
|
|||
|
|
maxLength: 500,
|
|||
|
|
decoration: InputDecoration(
|
|||
|
|
labelText: l10n.feedbackContentLabel,
|
|||
|
|
hintText: l10n.feedbackContentHint,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
|
|||
|
|
// 3. 图片选择(最多3张)
|
|||
|
|
ImagePickerWidget(
|
|||
|
|
maxImages: 3,
|
|||
|
|
onImagesSelected: (files) => _selectedImages = files,
|
|||
|
|
),
|
|||
|
|
|
|||
|
|
// 4. 隐私勾选框
|
|||
|
|
CheckboxListTile(
|
|||
|
|
title: Text(l10n.feedbackAnonymousLabel),
|
|||
|
|
subtitle: Text(l10n.feedbackAnonymousHint),
|
|||
|
|
value: _isAnonymous,
|
|||
|
|
onChanged: (value) => setState(() => _isAnonymous = value ?? false),
|
|||
|
|
),
|
|||
|
|
|
|||
|
|
// 5. 提交按钮
|
|||
|
|
FilledButton(
|
|||
|
|
onPressed: _submitFeedback,
|
|||
|
|
child: Text(l10n.feedbackSubmit),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 图片选择与上传实现
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// 1. 从相册选择图片
|
|||
|
|
import 'package:image_picker/image_picker.dart';
|
|||
|
|
import 'package:dio/dio.dart';
|
|||
|
|
|
|||
|
|
final ImagePicker _picker = ImagePicker();
|
|||
|
|
|
|||
|
|
Future<List<XFile>> pickImages() async {
|
|||
|
|
final List<XFile> images = await _picker.pickMultiImage(
|
|||
|
|
maxWidth: 1920,
|
|||
|
|
maxHeight: 1080,
|
|||
|
|
imageQuality: 85,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (images.length > 3) {
|
|||
|
|
throw Exception('最多只能选择3张图片');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return images;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 通过 multipart/form-data 提交反馈
|
|||
|
|
Future<void> submitFeedback({
|
|||
|
|
required FeedbackType feedbackType,
|
|||
|
|
required String content,
|
|||
|
|
required DeviceInfo deviceInfo,
|
|||
|
|
required String appVersion,
|
|||
|
|
required String osVersion,
|
|||
|
|
required List<XFile> images,
|
|||
|
|
bool isAnonymous = false, // 前端勾选框:是否不上传我的个人信息
|
|||
|
|
}) async {
|
|||
|
|
final dio = Dio();
|
|||
|
|
|
|||
|
|
// 构建 FormData
|
|||
|
|
final formData = FormData();
|
|||
|
|
|
|||
|
|
// 添加表单字段
|
|||
|
|
formData.fields.addAll([
|
|||
|
|
MapEntry('feedback_type', feedbackType.name),
|
|||
|
|
MapEntry('content', content),
|
|||
|
|
MapEntry('device_info', jsonEncode(deviceInfo.toJson())),
|
|||
|
|
MapEntry('app_version', appVersion),
|
|||
|
|
MapEntry('os_version', osVersion),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
// 添加图片文件
|
|||
|
|
for (final image in images) {
|
|||
|
|
final bytes = await image.readAsBytes();
|
|||
|
|
formData.files.add(
|
|||
|
|
MapEntry(
|
|||
|
|
'images',
|
|||
|
|
MultipartFile.fromBytes(
|
|||
|
|
bytes,
|
|||
|
|
filename: image.name,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 发送请求(匿名时不传 Authorization)
|
|||
|
|
final response = await dio.post(
|
|||
|
|
'/api/v1/feedback',
|
|||
|
|
data: formData,
|
|||
|
|
options: Options(
|
|||
|
|
contentType: 'multipart/form-data',
|
|||
|
|
headers: isAnonymous ? {} : {'Authorization': 'Bearer $token'},
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
return response.data;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 表单验证
|
|||
|
|
|
|||
|
|
- 反馈内容:必填,1-500 字符
|
|||
|
|
- 图片数量:最多 3 张
|
|||
|
|
- 设备信息:自动收集(Platform + device_info_plus)
|
|||
|
|
|
|||
|
|
### 4.5 环境变量配置
|
|||
|
|
|
|||
|
|
#### 新增环境变量
|
|||
|
|
|
|||
|
|
在 `.env.example` 和 `.env` 中添加:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
############
|
|||
|
|
# Storage 配置(反馈图片)
|
|||
|
|
############
|
|||
|
|
ERYAO_STORAGE__FEEDBACK__BUCKET=feedback-images
|
|||
|
|
ERYAO_STORAGE__FEEDBACK__MAX_SIZE_MB=5
|
|||
|
|
|
|||
|
|
############
|
|||
|
|
# 反馈报告配置(Phase 2)
|
|||
|
|
############
|
|||
|
|
ERYAO_FEEDBACK_REPORT__EMAIL=support@example.com
|
|||
|
|
ERYAO_FEEDBACK_REPORT__CRON=0 10 * * *
|
|||
|
|
ERYAO_FEEDBACK_REPORT__ENABLED=false
|
|||
|
|
|
|||
|
|
############
|
|||
|
|
# Email SMTP 配置(飞书邮箱,Phase 3)
|
|||
|
|
############
|
|||
|
|
ERYAO_EMAIL__HOST=smtp.feishu.cn
|
|||
|
|
ERYAO_EMAIL__PORT=465
|
|||
|
|
ERYAO_EMAIL__USERNAME=robot@xunmee.com
|
|||
|
|
ERYAO_EMAIL__PASSWORD=<填入飞书邮箱专用密码>
|
|||
|
|
ERYAO_EMAIL__USE_SSL=true
|
|||
|
|
ERYAO_EMAIL__FROM_ADDRESS=robot@xunmee.com
|
|||
|
|
ERYAO_EMAIL__FROM_NAME=Eryao 反馈系统
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Settings 配置更新
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# backend/src/core/config/settings.py
|
|||
|
|
|
|||
|
|
class StorageSettings(BaseModel):
|
|||
|
|
# ... 现有配置 ...
|
|||
|
|
|
|||
|
|
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 FeedbackReportSettings(BaseModel):
|
|||
|
|
"""反馈报告配置(Phase 2)"""
|
|||
|
|
email: str = Field(default="support@example.com", description="客服邮箱")
|
|||
|
|
cron: str = Field(default="0 10 * * *", description="报告生成cron表达式,默认每天10点")
|
|||
|
|
enabled: bool = Field(default=False, description="是否启用报告推送")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Settings(BaseSettings):
|
|||
|
|
# ... 现有配置 ...
|
|||
|
|
feedback_report: FeedbackReportSettings = Field(default_factory=FeedbackReportSettings)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.6 Supabase Storage Bucket 自动创建
|
|||
|
|
|
|||
|
|
**复用现有 SupabaseService 的 bucket 自动创建机制**:
|
|||
|
|
|
|||
|
|
项目已有 `SupabaseService._ensure_storage_bucket()` 方法,会自动检查并创建配置的 bucket。只需在 Settings 中添加 feedback bucket 配置即可。
|
|||
|
|
|
|||
|
|
#### Settings 配置更新
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# backend/src/core/config/settings.py
|
|||
|
|
|
|||
|
|
class StorageSettings(BaseModel):
|
|||
|
|
provider: Literal["supabase"] = "supabase"
|
|||
|
|
signed_url_ttl_seconds: int = Field(default=600, ge=60, le=3600)
|
|||
|
|
retention_days: int = Field(default=30, ge=1, le=3650)
|
|||
|
|
|
|||
|
|
class AttachmentSettings(BaseModel):
|
|||
|
|
bucket: str = Field(default="eryao-attachments", min_length=3, max_length=63)
|
|||
|
|
max_size_mb: int = Field(default=20, ge=1, le=200)
|
|||
|
|
|
|||
|
|
class AvatarSettings(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) # 新增
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### SupabaseService 更新
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# backend/src/services/base/supabase.py
|
|||
|
|
|
|||
|
|
async def _ensure_storage_bucket(self) -> None:
|
|||
|
|
"""自动创建配置的 storage buckets"""
|
|||
|
|
storage = getattr(self._admin_client, "storage", None)
|
|||
|
|
if storage is None:
|
|||
|
|
self.logger.warning("Storage client unavailable, skipping bucket check")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
get_bucket = getattr(storage, "get_bucket", None)
|
|||
|
|
if not callable(get_bucket):
|
|||
|
|
self.logger.warning("Storage get_bucket unavailable, skipping bucket check")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# 配置所有 bucket(名称, 是否公开)
|
|||
|
|
buckets = [
|
|||
|
|
(config.storage.attachment.bucket, False), # 附件 bucket,私有
|
|||
|
|
(config.storage.avatar.bucket, True), # 头像 bucket,公开
|
|||
|
|
(config.storage.feedback.bucket, False), # 反馈图片 bucket,私有(新增)
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
def _check_and_create() -> None:
|
|||
|
|
for bucket_name, is_public in buckets:
|
|||
|
|
try:
|
|||
|
|
get_bucket(bucket_name)
|
|||
|
|
self.logger.debug(
|
|||
|
|
"Storage bucket already exists", bucket=bucket_name
|
|||
|
|
)
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
create_bucket = getattr(storage, "create_bucket", None)
|
|||
|
|
if not callable(create_bucket):
|
|||
|
|
self.logger.warning(
|
|||
|
|
"Storage create_bucket unavailable, skipping bucket creation"
|
|||
|
|
)
|
|||
|
|
return
|
|||
|
|
try:
|
|||
|
|
create_bucket(bucket_name, options={"public": is_public})
|
|||
|
|
self.logger.info(
|
|||
|
|
"Storage bucket created",
|
|||
|
|
bucket=bucket_name,
|
|||
|
|
public=is_public,
|
|||
|
|
)
|
|||
|
|
except Exception as exc: # noqa: BLE001
|
|||
|
|
msg = str(exc).lower()
|
|||
|
|
if "already exists" in msg or "duplicate" in msg:
|
|||
|
|
self.logger.debug(
|
|||
|
|
"Storage bucket already exists (race)",
|
|||
|
|
bucket=bucket_name,
|
|||
|
|
)
|
|||
|
|
continue
|
|||
|
|
self.logger.warning(
|
|||
|
|
"Failed to create storage bucket",
|
|||
|
|
bucket=bucket_name,
|
|||
|
|
error=str(exc),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
await asyncio.to_thread(_check_and_create)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**说明**:
|
|||
|
|
- 无需手动创建 bucket,服务启动时自动检查并创建
|
|||
|
|
- 反馈图片 bucket 设置为私有(`is_public=False`),因为用户不需要查看自己的反馈
|
|||
|
|
- 只需在 `.env` 中配置 `ERYAO_STORAGE__FEEDBACK__BUCKET=feedback-images`
|
|||
|
|
|
|||
|
|
### 4.7 后端 Service 实现
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# backend/src/v1/feedback/service.py
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from fastapi import UploadFile
|
|||
|
|
from datetime import datetime
|
|||
|
|
import json
|
|||
|
|
|
|||
|
|
from core.config.settings import config
|
|||
|
|
from core.logging import get_logger
|
|||
|
|
from v1.feedback.repository import FeedbackRepository
|
|||
|
|
from v1.feedback.schemas import FeedbackCreateResponse
|
|||
|
|
|
|||
|
|
logger = get_logger("v1.feedback.service")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class FeedbackService:
|
|||
|
|
def __init__(self):
|
|||
|
|
self.repo = FeedbackRepository()
|
|||
|
|
self.bucket_name = config.storage.feedback.bucket
|
|||
|
|
|
|||
|
|
async def submit_feedback(
|
|||
|
|
self,
|
|||
|
|
*,
|
|||
|
|
feedback_type: str,
|
|||
|
|
content: str,
|
|||
|
|
device_info: dict,
|
|||
|
|
app_version: str,
|
|||
|
|
os_version: str,
|
|||
|
|
images: list[UploadFile],
|
|||
|
|
user_id: str | None,
|
|||
|
|
) -> FeedbackCreateResponse:
|
|||
|
|
"""
|
|||
|
|
提交反馈
|
|||
|
|
|
|||
|
|
1. 上传图片到 Supabase Storage
|
|||
|
|
2. 保存反馈记录到数据库
|
|||
|
|
"""
|
|||
|
|
# 1. 上传图片到 Storage
|
|||
|
|
image_paths = []
|
|||
|
|
if images:
|
|||
|
|
image_paths = await self._upload_images(images)
|
|||
|
|
|
|||
|
|
# 2. 保存到数据库
|
|||
|
|
feedback = await self.repo.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,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
logger.info(
|
|||
|
|
"Feedback submitted",
|
|||
|
|
feedback_id=feedback.id,
|
|||
|
|
user_id=user_id,
|
|||
|
|
image_count=len(image_paths),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return FeedbackCreateResponse(
|
|||
|
|
id=str(feedback.id),
|
|||
|
|
created_at=feedback.created_at.isoformat(),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
async def _upload_images(self, images: list[UploadFile]) -> list[str]:
|
|||
|
|
"""
|
|||
|
|
上传图片到 Supabase Storage
|
|||
|
|
|
|||
|
|
返回存储路径列表(非公开 URL)
|
|||
|
|
"""
|
|||
|
|
from supabase import create_client
|
|||
|
|
|
|||
|
|
supabase = create_client(
|
|||
|
|
config.supabase.public_url,
|
|||
|
|
config.supabase.service_role_key,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
paths = []
|
|||
|
|
timestamp = datetime.now().strftime("%Y-%m-%d")
|
|||
|
|
|
|||
|
|
for i, image in enumerate(images):
|
|||
|
|
# 验证文件类型
|
|||
|
|
if image.content_type not in ["image/jpeg", "image/png"]:
|
|||
|
|
raise ValueError(f"Unsupported image type: {image.content_type}")
|
|||
|
|
|
|||
|
|
# 验证文件大小
|
|||
|
|
content = await image.read()
|
|||
|
|
if len(content) > config.storage.feedback.max_size_mb * 1024 * 1024:
|
|||
|
|
raise ValueError(f"Image too large: {image.filename}")
|
|||
|
|
|
|||
|
|
# 生成存储路径
|
|||
|
|
file_ext = "jpg" if image.content_type == "image/jpeg" else "png"
|
|||
|
|
storage_path = f"{timestamp}/{datetime.now().timestamp()}_{i}.{file_ext}"
|
|||
|
|
|
|||
|
|
# 上传到 Storage
|
|||
|
|
supabase.storage.from_(self.bucket_name).upload(
|
|||
|
|
storage_path,
|
|||
|
|
content,
|
|||
|
|
{"content-type": image.content_type},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
paths.append(storage_path)
|
|||
|
|
logger.debug("Image uploaded", path=storage_path, filename=image.filename)
|
|||
|
|
|
|||
|
|
return paths
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.8 定时任务方案(Phase 2)
|
|||
|
|
|
|||
|
|
使用 Taskiq 实现每日报告生成和邮件发送:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# backend/src/v1/feedback/tasks.py
|
|||
|
|
from datetime import datetime, timedelta
|
|||
|
|
from taskiq_redis import ListQueueBroker
|
|||
|
|
|
|||
|
|
from core.taskiq.app import worker_general_broker
|
|||
|
|
from core.config.settings import config
|
|||
|
|
|
|||
|
|
@worker_general_broker.task(schedule=[{"cron": config.feedback_report_cron}])
|
|||
|
|
async def generate_daily_feedback_report():
|
|||
|
|
"""生成每日反馈报告并发送邮件"""
|
|||
|
|
# 1. 查询昨日所有反馈
|
|||
|
|
# 2. 生成 xlsx 报告
|
|||
|
|
# 3. 发送邮件到客服邮箱
|
|||
|
|
# 4. 更新反馈状态为 processed
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.7 邮件发送方案(Phase 3)
|
|||
|
|
|
|||
|
|
#### 4.7.1 架构定位:无状态工具类,非 BaseServiceProvider
|
|||
|
|
|
|||
|
|
邮件发送是**无状态操作**(每次调用:连接 → 发送 → 断开),不需要生命周期管理(`initialize` / `close`),因此:
|
|||
|
|
|
|||
|
|
- **不放入 `services/base/`**(该层是有状态长连接服务:Supabase client、Redis pool,需要注册到 ServiceRegistry)
|
|||
|
|
- **放入 `core/email/sender.py`**(与 `core/logging`、`core/config` 同层,无状态工具类)
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
backend/src/
|
|||
|
|
├── core/
|
|||
|
|
│ ├── config/ # 配置
|
|||
|
|
│ ├── email/ # 邮件(无状态工具)
|
|||
|
|
│ │ ├── __init__.py
|
|||
|
|
│ │ ├── sender.py # EmailSender 类(轻量封装 aiosmtplib)
|
|||
|
|
│ │ └── templates/ # HTML 邮件模板(动态加载)
|
|||
|
|
│ │ └── feedback/
|
|||
|
|
│ │ ├── daily_report.html
|
|||
|
|
│ │ └── _styles.css
|
|||
|
|
│ ├── logging/ # 日志
|
|||
|
|
│ └── ...
|
|||
|
|
├── services/
|
|||
|
|
│ └── base/ # 有状态服务(Supabase、Redis)
|
|||
|
|
│ ├── supabase.py
|
|||
|
|
│ └── redis.py
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 4.7.2 SMTP 参数(飞书邮箱)
|
|||
|
|
|
|||
|
|
采用飞书企业邮箱 SMTP 服务发送邮件:
|
|||
|
|
|
|||
|
|
| 环境变量 | 说明 | 示例 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `ERYAO_EMAIL__HOST` | SMTP 服务器地址 | `smtp.feishu.cn` |
|
|||
|
|
| `ERYAO_EMAIL__PORT` | SMTP 端口 | `465` |
|
|||
|
|
| `ERYAO_EMAIL__USERNAME` | SMTP 认证用户名 | `robot@xunmee.com` |
|
|||
|
|
| `ERYAO_EMAIL__PASSWORD` | SMTP 认证密码(SecretStr) | 飞书邮箱专用密码 |
|
|||
|
|
| `ERYAO_EMAIL__USE_SSL` | 是否使用 SSL | `true` |
|
|||
|
|
| `ERYAO_EMAIL__FROM_ADDRESS` | 发件人地址 | `robot@xunmee.com` |
|
|||
|
|
| `ERYAO_EMAIL__FROM_NAME` | 发件人显示名称 | `Eryao 反馈系统` |
|
|||
|
|
|
|||
|
|
#### 4.7.3 Settings 配置
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# backend/src/core/config/settings.py
|
|||
|
|
|
|||
|
|
class EmailSettings(BaseModel):
|
|||
|
|
host: str = Field(default="localhost", 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):
|
|||
|
|
# ... 现有配置 ...
|
|||
|
|
email: EmailSettings = Field(default_factory=EmailSettings)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 4.7.4 EmailSender 实现
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# backend/src/core/email/sender.py
|
|||
|
|
|
|||
|
|
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
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
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:
|
|||
|
|
"""
|
|||
|
|
无状态邮件发送工具类。
|
|||
|
|
|
|||
|
|
每次调用 send() 建立 SMTP 连接、发送邮件、关闭连接。
|
|||
|
|
不注册到 ServiceRegistry,不需要 initialize/close 生命周期。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
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()
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 4.7.5 附件命名规范
|
|||
|
|
|
|||
|
|
邮件附带的 xlsx 报告文件名格式:`{YYYYMMDD_HHMMSS}_{业务含义}.xlsx`
|
|||
|
|
|
|||
|
|
反馈报告示例:
|
|||
|
|
- `20260417_100000_feedback_report.xlsx`
|
|||
|
|
- `20260418_100000_feedback_report.xlsx`
|
|||
|
|
|
|||
|
|
全量报告(手动触发):
|
|||
|
|
- `20260417_143052_feedback_report_all.xlsx`
|
|||
|
|
|
|||
|
|
命名规则:
|
|||
|
|
1. 时间戳精确到秒,使用服务器本地时间
|
|||
|
|
2. 业务含义使用 snake_case,反映报告内容
|
|||
|
|
3. `.xlsx` 扩展名固定
|
|||
|
|
|
|||
|
|
#### 4.7.6 常见 SMTP 服务商配置参考
|
|||
|
|
|
|||
|
|
| 服务商 | Host | Port | SSL |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| 飞书企业邮箱 | `smtp.feishu.cn` | 465 | true |
|
|||
|
|
| 腾讯企业邮 | `smtp.exmail.qq.com` | 465 | true |
|
|||
|
|
| 阿里云邮件推送 | `smtpdm.aliyun.com` | 465 或 80 | true |
|
|||
|
|
| SendGrid | `smtp.sendgrid.net` | 465 | true |
|
|||
|
|
| Gmail | `smtp.gmail.com` | 587 | false(STARTTLS) |
|
|||
|
|
|
|||
|
|
#### 4.7.7 环境变量配置
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
############
|
|||
|
|
# Email SMTP 配置(飞书企业邮箱)
|
|||
|
|
############
|
|||
|
|
ERYAO_EMAIL__HOST=smtp.feishu.cn
|
|||
|
|
ERYAO_EMAIL__PORT=465
|
|||
|
|
ERYAO_EMAIL__USERNAME=robot@xunmee.com
|
|||
|
|
ERYAO_EMAIL__PASSWORD=<飞书邮箱专用密码>
|
|||
|
|
ERYAO_EMAIL__USE_SSL=true
|
|||
|
|
ERYAO_EMAIL__FROM_ADDRESS=robot@xunmee.com
|
|||
|
|
ERYAO_EMAIL__FROM_NAME=Eryao 反馈系统
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.11 邮件模板方案
|
|||
|
|
|
|||
|
|
#### 4.11.1 设计目标
|
|||
|
|
|
|||
|
|
1. **动态加载**:模板以 HTML 文件存放在 `core/email/templates/` 下,运行时从磁盘读取。修改模板文件即可改变邮件格式,无需重新部署
|
|||
|
|
2. **HTML 富文本**:支持 CSS 样式渲染,不限于纯文本。邮件客户端兼容 inline style
|
|||
|
|
3. **模板分级**:按业务模块组织模板目录,当前先建 `feedback/`,后续可扩展 `notification/`、`marketing/` 等
|
|||
|
|
4. **变量替换**:使用 Python `str.format()` 或 `string.Template` 进行简单变量插值,不引入 Jinja2 依赖
|
|||
|
|
|
|||
|
|
#### 4.11.2 目录结构
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
backend/src/core/email/
|
|||
|
|
├── __init__.py
|
|||
|
|
├── sender.py # EmailSender 类
|
|||
|
|
└── templates/
|
|||
|
|
└── feedback/ # 反馈相关邮件模板
|
|||
|
|
├── daily_report.html # 每日反馈报告正文
|
|||
|
|
└── _styles.css # 共享样式(inline 化)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 4.11.3 模板加载器
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# backend/src/core/email/template_loader.py
|
|||
|
|
|
|||
|
|
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:
|
|||
|
|
"""
|
|||
|
|
从磁盘加载邮件模板。
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
category: 业务分类,如 "feedback"
|
|||
|
|
name: 模板文件名,如 "daily_report.html"
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
string.Template 对象,调用 .substitute(**kwargs) 进行变量替换
|
|||
|
|
"""
|
|||
|
|
template_path = _TEMPLATES_DIR / category / name
|
|||
|
|
content = template_path.read_text(encoding="utf-8")
|
|||
|
|
return Template(content)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 4.11.4 feedback 模板:daily_report.html
|
|||
|
|
|
|||
|
|
```html
|
|||
|
|
<!-- backend/src/core/email/templates/feedback/daily_report.html -->
|
|||
|
|
<!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, 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);">
|
|||
|
|
|
|||
|
|
<!-- Header -->
|
|||
|
|
<tr>
|
|||
|
|
<td style="background-color: #4472C4; padding: 24px 32px;">
|
|||
|
|
<h1 style="margin: 0; color: #ffffff; font-size: 20px; font-weight: 600;">
|
|||
|
|
用户反馈日报
|
|||
|
|
</h1>
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
|
|||
|
|
<!-- Body -->
|
|||
|
|
<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>
|
|||
|
|
|
|||
|
|
<!-- Stats Cards -->
|
|||
|
|
<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>
|
|||
|
|
|
|||
|
|
<!-- Footer -->
|
|||
|
|
<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>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 4.11.5 模板变量说明
|
|||
|
|
|
|||
|
|
| 变量 | 类型 | 说明 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `${start_date}` | string | 采集开始日期,如 `2026-04-16` |
|
|||
|
|
| `${start_hour}` | string | 采集开始小时,如 `10` |
|
|||
|
|
| `${end_date}` | string | 采集结束日期,如 `2026-04-17` |
|
|||
|
|
| `${end_hour}` | string | 采集结束小时,如 `10` |
|
|||
|
|
| `${total_count}` | int | 反馈总数 |
|
|||
|
|
| `${bug_count}` | int | bug 类型数量 |
|
|||
|
|
| `${suggestion_count}` | int | suggestion 类型数量 |
|
|||
|
|
| `${generated_at}` | string | 报告生成时间,如 `2026-04-17 10:00:05` |
|
|||
|
|
|
|||
|
|
#### 4.11.6 调用示例
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# 在 v1/feedback/tasks.py 的 generate_daily_feedback_report 中调用:
|
|||
|
|
|
|||
|
|
from core.email.sender import EmailSender, EmailMessage, EmailAttachment
|
|||
|
|
from core.email.template_loader import load_template
|
|||
|
|
|
|||
|
|
# 1. 加载并渲染模板
|
|||
|
|
template = load_template("feedback", "daily_report.html")
|
|||
|
|
body_html = 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().strftime("%Y-%m-%d %H:%M:%S"),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 2. 构造邮件消息
|
|||
|
|
report_filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_feedback_report.xlsx"
|
|||
|
|
message = EmailMessage(
|
|||
|
|
to=config.feedback_report.email,
|
|||
|
|
subject=f"用户反馈日报 - {start_time.strftime('%Y-%m-%d')}",
|
|||
|
|
body_html=body_html,
|
|||
|
|
attachments=[
|
|||
|
|
EmailAttachment(
|
|||
|
|
filename=report_filename,
|
|||
|
|
content=report_path.read_bytes(),
|
|||
|
|
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|||
|
|
)
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 3. 发送
|
|||
|
|
sender = EmailSender()
|
|||
|
|
await sender.send(message)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 4.11.7 模板扩展约定
|
|||
|
|
|
|||
|
|
后续新增业务邮件时,在 `templates/` 下新建对应目录即可:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
templates/
|
|||
|
|
├── feedback/ # 用户反馈相关
|
|||
|
|
│ └── daily_report.html
|
|||
|
|
├── notification/ # 系统通知(未来)
|
|||
|
|
│ └── system_alert.html
|
|||
|
|
└── marketing/ # 营销邮件(未来)
|
|||
|
|
└── welcome.html
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
每个模板目录独立,互不影响。调用方通过 `load_template(category, name)` 加载。
|
|||
|
|
|
|||
|
|
### 4.9 文档生成方案(Phase 2)
|
|||
|
|
|
|||
|
|
使用 openpyxl 生成 xlsx 格式报告,**嵌入图片**,**根据 user_id 是否为 NULL 决定是否包含用户数据**,发送后删除临时文件。
|
|||
|
|
|
|||
|
|
**时间范围采集逻辑**:
|
|||
|
|
|
|||
|
|
报告按固定每日周期采集,根据 `created_at` 筛选:
|
|||
|
|
- 推送时间:默认每天 10:00(可通过 `ERYAO_FEEDBACK_REPORT__CRON` 配置)
|
|||
|
|
- 采集范围:昨天 10:00 到今天 10:00
|
|||
|
|
- 例如:2026-04-17 10:00 推送,采集 2026-04-16 10:00:00 ≤ created_at < 2026-04-17 10:00:00
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# backend/src/v1/feedback/report.py
|
|||
|
|
from openpyxl import Workbook
|
|||
|
|
from openpyxl.drawing.image import Image as XLImage
|
|||
|
|
from openpyxl.styles import Font, PatternFill, Alignment
|
|||
|
|
from datetime import datetime
|
|||
|
|
from pathlib import Path
|
|||
|
|
import tempfile
|
|||
|
|
from io import BytesIO
|
|||
|
|
|
|||
|
|
from services.base.supabase import supabase_service
|
|||
|
|
from core.config.settings import config
|
|||
|
|
from core.logging import get_logger
|
|||
|
|
|
|||
|
|
logger = get_logger("v1.feedback.report")
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def download_image_from_storage(storage_path: str) -> bytes:
|
|||
|
|
"""
|
|||
|
|
从 Supabase Storage 下载图片(复用 SupabaseService)
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
storage_path: Storage 路径,如 "2026-04-17/1234567890.123_0.jpg"
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
图片字节数据
|
|||
|
|
"""
|
|||
|
|
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 from storage",
|
|||
|
|
path=storage_path,
|
|||
|
|
error=str(e),
|
|||
|
|
)
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def generate_feedback_report(feedbacks: list[FeedbackInDB]) -> Path:
|
|||
|
|
"""
|
|||
|
|
生成 xlsx 格式的反馈报告(包含嵌入图片)
|
|||
|
|
|
|||
|
|
关键逻辑:
|
|||
|
|
- user_id IS NOT NULL:报告中包含用户ID(脱敏显示)
|
|||
|
|
- user_id IS NULL:报告中用户列显示"匿名",不包含任何用户身份信息
|
|||
|
|
|
|||
|
|
返回临时文件路径,发送邮件后需删除
|
|||
|
|
"""
|
|||
|
|
wb = Workbook()
|
|||
|
|
ws = wb.active
|
|||
|
|
ws.title = "用户反馈"
|
|||
|
|
|
|||
|
|
# 表头样式
|
|||
|
|
header_font = Font(bold=True, color="FFFFFF")
|
|||
|
|
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
|||
|
|
|
|||
|
|
# 表头
|
|||
|
|
headers = ["时间", "类型", "用户ID", "设备", "App版本", "系统版本", "内容", "图片", "状态"]
|
|||
|
|
for col, header in enumerate(headers, start=1):
|
|||
|
|
cell = ws.cell(row=1, column=col, value=header)
|
|||
|
|
cell.font = header_font
|
|||
|
|
cell.fill = header_fill
|
|||
|
|
cell.alignment = Alignment(horizontal="center")
|
|||
|
|
|
|||
|
|
# 数据行(根据 user_id 是否为 NULL 决定是否包含用户数据)
|
|||
|
|
for row_idx, fb in enumerate(feedbacks, start=2):
|
|||
|
|
ws.cell(row=row_idx, column=1, value=fb.created_at.strftime("%Y-%m-%d %H:%M"))
|
|||
|
|
ws.cell(row=row_idx, column=2, value=fb.feedback_type)
|
|||
|
|
|
|||
|
|
# 关键:根据 user_id 是否为 NULL 决定显示内容
|
|||
|
|
if fb.user_id:
|
|||
|
|
# user_id 存在:显示脱敏用户ID
|
|||
|
|
ws.cell(row=row_idx, column=3, value=str(fb.user_id)[:8] + "...")
|
|||
|
|
else:
|
|||
|
|
# user_id 为 NULL(匿名):不包含用户数据
|
|||
|
|
ws.cell(row=row_idx, column=3, value="匿名")
|
|||
|
|
|
|||
|
|
# 设备信息无论是否匿名都显示(不涉及隐私)
|
|||
|
|
ws.cell(row=row_idx, column=4, value=f"{fb.device_info.get('platform', '-')} {fb.device_info.get('model', '-')}")
|
|||
|
|
ws.cell(row=row_idx, column=5, value=fb.app_version)
|
|||
|
|
ws.cell(row=row_idx, column=6, value=fb.os_version)
|
|||
|
|
ws.cell(row=row_idx, column=7, value=fb.content[:100] + "..." if len(fb.content) > 100 else fb.content)
|
|||
|
|
|
|||
|
|
# 嵌入图片(从 Storage 下载)
|
|||
|
|
if fb.images:
|
|||
|
|
ws.cell(row=row_idx, column=8, value=f"{len(fb.images)}张")
|
|||
|
|
|
|||
|
|
# 下载并嵌入第一张图片作为预览
|
|||
|
|
try:
|
|||
|
|
img_data = await download_image_from_storage(fb.images[0])
|
|||
|
|
img_buffer = BytesIO(img_data)
|
|||
|
|
|
|||
|
|
# 设置图片大小
|
|||
|
|
xl_img = XLImage(img_buffer)
|
|||
|
|
xl_img.width = 100
|
|||
|
|
xl_img.height = 100
|
|||
|
|
|
|||
|
|
# 插入到右侧列
|
|||
|
|
img_col = 10 # 第10列插入图片
|
|||
|
|
ws.add_image(xl_img, f"{chr(64 + img_col)}{row_idx}")
|
|||
|
|
|
|||
|
|
# 调整行高以适应图片
|
|||
|
|
ws.row_dimensions[row_idx].height = 80
|
|||
|
|
except Exception as e:
|
|||
|
|
# 图片下载失败,只显示数量
|
|||
|
|
logger.warning(
|
|||
|
|
"Failed to embed image in report",
|
|||
|
|
feedback_id=fb.id,
|
|||
|
|
image_path=fb.images[0],
|
|||
|
|
error=str(e),
|
|||
|
|
)
|
|||
|
|
ws.cell(row=row_idx, column=8, value=f"{len(fb.images)}张(预览失败)")
|
|||
|
|
else:
|
|||
|
|
ws.cell(row=row_idx, column=8, value="-")
|
|||
|
|
|
|||
|
|
ws.cell(row=row_idx, column=9, value=fb.status)
|
|||
|
|
|
|||
|
|
# 自动调整列宽
|
|||
|
|
column_widths = {
|
|||
|
|
'A': 18, # 时间
|
|||
|
|
'B': 12, # 类型
|
|||
|
|
'C': 12, # 用户ID
|
|||
|
|
'D': 25, # 设备
|
|||
|
|
'E': 12, # App版本
|
|||
|
|
'F': 15, # 系统版本
|
|||
|
|
'G': 50, # 内容
|
|||
|
|
'H': 10, # 图片
|
|||
|
|
'I': 10, # 状态
|
|||
|
|
'J': 15, # 图片预览列
|
|||
|
|
}
|
|||
|
|
for col, width in column_widths.items():
|
|||
|
|
ws.column_dimensions[col].width = width
|
|||
|
|
|
|||
|
|
# 保存到临时文件
|
|||
|
|
temp_dir = Path(tempfile.gettempdir())
|
|||
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|||
|
|
report_path = temp_dir / f"feedback_report_{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("Temporary 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),
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.10 定时任务实现(Phase 2)- 使用 worker-general 进程
|
|||
|
|
|
|||
|
|
**关键设计**:使用独立的 `worker-general` 异步任务进程,与主 web 进程分离,不阻塞主进程。
|
|||
|
|
|
|||
|
|
#### worker-general 架构说明
|
|||
|
|
|
|||
|
|
项目已有 `worker-general` 进程,通过 `infra/scripts/app.sh` 启动:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
# infra/scripts/app.sh 启动三个进程:
|
|||
|
|
# 1. web: 主 web 服务(uvicorn)
|
|||
|
|
# 2. worker-agent: 实时 Agent 任务
|
|||
|
|
# 3. worker-general: 通用异步任务(反馈报告生成)
|
|||
|
|
|
|||
|
|
WORKER_GENERAL_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src ERYAO_RUNTIME__SERVICE_NAME=worker-general uv run taskiq worker core.taskiq.app:worker_general_broker core.agentscope.runtime.tasks --workers ${ERYAO_WORKER__GROUPS__GENERAL__CONCURRENCY:-1}"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**优势**:
|
|||
|
|
- 与主 web 进程分离,不阻塞 API 请求
|
|||
|
|
- 独立的日志文件:`worker-general.log`、`worker-general.error.log`
|
|||
|
|
- 可独立重启,不影响 web 服务
|
|||
|
|
- 通过 `ERYAO_WORKER__GROUPS__GENERAL__CONCURRENCY` 控制并发数
|
|||
|
|
|
|||
|
|
#### 定时任务实现
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# backend/src/v1/feedback/tasks.py
|
|||
|
|
from datetime import datetime, timedelta
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
from core.taskiq.app import worker_general_broker # 使用 worker-general broker
|
|||
|
|
from core.config.settings import config
|
|||
|
|
from core.logging import get_logger
|
|||
|
|
from v1.feedback.repository import FeedbackRepository
|
|||
|
|
from v1.feedback.report import generate_feedback_report, cleanup_report_file
|
|||
|
|
from core.email.sender import send_email
|
|||
|
|
|
|||
|
|
logger = get_logger("v1.feedback.tasks")
|
|||
|
|
|
|||
|
|
|
|||
|
|
@worker_general_broker.task(
|
|||
|
|
task_name="feedback.generate_daily_report",
|
|||
|
|
schedule=[{"cron": config.feedback_report.cron}] # 默认每天 10:00
|
|||
|
|
)
|
|||
|
|
async def generate_and_send_daily_feedback_report():
|
|||
|
|
"""
|
|||
|
|
生成每日反馈报告并发送邮件(在 worker-general 进程中执行)
|
|||
|
|
|
|||
|
|
时间范围:
|
|||
|
|
- 推送时间:每天 10:00(可配置)
|
|||
|
|
- 采集范围:昨天 10:00 到今天 10:00
|
|||
|
|
- 例如:2026-04-17 10:00 推送,采集 2026-04-16 10:00:00 ≤ created_at < 2026-04-17 10:00:00
|
|||
|
|
|
|||
|
|
流程:
|
|||
|
|
1. 计算时间范围(昨天推送时间 到 今天推送时间)
|
|||
|
|
2. 查询该时间段内的反馈(根据 created_at 筛选)
|
|||
|
|
3. 生成 xlsx 报告(user_id 不为 NULL 显示脱敏ID,为 NULL 显示"匿名")
|
|||
|
|
4. 发送邮件到客服邮箱
|
|||
|
|
5. 删除临时文件
|
|||
|
|
6. 更新反馈状态为 processed
|
|||
|
|
|
|||
|
|
执行环境:
|
|||
|
|
- 进程:worker-general(与 web 进程分离)
|
|||
|
|
- Broker:worker_general_broker(Redis "general" 队列)
|
|||
|
|
- 日志:logs/worker-general.log
|
|||
|
|
"""
|
|||
|
|
if not config.feedback_report.enabled:
|
|||
|
|
logger.info("Feedback report is disabled, skipping")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
report_path: Path | None = None
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# 1. 计算时间范围(昨天推送时间 到 今天推送时间)
|
|||
|
|
now = datetime.now()
|
|||
|
|
# 假设推送时间为 10:00,从配置解析
|
|||
|
|
push_hour = 10 # TODO: 从 config.feedback_report.cron 解析
|
|||
|
|
end_time = now.replace(hour=push_hour, minute=0, second=0, microsecond=0)
|
|||
|
|
start_time = end_time - timedelta(days=1)
|
|||
|
|
|
|||
|
|
# 2. 查询该时间段内的反馈
|
|||
|
|
repo = FeedbackRepository()
|
|||
|
|
feedbacks = await repo.get_feedbacks_by_time_range(start_time, end_time)
|
|||
|
|
|
|||
|
|
if not feedbacks:
|
|||
|
|
logger.info("No feedbacks found in time range", start=start_time, end=end_time)
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
logger.info("Generating feedback report", count=len(feedbacks), start=start_time, end=end_time)
|
|||
|
|
|
|||
|
|
# 3. 生成 xlsx 报告(根据 user_id 是否为 NULL 决定是否包含用户数据)
|
|||
|
|
report_path = await generate_feedback_report(feedbacks)
|
|||
|
|
logger.info("Report generated", path=str(report_path))
|
|||
|
|
|
|||
|
|
# 4. 发送邮件(TODO: 暂不实现)
|
|||
|
|
# await send_email(
|
|||
|
|
# to=config.feedback_report.email,
|
|||
|
|
# subject=f"用户反馈日报 - {start_time.strftime('%Y-%m-%d')}",
|
|||
|
|
# body=f"附件为 {start_time.strftime('%Y-%m-%d')} {push_hour}:00 到 {end_time.strftime('%Y-%m-%d')} {push_hour}:00 的用户反馈报告,共 {len(feedbacks)} 条反馈。",
|
|||
|
|
# attachments=[
|
|||
|
|
# (report_path.name, report_path.read_bytes())
|
|||
|
|
# ]
|
|||
|
|
# )
|
|||
|
|
# logger.info("Report sent successfully", to=config.feedback_report.email)
|
|||
|
|
|
|||
|
|
# 5. 更新反馈状态
|
|||
|
|
for fb in feedbacks:
|
|||
|
|
await repo.update_status(fb.id, "processed")
|
|||
|
|
|
|||
|
|
logger.info("Feedbacks marked as processed", count=len(feedbacks))
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error("Failed to generate feedback report", error=str(e))
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
finally:
|
|||
|
|
# 6. 清理临时文件
|
|||
|
|
if report_path:
|
|||
|
|
await cleanup_report_file(report_path)
|
|||
|
|
logger.info("Temporary report file cleaned up", path=str(report_path))
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 5. 实施步骤
|
|||
|
|
|
|||
|
|
### Phase 1: 后端基础设施(当前)
|
|||
|
|
|
|||
|
|
- [ ] 添加 openpyxl 依赖(`uv add openpyxl`)
|
|||
|
|
- [ ] 添加 httpx 依赖(`uv add httpx`)用于下载图片
|
|||
|
|
- [ ] 更新 `backend/src/core/config/settings.py`,添加 `StorageSettings.FeedbackSettings` 和 `FeedbackReportSettings`
|
|||
|
|
- [ ] 更新 `.env.example` 和 `.env`,添加 Storage 和 Feedback Report 环境变量
|
|||
|
|
- [ ] 创建 user_feedback 表迁移文件
|
|||
|
|
- [ ] 创建 Supabase Storage bucket(通过 Dashboard 或 SQL)
|
|||
|
|
- [ ] 创建 Pydantic Schema(schemas.py,extra="forbid")
|
|||
|
|
- [ ] 创建 Feedback 模型(models/feedback.py)
|
|||
|
|
- [ ] 创建 FeedbackRepository(CRUD 操作)
|
|||
|
|
- [ ] 创建 FeedbackService(业务逻辑)
|
|||
|
|
- [ ] 创建 POST /api/v1/feedback 接口
|
|||
|
|
- [ ] 添加错误码到 `docs/protocols/common/http-error-codes.md`
|
|||
|
|
|
|||
|
|
### Phase 2: 前端实现(当前)
|
|||
|
|
|
|||
|
|
- [ ] 创建 Feedback 数据模型(feedback.dart)
|
|||
|
|
- [ ] 创建 FeedbackApi(feedback_api.dart)
|
|||
|
|
- [ ] 创建 FeedbackRepository(feedback_repository.dart)
|
|||
|
|
- [ ] 创建图片选择和上传组件(image_picker + Supabase Storage)
|
|||
|
|
- [ ] 创建 FeedbackScreen(表单 UI + 图片上传)
|
|||
|
|
- [ ] 在 SettingsScreen 添加反馈入口
|
|||
|
|
- [ ] 添加 l10n keys(中/英/繁)
|
|||
|
|
|
|||
|
|
### Phase 3: 报告生成与邮件
|
|||
|
|
|
|||
|
|
- [x] 创建 xlsx 报告生成模块(v1/feedback/report.py),包含图片嵌入
|
|||
|
|
- [x] 创建定时任务(v1/feedback/tasks.py),worker-general 启动时注册 cron
|
|||
|
|
- [x] 配置 Taskiq 定时任务(RedisScheduleSource + startup 事件)
|
|||
|
|
- [ ] 添加 `aiosmtplib` 依赖(`uv add aiosmtplib`)
|
|||
|
|
- [ ] 添加 `EmailSettings` 到 `core/config/settings.py`
|
|||
|
|
- [ ] 更新 `.env.example` / `.env`,添加 SMTP 环境变量
|
|||
|
|
- [ ] 创建 `core/email/sender.py`(无状态 EmailSender 类)
|
|||
|
|
- [ ] 创建 `core/email/template_loader.py`(模板动态加载器)
|
|||
|
|
- [ ] 创建 `core/email/templates/feedback/daily_report.html`(HTML 邮件模板)
|
|||
|
|
- [ ] 在 `generate_daily_feedback_report` 中集成邮件发送(模板渲染 + 附件)
|
|||
|
|
- [ ] 发送后清理临时报告文件
|
|||
|
|
- [ ] 设置 `ERYAO_FEEDBACK_REPORT__ENABLED=true` 启用推送
|
|||
|
|
|
|||
|
|
### Phase 4: 测试与验证
|
|||
|
|
|
|||
|
|
- [ ] 单元测试:Repository、Service、Schema 验证
|
|||
|
|
- [ ] 集成测试:API 端点
|
|||
|
|
- [ ] 手动测试:前端提交流程
|
|||
|
|
- [ ] 手动测试:图片上传(从相册选择)
|
|||
|
|
- [ ] 手动测试:定时任务触发(Phase 3)
|
|||
|
|
- [ ] 手动测试:xlsx 报告包含嵌入图片(Phase 3)
|
|||
|
|
- [ ] 手动测试:临时文件清理(Phase 3)
|
|||
|
|
|
|||
|
|
## 7. 相关代码文件
|
|||
|
|
|
|||
|
|
### Backend
|
|||
|
|
|
|||
|
|
| 文件 | 说明 | 变更类型 |
|
|||
|
|
|------|------|----------|
|
|||
|
|
| `pyproject.toml` | 添加 openpyxl、httpx、aiosmtplib 依赖 | 修改 |
|
|||
|
|
| `.env.example` | 添加 Storage 和 Feedback Report 环境变量 | 修改 |
|
|||
|
|
| `.env` | 添加 Storage 和 Feedback Report 环境变量 | 修改 |
|
|||
|
|
| `backend/src/core/config/settings.py` | 添加 FeedbackSettings、FeedbackReportSettings | 修改 |
|
|||
|
|
| `backend/alembic/versions/YYYYMMDD_N_create_user_feedback.py` | 数据库迁移 | 新增 |
|
|||
|
|
| `backend/src/models/feedback.py` | Feedback 模型 | 新增 |
|
|||
|
|
| `backend/src/v1/feedback/schemas.py` | Pydantic Schema(强约束) | 新增 |
|
|||
|
|
| `backend/src/v1/feedback/repository.py` | 数据访问层 | 新增 |
|
|||
|
|
| `backend/src/v1/feedback/service.py` | 业务逻辑层 | 新增 |
|
|||
|
|
| `backend/src/v1/feedback/router.py` | API 路由 | 新增 |
|
|||
|
|
| `backend/src/v1/feedback/tasks.py` | 定时任务(Phase 3) | 新增 |
|
|||
|
|
| `backend/src/v1/feedback/report.py` | 报告生成+图片嵌入+临时文件清理(Phase 3) | 新增 |
|
|||
|
|
| `backend/src/core/email/__init__.py` | 邮件模块初始化 | 新增 |
|
|||
|
|
| `backend/src/core/email/sender.py` | 邮件发送(无状态 EmailSender,Phase 3) | 新增 |
|
|||
|
|
| `backend/src/core/email/template_loader.py` | 邮件模板动态加载器(Phase 3) | 新增 |
|
|||
|
|
| `backend/src/core/email/templates/feedback/daily_report.html` | 反馈日报 HTML 邮件模板(Phase 3) | 新增 |
|
|||
|
|
| `backend/src/v1/router.py` | 注册 feedback router | 修改 |
|
|||
|
|
| `docs/protocols/common/http-error-codes.md` | 添加反馈相关错误码 | 修改 |
|
|||
|
|
|
|||
|
|
### Frontend
|
|||
|
|
|
|||
|
|
| 文件 | 说明 | 优先级 |
|
|||
|
|
|------|------|--------|
|
|||
|
|
| `apps/lib/features/settings/data/models/feedback.dart` | Feedback 数据模型 | P0 |
|
|||
|
|
| `apps/lib/features/settings/data/apis/feedback_api.dart` | API 调用 | P0 |
|
|||
|
|
| `apps/lib/features/settings/data/repositories/feedback_repository.dart` | Repository | P0 |
|
|||
|
|
| `apps/lib/features/settings/presentation/widgets/image_picker_widget.dart` | 图片选择和上传组件 | P0 |
|
|||
|
|
| `apps/lib/features/settings/presentation/screens/feedback_screen.dart` | 反馈表单页 | P0 |
|
|||
|
|
| `apps/lib/features/settings/presentation/screens/settings_screen.dart` | 添加反馈入口 | P0 |
|
|||
|
|
| `apps/lib/l10n/app_zh.arb` | 中文翻译 | P0 |
|
|||
|
|
| `apps/lib/l10n/app_en.arb` | 英文翻译 | P0 |
|
|||
|
|
| `apps/lib/l10n/app_zh_hant.arb` | 繁体中文翻译 | P0 |
|
|||
|
|
|
|||
|
|
## 8. 错误码定义
|
|||
|
|
|
|||
|
|
在 `docs/protocols/common/http-error-codes.md` 添加:
|
|||
|
|
|
|||
|
|
| code | status | meaning | frontend handling |
|
|||
|
|
|------|--------|---------|------------------|
|
|||
|
|
| `FEEDBACK_CONTENT_EMPTY` | 400 | 反馈内容为空 | 提示用户输入内容 |
|
|||
|
|
| `FEEDBACK_CONTENT_TOO_LONG` | 400 | 反馈内容超过 500 字符 | 提示字数限制 |
|
|||
|
|
| `FEEDBACK_TOO_MANY_IMAGES` | 400 | 图片数量超过 3 张 | 提示图片数量限制 |
|
|||
|
|
| `FEEDBACK_IMAGE_TOO_LARGE` | 400 | 图片大小超过 5MB | 提示图片大小限制 |
|
|||
|
|
| `FEEDBACK_INVALID_IMAGE_TYPE` | 400 | 图片类型不支持(仅支持 jpg/png) | 提示图片格式 |
|
|||
|
|
| `FEEDBACK_SUBMIT_FAILED` | 500 | 反馈提交失败 | 显示重试提示 |
|
|||
|
|
|
|||
|
|
## 9. 国际化文案
|
|||
|
|
|
|||
|
|
### 中文(app_zh.arb)
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"settingsFeedbackTitle": "意见反馈",
|
|||
|
|
"feedbackTitle": "意见反馈",
|
|||
|
|
"feedbackTypeLabel": "反馈类型",
|
|||
|
|
"feedbackTypeBug": "问题反馈",
|
|||
|
|
"feedbackTypeSuggestion": "功能建议",
|
|||
|
|
"feedbackTypeOther": "其他",
|
|||
|
|
"feedbackContentLabel": "反馈内容",
|
|||
|
|
"feedbackContentHint": "请详细描述您的问题或建议...",
|
|||
|
|
"feedbackImagesLabel": "添加截图(最多3张)",
|
|||
|
|
"feedbackAnonymousLabel": "不上传我的个人信息",
|
|||
|
|
"feedbackAnonymousHint": "勾选后将不采集您的用户ID,仅采集设备信息用于问题排查",
|
|||
|
|
"feedbackSubmit": "提交反馈",
|
|||
|
|
"feedbackSubmitting": "提交中...",
|
|||
|
|
"feedbackSuccess": "感谢您的反馈,我们会尽快处理",
|
|||
|
|
"feedbackContentRequired": "请输入反馈内容",
|
|||
|
|
"feedbackContentTooLong": "反馈内容不能超过 500 字",
|
|||
|
|
"feedbackTooManyImages": "最多只能上传 3 张图片"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 英文(app_en.arb)
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"settingsFeedbackTitle": "Feedback",
|
|||
|
|
"feedbackTitle": "Feedback",
|
|||
|
|
"feedbackTypeLabel": "Feedback Type",
|
|||
|
|
"feedbackTypeBug": "Bug Report",
|
|||
|
|
"feedbackTypeSuggestion": "Feature Suggestion",
|
|||
|
|
"feedbackTypeOther": "Other",
|
|||
|
|
"feedbackContentLabel": "Content",
|
|||
|
|
"feedbackContentHint": "Please describe your issue or suggestion in detail...",
|
|||
|
|
"feedbackImagesLabel": "Add Screenshots (max 3)",
|
|||
|
|
"feedbackAnonymousLabel": "Do not upload my personal information",
|
|||
|
|
"feedbackAnonymousHint": "If checked, your user ID will not be collected. Device info will still be collected for troubleshooting.",
|
|||
|
|
"feedbackSubmit": "Submit Feedback",
|
|||
|
|
"feedbackSubmitting": "Submitting...",
|
|||
|
|
"feedbackSuccess": "Thank you for your feedback. We will process it soon.",
|
|||
|
|
"feedbackContentRequired": "Please enter feedback content",
|
|||
|
|
"feedbackContentTooLong": "Feedback content cannot exceed 500 characters",
|
|||
|
|
"feedbackTooManyImages": "Maximum 3 images allowed"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 10. 测试计划
|
|||
|
|
|
|||
|
|
### 10.1 单元测试
|
|||
|
|
|
|||
|
|
- [ ] FeedbackCreateRequest Schema 验证
|
|||
|
|
- [ ] DeviceInfo Schema 验证
|
|||
|
|
- [ ] FeedbackRepository CRUD 操作
|
|||
|
|
- [ ] FeedbackService 业务逻辑
|
|||
|
|
- [ ] xlsx 报告生成(Phase 3)
|
|||
|
|
- [ ] 邮件发送 mock(Phase 3)
|
|||
|
|
- [ ] 邮件模板渲染(Phase 3)
|
|||
|
|
|
|||
|
|
### 10.2 集成测试
|
|||
|
|
|
|||
|
|
- [ ] POST /api/v1/feedback 成功提交(登录用户)
|
|||
|
|
- [ ] POST /api/v1/feedback 成功提交(匿名用户)
|
|||
|
|
- [ ] POST /api/v1/feedback 参数验证失败
|
|||
|
|
- [ ] POST /api/v1/feedback 图片数量超限
|
|||
|
|
|
|||
|
|
### 10.3 手动测试
|
|||
|
|
|
|||
|
|
- [ ] 前端提交流程完整测试
|
|||
|
|
- [ ] 图片上传测试
|
|||
|
|
- [ ] 定时任务触发验证(Phase 3)
|
|||
|
|
- [ ] 邮件接收验证(Phase 3)
|
|||
|
|
- [ ] xlsx 报告格式验证(Phase 3)
|
|||
|
|
- [ ] 邮件 HTML 样式验证(Phase 3)
|
|||
|
|
- [ ] 附件文件名格式验证(Phase 3)
|
|||
|
|
|
|||
|
|
## 11. 注意事项
|
|||
|
|
|
|||
|
|
1. **Schema 强约束**:所有字段必须有明确的类型和验证规则,使用 `extra="forbid"` 禁止模糊字段
|
|||
|
|
2. **匿名化处理**:
|
|||
|
|
- 前端勾选框"不上传我的个人信息"
|
|||
|
|
- 勾选后前端不传 `Authorization` 头,后端 `user_id = NULL`
|
|||
|
|
- **不需要 `is_anonymous` 字段**,`user_id` 为 NULL 就是匿名
|
|||
|
|
- 设备信息、App版本等照样采集(不涉及隐私)
|
|||
|
|
3. **报告生成逻辑**:
|
|||
|
|
- `user_id IS NOT NULL`:报告中显示脱敏用户ID
|
|||
|
|
- `user_id IS NULL`:报告中用户列显示"匿名"
|
|||
|
|
4. **报告时间范围**:
|
|||
|
|
- 按固定每日周期采集,根据 `created_at` 筛选
|
|||
|
|
- 推送时间:默认每天 10:00(可配置)
|
|||
|
|
- 采集范围:昨天 10:00 到今天 10:00
|
|||
|
|
- 例如:2026-04-17 10:00 推送,采集 `2026-04-16 10:00:00 ≤ created_at < 2026-04-17 10:00:00`
|
|||
|
|
5. **图片上传流程**:
|
|||
|
|
- 前端从相册选择图片(使用 `image_picker`)
|
|||
|
|
- 前端通过 `multipart/form-data` 上传图片 + 表单数据
|
|||
|
|
- 后端接收文件,上传到 Supabase Storage(私有 bucket)
|
|||
|
|
- 后端保存 Storage 路径到数据库(不需要公开 URL)
|
|||
|
|
- 最多 3 张图片,每张最大 5MB
|
|||
|
|
6. **用户不可见自己的反馈**:图片存储为私有路径,用户提交后无法查看
|
|||
|
|
7. **Storage Bucket 自动创建**:
|
|||
|
|
- 复用 `SupabaseService._ensure_storage_bucket()` 方法
|
|||
|
|
- 在 Settings 中添加 `StorageSettings.FeedbackSettings` 配置
|
|||
|
|
- 服务启动时自动检查并创建 bucket,无需手动操作
|
|||
|
|
8. **环境变量管理**:
|
|||
|
|
- Storage bucket 名称:`ERYAO_STORAGE__FEEDBACK__BUCKET`
|
|||
|
|
- 客服邮箱:`ERYAO_FEEDBACK_REPORT__EMAIL`
|
|||
|
|
- 推送时间:`ERYAO_FEEDBACK_REPORT__CRON`
|
|||
|
|
- SMTP 配置:`ERYAO_EMAIL__HOST`、`ERYAO_EMAIL__PORT`、`ERYAO_EMAIL__USERNAME`、`ERYAO_EMAIL__PASSWORD`
|
|||
|
|
- 所有配置项必须添加到 `.env.example` 和 `.env`
|
|||
|
|
9. **设备信息收集**:无论是否匿名,都收集设备型号、系统版本(不涉及隐私)
|
|||
|
|
10. **定时任务架构**:
|
|||
|
|
- 使用 `worker-general` 独立进程(与主 web 进程分离)
|
|||
|
|
- 不阻塞主进程 API 请求
|
|||
|
|
- 独立日志:`logs/worker-general.log`
|
|||
|
|
- 可独立重启,不影响 web 服务
|
|||
|
|
11. **邮件发送架构**:
|
|||
|
|
- 无状态工具类 `core/email/sender.py`,不是 `services/base/` 下的有状态服务
|
|||
|
|
- 使用飞书企业邮箱 SMTP(`smtp.feishu.cn:465`,发件人 `robot@xunmee.com`)
|
|||
|
|
- 每次发送:连接 → 发邮件 → 断开,不需要 initialize/close 生命周期
|
|||
|
|
12. **邮件模板**:
|
|||
|
|
- HTML 模板存放在 `core/email/templates/{category}/` 下
|
|||
|
|
- 运行时从磁盘动态加载,修改模板文件即可改变邮件格式,无需重新部署
|
|||
|
|
- 当前只有 `feedback/daily_report.html`,后续可扩展 `notification/`、`marketing/` 等
|
|||
|
|
- 使用 `string.Template` 做变量替换,不引入 Jinja2
|
|||
|
|
13. **报告格式**:使用 xlsx(openpyxl),从 Storage 下载图片并嵌入报告
|
|||
|
|
13. **临时文件清理**:
|
|||
|
|
- xlsx 报告保存到临时目录(`tempfile.gettempdir()`)
|
|||
|
|
- 发送邮件后**立即删除**临时文件
|
|||
|
|
- 使用 `try-finally` 确保清理逻辑一定执行
|
|||
|
|
14. **错误处理**:遵循 RFC 7807 规范,添加错误码到协议文档
|
|||
|
|
14. **国际化**:支持中/英/繁三种语言
|
|||
|
|
15. **数据保留**:反馈数据保留策略待定(建议至少 90 天)
|
|||
|
|
16. **隐私合规**:反馈功能入口需在隐私政策中说明
|
|||
|
|
17. **监控告警**:定时任务失败需有告警机制(Phase 3)
|