From 777bbef4522915d2b15b7df686a515b29b13487c Mon Sep 17 00:00:00 2001 From: marco Date: Tue, 16 Jun 2026 01:16:58 +0800 Subject: [PATCH] =?UTF-8?q?wip(coupon):=20=E9=A2=86=E5=88=B8=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E7=9B=B8=E5=85=B3=20server=20=E6=94=B9=E5=8A=A8=20(?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E5=BF=AB=E7=85=A7,=20=E9=9D=9E=E6=9C=AC?= =?UTF-8?q?=E6=AC=A1=E4=BC=9A=E8=AF=9D=E4=BA=A7=E5=87=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app/api/v1/coupon.py + repositories/coupon_state + schemas/coupon_state 进行中改动, 未经我 review, 配合 launch_confirm / 领券链路。 --- app/api/v1/coupon.py | 15 ++++++++++++++- app/repositories/coupon_state.py | 28 +++++++++++++++++++++++++++- app/schemas/coupon_state.py | 10 ++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/app/api/v1/coupon.py b/app/api/v1/coupon.py index 6e07120..07c19af 100644 --- a/app/api/v1/coupon.py +++ b/app/api/v1/coupon.py @@ -18,7 +18,7 @@ import httpx from fastapi import APIRouter, HTTPException, Request, status from fastapi.concurrency import run_in_threadpool -from app.api.deps import DbSession +from app.api.deps import CurrentUser, DbSession from app.core.config import settings from app.core.pricebot_router import pick_pricebot from app.db.session import SessionLocal @@ -28,6 +28,7 @@ from app.schemas.coupon_state import ( CouponPromptDismissIn, CouponPromptShouldShowOut, CouponPromptShownIn, + CouponStatsOut, ) logger = logging.getLogger("shagua.coupon") @@ -268,3 +269,15 @@ def coupon_completed_today_reset( 与 /prompt/reset 配套:开发设置「重置今日领券弹窗状态」一键把今日状态全清。MVP 不鉴权。""" coupon_repo.reset_today_completion(db, payload.device_id) return {"ok": True} + + +@router.get( + "/stats", + response_model=CouponStatsOut, + summary="累计领券数(「我的」页战绩卡「领取优惠券 X 张」)", +) +def coupon_stats(user: CurrentUser, db: DbSession) -> CouponStatsOut: + """该登录用户累计领到的券数(SUM(claimed_count),口径见 coupon_repo.sum_claimed_count)。 + **鉴权(CurrentUser)**——区别于同文件不鉴权的 /step 透传与 /prompt 频控(那些按 device_id): + 个人战绩按 user_id 聚合,必须有登录态。""" + return CouponStatsOut(coupon_count=coupon_repo.sum_claimed_count(db, user.id)) diff --git a/app/repositories/coupon_state.py b/app/repositories/coupon_state.py index 33f4781..e3e2765 100644 --- a/app/repositories/coupon_state.py +++ b/app/repositories/coupon_state.py @@ -9,7 +9,7 @@ import logging from datetime import date, datetime from zoneinfo import ZoneInfo -from sqlalchemy import delete, select +from sqlalchemy import delete, func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -206,3 +206,29 @@ def record_claims( ) return 0 return written + + +# ===== 累计领券数(「我的」页战绩卡「领取优惠券 X 张」)===== + +def sum_claimed_count(db: Session, user_id: int) -> int: + """该用户累计领到的优惠券张数。口径(2026-06-15 用户定):SUM(claimed_count) —— + 各成功领券记录的 pricebot 展示张数(claimed_count 列,存的是 display_count)之和, + 与领券完成时给用户看的「本次领了 N 张」同源。 + + - 只算 status ∈ {success, already_claimed}:already_claimed=今日已领过,协议里算「已领到」; + failed / skipped 不计。 + - claimed_count 为 0 的保持 0:那是同 count_group 合并去重项(pricebot 只让一条出数),不重复计; + 为 NULL 的兜底成 1(成功领到至少 1 张;实际 pricebot to_dict 恒下发 display_count,NULL 基本不出现)。 + - 维度 user_id:登录态领的券才归入。登录前匿名领的(user_id 为空)不算(产品可接受)。 + """ + total = db.execute( + select( + func.coalesce( + func.sum(func.coalesce(CouponClaimRecord.claimed_count, 1)), 0 + ) + ).where( + CouponClaimRecord.user_id == user_id, + CouponClaimRecord.status.in_(("success", "already_claimed")), + ) + ).scalar_one() + return int(total or 0) diff --git a/app/schemas/coupon_state.py b/app/schemas/coupon_state.py index 15e9c16..236d311 100644 --- a/app/schemas/coupon_state.py +++ b/app/schemas/coupon_state.py @@ -39,3 +39,13 @@ class CouponCompletedTodayOut(BaseModel): """这台设备今天是否已跑完整轮领券(到 done 帧)。完成 → 首页「去领取」卡置灰。""" completed: bool + + +class CouponStatsOut(BaseModel): + """「我的」页战绩卡「领取优惠券 X 张」数据源:该登录用户累计领到的券数。 + + 口径(2026-06-15 用户定):SUM(claimed_count) —— 各成功领券记录的 pricebot 展示张数之和, + 与领券完成时给用户看的「本次领了 N 张」同源。详见 repositories.coupon_state.sum_claimed_count。 + """ + + coupon_count: int