Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9165397b8 | |||
| 953a05a5e6 | |||
| e69788eb41 | |||
| cfc54ac2be |
@@ -0,0 +1,26 @@
|
||||
"""merge invite_fingerprint and ad_reward heads
|
||||
|
||||
Revision ID: a8c47fc4dc39
|
||||
Revises: invite_fingerprint_table, 044dce6e9b1f
|
||||
Create Date: 2026-06-10 01:03:03.699443
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a8c47fc4dc39'
|
||||
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:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -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")
|
||||
@@ -21,6 +21,7 @@ from app.admin.routers.dashboard import router as dashboard_router
|
||||
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
|
||||
from app.admin.routers.feedback import router as feedback_router
|
||||
from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router
|
||||
from app.admin.routers.price_report import router as price_report_router
|
||||
from app.admin.routers.users import router as users_router
|
||||
from app.admin.routers.wallet import router as wallet_router
|
||||
from app.admin.routers.withdraw import router as withdraw_router
|
||||
@@ -80,6 +81,7 @@ admin_app.include_router(ops_marquee_seed_router)
|
||||
admin_app.include_router(users_router)
|
||||
admin_app.include_router(wallet_router)
|
||||
admin_app.include_router(withdraw_router)
|
||||
admin_app.include_router(price_report_router)
|
||||
admin_app.include_router(feedback_router)
|
||||
admin_app.include_router(admins_router)
|
||||
admin_app.include_router(audit_router)
|
||||
|
||||
@@ -8,9 +8,13 @@ set_user_status / update_feedback_status 支持 commit=False,让 router 把"业
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import CN_TZ
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.price_report import PriceReport
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
@@ -24,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:
|
||||
@@ -34,3 +52,30 @@ def update_feedback_status(
|
||||
else:
|
||||
db.flush()
|
||||
return feedback
|
||||
|
||||
|
||||
def review_price_report(
|
||||
db: Session,
|
||||
report: PriceReport,
|
||||
*,
|
||||
status: str,
|
||||
reward_coins: int | None = None,
|
||||
reject_reason: str | None = None,
|
||||
commit: bool = True,
|
||||
) -> PriceReport:
|
||||
"""审核上报:置 approved/rejected + 记审核时间;通过填 reward_coins、拒绝填 reject_reason。
|
||||
|
||||
发金币(wallet.grant_coins)不在这里——由 router 在同一事务里调,涉钱逻辑不重写(见模块头注释)。
|
||||
"""
|
||||
report.status = status
|
||||
if reward_coins is not None:
|
||||
report.reward_coins = reward_coins
|
||||
if reject_reason is not None:
|
||||
report.reject_reason = reject_reason
|
||||
report.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None) # 北京 wall-clock,同 created_at
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(report)
|
||||
else:
|
||||
db.flush()
|
||||
return report
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
|
||||
from app.models.admin import AdminAuditLog
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.price_report import PriceReport
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
|
||||
|
||||
@@ -395,3 +396,34 @@ def get_user_overview(db: Session, user_id: int) -> dict | None:
|
||||
).scalar_one(),
|
||||
"feedback_total": _count(Feedback, Feedback.user_id == user_id),
|
||||
}
|
||||
|
||||
|
||||
def list_price_reports(
|
||||
db: Session,
|
||||
*,
|
||||
status: str | None = None,
|
||||
user_id: int | None = None,
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[PriceReport], int | None]:
|
||||
"""上报更低价列表(admin 全量,可按状态/用户筛)。游标同 feedback:id 倒序。"""
|
||||
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 cursor_paginate(db, stmt, PriceReport.id, limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
def price_report_summary(db: Session) -> dict:
|
||||
"""上报审核台各状态计数(待审核/已通过/已拒绝/合计)。"""
|
||||
rows = db.execute(
|
||||
select(PriceReport.status, func.count(PriceReport.id)).group_by(PriceReport.status)
|
||||
).all()
|
||||
by_status = {status: int(count) for status, count in rows}
|
||||
return {
|
||||
"pending": by_status.get("pending", 0),
|
||||
"approved": by_status.get("approved", 0),
|
||||
"rejected": by_status.get("rejected", 0),
|
||||
"total": sum(by_status.values()),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""admin 上报更低价审核:列表 + 统计(读)+ 通过(发金币)/拒绝(写,带审计)。
|
||||
|
||||
数据由客户端 POST /api/v1/report 写入 price_report 表(提交即 pending);本路由是运营后台
|
||||
对它的人工审核窗口。**通过** → 给上报用户钱包发固定金币(PRICE_REPORT_REWARD_COINS):
|
||||
改状态 + 发金币(wallet.grant_coins)+ 审计同一事务一起 commit(原子,仿 users.grant_user_coins),
|
||||
绝不只改状态不发钱或反之。客户端轮询 GET /api/v1/report/records 自动看到结果(无需推送)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
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.price_report import (
|
||||
PriceReportOut,
|
||||
PriceReportRejectRequest,
|
||||
PriceReportSummary,
|
||||
)
|
||||
from app.core.rewards import PRICE_REPORT_REWARD_COINS
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.price_report import PriceReport
|
||||
from app.repositories import wallet as wallet_repo
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/price-reports",
|
||||
tags=["admin-price-report"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=CursorPage[PriceReportOut], summary="上报更低价列表(筛选+分页)")
|
||||
def list_price_reports(
|
||||
db: AdminDb,
|
||||
status: Annotated[str | None, Query()] = None,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[PriceReportOut]:
|
||||
items, next_cursor = queries.list_price_reports(
|
||||
db, status=status, user_id=user_id, limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[PriceReportOut.model_validate(r) for r in items], next_cursor=next_cursor,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/summary", response_model=PriceReportSummary, summary="上报审核统计(各状态计数)")
|
||||
def price_report_summary(db: AdminDb) -> PriceReportSummary:
|
||||
return PriceReportSummary.model_validate(queries.price_report_summary(db))
|
||||
|
||||
|
||||
@router.post("/{report_id}/approve", response_model=OkResponse, summary="通过上报(发固定金币)")
|
||||
def approve_price_report(
|
||||
report_id: int,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
rep = db.get(PriceReport, report_id)
|
||||
if rep is None:
|
||||
raise HTTPException(status_code=404, detail="上报记录不存在")
|
||||
if rep.status != "pending":
|
||||
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
|
||||
coins = PRICE_REPORT_REWARD_COINS
|
||||
# 改状态 + 发金币 + 审计同一事务(commit=False),最后一起 commit:改了就有痕、发了就留账
|
||||
mutations.review_price_report(db, rep, status="approved", reward_coins=coins, commit=False)
|
||||
wallet_repo.grant_coins(
|
||||
db, rep.user_id, coins,
|
||||
biz_type="price_report_reward", ref_id=str(rep.id), remark="上报更低价审核通过",
|
||||
)
|
||||
write_audit(
|
||||
db, admin, action="price_report.approve", target_type="price_report", target_id=report_id,
|
||||
detail={"reward_coins": coins, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.post("/{report_id}/reject", response_model=OkResponse, summary="拒绝上报")
|
||||
def reject_price_report(
|
||||
report_id: int,
|
||||
body: PriceReportRejectRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
rep = db.get(PriceReport, report_id)
|
||||
if rep is None:
|
||||
raise HTTPException(status_code=404, detail="上报记录不存在")
|
||||
if rep.status != "pending":
|
||||
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
|
||||
reason = body.reason.strip()
|
||||
mutations.review_price_report(db, rep, status="rejected", reject_reason=reason, commit=False)
|
||||
write_audit(
|
||||
db, admin, action="price_report.reject", target_type="price_report", target_id=report_id,
|
||||
detail={"reason": reason, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return OkResponse()
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""admin 上报更低价审核 schemas。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class PriceReportOut(BaseModel):
|
||||
"""admin 列表项:price_report 全字段(审核要看的快照 + 截图 + 状态 + 奖励)。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
comparison_record_id: int | None = None
|
||||
# 选中比价记录的快照
|
||||
store_name: str | None = None
|
||||
dish_summary: str | None = None
|
||||
original_platform_id: str | None = None
|
||||
original_platform_name: str | None = None
|
||||
original_price_cents: int | None = None
|
||||
# 用户上报的更低价
|
||||
reported_platform_id: str
|
||||
reported_platform_name: str
|
||||
reported_price_cents: int
|
||||
images: list[str] = []
|
||||
# 审核
|
||||
status: str
|
||||
reject_reason: str | None = None
|
||||
reward_coins: int | None = None
|
||||
reviewed_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PriceReportRejectRequest(BaseModel):
|
||||
reason: str = Field(min_length=1, max_length=256, description="拒绝理由,用户端记录页会看到")
|
||||
|
||||
|
||||
class PriceReportSummary(BaseModel):
|
||||
"""审核台顶部各状态计数。"""
|
||||
|
||||
pending: int
|
||||
approved: int
|
||||
rejected: int
|
||||
total: int
|
||||
@@ -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="是否给该用户开「复制调试链接」权限")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -77,6 +77,12 @@ def record_milestone_reward(milestone: int) -> int:
|
||||
return RECORD_MILESTONES[milestone - 1]
|
||||
|
||||
|
||||
# ===== 上报更低价(人工审核通过发固定金币)=====
|
||||
# 用户上报"某平台比我们算的最低价更便宜"+ 截图,经运营后台人工审核通过后发放的固定金币奖励。
|
||||
# 与广告/任务同量级;固定值(产品 2026-06 定),要调直接改这里;客户端记录页按 reward_coins 显示。
|
||||
PRICE_REPORT_REWARD_COINS: int = 1000
|
||||
|
||||
|
||||
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
|
||||
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。
|
||||
INVITE_INVITER_COINS: int = 10000
|
||||
|
||||
@@ -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)
|
||||
|
||||
+8
-1
@@ -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
|
||||
)
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -25,6 +25,8 @@ class UserOut(BaseModel):
|
||||
status: str
|
||||
created_at: datetime
|
||||
last_login_at: datetime
|
||||
# 调试链接权限:前端据此在比价结果弹窗/记录页显示「复制调试链接」按钮。默认 false。
|
||||
debug_trace_enabled: bool = False
|
||||
|
||||
|
||||
# ===== Token 通用结构 =====
|
||||
|
||||
@@ -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
|
||||
|
||||
+27
-15
@@ -62,11 +62,11 @@
|
||||
<div class="feat">💰 <span>省下的钱看得见,还能<b>赚金币提现</b></span></div>
|
||||
</div>
|
||||
|
||||
<button class="btn" id="dlbtn">⬇️ 下载安装包</button>
|
||||
<div class="hint" id="hint">Android 安卓版 · 安装包约 24 MB</div>
|
||||
<button class="btn" id="dlbtn">🏪 打开应用商店下载</button>
|
||||
<div class="hint" id="hint">Android 安卓版 · 应用商店安全下载</div>
|
||||
|
||||
<div class="foot">
|
||||
安装时如提示「未知来源」,请允许后继续安装。<br>
|
||||
将前往应用商店下载,安全放心。<br>
|
||||
本页为内部测试页。
|
||||
</div>
|
||||
|
||||
@@ -82,16 +82,18 @@
|
||||
<div class="wxsteps">
|
||||
<div><span class="n">1</span>点右上角的 ··· 菜单</div>
|
||||
<div><span class="n">2</span>选择「在浏览器打开」</div>
|
||||
<div><span class="n">3</span>在浏览器里点「下载安装包」</div>
|
||||
<div><span class="n">3</span>在浏览器里按提示去应用商店下载</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="closebar" id="wxclose">我知道了 ✕</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// APK 下载地址跟随当前页面 origin:本地测试时页面由笔记本 app-server(LAN:8770)托管 →
|
||||
// 下载也走 LAN;生产由 app-api.shaguabijia.com 托管 → 下载走生产域名。无需按机器改 IP。
|
||||
var APK_URL = location.origin + "/media/shaguabijia.apk";
|
||||
// 应用商店跳转:用包名(App 唯一标识 = 身份证号)定位到傻瓜比价的商店下载页。
|
||||
// 主:market:// 唤起手机自带应用市场(华为/小米/OV);兜底:应用宝网页(任何浏览器都能开)。
|
||||
var PKG = "com.jishisongfu.shaguabijia";
|
||||
var MARKET_URL = "market://details?id=" + PKG;
|
||||
var YYB_URL = "https://a.app.qq.com/o/simple.jsp?pkgname=" + PKG;
|
||||
var ua = navigator.userAgent || "";
|
||||
var isWeChat = /MicroMessenger/i.test(ua);
|
||||
var isIOS = /iPhone|iPad|iPod/i.test(ua);
|
||||
@@ -138,12 +140,20 @@
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
// 确保剪贴板写完(或最多等 400ms 兜底,防写入卡住)再触发下载。
|
||||
function startDownload() {
|
||||
var fired = false;
|
||||
function go() { if (!fired) { fired = true; window.location.href = APK_URL; } }
|
||||
copyInviteCode().then(go);
|
||||
setTimeout(go, 400);
|
||||
// 跳应用商店:先尝试 market:// 唤起手机自带商店;若 2.5s 内页面没切到后台
|
||||
//(= 没有商店接管 market://),兜底跳应用宝网页下载页,不让用户卡死。
|
||||
function openStore() {
|
||||
var jumped = false;
|
||||
function markJumped() { jumped = true; } // 页面切到后台 = 商店已唤起
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (document.hidden) jumped = true;
|
||||
});
|
||||
window.addEventListener("pagehide", markJumped);
|
||||
window.addEventListener("blur", markJumped);
|
||||
window.location.href = MARKET_URL; // 唤起自带应用市场
|
||||
setTimeout(function () {
|
||||
if (!jumped) window.location.href = YYB_URL; // 没唤起 → 应用宝网页兜底
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
var hint = document.getElementById("hint");
|
||||
@@ -160,8 +170,10 @@
|
||||
document.getElementById("dlbtn").addEventListener("click", function(){
|
||||
if (isWeChat) { showMask(); return; } // 微信内:引导去浏览器
|
||||
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
|
||||
// 普通浏览器:先把邀请码写进剪贴板(写完或 400ms 兜底),再下载 APK(首启读回完成归因)
|
||||
startDownload();
|
||||
// 安卓浏览器:先把邀请码写进剪贴板(legacyCopy 同步写、最可靠),再跳应用商店。
|
||||
// 剪贴板供 App 首启归因;链路长易丢时由指纹兜底(已上报 landing-track)接住。
|
||||
copyInviteCode();
|
||||
openStore();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user