Compare commits

...

3 Commits

Author SHA1 Message Date
OuYingJun1024 ea27fe0c01 feat(admin/users): 用户列表支持列排序 + 渠道/昵称/时间范围筛选
list_users 由 keyset 游标改为 offset 分页以支持任意列排序:
- 排序:sort_by ∈ {id, created_at, last_login_at} + sort_order(白名单,非法回落 id;同值 id 同向兜底)
- 筛选:新增 nickname 模糊、注册/最近登录时间范围(register_channel 本就支持,这次在 UI/文档补齐)
- 文档 admin-users-list.md 与 test_admin_read 同步(排序/渠道/昵称/时间范围/非法 sort_by 422)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:41:10 +08:00
OuYingJun1024 995908cd0b feat(rewards/admin): LT 因子改账号累计 + 审计 only_mismatch + 用户金币现金 set 模式
- 看视频奖励 LT 因子(因子2)按「账号累计第 N 次看视频」递减,不再按天重置:
  ad_reward 新增 _granted_cumulative、ad_feed_reward 单位序号去掉当天过滤、
  rewards 形参 today_count_after_this → count_after_this;每日次数上限/冷却仍按当日统计
- 广告金币审计:加 only_mismatch 筛选只看 ✗ 行;total/mismatch_count 改全量统计
  (不受 limit/筛选影响、截断前算)+ 新增 truncated 标记展示集是否被截断
- admin 用户金币/现金接口加 mode=delta|set:set=设为目标值(读余额算差值、仍写一笔流水,
  沿用原子/审计/扣负保护);新增 admin-user-cash 文档 + 更新 API 索引/coins 等文档 + 补 admin read/write 测试
2026-06-13 21:26:11 +08:00
OuYingJun1024 fb661814cb fix(marquee): 首页轮播脱敏名改为按 user_id 恒定 + 去 Faker 依赖
- _mask_real 无昵称分支用 random.Random(user_id) 播种合成假名 → 同一用户每次展示恒定
  (刷新/翻页不变脸)、不同用户各异;修复原全局 _rng 导致同一用户每请求换名(像假数据)的问题
- 去掉 faker 依赖,改用本地姓池(~100)×名字字池(~120)组合真名 + 中英网络昵称语料,组合空间上万、
  同屏几乎不撞名;_synth_full_name/_synth_masked_name 串入 rng 参数,种子/兜底仍用全局 _rng 出多样
- 同步 docs/api/platform-savings-feed.md、docs/database/ops_marquee_seed.md 脱敏描述
  (去手机尾号/Faker 表述,补 user_id 恒定 + JOIN user 仅活跃用户记录说明)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:27:05 +08:00
