b2f5a53dd8
- GET /invitees: 分页 + 名字降级兜底(昵称→微信昵称→脱敏手机号) - 指纹归因: 落地页采集 + (IP,设备型号)反查撞库 + 时间窗口闸 - test_invite: +5 个列表测试(脱敏/倒序/昵称优先/分页/空), 修指纹测试跨用例串味 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Reviewed-on: #31 Co-authored-by: xiebing <xiebing@wonderable.ai> Co-committed-by: xiebing <xiebing@wonderable.ai>
248 lines
9.5 KiB
Python
248 lines
9.5 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()
|
||
|
||
|
||
# ===== 签到:14 天循环,断签重置回第 1 天 =====
|
||
# 第 1→14 天的金币奖励,签到逻辑用 cycle_day(1..14)索引。
|
||
SIGNIN_REWARDS: tuple[int, ...] = (
|
||
200, 120, 150, 180, 200, 250, 500,
|
||
200, 250, 300, 350, 400, 500, 3000,
|
||
)
|
||
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 -> 奖励金币
|
||
# 打开消息提醒: 1000 金币(=¥0.1, 客户端原型展示口径; 量级与签到/里程碑相称)。
|
||
# 注意: 不再 = 兑换下限(下限已降到 MIN_EXCHANGE_COIN=COIN_PER_CENT=100), test_exchange_flow 改走 grant_coins 直接供款。
|
||
TASK_REWARDS: dict[str, int] = {
|
||
TASK_ENABLE_NOTIFICATION: 1000,
|
||
}
|
||
|
||
|
||
# ===== 比价战绩里程碑(累计成功比价 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]
|
||
|
||
|
||
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
|
||
# 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 单位按"元/千次展示"处理,单次收入 = 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),
|
||
)
|
||
|
||
|
||
def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
|
||
"""解析 eCPM 原始值。当前产品口径:SDK 返回值按"元/千次展示"处理。"""
|
||
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 ad_ecpm_factor(ecpm_yuan: float) -> float:
|
||
"""eCPM 档位因子:0-100=0.1,101-200=0.3,201-400=0.4,>400=0.6。"""
|
||
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(today_count_after_this: int) -> float:
|
||
"""LT 因子。today_count_after_this 是当天累计第 N 条/份广告奖励。"""
|
||
count = max(1, today_count_after_this)
|
||
for factor, lo, hi in AD_LT_FACTOR_TABLE:
|
||
if count >= lo and (hi is None or count <= hi):
|
||
return factor
|
||
return 1.0
|
||
|
||
|
||
def calculate_ad_reward_coin(ecpm: str | int | float | None, today_count_after_this: int) -> int:
|
||
"""按金币数值体系计算单份广告奖励金币。
|
||
|
||
单次奖励(元)=eCPM/1000 × 因子1(eCPM 档) × 因子2(LT);再按 1 元=10000 金币取整。
|
||
"""
|
||
ecpm_yuan = parse_ecpm_yuan(ecpm)
|
||
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(today_count_after_this)
|
||
return max(0, round(yuan * COIN_PER_YUAN))
|
||
|
||
|
||
SIGNIN_BOOST_COIN: int = 2000
|
||
|
||
|
||
# ===== 看激励视频发金币(穿山甲 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"))
|