Files
shaguabijia-app-server/app/repositories/coupon_state.py
T

235 lines
8.8 KiB
Python

"""领券今日状态读写:弹窗频控(engagement)+ 领券记录(claim)。
写操作按唯一键幂等 upsert,自带 commit + 并发 IntegrityError 兜底(对齐 price_observation)。
日期口径 = Asia/Shanghai 的自然日。
"""
from __future__ import annotations
import logging
from datetime import date, datetime
from zoneinfo import ZoneInfo
from sqlalchemy import delete, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models.coupon_state import (
CouponClaimRecord,
CouponDailyCompletion,
CouponPromptEngagement,
)
logger = logging.getLogger("shagua.coupon_state")
_CN_TZ = ZoneInfo("Asia/Shanghai")
def today_cn() -> date:
"""Asia/Shanghai 的自然日(领券判断的"今天")。"""
return datetime.now(_CN_TZ).date()
# ===== 弹窗频控(coupon_prompt_engagement)=====
def has_engaged_today(db: Session, device_id: str, package: str) -> bool:
"""这台设备今天**这个 App** 是否已对领券引导窗表达过意向(领/拒/弹出)。有 = 该 App 不再弹。
频控按 (device, package, 日):美团弹过不影响淘宝/京东今天各自仍弹一次。"""
row = db.execute(
select(CouponPromptEngagement.id).where(
CouponPromptEngagement.device_id == device_id,
CouponPromptEngagement.package == package,
CouponPromptEngagement.engage_date == today_cn(),
)
).first()
return row is not None
def mark_engagement(
db: Session, device_id: str, package: str, user_id: int | None, engage_type: str
) -> None:
"""记今日意向(shown / claim_started / dismissed)。(device, package, 今天) 唯一,幂等 upsert。
engage_type 升级口径(同一 (device,package,日) 多次调,只覆盖 type,不新增行):
shown(自动弹出)→ claim_started(点一键领取)/ dismissed(点关闭)。判断只看"有没有这条"
"""
today = today_cn()
row = db.execute(
select(CouponPromptEngagement).where(
CouponPromptEngagement.device_id == device_id,
CouponPromptEngagement.package == package,
CouponPromptEngagement.engage_date == today,
)
).scalar_one_or_none()
if row is not None:
row.engage_type = engage_type
if user_id is not None:
row.user_id = user_id
else:
db.add(CouponPromptEngagement(
device_id=device_id, package=package, user_id=user_id,
engage_date=today, engage_type=engage_type,
))
try:
db.commit()
except IntegrityError:
# 并发下另一请求刚插了同 (device, package, 日) → 唯一约束撞,回滚忽略(本就幂等)。
db.rollback()
def reset_today_engagement(db: Session, device_id: str) -> int:
"""删这台设备今天**所有 App** 的 engagement(开发设置「重置今日领券弹窗状态」调,测频控用)。
删后各 App has_engaged_today → false,今天又都能弹。返回删除行数。
(不按 package 过滤:重置是"把今天清干净从头测",清全部 App 最符合预期。)"""
result = db.execute(
delete(CouponPromptEngagement).where(
CouponPromptEngagement.device_id == device_id,
CouponPromptEngagement.engage_date == today_cn(),
)
)
db.commit()
return result.rowcount or 0
# ===== 今日跑完整轮(coupon_daily_completion)=====
def has_completed_today(db: Session, device_id: str) -> bool:
"""这台设备今天是否已跑完整轮领券(到 done 帧)。有 = 首页置灰、不能再领。"""
row = db.execute(
select(CouponDailyCompletion.id).where(
CouponDailyCompletion.device_id == device_id,
CouponDailyCompletion.complete_date == today_cn(),
)
).first()
return row is not None
def mark_completed_today(
db: Session, device_id: str, user_id: int | None, trace_id: str | None = None
) -> None:
"""记今日已跑完整轮。(device, 今天) 唯一,幂等 upsert。到 done 即记,不管单券成败。"""
today = today_cn()
row = db.execute(
select(CouponDailyCompletion).where(
CouponDailyCompletion.device_id == device_id,
CouponDailyCompletion.complete_date == today,
)
).scalar_one_or_none()
if row is not None:
if user_id is not None:
row.user_id = user_id
if trace_id is not None:
row.trace_id = trace_id
else:
db.add(CouponDailyCompletion(
device_id=device_id, user_id=user_id,
complete_date=today, trace_id=trace_id,
))
try:
db.commit()
except IntegrityError:
# 并发下另一请求刚插了同 (device, 日) → 唯一约束撞,回滚忽略(本就幂等)。
db.rollback()
def reset_today_completion(db: Session, device_id: str) -> int:
"""删这台设备今天的"已完成"记录(开发设置「重置今日领券弹窗状态」全重置时调)。
删后 has_completed_today → false,首页「去领取」卡恢复可点。返回删除行数。"""
result = db.execute(
delete(CouponDailyCompletion).where(
CouponDailyCompletion.device_id == device_id,
CouponDailyCompletion.complete_date == today_cn(),
)
)
db.commit()
return result.rowcount or 0
# ===== 领券记录(coupon_claim_record)=====
def record_claims(
db: Session,
device_id: str,
user_id: int | None,
trace_id: str | None,
results: list[dict],
) -> int:
"""一批券领取结果幂等写入,返回写入(新增 + 更新)条数。
results 单项取自 pricebot 的 last_coupon_result / done.coupon_results,识别字段:
coupon_id(必需)/ status(必需)/ name / vendor / reason /(display_count)。
(device, coupon_id, 今天) 唯一:重复上报同张券走更新(status 以最后一次为准)。
"""
today = today_cn()
written = 0
seen: set[str] = set() # 同批去重防御:autoflush=False 下同 coupon_id 重复会两次 add → 撞唯一约束回滚整批
for r in results:
coupon_id = r.get("coupon_id")
status = r.get("status")
if not coupon_id or not status or coupon_id in seen:
continue # 脏数据 / 同批重复跳过
seen.add(coupon_id)
count = r.get("display_count")
if count is None:
count = r.get("claimed_count")
row = db.execute(
select(CouponClaimRecord).where(
CouponClaimRecord.device_id == device_id,
CouponClaimRecord.coupon_id == coupon_id,
CouponClaimRecord.claim_date == today,
)
).scalar_one_or_none()
if row is not None:
row.status = status
row.reason = r.get("reason")
if user_id is not None:
row.user_id = user_id
if count is not None:
row.claimed_count = count
row.extra = r
else:
db.add(CouponClaimRecord(
device_id=device_id, user_id=user_id,
coupon_id=coupon_id, claim_date=today,
status=status, vendor=r.get("vendor"), coupon_name=r.get("name"),
claimed_count=count, trace_id=trace_id, reason=r.get("reason"),
extra=r,
))
written += 1
if written == 0:
return 0
try:
db.commit()
except IntegrityError:
db.rollback()
logger.warning(
"coupon_claim 并发幂等冲突 device=%s trace=%s,回滚", device_id, trace_id
)
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)