Skip to content

Commit 6184bf4

Browse files
authored
Merge pull request #318 from Pseudo-Lab/deploy/cert-develop
feat(cert): Improve certificate issuance workflow and implement DB logging
2 parents 1d1748e + 5819340 commit 6184bf4

19 files changed

Lines changed: 2833 additions & 947 deletions

โ€Ž.github/workflows/certificate-system.ymlโ€Ž

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ jobs:
3333
APP_HOST=${{ vars.APP_HOST }}
3434
ENVIRONMENT=${{ vars.ENVIRONMENT }}
3535
NODE_ENV=${{ vars.NODE_ENV }}
36+
DB_HOST=${{ vars.DB_HOST }}
37+
DB_PORT=${{ vars.DB_PORT }}
38+
DB_NAME=${{ vars.DB_NAME }}
39+
DB_USER=${{ vars.DB_USER }}
40+
DB_PASSWORD=${{ secrets.DB_PASSWORD }}
3641
CERT_TEMPLATE_ARCHIVE_PASSWORD=${{ secrets.CERT_TEMPLATE_ARCHIVE_PASSWORD }}
3742
NOTION_API_KEY=${{ secrets.NOTION_API_KEY }}
3843
NOTION_CERT_DB_ID=${{ secrets.NOTION_CERT_DB_ID }}
@@ -41,6 +46,7 @@ jobs:
4146
SMTP_PORT=${{ vars.SMTP_PORT }}
4247
SMTP_USERNAME=${{ secrets.SMTP_USERNAME }}
4348
SMTP_PASSWORD=${{ secrets.SMTP_PASSWORD }}
49+
ACCESS_LOGGING_IP_SALT=${{ secrets.ACCESS_LOGGING_IP_SALT }}
4450
CORS_ORIGINS=${{ vars.CORS_ORIGINS }}
4551
FRONTEND_EXTERNAL_API_URL=${{ vars.FRONTEND_EXTERNAL_API_URL }}
4652
EOF
@@ -70,6 +76,11 @@ jobs:
7076
APP_HOST=${{ vars.APP_HOST }}
7177
ENVIRONMENT=${{ vars.ENVIRONMENT }}
7278
NODE_ENV=${{ vars.NODE_ENV }}
79+
DB_HOST=${{ vars.DB_HOST }}
80+
DB_PORT=${{ vars.DB_PORT }}
81+
DB_NAME=${{ vars.DB_NAME }}
82+
DB_USER=${{ vars.DB_USER }}
83+
DB_PASSWORD=${{ secrets.DB_PASSWORD }}
7384
CERT_TEMPLATE_ARCHIVE_PASSWORD=${{ secrets.CERT_TEMPLATE_ARCHIVE_PASSWORD }}
7485
NOTION_API_KEY=${{ secrets.NOTION_API_KEY }}
7586
NOTION_CERT_DB_ID=${{ secrets.NOTION_CERT_DB_ID }}
@@ -78,6 +89,7 @@ jobs:
7889
SMTP_PORT=${{ vars.SMTP_PORT }}
7990
SMTP_USERNAME=${{ secrets.SMTP_USERNAME }}
8091
SMTP_PASSWORD=${{ secrets.SMTP_PASSWORD }}
92+
ACCESS_LOGGING_IP_SALT=${{ secrets.ACCESS_LOGGING_IP_SALT }}
8193
CORS_ORIGINS=${{ vars.CORS_ORIGINS }}
8294
FRONTEND_EXTERNAL_API_URL=${{ vars.FRONTEND_EXTERNAL_API_URL }}
8395
EOF

โ€Žcert/.env.exampleโ€Ž

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,14 @@ APP_HOST=example.com
1616
ENVIRONMENT=dev
1717
NODE_ENV=development
1818

19-
CERT_TEMPLATE_ARCHIVE_PASSWORD=your_secure_password
19+
CERT_TEMPLATE_ARCHIVE_PASSWORD=your_secure_password
20+
21+
# DB configuration
22+
DB_HOST=your_db_host
23+
DB_PORT=your_db_port
24+
DB_NAME=your_db_name
25+
DB_USER=your_db_user
26+
DB_PASSWORD=your_db_password
27+
ACCESS_LOGGING_ENABLED=true
28+
ACCESS_LOGGING_EXCLUDE_PATHS=/health,/api/certs/all-projects,/api/log/pageview
29+
ACCESS_LOGGING_IP_SALT=your_ip_salt

