Skip to content

Commit 2af963e

Browse files
committed
[FIX] apply data server sync, fix via code review comments
1 parent 34499f8 commit 2af963e

27 files changed

Lines changed: 359 additions & 136 deletions

backend/app/api/me.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
GET /me/collection — registered users only (guests have no persistent collection)
66
"""
77

8-
from fastapi import APIRouter, Depends
8+
import time
9+
10+
from fastapi import APIRouter, Depends, HTTPException
911
from sqlalchemy.orm import Session
1012

13+
from app.config import settings
1114
from app.database import get_db
1215
from app.dependencies import get_current_user, require_registered
1316
from app.models.db import User
@@ -16,10 +19,10 @@
1619
from app.schemas.progress import (
1720
CollectionUnlockRequest,
1821
CollectionUnlockResponse,
19-
CurrencyUpdateRequest,
20-
CurrencyUpdateResponse,
2122
ProgressMergeRequest,
2223
ProgressMergeResponse,
24+
RewardClaimRequest,
25+
RewardClaimResponse,
2326
)
2427

2528
router = APIRouter(prefix="/me", tags=["me"])
@@ -107,17 +110,35 @@ def merge_progress(
107110
)
108111

109112

110-
@router.put("/currency", response_model=CurrencyUpdateResponse)
111-
def update_currency(
112-
req: CurrencyUpdateRequest,
113+
# Per-user rate-limit state for single-play reward claims.
114+
# Key: user_id, Value: last claim timestamp.
115+
_reward_last_claim: dict[str, float] = {}
116+
117+
118+
@router.post("/reward", response_model=RewardClaimResponse)
119+
def claim_reward(
120+
req: RewardClaimRequest,
113121
user: User = Depends(require_registered),
114122
db: Session = Depends(get_db),
115-
) -> CurrencyUpdateResponse:
123+
) -> RewardClaimResponse:
116124
"""
117-
Persist the exact wallet amount for the authenticated registered user.
125+
Claim a delta-based currency reward from a single-player game.
126+
127+
The server validates:
128+
- amount does not exceed REWARD_SINGLE_PLAY_MAX
129+
- minimum cooldown between claims (rate-limit)
118130
"""
119-
profile_repo.set_currency(db, user.profile, req.currency)
120-
return CurrencyUpdateResponse(currency=req.currency)
131+
if req.amount > settings.REWARD_SINGLE_PLAY_MAX:
132+
raise HTTPException(status_code=422, detail="Reward amount exceeds maximum")
133+
134+
now = time.monotonic()
135+
last = _reward_last_claim.get(user.id, 0.0)
136+
if now - last < settings.REWARD_SINGLE_PLAY_COOLDOWN_SECONDS:
137+
raise HTTPException(status_code=429, detail="Reward claim too frequent")
138+
_reward_last_claim[user.id] = now
139+
140+
profile_repo.add_currency(db, user.profile, req.amount)
141+
return RewardClaimResponse(granted=req.amount, currency=user.profile.currency)
121142

122143

123144
@router.post("/collection/unlock", response_model=CollectionUnlockResponse)

backend/app/config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,17 @@ class Settings(BaseSettings):
7777
LOG_LEVEL: str = "INFO"
7878
ADMIN_TOKEN: str = ""
7979

80+
# Reward constants (must match client-side values for single-player validation).
81+
REWARD_VICTORY_BASE: int = 100
82+
REWARD_VICTORY_PER_ROUND: int = 10
83+
REWARD_DEFEAT_BASE: int = 30
84+
REWARD_DEFEAT_PER_ROUND: int = 5
85+
# Maximum reward the server will accept from a single-play claim.
86+
# Victory at round 10 = 100 + 10*10 = 200; generous cap at 500.
87+
REWARD_SINGLE_PLAY_MAX: int = 500
88+
# Rate-limit: minimum seconds between single-play reward claims.
89+
REWARD_SINGLE_PLAY_COOLDOWN_SECONDS: int = 30
90+
8091
# CORS — comma-separated origins, or ["*"] for wide-open dev.
8192
# Production example: "https://fallin.example.com,https://web.fallin.example.com"
8293
CORS_ORIGINS: list[str] = ["*"]

backend/app/main.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,13 @@ async def lifespan(app: FastAPI):
6464
@app.get("/healthz", tags=["ops"])
6565
def health_check():
6666
"""Liveness probe. Verifies the DB connection pool is healthy."""
67+
db = SessionLocal()
6768
try:
68-
db = SessionLocal()
6969
db.execute(text("SELECT 1"))
70-
db.close()
7170
except Exception:
7271
return {"status": "degraded", "db": "unreachable"}
72+
finally:
73+
db.close()
7374
return {"status": "ok"}
7475

7576

backend/app/repositories/profile_repo.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,12 @@ def set_currency(db: Session, profile: Profile, currency: int) -> Profile:
2020
db.commit()
2121
db.refresh(profile)
2222
return profile
23+
24+
25+
def add_currency(db: Session, profile: Profile, delta: int) -> Profile:
26+
"""Atomically add *delta* to the profile's wallet (can be negative)."""
27+
profile.currency = max(0, profile.currency + delta)
28+
profile.updated_at = _utcnow()
29+
db.commit()
30+
db.refresh(profile)
31+
return profile

