Skip to content

Commit eda2b02

Browse files
authored
Merge pull request #294 from Pseudo-Lab/feat/cert-watermark-verification
Feat/cert watermark verification
2 parents 7900a9f + 27c0771 commit eda2b02

File tree

7 files changed

+845
-10
lines changed

7 files changed

+845
-10
lines changed

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ dependencies = [
88
"aiohttp>=3.12.15",
99
"dotenv>=0.9.9",
1010
"fastapi>=0.109.1",
11+
"invisible-watermark>=0.2.0",
12+
"opencv-python>=4.11.0.86",
1113
"pillow>=10.1.0",
1214
"pydantic>=2.5.3",
1315
"pydantic-settings>=2.1.0",
16+
"pypdf>=6.5.0",
1417
"python-dotenv>=1.0.1",
1518
"python-multipart>=0.0.6",
1619
"reportlab>=4.0.7",

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from fastapi import APIRouter, HTTPException, Response
1+
from fastapi import APIRouter, HTTPException, Response, File, UploadFile
22

33
from ..models.project import Project, ProjectsBySeasonResponse
44
from ..models.certificate import CertificateCreate, CertificateResponse, ErrorResponse
@@ -91,3 +91,19 @@ async def clear_cache():
9191
"""์บ์‹œ ์‚ญ์ œ"""
9292
ProjectService.clear_cache()
9393
return {"message": "์บ์‹œ ์‚ญ์ œ ์™„๋ฃŒ"}
94+
95+
@certificate_router.post("/verify")
96+
async def verify_certificate(file: UploadFile = File(...)):
97+
"""์ˆ˜๋ฃŒ์ฆ์˜ ์ง„์œ„ ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค."""
98+
# ํŒŒ์ผ ํ™•์žฅ์ž ์ฒดํฌ (PDF๋งŒ ํ—ˆ์šฉ)
99+
if not file.filename.lower().endswith('.pdf'):
100+
raise HTTPException(status_code=400, detail="PDF ํŒŒ์ผ๋งŒ ์—…๋กœ๋“œ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.")
101+
102+
try:
103+
file_bytes = await file.read()
104+
result = await CertificateService.verify_certificate(file_bytes)
105+
return result
106+
except Exception as e:
107+
import logging
108+
logging.error(f"๊ฒ€์ฆ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: {e}")
109+
raise HTTPException(status_code=500, detail="ํŒŒ์ผ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.")

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

Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,82 @@ async def create_certificate(certificate_data: dict) -> CertificateResponse:
111111
message="์ˆ˜๋ฃŒ์ฆ ๋ฐœ๊ธ‰์„ ์™„๋ฃŒํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ๊ด€๋ฆฌ์ž์—๊ฒŒ ๋ฌธ์˜ํ•ด์ฃผ์„ธ์š”.",
112112
data=None,
113113
)
114+
115+
@staticmethod
116+
async def verify_certificate(file_bytes: bytes) -> dict:
117+
"""์ˆ˜๋ฃŒ์ฆ ๊ฒ€์ฆ"""
118+
try:
119+
pdf_generator = PDFGenerator()
120+
watermark_text = pdf_generator.extract_watermark_from_pdf(file_bytes)
121+
122+
if not watermark_text:
123+
return {
124+
"valid": False,
125+
"message": "์ˆ˜๋ฃŒ์ฆ์—์„œ ์›Œํ„ฐ๋งˆํฌ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."
126+
}
127+
128+
# ๊ธฐ๋ณธ ๊ฒ€์ฆ (PSEUDOLAB ์ ‘๋‘์‚ฌ ํ™•์ธ)
129+
if not watermark_text.startswith("PSEUDOLAB"):
130+
return {
131+
"valid": False,
132+
"message": "์œ ํšจํ•˜์ง€ ์•Š์€ ์ˆ˜๋ฃŒ์ฆ ์›Œํ„ฐ๋งˆํฌ์ž…๋‹ˆ๋‹ค.",
133+
"debug_text": watermark_text
134+
}
135+
136+
# ์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ ์ถ”์ถœ (PSEUDOLAB_CERT-XXXX ํฌ๋งท ๊ธฐ๋Œ€)
137+
cert_number = ""
138+
if "_" in watermark_text:
139+
cert_number = watermark_text.split("_")[1]
140+
141+
# ๋ฒˆํ˜ธ๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ (ํ…Œ์ŠคํŠธ์šฉ ๋“ฑ)
142+
if not cert_number:
143+
return {
144+
"valid": True,
145+
"message": "์›Œํ„ฐ๋งˆํฌ๊ฐ€ ํ™•์ธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค (ํ…Œ์ŠคํŠธ์šฉ/๋ฒˆํ˜ธ์—†์Œ).",
146+
"watermark_text": watermark_text
147+
}
148+
149+
# Notion ์‹ค๋ฐ์ดํ„ฐ ์กฐํšŒ
150+
notion_client = NotionClient()
151+
cert_page = await notion_client.get_certificate_by_number(cert_number)
152+
153+
if not cert_page:
154+
return {
155+
"valid": False,
156+
"message": f"์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ({cert_number})์— ํ•ด๋‹นํ•˜๋Š” ๋ฐœ๊ธ‰ ๊ธฐ๋ก์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."
157+
}
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+
}
180+
181+
except Exception as e:
182+
if "์›Œํ„ฐ๋งˆํฌ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค" in str(e):
183+
logger.info(f"์ˆ˜๋ฃŒ์ฆ ๊ฒ€์ฆ ์‹คํŒจ (์›Œํ„ฐ๋งˆํฌ ์—†์Œ): {str(e)}")
184+
else:
185+
logger.exception("์ˆ˜๋ฃŒ์ฆ ๊ฒ€์ฆ ์ค‘ ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์˜ค๋ฅ˜")
186+
return {
187+
"valid": False,
188+
"message": "์ˆ˜๋ฃŒ์ฆ ๊ฒ€์ฆ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."
189+
}
114190

