feat(admin): 首页门面数字保底/护栏 + 轮播种子真实化与批量 + 比价记录索引

- ops_stat: 真实值 offset→保底(max);初始基数过只增不减护栏
- ops_marquee: 脱敏名手机尾号+中文昵称混合;金额长尾;时间随机抖动;真实+种子不足兜底补满;真实记录查询 ~30s 缓存
- comparison_record: 复合索引 (status, created_at) + 迁移(PG CONCURRENTLY);修复 alembic 多 head(merge 改挂 onboarding_completion)
- 轮播种子批量删除/启用接口 + ids 上限;docs 同步
- 附带并行会话 WIP:admin users / wallet

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
OuYingJun1024
2026-06-10 20:48:35 +08:00
parent bf82c68408
commit fff09a3d4d
20 changed files with 399 additions and 66 deletions
+36
View File
@@ -12,6 +12,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.schemas.ops_marquee_seed import (
OpsMarqueeSeedBatchDelete,
OpsMarqueeSeedBatchEnable,
OpsMarqueeSeedBulkCreate,
OpsMarqueeSeedCreate,
OpsMarqueeSeedOut,
@@ -111,6 +113,40 @@ def bulk_generate(
return [_out(s) for s in seeds]
@router.post("/batch-delete", summary="批量删除种子(带审计)")
def batch_delete_seeds(
body: OpsMarqueeSeedBatchDelete,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> dict:
n = ops_marquee.batch_delete(db, body.ids, commit=False)
write_audit(
db, admin, action="ops_marquee_seed.batch_delete", target_type="ops_marquee_seed",
target_id=None, detail={"ids": body.ids, "deleted": n},
ip=get_client_ip(request), commit=False,
)
db.commit()
return {"deleted": n}
@router.post("/batch-enable", summary="批量启用 / 停用种子(带审计)")
def batch_enable_seeds(
body: OpsMarqueeSeedBatchEnable,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> dict:
n = ops_marquee.batch_set_enabled(db, body.ids, body.enabled, commit=False)
write_audit(
db, admin, action="ops_marquee_seed.batch_enable", target_type="ops_marquee_seed",
target_id=None, detail={"ids": body.ids, "enabled": body.enabled, "updated": n},
ip=get_client_ip(request), commit=False,
)
db.commit()
return {"updated": n}
@router.patch("/{seed_id}", response_model=OpsMarqueeSeedOut, summary="改轮播种子(带审计)")
def update_seed(
seed_id: int,
+40
View File
@@ -12,6 +12,7 @@ from app.admin.schemas.common import CursorPage, OkResponse
from app.admin.schemas.user import (
AdminUserListItem,
AdminUserOverview,
GrantCashRequest,
GrantCoinsRequest,
SetDebugTraceRequest,
SetUserStatusRequest,
@@ -132,3 +133,42 @@ def grant_user_coins(
)
db.commit()
return OkResponse()
@router.post("/{user_id}/cash", response_model=OkResponse, summary="手动增减现金(带审计)")
def grant_user_cash(
user_id: int,
body: GrantCashRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb,
) -> OkResponse:
"""给指定用户增/减现金(分)。正=发放、负=扣减;主要用于让无现金用户直接测试提现。"""
if body.amount_cents == 0:
raise HTTPException(status_code=400, detail="amount_cents 不能为 0")
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:
raise HTTPException(
status_code=400, detail=f"扣减后现金为负(当前余额 {acc_now.cash_balance_cents} 分)"
)
biz_type = "admin_grant" if body.amount_cents > 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],
)
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,
)
db.commit()
return OkResponse()
+12 -1
View File
@@ -4,7 +4,7 @@
"""
from __future__ import annotations
from pydantic import BaseModel
from pydantic import BaseModel, Field
class OpsMarqueeSeedOut(BaseModel):
@@ -42,6 +42,17 @@ class OpsMarqueeSeedBulkCreate(BaseModel):
enabled: bool = True
class OpsMarqueeSeedBatchDelete(BaseModel):
"""批量删除选中的种子。"""
ids: list[int] = Field(..., max_length=500) # 上限防超大列表(审计行/IN 子句过大)
class OpsMarqueeSeedBatchEnable(BaseModel):
"""批量启用 / 停用选中的种子。"""
ids: list[int] = Field(..., max_length=500)
enabled: bool
class OpsSavingsFeedPreviewItem(BaseModel):
masked_user: str
saved_amount_cents: int
+1 -1
View File
@@ -20,7 +20,7 @@ class OpsStatItemOut(BaseModel):
random_kind: str # mult(×倍率) / add(+绝对增量)
random_step_min: int # 绝对增量区间(基础单位;total_saved 为分)
random_step_max: int
real_offset: int # 真实值模式基数偏移(基础单位)
real_offset: int # 真实值模式保底值(基础单位):展示=max(真实,保底)。列名沿用 real_offset
allow_decrease: bool # 是否允许展示值下降(默认 false = 只增不减)
random_current: int | None = None
random_last_tick_at: str | None = None
+5
View File
@@ -42,6 +42,11 @@ class GrantCoinsRequest(BaseModel):
reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)")
class GrantCashRequest(BaseModel):
amount_cents: int = Field(..., description="现金变动(分):正=增加,负=扣减(不可为 0)")
reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)")
class SetUserStatusRequest(BaseModel):
status: Literal["active", "disabled"] = Field(
..., description="active=解封 / disabled=封禁(注销 deleted 不走此接口)"
+4
View File
@@ -19,6 +19,7 @@ from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
String,
UniqueConstraint,
@@ -39,6 +40,9 @@ class ComparisonRecord(Base):
__table_args__ = (
# 同一用户同一次比价(trace_id)只存一条:客户端重试/误点重复上报时幂等覆盖。
UniqueConstraint("user_id", "trace_id", name="uq_comparison_user_trace"),
# 首页轮播 / 省钱战绩聚合都按 status='success' 过滤 + created_at 近期排序;
# 复合索引避免随数据量增大退化成全表扫(单列 created_at 索引不含 status)。
Index("ix_comparison_status_created", "status", "created_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+2 -1
View File
@@ -25,7 +25,8 @@ class OpsMarqueeSeed(Base):
__tablename__ = "ops_marquee_seed"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 脱敏用户名,如「用户********a52」;留空(NULL)→ feed 展示时按脱敏格式随机合成(避开撞名)
# 脱敏用户名(手机尾号 / 中文昵称风格,如「138****5678」「省钱**」);留空(NULL)或旧「用户****xxx」
# 模板名 → feed 展示时随机合成混合风格名(避开撞名、自愈历史种子)
masked_user: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 节省金额区间(分);feed 每次在 [min,max] 随机取一个值。固定金额则 min==max。客户端 ÷100 显示「x.xx 元」
min_cents: Mapped[int] = mapped_column(Integer, nullable=False)
+2 -1
View File
@@ -53,7 +53,8 @@ class OpsStatConfig(Base):
random_kind: Mapped[str] = mapped_column(String(8), nullable=False, default="mult")
random_step_min: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
random_step_max: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# real 模式基数偏移(基础单位;total_saved 为分):展示值 = 真实值 + 偏移(冷启动期既真实又体面)
# real 模式保底值(基础单位;total_saved 为分):展示值 = max(真实值, 保底值)。冷启动显示保底撑
# 场面,真实值涨过保底后显示纯真实、零差距。列名沿用 real_offset(语义已从"偏移"改为"保底")
real_offset: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 门面数字默认「只增不减」;运营明确要下调时才开此开关(real/manual 刷新都受护栏约束)
allow_decrease: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+158 -44
View File
@@ -5,14 +5,17 @@ feed 取最近的成功且省到钱的比价记录(脱敏用户名);不足 N 条
种子是「生成规则」:用户名可空(空→随机合成脱敏名,避开同屏撞名)、金额是区间(每次随机取值)、
feed 公平随机抽取(不看 sort_order),所有启用种子都有机会露出。
脱敏名风格:手机尾号(138****5678)+ 中文昵称(省钱**、小张**、吃货**达人)混合,看起来像真实
异质用户群。真实条按 user_id 确定性合成(同用户恒定);种子留空 / 旧「用户****xxx」模板名一律随机
合成(自愈历史种子,无需迁移)。
"""
from __future__ import annotations
import hashlib
import random
from datetime import datetime, timedelta
from sqlalchemy import func, select
from sqlalchemy import delete, func, select, update
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ
@@ -22,34 +25,70 @@ from app.models.ops_marquee_seed import OpsMarqueeSeed
# feed 运行时随机源(每次请求结果不同 = 轮播想要的「鲜活感」)
_rng = random.Random()
_SUFFIX_HEX = 3 # 脱敏用户名后缀位数,与真实条 md5[:3] 对齐
_HEX = "0123456789abcdef"
# 真实条金额上限(300 元):超过视为异常 / bug 值,不进轮播(防「节省 999 元」穿帮)
_REAL_MAX_CENTS = 30000
# 种子金额防呆上限(1000 元):运营手滑填超大值时拦下
_SEED_MAX_CENTS = 100000
# 真实记录查询缓存:首页人人都看、真实数据变化慢 → 缓存原始行 ~30s,省掉高频 DB 往返。
# 展示层随机(抽样/金额/时间/名字合成)仍每次重算,缓存只省查询;新记录最多晚 30s 进轮播,可接受。
_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 = [
"省钱", "吃货", "羊毛", "薅羊毛", "爱比价", "精打细算", "持家", "干饭",
"美食控", "外卖", "剁手", "捡漏", "搞钱", "打工人", "摸鱼", "养生",
"佛系", "躺平", "暴富", "锦鲤", "夜宵", "奶茶", "热爱生活", "会过日子",
]
_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 _nickname(rng: random.Random) -> str:
"""昵称打码式,中文为主(约 11/12),掺少量中英混 / 纯字母。"""
p = rng.randint(0, 11)
if p in (0, 1, 2):
return rng.choice(_NICK_WORDS) + "**" # 省钱**
if p in (3, 4):
return rng.choice(_NICK_WORDS) + "**" + rng.choice(_NICK_SUFFIX) # 吃货**达人
if p in (5, 6):
return rng.choice(_SURNAMES) + "**" # 张**
if p in (7, 8):
return rng.choice(_NICK_PREFIX) + rng.choice(_SURNAMES) + "**" # 小张**
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)
def _realistic_name(rng: random.Random) -> str:
"""~45% 手机尾号 + ~55% 中文昵称,混合出真实异质感。"""
return _phone_name(rng) if rng.random() < 0.45 else _nickname(rng)
def _mask_user(user_id: int) -> str:
"""真实用户脱敏:用户********+3 位哈希后缀(稳定、不暴露真实信息)。"""
suffix = hashlib.md5(str(user_id).encode()).hexdigest()[:_SUFFIX_HEX]
return f"用户********{suffix}"
"""真实用户脱敏:按 user_id 确定性合成一个名(同用户恒定、不暴露真实信息)。"""
return _realistic_name(random.Random(user_id))
def _random_suffix(used: set[str]) -> str:
"""随机 3 位 hex 后缀,避开 used(真实条 + 已生成种子),防同屏撞名。"""
def _unique_name(used: set[str]) -> str:
"""随机合成一个不与 used 撞的脱敏名(种子留空 / 旧模板名用),防同屏撞名。"""
for _ in range(60):
s = "".join(_rng.choice(_HEX) for _ in range(_SUFFIX_HEX))
if s not in used:
used.add(s)
return s
# 兜底(几乎不会到):线性扫一个空位
for n in range(16 ** _SUFFIX_HEX):
s = format(n, f"0{_SUFFIX_HEX}x")
if s not in used:
used.add(s)
return s
return "000"
n = _realistic_name(_rng)
if n not in used:
return n
return _realistic_name(_rng) + str(_rng.randint(0, 99)) # 兜底(几乎不会到)
def _validate_amount(min_cents: int, max_cents: int) -> None:
@@ -61,17 +100,30 @@ def _validate_amount(min_cents: int, max_cents: int) -> None:
raise ValueError(f"金额上限过大(≤ {_SEED_MAX_CENTS // 100} 元)")
# ===== 用户侧:轮播 feed(真实 + 种子混播) =====
def get_feed(db: Session, limit: int = 8) -> list[dict]:
"""返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}
真实条:success 且 0 < saved ≤ 上限,按 user 去重(同一用户只取最新一条,避免单人刷屏)。
不足用启用的种子补齐——**公平随机抽取** need 个(而非固定取前 N),让所有种子都有机会露出;
种子用户名留空则随机合成(避开撞名),金额在 [min,max] 区间随机取值。
展示时间统一「刷新」成相对现在的最近时刻(从 now 往前递减),保证轮播永远像刚发生
(真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时/未来时间)。
def _skewed_amount(lo: int, hi: int) -> int:
"""长尾金额:多数贴近下限、偶尔接近上限(比均匀随机更像真实省钱分布)。
用 u**2.2 把 [0,1) 均匀值压向 0 → 小额居多;偶尔 u 接近 1 给出大额
"""
# 真实条:取较多近期记录后按 user 去重;金额超上限的异常值剔除(防穿帮)。
if hi <= lo:
return max(0, lo)
u = _rng.random() ** 2.2
return lo + int(round((hi - lo) * u))
# 兜底金额区间(真实 + 种子都凑不满 limit 时,内置合成条用):3~35 元
_FALLBACK_MIN_CENTS = 300
_FALLBACK_MAX_CENTS = 3500
# ===== 用户侧:轮播 feed(真实 + 种子混播) =====
def _recent_real_rows(db: Session) -> list[tuple[int, int]]:
"""近期 success 且省到钱的 (user_id, saved_cents),按 created_at desc;带 ~30s 进程内缓存。
返回纯元组(脱离 session),可安全跨请求复用。极端并发下偶尔多查一次(无锁、幂等),纯门面无副作用。
"""
now = datetime.now(CN_TZ)
cached, at = _real_rows_cache["rows"], _real_rows_cache["at"]
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)
.where(
@@ -80,18 +132,35 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]:
ComparisonRecord.saved_amount_cents <= _REAL_MAX_CENTS,
)
.order_by(ComparisonRecord.created_at.desc())
.limit(max(limit * 20, 100))
.limit(_REAL_ROWS_FETCH_CAP)
).all()
out = [(int(uid), int(sc)) for uid, sc in rows]
_real_rows_cache["rows"], _real_rows_cache["at"] = out, now
return out
def get_feed(db: Session, limit: int = 8) -> list[dict]:
"""返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}。
真实条:success 且 0 < saved ≤ 上限,按 user 去重(同一用户只取最新一条,避免单人刷屏)。
不足用启用的种子补齐——**公平随机抽取** need 个(而非固定取前 N),让所有种子都有机会露出;
种子用户名留空则随机合成(避开撞名),金额取**长尾随机**(小额居多、偶尔大额,更像真实分布)。
真实 + 种子仍不满 limit → 内置合成条**补满**,保证轮播既不空也不稀疏。
展示时间统一「刷新」成相对现在的最近时刻(从 now 往前**随机抖动**递减),保证轮播永远像刚发生、
节奏自然不机械(真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时时间)。
"""
# 真实条:取较多近期记录(带 ~30s 缓存)后按 user 去重;金额超上限的异常值已在查询剔除。
rows = _recent_real_rows(db)
items: list[dict] = []
used_suffixes: set[str] = set()
used_names: set[str] = set()
seen_users: set[int] = set()
for uid, sc in rows:
if uid in seen_users:
continue
seen_users.add(uid)
name = _mask_user(uid)
used_suffixes.add(name[-_SUFFIX_HEX:])
used_names.add(name) # 真实名按 user_id 稳定;偶发撞名可接受
items.append({"masked_user": name, "saved_amount_cents": int(sc)})
if len(items) >= limit:
break
@@ -105,22 +174,41 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]:
chosen = _rng.sample(seeds, need) if len(seeds) > need else list(seeds)
_rng.shuffle(chosen)
for s in chosen:
# 用户名:填了固定名优先;留空则按脱敏格式随机合成(避开撞名)。
if s.masked_user and s.masked_user.strip():
name = s.masked_user.strip()
used_suffixes.add(name[-_SUFFIX_HEX:])
else:
name = f"用户********{_random_suffix(used_suffixes)}"
# 金额:在 [min,max] 随机一个(固定金额则 min==max)。
# 用户名:旧的「用户****xxx」统一模板名 / 留空 → 一律走新混合合成(避开同屏撞名、自愈历史种子);
# 仅运营手动设的非模板真名才原样用。
fixed = (s.masked_user or "").strip()
name = fixed if fixed and not fixed.startswith("用户****") else _unique_name(used_names)
used_names.add(name)
# 金额:在 [min,max] 取长尾随机值(小额居多、偶尔大额;固定金额则 min==max)。
lo = max(0, int(s.min_cents))
hi = max(lo, int(s.max_cents))
cents = lo if lo >= hi else _rng.randint(lo, hi)
items.append({"masked_user": name, "saved_amount_cents": cents})
items.append({"masked_user": name, "saved_amount_cents": _skewed_amount(lo, hi)})
# 统一赋「最近」时间:首条约 20 秒前,其余每条往前 2~6 分钟,降序、节奏自然。
# 兜底:真实 + 种子仍凑不满 limit(种子被全停用 / 数量太少)→ 用内置合成补满,
# 保证轮播既不空也不稀疏(稀疏的几条循环同样像假)。
while len(items) < limit:
name = _unique_name(used_names)
used_names.add(name)
items.append({
"masked_user": name,
"saved_amount_cents": _skewed_amount(_FALLBACK_MIN_CENTS, _FALLBACK_MAX_CENTS),
})
# 统一赋「最近」时间:从 now 往前**随机抖动**递减,避免固定节奏被看出规律。
# 首条几十秒前;其余多数 1~5 分钟,偶尔扎堆(20~55s)或较长(5~9 分钟),降序、像真实流水。
cursor = datetime.now(CN_TZ)
for i, it in enumerate(items):
cursor = cursor - timedelta(seconds=20 if i == 0 else 60 * (2 + (i * 3) % 5))
if i == 0:
gap = _rng.randint(8, 40)
else:
r = _rng.random()
if r < 0.2:
gap = _rng.randint(20, 55) # 偶尔扎堆
elif r < 0.85:
gap = _rng.randint(60, 300) # 常规 1~5 分钟
else:
gap = _rng.randint(300, 540) # 偶尔较长 5~9 分钟
cursor = cursor - timedelta(seconds=gap)
it["time"] = cursor.strftime("%H:%M:%S")
return items
@@ -244,3 +332,29 @@ def delete_seed(db: Session, seed_id: int, *, commit: bool = True) -> bool:
else:
db.flush()
return True
def batch_delete(db: Session, ids: list[int], *, commit: bool = True) -> int:
"""批量删除选中的种子,返回实际删除条数(不存在的 id 自动跳过)。"""
if not ids:
return 0
n = db.execute(delete(OpsMarqueeSeed).where(OpsMarqueeSeed.id.in_(ids))).rowcount
if commit:
db.commit()
else:
db.flush()
return int(n or 0)
def batch_set_enabled(db: Session, ids: list[int], enabled: bool, *, commit: bool = True) -> int:
"""批量启用 / 停用选中的种子,返回实际更新条数。"""
if not ids:
return 0
n = db.execute(
update(OpsMarqueeSeed).where(OpsMarqueeSeed.id.in_(ids)).values(enabled=enabled)
).rowcount
if commit:
db.commit()
else:
db.flush()
return int(n or 0)
+9 -5
View File
@@ -7,7 +7,8 @@ random 自增长支持两种方式(random_kind):
- mult:×随机倍率([min,max]/1000, 1.0)复利指数增长
- add :+随机绝对增量([step_min,step_max])线性可控,更像真实平台日增
真实值(real)模式展示 = 真实统计 + real_offset(基数偏移),冷启动期既真实又体面
真实值(real)模式展示 = max(真实统计, real_offset 保底值):冷启动显示保底撑场面,真实值涨过
保底后显示纯真实零差距(real_offset 列名沿用,语义从"偏移"改为"保底")
只增不减护栏(allow_decrease=False,默认):real/manual 刷新时若新目标值低于当前展示值,
保持当前值不回退门面数字最忌当众缩水random 天然只增(倍率1 / 增量0)
@@ -116,8 +117,9 @@ def _current_target(db: Session, row: OpsStatConfig) -> int:
if row.mode == "manual":
return max(0, int(row.manual_value or 0))
if row.mode == "real":
# 真实值 + 基数偏移
return max(0, int(_real_value(db, row.metric)) + int(row.real_offset or 0))
# 真实值保底:展示 = max(真实值, 保底值)。冷启动真实值小→显示保底撑场面;
# 真实值涨过保底后显示纯真实、零差距(real_offset 列名沿用,语义已从"偏移"改为"保底")。
return max(0, int(_real_value(db, row.metric)), int(row.real_offset or 0))
# random:无显式初始基数时用真实值(纯真实,不含偏移)播种
return max(0, int(_real_value(db, row.metric)))
@@ -302,7 +304,7 @@ def update_config(
raise ValueError("增量上限不得小于下限")
if real_offset is not None:
if real_offset < 0:
raise ValueError("基数偏移需为非负整数")
raise ValueError("保底值需为非负整数")
row.real_offset = real_offset
if allow_decrease is not None:
row.allow_decrease = allow_decrease
@@ -311,7 +313,9 @@ def update_config(
if random_initial is not None:
if random_initial < 0:
raise ValueError("初始基数需为非负整数")
row.random_current = random_initial # 设起点是运营明确意图,不受护栏约束
# 初始基数受「只增不减」护栏(与 manual/real 一致):未勾 allow_decrease 且低于
# 当前展示值时保持现值。首次无值(random_current is None)则护栏放行、直接设。
row.random_current = _monotonic(row, random_initial)
row.random_last_tick_at = now
elif apply_now:
# 立即更新:不等钟点,马上刷新一次
+31
View File
@@ -129,6 +129,37 @@ def grant_coins(
return acc, txn
def grant_cash(
db: Session,
user_id: int,
amount_cents: int,
*,
biz_type: str,
ref_id: str | None = None,
remark: str | None = None,
) -> tuple[CoinAccount, CashTransaction]:
"""现金变动入口(正数入账 / 负数出账)。更新现金余额 + 写流水,不 commit。
[grant_coins] 同模式(运营手动调现金 / 测试发现金用)返回 (account, transaction),
调用方负责 commit不在此校验扣成负由调用方(admin router)按业务保护
"""
acc = get_or_create_account(db, user_id, commit=False)
acc.cash_balance_cents += amount_cents
txn = CashTransaction(
user_id=user_id,
amount_cents=amount_cents,
balance_after_cents=acc.cash_balance_cents,
biz_type=biz_type,
ref_id=ref_id,
remark=remark,
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None), # 存北京 wall-clock
)
db.add(txn)
db.flush()
return acc, txn
def list_coin_transactions(
db: Session,
user_id: int,
+1 -1
View File
@@ -15,7 +15,7 @@ class PlatformStatsOut(BaseModel):
class SavingsFeedItem(BaseModel):
"""首页轮播一条。time 为北京时间 HH:MM:SS。"""
masked_user: str # 脱敏用户名,如「用户********a52」
masked_user: str # 脱敏用户名,手机尾号(138****5678)或中文昵称(省钱**)混合风格
saved_amount_cents: int # 节省(分),客户端 ÷100 显示「x.xx 元」
time: str