diff --git a/.env.example b/.env.example index b2a158f..0ec5098 100644 --- a/.env.example +++ b/.env.example @@ -57,8 +57,28 @@ ERYAO_STORAGE__AVATAR__BUCKET=avatars ERYAO_STORAGE__SIGNED_URL_TTL_SECONDS=600 ERYAO_STORAGE__ATTACHMENT__MAX_SIZE_MB=20 ERYAO_STORAGE__AVATAR__MAX_SIZE_MB=2 +ERYAO_STORAGE__FEEDBACK__BUCKET=feedback-images +ERYAO_STORAGE__FEEDBACK__MAX_SIZE_MB=5 ERYAO_STORAGE__RETENTION_DAYS=30 +############ +# Feedback Report +############ +ERYAO_FEEDBACK_REPORT__EMAIL=support@example.com +ERYAO_FEEDBACK_REPORT__CRON=0 10 * * * +ERYAO_FEEDBACK_REPORT__ENABLED=false + +############ +# 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 反馈系统 + ############ # LLM API KEY ############ diff --git a/.trellis/tasks/04-17-feat-user-feedback/check.jsonl b/.trellis/tasks/04-17-feat-user-feedback/check.jsonl new file mode 100644 index 0000000..d1fe1d0 --- /dev/null +++ b/.trellis/tasks/04-17-feat-user-feedback/check.jsonl @@ -0,0 +1,22 @@ +{"check": "dependency_installed", "description": "验证 openpyxl 已安装", "command": "uv pip show openpyxl"} +{"check": "migration_file_exists", "description": "验证迁移文件已创建", "command": "ls backend/alembic/versions/*create_user_feedback.py"} +{"check": "schema_file_exists", "description": "验证 Schema 文件已创建", "command": "test -f backend/src/v1/feedback/schemas.py"} +{"check": "schema_strict_validation", "description": "验证 Schema 使用 extra=forbid 强约束", "command": "grep -q 'extra.*forbid' backend/src/v1/feedback/schemas.py"} +{"check": "model_file_exists", "description": "验证模型文件已创建", "command": "test -f backend/src/models/feedback.py"} +{"check": "repository_file_exists", "description": "验证 Repository 文件已创建", "command": "test -f backend/src/v1/feedback/repository.py"} +{"check": "service_file_exists", "description": "验证 Service 文件已创建", "command": "test -f backend/src/v1/feedback/service.py"} +{"check": "router_file_exists", "description": "验证路由文件已创建", "command": "test -f backend/src/v1/feedback/router.py"} +{"check": "router_registered", "description": "验证路由已注册", "command": "grep -q 'feedback' backend/src/v1/router.py"} +{"check": "error_codes_added", "description": "验证错误码已添加", "command": "grep -q 'FEEDBACK_' docs/protocols/common/http-error-codes.md"} +{"check": "frontend_model_exists", "description": "验证前端模型已创建", "command": "test -f apps/lib/features/settings/data/models/feedback.dart"} +{"check": "frontend_api_exists", "description": "验证前端 API 已创建", "command": "test -f apps/lib/features/settings/data/apis/feedback_api.dart"} +{"check": "frontend_screen_exists", "description": "验证前端反馈页已创建", "command": "test -f apps/lib/features/settings/presentation/screens/feedback_screen.dart"} +{"check": "l10n_zh_added", "description": "验证中文翻译已添加", "command": "grep -q 'settingsFeedbackTitle' apps/lib/l10n/app_zh.arb"} +{"check": "l10n_en_added", "description": "验证英文翻译已添加", "command": "grep -q 'settingsFeedbackTitle' apps/lib/l10n/app_en.arb"} +{"check": "database_table_exists", "description": "验证数据库表已创建", "command": "psql -c '\\d user_feedback'"} +{"check": "api_submit_works", "description": "测试反馈提交 API(匿名用户)", "command": "curl -X POST http://localhost:8000/api/v1/feedback -H 'Content-Type: application/json' -d '{\"feedback_type\":\"bug\",\"content\":\"test\",\"images\":[],\"device_info\":{\"platform\":\"ios\",\"model\":\"test\"},\"app_version\":\"1.0\",\"os_version\":\"iOS 17\"}'"} +{"check": "api_validation_content_empty", "description": "测试 API 参数验证(内容为空)", "command": "curl -X POST http://localhost:8000/api/v1/feedback -H 'Content-Type: application/json' -d '{\"feedback_type\":\"bug\",\"content\":\"\",\"images\":[],\"device_info\":{\"platform\":\"ios\",\"model\":\"test\"},\"app_version\":\"1.0\",\"os_version\":\"iOS 17\"}' | grep -q 'FEEDBACK_CONTENT_EMPTY'"} +{"check": "api_validation_too_many_images", "description": "测试 API 参数验证(图片超限)", "command": "curl -X POST http://localhost:8000/api/v1/feedback -H 'Content-Type: application/json' -d '{\"feedback_type\":\"bug\",\"content\":\"test\",\"images\":[\"url1\",\"url2\",\"url3\",\"url4\"],\"device_info\":{\"platform\":\"ios\",\"model\":\"test\"},\"app_version\":\"1.0\",\"os_version\":\"iOS 17\"}' | grep -q 'FEEDBACK_TOO_MANY_IMAGES'"} +{"check": "frontend_builds", "description": "验证前端编译通过", "command": "cd apps && flutter analyze"} +{"check": "backend_typecheck", "description": "验证后端类型检查通过", "command": "uv run basedpyright backend/src/v1/feedback/"} +{"check": "backend_lint", "description": "验证后端 lint 通过", "command": "uv run ruff check backend/src/v1/feedback/"} diff --git a/.trellis/tasks/04-17-feat-user-feedback/implement.jsonl b/.trellis/tasks/04-17-feat-user-feedback/implement.jsonl new file mode 100644 index 0000000..cadf5ac --- /dev/null +++ b/.trellis/tasks/04-17-feat-user-feedback/implement.jsonl @@ -0,0 +1,20 @@ +{"step": 1, "action": "add_dependency", "description": "添加 openpyxl 依赖", "command": "uv add openpyxl", "files": ["pyproject.toml"]} +{"step": 2, "action": "create_migration", "description": "创建 user_feedback 数据库表迁移", "files": ["backend/alembic/versions/20260417_1_create_user_feedback.py"]} +{"step": 3, "action": "create_storage_bucket", "description": "创建 Supabase Storage feedback bucket", "files": ["supabase/storage/feedback"]} +{"step": 4, "action": "create_schemas", "description": "创建 Pydantic Schema(强约束,extra=forbid)", "files": ["backend/src/v1/feedback/schemas.py"]} +{"step": 5, "action": "create_model", "description": "创建 Feedback 数据库模型", "files": ["backend/src/models/feedback.py"]} +{"step": 6, "action": "create_repository", "description": "创建 FeedbackRepository(CRUD 操作)", "files": ["backend/src/v1/feedback/repository.py"]} +{"step": 7, "action": "create_service", "description": "创建 FeedbackService(业务逻辑)", "files": ["backend/src/v1/feedback/service.py"]} +{"step": 8, "action": "create_router", "description": "创建 POST /api/v1/feedback 路由", "files": ["backend/src/v1/feedback/router.py"]} +{"step": 9, "action": "register_router", "description": "注册 feedback router 到主路由", "files": ["backend/src/v1/router.py"]} +{"step": 10, "action": "add_error_codes", "description": "添加反馈相关错误码到协议文档", "files": ["docs/protocols/common/http-error-codes.md"]} +{"step": 11, "action": "create_frontend_model", "description": "创建前端 Feedback 数据模型", "files": ["apps/lib/features/settings/data/models/feedback.dart"]} +{"step": 12, "action": "create_frontend_api", "description": "创建前端 FeedbackApi", "files": ["apps/lib/features/settings/data/apis/feedback_api.dart"]} +{"step": 13, "action": "create_frontend_repository", "description": "创建前端 FeedbackRepository", "files": ["apps/lib/features/settings/data/repositories/feedback_repository.dart"]} +{"step": 14, "action": "create_feedback_screen", "description": "创建反馈表单页(含图片上传组件)", "files": ["apps/lib/features/settings/presentation/screens/feedback_screen.dart"]} +{"step": 15, "action": "add_feedback_entry", "description": "在 SettingsScreen 添加反馈入口", "files": ["apps/lib/features/settings/presentation/screens/settings_screen.dart"]} +{"step": 16, "action": "add_l10n_zh", "description": "添加中文翻译", "files": ["apps/lib/l10n/app_zh.arb"]} +{"step": 17, "action": "add_l10n_en", "description": "添加英文翻译", "files": ["apps/lib/l10n/app_en.arb"]} +{"step": 18, "action": "add_l10n_zh_hant", "description": "添加繁体中文翻译", "files": ["apps/lib/l10n/app_zh_hant.arb"]} +{"step": 19, "action": "run_migration", "description": "执行数据库迁移", "command": "./infra/scripts/dev-migrate.sh migrate"} +{"step": 20, "action": "run_l10n_gen", "description": "生成国际化代码", "command": "cd apps && flutter gen-l10n"} diff --git a/.trellis/tasks/04-17-feat-user-feedback/prd.md b/.trellis/tasks/04-17-feat-user-feedback/prd.md new file mode 100644 index 0000000..fa33a93 --- /dev/null +++ b/.trellis/tasks/04-17-feat-user-feedback/prd.md @@ -0,0 +1,1692 @@ +# 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( + 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 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> pickImages() async { + final List images = await _picker.pickMultiImage( + maxWidth: 1920, + maxHeight: 1080, + imageQuality: 85, + ); + + if (images.length > 3) { + throw Exception('最多只能选择3张图片'); + } + + return images; +} + +// 2. 通过 multipart/form-data 提交反馈 +Future submitFeedback({ + required FeedbackType feedbackType, + required String content, + required DeviceInfo deviceInfo, + required String appVersion, + required String osVersion, + required List 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 + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+

+ 用户反馈日报 +

+
+

+ 您好, +

+

+ 以下是 ${start_date} ${start_hour}:00 至 + ${end_date} ${end_hour}:00 期间的用户反馈汇总。 +

+ + + + + + + + +
+

${total_count}

+

反馈总数

+
+

${bug_count}

+

问题反馈

+
+

${suggestion_count}

+

功能建议

+
+ +

+ 详细反馈内容及截图请查看附件中的 xlsx 报告。 +

+
+

+ 此邮件由 Eryao 反馈系统自动发送,请勿直接回复。 +
报告生成时间:${generated_at} +

