o2o-triple-pick/backend/app/services/points.py

100 lines
3.0 KiB
Python

"""유저별 포인트 누적 — grade_prediction 의 (key, value) 를 user_points 에 반영.
채점(apply_result)에서 호출. 결과 정정(재채점)에도 안전하도록 영향받은
이메일의 채점 가능한 픽 전체를 재집계해 upsert 한다(idempotent — 몇 번을
다시 돌려도 같은 결과, 증분 방식의 이중 누적 위험 없음).
"""
from __future__ import annotations
import logging
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..domain import now_utc
from ..models import Match, UserPoints, UserPrediction
from ..scoring import grade_prediction
log = logging.getLogger("triplepick.points")
# scoring.json 의 key → user_points 등급별 횟수 컬럼
_KEY_TO_COL = {
"score_exact": "exact_count",
"score_close": "close_count",
"score_outcome": "outcome_count",
"score_partial": "partial_count",
"score_miss": "miss_count",
}
def _empty() -> dict:
return {
"total_points": 0,
"exact_count": 0,
"close_count": 0,
"outcome_count": 0,
"partial_count": 0,
"miss_count": 0,
"matches_played": 0,
"first_scored_at": None,
}
async def accumulate_user_points(
db: AsyncSession, emails: set[str | None]
) -> int:
"""이메일별 누적 포인트 재집계 → user_points upsert. 갱신 행 수 반환.
커밋은 호출자(apply_result) 책임 — 채점과 누적이 한 트랜잭션으로 묶인다.
"""
targets = {e.strip().lower() for e in emails if e and e.strip()}
if not targets:
return 0
rows = (
await db.execute(
select(UserPrediction, Match)
.join(Match, UserPrediction.match_id == Match.match_id)
.where(
func.lower(UserPrediction.email).in_(targets),
Match.result_score_a.is_not(None),
Match.result_score_b.is_not(None),
)
)
).all()
agg: dict[str, dict] = {e: _empty() for e in targets}
for pick, match in rows:
key, value = grade_prediction(
pick.score_a, pick.score_b, match.result_score_a, match.result_score_b
)
t = agg[pick.email.strip().lower()]
t["total_points"] += value
t[_KEY_TO_COL[key]] += 1
t["matches_played"] += 1
ts = pick.scored_at or pick.created_at
if ts and (t["first_scored_at"] is None or ts < t["first_scored_at"]):
t["first_scored_at"] = ts
existing = {
up.email: up
for up in (
await db.execute(select(UserPoints).where(UserPoints.email.in_(targets)))
).scalars()
}
for email, t in agg.items():
row = existing.get(email)
if row is None:
row = UserPoints(email=email)
db.add(row)
for col, val in t.items():
setattr(row, col, val)
row.updated_at = now_utc()
log.info(
"user_points: %d명 누적 갱신 (%s)",
len(agg),
", ".join(f"{e}={t['total_points']}p" for e, t in agg.items()),
)
return len(agg)