From 953a05a5e6e1a7af9084e341a25ea29873469364 Mon Sep 17 00:00:00 2001 From: marco Date: Wed, 10 Jun 2026 15:35:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(debug-trace):=20=E8=B0=83=E8=AF=95?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E6=9D=83=E9=99=90=20+=20trace=5Furl=20?= =?UTF-8?q?=E8=90=BD=E5=BA=93=E4=B8=8E=E6=8C=89=E6=9D=83=E9=99=90=E4=B8=8B?= =?UTF-8?q?=E5=8F=91=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - user 加 debug_trace_enabled、comparison_record 加 trace_url - 迁移 f8d3b1e60a27:加两列,down_revision 用 tuple 顺带合并既存双 head(invite_fingerprint_table + 044dce6e9b1f) - UserOut 加 debug_trace_enabled → /me 与登录响应带出 - ComparisonRecordIn/Out 加 trace_url;upsert 落库 - /compare/records 列表与详情按 user.debug_trace_enabled 下发;详情连 raw_payload 里那份一并抹掉,防权限绕过 - admin 加 POST /users/{id}/debug-trace(operator + 审计),列表带该字段 Co-Authored-By: Claude Opus 4.8 (1M context) Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/35 --- ...1e60a27_add_debug_trace_and_merge_heads.py | 54 +++++++++++++++++++ app/admin/repositories/mutations.py | 14 +++++ app/admin/routers/users.py | 23 ++++++++ app/admin/schemas/user.py | 5 ++ app/api/v1/compare_record.py | 20 +++++-- app/models/comparison.py | 4 ++ app/models/user.py | 9 +++- app/repositories/comparison.py | 1 + app/schemas/auth.py | 2 + app/schemas/compare_record.py | 5 ++ 10 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 alembic/versions/f8d3b1e60a27_add_debug_trace_and_merge_heads.py diff --git a/alembic/versions/f8d3b1e60a27_add_debug_trace_and_merge_heads.py b/alembic/versions/f8d3b1e60a27_add_debug_trace_and_merge_heads.py new file mode 100644 index 0000000..93cb576 --- /dev/null +++ b/alembic/versions/f8d3b1e60a27_add_debug_trace_and_merge_heads.py @@ -0,0 +1,54 @@ +"""add user.debug_trace_enabled + comparison_record.trace_url(并合并既存双 head) + +调试链接权限功能: + - user.debug_trace_enabled:运营后台给指定用户开「复制调试链接」权限 + - comparison_record.trace_url:比价记录页「复制调试链接」的数据(pricebot done 帧给, + dir 名含落盘时分秒、前端/server 拼不出,必须落库) + +⚠️ 本迁移 down_revision 是 **tuple**,同时合并了 clone 时 main 上既存的双 head: + - invite_fingerprint_table (#31 被邀请人列表 + 指纹归因 那条线) + - 044dce6e9b1f (merge_ad_feed_reward_session + withdraw_safety_indexes) + 这两个头平行未合并,`alembic upgrade head` 会报 Multiple heads。本迁移顺手收口成单 head。 + 若想把「合并双 head」与「加列」拆开,可改成:先一个纯 merge 迁移、再一个加列迁移。 + +Revision ID: f8d3b1e60a27 +Revises: invite_fingerprint_table, 044dce6e9b1f +Create Date: 2026-06-10 02:40:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "f8d3b1e60a27" +down_revision: Union[str, Sequence[str], None] = ( + "invite_fingerprint_table", + "044dce6e9b1f", +) +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # sa.false() 渲染成 PG 的 false / SQLite 的 0,两端兼容(避免字符串 "false" 在 SQLite 上存歪) + op.add_column( + "user", + sa.Column( + "debug_trace_enabled", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + op.add_column( + "comparison_record", + sa.Column("trace_url", sa.String(length=512), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("comparison_record", "trace_url") + op.drop_column("user", "debug_trace_enabled") diff --git a/app/admin/repositories/mutations.py b/app/admin/repositories/mutations.py index 9effde4..1f7d230 100644 --- a/app/admin/repositories/mutations.py +++ b/app/admin/repositories/mutations.py @@ -28,6 +28,20 @@ def set_user_status(db: Session, user: User, *, status: str, commit: bool = True return user +def set_user_debug_trace( + db: Session, user: User, *, enabled: bool, commit: bool = True +) -> User: + """开关用户「复制调试链接」权限。同 set_user_status:支持 commit=False 让 router 把 + 业务写 + 审计写放进同一事务。""" + user.debug_trace_enabled = enabled + if commit: + db.commit() + db.refresh(user) + else: + db.flush() + return user + + def update_feedback_status( db: Session, feedback: Feedback, *, status: str, commit: bool = True ) -> Feedback: diff --git a/app/admin/routers/users.py b/app/admin/routers/users.py index 6c03f9a..6da09b9 100644 --- a/app/admin/routers/users.py +++ b/app/admin/routers/users.py @@ -13,6 +13,7 @@ from app.admin.schemas.user import ( AdminUserListItem, AdminUserOverview, GrantCoinsRequest, + SetDebugTraceRequest, SetUserStatusRequest, ) from app.models.admin import AdminUser @@ -77,6 +78,28 @@ def set_user_status( return OkResponse() +@router.post("/{user_id}/debug-trace", response_model=OkResponse, summary="开关调试链接权限") +def set_user_debug_trace( + user_id: int, + body: SetDebugTraceRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> OkResponse: + user = user_repo.get_user_by_id(db, user_id) + if user is None: + raise HTTPException(status_code=404, detail="用户不存在") + before = user.debug_trace_enabled + # 业务写 + 审计写同一事务(commit=False),最后一起 commit(同 set_user_status) + mutations.set_user_debug_trace(db, user, enabled=body.enabled, commit=False) + write_audit( + db, admin, action="user.debug_trace.set", target_type="user", target_id=user_id, + detail={"before": before, "after": body.enabled}, ip=get_client_ip(request), commit=False, + ) + db.commit() + return OkResponse() + + @router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减金币(带审计)") def grant_user_coins( user_id: int, diff --git a/app/admin/schemas/user.py b/app/admin/schemas/user.py index 46a548f..304c8b7 100644 --- a/app/admin/schemas/user.py +++ b/app/admin/schemas/user.py @@ -15,6 +15,7 @@ class AdminUserListItem(BaseModel): nickname: str | None = None register_channel: str status: str + debug_trace_enabled: bool = False wechat_openid: str | None = None created_at: datetime last_login_at: datetime @@ -45,3 +46,7 @@ class SetUserStatusRequest(BaseModel): status: Literal["active", "disabled"] = Field( ..., description="active=解封 / disabled=封禁(注销 deleted 不走此接口)" ) + + +class SetDebugTraceRequest(BaseModel): + enabled: bool = Field(..., description="是否给该用户开「复制调试链接」权限") diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py index 9c8f55b..8e527dc 100644 --- a/app/api/v1/compare_record.py +++ b/app/api/v1/compare_record.py @@ -77,10 +77,12 @@ def list_records( items, next_cursor = crud_compare.list_records( db, user.id, limit=limit, cursor=cursor ) - return ComparisonRecordPage( - items=[ComparisonRecordOut.model_validate(it) for it in items], - next_cursor=next_cursor, - ) + outs = [ComparisonRecordOut.model_validate(it) for it in items] + # 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它) + if not user.debug_trace_enabled: + for o in outs: + o.trace_url = None + return ComparisonRecordPage(items=outs, next_cursor=next_cursor) @router.get( @@ -94,4 +96,12 @@ def get_record( rec = crud_compare.get_record(db, user.id, record_id) if rec is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="record not found") - return ComparisonRecordDetailOut.model_validate(rec) + out = ComparisonRecordDetailOut.model_validate(rec) + # 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url。 + # ⚠️ raw_payload 是上报体全量(model_dump),里面也藏着一份 trace_url,必须一并抹掉—— + # 否则无权限用户从详情接口的 raw_payload 绕过权限闸拿到 trace_url。 + if not user.debug_trace_enabled: + out.trace_url = None + if isinstance(out.raw_payload, dict): + out.raw_payload.pop("trace_url", None) + return out diff --git a/app/models/comparison.py b/app/models/comparison.py index f1f593c..7a42a4f 100644 --- a/app/models/comparison.py +++ b/app/models/comparison.py @@ -53,6 +53,10 @@ class ComparisonRecord(Base): ) # pricebot 侧 trace_id:关联调试落盘 + 幂等去重键 trace_id: Mapped[str] = mapped_column(String(64), nullable=False) + # 本次比价的公网调试链接(price.shaguabijia.com/traces/{dir}/)。pricebot done 帧给、 + # 客户端上报带上——dir 名含 pricebot 落盘的时分秒,前端/server 都拼不出,必须存。 + # 查看接口按 user.debug_trace_enabled 决定返不返回。旧记录 / 未开上云为 None。 + trace_url: Mapped[str | None] = mapped_column(String(512), nullable=True) # ===== 源平台(发起比价的那家)===== source_platform_id: Mapped[str | None] = mapped_column(String(32), nullable=True) diff --git a/app/models/user.py b/app/models/user.py index 2f1d672..67d6600 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -11,7 +11,7 @@ from __future__ import annotations from datetime import datetime, timezone -from sqlalchemy import DateTime, Integer, String, func +from sqlalchemy import Boolean, DateTime, Integer, String, false, func from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -51,6 +51,13 @@ class User(Base): # 账号状态:active / disabled / deleted status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") + # 调试链接权限:开了的用户在比价完成弹窗 + 比价记录页能看到「复制调试链接」按钮 + # (复制 price.shaguabijia.com 的 trace 链接发给开发排障)。运营后台按用户配置; + # /me 与登录响应里带出给前端做条件渲染。默认 false。 + debug_trace_enabled: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default=false() + ) + created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) diff --git a/app/repositories/comparison.py b/app/repositories/comparison.py index e697be6..640ffa0 100644 --- a/app/repositories/comparison.py +++ b/app/repositories/comparison.py @@ -84,6 +84,7 @@ def upsert_record( source_package=payload.source_package, information=payload.information, best_deeplink=payload.best_deeplink, + trace_url=payload.trace_url, total_dish_count=payload.total_dish_count, skipped_dish_count=payload.skipped_dish_count, items=[it.model_dump(exclude_none=True) for it in payload.items], diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 2705bba..b878a91 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -25,6 +25,8 @@ class UserOut(BaseModel): status: str created_at: datetime last_login_at: datetime + # 调试链接权限:前端据此在比价结果弹窗/记录页显示「复制调试链接」按钮。默认 false。 + debug_trace_enabled: bool = False # ===== Token 通用结构 ===== diff --git a/app/schemas/compare_record.py b/app/schemas/compare_record.py index 7543d4a..2a6ba5f 100644 --- a/app/schemas/compare_record.py +++ b/app/schemas/compare_record.py @@ -67,6 +67,9 @@ class ComparisonRecordIn(BaseModel): status: str | None = Field(None, description="success / failed,可不传由服务端派生") # 最优平台商家/商品深链(客户端从 collectedLinks[best_index] 取);「再次比价」直达用 best_deeplink: str | None = Field(None, description="最优平台深链,再次比价直达") + # pricebot done.params.trace_url 原样上报,落库供记录页「复制调试链接」(dir 名含落盘 + # 时分秒前端拼不出,必须由后端透传)。 + trace_url: str | None = Field(None, description="本次比价公网调试链接") # ===== 读取出参 ===== @@ -79,6 +82,8 @@ class ComparisonRecordOut(BaseModel): id: int business_type: str trace_id: str + # 公网调试链接;仅当 user.debug_trace_enabled 时由端点填充,否则端点层置 None(权限闸)。 + trace_url: str | None = None source_platform_id: str | None = None source_platform_name: str | None = None source_package: str | None = None