Files
eryao/backend/alembic/versions/20260417_0001_create_user_feedback.py
T
qzl 6a2a9d2c87 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
2026-04-20 12:49:54 +08:00

119 lines
3.5 KiB
Python

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