feat(admin): app_config 运营配置后台(rewards 等常量可后台配置)
- 新增 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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')
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
+1
-1
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
+59
-5
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"<AppConfig key={self.key}>"
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user