Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 216441a401 | |||
| 7ed942cb8c | |||
| e0874112a9 | |||
| 00404c8b45 | |||
| 964032d16f | |||
| 1f97b19d21 | |||
| 8938b951fb | |||
| 0cf4cc706b | |||
| a5f63cb53c | |||
| 65e9b422fe | |||
| 0350a140fe | |||
| 95a4c5c3cc | |||
| 822c2ca2a6 | |||
| 9354588ba2 | |||
| 6c624c3cea | |||
| 70c5c4cf08 | |||
| 804de02188 | |||
| 76cb981d84 | |||
| 20fa32e884 | |||
| 29aa6fbff8 |
@@ -30,7 +30,6 @@ 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
|
||||
@@ -110,5 +109,4 @@ 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)
|
||||
|
||||
@@ -31,7 +31,6 @@ PERMISSION_CATALOG: list[dict] = [
|
||||
{"group": "数据配置", "pages": [
|
||||
{"key": "config", "label": "系统配置"},
|
||||
{"key": "ad-revenue", "label": "广告配置"},
|
||||
{"key": "huawei-review", "label": "华为审核开关"},
|
||||
{"key": "users", "label": "用户管理"},
|
||||
]},
|
||||
{"group": "其他", "pages": [
|
||||
@@ -52,14 +51,13 @@ 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", "huawei-review",
|
||||
"cps", "device-liveness", "price-reports", "feedbacks",
|
||||
]},
|
||||
{"name": "finance", "label": "财务", "pages": [
|
||||
"dashboard", "ad-revenue-report", "cps", "withdraws",
|
||||
]},
|
||||
{"name": "tech", "label": "技术", "pages": [
|
||||
"dashboard", "device-liveness", "config", "ad-revenue", "huawei-review",
|
||||
"event-logs", "audit-logs",
|
||||
"dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs",
|
||||
]},
|
||||
]
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -109,23 +109,6 @@ def _date_range(date_from: date, date_to: date) -> list[date]:
|
||||
return [date_from + timedelta(days=i) for i in range(days + 1)]
|
||||
|
||||
|
||||
def _duration_percentile(sorted_values: list[int], q: float) -> int | None:
|
||||
"""Linear-interpolated percentile with the same half-up rounding as Math.round."""
|
||||
if not sorted_values:
|
||||
return None
|
||||
if len(sorted_values) == 1:
|
||||
return sorted_values[0]
|
||||
index = (len(sorted_values) - 1) * q
|
||||
lower = int(index)
|
||||
upper = min(lower + 1, len(sorted_values) - 1)
|
||||
fraction = Decimal(str(index - lower))
|
||||
value = (
|
||||
Decimal(sorted_values[lower]) * (Decimal(1) - fraction)
|
||||
+ Decimal(sorted_values[upper]) * fraction
|
||||
)
|
||||
return int(value.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _id_set(db: Session, stmt) -> set[int]:
|
||||
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
|
||||
|
||||
@@ -259,46 +242,16 @@ def dashboard_overview(
|
||||
ComparisonRecord.created_at >= start_local,
|
||||
ComparisonRecord.created_at < end_local,
|
||||
)
|
||||
period_comparison_stats = db.execute(
|
||||
select(
|
||||
func.count(ComparisonRecord.id),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(ComparisonRecord.status.in_(("success", "failed")), 1),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case((ComparisonRecord.status == "cancelled", 1), else_=0)
|
||||
),
|
||||
0,
|
||||
),
|
||||
func.coalesce(
|
||||
func.sum(case((ComparisonRecord.status == "success", 1), else_=0)),
|
||||
0,
|
||||
),
|
||||
func.coalesce(func.sum(ComparisonRecord.llm_cost_yuan), 0.0),
|
||||
).where(*period_comparison_conds)
|
||||
).one()
|
||||
period_comparison_total = int(period_comparison_stats[0])
|
||||
period_comparison_completed = int(period_comparison_stats[1])
|
||||
period_comparison_cancelled = int(period_comparison_stats[2])
|
||||
period_comparison_success = int(period_comparison_stats[3])
|
||||
period_comparison_token_cost_yuan = float(period_comparison_stats[4])
|
||||
period_comparison_success_denominator = (
|
||||
period_comparison_total - period_comparison_cancelled
|
||||
period_comparison_total = _count(ComparisonRecord, *period_comparison_conds)
|
||||
period_comparison_success = _count(
|
||||
ComparisonRecord,
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.status == "success",
|
||||
)
|
||||
period_comparison_success_rate = (
|
||||
round(
|
||||
period_comparison_success / period_comparison_success_denominator,
|
||||
4,
|
||||
)
|
||||
if period_comparison_success_denominator > 0
|
||||
else None
|
||||
round(period_comparison_success / period_comparison_total, 4)
|
||||
if period_comparison_total
|
||||
else 0.0
|
||||
)
|
||||
period_saved_positive_count = _count(
|
||||
ComparisonRecord,
|
||||
@@ -329,47 +282,6 @@ def dashboard_overview(
|
||||
if period_avg_duration_ms is not None
|
||||
else None
|
||||
)
|
||||
completed_duration_conds = (
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.status.in_(("success", "failed")),
|
||||
ComparisonRecord.total_ms.is_not(None),
|
||||
)
|
||||
if db.bind is not None and db.bind.dialect.name == "postgresql":
|
||||
period_median_duration_ms, period_p95_duration_ms = db.execute(
|
||||
select(
|
||||
func.percentile_cont(0.5).within_group(ComparisonRecord.total_ms),
|
||||
func.percentile_cont(0.95).within_group(ComparisonRecord.total_ms),
|
||||
).where(*completed_duration_conds)
|
||||
).one()
|
||||
period_median_duration_ms = (
|
||||
int(
|
||||
Decimal(str(period_median_duration_ms)).quantize(
|
||||
Decimal("1"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
)
|
||||
if period_median_duration_ms is not None
|
||||
else None
|
||||
)
|
||||
period_p95_duration_ms = (
|
||||
int(
|
||||
Decimal(str(period_p95_duration_ms)).quantize(
|
||||
Decimal("1"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
)
|
||||
if period_p95_duration_ms is not None
|
||||
else None
|
||||
)
|
||||
else:
|
||||
# SQLite 测试环境没有 percentile_cont;仅回退读取耗时单列,不加载完整记录。
|
||||
completed_durations = list(
|
||||
db.execute(
|
||||
select(ComparisonRecord.total_ms)
|
||||
.where(*completed_duration_conds)
|
||||
.order_by(ComparisonRecord.total_ms)
|
||||
).scalars()
|
||||
)
|
||||
period_median_duration_ms = _duration_percentile(completed_durations, 0.5)
|
||||
period_p95_duration_ms = _duration_percentile(completed_durations, 0.95)
|
||||
|
||||
ordered_exists = (
|
||||
select(SavingsRecord.id)
|
||||
@@ -710,16 +622,11 @@ def dashboard_overview(
|
||||
},
|
||||
"comparison": {
|
||||
"total": period_comparison_total,
|
||||
"completed": period_comparison_completed,
|
||||
"cancelled": period_comparison_cancelled,
|
||||
"success": period_comparison_success,
|
||||
"success_rate": period_comparison_success_rate,
|
||||
"ordered": period_ordered_count,
|
||||
"average_duration_ms": period_avg_duration_ms,
|
||||
"median_duration_ms": period_median_duration_ms,
|
||||
"p95_duration_ms": period_p95_duration_ms,
|
||||
"average_saved_cents": period_avg_saved_cents,
|
||||
"token_cost_total_yuan": period_comparison_token_cost_yuan,
|
||||
},
|
||||
"coupon": {
|
||||
"started": coupon_started,
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"""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)
|
||||
@@ -53,16 +53,11 @@ class DashboardPeriodUsers(BaseModel):
|
||||
|
||||
class DashboardPeriodComparison(BaseModel):
|
||||
total: int
|
||||
completed: int
|
||||
cancelled: int
|
||||
success: int
|
||||
success_rate: float | None = None
|
||||
success_rate: float
|
||||
ordered: int
|
||||
average_duration_ms: int | None = None
|
||||
median_duration_ms: int | None = None
|
||||
p95_duration_ms: int | None = None
|
||||
average_saved_cents: int | None = None
|
||||
token_cost_total_yuan: float = 0.0
|
||||
|
||||
|
||||
class DashboardPeriodCoupon(BaseModel):
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
"""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"]
|
||||
+1
-9
@@ -280,21 +280,13 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
|
||||
Bearer 鉴权,user_id 取自 JWT(不信 body)。best-effort:落库即 ok,客户端 fire-and-forget,
|
||||
丢一两条不影响业务(穿山甲后台报表是结算权威)。eCPM 与发奖(S2S)是两条独立流,不逐条关联。
|
||||
"""
|
||||
attributed_trace_id = crud_ecpm.attributable_trace_id(
|
||||
db, feed_scene=payload.feed_scene, trace_id=payload.trace_id
|
||||
)
|
||||
if payload.trace_id and attributed_trace_id is None:
|
||||
logger.info(
|
||||
"detach late coupon ad impression from failed trace user_id=%d trace=%s session=%s",
|
||||
user.id, payload.trace_id, payload.ad_session_id,
|
||||
)
|
||||
crud_ecpm.create_ecpm_record(
|
||||
db, user.id,
|
||||
ad_type=payload.ad_type, ecpm_raw=payload.ecpm,
|
||||
ad_session_id=payload.ad_session_id,
|
||||
adn=payload.adn, slot_id=payload.slot_id,
|
||||
feed_scene=payload.feed_scene,
|
||||
trace_id=attributed_trace_id,
|
||||
trace_id=payload.trace_id,
|
||||
app_env=payload.app_env, our_code_id=payload.our_code_id,
|
||||
)
|
||||
logger.info(
|
||||
|
||||
@@ -20,7 +20,6 @@ from app.schemas.platform import (
|
||||
AdConfigPublicOut,
|
||||
AppFlagsOut,
|
||||
AppVersionOut,
|
||||
HuaweiReviewOut,
|
||||
PlatformStatsOut,
|
||||
SavingsFeedItem,
|
||||
SavingsFeedOut,
|
||||
@@ -73,15 +72,6 @@ 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:
|
||||
"""客户端启动 / 手动检查更新时拉取。不鉴权:版本信息非敏感,且检查更新可能在登录前。
|
||||
|
||||
@@ -13,25 +13,6 @@ from sqlalchemy.orm import Session
|
||||
from app.core import rewards
|
||||
from app.core.rewards import cn_today
|
||||
from app.models.ad_ecpm import AdEcpmRecord
|
||||
from app.models.coupon_state import CouponSession
|
||||
|
||||
|
||||
def attributable_trace_id(
|
||||
db: Session, *, feed_scene: str | None, trace_id: str | None
|
||||
) -> str | None:
|
||||
"""返回广告展示允许归属的业务 trace。
|
||||
|
||||
领券任务可能在 Draw 广告异步加载完成前已经失败或被用户放弃。非完成终态先落库、
|
||||
广告回调后到时,收益仍需保留在总广告报表中,但不能再挂到该死亡领券明细,
|
||||
因此清空关联 trace。其它场景、找不到 session、进行中或已完成状态保持原值,
|
||||
由客户端生命周期修复负责主防线。
|
||||
"""
|
||||
if feed_scene != "coupon" or not trace_id:
|
||||
return trace_id
|
||||
session_status = db.execute(
|
||||
select(CouponSession.status).where(CouponSession.trace_id == trace_id)
|
||||
).scalar_one_or_none()
|
||||
return None if session_status in {"failed", "abandoned"} else trace_id
|
||||
|
||||
|
||||
def create_ecpm_record(
|
||||
|
||||
@@ -142,47 +142,3 @@ 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
|
||||
|
||||
@@ -46,17 +46,6 @@ 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 检查更新,不鉴权)。
|
||||
|
||||
|
||||
@@ -115,7 +115,6 @@
|
||||
| 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 群发短链落地**(**无前缀**,挂域名根;公网不鉴权) |||
|
||||
@@ -154,7 +153,6 @@
|
||||
| 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) |
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# 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` 即可一键收回退出按钮(审核通过后无需发版)。
|
||||
@@ -29,12 +29,3 @@
|
||||
## 注意
|
||||
- 不缓存:配置读频率低(每次福利操作读一次,主键查极快),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`(不给退出按钮) |
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"""ad_ecpm_record.trace_id 落库 + 按 trace 聚合广告收益(元)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.ad_ecpm import AdEcpmRecord
|
||||
from app.models.coupon_state import CouponSession
|
||||
from app.repositories import ad_ecpm as crud_ecpm
|
||||
|
||||
|
||||
@@ -58,39 +57,6 @@ def test_revenue_yuan_by_trace_empty() -> None:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_terminal_coupon_trace_is_not_attributable_to_late_impression() -> None:
|
||||
"""领券失败或被放弃后才到达的广告展示保留收益记录,但不再关联死亡 trace。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add_all([
|
||||
CouponSession(
|
||||
trace_id="failed-before-ad", device_id="d-late-ad", status="failed", app_env="prod",
|
||||
started_at=datetime(2020, 1, 2, tzinfo=UTC), started_date=date(2020, 1, 2),
|
||||
),
|
||||
CouponSession(
|
||||
trace_id="abandoned-before-ad", device_id="d-late-ad", status="abandoned", app_env="prod",
|
||||
started_at=datetime(2020, 1, 2, tzinfo=UTC), started_date=date(2020, 1, 2),
|
||||
),
|
||||
])
|
||||
db.flush()
|
||||
|
||||
assert crud_ecpm.attributable_trace_id(
|
||||
db, feed_scene="coupon", trace_id="failed-before-ad"
|
||||
) is None
|
||||
assert crud_ecpm.attributable_trace_id(
|
||||
db, feed_scene="coupon", trace_id="abandoned-before-ad"
|
||||
) is None
|
||||
assert crud_ecpm.attributable_trace_id(
|
||||
db, feed_scene="comparison", trace_id="failed-before-ad"
|
||||
) == "failed-before-ad"
|
||||
assert crud_ecpm.attributable_trace_id(
|
||||
db, feed_scene="coupon", trace_id="unknown-trace"
|
||||
) == "unknown-trace"
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_create_ecpm_record_persists_trace_id() -> None:
|
||||
"""create_ecpm_record 落 trace_id。"""
|
||||
db = SessionLocal()
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
"""Admin M2 读接口测试:大盘聚合 + 用户/流水/提现/反馈列表 + 鉴权拦截。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
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.comparison import ComparisonRecord
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.wallet import CashTransaction, WithdrawOrder
|
||||
from app.repositories import user as user_repo
|
||||
@@ -72,49 +69,6 @@ def test_dashboard_overview(admin_client: TestClient, admin_token: str) -> None:
|
||||
assert "jd_order_count" in data["cps"]
|
||||
|
||||
|
||||
def test_dashboard_period_comparison_is_aggregated_by_backend(
|
||||
admin_client: TestClient, admin_token: str
|
||||
) -> None:
|
||||
created_at = datetime(2037, 1, 15, 12)
|
||||
rows = [
|
||||
("dashboard-aggregate-success", "success", 101, 0.1),
|
||||
("dashboard-aggregate-failed", "failed", 200, 0.2),
|
||||
("dashboard-aggregate-cancelled", "cancelled", 300, 0.3),
|
||||
("dashboard-aggregate-running", "running", 400, 0.4),
|
||||
]
|
||||
db = SessionLocal()
|
||||
try:
|
||||
for trace_id, status, total_ms, llm_cost_yuan in rows:
|
||||
db.add(
|
||||
ComparisonRecord(
|
||||
trace_id=trace_id,
|
||||
status=status,
|
||||
total_ms=total_ms,
|
||||
llm_cost_yuan=llm_cost_yuan,
|
||||
created_at=created_at,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
response = admin_client.get(
|
||||
"/admin/api/stats/overview",
|
||||
params={"date_from": "2037-01-15", "date_to": "2037-01-15"},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
comparison = response.json()["period"]["comparison"]
|
||||
assert comparison["total"] == 4
|
||||
assert comparison["completed"] == 2
|
||||
assert comparison["cancelled"] == 1
|
||||
assert comparison["success"] == 1
|
||||
assert comparison["success_rate"] == 0.3333
|
||||
assert comparison["median_duration_ms"] == 151
|
||||
assert comparison["p95_duration_ms"] == 195
|
||||
assert comparison["token_cost_total_yuan"] == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> None:
|
||||
uid = _seed_user_with_data("13800000002")
|
||||
r = admin_client.get("/admin/api/users", headers=_auth(admin_token))
|
||||
|
||||
@@ -123,8 +123,7 @@ 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", "huawei-review",
|
||||
"event-logs", "audit-logs",
|
||||
"dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
"""华为审核开关: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
|
||||
Reference in New Issue
Block a user