From cce3a01de166650346d4ddd555cc2729ba18cea2 Mon Sep 17 00:00:00 2001 From: ouzhou Date: Sat, 13 Jun 2026 23:44:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(marquee):=20=E9=A6=96=E9=A1=B5=E8=BD=AE?= =?UTF-8?q?=E6=92=AD=E8=84=B1=E6=95=8F=E5=90=8D=E6=94=B9=E4=B8=BA=E6=8C=89?= =?UTF-8?q?=20user=5Fid=20=E6=81=92=E5=AE=9A=20+=20=E5=8E=BB=20Faker=20?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/49 Co-authored-by: ouzhou Co-committed-by: ouzhou --- app/admin/repositories/ad_audit.py | 90 ++++++++++-- app/admin/repositories/queries.py | 39 ++++- app/admin/routers/ad_audit.py | 14 +- app/admin/routers/users.py | 103 +++++++++----- app/admin/schemas/ad_audit.py | 13 +- app/admin/schemas/user.py | 16 ++- app/core/rewards.py | 12 +- app/repositories/ad_feed_reward.py | 7 +- app/repositories/ad_reward.py | 18 ++- app/repositories/ops_marquee.py | 158 ++++++++++++++------- docs/api/README.md | 41 +++--- docs/api/admin-ad-coin-audit.md | 10 +- docs/api/admin-user-cash.md | 34 +++++ docs/api/admin-user-coins.md | 17 ++- docs/api/admin-users-list.md | 16 ++- docs/api/admin-wallet-cash-transactions.md | 2 +- docs/api/admin-wallet-coin-transactions.md | 2 +- docs/api/platform-savings-feed.md | 12 +- docs/database/ops_marquee_seed.md | 2 +- tests/test_admin_read.py | 133 +++++++++++++++++ tests/test_admin_write.py | 96 +++++++++++++ 21 files changed, 673 insertions(+), 162 deletions(-) create mode 100644 docs/api/admin-user-cash.md diff --git a/app/admin/repositories/ad_audit.py b/app/admin/repositories/ad_audit.py index c882b12..064b036 100644 --- a/app/admin/repositories/ad_audit.py +++ b/app/admin/repositories/ad_audit.py @@ -1,16 +1,18 @@ """看广告金币审计:复算 expected_coin 并与实发对比。 只读。复用 [app.core.rewards] 的公式函数(不另写公式,避免与正式发奖口径漂移): -- 看视频:每条 granted = 1 份,第 N 份 = 当日该用户 granted 的 reward_video 顺序号 - (与 ad_reward.grant_ad_reward 里 `_granted_today + 1` 一致)。 -- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 当日该用户已 granted 份数累计 - (与 ad_feed_reward._unit_reward_total 的 existing_units 一致)。 +- 看视频:每条 granted = 1 份,第 N 份 = 该用户 granted 的 reward_video **账号累计**顺序号 + (与 ad_reward.grant_ad_reward 里 `_granted_cumulative + 1` 一致;LT 因子不按天重置, + 故复算时要把当日序号叠加上该用户在本日**之前**的累计已发份数)。 +- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 该用户 granted 份数**账号累计** + (与 ad_feed_reward._unit_reward_total 的 existing_units 一致;同样不按天重置, + 复算需叠加本日之前的累计份数)。 非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。 """ from __future__ import annotations -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session from app.core import rewards @@ -19,10 +21,29 @@ from app.models.ad_reward import AdRewardRecord from app.repositories.ad_feed_reward import FEED_REWARD_UNIT_SECONDS +def _prior_granted_counts( + db: Session, *, date: str, user_id: int | None +) -> dict[int, int]: + """各用户在 date **之前**已发奖的 reward_video 累计份数,作为当日复算的 LT 序号起点。 + LT 因子改账号累计后,当日第 1 份并非全局第 1 份,需叠加历史累计。""" + stmt = ( + select(AdRewardRecord.user_id, func.count()) + .where( + AdRewardRecord.reward_date < date, + AdRewardRecord.reward_scene == "reward_video", + AdRewardRecord.status == "granted", + ) + .group_by(AdRewardRecord.user_id) + ) + if user_id is not None: + stmt = stmt.where(AdRewardRecord.user_id == user_id) + return {uid: n for uid, n in db.execute(stmt).all()} + + def _reward_video_rows( db: Session, *, date: str, user_id: int | None ) -> list[dict]: - """看视频记录复算。按 (user_id, created_at) 升序还原当日第 N 份。""" + """看视频记录复算。按 (user_id, created_at) 升序还原账号累计第 N 份(含本日之前的累计)。""" stmt = ( select(AdRewardRecord) .where( @@ -34,7 +55,8 @@ def _reward_video_rows( if user_id is not None: stmt = stmt.where(AdRewardRecord.user_id == user_id) - granted_n: dict[int, int] = {} # user_id -> 已 granted 份数 + # 用本日之前的累计份数做起点,当日 granted 在其上继续递增 → 与 _granted_cumulative+1 对齐 + granted_n: dict[int, int] = _prior_granted_counts(db, date=date, user_id=user_id) rows: list[dict] = [] for rec in db.execute(stmt).scalars(): if rec.status == "granted": @@ -80,8 +102,28 @@ def _reward_video_rows( return rows +def _feed_prior_granted_units( + db: Session, *, date: str, user_id: int | None +) -> dict[int, int]: + """各用户在 date **之前** granted 的信息流份数累计,作为当日复算的 LT 序号起点。""" + stmt = ( + select( + AdFeedRewardRecord.user_id, + func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0), + ) + .where( + AdFeedRewardRecord.reward_date < date, + AdFeedRewardRecord.status == "granted", + ) + .group_by(AdFeedRewardRecord.user_id) + ) + if user_id is not None: + stmt = stmt.where(AdFeedRewardRecord.user_id == user_id) + return {uid: int(n) for uid, n in db.execute(stmt).all()} + + def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: - """信息流记录复算。granted 记录逐份累加,LT 序号沿用当日累计份数。""" + """信息流记录复算。granted 记录逐份累加,LT 序号沿用账号累计份数(含本日之前)。""" stmt = ( select(AdFeedRewardRecord) .where(AdFeedRewardRecord.reward_date == date) @@ -90,7 +132,8 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: if user_id is not None: stmt = stmt.where(AdFeedRewardRecord.user_id == user_id) - granted_units: dict[int, int] = {} # user_id -> 已 granted 份数累计 + # 本日之前的累计份数做起点,与 _unit_reward_total 的 existing_units(累计)对齐 + granted_units: dict[int, int] = _feed_prior_granted_units(db, date=date, user_id=user_id) rows: list[dict] = [] for rec in db.execute(stmt).scalars(): if rec.status == "granted": @@ -142,12 +185,20 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: def ad_coin_audit( - db: Session, *, date: str, user_id: int | None, scene: str | None, limit: int -) -> list[dict]: - """返回当日发奖复算明细,按 created_at 倒序(最新在前)截断到 limit。 + db: Session, + *, + date: str, + user_id: int | None, + scene: str | None, + limit: int, + only_mismatch: bool = False, +) -> dict: + """当日发奖复算。返回 {total, mismatch_count, truncated, items}。 - scene: None=两类都要 / "reward_video" / "feed"。 - 份序号在截断前已基于全天数据算好,故 limit 只影响展示条数、不影响 expected 复算正确性。 + scene: None=两类都要 / "reward_video" / "feed";only_mismatch=True 只展示不一致(✗)行。 + 关键:`total` 与 `mismatch_count` 在**全量**(截断前)上统计,故对账数字始终可信,不受 limit + 影响;`items` 才是展示集(only_mismatch 时只取 ✗ 行)按 created_at 倒序截断到 limit。 + 份序号在全天数据上已算好,limit 只影响展示条数、不影响 expected 复算正确性。 """ rows: list[dict] = [] if scene in (None, "reward_video"): @@ -155,7 +206,16 @@ def ad_coin_audit( if scene in (None, "feed"): rows.extend(_feed_rows(db, date=date, user_id=user_id)) rows.sort(key=lambda r: r["created_at"], reverse=True) - return rows[:limit] + + total = len(rows) + mismatch_count = sum(1 for r in rows if not r["matched"]) + display = [r for r in rows if not r["matched"]] if only_mismatch else rows + return { + "total": total, + "mismatch_count": mismatch_count, + "truncated": len(display) > limit, + "items": display[:limit], + } def formula_snapshot() -> dict: diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index ab306d1..d7293f0 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -44,9 +44,20 @@ def list_users( phone: str | None = None, register_channel: str | None = None, status: str | None = None, + nickname: str | None = None, + created_from: datetime | None = None, + created_to: datetime | None = None, + last_login_from: datetime | None = None, + last_login_to: datetime | None = None, + sort_by: str = "id", + sort_order: str = "desc", limit: int = 20, cursor: int | None = None, ) -> tuple[list[User], int | None]: + """用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录时间范围筛选, + 按 id·注册时间·最近登录排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一, + 代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。 + 日期入参统一转 UTC naive 比较(User 时间均为 UTC naive,见 _as_utc_naive)。""" stmt = select(User) if phone: stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配 @@ -54,7 +65,33 @@ def list_users( stmt = stmt.where(User.register_channel == register_channel) if status: stmt = stmt.where(User.status == status) - return cursor_paginate(db, stmt, User.id, limit=limit, cursor=cursor) + if nickname and nickname.strip(): + stmt = stmt.where(User.nickname.ilike(f"%{nickname.strip()}%")) + if created_from is not None: + stmt = stmt.where(User.created_at >= _as_utc_naive(created_from)) + if created_to is not None: + stmt = stmt.where(User.created_at <= _as_utc_naive(created_to)) + if last_login_from is not None: + stmt = stmt.where(User.last_login_at >= _as_utc_naive(last_login_from)) + if last_login_to is not None: + stmt = stmt.where(User.last_login_at <= _as_utc_naive(last_login_to)) + + sort_cols = { + "id": User.id, + "created_at": User.created_at, + "last_login_at": User.last_login_at, + } + sort_col = sort_cols.get(sort_by, User.id) + order_fn = asc if sort_order == "asc" else desc + id_order = asc(User.id) if sort_order == "asc" else desc(User.id) + stmt = stmt.order_by(order_fn(sort_col), id_order) + + offset = max(cursor or 0, 0) + rows = list(db.execute(stmt.offset(offset).limit(limit + 1)).scalars().all()) + has_more = len(rows) > limit + items = rows[:limit] + next_cursor = offset + limit if has_more else None + return items, next_cursor def list_onboarding_devices(db: Session, *, limit: int = 500) -> list[dict]: diff --git a/app/admin/routers/ad_audit.py b/app/admin/routers/ad_audit.py index fd4b057..69f98f3 100644 --- a/app/admin/routers/ad_audit.py +++ b/app/admin/routers/ad_audit.py @@ -29,16 +29,20 @@ def get_ad_coin_audit( str | None, Query(description="reward_video / feed;不传=两类都要") ] = None, limit: Annotated[int, Query(ge=1, le=500)] = 100, + only_mismatch: Annotated[ + bool, Query(description="只看不一致(✗)行;统计数仍按全量,不受影响") + ] = False, ) -> AdCoinAuditOut: audit_date = date or cn_today().isoformat() - rows = ad_audit.ad_coin_audit( + result = ad_audit.ad_coin_audit( db, date=audit_date, user_id=user_id, scene=scene, limit=limit, + only_mismatch=only_mismatch, ) - items = [AdCoinAuditRow(**r) for r in rows] return AdCoinAuditOut( date=audit_date, formula=AdCoinFormulaOut(**ad_audit.formula_snapshot()), - total=len(items), - mismatch_count=sum(1 for it in items if not it.matched), - items=items, + total=result["total"], + mismatch_count=result["mismatch_count"], + truncated=result["truncated"], + items=[AdCoinAuditRow(**r) for r in result["items"]], ) diff --git a/app/admin/routers/users.py b/app/admin/routers/users.py index c7bb408..e992e20 100644 --- a/app/admin/routers/users.py +++ b/app/admin/routers/users.py @@ -1,6 +1,7 @@ """admin 用户管理:列表 + 360 详情(读)+ 封禁/解封 + 手动调金币(写,带审计)。""" from __future__ import annotations +from datetime import datetime from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, Request @@ -28,18 +29,27 @@ router = APIRouter( ) -@router.get("", response_model=CursorPage[AdminUserListItem], summary="用户列表(筛选+分页)") +@router.get("", response_model=CursorPage[AdminUserListItem], summary="用户列表(筛选+排序+分页)") def list_users( db: AdminDb, phone: Annotated[str | None, Query()] = None, register_channel: Annotated[str | None, Query()] = None, status: Annotated[str | None, Query()] = None, + nickname: Annotated[str | None, Query(max_length=100)] = None, + created_from: Annotated[datetime | None, Query()] = None, + created_to: Annotated[datetime | None, Query()] = None, + last_login_from: Annotated[datetime | None, Query()] = None, + last_login_to: Annotated[datetime | None, Query()] = None, + sort_by: Annotated[str, Query(pattern="^(id|created_at|last_login_at)$")] = "id", + sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc", limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, ) -> CursorPage[AdminUserListItem]: items, next_cursor = queries.list_users( db, phone=phone, register_channel=register_channel, status=status, - limit=limit, cursor=cursor, + nickname=nickname, created_from=created_from, created_to=created_to, + last_login_from=last_login_from, last_login_to=last_login_to, + sort_by=sort_by, sort_order=sort_order, limit=limit, cursor=cursor, ) return CursorPage( items=[AdminUserListItem.model_validate(u) for u in items], @@ -101,7 +111,7 @@ def set_user_debug_trace( return OkResponse() -@router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减金币(带审计)") +@router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减/设值金币(带审计)") def grant_user_coins( user_id: int, body: GrantCoinsRequest, @@ -109,33 +119,45 @@ def grant_user_coins( admin: Annotated[AdminUser, Depends(require_role("finance"))], db: AdminDb, ) -> OkResponse: - if body.amount == 0: - raise HTTPException(status_code=400, detail="amount 不能为 0") user = user_repo.get_user_by_id(db, user_id) if user is None: raise HTTPException(status_code=404, detail="用户不存在") - # 负数扣减时不允许扣成负余额(运营误操作保护) - if body.amount < 0: - acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False) - if acc_now.coin_balance + body.amount < 0: - raise HTTPException( - status_code=400, detail=f"扣减后金币为负(当前余额 {acc_now.coin_balance})" - ) - biz_type = "admin_grant" if body.amount > 0 else "admin_deduct" + # set=设为目标值:读当前余额算出要写的差值,仍复用 grant_coins 写一笔流水(沿用原子/审计/扣负保护) + if body.mode == "set": + if body.amount < 0: + raise HTTPException(status_code=400, detail="目标金币值不能为负") + before = wallet_repo.get_or_create_account(db, user_id, commit=False).coin_balance + delta = body.amount - before + if delta == 0: + raise HTTPException(status_code=400, detail=f"当前金币已为 {body.amount},无需调整") + else: + if body.amount == 0: + raise HTTPException(status_code=400, detail="amount 不能为 0") + delta = body.amount + # 负数扣减时不允许扣成负余额(运营误操作保护) + if delta < 0: + acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False) + if acc_now.coin_balance + delta < 0: + raise HTTPException( + status_code=400, detail=f"扣减后金币为负(当前余额 {acc_now.coin_balance})" + ) + biz_type = "admin_grant" if delta > 0 else "admin_deduct" # grant_coins 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕) acc, _ = wallet_repo.grant_coins( - db, user_id, body.amount, biz_type=biz_type, remark=f"admin:{body.reason}"[:128], + db, user_id, delta, biz_type=biz_type, remark=f"admin:{body.reason}"[:128], ) + detail = {"amount": delta, "balance_after": acc.coin_balance, "reason": body.reason} + if body.mode == "set": + detail.update({"mode": "set", "target": body.amount, "before": before}) write_audit( db, admin, action="user.coins.grant", target_type="user", target_id=user_id, - detail={"amount": body.amount, "balance_after": acc.coin_balance, "reason": body.reason}, - ip=get_client_ip(request), commit=False, + detail=detail, ip=get_client_ip(request), commit=False, ) db.commit() return OkResponse() -@router.post("/{user_id}/cash", response_model=OkResponse, summary="手动增减现金(带审计)") +@router.post("/{user_id}/cash", response_model=OkResponse, summary="手动增减/设值现金(带审计)") def grant_user_cash( user_id: int, body: GrantCashRequest, @@ -143,32 +165,47 @@ def grant_user_cash( admin: Annotated[AdminUser, Depends(require_role("finance"))], db: AdminDb, ) -> OkResponse: - """给指定用户增/减现金(分)。正=发放、负=扣减;主要用于让无现金用户直接测试提现。""" - if body.amount_cents == 0: - raise HTTPException(status_code=400, detail="amount_cents 不能为 0") + """给指定用户增/减或设值现金(分)。delta:正=发放、负=扣减;set:直接设为目标值。 + 主要用于让无现金用户直接测试提现。""" user = user_repo.get_user_by_id(db, user_id) if user is None: raise HTTPException(status_code=404, detail="用户不存在") - # 负数扣减时不允许扣成负余额(运营误操作保护) - if body.amount_cents < 0: - acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False) - if acc_now.cash_balance_cents + body.amount_cents < 0: + # set=设为目标值:读当前余额算差值,仍复用 grant_cash 写一笔流水(沿用原子/审计/扣负保护) + if body.mode == "set": + if body.amount_cents < 0: + raise HTTPException(status_code=400, detail="目标现金值不能为负") + before = wallet_repo.get_or_create_account(db, user_id, commit=False).cash_balance_cents + delta = body.amount_cents - before + if delta == 0: raise HTTPException( - status_code=400, detail=f"扣减后现金为负(当前余额 {acc_now.cash_balance_cents} 分)" + status_code=400, detail=f"当前现金已为 {body.amount_cents} 分,无需调整" ) - biz_type = "admin_grant" if body.amount_cents > 0 else "admin_deduct" + else: + if body.amount_cents == 0: + raise HTTPException(status_code=400, detail="amount_cents 不能为 0") + delta = body.amount_cents + # 负数扣减时不允许扣成负余额(运营误操作保护) + if delta < 0: + acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False) + if acc_now.cash_balance_cents + delta < 0: + raise HTTPException( + status_code=400, detail=f"扣减后现金为负(当前余额 {acc_now.cash_balance_cents} 分)" + ) + biz_type = "admin_grant" if delta > 0 else "admin_deduct" # grant_cash 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕) acc, _ = wallet_repo.grant_cash( - db, user_id, body.amount_cents, biz_type=biz_type, remark=f"admin:{body.reason}"[:128], + db, user_id, delta, biz_type=biz_type, remark=f"admin:{body.reason}"[:128], ) + detail = { + "amount_cents": delta, + "balance_after_cents": acc.cash_balance_cents, + "reason": body.reason, + } + if body.mode == "set": + detail.update({"mode": "set", "target_cents": body.amount_cents, "before_cents": before}) write_audit( db, admin, action="user.cash.grant", target_type="user", target_id=user_id, - detail={ - "amount_cents": body.amount_cents, - "balance_after_cents": acc.cash_balance_cents, - "reason": body.reason, - }, - ip=get_client_ip(request), commit=False, + detail=detail, ip=get_client_ip(request), commit=False, ) db.commit() return OkResponse() diff --git a/app/admin/schemas/ad_audit.py b/app/admin/schemas/ad_audit.py index cd5f949..b268d8d 100644 --- a/app/admin/schemas/ad_audit.py +++ b/app/admin/schemas/ad_audit.py @@ -48,10 +48,15 @@ class AdCoinFormulaOut(BaseModel): class AdCoinAuditOut(BaseModel): - """审计响应:公式参照 + 命中条数 + 明细。""" + """审计响应:公式参照 + 全量统计 + 明细。""" date: str = Field(..., description="审计日期(北京时间 YYYY-MM-DD)") formula: AdCoinFormulaOut - total: int = Field(..., description="返回的明细条数") - mismatch_count: int = Field(..., description="其中 matched=false 的条数(=0 说明公式全部生效)") - items: list[AdCoinAuditRow] + total: int = Field(..., description="该筛选下复算总条数(全量,不受 limit/only_mismatch 影响)") + mismatch_count: int = Field( + ..., description="全量不一致条数(=0 说明公式全部生效;在截断前统计,可信)" + ) + truncated: bool = Field( + ..., description="展示集是否被 limit 截断(true=还有未返回的明细,请缩小范围或调大 limit)" + ) + items: list[AdCoinAuditRow] = Field(..., description="展示明细;only_mismatch=true 时只含 ✗ 行") diff --git a/app/admin/schemas/user.py b/app/admin/schemas/user.py index 0b49582..f781fe5 100644 --- a/app/admin/schemas/user.py +++ b/app/admin/schemas/user.py @@ -38,12 +38,24 @@ class AdminUserOverview(BaseModel): class GrantCoinsRequest(BaseModel): - amount: int = Field(..., description="金币变动:正=增加,负=扣减(不可为 0)") + mode: Literal["delta", "set"] = Field( + "delta", description="delta=增减(amount 为变动量) / set=设为(amount 为目标值,须≥0)" + ) + amount: int = Field( + ..., + description="delta 模式:金币变动(正=增加,负=扣减,不可为 0);set 模式:目标金币值(须≥0)", + ) reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)") class GrantCashRequest(BaseModel): - amount_cents: int = Field(..., description="现金变动(分):正=增加,负=扣减(不可为 0)") + mode: Literal["delta", "set"] = Field( + "delta", description="delta=增减(amount_cents 为变动量) / set=设为(amount_cents 为目标值,须≥0)" + ) + amount_cents: int = Field( + ..., + description="delta 模式:现金变动(分,正=增加,负=扣减,不可为 0);set 模式:目标现金值(分,须≥0)", + ) reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)") diff --git a/app/core/rewards.py b/app/core/rewards.py index 713282f..3870af0 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -172,24 +172,24 @@ def ad_ecpm_factor(ecpm_yuan: float) -> float: return 0.1 -def ad_lt_factor(today_count_after_this: int) -> float: - """LT 因子。today_count_after_this 是当天累计第 N 条/份广告奖励。""" - count = max(1, today_count_after_this) +def ad_lt_factor(count_after_this: int) -> float: + """LT 因子。count_after_this 是该账号累计第 N 条/份看视频奖励(不按天重置)。""" + count = max(1, count_after_this) for factor, lo, hi in AD_LT_FACTOR_TABLE: if count >= lo and (hi is None or count <= hi): return factor return 1.0 -def calculate_ad_reward_coin(ecpm: str | int | float | None, today_count_after_this: int) -> int: +def calculate_ad_reward_coin(ecpm: str | int | float | None, count_after_this: int) -> int: """按金币数值体系计算单份广告奖励金币。 eCPM 是穿山甲 getEcpm 原值,单位【分/千次展示】;先 ÷100 转成元(因子判档 + 收益换算都用元)。 单次收益(元)= eCPM元 ÷ 1000(每千次→单次) × 因子1(eCPM 元档) × 因子2(LT); - 再按 1 元=10000 金币取整。 + 再按 1 元=10000 金币取整。count_after_this 为账号累计第 N 次看视频(LT 因子用,不按天重置)。 """ ecpm_yuan = parse_ecpm_yuan(ecpm) - yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(today_count_after_this) + yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(count_after_this) return max(0, round(yuan * COIN_PER_YUAN)) diff --git a/app/repositories/ad_feed_reward.py b/app/repositories/ad_feed_reward.py index fdf0059..894c928 100644 --- a/app/repositories/ad_feed_reward.py +++ b/app/repositories/ad_feed_reward.py @@ -38,15 +38,14 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int: ).scalar_one() -def _unit_reward_total(db: Session, user_id: int, ecpm: str, unit_count: int, today: str) -> int: - """按每个 10 秒单位逐份计算奖励,LT 使用当天累计奖励份序号。""" +def _unit_reward_total(db: Session, user_id: int, ecpm: str, unit_count: int) -> int: + """按每个 10 秒单位逐份计算奖励,LT 使用**账号累计**奖励份序号(不按天重置)。""" if unit_count <= 0: return 0 existing_units = db.execute( select(func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0)) .where( AdFeedRewardRecord.user_id == user_id, - AdFeedRewardRecord.reward_date == today, AdFeedRewardRecord.status == "granted", ) ).scalar_one() @@ -92,7 +91,7 @@ def grant_feed_reward( ) return _commit_record(db, rec, client_event_id) - coin = _unit_reward_total(db, user_id, ecpm, unit_count, today) + coin = _unit_reward_total(db, user_id, ecpm, unit_count) if coin > 0: crud_wallet.grant_coins( db, user_id, coin, diff --git a/app/repositories/ad_reward.py b/app/repositories/ad_reward.py index e018ec0..7c433d5 100644 --- a/app/repositories/ad_reward.py +++ b/app/repositories/ad_reward.py @@ -54,6 +54,20 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int: ).scalar_one() +def _granted_cumulative(db: Session, user_id: int) -> int: + """账号累计已发奖的看视频次数(不按天重置)——LT 因子(因子2)用它定"第 N 次广告"。 + 与 _granted_today 区别仅在去掉 reward_date 过滤;每日次数上限/冷却仍按当日统计。""" + return db.execute( + select(func.count()) + .select_from(AdRewardRecord) + .where( + AdRewardRecord.user_id == user_id, + AdRewardRecord.status == "granted", + AdRewardRecord.reward_scene == "reward_video", + ) + ).scalar_one() + + def grant_ad_reward( db: Session, user_id: int, @@ -104,7 +118,9 @@ def grant_ad_reward( ) return _commit_record(db, rec, trans_id) - coin = rewards.calculate_ad_reward_coin(ecpm_raw, _granted_today(db, user_id, today) + 1) + # LT 因子(因子2)按"账号累计第 N 次看广告"递减(2.0→1.0),不再按天重置; + # 每日次数上限/冷却仍按当日统计(over_count 用 _granted_today)。 + coin = rewards.calculate_ad_reward_coin(ecpm_raw, _granted_cumulative(db, user_id) + 1) # 发金币 + 记一笔,同事务 crud_wallet.grant_coins( diff --git a/app/repositories/ops_marquee.py b/app/repositories/ops_marquee.py index 717e449..87128b7 100644 --- a/app/repositories/ops_marquee.py +++ b/app/repositories/ops_marquee.py @@ -6,9 +6,16 @@ feed 取最近的成功且省到钱的比价记录(脱敏用户名);不足 N 条 种子是「生成规则」:用户名可空(空→随机合成脱敏名,避开同屏撞名)、金额是区间(每次随机取值)、 feed 公平随机抽取(不看 sort_order),所有启用种子都有机会露出。 -脱敏名风格:手机尾号(138****5678)+ 中文昵称(省钱**、小张**、吃货**达人)混合,看起来像真实 -异质用户群。真实条按 user_id 确定性合成(同用户恒定);种子留空 / 旧「用户****xxx」模板名一律随机 -合成(自愈历史种子,无需迁移)。 +脱敏名规则(全 feed 统一,按字符算、中英文皆适用): + - 有昵称 → 昵称脱敏:≥3 字「首+隐藏字数个星+末」(省钱小能手→省***手 / SaveKing→S******g), + 2 字「首+星」(阿强→阿*),1 字「用户+该字」(喵→用户喵); + - 没昵称 → 合成一个假名(见下)再脱敏,少数露「用户+id 后 3 位」(用户********618)。 +真实条:设过昵称的按真实昵称脱敏;当前多数用户没设昵称,直接展示「用户****」会清一色,故给它们合成 +假名——后期真实昵称多了,真实条会直接用真实昵称,合成占比自然下降。种子 / 兜底同样合成、避开同屏撞名; +种子留空 / 旧「用户****xxx」模板名一律重新合成(自愈历史种子,无需迁移)。 +假名合成:纯本地语料**组合生成**(中文姓池×名字池 拼真名 / 中文网络昵称 / 英文昵称 / 英文名带数字尾), +组合空间上万、中文为主英文为辅,脱敏后像真实异质用户群。**真实条按 `user_id` 播种确定性合成**(同一用户 +每次展示恒定、不同用户各异),刷新/翻页不变脸;种子 / 兜底用运行时随机源出多样填充。不依赖外部库。 """ from __future__ import annotations @@ -21,6 +28,7 @@ from sqlalchemy.orm import Session from app.core.rewards import CN_TZ from app.models.comparison import ComparisonRecord from app.models.ops_marquee_seed import OpsMarqueeSeed +from app.models.user import User # feed 运行时随机源(每次请求结果不同 = 轮播想要的「鲜活感」) _rng = random.Random() @@ -36,59 +44,111 @@ _REAL_ROWS_TTL_SECONDS = 30 _REAL_ROWS_FETCH_CAP = 600 # 一次多取些,够 limit≤30 去重后取数;命中缓存后复用 _real_rows_cache: dict = {"at": None, "rows": None} -# ===== 脱敏名生成:手机尾号 + 中文昵称 混合,看起来像真实异质用户群(替代旧的「用户********xxx」统一模板)===== -_PHONE_2ND = "3456789" # 手机号第 2 位(13x~19x) -_SURNAMES = "王李张刘陈杨黄赵周吴徐孙马朱胡郭何高林罗郑梁谢宋唐许韩冯邓曹彭曾肖田董袁潘蒋蔡余杜叶程苏魏吕丁任沈姚卢" -_NICK_WORDS = [ - "省钱", "吃货", "羊毛", "薅羊毛", "爱比价", "精打细算", "持家", "干饭", - "美食控", "外卖", "剁手", "捡漏", "搞钱", "打工人", "摸鱼", "养生", - "佛系", "躺平", "暴富", "锦鲤", "夜宵", "奶茶", "热爱生活", "会过日子", +# ===== 用户标识脱敏 + 没真实昵称时的假名合成 ===== +# 脱敏规则(中英文皆适用,按字符算):有昵称→≥3 字「首+隐藏字数个星+末」(省钱小能手→省***手 / +# SaveKing→S******g)、2 字「首+星」(阿强→阿*)、1 字「用户+该字」(喵→用户喵);没昵称→用户+8星+id 后 3 位。 +# 没真实昵称时(当前多数用户 / 种子 / 兜底)合成假名:本地姓/名字池组合出真名 + 网络昵称语料(中/英) +# 混播再走同一套脱敏,像真实异质用户群。后期真实昵称多了,真实条直接用真实昵称,这里占比自然下降。 +_ANON_STARS = "*" * 8 # 无昵称匿名串固定 8 星(用户********xxx) + +# 中文真名组合池:姓(百家姓常见 ~100)× 名字单字(~120)拼「姓 + 1~2 字」,组合空间上万。 +# 脱敏后只露「姓*」或「姓*末」,故无需真实姓名库——组合够多即看着各异(替代原 Faker zh_CN)。 +_SURNAME_ZH = list( + "王李张刘陈杨赵黄周吴徐孙胡朱高林何郭马罗梁宋郑谢韩唐冯于董萧程曹袁邓许傅沈曾彭吕苏卢蒋蔡贾丁" + "魏薛叶阎余潘杜戴夏钟汪田任姜范方石姚谭廖邹熊金陆郝孔白崔康毛邱秦江史顾侯邵孟龙万段钱汤尹黎" + "易常武乔贺赖龚文施洪丰房邢" +) +_GIVEN_ZH = list( + "伟芳娜秀英敏静丽强磊军洋勇艳杰娟涛明超霞平刚桂兰华健世广义兴良海山仁波宁贵福生龙元全国胜学祥" + "才发新利清飞彬富顺信子昌成康星光天达安岩中茂进有坚和彪博诚先敬震振壮会思群豪心邦承乐绍功松善" + "厚庆民友裕河哲亮政谦亨奇之轮翰朗伯宏言若鸣朋斌栋维启克伦翔旭鹏泽晨辰士以建家致树炎德行时泰盛雄" +) +# 英文名池:与英文昵称互补,偶尔带数字尾(jay87)更像注册用户名(替代原 Faker en_US)。 +_FIRST_EN = [ + "Alex", "Chris", "Sam", "Jamie", "Taylor", "Jordan", "Casey", "Morgan", "Riley", "Jessie", + "Robin", "Drew", "Lee", "Max", "Kim", "Ray", "Dana", "Pat", "Terry", "Jay", + "Kelly", "Sky", "Ash", "Nico", "Remy", "Quinn", "Reese", "Sage", "Toni", "Val", +] + +# 网络昵称语料:补一份「省钱小能手 / SaveKing」式网络昵称更像真实 app 用户。想更丰富往里加即可。 +_NICK_ZH = [ + "省钱小能手", "薅羊毛专业户", "吃货一枚", "干饭人", "奶茶续命", "精打细算过日子", + "会过日子的人", "捡漏王", "外卖救星", "养生少年", "持家有道", "比价狂魔", + "月光族逆袭", "摸鱼达人", "暴富锦鲤", "美食猎人", "折扣猎手", "隐形贫困人口", + "打工不打烊", "佛系青年", "元气满满", "一只小馋猫", "爱吃的小朋友", "省钱才是硬道理", +] +_NICK_EN = [ + "SaveKing", "DealHunter", "FoodieLife", "BudgetBoss", "CouponQueen", "ThriftyOne", + "SnackLover", "BargainHero", "PennyWise", "SmartShopper", "DiscountNinja", "HungryPanda", + "MoneyMaster", "CheapEats", "SaverPro", "FrugalFox", "NoodleKing", "BubbleTeaFan", + "GoldenSaver", "ValueHunter", "SnackAttack", "LazyFoodie", ] -_NICK_PREFIX = ["小", "大", "老", "阿", "一只", "爱吃的", "快乐的"] -_NICK_SUFFIX = ["达人", "小能手", "侠", "党", "一族", "星人", "鸭", "酱"] -_LETTER_U = "ABCDEFGHJKLMNPQRSTUVWXYZ" -_LETTER_L = "abcdefghijkmnpqrstuvwxyz" -def _phone_name(rng: random.Random) -> str: - """手机尾号式:1[3-9]X****XXXX(合成、非真实号)。""" - head = "1" + rng.choice(_PHONE_2ND) + str(rng.randint(0, 9)) - return f"{head}****{rng.randint(0, 9999):04d}" +def _mask_nickname(nick: str) -> str: + """昵称脱敏(按字符,中英文皆可):≥3 字→首+(隐藏字数个)星+末(省钱小能手→省***手 / SaveKing→S******g); + 2 字→首+星(阿强→阿*);1 字→「用户」+该字(喵→用户喵)。调用方需保证 nick 已 strip 且非空。""" + n = len(nick) + if n >= 3: + return nick[0] + "*" * (n - 2) + nick[-1] + if n == 2: + return nick[0] + "*" + return "用户" + nick -def _nickname(rng: random.Random) -> str: - """昵称打码式,中文为主(约 11/12),掺少量中英混 / 纯字母。 - ⚠️ 脱敏星号一律夹在中间(xx**yy),星号前后都要有可见字——不出现结尾打码(省钱**)的形态。 - """ - p = rng.randint(0, 11) - if p in (0, 1, 2, 3, 4): - return rng.choice(_NICK_WORDS) + "**" + rng.choice(_NICK_SUFFIX) # 省钱**党 / 吃货**达人 - if p in (5, 6): - return rng.choice(_SURNAMES) + "**" + rng.choice(_NICK_SUFFIX) # 张**侠 - if p in (7, 8): - return rng.choice(_NICK_PREFIX) + rng.choice(_SURNAMES) + "**" + rng.choice(_NICK_SUFFIX) # 小张**达人 - if p in (9, 10): - return rng.choice(_NICK_WORDS) + "**" + rng.choice(_LETTER_U) # 中英混 省钱**A(字母移到星号后) - return rng.choice(_LETTER_U) + "**" + rng.choice(_LETTER_L) # 纯字母(约 1/12) A**b +def _anon_by_id(user_id: int) -> str: + """没昵称匿名:固定「用户」+ 8 星 + id 后 3 位(不足 3 位前补 0):用户********618。""" + return "用户" + _ANON_STARS + f"{user_id % 1000:03d}" -def _realistic_name(rng: random.Random) -> str: - """~45% 手机尾号 + ~55% 中文昵称,混合出真实异质感。""" - return _phone_name(rng) if rng.random() < 0.45 else _nickname(rng) +def _synth_full_name(rng: random.Random) -> str: + """合成一个完整(未脱敏)假名:中文真名(姓+1~2字) / 中文网络昵称 / 英文昵称 / 英文名(可带数字尾) 混播, + 中文为主(~70%)英文为辅(~30%)。**全程用传入 rng** → 同种子复现、不同种子各异、组合空间上万。""" + r = rng.random() + if r < 0.35: + # 中文真名:姓 + 1~2 个名字字(60% 概率两字),脱敏后露「姓*」或「姓*末」 + given = rng.choice(_GIVEN_ZH) + (rng.choice(_GIVEN_ZH) if rng.random() < 0.6 else "") + return rng.choice(_SURNAME_ZH) + given # 王伟 / 李秀兰 + if r < 0.70: + return rng.choice(_NICK_ZH) # 中文网络昵称:省钱小能手 + if r < 0.85: + return rng.choice(_NICK_EN) # 英文昵称:SaveKing + base = rng.choice(_FIRST_EN) # 英文名,半数带数字尾:Jay87 / Alex + return base + (str(rng.randint(1, 99)) if rng.random() < 0.5 else "") -def _mask_user(user_id: int) -> str: - """真实用户脱敏:按 user_id 确定性合成一个名(同用户恒定、不暴露真实信息)。""" - return _realistic_name(random.Random(user_id)) +def _synth_masked_name(rng: random.Random) -> str: + """合成一条「已脱敏」假名(供没真实昵称的真实用户 / 种子 / 兜底用)。用传入 rng 决定一切随机。""" + full = _synth_full_name(rng).strip() + return _mask_nickname(full) if full else "用户" + _ANON_STARS + f"{rng.randint(0, 999):03d}" + + +def _mask_real(nickname: str | None, user_id: int) -> str: + """真实用户脱敏:设过昵称→昵称脱敏(中英文皆可);没昵称→**按 user_id 播种确定性合成假名**再脱敏 + (同一用户每次展示恒定、刷新不变脸,不同用户各异),避免无昵称用户清一色「用户****xxx」; + 少数(~15%)露「用户+真实 id 后 3 位」。当前多数用户没昵称,后期真实昵称多了占比下降。""" + nick = (nickname or "").strip() + if nick: + return _mask_nickname(nick) + # 关键:按 user_id 播种本地 rng → 同一用户的合成名跨请求/刷新恒定(0.15 抽签也用它)。 + seeded = random.Random(user_id) + return _anon_by_id(user_id) if seeded.random() < 0.15 else _synth_masked_name(seeded) + + +def _synth_name(rng: random.Random) -> str: + """合成一条脱敏名(种子留空 / 旧模板名 / 兜底用):绝大多数=假名脱敏,少数=用户+随机 3 位(用户********618)。""" + if rng.random() < 0.15: + return "用户" + _ANON_STARS + f"{rng.randint(0, 999):03d}" + return _synth_masked_name(rng) def _unique_name(used: set[str]) -> str: - """随机合成一个不与 used 撞的脱敏名(种子留空 / 旧模板名用),防同屏撞名。""" + """随机合成一个不与 used 撞的脱敏名(种子留空 / 旧模板名 / 兜底用),防同屏撞名。""" for _ in range(60): - n = _realistic_name(_rng) + n = _synth_name(_rng) if n not in used: return n - return _realistic_name(_rng) + str(_rng.randint(0, 99)) # 兜底(几乎不会到) + return "用户" + _ANON_STARS + f"{_rng.randint(0, 999):03d}" # 兜底:数字尾号天然好去重 def _validate_amount(min_cents: int, max_cents: int) -> None: @@ -116,8 +176,9 @@ _FALLBACK_MAX_CENTS = 3500 # ===== 用户侧:轮播 feed(真实 + 种子混播) ===== -def _recent_real_rows(db: Session) -> list[tuple[int, int]]: - """近期 success 且省到钱的 (user_id, saved_cents),按 created_at desc;带 ~30s 进程内缓存。 +def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]: + """近期 success 且省到钱的 (user_id, saved_cents, nickname),按 created_at desc;带 ~30s 进程内缓存。 + join user 取真实昵称用于脱敏(无昵称则展示层走「用户+id 后 3 位」)。 返回纯元组(脱离 session),可安全跨请求复用。极端并发下偶尔多查一次(无锁、幂等),纯门面无副作用。 """ now = datetime.now(CN_TZ) @@ -125,7 +186,8 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int]]: if cached is not None and at is not None and (now - at).total_seconds() < _REAL_ROWS_TTL_SECONDS: return cached rows = db.execute( - select(ComparisonRecord.user_id, ComparisonRecord.saved_amount_cents) + select(ComparisonRecord.user_id, ComparisonRecord.saved_amount_cents, User.nickname) + .join(User, User.id == ComparisonRecord.user_id) .where( ComparisonRecord.status == "success", ComparisonRecord.saved_amount_cents > 0, @@ -134,7 +196,7 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int]]: .order_by(ComparisonRecord.created_at.desc()) .limit(_REAL_ROWS_FETCH_CAP) ).all() - out = [(int(uid), int(sc)) for uid, sc in rows] + out = [(int(uid), int(sc), nick) for uid, sc, nick in rows] _real_rows_cache["rows"], _real_rows_cache["at"] = out, now return out @@ -155,12 +217,12 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]: items: list[dict] = [] used_names: set[str] = set() seen_users: set[int] = set() - for uid, sc in rows: + for uid, sc, nick in rows: if uid in seen_users: continue seen_users.add(uid) - name = _mask_user(uid) - used_names.add(name) # 真实名按 user_id 稳定;偶发撞名可接受 + name = _mask_real(nick, uid) + used_names.add(name) # 真实名按昵称/id 稳定;偶发撞名可接受 items.append({"masked_user": name, "saved_amount_cents": int(sc)}) if len(items) >= limit: break diff --git a/docs/api/README.md b/docs/api/README.md index 7ff8981..79bea57 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -83,26 +83,27 @@ | A5 | `GET /admin/api/users/{user_id}` | admin | [详情](./admin-user-detail.md) | | A6 | `POST /admin/api/users/{user_id}/status` | operator | [详情](./admin-user-status.md) | | A7 | `POST /admin/api/users/{user_id}/coins` | finance | [详情](./admin-user-coins.md) | -| A8 | `GET /admin/api/wallet/coin-transactions` | admin | [详情](./admin-wallet-coin-transactions.md) | -| A9 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin-wallet-cash-transactions.md) | -| A10 | `GET /admin/api/withdraws` | admin | [详情](./admin-withdraws-list.md) | -| A11 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin-withdraw-reconcile.md) | -| A12 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin-withdraw-refresh.md) | -| A13 | `GET /admin/api/feedbacks` | admin | [详情](./admin-feedbacks-list.md) | -| A14 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin-feedback-handle.md) | -| A15 | `GET /admin/api/admins` | super_admin | [详情](./admin-admins-list.md) | -| A16 | `POST /admin/api/admins` | super_admin | [详情](./admin-admin-create.md) | -| A17 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin-admin-update.md) | -| A18 | `GET /admin/api/audit-logs` | admin | [详情](./admin-audit-logs.md) | -| A19 | `GET /admin/api/dashboard-display` | admin | [详情](./admin-dashboard-display.md) | -| A20 | `PATCH /admin/api/dashboard-display/{metric}` | operator | [详情](./admin-dashboard-display.md) | -| A21 | `GET /admin/api/marquee-seeds` | admin | [详情](./admin-marquee-seeds.md) | -| A22 | `POST /admin/api/marquee-seeds` | operator | [详情](./admin-marquee-seeds.md) | -| A23 | `PATCH /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) | -| A24 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) | -| A25 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) | -| A26 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) | -| A27 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) | +| A8 | `POST /admin/api/users/{user_id}/cash` | finance | [详情](./admin-user-cash.md) | +| A9 | `GET /admin/api/wallet/coin-transactions` | admin | [详情](./admin-wallet-coin-transactions.md) | +| A10 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin-wallet-cash-transactions.md) | +| A11 | `GET /admin/api/withdraws` | admin | [详情](./admin-withdraws-list.md) | +| A12 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin-withdraw-reconcile.md) | +| A13 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin-withdraw-refresh.md) | +| A14 | `GET /admin/api/feedbacks` | admin | [详情](./admin-feedbacks-list.md) | +| A15 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin-feedback-handle.md) | +| A16 | `GET /admin/api/admins` | super_admin | [详情](./admin-admins-list.md) | +| A17 | `POST /admin/api/admins` | super_admin | [详情](./admin-admin-create.md) | +| A18 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin-admin-update.md) | +| A19 | `GET /admin/api/audit-logs` | admin | [详情](./admin-audit-logs.md) | +| A20 | `GET /admin/api/dashboard-display` | admin | [详情](./admin-dashboard-display.md) | +| A21 | `PATCH /admin/api/dashboard-display/{metric}` | operator | [详情](./admin-dashboard-display.md) | +| A22 | `GET /admin/api/marquee-seeds` | admin | [详情](./admin-marquee-seeds.md) | +| A23 | `POST /admin/api/marquee-seeds` | operator | [详情](./admin-marquee-seeds.md) | +| A24 | `PATCH /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) | +| A25 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) | +| A26 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) | +| A27 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) | +| A28 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) | | - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) | > ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。 diff --git a/docs/api/admin-ad-coin-audit.md b/docs/api/admin-ad-coin-audit.md index 8b69e71..2956994 100644 --- a/docs/api/admin-ad-coin-audit.md +++ b/docs/api/admin-ad-coin-audit.md @@ -25,16 +25,18 @@ eCPM元 = getEcpm分 ÷ 100 | `date` | string | 今天 | 北京时间 `YYYY-MM-DD`,审计某天 | | `user_id` | int | 全部 | 只看某用户;不传=所有用户 | | `scene` | string | 两类 | `reward_video` / `feed`;不传=两类都返回 | - | `limit` | int(1~500) | 100 | 返回明细条数(按时间倒序截断;**份序号在截断前已按全天数据算好**,不影响复算正确性) | + | `limit` | int(1~500) | 100 | **展示**明细条数(按时间倒序截断;**份序号在截断前已按全天数据算好**,不影响复算正确性) | + | `only_mismatch` | bool | false | 只展示不一致(✗)行;统计数仍按全量,不受影响 | - 出参 `200`:`AdCoinAuditOut` | 字段 | 类型 | 说明 | |---|---|---| | `date` | string | 审计日期 | | `formula` | object | 当前公式参数快照(见下) | - | `total` | int | 返回明细条数 | - | `mismatch_count` | int | 其中 `matched=false` 的条数;**=0 说明全部按公式发放** | - | `items` | `AdCoinAuditRow[]` | 明细(见下) | + | `total` | int | 该筛选下复算**总条数**(全量,不受 `limit`/`only_mismatch` 影响) | + | `mismatch_count` | int | **全量**不一致条数(截断前统计,可信);**=0 说明全部按公式发放** | + | `truncated` | bool | 展示集是否被 `limit` 截断(true=还有未返回的明细,请缩小范围或调大 `limit`) | + | `items` | `AdCoinAuditRow[]` | 展示明细(见下);`only_mismatch=true` 时只含 ✗ 行 | ### AdCoinFormulaOut(`formula`) | 字段 | 类型 | 说明 | diff --git a/docs/api/admin-user-cash.md b/docs/api/admin-user-cash.md new file mode 100644 index 0000000..c9ea739 --- /dev/null +++ b/docs/api/admin-user-cash.md @@ -0,0 +1,34 @@ +# POST /admin/api/users/{user_id}/cash — 手动增减/设值现金(带审计) + +> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md) + +主要用于给无现金用户直接发钱、好让其测试提现链路。 + +## 入参 +- 路径:`user_id`(int) +- **application/json**: +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `mode` | string | ✗ | `delta`(默认)=增减 / `set`=设为指定值 | +| `amount_cents` | int | ✓ | `delta` 模式:现金变动(分,正=发放,负=扣减,不可为 0);`set` 模式:目标现金值(分,须 ≥ 0) | +| `reason` | string | ✓ | 操作原因,1–128 字(必填,入审计与流水备注) | + +## 出参 +响应 `200`:`OkResponse` = `{ "ok": true }` + +## 错误码 +- `400`(`delta`)`amount_cents == 0`(`detail: "amount_cents 不能为 0"`);或负数扣减后余额会变负(`detail: "扣减后现金为负(当前余额 N 分)"`) +- `400`(`set`)目标值为负(`detail: "目标现金值不能为负"`);或目标值等于当前余额(`detail: "当前现金已为 N 分,无需调整"`) +- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`) +- `403` 角色不足(需 `finance` 或 `super_admin`) +- `404` 用户不存在(`detail: "用户不存在"`) +- `422` 缺 `amount_cents`/`reason`、`reason` 长度不在 1–128、`mode` 非 `delta`/`set`、字段类型不合法 / `user_id` 非整数 + +## 说明 +- 金额单位一律为**分**(`*_cents`);本接口只动现金余额,不涉及金币。 +- **set 模式**:读当前余额算出差值 `delta = target - 当前余额`,再复用同一套写入逻辑(故只写一笔差值流水)。目标值须 ≥ 0;差值为 0(已等于目标)直接拒绝。 +- 扣减保护:实际写入的 `delta < 0` 时若扣减后现金余额 < 0 直接拒绝(运营误操作保护);set 模式目标值 ≥ 0 天然不会扣成负。 +- 现金变动写流水 [cash_transaction](../database/cash_transaction.md):`biz_type` 实际差值为正记 `admin_grant`、为负记 `admin_deduct`(set 模式同理,不新增流水类型),`remark = admin:`(截断至 128 字)。 +- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.cash.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount_cents(=实际差值), balance_after_cents, reason}`;set 模式额外带 `{mode:"set", target_cents, before_cents}`。并记录操作 IP。 +- 现金变动 + 审计在同一事务原子提交(改钱必留痕)。 +- 关联用户表 [user](../database/user.md);现金账户 [coin_account](../database/coin_account.md);现金流水 [cash_transaction](../database/cash_transaction.md)。 diff --git a/docs/api/admin-user-coins.md b/docs/api/admin-user-coins.md index c04f5dc..c126afc 100644 --- a/docs/api/admin-user-coins.md +++ b/docs/api/admin-user-coins.md @@ -1,4 +1,4 @@ -# POST /admin/api/users/{user_id}/coins — 手动增减金币(带审计) +# POST /admin/api/users/{user_id}/coins — 手动增减/设值金币(带审计) > 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md) @@ -7,23 +7,26 @@ - **application/json**: | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| -| `amount` | int | ✓ | 金币变动(个数):正=增加,负=扣减;不可为 0 | +| `mode` | string | ✗ | `delta`(默认)=增减 / `set`=设为指定值 | +| `amount` | int | ✓ | `delta` 模式:金币变动(正=增加,负=扣减,不可为 0);`set` 模式:目标金币值(须 ≥ 0) | | `reason` | string | ✓ | 操作原因,1–128 字(必填,入审计与流水备注) | ## 出参 响应 `200`:`OkResponse` = `{ "ok": true }` ## 错误码 -- `400` `amount == 0`(`detail: "amount 不能为 0"`);或负数扣减后余额会变负(`detail: "扣减后金币为负(当前余额 N)"`) +- `400`(`delta`)`amount == 0`(`detail: "amount 不能为 0"`);或负数扣减后余额会变负(`detail: "扣减后金币为负(当前余额 N)"`) +- `400`(`set`)目标值为负(`detail: "目标金币值不能为负"`);或目标值等于当前余额(`detail: "当前金币已为 N,无需调整"`) - `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`) - `403` 角色不足(需 `finance` 或 `super_admin`) - `404` 用户不存在(`detail: "用户不存在"`) -- `422` 缺 `amount`/`reason`、`reason` 长度不在 1–128、字段类型不合法 / `user_id` 非整数 +- `422` 缺 `amount`/`reason`、`reason` 长度不在 1–128、`mode` 非 `delta`/`set`、字段类型不合法 / `user_id` 非整数 ## 说明 - 金币 = 个数(非现金);本接口只动金币余额,不涉及现金(`*_cents`)。 -- 扣减保护:`amount < 0` 时若扣减后金币余额 < 0 直接拒绝(运营误操作保护)。 -- 金币变动写流水 [coin_transaction](../database/coin_transaction.md):`biz_type` 增加为 `admin_grant`、扣减为 `admin_deduct`,`remark = admin:`(截断至 128 字)。 -- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.coins.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount, balance_after, reason}`,并记录操作 IP。 +- **set 模式**:读当前余额算出差值 `delta = target - 当前余额`,再复用同一套写入逻辑(故只写一笔差值流水)。目标值须 ≥ 0;差值为 0(已等于目标)直接拒绝。 +- 扣减保护:实际写入的 `delta < 0` 时若扣减后金币余额 < 0 直接拒绝(运营误操作保护);set 模式目标值 ≥ 0 天然不会扣成负。 +- 金币变动写流水 [coin_transaction](../database/coin_transaction.md):`biz_type` 实际差值为正记 `admin_grant`、为负记 `admin_deduct`(set 模式同理,不新增流水类型),`remark = admin:`(截断至 128 字)。 +- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.coins.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount(=实际差值), balance_after, reason}`;set 模式额外带 `{mode:"set", target, before}`。并记录操作 IP。 - 金币变动 + 审计在同一事务原子提交(改钱必留痕)。 - 关联用户表 [user](../database/user.md);金币账户 [coin_account](../database/coin_account.md);金币流水 [coin_transaction](../database/coin_transaction.md)。 diff --git a/docs/api/admin-users-list.md b/docs/api/admin-users-list.md index 287e057..f4ec2ba 100644 --- a/docs/api/admin-users-list.md +++ b/docs/api/admin-users-list.md @@ -1,4 +1,4 @@ -# GET /admin/api/users — 用户列表(筛选+分页) +# GET /admin/api/users — 用户列表(筛选+排序+分页) > 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](./README.md) @@ -8,8 +8,13 @@ | `phone` | string | ❌ | null | 手机号**前缀**匹配(`phone LIKE '<值>%'`) | | `register_channel` | string | ❌ | null | 注册渠道,精确匹配 | | `status` | string | ❌ | null | 用户状态,精确匹配:`active` / `disabled` / `deleted` | +| `nickname` | string | ❌ | null | 昵称**模糊**匹配(`ILIKE '%<值>%'`) | +| `created_from` / `created_to` | datetime | ❌ | null | 注册时间范围(ISO 8601;按 UTC 比较) | +| `last_login_from` / `last_login_to` | datetime | ❌ | null | 最近登录时间范围(ISO 8601;按 UTC 比较) | +| `sort_by` | string | ❌ | `id` | 排序列:`id` / `created_at` / `last_login_at` | +| `sort_order` | string | ❌ | `desc` | `asc` / `desc` | | `limit` | int | ❌ | 20 | 1–100 | -| `cursor` | int | ❌ | null | 上一页 next_cursor(按 user id 倒序,查 `id < cursor`) | +| `cursor` | int | ❌ | null | 上一页 next_cursor(**offset 偏移量**) | ## 出参 响应 `200`:`{ items: AdminUserListItem[], next_cursor: int|null }`(`next_cursor=null` 表示末页) @@ -28,10 +33,11 @@ ## 错误码 - `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`) -- `422` `limit` 超出 1–100 范围 / 字段类型不合法 +- `422` `limit` 超出 1–100 范围 / `sort_by`·`sort_order` 不在白名单 / 日期格式不合法 / 字段类型不合法 ## 说明 -- 游标分页约定:结果按 user `id` 倒序;`cursor` 传上一页返回的 `next_cursor`;`next_cursor=null` 即末页。 -- `phone` 为前缀匹配(`LIKE '<值>%'`),`register_channel` / `status` 为精确匹配;三者可叠加。 +- **分页为 offset 偏移式**(为支持任意列排序):`cursor` 传上一页返回的 `next_cursor`(实为 offset);`next_cursor=null` 即末页。代价:翻页期间数据变动可能错位一条,admin 低频场景可接受(同提现列表)。 +- 排序:`sort_by` 限 `id`/`created_at`/`last_login_at`,非法值回落 `id`;同值再按 `id` 同向兜底,次序稳定。 +- 筛选:`phone` 前缀、`nickname` 模糊(`ILIKE`)、`register_channel`/`status` 精确、`created_*`/`last_login_*` 时间范围;均可叠加。 - 关联用户表 [user](../database/user.md)。 - 历史明细(金币流水、提现、比价、反馈等)不在本列表,走各自带 `user_id` 过滤的分页接口。 diff --git a/docs/api/admin-wallet-cash-transactions.md b/docs/api/admin-wallet-cash-transactions.md index f5f5da5..e3942be 100644 --- a/docs/api/admin-wallet-cash-transactions.md +++ b/docs/api/admin-wallet-cash-transactions.md @@ -25,7 +25,7 @@ | `biz_type` | string | 业务类型 | | `ref_id` | string \| null | 关联业务 ID | | `remark` | string \| null | 备注 | -| `created_at` | datetime | 创建时间(ISO 8601 UTC) | +| `created_at` | datetime | 创建时间(北京 wall-clock naive,非 UTC;前端按字面显示,不做时区换算) | ## 错误码 - `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`) diff --git a/docs/api/admin-wallet-coin-transactions.md b/docs/api/admin-wallet-coin-transactions.md index e4ca061..fc40802 100644 --- a/docs/api/admin-wallet-coin-transactions.md +++ b/docs/api/admin-wallet-coin-transactions.md @@ -25,7 +25,7 @@ | `biz_type` | string | 业务类型 | | `ref_id` | string \| null | 关联业务 ID | | `remark` | string \| null | 备注 | -| `created_at` | datetime | 创建时间(ISO 8601 UTC) | +| `created_at` | datetime | 创建时间(北京 wall-clock naive,非 UTC;前端按字面显示,不做时区换算) | ## 错误码 - `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`) diff --git a/docs/api/platform-savings-feed.md b/docs/api/platform-savings-feed.md index 321dc69..511e8c4 100644 --- a/docs/api/platform-savings-feed.md +++ b/docs/api/platform-savings-feed.md @@ -2,7 +2,11 @@ > 所属:Platform 组(前缀 `/api/v1/platform`) | 鉴权:**无** | [← 返回 API 索引](./README.md) -客户端首页顶部「用户****xxx 比价后节省 xx 元」滚动条数据源。全平台真实比价记录优先;不足时用运营配的种子([ops_marquee_seed](../database/ops_marquee_seed.md))补齐到 `limit` 条「混播」,保证轮播不空。 +客户端首页顶部「某用户 比价后节省 xx 元」滚动条数据源。全平台真实比价记录优先;不足时用运营配的种子([ops_marquee_seed](../database/ops_marquee_seed.md))补齐到 `limit` 条「混播」,保证轮播不空。 + +**用户标识脱敏规则(全 feed 统一)**:① 有昵称 → 昵称脱敏:≥3 字「首+隐藏字数个星+末」(省钱小能手→`省***手`)、2 字「首+星」(阿强→`阿*`)、1 字「用户+该字」(喵→`用户喵`);② 没昵称 → 按 `user_id` **确定性合成一个假昵称再脱敏**(同用户恒定,避免无昵称用户清一色 `用户****`),少数露「用户」+ 8 星 + id 后 3 位(`用户********618`)。 + +> 合成假名走**本地语料组合生成**(无外部库):中文真名(姓池 ×名字字池 拼「姓+1~2字」) / 中文网络昵称 / 英文昵称 / 英文名(可带数字尾) 混播,中文为主英文为辅,组合空间上万。真实条按 `user_id` 播种 → 同一用户名字恒定、不同用户各异(刷新/翻页不变脸,同屏几乎不撞名);种子/兜底用运行时随机源出多样。当前真实用户大多没设昵称,故走合成。 ## 入参 | 参数 | 类型 | 说明 | @@ -15,15 +19,15 @@ | 字段 | 类型 | 说明 | |---|---|---| | `items` | list | 轮播条目数组(最多 `limit` 条) | -| `items[].masked_user` | string | 脱敏用户名,手机尾号(`138****5678`)/ 中文昵称(`省钱**`)混合风格 | +| `items[].masked_user` | string | 脱敏用户名:昵称/合成名脱敏(`省***手` / `王*` / `SaveK**g`)或匿名 `用户********618` | | `items[].saved_amount_cents` | int | 节省(分),客户端 ÷100 显示「x.xx 元」 | | `items[].time` | string | 北京时间 `HH:MM:SS` | ## 说明 -- 真实条:`comparison_record` 中 `status='success'` 且 `0 < saved_amount_cents ≤ 300 元` 的近期记录(金额超 300 元视为异常 / bug 值剔除,防「节省 999 元」穿帮),**按 `user_id` 去重**(同一用户只取最新一条,避免单人刷屏);用户名按 `user_id` **确定性合成**手机尾号 / 中文昵称混合脱敏名(同用户恒定)。 +- 真实条:`comparison_record` 中 `status='success'` 且 `0 < saved_amount_cents ≤ 300 元` 的近期记录(金额超 300 元视为异常 / bug 值剔除,防「节省 999 元」穿帮),**JOIN `user` 表取昵称 → 仅 user 表内用户的记录入选**(孤儿/已删用户记录不计,设计如此;当前用户量少,缺口由种子补足),**按 `user_id` 去重**(同一用户只取最新一条,避免单人刷屏);有昵称按真实昵称脱敏,无昵称按 `user_id` 播种**确定性合成**脱敏名(同用户恒定、刷新不变脸)。 - 种子条:`ops_marquee_seed`(`enabled=true`),仅在真实去重后不足 `limit` 时补齐;**从启用种子中公平随机抽取**(不再固定取前 N,所有种子都有机会露出)。每条种子:用户名留空或旧 `用户****xxx` 模板名则**随机合成混合风格名并避开同屏撞名**(自愈历史种子),金额在 `[min_cents, max_cents]` 取**长尾随机值**(小额居多、偶尔大额,固定金额则 min==max)。 - 兜底:真实 + 种子仍凑不满 `limit`(种子被全停用 / 太少)时,用**内置合成条补满**,保证轮播既不空也不稀疏。 - **`time` 为合成的「最近」时间**(从当前北京时间往前**随机抖动**递减,首条几十秒前,其余多数 1~5 分钟、偶尔扎堆或较长):社会证明轮播保证永远像刚发生且节奏自然不机械,不受旧测试数据 / 低谷期记录影响。真实的用户/金额不变,只换展示时间。 -- 因含随机(抽取 / 金额 / 合成名),**每次请求结果都不同**——这是轮播想要的鲜活感。 +- 因含随机(种子抽取 / 金额 / 展示时间 / 种子合成名),**每次请求结果都不同**——这是轮播想要的鲜活感;但**真实用户的脱敏名按 `user_id` 恒定**,同一用户跨请求不变脸(避免「同一人换张脸」露馅)。 - **性能**:首页人人都看、真实数据变化慢,真实记录原始行带 **~30s 进程内缓存**(展示层随机仍每次重算,只省 DB 往返;新记录最多晚 30s 进轮播)。查询走复合索引 `ix_comparison_status_created (status, created_at)`,避免随数据量增大全表扫。 - 逻辑见 `app/repositories/ops_marquee.py`;运营管理种子见 [admin-marquee-seeds](./admin-marquee-seeds.md)。 diff --git a/docs/database/ops_marquee_seed.md b/docs/database/ops_marquee_seed.md index 7de9bdb..a001611 100644 --- a/docs/database/ops_marquee_seed.md +++ b/docs/database/ops_marquee_seed.md @@ -8,7 +8,7 @@ | 列 | 类型 | 约束 / 默认 | 说明 | |---|---|---|---| | `id` | Integer | PK, autoincrement | | -| `masked_user` | String(64) | NULL 可空 | 脱敏用户名,手机尾号(`138****5678`)/ 中文昵称(`省钱**`)风格(整串存)。**留空或旧 `用户****xxx` 模板名 → feed 展示时随机合成混合风格名**(避开同屏撞名、自愈历史种子),风格与真实条一致、永不穿帮 | +| `masked_user` | String(64) | NULL 可空 | 脱敏用户名(整串存)。**留空或旧 `用户****xxx` 模板名 → feed 展示时按脱敏规则随机合成**(本地姓/名字池组合的真名 + 中英网络昵称语料,首末字+星;避开同屏撞名、自愈历史种子),风格与真实条一致、永不穿帮。详见 [platform-savings-feed](../api/platform-savings-feed.md) | | `min_cents` | Integer | NOT NULL | 节省金额区间下限(分) | | `max_cents` | Integer | NOT NULL | 节省金额区间上限(分);feed 每次在 `[min,max]` 随机取值,**固定金额则 min==max** | | `enabled` | Boolean | NOT NULL, default true | 停用的不参与混播 | diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index 4111d50..90005ad 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -91,6 +91,80 @@ def test_user_filter_by_status(admin_client: TestClient, admin_token: str) -> No assert all(u["status"] == "active" for u in r.json()["items"]) +def test_user_list_sort_filter_range(admin_client: TestClient, admin_token: str) -> None: + """用户列表:渠道/昵称筛选 + id 升降序 + 时间范围 + 非法 sort_by 422。""" + db = SessionLocal() + try: + u1 = user_repo.upsert_user_for_login(db, phone="13811110001", register_channel="sms") + u1.nickname = "排序测试甲" + u2 = user_repo.upsert_user_for_login(db, phone="13811110002", register_channel="wechat") + u2.nickname = "排序测试乙" + db.commit() + id1, id2 = u1.id, u2.id + finally: + db.close() + + # 渠道精确筛选 + r = admin_client.get( + "/admin/api/users", params={"register_channel": "wechat"}, headers=_auth(admin_token) + ) + assert r.status_code == 200, r.text + assert all(u["register_channel"] == "wechat" for u in r.json()["items"]) + + # 昵称模糊筛选 + r = admin_client.get( + "/admin/api/users", params={"nickname": "排序测试", "limit": 100}, headers=_auth(admin_token) + ) + ids = {u["id"] for u in r.json()["items"]} + assert id1 in ids and id2 in ids + + # id 升序 / 降序 + asc_ids = [ + u["id"] + for u in admin_client.get( + "/admin/api/users", + params={"sort_by": "id", "sort_order": "asc", "limit": 100}, + headers=_auth(admin_token), + ).json()["items"] + ] + assert asc_ids == sorted(asc_ids) + desc_ids = [ + u["id"] + for u in admin_client.get( + "/admin/api/users", + params={"sort_by": "id", "sort_order": "desc", "limit": 100}, + headers=_auth(admin_token), + ).json()["items"] + ] + assert desc_ids == sorted(desc_ids, reverse=True) + + # 时间范围:2000 年之前无人;之后有人(验证范围条件确实生效) + assert ( + admin_client.get( + "/admin/api/users", params={"created_to": "2000-01-01T00:00:00Z"}, + headers=_auth(admin_token), + ).json()["items"] + == [] + ) + assert ( + len( + admin_client.get( + "/admin/api/users", params={"created_from": "2000-01-01T00:00:00Z", "limit": 100}, + headers=_auth(admin_token), + ).json()["items"] + ) + >= 1 + ) + + # 非法 sort_by → 422 + assert ( + admin_client.get( + "/admin/api/users", params={"sort_by": "phone"}, headers=_auth(admin_token) + ).status_code + == 422 + ) + + def test_wallet_and_withdraw_lists(admin_client: TestClient, admin_token: str) -> None: uid = _seed_user_with_data("13800000004") r = admin_client.get( @@ -112,6 +186,65 @@ def test_feedback_list(admin_client: TestClient, admin_token: str) -> None: assert all(f["status"] == "new" for f in r.json()["items"]) +def test_ad_coin_audit_full_count_truncate_and_only_mismatch( + admin_client: TestClient, admin_token: str +) -> None: + """A+B:total/mismatch_count 按全量统计(不受 limit 影响),truncated 旗标 + only_mismatch 过滤。 + + 用 capped 行造确定性数据(应发恒 0,coin==0 即一致),不依赖发奖公式: + 3 条 coin=0(一致) + 2 条 coin=7(不一致) → 全量 total=5、mismatch=2。 + """ + from app.models.ad_reward import AdRewardRecord + + d = "2020-01-15" # 固定历史日 + 独立 user,隔离其它用例数据 + db = SessionLocal() + try: + uid = user_repo.upsert_user_for_login(db, phone="13800009999", register_channel="sms").id + for i in range(3): + db.add(AdRewardRecord( + trans_id=f"adaudit-ok-{i}", user_id=uid, coin=0, status="capped", + reward_scene="reward_video", reward_date=d, + )) + for i in range(2): + db.add(AdRewardRecord( + trans_id=f"adaudit-bad-{i}", user_id=uid, coin=7, status="capped", + reward_scene="reward_video", reward_date=d, + )) + db.commit() + finally: + db.close() + + base = {"date": d, "user_id": uid} + + # 全量:total=5、mismatch=2、不截断、返回 5 条 + body = admin_client.get( + "/admin/api/ad-coin-audit", params={**base, "limit": 100}, headers=_auth(admin_token) + ).json() + assert body["total"] == 5 + assert body["mismatch_count"] == 2 + assert body["truncated"] is False + assert len(body["items"]) == 5 + + # 截断:limit=2 → 统计仍全量、truncated=True、只回 2 条 + body = admin_client.get( + "/admin/api/ad-coin-audit", params={**base, "limit": 2}, headers=_auth(admin_token) + ).json() + assert body["total"] == 5 and body["mismatch_count"] == 2 + assert body["truncated"] is True + assert len(body["items"]) == 2 + + # only_mismatch:只回 ✗ 行(2 条),统计仍全量、不截断 + body = admin_client.get( + "/admin/api/ad-coin-audit", + params={**base, "limit": 100, "only_mismatch": True}, + headers=_auth(admin_token), + ).json() + assert body["total"] == 5 and body["mismatch_count"] == 2 + assert body["truncated"] is False + assert len(body["items"]) == 2 + assert all(it["matched"] is False for it in body["items"]) + + def test_read_apis_require_auth(admin_client: TestClient) -> None: """所有 M2 读接口未带 token → 401(router 级 get_current_admin 守卫)。""" for path in [ diff --git a/tests/test_admin_write.py b/tests/test_admin_write.py index 67bc719..bd8cd8e 100644 --- a/tests/test_admin_write.py +++ b/tests/test_admin_write.py @@ -136,6 +136,102 @@ def test_grant_zero_rejected(admin_client: TestClient, finance_token: str) -> No assert r.status_code == 400 +def test_set_coins_writes_delta_txn(admin_client: TestClient, finance_token: str) -> None: + """set 模式:先有余额,再设为目标值,只写一笔差值流水,审计带 mode/target/before。""" + uid = _seed_user("13900000009") + # 先增到 300 + admin_client.post( + f"/admin/api/users/{uid}/coins", json={"amount": 300, "reason": "底"}, + headers=_auth(finance_token), + ) + # 设为 100 → 差值 -200(扣减) + r = admin_client.post( + f"/admin/api/users/{uid}/coins", json={"mode": "set", "amount": 100, "reason": "设值"}, + headers=_auth(finance_token), + ) + assert r.status_code == 200, r.text + db = SessionLocal() + try: + assert db.get(CoinAccount, uid).coin_balance == 100 + txns = db.execute( + select(CoinTransaction).where(CoinTransaction.user_id == uid) + .order_by(CoinTransaction.id.desc()) + ).scalars().all() + # 两笔:+300(admin_grant)、-200(admin_deduct) + assert txns[0].amount == -200 and txns[0].biz_type == "admin_deduct" + logs = db.execute( + select(AdminAuditLog).where( + AdminAuditLog.action == "user.coins.grant", AdminAuditLog.target_id == str(uid) + ).order_by(AdminAuditLog.id.desc()) + ).scalars().all() + assert logs[0].detail["mode"] == "set" + assert logs[0].detail["target"] == 100 + assert logs[0].detail["before"] == 300 + assert logs[0].detail["amount"] == -200 + finally: + db.close() + + +def test_set_coins_equal_balance_rejected(admin_client: TestClient, finance_token: str) -> None: + """set 为当前余额(差值 0)→ 拒绝,不写流水。""" + uid = _seed_user("13900000010") + admin_client.post( + f"/admin/api/users/{uid}/coins", json={"amount": 50, "reason": "底"}, + headers=_auth(finance_token), + ) + r = admin_client.post( + f"/admin/api/users/{uid}/coins", json={"mode": "set", "amount": 50, "reason": "x"}, + headers=_auth(finance_token), + ) + assert r.status_code == 400 + db = SessionLocal() + try: + txns = db.execute( + select(CoinTransaction).where(CoinTransaction.user_id == uid) + ).scalars().all() + assert len(txns) == 1 # 仅初始那笔,设值被拒后无新流水 + finally: + db.close() + + +def test_set_coins_negative_target_rejected(admin_client: TestClient, finance_token: str) -> None: + uid = _seed_user("13900000011") + r = admin_client.post( + f"/admin/api/users/{uid}/coins", json={"mode": "set", "amount": -1, "reason": "x"}, + headers=_auth(finance_token), + ) + assert r.status_code == 400 + + +def test_set_cash_writes_delta_txn(admin_client: TestClient, finance_token: str) -> None: + """现金 set 模式:设为目标分值,写一笔差值流水。""" + uid = _seed_user("13900000012") + admin_client.post( + f"/admin/api/users/{uid}/cash", json={"amount_cents": 500, "reason": "底"}, + headers=_auth(finance_token), + ) + r = admin_client.post( + f"/admin/api/users/{uid}/cash", + json={"mode": "set", "amount_cents": 200, "reason": "设值"}, + headers=_auth(finance_token), + ) + assert r.status_code == 200, r.text + db = SessionLocal() + try: + assert db.get(CoinAccount, uid).cash_balance_cents == 200 + logs = db.execute( + select(AdminAuditLog).where( + AdminAuditLog.action == "user.cash.grant", AdminAuditLog.target_id == str(uid) + ).order_by(AdminAuditLog.id.desc()) + ).scalars().all() + assert logs[0].detail["mode"] == "set" + assert logs[0].detail["target_cents"] == 200 + assert logs[0].detail["before_cents"] == 500 + assert logs[0].detail["amount_cents"] == -300 + finally: + db.close() + + # ===== 封号 ===== def test_set_user_status_and_audit(admin_client: TestClient, operator_token: str) -> None: