Compare commits

..

14 Commits

Author SHA1 Message Date
guke 3f449d3600 fix(dev): ensure_pg 智能定位 Docker Desktop(装 D 盘等非默认盘符也能自动拉起)
原先把 Docker Desktop.exe 写死在 %ProgramFiles%\Docker\Docker\,装在 D 盘就自动拉起
失败(明明 docker CLI 已在 PATH 上可用、只是 daemon 没启)。改为按可靠性排序的多路探测:
DOCKER_DESKTOP_EXE 显式指定 → 从 PATH 上 docker CLI 反推安装目录(跟随实际盘符)→
注册表 → 各 Program Files 变体兜底;找不到时提示 DOCKER_DESKTOP_EXE / sqlite 逃生舱。
.env.example 记一笔用法。

顺带修一个先前就红、与现行"sqlite→降级返 True"契约矛盾的测试
(test_ensure_rejects_sqlite → test_ensure_sqlite_escape_hatch)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 14:13:11 +08:00
guke c9ef4464f6 Merge branch 'main' of https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server into chore/local-dev-postgres-docker 2026-07-28 09:59:50 +08:00
guke e052fb778b feat(dev): ensure_pg 加显式 SQLite 逃生舱 + 修 Docker 检测/项目名撞名;新增 run8771 起 admin
- ensure_pg: 无 Docker 时【显式】降级 SQLite(带醒目降级横幅),不再硬失败(反转设计 D4)
- ensure_pg: _docker_cli_ok 改用 `docker --version`(纯客户端),修「装了 Docker 但没启动」
  被 `docker version`(要连 daemon)误判成「没装 CLI」而绕过自动拉起(需求②)
- ensure_pg: 钉死 COMPOSE_PROJECT_NAME=shaguabijia + 清「同名但非本项目」残留容器,
  修跨目录/worktree 切换时 container_name 撞名 + pgdata 卷分裂
- run8771.bat: 新增 admin 后端(:8771)启动脚本,跑 app.admin.main:admin_app(run.bat 对等版)
- 设计文档 §10 记录以上 D4 反转与三处修复

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 19:12:29 +08:00
guke b0482ec157 docs(dev): 记录本地 Docker PostgreSQL 用法,区分生产原生 PG 路径
- postgres-migration.md 增「1.0 Docker 一键起」推荐节
- CLAUDE.md 订正 Dev/Test 已切 Docker PG(不再 SQLite)+ conftest 描述
- init_postgres.py docstring 标明其面向生产原生 PG,本地改用 compose

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:13:51 +08:00
guke 0aee9d4dd0 test(db): 修 test_compare_harvest 外键严格性(切 PG 暴露)
upsert 用例原用合成 user_id=987654,靠 SQLite 不强制外键;PG 强制
comparison_record.user_id → user.id,故改为登录建真实用户再用其 id。
纯测试改动,不动业务逻辑。剩余 5 个红为预存(奖励/透传,与本改动无关)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:49:46 +08:00
guke c6309f0f74 test(dev): conftest 切 shaguabijia_test(Docker PG)
conftest 在 import app 前设 test DATABASE_URL、调 ensure()+ensure_test_db()
引导 PG,fixture 改 drop_all→create_all(防持久卷残留)。ensure_pg 把
_ensure_test_db 提为公开 ensure_test_db(短路路径不建库,测试侧需显式补)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:46:07 +08:00
guke 3b90e2f212 feat(dev): .env.example 默认 DATABASE_URL 切 Docker PostgreSQL
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:38:59 +08:00
guke b2ea6c727c feat(dev): run.sh/run.bat 启动前确保 Docker PostgreSQL 就绪
在 alembic upgrade head 之前调用 scripts.ensure_pg:没起会自动拉起
Docker + PG 容器,失败即退出(run.bat 判 errorlevel)。顺带订正过时的
"sqlite" 注释。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:38:09 +08:00
guke 5e706fd003 refactor(dev): ensure_pg 应用代码评审改进
- 所有 docker 探测/exec 加 per-call timeout(防守护进程半死时无限挂起、绕过总超时)
- _ensure_test_db 改为返回 bool 并由 ensure() 传播;建库失败(含超时)显式告警,
  与并发创建者竞争失败但库已存在(42P04)仍算就绪
- 抽出 _test_db_exists 复用;去掉一处冗余 f-string

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:31:02 +08:00
guke 9c55344e85 fix(dev): ensure_pg 日志对 GBK 控制台编码安全( emoji 不再崩 run.bat)
Windows cmd.exe 默认 GBK,print (U+2705)抛 UnicodeEncodeError → 脚本退非0、
run.bat 误判 ensure_pg 失败。改为启动时对 stdout/stderr 设 errors=backslashreplace:
中文照常,仅不可编码字符转义。顺带订正计划里的测试计数(11)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:16:11 +08:00
guke 4d3b73ae70 feat(dev): scripts/ensure_pg.py 探测/拉起本地 Docker PostgreSQL 2026-07-08 18:09:50 +08:00
guke 5dff56bbb2 feat(dev): docker-compose 起本地 PostgreSQL(含测试库 initdb) 2026-07-08 17:52:25 +08:00
guke c734c00742 docs: 本地开发切 Docker PG 实现计划
7 个 TDD 任务:compose+initdb → ensure_pg.py(单测)→ run 接线 →
.env.example → conftest 切测试库 → 修红用例 → 文档。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:36:41 +08:00
guke 886e781a4f docs: 本地开发切 Docker PG 设计文档
新增 spec:让 dev 运行与 pytest 都跑在 Docker 化的 PostgreSQL 上,退掉
SQLite,使 admin 报表聚合可用 PG 专有函数(不再内存聚合)。方案 A:
docker-compose + scripts/ensure_pg.py,run.bat/run.sh/conftest 共用。

顺带把 .worktrees/ 加入 .gitignore(superpowers 隔离工作区)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:20:11 +08:00
103 changed files with 2415 additions and 9490 deletions
+12 -21
View File
@@ -6,8 +6,17 @@ APP_NAME=shaguabijia-app-server
APP_DEBUG=true APP_DEBUG=true
# ===== 数据库 ===== # ===== 数据库 =====
# SQLite 本地文件路径。生产环境用 /opt/shaguabijia-app-server/data.db # 本地开发/测试统一用 Docker PostgreSQL:run.bat/run.sh 会自动拉起容器
DATABASE_URL=sqlite:///./data/app.db # (docker-compose.yml + scripts/ensure_pg.py)。详见 docs/database/postgres-migration.md。
# 生产用原生 PG,由 scripts/init_postgres.py 写入强随机密码的连接串。
# ⚠️ scheme 必须是 postgresql+psycopg://(psycopg3);不要写成 postgresql://(会去找未装的 psycopg2)。
DATABASE_URL=postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia
# Docker 自动定位:run.bat/run.sh 会自动找 Docker Desktop 并启动——优先从 PATH 上的 docker CLI 反推
# 安装目录(装在 D 盘等非默认盘符也能找到),再退到注册表 / 常见目录。仅当你的安装位置极特殊、自动
# 探测失败时,才需下面这行显式指到 exe(值可含空格,直接写到行尾即可,无需引号):
# DOCKER_DESKTOP_EXE=D:\Program Files\Docker\Docker\Docker Desktop.exe
# 实在不想装/启 Docker → 把上面 DATABASE_URL 改成 sqlite 可降级跑(仅救急,PG 专有 SQL/严格性不被验证):
# DATABASE_URL=sqlite:///./data/app.db
# ===== JWT ===== # ===== JWT =====
# 生产部署务必改成随机长字符串,可用:python -c "import secrets; print(secrets.token_urlsafe(64))" # 生产部署务必改成随机长字符串,可用:python -c "import secrets; print(secrets.token_urlsafe(64))"
@@ -45,9 +54,6 @@ SMS_SEND_INTERVAL_SEC=60
# chuanglan:创蓝云智(253)模板短信,本服务生成码、创蓝只下发、本地校验(Mode B,与极光同);缺凭证 503。 # chuanglan:创蓝云智(253)模板短信,本服务生成码、创蓝只下发、本地校验(Mode B,与极光同);缺凭证 503。
# 用 YZM 前缀验证码账号;服务器出网 IP 需在创蓝控制台加白名单(否则 117)。见 docs/integrations/chuanglan/tpl-send.md。 # 用 YZM 前缀验证码账号;服务器出网 IP 需在创蓝控制台加白名单(否则 117)。见 docs/integrations/chuanglan/tpl-send.md。
SMS_PROVIDER=jiguang SMS_PROVIDER=jiguang
# 极光→创蓝 容灾:主 provider(SMS_PROVIDER)供应商不可用(503)时转投的备。
# 空=关闭 fallback(默认);生产设 chuanglan 开启极光主、创蓝备;置空即秒回退。
SMS_FALLBACK_PROVIDER=
ALIYUN_SMS_ACCESS_KEY_ID= ALIYUN_SMS_ACCESS_KEY_ID=
ALIYUN_SMS_ACCESS_KEY_SECRET= ALIYUN_SMS_ACCESS_KEY_SECRET=
ALIYUN_SMS_SIGN_NAME= ALIYUN_SMS_SIGN_NAME=
@@ -72,10 +78,8 @@ CHUANGLAN_SMS_TIMEOUT_SEC=10
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】 # 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
# (real 模式下也跳过校验)、每次登录【都重走新手引导】,并有【每日登录上限】防被人猜到号后脚本刷。 # (real 模式下也跳过校验)、每次登录【都重走新手引导】,并有【每日登录上限】防被人猜到号后脚本刷。
# 逻辑见 app/core/test_account.py,与其他业务解耦。 # 逻辑见 app/core/test_account.py,与其他业务解耦。
# ⚠️ 两项都留空 = 关闭整功能(生产默认)。多账号用英文逗号分隔,改完重启生效 # ⚠️ 留空 = 关闭整功能(生产默认);要启用才填号(如 11111111111)。改完重启生效,随时可清空停用
TEST_ACCOUNT_PHONE= TEST_ACCOUNT_PHONE=
# 推荐新配置;例如联调环境填 11111111111,22222222222。
TEST_ACCOUNT_PHONES=
# 该测试号每日最多登录次数,当日超过即拒绝(429),次日归零。 # 该测试号每日最多登录次数,当日超过即拒绝(429),次日归零。
TEST_ACCOUNT_DAILY_LIMIT=500 TEST_ACCOUNT_DAILY_LIMIT=500
@@ -180,16 +184,3 @@ PANGLE_REPORT_SITE_ID_TEST=5832303
# APPLOG_MAX_BATCH=500 # 单批最大条数(超 → 422;导入期常量,改需重启) # APPLOG_MAX_BATCH=500 # 单批最大条数(超 → 422;导入期常量,改需重启)
# APPLOG_MAX_BODY_BYTES=2097152 # 请求体上限 2MB(超 → 413;运行期可调) # APPLOG_MAX_BODY_BYTES=2097152 # 请求体上限 2MB(超 → 413;运行期可调)
# APPLOG_MAX_MSG_BYTES=8192 # 单条 msg 超此字节数截断 # APPLOG_MAX_MSG_BYTES=8192 # 单条 msg 超此字节数截断
# ===== 荣耀 Push =====
HONOR_PUSH_APP_ID=
HONOR_PUSH_CLIENT_ID=
HONOR_PUSH_CLIENT_SECRET=
# 0=正式消息(默认);1=测试消息(仅开发联调,生产必须保持 0)
HONOR_PUSH_TARGET_USER_TYPE=0
# ===== 华为 Push =====
HUAWEI_PUSH_APP_ID=
HUAWEI_PUSH_APP_SECRET=
# 0=正式消息(默认);1=测试消息(仅开发联调,生产必须保持 0)
HUAWEI_PUSH_TARGET_USER_TYPE=0
+3
View File
@@ -60,3 +60,6 @@ tests/meituan_coupon_bj.tsv
tests/meituan_coupon_data.tsv tests/meituan_coupon_data.tsv
tests/meituan_coupon_fz.tsv tests/meituan_coupon_fz.tsv
tests/meituan_coupon_xm.tsv tests/meituan_coupon_xm.tsv
# git worktrees (superpowers 隔离工作区)
.worktrees/
+3 -3
View File
@@ -76,8 +76,8 @@ Endpoints under `app/api/internal/` are for server-to-server communication (pric
## Database ## Database
- **Dev**: SQLite (`sqlite:///./data/app.db`), `check_same_thread=False`, no connection pool. - **Dev/Test**: Docker PostgreSQL 16 — `run.sh`/`run.bat` auto-start it via `scripts/ensure_pg.py` + `docker-compose.yml`; `.env.example` ships the PG URL by default; pytest uses the same container's `shaguabijia_test` DB. **Local no longer uses SQLite** (the SQLite branch in `db/session.py` is retained as a fallback only).
- **Prod**: PostgreSQL — just change `DATABASE_URL` in `.env`. Pool size 10 + max overflow 20, pool_recycle 3600. - **Prod**: native PostgreSQL — bootstrap with `scripts/init_postgres.py` (no Docker). Pool size 10 + max overflow 20, pool_recycle 3600.
- **Migrations**: Alembic with `render_as_batch` for SQLite compatibility. ~60+ migration files in `alembic/versions/` (filenames are descriptive, not hex prefixes). Migration chain uses `down_revision` within each file. - **Migrations**: Alembic with `render_as_batch` for SQLite compatibility. ~60+ migration files in `alembic/versions/` (filenames are descriptive, not hex prefixes). Migration chain uses `down_revision` within each file.
- **New models**: Define in `app/models/`, import in `app/models/__init__.py`, then run `alembic revision --autogenerate`. - **New models**: Define in `app/models/`, import in `app/models/__init__.py`, then run `alembic revision --autogenerate`.
@@ -89,7 +89,7 @@ All config via `pydantic-settings` in `app/core/config.py`. Single `Settings` cl
## Testing ## Testing
- `tests/conftest.py`: Sets env vars BEFORE imports, creates temp SQLite file, builds all tables with `Base.metadata.create_all()`, tears down with `drop_all()` + unlink. - `tests/conftest.py`: Sets env vars BEFORE imports, ensures the Docker PG `shaguabijia_test` DB via `scripts/ensure_pg.py`, builds all tables with `Base.metadata.create_all()` (drop+create for a clean start), tears down with `drop_all()`.
- External integrations are monkeypatched in tests (e.g., WeChat Pay, Jiguang, Pangle callbacks) — tests never make real HTTP calls. - External integrations are monkeypatched in tests (e.g., WeChat Pay, Jiguang, Pangle callbacks) — tests never make real HTTP calls.
- `TestClient` from FastAPI is used for all tests. Rate limiting is disabled globally in tests. - `TestClient` from FastAPI is used for all tests. Rate limiting is disabled globally in tests.
@@ -1,26 +0,0 @@
"""merge comparison platforms + fail_reason heads
Revision ID: 6d2309208549
Revises: comparison_platforms_col, comparison_record_fail_reason
Create Date: 2026-07-29 01:48:41.868083
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '6d2309208549'
down_revision: Union[str, Sequence[str], None] = ('comparison_platforms_col', 'comparison_record_fail_reason')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -1,47 +0,0 @@
"""add platforms unified array column to comparison_record
展示模型统一数组(pricebot done.params.platforms 原样存): 每平台一行、自带
status/is_best/display, 记录页据此直接渲染, 不再靠 comparison_results + 客户端合并 + 前端派生。
纯新增列, 老记录为空 → 前端回退老 comparison_results。
Revision ID: comparison_platforms_col
Revises: user_manual_risk_fields
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "comparison_platforms_col"
down_revision: str | None = "user_manual_risk_fields"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
def upgrade() -> None:
# 幂等: 线上为了提前给历史数据补 platforms(2026-07-29), 已手动
# `ALTER TABLE comparison_record ADD COLUMN IF NOT EXISTS platforms jsonb
# NOT NULL DEFAULT '[]'::jsonb`(与本 migration 定义一致)。列已存在时跳过,
# 否则上线 alembic upgrade head 会撞 DuplicateColumn 直接部署失败。
bind = op.get_bind()
cols = {c["name"] for c in sa.inspect(bind).get_columns("comparison_record")}
if "platforms" in cols:
return
with op.batch_alter_table("comparison_record") as batch_op:
batch_op.add_column(
sa.Column(
"platforms", _JSON, nullable=False,
server_default=sa.text("'[]'"),
)
)
def downgrade() -> None:
with op.batch_alter_table("comparison_record") as batch_op:
batch_op.drop_column("platforms")
@@ -1,49 +0,0 @@
"""comparison_record.fail_reason (失败卡展示原因)
Revision ID: comparison_record_fail_reason
Revises: user_manual_risk_fields
Create Date: 2026-07-28 12:00:00.000000
失败记录的展示原因:information 具体则=它;笼统则由写路径从 platform_results 捞出的
业务原因;纯系统失败为 None(端侧品牌兜底)。见 repositories.comparison._derive_fail_display。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'comparison_record_fail_reason'
down_revision: Union[str, Sequence[str], None] = 'user_manual_risk_fields'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
batch_op.add_column(sa.Column('fail_reason', sa.String(length=256), nullable=True))
# 回填老失败记录:information 具体的直接搬过来(笼统/系统失败留 None → 端侧品牌兜底)。
# 新记录由写路径 _derive_fail_display 落库(含 platform_results 救援/补判),不走这条。
# platform_results 只在 raw_payload 里,SQL 里不易解析,故老记录不做救援/补判(可接受:
# 老 mixed/打烊记录回退品牌兜底);具体 information 的老记录本次即可显示真实原因。
op.execute(
"""
UPDATE comparison_record
SET fail_reason = information
WHERE status = 'failed'
AND information IS NOT NULL
AND information <> ''
AND information NOT IN (
'比价过程出错,请稍后重试',
'比价出错',
'比价未完成',
'done 参数缺少可验证的目标平台结果'
)
"""
)
def downgrade() -> None:
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
batch_op.drop_column('fail_reason')
@@ -1,31 +0,0 @@
"""guide video play count is independent for coupon and comparison
Revision ID: guide_video_scene_unique
Revises: 6d2309208549
"""
from alembic import op
revision = "guide_video_scene_unique"
down_revision = "6d2309208549"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_index("uq_guide_video_play_user_seq", table_name="guide_video_play")
op.create_index(
"uq_guide_video_play_user_scene_seq",
"guide_video_play",
["user_id", "scene", "seq"],
unique=True,
)
def downgrade() -> None:
op.drop_index("uq_guide_video_play_user_scene_seq", table_name="guide_video_play")
op.create_index(
"uq_guide_video_play_user_seq",
"guide_video_play",
["user_id", "seq"],
unique=True,
)
@@ -1,164 +0,0 @@
"""guide video prepare/start/ten-circle reward state machine
Revision ID: guide_video_ten_circle_v2
Revises: guide_video_scene_unique
"""
from __future__ import annotations
import json
from pathlib import Path
import sqlalchemy as sa
from alembic import op
revision = "guide_video_ten_circle_v2"
down_revision = "guide_video_scene_unique"
branch_labels = None
depends_on = None
def _backfill_configs(connection) -> None:
from app.core import media
from app.core.config import settings
for key in ("coupon_guide_video", "comparison_guide_video"):
row = connection.execute(
sa.text("SELECT value FROM app_config WHERE key = :key"), {"key": key}
).first()
if not row:
continue
raw = row[0]
value = json.loads(raw) if isinstance(raw, str) else dict(raw or {})
if value.get("guide_video_v2_migrated") is True:
continue
maximum = int(value.get("max_plays", 3) or 0)
reward = int(value.get("reward_coin", 100) or 0)
video_url = str(value.get("video_url") or "")
error = None
analysis = None
if not 1 <= maximum <= 50:
error = "旧播放次数不符合 150 约束"
elif not 10 <= reward <= 10_000 or reward % 10:
error = "旧金币总价不符合 10~10000 且为 10 的倍数约束"
elif not video_url:
error = "尚未上传视频"
else:
prefix = f"{settings.MEDIA_URL_PREFIX}/guide_video/"
if not video_url.startswith(prefix):
error = "旧视频不是服务端托管文件,请重新上传"
else:
path = Path(settings.MEDIA_ROOT) / "guide_video" / video_url[len(prefix):]
if not path.is_file():
error = "旧视频文件缺失,请重新上传"
else:
try:
analysis = media._probe_guide_video(path)
except media.MediaError as exc:
error = str(exc)
value["config_version"] = int(value.get("config_version", 0) or 0) + 1
value["guide_video_v2_migrated"] = True
if analysis:
value.update(analysis)
else:
value.update(
enabled=False,
duration_ms=None,
video_codec=None,
audio_codec=None,
analysis_status="invalid" if video_url else "missing",
analysis_error=error,
)
statement = sa.text(
"UPDATE app_config SET value = :value WHERE key = :key"
).bindparams(sa.bindparam("value", type_=sa.JSON()))
connection.execute(statement, {"key": key, "value": value})
def _seal_legacy_plays(connection) -> None:
"""旧整笔发奖 token 永久封口;旧未发奖会话终止但仍保留计次行。"""
connection.execute(
sa.text(
"""
UPDATE guide_video_play
SET status = CASE WHEN status = 'granted' THEN 'legacy_completed' ELSE 'legacy_closed' END,
settled_circles = CASE WHEN status = 'granted' THEN 10 ELSE 0 END,
prepared_at = started_at,
expires_at = started_at
WHERE status IN ('granted', 'playing')
"""
)
)
def _prepare_downgrade(connection) -> None:
"""清除未起播计划,并把所有保留 token 封成旧版不可再领奖的 granted。"""
connection.execute(
sa.text("DELETE FROM guide_video_play WHERE status = 'prepared'")
)
connection.execute(
sa.text(
"""
UPDATE guide_video_play
SET status = 'granted',
completed = CASE WHEN settled_circles >= 10 THEN 1 ELSE completed END,
granted_at = CASE
WHEN settled_circles >= 10 AND granted_at IS NULL THEN started_at
ELSE granted_at
END
WHERE status IN (
'started', 'completed', 'legacy_completed', 'legacy_closed'
)
"""
)
)
# 防御未知/中间状态:宁可删除,也绝不让旧应用把它当 playing 整笔发奖。
connection.execute(
sa.text(
"""
DELETE FROM guide_video_play
WHERE seq IS NULL OR started_at IS NULL OR status != 'granted'
"""
)
)
def upgrade() -> None:
with op.batch_alter_table("guide_video_play") as batch:
batch.alter_column("seq", existing_type=sa.Integer(), nullable=True)
batch.alter_column("started_at", existing_type=sa.DateTime(timezone=True), nullable=True)
batch.add_column(sa.Column("duration_ms", sa.Integer(), nullable=False, server_default="0"))
batch.add_column(sa.Column("config_version", sa.Integer(), nullable=False, server_default="0"))
batch.add_column(sa.Column("settled_circles", sa.Integer(), nullable=False, server_default="0"))
batch.add_column(
sa.Column("prepared_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now())
)
batch.add_column(sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True))
batch.alter_column("status", existing_type=sa.String(length=16), type_=sa.String(length=24))
op.create_index("ix_guide_video_play_expires_at", "guide_video_play", ["expires_at"])
op.create_index(
"ux_coin_transaction_guide_video_ref",
"coin_transaction",
["user_id", "biz_type", "ref_id"],
unique=True,
sqlite_where=sa.text("biz_type = 'guide_video' AND ref_id IS NOT NULL"),
postgresql_where=sa.text("biz_type = 'guide_video' AND ref_id IS NOT NULL"),
)
connection = op.get_bind()
_seal_legacy_plays(connection)
_backfill_configs(connection)
def downgrade() -> None:
_prepare_downgrade(op.get_bind())
op.drop_index("ux_coin_transaction_guide_video_ref", table_name="coin_transaction")
op.drop_index("ix_guide_video_play_expires_at", table_name="guide_video_play")
with op.batch_alter_table("guide_video_play") as batch:
batch.alter_column("status", existing_type=sa.String(length=24), type_=sa.String(length=16))
batch.drop_column("expires_at")
batch.drop_column("prepared_at")
batch.drop_column("settled_circles")
batch.drop_column("config_version")
batch.drop_column("duration_ms")
batch.alter_column("started_at", existing_type=sa.DateTime(timezone=True), nullable=False)
batch.alter_column("seq", existing_type=sa.Integer(), nullable=False)
@@ -1,193 +0,0 @@
"""store all global limit values in one complete JSON document
Revision ID: limit_policy_global_bundle
Revises: limit_policy_whitelist
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "limit_policy_global_bundle"
down_revision: str | Sequence[str] | None = "limit_policy_whitelist"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
_BUNDLE_KEY = "limit_policy_global"
# rule_code, old sparse key, default, legacy structured key, legacy JSON field
_RULES: tuple[tuple[str, str, int, str | None, str | None], ...] = (
("compare.start.daily", "compare_daily_limit", 100, None, None),
("sms.send.hourly", "sms_send_hourly_limit", 5, None, None),
("sms.send.daily", "sms_send_daily_limit", 20, None, None),
("sms.phone.cooldown", "sms_phone_cooldown_seconds", 60, None, None),
("sms.code.failed_attempts", "sms_code_max_failed_attempts", 5, None, None),
("sms.login.hourly", "sms_login_hourly_limit", 5, None, None),
("wechat.bind.hourly", "wechat_bind_sms_hourly_limit", 5, None, None),
("wechat.conflict.hourly", "wechat_conflict_hourly_limit", 5, None, None),
(
"ad.reward_video.daily",
"ad_reward_video_daily_limit",
500,
"ad_daily_limit",
None,
),
(
"ad.feed.daily",
"ad_feed_daily_limit",
500,
"ad_daily_limit",
None,
),
("ad.reward_video.cooldown", "ad_cooldown_sec", 3, None, None),
(
"guide.video.lifetime",
"guide_video_max_plays",
3,
"coupon_guide_video",
"max_plays",
),
("phone.rebind.days", "phone_rebind_days", 30, None, None),
("risk.sms.hourly", "risk_sms_hourly_threshold", 5, None, None),
(
"risk.oneclick.daily",
"risk_oneclick_daily_threshold",
20,
None,
None,
),
(
"risk.compare.daily",
"risk_compare_daily_threshold",
100,
None,
None,
),
)
def _table() -> sa.TableClause:
return sa.table(
"app_config",
sa.column("key", sa.String(64)),
sa.column("value", _JSON),
sa.column("updated_by_admin_id", sa.Integer),
sa.column("updated_at", sa.DateTime(timezone=True)),
)
def _row(conn, table, key: str):
return conn.execute(
sa.select(
table.c.value,
table.c.updated_by_admin_id,
).where(table.c.key == key)
).mappings().first()
def _int_or_none(value: Any) -> int | None:
if isinstance(value, bool):
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def upgrade() -> None:
conn = op.get_bind()
table = _table()
values = {rule_code: default for rule_code, _, default, _, _ in _RULES}
# Old shared/structured values are the lowest-precedence compatibility
# source. Dedicated sparse keys override them.
for rule_code, _, _, legacy_key, legacy_field in _RULES:
if legacy_key is None:
continue
legacy = _row(conn, table, legacy_key)
if legacy is None:
continue
raw = legacy["value"]
if legacy_field is not None:
raw = raw.get(legacy_field) if isinstance(raw, dict) else None
parsed = _int_or_none(raw)
if parsed is not None:
values[rule_code] = parsed
for rule_code, sparse_key, _, _, _ in _RULES:
sparse = _row(conn, table, sparse_key)
parsed = _int_or_none(sparse["value"]) if sparse is not None else None
if parsed is not None:
values[rule_code] = parsed
# If a deployment already wrote the new key, preserve it over old keys.
bundle = _row(conn, table, _BUNDLE_KEY)
if bundle is not None and isinstance(bundle["value"], dict):
for rule_code, raw in bundle["value"].items():
if rule_code not in values:
continue
parsed = _int_or_none(raw)
if parsed is not None:
values[rule_code] = parsed
if bundle is None:
conn.execute(
table.insert().values(
key=_BUNDLE_KEY,
value=values,
updated_by_admin_id=None,
)
)
else:
conn.execute(
table.update()
.where(table.c.key == _BUNDLE_KEY)
.values(value=values, updated_at=sa.func.now())
)
sparse_keys = [sparse_key for _, sparse_key, _, _, _ in _RULES]
conn.execute(table.delete().where(table.c.key.in_(sparse_keys)))
def downgrade() -> None:
conn = op.get_bind()
table = _table()
bundle = _row(conn, table, _BUNDLE_KEY)
values = (
bundle["value"]
if bundle is not None and isinstance(bundle["value"], dict)
else {}
)
admin_id = bundle["updated_by_admin_id"] if bundle is not None else None
for rule_code, sparse_key, default, _, _ in _RULES:
value = _int_or_none(values.get(rule_code))
if value is None:
value = default
existing = _row(conn, table, sparse_key)
if existing is None:
conn.execute(
table.insert().values(
key=sparse_key,
value=value,
updated_by_admin_id=admin_id,
)
)
else:
conn.execute(
table.update()
.where(table.c.key == sparse_key)
.values(
value=value,
updated_by_admin_id=admin_id,
updated_at=sa.func.now(),
)
)
conn.execute(table.delete().where(table.c.key == _BUNDLE_KEY))
-128
View File
@@ -1,128 +0,0 @@
"""add per-subject limit policy whitelist
Revision ID: limit_policy_whitelist
Revises: push_binding_isolation
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "limit_policy_whitelist"
down_revision: str | Sequence[str] | None = "push_binding_isolation"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
_PAGE = "limit-whitelist"
_DEFAULT_ROLES = ("operator", "tech")
def _role_table() -> sa.TableClause:
return sa.table(
"admin_role",
sa.column("name", sa.String),
sa.column("pages", _JSON),
)
def _add_default_role_permissions() -> None:
"""Grant the page without replacing existing role customisations."""
role = _role_table()
conn = op.get_bind()
rows = conn.execute(
sa.select(role.c.name, role.c.pages).where(
role.c.name.in_(_DEFAULT_ROLES)
)
).all()
for name, pages in rows:
current_pages = list(pages or [])
if _PAGE not in current_pages:
conn.execute(
role.update()
.where(role.c.name == name)
.values(pages=[*current_pages, _PAGE])
)
def _remove_default_role_permissions() -> None:
role = _role_table()
conn = op.get_bind()
rows = conn.execute(
sa.select(role.c.name, role.c.pages).where(
role.c.name.in_(_DEFAULT_ROLES)
)
).all()
for name, pages in rows:
current_pages = list(pages or [])
if _PAGE in current_pages:
conn.execute(
role.update()
.where(role.c.name == name)
.values(
pages=[page for page in current_pages if page != _PAGE]
)
)
def upgrade() -> None:
op.create_table(
"limit_policy_override",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("subject_type", sa.String(length=16), nullable=False),
sa.Column("subject_value", sa.String(length=128), nullable=False),
sa.Column("rule_code", sa.String(length=64), nullable=False),
sa.Column("mode", sa.String(length=24), nullable=False),
sa.Column("limit_value", sa.Integer(), nullable=True),
sa.Column(
"enabled", sa.Boolean(), server_default=sa.true(), nullable=False
),
sa.Column("starts_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("reset_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("reason", sa.String(length=256), nullable=True),
sa.Column("created_by_admin_id", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"subject_type",
"subject_value",
"rule_code",
name="uq_limit_policy_subject_rule",
),
)
op.create_index(
"ix_limit_policy_lookup",
"limit_policy_override",
["subject_type", "subject_value", "rule_code", "enabled"],
unique=False,
)
op.create_index(
"ix_limit_policy_expires",
"limit_policy_override",
["expires_at"],
unique=False,
)
_add_default_role_permissions()
def downgrade() -> None:
_remove_default_role_permissions()
op.drop_index("ix_limit_policy_expires", table_name="limit_policy_override")
op.drop_index("ix_limit_policy_lookup", table_name="limit_policy_override")
op.drop_table("limit_policy_override")
@@ -1,89 +0,0 @@
"""isolate vendor push binding across user accounts
Revision ID: push_binding_isolation
Revises: guide_video_ten_circle_v2
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "push_binding_isolation"
down_revision = "guide_video_ten_circle_v2"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("device_liveness") as batch_op:
batch_op.add_column(sa.Column("push_binding_id", sa.String(length=128), nullable=True))
batch_op.add_column(
sa.Column(
"push_binding_revoked",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
)
)
batch_op.create_index(
"ix_device_liveness_push_binding_id",
["push_binding_id"],
unique=False,
)
connection = op.get_bind()
device = sa.table(
"device_liveness",
sa.column("id", sa.Integer),
sa.column("push_vendor", sa.String),
sa.column("push_token", sa.String),
sa.column("updated_at", sa.DateTime),
)
connection.execute(
device.update()
.where(
sa.or_(
device.c.push_vendor == "",
device.c.push_token == "",
)
)
.values(push_vendor=None, push_token=None)
)
rows = connection.execute(
sa.select(device.c.id, device.c.push_vendor, device.c.push_token)
.where(
device.c.push_vendor.is_not(None),
device.c.push_token.is_not(None),
)
.order_by(device.c.updated_at.desc(), device.c.id.desc())
)
seen: set[tuple[str, str]] = set()
duplicate_ids: list[int] = []
for row in rows:
key = (row.push_vendor, row.push_token)
if key in seen:
duplicate_ids.append(row.id)
else:
seen.add(key)
if duplicate_ids:
connection.execute(
device.update()
.where(device.c.id.in_(duplicate_ids))
.values(push_vendor=None, push_token=None)
)
with op.batch_alter_table("device_liveness") as batch_op:
batch_op.create_unique_constraint(
"uq_device_liveness_vendor_token",
["push_vendor", "push_token"],
)
def downgrade() -> None:
with op.batch_alter_table("device_liveness") as batch_op:
batch_op.drop_constraint("uq_device_liveness_vendor_token", type_="unique")
batch_op.drop_index("ix_device_liveness_push_binding_id")
batch_op.drop_column("push_binding_revoked")
batch_op.drop_column("push_binding_id")
+1 -3
View File
@@ -1,6 +1,6 @@
"""Admin 后台 FastAPI app(独立进程)。 """Admin 后台 FastAPI app(独立进程)。
启动:uvicorn app.admin.main:admin_app --host 127.0.0.1 --port 8773 启动:uvicorn app.admin.main:admin_app --host 127.0.0.1 --port 8771
复用 App 的 DB/models/repositories/integrations;鉴权独立(admin JWT,见 app/admin/security.py)。 复用 App 的 DB/models/repositories/integrations;鉴权独立(admin JWT,见 app/admin/security.py)。
现有 app.main:app 不 import 本模块,两进程互不影响。 现有 app.main:app 不 import 本模块,两进程互不影响。
""" """
@@ -31,7 +31,6 @@ from app.admin.routers.feedback import router as feedback_router
from app.admin.routers.feedback_qr import router as feedback_qr_router from app.admin.routers.feedback_qr import router as feedback_qr_router
from app.admin.routers.guide_video import router as guide_video_router from app.admin.routers.guide_video import router as guide_video_router
from app.admin.routers.huawei_review import router as huawei_review_router from app.admin.routers.huawei_review import router as huawei_review_router
from app.admin.routers.limit_whitelist import router as limit_whitelist_router
from app.admin.routers.onboarding import router as onboarding_router from app.admin.routers.onboarding import router as onboarding_router
from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router
from app.admin.routers.ops_stat_config import router as ops_stat_config_router from app.admin.routers.ops_stat_config import router as ops_stat_config_router
@@ -104,7 +103,6 @@ admin_app.include_router(wallet_router)
admin_app.include_router(withdraw_router) admin_app.include_router(withdraw_router)
admin_app.include_router(price_report_router) admin_app.include_router(price_report_router)
admin_app.include_router(risk_monitor_router) admin_app.include_router(risk_monitor_router)
admin_app.include_router(limit_whitelist_router)
admin_app.include_router(feedback_router) admin_app.include_router(feedback_router)
admin_app.include_router(event_logs_router) admin_app.include_router(event_logs_router)
admin_app.include_router(analytics_health_router) admin_app.include_router(analytics_health_router)
+1 -3
View File
@@ -40,7 +40,6 @@ PERMISSION_CATALOG: list[dict] = [
{"key": "analytics-health", "label": "埋点成功率"}, {"key": "analytics-health", "label": "埋点成功率"},
{"key": "event-logs", "label": "埋点日志"}, {"key": "event-logs", "label": "埋点日志"},
{"key": "audit-logs", "label": "审计日志"}, {"key": "audit-logs", "label": "审计日志"},
{"key": "limit-whitelist", "label": "白名单"},
]}, ]},
{"group": "其他", "pages": [ {"group": "其他", "pages": [
{"key": "admins", "label": "权限管理"}, {"key": "admins", "label": "权限管理"},
@@ -59,14 +58,13 @@ BUILTIN_ROLES: list[dict] = [
{"name": "operator", "label": "运营", "pages": [ {"name": "operator", "label": "运营", "pages": [
"dashboard", "coupon-data", "ad-revenue-report", "comparison-records", "dashboard", "coupon-data", "ad-revenue-report", "comparison-records",
"cps", "risk-monitor", "device-liveness", "price-reports", "feedbacks", "huawei-review", "cps", "risk-monitor", "device-liveness", "price-reports", "feedbacks", "huawei-review",
"limit-whitelist",
]}, ]},
{"name": "finance", "label": "财务", "pages": [ {"name": "finance", "label": "财务", "pages": [
"dashboard", "ad-revenue-report", "cps", "invite-withdraws", "withdraws", "dashboard", "ad-revenue-report", "cps", "invite-withdraws", "withdraws",
]}, ]},
{"name": "tech", "label": "技术", "pages": [ {"name": "tech", "label": "技术", "pages": [
"dashboard", "risk-monitor", "device-liveness", "analytics-health", "config", "ad-revenue", "huawei-review", "dashboard", "risk-monitor", "device-liveness", "analytics-health", "config", "ad-revenue", "huawei-review",
"event-logs", "audit-logs", "limit-whitelist", "event-logs", "audit-logs",
]}, ]},
] ]
+5 -5
View File
@@ -43,7 +43,7 @@ _KNOWN_PROD_BUSINESS_CODE_IDS = frozenset({"104098712", "104099389"})
_TEST_BUSINESS_CODE_IDS = frozenset({"104127529", "104127626", "104137445"}) _TEST_BUSINESS_CODE_IDS = frozenset({"104127529", "104127626", "104137445"})
def business_code_ids(db: Session, app_env: str | None) -> set[str]: def _business_code_ids(db: Session, app_env: str | None) -> set[str]:
"""返回指定应用环境下可用于业务收益对账的 GroMore 聚合代码位。""" """返回指定应用环境下可用于业务收益对账的 GroMore 聚合代码位。"""
prod_config = app_config.get_ad_config(db) prod_config = app_config.get_ad_config(db)
prod_ids = set(_KNOWN_PROD_BUSINESS_CODE_IDS) | { prod_ids = set(_KNOWN_PROD_BUSINESS_CODE_IDS) | {
@@ -320,10 +320,10 @@ def ad_revenue_report(
# 业务口径仅保留正式配置/测试业务链路实际使用的代码位。穿山甲“全量”还包含广告测试 # 业务口径仅保留正式配置/测试业务链路实际使用的代码位。穿山甲“全量”还包含广告测试
# demo、插屏等没有客户端收益上报的曝光,两边直接比较会天然产生假差额。 # demo、插屏等没有客户端收益上报的曝光,两边直接比较会天然产生假差额。
business_ids: set[str] | None = None business_code_ids: set[str] | None = None
if revenue_scope == "business": if revenue_scope == "business":
business_ids = business_code_ids(db, app_env) business_code_ids = _business_code_ids(db, app_env)
events = [e for e in events if e.get("our_code_id") in business_ids] events = [e for e in events if e.get("our_code_id") in business_code_ids]
# 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排; # 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排;
# 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。 # 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。
@@ -381,7 +381,7 @@ def ad_revenue_report(
date_from=date_from, date_from=date_from,
date_to=date_to, date_to=date_to,
app_env=app_env, app_env=app_env,
our_code_ids=business_ids, our_code_ids=business_code_ids,
) )
if pangle_aggs: if pangle_aggs:
by_date = {a["date"]: a for a in pangle_aggs} by_date = {a["date"]: a for a in pangle_aggs}
-583
View File
@@ -1,583 +0,0 @@
"""CRUD and presentation helpers for limit policy overrides."""
from __future__ import annotations
from datetime import UTC, datetime
from hashlib import blake2b
from sqlalchemy import String, cast, func, or_, select, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core import limit_policy
from app.models.app_config import AppConfig
from app.models.comparison import ComparisonRecord
from app.models.limit_policy import LimitPolicyOverride
from app.models.risk import BehaviorEvent
from app.models.user import User
from app.repositories import risk as risk_repo
class DuplicateOverrideError(Exception):
pass
def lock_subject(db: Session, *, subject_type: str, subject_value: str) -> None:
"""Serialize writes for one logical whitelist subject on PostgreSQL.
Row locks cannot protect a brand-new subject because there is no row to
lock yet. A transaction-scoped advisory lock closes that gap and also
serializes concurrent appends that touch different rule rows.
"""
bind = db.get_bind()
if bind.dialect.name != "postgresql":
return
identity = f"limit-whitelist:{subject_type}:{subject_value}".encode()
lock_id = int.from_bytes(
blake2b(identity, digest_size=8).digest(),
byteorder="big",
signed=True,
)
db.execute(
text("SELECT pg_advisory_xact_lock(:lock_id)"),
{"lock_id": lock_id},
)
_EVENT_SOURCE_LABELS = {
risk_repo.EVENT_SMS_SEND: "短信验证码",
risk_repo.EVENT_SMS_LOGIN: "短信登录",
risk_repo.EVENT_ONECLICK_LOGIN: "一键登录",
}
def _matching_users(keyword: str):
pattern = f"%{keyword}%"
return select(User.id).where(
or_(
cast(User.id, String).ilike(pattern),
User.username.ilike(pattern),
User.phone.ilike(pattern),
User.nickname.ilike(pattern),
)
)
def _user_maps(
db: Session, *, user_ids: set[int], phones: set[str]
) -> tuple[dict[int, User], dict[str, User]]:
conditions = []
if user_ids:
conditions.append(User.id.in_(user_ids))
if phones:
conditions.append(User.phone.in_(phones))
if not conditions:
return {}, {}
users = list(db.scalars(select(User).where(or_(*conditions))).all())
return {user.id: user for user in users}, {user.phone: user for user in users}
def _behavior_device_candidates(db: Session, *, keyword: str | None, limit: int) -> list[dict]:
conditions = [
BehaviorEvent.subject_type == limit_policy.SUBJECT_DEVICE,
BehaviorEvent.subject_id != "",
BehaviorEvent.subject_id.not_like(f"{limit_policy.LEGACY_IP_DEVICE_PREFIX}%"),
]
if keyword:
value = keyword.strip()
pattern = f"%{value}%"
matched_users = _matching_users(value)
conditions.append(
or_(
BehaviorEvent.subject_id.ilike(pattern),
BehaviorEvent.device_id.ilike(pattern),
BehaviorEvent.device_model.ilike(pattern),
BehaviorEvent.phone.ilike(pattern),
cast(BehaviorEvent.user_id, String).ilike(pattern),
BehaviorEvent.user_id.in_(matched_users),
BehaviorEvent.phone.in_(
select(User.phone).where(User.id.in_(_matching_users(value)))
),
)
)
ranked = (
select(
BehaviorEvent.subject_id.label("device_id"),
BehaviorEvent.event_type,
BehaviorEvent.user_id,
BehaviorEvent.phone,
BehaviorEvent.device_model,
BehaviorEvent.occurred_at.label("last_active_at"),
func.row_number()
.over(
partition_by=BehaviorEvent.subject_id,
order_by=(
BehaviorEvent.occurred_at.desc(),
BehaviorEvent.id.desc(),
),
)
.label("rank"),
)
.where(*conditions)
.subquery()
)
rows = db.execute(
select(ranked)
.where(ranked.c.rank == 1)
.order_by(ranked.c.last_active_at.desc(), ranked.c.device_id.asc())
.limit(limit)
).all()
by_id, by_phone = _user_maps(
db,
user_ids={row.user_id for row in rows if row.user_id is not None},
phones={row.phone for row in rows if row.phone},
)
result = []
for row in rows:
user = by_id.get(row.user_id) if row.user_id is not None else None
user = user or (by_phone.get(row.phone) if row.phone else None)
result.append(
{
"device_id": row.device_id,
"source": row.event_type,
"source_label": _EVENT_SOURCE_LABELS.get(row.event_type, "登录/鉴权流水"),
"user_id": user.id if user else row.user_id,
"username": user.username if user else None,
"phone": user.phone if user else row.phone,
"nickname": user.nickname if user else None,
"device_model": row.device_model,
"last_active_at": row.last_active_at,
}
)
return result
def _comparison_device_candidates(db: Session, *, keyword: str | None, limit: int) -> list[dict]:
conditions = [
ComparisonRecord.device_id.is_not(None),
ComparisonRecord.device_id != "",
]
if keyword:
value = keyword.strip()
pattern = f"%{value}%"
conditions.append(
or_(
ComparisonRecord.device_id.ilike(pattern),
ComparisonRecord.device_model.ilike(pattern),
cast(ComparisonRecord.user_id, String).ilike(pattern),
ComparisonRecord.user_id.in_(_matching_users(value)),
)
)
ranked = (
select(
ComparisonRecord.device_id,
ComparisonRecord.user_id,
ComparisonRecord.device_model,
ComparisonRecord.created_at.label("last_active_at"),
func.row_number()
.over(
partition_by=ComparisonRecord.device_id,
order_by=(
ComparisonRecord.created_at.desc(),
ComparisonRecord.id.desc(),
),
)
.label("rank"),
)
.where(*conditions)
.subquery()
)
rows = db.execute(
select(ranked)
.where(ranked.c.rank == 1)
.order_by(ranked.c.last_active_at.desc(), ranked.c.device_id.asc())
.limit(limit)
).all()
by_id, _ = _user_maps(
db,
user_ids={row.user_id for row in rows if row.user_id is not None},
phones=set(),
)
return [
{
"device_id": row.device_id,
"source": "comparison_record",
"source_label": "比价记录",
"user_id": user.id if user else row.user_id,
"username": user.username if user else None,
"phone": user.phone if user else None,
"nickname": user.nickname if user else None,
"device_model": row.device_model,
"last_active_at": row.last_active_at,
}
for row in rows
for user in [by_id.get(row.user_id) if row.user_id is not None else None]
]
def list_device_candidates(
db: Session,
*,
rule_code: str,
keyword: str | None = None,
limit: int = 30,
) -> list[dict]:
rule = limit_policy.get_rule(rule_code)
if limit_policy.SUBJECT_DEVICE not in rule.subject_types:
raise ValueError("当前限制项不支持设备白名单")
if limit_policy.device_source_scope(rule_code) == "comparison":
return _comparison_device_candidates(db, keyword=keyword, limit=limit)
return _behavior_device_candidates(db, keyword=keyword, limit=limit)
def _aware(value: datetime | None) -> datetime | None:
if value is None:
return None
return value.replace(tzinfo=UTC) if value.tzinfo is None else value
def status_of(row: LimitPolicyOverride, *, now: datetime | None = None) -> str:
now = _aware(now) or datetime.now(UTC)
if not row.enabled:
return "disabled"
if _aware(row.starts_at) and _aware(row.starts_at) > now:
return "scheduled"
if _aware(row.expires_at) and _aware(row.expires_at) <= now:
return "expired"
return "active"
def to_dict(db: Session, row: LimitPolicyOverride) -> dict:
rule = limit_policy.get_rule(row.rule_code)
effective = limit_policy.resolve(
db,
row.rule_code,
phone=row.subject_value if row.subject_type == "phone" else None,
device=row.subject_value if row.subject_type == "device" else None,
)
return {
"id": row.id,
"subject_type": row.subject_type,
"subject_value": row.subject_value,
"rule_code": row.rule_code,
"rule_label": rule.label,
"rule_group": rule.group,
"mode": row.mode,
"limit_value": row.limit_value,
"global_limit": effective.global_limit,
"effective_limit": effective.limit,
"enabled": row.enabled,
"starts_at": row.starts_at,
"expires_at": row.expires_at,
"reset_at": row.reset_at,
"reason": row.reason,
"status": status_of(row),
"created_by_admin_id": row.created_by_admin_id,
"created_at": row.created_at,
"updated_at": row.updated_at,
}
def list_rows(
db: Session,
*,
subject_type: str | None = None,
keyword: str | None = None,
rule_code: str | None = None,
offset: int = 0,
limit: int = 100,
) -> tuple[list[LimitPolicyOverride], int]:
stmt = select(LimitPolicyOverride)
count_stmt = select(func.count(LimitPolicyOverride.id))
conditions = []
if subject_type:
conditions.append(LimitPolicyOverride.subject_type == subject_type)
if keyword:
conditions.append(LimitPolicyOverride.subject_value.ilike(f"%{keyword.strip()}%"))
if rule_code:
conditions.append(LimitPolicyOverride.rule_code == rule_code)
if conditions:
stmt = stmt.where(*conditions)
count_stmt = count_stmt.where(*conditions)
total = int(db.scalar(count_stmt) or 0)
rows = list(
db.execute(
stmt.order_by(
LimitPolicyOverride.created_at.desc(),
LimitPolicyOverride.id.desc(),
)
.offset(offset)
.limit(limit)
).scalars()
)
return rows, total
def list_subject_rows(
db: Session,
*,
subject_type: str | None = None,
keyword: str | None = None,
rule_code: str | None = None,
offset: int = 0,
limit: int = 10,
) -> tuple[list[tuple[str, str, list[LimitPolicyOverride], datetime]], int]:
"""List whitelist entries grouped and paginated by subject.
The filter decides which subjects match. Once a subject matches, all of
its rules are returned so category counts and the edit form stay complete.
Ordering uses the original creation time, therefore editing a subject does
not unexpectedly move it to the first page.
"""
conditions = [
LimitPolicyOverride.mode.in_(
[limit_policy.MODE_UNLIMITED, limit_policy.MODE_SUPPRESS_ALERT]
)
]
if subject_type:
conditions.append(LimitPolicyOverride.subject_type == subject_type)
if keyword:
conditions.append(LimitPolicyOverride.subject_value.ilike(f"%{keyword.strip()}%"))
if rule_code:
conditions.append(LimitPolicyOverride.rule_code == rule_code)
matched_subjects = (
select(
LimitPolicyOverride.subject_type.label("subject_type"),
LimitPolicyOverride.subject_value.label("subject_value"),
func.min(LimitPolicyOverride.created_at).label("subject_created_at"),
)
.where(*conditions)
.group_by(
LimitPolicyOverride.subject_type,
LimitPolicyOverride.subject_value,
)
.subquery()
)
total = int(db.scalar(select(func.count()).select_from(matched_subjects)) or 0)
subjects = list(
db.execute(
select(
matched_subjects.c.subject_type,
matched_subjects.c.subject_value,
matched_subjects.c.subject_created_at,
)
.order_by(
matched_subjects.c.subject_created_at.desc(),
matched_subjects.c.subject_type,
matched_subjects.c.subject_value,
)
.offset(offset)
.limit(limit)
).all()
)
if not subjects:
return [], total
pair_conditions = [
(
(LimitPolicyOverride.subject_type == item.subject_type)
& (LimitPolicyOverride.subject_value == item.subject_value)
)
for item in subjects
]
rows = list(
db.scalars(
select(LimitPolicyOverride)
.where(
or_(*pair_conditions),
LimitPolicyOverride.mode.in_(
[
limit_policy.MODE_UNLIMITED,
limit_policy.MODE_SUPPRESS_ALERT,
]
),
)
.order_by(
LimitPolicyOverride.created_at.desc(),
LimitPolicyOverride.id.desc(),
)
).all()
)
rows_by_subject: dict[tuple[str, str], list[LimitPolicyOverride]] = {}
for row in rows:
rows_by_subject.setdefault((row.subject_type, row.subject_value), []).append(row)
return [
(
item.subject_type,
item.subject_value,
rows_by_subject.get((item.subject_type, item.subject_value), []),
item.subject_created_at,
)
for item in subjects
], total
def rows_for_subject(
db: Session, *, subject_type: str, subject_value: str
) -> list[LimitPolicyOverride]:
return list(
db.scalars(
select(LimitPolicyOverride)
.where(
LimitPolicyOverride.subject_type == subject_type,
LimitPolicyOverride.subject_value == subject_value,
)
.order_by(
LimitPolicyOverride.created_at.desc(),
LimitPolicyOverride.id.desc(),
)
).all()
)
def create(
db: Session,
*,
subject_type: str,
subject_value: str,
rule_code: str,
mode: str,
limit_value: int | None,
enabled: bool,
starts_at: datetime | None,
expires_at: datetime | None,
reason: str,
admin_id: int,
reactivate_inactive: bool = False,
) -> LimitPolicyOverride:
rule = limit_policy.get_rule(rule_code)
subject_value = limit_policy.validate_whitelist_subject(subject_type, subject_value)
limit_policy.validate_override(
rule,
subject_type=subject_type,
mode=mode,
limit_value=limit_value,
starts_at=starts_at,
expires_at=expires_at,
)
existing = db.execute(
select(LimitPolicyOverride).where(
LimitPolicyOverride.subject_type == subject_type,
LimitPolicyOverride.subject_value == subject_value,
LimitPolicyOverride.rule_code == rule_code,
)
).scalar_one_or_none()
if existing is not None:
if not reactivate_inactive or status_of(existing) not in {"disabled", "expired"}:
raise DuplicateOverrideError
# “恢复全局”与自然过期都会保留原行供审计。再次加入白名单时
# 复用该行,避免唯一键让批量新增永久失败;完整变更仍写 admin 审计日志。
existing.mode = mode
existing.limit_value = limit_value if mode == limit_policy.MODE_OVERRIDE else None
existing.enabled = enabled
existing.starts_at = starts_at
existing.expires_at = expires_at
existing.reset_at = None
existing.reason = reason.strip() or None
db.flush()
return existing
row = LimitPolicyOverride(
subject_type=subject_type,
subject_value=subject_value,
rule_code=rule_code,
mode=mode,
limit_value=limit_value if mode == limit_policy.MODE_OVERRIDE else None,
enabled=enabled,
starts_at=starts_at,
expires_at=expires_at,
reason=reason.strip() or None,
created_by_admin_id=admin_id,
)
db.add(row)
try:
db.flush()
except IntegrityError as exc:
db.rollback()
raise DuplicateOverrideError from exc
return row
def update(
db: Session,
row: LimitPolicyOverride,
*,
enabled: bool | None,
starts_at: datetime | None,
expires_at: datetime | None,
reason: str | None,
fields_set: set[str],
) -> LimitPolicyOverride:
new_starts = starts_at if "starts_at" in fields_set else row.starts_at
new_expires = expires_at if "expires_at" in fields_set else row.expires_at
new_enabled = enabled if "enabled" in fields_set and enabled is not None else row.enabled
rule = limit_policy.get_rule(row.rule_code)
if row.mode == limit_policy.MODE_OVERRIDE and new_enabled:
raise ValueError("历史覆盖配置只能停用、恢复全局或删除")
if new_enabled:
limit_policy.validate_override(
rule,
subject_type=row.subject_type,
mode=row.mode,
limit_value=None,
starts_at=new_starts,
expires_at=new_expires,
)
if "enabled" in fields_set and enabled is not None:
row.enabled = enabled
if "starts_at" in fields_set:
row.starts_at = starts_at
if "expires_at" in fields_set:
row.expires_at = expires_at
if "reason" in fields_set:
row.reason = reason.strip() if reason else None
db.flush()
return row
def update_global_limit(
db: Session, rule_code: str, value: int, *, admin_id: int
) -> tuple[int, int]:
rule = limit_policy.get_rule(rule_code)
if not rule.min_value <= value <= rule.max_value:
raise ValueError(f"限制值必须在 {rule.min_value}{rule.max_value} 之间")
before = limit_policy.resolve(db, rule_code).global_limit
limit_policy.set_global_limits(
db,
{rule.code: value},
admin_id=admin_id,
commit=False,
)
# 引导视频仍有一个旧的专用配置页。同步其结构化配置,保证两个入口读取
# 同一数值;业务判定仍统一走 limit_policy。
if rule.legacy_config_key and rule.legacy_json_field:
legacy = db.get(AppConfig, rule.legacy_config_key)
legacy_value = (
dict(legacy.value) if legacy is not None and isinstance(legacy.value, dict) else {}
)
legacy_value[rule.legacy_json_field] = value
if legacy is None:
legacy = AppConfig(
key=rule.legacy_config_key,
value=legacy_value,
updated_by_admin_id=admin_id,
)
db.add(legacy)
else:
legacy.value = legacy_value
legacy.updated_by_admin_id = admin_id
db.flush()
return before, value
def restore_global(row: LimitPolicyOverride) -> LimitPolicyOverride:
"""Stop applying the exception while retaining its history for audit."""
row.enabled = False
row.reset_at = None
return row
+14 -57
View File
@@ -14,7 +14,6 @@ from sqlalchemy.orm import Session
from app.core import rewards from app.core import rewards
from app.core.config import settings from app.core.config import settings
from app.models.ad_ecpm import AdEcpmRecord
from app.models.ad_feed_reward import AdFeedRewardRecord from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord from app.models.ad_reward import AdRewardRecord
from app.models.admin import AdminAuditLog from app.models.admin import AdminAuditLog
@@ -1258,15 +1257,11 @@ def user_reward_stats(
date_from: datetime | None = None, date_from: datetime | None = None,
date_to: datetime | None = None, date_to: datetime | None = None,
withdraw_source: str | None = None, withdraw_source: str | None = None,
app_env: str | None = None,
revenue_scope: str = "all",
feed_scene: str | None = None,
) -> dict: ) -> dict:
"""提现详情「用户统计区」10 项。窗口作用于除「现金余额」外的所有项(余额是当前快照)。 """提现详情「用户统计区」10 项。窗口作用于除「现金余额」外的所有项(余额是当前快照)。
口径:激励视频/信息流奖励数量只统计 granted;数量——视频按条数、信息流按份数(unit_count 累加) 口径:激励视频/信息流只统计 granted;数量——视频按条数、信息流按份数(unit_count 累加);
平均 Draw eCPM 与广告收益报表一致:基于 ad_ecpm_record 的全部 draw/feed 展示记录计算, 平均 eCPM 用原始分值(分/千次)按记录取算术平均;各「提现」= 该来源累计金币折现。
不以是否发奖为筛选条件。各「提现」= 该来源累计金币折现。
传统任务 = 窗口内正向金币中,排除广告(reward_video/feed_ad_reward)与人工调整后的折现。 传统任务 = 窗口内正向金币中,排除广告(reward_video/feed_ad_reward)与人工调整后的折现。
""" """
withdraw_source_conds = ( withdraw_source_conds = (
@@ -1301,68 +1296,30 @@ def user_reward_stats(
# 只投影本统计实际使用的列。避免滚动发布或旧本地库尚未补齐无关新列时, # 只投影本统计实际使用的列。避免滚动发布或旧本地库尚未补齐无关新列时,
# SQLAlchemy 因 select(ORM) 自动展开整表字段而让提现详情整体 500。 # SQLAlchemy 因 select(ORM) 自动展开整表字段而让提现详情整体 500。
business_ids: set[str] | None = None
if revenue_scope == "business":
# 与广告收益报表共用正式/测试业务代码位集合,避免两个页面随配置切换后再次漂移。
from app.admin.repositories.ad_revenue import business_code_ids
business_ids = business_code_ids(db, app_env)
rv_conds = [
AdRewardRecord.user_id == user_id,
AdRewardRecord.reward_scene == "reward_video",
AdRewardRecord.status == "granted",
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
]
if app_env is not None:
rv_conds.append(AdRewardRecord.app_env == app_env)
if business_ids is not None:
rv_conds.append(AdRewardRecord.our_code_id.in_(business_ids))
rv = db.execute( rv = db.execute(
select(AdRewardRecord.ecpm_raw, AdRewardRecord.coin).where( select(AdRewardRecord.ecpm_raw, AdRewardRecord.coin).where(
*rv_conds, AdRewardRecord.user_id == user_id,
AdRewardRecord.reward_scene == "reward_video",
AdRewardRecord.status == "granted",
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
) )
).all() ).all()
rv_ecpms = [rewards.parse_ecpm_fen(r.ecpm_raw) for r in rv if r.ecpm_raw] rv_ecpms = [rewards.parse_ecpm_fen(r.ecpm_raw) for r in rv if r.ecpm_raw]
rv_coins = sum(r.coin for r in rv) rv_coins = sum(r.coin for r in rv)
feed_reward_conds = [ feed = db.execute(
AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.status == "granted",
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
]
if app_env is not None:
feed_reward_conds.append(AdFeedRewardRecord.app_env == app_env)
if feed_scene is not None:
feed_reward_conds.append(AdFeedRewardRecord.feed_scene == feed_scene)
if business_ids is not None:
feed_reward_conds.append(AdFeedRewardRecord.our_code_id.in_(business_ids))
feed_rewards = db.execute(
select( select(
AdFeedRewardRecord.unit_count, AdFeedRewardRecord.unit_count,
AdFeedRewardRecord.ecpm_raw,
AdFeedRewardRecord.coin, AdFeedRewardRecord.coin,
).where( ).where(
*feed_reward_conds, AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.status == "granted",
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
) )
).all() ).all()
feed_coins = sum(f.coin for f in feed_rewards) feed_ecpms = [rewards.parse_ecpm_fen(f.ecpm_raw) for f in feed if f.ecpm_raw]
feed_coins = sum(f.coin for f in feed)
feed_impression_conds = [
AdEcpmRecord.user_id == user_id,
AdEcpmRecord.ad_type.in_(("draw", "feed")),
*_window_conds(AdEcpmRecord.created_at, date_from, date_to),
]
if app_env is not None:
feed_impression_conds.append(AdEcpmRecord.app_env == app_env)
if feed_scene is not None:
feed_impression_conds.append(AdEcpmRecord.feed_scene == feed_scene)
if business_ids is not None:
feed_impression_conds.append(AdEcpmRecord.our_code_id.in_(business_ids))
feed_impressions = db.execute(
select(AdEcpmRecord.ecpm_raw).where(*feed_impression_conds)
).all()
# 与 ad_revenue.category_stats 相同:每次展示权重相同,非法原值按 parse_ecpm_fen 记 0。
feed_ecpms = [rewards.parse_ecpm_fen(row.ecpm_raw) for row in feed_impressions]
trad_coins = db.execute( trad_coins = db.execute(
select(func.coalesce(func.sum(CoinTransaction.amount), 0)).where( select(func.coalesce(func.sum(CoinTransaction.amount), 0)).where(
@@ -1381,7 +1338,7 @@ def user_reward_stats(
"reward_video_count": len(rv), "reward_video_count": len(rv),
"reward_video_avg_ecpm": round(sum(rv_ecpms) / len(rv_ecpms), 2) if rv_ecpms else 0.0, "reward_video_avg_ecpm": round(sum(rv_ecpms) / len(rv_ecpms), 2) if rv_ecpms else 0.0,
"reward_video_cash_cents": _coins_to_cents(rv_coins), "reward_video_cash_cents": _coins_to_cents(rv_coins),
"feed_count": int(sum(f.unit_count for f in feed_rewards)), "feed_count": int(sum(f.unit_count for f in feed)),
"feed_avg_ecpm": round(sum(feed_ecpms) / len(feed_ecpms), 2) if feed_ecpms else 0.0, "feed_avg_ecpm": round(sum(feed_ecpms) / len(feed_ecpms), 2) if feed_ecpms else 0.0,
"feed_cash_cents": _coins_to_cents(feed_coins), "feed_cash_cents": _coins_to_cents(feed_coins),
} }
+8 -75
View File
@@ -12,11 +12,9 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from app.admin.audit import write_audit from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.schemas.config import ConfigItemOut, ConfigUpdateRequest from app.admin.schemas.config import ConfigItemOut, ConfigUpdateRequest
from app.core import limit_policy
from app.core.config_schema import CONFIG_DEFS from app.core.config_schema import CONFIG_DEFS
from app.core.rewards import SIGNIN_CYCLE_LEN from app.core.rewards import SIGNIN_CYCLE_LEN
from app.models.admin import AdminUser from app.models.admin import AdminUser
from app.models.app_config import AppConfig
from app.repositories import app_config from app.repositories import app_config
router = APIRouter( router = APIRouter(
@@ -56,35 +54,7 @@ def _validate(key: str, value: Any) -> None:
raise ValueError("需为布尔值") raise ValueError("需为布尔值")
def _limit_item(
key: str,
values: dict[str, int],
bundle: AppConfig | None,
) -> ConfigItemOut:
definition = CONFIG_DEFS[key]
rule_code = limit_policy.RULE_CODE_BY_CONFIG_KEY[key]
return ConfigItemOut(
key=key,
value=values[rule_code],
label=definition["label"],
group=definition["group"],
type=definition["type"],
help=definition.get("help"),
default=definition["default"],
overridden=bundle is not None,
updated_at=(
bundle.updated_at.isoformat() if bundle is not None else None
),
)
def _item(db, key: str) -> ConfigItemOut: def _item(db, key: str) -> ConfigItemOut:
if key in limit_policy.RULE_CODE_BY_CONFIG_KEY:
return _limit_item(
key,
limit_policy.get_global_limits(db),
db.get(AppConfig, limit_policy.LIMIT_POLICY_GLOBAL_KEY),
)
for item in app_config.list_all(db): for item in app_config.list_all(db):
if item["key"] == key: if item["key"] == key:
return ConfigItemOut(**item) return ConfigItemOut(**item)
@@ -94,20 +64,11 @@ def _item(db, key: str) -> ConfigItemOut:
@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值(不含 hidden)") @router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值(不含 hidden)")
def list_config(db: AdminDb) -> list[ConfigItemOut]: def list_config(db: AdminDb) -> list[ConfigItemOut]:
# hidden 项(已下线/由专用页管理,如福利页任务·里程碑·看广告调参、首页轮播数据源)不在本页渲染。 # hidden 项(已下线/由专用页管理,如福利页任务·里程碑·看广告调参、首页轮播数据源)不在本页渲染。
legacy_items = { return [
item["key"]: item for item in app_config.list_all(db) ConfigItemOut(**item)
} for item in app_config.list_all(db)
values = limit_policy.get_global_limits(db) if not CONFIG_DEFS[item["key"]].get("hidden")
bundle = db.get(AppConfig, limit_policy.LIMIT_POLICY_GLOBAL_KEY) ]
out: list[ConfigItemOut] = []
for key, definition in CONFIG_DEFS.items():
if definition.get("hidden"):
continue
if key in limit_policy.RULE_CODE_BY_CONFIG_KEY:
out.append(_limit_item(key, values, bundle))
else:
out.append(ConfigItemOut(**legacy_items[key]))
return out
@router.patch("/{key}", response_model=ConfigItemOut, summary="改某项配置(带审计)") @router.patch("/{key}", response_model=ConfigItemOut, summary="改某项配置(带审计)")
@@ -122,39 +83,11 @@ def update_config(
raise HTTPException(status_code=404, detail="未知配置项") raise HTTPException(status_code=404, detail="未知配置项")
try: try:
_validate(key, body.value) _validate(key, body.value)
rule_code = limit_policy.RULE_CODE_BY_CONFIG_KEY.get(key)
if rule_code is not None:
before = limit_policy.get_global_limits(db)[rule_code]
limit_policy.set_global_limits(
db,
{rule_code: body.value},
admin_id=admin.id,
commit=False,
)
else:
before = app_config.get_value(db, key)
app_config.set_value(
db,
key,
body.value,
admin_id=admin.id,
commit=False,
)
if key == "ad_daily_limit":
# 旧系统配置接口过去只有一个广告日上限。仍有人直接调用时,同时同步
# 新的激励视频/Draw 两项,避免旧入口写入后业务实际值不变。
limit_policy.set_global_limits(
db,
{
"ad.reward_video.daily": body.value,
"ad.feed.daily": body.value,
},
admin_id=admin.id,
commit=False,
)
except ValueError as e: except ValueError as e:
db.rollback()
raise HTTPException(status_code=400, detail=str(e)) from e raise HTTPException(status_code=400, detail=str(e)) from e
before = app_config.get_value(db, key)
app_config.set_value(db, key, body.value, admin_id=admin.id, commit=False)
write_audit( write_audit(
db, admin, action="config.set", target_type="config", target_id=key, db, admin, action="config.set", target_type="config", target_id=key,
detail={"before": before, "after": body.value}, ip=get_client_ip(request), commit=False, detail={"before": before, "after": body.value}, ip=get_client_ip(request), commit=False,
+6 -109
View File
@@ -1,7 +1,6 @@
"""admin 反馈工单:列表筛选 + 审核采纳/拒绝(带金币发放与审计)。""" """admin 反馈工单:列表筛选 + 审核采纳/拒绝(带金币发放与审计)。"""
from __future__ import annotations from __future__ import annotations
import logging
from datetime import datetime from datetime import datetime
from typing import Annotated from typing import Annotated
@@ -26,8 +25,6 @@ from app.models.feedback import Feedback
from app.repositories import wallet as wallet_repo from app.repositories import wallet as wallet_repo
from app.services import notification_events from app.services import notification_events
logger = logging.getLogger("shagua.admin.feedback")
router = APIRouter( router = APIRouter(
prefix="/admin/api/feedbacks", prefix="/admin/api/feedbacks",
tags=["admin-feedback"], tags=["admin-feedback"],
@@ -49,24 +46,10 @@ def _approve_feedback(
*, *,
bulk: bool = False, bulk: bool = False,
) -> FeedbackOut: ) -> FeedbackOut:
logger.info(
"feedback approve started feedback_id=%s admin_id=%s bulk=%s",
feedback_id, admin.id, bulk,
)
fb = db.get(Feedback, feedback_id, with_for_update=True) fb = db.get(Feedback, feedback_id, with_for_update=True)
if fb is None: if fb is None:
logger.warning(
"feedback approve rejected not found feedback_id=%s admin_id=%s bulk=%s",
feedback_id, admin.id, bulk,
)
raise HTTPException(status_code=404, detail="反馈不存在") raise HTTPException(status_code=404, detail="反馈不存在")
if fb.status not in {"pending", "new"}: _ensure_pending(fb)
logger.warning(
"feedback approve rejected invalid status feedback_id=%s user_id=%s "
"admin_id=%s status=%s bulk=%s",
feedback_id, fb.user_id, admin.id, fb.status, bulk,
)
_ensure_pending(fb)
before = fb.status before = fb.status
mutations.review_feedback( mutations.review_feedback(
@@ -108,18 +91,8 @@ def _approve_feedback(
) )
db.commit() db.commit()
db.refresh(fb) db.refresh(fb)
logger.info(
"feedback approve committed feedback_id=%s user_id=%s admin_id=%s "
"before=%s after=%s reward_coins=%s bulk=%s",
feedback_id, fb.user_id, admin.id, before, fb.status, payload.reward_coins, bulk,
)
out = FeedbackOut.model_validate(fb) out = FeedbackOut.model_validate(fb)
notification_events.notify_feedback_reward(db, fb) notification_events.notify_feedback_reward(db, fb)
logger.info(
"feedback approve notification dispatch returned feedback_id=%s user_id=%s "
"admin_id=%s notification_type=feedback_reward bulk=%s",
feedback_id, fb.user_id, admin.id, bulk,
)
return out return out
@@ -132,24 +105,10 @@ def _reject_feedback(
*, *,
bulk: bool = False, bulk: bool = False,
) -> FeedbackOut: ) -> FeedbackOut:
logger.info(
"feedback reject started feedback_id=%s admin_id=%s bulk=%s",
feedback_id, admin.id, bulk,
)
fb = db.get(Feedback, feedback_id, with_for_update=True) fb = db.get(Feedback, feedback_id, with_for_update=True)
if fb is None: if fb is None:
logger.warning(
"feedback reject rejected not found feedback_id=%s admin_id=%s bulk=%s",
feedback_id, admin.id, bulk,
)
raise HTTPException(status_code=404, detail="反馈不存在") raise HTTPException(status_code=404, detail="反馈不存在")
if fb.status not in {"pending", "new"}: _ensure_pending(fb)
logger.warning(
"feedback reject rejected invalid status feedback_id=%s user_id=%s "
"admin_id=%s status=%s bulk=%s",
feedback_id, fb.user_id, admin.id, fb.status, bulk,
)
_ensure_pending(fb)
before = fb.status before = fb.status
mutations.review_feedback( mutations.review_feedback(
@@ -183,18 +142,8 @@ def _reject_feedback(
) )
db.commit() db.commit()
db.refresh(fb) db.refresh(fb)
logger.info(
"feedback reject committed feedback_id=%s user_id=%s admin_id=%s "
"before=%s after=%s bulk=%s",
feedback_id, fb.user_id, admin.id, before, fb.status, bulk,
)
out = FeedbackOut.model_validate(fb) out = FeedbackOut.model_validate(fb)
notification_events.notify_feedback_reply(db, fb) notification_events.notify_feedback_reply(db, fb)
logger.info(
"feedback reject notification dispatch returned feedback_id=%s user_id=%s "
"admin_id=%s notification_type=feedback_reply bulk=%s",
feedback_id, fb.user_id, admin.id, bulk,
)
return out return out
@@ -252,10 +201,6 @@ def bulk_approve_feedbacks(
admin: Annotated[AdminUser, Depends(require_role("operator"))], admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb, db: AdminDb,
) -> FeedbackBulkResult: ) -> FeedbackBulkResult:
logger.info(
"feedback bulk approve started admin_id=%s item_count=%s",
admin.id, len(body.ids),
)
results: list[FeedbackBulkItemResult] = [] results: list[FeedbackBulkItemResult] = []
ip = get_client_ip(request) ip = get_client_ip(request)
for feedback_id in body.ids: for feedback_id in body.ids:
@@ -264,24 +209,11 @@ def bulk_approve_feedbacks(
results.append(FeedbackBulkItemResult(id=feedback_id, ok=True, status=out.status)) results.append(FeedbackBulkItemResult(id=feedback_id, ok=True, status=out.status))
except HTTPException as exc: except HTTPException as exc:
db.rollback() db.rollback()
logger.warning(
"feedback bulk approve item failed feedback_id=%s admin_id=%s error=%s",
feedback_id, admin.id, exc.detail,
)
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error=str(exc.detail))) results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error=str(exc.detail)))
except Exception: # noqa: BLE001 - 单笔失败不打断整批 except Exception: # noqa: BLE001 - 单笔失败不打断整批
db.rollback() db.rollback()
logger.exception(
"feedback bulk approve item failed feedback_id=%s admin_id=%s",
feedback_id, admin.id,
)
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error="系统异常")) results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error="系统异常"))
result = _bulk_result(results) return _bulk_result(results)
logger.info(
"feedback bulk approve completed admin_id=%s total=%s success=%s failed=%s",
admin.id, result.total, result.success, result.failed,
)
return result
@router.post("/bulk/reject", response_model=FeedbackBulkResult, summary="批量拒绝采纳反馈") @router.post("/bulk/reject", response_model=FeedbackBulkResult, summary="批量拒绝采纳反馈")
@@ -291,10 +223,6 @@ def bulk_reject_feedbacks(
admin: Annotated[AdminUser, Depends(require_role("operator"))], admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb, db: AdminDb,
) -> FeedbackBulkResult: ) -> FeedbackBulkResult:
logger.info(
"feedback bulk reject started admin_id=%s item_count=%s",
admin.id, len(body.ids),
)
results: list[FeedbackBulkItemResult] = [] results: list[FeedbackBulkItemResult] = []
ip = get_client_ip(request) ip = get_client_ip(request)
for feedback_id in body.ids: for feedback_id in body.ids:
@@ -303,24 +231,11 @@ def bulk_reject_feedbacks(
results.append(FeedbackBulkItemResult(id=feedback_id, ok=True, status=out.status)) results.append(FeedbackBulkItemResult(id=feedback_id, ok=True, status=out.status))
except HTTPException as exc: except HTTPException as exc:
db.rollback() db.rollback()
logger.warning(
"feedback bulk reject item failed feedback_id=%s admin_id=%s error=%s",
feedback_id, admin.id, exc.detail,
)
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error=str(exc.detail))) results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error=str(exc.detail)))
except Exception: # noqa: BLE001 - 单笔失败不打断整批 except Exception: # noqa: BLE001 - 单笔失败不打断整批
db.rollback() db.rollback()
logger.exception(
"feedback bulk reject item failed feedback_id=%s admin_id=%s",
feedback_id, admin.id,
)
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error="系统异常")) results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error="系统异常"))
result = _bulk_result(results) return _bulk_result(results)
logger.info(
"feedback bulk reject completed admin_id=%s total=%s success=%s failed=%s",
admin.id, result.total, result.success, result.failed,
)
return result
@router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理") @router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理")
@@ -343,16 +258,7 @@ def approve_feedback(
admin: Annotated[AdminUser, Depends(require_role("operator"))], admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb, db: AdminDb,
) -> FeedbackOut: ) -> FeedbackOut:
try: return _approve_feedback(db, admin, feedback_id, payload, get_client_ip(request))
return _approve_feedback(db, admin, feedback_id, payload, get_client_ip(request))
except HTTPException:
raise
except Exception:
logger.exception(
"feedback approve failed feedback_id=%s admin_id=%s",
feedback_id, admin.id,
)
raise
@router.post("/{feedback_id}/reject", response_model=FeedbackOut, summary="拒绝采纳反馈") @router.post("/{feedback_id}/reject", response_model=FeedbackOut, summary="拒绝采纳反馈")
@@ -363,13 +269,4 @@ def reject_feedback(
admin: Annotated[AdminUser, Depends(require_role("operator"))], admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb, db: AdminDb,
) -> FeedbackOut: ) -> FeedbackOut:
try: return _reject_feedback(db, admin, feedback_id, payload, get_client_ip(request))
return _reject_feedback(db, admin, feedback_id, payload, get_client_ip(request))
except HTTPException:
raise
except Exception:
logger.exception(
"feedback reject failed feedback_id=%s admin_id=%s",
feedback_id, admin.id,
)
raise
+32 -60
View File
@@ -9,7 +9,7 @@ client_max_body_size,见 shaguabijia-admin-web/deploy/nginx/admin.shaguabijia.co
""" """
from __future__ import annotations from __future__ import annotations
from typing import Annotated, Literal from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
@@ -27,16 +27,14 @@ router = APIRouter(
) )
GuideScene = Literal["coupon", "comparison"] def _out(db: AdminDb) -> GuideVideoConfigOut:
"""配置 + 播放统计合成响应(四个写接口都以最新状态返回,前端一次同步到位)。"""
return GuideVideoConfigOut(**guide_video.get_config(db), **guide_video.play_stats(db))
def _out(db: AdminDb, scene: GuideScene) -> GuideVideoConfigOut:
return GuideVideoConfigOut(scene=scene, **guide_video.get_config(db, scene))
@router.get("", response_model=GuideVideoConfigOut, summary="新手引导视频配置(领券浮层)") @router.get("", response_model=GuideVideoConfigOut, summary="新手引导视频配置(领券浮层)")
def get_config(db: AdminDb, scene: GuideScene = "coupon") -> GuideVideoConfigOut: def get_config(db: AdminDb) -> GuideVideoConfigOut:
return _out(db, scene) return _out(db)
@router.patch("", response_model=GuideVideoConfigOut, summary="改开关/次数/金币(带审计)") @router.patch("", response_model=GuideVideoConfigOut, summary="改开关/次数/金币(带审计)")
@@ -45,26 +43,21 @@ def update_config(
request: Request, request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))], admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb, db: AdminDb,
scene: GuideScene = "coupon",
) -> GuideVideoConfigOut: ) -> GuideVideoConfigOut:
try: before, after = guide_video.update_config(
before, after = guide_video.update_config( db,
db, enabled=body.enabled,
enabled=body.enabled, max_plays=body.max_plays,
max_plays=body.max_plays, reward_coin=body.reward_coin,
reward_coin=body.reward_coin, admin_id=admin.id,
scene=scene, commit=False,
admin_id=admin.id, )
commit=False,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
write_audit( write_audit(
db, admin, action="guide_video.update", target_type="guide_video", target_id=None, db, admin, action="guide_video.update", target_type="guide_video", target_id=None,
detail={"scene": scene, "before": before, "after": after}, ip=get_client_ip(request), commit=False, detail={"before": before, "after": after}, ip=get_client_ip(request), commit=False,
) )
db.commit() db.commit()
return _out(db, scene) return _out(db)
@router.post("/video", response_model=GuideVideoConfigOut, summary="上传新手引导视频(MP4,带审计)") @router.post("/video", response_model=GuideVideoConfigOut, summary="上传新手引导视频(MP4,带审计)")
@@ -72,40 +65,23 @@ async def upload_video(
request: Request, request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))], admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb, db: AdminDb,
file: Annotated[UploadFile, File()], file: UploadFile = File(...),
scene: GuideScene = "coupon",
) -> GuideVideoConfigOut: ) -> GuideVideoConfigOut:
data = await file.read() data = await file.read()
url: str | None = None
try: try:
url, analysis = media.save_guide_video(data) url = media.save_guide_video(data)
except media.MediaError as e: except media.MediaError as e:
raise HTTPException(status_code=400, detail=str(e)) from e raise HTTPException(status_code=400, detail=str(e)) from e
try: before, after = guide_video.set_video(db, url, admin_id=admin.id, commit=False)
before, after = guide_video.set_video( write_audit(
db, url, analysis=analysis, scene=scene, admin_id=admin.id, commit=False db, admin, action="guide_video.set_video", target_type="guide_video", target_id=None,
) detail={"before": before.get("video_url"), "after": url, "bytes": len(data)},
write_audit( ip=get_client_ip(request), commit=False,
db, admin, action="guide_video.set_video", target_type="guide_video", target_id=None, )
detail={ db.commit()
"scene": scene, # 提交成功后再删旧片,避免新片没落库就把旧片丢了
"before": before.get("video_url"), media.delete_guide_video(before.get("video_url"))
"after": url, return _out(db)
"bytes": len(data),
"duration_ms": analysis["duration_ms"],
"video_codec": analysis["video_codec"],
"audio_codec": analysis["audio_codec"],
},
ip=get_client_ip(request), commit=False,
)
db.commit()
except Exception:
db.rollback()
media.delete_guide_video(url)
raise
# 旧文件仍可能被 prepare/已起播快照引用,不能在这里立即删除。
guide_video.cleanup_old_videos(db)
return _out(db, scene)
@router.delete("/video", response_model=GuideVideoConfigOut, summary="移除新手引导视频(带审计)") @router.delete("/video", response_model=GuideVideoConfigOut, summary="移除新手引导视频(带审计)")
@@ -113,17 +89,13 @@ def delete_video(
request: Request, request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))], admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb, db: AdminDb,
scene: GuideScene = "coupon",
) -> GuideVideoConfigOut: ) -> GuideVideoConfigOut:
"""移除后 /guide-video/start 一律返回 should_play=false,领券浮层回到「只放广告」。""" """移除后 /guide-video/start 一律返回 should_play=false,领券浮层回到「只放广告」。"""
before, after = guide_video.set_video( before, after = guide_video.set_video(db, None, admin_id=admin.id, commit=False)
db, None, scene=scene, admin_id=admin.id, commit=False
)
write_audit( write_audit(
db, admin, action="guide_video.delete_video", target_type="guide_video", target_id=None, db, admin, action="guide_video.delete_video", target_type="guide_video", target_id=None,
detail={"scene": scene, "before": before.get("video_url")}, ip=get_client_ip(request), commit=False, detail={"before": before.get("video_url")}, ip=get_client_ip(request), commit=False,
) )
db.commit() db.commit()
# 旧文件由保护期清理任务处理,避免破坏有效计划与已起播快照。 media.delete_guide_video(before.get("video_url"))
guide_video.cleanup_old_videos(db) return _out(db)
return _out(db, scene)
-838
View File
@@ -1,838 +0,0 @@
"""Unified limit rules and per-phone/device whitelist overrides."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_page
from app.admin.repositories import limit_whitelist as repo
from app.admin.schemas.limit_whitelist import (
DeviceCandidateOut,
GlobalLimitUpdate,
LimitOverrideBulkWrite,
LimitOverrideList,
LimitOverrideOut,
LimitOverridePatch,
LimitOverrideWrite,
LimitRuleOut,
LimitSubjectEnabledPatch,
LimitSubjectList,
LimitSubjectOut,
)
from app.core import limit_policy
from app.models.limit_policy import LimitPolicyOverride
from app.repositories import risk as risk_repo
router = APIRouter(
prefix="/admin/api/limit-whitelist",
tags=["admin-limit-whitelist"],
dependencies=[Depends(require_page("limit-whitelist"))],
)
_WHITELIST_MODES = {
limit_policy.MODE_UNLIMITED,
limit_policy.MODE_SUPPRESS_ALERT,
}
def _subject_whitelist_rows(
db,
*,
subject_type: str,
subject_value: str,
) -> list[LimitPolicyOverride]:
return [
row
for row in repo.rows_for_subject(
db,
subject_type=subject_type,
subject_value=subject_value,
)
if row.mode in _WHITELIST_MODES
]
def _reconcile_risk_rule(db, rule_code: str) -> None:
now = risk_repo.utcnow()
if rule_code == "risk.sms.hourly":
risk_repo.reconcile_behavior_rule(
db,
rule_code=risk_repo.RULE_SMS_HOURLY,
at=now,
commit=False,
)
elif rule_code == "risk.oneclick.daily":
risk_repo.reconcile_behavior_rule(
db,
rule_code=risk_repo.RULE_ONECLICK_DAILY,
at=now,
commit=False,
)
elif rule_code == "risk.compare.daily":
risk_repo.reconcile_compare_rule(db, at=now, commit=False)
def _row_or_404(db, override_id: int) -> LimitPolicyOverride:
row = db.get(LimitPolicyOverride, override_id, populate_existing=True)
if row is None:
raise HTTPException(status_code=404, detail="白名单配置不存在")
return row
def _out(db, row: LimitPolicyOverride) -> LimitOverrideOut:
return LimitOverrideOut(**repo.to_dict(db, row))
def _audit_payload(value: LimitOverrideOut) -> dict:
return value.model_dump(mode="json")
def _subject_out(
db,
*,
subject_type: str,
subject_value: str,
rows: list[LimitPolicyOverride],
created_at,
) -> LimitSubjectOut:
items = [_out(db, row) for row in rows]
group_counts: dict[str, int] = {}
for item in items:
group_counts[item.rule_group] = group_counts.get(item.rule_group, 0) + 1
return LimitSubjectOut(
subject_type=subject_type,
subject_value=subject_value,
group_counts=group_counts,
total_rules=len(items),
items=items,
created_at=created_at,
updated_at=max(
(item.updated_at for item in items),
default=created_at,
),
)
@router.get("/rules", response_model=list[LimitRuleOut], summary="读取所有限制规则")
def list_rules(db: AdminDb) -> list[LimitRuleOut]:
return [LimitRuleOut(**item) for item in limit_policy.rule_catalog(db)]
@router.patch(
"/rules/{rule_code}",
response_model=LimitRuleOut,
summary="修改规则的全局限制值",
)
def update_rule(
rule_code: str,
body: GlobalLimitUpdate,
request: Request,
admin: CurrentAdmin,
db: AdminDb,
) -> LimitRuleOut:
try:
before, after = repo.update_global_limit(db, rule_code, body.value, admin_id=admin.id)
except KeyError as exc:
raise HTTPException(status_code=404, detail="未知限制规则") from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_reconcile_risk_rule(db, rule_code)
write_audit(
db,
admin,
action="limit.rule.update",
target_type="limit_rule",
target_id=rule_code,
detail={"before": before, "after": after},
ip=get_client_ip(request),
commit=False,
)
db.commit()
item = next(item for item in limit_policy.rule_catalog(db) if item["code"] == rule_code)
return LimitRuleOut(**item)
@router.get(
"/device-candidates",
response_model=list[DeviceCandidateOut],
summary="按限制项搜索可加入白名单的设备",
)
def list_device_candidates(
db: AdminDb,
rule_code: str = Query(..., min_length=1, max_length=64),
keyword: str | None = Query(None, max_length=128),
limit: int = Query(30, ge=1, le=50),
) -> list[DeviceCandidateOut]:
try:
rows = repo.list_device_candidates(
db,
rule_code=rule_code,
keyword=keyword,
limit=limit,
)
except KeyError as exc:
raise HTTPException(status_code=404, detail="未知限制规则") from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return [DeviceCandidateOut(**row) for row in rows]
@router.get("", response_model=LimitOverrideList, summary="读取白名单配置")
def list_overrides(
db: AdminDb,
subject_type: str | None = Query(None, pattern="^(phone|device)$"),
keyword: str | None = Query(None, max_length=128),
rule_code: str | None = Query(None, max_length=64),
offset: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500),
) -> LimitOverrideList:
rows, total = repo.list_rows(
db,
subject_type=subject_type,
keyword=keyword,
rule_code=rule_code,
offset=offset,
limit=limit,
)
return LimitOverrideList(items=[_out(db, row) for row in rows], total=total)
@router.get(
"/subjects",
response_model=LimitSubjectList,
summary="按手机号或设备聚合读取白名单配置",
)
def list_override_subjects(
db: AdminDb,
subject_type: str | None = Query(None, pattern="^(phone|device)$"),
keyword: str | None = Query(None, max_length=128),
rule_code: str | None = Query(None, max_length=64),
offset: int = Query(0, ge=0),
limit: int = Query(10, ge=1, le=100),
) -> LimitSubjectList:
subjects, total = repo.list_subject_rows(
db,
subject_type=subject_type,
keyword=keyword,
rule_code=rule_code,
offset=offset,
limit=limit,
)
return LimitSubjectList(
items=[
_subject_out(
db,
subject_type=item_subject_type,
subject_value=subject_value,
rows=rows,
created_at=created_at,
)
for item_subject_type, subject_value, rows, created_at in subjects
],
total=total,
)
@router.patch(
"/subjects/enabled",
response_model=LimitSubjectOut,
summary="整体启用或停用一个手机号或设备的白名单",
)
def set_override_subject_enabled(
body: LimitSubjectEnabledPatch,
request: Request,
admin: CurrentAdmin,
db: AdminDb,
) -> LimitSubjectOut:
try:
subject_value = limit_policy.validate_whitelist_subject(
body.subject_type, body.subject_value
)
repo.lock_subject(
db,
subject_type=body.subject_type,
subject_value=subject_value,
)
rows = _subject_whitelist_rows(
db,
subject_type=body.subject_type,
subject_value=subject_value,
)
if not rows:
raise HTTPException(status_code=404, detail="白名单主体不存在")
before = [_audit_payload(_out(db, row)) for row in rows]
for row in rows:
repo.update(
db,
row,
enabled=body.enabled,
starts_at=None,
expires_at=None,
reason=None,
fields_set={"enabled"},
)
for rule_code in {row.rule_code for row in rows}:
_reconcile_risk_rule(db, rule_code)
after = [_audit_payload(_out(db, row)) for row in rows]
write_audit(
db,
admin,
action="limit.override.subject_enabled",
target_type="limit_override_subject",
target_id=f"{body.subject_type}:{subject_value}",
detail={"before": before, "after": after},
ip=get_client_ip(request),
commit=False,
)
db.commit()
except HTTPException:
raise
except (KeyError, ValueError) as exc:
db.rollback()
raise HTTPException(status_code=400, detail=str(exc)) from exc
rows = [
row
for row in repo.rows_for_subject(
db,
subject_type=body.subject_type,
subject_value=subject_value,
)
if row.mode
in {
limit_policy.MODE_UNLIMITED,
limit_policy.MODE_SUPPRESS_ALERT,
}
]
return _subject_out(
db,
subject_type=body.subject_type,
subject_value=subject_value,
rows=rows,
created_at=min(row.created_at for row in rows),
)
@router.put(
"/subjects",
response_model=LimitSubjectOut,
summary="整体更新一个手机号或设备的白名单限制项",
)
def replace_override_subject(
body: LimitOverrideBulkWrite,
request: Request,
admin: CurrentAdmin,
db: AdminDb,
) -> LimitSubjectOut:
current_rule_code = ""
try:
subject_value = limit_policy.validate_whitelist_subject(
body.subject_type, body.subject_value
)
repo.lock_subject(
db,
subject_type=body.subject_type,
subject_value=subject_value,
)
existing_rows = repo.rows_for_subject(
db,
subject_type=body.subject_type,
subject_value=subject_value,
)
subject_created_at = min(
(row.created_at for row in existing_rows),
default=None,
)
before = [_audit_payload(_out(db, row)) for row in existing_rows]
existing_by_rule = {row.rule_code: row for row in existing_rows}
selected_codes = set(body.rule_codes)
touched_rule_codes = set(selected_codes)
for current_rule_code in body.rule_codes:
rule = limit_policy.get_rule(current_rule_code)
mode = (
limit_policy.MODE_SUPPRESS_ALERT if rule.alert_only else limit_policy.MODE_UNLIMITED
)
row = existing_by_rule.get(current_rule_code)
if row is None:
row = repo.create(
db,
subject_type=body.subject_type,
subject_value=subject_value,
rule_code=current_rule_code,
mode=mode,
limit_value=None,
enabled=body.enabled,
starts_at=body.starts_at,
expires_at=body.expires_at,
reason=body.reason,
admin_id=admin.id,
)
# The table is ordered by the subject's original creation
# time. If an edit replaces every rule, carry that timestamp
# to the new rows so the subject does not jump to the top.
if subject_created_at is not None:
row.created_at = subject_created_at
continue
# Historical custom-value rows are converted to the only supported
# product modes when the administrator selects that rule again.
row.mode = mode
row.limit_value = None
row.reset_at = None
repo.update(
db,
row,
enabled=body.enabled,
starts_at=body.starts_at,
expires_at=body.expires_at,
reason=body.reason,
fields_set={"enabled", "starts_at", "expires_at", "reason"},
)
for row in existing_rows:
if row.rule_code not in selected_codes and row.mode in {
limit_policy.MODE_UNLIMITED,
limit_policy.MODE_SUPPRESS_ALERT,
}:
touched_rule_codes.add(row.rule_code)
db.delete(row)
db.flush()
except KeyError as exc:
raise HTTPException(status_code=404, detail="未知限制规则") from exc
except repo.DuplicateOverrideError as exc:
rule = limit_policy.get_rule(current_rule_code)
raise HTTPException(
status_code=409,
detail=f"{rule.label}”已有白名单配置,请刷新后重试",
) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
for rule_code in touched_rule_codes:
_reconcile_risk_rule(db, rule_code)
rows = [
row
for row in repo.rows_for_subject(
db,
subject_type=body.subject_type,
subject_value=subject_value,
)
if row.mode
in {
limit_policy.MODE_UNLIMITED,
limit_policy.MODE_SUPPRESS_ALERT,
}
]
after = [_audit_payload(_out(db, row)) for row in rows]
write_audit(
db,
admin,
action="limit.override.subject_replace",
target_type="limit_override_subject",
target_id=f"{body.subject_type}:{subject_value}",
detail={"before": before, "after": after},
ip=get_client_ip(request),
commit=False,
)
db.commit()
rows = [
row
for row in repo.rows_for_subject(
db,
subject_type=body.subject_type,
subject_value=subject_value,
)
if row.mode
in {
limit_policy.MODE_UNLIMITED,
limit_policy.MODE_SUPPRESS_ALERT,
}
]
return _subject_out(
db,
subject_type=body.subject_type,
subject_value=subject_value,
rows=rows,
created_at=min(row.created_at for row in rows),
)
@router.post(
"",
response_model=LimitOverrideOut,
status_code=status.HTTP_201_CREATED,
summary="新增白名单配置",
)
def create_override(
body: LimitOverrideWrite,
request: Request,
admin: CurrentAdmin,
db: AdminDb,
) -> LimitOverrideOut:
before: list[dict] = []
touched_rows: list[LimitPolicyOverride] = []
try:
subject_value = limit_policy.validate_whitelist_subject(
body.subject_type, body.subject_value
)
repo.lock_subject(
db,
subject_type=body.subject_type,
subject_value=subject_value,
)
existing_rows = _subject_whitelist_rows(
db,
subject_type=body.subject_type,
subject_value=subject_value,
)
before = [_audit_payload(_out(db, item)) for item in existing_rows]
row = repo.create(
db,
subject_type=body.subject_type,
subject_value=subject_value,
rule_code=body.rule_code,
mode=body.mode,
enabled=body.enabled,
starts_at=body.starts_at,
expires_at=body.expires_at,
reason=body.reason,
limit_value=None,
admin_id=admin.id,
)
touched_rows = [*existing_rows, row]
if row.mode in _WHITELIST_MODES:
# Keep the legacy single-rule endpoint compatible without letting
# it create a second validity period for the same logical subject.
for existing in existing_rows:
repo.update(
db,
existing,
enabled=body.enabled,
starts_at=body.starts_at,
expires_at=body.expires_at,
reason=None,
fields_set={"enabled", "starts_at", "expires_at"},
)
except KeyError as exc:
raise HTTPException(status_code=404, detail="未知限制规则") from exc
except repo.DuplicateOverrideError as exc:
raise HTTPException(
status_code=409,
detail="该手机号或设备已配置此规则,请编辑现有配置",
) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
for rule_code in {item.rule_code for item in touched_rows}:
_reconcile_risk_rule(db, rule_code)
after_rows = _subject_whitelist_rows(
db,
subject_type=row.subject_type,
subject_value=row.subject_value,
)
write_audit(
db,
admin,
action="limit.override.create",
target_type="limit_override_subject",
target_id=f"{row.subject_type}:{row.subject_value}",
detail={
"before": before,
"after": [_audit_payload(_out(db, item)) for item in after_rows],
},
ip=get_client_ip(request),
commit=False,
)
db.commit()
return _out(db, row)
@router.post(
"/bulk",
response_model=list[LimitOverrideOut],
status_code=status.HTTP_201_CREATED,
summary="批量新增临时不限或免告警白名单",
)
def create_overrides_bulk(
body: LimitOverrideBulkWrite,
request: Request,
admin: CurrentAdmin,
db: AdminDb,
) -> list[LimitOverrideOut]:
rows: list[LimitPolicyOverride] = []
before: list[dict] = []
current_rule_code = ""
try:
subject_value = limit_policy.validate_whitelist_subject(
body.subject_type, body.subject_value
)
repo.lock_subject(
db,
subject_type=body.subject_type,
subject_value=subject_value,
)
existing_rows = repo.rows_for_subject(
db,
subject_type=body.subject_type,
subject_value=subject_value,
)
whitelist_modes = {
limit_policy.MODE_UNLIMITED,
limit_policy.MODE_SUPPRESS_ALERT,
}
existing_whitelist_rows = [row for row in existing_rows if row.mode in whitelist_modes]
before = [_audit_payload(_out(db, row)) for row in existing_whitelist_rows]
existing_by_rule = {row.rule_code: row for row in existing_rows}
selected_codes = set(body.rule_codes)
for current_rule_code in body.rule_codes:
rule = limit_policy.get_rule(current_rule_code)
mode = (
limit_policy.MODE_SUPPRESS_ALERT if rule.alert_only else limit_policy.MODE_UNLIMITED
)
row = existing_by_rule.get(current_rule_code)
if row is None:
row = repo.create(
db,
subject_type=body.subject_type,
subject_value=subject_value,
rule_code=current_rule_code,
mode=mode,
limit_value=None,
enabled=body.enabled,
starts_at=body.starts_at,
expires_at=body.expires_at,
reason=body.reason,
admin_id=admin.id,
)
else:
row.mode = mode
row.limit_value = None
row.reset_at = None
repo.update(
db,
row,
enabled=body.enabled,
starts_at=body.starts_at,
expires_at=body.expires_at,
reason=body.reason,
fields_set={
"enabled",
"starts_at",
"expires_at",
"reason",
},
)
# 同一手机号或设备在产品上是一条白名单。追加限制项时保留原规则,
# 但统一使用最后一次配置的启用状态与有效期,避免一个主体出现多套时间。
rows = [
row
for row in repo.rows_for_subject(
db,
subject_type=body.subject_type,
subject_value=subject_value,
)
if row.mode in whitelist_modes
]
for row in rows:
if row.rule_code in selected_codes:
continue
repo.update(
db,
row,
enabled=body.enabled,
starts_at=body.starts_at,
expires_at=body.expires_at,
reason=None,
fields_set={"enabled", "starts_at", "expires_at"},
)
except KeyError as exc:
raise HTTPException(status_code=404, detail="未知限制规则") from exc
except repo.DuplicateOverrideError as exc:
rule = limit_policy.get_rule(current_rule_code)
raise HTTPException(
status_code=409,
detail=f"{rule.label}”白名单配置发生并发更新,请刷新后重试",
) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
for row in rows:
_reconcile_risk_rule(db, row.rule_code)
results = [_out(db, row) for row in rows]
write_audit(
db,
admin,
action="limit.override.bulk_create",
target_type="limit_override",
target_id=",".join(str(row.id) for row in rows),
detail={
"before": before,
"after": [_audit_payload(item) for item in results],
},
ip=get_client_ip(request),
commit=False,
)
db.commit()
return [_out(db, row) for row in rows]
@router.patch(
"/{override_id}",
response_model=LimitOverrideOut,
summary="编辑白名单配置",
)
def update_override(
override_id: int,
body: LimitOverridePatch,
request: Request,
admin: CurrentAdmin,
db: AdminDb,
) -> LimitOverrideOut:
row = _row_or_404(db, override_id)
repo.lock_subject(
db,
subject_type=row.subject_type,
subject_value=row.subject_value,
)
row = _row_or_404(db, override_id)
subject_rows = (
_subject_whitelist_rows(
db,
subject_type=row.subject_type,
subject_value=row.subject_value,
)
if row.mode in _WHITELIST_MODES
else [row]
)
before = [_audit_payload(_out(db, item)) for item in subject_rows]
fields_set = set(body.model_fields_set)
try:
period_fields = {"enabled", "starts_at", "expires_at"}
if row.mode in _WHITELIST_MODES and fields_set & period_fields:
new_enabled = body.enabled if "enabled" in fields_set else row.enabled
new_starts = body.starts_at if "starts_at" in fields_set else row.starts_at
new_expires = body.expires_at if "expires_at" in fields_set else row.expires_at
for item in subject_rows:
item_fields = set(period_fields)
if item.id == row.id and "reason" in fields_set:
item_fields.add("reason")
repo.update(
db,
item,
enabled=new_enabled,
starts_at=new_starts,
expires_at=new_expires,
reason=body.reason if item.id == row.id else None,
fields_set=item_fields,
)
else:
repo.update(
db,
row,
**body.model_dump(),
fields_set=fields_set,
)
except (KeyError, ValueError) as exc:
db.rollback()
raise HTTPException(status_code=400, detail=str(exc)) from exc
for rule_code in {item.rule_code for item in subject_rows}:
_reconcile_risk_rule(db, rule_code)
after_rows = [_audit_payload(_out(db, item)) for item in subject_rows]
write_audit(
db,
admin,
action="limit.override.update",
target_type="limit_override_subject",
target_id=f"{row.subject_type}:{row.subject_value}",
detail={"before": before, "after": after_rows},
ip=get_client_ip(request),
commit=False,
)
db.commit()
return _out(db, row)
@router.post(
"/{override_id}/reset",
response_model=LimitOverrideOut,
summary="停用例外配置并恢复全局策略",
)
def restore_global_policy(
override_id: int,
request: Request,
admin: CurrentAdmin,
db: AdminDb,
) -> LimitOverrideOut:
row = _row_or_404(db, override_id)
repo.lock_subject(
db,
subject_type=row.subject_type,
subject_value=row.subject_value,
)
row = _row_or_404(db, override_id)
subject_rows = (
_subject_whitelist_rows(
db,
subject_type=row.subject_type,
subject_value=row.subject_value,
)
if row.mode in _WHITELIST_MODES
else [row]
)
before = [_audit_payload(_out(db, item)) for item in subject_rows]
for item in subject_rows:
repo.restore_global(item)
for rule_code in {item.rule_code for item in subject_rows}:
_reconcile_risk_rule(db, rule_code)
after = [_audit_payload(_out(db, item)) for item in subject_rows]
write_audit(
db,
admin,
action="limit.override.restore_global",
target_type="limit_override_subject",
target_id=f"{row.subject_type}:{row.subject_value}",
detail={"before": before, "after": after},
ip=get_client_ip(request),
commit=False,
)
db.commit()
return _out(db, row)
@router.delete(
"/{override_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="删除白名单配置",
)
def delete_override(
override_id: int,
request: Request,
admin: CurrentAdmin,
db: AdminDb,
) -> None:
row = _row_or_404(db, override_id)
repo.lock_subject(
db,
subject_type=row.subject_type,
subject_value=row.subject_value,
)
row = _row_or_404(db, override_id)
before = _audit_payload(_out(db, row))
target_id = str(row.id)
rule_code = row.rule_code
db.delete(row)
db.flush()
_reconcile_risk_rule(db, rule_code)
write_audit(
db,
admin,
action="limit.override.delete",
target_type="limit_override",
target_id=target_id,
detail={"before": before},
ip=get_client_ip(request),
commit=False,
)
db.commit()
+14 -10
View File
@@ -23,8 +23,13 @@ from app.admin.schemas.risk_monitor import (
RiskResetResponse, RiskResetResponse,
RiskRuleConfig, RiskRuleConfig,
) )
from app.core import limit_policy from app.core.config_schema import (
RISK_COMPARE_DAILY_THRESHOLD_KEY,
RISK_ONECLICK_DAILY_THRESHOLD_KEY,
RISK_SMS_HOURLY_THRESHOLD_KEY,
)
from app.models.risk import RiskIncident, SubjectRestriction from app.models.risk import RiskIncident, SubjectRestriction
from app.repositories import app_config
from app.repositories import risk as risk_repo from app.repositories import risk as risk_repo
router = APIRouter( router = APIRouter(
@@ -78,16 +83,15 @@ def update_rules(
) -> RiskRuleConfig: ) -> RiskRuleConfig:
before = _rule_config(db).model_dump() before = _rule_config(db).model_dump()
after = body.model_dump() after = body.model_dump()
limit_policy.set_global_limits( values = (
db, (RISK_SMS_HOURLY_THRESHOLD_KEY, body.sms_hourly_threshold),
{ (RISK_ONECLICK_DAILY_THRESHOLD_KEY, body.oneclick_daily_threshold),
"risk.sms.hourly": body.sms_hourly_threshold, (RISK_COMPARE_DAILY_THRESHOLD_KEY, body.compare_daily_threshold),
"risk.oneclick.daily": body.oneclick_daily_threshold,
"risk.compare.daily": body.compare_daily_threshold,
},
admin_id=admin.id,
commit=False,
) )
for key, value in values:
app_config.set_value(
db, key, value, admin_id=admin.id, commit=False
)
now = risk_repo.utcnow() now = risk_repo.utcnow()
risk_repo.reconcile_behavior_rule( risk_repo.reconcile_behavior_rule(
-8
View File
@@ -88,11 +88,6 @@ def get_user_reward_stats(
withdraw_source: Annotated[ withdraw_source: Annotated[
str | None, Query(pattern="^(coin_cash|invite_cash)$") str | None, Query(pattern="^(coin_cash|invite_cash)$")
] = None, ] = None,
app_env: Annotated[str | None, Query(pattern="^(prod|test)$")] = None,
revenue_scope: Annotated[str, Query(pattern="^(business|all)$")] = "all",
feed_scene: Annotated[
str | None, Query(pattern="^(comparison|coupon|welfare)$")
] = None,
) -> UserRewardStats: ) -> UserRewardStats:
"""提现详情抽屉「用户统计区」。date_from/date_to 都不传 = 注册至今(全量)。""" """提现详情抽屉「用户统计区」。date_from/date_to 都不传 = 注册至今(全量)。"""
if not user_repo.user_exists(db, user_id): if not user_repo.user_exists(db, user_id):
@@ -104,9 +99,6 @@ def get_user_reward_stats(
date_from=date_from, date_from=date_from,
date_to=date_to, date_to=date_to,
withdraw_source=withdraw_source, withdraw_source=withdraw_source,
app_env=app_env,
revenue_scope=revenue_scope,
feed_scene=feed_scene,
) )
) )
+11 -30
View File
@@ -1,44 +1,25 @@
"""后台引导视频配置协议""" """admin 新手引导视频配置 schemas(开关 / 视频地址 / 前几次 / 每次金币)"""
from __future__ import annotations from __future__ import annotations
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field
from app.repositories.guide_video import ( from app.repositories.guide_video import MAX_PLAYS_LIMIT, REWARD_COIN_LIMIT
MAX_PLAYS_LIMIT,
MIN_PLAYS,
MIN_REWARD_COIN,
REWARD_COIN_LIMIT,
)
class GuideVideoConfigOut(BaseModel): class GuideVideoConfigOut(BaseModel):
scene: str
enabled: bool enabled: bool
video_url: str | None = None video_url: str | None = None # 相对地址 /media/guide_video/xxx.mp4;未配片 = None
max_plays: int max_plays: int
reward_coin: int reward_coin: int
duration_ms: int | None = None
circle_count: int = 10
circle_duration_ms: float | None = None
reward_per_circle: int
video_codec: str | None = None
audio_codec: str | None = None
analysis_status: str
analysis_error: str | None = None
config_version: int
updated_at: str | None = None updated_at: str | None = None
# 只读统计,后台展示用:已有多少次播放、其中已发币多少次。
total_plays: int = 0
granted_plays: int = 0
class GuideVideoConfigUpdate(BaseModel): class GuideVideoConfigUpdate(BaseModel):
enabled: bool | None = None """部分更新:只改传入(非 None)字段。视频文件走 /video 上传接口。"""
max_plays: int | None = Field(default=None, ge=MIN_PLAYS, le=MAX_PLAYS_LIMIT)
reward_coin: int | None = Field(
default=None, ge=MIN_REWARD_COIN, le=REWARD_COIN_LIMIT
)
@field_validator("reward_coin") enabled: bool | None = None
@classmethod max_plays: int | None = Field(default=None, ge=0, le=MAX_PLAYS_LIMIT)
def reward_must_be_multiple_of_ten(cls, value: int | None) -> int | None: reward_coin: int | None = Field(default=None, ge=0, le=REWARD_COIN_LIMIT)
if value is not None and value % 10:
raise ValueError("金币总价必须是 10 的倍数")
return value
-150
View File
@@ -1,150 +0,0 @@
"""Admin contracts for global limit rules and per-subject overrides."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
SubjectType = Literal["phone", "device"]
PolicyMode = Literal["unlimited", "suppress_alert"]
class LimitRuleOut(BaseModel):
code: str
label: str
group: str
global_limit: int
default_limit: int
window_label: str
subject_types: list[str]
allowed_modes: list[str]
min_value: int
max_value: int
supports_reset: bool
alert_only: bool
class GlobalLimitUpdate(BaseModel):
value: int = Field(ge=0, le=1_000_000)
class DeviceCandidateOut(BaseModel):
device_id: str
source: str
source_label: str
user_id: int | None = None
username: str | None = None
phone: str | None = None
nickname: str | None = None
device_model: str | None = None
last_active_at: datetime
class LimitOverrideWrite(BaseModel):
model_config = ConfigDict(extra="forbid")
subject_type: SubjectType
subject_value: str = Field(min_length=1, max_length=128)
rule_code: str = Field(min_length=1, max_length=64)
mode: PolicyMode
enabled: bool = True
starts_at: datetime | None = None
expires_at: datetime | None = None
reason: str = Field("", max_length=256)
@model_validator(mode="after")
def validate_time_range(self):
if self.starts_at and self.expires_at and self.expires_at <= self.starts_at:
raise ValueError("失效时间必须晚于生效时间")
return self
class LimitOverrideBulkWrite(BaseModel):
model_config = ConfigDict(extra="forbid")
subject_type: SubjectType
subject_value: str = Field(min_length=1, max_length=128)
rule_codes: list[str] = Field(min_length=1, max_length=32)
enabled: bool = True
starts_at: datetime | None = None
expires_at: datetime
reason: str = Field("", max_length=256)
@model_validator(mode="after")
def validate_bulk_request(self):
self.rule_codes = list(dict.fromkeys(self.rule_codes))
starts_at = (
self.starts_at.replace(tzinfo=UTC)
if self.starts_at and self.starts_at.tzinfo is None
else self.starts_at
)
expires_at = (
self.expires_at.replace(tzinfo=UTC)
if self.expires_at.tzinfo is None
else self.expires_at
)
if starts_at and expires_at <= starts_at:
raise ValueError("失效时间必须晚于生效时间")
if expires_at <= datetime.now(UTC):
raise ValueError("失效时间必须晚于当前时间")
return self
class LimitOverridePatch(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled: bool | None = None
starts_at: datetime | None = None
expires_at: datetime | None = None
reason: str | None = Field(None, max_length=256)
class LimitSubjectEnabledPatch(BaseModel):
model_config = ConfigDict(extra="forbid")
subject_type: SubjectType
subject_value: str = Field(min_length=1, max_length=128)
enabled: bool
class LimitOverrideOut(BaseModel):
id: int
subject_type: str
subject_value: str
rule_code: str
rule_label: str
rule_group: str
mode: str
limit_value: int | None
global_limit: int
effective_limit: int | None
enabled: bool
starts_at: datetime | None
expires_at: datetime | None
reset_at: datetime | None
reason: str | None
status: str
created_by_admin_id: int | None
created_at: datetime
updated_at: datetime
class LimitOverrideList(BaseModel):
items: list[LimitOverrideOut]
total: int
class LimitSubjectOut(BaseModel):
subject_type: str
subject_value: str
group_counts: dict[str, int]
total_rules: int
items: list[LimitOverrideOut]
created_at: datetime
updated_at: datetime
class LimitSubjectList(BaseModel):
items: list[LimitSubjectOut]
total: int
+2 -2
View File
@@ -21,9 +21,9 @@ class RiskMonitorSummary(BaseModel):
class RiskRuleConfig(BaseModel): class RiskRuleConfig(BaseModel):
sms_hourly_threshold: int = Field(ge=1, le=100_000) sms_hourly_threshold: int = Field(ge=1, le=5)
oneclick_daily_threshold: int = Field(ge=1, le=100_000) oneclick_daily_threshold: int = Field(ge=1, le=100_000)
compare_daily_threshold: int = Field(ge=1, le=100_000) compare_daily_threshold: int = Field(ge=1, le=100)
class RiskIncidentItem(BaseModel): class RiskIncidentItem(BaseModel):
+1 -1
View File
@@ -60,7 +60,7 @@ class UserRewardStats(BaseModel):
reward_video_avg_ecpm: float # 平均激励视频 eCPM(分/千次) reward_video_avg_ecpm: float # 平均激励视频 eCPM(分/千次)
reward_video_cash_cents: int # 激励视频提现(金币折现) reward_video_cash_cents: int # 激励视频提现(金币折现)
feed_count: int # 累计信息流广告数(granted 份数,unit_count 累加) feed_count: int # 累计信息流广告数(granted 份数,unit_count 累加)
feed_avg_ecpm: float # 全部 Draw/feed 实际展示的平均 eCPM(分/千次,含未发奖展示) feed_avg_ecpm: float # 平均信息流广告 eCPM(分/千次)
feed_cash_cents: int # 信息流广告提现(金币折现) feed_cash_cents: int # 信息流广告提现(金币折现)
+2 -13
View File
@@ -18,7 +18,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, Path, Request, status from fastapi import APIRouter, Depends, HTTPException, Path, Request, status
from app.api.deps import CurrentUser, DbSession from app.api.deps import CurrentUser, DbSession
from app.core import limit_policy, rewards from app.core import rewards
from app.core.config import settings from app.core.config import settings
from app.core.ratelimit import rate_limit from app.core.ratelimit import rate_limit
from app.integrations import pangle from app.integrations import pangle
@@ -413,23 +413,12 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
"feed ad reward user_id=%d event=%s status=%s units=%d coin=%d", "feed ad reward user_id=%d event=%s status=%s units=%d coin=%d",
user.id, rec.client_event_id, rec.status, rec.unit_count, rec.coin, user.id, rec.client_event_id, rec.status, rec.unit_count, rec.coin,
) )
feed_policy = limit_policy.resolve_for_user(db, "ad.feed.daily", user.id)
feed_limit = (
rewards.get_ad_daily_limit(db)
if feed_policy.override_id is None
and feed_policy.bucket_version == "default"
else feed_policy.limit
)
return FeedRewardOut( return FeedRewardOut(
granted=(rec.status == "granted"), granted=(rec.status == "granted"),
status=rec.status, status=rec.status,
coin=rec.coin, coin=rec.coin,
unit_count=rec.unit_count, unit_count=rec.unit_count,
daily_limit=( daily_limit=rewards.get_ad_daily_limit(db),
feed_limit
if feed_limit is not None
else limit_policy.get_rule("ad.feed.daily").max_value
),
) )
+28 -190
View File
@@ -16,7 +16,7 @@ from fastapi import APIRouter, HTTPException, Request
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from app.api.deps import CurrentUser, DbSession from app.api.deps import CurrentUser, DbSession
from app.core import limit_policy, test_account from app.core import test_account
from app.core.ratelimit import ( from app.core.ratelimit import (
RateLimitRule, RateLimitRule,
check_rate_limits, check_rate_limits,
@@ -69,7 +69,6 @@ SMS_LOGIN_MAX_PER_HOUR = 5
# 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。 # 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5 # 每小时上限 SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5 # 每小时上限
SMS_SEND_MAX_PER_DAY_PER_DEVICE = 20 # 每天上限(再叠一层日封顶,挡低频长时间轰炸) SMS_SEND_MAX_PER_DAY_PER_DEVICE = 20 # 每天上限(再叠一层日封顶,挡低频长时间轰炸)
UNLIMITED_VERIFY_ATTEMPTS = 2_147_483_647
def _client_ip(request: Request) -> str: def _client_ip(request: Request) -> str:
@@ -199,54 +198,16 @@ def sms_send(req: SmsSendRequest, request: Request, db: DbSession) -> SmsSendRes
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,挡短信轰炸/烧钱。 # 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,挡短信轰炸/烧钱。
# 关键:被单号 60s 冷却挡下的重发是「没真发、没烧钱」→ 不该占额度。故 check(先判)放在真发之前 # 关键:被单号 60s 冷却挡下的重发是「没真发、没烧钱」→ 不该占额度。故 check(先判)放在真发之前
# (超限直接 429、不真发),record(计数)只在 send_code 成功后调 —— 冷却/供应商失败抛 429 时直接返回、不计数。 # (超限直接 429、不真发),record(计数)只在 send_code 成功后调 —— 冷却/供应商失败抛 429 时直接返回、不计数。
hourly_policy = limit_policy.resolve(
db,
"sms.send.hourly",
phone=req.phone,
device=subject_id,
)
daily_policy = limit_policy.resolve(
db,
"sms.send.daily",
phone=req.phone,
device=subject_id,
)
cooldown_policy = limit_policy.resolve(
db,
"sms.phone.cooldown",
phone=req.phone,
)
hourly_limit = (
SMS_SEND_MAX_PER_HOUR_PER_DEVICE
if hourly_policy.override_id is None
and hourly_policy.bucket_version == "default"
else hourly_policy.limit
)
daily_limit = (
SMS_SEND_MAX_PER_DAY_PER_DEVICE
if daily_policy.override_id is None
and daily_policy.bucket_version == "default"
else daily_policy.limit
)
send_rules = [ send_rules = [
RateLimitRule("sms-send-device", hourly_limit, 3600, RateLimitRule("sms-send-device", SMS_SEND_MAX_PER_HOUR_PER_DEVICE, 3600,
"操作过于频繁,请稍后再试", hourly_policy.bucket_version), "操作过于频繁,请稍后再试"),
RateLimitRule("sms-send-device-daily", daily_limit, 86400, RateLimitRule("sms-send-device-daily", SMS_SEND_MAX_PER_DAY_PER_DEVICE, 86400,
"今日验证码发送次数过多,请明天再试", daily_policy.bucket_version), "今日验证码发送次数过多,请明天再试"),
] ]
check_rate_limits(request, subject=subject_id, rules=send_rules) check_rate_limits(request, subject=req.device_id, rules=send_rules)
try: try:
send_result = send_code( cooldown = send_code(req.phone)
req.phone,
cooldown_sec=(
None
if cooldown_policy.override_id is None
and cooldown_policy.bucket_version == "default"
else (0 if cooldown_policy.limit is None else cooldown_policy.limit)
),
)
cooldown = send_result.cooldown_sec
except SmsError as e: except SmsError as e:
risk_repo.record_behavior_event( risk_repo.record_behavior_event(
db, db,
@@ -263,7 +224,7 @@ def sms_send(req: SmsSendRequest, request: Request, db: DbSession) -> SmsSendRes
raise HTTPException(status_code=e.status_code, detail=str(e)) from e raise HTTPException(status_code=e.status_code, detail=str(e)) from e
# 发码成功 → 两道闸各 +1(被单号冷却挡下的重发走不到这里,故不占额度) # 发码成功 → 两道闸各 +1(被单号冷却挡下的重发走不到这里,故不占额度)
record_rate_limits(request, subject=subject_id, rules=send_rules) record_rate_limits(request, subject=req.device_id, rules=send_rules)
from app.core.config import settings # 局部 import 避免循环 from app.core.config import settings # 局部 import 避免循环
@@ -277,9 +238,7 @@ def sms_send(req: SmsSendRequest, request: Request, db: DbSession) -> SmsSendRes
phone=req.phone, phone=req.phone,
client_ip=_client_ip(request), client_ip=_client_ip(request),
outcome="success", outcome="success",
details={"mock": settings.SMS_MOCK, details={"mock": settings.SMS_MOCK},
"provider": send_result.provider,
"fallback": send_result.fallback},
evaluate_rule=risk_repo.RULE_SMS_HOURLY, evaluate_rule=risk_repo.RULE_SMS_HOURLY,
) )
@@ -297,7 +256,7 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
# 放在最前面:命中即不校验验证码;先扣当日额度,超限直接拒,挡住有人猜到号后脚本刷。 # 放在最前面:命中即不校验验证码;先扣当日额度,超限直接拒,挡住有人猜到号后脚本刷。
# 测试账号走自己的每日额度、不受下面 (设备+IP) 每小时限流约束(QA 需在一小时内反复登录联调)。 # 测试账号走自己的每日额度、不受下面 (设备+IP) 每小时限流约束(QA 需在一小时内反复登录联调)。
if test_account.is_test_account(req.phone): if test_account.is_test_account(req.phone):
if not test_account.try_consume_quota(req.phone): if not test_account.try_consume_quota():
raise HTTPException(status_code=429, detail="测试账号今日使用次数已达上限,请明天再试") raise HTTPException(status_code=429, detail="测试账号今日使用次数已达上限,请明天再试")
user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms") user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
risk_repo.record_behavior_event( risk_repo.record_behavior_event(
@@ -324,48 +283,17 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
# **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破(另有单码失败 SMS_MAX_VERIFY_ATTEMPTS 次即作废兜底)。 # **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破(另有单码失败 SMS_MAX_VERIFY_ATTEMPTS 次即作废兜底)。
# ⚠️ 按设备而非手机号 → 一台机器换不同手机号刷登录也受限(防一机狂登多号);device_id 空(老客户端)时 # ⚠️ 按设备而非手机号 → 一台机器换不同手机号刷登录也受限(防一机狂登多号);device_id 空(老客户端)时
# 退化为该 IP 下所有空设备聚一桶,仍受限。 # 退化为该 IP 下所有空设备聚一桶,仍受限。
login_policy = limit_policy.resolve(
db,
"sms.login.hourly",
phone=req.phone,
device=subject_id,
)
verify_policy = limit_policy.resolve(
db,
"sms.code.failed_attempts",
phone=req.phone,
)
login_limit = (
SMS_LOGIN_MAX_PER_HOUR
if login_policy.override_id is None
and login_policy.bucket_version == "default"
else login_policy.limit
)
enforce_rate_limit( enforce_rate_limit(
request, request,
scope="sms-login-device", scope="sms-login-device",
subject=subject_id, subject=req.device_id,
limit=login_limit, limit=SMS_LOGIN_MAX_PER_HOUR,
window_sec=3600, window_sec=3600,
detail="登录尝试过于频繁,请稍后再试", detail="登录尝试过于频繁,请稍后再试",
bucket_suffix=login_policy.bucket_version,
) )
try: try:
ok = verify_code( ok = verify_code(req.phone, req.code)
req.phone,
req.code,
max_failed_attempts=(
None
if verify_policy.override_id is None
and verify_policy.bucket_version == "default"
else (
UNLIMITED_VERIFY_ATTEMPTS
if verify_policy.limit is None
else verify_policy.limit
)
),
)
except SmsError as e: # provider 校验降级(如阿里云接口异常)→ 原样透出其状态码(503),别误报「验证码错误」 except SmsError as e: # provider 校验降级(如阿里云接口异常)→ 原样透出其状态码(503),别误报「验证码错误」
raise HTTPException(status_code=e.status_code, detail=str(e)) from e raise HTTPException(status_code=e.status_code, detail=str(e)) from e
if not ok: if not ok:
@@ -466,25 +394,15 @@ def _finish_wechat_bind(
未占用 → 新建微信账号(channel=wechat,昵称头像取微信)→ 签 token 登入。""" 未占用 → 新建微信账号(channel=wechat,昵称头像取微信)→ 签 token 登入。"""
existing = user_repo.get_user_by_phone(db, phone) existing = user_repo.get_user_by_phone(db, phone)
if existing is not None: if existing is not None:
from app.core.config import settings # 局部 import,避免循环
ticket = create_conflict_ticket( ticket = create_conflict_ticket(
openid=openid, openid=openid,
wechat_nickname=wechat_nickname, wechat_nickname=wechat_nickname,
wechat_avatar_url=wechat_avatar_url, wechat_avatar_url=wechat_avatar_url,
phone=phone, phone=phone,
) )
rebind_policy = limit_policy.resolve( blocked = rebind_repo.rebound_within_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS)
db,
"phone.rebind.days",
phone=phone,
device=device_id,
)
rebind_days = rebind_policy.limit or 0
blocked = rebind_repo.rebound_within_days(
db,
phone,
rebind_days,
reset_at=rebind_policy.reset_at,
)
logger.info( logger.info(
"wechat bind phone occupied phone=%s by user_id=%d has_wechat=%s", "wechat bind phone occupied phone=%s by user_id=%d has_wechat=%s",
mask_phone(phone), existing.id, bool(existing.wechat_openid), mask_phone(phone), existing.id, bool(existing.wechat_openid),
@@ -500,12 +418,7 @@ def _finish_wechat_bind(
conflict_ticket=ticket, conflict_ticket=ticket,
rebind_available=not blocked, rebind_available=not blocked,
rebind_blocked_days=( rebind_blocked_days=(
rebind_repo.remaining_block_days( rebind_repo.remaining_block_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS)
db,
phone,
rebind_days,
reset_at=rebind_policy.reset_at,
)
if blocked else 0 if blocked else 0
), ),
) )
@@ -538,50 +451,18 @@ def wechat_bind_phone_sms(
except TokenError as e: except TokenError as e:
raise HTTPException(status_code=401, detail="授权已过期,请重新用微信登录") from e raise HTTPException(status_code=401, detail="授权已过期,请重新用微信登录") from e
subject_id = _device_subject(req.device_id, request)
# 防刷:同 sms/login,按 设备+IP 每小时限流(放在验证码校验之前,失败也计数) # 防刷:同 sms/login,按 设备+IP 每小时限流(放在验证码校验之前,失败也计数)
bind_policy = limit_policy.resolve(
db,
"wechat.bind.hourly",
phone=req.phone,
device=subject_id,
)
verify_policy = limit_policy.resolve(
db,
"sms.code.failed_attempts",
phone=req.phone,
)
bind_limit = (
SMS_LOGIN_MAX_PER_HOUR
if bind_policy.override_id is None
and bind_policy.bucket_version == "default"
else bind_policy.limit
)
enforce_rate_limit( enforce_rate_limit(
request, request,
scope="wechat-bind-sms-device", scope="wechat-bind-sms-device",
subject=subject_id, subject=req.device_id,
limit=bind_limit, limit=SMS_LOGIN_MAX_PER_HOUR,
window_sec=3600, window_sec=3600,
detail="登录尝试过于频繁,请稍后再试", detail="登录尝试过于频繁,请稍后再试",
bucket_suffix=bind_policy.bucket_version,
) )
try: try:
ok = verify_code( ok = verify_code(req.phone, req.code)
req.phone,
req.code,
max_failed_attempts=(
None
if verify_policy.override_id is None
and verify_policy.bucket_version == "default"
else (
UNLIMITED_VERIFY_ATTEMPTS
if verify_policy.limit is None
else verify_policy.limit
)
),
)
except SmsError as e: # provider 校验降级(如阿里云接口异常)→ 原样透出其状态码(503),别误报「验证码错误」 except SmsError as e: # provider 校验降级(如阿里云接口异常)→ 原样透出其状态码(503),别误报「验证码错误」
raise HTTPException(status_code=e.status_code, detail=str(e)) from e raise HTTPException(status_code=e.status_code, detail=str(e)) from e
if not ok: if not ok:
@@ -641,23 +522,9 @@ def wechat_conflict_continue(
except TokenError as e: except TokenError as e:
raise HTTPException(status_code=401, detail="操作超时,请重新用微信登录") from e raise HTTPException(status_code=401, detail="操作超时,请重新用微信登录") from e
subject_id = _device_subject(req.device_id, request)
conflict_policy = limit_policy.resolve(
db,
"wechat.conflict.hourly",
phone=claims["phone"],
device=subject_id,
)
conflict_limit = (
SMS_LOGIN_MAX_PER_HOUR
if conflict_policy.override_id is None
and conflict_policy.bucket_version == "default"
else conflict_policy.limit
)
enforce_rate_limit( enforce_rate_limit(
request, scope="wechat-conflict-device", subject=subject_id, request, scope="wechat-conflict-device", subject=req.device_id,
limit=conflict_limit, window_sec=3600, detail="操作过于频繁,请稍后再试", limit=SMS_LOGIN_MAX_PER_HOUR, window_sec=3600, detail="操作过于频繁,请稍后再试",
bucket_suffix=conflict_policy.bucket_version,
) )
user = user_repo.get_user_by_phone(db, claims["phone"]) user = user_repo.get_user_by_phone(db, claims["phone"])
@@ -695,50 +562,21 @@ def wechat_conflict_continue(
def wechat_conflict_rebind( def wechat_conflict_rebind(
req: WechatConflictRebindRequest, request: Request, db: DbSession req: WechatConflictRebindRequest, request: Request, db: DbSession
) -> WechatBindResultResponse: ) -> WechatBindResultResponse:
from app.core.config import settings # 局部 import,避免循环
try: try:
claims = decode_conflict_ticket(req.conflict_ticket) claims = decode_conflict_ticket(req.conflict_ticket)
except TokenError as e: except TokenError as e:
raise HTTPException(status_code=401, detail="操作超时,请重新用微信登录") from e raise HTTPException(status_code=401, detail="操作超时,请重新用微信登录") from e
subject_id = _device_subject(req.device_id, request)
conflict_policy = limit_policy.resolve(
db,
"wechat.conflict.hourly",
phone=claims["phone"],
device=subject_id,
)
conflict_limit = (
SMS_LOGIN_MAX_PER_HOUR
if conflict_policy.override_id is None
and conflict_policy.bucket_version == "default"
else conflict_policy.limit
)
enforce_rate_limit( enforce_rate_limit(
request, scope="wechat-conflict-device", subject=subject_id, request, scope="wechat-conflict-device", subject=req.device_id,
limit=conflict_limit, window_sec=3600, detail="操作过于频繁,请稍后再试", limit=SMS_LOGIN_MAX_PER_HOUR, window_sec=3600, detail="操作过于频繁,请稍后再试",
bucket_suffix=conflict_policy.bucket_version,
) )
phone = claims["phone"] phone = claims["phone"]
rebind_policy = limit_policy.resolve( if rebind_repo.rebound_within_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS):
db, days = rebind_repo.remaining_block_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS)
"phone.rebind.days",
phone=phone,
device=req.device_id,
)
rebind_days = rebind_policy.limit or 0
if rebind_repo.rebound_within_days(
db,
phone,
rebind_days,
reset_at=rebind_policy.reset_at,
):
days = rebind_repo.remaining_block_days(
db,
phone,
rebind_days,
reset_at=rebind_policy.reset_at,
)
raise HTTPException(status_code=409, detail=f"该手机号 {days} 天内已换绑过,暂不能再次换绑") raise HTTPException(status_code=409, detail=f"该手机号 {days} 天内已换绑过,暂不能再次换绑")
user = user_repo.rebind_account( user = user_repo.rebind_account(
+5 -13
View File
@@ -21,6 +21,7 @@ from __future__ import annotations
import json import json
import logging import logging
import time import time
import uuid
from typing import Any from typing import Any
import httpx import httpx
@@ -31,7 +32,6 @@ from app.api.deps import DbSession, OptionalUser
from app.core.config import settings from app.core.config import settings
from app.core.logging import trace_id_ctx from app.core.logging import trace_id_ctx
from app.core.pricebot_client import get_pricebot_client from app.core.pricebot_client import get_pricebot_client
from app.core.trace_ids import new_trace_id
from app.core.pricebot_router import pick_pricebot from app.core.pricebot_router import pick_pricebot
from app.db.session import SessionLocal from app.db.session import SessionLocal
from app.repositories import comparison as crud_compare from app.repositories import comparison as crud_compare
@@ -108,7 +108,7 @@ def _harvest_done_blocking(
def _harvest_abort_blocking( def _harvest_abort_blocking(
trace_id: str, status_hint: str, reason: str | None, trace_url: str | None, trace_id: str, status_hint: str, reason: str | None, trace_url: str | None,
) -> int | None: ) -> None:
with SessionLocal() as db: with SessionLocal() as db:
rec = crud_compare.harvest_abort( rec = crud_compare.harvest_abort(
db, trace_id=trace_id, status=status_hint, reason=reason, trace_url=trace_url, db, trace_id=trace_id, status=status_hint, reason=reason, trace_url=trace_url,
@@ -118,7 +118,6 @@ def _harvest_abort_blocking(
extra={"phase": "harvest_abort", extra={"phase": "harvest_abort",
"status": (rec.status if rec else None), "reason": reason}, "status": (rec.status if rec else None), "reason": reason},
) )
return rec.id if rec is not None else None
async def _forward( async def _forward(
@@ -142,7 +141,7 @@ async def _forward(
trace_id = meta.get("trace_id") trace_id = meta.get("trace_id")
minted = False minted = False
if not trace_id: if not trace_id:
trace_id = new_trace_id() trace_id = str(uuid.uuid4())
meta["trace_id"] = trace_id meta["trace_id"] = trace_id
raw = json.dumps(meta).encode() # 仅首帧重新序列化(注入 trace_id);后续帧走原始 bytes raw = json.dumps(meta).encode() # 仅首帧重新序列化(注入 trace_id);后续帧走原始 bytes
minted = True minted = True
@@ -292,10 +291,7 @@ async def trace_epilogue(
@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传 + 夭折落库)") @router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传 + 夭折落库)")
async def trace_finalize( async def trace_finalize(
request: Request, request: Request, user: OptionalUser, db: DbSession
background_tasks: BackgroundTasks,
user: OptionalUser,
db: DbSession,
) -> dict[str, Any]: ) -> dict[str, Any]:
_ensure_compare_allowed(user, db) _ensure_compare_allowed(user, db)
# 用户终止 / Phase1 未识别没到 done 帧: pricebot 打包半截上云返回 {trace_url}; # 用户终止 / Phase1 未识别没到 done 帧: pricebot 打包半截上云返回 {trace_url};
@@ -306,16 +302,12 @@ async def trace_finalize(
request, "/api/trace/finalize", user, harvest_first_frame=False, request, "/api/trace/finalize", user, harvest_first_frame=False,
) )
try: try:
record_id = await run_in_threadpool( await run_in_threadpool(
_harvest_abort_blocking, trace_id, _harvest_abort_blocking, trace_id,
(meta.get("status") or "cancelled"), (meta.get("status") or "cancelled"),
(meta.get("reason") or meta.get("information")), (meta.get("reason") or meta.get("information")),
(resp.get("trace_url") if isinstance(resp, dict) else None), (resp.get("trace_url") if isinstance(resp, dict) else None),
) )
if record_id is not None:
background_tasks.add_task(
backfill_comparison_llm_cost, record_id, trace_id
)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.warning("harvest_abort failed trace=%s: %s", trace_id, e) logger.warning("harvest_abort failed trace=%s: %s", trace_id, e)
return resp return resp
+5 -29
View File
@@ -16,8 +16,6 @@ import logging
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status
from app.api.deps import CurrentUser, DbSession from app.api.deps import CurrentUser, DbSession
from app.core import limit_policy
from app.core.trace_ids import new_trace_id
from app.repositories import comparison as crud_compare from app.repositories import comparison as crud_compare
from app.repositories import risk as risk_repo from app.repositories import risk as risk_repo
from app.schemas.compare_record import ( from app.schemas.compare_record import (
@@ -54,34 +52,18 @@ def reserve_compare_start(
scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT, scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT,
): ):
raise HTTPException(status_code=403, detail="账号存在异常,该功能暂不可用") raise HTTPException(status_code=403, detail="账号存在异常,该功能暂不可用")
# trace_id 统一由服务端签发(客户端不带时):预占额度本就是任务的第一个请求,
# 签发与建 running 行合一,此后 Phase1/Phase2/记录/前端日志全链用同一个 id。
# 客户端带了则沿用——老客户端兼容 + 同 trace 重试幂等(reserve_daily_start 按 trace_id 去重)。
trace_id = payload.trace_id or new_trace_id()
try: try:
policy = limit_policy.resolve(
db,
"compare.start.daily",
phone=user.phone,
device=payload.device_id,
)
rec, used = crud_compare.reserve_daily_start( rec, used = crud_compare.reserve_daily_start(
db, db,
user_id=user.id, user_id=user.id,
trace_id=trace_id, trace_id=payload.trace_id,
business_type=payload.business_type, business_type=payload.business_type,
device_id=payload.device_id, device_id=payload.device_id,
limit=policy.limit,
reset_at=policy.reset_at,
) )
except crud_compare.DailyCompareStartLimitExceeded: except crud_compare.DailyCompareStartLimitExceeded:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=( detail="今日已比价超过100次,请明天再试",
f"今日已比价超过{policy.limit}次,请明天再试"
if policy.limit is not None
else "今日比价次数已达上限,请明天再试"
),
) from None ) from None
except crud_compare.ComparisonTraceOwnershipError: except crud_compare.ComparisonTraceOwnershipError:
raise HTTPException( raise HTTPException(
@@ -89,17 +71,11 @@ def reserve_compare_start(
detail="比价任务标识冲突,请重新发起", detail="比价任务标识冲突,请重新发起",
) from None ) from None
# 风控阈值由后台动态配置,不能再只在固定的 100 次业务上限处同步。 # 风控阈值由后台动态配置,不能再只在固定的 100 次业务上限处同步。
risk_repo.sync_compare_incident( risk_repo.sync_compare_incident(db, user_id=user.id, at=rec.created_at)
db,
user_id=user.id,
at=rec.created_at,
device_id=payload.device_id,
)
return CompareStartReserveOut( return CompareStartReserveOut(
limit=policy.limit, limit=crud_compare.DAILY_COMPARE_START_LIMIT,
used=used, used=used,
remaining=max(policy.limit - used, 0) if policy.limit is not None else None, remaining=max(crud_compare.DAILY_COMPARE_START_LIMIT - used, 0),
trace_id=trace_id,
) )
+5 -31
View File
@@ -21,7 +21,6 @@ from fastapi.concurrency import run_in_threadpool
from app.api.deps import CurrentUser, DbSession from app.api.deps import CurrentUser, DbSession
from app.core.config import settings from app.core.config import settings
from app.core.pricebot_client import get_pricebot_client from app.core.pricebot_client import get_pricebot_client
from app.core.trace_ids import new_trace_id
from app.core.pricebot_router import pick_pricebot from app.core.pricebot_router import pick_pricebot
from app.db.session import SessionLocal from app.db.session import SessionLocal
from app.repositories import coupon_state as coupon_repo from app.repositories import coupon_state as coupon_repo
@@ -31,7 +30,6 @@ from app.schemas.coupon_state import (
CouponPromptShouldShowOut, CouponPromptShouldShowOut,
CouponPromptShownIn, CouponPromptShownIn,
CouponSessionIn, CouponSessionIn,
CouponSessionOut,
CouponStatsOut, CouponStatsOut,
) )
@@ -177,12 +175,6 @@ async def coupon_step(
) )
resp_json = resp.json() resp_json = resp.json()
# 每帧响应顶层回传本次任务 trace_id(对齐 compare _forward 的 setdefault):客户端任一帧
# 都能从响应拿到全链 id。**只回显请求里带的、不 mint**——step 是循环接口,每帧签新 id
# 会把一次任务打散;领券 trace_id 的唯一签发点在 /coupon/session (status=started)。
# pricebot 响应顶层本无 trace_id(只有 trace_url),setdefault 不会覆盖任何上游值。
if isinstance(resp_json, dict) and trace_id:
resp_json.setdefault("trace_id", trace_id)
# 领券结果沉淀:每日资产 + 逐次事件;中间帧和 done 全量帧均幂等写库。 # 领券结果沉淀:每日资产 + 逐次事件;中间帧和 done 全量帧均幂等写库。
# 当前只记录、不参与"要不要领"判断(MVP 先不去重)。写库失败不影响返回。 # 当前只记录、不参与"要不要领"判断(MVP 先不去重)。写库失败不影响返回。
@@ -212,33 +204,15 @@ async def coupon_step(
return resp_json return resp_json
@router.post( @router.post("/session", summary="领券任务流水上报(admin 领券数据看板数据源)")
"/session", def coupon_session(payload: CouponSessionIn, db: DbSession) -> dict[str, bool]:
response_model=CouponSessionOut,
summary="领券任务流水上报(admin 领券数据看板数据源;started 兼签发本轮 trace_id)",
)
def coupon_session(payload: CouponSessionIn, db: DbSession) -> CouponSessionOut:
"""客户端两段上报一次领券流水(发起 started / 收尾 completed-failed-abandoned),按 trace_id upsert """客户端两段上报一次领券流水(发起 started / 收尾 completed-failed-abandoned),按 trace_id upsert
到 coupon_session。不鉴权(同领券循环 MVP,按 device_id/trace_id);供 admin「领券数据」看板算 到 coupon_session。不鉴权(同领券循环 MVP,按 device_id/trace_id);供 admin「领券数据」看板算
发起/完成数、耗时分位、机型维度。写库失败不应连累客户端(本就 fire-and-forget),吞掉返回 ok。 发起/完成数、耗时分位、机型维度。写库失败不应连累客户端(本就 fire-and-forget),吞掉返回 ok。"""
trace_id 统一由后端签发:started 不带 trace_id → 签发 uuid 并随响应返回,客户端全程用它
(领券 step 循环 / 收尾上报 / 前端运行日志)。签发不依赖写库成功——写库失败照样返回 trace_id,
后续收尾上报 upsert 会补建行。非 started 缺 trace_id 不签发(收尾没有 id 只能是异常调用,
签发新 id 只会造出一行查不到发起信息的孤儿),不写库、trace_id=null 返回。
"""
trace_id = payload.trace_id or (
new_trace_id() if payload.status == "started" else None
)
if trace_id is None:
logger.warning(
"coupon session missing trace_id for status=%s (skip write)", payload.status
)
return CouponSessionOut(ok=True, trace_id=None)
try: try:
coupon_repo.upsert_coupon_session( coupon_repo.upsert_coupon_session(
db, db,
trace_id=trace_id, trace_id=payload.trace_id,
device_id=payload.device_id, device_id=payload.device_id,
status=payload.status, status=payload.status,
started_at_ms=payload.started_at_ms, started_at_ms=payload.started_at_ms,
@@ -255,7 +229,7 @@ def coupon_session(payload: CouponSessionIn, db: DbSession) -> CouponSessionOut:
) )
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.warning("coupon session write failed: %s", e) logger.warning("coupon session write failed: %s", e)
return CouponSessionOut(ok=True, trace_id=trace_id) return {"ok": True}
@router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)") @router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)")
-33
View File
@@ -19,10 +19,8 @@ from app.api.deps import CurrentUser, DbSession
from app.integrations import vendor_push from app.integrations import vendor_push
from app.repositories import device as device_repo from app.repositories import device as device_repo
from app.schemas.device import ( from app.schemas.device import (
DeferredDeviceUnregisterRequest,
DeviceOut, DeviceOut,
DeviceRegisterRequest, DeviceRegisterRequest,
DeviceUnregisterRequest,
HeartbeatRequest, HeartbeatRequest,
LivenessAckRequest, LivenessAckRequest,
LivenessOut, LivenessOut,
@@ -80,7 +78,6 @@ def register_device(
registration_id=req.registration_id, registration_id=req.registration_id,
push_vendor=req.push_vendor, push_vendor=req.push_vendor,
push_token=req.push_token, push_token=req.push_token,
push_binding_id=req.push_binding_id,
platform=req.platform, platform=req.platform,
app_version=req.app_version, app_version=req.app_version,
) )
@@ -109,36 +106,6 @@ def report_heartbeat(
registration_id=req.registration_id, registration_id=req.registration_id,
push_vendor=req.push_vendor, push_vendor=req.push_vendor,
push_token=req.push_token, push_token=req.push_token,
push_binding_id=req.push_binding_id,
)
return OkResponse()
@router.post("/unregister", response_model=OkResponse, summary="解绑当前用户的本机推送目标")
def unregister_device(
req: DeviceUnregisterRequest,
user: CurrentUser,
db: DbSession,
) -> OkResponse:
device_repo.unregister_push_binding(db, user_id=user.id, device_id=req.device_id)
logger.info("device unregister user_id=%d device_id=%s", user.id, req.device_id)
return OkResponse()
@router.post(
"/unregister/deferred",
response_model=OkResponse,
summary="离线退出后按登录会话凭据幂等解绑",
)
def unregister_device_deferred(
req: DeferredDeviceUnregisterRequest,
db: DbSession,
) -> OkResponse:
# 不返回是否命中,避免公开接口泄露设备绑定状态。
device_repo.unregister_push_binding_deferred(
db,
device_id=req.device_id,
push_binding_id=req.push_binding_id,
) )
return OkResponse() return OkResponse()
+27 -42
View File
@@ -1,16 +1,22 @@
"""引导视频 prepare/start/reward 客户端 API。""" """新手引导视频(领券等候浮层前 N 次替代广告)。
路由前缀 `/api/v1/guide-video`(均需 Bearer):
POST /start 这次浮层放引导视频还是放广告?命中则**当场计次**并下发 play_token
POST /reward 播完 / 中途关闭都调,按 play_token 幂等发固定金币
发币额度以**服务端配置**为准(运营后台可改),客户端只报"播完/关闭",报不了金额,
所以被破解也刷不到超额金币;次数上限由 guide_video_play 行数(按账号)硬卡。
"""
from __future__ import annotations from __future__ import annotations
import logging import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from app.api.deps import CurrentUser, DbSession from app.api.deps import CurrentUser, DbSession
from app.core.ratelimit import rate_limit from app.core.ratelimit import rate_limit
from app.repositories import guide_video as crud_guide from app.repositories import guide_video as crud_guide
from app.schemas.guide_video import ( from app.schemas.guide_video import (
GuideVideoPrepareIn,
GuideVideoPrepareOut,
GuideVideoRewardIn, GuideVideoRewardIn,
GuideVideoRewardOut, GuideVideoRewardOut,
GuideVideoStartIn, GuideVideoStartIn,
@@ -18,41 +24,25 @@ from app.schemas.guide_video import (
) )
logger = logging.getLogger("shagua.guide_video") logger = logging.getLogger("shagua.guide_video")
router = APIRouter(prefix="/api/v1/guide-video", tags=["guide-video"]) router = APIRouter(prefix="/api/v1/guide-video", tags=["guide-video"])
@router.post(
"/prepare",
response_model=GuideVideoPrepareOut,
dependencies=[Depends(rate_limit(60, 60, "guide-video-prepare"))],
)
def prepare(
payload: GuideVideoPrepareIn, user: CurrentUser, db: DbSession
) -> GuideVideoPrepareOut:
result = crud_guide.prepare_play(db, user.id, scene=payload.scene)
logger.info(
"guide video prepare user_id=%d scene=%s should_play=%s reason=%s",
user.id, payload.scene, result["should_play"], result["reason"],
)
return GuideVideoPrepareOut(**result)
@router.post( @router.post(
"/start", "/start",
response_model=GuideVideoStartOut, response_model=GuideVideoStartOut,
summary="领券浮层是否放新手引导视频(命中即计次)",
dependencies=[Depends(rate_limit(60, 60, "guide-video-start"))], dependencies=[Depends(rate_limit(60, 60, "guide-video-start"))],
) )
def start( def start(payload: GuideVideoStartIn, user: CurrentUser, db: DbSession) -> GuideVideoStartOut:
payload: GuideVideoStartIn, user: CurrentUser, db: DbSession """开播即计数:返回 should_play=True 时服务端已写下这一次,客户端必须真的播。
) -> GuideVideoStartOut:
try: 没配视频 / 开关关 / 次数用完 → should_play=False,客户端照旧走广告链路(行为不变)。
result = crud_guide.start_play(db, user.id, play_token=payload.play_token) """
except crud_guide.PlayStateError as exc: result = crud_guide.start_play(db, user.id, scene=payload.scene or "coupon")
status_code = 404 if exc.code == "play_not_found" else 409
raise HTTPException(status_code=status_code, detail=exc.detail()) from exc
logger.info( logger.info(
"guide video start user_id=%d token=%s status=%s seq=%d", "guide video start user_id=%d scene=%s should_play=%s seq=%d remaining=%d",
user.id, payload.play_token[:12], result["status"], result["seq"], user.id, payload.scene, result["should_play"], result["seq"], result["remaining"],
) )
return GuideVideoStartOut(**result) return GuideVideoStartOut(**result)
@@ -60,20 +50,15 @@ def start(
@router.post( @router.post(
"/reward", "/reward",
response_model=GuideVideoRewardOut, response_model=GuideVideoRewardOut,
dependencies=[Depends(rate_limit(120, 60, "guide-video-reward"))], summary="引导视频发金币(播完/中途关闭都发,play_token 幂等)",
dependencies=[Depends(rate_limit(60, 60, "guide-video-reward"))],
) )
def reward( def reward(payload: GuideVideoRewardIn, user: CurrentUser, db: DbSession) -> GuideVideoRewardOut:
payload: GuideVideoRewardIn, user: CurrentUser, db: DbSession result = crud_guide.grant_play(
) -> GuideVideoRewardOut: db, user.id, play_token=payload.play_token, completed=payload.completed
result = crud_guide.grant_circle(
db,
user.id,
play_token=payload.play_token,
circle=payload.circle,
) )
logger.info( logger.info(
"guide video reward user_id=%d token=%s circle=%d status=%s granted=%s", "guide video reward user_id=%d token=%s completed=%s granted=%s coin=%d",
user.id, payload.play_token[:12], payload.circle, user.id, payload.play_token[:12], payload.completed, result["granted"], result["coin"],
result["status"], result["granted"],
) )
return GuideVideoRewardOut(**result) return GuideVideoRewardOut(**result)
+3 -24
View File
@@ -79,8 +79,6 @@ class Settings(BaseSettings):
HONOR_PUSH_APP_ID: str = "" HONOR_PUSH_APP_ID: str = ""
HONOR_PUSH_CLIENT_ID: str = "" HONOR_PUSH_CLIENT_ID: str = ""
HONOR_PUSH_CLIENT_SECRET: str = "" HONOR_PUSH_CLIENT_SECRET: str = ""
# 0=正式消息(默认);1=测试消息(仅开发联调,勿用于生产)。
HONOR_PUSH_TARGET_USER_TYPE: int = Field(default=0, ge=0, le=1)
HONOR_PUSH_TOKEN_ENDPOINT: str = "https://iam.developer.honor.com/auth/token" HONOR_PUSH_TOKEN_ENDPOINT: str = "https://iam.developer.honor.com/auth/token"
HONOR_PUSH_SEND_ENDPOINT_TEMPLATE: str = ( HONOR_PUSH_SEND_ENDPOINT_TEMPLATE: str = (
"https://push-api.cloud.honor.com/api/v1/{app_id}/sendMessage" "https://push-api.cloud.honor.com/api/v1/{app_id}/sendMessage"
@@ -90,8 +88,6 @@ class Settings(BaseSettings):
# (OAuth 换 token 时 client_id 即 AppId)。发送走 v1 messages:send,成功码 80000000。 # (OAuth 换 token 时 client_id 即 AppId)。发送走 v1 messages:send,成功码 80000000。
HUAWEI_PUSH_APP_ID: str = "" HUAWEI_PUSH_APP_ID: str = ""
HUAWEI_PUSH_APP_SECRET: str = "" HUAWEI_PUSH_APP_SECRET: str = ""
# 0=正式消息(默认,受正式消息频控);1=测试消息(仅开发联调,勿用于生产)。
HUAWEI_PUSH_TARGET_USER_TYPE: int = Field(default=0, ge=0, le=1)
HUAWEI_PUSH_TOKEN_ENDPOINT: str = "https://oauth-login.cloud.huawei.com/oauth2/v3/token" HUAWEI_PUSH_TOKEN_ENDPOINT: str = "https://oauth-login.cloud.huawei.com/oauth2/v3/token"
HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE: str = ( HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE: str = (
"https://push-api.cloud.huawei.com/v1/{app_id}/messages:send" "https://push-api.cloud.huawei.com/v1/{app_id}/messages:send"
@@ -150,9 +146,6 @@ class Settings(BaseSettings):
# aliyun:阿里云 dypns 号码认证,阿里云生成+下发+校验(Mode A);缺凭证时 /sms/* 返 503(优雅降级)。 # aliyun:阿里云 dypns 号码认证,阿里云生成+下发+校验(Mode A);缺凭证时 /sms/* 返 503(优雅降级)。
# chuanglan:创蓝云智(253)模板短信,本服务生成码、创蓝只下发、本地校验(Mode B,与极光同);缺凭证 503。 # chuanglan:创蓝云智(253)模板短信,本服务生成码、创蓝只下发、本地校验(Mode B,与极光同);缺凭证 503。
SMS_PROVIDER: Literal["jiguang", "aliyun", "chuanglan"] = "jiguang" SMS_PROVIDER: Literal["jiguang", "aliyun", "chuanglan"] = "jiguang"
# 主 provider 供应商不可用(SmsError 503)时转投的备 provider;空=关闭 fallback(默认,
# 保持现状零风险)。仅 Mode B(jiguang/chuanglan)互为主备有意义;生产建议设 "chuanglan"。
SMS_FALLBACK_PROVIDER: Literal["jiguang", "chuanglan", ""] = ""
ALIYUN_SMS_ACCESS_KEY_ID: str = "" ALIYUN_SMS_ACCESS_KEY_ID: str = ""
ALIYUN_SMS_ACCESS_KEY_SECRET: str = "" ALIYUN_SMS_ACCESS_KEY_SECRET: str = ""
ALIYUN_SMS_SIGN_NAME: str = "" # 系统赠送签名(自定义签名下发易失败) ALIYUN_SMS_SIGN_NAME: str = "" # 系统赠送签名(自定义签名下发易失败)
@@ -196,27 +189,14 @@ class Settings(BaseSettings):
# (real 模式下也跳过校验)、每次登录【强制重走新手引导】,并设【每日使用次数上限】防被人 # (real 模式下也跳过校验)、每次登录【强制重走新手引导】,并设【每日使用次数上限】防被人
# 猜到号后脚本滥用。两个值都能随时改 .env。逻辑全在 app/core/test_account.py,与其他业务解耦。 # 猜到号后脚本滥用。两个值都能随时改 .env。逻辑全在 app/core/test_account.py,与其他业务解耦。
# ⚠️ TEST_ACCOUNT_PHONE 留空 = 整个功能关闭(生产默认安全;要启用才显式填号)。 # ⚠️ TEST_ACCOUNT_PHONE 留空 = 整个功能关闭(生产默认安全;要启用才显式填号)。
TEST_ACCOUNT_PHONE: str = "" # 兼容旧配置:单个测试手机号 TEST_ACCOUNT_PHONE: str = "" # 测试手机号(11 位,如 11111111111);空=关闭整功能
TEST_ACCOUNT_PHONES: str = "" # 多个测试手机号,英文逗号分隔
TEST_ACCOUNT_DAILY_LIMIT: int = 500 # 该测试号每日最多登录次数,当日超过即拒绝登录 TEST_ACCOUNT_DAILY_LIMIT: int = 500 # 该测试号每日最多登录次数,当日超过即拒绝登录
@property @property
def test_account_phone(self) -> str: def test_account_phone(self) -> str:
"""兼容旧调用:规整后的单个测试手机号。""" """规整后的测试手机号(去空白);空串=功能关闭"""
return self.TEST_ACCOUNT_PHONE.strip() return self.TEST_ACCOUNT_PHONE.strip()
@property
def test_account_phones(self) -> frozenset[str]:
"""所有测试手机号;新旧配置取并集,便于线上平滑迁移。"""
phones = {
phone.strip()
for phone in self.TEST_ACCOUNT_PHONES.split(",")
if phone.strip()
}
if self.test_account_phone:
phones.add(self.test_account_phone)
return frozenset(phones)
# ===== 美团联盟 CPS ===== # ===== 美团联盟 CPS =====
# 未配置时所有 /api/v1/meituan/* 接口 200 返空(优雅降级),不影响登录/领券等其他业务。 # 未配置时所有 /api/v1/meituan/* 接口 200 返空(优雅降级),不影响登录/领券等其他业务。
MT_CPS_APP_KEY: str = "" MT_CPS_APP_KEY: str = ""
@@ -456,7 +436,6 @@ class Settings(BaseSettings):
# 运营后台上传的新手引导视频上限。视频比图片大一个量级,单独一档; # 运营后台上传的新手引导视频上限。视频比图片大一个量级,单独一档;
# ⚠️ 改大时同步放宽网关 client_max_body_size(实测 QA 4MiB / prod 32MiB),否则 nginx 先挡下。 # ⚠️ 改大时同步放宽网关 client_max_body_size(实测 QA 4MiB / prod 32MiB),否则 nginx 先挡下。
GUIDE_VIDEO_MAX_BYTES: int = 100 * 1024 * 1024 # 引导视频最大 100MB GUIDE_VIDEO_MAX_BYTES: int = 100 * 1024 * 1024 # 引导视频最大 100MB
FFPROBE_BINARY: str = "ffprobe"
# ===== 邀请好友 ===== # ===== 邀请好友 =====
# 分享落地页(二维码 / 分享链接指向这里;扫码 → 落地页 → 引导浏览器下载 APK)。 # 分享落地页(二维码 / 分享链接指向这里;扫码 → 落地页 → 引导浏览器下载 APK)。
@@ -509,7 +488,7 @@ class Settings(BaseSettings):
return self.APP_ENV == "prod" return self.APP_ENV == "prod"
@model_validator(mode="after") @model_validator(mode="after")
def _enforce_prod_secrets(self) -> Settings: def _enforce_prod_secrets(self) -> "Settings":
"""prod 下强校验 JWT secret,弱/默认/空即启动报错(fail-fast,挡住 token 被伪造)。 """prod 下强校验 JWT secret,弱/默认/空即启动报错(fail-fast,挡住 token 被伪造)。
只校验两个签发凭证:App 用户的 JWT_SECRET_KEY、后台的 ADMIN_JWT_SECRET——它们沿用默认值 只校验两个签发凭证:App 用户的 JWT_SECRET_KEY、后台的 ADMIN_JWT_SECRET——它们沿用默认值
+5 -133
View File
@@ -14,138 +14,12 @@ from app.core import rewards as r
RISK_SMS_HOURLY_THRESHOLD_KEY = "risk_sms_hourly_threshold" RISK_SMS_HOURLY_THRESHOLD_KEY = "risk_sms_hourly_threshold"
RISK_ONECLICK_DAILY_THRESHOLD_KEY = "risk_oneclick_daily_threshold" RISK_ONECLICK_DAILY_THRESHOLD_KEY = "risk_oneclick_daily_threshold"
RISK_COMPARE_DAILY_THRESHOLD_KEY = "risk_compare_daily_threshold" RISK_COMPARE_DAILY_THRESHOLD_KEY = "risk_compare_daily_threshold"
COMPARE_DAILY_LIMIT_KEY = "compare_daily_limit"
SMS_SEND_HOURLY_LIMIT_KEY = "sms_send_hourly_limit"
SMS_SEND_DAILY_LIMIT_KEY = "sms_send_daily_limit"
SMS_LOGIN_HOURLY_LIMIT_KEY = "sms_login_hourly_limit"
WECHAT_BIND_SMS_HOURLY_LIMIT_KEY = "wechat_bind_sms_hourly_limit"
WECHAT_CONFLICT_HOURLY_LIMIT_KEY = "wechat_conflict_hourly_limit"
SMS_PHONE_COOLDOWN_SECONDS_KEY = "sms_phone_cooldown_seconds"
SMS_CODE_MAX_FAILED_ATTEMPTS_KEY = "sms_code_max_failed_attempts"
AD_REWARD_VIDEO_DAILY_LIMIT_KEY = "ad_reward_video_daily_limit"
AD_FEED_DAILY_LIMIT_KEY = "ad_feed_daily_limit"
PHONE_REBIND_DAYS_KEY = "phone_rebind_days"
GUIDE_VIDEO_MAX_PLAYS_KEY = "guide_video_max_plays"
LIMIT_POLICY_GLOBAL_KEY = "limit_policy_global"
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool / enum # type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool / enum
# hidden=True:仍是合法可配项(业务照常 get_value / admin 可经专用端点读写),但**不在通用 # hidden=True:仍是合法可配项(业务照常 get_value / admin 可经专用端点读写),但**不在通用
# 「系统配置」页渲染**(admin/routers/config.py:list_config 按此过滤)。用于把已下线/已改由 # 「系统配置」页渲染**(admin/routers/config.py:list_config 按此过滤)。用于把已下线/已改由
# 专用页管理的项从福利页 Tab 收起,同时保留后端默认值与写入能力。 # 专用页管理的项从福利页 Tab 收起,同时保留后端默认值与写入能力。
CONFIG_DEFS: dict[str, dict[str, Any]] = { CONFIG_DEFS: dict[str, dict[str, Any]] = {
COMPARE_DAILY_LIMIT_KEY: {
"default": 100,
"label": "账号每日比价次数上限",
"group": "限制策略",
"type": "int",
"min": 1,
"max": 100_000,
"hidden": True,
"help": "北京时间自然日内每个账号可发起的比价次数。",
},
SMS_SEND_HOURLY_LIMIT_KEY: {
"default": 5,
"label": "短信每小时发送上限",
"group": "限制策略",
"type": "int",
"min": 1,
"max": 10_000,
"hidden": True,
"help": "同一设备和 IP 在固定 1 小时窗口内成功发送短信的次数。",
},
SMS_SEND_DAILY_LIMIT_KEY: {
"default": 20,
"label": "短信 24 小时发送上限",
"group": "限制策略",
"type": "int",
"min": 1,
"max": 100_000,
"hidden": True,
"help": "同一设备和 IP 在固定 24 小时窗口内成功发送短信的次数。",
},
SMS_LOGIN_HOURLY_LIMIT_KEY: {
"default": 5,
"label": "短信登录每小时尝试上限",
"group": "限制策略",
"type": "int",
"min": 1,
"max": 10_000,
"hidden": True,
"help": "成功与失败均计入。",
},
WECHAT_BIND_SMS_HOURLY_LIMIT_KEY: {
"default": 5,
"label": "微信短信绑定每小时尝试上限",
"group": "限制策略",
"type": "int",
"min": 1,
"max": 10_000,
"hidden": True,
},
WECHAT_CONFLICT_HOURLY_LIMIT_KEY: {
"default": 5,
"label": "微信冲突处理每小时尝试上限",
"group": "限制策略",
"type": "int",
"min": 1,
"max": 10_000,
"hidden": True,
"help": "继续登录与重新绑定共享此额度。",
},
SMS_PHONE_COOLDOWN_SECONDS_KEY: {
"default": 60,
"label": "同手机号短信发送冷却秒数",
"group": "限制策略",
"type": "int",
"min": 0,
"max": 86_400,
"hidden": True,
},
SMS_CODE_MAX_FAILED_ATTEMPTS_KEY: {
"default": 5,
"label": "单验证码最大失败次数",
"group": "限制策略",
"type": "int",
"min": 1,
"max": 100,
"hidden": True,
},
AD_REWARD_VIDEO_DAILY_LIMIT_KEY: {
"default": r.DAILY_AD_REWARD_LIMIT,
"label": "激励视频每日发奖次数",
"group": "限制策略",
"type": "int",
"min": 1,
"max": 100_000,
"hidden": True,
},
AD_FEED_DAILY_LIMIT_KEY: {
"default": r.DAILY_AD_REWARD_LIMIT,
"label": "Draw 信息流每日发奖次数",
"group": "限制策略",
"type": "int",
"min": 1,
"max": 100_000,
"hidden": True,
},
PHONE_REBIND_DAYS_KEY: {
"default": 30,
"label": "手机/微信换绑冷却天数",
"group": "限制策略",
"type": "int",
"min": 0,
"max": 3650,
"hidden": True,
},
GUIDE_VIDEO_MAX_PLAYS_KEY: {
"default": 3,
"label": "领券引导视频最大播放次数",
"group": "限制策略",
"type": "int",
"min": 0,
"max": 50,
"hidden": True,
},
"signin_rewards": { "signin_rewards": {
"default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位", "default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位",
"group": "签到", "type": "int_list", "group": "签到", "type": "int_list",
@@ -182,8 +56,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
}, },
"ad_daily_limit": { "ad_daily_limit": {
"default": r.DAILY_AD_REWARD_LIMIT, "label": "看广告每日上限(次)", "default": r.DAILY_AD_REWARD_LIMIT, "label": "看广告每日上限(次)",
"group": "看广告", "type": "int", "min": 1, "max": 100_000, "hidden": True, "group": "看广告", "type": "int", "help": "福利页激励视频每日可发奖次数上限,默认 500。",
"help": "历史共享上限;新配置由白名单页分别管理激励视频与 Draw 信息流。",
}, },
"ad_max_coin": { "ad_max_coin": {
"default": r.MAX_AD_REWARD_COIN, "label": "看广告单次金币上限", "default": r.MAX_AD_REWARD_COIN, "label": "看广告单次金币上限",
@@ -195,8 +68,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
}, },
"ad_cooldown_sec": { "ad_cooldown_sec": {
"default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)", "default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)",
"group": "看广告", "type": "int", "min": 0, "max": 86_400, "group": "看广告", "type": "int", "help": "点击退出广告后,下次点击观看前的冷却时间,默认 3 秒。",
"help": "点击退出广告后,下次点击观看前的冷却时间,默认 3 秒。",
}, },
"comparing_ad_enabled": { "comparing_ad_enabled": {
"default": True, "label": "比价/领券期信息流广告", "default": True, "label": "比价/领券期信息流广告",
@@ -246,9 +118,9 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
"group": "风控", "group": "风控",
"type": "int", "type": "int",
"min": 1, "min": 1,
"max": 100_000, "max": 5,
"hidden": True, "hidden": True,
"help": "同一设备在北京时间同一自然小时内成功下发短信达到该次数时告警。", "help": "同一设备在北京时间同一自然小时内成功下发短信达到该次数时告警;不得高于现有每小时 5 次的发送上限",
}, },
RISK_ONECLICK_DAILY_THRESHOLD_KEY: { RISK_ONECLICK_DAILY_THRESHOLD_KEY: {
"default": 20, "default": 20,
@@ -266,7 +138,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
"group": "风控", "group": "风控",
"type": "int", "type": "int",
"min": 1, "min": 1,
"max": 100_000, "max": 100,
"hidden": True, "hidden": True,
"help": "同一账户在北京时间同一自然日内发起比价达到该次数时告警。", "help": "同一账户在北京时间同一自然日内发起比价达到该次数时告警。",
}, },
-623
View File
@@ -1,623 +0,0 @@
"""Unified global limits and per-phone/device policy overrides.
The registry is the single source of truth for the whitelist page. Existing
constants remain as backwards-compatible defaults, while business call sites
resolve an effective value here.
"""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import UTC, datetime
from sqlalchemy import delete, or_, select
from sqlalchemy.orm import Session
from app.core.config_schema import (
AD_FEED_DAILY_LIMIT_KEY,
AD_REWARD_VIDEO_DAILY_LIMIT_KEY,
COMPARE_DAILY_LIMIT_KEY,
GUIDE_VIDEO_MAX_PLAYS_KEY,
LIMIT_POLICY_GLOBAL_KEY,
PHONE_REBIND_DAYS_KEY,
RISK_COMPARE_DAILY_THRESHOLD_KEY,
RISK_ONECLICK_DAILY_THRESHOLD_KEY,
RISK_SMS_HOURLY_THRESHOLD_KEY,
SMS_CODE_MAX_FAILED_ATTEMPTS_KEY,
SMS_LOGIN_HOURLY_LIMIT_KEY,
SMS_PHONE_COOLDOWN_SECONDS_KEY,
SMS_SEND_DAILY_LIMIT_KEY,
SMS_SEND_HOURLY_LIMIT_KEY,
WECHAT_BIND_SMS_HOURLY_LIMIT_KEY,
WECHAT_CONFLICT_HOURLY_LIMIT_KEY,
)
from app.models.app_config import AppConfig
from app.models.limit_policy import LimitPolicyOverride
from app.models.user import User
from app.repositories import app_config
MODE_INHERIT = "inherit"
MODE_OVERRIDE = "override"
MODE_UNLIMITED = "unlimited"
MODE_SUPPRESS_ALERT = "suppress_alert"
SUBJECT_PHONE = "phone"
SUBJECT_DEVICE = "device"
SUBJECT_TYPES = (SUBJECT_PHONE, SUBJECT_DEVICE)
SUBJECT_PRECEDENCE = {SUBJECT_DEVICE: 0, SUBJECT_PHONE: 1}
LEGACY_IP_DEVICE_PREFIX = "legacy-ip:"
@dataclass(frozen=True)
class RuleDefinition:
code: str
label: str
group: str
config_key: str
default_limit: int
window_label: str
subject_types: tuple[str, ...]
min_value: int = 1
max_value: int = 100_000
allow_unlimited: bool = True
supports_reset: bool = True
alert_only: bool = False
legacy_config_key: str | None = None
legacy_json_field: str | None = None
@property
def allowed_modes(self) -> tuple[str, ...]:
if self.alert_only:
return (MODE_SUPPRESS_ALERT,)
return (MODE_UNLIMITED,) if self.allow_unlimited else ()
RULES: tuple[RuleDefinition, ...] = (
RuleDefinition(
"compare.start.daily",
"每日发起比价次数",
"比价",
COMPARE_DAILY_LIMIT_KEY,
100,
"北京时间自然日",
SUBJECT_TYPES,
),
RuleDefinition(
"sms.send.hourly",
"短信每小时成功发送次数",
"短信与登录",
SMS_SEND_HOURLY_LIMIT_KEY,
5,
"固定 1 小时窗口",
SUBJECT_TYPES,
max_value=10_000,
),
RuleDefinition(
"sms.send.daily",
"短信 24 小时成功发送次数",
"短信与登录",
SMS_SEND_DAILY_LIMIT_KEY,
20,
"固定 24 小时窗口",
SUBJECT_TYPES,
),
RuleDefinition(
"sms.phone.cooldown",
"同手机号短信发送冷却",
"短信与登录",
SMS_PHONE_COOLDOWN_SECONDS_KEY,
60,
"",
(SUBJECT_PHONE,),
min_value=0,
max_value=86_400,
),
RuleDefinition(
"sms.code.failed_attempts",
"单验证码最大失败次数",
"短信与登录",
SMS_CODE_MAX_FAILED_ATTEMPTS_KEY,
5,
"单个验证码",
(SUBJECT_PHONE,),
max_value=100,
),
RuleDefinition(
"sms.login.hourly",
"短信登录每小时尝试次数",
"短信与登录",
SMS_LOGIN_HOURLY_LIMIT_KEY,
5,
"固定 1 小时窗口",
SUBJECT_TYPES,
max_value=10_000,
),
RuleDefinition(
"wechat.bind.hourly",
"微信短信绑定每小时尝试次数",
"短信与登录",
WECHAT_BIND_SMS_HOURLY_LIMIT_KEY,
5,
"固定 1 小时窗口",
SUBJECT_TYPES,
max_value=10_000,
),
RuleDefinition(
"wechat.conflict.hourly",
"微信冲突处理每小时尝试次数",
"短信与登录",
WECHAT_CONFLICT_HOURLY_LIMIT_KEY,
5,
"固定 1 小时窗口",
SUBJECT_TYPES,
max_value=10_000,
),
RuleDefinition(
"ad.reward_video.daily",
"激励视频每日发奖次数",
"广告",
AD_REWARD_VIDEO_DAILY_LIMIT_KEY,
500,
"北京时间自然日",
(SUBJECT_PHONE,),
legacy_config_key="ad_daily_limit",
),
RuleDefinition(
"ad.feed.daily",
"Draw 信息流每日发奖次数",
"广告",
AD_FEED_DAILY_LIMIT_KEY,
500,
"北京时间自然日",
(SUBJECT_PHONE,),
legacy_config_key="ad_daily_limit",
),
RuleDefinition(
"ad.reward_video.cooldown",
"激励视频发奖后冷却秒数",
"广告",
"ad_cooldown_sec",
3,
"",
(SUBJECT_PHONE,),
min_value=0,
max_value=86_400,
),
RuleDefinition(
"guide.video.lifetime",
"领券引导视频最大播放次数",
"引导与账号",
GUIDE_VIDEO_MAX_PLAYS_KEY,
3,
"账号生命周期",
(SUBJECT_PHONE,),
min_value=0,
max_value=50,
legacy_config_key="coupon_guide_video",
legacy_json_field="max_plays",
),
RuleDefinition(
"phone.rebind.days",
"手机/微信换绑冷却天数",
"引导与账号",
PHONE_REBIND_DAYS_KEY,
30,
"自然日",
(SUBJECT_PHONE,),
min_value=0,
max_value=3650,
),
RuleDefinition(
"risk.sms.hourly",
"短信设备每小时告警",
"风控免告警",
RISK_SMS_HOURLY_THRESHOLD_KEY,
5,
"北京时间自然小时",
(SUBJECT_DEVICE,),
max_value=100_000,
allow_unlimited=False,
alert_only=True,
),
RuleDefinition(
"risk.oneclick.daily",
"一键登录设备每日告警",
"风控免告警",
RISK_ONECLICK_DAILY_THRESHOLD_KEY,
20,
"北京时间自然日",
(SUBJECT_DEVICE,),
max_value=100_000,
allow_unlimited=False,
alert_only=True,
),
RuleDefinition(
"risk.compare.daily",
"比价账号每日告警",
"风控免告警",
RISK_COMPARE_DAILY_THRESHOLD_KEY,
100,
"北京时间自然日",
(SUBJECT_PHONE,),
max_value=100_000,
allow_unlimited=False,
alert_only=True,
),
)
RULE_MAP = {rule.code: rule for rule in RULES}
RULE_CODE_BY_CONFIG_KEY = {rule.config_key: rule.code for rule in RULES}
LIMIT_CONFIG_KEYS = tuple(RULE_CODE_BY_CONFIG_KEY)
@dataclass(frozen=True)
class EffectiveLimit:
rule_code: str
global_limit: int
limit: int | None
mode: str
suppressed: bool
override_id: int | None
matched_subject_type: str | None
matched_subject_value: str | None
reset_at: datetime | None
bucket_version: str
@property
def unlimited(self) -> bool:
return self.limit is None
def normalize_subject(subject_type: str, value: str) -> str:
value = (value or "").strip()
if subject_type == SUBJECT_PHONE:
value = "".join(ch for ch in value if ch.isdigit())
if subject_type not in SUBJECT_TYPES:
raise ValueError(f"unsupported subject type: {subject_type}")
if not value:
raise ValueError("subject value is empty")
return value[:128]
def validate_whitelist_subject(subject_type: str, value: str) -> str:
"""Normalize a whitelist subject and reject unsafe pseudo-devices.
Old clients without a device ID are grouped by public IP for rate limiting.
That fallback remains valid at runtime, but it is not a stable, unique
device identity and must never be persisted as a device whitelist target.
"""
normalized = normalize_subject(subject_type, value)
if (
subject_type == SUBJECT_DEVICE
and normalized.startswith(LEGACY_IP_DEVICE_PREFIX)
):
raise ValueError(
"旧客户端未上报真实设备 ID,不能加入设备白名单,请升级客户端后重试"
)
return normalized
def get_rule(rule_code: str) -> RuleDefinition:
try:
return RULE_MAP[rule_code]
except KeyError as exc:
raise ValueError(f"unknown rule: {rule_code}") from exc
def device_source_scope(rule_code: str) -> str:
"""返回设备候选数据所属命名空间,防止一个设备 ID 跨来源误套规则。"""
rule = get_rule(rule_code)
if SUBJECT_DEVICE not in rule.subject_types:
raise ValueError("当前限制项不支持设备白名单")
return "comparison" if rule_code == "compare.start.daily" else "auth"
def default_global_limits() -> dict[str, int]:
"""Return the complete 16-rule default snapshot keyed by rule code."""
return {rule.code: rule.default_limit for rule in RULES}
def _normalise_global_limits(value: object) -> dict[str, int]:
"""Merge a stored JSON object with safe code defaults.
The migration and every admin write persist all rules. Defaults are still
merged here so a manually damaged/older partial JSON cannot take the
service down after deployment.
"""
values = default_global_limits()
if not isinstance(value, dict):
return values
for rule_code, raw in value.items():
rule = RULE_MAP.get(str(rule_code))
if rule is None or isinstance(raw, bool):
continue
try:
parsed = int(raw)
except (TypeError, ValueError):
continue
if rule.min_value <= parsed <= rule.max_value:
values[rule.code] = parsed
return values
def _legacy_global_limit(db: Session, rule: RuleDefinition) -> tuple[int, str]:
"""Read the pre-bundle representation while upgrading old/test databases."""
row = db.get(AppConfig, rule.config_key)
if row is not None:
return int(row.value), "legacy-key"
if rule.legacy_config_key:
legacy = db.get(AppConfig, rule.legacy_config_key)
if legacy is not None:
value = legacy.value
if rule.legacy_json_field:
value = value.get(rule.legacy_json_field) if isinstance(value, dict) else None
if value is not None:
return int(value), "legacy"
try:
return int(app_config.get_value(db, rule.config_key)), "default"
except KeyError:
return rule.default_limit, "default"
def get_global_limits(db: Session) -> dict[str, int]:
"""Read the complete global-limit JSON, with a pre-migration fallback."""
row = db.get(AppConfig, LIMIT_POLICY_GLOBAL_KEY)
if row is not None:
return _normalise_global_limits(row.value)
return {rule.code: _legacy_global_limit(db, rule)[0] for rule in RULES}
def set_global_limits(
db: Session,
updates: dict[str, int],
*,
admin_id: int,
commit: bool = True,
) -> dict[str, int]:
"""Atomically update selected rules inside the single complete JSON row."""
parsed_updates: dict[str, int] = {}
for rule_code, raw_value in updates.items():
rule = get_rule(rule_code)
value = int(raw_value)
if not rule.min_value <= value <= rule.max_value:
raise ValueError(
f"limit for {rule_code} must be between "
f"{rule.min_value} and {rule.max_value}"
)
parsed_updates[rule.code] = value
row = db.scalar(
select(AppConfig)
.where(AppConfig.key == LIMIT_POLICY_GLOBAL_KEY)
.with_for_update()
)
values = (
_normalise_global_limits(row.value)
if row is not None
else {rule.code: _legacy_global_limit(db, rule)[0] for rule in RULES}
)
values.update(parsed_updates)
if row is None:
row = AppConfig(
key=LIMIT_POLICY_GLOBAL_KEY,
value=values,
updated_by_admin_id=admin_id,
)
db.add(row)
else:
row.value = dict(values)
row.updated_by_admin_id = admin_id
# Once the bundle exists, stale sparse rows must not become a second source
# of truth. The data migration performs the same cleanup for production.
db.execute(delete(AppConfig).where(AppConfig.key.in_(LIMIT_CONFIG_KEYS)))
if commit:
db.commit()
db.refresh(row)
else:
db.flush()
return values
def _global_limit(db: Session, rule: RuleDefinition) -> tuple[int, str]:
row = db.get(AppConfig, LIMIT_POLICY_GLOBAL_KEY)
if row is not None:
return _normalise_global_limits(row.value)[rule.code], "configured"
return _legacy_global_limit(db, rule)
def _aware(value: datetime | None) -> datetime | None:
if value is None:
return None
return value.replace(tzinfo=UTC) if value.tzinfo is None else value
def _matching_overrides(
db: Session,
rule: RuleDefinition,
subjects: dict[str, str | None],
now: datetime,
) -> list[LimitPolicyOverride]:
pairs: list[tuple[str, str]] = []
for subject_type in rule.subject_types:
raw = subjects.get(subject_type)
if raw:
pairs.append((subject_type, normalize_subject(subject_type, raw)))
if not pairs:
return []
clauses = [
(
(LimitPolicyOverride.subject_type == subject_type)
& (LimitPolicyOverride.subject_value == subject_value)
)
for subject_type, subject_value in pairs
]
rows = list(
db.execute(
select(LimitPolicyOverride).where(
LimitPolicyOverride.rule_code == rule.code,
LimitPolicyOverride.enabled.is_(True),
or_(*clauses),
)
).scalars()
)
active = [
row
for row in rows
if (_aware(row.starts_at) is None or _aware(row.starts_at) <= now)
and (_aware(row.expires_at) is None or _aware(row.expires_at) > now)
]
return sorted(active, key=lambda row: SUBJECT_PRECEDENCE[row.subject_type])
def resolve(
db: Session,
rule_code: str,
*,
phone: str | None = None,
device: str | None = None,
now: datetime | None = None,
) -> EffectiveLimit:
"""Resolve global config plus the most specific active override.
Device overrides win over phone overrides when both match.
"""
rule = get_rule(rule_code)
now = _aware(now) or datetime.now(UTC)
global_limit, global_version = _global_limit(db, rule)
matches = _matching_overrides(
db, rule, {SUBJECT_PHONE: phone, SUBJECT_DEVICE: device}, now
)
row = matches[0] if matches else None
if row is None:
return EffectiveLimit(
rule.code,
global_limit,
global_limit,
MODE_INHERIT,
False,
None,
None,
None,
None,
global_version,
)
limit: int | None = global_limit
suppressed = False
if row.mode == MODE_OVERRIDE:
limit = int(row.limit_value) if row.limit_value is not None else global_limit
elif row.mode == MODE_UNLIMITED:
limit = None
elif row.mode == MODE_SUPPRESS_ALERT:
suppressed = True
# 编辑限制值/备注不应隐式清空计数;只有显式“重置状态”才切换桶。
reset_version = _aware(row.reset_at)
version = (
f"{global_version}:o:{row.id}:"
f"{reset_version.isoformat() if reset_version else '0'}"
)
return EffectiveLimit(
rule.code,
global_limit,
limit,
row.mode,
suppressed,
row.id,
row.subject_type,
row.subject_value,
_aware(row.reset_at),
version,
)
def resolve_for_user(
db: Session,
rule_code: str,
user_id: int,
*,
device: str | None = None,
) -> EffectiveLimit:
user = db.get(User, user_id)
return resolve(
db,
rule_code,
phone=user.phone if user is not None else None,
device=device,
)
def rule_catalog(db: Session) -> list[dict]:
out: list[dict] = []
values = get_global_limits(db)
for rule in RULES:
out.append(
{
"code": rule.code,
"label": rule.label,
"group": rule.group,
"global_limit": values[rule.code],
"default_limit": rule.default_limit,
"window_label": rule.window_label,
"subject_types": list(rule.subject_types),
"allowed_modes": list(rule.allowed_modes),
"min_value": rule.min_value,
"max_value": rule.max_value,
"supports_reset": rule.supports_reset,
"alert_only": rule.alert_only,
}
)
return out
def validate_override(
rule: RuleDefinition,
*,
subject_type: str,
mode: str,
limit_value: int | None,
starts_at: datetime | None,
expires_at: datetime | None,
) -> None:
if subject_type not in rule.subject_types:
raise ValueError("该规则不支持此主体类型")
if mode not in rule.allowed_modes:
raise ValueError("该规则不支持此策略模式")
if limit_value is not None:
raise ValueError("白名单不支持覆盖指定值")
if mode in {MODE_UNLIMITED, MODE_SUPPRESS_ALERT} and expires_at is None:
raise ValueError("临时白名单必须设置失效时间")
if starts_at and expires_at and _aware(expires_at) <= _aware(starts_at):
raise ValueError("失效时间必须晚于生效时间")
if (
mode in {MODE_UNLIMITED, MODE_SUPPRESS_ALERT}
and expires_at is not None
and _aware(expires_at) <= datetime.now(UTC)
):
raise ValueError("临时白名单的失效时间必须晚于当前时间")
if mode == MODE_SUPPRESS_ALERT and not rule.alert_only:
raise ValueError("免告警只支持风控监控的三项规则")
def active_overrides(
db: Session,
*,
subject_type: str | None = None,
keyword: str | None = None,
) -> Iterable[LimitPolicyOverride]:
stmt = select(LimitPolicyOverride)
if subject_type:
stmt = stmt.where(LimitPolicyOverride.subject_type == subject_type)
if keyword:
stmt = stmt.where(LimitPolicyOverride.subject_value.ilike(f"%{keyword.strip()}%"))
return db.execute(
stmt.order_by(LimitPolicyOverride.updated_at.desc(), LimitPolicyOverride.id.desc())
).scalars()
+9 -76
View File
@@ -10,13 +10,8 @@
""" """
from __future__ import annotations from __future__ import annotations
import json
import os
import secrets import secrets
import subprocess
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from pathlib import Path from pathlib import Path
from typing import Any
from app.core.config import settings from app.core.config import settings
@@ -86,91 +81,29 @@ def _sniff_video_ext(data: bytes) -> str | None:
"""按魔数判定视频类型,返回扩展名;非支持类型返回 None。 """按魔数判定视频类型,返回扩展名;非支持类型返回 None。
只认 MP4 家族(ISO BMFF):`....ftyp` 在偏移 4。Android ExoPlayer 与浏览器 <video> 只认 MP4 家族(ISO BMFF):`....ftyp` 在偏移 4。Android ExoPlayer 与浏览器 <video>
支持 H.264 或 HEVC/H.265 视频及 AAC 音频;放开 mkv/avi 只会让端上放不出来, 都稳吃 H.264/AAC 的 mp4;放开 mkv/avi 只会让端上放不出来,不如在入口就挡掉。
不如在入口就挡掉。
""" """
if len(data) >= 12 and data[4:8] == b"ftyp": if len(data) >= 12 and data[4:8] == b"ftyp":
return ".mp4" return ".mp4"
return None return None
def _probe_guide_video(path: Path) -> dict[str, Any]: def save_guide_video(data: bytes) -> str:
"""用 ffprobe 读取服务端可信的时长/编码信息。""" """保存新手引导视频(运营后台上传的运营素材),返回相对 URL(`/media/guide_video/<file>`)。
try:
proc = subprocess.run(
[
settings.FFPROBE_BINARY,
"-v", "error",
"-show_format",
"-show_streams",
"-of", "json",
str(path),
],
capture_output=True,
text=True,
timeout=30,
check=False,
)
except FileNotFoundError as exc:
raise MediaError("服务器未安装 ffprobe,暂时无法分析视频") from exc
except (OSError, subprocess.TimeoutExpired) as exc:
raise MediaError("ffprobe 分析视频失败") from exc
if proc.returncode != 0:
raise MediaError("无法分析视频,请确认文件是有效的 MP4")
try:
payload = json.loads(proc.stdout)
streams = payload.get("streams") or []
video = next(s for s in streams if s.get("codec_type") == "video")
audio = next((s for s in streams if s.get("codec_type") == "audio"), None)
raw_duration = (
(payload.get("format") or {}).get("duration")
or video.get("duration")
)
duration_ms = int(
(Decimal(str(raw_duration)) * 1000).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
)
except (StopIteration, TypeError, ValueError, InvalidOperation) as exc:
raise MediaError("视频缺少可识别的视频轨或时长") from exc
video_codec = str(video.get("codec_name") or "").lower()
audio_codec = str(audio.get("codec_name") or "").lower() if audio else None
if not 30_000 <= duration_ms <= 180_000:
raise MediaError("视频时长必须在 30180 秒之间")
if video_codec not in ("h264", "hevc", "h265"):
raise MediaError("视频编码必须为 H.264 或 HEVC/H.265")
if audio_codec not in (None, "aac"):
raise MediaError("音频编码必须为 AAC")
return {
"duration_ms": duration_ms,
"video_codec": video_codec,
"audio_codec": audio_codec,
"analysis_status": "valid",
"analysis_error": None,
}
与图片分开一套校验:体积上限走 [settings.GUIDE_VIDEO_MAX_BYTES],类型只认 MP4。
def save_guide_video(data: bytes) -> tuple[str, dict[str, Any]]: """
"""临时落盘、ffprobe 校验后原子发布,返回 URL 与分析结果。"""
if not data: if not data:
raise MediaError("空文件") raise MediaError("空文件")
limit = settings.GUIDE_VIDEO_MAX_BYTES limit = settings.GUIDE_VIDEO_MAX_BYTES
if len(data) > limit: if len(data) > limit:
raise MediaError(f"视频过大(上限 {limit // (1024 * 1024)}MB)") raise MediaError(f"视频过大(上限 {limit // (1024 * 1024)}MB)")
if _sniff_video_ext(data) is None: if _sniff_video_ext(data) is None:
raise MediaError("仅支持 MP4 视频(H.264 或 HEVC/H.265 编码)") raise MediaError("仅支持 MP4 视频(H.264 编码)")
directory = _media_dir("guide_video") fname = f"guide_{secrets.token_hex(8)}.mp4"
stem = f"guide_{secrets.token_hex(8)}" (_media_dir("guide_video") / fname).write_bytes(data)
temp_path = directory / f".{stem}.upload" return f"{settings.MEDIA_URL_PREFIX}/guide_video/{fname}"
final_path = directory / f"{stem}.mp4"
try:
temp_path.write_bytes(data)
analysis = _probe_guide_video(temp_path)
os.replace(temp_path, final_path)
except Exception:
temp_path.unlink(missing_ok=True)
final_path.unlink(missing_ok=True)
raise
return f"{settings.MEDIA_URL_PREFIX}/guide_video/{final_path.name}", analysis
def save_cps_image(admin_id: int, data: bytes) -> str: def save_cps_image(admin_id: int, data: bytes) -> str:
+6 -19
View File
@@ -75,11 +75,10 @@ def enforce_rate_limit(
request: Request, request: Request,
scope: str, scope: str,
subject: str, subject: str,
limit: int | None, limit: int,
window_sec: float, window_sec: float,
*, *,
detail: str = "操作过于频繁,请稍后再试", detail: str = "操作过于频繁,请稍后再试",
bucket_suffix: str = "",
) -> None: ) -> None:
"""在路由内部手动限流,按 (subject, 客户端 IP) 计数。 """在路由内部手动限流,按 (subject, 客户端 IP) 计数。
@@ -88,9 +87,9 @@ def enforce_rate_limit(
key = `scope:subject:client_ip`;同一 (subject, IP) 在 window_sec 内超过 limit 次 → 抛 429。 key = `scope:subject:client_ip`;同一 (subject, IP) 在 window_sec 内超过 limit 次 → 抛 429。
受 [settings.RATE_LIMIT_ENABLED] 总开关控制(与 [rate_limit] 一致)。 受 [settings.RATE_LIMIT_ENABLED] 总开关控制(与 [rate_limit] 一致)。
""" """
if not settings.RATE_LIMIT_ENABLED or limit is None: if not settings.RATE_LIMIT_ENABLED:
return return
key = f"{scope}:{subject}:{_client_ip(request)}:{bucket_suffix}" key = f"{scope}:{subject}:{_client_ip(request)}"
if not _hit(key, limit, window_sec): if not _hit(key, limit, window_sec):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
@@ -113,10 +112,9 @@ class RateLimitRule(NamedTuple):
""" """
scope: str scope: str
limit: int | None limit: int
window_sec: float window_sec: float
detail: str = "操作过于频繁,请稍后再试" detail: str = "操作过于频繁,请稍后再试"
bucket_suffix: str = ""
def _peek(key: str, limit: int, window_sec: float) -> bool: def _peek(key: str, limit: int, window_sec: float) -> bool:
@@ -152,13 +150,7 @@ def check_rate_limits(request: Request, subject: str, rules: list[RateLimitRule]
return return
ip = _client_ip(request) ip = _client_ip(request)
for rule in rules: for rule in rules:
if rule.limit is None: if not _peek(f"{rule.scope}:{subject}:{ip}", rule.limit, rule.window_sec):
continue
if not _peek(
f"{rule.scope}:{subject}:{ip}:{rule.bucket_suffix}",
rule.limit,
rule.window_sec,
):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=rule.detail, detail=rule.detail,
@@ -175,9 +167,4 @@ def record_rate_limits(request: Request, subject: str, rules: list[RateLimitRule
return return
ip = _client_ip(request) ip = _client_ip(request)
for rule in rules: for rule in rules:
if rule.limit is None: _commit(f"{rule.scope}:{subject}:{ip}", rule.window_sec)
continue
_commit(
f"{rule.scope}:{subject}:{ip}:{rule.bucket_suffix}",
rule.window_sec,
)
+1 -11
View File
@@ -233,20 +233,10 @@ def calculate_ad_reward_coin(ecpm: str | int | float | None, count_after_this: i
eCPM 在此先钳到 AD_ECPM_MAX_FEN(¥500 CPM):信息流广告一期 eCPM 由客户端上报,伪造天价值 eCPM 在此先钳到 AD_ECPM_MAX_FEN(¥500 CPM):信息流广告一期 eCPM 由客户端上报,伪造天价值
会铸天量金币;钳在这唯一入口,feed reward_video 回退客户端 eCPM 的路径都护住,且阈值高于 会铸天量金币;钳在这唯一入口,feed reward_video 回退客户端 eCPM 的路径都护住,且阈值高于
所有真实值,不影响正规发奖 所有真实值,不影响正规发奖
下限(2026-08,产品口径看了就保底 1):**任何输入**都至少 1 金币,与前端展示公式
FeedRewardFormula.singleUnitCoin 完全对齐(那边注释:"无论 eCPM 是否为空、非法或非正数,
单条广告最低都发 1 金币,不能出现 +0")。此前 eCPM 缺失/为 0 返 0,造成两端不一致:
小球显示 +1后端信息流记 too_short 零发;激励视频侧 "0" 字符串还是 truthy绕过
ecpm_missing `if not ecpm_raw` 判定,落成 granted 0 币且白占当日额度/LT 计数
防刷影响:伪造 eCPM0 每天至多多骗 每日上限×1 金币(500 金币=0.05 ),量级可控;
天价伪造仍由 AD_ECPM_MAX_FEN 钳顶把守
""" """
ecpm_yuan = min(parse_ecpm_yuan(ecpm), AD_ECPM_MAX_FEN / 100.0) ecpm_yuan = min(parse_ecpm_yuan(ecpm), AD_ECPM_MAX_FEN / 100.0)
if ecpm_yuan <= 0:
return 1 # 保底:缺失/为 0/非法也发 1(镜像前端 validEcpmFen 判非法 → 直接返 1)
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(count_after_this) yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(count_after_this)
return max(1, round(yuan * COIN_PER_YUAN)) return max(0, round(yuan * COIN_PER_YUAN))
# 签到膨胀(SIGNIN_BOOST_COIN,固定 3000)已于 2026-07 下线:它与广告实际收益脱钩,产品确认 # 签到膨胀(SIGNIN_BOOST_COIN,固定 3000)已于 2026-07 下线:它与广告实际收益脱钩,产品确认
+16 -15
View File
@@ -9,8 +9,9 @@
3. **每日使用次数上限**:防被人猜到这个号后写脚本一直刷当天登录数超过上限即拒绝(429), 3. **每日使用次数上限**:防被人猜到这个号后写脚本一直刷当天登录数超过上限即拒绝(429),
次日自动归零 次日自动归零
手机号与上限都在 .env (`TEST_ACCOUNT_PHONES` / `TEST_ACCOUNT_DAILY_LIMIT`),随时可改 手机号与上限都在 .env (`TEST_ACCOUNT_PHONE` / `TEST_ACCOUNT_DAILY_LIMIT`),随时可改
兼容旧的单号配置 `TEST_ACCOUNT_PHONE`;两项都留空 = 整个功能关闭(生产默认态) `TEST_ACCOUNT_PHONE` 留空 = 整个功能关闭(生产默认态),`is_test_account()` 对任何号都返回
False,登录/短信回到原逻辑,零影响
计数存**进程内存**( worker 够用, sms.py 同款约定):重启清零 worker 不共享作为 计数存**进程内存**( worker 够用, sms.py 同款约定):重启清零 worker 不共享作为
一个测试号的粗粒度防滥用闸够用;且因所有登录都落同一个 phone 同一个 user,滥用面天然只 一个测试号的粗粒度防滥用闸够用;且因所有登录都落同一个 phone 同一个 user,滥用面天然只
@@ -26,9 +27,9 @@ from app.core.config import settings
logger = logging.getLogger("shagua.test_account") logger = logging.getLogger("shagua.test_account")
# 进程内每日计数:{手机号: (date_str, 当日已登录次数)}。单 worker 有效,重启清零。 # 进程内每日计数:(date_str, 当日已登录次数)。单 worker 有效,重启清零(见模块 docstring)
_lock = Lock() _lock = Lock()
_usage: dict[str, tuple[str, int]] = {} _usage: tuple[str, int] = ("", 0)
def _today() -> str: def _today() -> str:
@@ -36,16 +37,16 @@ def _today() -> str:
def is_enabled() -> bool: def is_enabled() -> bool:
"""功能总开关:至少配置了一个测试手机号才启用""" """功能总开关:配了 TEST_ACCOUNT_PHONE 才启用(空=关闭)"""
return bool(settings.test_account_phones) return bool(settings.test_account_phone)
def is_test_account(phone: str) -> bool: def is_test_account(phone: str) -> bool:
"""该手机号是否在测试账号集合中""" """该手机号是否为配置的测试账号。功能关闭时对任何号都返回 False"""
return phone in settings.test_account_phones return is_enabled() and phone == settings.test_account_phone
def try_consume_quota(phone: str) -> bool: def try_consume_quota() -> bool:
"""测试账号登录时调:当日计数 +1。 """测试账号登录时调:当日计数 +1。
Returns: Returns:
@@ -58,17 +59,17 @@ def try_consume_quota(phone: str) -> bool:
limit = settings.TEST_ACCOUNT_DAILY_LIMIT limit = settings.TEST_ACCOUNT_DAILY_LIMIT
with _lock: with _lock:
today = _today() today = _today()
day, cnt = _usage.get(phone, ("", 0)) day, cnt = _usage
if day != today: # 跨天归零 if day != today: # 跨天归零
cnt = 0 cnt = 0
if cnt >= limit: if cnt >= limit:
_usage[phone] = (today, cnt) # 已满,保持不变 _usage = (today, cnt) # 已满,保持不变
logger.warning( logger.warning(
"测试账号 %s 今日登录数已达上限 %d,拒绝", phone, limit "测试账号 %s 今日登录数已达上限 %d,拒绝", settings.test_account_phone, limit
) )
return False return False
_usage[phone] = (today, cnt + 1) _usage = (today, cnt + 1)
logger.info("测试账号 %s%d/%d 次登录", phone, cnt + 1, limit) logger.info("测试账号 %s%d/%d 次登录", settings.test_account_phone, cnt + 1, limit)
return True return True
@@ -76,4 +77,4 @@ def _reset_for_test() -> None:
"""仅供单测:清空进程内计数,隔离用例间状态。""" """仅供单测:清空进程内计数,隔离用例间状态。"""
global _usage global _usage
with _lock: with _lock:
_usage = {} _usage = ("", 0)
-29
View File
@@ -1,29 +0,0 @@
"""trace_id 签发(全后端唯一签发口径, 2026-07 起替代裸 uuid4)。
格式: "YYYYMMDD_HHMMSS_" + 12 位小写 hex 随机, 28 字符,
20260731_162254_a1b2c3d4e5f6
Why 带时间前缀: pricebot 落盘目录名/trace_url 尾段**直接用 trace_id 本身**
( pricebot app/utils/trace_ids.py), id/目录/URL 三者合一此前 uuid trace_id
{首帧时刻}_{uuid[:16]} 目录名是两套标识, URL 只有 pricebot 能拼按前缀反查
还有同秒歧义时间用北京时间(CN_TZ)不依赖各机器 TZ 配置, 与业务时区一致
唯一性: 秒级前缀 + 48bit 随机(hex12), 同一秒内碰撞概率可忽略(比价/领券发起 QPS
远低于产生生日碰撞的量级); pricebot 侧同秒多 trace 靠随机段区分(目录精确匹配
llm jsonl 按尾 12 分文件, 不做前缀模糊匹配)
兼容: 三个签发点(compare/startcoupon/session startedcompare.py _forward mint)
统一走这里; 客户端自带 trace_id(老客户端/重试幂等)仍原样沿用pricebot 对老
uuid 格式保持既有目录/短标识行为, 两代 id 并行不冲突
"""
from __future__ import annotations
import uuid
from datetime import datetime
from app.core.rewards import CN_TZ
def new_trace_id() -> str:
"""签发一个自描述 trace_id: 北京时间前缀 + 12 位 hex 随机。"""
return f"{datetime.now(CN_TZ):%Y%m%d_%H%M%S}_{uuid.uuid4().hex[:12]}"
+22 -81
View File
@@ -1,96 +1,37 @@
"""短信验证码服务 —— provider 分派入口(极光主 + 可选创蓝备) """短信验证码服务 —— provider 分派入口。
对外暴露 `send_code`返回 SendResult / `verify_code` / `SmsError` / `SendResult` 对外暴露 `send_code` / `verify_code` / `SmsError`,api 层无需关心用哪个 provider
provider = settings.SMS_PROVIDER默认 jiguang = settings.SMS_FALLBACK_PROVIDER provider `settings.SMS_PROVIDER` 选择(**每次调用读取**,支持运行时切换 + 灰度回退):
默认空=无备**每次调用读 settings**支持运行时切换 / 灰度回退 - `jiguang`(默认):自管码(本服务生成内存存/校验,极光只发) [jiguang.py](jiguang.py)
- `aliyun`:阿里云号码认证(阿里云生成+下发+校验,Mode A) [aliyun.py](aliyun.py)
- `chuanglan`:创蓝云智(253)模板短信,自管码 Mode B(本服务生成内存存/校验,创蓝只发) [chuanglan.py](chuanglan.py)
mock(`SMS_MOCK=true`)与各 provider 的行为差异都封在 provider 内部;本层只做路由
拆包前本模块是单文件 `sms.py`;拆包后极光逻辑迁入 `jiguang` 子模块,行为零改动
""" """
from __future__ import annotations from __future__ import annotations
import logging
from app.core.config import settings from app.core.config import settings
from . import aliyun, chuanglan, jiguang from . import aliyun, chuanglan, jiguang
from .base import SendResult, SmsError from .base import SmsError
__all__ = ["SmsError", "SendResult", "send_code", "verify_code"] __all__ = ["SmsError", "send_code", "verify_code"]
logger = logging.getLogger("shagua.sms") # provider 名 -> 模块;未知/缺省值回退 jiguang(默认兜底,防误配把登录打挂)。
_PROVIDERS = {"aliyun": aliyun, "chuanglan": chuanglan}
# provider 名 <-> 模块。未知/缺省主 provider 回退 jiguang(防误配把登录打挂)。
_ALL = {"jiguang": jiguang, "aliyun": aliyun, "chuanglan": chuanglan}
_NAME = {jiguang: "jiguang", aliyun: "aliyun", chuanglan: "chuanglan"}
def _primary(): def _provider():
"""当前主 provider 模块(未知/缺省回退 jiguang""" """按配置选 provider 模块(每次调用读 settings,支持运行时切换 / 测试注入)"""
return _ALL.get(settings.SMS_PROVIDER, jiguang) return _PROVIDERS.get(settings.SMS_PROVIDER, jiguang)
def _fallback(): def send_code(phone: str) -> int:
"""启用的备 provider 模块;未配置 / 同主 / 未知名 → None= 不启用 fallback""" """发送验证码,返回距下次可发的冷却秒数;失败抛 SmsError。委托给当前 provider"""
name = (settings.SMS_FALLBACK_PROVIDER or "").strip() return _provider().send_code(phone)
if not name or name == settings.SMS_PROVIDER:
return None
return _ALL.get(name)
def _send_with_cooldown(provider, phone: str, cooldown_sec: int | None) -> int: def verify_code(phone: str, code: str) -> bool:
if cooldown_sec is None: """校验验证码,返回是否通过;provider 异常降级抛 SmsError。委托给当前 provider。"""
return provider.send_code(phone) return _provider().verify_code(phone, code)
return provider.send_code(phone, cooldown_sec=cooldown_sec)
def send_code(
phone: str,
*,
cooldown_sec: int | None = None,
) -> SendResult:
"""发码:主成功即返回;仅主「供应商不可用(503)」且配置了备时转备补发。
429本地冷却/超频400手机号无效不转不绕过防刷不为无效号白烧
备也失败则抛备的 SmsError调用方传入的动态冷却值在主备渠道保持一致
"""
primary = _primary()
fb = _fallback()
try:
cooldown = _send_with_cooldown(primary, phone, cooldown_sec)
return SendResult(cooldown_sec=cooldown, provider=_NAME[primary], fallback=False)
except SmsError as e:
if fb is not None and e.status_code == 503:
logger.warning("[SMS] primary=%s 不可用(%s)fallback→%s",
_NAME[primary], e, _NAME[fb])
cooldown = _send_with_cooldown(fb, phone, cooldown_sec)
return SendResult(cooldown_sec=cooldown, provider=_NAME[fb], fallback=True)
raise
def verify_code(
phone: str,
code: str,
*,
max_failed_attempts: int | None = None,
) -> bool:
"""校验:try-both,遍历「启用的 fallback 链」(主→备),任一命中即 True。
码只存在实际发码那家fallback 前主已 pop 掉自己的码另一家 rec is None False
不误判不累加其防爆破计数动态失败次数上限会一致传给主备渠道
"""
chain = [_primary()]
fb = _fallback()
if fb is not None:
chain.append(fb)
for prov in chain:
verified = (
prov.verify_code(phone, code)
if max_failed_attempts is None
else prov.verify_code(
phone,
code,
max_failed_attempts=max_failed_attempts,
)
)
if verified:
logger.info("[SMS] verify hit provider=%s", _NAME[prov])
return True
return False
+9 -30
View File
@@ -35,7 +35,7 @@ _GC_THRESHOLD = 10000 # 超此阈值,send 时顺手清老于
# 发码错误码 → (HTTP 码, 用户提示)。未列出的一律 503(供应商不可用)。 # 发码错误码 → (HTTP 码, 用户提示)。未列出的一律 503(供应商不可用)。
_SEND_ERRORS: dict[str, tuple[int, str]] = { _SEND_ERRORS: dict[str, tuple[int, str]] = {
"MOBILE_NUMBER_ILLEGAL": (400, "请输入有效的手机号"), "MOBILE_NUMBER_ILLEGAL": (400, "手机号无效"),
"BUSINESS_LIMIT_CONTROL": (429, "今日发送次数过多,请明天再试"), "BUSINESS_LIMIT_CONTROL": (429, "今日发送次数过多,请明天再试"),
"FREQUENCY_FAIL": (429, "发送过于频繁,请稍后再试"), "FREQUENCY_FAIL": (429, "发送过于频繁,请稍后再试"),
} }
@@ -47,26 +47,19 @@ _client = None # 惰性构建的 SDK client(模块级缓存)
# ============================ 对外:发码 / 校验 ============================ # ============================ 对外:发码 / 校验 ============================
def send_code(phone: str, *, cooldown_sec: int | None = None) -> int: def send_code(phone: str) -> int:
"""发送验证码(阿里云生成+下发)。 """发送验证码(阿里云生成+下发)。
Returns: 距下次可发的秒数(= ALIYUN_SMS_INTERVAL_SEC,冷却由阿里云 Interval 侧执行) Returns: 距下次可发的秒数(= ALIYUN_SMS_INTERVAL_SEC,冷却由阿里云 Interval 侧执行)
Raises: SmsError(手机号无效 400 / 过频·天级流控 429 / 未配置·未开通·其他 503) Raises: SmsError(手机号无效 400 / 过频·天级流控 429 / 未配置·未开通·其他 503)
""" """
effective_cooldown = (
settings.ALIYUN_SMS_INTERVAL_SEC if cooldown_sec is None else cooldown_sec
)
if settings.SMS_MOCK: if settings.SMS_MOCK:
logger.info("[SMS-aliyun-MOCK] to %s**** (不真发)", phone[:3]) logger.info("[SMS-aliyun-MOCK] to %s**** (不真发)", phone[:3])
return effective_cooldown return settings.ALIYUN_SMS_INTERVAL_SEC
if not settings.aliyun_sms_configured: if not settings.aliyun_sms_configured:
raise SmsError("短信服务未配置(缺阿里云凭证)", status_code=503) raise SmsError("短信服务未配置(缺阿里云凭证)", status_code=503)
result = ( result = _call_send(phone) # 传输/SDK 异常在内部抛 SmsError(503)
_call_send(phone)
if cooldown_sec is None
else _call_send(phone, cooldown_sec=effective_cooldown)
)
if result["success"] and result["code"] == "OK": if result["success"] and result["code"] == "OK":
now = time.time() now = time.time()
@@ -75,7 +68,7 @@ def send_code(phone: str, *, cooldown_sec: int | None = None) -> int:
_verify_attempts.pop(phone, None) # 新码 = 新失败预算 _verify_attempts.pop(phone, None) # 新码 = 新失败预算
_verify_seen.pop(phone, None) _verify_seen.pop(phone, None)
logger.info("[SMS-aliyun] sent to %s****", phone[:3]) logger.info("[SMS-aliyun] sent to %s****", phone[:3])
return effective_cooldown return settings.ALIYUN_SMS_INTERVAL_SEC
code = result["code"] code = result["code"]
logger.error("[SMS-aliyun] send failed code=%s msg=%s", code, result["message"]) logger.error("[SMS-aliyun] send failed code=%s msg=%s", code, result["message"])
@@ -85,12 +78,7 @@ def send_code(phone: str, *, cooldown_sec: int | None = None) -> int:
raise SmsError(msg, status_code=status) raise SmsError(msg, status_code=status)
def verify_code( def verify_code(phone: str, code: str) -> bool:
phone: str,
code: str,
*,
max_failed_attempts: int | None = None,
) -> bool:
"""校验验证码(阿里云裁决)。 """校验验证码(阿里云裁决)。
- **mock**:放行任意 N 位数字(provider 无关,同极光) - **mock**:放行任意 N 位数字(provider 无关,同极光)
@@ -104,13 +92,8 @@ def verify_code(
# 失败计数是 best-effort:网络调用不持锁(不能锁跨 IO),故并发下同号可能多放行个位数次。 # 失败计数是 best-effort:网络调用不持锁(不能锁跨 IO),故并发下同号可能多放行个位数次。
# 无碍——API 层登录频控(设备+IP 5/时)是硬上限,阿里云码有效期 + DuplicatePolicy 亦兜底。 # 无碍——API 层登录频控(设备+IP 5/时)是硬上限,阿里云码有效期 + DuplicatePolicy 亦兜底。
effective_max_attempts = (
settings.SMS_MAX_VERIFY_ATTEMPTS
if max_failed_attempts is None
else max_failed_attempts
)
with _lock: with _lock:
if _verify_attempts.get(phone, 0) >= effective_max_attempts: if _verify_attempts.get(phone, 0) >= settings.SMS_MAX_VERIFY_ATTEMPTS:
return False # 已作废:保持计数(直到 send_code 重置),与极光「达上限即作废」一致 return False # 已作废:保持计数(直到 send_code 重置),与极光「达上限即作废」一致
result = _call_check(phone, code) # 传输/SDK 异常在内部抛 SmsError(503) result = _call_check(phone, code) # 传输/SDK 异常在内部抛 SmsError(503)
@@ -163,7 +146,7 @@ def _get_client():
return _client return _client
def _call_send(phone: str, *, cooldown_sec: int | None = None) -> dict: def _call_send(phone: str) -> dict:
"""调 SendSmsVerifyCode。返回归一化 {success, code, message};import/建 client/调用 任一失败抛 SmsError(503)。""" """调 SendSmsVerifyCode。返回归一化 {success, code, message};import/建 client/调用 任一失败抛 SmsError(503)。"""
valid_min = max(1, settings.ALIYUN_SMS_VALID_TIME_SEC // 60) valid_min = max(1, settings.ALIYUN_SMS_VALID_TIME_SEC // 60)
template_param = json.dumps({"code": "##code##", "min": str(valid_min)}, ensure_ascii=False) template_param = json.dumps({"code": "##code##", "min": str(valid_min)}, ensure_ascii=False)
@@ -177,11 +160,7 @@ def _call_send(phone: str, *, cooldown_sec: int | None = None) -> dict:
template_param=template_param, template_param=template_param,
code_length=settings.ALIYUN_SMS_CODE_LENGTH, code_length=settings.ALIYUN_SMS_CODE_LENGTH,
valid_time=settings.ALIYUN_SMS_VALID_TIME_SEC, valid_time=settings.ALIYUN_SMS_VALID_TIME_SEC,
interval=( interval=settings.ALIYUN_SMS_INTERVAL_SEC,
settings.ALIYUN_SMS_INTERVAL_SEC
if cooldown_sec is None
else cooldown_sec
),
scheme_name=settings.ALIYUN_SMS_SCHEME_NAME or None, scheme_name=settings.ALIYUN_SMS_SCHEME_NAME or None,
) )
body = _get_client().send_sms_verify_code(req).body body = _get_client().send_sms_verify_code(req).body
-16
View File
@@ -2,12 +2,9 @@
provider(jiguang / aliyun) `from .base import SmsError`,api 层也从包入口拿到同一个 provider(jiguang / aliyun) `from .base import SmsError`,api 层也从包入口拿到同一个
`SmsError` 保证无论用哪个 provider,异常类型与 HTTP 码映射语义一致 `SmsError` 保证无论用哪个 provider,异常类型与 HTTP 码映射语义一致
`SendResult` 是分派层 `send_code` 的返回类型,携带 cooldown实际 provider 及是否走了 fallback
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from app.core.config import settings from app.core.config import settings
@@ -21,19 +18,6 @@ class SmsError(Exception):
self.status_code = status_code self.status_code = status_code
@dataclass(frozen=True)
class SendResult:
"""发码结果:距下次可发秒数 + 实际发码的 provider 名 + 是否走了备(fallback)。
分派层 send_code 的返回类型provider 各自的 send_code 仍返回 int cooldown
由分派层包装auth 层据此把渠道记入风控流水 details
"""
cooldown_sec: int
provider: str
fallback: bool = False
def mock_verify(code: str) -> bool: def mock_verify(code: str) -> bool:
"""mock 模式校验:放行任意 SMS_CODE_LENGTH 位数字(provider 无关,测试/开发便利,不真校验)。""" """mock 模式校验:放行任意 SMS_CODE_LENGTH 位数字(provider 无关,测试/开发便利,不真校验)。"""
return len(code) == settings.SMS_CODE_LENGTH and code.isdigit() return len(code) == settings.SMS_CODE_LENGTH and code.isdigit()
+7 -20
View File
@@ -53,7 +53,7 @@ _GC_THRESHOLD = 10000 # 任一内存 dict 超此阈值,send 时顺手清过期
# 发码错误码(创蓝 `code`)→ (HTTP 码, 用户提示)。未列出的一律 503(供应商不可用)。 # 发码错误码(创蓝 `code`)→ (HTTP 码, 用户提示)。未列出的一律 503(供应商不可用)。
_SEND_ERRORS: dict[str, tuple[int, str]] = { _SEND_ERRORS: dict[str, tuple[int, str]] = {
"103": (429, "发送过于频繁,请稍后再试"), # 提交速度过快 "103": (429, "发送过于频繁,请稍后再试"), # 提交速度过快
"107": (400, "请输入有效的手机号"), # 手机号码错误 "107": (400, "手机号无效"), # 手机号码错误
} }
# 需运维介入的配置/开通/余额类错误:打 critical 日志(仍归 503)。 # 需运维介入的配置/开通/余额类错误:打 critical 日志(仍归 503)。
_SEND_CRITICAL_CODES = frozenset({ _SEND_CRITICAL_CODES = frozenset({
@@ -84,23 +84,20 @@ def _gc(now: float) -> None:
_last_sent.pop(p, None) _last_sent.pop(p, None)
def send_code(phone: str, *, cooldown_sec: int | None = None) -> int: def send_code(phone: str) -> int:
"""发送验证码。 """发送验证码。
Returns: 距下次可发的秒数(= SMS_SEND_INTERVAL_SEC) Returns: 距下次可发的秒数(= SMS_SEND_INTERVAL_SEC)
Raises: SmsError(过频 429 / 手机号无效 400 / 供应商失败 503) Raises: SmsError(过频 429 / 手机号无效 400 / 供应商失败 503)
""" """
now = time.time() now = time.time()
effective_cooldown = (
settings.SMS_SEND_INTERVAL_SEC if cooldown_sec is None else cooldown_sec
)
# --- lock 内:防刷检查 + 预占(防并发重复发烧钱)--- # --- lock 内:防刷检查 + 预占(防并发重复发烧钱)---
with _lock: with _lock:
_gc(now) # 顺手清过期内存(超阈值才扫) _gc(now) # 顺手清过期内存(超阈值才扫)
elapsed = now - _last_sent.get(phone, 0.0) elapsed = now - _last_sent.get(phone, 0.0)
if elapsed < effective_cooldown: if elapsed < settings.SMS_SEND_INTERVAL_SEC:
remain = int(effective_cooldown - elapsed) remain = int(settings.SMS_SEND_INTERVAL_SEC - elapsed)
raise SmsError(f"发送过于频繁,请 {remain}s 后再试") raise SmsError(f"发送过于频繁,请 {remain}s 后再试")
code = _gen_code() code = _gen_code()
@@ -125,15 +122,10 @@ def send_code(phone: str, *, cooldown_sec: int | None = None) -> int:
logger.exception("[SMS-chuanglan] send failed phone=%s****", phone[:3]) logger.exception("[SMS-chuanglan] send failed phone=%s****", phone[:3])
raise SmsError("验证码发送失败,请稍后重试", status_code=503) from e raise SmsError("验证码发送失败,请稍后重试", status_code=503) from e
return effective_cooldown return settings.SMS_SEND_INTERVAL_SEC
def verify_code( def verify_code(phone: str, code: str) -> bool:
phone: str,
code: str,
*,
max_failed_attempts: int | None = None,
) -> bool:
"""校验验证码。 """校验验证码。
- **mock 模式**:放行任意 N 位数字(测试/开发便利,不真校验) - **mock 模式**:放行任意 N 位数字(测试/开发便利,不真校验)
@@ -144,11 +136,6 @@ def verify_code(
logger.info("[SMS-chuanglan-MOCK] verify %s for %s****", "ok" if ok else "fail", phone[:3]) logger.info("[SMS-chuanglan-MOCK] verify %s for %s****", "ok" if ok else "fail", phone[:3])
return ok return ok
effective_max_attempts = (
settings.SMS_MAX_VERIFY_ATTEMPTS
if max_failed_attempts is None
else max_failed_attempts
)
with _lock: with _lock:
rec = _codes.get(phone) rec = _codes.get(phone)
if rec is None: if rec is None:
@@ -156,7 +143,7 @@ def verify_code(
if time.time() > rec.expires_at: if time.time() > rec.expires_at:
_codes.pop(phone, None) _codes.pop(phone, None)
return False return False
if rec.attempts >= effective_max_attempts: if rec.attempts >= settings.SMS_MAX_VERIFY_ATTEMPTS:
_codes.pop(phone, None) # 试错过多,作废 _codes.pop(phone, None) # 试错过多,作废
return False return False
if secrets.compare_digest(code.encode("utf-8"), rec.code.encode("utf-8")): if secrets.compare_digest(code.encode("utf-8"), rec.code.encode("utf-8")):
+7 -20
View File
@@ -70,23 +70,20 @@ def _gc(now: float) -> None:
_last_sent.pop(p, None) _last_sent.pop(p, None)
def send_code(phone: str, *, cooldown_sec: int | None = None) -> int: def send_code(phone: str) -> int:
"""发送验证码。 """发送验证码。
Returns: 距下次可发的秒数(= SMS_SEND_INTERVAL_SEC) Returns: 距下次可发的秒数(= SMS_SEND_INTERVAL_SEC)
Raises: SmsError(过频 429 / 当日超限 429 / 供应商失败 503 / 手机号无效 400) Raises: SmsError(过频 429 / 当日超限 429 / 供应商失败 503 / 手机号无效 400)
""" """
now = time.time() now = time.time()
effective_cooldown = (
settings.SMS_SEND_INTERVAL_SEC if cooldown_sec is None else cooldown_sec
)
# --- lock 内:防刷检查 + 预占(防并发重复发烧钱)--- # --- lock 内:防刷检查 + 预占(防并发重复发烧钱)---
with _lock: with _lock:
_gc(now) # 顺手清过期内存(超阈值才扫) _gc(now) # 顺手清过期内存(超阈值才扫)
elapsed = now - _last_sent.get(phone, 0.0) elapsed = now - _last_sent.get(phone, 0.0)
if elapsed < effective_cooldown: if elapsed < settings.SMS_SEND_INTERVAL_SEC:
remain = int(effective_cooldown - elapsed) remain = int(settings.SMS_SEND_INTERVAL_SEC - elapsed)
raise SmsError(f"发送过于频繁,请 {remain}s 后再试") raise SmsError(f"发送过于频繁,请 {remain}s 后再试")
code = _gen_code() code = _gen_code()
@@ -111,15 +108,10 @@ def send_code(phone: str, *, cooldown_sec: int | None = None) -> int:
logger.exception("[SMS] send failed phone=%s****", phone[:3]) logger.exception("[SMS] send failed phone=%s****", phone[:3])
raise SmsError("验证码发送失败,请稍后重试", status_code=503) from e raise SmsError("验证码发送失败,请稍后重试", status_code=503) from e
return effective_cooldown return settings.SMS_SEND_INTERVAL_SEC
def verify_code( def verify_code(phone: str, code: str) -> bool:
phone: str,
code: str,
*,
max_failed_attempts: int | None = None,
) -> bool:
"""校验验证码。 """校验验证码。
- **mock 模式**:放行任意 N 位数字(测试/开发便利,不真校验) - **mock 模式**:放行任意 N 位数字(测试/开发便利,不真校验)
@@ -130,11 +122,6 @@ def verify_code(
logger.info("[SMS-MOCK] verify %s for %s****", "ok" if ok else "fail", phone[:3]) logger.info("[SMS-MOCK] verify %s for %s****", "ok" if ok else "fail", phone[:3])
return ok return ok
effective_max_attempts = (
settings.SMS_MAX_VERIFY_ATTEMPTS
if max_failed_attempts is None
else max_failed_attempts
)
with _lock: with _lock:
rec = _codes.get(phone) rec = _codes.get(phone)
if rec is None: if rec is None:
@@ -142,7 +129,7 @@ def verify_code(
if time.time() > rec.expires_at: if time.time() > rec.expires_at:
_codes.pop(phone, None) _codes.pop(phone, None)
return False return False
if rec.attempts >= effective_max_attempts: if rec.attempts >= settings.SMS_MAX_VERIFY_ATTEMPTS:
_codes.pop(phone, None) # 试错过多,作废 _codes.pop(phone, None) # 试错过多,作废
return False return False
if secrets.compare_digest(code.encode("utf-8"), rec.code.encode("utf-8")): if secrets.compare_digest(code.encode("utf-8"), rec.code.encode("utf-8")):
@@ -198,5 +185,5 @@ def _send_via_jiguang(phone: str, code: str) -> None:
if ecode == 50009: # 极光侧超频 if ecode == 50009: # 极光侧超频
raise SmsError("发送过于频繁,请稍后再试", status_code=429) raise SmsError("发送过于频繁,请稍后再试", status_code=429)
if ecode == 50006: # 手机号无效(schema 已挡格式,这里多是空号/停机) if ecode == 50006: # 手机号无效(schema 已挡格式,这里多是空号/停机)
raise SmsError("请输入有效的手机号", status_code=400) raise SmsError("手机号无效", status_code=400)
raise SmsError(f"短信发送失败(code={ecode})", status_code=503) raise SmsError(f"短信发送失败(code={ecode})", status_code=503)
+47 -459
View File
@@ -22,7 +22,7 @@ import uuid
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from urllib.parse import quote, urlsplit from urllib.parse import quote
import httpx import httpx
@@ -31,7 +31,6 @@ from app.core.config import settings
logger = logging.getLogger("shagua.vendor_push") logger = logging.getLogger("shagua.vendor_push")
TYPE_ACCESSIBILITY_DISABLED = "accessibility_disabled" TYPE_ACCESSIBILITY_DISABLED = "accessibility_disabled"
DATA_EVENT_NOTIFICATION_CREATED = "notification_created"
SUPPORTED_VENDORS = frozenset({"honor", "huawei", "vivo", "xiaomi", "oppo"}) SUPPORTED_VENDORS = frozenset({"honor", "huawei", "vivo", "xiaomi", "oppo"})
# vendor key → 中文名(测试/配置状态接口展示用) # vendor key → 中文名(测试/配置状态接口展示用)
@@ -70,18 +69,6 @@ class _CachedToken:
_token_cache: dict[str, _CachedToken] = {} _token_cache: dict[str, _CachedToken] = {}
_LOG_SUMMARY_MAX_CHARS = 1500
_LOG_STRING_MAX_CHARS = 200
_SENSITIVE_LOG_KEYS = {
"accesstoken",
"appkey",
"authtoken",
"authorization",
"clientsecret",
"mastersecret",
"sign",
}
def normalize_vendor(push_vendor: str | None) -> str | None: def normalize_vendor(push_vendor: str | None) -> str | None:
if not push_vendor: if not push_vendor:
@@ -128,16 +115,8 @@ def send_notification(
if mock: if mock:
logger.info( logger.info(
"vendor push mock vendor=%s request=%s", "[mock push] vendor=%s token=%s... title=%s body=%s extras=%s",
vendor, vendor, token[:12], title, body, extras,
_log_summary(
{
"push_token": token,
"title": title,
"body": body,
"extras": extras,
}
),
) )
return { return {
"mock": True, "mock": True,
@@ -154,28 +133,7 @@ def send_notification(
"xiaomi": _send_xiaomi, "xiaomi": _send_xiaomi,
"oppo": _send_oppo, "oppo": _send_oppo,
} }
started = time.perf_counter() return dispatch[vendor](token, title, body, extras)
try:
result = dispatch[vendor](token, title, body, extras)
except Exception:
logger.exception(
"vendor push dispatch failed vendor=%s token=%s notification_type=%s "
"elapsed_ms=%.1f",
vendor,
token,
extras.get("type", ""),
_elapsed_ms(started),
)
raise
logger.info(
"vendor push dispatch succeeded vendor=%s token=%s notification_type=%s "
"elapsed_ms=%.1f",
vendor,
token,
extras.get("type", ""),
_elapsed_ms(started),
)
return result
def send_accessibility_disabled( def send_accessibility_disabled(
@@ -195,191 +153,19 @@ def send_accessibility_disabled(
) )
def send_data_event(
push_vendor: str,
push_token: str,
*,
event: str,
notification_id: str,
mock: bool = False,
) -> dict[str, Any]:
"""发送无界面的轻量事件;当前只允许站内消息创建事件。"""
vendor = normalize_vendor(push_vendor)
token = push_token.strip() if push_token else ""
if not vendor or vendor not in SUPPORTED_VENDORS:
raise VendorPushError(f"unsupported push vendor: {push_vendor}")
if not token:
raise VendorPushError("push token is empty")
if event != DATA_EVENT_NOTIFICATION_CREATED:
raise VendorPushError(f"unsupported data event: {event}")
if not notification_id:
raise VendorPushError("notification_id is empty")
payload = {"event": event, "notificationId": str(notification_id)}
if mock:
return {"mock": True, "vendor": vendor, "payload": payload}
# vivo 的通知已设置 foregroundShow=falseApp 在前台时必走
# onForegroundMessageArrived,正好就是本事件需要的刷新信号;不重复占用一次推送配额。
if vendor == "vivo":
return {"skipped": True, "reason": "foreground notification callback"}
# 小米服务端不再发送透传消息;通知栏消息仍照常发送,前台角标由客户端主动刷新。
if vendor == "xiaomi":
return {"skipped": True, "reason": "xiaomi data messages disabled"}
# OPush 当前只支持通知栏消息,没有服务端透传单推接口。
# 客户端在 OPPO 首页可见时轮询未读数;这里必须安全跳过,不能请求不存在的
# /message/transparent/unicast(该地址会稳定返回 HTTP 404)。
if vendor == "oppo":
return {"skipped": True, "reason": "oppo does not support data messages"}
dispatch: dict[str, Callable[[str, dict[str, str]], dict[str, Any]]] = {
"honor": _send_honor_data,
"huawei": _send_huawei_data,
}
return dispatch[vendor](token, payload)
def _require(value: str, name: str) -> str: def _require(value: str, name: str) -> str:
if not value: if not value:
raise VendorPushError(f"{name} not configured") raise VendorPushError(f"{name} not configured")
return value return value
def _elapsed_ms(started: float) -> float:
return (time.perf_counter() - started) * 1000
def _redacted_value(value: Any) -> str:
raw = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
digest = hashlib.sha256(raw.encode()).hexdigest()[:10]
return f"<redacted len={len(raw)} sha256={digest}>"
def _sanitize_for_log(value: Any, *, key: str = "", depth: int = 0) -> Any:
"""生成有排障价值、但不暴露服务端鉴权凭据的紧凑日志摘要。"""
normalized_key = "".join(char for char in key.lower() if char.isalnum())
if normalized_key in _SENSITIVE_LOG_KEYS or normalized_key.endswith("secret"):
return _redacted_value(value)
if depth >= 10:
return f"<{type(value).__name__}>"
if isinstance(value, dict):
items = list(value.items())
sanitized = {
str(item_key): _sanitize_for_log(item_value, key=str(item_key), depth=depth + 1)
for item_key, item_value in items[:30]
}
if len(items) > 30:
sanitized["<omitted_keys>"] = len(items) - 30
return sanitized
if isinstance(value, (list, tuple)):
items = list(value)
sanitized = [_sanitize_for_log(item, key=key, depth=depth + 1) for item in items[:20]]
if len(items) > 20:
sanitized.append(f"<omitted_items={len(items) - 20}>")
return sanitized
if isinstance(value, str):
if value.lstrip().startswith(("{", "[")):
try:
decoded = json.loads(value)
except ValueError:
pass
else:
return _sanitize_for_log(decoded, key=key, depth=depth + 1)
if len(value) > _LOG_STRING_MAX_CHARS:
return f"{value[:_LOG_STRING_MAX_CHARS]}…<len={len(value)}>"
return value
def _log_summary(value: Any) -> str:
rendered = json.dumps(
_sanitize_for_log(value),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
if len(rendered) > _LOG_SUMMARY_MAX_CHARS:
return f"{rendered[:_LOG_SUMMARY_MAX_CHARS]}…<len={len(rendered)}>"
return rendered
def _raw_log_summary(value: Any) -> str:
"""厂商响应摘要:保留原始字段和值,仅限制单条日志长度。"""
rendered = json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
if len(rendered) > _LOG_SUMMARY_MAX_CHARS:
return f"{rendered[:_LOG_SUMMARY_MAX_CHARS]}…<len={len(rendered)}>"
return rendered
def _endpoint_for_log(url: str) -> str:
parsed = urlsplit(url)
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
def _request_summary(kwargs: dict[str, Any]) -> str:
summary: dict[str, Any] = {}
if "json" in kwargs:
summary["body_type"] = "json"
summary["request_payload"] = kwargs["json"]
elif "data" in kwargs:
summary["body_type"] = "form"
summary["request_payload"] = kwargs["data"]
if kwargs.get("params"):
summary["params"] = kwargs["params"]
if kwargs.get("headers"):
summary["headers"] = kwargs["headers"]
return _log_summary(summary)
def _response_summary(resp: Any, parsed: Any | None = None) -> str:
if parsed is None:
try:
parsed = resp.json()
except ValueError:
pass
if parsed is not None:
return _raw_log_summary(parsed)
return _raw_log_summary(getattr(resp, "text", ""))
def _vendor_response_failed(vendor: str, data: dict[str, Any]) -> bool:
"""识别 HTTP 200 中明确的厂商业务失败,避免将失败请求记录成 success。"""
if data.get("error") or data.get("success") is False:
return True
if vendor == "xiaomi":
code = data.get("code")
result = str(data.get("result", "ok")).lower()
return code not in (0, "0", None) or result not in ("ok", "success")
if vendor == "oppo" and data.get("code") is not None:
return int(data["code"]) != 0
if vendor == "vivo" and data.get("result") is not None:
return int(data["result"]) != 0
if vendor == "honor" and data.get("code") is not None:
return int(data["code"]) != 200
if vendor == "huawei" and data.get("code") is not None:
return str(data["code"]) != "80000000"
return False
def _request_json( def _request_json(
method: str, method: str,
url: str, url: str,
*, *,
vendor: str,
operation: str,
expected_status: tuple[int, ...] = (200,), expected_status: tuple[int, ...] = (200,),
**kwargs: Any, **kwargs: Any,
) -> dict[str, Any]: ) -> dict[str, Any]:
started = time.perf_counter()
request_summary = _request_summary(kwargs)
try: try:
resp = httpx.request( resp = httpx.request(
method, method,
@@ -388,85 +174,41 @@ def _request_json(
**kwargs, **kwargs,
) )
except httpx.HTTPError as e: except httpx.HTTPError as e:
logger.error(
"vendor push http completed vendor=%s operation=%s method=%s endpoint=%s "
"outcome=network_error request=%s response=%s elapsed_ms=%.1f error=%s",
vendor,
operation,
method,
_endpoint_for_log(url),
request_summary,
"<no_response>",
_elapsed_ms(started),
type(e).__name__,
)
raise VendorPushError(f"push http error: {e}") from e raise VendorPushError(f"push http error: {e}") from e
if resp.status_code not in expected_status: if resp.status_code not in expected_status:
logger.error( logger.error("vendor push http failed url=%s http=%s body=%s", url, resp.status_code, resp.text[:500])
"vendor push http completed vendor=%s operation=%s method=%s endpoint=%s "
"outcome=http_error http_status=%s request=%s response=%s elapsed_ms=%.1f",
vendor,
operation,
method,
_endpoint_for_log(url),
resp.status_code,
request_summary,
_response_summary(resp),
_elapsed_ms(started),
)
raise VendorPushError(f"push http {resp.status_code}") raise VendorPushError(f"push http {resp.status_code}")
try: try:
data = resp.json() return resp.json()
except ValueError as e: except ValueError as e:
logger.error(
"vendor push http completed vendor=%s operation=%s method=%s endpoint=%s "
"outcome=invalid_json http_status=%s request=%s response=%s elapsed_ms=%.1f",
vendor,
operation,
method,
_endpoint_for_log(url),
resp.status_code,
request_summary,
_response_summary(resp),
_elapsed_ms(started),
)
raise VendorPushError(f"push invalid json: {resp.text[:200]}") from e raise VendorPushError(f"push invalid json: {resp.text[:200]}") from e
vendor_failed = _vendor_response_failed(vendor, data)
log = logger.warning if vendor_failed else logger.info
log(
"vendor push http completed vendor=%s operation=%s method=%s endpoint=%s "
"outcome=%s http_status=%s request=%s response=%s elapsed_ms=%.1f",
vendor,
operation,
method,
_endpoint_for_log(url),
"vendor_error" if vendor_failed else "success",
resp.status_code,
request_summary,
_response_summary(resp, data),
_elapsed_ms(started),
)
return data
def _request_form( def _request_form(
method: str, method: str,
url: str, url: str,
*, *,
vendor: str,
operation: str,
expected_status: tuple[int, ...] = (200,), expected_status: tuple[int, ...] = (200,),
**kwargs: Any, **kwargs: Any,
) -> dict[str, Any]: ) -> dict[str, Any]:
return _request_json( try:
method, resp = httpx.request(
url, method,
vendor=vendor, url,
operation=operation, timeout=settings.PUSH_REQUEST_TIMEOUT_SEC,
expected_status=expected_status, **kwargs,
**kwargs, )
) except httpx.HTTPError as e:
raise VendorPushError(f"push http error: {e}") from e
if resp.status_code not in expected_status:
logger.error("vendor push http failed url=%s http=%s body=%s", url, resp.status_code, resp.text[:500])
raise VendorPushError(f"push http {resp.status_code}")
try:
return resp.json()
except ValueError as e:
raise VendorPushError(f"push invalid json: {resp.text[:200]}") from e
def _cache_get(key: str) -> str | None: def _cache_get(key: str) -> str | None:
@@ -492,8 +234,6 @@ def _honor_access_token() -> str:
data = _request_form( data = _request_form(
"POST", "POST",
settings.HONOR_PUSH_TOKEN_ENDPOINT, settings.HONOR_PUSH_TOKEN_ENDPOINT,
vendor="honor",
operation="authenticate",
data={ data={
"grant_type": "client_credentials", "grant_type": "client_credentials",
"client_id": client_id, "client_id": client_id,
@@ -503,31 +243,25 @@ def _honor_access_token() -> str:
) )
token = data.get("access_token") token = data.get("access_token")
if not token: if not token:
raise VendorPushError(f"honor auth failed: {_raw_log_summary(data)}") raise VendorPushError(f"honor auth failed: {data}")
return _cache_put(cache_key, str(token), data.get("expires_in")) return _cache_put(cache_key, str(token), data.get("expires_in"))
def _send_honor(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]: def _send_honor(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
app_id = _require(settings.HONOR_PUSH_APP_ID, "HONOR_PUSH_APP_ID") app_id = _require(settings.HONOR_PUSH_APP_ID, "HONOR_PUSH_APP_ID")
access_token = _honor_access_token() access_token = _honor_access_token()
click_action: dict[str, Any]
if extras.get("notificationId"):
# type=3 只负责打开首页,不保证 data 会变成目标 Activity 的 extras。消息中心通知必须
# 用自定义页面(type=1)+ intent URI,把 notificationId/type/feedbackId 等直接带给
# MainActivity;否则华为/荣耀系统能展示通知,但用户点击后客户端收不到任何导航参数。
click_action = {"type": 1, "intent": _click_intent_uri(extras)}
else:
click_action = {"type": 3}
payload = { payload = {
# clickAction type=3(打开应用首页)时,荣耀点击会把 data JSON 的键值对注入启动 intent 的
# extras(与 HMS 同机制)→ MainActivity.consumeNavTarget 读 notif_id/notif_type 直达落地。
"data": json.dumps(_click_extras(extras), ensure_ascii=False), "data": json.dumps(_click_extras(extras), ensure_ascii=False),
"notification": {"title": title, "body": body}, "notification": {"title": title, "body": body},
"android": { "android": {
"ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s", "ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s",
"targetUserType": settings.HONOR_PUSH_TARGET_USER_TYPE, "targetUserType": 1,
"notification": { "notification": {
"title": title, "title": title,
"body": body, "body": body,
"clickAction": click_action, "clickAction": {"type": 3},
"importance": "NORMAL", "importance": "NORMAL",
}, },
}, },
@@ -536,8 +270,6 @@ def _send_honor(token: str, title: str, body: str, extras: dict[str, str]) -> di
data = _request_json( data = _request_json(
"POST", "POST",
settings.HONOR_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id), settings.HONOR_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
vendor="honor",
operation="send_notification",
json=payload, json=payload,
headers={ headers={
"Content-Type": "application/json; charset=UTF-8", "Content-Type": "application/json; charset=UTF-8",
@@ -547,28 +279,7 @@ def _send_honor(token: str, title: str, body: str, extras: dict[str, str]) -> di
) )
code = data.get("code") code = data.get("code")
if code is not None and int(code) != 200: if code is not None and int(code) != 200:
raise VendorPushError(f"honor push failed: {_raw_log_summary(data)}") raise VendorPushError(f"honor push failed: {data}")
return data
def _send_honor_data(token: str, payload: dict[str, str]) -> dict[str, Any]:
app_id = _require(settings.HONOR_PUSH_APP_ID, "HONOR_PUSH_APP_ID")
access_token = _honor_access_token()
data = _request_json(
"POST",
settings.HONOR_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
vendor="honor",
operation="send_data_event",
json={"data": json.dumps(payload, ensure_ascii=False), "token": [token]},
headers={
"Content-Type": "application/json; charset=UTF-8",
"Authorization": f"Bearer {access_token}",
"timestamp": str(int(time.time() * 1000)),
},
)
code = data.get("code")
if code is not None and int(code) != 200:
raise VendorPushError(f"honor data push failed: {_raw_log_summary(data)}")
return data return data
@@ -583,8 +294,6 @@ def _huawei_access_token() -> str:
data = _request_form( data = _request_form(
"POST", "POST",
settings.HUAWEI_PUSH_TOKEN_ENDPOINT, settings.HUAWEI_PUSH_TOKEN_ENDPOINT,
vendor="huawei",
operation="authenticate",
data={ data={
"grant_type": "client_credentials", "grant_type": "client_credentials",
"client_id": app_id, "client_id": app_id,
@@ -594,7 +303,7 @@ def _huawei_access_token() -> str:
) )
token = data.get("access_token") token = data.get("access_token")
if not token: if not token:
raise VendorPushError(f"huawei auth failed: {_raw_log_summary(data)}") raise VendorPushError(f"huawei auth failed: {data}")
return _cache_put(cache_key, str(token), data.get("expires_in")) return _cache_put(cache_key, str(token), data.get("expires_in"))
@@ -603,24 +312,18 @@ def _send_huawei(token: str, title: str, body: str, extras: dict[str, str]) -> d
'80100000' 为部分成功( token 场景仍视为失败,错误里带原始响应便于排障)""" '80100000' 为部分成功( token 场景仍视为失败,错误里带原始响应便于排障)"""
app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID") app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID")
access_token = _huawei_access_token() access_token = _huawei_access_token()
click_action: dict[str, Any]
if extras.get("notificationId"):
# type=3 只打开首页,Mate 20/EMUI 不会把 message.data 自动拆成启动 Intent extras。
# type=1 的自定义 intent 才能稳定携带反馈记录 id,且冷启动/onNewIntent 都走同一路由。
click_action = {"type": 1, "intent": _click_intent_uri(extras)}
else:
click_action = {"type": 3}
payload = { payload = {
"validate_only": False, "validate_only": False,
"message": { "message": {
# click_action type=3(打开应用首页)时,HMS 点击会把 data JSON 的键值对注入启动 intent
# 的 extras → MainActivity.consumeNavTarget 读 notif_id/notif_type 直达落地。
"data": json.dumps(_click_extras(extras), ensure_ascii=False), "data": json.dumps(_click_extras(extras), ensure_ascii=False),
"android": { "android": {
"target_user_type": settings.HUAWEI_PUSH_TARGET_USER_TYPE,
"ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s", "ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s",
"notification": { "notification": {
"title": title, "title": title,
"body": body, "body": body,
"click_action": click_action, "click_action": {"type": 3},
"importance": "NORMAL", "importance": "NORMAL",
}, },
}, },
@@ -630,8 +333,6 @@ def _send_huawei(token: str, title: str, body: str, extras: dict[str, str]) -> d
data = _request_json( data = _request_json(
"POST", "POST",
settings.HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id), settings.HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
vendor="huawei",
operation="send_notification",
json=payload, json=payload,
headers={ headers={
"Content-Type": "application/json; charset=UTF-8", "Content-Type": "application/json; charset=UTF-8",
@@ -639,32 +340,7 @@ def _send_huawei(token: str, title: str, body: str, extras: dict[str, str]) -> d
}, },
) )
if str(data.get("code", "")) != "80000000": if str(data.get("code", "")) != "80000000":
raise VendorPushError(f"huawei push failed: {_raw_log_summary(data)}") raise VendorPushError(f"huawei push failed: {data}")
return data
def _send_huawei_data(token: str, payload: dict[str, str]) -> dict[str, Any]:
app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID")
access_token = _huawei_access_token()
data = _request_json(
"POST",
settings.HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
vendor="huawei",
operation="send_data_event",
json={
"validate_only": False,
"message": {
"data": json.dumps(payload, ensure_ascii=False),
"token": [token],
},
},
headers={
"Content-Type": "application/json; charset=UTF-8",
"Authorization": f"Bearer {access_token}",
},
)
if str(data.get("code", "")) != "80000000":
raise VendorPushError(f"huawei data push failed: {_raw_log_summary(data)}")
return data return data
@@ -681,8 +357,6 @@ def _vivo_auth_token() -> str:
data = _request_json( data = _request_json(
"POST", "POST",
settings.VIVO_PUSH_AUTH_ENDPOINT, settings.VIVO_PUSH_AUTH_ENDPOINT,
vendor="vivo",
operation="authenticate",
json={ json={
"appId": app_id, "appId": app_id,
"appKey": app_key, "appKey": app_key,
@@ -692,10 +366,10 @@ def _vivo_auth_token() -> str:
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
) )
if int(data.get("result", -1)) != 0: if int(data.get("result", -1)) != 0:
raise VendorPushError(f"vivo auth failed: {_raw_log_summary(data)}") raise VendorPushError(f"vivo auth failed: {data}")
token = data.get("authToken") token = data.get("authToken")
if not token: if not token:
raise VendorPushError(f"vivo auth missing authToken: {_raw_log_summary(data)}") raise VendorPushError(f"vivo auth missing authToken: {data}")
return _cache_put(cache_key, str(token), 24 * 3600) return _cache_put(cache_key, str(token), 24 * 3600)
@@ -711,22 +385,15 @@ def _send_vivo(token: str, title: str, body: str, extras: dict[str, str]) -> dic
"timeToLive": settings.PUSH_TIME_TO_LIVE_SEC, "timeToLive": settings.PUSH_TIME_TO_LIVE_SEC,
"requestId": uuid.uuid4().hex, "requestId": uuid.uuid4().hex,
"pushMode": settings.VIVO_PUSH_MODE, "pushMode": settings.VIVO_PUSH_MODE,
# vivo SDK 4.1.5 只有在前台展示关闭时才调用
# OpenClientPushMessageReceiver.onForegroundMessageArrived。
# App 在该回调中刷新服务端未读数并补发一条本地通知;后台/锁屏时仍由
# vivo 系统展示,因而任何场景都只会出现一条通知。
"foregroundShow": False,
# vivo 官方 VPush 角标字段:离线收到通知时先由桌面自动 +1;
# App 启动/同步后再由统一未读数通过系统 API 精确校准。
"addBadge": True,
"clientCustomMap": extras, "clientCustomMap": extras,
} }
# vivo Push SDK 480+ 已不再回调 skipType=3;必须使用 skipType=4 的完整 Intent URI。 # 点击落地:消息中心推送(带 notificationId)→ skipType=4 + skipContent=intent uri,由 vivo
# URI 同时包含 data/scheme、显式 component 和 S. extrasOriginOS 才会把业务参数 # 系统直启 MainActivity 并携带 S. extras(与小米 notify_effect=2 同机制)。不依赖客户端
# 原样交给 MainActivity(仅靠隐式 deeplink 会打开 App,但可能剥掉 extras)。 # VivoPushReceiver.onNotificationMessageClicked 里的后台 startActivity——Android 10+ BAL
# 会静默拦掉,receiver 路径仅作兜底。无 notificationId 的召回类保持 skipType=1 仅打开首页。
if extras.get("notificationId"): if extras.get("notificationId"):
payload["skipType"] = 4 payload["skipType"] = 4
payload["skipContent"] = _vivo_click_intent_uri(extras) payload["skipContent"] = _click_intent_uri(extras)
else: else:
payload["skipType"] = 1 payload["skipType"] = 1
if settings.VIVO_PUSH_CATEGORY: if settings.VIVO_PUSH_CATEGORY:
@@ -734,37 +401,14 @@ def _send_vivo(token: str, title: str, body: str, extras: dict[str, str]) -> dic
data = _request_json( data = _request_json(
"POST", "POST",
settings.VIVO_PUSH_SEND_ENDPOINT, settings.VIVO_PUSH_SEND_ENDPOINT,
vendor="vivo",
operation="send_notification",
json=payload, json=payload,
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
"authToken": auth_token, "authToken": auth_token,
}, },
) )
# 10089 = 当前 vivo 应用尚未开通 VPush「离线自动角标」能力。
# 角标只是通知中心未读数的镜像,不能因此让整条通知发送失败:去掉 addBadge
# 原样重试,通知到达/应用同步后仍由客户端系统 API 按统一未读数精确设置。
if int(data.get("result", -1)) == 10089:
logger.warning("vivo VPush badge not enabled (10089); retrying without addBadge")
fallback_payload = dict(payload)
fallback_payload.pop("addBadge", None)
fallback_payload["requestId"] = uuid.uuid4().hex
data = _request_json(
"POST",
settings.VIVO_PUSH_SEND_ENDPOINT,
vendor="vivo",
operation="send_notification",
json=fallback_payload,
headers={
"Content-Type": "application/json",
"authToken": auth_token,
},
)
if int(data.get("result", -1)) == 0:
data = {**data, "badgeFallback": True}
if int(data.get("result", -1)) != 0: if int(data.get("result", -1)) != 0:
raise VendorPushError(f"vivo push failed: {_raw_log_summary(data)}") raise VendorPushError(f"vivo push failed: {data}")
return data return data
@@ -802,43 +446,14 @@ def _send_xiaomi(token: str, title: str, body: str, extras: dict[str, str]) -> d
data = _request_form( data = _request_form(
"POST", "POST",
settings.XIAOMI_PUSH_SEND_ENDPOINT, settings.XIAOMI_PUSH_SEND_ENDPOINT,
vendor="xiaomi",
operation="send_notification",
data=form, data=form,
headers={"Authorization": f"key={app_secret}"}, headers={"Authorization": f"key={app_secret}"},
) )
code = data.get("code") code = data.get("code")
if code not in (0, "0", None): if code not in (0, "0", None):
raise VendorPushError(f"xiaomi push failed: {_raw_log_summary(data)}") raise VendorPushError(f"xiaomi push failed: {data}")
if str(data.get("result", "ok")).lower() not in ("ok", "success"): if str(data.get("result", "ok")).lower() not in ("ok", "success"):
raise VendorPushError(f"xiaomi push failed: {_raw_log_summary(data)}") raise VendorPushError(f"xiaomi push failed: {data}")
return data
def _send_xiaomi_data(token: str, payload: dict[str, str]) -> dict[str, Any]:
app_secret = _require(settings.XIAOMI_PUSH_APP_SECRET, "XIAOMI_PUSH_APP_SECRET")
form = {
"registration_id": token,
"restricted_package_name": settings.ANDROID_PACKAGE_NAME,
"payload": json.dumps(payload, ensure_ascii=False),
"pass_through": "1",
"time_to_live": str(settings.PUSH_TIME_TO_LIVE_SEC * 1000),
}
if settings.XIAOMI_PUSH_CHANNEL_ID.strip():
form["extra.channel_id"] = settings.XIAOMI_PUSH_CHANNEL_ID.strip()
data = _request_form(
"POST",
settings.XIAOMI_PUSH_SEND_ENDPOINT,
vendor="xiaomi",
operation="send_data_event",
data=form,
headers={"Authorization": f"key={app_secret}"},
)
code = data.get("code")
if code not in (0, "0", None):
raise VendorPushError(f"xiaomi data push failed: {_raw_log_summary(data)}")
if str(data.get("result", "ok")).lower() not in ("ok", "success"):
raise VendorPushError(f"xiaomi data push failed: {_raw_log_summary(data)}")
return data return data
@@ -875,24 +490,6 @@ def _click_intent_uri(extras: dict[str, str]) -> str:
return ";".join(parts) return ";".join(parts)
def _vivo_click_intent_uri(extras: dict[str, str]) -> str:
"""vivo skipType=4 使用官方要求的完整 intent deeplink 形态。
`?#Intent` 中的问号不能省;OriginOS 对缺少它的 URI 会退化为“仅打开应用”,
不把 S. 参数交给目标 Activity
"""
pkg = settings.ANDROID_PACKAGE_NAME
parts = [
"intent://push/detail?#Intent",
"scheme=shaguabijia",
f"component={pkg}/{pkg}.MainActivity",
"launchFlags=0x14000000",
]
parts += [f"S.{key}={quote(str(value), safe='')}" for key, value in _click_extras(extras).items()]
parts.append("end")
return ";".join(parts)
def _xiaomi_template_param(title: str, alert: str) -> str: def _xiaomi_template_param(title: str, alert: str) -> str:
rendered = ( rendered = (
settings.XIAOMI_PUSH_TEMPLATE_PARAM_JSON settings.XIAOMI_PUSH_TEMPLATE_PARAM_JSON
@@ -925,8 +522,6 @@ def _oppo_auth_token() -> str:
data = _request_form( data = _request_form(
"POST", "POST",
settings.OPPO_PUSH_AUTH_ENDPOINT, settings.OPPO_PUSH_AUTH_ENDPOINT,
vendor="oppo",
operation="authenticate",
data={ data={
"app_key": app_key, "app_key": app_key,
"timestamp": timestamp, "timestamp": timestamp,
@@ -935,10 +530,10 @@ def _oppo_auth_token() -> str:
headers={"Content-Type": "application/x-www-form-urlencoded"}, headers={"Content-Type": "application/x-www-form-urlencoded"},
) )
if int(data.get("code", -1)) != 0: if int(data.get("code", -1)) != 0:
raise VendorPushError(f"oppo auth failed: {_raw_log_summary(data)}") raise VendorPushError(f"oppo auth failed: {data}")
token = (data.get("data") or {}).get("auth_token") or data.get("auth_token") token = (data.get("data") or {}).get("auth_token") or data.get("auth_token")
if not token: if not token:
raise VendorPushError(f"oppo auth missing auth_token: {_raw_log_summary(data)}") raise VendorPushError(f"oppo auth missing auth_token: {data}")
return _cache_put(cache_key, str(token), 24 * 3600) return _cache_put(cache_key, str(token), 24 * 3600)
@@ -953,11 +548,6 @@ def _send_oppo(token: str, title: str, body: str, extras: dict[str, str]) -> dic
"off_line_ttl": ttl_hours, "off_line_ttl": ttl_hours,
"action_parameters": json.dumps(_click_extras(extras), ensure_ascii=False), "action_parameters": json.dumps(_click_extras(extras), ensure_ascii=False),
} }
if extras.get("notificationId"):
# 只有已落库的站内消息才计入角标;保活提醒等临时推送不应留下无法消除的未读数。
# 客户端打开/已读后会按服务端真实未读总数覆盖,避免长期漂移。
notification["badge_operation_type"] = 1
notification["badge_message_count"] = 1
# 点击落地:OPPO SDK 没有点击回调,参数只能靠服务端点击动作配置送达——action_parameters 的 # 点击落地:OPPO SDK 没有点击回调,参数只能靠服务端点击动作配置送达——action_parameters 的
# 键值对仅在 click_action_type=1/4 时才会注入目标 Activity 的 intent extras(type=0「启动应用」 # 键值对仅在 click_action_type=1/4 时才会注入目标 Activity 的 intent extras(type=0「启动应用」
# 会忽略它,extras 全丢 → 点了没反应,与小米 notify_effect=1 同款坑)。 # 会忽略它,extras 全丢 → 点了没反应,与小米 notify_effect=1 同款坑)。
@@ -983,8 +573,6 @@ def _send_oppo(token: str, title: str, body: str, extras: dict[str, str]) -> dic
data = _request_form( data = _request_form(
"POST", "POST",
settings.OPPO_PUSH_SEND_ENDPOINT, settings.OPPO_PUSH_SEND_ENDPOINT,
vendor="oppo",
operation="send_notification",
data={ data={
"auth_token": auth_token, "auth_token": auth_token,
"message": json.dumps(message, ensure_ascii=False), "message": json.dumps(message, ensure_ascii=False),
@@ -992,5 +580,5 @@ def _send_oppo(token: str, title: str, body: str, extras: dict[str, str]) -> dic
headers={"Content-Type": "application/x-www-form-urlencoded"}, headers={"Content-Type": "application/x-www-form-urlencoded"},
) )
if int(data.get("code", -1)) != 0: if int(data.get("code", -1)) != 0:
raise VendorPushError(f"oppo push failed: {_raw_log_summary(data)}") raise VendorPushError(f"oppo push failed: {data}")
return data return data
-1
View File
@@ -36,7 +36,6 @@ from app.models.inactivity import ( # noqa: F401
from app.models.invite import InviteRelation # noqa: F401 from app.models.invite import InviteRelation # noqa: F401
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401 from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401 from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401
from app.models.limit_policy import LimitPolicyOverride # noqa: F401
from app.models.meituan_coupon import MeituanCoupon # noqa: F401 from app.models.meituan_coupon import MeituanCoupon # noqa: F401
from app.models.notification import Notification # noqa: F401 from app.models.notification import Notification # noqa: F401
from app.models.onboarding import OnboardingCompletion # noqa: F401 from app.models.onboarding import OnboardingCompletion # noqa: F401
-9
View File
@@ -102,21 +102,12 @@ class ComparisonRecord(Base):
# done 帧 information 文案。成功:"在美团找到同店,到手价 ¥X…"; # done 帧 information 文案。成功:"在美团找到同店,到手价 ¥X…";
# 失败:具体原因(如"美团、京东外卖均未找到该商品")。前端在比价失败时当原因展示。 # 失败:具体原因(如"美团、京东外卖均未找到该商品")。前端在比价失败时当原因展示。
information: Mapped[str | None] = mapped_column(String(256), nullable=True) information: Mapped[str | None] = mapped_column(String(256), nullable=True)
# 失败卡「原因」行的展示文案(仅 status=failed 时非空):information 具体则=它;笼统则从
# platform_results 捞出的业务原因(打烊/未起送/找不到店或菜/单点不配送);纯系统失败为 None
# → 端侧显示品牌兜底「网络开小差…」。写路径(harvest_done / upsert_record)落库时派生。
# 见 repositories.comparison._derive_fail_display。
fail_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
# ===== 明细(JSON,越详细越好)===== # ===== 明细(JSON,越详细越好)=====
# 下单菜品 [{name, qty, specs?}] # 下单菜品 [{name, qty, specs?}]
items: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) items: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name, applied_coupons}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名;applied_coupons=[{name,amount}] 多券明细) # 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name, applied_coupons}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名;applied_coupons=[{name,amount}] 多券明细)
comparison_results: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) comparison_results: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 展示模型统一数组(pricebot done.params.platforms 原样存): 每平台一行、自带
# status/is_best/display/display_order, 记录页据此直接渲染, 不再靠 comparison_results
# + 客户端合并 + 前端派生。老记录/旧客户端为空 → 前端回退老 comparison_results 渲染。
platforms: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 目标平台未找到、跳过的菜名 # 目标平台未找到、跳过的菜名
skipped_dish_names: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) skipped_dish_names: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 客户端上报的原始 payload(calibration + done.params 全量),未来取数兜底 # 客户端上报的原始 payload(calibration + done.params 全量),未来取数兜底
-10
View File
@@ -34,11 +34,6 @@ class DeviceLiveness(Base):
__tablename__ = "device_liveness" __tablename__ = "device_liveness"
__table_args__ = ( __table_args__ = (
UniqueConstraint("user_id", "device_id", name="uq_device_liveness_user_device"), UniqueConstraint("user_id", "device_id", name="uq_device_liveness_user_device"),
UniqueConstraint(
"push_vendor",
"push_token",
name="uq_device_liveness_vendor_token",
),
) )
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
@@ -53,11 +48,6 @@ class DeviceLiveness(Base):
push_vendor: Mapped[str | None] = mapped_column(String(32), nullable=True) push_vendor: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 厂商 push token / regId / registration_id;不同厂商命名不同,后端统一存这里。 # 厂商 push token / regId / registration_id;不同厂商命名不同,后端统一存这里。
push_token: Mapped[str | None] = mapped_column(String(256), nullable=True) push_token: Mapped[str | None] = mapped_column(String(256), nullable=True)
# 客户端每次登录生成的高熵随机值。离线退出后只凭此值精确撤销旧会话绑定,
# 新账号/新会话注册时会替换它,故旧解绑任务绝不能误解绑新登录。
push_binding_id: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
# 被退出/转移的 binding 留作墓碑,阻止旧的在途 register/heartbeat 把 token 抢回。
push_binding_revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
platform: Mapped[str] = mapped_column(String(16), nullable=False, default="android") platform: Mapped[str] = mapped_column(String(16), nullable=False, default="android")
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
+41 -25
View File
@@ -1,4 +1,20 @@
"""引导视频播放计划、起播快照与逐圈结算状态。""" """新手引导视频播放记录(领券浮层前 N 次用它替代广告)。
产品规则(2026-07 拍板):新用户点一键自动领取后的等候浮层,** 3 **不放广告,
改放运营后台上传的引导视频;每次固定 120 金币,中途关闭也算看完照发
口径:
- **计次按账号**(user_id),与设备无关 换设备不重新送 3
- **开播即计数**:客户端每次要展示浮层时调 `/api/v1/guide-video/start`,服务端当场
写一行(status='playing')并返回 play_token;`COUNT(*)` 即已用次数用户中途 kill
App 也算用掉一次(产品选定口径,防反复进出刷金币)
- **发币幂等** play_token 定位 + `status='playing'` 条件更新:并发两次上报只有一次
改到行(另一次 rowcount=0),所以只发一次币光有 play_token 唯一键挡不住 发币走的是
UPDATE, INSERT,撞不到任何唯一键
- **次数上限** (user_id, seq) 唯一键兜底,防并发 /start 绕过 COUNT 判定(见下)
与广告收益(ad_feed_reward_record)彻底分离:引导视频不是广告,不该进广告收益报表
"""
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
@@ -10,48 +26,48 @@ from app.db.base import Base
class GuideVideoPlay(Base): class GuideVideoPlay(Base):
"""一次引导视频播放一行。开播时建(status='playing'),发币后置 'granted'"""
__tablename__ = "guide_video_play" __tablename__ = "guide_video_play"
__table_args__ = ( __table_args__ = (
# 客户端幂等键:同一次播放重复上报奖励只发一次。
UniqueConstraint("play_token", name="uq_guide_video_play_token"), UniqueConstraint("play_token", name="uq_guide_video_play_token"),
Index( # 次数上限的**硬约束**:start_play 是无锁 check-then-insert(读 COUNT 算 seq 再插),
"uq_guide_video_play_user_scene_seq", # N 个并发 /start 会都读到同一个已用次数、算出同一个 seq,不拦就能各拿一个 token、
"user_id", # 各发一次金币,3 次上限形同虚设(改包即可无限刷)。seq 唯一 → 并发同 seq 必撞,
"scene", # start_play 捕获 IntegrityError 降级成"这次不放视频"。
"seq", # 用 unique Index 而非 UniqueConstraint:与迁移里的 create_index 对齐(SQLite 加约束
unique=True, # 要整表重建),autogenerate 才不会每次报一条假 diff。
), Index("uq_guide_video_play_user_seq", "user_id", "seq", unique=True),
) )
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column( user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False Integer, ForeignKey("user.id"), index=True, nullable=False
) )
# 服务端生成下发给客户端的幂等键(uuid hex)。
play_token: Mapped[str] = mapped_column(String(64), nullable=False) play_token: Mapped[str] = mapped_column(String(64), nullable=False)
# 触发场景:目前只有 coupon(领券等候浮层);留字段以便日后比价等场景复用。
scene: Mapped[str] = mapped_column(String(16), nullable=False, default="coupon") scene: Mapped[str] = mapped_column(String(16), nullable=False, default="coupon")
# prepare 不占次数,seq=NULLstart 成功才写入 1-based seq # 本账号第几次(1-based),= 建行时已有行数 + 1。日常判定仍以 COUNT 为准,但 (user_id, seq)
seq: Mapped[int | None] = mapped_column(Integer, nullable=True) # 唯一键让并发 /start 只能成一个 —— 见 __table_args__。
seq: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
# 当次下发的视频地址(运营换片后能回溯用户当时看的是哪支)。
video_url: Mapped[str | None] = mapped_column(String(512), nullable=True) video_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
# 实发金币;未发时 0。
coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0) coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
duration_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # playing(已开播未发币) / granted(已发币)。
config_version: Mapped[int] = mapped_column(Integer, nullable=False, default=0) status: Mapped[str] = mapped_column(String(16), nullable=False, default="playing")
settled_circles: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # 客户端上报时是否播完(true=自然播完 / false=中途关闭)。仅留痕:两者都发币。
# prepared / started / completed / legacy_completed / legacy_closed
status: Mapped[str] = mapped_column(String(24), nullable=False, default="prepared")
completed: Mapped[int] = mapped_column(Integer, nullable=False, default=0) completed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
prepared_at: Mapped[datetime] = mapped_column( started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), index=True, nullable=True
)
started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), index=True, nullable=True
) )
granted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) granted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
def __repr__(self) -> str: # pragma: no cover def __repr__(self) -> str: # pragma: no cover
return ( return (
f"<GuideVideoPlay user={self.user_id} scene={self.scene} seq={self.seq} " f"<GuideVideoPlay user={self.user_id} seq={self.seq} "
f"{self.status} circles={self.settled_circles}>" f"{self.status} coin={self.coin}>"
) )
-77
View File
@@ -1,77 +0,0 @@
"""Per-subject limit policy overrides used by the admin whitelist page."""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
Index,
Integer,
String,
UniqueConstraint,
func,
true,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class LimitPolicyOverride(Base):
"""One rule override for one phone or device.
``reset_at`` is a non-destructive usage baseline. Business records and
security events remain intact; quota readers only count rows at or after
this timestamp.
"""
__tablename__ = "limit_policy_override"
__table_args__ = (
UniqueConstraint(
"subject_type",
"subject_value",
"rule_code",
name="uq_limit_policy_subject_rule",
),
Index(
"ix_limit_policy_lookup",
"subject_type",
"subject_value",
"rule_code",
"enabled",
),
Index("ix_limit_policy_expires", "expires_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
subject_type: Mapped[str] = mapped_column(String(16), nullable=False)
subject_value: Mapped[str] = mapped_column(String(128), nullable=False)
rule_code: Mapped[str] = mapped_column(String(64), nullable=False)
# 产品只保留“临时不限/免告警”。即使有内部脚本绕过 API 直接建 ORM
# 对象,也不能再悄悄落成已经下线的 override 模式。
mode: Mapped[str] = mapped_column(String(24), nullable=False, default="unlimited")
limit_value: Mapped[int | None] = mapped_column(Integer, nullable=True)
enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default=true()
)
starts_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
reset_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
created_by_admin_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
-9
View File
@@ -60,15 +60,6 @@ class CoinTransaction(Base):
sqlite_where=text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"), sqlite_where=text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"),
postgresql_where=text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"), postgresql_where=text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"),
), ),
Index(
"ux_coin_transaction_guide_video_ref",
"user_id",
"biz_type",
"ref_id",
unique=True,
sqlite_where=text("biz_type = 'guide_video' AND ref_id IS NOT NULL"),
postgresql_where=text("biz_type = 'guide_video' AND ref_id IS NOT NULL"),
),
) )
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+5 -36
View File
@@ -5,13 +5,11 @@
""" """
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core import limit_policy, rewards from app.core import rewards
from app.core.rewards import cn_today from app.core.rewards import cn_today
from app.models.ad_feed_reward import AdFeedRewardRecord from app.models.ad_feed_reward import AdFeedRewardRecord
from app.repositories import wallet as crud_wallet from app.repositories import wallet as crud_wallet
@@ -31,14 +29,8 @@ def _find_by_event(db: Session, client_event_id: str) -> AdFeedRewardRecord | No
).scalar_one_or_none() ).scalar_one_or_none()
def _granted_today( def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
db: Session, return db.execute(
user_id: int,
reward_date: str,
*,
reset_at: datetime | None = None,
) -> int:
stmt = (
select(func.count()) select(func.count())
.select_from(AdFeedRewardRecord) .select_from(AdFeedRewardRecord)
.where( .where(
@@ -46,10 +38,7 @@ def _granted_today(
AdFeedRewardRecord.reward_date == reward_date, AdFeedRewardRecord.reward_date == reward_date,
AdFeedRewardRecord.status == "granted", AdFeedRewardRecord.status == "granted",
) )
) ).scalar_one()
if reset_at is not None:
stmt = stmt.where(AdFeedRewardRecord.created_at >= reset_at)
return db.execute(stmt).scalar_one()
def granted_unit_total(db: Session, user_id: int) -> int: def granted_unit_total(db: Session, user_id: int) -> int:
@@ -132,27 +121,7 @@ def grant_feed_reward(
) )
return _commit_record(db, rec, client_event_id) return _commit_record(db, rec, client_event_id)
daily_policy = limit_policy.resolve_for_user( if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db):
db,
"ad.feed.daily",
user_id,
)
daily_limit = (
rewards.get_ad_daily_limit(db)
if daily_policy.override_id is None
and daily_policy.bucket_version == "default"
else daily_policy.limit
)
if (
daily_limit is not None
and _granted_today(
db,
user_id,
today,
reset_at=daily_policy.reset_at,
)
>= daily_limit
):
rec = AdFeedRewardRecord( rec = AdFeedRewardRecord(
client_event_id=client_event_id, client_event_id=client_event_id,
user_id=user_id, user_id=user_id,
+17 -82
View File
@@ -10,13 +10,13 @@
""" """
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from datetime import datetime, timezone
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core import limit_policy, rewards from app.core import rewards
from app.core.ad_cooldown import compute_cooldown from app.core.ad_cooldown import compute_cooldown
from app.core.rewards import DAILY_AD_WATCH_SECONDS_LIMIT, cn_today from app.core.rewards import DAILY_AD_WATCH_SECONDS_LIMIT, cn_today
from app.models.ad_reward import AdRewardRecord from app.models.ad_reward import AdRewardRecord
@@ -96,14 +96,8 @@ def round_coin_total(db: Session, user_id: int, boost_round_id: str) -> int:
) )
def _granted_today( def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
db: Session, return db.execute(
user_id: int,
reward_date: str,
*,
reset_at: datetime | None = None,
) -> int:
stmt = (
select(func.count()) select(func.count())
.select_from(AdRewardRecord) .select_from(AdRewardRecord)
.where( .where(
@@ -112,10 +106,7 @@ def _granted_today(
AdRewardRecord.status == "granted", AdRewardRecord.status == "granted",
AdRewardRecord.reward_scene == "reward_video", AdRewardRecord.reward_scene == "reward_video",
) )
) ).scalar_one()
if reset_at is not None:
stmt = stmt.where(AdRewardRecord.created_at >= reset_at)
return db.execute(stmt).scalar_one()
def _granted_cumulative(db: Session, user_id: int) -> int: def _granted_cumulative(db: Session, user_id: int) -> int:
@@ -174,27 +165,7 @@ def grant_ad_reward(
DAILY_AD_WATCH_SECONDS_LIMIT > 0 DAILY_AD_WATCH_SECONDS_LIMIT > 0
and watched_seconds_today(db, user_id, today=today) >= DAILY_AD_WATCH_SECONDS_LIMIT and watched_seconds_today(db, user_id, today=today) >= DAILY_AD_WATCH_SECONDS_LIMIT
) )
daily_policy = limit_policy.resolve_for_user( over_count = _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db)
db,
"ad.reward_video.daily",
user_id,
)
daily_limit = (
rewards.get_ad_daily_limit(db)
if daily_policy.override_id is None
and daily_policy.bucket_version == "default"
else daily_policy.limit
)
over_count = (
daily_limit is not None
and _granted_today(
db,
user_id,
today,
reset_at=daily_policy.reset_at,
)
>= daily_limit
)
if over_time or over_count: if over_time or over_count:
rec = AdRewardRecord( rec = AdRewardRecord(
trans_id=trans_id, user_id=user_id, coin=0, status="capped", trans_id=trans_id, user_id=user_id, coin=0, status="capped",
@@ -350,24 +321,19 @@ def _commit_record(db: Session, rec: AdRewardRecord, trans_id: str) -> AdRewardR
return rec return rec
def _granted_times_today_desc( def _granted_times_today_desc(db: Session, user_id: int, reward_date: str) -> list[datetime]:
db: Session,
user_id: int,
reward_date: str,
*,
reset_at: datetime | None = None,
) -> list[datetime]:
"""当日 status=granted 记录的 created_at,按时间倒序(最新在前)——冷却策略的输入数据。""" """当日 status=granted 记录的 created_at,按时间倒序(最新在前)——冷却策略的输入数据。"""
stmt = select(AdRewardRecord.created_at).where( return list(
db.execute(
select(AdRewardRecord.created_at)
.where(
AdRewardRecord.user_id == user_id, AdRewardRecord.user_id == user_id,
AdRewardRecord.reward_date == reward_date, AdRewardRecord.reward_date == reward_date,
AdRewardRecord.status == "granted", AdRewardRecord.status == "granted",
AdRewardRecord.reward_scene == "reward_video", AdRewardRecord.reward_scene == "reward_video",
) )
if reset_at is not None: .order_by(AdRewardRecord.created_at.desc())
stmt = stmt.where(AdRewardRecord.created_at >= reset_at) ).scalars()
return list(
db.execute(stmt.order_by(AdRewardRecord.created_at.desc())).scalars()
) )
@@ -383,47 +349,16 @@ def today_status(
旧客户端兼容,当前 DAILY_AD_WATCH_SECONDS_LIMIT=0 表示不启用时长闸 旧客户端兼容,当前 DAILY_AD_WATCH_SECONDS_LIMIT=0 表示不启用时长闸
""" """
today = cn_today().isoformat() today = cn_today().isoformat()
daily_policy = limit_policy.resolve_for_user( granted_desc = _granted_times_today_desc(db, user_id, today)
db,
"ad.reward_video.daily",
user_id,
)
cooldown_policy = limit_policy.resolve_for_user(
db,
"ad.reward_video.cooldown",
user_id,
)
daily_limit = (
rewards.get_ad_daily_limit(db)
if daily_policy.override_id is None
and daily_policy.bucket_version == "default"
else daily_policy.limit
)
cooldown_seconds = (
rewards.get_ad_cooldown_sec(db)
if cooldown_policy.override_id is None
and cooldown_policy.bucket_version == "default"
else (cooldown_policy.limit or 0)
)
granted_desc = _granted_times_today_desc(
db,
user_id,
today,
reset_at=daily_policy.reset_at,
)
state = compute_cooldown( state = compute_cooldown(
granted_desc, granted_desc,
datetime.now(UTC), datetime.now(timezone.utc),
round_size=rewards.get_ad_round_count(db), round_size=rewards.get_ad_round_count(db),
cooldown_seconds=cooldown_seconds, cooldown_seconds=rewards.get_ad_cooldown_sec(db),
) )
return ( return (
len(granted_desc), len(granted_desc),
( rewards.get_ad_daily_limit(db),
daily_limit
if daily_limit is not None
else limit_policy.get_rule("ad.reward_video.daily").max_value
),
0, 0,
state.round_count, state.round_count,
state.cooldown_until, state.cooldown_until,
+13 -212
View File
@@ -52,81 +52,6 @@ def _product_names_from_items(items: list | None) -> str | None:
return joined[:500] or None return joined[:500] or None
# ---- 失败记录的展示文案(记录页失败卡「原因」行)------------------------------
# information 具体就直出;笼统(_GENERIC_INFO)则从 platform_results 捞一条用户可读的业务
# 原因;捞不到 → None(端侧显示品牌兜底「网络开小差…」)。pricebot 把 store_closed /
# no_delivery 漏成了 status=failed,这里按 reason 关键字补判;打烊类 reason 常带一坨脏店名
# (店名+月售+起送+配送…),统一成简短模板。自动化黑话(搜索失败/读价失败/购物车残留/裸
# FAILED…)不给用户看 → 归入品牌兜底。
# pricebot 组不出具体原因时的笼统 information(线上统计的大头),一律走品牌兜底。
_GENERIC_INFO = {
"比价过程出错,请稍后重试",
"比价出错",
"比价未完成",
"done 参数缺少可验证的目标平台结果",
}
# 干净业务结局 status(直接可信),按展示优先级(越靠前越先选)。
_BIZ_STATUS_PRIORITY = (
"below_minimum",
"no_delivery",
"store_closed",
"items_not_found",
"store_not_found",
)
def _store_closed_text(reason: str | None) -> str:
"""打烊/暂停营业/休息类 reason 常带脏店名元数据 → 只留结论,套简短模板。"""
r = reason or ""
if "暂停营业" in r:
state = "暂停营业"
elif "休息" in r:
state = "休息中"
else:
state = "已打烊"
return f"门店{state},无法比价"
def _target_display_reason(platform_results: dict | None) -> str | None:
"""从逐平台结果里挑一条"可展示给用户"的失败原因;挑不到返回 None。
status 命中干净业务结局集 直接采信(打烊套模板,其余用 reason);
补判 pricebot 漏成 status=failed 的两类:打烊(套模板)单点不配送(reason 本身干净);
自动化黑话(搜索失败/读价失败/购物车残留/ FAILED)一律不展示 None"""
pr = platform_results or {}
targets = [
v for v in pr.values() if isinstance(v, dict) and not v.get("is_source")
]
for want in _BIZ_STATUS_PRIORITY: # ① 干净 status 优先
for v in targets:
if v.get("status") == want:
if want == "store_closed":
return _store_closed_text(v.get("reason"))
if v.get("reason"):
return v["reason"]
for v in targets: # ② 漏成 failed 的业务结局补判
if v.get("status") != "failed":
continue
reason = (v.get("reason") or "").strip()
if any(k in reason for k in ("打烊", "暂停营业", "休息")):
return _store_closed_text(reason)
if "单点不配送" in reason:
return reason
return None
def _derive_fail_display(
information: str | None, platform_results: dict | None
) -> str | None:
"""失败记录展示文案:information 具体则直出;笼统则从 platform_results 捞/补判;
都拿不到 None(端侧品牌兜底)仅在 status=failed 时调用"""
info = (information or "").strip()
text = info if (info and info not in _GENERIC_INFO) else _target_display_reason(
platform_results
)
return text[:256] if text else None
def _derive(payload: ComparisonRecordIn) -> dict: def _derive(payload: ComparisonRecordIn) -> dict:
"""从上报 payload 派生结构化列(best/saved/is_source_best/status)。""" """从上报 payload 派生结构化列(best/saved/is_source_best/status)。"""
results = payload.comparison_results results = payload.comparison_results
@@ -164,9 +89,8 @@ def _derive(payload: ComparisonRecordIn) -> dict:
is_source_best = best.is_source if best is not None else None is_source_best = best.is_source if best is not None else None
# status:优先 pricebot record_status(区分 below_minimum/store_closed) → 客户端显式 status # status:客户端显式给了就用;否则有"非源且有价"的结果=success,否则 failed
# → 兜底"非源且有价"=success/否则 failed。record_status 让"未满起送"不再塌缩成 failed。 status = payload.status
status = payload.record_status or payload.status
if status is None: if status is None:
has_valid_target = any( has_valid_target = any(
(not r.is_source) and r.price is not None for r in results (not r.is_source) and r.price is not None for r in results
@@ -181,11 +105,6 @@ def _derive(payload: ComparisonRecordIn) -> dict:
"saved_amount_cents": saved_amount_cents, "saved_amount_cents": saved_amount_cents,
"is_source_best": is_source_best, "is_source_best": is_source_best,
"status": status, "status": status,
"fail_reason": (
_derive_fail_display(payload.information, _pr)
if status == "failed"
else None
),
} }
@@ -198,35 +117,16 @@ def upsert_record(
灰度期老客户端 POST /compare/record 走这条,与后端 harvest trace_id reconcile; 灰度期老客户端 POST /compare/record 走这条,与后端 harvest trace_id reconcile;
新客户端不再 POST(改由 compare.py 透传壳 harvest 落库) 新客户端不再 POST(改由 compare.py 透传壳 harvest 落库)
""" """
# 单源派生: 与 harvest_done 一致, payload 带 platforms 时从它派生(唯一真相源 derived = _derive(payload)
# _derive_from_platforms), 老客户端不带 platforms 时回退 _derive(从 comparison_results)。
if payload.platforms:
derived = _derive_from_platforms(payload.platforms, payload.record_status)
# 对齐 _derive 返回键(#189 fail_reason): 两路径 fields 键集一致, 覆盖已有行时不残留旧值
derived["fail_reason"] = (
_derive_fail_display(payload.information, payload.platform_results or {})
if derived["status"] == "failed"
else None
)
# 单源派生取自 platforms 源行(常无源平台元数据/店名)→ 空则用 payload 兜底不丢字段。
# 下面 fields 不再显式写这四个键, 统一由 derived 提供(否则 dict(store_name=..., **derived)
# 与 _derive_from_platforms 同名键撞键 TypeError)。
for _k in ("store_name", "source_platform_id", "source_platform_name", "source_package"):
if not derived.get(_k):
derived[_k] = getattr(payload, _k)
else:
derived = _derive(payload)
# _derive 只从 comparison_results 派生, 不含源平台四件套 / store_name → 从 payload 补,
# 与上面 platforms 分支键集对齐(fields 统一靠 **derived 提供这些列)。
for _k in ("store_name", "source_platform_id", "source_platform_name", "source_package"):
derived[_k] = getattr(payload, _k)
items = [it.model_dump(exclude_none=True) for it in payload.items] items = [it.model_dump(exclude_none=True) for it in payload.items]
fields = dict( fields = dict(
device_id=payload.device_id, device_id=payload.device_id,
business_type=payload.business_type, business_type=payload.business_type,
store_name=payload.store_name,
product_names=_product_names_from_items(items), product_names=_product_names_from_items(items),
# store_name / source_platform_id / source_platform_name / source_package 统一由 source_platform_id=payload.source_platform_id,
# derived 提供(见上方两分支补齐), 不在此显式写 —— 否则与 _derive_from_platforms 撞键。 source_platform_name=payload.source_platform_name,
source_package=payload.source_package,
information=payload.information, information=payload.information,
best_deeplink=payload.best_deeplink, best_deeplink=payload.best_deeplink,
trace_url=payload.trace_url, trace_url=payload.trace_url,
@@ -234,7 +134,6 @@ def upsert_record(
skipped_dish_count=payload.skipped_dish_count, skipped_dish_count=payload.skipped_dish_count,
items=items, items=items,
comparison_results=[r.model_dump() for r in payload.comparison_results], comparison_results=[r.model_dump() for r in payload.comparison_results],
platforms=list(payload.platforms or []),
skipped_dish_names=list(payload.skipped_dish_names), skipped_dish_names=list(payload.skipped_dish_names),
# 客户端环境 / 性能(debug,客户端上报;旧客户端为 None) # 客户端环境 / 性能(debug,客户端上报;旧客户端为 None)
device_model=payload.device_model, device_model=payload.device_model,
@@ -307,8 +206,7 @@ def upsert_record(
def _derive_from_results( def _derive_from_results(
results: list[dict], platform_results: dict | None = None, results: list[dict], platform_results: dict | None = None
record_status: str | None = None,
) -> dict: ) -> dict:
"""从 done 帧 comparison_results(pricebot 原始 dict 列表)派生结构化列。 """从 done 帧 comparison_results(pricebot 原始 dict 列表)派生结构化列。
等价 _derive,但吃原始字段(is_source/price/rank/platform_id/store_name...)而非 pydantic 对象 等价 _derive,但吃原始字段(is_source/price/rank/platform_id/store_name...)而非 pydantic 对象
@@ -356,63 +254,7 @@ def _derive_from_results(
"saved_amount_cents": saved_amount_cents, "saved_amount_cents": saved_amount_cents,
"is_source_best": best.get("is_source") if best else None, "is_source_best": best.get("is_source") if best else None,
"store_name": (src_row or {}).get("store_name") or None, "store_name": (src_row or {}).get("store_name") or None,
# 记录级结局: 优先用 pricebot 下发的 record_status(区分 below_minimum/store_closed, "status": "success" if has_valid_target else "failed",
# 不再把"未满起送"塌缩成 failed → 记录页不再误报"网络开小差"); 旧 pricebot 未下发时
# 回退老的 success/failed 二态派生, 向后兼容。
"status": record_status or ("success" if has_valid_target else "failed"),
}
def _derive_from_platforms(
platforms: list, record_status: str | None = None,
) -> dict:
"""从 done 帧 platforms(每平台一行、渲染就绪)派生结构化列——**单一真相源**。
best_* 直接取 platforms is_best 的那一行source_* role=source ,与前端读的
platforms 天然一致(不再像 _derive_from_results 那样从 comparison_results 二次评最优,
消除"标量列 vs platforms"双源不一致)platforms 非空时优先走这里; pricebot
platforms 时调用方回退 _derive_from_results(向后兼容)"""
rows = [p for p in (platforms or []) if isinstance(p, dict)]
src = next((p for p in rows if p.get("role") == "source"), None)
best = next((p for p in rows if p.get("is_best")), None)
if best is None:
# pricebot 没标 is_best(如全平台 has_dish_diff「相似替换/仅供参考」→ 不认定权威最低价)
# 但仍有有价行 → 兜底取有价行里最低价当参考 best,避免记录级 best_*/saved 整条落 NULL
# (否则首页价 0.00 / 记录页无最低红框 / 省额丢失 /「累计发现可省」漏计)。**候选含源**:
# 源常年全菜、价可信,源本身最便宜时 best 回落源(saved=0、is_source_best=True),与 _derive
# 「全目标缺菜回落源、不虚报省」同一语义;若排除源强选更贵目标,saved 会变负、倒扣「累计
# 发现可省」(get_stats 对 saved_amount_cents 求和不带 >0 过滤)。
priced = [p for p in rows if p.get("price") is not None]
if priced:
best = min(priced, key=lambda p: (p["price"], p.get("display_order") or 0))
source_price_cents = _yuan_to_cents(src.get("price")) if src else None
best_price_cents = _yuan_to_cents(best.get("price")) if best else None
saved_amount_cents = None
if source_price_cents is not None and best_price_cents is not None:
saved_amount_cents = source_price_cents - best_price_cents
has_valid_target = any(
p.get("role") != "source" and p.get("price") is not None for p in rows
)
# store_name: 优先源行; recompare 场景源平台自己当目标、源行被目标覆盖(pricebot
# _build_platform_rows 有意去重, platforms 无 role=source 行)→ 回退 best 行 → 首个有店名
# 的行(显示现场实际比到的店), 免得记录页店名空掉兜底显示成"比价"。正常比价有源行不走回退。
store_name = (
(src or {}).get("store_name")
or (best or {}).get("store_name")
or next((p.get("store_name") for p in rows if p.get("store_name")), None)
)
return {
"source_platform_id": (src or {}).get("platform_id"),
"source_platform_name": (src or {}).get("platform_name"),
"source_package": (src or {}).get("package"),
"source_price_cents": source_price_cents,
"best_platform_id": (best or {}).get("platform_id"),
"best_platform_name": (best or {}).get("platform_name"),
"best_price_cents": best_price_cents,
"saved_amount_cents": saved_amount_cents,
"is_source_best": (best.get("role") == "source") if best else None,
"store_name": store_name or None,
"status": record_status or ("success" if has_valid_target else "failed"),
} }
@@ -444,8 +286,6 @@ def reserve_daily_start(
business_type: str = "food", business_type: str = "food",
device_id: str | None = None, device_id: str | None = None,
now: datetime | None = None, now: datetime | None = None,
limit: int | None = DAILY_COMPARE_START_LIMIT,
reset_at: datetime | None = None,
) -> tuple[ComparisonRecord, int]: ) -> tuple[ComparisonRecord, int]:
"""Atomically reserve one of a user's 100 Beijing-day comparison starts. """Atomically reserve one of a user's 100 Beijing-day comparison starts.
@@ -471,11 +311,6 @@ def reserve_daily_start(
if existing_at.tzinfo is not None: if existing_at.tzinfo is not None:
existing_at = existing_at.astimezone(CN_TZ).replace(tzinfo=None) existing_at = existing_at.astimezone(CN_TZ).replace(tzinfo=None)
day_start = existing_at.replace(hour=0, minute=0, second=0, microsecond=0) day_start = existing_at.replace(hour=0, minute=0, second=0, microsecond=0)
if reset_at is not None:
reset_start = reset_at
if reset_start.tzinfo is not None:
reset_start = reset_start.astimezone(CN_TZ).replace(tzinfo=None)
day_start = max(day_start, reset_start)
day_end = day_start + timedelta(days=1) day_end = day_start + timedelta(days=1)
used = db.scalar( used = db.scalar(
select(func.count(ComparisonRecord.id)).where( select(func.count(ComparisonRecord.id)).where(
@@ -490,11 +325,6 @@ def reserve_daily_start(
if current.tzinfo is not None: if current.tzinfo is not None:
current = current.astimezone(CN_TZ).replace(tzinfo=None) current = current.astimezone(CN_TZ).replace(tzinfo=None)
day_start = current.replace(hour=0, minute=0, second=0, microsecond=0) day_start = current.replace(hour=0, minute=0, second=0, microsecond=0)
if reset_at is not None:
reset_start = reset_at
if reset_start.tzinfo is not None:
reset_start = reset_start.astimezone(CN_TZ).replace(tzinfo=None)
day_start = max(day_start, reset_start)
day_end = day_start + timedelta(days=1) day_end = day_start + timedelta(days=1)
used = db.scalar( used = db.scalar(
select(func.count(ComparisonRecord.id)).where( select(func.count(ComparisonRecord.id)).where(
@@ -503,7 +333,7 @@ def reserve_daily_start(
ComparisonRecord.created_at < day_end, ComparisonRecord.created_at < day_end,
) )
) or 0 ) or 0
if limit is not None and used >= limit: if used >= DAILY_COMPARE_START_LIMIT:
raise DailyCompareStartLimitExceeded raise DailyCompareStartLimitExceeded
rec = ComparisonRecord( rec = ComparisonRecord(
@@ -583,40 +413,12 @@ def harvest_done(
返回 (记录, 是否本次****落成 success)供调用方据此幂等发一次邀请奖 返回 (记录, 是否本次****落成 success)供调用方据此幂等发一次邀请奖
行不存在(理论上帧0已建;防御)则新建""" 行不存在(理论上帧0已建;防御)则新建"""
results = done_params.get("comparison_results") or [] results = done_params.get("comparison_results") or []
# 展示模型统一数组(pricebot 新增, 每平台一行自带 status/is_best): 原样存, 记录页据此直渲染。 derived = _derive_from_results(results, done_params.get("platform_results"))
# record_status: 记录级结局(success/below_minimum/store_closed/failed), 覆盖老二态派生。 # 菜品:pricebot 已把源单菜品塞进 comparison_results[源行].items
platforms = done_params.get("platforms") or [] items = next((r.get("items") or [] for r in results if r.get("is_source")), [])
record_status = done_params.get("record_status")
# 单源派生: platforms(含 pricebot 权威 is_best)是唯一真相源, best_*/source_*/saved/status
# 全从它取 → 与前端读的 platforms 天然一致; 菜品也取 platforms 源行。老 pricebot 无
# platforms 时回退从 comparison_results 派生(向后兼容)。
if platforms:
derived = _derive_from_platforms(platforms, record_status)
# 菜品优先源行; recompare 无源行 → 回退 best 行 → 首个有菜品的行(同 store_name 回退)
_item_row = (
next((p for p in platforms if isinstance(p, dict) and p.get("role") == "source"), None)
or next((p for p in platforms if isinstance(p, dict) and p.get("is_best")), None)
or next((p for p in platforms if isinstance(p, dict) and p.get("items")), None)
)
items = (_item_row or {}).get("items") or []
else:
derived = _derive_from_results(
results, done_params.get("platform_results"), record_status
)
# pricebot 已把源单菜品塞进 comparison_results[源行].items
items = next((r.get("items") or [] for r in results if r.get("is_source")), [])
# 失败展示原因(#189): platforms / results 两个派生分支的 status 都可能 failed, 统一在此算
fail_reason = (
_derive_fail_display(
done_params.get("information"), done_params.get("platform_results")
)
if derived["status"] == "failed"
else None
)
fields = dict( fields = dict(
business_type=business_type or "food", business_type=business_type or "food",
information=done_params.get("information") or None, information=done_params.get("information") or None,
fail_reason=fail_reason,
# best_deeplink 来自客户端剪贴板采集,harvest 拿不到 → 留空(灰度期 fromComparison 会补; # best_deeplink 来自客户端剪贴板采集,harvest 拿不到 → 留空(灰度期 fromComparison 会补;
# 纯 harvest 行「再次比价」退化为按 package 拉起 App。要精确深链需客户端另传,后续)。 # 纯 harvest 行「再次比价」退化为按 package 拉起 App。要精确深链需客户端另传,后续)。
trace_url=trace_url or done_params.get("trace_url"), trace_url=trace_url or done_params.get("trace_url"),
@@ -624,7 +426,6 @@ def harvest_done(
skipped_dish_count=done_params.get("skipped_dish_count"), skipped_dish_count=done_params.get("skipped_dish_count"),
skipped_dish_names=list(done_params.get("skipped_dish_names") or []), skipped_dish_names=list(done_params.get("skipped_dish_names") or []),
comparison_results=results, comparison_results=results,
platforms=platforms,
items=items, items=items,
product_names=_product_names_from_items(items), product_names=_product_names_from_items(items),
raw_payload=done_params, raw_payload=done_params,
+14 -117
View File
@@ -1,9 +1,9 @@
"""device 表读写(设备注册 / 心跳 / 超时扫描)。""" """device 表读写(设备注册 / 心跳 / 超时扫描)。"""
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime, timedelta from datetime import datetime, timedelta, timezone
from sqlalchemy import and_, or_, select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.device import DeviceLiveness from app.models.device import DeviceLiveness
@@ -24,29 +24,13 @@ def register_or_update(
registration_id: str | None = None, registration_id: str | None = None,
push_vendor: str | None = None, push_vendor: str | None = None,
push_token: str | None = None, push_token: str | None = None,
push_binding_id: str | None = None,
platform: str = "android", platform: str = "android",
app_version: str | None = None, app_version: str | None = None,
) -> DeviceLiveness: ) -> DeviceLiveness:
"""注册设备或更新推送目标;同一设备/token 的旧用户绑定在同一事务内失效""" """注册设备或更新其厂商 push token / 元信息。upsert by (user_id, device_id)"""
normalized_vendor = _normalize_push_vendor(push_vendor) normalized_vendor = _normalize_push_vendor(push_vendor)
normalized_token = push_token.strip() if push_token else None normalized_token = push_token.strip() if push_token else None
normalized_binding = push_binding_id.strip() if push_binding_id else None
device = _get(db, user_id=user_id, device_id=device_id) device = _get(db, user_id=user_id, device_id=device_id)
stale_binding = bool(
device
and normalized_binding
and device.push_binding_id == normalized_binding
and device.push_binding_revoked
)
if not stale_binding:
_release_conflicting_push_bindings(
db,
user_id=user_id,
device_id=device_id,
push_vendor=normalized_vendor,
push_token=normalized_token,
)
if device is None: if device is None:
device = DeviceLiveness( device = DeviceLiveness(
user_id=user_id, user_id=user_id,
@@ -54,22 +38,17 @@ def register_or_update(
registration_id=registration_id, registration_id=registration_id,
push_vendor=normalized_vendor, push_vendor=normalized_vendor,
push_token=normalized_token, push_token=normalized_token,
push_binding_id=normalized_binding,
push_binding_revoked=False,
platform=platform or "android", platform=platform or "android",
app_version=app_version, app_version=app_version,
) )
db.add(device) db.add(device)
else: else:
if registration_id and not stale_binding: if registration_id:
device.registration_id = registration_id device.registration_id = registration_id
if normalized_vendor and not stale_binding: if normalized_vendor:
device.push_vendor = normalized_vendor device.push_vendor = normalized_vendor
if normalized_token and not stale_binding: if normalized_token:
device.push_token = normalized_token device.push_token = normalized_token
if normalized_binding and not stale_binding:
device.push_binding_id = normalized_binding
device.push_binding_revoked = False
if platform: if platform:
device.platform = platform device.platform = platform
if app_version: if app_version:
@@ -88,45 +67,26 @@ def touch_heartbeat(
registration_id: str | None = None, registration_id: str | None = None,
push_vendor: str | None = None, push_vendor: str | None = None,
push_token: str | None = None, push_token: str | None = None,
push_binding_id: str | None = None,
) -> DeviceLiveness: ) -> DeviceLiveness:
"""处理一次心跳(心跳也能自注册)。 """处理一次心跳(心跳也能自注册)。
service 心跳或 accessibility_enabled=true ,刷新存活并把状态机重置回 alive service 心跳或 accessibility_enabled=true ,刷新存活并把状态机重置回 alive
清掉 notified_at(掉线恢复 下次再断才会再推一条) 清掉 notified_at(掉线恢复 下次再断才会再推一条)
""" """
now = datetime.now(UTC) now = datetime.now(timezone.utc)
device = _get(db, user_id=user_id, device_id=device_id) device = _get(db, user_id=user_id, device_id=device_id)
normalized_vendor = _normalize_push_vendor(push_vendor)
normalized_token = push_token.strip() if push_token else None
normalized_binding = push_binding_id.strip() if push_binding_id else None
stale_binding = bool(
device
and normalized_binding
and device.push_binding_id == normalized_binding
and device.push_binding_revoked
)
if (normalized_token or normalized_binding) and not stale_binding:
_release_conflicting_push_bindings(
db,
user_id=user_id,
device_id=device_id,
push_vendor=normalized_vendor,
push_token=normalized_token,
)
if device is None: if device is None:
device = DeviceLiveness(user_id=user_id, device_id=device_id) device = DeviceLiveness(user_id=user_id, device_id=device_id)
db.add(device) db.add(device)
if registration_id and not stale_binding: if registration_id:
device.registration_id = registration_id device.registration_id = registration_id
if normalized_vendor and not stale_binding: normalized_vendor = _normalize_push_vendor(push_vendor)
normalized_token = push_token.strip() if push_token else None
if normalized_vendor:
device.push_vendor = normalized_vendor device.push_vendor = normalized_vendor
if normalized_token and not stale_binding: if normalized_token:
device.push_token = normalized_token device.push_token = normalized_token
if normalized_binding and not stale_binding:
device.push_binding_id = normalized_binding
device.push_binding_revoked = False
device.last_report_protection_on = accessibility_enabled device.last_report_protection_on = accessibility_enabled
if accessibility_enabled: if accessibility_enabled:
@@ -147,7 +107,7 @@ def list_overdue(db: Session, *, timeout_minutes: int) -> list[DeviceLiveness]:
即使没有厂商 token 也要检出,后续由 kill_alert_pending 走客户端进 App 后兜底提醒 即使没有厂商 token 也要检出,后续由 kill_alert_pending 走客户端进 App 后兜底提醒
""" """
cutoff = datetime.now(UTC) - timedelta(minutes=timeout_minutes) cutoff = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes)
stmt = select(DeviceLiveness).where( stmt = select(DeviceLiveness).where(
DeviceLiveness.ever_protected.is_(True), DeviceLiveness.ever_protected.is_(True),
DeviceLiveness.liveness_state == "alive", DeviceLiveness.liveness_state == "alive",
@@ -166,7 +126,7 @@ def mark_notified(db: Session, *, device_id_pk: int) -> None:
device = db.get(DeviceLiveness, device_id_pk) device = db.get(DeviceLiveness, device_id_pk)
if device is not None: if device is not None:
device.liveness_state = "notified" device.liveness_state = "notified"
device.notified_at = datetime.now(UTC) device.notified_at = datetime.now(timezone.utc)
device.kill_alert_pending = True device.kill_alert_pending = True
db.commit() db.commit()
@@ -217,69 +177,6 @@ def list_push_targets(db: Session, *, user_id: int) -> list[DeviceLiveness]:
return targets return targets
def unregister_push_binding(db: Session, *, user_id: int, device_id: str) -> None:
"""当前用户主动退出:清推送归属但保留设备存活历史。"""
device = _get(db, user_id=user_id, device_id=device_id)
if device is not None:
_clear_push_binding(device)
db.commit()
def unregister_push_binding_deferred(
db: Session, *, device_id: str, push_binding_id: str
) -> None:
"""离线退出补偿;只撤销完全匹配的旧登录会话,且始终幂等。"""
stmt = select(DeviceLiveness).where(
DeviceLiveness.device_id == device_id,
DeviceLiveness.push_binding_id == push_binding_id,
)
device = db.execute(stmt).scalar_one_or_none()
if device is not None:
_clear_push_binding(device)
db.commit()
def _release_conflicting_push_bindings(
db: Session,
*,
user_id: int,
device_id: str,
push_vendor: str | None,
push_token: str | None,
) -> None:
"""把设备或 token 从其他记录转移走,当前 (user,device) 行除外。"""
conflicts = [DeviceLiveness.device_id == device_id]
if push_vendor and push_token:
conflicts.append(
and_(
DeviceLiveness.push_vendor == push_vendor,
DeviceLiveness.push_token == push_token,
)
)
stmt = select(DeviceLiveness).where(
or_(*conflicts),
~and_(
DeviceLiveness.user_id == user_id,
DeviceLiveness.device_id == device_id,
),
)
released = False
for old in db.execute(stmt).scalars():
_clear_push_binding(old)
released = True
if released:
# 唯一约束下必须先落旧行清理,再把同一 token 写给新行。
db.flush()
def _clear_push_binding(device: DeviceLiveness) -> None:
device.registration_id = None
device.push_vendor = None
device.push_token = None
# binding_id 留作撤销墓碑;同一旧会话的在途请求不能重新认领 token。
device.push_binding_revoked = bool(device.push_binding_id)
def _normalize_push_vendor(push_vendor: str | None) -> str | None: def _normalize_push_vendor(push_vendor: str | None) -> str | None:
if not push_vendor: if not push_vendor:
return None return None
+184 -463
View File
@@ -1,136 +1,95 @@
"""双场景引导视频配置与 prepare/start/reward 状态机。""" """新手引导视频:运营配置读写 + 播放计次 + 发币。
**配置**(开关 / 视频地址 / 前几次 / 每次金币)整体作为一个 JSON 存进通用 app_config
(key=coupon_guide_video),写法完全对齐 feedback_qr 不进 CONFIG_DEFS,所以不会污染
系统配置页的通用列表,由本模块独占维护
**计次**按账号(user_id)**开播即计数**:客户端每次要展示领券等候浮层时调
`/api/v1/guide-video/start`,命中则当场写一行 guide_video_play(status='playing')
已用次数 = 该账号的行数,达到 max_plays 后不再下发,客户端改放广告(原逻辑)
COUNT 判定本身无锁,真正卡住次数上限的是 (user_id, seq) 唯一键:并发 /start 只能成一个
**发币**幂等键是 play_token,落地方式是 `status='playing' 'granted'` **条件更新**:
同一次播放重复上报只入账一次(网络重试 / 关闭与播完同时触发都靠它挡住)
中途关闭也照发 产品拍板中途关闭也算看完
两处都是直接铸币的路径,改动前先看 `start_play` / `grant_play` 上的并发注释
"""
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import datetime, timedelta from datetime import datetime
from pathlib import Path
from typing import Any from typing import Any
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core import limit_policy, media, rewards from app.core import rewards
from app.core.config import settings
from app.models.app_config import AppConfig from app.models.app_config import AppConfig
from app.models.guide_video import GuideVideoPlay from app.models.guide_video import GuideVideoPlay
from app.models.wallet import CoinAccount
from app.repositories import wallet as crud_wallet from app.repositories import wallet as crud_wallet
SCENES = ("coupon", "comparison") _KEY = "coupon_guide_video"
_KEY_BY_SCENE = {
"coupon": "coupon_guide_video", #: 金币流水 biz_type。客户端收益明细按它显示「新手引导视频奖励」。
"comparison": "comparison_guide_video",
}
BIZ_TYPE = "guide_video" BIZ_TYPE = "guide_video"
CIRCLE_COUNT = 10
PLAN_TTL = timedelta(minutes=10) # 默认值 = 「运营还没配」时的行为:video_url 为空 → 一律不下发引导视频,浮层维持现状(放广告)。
# 0 表示全局暂停播放;白名单页与旧引导视频配置页必须接受同一口径, # 所以本功能上线后**不配视频就等于没上线**,不会影响存量用户。
# 否则在白名单页设为 0 后,旧页面连金币/开关等无关字段也无法保存。 _DEFAULTS: dict[str, Any] = {
MIN_PLAYS = 0 "enabled": True,
"video_url": None, # None/空 = 未配片 → 不下发,浮层照旧放广告
"max_plays": 3, # 每个账号前 N 次浮层放引导视频
"reward_coin": 120, # 每次固定金币
}
_FIELDS = tuple(_DEFAULTS.keys())
# 后台可配范围的护栏:防手滑把次数/金币填成天文数字(配置直接决定发币)。
MAX_PLAYS_LIMIT = 50 MAX_PLAYS_LIMIT = 50
MIN_REWARD_COIN = 10
REWARD_COIN_LIMIT = 10_000 REWARD_COIN_LIMIT = 10_000
_DEFAULTS: dict[str, Any] = {
"enabled": False,
"video_url": None,
"max_plays": 3,
"reward_coin": 100,
"duration_ms": None,
"video_codec": None,
"audio_codec": None,
"analysis_status": "missing",
"analysis_error": None,
"config_version": 0,
}
_FIELDS = tuple(_DEFAULTS)
# ===== 配置 =====
class PlayStateError(Exception):
def __init__(self, code: str, message: str, *, reprepare_required: bool = False):
super().__init__(message)
self.code = code
self.message = message
self.reprepare_required = reprepare_required
def detail(self) -> dict[str, Any]:
return {
"code": self.code,
"message": self.message,
"retryable": False,
"reprepare_required": self.reprepare_required,
}
def _now() -> datetime:
return datetime.now(rewards.CN_TZ).replace(tzinfo=None)
def _config_key(scene: str) -> str:
if scene not in _KEY_BY_SCENE:
raise ValueError(f"unsupported guide video scene: {scene}")
return _KEY_BY_SCENE[scene]
def _merge(raw: Any) -> dict[str, Any]: def _merge(raw: Any) -> dict[str, Any]:
"""DB 里(可能不全的)dict 叠加到默认上,得到完整配置(4 个字段,无 updated_at)。"""
out = dict(_DEFAULTS) out = dict(_DEFAULTS)
if isinstance(raw, dict): if isinstance(raw, dict):
for key in _FIELDS: for k in _FIELDS:
if key in raw and raw[key] is not None: v = raw.get(k)
out[key] = raw[key] if v is not None:
out[k] = v
return out return out
def _public_config(cfg: dict[str, Any]) -> dict[str, Any]: def get_config(db: Session) -> dict[str, Any]:
duration = int(cfg.get("duration_ms") or 0) """完整配置 + updated_at(admin 读 / 业务读共用)。"""
reward = int(cfg.get("reward_coin") or 0) row = db.get(AppConfig, _KEY)
return { cfg = _merge(row.value if row is not None else None)
**cfg,
"duration_ms": duration or None,
"circle_count": CIRCLE_COUNT,
"circle_duration_ms": duration / CIRCLE_COUNT if duration else None,
"reward_per_circle": reward // CIRCLE_COUNT,
}
def get_config(db: Session, scene: str = "coupon") -> dict[str, Any]:
row = db.get(AppConfig, _config_key(scene))
cfg = _public_config(_merge(row.value if row is not None else None))
if scene == "coupon":
cfg["max_plays"] = limit_policy.resolve(
db, "guide.video.lifetime"
).global_limit
cfg["updated_at"] = row.updated_at.isoformat() if row is not None and row.updated_at else None cfg["updated_at"] = row.updated_at.isoformat() if row is not None and row.updated_at else None
return cfg return cfg
def _validate_settings(max_plays: int, reward_coin: int) -> None: def _write(db: Session, value: dict[str, Any], *, admin_id: int, commit: bool) -> dict[str, Any]:
if not MIN_PLAYS <= max_plays <= MAX_PLAYS_LIMIT: """整体覆写该行(value 须为完整字段 dict),返回合并后的完整配置(含 updated_at)。"""
raise ValueError(f"播放次数必须在 {MIN_PLAYS}{MAX_PLAYS_LIMIT} 之间") row = db.get(AppConfig, _KEY)
if not MIN_REWARD_COIN <= reward_coin <= REWARD_COIN_LIMIT:
raise ValueError(f"金币总价必须在 {MIN_REWARD_COIN}{REWARD_COIN_LIMIT} 之间")
if reward_coin % CIRCLE_COUNT:
raise ValueError("金币总价必须是 10 的倍数")
def _write(
db: Session, value: dict[str, Any], *, scene: str, admin_id: int, commit: bool
) -> dict[str, Any]:
key = _config_key(scene)
row = db.get(AppConfig, key)
if row is None: if row is None:
row = AppConfig(key=key, value=value, updated_by_admin_id=admin_id) row = AppConfig(key=_KEY, value=value, updated_by_admin_id=admin_id)
db.add(row) db.add(row)
else: else:
row.value = value row.value = value # 整体重新赋值,SQLAlchemy 才侦测得到变更
row.updated_by_admin_id = admin_id row.updated_by_admin_id = admin_id
if commit: if commit:
db.commit() db.commit()
db.refresh(row) db.refresh(row)
else: else:
db.flush() db.flush()
return get_config(db, scene) out = _merge(row.value)
out["updated_at"] = row.updated_at.isoformat() if row.updated_at else None
return out
def update_config( def update_config(
@@ -139,431 +98,193 @@ def update_config(
enabled: bool | None = None, enabled: bool | None = None,
max_plays: int | None = None, max_plays: int | None = None,
reward_coin: int | None = None, reward_coin: int | None = None,
scene: str = "coupon",
admin_id: int, admin_id: int,
commit: bool = True, commit: bool = True,
) -> tuple[dict[str, Any], dict[str, Any]]: ) -> tuple[dict[str, Any], dict[str, Any]]:
before = get_config(db, scene) """改开关 / 次数 / 金币(只改传了的字段;视频走 set_video)。返回 (before, after) 供审计。"""
raw = {key: before[key] for key in _FIELDS} row = db.get(AppConfig, _KEY)
candidate_plays = int(max_plays if max_plays is not None else raw["max_plays"]) before = _merge(row.value if row is not None else None)
candidate_reward = int(reward_coin if reward_coin is not None else raw["reward_coin"]) new_value = {k: before[k] for k in _FIELDS}
_validate_settings(candidate_plays, candidate_reward)
if enabled is True and (
not raw.get("video_url")
or raw.get("analysis_status") != "valid"
or not raw.get("duration_ms")
):
raise ValueError("请先上传并通过分析的视频,再启用")
if enabled is not None: if enabled is not None:
raw["enabled"] = enabled new_value["enabled"] = enabled
raw["max_plays"] = candidate_plays if max_plays is not None:
raw["reward_coin"] = candidate_reward new_value["max_plays"] = max(0, min(int(max_plays), MAX_PLAYS_LIMIT))
raw["config_version"] = int(raw.get("config_version") or 0) + 1 if reward_coin is not None:
after = _write( new_value["reward_coin"] = max(0, min(int(reward_coin), REWARD_COIN_LIMIT))
db, after = _write(db, new_value, admin_id=admin_id, commit=commit)
raw,
scene=scene,
admin_id=admin_id,
commit=False,
)
if scene == "coupon" and max_plays is not None:
# 兼容仍调用旧专用接口的客户端/脚本,并把统一策略全局值一并更新。
limit_policy.set_global_limits(
db,
{"guide.video.lifetime": candidate_plays},
admin_id=admin_id,
commit=False,
)
# set_global_limits 已 flush;即使由 admin 路由统一在外层提交,也要把
# 同一事务内的最新统一策略值写进审计 after 快照。
after = get_config(db, scene)
if commit:
db.commit()
after = get_config(db, scene)
return before, after return before, after
def set_video( def set_video(
db: Session, db: Session, video_url: str | None, *, admin_id: int, commit: bool = True
video_url: str | None,
*,
analysis: dict[str, Any] | None = None,
scene: str = "coupon",
admin_id: int,
commit: bool = True,
) -> tuple[dict[str, Any], dict[str, Any]]: ) -> tuple[dict[str, Any], dict[str, Any]]:
before = get_config(db, scene) """设置/清空引导视频地址。返回 (before, after);before['video_url'] 供调用方删旧文件。"""
raw = {key: before[key] for key in _FIELDS} row = db.get(AppConfig, _KEY)
raw["video_url"] = video_url before = _merge(row.value if row is not None else None)
if video_url: new_value = {k: before[k] for k in _FIELDS}
if not analysis or analysis.get("analysis_status") != "valid": new_value["video_url"] = video_url
raise ValueError("视频必须先完成服务端分析") after = _write(db, new_value, admin_id=admin_id, commit=commit)
for key in (
"duration_ms", "video_codec", "audio_codec",
"analysis_status", "analysis_error",
):
raw[key] = analysis.get(key)
else:
raw.update(
duration_ms=None,
video_codec=None,
audio_codec=None,
analysis_status="missing",
analysis_error=None,
)
raw["enabled"] = False
raw["config_version"] = int(raw.get("config_version") or 0) + 1
after = _write(db, raw, scene=scene, admin_id=admin_id, commit=commit)
return before, after return before, after
def used_plays( # ===== 播放计次 =====
db: Session,
user_id: int,
scene: str = "coupon", def used_plays(db: Session, user_id: int) -> int:
*, """该账号已用掉的引导视频次数(开播即算,含未发币的)。"""
reset_at: datetime | None = None, return int(
) -> int: db.execute(
stmt = select(func.count()).select_from(GuideVideoPlay).where( select(func.count()).select_from(GuideVideoPlay).where(
GuideVideoPlay.user_id == user_id, GuideVideoPlay.user_id == user_id
GuideVideoPlay.scene == scene, )
GuideVideoPlay.status != "prepared", ).scalar_one()
) )
if reset_at is not None:
# guide_video_play 使用北京时间 naive 墙钟;白名单重置点是带时区时间。
reset_value = reset_at.astimezone(rewards.CN_TZ).replace(tzinfo=None)
stmt = stmt.where(GuideVideoPlay.started_at >= reset_value)
return int(db.execute(stmt).scalar_one())
def _effective_quota( def play_stats(db: Session) -> dict[str, int]:
db: Session, """全站播放统计(admin 页展示):总播放次数 / 其中已发币次数。"""
user_id: int, total = int(
scene: str, db.execute(select(func.count()).select_from(GuideVideoPlay)).scalar_one()
cfg: dict[str, Any],
) -> tuple[int | None, datetime | None]:
if scene != "coupon":
return int(cfg.get("max_plays") or 0), None
policy = limit_policy.resolve_for_user(
db, "guide.video.lifetime", user_id
) )
return policy.limit, policy.reset_at granted = int(
db.execute(
select(func.count()).select_from(GuideVideoPlay).where(
GuideVideoPlay.status == "granted"
)
).scalar_one()
)
return {"total_plays": total, "granted_plays": granted}
def _remaining(maximum: int | None, used: int) -> int: def start_play(
# API 字段保持整数兼容;不限时使用足够大的展示值,不参与服务端判定。 db: Session, user_id: int, *, scene: str = "coupon", commit: bool = True
exposed_maximum = maximum if maximum is not None else 1_000_000
return max(0, exposed_maximum - used)
def _prepare_miss(
scene: str,
reason: str,
cfg: dict[str, Any],
used: int,
maximum: int | None,
) -> dict[str, Any]: ) -> dict[str, Any]:
return { """决定这次浮层是否放引导视频;命中则**当场计次**并返回 play_token。
"should_play": False,
"reason": reason,
"scene": scene,
"video_url": None,
"play_token": "",
"config_version": int(cfg.get("config_version") or 0),
"duration_ms": 0,
"circle_count": CIRCLE_COUNT,
"circle_duration_ms": 0,
"reward_coin": int(cfg.get("reward_coin") or 0),
"reward_per_circle": int(cfg.get("reward_coin") or 0) // CIRCLE_COUNT,
"seq": used,
"remaining": _remaining(maximum, used),
"expires_at": None,
}
返回 dict:
should_play 是否放引导视频(False 客户端照旧放广告)
video_url 相对地址(/media/...);客户端自行拼 BASE_URL
play_token 发币幂等键(should_play=False 时为空串)
reward_coin 播完/中途关闭都发的固定金币
seq / remaining 第几次 / 发完这次还剩几次(仅展示与排查用)
"""
cfg = get_config(db)
video_url = (cfg.get("video_url") or "").strip()
max_plays = int(cfg.get("max_plays") or 0)
reward_coin = int(cfg.get("reward_coin") or 0)
used = used_plays(db, user_id)
def prepare_play(db: Session, user_id: int, *, scene: str = "coupon") -> dict[str, Any]: def _miss(used_now: int) -> dict[str, Any]:
cfg = get_config(db, scene) return {
maximum, reset_at = _effective_quota(db, user_id, scene, cfg) "should_play": False,
used = used_plays(db, user_id, scene, reset_at=reset_at) "video_url": None,
video_url = str(cfg.get("video_url") or "").strip() "play_token": "",
duration = int(cfg.get("duration_ms") or 0) "reward_coin": reward_coin,
if not cfg.get("enabled"): "seq": used_now,
return _prepare_miss(scene, "disabled", cfg, used, maximum) "remaining": max(0, max_plays - used_now),
if not video_url or cfg.get("analysis_status") != "valid" or duration <= 0: }
return _prepare_miss(
scene, "video_unavailable", cfg, used, maximum
)
if maximum is not None and used >= maximum:
return _prepare_miss(
scene, "play_limit_reached", cfg, used, maximum
)
now = _now() if not cfg.get("enabled") or not video_url or max_plays <= 0 or used >= max_plays:
return _miss(used)
seq = used + 1
play = GuideVideoPlay( play = GuideVideoPlay(
user_id=user_id, user_id=user_id,
play_token=uuid.uuid4().hex, play_token=uuid.uuid4().hex,
scene=scene, scene=scene,
seq=None, seq=seq,
video_url=video_url, video_url=video_url,
coin=int(cfg["reward_coin"]), coin=0,
duration_ms=duration, status="playing",
config_version=int(cfg["config_version"]),
settled_circles=0,
status="prepared",
completed=0, completed=0,
prepared_at=now, started_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
expires_at=now + PLAN_TTL,
started_at=None,
) )
db.add(play) db.add(play)
db.commit() # 上面的 COUNT 判定是无锁 check-then-insert:并发 /start 会都算出同一个 seq。
# (user_id, seq) 唯一键让只有一个能落库,其余撞键 → 回滚后按"这次不放视频"降级,
# 客户端照旧走广告链路。没有它,并发就能绕过 max_plays 无限刷金币。
try:
if commit:
db.commit()
else:
db.flush()
except IntegrityError:
db.rollback()
return _miss(used_plays(db, user_id))
return { return {
"should_play": True, "should_play": True,
"reason": "ready",
"scene": scene,
"video_url": video_url, "video_url": video_url,
"play_token": play.play_token, "play_token": play.play_token,
"config_version": play.config_version, "reward_coin": reward_coin,
"duration_ms": duration, "seq": seq,
"circle_count": CIRCLE_COUNT, "remaining": max(0, max_plays - seq),
"circle_duration_ms": duration / CIRCLE_COUNT,
"reward_coin": play.coin,
"reward_per_circle": play.coin // CIRCLE_COUNT,
"seq": used + 1,
"remaining": _remaining(maximum, used + 1),
"expires_at": play.expires_at.isoformat(),
} }
def _find_play(db: Session, user_id: int, token: str) -> GuideVideoPlay | None: def _find_play(db: Session, user_id: int, token: str) -> GuideVideoPlay | None:
"""按 (play_token, user_id) 取播放行 —— 带 user_id 是防拿别人的 token 来兑。"""
return db.execute( return db.execute(
select(GuideVideoPlay).where( select(GuideVideoPlay).where(
GuideVideoPlay.play_token == token.strip(), GuideVideoPlay.play_token == token,
GuideVideoPlay.user_id == user_id, GuideVideoPlay.user_id == user_id,
) )
).scalar_one_or_none() ).scalar_one_or_none()
def _start_out( def grant_play(
play: GuideVideoPlay, db: Session, user_id: int, *, play_token: str, completed: bool
maximum: int | None,
used: int,
status: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
assert play.started_at is not None and play.seq is not None and play.video_url """按 play_token 发这次引导视频的金币(幂等)。播完 / 中途关闭都发。
return {
"started": True,
"status": status,
"play_token": play.play_token,
"scene": play.scene,
"video_url": play.video_url,
"config_version": play.config_version,
"duration_ms": play.duration_ms,
"circle_count": CIRCLE_COUNT,
"circle_duration_ms": play.duration_ms / CIRCLE_COUNT,
"reward_coin": play.coin,
"reward_per_circle": play.coin // CIRCLE_COUNT,
"seq": play.seq,
"remaining": _remaining(maximum, used),
"started_at": play.started_at.isoformat(),
}
返回 {granted, coin, status}:granted=True 表示**本次调用真的入账了**;
重复上报返回 granted=False + 已发金币(客户端据此不重复累加 toast 金额)
"""
token = (play_token or "").strip()
# 金币额度以**服务端配置**为准,不信客户端(客户端只上报"播完/关闭")。
coin = int(get_config(db).get("reward_coin") or 0)
def start_play(db: Session, user_id: int, *, play_token: str) -> dict[str, Any]: # 幂等核心:把 status 放进 WHERE 做条件更新(compare-and-set),而不是"先读再判再写"。
play = _find_play(db, user_id, play_token) # 「播完」与「✕ 关闭」抢跑、或客户端超时重试时,两个请求会都读到 status='playing',
if play is None: # 无锁的话就都往下发币、都 commit,金币入账两次(不用恶意,重试就会中招)。改成条件更新后
raise PlayStateError("play_not_found", "播放计划不存在") # 并发里只有一条 rowcount=1,另一条拿 0 → 按已发返回,不二次铸币。
cfg = get_config(db, play.scene) # (PG READ COMMITTED 下后到的 UPDATE 阻塞到对手提交,再按新版本重判 status;SQLite 写串行。)
maximum, reset_at = _effective_quota( #
db, user_id, play.scene, cfg # 别指望 IntegrityError 兜底:这里只 UPDATE 不 INSERT,撞不到 uq_guide_video_play_token;
) # 而 biz_type='guide_video' 的金币流水也不在 ux_coin_transaction_task_ref 的谓词
used = used_plays( # (biz_type LIKE 'task%')覆盖范围内 —— 两个唯一键在这条路径上都是不生效的。
db, user_id, play.scene, reset_at=reset_at
)
if play.status in {"started", "completed"}:
return _start_out(play, maximum, used, "already_started")
if play.status != "prepared":
raise PlayStateError("play_not_found", "播放计划不可用")
now = _now()
if play.expires_at is None or now > play.expires_at:
raise PlayStateError(
"plan_expired", "播放计划已过期,请重新获取",
reprepare_required=True,
)
if int(cfg["config_version"]) != play.config_version:
raise PlayStateError(
"config_changed", "视频配置已变化,请重新获取",
reprepare_required=True,
)
if maximum is not None and used >= maximum:
raise PlayStateError("play_limit_reached", "播放次数已用完")
# seq 在数据库中是账号+场景生命周期唯一值;白名单重置只重置额度,
# 不能从 1 重新编号,否则会与历史记录冲突。
play.seq = used_plays(db, user_id, play.scene) + 1
play.status = "started"
play.started_at = now
try:
db.commit()
db.refresh(play)
except IntegrityError as exc:
db.rollback()
raise PlayStateError("play_limit_reached", "并发起播冲突,请重新获取") from exc
return _start_out(play, maximum, used + 1, "started")
def _coin_balance(db: Session, user_id: int) -> int:
account = db.get(CoinAccount, user_id)
return int(account.coin_balance if account else 0)
def grant_circle(
db: Session, user_id: int, *, play_token: str, circle: int
) -> dict[str, Any]:
play = _find_play(db, user_id, play_token)
balance = _coin_balance(db, user_id)
base = {
"granted": False,
"retryable": False,
"retry_after_ms": 0,
"circle": circle,
"granted_coin": 0,
"settled_circles": int(play.settled_circles if play else 0),
"coin_balance": balance,
}
if play is None:
return {**base, "status": "not_found"}
if play.status not in {"started", "completed"} or play.started_at is None:
return {**base, "status": "not_started"}
settled = int(play.settled_circles)
if circle <= settled:
return {
**base,
"status": "already_granted",
"granted_coin": play.coin // CIRCLE_COUNT,
"settled_circles": settled,
}
if settled >= CIRCLE_COUNT:
return {**base, "status": "finished", "settled_circles": settled}
if circle != settled + 1:
return {
**base,
"status": "out_of_order",
"retryable": True,
"settled_circles": settled,
}
# 向上取整到毫秒,绝不让第 N 圈早于精确 N/10 边界发奖。
boundary_ms = (circle * play.duration_ms + CIRCLE_COUNT - 1) // CIRCLE_COUNT
elapsed_ms = max(0, int((_now() - play.started_at).total_seconds() * 1000))
if elapsed_ms < boundary_ms:
return {
**base,
"status": "too_early",
"retryable": True,
"retry_after_ms": boundary_ms - elapsed_ms,
"settled_circles": settled,
}
final = circle == CIRCLE_COUNT
won = db.execute( won = db.execute(
update(GuideVideoPlay) update(GuideVideoPlay)
.where( .where(
GuideVideoPlay.id == play.id, GuideVideoPlay.play_token == token,
GuideVideoPlay.status == "started", GuideVideoPlay.user_id == user_id,
GuideVideoPlay.settled_circles == settled, GuideVideoPlay.status == "playing",
) )
.values( .values(
settled_circles=circle, status="granted",
status="completed" if final else "started", coin=coin,
completed=1 if final else 0, completed=1 if completed else 0,
granted_at=_now() if final else None, granted_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
) )
.execution_options(synchronize_session=False) .execution_options(synchronize_session=False)
).rowcount ).rowcount
if not won: if not won:
# 没抢到:token 不存在 / 不是本人的 / 已被另一次上报发过。回滚拿干净快照再区分两者
# (对手此时必然已提交 —— 我们就是被它挡下的,所以读得到它写的 coin)。
db.rollback() db.rollback()
fresh = _find_play(db, user_id, play_token) play = _find_play(db, user_id, token)
fresh_settled = int(fresh.settled_circles if fresh else 0) if play is None:
return { return {"granted": False, "coin": 0, "status": "not_found"}
**base, return {"granted": False, "coin": play.coin, "status": "already_granted"}
"status": "already_granted" if circle <= fresh_settled else "out_of_order",
"retryable": circle > fresh_settled,
"granted_coin": (
play.coin // CIRCLE_COUNT if circle <= fresh_settled else 0
),
"settled_circles": fresh_settled,
"coin_balance": _coin_balance(db, user_id),
}
per_circle = play.coin // CIRCLE_COUNT if coin > 0:
account, _ = crud_wallet.grant_coins( crud_wallet.grant_coins(
db, db,
user_id, user_id,
per_circle, coin,
biz_type=BIZ_TYPE, biz_type=BIZ_TYPE,
ref_id=f"{play.play_token}:{circle}", ref_id=token,
remark=f"新手引导视频{circle}奖励", remark="新手引导视频奖励",
) )
try: db.commit()
db.commit() return {"granted": True, "coin": coin, "status": "granted"}
except IntegrityError:
# 流水唯一键是第二道幂等保险;若撞键,回滚后返回权威状态。
db.rollback()
fresh = _find_play(db, user_id, play_token)
return {
**base,
"status": "already_granted",
"granted_coin": play.coin // CIRCLE_COUNT,
"settled_circles": int(fresh.settled_circles if fresh else settled),
"coin_balance": _coin_balance(db, user_id),
}
return {
**base,
"granted": True,
"status": "granted",
"granted_coin": per_circle,
"settled_circles": circle,
"coin_balance": int(account.coin_balance),
}
def cleanup_old_videos(db: Session, *, protection: timedelta = PLAN_TTL) -> int:
"""清理超过保护期且未被当前配置/有效计划/近期起播引用的托管旧片。"""
now = _now()
protected_urls: set[str] = set()
for scene in SCENES:
url = get_config(db, scene).get("video_url")
if url:
protected_urls.add(str(url))
protected_urls.update(
str(url)
for url in db.execute(
select(GuideVideoPlay.video_url).where(
GuideVideoPlay.video_url.is_not(None),
(
(
(GuideVideoPlay.status == "prepared")
& (GuideVideoPlay.expires_at >= now)
)
| (
(GuideVideoPlay.status == "started")
& (GuideVideoPlay.started_at >= now - protection)
)
),
)
).scalars()
if url
)
directory = Path(settings.MEDIA_ROOT) / "guide_video"
if not directory.is_dir():
return 0
cutoff = now.timestamp() - protection.total_seconds()
removed = 0
for path in directory.glob("guide_*.mp4"):
url = f"{settings.MEDIA_URL_PREFIX}/guide_video/{path.name}"
try:
if url not in protected_urls and path.stat().st_mtime <= cutoff:
media.delete_guide_video(url)
removed += 1
except OSError:
continue
return removed
+7 -29
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
import math import math
from datetime import UTC, datetime, timedelta from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -10,22 +10,9 @@ from sqlalchemy.orm import Session
from app.models.phone_rebind_log import PhoneRebindLog from app.models.phone_rebind_log import PhoneRebindLog
def rebound_within_days( def rebound_within_days(db: Session, phone: str, days: int) -> bool:
db: Session,
phone: str,
days: int,
*,
reset_at: datetime | None = None,
) -> bool:
"""该手机号在最近 days 天内是否换绑过(命中 → 禁止再次换绑)。""" """该手机号在最近 days 天内是否换绑过(命中 → 禁止再次换绑)。"""
since = datetime.now(UTC) - timedelta(days=days) since = datetime.now(timezone.utc) - timedelta(days=days)
if reset_at is not None:
reset_value = (
reset_at.replace(tzinfo=UTC)
if reset_at.tzinfo is None
else reset_at.astimezone(UTC)
)
since = max(since, reset_value)
stmt = ( stmt = (
select(PhoneRebindLog.id) select(PhoneRebindLog.id)
.where(PhoneRebindLog.phone == phone, PhoneRebindLog.rebound_at >= since) .where(PhoneRebindLog.phone == phone, PhoneRebindLog.rebound_at >= since)
@@ -34,25 +21,16 @@ def rebound_within_days(
return db.execute(stmt).first() is not None return db.execute(stmt).first() is not None
def remaining_block_days( def remaining_block_days(db: Session, phone: str, days: int) -> int:
db: Session,
phone: str,
days: int,
*,
reset_at: datetime | None = None,
) -> int:
"""距离该手机号可再次换绑还剩几天(向上取整;无记录返回 0)。""" """距离该手机号可再次换绑还剩几天(向上取整;无记录返回 0)。"""
conditions = [PhoneRebindLog.phone == phone]
if reset_at is not None:
conditions.append(PhoneRebindLog.rebound_at >= reset_at)
last = db.execute( last = db.execute(
select(func.max(PhoneRebindLog.rebound_at)).where(*conditions) select(func.max(PhoneRebindLog.rebound_at)).where(PhoneRebindLog.phone == phone)
).scalar_one_or_none() ).scalar_one_or_none()
if last is None: if last is None:
return 0 return 0
if last.tzinfo is None: # SQLite 取回 naive datetime,按 UTC 归一 if last.tzinfo is None: # SQLite 取回 naive datetime,按 UTC 归一
last = last.replace(tzinfo=UTC) last = last.replace(tzinfo=timezone.utc)
remaining = (last + timedelta(days=days) - datetime.now(UTC)).total_seconds() remaining = (last + timedelta(days=days) - datetime.now(timezone.utc)).total_seconds()
return max(0, math.ceil(remaining / 86400)) return max(0, math.ceil(remaining / 86400))
+37 -92
View File
@@ -9,10 +9,15 @@ from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core import limit_policy from app.core.config_schema import (
RISK_COMPARE_DAILY_THRESHOLD_KEY,
RISK_ONECLICK_DAILY_THRESHOLD_KEY,
RISK_SMS_HOURLY_THRESHOLD_KEY,
)
from app.models.app_config import AppConfig from app.models.app_config import AppConfig
from app.models.comparison import ComparisonRecord from app.models.comparison import ComparisonRecord
from app.models.risk import BehaviorEvent, RiskIncident, SubjectRestriction from app.models.risk import BehaviorEvent, RiskIncident, SubjectRestriction
from app.repositories import app_config
CN_TZ = ZoneInfo("Asia/Shanghai") CN_TZ = ZoneInfo("Asia/Shanghai")
@@ -36,6 +41,7 @@ class RuleSpec:
code: str code: str
event_type: str event_type: str
subject_type: str subject_type: str
threshold_key: str
window: str window: str
count_outcomes: tuple[str, ...] count_outcomes: tuple[str, ...]
@@ -45,6 +51,7 @@ RULES: dict[str, RuleSpec] = {
code=RULE_SMS_HOURLY, code=RULE_SMS_HOURLY,
event_type=EVENT_SMS_SEND, event_type=EVENT_SMS_SEND,
subject_type="device", subject_type="device",
threshold_key=RISK_SMS_HOURLY_THRESHOLD_KEY,
window="hour", window="hour",
count_outcomes=("success",), count_outcomes=("success",),
), ),
@@ -52,23 +59,22 @@ RULES: dict[str, RuleSpec] = {
code=RULE_ONECLICK_DAILY, code=RULE_ONECLICK_DAILY,
event_type=EVENT_ONECLICK_LOGIN, event_type=EVENT_ONECLICK_LOGIN,
subject_type="device", subject_type="device",
threshold_key=RISK_ONECLICK_DAILY_THRESHOLD_KEY,
window="day", window="day",
count_outcomes=("success", "failed"), count_outcomes=("success", "failed"),
), ),
} }
RISK_LIMIT_RULE_CODES: dict[str, str] = { RULE_THRESHOLD_KEYS: dict[str, str] = {
RULE_SMS_HOURLY: "risk.sms.hourly", RULE_SMS_HOURLY: RISK_SMS_HOURLY_THRESHOLD_KEY,
RULE_ONECLICK_DAILY: "risk.oneclick.daily", RULE_ONECLICK_DAILY: RISK_ONECLICK_DAILY_THRESHOLD_KEY,
RULE_COMPARE_DAILY: "risk.compare.daily", RULE_COMPARE_DAILY: RISK_COMPARE_DAILY_THRESHOLD_KEY,
} }
def get_rule_threshold(db: Session, rule_code: str) -> int: def get_rule_threshold(db: Session, rule_code: str) -> int:
"""读取规则当前阈值;配置表为空时回退上线前的 5/20/100 默认值。""" """读取规则当前阈值;配置表为空时回退上线前的 5/20/100 默认值。"""
return limit_policy.resolve( return int(app_config.get_value(db, RULE_THRESHOLD_KEYS[rule_code]))
db, RISK_LIMIT_RULE_CODES[rule_code]
).global_limit
def utcnow() -> datetime: def utcnow() -> datetime:
@@ -251,34 +257,22 @@ def _upsert_incident(
def evaluate_behavior_rule( def evaluate_behavior_rule(
db: Session, db: Session, *, rule_code: str, subject_id: str, at: datetime
*,
rule_code: str,
subject_id: str,
at: datetime,
threshold: int | None = None,
subject_reset_at: datetime | None = None,
) -> RiskIncident | None: ) -> RiskIncident | None:
spec = RULES[rule_code] spec = RULES[rule_code]
effective_threshold = threshold or get_rule_threshold(db, rule_code) threshold = get_rule_threshold(db, rule_code)
window_key, window_start, end = _window_bounds(at, spec.window) window_key, window_start, end = _window_bounds(at, spec.window)
reset_at = get_rule_reset_at(db, rule_code) reset_at = get_rule_reset_at(db, rule_code)
baselines = [value for value in (reset_at, subject_reset_at) if value is not None] start = max(window_start, reset_at) if reset_at else window_start
start = max(window_start, *baselines) if baselines else window_start
count, first_at, last_at, triggered_at = _event_stats( count, first_at, last_at, triggered_at = _event_stats(
db, db,
spec, spec,
subject_id=subject_id, subject_id=subject_id,
start=start, start=start,
end=end, end=end,
threshold=effective_threshold, threshold=threshold,
) )
if ( if count < threshold or first_at is None or last_at is None or triggered_at is None:
count < effective_threshold
or first_at is None
or last_at is None
or triggered_at is None
):
return None return None
return _upsert_incident( return _upsert_incident(
db, db,
@@ -306,6 +300,7 @@ def reconcile_behavior_rule(
"""按当前阈值重算短信/一键登录当前窗口,并收起已不再命中的待处理告警。""" """按当前阈值重算短信/一键登录当前窗口,并收起已不再命中的待处理告警。"""
spec = RULES[rule_code] spec = RULES[rule_code]
current = at or utcnow() current = at or utcnow()
threshold = get_rule_threshold(db, rule_code)
window_key, window_start, end = _window_bounds(current, spec.window) window_key, window_start, end = _window_bounds(current, spec.window)
reset_at = get_rule_reset_at(db, rule_code) reset_at = get_rule_reset_at(db, rule_code)
start = max(window_start, reset_at) if reset_at else window_start start = max(window_start, reset_at) if reset_at else window_start
@@ -316,39 +311,23 @@ def reconcile_behavior_rule(
BehaviorEvent.occurred_at >= start, BehaviorEvent.occurred_at >= start,
BehaviorEvent.occurred_at < end, BehaviorEvent.occurred_at < end,
) )
subject_rows = db.execute( qualifying_rows = db.execute(
select( select(
BehaviorEvent.subject_id, BehaviorEvent.subject_id,
func.max(BehaviorEvent.occurred_at), func.max(BehaviorEvent.occurred_at),
func.max(BehaviorEvent.phone),
) )
.where(*filters) .where(*filters)
.group_by(BehaviorEvent.subject_id) .group_by(BehaviorEvent.subject_id)
.having(func.count(BehaviorEvent.id) >= threshold)
).all() ).all()
qualifying: set[str] = set() qualifying = {str(subject_id) for subject_id, _ in qualifying_rows}
policy_code = { for subject_id, last_at in qualifying_rows:
RULE_SMS_HOURLY: "risk.sms.hourly", evaluate_behavior_rule(
RULE_ONECLICK_DAILY: "risk.oneclick.daily",
}[rule_code]
for subject_id, last_at, phone in subject_rows:
policy = limit_policy.resolve(
db,
policy_code,
phone=phone,
device=str(subject_id),
)
if policy.suppressed or policy.limit is None:
continue
incident = evaluate_behavior_rule(
db, db,
rule_code=rule_code, rule_code=rule_code,
subject_id=str(subject_id), subject_id=str(subject_id),
at=last_at or current, at=last_at or current,
threshold=policy.limit,
subject_reset_at=policy.reset_at,
) )
if incident is not None:
qualifying.add(str(subject_id))
open_incidents = db.scalars( open_incidents = db.scalars(
select(RiskIncident).where( select(RiskIncident).where(
@@ -404,29 +383,7 @@ def record_behavior_event(
db.add(event) db.add(event)
db.flush() db.flush()
if evaluate_rule: if evaluate_rule:
policy_code = { evaluate_behavior_rule(db, rule_code=evaluate_rule, subject_id=subject_id, at=at)
RULE_SMS_HOURLY: "risk.sms.hourly",
RULE_ONECLICK_DAILY: "risk.oneclick.daily",
}.get(evaluate_rule)
policy = (
limit_policy.resolve(
db,
policy_code,
phone=phone,
device=device_id or subject_id,
)
if policy_code
else None
)
if policy is None or not policy.suppressed:
evaluate_behavior_rule(
db,
rule_code=evaluate_rule,
subject_id=subject_id,
at=at,
threshold=policy.limit if policy else None,
subject_reset_at=policy.reset_at if policy else None,
)
if commit: if commit:
db.commit() db.commit()
db.refresh(event) db.refresh(event)
@@ -439,33 +396,20 @@ def sync_compare_incident(
user_id: int, user_id: int,
at: datetime, at: datetime,
threshold: int | None = None, threshold: int | None = None,
device_id: str | None = None,
commit: bool = True, commit: bool = True,
) -> RiskIncident | None: ) -> RiskIncident | None:
policy = limit_policy.resolve_for_user( effective_threshold = threshold or get_rule_threshold(db, RULE_COMPARE_DAILY)
db,
"risk.compare.daily",
user_id,
device=device_id,
)
if policy.suppressed:
return None
effective_threshold = threshold or policy.limit or get_rule_threshold(
db, RULE_COMPARE_DAILY
)
# comparison_record 的既有写入口统一落“北京时间 naive”时间;这里必须沿用同一 # comparison_record 的既有写入口统一落“北京时间 naive”时间;这里必须沿用同一
# 口径,否则 SQLite/PG session timezone 不同时会把凌晨记录算到前一天。 # 口径,否则 SQLite/PG session timezone 不同时会把凌晨记录算到前一天。
local = at.astimezone(CN_TZ).replace(tzinfo=None) if at.tzinfo else at local = at.astimezone(CN_TZ).replace(tzinfo=None) if at.tzinfo else at
window_start = local.replace(hour=0, minute=0, second=0, microsecond=0) window_start = local.replace(hour=0, minute=0, second=0, microsecond=0)
end = window_start + timedelta(days=1) end = window_start + timedelta(days=1)
window_key = window_start.strftime("%Y-%m-%d") window_key = window_start.strftime("%Y-%m-%d")
global_reset_at = get_rule_reset_at(db, RULE_COMPARE_DAILY) reset_at = get_rule_reset_at(db, RULE_COMPARE_DAILY)
baselines = [ reset_local = (
value.astimezone(CN_TZ).replace(tzinfo=None) reset_at.astimezone(CN_TZ).replace(tzinfo=None) if reset_at else None
for value in (global_reset_at, policy.reset_at) )
if value is not None start = max(window_start, reset_local) if reset_local else window_start
]
start = max(window_start, *baselines) if baselines else window_start
filters = ( filters = (
ComparisonRecord.user_id == user_id, ComparisonRecord.user_id == user_id,
ComparisonRecord.created_at >= start, ComparisonRecord.created_at >= start,
@@ -520,6 +464,7 @@ def reconcile_compare_rule(
reset_at.astimezone(CN_TZ).replace(tzinfo=None) if reset_at else None reset_at.astimezone(CN_TZ).replace(tzinfo=None) if reset_at else None
) )
start = max(window_start, reset_local) if reset_local else window_start start = max(window_start, reset_local) if reset_local else window_start
threshold = get_rule_threshold(db, RULE_COMPARE_DAILY)
rows = db.execute( rows = db.execute(
select( select(
ComparisonRecord.user_id, ComparisonRecord.user_id,
@@ -531,17 +476,17 @@ def reconcile_compare_rule(
ComparisonRecord.created_at < end, ComparisonRecord.created_at < end,
) )
.group_by(ComparisonRecord.user_id) .group_by(ComparisonRecord.user_id)
.having(func.count(ComparisonRecord.id) >= threshold)
).all() ).all()
qualifying: set[str] = set() qualifying = {str(user_id) for user_id, _ in rows}
for user_id, last_at in rows: for user_id, last_at in rows:
incident = sync_compare_incident( sync_compare_incident(
db, db,
user_id=int(user_id), user_id=int(user_id),
at=last_at or current, at=last_at or current,
threshold=threshold,
commit=False, commit=False,
) )
if incident is not None:
qualifying.add(str(user_id))
open_incidents = db.scalars( open_incidents = db.scalars(
select(RiskIncident).where( select(RiskIncident).where(
+7 -20
View File
@@ -132,20 +132,10 @@ def get_or_create_account(
) -> CoinAccount: ) -> CoinAccount:
"""取用户金币账户,不存在则建一个空账户。 """取用户金币账户,不存在则建一个空账户。
lock=True 时对已存在的账户行加 SELECT FOR UPDATE并强制刷新 identity map 中可能 lock=True 时对已存在的账户行加 SELECT FOR UPDATE(--写余额的调用方串行化,防并发
缓存的旧余额所有金币写入口都会使用它SQLite FOR UPDATE no-op 双写余额错位, admin set 模式连点);默认 False 不改 C 端发奖行为SQLite no-op
""" """
if lock: acc = db.get(CoinAccount, user_id, with_for_update=True) if lock else db.get(CoinAccount, user_id)
# populate_existing 很关键:同一 Session 可能早已缓存旧余额。只加 FOR UPDATE 而
# 继续复用 identity map 里的旧对象,仍会在另一笔发奖提交后把余额覆盖回去。
acc = db.execute(
select(CoinAccount)
.where(CoinAccount.user_id == user_id)
.with_for_update()
.execution_options(populate_existing=True)
).scalar_one_or_none()
else:
acc = db.get(CoinAccount, user_id)
if acc is None: if acc is None:
acc = CoinAccount( acc = CoinAccount(
user_id=user_id, user_id=user_id,
@@ -175,9 +165,7 @@ def grant_coins(
返回 (account, transaction)调用方负责 commit 返回 (account, transaction)调用方负责 commit
""" """
# 所有金币来源统一遵守账户行锁协议;否则 guide 锁了账户,广告/签到等未锁路径 acc = get_or_create_account(db, user_id, commit=False)
# 仍可拿旧余额做 ORM 读改写,最终余额会小于流水累计。
acc = get_or_create_account(db, user_id, commit=False, lock=True)
acc.coin_balance += amount acc.coin_balance += amount
if amount > 0: if amount > 0:
acc.total_coin_earned += amount acc.total_coin_earned += amount
@@ -210,7 +198,7 @@ def grant_cash(
[grant_coins] 同模式(运营手动调现金 / 测试发现金用)返回 (account, transaction), [grant_coins] 同模式(运营手动调现金 / 测试发现金用)返回 (account, transaction),
调用方负责 commit不在此校验扣成负由调用方(admin router)按业务保护 调用方负责 commit不在此校验扣成负由调用方(admin router)按业务保护
""" """
acc = get_or_create_account(db, user_id, commit=False, lock=True) acc = get_or_create_account(db, user_id, commit=False)
acc.cash_balance_cents += amount_cents acc.cash_balance_cents += amount_cents
txn = CashTransaction( txn = CashTransaction(
@@ -240,7 +228,7 @@ def grant_invite_cash(
invite_cash_transaction, commit与金币兑换的 cash_balance_cents **物理隔离** invite_cash_transaction, commit与金币兑换的 cash_balance_cents **物理隔离**
(产品红线:邀请奖励金 金币现金,两本账不可累加)返回 (account, transaction), (产品红线:邀请奖励金 金币现金,两本账不可累加)返回 (account, transaction),
调用方负责 commit不在此校验扣成负由调用方按业务保护""" 调用方负责 commit不在此校验扣成负由调用方按业务保护"""
acc = get_or_create_account(db, user_id, commit=False, lock=True) acc = get_or_create_account(db, user_id, commit=False)
acc.invite_cash_balance_cents += amount_cents acc.invite_cash_balance_cents += amount_cents
txn = InviteCashTransaction( txn = InviteCashTransaction(
@@ -298,8 +286,7 @@ def exchange_coins_to_cash(
if coin_amount < floor_min or coin_amount % COIN_PER_CENT != 0: if coin_amount < floor_min or coin_amount % COIN_PER_CENT != 0:
raise InvalidExchangeAmountError raise InvalidExchangeAmountError
# 余额充足校验必须和扣减遵守同一账户锁,否则并发消费都可能通过旧余额校验。 acc = get_or_create_account(db, user_id, commit=False)
acc = get_or_create_account(db, user_id, commit=False, lock=True)
if acc.coin_balance < coin_amount: if acc.coin_balance < coin_amount:
raise InsufficientCoinError raise InsufficientCoinError
+3 -15
View File
@@ -7,19 +7,9 @@
""" """
from __future__ import annotations from __future__ import annotations
import re
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field
from app.core.config import settings
def _validate_login_phone(phone: str) -> str:
"""普通号保持大陆手机号格式;仅显式配置的测试号允许例外。"""
if re.fullmatch(r"^1\d{10}$", phone) or phone in settings.test_account_phones:
return phone
raise ValueError("invalid phone")
# ===== 用户对外信息 ===== # ===== 用户对外信息 =====
@@ -82,7 +72,7 @@ class JverifyLoginRequest(BaseModel):
# ===== 短信验证码 ===== # ===== 短信验证码 =====
class SmsSendRequest(BaseModel): class SmsSendRequest(BaseModel):
phone: str = Field(..., min_length=11, max_length=11) phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$")
device_id: str = Field( device_id: str = Field(
"", max_length=64, "", max_length=64,
description="硬件级设备标识(Android ANDROID_ID),用于发码防刷按 设备+IP 限流;空=按 IP 聚一桶", description="硬件级设备标识(Android ANDROID_ID),用于发码防刷按 设备+IP 限流;空=按 IP 聚一桶",
@@ -90,7 +80,6 @@ class SmsSendRequest(BaseModel):
device_model: str = Field( device_model: str = Field(
"", max_length=128, description="客户端设备型号快照,用于短信安全审计" "", max_length=128, description="客户端设备型号快照,用于短信安全审计"
) )
_valid_phone = field_validator("phone")(_validate_login_phone)
class SmsSendResponse(BaseModel): class SmsSendResponse(BaseModel):
@@ -100,7 +89,7 @@ class SmsSendResponse(BaseModel):
class SmsLoginRequest(BaseModel): class SmsLoginRequest(BaseModel):
phone: str = Field(..., min_length=11, max_length=11) phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$")
code: str = Field(..., min_length=4, max_length=8) code: str = Field(..., min_length=4, max_length=8)
device_id: str = Field( device_id: str = Field(
"", max_length=64, "", max_length=64,
@@ -109,7 +98,6 @@ class SmsLoginRequest(BaseModel):
device_model: str = Field( device_model: str = Field(
"", max_length=128, description="客户端设备型号快照,用于短信验证安全审计" "", max_length=128, description="客户端设备型号快照,用于短信验证安全审计"
) )
_valid_phone = field_validator("phone")(_validate_login_phone)
# ===== Refresh ===== # ===== Refresh =====
+4 -23
View File
@@ -107,13 +107,6 @@ class ComparisonRecordIn(BaseModel):
# 明细 # 明细
items: list[ComparisonItemIn] = Field(default_factory=list) items: list[ComparisonItemIn] = Field(default_factory=list)
comparison_results: list[ComparisonResultIn] = Field(default_factory=list) comparison_results: list[ComparisonResultIn] = Field(default_factory=list)
# 展示模型统一数组(pricebot done.params.platforms 原样透传): 每平台一行、自带
# status/is_best/display/display_order,记录页据此直渲染。宽松 list[dict] 存(结构由
# pricebot 定,server 只原样落库),前端读它、老记录空时回退 comparison_results。
platforms: list[dict] = Field(default_factory=list)
# 记录级结局(pricebot 下发): success/below_minimum/store_closed/failed。让"未满起送"不再
# 被塌缩成 failed。_derive 优先用它、其次客户端 status、再兜底二态派生。
record_status: str | None = None
# 逐平台结局摘要(含失败平台的细分原因 status: store_not_found/items_not_found/below_minimum/ # 逐平台结局摘要(含失败平台的细分原因 status: store_not_found/items_not_found/below_minimum/
# unsupported/...)。来自 done.params.platform_results,客户端透传;落 raw_payload(不单列), # unsupported/...)。来自 done.params.platform_results,客户端透传;落 raw_payload(不单列),
# admin「卡在哪一步」从这里读。dict{platform_id: {...}} 宽松存(结构由 pricebot 定——是 # admin「卡在哪一步」从这里读。dict{platform_id: {...}} 宽松存(结构由 pricebot 定——是
@@ -180,13 +173,8 @@ class ComparisonRecordOut(BaseModel):
skipped_dish_count: int | None = None skipped_dish_count: int | None = None
status: str status: str
information: str | None = None information: str | None = None
# 失败卡「原因」文案:具体失败给具体原因,纯系统失败为 None(端侧品牌兜底)。见模型 fail_reason。
fail_reason: str | None = None
items: list = [] items: list = []
comparison_results: list = [] comparison_results: list = []
# 展示模型统一数组(每平台一行、自带 status/is_best/display/display_order): 记录页据此
# 直渲染, 不再靠 comparison_results + 前端派生。老记录为空 → 前端回退 comparison_results。
platforms: list = []
skipped_dish_names: list = [] skipped_dish_names: list = []
total_ms: int | None = None total_ms: int | None = None
# 「已下单」(店级):该店名在该用户真实下单(source='compare')里出现过即 True。 # 「已下单」(店级):该店名在该用户真实下单(source='compare')里出现过即 True。
@@ -214,24 +202,17 @@ class ComparisonRecordCreatedOut(BaseModel):
class CompareStartReserveIn(BaseModel): class CompareStartReserveIn(BaseModel):
"""Reserve one authenticated comparison start before the agent begins. """Reserve one authenticated comparison start before the agent begins."""
trace_id 可选不带 = 请服务端签发统一 trace_id 由后端下发前端/SLS 日志/ trace_id: str = Field(..., min_length=1, max_length=64)
pricebot 全链用同一个 id = 沿用客户端值老客户端兼容 + 网络重试幂等
"""
trace_id: str | None = Field(default=None, min_length=1, max_length=64)
business_type: str = Field(default="food", min_length=1, max_length=16) business_type: str = Field(default="food", min_length=1, max_length=16)
device_id: str | None = Field(default=None, max_length=64) device_id: str | None = Field(default=None, max_length=64)
class CompareStartReserveOut(BaseModel): class CompareStartReserveOut(BaseModel):
limit: int | None limit: int
used: int used: int
remaining: int | None remaining: int
# 本次比价全链 trace_id(服务端签发的,或回显客户端带来的)。客户端必须以它为准,
# 贯穿 Phase1/Phase2 step、比价记录、trace 收尾与前端运行日志上报。
trace_id: str
class CompareStatsOut(BaseModel): class CompareStatsOut(BaseModel):
+1 -18
View File
@@ -58,13 +58,9 @@ class CouponSessionIn(BaseModel):
- 发起(status=started):带勾选平台 + 机型/ROM/app_env + started_at_ms(发起墙钟毫秒) - 发起(status=started):带勾选平台 + 机型/ROM/app_env + started_at_ms(发起墙钟毫秒)
- 收尾(completed/failed/abandoned): elapsed_ms(全程耗时)+ platform_elapsed(各平台耗时)+ claimed_count - 收尾(completed/failed/abandoned): elapsed_ms(全程耗时)+ platform_elapsed(各平台耗时)+ claimed_count
不鉴权(同领券循环 MVP, device_id/trace_id),user_id 登录态带上做留痕(可空) 不鉴权(同领券循环 MVP, device_id/trace_id),user_id 登录态带上做留痕(可空)
trace_id 可选:started 不带 = 请服务端签发本轮领券 trace_id(统一 trace_id 由后端下发,
响应 CouponSessionOut.trace_id 返回,客户端全程用它); = 沿用客户端值(老客户端兼容)
started trace_id 不签发(防孤儿行),返回 trace_id=null 且不写库
""" """
trace_id: str | None = None trace_id: str
device_id: str device_id: str
status: str # started / completed / failed / abandoned status: str # started / completed / failed / abandoned
started_at_ms: int # 发起墙钟毫秒(客户端 System.currentTimeMillis) started_at_ms: int # 发起墙钟毫秒(客户端 System.currentTimeMillis)
@@ -78,16 +74,3 @@ class CouponSessionIn(BaseModel):
platform_elapsed: dict[str, int] | None = None platform_elapsed: dict[str, int] | None = None
claimed_count: int | None = None claimed_count: int | None = None
trace_url: str | None = None trace_url: str | None = None
class CouponSessionOut(BaseModel):
"""POST /api/v1/coupon/session 响应。
trace_id = 本轮领券全链 id(服务端签发的,或回显客户端带来的);客户端以它为准贯穿
/coupon/step 循环收尾上报与前端运行日志 不能沿用旧的 dict[str, bool] 返回注解
FastAPI 会按注解校验响应,字符串 trace_id bool 校验必炸,故显式建模
started 且缺 trace_id 时为 null(不签发防孤儿行)
"""
ok: bool = True
trace_id: str | None = None
-11
View File
@@ -12,7 +12,6 @@ class DeviceRegisterRequest(BaseModel):
registration_id: str | None = None registration_id: str | None = None
push_vendor: str | None = None push_vendor: str | None = None
push_token: str | None = None push_token: str | None = None
push_binding_id: str | None = Field(default=None, max_length=128)
platform: str = "android" platform: str = "android"
app_version: str | None = None app_version: str | None = None
@@ -24,7 +23,6 @@ class HeartbeatRequest(BaseModel):
registration_id: str | None = None registration_id: str | None = None
push_vendor: str | None = None push_vendor: str | None = None
push_token: str | None = None push_token: str | None = None
push_binding_id: str | None = Field(default=None, max_length=128)
class DeviceOut(BaseModel): class DeviceOut(BaseModel):
@@ -35,7 +33,6 @@ class DeviceOut(BaseModel):
registration_id: str | None registration_id: str | None
push_vendor: str | None push_vendor: str | None
push_token: str | None push_token: str | None
push_binding_id: str | None
ever_protected: bool ever_protected: bool
liveness_state: str liveness_state: str
last_heartbeat_at: datetime | None last_heartbeat_at: datetime | None
@@ -46,14 +43,6 @@ class OkResponse(BaseModel):
ok: bool = True ok: bool = True
class DeviceUnregisterRequest(BaseModel):
device_id: str = Field(min_length=1, max_length=128)
class DeferredDeviceUnregisterRequest(DeviceUnregisterRequest):
push_binding_id: str = Field(min_length=32, max_length=128)
class LivenessOut(BaseModel): class LivenessOut(BaseModel):
"""本机掉线告警状态(后置检测 pull 版)。客户端只需这一个布尔判断要不要弹「开启自启动」引导, """本机掉线告警状态(后置检测 pull 版)。客户端只需这一个布尔判断要不要弹「开启自启动」引导,
故只返回 kill_alert_pending(不暴露设备详情 / 内部 liveness_state )从未注册过 默认 False(无告警)""" 故只返回 kill_alert_pending(不暴露设备详情 / 内部 liveness_state )从未注册过 默认 False(无告警)"""
+20 -56
View File
@@ -1,72 +1,36 @@
"""客户端引导视频三阶段协议""" """新手引导视频(领券等候浮层前 N 次替代广告)的客户端请求/响应契约"""
from typing import Literal from __future__ import annotations
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
GuideScene = Literal["coupon", "comparison"]
class GuideVideoPrepareIn(BaseModel):
scene: GuideScene = "coupon"
class GuideVideoPrepareOut(BaseModel):
should_play: bool
reason: str
scene: GuideScene
video_url: str | None = None
play_token: str = ""
config_version: int = 0
duration_ms: int = 0
circle_count: int = 10
circle_duration_ms: float = 0
reward_coin: int = 0
reward_per_circle: int = 0
seq: int = 0
remaining: int = 0
expires_at: str | None = None
class GuideVideoStartIn(BaseModel): class GuideVideoStartIn(BaseModel):
play_token: str = Field(min_length=1, max_length=64) """开播询问。scene 目前只有 coupon(领券浮层);预留给日后比价等场景。"""
scene: str = Field(default="coupon", max_length=16)
class GuideVideoStartOut(BaseModel): class GuideVideoStartOut(BaseModel):
started: bool """should_play=False 时客户端照旧走广告链路,其余字段无意义。"""
status: Literal["started", "already_started"]
play_token: str should_play: bool
scene: GuideScene video_url: str | None = None # 相对地址 /media/...;客户端自行拼 BASE_URL
video_url: str play_token: str = "" # 发奖幂等键
config_version: int reward_coin: int = 0 # 播完/中途关闭都发的固定金币
duration_ms: int seq: int = 0 # 本账号第几次
circle_count: int = 10 remaining: int = 0 # 发完这次还剩几次
circle_duration_ms: float
reward_coin: int
reward_per_circle: int
seq: int
remaining: int
started_at: str
class GuideVideoRewardIn(BaseModel): class GuideVideoRewardIn(BaseModel):
"""播完或中途关闭都调这个;completed 只做留痕,两者都发币。"""
play_token: str = Field(min_length=1, max_length=64) play_token: str = Field(min_length=1, max_length=64)
circle: int = Field(ge=1, le=10) completed: bool = False
class GuideVideoRewardOut(BaseModel): class GuideVideoRewardOut(BaseModel):
"""granted=True 表示本次调用真的入账(重复上报为 False,coin 是已发金额)。"""
granted: bool granted: bool
status: Literal[ coin: int
"granted", status: str
"already_granted",
"too_early",
"out_of_order",
"not_started",
"not_found",
"finished",
]
retryable: bool = False
retry_after_ms: int = 0
circle: int
granted_coin: int = 0
settled_circles: int = 0
coin_balance: int = 0
+1 -1
View File
@@ -135,7 +135,7 @@ def repair_missing_comparison_llm_costs(
select(ComparisonRecord.id, ComparisonRecord.trace_id) select(ComparisonRecord.id, ComparisonRecord.trace_id)
.where( .where(
*date_conditions, *date_conditions,
ComparisonRecord.status.in_(("success", "failed", "cancelled")), ComparisonRecord.status.in_(("success", "failed")),
ComparisonRecord.llm_cost_yuan.is_(None), ComparisonRecord.llm_cost_yuan.is_(None),
) )
.order_by(ComparisonRecord.created_at.desc(), ComparisonRecord.id.desc()) .order_by(ComparisonRecord.created_at.desc(), ComparisonRecord.id.desc())
+17 -141
View File
@@ -83,13 +83,8 @@ def _dispatch(
extra: dict[str, str] | None = None, extra: dict[str, str] | None = None,
dedup_key: str | None = None, dedup_key: str | None = None,
push_vars: dict[str, str] | None = None, push_vars: dict[str, str] | None = None,
push_token_contains: str | None = None,
) -> Notification | None: ) -> Notification | None:
"""落一条站内消息并向该用户设备直推。返回落库行;去重命中/失败返回 None。""" """落一条站内消息并向该用户设备直推。返回落库行;去重命中/失败返回 None。"""
logger.info(
"notification dispatch started user_id=%s type=%s dedup_key=%s",
user_id, type_key, dedup_key,
)
try: try:
row = notif_repo.create_notification( row = notif_repo.create_notification(
db, db,
@@ -116,20 +111,11 @@ def _dispatch(
logger.exception("rollback after notification failure also failed") logger.exception("rollback after notification failure also failed")
return None return None
logger.info( _push_to_user_devices(db, row, push_vars)
"notification created user_id=%s type=%s notification_id=%s dedup_key=%s",
user_id, type_key, row.id, dedup_key,
)
_push_to_user_devices(db, row, push_vars, push_token_contains)
return row return row
def _push_to_user_devices( def _push_to_user_devices(db: Session, row: Notification, push_vars: dict[str, str] | None) -> None:
db: Session,
row: Notification,
push_vars: dict[str, str] | None,
push_token_contains: str | None = None,
) -> None:
"""向消息归属用户的全部厂商推送目标直推(best-effort,单设备失败不影响其余)。""" """向消息归属用户的全部厂商推送目标直推(best-effort,单设备失败不影响其余)。"""
try: try:
title, body = catalog.render_push(row.type, push_vars) title, body = catalog.render_push(row.type, push_vars)
@@ -138,103 +124,29 @@ def _push_to_user_devices(
extras.update({str(k): str(v) for k, v in (row.extra or {}).items()}) extras.update({str(k): str(v) for k, v in (row.extra or {}).items()})
extras["notificationId"] = str(row.id) extras["notificationId"] = str(row.id)
targets = device_repo.list_push_targets(db, user_id=row.user_id) for dev in device_repo.list_push_targets(db, user_id=row.user_id):
if push_token_contains:
normalized_filter = push_token_contains.casefold()
targets = [
target
for target in targets
if normalized_filter in (target.push_token or "").casefold()
]
logger.info(
"push targets resolved user_id=%s type=%s notification_id=%s "
"token_filter=%s target_count=%s",
row.user_id, row.type, row.id, bool(push_token_contains), len(targets),
)
if not targets:
logger.warning(
"push skipped no targets user_id=%s type=%s notification_id=%s",
row.user_id, row.type, row.id,
)
return
sent = failed = skipped = data_sent = data_failed = 0
for dev in targets:
vendor = vendor_push.normalize_vendor(dev.push_vendor) vendor = vendor_push.normalize_vendor(dev.push_vendor)
if not vendor or vendor not in vendor_push.SUPPORTED_VENDORS: if not vendor or vendor not in vendor_push.SUPPORTED_VENDORS:
skipped += 1
logger.warning(
"push target skipped unsupported vendor user_id=%s type=%s "
"notification_id=%s device_id=%s raw_vendor=%s normalized_vendor=%s",
row.user_id, row.type, row.id, dev.device_id, dev.push_vendor, vendor,
)
continue continue
missing = vendor_push.missing_settings(vendor) if vendor_push.missing_settings(vendor):
if missing:
skipped += 1
# 本地/测试环境凭据不齐 → 只落站内消息,不发真推送(与 push/vendors 的报缺口径一致) # 本地/测试环境凭据不齐 → 只落站内消息,不发真推送(与 push/vendors 的报缺口径一致)
logger.warning( logger.info(
"push target skipped vendor not configured user_id=%s type=%s " "skip push (vendor %s not configured) user_id=%s type=%s",
"notification_id=%s device_id=%s vendor=%s missing_settings=%s", vendor, row.user_id, row.type,
row.user_id, row.type, row.id, dev.device_id, vendor, missing,
) )
continue continue
logger.info(
"push send started user_id=%s type=%s notification_id=%s "
"device_id=%s vendor=%s",
row.user_id, row.type, row.id, dev.device_id, vendor,
)
try: try:
response = vendor_push.send_notification( vendor_push.send_notification(
vendor, dev.push_token, title=title, body=body, extras=extras vendor, dev.push_token, title=title, body=body, extras=extras
) )
if vendor == "huawei" and row.type in {"feedback_reply", "feedback_reward"}:
# 华为反馈推送联调日志:保留厂商返回码/requestId 等排障信息,
# 请求本身的 token、Authorization 和应用密钥不会进入 response。
logger.info(
"huawei feedback push response user_id=%s type=%s "
"notification_id=%s response=%s",
row.user_id, row.type, row.id, response,
)
logger.info( logger.info(
"push sent user_id=%s type=%s vendor=%s notification_id=%s device_id=%s", "push sent user_id=%s type=%s vendor=%s notification_id=%s",
row.user_id, row.type, vendor, row.id, dev.device_id, row.user_id, row.type, vendor, row.id,
)
sent += 1
except vendor_push.VendorPushError as e:
failed += 1
logger.warning(
"push failed user_id=%s type=%s vendor=%s notification_id=%s "
"device_id=%s error=%s",
row.user_id, row.type, vendor, row.id, dev.device_id, e,
)
try:
data_response = vendor_push.send_data_event(
vendor,
dev.push_token,
event=vendor_push.DATA_EVENT_NOTIFICATION_CREATED,
notification_id=str(row.id),
)
data_sent += 1
logger.info(
"push data event completed user_id=%s type=%s vendor=%s "
"notification_id=%s device_id=%s response=%s",
row.user_id, row.type, vendor, row.id, dev.device_id, data_response,
) )
except vendor_push.VendorPushError as e: except vendor_push.VendorPushError as e:
data_failed += 1
# 透传只负责前台铃铛实时刷新,失败不能影响通知栏消息或站内消息。
logger.warning( logger.warning(
"push data event failed user_id=%s type=%s vendor=%s notification_id=%s " "push failed user_id=%s type=%s vendor=%s: %s", row.user_id, row.type, vendor, e
"device_id=%s error=%s",
row.user_id, row.type, vendor, row.id, dev.device_id, e,
) )
logger.info(
"push dispatch completed user_id=%s type=%s notification_id=%s targets=%s "
"sent=%s failed=%s skipped=%s data_sent=%s data_failed=%s",
row.user_id, row.type, row.id, len(targets),
sent, failed, skipped, data_sent, data_failed,
)
except Exception: # noqa: BLE001 — 渲染/查设备等意外失败同样不外抛 except Exception: # noqa: BLE001 — 渲染/查设备等意外失败同样不外抛
logger.exception("push notification failed user_id=%s type=%s", row.user_id, row.type) logger.exception("push notification failed user_id=%s type=%s", row.user_id, row.type)
@@ -244,12 +156,7 @@ def _push_to_user_devices(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def notify_withdraw_success( def notify_withdraw_success(db: Session, order: WithdrawOrder) -> None:
db: Session,
order: WithdrawOrder,
*,
push_token_contains: str | None = None,
) -> None:
"""#3 提现成功:款项已存入微信零钱。点击无跳转仅消红点(extra 空)。""" """#3 提现成功:款项已存入微信零钱。点击无跳转仅消红点(extra 空)。"""
_dispatch( _dispatch(
db, db,
@@ -263,16 +170,10 @@ def notify_withdraw_success(
extra={}, extra={},
dedup_key=order.out_bill_no, dedup_key=order.out_bill_no,
push_vars={"amount": notif_repo.cash_yuan(order.amount_cents)}, push_vars={"amount": notif_repo.cash_yuan(order.amount_cents)},
push_token_contains=push_token_contains,
) )
def notify_withdraw_failed( def notify_withdraw_failed(db: Session, order: WithdrawOrder) -> None:
db: Session,
order: WithdrawOrder,
*,
push_token_contains: str | None = None,
) -> None:
"""#4 提现失败/退回:含微信侧失败、审核拒绝、解绑退回。点击跳提现页重新提现。 """#4 提现失败/退回:含微信侧失败、审核拒绝、解绑退回。点击跳提现页重新提现。
失败原因用 order.fail_reason( /withdraw/status 下发的用户可读原因同源) 失败原因用 order.fail_reason( /withdraw/status 下发的用户可读原因同源)
@@ -290,16 +191,10 @@ def notify_withdraw_failed(
extra={"withdrawId": order.out_bill_no}, extra={"withdrawId": order.out_bill_no},
dedup_key=order.out_bill_no, dedup_key=order.out_bill_no,
push_vars={"amount": notif_repo.cash_yuan(order.amount_cents), "reason": reason}, push_vars={"amount": notif_repo.cash_yuan(order.amount_cents), "reason": reason},
push_token_contains=push_token_contains,
) )
def notify_feedback_reply( def notify_feedback_reply(db: Session, feedback: Feedback) -> None:
db: Session,
feedback: Feedback,
*,
push_token_contains: str | None = None,
) -> None:
"""#9 官方回复:运营审核了反馈且未采纳(用户可见原因/留言落在反馈记录上)。 """#9 官方回复:运营审核了反馈且未采纳(用户可见原因/留言落在反馈记录上)。
点击跳反馈历史页滚动高亮该条(extra.feedbackId)""" 点击跳反馈历史页滚动高亮该条(extra.feedbackId)"""
_dispatch( _dispatch(
@@ -309,16 +204,10 @@ def notify_feedback_reply(
info_rows=[{"label": "说明文案", "value": "快去看看官方给您的回复吧~"}], info_rows=[{"label": "说明文案", "value": "快去看看官方给您的回复吧~"}],
extra={"feedbackId": str(feedback.id)}, extra={"feedbackId": str(feedback.id)},
dedup_key=str(feedback.id), dedup_key=str(feedback.id),
push_token_contains=push_token_contains,
) )
def notify_feedback_reward( def notify_feedback_reward(db: Session, feedback: Feedback) -> None:
db: Session,
feedback: Feedback,
*,
push_token_contains: str | None = None,
) -> None:
"""#10 反馈奖励:反馈被采纳,金币已到账。PRD 约定发奖必带官方留言(admin_reply); """#10 反馈奖励:反馈被采纳,金币已到账。PRD 约定发奖必带官方留言(admin_reply);
运营漏填时省略该信息行,不硬造文案""" 运营漏填时省略该信息行,不硬造文案"""
coins = int(feedback.reward_coins or 0) coins = int(feedback.reward_coins or 0)
@@ -336,16 +225,10 @@ def notify_feedback_reward(
extra={"feedbackId": str(feedback.id)}, extra={"feedbackId": str(feedback.id)},
dedup_key=str(feedback.id), dedup_key=str(feedback.id),
push_vars={"coins": str(coins)}, push_vars={"coins": str(coins)},
push_token_contains=push_token_contains,
) )
def notify_report_approved( def notify_report_approved(db: Session, report: PriceReport) -> None:
db: Session,
report: PriceReport,
*,
push_token_contains: str | None = None,
) -> None:
"""#11 爆料审核通过:上报的更低价过审,金币已到账。点击跳爆料记录页高亮该条。""" """#11 爆料审核通过:上报的更低价过审,金币已到账。点击跳爆料记录页高亮该条。"""
coins = int(report.reward_coins or 0) coins = int(report.reward_coins or 0)
store = (report.store_name or "").strip() or "该店铺" store = (report.store_name or "").strip() or "该店铺"
@@ -361,17 +244,11 @@ def notify_report_approved(
extra={"reportId": str(report.id)}, extra={"reportId": str(report.id)},
dedup_key=str(report.id), dedup_key=str(report.id),
push_vars={"store": store, "coins": str(coins)}, push_vars={"store": store, "coins": str(coins)},
push_token_contains=push_token_contains,
) )
def notify_invite_order_reward( def notify_invite_order_reward(
db: Session, db: Session, *, inviter_user_id: int, invitee_user_id: int, cash_cents: int
*,
inviter_user_id: int,
invitee_user_id: int,
cash_cents: int,
push_token_contains: str | None = None,
) -> None: ) -> None:
"""#12 好友下单到账:被邀请好友完成首次下单(比价),现金奖励已入邀请人账户。 """#12 好友下单到账:被邀请好友完成首次下单(比价),现金奖励已入邀请人账户。
通知发给邀请人;每个好友只发一次奖 dedup 按被邀请人""" 通知发给邀请人;每个好友只发一次奖 dedup 按被邀请人"""
@@ -389,5 +266,4 @@ def notify_invite_order_reward(
extra={"inviteeNickname": nickname}, extra={"inviteeNickname": nickname},
dedup_key=str(invitee_user_id), dedup_key=str(invitee_user_id),
push_vars={"nickname": nickname, "amount": _yuan_trim(cash_cents)}, push_vars={"nickname": nickname, "amount": _yuan_trim(cash_cents)},
push_token_contains=push_token_contains,
) )
+22
View File
@@ -0,0 +1,22 @@
# 本地开发/测试用 PostgreSQL。生产用原生 PG(scripts/init_postgres.py),不使用本文件。
services:
postgres:
image: postgres:16-alpine
container_name: shaguabijia-pg
environment:
POSTGRES_USER: shaguabijia_app
POSTGRES_PASSWORD: shaguabijia_dev_pw
POSTGRES_DB: shaguabijia
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./docker/initdb:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shaguabijia_app -d shaguabijia"]
interval: 3s
timeout: 3s
retries: 20
volumes:
pgdata:
+3
View File
@@ -0,0 +1,3 @@
-- 仅在 pgdata 卷首次初始化时执行一次(以 shaguabijia_app 连 shaguabijia 库运行)。
-- 幂等兜底见 scripts/ensure_pg.py 的 _ensure_test_db()。
CREATE DATABASE shaguabijia_test OWNER shaguabijia_app;
-1
View File
@@ -87,7 +87,6 @@ PRD §5 的 13 条 push 文案(标题固定 ≤11 字不带变量;正文 `{var}`
**真发注意**: **真发注意**:
- 目标手机必须先装 App 且客户端已集成对应厂商 SDK、`/device/register` 上报过 token; - 目标手机必须先装 App 且客户端已集成对应厂商 SDK、`/device/register` 上报过 token;
- 荣耀正式环境必须使用正式消息(`HONOR_PUSH_TARGET_USER_TYPE=0`);开发联调测试设备时才设为 `1`;
- vivo 未上架前走测试推送(`VIVO_PUSH_MODE=1`),目标手机需在 vivo 开放平台加入测试设备; - vivo 未上架前走测试推送(`VIVO_PUSH_MODE=1`),目标手机需在 vivo 开放平台加入测试设备;
- 小米新设备需在开放平台把签名/包名配好,token 才有效。 - 小米新设备需在开放平台把签名/包名配好,token 才有效。
+15 -1
View File
@@ -27,7 +27,21 @@ PG 默认上 16 版(工具链最齐),驱动用 **psycopg3**(SQLAlchemy 2.0 时
## 1. 本地起 PG + 跑通空库(半天) ## 1. 本地起 PG + 跑通空库(半天)
### 1.1 装 PG ### 1.0 推荐:Docker 一键起(本地开发/测试)
本地开发不必手动装 PG。已提供 `docker-compose.yml` + `scripts/ensure_pg.py`:
```bash
cp .env.example .env # DATABASE_URL 默认已是 Docker PG 连接串
./run.sh # 或 run.bat;会自动:探测 PG → 没起则启 Docker → 起 PG 容器 → 建库 → alembic → uvicorn
pytest # conftest 自动引导同一容器的 shaguabijia_test 库
```
容器:`postgres:16-alpine`(名 `shaguabijia-pg`,端口 5432,命名卷 `pgdata` 持久化),
首启即建业务库 `shaguabijia` 与测试库 `shaguabijia_test`。下面 1.1-1.5 的手动装 PG 步骤仅在
不用 Docker 时才需要;生产仍走 §4 的原生 PG。
### 1.1 装 PG(不用 Docker 时的手动方式)
macOS: macOS:
```bash ```bash
@@ -0,0 +1,740 @@
# 本地开发切 Docker PostgreSQL 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让 app-server 本地开发运行与 pytest 都跑在 Docker 化的 PostgreSQL 16 上,退掉 SQLite,`run.bat`/`run.sh` 启动时自动检测并拉起 PG(必要时先启 Docker Desktop、缺镜像先拉)。
**Architecture:** 新增 `docker-compose.yml`(声明 PG 服务/卷/健康检查)+ `scripts/ensure_pg.py`(跨平台引导:探测→启 Docker→compose up→等就绪→幂等建测试库),由 `run.sh`/`run.bat`/`tests/conftest.py` 三处共用。`.env.example` 默认切 PG。`app/db/session.py``alembic/env.py` 已天然支持 PG,无需改。
**Tech Stack:** Docker Compose、`postgres:16-alpine`、Python 3.10+ 标准库(`socket`/`subprocess`/`urllib.parse`)、psycopg3(已装)、SQLAlchemy 2.0 + Alembic、pytest。
**工作目录:** 本计划在 worktree `.worktrees/local-dev-postgres-docker`(分支 `chore/local-dev-postgres-docker`,基于 `main`)内执行。下面所有路径相对该 worktree 根(= 仓库根)。
**设计依据:** [docs/superpowers/specs/2026-07-08-local-dev-postgres-docker-design.md](../specs/2026-07-08-local-dev-postgres-docker-design.md)
**连接参数(全程固定值):**
- 镜像 `postgres:16-alpine`,容器名 `shaguabijia-pg`,宿主端口 `5432`
- 用户 `shaguabijia_app`,dev 密码 `shaguabijia_dev_pw`(本地非机密)
- 业务库 `shaguabijia`,测试库 `shaguabijia_test`,命名卷 `pgdata`
- dev URL:`postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia`
- test URL:`...@localhost:5432/shaguabijia_test`
**前置:** 执行机已安装 Docker Desktop。
---
## Task 1: Docker Compose + 测试库 initdb 脚本
**Files:**
- Create: `docker-compose.yml`
- Create: `docker/initdb/01-create-test-db.sql`
- [ ] **Step 1: 写 `docker-compose.yml`**
```yaml
# 本地开发/测试用 PostgreSQL。生产用原生 PG(scripts/init_postgres.py),不使用本文件。
services:
postgres:
image: postgres:16-alpine
container_name: shaguabijia-pg
environment:
POSTGRES_USER: shaguabijia_app
POSTGRES_PASSWORD: shaguabijia_dev_pw
POSTGRES_DB: shaguabijia
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./docker/initdb:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shaguabijia_app -d shaguabijia"]
interval: 3s
timeout: 3s
retries: 20
volumes:
pgdata:
```
- [ ] **Step 2: 写 `docker/initdb/01-create-test-db.sql`**
```sql
-- 仅在 pgdata 卷首次初始化时执行一次(以 shaguabijia_app 连 shaguabijia 库运行)。
-- 幂等兜底见 scripts/ensure_pg.py 的 _ensure_test_db()。
CREATE DATABASE shaguabijia_test OWNER shaguabijia_app;
```
- [ ] **Step 3: 起容器验证**
Run: `docker compose up -d`
Expected: 拉取 `postgres:16-alpine`(首次)后 `Container shaguabijia-pg Started`
- [ ] **Step 4: 验证两个库都在 + 健康**
Run: `docker compose exec -T postgres psql -U shaguabijia_app -d shaguabijia -tAc "SELECT datname FROM pg_database WHERE datname IN ('shaguabijia','shaguabijia_test') ORDER BY 1"`
Expected 输出:
```
shaguabijia
shaguabijia_test
```
- [ ] **Step 5: 验证 `.worktrees/``data/` 忽略不受影响、compose 无落盘到项目目录**
Run: `git status --short`
Expected: 只列出本任务新增的 `docker-compose.yml``docker/initdb/01-create-test-db.sql`(数据在命名卷 `pgdata`,不在项目目录;`.worktrees/` 已忽略)。
- [ ] **Step 6: Commit**
```bash
git add docker-compose.yml docker/initdb/01-create-test-db.sql
git commit -m "feat(dev): docker-compose 起本地 PostgreSQL(含测试库 initdb)"
```
---
## Task 2: `scripts/ensure_pg.py` 引导脚本(TDD)
**Files:**
- Create: `scripts/ensure_pg.py`
- Test: `tests/test_ensure_pg.py`
> 说明:纯函数(URL 解析 / sqlite 判定 / 端口探测 / 平台命令映射 / sqlite 守卫 / 端口通时短路)走 TDD 单测;真正拉 Docker 的编排 `ensure()` 全链路靠 Task 3/5 的运行来验证(需真 Docker,不做单测)。此时 `conftest.py` 仍是 SQLite,不依赖 PG,单测可独立跑。
- [ ] **Step 1: 写失败测试 `tests/test_ensure_pg.py`**
```python
"""scripts/ensure_pg.py 纯函数单测(不需要 Docker/PG)。"""
from __future__ import annotations
import socket
from scripts.ensure_pg import (
_docker_desktop_cmd,
_is_sqlite,
_parse_host_port,
_port_open,
ensure,
)
def test_is_sqlite():
assert _is_sqlite("sqlite:///./data/app.db")
assert _is_sqlite(" SQLite:///x ")
assert not _is_sqlite("postgresql+psycopg://u:p@localhost:5432/db")
def test_parse_host_port_full():
assert _parse_host_port(
"postgresql+psycopg://u:p@localhost:5432/shaguabijia"
) == ("localhost", 5432)
def test_parse_host_port_defaults():
# 缺端口 → 5432
assert _parse_host_port("postgresql+psycopg://u:p@db.example/x")[1] == 5432
# 缺 host → localhost
assert _parse_host_port("postgresql+psycopg:///x") == ("localhost", 5432)
def test_parse_host_port_testdb():
assert _parse_host_port(
"postgresql+psycopg://u:p@localhost:5432/shaguabijia_test"
) == ("localhost", 5432)
def test_port_open_true():
srv = socket.socket()
srv.bind(("127.0.0.1", 0))
srv.listen(1)
port = srv.getsockname()[1]
try:
assert _port_open("127.0.0.1", port, timeout=1.0)
finally:
srv.close()
def test_port_open_false():
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close() # 释放端口,无人监听 → 连接应失败
assert not _port_open("127.0.0.1", port, timeout=0.3)
def test_docker_desktop_cmd_windows():
cmd = _docker_desktop_cmd("win32", r"C:\Program Files")
assert cmd is not None
assert cmd[0].endswith("Docker Desktop.exe")
assert "Docker" in cmd[0]
def test_docker_desktop_cmd_darwin():
assert _docker_desktop_cmd("darwin", "") == ["open", "-a", "Docker"]
def test_docker_desktop_cmd_linux():
assert _docker_desktop_cmd("linux", "") is None
def test_ensure_rejects_sqlite():
# dev 守卫:sqlite 直接 False(不碰 Docker)
assert ensure("sqlite:///./data/app.db") is False
def test_ensure_shortcircuits_when_pg_up(monkeypatch):
# 端口通 → 直接 True,绝不触碰 docker
monkeypatch.setattr("scripts.ensure_pg._port_open", lambda *a, **k: True)
def _boom():
raise AssertionError("端口通时不应调用 docker")
monkeypatch.setattr("scripts.ensure_pg._docker_cli_ok", _boom)
assert ensure("postgresql+psycopg://u:p@localhost:5432/shaguabijia") is True
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pytest tests/test_ensure_pg.py -v`
Expected: FAIL —— `ModuleNotFoundError: No module named 'scripts.ensure_pg'`(还没建)。
- [ ] **Step 3: 写实现 `scripts/ensure_pg.py`**
```python
"""确保本地 PostgreSQL 就绪(开发/测试统一用 Docker PG)。
被三处复用:
- run.sh / run.bat:`python -m scripts.ensure_pg`(CLI,失败退非 0)
- tests/conftest.py:`from scripts.ensure_pg import ensure; ensure(test_url)`
流程:读 DATABASE_URL → TCP 探测 → 没起就(必要时启 Docker Desktop)→
`docker compose up -d` → 等 PG ready → 幂等确保测试库存在。全程无 SQLite 兜底。
生产用原生 PG(scripts/init_postgres.py),不走本模块。
"""
from __future__ import annotations
import os
import socket
import subprocess
import sys
import time
from pathlib import Path
from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parent.parent
# 日志里可能含 emoji(如 ✅);Windows GBK 控制台(cmd.exe)无法编码会抛 UnicodeEncodeError → 脚本崩、
# run.bat 误判 ensure_pg 失败。用 backslashreplace 保底:中文仍正常,仅不可编码字符被转义,不崩。
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(errors="backslashreplace")
except (AttributeError, ValueError):
pass
APP_DB = "shaguabijia"
TEST_DB = "shaguabijia_test"
DB_USER = "shaguabijia_app"
COMPOSE_SERVICE = "postgres"
DOCKER_START_TIMEOUT = int(os.environ.get("ENSURE_PG_DOCKER_TIMEOUT", "120"))
PG_READY_TIMEOUT = int(os.environ.get("ENSURE_PG_READY_TIMEOUT", "60"))
POLL_INTERVAL = 3.0
SQLITE_FIX_HINT = (
"postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia"
)
def _log(msg: str) -> None:
print(f"[ensure_pg] {msg}", flush=True)
def _is_sqlite(url: str) -> bool:
return url.strip().lower().startswith("sqlite")
def _parse_host_port(url: str) -> tuple[str, int]:
"""从 SQLAlchemy URL 取 host/port,缺省 localhost:5432。"""
parts = urlsplit(url)
return (parts.hostname or "localhost"), (parts.port or 5432)
def _port_open(host: str, port: int, timeout: float = 1.0) -> bool:
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except OSError:
return False
def _docker_desktop_cmd(platform: str, program_files: str) -> list[str] | None:
"""按平台给出启动 Docker Desktop 的命令;Linux 返回 None(daemon 需 sudo,让用户手动)。"""
if platform.startswith("win"):
return [str(Path(program_files) / "Docker" / "Docker" / "Docker Desktop.exe")]
if platform == "darwin":
return ["open", "-a", "Docker"]
return None
def _docker_ok(subcmd: str) -> bool:
"""`docker version`(CLI 在不在)/`docker info`(daemon 起没起)成功与否。"""
try:
subprocess.run(
["docker", subcmd],
cwd=ROOT,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True,
)
return True
except (OSError, subprocess.CalledProcessError):
return False
def _docker_cli_ok() -> bool:
return _docker_ok("version")
def _docker_daemon_ok() -> bool:
return _docker_ok("info")
def _start_docker_daemon() -> bool:
"""守护进程没起时按平台拉起,轮询到就绪。返回是否成功。"""
if _docker_daemon_ok():
return True
cmd = _docker_desktop_cmd(
sys.platform, os.environ.get("ProgramFiles", r"C:\Program Files")
)
if cmd is None:
_log("Docker 守护进程未运行。Linux 请手动:sudo systemctl start docker,然后重试。")
return False
if sys.platform.startswith("win") and not Path(cmd[0]).exists():
_log(f"找不到 Docker Desktop:{cmd[0]}。请手动启动 Docker Desktop 后重试。")
return False
_log(f"启动 Docker Desktop(首次冷启可能 30-60s)…")
try:
subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except OSError as e:
_log(f"启动 Docker Desktop 失败:{e}")
return False
deadline = time.monotonic() + DOCKER_START_TIMEOUT
while time.monotonic() < deadline:
if _docker_daemon_ok():
_log("Docker 守护进程已就绪。")
return True
_log("等待 Docker 守护进程…")
time.sleep(POLL_INTERVAL)
_log(f"等待 Docker 守护进程超时({DOCKER_START_TIMEOUT}s)。")
return False
def _compose_up() -> bool:
_log("docker compose up -d(镜像缺失会自动拉取,首用约几十秒)…")
try:
subprocess.run(["docker", "compose", "up", "-d"], cwd=ROOT, check=True)
return True
except (OSError, subprocess.CalledProcessError) as e:
_log(f"docker compose up 失败:{e}")
return False
def _pg_isready() -> bool:
r = subprocess.run(
["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
"pg_isready", "-U", DB_USER, "-d", APP_DB],
cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
return r.returncode == 0
def _wait_pg_ready(host: str, port: int) -> bool:
deadline = time.monotonic() + PG_READY_TIMEOUT
while time.monotonic() < deadline:
if _port_open(host, port) and _pg_isready():
_log("PostgreSQL 已就绪。")
return True
_log("等待 PostgreSQL 就绪…")
time.sleep(POLL_INTERVAL)
_log(f"等待 PostgreSQL 就绪超时({PG_READY_TIMEOUT}s)。")
return False
def _ensure_test_db() -> None:
"""幂等建测试库(兼容老 pgdata 卷首启没跑 initdb 的情况)。"""
check = subprocess.run(
["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
"psql", "-U", DB_USER, "-d", APP_DB, "-tAc",
f"SELECT 1 FROM pg_database WHERE datname='{TEST_DB}'"],
cwd=ROOT, capture_output=True, text=True,
)
if check.returncode == 0 and check.stdout.strip() == "1":
return
_log(f"建测试库 {TEST_DB}…")
subprocess.run(
["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
"psql", "-U", DB_USER, "-d", APP_DB, "-c",
f"CREATE DATABASE {TEST_DB} OWNER {DB_USER}"],
cwd=ROOT, check=False,
)
def ensure(database_url: str | None = None) -> bool:
"""确保 PG 就绪,返回 True/False。database_url 缺省从 settings 读(尊重 .env)。"""
if database_url is None:
from app.core.config import settings # 延迟导入,避免过早固化 settings
database_url = settings.DATABASE_URL
if _is_sqlite(database_url):
_log("检测到 DATABASE_URL 仍是 SQLite。本地开发/测试已切 PostgreSQL,请改成:")
_log(f" DATABASE_URL={SQLITE_FIX_HINT}")
return False
host, port = _parse_host_port(database_url)
if _port_open(host, port):
_log(f"✅ PostgreSQL 已在 {host}:{port} 运行,跳过 Docker。")
return True
_log(f"{host}:{port} 无 PostgreSQL,准备用 Docker 拉起…")
if not _docker_cli_ok():
_log("未检测到 docker 命令。请先安装 Docker Desktop:")
_log(" https://www.docker.com/products/docker-desktop/")
return False
if not _start_docker_daemon():
return False
if not _compose_up():
return False
if not _wait_pg_ready(host, port):
return False
_ensure_test_db()
return True
if __name__ == "__main__":
sys.exit(0 if ensure() else 1)
```
- [ ] **Step 4: 跑测试确认通过**
Run: `pytest tests/test_ensure_pg.py -v`
Expected: 11 passed。
- [ ] **Step 5: 手动冒烟(PG 已在跑时应秒过短路)**
Run: `python -m scripts.ensure_pg`
Expected: 打印 `[ensure_pg] ✅ PostgreSQL 已在 localhost:5432 运行,跳过 Docker。`,退出码 0。
- [ ] **Step 6: Commit**
```bash
git add scripts/ensure_pg.py tests/test_ensure_pg.py
git commit -m "feat(dev): scripts/ensure_pg.py 探测/拉起本地 Docker PostgreSQL"
```
---
## Task 3: `run.sh` / `run.bat` 接入 ensure_pg
**Files:**
- Modify: `run.sh`(在 `alembic upgrade head` 前插一步)
- Modify: `run.bat`(同上)
- [ ] **Step 1: 改 `run.sh`**
`mkdir -p data` 之后、`"$PY" -m alembic upgrade head` 之前插入:
```bash
"$PY" -m scripts.ensure_pg # 确保本地 Docker PostgreSQL 就绪(没起会自动拉起;失败即退出)
```
(`set -e` 已在文件顶部,ensure_pg 失败会自动终止脚本。)
- [ ] **Step 2: 改 `run.bat`**
`if not exist data mkdir data` 之后、`call "%PY%" -m alembic upgrade head` 之前插入:
```bat
REM 确保本地 Docker PostgreSQL 就绪(没起会自动拉起 Docker + PG 容器)
call "%PY%" -m scripts.ensure_pg
if errorlevel 1 (
echo [X] ensure_pg failed ^(PostgreSQL 未就绪^)
exit /b %errorlevel%
)
```
- [ ] **Step 3: 验证 `run.sh`(PG 已在跑,应短路后继续 alembic + uvicorn)**
Run(Git Bash):`bash run.sh 8770`
Expected: 依次出现 `[ensure_pg] ✅ PostgreSQL 已在 localhost:5432 运行` → alembic 无报错 → uvicorn `Application startup complete``Ctrl-C` 停。
- [ ] **Step 4: 验证 `run.bat`(同上,Windows 原生)**
Run(cmd/PowerShell):`.\run.bat 8770`
Expected: 同 Step 3。`Ctrl-C` 停。
- [ ] **Step 5: Commit**
```bash
git add run.sh run.bat
git commit -m "feat(dev): run.sh/run.bat 启动前确保 Docker PostgreSQL 就绪"
```
---
## Task 4: `.env.example` 默认切 PostgreSQL
**Files:**
- Modify: `.env.example`(第 8-10 行「数据库」段)
- [ ] **Step 1: 改 `.env.example` 的 DATABASE_URL**
把:
```ini
# ===== 数据库 =====
# SQLite 本地文件路径。生产环境用 /opt/shaguabijia-app-server/data.db
DATABASE_URL=sqlite:///./data/app.db
```
改成:
```ini
# ===== 数据库 =====
# 本地开发/测试统一用 Docker PostgreSQL:run.bat/run.sh 会自动拉起容器
# (docker-compose.yml + scripts/ensure_pg.py)。详见 docs/database/postgres-migration.md。
# 生产用原生 PG,由 scripts/init_postgres.py 写入强随机密码的连接串。
# ⚠️ scheme 必须是 postgresql+psycopg://(psycopg3);不要写成 postgresql://(会去找未装的 psycopg2)。
DATABASE_URL=postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia
```
- [ ] **Step 2: 验证(新 .env 从模板复制后能起服务)**
Run: `cp .env.example /tmp/env.check && grep '^DATABASE_URL=' /tmp/env.check`
Expected: `DATABASE_URL=postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia`
- [ ] **Step 3: Commit**
```bash
git add .env.example
git commit -m "feat(dev): .env.example 默认 DATABASE_URL 切 Docker PostgreSQL"
```
---
## Task 5: `tests/conftest.py` 切 PostgreSQL 测试库
**Files:**
- Modify: `tests/conftest.py`(整体替换:去掉临时 SQLite,改指 `shaguabijia_test` + 调 ensure_pg + fixture 改 drop/create)
- [ ] **Step 1: 整体替换 `tests/conftest.py`**
```python
"""测试用 fixtures。
测试库用 Docker PG 的 shaguabijia_test(与 dev 业务库 shaguabijia 隔离)。
顺序(必须):设 test DATABASE_URL(在 import app.* 之前)→ ensure PG 就绪 →
import app → 建表。持久卷可能残留上次的表 → session 开头先 drop 再 create。
"""
from __future__ import annotations
import os
from collections.abc import Iterator
# 1) 测试库连接串——必须在 import app.* 之前设好(app.db.session 在 import 期建 engine)
_TEST_DB_URL = (
"postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia_test"
)
os.environ["DATABASE_URL"] = _TEST_DB_URL
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-please-ignore-this-is-only-for-pytest-not-real")
os.environ.setdefault("ADMIN_JWT_SECRET", "test-admin-secret-please-ignore-only-for-pytest-not-real")
os.environ.setdefault("JG_APP_KEY", "test-key")
os.environ.setdefault("JG_MASTER_SECRET", "test-secret")
os.environ.setdefault("SMS_MOCK", "true")
os.environ.setdefault("APP_ENV", "dev")
os.environ.setdefault("APP_DEBUG", "false") # 测试不打 SQL 日志
os.environ.setdefault("WECHAT_APP_ID", "wxtest0000000000")
os.environ.setdefault("WECHAT_APP_SECRET", "test-secret")
os.environ.setdefault("WXPAY_MCH_ID", "test-mch")
os.environ.setdefault("WXPAY_MCH_SERIAL_NO", "test-serial")
os.environ.setdefault("WXPAY_PUBLIC_KEY_ID", "test-pubkey-id")
os.environ.setdefault("RATE_LIMIT_ENABLED", "false")
os.environ.setdefault("PANGLE_CALLBACK_ENABLED", "true")
os.environ.setdefault("PANGLE_REWARD_SECRET", "test-pangle-secret-only-for-pytest")
# 2) 保证 Docker PG 就绪 + 测试库存在(必须在 import app.db.session 建 engine 之前)
from scripts.ensure_pg import ensure
if not ensure(_TEST_DB_URL):
raise RuntimeError(
"测试需要 Docker PostgreSQL 就绪。请确认已装 Docker Desktop;"
"或先跑一次 run.bat/run.sh 把 PG 拉起,再重试 pytest。"
)
import pytest
from fastapi.testclient import TestClient
from app.db.base import Base
from app.db.session import engine
from app.main import app
@pytest.fixture(scope="session", autouse=True)
def _setup_db() -> Iterator[None]:
# 持久卷可能残留上次跑崩后的表/数据 → 先 drop 再 create,保证干净起点
Base.metadata.drop_all(engine)
Base.metadata.create_all(engine)
yield
Base.metadata.drop_all(engine)
@pytest.fixture()
def client() -> TestClient:
return TestClient(app)
```
- [ ] **Step 2: 验证 conftest 能引导 PG 并收集用例(选一个不涉 DB 的测试文件)**
Run: `pytest tests/test_ensure_pg.py -v`
Expected: conftest 先打印 `[ensure_pg] ✅ PostgreSQL 已在 localhost:5432 运行`(或拉起过程),随后 12 passed。说明「测试走 PG 引导」链路通、且纯函数测试不受影响。
- [ ] **Step 3: 验证建表落到 PG 测试库(跑一个 DB 相关用例)**
Run: `pytest tests/test_invite.py -v`
Expected: 用例在 `shaguabijia_test` 上建表并执行(可能有个别红,留待 Task 6);关键是不再出现 SQLite 临时文件、engine 连的是 PG。
- [ ] **Step 4: Commit**
```bash
git add tests/conftest.py
git commit -m "test(dev): conftest 切 shaguabijia_test(Docker PG),引导+drop/create"
```
---
## Task 6: 全量跑 pytest on PG,逐个修红用例
> SQLite 宽松、PG 严格,切库会暴露一批真 bug(迁移指南 §2.2 已列)。本任务是**发现驱动**:先跑全量、按类别归因、按下述配方修,直到全绿。修改范围限被测业务/模型代码,不改测试来掩盖真 bug(除非测试本身依赖 SQLite 特性,如秒级时间精度)。
**Files:**
- Modify: 视失败而定(常见:`app/models/*.py``app/**/repositories/*.py`、少量 `tests/*.py`)
- [ ] **Step 1: 全量跑,拿到失败清单**
Run: `pytest -q`
Expected: 大部分通过;记录所有 FAIL 的用例名与报错文本,按下面类别归因。
- [ ] **Step 2: 修「naive datetime / 时区」类**
定位:`git grep -n "utcnow()" app/`。把 `datetime.utcnow()` 改成 `datetime.now(timezone.utc)`(并 `from datetime import timezone`)。
症状:PG `TIMESTAMPTZ` 与 naive datetime 比较/写入报错或结果错位;`tests/test_cps_admin.py` 已注释过 SQLite 忽略 tzinfo 的行为。
例:
```python
# 改前
from datetime import datetime
ts = datetime.utcnow()
# 改后
from datetime import datetime, timezone
ts = datetime.now(timezone.utc)
```
- [ ] **Step 3: 修「字符串/整数隐式比较」类**
症状:SQLite 允许 `WHERE phone = 13800138000`(自动转型),PG 直接报类型错。定位报错用例引用的查询,确保比较两侧类型一致(手机号等一律按字符串传参 `:phone`,不要传裸 int)。
- [ ] **Step 3.5: 修「事务已中止」类**
症状:某用例后续报 `current transaction is aborted, commands ignored until end of transaction block`,根因是前一句 SQL 出错后业务代码缺 `db.rollback()`/`db.commit()` 边界。补上正确的 commit/rollback。
- [ ] **Step 4: 修「测试依赖 SQLite 特性」类(仅此类可改测试)**
症状:测试断言依赖 SQLite 秒级时间精度或 FK 不强制(见 `test_invite.py:328``test_compare_harvest.py:153` 的注释)。PG 下时间精度更高/FK 更严——调整测试数据(如手动拉开时间间隔、用合法 FK)使断言在 PG 下成立,不改业务逻辑。
- [ ] **Step 5: 反复跑到全绿**
Run: `pytest -q`
Expected: `N passed`(0 failed)。若仍有红,回到 Step 2-4 继续归因。
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "fix(db): 测试套件切 PostgreSQL 后修复严格性暴露的用例"
```
---
## Task 7: 文档更新
**Files:**
- Modify: `docs/database/postgres-migration.md`(§1 增「本地 Docker 一键起」小节)
- Modify: `CLAUDE.md`(DB 段注明 dev/test = Docker PG)
- Modify: `scripts/init_postgres.py`(顶部注释区分生产/本地)
- [ ] **Step 1: `postgres-migration.md` 在「## 1. 本地起 PG」开头插入推荐做法**
`## 1. 本地起 PG + 跑通空库(半天)` 标题下、`### 1.1 装 PG` 之前插入:
```markdown
### 1.0 推荐:Docker 一键起(本地开发/测试)
本地开发不必手动装 PG。已提供 `docker-compose.yml` + `scripts/ensure_pg.py`:
```bash
cp .env.example .env # DATABASE_URL 默认已是 Docker PG 连接串
./run.sh # 或 run.bat;会自动:探测 PG → 没起则启 Docker → 起 PG 容器 → 建库 → alembic → uvicorn
pytest # conftest 自动引导同一容器的 shaguabijia_test 库
```
容器:`postgres:16-alpine`(名 `shaguabijia-pg`,端口 5432,命名卷 `pgdata` 持久化),
首启即建业务库 `shaguabijia` 与测试库 `shaguabijia_test`。下面 1.1-1.5 的手动装 PG 步骤仅在
不用 Docker 时才需要;生产仍走 §4 的原生 PG。
```
- [ ] **Step 2: `CLAUDE.md` DB 段补充**
找到 DB 相关行(`**Prod**: PostgreSQL — just change DATABASE_URL...`)所在段,在其上方加一行:
```markdown
- **Dev/Test**: Docker PostgreSQL 16 — `run.sh`/`run.bat``scripts/ensure_pg.py` + `docker-compose.yml` 自动拉起;`.env.example` 默认即 PG 连接串;pytest 用同容器的 `shaguabijia_test` 库。**本地不再用 SQLite**。
```
- [ ] **Step 3: `scripts/init_postgres.py` 顶部注释区分场景**
把模块 docstring 第一行下方(`新机器初始化用。前置:...` 那行)改为:
```python
新机器初始化用(面向【生产原生 PG】:apt/systemd 装好的 PostgreSQL)。
本地开发/测试请改用 docker-compose.yml + scripts/ensure_pg.py(run.sh/run.bat 自动拉起),不必跑本脚本。
前置:已装 PostgreSQL 16 + 知道 postgres 超级用户密码。
```
- [ ] **Step 4: 验证无坏链接/格式**
Run: `git diff --stat`
Expected: 三个文档文件有改动,无其他文件被误改。
- [ ] **Step 5: Commit**
```bash
git add docs/database/postgres-migration.md CLAUDE.md scripts/init_postgres.py
git commit -m "docs(dev): 记录本地 Docker PostgreSQL 用法,区分生产原生 PG 路径"
```
---
## 完成标准(对齐 spec §8 验收)
- [ ] 全新机器(装了 Docker Desktop、`.env``.env.example` 复制)跑 `run.bat`/`run.sh` 全自动拉起 PG 并起服务,无手动装 PG。
- [ ] `docker ps``shaguabijia-pg` healthy;`shaguabijia``shaguabijia_test` 两库都在。
- [ ] PG 已在跑时再跑 `run`,ensure_pg 秒过短路。
- [ ] `pytest -q` 全绿(连 `shaguabijia_test`)。
- [ ] `.env` 改回 sqlite 时,`python -m scripts.ensure_pg` 硬失败并打印正确 PG 串。
- [ ] 能在 `admin/repositories` 写一段 PG 专有聚合(如 `count(*) FILTER (WHERE ...)`),`run` 手动跑通且相应 pytest 通过。
## Self-Review 记录(计划作者已核)
- **Spec 覆盖:** spec §9 待实现清单 8 项 → Task 1(compose+initdb)、Task 2(ensure_pg)、Task 3(run 接线)、Task 4(.env.example)、Task 5(conftest)、Task 6(修红用例)、Task 7(文档);「session.py 无需改」在 Header 与 spec §4.7 说明;「data/ 忽略」在 Task 1 Step 5 验证。无遗漏。
- **占位符:** 全部步骤含真实代码/命令/期望输出。Task 6 是发现驱动,已用「类别+具体转换配方+定位命令」代替不可预知的逐条 diff——非占位。
- **类型/命名一致:** `ensure(database_url=None)` 签名在 Task 2 定义,Task 5 以 `ensure(_TEST_DB_URL)` 调用一致;库名/用户/密码/端口全程为 Header 固定值;compose 服务名 `postgres` 与 ensure_pg `COMPOSE_SERVICE` 一致;`shaguabijia_test` 在 initdb SQL、`_ensure_test_db()`、conftest 三处一致。
@@ -1,639 +0,0 @@
# 短信 极光→创蓝 容灾 fallback 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 极光为主、创蓝为备的短信验证码容灾——极光供应商不可用(503)时自动转创蓝补发,发/校验全程后台可区分实际渠道,由配置开关控制、可秒回退。
**Architecture:** 全部逻辑收在分派层 `app/integrations/sms/__init__.py`provider 文件 jiguang/chuanglan/aliyun 一行不动)。`send_code` 返回携带渠道的 `SendResult``verify_code` 用 try-both 遍历「启用的 fallback 链」。开关 `SMS_FALLBACK_PROVIDER` 默认空=关。
**Tech Stack:** FastAPI + pydantic-settings + pytest(monkeypatch)。SMS provider 均 Mode B(本地内存自管码)。
设计依据:`docs/superpowers/specs/2026-07-30-sms-jiguang-chuanglan-fallback-design.md`
---
## 文件结构
| 文件 | 职责 | 动作 |
|---|---|---|
| `app/integrations/sms/base.py` | 加 `SendResult` 结果类型(provider 无关,与 `SmsError` 同层) | 修改 |
| `app/core/config.py` | 加 `SMS_FALLBACK_PROVIDER` 开关 | 修改 |
| `app/integrations/sms/__init__.py` | 分派层:主/备解析 + `send_code` fallback + `verify_code` try-both | 修改(核心) |
| `app/api/v1/auth.py` | `sms_send``SendResult.cooldown_sec` + `details``provider`/`fallback` | 修改(一处) |
| `.env.example` | 加 `SMS_FALLBACK_PROVIDER=` 注释 | 修改 |
| `tests/test_sms_dispatch.py` | 现有 3 处 `== 60` 断言改读 `.cooldown_sec` | 修改 |
| `tests/test_sms_fallback.py` | fallback + try-both 全量新测试 | 新建 |
| `tests/test_auth.py` | `sms_send` 成功 `details` 含 provider/fallback | 修改(加 1 用例) |
`jiguang.py` / `chuanglan.py` / `aliyun.py` **不动**
---
## Task 1: `SendResult` 结果类型(base.py
**Files:**
- Modify: `app/integrations/sms/base.py`
- Test: `tests/test_sms_base.py`(新建)
- [ ] **Step 1: 写失败测试**
Create `tests/test_sms_base.py`:
```python
"""SMS 共享基座类型 SendResult。"""
from __future__ import annotations
import dataclasses
import pytest
from app.integrations.sms.base import SendResult
def test_send_result_fields():
r = SendResult(cooldown_sec=60, provider="jiguang", fallback=True)
assert r.cooldown_sec == 60
assert r.provider == "jiguang"
assert r.fallback is True
def test_send_result_fallback_defaults_false():
r = SendResult(cooldown_sec=60, provider="jiguang")
assert r.fallback is False
def test_send_result_is_frozen():
r = SendResult(cooldown_sec=60, provider="jiguang")
with pytest.raises(dataclasses.FrozenInstanceError):
r.provider = "chuanglan"
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_sms_base.py -q`
Expected: FAIL — `ImportError: cannot import name 'SendResult'`
- [ ] **Step 3: 最小实现**
`app/integrations/sms/base.py` 顶部 import 区加 `from dataclasses import dataclass`,并在 `SmsError` 类后新增:
```python
@dataclass(frozen=True)
class SendResult:
"""发码结果:距下次可发秒数 + 实际发码的 provider 名 + 是否走了备(fallback)。
分派层 send_code 的返回类型(provider 各自的 send_code 仍返回 int cooldown
由分派层包装)。auth 层据此把渠道记入风控流水 details。
"""
cooldown_sec: int
provider: str
fallback: bool = False
```
- [ ] **Step 4: 跑测试确认通过**
Run: `python -m pytest tests/test_sms_base.py -q`
Expected: PASS (3 passed)
- [ ] **Step 5: 提交**
```bash
git add app/integrations/sms/base.py tests/test_sms_base.py
git commit -m "feat(sms): 加 SendResult 结果类型(cooldown+provider+fallback)"
```
---
## Task 2: `SMS_FALLBACK_PROVIDER` 配置开关(config.py
**Files:**
- Modify: `app/core/config.py``SMS_PROVIDER` 定义附近,约 line 150
- Test: `tests/test_sms_fallback.py`(新建,本 Task 起逐步补全)
- [ ] **Step 1: 写失败测试**
Create `tests/test_sms_fallback.py`:
```python
"""极光→创蓝 容灾 fallback(分派层 send_code / verify_code)。
monkeypatch 各 provider 的 send_code/verify_code 为标记函数,不发真网络;
断言:仅主 503 才转备;429/400 不转;verify try-both 只遍历启用的链。
"""
from __future__ import annotations
import pytest
from app.core.config import settings
from app.integrations import sms
from app.integrations.sms import aliyun, chuanglan, jiguang
from app.integrations.sms.base import SendResult, SmsError
PHONE = "13800138000"
def _raiser(exc: Exception):
"""返回一个调用即 raise exc 的 send_code 替身(lambda 不能 raise)。"""
def _f(phone):
raise exc
return _f
def test_fallback_provider_defaults_empty():
assert settings.SMS_FALLBACK_PROVIDER == ""
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_sms_fallback.py::test_fallback_provider_defaults_empty -q`
Expected: FAIL — `AttributeError: 'Settings' object has no attribute 'SMS_FALLBACK_PROVIDER'`
- [ ] **Step 3: 最小实现**
`app/core/config.py``SMS_PROVIDER` 定义行下方新增:
```python
# 主 provider 供应商不可用(SmsError 503)时转投的备 provider;空=关闭 fallback(默认,
# 保持现状零风险)。仅 Mode B(jiguang/chuanglan)互为主备有意义;生产建议设 "chuanglan"。
SMS_FALLBACK_PROVIDER: Literal["jiguang", "chuanglan", ""] = ""
```
- [ ] **Step 4: 跑测试确认通过**
Run: `python -m pytest tests/test_sms_fallback.py::test_fallback_provider_defaults_empty -q`
Expected: PASS
- [ ] **Step 5: 提交**
```bash
git add app/core/config.py tests/test_sms_fallback.py
git commit -m "feat(sms): 加 SMS_FALLBACK_PROVIDER 开关(默认空=关)"
```
---
## Task 3: 分派层 `send_code` 返回 SendResult(主路径)
先让主路径返回 `SendResult`(不含 fallback),并把现有 `test_sms_dispatch.py``== 60` 断言迁到 `.cooldown_sec`。fallback 分支在 Task 4 加。
**Files:**
- Modify: `app/integrations/sms/__init__.py`
- Modify: `tests/test_sms_dispatch.py`line 20/22/24、line 45-53
- Test: `tests/test_sms_fallback.py`
- [ ] **Step 1: 写失败测试**
`tests/test_sms_fallback.py` 追加:
```python
def test_send_code_primary_success_returns_sendresult(monkeypatch):
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "")
monkeypatch.setattr(jiguang, "send_code", lambda phone: 60)
r = sms.send_code(PHONE)
assert isinstance(r, SendResult)
assert r.cooldown_sec == 60
assert r.provider == "jiguang"
assert r.fallback is False
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_sms_fallback.py::test_send_code_primary_success_returns_sendresult -q`
Expected: FAIL — `assert isinstance(r, SendResult)`(现 send_code 返回 int 60
- [ ] **Step 3: 最小实现**
`app/integrations/sms/__init__.py` 整体替换为(本 Task 只到主路径;Task 4/5 再扩 fallback 与 try-both):
```python
"""短信验证码服务 —— provider 分派入口(极光主 + 可选创蓝备)。
对外暴露 `send_code`(返回 SendResult / `verify_code` / `SmsError` / `SendResult`
主 provider = settings.SMS_PROVIDER(默认 jiguang);备 = settings.SMS_FALLBACK_PROVIDER
(默认空=无备)。**每次调用读 settings**,支持运行时切换 / 灰度回退。
"""
from __future__ import annotations
import logging
from app.core.config import settings
from . import aliyun, chuanglan, jiguang
from .base import SendResult, SmsError
__all__ = ["SmsError", "SendResult", "send_code", "verify_code"]
logger = logging.getLogger("shagua.sms")
# provider 名 <-> 模块。未知/缺省主 provider 回退 jiguang(防误配把登录打挂)。
_ALL = {"jiguang": jiguang, "aliyun": aliyun, "chuanglan": chuanglan}
_NAME = {jiguang: "jiguang", aliyun: "aliyun", chuanglan: "chuanglan"}
def _primary():
"""当前主 provider 模块(未知/缺省回退 jiguang)。"""
return _ALL.get(settings.SMS_PROVIDER, jiguang)
def _fallback():
"""启用的备 provider 模块;未配置 / 同主 / 未知名 → None= 不启用 fallback)。"""
name = (settings.SMS_FALLBACK_PROVIDER or "").strip()
if not name or name == settings.SMS_PROVIDER:
return None
return _ALL.get(name)
def send_code(phone: str) -> SendResult:
"""发码:主成功即返回;失败抛 SmsError。返回 SendResultcooldown + 实际渠道)。"""
primary = _primary()
cooldown = primary.send_code(phone)
return SendResult(cooldown_sec=cooldown, provider=_NAME[primary], fallback=False)
def verify_code(phone: str, code: str) -> bool:
"""校验:委托当前主 providertry-both 在 Task 5 加)。"""
return _primary().verify_code(phone, code)
```
- [ ] **Step 4: 迁移现有 dispatch 测试断言**
`tests/test_sms_dispatch.py` 中,把 3 处 `assert sms.send_code("13800138000") == 60`line 20/22/24)改为:
```python
assert sms.send_code("13800138000").cooldown_sec == 60
```
line 45-53 的 `test_unknown_provider_falls_back_to_jiguang``sms.send_code(...)` 不比较返回值,无需改;`test_verify_code_routes_by_provider` 不涉及返回值,无需改。)
- [ ] **Step 5: 跑测试确认通过**
Run: `python -m pytest tests/test_sms_fallback.py tests/test_sms_dispatch.py -q`
Expected: PASS(新用例 + 原 dispatch 3 用例全绿)
- [ ] **Step 6: 提交**
```bash
git add app/integrations/sms/__init__.py tests/test_sms_dispatch.py tests/test_sms_fallback.py
git commit -m "feat(sms): send_code 返回 SendResult(主路径),迁移 dispatch 断言"
```
---
## Task 4: 分派层 `send_code` fallback 分支(仅 503 转备)
**Files:**
- Modify: `app/integrations/sms/__init__.py``send_code`
- Test: `tests/test_sms_fallback.py`
- [ ] **Step 1: 写失败测试**
`tests/test_sms_fallback.py` 追加:
```python
def test_send_code_503_falls_back_to_configured_backup(monkeypatch):
calls = []
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "send_code",
_raiser(SmsError("极光不可用", status_code=503)))
monkeypatch.setattr(chuanglan, "send_code",
lambda phone: (calls.append("chuanglan"), 60)[1])
r = sms.send_code(PHONE)
assert r.provider == "chuanglan"
assert r.fallback is True
assert r.cooldown_sec == 60
assert calls == ["chuanglan"]
@pytest.mark.parametrize("status", [429, 400])
def test_send_code_non_503_does_not_fall_back(monkeypatch, status):
calls = []
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "send_code",
_raiser(SmsError("x", status_code=status)))
monkeypatch.setattr(chuanglan, "send_code",
lambda phone: (calls.append("chuanglan"), 60)[1])
with pytest.raises(SmsError) as ei:
sms.send_code(PHONE)
assert ei.value.status_code == status
assert calls == [] # 备未被调用
def test_send_code_503_without_backup_reraises(monkeypatch):
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "") # 无备
monkeypatch.setattr(jiguang, "send_code",
_raiser(SmsError("极光不可用", status_code=503)))
with pytest.raises(SmsError) as ei:
sms.send_code(PHONE)
assert ei.value.status_code == 503
def test_send_code_backup_also_fails_raises_backup_error(monkeypatch):
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "send_code",
_raiser(SmsError("极光不可用", status_code=503)))
monkeypatch.setattr(chuanglan, "send_code",
_raiser(SmsError("创蓝也不可用", status_code=503)))
with pytest.raises(SmsError) as ei:
sms.send_code(PHONE)
assert "创蓝" in str(ei.value)
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_sms_fallback.py -k "fall_back or 503 or non_503 or backup" -q`
Expected: FAIL — 主抛 503 时当前 `send_code` 未捕获,直接透出(`test_...falls_back...` 期望走到创蓝但没走)
- [ ] **Step 3: 实现 fallback 分支**
`app/integrations/sms/__init__.py``send_code` 替换为:
```python
def send_code(phone: str) -> SendResult:
"""发码:主成功即返回;仅主「供应商不可用(503)」且配置了备时转备补发。
429(本地冷却/超频)、400(手机号无效)不转——不绕过防刷、不为无效号白烧。
备也失败则抛备的 SmsError。返回 SendResultcooldown + 实际渠道 + 是否 fallback)。
"""
primary = _primary()
fb = _fallback()
try:
cooldown = primary.send_code(phone)
return SendResult(cooldown_sec=cooldown, provider=_NAME[primary], fallback=False)
except SmsError as e:
if fb is not None and e.status_code == 503:
logger.warning("[SMS] primary=%s 不可用(%s)fallback→%s",
_NAME[primary], e, _NAME[fb])
cooldown = fb.send_code(phone) # 备的冷却/错误码原样透出
return SendResult(cooldown_sec=cooldown, provider=_NAME[fb], fallback=True)
raise
```
- [ ] **Step 4: 跑测试确认通过**
Run: `python -m pytest tests/test_sms_fallback.py -q`
Expected: PASS(含 parametrize 的 429/400 两条)
- [ ] **Step 5: 提交**
```bash
git add app/integrations/sms/__init__.py tests/test_sms_fallback.py
git commit -m "feat(sms): send_code 仅主 503 时转创蓝补发(429/400 不转)"
```
---
## Task 5: 分派层 `verify_code` try-both(只遍历启用的链)
**Files:**
- Modify: `app/integrations/sms/__init__.py``verify_code`
- Test: `tests/test_sms_fallback.py`
- [ ] **Step 1: 写失败测试**
`tests/test_sms_fallback.py` 追加:
```python
def test_verify_hits_primary_without_touching_backup(monkeypatch):
calls = []
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "verify_code",
lambda p, c: (calls.append("jiguang"), True)[1])
monkeypatch.setattr(chuanglan, "verify_code",
lambda p, c: (calls.append("chuanglan"), True)[1])
assert sms.verify_code(PHONE, "123456") is True
assert calls == ["jiguang"] # 主命中即停,不查备
def test_verify_falls_through_to_backup(monkeypatch):
calls = []
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "verify_code",
lambda p, c: (calls.append("jiguang"), False)[1])
monkeypatch.setattr(chuanglan, "verify_code",
lambda p, c: (calls.append("chuanglan"), True)[1])
assert sms.verify_code(PHONE, "123456") is True
assert calls == ["jiguang", "chuanglan"] # 主未命中 → 查备(码在备)
def test_verify_all_miss_returns_false(monkeypatch):
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "verify_code", lambda p, c: False)
monkeypatch.setattr(chuanglan, "verify_code", lambda p, c: False)
assert sms.verify_code(PHONE, "123456") is False
def test_verify_backup_not_touched_when_fallback_off(monkeypatch):
calls = []
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "") # 关闭 fallback
monkeypatch.setattr(jiguang, "verify_code",
lambda p, c: (calls.append("jiguang"), False)[1])
monkeypatch.setattr(chuanglan, "verify_code",
lambda p, c: (calls.append("chuanglan"), True)[1])
assert sms.verify_code(PHONE, "123456") is False
assert calls == ["jiguang"] # 关闭时创蓝零参与
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_sms_fallback.py -k verify -q`
Expected: FAIL — `test_verify_falls_through_to_backup`(当前 verify 只问主,主 False 即返 False,不查备)
- [ ] **Step 3: 实现 try-both**
`app/integrations/sms/__init__.py``verify_code` 替换为:
```python
def verify_code(phone: str, code: str) -> bool:
"""校验:try-both,遍历「启用的 fallback 链」(主→备),任一命中即 True。
码只存在实际发码那家(fallback 前主已 pop 掉自己的码),另一家 rec is None 即 False、
不误判、不累加其防爆破计数。关闭 fallback 时链中只有主,备完全不参与。
"""
chain = [_primary()]
fb = _fallback()
if fb is not None:
chain.append(fb)
for prov in chain:
if prov.verify_code(phone, code): # Mode B:纯本地内存比对,不联网
logger.info("[SMS] verify hit provider=%s", _NAME[prov])
return True
return False
```
- [ ] **Step 4: 跑测试确认通过**
Run: `python -m pytest tests/test_sms_fallback.py tests/test_sms_dispatch.py -q`
Expected: PASS(全部 fallback + 原 dispatch 用例)
- [ ] **Step 5: 提交**
```bash
git add app/integrations/sms/__init__.py tests/test_sms_fallback.py
git commit -m "feat(sms): verify_code try-both 遍历启用的 fallback 链"
```
---
## Task 6: auth `sms_send` 接入 SendResult + 记 provider/fallback
**Files:**
- Modify: `app/api/v1/auth.py`line 210 调用、line 231-243 成功侧 details
- Test: `tests/test_auth.py`(加 1 用例)
- [ ] **Step 1: 写失败测试**
先确认现有 `test_auth.py``sms_send` 的调用/断言与 fixture 写法(找一个已有的 sms_send 成功用例作参照),再在 `tests/test_auth.py` 末尾追加:
```python
def test_sms_send_records_provider_in_details(client, db_session, monkeypatch):
"""sms_send 成功后风控流水 details 带 provider/fallbackmock 下 jiguang/False)。"""
from app.repositories import risk as risk_repo
captured = {}
orig = risk_repo.record_behavior_event
def _spy(db, **kw):
if kw.get("event_type") == risk_repo.EVENT_SMS_SEND and kw.get("outcome") == "success":
captured.update(kw.get("details") or {})
return orig(db, **kw)
monkeypatch.setattr(risk_repo, "record_behavior_event", _spy)
resp = client.post("/api/v1/auth/sms/send",
json={"phone": "13800009999", "device_id": "dev-provider-test"})
assert resp.status_code == 200, resp.text
assert captured.get("provider") == "jiguang"
assert captured.get("fallback") is False
```
> 注:`client` / `db_session` fixture 名、请求路径前缀(`/api/v1/auth/...`)、`record_behavior_event` 的 import 路径以 `tests/test_auth.py` 现有用例为准,若不同则对齐后再写。
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_auth.py::test_sms_send_records_provider_in_details -q`
Expected: FAIL — `captured.get("provider")` 为 None(当前 details 只有 `{"mock": ...}`
- [ ] **Step 3: 实现**
`app/api/v1/auth.py``sms_send` 中:
(a) line 209-210 的
```python
try:
cooldown = send_code(req.phone)
```
改为
```python
try:
send_result = send_code(req.phone)
cooldown = send_result.cooldown_sec
```
(b) line 231-243 成功侧 `record_behavior_event``details`
```python
details={"mock": settings.SMS_MOCK},
```
改为
```python
details={"mock": settings.SMS_MOCK,
"provider": send_result.provider,
"fallback": send_result.fallback},
```
- [ ] **Step 4: 跑测试确认通过**
Run: `python -m pytest tests/test_auth.py::test_sms_send_records_provider_in_details -q`
Expected: PASS
- [ ] **Step 5: 提交**
```bash
git add app/api/v1/auth.py tests/test_auth.py
git commit -m "feat(sms): sms_send 记 provider/fallback 到风控流水 details"
```
---
## Task 7: `.env.example` + 全量回归 + ruff
**Files:**
- Modify: `.env.example`
- [ ] **Step 1: 加配置注释**
`.env.example``SMS_PROVIDER` 附近加:
```bash
# 极光→创蓝 容灾:主 provider(SMS_PROVIDER)供应商不可用(503)时转投的备。
# 空=关闭 fallback(默认);生产设 chuanglan 开启极光主、创蓝备;置空即秒回退。
SMS_FALLBACK_PROVIDER=
```
- [ ] **Step 2: 全量 SMS 相关回归**
Run: `python -m pytest tests/test_sms_fallback.py tests/test_sms_dispatch.py tests/test_sms_chuanglan.py tests/test_sms_aliyun.py tests/test_auth.py -q`
Expected: PASS(新增全绿;原 SMS/auth 用例不回归)
- [ ] **Step 3: lint**
Run: `python -m ruff check app/integrations/sms/ app/core/config.py app/api/v1/auth.py tests/test_sms_fallback.py tests/test_sms_base.py`
Expected: `All checks passed!`
- [ ] **Step 4: 基线对比(确认无新增失败)**
Run: `python -m pytest -q`
Expected: 失败数 ≤ 基线(已知先前债 ~8 个);本改动不新增失败。若有新增,定位并修。
- [ ] **Step 5: 提交**
```bash
git add .env.example
git commit -m "docs(sms): .env.example 加 SMS_FALLBACK_PROVIDER 说明"
```
---
## Self-Review
**Spec 覆盖核对**(对 `2026-07-30-sms-jiguang-chuanglan-fallback-design.md`):
- §2-A 仅 503 转 / 429·400 不转 → Task 4`test_send_code_503_falls_back` + `test_send_code_non_503_does_not_fall_back`)✅
- §2-B try-both 只遍历启用链 → Task 5(4 个 verify 用例,含关闭时备零参与)✅
- §2-C details 记 provider/fallback + 日志 → Task 6details+ Task 4/5logger.warning/info)✅
- §2-D 配置开关默认空、生产设开 → Task 2 + Task 7 ✅
- §4.1 主/备解析(未知/同主→None)→ Task 3`_primary`/`_fallback`)✅
- §4.2 SendResult + 只认 503 + 备失败抛备错 → Task 1 + Task 4`test_send_code_backup_also_fails`)✅
- §4.4 码只在一家/防爆破不被绕过 → provider 内部行为(不改),try-both 逻辑 Task 5 覆盖;防爆破由 provider 各自既有测试保证 ✅
- §4.5 auth 一处改、verify 调用点不变 → Task 6 ✅
- §5 配置项 → Task 2 ✅
- §6 mock 永不 fallback → mock 下主 send_code 成功不抛异常,天然不进 fallback 分支(现有 mock 测试在 Task 7 回归保证)✅
- §10 send_code int→SendResult 仅 auth 一处 + dispatch 测试 → Task 3/6 ✅
**占位扫描**:无 TBD/TODO;每个 code step 均含完整代码与命令。Task 6 Step 1 标注了「以现有 fixture 为准」——这是**对齐现有约定**的指示,非占位(fixture 名在同文件已存在,执行时照抄)。
**类型/命名一致性**`SendResult(cooldown_sec, provider, fallback)` 三字段在 Task 1/3/4/6 全程一致;`_primary()`/`_fallback()`/`_ALL`/`_NAME` 命名跨 Task 3/4/5 一致;`SMS_FALLBACK_PROVIDER` 跨 Task 2/3/4/5/7 一致。
---
## 执行顺序说明
Task 1→2→3 有依赖(后者用前者的类型/配置/分派骨架),必须顺序执行。Task 4/5 都改 `send_code`/`verify_code`,也应在 Task 3 之后顺序做。Task 6 依赖 Task 3 的 `SendResult` 返回。Task 7 收尾。全程一条功能分支 `feat/sms-jiguang-chuanglan-fallback`,每 Task 一提交。
@@ -0,0 +1,281 @@
# 本地开发切 Docker PostgreSQL —— 设计文档
> 让本地开发与测试统一跑在 Docker 化的 PostgreSQL 上,彻底退掉 SQLite。
> `run.bat` / `run.sh` 启动时自动检测本机 PG,没起就拉起 Docker → 起 PG 容器(镜像缺失先拉),
> 目的是让开发/大模型能放心用 PG 专有的高效聚合函数,不再为兼容 SQLite 而退化成"取基础数据后内存聚合"。
>
> 状态:已定稿(待用户复核)。作者对话日期:2026-07-08。
> 关联:[postgres-migration.md](../../database/postgres-migration.md)(切引擎完整步骤)、`scripts/init_postgres.py`(生产原生 PG 初始化)。
> ⚠️ **2026-07-27 增补(见 §10)**:D4 已从「sqlite 硬失败」松为「显式 SQLite 逃生舱」。§1-9 描述的是初版「彻底退掉 SQLite」设计;凡涉及「无 Docker / DATABASE_URL 是 sqlite 时如何处理」,**以 §10 为准**(测试仍只跑 PG 不变)。
---
## 1. 背景与目标
### 问题
当前开发环境默认用 SQLite(`DATABASE_URL=sqlite:///./data/app.db`),生产用 PostgreSQL 16。两套引擎并存,导致写数据访问代码时(尤其 `app/admin/repositories/` 的报表聚合)为了"两边都能跑",放弃 PG 专有能力(窗口函数、`FILTER``JSONB` 操作符、`GROUPING SETS` 等),改成"先查基础数据、再在 Python 内存里聚合"——既慢又啰嗦。
### 目标
本地开发与测试都跑在 PG 上,SQLite 退出本地开发闭环。之后写 PG 专有 SQL 时:
- 开发运行时(`run.bat`/`run.sh`)直接连 PG,手动验证可行;
- `pytest` 也连 PG,PG 专有 SQL 在被测代码路径里也安全,不会因 SQLite 而挂——**这是"双库兼容代码彻底消失"的必要条件**。
### 非目标(本期不做)
- 不动**生产**部署(生产仍是原生 PG16 + systemd,无 Docker;`init_postgres.py` 保持不变)。
- 不做数据搬迁(MVP 阶段无真实用户数据,详见迁移指南背景假设)。
- 不接 CI(仓库当前无 `.github/workflows`;若将来加 CI,再单独让 CI 起 PG service)。
- 不引入 Redis / testcontainers / 连接池中间件。
---
## 2. 决策记录(本次对话已拍板)
| # | 决策点 | 结论 | 理由 |
|---|---|---|---|
| D1 | PG 覆盖范围 | **dev 运行 + 测试都切 PG** | 只切运行时的话,被 SQLite 测试覆盖的代码路径(如 `test_cps_admin.py` 覆盖的 `admin/repositories/cps.py`)仍不能用 PG 专有 SQL,双库代码不会真正消失 |
| D2 | 打包方式 | **方案 A:Compose + `scripts/ensure_pg.py`** | 唯一真正需要定制的部分(启动 Docker 守护进程、等 PG 就绪)集中到一个跨平台模块,`run.bat`/`run.sh`/`conftest.py` 共用;声明式的容器/卷/健康检查交给 Compose |
| D3 | 宿主端口 | **5432**(与生产/文档一致) | 边界:若本机已有原生 PG 占 5432,`ensure_pg` 会探测到"PG 已在"直接复用它(可能连到不带业务库的实例)——见 §7 风险,文档提示 |
| D4 | dev 下 `DATABASE_URL` 仍是 sqlite | **硬失败**(打印一行 fix 后非 0 退出) | 彻底断掉 SQLite 退路,符合"让大家都用 PG"的目标 |
| D5 | dev 数据库密码 | 固定 `shaguabijia_dev_pw`,写进 compose + `.env.example` | 本地容器仅绑 `localhost`,非机密;保证 `.env.example` 复制即可用。生产密码另由 `init_postgres.py` 强随机生成,不复用 |
| D6 | 改哪些启动脚本 | `run.bat``run.sh` **都改** | 仓库一贯保持两者同步 |
| D7 | 镜像 | `postgres:16-alpine` | 对齐生产 PG16;alpine 体积小 |
---
## 3. 现状(改动前)
- **配置**:`app/core/config.py` `DATABASE_URL` 默认 `sqlite:///./data/app.db`,pydantic-settings 从 `.env` 读(环境变量优先级高于 `.env` 文件)。
- **引擎**:`app/db/session.py``_is_sqlite = DATABASE_URL.startswith("sqlite")` 分流——SQLite 加 `check_same_thread=False`、不建池;非 SQLite 加 `pool_size=10/max_overflow=20/pool_recycle=3600`。**已天然支持 PG,无需改。**
- **迁移**:`alembic/env.py``settings.DATABASE_URL` 读连接串,`render_as_batch` 仅对 sqlite 开;PG 下自动关。**无 psycopg2 硬编码,切 PG 无需改。**
- **驱动**:`pyproject.toml` 已装 `psycopg[binary]>=3.1`(psycopg3)。URL scheme 必须 `postgresql+psycopg://`(裸 `postgresql://` 会被 SQLAlchemy 路由到未安装的 psycopg2 → ModuleNotFoundError)。
- **测试**:`tests/conftest.py` 在 import app 前把 `DATABASE_URL` 设成临时文件 SQLite;session 级 autouse fixture 做 `Base.metadata.create_all(engine)` / 结束 `drop_all`(schema 来自 model 而非 alembic,无逐用例 rollback,全会话共享一个库)。
- **启动脚本**:`run.bat` / `run.sh` 均为:校验 `.env` 存在 → `mkdir data``alembic upgrade head` → uvicorn 监听 `0.0.0.0:8770`(`.sh``--reload --reload-dir app`)。
- **现有 PG 资产**:`scripts/init_postgres.py`(交互式:建用户/建库/授权/写 .env/跑迁移,面向**已装好的原生 PG**)、`docs/database/postgres-migration.md`(切引擎完整步骤,含 §2 测试切 PG、§2.2 会暴露的真 bug 清单)。
- **CI**:无(`.github/workflows` 不存在),故测试切 PG 无 CI 联动负担。
---
## 4. 方案详解
### 4.1 新增 `docker-compose.yml`(app-server 根目录)
```yaml
services:
postgres:
image: postgres:16-alpine
container_name: shaguabijia-pg
environment:
POSTGRES_USER: shaguabijia_app
POSTGRES_PASSWORD: shaguabijia_dev_pw
POSTGRES_DB: shaguabijia
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./docker/initdb:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shaguabijia_app -d shaguabijia"]
interval: 3s
timeout: 3s
retries: 20
volumes:
pgdata:
```
- `POSTGRES_USER` 设定后,该用户以超级用户身份创建并拥有 `POSTGRES_DB`,故能再建测试库。
- 首启 initdb 脚本建测试库(见 4.2)。命名卷 `pgdata` 让数据跨重启留存。
### 4.2 新增 `docker/initdb/01-create-test-db.sql`
```sql
-- 仅在 pgdata 卷首次初始化时执行一次。以 shaguabijia_app(超级用户)连 shaguabijia 库运行。
CREATE DATABASE shaguabijia_test OWNER shaguabijia_app;
```
### 4.3 新增 `scripts/ensure_pg.py`(纯标准库 + docker CLI,跨平台)
对外同时暴露**可导入函数** `ensure()`(供 `conftest.py` 直接调)和 **CLI 入口** `if __name__ == "__main__": sys.exit(0 if ensure() else 1)`(供 `run``python -m scripts.ensure_pg` 跑)。`ensure()``app.core.config.settings``DATABASE_URL`,解析 host/port,主流程:
1. **sqlite 守卫**:若 `DATABASE_URL``sqlite` 开头 → 打印"dev 已切 PG,请把 .env 的 DATABASE_URL 改成 `postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia`"→ 非 0 退出(D4)。
2. **TCP 探测** `host:port`(stdlib `socket`,超时 1s)。通 → 打印"✅ PG 已就绪"直接返回(幂等:PG 已在跑时开销≈一次握手)。
3. 不通 → `docker version` 探 CLI;缺失 → 中文报错"请先安装 Docker Desktop:https://www.docker.com/products/docker-desktop/" → 非 0 退出。
4. `docker info` 探守护进程;不通 → 按平台启动:
- Windows:`start "" "%ProgramFiles%\Docker\Docker\Docker Desktop.exe"`(找不到则报错让用户手动开)
- macOS:`open -a Docker`
- Linux:不自动 sudo,打印 `sudo systemctl start docker` 让用户执行后重试
然后轮询 `docker info` 直到就绪或超时(默认 120s,每 3s 一次,打印进度)。
5. `docker compose up -d`(Compose 在镜像缺失时**自动拉取**,首用拉 alpine ~90MB;有进度输出)。
6. 轮询 healthcheck(`docker inspect` 的 health 状态)/ TCP 直到 PG 接受连接(默认 60s 超时)。
7. **幂等确保测试库存在**(兼容"老 pgdata 卷没跑过 initdb"的情况):
`docker compose exec -T postgres psql -U shaguabijia_app -tc "SELECT 1 FROM pg_database WHERE datname='shaguabijia_test'"`,不存在则 `CREATE DATABASE shaguabijia_test OWNER shaguabijia_app`
失败即清晰中文报错 + 非 0 退出,**全程不回退 SQLite**。所有超时可用环境变量覆盖(如 `ENSURE_PG_DOCKER_TIMEOUT`)。
### 4.4 `run.bat` / `run.sh` 接线
`alembic upgrade head` **之前**插一行调用,失败即退出:
- `run.sh`:`"$PY" -m scripts.ensure_pg`(`set -e` 已在,失败自动退出)
- `run.bat`:`call "%PY%" -m scripts.ensure_pg` + `if errorlevel 1 exit /b 1`
其余逻辑不动(`mkdir data` 保留给 media 等落盘目录)。
### 4.5 `.env.example` 默认切 PG
```ini
DATABASE_URL=sqlite:///./data/app.db
```
改为
```ini
# 本地开发/测试统一用 Docker PG(run.bat/run.sh 会自动拉起容器;详见 docs/database/postgres-migration.md §本地 Docker 一键起)。
# 生产用原生 PG,由 scripts/init_postgres.py 写入强随机密码的连接串。
DATABASE_URL=postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia
```
### 4.6 `tests/conftest.py` 切 PG
调整顶部顺序(仍必须在 `import app.*` 之前完成 env 设定):
1. 设 `os.environ["DATABASE_URL"] = "postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia_test"`(测试库,永不碰 dev 业务库)。
2. 调 `scripts.ensure_pg.ensure()`(保证容器在 + 测试库在;PG 已在时几乎零开销)。
3. `import app...`
session 级 autouse fixture:改为 **`Base.metadata.drop_all(engine)``create_all(engine)`(开头先清干净,防持久卷里上一次跑残留的表/数据)→ yield → 结束 `drop_all`**;删掉临时 SQLite 文件相关代码。
> 预期:部分用例会因 PG 的严格性变红(SQLite 宽松、PG 严格),按迁移指南 §2.2 逐个修——常见为:字符串/整数隐式比较、`datetime.utcnow()` naive vs `TIMESTAMPTZ`、事务边界(`current transaction is aborted`)。这既是工作量也是本次改造的**直接收益**(暴露真 bug)。实现阶段需为"跑 pytest 并修红用例"单列步骤。
### 4.7 `app/db/session.py`
**无需改动**——`_is_sqlite` 为假时自动走 PG 池化分支。
### 4.8 文档
- `docs/database/postgres-migration.md` 增一节「本地 Docker 一键起 PG(推荐)」,指向 compose + `ensure_pg`,并说明它替代了 §1.1 的手动 brew/apt 装 PG。
- `CLAUDE.md` 的 DB 段注明:dev/test = Docker PG(`run` 自动拉起);prod = 原生 PG(`init_postgres.py`)。
- `scripts/init_postgres.py` 顶部注释补一句"本脚本面向生产原生 PG;本地开发用 docker-compose + scripts/ensure_pg"。
---
## 5. 连接参数汇总
| 项 | 值 |
|---|---|
| 镜像 | `postgres:16-alpine` |
| 容器名 | `shaguabijia-pg` |
| 宿主端口 | `5432` |
| 超级/业务用户 | `shaguabijia_app` |
| dev 密码 | `shaguabijia_dev_pw`(本地非机密) |
| 业务库(dev 运行) | `shaguabijia` |
| 测试库(pytest) | `shaguabijia_test` |
| dev `DATABASE_URL` | `postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia` |
| test `DATABASE_URL` | `postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia_test` |
| 数据持久化 | 命名卷 `pgdata` |
---
## 6. 失败处理矩阵(无 SQLite 兜底)
| 情形 | ensure_pg 行为 |
|---|---|
| `DATABASE_URL` 是 sqlite | 打印应改成的 PG 串 → 非 0 退出 |
| PG 已在跑(TCP 通) | 打印"已就绪" → 返回 0(跳过 docker) |
| 无 docker CLI | 提示装 Docker Desktop + 官网链接 → 非 0 退出 |
| docker 守护进程未起 | 尝试按平台启动 Docker Desktop,轮询到就绪;超时则报错 → 非 0 退出 |
| 镜像缺失 | `docker compose up -d` 自动拉取(不额外处理) |
| 容器起了但 PG 未 ready | 轮询 healthcheck 到超时;超时报错 → 非 0 退出 |
| 老 pgdata 卷缺测试库 | 幂等 `CREATE DATABASE shaguabijia_test` |
---
## 7. 风险与边界
- **端口占用(原生 PG 撞 5432)**:D3 选了 5432。若开发机已有原生 PG 监听 5432,step 2 的 TCP 探测会判"PG 已在"并复用它——但那个实例可能没有 `shaguabijia`/`shaguabijia_test` 库或用户,后续 `alembic upgrade head` / 测试会报连不上库或认证失败。**缓解**:文档提示"本机别再单独跑原生 PG";报错信息里提示检查是不是撞了原生 PG。
- **首次启动慢**:首用需 Docker Desktop 冷启(~3060s)+ 拉镜像(~数十秒~数分钟,视网络)。`ensure_pg` 全程打印进度,超时可配。
- **`DATABASE_URL` 环境变量优先级**:pydantic-settings 里 shell 环境变量优先于 `.env`。若开发者 shell 残留旧的 `DATABASE_URL`(如指向 sqlite),会盖过 `.env`。sqlite 守卫(D4)能挡住 sqlite 残留;但若残留的是另一个 PG 串,则以它为准——文档提示。
- **持久卷脏状态**:测试用 drop_all→create_all 开头清库,避免上次崩溃残留污染;dev 业务库随卷留存(符合预期)。
- **Docker 未安装/公司网络拉镜像受限**:硬失败并给出明确指引;不提供 SQLite 退路是刻意选择(D4/目标)。
---
## 8. 验收标准
1. 全新机器(装了 Docker Desktop、`.env``.env.example` 复制)执行 `run.bat`(或 `run.sh`):自动拉起 Docker→起 PG 容器→建库→`alembic upgrade head`→uvicorn 起在 8770,无手动装 PG 步骤。
2. `docker ps``shaguabijia-pg` 健康;`psql`/客户端能连 `shaguabijia``shaguabijia_test` 两个库。
3. PG 已在跑时再次 `run`,`ensure_pg` 秒过(不重复拉容器)。
4. `pytest``shaguabijia_test` 跑;红用例全部修绿(PG 严格性暴露的问题)。
5. `.env``DATABASE_URL` 改回 sqlite 时,`run`/`pytest` 硬失败并打印正确的 PG 串。
6. 能在 `admin/repositories/` 里写一段 PG 专有聚合 SQL(如带 `FILTER (WHERE ...)` 的聚合),`run` 下手动跑通、相应 pytest 也通过——即"双库兼容负担消失"的实证。
---
## 9. 待实现清单(供 writing-plans 拆解)
- [ ] 新增 `docker-compose.yml`
- [ ] 新增 `docker/initdb/01-create-test-db.sql`
- [ ] 新增 `scripts/ensure_pg.py`(TCP 探测 / 启 Docker Desktop 轮询 / compose up / 等 healthy / 幂等建测试库 / sqlite 守卫)
- [ ] `run.sh``run.bat` 接入 `ensure_pg`
- [ ] `.env.example``DATABASE_URL` 切 PG
- [ ] `tests/conftest.py``shaguabijia_test` + 调 `ensure_pg` + fixture 改 drop/create
- [ ] 跑 `pytest`,按迁移指南 §2.2 修红用例
- [ ] 文档:`postgres-migration.md` 增「本地 Docker 一键起」节;`CLAUDE.md` DB 段;`init_postgres.py` 注释
- [ ] `.gitignore` 确认 `data/` 已忽略(compose 用命名卷,不落项目目录,无需额外忽略)
---
## 10. 增补(2026-07-27):D4 反转 —— 显式 SQLite 逃生舱
> 背景:§2 的 D4 定为「dev 下 `DATABASE_URL` 仍是 sqlite → 硬失败」,目的是彻底断掉 SQLite 退路。实践中这对「本机装不了 Docker」的开发者过于刚性——直接被卡死、连跑都跑不起来。本次(2026-07-27 对话)把 D4 从「硬失败」松成「**显式逃生舱**」:工具**从不替你静默切库**,但会在没 Docker 时告诉你怎么手动降级,且降级时每次启动都醒目告警。
### 10.1 决策更新
| # | 原决策 | 新决策 | 理由 |
|---|---|---|---|
| D4 | dev sqlite URL → 硬失败退出 | **放行 + 每次打印醒目降级横幅**(仍非静默) | 已手动改 `.env`=sqlite = 开发者的显式选择,尊重它;但吼一嗓子防止忘了自己在降级、把 PG 专有 SQL 提交上去 |
| D8(新) | (无) | 无 docker CLI 时,报错里**追加逃生舱指路**(改 `.env`=sqlite),但仍非 0 退出 | 「显式」的关键:工具不替你切库,只指路;开发者改完 `.env` 再跑一次才真正降级 |
**未变**:D1(测试仍只跑 PG)、D2-D3、D5-D7 全部保留。逃生舱**只作用于 `run.sh`/`run.bat` 运行时**;`pytest` 仍写死连 PG 测试库(`conftest.py` 传 PG URL,sqlite 分支根本不触发),没 Docker 就 `raise`、跑不了完整套件——这正是 D1「测试上 PG 才能暴露真 bug」的初衷,刻意不给逃生舱。
### 10.2 代码改动(仅 `scripts/ensure_pg.py``ensure()`)
1. **sqlite 分支**(原 `return False`)→ 打印多行降级横幅后 `return True`。横幅点明:PG 专有 SQL/严格类型在此模式**不被验证**、提交前须在有 Docker 的机器上用 PG 复跑、装好 Docker 后把 `DATABASE_URL` 改回 PG 串。
2. **无 docker CLI 分支**(原仅提示装 Docker + `return False`)→ 追加一句「装不了 Docker?把 `.env``DATABASE_URL` 改成 `sqlite:///./data/app.db` 可降级运行」;**仍 `return False`**(run 脚本照常退出,开发者需显式改 .env 再跑)。
3. **常量**:新增 `SQLITE_URL = "sqlite:///./data/app.db"`(逃生舱指路用);`SQLITE_FIX_HINT` 重命名 `PG_URL`(降级横幅"改回 PG"引用)。
4. 更新模块 docstring 中「全程无 SQLite 兜底」一句,改述为「无 Docker/sqlite URL 时【显式】降级 SQLite(带醒目告警),测试侧不降级」。
**其余全不动**:`run.sh`/`run.bat`(sqlite 下 `ensure` 返 True → 照常 `alembic upgrade head` + uvicorn)、`docker-compose.yml``app/db/session.py`(SQLite 引擎分支本就保留为 fallback)、`tests/conftest.py``.env.example`(默认仍 PG)。
### 10.3 改完后行为矩阵(覆盖用户列的 5 场景)
| 场景 | `DATABASE_URL` | ensure_pg 行为 |
|---|---|---|
| ① 无 Docker | PG(默认) | 报错 + 指逃生舱 → 退出;开发者改 `.env`=sqlite → 再跑 → **放行 + 降级横幅**,alembic/uvicorn 跑 SQLite |
| ② 有 Docker 未启动 | PG | 启 Docker Desktop → `compose up` → 等 ready → 建测试库(**不变**) |
| ③ 有 Docker 已启动 | PG | `compose up` → 等 ready(**不变**) |
| ④ PG 已在跑 | PG | TCP 通 → 秒过跳过 Docker(**不变**) |
| ⑤ PG 起来后 | 任意 | run 脚本 `alembic upgrade head`(**不变**;SQLite 走 `render_as_batch`) |
### 10.4 风险
- **降级被忽视**:横幅仅在 `run` 启动时打印一次;若开发者用 IDE 直接起 uvicorn(绕过 run 脚本)则看不到。缓解:横幅足够醒目 + 文档强调;**不**引入 app 启动期重复告警(YAGNI)。
- **测试无 Docker 跑不了**:刻意保留(D1)。文档提示无 Docker 者:要么装 Docker 跑全量测试,要么只在 CI/有 Docker 的机器上验证 PG 相关改动。
### 10.5 Redis 前瞻(不在本次)
§2 未涉及 Redis。②③ 场景未来若加 Redis 实例:在 `docker-compose.yml``redis` 服务即可,`docker compose up -d` 天然带起;仅当启动期有组件依赖 Redis 才需给 `ensure_pg` 加 redis readiness 探测。本次不做,方案对它友好。
### 10.6 附带修复:`_docker_cli_ok` 守护进程误判(2026-07-27)
诊断「装了 Docker Desktop 却报未检测到 docker」时发现的真 bug:`_docker_cli_ok()` 原用 `docker version`
判断 CLI 是否存在,但该命令**要连 daemon**,守护进程没起时退非零 → 把「Docker 装了但没启动」
误判成「没装 CLI」,`ensure()` 直接打印"请安装 Docker Desktop"并 `return False`,**绕过了专为需求②
写的 `_start_docker_daemon()` 自动拉起逻辑**——需求②(有 Docker 未启动 → 自动启动)因此从未真正生效。
修复:改用 `docker --version`(纯客户端、不连 daemon、退 0)。`_docker_daemon_ok()` 仍用 `docker info`
(正确,该检查本就依赖 daemon)。实测机器:Docker Desktop 20.10.12 已装但引擎未起,修复前 `_docker_cli_ok()`
误报 False,修复后 True。
### 10.7 附带修复:固定 compose 项目名 + 清理残留同名容器(2026-07-27)
诊断「`docker compose up``container name "/shaguabijia-pg" already in use`」时发现的又一 bug:compose
项目名默认取运行目录 basename,在不同目录/worktree(如 `local-dev-postgres-docker` vs `shaguabijia-app-server`)
之间切换会各自成一个项目;而 `docker-compose.yml` 写死了 `container_name: shaguabijia-pg`(全局唯一名),
于是新项目 `up` 时要创建同名容器 → 撞上旧项目留下的那个 → 冲突。副作用:`pgdata` 卷也按项目名分裂
`local-dev-postgres-docker_pgdata` / `shaguabijia-app-server_pgdata`,数据被切成两半。
修复(均在 `scripts/ensure_pg.py`,`docker-compose.yml` 不动、容器名仍是 `shaguabijia-pg`):
1. 模块级 `os.environ.setdefault("COMPOSE_PROJECT_NAME", "shaguabijia")` —— 钉死项目名,无论从哪个
目录/worktree 跑都是同一个项目、同一个卷 `shaguabijia_pgdata`,所有 `docker compose up/exec` 一致。
2. `_compose_up()` 前置 `_remove_stale_container()`:若存在「同名但不属于本项目」的残留容器,先 `docker rm -f`
再 up(靠 `docker ps --filter name/label` 判归属;数据在命名卷里,删容器不丢)。旧目录/worktree 留下的
残留容器就此自动清掉,不需手动干预。
影响:本次修复后首跑,旧的 `shaguabijia-pg`(属项目 `local-dev-postgres-docker`)会被自动删除、在项目
`shaguabijia` 下重建,挂载全新的 `shaguabijia_pgdata`(空库,`alembic upgrade head` 重建表)。旧数据仍留在
`local-dev-postgres-docker_pgdata` 卷里(未删,可恢复);确认不需要后可 `docker volume rm` 清理两个旧卷。
@@ -1,208 +0,0 @@
# 短信验证码 极光→创蓝 容灾 fallback — 设计方案
- 日期:2026-07-30
- 状态:待评审
- 范围:短信发送验证码服务,默认极光;**极光供应商不可用时自动转创蓝补发**;发/校验全程后台可区分本次走的是极光还是创蓝。
- 关联:`docs/superpowers/specs/2026-07-26-chuanglan-sms-verify-design.md`(创蓝 provider 接入)
## 1. 背景与目标
短信验证码已是**可切换 provider** 架构(`app/integrations/sms/``__init__` 分派 + `jiguang` / `aliyun` / `chuanglan` + `base`)。极光(默认)与创蓝都是 **Mode B(自管码)**:本服务 `secrets` 生成码 → 存**各自模块的进程内存** → provider 只负责发送;`verify_code` 是**纯本地内存比对**,不联网。
现状是"单选一个 provider"`send_code` / `verify_code` 都只走 `SMS_PROVIDER` 指定的那一家。极光一旦供应商侧故障(欠费 / 网络 / 服务异常),`/sms/send` 直接 503,用户收不到码、登录中断。
**目标**:把极光设为主、创蓝设为备,在**极光供应商不可用**时自动转创蓝补发,最大化验证码送达率;同时让后台能查到每次发码/校验实际走的是哪家、是否发生了 fallback。
**非目标**:不改极光/创蓝各自的 Mode B 机器(发码/存码/校验/防刷),不引入 DB/Redis 共享存码,不做多 worker 共享(沿用现有内存态技术债)。
## 2. 关键决策(已与需求方确认)
| # | 决策 | 结论 |
|---|---|---|
| A | **fallback 触发范围** | **仅主 provider 返回"供应商不可用"`SmsError.status_code == 503`:网络错误 / 余额不足 / 服务故障)时转备。** 本地单号 60s 冷却与极光侧超频(均 429)、手机号无效(400)**不转**——不绕过防刷、不为无效号白烧一条。 |
| B | **校验路由** | **try-both,但只遍历"当前启用的 fallback 链"。** verify 依次问链上 provider(主→备),任一命中即通过;关闭 fallback 时链中只有主,创蓝完全不参与。 |
| C | **后台可见性** | **风控行为流水 + 结构化日志。** 复用 `risk_repo.record_behavior_event(EVENT_SMS_SEND)``details``provider` / `fallback` 字段;dispatch 层对 fallback 触发、校验命中打点。不新增表、不改客户端响应。 |
| D | **默认开关** | **配置开关 opt-in,生产设开。** 新增 `SMS_FALLBACK_PROVIDER`,默认空=关(保持现状零风险);生产 `.env``chuanglan` 开启。出问题置空即秒回退、不改代码。 |
补充约束:**fallback 仅在 Mode B ↔ Mode Bjiguang / chuanglan)之间有意义**。aliyun 是 Mode A(阿里云托管码、校验走接口),不参与 fallback 链。
## 3. 现状回顾(设计依赖的事实)
- **分派层** [`app/integrations/sms/__init__.py`](../../../app/integrations/sms/__init__.py)`_provider()` 每次按 `settings.SMS_PROVIDER` 选一个模块(默认 jiguang),`send_code` / `verify_code` 委托给它。
- **极光 / 创蓝均 Mode B**,各持一份**独立**的 `_codes` / `_last_sent` / `_lock`(两个互不相通的进程内存 dict)。`verify_code` 纯本地比对(`_codes.get(phone)` + `secrets.compare_digest`),**不发网络**;provider 的网络调用只在**发送**一步。
- **`SmsError(message, status_code)`**[`base.py`](../../../app/integrations/sms/base.py)):`429`=过频/超频(客户端稍后再来)、`503`=供应商不可用、`400`=手机号无效。这是 A 决策用来判定"是否 fallback"的唯一信号。
- **极光/创蓝 `send_code` 失败时"保留冷却、清掉未发出的码"**`_last_sent[phone]=now` 保留、`_codes.pop(phone)`。此行为不改(见 §4.3 冷却交互)。
- **auth 层落点**`sms_send` 发码成功 [auth.py:231](../../../app/api/v1/auth.py) 记 `EVENT_SMS_SEND` `outcome="success" details={"mock":...}`;失败 [auth.py:212](../../../app/api/v1/auth.py) 记 `outcome="failed" reason=...``send_code` 是内部函数,全仓仅此一处调用。`verify_code``sms_login` 与换绑两处调用,签名 `(phone, code)->bool`
## 4. 设计
### 4.1 fallback 链模型(分派层,`__init__.py`
引入"主 + 备"两级链,全部在分派层组装,**provider 文件一行不动**
```python
_ALL = {"jiguang": jiguang, "aliyun": aliyun, "chuanglan": chuanglan}
_NAME = {jiguang: "jiguang", aliyun: "aliyun", chuanglan: "chuanglan"}
def _primary():
# 未知/缺省回退 jiguang(保持 test_unknown_provider_falls_back_to_jiguang 语义)
return _ALL.get(settings.SMS_PROVIDER, jiguang)
def _fallback():
"""启用的备 provider;未配置/同主/未知名 → None= 不启用 fallback)。"""
name = (settings.SMS_FALLBACK_PROVIDER or "").strip()
if not name or name == settings.SMS_PROVIDER:
return None
return _ALL.get(name) # 未知名 → None
```
### 4.2 发码 `send_code(phone) -> SendResult`(透出实际渠道)
现返回 `int`(cooldown),改为返回携带渠道信息的小结果对象(放 `base.py`provider 无关):
```python
@dataclass(frozen=True)
class SendResult:
cooldown_sec: int # 距下次可发秒数(= 实际发码 provider 的 SMS_SEND_INTERVAL_SEC
provider: str # 实际发码的 provider 名:"jiguang" / "chuanglan"
fallback: bool # 是否走了备(True=主不可用转备)
```
分派逻辑:
```python
def send_code(phone) -> SendResult:
primary, fb = _primary(), _fallback()
try:
cooldown = primary.send_code(phone)
return SendResult(cooldown, _NAME[primary], fallback=False)
except SmsError as e:
# A 决策:仅"供应商不可用(503)"且配置了备时转备;429/400 原样抛
if fb is not None and e.status_code == 503:
logger.warning("[SMS] primary=%s 不可用(%s)fallback→%s",
_NAME[primary], e, _NAME[fb])
cooldown = fb.send_code(phone) # 备也可能抛 SmsError(其冷却/错误码)→ 原样透出
return SendResult(cooldown, _NAME[fb], fallback=True)
raise
```
要点:
- **只认 `status_code == 503`**:极光的 429(本地冷却 / 超频 50009)、400(手机号无效)直接 raise,不 fallback。
- **备自带完整 Mode B 机器**:创蓝重新走自己的单号冷却 / 存码 / 错误码映射。备也失败则抛备的 `SmsError``auth` 按其 `status_code` 返回。
- **主/备文件不动**`jiguang.send_code` / `chuanglan.send_code` 仍返回 `int`;由分派层包装成 `SendResult`
### 4.3 冷却交互(已知行为,保守安全,不改 provider)
极光 `send_code` 失败(503)时**保留自己的冷却**(现有行为,防"欠费/签名失效时前端狂打极光")。在 fallback 下的连锁:
1. 首发:极光 503(记极光冷却)→ 转创蓝发成功(记创蓝冷却)。用户收到创蓝的码。
2. 60s 内重发:主=极光**冷却中 → 429**,`429 ≠ 503`**不 fallback** → 用户收到 429"过于频繁"。**这是期望行为**:挡住 60s 内重复补发烧钱;且此时创蓝也在自己冷却期。
3. 极光**持续故障**:每过 60s,极光冷却过期→再试极光(仍 503)→转创蓝补发。稳态下每 60s 白试一次极光、成功走创蓝,功能可用。
结论:现有"失败保留冷却"语义在 fallback 下自洽且保守。**不改极光/创蓝任何冷却逻辑。**
### 4.4 校验 `verify_code(phone, code) -> bool`try-both over 启用链)
```python
def verify_code(phone, code) -> bool:
chain = [_primary()]
fb = _fallback()
if fb is not None:
chain.append(fb)
for prov in chain:
if prov.verify_code(phone, code): # Mode B:纯本地内存比对,不联网、不抛异常
logger.info("[SMS] verify hit provider=%s", _NAME[prov])
return True
return False
```
要点:
- **关闭 fallback 时 `chain=[极光]`**,与现状字节级等价,**创蓝完全不参与**。
- **码只存在实际发码那家**fallback 发码前极光已 `_codes.pop` 清掉自己的码)→ 同一手机号同一时刻只有一家有码;另一家 `rec is None` 立即 False,不误判、不累加其 `attempts`。**防爆破仍在真实那家正常生效,不被绕过**。
- **命中即知渠道**(日志打点),天然满足 C 的校验侧可见性,无需显式记忆 phone→provider。
- 绝大多数登录:极光发成功→码在极光→第一家命中,创蓝那次内存查根本不发生。
- Mode B 的 `verify_code` 不抛 `SmsError`;若链中混入 Mode A(aliyun,本设计约束外)其 verify 降级抛 503,应原样透出中断——但按 §2 约束 aliyun 不入链,此路不触发。
### 4.5 auth.py 改动(最小)
- **`sms_send`**(唯一改动点):
```python
result = send_code(req.phone) # 现 SendResult
cooldown = result.cooldown_sec
# 成功侧 details 加 provider/fallbackC
details={"mock": settings.SMS_MOCK, "provider": result.provider, "fallback": result.fallback}
```
`SmsSendResponse(cooldown_sec=cooldown, ...)` 不变——**不把 provider 返回客户端**(C 选风控流水,非接口返回)。
- **`verify_code` 两处调用点不变**:签名 `(phone,code)->bool` 未改;校验命中渠道由分派层 §4.4 日志打点,auth 无需感知。
- **失败可观测**:极光 503 且创蓝也失败时,抛创蓝的 `SmsError` → 现有失败侧 `record_behavior_event(reason=str(e))` 记录;fallback 触发/失败另有 §4.2 分派层 `logger.warning`
### 4.6 可观测性汇总(满足 C)
| 事件 | 落点 | 内容 |
|---|---|---|
| 发码成功 | `EVENT_SMS_SEND` `details` | `provider`jiguang/chuanglan)、`fallback`bool |
| fallback 触发 | 分派层 `logger.warning` | 主不可用原因 + 转向的备 |
| 校验命中 | 分派层 `logger.info` | 命中的 `provider` |
| 发码彻底失败 | `EVENT_SMS_SEND` `outcome=failed` | `reason`(末端 SmsError 文案) |
## 5. 配置项(`app/core/config.py` 新增)
```python
# 主 provider 供应商不可用(503)时转投的备 provider;空=关闭 fallback(默认,保持现状)。
# 仅 Mode B(jiguang/chuanglan)互为主备有意义;生产建议设 "chuanglan"。
SMS_FALLBACK_PROVIDER: Literal["jiguang", "chuanglan", ""] = "" # Mode B only(aliyun 不入链)
```
- 复用现有 `SMS_PROVIDER`(主,默认 jiguang)与两家的凭证/旋钮(`CHUANGLAN_SMS_*``SMS_CODE_LENGTH` 等),**无其它新增**。
- `.env.example``SMS_FALLBACK_PROVIDER=`(注释:生产设 `chuanglan` 开启极光→创蓝容灾)。
- 生产前置:创蓝凭证齐全(`chuanglan_sms_configured`)+ 创蓝控制台 IP 白名单,否则 fallback 到创蓝仍 503。
## 6. 错误处理与边界
- **主 503 + 未配备**`_fallback()` 返 None → 原样抛主的 503(= 现状)。
- **主 503 + 备也失败**:抛备的 `SmsError`(按备错误码 429/400/503),auth 照常返回。
- **主 429/400**:不 fallback,原样抛。
- **mock 模式(`SMS_MOCK=true`**:主 `send_code` mock 分支直接成功、不抛异常 → **永不触发 fallback**`verify` mock 分支放行任意 N 位 → try-both 第一家(主)即 True。**现有 mock 测试全绿不变。**
- **未知 `SMS_FALLBACK_PROVIDER` 名**`_ALL.get` 返 None → 视为未配置、不 fallback(不崩)。
- **`SMS_FALLBACK_PROVIDER == SMS_PROVIDER`**`_fallback()` 返 None(不自我 fallback)。
## 7. 测试计划
新增 `tests/test_sms_fallback.py`monkeypatch 各 provider 的 `send_code`/`verify_code`,不真发):
1. 主发成功 → `SendResult(provider=主, fallback=False)`,不调备。
2. 主 503 + 配备 → 转备,备成功 → `SendResult(provider=备, fallback=True)`
3. 主 429 / 400 → 不 fallback,原样抛(断言备未被调用)。
4. 主 503 + 未配备 → 抛 503。
5. 主 503 + 备也 503 → 抛备的 SmsError。
6. verify:码在主 → 命中主;码在备 → 命中备;两家都无 → False。
7. **关闭 fallback**`SMS_FALLBACK_PROVIDER=""`)→ verify 只问主,**断言备 `verify_code` 未被调用**;send 主失败原样抛、不转备。
8. 防爆破:码在主,连续错到 `SMS_MAX_VERIFY_ATTEMPTS` 作废;全程备 `rec is None` 不受影响。
auth 层扩 `tests/test_auth.py``sms_send` 成功后 `EVENT_SMS_SEND``details``provider`/`fallback`mock 下 provider=jiguang、fallback=False)。
回归:`test_sms_dispatch.py`、现有 `test_auth.py` 中依赖 `send_code` 返回 `int` 的断言改为读 `SendResult.cooldown_sec`
## 8. YAGNI(明确不做)
- ❌ 不改极光/创蓝的 Mode B 机器(发/存/校验/冷却/防爆破)。
- ❌ 不做 DB/Redis 共享存码或 phone→provider 记忆表(多 worker 债维持现状、两家一起将来迁)。
- ❌ 不做 aliyunMode A)参与 fallback。
- ❌ 不做多级(>2)fallback 链、不做主备自动健康探测/熔断。
- ❌ 不把 provider 渠道透传给客户端(仅后台可见)。
- ❌ 不做发送量/成功率统计报表(C 只取"风控流水+日志",报表另议)。
## 9. 验收标准
- `SMS_FALLBACK_PROVIDER=chuanglan` 且创蓝配置齐全时:极光注入 503(欠费/网络)→ `/sms/send` 实际由创蓝下发、真机可收码并登录;`EVENT_SMS_SEND.details.provider=chuanglan``fallback=true`
- 极光正常时:走极光,`provider=jiguang``fallback=false`;创蓝零调用。
- `SMS_FALLBACK_PROVIDER=""`(默认):行为与当前完全一致,创蓝不参与发/校验。
- 极光 429/400:不转创蓝,原样返回。
- `SMS_MOCK=true`:任意 N 位通过、不真发、不触发 fallback。
- `ruff check .` 通过;新增/现有 `pytest` 全绿。
## 10. 影响面 / 回滚
- **改动文件**`sms/__init__.py`(分派层,主要)、`sms/base.py`(加 `SendResult`)、`core/config.py`(加开关)、`api/v1/auth.py``sms_send` 一处记 details)、`.env.example`、测试。**`jiguang.py` / `chuanglan.py` / `aliyun.py` 不动。**
- **破坏性接口变更**`send_code` 返回 `int → SendResult`,仅 `auth.py:210` 一处调用 + 相关测试需同步。
- **回滚**:置 `SMS_FALLBACK_PROVIDER=""` 即运行时秒回退到"纯极光",无需改代码/迁移。
+8 -1
View File
@@ -30,7 +30,14 @@ if not exist .env (
if not exist data mkdir data if not exist data mkdir data
REM Build/upgrade SQLite schema (idempotent; no-op if already at head) REM Ensure local Docker PostgreSQL is up (auto-starts Docker + PG container if needed)
call "%PY%" -m scripts.ensure_pg
if errorlevel 1 (
echo [X] ensure_pg failed ^(PostgreSQL not ready^)
exit /b %errorlevel%
)
REM Build/upgrade schema (idempotent; no-op if already at head)
call "%PY%" -m alembic upgrade head call "%PY%" -m alembic upgrade head
if errorlevel 1 ( if errorlevel 1 (
echo [X] alembic upgrade head failed echo [X] alembic upgrade head failed
+2 -1
View File
@@ -18,7 +18,8 @@ if [ ! -f .env ]; then
exit 1 exit 1
fi fi
mkdir -p data # sqlite 文件所在目录 mkdir -p data # 运行期落盘目录(媒体上传等)
"$PY" -m scripts.ensure_pg # 确保本地 Docker PostgreSQL 就绪(没起会自动拉起;失败即退出)
"$PY" -m alembic upgrade head # 确保表已建(幂等,已是最新则 no-op) "$PY" -m alembic upgrade head # 确保表已建(幂等,已是最新则 no-op)
# --reload 只盯源码目录 app/:别去监视 logs/(日志写入触发"检测→再写日志"回环)和 # --reload 只盯源码目录 app/:别去监视 logs/(日志写入触发"检测→再写日志"回环)和
+50
View File
@@ -0,0 +1,50 @@
@echo off
REM Admin backend startup (Windows) - the :8771 peer of run.bat.
REM
REM Usage:
REM cd shaguabijia-app-server
REM run8771.bat
REM
REM Runs the ADMIN FastAPI app (app.admin.main:admin_app) on 127.0.0.1:8771 —
REM a SEPARATE process from run.bat (which runs app.main:app on 8770). The admin
REM web frontend (Next.js :3001) points at http://localhost:8771. Auto-reload on
REM code change.
REM
REM Prerequisite (first time):
REM conda activate pricebot ^&^& pip install -e .
REM copy .env.example .env ^&^& fill JWT_SECRET_KEY
REM
REM Tip: shaguabijia-admin-web\start.bat starts user-api(8770) + admin-api(8771)
REM + frontend(3001) in one go, if you prefer a single command.
cd /d "%~dp0"
REM Prefer the project virtualenv (.venv) so we never inherit a wrong
REM global/conda interpreter. FastAPI<0.115 on Pydantic 2.12 crashes at import
REM with "'FieldInfo' object has no attribute 'in_'". Falls back to PATH python.
set "PY=python"
if exist "%~dp0.venv\Scripts\python.exe" set "PY=%~dp0.venv\Scripts\python.exe"
if not exist .env (
echo [X] Missing .env. Run: copy .env.example .env and fill JWT_SECRET_KEY ^(plus MT_CPS_* if you test Meituan^)
exit /b 1
)
if not exist data mkdir data
REM Ensure local Docker PostgreSQL is up (auto-starts Docker + PG container if needed)
call "%PY%" -m scripts.ensure_pg
if errorlevel 1 (
echo [X] ensure_pg failed ^(PostgreSQL not ready^)
exit /b %errorlevel%
)
REM Build/upgrade schema (idempotent; no-op if already at head)
call "%PY%" -m alembic upgrade head
if errorlevel 1 (
echo [X] alembic upgrade head failed
exit /b %errorlevel%
)
REM Long-running foreground process. Ctrl+C to stop.
"%PY%" -m uvicorn app.admin.main:admin_app --host 127.0.0.1 --port 8771 --reload
+394
View File
@@ -0,0 +1,394 @@
"""确保本地 PostgreSQL 就绪(开发/测试统一用 Docker PG)。
被三处复用:
- run.sh / run.bat:`python -m scripts.ensure_pg`(CLI,失败退非 0)
- tests/conftest.py:`from scripts.ensure_pg import ensure; ensure(test_url)`
流程: DATABASE_URL TCP 探测 没起就(必要时启 Docker Desktop)
`docker compose up -d` PG ready 幂等确保测试库存在
运行时(run.sh/run.bat)支持显式SQLite 逃生舱:DATABASE_URL 设为 sqlite 放行并打印
醒目降级横幅(绝不静默替你切库); docker CLI 时报错里也指路该逃生舱测试侧
(conftest PG URL)不降级sqlite 分支不触发, PG 直接 raise详见设计文档 §10
生产用原生 PG(scripts/init_postgres.py),不走本模块
"""
from __future__ import annotations
import os
import shutil
import socket
import subprocess
import sys
import time
from collections.abc import Mapping
from pathlib import Path, PureWindowsPath
from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parent.parent
# 日志里可能含 emoji(如 ✅);Windows GBK 控制台(cmd.exe)无法编码会抛 UnicodeEncodeError → 脚本崩、
# run.bat 误判 ensure_pg 失败。用 backslashreplace 保底:中文仍正常,仅不可编码字符被转义,不崩。
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(errors="backslashreplace")
except (AttributeError, ValueError):
pass
APP_DB = "shaguabijia"
TEST_DB = "shaguabijia_test"
DB_USER = "shaguabijia_app"
COMPOSE_SERVICE = "postgres"
CONTAINER_NAME = "shaguabijia-pg" # 必须与 docker-compose.yml 的 container_name 一致
# 钉死 compose 项目名:否则它默认取运行目录 basename,在不同目录/worktree 之间切会各自
# 成一个项目 → 同一个固定 container_name 撞名报错、pgdata 卷还会按项目名分裂成多份。
# 钉成 app 名后,无论从哪个目录/worktree 跑都是同一个项目、同一个卷。setdefault:尊重外部覆盖。
os.environ.setdefault("COMPOSE_PROJECT_NAME", "shaguabijia")
DOCKER_START_TIMEOUT = int(os.environ.get("ENSURE_PG_DOCKER_TIMEOUT", "120"))
PG_READY_TIMEOUT = int(os.environ.get("ENSURE_PG_READY_TIMEOUT", "60"))
# 单条 docker 探测/exec 命令的超时:防 Docker 守护进程半死(尤其 Windows 冷启)时
# docker info / exec 无限挂起、绕过上面的总超时。
DOCKER_CMD_TIMEOUT = int(os.environ.get("ENSURE_PG_CMD_TIMEOUT", "15"))
POLL_INTERVAL = 3.0
PG_URL = (
"postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia"
)
SQLITE_URL = "sqlite:///./data/app.db" # 无 Docker 时的显式降级逃生舱(仅 run 运行时)
def _log(msg: str) -> None:
print(f"[ensure_pg] {msg}", flush=True)
def _is_sqlite(url: str) -> bool:
return url.strip().lower().startswith("sqlite")
def _parse_host_port(url: str) -> tuple[str, int]:
"""从 SQLAlchemy URL 取 host/port,缺省 localhost:5432。"""
parts = urlsplit(url)
return (parts.hostname or "localhost"), (parts.port or 5432)
def _port_open(host: str, port: int, timeout: float = 1.0) -> bool:
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except OSError:
return False
def _win_docker_desktop_candidates(
env: Mapping[str, str], docker_cli: str | None
) -> list[PureWindowsPath]:
"""Windows 上 Docker Desktop.exe 的候选路径(按优先级)。纯函数:不碰文件系统、不读注册表。
PureWindowsPath 解析,故在任意 OS 上跑单测都按 Windows 语义(反斜杠分隔),行为确定
优先级:
环境变量 DOCKER_DESKTOP_EXE 显式指定(终极逃生舱,盘符随你);
PATH 上的 docker CLI 反推Docker Desktop CLI
<安装目录>\\resources\\bin\\docker.exe,往上几级即安装目录,天然跟随实际盘符
(装在 D 盘就反推出 D ,不再写死 C );多取几级容忍未来目录布局微调;
Program Files 变体下的标准安装路径兜底(覆盖常规 C 盘装)
调用方按序取第一个真实存在的
"""
out: list[PureWindowsPath] = []
override = (env.get("DOCKER_DESKTOP_EXE") or "").strip().strip('"')
if override:
out.append(PureWindowsPath(override))
if docker_cli:
for parent in list(PureWindowsPath(docker_cli).parents)[:4]:
out.append(parent / "Docker Desktop.exe")
for var in ("ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"):
root = env.get(var)
if root:
out.append(PureWindowsPath(root) / "Docker" / "Docker" / "Docker Desktop.exe")
return out
def _docker_desktop_from_registry() -> Path | None:
"""从注册表尽力取 Docker Desktop.exe 位置(best-effort;非 Windows / 任何异常都当没找到)。
比路径猜测更权威且完全跟随实际盘符探两处:
- App Paths\\Docker Desktop.exe 的默认值(通常就是 exe 全路径);
- Uninstall\\Docker Desktop InstallLocation(安装目录,需再拼 exe )
"""
try:
import winreg
except ImportError: # 非 Windows
return None
probes = (
(winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\Docker Desktop.exe", "", False),
(winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Docker Desktop",
"InstallLocation", True),
)
for hive, subkey, value_name, join_exe in probes:
try:
with winreg.OpenKey(hive, subkey) as key:
val, _ = winreg.QueryValueEx(key, value_name)
except OSError:
continue # 键不存在/无权限 → 下一个
if not val:
continue
exe = Path(val) / "Docker Desktop.exe" if join_exe else Path(val)
if exe.exists():
return exe
return None
def _find_docker_desktop_exe() -> Path | None:
"""Windows 上尽力定位【真实存在】的 Docker Desktop.exe;遍历候选 + 注册表兜底,找不到返回 None。"""
for cand in _win_docker_desktop_candidates(os.environ, shutil.which("docker")):
if Path(cand).exists():
return Path(cand)
return _docker_desktop_from_registry()
def _docker_desktop_cmd(platform: str) -> list[str] | None:
"""按平台给出启动 Docker Desktop 的命令。
Windows:智能定位 exe( _find_docker_desktop_exe),找不到 None
macOS:交给 `open -a Docker`Linux:None(daemon sudo,让用户手动)
"""
if platform.startswith("win"):
exe = _find_docker_desktop_exe()
return [str(exe)] if exe else None
if platform == "darwin":
return ["open", "-a", "Docker"]
return None
def _docker_ok(subcmd: str) -> bool:
"""`docker --version`(CLI 在不在,纯客户端)/`docker info`(daemon 起没起)成功与否。"""
try:
subprocess.run(
["docker", subcmd],
cwd=ROOT,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True,
timeout=DOCKER_CMD_TIMEOUT,
)
return True
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
return False
def _docker_cli_ok() -> bool:
# 必须用 `docker --version`(纯客户端,不连 daemon)而非 `docker version`
# (后者要连 daemon,守护进程没起时退非零)——否则「装了 Docker 但没启动」
# 会被误判成「没装 CLI」,直接绕过下面 _start_docker_daemon() 的自动拉起(需求②)。
return _docker_ok("--version")
def _docker_daemon_ok() -> bool:
return _docker_ok("info")
def _start_docker_daemon() -> bool:
"""守护进程没起时按平台拉起,轮询到就绪。返回是否成功。"""
if _docker_daemon_ok():
return True
cmd = _docker_desktop_cmd(sys.platform)
if cmd is None:
if sys.platform.startswith("win"):
# docker CLI 在 PATH 上(否则走不到这)、却定位不到 Docker Desktop.exe:多为非标准安装位置
_log("找不到 Docker Desktop.exe(已试:PATH 上 docker CLI 反推、注册表、常见安装目录)。")
_log(" 确已安装 → 设环境变量 DOCKER_DESKTOP_EXE=<Docker Desktop.exe 全路径> 再重试,"
"或先手动启动 Docker Desktop。")
_log(f" 不想折腾 → 把 .env 的 DATABASE_URL 改成 {SQLITE_URL} 可降级用 SQLite 跑(仅救急)。")
else:
_log("Docker 守护进程未运行。Linux 请手动:sudo systemctl start docker,然后重试。")
return False
_log(f"启动 Docker Desktop(首次冷启可能 30-60s):{cmd[0]}")
try:
subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except OSError as e:
_log(f"启动 Docker Desktop 失败:{e}")
return False
deadline = time.monotonic() + DOCKER_START_TIMEOUT
while time.monotonic() < deadline:
if _docker_daemon_ok():
_log("Docker 守护进程已就绪。")
return True
_log("等待 Docker 守护进程…")
time.sleep(POLL_INTERVAL)
_log(f"等待 Docker 守护进程超时({DOCKER_START_TIMEOUT}s)。")
return False
def _ps_names(*filters: str) -> str:
"""docker ps -a 按 filter 查容器名(每行一个);失败返回空串。"""
args = ["docker", "ps", "-a", "--format", "{{.Names}}"]
for f in filters:
args += ["--filter", f]
try:
r = subprocess.run(
args, cwd=ROOT, capture_output=True, text=True, timeout=DOCKER_CMD_TIMEOUT,
)
except (OSError, subprocess.TimeoutExpired):
return ""
return r.stdout if r.returncode == 0 else ""
def _remove_stale_container() -> None:
"""删掉「同名但不属于本 compose 项目」的残留容器(旧目录/worktree 建的)。
固定的 container_name 是全局唯一名:若旧项目留下一个同名容器,`docker compose up`
会因撞名报 "container name already in use" 而失败这里在 up 之前主动清掉它
数据在命名卷(<project>_pgdata),删容器不删卷不丢数据
"""
project = os.environ.get("COMPOSE_PROJECT_NAME", "")
name_filter = f"name=^{CONTAINER_NAME}$"
if CONTAINER_NAME not in _ps_names(name_filter).split():
return # 没有同名容器
ours = _ps_names(name_filter, f"label=com.docker.compose.project={project}")
if CONTAINER_NAME in ours.split():
return # 就是本项目的容器,compose 会自己 start/复用,别删
_log(f"发现残留同名容器 {CONTAINER_NAME}(非本项目 '{project}'),删除以避免撞名"
f"(数据在卷里,不丢)…")
try:
subprocess.run(
["docker", "rm", "-f", CONTAINER_NAME], cwd=ROOT,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=DOCKER_CMD_TIMEOUT,
)
except (OSError, subprocess.TimeoutExpired):
_log(f"⚠️ 删除残留容器失败,可手动: docker rm -f {CONTAINER_NAME}")
def _compose_up() -> bool:
_remove_stale_container()
_log("docker compose up -d(镜像缺失会自动拉取,首用约几十秒)…")
try:
subprocess.run(["docker", "compose", "up", "-d"], cwd=ROOT, check=True)
return True
except (OSError, subprocess.CalledProcessError) as e:
_log(f"docker compose up 失败:{e}")
return False
def _pg_isready() -> bool:
try:
r = subprocess.run(
["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
"pg_isready", "-U", DB_USER, "-d", APP_DB],
cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=DOCKER_CMD_TIMEOUT,
)
except (OSError, subprocess.TimeoutExpired):
return False
return r.returncode == 0
def _wait_pg_ready(host: str, port: int) -> bool:
deadline = time.monotonic() + PG_READY_TIMEOUT
while time.monotonic() < deadline:
if _port_open(host, port) and _pg_isready():
_log("PostgreSQL 已就绪。")
return True
_log("等待 PostgreSQL 就绪…")
time.sleep(POLL_INTERVAL)
_log(f"等待 PostgreSQL 就绪超时({PG_READY_TIMEOUT}s)。")
return False
def _test_db_exists() -> bool:
"""测试库是否已存在(连业务库 shaguabijia 查 pg_database)。"""
try:
r = subprocess.run(
["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
"psql", "-U", DB_USER, "-d", APP_DB, "-tAc",
f"SELECT 1 FROM pg_database WHERE datname='{TEST_DB}'"],
cwd=ROOT, capture_output=True, text=True, timeout=DOCKER_CMD_TIMEOUT,
)
except (OSError, subprocess.TimeoutExpired):
return False
return r.returncode == 0 and r.stdout.strip() == "1"
def ensure_test_db() -> bool:
"""幂等建测试库(兼容老 pgdata 卷首启没跑 initdb 的情况)。返回测试库是否就绪。
公开给 conftest 单独调用:ensure() 端口已通时会短路返回不建测试库,
所以测试侧需在 ensure() 之后再显式补一刀(best-effort)
"""
if _test_db_exists():
return True
_log(f"建测试库 {TEST_DB}")
try:
create = subprocess.run(
["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
"psql", "-U", DB_USER, "-d", APP_DB, "-c",
f"CREATE DATABASE {TEST_DB} OWNER {DB_USER}"],
cwd=ROOT, capture_output=True, text=True, timeout=DOCKER_CMD_TIMEOUT,
)
except (OSError, subprocess.TimeoutExpired) as e:
_log(f"⚠️ 建测试库 {TEST_DB} 失败:{e}")
return False
# returncode==0=建成功;非 0 但库已存在=与并发创建者竞争失败(42P04),仍算就绪
if create.returncode == 0 or _test_db_exists():
return True
_log(f"⚠️ 建测试库 {TEST_DB} 失败:{(create.stderr or '').strip()}")
return False
def _warn_sqlite_degraded() -> None:
"""DATABASE_URL 是 SQLite 时打印醒目降级横幅(显式逃生舱,非静默切库)。"""
for line in (
"⚠️ ================= 降级模式(SQLite) =================",
"⚠️ DATABASE_URL 是 SQLite,不是 PostgreSQL。",
"⚠️ PG 专有 SQL(窗口函数/FILTER/JSONB)与严格类型在此模式【不被验证】。",
"⚠️ 提交前请在装了 Docker 的机器上用 PG 复跑;装好后把 DATABASE_URL 改回:",
f"⚠️ {PG_URL}",
"⚠️ ===================================================",
):
_log(line)
def ensure(database_url: str | None = None) -> bool:
"""确保 PG 就绪,返回 True/False。database_url 缺省从 settings 读(尊重 .env)。
运行时若 DATABASE_URL SQLite 打印降级横幅并返回 True(显式逃生舱);
conftest 传的是 PG URL,故测试侧永不走此分支
"""
if database_url is None:
from app.core.config import settings # 延迟导入,避免过早固化 settings
database_url = settings.DATABASE_URL
if _is_sqlite(database_url):
_warn_sqlite_degraded()
return True
host, port = _parse_host_port(database_url)
if _port_open(host, port):
_log(f"✅ PostgreSQL 已在 {host}:{port} 运行,跳过 Docker。")
return True
_log(f"{host}:{port} 无 PostgreSQL,准备用 Docker 拉起…")
if not _docker_cli_ok():
_log("未检测到 docker 命令。请先安装 Docker Desktop:")
_log(" https://www.docker.com/products/docker-desktop/")
_log(f"装不了 Docker?把 .env 的 DATABASE_URL 改成 {SQLITE_URL} 可降级用 SQLite 跑")
_log(" (PG 专有 SQL/严格性不被验证,仅救急);改完重跑 run.sh/run.bat。")
return False
if not _start_docker_daemon():
return False
if not _compose_up():
return False
if not _wait_pg_ready(host, port):
return False
if not ensure_test_db():
return False
return True
if __name__ == "__main__":
sys.exit(0 if ensure() else 1)
+10 -66
View File
@@ -16,10 +16,6 @@ xiaomi/oppo/vivo)到该用户 device_liveness 里已注册的 push token。
.venv\\Scripts\\python.exe scripts\\fire_push_events.py --count 1 # 各 1 条(先小量验证通道) .venv\\Scripts\\python.exe scripts\\fire_push_events.py --count 1 # 各 1 条(先小量验证通道)
.venv\\Scripts\\python.exe scripts\\fire_push_events.py --types withdraw_failed --count 3 .venv\\Scripts\\python.exe scripts\\fire_push_events.py --types withdraw_failed --count 3
.venv\\Scripts\\python.exe scripts\\fire_push_events.py --types all --count 2 .venv\\Scripts\\python.exe scripts\\fire_push_events.py --types all --count 2
.venv\\Scripts\\python.exe scripts\\fire_push_events.py --phone 22222222222 --token ABC123 --count 1
`--token` 是可选不区分大小写的 token 子串过滤器;会推送给该账号下所有 token 包含该
子串的设备没有匹配设备时脚本直接停止,不会生成站内消息
推送成败看输出里的 `shagua.vendor_push` 日志(push sent / push failed);2 台设备则每条各推 2 推送成败看输出里的 `shagua.vendor_push` 日志(push sent / push failed);2 台设备则每条各推 2
凭据缺失或 token 失效时 notification_events 只记日志不抛错(站内消息仍会落库) 凭据缺失或 token 失效时 notification_events 只记日志不抛错(站内消息仍会落库)
@@ -62,52 +58,33 @@ _FAIL_REASONS = [
] ]
def _fire_one( def _fire_one(db, uid: int, type_key: str, i: int) -> None:
db,
uid: int,
type_key: str,
i: int,
*,
push_token_contains: str | None = None,
) -> None:
"""构造一条该类型的瞬态业务对象(不落业务表,只为给 notify 函数读字段),触发真实推送。""" """构造一条该类型的瞬态业务对象(不落业务表,只为给 notify 函数读字段),触发真实推送。"""
if type_key == "withdraw_success": if type_key == "withdraw_success":
order = WithdrawOrder(user_id=uid, out_bill_no=uuid.uuid4().hex, amount_cents=50, source="coin_cash") order = WithdrawOrder(user_id=uid, out_bill_no=uuid.uuid4().hex, amount_cents=50, source="coin_cash")
notification_events.notify_withdraw_success( notification_events.notify_withdraw_success(db, order)
db, order, push_token_contains=push_token_contains
)
elif type_key == "withdraw_failed": elif type_key == "withdraw_failed":
order = WithdrawOrder( order = WithdrawOrder(
user_id=uid, out_bill_no=uuid.uuid4().hex, amount_cents=350, source="coin_cash", user_id=uid, out_bill_no=uuid.uuid4().hex, amount_cents=350, source="coin_cash",
fail_reason=random.choice(_FAIL_REASONS), fail_reason=random.choice(_FAIL_REASONS),
) )
notification_events.notify_withdraw_failed( notification_events.notify_withdraw_failed(db, order)
db, order, push_token_contains=push_token_contains
)
elif type_key == "invite_order_reward": elif type_key == "invite_order_reward":
# 假被邀请人 id(> 真实用户范围,避重):昵称回退「好友」。真实昵称请走 API 流程(见文末说明)。 # 假被邀请人 id(> 真实用户范围,避重):昵称回退「好友」。真实昵称请走 API 流程(见文末说明)。
fake_invitee = random.randint(900000, 999999) fake_invitee = random.randint(900000, 999999)
notification_events.notify_invite_order_reward( notification_events.notify_invite_order_reward(
db, db, inviter_user_id=uid, invitee_user_id=fake_invitee, cash_cents=INVITE_COMPARE_REWARD_CENTS
inviter_user_id=uid,
invitee_user_id=fake_invitee,
cash_cents=INVITE_COMPARE_REWARD_CENTS,
push_token_contains=push_token_contains,
) )
elif type_key == "feedback_reward": elif type_key == "feedback_reward":
fb = Feedback(user_id=uid, content="(直发)", contact="", status="adopted", fb = Feedback(user_id=uid, content="(直发)", contact="", status="adopted",
reward_coins=300, admin_reply="感谢反馈,您说的问题已修复上线,金币请查收~") reward_coins=300, admin_reply="感谢反馈,您说的问题已修复上线,金币请查收~")
fb.id = random.randint(900000, 999999) fb.id = random.randint(900000, 999999)
notification_events.notify_feedback_reward( notification_events.notify_feedback_reward(db, fb)
db, fb, push_token_contains=push_token_contains
)
elif type_key == "feedback_reply": elif type_key == "feedback_reply":
fb = Feedback(user_id=uid, content="(直发)", contact="", status="rejected", fb = Feedback(user_id=uid, content="(直发)", contact="", status="rejected",
admin_reply="您的建议我们记录啦,会在后续版本评估~") admin_reply="您的建议我们记录啦,会在后续版本评估~")
fb.id = random.randint(900000, 999999) fb.id = random.randint(900000, 999999)
notification_events.notify_feedback_reply( notification_events.notify_feedback_reply(db, fb)
db, fb, push_token_contains=push_token_contains
)
elif type_key == "report_approved": elif type_key == "report_approved":
rep = PriceReport( rep = PriceReport(
user_id=uid, reported_platform_id="jd", reported_platform_name="京东外卖", user_id=uid, reported_platform_id="jd", reported_platform_name="京东外卖",
@@ -115,9 +92,7 @@ def _fire_one(
reward_coins=PRICE_REPORT_REWARD_COINS, store_name=f"测试火锅店{i:02d}", reward_coins=PRICE_REPORT_REWARD_COINS, store_name=f"测试火锅店{i:02d}",
) )
rep.id = random.randint(900000, 999999) rep.id = random.randint(900000, 999999)
notification_events.notify_report_approved( notification_events.notify_report_approved(db, rep)
db, rep, push_token_contains=push_token_contains
)
else: else:
raise SystemExit(f"未知类型: {type_key}(可选: {', '.join(ALL_TYPES)})") raise SystemExit(f"未知类型: {type_key}(可选: {', '.join(ALL_TYPES)})")
@@ -125,11 +100,6 @@ def _fire_one(
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description="直接触发消息通知中心真实推送(后台驱动不了的事件用)") parser = argparse.ArgumentParser(description="直接触发消息通知中心真实推送(后台驱动不了的事件用)")
parser.add_argument("--phone", default=DEFAULT_PHONE, help=f"目标用户手机号(默认 {DEFAULT_PHONE})") parser.add_argument("--phone", default=DEFAULT_PHONE, help=f"目标用户手机号(默认 {DEFAULT_PHONE})")
parser.add_argument(
"--token",
default="",
help="可选的厂商 token 子串(不区分大小写);仅推送到该账号下所有匹配设备",
)
parser.add_argument("--count", type=int, default=10, help="每类发多少条(默认 10)") parser.add_argument("--count", type=int, default=10, help="每类发多少条(默认 10)")
parser.add_argument( parser.add_argument(
"--types", default=",".join(DEFAULT_TYPES), "--types", default=",".join(DEFAULT_TYPES),
@@ -152,40 +122,14 @@ def main() -> None:
uid = user.id uid = user.id
targets = device_repo.list_push_targets(db, user_id=uid) targets = device_repo.list_push_targets(db, user_id=uid)
token_filter = args.token.strip() print(f"目标用户 {args.phone}(id={uid});已注册推送设备 {len(targets)} 台:"
normalized_filter = token_filter.casefold() f"{[t.push_vendor for t in targets] or '无(手机收不到!先在 App 上报 push token)'}")
matched_targets = [
target
for target in targets
if not token_filter
or normalized_filter in (target.push_token or "").casefold()
]
if token_filter and not matched_targets:
print(
f"❌ 用户 {args.phone}{len(targets)} 台有效推送设备中,"
f"没有 token 包含 {token_filter!r};未生成站内消息,也未发送推送。"
)
return
print(
f"目标用户 {args.phone}(id={uid});有效推送设备 {len(targets)} 台;"
f"本次匹配 {len(matched_targets)} 台:"
f"{[target.push_vendor for target in matched_targets] or '无(手机收不到!先在 App 上报 push token)'}"
)
if token_filter:
print(f"token 子串过滤(不区分大小写): {token_filter!r}")
print(f"即将触发:{types},每类 {args.count} 条 → 共 {len(types) * args.count}\n") print(f"即将触发:{types},每类 {args.count} 条 → 共 {len(types) * args.count}\n")
for t in types: for t in types:
print(f"── {t} ×{args.count} " + "" * 30) print(f"── {t} ×{args.count} " + "" * 30)
for i in range(1, args.count + 1): for i in range(1, args.count + 1):
_fire_one( _fire_one(db, uid, t, i)
db,
uid,
t,
i,
push_token_contains=token_filter or None,
)
print(f"\n✅ 已触发完。站内消息已落 notification 表(用 {args.phone} 登录 App 可在消息中心看到);" print(f"\n✅ 已触发完。站内消息已落 notification 表(用 {args.phone} 登录 App 可在消息中心看到);"
"\n 手机推送成败见上方 `shagua.vendor_push` 日志(push sent=成功 / push failed=失败)。") "\n 手机推送成败见上方 `shagua.vendor_push` 日志(push sent=成功 / push failed=失败)。")
+3 -1
View File
@@ -1,6 +1,8 @@
"""Bootstrap PostgreSQL: 建用户 + 建库 + 授权 + 写 .env + 跑迁移。 """Bootstrap PostgreSQL: 建用户 + 建库 + 授权 + 写 .env + 跑迁移。
新机器初始化用前置:已装 PostgreSQL 16 + 知道 postgres 超级用户密码 新机器初始化用(面向生产原生 PG:apt/systemd 装好的 PostgreSQL)
本地开发/测试请改用 docker-compose.yml + scripts/ensure_pg.py(run.sh/run.bat 自动拉起),不必跑本脚本
前置:已装 PostgreSQL 16 + 知道 postgres 超级用户密码
用法: 用法:
python scripts/init_postgres.py python scripts/init_postgres.py
+3 -36
View File
@@ -15,10 +15,6 @@ services/notification_events.notify_invite_order_reward —— 与生产同一
.venv\\Scripts\\python.exe scripts\\test_push_invite_order_reward.py # 随机金额发 1 条 .venv\\Scripts\\python.exe scripts\\test_push_invite_order_reward.py # 随机金额发 1 条
.venv\\Scripts\\python.exe scripts\\test_push_invite_order_reward.py --cents 200 # 固定 2.00 元 .venv\\Scripts\\python.exe scripts\\test_push_invite_order_reward.py --cents 200 # 固定 2.00 元
.venv\\Scripts\\python.exe scripts\\test_push_invite_order_reward.py --invitee-phone 12000000001 # 真实昵称 .venv\\Scripts\\python.exe scripts\\test_push_invite_order_reward.py --invitee-phone 12000000001 # 真实昵称
.venv\\Scripts\\python.exe scripts\\test_push_invite_order_reward.py --phone 22222222222 --token gkha
`--token` 是可选不区分大小写的 token 子串过滤器会推送给该账号下所有 token 包含该
子串的设备没有匹配设备时脚本直接停止不会生成站内消息
结果判读(看输出日志): 结果判读(看输出日志):
push sent = 厂商接口受理成功,手机应弹好友下单奖励到账通知 push sent = 厂商接口受理成功,手机应弹好友下单奖励到账通知
@@ -66,11 +62,6 @@ def _notif_count(db, uid: int) -> int:
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description="#12 好友下单到账 推送联调(每次 1 条,金额默认随机)") parser = argparse.ArgumentParser(description="#12 好友下单到账 推送联调(每次 1 条,金额默认随机)")
parser.add_argument("--phone", default="11111111111", help="邀请人(收通知方)手机号,默认 11111111111") parser.add_argument("--phone", default="11111111111", help="邀请人(收通知方)手机号,默认 11111111111")
parser.add_argument(
"--token",
default="",
help="可选的厂商 token 子串(不区分大小写);仅推送到该账号下所有匹配设备",
)
parser.add_argument("--cents", type=int, default=None, parser.add_argument("--cents", type=int, default=None,
help="奖励金额,单位分(默认随机 1~9999;线上真实值 200)") help="奖励金额,单位分(默认随机 1~9999;线上真实值 200)")
parser.add_argument("--invitee-phone", default="", parser.add_argument("--invitee-phone", default="",
@@ -96,37 +87,13 @@ def main() -> None:
invitee_id = random.randint(900000, 999999) # 假 id,昵称兜底「好友」,永不去重 invitee_id = random.randint(900000, 999999) # 假 id,昵称兜底「好友」,永不去重
targets = device_repo.list_push_targets(db, user_id=user.id) targets = device_repo.list_push_targets(db, user_id=user.id)
token_filter = args.token.strip() vendors = [t.push_vendor for t in targets]
normalized_filter = token_filter.casefold() print(f"邀请人 {args.phone}(id={user.id});推送设备 {len(targets)} 台:{vendors or '无 ← 手机收不到!先在 App 上报 push token'}")
matched_targets = [
target
for target in targets
if not token_filter
or normalized_filter in (target.push_token or "").casefold()
]
if token_filter and not matched_targets:
print(
f"❌ 用户 {args.phone}{len(targets)} 台有效推送设备中,"
f"没有 token 包含 {token_filter!r};未生成站内消息,也未发送推送。"
)
return
print(
f"邀请人 {args.phone}(id={user.id});有效推送设备 {len(targets)} 台;"
f"本次匹配 {len(matched_targets)} 台:"
f"{[target.push_vendor for target in matched_targets] or '无 ← 手机收不到!先在 App 上报 push token'}"
)
if token_filter:
print(f"token 子串过滤(不区分大小写): {token_filter!r}")
before = _notif_count(db, user.id) before = _notif_count(db, user.id)
print(f"→ 本次奖励 【{cents / 100:.2f} 元】(invitee_user_id={invitee_id}),手机上按金额认领这条通知") print(f"→ 本次奖励 【{cents / 100:.2f} 元】(invitee_user_id={invitee_id}),手机上按金额认领这条通知")
notification_events.notify_invite_order_reward( notification_events.notify_invite_order_reward(
db, db, inviter_user_id=user.id, invitee_user_id=invitee_id, cash_cents=cents
inviter_user_id=user.id,
invitee_user_id=invitee_id,
cash_cents=cents,
push_token_contains=token_filter or None,
) )
created = _notif_count(db, user.id) - before created = _notif_count(db, user.id) - before
+22 -15
View File
@@ -1,22 +1,19 @@
"""测试用 fixtures。 """测试用 fixtures。
测试 DB 用临时文件 SQLite 测试库用 Docker PostgreSQL shaguabijia_test( dev 业务库 shaguabijia 隔离)
- 不用 in-memory:in-memory 默认 per-connection,跨连接看不到表 顺序(必须): test DATABASE_URL( import app.* 之前) ensure PG 就绪 + 测试库存在
- 用临时文件保证 SessionLocal 每次新连都看到同一份 schema import app 建表持久卷可能残留上次的表 session 开头先 drop create
顺序:set env(必须在 import app.* 之前) import app 建表 TestClient
""" """
from __future__ import annotations from __future__ import annotations
import os import os
import tempfile
from collections.abc import Iterator from collections.abc import Iterator
# 临时 db 文件路径,进程退出后清理 # 1) 测试库连接串——必须在 import app.* 之前设好(app.db.session 在 import 期就建 engine)
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False) _TEST_DB_URL = (
_tmp_db.close() "postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia_test"
)
os.environ["DATABASE_URL"] = f"sqlite:///{_tmp_db.name}" os.environ["DATABASE_URL"] = _TEST_DB_URL
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-please-ignore-this-is-only-for-pytest-not-real") os.environ.setdefault("JWT_SECRET_KEY", "test-secret-please-ignore-this-is-only-for-pytest-not-real")
os.environ.setdefault("ADMIN_JWT_SECRET", "test-admin-secret-please-ignore-only-for-pytest-not-real") os.environ.setdefault("ADMIN_JWT_SECRET", "test-admin-secret-please-ignore-only-for-pytest-not-real")
os.environ.setdefault("JG_APP_KEY", "test-key") os.environ.setdefault("JG_APP_KEY", "test-key")
@@ -35,6 +32,18 @@ os.environ.setdefault("RATE_LIMIT_ENABLED", "false") # 限流内存计数会跨
os.environ.setdefault("PANGLE_CALLBACK_ENABLED", "true") os.environ.setdefault("PANGLE_CALLBACK_ENABLED", "true")
os.environ.setdefault("PANGLE_REWARD_SECRET", "test-pangle-secret-only-for-pytest") os.environ.setdefault("PANGLE_REWARD_SECRET", "test-pangle-secret-only-for-pytest")
# 2) 保证 Docker PG 就绪(必须在 import app.db.session 建 engine 之前)。
# ensure() 在「端口已通」时会短路、不建测试库,故随后再显式补一刀 ensure_test_db()。
from scripts.ensure_pg import ensure, ensure_test_db
if not ensure(_TEST_DB_URL):
raise RuntimeError(
"测试需要 Docker PostgreSQL 就绪。请确认已装 Docker Desktop;"
"或先跑一次 run.bat/run.sh 把 PG 拉起,再重试 pytest。"
)
# best-effort 兜底建测试库(PG 已在跑但测试库缺失=老卷)。真缺库时下面 create_all 会明确报错。
ensure_test_db()
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -45,13 +54,11 @@ from app.main import app
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
def _setup_db() -> Iterator[None]: def _setup_db() -> Iterator[None]:
# 持久卷可能残留上次跑崩后的表/数据 → 先 drop 再 create,保证干净起点
Base.metadata.drop_all(engine)
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
yield yield
Base.metadata.drop_all(engine) Base.metadata.drop_all(engine)
try:
os.unlink(_tmp_db.name)
except OSError:
pass
@pytest.fixture() @pytest.fixture()
-96
View File
@@ -1,96 +0,0 @@
"""发奖公式下限:看了就保底 1 金币,与前端展示公式完全对齐。
产品口径(2026-08看了就保底 1):`calculate_ad_reward_coin` **任何输入**都至少返回 1,
镜像客户端 FeedRewardFormula.singleUnitCoin(那边:eCPM /非法/非正数都返 1)此前 eCPM
缺失/ 0 0,与前端小球显示的 +1 不一致,且信息流侧把这类看满一份的广告记成 too_short 零发
公式是发奖与后台审计对账的唯一口径,改这一处两边同源
:激励视频(S2S 回调)路径在公式之前还有一道 `if not ecpm_raw` 早退**完全没上报 eCPM**
的回调仍记 ecpm_missing 零发( test_ad_reward.test_callback_without_ecpm_records_exception),
那是"回调缺字段"的数据完整性闸,区别于"广告如实上报 eCPM=0"(=看了真广告 保底 1)
"""
from __future__ import annotations
from app.core.rewards import calculate_ad_reward_coin
def test_low_positive_ecpm_floors_to_one_coin() -> None:
"""真实但极低的 eCPM 原本四舍五入成 0 金币,兜底为 1(线上 record 4667 的 43 分场景)。"""
# 43 分 = ¥0.43 CPM,因子1=0.1,重度用户 LT 第 69 条=1.0 → 0.43/1000×0.1×1.0×10000=0.43 → 旧口径 round=0
assert calculate_ad_reward_coin("43", 69) == 1
# 更低的 5 分同理:算出来 <0.5,旧口径也是 0
assert calculate_ad_reward_coin("5", 11) == 1
def test_zero_or_missing_ecpm_also_floors_to_one() -> None:
"""eCPM 为 0 / 缺失 / 非法都保底 1(2026-08「看了就保底 1」,与前端 FeedRewardFormula 对齐)。
尤其 "0"(广告如实上报零价值)此前返 0,导致小球显示 +1后端信息流记 too_short 零发的
前后端不一致 现在两端都 1
"""
assert calculate_ad_reward_coin("0", 1) == 1
assert calculate_ad_reward_coin(None, 1) == 1
assert calculate_ad_reward_coin("", 1) == 1
assert calculate_ad_reward_coin("abc", 1) == 1
def test_normal_ecpm_value_unchanged() -> None:
"""正常量级 eCPM 本就 ≥1,下限不改变其取值(回归保护)。"""
# 200 分 = ¥2 CPM,因子1=0.1,LT 第 1 条=2.0 → 2/1000×0.1×2.0×10000 = 4
assert calculate_ad_reward_coin("200", 1) == 4
def test_feed_reward_low_ecpm_grants_one_coin_instead_of_too_short() -> None:
"""端到端:低 eCPM 的信息流广告看满一份(≥10s),原本单份金币算成 0 被记 too_short,
现在兜底发 1 金币状态 granted(线上 record 4667 那类看了却零发的修复)"""
from app.db.session import SessionLocal
from app.models.user import User
from app.repositories.ad_feed_reward import grant_feed_reward
db = SessionLocal()
try:
user = User(phone="19900000043", username="feedfloor43", register_channel="sms")
db.add(user)
db.commit()
db.refresh(user)
rec = grant_feed_reward(
db, user.id,
client_event_id="feed-floor-low-ecpm-0001",
ecpm="2", # ¥0.02 CPM,单份收益四舍五入 <0.5 → 旧口径算成 0
duration_seconds=15, # ≥10s → 看满一份,不走「时长不足」的 else 分支
ad_type="draw",
feed_scene="comparison",
)
assert rec.status == "granted"
assert rec.coin == 1
finally:
db.close()
def test_feed_reward_zero_ecpm_grants_one_coin() -> None:
"""端到端:eCPM 如实上报 0(用户反馈的真实广告返回 eCPM=0 场景),看满一份也保底 1、
状态 granted 与前端小球显示的 +1 一致,不再前显示后零发"""
from app.db.session import SessionLocal
from app.models.user import User
from app.repositories.ad_feed_reward import grant_feed_reward
db = SessionLocal()
try:
user = User(phone="19900000044", username="feedzero44", register_channel="sms")
db.add(user)
db.commit()
db.refresh(user)
rec = grant_feed_reward(
db, user.id,
client_event_id="feed-zero-ecpm-0001",
ecpm="0", # 广告如实上报 eCPM=0
duration_seconds=15, # 看满一份
ad_type="draw",
feed_scene="comparison",
)
assert rec.status == "granted"
assert rec.coin == 1
finally:
db.close()
+4 -48
View File
@@ -68,12 +68,12 @@ def test_list_config(admin_client: TestClient, token: str) -> None:
r = admin_client.get("/admin/api/config", headers=_auth(token)) r = admin_client.get("/admin/api/config", headers=_auth(token))
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
items = {i["key"]: i for i in r.json()} items = {i["key"]: i for i in r.json()}
# 非 hidden 项照常返回;广告次数上限迁到「白名单」统一配置 # 非 hidden 项照常返回;看广告组保留可见的:每日上限 / 单次金币上限 / 关闭后冷却
assert "signin_rewards" in items and "ad_cooldown_sec" in items assert "signin_rewards" in items and "ad_daily_limit" in items and "ad_cooldown_sec" in items
# hidden 项(任务/里程碑、首页轮播数据源、广告次数/单次金币/每轮次数/信息流广告开关)不在配置页返回。 # hidden 项(任务/里程碑、首页轮播数据源、广告组的单次金币/每轮次数/信息流广告开关)不在配置页返回。
for hidden_key in ( for hidden_key in (
"task_rewards", "record_milestones", "marquee_feed_mode", "task_rewards", "record_milestones", "marquee_feed_mode",
"ad_daily_limit", "ad_reward_coin", "ad_round_count", "comparing_ad_enabled", "ad_reward_coin", "ad_round_count", "comparing_ad_enabled",
): ):
assert hidden_key not in items, f"{hidden_key} 应被 hidden 过滤" assert hidden_key not in items, f"{hidden_key} 应被 hidden 过滤"
assert items["signin_rewards"]["value"] == [ assert items["signin_rewards"]["value"] == [
@@ -124,50 +124,6 @@ def test_update_ad_limit_takes_effect(admin_client: TestClient, token: str) -> N
db.close() db.close()
def test_list_config_reads_limit_values_from_global_bundle(
admin_client: TestClient,
token: str,
) -> None:
changed = admin_client.patch(
"/admin/api/config/ad_cooldown_sec",
json={"value": 17},
headers=_auth(token),
)
assert changed.status_code == 200, changed.text
items = {
item["key"]: item
for item in admin_client.get(
"/admin/api/config",
headers=_auth(token),
).json()
}
assert items["ad_cooldown_sec"]["value"] == 17
assert items["ad_cooldown_sec"]["overridden"] is True
@pytest.mark.parametrize(
("key", "value"),
[
("ad_daily_limit", 0),
("ad_daily_limit", 100_001),
("ad_cooldown_sec", 86_401),
],
)
def test_update_limit_config_rejects_out_of_range_values(
admin_client: TestClient,
token: str,
key: str,
value: int,
) -> None:
response = admin_client.patch(
f"/admin/api/config/{key}",
json={"value": value},
headers=_auth(token),
)
assert response.status_code == 400, response.text
def test_update_bool_config(admin_client: TestClient, token: str) -> None: def test_update_bool_config(admin_client: TestClient, token: str) -> None:
# 提现自动对账开关默认 True # 提现自动对账开关默认 True
items = { items = {
-109
View File
@@ -219,115 +219,6 @@ def test_user_reward_stats_can_scope_withdrawals_by_account(
assert invite.json()["cash_balance_cents"] == 456 assert invite.json()["cash_balance_cents"] == 456
def test_user_reward_stats_draw_ecpm_uses_all_filtered_impressions(
admin_client: TestClient, admin_token: str
) -> None:
"""Draw 平均 eCPM 应与收益报表一致,不能只平均成功发奖记录。"""
from app.models.ad_ecpm import AdEcpmRecord
from app.models.ad_feed_reward import AdFeedRewardRecord
uid = _seed_user_with_data("13800000024")
created_at = datetime(2038, 1, 15, 4, tzinfo=UTC)
db = SessionLocal()
try:
db.add_all(
[
AdFeedRewardRecord(
client_event_id="reward-stats-granted-high",
ad_session_id="reward-stats-granted-high",
user_id=uid,
reward_date="2038-01-15",
duration_seconds=10,
unit_count=1,
ecpm_raw="9000",
ad_type="draw",
feed_scene="coupon",
app_env="prod",
our_code_id="104098712",
coin=9,
status="granted",
created_at=created_at,
),
AdEcpmRecord(
user_id=uid,
ad_type="draw",
feed_scene="coupon",
ad_session_id="reward-stats-impression-low",
app_env="prod",
our_code_id="104098712",
ecpm_raw="1000",
report_date="2038-01-15",
created_at=created_at,
),
AdEcpmRecord(
user_id=uid,
ad_type="feed",
feed_scene="coupon",
ad_session_id="reward-stats-impression-mid",
app_env="prod",
our_code_id="104098712",
ecpm_raw="3000",
report_date="2038-01-15",
created_at=created_at,
),
# 同用户但不同场景/环境/非业务代码位,均不应进入本次详情筛选。
AdEcpmRecord(
user_id=uid,
ad_type="draw",
feed_scene="comparison",
ad_session_id="reward-stats-other-scene",
app_env="prod",
our_code_id="104098712",
ecpm_raw="7000",
report_date="2038-01-15",
created_at=created_at,
),
AdEcpmRecord(
user_id=uid,
ad_type="draw",
feed_scene="coupon",
ad_session_id="reward-stats-test-env",
app_env="test",
our_code_id="104127529",
ecpm_raw="8000",
report_date="2038-01-15",
created_at=created_at,
),
AdEcpmRecord(
user_id=uid,
ad_type="draw",
feed_scene="coupon",
ad_session_id="reward-stats-non-business",
app_env="prod",
our_code_id="demo-slot",
ecpm_raw="9000",
report_date="2038-01-15",
created_at=created_at,
),
]
)
db.commit()
finally:
db.close()
response = admin_client.get(
f"/admin/api/users/{uid}/reward-stats",
params={
"date_from": "2038-01-15T00:00:00Z",
"date_to": "2038-01-15T23:59:59Z",
"app_env": "prod",
"revenue_scope": "business",
"feed_scene": "coupon",
},
headers=_auth(admin_token),
)
assert response.status_code == 200, response.text
data = response.json()
assert data["feed_count"] == 1
# 全部真实展示 (1000 + 3000) / 2;不能返回成功发奖记录的 9000。
assert data["feed_avg_ecpm"] == 2000.0
def test_user_coin_record_sort_accepts_mixed_timezone_datetimes() -> None: def test_user_coin_record_sort_accepts_mixed_timezone_datetimes() -> None:
"""线上 PostgreSQL 返回 awareSQLite/历史转换可能返回 naive,二者必须可混排。""" """线上 PostgreSQL 返回 awareSQLite/历史转换可能返回 naive,二者必须可混排。"""
naive = datetime(2038, 1, 1, 8, 0) naive = datetime(2038, 1, 1, 8, 0)
+2 -7
View File
@@ -73,7 +73,6 @@ def test_monitoring_audit_catalog_and_api_permissions(
"analytics-health", "analytics-health",
"event-logs", "event-logs",
"audit-logs", "audit-logs",
"limit-whitelist",
] ]
# 运营默认可查风控和设备存活,不能绕过导航直调技术/审计接口。 # 运营默认可查风控和设备存活,不能绕过导航直调技术/审计接口。
@@ -83,9 +82,6 @@ def test_monitoring_audit_catalog_and_api_permissions(
assert admin_client.get( assert admin_client.get(
"/admin/api/device-liveness/stats", headers=_auth(operator_token) "/admin/api/device-liveness/stats", headers=_auth(operator_token)
).status_code == 200 ).status_code == 200
assert admin_client.get(
"/admin/api/limit-whitelist/rules", headers=_auth(operator_token)
).status_code == 200
for path in ( for path in (
"/admin/api/analytics-health/overview?date_from=2026-07-01T00:00:00Z&date_to=2026-07-02T00:00:00Z", "/admin/api/analytics-health/overview?date_from=2026-07-01T00:00:00Z&date_to=2026-07-02T00:00:00Z",
"/admin/api/event-logs", "/admin/api/event-logs",
@@ -93,14 +89,13 @@ def test_monitoring_audit_catalog_and_api_permissions(
): ):
assert admin_client.get(path, headers=_auth(operator_token)).status_code == 403 assert admin_client.get(path, headers=_auth(operator_token)).status_code == 403
# 技术角色默认拥有监控审计组全部项权限。 # 技术角色默认拥有监控审计组全部项权限。
for path in ( for path in (
"/admin/api/risk-monitor/summary", "/admin/api/risk-monitor/summary",
"/admin/api/device-liveness/stats", "/admin/api/device-liveness/stats",
"/admin/api/analytics-health/overview?date_from=2026-07-01T00:00:00Z&date_to=2026-07-02T00:00:00Z", "/admin/api/analytics-health/overview?date_from=2026-07-01T00:00:00Z&date_to=2026-07-02T00:00:00Z",
"/admin/api/event-logs", "/admin/api/event-logs",
"/admin/api/audit-logs", "/admin/api/audit-logs",
"/admin/api/limit-whitelist/rules",
): ):
assert admin_client.get(path, headers=_auth(tech_token)).status_code == 200 assert admin_client.get(path, headers=_auth(tech_token)).status_code == 200
@@ -204,7 +199,7 @@ def test_builtin_roles_labels_and_pages(admin_client, super_token) -> None:
} }
assert set(roles["tech"]["pages"]) == { assert set(roles["tech"]["pages"]) == {
"dashboard", "risk-monitor", "device-liveness", "analytics-health", "config", "dashboard", "risk-monitor", "device-liveness", "analytics-health", "config",
"ad-revenue", "huawei-review", "event-logs", "audit-logs", "limit-whitelist", "ad-revenue", "huawei-review", "event-logs", "audit-logs",
} }
-23
View File
@@ -68,29 +68,6 @@ def test_sms_login_and_me_flow(client) -> None:
assert r.json()["ok"] is True assert r.json()["ok"] is True
def test_sms_send_records_provider_and_fallback_in_details(client, monkeypatch) -> None:
"""批次3(Task 6):sms_send 成功后风控流水 details 带 provider/fallback。
mock 默认走极光主(SMS_FALLBACK_PROVIDER ) provider=jiguangfallback=False
(回归:修复前 send_code 返回 SendResult 被塞进 cooldown_sec:int 500,此处 200 断言即挡住)"""
from app.repositories import risk as risk_repo
captured: dict = {}
orig = risk_repo.record_behavior_event
def _spy(db, **kw):
if kw.get("event_type") == risk_repo.EVENT_SMS_SEND and kw.get("outcome") == "success":
captured.update(kw.get("details") or {})
return orig(db, **kw)
monkeypatch.setattr(risk_repo, "record_behavior_event", _spy)
r = client.post("/api/v1/auth/sms/send",
json={"phone": "13800009999", "device_id": "dev-provider-test"})
assert r.status_code == 200, r.text
assert captured.get("provider") == "jiguang"
assert captured.get("fallback") is False
def test_sms_send_too_frequent(client) -> None: def test_sms_send_too_frequent(client) -> None:
phone = "13900139000" phone = "13900139000"
assert client.post("/api/v1/auth/sms/send", json={"phone": phone}).status_code == 200 assert client.post("/api/v1/auth/sms/send", json={"phone": phone}).status_code == 200
+2 -29
View File
@@ -48,10 +48,7 @@ def test_compare_start_is_idempotent_by_trace_id(client) -> None:
retry = client.post("/api/v1/compare/start", json=payload, headers=_headers(token)) retry = client.post("/api/v1/compare/start", json=payload, headers=_headers(token))
assert first.status_code == 200, first.text assert first.status_code == 200, first.text
# trace_id 回显客户端带来的值(老协议幂等路径) assert first.json() == {"limit": 100, "used": 1, "remaining": 99}
assert first.json() == {
"limit": 100, "used": 1, "remaining": 99, "trace_id": payload["trace_id"],
}
assert retry.status_code == 200, retry.text assert retry.status_code == 200, retry.text
assert retry.json() == first.json() assert retry.json() == first.json()
with SessionLocal() as db: with SessionLocal() as db:
@@ -72,28 +69,6 @@ def test_compare_start_is_idempotent_by_trace_id(client) -> None:
assert record.device_id == "quota-device" assert record.device_id == "quota-device"
def test_compare_start_issues_trace_id_when_absent(client) -> None:
"""新客户端不带 trace_id → 服务端签发并随响应返回,running 行以签发 id 建。"""
token, user_id = _login(client)
response = client.post(
"/api/v1/compare/start",
json={"business_type": "food", "device_id": "quota-device-issue"},
headers=_headers(token),
)
assert response.status_code == 200, response.text
body = response.json()
issued = body["trace_id"]
assert issued # 非空签发
assert body["used"] == 1
with SessionLocal() as db:
record = db.execute(
select(ComparisonRecord).where(ComparisonRecord.trace_id == issued)
).scalar_one()
assert record.user_id == user_id
assert record.status == "running"
assert record.device_id == "quota-device-issue"
def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None: def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None:
token, user_id = _login(client) token, user_id = _login(client)
now = datetime.now(CN_TZ).replace(tzinfo=None) now = datetime.now(CN_TZ).replace(tzinfo=None)
@@ -126,9 +101,7 @@ def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None:
headers=_headers(token), headers=_headers(token),
) )
assert allowed.status_code == 200, allowed.text assert allowed.status_code == 200, allowed.text
assert allowed.json() == { assert allowed.json() == {"limit": 100, "used": 100, "remaining": 0}
"limit": 100, "used": 100, "remaining": 0, "trace_id": final_allowed_trace,
}
rejected_trace = f"quota-rejected-{user_id}" rejected_trace = f"quota-rejected-{user_id}"
response = client.post( response = client.post(
+18 -107
View File
@@ -18,6 +18,7 @@ from sqlalchemy import select
from app.db.session import SessionLocal from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord from app.models.comparison import ComparisonRecord
from app.repositories import comparison as crud from app.repositories import comparison as crud
from app.repositories.user import get_user_by_phone
from app.schemas.compare_record import ComparisonRecordIn from app.schemas.compare_record import ComparisonRecordIn
@@ -46,6 +47,17 @@ def _get(db, trace_id: str) -> ComparisonRecord | None:
).scalar_one_or_none() ).scalar_one_or_none()
def _make_user(client, phone: str) -> int:
"""登录建号并返回其真实 user_id。
PG 强制 comparison_record.user_id user.id 外键,须引用真实存在的用户;
用本用例自己登录出的用户,不会与别的用例撞
"""
client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
with SessionLocal() as db:
return get_user_by_phone(db, phone).id
# ============================================================ # ============================================================
# repo 层 # repo 层
# ============================================================ # ============================================================
@@ -102,7 +114,6 @@ def test_harvest_done_derives_and_newly_success_once(client) -> None:
assert rec.is_source_best is False assert rec.is_source_best is False
assert rec.store_name == "测试店" assert rec.store_name == "测试店"
assert rec.information == "美团更便宜" assert rec.information == "美团更便宜"
assert rec.fail_reason is None # 成功记录不派生失败原因
assert rec.items == [{"name": "肥牛饭", "qty": 1}] assert rec.items == [{"name": "肥牛饭", "qty": 1}]
assert rec.trace_url.endswith("/done/") assert rec.trace_url.endswith("/done/")
# 再来一次(重试 done)→ 已 success,newly_success=False(发奖不重复触发) # 再来一次(重试 done)→ 已 success,newly_success=False(发奖不重复触发)
@@ -111,104 +122,6 @@ def test_harvest_done_derives_and_newly_success_once(client) -> None:
assert newly2 is False assert newly2 is False
def test_harvest_done_failed_derives_fail_reason(client) -> None:
"""failed 记录:记录级 information 笼统,但 fail_reason 从 platform_results 救出具体原因
(id 3030 :美团系统失败 + 京东 items_not_found 展示京东那条)"""
tid = _tid()
done_failed = {
"comparison_results": [
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购",
"package": "com.taobao.taobao", "price": 23.04, "is_source": True, "rank": 1,
"items": [{"name": "肥牛饭", "qty": 1}]},
],
"platform_results": {
"taobao_flash": {"is_source": True, "status": "source", "price": 23.04},
"meituan_waimai": {"is_source": False, "status": "failed",
"reason": "搜索店铺失败, 无法跳转到搜索页"},
"jd_waimai_standalone": {"is_source": False, "status": "items_not_found",
"reason": "京东外卖此店内未找到这些菜品"},
},
"information": "比价过程出错,请稍后重试",
}
with SessionLocal() as db:
crud.harvest_running(db, trace_id=tid, user_id=None)
rec, newly = crud.harvest_done(db, trace_id=tid, user_id=None,
done_params=done_failed)
assert newly is False # 没落成 success
assert rec.status == "failed"
assert rec.fail_reason == "京东外卖此店内未找到这些菜品"
assert rec.information == "比价过程出错,请稍后重试" # 原文案仍留存
def _done_params_platforms_no_isbest() -> dict:
"""id 3304 型:done 帧 platforms 全平台「相似替换/仅供参考」(has_dish_diff),pricebot
一个 is_best 都没标,但有有价目标(美团 57.8 < 源淘宝闪购 60.8)"""
return {
"platforms": [
{"role": "source", "platform_id": "eleme", "platform_name": "淘宝闪购",
"package": "me.ele", "price": 60.8, "is_best": False, "has_dish_diff": False,
"store_name": "窑鸡王", "items": [{"name": "招牌窑鸡 整只-香辣", "qty": 1}]},
{"role": "target", "platform_id": "meituan_waimai", "platform_name": "美团外卖",
"package": "com.sankuai.meituan.takeoutnew", "price": 57.8, "is_best": False,
"has_dish_diff": True},
{"role": "target", "platform_id": "jd_waimai_standalone", "platform_name": "京东外卖",
"package": "com.jd.waimai", "price": 74.9, "is_best": False, "has_dish_diff": True},
],
"information": "美团更便宜(含相似商品替换)",
}
def test_harvest_done_platforms_no_isbest_falls_back_to_cheapest_target(client) -> None:
"""回归(id 3304):platforms 全无 is_best(全平台相似替换)但有有价目标 → best 应兜底取
有价目标里最低那家,不能让 best_*/saved 整条落 NULL(否则首页价 0.00 / 记录页无最低红框 /
省额丢失)"""
tid = _tid()
with SessionLocal() as db:
crud.harvest_running(db, trace_id=tid, user_id=None)
rec, _ = crud.harvest_done(db, trace_id=tid, user_id=None,
done_params=_done_params_platforms_no_isbest())
assert rec.status == "success"
assert rec.best_platform_id == "meituan_waimai" # 有价目标里最低
assert rec.best_price_cents == 5780 # 57.8 元
assert rec.source_price_cents == 6080 # 源淘宝闪购 60.8
assert rec.saved_amount_cents == 300 # 60.8 - 57.8
assert rec.is_source_best is False # 兜底选的是目标,非源
def _done_params_no_isbest_source_cheapest() -> dict:
"""无 is_best 且源本身最便宜:源淘宝闪购 50.0 < 全部 dish-diff 目标(美团 57.8 / 京东 74.9)。
此时不能强选更贵的目标当 best(否则 saved 变负污染累计发现可省),应回落源"""
return {
"platforms": [
{"role": "source", "platform_id": "eleme", "platform_name": "淘宝闪购",
"package": "me.ele", "price": 50.0, "is_best": False, "has_dish_diff": False,
"store_name": "窑鸡王", "items": [{"name": "招牌窑鸡 整只-香辣", "qty": 1}]},
{"role": "target", "platform_id": "meituan_waimai", "platform_name": "美团外卖",
"package": "com.sankuai.meituan.takeoutnew", "price": 57.8, "is_best": False,
"has_dish_diff": True},
{"role": "target", "platform_id": "jd_waimai_standalone", "platform_name": "京东外卖",
"package": "com.jd.waimai", "price": 74.9, "is_best": False, "has_dish_diff": True},
],
"information": "源平台已是最低(其余为相似替换)",
}
def test_harvest_done_no_isbest_source_cheapest_falls_back_to_source(client) -> None:
"""回归:无 is_best 且源最便宜 → best 回落源(与 _derive「全目标缺菜回落源、不虚报省」同语义),
saved=0is_source_best=True,绝不因强选更贵目标而让 saved 变负倒扣累计发现可省"""
tid = _tid()
with SessionLocal() as db:
crud.harvest_running(db, trace_id=tid, user_id=None)
rec, _ = crud.harvest_done(db, trace_id=tid, user_id=None,
done_params=_done_params_no_isbest_source_cheapest())
assert rec.status == "success"
assert rec.best_platform_id == "eleme" # 回落到源(源最便宜)
assert rec.best_price_cents == 5000 # 源 50.0
assert rec.source_price_cents == 5000
assert rec.saved_amount_cents == 0 # 没省到,绝不为负
assert rec.is_source_best is True # 源就是最便宜
def test_harvest_abort_cancels_running(client) -> None: def test_harvest_abort_cancels_running(client) -> None:
tid = _tid() tid = _tid()
with SessionLocal() as db: with SessionLocal() as db:
@@ -242,17 +155,18 @@ def test_harvest_abort_missing_row_returns_none(client) -> None:
def test_upsert_record_no_downgrade_after_harvest_success(client) -> None: def test_upsert_record_no_downgrade_after_harvest_success(client) -> None:
"""harvest 落 success 后,老客户端 fromFailure 的 cancelled 上报不许把它盖回去。""" """harvest 落 success 后,老客户端 fromFailure 的 cancelled 上报不许把它盖回去。"""
tid = _tid() tid = _tid()
# PG 强制 comparison_record.user_id → user.id 外键(SQLite 不强制,老写法用合成 id
# 987654)。用本用例自己登录出的真实用户,既满足外键、又不与别的用例撞。
uid = _make_user(client, "13800007701")
with SessionLocal() as db: with SessionLocal() as db:
crud.harvest_done(db, trace_id=tid, user_id=None, done_params=_done_params()) crud.harvest_done(db, trace_id=tid, user_id=None, done_params=_done_params())
payload = ComparisonRecordIn( payload = ComparisonRecordIn(
trace_id=tid, business_type="food", status="cancelled", trace_id=tid, business_type="food", status="cancelled",
information="用户终止", comparison_results=[], information="用户终止", comparison_results=[],
) )
# 用一个不会与顺序自增用户撞的合成 id(SQLite 测试库 FK 不强制;别用小整数, rec = crud.upsert_record(db, user_id=uid, payload=payload)
# 否则会撞上别的测试 login 出来的真实 user_id → 记录混进那个用户的列表)。
rec = crud.upsert_record(db, user_id=987654, payload=payload)
assert rec.status == "success" # 不降级 assert rec.status == "success" # 不降级
assert rec.user_id == 987654 # 但补上了 user_id(原为 None) assert rec.user_id == uid # 但补上了 user_id(原为 None)
# ============================================================ # ============================================================
@@ -332,9 +246,7 @@ def test_trace_finalize_harvests_abort(client) -> None:
with SessionLocal() as db: # 先有 running 行(帧0建的) with SessionLocal() as db: # 先有 running 行(帧0建的)
crud.harvest_running(db, trace_id=tid, user_id=None) crud.harvest_running(db, trace_id=tid, user_id=None)
p, _cap = _mock_pricebot({"trace_url": "https://price.shaguabijia.com/traces/fin/"}) p, _cap = _mock_pricebot({"trace_url": "https://price.shaguabijia.com/traces/fin/"})
with p, patch( with p:
"app.api.v1.compare.backfill_comparison_llm_cost"
) as backfill:
r = client.post("/api/v1/trace/finalize", r = client.post("/api/v1/trace/finalize",
json={"trace_id": tid, "status": "cancelled", "reason": "用户终止"}) json={"trace_id": tid, "status": "cancelled", "reason": "用户终止"})
assert r.status_code == 200 assert r.status_code == 200
@@ -342,7 +254,6 @@ def test_trace_finalize_harvests_abort(client) -> None:
rec = _get(db, tid) rec = _get(db, tid)
assert rec is not None and rec.status == "cancelled" assert rec is not None and rec.status == "cancelled"
assert rec.trace_url.endswith("/fin/") assert rec.trace_url.endswith("/fin/")
backfill.assert_called_once_with(rec.id, tid)
def test_price_step_binds_user_when_authed(client) -> None: def test_price_step_binds_user_when_authed(client) -> None:
-4
View File
@@ -72,7 +72,6 @@ def test_backfill_retries_then_persists_cost(monkeypatch):
def test_repair_batch_only_targets_terminal_missing_rows(monkeypatch): def test_repair_batch_only_targets_terminal_missing_rows(monkeypatch):
missing_id = _record("llm-repair-missing") missing_id = _record("llm-repair-missing")
cancelled_id = _record("llm-repair-cancelled", status="cancelled")
running_id = _record("llm-repair-running", status="running") running_id = _record("llm-repair-running", status="running")
calls = [ calls = [
{ {
@@ -97,15 +96,12 @@ def test_repair_batch_only_targets_terminal_missing_rows(monkeypatch):
) )
assert result["repaired"] >= 1 assert result["repaired"] >= 1
assert "llm-repair-missing" in seen assert "llm-repair-missing" in seen
assert "llm-repair-cancelled" in seen
assert "llm-repair-running" not in seen assert "llm-repair-running" not in seen
with SessionLocal() as db: with SessionLocal() as db:
assert db.get(ComparisonRecord, missing_id).llm_cost_yuan is not None assert db.get(ComparisonRecord, missing_id).llm_cost_yuan is not None
assert db.get(ComparisonRecord, cancelled_id).llm_cost_yuan is not None
assert db.get(ComparisonRecord, running_id).llm_cost_yuan is None assert db.get(ComparisonRecord, running_id).llm_cost_yuan is None
finally: finally:
_delete(missing_id) _delete(missing_id)
_delete(cancelled_id)
_delete(running_id) _delete(running_id)
+6 -12
View File
@@ -8,7 +8,6 @@ mock 掉对 pricebot 的 httpx 调用,验证:
""" """
from __future__ import annotations from __future__ import annotations
import json
import time import time
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -63,8 +62,7 @@ def test_coupon_step_no_auth_required(client) -> None:
def test_coupon_step_passes_body_through(client, access_token) -> None: def test_coupon_step_passes_body_through(client, access_token) -> None:
"""带 token + pricebot 200 → 请求 body 原样转发到 /api/coupon/step; """带 token + pricebot 200 → 响应原样透传,请求 body 原样转发到 /api/coupon/step"""
响应在透传基础上顶层回显本次任务 trace_id(setdefault 注入,其余字段原样)"""
fake_pricebot_resp = { fake_pricebot_resp = {
"success": True, "success": True,
"action": { "action": {
@@ -85,12 +83,9 @@ def test_coupon_step_passes_body_through(client, access_token) -> None:
captured: dict = {} captured: dict = {}
# ⚠️ coupon_step 转发用 content=raw(原始字节透传,不重新 dumps),不是 json= —— async def fake_post(self, url, json=None, **kw):
# fake 必须捕 content。旧 fake 只捕 json= 导致 captured["json"] 恒 None,本测试
# 自 content=raw 优化后一直红着(pre-existing),本次顺手修正。
async def fake_post(self, url, content=None, **kw):
captured["url"] = url captured["url"] = url
captured["content"] = content captured["json"] = json
mock_resp = MagicMock() mock_resp = MagicMock()
mock_resp.status_code = 200 mock_resp.status_code = 200
mock_resp.json = lambda: fake_pricebot_resp mock_resp.json = lambda: fake_pricebot_resp
@@ -104,10 +99,9 @@ def test_coupon_step_passes_body_through(client, access_token) -> None:
) )
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
# 顶层多出 trace_id 回显(值=请求带的;不 mint,签发点唯一在 /coupon/session started) assert r.json() == fake_pricebot_resp
assert r.json() == {**fake_pricebot_resp, "trace_id": "test-trace-1"} # 验证请求被原样转发(body 不动 + URL 指向 pricebot)
# 验证请求被原样转发(body 字节不动 + URL 指向 pricebot) assert captured["json"] == _stub_request_body()
assert json.loads(captured["content"]) == _stub_request_body()
assert captured["url"].endswith("/api/coupon/step") assert captured["url"].endswith("/api/coupon/step")
-65
View File
@@ -1,65 +0,0 @@
"""POST /api/v1/coupon/session 的 trace_id 签发行为(统一 trace_id 由后端下发)。
- started 不带 trace_id 服务端签发并返回,行以签发 id ;
- started trace_id 回显沿用(老客户端兼容);
- started trace_id 不签发不写库,trace_id=null(防孤儿行)
"""
from __future__ import annotations
from sqlalchemy import func, select
from app.db.session import SessionLocal
from app.models.coupon_state import CouponSession
def _base_payload(**overrides) -> dict:
payload = {
"device_id": "cs-issue-device",
"status": "started",
"started_at_ms": 1_722_000_000_000,
"platforms": ["meituan-waimai"],
"app_env": "dev",
}
payload.update(overrides)
return payload
def test_session_started_issues_trace_id_when_absent(client) -> None:
response = client.post("/api/v1/coupon/session", json=_base_payload())
assert response.status_code == 200, response.text
body = response.json()
assert body["ok"] is True
issued = body["trace_id"]
assert issued
with SessionLocal() as db:
row = db.execute(
select(CouponSession).where(CouponSession.trace_id == issued)
).scalar_one()
assert row.device_id == "cs-issue-device"
assert row.status == "started"
def test_session_started_echoes_client_trace_id(client) -> None:
response = client.post(
"/api/v1/coupon/session", json=_base_payload(trace_id="cs-legacy-1")
)
assert response.status_code == 200, response.text
assert response.json()["trace_id"] == "cs-legacy-1"
with SessionLocal() as db:
count = db.scalar(
select(func.count(CouponSession.id)).where(
CouponSession.trace_id == "cs-legacy-1"
)
)
assert count == 1
def test_session_terminal_without_trace_id_skips_write(client) -> None:
response = client.post(
"/api/v1/coupon/session",
json=_base_payload(status="completed", elapsed_ms=1234, claimed_count=2),
)
assert response.status_code == 200, response.text
body = response.json()
assert body["ok"] is True
assert body["trace_id"] is None
+129
View File
@@ -0,0 +1,129 @@
"""scripts/ensure_pg.py 纯函数单测(不需要 Docker/PG)。"""
from __future__ import annotations
import socket
from scripts.ensure_pg import (
_docker_desktop_cmd,
_docker_desktop_from_registry,
_is_sqlite,
_parse_host_port,
_port_open,
_win_docker_desktop_candidates,
ensure,
)
def test_is_sqlite():
assert _is_sqlite("sqlite:///./data/app.db")
assert _is_sqlite(" SQLite:///x ")
assert not _is_sqlite("postgresql+psycopg://u:p@localhost:5432/db")
def test_parse_host_port_full():
assert _parse_host_port(
"postgresql+psycopg://u:p@localhost:5432/shaguabijia"
) == ("localhost", 5432)
def test_parse_host_port_defaults():
# 缺端口 → 5432
assert _parse_host_port("postgresql+psycopg://u:p@db.example/x")[1] == 5432
# 缺 host → localhost
assert _parse_host_port("postgresql+psycopg:///x") == ("localhost", 5432)
def test_parse_host_port_testdb():
assert _parse_host_port(
"postgresql+psycopg://u:p@localhost:5432/shaguabijia_test"
) == ("localhost", 5432)
def test_port_open_true():
srv = socket.socket()
srv.bind(("127.0.0.1", 0))
srv.listen(1)
port = srv.getsockname()[1]
try:
assert _port_open("127.0.0.1", port, timeout=1.0)
finally:
srv.close()
def test_port_open_false():
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close() # 释放端口,无人监听 → 连接应失败
assert not _port_open("127.0.0.1", port, timeout=0.3)
def test_win_candidates_override_wins():
env = {
"DOCKER_DESKTOP_EXE": r"X:\custom\Docker Desktop.exe",
"ProgramFiles": r"C:\Program Files",
}
cands = [str(p) for p in _win_docker_desktop_candidates(env, None)]
assert cands[0] == r"X:\custom\Docker Desktop.exe"
def test_win_candidates_follow_cli_drive():
# 核心:docker CLI 在 D 盘 → 反推出 D 盘的 Docker Desktop.exe(不再写死 C 盘)
env = {"ProgramFiles": r"C:\Program Files"}
cli = r"D:\Docker\Docker\resources\bin\docker.exe"
cands = [str(p) for p in _win_docker_desktop_candidates(env, cli)]
assert r"D:\Docker\Docker\Docker Desktop.exe" in cands
# 兜底的 C 盘常见路径也仍在
assert r"C:\Program Files\Docker\Docker\Docker Desktop.exe" in cands
def test_win_candidates_no_cli_uses_program_files():
env = {"ProgramFiles": r"C:\Program Files"}
cands = [str(p) for p in _win_docker_desktop_candidates(env, None)]
assert cands == [r"C:\Program Files\Docker\Docker\Docker Desktop.exe"]
def test_docker_desktop_cmd_windows_found(monkeypatch, tmp_path):
exe = tmp_path / "Docker Desktop.exe"
exe.write_text("") # 真实存在
monkeypatch.setattr("scripts.ensure_pg._find_docker_desktop_exe", lambda: exe)
assert _docker_desktop_cmd("win32") == [str(exe)]
def test_docker_desktop_cmd_windows_not_found(monkeypatch):
monkeypatch.setattr("scripts.ensure_pg._find_docker_desktop_exe", lambda: None)
assert _docker_desktop_cmd("win32") is None
def test_docker_desktop_cmd_darwin():
assert _docker_desktop_cmd("darwin") == ["open", "-a", "Docker"]
def test_docker_desktop_cmd_linux():
assert _docker_desktop_cmd("linux") is None
def test_registry_probe_never_raises():
# best-effort:无论平台/有无键,只返回 Path 或 None,绝不抛
r = _docker_desktop_from_registry()
assert r is None or hasattr(r, "exists")
def test_ensure_sqlite_escape_hatch(monkeypatch):
# sqlite 是【显式降级逃生舱】:打印横幅、返回 True,且绝不触碰 docker
def _boom():
raise AssertionError("sqlite 分支不应调用 docker")
monkeypatch.setattr("scripts.ensure_pg._docker_cli_ok", _boom)
assert ensure("sqlite:///./data/app.db") is True
def test_ensure_shortcircuits_when_pg_up(monkeypatch):
# 端口通 → 直接 True,绝不触碰 docker
monkeypatch.setattr("scripts.ensure_pg._port_open", lambda *a, **k: True)
def _boom():
raise AssertionError("端口通时不应调用 docker")
monkeypatch.setattr("scripts.ensure_pg._docker_cli_ok", _boom)
assert ensure("postgresql+psycopg://u:p@localhost:5432/shaguabijia") is True
-122
View File
@@ -1,122 +0,0 @@
"""失败卡展示原因派生(repositories.comparison._derive_fail_display)单元测试。
用例取自线上真实 failed 记录(platform_results 形态),覆盖:
- information 具体 直出
- information 笼统 + platform_results 有干净业务结局 救援出该原因(id 3030/2964 )
- information 笼统 + 仅系统失败(搜索失败等黑话) None(端侧品牌兜底,id 3027 )
- store_closed / no_delivery pricebot 漏成 status=failed reason 关键字补判
- 打烊类脏店名 blob 统一简短模板
- platform_results 为空 / 非对象 None
"""
from __future__ import annotations
from app.repositories import comparison as crud
def test_specific_information_passthrough() -> None:
# information 本身具体(未达起送/找不到菜等)→ 直出,不看 platform_results
assert (
crud._derive_fail_display("淘宝闪购未达起送门槛,可加菜凑单后下单", {})
== "淘宝闪购未达起送门槛,可加菜凑单后下单"
)
assert crud._derive_fail_display("未识别到商品", {}) == "未识别到商品"
def test_generic_info_rescued_from_items_not_found() -> None:
# id 3030 型:美团系统失败 + 京东 items_not_found,记录级 information 笼统 → 救出京东那条
pr = {
"eleme": {"is_source": True, "status": "source", "price": 23.04},
"meituan_waimai": {
"is_source": False, "status": "failed",
"reason": "搜索店铺失败, 无法跳转到搜索页",
},
"jd_waimai_standalone": {
"is_source": False, "status": "items_not_found",
"reason": "京东外卖此店内未找到这些菜品",
},
}
assert (
crud._derive_fail_display("比价过程出错,请稍后重试", pr)
== "京东外卖此店内未找到这些菜品"
)
def test_generic_info_rescued_from_store_not_found() -> None:
# id 2964 型:美团系统失败 + 京东 store_not_found → 救出京东相似店铺文案
pr = {
"taobao_flash": {"is_source": True, "status": "source", "price": 127.98},
"meituan": {
"is_source": False, "status": "failed",
"reason": "比价过程出错,请稍后重试",
},
"jd_waimai": {
"is_source": False, "status": "store_not_found",
"reason": "未在京东找到「黔珍味·贵州牛肉蘸水健康菜 (望京店)」相似店铺",
},
}
assert (
crud._derive_fail_display("比价过程出错,请稍后重试", pr)
== "未在京东找到「黔珍味·贵州牛肉蘸水健康菜 (望京店)」相似店铺"
)
def test_generic_info_pure_system_failure_returns_none() -> None:
# id 3027 型:唯一目标平台是自动化黑话失败 → 不给用户看 → None(端侧品牌兜底)
pr = {
"eleme": {"is_source": True, "status": "source", "price": 18.83},
"meituan_waimai": {
"is_source": False, "status": "failed",
"reason": "搜索店铺失败, 无法跳转到搜索页",
},
}
assert crud._derive_fail_display("比价过程出错,请稍后重试", pr) is None
def test_store_closed_leaked_to_failed_is_rescued_and_cleaned() -> None:
# 打烊被漏成 status=failed;reason 常带脏店名 blob → 统一简短模板
pr = {
"jd_waimai": {"is_source": True, "status": "source", "price": 25},
"taobao_flash": {
"is_source": False, "status": "failed",
"reason": "淘宝闪购「沙胆彪炭炉牛杂煲(...),蜂鸟准时达,月售300+,起送¥20」本店已休息,无法比价",
},
}
assert crud._derive_fail_display("比价过程出错,请稍后重试", pr) == "门店休息中,无法比价"
pr2 = {
"taobao_flash": {"is_source": True, "status": "source", "price": 31.83},
"meituan": {
"is_source": False, "status": "failed",
"reason": "美团「奈雪的茶(北京王府井奥莱·香江」门店已打烊,无法比价",
},
}
assert crud._derive_fail_display("比价出错", pr2) == "门店已打烊,无法比价"
def test_no_delivery_leaked_to_failed_is_rescued() -> None:
# 单点不配送被漏成 status=failed;reason 本身干净 → 直接用
reason = "京东外卖该商家所选商品单点不配送,无法进入结算比价"
pr = {
"taobao_flash": {"is_source": True, "status": "source", "price": 20.1},
"jd_waimai_standalone": {
"is_source": False, "status": "failed", "reason": reason,
},
}
assert crud._derive_fail_display("比价过程出错,请稍后重试", pr) == reason
def test_empty_or_missing_platform_results_returns_none() -> None:
# 「比价出错」+ 空 {} / None / 非对象:引擎早夭,无可展示原因 → None
assert crud._derive_fail_display("比价出错", {}) is None
assert crud._derive_fail_display("比价过程出错,请稍后重试", None) is None
assert crud._derive_fail_display("比价出错", []) is None # 老 array 形态,防御
def test_clean_status_wins_over_priority_order() -> None:
# 多个业务结局同现时按 _BIZ_STATUS_PRIORITY 选(below_minimum 优先于 store_not_found)
pr = {
"src": {"is_source": True, "status": "source", "price": 30},
"a": {"is_source": False, "status": "store_not_found", "reason": "未找到店铺A"},
"b": {"is_source": False, "status": "below_minimum", "reason": "B未达起送门槛"},
}
assert crud._derive_fail_display("比价过程出错,请稍后重试", pr) == "B未达起送门槛"
+1 -1
View File
@@ -201,7 +201,7 @@ def test_start_loses_seq_race_degrades_to_ad(client, guide_configured, monkeypat
assert first["should_play"] is True and first["seq"] == 1 assert first["should_play"] is True and first["seq"] == 1
# 本次请求读到的是过期计数 → 仍会算出 seq=1 # 本次请求读到的是过期计数 → 仍会算出 seq=1
monkeypatch.setattr(crud_guide, "used_plays", lambda db, user_id, scene="coupon": 0) monkeypatch.setattr(crud_guide, "used_plays", lambda db, user_id: 0)
with SessionLocal() as db: with SessionLocal() as db:
result = crud_guide.start_play(db, uid) result = crud_guide.start_play(db, uid)
File diff suppressed because it is too large Load Diff
-95
View File
@@ -7,9 +7,7 @@ mock/真发/变量覆盖/站内联动/设备反查/参数校验。厂商 HTTP
from __future__ import annotations from __future__ import annotations
import json import json
import logging
import httpx
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -112,99 +110,6 @@ def test_huawei_non_success_code_raises(monkeypatch) -> None:
vendor_push.send_notification("huawei", "bad-token", title="t", body="b") vendor_push.send_notification("huawei", "bad-token", title="t", body="b")
def test_vendor_http_logs_device_token_raw_response_and_elapsed(monkeypatch, caplog) -> None:
vendor_push._token_cache.clear()
def _fake_request(method, url, **kwargs): # noqa: ANN001
if url == vendor_push.settings.HUAWEI_PUSH_TOKEN_ENDPOINT:
return _Resp({"access_token": "raw-access-token", "expires_in": 3600})
return _Resp({"code": "80000000", "msg": "Success", "requestId": "vendor-request-1"})
monkeypatch.setattr(vendor_push.settings, "HUAWEI_PUSH_APP_ID", "10086001")
monkeypatch.setattr(vendor_push.settings, "HUAWEI_PUSH_APP_SECRET", "raw-app-secret")
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
with caplog.at_level(logging.INFO, logger=vendor_push.logger.name):
vendor_push.send_notification(
"huawei",
"raw-device-token",
title="需要进入日志的标题",
body="需要进入日志的正文",
extras={"type": "withdraw_success", "notificationId": "90001"},
)
messages = "\n".join(record.getMessage() for record in caplog.records)
assert "operation=authenticate" in messages
assert "operation=send_notification" in messages
assert "request=" in messages
assert "response=" in messages
assert "elapsed_ms=" in messages
assert "vendor-request-1" in messages
assert "withdraw_success" in messages
assert "raw-access-token" in messages
assert "raw-device-token" in messages
assert "需要进入日志的标题" in messages
assert "需要进入日志的正文" in messages
assert "<redacted" in messages
assert "raw-app-secret" not in messages
send_http_log = next(
record.getMessage()
for record in caplog.records
if "operation=send_notification" in record.getMessage()
and "vendor push http completed" in record.getMessage()
)
assert "raw-device-token" in send_http_log
assert "raw-access-token" not in send_http_log # 请求头中的厂商鉴权 token 仍脱敏
def test_vendor_http_network_error_logs_request_context(monkeypatch, caplog) -> None:
def _fake_request(*args, **kwargs): # noqa: ANN002, ANN003
raise httpx.ConnectError("connection refused")
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "raw-xiaomi-secret")
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
with (
caplog.at_level(logging.ERROR, logger=vendor_push.logger.name),
pytest.raises(vendor_push.VendorPushError, match="push http error"),
):
vendor_push.send_notification(
"xiaomi",
"raw-xiaomi-token",
title="title",
body="body",
extras={"type": "push_test"},
)
messages = "\n".join(record.getMessage() for record in caplog.records)
assert "outcome=network_error" in messages
assert "response=<no_response>" in messages
assert "operation=send_notification" in messages
assert "elapsed_ms=" in messages
assert "raw-xiaomi-secret" not in messages
assert "raw-xiaomi-token" in messages
@pytest.mark.parametrize(
("request_kwargs", "device_token"),
[
({"json": {"token": ["honor-device-token"]}}, "honor-device-token"),
({"json": {"message": {"token": ["huawei-device-token"]}}}, "huawei-device-token"),
({"json": {"regId": "vivo-device-token"}}, "vivo-device-token"),
({"data": {"registration_id": "xiaomi-device-token"}}, "xiaomi-device-token"),
(
{"data": {"message": '{"target_value":"oppo-device-token"}'}},
"oppo-device-token",
),
],
)
def test_all_vendor_device_token_fields_remain_visible_in_request_summary(
request_kwargs, device_token
) -> None:
summary = vendor_push._request_summary(request_kwargs)
assert device_token in summary
def test_vendor_aliases_normalize() -> None: def test_vendor_aliases_normalize() -> None:
assert vendor_push.normalize_vendor("华为") == "huawei" assert vendor_push.normalize_vendor("华为") == "huawei"
assert vendor_push.normalize_vendor("HMS") == "huawei" assert vendor_push.normalize_vendor("HMS") == "huawei"
+9 -3
View File
@@ -10,7 +10,11 @@ from fastapi.testclient import TestClient
from app.admin.main import admin_app from app.admin.main import admin_app
from app.admin.repositories import admin_user as admin_repo from app.admin.repositories import admin_user as admin_repo
from app.api.v1 import compare as compare_api from app.api.v1 import compare as compare_api
from app.core.config_schema import LIMIT_POLICY_GLOBAL_KEY from app.core.config_schema import (
RISK_COMPARE_DAILY_THRESHOLD_KEY,
RISK_ONECLICK_DAILY_THRESHOLD_KEY,
RISK_SMS_HOURLY_THRESHOLD_KEY,
)
from app.core.security import issue_token_pair from app.core.security import issue_token_pair
from app.db.session import SessionLocal from app.db.session import SessionLocal
from app.models.app_config import AppConfig from app.models.app_config import AppConfig
@@ -24,7 +28,9 @@ from app.repositories import user as user_repo
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _reset_risk_rule_config(): def _reset_risk_rule_config():
keys = ( keys = (
LIMIT_POLICY_GLOBAL_KEY, RISK_SMS_HOURLY_THRESHOLD_KEY,
RISK_ONECLICK_DAILY_THRESHOLD_KEY,
RISK_COMPARE_DAILY_THRESHOLD_KEY,
risk_repo.RISK_RESET_BASELINES_KEY, risk_repo.RISK_RESET_BASELINES_KEY,
) )
with SessionLocal() as db: with SessionLocal() as db:
@@ -527,7 +533,7 @@ def test_admin_can_edit_rules_and_current_window_is_reconciled() -> None:
"/admin/api/risk-monitor/rules", "/admin/api/risk-monitor/rules",
headers=headers, headers=headers,
json={ json={
"sms_hourly_threshold": 100_001, "sms_hourly_threshold": 21,
"oneclick_daily_threshold": 20, "oneclick_daily_threshold": 20,
"compare_daily_threshold": 100, "compare_daily_threshold": 100,
}, },

Some files were not shown because too many files have changed in this diff Show More