diff --git a/app/admin/main.py b/app/admin/main.py index 7345803..c869487 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -30,6 +30,7 @@ from app.admin.routers.analytics_health import router as analytics_health_router from app.admin.routers.event_logs import router as event_logs_router 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.huawei_review import router as huawei_review_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.price_report import router as price_report_router @@ -109,4 +110,5 @@ admin_app.include_router(cps_router) admin_app.include_router(coupon_data_router) admin_app.include_router(ad_audit_router) admin_app.include_router(ad_config_router) +admin_app.include_router(huawei_review_router) admin_app.include_router(ad_revenue_router) diff --git a/app/admin/permissions.py b/app/admin/permissions.py index ca0ee33..c0b4829 100644 --- a/app/admin/permissions.py +++ b/app/admin/permissions.py @@ -31,6 +31,7 @@ PERMISSION_CATALOG: list[dict] = [ {"group": "数据配置", "pages": [ {"key": "config", "label": "系统配置"}, {"key": "ad-revenue", "label": "广告配置"}, + {"key": "huawei-review", "label": "华为审核开关"}, {"key": "users", "label": "用户管理"}, ]}, {"group": "其他", "pages": [ @@ -51,13 +52,14 @@ BUILTIN_ROLES: list[dict] = [ {"name": SUPER_ADMIN_ROLE, "label": "管理员", "pages": []}, {"name": "operator", "label": "运营", "pages": [ "dashboard", "coupon-data", "ad-revenue-report", "comparison-records", - "cps", "device-liveness", "price-reports", "feedbacks", + "cps", "device-liveness", "price-reports", "feedbacks", "huawei-review", ]}, {"name": "finance", "label": "财务", "pages": [ "dashboard", "ad-revenue-report", "cps", "withdraws", ]}, {"name": "tech", "label": "技术", "pages": [ - "dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs", + "dashboard", "device-liveness", "config", "ad-revenue", "huawei-review", + "event-logs", "audit-logs", ]}, ] diff --git a/app/admin/routers/huawei_review.py b/app/admin/routers/huawei_review.py new file mode 100644 index 0000000..b23fb18 --- /dev/null +++ b/app/admin/routers/huawei_review.py @@ -0,0 +1,58 @@ +"""admin 华为审核开关:控制新手引导页(快速设置)在华为 ROM 客户端能否被用户关闭。 + +存在 app_config 表的 huawei_review dict(见 repositories/app_config.get_huawei_review/set_huawei_review)。 +客户端经 /api/v1/platform/huawei-review 拉取(且只有华为 ROM 机型会去拉)。权限 operator/tech + 审计。 +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.orm import Session + +from app.admin.audit import write_audit +from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role +from app.admin.schemas.huawei_review import HuaweiReviewOut, HuaweiReviewUpdate +from app.models.admin import AdminUser +from app.models.app_config import AppConfig +from app.repositories import app_config + +router = APIRouter( + prefix="/admin/api/huawei-review", + tags=["admin-huawei-review"], + dependencies=[Depends(get_current_admin)], +) + + +def _out(db: Session) -> HuaweiReviewOut: + row = db.get(AppConfig, app_config.HUAWEI_REVIEW_KEY) + return HuaweiReviewOut( + mode=app_config.get_huawei_review(db)["mode"], + updated_at=row.updated_at.isoformat() if row is not None else None, + ) + + +@router.get("", response_model=HuaweiReviewOut, summary="华为审核开关当前状态") +def get_huawei_review(db: AdminDb) -> HuaweiReviewOut: + return _out(db) + + +@router.patch("", response_model=HuaweiReviewOut, summary="切换华为审核开关(带审计)") +def update_huawei_review( + body: HuaweiReviewUpdate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator", "tech"))], + db: AdminDb, +) -> HuaweiReviewOut: + before = app_config.get_huawei_review(db)["mode"] + try: + app_config.set_huawei_review(db, body.mode, admin_id=admin.id, commit=False) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + write_audit( + db, admin, action="huawei_review.set", target_type="huawei_review", + target_id=app_config.HUAWEI_REVIEW_KEY, + detail={"before": before, "after": body.mode}, ip=get_client_ip(request), commit=False, + ) + db.commit() + return _out(db) diff --git a/app/admin/schemas/huawei_review.py b/app/admin/schemas/huawei_review.py new file mode 100644 index 0000000..198a321 --- /dev/null +++ b/app/admin/schemas/huawei_review.py @@ -0,0 +1,19 @@ +"""admin 华为审核开关 schemas(两态:default / review)。""" +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel + + +class HuaweiReviewOut(BaseModel): + """当前开关状态。updated_at 给后台展示「谁什么时候切的」提供时间锚点。""" + + mode: Literal["default", "review"] + updated_at: str | None = None # ISO 字符串;从未切过为 None + + +class HuaweiReviewUpdate(BaseModel): + """切换开关。整值覆盖,不做部分更新(就一个字段)。""" + + mode: Literal["default", "review"] diff --git a/app/api/v1/platform.py b/app/api/v1/platform.py index c7a5873..7ee5be4 100644 --- a/app/api/v1/platform.py +++ b/app/api/v1/platform.py @@ -20,6 +20,7 @@ from app.schemas.platform import ( AdConfigPublicOut, AppFlagsOut, AppVersionOut, + HuaweiReviewOut, PlatformStatsOut, SavingsFeedItem, SavingsFeedOut, @@ -72,6 +73,15 @@ def ad_config(db: DbSession) -> AdConfigPublicOut: ) +@router.get("/huawei-review", response_model=HuaweiReviewOut, summary="华为审核开关(不鉴权)") +def huawei_review(db: DbSession) -> HuaweiReviewOut: + """客户端进新手引导前拉一次,决定「快速设置」权限步左上角要不要给退出按钮。 + 不鉴权:引导页在登录之前就展示,此时必然没有 token。空库回退 default(=不给退出按钮,维持现状)。 + 只有华为 ROM 客户端会来拉(荣耀 MagicOS 不拉),故这里不做机型判断,由客户端自己 gate。""" + mode = app_config.get_huawei_review(db)["mode"] + return HuaweiReviewOut(mode=mode, onboarding_closable=(mode == "review")) + + @router.get("/app-version", response_model=AppVersionOut, summary="最新 App 版本(OTA 检查更新,不鉴权)") def app_version(db: DbSession) -> AppVersionOut: """客户端启动 / 手动检查更新时拉取。不鉴权:版本信息非敏感,且检查更新可能在登录前。 diff --git a/app/repositories/app_config.py b/app/repositories/app_config.py index 2cca9d0..3454ac9 100644 --- a/app/repositories/app_config.py +++ b/app/repositories/app_config.py @@ -142,3 +142,47 @@ def set_ad_config(db: Session, data: dict, *, admin_id: int, commit: bool = True else: db.flush() return row + + +# ── 华为审核开关(admin 可切,仅华为 ROM 客户端拉)──────────────────────────────── +# 同 ad_config:复用 AppConfig 表但不进 CONFIG_DEFS——它由「华为审核开关」专用页管理, +# 有自己的两态语义,混进通用系统配置页只会显示成一个没头没尾的 on/off。 +# default → 新手引导页(快速设置)强制展示,用户无法关闭(= 上线至今的现状) +# review → 快速设置的权限步左上角出现退出按钮,可直接进首页(过华为应用市场审核用) +# 空库 = default = 行为完全不变。客户端经 /api/v1/platform/huawei-review 拉取。 +HUAWEI_REVIEW_KEY = "huawei_review" +HUAWEI_REVIEW_MODES = ("default", "review") +_HUAWEI_REVIEW_DEFAULTS: dict[str, Any] = { + "mode": "default", +} + + +def get_huawei_review(db: Session) -> dict: + """读华为审核开关。DB 无 / 脏值一律回退 default(宁可不给退出按钮,也不误放开)。""" + row = db.get(AppConfig, HUAWEI_REVIEW_KEY) + merged = dict(_HUAWEI_REVIEW_DEFAULTS) + if row is not None and isinstance(row.value, dict): + merged.update(row.value) + if merged.get("mode") not in HUAWEI_REVIEW_MODES: + merged["mode"] = _HUAWEI_REVIEW_DEFAULTS["mode"] + return merged + + +def set_huawei_review(db: Session, mode: str, *, admin_id: int, commit: bool = True) -> AppConfig: + """admin 切换华为审核开关。非法 mode 抛 ValueError(路由转 400)。""" + if mode not in HUAWEI_REVIEW_MODES: + raise ValueError(f"invalid mode: {mode} (expected one of {list(HUAWEI_REVIEW_MODES)})") + row = db.get(AppConfig, HUAWEI_REVIEW_KEY) + value = {"mode": mode} + if row is None: + row = AppConfig(key=HUAWEI_REVIEW_KEY, value=value, updated_by_admin_id=admin_id) + db.add(row) + else: + row.value = value + row.updated_by_admin_id = admin_id + if commit: + db.commit() + db.refresh(row) + else: + db.flush() + return row diff --git a/app/schemas/platform.py b/app/schemas/platform.py index 13ca209..e56b035 100644 --- a/app/schemas/platform.py +++ b/app/schemas/platform.py @@ -46,6 +46,17 @@ class AdConfigPublicOut(BaseModel): withdrawal_ad_enabled: bool # 提现激励视频开关(关=客户端直接放行提现) +class HuaweiReviewOut(BaseModel): + """华为审核开关下发给客户端(不鉴权,引导页在登录前就要展示)。 + + 客户端只需读 onboarding_closable 决策;mode 仅供排查问题时看后台切成了哪态。 + 只有华为 ROM(HarmonyOS/EMUI,不含荣耀 MagicOS)的客户端才会来拉这个端点。 + """ + + mode: str = "default" # default | review + onboarding_closable: bool = False # 快速设置权限步是否允许用户退出(mode == review) + + class AppVersionOut(BaseModel): """最新 App 版本信息(OTA 检查更新,不鉴权)。 diff --git a/docs/api/README.md b/docs/api/README.md index 4ade4e6..7d29d6f 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -115,6 +115,7 @@ | 40a | `GET /api/v1/platform/flags` | 无 | [详情](./platform/platform-flags.md)(客户端运营 feature flag,比价/领券期广告开关等,拉取后缓存) | | 40b | `GET /api/v1/platform/ad-config` | 无 | [详情](./platform/platform-ad-config.md)(客户端拉广告配置:穿山甲 app_id+各位ID+各场景开关;不含验签密钥) | | 40c | `GET /api/v1/platform/app-version` | 无 | [详情](./platform/platform-app-version.md)(最新 App 版本,OTA 检查更新;与本机 versionCode 比) | +| 40d | `GET /api/v1/platform/huawei-review` | 无 | [详情](./platform/platform-huawei-review.md)(华为审核开关:快速设置权限步能否被用户关闭;仅华为 ROM 客户端拉) | | **微信支付回调**(前缀 `/api/v1/wxpay`) ||| | W1 | `POST /api/v1/wxpay/transfer-auth-notify` | 无 | 免确认收款授权结果通知(一期 stub:仅应答 200 不验签不改账,授权状态靠主动查询兜底)(无单独文档) | | **CPS 群发短链落地**(**无前缀**,挂域名根;公网不鉴权) ||| @@ -153,6 +154,7 @@ | A12 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin/ad/admin-ad-revenue-report.md)(广告收益报表:分页/场景/`app_env` 筛 + **DAU/ARPU** #120;真实收益侧接穿山甲日表 #92) | | A13 | `GET / PATCH /admin/api/ad-config` | operator/finance | 广告配置(穿山甲 ID/验签密钥/各场景开关;C 端只读版见 40b)(无单独文档,见 `app/admin/routers/ad_config.py`) | | A14 | `GET /admin/api/config`、`PATCH /config/{key}` | operator/finance | 运营可配置项([app_config](../database/app_config.md):奖励常量/提现地板价等;#117 修系统配置下发)(无单独文档,见 `app/admin/routers/config.py`) | +| A16 | `GET / PATCH /admin/api/huawei-review` | operator/tech | 华为审核开关(快速设置权限步能否被用户关闭,落 `app_config.huawei_review`;C 端只读版见 40d)(无单独文档,见 `app/admin/routers/huawei_review.py`) | | **A·管理员与角色**(super_admin):`GET`/`POST` `/admins`、`PATCH`/`DELETE` `/admins/{id}`(#126 删除+`pages_override`)、`GET`/`POST` `/roles`、`GET /roles/catalog`、`PATCH`/`DELETE` `/roles/{id}`(#117/#126 自定义角色) ||| [列表](./admin/admins/admin-admins-list.md) / [建](./admin/admins/admin-admin-create.md) / [改+删](./admin/admins/admin-admin-update.md) / [角色](./admin/admin-roles.md) | | A15 | `GET /admin/api/audit-logs` | admin | [详情](./admin/admin-audit-logs.md) | | **A·CPS 运营台**:群/活动 CRUD、`POST /referral-links`、`POST /orders/reconcile`(美团+京东 #90)、`GET /orders`、`/stats`、群 `timeseries`/`daily`/`wx-users`/`day-users`(#79) ||| [详情](./admin/admin-cps.md) | diff --git a/docs/api/platform/platform-huawei-review.md b/docs/api/platform/platform-huawei-review.md new file mode 100644 index 0000000..b2fa0eb --- /dev/null +++ b/docs/api/platform/platform-huawei-review.md @@ -0,0 +1,36 @@ +# GET /api/v1/platform/huawei-review — 华为审核开关 + +> 所属:Platform 组(前缀 `/api/v1/platform`) | 鉴权:无 | [← 返回 API 索引](../README.md) + +华为应用市场审核要求:新手引导的**「快速设置」权限步必须可被用户关闭**(引导视频页不在要求内)。本端点把运营后台配的开关下发给客户端,决定该步左上角是否出现退出按钮。 + +**不鉴权**:引导页在登录之前就展示,此时客户端必然没有 token。 + +值来自 `app_config` 表的 `huawei_review` 行(admin 页 `GET / PATCH /admin/api/huawei-review` 可改),空库回退 `default`。 + +## 入参 + +无。 + +## 出参 + +响应 `200`:`HuaweiReviewOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `mode` | string | `default`(强制展示,不可关闭 = 上线至今的现状)/ `review`(可关闭,过审用)。仅供排查时看后台切成了哪态 | +| `onboarding_closable` | bool | 快速设置权限步是否允许用户退出(= `mode == "review"`)。**客户端只读这一个字段决策** | + +Mock 出参: +```json +{ + "mode": "review", + "onboarding_closable": true +} +``` + +## 说明 +- **只有华为 ROM 客户端会来拉**(HarmonyOS / EMUI;荣耀 MagicOS 不拉)。机型判断在客户端做(`OemDetector`),服务端不看 UA,也就不用维护机型名单。 +- 客户端在进新手引导前拉一次并本地缓存;请求失败/超时用上次缓存值,从未拉到过则按 `onboarding_closable=false`(宁可不给退出按钮,也不误放开)。 +- 脏值兜底:DB 里 `mode` 不在枚举内时服务端一律回退 `default`。 +- 切回 `default` 即可一键收回退出按钮(审核通过后无需发版)。 diff --git a/docs/database/app_config.md b/docs/database/app_config.md index 95e7aac..1f3a32d 100644 --- a/docs/database/app_config.md +++ b/docs/database/app_config.md @@ -29,3 +29,12 @@ ## 注意 - 不缓存:配置读频率低(每次福利操作读一次,主键查极快),admin 改了立即生效、跨进程一致(多 worker 也对)。 - 新增可配项 = 在 `CONFIG_DEFS` 加一条 + 业务处改用 `app_config.get_value(db, key)` 读;不需要建迁移(行是动态插的,表结构不变)。 + +## 专用 key(借表不进 CONFIG_DEFS) +有自己的语义与专用管理页的配置,复用本表但**不注册进 `CONFIG_DEFS`**——混进通用「系统配置」页只会显示成一个没头没尾的 on/off。它们各有一对 `get_*` / `set_*` 函数(仍在 `repositories/app_config.py`),`value` 存 dict,空行回退各自的模块内默认值。 + +| key | 管理页 / admin 端点 | C 端读取 | 说明 | +|---|---|---|---| +| `ad_config` | `GET/PATCH /admin/api/ad-config` | `GET /api/v1/platform/ad-config`(去密钥) | 穿山甲 app_id / 各代码位 / 各场景开关 | +| `app_version` | 内部写入(`X-Internal-Secret`) | `GET /api/v1/platform/app-version` | OTA 最新版本信息 | +| `huawei_review` | `GET/PATCH /admin/api/huawei-review` | `GET /api/v1/platform/huawei-review` | 华为审核开关:`{"mode": "default"|"review"}`,决定新手引导「快速设置」权限步能否被用户关闭。脏值/空行一律回退 `default`(不给退出按钮) | diff --git a/tests/test_admin_roles.py b/tests/test_admin_roles.py index 85ab010..fc1603f 100644 --- a/tests/test_admin_roles.py +++ b/tests/test_admin_roles.py @@ -123,7 +123,8 @@ def test_builtin_roles_labels_and_pages(admin_client, super_token) -> None: # 页集对齐 Prototypes/dashboard/permissions.md 的 ROLES assert set(roles["finance"]["pages"]) == {"dashboard", "ad-revenue-report", "cps", "withdraws"} assert set(roles["tech"]["pages"]) == { - "dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs", + "dashboard", "device-liveness", "config", "ad-revenue", "huawei-review", + "event-logs", "audit-logs", } diff --git a/tests/test_huawei_review.py b/tests/test_huawei_review.py new file mode 100644 index 0000000..76c97b2 --- /dev/null +++ b/tests/test_huawei_review.py @@ -0,0 +1,130 @@ +"""华为审核开关:admin 读写 + 客户端公开端点 + 审计 + 空库回退。 + +背景:华为应用市场审核要求新手引导的「快速设置」权限步必须可被用户关闭。开关切到 review 后 +客户端(仅华为 ROM)在该步左上角显示退出按钮。默认 default = 上线至今的现状(不可关闭)。 + +autouse 清理每个用例后清空 app_config,避免污染其他文件里假设默认值的用例(同 test_admin_config)。 +""" +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import delete, select + +from app.admin.main import admin_app +from app.admin.repositories import admin_user as admin_repo +from app.db.session import SessionLocal +from app.models.admin import AdminAuditLog +from app.models.app_config import AppConfig + + +@pytest.fixture() +def admin_client() -> TestClient: + return TestClient(admin_app) + + +@pytest.fixture() +def token() -> str: + db = SessionLocal() + try: + if admin_repo.get_by_username(db, "hw_admin") is None: + admin_repo.create_admin( + db, username="hw_admin", password="hwpass123", role="super_admin" + ) + finally: + db.close() + c = TestClient(admin_app) + return c.post( + "/admin/api/auth/login", json={"username": "hw_admin", "password": "hwpass123"} + ).json()["access_token"] + + +@pytest.fixture(autouse=True) +def _clean_config() -> Iterator[None]: + yield + db = SessionLocal() + try: + db.execute(delete(AppConfig)) + # 审计行同样要清:同库跨用例累积会让 test_switch_writes_audit 数到前面用例写的行 + # (按 action 限定,不碰其他模块可能已写入的审计)。 + db.execute(delete(AdminAuditLog).where(AdminAuditLog.action == "huawei_review.set")) + db.commit() + finally: + db.close() + + +def _auth(t: str) -> dict: + return {"Authorization": f"Bearer {t}"} + + +def test_public_default_not_closable(client: TestClient) -> None: + """空库(从未切过)→ 客户端拿到 default / 不可关闭 = 维持现状;且不需要鉴权。""" + r = client.get("/api/v1/platform/huawei-review") + assert r.status_code == 200, r.text + assert r.json() == {"mode": "default", "onboarding_closable": False} + + +def test_admin_get_default(admin_client: TestClient, token: str) -> None: + r = admin_client.get("/admin/api/huawei-review", headers=_auth(token)) + assert r.status_code == 200, r.text + body = r.json() + assert body["mode"] == "default" + assert body["updated_at"] is None # 从未切过 + + +def test_switch_to_review_takes_effect( + admin_client: TestClient, client: TestClient, token: str +) -> None: + """admin 切 review → 公开端点立刻下发可关闭(跨进程/跨 app 一致,因为落在 DB 而非内存)。""" + r = admin_client.patch( + "/admin/api/huawei-review", json={"mode": "review"}, headers=_auth(token) + ) + assert r.status_code == 200, r.text + assert r.json()["mode"] == "review" + assert r.json()["updated_at"] is not None + + pub = client.get("/api/v1/platform/huawei-review").json() + assert pub == {"mode": "review", "onboarding_closable": True} + + # 切回 default → 客户端恢复不可关闭(审核过了要能一键收回) + admin_client.patch( + "/admin/api/huawei-review", json={"mode": "default"}, headers=_auth(token) + ) + assert client.get("/api/v1/platform/huawei-review").json()["onboarding_closable"] is False + + +def test_switch_writes_audit(admin_client: TestClient, token: str) -> None: + admin_client.patch( + "/admin/api/huawei-review", json={"mode": "review"}, headers=_auth(token) + ) + db = SessionLocal() + try: + logs = db.execute( + select(AdminAuditLog).where(AdminAuditLog.action == "huawei_review.set") + ).scalars().all() + assert len(logs) == 1 + assert logs[0].detail == {"before": "default", "after": "review"} + finally: + db.close() + + +def test_invalid_mode_rejected(admin_client: TestClient, token: str) -> None: + """mode 是 Literal,非法值由 FastAPI 校验挡在 422(不会落库)。""" + r = admin_client.patch( + "/admin/api/huawei-review", json={"mode": "nope"}, headers=_auth(token) + ) + assert r.status_code == 422, r.text + db = SessionLocal() + try: + assert db.get(AppConfig, "huawei_review") is None + finally: + db.close() + + +def test_requires_admin_auth(admin_client: TestClient) -> None: + assert admin_client.get("/admin/api/huawei-review").status_code == 401 + assert admin_client.patch( + "/admin/api/huawei-review", json={"mode": "review"} + ).status_code == 401