+
+
+ + +``` + +#### 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) diff --git a/.trellis/tasks/04-17-feat-user-feedback/task.json b/.trellis/tasks/04-17-feat-user-feedback/task.json new file mode 100644 index 0000000..22cb0ab --- /dev/null +++ b/.trellis/tasks/04-17-feat-user-feedback/task.json @@ -0,0 +1,75 @@ +{ + "name": "feat-user-feedback", + "title": "用户反馈投送功能", + "type": "feature", + "priority": "medium", + "dev_type": "fullstack", + "created_at": "2026-04-17", + "status": "planning", + "worktree": "feat-user-feedback", + "branch": "worktree/feat-user-feedback", + "description": "实现用户反馈投送功能:前端反馈表单 + 后端数据存储 + 图片上传。Phase 2 实现定时报告生成和邮件推送。", + "prd": "prd.md", + "phase": { + "current": 1, + "total": 3, + "description": "Phase 1: 前端反馈 + 后端存储 + 图片上传" + }, + "tech_stack": { + "backend": { + "language": "Python", + "framework": "FastAPI", + "orm": "SQLAlchemy", + "task_queue": "Taskiq", + "xlsx_lib": "openpyxl" + }, + "frontend": { + "language": "Dart", + "framework": "Flutter", + "storage": "Supabase Storage" + } + }, + "features": { + "anonymous_feedback": true, + "image_upload": true, + "max_images": 3, + "max_content_length": 500, + "report_generation": false, + "email_notification": false + }, + "constraints": { + "schema_strict": "所有字段使用 Pydantic Schema 强约束,extra=forbid", + "no_contact_email": "删除 contact_email 字段,不存储用户联系方式", + "no_admin_routes": "暂不实现管理员路由", + "config_via_settings": "客服邮箱和推送时间通过 Settings 环境变量配置" + }, + "checklist": { + "backend": [ + "添加 openpyxl 依赖", + "创建 user_feedback 表迁移(含 images JSONB 字段)", + "创建 Supabase Storage feedback bucket", + "创建 Pydantic Schema(强约束,extra=forbid)", + "创建 Feedback 模型", + "创建 FeedbackRepository(CRUD 操作)", + "创建 FeedbackService(业务逻辑)", + "创建 POST /api/v1/feedback 接口", + "添加错误码到协议文档" + ], + "frontend": [ + "创建 Feedback 数据模型", + "创建 FeedbackApi", + "创建 FeedbackRepository", + "创建 FeedbackScreen(含图片上传组件)", + "在 SettingsScreen 添加反馈入口", + "添加 l10n keys(中/英/繁)" + ] + }, + "notes": [ + "Phase 1 只实现前端反馈 + 后端存储 + 图片上传", + "Phase 2 实现定时报告生成和邮件推送", + "使用 Python openpyxl 生成 xlsx 报告(技术栈统一)", + "Schema 使用 extra=forbid 禁止模糊字段", + "客服邮箱通过 FEEDBACK_REPORT_EMAIL 环境变量配置", + "推送时间通过 FEEDBACK_REPORT_CRON 环境变量配置" + ] +} diff --git a/apps/lib/data/network/api_client.dart b/apps/lib/data/network/api_client.dart index c90f7f4..7d54216 100644 --- a/apps/lib/data/network/api_client.dart +++ b/apps/lib/data/network/api_client.dart @@ -20,6 +20,10 @@ class ApiClient { _dio.interceptors.add( InterceptorsWrapper( onRequest: (options, handler) async { + if (options.extra['skipAuth'] == true) { + handler.next(options); + return; + } final token = await tokenProvider(); if (token != null && token.isNotEmpty) { options.headers['Authorization'] = 'Bearer $token'; diff --git a/apps/lib/features/home/presentation/screens/home_screen.dart b/apps/lib/features/home/presentation/screens/home_screen.dart index 8804cf6..8bff2ad 100644 --- a/apps/lib/features/home/presentation/screens/home_screen.dart +++ b/apps/lib/features/home/presentation/screens/home_screen.dart @@ -73,16 +73,16 @@ class HomeScreen extends StatefulWidget { class _HomeScreenState extends State { MainTab _currentTab = MainTab.home; late final InviteRepository _inviteRepository; + late final ApiClient _apiClient; @override void initState() { super.initState(); - final inviteApi = InviteApi( - apiClient: ApiClient( - baseUrl: appDependencies.backendUrl, - tokenProvider: widget.sessionStore.getToken, - ), + _apiClient = ApiClient( + baseUrl: appDependencies.backendUrl, + tokenProvider: widget.sessionStore.getToken, ); + final inviteApi = InviteApi(apiClient: _apiClient); _inviteRepository = InviteRepositoryImpl(inviteApi: inviteApi); WidgetsBinding.instance.addPostFrameCallback((_) { _tryShowWelcomeDialog(); @@ -135,6 +135,7 @@ class _HomeScreenState extends State { settings: widget.profileSettings, coinBalance: widget.coinBalance, inviteRepository: _inviteRepository, + apiClient: _apiClient, onLocaleChanged: widget.onLocaleChanged, onSettingsChanged: widget.onProfileSettingsChanged, onSaveProfile: widget.onSaveProfile, @@ -563,6 +564,7 @@ class _ProfileTab extends StatelessWidget { required this.settings, required this.coinBalance, required this.inviteRepository, + required this.apiClient, required this.onLocaleChanged, required this.onSettingsChanged, required this.onSaveProfile, @@ -575,6 +577,7 @@ class _ProfileTab extends StatelessWidget { final ProfileSettingsV1 settings; final int coinBalance; final InviteRepository inviteRepository; + final ApiClient apiClient; final Future Function(String languageTag) onLocaleChanged; final Future Function(ProfileSettingsV1 settings) onSettingsChanged; final Future Function(ProfileSettingsV1 updated) @@ -590,6 +593,7 @@ class _ProfileTab extends StatelessWidget { settings: settings, coinBalance: coinBalance, inviteRepository: inviteRepository, + apiClient: apiClient, onInterfaceLanguageChanged: onLocaleChanged, onSettingsChanged: onSettingsChanged, onSaveProfile: onSaveProfile, diff --git a/apps/lib/features/settings/data/apis/feedback_api.dart b/apps/lib/features/settings/data/apis/feedback_api.dart new file mode 100644 index 0000000..3db0512 --- /dev/null +++ b/apps/lib/features/settings/data/apis/feedback_api.dart @@ -0,0 +1,85 @@ +import 'package:dio/dio.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../../../../core/logging/logger.dart'; +import '../../../../core/network/api_problem.dart'; +import '../../../../data/network/api_client.dart'; +import '../models/feedback.dart'; + +class FeedbackApi { + FeedbackApi({required ApiClient apiClient}) : _apiClient = apiClient; + + final ApiClient _apiClient; + final Logger _logger = getLogger('features.settings.feedback_api'); + + Future submitFeedback({ + required FeedbackType type, + required String content, + required DeviceInfo deviceInfo, + required String appVersion, + required String osVersion, + required List images, + required bool isAnonymous, + }) async { + final typeName = switch (type) { + FeedbackType.bug => 'bug', + FeedbackType.suggestion => 'suggestion', + FeedbackType.other => 'other', + }; + + final formData = FormData.fromMap({ + 'feedback_type': typeName, + 'content': content, + 'device_info': + '{"platform":"${deviceInfo.platform}","model":"${deviceInfo.model}"}', + 'app_version': appVersion, + 'os_version': osVersion, + }); + + for (final image in images) { + formData.files.add( + MapEntry( + 'images', + await MultipartFile.fromFile(image.path, filename: image.name), + ), + ); + } + + final options = isAnonymous + ? Options(extra: {'skipAuth': true}) + : Options(); + + try { + await _apiClient.rawDio.post( + '/api/v1/feedback', + data: formData, + options: options, + ); + } on DioException catch (error, stackTrace) { + _logger.error( + message: 'Submit feedback failed', + error: error, + stackTrace: stackTrace, + ); + throw _mapProblem(error); + } + } + + ApiProblem _mapProblem(DioException error) { + final status = error.response?.statusCode ?? 500; + final data = error.response?.data; + if (data is Map) { + return ApiProblem( + status: status, + title: (data['title'] as String?) ?? 'Request failed', + detail: (data['detail'] as String?) ?? '', + code: data['code'] as String?, + ); + } + return ApiProblem( + status: status, + title: 'Network error', + detail: error.message ?? 'Request failed', + ); + } +} diff --git a/apps/lib/features/settings/data/models/feedback.dart b/apps/lib/features/settings/data/models/feedback.dart new file mode 100644 index 0000000..2a255c9 --- /dev/null +++ b/apps/lib/features/settings/data/models/feedback.dart @@ -0,0 +1,8 @@ +enum FeedbackType { bug, suggestion, other } + +class DeviceInfo { + const DeviceInfo({required this.platform, required this.model}); + + final String platform; + final String model; +} diff --git a/apps/lib/features/settings/data/repositories/feedback_repository.dart b/apps/lib/features/settings/data/repositories/feedback_repository.dart new file mode 100644 index 0000000..26d4c3e --- /dev/null +++ b/apps/lib/features/settings/data/repositories/feedback_repository.dart @@ -0,0 +1,44 @@ +import 'package:image_picker/image_picker.dart'; + +import '../apis/feedback_api.dart'; +import '../models/feedback.dart'; + +abstract class FeedbackRepository { + Future submitFeedback({ + required FeedbackType type, + required String content, + required DeviceInfo deviceInfo, + required String appVersion, + required String osVersion, + required List images, + required bool isAnonymous, + }); +} + +class FeedbackRepositoryImpl implements FeedbackRepository { + FeedbackRepositoryImpl({required FeedbackApi feedbackApi}) + : _feedbackApi = feedbackApi; + + final FeedbackApi _feedbackApi; + + @override + Future submitFeedback({ + required FeedbackType type, + required String content, + required DeviceInfo deviceInfo, + required String appVersion, + required String osVersion, + required List images, + required bool isAnonymous, + }) { + return _feedbackApi.submitFeedback( + type: type, + content: content, + deviceInfo: deviceInfo, + appVersion: appVersion, + osVersion: osVersion, + images: images, + isAnonymous: isAnonymous, + ); + } +} diff --git a/apps/lib/features/settings/presentation/screens/feedback_screen.dart b/apps/lib/features/settings/presentation/screens/feedback_screen.dart new file mode 100644 index 0000000..60eeb50 --- /dev/null +++ b/apps/lib/features/settings/presentation/screens/feedback_screen.dart @@ -0,0 +1,389 @@ +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../../../../core/logging/logger.dart'; +import '../../../../data/network/api_client.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../shared/theme/design_tokens.dart'; +import '../../../../shared/widgets/toast/toast.dart'; +import '../../../../shared/widgets/toast/toast_type.dart'; +import '../../data/apis/feedback_api.dart'; +import '../../data/models/feedback.dart'; +import '../../data/repositories/feedback_repository.dart'; + +class FeedbackScreen extends StatefulWidget { + const FeedbackScreen({super.key, required this.apiClient}); + + final ApiClient apiClient; + + @override + State createState() => _FeedbackScreenState(); +} + +class _FeedbackScreenState extends State { + final Logger _logger = getLogger('features.settings.feedback_screen'); + final ImagePicker _imagePicker = ImagePicker(); + final TextEditingController _contentController = TextEditingController(); + + FeedbackType _selectedType = FeedbackType.bug; + List _selectedImages = []; + bool _isAnonymous = false; + bool _isSubmitting = false; + + static const int _maxImages = 3; + static const int _maxContentSize = 500; + static const int _maxImageSizeBytes = 5 * 1024 * 1024; + + @override + void dispose() { + _contentController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + + return Scaffold( + backgroundColor: colors.surfaceContainerLow, + appBar: AppBar( + title: Text(l10n.feedbackTitle), + centerTitle: true, + backgroundColor: colors.surfaceContainerLow, + surfaceTintColor: colors.surfaceContainerLow, + ), + body: ListView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.md, + AppSpacing.lg, + AppSpacing.xxl, + ), + children: [ + Text( + l10n.feedbackTypeLabel, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.sm), + SegmentedButton( + showSelectedIcon: false, + 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: (selection) { + setState(() { + _selectedType = selection.first; + }); + }, + ), + const SizedBox(height: AppSpacing.xl), + Text( + l10n.feedbackContentLabel, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.sm), + TextField( + controller: _contentController, + maxLines: 8, + maxLength: _maxContentSize, + decoration: InputDecoration( + hintText: l10n.feedbackContentHint, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), + ), + const SizedBox(height: AppSpacing.xl), + Text( + l10n.feedbackImagesLabel, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.sm), + _buildImagePickerRow(colors), + const SizedBox(height: AppSpacing.xl), + CheckboxListTile( + value: _isAnonymous, + onChanged: (value) { + setState(() { + _isAnonymous = value ?? false; + }); + }, + title: Text(l10n.feedbackAnonymousLabel), + subtitle: Text( + l10n.feedbackAnonymousHint, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant), + ), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + ), + const SizedBox(height: AppSpacing.xl), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _isSubmitting ? null : _submit, + child: _isSubmitting + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colors.onPrimary, + ), + ) + : Text(l10n.feedbackSubmit), + ), + ), + ], + ), + ); + } + + Widget _buildImagePickerRow(ColorScheme colors) { + return Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: [ + ..._selectedImages.asMap().entries.map((entry) { + final index = entry.key; + final file = entry.value; + return Stack( + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: colors.outlineVariant), + ), + clipBehavior: Clip.antiAlias, + child: kIsWeb + ? Image.network( + file.path, + fit: BoxFit.cover, + errorBuilder: (_, e, _) => + const Icon(Icons.broken_image), + ) + : Image.file( + File(file.path), + fit: BoxFit.cover, + errorBuilder: (_, e, _) => + const Icon(Icons.broken_image), + ), + ), + Positioned( + top: 4, + right: 4, + child: GestureDetector( + onTap: () => _removeImage(index), + child: Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: colors.error, + shape: BoxShape.circle, + ), + child: Icon(Icons.close, size: 14, color: colors.onError), + ), + ), + ), + ], + ); + }), + if (_selectedImages.length < _maxImages) + GestureDetector( + onTap: _pickImage, + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: colors.outlineVariant), + color: colors.surfaceContainerHighest, + ), + child: Icon( + Icons.add_photo_alternate_outlined, + color: colors.onSurfaceVariant, + ), + ), + ), + ], + ); + } + + Future _pickImage() async { + final l10n = AppLocalizations.of(context)!; + if (_selectedImages.length >= _maxImages) { + Toast.show(context, l10n.feedbackTooManyImages, type: ToastType.warning); + return; + } + XFile? picked; + try { + picked = await _imagePicker.pickImage( + source: ImageSource.gallery, + maxWidth: 1920, + imageQuality: 85, + requestFullMetadata: false, + ); + } catch (error, stackTrace) { + _logger.error( + message: 'Image picker failed', + error: error, + stackTrace: stackTrace, + ); + return; + } + if (picked == null || !mounted) return; + + final fileSize = await picked.length(); + if (fileSize > _maxImageSizeBytes) { + if (!mounted) return; + Toast.show(context, l10n.feedbackImageTooLarge, type: ToastType.warning); + return; + } + + setState(() { + _selectedImages = [..._selectedImages, picked!]; + }); + } + + void _removeImage(int index) { + setState(() { + _selectedImages = List.from(_selectedImages)..removeAt(index); + }); + } + + Future _submit() async { + final l10n = AppLocalizations.of(context)!; + final content = _contentController.text.trim(); + + if (content.isEmpty) { + Toast.show( + context, + l10n.feedbackContentRequired, + type: ToastType.warning, + ); + return; + } + if (content.length > _maxContentSize) { + Toast.show(context, l10n.feedbackContentTooLong, type: ToastType.warning); + return; + } + + setState(() { + _isSubmitting = true; + }); + + try { + final feedbackApi = FeedbackApi(apiClient: widget.apiClient); + final repository = FeedbackRepositoryImpl(feedbackApi: feedbackApi); + + await repository.submitFeedback( + type: _selectedType, + content: content, + deviceInfo: await _collectDeviceInfo(), + appVersion: await _appVersion(), + osVersion: await _osVersion(), + images: _selectedImages, + isAnonymous: _isAnonymous, + ); + + if (!mounted) return; + Toast.show(context, l10n.feedbackSuccess, type: ToastType.success); + Navigator.of(context).pop(); + } catch (error, stackTrace) { + _logger.error( + message: 'Submit feedback failed', + error: error, + stackTrace: stackTrace, + ); + if (!mounted) return; + Toast.show(context, l10n.errorRequestGeneric, type: ToastType.error); + } finally { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + } + } + } + + Future _collectDeviceInfo() async { + final deviceInfo = DeviceInfoPlugin(); + if (Platform.isAndroid) { + final android = await deviceInfo.androidInfo; + return DeviceInfo( + platform: 'android', + model: '${android.brand} ${android.model}', + ); + } else if (Platform.isIOS) { + final ios = await deviceInfo.iosInfo; + return DeviceInfo(platform: 'ios', model: _iosDeviceName(ios)); + } + return DeviceInfo(platform: defaultTargetPlatform.name, model: 'unknown'); + } + + String _iosDeviceName(IosDeviceInfo ios) { + final machine = ios.utsname.machine; + final deviceName = _iosDeviceMapping[machine] ?? machine; + return deviceName; + } + + static const Map _iosDeviceMapping = { + 'iPhone13,2': 'iPhone 12', + 'iPhone13,3': 'iPhone 12 Pro', + 'iPhone13,4': 'iPhone 12 Pro Max', + 'iPhone14,2': 'iPhone 13 Pro', + 'iPhone14,3': 'iPhone 13 Pro Max', + 'iPhone14,4': 'iPhone 13 mini', + 'iPhone14,5': 'iPhone 13', + 'iPhone15,2': 'iPhone 14 Pro', + 'iPhone15,3': 'iPhone 14 Pro Max', + 'iPhone14,7': 'iPhone 14', + 'iPhone14,8': 'iPhone 14 Plus', + 'iPhone15,4': 'iPhone 15', + 'iPhone15,5': 'iPhone 15 Plus', + 'iPhone16,1': 'iPhone 15 Pro', + 'iPhone16,2': 'iPhone 15 Pro Max', + 'iPhone17,1': 'iPhone 16 Pro', + 'iPhone17,2': 'iPhone 16 Pro Max', + 'iPhone17,3': 'iPhone 16', + 'iPhone17,4': 'iPhone 16 Plus', + }; + + Future _appVersion() async { + final info = await PackageInfo.fromPlatform(); + return '${info.version}+${info.buildNumber}'; + } + + Future _osVersion() async { + final deviceInfo = DeviceInfoPlugin(); + if (Platform.isAndroid) { + final android = await deviceInfo.androidInfo; + return 'Android ${android.version.release} (API ${android.version.sdkInt})'; + } else if (Platform.isIOS) { + final ios = await deviceInfo.iosInfo; + return 'iOS ${ios.systemVersion}'; + } + return defaultTargetPlatform.name; + } +} diff --git a/apps/lib/features/settings/presentation/screens/settings_screen.dart b/apps/lib/features/settings/presentation/screens/settings_screen.dart index 0b68574..dd8370b 100644 --- a/apps/lib/features/settings/presentation/screens/settings_screen.dart +++ b/apps/lib/features/settings/presentation/screens/settings_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../../../data/network/api_client.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/design_tokens.dart'; import '../../../../shared/widgets/app_modal_dialog.dart'; @@ -9,6 +10,7 @@ import '../../data/repositories/invite_repository.dart'; import 'account_delete_screen.dart'; import '../widgets/settings_section_widgets.dart'; import 'coin_center_screen.dart'; +import 'feedback_screen.dart'; import 'general_settings_screen.dart'; import 'invite_screen.dart'; import 'legal_center_screen.dart'; @@ -21,6 +23,7 @@ class SettingsScreen extends StatefulWidget { required this.settings, required this.coinBalance, required this.inviteRepository, + required this.apiClient, required this.onInterfaceLanguageChanged, required this.onSettingsChanged, required this.onUploadAvatar, @@ -33,6 +36,7 @@ class SettingsScreen extends StatefulWidget { final ProfileSettingsV1 settings; final int coinBalance; final InviteRepository inviteRepository; + final ApiClient apiClient; final Future Function(String languageTag) onInterfaceLanguageChanged; final Future Function(ProfileSettingsV1 settings) onSettingsChanged; final Future Function(String filePath) onUploadAvatar; @@ -125,6 +129,23 @@ class _SettingsScreenState extends State { ), ], ), + const SizedBox(height: AppSpacing.xl), + SettingsGroupCard( + children: [ + SettingsMenuTile( + icon: Icons.feedback_outlined, + title: l10n.settingsFeedbackTitle, + tint: colors.primary, + background: colors.surfaceContainerHighest, + showDivider: false, + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => FeedbackScreen(apiClient: widget.apiClient), + ), + ), + ), + ], + ), SettingsGroupCard( children: [ SettingsMenuTile( diff --git a/apps/lib/l10n/app_en.arb b/apps/lib/l10n/app_en.arb index 398b802..7c1780f 100644 --- a/apps/lib/l10n/app_en.arb +++ b/apps/lib/l10n/app_en.arb @@ -487,5 +487,23 @@ "settingsDoNotSellTitle": "Personalized Ads", "settingsDoNotSellDescription": "When off, your personal info won't be used for ad recommendations", "settingsDoNotSellEnabled": "Off", - "settingsDoNotSellDisabled": "On" + "settingsDoNotSellDisabled": "On", + "settingsFeedbackTitle": "Feedback", + "feedbackTitle": "Feedback", + "feedbackTypeLabel": "Feedback Type", + "feedbackTypeBug": "Bug", + "feedbackTypeSuggestion": "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", + "feedbackImageTooLarge": "Image size cannot exceed 5MB" } diff --git a/apps/lib/l10n/app_localizations.dart b/apps/lib/l10n/app_localizations.dart index 0ace5d1..7e0c2d8 100644 --- a/apps/lib/l10n/app_localizations.dart +++ b/apps/lib/l10n/app_localizations.dart @@ -2324,6 +2324,114 @@ abstract class AppLocalizations { /// In zh, this message translates to: /// **'已开启'** String get settingsDoNotSellDisabled; + + /// No description provided for @settingsFeedbackTitle. + /// + /// In zh, this message translates to: + /// **'意见反馈'** + String get settingsFeedbackTitle; + + /// No description provided for @feedbackTitle. + /// + /// In zh, this message translates to: + /// **'意见反馈'** + String get feedbackTitle; + + /// No description provided for @feedbackTypeLabel. + /// + /// In zh, this message translates to: + /// **'反馈类型'** + String get feedbackTypeLabel; + + /// No description provided for @feedbackTypeBug. + /// + /// In zh, this message translates to: + /// **'问题反馈'** + String get feedbackTypeBug; + + /// No description provided for @feedbackTypeSuggestion. + /// + /// In zh, this message translates to: + /// **'功能建议'** + String get feedbackTypeSuggestion; + + /// No description provided for @feedbackTypeOther. + /// + /// In zh, this message translates to: + /// **'其他'** + String get feedbackTypeOther; + + /// No description provided for @feedbackContentLabel. + /// + /// In zh, this message translates to: + /// **'反馈内容'** + String get feedbackContentLabel; + + /// No description provided for @feedbackContentHint. + /// + /// In zh, this message translates to: + /// **'请详细描述您的问题或建议...'** + String get feedbackContentHint; + + /// No description provided for @feedbackImagesLabel. + /// + /// In zh, this message translates to: + /// **'添加截图(最多3张)'** + String get feedbackImagesLabel; + + /// No description provided for @feedbackAnonymousLabel. + /// + /// In zh, this message translates to: + /// **'不上传我的个人信息'** + String get feedbackAnonymousLabel; + + /// No description provided for @feedbackAnonymousHint. + /// + /// In zh, this message translates to: + /// **'勾选后将不采集您的用户ID,仅采集设备信息用于问题排查'** + String get feedbackAnonymousHint; + + /// No description provided for @feedbackSubmit. + /// + /// In zh, this message translates to: + /// **'提交反馈'** + String get feedbackSubmit; + + /// No description provided for @feedbackSubmitting. + /// + /// In zh, this message translates to: + /// **'提交中...'** + String get feedbackSubmitting; + + /// No description provided for @feedbackSuccess. + /// + /// In zh, this message translates to: + /// **'感谢您的反馈,我们会尽快处理'** + String get feedbackSuccess; + + /// No description provided for @feedbackContentRequired. + /// + /// In zh, this message translates to: + /// **'请输入反馈内容'** + String get feedbackContentRequired; + + /// No description provided for @feedbackContentTooLong. + /// + /// In zh, this message translates to: + /// **'反馈内容不能超过500字'** + String get feedbackContentTooLong; + + /// No description provided for @feedbackTooManyImages. + /// + /// In zh, this message translates to: + /// **'最多只能上传3张图片'** + String get feedbackTooManyImages; + + /// No description provided for @feedbackImageTooLarge. + /// + /// In zh, this message translates to: + /// **'图片大小不能超过5MB'** + String get feedbackImageTooLarge; } class _AppLocalizationsDelegate diff --git a/apps/lib/l10n/app_localizations_en.dart b/apps/lib/l10n/app_localizations_en.dart index c8920d0..ede602b 100644 --- a/apps/lib/l10n/app_localizations_en.dart +++ b/apps/lib/l10n/app_localizations_en.dart @@ -1224,4 +1224,62 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsDoNotSellDisabled => 'On'; + + @override + String get settingsFeedbackTitle => 'Feedback'; + + @override + String get feedbackTitle => 'Feedback'; + + @override + String get feedbackTypeLabel => 'Feedback Type'; + + @override + String get feedbackTypeBug => 'Bug'; + + @override + String get feedbackTypeSuggestion => 'Suggestion'; + + @override + String get feedbackTypeOther => 'Other'; + + @override + String get feedbackContentLabel => 'Content'; + + @override + String get feedbackContentHint => + 'Please describe your issue or suggestion in detail...'; + + @override + String get feedbackImagesLabel => 'Add Screenshots (max 3)'; + + @override + String get feedbackAnonymousLabel => 'Do not upload my personal information'; + + @override + String get feedbackAnonymousHint => + 'If checked, your user ID will not be collected. Device info will still be collected for troubleshooting.'; + + @override + String get feedbackSubmit => 'Submit Feedback'; + + @override + String get feedbackSubmitting => 'Submitting...'; + + @override + String get feedbackSuccess => + 'Thank you for your feedback. We will process it soon.'; + + @override + String get feedbackContentRequired => 'Please enter feedback content'; + + @override + String get feedbackContentTooLong => + 'Feedback content cannot exceed 500 characters'; + + @override + String get feedbackTooManyImages => 'Maximum 3 images allowed'; + + @override + String get feedbackImageTooLarge => 'Image size cannot exceed 5MB'; } diff --git a/apps/lib/l10n/app_localizations_zh.dart b/apps/lib/l10n/app_localizations_zh.dart index f35c4fa..3961153 100644 --- a/apps/lib/l10n/app_localizations_zh.dart +++ b/apps/lib/l10n/app_localizations_zh.dart @@ -1171,6 +1171,60 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settingsDoNotSellDisabled => '已开启'; + + @override + String get settingsFeedbackTitle => '意见反馈'; + + @override + String get feedbackTitle => '意见反馈'; + + @override + String get feedbackTypeLabel => '反馈类型'; + + @override + String get feedbackTypeBug => '问题反馈'; + + @override + String get feedbackTypeSuggestion => '功能建议'; + + @override + String get feedbackTypeOther => '其他'; + + @override + String get feedbackContentLabel => '反馈内容'; + + @override + String get feedbackContentHint => '请详细描述您的问题或建议...'; + + @override + String get feedbackImagesLabel => '添加截图(最多3张)'; + + @override + String get feedbackAnonymousLabel => '不上传我的个人信息'; + + @override + String get feedbackAnonymousHint => '勾选后将不采集您的用户ID,仅采集设备信息用于问题排查'; + + @override + String get feedbackSubmit => '提交反馈'; + + @override + String get feedbackSubmitting => '提交中...'; + + @override + String get feedbackSuccess => '感谢您的反馈,我们会尽快处理'; + + @override + String get feedbackContentRequired => '请输入反馈内容'; + + @override + String get feedbackContentTooLong => '反馈内容不能超过500字'; + + @override + String get feedbackTooManyImages => '最多只能上传3张图片'; + + @override + String get feedbackImageTooLarge => '图片大小不能超过5MB'; } /// The translations for Chinese, using the Han script (`zh_Hant`). @@ -2096,4 +2150,58 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get settingsDoNotSellDisabled => '已開啟'; + + @override + String get settingsFeedbackTitle => '意見回饋'; + + @override + String get feedbackTitle => '意見回饋'; + + @override + String get feedbackTypeLabel => '回饋類型'; + + @override + String get feedbackTypeBug => '問題回饋'; + + @override + String get feedbackTypeSuggestion => '功能建議'; + + @override + String get feedbackTypeOther => '其他'; + + @override + String get feedbackContentLabel => '回饋內容'; + + @override + String get feedbackContentHint => '請詳細描述您的問題或建議...'; + + @override + String get feedbackImagesLabel => '添加截圖(最多3張)'; + + @override + String get feedbackAnonymousLabel => '不上傳我的個人信息'; + + @override + String get feedbackAnonymousHint => '勾選後將不採集您的用戶ID,僅採集設備信息用於問題排查'; + + @override + String get feedbackSubmit => '提交回饋'; + + @override + String get feedbackSubmitting => '提交中...'; + + @override + String get feedbackSuccess => '感謝您的回饋,我們會盡快處理'; + + @override + String get feedbackContentRequired => '請輸入回饋內容'; + + @override + String get feedbackContentTooLong => '回饋內容不能超過500字'; + + @override + String get feedbackTooManyImages => '最多只能上傳3張圖片'; + + @override + String get feedbackImageTooLarge => '圖片大小不能超過5MB'; } diff --git a/apps/lib/l10n/app_zh.arb b/apps/lib/l10n/app_zh.arb index 8c4e3cd..bd14dc0 100644 --- a/apps/lib/l10n/app_zh.arb +++ b/apps/lib/l10n/app_zh.arb @@ -487,5 +487,23 @@ "settingsDoNotSellTitle": "个性化广告推荐", "settingsDoNotSellDescription": "关闭后,我们不会将您的个人信息用于广告推荐", "settingsDoNotSellEnabled": "已关闭", - "settingsDoNotSellDisabled": "已开启" + "settingsDoNotSellDisabled": "已开启", + "settingsFeedbackTitle": "意见反馈", + "feedbackTitle": "意见反馈", + "feedbackTypeLabel": "反馈类型", + "feedbackTypeBug": "问题反馈", + "feedbackTypeSuggestion": "功能建议", + "feedbackTypeOther": "其他", + "feedbackContentLabel": "反馈内容", + "feedbackContentHint": "请详细描述您的问题或建议...", + "feedbackImagesLabel": "添加截图(最多3张)", + "feedbackAnonymousLabel": "不上传我的个人信息", + "feedbackAnonymousHint": "勾选后将不采集您的用户ID,仅采集设备信息用于问题排查", + "feedbackSubmit": "提交反馈", + "feedbackSubmitting": "提交中...", + "feedbackSuccess": "感谢您的反馈,我们会尽快处理", + "feedbackContentRequired": "请输入反馈内容", + "feedbackContentTooLong": "反馈内容不能超过500字", + "feedbackTooManyImages": "最多只能上传3张图片", + "feedbackImageTooLarge": "图片大小不能超过5MB" } diff --git a/apps/lib/l10n/app_zh_hant.arb b/apps/lib/l10n/app_zh_hant.arb index d1c1f38..da15b66 100644 --- a/apps/lib/l10n/app_zh_hant.arb +++ b/apps/lib/l10n/app_zh_hant.arb @@ -389,5 +389,23 @@ "settingsDoNotSellTitle": "個人化廣告推薦", "settingsDoNotSellDescription": "關閉後,我們不會將您的個人資訊用於廣告推薦", "settingsDoNotSellEnabled": "已關閉", - "settingsDoNotSellDisabled": "已開啟" + "settingsDoNotSellDisabled": "已開啟", + "settingsFeedbackTitle": "意見回饋", + "feedbackTitle": "意見回饋", + "feedbackTypeLabel": "回饋類型", + "feedbackTypeBug": "問題回饋", + "feedbackTypeSuggestion": "功能建議", + "feedbackTypeOther": "其他", + "feedbackContentLabel": "回饋內容", + "feedbackContentHint": "請詳細描述您的問題或建議...", + "feedbackImagesLabel": "添加截圖(最多3張)", + "feedbackAnonymousLabel": "不上傳我的個人信息", + "feedbackAnonymousHint": "勾選後將不採集您的用戶ID,僅採集設備信息用於問題排查", + "feedbackSubmit": "提交回饋", + "feedbackSubmitting": "提交中...", + "feedbackSuccess": "感謝您的回饋,我們會盡快處理", + "feedbackContentRequired": "請輸入回饋內容", + "feedbackContentTooLong": "回饋內容不能超過500字", + "feedbackTooManyImages": "最多只能上傳3張圖片", + "feedbackImageTooLarge": "圖片大小不能超過5MB" } diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index 2161c8b..7c3e1e6 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -47,6 +47,8 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + device_info_plus: ^12.4.0 + package_info_plus: ^9.0.1 dev_dependencies: flutter_test: diff --git a/backend/alembic/versions/20260417_0001_create_user_feedback.py b/backend/alembic/versions/20260417_0001_create_user_feedback.py new file mode 100644 index 0000000..2b3fa7e --- /dev/null +++ b/backend/alembic/versions/20260417_0001_create_user_feedback.py @@ -0,0 +1,118 @@ +"""create user_feedback table + +Revision ID: 20260417_0001 +Revises: 20260416_0003 +Create Date: 2026-04-17 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB, UUID + +revision: str = "20260417_0001" +down_revision: Union[str, Sequence[str], None] = "20260416_0003" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "user_feedback", + sa.Column( + "id", + UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + primary_key=True, + ), + sa.Column( + "user_id", + UUID(as_uuid=True), + sa.ForeignKey("auth.users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column( + "feedback_type", + sa.String(20), + nullable=False, + server_default="other", + ), + sa.Column("content", sa.Text, nullable=False), + sa.Column( + "images", + JSONB, + nullable=False, + server_default=sa.text("'[]'::jsonb"), + ), + sa.Column( + "device_info", + JSONB, + nullable=False, + server_default=sa.text("'{}'::jsonb"), + ), + sa.Column("app_version", sa.String(20), nullable=False), + sa.Column("os_version", sa.String(50), nullable=False), + sa.Column( + "status", + sa.String(20), + nullable=False, + server_default="pending", + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + + op.create_index("ix_user_feedback_user_id", "user_feedback", ["user_id"]) + op.create_index("ix_user_feedback_created_at", "user_feedback", ["created_at"]) + op.create_index("ix_user_feedback_status", "user_feedback", ["status"]) + + op.execute("COMMENT ON TABLE user_feedback IS '用户反馈表'") + op.execute( + "COMMENT ON COLUMN user_feedback.user_id IS " + "'用户ID,NULL表示匿名(勾选不上传我的个人信息)'" + ) + op.execute( + "COMMENT ON COLUMN user_feedback.feedback_type IS " + "'反馈类型: bug/suggestion/other'" + ) + op.execute("COMMENT ON COLUMN user_feedback.content IS '反馈内容,最多500字'") + op.execute( + "COMMENT ON COLUMN user_feedback.images IS '图片Storage路径列表,最多3张'" + ) + op.execute( + "COMMENT ON COLUMN user_feedback.device_info IS " + "'设备信息JSON,匿名时照样采集(不涉及隐私)'" + ) + op.execute( + "COMMENT ON COLUMN user_feedback.status IS '处理状态: pending/processed'" + ) + + op.execute("ALTER TABLE public.user_feedback ENABLE ROW LEVEL SECURITY") + + op.execute(""" + CREATE POLICY "Service role full access on user_feedback" + ON public.user_feedback + FOR ALL + TO service_role + USING (true) + WITH CHECK (true) + """) + + +def downgrade() -> None: + op.execute( + 'DROP POLICY IF EXISTS "Service role full access on user_feedback" ON public.user_feedback' + ) + op.execute("ALTER TABLE public.user_feedback DISABLE ROW LEVEL SECURITY") + op.drop_table("user_feedback") diff --git a/backend/scripts/generate_feedback_report.py b/backend/scripts/generate_feedback_report.py new file mode 100644 index 0000000..10c1288 --- /dev/null +++ b/backend/scripts/generate_feedback_report.py @@ -0,0 +1,119 @@ +"""手动触发反馈报告生成并推送邮件 + +用法: + cd /home/qzl/Code/eryao/.worktrees/feat-user-feedback + PYTHONPATH=backend/src uv run python backend/scripts/generate_feedback_report.py [--all] [--no-email] +""" + +from __future__ import annotations + +import argparse +import asyncio +from datetime import datetime, timedelta +from pathlib import Path + +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) + +from sqlalchemy import select + +from core.config.settings import config +from core.db.session import AsyncSessionLocal +from models.user_feedback import UserFeedback +from v1.feedback.report import generate_feedback_report +from v1.feedback.tasks import send_feedback_report_email + + +async def _fetch_all_feedbacks() -> list[UserFeedback]: + async with AsyncSessionLocal() as session: + stmt = select(UserFeedback).order_by(UserFeedback.created_at.desc()) + result = await session.execute(stmt) + return list(result.scalars().all()) + + +async def main(): + parser = argparse.ArgumentParser( + description="Generate feedback report and send email" + ) + parser.add_argument( + "--all", action="store_true", help="Generate report for all feedbacks" + ) + parser.add_argument("--no-email", action="store_true", help="Skip email sending") + args = parser.parse_args() + + feedbacks = await _fetch_all_feedbacks() + + report_path: Path | None = None + + try: + if args.all: + if not feedbacks: + print("No feedbacks found in database") + return + reports_dir = Path(config.runtime.log_dir).parent / "reports" + report_path = await generate_feedback_report( + feedbacks, output_dir=reports_dir + ) + print(f"\n=== 全量报告生成完成 ===") + else: + if not feedbacks: + print("No feedbacks found in database") + if not args.no_email: + print("\n发送无反馈通知邮件...") + now = datetime.now() + push_hour = 10 + end_time = now.replace( + hour=push_hour, minute=0, second=0, microsecond=0 + ) + start_time = end_time - timedelta(days=1) + await send_feedback_report_email( + feedbacks=[], + start_time=start_time, + end_time=end_time, + push_hour=push_hour, + report_path=None, + ) + print("无反馈通知邮件已发送") + return + + reports_dir = Path(config.runtime.log_dir).parent / "reports" + report_path = await generate_feedback_report( + feedbacks, output_dir=reports_dir + ) + print(f"\n=== 报告生成完成 ===") + + print(f"文件路径: {report_path}") + print(f"文件大小: {report_path.stat().st_size:,} bytes") + + if not args.no_email: + now = datetime.now() + push_hour = 10 + end_time = now.replace(hour=push_hour, minute=0, second=0, microsecond=0) + start_time = end_time - timedelta(days=1) + + print(f"\n发送邮件到: {config.feedback_report.email}") + print( + f"时间范围: {start_time.strftime('%Y-%m-%d %H:%M')} ~ {end_time.strftime('%Y-%m-%d %H:%M')}" + ) + print(f"反馈数量: {len(feedbacks)}") + + await send_feedback_report_email( + feedbacks=feedbacks, + start_time=start_time, + end_time=end_time, + push_hour=push_hour, + report_path=report_path, + ) + print("邮件发送成功") + else: + print("\n跳过邮件发送 (--no-email)") + + finally: + if report_path and report_path.exists(): + report_path.unlink() + print(f"\n临时文件已清理: {report_path}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/scripts/trigger_feedback_report.py b/backend/scripts/trigger_feedback_report.py new file mode 100644 index 0000000..355f068 --- /dev/null +++ b/backend/scripts/trigger_feedback_report.py @@ -0,0 +1,26 @@ +"""手动触发 worker-general 定时任务:生成反馈报告 + +用法: + cd /home/qzl/Code/eryao/.worktrees/feat-user-feedback + PYTHONPATH=backend/src uv run python backend/scripts/trigger_feedback_report.py +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) + +from core.taskiq.app import worker_general_broker +from v1.feedback.tasks import generate_daily_feedback_report + + +def main(): + task = generate_daily_feedback_report.kiq() + result = worker_general_broker.wait_result(task, timeout=120) + print(f"Task result: {result.return_value}") + + +if __name__ == "__main__": + main() diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py index 1077eb4..3728718 100644 --- a/backend/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -153,8 +153,13 @@ class StorageSettings(BaseModel): bucket: str = Field(default="avatars", min_length=3, max_length=63) max_size_mb: int = Field(default=2, ge=1, le=10) + class FeedbackSettings(BaseModel): + bucket: str = Field(default="feedback-images", min_length=3, max_length=63) + max_size_mb: int = Field(default=5, ge=1, le=20) + attachment: AttachmentSettings = Field(default_factory=AttachmentSettings) avatar: AvatarSettings = Field(default_factory=AvatarSettings) + feedback: FeedbackSettings = Field(default_factory=FeedbackSettings) class LlmSettings(BaseModel): @@ -235,6 +240,22 @@ def _resolve_env_file() -> str: PROJECT_ROOT = _resolve_project_root() +class FeedbackReportSettings(BaseModel): + email: str = Field(default="support@example.com", description="客服邮箱") + cron: str = Field(default="0 10 * * *", description="报告生成cron表达式") + enabled: bool = Field(default=False, description="是否启用报告推送") + + +class EmailSettings(BaseModel): + host: str = Field(default="smtp.feishu.cn", description="SMTP 服务器地址") + port: int = Field(default=465, ge=1, le=65535, description="SMTP 端口") + username: str = Field(default="", description="SMTP 用户名") + password: SecretStr = Field(default=SecretStr(""), description="SMTP 密码") + use_ssl: bool = Field(default=True, description="是否使用 SSL") + from_address: str = Field(default="noreply@example.com", description="发件人地址") + from_name: str = Field(default="Eryao Feedback", description="发件人显示名称") + + class Settings(BaseSettings): runtime: RuntimeSettings = RuntimeSettings() cors: CorsSettings = CorsSettings() @@ -250,6 +271,10 @@ class Settings(BaseSettings): taskiq: TaskiqSettings = Field(default_factory=TaskiqSettings) agent_runtime: AgentRuntimeSettings = Field(default_factory=AgentRuntimeSettings) points_policy: PointsPolicySettings = Field(default_factory=PointsPolicySettings) + feedback_report: FeedbackReportSettings = Field( + default_factory=FeedbackReportSettings + ) + email: EmailSettings = Field(default_factory=EmailSettings) @computed_field @property diff --git a/backend/src/core/email/__init__.py b/backend/src/core/email/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/core/email/sender.py b/backend/src/core/email/sender.py new file mode 100644 index 0000000..ae8d78a --- /dev/null +++ b/backend/src/core/email/sender.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from email import encoders +from email.mime.base import MIMEBase +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +import aiosmtplib +from structlog import get_logger + +from core.config.settings import config + +logger = get_logger("core.email.sender") + + +@dataclass +class EmailAttachment: + filename: str + content: bytes + content_type: str = "application/octet-stream" + + +@dataclass +class EmailMessage: + to: str + subject: str + body_html: str + attachments: list[EmailAttachment] = field(default_factory=list) + + +class EmailSender: + def __init__(self) -> None: + self._settings = config.email + + async def send(self, message: EmailMessage) -> bool: + msg = MIMEMultipart() + msg["From"] = f"{self._settings.from_name} <{self._settings.from_address}>" + msg["To"] = message.to + msg["Subject"] = message.subject + msg.attach(MIMEText(message.body_html, "html", "utf-8")) + + for attachment in message.attachments: + part = MIMEBase("application", "octet-stream") + part.set_payload(attachment.content) + encoders.encode_base64(part) + part.add_header( + "Content-Disposition", + f"attachment; filename*=UTF-8''{attachment.filename}", + ) + msg.attach(part) + + try: + await aiosmtplib.send( + msg, + hostname=self._settings.host, + port=self._settings.port, + username=self._settings.username, + password=self._settings.password.get_secret_value(), + use_tls=self._settings.use_ssl, + ) + logger.info("Email sent", to=message.to, subject=message.subject) + return True + except Exception as e: + logger.error( + "Failed to send email", + to=message.to, + subject=message.subject, + error=str(e), + ) + raise + + +email_sender = EmailSender() diff --git a/backend/src/core/email/template_loader.py b/backend/src/core/email/template_loader.py new file mode 100644 index 0000000..82c93dd --- /dev/null +++ b/backend/src/core/email/template_loader.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from pathlib import Path +from string import Template + +from structlog import get_logger + +logger = get_logger("core.email.template_loader") + +_TEMPLATES_DIR = Path(__file__).parent / "templates" + + +def load_template(category: str, name: str) -> Template: + template_path = _TEMPLATES_DIR / category / name + content = template_path.read_text(encoding="utf-8") + return Template(content) diff --git a/backend/src/core/email/templates/feedback/daily_report.html b/backend/src/core/email/templates/feedback/daily_report.html new file mode 100644 index 0000000..66c961f --- /dev/null +++ b/backend/src/core/email/templates/feedback/daily_report.html @@ -0,0 +1,68 @@ + + + + + + + + + + + +
+ + + + + + + + + + + + + + +
+

+ 用户反馈日报 +

+
+

+ 您好, +

+

+ 以下是 ${start_date} ${start_hour}:00 至 + ${end_date} ${end_hour}:00 期间的用户反馈汇总。 +

+ + + + + + + +
+

${total_count}

+

反馈总数

+
+

${bug_count}

+

问题反馈

+
+

${suggestion_count}

+

功能建议

+
+ +

+ 详细反馈内容及截图请查看附件中的 xlsx 报告。 +

+
+

+ 此邮件由 Eryao 反馈系统自动发送,请勿直接回复。
+ 报告生成时间:${generated_at} +

+
+
+ + diff --git a/backend/src/core/email/templates/feedback/no_feedback.html b/backend/src/core/email/templates/feedback/no_feedback.html new file mode 100644 index 0000000..1474694 --- /dev/null +++ b/backend/src/core/email/templates/feedback/no_feedback.html @@ -0,0 +1,62 @@ + + + + + + + + + + + +
+ + + + + + + + + + + + + + +
+

+ 用户反馈日报 +

+
+

+ 您好, +

+

+ ${start_date} ${start_hour}:00 至 + ${end_date} ${end_hour}:00 期间暂无用户反馈。 +

+ + + + + +
+

📭

+

+ 今日无反馈数据 +

+
+ +

+ 如有反馈,系统将在下一个报告周期自动推送。 +

+
+

+ 此邮件由 Eryao 反馈系统自动发送,请勿直接回复。
+ 报告生成时间:${generated_at} +

+
+
+ + diff --git a/backend/src/models/user_feedback.py b/backend/src/models/user_feedback.py new file mode 100644 index 0000000..2c4ed04 --- /dev/null +++ b/backend/src/models/user_feedback.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import uuid + +from sqlalchemy import Index, String, Text, text +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, TimestampMixin + + +class UserFeedback(TimestampMixin, Base): + __tablename__ = "user_feedback" + __table_args__ = ( + Index("ix_user_feedback_user_id", "user_id"), + Index("ix_user_feedback_created_at", "created_at"), + Index("ix_user_feedback_status", "status"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + server_default=text("gen_random_uuid()"), + primary_key=True, + ) + user_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) + feedback_type: Mapped[str] = mapped_column( + String(20), nullable=False, server_default="other" + ) + content: Mapped[str] = mapped_column(Text, nullable=False) + images: Mapped[list[str]] = mapped_column( + JSONB, nullable=False, server_default=text("'[]'::jsonb"), default=list + ) + device_info: Mapped[dict] = mapped_column( + JSONB, nullable=False, server_default=text("'{}'::jsonb"), default=dict + ) + app_version: Mapped[str] = mapped_column(String(20), nullable=False) + os_version: Mapped[str] = mapped_column(String(50), nullable=False) + status: Mapped[str] = mapped_column( + String(20), nullable=False, server_default="pending" + ) diff --git a/backend/src/services/base/supabase.py b/backend/src/services/base/supabase.py index 728dab6..0c41b86 100644 --- a/backend/src/services/base/supabase.py +++ b/backend/src/services/base/supabase.py @@ -110,6 +110,7 @@ class SupabaseService(BaseServiceProvider): buckets = [ (config.storage.attachment.bucket, False), (config.storage.avatar.bucket, True), + (config.storage.feedback.bucket, False), ] def _check_and_create() -> None: @@ -170,6 +171,7 @@ class SupabaseService(BaseServiceProvider): allowed_buckets = { config.storage.attachment.bucket, config.storage.avatar.bucket, + config.storage.feedback.bucket, } if bucket not in allowed_buckets: raise RuntimeError("Invalid storage bucket") diff --git a/backend/src/v1/feedback/__init__.py b/backend/src/v1/feedback/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/v1/feedback/dependencies.py b/backend/src/v1/feedback/dependencies.py new file mode 100644 index 0000000..ac8a6de --- /dev/null +++ b/backend/src/v1/feedback/dependencies.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import asyncio +from typing import Annotated +from uuid import UUID + +from fastapi import Depends, Header +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth.models import CurrentUser +from core.db import get_db +from services.base.supabase import supabase_service +from v1.feedback.repository import FeedbackRepository +from v1.feedback.service import FeedbackService + + +async def get_optional_user( + authorization: str | None = Header(default=None), +) -> CurrentUser | None: + if not authorization: + return None + + scheme, _, token = authorization.partition(" ") + if scheme.lower() != "bearer" or not token: + return None + + try: + client = supabase_service.get_client() + response = await asyncio.to_thread(client.auth.get_user, token) + user = getattr(response, "user", None) + user_id = getattr(user, "id", None) + if not isinstance(user_id, str) or not user_id: + return None + return CurrentUser( + id=UUID(user_id), + email=getattr(user, "email", None), + role=getattr(user, "role", None), + ) + except Exception: + return None + + +def get_feedback_service( + session: Annotated[AsyncSession, Depends(get_db)], +) -> FeedbackService: + return FeedbackService( + repository=FeedbackRepository(session=session), + storage=supabase_service, + ) diff --git a/backend/src/v1/feedback/report.py b/backend/src/v1/feedback/report.py new file mode 100644 index 0000000..d0fb9a5 --- /dev/null +++ b/backend/src/v1/feedback/report.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from io import BytesIO +from pathlib import Path +from tempfile import gettempdir + +from openpyxl import Workbook +from openpyxl.drawing.image import Image as XLImage +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side +from openpyxl.utils import get_column_letter +from structlog import get_logger + +from core.config.settings import config +from models.user_feedback import UserFeedback +from services.base.supabase import supabase_service + +logger = get_logger("v1.feedback.report") + +IMAGE_PREVIEW_WIDTH_PX = 100 +IMAGE_PREVIEW_HEIGHT_PX = 100 +IMAGE_COL_WIDTH = 15 +IMAGE_ROW_HEIGHT = 80 + + +async def download_image_from_storage(storage_path: str) -> bytes: + bucket_name = config.storage.feedback.bucket + try: + return await supabase_service.download_bytes( + bucket=bucket_name, path=storage_path + ) + except Exception as e: + logger.warning("Failed to download image", path=storage_path, error=str(e)) + raise + + +def _embed_image_in_cell(ws, img_data: bytes, row: int, col: int) -> bool: + try: + img_buffer = BytesIO(img_data) + xl_img = XLImage(img_buffer) + xl_img.width = IMAGE_PREVIEW_WIDTH_PX + xl_img.height = IMAGE_PREVIEW_HEIGHT_PX + xl_img.anchor = f"{get_column_letter(col)}{row}" + ws.add_image(xl_img) + return True + except Exception as e: + logger.warning("Failed to embed image", row=row, col=col, error=str(e)) + return False + + +async def generate_feedback_report( + feedbacks: list[UserFeedback], + *, + output_dir: Path | None = None, + filename_prefix: str = "feedback_report", +) -> Path: + wb = Workbook() + ws = wb.active + assert ws is not None + ws.title = "用户反馈" + + header_font = Font(bold=True, color="FFFFFF", size=11) + header_fill = PatternFill( + start_color="4472C4", end_color="4472C4", fill_type="solid" + ) + header_alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) + thin_border = Border( + left=Side(style="thin", color="D9D9D9"), + right=Side(style="thin", color="D9D9D9"), + top=Side(style="thin", color="D9D9D9"), + bottom=Side(style="thin", color="D9D9D9"), + ) + center_alignment = Alignment(horizontal="center", vertical="center") + + headers = [ + "序号", + "提交时间", + "反馈类型", + "用户身份", + "设备信息", + "App版本", + "系统版本", + "反馈内容", + "图片数量", + "状态", + "截图1", + "截图2", + "截图3", + ] + col_widths = [ + 6, + 18, + 12, + 12, + 25, + 10, + 14, + 50, + 10, + 10, + IMAGE_COL_WIDTH, + IMAGE_COL_WIDTH, + IMAGE_COL_WIDTH, + ] + + for col, (header, width) in enumerate(zip(headers, col_widths), start=1): + cell = ws.cell(row=1, column=col, value=header) + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + cell.border = thin_border + ws.column_dimensions[get_column_letter(col)].width = width + + ws.row_dimensions[1].height = 25 + ws.freeze_panes = "A2" + + type_colors = { + "bug": PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid"), + "suggestion": PatternFill( + start_color="E2EFDA", end_color="E2EFDA", fill_type="solid" + ), + "other": PatternFill( + start_color="FFF2CC", end_color="FFF2CC", fill_type="solid" + ), + } + + for row_idx, fb in enumerate(feedbacks, start=2): + ws.cell(row=row_idx, column=1, value=row_idx - 1).alignment = center_alignment + ws.cell(row=row_idx, column=2, value=fb.created_at.strftime("%Y-%m-%d %H:%M")) + + type_cell = ws.cell(row=row_idx, column=3, value=fb.feedback_type) + type_cell.alignment = center_alignment + type_cell.fill = type_colors.get(fb.feedback_type, PatternFill()) + + if fb.user_id: + ws.cell(row=row_idx, column=4, value=f"用户:{str(fb.user_id)[:8]}...") + else: + ws.cell(row=row_idx, column=4, value="匿名").alignment = center_alignment + + device_info = fb.device_info or {} + ws.cell( + row=row_idx, + column=5, + value=f"{device_info.get('platform', '-')} / {device_info.get('model', '-')}", + ) + ws.cell( + row=row_idx, column=6, value=fb.app_version + ).alignment = center_alignment + ws.cell(row=row_idx, column=7, value=fb.os_version) + + content_cell = ws.cell(row=row_idx, column=8, value=fb.content) + content_cell.alignment = Alignment(vertical="top", wrap_text=True) + + ws.cell( + row=row_idx, column=9, value=len(fb.images) if fb.images else 0 + ).alignment = center_alignment + + status_cell = ws.cell(row=row_idx, column=10, value=fb.status) + status_cell.alignment = center_alignment + if fb.status == "pending": + status_cell.fill = PatternFill( + start_color="FFF2CC", end_color="FFF2CC", fill_type="solid" + ) + elif fb.status == "processed": + status_cell.fill = PatternFill( + start_color="C6EFCE", end_color="C6EFCE", fill_type="solid" + ) + + ws.row_dimensions[row_idx].height = IMAGE_ROW_HEIGHT + + images = fb.images or [] + for img_idx in range(3): + img_col = 11 + img_idx + img_cell = ws.cell(row=row_idx, column=img_col, value="") + img_cell.border = thin_border + + if img_idx < len(images): + try: + img_data = await download_image_from_storage(images[img_idx]) + success = _embed_image_in_cell(ws, img_data, row_idx, img_col) + if not success: + img_cell.value = "加载失败" + img_cell.alignment = center_alignment + except Exception: + img_cell.value = "加载失败" + img_cell.alignment = center_alignment + else: + img_cell.value = "-" + img_cell.alignment = center_alignment + + for col in range(1, 11): + ws.cell(row=row_idx, column=col).border = thin_border + + ws.auto_filter.ref = ( + f"A1:{get_column_letter(len(headers))}{max(2, len(feedbacks) + 1)}" + ) + + if output_dir is None: + output_dir = Path(gettempdir()) + output_dir.mkdir(parents=True, exist_ok=True) + + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + report_path = output_dir / f"{filename_prefix}_{timestamp}.xlsx" + + wb.save(report_path) + logger.info( + "Feedback report generated", path=str(report_path), count=len(feedbacks) + ) + return report_path + + +async def cleanup_report_file(report_path: Path) -> None: + try: + if report_path.exists(): + report_path.unlink() + logger.info("Report file cleaned up", path=str(report_path)) + except Exception as e: + logger.warning( + "Failed to cleanup report file", path=str(report_path), error=str(e) + ) diff --git a/backend/src/v1/feedback/repository.py b/backend/src/v1/feedback/repository.py new file mode 100644 index 0000000..efa2a94 --- /dev/null +++ b/backend/src/v1/feedback/repository.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from models.user_feedback import UserFeedback + + +@dataclass +class FeedbackRepository: + session: AsyncSession + + async def create_feedback( + self, + *, + user_id: UUID | None, + feedback_type: str, + content: str, + images: list[str], + device_info: dict, + app_version: str, + os_version: str, + ) -> UserFeedback: + feedback = UserFeedback( + user_id=user_id, + feedback_type=feedback_type, + content=content, + images=images, + device_info=device_info, + app_version=app_version, + os_version=os_version, + ) + self.session.add(feedback) + await self.session.flush() + return feedback + + async def save(self) -> None: + await self.session.commit() diff --git a/backend/src/v1/feedback/router.py b/backend/src/v1/feedback/router.py new file mode 100644 index 0000000..f04b3e8 --- /dev/null +++ b/backend/src/v1/feedback/router.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import json +from typing import Annotated + +from fastapi import APIRouter, Depends, File, Form, UploadFile + +from core.auth.models import CurrentUser +from core.http.errors import ApiProblemError, problem_payload +from v1.feedback.dependencies import get_feedback_service, get_optional_user +from v1.feedback.schemas import FeedbackCreateResponse +from v1.feedback.service import FeedbackService + +router = APIRouter(prefix="/feedback", tags=["feedback"]) + + +@router.post("", response_model=FeedbackCreateResponse, status_code=201) +async def create_feedback( + feedback_type: Annotated[str, Form(...)], + content: Annotated[str, Form(...)], + device_info: Annotated[str, Form(...)], + app_version: Annotated[str, Form(...)], + os_version: Annotated[str, Form(...)], + images: list[UploadFile] = File(default=[]), + user: CurrentUser | None = Depends(get_optional_user), + service: FeedbackService = Depends(get_feedback_service), +) -> FeedbackCreateResponse: + if len(images) > 3: + raise ApiProblemError( + status_code=400, + detail=problem_payload( + code="FEEDBACK_TOO_MANY_IMAGES", + detail="Maximum 3 images allowed", + ), + ) + + try: + device_info_dict = json.loads(device_info) + except (json.JSONDecodeError, TypeError) as exc: + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="REQUEST_VALIDATION_ERROR", + detail="Invalid device_info JSON", + ), + ) from exc + + user_id = user.id if isinstance(user, CurrentUser) else None + + return await service.submit_feedback( + feedback_type=feedback_type, + content=content, + device_info=device_info_dict, + app_version=app_version, + os_version=os_version, + images=images, + user_id=user_id, + ) diff --git a/backend/src/v1/feedback/schemas.py b/backend/src/v1/feedback/schemas.py new file mode 100644 index 0000000..39ee8d8 --- /dev/null +++ b/backend/src/v1/feedback/schemas.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +FeedbackType = Literal["bug", "suggestion", "other"] +FeedbackStatus = Literal["pending", "processed"] + + +class FeedbackCreateResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + created_at: str diff --git a/backend/src/v1/feedback/service.py b/backend/src/v1/feedback/service.py new file mode 100644 index 0000000..af9e15b --- /dev/null +++ b/backend/src/v1/feedback/service.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from uuid import UUID + +from fastapi import UploadFile +from structlog import get_logger + +from core.config.settings import config +from core.http.errors import ApiProblemError, problem_payload +from services.base.supabase import SupabaseService +from v1.feedback.repository import FeedbackRepository +from v1.feedback.schemas import FeedbackCreateResponse + +logger = get_logger("v1.feedback.service") + +_MAX_IMAGES = 3 +_ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png"} +_ALLOWED_FEEDBACK_TYPES = {"bug", "suggestion", "other"} + + +@dataclass +class FeedbackService: + repository: FeedbackRepository + storage: SupabaseService + + async def submit_feedback( + self, + *, + feedback_type: str, + content: str, + device_info: dict, + app_version: str, + os_version: str, + images: list[UploadFile], + user_id: UUID | None, + ) -> FeedbackCreateResponse: + self._validate_feedback_type(feedback_type) + self._validate_content(content) + self._validate_images(images) + + image_paths: list[str] = [] + if images: + image_paths = await self._upload_images(images) + + feedback = await self.repository.create_feedback( + user_id=user_id, + feedback_type=feedback_type, + content=content, + images=image_paths, + device_info=device_info, + app_version=app_version, + os_version=os_version, + ) + await self.repository.save() + + logger.info( + "Feedback submitted", + feedback_id=str(feedback.id), + user_id=str(user_id) if user_id else "anonymous", + image_count=len(image_paths), + ) + + return FeedbackCreateResponse( + id=str(feedback.id), + created_at=feedback.created_at.isoformat(), + ) + + def _validate_feedback_type(self, feedback_type: str) -> None: + if feedback_type not in _ALLOWED_FEEDBACK_TYPES: + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="REQUEST_VALIDATION_ERROR", + detail=f"Invalid feedback_type: {feedback_type}", + ), + ) + + def _validate_content(self, content: str) -> None: + stripped = content.strip() + if not stripped: + raise ApiProblemError( + status_code=400, + detail=problem_payload( + code="FEEDBACK_CONTENT_EMPTY", + detail="Feedback content must not be empty", + ), + ) + if len(stripped) > 500: + raise ApiProblemError( + status_code=400, + detail=problem_payload( + code="FEEDBACK_CONTENT_TOO_LONG", + detail="Feedback content exceeds 500 characters", + ), + ) + + def _validate_images(self, images: list[UploadFile]) -> None: + if len(images) > _MAX_IMAGES: + raise ApiProblemError( + status_code=400, + detail=problem_payload( + code="FEEDBACK_TOO_MANY_IMAGES", + detail="Maximum 3 images allowed", + ), + ) + for image in images: + content_type = (image.content_type or "").lower().strip() + if content_type and content_type not in _ALLOWED_CONTENT_TYPES: + raise ApiProblemError( + status_code=400, + detail=problem_payload( + code="FEEDBACK_INVALID_IMAGE_TYPE", + detail=f"Unsupported image type: {content_type}", + ), + ) + + async def _upload_images(self, images: list[UploadFile]) -> list[str]: + bucket = config.storage.feedback.bucket + max_bytes = config.storage.feedback.max_size_mb * 1024 * 1024 + timestamp_prefix = datetime.now(timezone.utc).strftime("%Y-%m-%d") + paths: list[str] = [] + + for i, image in enumerate(images): + content = await image.read() + if len(content) > max_bytes: + raise ApiProblemError( + status_code=400, + detail=problem_payload( + code="FEEDBACK_IMAGE_TOO_LARGE", + detail=f"Image too large: {image.filename}", + ), + ) + + content_type = image.content_type or "image/jpeg" + ext = "jpg" if content_type == "image/jpeg" else "png" + storage_path = ( + f"{timestamp_prefix}/{datetime.now(timezone.utc).timestamp()}_{i}.{ext}" + ) + + try: + await self.storage.upload_bytes( + bucket=bucket, + path=storage_path, + content=content, + content_type=content_type, + ) + except Exception as exc: + logger.exception( + "Feedback image upload failed", + path=storage_path, + filename=image.filename, + ) + raise ApiProblemError( + status_code=500, + detail=problem_payload( + code="FEEDBACK_SUBMIT_FAILED", + detail="Failed to upload feedback images", + ), + ) from exc + + paths.append(storage_path) + + return paths diff --git a/backend/src/v1/feedback/tasks.py b/backend/src/v1/feedback/tasks.py new file mode 100644 index 0000000..87f8253 --- /dev/null +++ b/backend/src/v1/feedback/tasks.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from sqlalchemy import select +from structlog import get_logger +from taskiq_redis import RedisScheduleSource + +from core.config.settings import config +from core.db.session import AsyncSessionLocal +from core.email.sender import EmailAttachment, EmailMessage, EmailSender +from core.email.template_loader import load_template +from core.taskiq.app import worker_general_broker +from models.user_feedback import UserFeedback +from v1.feedback.report import generate_feedback_report + +logger = get_logger("v1.feedback.tasks") + + +async def _fetch_pending_feedbacks_by_time_range( + start_time: datetime, end_time: datetime +) -> list[UserFeedback]: + async with AsyncSessionLocal() as session: + stmt = ( + select(UserFeedback) + .where(UserFeedback.created_at >= start_time) + .where(UserFeedback.created_at < end_time) + .where(UserFeedback.status == "pending") + .order_by(UserFeedback.created_at.desc()) + ) + result = await session.execute(stmt) + return list(result.scalars().all()) + + +async def _mark_feedbacks_processed(feedback_ids: list) -> None: + if not feedback_ids: + return + async with AsyncSessionLocal() as session: + stmt = select(UserFeedback).where(UserFeedback.id.in_(feedback_ids)) + result = await session.execute(stmt) + for fb in result.scalars().all(): + fb.status = "processed" + fb.updated_at = datetime.now(timezone.utc) + await session.commit() + logger.info("Feedbacks marked as processed", count=len(feedback_ids)) + + +def _build_report_email_html( + feedbacks: list[UserFeedback], + start_time: datetime, + end_time: datetime, + push_hour: int, +) -> str: + template = load_template("feedback", "daily_report.html") + return template.substitute( + start_date=start_time.strftime("%Y-%m-%d"), + start_hour=str(push_hour), + end_date=end_time.strftime("%Y-%m-%d"), + end_hour=str(push_hour), + total_count=len(feedbacks), + bug_count=sum(1 for fb in feedbacks if fb.feedback_type == "bug"), + suggestion_count=sum(1 for fb in feedbacks if fb.feedback_type == "suggestion"), + generated_at=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"), + ) + + +def _build_no_feedback_email_html( + start_time: datetime, + end_time: datetime, + push_hour: int, +) -> str: + template = load_template("feedback", "no_feedback.html") + return template.substitute( + start_date=start_time.strftime("%Y-%m-%d"), + start_hour=str(push_hour), + end_date=end_time.strftime("%Y-%m-%d"), + end_hour=str(push_hour), + generated_at=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"), + ) + + +async def _send_feedback_email( + feedbacks: list[UserFeedback], + start_time: datetime, + end_time: datetime, + push_hour: int, + report_path: Path | None = None, +) -> bool: + sender = EmailSender() + + if feedbacks: + body_html = _build_report_email_html(feedbacks, start_time, end_time, push_hour) + subject = f"用户反馈日报 - {start_time.strftime('%Y-%m-%d')}" + else: + body_html = _build_no_feedback_email_html(start_time, end_time, push_hour) + subject = f"用户反馈日报(无反馈)- {start_time.strftime('%Y-%m-%d')}" + + attachments: list[EmailAttachment] = [] + if report_path is not None and report_path.exists(): + attachments.append( + EmailAttachment( + filename=report_path.name, + content=report_path.read_bytes(), + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + ) + + message = EmailMessage( + to=config.feedback_report.email, + subject=subject, + body_html=body_html, + attachments=attachments, + ) + await sender.send(message) + logger.info( + "Feedback report email sent", + to=config.feedback_report.email, + has_feedback=len(feedbacks) > 0, + has_attachment=len(attachments) > 0, + ) + return True + + +# type: ignore reportArgumentType for taskiq decorator +@worker_general_broker.on_event("startup") # type: ignore[arg-type] +async def _register_feedback_report_schedule() -> None: + if not config.feedback_report.enabled: + logger.info("Feedback report scheduling disabled") + return + + schedule_source = RedisScheduleSource( + url=config.taskiq_broker_url, + prefix="schedule:feedback", + ) + await schedule_source.startup() + + await generate_daily_feedback_report.schedule_by_cron( + source=schedule_source, + cron=config.feedback_report.cron, + ) + + logger.info( + "Feedback report cron registered", + cron=config.feedback_report.cron, + ) + + +@worker_general_broker.task(task_name="tasks.feedback.generate_daily_report") +async def generate_daily_feedback_report() -> str | None: + if not config.feedback_report.enabled: + logger.info("Feedback report is disabled, skipping") + return None + + now = datetime.now(timezone.utc) + push_hour = 10 + end_time = now.replace(hour=push_hour, minute=0, second=0, microsecond=0) + start_time = end_time - timedelta(days=1) + + feedbacks = await _fetch_pending_feedbacks_by_time_range(start_time, end_time) + logger.info( + "Feedback query result", + count=len(feedbacks), + start=start_time, + end=end_time, + ) + + report_path: Path | None = None + + try: + if feedbacks: + reports_dir = Path(config.runtime.log_dir).parent / "reports" + report_path = await generate_feedback_report( + feedbacks, output_dir=reports_dir + ) + logger.info("Report generated", path=str(report_path)) + await _mark_feedbacks_processed([fb.id for fb in feedbacks]) + + await _send_feedback_email( + feedbacks=feedbacks, + start_time=start_time, + end_time=end_time, + push_hour=push_hour, + report_path=report_path, + ) + finally: + if report_path and report_path.exists(): + report_path.unlink() + logger.info("Report file cleaned up", path=str(report_path)) + + return str(report_path) if report_path else None + + +async def generate_all_feedback_report() -> Path: + async with AsyncSessionLocal() as session: + stmt = select(UserFeedback).order_by(UserFeedback.created_at.desc()) + result = await session.execute(stmt) + feedbacks = list(result.scalars().all()) + + if not feedbacks: + raise ValueError("No feedbacks to report") + + logger.info("Generating all feedback report", count=len(feedbacks)) + + reports_dir = Path(config.runtime.log_dir).parent / "reports" + report_path = await generate_feedback_report(feedbacks, output_dir=reports_dir) + + await _mark_feedbacks_processed([fb.id for fb in feedbacks]) + + return report_path + + +async def send_feedback_report_email( + feedbacks: list[UserFeedback], + start_time: datetime, + end_time: datetime, + push_hour: int, + report_path: Path | None = None, +) -> bool: + return await _send_feedback_email( + feedbacks=feedbacks, + start_time=start_time, + end_time=end_time, + push_hour=push_hour, + report_path=report_path, + ) diff --git a/backend/src/v1/router.py b/backend/src/v1/router.py index 4ab2e35..7317eb3 100644 --- a/backend/src/v1/router.py +++ b/backend/src/v1/router.py @@ -4,6 +4,7 @@ from fastapi import APIRouter from v1.agent.router import router as agent_router from v1.auth.router import router as auth_router +from v1.feedback.router import router as feedback_router from v1.invite.router import router as invite_router from v1.notifications.router import router as notifications_router from v1.points.router import router as points_router @@ -13,6 +14,7 @@ from v1.users.router import router as users_router router = APIRouter(prefix="/api/v1") router.include_router(auth_router) router.include_router(agent_router) +router.include_router(feedback_router) router.include_router(invite_router) router.include_router(notifications_router) router.include_router(points_router) diff --git a/backend/tests/integration/conftest_feedback.py b/backend/tests/integration/conftest_feedback.py new file mode 100644 index 0000000..889ab19 --- /dev/null +++ b/backend/tests/integration/conftest_feedback.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import os +import time + +import pytest + + +def pytest_configure(config): # noqa: ARG001 + config.addinivalue_line( + "markers", "integration: integration test requiring live backend" + ) + + +def pytest_collection_modifyitems(items): + for item in items: + if "integration" in item.nodeid: + item.add_marker(pytest.mark.integration) + + +@pytest.fixture(scope="session") +def api_base_url() -> str: + return os.environ.get("ERYAO_TEST_BASE_URL", "http://localhost:5775") + + +@pytest.fixture +def unique_test_email() -> str: + base_email = os.environ.get("ERYAO_TEST__EMAIL", "test@example.com").strip().lower() + if "@" in base_email: + name, domain = base_email.split("@", 1) + else: + name, domain = base_email, "example.com" + return f"{name}+fb{int(time.time() * 1000)}@{domain}" + + +@pytest.fixture +def test_verify_code() -> str: + return os.environ.get("ERYAO_TEST__CODE", "123456") + + +@pytest.fixture +def test_identity(unique_test_email: str, test_verify_code: str) -> dict[str, str]: + return {"email": unique_test_email, "code": test_verify_code} diff --git a/backend/tests/integration/test_feedback_submit.py b/backend/tests/integration/test_feedback_submit.py new file mode 100644 index 0000000..8ac195e --- /dev/null +++ b/backend/tests/integration/test_feedback_submit.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator + +import httpx +import pytest +import json + + +@pytest.fixture +async def feedback_client(api_base_url: str) -> AsyncIterator[httpx.AsyncClient]: + async with httpx.AsyncClient(base_url=api_base_url, timeout=30.0) as client: + try: + health = await client.get("/health") + if health.status_code != 200: + pytest.skip(f"API not ready: /health={health.status_code}") + except Exception as exc: + pytest.skip(f"API unavailable: {exc}") + yield client + + +@pytest.fixture +async def authed_feedback_client( + feedback_client: httpx.AsyncClient, + test_identity: dict[str, str], +) -> httpx.AsyncClient: + otp_response = await feedback_client.post( + "/api/v1/auth/otp", + json={"email": test_identity["email"]}, + ) + if otp_response.status_code not in (200, 204): + pytest.skip(f"OTP request failed: {otp_response.status_code}") + + verify_response = await feedback_client.post( + "/api/v1/auth/verify", + json={ + "email": test_identity["email"], + "code": test_identity["code"], + }, + ) + if verify_response.status_code != 200: + pytest.skip(f"Auth verify failed: {verify_response.status_code}") + + token = verify_response.json().get("access_token") or verify_response.json().get( + "session", {} + ).get("access_token") + if not token: + pytest.skip("No access token in auth response") + + feedback_client.headers["Authorization"] = f"Bearer {token}" + return feedback_client + + +class TestFeedbackSubmitAnonymous: + @pytest.mark.asyncio + async def test_submit_feedback_anonymous_success( + self, feedback_client: httpx.AsyncClient + ): + response = await feedback_client.post( + "/api/v1/feedback", + data={ + "feedback_type": "bug", + "content": "App crashes when opening settings", + "device_info": json.dumps({"platform": "ios", "model": "iPhone 15"}), + "app_version": "1.0.0", + "os_version": "iOS 17.0", + }, + ) + assert response.status_code == 201 + body = response.json() + assert "id" in body + assert "created_at" in body + + @pytest.mark.asyncio + async def test_submit_feedback_with_auth( + self, authed_feedback_client: httpx.AsyncClient + ): + response = await authed_feedback_client.post( + "/api/v1/feedback", + data={ + "feedback_type": "suggestion", + "content": "Please add dark mode", + "device_info": json.dumps({"platform": "android", "model": "Pixel 8"}), + "app_version": "1.0.0", + "os_version": "Android 14", + }, + ) + assert response.status_code == 201 + body = response.json() + assert "id" in body + assert "created_at" in body + + @pytest.mark.asyncio + async def test_submit_feedback_content_empty( + self, feedback_client: httpx.AsyncClient + ): + response = await feedback_client.post( + "/api/v1/feedback", + data={ + "feedback_type": "bug", + "content": "", + "device_info": json.dumps({"platform": "ios", "model": "iPhone 15"}), + "app_version": "1.0.0", + "os_version": "iOS 17.0", + }, + ) + assert response.status_code == 400 + assert response.json().get("code") == "FEEDBACK_CONTENT_EMPTY" + + @pytest.mark.asyncio + async def test_submit_feedback_content_too_long( + self, feedback_client: httpx.AsyncClient + ): + response = await feedback_client.post( + "/api/v1/feedback", + data={ + "feedback_type": "bug", + "content": "x" * 501, + "device_info": json.dumps({"platform": "ios", "model": "iPhone 15"}), + "app_version": "1.0.0", + "os_version": "iOS 17.0", + }, + ) + assert response.status_code == 400 + assert response.json().get("code") == "FEEDBACK_CONTENT_TOO_LONG" + + @pytest.mark.asyncio + async def test_submit_feedback_invalid_device_info( + self, feedback_client: httpx.AsyncClient + ): + response = await feedback_client.post( + "/api/v1/feedback", + data={ + "feedback_type": "bug", + "content": "Test content", + "device_info": "not-json", + "app_version": "1.0.0", + "os_version": "iOS 17.0", + }, + ) + assert response.status_code == 422 + + @pytest.mark.asyncio + async def test_submit_feedback_with_image(self, feedback_client: httpx.AsyncClient): + fake_image = b"\xff\xd8\xff\xe0" + b"\x00" * 100 + response = await feedback_client.post( + "/api/v1/feedback", + data={ + "feedback_type": "bug", + "content": "Screenshot of the issue", + "device_info": json.dumps({"platform": "ios", "model": "iPhone 15"}), + "app_version": "1.0.0", + "os_version": "iOS 17.0", + }, + files=[("images", ("screenshot.jpg", fake_image, "image/jpeg"))], + ) + assert response.status_code == 201 + body = response.json() + assert "id" in body diff --git a/backend/tests/unit/test_feedback_service.py b/backend/tests/unit/test_feedback_service.py new file mode 100644 index 0000000..4fe78ca --- /dev/null +++ b/backend/tests/unit/test_feedback_service.py @@ -0,0 +1,349 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID, uuid4 + +import pytest + +from core.http.errors import ApiProblemError +from v1.feedback.schemas import FeedbackCreateResponse + + +class TestFeedbackCreateResponse: + def test_valid_response(self): + resp = FeedbackCreateResponse( + id=str(uuid4()), + created_at="2026-04-17T10:30:00Z", + ) + assert isinstance(resp.id, str) + assert resp.created_at == "2026-04-17T10:30:00Z" + + def test_extra_fields_forbidden(self): + with pytest.raises(Exception): + FeedbackCreateResponse( + id=str(uuid4()), + created_at="2026-04-17T10:30:00Z", + unexpected_field="value", + ) + + +class _FakeUserFeedback: + def __init__( + self, + *, + id: UUID, + user_id: UUID | None, + feedback_type: str, + content: str, + images: list[str], + device_info: dict, + app_version: str, + os_version: str, + status: str = "pending", + ) -> None: + self.id = id + self.user_id = user_id + self.feedback_type = feedback_type + self.content = content + self.images = images + self.device_info = device_info + self.app_version = app_version + self.os_version = os_version + self.status = status + self.created_at = datetime.now() + self.updated_at = datetime.now() + + +class _FakeFeedbackRepository: + def __init__(self) -> None: + self._records: list[_FakeUserFeedback] = [] + self._committed = False + + async def create_feedback( + self, + *, + user_id: UUID | None, + feedback_type: str, + content: str, + images: list[str], + device_info: dict, + app_version: str, + os_version: str, + ) -> _FakeUserFeedback: + record = _FakeUserFeedback( + id=uuid4(), + user_id=user_id, + feedback_type=feedback_type, + content=content, + images=images, + device_info=device_info, + app_version=app_version, + os_version=os_version, + ) + self._records.append(record) + return record + + async def save(self) -> None: + self._committed = True + + +class _FakeUploadFile: + def __init__( + self, + *, + filename: str = "test.jpg", + content_type: str = "image/jpeg", + content: bytes = b"fake-image-data", + ) -> None: + self.filename = filename + self.content_type = content_type + self._content = content + self._read = False + + async def read(self) -> bytes: + self._read = True + return self._content + + +class _FakeStorage: + def __init__(self) -> None: + self.uploaded: list[dict] = [] + + async def upload_bytes( + self, + *, + bucket: str, + path: str, + content: bytes, + content_type: str, + ) -> str: + self.uploaded.append( + { + "bucket": bucket, + "path": path, + "content_type": content_type, + "size": len(content), + } + ) + return path + + +@pytest.fixture +def fake_repo() -> _FakeFeedbackRepository: + return _FakeFeedbackRepository() + + +@pytest.fixture +def fake_storage() -> _FakeStorage: + return _FakeStorage() + + +class TestFeedbackServiceValidation: + @pytest.mark.asyncio + async def test_submit_feedback_success_no_images( + self, fake_repo: _FakeFeedbackRepository, fake_storage: _FakeStorage + ): + from v1.feedback.service import FeedbackService + + service = FeedbackService(repository=fake_repo, storage=fake_storage) + result = await service.submit_feedback( + feedback_type="bug", + content="App crashes on launch", + device_info={"platform": "ios", "model": "iPhone 15"}, + app_version="1.0.0", + os_version="iOS 17.0", + images=[], + user_id=uuid4(), + ) + assert isinstance(result, FeedbackCreateResponse) + assert result.id + assert result.created_at + assert len(fake_repo._records) == 1 + assert fake_repo._records[0].feedback_type == "bug" + assert fake_repo._records[0].images == [] + + @pytest.mark.asyncio + async def test_submit_feedback_anonymous( + self, fake_repo: _FakeFeedbackRepository, fake_storage: _FakeStorage + ): + from v1.feedback.service import FeedbackService + + service = FeedbackService(repository=fake_repo, storage=fake_storage) + await service.submit_feedback( + feedback_type="suggestion", + content="Add dark mode", + device_info={"platform": "android", "model": "Pixel 8"}, + app_version="1.0.0", + os_version="Android 14", + images=[], + user_id=None, + ) + assert fake_repo._records[0].user_id is None + + @pytest.mark.asyncio + async def test_submit_feedback_with_images( + self, fake_repo: _FakeFeedbackRepository, fake_storage: _FakeStorage + ): + from v1.feedback.service import FeedbackService + + service = FeedbackService(repository=fake_repo, storage=fake_storage) + images = [ + _FakeUploadFile(filename="screenshot1.jpg", content_type="image/jpeg"), + _FakeUploadFile(filename="screenshot2.png", content_type="image/png"), + ] + await service.submit_feedback( + feedback_type="bug", + content="UI glitch", + device_info={"platform": "ios", "model": "iPhone 15"}, + app_version="1.0.0", + os_version="iOS 17.0", + images=images, # type: ignore[arg-type] + user_id=uuid4(), + ) + assert len(fake_storage.uploaded) == 2 + assert len(fake_repo._records[0].images) == 2 + + @pytest.mark.asyncio + async def test_content_empty_raises( + self, fake_repo: _FakeFeedbackRepository, fake_storage: _FakeStorage + ): + from v1.feedback.service import FeedbackService + + service = FeedbackService(repository=fake_repo, storage=fake_storage) + with pytest.raises(ApiProblemError) as exc_info: + await service.submit_feedback( + feedback_type="bug", + content=" ", + device_info={"platform": "ios", "model": "iPhone 15"}, + app_version="1.0.0", + os_version="iOS 17.0", + images=[], + user_id=None, + ) + assert exc_info.value.code == "FEEDBACK_CONTENT_EMPTY" + + @pytest.mark.asyncio + async def test_content_too_long_raises( + self, fake_repo: _FakeFeedbackRepository, fake_storage: _FakeStorage + ): + from v1.feedback.service import FeedbackService + + service = FeedbackService(repository=fake_repo, storage=fake_storage) + with pytest.raises(ApiProblemError) as exc_info: + await service.submit_feedback( + feedback_type="bug", + content="x" * 501, + device_info={"platform": "ios", "model": "iPhone 15"}, + app_version="1.0.0", + os_version="iOS 17.0", + images=[], + user_id=None, + ) + assert exc_info.value.code == "FEEDBACK_CONTENT_TOO_LONG" + + @pytest.mark.asyncio + async def test_too_many_images_raises( + self, fake_repo: _FakeFeedbackRepository, fake_storage: _FakeStorage + ): + from v1.feedback.service import FeedbackService + + service = FeedbackService(repository=fake_repo, storage=fake_storage) + images = [_FakeUploadFile() for _ in range(4)] + with pytest.raises(ApiProblemError) as exc_info: + await service.submit_feedback( + feedback_type="bug", + content="Test", + device_info={"platform": "ios", "model": "iPhone 15"}, + app_version="1.0.0", + os_version="iOS 17.0", + images=images, # type: ignore[arg-type] + user_id=None, + ) + assert exc_info.value.code == "FEEDBACK_TOO_MANY_IMAGES" + + @pytest.mark.asyncio + async def test_invalid_image_type_raises( + self, fake_repo: _FakeFeedbackRepository, fake_storage: _FakeStorage + ): + from v1.feedback.service import FeedbackService + + service = FeedbackService(repository=fake_repo, storage=fake_storage) + images = [_FakeUploadFile(content_type="image/gif")] + with pytest.raises(ApiProblemError) as exc_info: + await service.submit_feedback( + feedback_type="bug", + content="Test", + device_info={"platform": "ios", "model": "iPhone 15"}, + app_version="1.0.0", + os_version="iOS 17.0", + images=images, # type: ignore[arg-type] + user_id=None, + ) + assert exc_info.value.code == "FEEDBACK_INVALID_IMAGE_TYPE" + + @pytest.mark.asyncio + async def test_image_too_large_raises( + self, fake_repo: _FakeFeedbackRepository, fake_storage: _FakeStorage + ): + from v1.feedback.service import FeedbackService + + service = FeedbackService(repository=fake_repo, storage=fake_storage) + large_content = b"x" * (6 * 1024 * 1024) + images = [_FakeUploadFile(content=large_content, content_type="image/jpeg")] + with pytest.raises(ApiProblemError) as exc_info: + await service.submit_feedback( + feedback_type="bug", + content="Test", + device_info={"platform": "ios", "model": "iPhone 15"}, + app_version="1.0.0", + os_version="iOS 17.0", + images=images, # type: ignore[arg-type] + user_id=None, + ) + assert exc_info.value.code == "FEEDBACK_IMAGE_TOO_LARGE" + + @pytest.mark.asyncio + async def test_invalid_feedback_type_raises( + self, fake_repo: _FakeFeedbackRepository, fake_storage: _FakeStorage + ): + from v1.feedback.service import FeedbackService + + service = FeedbackService(repository=fake_repo, storage=fake_storage) + with pytest.raises(ApiProblemError) as exc_info: + await service.submit_feedback( + feedback_type="invalid_type", + content="Test", + device_info={"platform": "ios", "model": "iPhone 15"}, + app_version="1.0.0", + os_version="iOS 17.0", + images=[], + user_id=None, + ) + assert exc_info.value.code == "REQUEST_VALIDATION_ERROR" + + @pytest.mark.asyncio + async def test_storage_upload_failure_raises_submit_failed( + self, fake_repo: _FakeFeedbackRepository + ): + from v1.feedback.service import FeedbackService + + class _FailingStorage: + async def upload_bytes(self, **kwargs: object) -> str: + raise RuntimeError("Storage unavailable") + + service = FeedbackService( + repository=fake_repo, + storage=_FailingStorage(), # type: ignore[arg-type] + ) + images = [_FakeUploadFile()] + with pytest.raises(ApiProblemError) as exc_info: + await service.submit_feedback( + feedback_type="bug", + content="Test", + device_info={"platform": "ios", "model": "iPhone 15"}, + app_version="1.0.0", + os_version="iOS 17.0", + images=images, # type: ignore[arg-type] + user_id=None, + ) + assert exc_info.value.code == "FEEDBACK_SUBMIT_FAILED" diff --git a/docs/protocols/common/http-error-codes.md b/docs/protocols/common/http-error-codes.md index 479010b..ed7bb1b 100644 --- a/docs/protocols/common/http-error-codes.md +++ b/docs/protocols/common/http-error-codes.md @@ -89,6 +89,17 @@ This document is the source of truth for backend RFC7807 `code` values consumed |---|---:|---|---| | `INVITE_CODE_NOT_FOUND` | 404 | Invite code not found for current user | Show not-found message and trigger invite code bootstrap | +## Feedback + +| code | status | meaning | frontend handling | +|---|---:|---|---| +| `FEEDBACK_CONTENT_EMPTY` | 400 | Feedback content is empty | Prompt user to enter content | +| `FEEDBACK_CONTENT_TOO_LONG` | 400 | Feedback content exceeds 500 characters | Show character limit hint | +| `FEEDBACK_TOO_MANY_IMAGES` | 400 | More than 3 images uploaded | Show image count limit hint | +| `FEEDBACK_IMAGE_TOO_LARGE` | 400 | Image file exceeds 5 MB | Show image size limit hint | +| `FEEDBACK_INVALID_IMAGE_TYPE` | 400 | Image type not supported (only jpg/png) | Show supported format hint | +| `FEEDBACK_SUBMIT_FAILED` | 500 | Feedback submission failed | Show retry prompt | + ## Global | code | status | meaning | frontend handling | diff --git a/docs/protocols/feedback/feedback-protocol.md b/docs/protocols/feedback/feedback-protocol.md new file mode 100644 index 0000000..1bb84c5 --- /dev/null +++ b/docs/protocols/feedback/feedback-protocol.md @@ -0,0 +1,152 @@ +# Feedback Protocol (Frontend <-> Backend) + +This document defines the canonical backend contract for user feedback submission. + +Protocol verification status: + +- Backend route source: `backend/src/v1/feedback/router.py` +- Backend schema source: `backend/src/v1/feedback/schemas.py` +- Backend service source: `backend/src/v1/feedback/service.py` +- Backend dependencies source: `backend/src/v1/feedback/dependencies.py` +- Backend model source: `backend/src/models/user_feedback.py` +- Backend report source: `backend/src/v1/feedback/report.py` +- Backend tasks source: `backend/src/v1/feedback/tasks.py` +- Backend email source: `backend/src/core/email/sender.py` +- Frontend mapping source: `apps/lib/features/settings/data/apis/feedback_api.dart` +- Storage config source: `backend/src/core/config/settings.py` +- Current status: Phase 1 + Phase 2 + Phase 3 implemented + +## Compatibility strategy + +- Current strategy: additive evolution (`backward-compatible`). +- Breaking change requires explicit migration + rollback notes (`requires-migration`). + +## Route overview + +- Submit feedback: `POST /api/v1/feedback` (multipart/form-data) + +## Auth and trust boundary + +- Feedback submission supports both authenticated and anonymous modes. +- Authenticated: `Authorization: Bearer {token}` header. Backend extracts `user_id` from JWT. +- Anonymous: No `Authorization` header. `user_id` stored as `NULL`. +- Frontend controls anonymity via checkbox ("Do not upload my personal information"). + +## Submit feedback + +### Request + +``` +POST /api/v1/feedback +Content-Type: multipart/form-data +Authorization: Bearer {token} # Optional + +Form Fields: +- feedback_type: "bug" | "suggestion" | "other" (required) +- content: string (required, 1-500 chars after trim) +- device_info: string (JSON, e.g. {"platform":"ios","model":"iPhone 15"}) +- app_version: string (required) +- os_version: string (required) + +Files: +- images: File[] (optional, max 3 files, jpg/png only, max 5MB each) +``` + +### Response 201 + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "created_at": "2026-04-17T10:30:00+00:00" +} +``` + +### Error codes + +| code | status | meaning | +|---|---:|---| +| `FEEDBACK_CONTENT_EMPTY` | 400 | Content is empty or whitespace-only | +| `FEEDBACK_CONTENT_TOO_LONG` | 400 | Content exceeds 500 characters | +| `FEEDBACK_TOO_MANY_IMAGES` | 400 | More than 3 images uploaded | +| `FEEDBACK_IMAGE_TOO_LARGE` | 400 | Single image exceeds 5MB | +| `FEEDBACK_INVALID_IMAGE_TYPE` | 400 | Image type not jpg/png | +| `FEEDBACK_SUBMIT_FAILED` | 500 | Internal storage or database failure | +| `REQUEST_VALIDATION_ERROR` | 422 | Invalid feedback_type or device_info JSON | + +## Database schema + +Table: `user_feedback` + +| Column | Type | Nullable | Default | Description | +|---|---|---|---|---| +| `id` | UUID | NO | `gen_random_uuid()` | Primary key | +| `user_id` | UUID | YES | - | FK to auth.users, NULL = anonymous | +| `feedback_type` | VARCHAR(20) | NO | `'other'` | bug/suggestion/other | +| `content` | TEXT | NO | - | Feedback content, max 500 chars | +| `images` | JSONB | NO | `'[]'` | Storage path list, max 3 | +| `device_info` | JSONB | NO | `'{}'` | Device info JSON | +| `app_version` | VARCHAR(20) | NO | - | App version string | +| `os_version` | VARCHAR(50) | NO | - | OS version string | +| `status` | VARCHAR(20) | NO | `'pending'` | pending/processed | +| `created_at` | TIMESTAMPTZ | NO | `now()` | Creation timestamp | +| `updated_at` | TIMESTAMPTZ | NO | `now()` | Last update timestamp | + +## Storage + +- Bucket: configured via `ERYAO_STORAGE__FEEDBACK__BUCKET` (default: `feedback-images`) +- Visibility: private (no public access needed) +- Path pattern: `{YYYY-MM-DD}/{timestamp}_{index}.{ext}` +- Max file size: configured via `ERYAO_STORAGE__FEEDBACK__MAX_SIZE_MB` (default: 5) +- Allowed types: `image/jpeg`, `image/png` + +## Frontend implementation notes + +- Uses `image_picker` package for image selection +- Uses `dio` `FormData` for multipart upload +- Anonymous mode: uses `Options(extra: {'skipAuth': true})` to bypass token injection in ApiClient interceptor +- Client-side validation: content required, max 500 chars, max 3 images +- Server-side validation: all field validation happens in `FeedbackService` + +## Phase 2: Report Generation (Implemented) + +- **Report format**: xlsx (openpyxl) with embedded images +- **Image columns**: Screenshots anchored to columns K/L/M +- **Report path**: `{log_dir}/reports/{YYYYMMDD_HHMMSS}_feedback_report.xlsx` +- **Status transition**: `pending` -> `processed` after report generation + +## Phase 3: Email Delivery (Implemented) + +- **Email service**: `core/email/sender.py` (aiosmtplib, stateless) +- **SMTP provider**: Feishu enterprise email (`smtp.feishu.cn:465`) +- **Templates**: `core/email/templates/feedback/` + - `daily_report.html`: HTML email with stats cards (sent when feedbacks exist) + - `no_feedback.html`: Plain email (sent when no feedbacks in time range) +- **Attachment naming**: `{YYYYMMDD_HHMMSS}_feedback_report.xlsx` +- **Schedule**: Configured via `ERYAO_FEEDBACK_REPORT__CRON` (default: `0 10 * * *`) +- **Enable flag**: `ERYAO_FEEDBACK_REPORT__ENABLED` (default: `false`) + +### Email configuration + +```bash +ERYAO_EMAIL__HOST=smtp.feishu.cn +ERYAO_EMAIL__PORT=465 +ERYAO_EMAIL__USE_SSL=true +ERYAO_EMAIL__USERNAME= +ERYAO_EMAIL__PASSWORD= +ERYAO_EMAIL__FROM_ADDRESS= +ERYAO_EMAIL__FROM_NAME=Eryao Feedback System +ERYAO_FEEDBACK_REPORT__EMAIL= +``` + +### Email template variables + +| Variable | Description | +|---|---| +| `${start_date}` | Report period start date (YYYY-MM-DD) | +| `${start_hour}` | Report period start hour | +| `${end_date}` | Report period end date (YYYY-MM-DD) | +| `${end_hour}` | Report period end hour | +| `${total_count}` | Total feedback count | +| `${bug_count}` | Bug type count | +| `${suggestion_count}` | Suggestion type count | +| `${generated_at}` | Report generation timestamp | diff --git a/infra/scripts/app.sh b/infra/scripts/app.sh index 4610253..b10670e 100755 --- a/infra/scripts/app.sh +++ b/infra/scripts/app.sh @@ -153,7 +153,7 @@ start() { WEB_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src ERYAO_RUNTIME__SERVICE_NAME=web uv run uvicorn app:app --host ${ERYAO_WEB__HOST:-0.0.0.0} --port ${WEB_PORT} --workers ${ERYAO_WEB__WORKERS:-2} --log-level ${UVICORN_LOG_LEVEL}" WORKER_AGENT_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src ERYAO_RUNTIME__SERVICE_NAME=worker-agent uv run taskiq worker core.taskiq.app:worker_agent_broker core.agentscope.runtime.tasks --workers ${ERYAO_WORKER__GROUPS__AGENT__CONCURRENCY:-2}" - 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}" + 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 v1.feedback.tasks --workers ${ERYAO_WORKER__GROUPS__GENERAL__CONCURRENCY:-1}" echo "Starting tmux web process in session '$SESSION_NAME'..." diff --git a/pyproject.toml b/pyproject.toml index 3d8c74c..fb099ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ requires-python = ">=3.12" dependencies = [ "ag-ui-protocol==0.1.13", "agentscope>=1.0.18", + "aiosmtplib>=5.1.0", "alembic==1.18.4", "asyncpg==0.30.0", "cryptography==46.0.3", @@ -13,6 +14,8 @@ dependencies = [ "email-validator==2.3.0", "fastapi==0.135.1", "lunar-python>=1.4.8", + "openpyxl>=3.1.5", + "pillow>=12.2.0", "pydantic==2.12.5", "pydantic-settings==2.13.1", "pyjwt==2.11.0",