Merge origin/main into feat/a11y-liveness-heartbeat
冲突全在领券「弹窗频控按 App 独立」这一功能——本分支夹带的旧 WIP 草稿(2026-06-12, app_package 可空+NULL 兜底行)与 main 上已合并的最终版 #52(2026-06-14,package NOT NULL +"" 占位)是同一功能的两套不兼容实现。 解决:四个 coupon 文件(api/models/repositories/schemas)一律取 main(#52)——它是 权威已合版,且文件未冲突区已自动并入 #52 的签名,只有取 theirs 才自洽。 Alembic 收敛为单 head: - 删本分支 WIP 迁移 coupon_engagement_app_package(否则与 #52 重复建 per-App 列) 及其仅用于挂接它的两个空 merge 节点 3a9941e76909 / f3d0a16bb4c2 - device_liveness_table 的 down_revision 由 f3d0a16bb4c2 改挂 main 头 store_mapping_tb_dl_invalid 验证:alembic heads 单头;全链在临时 sqlite upgrade head 通过; coupon_prompt_engagement 只有 package、无 app_package;app 全量 import 通过。 注:本分支不再保留他人领券 WIP——无损,#52 即该功能的最终版,已在 main。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+29
-1
@@ -9,11 +9,16 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
# 生产环境 JWT secret 的最小可接受长度(字节)。HS256 推荐高熵随机串;<16 视为弱密钥。
|
||||
_MIN_PROD_SECRET_LEN = 16
|
||||
# 已知的占位默认值(代码里写死的 default),prod 下绝不能沿用。
|
||||
_INSECURE_SECRET_DEFAULTS = frozenset({"change-me", "change-me-admin", ""})
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
@@ -224,6 +229,29 @@ class Settings(BaseSettings):
|
||||
def is_prod(self) -> bool:
|
||||
return self.APP_ENV == "prod"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _enforce_prod_secrets(self) -> "Settings":
|
||||
"""prod 下强校验 JWT secret,弱/默认/空即启动报错(fail-fast,挡住 token 被伪造)。
|
||||
|
||||
只校验两个签发凭证:App 用户的 JWT_SECRET_KEY、后台的 ADMIN_JWT_SECRET——它们沿用默认值
|
||||
时任何人都能伪造 access/admin token → 账号与后台失陷。INTERNAL_API_SECRET 默认空 = 内部端点
|
||||
关闭(返 503),是安全的默认态,故不在此强制。dev 不触发,便于本地直接起。
|
||||
"""
|
||||
if not self.is_prod:
|
||||
return self
|
||||
weak: list[str] = []
|
||||
for name in ("JWT_SECRET_KEY", "ADMIN_JWT_SECRET"):
|
||||
value = getattr(self, name)
|
||||
if value in _INSECURE_SECRET_DEFAULTS or len(value) < _MIN_PROD_SECRET_LEN:
|
||||
weak.append(name)
|
||||
if weak:
|
||||
raise ValueError(
|
||||
f"APP_ENV=prod 但检测到弱/默认密钥: {', '.join(weak)} —— 必须改成 "
|
||||
f"≥{_MIN_PROD_SECRET_LEN} 位高熵随机串(否则 JWT 可被伪造 → 用户/后台账号失陷)。"
|
||||
f"生成示例: python -c \"import secrets; print(secrets.token_urlsafe(48))\""
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
|
||||
+12
-1
@@ -143,6 +143,13 @@ AD_LT_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
|
||||
(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。"""
|
||||
@@ -187,8 +194,12 @@ def calculate_ad_reward_coin(ecpm: str | int | float | None, count_after_this: i
|
||||
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 = parse_ecpm_yuan(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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user