Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87461d1dac | |||
| 49379fd045 |
@@ -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')
|
||||
+20
-7
@@ -1,17 +1,20 @@
|
||||
"""外卖比价业务透传端点。
|
||||
"""比价业务透传端点(外卖 + 电商两套)。
|
||||
|
||||
把客户端的 POST /api/v1/intent/recognize 和 /api/v1/price/step 请求原样转发给
|
||||
pricebot-backend 的 /api/intent/recognize 和 /api/price/step。MVP 阶段**不鉴权**
|
||||
(同 coupon/step,见 docs/待办与技术债.md P1:device_id 透传区分设备,待补 JWT +
|
||||
device_id↔user_id 绑定后才能做用户级画像)。
|
||||
把客户端的 POST /api/v1/{intent/recognize, price/step, ecom/intent/recognize, ecom/step}
|
||||
请求原样转发给 pricebot-backend 的对应路径。MVP 阶段**不鉴权**(同 coupon/step,
|
||||
见 docs/待办与技术债.md P1:device_id 透传区分设备,待补 JWT + device_id↔user_id
|
||||
绑定后才能做用户级画像)。
|
||||
|
||||
外卖(food):
|
||||
- Phase 1 /intent/recognize:从源平台(淘宝闪购/美团/京东外卖)购物车页识别店名+菜品+
|
||||
价格,一次性。
|
||||
- Phase 2 /price/step:多轮循环,在目标平台复现订单读到手价,直到 done。
|
||||
|
||||
电商(ecom):
|
||||
- Phase 1 /ecom/intent/recognize:从源平台(淘宝/抖音/拼多多)SKU+详情页识别商品,一次性。
|
||||
- Phase 2 /ecom/step:多轮循环,在目标平台搜索商品并采价,直到 done。
|
||||
|
||||
真正的目标驱动比价逻辑在 pricebot-backend(GoalEngine,另一个 repo),本接口只是透传壳。
|
||||
电商(ecom)那两个端点 food MVP 暂不需要,以后放开 scene 时再加 /ecom/intent/recognize
|
||||
和 /ecom/step 两行即可。
|
||||
|
||||
pricebot 协议文档:
|
||||
pricebot-backend/docs/main/02_api_protocol.md
|
||||
@@ -86,3 +89,13 @@ async def intent_recognize(request: Request) -> dict[str, Any]:
|
||||
@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传到 pricebot)")
|
||||
async def price_step(request: Request) -> dict[str, Any]:
|
||||
return await _passthrough(request, "/api/price/step")
|
||||
|
||||
|
||||
@router.post("/ecom/intent/recognize", summary="电商比价 Phase 1 意图识别 (透传到 pricebot)")
|
||||
async def ecom_intent_recognize(request: Request) -> dict[str, Any]:
|
||||
return await _passthrough(request, "/api/ecom/intent/recognize")
|
||||
|
||||
|
||||
@router.post("/ecom/step", summary="电商比价 Phase 2 步进 (透传到 pricebot)")
|
||||
async def ecom_step(request: Request) -> dict[str, Any]:
|
||||
return await _passthrough(request, "/api/ecom/step")
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -106,6 +106,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 = ""
|
||||
|
||||
|
||||
@@ -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/<file>`)。"""
|
||||
return _save_image("avatars", user_id, data)
|
||||
|
||||
|
||||
def save_feedback_image(user_id: int, data: bytes) -> str:
|
||||
"""保存反馈截图,返回相对 URL(`/media/feedback/<file>`)。"""
|
||||
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
|
||||
+16
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"<Feedback id={self.id} user_id={self.user_id} status={self.status}>"
|
||||
@@ -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
|
||||
@@ -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_<id>` 释放唯一约束,允许同号码重新注册成全新账号;
|
||||
清空 PII(昵称/头像),保留行用于审计。status=deleted 后将无法再登录该行
|
||||
(登录接口校验 status==active)。
|
||||
"""
|
||||
user.status = "deleted"
|
||||
user.phone = f"deleted_{user.id}"
|
||||
user.nickname = None
|
||||
user.avatar_url = None
|
||||
db.commit()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user