Merge remote-tracking branch 'origin/main' into feature/coupon-claim-integration

# Conflicts:
#	docs/api/README.md
This commit is contained in:
2026-05-28 10:33:35 +08:00
83 changed files with 5089 additions and 134 deletions
+131
View File
@@ -0,0 +1,131 @@
"""看激励视频发奖 endpoint。
路由前缀 `/api/v1/ad`:
GET /pangle-callback 穿山甲 S2S 发奖回调(**无 JWT,靠验签**),穿山甲服务器调
GET /reward-status 客户端查今日看广告发奖进度(Bearer)
发奖走服务端:激励视频播完穿山甲回调本接口,验签通过后幂等发金币。客户端只负责
看完后刷新余额,不参与发奖,被破解也刷不到钱。
"""
from __future__ import annotations
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status
from app.api.deps import CurrentUser, DbSession
from app.core import rewards
from app.core.config import settings
from app.integrations import pangle
from app.core.ratelimit import rate_limit
from app.repositories import ad_reward as crud_ad
from app.schemas.ad import AdRewardStatusOut, PangleCallbackOut, TestGrantOut
logger = logging.getLogger("shagua.ad")
router = APIRouter(prefix="/api/v1/ad", tags=["ad"])
# GroMore 回调 is_verify=false 时透传给客户端 SDK 的错误码(自定义,仅用于排查/客户端提示)
REASON_OK = 0
REASON_BAD_PARAMS = 1 # 验签过但缺 trans_id / user_id 非数字
REASON_UNKNOWN_USER = 2 # user_id 不存在(可能伪造)
@router.get(
"/pangle-callback",
response_model=PangleCallbackOut,
summary="穿山甲激励视频发奖回调(S2S,验签)",
dependencies=[Depends(rate_limit(300, 60, "pangle-callback"))],
)
def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
"""穿山甲 GroMore 在激励视频播完后回调,带 user_id / trans_id / reward_amount / sign 等 query 参数。
流程:开关/密钥就绪 → 验签(SHA256(m-key:trans_id)) → 解析 user_id/reward_amount → 幂等发金币。
验签失败 403(留给真请求重试);参数缺/坏或 user 不存在 → is_verify=false + reason(不发,不重试)。
granted / capped → is_verify=true + reason=0。
"""
if not settings.pangle_callback_configured:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
)
params = dict(request.query_params)
if not pangle.verify_callback_sign(params, settings.PANGLE_REWARD_SECRET):
logger.warning("pangle callback bad sign trans_id=%s", params.get("trans_id"))
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="bad sign")
trans_id = params.get("trans_id") or ""
raw_user_id = params.get("user_id") or ""
if not trans_id or not raw_user_id.isdigit():
# 验签过了但参数缺/坏:不发奖(is_verify=false),带错误码
logger.warning("pangle callback bad params: %s", params)
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"))
raw = "&".join(f"{k}={v}" for k, v in sorted(params.items()) if k != "sign")
try:
rec = crud_ad.grant_ad_reward(
db, user_id, trans_id, coin=coin,
reward_name=params.get("reward_name"), raw=raw[:1024],
)
except crud_ad.UnknownUserError:
logger.warning("pangle callback unknown user_id=%d trans_id=%s", user_id, trans_id)
return PangleCallbackOut(is_verify=False, reason=REASON_UNKNOWN_USER)
logger.info(
"ad reward user_id=%d trans_id=%s status=%s coin=%d", user_id, trans_id, rec.status, rec.coin
)
# granted / capped 均算"已处理":is_verify=true 不让穿山甲重试(capped 只是没加币)
return PangleCallbackOut(is_verify=True, reason=REASON_OK)
@router.get("/reward-status", response_model=AdRewardStatusOut, summary="今日看广告发奖进度")
def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut:
used, limit, coin_per = crud_ad.today_status(db, user.id)
return AdRewardStatusOut(
used_today=used,
daily_limit=limit,
remaining=max(0, limit - used),
coin_per_ad=coin_per,
)
@router.post(
"/test-grant",
response_model=TestGrantOut,
summary="[仅本地联调]模拟穿山甲回调发奖",
dependencies=[Depends(rate_limit(60, 60, "ad-test-grant"))],
)
def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut:
"""⚠️ 仅本地联调用:没部署公网、穿山甲 S2S 回调打不到本地时,客户端(debug 包)看完广告后
调这个接口,直接走与回调相同的发奖逻辑(幂等 + 每日上限),验证"看广告→金币到账"全链路。
必须 settings.AD_REWARD_TEST_GRANT_ENABLED=true 才开放(默认 False),否则 404 当不存在。
它让已登录客户端能自助发奖 = 绕过反作弊,**生产必须关闭**。
"""
if not settings.AD_REWARD_TEST_GRANT_ENABLED:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not found")
# 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限)
trans_id = f"test-{user.id}-{uuid.uuid4().hex}"
try:
rec = crud_ad.grant_ad_reward(
db, user.id, trans_id, reward_name="测试发奖", raw="client debug test-grant"
)
except crud_ad.UnknownUserError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
used, limit, coin_per = crud_ad.today_status(db, user.id)
logger.info("ad TEST grant user_id=%d status=%s coin=%d", user.id, rec.status, rec.coin)
return TestGrantOut(
granted=(rec.status == "granted"),
status=rec.status,
coin=rec.coin,
used_today=used,
daily_limit=limit,
remaining=max(0, limit - used),
coin_per_ad=coin_per,
)
+59
View File
@@ -0,0 +1,59 @@
"""省钱 endpoint(profile 的「累计帮你省了」「省钱战绩」「省钱明细」)。
路由前缀 `/api/v1/savings`:
GET /summary 累计省下 + 订单数 + 平均每单省
GET /battle 本周已省 + 超过百分比 + 连续省钱天数
GET /records 省钱明细(游标分页)
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Query
from app.api.deps import CurrentUser, DbSession
from app.repositories import savings as crud_savings
from app.schemas.welfare import (
SavingsBattleOut,
SavingsRecordOut,
SavingsRecordPage,
SavingsSummaryOut,
)
logger = logging.getLogger("shagua.savings")
router = APIRouter(prefix="/api/v1/savings", tags=["savings"])
@router.get("/summary", response_model=SavingsSummaryOut, summary="累计帮你省了")
def summary(user: CurrentUser, db: DbSession) -> SavingsSummaryOut:
s = crud_savings.get_summary(db, user.id)
return SavingsSummaryOut(
total_saved_cents=s.total_saved_cents,
order_count=s.order_count,
avg_saved_cents=s.avg_saved_cents,
)
@router.get("/battle", response_model=SavingsBattleOut, summary="省钱战绩")
def battle(user: CurrentUser, db: DbSession) -> SavingsBattleOut:
b = crud_savings.get_battle(db, user.id)
return SavingsBattleOut(
week_saved_cents=b.week_saved_cents,
beat_percent=b.beat_percent,
streak_days=b.streak_days,
)
@router.get("/records", response_model=SavingsRecordPage, summary="省钱明细(游标分页)")
def records(
user: CurrentUser,
db: DbSession,
limit: int = Query(20, ge=1, le=100),
cursor: int | None = Query(None, description="上一页末条 id"),
) -> SavingsRecordPage:
items, next_cursor = crud_savings.list_records(db, user.id, limit=limit, cursor=cursor)
return SavingsRecordPage(
items=[SavingsRecordOut.model_validate(it) for it in items],
next_cursor=next_cursor,
)
+44
View File
@@ -0,0 +1,44 @@
"""签到 endpoint。
路由前缀 `/api/v1/signin`:
GET /status 今日签到状态 + 7 天档位
POST / 执行今日签到
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, status
from app.api.deps import CurrentUser, DbSession
from app.repositories import signin as crud_signin
from app.schemas.welfare import SigninResultOut, SigninStatusOut
logger = logging.getLogger("shagua.signin")
router = APIRouter(prefix="/api/v1/signin", tags=["signin"])
@router.get("/status", response_model=SigninStatusOut, summary="今日签到状态")
def signin_status(user: CurrentUser, db: DbSession) -> SigninStatusOut:
st = crud_signin.get_status(db, user.id)
return SigninStatusOut.model_validate(st, from_attributes=True)
@router.post("", response_model=SigninResultOut, summary="执行今日签到")
def do_signin(user: CurrentUser, db: DbSession) -> SigninResultOut:
try:
record, balance = crud_signin.do_signin(db, user.id)
except crud_signin.AlreadySignedError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="already signed today") from e
logger.info(
"signin ok user_id=%d day=%d streak=%d coin=%d",
user.id, record.cycle_day, record.streak, record.coin_awarded,
)
return SigninResultOut(
coin_awarded=record.coin_awarded,
cycle_day=record.cycle_day,
streak=record.streak,
coin_balance=balance,
)
+47
View File
@@ -0,0 +1,47 @@
"""一次性任务 endpoint。
路由前缀 `/api/v1/tasks`:
GET / 任务及领取状态(如"打开消息提醒")
POST /{task_key}/claim 领取任务奖励
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, status
from app.api.deps import CurrentUser, DbSession
from app.repositories import task as crud_task
from app.schemas.welfare import TaskClaimResultOut, TaskListOut, TaskOut
logger = logging.getLogger("shagua.tasks")
router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"])
@router.get("", response_model=TaskListOut, summary="任务及领取状态")
def list_tasks(user: CurrentUser, db: DbSession) -> TaskListOut:
states = crud_task.list_tasks(db, user.id)
return TaskListOut(
items=[
TaskOut(task_key=s.task_key, coin=s.coin, claimed=s.claimed)
for s in states
]
)
@router.post(
"/{task_key}/claim",
response_model=TaskClaimResultOut,
summary="领取任务奖励",
)
def claim_task(task_key: str, user: CurrentUser, db: DbSession) -> TaskClaimResultOut:
try:
coin, balance = crud_task.claim_task(db, user.id, task_key)
except crud_task.UnknownTaskError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="unknown task") from e
except crud_task.AlreadyClaimedError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="task already claimed") from e
logger.info("task claimed user_id=%d key=%s coin=%d", user.id, task_key, coin)
return TaskClaimResultOut(task_key=task_key, coin_awarded=coin, coin_balance=balance)
+240
View File
@@ -0,0 +1,240 @@
"""钱包 / 我的资产 endpoint。
路由前缀 `/api/v1/wallet`:
GET /account 金币 + 现金余额(我的资产卡)
GET /coin-transactions 金币流水(游标分页)
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.api.deps import CurrentUser, DbSession
from app.core.config import settings
from app.core.ratelimit import rate_limit
from app.core.rewards import (
COIN_PER_CENT,
COIN_PER_YUAN,
MIN_EXCHANGE_COIN,
WITHDRAW_MAX_CENTS,
WITHDRAW_MIN_CENTS,
)
from app.repositories import wallet as crud_wallet
from app.models.user import User
from app.schemas.welfare import (
BindWechatRequest,
BindWechatResultOut,
CashTransactionOut,
CashTransactionPage,
CoinAccountOut,
CoinTransactionOut,
CoinTransactionPage,
ExchangeInfoOut,
ExchangeRequest,
ExchangeResultOut,
UnbindWechatResultOut,
WithdrawInfoOut,
WithdrawOrderOut,
WithdrawOrderPage,
WithdrawRequest,
WithdrawResultOut,
WithdrawStatusOut,
)
logger = logging.getLogger("shagua.wallet")
router = APIRouter(prefix="/api/v1/wallet", tags=["wallet"])
@router.get("/account", response_model=CoinAccountOut, summary="金币+现金余额")
def get_account(user: CurrentUser, db: DbSession) -> CoinAccountOut:
acc = crud_wallet.get_or_create_account(db, user.id)
return CoinAccountOut.model_validate(acc)
@router.get(
"/coin-transactions",
response_model=CoinTransactionPage,
summary="金币流水(游标分页)",
)
def coin_transactions(
user: CurrentUser,
db: DbSession,
limit: int = Query(20, ge=1, le=100),
cursor: int | None = Query(None, description="上一页末条 id"),
) -> CoinTransactionPage:
items, next_cursor = crud_wallet.list_coin_transactions(
db, user.id, limit=limit, cursor=cursor
)
return CoinTransactionPage(
items=[CoinTransactionOut.model_validate(it) for it in items],
next_cursor=next_cursor,
)
@router.get("/cash-transactions", response_model=CashTransactionPage, summary="现金流水(游标分页)")
def cash_transactions(
user: CurrentUser,
db: DbSession,
limit: int = Query(20, ge=1, le=100),
cursor: int | None = Query(None, description="上一页末条 id"),
) -> CashTransactionPage:
items, next_cursor = crud_wallet.list_cash_transactions(
db, user.id, limit=limit, cursor=cursor
)
return CashTransactionPage(
items=[CashTransactionOut.model_validate(it) for it in items],
next_cursor=next_cursor,
)
@router.get("/exchange-info", response_model=ExchangeInfoOut, summary="金币兑现金汇率/规则")
def exchange_info() -> ExchangeInfoOut:
return ExchangeInfoOut(
coin_per_yuan=COIN_PER_YUAN,
min_coin=MIN_EXCHANGE_COIN,
step_coin=COIN_PER_CENT,
)
@router.post("/exchange", response_model=ExchangeResultOut, summary="金币兑现金")
def exchange(req: ExchangeRequest, user: CurrentUser, db: DbSession) -> ExchangeResultOut:
try:
acc, cents = crud_wallet.exchange_coins_to_cash(db, user.id, req.coin_amount)
except crud_wallet.InvalidExchangeAmountError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"coin_amount must be >= {MIN_EXCHANGE_COIN} and a multiple of {COIN_PER_CENT}",
) from e
except crud_wallet.InsufficientCoinError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="insufficient coin balance") from e
logger.info("exchange ok user_id=%d coin=%d cents=%d", user.id, req.coin_amount, cents)
return ExchangeResultOut(
coin_amount=req.coin_amount,
cash_added_cents=cents,
coin_balance=acc.coin_balance,
cash_balance_cents=acc.cash_balance_cents,
)
# ===== 提现(现金 → 微信零钱) =====
@router.post(
"/bind-wechat",
response_model=BindWechatResultOut,
summary="微信授权 code 换 openid 并绑定",
dependencies=[Depends(rate_limit(10, 60, "bind-wechat"))], # #6 同 IP 每分钟≤10 次
)
def bind_wechat(req: BindWechatRequest, user: CurrentUser, db: DbSession) -> BindWechatResultOut:
if not settings.wxpay_configured:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="wechat pay not configured")
try:
info = crud_wallet.bind_wechat_openid(db, user.id, req.code)
except crud_wallet.WechatAlreadyBoundError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="该微信已绑定其他账号"
) from e
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
logger.info(
"bind wechat ok user_id=%d openid=%s*** nickname=%r avatar=%s",
user.id, info["openid"][:6], info["nickname"], bool(info["avatar_url"]),
)
return BindWechatResultOut(
bound=True, wechat_nickname=info["nickname"], wechat_avatar_url=info["avatar_url"]
)
@router.post("/unbind-wechat", response_model=UnbindWechatResultOut, summary="解绑微信(清空 openid)")
def unbind_wechat(user: CurrentUser, db: DbSession) -> UnbindWechatResultOut:
crud_wallet.unbind_wechat_openid(db, user.id)
logger.info("unbind wechat ok user_id=%d", user.id)
return UnbindWechatResultOut(bound=False)
@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态")
def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut:
u = db.get(User, user.id)
return WithdrawInfoOut(
min_cents=WITHDRAW_MIN_CENTS,
max_cents=WITHDRAW_MAX_CENTS,
wechat_bound=bool(u and u.wechat_openid),
wechat_nickname=u.wechat_nickname if u else None,
wechat_avatar_url=u.wechat_avatar_url if u else None,
)
@router.post(
"/withdraw",
response_model=WithdrawResultOut,
summary="发起提现到微信零钱",
dependencies=[Depends(rate_limit(20, 60, "withdraw"))], # #6 同 IP 每分钟≤20 次
)
def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> WithdrawResultOut:
if not settings.wxpay_configured:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="wechat pay not configured")
try:
order = crud_wallet.create_withdraw(
db, user.id, req.amount_cents, user_name=req.user_name, out_bill_no=req.out_bill_no
)
except crud_wallet.InvalidWithdrawAmountError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"amount_cents must be within [{WITHDRAW_MIN_CENTS}, {WITHDRAW_MAX_CENTS}]",
) from e
except crud_wallet.WechatNotBoundError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="wechat not bound") from e
except crud_wallet.InsufficientCashError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="insufficient cash balance") from e
except crud_wallet.WithdrawTransferError as e:
# 转账调用失败,余额已退回
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"wechat transfer failed: {e}") from e
acc = crud_wallet.get_or_create_account(db, user.id)
logger.info("withdraw user_id=%d cents=%d bill=%s state=%s", user.id, req.amount_cents, order.out_bill_no, order.wechat_state)
return WithdrawResultOut(
out_bill_no=order.out_bill_no,
status=order.status,
wechat_state=order.wechat_state,
amount_cents=order.amount_cents,
cash_balance_cents=acc.cash_balance_cents,
package_info=order.package_info,
mch_id=settings.WXPAY_MCH_ID,
app_id=settings.WECHAT_APP_ID,
)
@router.get("/withdraw/status", response_model=WithdrawStatusOut, summary="查提现单状态(轮询)")
def withdraw_status(
user: CurrentUser, db: DbSession, out_bill_no: str = Query(..., description="商户提现单号")
) -> WithdrawStatusOut:
try:
# 用户从确认页返回后查单:仍 WAIT_USER_CONFIRM 视为放弃 → 撤销+退款
order = crud_wallet.refresh_withdraw_status(
db, user.id, out_bill_no, cancel_if_unconfirmed=True
)
except crud_wallet.WithdrawOrderNotFound as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="withdraw order not found") from e
return WithdrawStatusOut(
out_bill_no=order.out_bill_no,
status=order.status,
wechat_state=order.wechat_state,
amount_cents=order.amount_cents,
)
@router.get("/withdraw-orders", response_model=WithdrawOrderPage, summary="提现单列表(游标分页)")
def withdraw_orders(
user: CurrentUser,
db: DbSession,
limit: int = Query(20, ge=1, le=100),
cursor: int | None = Query(None, description="上一页末条 id"),
) -> WithdrawOrderPage:
items, next_cursor = crud_wallet.list_withdraw_orders(db, user.id, limit=limit, cursor=cursor)
return WithdrawOrderPage(
items=[WithdrawOrderOut.model_validate(it) for it in items],
next_cursor=next_cursor,
)