21 changed files with 673 additions and 162 deletions
+75 -15
View File
@@ -1,16 +1,18 @@
"""看广告金币审计:复算 expected_coin 并与实发对比。 """看广告金币审计:复算 expected_coin 并与实发对比。
只读。复用 [app.core.rewards] 的公式函数(不另写公式,避免与正式发奖口径漂移): 只读。复用 [app.core.rewards] 的公式函数(不另写公式,避免与正式发奖口径漂移):
- 看视频:每条 granted = 1 份,第 N 份 = 当日该用户 granted 的 reward_video 顺序号 - 看视频:每条 granted = 1 份,第 N 份 = 该用户 granted 的 reward_video **账号累计**顺序号
(与 ad_reward.grant_ad_reward 里 `_granted_today + 1` 一致)。 (与 ad_reward.grant_ad_reward 里 `_granted_cumulative + 1` 一致;LT 因子不按天重置,
- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 当日该用户已 granted 份数累计 故复算时要把当日序号叠加上该用户在本日**之前**的累计已发份数)。
(与 ad_feed_reward._unit_reward_total 的 existing_units 一致)。 - 信息流:每条按 unit_count 份逐份累加,LT 序号 = 该用户 granted 份数**账号累计**
(与 ad_feed_reward._unit_reward_total 的 existing_units 一致;同样不按天重置,
复算需叠加本日之前的累计份数)。
非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。 非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。
""" """
from __future__ import annotations from __future__ import annotations
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core import rewards 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 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( def _reward_video_rows(
db: Session, *, date: str, user_id: int | None db: Session, *, date: str, user_id: int | None
) -> list[dict]: ) -> list[dict]:
"""看视频记录复算。按 (user_id, created_at) 升序还原当日第 N 份。""" """看视频记录复算。按 (user_id, created_at) 升序还原账号累计第 N 份(含本日之前的累计)"""
stmt = ( stmt = (
select(AdRewardRecord) select(AdRewardRecord)
.where( .where(
@@ -34,7 +55,8 @@ def _reward_video_rows(
if user_id is not None: if user_id is not None:
stmt = stmt.where(AdRewardRecord.user_id == user_id) 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] = [] rows: list[dict] = []
for rec in db.execute(stmt).scalars(): for rec in db.execute(stmt).scalars():
if rec.status == "granted": if rec.status == "granted":
@@ -80,8 +102,28 @@ def _reward_video_rows(
return 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]: def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
"""信息流记录复算。granted 记录逐份累加,LT 序号沿用当日累计份数。""" """信息流记录复算。granted 记录逐份累加,LT 序号沿用账号累计份数(含本日之前)"""
stmt = ( stmt = (
select(AdFeedRewardRecord) select(AdFeedRewardRecord)
.where(AdFeedRewardRecord.reward_date == date) .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: if user_id is not None:
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id) 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] = [] rows: list[dict] = []
for rec in db.execute(stmt).scalars(): for rec in db.execute(stmt).scalars():
if rec.status == "granted": 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( def ad_coin_audit(
db: Session, *, date: str, user_id: int | None, scene: str | None, limit: int db: Session,
) -> list[dict]: *,
"""返回当日发奖复算明细,按 created_at 倒序(最新在前)截断到 limit。 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" scene: None=两类都要 / "reward_video" / "feed";only_mismatch=True 只展示不一致(✗)行
份序号在截断前已基于全天数据算好,故 limit 只影响展示条数、不影响 expected 复算正确性。 关键:`total` 与 `mismatch_count` 在**全量**(截断前)上统计,故对账数字始终可信,不受 limit
影响;`items` 才是展示集(only_mismatch 时只取 ✗ 行)按 created_at 倒序截断到 limit。
份序号在全天数据上已算好,limit 只影响展示条数、不影响 expected 复算正确性。
""" """
rows: list[dict] = [] rows: list[dict] = []
if scene in (None, "reward_video"): if scene in (None, "reward_video"):
@@ -155,7 +206,16 @@ def ad_coin_audit(
if scene in (None, "feed"): if scene in (None, "feed"):
rows.extend(_feed_rows(db, date=date, user_id=user_id)) rows.extend(_feed_rows(db, date=date, user_id=user_id))
rows.sort(key=lambda r: r["created_at"], reverse=True) 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: def formula_snapshot() -> dict:
+38 -1
View File
@@ -44,9 +44,20 @@ def list_users(
phone: str | None = None, phone: str | None = None,
register_channel: str | None = None, register_channel: str | None = None,
status: 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, limit: int = 20,
cursor: int | None = None, cursor: int | None = None,
) -> tuple[list[User], int | 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) stmt = select(User)
if phone: if phone:
stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配 stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配
@@ -54,7 +65,33 @@ def list_users(
stmt = stmt.where(User.register_channel == register_channel) stmt = stmt.where(User.register_channel == register_channel)
if status: if status:
stmt = stmt.where(User.status == 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]: def list_onboarding_devices(db: Session, *, limit: int = 500) -> list[dict]:
+9 -5
View File
@@ -29,16 +29,20 @@ def get_ad_coin_audit(
str | None, Query(description="reward_video / feed;不传=两类都要") str | None, Query(description="reward_video / feed;不传=两类都要")
] = None, ] = None,
limit: Annotated[int, Query(ge=1, le=500)] = 100, limit: Annotated[int, Query(ge=1, le=500)] = 100,
only_mismatch: Annotated[
bool, Query(description="只看不一致(✗)行;统计数仍按全量,不受影响")
] = False,
) -> AdCoinAuditOut: ) -> AdCoinAuditOut:
audit_date = date or cn_today().isoformat() 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, 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( return AdCoinAuditOut(
date=audit_date, date=audit_date,
formula=AdCoinFormulaOut(**ad_audit.formula_snapshot()), formula=AdCoinFormulaOut(**ad_audit.formula_snapshot()),
total=len(items), total=result["total"],
mismatch_count=sum(1 for it in items if not it.matched), mismatch_count=result["mismatch_count"],
items=items, truncated=result["truncated"],
items=[AdCoinAuditRow(**r) for r in result["items"]],
) )
+70 -33
View File
@@ -1,6 +1,7 @@
"""admin 用户管理:列表 + 360 详情(读)+ 封禁/解封 + 手动调金币(写,带审计)。""" """admin 用户管理:列表 + 360 详情(读)+ 封禁/解封 + 手动调金币(写,带审计)。"""
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request 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( def list_users(
db: AdminDb, db: AdminDb,
phone: Annotated[str | None, Query()] = None, phone: Annotated[str | None, Query()] = None,
register_channel: Annotated[str | None, Query()] = None, register_channel: Annotated[str | None, Query()] = None,
status: 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, limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None, cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[AdminUserListItem]: ) -> CursorPage[AdminUserListItem]:
items, next_cursor = queries.list_users( items, next_cursor = queries.list_users(
db, phone=phone, register_channel=register_channel, status=status, 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( return CursorPage(
items=[AdminUserListItem.model_validate(u) for u in items], items=[AdminUserListItem.model_validate(u) for u in items],
@@ -101,7 +111,7 @@ def set_user_debug_trace(
return OkResponse() return OkResponse()
@router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减金币(带审计)") @router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减/设值金币(带审计)")
def grant_user_coins( def grant_user_coins(
user_id: int, user_id: int,
body: GrantCoinsRequest, body: GrantCoinsRequest,
@@ -109,33 +119,45 @@ def grant_user_coins(
admin: Annotated[AdminUser, Depends(require_role("finance"))], admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb, db: AdminDb,
) -> OkResponse: ) -> OkResponse:
if body.amount == 0:
raise HTTPException(status_code=400, detail="amount 不能为 0")
user = user_repo.get_user_by_id(db, user_id) user = user_repo.get_user_by_id(db, user_id)
if user is None: if user is None:
raise HTTPException(status_code=404, detail="用户不存在") raise HTTPException(status_code=404, detail="用户不存在")
# 负数扣减时不允许扣成负余额(运营误操作保护) # set=设为目标值:读当前余额算出要写的差值,仍复用 grant_coins 写一笔流水(沿用原子/审计/扣负保护)
if body.amount < 0: if body.mode == "set":
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False) if body.amount < 0:
if acc_now.coin_balance + body.amount < 0: raise HTTPException(status_code=400, detail="目标金币值不能为负")
raise HTTPException( before = wallet_repo.get_or_create_account(db, user_id, commit=False).coin_balance
status_code=400, detail=f"扣减后金币为负(当前余额 {acc_now.coin_balance})" delta = body.amount - before
) if delta == 0:
biz_type = "admin_grant" if body.amount > 0 else "admin_deduct" 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 → 原子(改钱+留痕) # grant_coins 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
acc, _ = wallet_repo.grant_coins( 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( write_audit(
db, admin, action="user.coins.grant", target_type="user", target_id=user_id, 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}, detail=detail, ip=get_client_ip(request), commit=False,
ip=get_client_ip(request), commit=False,
) )
db.commit() db.commit()
return OkResponse() return OkResponse()
@router.post("/{user_id}/cash", response_model=OkResponse, summary="手动增减现金(带审计)") @router.post("/{user_id}/cash", response_model=OkResponse, summary="手动增减/设值现金(带审计)")
def grant_user_cash( def grant_user_cash(
user_id: int, user_id: int,
body: GrantCashRequest, body: GrantCashRequest,
@@ -143,32 +165,47 @@ def grant_user_cash(
admin: Annotated[AdminUser, Depends(require_role("finance"))], admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb, db: AdminDb,
) -> OkResponse: ) -> OkResponse:
"""给指定用户增/减现金(分)。正=发放、负=扣减;主要用于让无现金用户直接测试提现。""" """给指定用户增/减或设值现金(分)。delta:正=发放、负=扣减;set:直接设为目标值。
if body.amount_cents == 0: 主要用于让无现金用户直接测试提现。"""
raise HTTPException(status_code=400, detail="amount_cents 不能为 0")
user = user_repo.get_user_by_id(db, user_id) user = user_repo.get_user_by_id(db, user_id)
if user is None: if user is None:
raise HTTPException(status_code=404, detail="用户不存在") raise HTTPException(status_code=404, detail="用户不存在")
# 负数扣减时不允许扣成负余额(运营误操作保护) # set=设为目标值:读当前余额算差值,仍复用 grant_cash 写一笔流水(沿用原子/审计/扣负保护)
if body.amount_cents < 0: if body.mode == "set":
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False) if body.amount_cents < 0:
if acc_now.cash_balance_cents + 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( 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 → 原子(改钱+留痕) # grant_cash 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
acc, _ = wallet_repo.grant_cash( 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( write_audit(
db, admin, action="user.cash.grant", target_type="user", target_id=user_id, db, admin, action="user.cash.grant", target_type="user", target_id=user_id,
detail={ detail=detail, ip=get_client_ip(request), commit=False,
"amount_cents": body.amount_cents,
"balance_after_cents": acc.cash_balance_cents,
"reason": body.reason,
},
ip=get_client_ip(request), commit=False,
) )
db.commit() db.commit()
return OkResponse() return OkResponse()
+9 -4
View File
@@ -48,10 +48,15 @@ class AdCoinFormulaOut(BaseModel):
class AdCoinAuditOut(BaseModel): class AdCoinAuditOut(BaseModel):
"""审计响应:公式参照 + 命中条数 + 明细。""" """审计响应:公式参照 + 全量统计 + 明细。"""
date: str = Field(..., description="审计日期(北京时间 YYYY-MM-DD)") date: str = Field(..., description="审计日期(北京时间 YYYY-MM-DD)")
formula: AdCoinFormulaOut formula: AdCoinFormulaOut
total: int = Field(..., description="返回的明细条数") total: int = Field(..., description="该筛选下复算总条数(全量,不受 limit/only_mismatch 影响)")
mismatch_count: int = Field(..., description="其中 matched=false 的条数(=0 说明公式全部生效)") mismatch_count: int = Field(
items: list[AdCoinAuditRow] ..., description="全量不一致条数(=0 说明公式全部生效;在截断前统计,可信)"
)
truncated: bool = Field(
..., description="展示集是否被 limit 截断(true=还有未返回的明细,请缩小范围或调大 limit)"
)
items: list[AdCoinAuditRow] = Field(..., description="展示明细;only_mismatch=true 时只含 ✗ 行")
+14 -2
View File
@@ -38,12 +38,24 @@ class AdminUserOverview(BaseModel):
class GrantCoinsRequest(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="操作原因(必填,入审计)") reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)")
class GrantCashRequest(BaseModel): 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="操作原因(必填,入审计)") reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)")
+6 -6
View File
@@ -172,24 +172,24 @@ def ad_ecpm_factor(ecpm_yuan: float) -> float:
return 0.1 return 0.1
def ad_lt_factor(today_count_after_this: int) -> float: def ad_lt_factor(count_after_this: int) -> float:
"""LT 因子。today_count_after_this 是当天累计第 N 条/份广告奖励""" """LT 因子。count_after_this 是该账号累计第 N 条/份看视频奖励(不按天重置)"""
count = max(1, today_count_after_this) count = max(1, count_after_this)
for factor, lo, hi in AD_LT_FACTOR_TABLE: for factor, lo, hi in AD_LT_FACTOR_TABLE:
if count >= lo and (hi is None or count <= hi): if count >= lo and (hi is None or count <= hi):
return factor return factor
return 1.0 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 是穿山甲 getEcpm 原值,单位【分/千次展示】;先 ÷100 转成元(因子判档 + 收益换算都用元)。
单次收益(元)= eCPM元 ÷ 1000(每千次→单次) × 因子1(eCPM 元档) × 因子2(LT); 单次收益(元)= eCPM元 ÷ 1000(每千次→单次) × 因子1(eCPM 元档) × 因子2(LT);
再按 1 元=10000 金币取整。 再按 1 元=10000 金币取整。count_after_this 为账号累计第 N 次看视频(LT 因子用,不按天重置)。
""" """
ecpm_yuan = parse_ecpm_yuan(ecpm) 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)) return max(0, round(yuan * COIN_PER_YUAN))
+3 -4
View File
@@ -38,15 +38,14 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
).scalar_one() ).scalar_one()
def _unit_reward_total(db: Session, user_id: int, ecpm: str, unit_count: int, today: str) -> int: def _unit_reward_total(db: Session, user_id: int, ecpm: str, unit_count: int) -> int:
"""按每个 10 秒单位逐份计算奖励,LT 使用当天累计奖励份序号""" """按每个 10 秒单位逐份计算奖励,LT 使用**账号累计**奖励份序号(不按天重置)"""
if unit_count <= 0: if unit_count <= 0:
return 0 return 0
existing_units = db.execute( existing_units = db.execute(
select(func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0)) select(func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0))
.where( .where(
AdFeedRewardRecord.user_id == user_id, AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.reward_date == today,
AdFeedRewardRecord.status == "granted", AdFeedRewardRecord.status == "granted",
) )
).scalar_one() ).scalar_one()
@@ -92,7 +91,7 @@ def grant_feed_reward(
) )
return _commit_record(db, rec, client_event_id) 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: if coin > 0:
crud_wallet.grant_coins( crud_wallet.grant_coins(
db, user_id, coin, db, user_id, coin,
+17 -1
View File
@@ -54,6 +54,20 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
).scalar_one() ).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( def grant_ad_reward(
db: Session, db: Session,
user_id: int, user_id: int,
@@ -104,7 +118,9 @@ def grant_ad_reward(
) )
return _commit_record(db, rec, trans_id) 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( crud_wallet.grant_coins(
+110 -48
View File
@@ -6,9 +6,16 @@ feed 取最近的成功且省到钱的比价记录(脱敏用户名);不足 N 条
种子是「生成规则」:用户名可空(空→随机合成脱敏名,避开同屏撞名)、金额是区间(每次随机取值)、 种子是「生成规则」:用户名可空(空→随机合成脱敏名,避开同屏撞名)、金额是区间(每次随机取值)、
feed 公平随机抽取(不看 sort_order),所有启用种子都有机会露出。 feed 公平随机抽取(不看 sort_order),所有启用种子都有机会露出。
脱敏名风格:手机尾号(138****5678)+ 中文昵称(省钱**、小张**、吃货**达人)混合,看起来像真实 脱敏名规则(全 feed 统一,按字符算、中英文皆适用):
异质用户群。真实条按 user_id 确定性合成(同用户恒定);种子留空 / 旧「用户****xxx」模板名一律随机 - 有昵称 → 昵称脱敏:≥3 字「首+隐藏字数个星+末」(省钱小能手→省***手 / SaveKing→S******g),
合成(自愈历史种子,无需迁移)。 2 字「首+星」(阿强→阿*),1 字「用户+该字」(喵→用户喵);
- 没昵称 → 合成一个假名(见下)再脱敏,少数露「用户+id 后 3 位」(用户********618)。
真实条:设过昵称的按真实昵称脱敏;当前多数用户没设昵称,直接展示「用户****」会清一色,故给它们合成
假名——后期真实昵称多了,真实条会直接用真实昵称,合成占比自然下降。种子 / 兜底同样合成、避开同屏撞名;
种子留空 / 旧「用户****xxx」模板名一律重新合成(自愈历史种子,无需迁移)。
假名合成:纯本地语料**组合生成**(中文姓池×名字池 拼真名 / 中文网络昵称 / 英文昵称 / 英文名带数字尾),
组合空间上万、中文为主英文为辅,脱敏后像真实异质用户群。**真实条按 `user_id` 播种确定性合成**(同一用户
每次展示恒定、不同用户各异),刷新/翻页不变脸;种子 / 兜底用运行时随机源出多样填充。不依赖外部库。
""" """
from __future__ import annotations from __future__ import annotations
@@ -21,6 +28,7 @@ from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ from app.core.rewards import CN_TZ
from app.models.comparison import ComparisonRecord from app.models.comparison import ComparisonRecord
from app.models.ops_marquee_seed import OpsMarqueeSeed from app.models.ops_marquee_seed import OpsMarqueeSeed
from app.models.user import User
# feed 运行时随机源(每次请求结果不同 = 轮播想要的「鲜活感」) # feed 运行时随机源(每次请求结果不同 = 轮播想要的「鲜活感」)
_rng = random.Random() _rng = random.Random()
@@ -36,59 +44,111 @@ _REAL_ROWS_TTL_SECONDS = 30
_REAL_ROWS_FETCH_CAP = 600 # 一次多取些,够 limit≤30 去重后取数;命中缓存后复用 _REAL_ROWS_FETCH_CAP = 600 # 一次多取些,够 limit≤30 去重后取数;命中缓存后复用
_real_rows_cache: dict = {"at": None, "rows": None} _real_rows_cache: dict = {"at": None, "rows": None}
# ===== 脱敏名生成:手机尾号 + 中文昵称 混合,看起来像真实异质用户群(替代旧的「用户********xxx」统一模板)===== # ===== 用户标识脱敏 + 没真实昵称时的假名合成 =====
_PHONE_2ND = "3456789" # 手机号第 2 位(13x~19x) # 脱敏规则(中英文皆适用,按字符算):有昵称→≥3 字「首+隐藏字数个星+末」(省钱小能手→省***手 /
_SURNAMES = "王李张刘陈杨黄赵周吴徐孙马朱胡郭何高林罗郑梁谢宋唐许韩冯邓曹彭曾肖田董袁潘蒋蔡余杜叶程苏魏吕丁任沈姚卢" # SaveKing→S******g)、2 字「首+星」(阿强→阿*)、1 字「用户+该字」(喵→用户喵);没昵称→用户+8星+id 后 3 位。
_NICK_WORDS = [ # 没真实昵称时(当前多数用户 / 种子 / 兜底)合成假名:本地姓/名字池组合出真名 + 网络昵称语料(中/英)
"省钱", "吃货", "羊毛", "薅羊毛", "爱比价", "精打细算", "持家", "干饭", # 混播再走同一套脱敏,像真实异质用户群。后期真实昵称多了,真实条直接用真实昵称,这里占比自然下降。
"美食控", "外卖", "剁手", "捡漏", "搞钱", "打工人", "摸鱼", "养生", _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: def _mask_nickname(nick: str) -> str:
"""手机尾号式:1[3-9]X****XXXX(合成、非真实号)。""" """昵称脱敏(按字符,中英文皆可):≥3 字→首+(隐藏字数个)星+末(省钱小能手→省***手 / SaveKing→S******g);
head = "1" + rng.choice(_PHONE_2ND) + str(rng.randint(0, 9)) 2 字→首+星(阿强→阿*);1 字→「用户」+该字(喵→用户喵)。调用方需保证 nick 已 strip 且非空。"""
return f"{head}****{rng.randint(0, 9999):04d}" 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: def _anon_by_id(user_id: int) -> str:
"""昵称打码式,中文为主(约 11/12),掺少量中英混 / 纯字母。 """昵称匿名:固定「用户」+ 8 星 + id 后 3 位(不足 3 位前补 0):用户********618。"""
⚠️ 脱敏星号一律夹在中间(xx**yy),星号前后都要有可见字——不出现结尾打码(省钱**)的形态。 return "用户" + _ANON_STARS + f"{user_id % 1000:03d}"
"""
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 _realistic_name(rng: random.Random) -> str: def _synth_full_name(rng: random.Random) -> str:
"""~45% 手机尾号 + ~55% 中文昵称,混合出真实异质感。""" """合成一个完整(未脱敏)假名:中文真名(姓+1~2字) / 中文网络昵称 / 英文昵称 / 英文名(可带数字尾) 混播,
return _phone_name(rng) if rng.random() < 0.45 else _nickname(rng) 中文为主(~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: def _synth_masked_name(rng: random.Random) -> str:
"""真实用户脱敏:按 user_id 确定性合成一个名(同用户恒定、不暴露真实信息)""" """合成一条「已脱敏」假名(供没真实昵称的真实用户 / 种子 / 兜底用)。用传入 rng 决定一切随机"""
return _realistic_name(random.Random(user_id)) 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: def _unique_name(used: set[str]) -> str:
"""随机合成一个不与 used 撞的脱敏名(种子留空 / 旧模板名用),防同屏撞名。""" """随机合成一个不与 used 撞的脱敏名(种子留空 / 旧模板名 / 兜底用),防同屏撞名。"""
for _ in range(60): for _ in range(60):
n = _realistic_name(_rng) n = _synth_name(_rng)
if n not in used: if n not in used:
return n 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: def _validate_amount(min_cents: int, max_cents: int) -> None:
@@ -116,8 +176,9 @@ _FALLBACK_MAX_CENTS = 3500
# ===== 用户侧:轮播 feed(真实 + 种子混播) ===== # ===== 用户侧:轮播 feed(真实 + 种子混播) =====
def _recent_real_rows(db: Session) -> list[tuple[int, int]]: def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]:
"""近期 success 且省到钱的 (user_id, saved_cents),按 created_at desc;带 ~30s 进程内缓存。 """近期 success 且省到钱的 (user_id, saved_cents, nickname),按 created_at desc;带 ~30s 进程内缓存。
join user 取真实昵称用于脱敏(无昵称则展示层走「用户+id 后 3 位」)。
返回纯元组(脱离 session),可安全跨请求复用。极端并发下偶尔多查一次(无锁、幂等),纯门面无副作用。 返回纯元组(脱离 session),可安全跨请求复用。极端并发下偶尔多查一次(无锁、幂等),纯门面无副作用。
""" """
now = datetime.now(CN_TZ) 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: if cached is not None and at is not None and (now - at).total_seconds() < _REAL_ROWS_TTL_SECONDS:
return cached return cached
rows = db.execute( 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( .where(
ComparisonRecord.status == "success", ComparisonRecord.status == "success",
ComparisonRecord.saved_amount_cents > 0, 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()) .order_by(ComparisonRecord.created_at.desc())
.limit(_REAL_ROWS_FETCH_CAP) .limit(_REAL_ROWS_FETCH_CAP)
).all() ).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 _real_rows_cache["rows"], _real_rows_cache["at"] = out, now
return out return out
@@ -155,12 +217,12 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]:
items: list[dict] = [] items: list[dict] = []
used_names: set[str] = set() used_names: set[str] = set()
seen_users: set[int] = set() seen_users: set[int] = set()
for uid, sc in rows: for uid, sc, nick in rows:
if uid in seen_users: if uid in seen_users:
continue continue
seen_users.add(uid) seen_users.add(uid)
name = _mask_user(uid) name = _mask_real(nick, uid)
used_names.add(name) # 真实名按 user_id 稳定;偶发撞名可接受 used_names.add(name) # 真实名按昵称/id 稳定;偶发撞名可接受
items.append({"masked_user": name, "saved_amount_cents": int(sc)}) items.append({"masked_user": name, "saved_amount_cents": int(sc)})
if len(items) >= limit: if len(items) >= limit:
break break
+21 -20
View File
@@ -83,26 +83,27 @@
| A5 | `GET /admin/api/users/{user_id}` | admin | [详情](./admin-user-detail.md) | | 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) | | 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) | | 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) | | A8 | `POST /admin/api/users/{user_id}/cash` | finance | [详情](./admin-user-cash.md) |
| A9 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin-wallet-cash-transactions.md) | | A9 | `GET /admin/api/wallet/coin-transactions` | admin | [详情](./admin-wallet-coin-transactions.md) |
| A10 | `GET /admin/api/withdraws` | admin | [详情](./admin-withdraws-list.md) | | A10 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin-wallet-cash-transactions.md) |
| A11 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin-withdraw-reconcile.md) | | A11 | `GET /admin/api/withdraws` | admin | [详情](./admin-withdraws-list.md) |
| A12 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin-withdraw-refresh.md) | | A12 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin-withdraw-reconcile.md) |
| A13 | `GET /admin/api/feedbacks` | admin | [详情](./admin-feedbacks-list.md) | | A13 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin-withdraw-refresh.md) |
| A14 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin-feedback-handle.md) | | A14 | `GET /admin/api/feedbacks` | admin | [详情](./admin-feedbacks-list.md) |
| A15 | `GET /admin/api/admins` | super_admin | [详情](./admin-admins-list.md) | | A15 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin-feedback-handle.md) |
| A16 | `POST /admin/api/admins` | super_admin | [详情](./admin-admin-create.md) | | A16 | `GET /admin/api/admins` | super_admin | [详情](./admin-admins-list.md) |
| A17 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin-admin-update.md) | | A17 | `POST /admin/api/admins` | super_admin | [详情](./admin-admin-create.md) |
| A18 | `GET /admin/api/audit-logs` | admin | [详情](./admin-audit-logs.md) | | A18 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin-admin-update.md) |
| A19 | `GET /admin/api/dashboard-display` | admin | [详情](./admin-dashboard-display.md) | | A19 | `GET /admin/api/audit-logs` | admin | [详情](./admin-audit-logs.md) |
| A20 | `PATCH /admin/api/dashboard-display/{metric}` | operator | [详情](./admin-dashboard-display.md) | | A20 | `GET /admin/api/dashboard-display` | admin | [详情](./admin-dashboard-display.md) |
| A21 | `GET /admin/api/marquee-seeds` | admin | [详情](./admin-marquee-seeds.md) | | A21 | `PATCH /admin/api/dashboard-display/{metric}` | operator | [详情](./admin-dashboard-display.md) |
| A22 | `POST /admin/api/marquee-seeds` | operator | [详情](./admin-marquee-seeds.md) | | A22 | `GET /admin/api/marquee-seeds` | admin | [详情](./admin-marquee-seeds.md) |
| A23 | `PATCH /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) | | A23 | `POST /admin/api/marquee-seeds` | operator | [详情](./admin-marquee-seeds.md) |
| A24 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) | | A24 | `PATCH /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) |
| A25 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) | | A25 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) |
| A26 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) | | A26 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) |
| A27 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin-ad-coin-audit.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 健康检查(无单独文档) | | - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。 > ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
+6 -4
View File
@@ -25,16 +25,18 @@ eCPM元 = getEcpm分 ÷ 100
| `date` | string | 今天 | 北京时间 `YYYY-MM-DD`,审计某天 | | `date` | string | 今天 | 北京时间 `YYYY-MM-DD`,审计某天 |
| `user_id` | int | 全部 | 只看某用户;不传=所有用户 | | `user_id` | int | 全部 | 只看某用户;不传=所有用户 |
| `scene` | string | 两类 | `reward_video` / `feed`;不传=两类都返回 | | `scene` | string | 两类 | `reward_video` / `feed`;不传=两类都返回 |
| `limit` | int(1~500) | 100 | 返回明细条数(按时间倒序截断;**份序号在截断前已按全天数据算好**,不影响复算正确性) | | `limit` | int(1~500) | 100 | **展示**明细条数(按时间倒序截断;**份序号在截断前已按全天数据算好**,不影响复算正确性) |
| `only_mismatch` | bool | false | 只展示不一致(✗)行;统计数仍按全量,不受影响 |
- 出参 `200`:`AdCoinAuditOut` - 出参 `200`:`AdCoinAuditOut`
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|---|---|---| |---|---|---|
| `date` | string | 审计日期 | | `date` | string | 审计日期 |
| `formula` | object | 当前公式参数快照(见下) | | `formula` | object | 当前公式参数快照(见下) |
| `total` | int | 返回明细条数 | | `total` | int | 该筛选下复算**总条数**(全量,不受 `limit`/`only_mismatch` 影响) |
| `mismatch_count` | int | 其中 `matched=false` 的条数;**=0 说明全部按公式发放** | | `mismatch_count` | int | **全量**不一致条数(截断前统计,可信);**=0 说明全部按公式发放** |
| `items` | `AdCoinAuditRow[]` | 明细(见下) | | `truncated` | bool | 展示集是否被 `limit` 截断(true=还有未返回的明细,请缩小范围或调大 `limit`) |
| `items` | `AdCoinAuditRow[]` | 展示明细(见下);`only_mismatch=true` 时只含 ✗ 行 |
### AdCoinFormulaOut(`formula`) ### AdCoinFormulaOut(`formula`)
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
+34
View File
@@ -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` 长度不在 1128、`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:<reason>`(截断至 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)。
+10 -7
View File
@@ -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) > 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md)
@@ -7,23 +7,26 @@
- **application/json**: - **application/json**:
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|---|---|---|---| |---|---|---|---|
| `amount` | int | | 金币变动(个数):正=增加,负=扣减;不可为 0 | | `mode` | string | | `delta`(默认)=增减 / `set`=设为指定值 |
| `amount` | int | ✓ | `delta` 模式:金币变动(正=增加,负=扣减,不可为 0);`set` 模式:目标金币值(须 ≥ 0) |
| `reason` | string | ✓ | 操作原因,1–128 字(必填,入审计与流水备注) | | `reason` | string | ✓ | 操作原因,1–128 字(必填,入审计与流水备注) |
## 出参 ## 出参
响应 `200`:`OkResponse` = `{ "ok": true }` 响应 `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`) - `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
- `403` 角色不足(需 `finance``super_admin`) - `403` 角色不足(需 `finance``super_admin`)
- `404` 用户不存在(`detail: "用户不存在"`) - `404` 用户不存在(`detail: "用户不存在"`)
- `422``amount`/`reason``reason` 长度不在 1–128、字段类型不合法 / `user_id` 非整数 - `422``amount`/`reason``reason` 长度不在 1128、`mode``delta`/`set`字段类型不合法 / `user_id` 非整数
## 说明 ## 说明
- 金币 = 个数(非现金);本接口只动金币余额,不涉及现金(`*_cents`)。 - 金币 = 个数(非现金);本接口只动金币余额,不涉及现金(`*_cents`)。
- 扣减保护:`amount < 0` 时若扣减后金币余额 < 0 直接拒绝(运营误操作保护) - **set 模式**:读当前余额算出差值 `delta = target - 当前余额`,再复用同一套写入逻辑(故只写一笔差值流水)。目标值须 ≥ 0;差值为 0(已等于目标)直接拒绝
- 金币变动写流水 [coin_transaction](../database/coin_transaction.md):`biz_type` 增加为 `admin_grant`、扣减为 `admin_deduct`,`remark = admin:<reason>`(截断至 128 字) - 扣减保护:实际写入的 `delta < 0` 时若扣减后金币余额 < 0 直接拒绝(运营误操作保护);set 模式目标值 ≥ 0 天然不会扣成负
- 写操作记审计 [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 - 金币变动写流水 [coin_transaction](../database/coin_transaction.md):`biz_type` 实际差值为正记 `admin_grant`、为负记 `admin_deduct`(set 模式同理,不新增流水类型),`remark = admin:<reason>`(截断至 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)。 - 关联用户表 [user](../database/user.md);金币账户 [coin_account](../database/coin_account.md);金币流水 [coin_transaction](../database/coin_transaction.md)。
+11 -5
View File
@@ -1,4 +1,4 @@
# GET /admin/api/users — 用户列表(筛选+分页) # GET /admin/api/users — 用户列表(筛选+排序+分页)
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role | [← 返回 API 索引](./README.md) > 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role | [← 返回 API 索引](./README.md)
@@ -8,8 +8,13 @@
| `phone` | string | ❌ | null | 手机号**前缀**匹配(`phone LIKE '<值>%'`) | | `phone` | string | ❌ | null | 手机号**前缀**匹配(`phone LIKE '<值>%'`) |
| `register_channel` | string | ❌ | null | 注册渠道,精确匹配 | | `register_channel` | string | ❌ | null | 注册渠道,精确匹配 |
| `status` | string | ❌ | null | 用户状态,精确匹配:`active` / `disabled` / `deleted` | | `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 | 1100 | | `limit` | int | ❌ | 20 | 1100 |
| `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` 表示末页) 响应 `200`:`{ items: AdminUserListItem[], next_cursor: int|null }`(`next_cursor=null` 表示末页)
@@ -28,10 +33,11 @@
## 错误码 ## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`) - `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
- `422` `limit` 超出 1–100 范围 / 字段类型不合法 - `422` `limit` 超出 1100 范围 / `sort_by`·`sort_order` 不在白名单 / 日期格式不合法 / 字段类型不合法
## 说明 ## 说明
- 游标分页约定:结果按 user `id` 倒序;`cursor` 传上一页返回的 `next_cursor`;`next_cursor=null` 即末页。 - **分页为 offset 偏移式**(为支持任意列排序):`cursor` 传上一页返回的 `next_cursor`(实为 offset);`next_cursor=null` 即末页。代价:翻页期间数据变动可能错位一条,admin 低频场景可接受(同提现列表)。
- `phone` 为前缀匹配(`LIKE '<值>%'`),`register_channel` / `status` 为精确匹配;三者可叠加 - 排序:`sort_by``id`/`created_at`/`last_login_at`,非法值回落 `id`;同值再按 `id` 同向兜底,次序稳定
- 筛选:`phone` 前缀、`nickname` 模糊(`ILIKE`)、`register_channel`/`status` 精确、`created_*`/`last_login_*` 时间范围;均可叠加。
- 关联用户表 [user](../database/user.md)。 - 关联用户表 [user](../database/user.md)。
- 历史明细(金币流水、提现、比价、反馈等)不在本列表,走各自带 `user_id` 过滤的分页接口。 - 历史明细(金币流水、提现、比价、反馈等)不在本列表,走各自带 `user_id` 过滤的分页接口。
+1 -1
View File
@@ -25,7 +25,7 @@
| `biz_type` | string | 业务类型 | | `biz_type` | string | 业务类型 |
| `ref_id` | string \| null | 关联业务 ID | | `ref_id` | string \| null | 关联业务 ID |
| `remark` | string \| null | 备注 | | `remark` | string \| null | 备注 |
| `created_at` | datetime | 创建时间(ISO 8601 UTC | | `created_at` | datetime | 创建时间(北京 wall-clock naive,非 UTC;前端按字面显示,不做时区换算 |
## 错误码 ## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer` - `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`
+1 -1
View File
@@ -25,7 +25,7 @@
| `biz_type` | string | 业务类型 | | `biz_type` | string | 业务类型 |
| `ref_id` | string \| null | 关联业务 ID | | `ref_id` | string \| null | 关联业务 ID |
| `remark` | string \| null | 备注 | | `remark` | string \| null | 备注 |
| `created_at` | datetime | 创建时间(ISO 8601 UTC | | `created_at` | datetime | 创建时间(北京 wall-clock naive,非 UTC;前端按字面显示,不做时区换算 |
## 错误码 ## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer` - `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`
+8 -4
View File
@@ -2,7 +2,11 @@
> 所属:Platform 组(前缀 `/api/v1/platform` | 鉴权:**无** | [← 返回 API 索引](./README.md) > 所属: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` | list | 轮播条目数组(最多 `limit` 条) |
| `items[].masked_user` | string | 脱敏用户名,手机尾号(`138****5678`)/ 中文昵称(`省钱**`)混合风格 | | `items[].masked_user` | string | 脱敏用户名:昵称/合成名脱敏(`省***手` / `王*` / `SaveK**g`)或匿名 `用户********618` |
| `items[].saved_amount_cents` | int | 节省(分),客户端 ÷100 显示「x.xx 元」 | | `items[].saved_amount_cents` | int | 节省(分),客户端 ÷100 显示「x.xx 元」 |
| `items[].time` | string | 北京时间 `HH:MM:SS` | | `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)。 - 种子条:`ops_marquee_seed`(`enabled=true`),仅在真实去重后不足 `limit` 时补齐;**从启用种子中公平随机抽取**(不再固定取前 N,所有种子都有机会露出)。每条种子:用户名留空或旧 `用户****xxx` 模板名则**随机合成混合风格名并避开同屏撞名**(自愈历史种子),金额在 `[min_cents, max_cents]` 取**长尾随机值**(小额居多、偶尔大额,固定金额则 min==max)。
- 兜底:真实 + 种子仍凑不满 `limit`(种子被全停用 / 太少)时,用**内置合成条补满**,保证轮播既不空也不稀疏。 - 兜底:真实 + 种子仍凑不满 `limit`(种子被全停用 / 太少)时,用**内置合成条补满**,保证轮播既不空也不稀疏。
- **`time` 为合成的「最近」时间**(从当前北京时间往前**随机抖动**递减,首条几十秒前,其余多数 1~5 分钟、偶尔扎堆或较长):社会证明轮播保证永远像刚发生且节奏自然不机械,不受旧测试数据 / 低谷期记录影响。真实的用户/金额不变,只换展示时间。 - **`time` 为合成的「最近」时间**(从当前北京时间往前**随机抖动**递减,首条几十秒前,其余多数 1~5 分钟、偶尔扎堆或较长):社会证明轮播保证永远像刚发生且节奏自然不机械,不受旧测试数据 / 低谷期记录影响。真实的用户/金额不变,只换展示时间。
- 因含随机(抽取 / 金额 / 合成名),**每次请求结果都不同**——这是轮播想要的鲜活感。 - 因含随机(种子抽取 / 金额 / 展示时间 / 种子合成名),**每次请求结果都不同**——这是轮播想要的鲜活感;但**真实用户的脱敏名按 `user_id` 恒定**,同一用户跨请求不变脸(避免「同一人换张脸」露馅)
- **性能**:首页人人都看、真实数据变化慢,真实记录原始行带 **~30s 进程内缓存**(展示层随机仍每次重算,只省 DB 往返;新记录最多晚 30s 进轮播)。查询走复合索引 `ix_comparison_status_created (status, created_at)`,避免随数据量增大全表扫。 - **性能**:首页人人都看、真实数据变化慢,真实记录原始行带 **~30s 进程内缓存**(展示层随机仍每次重算,只省 DB 往返;新记录最多晚 30s 进轮播)。查询走复合索引 `ix_comparison_status_created (status, created_at)`,避免随数据量增大全表扫。
- 逻辑见 `app/repositories/ops_marquee.py`;运营管理种子见 [admin-marquee-seeds](./admin-marquee-seeds.md)。 - 逻辑见 `app/repositories/ops_marquee.py`;运营管理种子见 [admin-marquee-seeds](./admin-marquee-seeds.md)。
+1 -1
View File
@@ -8,7 +8,7 @@
| 列 | 类型 | 约束 / 默认 | 说明 | | 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---| |---|---|---|---|
| `id` | Integer | PK, autoincrement | | | `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 | 节省金额区间下限(分) | | `min_cents` | Integer | NOT NULL | 节省金额区间下限(分) |
| `max_cents` | Integer | NOT NULL | 节省金额区间上限(分);feed 每次在 `[min,max]` 随机取值,**固定金额则 min==max** | | `max_cents` | Integer | NOT NULL | 节省金额区间上限(分);feed 每次在 `[min,max]` 随机取值,**固定金额则 min==max** |
| `enabled` | Boolean | NOT NULL, default true | 停用的不参与混播 | | `enabled` | Boolean | NOT NULL, default true | 停用的不参与混播 |
+133
View File
@@ -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"]) 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: def test_wallet_and_withdraw_lists(admin_client: TestClient, admin_token: str) -> None:
uid = _seed_user_with_data("13800000004") uid = _seed_user_with_data("13800000004")
r = admin_client.get( 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"]) 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=5mismatch=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: def test_read_apis_require_auth(admin_client: TestClient) -> None:
"""所有 M2 读接口未带 token → 401(router 级 get_current_admin 守卫)。""" """所有 M2 读接口未带 token → 401(router 级 get_current_admin 守卫)。"""
for path in [ for path in [
+96
View File
@@ -136,6 +136,102 @@ def test_grant_zero_rejected(admin_client: TestClient, finance_token: str) -> No
assert r.status_code == 400 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: def test_set_user_status_and_audit(admin_client: TestClient, operator_token: str) -> None: