From 88d2b57c7a8a602c17bae9bfe33c3a0939b48236 Mon Sep 17 00:00:00 2001 From: marco Date: Sat, 6 Jun 2026 02:29:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20app=5Fconfig=20=E8=BF=90?= =?UTF-8?q?=E8=90=A5=E9=85=8D=E7=BD=AE=E5=90=8E=E5=8F=B0(rewards=20?= =?UTF-8?q?=E7=AD=89=E5=B8=B8=E9=87=8F=E5=8F=AF=E5=90=8E=E5=8F=B0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 app_config 表 + model/repository/config_schema + admin config 路由与 schema + 测试 - rewards.py 及 ad_reward/signin/task/wallet/comparison_milestone repositories 接入可配置项;ad.py / admin/main.py 配套 - alembic merge(ebb6af5c0b56)合并本地 app_config(cfg1a2b3c4d5)与 incoming price_report/best_deeplink(b7e2c1a9f4d3)两 head Co-Authored-By: Claude Opus 4.8 --- alembic/versions/app_config_table.py | 33 +++++ ...merge_app_config_and_price_report_heads.py | 26 ++++ app/admin/main.py | 2 + app/admin/routers/config.py | 81 +++++++++++ app/admin/schemas/config.py | 22 +++ app/api/v1/ad.py | 2 +- app/core/config_schema.py | 63 +++++++++ app/core/rewards.py | 64 ++++++++- app/models/__init__.py | 1 + app/models/app_config.py | 32 +++++ app/repositories/ad_reward.py | 35 +++-- app/repositories/app_config.py | 65 +++++++++ app/repositories/comparison_milestone.py | 15 +- app/repositories/signin.py | 14 +- app/repositories/task.py | 13 +- app/repositories/wallet.py | 13 +- tests/test_admin_config.py | 129 ++++++++++++++++++ 17 files changed, 561 insertions(+), 49 deletions(-) create mode 100644 alembic/versions/app_config_table.py create mode 100644 alembic/versions/ebb6af5c0b56_merge_app_config_and_price_report_heads.py create mode 100644 app/admin/routers/config.py create mode 100644 app/admin/schemas/config.py create mode 100644 app/core/config_schema.py create mode 100644 app/models/app_config.py create mode 100644 app/repositories/app_config.py create mode 100644 tests/test_admin_config.py diff --git a/alembic/versions/app_config_table.py b/alembic/versions/app_config_table.py new file mode 100644 index 0000000..83c642b --- /dev/null +++ b/alembic/versions/app_config_table.py @@ -0,0 +1,33 @@ +"""app_config table (运营可配置项:rewards 常量等后台化) + +Revision ID: cfg1a2b3c4d5 +Revises: ad60a1b2c3d4 +Create Date: 2026-06-04 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'cfg1a2b3c4d5' +down_revision: Union[str, Sequence[str], None] = 'ad60a1b2c3d4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'app_config', + sa.Column('key', sa.String(length=64), nullable=False), + sa.Column('value', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=False), + sa.Column('updated_by_admin_id', sa.Integer(), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('key'), + ) + + +def downgrade() -> None: + op.drop_table('app_config') diff --git a/alembic/versions/ebb6af5c0b56_merge_app_config_and_price_report_heads.py b/alembic/versions/ebb6af5c0b56_merge_app_config_and_price_report_heads.py new file mode 100644 index 0000000..824f7b8 --- /dev/null +++ b/alembic/versions/ebb6af5c0b56_merge_app_config_and_price_report_heads.py @@ -0,0 +1,26 @@ +"""merge app_config and price_report heads + +Revision ID: ebb6af5c0b56 +Revises: cfg1a2b3c4d5, b7e2c1a9f4d3 +Create Date: 2026-06-06 02:29:13.475265 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ebb6af5c0b56' +down_revision: Union[str, Sequence[str], None] = ('cfg1a2b3c4d5', 'b7e2c1a9f4d3') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/app/admin/main.py b/app/admin/main.py index ed825f7..c5fda29 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -16,6 +16,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.admin.routers.admins import router as admins_router from app.admin.routers.audit import router as audit_router from app.admin.routers.auth import router as auth_router +from app.admin.routers.config import router as config_router from app.admin.routers.dashboard import router as dashboard_router from app.admin.routers.feedback import router as feedback_router from app.admin.routers.users import router as users_router @@ -78,3 +79,4 @@ admin_app.include_router(withdraw_router) admin_app.include_router(feedback_router) admin_app.include_router(admins_router) admin_app.include_router(audit_router) +admin_app.include_router(config_router) diff --git a/app/admin/routers/config.py b/app/admin/routers/config.py new file mode 100644 index 0000000..494da35 --- /dev/null +++ b/app/admin/routers/config.py @@ -0,0 +1,81 @@ +"""admin 运营配置:列出所有可配项 + 改某项(带审计 + 改值校验)。 + +配置项定义见 app.core.config_schema.CONFIG_DEFS;业务读配置 fallback 默认(见 app_config repo)。 +权限:operator / finance 都可改(运营调奖励、财务调额度),super 恒可。 +""" +from __future__ import annotations + +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException, Request + +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.config import ConfigItemOut, ConfigUpdateRequest +from app.core.config_schema import CONFIG_DEFS +from app.models.admin import AdminUser +from app.repositories import app_config + +router = APIRouter( + prefix="/admin/api/config", + tags=["admin-config"], + dependencies=[Depends(get_current_admin)], +) + + +def _validate(key: str, value: Any) -> None: + """按配置项 type 校验新值,不合法抛 ValueError(router 转 400)。""" + t = CONFIG_DEFS[key]["type"] + if t == "int": + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise ValueError("需为非负整数") + elif t == "int_list": + if not isinstance(value, list) or not value: + raise ValueError("需为非空整数列表") + if not all(isinstance(x, int) and not isinstance(x, bool) and x >= 0 for x in value): + raise ValueError("列表元素需为非负整数") + if key == "signin_rewards" and len(value) != 7: + raise ValueError("签到档位必须正好 7 个(对应 7 天循环)") + elif t == "dict_str_int": + if not isinstance(value, dict) or not all( + isinstance(k, str) and isinstance(v, int) and not isinstance(v, bool) + for k, v in value.items() + ): + raise ValueError("需为 {字符串: 整数} 映射") + + +def _item(db, key: str) -> ConfigItemOut: + for item in app_config.list_all(db): + if item["key"] == key: + return ConfigItemOut(**item) + raise HTTPException(status_code=404, detail="未知配置项") + + +@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值") +def list_config(db: AdminDb) -> list[ConfigItemOut]: + return [ConfigItemOut(**item) for item in app_config.list_all(db)] + + +@router.patch("/{key}", response_model=ConfigItemOut, summary="改某项配置(带审计)") +def update_config( + key: str, + body: ConfigUpdateRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator", "finance"))], + db: AdminDb, +) -> ConfigItemOut: + if key not in CONFIG_DEFS: + raise HTTPException(status_code=404, detail="未知配置项") + try: + _validate(key, body.value) + except ValueError as 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( + db, admin, action="config.set", target_type="config", target_id=key, + detail={"before": before, "after": body.value}, ip=get_client_ip(request), commit=False, + ) + db.commit() + return _item(db, key) diff --git a/app/admin/schemas/config.py b/app/admin/schemas/config.py new file mode 100644 index 0000000..01b56db --- /dev/null +++ b/app/admin/schemas/config.py @@ -0,0 +1,22 @@ +"""admin 运营配置 schemas。""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel + + +class ConfigItemOut(BaseModel): + key: str + label: str + group: str + type: str # int / int_list / dict_str_int + help: str | None = None + default: Any + value: Any + overridden: bool # 是否被运营覆盖过(false=用默认) + updated_at: str | None = None + + +class ConfigUpdateRequest(BaseModel): + value: Any diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index 51a0732..80c1d94 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -71,7 +71,7 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut: return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS) user_id = int(raw_user_id) - coin = rewards.resolve_ad_reward_coin(params.get("reward_amount")) + coin = rewards.resolve_ad_reward_coin(db, params.get("reward_amount")) raw = "&".join(f"{k}={v}" for k, v in sorted(params.items()) if k != "sign") try: rec = crud_ad.grant_ad_reward( diff --git a/app/core/config_schema.py b/app/core/config_schema.py new file mode 100644 index 0000000..9fefc6e --- /dev/null +++ b/app/core/config_schema.py @@ -0,0 +1,63 @@ +"""运营可配置项注册表:默认值(= 原 rewards 常量)+ 元信息(label/group/type)。 + +配置项的 single source of truth: +- 业务读配置时 fallback 到这里的 default(空 DB = 原行为不变)。 +- admin 配置页用它展示"有哪些可配项 + 当前值 + 默认值 + 说明"。 +新增可配项 = 这里加一条 + 业务处改用 app_config.get_value(db, key) 读。 +""" +from __future__ import annotations + +from typing import Any + +from app.core import rewards as r + +# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int +CONFIG_DEFS: dict[str, dict[str, Any]] = { + "signin_rewards": { + "default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位", + "group": "签到", "type": "int_list", + "help": "第 1~7 天每天签到发的金币;断签重置回第 1 天。长度需为 7。", + }, + "min_exchange_coin": { + "default": r.MIN_EXCHANGE_COIN, "label": "最低兑换金币", + "group": "钱包", "type": "int", "help": "金币兑现金的单次最低金币(10000 金币=1 元)。", + }, + "withdraw_min_cents": { + "default": r.WITHDRAW_MIN_CENTS, "label": "提现最低额(分)", + "group": "钱包", "type": "int", "help": "单次提现最低;微信商家转账最低 10 分(0.1 元)。", + }, + "withdraw_max_cents": { + "default": r.WITHDRAW_MAX_CENTS, "label": "提现最高额(分)", + "group": "钱包", "type": "int", + }, + "task_rewards": { + "default": dict(r.TASK_REWARDS), "label": "一次性任务奖励", + "group": "任务", "type": "dict_str_int", "help": "task_key → 金币。", + }, + "record_milestones": { + "default": list(r.RECORD_MILESTONES), "label": "比价里程碑金币档位", + "group": "里程碑", "type": "int_list", + "help": "累计成功比价第 1~N 档解锁发的金币。", + }, + "ad_reward_coin": { + "default": r.AD_REWARD_COIN, "label": "看广告单次金币", + "group": "看广告", "type": "int", + "help": "看完一个激励视频发的金币;须与穿山甲后台代码位'奖励数量'保持一致。", + }, + "ad_daily_limit": { + "default": r.DAILY_AD_REWARD_LIMIT, "label": "看广告每日上限(次)", + "group": "看广告", "type": "int", + }, + "ad_max_coin": { + "default": r.MAX_AD_REWARD_COIN, "label": "看广告单次金币上限", + "group": "看广告", "type": "int", "help": "夹紧穿山甲回调里异常的 reward_amount,防刷爆。", + }, + "ad_round_count": { + "default": r.VIDEO_ROUND_REQUIRED_COUNT, "label": "每轮看广告次数", + "group": "看广告", "type": "int", "help": "看完一轮触发冷却。", + }, + "ad_cooldown_sec": { + "default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "每轮冷却(秒)", + "group": "看广告", "type": "int", + }, +} diff --git a/app/core/rewards.py b/app/core/rewards.py index b2312d9..56b770b 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -92,17 +92,71 @@ VIDEO_ROUND_REQUIRED_COUNT: int = 3 VIDEO_ROUND_COOLDOWN_SECONDS: int = 600 -def resolve_ad_reward_coin(reward_amount: str | int | None) -> int: +def resolve_ad_reward_coin(db, reward_amount: str | int | None) -> int: # noqa: ANN001 """把穿山甲回调的 reward_amount(后台代码位配的"奖励数量")解析成本次发放金币。 - 缺失 / 非数字 / ≤0 → 回退 AD_REWARD_COIN;超过 MAX_AD_REWARD_COIN → 夹紧。 + 缺失 / 非数字 / ≤0 → 回退配置单次金币;超过配置单次上限 → 夹紧(均从 app_config 读)。 注:reward_amount 不参与穿山甲 sign(只签 trans_id),但伪造回调需先伪造合法 sign (要 SecurityKey),故能过验签的 reward_amount 即视为可信,这里只防后台配错。 """ + default_coin = get_ad_reward_coin(db) try: coin = int(reward_amount) # type: ignore[arg-type] except (TypeError, ValueError): - return AD_REWARD_COIN + return default_coin if coin <= 0: - return AD_REWARD_COIN - return min(coin, MAX_AD_REWARD_COIN) + return default_coin + return min(coin, get_ad_max_coin(db)) + + +# ===== 配置读取(运营后台可覆盖;app_config 表空时返回上面的常量默认 = 现状不变)===== +# 业务处用这些 getter(传 db)取值,而非直接用常量,这样 admin 改 app_config 即生效。 +# 函数内延迟 import,避免 rewards ← app_config ← config_schema ← rewards 的模块级循环。 + +def _cfg(db, key: str): # noqa: ANN001 + from app.repositories import app_config + return app_config.get_value(db, key) + + +def get_signin_rewards(db) -> list[int]: # noqa: ANN001 + return list(_cfg(db, "signin_rewards")) + + +def get_min_exchange_coin(db) -> int: # noqa: ANN001 + return int(_cfg(db, "min_exchange_coin")) + + +def get_withdraw_min_cents(db) -> int: # noqa: ANN001 + return int(_cfg(db, "withdraw_min_cents")) + + +def get_withdraw_max_cents(db) -> int: # noqa: ANN001 + return int(_cfg(db, "withdraw_max_cents")) + + +def get_task_rewards(db) -> dict[str, int]: # noqa: ANN001 + return dict(_cfg(db, "task_rewards")) + + +def get_record_milestones(db) -> list[int]: # noqa: ANN001 + return list(_cfg(db, "record_milestones")) + + +def get_ad_reward_coin(db) -> int: # noqa: ANN001 + return int(_cfg(db, "ad_reward_coin")) + + +def get_ad_daily_limit(db) -> int: # noqa: ANN001 + return int(_cfg(db, "ad_daily_limit")) + + +def get_ad_max_coin(db) -> int: # noqa: ANN001 + return int(_cfg(db, "ad_max_coin")) + + +def get_ad_round_count(db) -> int: # noqa: ANN001 + return int(_cfg(db, "ad_round_count")) + + +def get_ad_cooldown_sec(db) -> int: # noqa: ANN001 + return int(_cfg(db, "ad_cooldown_sec")) diff --git a/app/models/__init__.py b/app/models/__init__.py index 1a68fc9..15486de 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -2,6 +2,7 @@ from app.models.ad_ecpm import AdEcpmRecord # noqa: F401 from app.models.ad_reward import AdRewardRecord # noqa: F401 from app.models.admin import AdminAuditLog, AdminUser # noqa: F401 +from app.models.app_config import AppConfig # noqa: F401 from app.models.comparison import ComparisonRecord # noqa: F401 from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401 from app.models.feedback import Feedback # noqa: F401 diff --git a/app/models/app_config.py b/app/models/app_config.py new file mode 100644 index 0000000..ecb8072 --- /dev/null +++ b/app/models/app_config.py @@ -0,0 +1,32 @@ +"""运营可配置项(把 rewards.py 等硬编码规则挪到 DB,运营后台可改)。 + +key 是配置标识(见 app.core.config_schema.CONFIG_DEFS),value 用 JSON 存任意值(int/list/dict)。 +表里没有的 key,业务读配置时 fallback 到 CONFIG_DEFS 默认值(= 原 rewards 常量), +所以**空表 = 现有行为完全不变**。admin 改某项 → 写一行 → 业务下次读到新值(跨进程一致)。 +""" +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import JSON, DateTime, Integer, String, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + +_JSON = JSON().with_variant(JSONB(), "postgresql") + + +class AppConfig(Base): + __tablename__ = "app_config" + + key: Mapped[str] = mapped_column(String(64), primary_key=True) + value: Mapped[Any] = mapped_column(_JSON, nullable=False) + updated_by_admin_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/repositories/ad_reward.py b/app/repositories/ad_reward.py index 5e7388d..bf95d29 100644 --- a/app/repositories/ad_reward.py +++ b/app/repositories/ad_reward.py @@ -6,7 +6,7 @@ 3. 当日发奖次数 ≥ 上限 → 记一条 status='capped' 但不发金币。 发金币复用 `wallet.grant_coins`(biz_type='ad_reward', ref_id=trans_id),与发奖记录同事务, -保证"记一笔 + 加金币"原子化。 +保证"记一笔 + 加金币"原子化。单次金币 / 每日上限 / 每轮冷却 都从 app_config 读(运营后台可改)。 """ from __future__ import annotations @@ -16,15 +16,12 @@ from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from app.core import rewards from app.core.ad_cooldown import compute_cooldown -from app.core.rewards import ( - AD_REWARD_COIN, - DAILY_AD_REWARD_LIMIT, - cn_today, -) -from app.repositories import wallet as crud_wallet +from app.core.rewards import cn_today from app.models.ad_reward import AdRewardRecord from app.models.user import User +from app.repositories import wallet as crud_wallet class UnknownUserError(Exception): @@ -54,14 +51,14 @@ def grant_ad_reward( user_id: int, trans_id: str, *, - coin: int = AD_REWARD_COIN, + coin: int | None = None, reward_name: str | None = None, raw: str | None = None, ) -> AdRewardRecord: """看广告发奖(幂等 + 每日限额)。返回发奖记录(status=granted/capped)。 coin 为本次发放金币(由调用方按穿山甲回调 reward_amount 解析,见 - rewards.resolve_ad_reward_coin);默认 AD_REWARD_COIN 供 test-grant / 缺省场景用。 + rewards.resolve_ad_reward_coin);None → 读配置 get_ad_reward_coin(test-grant / 缺省场景)。 user_id 不存在抛 UnknownUserError(不建账户,防伪造 user_id 刷出脏数据)。 """ # #2 幂等:同 trans_id 已处理过 → 原样返回,不重复发 @@ -75,13 +72,16 @@ def grant_ad_reward( today = cn_today().isoformat() # #3 每日上限:超了记一条 capped(不发金币),让审计能看到"今天到顶了" - if _granted_today(db, user_id, today) >= DAILY_AD_REWARD_LIMIT: + if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db): rec = AdRewardRecord( trans_id=trans_id, user_id=user_id, coin=0, status="capped", reward_date=today, reward_name=reward_name, raw=raw, ) return _commit_record(db, rec, trans_id) + if coin is None: + coin = rewards.get_ad_reward_coin(db) + # 发金币 + 记一笔,同事务 crud_wallet.grant_coins( db, user_id, coin, @@ -130,16 +130,21 @@ def today_status( """客户端查"今日看广告发奖"进度。 返回 (今日已发次数, 每日上限, 单次金币, 本轮已看次数, 本轮冷却结束时间(UTC))。 - 本函数只**取数据**(今日 granted 的 created_at 倒序),把"本轮已看几次 + 冷却到几点"的 - **策略判断**委托给 [app.core.ad_cooldown.compute_cooldown](纯函数)——换冷却策略只动那个模块。 + 上限/单次金币/每轮次数/冷却秒 均从 app_config 读(运营后台可改);冷却策略判断委托 + [app.core.ad_cooldown.compute_cooldown](纯函数)。 """ today = cn_today().isoformat() granted_desc = _granted_times_today_desc(db, user_id, today) - state = compute_cooldown(granted_desc, datetime.now(timezone.utc)) + state = compute_cooldown( + granted_desc, + datetime.now(timezone.utc), + round_size=rewards.get_ad_round_count(db), + cooldown_seconds=rewards.get_ad_cooldown_sec(db), + ) return ( len(granted_desc), - DAILY_AD_REWARD_LIMIT, - AD_REWARD_COIN, + rewards.get_ad_daily_limit(db), + rewards.get_ad_reward_coin(db), state.round_count, state.cooldown_until, ) diff --git a/app/repositories/app_config.py b/app/repositories/app_config.py new file mode 100644 index 0000000..2cd8c2c --- /dev/null +++ b/app/repositories/app_config.py @@ -0,0 +1,65 @@ +"""运营配置读写。 + +get_value:业务读配置,DB 没有则 fallback 到 CONFIG_DEFS 默认(= 原 rewards 常量,空表行为不变)。 +不缓存:配置读频率低(每次福利操作读一次,key 主键查极快),admin 改了立即生效、跨进程一致。 +""" +from __future__ import annotations + +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.config_schema import CONFIG_DEFS +from app.models.app_config import AppConfig + + +def get_value(db: Session, key: str) -> Any: + """读配置值。DB 有用 DB,没有 fallback 到 CONFIG_DEFS 默认。未知 key 抛 KeyError。""" + row = db.get(AppConfig, key) + if row is not None: + return row.value + return CONFIG_DEFS[key]["default"] + + +def set_value( + db: Session, key: str, value: Any, *, admin_id: int, commit: bool = True +) -> AppConfig: + """写配置(admin 用)。未知 key 抛 KeyError。commit=False 让调用方与审计同事务。""" + if key not in CONFIG_DEFS: + raise KeyError(f"unknown config key: {key}") + row = db.get(AppConfig, key) + if row is None: + row = AppConfig(key=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 + + +def list_all(db: Session) -> list[dict]: + """所有可配项:default + 当前值(DB 有则用 DB) + 元信息。给 admin 配置页渲染。""" + overrides = {r.key: r for r in db.execute(select(AppConfig)).scalars().all()} + out: list[dict] = [] + for key, defn in CONFIG_DEFS.items(): + row = overrides.get(key) + out.append( + { + "key": key, + "label": defn["label"], + "group": defn["group"], + "type": defn["type"], + "help": defn.get("help"), + "default": defn["default"], + "value": row.value if row is not None else defn["default"], + "overridden": row is not None, + "updated_at": row.updated_at.isoformat() if row is not None else None, + } + ) + return out diff --git a/app/repositories/comparison_milestone.py b/app/repositories/comparison_milestone.py index 730c3ba..0c94b83 100644 --- a/app/repositories/comparison_milestone.py +++ b/app/repositories/comparison_milestone.py @@ -2,6 +2,7 @@ 进度 = comparison_record 里 status='success' 的条数(crud_compare.count_success)。 第 N 档在"成功次数 >= N"时解锁;每档领一次,写 comparison_milestone_claim 去重(标记已领)。 +档位金额来自 rewards.get_record_milestones(运营后台可改)。 ⚠️ 当前产品定暂不真发金币(后续整体删除该功能):claim 只写领取记录,不调 grant_coins、 不写 coin_transaction,coin_awarded 恒为 0,余额不变。仿一次性任务 (task.py)。 """ @@ -12,17 +13,14 @@ from dataclasses import dataclass from sqlalchemy import select from sqlalchemy.orm import Session -from app.core.rewards import ( - RECORD_MILESTONE_COUNT, - RECORD_MILESTONES, -) +from app.core import rewards from app.models.comparison_milestone import ComparisonMilestoneClaim from app.repositories import comparison as crud_compare from app.repositories import wallet as crud_wallet class UnknownMilestoneError(Exception): - """档位序号越界(不在 1..RECORD_MILESTONE_COUNT)。""" + """档位序号越界(不在 1..档位数)。""" class MilestoneLockedError(Exception): @@ -60,10 +58,11 @@ def get_status(db: Session, user_id: int) -> MilestoneStatus: """各档领取状态:已领=claimed,已解锁未领=active(可领),未解锁=locked。""" success_count = crud_compare.count_success(db, user_id) claimed = _claimed_set(db, user_id) + milestones_def = rewards.get_record_milestones(db) milestones: list[MilestoneState] = [] claimable = 0 - for m in range(1, RECORD_MILESTONE_COUNT + 1): + for m in range(1, len(milestones_def) + 1): if m in claimed: state = "claimed" elif success_count >= m: @@ -72,7 +71,7 @@ def get_status(db: Session, user_id: int) -> MilestoneStatus: else: state = "locked" milestones.append( - MilestoneState(milestone=m, coin=RECORD_MILESTONES[m - 1], state=state) + MilestoneState(milestone=m, coin=milestones_def[m - 1], state=state) ) return MilestoneStatus( @@ -87,7 +86,7 @@ def claim(db: Session, user_id: int, milestone: int) -> tuple[int, int]: 越界抛 UnknownMilestoneError;未解锁抛 MilestoneLockedError;重复领抛 AlreadyClaimedError。 """ - if milestone < 1 or milestone > RECORD_MILESTONE_COUNT: + if milestone < 1 or milestone > len(rewards.get_record_milestones(db)): raise UnknownMilestoneError existing = db.execute( diff --git a/app/repositories/signin.py b/app/repositories/signin.py index 14bda73..04f894a 100644 --- a/app/repositories/signin.py +++ b/app/repositories/signin.py @@ -3,7 +3,7 @@ 7 天循环规则: - 昨天签过 → 今天 cycle_day = 昨天 % 7 + 1,streak += 1(连续) - 否则(首签 / 断签) → cycle_day = 1,streak = 1(重置) -今天的 cycle_day 决定发多少金币(见 app.core.rewards.SIGNIN_REWARDS)。 +今天的 cycle_day 决定发多少金币(档位来自 rewards.get_signin_rewards,运营后台可改)。 """ from __future__ import annotations @@ -13,9 +13,10 @@ from datetime import timedelta from sqlalchemy import select from sqlalchemy.orm import Session -from app.core.rewards import SIGNIN_CYCLE_LEN, SIGNIN_REWARDS, cn_today, signin_reward -from app.repositories import wallet as crud_wallet +from app.core import rewards +from app.core.rewards import SIGNIN_CYCLE_LEN, cn_today from app.models.signin import SigninRecord +from app.repositories import wallet as crud_wallet class AlreadySignedError(Exception): @@ -58,6 +59,7 @@ def _next_cycle_day(last: SigninRecord | None, today) -> int: def get_status(db: Session, user_id: int) -> SigninStatus: today = cn_today() + rewards_list = rewards.get_signin_rewards(db) # 配置 or 默认(长度=SIGNIN_CYCLE_LEN) last = _latest_record(db, user_id) today_signed = last is not None and last.signin_date == today @@ -83,13 +85,13 @@ def get_status(db: Session, user_id: int) -> SigninStatus: status = "today" else: status = "locked" - steps.append(SigninStep(day=day, coin=SIGNIN_REWARDS[day - 1], status=status)) + steps.append(SigninStep(day=day, coin=rewards_list[day - 1], status=status)) return SigninStatus( today_signed=today_signed, consecutive_days=consecutive_days, today_cycle_day=today_cycle_day, - today_coin=signin_reward(today_cycle_day), + today_coin=rewards_list[today_cycle_day - 1], can_claim=not today_signed, steps=steps, ) @@ -109,7 +111,7 @@ def do_signin(db: Session, user_id: int) -> tuple[SigninRecord, int]: cycle_day = 1 streak = 1 - coin = signin_reward(cycle_day) + coin = rewards.get_signin_rewards(db)[cycle_day - 1] record = SigninRecord( user_id=user_id, signin_date=today, diff --git a/app/repositories/task.py b/app/repositories/task.py index 9012243..9b592b6 100644 --- a/app/repositories/task.py +++ b/app/repositories/task.py @@ -1,6 +1,7 @@ """一次性任务 CRUD:列表状态 + 领奖。 领奖原子化:写 user_task(唯一约束防重复)+ 发金币,同一事务 commit。 +任务列表 + 奖励来自 rewards.get_task_rewards(运营后台可改)。 """ from __future__ import annotations @@ -9,9 +10,9 @@ from dataclasses import dataclass from sqlalchemy import select from sqlalchemy.orm import Session -from app.core.rewards import TASK_REWARDS -from app.repositories import wallet as crud_wallet +from app.core import rewards from app.models.task import UserTask +from app.repositories import wallet as crud_wallet class UnknownTaskError(Exception): @@ -31,11 +32,12 @@ class TaskState: def list_tasks(db: Session, user_id: int) -> list[TaskState]: """返回所有已知一次性任务及其领取状态。""" + task_rewards = rewards.get_task_rewards(db) stmt = select(UserTask.task_key).where(UserTask.user_id == user_id) claimed_keys = set(db.execute(stmt).scalars().all()) return [ TaskState(task_key=key, coin=coin, claimed=key in claimed_keys) - for key, coin in TASK_REWARDS.items() + for key, coin in task_rewards.items() ] @@ -44,7 +46,8 @@ def claim_task(db: Session, user_id: int, task_key: str) -> tuple[int, int]: 未知任务抛 UnknownTaskError;重复领取抛 AlreadyClaimedError。 """ - if task_key not in TASK_REWARDS: + task_rewards = rewards.get_task_rewards(db) + if task_key not in task_rewards: raise UnknownTaskError existing = db.execute( @@ -55,7 +58,7 @@ def claim_task(db: Session, user_id: int, task_key: str) -> tuple[int, int]: if existing is not None: raise AlreadyClaimedError - coin = TASK_REWARDS[task_key] + coin = task_rewards[task_key] db.add( UserTask( user_id=user_id, diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index 97299b9..bd5bf7c 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -13,13 +13,8 @@ from datetime import datetime, timedelta, timezone from sqlalchemy import select, update from sqlalchemy.orm import Session -from app.core.rewards import ( - COIN_PER_CENT, - MIN_EXCHANGE_COIN, - WITHDRAW_MAX_CENTS, - WITHDRAW_MIN_CENTS, - coins_to_cents, -) +from app.core import rewards +from app.core.rewards import COIN_PER_CENT, coins_to_cents from app.integrations import wxpay from app.models.user import User from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder @@ -142,7 +137,7 @@ def exchange_coins_to_cash( 校验:金额 >= MIN_EXCHANGE_COIN 且为整分倍数(InvalidExchangeAmountError), 余额充足(InsufficientCoinError)。扣金币 + 加现金,两条流水同事务 commit。 """ - if coin_amount < MIN_EXCHANGE_COIN or coin_amount % COIN_PER_CENT != 0: + if coin_amount < rewards.get_min_exchange_coin(db) or coin_amount % COIN_PER_CENT != 0: raise InvalidExchangeAmountError acc = get_or_create_account(db, user_id, commit=False) @@ -339,7 +334,7 @@ def create_withdraw( #3 微信调用结果不明时先查单再决定,绝不盲目退款。 返回提现单(可能含 package_info 供 App 拉起确认页)。 """ - if amount_cents < WITHDRAW_MIN_CENTS or amount_cents > WITHDRAW_MAX_CENTS: + if amount_cents < rewards.get_withdraw_min_cents(db) or amount_cents > rewards.get_withdraw_max_cents(db): raise InvalidWithdrawAmountError user = db.get(User, user_id) diff --git a/tests/test_admin_config.py b/tests/test_admin_config.py new file mode 100644 index 0000000..65427b2 --- /dev/null +++ b/tests/test_admin_config.py @@ -0,0 +1,129 @@ +"""Admin M5 配置后台化测试:列出/改配置 + 改配**真生效** + 校验 + 审计。 + +autouse 清理每个用例后清空 app_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 +from app.repositories import ad_reward, signin +from app.repositories import user as user_repo + + +@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, "cfg_admin") is None: + admin_repo.create_admin( + db, username="cfg_admin", password="cfgpass12", role="super_admin" + ) + finally: + db.close() + c = TestClient(admin_app) + return c.post( + "/admin/api/auth/login", json={"username": "cfg_admin", "password": "cfgpass12"} + ).json()["access_token"] + + +@pytest.fixture(autouse=True) +def _clean_config() -> Iterator[None]: + """每个用例后清空 app_config,避免改配污染其他文件的福利测试(它们假设默认值)。""" + yield + db = SessionLocal() + try: + db.execute(delete(AppConfig)) + db.commit() + finally: + db.close() + + +def _auth(t: str) -> dict: + return {"Authorization": f"Bearer {t}"} + + +def _seed_user(phone: str) -> int: + db = SessionLocal() + try: + return user_repo.upsert_user_for_login(db, phone=phone, register_channel="sms").id + finally: + db.close() + + +def test_list_config(admin_client: TestClient, token: str) -> None: + r = admin_client.get("/admin/api/config", headers=_auth(token)) + assert r.status_code == 200, r.text + items = {i["key"]: i for i in r.json()} + assert "signin_rewards" in items and "ad_daily_limit" in items + assert items["signin_rewards"]["value"] == [10, 20, 30, 50, 80, 120, 200] + assert items["signin_rewards"]["overridden"] is False + + +def test_update_signin_takes_effect(admin_client: TestClient, token: str) -> None: + r = admin_client.patch( + "/admin/api/config/signin_rewards", + json={"value": [100, 200, 300, 400, 500, 600, 700]}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + assert r.json()["value"][0] == 100 and r.json()["overridden"] is True + + # 业务真的用上新值(配置后台化的核心验证) + uid = _seed_user("13912340001") + db = SessionLocal() + try: + st = signin.get_status(db, uid) + assert st.steps[0].coin == 100 + assert st.today_coin == 100 # 首签=第 1 天=100(新值) + logs = db.execute( + select(AdminAuditLog).where( + AdminAuditLog.action == "config.set", + AdminAuditLog.target_id == "signin_rewards", + ) + ).scalars().all() + assert len(logs) == 1 and logs[0].detail["after"][0] == 100 + finally: + db.close() + + +def test_update_ad_limit_takes_effect(admin_client: TestClient, token: str) -> None: + r = admin_client.patch( + "/admin/api/config/ad_daily_limit", json={"value": 5}, headers=_auth(token) + ) + assert r.status_code == 200, r.text + uid = _seed_user("13912340002") + db = SessionLocal() + try: + _used, limit, _coin, _rc, _cd = ad_reward.today_status(db, uid) + assert limit == 5 # 新配置生效 + finally: + db.close() + + +def test_config_validation(admin_client: TestClient, token: str) -> None: + # 签到档位长度≠7 + assert admin_client.patch( + "/admin/api/config/signin_rewards", json={"value": [1, 2, 3]}, headers=_auth(token) + ).status_code == 400 + # int 负数 + assert admin_client.patch( + "/admin/api/config/ad_daily_limit", json={"value": -5}, headers=_auth(token) + ).status_code == 400 + # 未知 key + assert admin_client.patch( + "/admin/api/config/nope", json={"value": 1}, headers=_auth(token) + ).status_code == 404