backend/app/schemas/progress.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
44
These endpoints are intentionally narrow:
55
- initial merge of local single-player progress into a registered account
6-
- exact currency updates after in-game earn/spend events
6+
- delta-based reward claims with server-side validation
77
- idempotent soldier unlock writes
88
"""
99

10+
from typing import Literal
11+
1012
from pydantic import BaseModel, Field
1113

1214

@@ -21,11 +23,18 @@ class ProgressMergeResponse(BaseModel):
2123
total_collected: int
2224

2325

24-
class CurrencyUpdateRequest(BaseModel):
25-
currency: int = Field(ge=0)
26+
class RewardClaimRequest(BaseModel):
27+
"""Delta-based reward claim with reason for server validation."""
28+
29+
amount: int = Field(ge=0)
30+
reason: Literal[
31+
"single_play_victory",
32+
"single_play_defeat",
33+
]
2634

2735

28-
class CurrencyUpdateResponse(BaseModel):
36+
class RewardClaimResponse(BaseModel):
37+
granted: int
2938
currency: int
3039

3140

backend/app/services/nickname_service.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ def validate_nickname(raw: str) -> str:
9494
if not _ALLOWED_RE.match(nick):
9595
raise NicknameError("닉네임에는 한글, 영문, 숫자, 밑줄(_), 공백만 사용할 수 있습니다.")
9696

97+
if " " in nick:
98+
raise NicknameError("닉네임에 연속된 공백은 사용할 수 없습니다.")
99+
97100
if _ALL_DIGITS_RE.match(nick):
98101
raise NicknameError("닉네임은 숫자만으로 구성될 수 없습니다.")
99102

backend/app/services/report_service.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from __future__ import annotations
2626

2727
import logging
28+
import re
2829
from typing import Optional
2930

3031
from sqlalchemy.orm import Session
@@ -35,6 +36,7 @@
3536
logger = logging.getLogger("fall_in.report")
3637

3738
_DETAILS_MAX_LEN = 280
39+
_HTML_TAG_RE = re.compile(r"<[^>]+>")
3840

3941

4042
class ReportError(ValueError):
@@ -76,10 +78,10 @@ def submit_report(
7678
if reporter_user_id and reporter_user_id == reported_user_id:
7779
raise ReportError("자기 자신을 신고할 수 없습니다.")
7880

79-
# Sanitise optional details.
81+
# Sanitise optional details: strip HTML tags, then trim.
8082
clean_details: Optional[str] = None
8183
if details:
82-
clean_details = details.strip()[:_DETAILS_MAX_LEN]
84+
clean_details = _HTML_TAG_RE.sub("", details).strip()[:_DETAILS_MAX_LEN]
8385
if not clean_details:
8486
clean_details = None
8587

backend/app/ws/handler.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,6 +1046,8 @@ async def _continue_after_round_settlement(
10461046
presence_manager.cancel_round_settlement_timeout(match.match_id)
10471047

10481048
if summary.game_over:
1049+
rewards = _calculate_match_rewards(match, summary)
1050+
_grant_match_rewards(match, rewards)
10491051
await manager.broadcast_to_room(
10501052
room_code,
10511053
{
@@ -1054,6 +1056,7 @@ async def _continue_after_round_settlement(
10541056
"match_id": match.match_id,
10551057
"winner_seat": summary.winner_seat,
10561058
"final_scores": summary.total_scores,
1059+
"rewards": rewards,
10571060
},
10581061
},
10591062
)
@@ -1108,6 +1111,57 @@ def _apply_mmr_update(match, winner_seat: int) -> None:
11081111
db.close()
11091112

11101113

1114+
# ---------------------------------------------------------------------------
1115+
# Reward helper (PR-09)
1116+
# ---------------------------------------------------------------------------
1117+
1118+
1119+
def _calculate_match_rewards(
1120+
match, summary
1121+
) -> dict[int, int]:
1122+
"""Return {seat_index: reward_amount} for every seat in the match."""
1123+
rewards: dict[int, int] = {}
1124+
for seat_index in match.seats:
1125+
is_winner = seat_index == summary.winner_seat
1126+
if is_winner:
1127+
amount = (
1128+
settings.REWARD_VICTORY_BASE
1129+
+ summary.round_number * settings.REWARD_VICTORY_PER_ROUND
1130+
)
1131+
else:
1132+
amount = (
1133+
settings.REWARD_DEFEAT_BASE
1134+
+ summary.round_number * settings.REWARD_DEFEAT_PER_ROUND
1135+
)
1136+
rewards[seat_index] = amount
1137+
return rewards
1138+
1139+
1140+
def _grant_match_rewards(match, rewards: dict[int, int]) -> None:
1141+
"""Persist currency rewards for registered human seats."""
1142+
from app.database import SessionLocal
1143+
from app.repositories import profile_repo
1144+
from app.repositories import user_repo as _user_repo
1145+
1146+
db = SessionLocal()
1147+
try:
1148+
for seat_index, amount in rewards.items():
1149+
seat = match.seats.get(seat_index)
1150+
if seat is None or seat.account_type != "registered" or seat.user_id is None:
1151+
continue
1152+
user = _user_repo.get_by_id(db, seat.user_id)
1153+
if user is None or user.profile is None:
1154+
continue
1155+
profile_repo.add_currency(db, user.profile, amount)
1156+
except Exception:
1157+
logger.exception(
1158+
"reward_grant_failed",
1159+
extra={"match_id": match.match_id},
1160+
)
1161+
finally:
1162+
db.close()
1163+
1164+
11111165
# ---------------------------------------------------------------------------
11121166
# Quick-match handlers (PR-06)
11131167
# ---------------------------------------------------------------------------

backend/tests/test_collection.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -254,10 +254,15 @@ def test_merge_progress_uses_max_currency_and_union_collection(self, client, db:
254254
assert data["collected_soldier_ids"] == [11, 42, 77]
255255
assert data["total_collected"] == 3
256256

257-
def test_currency_update_persists_exact_wallet_amount(self, client):
257+
def test_reward_claim_adds_currency(self, client):
258258
token = _register_and_get_token(client, "currency@example.com", nickname="Wallet")
259-
resp = client.put("/me/currency", headers=_auth(token), json={"currency": 35})
259+
resp = client.post(
260+
"/me/reward",
261+
headers=_auth(token),
262+
json={"amount": 35, "reason": "single_play_victory"},
263+
)
260264
assert resp.status_code == 200
265+
assert resp.json()["granted"] == 35
261266
assert resp.json()["currency"] == 35
262267

263268
profile = client.get("/me/profile", headers=_auth(token))
@@ -294,7 +299,11 @@ def test_guest_cannot_write_progress(self, client):
294299
headers=_auth(token),
295300
json={"currency": 50, "collected_soldier_ids": [7]},
296301
)
297-
update = client.put("/me/currency", headers=_auth(token), json={"currency": 50})
302+
update = client.post(
303+
"/me/reward",
304+
headers=_auth(token),
305+
json={"amount": 50, "reason": "single_play_victory"},
306+
)
298307
unlock = client.post(
299308
"/me/collection/unlock",
300309
headers=_auth(token),

backend/tests/test_match.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,6 +868,7 @@ def revoke_match_tokens(self, match_id: str) -> None:
868868
"match_id": match.match_id,
869869
"winner_seat": 0,
870870
"final_scores": {0: 12, 1: 40, 2: 66, 3: 66},
871+
"rewards": {0: 130, 1: 45, 2: 45, 3: 45},
871872
},
872873
},
873874
)

0 commit comments

Comments
 (0)