Skip to content

Commit 804bc4a

Browse files
committed
feat(cert): add certificate authenticity check
1 parent 4b556c5 commit 804bc4a

File tree

5 files changed

+481
-163
lines changed

5 files changed

+481
-163
lines changed

โ€Ž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="CERT-2026DC", 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="CERT-2026DC", 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: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
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

@@ -107,3 +113,30 @@ async def verify_certificate(file: UploadFile = File(...)):
107113
import logging
108114
logging.error(f"๊ฒ€์ฆ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: {e}")
109115
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

โ€Žcert/backend/src/services/certificate_service.pyโ€Ž

Lines changed: 45 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ async def verify_certificate(file_bytes: bytes) -> dict:
133133
"debug_text": watermark_text
134134
}
135135

136-
# ์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ ์ถ”์ถœ (PSEUDOLAB_CERT-XXXX ํฌ๋งท ๊ธฐ๋Œ€)
136+
# ์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ ์ถ”์ถœ (CERT-2026XX ํฌ๋งท ๊ธฐ๋Œ€)
137137
cert_number = ""
138138
if "_" in watermark_text:
139139
cert_number = watermark_text.split("_")[1]
@@ -155,28 +155,7 @@ async def verify_certificate(file_bytes: bytes) -> dict:
155155
"valid": False,
156156
"message": f"์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ({cert_number})์— ํ•ด๋‹นํ•˜๋Š” ๋ฐœ๊ธ‰ ๊ธฐ๋ก์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."
157157
}
158-
159-
# Notion ๊ฒฐ๊ณผ ํŒŒ์‹ฑ
160-
props = cert_page.get("properties", {})
161-
162-
name = props.get("Name", {}).get("title", [{}])[0].get("plain_text", "์•Œ ์ˆ˜ ์—†์Œ")
163-
course = props.get("Course Name", {}).get("rich_text", [{}])[0].get("plain_text", "์•Œ ์ˆ˜ ์—†์Œ")
164-
season = props.get("Season", {}).get("select", {}).get("name", "์•Œ ์ˆ˜ ์—†์Œ")
165-
issue_date = props.get("Issue Date", {}).get("date", {}).get("start", "์•Œ ์ˆ˜ ์—†์Œ")
166-
status = props.get("Certificate Status", {}).get("status", {}).get("name", "์•Œ ์ˆ˜ ์—†์Œ")
167-
168-
return {
169-
"valid": True,
170-
"message": "์ˆ˜๋ฃŒ์ฆ ์ง„์œ„ ํ™•์ธ์— ์„ฑ๊ณตํ–ˆ์Šต๋‹ˆ๋‹ค.",
171-
"data": {
172-
"name": name,
173-
"course": course,
174-
"season": season,
175-
"issue_date": issue_date,
176-
"certificate_number": cert_number,
177-
"status": status
178-
}
179-
}
158+
return CertificateService._build_verification_result(cert_page, cert_number)
180159

