Initial certificate system
This commit is contained in:
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
54
backend/app/services/captcha.py
Normal file
54
backend/app/services/captcha.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import base64
|
||||
import io
|
||||
import random
|
||||
import secrets
|
||||
import string
|
||||
import time
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
CAPTCHA_TTL_SECONDS = 300
|
||||
_captchas: dict[str, tuple[str, float]] = {}
|
||||
|
||||
|
||||
def make_captcha() -> dict[str, str]:
|
||||
clean_expired_captchas()
|
||||
captcha_id = secrets.token_urlsafe(16)
|
||||
code = "".join(random.choice(string.ascii_uppercase + string.digits) for _ in range(4))
|
||||
_captchas[captcha_id] = (code, time.time() + CAPTCHA_TTL_SECONDS)
|
||||
return {"captcha_id": captcha_id, "image": draw_captcha_image(code)}
|
||||
|
||||
|
||||
def verify_captcha(captcha_id: str, captcha_code: str) -> bool:
|
||||
clean_expired_captchas()
|
||||
record = _captchas.pop(captcha_id, None)
|
||||
if not record:
|
||||
return False
|
||||
expected, expires_at = record
|
||||
if expires_at < time.time():
|
||||
return False
|
||||
return expected.upper() == captcha_code.strip().upper()
|
||||
|
||||
|
||||
def clean_expired_captchas() -> None:
|
||||
now = time.time()
|
||||
expired = [captcha_id for captcha_id, (_, expires_at) in _captchas.items() if expires_at < now]
|
||||
for captcha_id in expired:
|
||||
_captchas.pop(captcha_id, None)
|
||||
|
||||
|
||||
def draw_captcha_image(code: str) -> str:
|
||||
width, height = 132, 44
|
||||
image = Image.new("RGB", (width, height), "#f8fafc")
|
||||
draw = ImageDraw.Draw(image)
|
||||
font = ImageFont.load_default()
|
||||
for i, char in enumerate(code):
|
||||
draw.text((18 + i * 26, 13 + random.randint(-2, 2)), char, fill="#1f2937", font=font)
|
||||
for _ in range(8):
|
||||
x1, y1 = random.randint(0, width), random.randint(0, height)
|
||||
x2, y2 = random.randint(0, width), random.randint(0, height)
|
||||
draw.line((x1, y1, x2, y2), fill="#cbd5e1", width=1)
|
||||
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
return "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii")
|
||||
30
backend/app/services/certificate_number.py
Normal file
30
backend/app/services/certificate_number.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import hashlib
|
||||
from datetime import date
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
|
||||
|
||||
def _to_base32ish(value: int, length: int) -> str:
|
||||
chars: list[str] = []
|
||||
base = len(ALPHABET)
|
||||
while value:
|
||||
value, remainder = divmod(value, base)
|
||||
chars.append(ALPHABET[remainder])
|
||||
encoded = "".join(reversed(chars)) or ALPHABET[0]
|
||||
return encoded[-length:].rjust(length, ALPHABET[0])
|
||||
|
||||
|
||||
def _digest_int(payload: str) -> int:
|
||||
digest = hashlib.sha256(payload.encode("utf-8")).digest()
|
||||
return int.from_bytes(digest[:8], "big")
|
||||
|
||||
|
||||
def build_certificate_no(sequence_id: int, project_code: str, issue_date: date | None = None) -> str:
|
||||
year = (issue_date or date.today()).year
|
||||
normalized_project = project_code.strip().upper()
|
||||
seed = f"{settings.certificate_no_secret}:{year}:{normalized_project}:{sequence_id}"
|
||||
short_code = _to_base32ish(_digest_int(seed), 7)
|
||||
check_code = _to_base32ish(_digest_int(f"check:{seed}:{short_code}"), 2)
|
||||
return f"PX{year}-{normalized_project}-{short_code}-{check_code}"
|
||||
148
backend/app/services/logs.py
Normal file
148
backend/app/services/logs.py
Normal file
@@ -0,0 +1,148 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import AdminUser, OperationLog
|
||||
|
||||
LOG_RETENTION_DAYS = 180
|
||||
HIGH_RISK_LOG_RETENTION_DAYS = 365
|
||||
|
||||
ACTION_LABELS = {
|
||||
"create_project": "\u65b0\u589e\u9879\u76ee",
|
||||
"update_project": "\u4fee\u6539\u9879\u76ee",
|
||||
"create_learner": "\u65b0\u589e\u5b66\u5458",
|
||||
"update_learner": "\u4fee\u6539\u5b66\u5458",
|
||||
"delete_learner": "\u5220\u9664\u5b66\u5458",
|
||||
"create_certificate": "\u65b0\u589e\u8bc1\u4e66",
|
||||
"void_certificate": "\u4f5c\u5e9f\u8bc1\u4e66",
|
||||
"regenerate_public_token": "\u91cd\u7f6e\u8bc1\u4e66\u94fe\u63a5",
|
||||
"upload_import_file": "\u4e0a\u4f20\u5bfc\u5165\u6587\u4ef6",
|
||||
"confirm_import_batch": "\u786e\u8ba4\u5bfc\u5165",
|
||||
"delete_import_batch": "\u5220\u9664\u5bfc\u5165\u6279\u6b21",
|
||||
"export_certificates": "\u5bfc\u51fa\u8bc1\u4e66",
|
||||
"public_search_certificate": "\u516c\u5f00\u67e5\u8be2\u8bc1\u4e66",
|
||||
"public_download_certificate": "\u516c\u5f00\u4e0b\u8f7d\u8bc1\u4e66",
|
||||
}
|
||||
OBJECT_LABELS = {
|
||||
"project": "\u9879\u76ee",
|
||||
"project_course": "\u9879\u76ee",
|
||||
"learner": "\u5b66\u5458",
|
||||
"certificate": "\u8bc1\u4e66",
|
||||
"import_batch": "\u5bfc\u5165\u6279\u6b21",
|
||||
"public_access": "\u516c\u5f00\u8bbf\u95ee",
|
||||
}
|
||||
HIGH_RISK_ACTIONS = {"void_certificate", "delete_import_batch", "regenerate_public_token", "delete_learner"}
|
||||
MEDIUM_RISK_ACTIONS = {"update_project", "update_learner", "confirm_import_batch", "create_certificate"}
|
||||
|
||||
|
||||
def log_action(
|
||||
db: Session,
|
||||
admin: AdminUser | None,
|
||||
action: str,
|
||||
object_type: str,
|
||||
object_id: int | str | None = None,
|
||||
detail: dict[str, object] | None = None,
|
||||
) -> None:
|
||||
db.add(
|
||||
OperationLog(
|
||||
admin_user_id=admin.id if admin else None,
|
||||
action=action,
|
||||
object_type=object_type,
|
||||
object_id=str(object_id) if object_id is not None else None,
|
||||
detail_json=json.dumps(detail, ensure_ascii=False, default=str) if detail else None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def mask_phone(phone: object) -> str:
|
||||
raw = "".join(ch for ch in str(phone or "") if ch.isdigit())
|
||||
if len(raw) >= 7:
|
||||
return raw[:3] + "****" + raw[-4:]
|
||||
return raw or "-"
|
||||
|
||||
|
||||
def log_risk(action: str) -> str:
|
||||
if action in HIGH_RISK_ACTIONS:
|
||||
return "high"
|
||||
if action in MEDIUM_RISK_ACTIONS:
|
||||
return "medium"
|
||||
return "low"
|
||||
|
||||
|
||||
def log_risk_text(risk: str) -> str:
|
||||
return {"high": "\u9ad8\u98ce\u9669", "medium": "\u4e2d\u98ce\u9669", "low": "\u4f4e\u98ce\u9669"}.get(risk, risk)
|
||||
|
||||
|
||||
def diff_values(before: dict[str, object], after: dict[str, object], fields: list[str]) -> dict[str, dict[str, object]]:
|
||||
changes: dict[str, dict[str, object]] = {}
|
||||
for field in fields:
|
||||
if before.get(field) != after.get(field):
|
||||
changes[field] = {"before": before.get(field), "after": after.get(field)}
|
||||
return changes
|
||||
|
||||
|
||||
def log_summary(action: str, object_type: str, object_id: object, detail: dict[str, object]) -> str:
|
||||
label = ACTION_LABELS.get(action, action)
|
||||
if action in {"create_project", "update_project"}:
|
||||
return f"{label}\uff1a{detail.get('name') or detail.get('code') or object_id}"
|
||||
if action in {"create_learner", "update_learner", "delete_learner"}:
|
||||
phone = detail.get("phone")
|
||||
return f"{label}\uff1a{detail.get('name') or object_id}" + (f"\uff08{phone}\uff09" if phone else "")
|
||||
if action in {"create_certificate", "void_certificate", "regenerate_public_token"}:
|
||||
learner_name = detail.get("learner_name")
|
||||
return f"{label}\uff1a{detail.get('certificate_no') or object_id}" + (f"\uff08{learner_name}\uff09" if learner_name else "")
|
||||
if action in {"upload_import_file", "delete_import_batch"}:
|
||||
return f"{label}\uff1a{detail.get('filename') or object_id}"
|
||||
if action == "confirm_import_batch":
|
||||
count = detail.get("imported_rows", detail.get("valid_rows", 0))
|
||||
return f"{label}\uff1a\u6279\u6b21 {object_id}\uff0c\u5bfc\u5165 {count} \u884c"
|
||||
if action == "public_search_certificate":
|
||||
mode = "\u8bc1\u4e66\u7f16\u53f7" if detail.get("mode") == "certificate_no" else "\u624b\u673a\u53f7"
|
||||
return f"{label}\uff1a{detail.get('name') or '-'} \u7528{mode}\u67e5\u8be2\uff0c\u5339\u914d {detail.get('matched_count', 0)} \u6761"
|
||||
if action == "public_download_certificate":
|
||||
return f"{label}\uff1a{detail.get('certificate_no') or object_id}\uff08{detail.get('learner_name') or '-'}\uff09"
|
||||
return f"{label}\uff1a{OBJECT_LABELS.get(object_type, object_type)} {object_id or ''}".strip()
|
||||
|
||||
|
||||
def format_log(log: OperationLog) -> dict[str, object]:
|
||||
detail: dict[str, object] = {}
|
||||
if log.detail_json:
|
||||
try:
|
||||
detail = json.loads(log.detail_json)
|
||||
except Exception:
|
||||
detail = {"raw": log.detail_json}
|
||||
risk = log_risk(log.action)
|
||||
return {
|
||||
"id": log.id,
|
||||
"admin_user_id": log.admin_user_id,
|
||||
"action": log.action,
|
||||
"action_label": ACTION_LABELS.get(log.action, log.action),
|
||||
"object_type": log.object_type,
|
||||
"object_label": OBJECT_LABELS.get(log.object_type, log.object_type),
|
||||
"object_id": log.object_id,
|
||||
"ip_address": log.ip_address,
|
||||
"detail_json": log.detail_json,
|
||||
"created_at": log.created_at,
|
||||
"risk": risk,
|
||||
"risk_label": log_risk_text(risk),
|
||||
"summary": log_summary(log.action, log.object_type, log.object_id, detail),
|
||||
}
|
||||
|
||||
|
||||
def cleanup_expired_logs(db: Session) -> int:
|
||||
normal_before = datetime.utcnow() - timedelta(days=LOG_RETENTION_DAYS)
|
||||
high_before = datetime.utcnow() - timedelta(days=HIGH_RISK_LOG_RETENTION_DAYS)
|
||||
normal_deleted = (
|
||||
db.query(OperationLog)
|
||||
.filter(OperationLog.created_at < normal_before)
|
||||
.filter(OperationLog.action.notin_(HIGH_RISK_ACTIONS))
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
high_deleted = (
|
||||
db.query(OperationLog)
|
||||
.filter(OperationLog.created_at < high_before)
|
||||
.filter(OperationLog.action.in_(HIGH_RISK_ACTIONS))
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
return int(normal_deleted or 0) + int(high_deleted or 0)
|
||||
192
backend/app/services/pdf.py
Normal file
192
backend/app/services/pdf.py
Normal file
@@ -0,0 +1,192 @@
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.paths import data_path
|
||||
from app.models import Certificate, CertificateAccessToken, Learner, ProjectCourse
|
||||
|
||||
|
||||
def pdf_cache_path(certificate_no: str) -> Path:
|
||||
safe_name = certificate_no.replace("/", "_")
|
||||
year = safe_name[2:6] if len(safe_name) >= 6 else "misc"
|
||||
folder = data_path("pdf-cache") / year
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
return folder / f"{safe_name}.pdf"
|
||||
|
||||
|
||||
def cached_pdf_is_fresh(path: Path) -> bool:
|
||||
if not path.exists():
|
||||
return False
|
||||
modified_at = datetime.fromtimestamp(path.stat().st_mtime)
|
||||
return modified_at >= datetime.now() - timedelta(days=settings.pdf_cache_days)
|
||||
|
||||
|
||||
def render_certificate_pdf(
|
||||
certificate: Certificate,
|
||||
learner: Learner,
|
||||
project: ProjectCourse | None,
|
||||
token: CertificateAccessToken,
|
||||
) -> Path:
|
||||
output_path = pdf_cache_path(certificate.certificate_no)
|
||||
template_path = Path(__file__).resolve().parents[1] / "assets" / "certificate-template.jpg"
|
||||
latest_renderer_mtime = max(template_path.stat().st_mtime, Path(__file__).stat().st_mtime)
|
||||
if cached_pdf_is_fresh(output_path) and output_path.stat().st_mtime >= latest_renderer_mtime:
|
||||
return output_path
|
||||
|
||||
image = render_certificate_image(certificate, learner, project)
|
||||
image.save(output_path, "PDF", resolution=150.0)
|
||||
|
||||
certificate.pdf_status = "generated"
|
||||
certificate.pdf_file_path = str(output_path)
|
||||
return output_path
|
||||
|
||||
|
||||
def render_certificate_image(certificate: Certificate, learner: Learner, project: ProjectCourse | None) -> Image.Image:
|
||||
template = Path(__file__).resolve().parents[1] / "assets" / "certificate-template.jpg"
|
||||
image = Image.open(template).convert("RGB")
|
||||
base_image = image.copy()
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
issue_year, issue_month, issue_day = date_parts(certificate.issue_date)
|
||||
course_name = certificate.course_name or certificate.certificate_name or (project.name if project else certificate.project_code)
|
||||
stage_name = certificate.stage_name or "\u521d\u7ea7\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002"
|
||||
|
||||
for box in [(150, 442, 760, 132), (404, 675, 220, 38)]:
|
||||
paper_patch(image, box)
|
||||
|
||||
draw.text((512, 414), learner.current_name, fill="#111111", font=font(32, "kai", True), anchor="mm")
|
||||
|
||||
x = 185.0
|
||||
y = 494
|
||||
x = draw_inline(draw, x, y, "\u5728", 24, 18)
|
||||
x = draw_inline(draw, x, y, issue_year, 24, 8, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u5e74", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_month, 24, 6, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u6708", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_day, 24, 4, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u65e5\u81f3", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_year, 24, 8, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u5e74", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_month, 24, 6, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u6708", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_day, 24, 4, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u65e5\u5b8c\u6210\u4e86", 24, 8)
|
||||
draw_inline_flow(draw, [(f"\u201c{course_name}\u201d", "course", 0), (stage_name, "normal", 14)], x, y)
|
||||
|
||||
draw.text((512, 704), f"\u53d1\u8bc1\u65e5\u671f\uff1a{issue_year} \u5e74 {issue_month} \u6708 {issue_day} \u65e5", fill="#43382f", font=font(13, "song", True), anchor="mm")
|
||||
draw.text((105, 76), f"\u8bc1\u4e66\u7f16\u53f7\uff1a{certificate.certificate_no}", fill="#111827", font=font(14, "hei", True))
|
||||
image.paste(base_image.crop((720, 535, 950, 629)), (720, 535))
|
||||
return image
|
||||
|
||||
|
||||
def font(size: int, kind: str = "song", bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
choices = {
|
||||
"kai": [r"C:\Windows\Fonts\simkai.ttf", r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc"],
|
||||
"hei": [r"C:\Windows\Fonts\simhei.ttf", r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\msyh.ttc"],
|
||||
"song": [r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc"],
|
||||
}
|
||||
paths = choices.get(kind, choices["song"])
|
||||
if bold and kind == "song":
|
||||
paths = [r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\simhei.ttf"] + paths
|
||||
for item in paths:
|
||||
if Path(item).exists():
|
||||
try:
|
||||
return ImageFont.truetype(item, size)
|
||||
except Exception:
|
||||
pass
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def date_parts(value: date | str | None) -> tuple[str, str, str]:
|
||||
if isinstance(value, date):
|
||||
return str(value.year), str(value.month), str(value.day)
|
||||
raw = str(value or "").strip()
|
||||
for fmt in ("%Y-%m-%d", "%Y/%m/%d"):
|
||||
try:
|
||||
d = datetime.strptime(raw[:10], fmt)
|
||||
return str(d.year), str(d.month), str(d.day)
|
||||
except ValueError:
|
||||
pass
|
||||
today = date.today()
|
||||
return str(today.year), str(today.month), str(today.day)
|
||||
|
||||
|
||||
def paper_patch(image: Image.Image, box: tuple[int, int, int, int]) -> None:
|
||||
x, y, w, h = box
|
||||
patch = Image.new("RGB", (w, h), "#fbf7ef")
|
||||
source = image.crop((72, max(0, y), 160, max(0, y) + h))
|
||||
for tx in range(0, w, source.width):
|
||||
patch.paste(source.crop((0, 0, min(source.width, w - tx), h)), (tx, 0))
|
||||
cover = Image.new("RGB", (w, h), "#fbf7ef")
|
||||
image.paste(Image.blend(patch, cover, 0.38), (x, y))
|
||||
|
||||
|
||||
def draw_inline(draw: ImageDraw.ImageDraw, x: float, y: int, value: str, size: int, gap: int, kind: str = "song", bold: bool = False) -> float:
|
||||
text_font = font(size, kind, bold)
|
||||
draw.text((x, y), value, fill="#111111", font=text_font, anchor="ls")
|
||||
if bold:
|
||||
draw.text((x + 0.45, y), value, fill="#111111", font=text_font, anchor="ls")
|
||||
return x + draw.textlength(value, font=text_font) + gap
|
||||
|
||||
|
||||
def draw_course_name(draw: ImageDraw.ImageDraw, value: str, x: float, y: int) -> int:
|
||||
max_width = 900 - x
|
||||
text_font = font(27, "kai", True)
|
||||
if draw.textlength(value, font=text_font) <= max_width:
|
||||
draw.text((x, y), value, fill="#111111", font=text_font)
|
||||
draw.text((x + 0.45, y), value, fill="#111111", font=text_font)
|
||||
return y
|
||||
|
||||
start_y = y + 34
|
||||
lines = split_by_width(draw, value, text_font, 690)[:2]
|
||||
for index, line in enumerate(lines):
|
||||
line_y = start_y + index * 34
|
||||
draw.text((185, line_y), line, fill="#111111", font=text_font)
|
||||
draw.text((185.45, line_y), line, fill="#111111", font=text_font)
|
||||
return start_y + (len(lines) - 1) * 34
|
||||
|
||||
|
||||
def draw_inline_flow(draw: ImageDraw.ImageDraw, segments: list[tuple[str, str, int]], start_x: float, start_y: int) -> None:
|
||||
x = start_x
|
||||
y = start_y
|
||||
line_start = 185
|
||||
line_height = 42
|
||||
for value, style, gap_before in segments:
|
||||
x += gap_before
|
||||
text_font = font(27, "kai", True) if style == "course" else font(24, "song", False)
|
||||
for char in value:
|
||||
max_right = 900 if y == start_y else 730
|
||||
width = draw.textlength(char, font=text_font)
|
||||
if x + width > max_right and x > line_start:
|
||||
x = line_start
|
||||
y += line_height
|
||||
draw.text((x, y), char, fill="#111111", font=text_font, anchor="ls")
|
||||
if style == "course":
|
||||
draw.text((x + 0.45, y), char, fill="#111111", font=text_font, anchor="ls")
|
||||
x += width
|
||||
|
||||
|
||||
def split_by_width(draw: ImageDraw.ImageDraw, value: str, text_font: ImageFont.ImageFont, max_width: int) -> list[str]:
|
||||
lines: list[str] = []
|
||||
line = ""
|
||||
for char in value:
|
||||
next_line = line + char
|
||||
if line and draw.textlength(next_line, font=text_font) > max_width:
|
||||
lines.append(line)
|
||||
line = char
|
||||
else:
|
||||
line = next_line
|
||||
if line:
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
def draw_fit(draw: ImageDraw.ImageDraw, pos: tuple[int, int], value: str, text_font: ImageFont.ImageFont, fill: str, max_width: int) -> None:
|
||||
font_obj = text_font
|
||||
size = getattr(text_font, "size", 24)
|
||||
while draw.textlength(value, font=font_obj) > max_width and size > 12:
|
||||
size -= 1
|
||||
font_obj = font(size, "song", True)
|
||||
draw.text(pos, value, fill=fill, font=font_obj)
|
||||
12
backend/app/services/rate_limit.py
Normal file
12
backend/app/services/rate_limit.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import time
|
||||
|
||||
_hits: dict[str, list[float]] = {}
|
||||
|
||||
|
||||
def hit_too_often(key: str, limit: int, window_seconds: int) -> bool:
|
||||
now = time.time()
|
||||
start = now - window_seconds
|
||||
recent = [value for value in _hits.get(key, []) if value >= start]
|
||||
recent.append(now)
|
||||
_hits[key] = recent
|
||||
return len(recent) > limit
|
||||
Reference in New Issue
Block a user