Merge remote-tracking branch 'origin/main' into feature/coupon-claim-integration
# Conflicts: # docs/api/README.md
This commit is contained in:
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user