diff --git a/alembic/versions/feedback_table.py b/alembic/versions/feedback_table.py new file mode 100644 index 0000000..d929fe7 --- /dev/null +++ b/alembic/versions/feedback_table.py @@ -0,0 +1,44 @@ +"""feedback table (用户帮助与反馈) + +Revision ID: d1e2f3a4b5c6 +Revises: c8d9e0f1a2b3 +Create Date: 2026-05-28 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd1e2f3a4b5c6' +down_revision: Union[str, Sequence[str], None] = 'c8d9e0f1a2b3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'feedback', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('contact', sa.String(length=128), nullable=False), + sa.Column('images', sa.JSON(), nullable=True), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + ) + with op.batch_alter_table('feedback', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_feedback_user_id'), ['user_id'], unique=False) + batch_op.create_index(batch_op.f('ix_feedback_created_at'), ['created_at'], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table('feedback', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_feedback_created_at')) + batch_op.drop_index(batch_op.f('ix_feedback_user_id')) + + op.drop_table('feedback') diff --git a/app/api/v1/feedback.py b/app/api/v1/feedback.py new file mode 100644 index 0000000..cc30d86 --- /dev/null +++ b/app/api/v1/feedback.py @@ -0,0 +1,63 @@ +"""帮助与反馈 endpoint。 + +路由前缀 `/api/v1/feedback`,需 Bearer 鉴权(反馈绑到登录用户,便于回访)。 + POST / 提交反馈(multipart:content / contact 必填,images 可选 ≤4 张) + +截图复用 [app.core.media] 落盘到 /media/feedback/。 +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, File, Form, HTTPException, UploadFile + +from app.api.deps import CurrentUser, DbSession +from app.core import media +from app.repositories import feedback as feedback_repo +from app.schemas.feedback import FeedbackOut + +logger = logging.getLogger("shagua.feedback") + +router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"]) + +_MAX_IMAGES = 4 +_CONTENT_MAX = 2000 +_CONTACT_MAX = 128 + + +@router.post("", response_model=FeedbackOut, summary="提交反馈") +async def submit_feedback( + user: CurrentUser, + db: DbSession, + content: str = Form(...), + contact: str = Form(...), + images: list[UploadFile] = File(default=[]), +) -> FeedbackOut: + content = content.strip() + contact = contact.strip() + if not content: + raise HTTPException(status_code=400, detail="反馈内容不能为空") + if len(content) > _CONTENT_MAX: + raise HTTPException(status_code=400, detail="反馈内容过长") + if not contact: + raise HTTPException(status_code=400, detail="联系方式不能为空") + if len(contact) > _CONTACT_MAX: + raise HTTPException(status_code=400, detail="联系方式过长") + + files = [f for f in (images or []) if f is not None and f.filename] + if len(files) > _MAX_IMAGES: + raise HTTPException(status_code=400, detail=f"最多上传 {_MAX_IMAGES} 张图片") + + urls: list[str] = [] + for f in files: + data = await f.read() + try: + urls.append(media.save_feedback_image(user.id, data)) + except media.MediaError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + fb = feedback_repo.create_feedback( + db, user_id=user.id, content=content, contact=contact, images=urls, + ) + logger.info("feedback id=%d user_id=%d images=%d", fb.id, user.id, len(urls)) + return FeedbackOut.model_validate(fb) diff --git a/app/api/v1/user.py b/app/api/v1/user.py new file mode 100644 index 0000000..d9cb31b --- /dev/null +++ b/app/api/v1/user.py @@ -0,0 +1,59 @@ +"""用户资料 endpoint。 + +路由前缀 `/api/v1/user`,全部需 Bearer 鉴权(用户级数据,不同于 MVP 透传接口): + PATCH /profile 修改昵称 + POST /avatar 上传头像(multipart,字段名 file) + DELETE / 注销账号(软删除 + 匿名化) + +昵称/头像后端持久化到 user 表,登录后经 /auth/me 与登录响应回传客户端——这才是 +"改完重登仍生效"的唯一数据源(此前客户端只存本地、退登即丢)。 +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, File, HTTPException, UploadFile + +from app.api.deps import CurrentUser, DbSession +from app.core import media +from app.repositories import user as user_repo +from app.schemas.auth import UserOut +from app.schemas.user import OkResponse, ProfileUpdateRequest + +logger = logging.getLogger("shagua.user") + +router = APIRouter(prefix="/api/v1/user", tags=["user"]) + + +@router.patch("/profile", response_model=UserOut, summary="修改昵称") +def update_profile(req: ProfileUpdateRequest, user: CurrentUser, db: DbSession) -> UserOut: + updated = user_repo.update_nickname(db, user, nickname=req.nickname) + logger.info("update nickname user_id=%d", user.id) + return UserOut.model_validate(updated) + + +@router.post("/avatar", response_model=UserOut, summary="上传头像") +async def upload_avatar( + user: CurrentUser, + db: DbSession, + file: UploadFile = File(..., description="头像图片(jpeg/png/webp,≤5MB)"), +) -> UserOut: + data = await file.read() + try: + url = media.save_avatar(user.id, data) + except media.MediaError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + old_url = user.avatar_url + updated = user_repo.set_avatar_url(db, user, avatar_url=url) + media.delete_avatar(old_url) # 落库成功后再删旧文件,避免删了新的没存上 + logger.info("update avatar user_id=%d url=%s", user.id, url) + return UserOut.model_validate(updated) + + +@router.delete("", response_model=OkResponse, summary="注销账号(软删除)") +def delete_account(user: CurrentUser, db: DbSession) -> OkResponse: + media.delete_avatar(user.avatar_url) + user_repo.soft_delete_account(db, user) + logger.info("delete account user_id=%d", user.id) + return OkResponse() diff --git a/app/core/config.py b/app/core/config.py index 59d2b96..792f94e 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -113,6 +113,13 @@ class Settings(BaseSettings): # price/step 每帧也是 LLM,可能 >30s;对齐客户端 agent ApiClient 的 60s 读超时。 PRICEBOT_COMPARE_TIMEOUT_SEC: int = 60 + # ===== 媒体文件(用户头像上传)===== + # 落盘根目录(data/ 已 gitignore,上传不进库);对外经 StaticFiles 挂在 MEDIA_URL_PREFIX。 + # 生产可改由 nginx 直接 serve MEDIA_ROOT,绕过应用进程。 + MEDIA_ROOT: str = "./data/media" + MEDIA_URL_PREFIX: str = "/media" + AVATAR_MAX_BYTES: int = 5 * 1024 * 1024 # 头像最大 5MB + # ===== CORS ===== CORS_ALLOW_ORIGINS: str = "" diff --git a/app/core/media.py b/app/core/media.py new file mode 100644 index 0000000..7f3ae05 --- /dev/null +++ b/app/core/media.py @@ -0,0 +1,76 @@ +"""用户上传文件(头像 / 反馈截图)的本地落盘 + 对外 URL 构建。 + +落盘到 `settings.MEDIA_ROOT/<子目录>/`,对外以 `settings.MEDIA_URL_PREFIX` 暴露 +(main.py 用 StaticFiles 挂载)。返回**相对路径** URL(如 `/media/avatars/xxx.jpg`), +由客户端按自己的 BASE_URL 解析为绝对地址——服务端不知道客户端怎么访问到自己(dev 下 +可能是 10.0.2.2 / LAN IP),所以不返回绝对 URL。 + +安全:不信任客户端给的 content-type / 文件名,改用魔数嗅探判定真实图片类型; +文件名服务端随机生成,杜绝路径穿越与覆盖。 +""" +from __future__ import annotations + +import secrets +from pathlib import Path + +from app.core.config import settings + + +class MediaError(Exception): + """上传文件不合法(类型/大小)。调用方转 400。""" + + +def _media_dir(subdir: str) -> Path: + d = Path(settings.MEDIA_ROOT) / subdir + d.mkdir(parents=True, exist_ok=True) + return d + + +def _sniff_ext(data: bytes) -> str | None: + """按魔数判定图片类型,返回扩展名;非支持类型返回 None。""" + if data[:3] == b"\xff\xd8\xff": + return ".jpg" + if data[:8] == b"\x89PNG\r\n\x1a\n": + return ".png" + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return ".webp" + return None + + +def _save_image(subdir: str, user_id: int, data: bytes) -> str: + """通用图片落盘:校验非空 / ≤上限 / 魔数为图片,随机文件名,返回相对 URL。""" + if not data: + raise MediaError("空文件") + if len(data) > settings.AVATAR_MAX_BYTES: + raise MediaError("图片过大(上限 5MB)") + ext = _sniff_ext(data) + if ext is None: + raise MediaError("仅支持 JPEG / PNG / WebP 图片") + + fname = f"u{user_id}_{secrets.token_hex(8)}{ext}" + (_media_dir(subdir) / fname).write_bytes(data) + return f"{settings.MEDIA_URL_PREFIX}/{subdir}/{fname}" + + +def save_avatar(user_id: int, data: bytes) -> str: + """保存头像,返回相对 URL(`/media/avatars/`)。""" + return _save_image("avatars", user_id, data) + + +def save_feedback_image(user_id: int, data: bytes) -> str: + """保存反馈截图,返回相对 URL(`/media/feedback/`)。""" + return _save_image("feedback", user_id, data) + + +def delete_avatar(url: str | None) -> None: + """删除本服务托管的旧头像文件;外部 URL(如微信头像)或空值不处理。""" + prefix = f"{settings.MEDIA_URL_PREFIX}/avatars/" + if not url or not url.startswith(prefix): + return + fname = url[len(prefix):] + if not fname or "/" in fname or "\\" in fname or ".." in fname: + return # 防路径穿越 + try: + (_media_dir("avatars") / fname).unlink(missing_ok=True) + except OSError: + pass diff --git a/app/main.py b/app/main.py index 014d93f..14686bb 100644 --- a/app/main.py +++ b/app/main.py @@ -8,17 +8,22 @@ import logging from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from pathlib import Path + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles from app.api.v1.ad import router as ad_router from app.api.v1.auth import router as auth_router from app.api.v1.compare import router as compare_router from app.api.v1.coupon import router as coupon_router +from app.api.v1.feedback import router as feedback_router from app.api.v1.meituan import router as meituan_router from app.api.v1.savings import router as savings_router from app.api.v1.signin import router as signin_router from app.api.v1.tasks import router as tasks_router +from app.api.v1.user import router as user_router from app.api.v1.wallet import router as wallet_router from app.core.config import settings from app.core.logging import setup_logging @@ -65,6 +70,8 @@ def health() -> dict[str, str]: app.include_router(auth_router) +app.include_router(user_router) +app.include_router(feedback_router) app.include_router(coupon_router) app.include_router(compare_router) app.include_router(meituan_router) @@ -73,3 +80,12 @@ app.include_router(signin_router) app.include_router(tasks_router) app.include_router(savings_router) app.include_router(ad_router) + +# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。 +_media_root = Path(settings.MEDIA_ROOT) +_media_root.mkdir(parents=True, exist_ok=True) +app.mount( + settings.MEDIA_URL_PREFIX, + StaticFiles(directory=str(_media_root)), + name="media", +) diff --git a/app/models/__init__.py b/app/models/__init__.py index d51de66..047a2a6 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,5 +1,6 @@ """所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。""" from app.models.ad_reward import AdRewardRecord # noqa: F401 +from app.models.feedback import Feedback # noqa: F401 from app.models.savings import SavingsRecord # noqa: F401 from app.models.signin import SigninRecord # noqa: F401 from app.models.task import UserTask # noqa: F401 diff --git a/app/models/feedback.py b/app/models/feedback.py new file mode 100644 index 0000000..004d9e2 --- /dev/null +++ b/app/models/feedback.py @@ -0,0 +1,35 @@ +"""用户反馈表(帮助与反馈)。 + +每条 = 用户一次提交。content 必填,contact 必填(微信/QQ/手机,便于回访),images 为可选的 +截图 URL 列表(/media/feedback/...,JSON 存)。status: new(待处理)/ handled(已处理)。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class Feedback(Base): + __tablename__ = "feedback" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id"), index=True, nullable=False + ) + content: Mapped[str] = mapped_column(Text, nullable=False) + contact: Mapped[str] = mapped_column(String(128), nullable=False) + # 截图 URL 列表(相对路径,如 ["/media/feedback/u1_ab12.jpg"]);无图为 None + images: Mapped[list[str] | None] = mapped_column(JSON, nullable=True) + # new(待处理) / handled(已处理) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="new") + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/repositories/feedback.py b/app/repositories/feedback.py new file mode 100644 index 0000000..fc20b1f --- /dev/null +++ b/app/repositories/feedback.py @@ -0,0 +1,27 @@ +"""feedback 表写入。""" +from __future__ import annotations + +from sqlalchemy.orm import Session + +from app.models.feedback import Feedback + + +def create_feedback( + db: Session, + *, + user_id: int, + content: str, + contact: str, + images: list[str] | None, +) -> Feedback: + fb = Feedback( + user_id=user_id, + content=content, + contact=contact, + images=images or None, + status="new", + ) + db.add(fb) + db.commit() + db.refresh(fb) + return fb diff --git a/app/repositories/user.py b/app/repositories/user.py index aecf39c..697d7e8 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -46,3 +46,31 @@ def upsert_user_for_login( db.commit() db.refresh(user) return user + + +def update_nickname(db: Session, user: User, *, nickname: str) -> User: + user.nickname = nickname + db.commit() + db.refresh(user) + return user + + +def set_avatar_url(db: Session, user: User, *, avatar_url: str) -> User: + user.avatar_url = avatar_url + db.commit() + db.refresh(user) + return user + + +def soft_delete_account(db: Session, user: User) -> None: + """注销账号:软删除 + 匿名化。 + + 把 phone 改成 `deleted_` 释放唯一约束,允许同号码重新注册成全新账号; + 清空 PII(昵称/头像),保留行用于审计。status=deleted 后将无法再登录该行 + (登录接口校验 status==active)。 + """ + user.status = "deleted" + user.phone = f"deleted_{user.id}" + user.nickname = None + user.avatar_url = None + db.commit() diff --git a/app/schemas/feedback.py b/app/schemas/feedback.py new file mode 100644 index 0000000..729025c --- /dev/null +++ b/app/schemas/feedback.py @@ -0,0 +1,14 @@ +"""反馈相关响应 schema。请求是 multipart 表单(content/contact/images),在路由里直接校验。""" +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class FeedbackOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + status: str + created_at: datetime diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..ebaba24 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,20 @@ +"""用户资料(昵称/头像)相关请求 schemas。响应复用 [auth.UserOut]。""" +from __future__ import annotations + +from pydantic import BaseModel, Field, field_validator + + +class ProfileUpdateRequest(BaseModel): + nickname: str = Field(..., min_length=1, max_length=16, description="昵称,1-16 字") + + @field_validator("nickname") + @classmethod + def _strip_non_blank(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("昵称不能为空") + return v + + +class OkResponse(BaseModel): + ok: bool = True