Compare commits

...

1 Commits

Author SHA1 Message Date
marco f0c80e51b9 feat(debug-trace): 调试链接权限 + trace_url 落库与按权限下发
- 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) <noreply@anthropic.com>
2026-06-10 15:31:24 +08:00
10 changed files with 131 additions and 6 deletions
@@ -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")
+14
View File
@@ -24,6 +24,20 @@ def set_user_status(db: Session, user: User, *, status: str, commit: bool = True
return user 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( def update_feedback_status(
db: Session, feedback: Feedback, *, status: str, commit: bool = True db: Session, feedback: Feedback, *, status: str, commit: bool = True
) -> Feedback: ) -> Feedback:
+23
View File
@@ -13,6 +13,7 @@ from app.admin.schemas.user import (
AdminUserListItem, AdminUserListItem,
AdminUserOverview, AdminUserOverview,
GrantCoinsRequest, GrantCoinsRequest,
SetDebugTraceRequest,
SetUserStatusRequest, SetUserStatusRequest,
) )
from app.models.admin import AdminUser from app.models.admin import AdminUser
@@ -77,6 +78,28 @@ def set_user_status(
return OkResponse() 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="手动增减金币(带审计)") @router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减金币(带审计)")
def grant_user_coins( def grant_user_coins(
user_id: int, user_id: int,
+5
View File
@@ -15,6 +15,7 @@ class AdminUserListItem(BaseModel):
nickname: str | None = None nickname: str | None = None
register_channel: str register_channel: str
status: str status: str
debug_trace_enabled: bool = False
wechat_openid: str | None = None wechat_openid: str | None = None
created_at: datetime created_at: datetime
last_login_at: datetime last_login_at: datetime
@@ -45,3 +46,7 @@ class SetUserStatusRequest(BaseModel):
status: Literal["active", "disabled"] = Field( status: Literal["active", "disabled"] = Field(
..., description="active=解封 / disabled=封禁(注销 deleted 不走此接口)" ..., description="active=解封 / disabled=封禁(注销 deleted 不走此接口)"
) )
class SetDebugTraceRequest(BaseModel):
enabled: bool = Field(..., description="是否给该用户开「复制调试链接」权限")
+15 -5
View File
@@ -77,10 +77,12 @@ def list_records(
items, next_cursor = crud_compare.list_records( items, next_cursor = crud_compare.list_records(
db, user.id, limit=limit, cursor=cursor db, user.id, limit=limit, cursor=cursor
) )
return ComparisonRecordPage( outs = [ComparisonRecordOut.model_validate(it) for it in items]
items=[ComparisonRecordOut.model_validate(it) for it in items], # 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它)
next_cursor=next_cursor, if not user.debug_trace_enabled:
) for o in outs:
o.trace_url = None
return ComparisonRecordPage(items=outs, next_cursor=next_cursor)
@router.get( @router.get(
@@ -94,4 +96,12 @@ def get_record(
rec = crud_compare.get_record(db, user.id, record_id) rec = crud_compare.get_record(db, user.id, record_id)
if rec is None: if rec is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="record not found") 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
+4
View File
@@ -53,6 +53,10 @@ class ComparisonRecord(Base):
) )
# pricebot 侧 trace_id:关联调试落盘 + 幂等去重键 # pricebot 侧 trace_id:关联调试落盘 + 幂等去重键
trace_id: Mapped[str] = mapped_column(String(64), nullable=False) 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) source_platform_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
+8 -1
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
from datetime import datetime, timezone 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 sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base from app.db.base import Base
@@ -51,6 +51,13 @@ class User(Base):
# 账号状态:active / disabled / deleted # 账号状态:active / disabled / deleted
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") 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( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False
) )
+1
View File
@@ -84,6 +84,7 @@ def upsert_record(
source_package=payload.source_package, source_package=payload.source_package,
information=payload.information, information=payload.information,
best_deeplink=payload.best_deeplink, best_deeplink=payload.best_deeplink,
trace_url=payload.trace_url,
total_dish_count=payload.total_dish_count, total_dish_count=payload.total_dish_count,
skipped_dish_count=payload.skipped_dish_count, skipped_dish_count=payload.skipped_dish_count,
items=[it.model_dump(exclude_none=True) for it in payload.items], items=[it.model_dump(exclude_none=True) for it in payload.items],
+2
View File
@@ -25,6 +25,8 @@ class UserOut(BaseModel):
status: str status: str
created_at: datetime created_at: datetime
last_login_at: datetime last_login_at: datetime
# 调试链接权限:前端据此在比价结果弹窗/记录页显示「复制调试链接」按钮。默认 false。
debug_trace_enabled: bool = False
# ===== Token 通用结构 ===== # ===== Token 通用结构 =====
+5
View File
@@ -67,6 +67,9 @@ class ComparisonRecordIn(BaseModel):
status: str | None = Field(None, description="success / failed,可不传由服务端派生") status: str | None = Field(None, description="success / failed,可不传由服务端派生")
# 最优平台商家/商品深链(客户端从 collectedLinks[best_index] 取);「再次比价」直达用 # 最优平台商家/商品深链(客户端从 collectedLinks[best_index] 取);「再次比价」直达用
best_deeplink: str | None = Field(None, description="最优平台深链,再次比价直达") 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 id: int
business_type: str business_type: str
trace_id: str trace_id: str
# 公网调试链接;仅当 user.debug_trace_enabled 时由端点填充,否则端点层置 None(权限闸)。
trace_url: str | None = None
source_platform_id: str | None = None source_platform_id: str | None = None
source_platform_name: str | None = None source_platform_name: str | None = None
source_package: str | None = None source_package: str | None = None