181160
except Exception as e:
182161
if "์›Œํ„ฐ๋งˆํฌ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค" in str(e):
@@ -187,6 +166,49 @@ async def verify_certificate(file_bytes: bytes) -> dict:
187166
"valid": False,
188167
"message": "์ˆ˜๋ฃŒ์ฆ ๊ฒ€์ฆ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."
189168
}
169+
170+
@staticmethod
171+
async def verify_certificate_by_number(certificate_number: str) -> dict:
172+
"""์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ๋กœ ์ˆ˜๋ฃŒ ์—ฌ๋ถ€ ํ™•์ธ"""
173+
try:
174+
notion_client = NotionClient()
175+
cert_page = await notion_client.get_certificate_by_number(certificate_number)
176+
177+
if not cert_page:
178+
return {
179+
"valid": False,
180+
"message": f"์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ({certificate_number})์— ํ•ด๋‹นํ•˜๋Š” ๋ฐœ๊ธ‰ ๊ธฐ๋ก์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."
181+
}
182+
183+
return CertificateService._build_verification_result(cert_page, certificate_number)
184+
except Exception:
185+
logger.exception("์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ ํ™•์ธ ์ค‘ ์˜ค๋ฅ˜")
186+
return {
187+
"valid": False,
188+
"message": "์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ ํ™•์ธ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."
189+
}
190+
191+
@staticmethod
192+
def _build_verification_result(cert_page: dict, certificate_number: str) -> dict:
193+
"""Notion ์ˆ˜๋ฃŒ์ฆ ํŽ˜์ด์ง€ ์‘๋‹ต ํฌ๋งทํŒ…"""
194+
props = cert_page.get("properties", {})
195+
196+
name = props.get("Name", {}).get("title", [{}])[0].get("plain_text", "์•Œ ์ˆ˜ ์—†์Œ")
197+
course = props.get("Course Name", {}).get("rich_text", [{}])[0].get("plain_text", "์•Œ ์ˆ˜ ์—†์Œ")
198+
season = props.get("Season", {}).get("select", {}).get("name", "์•Œ ์ˆ˜ ์—†์Œ")
199+
issue_date = props.get("Issue Date", {}).get("date", {}).get("start", "์•Œ ์ˆ˜ ์—†์Œ")
200+
status = props.get("Certificate Status", {}).get("status", {}).get("name", "์•Œ ์ˆ˜ ์—†์Œ")
201+
202+
return {
203+
"valid": True,
204+
"message": "์ˆ˜๋ฃŒ์ฆ ํ™•์ธ์— ์„ฑ๊ณตํ–ˆ์Šต๋‹ˆ๋‹ค.",
205+
"data": {
206+
"name": name,
207+
"course": course,
208+
"season": season,
209+
"issue_date": issue_date
210+
}
211+
}
190212

191213
@staticmethod
192214
async def _reissue_certificate(

โ€Žcert/docs/api.mdโ€Ž

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,61 @@ URL : https://cert.pseudolab-devfactory/api/certs/create
101101
"message": "๋ฐœ๊ธ‰ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."
102102
}
103103
}
104-
```
104+
```
105+
106+
---
107+
108+
## ์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ ํ™•์ธ
109+
์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ๋กœ ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.
110+
111+
HTTP Method : POST
112+
URL : https://cert.pseudolab-devfactory/api/certs/verify-by-number
113+
114+
### Request
115+
#### Header
116+
| ์ด๋ฆ„ | ๋‚ด์šฉ | ํ•„์ˆ˜ |
117+
|--------|----------------|:--:|
118+
| Content-Type | `application/json` | O |
119+
120+
#### Body
121+
| ์ด๋ฆ„ | ํƒ€์ž… | ์„ค๋ช… | ํ•„์ˆ˜ |
122+
|---|---|---|:--:|
123+
| certificate_number | String | ์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ | O |
124+
125+
### Response
126+
#### ์„ฑ๊ณต/์‹คํŒจ ๊ณตํ†ต
127+
| ์ด๋ฆ„ | ํƒ€์ž… | ์„ค๋ช… |
128+
|---|---|---|
129+
| valid | Boolean | ์ˆ˜๋ฃŒ ์—ฌ๋ถ€ |
130+
| message | String | ๊ฒฐ๊ณผ ๋ฉ”์„ธ์ง€ |
131+
| data | Object | ์ˆ˜๋ฃŒ์ฆ ์ƒ์„ธ (valid=true์ผ ๋•Œ) |
132+
133+
### Example
134+
#### Request
135+
```json
136+
{
137+
"certificate_number": "CERT-202418"
138+
}
139+
```
140+
141+
#### ์„ฑ๊ณต ์‘๋‹ต
142+
```json
143+
{
144+
"valid": true,
145+
"message": "์ˆ˜๋ฃŒ์ฆ ํ™•์ธ์— ์„ฑ๊ณตํ–ˆ์Šต๋‹ˆ๋‹ค.",
146+
"data": {
147+
"name": "ํ™๊ธธ๋™",
148+
"course": "Wrapping Up Pseudolab",
149+
"season": "10๊ธฐ",
150+
"issue_date": "2024-01-15"
151+
}
152+
}
153+
```
154+
155+
#### ์‹คํŒจ ์‘๋‹ต (๋ฐœ๊ธ‰ ๊ธฐ๋ก ์—†์Œ)
156+
```json
157+
{
158+
"valid": false,
159+
"message": "์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ(CERT-202618)์— ํ•ด๋‹นํ•˜๋Š” ๋ฐœ๊ธ‰ ๊ธฐ๋ก์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."
160+
}
161+
```

0 commit comments

Comments
ย (0)