โ€Žcert/backend/Dockerfileโ€Ž

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,31 @@
11
# Use an official Python runtime as a parent image
22
FROM python:3.11-slim
33

4+
# ๊ธฐ๋ณธ Python ๋Ÿฐํƒ€์ž„ ํ™˜๊ฒฝ ๋ณ€์ˆ˜
5+
ENV PYTHONDONTWRITEBYTECODE=1 \
6+
PYTHONUNBUFFERED=1 \
7+
TZ=Asia/Seoul \
8+
VIRTUAL_ENV=/app/.venv \
9+
PATH="/app/.venv/bin:${PATH}"
10+
411
# Set the working directory in the container
512
WORKDIR /app
613

714
# Install tzdata and set timezone to Asia/Seoul
815
RUN apt-get update && \
9-
apt-get install -y --no-install-recommends tzdata && \
16+
apt-get install -y --no-install-recommends tzdata libgl1 libglib2.0-0 && \
1017
ln -snf /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \
1118
echo "Asia/Seoul" > /etc/timezone && \
1219
apt-get clean && rm -rf /var/lib/apt/lists/*
1320

14-
ENV TZ=Asia/Seoul
21+
# install uv for improved package management
22+
RUN pip install --no-cache-dir "uv>=0.5.0"
1523

16-
# Copy the dependency files
17-
COPY requirements.txt .
18-
COPY uv.lock .
24+
# Copy only the dependency files to leverage Docker cache
25+
COPY pyproject.toml uv.lock ./
1926

20-
# Install any needed packages specified in requirements.txt
21-
# Using uv pip install for faster and more reliable dependency management
22-
RUN pip install uv && uv pip install --system -r requirements.txt
27+
# Install the dependencies into the project virtualenv
28+
RUN uv sync --frozen
2329

2430
# Copy the rest of the application code
2531
COPY . .

โ€Žcert/backend/pyproject.tomlโ€Ž

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,16 @@ readme = "README.md"
66
requires-python = ">=3.11"
77
dependencies = [
88
"aiohttp>=3.12.15",
9-
"dotenv>=0.9.9",
9+
"asyncpg>=0.30.0",
10+
"aiosmtplib>=4.0.1",
1011
"fastapi>=0.109.1",
12+
"gunicorn>=21.2.0",
13+
"invisible-watermark>=0.2.0",
14+
"opencv-python-headless>=4.11.0.86",
1115
"pillow>=10.1.0",
1216
"pydantic>=2.5.3",
1317
"pydantic-settings>=2.1.0",
18+
"pypdf>=6.5.0",
1419
"python-dotenv>=1.0.1",
1520
"python-multipart>=0.0.6",
1621
"reportlab>=4.0.7",
@@ -20,5 +25,4 @@ dependencies = [
2025

2126
[dependency-groups]
2227
dev = [
23-
"aiosmtplib>=4.0.1",
2428
]

โ€Žcert/backend/requirements.txtโ€Ž

Lines changed: 0 additions & 8 deletions
This file was deleted.

โ€Žcert/backend/src/main.pyโ€Ž

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from fastapi.middleware.cors import CORSMiddleware
77

88
from .routers import certificate
9+
from .routers.logging import logging_router
10+
from .utils.access_log import access_log_middleware, close_access_log, init_access_log
911

1012

1113
def configure_logging() -> None:
@@ -35,6 +37,9 @@ def configure_logging() -> None:
3537
openapi_url=None if hide_docs else "/openapi.json",
3638
)
3739

40+
# Access log middleware
41+
app.middleware("http")(access_log_middleware)
42+
3843
# CORS ๋ฏธ๋“ค์›จ์–ด ์„ค์ •
3944
origins = os.getenv("CORS_ORIGINS", "").split(",")
4045
app.add_middleware(
@@ -47,6 +52,7 @@ def configure_logging() -> None:
4752

4853
# ๋ชจ๋“  ํ™˜๊ฒฝ์—์„œ /api ํ”„๋ฆฌํ”ฝ์Šค ์‚ฌ์šฉ (๊ฐœ๋ฐœ/ํ”„๋กœ๋•์…˜ ํ†ต์ผ)
4954
app.include_router(certificate.certificate_router, prefix="/api")
55+
app.include_router(logging_router, prefix="/api")
5056
logger.info("FastAPI app initialized", extra={"environment": os.getenv("ENVIRONMENT")})
5157

5258

@@ -64,3 +70,13 @@ async def read_root():
6470
async def health_check():
6571
"""ํ—ฌ์Šค ์ฒดํฌ ์—”๋“œํฌ์ธํŠธ"""
6672
return {"status": "healthy"}
73+
74+
75+
@app.on_event("startup")
76+
async def setup_access_log():
77+
await init_access_log(app)
78+
79+
80+
@app.on_event("shutdown")
81+
async def teardown_access_log():
82+
await close_access_log(app)

โ€Žcert/backend/src/models/certificate.pyโ€Ž

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class CertificateData(BaseModel):
2828
id: str = Field(..., example="2533a2a2-eed5-81fa-9921-c14d2cd117b7", description="์ˆ˜๋ฃŒ์ฆ ์‹ ์ฒญ ํŽ˜์ด์ง€ ID")
2929
name: str = Field(..., example="ํ™๊ธธ๋™", description="์‹ ์ฒญ์ž ์ด๋ฆ„")
3030
recipient_email: str = Field(..., example="hong@example.com", description="์ˆ˜๋ฃŒ์ž ์ด๋ฉ”์ผ")
31-
certificate_number: str = Field(..., example="CERT-2024-001", description="์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ")
31+
certificate_number: str = Field(..., example="A2025S10_0156", description="์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ")
3232
issue_date: str = Field(..., example="2024-01-15", description="์‹ ์ฒญ ๋‚ ์งœ")
3333
certificate_status: CertificateStatus = Field(..., example=CertificateStatus.PENDING, description="๋ฐœ๊ธ‰ ์—ฌ๋ถ€")
3434
season: int = Field(..., example=10, description="์ฐธ์—ฌ ๊ธฐ์ˆ˜")
@@ -42,6 +42,26 @@ class CertificateResponse(BaseModel):
4242
data: Optional[CertificateData] = Field(None, description="์ˆ˜๋ฃŒ์ฆ ๋ฐ์ดํ„ฐ")
4343

4444

45+
class CertificateVerifyRequest(BaseModel):
46+
"""์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ ํ™•์ธ ์š”์ฒญ ๋ชจ๋ธ"""
47+
certificate_number: str = Field(..., example="A2025S10_0156", description="์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ")
48+
49+
50+
class CertificateVerifyData(BaseModel):
51+
"""์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ ํ™•์ธ ๋ฐ์ดํ„ฐ"""
52+
name: str = Field(..., example="ํ™๊ธธ๋™", description="์‹ ์ฒญ์ž ์ด๋ฆ„")
53+
course: str = Field(..., example="Wrapping Up Pseudolab", description="์Šคํ„ฐ๋””๋ช…")
54+
season: str = Field(..., example="10๊ธฐ", description="์ฐธ์—ฌ ๊ธฐ์ˆ˜")
55+
issue_date: str = Field(..., example="2024-01-15", description="๋ฐœ๊ธ‰์ผ")
56+
57+
58+
class CertificateVerifyResponse(BaseModel):
59+
"""์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ ํ™•์ธ ์‘๋‹ต"""
60+
valid: bool = Field(..., example=True, description="ํ™•์ธ ์—ฌ๋ถ€")
61+
message: str = Field(..., example="์ˆ˜๋ฃŒ์ฆ ํ™•์ธ์— ์„ฑ๊ณตํ–ˆ์Šต๋‹ˆ๋‹ค.", description="๊ฒฐ๊ณผ ๋ฉ”์‹œ์ง€")
62+
data: Optional[CertificateVerifyData] = Field(None, description="์ˆ˜๋ฃŒ์ฆ ์ •๋ณด")
63+
64+
4565
class ErrorResponse(BaseModel):
4666
"""์—๋Ÿฌ ์‘๋‹ต ๋ชจ๋ธ"""
4767
status: str = Field(..., example="fail", description="์‘๋‹ต ์ƒํƒœ")

โ€Žcert/backend/src/routers/certificate.pyโ€Ž

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1-
from fastapi import APIRouter, HTTPException, Response
1+
from fastapi import APIRouter, HTTPException, Response, File, UploadFile
22

33
from ..models.project import Project, ProjectsBySeasonResponse
4-
from ..models.certificate import CertificateCreate, CertificateResponse, ErrorResponse
4+
from ..models.certificate import (
5+
CertificateCreate,
6+
CertificateResponse,
7+
CertificateVerifyRequest,
8+
CertificateVerifyResponse,
9+
ErrorResponse,
10+
)
511
from ..services.certificate_service import CertificateService, ProjectService
612
from ..constants.error_codes import ResponseStatus
713

@@ -91,3 +97,46 @@ async def clear_cache():
9197
"""์บ์‹œ ์‚ญ์ œ"""
9298
ProjectService.clear_cache()
9399
return {"message": "์บ์‹œ ์‚ญ์ œ ์™„๋ฃŒ"}
100+
101+
@certificate_router.post("/verify")
102+
async def verify_certificate(file: UploadFile = File(...)):
103+
"""์ˆ˜๋ฃŒ์ฆ์˜ ์ง„์œ„ ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค."""
104+
# ํŒŒ์ผ ํ™•์žฅ์ž ์ฒดํฌ (PDF๋งŒ ํ—ˆ์šฉ)
105+
if not file.filename.lower().endswith('.pdf'):
106+
raise HTTPException(status_code=400, detail="PDF ํŒŒ์ผ๋งŒ ์—…๋กœ๋“œ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.")
107+
108+
try:
109+
file_bytes = await file.read()
110+
result = await CertificateService.verify_certificate(file_bytes)
111+
return result
112+
except Exception as e:
113+
import logging
114+
logging.error(f"๊ฒ€์ฆ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: {e}")
115+
raise HTTPException(status_code=500, detail="ํŒŒ์ผ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.")
116+
117+
@certificate_router.post(
118+
"/verify-by-number",
119+
response_model=CertificateVerifyResponse,
120+
responses={
121+
200: {
122+
"description": "์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ ํ™•์ธ ์„ฑ๊ณต/์‹คํŒจ",
123+
"model": CertificateVerifyResponse
124+
},
125+
400: {
126+
"description": "์ž˜๋ชป๋œ ์š”์ฒญ",
127+
"model": ErrorResponse
128+
},
129+
500: {
130+
"description": "์„œ๋ฒ„ ๋‚ด๋ถ€ ์˜ค๋ฅ˜",
131+
"model": ErrorResponse
132+
}
133+
}
134+
)
135+
async def verify_certificate_by_number(payload: CertificateVerifyRequest):
136+
"""์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ๋กœ ์ˆ˜๋ฃŒ ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค."""
137+
certificate_number = payload.certificate_number.strip()
138+
if not certificate_number:
139+
raise HTTPException(status_code=400, detail="์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.")
140+
141+
result = await CertificateService.verify_certificate_by_number(certificate_number)
142+
return result
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from fastapi import APIRouter, HTTPException, Request
2+
from pydantic import BaseModel
3+
4+
from ..utils.access_log import log_page_view
5+
6+
7+
logging_router = APIRouter(prefix="/log", tags=["log"])
8+
9+
10+
class PageViewRequest(BaseModel):
11+
path: str
12+
13+
14+
@logging_router.post("/pageview")
15+
async def track_page_view(payload: PageViewRequest, request: Request):
16+
path = payload.path.strip()
17+
if not path.startswith("/"):
18+
raise HTTPException(status_code=400, detail="path must start with '/'")
19+
20+
await log_page_view(request, path)
21+
return {"status": "ok"}

0 commit comments

Comments
ย (0)