27f76918b2
本 PR 汇合三块运营后台改动(原 #50 仅含其中「加固」一块,已并入本 PR 并关闭)。 ## 1. review 加固 (436b2a3) - 超管防自锁:降级/禁用最后一个 active super_admin 前校验,杜绝零超管死局 - 时间筛选统一 tz-aware(列为 timestamptz),比较绝对时刻、不依赖 DB 会话时区 - 上报审核 / 调余额(set·扣减)加行锁,防并发/连点重复发钱 - ad_audit 复算排序补 id 次级键;health-check 限 finance;调账/拒绝 reason 去空白校验 ## 2. 反馈改版 (5a18dbb) - contact 可选、截图≤6;admin 反馈列表筛选/排序;admin·wallet 接口调整 + docs ## 3. 列表页码分页 (1a7a624) - CursorPage 加 total;新增 offset_paginate(count 与分页同源) - 上报/审计日志从 id 游标改 offset 分页(支持跳页) - 用户 / 提现 / 上报 / 审计日志 四页接入页码分页 测试:admin 套件 47 passed。前端配套改动见 shaguabijia-admin-web。 --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: #51 Co-authored-by: ouzhou <ouzhou@wonderable.ai> Co-committed-by: ouzhou <ouzhou@wonderable.ai>
304 lines
13 KiB
Python
304 lines
13 KiB
Python
"""福利 / 激励相关的业务常量与换算规则。
|
||
|
||
集中放在这里,业务代码引用,避免数值散落各处。这些是**产品规则**(签到奖励、
|
||
任务奖励、金币汇率)而非部署配置,所以不走环境变量;要调整直接改这里。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from datetime import date, datetime, timedelta, timezone
|
||
|
||
# 业务时区:签到的"今天"按北京时间算,不能用 UTC。
|
||
# 否则 UTC+8 的凌晨 0~8 点会被算成 UTC 的前一天,导致签到日期错位。
|
||
CN_TZ = timezone(timedelta(hours=8))
|
||
|
||
|
||
def cn_today() -> date:
|
||
"""当前北京时间的日期,签到去重 / 连续判断都以此为准。"""
|
||
return datetime.now(CN_TZ).date()
|
||
|
||
|
||
# ===== 签到:7 天循环,断签重置回第 1 天(原型 2026-06 由 14 天改 7 天一轮)=====
|
||
# 第 1→7 天的金币奖励,签到逻辑用 cycle_day(1..7)索引;一轮 7 天累计 2500 金币(对原型 banner)。
|
||
SIGNIN_REWARDS: tuple[int, ...] = (
|
||
200, 200, 300, 200, 400, 400, 800,
|
||
)
|
||
SIGNIN_CYCLE_LEN: int = len(SIGNIN_REWARDS)
|
||
|
||
|
||
def signin_reward(cycle_day: int) -> int:
|
||
"""cycle_day 取值 1..SIGNIN_CYCLE_LEN。"""
|
||
return SIGNIN_REWARDS[cycle_day - 1]
|
||
|
||
|
||
# ===== 金币 → 现金 汇率 =====
|
||
# 10000 金币 = 1 元 = 100 分。
|
||
COIN_PER_YUAN: int = 10000
|
||
CENTS_PER_YUAN: int = 100
|
||
# 1 分对应多少金币 = 100。兑换金币数必须是它的整数倍,否则会出现不足 1 分的零头。
|
||
COIN_PER_CENT: int = COIN_PER_YUAN // CENTS_PER_YUAN
|
||
# 单次兑换最少 1 分(=COIN_PER_CENT;产品定:可兑 1 分起,与 step 同粒度)。早先为 1 元(=COIN_PER_YUAN),
|
||
# 用户 2026-06 改为 1 分:让"换现金"小额即可用(也方便联调验证资产卡飞金币动画)。
|
||
MIN_EXCHANGE_COIN: int = COIN_PER_CENT
|
||
|
||
|
||
def coins_to_cents(coin_amount: int) -> int:
|
||
"""金币换算成现金(分)。调用前应已校验 coin_amount 是 COIN_PER_CENT 的整数倍。"""
|
||
return coin_amount // COIN_PER_CENT
|
||
|
||
|
||
# ===== 提现(现金 → 微信零钱)=====
|
||
# 单次提现额度(分)。下限 0.1 元是微信商家转账的最低额,低于它(如 0.01 测试档)真转账会被拒。
|
||
WITHDRAW_MIN_CENTS: int = 10
|
||
WITHDRAW_MAX_CENTS: int = 5_000_000 # 5 万元
|
||
|
||
|
||
# ===== 一次性任务(领一次,user_task 去重)=====
|
||
TASK_ENABLE_NOTIFICATION = "enable_notification"
|
||
|
||
# task_key -> 奖励金币
|
||
# 打开消息提醒: 750 金币(客户端原型展示口径 2026-06 调 1000→750; 量级与签到/里程碑相称)。
|
||
# 注意: 不再 = 兑换下限(下限已降到 MIN_EXCHANGE_COIN=COIN_PER_CENT=100), test_exchange_flow 改走 grant_coins 直接供款。
|
||
# ⚠️打开消息提醒是「可重复领取」任务(2026-06):每次开启通知发一次,金额逐次减半(见 notification_reward),
|
||
# 这里的 750 是首次(基数)。客户端只在「通知权限关闭」时展示该任务,开着就隐藏。
|
||
TASK_REWARDS: dict[str, int] = {
|
||
TASK_ENABLE_NOTIFICATION: 750,
|
||
}
|
||
|
||
|
||
def notification_reward(base: int, times_claimed: int) -> int:
|
||
"""打开消息提醒第 (times_claimed+1) 次领取的金币。
|
||
|
||
基数 base(=750)起,每次减半、四舍五入到整数、最低 1:
|
||
750 → 375 → 188 → 94 → 47 → 24 → 12 → 6 → 3 → 2 → 1 → 1 …
|
||
(四舍五入用 (x+1)//2,匹配产品给的 750/375/188/94 序列。)
|
||
"""
|
||
r = max(1, base)
|
||
for _ in range(max(0, times_claimed)):
|
||
r = max(1, (r + 1) // 2)
|
||
return r
|
||
|
||
|
||
def notification_max_claims(base: int) -> int:
|
||
"""打开消息提醒最多可领取的次数:减半到最低 1 那次为止(含),之后不再可领。
|
||
|
||
防止「通知一直关着」时无限刷最低档 1 金币(发放上限闸,服务端硬限,不依赖客户端只在
|
||
通知关闭时才展示的约定)。base=750 时序列 750→…→1 在第 11 次落到 1,故上限 11 次。
|
||
"""
|
||
n = 0
|
||
while notification_reward(base, n) > 1:
|
||
n += 1
|
||
return n + 1 # 含第一次减半到 1 的那一次
|
||
|
||
|
||
# ===== 比价战绩里程碑(累计成功比价 N 次,逐档解锁领金币)=====
|
||
# 第 1→6 次的金币奖励(1-based:第 N 次比价解锁第 N 档)。值沿用客户端原型档位。
|
||
# 数据源是 comparison_record 里 status='success' 的条数;每档领一次,
|
||
# comparison_milestone_claim 去重(仿一次性任务)。要调档位/金额直接改这里。
|
||
RECORD_MILESTONES: tuple[int, ...] = (120, 180, 300, 500, 800, 1200)
|
||
RECORD_MILESTONE_COUNT: int = len(RECORD_MILESTONES)
|
||
|
||
|
||
def record_milestone_reward(milestone: int) -> int:
|
||
"""第 milestone 档(1..RECORD_MILESTONE_COUNT)的金币。越界抛 IndexError。"""
|
||
return RECORD_MILESTONES[milestone - 1]
|
||
|
||
|
||
# ===== 上报更低价(人工审核通过发固定金币)=====
|
||
# 用户上报"某平台比我们算的最低价更便宜"+ 截图,经运营后台人工审核通过后发放的固定金币奖励。
|
||
# 与广告/任务同量级;固定值(产品 2026-06 定),要调直接改这里;客户端记录页按 reward_coins 显示。
|
||
PRICE_REPORT_REWARD_COINS: int = 1000
|
||
|
||
|
||
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
|
||
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。
|
||
INVITE_INVITER_COINS: int = 10000
|
||
INVITE_INVITEE_COINS: int = 10000
|
||
# "新用户闸":被邀请人必须在注册后此窗口内绑定才发奖(挡存量老用户互相填码薅羊毛)。
|
||
# 自动绑(剪贴板)在首次注册登录后几秒内发生;留 72h 给手动填码兜底。
|
||
INVITE_NEW_USER_WINDOW_HOURS: int = 72
|
||
|
||
# 指纹归因兜底(剪贴板被覆盖时):落地页 POST /landing-track 存的指纹记录,在此窗口内可被
|
||
# /bind 反查撞库。窗口取舍:太短(24h)覆盖不到"晚上看链接、第二天装"的常见场景;太宽
|
||
# (30d)IP/设备会漂、匹配错率上升。7 天兼顾"看广告→使用"周期与匹配精度。
|
||
INVITE_FP_WINDOW_DAYS: int = 7
|
||
|
||
|
||
# ===== 看激励视频 / 信息流广告发金币 =====
|
||
# eCPM 取自穿山甲 SDK getShowEcpm().getEcpm(),官方口径单位是【分/千次展示】(不是元!
|
||
# csjplatform 文档原文"通过 getEcpm 获取的单位是分")。计算时先 ÷100 转成元;
|
||
# 因子1 档位阈值按【元/千次】定(100/200/400 元 = ¥100/¥200/¥400 CPM,产品口径 2026-06-09)。
|
||
# 注:真实 eCPM 一般 <¥100 CPM,故多落最低档 0.1,高档基本不触发——这是产品有意的取舍。
|
||
# 单次展示收益(元) = eCPM元 ÷ 1000(每千次→单次)。
|
||
AD_ECPM_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
|
||
(0.1, 0, 100),
|
||
(0.3, 101, 200),
|
||
(0.4, 201, 400),
|
||
(0.6, 401, None),
|
||
)
|
||
AD_LT_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
|
||
(2.0, 1, 1),
|
||
(1.5, 2, 2),
|
||
(1.3, 3, 3),
|
||
(1.1, 4, 10),
|
||
(1.0, 11, None),
|
||
)
|
||
|
||
# 客户端可影响的 eCPM 可信上限(分/千次展示):信息流广告一期由客户端上报 eCPM,伪造天价 eCPM
|
||
# 可铸出天量金币(见 calculate_ad_reward_coin)。真实 eCPM 一般 <¥100 CPM(=10000 分),档位表顶档
|
||
# 为 >¥400(=40000 分);取 ¥500 CPM=50000 分,留足真实头部余量又封死伪造值。钳在唯一计算口
|
||
# calculate_ad_reward_coin,故 feed 与 reward_video(回退客户端上报 eCPM 时)一并护住;阈值设在所有
|
||
# 真实值之上,不会少发正规奖励。
|
||
AD_ECPM_MAX_FEN: int = 50_000
|
||
|
||
|
||
def parse_ecpm_fen(ecpm: str | int | float | None) -> float:
|
||
"""解析 eCPM 原始值(穿山甲 getEcpm 原值,单位=分/千次展示)。非法/缺失→0。"""
|
||
if ecpm is None:
|
||
return 0.0
|
||
try:
|
||
value = float(str(ecpm).strip())
|
||
except (TypeError, ValueError):
|
||
return 0.0
|
||
return max(0.0, value)
|
||
|
||
|
||
def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
|
||
"""eCPM 转成元(getEcpm 原值是分,÷100)。因子档位判定与收益换算都用元。"""
|
||
return parse_ecpm_fen(ecpm) / 100.0
|
||
|
||
|
||
def ad_ecpm_factor(ecpm_yuan: float) -> float:
|
||
"""eCPM 档位因子(阈值单位=元/千次):≤100=0.1,101-200=0.3,201-400=0.4,>400=0.6。
|
||
产品口径(2026-06-09):阈值按元判档;真实 eCPM(<¥100 CPM)多落最低档 0.1。"""
|
||
if ecpm_yuan > 400:
|
||
return 0.6
|
||
if ecpm_yuan > 200:
|
||
return 0.4
|
||
if ecpm_yuan > 100:
|
||
return 0.3
|
||
return 0.1
|
||
|
||
|
||
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, count_after_this: int) -> int:
|
||
"""按金币数值体系计算单份广告奖励金币。
|
||
|
||
eCPM 是穿山甲 getEcpm 原值,单位【分/千次展示】;先 ÷100 转成元(因子判档 + 收益换算都用元)。
|
||
单次收益(元)= eCPM元 ÷ 1000(每千次→单次) × 因子1(eCPM 元档) × 因子2(LT);
|
||
再按 1 元=10000 金币取整。count_after_this 为账号累计第 N 次看视频(LT 因子用,不按天重置)。
|
||
|
||
eCPM 在此先钳到 AD_ECPM_MAX_FEN(¥500 CPM):信息流广告一期 eCPM 由客户端上报,伪造天价值
|
||
会铸天量金币;钳在这唯一入口,feed 与 reward_video 回退客户端 eCPM 的路径都护住,且阈值高于
|
||
所有真实值,不影响正规发奖。
|
||
"""
|
||
ecpm_yuan = min(parse_ecpm_yuan(ecpm), AD_ECPM_MAX_FEN / 100.0)
|
||
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(count_after_this)
|
||
return max(0, round(yuan * COIN_PER_YUAN))
|
||
|
||
|
||
# 签到看广告膨胀:S2S 固定补发(原型 2026-06 由 2000 提到 3000,对应 CTA「看广告最高膨胀至3000金币」)。
|
||
SIGNIN_BOOST_COIN: int = 3000
|
||
|
||
|
||
# ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)=====
|
||
# 历史固定金币口径保留项。正式激励视频实发按 calculate_ad_reward_coin(eCPM, 当日第 N 次)
|
||
# 计算;该值只用于旧接口兼容、配置页展示和本地联调兜底。
|
||
AD_REWARD_COIN: int = 666
|
||
# 历史 reward_amount 口径保留项。正式激励视频实发按 eCPM 公式计算。
|
||
MAX_AD_REWARD_COIN: int = 1000
|
||
# 每用户每日发奖次数上限。产品口径:一天最多看 500 次广告。
|
||
DAILY_AD_REWARD_LIMIT: int = 500
|
||
|
||
# ===== 看激励视频每日总时长上限(已停用)=====
|
||
# 旧版本用 50 分钟时长闸;当前产品只保留每日 500 次上限。保留字段返回给旧客户端,
|
||
# 但值为 0,客户端按 limit<=0 视为未启用。
|
||
DAILY_AD_WATCH_SECONDS_LIMIT: int = 0
|
||
# 单次上报观看时长的合理上限(夹紧异常值):激励视频一般 15~60 秒,超 120 秒按 120 记,防刷大值。
|
||
MAX_SINGLE_WATCH_SECONDS: int = 120
|
||
# 每次广告关闭后 3 秒内不允许再次点击观看。沿用 round_count/cooldown_until 字段表达:
|
||
# round_size=1 表示每次成功发奖后都会派生一个 3 秒 cooldown_until。
|
||
VIDEO_ROUND_REQUIRED_COUNT: int = 1
|
||
VIDEO_ROUND_COOLDOWN_SECONDS: int = 3
|
||
|
||
|
||
def resolve_ad_reward_coin(db, reward_amount: str | int | None) -> int: # noqa: ANN001
|
||
"""历史 reward_amount 口径解析函数,保留给旧脚本/旧配置兼容。
|
||
|
||
缺失 / 非数字 / ≤0 → 回退配置单次金币;超过配置单次上限 → 夹紧(均从 app_config 读)。
|
||
注:reward_amount 不参与穿山甲 sign(只签 trans_id),但伪造回调需先伪造合法 sign
|
||
(要 SecurityKey),故能过验签的 reward_amount 即视为可信,这里只防后台配错。
|
||
"""
|
||
default_coin = get_ad_reward_coin(db)
|
||
try:
|
||
coin = int(reward_amount) # type: ignore[arg-type]
|
||
except (TypeError, ValueError):
|
||
return default_coin
|
||
if coin <= 0:
|
||
return default_coin
|
||
return min(coin, get_ad_max_coin(db))
|
||
|
||
|
||
# ===== 配置读取(运营后台可覆盖;app_config 表空时返回上面的常量默认 = 现状不变)=====
|
||
# 业务处用这些 getter(传 db)取值,而非直接用常量,这样 admin 改 app_config 即生效。
|
||
# 函数内延迟 import,避免 rewards ← app_config ← config_schema ← rewards 的模块级循环。
|
||
|
||
def _cfg(db, key: str): # noqa: ANN001
|
||
from app.repositories import app_config
|
||
return app_config.get_value(db, key)
|
||
|
||
|
||
def get_signin_rewards(db) -> list[int]: # noqa: ANN001
|
||
return list(_cfg(db, "signin_rewards"))
|
||
|
||
|
||
def get_min_exchange_coin(db) -> int: # noqa: ANN001
|
||
return int(_cfg(db, "min_exchange_coin"))
|
||
|
||
|
||
def get_withdraw_min_cents(db) -> int: # noqa: ANN001
|
||
return int(_cfg(db, "withdraw_min_cents"))
|
||
|
||
|
||
def get_withdraw_max_cents(db) -> int: # noqa: ANN001
|
||
return int(_cfg(db, "withdraw_max_cents"))
|
||
|
||
|
||
def get_task_rewards(db) -> dict[str, int]: # noqa: ANN001
|
||
return dict(_cfg(db, "task_rewards"))
|
||
|
||
|
||
def get_record_milestones(db) -> list[int]: # noqa: ANN001
|
||
return list(_cfg(db, "record_milestones"))
|
||
|
||
|
||
def get_ad_reward_coin(db) -> int: # noqa: ANN001
|
||
return int(_cfg(db, "ad_reward_coin"))
|
||
|
||
|
||
def get_ad_daily_limit(db) -> int: # noqa: ANN001
|
||
return int(_cfg(db, "ad_daily_limit"))
|
||
|
||
|
||
def get_ad_max_coin(db) -> int: # noqa: ANN001
|
||
return int(_cfg(db, "ad_max_coin"))
|
||
|
||
|
||
def get_ad_round_count(db) -> int: # noqa: ANN001
|
||
return int(_cfg(db, "ad_round_count"))
|
||
|
||
|
||
def get_ad_cooldown_sec(db) -> int: # noqa: ANN001
|
||
return int(_cfg(db, "ad_cooldown_sec"))
|
||
|
||
|
||
def get_signin_boost_coin(db) -> int: # noqa: ANN001
|
||
return int(_cfg(db, "signin_boost_coin"))
|