Compare commits

..

1 Commits

Author SHA1 Message Date
zzhyyyyy d1ffed555c fix(withdraw): 提现账本校验按 source 分账,邀请奖励金提现纳入对账
线上后台「提现审核」页现金账本报红(缺扣款/缺退款流水),根因是
withdraw_ledger_check 用全部提现单去比对普通现金流水(cash_transaction),
而 source=invite_cash 的提现,其扣款/退款流水写在独立的 invite_cash_transaction
表,于是每笔邀请提现单都在普通现金流水里找不到、被误报为「缺流水」。

改动:
- 抽出 _check_withdraw_ledger_side 复用单账核对逻辑;withdraw_ledger_check
  改为按 order.source 分两本账各自对账:
  coin_cash ↔ cash_transaction(withdraw/withdraw_refund),
  invite_cash ↔ invite_cash_transaction(invite_withdraw/invite_withdraw_refund)。
- 邀请奖励金账户的余额差额也纳入校验(此前完全未对账)。
- 纯只读校验,不改任何资金/流水写入;不掩盖真实缺流水
  (coin_cash 单若真缺流水仍照报)。
- WithdrawLedgerCheckOut 新增 7 个 invite_* 字段(默认 0,向后兼容)。
- 补 tests/test_withdraw_ledger_check.py(此前无 ledger-check 测试):
  覆盖邀请提现不再误报、邀请账缺流水能被抓、普通现金账未回归。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:31:17 +08:00
3 changed files with 6 additions and 35 deletions
+2 -8
View File
@@ -57,15 +57,9 @@ def list_seeds(db: AdminDb) -> list[OpsMarqueeSeedOut]:
def preview_feed(
db: AdminDb,
limit: Annotated[int, Query(ge=1, le=30)] = 8,
mode: Annotated[str | None, Query(description="mixed/real/seed;不传=当前持久化模式")] = None,
) -> OpsSavingsFeedPreviewOut:
"""返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。
mode 显式指定则预览该模式(**不改持久化配置**,供前端切换开关时实时预览);不传则用当前持久化模式。
"""
if mode is not None and mode not in ops_marquee.FEED_MODES:
raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed")
return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit, mode=mode))
"""返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。"""
return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit))
# 注:/mode 两个端点须在 /{seed_id} 之前注册,否则 PATCH /mode 会被 /{seed_id} 抢先按 id 解析。
+4 -11
View File
@@ -30,7 +30,6 @@ from app.models.comparison import ComparisonRecord
from app.models.ops_marquee_seed import OpsMarqueeSeed
from app.models.user import User
from app.repositories import app_config
from app.repositories.user import is_default_nickname
# 首页轮播数据源模式(存 app_config.marquee_feed_mode):
# mixed=真实优先+种子补位+合成兜底(默认,原行为);real=只真实(不足则少/空);seed=只种子+合成兜底。
@@ -141,12 +140,9 @@ def _synth_masked_name(rng: random.Random) -> str:
def _mask_real(nickname: str | None, user_id: int) -> str:
"""真实用户脱敏(对齐 PRD「用户标识打码规则」):设过昵称→昵称脱敏(中英文皆可);
没昵称→「用户」+5星+id 后 2 位(用户*****08),按 user_id 稳定、刷新不变脸。
创建时自动分配的默认昵称(「用户」+9 位随机,见 user.is_default_nickname)不算用户主动设的昵称,
按「无昵称」处理走 id 规则(产品决策 2026-07:默认昵称归入「没昵称」档)。"""
没昵称→「用户」+5星+id 后 2 位(用户*****08),按 user_id 稳定、刷新不变脸。"""
nick = (nickname or "").strip()
if nick and not is_default_nickname(nick):
if nick:
return _mask_nickname(nick)
return _mask_anon(user_id)
@@ -217,11 +213,9 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]:
return out
def get_feed(db: Session, limit: int = 8, mode: str | None = None) -> list[dict]:
def get_feed(db: Session, limit: int = 8) -> list[dict]:
"""返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}。
mode:显式传入(admin 预览指定模式)则用它、**不改持久化配置**;不传(客户端 /savings-feed)读
持久化的 marquee_feed_mode;非法值一律回退到持久化模式。
真实条:success 且 0 < saved ≤ 上限,按 user 去重(同一用户只取最新一条,避免单人刷屏)。
不足用启用的种子补齐——**公平随机抽取** need 个(而非固定取前 N),让所有种子都有机会露出;
种子用户名留空则随机合成(避开撞名),金额取**长尾随机**(小额居多、偶尔大额,更像真实分布)。
@@ -229,8 +223,7 @@ def get_feed(db: Session, limit: int = 8, mode: str | None = None) -> list[dict]
展示时间统一「刷新」成相对现在的最近时刻(从 now 往前**随机抖动**递减),保证轮播永远像刚发生、
节奏自然不机械(真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时时间)。
"""
# 预览可显式指定模式(所见=选中模式,不依赖 PATCH 落库时序);None/非法 → 读持久化配置。
mode = mode if mode in FEED_MODES else get_feed_mode(db) # mixed / real / seed
mode = get_feed_mode(db) # mixed / real / seed(运营可在「首页轮播种子」页切换)
items: list[dict] = []
used_names: set[str] = set()
-16
View File
@@ -42,22 +42,6 @@ def _gen_nickname() -> str:
)
def is_default_nickname(nickname: str | None) -> bool:
"""是否为创建时自动分配的默认昵称(= "用户" + 9 位字母数字,见 [_gen_nickname])。
这类不是用户主动设置的昵称,展示脱敏时按「无昵称」处理(走 id 规则,见 ops_marquee._mask_real)。
精确匹配生成格式(前缀 + 定长字母数字集),不误伤真人以「用户」开头的昵称(如「用户体验师」含汉字、
长度也不符)。用户改过昵称即不再匹配。"""
if not nickname:
return False
s = nickname.strip()
return (
len(s) == len(_NICKNAME_PREFIX) + _NICKNAME_LEN
and s.startswith(_NICKNAME_PREFIX)
and all(c in _NICKNAME_ALPHABET for c in s[len(_NICKNAME_PREFIX):])
)
def get_user_by_username(db: Session, username: str) -> User | None:
return db.execute(
select(User).where(User.username == username)