Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe32704898 | |||
| e8bd12cc1f | |||
| 57ddcd356b |
@@ -83,6 +83,8 @@ WXPAY_AUTH_NOTIFY_URL=
|
||||
WITHDRAW_AUTO_RECONCILE_ENABLED=false
|
||||
WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC=300
|
||||
WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES=15
|
||||
# 0 点自动兑金币开关(deploy/daily-exchange.timer 触发的脚本读它;false=脚本 no-op)。默认 true。
|
||||
AUTO_EXCHANGE_ENABLED=true
|
||||
|
||||
# ===== 穿山甲激励视频(服务端发奖回调)=====
|
||||
# 看完激励视频后穿山甲服务器 S2S 回调本服务发金币(客户端不参与发奖)。
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""drop user.force_onboarding column
|
||||
|
||||
force_onboarding(运营「按用户」强制新手引导)机制废弃:客户端实际按 onboarding_completion
|
||||
(设备+账号 维度)判断是否走引导,该 force_onboarding 列从未被客户端正确响应(被
|
||||
onboarding_completed 压过)。改用 admin「设备维度」重置(删 onboarding_completion 记录)替代,
|
||||
见 app/admin/routers/onboarding.py。本迁移删列。
|
||||
|
||||
Revision ID: drop_force_onboarding
|
||||
Revises: 9b894f5fff05
|
||||
Create Date: 2026-06-12 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "drop_force_onboarding"
|
||||
down_revision: Union[str, Sequence[str], None] = "9b894f5fff05"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_column("user", "force_onboarding")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 回滚:重建列(同 user_force_onboarding 迁移的 upgrade)。sa.false() 两端兼容。
|
||||
op.add_column(
|
||||
"user",
|
||||
sa.Column(
|
||||
"force_onboarding",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
)
|
||||
@@ -21,6 +21,7 @@ from app.admin.routers.config import router as config_router
|
||||
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.onboarding import router as onboarding_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
|
||||
@@ -80,6 +81,7 @@ admin_app.include_router(dashboard_router)
|
||||
admin_app.include_router(ops_stat_config_router)
|
||||
admin_app.include_router(ops_marquee_seed_router)
|
||||
admin_app.include_router(users_router)
|
||||
admin_app.include_router(onboarding_router)
|
||||
admin_app.include_router(wallet_router)
|
||||
admin_app.include_router(withdraw_router)
|
||||
admin_app.include_router(price_report_router)
|
||||
|
||||
@@ -10,10 +10,12 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import CN_TZ
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.onboarding import OnboardingCompletion
|
||||
from app.models.price_report import PriceReport
|
||||
from app.models.user import User
|
||||
|
||||
@@ -42,21 +44,6 @@ def set_user_debug_trace(
|
||||
return user
|
||||
|
||||
|
||||
def set_user_force_onboarding(
|
||||
db: Session, user: User, *, enabled: bool, commit: bool = True
|
||||
) -> User:
|
||||
"""运营「一键开启/取消新手引导」:置 user.force_onboarding。开启后该用户下次启动 App 被强制
|
||||
重走引导教程,走完即由 /onboarding/complete 自动清回 false。同 set_user_debug_trace:支持
|
||||
commit=False 让 router 把业务写 + 审计写放进同一事务。"""
|
||||
user.force_onboarding = 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:
|
||||
@@ -94,3 +81,27 @@ def review_price_report(
|
||||
else:
|
||||
db.flush()
|
||||
return report
|
||||
|
||||
|
||||
def delete_device_onboarding(db: Session, device_id: str, *, commit: bool = True) -> int:
|
||||
"""删某设备(device_id)的全部 onboarding 完成记录 → 该设备上所有账号下次登录重走引导。
|
||||
返回删除行数。支持 commit=False 让 router 把业务写 + 审计写放进同一事务。"""
|
||||
n = db.execute(
|
||||
delete(OnboardingCompletion).where(OnboardingCompletion.device_id == device_id)
|
||||
).rowcount
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
db.flush()
|
||||
return n
|
||||
|
||||
|
||||
def delete_all_onboarding(db: Session, *, commit: bool = True) -> int:
|
||||
"""清空 onboarding_completion 表 → 所有用户在所有设备下次登录都重走引导。返回删除行数。
|
||||
支持 commit=False(同 delete_device_onboarding)。"""
|
||||
n = db.execute(delete(OnboardingCompletion)).rowcount
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
db.flush()
|
||||
return n
|
||||
|
||||
@@ -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.onboarding import OnboardingCompletion
|
||||
from app.models.price_report import PriceReport
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
|
||||
@@ -56,6 +57,26 @@ def list_users(
|
||||
return cursor_paginate(db, stmt, User.id, limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
def list_onboarding_devices(db: Session, *, limit: int = 500) -> list[dict]:
|
||||
"""按设备(device_id, ANDROID_ID)聚合 onboarding_completion:每台设备走过引导的账号数 +
|
||||
最近完成时间,按最近完成倒序。设备维度新手引导管理用。没走过引导的设备不在表里(本就会
|
||||
引导、无需管理)。当前全量返回(上限 limit;调试期设备少,量大再加分页/搜索)。"""
|
||||
rows = db.execute(
|
||||
select(
|
||||
OnboardingCompletion.device_id,
|
||||
func.count(OnboardingCompletion.id),
|
||||
func.max(OnboardingCompletion.completed_at),
|
||||
)
|
||||
.group_by(OnboardingCompletion.device_id)
|
||||
.order_by(func.max(OnboardingCompletion.completed_at).desc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
return [
|
||||
{"device_id": did, "account_count": int(cnt), "last_completed_at": last}
|
||||
for did, cnt, last in rows
|
||||
]
|
||||
|
||||
|
||||
def list_all_coin_transactions(
|
||||
db: Session,
|
||||
*,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""admin 设备维度新手引导管理:列设备 / 重置单设备 / 全部重置。
|
||||
|
||||
背景:原 force_onboarding(运营「按用户」强制引导)客户端不认(被 onboarding_completed 压过),
|
||||
已废弃。改为直接操作 onboarding_completion(设备+账号 维度):删记录 → 该(设备,账号)的
|
||||
onboarding_completed 变 false → 客户端任何版本下次登录都重走引导。可靠、不依赖客户端实现。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, 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.onboarding import DeviceOnboardingItem, ResetAllRequest
|
||||
from app.models.admin import AdminUser
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/onboarding",
|
||||
tags=["admin-onboarding"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/devices",
|
||||
response_model=CursorPage[DeviceOnboardingItem],
|
||||
summary="设备列表(按设备聚合走过引导的账号)",
|
||||
)
|
||||
def list_devices(db: AdminDb) -> CursorPage[DeviceOnboardingItem]:
|
||||
"""列出走过新手引导的设备(ANDROID_ID),按最近完成时间倒序。没走过引导的设备不在表里、
|
||||
本就会引导,无需管理,故不列。当前全量返回(上限 500;设备量大再加分页/搜索),
|
||||
next_cursor 恒 None。"""
|
||||
rows = queries.list_onboarding_devices(db)
|
||||
return CursorPage(
|
||||
items=[DeviceOnboardingItem(**r) for r in rows],
|
||||
next_cursor=None,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/devices/{device_id}/reset",
|
||||
response_model=OkResponse,
|
||||
summary="重置单个设备(该设备所有账号下次重走引导)",
|
||||
)
|
||||
def reset_device(
|
||||
device_id: str,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
"""删该设备的全部 onboarding 完成记录 → 该设备上所有账号下次登录重走引导。"""
|
||||
# 业务写 + 审计写同一事务(commit=False),最后一起 commit(同 users 各写接口)
|
||||
deleted = mutations.delete_device_onboarding(db, device_id, commit=False)
|
||||
write_audit(
|
||||
db, admin, action="device_onboarding.reset", target_type="device", target_id=device_id,
|
||||
detail={"deleted_rows": deleted}, ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/reset-all",
|
||||
response_model=OkResponse,
|
||||
summary="全部重设(清空所有设备引导记录)",
|
||||
)
|
||||
def reset_all(
|
||||
body: ResetAllRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
"""清空 onboarding_completion 表 → 所有用户在所有设备下次登录都重走引导。
|
||||
需 confirm=true 防误触(前端另有二次确认弹窗)。"""
|
||||
if not body.confirm:
|
||||
raise HTTPException(status_code=400, detail="需 confirm=true 确认")
|
||||
deleted = mutations.delete_all_onboarding(db, commit=False)
|
||||
write_audit(
|
||||
db, admin, action="device_onboarding.reset_all", target_type="onboarding", target_id=None,
|
||||
detail={"deleted_rows": deleted}, ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return OkResponse()
|
||||
@@ -15,7 +15,6 @@ from app.admin.schemas.user import (
|
||||
GrantCashRequest,
|
||||
GrantCoinsRequest,
|
||||
SetDebugTraceRequest,
|
||||
SetForceOnboardingRequest,
|
||||
SetUserStatusRequest,
|
||||
)
|
||||
from app.models.admin import AdminUser
|
||||
@@ -102,33 +101,6 @@ def set_user_debug_trace(
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.post("/{user_id}/force-onboarding", response_model=OkResponse, summary="一键开启/取消新手引导")
|
||||
def set_user_force_onboarding(
|
||||
user_id: int,
|
||||
body: SetForceOnboardingRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
"""运营「一键开启新手引导」:置 user.force_onboarding。开启后该用户下次启动 App(/me 带出此字段)
|
||||
会被强制重走引导教程——即便此前已看完;走完引导后端自动清回 false,不无限循环。
|
||||
仅对支持 force_onboarding 的 App 版本生效(老版本忽略该字段)。"""
|
||||
user = user_repo.get_user_by_id(db, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
if user.status == "deleted":
|
||||
raise HTTPException(status_code=400, detail="已注销账号不可操作")
|
||||
before = user.force_onboarding
|
||||
# 业务写 + 审计写同一事务(commit=False),最后一起 commit(同 set_user_status / debug-trace)
|
||||
mutations.set_user_force_onboarding(db, user, enabled=body.enabled, commit=False)
|
||||
write_audit(
|
||||
db, admin, action="user.force_onboarding.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,24 @@
|
||||
"""admin 设备维度新手引导管理 schemas。
|
||||
|
||||
force_onboarding(按用户)废弃后改用「按设备」:直接操作 onboarding_completion(设备+账号 维度),
|
||||
客户端任何版本都按它判断是否走引导,故可靠。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DeviceOnboardingItem(BaseModel):
|
||||
"""一台设备(ANDROID_ID)的引导聚合:在该设备走过引导的账号数 + 最近完成时间。"""
|
||||
|
||||
device_id: str
|
||||
account_count: int
|
||||
last_completed_at: datetime
|
||||
|
||||
|
||||
class ResetAllRequest(BaseModel):
|
||||
confirm: bool = Field(
|
||||
False, description="必须 true 才清空全部设备的引导记录(防误调接口;前端另有二次确认弹窗)"
|
||||
)
|
||||
@@ -16,8 +16,6 @@ class AdminUserListItem(BaseModel):
|
||||
register_channel: str
|
||||
status: str
|
||||
debug_trace_enabled: bool = False
|
||||
# 运营「一键开启新手引导」:true=该用户下次启动 App 被强制重走引导(走完自动清回 false)
|
||||
force_onboarding: bool = False
|
||||
wechat_openid: str | None = None
|
||||
created_at: datetime
|
||||
last_login_at: datetime
|
||||
@@ -57,9 +55,3 @@ class SetUserStatusRequest(BaseModel):
|
||||
|
||||
class SetDebugTraceRequest(BaseModel):
|
||||
enabled: bool = Field(..., description="是否给该用户开「复制调试链接」权限")
|
||||
|
||||
|
||||
class SetForceOnboardingRequest(BaseModel):
|
||||
enabled: bool = Field(
|
||||
..., description="true=一键开启(强制该用户下次启动 App 重走新手引导)/ false=取消"
|
||||
)
|
||||
|
||||
+24
-4
@@ -12,14 +12,19 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile
|
||||
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import media
|
||||
from app.repositories import onboarding as onboarding_repo
|
||||
from app.repositories import user as user_repo
|
||||
from app.schemas.auth import UserOut
|
||||
from app.schemas.user import OkResponse, OnboardingCompleteRequest, ProfileUpdateRequest
|
||||
from app.schemas.user import (
|
||||
OkResponse,
|
||||
OnboardingCompleteRequest,
|
||||
OnboardingStatusResponse,
|
||||
ProfileUpdateRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.user")
|
||||
|
||||
@@ -59,12 +64,27 @@ def complete_onboarding(
|
||||
"""走完新手引导时调一次。按 (当前账号, device_id) 落一条完成标记,跨卸载重装持久。
|
||||
幂等:重复调用不报错。device_id 取客户端硬件级 ANDROID_ID,与登录请求一致。"""
|
||||
onboarding_repo.mark_completed(db, user_id=user.id, device_id=req.device_id)
|
||||
# 若该用户被运营「一键开启新手引导」强制拉回引导,走完即清除强制标记(否则下次启动还会再触发)。
|
||||
user_repo.clear_force_onboarding(db, user)
|
||||
logger.info("onboarding complete user_id=%d device_len=%d", user.id, len(req.device_id))
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/onboarding/status",
|
||||
response_model=OnboardingStatusResponse,
|
||||
summary="查新手引导是否已完成(按 设备+账号)",
|
||||
)
|
||||
def onboarding_status(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
device_id: str = Query("", max_length=64, description="硬件级 ANDROID_ID;空=按未完成处理"),
|
||||
) -> OnboardingStatusResponse:
|
||||
"""已登录用户启动时查:该 (账号, 设备) 走过引导没。false → 客户端重走(运营在 admin 删了该设备
|
||||
记录即触发)。替代原 force_onboarding(按用户)——改设备维度后这是"运营触发重走"的查询入口。
|
||||
device_id 为空一律按未完成(同登录:不误跳过)。"""
|
||||
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=device_id)
|
||||
return OnboardingStatusResponse(completed=completed)
|
||||
|
||||
|
||||
@router.delete("", response_model=OkResponse, summary="注销账号(软删除)")
|
||||
def delete_account(user: CurrentUser, db: DbSession) -> OkResponse:
|
||||
media.delete_avatar(user.avatar_url)
|
||||
|
||||
@@ -105,6 +105,10 @@ class Settings(BaseSettings):
|
||||
WITHDRAW_AUTO_RECONCILE_ENABLED: bool = False
|
||||
WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC: int = 300
|
||||
WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES: int = 15
|
||||
# 0 点自动兑金币(由 deploy/daily-exchange.timer 触发 scripts.daily_auto_exchange)。
|
||||
# 客户端已删手动兑入口、改为「0 点自动兑现金」,故默认开;运营要临时停可在 .env 置 false,
|
||||
# 无需动 systemd timer——脚本读此开关,false 时直接 no-op 退出。
|
||||
AUTO_EXCHANGE_ENABLED: bool = True
|
||||
# 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。
|
||||
# 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容
|
||||
# (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
|
||||
|
||||
@@ -94,7 +94,7 @@ class ComparisonRecord(Base):
|
||||
# ===== 明细(JSON,越详细越好)=====
|
||||
# 下单菜品 [{name, qty, specs?}]
|
||||
items: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
|
||||
# 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name, applied_coupons}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名;applied_coupons=[{name,amount}] 多券明细)
|
||||
# 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名)
|
||||
comparison_results: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
|
||||
# 目标平台未找到、跳过的菜名
|
||||
skipped_dish_names: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
|
||||
|
||||
@@ -58,13 +58,6 @@ class User(Base):
|
||||
Boolean, nullable=False, default=False, server_default=false()
|
||||
)
|
||||
|
||||
# 运营后台「一键开启新手引导」:置 true 后,该用户下次启动 App(/me 与登录响应带出此字段)会被
|
||||
# 强制重走新手引导教程——即便本地早已标记完成。走完引导(/onboarding/complete)即自动清回 false,
|
||||
# 不会无限循环。默认 false。
|
||||
force_onboarding: 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
|
||||
)
|
||||
|
||||
@@ -62,16 +62,6 @@ def set_avatar_url(db: Session, user: User, *, avatar_url: str) -> User:
|
||||
return user
|
||||
|
||||
|
||||
def clear_force_onboarding(db: Session, user: User) -> None:
|
||||
"""用户走完(被运营强制开启的)新手引导后,清除强制标记,避免下次启动再被拉回引导。
|
||||
|
||||
幂等:未置位时直接返回,不空 commit。由 /onboarding/complete 在标记完成后调用。
|
||||
"""
|
||||
if user.force_onboarding:
|
||||
user.force_onboarding = False
|
||||
db.commit()
|
||||
|
||||
|
||||
def soft_delete_account(db: Session, user: User) -> None:
|
||||
"""注销账号:软删除 + 匿名化。
|
||||
|
||||
|
||||
@@ -183,14 +183,22 @@ def list_coin_transactions(
|
||||
|
||||
|
||||
def exchange_coins_to_cash(
|
||||
db: Session, user_id: int, coin_amount: int
|
||||
db: Session,
|
||||
user_id: int,
|
||||
coin_amount: int,
|
||||
*,
|
||||
remark: str | None = None,
|
||||
enforce_min: bool = True,
|
||||
) -> tuple[CoinAccount, int]:
|
||||
"""金币兑现金。返回 (account, 本次兑入的分)。
|
||||
|
||||
校验:金额 >= MIN_EXCHANGE_COIN 且为整分倍数(InvalidExchangeAmountError),
|
||||
余额充足(InsufficientCoinError)。扣金币 + 加现金,两条流水同事务 commit。
|
||||
校验:金额为整分倍数(InvalidExchangeAmountError)、余额充足(InsufficientCoinError);
|
||||
`enforce_min=True`(手动兑默认)时还要 >= 后台配置的兑换下限,`enforce_min=False`
|
||||
(0 点自动兑「到分全额」用)只要够 1 分(COIN_PER_CENT)即可,不受手动下限约束。
|
||||
`remark` 不传则用默认「N金币兑M分」。扣金币 + 加现金,两条流水同事务 commit。
|
||||
"""
|
||||
if coin_amount < rewards.get_min_exchange_coin(db) or coin_amount % COIN_PER_CENT != 0:
|
||||
floor_min = rewards.get_min_exchange_coin(db) if enforce_min else COIN_PER_CENT
|
||||
if coin_amount < floor_min or coin_amount % COIN_PER_CENT != 0:
|
||||
raise InvalidExchangeAmountError
|
||||
|
||||
acc = get_or_create_account(db, user_id, commit=False)
|
||||
@@ -198,10 +206,10 @@ def exchange_coins_to_cash(
|
||||
raise InsufficientCoinError
|
||||
|
||||
cents = coins_to_cents(coin_amount)
|
||||
remark = f"{coin_amount}金币兑{cents}分"
|
||||
tx_remark = remark or f"{coin_amount}金币兑{cents}分"
|
||||
|
||||
# 1. 扣金币(写 coin_transaction)
|
||||
grant_coins(db, user_id, -coin_amount, biz_type="exchange_out", remark=remark)
|
||||
grant_coins(db, user_id, -coin_amount, biz_type="exchange_out", remark=tx_remark)
|
||||
# 2. 加现金(写 cash_transaction)
|
||||
acc.cash_balance_cents += cents
|
||||
db.add(
|
||||
@@ -210,7 +218,7 @@ def exchange_coins_to_cash(
|
||||
amount_cents=cents,
|
||||
balance_after_cents=acc.cash_balance_cents,
|
||||
biz_type="exchange_in",
|
||||
remark=remark,
|
||||
remark=tx_remark,
|
||||
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
|
||||
)
|
||||
)
|
||||
@@ -219,6 +227,70 @@ def exchange_coins_to_cash(
|
||||
return acc, cents
|
||||
|
||||
|
||||
def _has_exchange_in_on(db: Session, user_id: int, day) -> bool:
|
||||
"""该用户在指定日期(北京日)是否已有 exchange_in 现金流水。
|
||||
|
||||
自动兑复用 exchange_in 类型,故用「当天是否已有 exchange_in」做幂等键:timer 重触发 /
|
||||
手动重跑当天不会重复兑。手动兑入口已下线,不会与之误判混淆(若日后恢复手动兑需改用独立类型)。
|
||||
created_at 落库为北京 naive 时间(见 exchange_coins_to_cash),按 [当天 0 点, 次日 0 点) 比较。
|
||||
"""
|
||||
day_start = datetime.combine(day, datetime.min.time())
|
||||
next_day = day_start + timedelta(days=1)
|
||||
return (
|
||||
db.execute(
|
||||
select(CashTransaction.id)
|
||||
.where(
|
||||
CashTransaction.user_id == user_id,
|
||||
CashTransaction.biz_type == "exchange_in",
|
||||
CashTransaction.created_at >= day_start,
|
||||
CashTransaction.created_at < next_day,
|
||||
)
|
||||
.limit(1)
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def daily_auto_exchange(db: Session) -> dict:
|
||||
"""0 点定时任务:把每个用户金币「到分全额」兑成现金(复用 exchange_in 流水类型)。
|
||||
|
||||
- **到分全额**:coin_amount = floor(余额 / 100) * 100;余额不足 1 分(<100)的零头留到下次。
|
||||
- **幂等**:当天已有 exchange_in 的用户跳过(见 [_has_exchange_in_on])。
|
||||
- **逐用户独立事务**:单个用户异常 rollback 不影响其他人;exchange_coins_to_cash 内部按用户 commit。
|
||||
返回统计 dict(scanned/converted/skipped_done/skipped_dust/failed/total_cents)。
|
||||
"""
|
||||
today = rewards.cn_today()
|
||||
stats = {
|
||||
"scanned": 0, "converted": 0, "skipped_done": 0,
|
||||
"skipped_dust": 0, "failed": 0, "total_cents": 0,
|
||||
}
|
||||
user_ids = db.execute(
|
||||
select(CoinAccount.user_id).where(CoinAccount.coin_balance >= COIN_PER_CENT)
|
||||
).scalars().all()
|
||||
|
||||
for user_id in user_ids:
|
||||
stats["scanned"] += 1
|
||||
try:
|
||||
if _has_exchange_in_on(db, user_id, today):
|
||||
stats["skipped_done"] += 1
|
||||
continue
|
||||
acc = get_or_create_account(db, user_id, commit=False)
|
||||
coin_amount = (acc.coin_balance // COIN_PER_CENT) * COIN_PER_CENT
|
||||
if coin_amount < COIN_PER_CENT:
|
||||
stats["skipped_dust"] += 1
|
||||
continue
|
||||
_, cents = exchange_coins_to_cash(
|
||||
db, user_id, coin_amount, remark="0 点自动兑现金", enforce_min=False
|
||||
)
|
||||
stats["converted"] += 1
|
||||
stats["total_cents"] += cents
|
||||
except Exception: # noqa: BLE001 - 批处理不因单用户异常中断
|
||||
db.rollback()
|
||||
stats["failed"] += 1
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def list_cash_transactions(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
|
||||
@@ -27,9 +27,6 @@ class UserOut(BaseModel):
|
||||
last_login_at: datetime
|
||||
# 调试链接权限:前端据此在比价结果弹窗/记录页显示「复制调试链接」按钮。默认 false。
|
||||
debug_trace_enabled: bool = False
|
||||
# 运营后台「一键开启新手引导」:true → 客户端下次启动强制重走引导(即便本地已完成);
|
||||
# 走完引导后端自动清回 false。默认 false 兼容老客户端。
|
||||
force_onboarding: bool = False
|
||||
|
||||
|
||||
# ===== Token 通用结构 =====
|
||||
|
||||
@@ -24,13 +24,6 @@ class ComparisonItemIn(BaseModel):
|
||||
specs: list[str] | None = None
|
||||
|
||||
|
||||
class AppliedCouponIn(BaseModel):
|
||||
"""单笔已用优惠(来自 comparison_results[].applied_coupons)。amount 单位:元、正数。"""
|
||||
|
||||
name: str
|
||||
amount: float
|
||||
|
||||
|
||||
class ComparisonResultIn(BaseModel):
|
||||
"""逐平台对比项(来自 done.params.comparison_results)。price 单位:元。"""
|
||||
|
||||
@@ -47,10 +40,6 @@ class ComparisonResultIn(BaseModel):
|
||||
# 优惠**来源名**(展示用, best-effort): 美团"外卖大额神券"/京东"百亿补贴"/淘宝"平台红包"。
|
||||
# None=没抠到 → 前端走通用"红包"。同样必须显式声明否则上报边界被 pydantic 静默丢弃(pricebot#38 引入)。
|
||||
coupon_name: str | None = None
|
||||
# 多券明细 [{name, amount}](全口径: 平台红包+商家券+满减+配送减免, amount 单位元正数)。
|
||||
# 跟 coupon_saved 并存, 是更丰富的明细; 空=没抠到 → 前端回退单券路径。
|
||||
# 必须显式声明: 落库走 model_dump(), pydantic 默认丢未知字段, 不声明这行会被悄悄吞掉。
|
||||
applied_coupons: list[AppliedCouponIn] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ComparisonRecordIn(BaseModel):
|
||||
|
||||
+5
-1
@@ -5,7 +5,7 @@ from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class ProfileUpdateRequest(BaseModel):
|
||||
nickname: str = Field(..., min_length=1, max_length=16, description="昵称,1-16 字")
|
||||
nickname: str = Field(..., min_length=1, max_length=20, description="昵称,1-20 字")
|
||||
|
||||
@field_validator("nickname")
|
||||
@classmethod
|
||||
@@ -23,5 +23,9 @@ class OnboardingCompleteRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class OnboardingStatusResponse(BaseModel):
|
||||
completed: bool = Field(..., description="该 设备+账号 是否已走过新手引导(false → 客户端应重走)")
|
||||
|
||||
|
||||
class OkResponse(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# 0 点自动兑金币 定时任务 — 运维手册
|
||||
|
||||
> 对象:维护「每天 0 点把用户金币自动兑成现金」这套定时任务的同事。
|
||||
> 🔒 服务器登录信息见**私密交接清单**,不入库。
|
||||
|
||||
## 它是什么
|
||||
客户端已删手动「兑金币」入口、改为「0 点自动兑现金(可能存在延迟)」,故服务端每天 0 点跑一轮:
|
||||
把每个用户的金币按汇率 **100 金币 = 1 分** 兑成现金。
|
||||
|
||||
- **到分全额**:`coin_amount = floor(余额 / 100) × 100`,不足 1 分(<100 金币)的零头留到下次。
|
||||
- **复用 `exchange_in` 流水类型**(手动兑入口已下线,不会混淆),用户在「现金明细」看到「金币兑换现金」。
|
||||
- **幂等**:同一用户当天已有 `exchange_in` 流水则跳过 → timer 重触发 / 手动重跑当天不会重复兑。
|
||||
- 逻辑见 `app/repositories/wallet.py::daily_auto_exchange`,入口 `scripts/daily_auto_exchange.py`。
|
||||
|
||||
## 文件
|
||||
| 项 | 路径 |
|
||||
|---|---|
|
||||
| 脚本入口 | `scripts/daily_auto_exchange.py` |
|
||||
| 核心逻辑 | `app/repositories/wallet.py::daily_auto_exchange` |
|
||||
| 开关 | `.env` 的 `AUTO_EXCHANGE_ENABLED`(默认 true;false=脚本 no-op) |
|
||||
| systemd 单元 | `deploy/daily-exchange.{service,timer}` |
|
||||
| 运行锁 | `/tmp/daily_auto_exchange.lock`(可用环境变量 `DAILY_EXCHANGE_LOCK` 覆盖) |
|
||||
|
||||
## 部署(Linux 服务器)
|
||||
```bash
|
||||
sudo cp deploy/daily-exchange.{service,timer} /etc/systemd/system/
|
||||
sudo systemctl daemon-reload && sudo systemctl enable --now daily-exchange.timer
|
||||
systemctl list-timers daily-exchange.timer # 确认下次触发时间
|
||||
```
|
||||
|
||||
## 本机 Windows 开发(无 systemd)
|
||||
直接手动跑:
|
||||
```
|
||||
.venv\Scripts\python -m scripts.daily_auto_exchange --once
|
||||
# 本地循环调试(每小时一轮,幂等空跑无害):
|
||||
.venv\Scripts\python -m scripts.daily_auto_exchange --loop --interval 3600
|
||||
```
|
||||
|
||||
## 怎么看健康 / 手动跑一次
|
||||
```bash
|
||||
sudo systemctl start daily-exchange.service # 立即手动跑一轮(不等 0 点)
|
||||
journalctl -u daily-exchange -n 30 --no-pager # 看日志:扫描/兑换/跳过/失败/累计兑入分
|
||||
```
|
||||
日志一行形如:`自动兑完成 用时1.2s 扫描120 兑换88 已兑过跳过30 零头跳过2 失败0 累计兑入15600分`。
|
||||
|
||||
## 怎么关 / 临时停
|
||||
- **临时停(推荐)**:`.env` 置 `AUTO_EXCHANGE_ENABLED=false` → 脚本 no-op,无需动 timer。
|
||||
- **停 timer**:`sudo systemctl disable --now daily-exchange.timer`。
|
||||
|
||||
## 脚本参数
|
||||
- `--once`:跑一轮(timer 用这个,默认)
|
||||
- `--loop --interval N`:常驻循环,每 N 秒一轮(默认 3600,调试用)
|
||||
- 锁位置可用环境变量 `DAILY_EXCHANGE_LOCK=/path/to.lock` 覆盖
|
||||
|
||||
## 注意事项
|
||||
- **触发时间**:`OnCalendar=*-*-* 00:00:00`。客户端文案已注明「可能存在延迟」,要错开整点扎堆可改 `00:05:00`。
|
||||
- **catch-up**:`Persistent=true`,服务器宕机错过当天会在重启后补跑一轮。
|
||||
- **DB 无关**:sqlite / postgres 均可(不像美团 ETL 需要 PG)。
|
||||
- **改脚本 / 改部署**:走 git + PR,由有 root 的人部署。
|
||||
@@ -0,0 +1,33 @@
|
||||
# 0 点自动兑金币 —— 单轮把每个用户金币「到分全额」兑成现金,由 daily-exchange.timer 每天 0 点触发。
|
||||
#
|
||||
# 仅用于 Linux 服务器;本机 Windows 开发无 systemd,直接手动跑脚本即可:
|
||||
# .venv\Scripts\python -m scripts.daily_auto_exchange --once
|
||||
#
|
||||
# 部署(服务器):
|
||||
# sudo cp deploy/daily-exchange.{service,timer} /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload && sudo systemctl enable --now daily-exchange.timer
|
||||
# # 手动跑一次验证: sudo systemctl start daily-exchange.service && journalctl -u daily-exchange -n 30
|
||||
#
|
||||
# 前置:.env 的 AUTO_EXCHANGE_ENABLED=true(默认);置 false 则脚本 no-op,免动 timer。
|
||||
[Unit]
|
||||
Description=Daily auto-exchange coins to cash (one-shot, driven by timer)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
WorkingDirectory=/opt/shaguabijia-app-server
|
||||
Environment="PATH=/opt/shaguabijia-app-server/.venv/bin:/usr/bin:/bin"
|
||||
EnvironmentFile=/opt/shaguabijia-app-server/.env
|
||||
ExecStart=/opt/shaguabijia-app-server/.venv/bin/python -m scripts.daily_auto_exchange --once
|
||||
SyslogIdentifier=daily-exchange
|
||||
# 脚本自带 30min 文件锁;给 20min 硬超时,防卡死轮次长期占锁。
|
||||
TimeoutStartSec=1200
|
||||
|
||||
# 与主服务 shaguabijia-app-server.service 同款加固。
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/opt/shaguabijia-app-server
|
||||
ProtectHome=true
|
||||
@@ -0,0 +1,14 @@
|
||||
# 每天 0 点触发一次「自动兑金币」(Linux 服务器用)。
|
||||
# 见 daily-exchange.service 顶部注释的部署步骤。
|
||||
[Unit]
|
||||
Description=Run daily auto-exchange coins->cash at midnight
|
||||
|
||||
[Timer]
|
||||
# 每天 0 点跑。客户端文案已注明「可能存在延迟」,可按需改 00:05 错开整点扎堆。
|
||||
OnCalendar=*-*-* 00:00:00
|
||||
# 服务器宕机/重启后,补跑错过的那一轮(而不是干等次日)。
|
||||
Persistent=true
|
||||
AccuracySec=1min
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。
|
||||
> 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。
|
||||
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **28 张业务表** + `alembic_version`(框架的迁移版本指针)。领券联动的「今日状态」三张表(`coupon_*`)同理:领券过程在 pricebot 内存态跑、**不落库**,只有结果回到 app-server 才落这三张表。
|
||||
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **23 张业务表** + `alembic_version`(框架的迁移版本指针)。
|
||||
|
||||
---
|
||||
|
||||
@@ -17,14 +17,6 @@
|
||||
| profile「累计省了 / 省钱战绩 / 省钱明细」 | [`savings_record`](./savings_record.md) | 真实下单归因(source=compare)+ 无真实数据时 demo 兜底 |
|
||||
| 「上报更低价」提交 / 列表 | [`price_report`](./price_report.md) | 众包纠偏:用户举证某平台更便宜,人工审核发奖 |
|
||||
|
||||
### 领券(每日领券联动 · 今日状态)
|
||||
| App 位置 / 动作 | 表 | 说明 |
|
||||
|---|---|---|
|
||||
| 领券**过程**(看屏→领券) | (无) | 在 pricebot-backend 内存态跑,**过程不落库**;结果回 app-server 才落下面三张表 |
|
||||
| 切外卖 App 时是否弹领券引导窗 | [`coupon_prompt_engagement`](./coupon_state.md) | 今天 engage 过(点领/点拒)就不再弹;判断维度 device_id |
|
||||
| 首页「去领取」卡是否置灰 | [`coupon_daily_completion`](./coupon_state.md) | 今天跑完整轮(到 done)就置灰;判断维度 device_id |
|
||||
| 每张券领取结果留痕 | [`coupon_claim_record`](./coupon_state.md) | 资产/画像/排查/CPS;当前**不参与**判断 |
|
||||
|
||||
### 钱包 / 福利(看广告赚钱闭环)
|
||||
| App 位置 / 动作 | 表 | 说明 |
|
||||
|---|---|---|
|
||||
@@ -84,13 +76,6 @@
|
||||
| 首次进 profile 省钱页且无真实记录 | `savings_record`(C `source=demo`) | 懒种子,`ensure_seeded` 按 user 幂等 |
|
||||
| 上报更低价 `POST /report` | `price_report`(C) | 读 `comparison_record.best_price_cents` 校验 |
|
||||
| 提交反馈 `POST /feedback` | `feedback`(C) | |
|
||||
| 领券首帧 `POST /api/v1/coupon/step`(step=0) | `coupon_prompt_engagement`(C/U `claim_started`) | `(device_id, 北京日)` 幂等;best-effort |
|
||||
| 领券每帧结果 `POST /api/v1/coupon/step` | `coupon_claim_record`(C/U) | `(device_id, coupon_id, 北京日)` 幂等;best-effort |
|
||||
| 领券跑完 `POST /api/v1/coupon/step`(action.command=done) | `coupon_daily_completion`(C/U) | `(device_id, 北京日)` 幂等;best-effort |
|
||||
| 拒绝领券引导窗 `POST /api/v1/coupon/prompt/dismiss` | `coupon_prompt_engagement`(C/U `dismissed`) | 同上;客户端通知(透传链路看不到拒绝) |
|
||||
| 重置今日弹窗 `POST /api/v1/coupon/prompt/reset`(开发) | `coupon_prompt_engagement`(**D** 今日条) | 删后今天又能弹 |
|
||||
|
||||
> 领券三表写库**全 best-effort**:`/coupon/step` 里写失败只 `logger.warning`、不连累领券返回;**判断只看 `device_id`**,`user_id` 可空旁路(资产留痕)。
|
||||
|
||||
### admin 端(管理员触发,均额外写一条 `admin_audit_log`)
|
||||
| 后台操作 | 写入 | 操作 |
|
||||
@@ -137,7 +122,6 @@
|
||||
- **`comparison_record.store_name` ≈ `savings_record.shop_name`**:无 id 关联,按**店名字符串相等**给比价记录打「已下单」标记(瞬态,不写库)。两边店名同源 = 比价意图识别阶段的门店 query,语义=**店级**(同店比价多次会一并标已下单)。
|
||||
- **广告流会话关联**:`ad_reward_record.ad_session_id` 可与 `ad_ecpm_record.ad_session_id` 对齐;`ad_watch_log` 仍是旧版兼容统计,不逐条参与发奖。
|
||||
- **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record` 里 `status='success'` 的 `count`。
|
||||
- **领券三表无硬 FK,全靠软关联**:`coupon_prompt_engagement` / `coupon_daily_completion` / `coupon_claim_record` 的 `user_id` **软指** `user.id`(可空、有登录态才记、不进唯一键、不阻塞判断);`trace_id` **软指** pricebot work_logs(排查回指);唯一键都以 `device_id` + 北京自然日为主(详见 [`coupon_state.md`](./coupon_state.md))。
|
||||
- **`onboarding_completion.(user_id, device_id)`**:`user_id` 语义关联 `user.id`(无硬 FK,同 `coupon_*` 设备表),`device_id` = 客户端硬件级 `ANDROID_ID`(≠ 领券 per-install `device_id`)。登录读、走完引导写,决定是否再展示新手引导。
|
||||
|
||||
### ER 关系(文字版)
|
||||
@@ -152,8 +136,6 @@ user ─1:N─ onboarding_completion (user_id, 无硬 FK; (user_id,d
|
||||
comparison_record ─1:N─ price_report (comparison_record_id, 可空)
|
||||
admin_user ─1:N─ admin_audit_log
|
||||
app_config (独立, 无外键, key 为主键)
|
||||
coupon_prompt_engagement / coupon_daily_completion / coupon_claim_record
|
||||
(独立, 无硬 FK; 维度=device_id+北京日, user_id/trace_id 仅软关联)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
> 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。
|
||||
> ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。
|
||||
> 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`。
|
||||
> 最后更新:2026-06-11(合并:新增 3 张领券今日状态表 `coupon_*` + `onboarding_completion` 新手引导完成表;含 [OVERVIEW 总览](./OVERVIEW.md))
|
||||
|
||||
> 最后更新:2026-06-10(新增 `onboarding_completion` 新手引导完成表 → 23 张业务表)
|
||||
=======
|
||||
|
||||
|
||||
> 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。
|
||||
|
||||
---
|
||||
|
||||
## 表总览(28 张业务表 + `alembic_version` 框架表)
|
||||
## 表总览(23 张业务表 + `alembic_version` 框架表)
|
||||
|
||||
### 账号 / 反馈
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
@@ -42,13 +45,6 @@
|
||||
| `savings_record` | 省钱记录(profile 省钱战绩源;真实下单归因 + demo) | `models/savings.py` | [详情](./savings_record.md) |
|
||||
| `price_report` | 上报更低价(众包纠偏,人工审核发奖) | `models/price_report.py` | [详情](./price_report.md) |
|
||||
|
||||
### 领券(每日领券联动 · 今日状态)
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|---|---|---|---|
|
||||
| `coupon_prompt_engagement` | 领券引导窗频控源(今日是否已 engage,按 device+日) | `models/coupon_state.py` | [详情](./coupon_state.md) |
|
||||
| `coupon_daily_completion` | 首页「去领取」置灰源(今日是否已跑完整轮) | `models/coupon_state.py` | [详情](./coupon_state.md) |
|
||||
| `coupon_claim_record` | 每张券领取结果沉淀(资产/画像/排查,不参与判断) | `models/coupon_state.py` | [详情](./coupon_state.md) |
|
||||
|
||||
### 美团 CPS 券缓存
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|---|---|---|---|
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
# coupon_state — 领券今日状态三张表(弹窗频控 / 首页置灰 / 领券记录)
|
||||
|
||||
> 模型 `app/models/coupon_state.py` · 仓库 `app/repositories/coupon_state.py` · 接口 `app/api/v1/coupon.py`(prefix `/api/v1/coupon`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
领券(优惠券自动化)联动产生的三张「今日状态」表,都挂在领券透传端点 `POST /api/v1/coupon/step` 这条链路上(pricebot 跑领券,结果回 app-server 落库;**领券过程本身在 pricebot 内存态跑、不落库**)。三表各管一件事:
|
||||
|
||||
- **`coupon_prompt_engagement`** — 弹窗频控源。按 `(device, 自然日)` 记「今天是否对领券引导窗表达过**意向**」(点「一键领取」=`claim_started` / 点拒绝关闭=`dismissed` 都算)。切到外卖 App 时据此决定弹不弹:今天 engage 过就不再弹。
|
||||
- **`coupon_daily_completion`** — 首页置灰源。按 `(device, 自然日)` 记「今天是否已**跑完整轮**领券(到 done 帧)」。首页「去领取」卡据此置灰:今天跑完了就不能再领。
|
||||
- **`coupon_claim_record`** — 资产沉淀层。按 `(device, 券, 自然日)` 记每张券的领取结果(success/already_claimed/failed/skipped),**纯沉淀**(资产/画像/排查/CPS 归因),当前**不参与**「要不要领 / 弹不弹」的判断。
|
||||
|
||||
三表共同口径:
|
||||
- **判断维度是 `device_id`,不是 `user_id`**:券发到的是设备上登录的那个外卖账号,device 比 user 更贴近「哪个登录环境」,且 `device_id` 全链路现成、不依赖领券鉴权(领券 MVP 阶段 `/coupon/step` 不鉴权)。客户端 `getOrCreateDeviceId` 生成存 SP,**卸载重装会变 → 当新设备重新弹一次**(产品预期)。
|
||||
- **日期 = `Asia/Shanghai` 自然日**(`claim_date` / `engage_date` / `complete_date`,`repositories/coupon_state.today_cn()`)。每日可领的券(签到/天天红包)靠这天然每天一条。
|
||||
- **`user_id` 可空**:领券登录态有就记(资产/画像),可空、**不进唯一键、不阻塞判断**。
|
||||
- **`trace_id` 可空**:回指 pricebot work_logs,供排查(哪次任务领的)。
|
||||
- **engagement vs completion vs claim 的区别**:engagement = 用户**表达过意向**(点了领或拒,不管跑没跑完);completion = 这一轮**真跑到了 done**(整套流程走完);claim = **每张券一条**的结果留痕。
|
||||
|
||||
> 写库全部 **best-effort**:在 `/coupon/step` 里写库失败只 `logger.warning`、**绝不连累领券主流程/返回**(见 `app/api/v1/coupon.py`)。
|
||||
|
||||
---
|
||||
|
||||
## coupon_prompt_engagement — 弹窗频控(今日是否已对引导窗表达意向)
|
||||
|
||||
`(device_id, engage_date)` 唯一,一台设备一天一条;今天 engage 过(领或拒)就不再弹。
|
||||
|
||||
### 用在哪 / 增删改查
|
||||
- **C / U(幂等 upsert)**:`mark_engagement`。两条触发:
|
||||
- `POST /api/v1/coupon/step` 且 `step==0`(领券首帧=用户已发起领券)→ 记 `claim_started`;
|
||||
- `POST /api/v1/coupon/prompt/dismiss`(用户点关闭引导窗;server 在透传链路看不到「拒绝」,必须客户端通知)→ 记 `dismissed`。
|
||||
- 已有今天那条则覆盖 `engage_type`(并补 `user_id`),否则插入。
|
||||
- **D**:`POST /api/v1/coupon/prompt/reset`(`reset_today_engagement`)—— 删这台设备今天那条,开发设置「重置今日领券弹窗状态」按钮调,测频控用;删后今天又能弹。
|
||||
- **R**:`GET /api/v1/coupon/prompt/should-show?device_id=…`(`has_engaged_today`)→ `should_show = not 今天已 engage`。客户端切外卖 App 前查,纯后台判据。
|
||||
|
||||
### 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `device_id` | String(64) | NOT NULL | 判断/聚合维度;客户端 `getOrCreateDeviceId`,重装会变 |
|
||||
| `user_id` | Integer | index, 可空 | 登录态有就记(资产);不进唯一键、不阻塞判断 |
|
||||
| `engage_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`) |
|
||||
| `engage_type` | String(16) | NOT NULL | `claim_started`(点一键领取)/ `dismissed`(点拒绝关闭);仅记录区分,**判断只看「今天有没有这条」,type 不影响弹不弹** |
|
||||
| `created_at` | DateTime(tz) | server_default now() | |
|
||||
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | |
|
||||
|
||||
### 索引与约束
|
||||
- PK `id`;index `user_id`;UNIQUE(`device_id`, `engage_date`) = `uq_coupon_engage_device_date`(一台设备一天一条)。
|
||||
|
||||
### 注意
|
||||
- `device_id` 重装会变 → 重装当新设备,今天重新弹一次(产品预期)。
|
||||
- 判断只看「今天这台设备有没有这条」,不看 `engage_type`(领或拒都算 engage 过、都不再弹)。
|
||||
|
||||
---
|
||||
|
||||
## coupon_daily_completion — 首页置灰(今日是否已跑完整轮领券)
|
||||
|
||||
`(device_id, complete_date)` 唯一,一台设备一天一条;今天跑完整轮(到 done 帧)就把首页「去领取」卡置灰。
|
||||
|
||||
### 用在哪 / 增删改查
|
||||
- **C / U(幂等 upsert)**:`mark_completed_today`,由 `POST /api/v1/coupon/step` 在 pricebot 返回 `action.command == "done"` 那帧调。pricebot 把中途单券 done 改写成 `wait+continue=true`,只有整套全跑完那帧才保留 `command=="done"`,故 **done 已等价「整轮完成」**。已有今天那条则补 `user_id`/`trace_id`,否则插入。
|
||||
- **U / D**:无业务删除。
|
||||
- **R**:`GET /api/v1/coupon/completed-today?device_id=…`(`has_completed_today`)→ `completed`。客户端据此把首页「去领取」卡置灰、不可点。
|
||||
|
||||
### 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `device_id` | String(64) | NOT NULL | 判断维度,与 engagement/claim 一致;客户端两端都用 ANDROID_ID |
|
||||
| `user_id` | Integer | index, 可空 | 登录态有就记(资产) |
|
||||
| `complete_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`) |
|
||||
| `trace_id` | String(64) | index, 可空 | 哪次任务跑到 done,回指 pricebot work_logs / 排查 |
|
||||
| `created_at` | DateTime(tz) | server_default now() | |
|
||||
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | |
|
||||
|
||||
### 索引与约束
|
||||
- PK `id`;index `user_id`、`trace_id`;UNIQUE(`device_id`, `complete_date`) = `uq_coupon_completion_device_date`(一台设备一天一条)。
|
||||
|
||||
### 注意
|
||||
- 口径(用户决策 2026-06-10 A 方案):**到 done 即算完成,不管单券成败**——失败/跳过常是无障碍/环境问题,重复点也补不回来。
|
||||
- 与 engagement 区别:engagement 是「表达过意向」(点了就记,不管跑没跑完);completion 是「真跑到了 done」。
|
||||
|
||||
---
|
||||
|
||||
## coupon_claim_record — 领券记录(每张券一天一条,资产沉淀层)
|
||||
|
||||
`(device_id, coupon_id, claim_date)` 唯一,同设备同券同一天只一条;纯沉淀,**当前不参与判断**,留作以后按券去重 / CPS 归因 / 用户画像的数据源。
|
||||
|
||||
### 用在哪 / 增删改查
|
||||
- **C / U(幂等 upsert)**:`record_claims`,由 `POST /api/v1/coupon/step` 写入。一帧的券结果来自 pricebot 的 `last_coupon_result`(最后一张)+ `action.params.coupon_results`(全量)——**会重复带同一张券**,端点 `_extract_coupon_results` 先**按 `coupon_id` 去重**(全量覆盖单张),仓库再靠唯一键幂等:已有则更新 `status`/`reason`/`claimed_count`/`extra`(以最后一次为准),否则插入。
|
||||
- **U / D**:无业务删除。
|
||||
- **R**:**当前无读取端点**(纯写入沉淀,未来做去重/归因/画像时再用)。
|
||||
|
||||
### 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `device_id` | String(64) | NOT NULL | 聚合维度;客户端 `getOrCreateDeviceId`,重装会变 |
|
||||
| `user_id` | Integer | index, 可空 | 登录态有就记(资产/画像);不进唯一键 |
|
||||
| `coupon_id` | String(64) | NOT NULL | 券标识(取自 pricebot 结果) |
|
||||
| `claim_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`);每日可领的券靠它天然每天一条 |
|
||||
| `status` | String(24) | NOT NULL | `success` / `already_claimed` / `failed` / `skipped`(原样取 pricebot coupon 结果) |
|
||||
| `vendor` | String(48) | 可空 | 券提供方 |
|
||||
| `coupon_name` | String(128) | 可空 | 取 pricebot `name` |
|
||||
| `claimed_count` | Integer | 可空 | 这张领到几张(pricebot `display_count`,给不出时 None;兼容 `claimed_count`) |
|
||||
| `trace_id` | String(64) | index, 可空 | 哪次任务领的,回指 pricebot work_logs / 排查 |
|
||||
| `reason` | String(255) | 可空 | failed / skipped 原因 |
|
||||
| `extra` | JSON(PG JSONB) | 可空 | 杂项兜底:券的结构化信息(面额/入口/关键节点摘要等),免得加字段就迁移;当前直接存这帧 pricebot 单券结果 dict |
|
||||
| `created_at` | DateTime(tz) | server_default now() | |
|
||||
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | |
|
||||
|
||||
### 索引与约束
|
||||
- PK `id`;index `user_id`、`trace_id`;UNIQUE(`device_id`, `coupon_id`, `claim_date`) = `uq_coupon_claim_device_coupon_date`;Index(`device_id`, `claim_date`) = `ix_coupon_claim_device_date`(按 device+日 取一天所有券)。
|
||||
|
||||
### 注意
|
||||
- **`extra` 别塞原始无障碍树**(几十 KB → 行膨胀);原始大树看 `trace_id` 指过去的 work_logs。
|
||||
- 同批去重很关键:`autoflush=False` 下同 `coupon_id` 两次 `add` 会撞唯一约束、`IntegrityError` 回滚整批(done/单券记录全丢),故端点 `_extract_coupon_results` + 仓库 `seen` 集合双重防御。
|
||||
- `extra` 是 `JSON().with_variant(JSONB(), "postgresql")`:PG 用 JSONB(可建 GIN 索引),SQLite 退化通用 JSON(同 `price_observation` / `comparison_record`)。
|
||||
|
||||
---
|
||||
|
||||
## 三表共性小结
|
||||
- 数据流向:客户端 → `POST /api/v1/coupon/step`(透传给 pricebot)→ 结果回写这三张表(best-effort,写库失败不影响领券)。
|
||||
- 唯一键都含 `device_id` + 某个北京自然日列;`user_id` 永远是可空旁路(资产留痕,不进唯一键、不阻塞判断)。
|
||||
- 无硬外键:`user_id` 软指 `user.id`、`trace_id` 软指 pricebot work_logs(详见 [OVERVIEW → 表间关系 & Join Key](./OVERVIEW.md))。
|
||||
@@ -0,0 +1,113 @@
|
||||
"""0 点自动兑金币(每日定时:把每个用户金币「到分全额」兑成现金)。
|
||||
|
||||
背景:客户端已删手动「兑金币」入口、改文案「0 点自动兑现金(可能存在延迟)」,故服务端需在
|
||||
每天 0 点把金币按汇率(100 金币 = 1 分)兑成现金。复用 exchange_in 流水类型(手动兑入口已下线,
|
||||
不会与之混淆),幂等键 = 用户当天是否已有 exchange_in 流水。核心逻辑见
|
||||
app/repositories/wallet.py::daily_auto_exchange。
|
||||
|
||||
用法:
|
||||
# 单轮(给 systemd timer / cron 用,线上每天 0 点一次)
|
||||
python -m scripts.daily_auto_exchange --once
|
||||
|
||||
# 本地循环(调试用,默认每小时一轮——真到分兑换幂等,空跑无害)
|
||||
python -m scripts.daily_auto_exchange --loop --interval 3600
|
||||
|
||||
开关:.env 的 AUTO_EXCHANGE_ENABLED=false 时脚本直接 no-op 退出(免动 systemd timer)。
|
||||
带文件锁,防上一轮没跑完下一轮又起。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
# Windows 控制台按 UTF-8 输出中文/¥
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal
|
||||
from app.repositories import wallet as wallet_repo
|
||||
|
||||
# 锁放系统临时目录(任何账号可写),不依赖代码目录 data/ 的写权限。需要指定位置时用环境变量覆盖。
|
||||
LOCK_FILE = os.environ.get("DAILY_EXCHANGE_LOCK") or os.path.join(
|
||||
tempfile.gettempdir(), "daily_auto_exchange.lock"
|
||||
)
|
||||
LOCK_STALE_SEC = 30 * 60 # 锁超过 30min 视为陈旧(进程异常退出残留),自动接管
|
||||
|
||||
|
||||
def _acquire_lock() -> bool:
|
||||
os.makedirs(os.path.dirname(LOCK_FILE) or ".", exist_ok=True)
|
||||
try:
|
||||
fd = os.open(LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
os.write(fd, f"{os.getpid()} {time.time()}".encode())
|
||||
os.close(fd)
|
||||
return True
|
||||
except FileExistsError:
|
||||
try:
|
||||
with open(LOCK_FILE) as f:
|
||||
parts = f.read().split()
|
||||
ts = float(parts[1]) if len(parts) > 1 else 0.0
|
||||
if time.time() - ts > LOCK_STALE_SEC:
|
||||
os.remove(LOCK_FILE)
|
||||
return _acquire_lock()
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _release_lock() -> None:
|
||||
try:
|
||||
os.remove(LOCK_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def run_once() -> None:
|
||||
if not settings.AUTO_EXCHANGE_ENABLED:
|
||||
print(f"[{datetime.now():%H:%M:%S}] AUTO_EXCHANGE_ENABLED=false,跳过(no-op)")
|
||||
return
|
||||
if not _acquire_lock():
|
||||
print(f"[{datetime.now():%H:%M:%S}] 上一轮还在跑(锁占用),跳过本轮")
|
||||
return
|
||||
t0 = time.time()
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
stats = wallet_repo.daily_auto_exchange(db)
|
||||
dt = time.time() - t0
|
||||
print(
|
||||
f"[{datetime.now():%H:%M:%S}] 自动兑完成 用时{dt:.1f}s "
|
||||
f"扫描{stats['scanned']} 兑换{stats['converted']} "
|
||||
f"已兑过跳过{stats['skipped_done']} 零头跳过{stats['skipped_dust']} "
|
||||
f"失败{stats['failed']} 累计兑入{stats['total_cents']}分"
|
||||
)
|
||||
finally:
|
||||
_release_lock()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="0 点自动兑金币")
|
||||
ap.add_argument("--once", action="store_true", help="只跑一轮(默认)")
|
||||
ap.add_argument("--loop", action="store_true", help="循环跑(本地调试)")
|
||||
ap.add_argument("--interval", type=int, default=3600, help="循环间隔秒(默认 3600=1h)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.loop:
|
||||
print(f"循环模式,每 {args.interval}s 一轮(Ctrl+C 退出)")
|
||||
try:
|
||||
while True:
|
||||
run_once()
|
||||
time.sleep(args.interval)
|
||||
except KeyboardInterrupt:
|
||||
print("已退出")
|
||||
else:
|
||||
run_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -160,48 +160,6 @@ def test_set_user_status_and_audit(admin_client: TestClient, operator_token: str
|
||||
).status_code == 200
|
||||
|
||||
|
||||
# ===== 一键开启新手引导 =====
|
||||
|
||||
def test_set_force_onboarding_and_audit(admin_client: TestClient, operator_token: str) -> None:
|
||||
uid = _seed_user("13900000009")
|
||||
r = admin_client.post(
|
||||
f"/admin/api/users/{uid}/force-onboarding", json={"enabled": True},
|
||||
headers=_auth(operator_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
db = SessionLocal()
|
||||
try:
|
||||
assert db.get(User, uid).force_onboarding is True
|
||||
logs = db.execute(
|
||||
select(AdminAuditLog).where(
|
||||
AdminAuditLog.action == "user.force_onboarding.set",
|
||||
AdminAuditLog.target_id == str(uid),
|
||||
)
|
||||
).scalars().all()
|
||||
assert logs[0].detail == {"before": False, "after": True}
|
||||
finally:
|
||||
db.close()
|
||||
# 取消(回到 false)
|
||||
assert admin_client.post(
|
||||
f"/admin/api/users/{uid}/force-onboarding", json={"enabled": False},
|
||||
headers=_auth(operator_token),
|
||||
).status_code == 200
|
||||
db = SessionLocal()
|
||||
try:
|
||||
assert db.get(User, uid).force_onboarding is False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_finance_cannot_force_onboarding(admin_client: TestClient, finance_token: str) -> None:
|
||||
uid = _seed_user("13900000010")
|
||||
r = admin_client.post(
|
||||
f"/admin/api/users/{uid}/force-onboarding", json={"enabled": True},
|
||||
headers=_auth(finance_token),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
# ===== 角色守卫 =====
|
||||
|
||||
def test_operator_cannot_grant_coins(admin_client: TestClient, operator_token: str) -> None:
|
||||
|
||||
@@ -65,33 +65,3 @@ def test_onboarding_missing_device_id_treated_as_incomplete(client) -> None:
|
||||
phone = "13800138003"
|
||||
data = _login(client, phone, device_id=None)
|
||||
assert data["onboarding_completed"] is False
|
||||
|
||||
|
||||
def test_complete_clears_force_onboarding(client) -> None:
|
||||
"""运营「一键开启新手引导」(force_onboarding=True)后,用户走完引导即自动清除该标记,
|
||||
/me 随之回到 false——否则下次启动会被无限拉回引导。"""
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.user import User
|
||||
|
||||
phone = "13800138004"
|
||||
dev = "android-id-force"
|
||||
data = _login(client, phone, dev)
|
||||
access = data["access_token"]
|
||||
uid = data["user"]["id"]
|
||||
|
||||
# 模拟运营在后台一键开启(admin 接口本身另在 test_admin_write 覆盖)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.get(User, uid).force_onboarding = True
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def _me() -> dict:
|
||||
return client.get(
|
||||
"/api/v1/auth/me", headers={"Authorization": f"Bearer {access}"}
|
||||
).json()
|
||||
|
||||
assert _me()["force_onboarding"] is True
|
||||
assert _mark_complete(client, access, dev).status_code == 200
|
||||
assert _me()["force_onboarding"] is False
|
||||
|
||||
Reference in New Issue
Block a user