diff --git a/app/routes/certificates.py b/app/routes/certificates.py index 0ff3eb8..ff272a5 100644 --- a/app/routes/certificates.py +++ b/app/routes/certificates.py @@ -1,13 +1,15 @@ """활동증명서(certificate of activities) API. - Member: 본인 발급/미리보기/내 이력 조회/다운로드 (require_certificate_eligible). -- Staff/admin: 초안 생성/미리보기, 회장 임기 관리 (require_admin). +- Staff/admin: 초안 생성/미리보기, 발급 이력 전체 조회, 회장 임기 관리 (require_admin). - President: 서명 등록/조회, 초안의 오프라인 서명 원본 등록 (require_president). 라우트 등록 순서 주의: 같은 HTTP 메서드에서 리터럴 세그먼트 경로(`/preview`, `/me`, `/signature/me`, `/president-terms/current`, ...)는 반드시 가변 경로 -(`/{certificate_id}/download`)보다 먼저 등록해야 Starlette가 "me"를 -`certificate_id="me"`로 잘못 매칭하지 않는다. +(`/{certificate_id}/download`, `/{certificate_id}`)보다 먼저 등록해야 +Starlette가 "me"를 `certificate_id="me"`로 잘못 매칭하지 않는다. `GET +/{certificate_id}`는 그중에서도 가장 넓게 매칭되는(1-세그먼트) 가변 경로라서 +라우터 맨 끝에 등록한다. """ from collections.abc import Callable @@ -43,6 +45,7 @@ from app.schemas import ( CertificateDetail, CertificateEventItem, + CertificateHistoryItem, CertificateOptions, CertificateSummary, CursorPage, @@ -394,3 +397,45 @@ async def get_current_president( ok=True, data=PresidentTermDetail.model_validate(term) if term else None, ) + + +# ===================================================================== +# Staff / admin -- history (registered LAST: `/{certificate_id}` is a bare +# 1-segment variable path and must not shadow any literal-segment GET route +# above it, e.g. `/me`, `/signature/me`, `/president-terms/current`). +# ===================================================================== +@router.get( + "", + response_model=Response[CursorPage[CertificateHistoryItem]], + summary="활동증명서 발급 이력 (운영진)", + description="전체 활동증명서 발급 이력을 조회한다. 원본 미등록(ORIGINAL_PENDING) 건이 먼저, 그다음 최신순.", +) +async def list_certificate_history( + cursor: str | None = Query(default=None), + limit: int = Query(default=20, ge=1, le=100), + _admin: User = Depends(require_admin), + db: Session = Depends(get_db), +): + items, next_cursor = CertificateService.list_history(db, cursor=cursor, limit=limit) + return Response( + ok=True, + data=CursorPage( + items=[CertificateHistoryItem.model_validate(item) for item in items], + next_cursor=next_cursor, + ), + ) + + +@router.get( + "/{certificate_id}", + response_model=Response[CertificateDetail], + summary="활동증명서 발급 이력 상세 (운영진)", + description="특정 활동증명서의 상세 정보와 처리 이력을 조회한다.", +) +async def get_certificate_history_detail( + certificate_id: int, + _admin: User = Depends(require_admin), + db: Session = Depends(get_db), +): + certificate = get_existing_certificate(db, certificate_id) + return Response(ok=True, data=to_detail(certificate)) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index 7a51bd3..9baed8f 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -15,6 +15,7 @@ from app.schemas.certificate import ( CertificateDetail, CertificateEventItem, + CertificateHistoryItem, CertificateOptions, CertificateSummary, DraftCertificateCreate, @@ -114,4 +115,5 @@ "CertificateDetail", "CertificateEventItem", "CertificateSummary", + "CertificateHistoryItem", ] diff --git a/app/schemas/certificate.py b/app/schemas/certificate.py index 4137cf2..e234d91 100644 --- a/app/schemas/certificate.py +++ b/app/schemas/certificate.py @@ -128,6 +128,19 @@ class CertificateSummary(BaseModel): model_config = {"from_attributes": True} +class CertificateHistoryItem(BaseModel): + """운영진용 발급 이력 목록 항목 (GET /certificates).""" + + id: int = Field(description="증명서 ID") + kind: CertificateKind = Field(description="구분: 'self'(본인 발급) | 'draft'(초안 생성)") + issued_at: int | None = Field(description="발급일시. 미발급 시 null") + status: CertificateStatus = Field(description="발급 상태") + user: UserBrief = Field(description="발급 대상 회원") + issue_number: str | None = Field(description="원본증명번호") + + model_config = {"from_attributes": True} + + class SignatureDetail(BaseModel): """회장 서명 등록 정보.""" diff --git a/app/services/certificate.py b/app/services/certificate.py index b28e330..239e671 100644 --- a/app/services/certificate.py +++ b/app/services/certificate.py @@ -83,6 +83,17 @@ # 동치가 아니게 된다 -- 반드시 컬럼이 가질 수 있는 최댓값보다 커야 한다. _ME_CURSOR_ID_OFFSET = 10**10 # > INT 컬럼 최댓값(2,147,483,647). +# `list_history`는 (priority, created_at, id) 3중 키를 커서로 쓴다. +# `created_at`은 `int(time.time())`로 초 단위까지만 기록되므로, 같은 초에 +# 여러 증명서가 생성되면 (priority, created_at)만으로는 동순위(tie)가 생긴다. +# id를 세 번째 키로 추가하지 않으면 그 tie가 페이지 경계에 걸릴 때 커서 술어 +# `created_at < cursor_created_at`가 동순위 행 전체를 건너뛰어 목록에서 +# 통째로 누락된다. +_HISTORY_CURSOR_CREATED_OFFSET = 10**13 # (priority, created_at) — epoch(초)보다 크다. +_HISTORY_CURSOR_ID_OFFSET = ( + 10**10 +) # > INT 컬럼 최댓값(2,147,483,647), 위 _ME_CURSOR_ID_OFFSET과 동일한 근거. + def _parse_cursor_int(cursor: str) -> int: """커서 문자열을 음이 아닌 정수로 파싱한다. 형식이 잘못되면 400.""" @@ -103,6 +114,34 @@ def _decode_cursor(cursor: str, offset: int) -> tuple[int, int]: return divmod(_parse_cursor_int(cursor), offset) +def _encode_history_cursor(priority: int, created_at: int, certificate_id: int) -> str: + # 이 packing이 정확히 왕복하려면 각 필드가 자신의 packing radix보다 + # 작아야 한다(위 `_HISTORY_CURSOR_ID_OFFSET`/`_HISTORY_CURSOR_CREATED_OFFSET` + # 선언부의 경계 가정 참고). 가정이 깨지면 초과분이 상위 필드로 넘어가 + # (priority, created_at, id) 정렬 순서를 조용히 오염시키므로(스킵/중복), + # 침묵 오염 대신 여기서 바로 크게 실패시킨다. + if certificate_id >= _HISTORY_CURSOR_ID_OFFSET: + raise ValueError( + "history cursor packing bound violated: " + f"certificate_id={certificate_id} >= _HISTORY_CURSOR_ID_OFFSET=" + f"{_HISTORY_CURSOR_ID_OFFSET}" + ) + if created_at >= _HISTORY_CURSOR_CREATED_OFFSET: + raise ValueError( + "history cursor packing bound violated: " + f"created_at={created_at} >= _HISTORY_CURSOR_CREATED_OFFSET=" + f"{_HISTORY_CURSOR_CREATED_OFFSET}" + ) + major = priority * _HISTORY_CURSOR_CREATED_OFFSET + created_at + return str(major * _HISTORY_CURSOR_ID_OFFSET + certificate_id) + + +def _decode_history_cursor(cursor: str) -> tuple[int, int, int]: + major, certificate_id = divmod(_parse_cursor_int(cursor), _HISTORY_CURSOR_ID_OFFSET) + priority, created_at = divmod(major, _HISTORY_CURSOR_CREATED_OFFSET) + return priority, created_at, certificate_id + + def _new_verification_token_hash() -> str: """검증용 토큰의 sha256 해시. 원본 토큰 자체는 저장하지 않는다. @@ -367,6 +406,66 @@ def list_own( next_cursor = _encode_cursor(last.created_at, last.id, _ME_CURSOR_ID_OFFSET) return page_items, next_cursor + @staticmethod + def list_history( + db: Session, *, cursor: str | None, limit: int + ) -> tuple[list[Certificate], str | None]: + """운영진용 발급 이력. ORIGINAL_PENDING 우선, 그다음 created_at DESC. + + `created_at`이 초 단위(`int(time.time())`)이므로 같은 초에 여러 건이 + 생성되면 (priority, created_at)만으로는 동순위가 생긴다. id를 세 번째 + 정렬/커서 키로 추가해 동순위 행이 페이지 경계에서 누락되지 않게 한다. + + 정렬 우선순위는 파이썬 쪽 `case()` 식이 아니라 실제 컬럼인 + `Certificate.pending_priority`(생성 컬럼, ORIGINAL_PENDING이면 1)를 + 사용한다 — `idx_certificates_history_priority` + (pending_priority, created_at, id) 복합 인덱스가 이 정렬/커서 술어를 + 그대로 커버하기 위함이다(`case()` 식으로는 인덱스가 이 쿼리 플랜에 + 매칭될 수 없다). + """ + priority = Certificate.pending_priority + query = ( + db.query(Certificate) + .options(joinedload(Certificate.user)) + .filter(Certificate.deleted_at.is_(None)) + ) + + if cursor is not None: + cursor_priority, cursor_created_at, cursor_id = _decode_history_cursor( + cursor + ) + query = query.filter( + or_( + priority < cursor_priority, + and_( + priority == cursor_priority, + Certificate.created_at < cursor_created_at, + ), + and_( + priority == cursor_priority, + Certificate.created_at == cursor_created_at, + Certificate.id < cursor_id, + ), + ) + ) + + items = ( + query.order_by( + priority.desc(), Certificate.created_at.desc(), Certificate.id.desc() + ) + .limit(limit + 1) + .all() + ) + has_more = len(items) > limit + page_items = items[:limit] + next_cursor = None + if has_more and page_items: + last = page_items[-1] + next_cursor = _encode_history_cursor( + last.pending_priority, last.created_at, last.id + ) + return page_items, next_cursor + # === Render-only (not persisted) === @staticmethod def preview( diff --git a/tests/test_certificates.py b/tests/test_certificates.py index 52b1363..2f14a5b 100644 --- a/tests/test_certificates.py +++ b/tests/test_certificates.py @@ -11,6 +11,7 @@ import base64 import threading +import time import uuid from datetime import date, timedelta from io import BytesIO @@ -1536,6 +1537,260 @@ def test_list_own_excludes_other_users_certificates( assert page["items"][0]["id"] is not None +class TestHistoryListing: + """GET /certificates (admin) — ORIGINAL_PENDING first, then created_at DESC.""" + + def test_pending_rows_come_before_issued_rows( + self, + client: TestClient, + db: Session, + admin_token: str, + regular_token: str, + regular_user: User, + active_user: User, + president_token: str, + open_president_term: PresidentTerm, + ): + pytest.importorskip("weasyprint") + assert _upload_signature(client, president_token).status_code == 200 + + issued = client.post( + "/certificates", json=_options(), headers=_auth(regular_token) + ).json()["data"] + pending = _create_draft(client, admin_token, active_user.id).json()["data"] + + response = client.get("/certificates?limit=10", headers=_auth(admin_token)) + assert response.status_code == 200 + items = response.json()["data"]["items"] + statuses = [item["status"] for item in items] + ids = [item["id"] for item in items] + + assert statuses[0] == "original_pending" + assert pending["id"] in ids[: statuses.count("original_pending")] + assert issued["id"] in ids[statuses.count("original_pending") :] + # All pending rows appear before all issued rows. + assert statuses == sorted(statuses, key=lambda s: s != "original_pending") + + def test_priority_sort_beats_created_at_when_pending_is_older( + self, + client: TestClient, + db: Session, + admin_token: str, + regular_token: str, + regular_user: User, + active_user: User, + president_token: str, + open_president_term: PresidentTerm, + monkeypatch: pytest.MonkeyPatch, + ): + """`test_pending_rows_come_before_issued_rows` above creates the + ISSUED row *first* and the pending draft *second*, so the pending + row also happens to be the newer row -- a plain `created_at DESC` + with no `pending_priority` at all would already sort it first, so + that test cannot actually tell `ORDER BY pending_priority DESC, + created_at DESC, id DESC` apart from a buggy `ORDER BY created_at + DESC, id DESC`. This test makes the ORIGINAL_PENDING draft strictly + *older* than the ISSUED certificate, so it can only sort first + because of `pending_priority` -- it must go RED if that column is + ever dropped from the ORDER BY.""" + pytest.importorskip("weasyprint") + assert _upload_signature(client, president_token).status_code == 200 + + older = time.time() + monkeypatch.setattr(time, "time", lambda: older) + pending = _create_draft(client, admin_token, active_user.id).json()["data"] + + # Comfortably newer than `older`, well beyond the 1-second + # resolution of `int(time.time())` so the ordering is unambiguous. + newer = older + 100_000 + monkeypatch.setattr(time, "time", lambda: newer) + issued = client.post( + "/certificates", json=_options(), headers=_auth(regular_token) + ).json()["data"] + + db_pending = db.get(Certificate, pending["id"]) + db_issued = db.get(Certificate, issued["id"]) + assert db_pending.created_at < db_issued.created_at + + response = client.get("/certificates?limit=10", headers=_auth(admin_token)) + assert response.status_code == 200 + ids = [item["id"] for item in response.json()["data"]["items"]] + assert ids.index(pending["id"]) < ids.index(issued["id"]), ( + "the older ORIGINAL_PENDING row must still sort before the " + "newer ISSUED row -- pending_priority must win over created_at" + ) + + def test_next_cursor_is_a_json_string_beyond_int64( + self, + client: TestClient, + admin_token: str, + regular_user: User, + ): + """The history cursor encodes (priority, created_at, id) as + `(priority * 10**13 + created_at) * 10**10 + id`, which not only + exceeds JS's safe integer bound but also signed int64 + (2**63 - 1 = 9223372036854775807). If this were serialized as a + plain JSON number, a JS client could even re-serialize it in + exponent notation, which the `cursor: int` query param would reject + with 422. It must round-trip as an opaque numeric *string*.""" + pytest.importorskip("weasyprint") + for _ in range(2): + _create_draft(client, admin_token, regular_user.id) + + page1 = client.get("/certificates?limit=1", headers=_auth(admin_token)).json()[ + "data" + ] + cursor = page1["next_cursor"] + assert isinstance(cursor, str) + assert int(cursor) > 2**63 - 1 + + page2 = client.get( + f"/certificates?limit=1&cursor={cursor}", headers=_auth(admin_token) + ) + assert page2.status_code == 200 + assert len(page2.json()["data"]["items"]) == 1 + + def test_invalid_cursor_returns_400(self, client: TestClient, admin_token: str): + response = client.get( + "/certificates?cursor=not-a-number", headers=_auth(admin_token) + ) + assert response.status_code == 400 + assert response.json()["error"] == "INVALID_CURSOR" + assert response.json()["message"] == "잘못된 페이지네이션 커서입니다." + + def test_list_history_requires_admin(self, client: TestClient, regular_token: str): + response = client.get("/certificates", headers=_auth(regular_token)) + assert response.status_code == 403 + + def test_history_detail_requires_admin( + self, + client: TestClient, + admin_token: str, + regular_token: str, + regular_user: User, + ): + pytest.importorskip("weasyprint") + draft = _create_draft(client, admin_token, regular_user.id).json()["data"] + + response = client.get( + f"/certificates/{draft['id']}", headers=_auth(regular_token) + ) + assert response.status_code == 403 + + def test_history_detail_returns_full_event_history( + self, + client: TestClient, + admin_token: str, + regular_user: User, + president_token: str, + open_president_term: PresidentTerm, + ): + pytest.importorskip("weasyprint") + draft = _create_draft(client, admin_token, regular_user.id).json()["data"] + original = client.post( + f"/certificates/{draft['id']}/original", + files={"file": ("original.pdf", VALID_PDF_BYTES, "application/pdf")}, + headers=_auth(president_token), + ) + assert original.status_code == 200 + + response = client.get( + f"/certificates/{draft['id']}", headers=_auth(admin_token) + ) + assert response.status_code == 200 + detail = response.json()["data"] + assert detail["status"] == "issued" + actions = [event["action"] for event in detail["events"]] + assert actions == ["draft_created", "original_registered"] + + def test_cursor_pagination_round_trips_over_mixed_statuses( + self, + client: TestClient, + admin_token: str, + regular_user: User, + regular_token: str, + active_user: User, + president_token: str, + open_president_term: PresidentTerm, + ): + """Exercises *both* partitions of the (priority, created_at, id) + keyset -- ORIGINAL_PENDING drafts (priority=1) and an ISSUED + certificate (priority=0) -- so pagination has to cross the + priority=1 -> priority=0 boundary at least once. The previous + version of this test only ever created drafts (all + ORIGINAL_PENDING), so the cross-partition cursor branch + (`priority < cursor_priority`) was never exercised.""" + pytest.importorskip("weasyprint") + assert _upload_signature(client, president_token).status_code == 200 + + created_ids = set() + for _ in range(5): + draft = _create_draft(client, admin_token, regular_user.id).json()["data"] + created_ids.add(draft["id"]) + + issued = client.post( + "/certificates", json=_options(), headers=_auth(regular_token) + ).json()["data"] + created_ids.add(issued["id"]) + + seen_ids = [] + cursor = None + for _ in range(10): # generous upper bound on pages + url = "/certificates?limit=2" + if cursor is not None: + url += f"&cursor={cursor}" + page = client.get(url, headers=_auth(admin_token)).json()["data"] + seen_ids.extend(item["id"] for item in page["items"]) + cursor = page["next_cursor"] + if cursor is None: + break + + assert cursor is None + assert set(seen_ids) == created_ids + assert len(seen_ids) == len(set(seen_ids)) # no duplicates + + def test_cursor_pagination_does_not_drop_rows_created_in_the_same_second( + self, + client: TestClient, + admin_token: str, + regular_user: User, + monkeypatch: pytest.MonkeyPatch, + ): + """`Certificate.created_at` has 1-second resolution + (`int(time.time())`), so multiple certificates created within the + same wall-clock second tie on the (priority, created_at) keyset. A + cursor predicate that only compares `created_at` (with no id + tiebreak) can silently drop or duplicate tied rows once the page + boundary lands inside the tie. This freezes time to force the tie + deterministically.""" + pytest.importorskip("weasyprint") + frozen = time.time() + monkeypatch.setattr(time, "time", lambda: frozen) + + created_ids = set() + for _ in range(3): + draft = _create_draft(client, admin_token, regular_user.id).json()["data"] + created_ids.add(draft["id"]) + + seen_ids = [] + cursor = None + for _ in range(10): + url = "/certificates?limit=1" + if cursor is not None: + url += f"&cursor={cursor}" + page = client.get(url, headers=_auth(admin_token)).json()["data"] + seen_ids.extend(item["id"] for item in page["items"]) + cursor = page["next_cursor"] + if cursor is None: + break + + assert set(seen_ids) == created_ids + assert len(seen_ids) == len(created_ids), ( + "cursor pagination lost or duplicated rows that tied on " + "(priority, created_at) within the same second" + ) + + class TestRenderContextMasking: """`build_context` is a pure function -- exercise the issue-number masking contract directly, without needing WeasyPrint installed."""