Skip to content

Commit a5dd976

Browse files
Uno-Takashiclaude
andcommitted
feat(stats): fold reactions into daily aggregate and cache stats API
リアクションは 1 タップ = 1 行で大量に溜まるため、ルーム終了時に日次×種別へ 畳み込み、生データは破棄する集計モデルを導入する。あわせて統計 API のレスポンスを Redis にキャッシュし、毎リクエストの DB 集計を避ける。 - models.ReactionStat: (date, reaction_type) 一意の日次集計(count 加算) - cron.fold_room_reactions: ルーム終了(host削除/猶予削除/保持期間切れ)時に 当該ルームのリアクションを ReactionStat へ加算し、AnimeReaction を hard delete - consumers: ルーム終了経路で畳み込みを呼ぶ - api/views: 統計 API を ReactionStat 参照+キャッシュ化 - settings.CACHES: channel layer(DB0) と分けて Redis DB1 を使用 - .env.django: STATS_CACHE_SECONDS を追加、不要になった REACTION_HARD_DIVIDE_DAY を削除 - tests: test_stats_folding.py 追加、api/streamer のテストを拡充 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f91f05d commit a5dd976

10 files changed

Lines changed: 537 additions & 58 deletions

File tree

.env.django

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ REDIS_PORT=6379
88
CHROME_EXTENSION_REQUIRED_VERSION=1.0.0
99
LOGICAL_DIVIDE_DAY=3
1010
HARD_DIVIDE_DAY=365
11-
REACTION_HARD_DIVIDE_DAY=90
11+
# 統計 API のレスポンスキャッシュ TTL(秒)。
12+
STATS_CACHE_SECONDS=300
1213
# Retention cleanup schedule (system cron in the container). Every minute in dev.
1314
CRON_SCHEDULE=* * * * *

api/tests.py

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
1+
import datetime
12
import os
23

34
import pytest
5+
from django.utils import timezone
46
from rest_framework import status
57
from rest_framework.test import APIClient, APITestCase
68

7-
from streamer.factories import AnimeRoomFactory
9+
from streamer.factories import AnimeRoomFactory, AnimeUserFactory
10+
from streamer.models import AnimeReaction, ReactionStat
11+
12+
13+
def _clear_reaction_tables() -> None:
14+
"""Drop reaction rows so reaction-count assertions are deterministic.
15+
16+
The test DB uses a ``MIRROR`` config, so rows committed by ``transaction=True``
17+
tests (e.g. the consumer folding test) are not flushed between runs. Called in
18+
``setUp`` it runs inside the per-test transaction and is rolled back afterwards,
19+
so it only hides that committed pollution for the duration of one test.
20+
"""
21+
ReactionStat.objects.all().delete()
22+
AnimeReaction.objects.all().delete(hard=True)
823

924

1025
class TestVersionCheckAPI(APITestCase):
@@ -102,3 +117,106 @@ def test_lobby_resolve_not_found_404(self):
102117

