Compare commits

...

3 Commits

Author SHA1 Message Date
zzhyyyyy fe32704898 feat(user): 昵称上限放宽到 20 字(与客户端/原型一致)
ProfileUpdateRequest.nickname 的 max_length 16 → 20,与客户端 take(20) 和
原型 settings.html(maxlength=20)对齐。修复 17–20 字昵称保存时后端返回 422、
客户端弹「更新失败,请重试」的问题。DB 列为 String(64),放得下,无需迁移。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:08:28 +08:00
marco e8bd12cc1f feat(onboarding): force_onboarding 改设备维度 + admin 设备管理 + status 查询
废弃 force_onboarding(运营按用户强制引导,客户端不认——被 onboarding_completed 压过),
改用 onboarding_completion(设备+账号 维度),admin 直接增删该表记录控制重走。

- 删 force_onboarding: User 列 + auth/admin schema + admin 接口 + mutations + repo
  clear + /onboarding/complete 调用 + 3 测试;新增 alembic 迁移删列
- admin 设备管理: 列设备(按 device 聚合) / 重置单设备 / 全部重置(清 onboarding_completion)
- 新增 GET /api/v1/user/onboarding/status: 客户端已登录启动查"本设备是否要重走引导"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 00:51:47 +08:00
ouzhou 57ddcd356b feat(wallet): 0 点定时把用户金币「到分全额」自动兑成现金 (#45)
客户端已删手动兑入口、改「0 点自动兑现金」,服务端补这条批处理:
- wallet.exchange_coins_to_cash 加 remark / enforce_min 参数:enforce_min=False(自动兑)只要够
  1 分(COIN_PER_CENT)即可,不受手动兑下限约束;remark 可定制。
- 新增 wallet.daily_auto_exchange(db):扫 coin_balance>=100 的用户,到分全额(floor(余额/100)*100)
  兑现金,零头留下次;幂等键=当天是否已有 exchange_in 流水(_has_exchange_in_on);逐用户独立事务,
  单用户异常 rollback 不中断。
- scripts/daily_auto_exchange.py(--once / --loop):带 .env AUTO_EXCHANGE_ENABLED 开关(false→no-op)
  + 文件锁防重入;deploy/ 配 systemd service+timer(0 点触发)+ 说明。
- config.AUTO_EXCHANGE_ENABLED(默认 true)+ .env.example。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #45
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-11 23:36:26 +08:00
22 changed files with 532 additions and 155 deletions
+2
View File
@@ -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(),
),
)
+2
View File
@@ -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)
+26 -15
View File
@@ -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
+21
View File
@@ -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,
*,
+86
View File
@@ -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()
-28
View File
@@ -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,
+24
View File
@@ -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 才清空全部设备的引导记录(防误调接口;前端另有二次确认弹窗)"
)
-8
View File
@@ -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
View File
@@ -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)
+4
View File
@@ -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 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
-7
View File
@@ -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
)
-10
View File
@@ -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:
"""注销账号:软删除 + 匿名化。
+79 -7
View File
@@ -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,
-3
View File
@@ -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
View File
@@ -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
+59
View File
@@ -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 的人部署。
+33
View File
@@ -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
+14
View File
@@ -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
+113
View File
@@ -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()
-42
View File
@@ -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:
-30
View File
@@ -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