115191
@staticmethod
116192
async def _reissue_certificate(
@@ -147,7 +223,26 @@ async def _reissue_certificate(
147223
"๊ธฐ์กด ์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ ์—†์Œ. ์ƒˆ๋กœ ์ƒ์„ฑ",
148224
extra={"applicant_name": certificate_data["applicant_name"]},
149225
)
150-
existing_cert_number = f"CERT-{datetime.now().year}{participation_info['project_code']}{str(uuid.uuid4())[:2].upper()}"
226+
# ๊ธฐ์กด ์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ๊ฐ€ ์—†์œผ๋ฉด DB ID(Unique ID) ๊ธฐ๋ฐ˜์œผ๋กœ ์ƒ์„ฑ
227+
existing_data = existing_cert.get("existing_data", {})
228+
properties = existing_data.get("properties", {})
229+
230+
# ๋กœ๊ทธ์—์„œ ํ™•์ธ๋œ ๊ตฌ์กฐ: properties['ID']['unique_id']['number']
231+
id_prop = properties.get("ID", {})
232+
unique_identifier = None
233+
234+
if id_prop.get("type") == "unique_id":
235+
unique_identifier = str(id_prop.get("unique_id", {}).get("number"))
236+
237+
if not unique_identifier:
238+
# fallback: ํ˜น์‹œ ID ์ปฌ๋Ÿผ๋ช…์ด ๋‹ค๋ฅผ ๊ฒฝ์šฐ๋ฅผ ๋Œ€๋น„ํ•ด ๊ธฐ์กด ๋ฐฉ์‹ ์œ ์ง€
239+
for prop_val in properties.values():
240+
if prop_val.get("type") == "unique_id":
241+
unique_identifier = str(prop_val.get("unique_id", {}).get("number"))
242+
break
243+
244+
245+
existing_cert_number = f"CERT-{datetime.now().year}{participation_info['project_code']}{unique_identifier.upper()}"
151246
logger.info(
152247
"์ƒˆ๋กœ์šด ์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ ์ƒ์„ฑ",
153248
extra={"certificate_number": existing_cert_number},
@@ -170,14 +265,17 @@ async def _reissue_certificate(
170265

171266
# ์ด๋ฉ”์ผ ์žฌ๋ฐœ์†ก
172267
email_sender = EmailSender()
173-
await email_sender.send_certificate_email(
268+
email_sent = await email_sender.send_certificate_email(
174269
recipient_email=certificate_data["recipient_email"],
175270
recipient_name=certificate_data["applicant_name"],
176271
course_name=certificate_data["course_name"],
177272
season=certificate_data["season"],
178273
role=participation_info["user_role"],
179274
certificate_bytes=pdf_bytes
180275
)
276+
277+
if not email_sent:
278+
raise Exception("์žฌ๋ฐœ๊ธ‰ ์ด๋ฉ”์ผ ๋ฐœ์†ก ์‹คํŒจ")
181279

182280
# ์žฌ๋ฐœ๊ธ‰ ๋กœ๊ทธ ๊ธฐ๋ก
183281
reissue_log = await notion_client.log_certificate_reissue(
@@ -258,8 +356,27 @@ async def _create_new_certificate(
258356
season=certificate_data["season"]
259357
)
260358

261-
# TODO: ์ž„์‹œ ๊ฐ’, ์ถ”ํ›„ ์ˆ˜์ • ํ•„์š”
262-
certificate_number = f"CERT-{datetime.now().year}{participation_info['project_code']}{str(uuid.uuid4())[:2].upper()}"
359+
# DB ID(Unique ID)๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ ์ƒ์„ฑ
360+
properties = certificate_request.get("properties", {})
361+
unique_identifier = None
362+
363+
# ๋กœ๊ทธ์—์„œ ํ™•์ธ๋œ ๊ตฌ์กฐ: properties['ID']['unique_id']['number']
364+
id_prop = properties.get("ID", {})
365+
if id_prop.get("type") == "unique_id":
366+
unique_identifier = str(id_prop.get("unique_id", {}).get("number"))
367+
368+
if not unique_identifier:
369+
# fallback: ํ˜น์‹œ ID ์ปฌ๋Ÿผ๋ช…์ด ๋‹ค๋ฅผ ๊ฒฝ์šฐ๋ฅผ ๋Œ€๋น„ํ•ด ์ˆœํšŒ ๊ฒ€์ƒ‰
370+
for prop_val in properties.values():
371+
if prop_val.get("type") == "unique_id":
372+
unique_identifier = str(prop_val.get("unique_id", {}).get("number"))
373+
break
374+
375+
if not unique_identifier:
376+
# fallback: Page ID์˜ ๋งˆ์ง€๋ง‰ 5์ž๋ฆฌ
377+
unique_identifier = request_id.replace("-", "")[-5:]
378+
379+
certificate_number = f"CERT-{datetime.now().year}{participation_info['project_code']}{unique_identifier.upper()}"
263380
issue_date = datetime.now().strftime("%Y-%m-%d")
264381

265382
# PDF ์ˆ˜๋ฃŒ์ฆ ์ƒ์„ฑ
@@ -275,14 +392,17 @@ async def _create_new_certificate(
275392
)
276393
# ์ด๋ฉ”์ผ ๋ฐœ์†ก
277394
email_sender = EmailSender()
278-
await email_sender.send_certificate_email(
395+
email_sent = await email_sender.send_certificate_email(
279396
recipient_email=certificate_data["recipient_email"],
280397
recipient_name=certificate_data["applicant_name"],
281398
course_name=certificate_data["course_name"],
282399
season=certificate_data["season"],
283400
role=participation_info["user_role"],
284401
certificate_bytes=pdf_bytes
285402
)
403+
404+
if not email_sent:
405+
raise Exception("์ด๋ฉ”์ผ ๋ฐœ์†ก ์‹คํŒจ")
286406

287407
# ์ˆ˜๋ฃŒ์ฆ ์ƒํƒœ ์—…๋ฐ์ดํŠธ
288408
logger.info(

โ€Žcert/backend/src/utils/email_sender.pyโ€Ž

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,13 @@ async def send_certificate_email(
8686
)
8787

8888
# ์ด๋ฉ”์ผ ๋ฐœ์†ก
89+
# Port 465(SSL)๋Š” use_tls=True, Port 587(STARTTLS)์€ use_tls=False ํ•„์š”
90+
use_tls = (self.smtp_port == 465)
91+
8992
async with aiosmtplib.SMTP(
9093
hostname=self.smtp_host,
9194
port=self.smtp_port,
92-
use_tls=False # ํฌํŠธ 587์€ STARTTLS ์‚ฌ์šฉ
95+
use_tls=use_tls
9396
) as smtp:
9497
# STARTTLS๋Š” aiosmtplib๊ฐ€ ์ž๋™์œผ๋กœ ์ฒ˜๋ฆฌ
9598
await smtp.login(self.smtp_username, self.smtp_password)

โ€Žcert/backend/src/utils/notion_client.pyโ€Ž

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,40 @@ async def check_existing_certificate(
873873
logger.exception("๊ธฐ์กด ์ˆ˜๋ฃŒ์ฆ ํ™•์ธ ์ค‘ ์˜ค๋ฅ˜")
874874
return None
875875

876+
async def get_certificate_by_number(self, certificate_number: str) -> Optional[Dict[str, Any]]:
877+
"""์ˆ˜๋ฃŒ์ฆ ๋ฒˆํ˜ธ๋กœ ์ˆ˜๋ฃŒ์ฆ ์ •๋ณด ์กฐํšŒ"""
878+
db_id = self.databases.get("certificate_requests")
879+
if not db_id:
880+
logger.error("์ˆ˜๋ฃŒ์ฆ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ID๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.")
881+
return None
882+
883+
url = f"{self.base_url}/databases/{db_id}/query"
884+
payload = {
885+
"filter": {
886+
"property": "Certificate Number",
887+
"rich_text": {
888+
"equals": certificate_number
889+
}
890+
}
891+
}
892+
893+
try:
894+
async with aiohttp.ClientSession() as session:
895+
async with session.post(url, headers=self.headers, json=payload) as response:
896+
if response.status == 200:
897+
data = await response.json()
898+
results = data.get("results", [])
899+
if results:
900+
return results[0]
901+
return None
902+
else:
903+
error_text = await response.text()
904+
logger.error(f"Notion ์ˆ˜๋ฃŒ์ฆ ์กฐํšŒ ์‹คํŒจ: {response.status}, {error_text}")
905+
return None
906+
except Exception:
907+
logger.exception("Notion ์ˆ˜๋ฃŒ์ฆ ์กฐํšŒ ์ค‘ ์˜ˆ์™ธ ๋ฐœ์ƒ")
908+
return None
909+
876910
async def get_database_structure(self, database_type: str = "project_history") -> Optional[Dict[str, Any]]:
877911
"""๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๊ตฌ์กฐ ์กฐํšŒ (๋””๋ฒ„๊น…์šฉ)"""
878912
try:

0 commit comments

Comments
ย (0)