Skip to content

Commit 6aea5c0

Browse files
9keyyyyGukhee JoCopilot
authored
[NL-56] 로또 결과/통계 테이블 추가, 데이터 삽입 스크립트 작성, 회차/번호별 통계 리스트 조회 API 구현 (#8)
* feat: lotto draws, statistics table 생성 및 alembic migration * feat: 로또 회차별 리스트 조회/번호별 통계 조회 API 구현 * fix: prize amount type 변경 * feat: lotto data insert 스크립트 추가 * fix: 매핑 수정 * Update src/lotto/entities/models.py Co-authored-by: Copilot <[email protected]> --------- Co-authored-by: Gukhee Jo <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent 225cdcd commit 6aea5c0

File tree

14 files changed

+505
-3
lines changed

14 files changed

+505
-3
lines changed

migrations/env.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
# 모든 도메인 모델을 import하여 메타데이터에 포함해야 함
3131
from src.users.entities.models import User
32+
from src.lotto.entities.models import LottoStatistics, LottoDraws
3233

3334
# 모델 메타데이터 설정
3435
target_metadata = Base.metadata
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
#!/usr/bin/env python3
2+
"""
3+
로또 데이터를 동행복권 API에서 가져와서 데이터베이스에 저장하는 스크립트
4+
"""
5+
6+
import asyncio
7+
import json
8+
import os
9+
import sys
10+
from datetime import datetime
11+
from pathlib import Path
12+
from typing import Dict, Any, Optional
13+
14+
import httpx
15+
from sqlalchemy import select
16+
from sqlalchemy.ext.asyncio import AsyncSession
17+
18+
# .env 파일 로드
19+
from dotenv import load_dotenv
20+
load_dotenv()
21+
22+
from src.config.database import Mysql
23+
from src.config.config import db_config
24+
from src.lotto.entities.models import LottoDraws, LottoStatistics
25+
26+
27+
class LottoDataImporter:
28+
def __init__(self):
29+
self.db = Mysql(db_config)
30+
self.base_url = "https://dhlottery.co.kr/common.do"
31+
32+
async def fetch_lotto_data(self, client: httpx.AsyncClient, drw_no: int) -> Optional[Dict[str, Any]]:
33+
"""특정 회차의 로또 데이터를 가져옵니다."""
34+
params = {
35+
"method": "getLottoNumber",
36+
"drwNo": drw_no
37+
}
38+
39+
try:
40+
response = await client.get(self.base_url, params=params)
41+
if response.status_code == 200:
42+
data = response.json()
43+
if data.get("returnValue") == "success":
44+
return data
45+
else:
46+
print(f"회차 {drw_no}: API 응답 오류 - {data}")
47+
return None
48+
else:
49+
print(f"회차 {drw_no}: HTTP 오류 - {response.status_code}")
50+
return None
51+
except Exception as e:
52+
print(f"회차 {drw_no}: 요청 오류 - {e}")
53+
return None
54+
55+
def parse_lotto_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
56+
"""API 응답 데이터를 데이터베이스 모델에 맞게 파싱합니다."""
57+
return {
58+
"round": data["drwNo"],
59+
"draw_date": datetime.strptime(data["drwNoDate"], "%Y-%m-%d").date(),
60+
"num1": data["drwtNo1"],
61+
"num2": data["drwtNo2"],
62+
"num3": data["drwtNo3"],
63+
"num4": data["drwtNo4"],
64+
"num5": data["drwtNo5"],
65+
"num6": data["drwtNo6"],
66+
"bonus_num": data["bnusNo"],
67+
"first_prize_amount": data["firstWinamnt"],
68+
"total_winners": data["firstPrzwnerCo"]
69+
}
70+
71+
async def save_lotto_draw(self, db_session: AsyncSession, lotto_data: Dict[str, Any]) -> bool:
72+
"""로또 추첨 데이터를 데이터베이스에 저장합니다."""
73+
try:
74+
# 이미 존재하는지 확인
75+
existing = await db_session.execute(
76+
select(LottoDraws).where(LottoDraws.round == lotto_data["round"])
77+
)
78+
if existing.scalar_one_or_none():
79+
print(f"회차 {lotto_data['round']}: 이미 존재함")
80+
return False
81+
82+
# 새로운 로또 추첨 데이터 생성
83+
lotto_draw = LottoDraws(**lotto_data)
84+
db_session.add(lotto_draw)
85+
await db_session.commit()
86+
print(f"회차 {lotto_data['round']}: 저장 완료")
87+
return True
88+
except Exception as e:
89+
await db_session.rollback()
90+
print(f"회차 {lotto_data['round']}: 저장 오류 - {e}")
91+
return False
92+
93+
async def update_statistics(self, db_session: AsyncSession, lotto_data: Dict[str, Any]) -> None:
94+
"""로또 통계 데이터를 업데이트합니다."""
95+
try:
96+
# 메인 번호들 (1-6번)
97+
main_numbers = [
98+
lotto_data["num1"], lotto_data["num2"], lotto_data["num3"],
99+
lotto_data["num4"], lotto_data["num5"], lotto_data["num6"]
100+
]
101+
bonus_number = lotto_data["bonus_num"]
102+
103+
# 각 번호에 대한 통계 업데이트
104+
for num in range(1, 46):
105+
# 기존 통계 조회
106+
stat = await db_session.execute(
107+
select(LottoStatistics).where(LottoStatistics.num == num)
108+
)
109+
stat = stat.scalar_one_or_none()
110+
111+
if not stat:
112+
# 새로운 통계 생성
113+
stat = LottoStatistics(
114+
num=num,
115+
main_count=0,
116+
bonus_count=0,
117+
total_count=0
118+
)
119+
db_session.add(stat)
120+
121+
# 카운트 업데이트
122+
if num in main_numbers:
123+
stat.main_count += 1
124+
stat.total_count += 1
125+
elif num == bonus_number:
126+
stat.bonus_count += 1
127+
stat.total_count += 1
128+
129+
# 마지막 출현 정보 업데이트
130+
if num in main_numbers or num == bonus_number:
131+
stat.last_round = lotto_data["round"]
132+
stat.last_date = lotto_data["draw_date"]
133+
134+
await db_session.commit()
135+
print(f"회차 {lotto_data['round']}: 통계 업데이트 완료")
136+
137+
except Exception as e:
138+
await db_session.rollback()
139+
print(f"회차 {lotto_data['round']}: 통계 업데이트 오류 - {e}")
140+
141+
async def import_lotto_data(self, start_drw_no: int = 1, end_drw_no: int = 1183):
142+
"""지정된 범위의 로또 데이터를 가져와서 저장합니다."""
143+
print(f"로또 데이터 가져오기 시작: 회차 {start_drw_no} ~ {end_drw_no}")
144+
145+
async with httpx.AsyncClient() as client:
146+
async with self.db.session() as db_session:
147+
success_count = 0
148+
error_count = 0
149+
150+
for drw_no in range(start_drw_no, end_drw_no + 1):
151+
print(f"처리 중: 회차 {drw_no}")
152+
153+
# API에서 데이터 가져오기
154+
api_data = await self.fetch_lotto_data(client, drw_no)
155+
if not api_data:
156+
error_count += 1
157+
continue
158+
159+
# 데이터 파싱
160+
lotto_data = self.parse_lotto_data(api_data)
161+
162+
# 데이터베이스에 저장
163+
if await self.save_lotto_draw(db_session, lotto_data):
164+
# 통계 업데이트
165+
await self.update_statistics(db_session, lotto_data)
166+
success_count += 1
167+
else:
168+
error_count += 1
169+
170+
# API 호출 간격 조절 (서버 부하 방지)
171+
await asyncio.sleep(0.1)
172+
173+
print(f"\n가져오기 완료:")
174+
print(f"성공: {success_count}개")
175+
print(f"실패: {error_count}개")
176+
177+
async def close(self):
178+
"""데이터베이스 연결을 종료합니다."""
179+
await self.db.close()
180+
181+
182+
async def main():
183+
"""메인 함수"""
184+
importer = LottoDataImporter()
185+
186+
try:
187+
# 전체 회차 데이터 가져오기 (1~1183)
188+
await importer.import_lotto_data(1, 1183)
189+
190+
# 또는 특정 범위만 가져오기
191+
# await importer.import_lotto_data(1100, 1183)
192+
193+
except KeyboardInterrupt:
194+
print("\n사용자에 의해 중단되었습니다.")
195+
except Exception as e:
196+
print(f"오류 발생: {e}")
197+
finally:
198+
await importer.close()
199+
200+
201+
if __name__ == "__main__":
202+
asyncio.run(main())
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""fix lotto prize amount type
2+
3+
Revision ID: 05818a33a6e5
4+
Revises: 47e81b19f851
5+
Create Date: 2025-08-05 23:53:32.318980
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
from sqlalchemy.dialects import mysql
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = '05818a33a6e5'
16+
down_revision: Union[str, Sequence[str], None] = '47e81b19f851'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema."""
23+
# ### commands auto generated by Alembic - please adjust! ###
24+
op.alter_column('lotto_draws', 'first_prize_amount',
25+
existing_type=mysql.INTEGER(),
26+
type_=sa.BigInteger(),
27+
existing_nullable=False)
28+
# ### end Alembic commands ###
29+
30+
31+
def downgrade() -> None:
32+
"""Downgrade schema."""
33+
# ### commands auto generated by Alembic - please adjust! ###
34+
op.alter_column('lotto_draws', 'first_prize_amount',
35+
existing_type=sa.BigInteger(),
36+
type_=mysql.INTEGER(),
37+
existing_nullable=False)
38+
# ### end Alembic commands ###
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""add lotto draws and statistics
2+
3+
Revision ID: 47e81b19f851
4+
Revises: ec45a4f91988
5+
Create Date: 2025-08-05 23:23:51.373015
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = '47e81b19f851'
16+
down_revision: Union[str, Sequence[str], None] = 'ec45a4f91988'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema."""
23+
# ### commands auto generated by Alembic - please adjust! ###
24+
op.create_table('lotto_draws',
25+
sa.Column('round', sa.Integer(), autoincrement=False, nullable=False),
26+
sa.Column('draw_date', sa.Date(), nullable=False),
27+
sa.Column('num1', sa.Integer(), nullable=False),
28+
sa.Column('num2', sa.Integer(), nullable=False),
29+
sa.Column('num3', sa.Integer(), nullable=False),
30+
sa.Column('num4', sa.Integer(), nullable=False),
31+
sa.Column('num5', sa.Integer(), nullable=False),
32+
sa.Column('num6', sa.Integer(), nullable=False),
33+
sa.Column('bonus_num', sa.Integer(), nullable=False),
34+
sa.Column('first_prize_amount', sa.Integer(), nullable=False),
35+
sa.Column('total_winners', sa.Integer(), nullable=False),
36+
sa.Column('created_at', sa.DateTime(), nullable=False),
37+
sa.Column('updated_at', sa.DateTime(), nullable=False),
38+
sa.CheckConstraint('bonus_num BETWEEN 1 AND 45', name='bonus_num_range_check'),
39+
sa.CheckConstraint('num1 BETWEEN 1 AND 45', name='num1_range_check'),
40+
sa.CheckConstraint('num2 BETWEEN 1 AND 45', name='num2_range_check'),
41+
sa.CheckConstraint('num3 BETWEEN 1 AND 45', name='num3_range_check'),
42+
sa.CheckConstraint('num4 BETWEEN 1 AND 45', name='num4_range_check'),
43+
sa.CheckConstraint('num5 BETWEEN 1 AND 45', name='num5_range_check'),
44+
sa.CheckConstraint('num6 BETWEEN 1 AND 45', name='num6_range_check'),
45+
sa.PrimaryKeyConstraint('round')
46+
)
47+
op.create_table('lotto_statistics',
48+
sa.Column('num', sa.Integer(), autoincrement=False, nullable=False),
49+
sa.Column('main_count', sa.Integer(), nullable=True),
50+
sa.Column('bonus_count', sa.Integer(), nullable=True),
51+
sa.Column('total_count', sa.Integer(), nullable=True),
52+
sa.Column('last_round', sa.Integer(), nullable=True),
53+
sa.Column('last_date', sa.Date(), nullable=True),
54+
sa.Column('created_at', sa.DateTime(), nullable=False),
55+
sa.Column('updated_at', sa.DateTime(), nullable=False),
56+
sa.CheckConstraint('num BETWEEN 1 AND 45', name='num_range_check'),
57+
sa.PrimaryKeyConstraint('num')
58+
)
59+
# ### end Alembic commands ###
60+
61+
62+
def downgrade() -> None:
63+
"""Downgrade schema."""
64+
# ### commands auto generated by Alembic - please adjust! ###
65+
op.drop_table('lotto_statistics')
66+
op.drop_table('lotto_draws')
67+
# ### end Alembic commands ###

poetry.lock

Lines changed: 1 addition & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ dependencies = [
1919
"cryptography (>=45.0.5,<46.0.0)",
2020
"gunicorn (>=23.0.0,<24.0.0)",
2121
"httpx (>=0.28.1,<0.29.0)",
22-
"pyyaml (>=6.0.2,<7.0.0)"
22+
"pyyaml (>=6.0.2,<7.0.0)",
23+
"greenlet (>=3.2.3,<4.0.0)"
2324
]
2425

2526

src/lotto/__init__.py

Whitespace-only changes.

src/lotto/entities/enums.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from enum import Enum
2+
3+
4+
class SortType(str, Enum):
5+
FREQUENCY = "frequency" # 빈도순
6+
NUMBER = "number" # 번호순

src/lotto/entities/models.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from sqlalchemy import Column, Integer, Date, CheckConstraint, BigInteger
2+
3+
from src.config.database import Base
4+
5+
6+
class LottoStatistics(Base):
7+
__tablename__ = "lotto_statistics"
8+
9+
num = Column(Integer, primary_key=True, autoincrement=False)
10+
main_count = Column(Integer, default=0) # 메인 번호로 나온 횟수
11+
bonus_count = Column(Integer, default=0) # 보너스 번호로 나온 횟수
12+
total_count = Column(Integer, default=0) # 전체 출현 횟수
13+
last_round = Column(Integer) # 마지막 출현 회차
14+
last_date = Column(Date) # 마지막 출현 날짜
15+
16+
__table_args__ = (CheckConstraint("num BETWEEN 1 AND 45", name="num_range_check"),)
17+
18+
19+
class LottoDraws(Base):
20+
__tablename__ = "lotto_draws"
21+
22+
round = Column(Integer, primary_key=True, autoincrement=False)
23+
draw_date = Column(Date, nullable=False)
24+
num1 = Column(Integer, nullable=False)
25+
num2 = Column(Integer, nullable=False)
26+
num3 = Column(Integer, nullable=False)
27+
num4 = Column(Integer, nullable=False)
28+
num5 = Column(Integer, nullable=False)
29+
num6 = Column(Integer, nullable=False)
30+
bonus_num = Column(Integer, nullable=False)
31+
first_prize_amount = Column(BigInteger, nullable=False)
32+
total_winners = Column(Integer, nullable=False)
33+
34+
__table_args__ = tuple(
35+
CheckConstraint(f"{col} BETWEEN 1 AND 45", name=f"{col}_range_check")
36+
for col in ["num1", "num2", "num3", "num4", "num5", "num6", "bonus_num"]
37+
)

0 commit comments

Comments
 (0)