feat(feedback): implement user feedback collection system with email reporting

Backend:
- Add user_feedback table with RLS policy
- Create feedback submission API (multipart/form-data)
- Implement xlsx report generation with embedded images
- Add scheduled email delivery via Feishu SMTP
- Create HTML email templates (daily_report, no_feedback)

Frontend:
- Add feedback screen with type selection and image picker
- Support anonymous submission via skipAuth flag
- Collect device info and app version

Protocol:
- Document feedback API contract and error codes
- Update http-error-codes.md with FEEDBACK_* codes
This commit is contained in:
qzl
2026-04-20 12:49:54 +08:00
parent 913ed26f8d
commit 6a2a9d2c87
46 changed files with 4768 additions and 9 deletions
@@ -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}
@@ -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