Compare commits

...

1 Commits

Author SHA1 Message Date
marco 88d2b57c7a 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>
2026-06-06 02:29:49 +08:00
17 changed files with 561 additions and 49 deletions
+33
View File
@@ -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
+2
View File
@@ -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)
+81
View File
@@ -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)
+22
View File
@@ -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
View File
@@ -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(
+63
View File
@@ -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
View File
@@ -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"))
+1
View File
@@ -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
+32
View File
@@ -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}>"
+20 -15
View File
@@ -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,
)
+65
View File
@@ -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
+7 -8
View File
@@ -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(
+8 -6
View File
@@ -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,
+8 -5
View File
@@ -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,
+4 -9
View File
@@ -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)
+129
View File
@@ -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