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 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
|
||||
|
||||
@@ -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 通用结构 =====
|
||||
|
||||
+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
|
||||
@@ -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