103118
response = self.client.get(self.endpoint(uuid.uuid4()))
104119
assert response.status_code == status.HTTP_404_NOT_FOUND
120+
121+
122+
class TestStatsDaysParam(APITestCase):
123+
"""``?days=N`` の検証(未指定→既定、範囲外→400)。"""
124+
125+
def setUp(self) -> None:
126+
self.client = APIClient()
127+
self.endpoint = "/api/v1/statistics/anime-store/active-user-per-day"
128+
129+
@pytest.mark.django_db
130+
def test_missing_days_defaults_ok_200(self):
131+
response = self.client.get(self.endpoint)
132+
assert response.status_code == status.HTTP_200_OK
133+
134+
@pytest.mark.django_db
135+
def test_days_out_of_range_400(self):
136+
for bad in ("0", "366", "-1", "abc", "1.5"):
137+
response = self.client.get(self.endpoint, {"days": bad})
138+
assert response.status_code == status.HTTP_400_BAD_REQUEST, bad
139+
140+
@pytest.mark.django_db
141+
def test_days_in_range_ok_200(self):
142+
for ok in ("1", "30", "365"):
143+
response = self.client.get(self.endpoint, {"days": ok})
144+
assert response.status_code == status.HTTP_200_OK, ok
145+
146+
147+
class TestStatsPeriodScope(APITestCase):
148+
"""期間スコープ(累計・リアクション)が days に従うことを確認。"""
149+
150+
def setUp(self) -> None:
151+
self.client = APIClient()
152+
_clear_reaction_tables()
153+
154+
@staticmethod
155+
def _make_reaction(room, reaction_type, when):
156+
reaction = AnimeReaction.objects.create(
157+
room_id=room, reaction_type=reaction_type
158+
)
159+
AnimeReaction.objects.filter(pk=reaction.pk).update(created_at=when)
160+
return reaction
161+
162+
@pytest.mark.django_db
163+
def test_user_all_count_respects_days(self):
164+
"""40 日前のユーザーは 30 日窓では数えず 365 日窓では数える。
165+
166+
テスト DB は ``MIRROR`` 設定で他テストの行が残りうるため、絶対値ではなく
167+
「40 日前ユーザーを 1 人足すと 365 日窓だけ +1 される」差分で検証する。
168+
"""
169+
from streamer.models import AnimeUser
170+
171+
AnimeUserFactory() # recent(auto_now_add で created_at=now)
172+
old = AnimeUserFactory()
173+
AnimeUser.objects.filter(pk=old.pk).update(
174+
created_at=timezone.now() - datetime.timedelta(days=40)
175+
)
176+
endpoint = "/api/v1/statistics/anime-store/anime-user-all-count"
177+
178+
c30 = self.client.get(endpoint, {"days": "30"}).data["data"]["count"]
179+
c365 = self.client.get(endpoint, {"days": "365"}).data["data"]["count"]
180+
assert c30 >= 1 # recent は 30 日窓に入る
181+
assert c365 - c30 >= 1 # 40 日前ユーザーは 365 日窓のみに入る
182+
183+
@pytest.mark.django_db
184+
def test_reaction_counts_come_from_reactionstat_and_live(self):
185+
"""リアクション集計は ReactionStat(畳み込み済み)と alive ルームの合算。"""
186+
today = timezone.now().date()
187+
ReactionStat.objects.create(date=today, reaction_type="S", count=5)
188+
live_room = AnimeRoomFactory() # alive
189+
self._make_reaction(live_room, "S", timezone.now())
190+
191+
by_type = self.client.get(
192+
"/api/v1/statistics/anime-store/anime-reaction-count", {"days": "30"}
193+
)
194+
smile = next(r for r in by_type.data["data"] if r["reaction_type"] == "smile")
195+
assert smile["count"] == 6 # 5 (folded) + 1 (live)
196+
197+
total = self.client.get(
198+
"/api/v1/statistics/anime-store/anime-reaction-all-count", {"days": "30"}
199+
)
200+
assert total.data["data"]["count"] == 6
201+
202+
203+
class TestStatsCache(APITestCase):
204+
"""キャッシュヒット時に DB の変化が TTL 内では反映されないこと。"""
205+
206+
def setUp(self) -> None:
207+
self.client = APIClient()
208+
self.endpoint = "/api/v1/statistics/anime-store/anime-reaction-all-count"
209+
_clear_reaction_tables()
210+
211+
@pytest.mark.django_db
212+
def test_response_is_cached(self):
213+
today = timezone.now().date()
214+
ReactionStat.objects.create(date=today, reaction_type="S", count=3)
215+
216+
first = self.client.get(self.endpoint, {"days": "30"})
217+
assert first.data["data"]["count"] == 3
218+
219+
# キャッシュ後に DB を増やしても、同一キーは TTL 内でキャッシュ値を返す。
220+
ReactionStat.objects.filter(date=today, reaction_type="S").update(count=99)
221+
second = self.client.get(self.endpoint, {"days": "30"})
222+
assert second.data["data"]["count"] == 3 # キャッシュヒット

0 commit comments

Comments
 (0)