Merge branch 'main' into feat/admin-api-db-docs
This commit is contained in:
+37
-3
@@ -21,12 +21,15 @@ from app.integrations import pangle
|
||||
from app.core.ratelimit import rate_limit
|
||||
from app.repositories import ad_ecpm as crud_ecpm
|
||||
from app.repositories import ad_reward as crud_ad
|
||||
from app.repositories import ad_watch as crud_watch
|
||||
from app.schemas.ad import (
|
||||
AdRewardStatusOut,
|
||||
EcpmReportIn,
|
||||
EcpmReportOut,
|
||||
PangleCallbackOut,
|
||||
TestGrantOut,
|
||||
WatchReportIn,
|
||||
WatchReportOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.ad")
|
||||
@@ -71,7 +74,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(
|
||||
@@ -91,7 +94,8 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
|
||||
|
||||
@router.get("/reward-status", response_model=AdRewardStatusOut, summary="今日看广告发奖进度")
|
||||
def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut:
|
||||
used, limit, coin_per, round_count, cooldown_until = crud_ad.today_status(db, user.id)
|
||||
(used, limit, coin_per, round_count, cooldown_until,
|
||||
watched_sec, watch_limit) = crud_ad.today_status(db, user.id)
|
||||
return AdRewardStatusOut(
|
||||
used_today=used,
|
||||
daily_limit=limit,
|
||||
@@ -99,6 +103,35 @@ def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut:
|
||||
coin_per_ad=coin_per,
|
||||
round_count=round_count,
|
||||
cooldown_until=cooldown_until,
|
||||
watched_seconds_today=watched_sec,
|
||||
watch_seconds_limit=watch_limit,
|
||||
watch_seconds_remaining=max(0, watch_limit - watched_sec),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/watch-report",
|
||||
response_model=WatchReportOut,
|
||||
summary="上报激励视频观看时长(每日 50 分钟防刷计时)",
|
||||
dependencies=[Depends(rate_limit(120, 60, "ad-watch-report"))],
|
||||
)
|
||||
def watch_report(payload: WatchReportIn, user: CurrentUser, db: DbSession) -> WatchReportOut:
|
||||
"""客户端在激励视频关闭(onAdClose)后上报本次实际观看秒数,服务端累计到当日总时长。
|
||||
|
||||
Bearer 鉴权,user_id 取自 JWT(不信 body)。seconds 服务端夹 [0, MAX_SINGLE_WATCH_SECONDS]。
|
||||
当日累计达 DAILY_AD_WATCH_SECONDS_LIMIT(50 分钟)后:① 本接口 / reward-status 返回 remaining=0,
|
||||
客户端不再展示广告;② 后端发奖(S2S 回调)也会因时长闸记 capped 不发金币(双闸,前端绕不过)。
|
||||
"""
|
||||
total = crud_watch.add_watch_seconds(db, user.id, payload.seconds)
|
||||
limit = rewards.DAILY_AD_WATCH_SECONDS_LIMIT
|
||||
logger.info(
|
||||
"ad watch report user_id=%d +%ds total=%d/%d",
|
||||
user.id, payload.seconds, total, limit,
|
||||
)
|
||||
return WatchReportOut(
|
||||
watched_seconds_today=total,
|
||||
watch_seconds_limit=limit,
|
||||
watch_seconds_remaining=max(0, limit - total),
|
||||
)
|
||||
|
||||
|
||||
@@ -151,7 +184,8 @@ def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut:
|
||||
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, round_count, cooldown_until = crud_ad.today_status(db, user.id)
|
||||
(used, limit, coin_per, round_count, cooldown_until,
|
||||
_watched, _watch_limit) = 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"),
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""上报更低价 endpoint。
|
||||
|
||||
路由前缀 `/api/v1/report`,需 Bearer 鉴权(上报绑登录用户)。
|
||||
POST / 提交上报(multipart:comparison_record_id / reported_platform_id /
|
||||
reported_price(元) + images 1~4 张)
|
||||
GET /records 上报记录列表(?status= pending/approved/rejected 可选筛选)
|
||||
|
||||
要点:
|
||||
- 截图复用 [app.core.media] 落盘到 /media/price_report/。
|
||||
- 原最低价由 comparison_record_id 反查比价记录 best_*(不信任客户端传的快照)。
|
||||
- 校验(D):上报价必须 < 原最低价,否则 400。
|
||||
- 提交一律 status=pending;通过发奖励是人工审核后台动作,不在此。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import media
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.repositories import report as report_repo
|
||||
from app.schemas.report import (
|
||||
ReportRecordCounts,
|
||||
ReportRecordOut,
|
||||
ReportRecordsOut,
|
||||
ReportSubmitOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.report")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/report", tags=["report"])
|
||||
|
||||
_MAX_IMAGES = 4
|
||||
_VALID_STATUS = ("pending", "approved", "rejected")
|
||||
|
||||
# 上报平台(canonical id → 展示名),与原型 fb-platform-chip data-pf 一致
|
||||
_PLATFORM_NAMES = {
|
||||
"meituan-waimai": "美团外卖",
|
||||
"jd-waimai": "京东外卖",
|
||||
"taobao-shanguang": "淘宝闪购",
|
||||
}
|
||||
|
||||
|
||||
def _dish_summary(items: list | None) -> str | None:
|
||||
"""把比价记录 items[{name,qty,...}] 拼成菜品摘要文案。"""
|
||||
if not items:
|
||||
return None
|
||||
names = [str(it.get("name", "")).strip() for it in items if isinstance(it, dict)]
|
||||
names = [n for n in names if n]
|
||||
return "、".join(names) if names else None
|
||||
|
||||
|
||||
@router.post("", response_model=ReportSubmitOut, summary="提交上报更低价")
|
||||
async def submit_report(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
comparison_record_id: int = Form(...),
|
||||
reported_platform_id: str = Form(...),
|
||||
reported_price: str = Form(..., description="用户填的更低价(元)"),
|
||||
images: list[UploadFile] = File(default=[]),
|
||||
) -> ReportSubmitOut:
|
||||
# 平台
|
||||
reported_platform_id = reported_platform_id.strip()
|
||||
platform_name = _PLATFORM_NAMES.get(reported_platform_id)
|
||||
if platform_name is None:
|
||||
raise HTTPException(status_code=400, detail="不支持的上报平台")
|
||||
|
||||
# 价格(元 → 分)
|
||||
try:
|
||||
price_yuan = float(reported_price)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="价格格式不正确") from None
|
||||
if price_yuan <= 0:
|
||||
raise HTTPException(status_code=400, detail="价格必须大于 0")
|
||||
reported_price_cents = round(price_yuan * 100)
|
||||
|
||||
# 反查比价记录(必须属于本人)→ 取原最低价快照
|
||||
rec = db.get(ComparisonRecord, comparison_record_id)
|
||||
if rec is None or rec.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="比价记录不存在")
|
||||
original_price_cents = rec.best_price_cents
|
||||
|
||||
# D:上报价必须低于原最低价
|
||||
if original_price_cents is not None and reported_price_cents >= original_price_cents:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"上报价需低于原最低价 ¥{original_price_cents / 100:.2f}",
|
||||
)
|
||||
|
||||
# 截图(至少 1 张,最多 4 张)
|
||||
files = [f for f in (images or []) if f is not None and f.filename]
|
||||
if not files:
|
||||
raise HTTPException(status_code=400, detail="请至少上传一张截图证明")
|
||||
if len(files) > _MAX_IMAGES:
|
||||
raise HTTPException(status_code=400, detail=f"最多上传 {_MAX_IMAGES} 张图片")
|
||||
urls: list[str] = []
|
||||
for f in files:
|
||||
data = await f.read()
|
||||
try:
|
||||
urls.append(media.save_report_image(user.id, data))
|
||||
except media.MediaError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
rep = report_repo.create_report(
|
||||
db,
|
||||
user_id=user.id,
|
||||
comparison_record_id=comparison_record_id,
|
||||
store_name=rec.store_name,
|
||||
dish_summary=_dish_summary(rec.items),
|
||||
original_platform_id=rec.best_platform_id,
|
||||
original_platform_name=rec.best_platform_name,
|
||||
original_price_cents=original_price_cents,
|
||||
reported_platform_id=reported_platform_id,
|
||||
reported_platform_name=platform_name,
|
||||
reported_price_cents=reported_price_cents,
|
||||
images=urls,
|
||||
)
|
||||
logger.info("price_report id=%d user_id=%d images=%d", rep.id, user.id, len(urls))
|
||||
return ReportSubmitOut.model_validate(rep)
|
||||
|
||||
|
||||
@router.get("/records", response_model=ReportRecordsOut, summary="上报记录列表")
|
||||
def list_report_records(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
status: str | None = None,
|
||||
) -> ReportRecordsOut:
|
||||
status = (status or "").strip() or None
|
||||
if status and status not in _VALID_STATUS:
|
||||
raise HTTPException(status_code=400, detail="无效的状态筛选")
|
||||
rows = report_repo.list_reports(db, user.id, status)
|
||||
# counts 始终基于全量(不受 status 筛选影响),供前端 chip 计数
|
||||
all_rows = rows if status is None else report_repo.list_reports(db, user.id, None)
|
||||
counts = ReportRecordCounts(
|
||||
all=len(all_rows),
|
||||
pending=sum(1 for r in all_rows if r.status == "pending"),
|
||||
approved=sum(1 for r in all_rows if r.status == "approved"),
|
||||
rejected=sum(1 for r in all_rows if r.status == "rejected"),
|
||||
)
|
||||
return ReportRecordsOut(
|
||||
records=[ReportRecordOut.model_validate(r) for r in rows],
|
||||
counts=counts,
|
||||
)
|
||||
+13
-5
@@ -170,10 +170,11 @@ def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut:
|
||||
@router.post(
|
||||
"/withdraw",
|
||||
response_model=WithdrawResultOut,
|
||||
summary="发起提现到微信零钱",
|
||||
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:
|
||||
@@ -189,12 +190,14 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
|
||||
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)
|
||||
logger.info(
|
||||
"withdraw submitted user_id=%d cents=%d bill=%s status=%s(待审核)",
|
||||
user.id, req.amount_cents, order.out_bill_no, order.status,
|
||||
)
|
||||
# 此刻 status=reviewing,尚未打款 → 无 package_info;App 据 status 提示"已提交,等待审核"。
|
||||
# 审核通过后客户端轮询 /withdraw/status 拿到 package_info(若需确认)再拉起微信确认页。
|
||||
return WithdrawResultOut(
|
||||
out_bill_no=order.out_bill_no,
|
||||
status=order.status,
|
||||
@@ -223,6 +226,11 @@ def withdraw_status(
|
||||
status=order.status,
|
||||
wechat_state=order.wechat_state,
|
||||
amount_cents=order.amount_cents,
|
||||
fail_reason=order.fail_reason,
|
||||
# 审核通过后 status=pending 且可能带 package_info(WAIT_USER_CONFIRM):供 App 拉起微信确认页
|
||||
package_info=order.package_info,
|
||||
mch_id=settings.WXPAY_MCH_ID if order.package_info else None,
|
||||
app_id=settings.WECHAT_APP_ID if order.package_info else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user