From 280e418ccfccd7201e3d89d6ca13d545eb4ae72d Mon Sep 17 00:00:00 2001 From: zzhyyyyy <2685922758@qq.com> Date: Mon, 29 Jun 2026 22:40:16 +0800 Subject: [PATCH] =?UTF-8?q?admin=20=E5=8F=8D=E9=A6=88/=E4=BD=8E=E4=BB=B7?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E6=8E=A5=E5=8F=A3=E5=A2=9E=E5=BC=BA=20+=20?= =?UTF-8?q?=E5=8F=8D=E9=A6=88=E9=87=87=E9=9B=86=E7=AB=AF=E7=8E=AF=E5=A2=83?= =?UTF-8?q?(=E7=89=88=E6=9C=AC/=E6=9C=BA=E5=9E=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 反馈:新增 GET /admin/api/feedbacks/summary(待审核/已采纳/未采纳/合计计数); 列表联表带出完整手机号 + 昵称(供 admin 点手机号查该用户全部反馈) - 低价审核:列表加 sort_by/sort_order(提交时间排序);联表带出手机号/昵称, 并按 comparison_record_id 关联比价记录带出 trace_id/trace_url + 机型/ROM/Android/app 版本 - feedback 表新增 app_version/device_model/rom_name/android_version 四列 + alembic 迁移; /api/v1/feedback 提交接口接收并落库,供 admin 反馈页展示「提交版本号」「机型OS版本」 (字段可空,旧端不传不影响提交) Co-Authored-By: Claude Opus 4.8 --- alembic/versions/feedback_submit_env.py | 34 ++++++++++++ app/admin/repositories/queries.py | 74 +++++++++++++++++++++++-- app/admin/routers/feedback.py | 12 +++- app/admin/routers/price_report.py | 5 +- app/admin/schemas/feedback.py | 17 ++++++ app/admin/schemas/price_report.py | 11 ++++ app/api/v1/feedback.py | 7 +++ app/models/feedback.py | 5 ++ app/repositories/feedback.py | 8 +++ 9 files changed, 166 insertions(+), 7 deletions(-) create mode 100644 alembic/versions/feedback_submit_env.py diff --git a/alembic/versions/feedback_submit_env.py b/alembic/versions/feedback_submit_env.py new file mode 100644 index 0000000..c6d5516 --- /dev/null +++ b/alembic/versions/feedback_submit_env.py @@ -0,0 +1,34 @@ +"""feedback 加提交端环境快照(app_version / device_model / rom_name / android_version) + +Revision ID: feedback_submit_env +Revises: ad_pangle_daily_revenue +Create Date: 2026-06-29 00:00:00.000000 + +admin 用户反馈页要展示「提交版本号」「机型OS版本」,需在提交时落库端环境。仅新增可空列, +SQLite 原生支持 add_column、不用 batch;downgrade 的 drop_column 在 SQLite 走 batch 兜底。 +历史反馈无此快照 → 留 NULL(无法回填);客户端改版带上后的新反馈才有值。 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "feedback_submit_env" +down_revision: Union[str, Sequence[str], None] = "ad_pangle_daily_revenue" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("feedback", sa.Column("app_version", sa.String(length=32), nullable=True)) + op.add_column("feedback", sa.Column("device_model", sa.String(length=64), nullable=True)) + op.add_column("feedback", sa.Column("rom_name", sa.String(length=32), nullable=True)) + op.add_column("feedback", sa.Column("android_version", sa.String(length=16), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("feedback") as batch_op: + batch_op.drop_column("android_version") + batch_op.drop_column("rom_name") + batch_op.drop_column("device_model") + batch_op.drop_column("app_version") diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index 93a2d80..f041210 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -125,8 +125,9 @@ def list_users( return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor) -def _attach_user_info(db: Session, records: list[ComparisonRecord]) -> None: - """给每条比价记录瞬态挂 phone/nickname(非 DB 列,供 admin schema from_attributes 读)。""" +def _attach_user_info(db: Session, records: list[ComparisonRecord | Feedback | PriceReport]) -> None: + """给每条记录瞬态挂 phone/nickname(非 DB 列,供 admin schema from_attributes 读)。 + 按 user_id 鸭子类型,比价记录/反馈/上报通用。""" uids = {r.user_id for r in records} if not uids: return @@ -562,7 +563,25 @@ def list_feedbacks( sort_col = sort_cols.get(sort_by, Feedback.id) order_fn = asc if sort_order == "asc" else desc id_order = asc(Feedback.id) if sort_order == "asc" else desc(Feedback.id) - return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor) + items, next_cursor, total = offset_paginate( + db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor + ) + _attach_user_info(db, items) # 列表展示完整手机号(点手机号查该用户全部反馈) + return items, next_cursor, total + + +def feedback_summary(db: Session) -> dict: + """反馈审核台各状态计数(待审核/已采纳/未采纳/合计)。待审核含历史 new 态(与前端 isPending 一致)。""" + rows = db.execute( + select(Feedback.status, func.count(Feedback.id)).group_by(Feedback.status) + ).all() + by_status = {status: int(count) for status, count in rows} + return { + "pending": by_status.get("pending", 0) + by_status.get("new", 0), + "adopted": by_status.get("adopted", 0), + "rejected": by_status.get("rejected", 0), + "total": sum(by_status.values()), + } def list_analytics_events( @@ -978,21 +997,66 @@ def user_coin_records( return rows[offset:offset + limit], (offset + limit if has_more else None), total +def _attach_price_report_comparison(db: Session, records: list[PriceReport]) -> None: + """给每条上报瞬态挂关联比价记录的 trace + 设备/版本快照: + trace_id/trace_url(点 Trace 看完整比价过程)、device_model/rom_name/android_version(机型OS版本列)、 + app_version(提交版本号列,= 提交时我们 app 的 versionName)。 + comparison_record_id 为空 / 关联记录查不到 → 全 None。""" + cids = {r.comparison_record_id for r in records if r.comparison_record_id is not None} + rows = ( + db.execute( + select( + ComparisonRecord.id, + ComparisonRecord.trace_id, + ComparisonRecord.trace_url, + ComparisonRecord.device_model, + ComparisonRecord.rom_name, + ComparisonRecord.android_version, + ComparisonRecord.app_version, + ).where(ComparisonRecord.id.in_(cids)) + ).all() + if cids + else [] + ) + cmap = {row.id: row for row in rows} + for r in records: + row = cmap.get(r.comparison_record_id) + r.trace_id = row.trace_id if row else None + r.trace_url = row.trace_url if row else None + r.device_model = row.device_model if row else None + r.rom_name = row.rom_name if row else None + r.android_version = row.android_version if row else None + r.app_version = row.app_version if row else None + + def list_price_reports( db: Session, *, status: str | None = None, user_id: int | None = None, + sort_by: str = "id", + sort_order: str = "desc", limit: int = 20, cursor: int | None = None, ) -> tuple[list[PriceReport], int | None, int]: - """上报更低价列表(admin 全量,可按状态/用户筛)。offset 分页 + total,id 倒序。""" + """上报更低价列表(admin 全量,可按状态/用户筛)。offset 分页 + total,按 id·提交时间排序。 + join User 取 phone 瞬态挂(列表展示完整手机号);按 comparison_record_id 挂 trace_id/trace_url。""" stmt = select(PriceReport) if status: stmt = stmt.where(PriceReport.status == status) if user_id is not None: stmt = stmt.where(PriceReport.user_id == user_id) - return offset_paginate(db, stmt, (PriceReport.id.desc(),), limit=limit, cursor=cursor) + + sort_cols = {"id": PriceReport.id, "created_at": PriceReport.created_at} + sort_col = sort_cols.get(sort_by, PriceReport.id) + order_fn = asc if sort_order == "asc" else desc + id_order = asc(PriceReport.id) if sort_order == "asc" else desc(PriceReport.id) + items, next_cursor, total = offset_paginate( + db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor + ) + _attach_user_info(db, items) + _attach_price_report_comparison(db, items) + return items, next_cursor, total def price_report_summary(db: Session) -> dict: diff --git a/app/admin/routers/feedback.py b/app/admin/routers/feedback.py index 0af2fca..8f27bda 100644 --- a/app/admin/routers/feedback.py +++ b/app/admin/routers/feedback.py @@ -10,7 +10,12 @@ from app.admin.audit import write_audit from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role from app.admin.repositories import mutations, queries from app.admin.schemas.common import CursorPage, OkResponse -from app.admin.schemas.feedback import FeedbackApproveRequest, FeedbackOut, FeedbackRejectRequest +from app.admin.schemas.feedback import ( + FeedbackApproveRequest, + FeedbackOut, + FeedbackRejectRequest, + FeedbackSummary, +) from app.models.admin import AdminUser from app.models.feedback import Feedback from app.repositories import wallet as wallet_repo @@ -59,6 +64,11 @@ def list_feedbacks( ) +@router.get("/summary", response_model=FeedbackSummary, summary="反馈审核统计(各状态计数)") +def feedback_summary(db: AdminDb) -> FeedbackSummary: + return FeedbackSummary.model_validate(queries.feedback_summary(db)) + + @router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理") def handle_feedback( feedback_id: int, diff --git a/app/admin/routers/price_report.py b/app/admin/routers/price_report.py index 8e04b6d..daf3e81 100644 --- a/app/admin/routers/price_report.py +++ b/app/admin/routers/price_report.py @@ -37,11 +37,14 @@ def list_price_reports( db: AdminDb, status: Annotated[str | None, Query()] = None, user_id: Annotated[int | None, Query()] = None, + sort_by: Annotated[str, Query(pattern="^(id|created_at)$")] = "id", + sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc", limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, ) -> CursorPage[PriceReportOut]: items, next_cursor, total = queries.list_price_reports( - db, status=status, user_id=user_id, limit=limit, cursor=cursor, + db, status=status, user_id=user_id, + sort_by=sort_by, sort_order=sort_order, limit=limit, cursor=cursor, ) return CursorPage( items=[PriceReportOut.model_validate(r) for r in items], diff --git a/app/admin/schemas/feedback.py b/app/admin/schemas/feedback.py index bec9107..3988b87 100644 --- a/app/admin/schemas/feedback.py +++ b/app/admin/schemas/feedback.py @@ -23,6 +23,14 @@ class FeedbackOut(BaseModel): reviewed_by_admin_id: int | None = None reviewed_at: datetime | None = None created_at: datetime + # 提交端环境快照(feedback 表列):提交版本号 / 机型OS版本;改版前的历史反馈为 None + app_version: str | None = None + device_model: str | None = None + rom_name: str | None = None + android_version: str | None = None + # 联表瞬态字段(queries._attach_user_info 挂):列表展示完整手机号,点手机号查该用户全部反馈 + phone: str | None = None + nickname: str | None = None class FeedbackApproveRequest(BaseModel): @@ -37,3 +45,12 @@ class FeedbackApproveRequest(BaseModel): class FeedbackRejectRequest(BaseModel): reason: str = Field(min_length=1, max_length=256, description="未采纳原因,用户端可见") note: str | None = Field(default=None, max_length=256, description="运营内部审核备注") + + +class FeedbackSummary(BaseModel): + """审核台顶部各状态计数(pending 含历史 new 态)。""" + + pending: int + adopted: int + rejected: int + total: int diff --git a/app/admin/schemas/price_report.py b/app/admin/schemas/price_report.py index ddf3278..abb89d9 100644 --- a/app/admin/schemas/price_report.py +++ b/app/admin/schemas/price_report.py @@ -31,6 +31,17 @@ class PriceReportOut(BaseModel): reward_coins: int | None = None reviewed_at: datetime | None = None created_at: datetime + # 联表瞬态字段:phone/nickname 由 _attach_user_info 挂(展示完整手机号、点击查该用户全部上报); + # 其余由 _attach_price_report_comparison 从关联比价记录挂:trace_*(Trace 列点跳调试链接)、 + # device_model/rom_name/android_version(机型OS版本列)、app_version(提交版本号列) + phone: str | None = None + nickname: str | None = None + trace_id: str | None = None + trace_url: str | None = None + device_model: str | None = None + rom_name: str | None = None + android_version: str | None = None + app_version: str | None = None class PriceReportRejectRequest(BaseModel): diff --git a/app/api/v1/feedback.py b/app/api/v1/feedback.py index 7f8b562..5455d21 100644 --- a/app/api/v1/feedback.py +++ b/app/api/v1/feedback.py @@ -61,6 +61,11 @@ async def submit_feedback( content: str = Form(...), # 原型改版后客户端不再采集联系方式;保留字段以兼容旧端 + 后续可能复用,默认空串。 contact: str = Form(default=""), + # 提交端环境快照(admin 反馈页展示「提交版本号」「机型OS版本」);旧端不带 → 空串 → 存 NULL。 + app_version: str = Form(default=""), + device_model: str = Form(default=""), + rom_name: str = Form(default=""), + android_version: str = Form(default=""), images: list[UploadFile] = File(default=[]), ) -> FeedbackOut: content = content.strip() @@ -86,6 +91,8 @@ async def submit_feedback( fb = feedback_repo.create_feedback( db, user_id=user.id, content=content, contact=contact, images=urls, + app_version=app_version.strip(), device_model=device_model.strip(), + rom_name=rom_name.strip(), android_version=android_version.strip(), ) 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/models/feedback.py b/app/models/feedback.py index b8d7ad6..fb3d68a 100644 --- a/app/models/feedback.py +++ b/app/models/feedback.py @@ -25,6 +25,11 @@ class Feedback(Base): 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) + # 提交时的端环境快照(admin 排查用;客户端改版带上后的新反馈才有,历史数据为 NULL) + app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) # 我们 app versionName + device_model: Mapped[str | None] = mapped_column(String(64), nullable=True) # Build.MODEL + rom_name: Mapped[str | None] = mapped_column(String(32), nullable=True) # OemDetector os:ColorOS/MIUI/... + android_version: Mapped[str | None] = mapped_column(String(16), nullable=True) # Build.VERSION.RELEASE # pending(审核中) / adopted(已采纳) / rejected(未采纳) status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True) reject_reason: Mapped[str | None] = mapped_column(String(256), nullable=True) diff --git a/app/repositories/feedback.py b/app/repositories/feedback.py index 6c5f8a5..aa74678 100644 --- a/app/repositories/feedback.py +++ b/app/repositories/feedback.py @@ -17,12 +17,20 @@ def create_feedback( content: str, contact: str, images: list[str] | None, + app_version: str | None = None, + device_model: str | None = None, + rom_name: str | None = None, + android_version: str | None = None, ) -> Feedback: fb = Feedback( user_id=user_id, content=content, contact=contact, images=images or None, + app_version=app_version or None, + device_model=device_model or None, + rom_name=rom_name or None, + android_version=android_version or None, status="pending", created_at=datetime.now(CN_TZ).replace(tzinfo=None), )