Files
eryao/.trellis/tasks/archive/2026-04/04-17-feat-user-feedback/prd.md
T

60 KiB
Raw Blame History

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 数据库表设计

-- 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 '用户IDNULL表示匿名(勾选"不上传我的个人信息"),报告生成时根据此字段决定是否包含用户数据';
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(强约束)

# 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 的 FormUploadFile

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"}

#### 前端 SchemaDart

```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): 提交失败

后端路由实现

# 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       # 新增

组件设计

// 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),
            ),
          ],
        ),
      ),
    );
  }
}

图片选择与上传实现

// 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 中添加:

############
# 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 配置更新

# 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 配置更新

# 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 更新

# 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 实现

# 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 实现每日报告生成和邮件发送:

# 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/loggingcore/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 配置

# 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 实现

# 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 falseSTARTTLS

4.7.7 环境变量配置

############
# 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 模板加载器

# 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

<!-- 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 调用示例

# 在 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
# 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 启动:

# 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.logworker-general.error.log
  • 可独立重启,不影响 web 服务
  • 通过 ERYAO_WORKER__GROUPS__GENERAL__CONCURRENCY 控制并发数

定时任务实现

# 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 进程分离)
    - Brokerworker_general_brokerRedis "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.FeedbackSettingsFeedbackReportSettings
  • 更新 .env.example.env,添加 Storage 和 Feedback Report 环境变量
  • 创建 user_feedback 表迁移文件
  • 创建 Supabase Storage bucket(通过 Dashboard 或 SQL
  • 创建 Pydantic Schemaschemas.pyextra="forbid"
  • 创建 Feedback 模型(models/feedback.py
  • 创建 FeedbackRepositoryCRUD 操作)
  • 创建 FeedbackService(业务逻辑)
  • 创建 POST /api/v1/feedback 接口
  • 添加错误码到 docs/protocols/common/http-error-codes.md

Phase 2: 前端实现(当前)

  • 创建 Feedback 数据模型(feedback.dart
  • 创建 FeedbackApifeedback_api.dart
  • 创建 FeedbackRepositoryfeedback_repository.dart
  • 创建图片选择和上传组件(image_picker + Supabase Storage
  • 创建 FeedbackScreen(表单 UI + 图片上传)
  • 在 SettingsScreen 添加反馈入口
  • 添加 l10n keys(中/英/繁)

Phase 3: 报告生成与邮件

  • 创建 xlsx 报告生成模块(v1/feedback/report.py),包含图片嵌入
  • 创建定时任务(v1/feedback/tasks.py),worker-general 启动时注册 cron
  • 配置 Taskiq 定时任务(RedisScheduleSource + startup 事件)
  • 添加 aiosmtplib 依赖(uv add aiosmtplib
  • 添加 EmailSettingscore/config/settings.py
  • 更新 .env.example / .env,添加 SMTP 环境变量
  • 创建 core/email/sender.py(无状态 EmailSender 类)
  • 创建 core/email/template_loader.py(模板动态加载器)
  • 创建 core/email/templates/feedback/daily_report.htmlHTML 邮件模板)
  • 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 邮件发送(无状态 EmailSenderPhase 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

{
  "settingsFeedbackTitle": "意见反馈",
  "feedbackTitle": "意见反馈",
  "feedbackTypeLabel": "反馈类型",
  "feedbackTypeBug": "问题反馈",
  "feedbackTypeSuggestion": "功能建议",
  "feedbackTypeOther": "其他",
  "feedbackContentLabel": "反馈内容",
  "feedbackContentHint": "请详细描述您的问题或建议...",
  "feedbackImagesLabel": "添加截图(最多3张)",
  "feedbackAnonymousLabel": "不上传我的个人信息",
  "feedbackAnonymousHint": "勾选后将不采集您的用户ID,仅采集设备信息用于问题排查",
  "feedbackSubmit": "提交反馈",
  "feedbackSubmitting": "提交中...",
  "feedbackSuccess": "感谢您的反馈,我们会尽快处理",
  "feedbackContentRequired": "请输入反馈内容",
  "feedbackContentTooLong": "反馈内容不能超过 500 字",
  "feedbackTooManyImages": "最多只能上传 3 张图片"
}

英文(app_en.arb

{
  "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
  • 邮件发送 mockPhase 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__HOSTERYAO_EMAIL__PORTERYAO_EMAIL__USERNAMEERYAO_EMAIL__PASSWORD
    • 所有配置项必须添加到 .env.example.env
  9. 设备信息收集:无论是否匿名,都收集设备型号、系统版本(不涉及隐私)
  10. 定时任务架构
    • 使用 worker-general 独立进程(与主 web 进程分离)
    • 不阻塞主进程 API 请求
    • 独立日志:logs/worker-general.log
    • 可独立重启,不影响 web 服务
  11. 邮件发送架构
    • 无状态工具类 core/email/sender.py,不是 services/base/ 下的有状态服务
    • 使用飞书企业邮箱 SMTPsmtp.feishu.cn:465,发件人 robot@xunmee.com
    • 每次发送:连接 → 发邮件 → 断开,不需要 initialize/close 生命周期
  12. 邮件模板
    • HTML 模板存放在 core/email/templates/{category}/
    • 运行时从磁盘动态加载,修改模板文件即可改变邮件格式,无需重新部署
    • 当前只有 feedback/daily_report.html,后续可扩展 notification/marketing/
    • 使用 string.Template 做变量替换,不引入 Jinja2
  13. 报告格式:使用 xlsxopenpyxl),从 Storage 下载图片并嵌入报告
  14. 临时文件清理
    • xlsx 报告保存到临时目录(tempfile.gettempdir()
    • 发送邮件后立即删除临时文件
    • 使用 try-finally 确保清理逻辑一定执行
  15. 错误处理:遵循 RFC 7807 规范,添加错误码到协议文档
  16. 国际化:支持中/英/繁三种语言
  17. 数据保留:反馈数据保留策略待定(建议至少 90 天)
  18. 隐私合规:反馈功能入口需在隐私政策中说明
  19. 监控告警:定时任务失败需有告警机制(Phase 3)