"""福利 / 激励相关的业务常量与换算规则。 集中放在这里,业务代码引用,避免数值散落各处。这些是**产品规则**(签到奖励、 任务奖励、金币汇率)而非部署配置,所以不走环境变量;要调整直接改这里。 """ 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] # ===== 上报更低价(人工审核通过发固定金币)===== # 用户上报"某平台比我们算的最低价更便宜"+ 截图,经运营后台人工审核通过后发放的固定金币奖励。 # 与广告/任务同量级;固定值(产品 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 单位按"元/千次展示"处理,单次收入 = 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"))