docs(design): W2(게임잼)+W3(잔여)+W4 풀설계 산출 + 골자 카탈로그 + stale 정정
설계 전용 세션(코드 0줄, src/·pom.xml 무변경). 골자 → 정석 결정 확정 → 풀설계. - 골자 신규: W2(6서브)·W4 카탈로그(docs/work-log/2026-06-23-w2-w4-feature-skeletons.md). W3-* 는 기존 골자 유지. - 풀설계 11기능(W2-1~6·W3-1/3-3/3-4/3-5·W4) — W1-design 깊이(DDL/API계약/시퀀스/파일영향맵/대안비교/AC매핑), 오픈질문 0. 본문 = .atp/work-session/20260623-104307/implementation/. - 정석 크로스-W 결정(개입없이 orchestrator 확정, 사용자 위임): 잼 스코프=별도 jam_judges / 평가단위=(jam_id,game_id) 자연키 / 유저평점=avg_rating 단방향 / 잼연결=조인테이블 jam_entries + 팀출품 1차 / 인기투표=1인1표 UNIQUE / SSRF=공용 SsrfSafeFetcher 9항목 / 권한키 BADGE_MANAGE 신규. - 교차정합 감사(_cross-consistency-audit.md): 동결 단일권위(W2-3) 유지·하류 정합 PASS. HIGH 1(W2-1 평가단위 표현 drift, DDL정합) 5개소 정정. LOW 3 무해. - 골자 stale 정정 3건: RBAC 인프라 실재(enforcement 갭)·리뷰 하이브리드(overall+6축)+VIEW·/game/** 정상 서빙(QG-3 해소, GameAssetController). - 통합 인덱스: docs/work-log/2026-06-23-w2-w4-full-design-summary.md. 검증: 설계 전용 — 코드 변경 0이라 L1/L2 해당 없음(graph-refresh §3.2 no-scope-change skip). 구현·검증은 착수 시 별도 세션. game_likes 운영 DB UNIQUE 는 미확인(추정, 운영 확인 권장). resumed_from: 20260622-180054 (W1 설계/구현) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a19619f434
commit
bfbe1de9e4
|
|
@ -0,0 +1,602 @@
|
||||||
|
---
|
||||||
|
phase: design
|
||||||
|
agent: design-advisor
|
||||||
|
agent_version: 1
|
||||||
|
generated_at: 2026-06-23T12:30:00+09:00
|
||||||
|
workstream: W2-1-게임잼 엔티티/라이프사이클
|
||||||
|
concerns:
|
||||||
|
- "JamService / 컨트롤러의 신규 헬퍼 시그니처는 최소 인자로 명세했다. 구현 단계에서 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고 방지, 프로토콜 §11.2). 특히 entrant 분기 헬퍼 resolveEntrant(...)."
|
||||||
|
- "신규 컨트롤러(JamController/JamAdminController) + 신규 매퍼(JamsMapper/JamEntriesMapper/JamTeamsMapper/JamStatusLogMapper) 의존 추가 — verification-strategies §30 에 따라 implementation 단계에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 신규 @Mapper @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException."
|
||||||
|
- "신규 매퍼 SQL 은 DB-방언 계약(L2) 대상 — snake→camel 직접 alias(jam.created_at AS createdAt)가 일반 매퍼 표준(verification-strategies §33). 큰따옴표 alias 는 집계 VIEW 매퍼만. keyset 커서 비교(created_at,id) 의 PostgreSQL row-comparison 또는 OR 분해 SQL 은 dev DB contract 로 실측 검증 권장."
|
||||||
|
- "games↔jam 연결 = 조인테이블 jam_entries 로 확정(games 무변경). **평가 단위 = (jam_id, game_id) 활성 자연키**(W2-3 동결 권위) — jam_entries 의 ux_jam_entries_jam_game_active(jam_id,game_id 활성 UNIQUE)가 이 자연키를 활성 출품작과 1:1 보장한다. W2-4/5/6 FK 는 game_id→games·jam_id→jams 직접이고 '출품 여부'는 앱계층 jam_entries 활성행 존재로 검증(jam_entries.id surrogate FK 아님). 이 (jam_id,game_id) 활성 UNIQUE 계약을 동결 전 변경 금지. crossRefs 참조."
|
||||||
|
- "잼 상태 자동전이(스케줄러)는 본 설계에서 훅 인터페이스만 정의하고 구현은 후속(W2-1 범위 밖). @Scheduled 빈 도입 시 BibimbapApplicationTests context 영향 재확인 — 본 설계는 수동 전이 enforcement 만 구현 범위."
|
||||||
|
- "잼 slug 는 사용자 비입력(서버 생성) 정석. slug 충돌 시 재시도 로직 필요 — 구현 점검 항목."
|
||||||
|
concerns_checked: true
|
||||||
|
self_verification:
|
||||||
|
checklist_passed: true
|
||||||
|
references:
|
||||||
|
requirements: docs/work-log/2026-06-23-w2-w4-feature-skeletons.md
|
||||||
|
research: .atp/work-session/20260623-104307/research/W2-W4-grounding.md
|
||||||
|
adrs:
|
||||||
|
- .atp/work-session/20260622-180054/implementation/W1-design.md
|
||||||
|
- docs/work-log/2026-06-17-jam-platform-roadmap.md
|
||||||
|
- docs/development/verification-strategies.md
|
||||||
|
- docs/rbac-ddl.sql
|
||||||
|
---
|
||||||
|
|
||||||
|
# 설계: W2-1 — 게임잼 엔티티 + 라이프사이클 (jams / jam_entries / jam_teams + 관리자 CRUD + 공개 목록·상세 + GAME_JAM_MANAGE enforcement)
|
||||||
|
|
||||||
|
## 목표 / 비목표
|
||||||
|
|
||||||
|
### 목표 (FR/NFR 추적 — 골자 W2-1)
|
||||||
|
- **G1 잼 엔티티**: 회차 독립 게임잼(`jams`) 도입. 모집→개발→평가→종료 라이프사이클 명시 컬럼 + 기간 필드.
|
||||||
|
- **G2 잼-게임 연결(정규화)**: 출품작 = 기존 `games` 재사용 + 조인테이블 `jam_entries`(games 무변경). 잼당 게임 1회 출품(UNIQUE).
|
||||||
|
- **G3 출품 주체 = 개인 OR 팀**: `jam_teams`/`jam_team_members` + `jam_entries.entrant_type CHECK('USER','TEAM')`. 둘 중 하나 NOT NULL(CHECK).
|
||||||
|
- **G4 관리자 잼 CRUD**: 잼 생성/수정/상태전이/삭제. `GAME_JAM_MANAGE` 게이트 enforcement 연결(W1 인프라 위, 임시 role 직접체크 금지).
|
||||||
|
- **G5 공개 페이지**: 잼 목록(`/jams`) + 상세(`/jams/{slug}`). RecruitController 패턴(읽기=JSP 뷰, 쓰기=JSON+CSRF). keyset 페이징.
|
||||||
|
- **G6 상태전이 + 감사**: 관리자 수동 전이(기간 정합 검증) + 전이 감사 기록(`jam_status_log`). 자동전이는 훅만(후속).
|
||||||
|
- **G7 이중 노출**: 출품작은 잼 전용 뷰 + 기존 일반 게임 허브(`games.is_visible` 유지) 둘 다 노출.
|
||||||
|
- **G8 운영 표시 필드**: discord_url/prize_info/sponsor_info(`jams` 컬럼, 표시 전용 — 실지급 수동).
|
||||||
|
- **NFR**: 상태변경 CSRF 전수, `#{}` 바인딩(`${}` 금지), 입력 sanitize/길이 검증, 비파괴 멱등 마이그레이션, DDL 권위=docs/*-ddl.sql.
|
||||||
|
|
||||||
|
### 비목표 (스코프 밖)
|
||||||
|
- **평가/심사/투표/시상 스키마**(jam_criteria/jam_scores/jam_votes/jam_awards) — **W2-3 동결 소유**. 본 설계는 평가 단위인 `(jam_id, game_id)` **활성 자연키**(jam_entries 의 활성 UNIQUE 로 1:1 보장)를 제공만. W2-3 동결은 이 자연키를 직접 FK(game_id→games·jam_id→jams)로 채택하며 jam_entries.id surrogate 를 FK 로 쓰지 않는다.
|
||||||
|
- **심사위원 역할/권한**(jam_judges) — **W2-2 소유**. 여기서 만들지 않음.
|
||||||
|
- **잼 투표 1인1표**(W2-5), **시상 집계**(W2-6) — 별도.
|
||||||
|
- **상태 자동전이 스케줄러 본체** — 본 설계는 전이 검증 메서드 + 훅 인터페이스만. `@Scheduled` 구현은 후속.
|
||||||
|
- **팀 초대/승인 워크플로 고도화** — 1차는 팀 생성 + 멤버 직접 추가까지. 초대 수락 플로우는 후속.
|
||||||
|
- **게임 업로드 경로 변경** — 기존 `/game/new` 생성 경로 무변경. 잼 연결은 별도 출품(entry) 액션.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
bibimbap 은 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis annotation `@Mapper`(`#{}` only) + JSP 스택이다. 현재 `jams` 테이블·`jam_id` 는 전무하고(grounding R-C: games 13컬럼에 jam_id 부재, jam grep 0 hit), `GAME_JAM_MANAGE` 권한 키는 enum 선언만 있고 소비처 0건이다(grounding R-A: PermissionKeys.java:4). RBAC 인프라(`PermissionGate.has(session, key)` 2인자, `RbacInterceptor` /admin/** + isAdmin, epoch 전파 `refreshIfStale`)는 W1 에서 완비됐다(PermissionGate.java:22,86 직접 확인).
|
||||||
|
|
||||||
|
본 설계는 **게임잼 엔티티 4테이블(+감사 1)을 신규 도입**하고, **GAME_JAM_MANAGE 게이트를 관리자 잼 CRUD enforcement 에 연결**하며, **공개 목록/상세를 RecruitController 패턴 + keyset 페이징**으로 제공한다.
|
||||||
|
|
||||||
|
확정된 정석 결정(전제):
|
||||||
|
- **연결 = 조인테이블 `jam_entries`** (games.jam_id 컬럼 끼워넣기 기각). games 무변경 → 일반 허브 노출/삭제연쇄 회귀 0, 정규화, entry 메타데이터 보유 가능.
|
||||||
|
- **출품 주체 = 개인 OR 팀 둘 다 1차 포함** (잼은 팀 이벤트 본질). `jam_entries.entrant_type` + entrant_user_id/jam_team_id 둘 중 하나 NOT NULL(CHECK).
|
||||||
|
- **상태 = 명시 컬럼 + 관리자 수동 전이(감사) + 자동전이 훅(후속)**.
|
||||||
|
- **enforcement 갭 메우기**: RbacInterceptor 는 `/admin/**` + isAdmin 만이므로 SUBADMIN+GAME_JAM_MANAGE 는 인터셉터를 통과 못 한다(isAdmin 은 ADMIN 만 true, PermissionGate.java:55-62 확인). 따라서 `/admin/jams/**` 컨트롤러 진입부에 `PermissionGate.has(session, GAME_JAM_MANAGE.name())` 게이트 헬퍼를 적용(W1-design 의 "콘솔=URL패턴, 소비 액션=게이트 헬퍼" 선례 그대로). 임시 role 직접체크 금지.
|
||||||
|
|
||||||
|
가장 까다로운 두 난제 확정:
|
||||||
|
- **난제1 (개인/팀 이중 주체 정합)**: `jam_entries` 단일 테이블 + `entrant_type CHECK('USER','TEAM')` + `entrant_user_id`(nullable) + `jam_team_id`(nullable) + **XOR CHECK**(둘 중 정확히 하나 NOT NULL). 출품 노출/집계 쿼리는 entrant_type 분기 없이 game_id 로 단일화. **평가 단위 = (jam_id, game_id) 활성 자연키**(W2-3 동결) — jam_entries active-UNIQUE 가 활성 출품작과 1:1 보장하므로 W2-3/4/5/6 은 entrant 종류를 몰라도 (jam_id, game_id) 만 참조한다(jam_entries.id surrogate 아님).
|
||||||
|
- **난제2 (상태전이 정합·감사·자동전이 공존)**: 상태는 `jams.status` 명시 컬럼(CHECK 4값)이 단일 진실. 관리자 수동 전이는 **허용 전이 그래프**(RECRUIT→DEV→EVAL→CLOSED + 역행/취소 제한)를 `JamLifecycle` 도메인이 검증하고, 전이마다 `jam_status_log` insert(감사). 자동전이는 같은 `JamLifecycle.transition(...)` 코어를 호출하는 **훅 인터페이스**만 정의(스케줄러 본체는 후속) → 수동/자동이 동일 검증·감사 경로를 공유(중복 0).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 핵심 결정 요약 (전제 — 재논의 금지)
|
||||||
|
|
||||||
|
| 결정 | 확정값 | 본 설계의 구체화 |
|
||||||
|
|---|---|---|
|
||||||
|
| D1 연결 | 조인테이블 `jam_entries` | games 무변경. UNIQUE(jam_id, game_id) = 잼당 게임 1회. entry 메타 보유 |
|
||||||
|
| D2 출품 주체 | 개인 OR 팀 둘 다 | `jam_teams`/`jam_team_members` + entrant_type CHECK + XOR CHECK(user/team 정확히 하나) |
|
||||||
|
| D3 상태 | 명시 컬럼 + 수동전이(감사) + 자동전이 훅 | `jams.status` CHECK('RECRUIT','DEV','EVAL','CLOSED') + `JamLifecycle` 전이 그래프 + `jam_status_log` |
|
||||||
|
| D4 enforcement | GAME_JAM_MANAGE 게이트 헬퍼 | `/admin/jams/**` 컨트롤러 진입부 `PermissionGate.has(session, GAME_JAM_MANAGE.name())`. 인터셉터 미등록(SUBADMIN 통과 위함) |
|
||||||
|
| D5 공개 페이지 | `/jams` 목록 + `/jams/{slug}` 상세 | RecruitController 패턴. **keyset 페이징(created_at,id 커서)** |
|
||||||
|
| D6 이중 노출 | 잼 전용 뷰 + 일반 허브 | jam_entries JOIN games. games.is_visible 유지(허브 동시 노출) |
|
||||||
|
| D7 운영 표시 | jams 컬럼 표시 전용 | discord_url/prize_info/sponsor_info — 실지급 수동, 표시만 |
|
||||||
|
| D8 식별자 | slug(서버 생성) | URL 안전 slug, 잼당 UNIQUE. 내부 join 은 jam_id(bigint) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 모델 (DDL)
|
||||||
|
|
||||||
|
> 권위 = **신규 파일 `docs/jam-ddl.sql`** (apply-local-ddl.sh 가 docs/*-ddl.sql 알파벳 글롭으로 멱등 적용, ON_ERROR_STOP, search_path=dev). `db/schema.sql` 에 동기 사본(아래 §schema.sql 반영). 선례: W1 docs/rbac-ddl.sql(직접 확인). **games 변경 없음**(연결은 jam_entries 보유). 멱등: CREATE TABLE/SEQUENCE IF NOT EXISTS, DO $$ guard, CREATE UNIQUE INDEX IF NOT EXISTS, ALTER ADD COLUMN IF NOT EXISTS. 타입은 기존 스타일(bigint/varchar/timestamptz/text).
|
||||||
|
|
||||||
|
### 신규 파일: `docs/jam-ddl.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- W2-1 게임잼 엔티티/라이프사이클. 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
|
||||||
|
-- games 변경 없음(연결은 jam_entries 가 보유). 추가만, 파괴 없음.
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- 1) jams (게임잼 회차. 회차 독립 = 다중 인스턴스)
|
||||||
|
-- ===========================================================================
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "jams_id_seq";
|
||||||
|
CREATE TABLE IF NOT EXISTS "jams" (
|
||||||
|
"id" bigint DEFAULT nextval('jams_id_seq'::regclass) NOT NULL,
|
||||||
|
"slug" character varying(80) NOT NULL, -- URL 식별자(서버 생성, UNIQUE)
|
||||||
|
"title" character varying(200) NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"status" character varying(20) DEFAULT 'RECRUIT' NOT NULL, -- 라이프사이클
|
||||||
|
"recruit_start_at" timestamp with time zone, -- 모집 시작(기간 정합 검증용)
|
||||||
|
"dev_start_at" timestamp with time zone, -- 개발 시작
|
||||||
|
"eval_start_at" timestamp with time zone, -- 평가 시작(W2-4/5 게이트 기준)
|
||||||
|
"eval_end_at" timestamp with time zone, -- 평가 종료(=종료 전이 기준)
|
||||||
|
"discord_url" character varying(500), -- 운영 표시 전용
|
||||||
|
"prize_info" text, -- 운영 표시 전용(실지급 수동)
|
||||||
|
"sponsor_info" text, -- 운영 표시 전용
|
||||||
|
"is_visible" boolean DEFAULT true NOT NULL, -- 공개 목록 노출
|
||||||
|
"created_by" bigint, -- 생성 관리자(감사 보조)
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"is_delete" boolean DEFAULT false NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
ALTER SEQUENCE "jams_id_seq" OWNED BY "jams"."id";
|
||||||
|
|
||||||
|
-- status 값집합 CHECK(멱등)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jams_status_check') THEN
|
||||||
|
ALTER TABLE "jams"
|
||||||
|
ADD CONSTRAINT "jams_status_check"
|
||||||
|
CHECK ("status" IN ('RECRUIT', 'DEV', 'EVAL', 'CLOSED'));
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- slug 활성 UNIQUE(삭제분 제외 — recruit/games 의 active-unique 선례와 동형)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jams_slug_active"
|
||||||
|
ON "jams" ("slug") WHERE "is_delete" IS NOT TRUE;
|
||||||
|
-- 공개 목록 keyset 페이징 인덱스(created_at DESC, id DESC 커서)
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_jams_visible_keyset"
|
||||||
|
ON "jams" ("is_visible", "is_delete", "created_at" DESC, "id" DESC);
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- 2) jam_teams (잼별 팀. 잼 회차에 종속 = 회차 독립)
|
||||||
|
-- ===========================================================================
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "jam_teams_id_seq";
|
||||||
|
CREATE TABLE IF NOT EXISTS "jam_teams" (
|
||||||
|
"id" bigint DEFAULT nextval('jam_teams_id_seq'::regclass) NOT NULL,
|
||||||
|
"jam_id" bigint NOT NULL,
|
||||||
|
"name" character varying(120) NOT NULL,
|
||||||
|
"owner_user_id" bigint NOT NULL, -- 팀 생성자(팀장)
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"is_delete" boolean DEFAULT false NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
ALTER SEQUENCE "jam_teams_id_seq" OWNED BY "jam_teams"."id";
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_teams_jam_id_fkey') THEN
|
||||||
|
ALTER TABLE "jam_teams" ADD CONSTRAINT "jam_teams_jam_id_fkey"
|
||||||
|
FOREIGN KEY ("jam_id") REFERENCES "jams" ("id");
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_teams_owner_fkey') THEN
|
||||||
|
ALTER TABLE "jam_teams" ADD CONSTRAINT "jam_teams_owner_fkey"
|
||||||
|
FOREIGN KEY ("owner_user_id") REFERENCES "users" ("id");
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_jam_teams_jam" ON "jam_teams" ("jam_id");
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- 3) jam_team_members (팀 멤버. 한 유저는 한 팀에 1회 — 잼 내 중복 가입은 멤버십 UNIQUE)
|
||||||
|
-- ===========================================================================
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "jam_team_members_id_seq";
|
||||||
|
CREATE TABLE IF NOT EXISTS "jam_team_members" (
|
||||||
|
"id" bigint DEFAULT nextval('jam_team_members_id_seq'::regclass) NOT NULL,
|
||||||
|
"jam_team_id" bigint NOT NULL,
|
||||||
|
"user_id" bigint NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
ALTER SEQUENCE "jam_team_members_id_seq" OWNED BY "jam_team_members"."id";
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_team_members_team_fkey') THEN
|
||||||
|
ALTER TABLE "jam_team_members" ADD CONSTRAINT "jam_team_members_team_fkey"
|
||||||
|
FOREIGN KEY ("jam_team_id") REFERENCES "jam_teams" ("id");
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_team_members_user_fkey') THEN
|
||||||
|
ALTER TABLE "jam_team_members" ADD CONSTRAINT "jam_team_members_user_fkey"
|
||||||
|
FOREIGN KEY ("user_id") REFERENCES "users" ("id");
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
-- 같은 팀에 같은 유저 중복 가입 방지
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_team_members_team_user"
|
||||||
|
ON "jam_team_members" ("jam_team_id", "user_id");
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- 4) jam_entries (출품작 = 잼-게임 연결 조인. 평가 단위 = (jam_id, game_id) 활성 자연키 — W2-3/4/5/6 참조점, jam_entries.id surrogate 아님. active-UNIQUE 가 1:1 보장)
|
||||||
|
-- ===========================================================================
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "jam_entries_id_seq";
|
||||||
|
CREATE TABLE IF NOT EXISTS "jam_entries" (
|
||||||
|
"id" bigint DEFAULT nextval('jam_entries_id_seq'::regclass) NOT NULL,
|
||||||
|
"jam_id" bigint NOT NULL,
|
||||||
|
"game_id" bigint NOT NULL,
|
||||||
|
"entrant_type" character varying(10) NOT NULL, -- 'USER' | 'TEAM'
|
||||||
|
"entrant_user_id" bigint, -- entrant_type='USER' 시 NOT NULL
|
||||||
|
"jam_team_id" bigint, -- entrant_type='TEAM' 시 NOT NULL
|
||||||
|
"submitted_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"is_delete" boolean DEFAULT false NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
ALTER SEQUENCE "jam_entries_id_seq" OWNED BY "jam_entries"."id";
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_entries_jam_fkey') THEN
|
||||||
|
ALTER TABLE "jam_entries" ADD CONSTRAINT "jam_entries_jam_fkey"
|
||||||
|
FOREIGN KEY ("jam_id") REFERENCES "jams" ("id");
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_entries_game_fkey') THEN
|
||||||
|
ALTER TABLE "jam_entries" ADD CONSTRAINT "jam_entries_game_fkey"
|
||||||
|
FOREIGN KEY ("game_id") REFERENCES "games" ("id");
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_entries_team_fkey') THEN
|
||||||
|
ALTER TABLE "jam_entries" ADD CONSTRAINT "jam_entries_team_fkey"
|
||||||
|
FOREIGN KEY ("jam_team_id") REFERENCES "jam_teams" ("id");
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_entries_user_fkey') THEN
|
||||||
|
ALTER TABLE "jam_entries" ADD CONSTRAINT "jam_entries_user_fkey"
|
||||||
|
FOREIGN KEY ("entrant_user_id") REFERENCES "users" ("id");
|
||||||
|
END IF;
|
||||||
|
-- entrant_type 값집합
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_entries_entrant_type_check') THEN
|
||||||
|
ALTER TABLE "jam_entries" ADD CONSTRAINT "jam_entries_entrant_type_check"
|
||||||
|
CHECK ("entrant_type" IN ('USER', 'TEAM'));
|
||||||
|
END IF;
|
||||||
|
-- XOR: 개인이면 user 만, 팀이면 team 만 NOT NULL(정확히 하나)
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_entries_entrant_xor_check') THEN
|
||||||
|
ALTER TABLE "jam_entries" ADD CONSTRAINT "jam_entries_entrant_xor_check"
|
||||||
|
CHECK (
|
||||||
|
("entrant_type" = 'USER' AND "entrant_user_id" IS NOT NULL AND "jam_team_id" IS NULL)
|
||||||
|
OR
|
||||||
|
("entrant_type" = 'TEAM' AND "jam_team_id" IS NOT NULL AND "entrant_user_id" IS NULL)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
-- 잼당 게임 1회 출품(활성). 삭제분은 재출품 허용 → 활성 partial unique
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_entries_jam_game_active"
|
||||||
|
ON "jam_entries" ("jam_id", "game_id") WHERE "is_delete" IS NOT TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_jam_entries_jam" ON "jam_entries" ("jam_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_jam_entries_game" ON "jam_entries" ("game_id");
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- 5) jam_status_log (상태전이 감사. 수동/자동 전이 공통 기록)
|
||||||
|
-- ===========================================================================
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "jam_status_log_id_seq";
|
||||||
|
CREATE TABLE IF NOT EXISTS "jam_status_log" (
|
||||||
|
"id" bigint DEFAULT nextval('jam_status_log_id_seq'::regclass) NOT NULL,
|
||||||
|
"jam_id" bigint NOT NULL,
|
||||||
|
"from_status" character varying(20), -- 최초 생성 시 NULL 허용
|
||||||
|
"to_status" character varying(20) NOT NULL,
|
||||||
|
"actor_id" bigint, -- 수동=관리자 id, 자동=NULL
|
||||||
|
"transition_type" character varying(10) DEFAULT 'MANUAL' NOT NULL, -- 'MANUAL'|'AUTO'
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
ALTER SEQUENCE "jam_status_log_id_seq" OWNED BY "jam_status_log"."id";
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_status_log_jam_fkey') THEN
|
||||||
|
ALTER TABLE "jam_status_log" ADD CONSTRAINT "jam_status_log_jam_fkey"
|
||||||
|
FOREIGN KEY ("jam_id") REFERENCES "jams" ("id");
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_status_log_to_status_check') THEN
|
||||||
|
ALTER TABLE "jam_status_log" ADD CONSTRAINT "jam_status_log_to_status_check"
|
||||||
|
CHECK ("to_status" IN ('RECRUIT', 'DEV', 'EVAL', 'CLOSED'));
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_status_log_transition_type_check') THEN
|
||||||
|
ALTER TABLE "jam_status_log" ADD CONSTRAINT "jam_status_log_transition_type_check"
|
||||||
|
CHECK ("transition_type" IN ('MANUAL', 'AUTO'));
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_jam_status_log_jam" ON "jam_status_log" ("jam_id", "created_at" DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `db/schema.sql` 반영 (최초 기동 1회 자동 주입 — docs/jam-ddl.sql 의 사본)
|
||||||
|
- `recruit_posts` 블록(또는 마지막 테이블 블록) 뒤에 위 1~5 전체를 **신설 블록**으로 추가.
|
||||||
|
- 헤더 주석: `-- 게임잼 W2-1 (권위 DDL — docs/jam-ddl.sql 와 동일)` — game_reviews 블록이 schema.sql:128 에서 `(권위 DDL — docs/game-reviews-ddl.sql 와 동일. W3-2 신규)` 라 단 선례와 동형(직접 확인).
|
||||||
|
- 반영 방식: **docs/jam-ddl.sql 이 권위, schema.sql 은 사본**. 두 곳에 동일 멱등 DDL.
|
||||||
|
|
||||||
|
### games 무변경 확인 (D1)
|
||||||
|
- `games` 테이블/컬럼 변경 0. jam 연결은 전부 `jam_entries` 가 보유 → 기존 getVisibleGames/searchVisibleGames/삭제연쇄(GamesMapper) 회귀 0. 일반 허브 노출 동작 불변(D6 이중 노출의 "허브" 측).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 외부 계약 (API)
|
||||||
|
|
||||||
|
> 공통: 모든 상태변경은 `CsrfTokens.isValid(request)` 검증(없으면 403 + `CsrfTokens.errorBody()`, CsrfTokens.java:35,51 확인). 응답은 RecruitController 패턴 — 읽기=JSP 뷰이름 반환, 쓰기=`ResponseEntity<Map<String,Object>>`(status/message). 관리자 API 는 컨트롤러 진입부에서 `PermissionGate.has(session, GAME_JAM_MANAGE.name())` 게이트 통과 후 본문 수행.
|
||||||
|
|
||||||
|
### 401 vs 403 정책 (W1-design 과 일치)
|
||||||
|
- **미인증**(세션 `userId` 없음): API 는 **401** JSON `{status:401, message:"로그인이 필요합니다."}`. 페이지(`/jams/{slug}/...` 폼 등 인증 필요분)는 `redirect:/login`.
|
||||||
|
- **인증·미인가**(로그인됐으나 GAME_JAM_MANAGE 없음): **403** JSON `{status:403, message:"권한이 없습니다."}`(리다이렉트 금지).
|
||||||
|
- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`.
|
||||||
|
- 게이트 분기는 `gate.isAuthenticated(session)`(401/redirect) → `gate.has(session, GAME_JAM_MANAGE.name())`(403) 2단계. PermissionGate.isAuthenticated/has 직접 확인(PermissionGate.java:47,22).
|
||||||
|
|
||||||
|
### 공개 페이지 (뷰 — 인증 불필요)
|
||||||
|
| method | path | 권한 | 응답 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/jams` | 공개 | `jam-list` JSP. 가시 잼 keyset 1페이지 + `nextCursor` 모델 주입 |
|
||||||
|
| GET | `/jams/{slug}` | 공개 | `jam-detail` JSP. 잼 + 출품작(jam_entries JOIN games) + CSRF 토큰 모델 주입 |
|
||||||
|
| GET | `/jams?cursor={createdAt}_{id}` | 공개 | (목록 동일 뷰, keyset 다음 페이지) |
|
||||||
|
|
||||||
|
### 공개 출품/팀 액션 (상태변경 API — 로그인 필요 + CSRF. 관리자 게이트 아님)
|
||||||
|
| 액션 | method | path | 요청 | 응답(200) | 에러 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 개인 출품 | POST | `/jams/{slug}/entries` | gameId | `{status:200, message, entryId}` | 401(미인증), 403(CSRF), 404(잼/게임 없음), 409(이미 출품), 422(잼 상태가 출품 불가/게임 소유자 아님) |
|
||||||
|
| 팀 출품 | POST | `/jams/{slug}/entries` | gameId, jamTeamId | `{status:200, message, entryId}` | 위 + 422(팀 멤버 아님) |
|
||||||
|
| 팀 생성 | POST | `/jams/{slug}/teams` | name | `{status:200, message, jamTeamId}` | 401, 403, 404, 422(잼 상태/이름 검증) |
|
||||||
|
| 팀 멤버 추가 | POST | `/jams/{slug}/teams/{teamId}/members` | userId | `{status:200, message}` | 401, 403, 404, 422(팀장 아님/중복) |
|
||||||
|
|
||||||
|
- 출품 단일 엔드포인트(`/entries`)에서 `jamTeamId` 유무로 개인/팀 분기 — entrant_type 결정. games.user_id 소유 검증(개인) 또는 jam_team_members 멤버 검증(팀)으로 도용 차단.
|
||||||
|
- **출품 가능 상태**: `jams.status IN ('RECRUIT','DEV')` 만 허용(EVAL/CLOSED 출품 거부 → 422). 구체 정책은 D3 + concern(출품 자격) 따름.
|
||||||
|
|
||||||
|
### 관리자 잼 CRUD (상태변경 API — CSRF + GAME_JAM_MANAGE 게이트)
|
||||||
|
| 액션 | method | path | 요청 | 응답(200) | 에러 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 콘솔 페이지 | GET | `/admin/jams` | (없음) | `admin-jam-list` JSP(잼 전건 + CSRF) | 401/redirect, 403 |
|
||||||
|
| 생성 | POST | `/admin/jams` | title, description, 기간필드, 표시필드 | `{status:200, message, jamId, slug}` | 403(CSRF/권한), 422(입력 검증/기간 정합) |
|
||||||
|
| 수정 | POST | `/admin/jams/{jamId}` | (생성과 동일 필드) | `{status:200, message, jamId}` | 403, 404, 422 |
|
||||||
|
| 상태전이 | POST | `/admin/jams/{jamId}/status` | toStatus | `{status:200, message, jamId, status:toStatus}` | 403, 404, 409(허용 안 되는 전이), 422(기간 필드 미충족) |
|
||||||
|
| 가시성 토글 | POST | `/admin/jams/{jamId}/visibility` | (없음) | `{status:200, message, jamId, visible:bool}` | 403, 404 |
|
||||||
|
| 삭제(소프트) | POST | `/admin/jams/{jamId}/delete` | (없음) | `{status:200, message, jamId}` | 403, 404, 422(출품작 존재 시 정책) |
|
||||||
|
|
||||||
|
- **GAME_JAM_MANAGE enforcement(D4)**: 위 `/admin/jams/**` 6액션 전부 진입부에서 게이트 통과 요구. RbacInterceptor 가 `/admin/**` 에 등록돼 있으나 isAdmin 만 검사하므로(RbacInterceptor.java:34) **SUBADMIN+GAME_JAM_MANAGE 가 인터셉터에서 막힌다** → 인터셉터는 `/admin/jams/**` 를 통과시키지 못함. 해결: 인터셉터 경로 매핑을 변경하지 않고(W1 콘솔 보호 유지), **`/admin/jams/**` 를 인터셉터 exclude 에 추가**한 뒤 컨트롤러 게이트 헬퍼로 GAME_JAM_MANAGE 검사(아래 §인터셉터 연동 D4-A 확정).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 인터셉터 / 게이트 연동 (D4 — enforcement 갭 메우기)
|
||||||
|
|
||||||
|
### 문제
|
||||||
|
- `RbacInterceptor.preHandle` 은 `/admin/**` 전체에 대해 `isAdmin(session)` 만 검사한다(RbacInterceptor.java:34, InterceptorConfig.java:19 직접 확인). `isAdmin` 은 role==ADMIN 만 true(PermissionGate.java:55-62).
|
||||||
|
- 따라서 `/admin/jams/**` 가 인터셉터에 그대로 걸리면 **SUBADMIN(+GAME_JAM_MANAGE 보유)이 잼 관리에 접근 못 한다**. GAME_JAM_MANAGE 권한 키의 존재 의의(ADMIN 아닌 위임 관리자)가 무력화됨.
|
||||||
|
|
||||||
|
### 확정 (D4-A: 인터셉터 exclude + 컨트롤러 게이트 헬퍼)
|
||||||
|
- **InterceptorConfig 수정**: `addPathPatterns("/admin/**").excludePathPatterns("/admin/jams/**")`. 콘솔(`/admin/console` 등 ADMIN 전용)은 인터셉터 ADMIN 게이트 유지, 잼 관리만 제외.
|
||||||
|
- **JamAdminController 진입부 게이트 헬퍼**: 각 액션 시작에서 아래 순서.
|
||||||
|
1. `gate.isAuthenticated(session)` 거짓 → 페이지면 redirect:/login, API 면 401.
|
||||||
|
2. `gate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())` 거짓 → 403(JSON 또는 페이지 403).
|
||||||
|
3. 통과 후 본문.
|
||||||
|
- **이유**: 인터셉터는 단일 권한(ADMIN)·URL 패턴에 최적(W1-design 결정), 잼 관리는 "ADMIN 또는 SUBADMIN+GAME_JAM_MANAGE" 라 권한 키 판정이 필요하므로 `gate.has`(ADMIN 암묵전권 + SUBADMIN 키보유, PermissionGate.java:30-36) 가 정확히 들어맞는다. 커스텀 어노테이션/AOP 는 W1-design 에서 이미 오버엔지니어링으로 기각된 선례 → 동일하게 게이트 헬퍼 채택.
|
||||||
|
- **중복 0**: 6액션 모두 동일한 private 헬퍼 `requireJamManage(session, response/return)` 로 게이트 + 401/403 응답 작성을 단일화.
|
||||||
|
|
||||||
|
### epoch 전파 연동 (W1 결정4)
|
||||||
|
- `gate.has` 내부가 `refreshIfStale`(PermissionGate.java:86) 로 요청당 epoch 대조 → ADMIN 이 SUBADMIN 에게 GAME_JAM_MANAGE 부여/회수하면 대상 다음 요청에서 즉시 반영(W1 메커니즘 그대로, 본 설계 추가 작업 0).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 시퀀스 (주요 플로우 의사코드)
|
||||||
|
|
||||||
|
### S1. 관리자 잼 생성 → 상태전이(감사)
|
||||||
|
```
|
||||||
|
[SUBADMIN(+GAME_JAM_MANAGE) 세션] POST /admin/jams (CSRF, title/기간/표시필드)
|
||||||
|
→ InterceptorConfig: /admin/jams/** exclude → 인터셉터 미개입
|
||||||
|
→ JamAdminController.createJam
|
||||||
|
→ requireJamManage(session): isAuthenticated? gate.has(GAME_JAM_MANAGE)? (아니면 401/403)
|
||||||
|
→ CsrfTokens.isValid(request) (아니면 403 errorBody)
|
||||||
|
→ 입력 sanitize/길이 검증 + 기간 정합(eval_start ≤ eval_end 등) (아니면 422)
|
||||||
|
→ slug = JamSlugs.generate(title) (충돌 시 재시도 — concern)
|
||||||
|
→ jamsMapper.insertJam(... status='RECRUIT', created_by=actor)
|
||||||
|
→ jamStatusLogMapper.insert(jamId, from=NULL, to='RECRUIT', actor, 'MANUAL')
|
||||||
|
→ 200 {jamId, slug}
|
||||||
|
|
||||||
|
[관리자] POST /admin/jams/42/status (CSRF, toStatus='EVAL')
|
||||||
|
→ JamAdminController.transitionStatus
|
||||||
|
→ requireJamManage + CSRF
|
||||||
|
→ jam = jamsMapper.getById(42) (없으면 404)
|
||||||
|
→ JamLifecycle.assertAllowed(jam.status, 'EVAL') (불가 전이면 409)
|
||||||
|
→ JamLifecycle.assertPeriodReady(jam, 'EVAL') (eval_start_at 미설정 등 422)
|
||||||
|
→ jamsMapper.updateStatus(42, 'EVAL')
|
||||||
|
→ jamStatusLogMapper.insert(42, from=jam.status, to='EVAL', actor, 'MANUAL')
|
||||||
|
→ 200 {status:'EVAL'}
|
||||||
|
```
|
||||||
|
|
||||||
|
### S2. 공개 출품(개인/팀 분기) + 이중 노출
|
||||||
|
```
|
||||||
|
[로그인 유저] POST /jams/{slug}/entries (CSRF, gameId[, jamTeamId])
|
||||||
|
→ JamController.submitEntry
|
||||||
|
→ CsrfTokens.isValid (아니면 403)
|
||||||
|
→ userId = sessionUserId(session) (없으면 401)
|
||||||
|
→ jam = jamsMapper.getBySlug(slug) (없으면 404)
|
||||||
|
→ jam.status IN ('RECRUIT','DEV')? (아니면 422 출품 불가 상태)
|
||||||
|
→ game = gamesMapper.getGame(gameId) (없으면 404)
|
||||||
|
→ resolveEntrant:
|
||||||
|
jamTeamId == null → entrant_type='USER':
|
||||||
|
game.userId == userId? (아니면 422 게임 소유자 아님)
|
||||||
|
entrantUserId = userId
|
||||||
|
else → entrant_type='TEAM':
|
||||||
|
jamTeamMembersMapper.exists(jamTeamId,userId)? (아니면 422 팀 멤버 아님)
|
||||||
|
→ jamEntriesMapper.insert(...) (UNIQUE 위반 시 409 이미 출품 — catch DuplicateKey)
|
||||||
|
→ 200 {entryId}
|
||||||
|
# 이중 노출(D6,D7): games.is_visible 무변경 → 일반 허브 그대로 노출.
|
||||||
|
# 잼 상세는 jam_entries JOIN games 로 별도 노출. 두 경로 동시 성립.
|
||||||
|
|
||||||
|
[공개] GET /jams/{slug}
|
||||||
|
→ JamController.detail
|
||||||
|
→ jam = jamsMapper.getBySlug(slug) (없거나 !is_visible → redirect:/jams)
|
||||||
|
→ entries = jamEntriesMapper.listByJam(jam.id) # JOIN games (이름/썸네일/평가단위 (jam_id,game_id) 자연키)
|
||||||
|
→ model: jam, entries, csrfToken → "jam-detail"
|
||||||
|
```
|
||||||
|
|
||||||
|
### S3. 공개 목록 keyset 페이징 (D5)
|
||||||
|
```
|
||||||
|
[공개] GET /jams?cursor=2026-06-20T10:00:00Z_57
|
||||||
|
→ JamController.list
|
||||||
|
→ (cursor 파싱: createdAt, id. 없으면 첫 페이지)
|
||||||
|
→ jams = jamsMapper.listVisibleKeyset(cursorCreatedAt, cursorId, pageSize+1)
|
||||||
|
# WHERE is_visible IS NOT FALSE AND is_delete IS NOT TRUE
|
||||||
|
# AND (cursor 있으면) (created_at, id) < (cursorCreatedAt, cursorId)
|
||||||
|
# ORDER BY created_at DESC, id DESC LIMIT pageSize+1
|
||||||
|
→ hasNext = jams.size > pageSize; trim; nextCursor = last(created_at)_last(id)
|
||||||
|
→ model: jams, nextCursor → "jam-list"
|
||||||
|
```
|
||||||
|
- **keyset 채택 이유(D5)**: RecruitPostsMapper 는 페이징 전무·전건 로드(RecruitPostsMapper.java:72 직접 확인). 잼 목록/출품작은 누적 증가 → offset 페이징은 깊은 페이지 비용·중복 행 위험. keyset(created_at,id 복합 커서)은 idx_jams_visible_keyset 인덱스로 O(log n) seek. id 동률 tie-break 포함(created_at 단독은 동시각 누락 위험).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 영향 맵
|
||||||
|
|
||||||
|
> 소유권 분할 가이드(implementation-advisor worker 단위 후보):
|
||||||
|
> **J-SCHEMA**(DDL/schema 동기) · **J-DOMAIN**(data POJO/enum/JamLifecycle/JamSlugs) · **J-MAPPER**(5 매퍼) · **J-ADMIN**(JamAdminController + JSP, D4 게이트) · **J-PUBLIC**(JamController + JSP, keyset) · **J-CONFIG**(InterceptorConfig exclude).
|
||||||
|
> 의존: J-SCHEMA → J-DOMAIN → J-MAPPER → {J-ADMIN, J-PUBLIC}. J-CONFIG 는 J-ADMIN 과 짝(exclude 없으면 SUBADMIN 막힘).
|
||||||
|
|
||||||
|
| 변경 유형 | 경로 | 역할 | 소유 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 신규 | `docs/jam-ddl.sql` | 권위 DDL(jams/jam_teams/jam_team_members/jam_entries/jam_status_log). apply-local-ddl.sh 자동 적용 | J-SCHEMA |
|
||||||
|
| 수정 | `db/schema.sql` | 위 5테이블 블록 추가(jam-ddl 사본). games 무변경 | J-SCHEMA |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/JamData.java` | jams 행 POJO(id/slug/title/description/status/기간4/표시3/isVisible/createdAt...) | J-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/JamEntryData.java` | jam_entries 행 + JOIN games 표시필드(gameName/thumbnailUrl/entrantType...) | J-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/JamTeamData.java` | jam_teams 행(+멤버 수 옵션) | J-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/jam/JamStatus.java` | enum RECRUIT/DEV/EVAL/CLOSED + isValid(String) (PermissionKeys 패턴) | J-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/jam/JamLifecycle.java` | 전이 그래프 검증 + 기간 정합 검증(수동/자동 공통 코어, 난제2) | J-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/jam/JamSlugs.java` | title→URL-safe slug 생성(서버 생성, D8) | J-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamsMapper.java` | `@Mapper` jams CRUD + keyset 목록 + getBySlug + updateStatus(`#{}`, snake→camel alias) | J-MAPPER |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamEntriesMapper.java` | `@Mapper` 출품 insert/listByJam(JOIN games)/exists(`#{}`) | J-MAPPER |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamTeamsMapper.java` | `@Mapper` 팀 insert/getById/listByJam(`#{}`) | J-MAPPER |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamTeamMembersMapper.java` | `@Mapper` 멤버 insert/exists(`#{}`) | J-MAPPER |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamStatusLogMapper.java` | `@Mapper` 상태전이 감사 insert(`#{}`) | J-MAPPER |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/JamController.java` | 공개 목록(keyset)/상세 + 출품/팀 액션(CSRF, RecruitController 패턴) | J-PUBLIC |
|
||||||
|
| 신규 | `src/main/webapp/WEB-INF/views/jam-list.jsp` | 잼 목록 + 다음 페이지(nextCursor) | J-PUBLIC |
|
||||||
|
| 신규 | `src/main/webapp/WEB-INF/views/jam-detail.jsp` | 잼 상세 + 출품작 + 출품/팀 폼(CSRF hidden) | J-PUBLIC |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/JamAdminController.java` | `/admin/jams/**` CRUD + 상태전이(D4 게이트 헬퍼) | J-ADMIN |
|
||||||
|
| 신규 | `src/main/webapp/WEB-INF/views/admin-jam-list.jsp` | 잼 관리 목록/폼(CSRF hidden) | J-ADMIN |
|
||||||
|
| 수정 | `src/main/java/com/pandoli365/bibimbap/config/InterceptorConfig.java` | `.excludePathPatterns("/admin/jams/**")` 추가(D4-A) | J-CONFIG |
|
||||||
|
| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 5매퍼 @MockBean 등록(contextLoads 보존, verification §30) | (검증) |
|
||||||
|
| 신규 | `src/test/.../JamAdminControllerTest.java` | CRUD + 상태전이 + 401/403/CSRF/게이트 + 허용전이 검증 | (검증) |
|
||||||
|
| 신규 | `src/test/.../JamControllerTest.java` | 목록 keyset + 상세 + 출품(개인/팀) + 409/422 + 소유/멤버 검증 | (검증) |
|
||||||
|
| 신규 | `src/test/.../JamLifecycleTest.java` | 전이 그래프 허용/거부 + 기간 정합 단위 | (검증) |
|
||||||
|
|
||||||
|
> SSR 호출지점 영향(verification §영향맵): jam_entries 는 games 무변경이라 기존 게임 허브 JSP·매퍼 깨짐 0. 신규 뷰·매퍼만 추가 → 기존 소비처 0 영향.
|
||||||
|
|
||||||
|
### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
|
||||||
|
```java
|
||||||
|
// JamLifecycle — 전이 검증 코어(수동/자동 공통). 상태 enum 만으로 전이 그래프 판정(최소).
|
||||||
|
boolean isAllowed(JamStatus from, // 현재 상태
|
||||||
|
JamStatus to) // 목표 상태 — 허용 전이 그래프 룩업
|
||||||
|
void assertPeriodReady(JamData jam, // 기간 필드 보유 잼(eval_start_at 등)
|
||||||
|
JamStatus to) // 목표 상태가 요구하는 기간 필드 충족 검증(미충족 시 신호)
|
||||||
|
|
||||||
|
// JamSlugs — title → URL-safe slug. title 만으로 생성(충돌 처리는 호출자/매퍼 책임 — concern).
|
||||||
|
String generate(String title) // 정규화·소문자·하이픈·길이절단
|
||||||
|
|
||||||
|
// JamsMapper (@Mapper, #{} only, snake→camel 직접 alias)
|
||||||
|
JamData getById(long jamId) // 관리자 수정/전이 조회
|
||||||
|
JamData getBySlug(String slug) // 공개 상세 조회(활성만)
|
||||||
|
List<JamData> listVisibleKeyset(java.time.OffsetDateTime cursorCreatedAt, // 커서 시각(첫페이지 null)
|
||||||
|
Long cursorId, // 커서 id tie-break(첫페이지 null)
|
||||||
|
int limit) // pageSize+1(hasNext 판정)
|
||||||
|
List<JamData> listAllForAdmin() // 관리자 콘솔 전건(삭제 제외)
|
||||||
|
int insertJam(JamData jam) // 생성(useGeneratedKeys id)
|
||||||
|
int updateJam(JamData jam) // 수정
|
||||||
|
int updateStatus(long jamId, String status) // 상태전이 반영
|
||||||
|
int updateVisibility(long jamId, boolean isVisible) // 가시성 토글
|
||||||
|
int softDelete(long jamId) // 소프트 삭제
|
||||||
|
|
||||||
|
// JamEntriesMapper (@Mapper, #{} only)
|
||||||
|
int insert(JamEntryData entry) // 출품(UNIQUE 위반 시 컨트롤러 catch→409)
|
||||||
|
List<JamEntryData> listByJam(long jamId) // 상세 출품작(JOIN games 표시필드)
|
||||||
|
boolean exists(long jamId, long gameId) // 사전 중복 체크(409 친절 메시지용)
|
||||||
|
|
||||||
|
// JamTeamsMapper (@Mapper, #{} only)
|
||||||
|
int insert(JamTeamData team) // 팀 생성
|
||||||
|
JamTeamData getById(long teamId) // 멤버 추가 시 팀장 검증 소스
|
||||||
|
List<JamTeamData> listByJam(long jamId) // 상세 팀 목록
|
||||||
|
|
||||||
|
// JamTeamMembersMapper (@Mapper, #{} only)
|
||||||
|
int insert(long jamTeamId, long userId) // 멤버 추가
|
||||||
|
boolean exists(long jamTeamId, long userId) // 팀 출품 시 멤버십 검증 + 중복 가입 방지
|
||||||
|
|
||||||
|
// JamStatusLogMapper (@Mapper, #{} only)
|
||||||
|
int insert(long jamId, // 대상 잼
|
||||||
|
String fromStatus, // 이전 상태(최초 NULL 허용)
|
||||||
|
String toStatus, // 전이 후 상태
|
||||||
|
Long actorId, // 수동=관리자 id, 자동=null
|
||||||
|
String transitionType) // 'MANUAL'|'AUTO'
|
||||||
|
```
|
||||||
|
> inflate 마킹(concern 1): `JamLifecycle.assertPeriodReady(jam, to)` 의 `jam` 파라미터는 전체 JamData 를 받지만 실제로는 기간 필드 4개만 읽는다 — 구현에서 사용 필드가 1~2개로 좁혀지면 필요 필드만 받는 시그니처로 축소 검토(과한 전달 방지). `resolveEntrant`(컨트롤러 private)는 의사코드상 분기일 뿐 별도 헬퍼로 추출 시 (userId, jam, gameId, jamTeamId) 전부 실제 사용되는지 구현 시 재확인.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 자동전이 훅 (D3 — 인터페이스만, 구현 후속)
|
||||||
|
- `JamLifecycle.transition(...)` 코어는 수동(JamAdminController)·자동(후속 스케줄러) 양쪽이 호출하는 단일 경로. 자동전이는 `jam_status_log.transition_type='AUTO'`, actor_id=null 로 기록.
|
||||||
|
- 본 설계는 **훅 시그니처만 명시**(아래), `@Scheduled` 빈 구현은 W2-1 범위 밖(concern 5 — context 영향 재확인 의무).
|
||||||
|
```java
|
||||||
|
// (후속) JamAutoTransitionRunner — eval_end_at 경과 잼을 CLOSED 로. @Scheduled 본체는 후속.
|
||||||
|
// 같은 JamLifecycle 검증·감사 경로 재사용(중복 0). 본 설계는 호출 계약만 고정.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 대안 비교
|
||||||
|
|
||||||
|
| 주제 | 안 | 장점 | 단점 | 채택 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 잼-게임 연결 | (A) 조인테이블 jam_entries | 정규화, games 무변경(허브 회귀 0), 다회차 출품·entry 메타 | 테이블 1개 추가 | **채택(D1)** |
|
||||||
|
| | (B) games.jam_id nullable 컬럼 | 단순 | games 변경(허브/삭제연쇄 회귀 위험), 1게임 1잼 한정, entry 메타 불가 | 기각 |
|
||||||
|
| 출품 주체 | (A) entrant_type + user/team XOR | 개인·팀 동시(잼 본질), 단일 테이블 | CHECK 2개 | **채택(D2)** |
|
||||||
|
| | (B) 개인만 우선, 팀은 후속 | 1차 단순 | 잼=팀 이벤트 본질과 어긋남, 후속 스키마 변경 재작업 | 기각 |
|
||||||
|
| 상태 모델 | (A) 명시 컬럼 + 수동전이(감사) + 자동훅 | 운영 통제·감사·자동 보조 공존, 단일 검증 코어 | JamLifecycle 도메인 1개 | **채택(D3)** |
|
||||||
|
| | (B) 기간 필드만 계산 상태 | 컬럼 절약 | 운영 수동 개입 불가, 감사 부재, 경계시각 모호 | 기각 |
|
||||||
|
| 목록 페이징 | (A) keyset(created_at,id 커서) | 깊은 페이지 O(log n), 누락/중복 없음 | 커서 파싱 | **채택(D5)** |
|
||||||
|
| | (B) offset/limit | 단순 | 깊은 페이지 비용·삽입 시 행 밀림 중복 | 기각 |
|
||||||
|
| | (C) 전건 로드(Recruit 선례) | 최단 | 잼 누적 증가 시 비효율 | 기각 |
|
||||||
|
| 잼관리 enforcement | (A) 인터셉터 exclude + 컨트롤러 게이트 헬퍼 | SUBADMIN+키 통과, ADMIN 콘솔 보호 유지, W1 선례 | exclude 1줄 + 헬퍼 | **채택(D4-A)** |
|
||||||
|
| | (B) 인터셉터에 잼 경로별 권한키 매핑 | 중앙집중 | 인터셉터에 경로↔키 테이블 신설(오버엔지니어링, W1서 기각된 방향) | 기각 |
|
||||||
|
| | (C) 임시 role 직접체크 | 빠름 | W1 인프라 우회·정석 위반(금지) | 기각 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 롤아웃 / 마이그레이션
|
||||||
|
|
||||||
|
### 순서
|
||||||
|
1. **스키마 적용**: `docs/jam-ddl.sql` → `db/apply-local-ddl.sh`(로컬). 운영은 동일 멱등 DDL 수동 적용. games 무변경 → 기존 데이터 회귀 0.
|
||||||
|
2. **코드 배포**: J-DOMAIN → J-MAPPER → J-PUBLIC/J-ADMIN/J-CONFIG. InterceptorConfig exclude 와 JamAdminController 게이트는 **동시 배포**(exclude 만 먼저 가면 잼 경로 무보호 노출, 게이트만 먼저 가면 SUBADMIN 인터셉터에 막힘 — 같은 PR/커밋으로).
|
||||||
|
3. **권한 시드 불필요**: GAME_JAM_MANAGE 키는 PermissionCatalogVerifier 가 이미 시드(grounding R-A). 본 설계는 enforcement 연결만.
|
||||||
|
4. **운영**: ADMIN 이 콘솔에서 SUBADMIN 에게 GAME_JAM_MANAGE 부여 → 잼 관리 위임 가능(W1 epoch 즉시 반영).
|
||||||
|
|
||||||
|
### 역호환
|
||||||
|
- 기존 games/허브/리뷰/좋아요 동작 불변(games 무변경, FK 추가만). 기존 사용자 영향 0.
|
||||||
|
- `/admin/jams/**` exclude 는 신규 경로라 기존 `/admin/console` 보호 무변경.
|
||||||
|
|
||||||
|
### 롤백
|
||||||
|
- 코드 롤백: JamController/JamAdminController/InterceptorConfig 되돌리면 잼 경로 미노출. 신규 테이블은 추가 전용이라 잔존 무해(비파괴). 명시 DROP 은 별도 maintenance.
|
||||||
|
- exclude 롤백 시 `/admin/jams/**` 가 다시 인터셉터 ADMIN 게이트로 — 컨트롤러 게이트 헬퍼가 내부에도 있어 이중 안전(미노출이면 무영향).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AC 매핑
|
||||||
|
|
||||||
|
| AC | 요구(골자 W2-1) | 만족 설계 요소 | 비고 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AC-1 | jams 라이프사이클 4상태 + 기간 필드 | jams.status CHECK 4값 + 기간 4컬럼 + JamLifecycle | §데이터모델1, D3 |
|
||||||
|
| AC-2 | 잼-게임 연결 정규화(games 무변경) | jam_entries 조인, games DDL 0변경 | D1, §games무변경 |
|
||||||
|
| AC-3 | 잼당 게임 1회 출품 | ux_jam_entries_jam_game_active UNIQUE | §데이터모델4 |
|
||||||
|
| AC-4 | 개인 OR 팀 출품 | entrant_type + XOR CHECK + jam_teams/members | D2 |
|
||||||
|
| AC-5 | 관리자 잼 CRUD = GAME_JAM_MANAGE 게이트 | JamAdminController requireJamManage(gate.has) + exclude | D4, D4-A |
|
||||||
|
| AC-6 | SUBADMIN(+키) 잼 관리 통과 / 미보유 403 | gate.has(ADMIN OR SUBADMIN+키), 인터셉터 exclude | D4-A, S1 |
|
||||||
|
| AC-7 | 공개 목록(keyset)/상세 | JamController list(keyset)/detail | D5, S3 |
|
||||||
|
| AC-8 | 출품작 이중 노출 | games.is_visible 유지(허브) + jam_entries JOIN(잼뷰) | D6 |
|
||||||
|
| AC-9 | 상태전이 감사 | jam_status_log insert(수동/자동, from/to/actor) | D3, S1 |
|
||||||
|
| AC-10 | 상태변경 전수 CSRF | 모든 쓰기 액션 CsrfTokens.isValid 선검증 → 403 | §외부계약 공통 |
|
||||||
|
| AC-11 | 권한 SQL `${}` 0 | 신규 5매퍼 `#{}` only | §파일영향맵 |
|
||||||
|
| AC-12 | 운영 표시 필드 | jams.discord_url/prize_info/sponsor_info(표시 전용) | D7 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 포인트 (verification-advisor 점검 대상)
|
||||||
|
|
||||||
|
> L레벨 매핑(verification-strategies): 인가/게이트/상태전이 플로우 = **L1+L2+L3**. 신규 매퍼 SQL/alias·keyset 커서 비교 = **L1+L2(dev DB contract)**. 신규 컨트롤러·매퍼 의존 = full `./mvnw -o test` 의무(§30).
|
||||||
|
|
||||||
|
### 시나리오 검증
|
||||||
|
- **VP-1 (AC-5/6 게이트, L1+L3)**: JamAdminControllerTest — ADMIN 세션 통과 / SUBADMIN+GAME_JAM_MANAGE 통과 / SUBADMIN 무키 403 / 미인증 401(redirect). L3 스모크: InterceptorConfig exclude 후 SUBADMIN 이 `/admin/jams` 도달.
|
||||||
|
- **VP-2 (AC-9 전이 감사, L1)**: JamLifecycleTest 허용/거부 전이 + JamAdminControllerTest 전이 후 jam_status_log insert 호출(from/to/actor/type).
|
||||||
|
- **VP-3 (AC-3/4 출품 무결성, L1)**: JamControllerTest — 개인 출품(소유 검증), 팀 출품(멤버 검증), 중복 출품 409, 비소유 게임 422, 비멤버 팀 422, EVAL 상태 출품 422.
|
||||||
|
- **VP-4 (AC-7 keyset, L1+L2)**: 목록 커서 진행 시 중복/누락 0, 동시각 id tie-break 동작. dev DB contract: row-comparison/OR 분해 SQL 실측.
|
||||||
|
- **VP-5 (AC-10 CSRF, L1)**: 쓰기 액션 전수 CSRF 누락 → 403 + mapper 미호출(deleteCommentRejectsMissingCsrfBeforeMapperAccess 패턴 준용).
|
||||||
|
- **VP-6 (DB-방언 계약, L2)**: 신규 매퍼 반환 POJO 키 == 컨트롤러/JSP 조회 키(snake→camel 직접 alias 확인, 큰따옴표 alias 미사용). XOR CHECK·status CHECK 위반 INSERT 거부 실측.
|
||||||
|
- **VP-7 (contextLoads, L1)**: BibimbapApplicationTests 에 신규 5매퍼 @MockBean 등록 후 PASS(§30). 누락 시 NoSuchBeanDefinitionException.
|
||||||
|
|
||||||
|
### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용)
|
||||||
|
> self-audit(시점): 아래 카운트는 **본 설계가 신규 생성하는 정적 산출물**(DDL 테이블·CHECK·매퍼·액션)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리처럼 계속 증가하는 대상 아님.
|
||||||
|
> self-audit(표현): 단일 리터럴 grep 취약성을 피해 enum 멤버/CHECK IN 목록/액션 핸들러 같은 **구조적 불변식**에 앵커. 매퍼 `${` 0건만 리터럴(부재 검증은 리터럴이 정당).
|
||||||
|
|
||||||
|
- **AC-T1 잼 신규 테이블 전수 5건 존재** — docs/jam-ddl.sql 의 `CREATE TABLE IF NOT EXISTS` 5건(jams/jam_teams/jam_team_members/jam_entries/jam_status_log): `grep -c 'CREATE TABLE IF NOT EXISTS' docs/jam-ddl.sql` == 5. AND db/schema.sql 에 동일 5 테이블명 전수 존재(동기 사본 누락 검출). 테이블 추가/삭제 누락을 갯수 1로 커버.
|
||||||
|
- **AC-T2 상태 4값 정합 불변식** — `JamStatus` enum 멤버 수 == jams_status_check CHECK IN 항목 수 == jam_status_log to_status CHECK IN 항목 수 == 4(RECRUIT/DEV/EVAL/CLOSED). 검증: enum 멤버 `grep -c` == 4 AND DDL 두 CHECK IN 목록 각 4항목. 상태 추가 시 enum↔DDL↔log 3곳 동기 누락 동시 검출(불변식).
|
||||||
|
- **AC-T3 관리자 잼 액션 전수 6건 게이트** — JamAdminController 의 핸들러(콘솔/생성/수정/상태전이/가시성/삭제) 전수가 `requireJamManage` 호출: 게이트 헬퍼 호출 수 == 핸들러 수(상태변경 핸들러는 추가로 CsrfTokens.isValid). 핸들러 추가 시 게이트 누락 = 인가 우회 보안결함 → FAIL. **이 전수 AC 가 D4 enforcement 의 핵심 가드**(수동 판정: @PostMapping/@GetMapping 핸들러 열거 후 각 진입부 requireJamManage 확인 — 리터럴 grep 단독 의존 회피).
|
||||||
|
- **AC-T4 잼 매퍼 전수 5개 `${` 0건** — 신규 매퍼 5파일(JamsMapper/JamEntriesMapper/JamTeamsMapper/JamTeamMembersMapper/JamStatusLogMapper)에 `${` 매치 0: `grep -rc '\${' <매퍼 5파일>` == 0 (AC-11, `${}` 동적치환 금지). 부재 검증이라 리터럴 정당.
|
||||||
|
- **AC-T5 entrant XOR 무결성** — jam_entries_entrant_xor_check + jam_entries_entrant_type_check 2 CHECK 전수 존재 AND 위반 INSERT(USER인데 jam_team_id 채움 / 둘 다 NULL / 둘 다 채움)가 DB 거부(L2 실측). 개인/팀 정확히 하나 보장(D2 핵심 가드).
|
||||||
|
- **AC-T6 잼 신규 매퍼 @MockBean 전수 5건** — BibimbapApplicationTests 에 신규 5 매퍼 @MockBean 전수 등록: contextLoads PASS AND `grep -c '@MockBean.*Jam' BibimbapApplicationTests.java` >= 5(또는 매퍼별 등록 수동 확인). 1건 누락 시 contextLoads FAIL 로 즉시 검출(§30, verification 시점에 자기 검증).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 잔여 오픈 질문
|
||||||
|
없음(0). 확정 결정 D1~D8 전제 고정, 두 난제(개인/팀 XOR 정합·상태전이 감사+자동훅)는 본 설계가 구체 메커니즘으로 확정. 인터셉터 exclude vs 컨트롤러 게이트(D4-A)도 확정. 구현 점검 항목(시그니처 inflate·full-test @MockBean·keyset SQL 방언·slug 충돌 재시도·자동전이 스케줄러 context)은 오픈 질문이 아니라 `concerns` 로 이관.
|
||||||
|
|
@ -0,0 +1,416 @@
|
||||||
|
---
|
||||||
|
phase: design
|
||||||
|
agent: design-advisor
|
||||||
|
agent_version: 1
|
||||||
|
generated_at: 2026-06-23T16:00:00+09:00
|
||||||
|
workstream: W2-2-심사위원 역할 권한(잼 스코프 게이트)
|
||||||
|
concerns:
|
||||||
|
- "JamRoleGate.isJudge / 컨트롤러 헬퍼 시그니처는 최소 인자(session, jamId)로 명세했다. 구현 단계에서 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고 방지, 프로토콜 §11.2). 특히 isJudge 가 jamId(bigint) 만 받는지, jam 객체/slug 까지 받는지 — 본 설계는 jamId 최소 채택, 충돌체크 헬퍼 hasOwnEntry(session, jamId) 도 최소 2인자."
|
||||||
|
- "신규 컨트롤러(JamJudgeAdminController) + 신규 매퍼(JamJudgesMapper) 의존 추가 — verification-strategies §30 에 따라 implementation 단계에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 신규 @Mapper @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException. JamRoleGate 가 @Component 면 @MockBean 또는 실제 빈+의존 매퍼 MockBean 필요."
|
||||||
|
- "신규 매퍼 SQL 은 DB-방언 계약(L2) 대상 — snake→camel 직접 alias(jj.created_at AS createdAt)가 일반 매퍼 표준(verification-strategies §33). 큰따옴표 alias 는 집계 VIEW 매퍼만(jam_judges 는 일반 매퍼 → 큰따옴표 금지). exists(EXISTS) 반환 boolean 매핑·listByJam JOIN users 표시필드 dev DB contract 실측 권장."
|
||||||
|
- "★자기출품 충돌 규칙(심사위원=자기 출품작 점수입력 불가)은 본 설계가 '계약'으로 정의하고 enforce 는 W2-4 점수입력 컨트롤러 소관이다. 본 W2-2 는 충돌 판정 헬퍼(JamRoleGate.hasOwnEntry 또는 JamEntriesMapper.existsEntrantUser)만 제공·계약 고정. W2-4 가 점수입력 진입부에서 이 헬퍼를 소비하는지 verification 시점 재확인 필요(crossRefs)."
|
||||||
|
- "심사위원 지정 게이트 = GAME_JAM_MANAGE 이며 인터셉터 exclude 경로는 W2-1 의 /admin/jams/** 와 동일 트리(/admin/jams/{id}/judges). W2-1 InterceptorConfig.excludePathPatterns(\"/admin/jams/**\") 가 이미 /admin/jams/{id}/judges 를 커버하므로 본 설계는 InterceptorConfig 를 추가 수정하지 않는다(W2-1 J-CONFIG 와 충돌 0). 단 W2-1 미배포 상태에서 본 컨트롤러만 배포되면 인터셉터 isAdmin 게이트에 SUBADMIN 이 막힘 — 배포 순서 의존(롤아웃 §순서)."
|
||||||
|
- "심사위원 자기출품 충돌 enforce 시점이 '지정 시점' 이 아니라 '점수입력 시점'(W2-4)이다. 따라서 자기 출품작이 있는 유저도 심사위원으로 지정될 수 있고(다른 출품작은 심사 가능), 자기 출품작에 대해서만 점수입력이 거부된다. 지정 자체를 막지 않는 이유는 §충돌 규칙 계약에 명시 — 구현이 지정 시점에 막지 않도록 주의."
|
||||||
|
concerns_checked: true
|
||||||
|
self_verification:
|
||||||
|
checklist_passed: true
|
||||||
|
references:
|
||||||
|
requirements: docs/work-log/2026-06-23-w2-w4-feature-skeletons.md
|
||||||
|
research: .atp/work-session/20260623-104307/research/W2-W4-grounding.md
|
||||||
|
adrs:
|
||||||
|
- .atp/work-session/20260623-104307/implementation/W2-1-jam-entity-design.md
|
||||||
|
- .atp/work-session/20260623-104307/implementation/W2-3-eval-freeze-design.md
|
||||||
|
- .atp/work-session/20260622-180054/implementation/W1-design.md
|
||||||
|
- docs/work-log/2026-06-17-jam-platform-roadmap.md
|
||||||
|
- docs/development/verification-strategies.md
|
||||||
|
- docs/rbac-ddl.sql
|
||||||
|
- docs/jam-ddl.sql
|
||||||
|
---
|
||||||
|
|
||||||
|
# 설계: W2-2 — 심사위원 역할 권한 (잼 스코프 게이트 / jam_judges + 지정 CRUD + 자기출품 충돌 계약)
|
||||||
|
|
||||||
|
> ★보안 워크스트림(권한 스코프). 핵심 보안 단언: **전역 RBAC(user_permissions)는 불변** — 잼 회차별 역할은 **잼 스코프 조인테이블(jam_judges)** 로 표현하고, 판정은 **잼 스코프 게이트(JamRoleGate.isJudge(session, jamId))** 로 한다. 전역 PermissionGate 와 별도 축. W2-4 점수입력이 본 게이트 + 자기출품 충돌 규칙을 소비.
|
||||||
|
|
||||||
|
## 목표 / 비목표
|
||||||
|
|
||||||
|
### 목표 (FR/NFR 추적 — 골자 W2-2)
|
||||||
|
- **G1 잼 스코프 역할 모델**: 잼 회차별 심사위원 역할을 **별도 `jam_judges` 테이블**(jam_id, user_id)로 표현. 전역 `user_permissions`(RBAC) 무변경 — 잼별 역할을 전역 권한 모델에 끼워넣지 않음(스코프 갭 정석 해소, QG-W2-A 채택 (b)).
|
||||||
|
- **G2 잼 스코프 게이트**: `JamRoleGate.isJudge(session, jamId)` — `jam_judges` 조회로 잼별 심사위원 자격 판정. W1 `PermissionGate`(전역 키 판정)와 **별도 축**. 리소스(jamId) 인자 보유.
|
||||||
|
- **G3 심사위원 지정/해제 = GAME_JAM_MANAGE 게이트**: `/admin/jams/{jamId}/judges` 추가/제거. 진입부에서 `PermissionGate.has(session, GAME_JAM_MANAGE)` 통과 요구(W1 인프라 위, **임시 role 직접체크 금지**). 임명 주체 = 잼 관리자(ADMIN 또는 SUBADMIN+GAME_JAM_MANAGE).
|
||||||
|
- **G4 자기출품 충돌 계약**: 심사위원은 같은 잼 **자기 출품작** 점수 입력 불가(자기출품 충돌 회피). 본 설계는 **충돌 판정 헬퍼 + 계약**을 제공하고, enforce 는 **W2-4 점수입력 컨트롤러**가 수행(계약 명시).
|
||||||
|
- **G5 역할 수명**: 잼 종료 후 `jam_judges` 레코드 **잔존**(이력). 점수입력 게이트의 활성 여부는 W2-3 평가기간 게이트(EVAL + 기간)가 별도로 통제 — 역할 자체는 만료 회수하지 않음.
|
||||||
|
- **G6 심사위원 자격**: 누구나 지정 가능(일반 USER 포함). 권한은 **잼별 부여**(전역 role 무관). USER 도 특정 잼 심사위원이 될 수 있음.
|
||||||
|
- **G7 지정 현황 조회**: 잼 관리자가 잼별 심사위원 목록을 조회(지정/해제 UI 소스).
|
||||||
|
- **NFR**: 상태변경(지정/해제) CSRF 전수, `#{}` 바인딩(`${}` 금지), 입력 검증, 비파괴 멱등 마이그레이션, DDL 권위=docs/*-ddl.sql.
|
||||||
|
|
||||||
|
### 비목표 (스코프 밖)
|
||||||
|
- **심사 점수 입력 API·UI·집계** — **W2-4 소유**. 본 설계는 `JamRoleGate.isJudge` 게이트 + 자기출품 충돌 헬퍼 **계약만** 제공. W2-4 가 점수입력 진입부에서 소비.
|
||||||
|
- **평가기간 게이트(EVAL + now∈[eval_start,eval_end])** — **W2-3 동결 계약(F6)**. 본 설계는 심사위원 *자격* 판정만, 평가 *시점* 게이트는 W2-3 소관(점수입력 시 두 게이트 AND).
|
||||||
|
- **잼 엔티티/CRUD/출품(jams/jam_entries/jam_teams)** — **W2-1 소유**. 본 설계는 jam_id FK 참조 + jam_entries 활성행 조회만.
|
||||||
|
- **전역 RBAC 모델 변경(user_permissions scope 컬럼 추가)** — 채택 안 함(QG-W2-A (a) 기각). 전역 모델 불변.
|
||||||
|
- **심사위원 인원 제한·정원·초대 워크플로** — 1차 미포함(누구나 지정 가능, 인원 무제한). 정원 정책은 후속(concern 아님 — 명시적 비목표).
|
||||||
|
- **InterceptorConfig 수정** — W2-1 이 이미 `/admin/jams/**` exclude 추가(W2-1 J-CONFIG). `/admin/jams/{id}/judges` 는 그 트리 하위라 추가 수정 불요(아래 §인터셉터 연동).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
bibimbap 은 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis annotation `@Mapper`(`#{}` only) + JSP 스택이다. 현재 `user_permissions` 는 **전역(글로벌) 권한 모델**이다 — 컬럼 (id/user_id/permission_key/granted_by/created_at), UNIQUE(user_id, permission_key), **scope/resource_id/jam_id 컬럼 부재**(grounding R-A, db/schema.sql:326-347). `PermissionGate.has(session, permissionKey)` 도 리소스 인자가 없다(PermissionGate.java:22 직접 확인). 따라서 "잼 회차별 심사위원 역할"은 전역 권한 모델 위에 그대로 얹히지 않는다(스코프 갭).
|
||||||
|
|
||||||
|
본 설계는 **별도 잼 스코프 조인테이블 `jam_judges`(jam_id, user_id)** 를 신규 도입하고(QG-W2-A 채택안 (b)), **잼 스코프 게이트 `JamRoleGate.isJudge(session, jamId)`** 를 제공한다. 전역 `PermissionGate` 는 무변경 — 두 게이트는 **서로 다른 축**(전역 권한 키 vs 잼 리소스 역할)이다. 심사위원 **지정/해제**는 잼 관리자 권한(`GAME_JAM_MANAGE`)으로 보호하고(W1 게이트 위, 임시 role 직접체크 금지), **자기출품 충돌**은 W2-4 가 소비할 계약으로 고정한다.
|
||||||
|
|
||||||
|
확정된 정석 결정(전제 — 재논의 금지):
|
||||||
|
- **별도 테이블 (b) 채택**: 전역 RBAC 에 scope 컬럼을 끼워넣는 (a)안은 전역 모델·게이트 시그니처를 침습적으로 바꾸고(회귀 위험) 잼 외 다른 스코프 역할이 생길 때마다 컬럼이 늘어난다. (b) `jam_judges` 는 잼 스코프 역할의 자연스러운 정규화이며 전역 모델 불변(회귀 0). 하이브리드 (c)는 over-engineering.
|
||||||
|
- **게이트 축 분리**: `JamRoleGate.isJudge(session, jamId)` 는 잼 리소스 인자를 받는 별도 게이트. 전역 `PermissionGate.has(session, key)` 시그니처를 건드리지 않는다(W1 회귀 0).
|
||||||
|
- **지정 게이트 = GAME_JAM_MANAGE**: 심사위원 지정은 잼 관리 행위 → W2-1 의 `/admin/jams/**` enforcement 패턴(인터셉터 exclude + 컨트롤러 게이트 헬퍼)을 그대로 재사용.
|
||||||
|
- **충돌 enforce 시점 = 점수입력(W2-4)**: 지정 시점이 아니라 점수입력 시점에 자기 출품작을 거부. 자기 출품작 보유 유저도 심사위원 지정 가능(다른 작품 심사 가능, 자기 작품만 점수입력 거부).
|
||||||
|
|
||||||
|
가장 까다로운 두 난제 확정:
|
||||||
|
- **난제1 (스코프 게이트 축 분리 — 전역 모델 보호)**: 잼별 역할을 전역 `user_permissions` 에 표현하려면 (user_id, permission_key) 에 scope/jam_id 가 필요한데, 이는 전역 UNIQUE(user_id, permission_key)·`PermissionGate.has(session, key)` 2인자 시그니처를 깬다(W1 회귀). 본 설계는 **전역 권한 축(PermissionGate, 키 기반, 잼 무관)** 과 **잼 스코프 역할 축(JamRoleGate, jam_judges 조회, jamId 기반)** 을 명확히 분리한다. 심사위원 자격은 전역 권한이 아니라 잼 리소스 멤버십이므로 별도 축이 정석. epoch 전파(W1 결정4)는 전역 권한에만 적용 — 잼 역할은 jam_judges 직접 조회(요청당 조회, 캐시 안 함 — 잼 역할 변경 즉시 반영, 별도 epoch 불요).
|
||||||
|
- **난제2 (자기출품 충돌 — 개인/팀 entrant 모두 커버)**: W2-1 `jam_entries` 는 entrant_type('USER'/'TEAM') + entrant_user_id(개인) / jam_team_id(팀) XOR 구조다. "자기 출품작" 은 ① 개인 출품: `jam_entries.entrant_user_id == 심사위원 userId`, ② 팀 출품: 심사위원이 그 팀(`jam_entries.jam_team_id`)의 멤버(`jam_team_members.user_id == 심사위원`). 본 설계는 충돌 판정 계약을 **두 경로 모두**로 정의하고, 판정 헬퍼 `hasOwnEntry(session, jamId)`(또는 매퍼 `existsConflictEntry(jamId, userId)`) 를 제공한다. W2-4 가 점수입력 대상 game_id 에 대해 "이 출품작이 심사위원 본인 것인가"를 검사 — 본 W2-2 는 게임 단위 충돌 판정 매퍼 `isOwnEntry(jamId, gameId, userId)` 시그니처를 동결 제공.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 핵심 결정 요약 (전제 — 재논의 금지. orchestrator 확정값)
|
||||||
|
|
||||||
|
| 결정 | 확정값 | 본 설계의 구체화 |
|
||||||
|
|---|---|---|
|
||||||
|
| J1 역할 모델 | 별도 `jam_judges` 테이블 | jam_judges(id, jam_id FK, user_id FK, assigned_by FK, created_at, UNIQUE(jam_id,user_id)). 전역 user_permissions 불변 |
|
||||||
|
| J2 게이트 | `JamRoleGate.isJudge(session, jamId)` | 잼 스코프 게이트(별도 축). jam_judges 조회 판정. PermissionGate(전역)와 분리 |
|
||||||
|
| J3 지정 게이트 | GAME_JAM_MANAGE | `/admin/jams/{jamId}/judges` 추가/제거 = `PermissionGate.has(session, GAME_JAM_MANAGE.name())`. 임시 role 직접체크 금지 |
|
||||||
|
| J4 자기출품 충돌 | 점수입력 시 자기출품작 거부 | 본 설계=충돌 판정 헬퍼 + 계약. enforce=W2-4. 개인+팀 entrant 모두 커버 |
|
||||||
|
| J5 역할 수명 | 잼 종료 후 잔존(이력) | jam_judges 만료 회수 없음. 점수입력 활성=W2-3 평가기간 게이트가 별도 통제 |
|
||||||
|
| J6 자격 | 누구나 지정(USER 포함) | 전역 role 무관. 권한은 잼별 부여 |
|
||||||
|
| J7 충돌 enforce 시점 | 점수입력 시점(지정 아님) | 자기출품작 있어도 지정 가능. 자기 작품만 점수입력 거부 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 모델 (DDL)
|
||||||
|
|
||||||
|
> 권위 = **신규 파일 `docs/jam-judge-ddl.sql`** (apply-local-ddl.sh 가 docs/*-ddl.sql 알파벳 글롭으로 멱등 적용, ON_ERROR_STOP, search_path=dev). `db/schema.sql` 에 동기 사본(아래 §schema.sql 반영). 선례: W1 docs/rbac-ddl.sql, W2-1 docs/jam-ddl.sql, W2-3 docs/jam-eval-ddl.sql(직접 확인). **jams/users 변경 없음**(FK 참조만). 멱등: CREATE TABLE/SEQUENCE IF NOT EXISTS, DO $$ guard, CREATE UNIQUE INDEX IF NOT EXISTS. 타입은 기존 스타일(bigint/varchar/timestamptz).
|
||||||
|
>
|
||||||
|
> ⚠️ **알파벳 글롭 순서**: `apply-local-ddl.sh` 가 docs/*-ddl.sql 을 알파벳순 적용. `jam_judges` FK 가 `jams`(W2-1 docs/jam-ddl.sql) + `users`(기존) 를 참조하므로 jam-ddl 이 jam-judge 보다 **먼저** 적용돼야 한다. 알파벳: 공통 prefix `jam-` 뒤 `d`(jam-**d**dl) < `j`(jam-**j**udge) → jam-ddl 먼저 적용 보장(롤아웃 §순서 재확인). game-reviews-ddl(`g`)·rbac-ddl(`r`)과도 무충돌.
|
||||||
|
|
||||||
|
### 신규 파일: `docs/jam-judge-ddl.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- W2-2 심사위원 역할 권한(잼 스코프). 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
|
||||||
|
-- 선행: docs/jam-ddl.sql(jams — 알파벳 글롭 순 jam-ddl 먼저 적용).
|
||||||
|
-- 전역 user_permissions(RBAC) 변경 없음 — 잼 회차별 역할은 잼 스코프 조인이 정석.
|
||||||
|
-- 추가만, 파괴 없음.
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- 1) jam_judges (잼별 심사위원. 잼 스코프 역할. 전역 권한과 별도 축)
|
||||||
|
-- ===========================================================================
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "jam_judges_id_seq";
|
||||||
|
CREATE TABLE IF NOT EXISTS "jam_judges" (
|
||||||
|
"id" bigint DEFAULT nextval('jam_judges_id_seq'::regclass) NOT NULL,
|
||||||
|
"jam_id" bigint NOT NULL, -- 잼 회차(FK jams)
|
||||||
|
"user_id" bigint NOT NULL, -- 심사위원(FK users; 누구나 가능)
|
||||||
|
"assigned_by" bigint, -- 지정 관리자(FK users; 감사 보조, nullable)
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
ALTER SEQUENCE "jam_judges_id_seq" OWNED BY "jam_judges"."id";
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_judges_jam_fkey') THEN
|
||||||
|
ALTER TABLE "jam_judges" ADD CONSTRAINT "jam_judges_jam_fkey"
|
||||||
|
FOREIGN KEY ("jam_id") REFERENCES "jams" ("id");
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_judges_user_fkey') THEN
|
||||||
|
ALTER TABLE "jam_judges" ADD CONSTRAINT "jam_judges_user_fkey"
|
||||||
|
FOREIGN KEY ("user_id") REFERENCES "users" ("id");
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_judges_assigned_by_fkey') THEN
|
||||||
|
ALTER TABLE "jam_judges" ADD CONSTRAINT "jam_judges_assigned_by_fkey"
|
||||||
|
FOREIGN KEY ("assigned_by") REFERENCES "users" ("id");
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
-- 같은 잼에 같은 유저 중복 지정 방지(멱등 지정). 잼 종료 후 잔존(J5) — soft delete 없음(이력=행 존재).
|
||||||
|
-- 해제는 hard DELETE(역할 회수). 재지정은 다시 INSERT.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_judges_jam_user"
|
||||||
|
ON "jam_judges" ("jam_id", "user_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_jam_judges_jam"
|
||||||
|
ON "jam_judges" ("jam_id");
|
||||||
|
```
|
||||||
|
|
||||||
|
> **soft delete 미채택 근거(J5 정합)**: jam_judges 는 `is_delete` 를 두지 않는다. "잔존 이력"의 단위는 *잼 종료 후에도 행이 남는다*(만료 회수 안 함)는 의미이고, **해제(관리자가 명시적으로 심사위원 자격 박탈)** 는 역할 회수이므로 hard DELETE 가 정석(행 존재 = 현재 심사위원). soft delete 를 두면 isJudge 판정마다 `is_delete IS NOT TRUE` 필터가 필요하고 UNIQUE 도 partial 이어야 해 복잡도만 늘린다. 해제 이력이 필요하면 후속에 별도 감사 로그(비목표). UNIQUE(jam_id, user_id) full 로 멱등 지정 보장.
|
||||||
|
|
||||||
|
### `db/schema.sql` 반영 (최초 기동 1회 자동 주입 — docs/jam-judge-ddl.sql 의 사본)
|
||||||
|
- W2-1 의 jams/jam_entries 블록 **뒤**(jams 가 FK 참조 대상이므로 schema.sql 순차 실행상 jams 가 먼저 정의돼야 함)에 위 1번을 **신설 블록**으로 추가.
|
||||||
|
- 헤더 주석: `-- 심사위원 역할 W2-2 (권위 DDL — docs/jam-judge-ddl.sql 와 동일. 잼 스코프 역할)` — game_reviews 블록 schema.sql:128 의 `(권위 DDL — docs/...-ddl.sql 와 동일)` 선례와 동형(직접 확인).
|
||||||
|
- 반영 방식: **docs/jam-judge-ddl.sql 이 권위, schema.sql 은 사본**. 두 곳에 동일 멱등 DDL.
|
||||||
|
|
||||||
|
### 전역 RBAC / jams / users 무변경 확인 (J1 보안 단언)
|
||||||
|
- `user_permissions`/`permissions`/`users` 테이블 변경 0. 잼 스코프 역할은 전부 `jam_judges` 가 보유 → W1 RBAC 동작(전역 권한 판정·epoch 전파) 회귀 0.
|
||||||
|
- `jams`/`jam_entries`/`jam_teams` 변경 0. FK 로 jams.id 참조만. W2-1 잼 동작 회귀 0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 외부 계약 (API)
|
||||||
|
|
||||||
|
> 공통: 모든 상태변경(지정/해제)은 `CsrfTokens.isValid(request)` 검증(없으면 403 + `CsrfTokens.errorBody()`, CsrfTokens.java 확인). 응답은 RecruitController/W2-1 패턴 — 읽기=JSP 뷰이름 반환 또는 JSON 조회, 쓰기=`ResponseEntity<Map<String,Object>>`(status/message). 관리자 API 는 진입부에서 `PermissionGate.has(session, GAME_JAM_MANAGE.name())` 게이트 통과 후 본문 수행(W2-1 requireJamManage 헬퍼 재사용 후보).
|
||||||
|
|
||||||
|
### 401 vs 403 vs 422 정책 (W1-design / W2-1 / W2-3 과 일치)
|
||||||
|
- **미인증**(세션 `userId` 없음): API 401 JSON `{status:401, message:"로그인이 필요합니다."}`. 페이지는 `redirect:/login`.
|
||||||
|
- **인증·미인가**(GAME_JAM_MANAGE 없음): 403 JSON `{status:403, message:"권한이 없습니다."}`(리다이렉트 금지).
|
||||||
|
- **자기출품 충돌**(W2-4 점수입력에서 본인 출품작): **422** JSON `{status:422, message:"본인 출품작은 심사할 수 없습니다."}` (인가는 됐으나 도메인 충돌 → 403 아님 422. W2-3 의 "평가기간 외=422" 정책과 동형 — 인가/시점/충돌은 422 계열).
|
||||||
|
- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`.
|
||||||
|
|
||||||
|
### 심사위원 지정/해제/조회 (관리자 API — CSRF + GAME_JAM_MANAGE 게이트)
|
||||||
|
| 액션 | method | path | 요청 | 응답(200) | 에러 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 지정 | POST | `/admin/jams/{jamId}/judges` | userId | `{status:200, message, jamId, userId}` | 401/redirect, 403(CSRF/권한), 404(잼/대상유저 없음), 409(이미 심사위원) |
|
||||||
|
| 해제 | POST | `/admin/jams/{jamId}/judges/{userId}/remove` | (path) | `{status:200, message, jamId, userId, removed:true}` | 401, 403, 404(지정 안 됨) |
|
||||||
|
| 목록 조회 | GET | `/admin/jams/{jamId}/judges` | (없음) | `{status:200, judges:[{userId, displayName, assignedBy, createdAt}]}` | 401/redirect, 403 |
|
||||||
|
|
||||||
|
- **지정 게이트(J3) enforcement**: 위 3액션 전부 진입부에서 `gate.isAuthenticated(session)`(401/redirect) → `gate.has(session, GAME_JAM_MANAGE.name())`(403) 2단계. PermissionGate.isAuthenticated(:47)/has(:22) 직접 확인. W2-1 의 `requireJamManage` private 헬퍼와 동일 패턴(재사용 권장 — 중복 0).
|
||||||
|
- **해제 = hard DELETE**: `removed:true` 응답. 멱등(이미 해제됐으면 404 또는 removed:false — 본 설계는 404 채택, 행 없음).
|
||||||
|
- **누구나 지정 가능(J6)**: 지정 대상 userId 의 전역 role 검사 없음. USER/SUBADMIN/ADMIN 무관 지정 가능. 단 대상 user 가 실재(`users` 활성)하는지만 검증(404).
|
||||||
|
- **자기출품 충돌은 지정에서 막지 않음(J7)**: 대상 유저가 그 잼에 출품했어도 지정 허용. 충돌은 점수입력(W2-4)에서만 거부.
|
||||||
|
|
||||||
|
### 점수입력 게이트 계약 (J2/J4 — W2-4 소비, 권위)
|
||||||
|
> 본 W2-2 는 점수입력 API 를 신설하지 않는다. 아래는 W2-4 가 점수입력 진입부에서 **준수해야 할 계약**이다.
|
||||||
|
|
||||||
|
- **심사위원 자격(J2)**: `JamRoleGate.isJudge(session, jamId)` true 여야 점수입력 허용. false → 403. (전역 GAME_JAM_MANAGE 와 무관 — 잼 스코프 역할.)
|
||||||
|
- **자기출품 충돌(J4)**: 점수입력 대상 game_id 에 대해 `JamRoleGate.isOwnEntry(jamId, gameId, judgeUserId)` true 면 거부(422 "본인 출품작은 심사할 수 없습니다"). 개인 출품(entrant_user_id==judge) + 팀 출품(judge 가 그 팀 멤버) 모두 충돌(난제2).
|
||||||
|
- **평가기간 게이트(W2-3 F6)**: `jam.status=='EVAL' AND now()∈[eval_start_at, eval_end_at]` (W2-3 소관). W2-4 진입부 게이트 순서 = ① CSRF → ② 로그인(401) → ③ 평가기간(W2-3, 422) → ④ isJudge(W2-2, 403) → ⑤ 자기출품 충돌(W2-2, 422) → 점수입력. (순서는 W2-4 결정 — 본 계약은 4·5 가 W2-2 소비점임만 고정.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 인터셉터 / 게이트 연동 (J3 — W2-1 enforcement 재사용)
|
||||||
|
|
||||||
|
### 문제 / 전제
|
||||||
|
- `RbacInterceptor.preHandle` 은 `/admin/**` 전체에 `isAdmin(session)` 만 검사(RbacInterceptor.java:34, PermissionGate.isAdmin:55-62 — role==ADMIN 만 true). SUBADMIN+GAME_JAM_MANAGE 는 인터셉터에 막힌다.
|
||||||
|
- W2-1 이 이 갭을 `InterceptorConfig.addPathPatterns("/admin/**").excludePathPatterns("/admin/jams/**")` 로 해소(W2-1 D4-A, J-CONFIG). `/admin/jams/{jamId}/judges` 는 `/admin/jams/**` 트리 하위이므로 **W2-1 의 exclude 가 이미 커버**한다.
|
||||||
|
|
||||||
|
### 확정 (J3-A: InterceptorConfig 추가 수정 없음 + 컨트롤러 게이트 헬퍼)
|
||||||
|
- **InterceptorConfig 무수정**: W2-1 의 `/admin/jams/**` exclude 가 `/admin/jams/{id}/judges` 를 포함 → 본 설계는 InterceptorConfig 를 건드리지 않는다(W2-1 J-CONFIG 와 충돌 0, 중복 exclude 0).
|
||||||
|
- **JamJudgeAdminController 진입부 게이트 헬퍼**: 각 액션 시작에서 `requireJamManage(session)`(W2-1 헬퍼 재사용 또는 동형):
|
||||||
|
1. `gate.isAuthenticated(session)` 거짓 → 페이지면 redirect:/login, API 면 401.
|
||||||
|
2. `gate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())` 거짓 → 403.
|
||||||
|
3. 통과 후 본문.
|
||||||
|
- **이유**: 심사위원 지정은 잼 관리 행위 → W2-1 잼 CRUD 와 동일 권한·동일 경로 트리. 별도 enforcement 메커니즘을 만들 이유 0(W2-1 선례 재사용, 중복 0).
|
||||||
|
- **배포 순서 의존(concern 5)**: W2-1 의 exclude 가 배포되기 전 본 컨트롤러만 배포되면 인터셉터 isAdmin 게이트에 SUBADMIN 이 막힌다. → W2-1 이후 배포(롤아웃 §순서).
|
||||||
|
|
||||||
|
### epoch 전파 연동 (W1 결정4 — 전역 권한만)
|
||||||
|
- `gate.has` 내부 `refreshIfStale`(PermissionGate.java:86) 가 요청당 전역 epoch 대조 → ADMIN 이 SUBADMIN 에게 GAME_JAM_MANAGE 부여/회수 시 즉시 반영(W1 메커니즘 그대로, 본 설계 추가 작업 0).
|
||||||
|
- **잼 역할(jam_judges)은 epoch 미사용**: `JamRoleGate.isJudge` 는 요청당 jam_judges 직접 조회(캐시 안 함). 심사위원 지정/해제는 다음 요청에서 즉시 반영(별도 epoch 스탬프 불요 — 조회가 곧 최신). 전역 권한 캐시(세션 permissions Set)와 다른 정책인 이유: 잼 역할은 세션에 캐시하지 않으므로 stale 문제가 없다(저비용 단일 인덱스 EXISTS 조회).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 시퀀스 (주요 플로우 의사코드)
|
||||||
|
|
||||||
|
### S1. 심사위원 지정 → 해제
|
||||||
|
```
|
||||||
|
[잼 관리자(ADMIN 또는 SUBADMIN+GAME_JAM_MANAGE) 세션] POST /admin/jams/42/judges (CSRF, userId=7)
|
||||||
|
→ InterceptorConfig: /admin/jams/** exclude(W2-1) → 인터셉터 미개입
|
||||||
|
→ JamJudgeAdminController.assignJudge(42, userId=7)
|
||||||
|
→ requireJamManage(session): isAuthenticated? gate.has(GAME_JAM_MANAGE)? (아니면 401/403)
|
||||||
|
→ CsrfTokens.isValid(request) (아니면 403 errorBody)
|
||||||
|
→ jam = jamsMapper.getById(42) (없으면 404) # W2-1 매퍼 소비
|
||||||
|
→ target = usersMapper.getUser(7) (없으면 404)
|
||||||
|
→ jamJudgesMapper.exists(42, 7)? (이미면 409 이미 심사위원)
|
||||||
|
→ jamJudgesMapper.insert(42, 7, assignedBy=actorId) (UNIQUE 보장 — race 시 catch DuplicateKey→409)
|
||||||
|
→ 200 {jamId:42, userId:7}
|
||||||
|
# 자기출품 충돌은 지정에서 막지 않음(J7) — target 이 42 에 출품했어도 지정 허용.
|
||||||
|
|
||||||
|
[잼 관리자] POST /admin/jams/42/judges/7/remove (CSRF)
|
||||||
|
→ JamJudgeAdminController.removeJudge(42, 7)
|
||||||
|
→ requireJamManage + CSRF
|
||||||
|
→ affected = jamJudgesMapper.delete(42, 7) # hard DELETE(역할 회수, J5)
|
||||||
|
→ affected == 0 → 404 (지정 안 됨)
|
||||||
|
→ 200 {removed:true}
|
||||||
|
```
|
||||||
|
|
||||||
|
### S2. 점수입력 게이트 소비 (W2-4 — 본 계약 검증용. 본 설계 미구현)
|
||||||
|
```
|
||||||
|
[유저 7 세션] POST /jams/{slug}/scores (CSRF, gameId=88, {criterionKey:score,...}) # W2-4 컨트롤러
|
||||||
|
→ CsrfTokens.isValid (아니면 403)
|
||||||
|
→ userId = sessionUserId(=7) (없으면 401)
|
||||||
|
→ jam = jamsMapper.getBySlug(slug) (없으면 404)
|
||||||
|
→ [W2-3 F6] jam.status=='EVAL' AND now∈[eval_start,eval_end]? (아니면 422 평가기간 외)
|
||||||
|
→ [W2-2 J2] jamRoleGate.isJudge(session, jam.id)? (아니면 403 심사위원 아님)
|
||||||
|
→ [W2-2 J4] jamRoleGate.isOwnEntry(jam.id, gameId=88, userId=7)?
|
||||||
|
true → 422 "본인 출품작은 심사할 수 없습니다" # 자기출품 충돌(개인 또는 팀멤버)
|
||||||
|
→ jamScoresMapper.upsertScore(...) (W2-3 동결 매퍼)
|
||||||
|
→ 200 {message}
|
||||||
|
```
|
||||||
|
|
||||||
|
### S3. isJudge / isOwnEntry 판정 내부 (JamRoleGate)
|
||||||
|
```
|
||||||
|
JamRoleGate.isJudge(session, jamId):
|
||||||
|
userId = sessionUserId(session) # PermissionGate.sessionUserId 와 동형
|
||||||
|
if userId == null: return false # 미인증은 심사위원 아님
|
||||||
|
return jamJudgesMapper.exists(jamId, userId) # 단일 인덱스 EXISTS(ux_jam_judges_jam_user)
|
||||||
|
|
||||||
|
JamRoleGate.isOwnEntry(jamId, gameId, userId): # 게임 단위 자기출품 충돌(난제2)
|
||||||
|
# jam_entries 활성행에서 (jamId, gameId) 출품작의 entrant 가 userId 본인인지.
|
||||||
|
# 개인: entrant_user_id == userId / 팀: jam_team_members 에 (jam_team_id, userId) 존재.
|
||||||
|
return jamEntriesMapper.isOwnEntry(jamId, gameId, userId) # 아래 매퍼 SQL 계약
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 영향 맵
|
||||||
|
|
||||||
|
> 소유권 분할 가이드(implementation-advisor worker 단위 후보):
|
||||||
|
> **K-SCHEMA**(DDL/schema 동기) · **K-DOMAIN**(data POJO) · **K-MAPPER**(JamJudgesMapper + JamEntriesMapper 충돌판정 메서드 추가) · **K-GATE**(JamRoleGate) · **K-ADMIN**(JamJudgeAdminController + JSP 또는 W2-1 admin-jam JSP 확장).
|
||||||
|
> 의존: K-SCHEMA → K-DOMAIN → K-MAPPER → {K-GATE, K-ADMIN}. K-GATE 는 W2-4 점수입력의 공통 선행(계약).
|
||||||
|
> **W2-1 의존**: jamsMapper.getById/getBySlug(존재), JamEntryData/jam_entries 스키마, InterceptorConfig exclude. K-MAPPER 의 isOwnEntry 는 W2-1 의 JamEntriesMapper 에 메서드 추가(소유권 경계 — concern·crossRefs).
|
||||||
|
|
||||||
|
| 변경 유형 | 경로 | 역할 | 소유 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 신규 | `docs/jam-judge-ddl.sql` | 권위 DDL(jam_judges). apply-local-ddl.sh 자동 적용(알파벳: jam-ddl 뒤) | K-SCHEMA |
|
||||||
|
| 수정 | `db/schema.sql` | jam_judges 블록 추가(jam-judge-ddl 사본). jams 블록 뒤. 전역 RBAC/jams 무변경 | K-SCHEMA |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/JamJudgeData.java` | jam_judges 행 + JOIN users 표시필드(userId/displayName/assignedBy/createdAt) | K-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamJudgesMapper.java` | `@Mapper` 지정 insert/delete/exists/listByJam(JOIN users)(`#{}`, snake→camel 직접 alias) | K-MAPPER |
|
||||||
|
| 수정 | `src/main/java/com/pandoli365/bibimbap/mapper/JamEntriesMapper.java` | `isOwnEntry(jamId, gameId, userId)` 추가(자기출품 충돌 판정, 개인+팀멤버 OR). **W2-1 소유 매퍼 — 메서드 추가**(crossRefs) | K-MAPPER |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/security/JamRoleGate.java` | 잼 스코프 게이트 — `isJudge(session, jamId)`/`isOwnEntry(jamId, gameId, userId)` (별도 축, @Component) | K-GATE |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/JamJudgeAdminController.java` | `/admin/jams/{jamId}/judges` 지정/해제/조회(GAME_JAM_MANAGE 게이트 헬퍼, CSRF) | K-ADMIN |
|
||||||
|
| 수정 | `src/main/webapp/WEB-INF/views/admin-jam-list.jsp` | 잼별 심사위원 지정/해제 폼·목록(CSRF hidden). **W2-1 소유 JSP — 섹션 추가**(또는 신규 admin-jam-judges.jsp). crossRefs | K-ADMIN |
|
||||||
|
| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 JamJudgesMapper + JamRoleGate @MockBean 등록(contextLoads 보존, verification §30) | (검증) |
|
||||||
|
| 신규 | `src/test/.../JamJudgeAdminControllerTest.java` | 지정/해제/조회 + 401/403/CSRF/409/404 + GAME_JAM_MANAGE 게이트(ADMIN/SUBADMIN+키/무키) | (검증) |
|
||||||
|
| 신규 | `src/test/.../JamRoleGateTest.java` | isJudge(지정/미지정/미인증) + isOwnEntry(개인출품/팀멤버출품/타인출품) 단위 | (검증) |
|
||||||
|
|
||||||
|
> SSR 호출지점 영향(verification §영향맵): jam_judges 는 신규 테이블, JamRoleGate 는 신규 컴포넌트 → 기존 소비처 0 영향. JamEntriesMapper.isOwnEntry 는 신규 메서드라 기존 호출지점 깨짐 0(W2-1 메서드 보존). admin-jam-list.jsp 섹션 추가는 기존 폼 보존 + 추가만.
|
||||||
|
|
||||||
|
### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
|
||||||
|
```java
|
||||||
|
// JamRoleGate — 잼 스코프 게이트(별도 축, @Component). 전역 PermissionGate 시그니처 무변경.
|
||||||
|
boolean isJudge(HttpSession session, // userId 출처(세션) — 미인증이면 false
|
||||||
|
long jamId) // 잼 리소스 식별 — jam_judges 조회 키
|
||||||
|
boolean isOwnEntry(long jamId, // 잼 식별
|
||||||
|
long gameId, // 점수입력 대상 출품작
|
||||||
|
long judgeUserId) // 충돌 판정 대상 심사위원 — 본인 출품작이면 true
|
||||||
|
// (isOwnEntry 가 session 이 아닌 judgeUserId 를 받는 이유: W2-4 가 이미 세션에서 userId 추출 후 호출하므로
|
||||||
|
// 게이트는 순수 판정만. session 재추출 중복 방지 — 최소 인자. isJudge 는 게이트 진입점이라 session 직수용.)
|
||||||
|
|
||||||
|
// JamJudgesMapper (@Mapper, #{} only, snake→camel 직접 alias)
|
||||||
|
int insert(long jamId, // 지정 잼
|
||||||
|
long userId, // 심사위원
|
||||||
|
long assignedBy) // 지정 관리자(감사)
|
||||||
|
int delete(long jamId, long userId) // 해제(hard DELETE, 역할 회수). 반환=affected rows(0이면 404)
|
||||||
|
boolean exists(long jamId, long userId) // 지정 여부(isJudge 소스 + 중복 지정 409 체크)
|
||||||
|
List<JamJudgeData> listByJam(long jamId)// 관리자 조회(JOIN users displayName)
|
||||||
|
|
||||||
|
// JamEntriesMapper 추가 (W2-1 소유 매퍼에 메서드 추가 — @Mapper, #{} only)
|
||||||
|
boolean isOwnEntry(long jamId, // 잼
|
||||||
|
long gameId, // 출품작
|
||||||
|
long userId) // 본인 여부 판정 대상(개인 entrant_user_id 또는 팀멤버)
|
||||||
|
```
|
||||||
|
> inflate 마킹(concern 1): `isOwnEntry` 는 3인자 전부(jamId/gameId/userId) 충돌 판정 SQL 에 쓰인다(개인 OR 팀멤버 EXISTS). 구현에서 gameId 없이 jamId+userId 만으로 "잼 내 본인 출품 존재" 를 본다면 다른 시그니처(hasOwnEntryInJam(jamId, userId))가 되지만, W2-4 는 *점수입력 대상 game_id 가 본인 것인지*를 묻는 게임 단위 판정이 필요하므로 gameId 포함이 정석(다른 출품작은 심사 가능, 자기 작품만 거부 — J7). `assignedBy` 는 감사 컬럼 채움에 실사용(nullable이지만 컨트롤러가 actorId 전달).
|
||||||
|
|
||||||
|
### JamEntriesMapper.isOwnEntry SQL 계약 (난제2 — 개인+팀 OR)
|
||||||
|
```sql
|
||||||
|
-- 본인 출품작 충돌 판정: 활성 출품작 (jamId,gameId) 의 entrant 가 userId 본인인가.
|
||||||
|
-- 개인 출품: entrant_user_id == userId
|
||||||
|
-- 팀 출품: jam_team_id 가 가리키는 팀에 userId 가 멤버(jam_team_members)
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM jam_entries e
|
||||||
|
WHERE e.jam_id = #{jamId} AND e.game_id = #{gameId} AND e.is_delete IS NOT TRUE
|
||||||
|
AND (
|
||||||
|
(e.entrant_type = 'USER' AND e.entrant_user_id = #{userId})
|
||||||
|
OR
|
||||||
|
(e.entrant_type = 'TEAM' AND EXISTS(
|
||||||
|
SELECT 1 FROM jam_team_members m
|
||||||
|
WHERE m.jam_team_id = e.jam_team_id AND m.user_id = #{userId}
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
> `#{}` 바인딩만, `${}` 0. jam_entries.entrant_type/entrant_user_id/jam_team_id + jam_team_members 는 W2-1 §데이터모델4·3 스키마. boolean 반환(EXISTS) — UserPermissionsMapper.exists 선례(:21-29)와 동형.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 대안 비교
|
||||||
|
|
||||||
|
| 주제 | 안 | 장점 | 단점 | 채택 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 잼 역할 모델 | (A) 별도 jam_judges 테이블 | 전역 RBAC 불변(회귀 0), 잼 스코프 정규화, 다잼 자연 표현 | 테이블 1개 + 게이트 1개 추가 | **채택(J1, QG-W2-A(b))** |
|
||||||
|
| | (B) user_permissions 에 scope/jam_id 컬럼 추가 | 권한 단일 모델 | 전역 UNIQUE/PermissionGate.has 시그니처 침습(W1 회귀), 잼 외 스코프마다 컬럼 증식 | 기각 |
|
||||||
|
| | (C) 하이브리드(전역키 + 스코프 별도) | 유연 | 두 모델 동기·우선순위 복잡, over-engineering | 기각 |
|
||||||
|
| 게이트 축 | (A) JamRoleGate 별도 게이트(jamId 인자) | 전역 PermissionGate 무변경, 리소스 스코프 명확 | 게이트 클래스 1개 | **채택(J2)** |
|
||||||
|
| | (B) PermissionGate.has 에 resourceId 인자 추가 | 단일 게이트 | 기존 2인자 호출지점 전수 정정(W1 회귀), 전역 키와 잼 역할 의미 혼재 | 기각 |
|
||||||
|
| 잼 역할 캐시 | (A) 요청당 jam_judges 직접 조회(캐시 0) | 지정/해제 즉시 반영, epoch 불요, 단순 | 요청당 EXISTS 1회(인덱스 — 저비용) | **채택** |
|
||||||
|
| | (B) 세션 캐시 + epoch(전역 권한처럼) | 조회 절감 | 잼 역할용 별도 epoch·세션 attr 증식, 다중잼 캐시 복잡 | 기각(over-engineering) |
|
||||||
|
| 충돌 enforce 시점 | (A) 점수입력 시점(W2-4) 게임단위 거부 | 자기작품만 거부·타작품 심사 허용(유연), 지정 자유 | 시점이 지정과 분리 | **채택(J4/J7)** |
|
||||||
|
| | (B) 지정 시점에 출품자 제외 | 단순 | 지정 후 출품/지정 전 출품 타이밍 갭, 타작품 심사도 봉쇄(과도) | 기각 |
|
||||||
|
| 해제 저장 | (A) hard DELETE | 행 존재=현재 심사위원(isJudge 단순), 멱등 | 해제 이력 미보존 | **채택(J5)** |
|
||||||
|
| | (B) soft delete(is_delete) | 해제 이력 | isJudge 마다 필터·partial UNIQUE 복잡, 이력은 비목표 | 기각 |
|
||||||
|
| 지정 enforcement | (A) W2-1 exclude + 컨트롤러 게이트 헬퍼 재사용 | W2-1 선례 일치, InterceptorConfig 무수정(충돌 0) | W2-1 배포 의존 | **채택(J3-A)** |
|
||||||
|
| | (B) 별도 인터셉터 경로 매핑 | 중앙집중 | 경로↔키 테이블 신설(W1/W2-1서 기각된 방향), 중복 | 기각 |
|
||||||
|
| | (C) 임시 role 직접체크 | 빠름 | W1 인프라 우회·정석 위반(금지) | 기각 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 롤아웃 / 마이그레이션
|
||||||
|
|
||||||
|
### 순서
|
||||||
|
1. **스키마 적용**: `docs/jam-judge-ddl.sql` → `db/apply-local-ddl.sh`(로컬). 운영은 동일 멱등 DDL 수동 적용.
|
||||||
|
- **선행 의존**: jam_judges FK 가 `jams`(W2-1 docs/jam-ddl.sql) + `users`(기존) 참조 → **jam-ddl.sql 이 먼저 적용돼야 함**. apply-local-ddl.sh 알파벳 글롭: `jam-ddl.sql` < `jam-judge-ddl.sql`(공통 `jam-` 뒤 `d` < `j`) → 순서 자동 보장. jam-eval-ddl(W2-3, `jam-e...`)과도 무충돌(jam_judges 는 eval 테이블 미참조).
|
||||||
|
2. **코드 배포(W2-1 이후)**: K-DOMAIN → K-MAPPER → {K-GATE, K-ADMIN}. **W2-1 의 InterceptorConfig exclude(`/admin/jams/**`) 가 선배포**돼야 SUBADMIN+GAME_JAM_MANAGE 가 `/admin/jams/{id}/judges` 도달(concern 5). W2-1 미배포 시 인터셉터 isAdmin 에 막힘 → W2-1 과 같은 배포 사이클 또는 그 이후.
|
||||||
|
3. **권한 시드 불필요**: GAME_JAM_MANAGE 키는 W1 PermissionCatalogVerifier 가 이미 시드(grounding R-A). 본 설계는 잼 스코프 역할 enforcement 추가만.
|
||||||
|
4. **운영**: 잼 관리자(ADMIN 또는 SUBADMIN+GAME_JAM_MANAGE)가 잼별 심사위원 지정 → W2-4 점수입력 게이트가 즉시 소비(jam_judges 직접 조회, epoch 불요).
|
||||||
|
|
||||||
|
### 역호환
|
||||||
|
- 전역 RBAC(user_permissions/PermissionGate)·jams/jam_entries/games **전부 무변경**. W1/W2-1 동작 0 영향.
|
||||||
|
- jam_judges 는 신규 테이블(추가 전용, 비파괴). 기존 사용자 영향 0.
|
||||||
|
- JamEntriesMapper.isOwnEntry 는 신규 메서드(기존 메서드 보존). admin-jam-list.jsp 는 섹션 추가(기존 폼 보존).
|
||||||
|
|
||||||
|
### 롤백
|
||||||
|
- 코드 롤백: JamJudgeAdminController/JamRoleGate/JamJudgesMapper/isOwnEntry 되돌리면 심사위원 지정·점수입력 게이트 소비 불가(W2-4 가 isJudge 의존 시 W2-4 도 영향 — 같은 사이클 롤백 고려). 신규 테이블은 추가 전용이라 잔존 무해.
|
||||||
|
- 스키마 롤백: jam_judges 는 추가 전용 → drop 없이 잔존 무해(비파괴). 명시 DROP 은 별도 maintenance(`DROP TABLE IF EXISTS jam_judges CASCADE;`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AC 매핑
|
||||||
|
|
||||||
|
| AC | 요구(골자 W2-2) | 만족 설계 요소 | 비고 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AC-1 | 잼 회차별 심사위원 역할(전역 RBAC 불변) | jam_judges 별도 테이블 + user_permissions 무변경 | J1, §전역무변경 |
|
||||||
|
| AC-2 | 잼 스코프 게이트 isJudge(session, jamId) | JamRoleGate.isJudge — jam_judges exists 조회 | J2, S3 |
|
||||||
|
| AC-3 | 심사위원 지정 = GAME_JAM_MANAGE 게이트 | JamJudgeAdminController requireJamManage(gate.has GAME_JAM_MANAGE) | J3, J3-A |
|
||||||
|
| AC-4 | SUBADMIN+키 지정 통과 / 무키 403 | gate.has(ADMIN OR SUBADMIN+GAME_JAM_MANAGE), W2-1 exclude | J3-A, S1 |
|
||||||
|
| AC-5 | 누구나 지정 가능(USER 포함) | 지정 시 대상 전역 role 검사 없음(실재만 404 체크) | J6, §외부계약 |
|
||||||
|
| AC-6 | 자기출품 충돌(점수입력 시 자기작품 거부) | JamRoleGate.isOwnEntry → W2-4 422. 개인+팀멤버 OR | J4/J7, S2, §isOwnEntry SQL |
|
||||||
|
| AC-7 | 역할 수명 = 잼 종료 후 잔존 | jam_judges 만료 회수 없음, soft delete 미채택(행 존재=현재). 점수입력 활성=W2-3 게이트 | J5 |
|
||||||
|
| AC-8 | 지정/해제 상태변경 전수 CSRF | assignJudge/removeJudge CsrfTokens.isValid 선검증 → 403 | §외부계약 공통 |
|
||||||
|
| AC-9 | 권한 SQL `${}` 0 | JamJudgesMapper + isOwnEntry `#{}` only | §파일영향맵/isOwnEntry SQL |
|
||||||
|
| AC-10 | 중복 지정 방지(멱등) | ux_jam_judges_jam_user UNIQUE + exists 409 | §데이터모델1, S1 |
|
||||||
|
| AC-11 | 잼별 심사위원 목록 조회 | listByJam(JOIN users) GET API | G7, §외부계약 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 포인트 (verification-advisor 점검 대상)
|
||||||
|
|
||||||
|
> L레벨 매핑(verification-strategies): 인가/게이트/잼 스코프 역할 플로우 = **L1+L2+L3**. 신규 매퍼 SQL/alias·isOwnEntry OR-EXISTS = **L1+L2(dev DB contract)**. 신규 컨트롤러·매퍼·게이트(@Component) 의존 = full `./mvnw -o test` 의무(§30).
|
||||||
|
|
||||||
|
### 시나리오 검증
|
||||||
|
- **VP-1 (AC-3/4 지정 게이트, L1+L3)**: JamJudgeAdminControllerTest — ADMIN 통과 / SUBADMIN+GAME_JAM_MANAGE 통과 / SUBADMIN 무키 403 / 미인증 401(redirect). L3 스모크: W2-1 exclude 후 SUBADMIN 이 `/admin/jams/{id}/judges` 도달.
|
||||||
|
- **VP-2 (AC-2 isJudge, L1)**: JamRoleGateTest — 지정된 유저 true / 미지정 false / 미인증 false. jam_judges exists 조회 정확.
|
||||||
|
- **VP-3 (★AC-6 자기출품 충돌, L1+L2)**: JamRoleGateTest.isOwnEntry — ① 개인 출품(entrant_user_id==judge) true ② 팀 출품(judge 가 그 팀 멤버) true ③ 타인 출품/타팀 출품 false ④ 비활성(is_delete) 출품 false. dev DB contract: OR-EXISTS(개인 OR 팀멤버) SQL 실측(jam_entries/jam_team_members 샘플).
|
||||||
|
- **VP-4 (AC-5 누구나 지정, L1)**: USER role 대상 지정 200(전역 role 검사 없음). 미존재 userId 404.
|
||||||
|
- **VP-5 (AC-8 CSRF, L1)**: 지정/해제 CSRF 누락 → 403 + mapper 미호출(deleteCommentRejectsMissingCsrfBeforeMapperAccess 패턴 준용).
|
||||||
|
- **VP-6 (AC-10 멱등, L1+L2)**: 이미 지정된 (jamId,userId) 재지정 → 409(exists) 또는 UNIQUE 거부(catch→409). dev DB: ux_jam_judges_jam_user 중복 INSERT 거부 실측.
|
||||||
|
- **VP-7 (DB-방언 계약, L2)**: JamJudgesMapper 반환 POJO 키 == 컨트롤러/JSP 조회 키(snake→camel 직접 alias, jam_judges 는 일반매퍼 → **큰따옴표 alias 금지** 확인). isOwnEntry boolean 매핑(EXISTS) 정합.
|
||||||
|
- **VP-8 (contextLoads, L1)**: BibimbapApplicationTests 에 JamJudgesMapper + JamRoleGate @MockBean 등록 후 PASS(§30). 누락 시 NoSuchBeanDefinitionException.
|
||||||
|
|
||||||
|
### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용)
|
||||||
|
> self-audit(시점): 아래 카운트는 **본 W2-2 가 신규 생성하는 정적 산출물**(jam_judges DDL·제약·매퍼 메서드·관리자 액션)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리처럼 증가하는 대상 아님. JamEntriesMapper.isOwnEntry 추가분은 W2-1 매퍼에 들어가지만 메서드 1건 추가라 카운트 안정.
|
||||||
|
> self-audit(표현): 단일 리터럴 grep 취약성을 피해 제약명/액션 핸들러/충돌 OR-분기 같은 **구조적 불변식**에 앵커. 매퍼 `${` 0건은 부재 검증이라 리터럴 정당.
|
||||||
|
|
||||||
|
- **AC-T1 jam_judges 무결성 제약 전수 4건 존재** — docs/jam-judge-ddl.sql 의 제약 전수: FK 3종(jam_id→jams / user_id→users / assigned_by→users) + UNIQUE 1종(ux_jam_judges_jam_user): `grep -c 'ADD CONSTRAINT' docs/jam-judge-ddl.sql` == 3(FK) AND `grep -c 'CREATE UNIQUE INDEX' docs/jam-judge-ddl.sql` == 1. AND db/schema.sql 에 jam_judges 동일 제약 전수 존재(동기 사본 누락 검출). 제약 추가/삭제 누락을 갯수로 동시 커버.
|
||||||
|
- **AC-T2 관리자 심사위원 액션 전수 3건 게이트** — JamJudgeAdminController 의 핸들러(지정/해제/조회) 전수가 `requireJamManage`(또는 gate.has(GAME_JAM_MANAGE)) 호출: 게이트 헬퍼 호출 수 == 핸들러 수(상태변경 핸들러 지정/해제는 추가로 CsrfTokens.isValid). 핸들러 추가 시 게이트 누락 = 인가 우회 보안결함 → FAIL. **이 전수 AC 가 J3 지정 enforcement 의 핵심 가드**(수동 판정: @PostMapping/@GetMapping 핸들러 열거 후 각 진입부 게이트 확인 — 리터럴 grep 단독 의존 회피).
|
||||||
|
- **AC-T3 상태변경 액션 전수 CSRF 가드** — JamJudgeAdminController 상태변경 핸들러(지정/해제 2건, GET 조회 제외)에 `CsrfTokens.isValid` 선검증 존재: `grep -c 'CsrfTokens.isValid' JamJudgeAdminController.java` == 상태변경 핸들러 수(2). 핸들러 추가 시 CSRF 누락 동시 검출(AC-8).
|
||||||
|
- **AC-T4 자기출품 충돌 양경로(개인+팀) 전수** — JamEntriesMapper.isOwnEntry SQL 이 entrant_type 양경로 전수 커버: 'USER'(entrant_user_id) 분기 AND 'TEAM'(jam_team_members EXISTS) 분기 둘 다 존재(OR 결합). 검증: SQL 에 `entrant_type = 'USER'` AND `entrant_type = 'TEAM'` 두 분기 grep 매치 각 1 AND L2 실측(개인/팀 출품 각각 충돌 true, 타인 false). **1경로 누락 = 충돌 우회(팀 출품 심사위원이 자기 팀 작품 채점) 보안결함 → FAIL**. W2-1 jam_entries XOR 구조(entrant_type 2값) 전수 대응 불변식.
|
||||||
|
- **AC-T5 jam_judges 매퍼 `${` 0건** — JamJudgesMapper + JamEntriesMapper(isOwnEntry 추가분)에 `${` 매치 0: `grep -rc '\${' JamJudgesMapper.java JamEntriesMapper.java` == 0 (AC-9, `${}` 동적치환 금지). 부재 검증이라 리터럴 정당.
|
||||||
|
- **AC-T6 신규 빈 @MockBean 전수 등록** — BibimbapApplicationTests 에 JamJudgesMapper + JamRoleGate 전수 @MockBean 등록: contextLoads PASS AND 두 빈 등록 확인(JamRoleGate 가 JamJudgesMapper/JamEntriesMapper 의존 @Component 이므로 의존 매퍼 MockBean 도 필요). 1건 누락 시 contextLoads NoSuchBeanDefinitionException 로 즉시 검출(§30, verification 시점 자기 검증).
|
||||||
|
- **AC-T7 전역 RBAC 무변경 불변식** — user_permissions/permissions/users 가 본 동결로 인해 변경 0: docs/jam-judge-ddl.sql 에 `user_permissions`/`permissions`/`users` 테이블의 ALTER/CREATE 변경문 0건(jam_judges FK 가 users 참조하는 `REFERENCES "users"` 는 무방하나, users 테이블 ALTER/CREATE 는 0). 검증: `grep -E 'ALTER TABLE "(user_permissions|permissions|users)"|CREATE TABLE.*"(user_permissions|permissions)"' docs/jam-judge-ddl.sql` 0건. **★보안 단언(J1 전역모델 보호) 위반 즉시 검출**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 잔여 오픈 질문
|
||||||
|
없음(0). 확정 결정 J1~J7 전제 고정. 두 난제(스코프 게이트 축 분리·자기출품 충돌 개인+팀 커버)는 본 설계가 구체 메커니즘으로 확정. 인터셉터 무수정(W2-1 exclude 재사용, J3-A)·해제 hard DELETE(J5)·충돌 enforce 시점 점수입력(J7)도 확정. 구현 점검 항목(시그니처 inflate·신규 매퍼/게이트 @MockBean full-test·DB-방언 L2·W2-1 JamEntriesMapper/admin-jam-list.jsp 소유권 경계·W2-1 배포 순서 의존·충돌 enforce W2-4 소비 재확인)은 오픈 질문이 아니라 `concerns`/`crossRefs` 로 이관.
|
||||||
|
|
@ -0,0 +1,544 @@
|
||||||
|
---
|
||||||
|
phase: design
|
||||||
|
agent: design-advisor
|
||||||
|
agent_version: 1
|
||||||
|
generated_at: 2026-06-23T14:30:00+09:00
|
||||||
|
workstream: W2-3-잼 평가 통합설계(스키마 동결)
|
||||||
|
concerns:
|
||||||
|
- "★평가단위 식별자 이중 모델 — W2-1 은 평가 단위를 jam_entries.id 로 제공한다고 명시했으나, 본 동결 스키마(orchestrator 확정)는 jam_scores/jam_votes/jam_awards 가 (jam_id, game_id) 를 참조한다. 본 설계는 둘을 정합시킨다: (jam_id, game_id) = 활성 출품작 자연키, jam_entries(jam_id,game_id active-UNIQUE) 와 1:1 대응. FK 는 game_id→games, jam_id→jams 로 직접 걸고, '출품 여부' 는 앱계층에서 jam_entries 활성행 존재로 검증(W2-4/5 진입 게이트). 구현 단계에서 jam_entries.id 직접 FK 채택 여부를 W2-1 소유자와 재확인 필요(현 동결은 game_id 자연키 채택 — 근거: orchestrator 확정 컬럼 + entrant 종류 무관 단일화). crossRefs 참조."
|
||||||
|
- "신규 매퍼(JamScoresMapper/JamVotesMapper/JamCriteriaMapper/JamAwardsMapper/JamScoreStatsMapper) 의존 추가 — verification-strategies §30 에 따라 implementation 단계에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 신규 @Mapper @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException. (본 W2-3 은 스키마+계약 동결이 범위이므로 매퍼/컨트롤러 구현은 W2-4/5/6 소관 — 본 설계는 매퍼 시그니처 계약만 고정, 실제 빈 등록 책임은 하류.)"
|
||||||
|
- "jam_score_stats VIEW 의 가중 종합점수(SUM(avg*weight)/SUM(weight)) 는 criterion weight 가 numeric 이고 NULL/0 가능 → 0-division 가드 필요. 집계 VIEW 매퍼는 camelCase alias 큰따옴표(AS \"weightedTotal\") 필수(케이스 폴딩, verification-strategies §33). dev DB contract(L2) 로 fan-out·NULL·0-division 실측 권장 — game_review_stats BUG-1 fan-out 선례(커밋 21892c8) 재발 방지."
|
||||||
|
- "유저평점 트랙 최소 리뷰수 임계 N — 본 설계는 기본값 N=3 으로 확정(아래 §시상 트랙 계약). 잼별 가변 임계가 필요하면 jams 또는 jam_awards 산정 파라미터로 확장 — 구현 점검 항목(현 동결은 상수 3, 시상 산정 로직에 위치)."
|
||||||
|
- "평가단위 = 활성 출품작이라는 전제는 jam_entries 가 (jam_id,game_id) 활성 UNIQUE 를 보장함에 의존(W2-1 ux_jam_entries_jam_game_active). 이 UNIQUE 가 동결 전 변경되면 jam_scores/jam_votes 의 game_id 참조 정합이 깨진다 — W2-1 PK/UNIQUE 동결 계약 유지 필수(crossRefs)."
|
||||||
|
concerns_checked: true
|
||||||
|
self_verification:
|
||||||
|
checklist_passed: true
|
||||||
|
references:
|
||||||
|
requirements: docs/work-log/2026-06-23-w2-w4-feature-skeletons.md
|
||||||
|
research: .atp/work-session/20260623-104307/research/W2-W4-grounding.md
|
||||||
|
adrs:
|
||||||
|
- .atp/work-session/20260623-104307/implementation/W2-1-jam-entity-design.md
|
||||||
|
- .atp/work-session/20260622-180054/implementation/W1-design.md
|
||||||
|
- docs/work-log/2026-06-17-jam-platform-roadmap.md
|
||||||
|
- docs/development/verification-strategies.md
|
||||||
|
- docs/jam-ddl.sql
|
||||||
|
---
|
||||||
|
|
||||||
|
# 설계: W2-3 — 잼 평가 통합설계 (심사점수 / 인기투표 / 시상집계 스키마 동결 + 단방향 평점 계약 + 평가기간 게이트 계약)
|
||||||
|
|
||||||
|
> ⚠️ **결합 클러스터 forward phase-gate (§2.7)**. 이 문서가 W2-4(심사평가)·W2-5(인기투표)·W2-6(시상집계) 가 **공유·소비하는 동결 스키마 + 계약의 권위**다. 하류 3워크스트림은 이 문서를 읽고 **API/로직만** 설계한다. 본 동결이 확정되기 전 하류 착수 금지(재작업 차단).
|
||||||
|
|
||||||
|
## 목표 / 비목표
|
||||||
|
|
||||||
|
### 목표 (FR/NFR 추적 — 골자 W2-3)
|
||||||
|
- **G1 심사점수 스키마 동결**: 잼별 설정형 심사 기준(`jam_criteria`) + 심사위원 점수(`jam_scores`) + 집계 VIEW(`jam_score_stats`). 고정 6축 강제 대신 정석 잼 심사 모델.
|
||||||
|
- **G2 인기투표 스키마 동결**: 잼당 1인 1표(`jam_votes`). `game_likes` 와 별개 신규 테이블(game_likes 는 user_key varchar·1인1표 비보장이라 재활용 안 함). 로그인 user_id 기반.
|
||||||
|
- **G3 시상 스키마 동결**: 3트랙(JUDGE/USER_RATING/POPULAR) + grand(`jam_awards`). 트랙별 개별 수상 + 가중 grand prize.
|
||||||
|
- **G4 ★단방향 유저평점 트랙 계약**: 시상 USER_RATING 트랙 = `game_review_stats` VIEW.avg_rating(overall) 소비. **단일 평균 채택**(6축은 표시 전용). 리뷰 도메인은 잼 무관 → 시상이 읽기만(write 0).
|
||||||
|
- **G5 평가기간 게이트 계약**: 심사점수(W2-4)·투표(W2-5) 는 `jams.status='EVAL' AND now() ∈ [eval_start_at, eval_end_at]` 일 때만 허용. 시상 집계(W2-6)는 `status='CLOSED'` 또는 eval 종료 후 확정.
|
||||||
|
- **G6 집계 노출 계약**: 심사 집계 = `jam_score_stats` VIEW(criterion별 평균 + 가중 종합, fan-out 방지 선집계 — game_review_stats VIEW 선례). 투표 집계 = count.
|
||||||
|
- **G7 평가단위 정합 계약**: 평가 단위 자연키 = `(jam_id, game_id)` = 활성 출품작(jam_entries 1:1 대응). W2-4/5/6 은 entrant 종류(개인/팀)를 몰라도 game_id 만 참조.
|
||||||
|
- **NFR**: 상태변경 CSRF 전수(하류 구현), `#{}` 바인딩(`${}` 금지), 평가기간 게이트, VIEW 매퍼 alias 큰따옴표(case-folding), 비파괴 멱등 마이그레이션, DDL 권위=docs/*-ddl.sql.
|
||||||
|
|
||||||
|
### 비목표 (스코프 밖 — 본 W2-3 은 스키마·계약 동결만)
|
||||||
|
- **심사 점수 입력 API·UI·컨트롤러 본체** — **W2-4 소유**. 본 설계는 jam_criteria/jam_scores 스키마 + jam_score_stats VIEW + 매퍼 시그니처 계약만 제공.
|
||||||
|
- **투표 토글 API·UI** — **W2-5 소유**. 본 설계는 jam_votes 스키마 + 1인1표 UNIQUE + 평가기간 게이트 계약만.
|
||||||
|
- **시상 산정 알고리즘 본체·확정 UI** — **W2-6 소유**. 본 설계는 jam_awards 스키마 + 3트랙 정의 + 유저평점 소비 계약(소스/NULL/임계) + grand 가중 규칙 자리만.
|
||||||
|
- **심사위원 자격 판정(jam_judges)** — **W2-2 소유**. 본 설계는 jam_scores.judge_user_id 가 FK users 이고 "is judge" 검증은 앱계층(W2-2 게이트)임만 명시.
|
||||||
|
- **댓글/리뷰 스키마 변경** — W3-2(구현완료). 본 동결 묶음 아님. 시상이 `game_review_stats` VIEW 를 **읽기만**(game_reviews 무변경, jam FK 추가 없음).
|
||||||
|
- **game_review_stats VIEW 재집계·평가기간 필터 적용** — 채택 안 함(아래 §유저평점 트랙 계약 근거). 시상은 출품작 전체 리뷰 avg_rating 을 그대로 소비.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
bibimbap 은 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis annotation `@Mapper`(`#{}` only) + JSP 스택이다. 현재 잼 평가 스키마(jam_criteria/jam_scores/jam_votes/jam_awards/jam_score_stats)는 전무하다(grep 0 hit — 본 advisor 직접 확인). W2-1 이 `jams`(status CHECK RECRUIT/DEV/EVAL/CLOSED + eval_start_at/eval_end_at) + `jam_entries`(잼당 game 활성 UNIQUE = 출품작) 를 제공하고, W3-2 가 `game_review_stats` VIEW(avg_rating numeric / review_count / 6축평균 9컬럼, schema.sql:217-244 직접 확인)를 제공한다.
|
||||||
|
|
||||||
|
본 설계는 **잼 평가 4테이블(+집계 VIEW 1)을 신규 동결**하고, 하류 W2-4/5/6 이 의존할 **3개 계약(단방향 유저평점 / 평가기간 게이트 / 집계 노출)**을 구체화한다. 동결 후 하류는 이 스키마를 **변경 없이** 소비한다.
|
||||||
|
|
||||||
|
확정된 정석 결정(전제 — 재논의 금지):
|
||||||
|
- **심사 척도 = 잼별 설정형 criteria** : 고정 6축 강제 대신 `jam_criteria` 로 잼마다 심사 기준 정의(정석 잼 심사 모델). `jam_scores` 는 criterion_key 단위 행(criterion별 1~5).
|
||||||
|
- **인기투표 = 잼당 1인 1표** : `jam_votes(jam_id, voter_user_id)` UNIQUE. game_likes 재활용 안 함(비권위·varchar·1인1표 비보장).
|
||||||
|
- **시상 = 3트랙 + grand** : `jam_awards.award_track CHECK('JUDGE','USER_RATING','POPULAR','GRAND')`.
|
||||||
|
- **유저평점 트랙 = avg_rating 단일 평균** (6축 표시 전용, 단방향 읽기).
|
||||||
|
- **평가단위 = (jam_id, game_id) 자연키** (활성 출품작, entrant 종류 무관 단일화).
|
||||||
|
|
||||||
|
가장 까다로운 세 난제 확정:
|
||||||
|
- **난제1 (평가단위 식별자 — W2-1 jam_entries.id vs 동결 game_id 정합)**: W2-1 은 "평가 단위 = jam_entries.id" 라 했고 orchestrator 동결 컬럼은 (jam_id, game_id) 다. 본 설계는 **자연키 (jam_id, game_id) 채택**으로 정합한다 — jam_entries 가 `ux_jam_entries_jam_game_active`(jam_id,game_id 활성 UNIQUE, W2-1 §데이터모델4)를 보장하므로 (jam_id, game_id) 는 활성 출품작과 **1:1 대응하는 자연키**다. FK 는 game_id→games·jam_id→jams 로 직접 걸고, "출품작인가" 검증(점수/투표 진입 게이트)은 앱계층에서 jam_entries 활성행 존재로 수행한다. surrogate jam_entries.id 직접 FK 보다 (jam_id,game_id) 자연키가 ① orchestrator 동결 컬럼과 일치 ② game_review_stats(game_id 기준) 와 join 정합 ③ entrant 종류 불투명(W2-1 의도)을 유지하는 장점이 있다(concern 1 에 W2-1 소유자 재확인 마킹).
|
||||||
|
- **난제2 (유저평점 트랙 소스·기간·NULL — 단방향 계약)**: 소스 = `game_review_stats.avg_rating`(overall 단일 평균). **6축은 표시 전용, 시상 산정 미사용**(단일평균 채택 — 6축 가중을 시상에 끌어오면 잼별 criteria 와 의미 충돌). 기간 = 잼 평가기간 필터 **미적용**, 출품 게임의 전체 리뷰 avg_rating 소비(리뷰는 잼 무관 상시 작성 → game_review_stats 재집계 부담 회피, 정석). NULL/미달 = 리뷰 0개/axes 0행 출품작은 avg_rating NULL → 시상 산정 시 **NULLS LAST + review_count >= N(기본 3) 임계 미달 시 트랙 제외**. 리뷰 도메인 write 0(읽기만, game_reviews 에 jam FK 추가 안 함 — code-fact 정합).
|
||||||
|
- **난제3 (집계 fan-out·가중종합·NULL)**: 심사 집계 `jam_score_stats` VIEW 는 game_review_stats 의 fan-out 방지 선례를 따른다(criterion별 평균은 FILTER 또는 GROUP BY 선집계). 가중 종합 = `SUM(criterion_avg * weight) / NULLIF(SUM(weight), 0)`(0-division 가드). criterion 미채점(0행) 시 해당 criterion 평균 NULL → 가중 종합에서 제외(COALESCE 또는 FILTER). 매퍼 alias 는 집계 VIEW 이므로 큰따옴표(`AS "weightedTotal"`) 필수.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 핵심 결정 요약 (전제 — 재논의 금지. orchestrator 확정 동결값)
|
||||||
|
|
||||||
|
| 결정 | 확정값 | 본 설계의 구체화 |
|
||||||
|
|---|---|---|
|
||||||
|
| F1 심사 척도 | 잼별 설정형 criteria | `jam_criteria(jam_id, criterion_key, display_name, sort_order, weight numeric)`. 고정 6축 강제 안 함 |
|
||||||
|
| F2 심사 점수 | criterion 단위 행 | `jam_scores(jam_id, game_id, judge_user_id, criterion_key, score 1~5)` + UNIQUE(jam_id,game_id,judge_user_id,criterion_key) |
|
||||||
|
| F3 인기투표 | 잼당 1인 1표 | `jam_votes(jam_id, game_id, voter_user_id)` + UNIQUE(jam_id, voter_user_id). game_likes 재활용 안 함 |
|
||||||
|
| F4 시상 | 3트랙 + grand | `jam_awards.award_track CHECK('JUDGE','USER_RATING','POPULAR','GRAND')` + rank + score_value |
|
||||||
|
| F5 유저평점 트랙 | avg_rating 단일평균(단방향) | game_review_stats.avg_rating 읽기만. 6축 표시전용. 기간필터 미적용(전체). NULLS LAST + review_count>=3 |
|
||||||
|
| F6 평가기간 게이트 | EVAL + now∈[eval_start,eval_end] | 점수(W2-4)·투표(W2-5) 허용 조건. 시상(W2-6)=CLOSED/eval종료후 |
|
||||||
|
| F7 집계 노출 | 심사=VIEW, 투표=count | `jam_score_stats` VIEW(criterion평균+가중종합, fan-out 방지). 투표는 COUNT |
|
||||||
|
| F8 평가단위 | (jam_id, game_id) 자연키 | 활성 출품작(jam_entries 1:1). FK game_id→games·jam_id→jams. 출품검증=앱계층 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 모델 (DDL)
|
||||||
|
|
||||||
|
> 권위 = **신규 파일 `docs/jam-eval-ddl.sql`** (apply-local-ddl.sh 가 docs/*-ddl.sql 알파벳 글롭으로 멱등 적용, ON_ERROR_STOP, search_path=dev). `db/schema.sql` 에 동기 사본(아래 §schema.sql 반영). 선례: W1 docs/rbac-ddl.sql, W2-1 docs/jam-ddl.sql(직접 확인). **game_reviews/game_review_stats 변경 없음**(시상이 VIEW 를 읽기만). **jams/jam_entries 변경 없음**(game_id/jam_id 를 FK 참조만). 멱등: CREATE TABLE/SEQUENCE IF NOT EXISTS, DO $$ guard, CREATE UNIQUE INDEX IF NOT EXISTS, CREATE OR REPLACE VIEW. 타입은 기존 스타일(bigint/varchar/timestamptz/numeric/smallint).
|
||||||
|
>
|
||||||
|
> ⚠️ **알파벳 글롭 순서 주의**: `apply-local-ddl.sh` 가 docs/*-ddl.sql 을 알파벳순으로 적용한다. FK 가 jams/jam_entries 를 참조하므로 `jam-ddl.sql`(W2-1) 이 `jam-eval-ddl.sql`(W2-3) 보다 **먼저** 적용돼야 한다. `jam-ddl` < `jam-eval-ddl`(알파벳: 'd' < 'e' 위치는 'jam-' 공통 뒤 'd' vs 'e' → jam-ddl 먼저) 이 성립하므로 순서 안전(롤아웃 §순서에서 재확인).
|
||||||
|
|
||||||
|
### 신규 파일: `docs/jam-eval-ddl.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- W2-3 잼 평가 통합(심사점수/인기투표/시상집계). 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
|
||||||
|
-- 선행: docs/jam-ddl.sql(jams/jam_entries — 알파벳 글롭 순 jam-ddl 먼저 적용).
|
||||||
|
-- game_reviews/game_review_stats(W3-2) 변경 없음 — 시상 USER_RATING 트랙이 VIEW 를 읽기만.
|
||||||
|
-- 평가단위 = (jam_id, game_id) 자연키 = 활성 출품작(jam_entries 1:1 대응). 추가만, 파괴 없음.
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- 1) jam_criteria (잼별 설정형 심사 기준. 고정 6축 강제 대신 정석)
|
||||||
|
-- ===========================================================================
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "jam_criteria_id_seq";
|
||||||
|
CREATE TABLE IF NOT EXISTS "jam_criteria" (
|
||||||
|
"id" bigint DEFAULT nextval('jam_criteria_id_seq'::regclass) NOT NULL,
|
||||||
|
"jam_id" bigint NOT NULL,
|
||||||
|
"criterion_key" character varying(40) NOT NULL, -- 잼 내 기준 식별(영문 키)
|
||||||
|
"display_name" character varying(100) NOT NULL, -- 표시 라벨
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL, -- 표시 순서
|
||||||
|
"weight" numeric(6,3) DEFAULT 1.0 NOT NULL, -- 가중 종합 산정 가중치(>0 권장)
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
ALTER SEQUENCE "jam_criteria_id_seq" OWNED BY "jam_criteria"."id";
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_criteria_jam_fkey') THEN
|
||||||
|
ALTER TABLE "jam_criteria" ADD CONSTRAINT "jam_criteria_jam_fkey"
|
||||||
|
FOREIGN KEY ("jam_id") REFERENCES "jams" ("id");
|
||||||
|
END IF;
|
||||||
|
-- weight 음수 방지(0 은 허용하되 가중종합에서 NULLIF 가드 — 0-division 방어는 VIEW)
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_criteria_weight_check') THEN
|
||||||
|
ALTER TABLE "jam_criteria" ADD CONSTRAINT "jam_criteria_weight_check"
|
||||||
|
CHECK ("weight" >= 0);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
-- 잼 내 criterion_key 중복 방지
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_criteria_jam_key"
|
||||||
|
ON "jam_criteria" ("jam_id", "criterion_key");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_jam_criteria_jam"
|
||||||
|
ON "jam_criteria" ("jam_id", "sort_order");
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- 2) jam_scores (심사위원 점수. criterion 단위 행. is-judge 검증은 앱계층 W2-2)
|
||||||
|
-- ===========================================================================
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "jam_scores_id_seq";
|
||||||
|
CREATE TABLE IF NOT EXISTS "jam_scores" (
|
||||||
|
"id" bigint DEFAULT nextval('jam_scores_id_seq'::regclass) NOT NULL,
|
||||||
|
"jam_id" bigint NOT NULL, -- 평가단위 자연키 1/2
|
||||||
|
"game_id" bigint NOT NULL, -- 평가단위 자연키 2/2(출품작)
|
||||||
|
"judge_user_id" bigint NOT NULL, -- 심사위원(FK users; is-judge 는 W2-2)
|
||||||
|
"criterion_key" character varying(40) NOT NULL, -- jam_criteria.criterion_key 논리참조
|
||||||
|
"score" smallint NOT NULL, -- 1~5
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
ALTER SEQUENCE "jam_scores_id_seq" OWNED BY "jam_scores"."id";
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_scores_jam_fkey') THEN
|
||||||
|
ALTER TABLE "jam_scores" ADD CONSTRAINT "jam_scores_jam_fkey"
|
||||||
|
FOREIGN KEY ("jam_id") REFERENCES "jams" ("id");
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_scores_game_fkey') THEN
|
||||||
|
ALTER TABLE "jam_scores" ADD CONSTRAINT "jam_scores_game_fkey"
|
||||||
|
FOREIGN KEY ("game_id") REFERENCES "games" ("id");
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_scores_judge_fkey') THEN
|
||||||
|
ALTER TABLE "jam_scores" ADD CONSTRAINT "jam_scores_judge_fkey"
|
||||||
|
FOREIGN KEY ("judge_user_id") REFERENCES "users" ("id");
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_scores_score_check') THEN
|
||||||
|
ALTER TABLE "jam_scores" ADD CONSTRAINT "jam_scores_score_check"
|
||||||
|
CHECK ("score" BETWEEN 1 AND 5);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
-- 한 심사위원이 한 출품작의 한 기준에 1점만(수정=UPDATE, 재입력 멱등)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_scores_jam_game_judge_criterion"
|
||||||
|
ON "jam_scores" ("jam_id", "game_id", "judge_user_id", "criterion_key");
|
||||||
|
-- 집계 join 대상(출품작 단위 선집계)
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_jam_scores_jam_game"
|
||||||
|
ON "jam_scores" ("jam_id", "game_id");
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- 3) jam_votes (인기투표. 잼당 1인 1표. game_likes 와 별개. 평가기간 게이트=앱계층)
|
||||||
|
-- ===========================================================================
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "jam_votes_id_seq";
|
||||||
|
CREATE TABLE IF NOT EXISTS "jam_votes" (
|
||||||
|
"id" bigint DEFAULT nextval('jam_votes_id_seq'::regclass) NOT NULL,
|
||||||
|
"jam_id" bigint NOT NULL, -- 평가단위 자연키 1/2
|
||||||
|
"game_id" bigint NOT NULL, -- 투표 대상 출품작
|
||||||
|
"voter_user_id" bigint NOT NULL, -- 투표자(FK users; 로그인 1인1표)
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
ALTER SEQUENCE "jam_votes_id_seq" OWNED BY "jam_votes"."id";
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_votes_jam_fkey') THEN
|
||||||
|
ALTER TABLE "jam_votes" ADD CONSTRAINT "jam_votes_jam_fkey"
|
||||||
|
FOREIGN KEY ("jam_id") REFERENCES "jams" ("id");
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_votes_game_fkey') THEN
|
||||||
|
ALTER TABLE "jam_votes" ADD CONSTRAINT "jam_votes_game_fkey"
|
||||||
|
FOREIGN KEY ("game_id") REFERENCES "games" ("id");
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_votes_voter_fkey') THEN
|
||||||
|
ALTER TABLE "jam_votes" ADD CONSTRAINT "jam_votes_voter_fkey"
|
||||||
|
FOREIGN KEY ("voter_user_id") REFERENCES "users" ("id");
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
-- 잼당 1인 1표(최애 1개). 표 변경 = UPDATE game_id 또는 DELETE→INSERT(앱 정책 W2-5)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_votes_jam_voter"
|
||||||
|
ON "jam_votes" ("jam_id", "voter_user_id");
|
||||||
|
-- 투표 집계(출품작별 count)
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_jam_votes_jam_game"
|
||||||
|
ON "jam_votes" ("jam_id", "game_id");
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- 4) jam_awards (시상. 3트랙 + grand. 트랙별 개별 수상 + 가중 grand)
|
||||||
|
-- ===========================================================================
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "jam_awards_id_seq";
|
||||||
|
CREATE TABLE IF NOT EXISTS "jam_awards" (
|
||||||
|
"id" bigint DEFAULT nextval('jam_awards_id_seq'::regclass) NOT NULL,
|
||||||
|
"jam_id" bigint NOT NULL, -- 평가단위 자연키 1/2
|
||||||
|
"game_id" bigint NOT NULL, -- 수상 출품작
|
||||||
|
"award_track" character varying(20) NOT NULL, -- JUDGE|USER_RATING|POPULAR|GRAND
|
||||||
|
"rank" integer NOT NULL, -- 트랙 내 순위(1=대상)
|
||||||
|
"score_value" numeric(10,4), -- 산정 점수(트랙별 의미 다름; NULL 허용)
|
||||||
|
"computed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
ALTER SEQUENCE "jam_awards_id_seq" OWNED BY "jam_awards"."id";
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_awards_jam_fkey') THEN
|
||||||
|
ALTER TABLE "jam_awards" ADD CONSTRAINT "jam_awards_jam_fkey"
|
||||||
|
FOREIGN KEY ("jam_id") REFERENCES "jams" ("id");
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_awards_game_fkey') THEN
|
||||||
|
ALTER TABLE "jam_awards" ADD CONSTRAINT "jam_awards_game_fkey"
|
||||||
|
FOREIGN KEY ("game_id") REFERENCES "games" ("id");
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_awards_track_check') THEN
|
||||||
|
ALTER TABLE "jam_awards" ADD CONSTRAINT "jam_awards_track_check"
|
||||||
|
CHECK ("award_track" IN ('JUDGE', 'USER_RATING', 'POPULAR', 'GRAND'));
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_awards_rank_check') THEN
|
||||||
|
ALTER TABLE "jam_awards" ADD CONSTRAINT "jam_awards_rank_check"
|
||||||
|
CHECK ("rank" >= 1);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
-- 잼·트랙·순위 1행(재산정=DELETE→INSERT 또는 UPSERT 멱등). 동률은 같은 rank 허용 위해
|
||||||
|
-- (jam_id, award_track, game_id) UNIQUE 로 출품작 트랙 중복만 차단(같은 rank 동률 허용).
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_awards_jam_track_game"
|
||||||
|
ON "jam_awards" ("jam_id", "award_track", "game_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_jam_awards_jam_track"
|
||||||
|
ON "jam_awards" ("jam_id", "award_track", "rank");
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- 5) jam_score_stats (심사 집계 VIEW. criterion별 평균 + 가중 종합. fan-out 방지)
|
||||||
|
-- game_review_stats(schema.sql:217) 선례: 출품작 단위 선집계 후 노출.
|
||||||
|
-- 가중 종합 = SUM(criterion_avg * weight) / NULLIF(SUM(weight),0) (0-division 가드)
|
||||||
|
-- criterion 미채점 시 그 criterion 은 가중 종합에서 제외(점수 있는 기준만).
|
||||||
|
-- ===========================================================================
|
||||||
|
CREATE OR REPLACE VIEW "jam_score_stats" AS
|
||||||
|
WITH per_criterion AS (
|
||||||
|
SELECT
|
||||||
|
s."jam_id" AS jam_id,
|
||||||
|
s."game_id" AS game_id,
|
||||||
|
s."criterion_key" AS criterion_key,
|
||||||
|
AVG(s."score")::numeric AS criterion_avg,
|
||||||
|
COUNT(DISTINCT s."judge_user_id") AS judge_count
|
||||||
|
FROM "jam_scores" s
|
||||||
|
GROUP BY s."jam_id", s."game_id", s."criterion_key"
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
pc."jam_id" AS jam_id,
|
||||||
|
pc."game_id" AS game_id,
|
||||||
|
-- 출품작 단위 종합: 채점된 criterion 의 가중 평균
|
||||||
|
ROUND(
|
||||||
|
SUM(pc.criterion_avg * COALESCE(c."weight", 1.0))
|
||||||
|
/ NULLIF(SUM(COALESCE(c."weight", 1.0)), 0)
|
||||||
|
, 3) AS weighted_total,
|
||||||
|
ROUND(AVG(pc.criterion_avg), 3) AS simple_total, -- 비가중 평균(참고)
|
||||||
|
COUNT(pc.criterion_key) AS scored_criteria, -- 채점된 기준 수
|
||||||
|
MAX(pc.judge_count) AS judge_count -- 최다 기준 심사 인원
|
||||||
|
FROM per_criterion pc
|
||||||
|
LEFT JOIN "jam_criteria" c
|
||||||
|
ON c."jam_id" = pc."jam_id" AND c."criterion_key" = pc."criterion_key"
|
||||||
|
GROUP BY pc."jam_id", pc."game_id";
|
||||||
|
COMMENT ON VIEW "jam_score_stats" IS
|
||||||
|
'W2-3 동결 심사 집계뷰. 출품작(jam_id,game_id)별 가중 종합·비가중 평균·채점 기준수·심사 인원. fan-out 방지 선집계(game_review_stats 선례).';
|
||||||
|
```
|
||||||
|
|
||||||
|
> **criterion별 평균 노출 형태 결정**: VIEW 는 출품작 단위 **1행**(weighted_total/simple_total 종합)으로 노출한다. criterion별 개별 평균이 화면에 필요하면 W2-4 가 `per_criterion` 상당의 매퍼 쿼리(또는 별도 criterion-level 조회)를 직접 작성한다(본 VIEW 는 종합만 — 출품작 정렬·시상 산정 소비에 최적). 이유: 시상(W2-6) JUDGE 트랙은 출품작 종합점수로 랭크하므로 1행 종합이 핵심 소비 형태이고, criterion 펼침은 표시 전용이라 VIEW fan-out 을 늘리지 않는다.
|
||||||
|
|
||||||
|
### `db/schema.sql` 반영 (최초 기동 1회 자동 주입 — docs/jam-eval-ddl.sql 의 사본)
|
||||||
|
- W2-1 의 jams/jam_entries 블록 **뒤**(또는 마지막 테이블 블록 뒤)에 위 1~5 전체를 **신설 블록**으로 추가.
|
||||||
|
- 헤더 주석: `-- 잼 평가 W2-3 (권위 DDL — docs/jam-eval-ddl.sql 와 동일. 심사/투표/시상 동결)` — game_reviews 블록 schema.sql:128 의 `(권위 DDL — docs/...-ddl.sql 와 동일)` 선례와 동형(직접 확인).
|
||||||
|
- 반영 방식: **docs/jam-eval-ddl.sql 이 권위, schema.sql 은 사본**. 두 곳에 동일 멱등 DDL. jams/jam_entries 블록보다 **뒤**에 위치(FK 참조 순서 — schema.sql 은 순차 실행이므로 참조 테이블이 먼저 정의돼야 함).
|
||||||
|
|
||||||
|
### game_reviews / game_review_stats 무변경 확인 (G4 단방향)
|
||||||
|
- W3-2 산출물(game_reviews/game_review_axes/game_review_stats VIEW) 변경 0. 시상 USER_RATING 트랙은 `game_review_stats.avg_rating` 을 **SELECT 만** 한다. game_reviews 에 jam_id FK 추가 없음(code-fact: 리뷰는 잼 무관). → W3-2 리뷰 동작·집계 회귀 0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 외부 계약 (소비 계약 — 본 W2-3 은 스키마+계약 동결. API 본체는 하류)
|
||||||
|
|
||||||
|
> 본 문서는 API 엔드포인트를 **신설하지 않는다**(스키마+계약 동결이 범위). 아래는 하류 W2-4/5/6 이 **준수해야 할 계약**이다. 컨트롤러 패턴은 RecruitController(읽기=JSP 뷰, 쓰기=ResponseEntity JSON status/message) + CSRF + 401/403 정책(W1-design 일치)을 따른다.
|
||||||
|
|
||||||
|
### 401 vs 403 정책 (W1-design / W2-1 과 일치 — 하류 강제)
|
||||||
|
- **미인증**(세션 `userId` 없음): API 401 JSON `{status:401, message:"로그인이 필요합니다."}`, 페이지는 `redirect:/login`.
|
||||||
|
- **인증·미인가**(심사위원 아님 / 권한 없음): 403 JSON `{status:403, message:"권한이 없습니다."}`(리다이렉트 금지).
|
||||||
|
- **평가기간 외**(F6 게이트 위반): **422** JSON `{status:422, message:"평가 기간이 아닙니다."}` (인가는 됐으나 도메인 상태 위반 → 403 아님 422).
|
||||||
|
- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`.
|
||||||
|
|
||||||
|
### 평가기간 게이트 계약 (F6 — W2-4/W2-5 진입 공통 전제)
|
||||||
|
- **점수 입력(W2-4) 허용 조건**: `jam.status = 'EVAL' AND now() ∈ [jam.eval_start_at, jam.eval_end_at]`. 미충족 시 422. AND 심사위원 자격(W2-2 jam_judges 게이트) AND 출품작 존재(jam_entries 활성행). 세 게이트 모두 앱계층.
|
||||||
|
- **투표(W2-5) 허용 조건**: `jam.status = 'EVAL' AND now() ∈ [eval_start_at, eval_end_at]` AND 로그인 user_id AND 출품작 존재. 자기 출품작 투표 가부는 W2-5 정책(본 동결은 스키마만 — UNIQUE(jam_id, voter_user_id) 로 1인1표만 강제).
|
||||||
|
- **시상 집계(W2-6) 허용 조건**: `jam.status = 'CLOSED'` 또는 eval 종료 후(now() > eval_end_at). 산정은 재실행 가능(멱등 — jam_awards DELETE→INSERT 또는 UPSERT).
|
||||||
|
- **게이트 위치**: 모두 컨트롤러/서비스 진입부 앱계층. DB CHECK 로 기간을 강제하지 않음(시각 비교는 런타임). DB 는 1인1표·점수범위·트랙값만 강제.
|
||||||
|
|
||||||
|
### 단방향 유저평점 트랙 계약 (G4/F5 — W2-6 소비, 권위)
|
||||||
|
- **소스**: `game_review_stats.avg_rating`(overall 단일 평균, numeric). 6축평균(avg_immersion 등)은 **시상 산정에 미사용**(표시 전용).
|
||||||
|
- **방향**: 단방향 읽기. 시상이 VIEW 를 SELECT 만, 리뷰 도메인(game_reviews/axes/stats) write 0. game_reviews 에 jam FK 추가 금지(잼 무관 유지).
|
||||||
|
- **기간**: 평가기간 필터 **미적용**. 출품 게임의 전체 리뷰 avg_rating 소비(잼 무관 상시 리뷰 → game_review_stats 재집계 부담 회피). 설계 명시 결정(orchestrator 확정).
|
||||||
|
- **NULL/미달**: 리뷰 0개 또는 axes 0행 → avg_rating NULL. 시상 산정 시:
|
||||||
|
- 정렬 `ORDER BY avg_rating DESC NULLS LAST`.
|
||||||
|
- **임계**: `review_count >= 3`(기본 N=3) 미달 출품작은 USER_RATING 트랙 **제외**(소수 리뷰 편향 방지). 임계값은 시상 산정 상수(W2-6 구현에 위치, concern 4 — 잼별 가변 필요 시 확장).
|
||||||
|
- **소비 SQL 형태(W2-6 참고)**:
|
||||||
|
```sql
|
||||||
|
SELECT e.game_id, st."avgRating", st."reviewCount"
|
||||||
|
FROM jam_entries e
|
||||||
|
LEFT JOIN game_review_stats st ON st.game_id = e.game_id
|
||||||
|
WHERE e.jam_id = #{jamId} AND e.is_delete IS NOT TRUE
|
||||||
|
AND st.review_count >= 3
|
||||||
|
ORDER BY st.avg_rating DESC NULLS LAST, e.game_id ASC;
|
||||||
|
```
|
||||||
|
(매퍼 alias: game_review_stats 는 집계 VIEW → camelCase 큰따옴표 `AS "avgRating"` — GameReviewStatsMapper.java:13 선례.)
|
||||||
|
|
||||||
|
### 집계 노출 계약 (G6/F7 — W2-4/W2-6 소비)
|
||||||
|
- **심사 집계**: `jam_score_stats` VIEW. 출품작(jam_id, game_id) 단위 1행 = {weighted_total, simple_total, scored_criteria, judge_count}. W2-4 정렬·W2-6 JUDGE 트랙 랭크 소스. fan-out 방지(per_criterion 선집계).
|
||||||
|
- **투표 집계**: `SELECT game_id, COUNT(*) FROM jam_votes WHERE jam_id = #{jamId} GROUP BY game_id`. 별도 VIEW 불필요(단순 count — over-engineering 회피). W2-6 POPULAR 트랙 소스.
|
||||||
|
- **시상 트랙 산정(W2-6)**:
|
||||||
|
- JUDGE: jam_score_stats.weighted_total DESC.
|
||||||
|
- USER_RATING: game_review_stats.avg_rating DESC NULLS LAST (review_count>=3).
|
||||||
|
- POPULAR: jam_votes count DESC.
|
||||||
|
- GRAND: 3트랙 결과의 가중 종합(가중치는 jam 설정 또는 동률 규칙 — W2-6 산정 파라미터). 본 동결은 jam_awards.award_track='GRAND' 행 자리만 제공.
|
||||||
|
|
||||||
|
### 매퍼 시그니처 계약 (하류가 구현할 매퍼의 동결 인터페이스 — 시그니처만, 본체는 하류)
|
||||||
|
> 본 W2-3 은 매퍼 클래스를 **생성하지 않는다**. 아래는 하류가 따를 동결 시그니처(snake→camel 직접 alias 일반매퍼 표준, 집계 VIEW 매퍼만 큰따옴표). 최소 인자.
|
||||||
|
|
||||||
|
```java
|
||||||
|
// --- W2-4 소유 (jam_criteria / jam_scores / jam_score_stats) ---
|
||||||
|
// JamCriteriaMapper (@Mapper, #{} only, snake→camel)
|
||||||
|
int insertCriterion(JamCriterionData criterion) // 잼 심사기준 등록(관리자)
|
||||||
|
List<JamCriterionData> listByJam(long jamId) // 점수입력 폼·집계 라벨
|
||||||
|
|
||||||
|
// JamScoresMapper (@Mapper, #{} only)
|
||||||
|
int upsertScore(long jamId, // 평가단위 1/2
|
||||||
|
long gameId, // 평가단위 2/2(출품작)
|
||||||
|
long judgeUserId, // 심사위원(세션)
|
||||||
|
String criterionKey, // 채점 기준
|
||||||
|
int score) // 1~5 (UNIQUE 충돌 시 UPDATE — ON CONFLICT 또는 exists→update)
|
||||||
|
List<JamScoreData> listByJudge(long jamId, long judgeUserId) // 심사위원 본인 입력 현황
|
||||||
|
|
||||||
|
// JamScoreStatsMapper (@Mapper, #{} only, 집계 VIEW → camelCase 큰따옴표 alias)
|
||||||
|
List<Map<String,Object>> listStatsByJam(long jamId) // 출품작별 종합(정렬·시상 소스)
|
||||||
|
Map<String,Object> getStats(long jamId, long gameId) // 단건 출품작 종합
|
||||||
|
|
||||||
|
// --- W2-5 소유 (jam_votes) ---
|
||||||
|
// JamVotesMapper (@Mapper, #{} only)
|
||||||
|
int castVote(long jamId, long gameId, long voterUserId) // 투표(UNIQUE 1인1표; 변경=update game_id)
|
||||||
|
int updateVote(long jamId, long voterUserId, long gameId) // 표 변경(최애 교체)
|
||||||
|
boolean hasVoted(long jamId, long voterUserId) // 1인1표 사전 체크
|
||||||
|
long countByGame(long jamId, long gameId) // 출품작 득표(또는 listCounts 집계)
|
||||||
|
|
||||||
|
// --- W2-6 소유 (jam_awards + 3트랙 소비) ---
|
||||||
|
// JamAwardsMapper (@Mapper, #{} only)
|
||||||
|
int upsertAward(JamAwardData award) // 트랙·순위 수상 기록(재산정 멱등)
|
||||||
|
int deleteByJamTrack(long jamId, String track) // 재산정 전 트랙 초기화
|
||||||
|
List<JamAwardData> listByJam(long jamId) // 시상 결과 노출
|
||||||
|
```
|
||||||
|
> inflate 마킹(concern 2): 위 시그니처는 **계약 골격**이다. 하류 구현 시 각 인자가 실제 SQL/로직에 쓰이는지 재확인(예: `JamScoresMapper.upsertScore` 를 ON CONFLICT 로 구현하면 별도 exists 조회 불요 — exists 메서드 추가 inflate 금지). 매퍼 본체·@MockBean 등록은 하류 소관.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 시퀀스 (계약 검증용 의사코드 — 하류 구현이 따를 흐름)
|
||||||
|
|
||||||
|
### S1. 심사 점수 입력 (W2-4 — 본 동결 스키마 소비)
|
||||||
|
```
|
||||||
|
[심사위원 세션] POST /jams/{slug}/scores (CSRF, gameId, {criterionKey: score, ...})
|
||||||
|
→ (W2-4 컨트롤러)
|
||||||
|
→ CsrfTokens.isValid (아니면 403)
|
||||||
|
→ userId = sessionUserId (없으면 401)
|
||||||
|
→ jam = jamsMapper.getBySlug(slug) (없으면 404)
|
||||||
|
→ 평가기간 게이트(F6): jam.status=='EVAL' AND now∈[eval_start,eval_end]? (아니면 422)
|
||||||
|
→ 출품작 존재: jamEntriesMapper.exists(jam.id, gameId)? (아니면 404/422)
|
||||||
|
→ 심사위원 자격(W2-2): jamJudgesGate.isJudge(jam.id, userId)? (아니면 403)
|
||||||
|
→ for (criterionKey, score) in 입력:
|
||||||
|
jamScoresMapper.upsertScore(jam.id, gameId, userId, criterionKey, score)
|
||||||
|
→ 200 {message}
|
||||||
|
# jam_score_stats VIEW 가 자동 반영(집계는 읽기 시점 계산).
|
||||||
|
```
|
||||||
|
|
||||||
|
### S2. 인기투표 (W2-5 — 본 동결 스키마 소비)
|
||||||
|
```
|
||||||
|
[로그인 유저] POST /jams/{slug}/votes (CSRF, gameId)
|
||||||
|
→ (W2-5 컨트롤러)
|
||||||
|
→ CsrfTokens.isValid (아니면 403)
|
||||||
|
→ userId = sessionUserId (없으면 401)
|
||||||
|
→ jam = getBySlug(slug); 평가기간 게이트(F6) (아니면 422)
|
||||||
|
→ 출품작 존재(jam_entries 활성)? (아니면 404)
|
||||||
|
→ hasVoted(jam.id, userId)?
|
||||||
|
미투표 → castVote(jam.id, gameId, userId) (UNIQUE 보장 1인1표)
|
||||||
|
기투표 → updateVote(jam.id, userId, gameId) (최애 교체 정책 — W2-5 결정)
|
||||||
|
→ 200 {message, votedGameId}
|
||||||
|
```
|
||||||
|
|
||||||
|
### S3. 시상 집계 확정 (W2-6 — 3트랙 + grand, 본 동결 스키마+계약 소비)
|
||||||
|
```
|
||||||
|
[관리자] POST /admin/jams/{jamId}/awards/compute (CSRF, GAME_JAM_MANAGE 게이트)
|
||||||
|
→ (W2-6 컨트롤러)
|
||||||
|
→ requireJamManage + CSRF
|
||||||
|
→ jam.status=='CLOSED' OR now()>eval_end_at? (아니면 422 — 시상 미개방)
|
||||||
|
→ JUDGE 트랙: jam_score_stats.weighted_total DESC → rank 부여 → upsertAward(track='JUDGE')
|
||||||
|
→ USER_RATING: game_review_stats.avg_rating DESC NULLS LAST, review_count>=3
|
||||||
|
→ rank → upsertAward(track='USER_RATING') # 단방향 읽기(G4)
|
||||||
|
→ POPULAR: jam_votes count DESC → rank → upsertAward(track='POPULAR')
|
||||||
|
→ GRAND: 3트랙 가중 종합(가중치 jam 설정/동률규칙) → 최상위 → upsertAward(track='GRAND')
|
||||||
|
→ 200 {message, awardCounts}
|
||||||
|
# 재실행 = deleteByJamTrack 후 재산정(멱등). game_reviews write 0.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 영향 맵
|
||||||
|
|
||||||
|
> 본 W2-3 은 **스키마+계약 동결**이 범위다. 신규 = DDL 권위 파일 + schema.sql 동기 2건만. 매퍼/data POJO/컨트롤러/JSP/테스트는 **하류 W2-4/5/6 소유**(아래 "하류 소유 — 본 설계 미생성" 표는 계약 추적용으로만 명시, 본 설계가 만들지 않음).
|
||||||
|
>
|
||||||
|
> 소유권 분할(본 W2-3 worker 단위): **E-SCHEMA**(jam-eval-ddl.sql + schema.sql 동기) 단일. data POJO 계약(JamCriterionData/JamScoreData/JamAwardData 등)은 하류가 자기 워크스트림에서 생성.
|
||||||
|
|
||||||
|
### 본 W2-3 이 생성/수정 (동결 산출물)
|
||||||
|
| 변경 유형 | 경로 | 역할 | 소유 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 신규 | `docs/jam-eval-ddl.sql` | 권위 DDL(jam_criteria/jam_scores/jam_votes/jam_awards/jam_score_stats VIEW). apply-local-ddl.sh 자동 적용(알파벳: jam-ddl 뒤) | E-SCHEMA |
|
||||||
|
| 수정 | `db/schema.sql` | 위 4테이블+1뷰 블록 추가(jam-eval-ddl 사본). jams/jam_entries 블록 뒤. game_reviews/games 무변경 | E-SCHEMA |
|
||||||
|
|
||||||
|
### 하류 소유 — 본 설계 미생성 (계약 추적용 — crossRefs)
|
||||||
|
| 소유 W | 생성물(예) | 본 동결 의존점 |
|
||||||
|
|---|---|---|
|
||||||
|
| W2-4 | JamCriteriaMapper/JamScoresMapper/JamScoreStatsMapper + data POJO + 점수입력 컨트롤러/JSP | jam_criteria/jam_scores/jam_score_stats VIEW + 평가기간 게이트(F6) + 집계 계약(G6) |
|
||||||
|
| W2-5 | JamVotesMapper + data + 투표 컨트롤러/JSP | jam_votes UNIQUE(1인1표) + 평가기간 게이트(F6) |
|
||||||
|
| W2-6 | JamAwardsMapper + data + 시상 산정/확정 컨트롤러/JSP | jam_awards 3트랙 + 단방향 유저평점 계약(G4/F5) + 집계 계약(G6) + jam_score_stats/game_review_stats/jam_votes count |
|
||||||
|
| W2-2 | jam_judges 게이트 | jam_scores.judge_user_id 의 is-judge 검증(앱계층) |
|
||||||
|
|
||||||
|
> SSR 호출지점 영향(verification §영향맵): 본 W2-3 은 신규 DDL/VIEW 만 추가, 기존 매퍼·JSP·컨트롤러 무변경 → 기존 소비처 0 영향. game_review_stats VIEW 무변경이므로 W3-2 리뷰 요약 화면 회귀 0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 대안 비교
|
||||||
|
|
||||||
|
| 주제 | 안 | 장점 | 단점 | 채택 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 평가단위 식별자 | (A) (jam_id, game_id) 자연키 + 앱계층 출품검증 | orchestrator 동결 컬럼 일치, game_review_stats join 정합, entrant 불투명 유지 | FK 출품 보장은 앱계층(jam_entries 활성행) | **채택(F8)** |
|
||||||
|
| | (B) jam_entries.id 직접 FK | 출품 보장 DB 강제 | game_review_stats(game_id)와 join 불일치, orchestrator 동결과 어긋남, entrant.id 노출 | 기각(concern 1 재확인 마킹) |
|
||||||
|
| 심사 척도 | (A) 잼별 설정형 jam_criteria | 잼마다 기준 다름(정석), 가중 종합 가능 | criteria 테이블 1개 | **채택(F1)** |
|
||||||
|
| | (B) 고정 6축(리뷰 axes 재사용) | 단순 | 잼별 심사 기준 다양성 부정, 리뷰축과 의미 강결합 | 기각 |
|
||||||
|
| 유저평점 트랙 소스 | (A) avg_rating 단일평균 | 단순·명확, 리뷰 6축과 의미 충돌 회피, VIEW 그대로 | 6축 정보 미반영 | **채택(F5)** |
|
||||||
|
| | (B) 6축 가중 평균 | 다면 평가 | 잼 criteria 와 의미 이중화, 6축 NULL 처리 복잡 | 기각(6축=표시전용) |
|
||||||
|
| 유저평점 기간 | (A) 전체 리뷰 avg_rating | game_review_stats 재집계 0, 리뷰=잼무관 상시 정합 | 평가기간 외 리뷰도 반영 | **채택(F5)** |
|
||||||
|
| | (B) 평가기간 내 리뷰만 | 기간 정합 | game_review_stats 기간필터 재집계 부담, 리뷰 도메인 잼 결합 유발 | 기각 |
|
||||||
|
| 인기투표 저장 | (A) jam_votes 신규(1인1표 UNIQUE) | 1인1표 DB 강제, user_id 기반, 잼 무관 game_likes 분리 | 테이블 1개 | **채택(F3)** |
|
||||||
|
| | (B) game_likes 재활용 | 재사용 | 비권위 복원본·user_key varchar·1인1표 비보장(grounding R-D) | 기각 |
|
||||||
|
| 심사 집계 노출 | (A) jam_score_stats VIEW(선집계) | fan-out 방지(game_review_stats 선례), 정렬/시상 소스 단일 | VIEW 1개 | **채택(F7)** |
|
||||||
|
| | (B) 매퍼 GROUP BY 매번 | VIEW 없음 | 소비처마다 집계 SQL 중복, fan-out 위험 반복 | 기각 |
|
||||||
|
| 투표 집계 | (A) COUNT 쿼리(VIEW 없음) | 단순, over-engineering 회피 | — | **채택(F7)** |
|
||||||
|
| | (B) jam_vote_stats VIEW | 일관성 | 단순 count 에 VIEW 과도 | 기각 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 롤아웃 / 마이그레이션
|
||||||
|
|
||||||
|
### 순서
|
||||||
|
1. **스키마 적용**: `docs/jam-eval-ddl.sql` → `db/apply-local-ddl.sh`(로컬). 운영은 동일 멱등 DDL 수동 적용.
|
||||||
|
- **선행 의존**: jam-eval-ddl 의 FK 가 jams/jam_entries(W2-1 docs/jam-ddl.sql) + games/users(기존) 를 참조 → **jam-ddl.sql 이 먼저 적용돼야 함**. apply-local-ddl.sh 알파벳 글롭에서 `jam-ddl.sql` < `jam-eval-ddl.sql`(공통 prefix `jam-` 뒤 `d` < `e`) → 순서 자동 보장. game_reviews(W3-2 docs/game-reviews-ddl.sql, `g` < `j`)도 먼저 적용됨 → game_review_stats VIEW 존재 보장(시상 소비 가능).
|
||||||
|
2. **하류 코드 배포**: W2-4/5/6 가 본 동결 스키마를 소비하는 매퍼/컨트롤러 구현. 본 W2-3 은 코드 배포 없음(스키마만).
|
||||||
|
3. **권한**: GAME_JAM_MANAGE(W1 시드) + jam_judges(W2-2). 본 W2-3 추가 시드 없음.
|
||||||
|
|
||||||
|
### 역호환
|
||||||
|
- games/game_reviews/game_review_stats/game_likes/jams/jam_entries **전부 무변경**. 기존 사용자·리뷰·게임 동작 0 영향.
|
||||||
|
- 신규 테이블/VIEW 는 추가 전용(비파괴). 하류 미배포 상태에서도 빈 테이블/VIEW 로 잔존 무해.
|
||||||
|
|
||||||
|
### 롤백
|
||||||
|
- 스키마 롤백: 신규 4테이블+1뷰는 추가 전용 → drop 없이 잔존 무해(비파괴). 명시 DROP 은 별도 maintenance(`DROP VIEW IF EXISTS jam_score_stats; DROP TABLE IF EXISTS jam_awards, jam_votes, jam_scores, jam_criteria CASCADE;` — FK 역순).
|
||||||
|
- 코드 롤백: 본 W2-3 은 코드 0 → 롤백 대상 없음. 하류 롤백은 각 워크스트림 소관.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AC 매핑
|
||||||
|
|
||||||
|
| AC | 요구(골자 W2-3) | 만족 설계 요소 | 비고 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AC-1 | 심사 척도 = 잼별 설정형 criteria | jam_criteria(jam_id, criterion_key, weight) + ux_jam_criteria_jam_key | F1, §데이터모델1 |
|
||||||
|
| AC-2 | 심사 점수 1~5, 1심사위원1기준1점 | jam_scores score CHECK 1~5 + ux_jam_scores_jam_game_judge_criterion | F2, §데이터모델2 |
|
||||||
|
| AC-3 | 인기투표 잼당 1인1표 | jam_votes ux_jam_votes_jam_voter UNIQUE | F3, §데이터모델3 |
|
||||||
|
| AC-4 | 투표 game_likes 와 별개 | jam_votes 신규 테이블(user_id 기반), game_likes 무참조 | F3, §대안 |
|
||||||
|
| AC-5 | 시상 3트랙 + grand | jam_awards award_track CHECK 4값(JUDGE/USER_RATING/POPULAR/GRAND) | F4, §데이터모델4 |
|
||||||
|
| AC-6 | ★유저평점 트랙 단방향(avg_rating 단일) | game_review_stats.avg_rating 읽기만, 6축 미사용, game_reviews 무변경 | G4/F5, §단방향계약 |
|
||||||
|
| AC-7 | 유저평점 NULL/미달 처리 | NULLS LAST + review_count>=3 임계 | F5, §단방향계약 |
|
||||||
|
| AC-8 | 평가기간 게이트(점수/투표 EVAL, 시상 CLOSED) | F6 계약(앱계층 jam.status + now∈eval 구간) | §평가기간게이트계약 |
|
||||||
|
| AC-9 | 심사 집계 fan-out 방지 VIEW | jam_score_stats per_criterion 선집계 + 가중종합 NULLIF 가드 | F7/난제3, §데이터모델5 |
|
||||||
|
| AC-10 | 투표 집계 = count | jam_votes COUNT 계약(VIEW 없음) | F7, §집계노출계약 |
|
||||||
|
| AC-11 | 권한/평가 SQL `${}` 0 | DDL·계약 매퍼 시그니처 `#{}` only(하류 강제) | §매퍼시그니처계약 |
|
||||||
|
| AC-12 | 집계 VIEW 매퍼 alias 큰따옴표 | game_review_stats/jam_score_stats 소비 매퍼 AS "..." | §단방향계약/집계계약 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 포인트 (verification-advisor 점검 대상)
|
||||||
|
|
||||||
|
> L레벨 매핑(verification-strategies): 동결 스키마 무결성(CHECK/UNIQUE/FK)·집계 VIEW(fan-out/NULL/0-division) = **L1+L2(dev DB contract)**. 단방향 계약·평가기간 게이트 = **L1**(스키마 차원) + 하류 구현 시 **L3**. 본 W2-3 은 코드 빈 추가 0이므로 contextLoads(§30 @MockBean)는 **하류 책임**(본 설계 매퍼 미생성).
|
||||||
|
|
||||||
|
### 시나리오 검증
|
||||||
|
- **VP-1 (AC-2/3 무결성, L2)**: jam_scores 같은 (jam_id,game_id,judge,criterion) 중복 INSERT → UNIQUE 거부. score 0/6 INSERT → CHECK 거부. jam_votes 같은 (jam_id, voter) 2표 INSERT → UNIQUE 거부(1인1표). dev DB contract 실측.
|
||||||
|
- **VP-2 (AC-9 집계 정합, L2)**: jam_score_stats 가 동일 출품작에 다수 심사위원·다수 criterion 입력 시 ① fan-out 없이 weighted_total 정확(SUM(avg*weight)/SUM(weight)) ② criterion 일부 미채점 시 채점 기준만 반영 ③ weight 전부 0 인 경계에서 0-division 없이 NULL(NULLIF 가드) — 샘플 데이터 실측(game_review_stats BUG-1 fan-out 선례 재발 방지).
|
||||||
|
- **VP-3 (AC-6 단방향, L1)**: 시상 USER_RATING 소비 SQL 이 game_review_stats 를 SELECT 만(write 0), game_reviews 에 jam FK 부재 확인(code 정합). 6축 컬럼 미참조 확인.
|
||||||
|
- **VP-4 (AC-7 NULL/미달, L2)**: 리뷰 0개 출품작 avg_rating NULL → NULLS LAST 정렬 말단 + review_count<3 제외. review_count==3 경계 포함. 샘플 실측.
|
||||||
|
- **VP-5 (AC-5 트랙값, L2)**: jam_awards award_track 에 'JUDGE'/'USER_RATING'/'POPULAR'/'GRAND' 외 값 INSERT → CHECK 거부.
|
||||||
|
- **VP-6 (FK 적용 순서, L2)**: apply-local-ddl.sh 가 jam-ddl(W2-1) 적용 후 jam-eval-ddl 적용 시 FK 생성 성공(jams/jam_entries/games/users 선존재). 단독/역순 적용 시 FK 실패 검출.
|
||||||
|
- **VP-7 (AC-12 alias, L2)**: 하류 jam_score_stats 매퍼 반환 키가 weightedTotal/simpleTotal/scoredCriteria/judgeCount 로 정합(집계 VIEW camelCase 큰따옴표 alias 확인 — GameReviewStatsMapper 케이스폴딩 BUG-2 선례 회피).
|
||||||
|
|
||||||
|
### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용)
|
||||||
|
> self-audit(시점): 아래 카운트는 **본 W2-3 이 신규 생성하는 정적 산출물**(DDL 테이블·VIEW·CHECK·트랙값)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리처럼 증가하는 대상 아님. 하류(W2-4/5/6)는 본 동결 스키마를 **소비만** 하고 본 DDL 파일을 수정하지 않으므로(소유 분리), 본 파일 카운트는 verification 시점에 불변.
|
||||||
|
> self-audit(표현): 단일 리터럴 grep 취약성을 피해 CHECK IN 목록/트랙 enum/테이블 생성문 같은 **구조적 불변식**에 앵커. 매퍼 `${` 0건은 부재 검증이라 리터럴 정당.
|
||||||
|
|
||||||
|
- **AC-T1 잼 평가 신규 테이블 전수 4건 + VIEW 1건** — docs/jam-eval-ddl.sql 의 `CREATE TABLE IF NOT EXISTS` 4건(jam_criteria/jam_scores/jam_votes/jam_awards) AND `CREATE OR REPLACE VIEW` 1건(jam_score_stats): `grep -c 'CREATE TABLE IF NOT EXISTS' docs/jam-eval-ddl.sql` == 4 AND `grep -c 'CREATE OR REPLACE VIEW' docs/jam-eval-ddl.sql` == 1. AND db/schema.sql 에 동일 4테이블+1뷰 전수 존재(동기 사본 누락 검출). 테이블/뷰 추가·삭제 누락을 갯수로 동시 커버.
|
||||||
|
- **AC-T2 시상 트랙 전수 4종 정합 불변식** — jam_awards_track_check CHECK 의 IN 목록(JUDGE/USER_RATING/POPULAR/GRAND) 4종 == 시상 산정(W2-6)이 upsert 하는 award_track 집합. 검증: DDL CHECK IN 항목 4 AND (하류 W2-6 구현 시) 3개별 트랙 + GRAND 전수 산정 경로 존재. 트랙 추가·누락을 갯수 1로 커버(F4 핵심 가드).
|
||||||
|
- **AC-T3 점수/투표 무결성 제약 전수** — 동결 핵심 UNIQUE/CHECK 4종 전수 존재: ux_jam_scores_jam_game_judge_criterion(1심사위원1기준1점), ux_jam_votes_jam_voter(1인1표), jam_scores_score_check(1~5), jam_awards_track_check(4트랙). 검증: `grep -c 'CREATE UNIQUE INDEX' docs/jam-eval-ddl.sql` >= 4(criteria/scores/votes/awards 각 1) AND 위 4 제약명 전수 존재. 1건 누락 = 무결성 결함(1인1표/중복채점 우회) → FAIL.
|
||||||
|
- **AC-T4 평가 매퍼 시그니처 계약 전수 `${` 0건(하류 검증)** — 하류 W2-4/5/6 가 본 계약대로 구현한 신규 매퍼 전수에 `${` 매치 0: `grep -rc '\${' <하류 jam-eval 매퍼들>` == 0 (AC-11, `${}` 금지). 부재 검증이라 리터럴 정당. **본 W2-3 은 매퍼 미생성 → 이 AC 는 하류 verification 시점 검사**(계약 위임 명시).
|
||||||
|
- **AC-T5 단방향 무결성(write 0) 불변식** — game_reviews/game_review_axes/game_review_stats 가 본 동결로 인해 변경 0: docs/jam-eval-ddl.sql 에 `game_reviews`/`game_review` 토큰의 ALTER/CREATE/INSERT/UPDATE 0건(읽기 계약뿐 — 주석/SELECT 형태 예시는 무방하나 DDL 변경문 0). 검증: `grep -E 'ALTER TABLE .*game_review|CREATE TABLE .*game_review' docs/jam-eval-ddl.sql` 0건. 단방향 계약(G4) 위반(시상이 리뷰 스키마 손대기) 즉시 검출.
|
||||||
|
- **AC-T6 FK 적용 순서 불변식** — jam-eval-ddl 의 FK 가 참조하는 선행 테이블 전수(jams/jam_entries/games/users) 가 apply 시점에 존재: 알파벳 글롭 순서상 jam-ddl/game-reviews-ddl 가 jam-eval-ddl 보다 먼저(공통 prefix 비교) → FK 생성 성공. 검증: apply-local-ddl.sh dry-run 또는 dev DB 전체 적용 후 jam_scores/jam_votes/jam_awards/jam_criteria 의 FK 4종 전수 생성 확인(pg_constraint). 순서 깨짐 시 FK 미생성 검출.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 잔여 오픈 질문
|
||||||
|
없음(0). 동결 결정 F1~F8 전제 고정. 세 난제(평가단위 식별자 정합·단방향 유저평점 계약·집계 fan-out/가중/NULL)는 본 설계가 구체 메커니즘으로 확정. 평가기간 게이트·집계 노출·매퍼 시그니처 계약 동결. 구현 점검 항목(평가단위 jam_entries.id vs game_id 재확인·하류 @MockBean·VIEW 0-division dev contract·최소리뷰수 임계 가변화·jam_entries UNIQUE 동결 유지)은 오픈 질문이 아니라 `concerns` 로 이관.
|
||||||
|
|
@ -0,0 +1,354 @@
|
||||||
|
---
|
||||||
|
phase: design
|
||||||
|
agent: design-advisor
|
||||||
|
agent_version: 1
|
||||||
|
generated_at: 2026-06-23T16:30:00+09:00
|
||||||
|
workstream: W2-4-심사위원 평가(점수 입력/집계 소비)
|
||||||
|
concerns:
|
||||||
|
- "★게이트 의존 시그니처 — 점수 입력은 W2-2 의 `JamRoleGate.isJudge(session, jamId)`(자격) + W2-2 자기출품 충돌규칙에 의존한다. 본 W2-4 는 이 게이트를 호출만 하고 소유하지 않는다(W2-2 미설계 시점이므로 시그니처는 orchestrator 확정값 `isJudge(session, jamId)` 를 계약으로 가정). 구현 단계에서 W2-2 산출 게이트의 실제 시그니처(인자·반환·자기출품 충돌 포함 여부)를 재확인 필요 — W2-2 가 충돌검사를 isJudge 안에 합칠지 별도 메서드로 둘지에 따라 본 컨트롤러 호출 코드가 갈린다. crossRefs 참조."
|
||||||
|
- "신규 컨트롤러(JamScoringController) + 신규 매퍼(JamCriteriaMapper/JamScoresMapper/JamScoreStatsMapper) 의존 추가 — verification-strategies §30 에 따라 implementation 단계에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 신규 @Mapper @MockBean 수동 등록 의무(JamRoleGate/PermissionGate 빈도 컨트롤러 주입 시 동일). 누락 시 contextLoads NoSuchBeanDefinitionException."
|
||||||
|
- "jam_score_stats 는 집계 VIEW → 소비 매퍼(JamScoreStatsMapper) alias 는 camelCase 큰따옴표(AS \"weightedTotal\") 필수(케이스 폴딩 함정, verification-strategies §33, GameReviewStatsMapper.java:13 선례). criterion 단위 펼침 조회(criterion별 평균)는 VIEW 가 종합 1행만 노출하므로 W2-4 가 jam_scores 직접 GROUP BY 매퍼로 별도 작성 — 이 펼침 쿼리도 집계라 큰따옴표 alias. dev DB contract(L2)로 fan-out·0-division·NULL 실측 권장."
|
||||||
|
- "JamScoringController 헬퍼(requireEvalOpen/requireJudge/resolveScores) 시그니처는 최소 인자로 명세했다. 구현 단계에서 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고 방지, 프로토콜 §11.2). 특히 평가기간 게이트가 JamData 전체를 받지만 status/eval_start_at/eval_end_at 3필드만 읽음 → 필요 필드로 좁혀질 수 있음."
|
||||||
|
- "jam_scores UPSERT 는 PostgreSQL `INSERT ... ON CONFLICT (jam_id,game_id,judge_user_id,criterion_key) DO UPDATE SET score=EXCLUDED.score, updated_at=now()` 단일문 권장(exists→update 2쿼리 inflate 회피). ux_jam_scores_jam_game_judge_criterion(W2-3 동결) 의존. 이 UNIQUE 가 W2-3 동결 후 변경되면 ON CONFLICT 타깃이 깨진다 — crossRefs 동결 유지 필수. dev DB contract 로 ON CONFLICT 실측."
|
||||||
|
- "criterion_key 검증 — 입력 score 의 criterion_key 가 해당 잼의 jam_criteria 에 실재하는지 앱계층 검증(미등록 키 점수 거부, 422). jam_scores.criterion_key 는 jam_criteria.criterion_key 의 논리참조(W2-3: FK 아님)이므로 DB 가 막지 않음 → 앱계층 화이트리스트 필수(보안/무결성)."
|
||||||
|
concerns_checked: true
|
||||||
|
self_verification:
|
||||||
|
checklist_passed: true
|
||||||
|
references:
|
||||||
|
requirements: docs/work-log/2026-06-23-w2-w4-feature-skeletons.md
|
||||||
|
research: .atp/work-session/20260623-104307/research/W2-W4-grounding.md
|
||||||
|
adrs:
|
||||||
|
- .atp/work-session/20260623-104307/implementation/W2-3-eval-freeze-design.md
|
||||||
|
- .atp/work-session/20260623-104307/implementation/W2-1-jam-entity-design.md
|
||||||
|
- .atp/work-session/20260622-180054/implementation/W1-design.md
|
||||||
|
- docs/work-log/2026-06-17-jam-platform-roadmap.md
|
||||||
|
- docs/development/verification-strategies.md
|
||||||
|
- docs/jam-eval-ddl.sql
|
||||||
|
---
|
||||||
|
|
||||||
|
# 설계: W2-4 — 심사위원 평가 (점수 입력 API + 3중 게이트 + criterion 단위 채점 + 가중 집계 소비)
|
||||||
|
|
||||||
|
> ⚠️ **W2-3 동결 스키마 소비 워크스트림**. 본 설계는 신규 테이블·VIEW 를 **만들지 않는다**. W2-3 이 동결한 `jam_criteria`/`jam_scores`/`jam_score_stats`(VIEW)를 **그대로 소비**하고, **API·로직·게이트·매퍼만** 설계한다. 동결 스키마 재정의 금지(W2-3 권위).
|
||||||
|
|
||||||
|
## 목표 / 비목표
|
||||||
|
|
||||||
|
### 목표 (FR/NFR 추적 — 골자 W2-4 + 확정 결정)
|
||||||
|
- **G1 점수 입력 API**: 심사위원이 출품작(`(jam_id, game_id)` 자연키)에 jam_criteria 기준별 점수(1~5)를 입력. `jam_scores` UPSERT(criterion 단위 멱등 재입력).
|
||||||
|
- **G2 3중 진입 게이트(정석 보안)**: ① 심사위원 자격 = `JamRoleGate.isJudge(session, jamId)`(W2-2) ② 평가기간 게이트 = `jam.status='EVAL' AND now() ∈ [eval_start_at, eval_end_at]`(W2-3 F6) ③ 자기출품 충돌 = 심사위원이 자기 출품작에 채점 불가(W2-2 충돌규칙). + 상태변경 전수 CSRF.
|
||||||
|
- **G3 집계 소비**: 심사 집계 = `jam_score_stats` VIEW(criterion별 평균의 가중 종합 `weighted_total` + 비가중 `simple_total` + scored_criteria + judge_count). criterion별 펼침 평균은 jam_scores 직접 GROUP BY 매퍼(VIEW 는 종합 1행만 — W2-3 결정). 심사위원 평균·동률 처리 명시.
|
||||||
|
- **G4 수정/재입력**: 평가기간 내 점수 수정 허용(UPSERT → updated_at 갱신). 평가 종료(EVAL 이탈 or now>eval_end) 후 입력·수정 잠금(422).
|
||||||
|
- **G5 부분 입력·미채점 처리**: 심사위원이 일부 criterion 만 입력(부분입력) 허용 — 미입력 criterion 은 jam_scores 행 부재 → 집계에서 그 criterion 제외(W2-3 VIEW per_criterion 선집계). 점수 미입력 criterion·심사위원 부분입력의 집계 의미 명시.
|
||||||
|
- **G6 본인 입력 현황 조회**: 심사위원이 본인이 출품작에 매긴 점수 현황 조회(폼 prefill·수정용).
|
||||||
|
- **NFR**: 상태변경 CSRF 전수, `#{}` 바인딩(`${}` 금지), 입력 sanitize(score 범위·criterion_key 화이트리스트), 임시 role 직접체크 금지(W1/W2-2 게이트 위), 신규 테이블 0(동결 소비).
|
||||||
|
|
||||||
|
### 비목표 (스코프 밖)
|
||||||
|
- **심사 스키마 정의**(jam_criteria/jam_scores/jam_score_stats DDL) — **W2-3 동결 소유**. 본 설계는 소비만(DDL 0).
|
||||||
|
- **심사위원 자격 판정·지정·자기출품 충돌규칙 본체**(`JamRoleGate.isJudge` / jam_judges) — **W2-2 소유**. 본 설계는 게이트를 호출만.
|
||||||
|
- **잼 심사 기준(jam_criteria) 등록 UI·CRUD** — 관리자 잼 설정의 일부. 본 설계는 criterion 조회(점수 폼 라벨·검증)만 소비하고, criterion 등록 API 는 W2-1 관리자 콘솔 확장 또는 별도 — 본 설계 비목표(아래 §criterion 등록 소유 결정).
|
||||||
|
- **인기투표**(W2-5), **시상 집계 산정**(W2-6) — 별도. 본 설계는 jam_score_stats 를 W2-6 JUDGE 트랙이 소비하도록 제공만.
|
||||||
|
- **점수 입력 이력 감사 로그(누가 언제 무엇을)** — 1차는 jam_scores.updated_at 으로 최종 상태만. 변경 이력 테이블은 후속(선택, 아래 §대안).
|
||||||
|
- **잼 평가단위 = jam_entries.id vs (jam_id,game_id) 재논의** — W2-3 동결(F8: (jam_id,game_id) 자연키) 그대로 채택. 재정의 안 함.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
bibimbap 은 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis annotation `@Mapper`(`#{}` only) + JSP 스택이다. W2-3 이 잼 평가 스키마를 동결했다 — `jam_criteria`(잼별 설정형 심사 기준, criterion_key/weight) + `jam_scores`(criterion 단위 점수 1~5, UNIQUE(jam_id,game_id,judge_user_id,criterion_key)) + `jam_score_stats`(출품작 단위 가중 종합 VIEW, fan-out 방지 선집계). W2-1 이 `jams`(status CHECK RECRUIT/DEV/EVAL/CLOSED + eval_start_at/eval_end_at) + `jam_entries`(잼당 game 활성 UNIQUE = 출품작)를 제공한다. W1 RBAC 의 `PermissionGate.has(session, key)`(2인자, PermissionGate.java:22 직접 확인) + epoch 전파(`refreshIfStale`, PermissionGate.java:86) + `CsrfTokens.isValid(request)`(CsrfTokens.java:35 직접 확인)가 인프라로 완비됐다.
|
||||||
|
|
||||||
|
본 설계는 **심사위원이 출품작에 criterion 단위 점수를 입력하는 API + 3중 진입 게이트 + 집계 소비 매퍼**를 설계한다. 신규 테이블·VIEW 는 0(W2-3 동결 소비). 컨트롤러 패턴은 RecruitController(읽기=JSP 뷰이름 반환, 쓰기=`ResponseEntity<Map<String,Object>>` status/message + CSRF, RecruitController.java:140,186 직접 확인)를 따른다.
|
||||||
|
|
||||||
|
확정된 정석 결정(전제 — 재논의 금지, orchestrator 확정):
|
||||||
|
- **점수 입력 단위 = criterion 단위 UPSERT** : `jam_scores` 의 UNIQUE(jam_id,game_id,judge_user_id,criterion_key) 위에 ON CONFLICT UPSERT. 한 출품작에 여러 criterion 을 한 요청으로 batch 입력하되 각 criterion 은 멱등 upsert.
|
||||||
|
- **3중 게이트(앱계층)** : isJudge(W2-2) + 평가기간(W2-3 F6) + 자기출품 충돌(W2-2) + CSRF. DB CHECK 는 score 범위·UNIQUE 만 강제(시각·자격은 런타임).
|
||||||
|
- **집계 = jam_score_stats VIEW(종합) + jam_scores GROUP BY(criterion 펼침)** : VIEW 는 출품작 종합 1행(W2-3 결정), criterion별 평균 펼침은 W2-4 매퍼가 jam_scores 직접 집계.
|
||||||
|
- **수정 = 평가기간 내 UPSERT, 종료 후 잠금** : 평가기간 게이트가 입력·수정 양쪽을 막음(같은 게이트).
|
||||||
|
|
||||||
|
가장 까다로운 세 난제 확정:
|
||||||
|
- **난제1 (3중 게이트 순서·401/403/422 정합)**: 게이트는 **CSRF → 인증(401) → isJudge 자격(403) → 평가기간(422) → 출품작 존재(404) → 자기출품 충돌(422) → criterion_key 화이트리스트(422)** 순으로 평가한다(아래 §게이트 연동 확정). 인증 실패=401, 인가(심사위원 아님)=403, 도메인 상태 위반(기간 외/자기출품/미등록 criterion)=422 — W1-design/W2-1/W2-3 의 401≠403≠422 정책과 일치. 순서 근거: 싼 검사(CSRF/세션)·보안 경계(자격) 먼저, DB 조회 필요한 검사(출품작/criterion) 뒤.
|
||||||
|
- **난제2 (부분 입력·미채점 집계 의미)**: 심사위원은 criterion 일부만 입력 가능(부분입력). 미입력 criterion 은 jam_scores 행 자체가 없음 → W2-3 `jam_score_stats` VIEW 의 `per_criterion` 선집계가 **채점된 criterion 만** 가중 종합에 반영(미채점 criterion 은 자동 제외, weight 분모에서도 빠짐). 따라서 "심사위원 A 가 immersion 만 채점" 해도 종합은 immersion 가중으로만 계산 — 부분입력이 종합을 왜곡하지 않고 채점된 범위로만 산정된다. 출품작별 `judge_count`(criterion별 DISTINCT judge 의 MAX)로 심사 참여 규모를 노출해 부분참여를 가시화.
|
||||||
|
- **난제3 (동률·심사위원 평균 처리)**: 출품작 종합점수(`weighted_total`) 동률은 **DB 가 강제하지 않음**(VIEW 는 산정만). 동률 정렬 tie-break 는 소비처(W2-4 정렬 목록·W2-6 JUDGE 트랙 rank)가 **2차 키(game_id ASC 또는 judge_count DESC)로 결정론적 정렬**. 본 설계 정렬 목록은 `ORDER BY weighted_total DESC NULLS LAST, judge_count DESC, game_id ASC`(미채점 출품작 말단, 심사 많은 작품 우선, 최종 id tie-break). 시상 rank 부여(동률 시 같은 rank)는 W2-6 소관 — 본 설계는 종합 점수 + 결정론 정렬만 제공.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 핵심 결정 요약 (전제 — 재논의 금지. orchestrator 확정 + W2-3 동결 소비)
|
||||||
|
|
||||||
|
| 결정 | 확정값 | 본 설계의 구체화 |
|
||||||
|
|---|---|---|
|
||||||
|
| S1 입력 API | POST/PUT `/jams/{jamId}/games/{gameId}/scores` | criterion별 점수(1~5) batch UPSERT. jamId path = 게이트 스코프, gameId path = 출품작 |
|
||||||
|
| S2 진입 게이트 | isJudge + 평가기간 + 자기출품충돌 + CSRF | 앱계층 3중(+CSRF). isJudge/충돌=W2-2, 기간=W2-3 F6 |
|
||||||
|
| S3 점수 저장 | jam_scores UPSERT(ON CONFLICT) | UNIQUE(jam_id,game_id,judge_user_id,criterion_key) 위 멱등. updated_at 갱신 |
|
||||||
|
| S4 집계 | jam_score_stats VIEW(종합) + jam_scores GROUP BY(펼침) | VIEW=가중종합 1행(W2-3), criterion 펼침=W2-4 매퍼. 큰따옴표 alias |
|
||||||
|
| S5 수정/잠금 | 평가기간 내 수정, 종료 후 잠금 | 입력·수정 동일 게이트(평가기간). EVAL 이탈/now>eval_end → 422 |
|
||||||
|
| S6 부분입력 | 일부 criterion 입력 허용 | 미입력=행 부재→집계 제외(VIEW per_criterion). judge_count 로 가시화 |
|
||||||
|
| S7 동률/평균 | VIEW 산정 + 소비처 결정론 정렬 | weighted_total DESC NULLS LAST, judge_count DESC, game_id ASC |
|
||||||
|
| S8 criterion_key | jam_criteria 논리참조(앱계층 화이트리스트) | 입력 키가 잼 criteria 에 실재해야 점수 수용(미등록→422) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 모델 (DDL — 신규 0. W2-3 동결 스키마 소비)
|
||||||
|
|
||||||
|
> **본 설계는 DDL 을 작성하지 않는다.** W2-3 `docs/jam-eval-ddl.sql`(권위) 의 `jam_criteria`/`jam_scores`/`jam_score_stats`(VIEW)를 그대로 소비한다. 아래는 **소비 형태 확인용 동결 스키마 요약**(W2-3 동결 — 재정의 금지).
|
||||||
|
|
||||||
|
### 소비하는 동결 테이블/뷰 (W2-3 권위 — 변경 0)
|
||||||
|
| 객체 | 종류 | 본 설계 소비 컬럼 | 동결 제약(의존) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `jam_criteria` | 테이블 | jam_id / criterion_key / display_name / sort_order / weight | ux_jam_criteria_jam_key(잼 내 키 UNIQUE) — criterion 화이트리스트·라벨 소스 |
|
||||||
|
| `jam_scores` | 테이블 | jam_id / game_id / judge_user_id / criterion_key / score / updated_at | **ux_jam_scores_jam_game_judge_criterion**(UPSERT ON CONFLICT 타깃) + score_check(1~5) |
|
||||||
|
| `jam_score_stats` | **VIEW** | jam_id / game_id / weighted_total / simple_total / scored_criteria / judge_count | per_criterion 선집계(fan-out 방지) + NULLIF 0-division 가드 |
|
||||||
|
|
||||||
|
- **신규 0 확인**: 본 W2-4 는 테이블·VIEW·컬럼·인덱스·CHECK 를 추가하지 않는다. `docs/*-ddl.sql` 신규 파일 0, `db/schema.sql` 수정 0. (점수 입력 이력 감사 테이블은 비목표 — 후속.)
|
||||||
|
- **criterion 등록 소유 결정**: 본 설계는 jam_criteria 를 **읽기만**(화이트리스트·라벨). criterion 등록(INSERT) API 는 W2-1 관리자 잼 CRUD(`/admin/jams/{jamId}` 확장) 또는 별도 워크스트림 소유 — 본 W2-4 비목표. W2-3 동결 매퍼 시그니처 `JamCriteriaMapper.insertCriterion` 은 등록 소유자가 구현(본 설계는 `listByJam` 만 소비).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 외부 계약 (API)
|
||||||
|
|
||||||
|
> 공통: 모든 상태변경은 `CsrfTokens.isValid(request)` 검증(없으면 403 + `CsrfTokens.errorBody()`, CsrfTokens.java:35,51 직접 확인). 응답은 RecruitController 패턴 — 읽기=JSP 뷰이름 반환, 쓰기=`ResponseEntity<Map<String,Object>>`(status/message, RecruitController.java:186 직접 확인). 점수 입력은 컨트롤러 진입부에서 3중 게이트(isJudge + 평가기간 + 자기출품) 통과 후 본문 수행.
|
||||||
|
|
||||||
|
### 401 vs 403 vs 422 정책 (W1-design / W2-1 / W2-3 과 일치 — 본 설계 준수)
|
||||||
|
- **미인증**(세션 `userId` 없음): API **401** JSON `{status:401, message:"로그인이 필요합니다."}`. 점수 폼 페이지(인증 필요)는 `redirect:/login`.
|
||||||
|
- **인증·미인가**(심사위원 아님 = `isJudge` false): **403** JSON `{status:403, message:"심사 권한이 없습니다."}`(리다이렉트 금지).
|
||||||
|
- **도메인 상태 위반**(평가기간 외 / 자기출품 충돌 / 미등록 criterion / score 범위 외): **422** JSON `{status:422, message:<구체사유>}`. 인가는 됐으나 도메인 규칙 위반 → 403 아님 422(W2-3 §401vs403 정책 일치).
|
||||||
|
- **출품작 없음**(jam_entries 활성행 부재 또는 game 없음): **404**.
|
||||||
|
- **CSRF 실패**: **403** + `CsrfTokens.errorBody()`.
|
||||||
|
|
||||||
|
### 점수 입력/수정 (상태변경 API — CSRF + 3중 게이트)
|
||||||
|
| 액션 | method | path | 요청 body | 응답(200) | 에러 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 점수 입력/수정 | POST | `/jams/{jamId}/games/{gameId}/scores` | `{"scores":[{"criterionKey":"...","score":1~5}, ...]}` | `{status:200, message, jamId, gameId, savedCount}` | 401(미인증), 403(CSRF/심사권한), 404(잼·출품작 없음), 422(평가기간 외/자기출품/미등록 criterion/score 범위) |
|
||||||
|
| 점수 입력/수정(멱등 별칭) | PUT | `/jams/{jamId}/games/{gameId}/scores` | (POST 와 동일) | (POST 와 동일) | (동일) |
|
||||||
|
|
||||||
|
- **POST vs PUT 둘 다 채택 근거(orchestrator 확정 "POST/PUT")**: UPSERT 의미는 PUT(멱등 전체 교체)에 정합하나, 기존 RecruitController 등 상태변경이 전부 POST + ResponseEntity JSON 패턴이고 JSP form 은 PUT 미지원(method 오버라이드 필요)이다. 따라서 **POST 를 1차 경로**(JSP form 호환), **PUT 을 동일 핸들러로 추가 매핑**(REST 멱등 의도 명시, API 클라이언트용)한다. 두 매핑은 같은 컨트롤러 메서드(`@RequestMapping(method={POST,PUT})`)로 동작 동일 — 중복 0.
|
||||||
|
- **batch 입력 채택 근거**: 한 출품작에 criterion 이 여러 개(예: 4개)이고 심사위원은 보통 한 화면에서 전부 매긴다. criterion별 단건 요청은 round-trip N배 + 부분 실패 정합 복잡. body 배열 1요청으로 트랜잭션 내 전 criterion UPSERT(부분입력 시 입력된 것만 배열에 포함).
|
||||||
|
- **savedCount**: 실제 UPSERT 된 criterion 수(입력 배열 길이와 검증 통과 수 일치 — 미등록 criterion 이 하나라도 있으면 전체 422 거부, 부분 저장 안 함 = 트랜잭션 원자성).
|
||||||
|
- **자기 채점 금지(자기출품 충돌, W2-2)**: 심사위원이 자신이 출품한(개인 entrant_user_id==judge 또는 팀 멤버) 출품작에 채점 시도 → 422 `{message:"자기 출품작은 심사할 수 없습니다."}`. 충돌 판정은 W2-2 규칙 위임(아래 §게이트 연동).
|
||||||
|
|
||||||
|
### 점수 조회 (읽기 — 심사위원 본인 현황 / 집계)
|
||||||
|
| 액션 | method | path | 권한 | 응답 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 본인 입력 현황 | GET | `/jams/{jamId}/games/{gameId}/scores/mine` | isJudge | `{status:200, scores:[{criterionKey, score}], criteria:[{criterionKey, displayName, sortOrder, weight}]}` (폼 prefill·수정용) |
|
||||||
|
| 출품작 심사 집계 | GET | `/jams/{jamId}/scores/summary` | 공개 또는 관리자(아래 노출시점 결정) | `{status:200, stats:[{gameId, weightedTotal, simpleTotal, scoredCriteria, judgeCount}]}` (정렬 적용) |
|
||||||
|
|
||||||
|
- **집계 노출 시점 결정(정석)**: 심사 진행 중(EVAL) 실시간 집계 노출은 심사위원 간 점수 동조(anchoring) 편향을 유발한다. 따라서 `/jams/{jamId}/scores/summary` 는 **`jam.status='CLOSED'` 또는 now()>eval_end_at 이후에만 공개**(EVAL 중에는 GAME_JAM_MANAGE 보유 관리자만 조회 — 운영 모니터링). 미개방 시 비관리자는 403/빈 결과. (W2-6 시상 집계도 CLOSED 이후 — W2-3 F6 정합.)
|
||||||
|
- **본인 현황(`/scores/mine`)은 EVAL 중에도 isJudge 본인에게 허용**(자기 입력 prefill — 동조 편향 무관, 본인 점수만).
|
||||||
|
|
||||||
|
### 게이트 진입 계약 (점수 입력 — 본 설계 핵심)
|
||||||
|
- 입력: `HttpServletRequest`(CSRF) + `HttpSession`(userId/role/permissions) + path(jamId, gameId) + body(scores).
|
||||||
|
- 출력: 200(저장) 또는 401/403/404/422.
|
||||||
|
- 판정 순서(난제1): CSRF → 인증 → isJudge(자격, 403) → 잼 조회(404) → 평가기간(422) → 출품작 존재(404) → 자기출품 충돌(422) → criterion_key 화이트리스트·score 범위(422) → UPSERT.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 인터셉터 / 게이트 연동 (S2 — 3중 게이트, 앱계층)
|
||||||
|
|
||||||
|
### 인터셉터 미개입 (확정)
|
||||||
|
- 점수 경로 `/jams/**` 는 RbacInterceptor 등록 대상(`/admin/**`)이 **아니다**(InterceptorConfig.java:18-20, grounding R-A 직접 확인). 따라서 인터셉터는 점수 입력에 개입하지 않고, **게이트는 전부 컨트롤러 진입부 앱계층**에서 수행한다(W2-1 D4-A "소비 액션=게이트 헬퍼" 선례와 동형). 임시 role 직접체크 금지 — 자격은 `JamRoleGate.isJudge`, 권한은 W1 `PermissionGate`(관리자 집계 노출 시).
|
||||||
|
|
||||||
|
### 3중 게이트 구성 (각 게이트의 소유·시그니처)
|
||||||
|
1. **심사위원 자격 (W2-2 소유 — 호출만)**: `jamRoleGate.isJudge(session, jamId)` → false 면 403. **시그니처는 orchestrator 확정값**(`isJudge(session, jamId)`). 본 설계는 이 메서드를 호출만 하고 구현하지 않는다(concern 1 — W2-2 미설계 시점, 구현 단계 시그니처 재확인).
|
||||||
|
2. **평가기간 (W2-3 F6 계약 — 본 설계가 검증 로직 구현)**: `jam.status == 'EVAL' AND now() ∈ [jam.eval_start_at, jam.eval_end_at]` → 위반 시 422. `JamEvalWindow` 헬퍼(본 설계 신규, 아래 §파일영향맵)가 JamData 의 status/eval_start_at/eval_end_at 로 판정. eval_start_at/eval_end_at NULL 이면 "기간 미설정" → 422(EVAL 인데 기간 미설정은 운영 오류).
|
||||||
|
3. **자기출품 충돌 (W2-2 규칙 — 호출/위임)**: 심사위원이 자기 출품작(개인 entrant 본인 또는 팀 멤버) 채점 금지. **충돌 판정 책임은 W2-2** — 본 설계는 두 경로 중 하나를 호출(concern 1):
|
||||||
|
- (a) W2-2 가 `isJudge` 에 자기출품 제외를 포함하면 → 별도 호출 불필요(isJudge 가 자기 출품작 게임에 대해 false 반환하도록 jamId+gameId 받는 변형이 필요할 수 있음 — 시그니처 재확인).
|
||||||
|
- (b) W2-2 가 충돌을 별도 메서드(`isOwnEntry(jamId, gameId, userId)` 등)로 두면 → 본 설계가 isJudge 통과 후 그 메서드 호출해 422.
|
||||||
|
- **본 설계 기본 가정**: (b) 별도 충돌 검사. isJudge 는 "잼 심사위원인가"(스코프 자격)만, 자기출품 충돌은 출품작 단위라 gameId 가 필요하므로 분리가 자연스럽다. 구현 시 W2-2 산출과 정합(concern 1).
|
||||||
|
- **epoch 전파 연동(W1 결정4)**: 관리자 집계 노출 게이트에서 `PermissionGate.has(session, GAME_JAM_MANAGE)` 사용 시 `refreshIfStale`(PermissionGate.java:86)로 요청당 epoch 대조 → 권한 부여/회수 즉시 반영(W1 메커니즘 그대로, 본 설계 추가 작업 0). isJudge 의 즉시성은 W2-2 소관.
|
||||||
|
- **중복 0**: 점수 입력 메서드(POST/PUT 공용) 진입부의 게이트 시퀀스를 private 헬퍼 `requireScoringAllowed(session, request, jamId, gameId)` 로 단일화 — 401/403/404/422 응답 작성 포함.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 시퀀스 (주요 플로우 의사코드)
|
||||||
|
|
||||||
|
### S1. 심사위원 점수 입력/수정 (3중 게이트 → batch UPSERT)
|
||||||
|
```
|
||||||
|
[심사위원 세션] POST /jams/42/games/777/scores (CSRF, {scores:[{immersion:5},{fun:4}]})
|
||||||
|
→ JamScoringController.submitScores(jamId=42, gameId=777, body)
|
||||||
|
→ CsrfTokens.isValid(request) (아니면 403 errorBody)
|
||||||
|
→ userId = sessionUserId(session) (없으면 401)
|
||||||
|
→ jamRoleGate.isJudge(session, 42)? (아니면 403 "심사 권한 없음") [W2-2]
|
||||||
|
→ jam = jamsMapper.getById(42) (없으면 404) [W2-1 매퍼 소비]
|
||||||
|
→ JamEvalWindow.isOpen(jam, now)? (아니면 422 "평가 기간 아님") [W2-3 F6]
|
||||||
|
→ jamEntriesMapper.exists(42, 777)? (아니면 404 "출품작 아님") [W2-1 매퍼 소비]
|
||||||
|
→ 자기출품 충돌(W2-2): isOwnEntry(42, 777, userId)? (참이면 422 "자기 출품작 심사 불가") [W2-2]
|
||||||
|
→ criteria = jamCriteriaMapper.listByJam(42) # 화이트리스트·라벨 [W2-3 매퍼 소비]
|
||||||
|
→ for each {criterionKey, score} in body.scores:
|
||||||
|
criterionKey ∈ criteria.keys? (아니면 422 "미등록 기준") [S8]
|
||||||
|
1 <= score <= 5? (아니면 422 "점수 범위")
|
||||||
|
# 검증 전수 통과 후 트랜잭션 내 일괄 UPSERT(원자성 — 부분저장 안 함)
|
||||||
|
→ @Transactional:
|
||||||
|
for each {criterionKey, score}:
|
||||||
|
jamScoresMapper.upsertScore(42, 777, userId, criterionKey, score)
|
||||||
|
# INSERT ... ON CONFLICT (jam_id,game_id,judge_user_id,criterion_key)
|
||||||
|
# DO UPDATE SET score=EXCLUDED.score, updated_at=now()
|
||||||
|
→ 200 {jamId:42, gameId:777, savedCount:N}
|
||||||
|
# jam_score_stats VIEW 가 다음 집계 조회 시 자동 반영(읽기 시점 계산).
|
||||||
|
```
|
||||||
|
|
||||||
|
### S2. 본인 입력 현황 조회 (폼 prefill / 수정)
|
||||||
|
```
|
||||||
|
[심사위원] GET /jams/42/games/777/scores/mine
|
||||||
|
→ JamScoringController.myScores(42, 777)
|
||||||
|
→ userId = sessionUserId (없으면 401)
|
||||||
|
→ jamRoleGate.isJudge(session, 42)? (아니면 403)
|
||||||
|
→ mine = jamScoresMapper.listByJudge(42, 777, userId) # 본인 criterion별 점수
|
||||||
|
→ criteria = jamCriteriaMapper.listByJam(42) # 라벨·순서·가중
|
||||||
|
→ 200 {scores: mine, criteria}
|
||||||
|
# 미입력 criterion 은 scores 에 부재 → 폼은 criteria 전체 표시 + 입력값만 prefill(부분입력 가시화).
|
||||||
|
```
|
||||||
|
|
||||||
|
### S3. 심사 집계 조회 (CLOSED 이후 공개 / EVAL 중 관리자만)
|
||||||
|
```
|
||||||
|
[조회자] GET /jams/42/scores/summary
|
||||||
|
→ JamScoringController.summary(42)
|
||||||
|
→ jam = jamsMapper.getById(42) (없으면 404)
|
||||||
|
→ 노출 게이트:
|
||||||
|
jam.status=='CLOSED' OR now()>jam.eval_end_at → 공개 허용
|
||||||
|
else (EVAL 진행 중) → PermissionGate.has(session, GAME_JAM_MANAGE)? (아니면 403/빈) [W1]
|
||||||
|
→ stats = jamScoreStatsMapper.listStatsByJam(42) # jam_score_stats VIEW
|
||||||
|
# ORDER BY weighted_total DESC NULLS LAST, judge_count DESC, game_id ASC (난제3 결정론)
|
||||||
|
→ 200 {stats}
|
||||||
|
# W2-6 JUDGE 트랙이 같은 VIEW 의 weighted_total DESC 를 rank 소스로 소비(crossRefs).
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 영향 맵
|
||||||
|
|
||||||
|
> 소유권 분할 가이드(implementation-advisor worker 단위 후보):
|
||||||
|
> **K-DOMAIN**(JamEvalWindow + data POJO) · **K-MAPPER**(JamCriteriaMapper/JamScoresMapper/JamScoreStatsMapper — W2-3 동결 시그니처 구현) · **K-CTRL**(JamScoringController + JSP, 3중 게이트) · **K-TEST**(@MockBean + 게이트/UPSERT/집계 테스트).
|
||||||
|
> 의존: W2-3 동결(DDL) + W2-2 게이트(JamRoleGate) + W2-1 매퍼(JamsMapper.getById/JamEntriesMapper.exists) **선행**. K-DOMAIN → K-MAPPER → K-CTRL.
|
||||||
|
|
||||||
|
| 변경 유형 | 경로 | 역할 | 소유 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/jam/JamEvalWindow.java` | 평가기간 게이트 판정(status='EVAL' AND now∈[eval_start,eval_end]). W2-3 F6 계약 구현 | K-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/JamCriterionData.java` | jam_criteria 행 POJO(criterionKey/displayName/sortOrder/weight) — 폼 라벨·화이트리스트 | K-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/JamScoreData.java` | jam_scores 행 POJO(criterionKey/score[/judgeUserId/updatedAt]) — 본인 현황 | K-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamCriteriaMapper.java` | `@Mapper` `listByJam(jamId)`(`#{}`, snake→camel 직접 alias). W2-3 동결 시그니처. insertCriterion 은 등록 소유자 소관(본 설계 미구현 가능 — listByJam 만) | K-MAPPER |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamScoresMapper.java` | `@Mapper` `upsertScore`(ON CONFLICT) + `listByJudge`(`#{}`, snake→camel) | K-MAPPER |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamScoreStatsMapper.java` | `@Mapper` `listStatsByJam`(jam_score_stats VIEW — **집계 VIEW → 큰따옴표 alias**) | K-MAPPER |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/JamScoringController.java` | 점수 입력(POST/PUT)/본인현황(GET)/집계(GET). 3중 게이트 헬퍼 requireScoringAllowed | K-CTRL |
|
||||||
|
| 신규 | `src/main/webapp/WEB-INF/views/jam-scoring.jsp` | 심사위원 채점 폼(criterion별 1~5, CSRF hidden, prefill). 표시용 — 게이트 아님 | K-CTRL |
|
||||||
|
| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 3매퍼 @MockBean 등록(JamRoleGate/PermissionGate 컨트롤러 주입분도 — contextLoads 보존, §30) | (검증) |
|
||||||
|
| 신규 | `src/test/.../JamScoringControllerTest.java` | 3중 게이트(isJudge/평가기간/자기출품) + CSRF + 401/403/404/422 + UPSERT 멱등 + 부분입력 | (검증) |
|
||||||
|
| 신규 | `src/test/.../JamEvalWindowTest.java` | 평가기간 경계(EVAL+구간내/구간밖/status≠EVAL/기간 NULL) 단위 | (검증) |
|
||||||
|
|
||||||
|
> **신규 테이블/VIEW 0 확인**: 본 설계 파일 영향에 `docs/*-ddl.sql` 신규·`db/schema.sql` 수정 **없음**(W2-3 동결 소비). 매퍼는 동결 스키마를 읽고/UPSERT 만.
|
||||||
|
> SSR 호출지점 영향(verification §영향맵): 신규 컨트롤러·매퍼·JSP·POJO 만 추가 → 기존 매퍼/JSP/컨트롤러 소비처 0 영향. jam_score_stats VIEW·jam_scores 는 W2-3 동결이라 본 설계가 스키마를 건드리지 않음.
|
||||||
|
|
||||||
|
### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
|
||||||
|
```java
|
||||||
|
// JamEvalWindow — 평가기간 게이트 판정(W2-3 F6 구현). jam + now 만으로 판정(최소).
|
||||||
|
boolean isOpen(JamData jam, // status/eval_start_at/eval_end_at 3필드만 읽음(inflate 마킹)
|
||||||
|
java.time.OffsetDateTime now) // 비교 기준 시각(테스트 주입 가능 — Clock 대신 인자)
|
||||||
|
|
||||||
|
// JamCriteriaMapper (@Mapper, #{} only, snake→camel 직접 alias)
|
||||||
|
List<JamCriterionData> listByJam(long jamId) // 점수 폼 라벨 + criterion_key 화이트리스트 소스
|
||||||
|
|
||||||
|
// JamScoresMapper (@Mapper, #{} only)
|
||||||
|
int upsertScore(long jamId, // 평가단위 1/2
|
||||||
|
long gameId, // 평가단위 2/2(출품작)
|
||||||
|
long judgeUserId, // 심사위원(세션 userId)
|
||||||
|
String criterionKey, // 채점 기준(jam_criteria 화이트리스트 통과분)
|
||||||
|
int score) // 1~5. INSERT ON CONFLICT DO UPDATE(멱등 재입력)
|
||||||
|
List<JamScoreData> listByJudge(long jamId, // 잼 스코프
|
||||||
|
long gameId, // 출품작
|
||||||
|
long judgeUserId)// 본인 현황(폼 prefill) — 3키로 본인 criterion별 점수
|
||||||
|
|
||||||
|
// JamScoreStatsMapper (@Mapper, #{} only, 집계 VIEW → camelCase 큰따옴표 alias)
|
||||||
|
List<Map<String,Object>> listStatsByJam(long jamId) // 출품작별 종합(정렬 적용, 시상/목록 소스)
|
||||||
|
```
|
||||||
|
> ⚠️ inflate 마킹(concern 4): `JamEvalWindow.isOpen(jam, now)` 의 `jam` 은 JamData 전체를 받지만 status/eval_start_at/eval_end_at 3필드만 읽는다 — 구현에서 필요 시 `isOpen(String status, OffsetDateTime start, OffsetDateTime end, OffsetDateTime now)` 로 좁힐 수 있음(최소 인자 원칙). `now` 를 Clock 대신 인자로 둔 이유: 테스트에서 경계 시각 주입(고정 Clock 빈 DI 보다 단순). `JamScoresMapper.upsertScore` 는 ON CONFLICT 단일문 권장 — exists→update 2메서드로 쪼개면 inflate(concern 5).
|
||||||
|
> ⚠️ criterion 펼침 평균 조회(criterion별 평균 화면 필요 시): W2-3 결정상 VIEW 는 종합 1행만 노출하므로, criterion별 평균이 화면에 필요하면 `JamScoresMapper` 에 `listCriterionAvgByJam(long jamId)`(jam_scores GROUP BY jam_id,game_id,criterion_key) 를 **구현 단계에서 필요 확인 후 추가**(현 설계는 종합 VIEW 소비로 충분 — 선제 추가 안 함, 최소 인자/메서드 원칙).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 대안 비교
|
||||||
|
|
||||||
|
| 주제 | 안 | 장점 | 단점 | 채택 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 점수 저장 | (A) jam_scores UPSERT(ON CONFLICT) | 멱등 재입력, 단일문, 동결 UNIQUE 활용 | PostgreSQL 의존(ON CONFLICT) | **채택(S3)** |
|
||||||
|
| | (B) exists→insert/update 2쿼리 | 방언 독립 | round-trip 2배·경합 윈도우, 메서드 inflate | 기각 |
|
||||||
|
| | (C) DELETE→INSERT | 단순 | updated_at 이력 소실, 트랜잭션 내 빈 구간 | 기각 |
|
||||||
|
| 입력 단위 | (A) criterion batch(body 배열) 1요청 | round-trip 1회, 원자성, 부분입력 자연 | 배열 검증 | **채택(S1)** |
|
||||||
|
| | (B) criterion 단건 요청 N회 | 단순 매핑 | N round-trip, 부분실패 정합, 폼 1화면과 불일치 | 기각 |
|
||||||
|
| HTTP method | (A) POST 1차 + PUT 동일핸들러 | JSP form 호환 + REST 멱등 의도, 중복 0 | 매핑 2개 | **채택(S1, orchestrator "POST/PUT")** |
|
||||||
|
| | (B) PUT 단독 | REST 정석 | JSP form method 오버라이드 필요, 기존 POST 패턴 이탈 | 기각 |
|
||||||
|
| 자기출품 충돌 위치 | (A) W2-2 별도 메서드 호출(gameId 필요) | 자격(잼)과 충돌(출품작) 관심사 분리, isJudge 단순 | 호출 1회 추가 | **채택(S2 기본가정, concern 1)** |
|
||||||
|
| | (B) isJudge 에 충돌 합침 | 호출 1회 | isJudge 가 gameId 받아야 함(스코프 자격과 출품작 충돌 혼합) | 조건부(W2-2 결정 따름) |
|
||||||
|
| 집계 노출 시점 | (A) CLOSED/eval종료 후 공개, EVAL 중 관리자만 | 심사위원 동조(anchoring) 편향 방지(정석) | 시점 분기 | **채택(§API)** |
|
||||||
|
| | (B) EVAL 중 실시간 공개 | 투명 | 점수 동조 편향(심사 무결성 저해) | 기각 |
|
||||||
|
| criterion 검증 | (A) 앱계층 화이트리스트(jam_criteria) | DB FK 부재(W2-3 논리참조) 보완, 미등록 키 422 | 조회 1회 | **채택(S8)** |
|
||||||
|
| | (B) 검증 없이 INSERT | 단순 | 오타·미등록 criterion 점수 오염(무결성/집계 왜곡) | 기각 |
|
||||||
|
| 입력 이력 | (A) updated_at 최종상태만(1차) | 단순, 동결 스키마 그대로 | 변경 이력 부재 | **채택(비목표 분리)** |
|
||||||
|
| | (B) jam_score_audit 테이블 | 감사 추적 | 신규 테이블(동결 외)·over-engineering(1차 불요) | 기각(후속) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 롤아웃 / 마이그레이션
|
||||||
|
|
||||||
|
### 순서
|
||||||
|
1. **스키마 선행(W2-3)**: `docs/jam-eval-ddl.sql`(jam_criteria/jam_scores/jam_score_stats VIEW) 적용 완료가 본 설계 전제. 본 W2-4 는 DDL 0 — 스키마 적용 단계 없음.
|
||||||
|
2. **선행 의존 코드**: W2-1(JamsMapper.getById/JamEntriesMapper.exists) + W2-2(JamRoleGate.isJudge + 자기출품 충돌) 가 본 설계의 게이트 호출 대상. **W2-2 미배포 시 점수 입력 게이트가 컴파일/동작 불가** → W2-2 와 동시 또는 후행 배포.
|
||||||
|
3. **코드 배포(본 설계)**: K-DOMAIN → K-MAPPER → K-CTRL. JamScoringController 가 JamRoleGate·PermissionGate·JamsMapper·JamEntriesMapper·3 신규 매퍼 주입.
|
||||||
|
4. **운영**: 관리자가 잼 criteria 등록(W2-1 콘솔 확장 또는 등록 소유자) + 심사위원 지정(W2-2) → EVAL 전이 후(W2-1 상태전이) 심사위원 채점 가능.
|
||||||
|
|
||||||
|
### 역호환
|
||||||
|
- 신규 컨트롤러·매퍼·JSP·POJO 만 추가 → 기존 게임/리뷰/잼 동작 0 영향. jam_scores/jam_score_stats 는 W2-3 동결이라 본 설계가 스키마 무변경.
|
||||||
|
- jam_criteria 등록 전(criteria 0행)이면 화이트리스트가 비어 모든 criterion_key 가 422 — 운영상 criteria 선등록 필요(순서 4).
|
||||||
|
|
||||||
|
### 롤백
|
||||||
|
- 코드 롤백: JamScoringController/3매퍼/JamEvalWindow 되돌리면 점수 경로 미노출. 동결 스키마는 추가 전용이라 잔존 무해(데이터 보존, 비파괴). jam_scores 행은 남아도 다른 도메인에 영향 0(읽는 곳이 본 설계뿐).
|
||||||
|
- 스키마 롤백 없음(DDL 0).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AC 매핑
|
||||||
|
|
||||||
|
| AC | 요구(골자 W2-4 + 확정 결정) | 만족 설계 요소 | 비고 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AC-1 | 점수 입력 API POST/PUT `/jams/{jamId}/games/{gameId}/scores` | JamScoringController submitScores(POST+PUT 동일핸들러) | S1, §API |
|
||||||
|
| AC-2 | 심사위원 자격 게이트(isJudge) | jamRoleGate.isJudge(session, jamId) → 아니면 403 | S2-1, §게이트연동 |
|
||||||
|
| AC-3 | 평가기간 게이트(EVAL + now∈구간) | JamEvalWindow.isOpen(jam, now) → 아니면 422 | S2-2, W2-3 F6 |
|
||||||
|
| AC-4 | 자기출품 충돌(자기 출품작 채점 불가) | W2-2 isOwnEntry 호출 → 참이면 422 | S2-3, W2-2 |
|
||||||
|
| AC-5 | 상태변경 CSRF 전수 | submitScores 진입부 CsrfTokens.isValid → 403 | §API 공통 |
|
||||||
|
| AC-6 | criterion별 점수 1~5 입력 | jam_scores score 1~5(동결 CHECK) + 앱 범위 검증 | S1, S8 |
|
||||||
|
| AC-7 | jam_scores UPSERT(멱등 재입력) | upsertScore ON CONFLICT (4키) DO UPDATE | S3, 동결 UNIQUE |
|
||||||
|
| AC-8 | 집계 = jam_score_stats VIEW(가중 종합) | JamScoreStatsMapper.listStatsByJam(큰따옴표 alias) | S4, W2-3 G6 |
|
||||||
|
| AC-9 | 심사위원 평균 + 동률 처리 명시 | VIEW weighted_total/simple_total + 결정론 정렬(judge_count,game_id) | S7/난제3 |
|
||||||
|
| AC-10 | 평가기간 내 수정 허용, 종료 후 잠금 | 입력·수정 동일 평가기간 게이트(EVAL 이탈→422) | S5, S1 |
|
||||||
|
| AC-11 | 점수 미입력 criterion 처리 | 미입력=행 부재→VIEW per_criterion 에서 제외(가중 분모도) | S6/난제2 |
|
||||||
|
| AC-12 | 심사위원 부분입력 처리 | batch 배열에 입력분만 포함, judge_count 로 참여 가시화 | S6/난제2 |
|
||||||
|
| AC-13 | criterion_key 화이트리스트(미등록 거부) | jamCriteriaMapper.listByJam 화이트리스트 → 미등록 422 | S8 |
|
||||||
|
| AC-14 | 권한/평가 SQL `${}` 0 | 신규 3매퍼 `#{}` only | §파일영향맵 |
|
||||||
|
| AC-15 | 신규 테이블 0(동결 소비) | DDL 0, schema.sql 무변경 | §데이터모델 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 포인트 (verification-advisor 점검 대상)
|
||||||
|
|
||||||
|
> L레벨 매핑(verification-strategies): 자격/기간/충돌 게이트·점수 입력 플로우 = **L1+L2+L3**. 신규 매퍼 SQL/alias·UPSERT ON CONFLICT·집계 VIEW 소비 = **L1+L2(dev DB contract)**. 신규 컨트롤러·매퍼 의존 = full `./mvnw -o test` 의무(§30).
|
||||||
|
|
||||||
|
### 시나리오 검증
|
||||||
|
- **VP-1 (AC-2/3/4 3중 게이트, L1+L3)**: JamScoringControllerTest — isJudge 통과/미심사위원 403, EVAL+구간내 통과/기간밖·status≠EVAL 422, 자기출품 충돌 422, 미인증 401. 게이트 순서(CSRF→인증→자격→기간→출품작→충돌→criterion)대로 첫 실패 지점 응답 검증. L3 스모크: 심사위원이 EVAL 잼 출품작 채점 성공.
|
||||||
|
- **VP-2 (AC-7 UPSERT 멱등, L1+L2)**: 같은 (jam,game,judge,criterion) 재입력 시 INSERT 아닌 UPDATE(행 수 불변, score·updated_at 갱신). dev DB contract: ON CONFLICT (4키) 실측 — ux_jam_scores_jam_game_judge_criterion(W2-3 동결) 타깃 정합.
|
||||||
|
- **VP-3 (AC-8/9 집계 정합, L2)**: jam_score_stats 소비 — 다수 심사위원·다수 criterion 입력 시 fan-out 없이 weighted_total 정확, 정렬(weighted_total DESC NULLS LAST, judge_count DESC, game_id ASC) 결정론. (W2-3 가 VIEW 자체 fan-out/0-division 을 검증 — 본 설계는 소비 정렬·alias 정합 검증.)
|
||||||
|
- **VP-4 (AC-11/12 부분입력·미채점, L1+L2)**: criterion 일부만 입력 시 미입력 criterion 이 종합에서 제외(weight 분모 포함), judge_count 가 부분참여 반영. 트랜잭션 원자성: 미등록 criterion 포함 배열은 전체 422(부분저장 0).
|
||||||
|
- **VP-5 (AC-5 CSRF, L1)**: 점수 입력 CSRF 누락 → 403 + mapper 미호출(deleteCommentRejectsMissingCsrfBeforeMapperAccess 패턴 준용, grounding 선례).
|
||||||
|
- **VP-6 (AC-13 화이트리스트, L1)**: jam_criteria 미등록 criterion_key 입력 → 422, jamScoresMapper.upsertScore 미호출.
|
||||||
|
- **VP-7 (DB-방언 계약, L2)**: JamScoreStatsMapper 반환 키가 weightedTotal/simpleTotal/scoredCriteria/judgeCount 로 정합(집계 VIEW camelCase 큰따옴표 alias 확인 — GameReviewStatsMapper.java:13 케이스폴딩 BUG-2 선례 회피). JamCriteriaMapper/JamScoresMapper 는 snake→camel 직접 alias(일반 매퍼 표준).
|
||||||
|
- **VP-8 (contextLoads, L1)**: BibimbapApplicationTests 에 신규 3매퍼 @MockBean 등록 후 PASS(§30). JamScoringController 가 주입하는 JamRoleGate(W2-2)·PermissionGate·JamsMapper·JamEntriesMapper 빈 가용성 확인. 누락 시 NoSuchBeanDefinitionException.
|
||||||
|
|
||||||
|
### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용)
|
||||||
|
> self-audit(시점): 아래 카운트는 **본 W2-4 가 신규 생성하는 정적 산출물**(매퍼·게이트·API 핸들러)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리처럼 증가하는 대상 아님. 동결 스키마(jam_scores/jam_score_stats/jam_criteria)는 W2-3 권위 — 본 설계가 수정 0이므로 그 카운트는 W2-3 검증 소관(여기서 재카운트 안 함).
|
||||||
|
> self-audit(표현): 게이트 호출·핸들러 열거는 단일 리터럴 grep 취약성을 피해 **메서드 열거 + 진입부 헬퍼 호출** 구조적 불변식에 앵커(수동 판정). 매퍼 `${` 0건·신규 DDL 0건은 부재 검증이라 리터럴 정당.
|
||||||
|
|
||||||
|
- **AC-T1 점수 입력 핸들러 3중 게이트 전수** — 점수를 **저장하는** 핸들러(submitScores: POST+PUT 동일 메서드 1개)가 진입부에서 게이트 3종(isJudge / JamEvalWindow.isOpen / 자기출품충돌) + CSRF 전수 통과: 수동 판정으로 submitScores 진입 시퀀스에 4가드(CSRF+3게이트) 전수 존재 확인. 1건이라도 누락 = 인가/기간/충돌 우회 보안결함 → FAIL. **이 전수 AC 가 S2 enforcement 의 핵심 가드**(리터럴 grep 단독 의존 회피 — 메서드 본문 게이트 호출 열거).
|
||||||
|
- **AC-T2 신규 매퍼 전수 3개 `${` 0건** — 신규 매퍼 3파일(JamCriteriaMapper/JamScoresMapper/JamScoreStatsMapper)에 `${` 매치 0: `grep -rc '\${' <매퍼 3파일>` == 0 (AC-14, `${}` 동적치환 금지). 부재 검증이라 리터럴 정당.
|
||||||
|
- **AC-T3 집계 VIEW 매퍼 alias 큰따옴표 전수** — JamScoreStatsMapper(집계 VIEW 소비)의 camelCase alias 전수가 큰따옴표: jam_score_stats 종합 4컬럼(weightedTotal/simpleTotal/scoredCriteria/judgeCount) 의 alias 가 `AS "..."` 형태(케이스 폴딩 회피, §33). 검증: JamScoreStatsMapper 의 `AS "` 출현이 camelCase alias 수와 일치(수동 판정 — 일반 매퍼 JamCriteriaMapper/JamScoresMapper 는 snake→camel 직접 alias 라 큰따옴표 불요·있으면 안 됨).
|
||||||
|
- **AC-T4 신규 테이블/VIEW 0건 불변식** — 본 W2-4 가 스키마를 추가/변경하지 않음: 본 워크스트림 파일 영향에 `docs/*-ddl.sql` 신규 0 AND `db/schema.sql` diff 0 AND 신규 매퍼에 `CREATE TABLE`/`ALTER TABLE`/`CREATE VIEW` 토큰 0. 검증: `grep -rE 'CREATE TABLE|ALTER TABLE|CREATE OR REPLACE VIEW' <K-MAPPER 3파일>` == 0(매퍼는 SELECT/INSERT ON CONFLICT 만). 동결 소비 계약(W2-3 권위) 위반(W2-4 가 스키마 손대기) 즉시 검출.
|
||||||
|
- **AC-T5 401/403/422 정책 응답 전수** — 점수 입력 게이트 실패 분기 전수가 정책대로: 미인증→401, 미심사위원→403, CSRF→403, 평가기간외/자기출품/미등록criterion/score범위→422, 출품작없음→404. 검증: JamScoringControllerTest 가 5분류(401/403/404/422 각) 케이스 전수 보유(테스트 메서드 열거) AND 각 응답 status 코드 정합. 정책 분류 누락(예: 자기출품을 403 으로 잘못 응답) 검출.
|
||||||
|
- **AC-T6 신규 매퍼 @MockBean 전수 3건** — BibimbapApplicationTests 에 신규 3매퍼 @MockBean 전수 등록: contextLoads PASS AND 3매퍼(JamCriteriaMapper/JamScoresMapper/JamScoreStatsMapper) 등록 수동 확인. 1건 누락 시 contextLoads FAIL 로 즉시 검출(§30, verification 시점 자기 검증).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 잔여 오픈 질문
|
||||||
|
없음(0). 확정 결정 S1~S8 전제 고정. 세 난제(3중 게이트 순서·401/403/422 정합 / 부분입력·미채점 집계 의미 / 동률·심사위원 평균)는 본 설계가 구체 메커니즘으로 확정. 집계 노출 시점(EVAL 중 관리자만/CLOSED 후 공개)·POST+PUT 동일핸들러·UPSERT ON CONFLICT·criterion 화이트리스트도 확정. 구현 점검 항목(W2-2 게이트 시그니처/자기출품 충돌 위치 재확인·신규 매퍼 @MockBean full-test·VIEW alias 큰따옴표·ON CONFLICT dev contract·criterion_key 화이트리스트·헬퍼 시그니처 inflate)은 오픈 질문이 아니라 `concerns` 로 이관.
|
||||||
|
|
@ -0,0 +1,328 @@
|
||||||
|
---
|
||||||
|
phase: design
|
||||||
|
agent: design-advisor
|
||||||
|
agent_version: 1
|
||||||
|
generated_at: 2026-06-23T16:30:00+09:00
|
||||||
|
workstream: W2-5-인기투표
|
||||||
|
concerns:
|
||||||
|
- "JamVotesMapper 시그니처는 최소 인자로 명세했다. 본 설계는 변경=DELETE→INSERT 가 아니라 '기존 표 갱신' 정석으로 updateVote(jamId, voterUserId, gameId) 단일경로를 채택했으나, 구현이 ON CONFLICT (jam_id, voter_user_id) DO UPDATE 한 문장으로 castVote 를 멱등 upsert 화하면 hasVoted 사전조회·updateVote 분리가 불요해질 수 있다(dead method/parameter 방지, 프로토콜 §11.2). 구현 1보에서 인자/메서드 전부 실사용 재확인."
|
||||||
|
- "신규 JamVotesMapper + (신규일 경우) JamVoteController 의존 추가 — verification-strategies §30 에 따라 implementation 단계에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 신규 @Mapper(JamVotesMapper) @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException."
|
||||||
|
- "투표 집계 조회 SQL(countByGame/listCountsByJam)은 단순 COUNT 라 일반 매퍼 snake→camel 직접 alias(COUNT(*) AS voteCount) 표준 — 집계 VIEW 아님(W2-3 은 jam_votes 에 VIEW 를 두지 않고 COUNT 계약, 본 설계 §집계 준수). 큰따옴표 alias 불요. dev DB contract(L2) 로 GROUP BY count 정합 실측 권장."
|
||||||
|
- "결과 노출 게이트(평가기간 중 표심 은닉 vs 종료 후 count 공개)는 컨트롤러/JSP 분기다 — DB 가 강제하지 않는다. 노출 시점 판정은 jam.status/eval_end_at 런타임 비교(W2-1 JamLifecycle 또는 인라인). 구현이 이 분기를 누락하면 밴드왜건 회피 요구(결과 은닉) 위반 → AC-T3 전수 가드로 검출. 노출 분기 위치는 구현 점검 항목."
|
||||||
|
- "자기 출품작 투표 가부는 W2-3 동결에서 'W2-5 정책' 으로 위임됐다. 본 설계는 정석(자기표 허용 — 잼 인기투표는 자기 작품 응원 통상 허용, 1인1표 UNIQUE 가 다중표를 막으므로 자기표가 결과를 왜곡하지 않음)으로 확정한다. 운영 정책상 금지가 필요하면 컨트롤러 422 분기 추가 — 구현 점검 항목(스키마 영향 없음)."
|
||||||
|
concerns_checked: true
|
||||||
|
self_verification:
|
||||||
|
checklist_passed: true
|
||||||
|
references:
|
||||||
|
requirements: docs/work-log/2026-06-23-w2-w4-feature-skeletons.md
|
||||||
|
research: .atp/work-session/20260623-104307/research/W2-W4-grounding.md
|
||||||
|
adrs:
|
||||||
|
- .atp/work-session/20260623-104307/implementation/W2-3-eval-freeze-design.md
|
||||||
|
- .atp/work-session/20260623-104307/implementation/W2-1-jam-entity-design.md
|
||||||
|
- .atp/work-session/20260622-180054/implementation/W1-design.md
|
||||||
|
- docs/work-log/2026-06-17-jam-platform-roadmap.md
|
||||||
|
- docs/development/verification-strategies.md
|
||||||
|
- docs/jam-eval-ddl.sql
|
||||||
|
---
|
||||||
|
|
||||||
|
# 설계: W2-5 — 인기투표 (잼당 1인 1표 / 평가기간 게이트 / 표심 은닉→종료 후 공개)
|
||||||
|
|
||||||
|
> ⚠️ **상류 동결 소비 워크스트림**. 본 설계는 W2-3(`.atp/work-session/20260623-104307/implementation/W2-3-eval-freeze-design.md`)이 동결한 `jam_votes` 스키마(테이블·1인1표 UNIQUE·FK)를 **재정의하지 않고 그대로 소비**한다. 신규 DDL 테이블 0건. 본 설계 범위 = 투표 API + 1인1표 토글 로직 + 평가기간 게이트 + 결과 노출 시점 + 신규 매퍼(`JamVotesMapper`) 1개.
|
||||||
|
|
||||||
|
## 목표 / 비목표
|
||||||
|
|
||||||
|
### 목표 (FR/NFR 추적 — 골자 W2-5)
|
||||||
|
- **G1 투표 API**: 출품작 투표(POST) / 취소(DELETE). `POST /jams/{slug}/vote`(body: gameId) + `DELETE /jams/{slug}/vote`. RecruitController 패턴(읽기 JSP 뷰, 쓰기 ResponseEntity JSON status/message).
|
||||||
|
- **G2 잼당 1인 1표(최애 1작품)**: `jam_votes` UNIQUE(jam_id, voter_user_id)(W2-3 동결 `ux_jam_votes_jam_voter`)를 권위로 1인 1표 보장. 표 변경 = 기존 표 갱신(같은 voter 행의 game_id UPDATE), 취소 = DELETE.
|
||||||
|
- **G3 game_likes 와 별개**: `jam_votes.voter_user_id`(bigint, 로그인 user_id) 기반. game_likes(user_key varchar·1인1표 비권위·grounding R-D) 무참조.
|
||||||
|
- **G4 평가기간 게이트**: 투표/취소는 `jam.status='EVAL' AND now() ∈ [eval_start_at, eval_end_at]` 일 때만 허용(W2-3 §평가기간 게이트 계약 F6). 미충족 시 422.
|
||||||
|
- **G5 미로그인 차단**: 1인1표 식별자 = 세션 `userId`. 미로그인 401(밴드왜건/중복표 방지의 식별 기반).
|
||||||
|
- **G6 결과 노출 시점**: **평가기간 중 표심 은닉(밴드왜건 회피)** → 평가 종료 후(`status='CLOSED'` 또는 `now() > eval_end_at`) 공개(출품작별 count). 본인 투표 여부(votedGameId)는 평가기간 중에도 본인에게는 노출(중복 투표 UX).
|
||||||
|
- **G7 W2-6 POPULAR 트랙 소스 제공**: 시상 인기 트랙은 `jam_votes` count(W2-3 §집계 노출 계약 — VIEW 없이 COUNT). 본 설계 매퍼가 잼·출품작별 집계 조회를 제공.
|
||||||
|
- **NFR**: 상태변경 CSRF 전수(`CsrfTokens.isValid` → 403 + `errorBody()`), `#{}` 바인딩(`${}` 금지), 1인1표 UNIQUE DB 강제, 비파괴(신규 DDL 0 — 동결 소비).
|
||||||
|
|
||||||
|
### 비목표 (스코프 밖)
|
||||||
|
- **jam_votes 스키마 정의** — **W2-3 동결 소유**. 본 설계는 컬럼/UNIQUE/FK 를 재정의하지 않고 소비만(신규 DDL 파일 0).
|
||||||
|
- **시상 산정 알고리즘·POPULAR 트랙 rank 부여** — **W2-6 소유**. 본 설계는 count 집계 조회 매퍼만 제공(트랙 산정은 W2-6).
|
||||||
|
- **심사 점수 입력(jam_scores)·유저평점 트랙** — W2-4/W2-6 소유. 본 설계 무관.
|
||||||
|
- **잼 엔티티·상태·출품(jam_entries) 본체** — **W2-1 소유**. 본 설계는 `JamsMapper.getBySlug`·`JamEntriesMapper.exists`(활성 출품작 검증)를 호출 소비만.
|
||||||
|
- **투표 결과 시각화 차트·실시간 갱신** — 1차는 종료 후 count 단순 노출. 실시간 폴링·차트는 후속.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
bibimbap 은 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis annotation `@Mapper`(`#{}` only) + JSP 스택이다. W2-3 이 `jam_votes`(id/jam_id/game_id/voter_user_id/created_at + FK jams·games·users + `ux_jam_votes_jam_voter` UNIQUE(jam_id, voter_user_id) + `idx_jam_votes_jam_game`)를 동결했고(W2-3 §데이터모델3, 직접 확인), W2-1 이 `jams`(status CHECK RECRUIT/DEV/EVAL/CLOSED + eval_start_at/eval_end_at) + `jam_entries`(잼당 game 활성 UNIQUE = 출품작) + `JamsMapper.getBySlug`/`JamEntriesMapper.exists`(W2-1 §파일영향맵·시그니처)를 제공한다. W1 이 `PermissionGate.isAuthenticated(session)`(PermissionGate.java:47, 직접 확인) + `CsrfTokens.isValid(request)`(CsrfTokens.java:35, 직접 확인) + 세션 `userId` attr(RecruitController.java:159 `session.getAttribute("userId")`, 직접 확인)을 제공한다.
|
||||||
|
|
||||||
|
본 설계는 **신규 테이블 0**으로 `jam_votes` 를 소비하는 **투표 토글 API + JamVotesMapper 1개**를 추가한다. 가장 까다로운 두 결정을 다음과 같이 확정한다.
|
||||||
|
|
||||||
|
- **난제1 (1인1표 토글 — 변경 vs 삭제후삽입)**: 잼당 1인 1표(최애 1작품)이므로 "다른 작품으로 표를 바꾸는" 행위는 **기존 voter 행의 game_id 를 UPDATE**(`updateVote`)로 처리한다(DELETE→INSERT 2문장 대신 1문장 — id/created_at 보존, 경합 윈도 최소, UNIQUE 충돌 없음). 사전 `hasVoted` 조회로 미투표→`castVote`(INSERT) / 기투표→`updateVote`(같은 game 재투표는 no-op 멱등) 분기. 취소(`DELETE /vote`)는 `deleteVote`(voter 행 삭제). UNIQUE(jam_id, voter_user_id)가 다중표를 DB 차원에서 차단하므로 동시요청 경합에서도 1인1표가 깨지지 않는다(INSERT 경합 시 한쪽 UNIQUE 위반 → 컨트롤러가 updateVote 재시도 또는 409 친절 처리, §시퀀스 S1 주석).
|
||||||
|
- **난제2 (결과 노출 시점 — 밴드왜건 회피)**: **평가기간 중에는 집계 count 를 일반 사용자에게 노출하지 않는다**(밴드왜건/표 쏠림 회피 — 골자 W2-5 Q3·확정 결정). 노출 게이트 = `jam.status='CLOSED' OR now() > jam.eval_end_at`. 이 판정은 **컨트롤러/JSP 런타임 분기**(DB 가 강제하지 않음 — 시각 비교는 런타임). 단 (a) 본인의 투표 여부/대상(`votedGameId`)은 평가기간 중에도 본인에게 노출(중복투표·취소 UX 필수), (b) 잼 관리자(GAME_JAM_MANAGE)는 운영 목적상 평가기간 중에도 집계 열람 가능(선택 — 본 설계는 공개 화면 은닉만 강제, 관리자 열람은 W2-6/관리 화면 소관으로 비강제). 결과 = 평가 종료 전 공개 화면은 "총 투표 수/내 투표만", 종료 후 "출품작별 득표 count" 공개.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 핵심 결정 요약 (전제 — 재논의 금지. orchestrator 확정 + W2-3 동결 소비)
|
||||||
|
|
||||||
|
| 결정 | 확정값 | 본 설계의 구체화 |
|
||||||
|
|---|---|---|
|
||||||
|
| P1 투표 API | POST/DELETE `/jams/{slug}/vote` (body gameId) | 로그인 필수 + 평가기간 게이트 + CSRF. 읽기=잼 상세 JSP(W2-1), 쓰기=JSON status/message |
|
||||||
|
| P2 1인1표 | `jam_votes` UNIQUE(jam_id, voter_user_id) | W2-3 동결 `ux_jam_votes_jam_voter` 권위. 변경=updateVote(game_id), 취소=deleteVote |
|
||||||
|
| P3 game_likes 별개 | voter_user_id bigint(로그인) | 신규 jam_votes 소비. game_likes(user_key varchar) 무참조 |
|
||||||
|
| P4 평가기간 게이트 | EVAL + now∈[eval_start,eval_end] | 미충족 422(W2-3 F6). 게이트 위치=컨트롤러 진입부 앱계층 |
|
||||||
|
| P5 미로그인 | 세션 userId 식별 | 미로그인 401(redirect 아님 — API). PermissionGate.isAuthenticated |
|
||||||
|
| P6 결과 노출 | 평가기간 중 은닉 → 종료 후 count 공개 | status='CLOSED' OR now()>eval_end_at 시 출품작별 count. 본인 votedGameId 는 상시 본인 노출 |
|
||||||
|
| P7 표 변경 정책 | 변경 허용(최애 교체) | hasVoted→updateVote(game_id). 같은 game 재투표=멱등 no-op |
|
||||||
|
| P8 자기표 | 허용 | 1인1표 UNIQUE 가 다중표 차단하므로 자기 작품 응원 허용(concern 5) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 모델 (신규 DDL 0 — W2-3 동결 jam_votes 소비)
|
||||||
|
|
||||||
|
> **신규 테이블/컬럼/VIEW 0건.** 본 설계는 W2-3 `docs/jam-eval-ddl.sql` 의 `jam_votes` 를 **소비만** 한다. 아래는 소비하는 동결 스키마의 **참조 사본**(W2-3 §데이터모델3 권위 — 본 설계가 정의/변경하지 않음. 재게시는 매퍼 SQL 작성 grounding 용).
|
||||||
|
|
||||||
|
### 소비 대상: `jam_votes` (W2-3 동결 — 변경 금지)
|
||||||
|
```sql
|
||||||
|
-- W2-3 docs/jam-eval-ddl.sql §3 (권위). 본 W2-5 는 읽기/쓰기 소비만, DDL 미수정.
|
||||||
|
CREATE TABLE IF NOT EXISTS "jam_votes" (
|
||||||
|
"id" bigint DEFAULT nextval('jam_votes_id_seq'::regclass) NOT NULL,
|
||||||
|
"jam_id" bigint NOT NULL, -- 평가단위 1/2 (FK jams)
|
||||||
|
"game_id" bigint NOT NULL, -- 투표 대상 출품작 (FK games)
|
||||||
|
"voter_user_id" bigint NOT NULL, -- 투표자 (FK users; 로그인 1인1표)
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
-- 잼당 1인 1표(최애 1개). 표 변경=UPDATE game_id, 취소=DELETE (W2-5 정책 = 본 설계 P7)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_votes_jam_voter"
|
||||||
|
ON "jam_votes" ("jam_id", "voter_user_id");
|
||||||
|
-- 투표 집계(출품작별 count) — W2-6 POPULAR 트랙 소스
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_jam_votes_jam_game"
|
||||||
|
ON "jam_votes" ("jam_id", "game_id");
|
||||||
|
```
|
||||||
|
|
||||||
|
### 동결 스키마와 본 설계 로직의 정합 확인
|
||||||
|
- **1인1표(P2/G2)**: `ux_jam_votes_jam_voter` 가 (jam_id, voter_user_id) 다중행을 DB 차원에서 차단 → 한 유저가 한 잼에 2표 INSERT 불가. 표 변경은 INSERT 추가가 아니라 **기존 행 UPDATE** 라 UNIQUE 충돌 없음.
|
||||||
|
- **집계(P6/G7)**: `idx_jam_votes_jam_game` 가 `SELECT game_id, COUNT(*) ... GROUP BY game_id` seek 를 지원(W2-3 §집계 노출 계약 — VIEW 없이 COUNT 정석).
|
||||||
|
- **평가기간 게이트(P4)**: DB CHECK 로 기간 강제 안 함(W2-3 F6 — 시각 비교는 런타임). `created_at` 은 감사/노출 시점 판정 보조이나, 게이트 자체는 `jams.status`+`eval_*_at` 런타임 비교(앱계층).
|
||||||
|
- **변경 금지 확인**: 본 설계는 `jam_votes` 에 컬럼/제약/인덱스를 추가하지 않는다. `game_likes`/`game_reviews`/`jams`/`jam_entries` 도 무변경(소비만). → 신규 DDL 파일 0, schema.sql 수정 0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 외부 계약 (API)
|
||||||
|
|
||||||
|
> 공통: 모든 상태변경(POST/DELETE)은 `CsrfTokens.isValid(request)` 검증(없으면 403 + `CsrfTokens.errorBody()`, CsrfTokens.java:35,51 직접 확인). 응답은 RecruitController 패턴 — `ResponseEntity<Map<String,Object>>`(status/message). 잼 상세 화면(투표 UI 부착)은 W2-1 `GET /jams/{slug}`(jam-detail JSP) 재사용 — 본 설계는 그 JSP 에 투표 폼/결과 영역 추가만(신규 페이지 컨트롤러 없음).
|
||||||
|
|
||||||
|
### 401 vs 403 vs 422 정책 (W1-design / W2-1 / W2-3 일치)
|
||||||
|
- **미인증**(세션 `userId` 없음): API **401** JSON `{status:401, message:"로그인이 필요합니다."}`. (투표는 API 이므로 redirect 아님.)
|
||||||
|
- **인증·미인가**: 본 투표 API 는 별도 권한 키(GAME_JAM_MANAGE 등) 불요 — **로그인 유저 누구나 투표 가능**(개방). 따라서 403(미인가)은 CSRF 실패 전용.
|
||||||
|
- **평가기간 외**(P4 게이트 위반): **422** JSON `{status:422, message:"투표 기간이 아닙니다."}`(인가는 됐으나 도메인 상태 위반 → 403 아님 422 — W2-3 F6 정책 일치).
|
||||||
|
- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`.
|
||||||
|
- **출품작 없음/잼 없음**: 404.
|
||||||
|
|
||||||
|
### 투표 액션 (상태변경 API — 로그인 필수 + CSRF + 평가기간 게이트)
|
||||||
|
| 액션 | method | path | 요청 | 응답(200) | 에러 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 투표/변경(G1/P1) | POST | `/jams/{slug}/vote` | gameId | `{status:200, message, votedGameId, changed:bool}` | 401(미인증), 403(CSRF), 404(잼/출품작 없음), 422(평가기간 외) |
|
||||||
|
| 취소(G1/P1) | DELETE | `/jams/{slug}/vote` | (없음 — voter 세션 식별) | `{status:200, message, votedGameId:null}` | 401, 403(CSRF), 404(잼 없음/미투표), 422(평가기간 외) |
|
||||||
|
| 내 투표 조회(P6 본인 노출) | GET | `/jams/{slug}/vote/mine` | (없음) | `{status:200, votedGameId:Long|null}` | 401, 404(잼 없음) |
|
||||||
|
| 집계 조회(P6/G7 — 종료 후만 공개) | GET | `/jams/{slug}/vote/results` | (없음) | 종료 후: `{status:200, results:[{gameId, voteCount}], total}` / 진행 중: `{status:200, open:true, total, results:null}` | 404(잼 없음) |
|
||||||
|
|
||||||
|
- **POST 토글 의미(P7)**: 같은 voter 가 (a) 미투표→투표(`changed:false`, 신규 INSERT), (b) 다른 game 으로 변경(`changed:true`, game_id UPDATE), (c) 같은 game 재요청(멱등 no-op, `changed:false`). 별도 grant/revoke 가 아닌 "현재 표 설정" 의미.
|
||||||
|
- **DELETE = 취소(P7)**: voter 의 잼 내 표 1행 삭제. 미투표 상태에서 DELETE 는 404(취소할 표 없음) 또는 멱등 200(정책 — 본 설계는 **404 미투표 명시**, 클라가 상태 동기화).
|
||||||
|
- **집계 노출 게이트(P6/G6 — 밴드왜건 회피 핵심)**: `GET /vote/results` 는 `jam.status='CLOSED' OR now()>jam.eval_end_at` 일 때만 `results` 배열(출품작별 count) 반환. 진행 중에는 `{open:true, results:null, total}`(총합만 — 표 쏠림 정보 미노출). JSP 도 동일 분기로 결과 영역 렌더.
|
||||||
|
- **자기 출품작 투표(P8)**: 허용. games.user_id == voter 여도 422 안 냄(concern 5 — 운영 금지 정책 시 컨트롤러 분기 추가, 스키마 무관).
|
||||||
|
|
||||||
|
### W2-6 소비 계약 (POPULAR 트랙 소스 — G7)
|
||||||
|
- W2-6 시상 산정은 본 설계 매퍼의 `listCountsByJam(jamId)`(또는 `countByGame`)를 호출해 출품작별 득표를 얻고 DESC rank 부여 → `jam_awards.award_track='POPULAR'`(W2-3 §시상 트랙 산정). 본 설계는 **count 조회만 제공**, rank/award insert 는 W2-6 소관.
|
||||||
|
- 집계 SQL(W2-6 참고): `SELECT game_id AS gameId, COUNT(*) AS voteCount FROM jam_votes WHERE jam_id = #{jamId} GROUP BY game_id ORDER BY COUNT(*) DESC, game_id ASC` — 일반 매퍼 snake→camel 직접 alias(집계 VIEW 아님 → 큰따옴표 불요, concern 3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 인터셉터 / 게이트 연동
|
||||||
|
|
||||||
|
> 본 투표 API 는 **관리자 게이트(GAME_JAM_MANAGE) 불요** — 로그인 유저 개방. 따라서 `/admin/**` RbacInterceptor 와 무관(투표 경로 `/jams/**` 는 인터셉터 미등록, InterceptorConfig.java:18 `/admin/**` 만 — W2-1 §인터셉터 직접 확인). 게이트는 **컨트롤러 진입부 앱계층**만.
|
||||||
|
|
||||||
|
### 컨트롤러 진입부 게이트 순서 (모든 상태변경 액션 공통)
|
||||||
|
1. `CsrfTokens.isValid(request)` 거짓 → 403 + `CsrfTokens.errorBody()` (mapper 접근 전 — verification §CSRF-before-mapper 패턴).
|
||||||
|
2. `userId = sessionUserId(session)` (RecruitController.java:155 선례 — `session.getAttribute("userId")`) null → 401. (PermissionGate.isAuthenticated(session) 동등 — 본 설계는 RecruitController 의 `sessionUserId` 헬퍼 패턴 재사용 권장: 투표 컨트롤러가 PermissionGate 의존 없이 세션 userId 만으로 충족 → 의존 최소화. PermissionGate.isAuthenticated 호출도 동등 허용.)
|
||||||
|
3. `jam = jamsMapper.getBySlug(slug)` (W2-1 제공) null → 404.
|
||||||
|
4. **평가기간 게이트(P4/F6)**: `jam.status == 'EVAL' AND now() ∈ [jam.eval_start_at, jam.eval_end_at]` 거짓 → 422.
|
||||||
|
5. 출품작 존재: `jamEntriesMapper.exists(jam.id, gameId)` (W2-1 제공, POST 만 — DELETE 는 gameId 불요) 거짓 → 404.
|
||||||
|
6. 통과 후 토글 본문.
|
||||||
|
|
||||||
|
### epoch 전파 무관
|
||||||
|
- 투표는 권한 키 판정이 없으므로 W1 epoch(refreshIfStale) 연동 불요. 세션 userId 존재만 확인(로그인 세션 유효성은 기존 로그인 인프라가 보장).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 시퀀스 (주요 플로우 의사코드)
|
||||||
|
|
||||||
|
### S1. 투표 / 변경 (POST — 1인1표 토글)
|
||||||
|
```
|
||||||
|
[로그인 유저] POST /jams/{slug}/vote (CSRF, gameId)
|
||||||
|
→ JamVoteController.vote
|
||||||
|
→ CsrfTokens.isValid(request) (아니면 403 errorBody)
|
||||||
|
→ userId = sessionUserId(session) (없으면 401)
|
||||||
|
→ jam = jamsMapper.getBySlug(slug) (없으면 404)
|
||||||
|
→ 평가기간 게이트(P4): jam.status=='EVAL' AND now∈[eval_start,eval_end]? (아니면 422)
|
||||||
|
→ jamEntriesMapper.exists(jam.id, gameId)? (아니면 404 출품작 아님)
|
||||||
|
→ current = jamVotesMapper.findVotedGameId(jam.id, userId) # 현재 표(없으면 null)
|
||||||
|
- current == null → jamVotesMapper.castVote(jam.id, gameId, userId) # INSERT, changed=false
|
||||||
|
- current == gameId → no-op (멱등) # changed=false
|
||||||
|
- current != gameId → jamVotesMapper.updateVote(jam.id, userId, gameId) # game_id 교체, changed=true
|
||||||
|
→ 200 {votedGameId: gameId, changed}
|
||||||
|
# 동시요청 경합(같은 voter 2 INSERT): UNIQUE(jam_id,voter_user_id) 위반 → 한쪽 DataIntegrityViolation
|
||||||
|
# → 컨트롤러 catch 후 updateVote 재시도 또는 409. 1인1표 불변(DB 강제).
|
||||||
|
# 결과 count 는 응답에 미포함(P6 밴드왜건 회피 — 본인 표만 반환).
|
||||||
|
```
|
||||||
|
|
||||||
|
### S2. 취소 (DELETE)
|
||||||
|
```
|
||||||
|
[로그인 유저] DELETE /jams/{slug}/vote (CSRF)
|
||||||
|
→ JamVoteController.cancel
|
||||||
|
→ CsrfTokens.isValid (아니면 403)
|
||||||
|
→ userId = sessionUserId (없으면 401)
|
||||||
|
→ jam = getBySlug(slug) (없으면 404)
|
||||||
|
→ 평가기간 게이트(P4) (아니면 422)
|
||||||
|
→ affected = jamVotesMapper.deleteVote(jam.id, userId) # voter 행 삭제
|
||||||
|
→ affected == 0 → 404 (취소할 표 없음)
|
||||||
|
→ 200 {votedGameId: null}
|
||||||
|
```
|
||||||
|
|
||||||
|
### S3. 결과 노출 (GET /vote/results — 밴드왜건 회피 분기)
|
||||||
|
```
|
||||||
|
[공개] GET /jams/{slug}/vote/results
|
||||||
|
→ JamVoteController.results
|
||||||
|
→ jam = getBySlug(slug) (없으면 404)
|
||||||
|
→ 노출 게이트(P6): jam.status=='CLOSED' OR now() > jam.eval_end_at?
|
||||||
|
- 종료 → results = jamVotesMapper.listCountsByJam(jam.id) # [{gameId, voteCount}] DESC
|
||||||
|
total = sum(voteCount)
|
||||||
|
200 {results, total}
|
||||||
|
- 진행 중 → total = jamVotesMapper.countByJam(jam.id) # 총합만(표심 은닉)
|
||||||
|
200 {open:true, results:null, total}
|
||||||
|
# JSP(jam-detail) 결과 영역도 동일 분기: 종료 후만 출품작별 막대, 진행 중엔 "투표 진행 중 · 총 N표".
|
||||||
|
|
||||||
|
[로그인 유저] GET /jams/{slug}/vote/mine
|
||||||
|
→ jam = getBySlug; userId = sessionUserId (없으면 401)
|
||||||
|
→ votedGameId = jamVotesMapper.findVotedGameId(jam.id, userId)
|
||||||
|
→ 200 {votedGameId} # 본인 표는 진행 중에도 노출(중복투표 UX)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 영향 맵
|
||||||
|
|
||||||
|
> 소유권 분할 가이드(implementation-advisor worker 단위 후보):
|
||||||
|
> **V-MAPPER**(JamVotesMapper) · **V-CONTROLLER**(JamVoteController + 401/403/422/CSRF/게이트 헬퍼) · **V-VIEW**(jam-detail.jsp 투표 폼·결과 영역 추가 — W2-1 산출 JSP 수정).
|
||||||
|
> 의존: W2-3 동결(jam_votes) + W2-1(jams/jam_entries/JamsMapper/JamEntriesMapper/jam-detail.jsp) 선행. V-MAPPER → V-CONTROLLER → V-VIEW.
|
||||||
|
|
||||||
|
| 변경 유형 | 경로 | 역할 | 소유 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamVotesMapper.java` | `@Mapper` jam_votes 투표/취소/조회/집계(`#{}`, snake→camel 직접 alias) | V-MAPPER |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/JamVoteController.java` | `/jams/{slug}/vote` POST/DELETE + /mine + /results. CSRF·평가기간 게이트·1인1표 토글 | V-CONTROLLER |
|
||||||
|
| 수정 | `src/main/webapp/WEB-INF/views/jam-detail.jsp` (W2-1 산출) | 출품작별 투표 버튼(CSRF hidden) + 결과 영역(종료 후 count / 진행 중 은닉) + 내 표 표시 | V-VIEW |
|
||||||
|
| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 JamVotesMapper @MockBean 등록(contextLoads 보존, verification §30) | (검증) |
|
||||||
|
| 신규 | `src/test/.../JamVoteControllerTest.java` | 투표/변경/취소 + 401/403(CSRF)/422(평가기간)/404 + 1인1표 + 결과 은닉/공개 분기 | (검증) |
|
||||||
|
|
||||||
|
> SSR 호출지점 영향(verification §영향맵): 본 설계는 jam_votes(W2-3 동결) 소비 + jam-detail.jsp(W2-1) 수정만. games/game_likes/jams/jam_entries 무변경 → 기존 소비처 0 영향. jam-detail.jsp 는 W2-1 신규 산출이라 기존 화면 회귀 없음(투표 영역 추가만). JamVoteController 신규 컨트롤러 의존은 JamsMapper/JamEntriesMapper(W2-1 제공) + JamVotesMapper(신규) → contextLoads 시 신규 매퍼 @MockBean 필요(concern 2).
|
||||||
|
|
||||||
|
### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
|
||||||
|
```java
|
||||||
|
// JamVotesMapper (@Mapper, #{} only, snake→camel 직접 alias — 집계 VIEW 아님 → 큰따옴표 불요)
|
||||||
|
int castVote(long jamId, // 평가단위 1/2 (UNIQUE 좌변)
|
||||||
|
long gameId, // 투표 대상 출품작
|
||||||
|
long voterUserId) // 투표자(세션 userId; 1인1표 식별)
|
||||||
|
int updateVote(long jamId, // 대상 잼
|
||||||
|
long voterUserId, // 기존 표 보유자(WHERE 절 식별)
|
||||||
|
long gameId) // 교체할 출품작(SET game_id)
|
||||||
|
int deleteVote(long jamId, // 대상 잼
|
||||||
|
long voterUserId) // 취소 대상 voter(취소=voter 행 삭제)
|
||||||
|
Long findVotedGameId(long jamId, // 대상 잼
|
||||||
|
long voterUserId) // 본인 현재 표 조회(null=미투표; 토글 분기·/mine 소스)
|
||||||
|
long countByJam(long jamId) // 진행 중 총합(표심 은닉 시 총 투표 수만)
|
||||||
|
List<Map<String,Object>> listCountsByJam(long jamId) // 종료 후 출품작별 count(W2-6 POPULAR 소스)
|
||||||
|
```
|
||||||
|
> ⚠️ inflate 마킹(concern 1): `castVote`/`updateVote`/`findVotedGameId` 분리는 "사전조회 후 분기" 전략이다. 구현이 `castVote` 를 `INSERT ... ON CONFLICT (jam_id, voter_user_id) DO UPDATE SET game_id = EXCLUDED.game_id` 단일 멱등 upsert 로 구현하면 `findVotedGameId` 사전조회와 `updateVote` 가 토글 경로에서 불요해질 수 있다(단 changed 플래그·/mine 응답에는 findVotedGameId 가 여전히 필요). `countByJam` 은 진행 중 총합 전용 — 종료 후 화면이 listCountsByJam 으로 total 을 합산하면 countByJam 이 dead 가 될 수 있으니 구현에서 실사용 재확인(최소 인자/메서드 원칙). `listCountsByJam` 반환은 W2-6 소비 형태 확정 전 Map 로 두되, 안정화 시 VoteCountData POJO 로 좁힘 검토.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 대안 비교
|
||||||
|
|
||||||
|
| 주제 | 안 | 장점 | 단점 | 채택 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 표 변경 처리 | (A) updateVote(기존 행 game_id UPDATE) | 1문장, id/created_at 보존, UNIQUE 충돌 없음, 경합 윈도 최소 | 사전 hasVoted 분기 | **채택(P7)** |
|
||||||
|
| | (B) DELETE→INSERT | 단순 일관 | 2문장 경합 윈도, created_at 리셋, 트랜잭션 필요 | 기각 |
|
||||||
|
| | (C) ON CONFLICT DO UPDATE upsert | 단일 멱등문 | findVotedGameId 분리(changed/mine) 여전 필요, 구현 시 채택 가능(concern 1) | 구현 재량(미배제) |
|
||||||
|
| 투표 단위 | (A) 잼당 1표(최애 1작품) | 골자 W2-5 확정, 1인1표 UNIQUE 정합, 밴드왜건 완화 | 여러 작품 응원 불가 | **채택(G2)** |
|
||||||
|
| | (B) 출품작당 1표(여러 작품 가능) | 폭넓은 응원 | jam_votes UNIQUE(jam_id,voter) 와 충돌(스키마 동결 위반) | 기각(동결 위반) |
|
||||||
|
| 결과 노출 | (A) 평가기간 중 은닉 → 종료 후 count | 밴드왜건 회피(확정 결정), 표심 쏠림 방지 | 진행 중 결과 궁금증 미충족 | **채택(P6)** |
|
||||||
|
| | (B) 실시간 공개 | 즉시성·재미 | 밴드왜건(인기작 쏠림) — 확정 결정 위반 | 기각 |
|
||||||
|
| 식별자 | (A) 세션 userId(bigint) | 로그인 1인1표 정석, voter_user_id FK 정합 | 미로그인 투표 불가 | **채택(P5)** |
|
||||||
|
| | (B) game_likes user_key varchar 재활용 | 재사용 | 비권위·1인1표 비보장(R-D)·varchar | 기각(별개 동결) |
|
||||||
|
| 게이트 위치 | (A) 컨트롤러 진입부 앱계층 | W2-3 F6 계약 일치, 시각 비교 런타임 | 컨트롤러 분기 | **채택(P4)** |
|
||||||
|
| | (B) DB CHECK 로 기간 강제 | DB 보장 | now() CHECK 불가·동결 위반(W2-3 F6 명시) | 기각 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 롤아웃 / 마이그레이션
|
||||||
|
|
||||||
|
### 순서
|
||||||
|
1. **스키마**: 신규 DDL 0 — W2-3 `docs/jam-eval-ddl.sql`(jam_votes) 이 이미 적용돼 있어야 함(선행). 본 설계 추가 DDL/schema.sql 수정 없음.
|
||||||
|
2. **선행 의존**: W2-3 동결(jam_votes) + W2-1(jams/jam_entries/JamsMapper/JamEntriesMapper/jam-detail.jsp) 코드 배포 선행. 본 설계는 그 위에 매퍼/컨트롤러/JSP 영역만 추가.
|
||||||
|
3. **코드 배포**: V-MAPPER → V-CONTROLLER → V-VIEW. BibimbapApplicationTests @MockBean(JamVotesMapper) 동반(contextLoads).
|
||||||
|
4. **권한 시드 불요**: 투표는 로그인 개방 — 권한 키/시드 없음.
|
||||||
|
|
||||||
|
### 역호환
|
||||||
|
- jam_votes(W2-3)/jams/jam_entries(W2-1)/games/game_likes/game_reviews **전부 무변경**. 기존 동작 0 영향.
|
||||||
|
- jam-detail.jsp 는 W2-1 신규 산출 — 투표 영역 추가만(기존 출품작 표시 회귀 0).
|
||||||
|
|
||||||
|
### 롤백
|
||||||
|
- 코드 롤백: JamVoteController/JamVotesMapper 제거 + jam-detail.jsp 투표 영역 제거 → 투표 기능 미노출. jam_votes 테이블은 추가 전용(W2-3 소유)이라 잔존 무해(데이터 남아도 무참조).
|
||||||
|
- 스키마 롤백: 본 설계 신규 DDL 0 → 롤백 대상 없음(jam_votes drop 은 W2-3 maintenance 소관).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AC 매핑
|
||||||
|
|
||||||
|
| AC | 요구(골자 W2-5 + 확정 결정) | 만족 설계 요소 | 비고 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AC-1 | 투표 API POST/DELETE `/jams/{slug}/vote`(gameId) | JamVoteController vote/cancel, RecruitController 패턴 | P1, §외부계약 |
|
||||||
|
| AC-2 | 잼당 1인 1표(최애 1작품) | jam_votes ux_jam_votes_jam_voter(W2-3) + updateVote 변경 경로 | P2/P7, S1 |
|
||||||
|
| AC-3 | game_likes 와 별개 | jam_votes.voter_user_id bigint 소비, game_likes 무참조 | P3, §대안 |
|
||||||
|
| AC-4 | 평가기간 게이트(EVAL + window) | 컨트롤러 진입부 jam.status=='EVAL' AND now∈[eval_start,eval_end] → 422 | P4, S1/S2 |
|
||||||
|
| AC-5 | 미로그인 차단 | sessionUserId null → 401(API) | P5, §게이트 |
|
||||||
|
| AC-6 | ★평가기간 중 표심 은닉 → 종료 후 공개 | /vote/results 노출 게이트(CLOSED OR now>eval_end) — 진행 중 results:null | P6/G6, S3 |
|
||||||
|
| AC-7 | 표 변경/취소 허용 | POST 변경(updateVote, changed:true) + DELETE 취소(deleteVote) | P7, S1/S2 |
|
||||||
|
| AC-8 | 상태변경 전수 CSRF | POST/DELETE 전부 CsrfTokens.isValid 선검증 → 403 errorBody | §게이트 공통 |
|
||||||
|
| AC-9 | 투표 SQL `${}` 0 | JamVotesMapper `#{}` only | §시그니처 |
|
||||||
|
| AC-10 | W2-6 POPULAR 트랙 소스 제공 | listCountsByJam(jamId) 출품작별 count DESC | G7, §W2-6계약 |
|
||||||
|
| AC-11 | 본인 투표 여부 상시 노출 | /vote/mine findVotedGameId(진행 중에도 본인 노출) | P6, S3 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 포인트 (verification-advisor 점검 대상)
|
||||||
|
|
||||||
|
> L레벨 매핑(verification-strategies): 투표 토글·1인1표·평가기간 게이트·미로그인 플로우 = **L1+L2+L3**. 신규 매퍼 SQL/alias·집계 GROUP BY = **L1+L2(dev DB contract)**. 신규 컨트롤러·매퍼 의존 = full `./mvnw -o test` 의무(§30, @MockBean).
|
||||||
|
|
||||||
|
### 시나리오 검증
|
||||||
|
- **VP-1 (AC-2 1인1표, L1+L2)**: 같은 voter 가 같은 잼에 2회 castVote → 2번째 UNIQUE(jam_id,voter_user_id) 위반(DB 강제, L2 dev contract 실측). 다른 game 으로 POST → updateVote 로 행 1개 유지(changed:true), 표 수 불변.
|
||||||
|
- **VP-2 (AC-4 평가기간 게이트, L1+L3)**: jam.status!='EVAL'(RECRUIT/DEV/CLOSED) 또는 now∉[eval_start,eval_end] 시 POST/DELETE → 422 + mapper 미호출. EVAL+window 내만 200.
|
||||||
|
- **VP-3 (AC-5 미로그인, L1)**: 세션 userId 없음 → POST/DELETE/mine 401. results 는 미로그인도 200(공개 조회).
|
||||||
|
- **VP-4 (AC-6 밴드왜건 은닉, L1)**: 진행 중(EVAL) /vote/results → `results:null, open:true`(출품작별 count 미노출). 종료 후(CLOSED 또는 now>eval_end) → results 배열 노출. JSP 동일 분기 렌더 확인.
|
||||||
|
- **VP-5 (AC-8 CSRF, L1)**: POST/DELETE CSRF 누락 → 403 + mapper 미호출(`deleteCommentRejectsMissingCsrfBeforeMapperAccess` 패턴 준용 — verification §CSRF-before-mapper).
|
||||||
|
- **VP-6 (DB-방언 계약, L2)**: JamVotesMapper 반환 키(votedGameId/voteCount/gameId)가 컨트롤러/JSP 조회 키와 정합. listCountsByJam GROUP BY count 가 샘플 데이터와 일치(snake→camel 직접 alias, 집계 VIEW 아님 → 큰따옴표 미사용 확인).
|
||||||
|
- **VP-7 (contextLoads, L1)**: BibimbapApplicationTests 에 JamVotesMapper @MockBean 등록 후 PASS(§30). 누락 시 NoSuchBeanDefinitionException.
|
||||||
|
- **VP-8 (AC-7 변경/취소, L1)**: 투표→다른 game POST(changed:true, 표 수 1 유지)→DELETE(votedGameId:null, 행 0)→재투표(changed:false) 시퀀스 정합.
|
||||||
|
|
||||||
|
### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용)
|
||||||
|
> self-audit(시점): 아래 카운트는 **본 W2-5 가 신규 생성하는 정적 산출물**(컨트롤러 상태변경 핸들러·매퍼 메서드)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리처럼 증가하는 대상 아님. jam_votes 동결 스키마 카운트는 W2-3 verification 소관(본 설계는 소비만 — 중복 검증 회피).
|
||||||
|
> self-audit(표현): 단일 리터럴 grep 취약성을 피해 상태변경 핸들러 집합/게이트 호출 같은 **구조적 불변식**에 앵커. 매퍼 `${` 0건만 리터럴(부재 검증은 리터럴 정당).
|
||||||
|
|
||||||
|
- **AC-T1 투표 상태변경 핸들러 전수 2건 CSRF + 평가기간 게이트** — JamVoteController 의 상태변경 핸들러(POST vote / DELETE cancel) 전수 2건이 ① `CsrfTokens.isValid` 선검증 ② 평가기간 게이트(jam.status=='EVAL' AND now∈window) 둘 다 보유. 검증: 상태변경 핸들러(@PostMapping/@DeleteMapping) 열거 == 2 AND 각 진입부에 CSRF + 게이트 존재(수동 판정 — @PostMapping/@DeleteMapping 핸들러 열거 후 각 본문 확인, 리터럴 grep 단독 의존 회피). GET(/mine, /results)은 상태변경 아님 → 게이트 비대상(읽기). 핸들러 추가 시 게이트 누락 = 평가기간 우회 보안결함 → FAIL. **이 전수 AC 가 P4 게이트의 핵심 가드**.
|
||||||
|
- **AC-T2 투표 매퍼 `${` 0건** — JamVotesMapper(1파일)에 `${` 매치 0: `grep -c '\${' JamVotesMapper.java` == 0 (AC-9, `${}` 동적치환 금지). 부재 검증이라 리터럴 정당.
|
||||||
|
- **AC-T3 결과 노출 게이트 불변식(밴드왜건 회피)** — `/vote/results` 핸들러와 jam-detail.jsp 결과 영역 **둘 다** 노출 게이트(`status=='CLOSED' OR now()>eval_end_at`)를 보유: 진행 중 출품작별 count 비노출(results:null/JSP 막대 미렌더). 검증: 컨트롤러 results 분기 + JSP 조건 렌더 전수 2지점 모두 게이트 존재(수동 판정 — 시각 비교는 런타임이라 리터럴 grep 부적합, 의미 불변식 점검). 1지점이라도 무조건 count 노출 시 밴드왜건 회피(P6/G6) 위반 → FAIL.
|
||||||
|
- **AC-T4 jam_votes 무변경 불변식(동결 소비)** — 본 W2-5 산출물에 `jam_votes` 의 DDL 변경문(ALTER TABLE/CREATE INDEX/CREATE TABLE) 0건: 본 설계는 신규 docs/*-ddl.sql 파일을 만들지 않고 schema.sql 도 수정하지 않음. 검증: 본 워크스트림 diff 에 `jam_votes` 대상 ALTER/CREATE 0건(읽기/쓰기 매퍼 SQL 의 INSERT/UPDATE/DELETE/SELECT 는 무방, DDL 변경문만 0). 동결 소비 계약(W2-3) 위반(투표가 스키마 손대기) 즉시 검출.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 잔여 오픈 질문
|
||||||
|
없음(0). 확정 결정 P1~P8 전제 고정. 두 난제(1인1표 토글 변경경로·결과 노출 시점 밴드왜건 회피)는 본 설계가 구체 메커니즘으로 확정. 자기표 허용(P8)·표 변경 허용(P7)도 정석으로 확정. 구현 점검 항목(매퍼 upsert vs 분기 시그니처 inflate·full-test @MockBean·집계 alias·결과 노출 분기 위치·자기표 운영 금지 정책)은 오픈 질문이 아니라 `concerns` 로 이관.
|
||||||
|
|
@ -0,0 +1,385 @@
|
||||||
|
---
|
||||||
|
phase: design
|
||||||
|
agent: design-advisor
|
||||||
|
agent_version: 1
|
||||||
|
generated_at: 2026-06-23T16:30:00+09:00
|
||||||
|
workstream: W2-6-시상 집계/결과
|
||||||
|
concerns:
|
||||||
|
- "JamAwardService / JamAwardsMapper 의 신규 메서드 시그니처는 최소 인자로 명세했다. 구현 단계에서 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고 방지, 프로토콜 §11.2). 특히 산정 결과 행 빌더 buildAward(...) 와 정규화 헬퍼 normalizeRankScore(...)."
|
||||||
|
- "신규 컨트롤러(JamAwardController/JamAwardAdminController)+신규 매퍼(JamAwardsMapper)+소비 매퍼(JamScoreStatsMapper/JamVotesMapper/GameReviewStatsMapper 재사용) 의존 추가 — verification-strategies §30 에 따라 implementation 단계에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 신규 @Mapper @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException."
|
||||||
|
- "유저평점 트랙 소비 SQL 은 game_review_stats(집계 VIEW) 를 읽으므로 매퍼 alias 큰따옴표(AS \"avgRating\"/\"reviewCount\") 필수(케이스 폴딩, verification-strategies §33). jam_score_stats 도 집계 VIEW → weightedTotal 등 큰따옴표. jam_votes count·jam_awards CRUD 는 일반 매퍼 → snake→camel 직접 alias(a.score_value AS scoreValue). dev DB contract(L2) 로 3트랙 join·NULL·동점·재산정 멱등 실측 권장."
|
||||||
|
- "최소 리뷰수 임계 N=3 은 W2-3 동결 계약(F5)의 상수다. 본 설계는 GRAND 정규화·가중치 기본값(트랙 균등 1/3)도 산정 상수로 둔다 — 잼별 가변(jams 컬럼 또는 jam_award_config)이 필요하면 확장. 현 설계는 상수(W2-6 산정 로직에 위치, 구현 점검 항목)."
|
||||||
|
- "산정 트리거 시점 = jams.status='CLOSED' OR now()>eval_end_at(W2-3 F6). DB CHECK 로 강제하지 않고 앱계층 게이트(시각 비교 런타임). 재산정 멱등은 deleteByJamTrack 후 재INSERT 를 단일 트랜잭션으로 — 부분 실패 시 트랙 비는 상태 회피. @Transactional 경계는 구현 점검 항목."
|
||||||
|
concerns_checked: true
|
||||||
|
self_verification:
|
||||||
|
checklist_passed: true
|
||||||
|
references:
|
||||||
|
requirements: docs/work-log/2026-06-23-w2-w4-feature-skeletons.md
|
||||||
|
research: .atp/work-session/20260623-104307/research/W2-W4-grounding.md
|
||||||
|
adrs:
|
||||||
|
- .atp/work-session/20260623-104307/implementation/W2-3-eval-freeze-design.md
|
||||||
|
- .atp/work-session/20260623-104307/implementation/W2-1-jam-entity-design.md
|
||||||
|
- .atp/work-session/20260622-180054/implementation/W1-design.md
|
||||||
|
- docs/work-log/2026-06-17-jam-platform-roadmap.md
|
||||||
|
- docs/development/verification-strategies.md
|
||||||
|
- docs/jam-eval-ddl.sql
|
||||||
|
---
|
||||||
|
|
||||||
|
# 설계: W2-6 — 시상 집계 / 결과 (3트랙 산정 + GRAND 종합 + 잼 결과 페이지/상세 수상 표시) ★크리티컬 패스 종점
|
||||||
|
|
||||||
|
> ⚠️ **W2-3 동결 소비 워크스트림**. 본 설계는 `jam_awards` 스키마(W2-3 동결, docs/jam-eval-ddl.sql §4)와 3트랙 소비 계약(G4/F5 단방향 유저평점·G6/F7 집계 노출)을 **재정의하지 않고 그대로 소비**한다. 신규 DDL 테이블 0(jam_awards 는 W2-3 이 이미 생성). 본 W2-6 은 **산정 로직 + 매퍼 + 컨트롤러 + 결과 표시 JSP** 만 신규.
|
||||||
|
|
||||||
|
## 목표 / 비목표
|
||||||
|
|
||||||
|
### 목표 (FR/NFR 추적 — 골자 W2-6)
|
||||||
|
- **G1 3트랙 산정** (FR-W2-6 핵심): JUDGE / USER_RATING / POPULAR 각 트랙 독립 랭킹 산정 → `jam_awards` 트랙별 행(rank) 기록.
|
||||||
|
- JUDGE = `jam_score_stats.weighted_total` DESC (W2-4 산출, W2-3 F7 동결 VIEW).
|
||||||
|
- USER_RATING = `game_review_stats.avg_rating`(overall) DESC NULLS LAST, `review_count >= 3` 임계 미달 제외 (W2-3 G4/F5 단방향 계약).
|
||||||
|
- POPULAR = `jam_votes` count DESC (W2-5 산출, W2-3 F7 동결 count 계약).
|
||||||
|
- **G2 GRAND 종합** (FR-W2-6 종합상): 3트랙 정규화 점수 가중합으로 종합 랭킹 산정 → `jam_awards` award_track='GRAND' 행. 정규화·가중·동점 규칙 본 설계가 확정.
|
||||||
|
- **G3 NULL/미달 처리** (W2-3 F5 계약 준수): 리뷰 0/axes 0행/심사 미완 출품작은 해당 트랙에서 **제외**(부당 0점 회피). GRAND 는 가용 트랙만 정규화 가중.
|
||||||
|
- **G4 확정 시점 + 멱등** (W2-3 F6): `jams.status='CLOSED'` 또는 `now() > eval_end_at` 일 때만 산정 허용. 재산정 멱등(트랙별 DELETE→INSERT 트랜잭션).
|
||||||
|
- **G5 결과 표시**: 잼 상세(`/jams/{slug}`)에 수상 요약 + 별도 결과 페이지(`/jams/{slug}/results`) 트랙별 + 종합 전체 노출.
|
||||||
|
- **G6 산정 트리거 권한** (W1 인프라 위): 관리자 산정 액션 = `GAME_JAM_MANAGE` 게이트(W2-1 D4-A 게이트 헬퍼 선례). 임시 role 직접체크 금지.
|
||||||
|
- **NFR**: 상태변경 CSRF 전수(산정 트리거), `#{}` 바인딩(`${}` 금지), 집계 VIEW 매퍼 alias 큰따옴표(case-folding), 단방향 유저평점(리뷰 도메인 write 0), 비파괴(신규 DDL 0 — jam_awards 소비만).
|
||||||
|
|
||||||
|
### 비목표 (스코프 밖)
|
||||||
|
- **jam_awards 스키마 정의** — **W2-3 동결 소유**(docs/jam-eval-ddl.sql §4). 본 설계는 컬럼/CHECK/UNIQUE 재정의 안 함, 소비만.
|
||||||
|
- **심사 점수 입력·집계 VIEW** — **W2-4 소유**(jam_score_stats VIEW 는 W2-3 동결, W2-4 가 점수 입력). 본 설계는 weighted_total 을 읽기만.
|
||||||
|
- **인기투표 토글·집계** — **W2-5 소유**(jam_votes). 본 설계는 count 를 읽기만.
|
||||||
|
- **리뷰 도메인 변경** — W3-2(구현완료). 본 설계는 `game_review_stats` VIEW 를 **SELECT 만**(W2-3 G4 단방향, game_reviews/axes 무변경).
|
||||||
|
- **자동 산정 스케줄러 본체** — 1차는 관리자 수동 트리거(`POST /admin/jams/{jamId}/awards/compute`). eval_end_at 경과 자동 산정은 W2-1 의 JamLifecycle 자동전이 훅(후속)과 동일하게 훅만 — 본 설계는 수동 산정 enforcement 만 구현 범위.
|
||||||
|
- **잼별 가변 가중치 UI/설정 테이블** — 1차는 산정 상수(트랙 균등 1/3 + 임계 N=3). 잼별 가변은 후속(concern 4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
bibimbap 은 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis annotation `@Mapper`(`#{}` only) + JSP 스택이다. 현재 시상 관련 산출물(jam_awards 소비 매퍼·산정 로직·결과 JSP)은 전무하다. W2-3 동결이 `jam_awards`(award_track CHECK 4값 JUDGE/USER_RATING/POPULAR/GRAND + rank + score_value + computed_at, `ux_jam_awards_jam_track_game` UNIQUE — docs/jam-eval-ddl.sql §4 직접 인용) + 3트랙 소비 계약을 이미 동결했고, W2-1 이 `jams`(status CHECK + slug + eval_end_at) + `jam_entries`(잼당 game 활성 UNIQUE = 출품작 모집단)를 제공한다.
|
||||||
|
|
||||||
|
본 설계는 **3트랙 + GRAND 산정 로직(JamAwardService) + jam_awards CRUD 매퍼 + 관리자 산정 트리거 + 공개 결과 표시(상세 수상 요약 + 결과 페이지)** 를 신규 도입한다. 산정은 출품작 모집단(`jam_entries` 활성행) 위에서 3트랙 소스를 각각 정렬·랭크 → jam_awards 트랙별 행 기록 → GRAND 는 트랙 정규화 가중합으로 종합 랭크. 신규 DDL 테이블 0(jam_awards 소비).
|
||||||
|
|
||||||
|
확정된 정석 결정(전제 — orchestrator 확정 + W2-3 동결 정합):
|
||||||
|
- **3트랙 = 독립 산정** : 각 트랙은 자기 소스로 독립 랭크. 트랙 간 결합 없음(JUDGE 미완이어도 POPULAR 산정 가능).
|
||||||
|
- **GRAND = 트랙별 순위점수(rank-score) 정규화 가중합** : 트랙별 스케일 상이(weighted_total numeric / avg_rating 1~5 / vote count 정수) → **순위점수 정규화**(min-max 가 아닌 rank 기반)로 스케일 통일. 가중치 기본 균등(1/3). 가용 트랙만 가중.
|
||||||
|
- **NULL/미달 = 트랙 제외** (부당 0점 회피, W2-3 F5).
|
||||||
|
- **확정 시점 = CLOSED/eval종료 후, 멱등** (W2-3 F6).
|
||||||
|
|
||||||
|
가장 까다로운 세 난제 확정:
|
||||||
|
|
||||||
|
- **난제1 (GRAND 정규화 — 트랙별 스케일 통일)**: 3트랙은 스케일이 전혀 다르다(JUDGE weighted_total numeric 1~5 가중평균 / USER_RATING avg_rating numeric 1~5 / POPULAR vote count 정수 0~N). raw 점수 단순 합산은 vote count 가 압도(스케일 폭주). min-max 정규화는 트랙 내 분포에 민감(이상치·단일 출품작 시 0/1 양극단). 본 설계는 **순위점수(rank-score) 정규화**를 채택: 각 트랙에서 출품작을 정렬해 순위를 매기고, 순위를 `rankScore = (참여작수 - rank + 1) / 참여작수` (1위=1.0, 최하위=1/N) 로 [1/N, 1] 정규화한다. 스케일·이상치 무관, 트랙 간 동일 의미(상대 순위). GRAND = `Σ(rankScore_track × weight_track) / Σ(weight_track 가용)`. **가용 트랙만** 분자·분모에 포함(트랙 결측 출품작은 그 트랙 0 아님 — 가중 평균이 가용 트랙으로 정규화됨, F5 부당 0점 회피 정합). 동점은 같은 GRAND 점수 → 같은 rank(아래 동점 규칙).
|
||||||
|
- **난제2 (NULL/미달 트랙 제외의 산정 위치)**: W2-3 F5 는 "USER_RATING 트랙에서 review_count<3 제외"를 동결했다. 본 설계는 **각 트랙 모집단을 트랙별로 좁힌다**: JUDGE = jam_score_stats 에 행이 있는 출품작(채점 1건 이상), USER_RATING = game_review_stats.review_count>=3, POPULAR = jam_votes count>=1(0표는 트랙 비포함이 아니라 동률 최하위 — 투표 트랙은 0표도 출품작이면 count 0 으로 랭크 가능하나, 정석=득표 0 출품작은 POPULAR 수상권 밖이므로 **rank 부여하되 score_value=0**, GRAND 의 POPULAR rankScore 계산 모집단에는 득표 있는 작품만). 트랙별 모집단을 명시(§시퀀스 S3). GRAND 모집단 = 3트랙 중 **최소 1트랙 이상 가용**한 출품작.
|
||||||
|
- **난제3 (동점 처리)**: 각 트랙 raw 점수 동점(예: weighted_total 동일, vote count 동일) → **같은 rank 부여**(standard competition ranking: 1,2,2,4). jam_awards UNIQUE 는 `(jam_id, award_track, game_id)` 이므로 같은 rank 동률 다행 허용(W2-3 동결 주석: "동률은 같은 rank 허용 위해 (jam_id, award_track, game_id) UNIQUE"). tie-break 표시 순서는 game_id ASC(결정적·재산정 안정). GRAND 동점도 동일 규칙.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 핵심 결정 요약 (전제 — 재논의 금지. orchestrator 확정 + W2-3 동결 정합)
|
||||||
|
|
||||||
|
| 결정 | 확정값 | 본 설계의 구체화 |
|
||||||
|
|---|---|---|
|
||||||
|
| A1 트랙 산정 | 3트랙 독립 랭크 | JUDGE=jam_score_stats.weighted_total / USER_RATING=avg_rating(F5) / POPULAR=jam_votes count. 트랙 간 결합 없음 |
|
||||||
|
| A2 GRAND 정규화 | 순위점수(rank-score) 가중합 | rankScore=(N-rank+1)/N ∈[1/N,1]. GRAND=Σ(rankScore×weight)/Σ(weight 가용). 가용 트랙만 |
|
||||||
|
| A3 가중치 | 기본 균등 1/3(상수) | 3트랙 동일 weight. 잼별 가변은 후속(concern 4) |
|
||||||
|
| A4 NULL/미달 | 트랙 제외(부당 0점 회피) | JUDGE=채점 1건+ / USER_RATING=review_count>=3 / POPULAR rankScore 모집단=득표>0. GRAND=가용 트랙만 |
|
||||||
|
| A5 동점 | standard competition ranking(같은 rank) | jam_awards (jam_id,track,game_id) UNIQUE 가 동률 다행 허용(W2-3 동결). tie-break 표시=game_id ASC |
|
||||||
|
| A6 확정 시점 | CLOSED/eval종료 후, 멱등 | jam.status='CLOSED' OR now()>eval_end_at. 재산정=deleteByJamTrack→INSERT(트랜잭션) |
|
||||||
|
| A7 트리거 권한 | GAME_JAM_MANAGE 게이트 | /admin/jams/{jamId}/awards/compute. W2-1 D4-A 게이트 헬퍼 재사용(인터셉터 exclude 범위 내) |
|
||||||
|
| A8 표시 | 상세 요약 + 결과 페이지 | /jams/{slug} 수상 배지 + /jams/{slug}/results 트랙별+종합 전체 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 모델 (DDL — 신규 0건. W2-3 동결 jam_awards 소비)
|
||||||
|
|
||||||
|
> **신규 테이블/VIEW 0**. `jam_awards`(W2-3 동결, docs/jam-eval-ddl.sql §4)를 그대로 소비한다. 본 설계는 DDL 을 **재정의하지 않는다**(W2-3 소유). 아래는 소비 계약 확인용 동결 스키마 재인용(읽기 전용 — 변경 금지).
|
||||||
|
|
||||||
|
### 소비 대상: `jam_awards` (W2-3 동결 — 재정의 금지, 인용만)
|
||||||
|
```sql
|
||||||
|
-- docs/jam-eval-ddl.sql §4 (W2-3 동결. 본 W2-6 은 이 스키마를 INSERT/SELECT/DELETE 소비)
|
||||||
|
-- jam_awards(id, jam_id, game_id, award_track, rank, score_value numeric(10,4), computed_at)
|
||||||
|
-- award_track CHECK IN ('JUDGE','USER_RATING','POPULAR','GRAND')
|
||||||
|
-- rank CHECK >= 1
|
||||||
|
-- UNIQUE ux_jam_awards_jam_track_game (jam_id, award_track, game_id) ← 동률 다행 허용
|
||||||
|
-- INDEX idx_jam_awards_jam_track (jam_id, award_track, rank) ← 결과 조회 정렬
|
||||||
|
```
|
||||||
|
|
||||||
|
### score_value 컬럼 의미 확정 (트랙별 — W2-3 동결 컬럼의 본 설계 채움 규약)
|
||||||
|
> W2-3 동결은 `score_value numeric(10,4)` 를 "트랙별 의미 다름; NULL 허용"으로만 정의했다. 본 W2-6 이 트랙별 채움 의미를 확정한다(스키마 변경 0 — 같은 컬럼의 값 규약만).
|
||||||
|
|
||||||
|
| award_track | score_value 채움값 | 의미 |
|
||||||
|
|---|---|---|
|
||||||
|
| JUDGE | jam_score_stats.weighted_total (numeric, 가중종합 1~5) | 심사 가중 종합점수 |
|
||||||
|
| USER_RATING | game_review_stats.avg_rating (numeric, overall 1~5) | 유저 평균 별점(overall) |
|
||||||
|
| POPULAR | jam_votes count (정수를 numeric 로) | 득표수 |
|
||||||
|
| GRAND | GRAND 종합점수 (Σ(rankScore×weight)/Σweight, [0,1] 정규화) | 트랙 정규화 가중합 |
|
||||||
|
|
||||||
|
- **결정 근거**: score_value 에 트랙별 **raw 점수**(JUDGE/USER_RATING/POPULAR)와 GRAND 종합점수를 저장하면 결과 페이지가 jam_awards 단독 조회로 "점수와 함께" 표시 가능(소스 VIEW 재join 불요). rank 는 정렬·동점, score_value 는 표시·재현 근거. 둘 다 보존이 정석(rank 만으로는 동점 근거·점수 표시 불가).
|
||||||
|
|
||||||
|
### 신규 DDL 0 확인 (G3 비파괴)
|
||||||
|
- `jam_awards`/`jam_score_stats`/`jam_votes`/`game_review_stats`/`jam_entries`/`jams` **전부 무변경**(전부 선행 워크스트림 소유). 본 W2-6 은 jam_awards 에 INSERT/DELETE/SELECT, 나머지 4개는 SELECT 만. → DDL 파일 신규 0, schema.sql 변경 0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 외부 계약 (API)
|
||||||
|
|
||||||
|
> 공통: 산정 트리거(상태변경)는 `CsrfTokens.isValid(request)` 검증(없으면 403 + `CsrfTokens.errorBody()`, CsrfTokens.java 확인). 결과 조회는 공개(인증 불필요). 응답은 RecruitController 패턴 — 읽기=JSP 뷰이름 반환, 쓰기=`ResponseEntity<Map<String,Object>>`(status/message). 관리자 산정 API 는 진입부에서 `PermissionGate.has(session, GAME_JAM_MANAGE.name())` 게이트 통과 후 본문(2-arg has — PermissionGate.java:22 직접 확인, request 인자 없음).
|
||||||
|
|
||||||
|
### 401 vs 403 정책 (W1-design / W2-1 / W2-3 과 일치)
|
||||||
|
- **미인증**(세션 `userId` 없음): API 401 JSON `{status:401, message:"로그인이 필요합니다."}`. 결과 페이지는 공개(미인증도 열람).
|
||||||
|
- **인증·미인가**(GAME_JAM_MANAGE 없음): 403 JSON `{status:403, message:"권한이 없습니다."}`(리다이렉트 금지).
|
||||||
|
- **산정 미개방**(F6 게이트 위반 — status≠CLOSED AND now()≤eval_end_at): **422** JSON `{status:422, message:"시상 산정 가능 상태가 아닙니다."}`(인가는 됐으나 도메인 상태 위반 → 403 아님 422, W2-3 F6 422 정책 일치).
|
||||||
|
- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`.
|
||||||
|
|
||||||
|
### 공개 결과 (뷰 — 인증 불필요)
|
||||||
|
| method | path | 권한 | 응답 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/jams/{slug}/results` | 공개 | `jam-results` JSP. 트랙별(JUDGE/USER_RATING/POPULAR) 랭킹 + GRAND 종합. 미산정 잼이면 "결과 준비 중" 안내(빈 jam_awards) |
|
||||||
|
| GET | `/jams/{slug}` | 공개 | (W2-1 소유 — 본 설계는 수상 요약 모델 주입만 추가) `jam-detail` JSP 에 GRAND 상위 + 트랙 대상 배지 표시. **JamController.detail 의 모델에 awardsSummary 추가**(W2-1 컨트롤러 협의 수정 — concerns crossRef) |
|
||||||
|
|
||||||
|
### 관리자 산정 트리거 (상태변경 API — CSRF + GAME_JAM_MANAGE 게이트)
|
||||||
|
| 액션 | method | path | 요청 | 응답(200) | 에러 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 시상 산정/재산정 | POST | `/admin/jams/{jamId}/awards/compute` | (path jamId) | `{status:200, message, jamId, awardCounts:{JUDGE:n, USER_RATING:n, POPULAR:n, GRAND:n}}` | 401(미인증), 403(CSRF/권한), 404(잼 없음), 422(산정 미개방 — CLOSED 아님+eval 진행중) |
|
||||||
|
|
||||||
|
- **단일 산정 엔드포인트 채택 근거**: 산정은 멱등(재실행=전체 재산정)이므로 최초 산정/재산정을 같은 엔드포인트로. 4트랙을 한 트랜잭션에 산정해 트랙 간 부분 산정(일부 트랙만 갱신된 비정합 상태) 회피. `awardCounts` 로 트랙별 수상 행수 반환.
|
||||||
|
- **경로 소속**: `/admin/jams/{jamId}/awards/compute` 는 `/admin/jams/**` 하위 → W2-1 D4-A 의 InterceptorConfig `.excludePathPatterns("/admin/jams/**")` 범위에 포함되어 인터셉터 ADMIN 게이트를 우회하고 컨트롤러 게이트 헬퍼(GAME_JAM_MANAGE)로 판정(SUBADMIN+키 통과 — W2-1 선례 그대로, 본 설계 인터셉터 추가 작업 0).
|
||||||
|
- **부작용**: jam_awards 4트랙 전체 deleteByJamTrack → 재INSERT(단일 트랜잭션). game_reviews/jam_scores/jam_votes write 0(읽기만 — 단방향).
|
||||||
|
|
||||||
|
### 결과 조회 계약
|
||||||
|
- `JamAwardsMapper.listByJam(jamId)` → 트랙별·rank 정렬(idx_jam_awards_jam_track). 컨트롤러가 트랙별로 그룹핑해 JSP 모델 주입.
|
||||||
|
- 결과 행에 게임 표시정보(이름/썸네일/출품자) 조인 필요 → `listByJamWithGame(jamId)`(JOIN games + jam_entries 표시필드). game_review_stats 등 소스 VIEW 재조회 불요(score_value 가 표시점수 보존).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 인터셉터 / 게이트 연동 (A7 — W2-1 D4-A 재사용, 본 설계 추가 0)
|
||||||
|
|
||||||
|
### 게이트 경로 (W2-1 확정 그대로 소비)
|
||||||
|
- `/admin/jams/{jamId}/awards/compute` 는 `/admin/jams/**` 하위 → **W2-1 이 이미 InterceptorConfig 에 `.excludePathPatterns("/admin/jams/**")` 추가함**(W2-1 D4-A). 본 W2-6 은 InterceptorConfig 를 **추가 수정하지 않는다**(W2-1 exclude 가 본 경로를 이미 커버). 인터셉터 우회 후 컨트롤러 게이트 헬퍼가 판정.
|
||||||
|
- **JamAwardAdminController 진입부 게이트 헬퍼**(W2-1 requireJamManage 패턴 동형):
|
||||||
|
1. `gate.isAuthenticated(session)` 거짓 → 401(API).
|
||||||
|
2. `gate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())` 거짓 → 403.
|
||||||
|
3. 통과 후 본문.
|
||||||
|
- **이유**: 잼 산정은 "ADMIN 또는 SUBADMIN+GAME_JAM_MANAGE" 라 권한 키 판정 필요 → `gate.has`(ADMIN 암묵전권 + SUBADMIN 키보유, PermissionGate.java:30-36 직접 확인) 정확히 적합. 커스텀 어노테이션/AOP 는 W1/W2-1 에서 오버엔지니어링으로 기각된 선례 → 동일하게 게이트 헬퍼.
|
||||||
|
- **중복 0**: 단일 산정 액션이므로 헬퍼는 1회 호출. W2-1 의 requireJamManage 와 동형 private 헬퍼(컨트롤러 내) 또는 W2-1 헬퍼가 공용 컴포넌트면 재사용(구현 시 W2-1 소유자와 협의 — concerns crossRef).
|
||||||
|
|
||||||
|
### epoch 전파 연동 (W1 결정4 — 추가 작업 0)
|
||||||
|
- `gate.has` 내부 `refreshIfStale`(PermissionGate.java:86)가 요청당 epoch 대조 → ADMIN 이 GAME_JAM_MANAGE 부여/회수하면 대상 다음 요청에서 즉시 반영(W1 메커니즘 그대로, 본 설계 추가 0).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 시퀀스 (주요 플로우 의사코드)
|
||||||
|
|
||||||
|
### S1. 관리자 시상 산정/재산정 (전체 트랜잭션)
|
||||||
|
```
|
||||||
|
[SUBADMIN(+GAME_JAM_MANAGE) 세션] POST /admin/jams/42/awards/compute (CSRF)
|
||||||
|
→ InterceptorConfig: /admin/jams/** exclude(W2-1) → 인터셉터 미개입
|
||||||
|
→ JamAwardAdminController.computeAwards(42)
|
||||||
|
→ requireJamManage(session): isAuthenticated? gate.has(GAME_JAM_MANAGE)? (아니면 401/403)
|
||||||
|
→ CsrfTokens.isValid(request) (아니면 403 errorBody)
|
||||||
|
→ jam = jamsMapper.getById(42) (없으면 404)
|
||||||
|
→ 산정 게이트(F6): jam.status=='CLOSED' OR now() > jam.evalEndAt? (아니면 422)
|
||||||
|
→ jamAwardService.recompute(jam.id): # @Transactional — 4트랙 원자적
|
||||||
|
for track in [JUDGE, USER_RATING, POPULAR, GRAND]:
|
||||||
|
jamAwardsMapper.deleteByJamTrack(jam.id, track) # 멱등 재산정
|
||||||
|
→ S2/S3 트랙별 산정 → jamAwardsMapper.insert(award) 다건
|
||||||
|
→ 200 {jamId:42, awardCounts:{JUDGE:n,...}}
|
||||||
|
# game_reviews/jam_scores/jam_votes write 0 (단방향 읽기).
|
||||||
|
```
|
||||||
|
|
||||||
|
### S2. 3트랙 독립 산정 (JamAwardService 내부 — 각 트랙 모집단·정렬·랭크)
|
||||||
|
```
|
||||||
|
trackJudge(jamId):
|
||||||
|
rows = jamScoreStatsMapper.listStatsByJam(jamId) # [{gameId, weightedTotal, ...}]
|
||||||
|
# W2-3 F7 동결 VIEW. weightedTotal NULL(채점 0) 행은 모집단 제외(A4)
|
||||||
|
rows = rows.filter(r -> r.weightedTotal != null)
|
||||||
|
rank = standardCompetitionRank(rows, key=weightedTotal DESC, tiebreak=gameId ASC)
|
||||||
|
for r: jamAwardsMapper.insert(award(jamId, r.gameId, 'JUDGE', rank[r], r.weightedTotal))
|
||||||
|
|
||||||
|
trackUserRating(jamId):
|
||||||
|
rows = gameReviewStatsConsumerMapper.listAvgByJam(jamId)
|
||||||
|
# jam_entries e LEFT JOIN game_review_stats st (st."avgRating"/"reviewCount")
|
||||||
|
# WHERE e.jam_id=#{jamId} AND e.is_delete<>true AND st.review_count >= 3 (F5 임계)
|
||||||
|
# ORDER BY st.avg_rating DESC NULLS LAST, e.game_id ASC (F5 정렬)
|
||||||
|
rank = standardCompetitionRank(rows, key=avgRating DESC, tiebreak=gameId ASC)
|
||||||
|
for r: jamAwardsMapper.insert(award(jamId, r.gameId, 'USER_RATING', rank[r], r.avgRating))
|
||||||
|
|
||||||
|
trackPopular(jamId):
|
||||||
|
rows = jamVotesMapper.listCountsByJam(jamId) # [{gameId, voteCount}] GROUP BY game_id
|
||||||
|
# 득표 0 출품작은 GROUP BY 에 안 나옴 → POPULAR rankScore 모집단=득표>0(A4)
|
||||||
|
rank = standardCompetitionRank(rows, key=voteCount DESC, tiebreak=gameId ASC)
|
||||||
|
for r: jamAwardsMapper.insert(award(jamId, r.gameId, 'POPULAR', rank[r], r.voteCount))
|
||||||
|
```
|
||||||
|
- **standardCompetitionRank**(공통 헬퍼): 정렬 후 동점이면 같은 rank, 다음 순위는 건너뜀(1,2,2,4). tiebreak=gameId ASC 로 결정적(재산정 안정). 이 헬퍼는 jam_awards rank 부여와 GRAND rankScore 산정 양쪽 공유(중복 0).
|
||||||
|
|
||||||
|
### S3. GRAND 종합 산정 (순위점수 정규화 가중합 — 난제1)
|
||||||
|
```
|
||||||
|
trackGrand(jamId, weights={JUDGE:1/3, USER_RATING:1/3, POPULAR:1/3}):
|
||||||
|
# 각 트랙의 "수상권 모집단" 위에서 rankScore 계산
|
||||||
|
judgeScore = rankScoreMap(trackJudge 모집단, key=weightedTotal DESC) # gameId -> (N-rank+1)/N
|
||||||
|
ratingScore = rankScoreMap(trackUserRating 모집단, key=avgRating DESC)
|
||||||
|
popularScore = rankScoreMap(trackPopular 모집단, key=voteCount DESC) # 득표>0 만
|
||||||
|
|
||||||
|
grandPop = union(모든 트랙 모집단 gameId) # 최소 1트랙 가용 출품작
|
||||||
|
for gameId in grandPop:
|
||||||
|
avail = [(judgeScore, JUDGE), (ratingScore, USER_RATING), (popularScore, POPULAR)]
|
||||||
|
.filter(트랙 모집단에 gameId 존재) # 가용 트랙만(A4 부당 0점 회피)
|
||||||
|
grand = Σ(score[gameId] × weights[track]) / Σ(weights[track] for 가용) # [1/N..1] 정규화
|
||||||
|
rank = standardCompetitionRank(grandPop, key=grand DESC, tiebreak=gameId ASC)
|
||||||
|
for g: jamAwardsMapper.insert(award(jamId, g, 'GRAND', rank[g], grand[g]))
|
||||||
|
```
|
||||||
|
- **정규화 논증(난제1)**: rankScore = (N-rank+1)/N 은 트랙 내 상대 순위만 쓰므로 스케일(numeric 1~5 vs vote count 0~수백) 무관. 가용 트랙만 분모에 넣어 트랙 결측이 부당 0점이 되지 않음(F5). 1트랙만 가용한 출품작도 그 트랙 rankScore 로 GRAND 산정(분모=그 트랙 weight) → 제외 안 함.
|
||||||
|
- **동점(난제3)**: grand 동일 → 같은 rank. game_id ASC tiebreak.
|
||||||
|
|
||||||
|
### S4. 공개 결과 페이지
|
||||||
|
```
|
||||||
|
[공개] GET /jams/{slug}/results
|
||||||
|
→ JamAwardController.results(slug)
|
||||||
|
→ jam = jamsMapper.getBySlug(slug) (없거나 !is_visible → redirect:/jams)
|
||||||
|
→ awards = jamAwardsMapper.listByJamWithGame(jam.id) # 트랙·rank 정렬 + JOIN games 표시
|
||||||
|
→ byTrack = group(awards, award_track) # {JUDGE:[...], USER_RATING:[...], ...}
|
||||||
|
→ model: jam, byTrack(트랙별 랭킹), grand=byTrack.GRAND
|
||||||
|
→ "jam-results" (미산정이면 빈 맵 → "결과 준비 중" 표시)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 영향 맵
|
||||||
|
|
||||||
|
> 소유권 분할 가이드(implementation-advisor worker 단위 후보):
|
||||||
|
> **AW-DOMAIN**(data POJO/JamAwardService 산정 코어/순위점수 헬퍼) · **AW-MAPPER**(JamAwardsMapper + 소비 매퍼 listAvgByJam/listCountsByJam — JamScoreStatsMapper.listStatsByJam 는 W2-4 소유 재사용) · **AW-ADMIN**(JamAwardAdminController 산정 트리거 + 게이트) · **AW-PUBLIC**(JamAwardController 결과 페이지 + JSP) · **AW-DETAIL**(W2-1 JamController.detail 수상요약 모델 주입 — W2-1 협의 수정).
|
||||||
|
> 의존: W2-3 동결(jam_awards) + W2-4(jam_score_stats) + W2-5(jam_votes) + W3-2(game_review_stats) 선행 → AW-DOMAIN → AW-MAPPER → {AW-ADMIN, AW-PUBLIC, AW-DETAIL}.
|
||||||
|
|
||||||
|
| 변경 유형 | 경로 | 역할 | 소유 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/JamAwardData.java` | jam_awards 행 POJO(jamId/gameId/awardTrack/rank/scoreValue/computedAt) + 표시조인(gameName/thumbnailUrl/entrantType — listByJamWithGame 용) | AW-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/jam/AwardTrack.java` | enum JUDGE/USER_RATING/POPULAR/GRAND + isValid(String) (PermissionKeys/JamStatus 패턴) | AW-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/jam/JamAwardService.java` | 3트랙+GRAND 산정 코어(recompute, 트랙별 모집단·정렬·rankScore·동점). @Transactional 멱등 재산정 | AW-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/jam/RankScores.java` | 순위점수 유틸(standardCompetitionRank + rankScore (N-rank+1)/N). jam_awards rank·GRAND 정규화 공유(중복 0) | AW-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamAwardsMapper.java` | `@Mapper` jam_awards insert/deleteByJamTrack/listByJam/listByJamWithGame(`#{}`, 일반 매퍼 snake→camel 직접 alias) | AW-MAPPER |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamReviewRatingMapper.java` | `@Mapper` USER_RATING 트랙 소비 — jam_entries LEFT JOIN game_review_stats(집계 VIEW → `AS "avgRating"`/`"reviewCount"` 큰따옴표), review_count>=3 + NULLS LAST(`#{}`) | AW-MAPPER |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamVoteCountMapper.java` | `@Mapper` POPULAR 트랙 소비 — jam_votes GROUP BY game_id count(일반 매퍼 직접 alias)(`#{}`). (W2-5 JamVotesMapper 와 별개 or 재사용 — 구현 시 협의, concerns) | AW-MAPPER |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/JamAwardAdminController.java` | `/admin/jams/{jamId}/awards/compute` 산정 트리거(게이트 헬퍼 + CSRF + F6 422) | AW-ADMIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/JamAwardController.java` | 공개 `/jams/{slug}/results`(결과 페이지, RecruitController 읽기 패턴) | AW-PUBLIC |
|
||||||
|
| 신규 | `src/main/webapp/WEB-INF/views/jam-results.jsp` | 트랙별 랭킹 + GRAND 종합 표 (HtmlUtils.htmlEscape/JSTL escape, textContent) | AW-PUBLIC |
|
||||||
|
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/JamController.java` | (W2-1 소유) detail 모델에 awardsSummary(GRAND 상위 + 트랙 대상) 주입 추가. **W2-1 소유자 협의 — 본 설계는 모델 키 계약만 명시** | AW-DETAIL(W2-1 협의) |
|
||||||
|
| 수정 | `src/main/webapp/WEB-INF/views/jam-detail.jsp` | (W2-1 소유) 수상 요약 배지 블록 추가(escape) | AW-DETAIL(W2-1 협의) |
|
||||||
|
| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 매퍼(JamAwardsMapper/JamReviewRatingMapper/JamVoteCountMapper) @MockBean 등록(contextLoads 보존, §30) | (검증) |
|
||||||
|
| 신규 | `src/test/.../JamAwardServiceTest.java` | 3트랙 산정 + GRAND 정규화 + NULL/미달 제외 + 동점 + 멱등 재산정 단위 | (검증) |
|
||||||
|
| 신규 | `src/test/.../JamAwardAdminControllerTest.java` | 산정 트리거 + 401/403/CSRF/게이트 + F6 422(CLOSED 아님) | (검증) |
|
||||||
|
| 신규 | `src/test/.../JamAwardControllerTest.java` | 결과 페이지 + 미산정 잼 빈 결과 + 트랙 그룹핑 | (검증) |
|
||||||
|
|
||||||
|
> SSR 호출지점 영향(verification §영향맵 SSR 포함): jam-detail.jsp 수정은 awardsSummary 모델 키 **신규 추가**라 기존 키 소비 깨짐 0(빈 잼이면 awardsSummary 빈 컬렉션 → 표시 분기). JamController.detail 모델 추가는 W2-1 소유 컨트롤러 협의 수정 — 기존 모델 키 보존(추가만). game_review_stats VIEW 무변경 → W3-2 리뷰 요약 회귀 0.
|
||||||
|
|
||||||
|
### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
|
||||||
|
```java
|
||||||
|
// JamAwardService — 산정 코어. jamId 만으로 4트랙 소스 조회·산정·기록(최소).
|
||||||
|
int recompute(long jamId) // 산정/재산정(@Transactional). 반환=총 수상 행수(또는 트랙별 Map)
|
||||||
|
|
||||||
|
// RankScores — 순위점수 유틸(트랙 rank·GRAND 정규화 공유). 정렬키 추출은 호출자 제공(제네릭 비교자).
|
||||||
|
// 최소 인자: 정렬 대상 리스트 + 비교 기준만. (트랙·잼 컨텍스트는 호출자가 알고 있으므로 전달 안 함 — inflate 방지)
|
||||||
|
<T> Map<T,Integer> standardCompetitionRank(List<T> items, // 산정 대상(트랙 모집단)
|
||||||
|
Comparator<T> byScoreDesc) // 점수 내림차순+tiebreak
|
||||||
|
<T> Map<T,Double> rankScore(List<T> items, // 동일 모집단
|
||||||
|
Comparator<T> byScoreDesc) // (N-rank+1)/N 정규화 — GRAND 가중합 입력
|
||||||
|
|
||||||
|
// JamAwardsMapper (@Mapper, #{} only, 일반 매퍼 snake→camel 직접 alias)
|
||||||
|
int insert(JamAwardData award) // 트랙·순위 수상 기록(재산정 후 다건)
|
||||||
|
int deleteByJamTrack(long jamId, String track) // 재산정 전 트랙 초기화(멱등)
|
||||||
|
List<JamAwardData> listByJamWithGame(long jamId) // 결과 페이지(JOIN games 표시 + 트랙·rank 정렬)
|
||||||
|
|
||||||
|
// JamReviewRatingMapper (@Mapper, #{} only, 집계 VIEW 소비 → camelCase 큰따옴표 alias)
|
||||||
|
List<JamReviewRatingRow> listAvgByJam(long jamId) // USER_RATING 모집단(review_count>=3, NULLS LAST)
|
||||||
|
|
||||||
|
// JamVoteCountMapper (@Mapper, #{} only, 일반 매퍼 직접 alias)
|
||||||
|
List<JamVoteCountRow> listCountsByJam(long jamId) // POPULAR 모집단(GROUP BY game_id count)
|
||||||
|
|
||||||
|
// (W2-4 소유 재사용) JamScoreStatsMapper.listStatsByJam(long jamId) — JUDGE 모집단(weightedTotal)
|
||||||
|
```
|
||||||
|
> ⚠️ inflate 마킹(concern 1): `JamAwardService.recompute` 는 jamId 단일 인자가 정석(소스 조회는 매퍼 주입으로 해결 — jam 객체/세션/actor 를 받지 말 것. 산정은 actor 무관 순수 집계). `RankScores` 의 두 메서드는 `Comparator` 만 받아 트랙/잼 컨텍스트를 끌어오지 않음(헬퍼 inflate 차단). 구현에서 buildAward(...) 헬퍼를 추출한다면 (jamId, gameId, track, rank, scoreValue) 전부 실제 INSERT 에 쓰이는지 재확인(dead parameter 방지). JamReviewRatingRow/JamVoteCountRow 는 필요 필드(gameId + 점수)만 — 6축 컬럼 매핑 금지(단방향 G4, 6축 미사용).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 대안 비교
|
||||||
|
|
||||||
|
| 주제 | 안 | 장점 | 단점 | 채택 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| GRAND 정규화 | (A) 순위점수(rank-score) 가중합 | 트랙 스케일 무관, 이상치 강건, 트랙 간 동일 의미(상대순위), 가용트랙 정규화로 F5 정합 | 절대 점수차 손실(1위-2위 격차 무시) | **채택(A2)** |
|
||||||
|
| | (B) min-max 정규화 raw 점수 | 점수차 보존 | 단일/소수 출품작 시 0/1 양극단, 이상치 민감, 트랙 내 분포 의존 | 기각 |
|
||||||
|
| | (C) raw 점수 단순 합 | 단순 | vote count 가 스케일 압도(수백 vs 1~5) → 사실상 인기상=종합 | 기각 |
|
||||||
|
| NULL/미달 | (A) 트랙 제외 + GRAND 가용트랙 정규화 | 부당 0점 회피(F5 동결), 1트랙만 가용해도 GRAND 산정 | 가용트랙 적은 작품 변동성 | **채택(A4)** |
|
||||||
|
| | (B) 미달=0점 | 단순 | 리뷰 0개가 GRAND 최하위 강제(부당, F5 위반) | 기각 |
|
||||||
|
| 동점 | (A) standard competition rank(같은 rank, 1,2,2,4) | 직관적, jam_awards UNIQUE 동률 다행 허용(W2-3 동결) | rank 건너뜀 | **채택(A5)** |
|
||||||
|
| | (B) dense rank(1,2,2,3) | 연속 rank | UNIQUE(jam_id,track,game_id) 와 무관하나 표시 관례상 competition 이 시상에 자연 | 기각 |
|
||||||
|
| | (C) tiebreak 강제 유일순위 | UNIQUE 단순 | 동점을 인위 분리(공정성 훼손) | 기각 |
|
||||||
|
| score_value 채움 | (A) 트랙=raw 점수, GRAND=종합점수 | 결과 단독조회 표시, 재현근거 보존 | 컬럼 의미 트랙별 분기 | **채택** |
|
||||||
|
| | (B) score_value 전부 NULL(rank 만) | 단순 | 점수 표시·동점 근거 불가, 소스 VIEW 재조회 필요 | 기각 |
|
||||||
|
| 산정 트리거 | (A) 관리자 수동(GAME_JAM_MANAGE 게이트) | 운영 통제, W2-1 게이트 재사용, 자동훅은 후속 | 수동 1스텝 | **채택(A7)** |
|
||||||
|
| | (B) eval_end_at 경과 자동 산정 | 무인 | @Scheduled context 영향, 1차 과도(W2-1 자동전이도 후속) | 기각(후속 훅) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 롤아웃 / 마이그레이션
|
||||||
|
|
||||||
|
### 순서
|
||||||
|
1. **스키마**: 신규 DDL 0. `jam_awards`(W2-3 docs/jam-eval-ddl.sql)·`jam_score_stats`(W2-3 VIEW)·`jam_votes`(W2-3)·`game_review_stats`(W3-2) 가 **선행 적용돼 있어야 함**. apply-local-ddl.sh 알파벳 글롭: game-reviews-ddl(`g`) < jam-eval-ddl(`j...e`) 선존재. 본 W2-6 은 DDL 추가 0(소비만).
|
||||||
|
2. **선행 코드 의존**: W2-4(jam_score_stats 채우는 점수 입력) + W2-5(jam_votes 채우는 투표) 배포 후라야 산정이 의미 있음(소스 비면 트랙 모집단 0). 단 **DDL/스키마는 W2-3 동결로 이미 존재**하므로 W2-6 코드는 W2-4/5 코드 배포와 독립 컴파일·배포 가능(빈 소스면 빈 결과 산정 — 무해).
|
||||||
|
3. **코드 배포**: AW-DOMAIN → AW-MAPPER → AW-ADMIN/AW-PUBLIC. AW-DETAIL(jam-detail 수상요약)은 W2-1 소유 파일 수정이므로 W2-1 소유자와 동시 PR/협의(concerns crossRef).
|
||||||
|
4. **권한 시드 불필요**: GAME_JAM_MANAGE 키는 PermissionCatalogVerifier 가 이미 시드(grounding R-A). 본 설계는 게이트 소비만.
|
||||||
|
|
||||||
|
### 역호환
|
||||||
|
- 신규 매퍼/컨트롤러/JSP 만 추가. 기존 games/리뷰/잼 동작 불변. jam_awards 는 W2-3 이 생성한 빈 테이블 → 미산정 잼은 결과 페이지 "준비 중"(빈 조회).
|
||||||
|
- jam-detail.jsp 수상요약 추가는 모델 키 신규(빈 잼 빈 컬렉션) → 기존 표시 회귀 0.
|
||||||
|
|
||||||
|
### 롤백
|
||||||
|
- 코드 롤백: JamAwardService/컨트롤러/JSP 되돌리면 산정·결과 미노출. jam_awards 데이터는 잔존(비파괴) — 무해. jam-detail awardsSummary 모델 제거 시 JSP 분기가 빈 컬렉션 처리하면 안전(W2-1 협의 시 빈 처리 명시).
|
||||||
|
- 데이터 롤백: jam_awards 행은 `deleteByJamTrack` 또는 maintenance DELETE 로 제거 가능(잼 재산정으로 덮어쓰기 멱등).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AC 매핑
|
||||||
|
|
||||||
|
| AC | 요구(골자 W2-6) | 만족 설계 요소 | 비고 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AC-1 | 3트랙 독립 산정(JUDGE/USER_RATING/POPULAR) | JamAwardService trackJudge/trackUserRating/trackPopular + jam_awards 트랙별 insert | A1, S2 |
|
||||||
|
| AC-2 | JUDGE = jam_score_stats.weighted_total | JamScoreStatsMapper.listStatsByJam 소비, weightedTotal DESC rank | A1, W2-3 F7 |
|
||||||
|
| AC-3 | USER_RATING = avg_rating(overall) 단방향, review_count>=3, NULLS LAST | JamReviewRatingMapper.listAvgByJam(game_review_stats SELECT 만, 6축 미사용) | A1/A4, W2-3 G4/F5 |
|
||||||
|
| AC-4 | POPULAR = jam_votes count | JamVoteCountMapper.listCountsByJam(GROUP BY game_id count) | A1, W2-3 F7 |
|
||||||
|
| AC-5 | GRAND 종합(정규화 가중합, 동점·NULL 규칙) | trackGrand rankScore 정규화 + 가용트랙 가중 + competition rank | A2/A4/A5, S3, 난제1 |
|
||||||
|
| AC-6 | NULL/미달 트랙 제외(부당 0점 회피) | 트랙별 모집단 필터(채점0/review<3/득표0) + GRAND 가용트랙만 | A4, 난제2 |
|
||||||
|
| AC-7 | 확정 시점 CLOSED/eval종료 후 | F6 게이트(status='CLOSED' OR now>eval_end_at) 아니면 422 | A6, S1 |
|
||||||
|
| AC-8 | 재산정 멱등 | deleteByJamTrack 4트랙 → 재INSERT(@Transactional 단일) | A6, S1 |
|
||||||
|
| AC-9 | 산정 트리거 = GAME_JAM_MANAGE 게이트 + CSRF | JamAwardAdminController requireJamManage(gate.has) + CsrfTokens.isValid | A7, §게이트연동 |
|
||||||
|
| AC-10 | 결과 표시(상세 수상 + 결과 페이지 트랙별+종합) | /jams/{slug}/results(jam-results JSP) + jam-detail awardsSummary | A8, S4 |
|
||||||
|
| AC-11 | 시상 SQL `${}` 0 | 신규 3매퍼 `#{}` only | §파일영향맵 |
|
||||||
|
| AC-12 | 집계 VIEW 매퍼 alias 큰따옴표 | JamReviewRatingMapper(game_review_stats)·JamScoreStatsMapper(jam_score_stats) AS "avgRating"/"weightedTotal" | §파일영향맵, verification §33 |
|
||||||
|
| AC-13 | 단방향(리뷰/심사/투표 write 0) | 산정은 4소스 SELECT + jam_awards write 만. game_reviews/jam_scores/jam_votes 무변경 | G3, S1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 포인트 (verification-advisor 점검 대상)
|
||||||
|
|
||||||
|
> L레벨 매핑(verification-strategies): 산정 알고리즘(3트랙·GRAND·NULL·동점·멱등) = **L1**(JamAwardServiceTest 단위) + 3트랙 join/정렬/NULL = **L2(dev DB contract)**. 인가/게이트/F6 422 = **L1+L3**. 신규 컨트롤러·매퍼 의존 = full `./mvnw -o test` 의무(§30).
|
||||||
|
|
||||||
|
### 시나리오 검증
|
||||||
|
- **VP-1 (AC-1~5 산정 코어, L1)**: JamAwardServiceTest — ① 3트랙 각 정렬·rank 정확(weightedTotal/avgRating/voteCount DESC) ② GRAND rankScore (N-rank+1)/N + 가용트랙 가중합 정확 ③ 1트랙만 가용 출품작이 GRAND 에 포함(분모=그 트랙 weight) ④ 모킹 소스로 결정적.
|
||||||
|
- **VP-2 (AC-6 NULL/미달, L1+L2)**: 리뷰 0개(avg_rating NULL) 출품작 → USER_RATING 트랙 제외(rank 없음). review_count==3 경계 포함, ==2 제외. 채점 0 출품작 JUDGE 제외. 득표 0 출품작 POPULAR rankScore 모집단 제외. GRAND 는 그래도 가용트랙으로 산정. dev DB contract 샘플 실측.
|
||||||
|
- **VP-3 (AC-5 동점, L1)**: weightedTotal 동일 2작품 → 같은 rank(competition 1,2,2,4), tiebreak game_id ASC 결정적. GRAND 동점도 동일. 재산정 시 같은 결과(멱등).
|
||||||
|
- **VP-4 (AC-8 멱등, L1+L2)**: recompute 2회 호출 → jam_awards 행이 중복 누적 아님(deleteByJamTrack 선행). UNIQUE(jam_id,track,game_id) 위반 없음. 부분 실패 시 트랜잭션 롤백(트랙 비는 상태 회피).
|
||||||
|
- **VP-5 (AC-7/9 게이트·F6, L1+L3)**: JamAwardAdminControllerTest — ADMIN 통과 / SUBADMIN+GAME_JAM_MANAGE 통과 / SUBADMIN 무키 403 / 미인증 401 / CLOSED 아님+eval진행중 422 / CSRF 누락 403(mapper 미호출). L3 스모크: CLOSED 잼 산정 → 결과 페이지 노출.
|
||||||
|
- **VP-6 (AC-12/13 alias·단방향, L2)**: JamReviewRatingMapper 반환 키 avgRating/reviewCount 정합(game_review_stats 큰따옴표 케이스폴딩 BUG-2 선례 회피, GameReviewStatsMapper.java:14 `AS "avgRating"` 동형). 산정 SQL 이 game_reviews/jam_scores/jam_votes 에 write 0(SELECT 만). 6축 컬럼 미참조.
|
||||||
|
- **VP-7 (contextLoads, L1)**: BibimbapApplicationTests 에 신규 3매퍼 @MockBean 등록 후 PASS(§30). 누락 시 NoSuchBeanDefinitionException.
|
||||||
|
|
||||||
|
### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용)
|
||||||
|
> self-audit(시점): 아래 카운트는 **본 W2-6 이 신규 생성하는 정적 산출물**(트랙 enum·매퍼·산정 트랙 경로) 또는 **W2-3 동결 불변식**(jam_awards CHECK 트랙값)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리처럼 증가하는 대상 아님. jam_awards 스키마는 W2-3 동결(소유 분리) 이므로 본 verification 시점에 트랙 4값 불변.
|
||||||
|
> self-audit(표현): 단일 리터럴 grep 취약성을 피해 enum 멤버/CHECK IN 목록/트랙 산정 메서드 같은 **구조적 불변식**에 앵커. 매퍼 `${` 0건만 리터럴(부재 검증은 리터럴이 정당).
|
||||||
|
|
||||||
|
- **AC-T1 시상 트랙 전수 4종 산정 경로 정합 불변식** — `AwardTrack` enum 멤버 수 == jam_awards_track_check CHECK IN 항목 수(W2-3 동결) == JamAwardService 가 insert 하는 award_track 집합 == 4(JUDGE/USER_RATING/POPULAR/GRAND). 검증: enum 멤버 `grep -c` == 4 AND JamAwardService 에 trackJudge/trackUserRating/trackPopular/trackGrand 4 산정 경로 전수 존재(수동 판정: 각 트랙이 jamAwardsMapper.insert(award_track=...) 호출). 트랙 추가/누락을 갯수 1로 동시 커버. **이 전수 AC 가 3트랙+GRAND 완전성의 핵심 가드**(1트랙 누락 시 시상 결과 불완전).
|
||||||
|
- **AC-T2 산정 소스 매퍼 전수 4종 SELECT-only 단방향 불변식** — 4트랙 소스(jam_score_stats/game_review_stats/jam_votes + jam_entries 모집단)는 본 산정에서 **읽기만**: 신규 산정 매퍼(JamReviewRatingMapper/JamVoteCountMapper)+소비(JamScoreStatsMapper.listStatsByJam) 에 INSERT/UPDATE/DELETE 가 game_reviews/jam_scores/jam_votes 대상으로 0건. 검증: `grep -iE 'INSERT|UPDATE|DELETE' <산정 소스 매퍼들>` 중 game_reviews/jam_scores/jam_votes 대상 0(jam_awards 대상 INSERT/DELETE 만 허용). 단방향(G4/AC-13) 위반 즉시 검출.
|
||||||
|
- **AC-T3 시상 신규 매퍼 전수 3개 `${` 0건** — 신규 매퍼 3파일(JamAwardsMapper/JamReviewRatingMapper/JamVoteCountMapper)에 `${` 매치 0: `grep -rc '\${' <매퍼 3파일>` == 0 (AC-11, `${}` 동적치환 금지). 부재 검증이라 리터럴 정당.
|
||||||
|
- **AC-T4 집계 VIEW 소비 매퍼 alias 큰따옴표 전수** — 집계 VIEW 를 소비하는 매퍼 전수(JamReviewRatingMapper→game_review_stats, JamScoreStatsMapper→jam_score_stats)가 camelCase alias 를 큰따옴표로 감쌈: 해당 매퍼들의 VIEW 컬럼 alias 가 `AS "avgRating"`/`AS "reviewCount"`/`AS "weightedTotal"` 형태(케이스 폴딩 회피, GameReviewStatsMapper.java:14 선례). 검증: 집계 VIEW 컬럼 alias 전수가 큰따옴표 — 일반 테이블 매퍼(JamAwardsMapper)는 반대로 직접 alias(scoreValue) 사용 확인(혼용 금지). 수동 판정(VIEW vs 테이블 구분 필요 — 리터럴 grep 단독 회피).
|
||||||
|
- **AC-T5 신규 시상 매퍼 @MockBean 전수 3건** — BibimbapApplicationTests 에 신규 3 매퍼(JamAwardsMapper/JamReviewRatingMapper/JamVoteCountMapper) @MockBean 전수 등록: contextLoads PASS AND 매퍼별 등록 수동 확인(또는 `grep -c '@MockBean' BibimbapApplicationTests.java 증가분 == 3`). 1건 누락 시 contextLoads NoSuchBeanDefinitionException 으로 verification 시점 즉시 검출(§30).
|
||||||
|
- **AC-T6 jam_awards 단방향 소비 무결성(신규 DDL 0)** — 본 W2-6 이 신규 DDL 파일 0건 추가: `docs/` 에 W2-6 신규 *-ddl.sql 0개(jam_awards 는 W2-3 docs/jam-eval-ddl.sql 소유). 검증: 본 워크스트림 산출물에 신규 docs/*-ddl.sql 0, db/schema.sql 변경 0(jam_awards 재정의 금지). W2-3 동결 스키마를 W2-6 이 변형하지 않음(소유 경계) 확인.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 잔여 오픈 질문
|
||||||
|
없음(0). 확정 결정 A1~A8 전제 고정, 세 난제(GRAND 순위점수 정규화·NULL/미달 트랙 제외 산정위치·동점 competition rank)는 본 설계가 구체 메커니즘으로 확정. score_value 트랙별 채움 규약·산정 게이트(F6 422)·재산정 멱등(@Transactional deleteByJamTrack)도 확정. 구현 점검 항목(시그니처 inflate·full-test @MockBean·집계 VIEW alias L2·임계/가중치 상수 가변화·@Transactional 경계·W2-1 jam-detail 협의 수정·JamVoteCountMapper vs W2-5 JamVotesMapper 재사용)은 오픈 질문이 아니라 `concerns` 로 이관.
|
||||||
|
|
@ -0,0 +1,485 @@
|
||||||
|
---
|
||||||
|
phase: design
|
||||||
|
agent: design-advisor
|
||||||
|
agent_version: 1
|
||||||
|
generated_at: 2026-06-23T10:43:07+09:00
|
||||||
|
concerns:
|
||||||
|
- "TAG_MANAGE 신규 권한키 vs 기존 CONTENT_MODERATE 재사용: §5.2/§13 에서 CONTENT_MODERATE 재사용으로 확정했으나, 향후 태그 전담 운영자 분리 요구가 생기면 PermissionKeys 에 TAG_MANAGE 추가 필요 — 구현 시 권한 게이트 호출부가 단일 상수 참조인지 재확인."
|
||||||
|
- "searchVisibleGames 확장 시그니처(SearchCriteria): 파라미터(keyword, creator, tags, tagMode, tagCount, sort, jamSlug)가 구현에서 전부 실제 사용되는지 재확인 필요 — 특히 jamSlug 는 잼 검색 경로에서만 사용되므로 단일 진입점 통합 시 null 분기가 dead path 가 되지 않는지, tagCount 는 AND 모드에서만 참조되므로 OR/미선택 시 unused 가 되지 않는지 점검."
|
||||||
|
- "신규 매퍼 메서드 시그니처 inflate 점검: GameTagsMapper.listGameIdsByTags(tagSlugs, tagMode, tagCount), GameViewsMapper.existsRecentView(gameId, viewerKey, sinceTs) 의 인자가 구현에서 전부 사용되는지 재확인 — tagMode 분기를 매퍼 내부 <choose> 로 흡수하면 tagCount 가 OR 경로에서 dead 가 됨. 매퍼를 AND/OR 두 메서드로 분리하는 편이 dead parameter 를 줄일 수 있음(구현 advisor 판단)."
|
||||||
|
- "검색 결과 행 매핑 타입(SearchRow vs GameData 확장): §8 에서 GameData 에 viewCount/avgRating/reviewCount 필드 추가 방향을 제안하나, 기존 GameData 소비처(목록·상세 JSP)에서 신규 필드가 null 로 남는 경로가 생기면 NPE/표시 누락 위험 — 구현 시 GameData 확장 vs 전용 SearchRow 중 소비처 영향 최소안으로 확정."
|
||||||
|
concerns_checked: true
|
||||||
|
references:
|
||||||
|
requirements: .atp/work-session/20260623-104307/requirements.md
|
||||||
|
research: null
|
||||||
|
adrs:
|
||||||
|
- docs/development/agent-team-protocol.md
|
||||||
|
- docs/rbac-ddl.sql
|
||||||
|
---
|
||||||
|
|
||||||
|
# 설계: W3-1 게임 태그 + 검색 확장
|
||||||
|
|
||||||
|
## ① 목표 / 비목표
|
||||||
|
|
||||||
|
### 목표 (FR)
|
||||||
|
- **FR-1 태그 도메인 도입**: 게임·잼에 부착 가능한 통합 태그 체계를 제공한다. 운영자 사전정의 태그(활성)와 사용자 생성 태그(pending→승인) 하이브리드를 지원한다.
|
||||||
|
- **FR-2 검색 확장**: 기존 `searchVisibleGames`(이름/제작자/노트 ILIKE) 를 ① 태그 다중 필터(AND/OR) ② 제작자(개발자) 검색 ③ 정렬키(최신/좋아요/방문수/리뷰수/평점/태그일치도) 로 확장한다.
|
||||||
|
- **FR-3 방문수 정렬**: 방문 로그(`game_views`) + 비정규화 카운터(`games.view_count`) 기반 방문수 정렬을 제공한다. 동일 viewer 24h 윈도 dedupe.
|
||||||
|
- **FR-4 리뷰 정렬**: `game_review_stats` VIEW 를 LEFT JOIN 하여 리뷰수(`review_count`)·평점(`avg_rating`) 정렬을 제공한다.
|
||||||
|
- **FR-5 태그 관리 API**: 태그 생성(사용자/운영자)·승인·비활성 API 를 권한 게이트 + CSRF 로 보호한다.
|
||||||
|
- **FR-6 잼 태그 검색 라우트**: 진행중 잼 배너에서 링크할 단일 검색 라우트(잼 출품작 한정)를 단일 출처로 확정한다. (W3-4 가 이 계약을 채택)
|
||||||
|
|
||||||
|
### 목표 (NFR)
|
||||||
|
- **NFR-1 정규화**: 통합 `tags` 1테이블 + 용도구분(`tag_type`) + 조인 테이블(`game_tags`/`jam_tags`)로 N:M 정규화. 다대다 중복 방지 UNIQUE.
|
||||||
|
- **NFR-2 멱등 DDL**: `docs/tag-ddl.sql` 신규 — `CREATE TABLE IF NOT EXISTS` / `CREATE UNIQUE INDEX IF NOT EXISTS` / `ALTER ... ADD COLUMN IF NOT EXISTS` / `DO $$` guard. `apply-local-ddl.sh` 알파벳순·`search_path=dev` 멱등 적용. `schema.sql` 동기 사본 갱신.
|
||||||
|
- **NFR-3 보안**: 태그 입력 sanitize(XSS/주입) + 금칙어 필터 + 길이/화이트리스트 제약. 검색어는 `#{}` 바인딩 + ILIKE 파라미터화(`${}` 금지). 태그 관리 권한 게이트 + CSRF.
|
||||||
|
- **NFR-4 확장성**: 태그 타입(`GAME`/`JAM`/`COMMON`)·정렬키·검색 모드를 enum/CHECK 로 모델링하여 후속 도메인(post 등) 확장 시 스키마 변경 최소화.
|
||||||
|
|
||||||
|
### 비목표
|
||||||
|
- 태그 자동 추천/연관 태그 그래프 (추후 별도 워크).
|
||||||
|
- 검색 형태소 분석·전문(full-text) 검색 엔진 도입 — 본 워크는 ILIKE + 인덱스 범위. (검색량 증가 시 별도 ADR)
|
||||||
|
- 태그별 통계 대시보드 / 트렌딩 태그 — 비목표.
|
||||||
|
- 잼 도메인 자체 구현(W2-1 소관). 본 설계는 `jams`/`jam_entries` 를 **참조만** 한다.
|
||||||
|
- post(게시판) 태그 — `tag_type='COMMON'`/`'POST'` 확장 여지만 두고 본 워크 스코프 외.
|
||||||
|
|
||||||
|
## ② 개요
|
||||||
|
|
||||||
|
현재 게임 검색은 `GamesMapper.searchVisibleGames`(GamesMapper.java:85-87)가 `name`·`users.display_name`·`creator_note` 3컬럼에 ILIKE 부분일치를 거는 단일 자유텍스트 검색이다. 정렬은 목록 기본(`getVisibleGames`, GamesMapper.java:62)의 `sort_order ASC, created_at DESC, id DESC` 고정이며, 사용자가 좋아요·방문수·리뷰·평점 기준으로 탐색할 수단이 없다. `games` 테이블에는 태그·방문수 컬럼이 존재하지 않는다.
|
||||||
|
|
||||||
|
이 설계는 (a) 통합 태그 도메인을 신규 도입하고, (b) 검색을 태그 필터 + 제작자 검색 + 다중 정렬키로 확장하며, (c) 방문수 집계 인프라(`game_views` 로그 + `games.view_count` 비정규화)와 (d) 리뷰 정렬(`game_review_stats` VIEW 소비)을 연결한다. 또한 인접 워크 W2-1(잼) 및 W3-4(진행중 잼 배너)와의 계약으로 **잼 태그 검색 라우트를 단일 출처로 확정**한다.
|
||||||
|
|
||||||
|
설계 정석 기준: 정규화(통합 1테이블 + 조인) · 확장성(타입/정렬키 enum 화) · 보안(입력 sanitize, 파라미터 바인딩, 권한 게이트) 우선. 단순 카운터 단축이나 태그 문자열 컬럼 같은 비정규화 over-engineering 회피는 명시적으로 배제(아래 ③ 핵심결정 근거).
|
||||||
|
|
||||||
|
## ③ 핵심 결정 요약표
|
||||||
|
|
||||||
|
| # | 결정 | 채택안 | 근거 | 기각안 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| D1 | 태그 저장 모델 | 통합 `tags` 1테이블 + `tag_type` 구분 + `game_tags`/`jam_tags` 조인 | 정규화·N:M·도메인 확장 시 스키마 안정. 태그명 검색/관리 단일 출처 | 도메인별 태그 테이블 분리(중복·관리 분산), `games.tags` 텍스트 컬럼(검색·정규화 불가) |
|
||||||
|
| D2 | 태그 생성 정책 | 운영자 사전정의(`is_active=true`) + 사용자 생성(pending→승인) 하이브리드 | 초기 카탈로그 보장 + 사용자 확장. 무분별 태그 난립을 승인 흐름으로 차단 | 운영자 전용(확장성↓), 무승인 자유생성(스팸·중복·XSS 위험) |
|
||||||
|
| D3 | 태그 입력 검증 | 길이 2~20 + 화이트리스트(한/영/숫자/하이픈) + 금칙어 사전 + sanitize | XSS/주입 차단, 표기 정규화(slug). 금칙어 출처는 §아래 명시 | 자유 입력(보안·정규화 실패) |
|
||||||
|
| D4 | 태그 관리 권한 | 기존 `CONTENT_MODERATE` 재사용 (PermissionGate.has) | W1 권한 인프라 재사용, 신규 키 도입 최소화. 전담 분리 요구 시 TAG_MANAGE 추가(concern 기록) | 신규 `TAG_MANAGE` 키 즉시 도입(인프라 변경 비용↑, 현 시점 불필요) |
|
||||||
|
| D5 | 방문수 집계 | `game_views` 로그(viewer_key, 24h dedupe) + `games.view_count` 비정규화 카운터 | dedupe 가능·감사 추적 가능. 정렬은 비정규화 컬럼으로 O(1) | 단순 `view_count++`(중복·어뷰징·감사불가), 로그 only(정렬 시 매번 COUNT 집계 비용) |
|
||||||
|
| D6 | 리뷰 정렬 소스 | `game_review_stats` VIEW LEFT JOIN | 읽기전용 집계 VIEW 재사용, 6축은 표시전용·정렬은 `avg_rating`/`review_count` 단일 | 매 쿼리 reviews 재집계(중복·성능) |
|
||||||
|
| D7 | 다중 태그 모드 | 쿼리 파라미터 `tagMode=and\|or` (기본 and) | 사용자가 교집합/합집합 선택. AND=전부 보유, OR=하나라도 보유 | 고정 AND(유연성↓) |
|
||||||
|
| D8 | NULL 정렬 | 평점/리뷰수 NULL `NULLS LAST` | 미평가 게임이 상위 점유 방지 | 기본 NULLS FIRST(UX 저해) |
|
||||||
|
| D9 | 잼 태그 검색 라우트 | `GET /games/search?jam={jamSlug}` 파라미터 통합(전용 경로 아님) | 단일 검색 진입점 유지, `jam_entries` 조인으로 출품작 한정. W3-4 가 이 계약 채택 | 전용 경로 `/jams/{slug}/games`(검색 로직 중복) |
|
||||||
|
|
||||||
|
## ④ 데이터 모델
|
||||||
|
|
||||||
|
### 4.1 신규 DDL — `docs/tag-ddl.sql` (전문, 멱등)
|
||||||
|
|
||||||
|
> 적용: `apply-local-ddl.sh` 가 `docs/*-ddl.sql` 을 알파벳순으로 `search_path=dev` 멱등 적용. `schema.sql` 에 동기 사본 반영(아래 4.3). 선례: W1 `docs/rbac-ddl.sql`.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- docs/tag-ddl.sql
|
||||||
|
-- W3-1 태그 + 검색 확장. 멱등(IF NOT EXISTS / DO $$ guard). search_path=dev.
|
||||||
|
|
||||||
|
-- 1) 통합 태그 마스터
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(20) NOT NULL, -- 표시명. 길이 2~20 (앱 검증 + CHECK)
|
||||||
|
slug VARCHAR(40) NOT NULL, -- 정규화 키(소문자/하이픈). 검색·URL 안정
|
||||||
|
tag_type VARCHAR(10) NOT NULL, -- 'GAME' | 'JAM' | 'COMMON'
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT FALSE, -- 운영자 사전정의=true, 사용자 생성=false(pending)
|
||||||
|
created_by BIGINT NULL, -- FK users.id (운영자 시드는 NULL 허용)
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 태그명 길이·타입 CHECK (멱등: DO $$ guard 로 중복 추가 방지)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_tags_name_len') THEN
|
||||||
|
ALTER TABLE tags ADD CONSTRAINT chk_tags_name_len
|
||||||
|
CHECK (char_length(name) BETWEEN 2 AND 20);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_tags_type') THEN
|
||||||
|
ALTER TABLE tags ADD CONSTRAINT chk_tags_type
|
||||||
|
CHECK (tag_type IN ('GAME', 'JAM', 'COMMON'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- created_by FK (users 존재 전제. 멱등 guard)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_tags_created_by') THEN
|
||||||
|
ALTER TABLE tags ADD CONSTRAINT fk_tags_created_by
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 태그명/slug unique (활성 여부 무관 전역 유일 — 중복 생성 차단)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_tags_slug ON tags (slug);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_tags_name ON tags (name);
|
||||||
|
-- 타입+활성 필터 인덱스(태그 목록/자동완성 조회)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tags_type_active ON tags (tag_type, is_active);
|
||||||
|
|
||||||
|
-- 2) 게임-태그 조인 (N:M)
|
||||||
|
CREATE TABLE IF NOT EXISTS game_tags (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
game_id BIGINT NOT NULL, -- FK games.id
|
||||||
|
tag_id BIGINT NOT NULL, -- FK tags.id
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_game_tags ON game_tags (game_id, tag_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_game_tags_tag ON game_tags (tag_id); -- 태그→게임 역검색
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_game_tags_game') THEN
|
||||||
|
ALTER TABLE game_tags ADD CONSTRAINT fk_game_tags_game
|
||||||
|
FOREIGN KEY (game_id) REFERENCES games(id);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_game_tags_tag') THEN
|
||||||
|
ALTER TABLE game_tags ADD CONSTRAINT fk_game_tags_tag
|
||||||
|
FOREIGN KEY (tag_id) REFERENCES tags(id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 3) 잼-태그 조인 (N:M, jams 는 W2-1 소관)
|
||||||
|
CREATE TABLE IF NOT EXISTS jam_tags (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
jam_id BIGINT NOT NULL, -- FK jams.id
|
||||||
|
tag_id BIGINT NOT NULL, -- FK tags.id
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_jam_tags ON jam_tags (jam_id, tag_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_jam_tags_tag ON jam_tags (tag_id);
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_jam_tags_jam') THEN
|
||||||
|
ALTER TABLE jam_tags ADD CONSTRAINT fk_jam_tags_jam
|
||||||
|
FOREIGN KEY (jam_id) REFERENCES jams(id);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_jam_tags_tag') THEN
|
||||||
|
ALTER TABLE jam_tags ADD CONSTRAINT fk_jam_tags_tag
|
||||||
|
FOREIGN KEY (tag_id) REFERENCES tags(id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 4) 방문 로그 (dedupe 가능)
|
||||||
|
CREATE TABLE IF NOT EXISTS game_views (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
game_id BIGINT NOT NULL, -- FK games.id
|
||||||
|
viewer_key VARCHAR(200) NOT NULL, -- 세션/해시 식별자(로그인=userId, 비로그인=익명 해시)
|
||||||
|
viewed_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_game_views_game ON game_views (game_id);
|
||||||
|
-- 24h dedupe 조회용(game_id + viewer_key + 시간범위)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_game_views_dedupe ON game_views (game_id, viewer_key, viewed_at);
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_game_views_game') THEN
|
||||||
|
ALTER TABLE game_views ADD CONSTRAINT fk_game_views_game
|
||||||
|
FOREIGN KEY (game_id) REFERENCES games(id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 5) games 비정규화 방문수 카운터 (기존 테이블 변경: ADD COLUMN IF NOT EXISTS)
|
||||||
|
ALTER TABLE games ADD COLUMN IF NOT EXISTS view_count INTEGER NOT NULL DEFAULT 0;
|
||||||
|
-- 방문수 정렬 인덱스
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_games_view_count ON games (view_count);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 금칙어 사전 출처 (D3)
|
||||||
|
|
||||||
|
- 금칙어 필터는 애플리케이션 레이어에서 적용한다(DDL 외). 출처는 `src/main/resources/banned-words.txt`(신규, 1줄 1단어, 소문자 비교) 를 단일 출처로 한다. 태그 sanitize 단계(§part2 §8 검증 흐름)에서 slug 화 후 부분/완전 매치 검사. (구현 advisor 가 시드 목록 확정 — 본 설계는 위치·매칭 규칙만 확정)
|
||||||
|
|
||||||
|
### 4.3 `schema.sql` 동기 위치
|
||||||
|
|
||||||
|
- `schema.sql` 은 DDL 권위(`docs/tag-ddl.sql`)의 동기 사본. 위 4.1 의 5개 블록(tags / game_tags / jam_tags / game_views / games.view_count ALTER)을 동일 멱등 형태로 추가한다.
|
||||||
|
- 추가 지점: 기존 `games` 정의 블록 직후(view_count ALTER) + `users`/`games`/`jams` 정의 이후(FK 의존 순서 보장). 적용 순서는 `apply-local-ddl.sh` 알파벳순에 의존하지 않도록 모든 블록이 멱등·FK guard 처리됨.
|
||||||
|
|
||||||
|
## ⑤ 외부 계약 (API)
|
||||||
|
|
||||||
|
> 규약: 읽기=JSP 뷰이름 반환, 쓰기=`ResponseEntity<JSON>`(RecruitController 패턴). 상태변경=`CsrfTokens.isValid` 검증. 태그 관리 쓰기=`PermissionGate.has(session, CONTENT_MODERATE)` 게이트.
|
||||||
|
|
||||||
|
### 5.1 검색 API (읽기)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /games/search
|
||||||
|
```
|
||||||
|
|
||||||
|
| 파라미터 | 타입 | 용도 | 기본값 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `keyword` | String? | 자유텍스트 — name/display_name/creator_note ILIKE | null |
|
||||||
|
| `creator` | String? | 제작자(개발자) 검색 — users.display_name ILIKE 한정 | null |
|
||||||
|
| `tags` | String? | 태그 slug CSV (예: `rpg,horror`) | null |
|
||||||
|
| `tagMode` | String | 다중 태그 결합 `and`\|`or` | `and` |
|
||||||
|
| `sort` | String | 정렬키 `latest`\|`likes`\|`views`\|`reviews`\|`rating`\|`relevance` | `latest` (태그 선택 시 `relevance` 가중 허용) |
|
||||||
|
| `jam` | String? | 잼 slug — 지정 시 jam_entries 조인으로 해당 잼 출품작 한정 (W3-4 타깃) | null |
|
||||||
|
|
||||||
|
- 응답: 검색 결과 JSP 뷰(`game-search` 또는 기존 목록 뷰 재사용) + model 에 결과 리스트/페이징/선택 태그.
|
||||||
|
- 보안: 모든 텍스트 파라미터는 MyBatis `#{}` 바인딩 + ILIKE. `${}` 동적 치환 금지. `tags` CSV 는 서버에서 slug 화이트리스트 검증 후 `foreach` 바인딩.
|
||||||
|
|
||||||
|
### 5.2 태그 관리 API (쓰기 — 권한 게이트 + CSRF)
|
||||||
|
|
||||||
|
| Method | Path | 권한 | 용도 | 요청 | 응답 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `POST` | `/tags` | 로그인(일반) | 사용자 태그 생성(pending: is_active=false) | `{name, tagType}` + CSRF | `ResponseEntity<{id, name, slug, isActive:false}>` |
|
||||||
|
| `POST` | `/admin/tags` | `CONTENT_MODERATE` | 운영자 태그 생성(즉시 활성) | `{name, tagType}` + CSRF | `ResponseEntity<{id, ..., isActive:true}>` |
|
||||||
|
| `POST` | `/admin/tags/{id}/approve` | `CONTENT_MODERATE` | pending 태그 승인(is_active=true) | CSRF | `ResponseEntity<{id, isActive:true}>` |
|
||||||
|
| `POST` | `/admin/tags/{id}/deactivate` | `CONTENT_MODERATE` | 태그 비활성(is_active=false) | CSRF | `ResponseEntity<{id, isActive:false}>` |
|
||||||
|
| `POST` | `/games/{gameId}/tags` | 게임 소유자 또는 `CONTENT_MODERATE` | 게임에 태그 부착(game_tags upsert) | `{tagIds[]}` + CSRF | `ResponseEntity<{gameId, tagIds[]}>` |
|
||||||
|
| `GET` | `/tags` | 공개 | 활성 태그 목록/자동완성(tag_type 필터) | `?type=GAME&q=` | `ResponseEntity<List<{id,name,slug}>>` |
|
||||||
|
|
||||||
|
- 사용자 생성(`POST /tags`)은 sanitize + 금칙어 + 길이/화이트리스트 검증 통과 시에만 pending 저장. 검증 실패는 400.
|
||||||
|
- 모든 상태변경(`POST`)은 `CsrfTokens.isValid` 선검증. 실패 403.
|
||||||
|
- 권한 결정(D4): 기존 `PermissionKeys.CONTENT_MODERATE` 재사용. (TAG_MANAGE 신규 도입은 concern 기록 — 향후 분리 시)
|
||||||
|
|
||||||
|
### 5.3 잼 태그 검색 라우트 — 단일 출처 확정 (W3-4 타깃)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /games/search?jam={jamSlug}&tags={tagSlugCsv}&tagMode=and&sort=latest
|
||||||
|
```
|
||||||
|
|
||||||
|
- **확정 계약**: 잼 검색은 별도 경로가 아니라 `GET /games/search` 의 `jam` 파라미터로 통합한다. `jam` 지정 시 `jam_entries`(W2-1: `jam_id` FK, `game_id` FK) 조인으로 해당 잼 출품작만 결과에 포함한다.
|
||||||
|
- W3-4(진행중 잼 배너)는 배너 링크 타깃을 `/games/search?jam={진행중잼.slug}` 로 구성한다. 추가로 잼별 추천 태그를 붙이려면 `&tags=` 를 부착(jam_tags 에서 조회).
|
||||||
|
- `jamSlug` 미해석/존재하지 않을 경우 빈 결과 + 안내(비-에러).
|
||||||
|
|
||||||
|
## ⑥ 검색 쿼리 설계
|
||||||
|
|
||||||
|
> MyBatis @Mapper, `#{}` 바인딩 전용, `${}` 금지. 동적 절은 `<script>`/`<if>`/`<foreach>` 로 구성. `game_review_stats` VIEW 는 camelCase alias 큰따옴표 필요(`"avgRating"`) — 단, 정렬/필터에 쓰는 컬럼은 VIEW 의 원본 컬럼명(`avg_rating`, `review_count`) 으로 참조하고 별칭은 select projection 에서만 사용.
|
||||||
|
|
||||||
|
### 6.1 확장 쿼리 골격 (searchVisibleGames 확장)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT g.id, g.user_id, g.name, g.creator_note, g.git_url, g.webgl_path,
|
||||||
|
g.thumbnail_url, g.like_count, g.is_visible, g.sort_order,
|
||||||
|
g.created_at, g.updated_at, g.view_count,
|
||||||
|
u.display_name AS "displayName",
|
||||||
|
grs.avg_rating AS "avgRating", -- VIEW: case-folding 회피 큰따옴표
|
||||||
|
grs.review_count AS "reviewCount"
|
||||||
|
FROM games g
|
||||||
|
JOIN users u ON u.id = g.user_id
|
||||||
|
LEFT JOIN game_review_stats grs ON grs.game_id = g.id
|
||||||
|
-- [잼 필터] jam 지정 시에만
|
||||||
|
<if test="jamSlug != null">
|
||||||
|
JOIN jam_entries je ON je.game_id = g.id
|
||||||
|
JOIN jams j ON j.id = je.jam_id AND j.slug = #{jamSlug}
|
||||||
|
</if>
|
||||||
|
WHERE g.is_visible IS NOT FALSE
|
||||||
|
AND g.is_delete IS NOT TRUE
|
||||||
|
-- [자유텍스트] keyword
|
||||||
|
<if test="keyword != null">
|
||||||
|
AND ( g.name ILIKE ('%' || #{keyword} || '%')
|
||||||
|
OR u.display_name ILIKE ('%' || #{keyword} || '%')
|
||||||
|
OR g.creator_note ILIKE ('%' || #{keyword} || '%') )
|
||||||
|
</if>
|
||||||
|
-- [제작자] creator
|
||||||
|
<if test="creator != null">
|
||||||
|
AND u.display_name ILIKE ('%' || #{creator} || '%')
|
||||||
|
</if>
|
||||||
|
-- [태그 AND] 선택 태그 전부 보유: 매칭 distinct count == 선택 수
|
||||||
|
<if test="tags != null and tagMode == 'and'">
|
||||||
|
AND g.id IN (
|
||||||
|
SELECT gt.game_id FROM game_tags gt
|
||||||
|
JOIN tags t ON t.id = gt.tag_id AND t.is_active = TRUE
|
||||||
|
WHERE t.slug IN <foreach item="s" collection="tags" open="(" separator="," close=")">#{s}</foreach>
|
||||||
|
GROUP BY gt.game_id
|
||||||
|
HAVING COUNT(DISTINCT t.slug) = #{tagCount}
|
||||||
|
)
|
||||||
|
</if>
|
||||||
|
-- [태그 OR] 하나라도 보유
|
||||||
|
<if test="tags != null and tagMode == 'or'">
|
||||||
|
AND g.id IN (
|
||||||
|
SELECT gt.game_id FROM game_tags gt
|
||||||
|
JOIN tags t ON t.id = gt.tag_id AND t.is_active = TRUE
|
||||||
|
WHERE t.slug IN <foreach item="s" collection="tags" open="(" separator="," close=")">#{s}</foreach>
|
||||||
|
)
|
||||||
|
</if>
|
||||||
|
ORDER BY
|
||||||
|
/* 정렬키별 ORDER BY — §6.2 */
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 정렬키별 ORDER BY (동률 2차키 `id DESC` 공통)
|
||||||
|
|
||||||
|
| `sort` | ORDER BY 절 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| `latest` (기본) | `g.sort_order ASC, g.created_at DESC, g.id DESC` | 목록 기본과 동일 |
|
||||||
|
| `likes` | `g.like_count DESC, g.id DESC` | |
|
||||||
|
| `views` | `g.view_count DESC, g.id DESC` | 비정규화 컬럼 |
|
||||||
|
| `reviews` | `grs.review_count DESC NULLS LAST, g.id DESC` | VIEW LEFT JOIN, 미평가 NULLS LAST |
|
||||||
|
| `rating` | `grs.avg_rating DESC NULLS LAST, g.id DESC` | overall 단일 평점 |
|
||||||
|
| `relevance` | 태그 일치도 가중(아래) `DESC, g.created_at DESC, g.id DESC` | 태그 선택 시 허용 |
|
||||||
|
|
||||||
|
- **태그 일치도 가중(relevance)**: 태그 선택 시 매칭 태그 수를 점수로 사용.
|
||||||
|
```sql
|
||||||
|
ORDER BY (
|
||||||
|
SELECT COUNT(*) FROM game_tags gt2
|
||||||
|
JOIN tags t2 ON t2.id = gt2.tag_id AND t2.is_active = TRUE
|
||||||
|
WHERE gt2.game_id = g.id
|
||||||
|
AND t2.slug IN <foreach item="s" collection="tags" open="(" separator="," close=")">#{s}</foreach>
|
||||||
|
) DESC, g.created_at DESC, g.id DESC
|
||||||
|
```
|
||||||
|
- `sort` 는 서버에서 enum 화이트리스트로 매핑(미허용 값 → `latest` fallback). MyBatis `<choose>` 분기로 ORDER BY 조립 — **컬럼/방향은 매퍼 내부 고정 문자열**(사용자 입력이 `${}` 로 흘러들지 않음).
|
||||||
|
- `tagCount` 는 서버가 `tags` CSV 길이에서 산출해 바인딩(AND 모드 HAVING 비교용).
|
||||||
|
|
||||||
|
## ⑦ 시퀀스
|
||||||
|
|
||||||
|
### 7.1 태그 검색 플로우
|
||||||
|
1. 진입: `GET /games/search?keyword=&creator=&tags=&tagMode=&sort=&jam=` (Controller).
|
||||||
|
2. 분기 — 파라미터 정규화: `tags` CSV → slug 리스트(화이트리스트 검증, 비활성/미존재 slug 제거), `sort`/`tagMode` enum 매핑(미허용→fallback), `tagCount` 산출.
|
||||||
|
3. 분기 — `jam` 존재 시 jams.slug 해석. 미존재 → 빈 결과 반환.
|
||||||
|
4. 매퍼 호출: `searchVisibleGames(SearchCriteria)` — §6 동적 쿼리. `#{}` 바인딩, ILIKE.
|
||||||
|
5. VIEW LEFT JOIN 으로 reviewCount/avgRating projection, ORDER BY 정렬키 적용.
|
||||||
|
6. 종단: 결과 리스트 + 선택 태그 + 페이징을 model 에 담아 검색 JSP 뷰 반환.
|
||||||
|
|
||||||
|
### 7.2 태그 생성 → 승인 플로우
|
||||||
|
1. 사용자 진입: `POST /tags {name, tagType}` + CSRF.
|
||||||
|
2. CSRF 검증(`CsrfTokens.isValid`) 실패 → 403.
|
||||||
|
3. sanitize: trim/소문자 slug 화 → 길이 2~20 검사 → 화이트리스트(한/영/숫자/하이픈) 검사 → 금칙어 사전(`banned-words.txt`) 매치 검사. 위반 → 400.
|
||||||
|
4. 중복 검사: `tags.slug`/`name` UNIQUE — 이미 존재 시 기존 태그 반환(또는 409).
|
||||||
|
5. 저장: `is_active=false`(pending), `created_by=session.userId`. → pending 응답.
|
||||||
|
6. 운영자 진입: `POST /admin/tags/{id}/approve` + CSRF → `PermissionGate.has(session, CONTENT_MODERATE)` 게이트. 미충족 → 403.
|
||||||
|
7. 승인: `is_active=true` 갱신. 종단: 활성 태그 검색·부착 가능.
|
||||||
|
8. (운영자 직접 생성 `POST /admin/tags` 는 3~4 검증 후 즉시 `is_active=true` 저장 — 단계 5~7 단축.)
|
||||||
|
|
||||||
|
### 7.3 방문수 기록 플로우 (24h dedupe)
|
||||||
|
1. 진입: 게임 상세/플레이 조회 시 viewer_key 산출(로그인=`userId` 문자열, 비로그인=익명 식별 해시 — 세션/쿠키 기반).
|
||||||
|
2. dedupe 조회: `game_views` 에서 `game_id == ? AND viewer_key == ? AND viewed_at >= now() - interval '24 hours'` 존재 여부.
|
||||||
|
3. 분기 — 이미 존재: 기록·증분 스킵(중복 방문).
|
||||||
|
4. 분기 — 미존재: `game_views` INSERT(game_id, viewer_key, now()) + `games.view_count` `UPDATE ... SET view_count = view_count + 1 WHERE id = ?`.
|
||||||
|
5. 종단: 방문수 정렬은 비정규화 `games.view_count` 를 O(1) 로 소비(§6.2 `views`).
|
||||||
|
6. 주의: dedupe 조회 + 증분은 동일 트랜잭션. 동시성은 view_count 가 정확 카운터가 아닌 정렬용 근사이므로 행 락 최소화(over-engineering 회피).
|
||||||
|
|
||||||
|
## ⑧ 파일 영향 맵
|
||||||
|
|
||||||
|
> 규약 추적: 신규 매퍼는 모두 `@Mapper` + `#{}` 바인딩(§NFR-3). 신규 함수 시그니처는 최소 인자 원칙(빈 칸 인라인 주석 = inflate 신호 → 제거). inflate 위험 시그니처는 frontmatter concerns 에 마킹됨.
|
||||||
|
|
||||||
|
### 8.1 변경 맵
|
||||||
|
|
||||||
|
| 변경 유형 | 경로 | 역할 | 소유권 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 신규 | `docs/tag-ddl.sql` | tags/game_tags/jam_tags/game_views 정의 + games.view_count ALTER (멱등, §4.1 전문) | U-TAG-SCHEMA |
|
||||||
|
| 수정 | `schema.sql` | 위 5블록 동기 사본(§4.3 지점·순서) | U-TAG-SCHEMA |
|
||||||
|
| 신규 | `TagsMapper.java` (@Mapper) | tags CRUD/승인/목록 | U-TAG-DOMAIN |
|
||||||
|
| 신규 | `GameTagsMapper.java` (@Mapper) | game_tags attach/detach/listByGame + 태그→게임ID 역검색 | U-TAG-DOMAIN |
|
||||||
|
| 신규 | `JamTagsMapper.java` (@Mapper) | jam_tags attach/listByJam (잼 태그 부착·조회. jams 는 W2-1 참조) | U-TAG-DOMAIN |
|
||||||
|
| 신규 | `GameViewsMapper.java` (@Mapper) | game_views dedupe 조회/INSERT + view_count 증분 | U-VIEW |
|
||||||
|
| 신규 | `TagData.java` (POJO) | tags 행 매핑(id/name/slug/tagType/isActive/createdBy/createdAt) | U-TAG-DOMAIN |
|
||||||
|
| 수정 | `GameData.java` (POJO) | 검색 projection 신규 필드(viewCount/avgRating/reviewCount) 추가 — 단 소비처 영향 점검(concern 4) | U-SEARCH |
|
||||||
|
| 수정 | `GamesMapper.java` | `searchGamesAdvanced(SearchCriteria)` **신규 메서드**(기존 `searchVisibleGames` 시그니처 비변경 → 호출처 갱신 0). VIEW LEFT JOIN + 태그/제작자/정렬키/jam 동적절(§6) | U-SEARCH |
|
||||||
|
| 신규 | `SearchController.java` | `GET /games/search` — 파라미터 정규화·매퍼 호출·검색 JSP 반환(§7.1) | U-SEARCH |
|
||||||
|
| 신규 | `TagController.java` | 태그 CRUD/승인 API(§5.2) — sanitize·금칙어·권한게이트·CSRF | U-TAG-ADMIN |
|
||||||
|
| 신규 | `TagSanitizer.java` (util) | 태그 입력 검증·slug 화·금칙어 매치(§8.3 시그니처) | U-TAG-ADMIN |
|
||||||
|
| 신규 | `src/main/resources/banned-words.txt` | 금칙어 사전(1줄 1단어, 소문자) — §4.2 단일 출처 | U-TAG-ADMIN |
|
||||||
|
| 수정 | 게임 상세 조회 컨트롤러 (기존, GameController 추정) | view 기록 훅 — `GameViewsMapper` dedupe 호출(§7.3) | U-VIEW |
|
||||||
|
| 수정 | 검색/목록 JSP (index.jsp 또는 신규 `game-search.jsp`) | 태그 필터 UI(선택 태그 chip)·정렬키 드롭다운·제작자 검색 입력 | U-JSP |
|
||||||
|
|
||||||
|
### 8.2 SSR 호출지점 영향
|
||||||
|
|
||||||
|
- **`searchVisibleGames` 기존 호출처**: 신규 `searchGamesAdvanced` 를 **별도 메서드로 추가**하므로 기존 시그니처/호출처(있다면 검색 컨트롤러 1개소) 변경은 0. 기존 자유텍스트 검색 동작 회귀 없음(§10).
|
||||||
|
- **index.jsp 검색 attr 보존**: 기존 검색 폼이 model 에 의존하는 attr(검색어/결과 리스트)는 신규 `SearchController` 에서 동일 이름으로 채워 보존. 신규 attr(선택 태그·정렬키·태그 목록)는 추가만(기존 제거 0).
|
||||||
|
- **GameData 확장 영향**: 신규 필드 추가 시 기존 목록/상세 JSP 가 EL 로 신규 필드를 참조하지 않으므로 표시 회귀 0. 단 mapper resultType 매핑에서 신규 컬럼이 비-검색 경로(getVisibleGames 등)에선 미채워짐 → null 허용 박스 타입(`Integer`/`Double`)으로 선언(concern 4 와 연계).
|
||||||
|
|
||||||
|
### 8.3 신규 함수 시그니처 (최소 인자, 인라인 주석 의무)
|
||||||
|
|
||||||
|
```java
|
||||||
|
// GamesMapper (신규 메서드)
|
||||||
|
List<GameData> searchGamesAdvanced(SearchCriteria c); // c: 검색 조건 묶음(아래). 단일 파라미터로 동적절 바인딩
|
||||||
|
|
||||||
|
// SearchCriteria (검색 조건 DTO — 매퍼 동적 SQL 바인딩용)
|
||||||
|
// keyword : 자유텍스트 ILIKE 대상(null=미적용)
|
||||||
|
// creator : 제작자 display_name ILIKE(null=미적용)
|
||||||
|
// tags : 화이트리스트 통과 slug 리스트(빈/null=태그필터 미적용)
|
||||||
|
// tagMode : "and" | "or" (tags 비었으면 미참조)
|
||||||
|
// tagCount : tags.size() (AND 모드 HAVING 비교 전용 — OR/미선택 시 미참조, concern 2)
|
||||||
|
// sort : 화이트리스트 정렬키(미허용→"latest" 서버 fallback 후 전달)
|
||||||
|
// jamSlug : 잼 출품작 한정(null=전체. concern 2)
|
||||||
|
|
||||||
|
// GameTagsMapper — AND/OR 분리로 dead param 회피(concern 3 채택안)
|
||||||
|
List<Long> listGameIdsByTagsAll(List<String> tagSlugs, int tagCount); // 전부 보유(AND): HAVING count==tagCount
|
||||||
|
List<Long> listGameIdsByTagsAny(List<String> tagSlugs); // 하나라도 보유(OR)
|
||||||
|
void attachTags(long gameId, List<Long> tagIds); // 게임에 태그 N개 부착(upsert)
|
||||||
|
List<TagData> listByGame(long gameId); // 게임의 태그 목록
|
||||||
|
void detach(long gameId, long tagId); // 단건 해제
|
||||||
|
|
||||||
|
// GameViewsMapper
|
||||||
|
boolean existsRecentView(long gameId, String viewerKey); // 24h 윈도 dedupe 조회(sinceTs 는 SQL 내 now()-interval 로 고정 → 인자 불필요, inflate 회피)
|
||||||
|
void insertView(long gameId, String viewerKey); // 방문 로그 INSERT
|
||||||
|
void incrementViewCount(long gameId); // games.view_count += 1
|
||||||
|
|
||||||
|
// TagsMapper
|
||||||
|
long insertTag(TagData t); // pending/active 생성(isActive 는 t 에 포함)
|
||||||
|
void approve(long id); // is_active=true
|
||||||
|
void deactivate(long id); // is_active=false
|
||||||
|
List<TagData> listActiveByType(String tagType, String q); // 활성 태그 목록/자동완성(q=null 전체)
|
||||||
|
TagData findBySlug(String slug); // 중복 검사/해석
|
||||||
|
|
||||||
|
// TagSanitizer (util) — 최소 인자. 결과는 검증 통과 slug 또는 예외/Optional
|
||||||
|
String toSlug(String rawName); // trim·소문자·정규화(검증은 별도 호출자가 결합)
|
||||||
|
boolean isAllowed(String slug); // 길이 2~20 + 화이트리스트 + 금칙어 미매치 통합 판정
|
||||||
|
```
|
||||||
|
|
||||||
|
> inflate 차단 메모: `GameTagsMapper` 의 태그 검색을 AND/OR 단일 메서드(`listGameIdsByTags(slugs, mode, count)`)로 통합하면 OR 경로에서 `count` 가 dead parameter 가 된다 → 두 메서드 분리안 채택(concern 3). `existsRecentView` 의 `sinceTs` 는 SQL 내 `now() - interval '24 hours'` 고정이라 인자에서 제거.
|
||||||
|
|
||||||
|
## ⑨ 대안 비교
|
||||||
|
|
||||||
|
| 결정축 | 채택안 | 장점 | 단점 | 기각안 | 기각 근거 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 태그 테이블 | 통합 `tags` 1테이블 + `tag_type` (D1) | 정규화·태그명 단일출처·도메인 확장 안정 | 타입 필터 항상 동반 | 용도별 분리(`game_tags_master`/`jam_tags_master`) | 중복 스키마·관리 분산·검색 단일출처 상실 |
|
||||||
|
| 방문수 | 로그(`game_views`) + 비정규화(`view_count`) (D5) | dedupe·감사 추적 + 정렬 O(1) | 테이블 1개 + 컬럼 1개 추가 | (a) 단순 `view_count++` (b) 로그 only 매번 COUNT | (a) 중복·어뷰징·감사불가 (b) 정렬마다 집계 비용 |
|
||||||
|
| 태그 관리 | 운영자 사전정의 + 사용자 pending→승인 하이브리드 (D2) | 초기 카탈로그 + 사용자 확장 + 난립 차단 | 승인 워크플로 필요 | (a) 운영자 전용 (b) 사용자 자유생성 | (a) 확장성↓ (b) 스팸·중복·XSS |
|
||||||
|
| 정렬 | 정렬키별 고정 ORDER BY + 동률 2차키 `id DESC` (D8) | 결정적 페이징·단순·예측가능 | relevance 는 서브쿼리 점수 | 단일 가중점수(likes·views·rating 혼합) | 가중치 튜닝 불명확·예측 불가·over-engineering |
|
||||||
|
| 다중 태그 | 파라미터 `tagMode=and\|or` (D7) | 사용자 교집합/합집합 선택 | 분기 2경로 | 고정 AND | 유연성↓(OR 탐색 불가) |
|
||||||
|
| 잼 검색 라우트 | `/games/search?jam=` 파라미터 통합 (D9) | 검색 진입점 단일·로직 1벌 | 컨트롤러 분기 1개 | 전용 `/jams/{slug}/games` | 검색 로직 중복·W3-4 계약 2벌 |
|
||||||
|
| 검색 행 타입 | GameData 확장(null 허용 박스 필드) | 매퍼 1벌·기존 매핑 재사용 | 비-검색 경로 null | 전용 `SearchRow` | 매핑 중복·소비처 분기 — 단 소비처 영향 시 재고(concern 4) |
|
||||||
|
|
||||||
|
## ⑩ 롤아웃 / 마이그레이션
|
||||||
|
|
||||||
|
### 10.1 적용 순서
|
||||||
|
1. `docs/tag-ddl.sql` 추가 → `apply-local-ddl.sh` 가 `docs/*-ddl.sql` 알파벳순 멱등 적용(`rbac-ddl.sql` < `tag-ddl.sql` 순서 무관 — 모든 블록 FK guard).
|
||||||
|
2. `schema.sql` 동기 사본 갱신(§4.3) — 신규 환경 부트스트랩 일관성.
|
||||||
|
3. 매퍼/POJO/컨트롤러/JSP 구현(소유권 분할 U-* 순서 무관, U-TAG-SCHEMA → U-TAG-DOMAIN → {U-SEARCH, U-TAG-ADMIN, U-VIEW} → U-JSP 의존순 권장).
|
||||||
|
|
||||||
|
### 10.2 역호환 (비파괴)
|
||||||
|
- `games.view_count` 는 `ADD COLUMN ... NOT NULL DEFAULT 0` — 기존 행은 자동 0 채움(비파괴, 백필 불필요).
|
||||||
|
- 신규 테이블(tags/game_tags/jam_tags/game_views)은 **추가 전용** — 기존 스키마 변경/삭제 0.
|
||||||
|
- 기존 검색 동작 회귀 0: 태그 미선택·정렬 미지정 시 `latest`(목록 기본과 동일) + keyword-only 경로 = 현 `searchVisibleGames` 동작과 동치. 신규 `searchGamesAdvanced` 는 별도 메서드라 기존 호출처 무영향.
|
||||||
|
|
||||||
|
### 10.3 롤백 경로
|
||||||
|
- 코드 롤백: 신규 컨트롤러/매퍼/JSP 변경 revert 시 기존 검색 즉시 복원(기존 메서드 미변경).
|
||||||
|
- 스키마 롤백: 신규 테이블·컬럼은 추가 전용이라 drop 없이 잔존해도 무해(읽지 않으면 영향 0). 강제 롤백 시 `DROP TABLE IF EXISTS` + `ALTER ... DROP COLUMN IF EXISTS view_count` (수동, 데이터 폐기 동반 — 운영 합의 전제).
|
||||||
|
|
||||||
|
## ⑪ AC 매핑
|
||||||
|
|
||||||
|
| FR (part1) | 만족 설계요소 | 검증(§12 VP) |
|
||||||
|
|---|---|---|
|
||||||
|
| FR-1 태그 도메인 | §4.1 tags/game_tags/jam_tags + TagData/매퍼(§8) | VP-L1(매퍼 단위), VP-L2(DDL 방언 contract) |
|
||||||
|
| FR-2 검색 확장(태그/제작자/정렬키) | §6 동적쿼리 + searchGamesAdvanced(§8.3) | VP-L1+L2(쿼리), AC-정렬키 전수 |
|
||||||
|
| FR-3 방문수 정렬 | §4.1 game_views + view_count, §7.3 dedupe, §6.2 views | VP-L1(GameViewsMapper), AC-dedupe 멱등 |
|
||||||
|
| FR-4 리뷰 정렬 | §6.1 game_review_stats LEFT JOIN, §6.2 reviews/rating | VP-L2(VIEW 별칭 contract) |
|
||||||
|
| FR-5 태그 관리 API | §5.2 + TagController + TagSanitizer(§8.3) | VP-보안(sanitize/금칙어/권한/CSRF) |
|
||||||
|
| FR-6 잼 태그 검색 라우트 | §5.3 `/games/search?jam=` 단일 출처 + jam_entries 조인 | VP-L1(jam 분기), W3-4 계약 일치 |
|
||||||
|
|
||||||
|
## ⑫ 검증 포인트
|
||||||
|
|
||||||
|
> L레벨 규약(프로토콜): L1=빌드/단위(`./mvnw -o test`), L2=DB-방언 contract(dev DB 실제 쿼리 실행), L3=통합/E2E. @MockBean 사용은 프로토콜 §30 준수.
|
||||||
|
|
||||||
|
### 12.1 L레벨 매핑
|
||||||
|
- **VP-L1 (단위)**: 신규 매퍼(TagsMapper/GameTagsMapper/JamTagsMapper/GameViewsMapper) + TagSanitizer 단위. `./mvnw -o test` full 실행(컴파일·기존 회귀 포함). 컨트롤러는 `@MockBean` 매퍼 주입(§30).
|
||||||
|
- **VP-L2 (DB-방언 contract)**: 검색 쿼리(searchGamesAdvanced)·태그 역검색(AND/OR)·view_count 증분을 **dev DB 실제 실행**으로 contract 검증 — game_review_stats VIEW 별칭(`"avgRating"`/`"reviewCount"`) case-folding, `NULLS LAST`, `ILIKE` Postgres 방언이 H2/mock 으로 검출 안 되는 회귀 게이트.
|
||||||
|
- **VP-L1 (sanitize)**: TagSanitizer 길이(2~20 경계)·화이트리스트(한/영/숫자/하이픈 외 거부)·금칙어 매치 단위 테스트.
|
||||||
|
|
||||||
|
### 12.2 집합 전수 체크 AC
|
||||||
|
|
||||||
|
> self-audit(시점 안정·표현 견고성): 아래 AC 는 모두 **검색 코드 자체가 verification 시점까지 변하지 않는 고정 산출물**을 대상으로 하며(자기 work-session 트리 아님), 단일 리터럴 grep 의존을 피해 의미 불변식 + 수동 판정을 병기한다.
|
||||||
|
|
||||||
|
- **AC-1 정렬키 전수 6건**: 정렬키 화이트리스트가 `latest/likes/views/reviews/rating/relevance` 전수 6건 매핑 — 정렬 매핑 소스(매퍼 `<choose>` 또는 컨트롤러 enum)에서 6개 키 각각 ORDER BY 절이 존재. 검증: 정렬키 enum/상수 정의에서 `grep -c` 한 case 수 == 6 **AND** 각 키별 L2 쿼리 실행이 정렬 적용 결과를 반환(의미 불변식: 미허용 키는 latest 와 동일 결과). 단일 grep 만으로 판정하지 않고 L2 실행 결과와 교차.
|
||||||
|
- **AC-2 태그 타입 전수 3건**: `tag_type` CHECK 가 `GAME/JAM/COMMON` 전수 3건 — `docs/tag-ddl.sql` 의 `chk_tags_type` CHECK IN 절 항목 수 == 3 **AND** schema.sql 동기 사본 동일. 검증: DDL 의 `CHECK (tag_type IN (...))` 항목 수 == 3 (구조 불변식: tag-ddl.sql 과 schema.sql 두 출처의 IN 목록 동등).
|
||||||
|
- **AC-3 다중태그 모드 전수 2건**: `tagMode` 가 `and`/`or` 전수 2건 — 매퍼 동적절에 AND 경로(HAVING count==tagCount)와 OR 경로(IN) 둘 다 존재 **AND** L2 실행으로 AND 결과 ⊆ OR 결과(불변식: 동일 태그셋에서 AND 매칭 게임은 OR 매칭의 부분집합). 단일 grep 회피 — 부분집합 불변식으로 판정.
|
||||||
|
- **AC-4 신규 매퍼 전수 4개**: TagsMapper/GameTagsMapper/JamTagsMapper/GameViewsMapper 4개 파일 존재 + 각 `@Mapper` 어노테이션 + `#{}` 바인딩만 사용. 검증: glob 매치 4건 **AND** 각 매퍼 `${` 동적치환 0건.
|
||||||
|
- **AC-5 DDL 블록 전수 5건**: `docs/tag-ddl.sql` 에 tags/game_tags/jam_tags/game_views 테이블 + games.view_count ALTER 전수 5블록 — `CREATE TABLE IF NOT EXISTS` 3건 + `ALTER TABLE games ADD COLUMN IF NOT EXISTS view_count` 1건 + game_views CREATE 1건. 검증: `CREATE TABLE IF NOT EXISTS` 매치 4건(tags/game_tags/jam_tags/game_views) + view_count ALTER 1건 == 5 **AND** schema.sql 동기 사본에 동일 5블록 존재(두 출처 동등 불변식).
|
||||||
|
|
||||||
|
### 12.3 보안 VP
|
||||||
|
- **태그 sanitize/금칙어**: 사용자 생성 입력이 길이/화이트리스트/금칙어 검증을 통과해야만 pending 저장(§7.2 step3). XSS 페이로드(`<script>` 등)·금칙어 입력 → 400 거부 테스트.
|
||||||
|
- **권한 게이트**: `/admin/tags/**` 4개 엔드포인트(생성/승인/비활성, §5.2)가 `PermissionGate.has(session, CONTENT_MODERATE)` 통과 필수. 미권한 세션 → 403.
|
||||||
|
- **CSRF**: 모든 상태변경 `POST`(태그 생성·승인·비활성·부착) `CsrfTokens.isValid` 선검증, 미검증 → 403.
|
||||||
|
- **`#{}` 바인딩 / `${}` 0건**: 검색·태그 쿼리 전 매퍼에서 `${` 동적치환 0건(`grep -c '\${' <매퍼들>` == 0). 정렬키/tagMode 는 매퍼 내부 고정 문자열 분기(`<choose>`)로 조립 — 사용자 입력이 `${}` 로 흐르지 않음(구조 불변식).
|
||||||
|
|
||||||
|
## ⑬ 잔여 오픈 질문
|
||||||
|
|
||||||
|
**오픈 질문 수: 0.** 모든 설계 결정은 D1~D9(§3) + §4~§8 에서 확정됨. 아래는 오픈 질문이 아니라 **구현 단계 점검 항목으로 concerns 에 이관**된 것이다.
|
||||||
|
|
||||||
|
- **권한키 확정(D4)**: 정석 후보는 ① 신규 `TAG_MANAGE` 도입(권한 분리·최소권한) ② 기존 `CONTENT_MODERATE` 재사용(W1 인프라 재사용·신규 키 비용 회피) 중 **② CONTENT_MODERATE 재사용으로 확정**한다. 근거: 현 시점 태그 모더레이션은 콘텐츠 모더레이션과 운영 주체가 동일하고(별도 태그 전담 운영자 미존재), W1 권한 인프라를 즉시 재사용하면 PermissionKeys/RBAC seed 변경 0. 향후 태그 전담 운영자 분리 요구 발생 시 `TAG_MANAGE` 추가 — 이는 오픈 질문이 아니라 미래 트리거 조건이 명확한 확장 포인트이며 concerns[0] 에 이관됨.
|
||||||
|
- **SearchCriteria/매퍼 시그니처 inflate**: §8.3 에서 최소 인자로 설계하고 AND/OR 매퍼 분리로 dead parameter 를 사전 차단. 구현 advisor 가 unused 진단 게이트(프로토콜 §11.2)에서 `tagCount`/`jamSlug` dead path 여부 최종 확인 — concerns[1][2] 이관.
|
||||||
|
- **검색 행 매핑 타입**: GameData 확장 vs SearchRow — 소비처 영향 최소안으로 구현 단계 확정. concerns[3] 이관.
|
||||||
|
|
@ -0,0 +1,543 @@
|
||||||
|
---
|
||||||
|
phase: design
|
||||||
|
agent: design-advisor
|
||||||
|
agent_version: 1
|
||||||
|
generated_at: 2026-06-23T12:00:00+09:00
|
||||||
|
workstream: W3-3-포스팅 보드(공지/블로그 + OG 미리보기 + 유니티블로그 피드 감시)
|
||||||
|
concerns:
|
||||||
|
- "★SSRF 핵심: SsrfSafeFetcher 의 'DNS resolve → IP 검증 → 그 IP 로 connect(host 헤더는 원본 호스트명)' 핀닝은 java.net.http.HttpClient/RestClient 기본 동작으로는 그대로 안 된다(클라이언트가 호스트명으로 재-resolve). 구현은 (a) custom javax.net.SocketFactory/InetAddress 핀닝 또는 (b) resolve→검증 후 connect→연결된 소켓의 실제 peer IP 재검증(TOCTOU 최소화) 중 (b)를 1차로 명세했다. 구현 단계에서 라이브러리 제약으로 (a)가 필요해질 수 있으니 SSRF 단위테스트(사설/메타데이터/rebinding mock)로 실제 차단을 검증할 것."
|
||||||
|
- "pom.xml 신규 의존 3종(commonmark, jsoup, 선택적으로 HTTP 클라이언트는 spring-web 내장 RestClient 로 충족) 추가가 전제다. design-advisor 는 pom.xml 을 수정하지 않으므로 implementation 단계 첫 작업이 의존 추가 + contextLoads 확인이다. 의존 미추가 시 컴파일 불가 — 구현 1보에서 의존 추가를 먼저 커밋."
|
||||||
|
- "신규 컨트롤러(PostController/PostAdminController/UnityFeedAdminController)·신규 매퍼 6종·SsrfSafeFetcher·OgPreviewService·UnityFeedPoller 빈 추가는 verification-strategies §30 대상 — BibimbapApplicationTests 에 @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException. full ./mvnw -o test 로 끝낼 것(test-compile 만 금지)."
|
||||||
|
- "신규 매퍼 SQL 의 camelCase alias 는 일반 매퍼 표준(snake→camel 직접 alias: og_title AS ogTitle). 집계/큰따옴표 alias 불필요(verification-strategies §33). DB-방언 계약(L2) 대상."
|
||||||
|
- "UnityFeedPoller @Scheduled 는 @EnableScheduling 활성화가 전제(현재 미활성, code-fact: @EnableScheduling 0 hit). 신규 @Configuration 또는 메인 클래스에 활성화 필요 — 구현 시 다중 인스턴스 동시 폴링 중복 방지(현 톰캣 단일 인스턴스라 1차는 무방, 스케일아웃 시 분산락 필요는 비목표)."
|
||||||
|
- "PostMarkdownService.render 시그니처는 최소 인자(markdown 1개)로 명세. 구현에서 sanitize 정책 주입이 필요하면 그때 확장(inflate 방지, 프로토콜 §11.2)."
|
||||||
|
concerns_checked: true
|
||||||
|
self_verification:
|
||||||
|
checklist_passed: true
|
||||||
|
references:
|
||||||
|
requirements: docs/work-log/2026-06-17-w3-feature-skeletons.md
|
||||||
|
research: .atp/work-session/20260623-104307/research/W2-W4-grounding.md
|
||||||
|
adrs:
|
||||||
|
- .atp/work-session/20260622-180054/implementation/W1-design.md
|
||||||
|
- docs/work-log/2026-06-23-w2-w4-feature-skeletons.md
|
||||||
|
- docs/work-log/2026-06-17-jam-platform-roadmap.md
|
||||||
|
- docs/development/verification-strategies.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# 설계: W3-3 — 포스팅 보드 (공지/블로그 + 외부링크 OG 미리보기 + 유니티블로그 피드 감시)
|
||||||
|
|
||||||
|
## 목표 / 비목표
|
||||||
|
|
||||||
|
### 목표 (FR/NFR 추적)
|
||||||
|
- **G1 포스팅 작성/수정/삭제** (골자 W3-3 핵심): POST_WRITE 권한자만 작성. 일반 유저 읽기 전용. **댓글 없음**(골자 Q5 — W3-2 결합 0).
|
||||||
|
- **G2 카테고리 운영** (골자 Q2): `post_categories` DB 저장, 운영자 추가/수정/삭제. 정렬·활성 토글.
|
||||||
|
- **G3 마크다운 본문 + sanitize** (골자 Q4): 본문은 마크다운 입력 → allowlist sanitize HTML 캐시. 저장 시 `body_markdown`(원본) + `body_sanitized_html`(렌더 캐시) 듀얼.
|
||||||
|
- **G4 ★외부링크 OG 미리보기 + SSRF 방어** (골자 Q3): 서버측 fetch 로 OG 메타 추출, SSRF 전수 방어, 결과 캐싱, 실패 graceful(링크만).
|
||||||
|
- **G5 ★유니티블로그 외부 피드 감시** (골자 Q6): RSS/Atom 폴링 + dedupe(last_seen_guid) + 새 글 감지 → 운영자 알림 표면. 피드 URL 도 SSRF 방어.
|
||||||
|
- **G6 게시판 페이징** (골자/grounding R-F): RecruitPostsMapper 페이징 부재 → keyset 페이징 신규.
|
||||||
|
- **NFR-보안**: 상태변경 CSRF 전수, `#{}` 바인딩(`${}` 금지), POST_WRITE 게이트(임시 role 직접체크 금지), SSRF 방어 전수, 마크다운 sanitize XSS 차단, JSP 출력 escape.
|
||||||
|
- **NFR-정합**: DDL 권위 = `docs/board-ddl.sql`, schema.sql 동기. 멱등·비파괴.
|
||||||
|
|
||||||
|
### 비목표 (스코프 밖)
|
||||||
|
- 포스팅 댓글/리액션 (골자 Q5 — 읽기 전용 채널 확정).
|
||||||
|
- 마크다운 위지윅 에디터 (입력은 textarea + 미리보기, 에디터 고도화는 후속).
|
||||||
|
- 외부 피드 자동 재게시(임포트) — 본 설계는 **감지 + 운영자 알림**까지. 감지 항목을 정식 포스트로 자동 전환하지 않음(운영자가 수동으로 작성). 피드 항목 클릭 시 원문 링크 이동.
|
||||||
|
- 피드 폴링 분산락/멀티인스턴스 중복 방지 (현 단일 톰캣 인스턴스 전제, 스케일아웃 시 후속).
|
||||||
|
- OG 미리보기 이미지 프록시/리사이즈 캐싱 (URL 캐시까지, 바이너리 캐시는 후속).
|
||||||
|
- 잼 연동 (골자 — 포스팅은 잼 무관 독립 채널).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
bibimbap 는 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis `@Mapper`(`#{}`) + JSP 스택이다. 게시판 선례는 `RecruitController`(읽기=JSP 뷰이름 반환, 쓰기=`ResponseEntity<Map>` + `CsrfTokens.isValid` + 세션 userId 화이트리스트)이며 **PermissionGate 미적용·페이징 부재**다(code-fact: RecruitController.java:65,159; RecruitPostsMapper 페이징 0).
|
||||||
|
|
||||||
|
본 설계는 `posts` + `post_categories` 게시판을 신설하고, **작성 경로에 W1 `PermissionGate.has(session, PermissionKeys.POST_WRITE.name())` enforcement 를 연결**한다(현재 POST_WRITE 키는 enum 선언만·소비처 0 — code-fact: PermissionKeys.java:5, grounding R-A). 임시 role 직접체크는 금지(정석 원칙).
|
||||||
|
|
||||||
|
가장 까다로운 두 보안 난제를 다음과 같이 확정한다.
|
||||||
|
|
||||||
|
- **난제1 (마크다운 → sanitize HTML)**: 단일 정의처 = `PostMarkdownService`. 입력 마크다운을 **commonmark** 로 HTML 변환 후 **jsoup `Safelist`**(allowlist) 로 sanitize 해 `body_sanitized_html` 에 캐시한다. 허용 서식은 제목(h1~h3)/볼드/이탤릭/리스트/링크(rel=nofollow noopener, http/https만)/인라인·블록 코드/이미지(http/https src만). 스크립트·이벤트핸들러(onclick 등)·`javascript:`·style·iframe·object 전부 제거. **저장 시 1회 sanitize → 조회 시 캐시 출력**(매 조회 재sanitize 비용 제거 + 정책 변경 시 재생성 배치 여지). JSP 는 sanitized HTML 을 `<c:out escapeXml="false">` 가 아니라 **이미 sanitize 된 신뢰 HTML 로 직접 출력**(단 sanitize 가 유일 신뢰원천).
|
||||||
|
- **난제2 (★SSRF 방어 — OG fetch + 피드 fetch 공통)**: **단일 공용 컴포넌트 `SsrfSafeFetcher`** 로 외부 fetch 를 단일화한다(crossRefs 권장). OG 미리보기와 유니티블로그 피드가 동일 fetcher 를 통과한다. 방어 체크리스트는 §보안(SsrfSafeFetcher 계약)에 전수 기록. 핵심: scheme allowlist(http/https) → 호스트 resolve → 모든 resolved IP 가 공인(public) 인지 검증(사설/루프백/링크로컬/메타데이터 169.254.169.254 차단) → connect → **연결된 소켓의 실제 peer IP 재검증(rebinding 방어)** → redirect 매 홉 재검증(최대 N홉) → 응답 size cap + timeout → Content-Type 검증. 실패는 예외가 아니라 graceful 결과(미리보기 없음 / 피드 폴링 skip + 로그).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 핵심 결정 요약 (전제 — 재논의 금지, 난제는 본 설계가 확정)
|
||||||
|
|
||||||
|
| 결정 | 확정값 | 본 설계의 구체화 |
|
||||||
|
|---|---|---|
|
||||||
|
| D1 게시판 모델 | posts + post_categories(DB저장) | `posts`(FK category_id, author_user_id, title, body_markdown, body_sanitized_html, og_*, status, is_delete) + `post_categories`(name/slug/sort_order/is_active) |
|
||||||
|
| D2 작성 권한 | POST_WRITE 게이트 enforcement 연결 | `PermissionGate.has(session, PermissionKeys.POST_WRITE.name())`. 임시 role 직접체크 금지. ADMIN 은 게이트 내부에서 암묵 통과 |
|
||||||
|
| D3 댓글 | 없음 | 읽기 전용 채널(골자 Q5). W3-2 결합 0 |
|
||||||
|
| D4 본문 | 마크다운 → sanitize HTML allowlist | commonmark 변환 + jsoup Safelist. body_markdown 원본 + body_sanitized_html 캐시(저장 시 1회 sanitize) |
|
||||||
|
| D5 ★OG 미리보기 | 서버 fetch + SSRF 방어 + 캐싱 | 공용 `SsrfSafeFetcher` 경유. 결과는 posts.og_* 컬럼 캐시. 실패 graceful |
|
||||||
|
| D6 ★유니티 피드 감시 | RSS/Atom 폴링 + dedupe + 알림 | `unity_feed_sources` + `unity_feed_items`. @Scheduled 폴링. last_seen_guid dedupe. 운영자 대시보드 미확인 배지 |
|
||||||
|
| D7 외부 fetch 인프라 | 공용 `SsrfSafeFetcher` 단일화 | OG·피드 동일 fetcher 경유(SSRF 방어 중복 0) |
|
||||||
|
| D8 페이징 | keyset 페이징 | (created_at, id) 커서. RecruitPostsMapper 전건 조회와 결별 |
|
||||||
|
| D9 신규 의존 | commonmark + jsoup | HTTP 는 spring-web 내장 RestClient 사용(신규 의존 0). 마크다운/sanitize 만 신규 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 모델 (DDL)
|
||||||
|
|
||||||
|
> 권위 = 신규 `docs/board-ddl.sql`. `db/apply-local-ddl.sh` 가 `docs/*-ddl.sql` 글롭 알파벳순 멱등 적용(ON_ERROR_STOP, search_path=dev). schema.sql 은 동기 사본(recruit_posts/rbac 선례와 동일). 전부 멱등·비파괴. 따옴표 식별자·SEQUENCE+nextval·DO $$ guard FK 등 기존 스타일(schema.sql:263-368) 준수.
|
||||||
|
|
||||||
|
### 신규 파일: `docs/board-ddl.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- W3-3 포스팅 보드. 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
|
||||||
|
-- posts / post_categories / unity_feed_sources / unity_feed_items. 추가만, 파괴 없음.
|
||||||
|
|
||||||
|
-- 1) post_categories (운영자 CRUD 카테고리 — D1/G2)
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "post_categories_id_seq";
|
||||||
|
CREATE TABLE IF NOT EXISTS "post_categories" (
|
||||||
|
"id" bigint DEFAULT nextval('post_categories_id_seq'::regclass) NOT NULL,
|
||||||
|
"name" character varying(80) NOT NULL,
|
||||||
|
"slug" character varying(80) NOT NULL,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"is_active" boolean DEFAULT true NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
ALTER SEQUENCE "post_categories_id_seq" OWNED BY "post_categories"."id";
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "ux_post_categories_slug"
|
||||||
|
ON "post_categories" ("slug");
|
||||||
|
|
||||||
|
-- 2) posts (D1) — body_markdown 원본 + body_sanitized_html 캐시(D4), og_* 캐시(D5)
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "posts_id_seq";
|
||||||
|
CREATE TABLE IF NOT EXISTS "posts" (
|
||||||
|
"id" bigint DEFAULT nextval('posts_id_seq'::regclass) NOT NULL,
|
||||||
|
"category_id" bigint NOT NULL,
|
||||||
|
"author_user_id" bigint NOT NULL,
|
||||||
|
"title" character varying(200) NOT NULL,
|
||||||
|
"body_markdown" text NOT NULL, -- 원본(편집·재렌더 소스)
|
||||||
|
"body_sanitized_html" text NOT NULL, -- 저장 시 sanitize 캐시(D4, 조회 출력원)
|
||||||
|
"link_url" character varying(2048), -- 외부링크 큐레이션 대상(OG 미리보기 소스)
|
||||||
|
"og_title" character varying(300), -- OG 캐시(D5). NULL=미리보기 없음
|
||||||
|
"og_description" character varying(600),
|
||||||
|
"og_image_url" character varying(2048),
|
||||||
|
"og_site_name" character varying(200),
|
||||||
|
"og_fetched_at" timestamp with time zone, -- 마지막 OG fetch 시각(재fetch 정책 기준)
|
||||||
|
"status" character varying(20) DEFAULT 'PUBLISHED' NOT NULL, -- DRAFT/PUBLISHED
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"deleted_at" timestamp with time zone,
|
||||||
|
"is_delete" boolean DEFAULT false NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
ALTER SEQUENCE "posts_id_seq" OWNED BY "posts"."id";
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_category_id_fkey') THEN
|
||||||
|
ALTER TABLE "posts"
|
||||||
|
ADD CONSTRAINT "posts_category_id_fkey"
|
||||||
|
FOREIGN KEY ("category_id") REFERENCES "post_categories" ("id");
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_author_user_id_fkey') THEN
|
||||||
|
ALTER TABLE "posts"
|
||||||
|
ADD CONSTRAINT "posts_author_user_id_fkey"
|
||||||
|
FOREIGN KEY ("author_user_id") REFERENCES "users" ("id");
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_status_check') THEN
|
||||||
|
ALTER TABLE "posts"
|
||||||
|
ADD CONSTRAINT "posts_status_check"
|
||||||
|
CHECK ("status" IN ('DRAFT', 'PUBLISHED'));
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
-- keyset 페이징 + 카테고리 필터 커버링 인덱스(D8)
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_posts_published_keyset"
|
||||||
|
ON "posts" ("status", "is_delete", "created_at" DESC, "id" DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_posts_category_keyset"
|
||||||
|
ON "posts" ("category_id", "created_at" DESC, "id" DESC)
|
||||||
|
WHERE "is_delete" = false AND "status" = 'PUBLISHED';
|
||||||
|
|
||||||
|
-- 3) unity_feed_sources (외부 피드 감시 소스 — D6/G5)
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "unity_feed_sources_id_seq";
|
||||||
|
CREATE TABLE IF NOT EXISTS "unity_feed_sources" (
|
||||||
|
"id" bigint DEFAULT nextval('unity_feed_sources_id_seq'::regclass) NOT NULL,
|
||||||
|
"name" character varying(120) NOT NULL, -- 표시명(예: Unity Blog)
|
||||||
|
"feed_url" character varying(2048) NOT NULL,
|
||||||
|
"is_active" boolean DEFAULT true NOT NULL,
|
||||||
|
"last_polled_at" timestamp with time zone,
|
||||||
|
"last_seen_guid" character varying(512), -- dedupe 커서(가장 최근 본 항목 guid)
|
||||||
|
"last_error" character varying(500), -- 마지막 폴링 실패 사유(graceful)
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
ALTER SEQUENCE "unity_feed_sources_id_seq" OWNED BY "unity_feed_sources"."id";
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "ux_unity_feed_sources_url"
|
||||||
|
ON "unity_feed_sources" ("feed_url");
|
||||||
|
|
||||||
|
-- 4) unity_feed_items (감지된 새 글 — 운영자 알림 표면 + dedupe 영속화)
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "unity_feed_items_id_seq";
|
||||||
|
CREATE TABLE IF NOT EXISTS "unity_feed_items" (
|
||||||
|
"id" bigint DEFAULT nextval('unity_feed_items_id_seq'::regclass) NOT NULL,
|
||||||
|
"source_id" bigint NOT NULL,
|
||||||
|
"guid" character varying(512) NOT NULL, -- 피드 항목 고유 식별자(dedupe 키)
|
||||||
|
"title" character varying(500) NOT NULL,
|
||||||
|
"link_url" character varying(2048) NOT NULL,
|
||||||
|
"published_at" timestamp with time zone,
|
||||||
|
"is_acknowledged" boolean DEFAULT false NOT NULL, -- 운영자 확인 여부(알림 배지)
|
||||||
|
"detected_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
ALTER SEQUENCE "unity_feed_items_id_seq" OWNED BY "unity_feed_items"."id";
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'unity_feed_items_source_id_fkey') THEN
|
||||||
|
ALTER TABLE "unity_feed_items"
|
||||||
|
ADD CONSTRAINT "unity_feed_items_source_id_fkey"
|
||||||
|
FOREIGN KEY ("source_id") REFERENCES "unity_feed_sources" ("id");
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
-- 동일 소스 내 guid 중복 차단(dedupe 영속 보장 — DB 레벨 1차 방어)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "ux_unity_feed_items_source_guid"
|
||||||
|
ON "unity_feed_items" ("source_id", "guid");
|
||||||
|
-- 미확인 항목 배지 카운트용
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_unity_feed_items_unack"
|
||||||
|
ON "unity_feed_items" ("is_acknowledged", "detected_at" DESC)
|
||||||
|
WHERE "is_acknowledged" = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
### `db/schema.sql` 반영 (최초 기동 1회 자동 주입)
|
||||||
|
- `rbac_audit_log` 블록 뒤(schema.sql:369 직후)에 위 1~4번 블록을 신설 추가.
|
||||||
|
- 반영 방식은 recruit_posts/rbac 가 schema.sql 에 동기화된 선례(schema.sql:261-368)와 동일 — **docs/board-ddl.sql 이 권위, schema.sql 은 그 사본**.
|
||||||
|
|
||||||
|
### 카테고리 부트스트랩
|
||||||
|
- 카테고리는 D2 에 따라 운영자가 콘솔에서 생성. 시드 불필요(빈 상태에서 운영자가 첫 카테고리 생성). 단 dev 편의를 위해 `db/seed-dev.sql` 류가 있으면 샘플 카테고리 1건 INSERT 는 documentation/seed 소관(본 설계는 요구만 명시, DDL 에 포함 안 함).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 외부 계약 (API)
|
||||||
|
|
||||||
|
> 공통: 모든 상태변경은 `CsrfTokens.isValid(request)` 검증(실패 시 403 + `CsrfTokens.errorBody()`). 쓰기 응답은 `ResponseEntity<Map<String,Object>>`(status/message), 읽기는 JSP 뷰이름 반환(RecruitController 패턴). 작성/카테고리/피드관리 액션은 권한 게이트 통과 후 도달.
|
||||||
|
|
||||||
|
### 401 vs 403 정책 (확정 — W1 정책과 일치)
|
||||||
|
- **미인증**(세션 userId 없음): 페이지 진입 `redirect:/login`, 쓰기 API 401 JSON(`{status:401,message:"로그인이 필요합니다."}`).
|
||||||
|
- **인증·미인가**(로그인됐으나 POST_WRITE 없음): **403**(`{status:403,message:"권한이 없습니다."}`). 페이지 작성폼 진입도 403(리다이렉트 금지 — 권한 없음을 로그인으로 오인 유도 방지).
|
||||||
|
- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`.
|
||||||
|
|
||||||
|
### 공개 포스팅 페이지 (뷰 — 인증 불요, 읽기 전용)
|
||||||
|
| method | path | 권한 | 응답 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/posts` | 공개 | `posts-list` JSP. PUBLISHED 글 keyset 페이징(쿼리 `?categoryId=&cursorCreatedAt=&cursorId=`) + 카테고리 탭(is_active) |
|
||||||
|
| GET | `/posts/{id}` | 공개 | `posts-detail` JSP. body_sanitized_html + (있으면) OG 미리보기 카드. is_delete/DRAFT 면 redirect:/posts |
|
||||||
|
|
||||||
|
### 작성/편집 (POST_WRITE 게이트 — D2)
|
||||||
|
| method | path | 권한 | 요청 | 응답(200) | 에러 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| GET | `/posts/new` | POST_WRITE | (없음) | `posts-form` JSP(활성 카테고리 목록 + CSRF) | 401 redirect / 403 |
|
||||||
|
| POST | `/posts` | POST_WRITE | categoryId, title, bodyMarkdown, linkUrl?, status | `{status:200, postId, location:"/posts/{id}"}` | 400(검증), 401, 403(권한/CSRF), 404(카테고리 없음/비활성) |
|
||||||
|
| GET | `/posts/{id}/edit` | POST_WRITE | (path) | `posts-form` JSP(기존 값) | 401/403/404 |
|
||||||
|
| POST | `/posts/{id}` (수정) | POST_WRITE | 동일 필드 | `{status:200, postId}` | 400/401/403/404 |
|
||||||
|
| POST | `/posts/{id}/delete` | POST_WRITE | (path) | `{status:200}` | 401/403/404 |
|
||||||
|
|
||||||
|
- **작성 검증**: title ≤ 200자, bodyMarkdown ≤ 20000자, linkUrl ≤ 2048자 + scheme http/https(없으면 OG 생략), status ∈ {DRAFT, PUBLISHED}, categoryId 는 존재·is_active.
|
||||||
|
- **저장 부작용**: bodyMarkdown → `PostMarkdownService.render` → body_sanitized_html. linkUrl 있으면 **동기 OG fetch**(아래 OG 정책) — 단 타임아웃 cap 내 실패 시 og_* NULL 로 graceful 저장(작성 자체는 성공).
|
||||||
|
- **권한 모델**: 작성·수정·삭제 모두 POST_WRITE 게이트. **작성자 본인 한정이 아니라 POST_WRITE 보유자 전원 편집 가능**(공지/블로그 = 운영 채널 특성 — 본인글 제한은 비목표; 단 삭제/수정 시 author_user_id 는 보존, 감사 가능성은 후속). ADMIN 은 게이트 내부에서 암묵 통과(PermissionGate.has 의 role==ADMIN 분기, PermissionGate.java:30).
|
||||||
|
|
||||||
|
### 카테고리 운영 (운영자 — D2/G2)
|
||||||
|
> 카테고리 CRUD 는 운영자 전용. **인터셉터 보호 경로 `/admin/**` 아래에 둔다** → RbacInterceptor 가 ADMIN 게이트 적용(InterceptorConfig.java:19 `/admin/**`). 카테고리 운영은 ADMIN 권한으로 통일(별도 권한키 불필요 — over-engineering 회피).
|
||||||
|
|
||||||
|
| method | path | 권한 | 요청 | 응답(200) | 에러 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| GET | `/admin/post-categories` | ADMIN(인터셉터) | (없음) | `admin-post-categories` JSP 또는 `{categories:[...]}` | 401/403 |
|
||||||
|
| POST | `/admin/post-categories` | ADMIN | name, slug, sortOrder | `{status:200, categoryId}` | 400, 409(slug 중복), 403 |
|
||||||
|
| POST | `/admin/post-categories/{id}` | ADMIN | name?, slug?, sortOrder?, isActive? | `{status:200}` | 400, 404, 409, 403 |
|
||||||
|
| POST | `/admin/post-categories/{id}/delete` | ADMIN | (path) | `{status:200}` | 404, 409(소속 포스트 존재 시 거부 또는 soft), 403 |
|
||||||
|
|
||||||
|
- **카테고리 삭제 정책**: 소속 PUBLISHED 포스트가 있으면 **하드 삭제 거부(409)** + `is_active=false` 권고(메시지). 정석: FK 무결성 보존, 포스트 고아화 방지.
|
||||||
|
|
||||||
|
### 유니티 피드 감시 운영 (운영자 — D6/G5)
|
||||||
|
> 동일하게 `/admin/**` 아래 → 인터셉터 ADMIN 게이트.
|
||||||
|
|
||||||
|
| method | path | 권한 | 요청 | 응답(200) | 에러 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| GET | `/admin/unity-feeds` | ADMIN | (없음) | `admin-unity-feeds` JSP(소스 목록 + 미확인 감지 항목 + 배지 카운트) | 401/403 |
|
||||||
|
| POST | `/admin/unity-feeds` | ADMIN | name, feedUrl | `{status:200, sourceId}` | 400(SSRF 거부 시 feedUrl 부적합), 409(url 중복), 403 |
|
||||||
|
| POST | `/admin/unity-feeds/{id}/toggle` | ADMIN | (path) | `{status:200, isActive}` | 404, 403 |
|
||||||
|
| POST | `/admin/unity-feeds/{id}/delete` | ADMIN | (path) | `{status:200}` | 404, 403 |
|
||||||
|
| POST | `/admin/unity-feeds/items/{itemId}/ack` | ADMIN | (path) | `{status:200}` | 404, 403 |
|
||||||
|
| POST | `/admin/unity-feeds/poll` | ADMIN | (없음, 수동 트리거) | `{status:200, newCount}` | 403 |
|
||||||
|
|
||||||
|
- **피드 등록 시 SSRF 선검증**: feedUrl 은 등록 시점에 `SsrfSafeFetcher` 의 URL 검증(scheme/host resolve/IP 공인 여부)을 통과해야 등록 허용(400 거부). 폴링 시 매번 재검증(rebinding 방어).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 보안: SsrfSafeFetcher 계약 (★핵심 — D5/D7, OG·피드 공통)
|
||||||
|
|
||||||
|
> 단일 공용 컴포넌트. OG 미리보기 fetch 와 유니티 피드 fetch 가 **둘 다 이 fetcher 만** 경유한다(SSRF 방어 중복 0, crossRefs 단일화 권장 채택).
|
||||||
|
|
||||||
|
### SSRF 방어 체크리스트 (전수 — securityNotes 에 기록)
|
||||||
|
1. **scheme allowlist**: `http`, `https` 만 허용. `file:`/`gopher:`/`ftp:`/`data:` 등 전부 거부.
|
||||||
|
2. **호스트명 resolve → IP 검증**: `InetAddress.getAllByName(host)` 로 전체 IP 해석. **모든** resolved IP 가 공인이어야 통과(하나라도 사설/예약이면 거부).
|
||||||
|
3. **차단 IP 대역**: 루프백(127.0.0.0/8, ::1), 사설(10/8, 172.16/12, 192.168/16, fc00::/7), 링크로컬(169.254/16, fe80::/10), **메타데이터(169.254.169.254)**, 0.0.0.0, multicast, 와일드카드. `InetAddress.isLoopbackAddress/isSiteLocalAddress/isLinkLocalAddress/isAnyLocalAddress/isMulticastAddress` + 명시 169.254.169.254 차단 + IPv4-mapped IPv6 정규화 후 재검사.
|
||||||
|
4. **★DNS rebinding 방어**: resolve 시점 IP 와 connect 후 실제 peer IP 가 다를 수 있다. 1차 명세 = **connect 후 소켓의 `getInetAddress()`(실제 연결된 peer IP) 를 #3 기준으로 재검증** → 실패 시 즉시 abort. (concern: HttpClient 가 host 로 재resolve 하므로 connection-level 검증 필요. 구현에서 custom SocketFactory IP 핀닝이 필요하면 그때 도입.)
|
||||||
|
5. **redirect 매 홉 재검증**: 자동 redirect 따라가기 **비활성**(`RestClient`/`HttpClient` followRedirects=NEVER) → 3xx Location 을 받으면 **수동으로** #1~#4 재검증 후 다음 홉. 최대 홉 수 cap(예: 3). 초과 시 abort.
|
||||||
|
6. **응답 size cap**: 응답 본문 최대 바이트(예: OG=512KB, 피드=2MB) 초과 시 스트림 중단(부분 파싱). Content-Length 신뢰 금지 — 읽는 바이트 누적 카운트.
|
||||||
|
7. **timeout**: connect + read timeout(예: 3s/5s). 무한 대기 방지.
|
||||||
|
8. **Content-Type 검증**: OG=text/html(또는 application/xhtml+xml), 피드=application/rss+xml / atom+xml / xml / text/xml. 불일치 시 파싱 거부(graceful).
|
||||||
|
9. **에러 graceful**: 위 어느 단계 실패든 예외를 호출자에 전파하지 않고 `Optional.empty()`/실패 결과 반환. OG = 미리보기 없이 링크만. 피드 = 폴링 skip + `unity_feed_sources.last_error` 기록.
|
||||||
|
|
||||||
|
### fetcher 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
|
||||||
|
```java
|
||||||
|
// SsrfSafeFetcher — 외부 fetch 단일 관문(@Component). OG·피드 공통.
|
||||||
|
// URL 검증 + 안전 fetch 를 한 메서드로 묶는다(검증만 따로 노출하면 검증-fetch 사이 TOCTOU).
|
||||||
|
Optional<FetchResult> fetch(URI url, // 대상(검증 통과해야 fetch). scheme/host 검증 진입점
|
||||||
|
long maxBytes, // 응답 size cap(OG/피드 호출자가 용도별 상한 지정)
|
||||||
|
Set<String> allowedContentTypes) // #8 검증용(용도별 다름)
|
||||||
|
// FetchResult: int status / String contentType / byte[] body(cap 내) / URI finalUrl
|
||||||
|
|
||||||
|
// 등록 시 사전 URL 검증만 필요한 경우(피드 등록 400 판정) — fetch 없이 검증만.
|
||||||
|
boolean isFetchableUrl(URI url) // #1~#3 정적 검증(등록 시점 빠른 거부)
|
||||||
|
```
|
||||||
|
> ⚠️ inflate 마킹(concern): `fetch` 의 `allowedContentTypes` 는 호출처가 2곳(OG=html, 피드=xml)뿐이라 인자로 받는 게 정당(상수 분기보다 명시적). `maxBytes` 도 용도별로 달라 정당. 그러나 구현에서 두 인자가 사실상 호출처별 고정 상수로만 쓰이면 `fetchHtml(url)`/`fetchFeed(url)` 2메서드로 분리하는 게 더 깔끔할 수 있음 — 구현 단계에서 재평가.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 마크다운 sanitize 계약 (D4)
|
||||||
|
|
||||||
|
```java
|
||||||
|
// PostMarkdownService — 마크다운→sanitize HTML 단일 정의처(@Component).
|
||||||
|
String render(String markdown) // 원본 → commonmark 변환 → jsoup Safelist sanitize → 캐시용 HTML
|
||||||
|
```
|
||||||
|
- **commonmark**(org.commonmark) 로 markdown → HTML.
|
||||||
|
- **jsoup `Safelist`** allowlist:
|
||||||
|
- 허용 태그: h1,h2,h3,p,br,strong,em,ul,ol,li,blockquote,code,pre,a,img,hr.
|
||||||
|
- `a`: href(http/https/mailto만), `rel=nofollow noopener`, `target=_blank` 강제 부여. `javascript:` 차단(jsoup 기본).
|
||||||
|
- `img`: src(http/https만), alt. 인라인 width/style 제거.
|
||||||
|
- 제거: script, style, iframe, object, embed, form, on* 이벤트 속성 전부.
|
||||||
|
- `Safelist.basicWithImages()` 기반 + 커스텀(h1~h3 추가, a rel 강제, protocol 제한). **저장 시 1회 render** → body_sanitized_html. 조회는 캐시 출력(재sanitize 없음).
|
||||||
|
- concern: render 시그니처는 markdown 1개 최소 인자. sanitize 정책을 호출별로 바꿀 필요가 생기면 그때 확장.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 시퀀스
|
||||||
|
|
||||||
|
### S1. 포스팅 작성(POST_WRITE 게이트 + 마크다운 sanitize + OG fetch)
|
||||||
|
```
|
||||||
|
[POST_WRITE 보유 세션] POST /posts (CSRF, categoryId, title, bodyMarkdown, linkUrl?)
|
||||||
|
→ PostController.create
|
||||||
|
→ CsrfTokens.isValid(request) 실패 → 403 errorBody ← AC-5
|
||||||
|
→ userId = sessionUserId; null → 401
|
||||||
|
→ permissionGate.has(session, POST_WRITE.name()) 실패 → 403 ← AC-1 (게이트 enforcement 연결)
|
||||||
|
(내부: refreshIfStale epoch 대조 → role==ADMIN 통과 | SUBADMIN&&perms.contains(POST_WRITE))
|
||||||
|
→ 입력 검증(title≤200, bodyMarkdown≤20000, status 화이트리스트)
|
||||||
|
→ categoryMapper.getActive(categoryId) 없음 → 404
|
||||||
|
→ sanitizedHtml = postMarkdownService.render(bodyMarkdown) ← AC-4 (XSS 차단)
|
||||||
|
→ linkUrl 있으면:
|
||||||
|
ogResult = ogPreviewService.fetch(linkUrl) ← SsrfSafeFetcher 경유
|
||||||
|
- 성공 → og_* 채움
|
||||||
|
- 실패/SSRF거부 → og_* NULL (graceful, 작성은 성공) ← AC-7
|
||||||
|
→ postsMapper.insert(category, author, title, markdown, sanitizedHtml, og_*, status)
|
||||||
|
→ 200 {postId, location:"/posts/{id}"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### S2. 포스팅 조회(공개 — sanitize 캐시 출력 + 페이징)
|
||||||
|
```
|
||||||
|
[익명/일반] GET /posts?categoryId=3&cursorCreatedAt=...&cursorId=...
|
||||||
|
→ PostController.list
|
||||||
|
→ postsMapper.listPublishedKeyset(categoryId, cursorCreatedAt, cursorId, limit+1) ← AC-8 keyset
|
||||||
|
→ limit+1 행이면 hasNext=true, 마지막 행을 다음 커서로
|
||||||
|
→ posts-list JSP: 카드 목록(title, og_image_url 썸네일, 작성자 display_name)
|
||||||
|
[익명] GET /posts/42
|
||||||
|
→ postsMapper.getPublished(42); null/DRAFT/is_delete → redirect:/posts
|
||||||
|
→ posts-detail JSP: body_sanitized_html 직접 출력(이미 sanitize됨) + OG 카드(og_title/desc/image)
|
||||||
|
```
|
||||||
|
|
||||||
|
### S3. 유니티 피드 폴링(@Scheduled + SSRF + dedupe + 알림)
|
||||||
|
```
|
||||||
|
[스케줄러] UnityFeedPoller.poll() (@Scheduled fixedDelay, 예: 30분)
|
||||||
|
→ for each source in feedSourcesMapper.listActive():
|
||||||
|
result = ssrfSafeFetcher.fetch(source.feedUrl, FEED_MAX_BYTES, FEED_CONTENT_TYPES)
|
||||||
|
- 실패 → feedSourcesMapper.updateError(source.id, reason); continue (graceful) ← AC-7
|
||||||
|
items = feedParser.parse(result.body) // RSS/Atom → (guid, title, link, publishedAt)
|
||||||
|
newItems = items where guid not in (DB ux_unity_feed_items_source_guid) ← AC-6 dedupe
|
||||||
|
for each newItem:
|
||||||
|
feedItemsMapper.insertIgnoreDup(source.id, guid, title, link, publishedAt)
|
||||||
|
(ux_unique 충돌 시 무시 — DB 레벨 2차 dedupe 방어)
|
||||||
|
feedSourcesMapper.updateCursor(source.id, lastSeenGuid=items[0].guid, last_polled_at=now)
|
||||||
|
→ 운영자가 GET /admin/unity-feeds 진입 시 미확인(is_acknowledged=false) 항목 배지 노출 ← AC-6 알림
|
||||||
|
```
|
||||||
|
- **dedupe 이중 방어**: (1) 폴링 로직이 guid 대조로 신규만 선별 + (2) DB `ux_unity_feed_items_source_guid` UNIQUE 가 경합·재폴링 시 중복 INSERT 를 무시(insertIgnoreDup = `ON CONFLICT DO NOTHING`). 단일 인스턴스라도 수동 트리거(`/admin/unity-feeds/poll`)와 스케줄 겹침 방어.
|
||||||
|
|
||||||
|
### S4. 카테고리 운영(ADMIN 인터셉터 게이트)
|
||||||
|
```
|
||||||
|
[ADMIN 세션] POST /admin/post-categories (CSRF, name, slug, sortOrder)
|
||||||
|
→ RbacInterceptor preHandle: /admin/** → isAdmin 통과(아니면 403/401) ← AC-3
|
||||||
|
→ PostAdminController.createCategory
|
||||||
|
→ CsrfTokens.isValid 실패 → 403
|
||||||
|
→ slug 중복 → 409
|
||||||
|
→ categoryMapper.insert → 200 {categoryId}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 영향 맵
|
||||||
|
|
||||||
|
> 소유권 분할 가이드(implementation-advisor worker 단위 후보):
|
||||||
|
> **U-SCHEMA**(board-ddl + schema.sql 동기) · **U-DOMAIN**(data POJO + 매퍼 6종) · **U-MARKDOWN**(PostMarkdownService + pom 의존) · **U-SSRF**(SsrfSafeFetcher + OgPreviewService — 보안 핵심, 단독 worker 권장) · **U-FEED**(UnityFeedPoller + FeedParser + 스케줄 활성) · **U-POST-CTRL**(PostController 공개/작성 + JSP) · **U-ADMIN-CTRL**(카테고리/피드 운영 컨트롤러 + JSP).
|
||||||
|
> 의존: U-SCHEMA → U-DOMAIN → {U-MARKDOWN, U-SSRF}. U-SSRF → {U-FEED(OgPreview 와 fetcher 공유), OG는 U-POST-CTRL 소비}. U-MARKDOWN/U-SSRF → U-POST-CTRL. U-DOMAIN → U-ADMIN-CTRL.
|
||||||
|
|
||||||
|
| 변경 유형 | 경로 | 역할 | 소유 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 신규 | `docs/board-ddl.sql` | 권위 DDL(posts/post_categories/unity_feed_sources/unity_feed_items). apply-local-ddl.sh 자동적용 | U-SCHEMA |
|
||||||
|
| 수정 | `db/schema.sql` | rbac 블록 뒤에 4테이블 동기 추가(board-ddl 사본) | U-SCHEMA |
|
||||||
|
| 수정 | `pom.xml` | commonmark + jsoup 의존 추가(HTTP 는 spring-web RestClient 내장) | U-MARKDOWN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/PostData.java` | posts 행 POJO(id/categoryId/authorUserId/title/bodyMarkdown/bodySanitizedHtml/linkUrl/og*/status + 조인 author displayName/categoryName) | U-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/PostCategoryData.java` | post_categories 행 POJO | U-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/UnityFeedSourceData.java` | unity_feed_sources 행 POJO | U-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/UnityFeedItemData.java` | unity_feed_items 행 POJO | U-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/PostsMapper.java` | `@Mapper` posts CRUD + keyset 페이징(listPublishedKeyset/getPublished/insert/update/softDelete, `#{}`, snake→camel alias) | U-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/PostCategoriesMapper.java` | `@Mapper` 카테고리 CRUD(listActive/getActive/insert/update/softToggle/delete/countPostsByCategory) | U-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/UnityFeedSourcesMapper.java` | `@Mapper` 소스 CRUD(listActive/insert/updateCursor/updateError/toggle/delete) | U-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/UnityFeedItemsMapper.java` | `@Mapper` 항목(insertIgnoreDup=ON CONFLICT DO NOTHING/listUnacknowledged/countUnacknowledged/acknowledge) | U-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/service/PostMarkdownService.java` | 마크다운→sanitize HTML(commonmark+jsoup Safelist). render(markdown) | U-MARKDOWN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/security/SsrfSafeFetcher.java` | ★외부 fetch 단일 관문(SSRF 방어 전수). fetch(url,maxBytes,types)/isFetchableUrl(url) | U-SSRF |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/service/OgPreviewService.java` | OG 메타 추출(SsrfSafeFetcher 경유 + jsoup OG 파싱). fetch(linkUrl)→OgPreview | U-SSRF |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/service/UnityFeedPoller.java` | `@Scheduled` 폴링(SsrfSafeFetcher + FeedParser + dedupe). poll()/pollOnce(sourceId) | U-FEED |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/service/FeedParser.java` | RSS/Atom 파싱(JDK javax.xml — 신규 의존 0). parse(bytes)→List<FeedItem> | U-FEED |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/config/SchedulingConfig.java` | `@EnableScheduling`(현재 미활성, code-fact 0 hit) | U-FEED |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/PostController.java` | 공개 목록/상세(JSP) + 작성/수정/삭제(POST_WRITE 게이트, JSON). PermissionGate 주입 | U-POST-CTRL |
|
||||||
|
| 신규 | `src/main/webapp/WEB-INF/views/posts-list.jsp` | 포스트 카드 목록 + 카테고리 탭 + keyset 더보기(JSTL escape) | U-POST-CTRL |
|
||||||
|
| 신규 | `src/main/webapp/WEB-INF/views/posts-detail.jsp` | 본문(sanitized html) + OG 카드(escape, og_image src 출력) | U-POST-CTRL |
|
||||||
|
| 신규 | `src/main/webapp/WEB-INF/views/posts-form.jsp` | 작성/수정 폼(textarea markdown + CSRF hidden + 카테고리 select) | U-POST-CTRL |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/PostAdminController.java` | 카테고리 CRUD(/admin/post-categories, 인터셉터 ADMIN 게이트 + CSRF) | U-ADMIN-CTRL |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/UnityFeedAdminController.java` | 피드 소스/항목 운영(/admin/unity-feeds, ADMIN + CSRF + ack/poll) | U-ADMIN-CTRL |
|
||||||
|
| 신규 | `src/main/webapp/WEB-INF/views/admin-post-categories.jsp` | 카테고리 운영 화면(CSRF) | U-ADMIN-CTRL |
|
||||||
|
| 신규 | `src/main/webapp/WEB-INF/views/admin-unity-feeds.jsp` | 피드 소스/미확인 항목 + 배지(CSRF) | U-ADMIN-CTRL |
|
||||||
|
| 수정 | `src/main/webapp/WEB-INF/views/header.jsp` | 포스팅 메뉴 링크 추가(`/posts`) + (운영자) 미확인 피드 배지 노출 후보 | U-POST-CTRL |
|
||||||
|
| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 매퍼 6종·SsrfSafeFetcher·서비스 @MockBean 등록(contextLoads, §30) | (검증) |
|
||||||
|
| 신규 | `src/test/.../SsrfSafeFetcherTest.java` | ★SSRF 전수 단위(사설/루프백/메타데이터/rebinding/redirect/size/timeout/scheme 차단) | (검증) |
|
||||||
|
| 신규 | `src/test/.../PostMarkdownServiceTest.java` | sanitize XSS 차단(script/onclick/javascript:/iframe 제거) 단위 | (검증) |
|
||||||
|
| 신규 | `src/test/.../PostControllerTest.java` | 작성 POST_WRITE 게이트 401/403/CSRF/검증 + keyset 페이징 | (검증) |
|
||||||
|
| 신규 | `src/test/.../FeedParserTest.java` + `UnityFeedPollerTest.java` | RSS/Atom 파싱 + dedupe(신규만 insert) 단위 | (검증) |
|
||||||
|
|
||||||
|
> SSR 호출지점: 전부 신규 경로(기존 호출지점 깨짐 0). header.jsp 메뉴 추가는 표시용.
|
||||||
|
|
||||||
|
### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
|
||||||
|
```java
|
||||||
|
// PostsMapper (@Mapper, #{} only, snake→camel 직접 alias)
|
||||||
|
List<PostData> listPublishedKeyset(java.time.OffsetDateTime cursorCreatedAt, // keyset 커서(null=첫페이지)
|
||||||
|
Long cursorId, // 동일 created_at 타이브레이커
|
||||||
|
Long categoryId, // null=전체 카테고리
|
||||||
|
int limit) // 페이지 크기(컨트롤러가 limit+1 요청해 hasNext 판정)
|
||||||
|
PostData getPublished(long id) // 상세(PUBLISHED && !is_delete + author/category 조인)
|
||||||
|
int insert(PostData post) // 작성(useGeneratedKeys)
|
||||||
|
int update(PostData post) // 수정(title/markdown/sanitized/og/status)
|
||||||
|
int softDelete(long id) // is_delete=true, deleted_at=now
|
||||||
|
|
||||||
|
// PostCategoriesMapper
|
||||||
|
List<PostCategoryData> listActive() // 공개 탭 + 작성폼 select
|
||||||
|
PostCategoryData getActive(long id) // 작성 시 카테고리 유효성
|
||||||
|
int insert(PostCategoryData c) / int update(PostCategoryData c) / int delete(long id)
|
||||||
|
int countPostsByCategory(long categoryId) // 삭제 거부(409) 판정
|
||||||
|
|
||||||
|
// UnityFeedSourcesMapper
|
||||||
|
List<UnityFeedSourceData> listActive() // 폴링 대상
|
||||||
|
int insert(UnityFeedSourceData s)
|
||||||
|
int updateCursor(long id, String lastSeenGuid) // 폴링 성공 후 커서 전진
|
||||||
|
int updateError(long id, String lastError) // 폴링 실패 graceful 기록
|
||||||
|
int toggle(long id) / int delete(long id)
|
||||||
|
|
||||||
|
// UnityFeedItemsMapper
|
||||||
|
int insertIgnoreDup(UnityFeedItemData item) // ON CONFLICT(source_id,guid) DO NOTHING
|
||||||
|
List<UnityFeedItemData> listUnacknowledged(int limit) // 운영자 알림 목록
|
||||||
|
int countUnacknowledged() // 배지 카운트
|
||||||
|
int acknowledge(long itemId) // 확인 처리
|
||||||
|
|
||||||
|
// OgPreviewService — SsrfSafeFetcher 경유. linkUrl 1개 최소.
|
||||||
|
Optional<OgPreview> fetch(String linkUrl) // 실패/SSRF거부 시 empty(graceful)
|
||||||
|
|
||||||
|
// UnityFeedPoller
|
||||||
|
void poll() // @Scheduled 전체 활성 소스 폴링
|
||||||
|
int pollOnce(long sourceId) // 수동 트리거(반환 newCount)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 대안 비교
|
||||||
|
|
||||||
|
| 주제 | 안 | 장점 | 단점 | 채택 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 외부 fetch 인프라 | (A) 공용 SsrfSafeFetcher 단일 | OG·피드 SSRF 방어 중복 0, 검증 일관 | 컴포넌트 1개 추상화 | **채택** |
|
||||||
|
| | (B) OG·피드 각자 fetch | 결합 낮음 | SSRF 방어 2벌 — 한쪽 누락 시 보안 구멍 | 기각 |
|
||||||
|
| DNS rebinding 방어 | (A) connect 후 peer IP 재검증 | 라이브러리 호환, TOCTOU 최소화 | host 재resolve 미차단 잔여(concern) | **채택(1차)** |
|
||||||
|
| | (B) custom SocketFactory IP 핀닝 | 완전 핀닝 | 구현 복잡·HttpClient 통합 부담 | 보류(필요시 승격) |
|
||||||
|
| 본문 sanitize 시점 | (A) 저장 시 1회 → 캐시 | 조회 저비용, 정책변경 재생성 여지 | 컬럼 1개(body_sanitized_html) | **채택** |
|
||||||
|
| | (B) 조회 시 매번 sanitize | 컬럼 1개 절약 | 매 조회 CPU, 캐시 이점 0 | 기각 |
|
||||||
|
| 피드 임포트 | (A) 감지+알림(운영자 수동 작성) | 큐레이션 품질·법적/저작권 안전 | 자동화 아님 | **채택** |
|
||||||
|
| | (B) 감지 후 자동 포스트 생성 | 완전 자동 | 저작권·스팸·중복 위험 | 기각(비목표) |
|
||||||
|
| 페이징 | (A) keyset(created_at,id) | 깊은 페이지 일관·성능 | 커서 전달 | **채택** |
|
||||||
|
| | (B) OFFSET/LIMIT | 단순 | 깊은 OFFSET 성능·삽입 시 드리프트 | 기각 |
|
||||||
|
| 카테고리 권한 | (A) ADMIN(/admin/** 인터셉터) | 신규 권한키 0, 인프라 재사용 | 카테고리=ADMIN 한정 | **채택** |
|
||||||
|
| | (B) 신규 CATEGORY_MANAGE 키 | 세분화 | over-engineering(소비 1곳) | 기각 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 롤아웃 / 마이그레이션
|
||||||
|
|
||||||
|
### 순서
|
||||||
|
1. **의존 추가**(U-MARKDOWN 선): pom.xml commonmark + jsoup → `./mvnw -o compile` 확인. (HTTP 는 RestClient 내장, 의존 0.)
|
||||||
|
2. **스키마 적용**: `docs/board-ddl.sql` → `db/apply-local-ddl.sh`. 기존 데이터 무관(신규 4테이블, 추가만).
|
||||||
|
3. **권한 시드 불필요**: POST_WRITE 키는 W1 PermissionCatalogVerifier 가 이미 enum→DB 시드(grounding R-A). 운영자가 콘솔에서 특정 SUBADMIN 에게 POST_WRITE 토글 부여하면 그때부터 포스팅 작성 가능.
|
||||||
|
4. **코드 배포**: 매퍼/서비스/컨트롤러/JSP. SchedulingConfig 활성 → 피드 폴링 시작.
|
||||||
|
5. **운영자 카테고리·피드 등록**: 콘솔에서 첫 카테고리 + 유니티 피드 소스 등록(SSRF 선검증 통과 URL만).
|
||||||
|
|
||||||
|
### 역호환
|
||||||
|
- 신규 경로·테이블·의존만 추가 — 기존 기능 영향 0. RecruitController/리뷰/RBAC 동작 불변.
|
||||||
|
- `/admin/**` 카테고리·피드 경로는 기존 RbacInterceptor `/admin/**` ADMIN 게이트(InterceptorConfig.java:19)에 자동 포섭 — 인터셉터 수정 불필요(경로 패턴 이미 커버).
|
||||||
|
|
||||||
|
### 롤백
|
||||||
|
- 코드 롤백: 컨트롤러/스케줄러 미등록 시 포스팅 기능 비활성. 신규 테이블 잔존해도 무해(비파괴).
|
||||||
|
- 의존 롤백: commonmark/jsoup 제거 시 PostMarkdownService 컴파일 깨짐 — 코드 동반 롤백.
|
||||||
|
- 스키마 롤백: 추가 전용이라 DROP 없이 잔존 무해. 명시적 DROP 은 별도 maintenance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AC 매핑
|
||||||
|
|
||||||
|
| AC | 요구 | 만족 설계 요소 | 비고 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AC-1 | POST_WRITE 보유자만 작성, 미보유 403 | PostController.create 의 `permissionGate.has(session, POST_WRITE)` (임시 role 체크 0) | S1, D2 |
|
||||||
|
| AC-2 | 일반 유저 읽기 전용 + 댓글 없음 | 공개 GET 만 인증 불요, 작성 게이트, 댓글 엔드포인트·테이블 부재 | D3 |
|
||||||
|
| AC-3 | 카테고리/피드 운영 ADMIN 전용 | `/admin/**` 경로 → RbacInterceptor isAdmin 게이트 | S4, §API |
|
||||||
|
| AC-4 | 본문 XSS 차단(sanitize) | PostMarkdownService render(commonmark+jsoup Safelist), script/on*/javascript: 제거 | D4, §sanitize |
|
||||||
|
| AC-5 | 상태변경 CSRF 없으면 403 | 전 쓰기 엔드포인트 `CsrfTokens.isValid` 선검증 | §API 공통 |
|
||||||
|
| AC-6 | 유니티 피드 새 글 감지·dedupe·알림 | UnityFeedPoller guid dedupe + ux_unique 2차 방어 + 미확인 배지 | S3, D6 |
|
||||||
|
| AC-7 | OG/피드 fetch 실패 graceful | SsrfSafeFetcher Optional.empty → OG NULL/피드 skip+last_error | S1/S3, §SSRF #9 |
|
||||||
|
| AC-8 | 게시판 keyset 페이징 | listPublishedKeyset(cursor) + idx_posts_*_keyset | S2, D8 |
|
||||||
|
| AC-9 | ★SSRF 전수 방어 | SsrfSafeFetcher 9항목 체크리스트(scheme/IP/rebinding/redirect/size/timeout/type/graceful) | §SSRF |
|
||||||
|
| AC-10 | SQL `${}` 0 | 신규 매퍼 6종 전부 `#{}` | §파일영향맵 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 포인트 (verification-advisor 점검 대상)
|
||||||
|
|
||||||
|
> L레벨 매핑(verification-strategies.md): 권한 게이트/인가 플로우 = **L1+L2+L3**. ★SSRF 방어 = **L1(차단 단위) 필수 + L3(실제 외부 차단 스모크)**. 신규 매퍼 SQL/alias = **L1+L2(dev DB contract)**. 신규 빈 다수 = full `./mvnw -o test` 의무(§30).
|
||||||
|
|
||||||
|
### 시나리오 검증
|
||||||
|
- **VP-1 (AC-1 게이트, L1+L3)**: PostControllerTest — POST_WRITE 미보유 세션 작성 → 403, 보유(또는 ADMIN) → 200. 미인증 → 401. L3: 콘솔에서 토글 부여 후 작성 통과.
|
||||||
|
- **VP-2 (AC-9 ★SSRF, L1 필수)**: SsrfSafeFetcherTest — `http://127.0.0.1`, `http://169.254.169.254/latest/meta-data`, `http://10.0.0.1`, `http://[::1]`, `file:///etc/passwd`, IPv4-mapped IPv6, redirect→사설IP, size 초과, timeout 전부 차단/abort. **rebinding mock**(resolve=공인, connect peer=사설) 차단. L3: 실 외부 URL OG 성공 + 사설 URL 차단 스모크.
|
||||||
|
- **VP-3 (AC-4 sanitize, L1)**: PostMarkdownServiceTest — `<script>`, `<img onerror=>`, `[x](javascript:alert(1))`, `<iframe>` 입력이 출력 HTML 에서 제거됨. 정상 마크다운(볼드/링크/리스트)은 보존.
|
||||||
|
- **VP-4 (AC-6 피드 dedupe, L1)**: UnityFeedPollerTest — 동일 guid 재폴링 시 insert 0(신규만), 신규 guid 만 unity_feed_items 추가 + countUnacknowledged 증가.
|
||||||
|
- **VP-5 (AC-5 CSRF, L1)**: 전 쓰기 엔드포인트 CSRF 누락 → 403 + mapper 미호출(RecruitController/W3-2 패턴 준용).
|
||||||
|
- **VP-6 (AC-8 keyset, L1+L2)**: listPublishedKeyset 커서 페이징 정확성 + alias 매핑(snake→camel) DB-방언 계약.
|
||||||
|
- **VP-7 (contextLoads, L1)**: BibimbapApplicationTests 에 신규 매퍼 6종·SsrfSafeFetcher·서비스 @MockBean 등록 후 PASS(§30).
|
||||||
|
|
||||||
|
### 집합 전수 체크 AC (시점·표현 self-audit 적용)
|
||||||
|
> self-audit (시점 안정성): 아래 카운트는 본 워크스트림이 신규 생성하는 정적 산출물 또는 DDL/체크리스트 항목으로, verification 시점까지 본 워크스트림 외 변경 주체가 없다(시점 안정). 자기 트리처럼 계속 증가하는 대상 아님.
|
||||||
|
> self-audit (표현 견고성): 단일 리터럴 grep 취약성을 피해 (a) DDL 객체 카운트는 `CREATE TABLE IF NOT EXISTS` 고정 패턴 + 테이블명 집합, (b) SSRF/sanitize 는 의미 불변식 + 수동 판정으로 앵커한다.
|
||||||
|
|
||||||
|
- **AC-T1 신규 board 테이블 전수 4건** — `docs/board-ddl.sql` 의 `CREATE TABLE IF NOT EXISTS` 4건(post_categories/posts/unity_feed_sources/unity_feed_items) AND schema.sql 동기 4건: `grep -c 'CREATE TABLE IF NOT EXISTS' docs/board-ddl.sql` == 4. 누락·오타 동시 검출.
|
||||||
|
- **AC-T2 ★SSRF 방어 체크리스트 전수 9항목** — SsrfSafeFetcherTest 가 §SSRF 9항목(scheme/resolve/사설·루프백·링크로컬·메타데이터/rebinding/redirect/size/timeout/Content-Type/graceful) **전부**에 대응하는 차단 테스트를 보유. 검증: 9항목 각각 최소 1 테스트 메서드 존재(수동 매핑 — 리터럴 grep 아님, 의미 단위). **1항목이라도 미커버 시 SSRF 보안 구멍 → FAIL**(이 전수 AC 가 보안 핵심 가드).
|
||||||
|
- **AC-T3 쓰기 엔드포인트 CSRF 가드 전수** — PostController/PostAdminController/UnityFeedAdminController 의 모든 상태변경(@PostMapping) 핸들러에 `CsrfTokens.isValid` 선검증 존재: 각 컨트롤러 내 `@PostMapping` 수 == `CsrfTokens.isValid` 호출 수. 핸들러 추가 시 가드 누락 동시 검출.
|
||||||
|
- **AC-T4 신규 매퍼 SQL `${}` 0건** — 신규 매퍼 6종(PostsMapper/PostCategoriesMapper/UnityFeedSourcesMapper/UnityFeedItemsMapper + 향후 추가분)에 `${` 매치 0: `grep -rc '\${' <매퍼 파일들>` == 0 (AC-10).
|
||||||
|
- **AC-T5 POST_WRITE enforcement 연결 확인** — 작성/수정/삭제 핸들러(쓰기 3종)가 `permissionGate.has(...POST_WRITE...)` 호출: PostController 의 POST_WRITE 게이트 호출 수 == 작성·수정·삭제 핸들러 수(3). 게이트 누락 핸들러(임시 개방) 동시 검출 — 보안 가드.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 잔여 오픈 질문
|
||||||
|
없음(0). 확정 결정 D1~D9 고정. 두 보안 난제(마크다운 sanitize · SSRF 방어)는 본 설계가 구체 메커니즘(commonmark+jsoup Safelist / SsrfSafeFetcher 9항목 체크리스트)으로 확정. SSRF rebinding 구현 라이브러리 제약·신규 빈 full-test·DB-방언 L2·pom 의존 추가·시그니처 inflate 위험·@EnableScheduling 활성은 오픈 질문이 아니라 **구현 단계 점검 항목**으로 `concerns` 에 이관.
|
||||||
|
|
@ -0,0 +1,346 @@
|
||||||
|
---
|
||||||
|
phase: design
|
||||||
|
agent: design-advisor
|
||||||
|
agent_version: 1
|
||||||
|
generated_at: 2026-06-23T16:30:00+09:00
|
||||||
|
workstream: W3-4-메인페이지(게임 허브)
|
||||||
|
concerns:
|
||||||
|
- "WebMvcController.indexModelAndView 시그니처 확장(query → query+cursor)은 최소 인자로 명세했다. 구현 단계에서 cursor 파싱 헬퍼(parseCursor)의 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고 방지, 프로토콜 §11.2)."
|
||||||
|
- "GamesMapper 신규 keyset 메서드(listVisibleKeyset/searchVisibleKeyset) 의존은 WebMvcController 가 이미 GamesMapper 를 주입받으므로 신규 빈 0 — 그러나 신규 매퍼 SQL 은 DB-방언 계약(L2) 대상. keyset 3-튜플 비교((sort_order,created_at,id) row-comparison 또는 OR 분해) PostgreSQL 동작은 dev DB contract 로 실측 검증 권장(verification-strategies §33). 신규 매퍼 메서드 추가만이라 BibimbapApplicationTests @MockBean 신규 등록 불요(GamesMapper 기존 등록 재사용)."
|
||||||
|
- "진행중 잼 배너는 W2-1 jams 테이블(status/is_visible) 의존 + W3-1 잼 태그 검색 라우트(라우팅 타깃)에 의존한다. 본 설계 작성 시점 W3-1-tags-search-design.md 미존재(병렬 워크스트림) → 배너 클릭 라우트는 §외부계약 '잼 검색 라우트 계약'으로 앵커만 고정. W3-1 이 실제 경로/파라미터를 확정하면 그 계약을 단일 출처로 채택. 배너 도입(단계2)은 W2-1 + W3-1 착지 후 — 단계1(페이징/그리드)은 선착수 독립."
|
||||||
|
- "동시 진행 잼 복수(N≥2) 처리는 '최신 1건 배너 + 전체 N건 카운트 라벨'로 확정(아래 D4). N=0 이면 배너 미렌더(기존 동작 회귀 0). jams.is_visible IS NOT FALSE 인 진행중 잼만 카운트 — 비공개 잼 노출 방지."
|
||||||
|
- "GamesMapper 에 jam 컬럼 추가 없음(W2-1 D1: games 무변경, 연결은 jam_entries). 따라서 허브 그리드는 잼 출품작을 별도 강조하지 않고 기존 전체 그리드 유지 — '신규 출품작 강조'는 별도 그리드 섹션이 아니라 배너 CTA(잼 검색 라우팅)로만 표현(중복노출 회피, 확정결정)."
|
||||||
|
concerns_checked: true
|
||||||
|
self_verification:
|
||||||
|
checklist_passed: true
|
||||||
|
references:
|
||||||
|
requirements: docs/work-log/2026-06-23-w2-w4-feature-skeletons.md
|
||||||
|
research: .atp/work-session/20260623-104307/research/W2-W4-grounding.md
|
||||||
|
adrs:
|
||||||
|
- .atp/work-session/20260623-104307/implementation/W2-1-jam-entity-design.md
|
||||||
|
- .atp/work-session/20260622-180054/implementation/W1-design.md
|
||||||
|
- docs/work-log/2026-06-17-jam-platform-roadmap.md
|
||||||
|
- docs/development/verification-strategies.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# 설계: W3-4 — 메인페이지(게임 허브): index.jsp 게임 허브 확장 + keyset 페이징 + 진행중 잼 안내 배너(W3-1 라우팅)
|
||||||
|
|
||||||
|
## 목표 / 비목표
|
||||||
|
|
||||||
|
### 목표 (FR/NFR 추적 — 골자 W3-4, stale 정정 #4 "index.jsp 잼 노출 없음 → W3-4 유효")
|
||||||
|
- **G1 게임 허브 확장**: 현 `index.jsp` 전체 게임 카드 그리드를 게임 허브로 확장. 현 정렬(`sort_order ASC, created_at DESC, id DESC`) **그대로 유지**(회귀 0).
|
||||||
|
- **G2 keyset 페이징(정석)**: 전건 로드(현 `getVisibleGames` 전건)를 **keyset 커서 페이징**으로 전환. 커서 = `(sort_order, created_at, id)` 3-튜플. offset 대비 깊은 페이지 일관성(삽입 시 행 밀림 중복 0, 누락 0). 검색(`q`) 경로도 동일 keyset.
|
||||||
|
- **G3 진행중 잼 안내 배너(조건부)**: **진행중 잼이 있을 때만** 검색창 아래·그리드 위에 "게임잼 진행 중" 배너 + CTA. 진행중 = `jams.status IN ('RECRUIT','DEV','EVAL')`(W2-1 상태 4값 중 CLOSED 제외) AND `is_visible IS NOT FALSE`.
|
||||||
|
- **G4 잼 검색 라우팅**: 배너 CTA 클릭 → **W3-1 잼 태그 검색**으로 라우팅(별도 그리드 섹션 아님 — 출품작 중복노출 회피, 확정결정). "신규 출품작 강조"는 배너 CTA 로만 표현.
|
||||||
|
- **G5 동시 진행 잼 복수 처리**: N≥2 진행중 잼 시 **최신 1건(`created_at DESC`)을 배너 주체 + 전체 N건 카운트 라벨**("외 N-1개 진행 중"). N=0 → 배너 미렌더.
|
||||||
|
- **G6 단계 착수 분리**: 단계1(G1·G2 허브/페이징)은 W2/W3-1 **무관 독립 선착수**. 단계2(G3·G4·G5 잼 배너)는 W2-1(jams) + W3-1(잼 검색 라우트) 착지 **후**.
|
||||||
|
- **NFR**: 검색/페이징 파라미터 `#{}` 바인딩(`${}` 금지), 커서/검색어 입력 sanitize, JSP 출력 `HtmlUtils.htmlEscape`, 배너 링크 `textContent`/escape, 비파괴(신규 매퍼 메서드 추가만·games DDL 0변경), 기존 index 동작 회귀 0.
|
||||||
|
|
||||||
|
### 비목표 (스코프 밖)
|
||||||
|
- **잼 엔티티/CRUD/출품**(jams/jam_entries 테이블·매퍼·관리자 콘솔) — **W2-1 소유**. 본 설계는 jams **조회만**(진행중 카운트/최신 1건).
|
||||||
|
- **잼 태그 검색 화면·태그 스키마·검색 SQL** — **W3-1 소유**. 본 설계는 배너 CTA 가 그 라우트로 **링크만**(계약 앵커).
|
||||||
|
- **games 스키마 변경**(jam_id/방문수/정렬키 추가) — games 무변경(W2-1 D1 정합). 정렬은 기존 3키 유지.
|
||||||
|
- **무한스크롤 JS 본체 고도화**(가상 스크롤·prefetch) — 1차는 서버 keyset + "더 보기" 버튼/링크(nextCursor). 가상화는 후속.
|
||||||
|
- **개인화 추천·인기순 재정렬** — 정렬 변경은 별도. 본 설계는 현 정렬 유지 + 페이징만.
|
||||||
|
- **잼 출품작 전용 그리드 섹션** — 확정결정으로 **미채택**(중복노출 회피). 배너 라우팅으로 대체.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
bibimbap 의 메인 허브는 `WebMvcController.indexView` → `indexModelAndView(query)`(WebMvcController.java:52-112 직접 확인)가 담당한다. 현재 `gamesMapper.getVisibleGames()`(전건) 또는 검색 시 `searchVisibleGames(query)`(전건)를 `model.games` 로 주입하고 `index.jsp` 가 `games` 리스트를 단일 그리드로 렌더한다(index.jsp:491-537 직접 확인). 정렬은 `sort_order ASC, created_at DESC, id DESC`(GamesMapper.java:60,89 직접 확인). 페이징은 전무(전건 로드). index.jsp 에 잼 노출 0(work-log stale 정정 #4 "안 뒤집힘").
|
||||||
|
|
||||||
|
본 설계는 두 가지를 더한다.
|
||||||
|
1. **keyset 페이징(G2)** — 전건 로드를 커서 기반 페이지로 전환. 커서 = 현 정렬키 3-튜플 `(sort_order, created_at, id)`. 정렬·필터·검색은 그대로 두고 `WHERE` 에 커서 비교 + `LIMIT pageSize+1` 만 추가 → 현 결과 순서 회귀 0, 깊은 페이지 일관성 확보.
|
||||||
|
2. **진행중 잼 안내 배너(G3~G5)** — `jams.status IN ('RECRUIT','DEV','EVAL') AND is_visible IS NOT FALSE`(W2-1 jams)인 잼을 조회해 N≥1 이면 검색창 아래 배너를 렌더. 배너 CTA 는 **W3-1 잼 태그 검색 라우트**로 라우팅(별도 그리드 아님 — 중복노출 회피).
|
||||||
|
|
||||||
|
확정된 정석 결정(전제):
|
||||||
|
- **정렬 불변 + keyset(D2)**: offset 페이징(행 밀림 중복) 대신 keyset. 현 정렬 3키가 그대로 커서 → 추가 정렬·인덱스 변경 최소. games 무변경.
|
||||||
|
- **배너 ≠ 그리드 섹션(D3)**: 진행중 잼 출품작을 허브에 두 번째 그리드로 깔면 일반 그리드와 중복노출. 대신 **배너 + CTA → W3-1 검색**으로 단일 진입(확정결정).
|
||||||
|
- **복수 잼 = 최신 1건 + 카운트(D4)**: 동시 진행 N개 시 배너 본문은 최신 1건, "외 N-1개" 라벨. 잼 목록 전체 노출은 W2-1 `/jams` 가 소유.
|
||||||
|
- **단계 분리(D5)**: 단계1(허브/페이징)은 jams 미존재여도 독립 동작 → 선착수. 단계2(배너)는 W2-1+W3-1 후. 한 워크스트림이나 착수 게이트가 둘.
|
||||||
|
|
||||||
|
가장 까다로운 두 난제 확정:
|
||||||
|
- **난제1 (검색·비검색 keyset 단일화)**: 비검색(`getVisibleGames`)과 검색(`searchVisibleGames`)이 동일 정렬·동일 커서 의미를 가져야 "더 보기"가 두 경로에서 일관. → 두 신규 매퍼 메서드가 **동일 커서 WHERE 절 + 동일 ORDER BY + 동일 LIMIT 규약**을 공유하고, 컨트롤러는 검색어 유무로만 분기(커서 처리 코드는 공통 헬퍼). 검색 경로도 keyset 으로 통일(검색 결과가 많을 때 동일 일관성).
|
||||||
|
- **난제2 (배너 의존성 부재 시 동작)**: W2-1 jams 미착지 또는 진행중 잼 0건 시 배너는 **렌더되지 않아야 하고 허브는 정상**이어야 한다(단계1 독립성). → 배너 데이터는 `activeJam`(최신 1건, nullable) + `activeJamCount`(int, 0 가능) 모델 attr 로 주입하되, **JamsMapper 미존재(W2-1 미착지) 단계1 에서는 이 attr 자체를 주입하지 않음**(JSP 가 attr 부재 시 배너 미렌더). 단계2 착지 후 attr 주입 활성화. 즉 JSP 는 `activeJamCount > 0` 일 때만 배너 렌더 → 의존성 부재/0건 모두 안전 회귀 0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 핵심 결정 요약 (전제 — 재논의 금지)
|
||||||
|
|
||||||
|
| 결정 | 확정값 | 본 설계의 구체화 |
|
||||||
|
|---|---|---|
|
||||||
|
| D1 허브 확장 | index.jsp = 게임 허브 | 현 그리드 유지 + 페이징 + 조건부 잼 배너. games 무변경 |
|
||||||
|
| D2 페이징 | keyset 커서 | 커서 = `(sort_order, created_at, id)` 3-튜플. 현 정렬 그대로. offset 기각 |
|
||||||
|
| D3 잼 노출 | 배너 + CTA(그리드 아님) | 진행중 잼 시 배너 → W3-1 잼 검색 라우팅. 별도 출품작 그리드 미채택(중복노출 회피) |
|
||||||
|
| D4 복수 잼 | 최신 1건 + 카운트 | `created_at DESC` 최신 1건 배너 + "외 N-1개" 라벨. N=0 → 미렌더 |
|
||||||
|
| D5 단계 분리 | 단계1 독립 / 단계2 의존 | 단계1(페이징) 선착수, 단계2(배너) = W2-1+W3-1 후 |
|
||||||
|
| D6 검색 통일 | 검색도 keyset | 비검색·검색 동일 커서 규약(난제1). 컨트롤러는 q 유무만 분기 |
|
||||||
|
| D7 페이지 진행 | 서버 nextCursor + 더보기 링크 | 1차는 "더 보기"(nextCursor 쿼리). 무한스크롤 JS 는 후속(점진) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 모델 (DDL)
|
||||||
|
|
||||||
|
> **신규 테이블 0**(확정결정). games(기존, W2-1 무변경) 조회 + jams(W2-1 신규, 본 설계는 조회만). 본 설계가 신규 추가하는 DDL 은 **없다** — keyset 성능 인덱스 1건만 권장(games 무파괴, 멱등).
|
||||||
|
|
||||||
|
### games keyset 정렬 인덱스 (권장 — 성능, 신규 테이블/컬럼 아님)
|
||||||
|
- 현 정렬 `sort_order ASC, created_at DESC, id DESC` 에 대한 keyset seek 효율을 위해 복합 인덱스 1건을 **권장**한다(games 컬럼 변경 0 — 인덱스만 추가, 비파괴 멱등).
|
||||||
|
- 권위 = 신규 파일 없이 **W2-1 의 docs/jam-ddl.sql 과 별개의 게임 허브 성능 인덱스**는 games 소유라 신설 `docs/games-hub-ddl.sql`(멱등) 또는 기존 games 관리 위치에 추가. 단 **games 는 schema.sql:88-100 비권위 복원본**(grounding R-C) → 인덱스 멱등 추가는 `CREATE INDEX IF NOT EXISTS`.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- W3-4 게임 허브 keyset 페이징 성능 인덱스. games 컬럼 변경 0(인덱스만). 멱등.
|
||||||
|
-- 권위 = docs/games-hub-ddl.sql (apply-local-ddl.sh 글롭 docs/*-ddl.sql 자동 적용) + db/schema.sql 동기 사본.
|
||||||
|
-- 정렬키 = sort_order ASC, created_at DESC, id DESC (GamesMapper.java:60 와 동일 순서).
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_games_visible_keyset"
|
||||||
|
ON "games" ("is_visible", "is_delete", "sort_order" ASC, "created_at" DESC, "id" DESC);
|
||||||
|
```
|
||||||
|
- **인덱스 없이도 동작**(소규모면 seq scan 도 정확) — 인덱스는 깊은 페이지/대량 시 seek 최적화. 채택 권장하되 keyset 정합의 필수 전제는 아님(정합은 WHERE 비교가 보장).
|
||||||
|
|
||||||
|
### jams 조회 (W2-1 소유 — 본 설계는 읽기만)
|
||||||
|
- W2-1 `jams` 테이블의 `status`(CHECK 'RECRUIT'/'DEV'/'EVAL'/'CLOSED')·`is_visible`·`slug`·`title`·`created_at` 컬럼만 SELECT. 본 설계는 jams DDL 을 **추가/변경하지 않는다**(W2-1 권위 docs/jam-ddl.sql).
|
||||||
|
- 진행중 잼 정렬·인덱스는 W2-1 의 `idx_jams_visible_keyset`(W2-1-design §데이터모델1, `(is_visible,is_delete,created_at DESC,id DESC)`)을 그대로 소비(추가 인덱스 불요).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 외부 계약 (API)
|
||||||
|
|
||||||
|
> 공통: 본 설계의 변경은 **읽기(GET) 뷰만**(허브는 상태변경 없음). 상태변경 API 0 → CSRF 신규 적용 대상 없음(기존 index 도 GET). 검색·커서 파라미터는 매퍼 `#{}` 바인딩 + 컨트롤러 sanitize. 응답은 기존 패턴(JSP 뷰이름 반환 + model attr).
|
||||||
|
|
||||||
|
### 401 vs 403 정책 (W1-design 일치)
|
||||||
|
- 허브(`/`)는 **공개 페이지**(인증 불필요) — 미인증/미인가 분기 없음. 401/403 해당 없음.
|
||||||
|
- 잼 배너 CTA 가 라우팅하는 W3-1 잼 검색도 공개(읽기) 전제 → 인증 게이트 없음. (W3-1 이 인증 요구하면 W3-1 정책 따름 — 본 설계 무관.)
|
||||||
|
|
||||||
|
### 허브 페이지 (뷰 — 변경 대상)
|
||||||
|
| method | path | 권한 | 응답 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/` | 공개 | `index` JSP. 첫 페이지 games + `nextCursor` + (단계2)`activeJam`/`activeJamCount` 모델 주입 |
|
||||||
|
| GET | `/?q={검색어}` | 공개 | `index` JSP. 검색 첫 페이지(keyset) + `nextCursor` |
|
||||||
|
| GET | `/?cursor={sortOrder}_{createdAtEpochMillis}_{id}` | 공개 | `index` JSP. 다음 페이지(keyset). q 동반 시 검색 다음 페이지 |
|
||||||
|
| GET | `/?q={검색어}&cursor={...}` | 공개 | 검색 다음 페이지(keyset) |
|
||||||
|
|
||||||
|
- **커서 인코딩(확정)**: `cursor = "{sortOrder}_{createdAtEpochMillis}_{id}"`. 3 컴포넌트 `_` 구분. `createdAt` 은 epoch millis(timezone 모호성 제거, OffsetDateTime → toInstant().toEpochMilli()). 파싱 실패/형식 오류 → 첫 페이지로 폴백(throw 금지 — 사용자 입력 신뢰 금지, sanitize). **불투명 토큰(base64) 미채택 근거**: 3 정수/타임스탬프라 평문 디버깅 용이 + 변조해도 정렬·필터가 보호(권한 데이터 아님). 단 파싱은 방어적(NumberFormat catch → 첫 페이지).
|
||||||
|
- **nextCursor 산정**: `LIMIT pageSize+1` 로 조회 → 결과 size > pageSize 면 `hasNext=true`, (pageSize+1 번째 잘라내고) 마지막 잔류 행의 `(sortOrder, createdAtEpochMillis, id)` 로 nextCursor 생성. size ≤ pageSize 면 nextCursor=null(더보기 미표시).
|
||||||
|
- **pageSize 확정**: 상수 24(카드 그리드 — 후속 조정 가능, 매직넘버는 컨트롤러 상수 `HUB_PAGE_SIZE`). 1차 고정.
|
||||||
|
|
||||||
|
### 잼 검색 라우트 계약 (W3-1 라우팅 타깃 — 앵커, W3-1 이 실제 경로 확정)
|
||||||
|
> 배너 CTA 의 링크 대상. **W3-1-tags-search-design.md 가 미존재(작성 시점)** → 아래는 라우팅 계약 앵커. W3-1 이 경로/파라미터를 확정하면 그 계약을 단일 출처로 채택(concern 3). 단계2 착수 시 W3-1 확정 경로로 본 링크 1줄을 정렬.
|
||||||
|
- **계약**: 배너 CTA 는 "진행중 잼의 출품작을 잼 태그로 필터한 검색 결과"로 라우팅한다.
|
||||||
|
- **앵커 후보(W3-1 확정 전 잠정)**: `GET /jams/{slug}`(W2-1 잼 상세, 출품작 JOIN 노출 — W2-1-design §외부계약 직접 확인) 또는 W3-1 `GET /?q=#{잼태그}` / 전용 잼 검색 경로. **단계2 구현 시 W3-1 확정 경로 1개로 고정**(둘 다 열어두지 않음 — 오픈 질문 회피 위해 폴백 우선순위 확정: W3-1 잼 검색 라우트 존재 시 그것, 미확정이면 W2-1 `/jams/{slug}` 상세로 라우팅).
|
||||||
|
- **본 설계가 고정하는 것**: 배너는 `activeJam.slug`(또는 W3-1 태그 식별자)를 링크에 담아 라우팅한다는 **구조**. 실제 path 토큰은 단계2 구현에서 W3-1/W2-1 확정값으로 치환.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 인터셉터 / 게이트 연동
|
||||||
|
- **해당 없음**. 허브(`/`)는 공개 GET, RbacInterceptor `/admin/**` 경로와 무관(InterceptorConfig.java:18-20 직접 확인 — `/admin/**` 만 등록). 본 설계는 인터셉터/게이트 변경 0. W3-1/W2-1 의 게이트는 각 워크스트림 소유.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 시퀀스 (주요 플로우 의사코드)
|
||||||
|
|
||||||
|
### S1. 허브 첫 페이지 + keyset 더보기 (단계1 — jams 무관 독립)
|
||||||
|
```
|
||||||
|
[공개] GET /
|
||||||
|
→ WebMvcController.indexView(q=null, cursor=null)
|
||||||
|
→ indexModelAndView(query=null, cursor=null):
|
||||||
|
normalizedQuery = "" # blank
|
||||||
|
cursor 파싱: 없음 → (sortOrder=null, createdAt=null, id=null) 첫 페이지
|
||||||
|
rows = gamesMapper.listVisibleKeyset(null, null, null, HUB_PAGE_SIZE+1)
|
||||||
|
# WHERE is_visible IS NOT FALSE AND is_delete IS NOT TRUE AND u.is_delete IS NOT TRUE
|
||||||
|
# (커서 null → 커서 비교 절 미적용)
|
||||||
|
# ORDER BY sort_order ASC, created_at DESC, id DESC LIMIT pageSize+1
|
||||||
|
hasNext = rows.size > HUB_PAGE_SIZE
|
||||||
|
if hasNext: last = rows.get(HUB_PAGE_SIZE-1); rows = rows.subList(0, HUB_PAGE_SIZE)
|
||||||
|
nextCursor = last.sortOrder + "_" + last.createdAt.toEpochMilli + "_" + last.id
|
||||||
|
else: nextCursor = null
|
||||||
|
model: games=rows, searchQuery="", nextCursor
|
||||||
|
(단계2면) model: activeJam, activeJamCount ← S3
|
||||||
|
→ "index"
|
||||||
|
|
||||||
|
[공개] GET /?cursor=10_1718000000000_57
|
||||||
|
→ indexModelAndView(query=null, cursor="10_1718000000000_57"):
|
||||||
|
parseCursor → (sortOrder=10, createdAtMillis=1718000000000, id=57)
|
||||||
|
rows = gamesMapper.listVisibleKeyset(10, instant(1718000000000), 57, pageSize+1)
|
||||||
|
# 커서 비교(다음 페이지): 정렬 sort_order ASC, created_at DESC, id DESC 의
|
||||||
|
# "커서 행보다 뒤" = keyset 3-튜플 lexicographic:
|
||||||
|
# sort_order > c.sortOrder
|
||||||
|
# OR (sort_order = c.sortOrder AND created_at < c.createdAt)
|
||||||
|
# OR (sort_order = c.sortOrder AND created_at = c.createdAt AND id < c.id)
|
||||||
|
... (hasNext/nextCursor 동일)
|
||||||
|
```
|
||||||
|
- **keyset 비교 방향 논증(난제1)**: 정렬이 혼합 방향(sort_order ASC, created_at DESC, id DESC)이라 단일 row-comparison `(a,b,c) > (...)` 가 안 맞는다 → **OR 분해**(위 3절)로 각 키 방향에 맞춰 비교. dev DB contract 로 실측 검증(concern 2). 동일 sort_order 다수 시 created_at/ id tie-break 으로 중복·누락 0.
|
||||||
|
|
||||||
|
### S2. 검색 + keyset (D6 — 비검색과 동일 규약)
|
||||||
|
```
|
||||||
|
[공개] GET /?q=플랫폼&cursor=...
|
||||||
|
→ indexModelAndView(query="플랫폼", cursor=...):
|
||||||
|
normalizedQuery = "플랫폼"
|
||||||
|
rows = gamesMapper.searchVisibleKeyset(query, cursorSortOrder, cursorCreatedAt, cursorId, pageSize+1)
|
||||||
|
# 기존 searchVisibleGames 의 ILIKE 3컬럼 절(name/display_name/creator_note) + 동일 커서 OR 분해 + 동일 ORDER BY/LIMIT
|
||||||
|
... (hasNext/nextCursor 동일, q 도 nextCursor 링크에 보존: /?q=플랫폼&cursor=...)
|
||||||
|
```
|
||||||
|
- 컨트롤러 분기는 `normalizedQuery.isBlank()` 하나뿐 — 커서 처리·hasNext·nextCursor 산정은 **공통 헬퍼**(중복 0).
|
||||||
|
|
||||||
|
### S3. 진행중 잼 배너 (단계2 — W2-1 jams + W3-1 라우팅 후)
|
||||||
|
```
|
||||||
|
indexModelAndView 내 (단계2 활성 시):
|
||||||
|
active = jamsMapper.listActive(2)
|
||||||
|
# WHERE status IN ('RECRUIT','DEV','EVAL') AND is_visible IS NOT FALSE AND is_delete IS NOT TRUE
|
||||||
|
# ORDER BY created_at DESC, id DESC LIMIT 2 (최신 1 + "외 N" 판정용 1 = 2건)
|
||||||
|
activeCount = jamsMapper.countActive()
|
||||||
|
# 동일 WHERE 의 COUNT(*) (배너 "외 N-1개" 라벨용 — limit 2 로는 정확 N 모름)
|
||||||
|
if activeCount > 0:
|
||||||
|
model: activeJam = active.get(0) # 최신 1건(slug/title)
|
||||||
|
model: activeJamCount = activeCount # 전체 N (JSP 가 N-1 라벨 산정)
|
||||||
|
# activeCount == 0 → attr 미주입 → JSP 배너 미렌더(D4)
|
||||||
|
|
||||||
|
[JSP index.jsp] search-section 아래, card-grid 위:
|
||||||
|
<% Integer activeJamCount = (Integer) request.getAttribute("activeJamCount"); %>
|
||||||
|
<% if (activeJamCount != null && activeJamCount > 0) { %>
|
||||||
|
배너 렌더: activeJam.title(escape) + (count>1 ? "외 "+(count-1)+"개 진행 중" : "진행 중")
|
||||||
|
CTA href = ctx + <W3-1 확정 잼 검색 라우트 with activeJam.slug> # 라우팅(D3)
|
||||||
|
<% } %>
|
||||||
|
```
|
||||||
|
- **단계1 안전성(난제2)**: 단계1 에서는 컨트롤러가 `activeJam*` attr 자체를 주입하지 않음 → JSP `activeJamCount == null` → 배너 미렌더 → 허브 정상. JamsMapper 미착지여도 컴파일/런타임 무영향(매퍼 호출 코드는 단계2 에서 추가).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 영향 맵
|
||||||
|
|
||||||
|
> 소유권 분할 가이드(implementation-advisor worker 단위 후보 — 단계 분리와 정합):
|
||||||
|
> **H-MAPPER**(GamesMapper keyset 메서드 + 인덱스 DDL) · **H-CTRL**(WebMvcController keyset/커서 헬퍼) · **H-VIEW1**(index.jsp 더보기/페이징 — 단계1) · **H-JAM**(JamsMapper.listActive/countActive + WebMvc 배너 attr + index.jsp 배너 — 단계2, W2-1+W3-1 후).
|
||||||
|
> 의존: H-MAPPER → H-CTRL → H-VIEW1 (단계1 완결). H-JAM(단계2)은 W2-1 JamsMapper 존재 + W3-1 라우트 확정 후.
|
||||||
|
|
||||||
|
| 변경 유형 | 경로 | 역할 | 소유 / 단계 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 신규 | `docs/games-hub-ddl.sql` | keyset 성능 인덱스(idx_games_visible_keyset). games 컬럼 변경 0(인덱스만, 멱등) | H-MAPPER / 단계1 |
|
||||||
|
| 수정 | `db/schema.sql` | games 블록 뒤 인덱스 동기 사본(games-hub-ddl 사본). games 컬럼 무변경 | H-MAPPER / 단계1 |
|
||||||
|
| 수정 | `src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java` | `listVisibleKeyset(...)` + `searchVisibleKeyset(...)` 신규(`#{}`, snake→camel 직접 alias, 커서 OR 분해 + LIMIT). 기존 getVisibleGames/searchVisibleGames 보존(타 호출처 영향 0) | H-MAPPER / 단계1 |
|
||||||
|
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java` | `indexView`/`indexModelAndView` 에 cursor 파라미터 + keyset 호출 + nextCursor 산정 + parseCursor 헬퍼. 기존 GamesMapper 주입 재사용(신규 빈 0) | H-CTRL / 단계1 |
|
||||||
|
| 수정 | `src/main/webapp/WEB-INF/views/index.jsp` | card-grid 하단 "더 보기"(nextCursor 링크, q 보존) — 단계1. search-section 아래 진행중 잼 배너 — 단계2 | H-VIEW1(단계1) / H-JAM(단계2) |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamsMapper.java` 에 메서드 추가 (또는 W2-1 JamsMapper 가 이미 생성 시 메서드 추가) | `listActive(int limit)` + `countActive()`(진행중 잼 — `#{}`) | H-JAM / 단계2 (W2-1 JamsMapper 소유 조율 — concern) |
|
||||||
|
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java` | (단계2) JamsMapper 주입 + activeJam/activeJamCount attr 주입. JamsMapper 신규 의존 → @MockBean 확인 | H-JAM / 단계2 |
|
||||||
|
| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | (단계2만) WebMvcController 가 JamsMapper 주입 시 @MockBean 등록 확인(W2-1 이 이미 등록했으면 재사용). 단계1 은 GamesMapper 기존 등록 재사용 → 신규 등록 불요 | (검증) / 단계2 |
|
||||||
|
| 수정 | `src/test/.../WebMvcControllerTest.java`(없으면 신규) | keyset 첫/다음 페이지 + nextCursor 산정 + 검색 keyset + 커서 파싱 폴백 + (단계2)배너 attr 조건 단위 | (검증) |
|
||||||
|
|
||||||
|
> SSR 호출지점 전수 확인(verification-strategies §영향맵 SSR 포함): `index.jsp` 의 `games`(List<GameData>)·`searchQuery` attr 는 **보존**(타입/이름 불변) → 기존 렌더 루프(index.jsp:500-536) 회귀 0. 신규 `nextCursor`(String, nullable)·`activeJam`/`activeJamCount` 는 **신규 attr** → 기존 소비처 깨짐 0. `GamesMapper.getVisibleGames`/`searchVisibleGames` 기존 메서드는 **보존**(WebMvcController 외 호출처 없음 — rg getVisibleGames 로 단일 확인 권장, 그러나 신규 메서드 추가는 기존 시그니처 무변경이라 안전).
|
||||||
|
|
||||||
|
### 신규/변경 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
|
||||||
|
```java
|
||||||
|
// GamesMapper (@Mapper, #{} only, snake→camel 직접 alias — 일반매퍼 표준, verification §33)
|
||||||
|
// 비검색 keyset. 커서 3컴포넌트 null 이면 첫 페이지(WHERE 커서 절 미적용).
|
||||||
|
List<GameData> listVisibleKeyset(
|
||||||
|
Integer cursorSortOrder, // 커서 sort_order(첫페이지 null)
|
||||||
|
java.time.OffsetDateTime cursorCreatedAt,// 커서 created_at(첫페이지 null)
|
||||||
|
Long cursorId, // 커서 id tie-break(첫페이지 null)
|
||||||
|
int limit) // pageSize+1(hasNext 판정)
|
||||||
|
// 검색 keyset. q + 동일 커서 규약(D6 단일화).
|
||||||
|
List<GameData> searchVisibleKeyset(
|
||||||
|
String query, // ILIKE 3컬럼 부분일치(기존 searchVisibleGames 절 재사용)
|
||||||
|
Integer cursorSortOrder, // (동일)
|
||||||
|
java.time.OffsetDateTime cursorCreatedAt,// (동일)
|
||||||
|
Long cursorId, // (동일)
|
||||||
|
int limit) // (동일)
|
||||||
|
|
||||||
|
// WebMvcController (단계1) — 컨트롤러. cursor 추가, 최소 인자.
|
||||||
|
ModelAndView indexView(String query, // ?q= 검색어(nullable)
|
||||||
|
String cursor) // ?cursor= keyset 커서(nullable, 첫페이지)
|
||||||
|
// private 헬퍼 — 커서 문자열 파싱(방어적: 형식 오류 → null 반환 = 첫 페이지 폴백).
|
||||||
|
// 3 컴포넌트만 필요(sortOrder_createdAtMillis_id). 그 외 컨텍스트 불요(최소).
|
||||||
|
HubCursor parseCursor(String cursor) // "10_1718..._57" → {sortOrder,createdAt,id} | null
|
||||||
|
// HubCursor = 내부 record(Integer sortOrder, OffsetDateTime createdAt, Long id). DTO 추가 1개.
|
||||||
|
|
||||||
|
// JamsMapper (단계2 — W2-1 소유 매퍼에 메서드 추가, @Mapper, #{} only)
|
||||||
|
List<JamData> listActive(int limit) // 진행중 잼 최신순 limit(배너 최신 1건 + N판정)
|
||||||
|
int countActive() // 진행중 잼 전체 수(배너 "외 N-1개" 라벨)
|
||||||
|
```
|
||||||
|
> inflate 마킹(concern 1): `parseCursor` 는 **문자열 1개만** 받아 record 또는 null 을 반환(최소). 컨트롤러/세션 컨텍스트를 미리 받지 말 것 — 파싱은 순수 함수. `HubCursor` record 3필드 전부 매퍼 인자로 전달되므로 dead 필드 위험 낮으나, 구현에서 createdAt 인코딩(epoch millis vs ISO) 확정 후 타입 일치 재확인. `listActive(limit)` 의 limit 은 배너가 최신 1건만 쓰면 2(N≥2 판정용) — 구현에서 countActive 가 N 을 주므로 listActive limit=1 로 축소 가능(최신 1건만 필요) → 구현 시 limit 사용처 재확인(축소 후보).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 대안 비교
|
||||||
|
|
||||||
|
| 주제 | 안 | 장점 | 단점 | 채택 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 페이징 | (A) keyset(sort_order,created_at,id 커서) | 깊은 페이지 O(log n)+인덱스 seek, 삽입 시 중복/누락 0, 현 정렬 그대로 | 혼합방향 OR 분해 SQL, 커서 파싱 | **채택(D2)** |
|
||||||
|
| | (B) offset/limit | 단순(LIMIT n OFFSET m) | 깊은 페이지 비용, 게임 추가/sort_order 변경 시 행 밀림 중복·누락 | 기각 |
|
||||||
|
| | (C) 전건 로드(현행) | 최단 | 게임 누적 증가 시 전송/렌더 비용 선형 증가 | 기각(현행 개선 대상) |
|
||||||
|
| 잼 노출 | (A) 배너 + CTA → W3-1 검색 | 출품작 중복노출 회피, 단일 진입, 허브 그리드 무변경 | W3-1 라우트 의존 | **채택(D3)** |
|
||||||
|
| | (B) 진행중 잼 출품작 전용 그리드 섹션 | 즉시 노출 | 일반 그리드와 출품작 중복노출, jam_entries JOIN 조회 추가, 정렬 충돌 | 기각(확정결정) |
|
||||||
|
| 복수 잼 | (A) 최신 1건 배너 + 카운트 라벨 | 배너 단순, 전체는 /jams 위임 | 최신 외 잼 배너 미노출(목록은 /jams) | **채택(D4)** |
|
||||||
|
| | (B) 진행중 잼 전부 캐러셀 | 전부 노출 | 배너 비대·캐러셀 JS, 허브 산만 | 기각 |
|
||||||
|
| 커서 인코딩 | (A) 평문 `sortOrder_millis_id` | 디버깅 용이, 권한데이터 아님(변조 무해) | 형식 노출 | **채택** |
|
||||||
|
| | (B) base64 불투명 토큰 | 캡슐화 | 디버깅 난해, 이점 없음(권한 0) | 기각 |
|
||||||
|
| 검색 페이징 | (A) 검색도 keyset 통일(D6) | 일관 더보기, 공통 헬퍼 | 검색 SQL 에 커서 절 추가 | **채택** |
|
||||||
|
| | (B) 검색은 전건 유지 | 변경 최소 | 비검색만 페이징 = 더보기 동작 불일치 | 기각 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 롤아웃 / 마이그레이션
|
||||||
|
|
||||||
|
### 순서
|
||||||
|
**단계1 (독립 — W2/W3-1 무관 선착수)**:
|
||||||
|
1. **인덱스 적용**: `docs/games-hub-ddl.sql`(idx_games_visible_keyset) → `db/apply-local-ddl.sh`(로컬) / 운영 동일 멱등. games 컬럼 0변경 → 회귀 0. **인덱스 미적용이어도 keyset 정합 동작**(seq scan 도 정확) — 성능 최적화만.
|
||||||
|
2. **매퍼**: GamesMapper.listVisibleKeyset/searchVisibleKeyset 추가(기존 메서드 보존).
|
||||||
|
3. **컨트롤러**: WebMvcController cursor 처리 + nextCursor. 기존 indexView(q only) 동작 = cursor null 경로 = 첫 페이지(회귀 0).
|
||||||
|
4. **뷰**: index.jsp "더 보기" 링크(nextCursor, q 보존). nextCursor null 이면 미표시.
|
||||||
|
|
||||||
|
**단계2 (W2-1 jams + W3-1 잼 검색 라우트 착지 후)**:
|
||||||
|
5. **JamsMapper**: listActive/countActive 추가(W2-1 JamsMapper 에 — 소유 조율 concern).
|
||||||
|
6. **컨트롤러**: WebMvcController 에 JamsMapper 주입 + activeJam/activeJamCount attr. JamsMapper 신규 의존 → BibimbapApplicationTests @MockBean 확인(W2-1 이 이미 등록 시 재사용, full ./mvnw -o test).
|
||||||
|
7. **뷰**: index.jsp 진행중 잼 배너(activeJamCount>0 조건) + CTA(W3-1 확정 라우트).
|
||||||
|
|
||||||
|
### 역호환
|
||||||
|
- **단계1**: `index` 모델 `games`/`searchQuery` attr 보존 → JSP 렌더 루프 불변. 신규 `nextCursor` 만 추가. cursor 없는 기존 URL(`/`, `/?q=...`) = 첫 페이지(동작 동일, 단 결과가 pageSize 로 제한 — 전건→첫 페이지로 의미 변경되나 "더 보기"로 전건 도달 가능). 기존 검색 URL 호환.
|
||||||
|
- **단계2**: jams 0건/미착지 시 배너 미렌더 → 단계1 동작 그대로. 배너는 순수 추가(기존 attr 무변경).
|
||||||
|
|
||||||
|
### 롤백
|
||||||
|
- **단계1 롤백**: WebMvcController cursor 분기 제거 → 첫 페이지 매퍼 호출만(또는 기존 getVisibleGames 복귀). 인덱스는 추가 전용이라 잔존 무해(비파괴). index.jsp 더보기 링크 제거.
|
||||||
|
- **단계2 롤백**: WebMvcController activeJam attr 주입 제거 → JSP 배너 미렌더(activeJamCount null). JamsMapper 메서드는 미사용 잔존 무해. W2-1/W3-1 무영향.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AC 매핑
|
||||||
|
|
||||||
|
| AC | 요구(골자 W3-4) | 만족 설계 요소 | 비고 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AC-1 | 허브 = 현 그리드 + 정렬 유지 | listVisibleKeyset ORDER BY sort_order ASC,created_at DESC,id DESC(기존 동일) | D1, D2 |
|
||||||
|
| AC-2 | keyset 페이징(전건→커서) | listVisibleKeyset 커서 OR 분해 + LIMIT pageSize+1 + nextCursor | D2, S1 |
|
||||||
|
| AC-3 | 검색도 keyset 일관 | searchVisibleKeyset 동일 커서 규약 + 공통 헬퍼 | D6, S2 |
|
||||||
|
| AC-4 | 진행중 잼 시에만 배너 | activeJamCount>0 조건 렌더(0/미착지 → 미렌더) | D4, 난제2, S3 |
|
||||||
|
| AC-5 | 진행중 = RECRUIT/DEV/EVAL | jamsMapper.listActive/countActive WHERE status IN(3값) AND is_visible | G3, S3 |
|
||||||
|
| AC-6 | 배너 CTA → W3-1 잼 검색 라우팅 | 배너 href = W3-1 확정 잼 검색 라우트(slug) — 그리드 아님 | D3, §잼검색라우트계약 |
|
||||||
|
| AC-7 | 복수 잼 처리 명시 | 최신 1건 배너 + countActive "외 N-1개" 라벨 | D4, S3 |
|
||||||
|
| AC-8 | 단계1 독립 선착수 | jams 미착지여도 단계1 동작(attr 미주입) | D5, 난제2 |
|
||||||
|
| AC-9 | 검색/페이징 SQL `${}` 0 | 신규 매퍼 메서드 `#{}` only | NFR, §파일영향맵 |
|
||||||
|
| AC-10 | 기존 index 동작 회귀 0 | games/searchQuery attr 보존, 기존 메서드 보존 | §SSR 호출지점 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 포인트 (verification-advisor 점검 대상)
|
||||||
|
|
||||||
|
> L레벨 매핑(verification-strategies): 신규 매퍼 SQL/alias·keyset 커서 비교 = **L1+L2(dev DB contract)**. 컨트롤러 분기/커서 파싱 = **L1**. 허브 렌더/더보기·배너 조건 = **L1+L3 스모크**. 단계2 JamsMapper 신규 의존 = full `./mvnw -o test`(§30) — 단계1 은 GamesMapper 기존 의존 재사용이라 신규 @MockBean 불요.
|
||||||
|
|
||||||
|
### 시나리오 검증
|
||||||
|
- **VP-1 (AC-2 keyset, L1+L2)**: WebMvcControllerTest — 첫 페이지(cursor null) pageSize 행 + nextCursor 산정 / 다음 페이지(cursor) 중복·누락 0 / 마지막 페이지 nextCursor=null. dev DB contract: 혼합방향 OR 분해 커서 비교 실측(동일 sort_order 다수 시 created_at/id tie-break — 경계 데이터로 중복·누락 0 확인).
|
||||||
|
- **VP-2 (AC-3 검색 keyset, L1+L2)**: 검색 q + cursor 다음 페이지에 q 보존 + ILIKE 3컬럼 절 유지 + 커서 일관. 비검색과 동일 nextCursor 규약.
|
||||||
|
- **VP-3 (커서 파싱 폴백, L1)**: 형식 오류 cursor("abc", "1_2", "x_y_z") → 첫 페이지 폴백(NumberFormat catch, throw 0). 입력 신뢰 금지.
|
||||||
|
- **VP-4 (AC-9 SQL `${}` 0, L1)**: 신규 매퍼 메서드 `${` 매치 0.
|
||||||
|
- **VP-5 (AC-10 회귀, L1+L3)**: 기존 `/`·`/?q=` 렌더 PASS(games/searchQuery attr 동일). index.jsp 렌더 루프 변경 없음(더보기 링크는 그리드 외부 추가).
|
||||||
|
- **VP-6 (AC-4/5/7 배너 단계2, L1+L3)**: activeJamCount>0 시 배너 렌더 + 최신 1건 title escape + "외 N-1개" 라벨 / activeJamCount=0 또는 attr null 시 미렌더. JamsMapper.listActive WHERE status IN 3값 AND is_visible 확인. L3: W2-1 진행중 잼 시드 후 `/` 에 배너 노출 → CTA 링크가 W3-1 라우트.
|
||||||
|
- **VP-7 (단계2 contextLoads, L1)**: 단계2 에서 WebMvcController 가 JamsMapper 주입 시 BibimbapApplicationTests @MockBean 등록 후 PASS(§30). W2-1 이 이미 등록했으면 재사용 — 단계1 은 불요.
|
||||||
|
|
||||||
|
### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용)
|
||||||
|
> self-audit(시점): 아래 카운트는 **본 설계가 신규 생성하는 정적 산출물**(매퍼 메서드·진행중 status 값·정렬키)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리처럼 증가하는 대상 아님. 단 AC-T4(진행중 status 집합)는 W2-1 jams_status_check 와의 **동등성 불변식**으로 앵커 — W2-1 이 status 값을 바꾸면 두 곳이 함께 변해야 하므로 고정 스칼라 대신 동등성으로.
|
||||||
|
> self-audit(표현): 단일 리터럴 grep 취약성을 피해 정렬키 순서/매퍼 메서드 쌍/status IN 목록 같은 **구조적 불변식**에 앵커. 매퍼 `${` 0건만 리터럴(부재 검증은 리터럴 정당).
|
||||||
|
|
||||||
|
- **AC-T1 keyset 매퍼 메서드 전수 2건(비검색+검색) 정합** — GamesMapper 신규 keyset 메서드 = `listVisibleKeyset` + `searchVisibleKeyset` 2개. 검증: 두 메서드 전수 존재 AND **둘의 ORDER BY 절이 동일 토큰**(`sort_order ASC, created_at DESC, id DESC`) AND **둘의 커서 OR 분해 절이 동일**(난제1 단일화 — 한쪽만 바뀌면 더보기 불일치). 수동 판정(두 SQL 본문 diff = ILIKE 절 외 동일). 메서드 추가/삭제 누락을 쌍 정합으로 커버.
|
||||||
|
- **AC-T2 정렬키 3-튜플 전수 일치** — keyset 정렬키 = `(sort_order ASC, created_at DESC, id DESC)` 3키가 (a)기존 getVisibleGames(GamesMapper.java:60) (b)신규 listVisibleKeyset (c)신규 searchVisibleKeyset (d)커서 인코딩(sortOrder_createdAt_id) (e)idx_games_visible_keyset 5곳 전수 동일 순서·방향. 검증: 5곳 정렬키 순서·방향 수동 대조(불변식 — 한 곳 불일치 시 페이지 경계 깨짐). 정렬 회귀(AC-1)·keyset 정합(AC-2)의 공통 가드.
|
||||||
|
- **AC-T3 신규 매퍼 `${` 0건** — 신규 keyset 매퍼 2메서드(+단계2 JamsMapper listActive/countActive) 에 `${` 매치 0: `grep -c '\${' GamesMapper.java`(신규 메서드 범위) == 0 (AC-9, `${}` 동적치환 금지). 부재 검증이라 리터럴 정당.
|
||||||
|
- **AC-T4 진행중 잼 status 집합 = W2-1 CHECK 동등성 불변식** — 배너 진행중 정의 `status IN ('RECRUIT','DEV','EVAL')` 3값은 W2-1 `jams_status_check` 4값(RECRUIT/DEV/EVAL/CLOSED) **에서 CLOSED 만 제외한 정확한 부분집합**. 검증: listActive/countActive WHERE 의 status IN 목록 == {W2-1 status 4값} − {CLOSED}(동등성 불변식 — W2-1 이 status 값 추가/변경 시 진행중 정의도 함께 점검). 고정 스칼라 아닌 W2-1 CHECK 와의 집합 관계로 앵커(시점 안정).
|
||||||
|
- **AC-T5 단계별 의존 게이트 전수 — 단계1 신규 빈 0 / 단계2 JamsMapper 1** — 단계1 변경(GamesMapper 메서드/WebMvcController cursor/index.jsp 더보기)에 **신규 @MockBean 등록 0**(GamesMapper 기존 등록 재사용) → 단계1 만 머지 시 contextLoads PASS(신규 의존 없음 불변식). 단계2 머지 시 WebMvcController JamsMapper 주입 1건 → @MockBean 등록 확인 후 contextLoads PASS. 검증: 단계1 PR 의 BibimbapApplicationTests diff == 0(@MockBean 추가 없음) AND 단계2 PR 에서 JamsMapper @MockBean 존재. 단계 분리(D5/AC-8) 무결성 + §30 누락 동시 가드.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 잔여 오픈 질문
|
||||||
|
없음(0). 확정 결정 D1~D7 전제 고정. 두 난제(검색·비검색 keyset 단일화·배너 의존성 부재 안전)는 본 설계가 구체 메커니즘(공통 커서 헬퍼 + attr 미주입 조건 렌더)으로 확정. 복수 잼 처리(최신1+카운트), 커서 인코딩(평문 3컴포넌트), 단계 분리(단계1 독립/단계2 의존)도 확정. 구현 점검 항목(parseCursor inflate·keyset OR 분해 dev DB 실측·W3-1 잼 검색 라우트 확정값 치환·JamsMapper 소유 조율·단계2 @MockBean)은 오픈 질문이 아니라 `concerns` 로 이관.
|
||||||
|
|
@ -0,0 +1,434 @@
|
||||||
|
---
|
||||||
|
phase: design
|
||||||
|
agent: design-advisor
|
||||||
|
agent_version: 1
|
||||||
|
generated_at: 2026-06-23T12:10:00+09:00
|
||||||
|
workstream: W3-5-Unity WebGL 빌드 업로드 자동화 (보안 보강)
|
||||||
|
concerns:
|
||||||
|
- "PermissionGate 신규 메서드 require(session, key)→boolean 은 이미 실재(PermissionGate.java:64). 본 설계는 신규 시그니처를 만들지 않고 기존 has/require 를 컨트롤러 진입부에서 호출만 한다 — 구현 시 신규 게이트 메서드 추가 금지(dead method 방지)."
|
||||||
|
- "ZipSecurity 헬퍼의 신규 시그니처(validateEntryName/assertWithinBoundary/isWindowsAbsoluteOrUnc)는 최소 인자로 명세. 구현 단계에서 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고, 프로토콜 §11.2). 특히 totalEntryBytes 누적값은 extractZip 루프 지역변수로 충분할 수 있어 헬퍼 인자로 들지 말 것."
|
||||||
|
- "엔트리별 압축비(ratio) 임계 검사는 ZipInputStream 으로는 개별 엔트리의 '압축 후 크기'를 직접 못 얻는다(getCompressedSize() 는 stored/일부 deflate 에서 -1). 본 설계는 압축비 임계 대신 '엔트리당 해제 크기 상한 + 누적 해제 상한 + 엔트리 수 상한' 3중 상한으로 zip bomb 을 방어한다(ratio 미사용 정석). 구현 시 getCompressedSize() 의존 금지."
|
||||||
|
- "gameRoot() 의 dev 프로파일 경로 이중중첩(app.upload.game-storage-path=src/main/resources/static/game → resolve('game') → .../static/game/game) 은 본 W3-5 보안 스코프 밖의 설정 정합 이슈. 보안 설계는 'gameRoot() 가 무엇이든 그 canonical 경계 내'만 보장한다. 경로 중첩 정정은 별도 운영 정합 작업으로 분리(orchestrator 에스컬레이션) — 본 설계는 건드리지 않음."
|
||||||
|
- "심볼릭 링크 거부는 (a)엔트리 external-attributes 의 심링크 모드 비트 검사 + (b)쓰기 후 toRealPath 재검증 2중. JVM ZipEntry 표준 API 는 external attributes 를 직접 노출 안 하므로(java.util.zip.ZipEntry 에 getUnixMode 부재), 본 설계는 (b) 쓰기 직전 부모 디렉터리 실경로(toRealPath) 경계 재검증 + 디렉터리 생성 시 기존 심링크 거부로 정석화. ZipFile+0x... 비트 직접 파싱은 over-engineering 으로 미채택 — 구현 시 (b) 방식 준수."
|
||||||
|
concerns_checked: true
|
||||||
|
self_verification:
|
||||||
|
checklist_passed: true
|
||||||
|
references:
|
||||||
|
requirements: docs/work-log/2026-06-17-w3-feature-skeletons.md
|
||||||
|
research: .atp/work-session/20260623-104307/research/W3-5-upload-research.md
|
||||||
|
adrs:
|
||||||
|
- .atp/work-session/20260622-180054/implementation/W1-design.md
|
||||||
|
- docs/work-log/2026-06-17-jam-platform-roadmap.md
|
||||||
|
- docs/development/verification-strategies.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# 설계: W3-5 — Unity WebGL 빌드 업로드 자동화 (보안 보강 — zip-slip / zip bomb / 포맷검증 / 권한게이트 / 자산 생명주기)
|
||||||
|
|
||||||
|
## 목표 / 비목표
|
||||||
|
|
||||||
|
### 목표 (FR/NFR 추적)
|
||||||
|
- **U1 zip-slip 정석 방어** (FR-보안 / NFR-업로드 boundary): 조사 포인트1 의 구멍 전수 보강 — 심볼릭 링크 엔트리/디렉터리 거부, 쓰기 직전 실경로(canonical) 경계 재검증, 절대경로·UNC·백슬래시·NUL·드라이브 prefix 엔트리 거부, 엔트리명 길이/중첩깊이 상한.
|
||||||
|
- **U2 WebGL 포맷 검증** (FR-기능 정합): index.html 단독 존재 검증을 넘어 Unity 빌드 필수 산출물(`Build/*.loader.js` + `*.framework.js(.br/.gz)` + `*.data(.br/.gz)` + `*.wasm(.br/.gz)`) 존재 검증. 누락 시 거부 + 디렉터리 롤백.
|
||||||
|
- **U3 크기/타입 이중·zip bomb 방어** (NFR-보안): zip 원본 크기 상한(코드 레벨 명시) + zip 매직바이트(PK\x03\x04) 검증 + MIME·확장자 이중(AND) + 엔트리당 해제 크기 상한 + 누적 해제 상한(기존 512MB 유지) + 엔트리 수 상한(기존 8000 유지).
|
||||||
|
- **U4 저장 경로 boundary** (NFR-보안): `gameRoot()` canonical 경계 검증 유지·강화(rawTargetDir.toRealPath() 또는 부모 실경로 대조). UUID 기반 `/game/{uuid}/` 배치 유지.
|
||||||
|
- **U5 업로드 권한 게이트** (FR-9 흡수 / QG-1 W3-5 부분): 현 "CSRF + 로그인" 위에 W1 게이트 훅을 얹는다. 일반 게임 업로드는 모든 로그인 유저 개방을 **유지하되**, 게이트 진입점을 표준화(`PermissionGate.isAuthenticated` 명시 호출)하고, 잼 출품 업로드 분기를 위한 권한키 훅(`GAME_JAM_MANAGE` 옵션 게이트) 설계를 명시.
|
||||||
|
- **U6 UUID 재사용/멱등 교체** (FR-자산 생명주기): 같은 게임 자산 재업로드 = 임시 디렉터리에 추출·검증 후 원자적 swap(기존 디렉터리 교체) + 구버전 정리. 업로드 실패 시 임시 디렉터리만 삭제(기존 자산 무손상).
|
||||||
|
- **U7 자산 정리(고아 파일)** (FR-자산 생명주기): 편집 시 교체된 구 UUID 디렉터리 정리 + 게임 soft-delete 시 자산 디렉터리 정리 정책 확정.
|
||||||
|
- **U8 감사 로그** (NFR-감사): 업로드 성공/거부(사유)를 `game_upload_audit_log` 에 기록(보안 사고 추적 — zip-slip/bomb 거부 패턴 가시성).
|
||||||
|
- **NFR**: 상태변경 CSRF 전수 보존, MyBatis `#{}` 바인딩(`${}` 0), 입력 sanitize, 비파괴 마이그레이션.
|
||||||
|
|
||||||
|
### 비목표 (스코프 밖)
|
||||||
|
- **`/game/**` 서빙 핸들러 신설** — 조사 포인트5 로 **stale 확정**: `GameAssetController.gameAsset(@GetMapping("/game/{gameUuid}/**"))` 가 이미 UUID 정규화·boundary·Content-Type·Content-Encoding(br/gz)·CSP 까지 서빙(GameAssetController.java:31-69). ResourceHandler 미등록은 의도된 설계(보안헤더·인코딩 협상 필요로 전용 컨트롤러 채택). 본 설계는 **신설하지 않고 현황 유지·기록만**. 골자 QG-3 문구는 documentation-advisor 가 정정.
|
||||||
|
- `gameRoot()` 경로 이중중첩(static/game/game) 정정 — 별도 운영 정합 작업(concern 4).
|
||||||
|
- 게임 메타 등록/편집 본체(`GameController.createGame/updateGame`) 재설계 — 본 설계는 webgl-zip 업로드 파이프라인 보강 + 자산 생명주기 훅만. 단 자산 정리 호출지점은 명시.
|
||||||
|
- 새 잼 출품 워크플로 본체(W2) — 권한키 훅만 열어두고 잼 분기 본체는 W2 소관.
|
||||||
|
- 클라이언트 업로드 UI(game-register.jsp) 재설계 — 응답 계약 변경분만 명시(documentation/구현 연계).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
bibimbap 의 WebGL 업로드는 `GameUploadController`(`/api/game-files/**`)에 1차 골격이 구현돼 있다(zip 추출·prefix 검증·엔트리/누적 상한·UUID 배치·전용 서빙 컨트롤러). 조사(7항목)는 이 골격이 4개 축에서 비어 있음을 code-fact 로 확정했다: (1) 심볼릭 링크 방어 전무, (2) Unity 포맷 검증이 index.html 단독, (3) 권한 게이트 부재(로그인만), (4) 자산 생명주기(고아 파일) 미정의. 추가로 zip 매직바이트·원본 크기 상한·MIME+확장자 AND 가 비어 있다.
|
||||||
|
|
||||||
|
본 설계는 위 골격을 **재작성하지 않고 보강**한다. 핵심 구조 결정은 다음과 같다.
|
||||||
|
|
||||||
|
1. **zip-slip 정석 = 정규화 prefix 검증(기존) + 쓰기 직전 부모 실경로(toRealPath) 경계 재검증(신규) + 심링크 거부(신규) + 엔트리명 사전 거부 규칙(신규)**. 심링크는 표준 ZipEntry API 가 모드 비트를 노출하지 않으므로 "쓰기 경로의 실경로가 targetDir 밖으로 새는지" 를 차단하는 방식으로 정석화(concern 6).
|
||||||
|
2. **추출 → 임시 디렉터리, 검증 통과 후 원자적 swap**. 기존 코드는 최종 디렉터리에 직접 추출 후 실패 시 통째 삭제하나, 재업로드 멱등 교체(U6)와 zip bomb 부분추출 잔여 방지를 위해 **임시 추출 디렉터리(`{root}/.tmp/{uuid}`) → 검증 → `Files.move(ATOMIC_MOVE)` 로 최종 위치 교체** 로 바꾼다. 실패 시 임시만 삭제 → 기존 자산 무손상.
|
||||||
|
3. **권한 게이트는 W1 인프라 재사용**. 신규 게이트 메서드를 만들지 않고(`PermissionGate` 는 이미 has/require/isAuthenticated 보유), 컨트롤러 진입부에서 `isAuthenticated` 를 명시 호출. 잼 출품 분기는 `has(session, GAME_JAM_MANAGE.name())` 옵션 게이트 훅으로 설계만 열어둔다(미연결 enum 키 `GAME_JAM_MANAGE` 의 첫 enforcement 소비처 후보).
|
||||||
|
4. **자산 생명주기는 webgl_path 기반 UUID 추출로 정리**. games 테이블은 변경하지 않고(`webgl_path` 활용), 편집/삭제 시 `webgl_path` 에서 UUID 를 파싱해 디렉터리를 정리.
|
||||||
|
|
||||||
|
신규 DDL 은 감사 로그 1테이블만(`game_upload_audit_log`) — 업로드 메타는 games.`webgl_path` 를 그대로 활용한다(정석상 메타 중복 회피).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 핵심 결정 요약 (전제 — 재논의 금지)
|
||||||
|
|
||||||
|
| 결정 | 확정값 | 본 설계의 구체화 |
|
||||||
|
|---|---|---|
|
||||||
|
| D1 zip-slip | canonical 경계 + 심링크 거부 + 엔트리명 사전거부 | 정규화 prefix(기존) + 쓰기 직전 부모 toRealPath 경계 재검증 + 디렉터리 심링크 거부 + 절대/UNC/백슬래시/NUL/드라이브-prefix/`..`/길이/깊이 사전거부 |
|
||||||
|
| D2 포맷검증 | Unity 빌드 필수 산출물 존재 | index.html + (loader.js OR loader.js.br/.gz) + framework + data + wasm 4종(압축변형 허용) 존재. 누락 시 거부 |
|
||||||
|
| D3 크기/타입 | 다중 상한 + 매직바이트 + MIME·확장자 AND | 원본 ≤512MB + PK 시그니처 + (MIME ∈ 화이트리스트 AND .zip 확장자) + 엔트리당 해제 ≤256MB + 누적 ≤512MB(기존) + 엔트리수 ≤8000(기존) |
|
||||||
|
| D4 경로 boundary | gameRoot() canonical 경계 | rawTargetDir.normalize().startsWith(root)(기존) + 쓰기 시 부모 실경로 재검증. gameRoot() 정의 불변(중첩 정정은 스코프 밖) |
|
||||||
|
| D5 권한 게이트 | W1 인프라 재사용 + 일반업로드 개방 유지 | `PermissionGate.isAuthenticated` 명시 + 잼출품 분기 `has(GAME_JAM_MANAGE)` 옵션 훅. 신규 게이트 메서드 0 |
|
||||||
|
| D6 UUID 재사용 | 임시추출 → 검증 → 원자적 swap | `{root}/.tmp/{newUuid}` 추출·검증 후 `Files.move(ATOMIC_MOVE)`. 실패 시 임시만 삭제 |
|
||||||
|
| D7 자산 정리 | webgl_path UUID 파싱 정리 | 편집 교체 시 구 UUID dir 삭제 + soft-delete 시 자산 dir 삭제(즉시). 외부 입력 UUID 는 UUID.fromString 검증 후 경계 내만 |
|
||||||
|
| D8 감사로그 | 신규 game_upload_audit_log | 성공/거부(사유코드) 기록. games 메타는 webgl_path 재사용(신규 메타테이블 없음) |
|
||||||
|
| /game/** 서빙 | 현행 유지(stale 정정) | GameAssetController 실재 — 신설 안 함. 골자 문구만 정정(doc) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 모델 (DDL)
|
||||||
|
|
||||||
|
> 권위 워크플로: 신규 테이블은 **신규 `docs/game-upload-ddl.sql` 파일**(권위)로 제안. `db/apply-local-ddl.sh` 가 `docs/*-ddl.sql` 글롭 알파벳순 멱등 적용(ON_ERROR_STOP, search_path=dev). schema.sql 에 동기 사본. 업로드 메타는 games.webgl_path 활용 — **games 테이블 변경 0**.
|
||||||
|
|
||||||
|
### 신규 파일: `docs/game-upload-ddl.sql` (권위 DDL)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- W3-5 Unity WebGL 업로드 감사 로그. 멱등. db/apply-local-ddl.sh 로 비파괴 적용.
|
||||||
|
-- 업로드 성공/거부 추적(zip-slip/zip-bomb/포맷거부 패턴 가시성). games 메타는 games.webgl_path 활용 — 신규 메타테이블 없음.
|
||||||
|
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "game_upload_audit_log_id_seq";
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "game_upload_audit_log" (
|
||||||
|
"id" bigint DEFAULT nextval('game_upload_audit_log_id_seq'::regclass) NOT NULL,
|
||||||
|
"actor_id" bigint NOT NULL, -- 업로드 수행 사용자 users.id
|
||||||
|
"game_uuid" character varying(36), -- 생성/교체된 UUID(거부 시 null 가능)
|
||||||
|
"outcome" character varying(20) NOT NULL, -- SUCCESS / REJECTED
|
||||||
|
"reject_reason" character varying(40), -- REJECTED 시 사유코드(아래 reason 카탈로그)
|
||||||
|
"original_name" character varying(255), -- 업로드 파일 원본명(sanitize 후 저장)
|
||||||
|
"upload_bytes" bigint, -- zip 원본 크기
|
||||||
|
"entry_count" integer, -- 추출 엔트리 수(성공 시)
|
||||||
|
"extracted_bytes" bigint, -- 누적 해제 크기(성공 시)
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
ALTER SEQUENCE "game_upload_audit_log_id_seq" OWNED BY "game_upload_audit_log"."id";
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_upload_audit_log_actor_id_fkey') THEN
|
||||||
|
ALTER TABLE "game_upload_audit_log"
|
||||||
|
ADD CONSTRAINT "game_upload_audit_log_actor_id_fkey"
|
||||||
|
FOREIGN KEY ("actor_id") REFERENCES "users" ("id");
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_upload_audit_log_outcome_check') THEN
|
||||||
|
ALTER TABLE "game_upload_audit_log"
|
||||||
|
ADD CONSTRAINT "game_upload_audit_log_outcome_check"
|
||||||
|
CHECK ("outcome" IN ('SUCCESS', 'REJECTED'));
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_game_upload_audit_actor"
|
||||||
|
ON "game_upload_audit_log" ("actor_id", "created_at" DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_game_upload_audit_outcome"
|
||||||
|
ON "game_upload_audit_log" ("outcome", "created_at" DESC);
|
||||||
|
|
||||||
|
COMMENT ON TABLE "game_upload_audit_log" IS 'WebGL 업로드 감사 로그(성공/거부 추적, W3-5)';
|
||||||
|
COMMENT ON COLUMN "game_upload_audit_log"."reject_reason" IS 'REJECTED 사유코드: NOT_ZIP/MAGIC_FAIL/TOO_LARGE/TOO_MANY_ENTRIES/ZIP_SLIP/SYMLINK/ENTRY_TOO_LARGE/BAD_ENTRY_NAME/NO_INDEX/INCOMPLETE_BUILD';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 거부 사유코드 카탈로그 (reject_reason — 코드 상수와 1:1)
|
||||||
|
| 코드 | 트리거 | HTTP |
|
||||||
|
|---|---|---|
|
||||||
|
| `NOT_ZIP` | MIME·확장자 AND 실패 | 400 |
|
||||||
|
| `MAGIC_FAIL` | PK\x03\x04 시그니처 불일치 | 400 |
|
||||||
|
| `TOO_LARGE` | zip 원본 > 512MB | 413 |
|
||||||
|
| `TOO_MANY_ENTRIES` | 엔트리 > 8000 | 400 |
|
||||||
|
| `ENTRY_TOO_LARGE` | 엔트리당 해제 > 256MB | 400 |
|
||||||
|
| `EXTRACTED_TOO_LARGE` | 누적 해제 > 512MB | 400 |
|
||||||
|
| `ZIP_SLIP` | 정규화/실경로 경계 탈출 | 400 |
|
||||||
|
| `SYMLINK` | 심링크 엔트리/디렉터리 | 400 |
|
||||||
|
| `BAD_ENTRY_NAME` | 절대/UNC/백슬래시/NUL/드라이브/`..`/길이/깊이 | 400 |
|
||||||
|
| `NO_INDEX` | index.html 부재 | 400 |
|
||||||
|
| `INCOMPLETE_BUILD` | Unity 필수 산출물 누락 | 400 |
|
||||||
|
|
||||||
|
### `db/schema.sql` 반영
|
||||||
|
- `recruit_posts` 블록 뒤(또는 마지막 테이블 블록 뒤)에 `game_upload_audit_log` 블록 신설(rbac-ddl → schema.sql 동기 선례와 동일 — docs/game-upload-ddl.sql 이 권위, schema.sql 은 사본).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 외부 계약 (API)
|
||||||
|
|
||||||
|
> 공통: 상태변경은 `CsrfTokens.isValid(request)`(없으면 403 + `CsrfTokens.errorBody()`). 응답은 기존 패턴 — webgl-zip 은 `Map<String,Object>`(status/message). 인증 401, 권한 403.
|
||||||
|
|
||||||
|
### 401 vs 403 정책 (확정 — W1 정책 일치)
|
||||||
|
- **미인증**(세션 `userId` 없음): API 엔드포인트는 **401** JSON `{message:"로그인이 필요합니다."}`(기존 동작 보존).
|
||||||
|
- **인증·미인가**(잼 출품 업로드에 `GAME_JAM_MANAGE` 없음 등): **403** JSON `{status:403, message:"권한이 없습니다."}`. **단 일반 게임 업로드는 미인가 없음**(모든 로그인 유저 개방 — D5).
|
||||||
|
- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`(기존 보존).
|
||||||
|
|
||||||
|
### `POST /api/game-files/webgl-zip` (보강 — 시그니처 불변, 검증 강화)
|
||||||
|
| 항목 | 값 |
|
||||||
|
|---|---|
|
||||||
|
| method/path | `POST /api/game-files/webgl-zip` |
|
||||||
|
| 권한 | CSRF + 로그인(isAuthenticated). 일반 업로드 = 추가 권한 없음(개방 유지). `?mode=jam` 옵션 시 `GAME_JAM_MANAGE` 게이트(W2 연계 훅 — 본 W3-5 에서 enforcement 활성화 여부는 D5 참조) |
|
||||||
|
| 요청 | `multipart/form-data`: `file`(zip, 필수), `replaceUuid`(선택 — 재업로드 교체 대상 기존 UUID. 없으면 신규 생성), `mode`(선택, `jam`이면 잼 게이트) |
|
||||||
|
| 응답(200) | `{status:200, message, gameUuid, webglPath, deployPath, entryCount, extractedBytes}`(기존 + replaceUuid 처리 시 동일 UUID 반환) |
|
||||||
|
| 에러 | 400(reject_reason 별 message), 401(미인증), 403(CSRF/잼게이트), 413(TOO_LARGE) |
|
||||||
|
|
||||||
|
- **`replaceUuid` 추가 근거(U6)**: 기존은 무조건 신규 UUID(GameUploadController.java:126) → 재업로드마다 고아 디렉터리. `replaceUuid` 가 유효 UUID 이고 (a)경계 내 (b)요청자가 해당 게임 소유자(또는 ADMIN) 이면 같은 UUID 로 원자적 교체. 없으면 기존처럼 신규. **소유권 검증**: `replaceUuid` → games 에서 `webgl_path LIKE '/game/{uuid}/%'` 인 게임의 user_id == 세션 userId(또는 ADMIN). 불일치 시 403.
|
||||||
|
- **하위호환**: `replaceUuid`/`mode` 모두 선택 파라미터 — 미전송 시 기존 동작(신규 UUID, 일반 업로드) 100% 보존. 기존 game-register.jsp 클라이언트 무변경 동작.
|
||||||
|
|
||||||
|
### `POST /api/game-files/thumbnail` (권한 게이트만 명시 추가 — 검증 로직 기존 보존)
|
||||||
|
- 기존 CSRF + 로그인 유지. `gameUuid` 는 이미 `UUID.fromString` 정규화 + boundary 검증(GameUploadController.java:192-205). 변경: `isAuthenticated` 명시 호출로 통일(동작 동일).
|
||||||
|
|
||||||
|
### `POST /api/game-files` (root, uploadGameFiles)
|
||||||
|
- 본 설계 범위: 권한 게이트 진입점 통일(`isAuthenticated`)만. path 검증은 기존 `resolveTargetFile` 보존(이미 normalize+startsWith). 단 zip-slip 헬퍼 공통화 시 이 경로의 path 검증도 같은 `ZipSecurity` 규칙 일부(BAD_ENTRY_NAME) 재사용 권장(구현 판단).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 인터셉터/게이트 연동
|
||||||
|
|
||||||
|
### 보호 경로 매핑 (택1 확정: **컨트롤러 진입부 게이트 헬퍼** — 인터셉터 등록 안 함)
|
||||||
|
- `/api/game-files/**` 를 `InterceptorConfig` 의 `RbacInterceptor` 에 **등록하지 않는다**. 이유: RbacInterceptor 는 `/admin/**` ADMIN-only 게이트 전용(RbacInterceptor.java:34 `isAdmin`)이라, 업로드처럼 "로그인 개방 + 옵션 권한키" 분기에는 부적합. W1 설계 선례(콘솔=URL패턴 / 소비액션=게이트 헬퍼)대로 **업로드는 게이트 헬퍼 방식**.
|
||||||
|
- 컨트롤러 진입부:
|
||||||
|
1. `CsrfTokens.isValid(request)` (기존)
|
||||||
|
2. `permissionGate.isAuthenticated(session)` → false 면 401 (기존 `sessionUserId==null` 을 게이트 호출로 통일)
|
||||||
|
3. (옵션, `mode=jam`) `permissionGate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())` → false 면 403
|
||||||
|
- **신규 게이트 메서드 0**(concern 1): `PermissionGate.isAuthenticated`/`has` 는 이미 실재(PermissionGate.java:47,22). 그대로 호출만.
|
||||||
|
|
||||||
|
### 일반 업로드 개방 유지 논증 (D5)
|
||||||
|
- 골자·요구상 일반 게임 업로드 = 모든 로그인 유저 개방. 본 설계는 이 정책을 바꾸지 않고 **게이트 진입점을 표준화**(임의 role 직접체크 금지 — `session.getAttribute("role")` 직접비교 안 함). 잼 출품(W2)이 권한 제한을 요구하면 `mode=jam` 분기에서 `GAME_JAM_MANAGE` 게이트를 켠다. 이로써 `GAME_JAM_MANAGE`(현 소비처 0) 의 첫 enforcement 자리를 W3-5 가 훅으로 마련(활성화 본체는 W2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 시퀀스
|
||||||
|
|
||||||
|
### S1. webgl-zip 업로드 (신규/교체 공통 — 임시추출 → 검증 → 원자적 swap)
|
||||||
|
```
|
||||||
|
[로그인 세션] POST /api/game-files/webgl-zip (CSRF, file=build.zip, replaceUuid?, mode?)
|
||||||
|
→ CsrfTokens.isValid 아니면 403 + errorBody
|
||||||
|
→ permissionGate.isAuthenticated(session) 아니면 401
|
||||||
|
→ if mode=jam: permissionGate.has(session, GAME_JAM_MANAGE) 아니면 403
|
||||||
|
→ file null/empty 체크 아니면 400
|
||||||
|
→ ZipSecurity.assertZipType(file) # MIME ∈ 화이트리스트 AND .zip 확장자
|
||||||
|
실패 → audit(REJECTED, NOT_ZIP) → 400
|
||||||
|
→ ZipSecurity.assertMagic(file) # 첫 4바이트 PK\x03\x04 (또는 빈zip PK\x05\x06)
|
||||||
|
실패 → audit(REJECTED, MAGIC_FAIL) → 400
|
||||||
|
→ if file.getSize() > 512MB audit(REJECTED, TOO_LARGE) → 413
|
||||||
|
→ root = gameRoot()
|
||||||
|
→ newUuid = (replaceUuid 유효·소유검증 통과) ? replaceUuid : UUID.randomUUID()
|
||||||
|
replaceUuid 소유검증: gamesMapper 로 webgl_path 의 game.user_id == userId (또는 ADMIN)
|
||||||
|
불일치 → 403
|
||||||
|
→ tmpDir = root.resolve(".tmp").resolve(newUuid) # 임시 추출지
|
||||||
|
→ assertWithinBoundary(tmpDir, root) 아니면 400 ZIP_SLIP
|
||||||
|
→ Files.createDirectories(tmpDir)
|
||||||
|
→ ExtractResult = ZipSecurity.extractZip(file, tmpDir): # 보강된 추출(아래 S2)
|
||||||
|
엔트리별: validateEntryName → boundary 재검증 → 심링크 거부 → 쓰기 → toRealPath 재검증
|
||||||
|
상한: 엔트리수 8000 / 엔트리당 256MB / 누적 512MB
|
||||||
|
위반 → IllegalArgument(reasonCode) → tmpDir 삭제 → audit(REJECTED, reason) → 400
|
||||||
|
→ indexFile = findIndexFile(tmpDir) 없으면 tmpDir삭제 + audit(NO_INDEX) → 400
|
||||||
|
→ ZipSecurity.assertUnityBuild(tmpDir) # loader/framework/data/wasm 존재
|
||||||
|
실패 → tmpDir삭제 + audit(INCOMPLETE_BUILD) → 400
|
||||||
|
→ finalDir = root.resolve(newUuid)
|
||||||
|
→ if Files.exists(finalDir): # 교체(U6)
|
||||||
|
backupDir = root.resolve(".tmp").resolve(newUuid + ".old")
|
||||||
|
Files.move(finalDir → backupDir) # 구버전 대피
|
||||||
|
→ Files.move(tmpDir → finalDir, ATOMIC_MOVE) # 원자적 swap
|
||||||
|
→ deleteRecursively(backupDir) # 구버전 정리(U7)
|
||||||
|
→ webglPath = "/game/{newUuid}/" + 상대 index 경로
|
||||||
|
→ audit(SUCCESS, newUuid, entryCount, extractedBytes)
|
||||||
|
→ 200 {gameUuid:newUuid, webglPath, deployPath, entryCount, extractedBytes}
|
||||||
|
|
||||||
|
[실패 롤백 불변식] tmpDir/backupDir 는 finally 에서 잔여 시 삭제 → 기존 finalDir 자산 무손상.
|
||||||
|
```
|
||||||
|
|
||||||
|
### S2. ZipSecurity.extractZip 엔트리 루프 (zip-slip + zip bomb 정석)
|
||||||
|
```
|
||||||
|
extractedBytes=0, entryCount=0
|
||||||
|
while (entry = zip.getNextEntry()) != null:
|
||||||
|
entryCount++
|
||||||
|
if entryCount > 8000: throw(TOO_MANY_ENTRIES)
|
||||||
|
name = entry.getName()
|
||||||
|
validateEntryName(name): # BAD_ENTRY_NAME 사전거부
|
||||||
|
- null/blank → reject
|
||||||
|
- 절대경로: name.startsWith("/") || 드라이브(^[A-Za-z]:) || UNC(\\\\ 또는 //) → reject
|
||||||
|
- 백슬래시 포함('\\') → reject (윈도우 경로 우회 차단)
|
||||||
|
- NUL('\0') 포함 → reject
|
||||||
|
- 경로 분절에 ".." 존재 → reject (normalize 전 명시 차단)
|
||||||
|
- 분절 깊이 > 32 또는 name 길이 > 255 → reject
|
||||||
|
target = targetDir.resolve(name).normalize()
|
||||||
|
if !target.startsWith(targetDir): throw(ZIP_SLIP)
|
||||||
|
if entry.isDirectory():
|
||||||
|
assertParentNotSymlink(target, targetDir) # 부모 경로상 심링크 거부
|
||||||
|
Files.createDirectories(target)
|
||||||
|
else:
|
||||||
|
parent = target.getParent(); if null throw(BAD_ENTRY_NAME)
|
||||||
|
Files.createDirectories(parent)
|
||||||
|
assertParentNotSymlink(target, targetDir) # SYMLINK: 쓰기 직전 부모 실경로 검증
|
||||||
|
copied = copyEntry(zip, target, extractedBytes) # 엔트리당 256MB + 누적 512MB
|
||||||
|
extractedBytes += copied
|
||||||
|
# 쓰기 후 실경로 재검증(심링크가 새로 생겼거나 따라간 경우 차단)
|
||||||
|
if !target.toRealPath().startsWith(targetDir.toRealPath()): throw(SYMLINK)
|
||||||
|
zip.closeEntry()
|
||||||
|
if entryCount==0: throw(empty)
|
||||||
|
|
||||||
|
assertParentNotSymlink(target, targetDir):
|
||||||
|
# target 의 부모부터 targetDir 까지 각 구간이 심링크가 아님을 확인
|
||||||
|
p = target.getParent()
|
||||||
|
while p != null && p.startsWith(targetDir) && !p.equals(targetDir):
|
||||||
|
if Files.exists(p) && Files.isSymbolicLink(p): throw(SYMLINK)
|
||||||
|
p = p.getParent()
|
||||||
|
```
|
||||||
|
|
||||||
|
### S3. 자산 정리(U7) — 편집 교체 / soft-delete
|
||||||
|
```
|
||||||
|
[편집 교체] S1 의 replaceUuid 경로가 동일 UUID 교체 → 구 디렉터리 자동 정리(별도 GC 불요).
|
||||||
|
replaceUuid 미전송으로 신규 UUID 가 생성된 경우(기존 클라이언트 흐름):
|
||||||
|
GameController.updateGame 에서 old webgl_path UUID != new UUID 이면
|
||||||
|
assetCleanupService.purge(oldUuid) 호출(구 디렉터리 즉시 삭제, 경계검증 후).
|
||||||
|
|
||||||
|
[게임 삭제] GameController.deleteGame (CSRF + 소유/ADMIN, 기존):
|
||||||
|
softDeleteGame 직후 assetCleanupService.purge(uuidFromWebglPath(game.webglPath))
|
||||||
|
→ /game/{uuid}/ 디렉터리 즉시 삭제(soft-delete 와 정합: 메타는 soft, 자산은 hard).
|
||||||
|
근거: 자산은 복원 대상 아님(재업로드로 갈음), 디스크 누수 방지 우선.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 영향 맵
|
||||||
|
|
||||||
|
> 소유권 분할(implementation-advisor worker 단위 후보):
|
||||||
|
> **U-DDL**(감사 DDL/schema 동기) · **U-ZIPSEC**(ZipSecurity 헬퍼 + 추출 보강) · **U-UPLOAD**(GameUploadController 보강 + 게이트/감사) · **U-LIFECYCLE**(자산 정리 서비스 + GameController 훅) · **U-AUDIT-MAPPER**(감사 매퍼).
|
||||||
|
> 의존: U-DDL → U-AUDIT-MAPPER → U-UPLOAD. U-ZIPSEC 는 U-UPLOAD 선행. U-LIFECYCLE 는 U-UPLOAD 와 병렬 가능(독립 호출지점).
|
||||||
|
|
||||||
|
| 변경 유형 | 경로 | 역할 | 소유 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 신규 | `docs/game-upload-ddl.sql` | 권위 DDL(game_upload_audit_log). apply-local-ddl.sh 자동적용 | U-DDL |
|
||||||
|
| 수정 | `db/schema.sql` | game_upload_audit_log 블록 추가(ddl 사본) | U-DDL |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/security/ZipSecurity.java` | zip-slip/심링크/엔트리명/zip bomb 검증 + 추출 코어(정석 보강). 거부 시 reasonCode 담은 예외 | U-ZIPSEC |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/security/ZipRejectException.java` | reasonCode(거부 사유 enum) + message 담은 unchecked 예외. 컨트롤러가 사유코드→audit/HTTP 매핑 | U-ZIPSEC |
|
||||||
|
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java` | 진입부 게이트 통일(isAuthenticated) + 매직바이트/원본크기/MIME·확장자 AND + ZipSecurity 위임 + 임시추출→swap(U6) + replaceUuid 소유검증 + 감사기록 | U-UPLOAD |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/service/GameAssetCleanupService.java` | webgl_path UUID 파싱 → 경계검증 → 디렉터리 정리(purge). 편집/삭제 훅 | U-LIFECYCLE |
|
||||||
|
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java` | updateGame(구 UUID != 신 UUID 시 purge) + deleteGame(soft-delete 후 자산 purge) 훅 | U-LIFECYCLE |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/GameUploadAuditMapper.java` | `@Mapper` 감사 insert + (소유검증용) findGameByWebglUuid 조회. `#{}` only | U-AUDIT-MAPPER |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/GameUploadAudit.java` | 감사 행 POJO(actorId/gameUuid/outcome/rejectReason/originalName/uploadBytes/entryCount/extractedBytes) | U-AUDIT-MAPPER |
|
||||||
|
| 수정 | `src/main/java/com/pandoli365/bibimbap/config/UploadResourceConfig.java` | **변경 없음(현황 기록)** — /game/** 는 GameAssetController 서빙(비목표). `.tmp` 디렉터리가 정적노출 안 되도록 주석 명시(ResourceHandler 미추가 유지) | (기록) |
|
||||||
|
| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 매퍼(GameUploadAuditMapper)·서비스 @MockBean 등록(contextLoads — verification §30) | (검증) |
|
||||||
|
| 신규 | `src/test/.../ZipSecurityTest.java` | zip-slip/심링크/엔트리명/zip bomb/포맷검증 단위(악성 zip 픽스처) | (검증) |
|
||||||
|
| 신규 | `src/test/.../GameUploadControllerSecurityTest.java` | 게이트(401/403)/CSRF/매직바이트/원본크기/replaceUuid 소유검증/감사기록 | (검증) |
|
||||||
|
|
||||||
|
> SSR 호출지점 전수(verification-strategies 영향맵): webgl-zip 응답 JSON 키(gameUuid/webglPath/deployPath/entryCount/extractedBytes) 불변 → game-register.jsp 클라이언트(649-653 hidden 필드 교체) 무변경 동작. `replaceUuid`/`mode` 는 신규 선택 파라미터라 기존 호출 깨짐 0.
|
||||||
|
|
||||||
|
### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
|
||||||
|
```java
|
||||||
|
// ZipSecurity — zip 검증·추출 코어. static 유틸 + 추출은 인스턴스 불요(상한은 상수).
|
||||||
|
// 추출 결과(entryCount/extractedBytes)는 record 반환.
|
||||||
|
ExtractResult extractZip(MultipartFile file, // zip 입력 스트림 출처
|
||||||
|
Path targetDir) // 추출 목적지(이미 경계검증된 tmpDir)
|
||||||
|
throws ZipRejectException // 거부 시 reasonCode 전파(컨트롤러가 매핑)
|
||||||
|
|
||||||
|
void assertZipType(MultipartFile file) // MIME ∈ 화이트리스트 AND .zip 확장자(NOT_ZIP)
|
||||||
|
void assertMagic(MultipartFile file) // 첫 4바이트 PK\x03\x04(또는 PK\x05\x06 빈zip) (MAGIC_FAIL)
|
||||||
|
void assertUnityBuild(Path extractedRoot) // loader/framework/data/wasm 존재(INCOMPLETE_BUILD)
|
||||||
|
|
||||||
|
// 내부(private) — 인자 최소화. targetDir 경계는 호출자가 1회 검증 후 루프 내 재사용.
|
||||||
|
String normalizeAndValidateEntry(String entryName, Path targetDir) // 사전거부+경계, 반환=정규화된 target 경로 문자열은 불요 → Path 반환 검토(구현 1보)
|
||||||
|
|
||||||
|
// GameAssetCleanupService — webgl_path 기반 자산 정리. gameRoot 는 @Value 주입(컨트롤러와 동일 경로).
|
||||||
|
void purgeByWebglPath(String webglPath) // webgl_path 에서 UUID 파싱 → 경계 내 디렉터리 삭제
|
||||||
|
|
||||||
|
// GameUploadAuditMapper (@Mapper, #{} only)
|
||||||
|
int insertAudit(GameUploadAudit audit) // 단일 POJO 인자(필드 다수 → 1 POJO 가 정석)
|
||||||
|
GameAssetOwner findGameByWebglUuid(String gameUuid) // replaceUuid 소유검증용(user_id 반환). null=대상없음
|
||||||
|
```
|
||||||
|
> inflate 마킹(concern 2): `normalizeAndValidateEntry` 의 `targetDir` 는 경계검증(startsWith) 에 필요하므로 사용 확정. 단 반환 타입(String vs Path)은 호출 루프가 Path 를 바로 쓰는지로 결정 — 구현 1보에서 Path 반환으로 시작하고 String 가공이 불요하면 그대로. `extractZip` 의 상한값(8000/256MB/512MB)은 인자가 아니라 ZipSecurity 상수로 둔다(호출자가 매번 안 넘김 — inflate 방지).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 대안 비교
|
||||||
|
|
||||||
|
| 주제 | 안 | 장점 | 단점 | 채택 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 심링크 방어 | (A) 쓰기 직전 부모 toRealPath 경계 재검증 + isSymbolicLink 거부 | 표준 NIO API 만, 플랫폼 독립, 정석 | 디렉터리 walk 비용(엔트리당) | **채택** |
|
||||||
|
| | (B) ZipFile + external attributes 모드비트 직접 파싱(0xA000 심링크) | 추출 전 차단 | java.util.zip.ZipEntry 가 unix mode 미노출(직접 바이트파싱 필요), over-engineering | 기각 |
|
||||||
|
| zip bomb | (A) 엔트리당 해제상한 + 누적상한 + 엔트리수상한(3중) | ZipInputStream 만으로 측정 가능, 정석 | 압축비 직접 미측정 | **채택** |
|
||||||
|
| | (B) 엔트리 압축비(compressed/uncompressed) 임계 | 폭탄 조기탐지 | getCompressedSize() 가 -1 반환 케이스(stored/stream) → 신뢰불가 | 기각(concern 3) |
|
||||||
|
| 재업로드 교체 | (A) 임시추출 → 검증 → ATOMIC_MOVE swap | 실패 시 기존 자산 무손상, 부분추출 잔여 0, 멱등 | tmp 디렉터리·디스크 일시 2배 | **채택** |
|
||||||
|
| | (B) 최종 디렉터리 직접 추출(기존) + 실패 시 통째 삭제 | 단순 | 교체 중 실패 시 기존 자산 손실, 부분추출 노출 | 기각 |
|
||||||
|
| 권한 게이트 | (A) 컨트롤러 진입부 게이트 헬퍼(isAuthenticated + 옵션 has) | 개방/제한 분기 유연, W1 인프라 재사용, 신규 0 | 호출지점 결합 | **채택** |
|
||||||
|
| | (B) RbacInterceptor 에 /api/game-files/** 등록 | 선언적 | isAdmin 전용이라 로그인개방 부적합, 인터셉터 개조 필요 | 기각 |
|
||||||
|
| 업로드 메타 | (A) games.webgl_path 재사용 + 감사로그만 신설 | 메타중복 0, 정규화 정석 | 감사는 별도 | **채택** |
|
||||||
|
| | (B) game_uploads 메타테이블 신설 | 업로드 이력 풍부 | webgl_path 와 중복, over-engineering | 기각 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 롤아웃 / 마이그레이션
|
||||||
|
|
||||||
|
### 순서
|
||||||
|
1. **스키마 적용**: `docs/game-upload-ddl.sql` → `db/apply-local-ddl.sh`(로컬) / 운영 동일 멱등 DDL. game_upload_audit_log 신설(추가만, 파괴 0). 기존 데이터 무영향.
|
||||||
|
2. **코드 배포**: ZipSecurity/ZipRejectException → GameUploadAuditMapper/POJO → GameUploadController 보강 → GameAssetCleanupService + GameController 훅.
|
||||||
|
3. **검증**: 악성 zip 픽스처(심링크/`../`/백슬래시/bomb/비-Unity) 거부 + 정상 Unity 빌드 통과 L1 + L3 스모크(실제 zip 업로드→서빙).
|
||||||
|
|
||||||
|
### 역호환
|
||||||
|
- webgl-zip 응답 JSON 키 불변 → game-register.jsp 클라이언트 무변경. `replaceUuid`/`mode` 미전송 시 기존 동작(신규 UUID, 일반 업로드) 100% 보존.
|
||||||
|
- 기존 업로드된 `/game/{uuid}/` 자산은 그대로 서빙(GameAssetController 불변). 기존 고아 디렉터리는 본 배포가 소급 정리하지 않음(신규 업로드/삭제부터 정리 적용 — 소급 정리는 별도 배치, 비목표).
|
||||||
|
- 검증 강화로 **기존엔 통과하던 비정상 zip(예: index.html 만 있고 Build 없는 zip)이 거부**될 수 있다 → 이는 의도된 보강(정석). 운영 공지 필요(documentation-advisor).
|
||||||
|
|
||||||
|
### 롤백
|
||||||
|
- 코드 롤백: ZipSecurity 위임 전 직접 추출 로직으로 복귀 시 심링크/포맷검증만 사라짐(zip-slip prefix 검증은 기존부터 존재). 게이트 호출은 isAuthenticated → sessionUserId==null 복귀.
|
||||||
|
- 스키마 롤백: game_upload_audit_log 는 추가 전용 → drop 없이 잔존 무해(비파괴).
|
||||||
|
- 자산 정리(purge) 롤백: 훅 제거 시 고아 파일 재발생(기능 후퇴)뿐 데이터 손상 없음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AC 매핑
|
||||||
|
|
||||||
|
| AC | 요구 | 만족 설계 요소 | 비고 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AC-1 | zip-slip(`../`/절대/백슬래시) 거부 | validateEntryName 사전거부 + normalize startsWith + toRealPath 재검증 | S2, ZIP_SLIP/BAD_ENTRY_NAME |
|
||||||
|
| AC-2 | **심볼릭 링크 엔트리/디렉터리 거부** | assertParentNotSymlink + 쓰기 후 toRealPath 경계 재검증 | S2, SYMLINK (조사 최대 구멍 보강) |
|
||||||
|
| AC-3 | zip bomb 방어 | 엔트리당 256MB + 누적 512MB + 엔트리수 8000 (3중상한) | S2, ENTRY/EXTRACTED/TOO_MANY |
|
||||||
|
| AC-4 | Unity 빌드 포맷 검증 | assertUnityBuild(loader+framework+data+wasm 존재, 압축변형 허용) + index.html | S1, INCOMPLETE_BUILD/NO_INDEX |
|
||||||
|
| AC-5 | 매직바이트 + MIME·확장자 AND | assertMagic(PK) + assertZipType(MIME AND .zip) | S1, MAGIC_FAIL/NOT_ZIP |
|
||||||
|
| AC-6 | 원본 크기 상한 | file.getSize() > 512MB → 413 | S1, TOO_LARGE |
|
||||||
|
| AC-7 | 저장 경로 boundary | tmpDir/finalDir assertWithinBoundary(gameRoot canonical) | S1, D4 |
|
||||||
|
| AC-8 | 권한 게이트(로그인 개방 유지 + 잼 옵션) | isAuthenticated 명시 + mode=jam 시 has(GAME_JAM_MANAGE) | §게이트연동, D5 |
|
||||||
|
| AC-9 | UUID 재업로드 원자적 교체 + 멱등 | replaceUuid → 임시추출 → ATOMIC_MOVE swap → 구버전 정리 | S1, U6 |
|
||||||
|
| AC-10 | 자산 정리(고아 방지) | 편집 UUID 변경 시 purge + soft-delete 시 purge | S3, U7 |
|
||||||
|
| AC-11 | 업로드 성공/거부 감사 | game_upload_audit_log insert(outcome + reject_reason) | §데이터모델, U8 |
|
||||||
|
| AC-12 | CSRF 전수 + `${}` 0 | CsrfTokens.isValid(기존) + 감사 매퍼 `#{}` only | §외부계약, AC-T 아래 |
|
||||||
|
| AC-13 | /game/** 서빙 현행 유지 | GameAssetController 불변, ResourceHandler 미추가 기록 | 비목표, 조사 포인트5 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 포인트 (verification-advisor 점검 대상)
|
||||||
|
|
||||||
|
> L레벨 매핑(verification-strategies.md): 보안 입력검증/경로 boundary/권한 = **L1+L2+L3**. zip 추출은 실파일시스템 의존 → **L1(악성 zip 픽스처 단위) + L3(실 업로드→서빙 스모크)**. 신규 매퍼 SQL = **L1+L2(DB-방언 계약)**. 신규 매퍼·서비스 의존 → full `./mvnw -o test` + @MockBean(verification §30).
|
||||||
|
|
||||||
|
### 시나리오 검증
|
||||||
|
- **VP-1 (AC-1/2 경로탈출·심링크, L1)**: ZipSecurityTest — `../escape`, `/abs/path`, `..\\win`, 심링크 엔트리(부모가 심링크인 케이스 포함) zip 픽스처가 각각 ZIP_SLIP/BAD_ENTRY_NAME/SYMLINK 로 거부 + 대상 디렉터리 밖에 파일 0.
|
||||||
|
- **VP-2 (AC-3 zip bomb, L1)**: 9000 엔트리 / 단일 300MB 엔트리 / 누적 600MB zip 이 각각 TOO_MANY_ENTRIES/ENTRY_TOO_LARGE/EXTRACTED_TOO_LARGE 로 거부. 부분추출 잔여(tmpDir) 정리 확인.
|
||||||
|
- **VP-3 (AC-4 포맷, L1)**: index.html 만 있고 Build 없는 zip → INCOMPLETE_BUILD. 정상 Unity 빌드(loader+framework+data+wasm, .br 변형 포함) → 통과.
|
||||||
|
- **VP-4 (AC-5/6 타입·크기, L1)**: 비-zip(MIME 위조 또는 매직 불일치) → NOT_ZIP/MAGIC_FAIL. 513MB → TOO_LARGE(413).
|
||||||
|
- **VP-5 (AC-8 게이트, L1+L3)**: 미인증 → 401. mode=jam + GAME_JAM_MANAGE 없음 → 403. 일반 업로드(로그인만) → 통과(개방 유지 회귀).
|
||||||
|
- **VP-6 (AC-9 교체 멱등, L1+L3)**: replaceUuid 로 재업로드 → 같은 UUID, 구 파일 교체, 디렉터리 1개만 잔존. 교체 중 검증실패 시 기존 자산 무손상.
|
||||||
|
- **VP-7 (AC-10 정리, L1)**: GameAssetCleanupService.purgeByWebglPath — 경계 내 UUID 만 삭제, 경계 밖/비-UUID 입력은 무동작(거부).
|
||||||
|
- **VP-8 (AC-11 감사, L1+L2)**: 성공/각 거부사유가 game_upload_audit_log 에 outcome+reject_reason 으로 기록(DB-방언 계약: insertAudit 컬럼↔POJO 정합).
|
||||||
|
- **VP-9 (contextLoads, L1)**: BibimbapApplicationTests 에 GameUploadAuditMapper·GameAssetCleanupService @MockBean 등록 후 PASS(§30).
|
||||||
|
|
||||||
|
### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용)
|
||||||
|
> self-audit(시점): 아래 카운트는 모두 **본 W3-5 가 신규 생성하는 정적 산출물**(reason 카탈로그/거부분기/매퍼)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리(work-session) 카운트는 사용 안 함.
|
||||||
|
> self-audit(표현): 단일 리터럴 grep 취약성을 피해 — reason 코드는 **enum 멤버 ↔ DDL CHECK 주석 ↔ 컨트롤러 매핑** 의 구조적 동등성에 앵커. enum values() 순회로 추가 시 자동 동기되는 불변식 우선.
|
||||||
|
|
||||||
|
- **AC-T1 거부 사유코드 전수 11종 정합** — reject_reason 카탈로그(NOT_ZIP/MAGIC_FAIL/TOO_LARGE/TOO_MANY_ENTRIES/ENTRY_TOO_LARGE/EXTRACTED_TOO_LARGE/ZIP_SLIP/SYMLINK/BAD_ENTRY_NAME/NO_INDEX/INCOMPLETE_BUILD) 11종이 **ZipRejectException 의 reason enum 멤버 수 == 본 설계 카탈로그 표 행수 == 11**. 검증: enum 멤버 `grep -c` == 11 AND DDL COMMENT 의 사유 나열 토큰 수 == 11(`docs/game-upload-ddl.sql` 의 reject_reason COMMENT 내 `/` 구분 토큰 == 11). 코드 enum 이 단일 정의처 — 사유 추가 시 enum/표/DDL주석 3곳 동기 누락을 갯수 1로 동시 검출.
|
||||||
|
- **AC-T2 zip 검증 게이트 전수 — webgl-zip 핸들러가 검증 단계를 모두 통과 후 추출** : assertZipType/assertMagic/원본크기/extractZip(내부 4상한+slip+symlink) 의 진입 호출이 컨트롤러에 존재. 검증: GameUploadController.uploadWebglZip 본문에 `assertZipType`·`assertMagic`·`extractZip` 호출 grep 각 ≥1 (3개 호출지점 전수). 누락 시 검증 우회.
|
||||||
|
- **AC-T3 상태변경 엔드포인트 CSRF 가드 전수** — GameUploadController 의 상태변경 핸들러(uploadGameFiles/uploadWebglZip/uploadThumbnail) 전수 `CsrfTokens.isValid` 선검증: `grep -c 'CsrfTokens.isValid' GameUploadController.java` == @PostMapping 핸들러 수(3). 핸들러 추가 시 가드 누락 동시 검출.
|
||||||
|
- **AC-T4 권한 게이트 진입 전수** — 상태변경 핸들러 3개 전수 `permissionGate.isAuthenticated`(또는 동치 로그인 게이트) 호출: 각 핸들러에 인증 게이트 1회 존재(임의 role 직접체크 0). 검증: `permissionGate.isAuthenticated` grep ≥ 상태변경 핸들러 수, AND `session.getAttribute("role")` 직접 비교 0건(임시체크 금지 — 정석 원칙).
|
||||||
|
- **AC-T5 업로드/감사 매퍼 `${}` 0건** — 신규 매퍼(GameUploadAuditMapper) 에 `${` 매치 0: `grep -c '\${' GameUploadAuditMapper.java` == 0 (AC-12, #{} only 표준).
|
||||||
|
- **AC-T6 Unity 필수 산출물 검증 집합 전수 4종** — assertUnityBuild 가 검사하는 산출물 카테고리(loader/framework/data/wasm) 4종이 코드 검사 목록에 전수 존재. 검증: assertUnityBuild 내 마커 문자열(`loader`/`framework`/`.data`/`.wasm`) 4종 grep 각 ≥1. 카테고리 추가/삭제 시 동시 검출.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 잔여 오픈 질문
|
||||||
|
없음(0). 확정 결정 D1~D8 + /game/** 현행유지(조사 stale 정정)로 전 항목 닫음. 다음은 오픈 질문이 아니라 **구현 단계 점검사항**으로 `concerns` 에 이관:
|
||||||
|
- 신규 게이트 메서드 추가 금지(기존 has/require 재사용, concern 1).
|
||||||
|
- ZipSecurity 헬퍼 시그니처 dead parameter 재확인(concern 2).
|
||||||
|
- 압축비 미사용·getCompressedSize 의존 금지(concern 3).
|
||||||
|
- gameRoot() 경로 이중중첩 정정은 스코프 밖(concern 4 — orchestrator 에스컬레이션).
|
||||||
|
- 심링크 거부 구현 방식은 toRealPath 재검증 채택, external-attributes 파싱 미채택(concern 6).
|
||||||
|
|
@ -0,0 +1,461 @@
|
||||||
|
---
|
||||||
|
phase: design
|
||||||
|
agent: design-advisor
|
||||||
|
agent_version: 1
|
||||||
|
generated_at: 2026-06-23T12:00:00+09:00
|
||||||
|
workstream: W4-유저 배지/평판
|
||||||
|
concerns:
|
||||||
|
- "신규 함수 시그니처(BadgeService.evaluateAndSync / ReputationService.record / BadgeQueryMapper.* )는 최소 인자로 명세했다. 구현 단계에서 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고 방지, 프로토콜 §11.2). 특히 evaluateAndSync 의 reason/actorId 후보 인자."
|
||||||
|
- "신규 매퍼(BadgesMapper / UserBadgesMapper / ReputationEventsMapper / BadgeQueryMapper) + BadgeService / ReputationService 의 컨트롤러/JSP 신규 의존은 verification-strategies §30 에 따라 implementation 에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 신규 빈 @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException."
|
||||||
|
- "배지 표시 조인(리뷰 작성자·게임 카드 옆 배지)은 N+1 위험. 본 설계는 배치 조회(listActiveBadgeKeysByUserIds(List)) 로 차단하도록 명세했으나, 구현이 리뷰 1건당 1쿼리로 풀면 N+1 회귀. listGameReviews/getVisibleGames 호출지점에서 userId 집합을 1회 배치 조회하는지 verification 에서 확인 필요."
|
||||||
|
- "신규 매퍼 SQL 의 camelCase alias 는 일반 매퍼 표준(snake→camel 직접 alias, r.created_at AS createdAt)을 따른다. 단 배지 집계/통계성 조회(reputation 누적 합산 등 VIEW 성)는 케이스 폴딩 함정 대상 — 그 경우만 큰따옴표 alias(verification-strategies §33). DB-방언 계약(L2) 대상이며 dev DB contract 미구축은 기존 open item."
|
||||||
|
- "PermissionKeys 에 BADGE_MANAGE 추가는 enum 멤버 1개 추가 — PermissionCatalogVerifier 가 values() 순회 시드라 자동 동기되나, W1 의 AC-T1(권한 카탈로그 전수 N건) 카운트가 3→4 로 변한다. W1 의 기존 전수 AC 가 하드코딩 3이면 그 AC 가 본 변경으로 FAIL 할 수 있음 — 본 설계 §롤아웃에서 명시, verification 에서 W1 AC-T1 재측정 필요."
|
||||||
|
concerns_checked: true
|
||||||
|
self_verification:
|
||||||
|
checklist_passed: true
|
||||||
|
references:
|
||||||
|
requirements: docs/work-log/2026-06-23-w2-w4-feature-skeletons.md
|
||||||
|
research: .atp/work-session/20260623-104307/research/W2-W4-grounding.md
|
||||||
|
adrs:
|
||||||
|
- .atp/work-session/20260622-180054/implementation/W1-design.md
|
||||||
|
- docs/work-log/2026-06-17-jam-platform-roadmap.md
|
||||||
|
- docs/development/verification-strategies.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# 설계: W4 — 유저 배지 / 평판 (리뷰어/테크니션 배지 + 평판 이벤트 감사로그 + 자동/수동 하이브리드 부여)
|
||||||
|
|
||||||
|
## 목표 / 비목표
|
||||||
|
|
||||||
|
### 목표 (FR/NFR 추적 — 골자 W4 Q1~Q6 + QG-W4-A 를 확정값으로 닫음)
|
||||||
|
- **B1 배지 카탈로그** (Q1, Q4): 정의 가능·확장 가능한 배지 카탈로그(`badges`). 1차 배지 2종 = 리뷰어(`REVIEWER`)·테크니션(`TECHNICIAN`). badge_type CHECK 로 확장 가능. **"기술자" 명칭 = TECHNICIAN(영문 키) / display_name "테크니션"(한글 표시)** 확정(Q4 닫음).
|
||||||
|
- **B2 유저 배지 보유** (Q1, Q6): `user_badges` 가 유저↔배지 보유를 표현. 활성(미회수) UNIQUE(user_id, badge_key) 로 중복 부여 방지(자동부여 멱등성).
|
||||||
|
- **B3 평판 이벤트 감사로그** (Q3): `reputation_events` 가 평판 신호(리뷰 작성·업로드·역할·좋아요 등)를 누적 감사 기록. 배지가 1차 노출(이산), reputation_events 가 임계 판정·감사 소스(연속 점수 보조).
|
||||||
|
- **B4 자동 부여(임계)** (Q2 → QG-W4-A 닫음): 평판 신호 누적이 배지 기준 임계 도달 시 자동 부여. 이벤트 훅(리뷰 작성/게임 업로드 직후) + 멱등(UNIQUE 방어).
|
||||||
|
- **B5 수동 부여/회수** (Q2, Q5): 운영자가 배지 수동 부여·회수. 신규 권한 키 `BADGE_MANAGE` 게이트(W1 PermissionGate 위). 회수 = `revoked_at` + 사유.
|
||||||
|
- **B6 배지 표시** (Q6): 프로필 + 리뷰 작성자(닉네임 옆) + 게임 카드. 표시명 = `users.display_name` 단일 출처에 배지 부착(스냅샷 닉네임 아닌 유저 단위 배지).
|
||||||
|
- **NFR**: 상태변경(수동 부여/회수) CSRF 전수, `#{}` 바인딩(`${}` 금지), 권한 게이트는 W1 인프라(PermissionGate/PermissionKeys) 위에 얹고 임시 role 직접 체크 금지, 비파괴 멱등 DDL, 배지 표시 조회 N+1 차단(배치 조회).
|
||||||
|
- **착수 독립성** (골자 C5): 평판 소스(리뷰 R-B / 좋아요 R-D / role R-A / 업로드 R-C) 전부 실재 → **W2 무관 독립 착수**.
|
||||||
|
|
||||||
|
### 비목표 (스코프 밖)
|
||||||
|
- 연속 평판 "점수" 산식 노출 UI — reputation_events 는 감사·임계 판정 소스로만 사용, 점수 가시화는 후속.
|
||||||
|
- 배지 기준의 정교한 "품질" 가중(추천수·신고수 반영) — 1차는 활동량 임계 + 회수(부정 다수). 추천 인프라는 현재 부재(code-fact: game_reviews 에 추천 컬럼 0).
|
||||||
|
- 잼(W2) 연동 배지(수상자 배지 등) — W2 시상(W2-6) 종속이라 W4 1차 스코프 밖. badge_type 확장으로 후속 흡수 가능.
|
||||||
|
- 좋아요(`game_likes`) 기반 배지 — R-D 가 1인1표 UNIQUE 미보장(추정)·user_key varchar 라 신뢰 신호로 부적합. reputation_events 스키마는 source 확장 열어두되 1차 자동부여 소스에서 제외.
|
||||||
|
- 자동부여 스케줄러(주기 배치) — 1차는 **이벤트 훅 동기 평가**(리뷰/업로드 트랜잭션 직후). 스케줄 백필은 후속(아래 §대안 비교).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
bibimbap 에 배지/평판 도메인은 전무하다(grounding R-E: badge/reputation 0 hit). 본 설계는 신규 3테이블(`badges`/`user_badges`/`reputation_events`) + 신규 권한 키 `BADGE_MANAGE` 를 도입한다. 평판 신호 원천은 전부 실재한다 — 리뷰(`game_reviews`, R-B), 업로드(`games`, R-C), 역할(`users.role`, R-A).
|
||||||
|
|
||||||
|
핵심 구조:
|
||||||
|
- **이산 배지(1차 노출)**: `badges` 카탈로그 + `user_badges` 보유. 리뷰어/테크니션 2종으로 시작, badge_type CHECK 확장.
|
||||||
|
- **연속 신호(보조·감사)**: `reputation_events` 가 평판 신호를 append-only 누적. 자동부여는 "이벤트 기록 → 해당 유저의 누적 집계 → 임계 도달 시 배지 부여" 흐름. reputation_events 가 임계 판정 단일 소스 + 감사 추적.
|
||||||
|
- **하이브리드 부여**: 자동(임계, 이벤트 훅 동기) + 수동(운영자, BADGE_MANAGE 게이트). 둘 다 같은 `user_badges` 에 기록되며 `awarded_by`(자동=NULL, 수동=actor) 로 출처 구분.
|
||||||
|
- **표시**: 유저 단위 배지를 `users.display_name` 단일 출처에 부착. 리뷰 작성자(authorName)·게임 카드(creator)·프로필 표면에서 해당 userId 의 활성 배지를 배치 조회로 붙인다.
|
||||||
|
|
||||||
|
확정한 골자 미결(W4 Q1~Q6, QG-W4-A):
|
||||||
|
- **Q1/Q4 (배지 종류·명칭)**: 1차 2종 REVIEWER/TECHNICIAN. "기술자" 표시명=테크니션. badge_type CHECK('REVIEWER','TECHNICIAN') + 확장 시 CHECK 멱등 확장.
|
||||||
|
- **Q2/QG-W4-A (부여 방식)**: 자동 임계 + 수동 하이브리드. 정석 = 신규 BADGE_MANAGE 권한 키(권한 분리, CONTENT_MODERATE 재사용 기각 — 근거 §대안 비교).
|
||||||
|
- **Q3 (평판 점수)**: 이산 배지 1차 노출 + reputation_events 누적(연속 보조·감사). 점수 UI 비목표.
|
||||||
|
- **Q5 (회수)**: `user_badges.revoked_at` + `revoke_reason`. 부정 리뷰/삭제 다수 등 운영 판단 → 수동 회수. 자동 회수는 1차 비목표(거짓 회수 위험).
|
||||||
|
- **Q6 (표시 위치)**: 프로필 + 리뷰 작성자 + 게임 카드. display_name 단일 출처.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 핵심 결정 요약 (전제 — 재논의 금지)
|
||||||
|
|
||||||
|
| 결정 | 확정값 | 본 설계의 구체화 |
|
||||||
|
|---|---|---|
|
||||||
|
| 배지 스토리지 | badges + user_badges + reputation_events 3테이블 | `docs/badge-ddl.sql` 권위 + schema.sql 동기(멱등) |
|
||||||
|
| 배지 1차 종류 | REVIEWER / TECHNICIAN | badge_type CHECK 확장 가능. 카탈로그 부팅 시드(BadgeCatalogSeeder) |
|
||||||
|
| "기술자" 명칭 | 키 TECHNICIAN / 표시 "테크니션" | badge_key=TECHNICIAN, display_name="테크니션" |
|
||||||
|
| 부여 방식 | 자동 임계 + 수동 하이브리드 | 자동=reputation_events 누적 집계 임계, 수동=BADGE_MANAGE 게이트. 둘 다 user_badges, awarded_by 로 출처 구분 |
|
||||||
|
| 권한 키 | **신규 BADGE_MANAGE** (CONTENT_MODERATE 재사용 기각) | PermissionKeys enum +1, PermissionCatalogVerifier values() 자동 시드 |
|
||||||
|
| 평판 점수 | 이산 배지 1차 + reputation_events 누적(보조) | reputation_events = 임계 판정 + 감사 단일 소스 |
|
||||||
|
| 회수 | revoked_at + revoke_reason(수동) | UNIQUE active 부분인덱스로 재부여 허용(회수 후 재획득) |
|
||||||
|
| 자동부여 트리거 | 이벤트 훅 동기(리뷰/업로드 직후) | 스케줄 배치 비목표(1차) |
|
||||||
|
| 자동부여 멱등 | UNIQUE(user_id, badge_key) active 부분인덱스 | 중복부여 INSERT ... ON CONFLICT DO NOTHING |
|
||||||
|
| 표시 | display_name 단일 출처 + 배치 조회 | listActiveBadgeKeysByUserIds(List) N+1 차단 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 모델 (DDL)
|
||||||
|
|
||||||
|
> 권위 워크플로(grounding R-F): **권위 원본 = `docs/badge-ddl.sql`** (신규). `db/apply-local-ddl.sh` 가 `docs/*-ddl.sql` 글롭 알파벳순 멱등 적용(ON_ERROR_STOP, search_path=dev). `db/schema.sql` 은 동기 사본(컨테이너 최초1회 부트스트랩). 선례: W1 `docs/rbac-ddl.sql`, W3-2 `docs/game-reviews-ddl.sql`. 알파벳순상 `badge-ddl.sql` 은 `game-reviews-ddl.sql` 보다 먼저 적용되나, badges 는 users 외 FK 가 game_reviews/games 를 직접 참조하지 않으므로(소비는 런타임 조회) 적용 순서 의존 없음(아래 FK 설계 참조).
|
||||||
|
|
||||||
|
### 신규 파일: `docs/badge-ddl.sql` (권위 DDL)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- W4 유저 배지/평판. 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
|
||||||
|
-- 신규 도메인(스토리지 0). 추가만, 파괴 없음. search_path=dev.
|
||||||
|
|
||||||
|
-- 1) badges (배지 카탈로그 — 정의 가능·확장 가능)
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "badges_id_seq";
|
||||||
|
CREATE TABLE IF NOT EXISTS "badges" (
|
||||||
|
"id" bigint DEFAULT nextval('badges_id_seq'::regclass) NOT NULL,
|
||||||
|
"badge_key" character varying(50) NOT NULL, -- 코드 상수 단일 정의처와 정합(REVIEWER/TECHNICIAN)
|
||||||
|
"display_name" character varying(100) NOT NULL, -- 표시명("리뷰어"/"테크니션")
|
||||||
|
"description" character varying(500), -- 획득 기준 설명(표시용)
|
||||||
|
"badge_type" character varying(30) NOT NULL, -- 분류(REVIEWER/TECHNICIAN, 확장 가능)
|
||||||
|
"criteria_json" text, -- 임계 기준 직렬화(예: {"minReviews":10}). 표시·문서화용. 판정은 코드 상수
|
||||||
|
"is_active" boolean DEFAULT true NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
ALTER SEQUENCE "badges_id_seq" OWNED BY "badges"."id";
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "ux_badges_key" ON "badges" ("badge_key");
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'badges_type_check') THEN
|
||||||
|
ALTER TABLE "badges"
|
||||||
|
ADD CONSTRAINT "badges_type_check"
|
||||||
|
CHECK ("badge_type" IN ('REVIEWER', 'TECHNICIAN'));
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- 2) user_badges (유저↔배지 보유. 자동/수동 부여 공통 기록)
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "user_badges_id_seq";
|
||||||
|
CREATE TABLE IF NOT EXISTS "user_badges" (
|
||||||
|
"id" bigint DEFAULT nextval('user_badges_id_seq'::regclass) NOT NULL,
|
||||||
|
"user_id" bigint NOT NULL,
|
||||||
|
"badge_key" character varying(50) NOT NULL,
|
||||||
|
"awarded_by" bigint, -- 수동=운영자 user_id, 자동=NULL
|
||||||
|
"awarded_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"revoked_at" timestamp with time zone, -- 회수 시각(NULL=활성)
|
||||||
|
"revoke_reason" character varying(500), -- 회수 사유(수동)
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
ALTER SEQUENCE "user_badges_id_seq" OWNED BY "user_badges"."id";
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_badges_user_id_fkey') THEN
|
||||||
|
ALTER TABLE "user_badges"
|
||||||
|
ADD CONSTRAINT "user_badges_user_id_fkey"
|
||||||
|
FOREIGN KEY ("user_id") REFERENCES "users" ("id");
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
-- 활성(미회수) 중복 부여 방지 — 부분 유니크. 회수 후 재부여 허용(revoked_at IS NOT NULL 행은 제외).
|
||||||
|
-- 자동부여 멱등성의 DB 차원 보증(INSERT ... ON CONFLICT DO NOTHING 대상 인덱스).
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "ux_user_badges_active"
|
||||||
|
ON "user_badges" ("user_id", "badge_key") WHERE "revoked_at" IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_user_badges_user_active"
|
||||||
|
ON "user_badges" ("user_id") WHERE "revoked_at" IS NULL;
|
||||||
|
|
||||||
|
-- 3) reputation_events (평판 신호 감사로그 — append-only, 임계 판정 단일 소스)
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "reputation_events_id_seq";
|
||||||
|
CREATE TABLE IF NOT EXISTS "reputation_events" (
|
||||||
|
"id" bigint DEFAULT nextval('reputation_events_id_seq'::regclass) NOT NULL,
|
||||||
|
"user_id" bigint NOT NULL,
|
||||||
|
"event_type" character varying(40) NOT NULL, -- REVIEW_WRITTEN/GAME_UPLOADED(1차). 확장 가능
|
||||||
|
"source_ref" character varying(100), -- 원천 식별(review:123, game:45 등). 중복 이벤트 방지 키
|
||||||
|
"weight" integer DEFAULT 1 NOT NULL, -- 신호 가중(1차 전부 1, 향후 품질 가중)
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
ALTER SEQUENCE "reputation_events_id_seq" OWNED BY "reputation_events"."id";
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'reputation_events_type_check') THEN
|
||||||
|
ALTER TABLE "reputation_events"
|
||||||
|
ADD CONSTRAINT "reputation_events_type_check"
|
||||||
|
CHECK ("event_type" IN ('REVIEW_WRITTEN', 'GAME_UPLOADED'));
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'reputation_events_user_id_fkey') THEN
|
||||||
|
ALTER TABLE "reputation_events"
|
||||||
|
ADD CONSTRAINT "reputation_events_user_id_fkey"
|
||||||
|
FOREIGN KEY ("user_id") REFERENCES "users" ("id");
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
-- 같은 원천 신호 중복 기록 방지(예: 같은 리뷰 재집계). source_ref NULL 은 제외(부분 유니크).
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "ux_reputation_events_source"
|
||||||
|
ON "reputation_events" ("user_id", "event_type", "source_ref")
|
||||||
|
WHERE "source_ref" IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_reputation_events_user_type"
|
||||||
|
ON "reputation_events" ("user_id", "event_type");
|
||||||
|
```
|
||||||
|
|
||||||
|
### FK 설계 결정 (적용 순서·소비 결합)
|
||||||
|
- `user_badges`/`reputation_events` 는 **`users` 만 FK** 참조. game_reviews/games 를 FK 로 묶지 않는다 — 이유: (a) docs/*-ddl.sql 알파벳 적용 순서 의존 회피, (b) 소비는 런타임 집계 조회(리뷰 수·업로드 수)이지 참조 무결성 대상 아님, (c) 리뷰/게임 soft-delete 시 평판 이벤트는 감사 기록으로 잔존해야 함(append-only). `source_ref` 는 varchar 약결합(`review:123`)으로 추적성만 확보.
|
||||||
|
|
||||||
|
### `db/schema.sql` 반영 (동기 사본)
|
||||||
|
- `recruit_posts` 블록 뒤(파일 말미)에 위 1·2·3번(badges/user_badges/reputation_events)을 신설 블록으로 추가. 반영 방식은 `game_reviews`(권위 DDL → schema.sql 동기, schema.sql:128~) 선례와 동일 — **docs/badge-ddl.sql 이 권위, schema.sql 은 사본**.
|
||||||
|
|
||||||
|
### 카탈로그 시드 (badges 행 — 부팅 멱등)
|
||||||
|
- W1 `PermissionCatalogVerifier`(enum→DB 멱등 upsert) 선례와 동일 패턴으로 `BadgeCatalogSeeder`(`ApplicationRunner`) 가 코드 배지 상수(`BadgeKeys` enum)를 `badges` 에 멱등 upsert. 단일 정의처 = 코드 enum, DB 는 시드. **권한과 달리 배지는 운영자가 표시명/설명을 DB 에서 조정할 수 있어야 하므로, upsert 는 `badge_key` 부재 시 INSERT 만 하고 기존 행 display_name/description 은 덮어쓰지 않는다**(INSERT ... ON CONFLICT DO NOTHING). 신규 키만 시드.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 외부 계약 (API)
|
||||||
|
|
||||||
|
> 공통: 모든 상태변경(수동 부여/회수)은 `CsrfTokens.isValid(request)` 검증(없으면 403 + `CsrfTokens.errorBody()`). 수동 부여/회수 API 는 `BADGE_MANAGE` 게이트 통과 후 도달. 응답은 기존 컨트롤러 패턴(`Map<String,Object>` + `status`/`message`). 읽기(배지 표시)는 기존 리뷰/게임 조회에 배치 부착(신규 페이지 API 최소).
|
||||||
|
|
||||||
|
### 401 vs 403 정책 (W1 정책 계승)
|
||||||
|
- **미인증**(세션 `userId` 없음): API → **401** JSON, 페이지 → `redirect:/login`.
|
||||||
|
- **인증·미인가**(BADGE_MANAGE 없음): **403** JSON `{status:403, message:"권한이 없습니다."}`.
|
||||||
|
- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`.
|
||||||
|
|
||||||
|
### 수동 부여/회수 API (상태변경 — 전부 CSRF + BADGE_MANAGE 게이트)
|
||||||
|
| 액션 | method | path | 요청 | 응답(200) | 에러 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 수동 부여(B5) | POST | `/admin/users/{userId}/badges/{badgeKey}/grant` | (path) | `{status:200, message, userId, badgeKey, awarded:true}` | 404(대상/배지키 없음), 409(이미 활성 보유), 403(CSRF/권한) |
|
||||||
|
| 수동 회수(B5/Q5) | POST | `/admin/users/{userId}/badges/{badgeKey}/revoke` | `{reason}`(form) | `{status:200, message, userId, badgeKey, revoked:true}` | 404(대상/활성보유 없음), 403 |
|
||||||
|
|
||||||
|
- **grant/revoke 분리 채택 근거**: W1 권한 토글은 부여=회수 대칭 역연산이라 단일 toggle 이었으나, 배지 회수는 **사유(revoke_reason) 필수 + 부정 판단 동반**이라 의미가 비대칭이다. 회수를 토글로 묶으면 사유 누락·오회수 위험. 따라서 명시적 2엔드포인트 채택(grant 는 사유 불요, revoke 는 사유 요구).
|
||||||
|
- **경로 선택**: `/admin/**` 하위 — RbacInterceptor 가 `/admin/**` 를 ADMIN 게이트로 1차 보호하나, 배지 관리는 ADMIN 뿐 아니라 BADGE_MANAGE 보유 SUBADMIN 도 허용해야 함. 따라서 **인터셉터의 `/admin/**` ADMIN-only 와 충돌**(아래 §인터셉터 연동에서 해소).
|
||||||
|
|
||||||
|
### 배지 표시 조회 (읽기 — 기존 조회에 배치 부착, 신규 페이지 API 없음)
|
||||||
|
- 리뷰 목록(`GameReviewController` listReviews) / 게임 목록(`GameController` getVisibleGames) / 프로필 응답에 해당 userId 집합의 활성 배지 키를 **1회 배치 조회**로 부착. 응답 형태: 각 리뷰/게임 항목에 `authorBadges: ["REVIEWER", ...]`(키 배열) 추가. 표시명 매핑은 클라이언트가 카탈로그(부트 시 모델 주입 또는 정적 상수)로 변환.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 인터셉터 / 게이트 연동
|
||||||
|
|
||||||
|
### `/admin/**` ADMIN-only 와 BADGE_MANAGE(SUBADMIN 허용)의 충돌 해소 (확정)
|
||||||
|
- 현 `RbacInterceptor` 는 `/admin/**` 전체를 `isAdmin` 게이트로 보호(grounding R-A: InterceptorConfig.java:18-20, `addPathPatterns("/admin/**")` + isAdmin). SUBADMIN 은 어떤 권한 키를 가져도 `/admin/**` 진입 불가.
|
||||||
|
- **결정: 배지 관리 API 를 `/admin/**` 밖 경로로 둔다 → `/manage/badges/...`**. 이유: `/admin/**` 의 ADMIN-only 인터셉터 의미를 훼손하지 않으면서 BADGE_MANAGE 보유 SUBADMIN 진입을 허용. 인터셉터 URL 패턴 변경(SUBADMIN 분기 추가)은 W1 enforcement 계약을 흔들어 회귀 위험 — 신규 경로가 정석.
|
||||||
|
- 수정 경로 계약: 수동 부여 `POST /manage/users/{userId}/badges/{badgeKey}/grant`, 회수 `POST /manage/users/{userId}/badges/{badgeKey}/revoke`.
|
||||||
|
- 이 경로는 인터셉터에 등록하지 **않고**, 컨트롤러 진입부에서 **게이트 헬퍼** `permissionGate.has(session, PermissionKeys.BADGE_MANAGE.name())` 를 직접 호출(W1-design §인터셉터 "소비 액션=게이트 헬퍼" 선례와 동일 — 경로별 권한이 콘솔 단일 ADMIN 과 다를 때의 표준 패턴). 미인증→401, 미인가→403 은 컨트롤러가 작성.
|
||||||
|
- **PermissionGate 재사용**: 신규 게이트 메서드 불요. 기존 `has(HttpSession, String)` 가 ADMIN 암묵전권 + SUBADMIN 키 보유를 모두 커버(PermissionGate.java:22-38 확인). BADGE_MANAGE 는 enum 값일 뿐 게이트 코어 변경 0.
|
||||||
|
|
||||||
|
### PermissionKeys 확장 (코드 — 설계 명세, 본 문서는 enum 확장 명세만, 코드 미수정)
|
||||||
|
- `PermissionKeys` enum 에 멤버 1개 추가:
|
||||||
|
```java
|
||||||
|
BADGE_MANAGE("배지 관리")
|
||||||
|
```
|
||||||
|
- `PermissionCatalogVerifier`(PermissionCatalogVerifier.java:30, `values()` 순회 시드) 가 자동으로 DB `permissions` 에 멱등 upsert → **코드 수정 0(verifier 불변), enum 멤버 추가만으로 카탈로그 동기**. 이것이 W1 의 "enum 단일 정의처" 불변식의 확장 시연.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 시퀀스
|
||||||
|
|
||||||
|
### S1. 자동 부여 (리뷰 작성 → 평판 이벤트 → 임계 판정 → 배지 부여)
|
||||||
|
```
|
||||||
|
[유저 42] POST /game/7/reviews (CSRF) — 리뷰 작성(기존 W3-2 경로)
|
||||||
|
→ GameReviewController.createReview (기존)
|
||||||
|
→ CSRF + 리뷰 INSERT (기존 트랜잭션)
|
||||||
|
→ [신규 훅, 같은 트랜잭션 후단] reputationService.record(
|
||||||
|
userId=42, eventType=REVIEW_WRITTEN, sourceRef="review:" + newReviewId)
|
||||||
|
→ reputationEventsMapper.insertIgnore(...) # ux_reputation_events_source ON CONFLICT DO NOTHING(중복 신호 방어)
|
||||||
|
→ badgeService.evaluateAndSync(userId=42, badgeKey=REVIEWER)
|
||||||
|
→ countActiveReputation(42, REVIEW_WRITTEN) # reputation_events 누적 집계(단일 소스)
|
||||||
|
→ if count >= REVIEWER_THRESHOLD(=10, 코드 상수):
|
||||||
|
userBadgesMapper.insertIgnore(userId=42, badgeKey=REVIEWER, awardedBy=NULL)
|
||||||
|
# ux_user_badges_active ON CONFLICT DO NOTHING(자동부여 멱등 — 이미 보유면 no-op)
|
||||||
|
→ 200 (리뷰 응답, 배지 부여는 부작용)
|
||||||
|
```
|
||||||
|
- **자동부여 멱등 논증(crossRefs 보안)**: 동일 유저가 임계 초과 후 리뷰를 더 써도 `ux_user_badges_active` 부분 유니크가 중복 INSERT 를 DO NOTHING 으로 흡수 → 1회만 부여. 동시성(같은 유저 동시 2리뷰)도 DB 유니크가 차단.
|
||||||
|
- **트랜잭션 경계**: 평판 훅은 리뷰 INSERT 와 같은 트랜잭션. 훅 실패가 리뷰 작성을 롤백하면 UX 손상 — 따라서 **훅은 best-effort(try/catch + 로그)**로 감싸 리뷰 본 흐름을 막지 않는다(평판은 보조 도메인). 누락된 이벤트는 후속 스케줄 백필로 복구 가능(비목표지만 구조 보존).
|
||||||
|
|
||||||
|
### S2. 자동 부여 (게임 업로드 → TECHNICIAN)
|
||||||
|
```
|
||||||
|
[유저 42] POST /game/new (CSRF) — 게임 메타 생성(기존 GameController.createGame)
|
||||||
|
→ [신규 훅, 트랜잭션 후단] reputationService.record(42, GAME_UPLOADED, "game:" + newGameId)
|
||||||
|
→ badgeService.evaluateAndSync(42, TECHNICIAN)
|
||||||
|
→ countActiveReputation(42, GAME_UPLOADED) >= TECHNICIAN_THRESHOLD(=3) → insertIgnore TECHNICIAN
|
||||||
|
```
|
||||||
|
|
||||||
|
### S3. 수동 부여/회수 (운영자, BADGE_MANAGE 게이트)
|
||||||
|
```
|
||||||
|
[SUBADMIN(BADGE_MANAGE 보유) 세션] POST /manage/users/42/badges/REVIEWER/grant (CSRF)
|
||||||
|
→ BadgeManageController.grant(42, "REVIEWER")
|
||||||
|
→ CSRF 검증 (없으면 403 + errorBody)
|
||||||
|
→ permissionGate.has(session, BADGE_MANAGE) (미인증 401 / 미인가 403)
|
||||||
|
→ BadgeKeys.isValid("REVIEWER") (아니면 404)
|
||||||
|
→ usersMapper.getUser(42) 존재 확인 (아니면 404)
|
||||||
|
→ 이미 활성 보유? userBadgesMapper.existsActive(42, REVIEWER) → 있으면 409
|
||||||
|
→ userBadgesMapper.insert(42, REVIEWER, awardedBy=actorId) # 수동: awarded_by=actor
|
||||||
|
→ 200 {awarded:true}
|
||||||
|
|
||||||
|
[운영자] POST /manage/users/42/badges/REVIEWER/revoke (CSRF, reason="부정 리뷰 다수")
|
||||||
|
→ BadgeManageController.revoke(42, "REVIEWER", reason)
|
||||||
|
→ CSRF + BADGE_MANAGE 게이트
|
||||||
|
→ 활성 보유? (아니면 404)
|
||||||
|
→ userBadgesMapper.revoke(42, REVIEWER, reason, now()) # revoked_at + revoke_reason set
|
||||||
|
# 부분 유니크가 revoked_at IS NULL 만 보므로, 회수 후 재획득(자동/수동) 가능
|
||||||
|
→ 200 {revoked:true}
|
||||||
|
```
|
||||||
|
|
||||||
|
### S4. 배지 표시 (리뷰 목록에 작성자 배지 배치 부착 — N+1 차단)
|
||||||
|
```
|
||||||
|
[누구나] GET /game/7/reviews (기존 listReviews)
|
||||||
|
→ GameReviewController.listReviews → List<GameReviewData>(각 userId/authorName 보유)
|
||||||
|
→ [신규] userIds = reviews.stream().map(userId).distinct()
|
||||||
|
→ Map<Long,List<String>> badges = userBadgesQueryMapper.listActiveBadgeKeysByUserIds(userIds) # 1쿼리(IN)
|
||||||
|
→ 각 리뷰 DTO 에 authorBadges = badges.getOrDefault(userId, []) 부착
|
||||||
|
→ JSP/JSON: 작성자 닉네임 옆 배지 표시(textContent/HtmlUtils 이스케이프)
|
||||||
|
```
|
||||||
|
- **N+1 차단 논증(concern 3)**: userId 집합을 1회 `IN (...)` 배치 조회. 리뷰 1건당 1쿼리 금지. 게임 카드(getVisibleGames)·프로필 동일 패턴.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 영향 맵
|
||||||
|
|
||||||
|
> 소유권 분할(implementation-advisor worker 단위 후보):
|
||||||
|
> **B-SCHEMA**(DDL/schema 동기) · **B-DOMAIN**(BadgeKeys enum/data POJO/매퍼/카탈로그 시더) · **B-SERVICE**(ReputationService/BadgeService 임계 판정·멱등 부여) · **B-HOOK**(리뷰/게임 작성 경로 평판 훅 연결) · **B-MANAGE**(수동 부여/회수 컨트롤러 + 게이트) · **B-DISPLAY**(리뷰/게임/프로필 배지 배치 부착 + JSP) · **B-PERM**(PermissionKeys BADGE_MANAGE 추가).
|
||||||
|
> 의존: B-SCHEMA → B-DOMAIN → {B-SERVICE → B-HOOK, B-MANAGE, B-DISPLAY}. B-PERM 은 B-MANAGE 선행.
|
||||||
|
|
||||||
|
| 변경 유형 | 경로 | 역할 | 소유 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 신규 | `docs/badge-ddl.sql` | 권위 DDL(badges/user_badges/reputation_events + CHECK/유니크). apply-local-ddl.sh 자동 적용 | B-SCHEMA |
|
||||||
|
| 수정 | `db/schema.sql` | 말미에 3테이블 블록 추가(badge-ddl 동기 사본) | B-SCHEMA |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/badge/BadgeKeys.java` | 배지 키 enum 단일 정의처(REVIEWER/TECHNICIAN + displayName + isValid). PermissionKeys 선례 | B-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/config/BadgeCatalogSeeder.java` | `ApplicationRunner` — BadgeKeys → badges 멱등 INSERT(ON CONFLICT DO NOTHING). PermissionCatalogVerifier 선례 | B-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/BadgeData.java` | badges 행 POJO(badgeKey/displayName/description/badgeType/isActive) | B-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/UserBadgeData.java` | user_badges 행 POJO(userId/badgeKey/awardedBy/awardedAt/revokedAt/revokeReason) | B-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/BadgesMapper.java` | `@Mapper` 카탈로그 시드/조회(`#{}`) | B-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/UserBadgesMapper.java` | `@Mapper` user_badges CRUD(insertIgnore/insert/existsActive/revoke, `#{}`) | B-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/UserBadgesQueryMapper.java` | `@Mapper` 표시 배치 조회(listActiveBadgeKeysByUserIds — IN, N+1 차단) | B-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/ReputationEventsMapper.java` | `@Mapper` 평판 이벤트 insertIgnore + 누적 집계(countActive, `#{}`) | B-DOMAIN |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/badge/ReputationService.java` | 평판 이벤트 기록 + 임계 평가 위임(record). best-effort 훅 | B-SERVICE |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/badge/BadgeService.java` | 임계 판정 + 멱등 부여(evaluateAndSync) + 수동 부여/회수 도메인 로직 | B-SERVICE |
|
||||||
|
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/api/GameReviewController.java` | createReview 후단 reputationService.record(REVIEW_WRITTEN) 훅. listReviews 응답에 배지 배치 부착 | B-HOOK / B-DISPLAY |
|
||||||
|
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/GameController.java` | createGame 후단 reputationService.record(GAME_UPLOADED) 훅 | B-HOOK |
|
||||||
|
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/GameController.java` (또는 GameRestController) | getVisibleGames 응답에 creator 배지 배치 부착 | B-DISPLAY |
|
||||||
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/BadgeManageController.java` | 수동 부여/회수 `/manage/users/{userId}/badges/{badgeKey}/(grant|revoke)` + BADGE_MANAGE 게이트 헬퍼 | B-MANAGE |
|
||||||
|
| 수정 | `src/main/java/com/pandoli365/bibimbap/security/PermissionKeys.java` | `BADGE_MANAGE("배지 관리")` enum 멤버 1개 추가(verifier 자동 시드) | B-PERM |
|
||||||
|
| 수정 | `src/main/webapp/WEB-INF/views/profile.jsp` | 프로필에 본인 활성 배지 표시(HtmlUtils 이스케이프/textContent) | B-DISPLAY |
|
||||||
|
| 수정 | (리뷰/게임 카드 JSP — 리뷰 목록·게임 목록 렌더 지점) | 작성자/creator 닉네임 옆 배지 표시 | B-DISPLAY |
|
||||||
|
| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 매퍼·BadgeService·ReputationService `@MockBean` 등록(contextLoads, verification §30) | (검증) |
|
||||||
|
| 신규 | `src/test/.../BadgeServiceTest.java` | 임계 판정·멱등 부여(중복 no-op)·회수 후 재부여 단위 | (검증) |
|
||||||
|
| 신규 | `src/test/.../BadgeManageControllerTest.java` | grant/revoke + 401/403/CSRF/404/409 + BADGE_MANAGE 게이트 | (검증) |
|
||||||
|
| 수정 | `src/test/.../GameReviewControllerTest.java` | 리뷰 작성 후 평판 훅 호출 회귀(REVIEW_WRITTEN 기록) + 작성 본흐름 불변 | (검증) |
|
||||||
|
|
||||||
|
> SSR 호출지점 확인(verification §영향맵): 배지 배치 부착은 기존 리뷰/게임 DTO 에 **신규 필드(authorBadges) 추가**라 기존 호출지점 깨짐 0. PermissionKeys 멤버 추가는 enum 확장(values() 순회 코드 불변)이라 컴파일 회귀 0.
|
||||||
|
|
||||||
|
### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
|
||||||
|
```java
|
||||||
|
// ReputationService — 평판 이벤트 기록 + 임계 평가 위임. best-effort 훅.
|
||||||
|
void record(long userId, // 신호 주체
|
||||||
|
String eventType, // REVIEW_WRITTEN/GAME_UPLOADED(ReputationEventTypes 상수)
|
||||||
|
String sourceRef) // 중복 신호 방지 키(review:N/game:N). NULL 허용은 안 함(훅은 항상 원천 보유)
|
||||||
|
|
||||||
|
// BadgeService — 임계 판정 + 멱등 부여.
|
||||||
|
void evaluateAndSync(long userId, // 평가 대상
|
||||||
|
String badgeKey) // 평가할 배지(REVIEWER/TECHNICIAN). 임계·소스 매핑은 BadgeKeys 내부
|
||||||
|
// (수동) — 컨트롤러가 BADGE_MANAGE 게이트·CSRF 통과 후 호출. 도메인 검증(존재/중복/회수)만.
|
||||||
|
GrantResult grantManual(long userId, // 대상
|
||||||
|
String badgeKey,// 부여 배지
|
||||||
|
long awardedBy) // 운영자 user_id(awarded_by 기록)
|
||||||
|
void revokeManual(long userId, // 대상
|
||||||
|
String badgeKey, // 회수 배지
|
||||||
|
String reason) // 회수 사유(revoke_reason). NULL/blank 거부는 컨트롤러 검증
|
||||||
|
|
||||||
|
// UserBadgesMapper (@Mapper, #{} only)
|
||||||
|
int insertIgnore(long userId, String badgeKey) // 자동부여 멱등(ON CONFLICT DO NOTHING, awarded_by NULL)
|
||||||
|
int insert(@Param("userId") long userId, @Param("badgeKey") String badgeKey, @Param("awardedBy") long awardedBy) // 수동부여
|
||||||
|
boolean existsActive(@Param("userId") long userId, @Param("badgeKey") String badgeKey) // 409/404 판정
|
||||||
|
int revoke(@Param("userId") long userId, @Param("badgeKey") String badgeKey, @Param("reason") String reason) // revoked_at+reason set
|
||||||
|
|
||||||
|
// UserBadgesQueryMapper (@Mapper, #{} only) — 표시 배치(N+1 차단)
|
||||||
|
List<UserBadgeKeyRow> listActiveBadgeKeysByUserIds(@Param("userIds") List<Long> userIds) // IN 1쿼리. (userId, badgeKey) 행
|
||||||
|
|
||||||
|
// ReputationEventsMapper (@Mapper, #{} only)
|
||||||
|
int insertIgnore(@Param("userId") long userId, @Param("eventType") String eventType, @Param("sourceRef") String sourceRef) // 중복신호 ON CONFLICT DO NOTHING
|
||||||
|
long countActive(@Param("userId") long userId, @Param("eventType") String eventType) // 누적 집계(임계 좌변)
|
||||||
|
|
||||||
|
// BadgesMapper (@Mapper, #{} only)
|
||||||
|
int insertIgnore(@Param("badgeKey") String k, @Param("displayName") String d, @Param("description") String desc, @Param("badgeType") String t) // 시더(ON CONFLICT DO NOTHING)
|
||||||
|
List<BadgeData> listActive() // 카탈로그(표시명 매핑 모델 주입)
|
||||||
|
```
|
||||||
|
> inflate 마킹(concern 1): `evaluateAndSync` 는 의도적으로 `(userId, badgeKey)` 2인자로 최소화했다 — 임계값·평판 소스 event_type 은 `BadgeKeys` enum 내부 매핑(REVIEWER→REVIEW_WRITTEN/임계10, TECHNICIAN→GAME_UPLOADED/임계3)으로 외부 인자 부풀림을 막는다. `record` 의 `weight` 는 시그니처에서 제외(1차 전부 weight=1, DB DEFAULT 1) — 품질 가중 도입 시 그때 추가. `grantManual` 의 `awardedBy` 는 audit 출처라 실사용 확정. 구현에서 각 인자 실제 사용 재확인 필요(concern 1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 대안 비교
|
||||||
|
|
||||||
|
| 주제 | 안 | 장점 | 단점 | 채택 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 권한 키 | (A) 신규 `BADGE_MANAGE` | 권한 분리(콘텐츠 모더 ≠ 배지 관리), 최소권한 정석 | enum +1, 카탈로그 +1 | **채택** |
|
||||||
|
| | (B) `CONTENT_MODERATE` 재사용 | 신규 키 0 | 모더레이터=배지관리자 강제 결합, 권한 분리 위배 | 기각 |
|
||||||
|
| 자동부여 트리거 | (A) 이벤트 훅 동기(작성 직후 best-effort) | 즉시 반영, 인프라 0, 트랜잭션 결합 약 | 누락 신호 백필 별도 | **채택** |
|
||||||
|
| | (B) 주기 스케줄 배치 | 누락 0 일괄 | 신규 스케줄러 인프라, 즉시성 손실, 1차 과도 | 기각(후속) |
|
||||||
|
| | (C) DB 트리거/계산컬럼 | 앱 무관 | DB 로직 산재, 임계 변경 어려움, 테스트 난 | 기각 |
|
||||||
|
| 배지 회수 | (A) revoked_at + reason, 수동만(부분유니크 재획득 허용) | 감사·재획득·정석 | 회수 후 재부여 가능(의도) | **채택** |
|
||||||
|
| | (B) hard delete | 단순 | 감사 소실, 회수 이력 0 | 기각 |
|
||||||
|
| | (C) 자동 회수(임계 하락) | 일관 | 거짓 회수(리뷰 일시 삭제 등) 위험 | 기각(1차) |
|
||||||
|
| 평판 점수 | (A) 이산 배지 1차 + reputation_events 누적(보조) | 노출 단순 + 감사·임계 소스 | 이벤트 테이블 1개 | **채택** |
|
||||||
|
| | (B) users 에 점수 컬럼 누적 | 조회 빠름 | 감사 소실, 재계산 불가, 단조증가 결함 | 기각 |
|
||||||
|
| 배지 관리 경로 | (A) `/manage/**` 신규(게이트 헬퍼) | `/admin/**` ADMIN-only 의미 보존 + SUBADMIN+BADGE_MANAGE 허용 | 경로 1개 신설 | **채택** |
|
||||||
|
| | (B) `/admin/**` 하위 + 인터셉터 SUBADMIN 분기 | 경로 통일 | W1 인터셉터 ADMIN-only 계약 훼손, 회귀 위험 | 기각 |
|
||||||
|
| 표시 조회 | (A) userId 집합 배치 IN 1쿼리 | N+1 0 | 매퍼 IN 1개 | **채택** |
|
||||||
|
| | (B) 리뷰/게임 1건당 배지 조회 | 단순 | N+1(목록 성능) | 기각 |
|
||||||
|
| 좋아요 평판 소스 | (A) 1차 제외 | game_likes 신뢰 불가(1인1표 미보장 추정·user_key varchar) | 신호 1종 감소 | **채택** |
|
||||||
|
| | (B) game_likes 포함 | 신호 풍부 | 어뷰징·중복(R-D concern) | 기각(소스 확장 여지만 보존) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 롤아웃 / 마이그레이션
|
||||||
|
|
||||||
|
### 순서
|
||||||
|
1. **스키마 적용**: `docs/badge-ddl.sql` → `db/apply-local-ddl.sh`(로컬, 멱등) / 운영 동일 멱등 DDL. 신규 도메인이라 기존 데이터 호환(추가만, 파괴 0). `db/schema.sql` 동기.
|
||||||
|
2. **권한 키**: `PermissionKeys.BADGE_MANAGE` 추가 → 배포 시 `PermissionCatalogVerifier` 가 permissions 카탈로그 자동 시드(코드 verifier 불변).
|
||||||
|
3. **배지 카탈로그**: 부팅 시 `BadgeCatalogSeeder` 가 REVIEWER/TECHNICIAN 멱등 INSERT.
|
||||||
|
4. **코드 배포**: 도메인/서비스/훅/매니지/표시. 평판 훅이 리뷰/게임 작성 경로에 발효.
|
||||||
|
5. **BADGE_MANAGE 부여**: ADMIN 이 W1 콘솔(`/admin/console` 권한 토글)로 대상 SUBADMIN 에게 BADGE_MANAGE 부여 → `/manage/badges` 진입 가능. (W1 토글이 PermissionKeys 전체를 노출하므로 신규 키 자동 토글 가능 — W1 콘솔 코드 변경 불요, 단 §concerns AC-T1 카운트 주의.)
|
||||||
|
|
||||||
|
### 역호환
|
||||||
|
- 기존 유저/리뷰/게임: 배지 0 상태로 시작. 평판 이벤트는 배포 이후 신규 작성분만 기록(과거 백필 안 함 — 비목표). 표시 배치 조회는 배지 없으면 빈 배열 → 표시 변화 0.
|
||||||
|
- W1 콘솔: PermissionKeys 멤버 추가만 — 콘솔이 enum 순회로 키를 노출하면 BADGE_MANAGE 가 자동 토글 대상에 추가됨(동작 확장, 회귀 0).
|
||||||
|
- 리뷰/게임 DTO: 신규 필드 `authorBadges` 추가만 → 기존 직렬화/JSP 깨짐 0.
|
||||||
|
|
||||||
|
### 롤백
|
||||||
|
- 코드 롤백: 평판 훅·표시 부착·매니지 컨트롤러 제거 시 배지 도메인 비활성. 신규 테이블/컬럼은 추가 전용이라 잔존해도 무해(비파괴). PermissionKeys 에서 BADGE_MANAGE 제거 시 DB permissions 의 BADGE_MANAGE 행은 `PermissionCatalogVerifier` 가 "DB-only 키 경고 로그"로 감지(W1 PermissionCatalogVerifier.java:40-44) — 삭제 안 하므로 안전.
|
||||||
|
- 스키마 롤백: 명시적 DROP 은 별도 maintenance(append-only 감사 데이터 보존 권장).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AC 매핑
|
||||||
|
|
||||||
|
| AC | 요구 | 만족 설계 요소 | 비고 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AC-1 | 리뷰 N개 작성 시 REVIEWER 자동 부여 | S1 평판 훅 → countActive >= 임계 → insertIgnore | 임계=BadgeKeys 상수 |
|
||||||
|
| AC-2 | 게임 M개 업로드 시 TECHNICIAN 자동 부여 | S2 평판 훅 → GAME_UPLOADED 집계 임계 | |
|
||||||
|
| AC-3 | **자동부여 멱등(중복 부여 0)** | ux_user_badges_active 부분유니크 + insertIgnore(ON CONFLICT DO NOTHING). 임계 초과 추가 활동·동시성 모두 1회만 | S1 논증 |
|
||||||
|
| AC-4 | 운영자 수동 부여 — BADGE_MANAGE 게이트 | grant 컨트롤러 permissionGate.has(BADGE_MANAGE), 미인가 403 | S3 |
|
||||||
|
| AC-5 | 수동 부여/회수 CSRF 없으면 403 | grant/revoke CsrfTokens.isValid 선검증 → 403+errorBody | §외부계약 공통 |
|
||||||
|
| AC-6 | 회수 — revoked_at + 사유 기록 | revoke: revoked_at=now()+revoke_reason. 활성 보유 아니면 404 | S3 |
|
||||||
|
| AC-7 | 회수 후 재획득 가능 | 부분유니크 WHERE revoked_at IS NULL → 회수행 제외, 재부여 INSERT 가능 | DDL 2 |
|
||||||
|
| AC-8 | 배지 표시(프로필/리뷰작성자/게임카드) display_name 단일출처 | listActiveBadgeKeysByUserIds 배치 부착, userId 기준(스냅샷 닉네임 아님) | S4 |
|
||||||
|
| AC-9 | 표시 조회 N+1 0 | userId 집합 IN 1쿼리 | concern 3 |
|
||||||
|
| AC-10 | 배지 SQL `${}` 0 | 신규 매퍼 전부 `#{}` | §파일영향맵 |
|
||||||
|
| AC-11 | 비-BADGE_MANAGE 의 /manage/badges 접근 차단 | 게이트 헬퍼 미인가 403, 미인증 401 | §인터셉터연동 |
|
||||||
|
| AC-12 | reputation_events 중복 신호 방어 | ux_reputation_events_source + insertIgnore | DDL 3 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 포인트 (verification-advisor 점검 대상)
|
||||||
|
|
||||||
|
> L레벨 매핑(verification-strategies.md): 권한 게이트/부여 플로우 = **L1+L2+L3**. 신규 매퍼 SQL/alias = **L1+L2(dev DB contract)**. 신규 매퍼·서비스 빈 의존 = full `./mvnw -o test` 의무(§30). 자동부여 멱등·회수 = L1+L3.
|
||||||
|
|
||||||
|
### 시나리오 검증
|
||||||
|
- **VP-1 (AC-3 멱등, L1)**: BadgeServiceTest — 임계 도달 후 evaluateAndSync 반복 호출 시 user_badges 활성 1행 유지(insertIgnore no-op). 회수 후 재호출 시 재부여(AC-7).
|
||||||
|
- **VP-2 (AC-4/AC-11 게이트, L1+L3)**: BadgeManageControllerTest — BADGE_MANAGE 미보유 SUBADMIN→403, 미인증→401, ADMIN→통과, BADGE_MANAGE 보유 SUBADMIN→통과.
|
||||||
|
- **VP-3 (AC-5 CSRF, L1)**: grant/revoke CSRF 누락 → 403 + mapper 미호출(W1 `deleteCommentRejectsMissingCsrfBeforeMapperAccess` 패턴 준용).
|
||||||
|
- **VP-4 (AC-1/AC-2 자동부여, L1+L3)**: 리뷰/게임 작성 훅이 reputation_events insert + 임계 도달 시 배지 부여. 본 흐름(리뷰/게임 작성) 불변 회귀(훅 best-effort).
|
||||||
|
- **VP-5 (AC-9 N+1, L1)**: 리뷰 목록 N건 응답 시 배지 조회 쿼리 1회(IN). 리뷰 1건당 1쿼리면 FAIL.
|
||||||
|
- **VP-6 (DB-방언 계약, L2)**: 신규 매퍼 반환 키가 소비 키와 정합. 집계성 조회(countActive)·alias 케이스 폴딩 확인.
|
||||||
|
- **VP-7 (contextLoads, L1)**: BibimbapApplicationTests 에 신규 매퍼·BadgeService·ReputationService @MockBean 등록 후 PASS(§30).
|
||||||
|
|
||||||
|
### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용)
|
||||||
|
> self-audit: 아래 카운트는 본 설계가 신규 생성하는 정적 산출물 + 기존 docs/*-ddl.sql 집합이며, 시점·표현 견고성을 2축 점검했다.
|
||||||
|
|
||||||
|
- **AC-T1 (시점 self-audit 적용) DDL 권위 파일 전수** — `docs/*-ddl.sql` 은 verification 시점까지 **W2 등 타 워크스트림이 동시 추가할 수 있는 집합**(현재 4: recruit-posts/security-hardening/game-reviews/rbac, 본 설계가 badge 추가 → 5). 고정 스칼라 5 는 시점 불안정 → **불변식으로 전환**: "badge-ddl.sql 이 존재하고 그 안의 CREATE TABLE 3종(badges/user_badges/reputation_events)이 schema.sql 에도 동일 존재" — `grep -c 'CREATE TABLE IF NOT EXISTS "\(badges\|user_badges\|reputation_events\)"' docs/badge-ddl.sql` == 3 AND db/schema.sql 동일 == 3 (권위↔사본 동등성 불변식). 타 W 파일 추가와 무관.
|
||||||
|
- **AC-T2 신규 테이블 멱등 가드 전수 3건** — badge-ddl.sql 의 3테이블 전부 `CREATE TABLE IF NOT EXISTS`: `grep -c 'CREATE TABLE IF NOT EXISTS' docs/badge-ddl.sql` == 3. 멱등 누락(IF NOT EXISTS 빠진 CREATE) 동시 검출.
|
||||||
|
- **AC-T3 배지 SQL `${}` 0건** — 신규 매퍼 5개(BadgesMapper/UserBadgesMapper/UserBadgesQueryMapper/ReputationEventsMapper) + 수정 컨트롤러에 `${` 매치 0: `grep -rc '\${' <매퍼 파일들>` == 0 (AC-10).
|
||||||
|
- **AC-T4 (표현 self-audit 적용) 자동부여 멱등 가드 전수** — 자동부여 INSERT 경로 전부(user_badges/reputation_events insertIgnore)가 `ON CONFLICT DO NOTHING` 동반. 단일 리터럴 grep 은 SQL 표현 변형(`ON CONFLICT (...) DO NOTHING` 컬럼 명시)에 취약 → **의미 불변식 + 수동 판정**: insertIgnore 메서드 2개(UserBadgesMapper.insertIgnore / ReputationEventsMapper.insertIgnore)가 각각 부분유니크 인덱스(ux_user_badges_active / ux_reputation_events_source)에 대응하는 ON CONFLICT 절을 갖는지 코드 리뷰 확인. **이 AC 가 AC-3/AC-12 멱등의 핵심 가드** — 누락 시 중복 부여 보안결함.
|
||||||
|
- **AC-T5 (시점 self-audit 적용) PermissionKeys 카탈로그 동기 불변식** — BADGE_MANAGE 추가로 enum 멤버 수가 변한다(고정 카운트 금지). 불변식: `PermissionCatalogVerifier` 가 `values()` 순회 시드이므로 enum 멤버 수 == DB 시드 키 수(자동 동기). 검증: PermissionKeys enum 멤버에 BADGE_MANAGE 존재 AND verifier 코드가 여전히 `values()` 순회(하드코딩 리스트 아님). **W1 의 기존 AC-T1(권한 전수 3건)이 하드코딩 3이면 본 변경으로 FAIL — verification 에서 W1 AC 를 4로 재측정 필요**(concern 5).
|
||||||
|
- **AC-T6 평판 신호 event_type CHECK 정합** — reputation_events CHECK IN 목록(REVIEW_WRITTEN/GAME_UPLOADED) == 코드 record 호출이 사용하는 event_type 집합. 검증: DDL CHECK IN 2종 == ReputationEventTypes 상수 2종 == 훅 호출지점 2곳(리뷰/게임). 신호 종류 추가 시 CHECK 누락 동시 검출.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 잔여 오픈 질문
|
||||||
|
없음(0). 골자 W4 Q1~Q6 + QG-W4-A 를 확정값으로 닫음(§핵심 결정 요약). 시그니처 inflate·신규 빈 full-test·표시 N+1·DB-방언 L2·W1 AC-T1 카운트 변화는 오픈 질문이 아니라 **구현/검증 단계 점검 항목**으로 `concerns` 에 이관.
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
# 교차정합 감사 — W2 결합 클러스터 + W3/W4 cross-ref (설계 전용)
|
||||||
|
|
||||||
|
> **HIGH 편차: 1건.** 동결 단일권위(W2-3) **유지됨** — jam_scores/jam_votes/jam_awards/jam_score_stats VIEW 의 평가단위·컬럼·트랙·게이트 계약을 하류 W2-4/5/6 이 재정의 없이 소비. 단 W2-1 본문이 동결 전 표현(jam_entries.id)을 그대로 남겨 동결값(game_id 자연키)과 문서상 모순(HIGH-1, 스키마 영향 없음·문서 정정 권고).
|
||||||
|
|
||||||
|
생성: 2026-06-23 / 감사자: cross-consistency audit (Read-only). 코드/설계 수정 0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 점검 항목별 verdict
|
||||||
|
|
||||||
|
### 1. ★평가 단위 일관성 (최고 위험) — **편차(HIGH-1, 문서 표현 한정)**
|
||||||
|
- **동결 권위(W2-3 F8/§64/§67)**: 평가단위 = `(jam_id, game_id)` 자연키 = 활성 출품작. FK 는 `game_id→games`·`jam_id→jams` 직접. 출품검증=앱계층(jam_entries 활성행).
|
||||||
|
- **하류 정합**: jam_scores(W2-3:142-143)·jam_votes(W2-3:185-186)·jam_awards(W2-3:221-222) DDL FK 전부 `(jam_id, game_id)`. W2-4 점수입력(`/jams/{jamId}/games/{gameId}/scores`), W2-5 투표(`castVote(jamId, gameId, voterUserId)`), W2-6 시상(트랙별 gameId 랭크) **전부 game_id 단위로 일치**. game_id 혼용·jam_entries.id 직접 FK **없음** → 평가단위 일관성 **OK**.
|
||||||
|
- `[HIGH] W2-1:43, W2-1:65 → 문제`: W2-1 본문이 "평가 단위 = `jam_entries.id`"(§43 "jam_entries.id 를 평가 단위로 제공", §65 "평가 단위 = jam_entries.id", §196 DDL 주석 "평가 단위 = entry.id — W2-3/4/5/6 참조점")로 **동결값과 정반대 식별자**를 명시. W2-3 은 이 모순을 concern 1·난제1·F8 에서 명시 인지하고 game_id 자연키로 **이미 해소**했으나(W2-3 이 권위), **W2-1 문서는 미정정 잔존**. → 구현자가 W2-1 본문만 읽으면 잘못된 평가단위로 매퍼/FK 설계 가능. `권고`: W2-1 §43/§65/§196 을 "평가단위 = (jam_id, game_id) 자연키, jam_entries 가 active-UNIQUE 로 1:1 대응 보장(W2-3 F8 동결)" 으로 문서 정정. **스키마 영향 0**(W2-1 DDL 자체는 game_id/jam_id 컬럼을 정상 보유, ux_jam_entries_jam_game_active 도 정합) — 순수 본문 표현 drift.
|
||||||
|
|
||||||
|
### 2. 동결 스키마 컬럼 정합 — **OK**
|
||||||
|
- W2-3 동결 VIEW 출력 컬럼 = `jam_id/game_id/weighted_total/simple_total/scored_criteria/judge_count`(W2-3:281-284).
|
||||||
|
- W2-4 소비(§97/§130/§207): `weightedTotal/simpleTotal/scoredCriteria/judgeCount` camelCase **정합**. 정렬·alias·큰따옴표 규약 일치.
|
||||||
|
- W2-6 소비(§36/§110-113/§191): JUDGE=`jam_score_stats.weighted_total`, USER_RATING=`game_review_stats.avg_rating`+`review_count>=3`, POPULAR=`jam_votes` count — 전부 동결 컬럼명 일치. `score_value numeric(10,4)` 는 트랙별 **값 규약**만 확정(스키마 변경 0). 컬럼명 drift **없음**.
|
||||||
|
|
||||||
|
### 3. 게이트 계약 정합 — **OK (게이트 순서 LOW 1건)**
|
||||||
|
- **isJudge 시그니처**: W2-2 동결 `JamRoleGate.isJudge(session, jamId)`(W2-2:283) ↔ W2-4 호출 `jamRoleGate.isJudge(session, 42)`(W2-4:167) **일치**.
|
||||||
|
- **자기출품 충돌**: W2-2 제공 `isOwnEntry(jamId, gameId, userId)`(W2-2:285, option (b) 별도 메서드) ↔ W2-4 호출 `isOwnEntry(42, 777, userId)`(W2-4:171, "기본 가정 (b)") **일치**. 개인+팀멤버 OR 양경로 커버(W2-2 §307 SQL). enforce 시점=점수입력(W2-2 J7) ↔ W2-4 enforce **정합**.
|
||||||
|
- **평가기간 게이트(F6)**: W2-3 `status='EVAL' AND now∈[eval_start_at,eval_end_at]` → W2-4 `JamEvalWindow.isOpen`(§149), W2-5 컨트롤러 진입부(§148) **동일 조건** 사용. 위반=422 정책 3문서 일치.
|
||||||
|
- `[LOW] W2-4:67/138 vs W2-2:179 → 문제`: 점수입력 게이트 **순서**가 두 문서에서 상이. W2-4 = CSRF→인증→**isJudge(403)**→jam→**평가기간(422)**→출품작→자기출품. W2-2 §179 = CSRF→로그인→**평가기간(422)**→**isJudge(403)**→자기출품. isJudge 와 평가기간의 선후가 swap. `권고`: 무해(둘 다 첫 실패 지점 반환·최종 결과 동일, W2-2 §179 이 "순서는 W2-4 결정"으로 이미 양보) — 구현은 W2-4 순서 채택 명시면 충분.
|
||||||
|
|
||||||
|
### 4. 중복 DDL — **OK**
|
||||||
|
- jam_votes `CREATE TABLE` 가 W2-3(§182, 소유)+W2-5(§86) 양쪽 등장. W2-5 의 것은 §79-85 에서 "참조 사본(W2-3 §3 권위, 본 설계가 정의/변경하지 않음, DDL 미수정, 신규 DDL 0)"으로 **명시 인용**이고 본문(컬럼/UNIQUE ux_jam_votes_jam_voter/idx_jam_votes_jam_game)이 동결과 **동일** → 발산하는 2번째 정의 **아님**.
|
||||||
|
- W2-4/W2-6 은 `CREATE TABLE/VIEW` **0건**(AC 텍스트의 부재검증 토큰만). 다른 중복 CREATE 없음.
|
||||||
|
|
||||||
|
### 5. 권한키 정합 — **OK**
|
||||||
|
- 카탈로그 = W1(GAME_JAM_MANAGE/POST_WRITE/CONTENT_MODERATE) + W4 신규 BADGE_MANAGE.
|
||||||
|
- **W3-1**: TAG_MANAGE 신규키 도입 **안 함** — `CONTENT_MODERATE` 재사용으로 확정(D4 §60/§483, 본문·게이트 호출부 일관). 미래 분리는 concern 으로만 이관(키 도입 0).
|
||||||
|
- **W3-3**: POST_WRITE 재사용(신규키 0, §55/§69).
|
||||||
|
- **W4 BADGE_MANAGE**: 신규키 1개 추가. W1 AC-T1(카탈로그 카운트 3) 깨짐을 **명시**(W4 §12 concern·§455 AC-T5·§405 — "W1 AC-T1 을 4로 재측정 필요"). PermissionCatalogVerifier values() 자동 시드라 enum +1 만으로 동기. **누락 없음**.
|
||||||
|
|
||||||
|
### 6. cross-W 참조 — **OK (1건 전제 정정)**
|
||||||
|
- **W3-4 배너→잼검색**: W3-1 동결 `GET /games/search?jam={jamSlug}`(§65/§234) ↔ W3-4 §126-130 동일 라우트 앵커(+ W3-1 미확정 시 `/jams/{slug}` 폴백 우선순위 확정). **일치**.
|
||||||
|
- **W3-3 POST_WRITE enforcement**: `PermissionGate.has(session, POST_WRITE.name())` 연결(§55/§325). **OK**.
|
||||||
|
- **W2-6 유저평점 트랙 = game_review_stats.avg_rating**(W2-6 §37/§111) — 단방향 읽기(write 0, AC-T2). **OK**.
|
||||||
|
- `정정(편차 아님)`: 감사 전제 "W2-6 유저평점 트랙 = W4 와 **동일 VIEW 소비**"는 **부정확**. W4 REVIEWER 배지는 `game_review_stats` 가 아니라 `reputation_events`(리뷰작성 이벤트 누적 임계, W4 §54/§263)를 소비. 둘은 **독립 메커니즘**(공유 VIEW 아님) → 발산 위험 없음(같은 VIEW 를 다르게 재정의하는 상황이 애초에 없음). 정합 무관.
|
||||||
|
|
||||||
|
### 7. 보안 존재성 — **OK (전부 실재·비자명)**
|
||||||
|
- **W2-2 잼-스코프 게이트**: `JamRoleGate`(jam_judges 조회, 전역 PermissionGate 와 별도 축). isOwnEntry 개인+팀멤버 OR-EXISTS(§307). 전역 RBAC 무변경 단언(AC-T7). **실재·비자명**.
|
||||||
|
- **W3-3 SSRF(SsrfSafeFetcher)**: 9항목 체크리스트 실재(§273-284 — scheme allowlist / host resolve / 모든 resolved IP 공인검증(사설·루프백·링크로컬·메타데이터 169.254.169.254) / connect / 연결소켓 peer-IP 재검증(rebinding) / redirect 매홉 재검증 / size cap / timeout / Content-Type). DNS-pin TOCTOU 한계까지 concern 명시. **비자명**.
|
||||||
|
- **W3-5 zip-slip(canonical+심링크)**: `toRealPath()` 경계 재검증 + assertParentNotSymlink(부모경로 walk+isSymbolicLink) + 쓰기후 toRealPath().startsWith 재검증 + 엔트리명 사전거부(절대/UNC/백슬래시/NUL/드라이브/`..`/길이/깊이). ZipEntry mode-bit 직접파싱은 근거와 함께 기각(over-eng). **비자명**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부차 관찰 (편차 아님 — 동결 허용범위 내)
|
||||||
|
- `[LOW] W2-5 vote mapper 시그니처 drift`: W2-3 계약(§371-374)은 `castVote/updateVote/hasVoted/countByGame`, W2-5 구현(§231-242)은 `castVote/updateVote/deleteVote/findVotedGameId/countByJam/listCountsByJam`. W2-3 이 해당 시그니처를 "계약 골격(inflate 마킹, 구현 재확인)"으로 명시했고 `countByGame ... (또는 listCounts 집계)`로 listCounts 변형을 예고 → 동결 허용범위 내. W2-6 POPULAR 소스(`listCountsByJam`)와 W2-5 제공이 정합. castVote 인자순서(jamId,gameId,voterUserId)는 W2-3↔W2-5 **완전 일치**.
|
||||||
|
- `[LOW] 점수입력 경로 표현차`: W2-2 §230 의사코드는 `POST /jams/{slug}/scores`(예시), W2-4 실제 계약은 `POST /jams/{jamId}/games/{gameId}/scores`. W2-2 시퀀스는 게이트 소비점 예시일 뿐 경로 권위 아님 → 무해.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 전체 verdict
|
||||||
|
- **HIGH 1**(W2-1 본문 평가단위 표현 정정 — 스키마/계약 영향 0, 문서 drift).
|
||||||
|
- **LOW 3**(게이트 순서 표현차·vote mapper 시그니처 골격 변형·점수입력 경로 예시차 — 전부 동결 허용범위/무해).
|
||||||
|
- **동결 단일권위(W2-3) 유지·하류 소비 정합 — PASS.** jam_scores/jam_votes/jam_awards/jam_score_stats 의 평가단위·컬럼·트랙enum·게이트·단방향 계약을 W2-4/5/6 이 재정의 없이 소비. 중복 DDL·권한키 silent 추가·cross-W 라우트 불일치 **없음**.
|
||||||
|
|
@ -0,0 +1,229 @@
|
||||||
|
---
|
||||||
|
schema_version: 2
|
||||||
|
session_id: 20260623-104307
|
||||||
|
resumed_from: 20260622-180054
|
||||||
|
started_at: 2026-06-23T10:43:07+09:00
|
||||||
|
ended_at: null
|
||||||
|
user_request: |
|
||||||
|
남은 W 워크스트림을 설계한다(구현 아님). "골자 먼저 → 미결질문 확정 → 풀설계" 단계.
|
||||||
|
- 설계 대상: W1·W3-2 제외 전부 (W2-1~W2-6, W3-1/3-3/3-4/3-5, W4).
|
||||||
|
- 골자 신규 작성 = W2(6개)·W4 (W3-* 는 골자 이미 존재 → 재작성 불필요, 풀설계 진입 시 grounding 갱신).
|
||||||
|
- W3 skeletons "코드 현황" 절 stale 2건 명시: (1) Interceptor/권한게이트 이제 존재(security/PermissionGate·RbacInterceptor·PermissionKeys·config/InterceptorConfig) (2) game_reviews 리뷰테이블 이제 존재(+GameReviewStatsMapper).
|
||||||
|
- 제약: 설계만, 코드 0줄, src/·pom.xml 수정 금지. 미결질문은 골자 단계에서 해소하지 않음(풀설계 진입 시 AskUserQuestion 확정). 보안 명시: W2-2 권한 / W3-3 SSRF(OG·유니티블로그 피드) / W3-5 zip-slip.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
설계 전용(코드 0줄) 세션. 직전 세션 20260622-180054(W1 RBAC 설계/구현)에서 이어짐. 단계: ① W2·W4 골자 카탈로그 신규 작성(W3 skeletons 포맷) + W3 stale 2건 명시 → ② 골자 합의 → ③ 각 W 미결질문 AskUserQuestion 확정 → ④ 풀설계(W1-design.md 깊이: DDL/파일영향맵/API계약/시퀀스/AC매핑). 현재 ①단계 진입 — 골자 정확도 위해 W2/W4 코드 surface grounding 선행.
|
||||||
|
|
||||||
|
# Advisor Invocation Decision Log
|
||||||
|
# 각 advisor 호출/스킵 판단 즉시 1줄 append
|
||||||
|
- advisor: requirements-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '로드맵(jam-platform-roadmap.md)이 W2/W4 를 이미 기능 분해. 골자 카탈로그 포맷이 곧 요구 윤곽 — 별도 요구분해 불요. 미결질문은 골자 단계 비해소가 사용자 명시 제약.'
|
||||||
|
checked_at: 2026-06-23T10:44:00+09:00
|
||||||
|
- advisor: research-advisor
|
||||||
|
decision: call
|
||||||
|
rationale: '골자 "결합/의존" 의 code-fact vs 해석 태깅 정확도가 세션 핵심. W1 RBAC·리뷰 인프라가 직전 세션에 착지(941f9fb 등) → graph/문서 stale 가능성. 코드 직접 grounding 필요. parallel-explorer 6버킷 팬아웃.'
|
||||||
|
checked_at: 2026-06-23T10:44:00+09:00
|
||||||
|
- advisor: design-advisor
|
||||||
|
decision: skip (골자 단계 한정)
|
||||||
|
rationale: 'design-advisor 정체성 = "오픈질문 0 구현가능 설계도". 골자는 미결질문 *보존*이 목적(사용자 제약) → 권한 상충. 판단 핵심(grounding)은 research 로 위임 완료, 남은 건 고정 템플릿(W3 skeletons 포맷)에 grounded 사실 조립 + 미결 비해소. orchestrator 직접 작성이 적합(인플레이션 회피). 풀설계 단계(④)에서는 design-advisor 정식 호출.'
|
||||||
|
checked_at: 2026-06-23T10:58:00+09:00
|
||||||
|
- advisor: design-advisor (풀설계 ×11)
|
||||||
|
decision: call (Workflow 오케스트레이션)
|
||||||
|
rationale: '사용자 "전체 설계 개입없이 직접·정석" → 11기능 풀설계. ultracode ON → Workflow 로 3-phase 의존순서 병렬(design-advisor agentType, opus/high). 백그라운드라 orchestrator 컨텍스트 오염 0 → §2 parallel-advisor 금지의 취지(오염) 충족. 미결질문은 orchestrator 가 정석 결정으로 닫아 주입(design 은 0 오픈질문 산출).'
|
||||||
|
checked_at: 2026-06-23T11:14:00+09:00
|
||||||
|
- advisor: research-advisor (W3-5 선행)
|
||||||
|
decision: call (Workflow PhaseA 내)
|
||||||
|
rationale: '골자대로 W3-5 는 7항목 조사 선행 후 설계. 조사(PhaseA) → 설계(PhaseB) 파이프.'
|
||||||
|
checked_at: 2026-06-23T11:14:00+09:00
|
||||||
|
|
||||||
|
# Invocations
|
||||||
|
- id: inv-001
|
||||||
|
layer: orchestrator
|
||||||
|
name: orchestrator
|
||||||
|
parent_invocation_id: null
|
||||||
|
started_at: 2026-06-23T10:43:07+09:00
|
||||||
|
ended_at: 2026-06-23T10:44:00+09:00
|
||||||
|
input_digest: '남은 W 설계(골자먼저) 요청 + docs-first grounding(로드맵/W3skeletons/W1-design 선례) + ★ stale 2건 파일레벨 확인'
|
||||||
|
output_digest: 'init가드 pass, migrate 마커 없음(skip), 프로토콜 코어 로드, ★ RBAC/리뷰 인프라 파일 존재 확인(+GameReviewAxesMapper 발견), research-advisor 디스패치 결정'
|
||||||
|
artifacts: [.atp/work-session/20260623-104307/report.md]
|
||||||
|
concerns: []
|
||||||
|
- id: inv-002
|
||||||
|
layer: advisor
|
||||||
|
name: research-advisor
|
||||||
|
agent_version: 2
|
||||||
|
parent_invocation_id: inv-001
|
||||||
|
started_at: 2026-06-23T10:45:00+09:00
|
||||||
|
ended_at: 2026-06-23T10:53:00+09:00
|
||||||
|
input_digest: 'W2/W4 골자 grounding 6버킷(RBAC소비/리뷰평점/games/game_likes/users+배지/관례·DDL) — code-fact vs 해석 태깅, ★STALE 2건 확정'
|
||||||
|
output_digest: '★STALE1·2 둘 다 실재 확정. enforcement 갭(POST_WRITE/GAME_JAM_MANAGE 소비처0), 리뷰 하이브리드(overall+6축)+stats VIEW, W2-2 스코프 갭(전역 권한모델), jams/배지 전무, game_likes 1인1표 UNIQUE 추정(비권위)'
|
||||||
|
artifacts: [.atp/work-session/20260623-104307/research/W2-W4-grounding.md]
|
||||||
|
concerns:
|
||||||
|
- 'game_likes UNIQUE 제약 비권위(운영 DB 검증 필요) → QG-W2-D'
|
||||||
|
- 'W2-2 잼 스코프 권한 미수렴(전역 모델) → QG-W2-A, design 결정'
|
||||||
|
model_choice:
|
||||||
|
phase: analyze
|
||||||
|
dispatch_size: parallel
|
||||||
|
tier: large
|
||||||
|
effort: high
|
||||||
|
resolved_model: opus
|
||||||
|
capped: false
|
||||||
|
escalation_reason: '탐색적(worker 가 볼 대상 결정) + grounding 정확도가 세션 전체 전제 + W2-2 스코프갭 trade-off 발견'
|
||||||
|
rationale: 'code-fact 태깅·갭 발견이 하류 설계의 토대. 6 explorer 병렬.'
|
||||||
|
planned_workers: 6
|
||||||
|
actual_workers: 6
|
||||||
|
- id: inv-003
|
||||||
|
layer: orchestrator
|
||||||
|
name: orchestrator
|
||||||
|
parent_invocation_id: inv-001
|
||||||
|
started_at: 2026-06-23T10:54:00+09:00
|
||||||
|
ended_at: 2026-06-23T11:00:00+09:00
|
||||||
|
input_digest: 'grounding + 로드맵 → W2(6)·W4 골자 카탈로그 직접 작성(design-advisor skip, 미결 비해소 보존)'
|
||||||
|
output_digest: 'W2-W4 골자 카탈로그(stale정정 2건 + 코드현황 + 7기능 골자 + 결합표 + 착수순서 + QG-W2-A~D·W4-A) 작성. W3 skeletons 배너 + index 링크'
|
||||||
|
artifacts:
|
||||||
|
- docs/work-log/2026-06-23-w2-w4-feature-skeletons.md
|
||||||
|
- docs/work-log/2026-06-17-w3-feature-skeletons.md
|
||||||
|
- docs/work-log/index.md
|
||||||
|
concerns: []
|
||||||
|
- id: inv-004
|
||||||
|
layer: orchestrator
|
||||||
|
name: orchestrator (Workflow w2-w4-full-design)
|
||||||
|
parent_invocation_id: inv-001
|
||||||
|
started_at: 2026-06-23T11:14:00+09:00
|
||||||
|
ended_at: null
|
||||||
|
input_digest: '11기능 풀설계 — 정석 결정 주입, 3-phase 의존순서(A:W2-1/W3-1/W3-3/W4/W3-5조사 → B:W2-3동결/W3-4/W3-5설계 → C:W2-2/4/5/6). Workflow run wf_9e1bd074-6ae'
|
||||||
|
output_digest: '10/11 설계 완성(각 34-49KB, W1-design 깊이) + W3-5 조사 완료. W3-1 만 Stream idle timeout 실패. src/ 변경 0(설계전용 준수). 12 agent, ~1.32M tokens.'
|
||||||
|
artifacts:
|
||||||
|
- .atp/work-session/20260623-104307/implementation/W2-1-jam-entity-design.md
|
||||||
|
- .atp/work-session/20260623-104307/implementation/W2-2-judge-role-design.md
|
||||||
|
- .atp/work-session/20260623-104307/implementation/W2-3-eval-freeze-design.md
|
||||||
|
- .atp/work-session/20260623-104307/implementation/W2-4-judge-scoring-design.md
|
||||||
|
- .atp/work-session/20260623-104307/implementation/W2-5-popular-vote-design.md
|
||||||
|
- .atp/work-session/20260623-104307/implementation/W2-6-award-aggregation-design.md
|
||||||
|
- .atp/work-session/20260623-104307/implementation/W3-3-posting-board-design.md
|
||||||
|
- .atp/work-session/20260623-104307/implementation/W3-4-main-hub-design.md
|
||||||
|
- .atp/work-session/20260623-104307/implementation/W3-5-upload-design.md
|
||||||
|
- .atp/work-session/20260623-104307/implementation/W4-badges-design.md
|
||||||
|
- .atp/work-session/20260623-104307/research/W3-5-upload-research.md
|
||||||
|
concerns:
|
||||||
|
- 'W3-1 실패 → inv-005 재실행. W3-4 는 W3-1 을 라우팅 계약 앵커로 방어설계(부재 명시) → 재실행 불요'
|
||||||
|
- 'QG-3 stale 추가 발견(W3-5 조사): /game/** 서빙은 GameAssetController(@GetMapping /game/{uuid}/**) 로 정상 동작 — 골자 "핸들러 미등록=서빙 미보장" 정정 필요(버그 아님)'
|
||||||
|
- 'W3-5 조사: zip-slip 심링크 구멍·업로드 권한게이트 전무·포맷검증 index.html만 — W3-5 설계가 보강'
|
||||||
|
model_choice:
|
||||||
|
phase: design
|
||||||
|
dispatch_size: parallel
|
||||||
|
tier: large
|
||||||
|
effort: high
|
||||||
|
resolved_model: opus
|
||||||
|
rationale: '풀설계 — trade-off 빈번 + 보안(W2-2/W3-3 SSRF/W3-5 zip-slip) + 정석 요구. 전 design 에이전트 opus/high.'
|
||||||
|
planned_workers: 11
|
||||||
|
actual_workers: 11
|
||||||
|
- id: inv-005
|
||||||
|
layer: advisor
|
||||||
|
name: design-advisor (W3-1 재실행 1 — 실패)
|
||||||
|
agent_version: 1
|
||||||
|
parent_invocation_id: inv-001
|
||||||
|
started_at: 2026-06-23T11:36:00+09:00
|
||||||
|
ended_at: 2026-06-23T11:42:00+09:00
|
||||||
|
input_digest: 'W3-1 풀설계 단일 재실행(인접 Read 포함)'
|
||||||
|
output_digest: 'Stream idle timeout 2회 연속(workflow 포함) → §2.1 분할 재시도 전환'
|
||||||
|
artifacts: []
|
||||||
|
concerns: ['design-advisor opus 대형 단일호출 + 다중 대형 Read 가 stream idle timeout 유발(2회). §2.1 분할로 회복.']
|
||||||
|
model_choice: { phase: design, dispatch_size: direct, tier: large, effort: high, resolved_model: opus, fallback_reason: 'stream idle timeout 2회 → 분할', rationale: '실패 재실행' }
|
||||||
|
- id: inv-006
|
||||||
|
layer: advisor
|
||||||
|
name: design-advisor (W3-1 part1 §1-7)
|
||||||
|
agent_version: 1
|
||||||
|
parent_invocation_id: inv-001
|
||||||
|
started_at: 2026-06-23T11:48:00+09:00
|
||||||
|
ended_at: 2026-06-23T11:51:00+09:00
|
||||||
|
input_digest: 'W3-1 §1-7, 필요 사실 전부 인라인 주입(Read 0) — idle timeout 회피'
|
||||||
|
output_digest: 'frontmatter+§1-7 작성. tags/game_tags/jam_tags/game_views, 잼검색 라우트 GET /games/search?jam={slug} 확정'
|
||||||
|
artifacts: [.atp/work-session/20260623-104307/implementation/W3-1-tags-search-design.md]
|
||||||
|
concerns: []
|
||||||
|
- id: inv-007
|
||||||
|
layer: advisor
|
||||||
|
name: design-advisor (W3-1 part2 §8-13)
|
||||||
|
agent_version: 1
|
||||||
|
parent_invocation_id: inv-001
|
||||||
|
started_at: 2026-06-23T11:54:00+09:00
|
||||||
|
ended_at: 2026-06-23T11:57:00+09:00
|
||||||
|
input_digest: 'W3-1 §8-13 append(part1 1회 Read)'
|
||||||
|
output_digest: 'W3-1 완성 470줄, 오픈질문 0. 권한키 D4=CONTENT_MODERATE 재사용 확정'
|
||||||
|
artifacts: [.atp/work-session/20260623-104307/implementation/W3-1-tags-search-design.md]
|
||||||
|
concerns: []
|
||||||
|
- id: inv-008
|
||||||
|
layer: advisor
|
||||||
|
name: general-purpose (W2 클러스터 교차정합 감사)
|
||||||
|
parent_invocation_id: inv-001
|
||||||
|
started_at: 2026-06-23T12:00:00+09:00
|
||||||
|
ended_at: null
|
||||||
|
input_digest: '11설계 교차정합 — 평가단위·동결컬럼·게이트계약·중복DDL·권한키·보안 점검'
|
||||||
|
output_digest: 'HIGH 1(W2-1 평가단위 표현 drift, DDL정합) + MED 0 + LOW 3(무해). 동결 단일권위 유지, 하류 정합 PASS. 컬럼/권한키/라우트/보안 clean'
|
||||||
|
artifacts: [.atp/work-session/20260623-104307/implementation/_cross-consistency-audit.md]
|
||||||
|
concerns: []
|
||||||
|
model_choice: { phase: validation-런타임, dispatch_size: direct, tier: large, effort: high, resolved_model: opus, rationale: '교차정합은 주관적 판단 — large' }
|
||||||
|
- id: inv-009
|
||||||
|
layer: orchestrator
|
||||||
|
name: orchestrator (HIGH-1 정정 + 설계 인덱스)
|
||||||
|
parent_invocation_id: inv-001
|
||||||
|
started_at: 2026-06-23T12:05:00+09:00
|
||||||
|
ended_at: 2026-06-23T12:12:00+09:00
|
||||||
|
input_digest: '감사 HIGH-1(W2-1 평가단위 표현) 5개소 정정 + 통합 결정요약/설계 인덱스 작성 + work-log index'
|
||||||
|
output_digest: 'W2-1 (jam_id,game_id) 자연키로 정정, summary 문서 + 인덱스 링크. 골자 stale 3건 정정'
|
||||||
|
artifacts:
|
||||||
|
- .atp/work-session/20260623-104307/implementation/W2-1-jam-entity-design.md
|
||||||
|
- docs/work-log/2026-06-23-w2-w4-full-design-summary.md
|
||||||
|
- docs/work-log/index.md
|
||||||
|
concerns: []
|
||||||
|
|
||||||
|
# Conflicts
|
||||||
|
- between: [W2-3-eval-freeze, W2-5-popular-vote]
|
||||||
|
detected_at: 2026-06-23T11:59:00+09:00
|
||||||
|
resolved_by: orchestrator
|
||||||
|
outcome: '비충돌 확정 — jam_votes 가 양쪽 CREATE 로 등장하나 W2-5 본문이 명시적으로 "재정의 아님·신규 DDL 0·권위 W2-3"(line 30/36/45/55) + 컬럼/UNIQUE 동일. W2-5 의 CREATE 는 참조용 인용. 동결 단일권위(W2-3) 유지. 구현 혼동 방지 마커는 감사 후 미세 보강 검토.'
|
||||||
|
|
||||||
|
# Open Items
|
||||||
|
- by: orchestrator
|
||||||
|
at: 2026-06-23T10:44:00+09:00
|
||||||
|
decision: 'resumed_from 20260622-180054 기록(사용자 명시). 이 세션은 골자 단계만 우선 — 골자 합의 후 미결질문/풀설계로 진행(중간 사용자 게이트).'
|
||||||
|
rationale: '사용자 브리프가 단계적 진행을 명시. 골자 = 합의 artifact.'
|
||||||
|
related_invocations: [inv-001]
|
||||||
|
|
||||||
|
- by: user
|
||||||
|
at: 2026-06-23T11:10:00+09:00
|
||||||
|
decision: '전체 설계를 orchestrator 가 개입 없이 직접 결정·진행. 빠른길 아닌 "확실하고 정석적" 방법. AskUserQuestion 면제(명시 위임).'
|
||||||
|
rationale: '사용자 발화 "전체 설계를 내 개입없이 직접해보고. 다만 빠르고 쉽게 끝낼수있는 방법이 아니라 확실하고 정석적인 방법으로". §명시적 비활성 경로 + §5.0 면제.'
|
||||||
|
related_invocations: [inv-001]
|
||||||
|
- by: orchestrator
|
||||||
|
at: 2026-06-23T11:12:00+09:00
|
||||||
|
decision: '정석 기준 캐스케이드 결정 확정: (W2-A) 심사위원=별도 jam_judges 테이블(전역 RBAC 불변). (W2-B) 유저평점 트랙=overall avg_rating(game_review_stats VIEW, 기간필터+NULL처리). (W2-C1) 잼 연결=조인테이블 jam_entries(정규화·다회차). (W2-C2) 팀 출품=1차 포함(jam_teams/jam_team_members — 잼은 팀 이벤트 본질). (W2-4 척도) 잼별 jam_criteria 설정형. (W2-5 단위) 잼당 1인1표 최애(UNIQUE(jam_id,voter_user_id)). (W2-6) 3트랙 개별 수상 + 가중 grand prize. (W4) 자동임계+수동 하이브리드, badges+user_badges+reputation_events.'
|
||||||
|
rationale: '정석/robust 우선 — 빠른 스코프 최소화(jam_id 컬럼/개인우선/단일평점) 대신 정규화·확장성·도메인 정합 선택. 각 design 문서가 대안비교로 근거 보존.'
|
||||||
|
related_invocations: [inv-004]
|
||||||
|
|
||||||
|
# Conflicts
|
||||||
|
|
||||||
|
# Regression
|
||||||
|
- surfaced_at_stage: 교차정합 감사(inv-008)
|
||||||
|
source_stage: 설계(W2-1)
|
||||||
|
defect: 'W2-1 본문 5개소가 "평가 단위 = jam_entries.id" 로 표현 — W2-3 동결 권위((jam_id,game_id) 자연키)와 식별자 모순. W2-1 DDL 자체·하류 W2-4/5/6 은 정합(game_id), 순수 표현 drift'
|
||||||
|
full_set_recheck: true
|
||||||
|
downstream_rerun: ['W2-1 본문 5개소 정정(11/43/65/196/406). 하류 재실행 불요 — 감사가 W2-4/5/6 game_id 정합 확인']
|
||||||
|
resolved_at: 2026-06-23T12:08:00+09:00
|
||||||
|
|
||||||
|
# Open Items
|
||||||
|
- 'graph-refresh-checker: skip — git diff 가 docs/ + .atp/work-session/ 뿐, src/ scope 변경 0(설계 전용). §3.2 no-scope-change → fresh 취급'
|
||||||
|
- 'game_likes 운영 DB UNIQUE 제약 미확인(추정, grounding concern) — W2-5 는 신규 jam_votes 로 무관하나 별도 운영 DB 확인 권장(QG-W2-D)'
|
||||||
|
- '구현 단계 점검 항목(각 설계 concerns): 신규 매퍼 @MockBean full-test §30, DB-방언 alias §33, 시그니처 inflate §11.2, W3-3 pom 의존(commonmark/jsoup)+@EnableScheduling, W4 의 W1 AC-T1 카운트 3→4 재측정, SSRF rebinding 단위테스트'
|
||||||
|
- '이 세션은 설계까지 — 구현(implementation-advisor)·L1/L2 검증은 착수 시 별도 세션'
|
||||||
|
|
||||||
|
# User Signals
|
||||||
|
user_signals:
|
||||||
|
positive:
|
||||||
|
- quote_or_paraphrase: '"전체 설계를 내 개입없이 직접해보고" — orchestrator 자율 진행 위임'
|
||||||
|
about: '골자 합의 후 미결확정/풀설계 전 과정을 orchestrator 자율 결정에 위임(신뢰 시그널)'
|
||||||
|
negative:
|
||||||
|
- quote_or_paraphrase: '"확실하고 정석적인 방법으로" (빠른길 아닌) — AskUserQuestion 1차 옵션이 스코프 최소화(개인우선/jam_id컬럼/단일평점) 편향이었음을 사용자가 clarify 요청으로 교정 유도'
|
||||||
|
about: 'AskUserQuestion Recommended 가 "빠르고 쉬운" 쪽으로 편향 → 사용자가 정석 지향 명시. 옵션 설계 시 정석/robust 축을 기본 Recommended 로 두지 않은 점'
|
||||||
|
structural: false
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
---
|
||||||
|
phase: research
|
||||||
|
agent: research-advisor
|
||||||
|
agent_version: 2
|
||||||
|
generated_at: 2026-06-23T10:43:00+09:00
|
||||||
|
session_id: 20260623-104307
|
||||||
|
concerns:
|
||||||
|
- "low source confidence — game_likes 테이블/제약(R-D): db/schema.sql 의 game_likes 는 schema.sql:8,248 에서 '비권위 복원본'으로 명시됨. UNIQUE(game_id,user_key) 부재·user_key=varchar 는 schema.sql 근거이나 운영 DB 실제 제약은 미확인. W2-5 인기투표 1인1표 전제로 승격 전 운영 DB 제약 검증 필요."
|
||||||
|
- "scope-axis 미수렴(W2-2): user_permissions 가 (user_id, permission_key) 전역 모델임은 code-fact 로 확정되나, '잼 회차별 역할'이 현 모델에 안 얹힌다는 판단은 해석. design 단계에서 스코프 확장 vs 별도 테이블 결정 필요 — 본 research 는 결정하지 않음."
|
||||||
|
concerns_checked: true
|
||||||
|
source_confidence: mixed
|
||||||
|
workers_spawned: 6
|
||||||
|
---
|
||||||
|
|
||||||
|
# W2(게임잼 6서브) + W4(유저 배지/평판) 골자 grounding — code surface
|
||||||
|
|
||||||
|
조사만. 설계·구현·미결질문 해소 없음. 각 사실은 `code-fact`(파일:라인) / `해석`(설계판단) 으로 태깅.
|
||||||
|
|
||||||
|
## 주제
|
||||||
|
"남은 W 워크스트림" 설계 세션 진입 전, W2/W4 골자 카탈로그 "결합/의존" 절을 code-fact 정확도로 채우기 위한 코드 surface grounding. 특히 ★STALE1(W1 RBAC), ★STALE2(W3-2 리뷰) 의 실재 확정.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ★요약 (최상단 — 설계 전제)
|
||||||
|
|
||||||
|
### ★STALE1 — W1 RBAC: **확정·실재** (code-fact)
|
||||||
|
W1 RBAC 인터셉터/권한게이트/카탈로그가 전부 실재하며 동작 surface 가 명확하다.
|
||||||
|
- `PermissionGate`(has/canModerate/isAuthenticated/isAdmin/require), `RbacInterceptor`(401 vs 403 분기), `PermissionKeys`(enum 3종), `Roles`(ADMIN/SUBADMIN/USER), `CsrfTokens`, `InterceptorConfig`, `PermissionCatalogVerifier`, `UserPermissionsMapper` 전부 존재.
|
||||||
|
- users 에 `role`(CHECK ADMIN/SUBADMIN/USER) + `permissions_epoch` 컬럼 실재. 세션 epoch 전파(`refreshIfStale`) 동작.
|
||||||
|
- **단, enforcement 갭(code-fact)**: 인터셉터는 `/admin/**` 에만 등록 + `isAdmin` 만 검사(SUBADMIN/권한키 분기 없음). 권한 키 `CONTENT_MODERATE` 만 컨트롤러(canModify)에서 소비되고, **`POST_WRITE`·`GAME_JAM_MANAGE` 는 enum 선언만 있고 소비처 0건**.
|
||||||
|
|
||||||
|
### ★STALE2 — W3-2 리뷰: **확정·실재** (code-fact)
|
||||||
|
game_reviews + game_review_axes + game_review_stats(VIEW) + 4개 매퍼 전부 실재.
|
||||||
|
- 평점은 **하이브리드**: 단일 overall(`game_reviews.rating` smallint CHECK 1~5) + **6축 다축**(`game_review_axes`, 리뷰당 6행, axis_key 6종). `is_rating_manual` 로 overall 출처 구분(true=직접, false=6축 자동평균).
|
||||||
|
- `game_review_stats` 는 **테이블이 아니라 VIEW**(읽기전용 집계, 9컬럼: avg_rating/review_count/6축평균). 커밋 21892c8 = 이 뷰의 fan-out 버그 + 매퍼 alias 케이스폴딩 버그 2건 수정.
|
||||||
|
|
||||||
|
### ★W2-2 권한 스코프 갭 (해석 — code 근거 제시)
|
||||||
|
- **code-fact**: `user_permissions` 컬럼 = id/user_id/permission_key/granted_by/created_at. UNIQUE = `(user_id, permission_key)`. **scope/resource_id/jam_id 컬럼 부재**. `PermissionGate.has(session, permissionKey)` 도 리소스 인자 없음 → 권한은 **전역(글로벌) 모델**.
|
||||||
|
- **해석**: W2-2 "심사위원 = 잼 회차별 역할" 은 현 글로벌 권한 모델 위에 그대로 안 얹힌다(스코프 갭). 잼별 권한을 표현하려면 스키마·게이트 시그니처 확장 또는 별도 잼-역할 테이블이 필요 — **이건 설계 결정, research 는 결정 안 함**.
|
||||||
|
|
||||||
|
### 신규성 확정 (code-fact)
|
||||||
|
- **jams 테이블 전무**: schema.sql / docs/*.sql / src 전체 `jam_id`·`CREATE TABLE jam` 0 hit (PermissionKeys 의 "게임잼" 은 한글 라벨일 뿐). → W2 잼 엔티티 전부 신규.
|
||||||
|
- **배지/평판 구조 전무**: badge/reputation/trust_level/karma/honor 도메인 테이블·컬럼·클래스 0 hit (hit 는 전부 JSP CSS 클래스명). → W4 전부 신규.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 버킷별 발견
|
||||||
|
|
||||||
|
### R-A. W1 RBAC 소비 surface (★STALE1)
|
||||||
|
**신뢰도: 확인됨** (전 항목 1차 파일:라인 확인)
|
||||||
|
|
||||||
|
| 항목 | 사실 | 근거 | 태그 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| PermissionGate 메서드 | `boolean has(HttpSession, String)` / `canModerate(HttpSession)`→has(CONTENT_MODERATE) / `isAuthenticated` / `isAdmin` / `require`(이름과 달리 throw 없이 has 위임) | PermissionGate.java:22,40,47,55,64 | code-fact |
|
||||||
|
| epoch 동기화 | `refreshIfStale` 가 `usersMapper.getPermissionsEpoch` 와 세션 permsEpoch 비교 후 role/permissions/permsEpoch 재설정 | PermissionGate.java:86-104 | code-fact |
|
||||||
|
| PermissionKeys | enum 3종: GAME_JAM_MANAGE/POST_WRITE/CONTENT_MODERATE (그 외 없음) | PermissionKeys.java:4-6 | code-fact |
|
||||||
|
| Roles | String 상수 ADMIN/SUBADMIN/USER (enum 아님) | Roles.java:5-7 | code-fact |
|
||||||
|
| RbacInterceptor | preHandle: 미인증→page면 /login redirect 아니면 401 JSON / 미인가(`!isAdmin`)→403 JSON | RbacInterceptor.java:20-40 | code-fact |
|
||||||
|
| 인터셉터 등록 | `addPathPatterns("/admin/**")` 단 하나, exclude 없음 | InterceptorConfig.java:18-20 | code-fact |
|
||||||
|
| PermissionCatalogVerifier | 부팅 시 enum→DB upsert 멱등 시드 + DB-only 키 log.warn | PermissionCatalogVerifier.java:28-48 | code-fact |
|
||||||
|
| UserPermissionsMapper | listKeys/exists/insert/delete/deleteAllByUser, 전부 `#{}` | UserPermissionsMapper.java:14-48 | code-fact |
|
||||||
|
| **게이트 소비처** | `CONTENT_MODERATE`(canModerate) 만 소비: GameCommentController.java:203, GameReviewController.java:422. **POST_WRITE·GAME_JAM_MANAGE 소비처 0건** | rg POST_WRITE\|GAME_JAM_MANAGE → PermissionKeys.java 만 hit | code-fact |
|
||||||
|
| user_permissions scope | 컬럼 id/user_id/permission_key/granted_by/created_at, UNIQUE(user_id,permission_key), **scope 컬럼 부재 → 전역 권한** | db/schema.sql:326-347, docs/rbac-ddl.sql:37-58 | code-fact |
|
||||||
|
|
||||||
|
- **W2 결합 함의(1줄)**: W2-2 잼 회차별 심사위원 역할은 현 전역 권한 모델 + 미연결 GAME_JAM_MANAGE 키 위에서 스코프 확장 결정이 선행돼야 한다(해석).
|
||||||
|
|
||||||
|
### R-B. 리뷰/평점 surface (★STALE2)
|
||||||
|
**신뢰도: 확인됨**
|
||||||
|
|
||||||
|
| 항목 | 사실 | 근거 | 태그 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| game_reviews | rating smallint CHECK 1~5, body text, user_id nullable, nickname 스냅샷, is_rating_manual(true=직접/false=6축자동평균) | db/schema.sql:130-148,162-164 | code-fact |
|
||||||
|
| 활성 유니크 | `ux_game_reviews_game_user_active (game_id,user_id) WHERE is_delete IS NOT TRUE` → 게임당 유저 1리뷰 | db/schema.sql:145-146 | code-fact |
|
||||||
|
| game_review_axes | review_id/axis_key(varchar20)/score(smallint CHECK 1~5), axis_key CHECK 6종(immersion/creativity/controls/completeness/sound/visual), UNIQUE(review_id,axis_key) → **리뷰당 6행** | db/schema.sql:168-186,201 | code-fact |
|
||||||
|
| game_review_stats | **VIEW**(테이블 아님). 9컬럼: game_id/avg_rating/review_count/avg_immersion..avg_visual. fan-out 방지: 축평균 서브쿼리 game단위 선집계 후 LEFT JOIN | db/schema.sql:212-243 | code-fact |
|
||||||
|
| StatsMapper alias | 리뷰단위 3컬럼 큰따옴표 alias(`AS "avgRating"` 등, case 보존), 6축은 따옴표 없는 단축명 | GameReviewStatsMapper.java:13-30 | code-fact |
|
||||||
|
| AxesMapper | addReviewAxes(배치INSERT)/deleteReviewAxes/listByReviewIds, 수정 시 전체삭제→재삽입 | GameReviewAxesMapper.java:17-28 | code-fact |
|
||||||
|
| 컨트롤러 | GET/POST/PUT/DELETE `/game/{id}/reviews`, summary 는 stats 뷰 사용, rating 미입력 시 6축평균 반올림 | GameReviewController.java:38,159-170,375-394 | code-fact |
|
||||||
|
| 커밋 21892c8 | BUG-1 뷰 fan-out(COUNT/AVG 왜곡) + BUG-2 매퍼 alias 케이스폴딩(summary 항상 null) 수정 | docs/changes/2026-06-22-...enhancement.md:300-311 (1차 changes 문서 교차검증) | code-fact |
|
||||||
|
| game_comments | 리뷰와 **별개 테이블**(rating 없음), 동일 game_id 공유뿐 FK 없음 | GameCommentsMapper.java | code-fact |
|
||||||
|
|
||||||
|
- **W2/W4 결합 함의(1줄)**: W2-3 동결계약·W2-6 유저평점·W3-1 정렬·W4 배지가 모두 이 하이브리드(overall+6축) + VIEW 집계를 소비 — 단일 평균 가정 금지, axes 0행 리뷰는 6축평균 NULL(백필 미적용 code-fact, enhancement.md:322).
|
||||||
|
|
||||||
|
### R-C. games 엔티티 surface (W2-1)
|
||||||
|
**신뢰도: 확인됨**
|
||||||
|
|
||||||
|
| 항목 | 사실 | 근거 | 태그 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| games 컬럼(13) | id/user_id(FK users)/name/creator_note/git_url/webgl_path/thumbnail_url/like_count(int default0)/is_visible/sort_order/created_at/updated_at/is_delete | db/schema.sql:88-100 | code-fact |
|
||||||
|
| **jam_id** | **부재** (games 13컬럼에 없음, jam_id grep 0 hit) | db/schema.sql:88-100 | code-fact |
|
||||||
|
| 조회/방문수 | visit_count/view_count **부재**. 인기지표는 like_count 만 | db/schema.sql:88-100 | code-fact |
|
||||||
|
| 팀 구분 | team_id **부재**. user_id 단일 FK → 1게임=1유저 | db/schema.sql:89 | code-fact |
|
||||||
|
| getVisibleGames | `is_visible IS NOT FALSE AND is_delete IS NOT TRUE`, ORDER BY sort_order ASC, created_at DESC, id DESC | GamesMapper.java:62 | code-fact |
|
||||||
|
| searchVisibleGames | ILIKE 부분일치 3컬럼(name/users.display_name/creator_note) | GamesMapper.java:85-87 | code-fact |
|
||||||
|
| 생성 경로 | 메타: GameController `@PostMapping("/game/new") @Transactional createGame`; 파일: GameUploadController uploadGameFiles/webgl-zip/thumbnail. jam 파라미터 없음 | GameController.java:50, GameUploadController.java:52 | code-fact |
|
||||||
|
| 삭제 연쇄 | GamesMapper: softDeleteGameComments/softDeleteGameReviews/deleteGameLikes(hard)/softDeleteGame | GamesMapper.java:173-199 | code-fact |
|
||||||
|
|
||||||
|
- **W2 결합 함의(1줄)**: W2-1 잼 엔티티가 games 를 재사용하려면 jam 연결(jam_id 또는 조인테이블)·방문수·팀출품 구분이 모두 신규 추가 대상(해석: 재사용 가능하나 확장 필요).
|
||||||
|
|
||||||
|
### R-D. game_likes surface (W2-5)
|
||||||
|
**신뢰도: 추정** (테이블/매퍼 실재는 직접 1차 확인 = 확인됨, 그러나 **제약은 비권위 복원본 근거**)
|
||||||
|
|
||||||
|
| 항목 | 사실 | 근거 | 태그 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GameLikesMapper 실재 | getGameLike(id)/addGameLike(INSERT)/updateGameLike(id기준). **DELETE·토글·(game_id,user_key)조회 메서드 없음** | GameLikesMapper.java:13-43 (advisor 직접 1차 확인) | code-fact |
|
||||||
|
| game_likes 테이블 | 컬럼 id(PK)/game_id(FK games)/**user_key varchar(200)**/created_at. PRIMARY KEY(id) 만 | db/schema.sql:251-257 (advisor 직접 1차 확인) | code-fact |
|
||||||
|
| **1인1표 UNIQUE** | (game_id,user_key) UNIQUE **부재** (schema.sql 상). 비권위 복원본이라 운영 DB 제약 **미확인** | db/schema.sql:8,248,251-257 | **추정** |
|
||||||
|
| user_key 의미 | user_id(bigint) 아님 — varchar 식별자. 채움값(로그인 user_id vs 익명)은 호출부 영역 미확인 | db/schema.sql:254 | code-fact(컬럼)/미확인(값) |
|
||||||
|
| 토글/카운터 | 매퍼에 토글·games.like_count 갱신 SQL 없음. 게임 삭제 시 GamesMapper.deleteGameLikes hard delete 만 | GameLikesMapper.java / GamesMapper.java:186-189, GameController.java:261, seed-dev-teardown.sql:25 | code-fact |
|
||||||
|
|
||||||
|
- **W2 결합 함의(1줄)**: W2-5 인기투표는 game_likes 와 별개로 봐야 하며, game_likes 자체가 1인1표 미보장·user_key varchar·토글 부재 상태라 인기투표 1인1표 전제는 game_likes 재활용으로 자동 충족되지 않음(해석; 운영 DB 제약 검증이 concern).
|
||||||
|
|
||||||
|
> **R-D worker 중간 출력 정정**: worker 의 첫 응답은 "GameLikesMapper/game_likes 부재"였으나 **오류**. advisor 가 1차 직접 확인(`ls`, `rg game_likes db/schema.sql`)으로 **둘 다 실재** 확정. worker 최종 응답도 실재로 정정됨. 이 문서는 직접 확인 결과를 채택.
|
||||||
|
|
||||||
|
### R-E. users/identity + 배지 surface (W4)
|
||||||
|
**신뢰도: 확인됨**
|
||||||
|
|
||||||
|
| 항목 | 사실 | 근거 | 태그 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| users 컬럼 | id/display_name/canonical_email/avatar_url/**role**(default USER, CHECK ADMIN/SUBADMIN/USER)/status/last_login_at/created_at/updated_at/is_delete + **permissions_epoch**(bigint default0) | db/schema.sql:31-56 | code-fact |
|
||||||
|
| role/epoch 출처 | role·permissions_epoch 는 **W1 RBAC 산출물**(W4 신규 아님). perms_epoch 명칭은 0 hit | db/schema.sql:48-56 | code-fact |
|
||||||
|
| UserData(10필드) | id/displayName/canonicalEmail/avatarUrl/role/permissionsEpoch/status/lastLoginAt/createdAt/updatedAt (is_delete 미매핑) | UserData.java:7-16 | code-fact |
|
||||||
|
| UsersMapper | getUser/addUser/updateUser/getPermissionsEpoch/bumpPermissionsEpoch/updateRole/listOperators | UsersMapper.java:17-104 | code-fact |
|
||||||
|
| user_auth_identities | provider/provider_user_id/email/password_hash/display_name/avatar_url, users 1:N, active-unique(provider,provider_user_id) | db/schema.sql:62-81 | code-fact |
|
||||||
|
| 표시명 출처 | **users.display_name 단일 출처**(nickname 은 game_comments/리뷰 스냅샷이지 users 컬럼 아님). 세션 attr `displayName` | db/schema.sql:32, UserController.java:511 | code-fact |
|
||||||
|
| **배지/평판** | badge/reputation/trust_level/karma/honor 도메인 테이블·컬럼·클래스 **0 hit**(hit 는 전부 JSP CSS) | rg 0 hit (db/docs/src) | code-fact(부재) |
|
||||||
|
|
||||||
|
- **W4 결합 함의(1줄)**: W4 배지/평판은 전부 신규(스토리지 0). 단 평판 신호의 원천(리뷰=R-B 6축, 좋아요=R-D game_likes, 역할=R-A role)은 모두 실재 — 신규 배지 테이블이 이들을 집계 소비하는 구조(해석).
|
||||||
|
|
||||||
|
### R-F. 관례/DDL 워크플로 (전 W 공통)
|
||||||
|
**신뢰도: 확인됨**
|
||||||
|
|
||||||
|
| 항목 | 사실 | 근거 | 태그 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 게시판 패턴 | RecruitPostsMapper: get/getVisible(users JOIN)/add(useGeneratedKeys)/nextSortOrder. **페이징 없음**(전건), ORDER BY sort_order ASC,created_at DESC,id DESC | RecruitPostsMapper.java:70,107,110 | code-fact |
|
||||||
|
| 컨트롤러 혼합 | 읽기=JSP 뷰이름 반환(`return "recruit-list"`), 상태변경 POST=ResponseEntity JSON + `CsrfTokens.isValid` + 세션 userId + 화이트리스트 | RecruitController.java:36,65,145 | code-fact |
|
||||||
|
| **POST 권한게이트** | RecruitController POST /recruit/new 는 **CSRF+세션userId만**, PermissionGate/POST_WRITE 미적용 → 작성은 모든 로그인 유저 개방 | RecruitController.java:65,159 (advisor 직접 교차확인) | code-fact |
|
||||||
|
| 로그인 세션 attr | id/userId/displayName/email/avatarUrl/role/status/authProvider/authIdentityId/lastLoginAt/account(Map)/**permissions(Set)**/**permsEpoch(long)**. changeSessionId()로 세션고정방어 | UserController.java(controller/api/):166,508-537 | code-fact |
|
||||||
|
| DDL 권위 | **권위 원본 = docs/*-ddl.sql**. apply-local-ddl.sh 가 docs/*-ddl.sql glob 순(알파벳) 멱등 적용, ON_ERROR_STOP, search_path=dev. schema.sql = 컨테이너 최초1회 부트스트랩(신규테이블은 docs 동기, legacy users/games/game_comments/game_likes 는 비권위 복원본) | apply-local-ddl.sh:4-64, schema.sql:5-18,45,128,261 | code-fact |
|
||||||
|
| JSP/MyBatis 관례 | views/ 평면배치 kebab-case, 컨트롤러는 확장자없는 뷰이름 반환. 매퍼 전부 `#{}`, `${}` 회피(주석 명시). **큰따옴표 alias 는 일반매퍼 0 hit** — snake→camel 직접 alias(`r.created_at AS createdAt`)가 표준, 큰따옴표는 집계뷰 case-folding 회피용만 | GameReviewsMapper.java:21-29,92; RecruitPostsMapper.java:34 | code-fact |
|
||||||
|
|
||||||
|
- **전 W 결합 함의(1줄)**: W2 잼 목록/상세·W2/W3 게시판류는 RecruitController 패턴(읽기=JSP뷰, 쓰기=JSON+CSRF) 선례를 따르고, 신규 DDL 은 docs/*-ddl.sql 을 권위로 두는 선례(W1-design 의 docs/rbac-ddl.sql 권위와 동일)를 따른다(해석).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 종합 판단
|
||||||
|
|
||||||
|
### 상위 패턴
|
||||||
|
1. **권한 카탈로그 vs enforcement 갭** (code-fact): RBAC 인프라(게이트/인터셉터/카탈로그/epoch)는 완비됐으나, 실제 enforcement 는 `/admin/**` ADMIN 게이트 + `CONTENT_MODERATE` canModify 2곳뿐. `POST_WRITE`·`GAME_JAM_MANAGE` 는 선언만 있고 미연결 → W2(잼관리)·W3(포스팅) 가 이 키를 enforcement 에 연결하는 작업이 신규.
|
||||||
|
2. **잼·배지 스토리지 전무** (code-fact): jams 테이블 0, 배지/평판 0 → W2 엔티티·W4 도메인은 전부 신규 DDL. 단 평점(하이브리드 6축)·좋아요·역할 등 소비 원천은 실재.
|
||||||
|
3. **권위 DDL = docs/*-ddl.sql** (code-fact): 신규 W2/W4 테이블도 docs/*-ddl.sql 권위 + schema.sql 동기 + apply-local-ddl.sh 멱등 적용 선례를 따른다.
|
||||||
|
|
||||||
|
### 충돌·갭
|
||||||
|
- **충돌 1 (해소됨)**: R-D worker 중간 출력이 game_likes 부재라 했으나 advisor 1차 직접 확인으로 실재 확정. 이 문서는 실재를 채택.
|
||||||
|
- **갭 1 (해석, W2-2)**: 전역 권한 모델 ↔ 잼 회차별 역할 스코프 미수렴 → design 결정 필요 (concern 기록).
|
||||||
|
- **갭 2 (추정, W2-5)**: game_likes 1인1표 제약은 비권위 복원본 근거뿐 → 운영 DB 제약 검증 필요 (concern 기록).
|
||||||
|
|
||||||
|
### 권위 격상 전 검증 필요 항목 (source_confidence: mixed 사유)
|
||||||
|
- game_likes UNIQUE(game_id,user_key) 부재 → **추정**. 운영 DB 제약 확인 전 W2-5 1인1표 전제로 승격 금지.
|
||||||
|
- game_likes user_key 채움값(로그인 user_id vs 익명) → **미확인** (호출부 미조사).
|
||||||
|
|
||||||
|
## 미해결 (research 로도 미해소 — 골자 단계 비해소가 사용자 제약)
|
||||||
|
- game_likes 운영 DB 실제 UNIQUE 제약 (schema.sql 비권위).
|
||||||
|
- game_likes user_key 가 무엇으로 채워지는가 (서비스/컨트롤러 호출부 미조사).
|
||||||
|
- W2-2 잼 스코프 권한을 현 모델 확장 vs 별도 테이블로 풀지 (설계 결정 — 의도적 비해소).
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
---
|
||||||
|
phase: research
|
||||||
|
agent: research-advisor
|
||||||
|
agent_version: 2
|
||||||
|
generated_at: 2026-06-23T02:29:01Z
|
||||||
|
concerns:
|
||||||
|
- "stale-skeleton-fact: W3 skeleton(2026-06-17) §코드현황5 의 '/game/** 정적 핸들러 미등록 → 서빙 미보장(QG-3)' 전제는 코드상 뒤집힘 — GameAssetController 가 /game/{gameUuid}/** 를 @Controller 핸들러로 서빙한다. 설계 진입 전 골자의 QG-3 문구 정정 필요."
|
||||||
|
concerns_checked: true
|
||||||
|
source_confidence: high
|
||||||
|
workers_spawned: 0
|
||||||
|
self_verification:
|
||||||
|
checklist_passed: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# 조사 결과 — W3-5 Unity WebGL 업로드 자동화 선행 조사
|
||||||
|
|
||||||
|
## 주제
|
||||||
|
bibimbap GameUpload(`/api/game-files/webgl-zip`) 부분구현의 선행 조사 7항목을 코드 grounding 으로 확인. 각 항목 현황(code-fact) + 정석 보강 방향 후보(해석).
|
||||||
|
|
||||||
|
> 본 조사는 라이브러리 API 조사가 아닌 **단일 코드베이스 결합점 조사**이며, 7개 항목은 사전 지정된 조사 점검표(열거형 카탈로그 아님)다. 따라서 §4.8 축-완결성 패스는 비적용. 모든 사실은 file:line 직접 확인(`확인됨`), source_confidence=high. 단 stale 골자 전제 1건 concern 기록.
|
||||||
|
|
||||||
|
## 요약 (최상단 — 핵심 3건)
|
||||||
|
|
||||||
|
1. **zip-slip 방어에 심볼릭 링크 구멍**: `extractZip` 은 `target.startsWith(targetDir)` 정규화 검증(GameUploadController.java:266-269)으로 `../` 경로탈출은 막지만, **zip 엔트리가 심볼릭 링크인 경우를 전혀 처리하지 않는다**(코드 전역 `isSymbolicLink`/`NOFOLLOW`/`toRealPath` 사용 0건). 디렉터리 생성·파일 쓰기가 기존 심링크를 따라가 targetDir 밖에 쓸 수 있다(`Files.createDirectories`/`Files.newOutputStream` 모두 기본 follow-links). → 정석 보강 후보: 엔트리별 심링크 차단 + 쓰기 전 `toRealPath` 재검증. — code-fact(부재) + 해석(보강)
|
||||||
|
|
||||||
|
2. **`/game/**` 서빙은 실재 — 골자 QG-3 전제 stale**: 골자(2026-06-17)는 "`/game/**` 핸들러 미등록 → 서빙 미보장"이라 했으나, `GameAssetController.gameAsset` 이 `@GetMapping("/game/{gameUuid}/**")` 로 직접 서빙한다(GameAssetController.java:31-69). UUID 검증·경로 boundary·Content-Type·CSP·Content-Encoding(br/gz) 까지 처리. `UploadResourceConfig` 는 `/profile/**` 만 등록(UploadResourceConfig.java:22-23)한 것이 맞으나, WebGL 은 ResourceHandler 가 아니라 전용 컨트롤러로 서빙되는 **의도된 설계**다(버그 아님). → 골자 문구 정정 필요. — code-fact
|
||||||
|
|
||||||
|
3. **업로드 권한 게이트 전무**: 세 업로드 엔드포인트(`webgl-zip`/`thumbnail`/POST root) 모두 **CSRF + 로그인(세션 userId) 체크만** 하고 권한 검사가 없다(GameUploadController.java:113-117, 173-177, 62-65). W1 RBAC 인프라(PermissionGate/RbacInterceptor)는 실재하나 `InterceptorConfig` 가 **`/admin/**` 에만** 인터셉터를 건다(InterceptorConfig.java:19) → `/api/game-files/**` 미보호. 로그인한 모든 사용자가 업로드 가능. → W1 게이트를 얹을 자리. — code-fact
|
||||||
|
|
||||||
|
## 포인트별 발견
|
||||||
|
|
||||||
|
### 포인트 1: zip-slip 방어 충분성
|
||||||
|
- 경로: `GameUploadController.java`
|
||||||
|
- 현황(code-fact):
|
||||||
|
- 엔트리별 정규화 후 prefix 검증: `Path target = targetDir.resolve(entry.getName()).normalize(); if (!target.startsWith(targetDir)) throw ...`(266-269). `../`·절대경로(`resolve` 시 절대경로면 targetDir 밖으로 normalize → startsWith 탈락)·백슬래시는 이 검증으로 차단됨. — 확인됨
|
||||||
|
- 디렉터리 엔트리: `Files.createDirectories(target)`(272). 파일: `Files.createDirectories(target.getParent())` + `copyZipEntry`(277-278). 둘 다 **심링크 follow 기본 동작** — NOFOLLOW/실경로 재검증 없음. — 확인됨(부재)
|
||||||
|
- 심볼릭 링크 처리 코드 전무: `rg isSymbolicLink|NOFOLLOW|LinkOption|toRealPath` 결과 0건(전 소스). — 확인됨
|
||||||
|
- `%2e` 등 URL 인코딩 우회: `extractZip` 은 zip 엔트리명을 디코딩하지 않고 그대로 `resolve` 하므로 `%2e` 는 리터럴 파일명이 되어 무해(zip 엔트리명은 URL 인코딩 대상 아님). 단 서빙 측 `GameAssetController.resolveAssetFile` 은 `UriUtils.decode`(GameAssetController.java:92) 후 정규화·boundary 재검증(101-103) 하므로 서빙 경로도 boundary 보호됨. — 확인됨
|
||||||
|
- NUL 바이트: 서빙 측만 `assetPath.contains("\0")` 차단(GameAssetController.java:97). 추출 측은 미체크(JVM 이 NUL 포함 경로 쓰기 시 예외 발생하나 명시 방어 아님). — 확인됨
|
||||||
|
- 구멍 요약: (a) **심볼릭 링크 엔트리 무방비**(최대 구멍), (b) zip 폭탄 부분방어 — 누적 압축해제 크기(512MB)·엔트리 수(8000)는 막으나 **개별 엔트리 압축비(zip bomb ratio) 검사 없음**(copyZipEntry 는 누적 바이트만 — 512MB 한도 내라면 통과), (c) 엔트리명 길이/중첩깊이 상한 없음. — code-fact(a,c 부재) + 해석(b)
|
||||||
|
- 정석 보강 방향 후보(해석): 엔트리별 `target.toRealPath()` 또는 부모 디렉터리 실경로 재검증, 심링크 엔트리(`entry` 의 external attributes 또는 추출 후 `Files.isSymbolicLink`) 거부, 압축비 임계 검사.
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
### 포인트 2: WebGL 빌드 포맷 검증 범위
|
||||||
|
- 경로: `GameUploadController.java:135-139, 306-316`
|
||||||
|
- 현황(code-fact):
|
||||||
|
- 검증하는 것: **index.html 존재만**. `findIndexFile` 이 추출 트리를 walk 하여 `index.html`(대소문자 무시) 중 **가장 얕은 경로** 1개를 선택(306-314). 없으면 `deleteRecursively` 후 400(136-139). — 확인됨
|
||||||
|
- 검증하지 않는 것: `Build/*.data`·`*.wasm`·`*.framework.js`·`*.loader.js` 등 Unity WebGL 필수 산출물 존재 검사 **전무**(rg 결과 해당 확장자 검증 로직 없음, contentType 매핑만 GameAssetController 에 존재). — 확인됨(부재)
|
||||||
|
- index.html 내용/구조 검증 없음 — 임의 index.html 만 있어도 통과. — 확인됨
|
||||||
|
- 정석 보강 방향 후보(해석): Unity 빌드 마커(`Build/` 디렉터리 + loader/framework/wasm/data 4종 또는 그 압축변형 `.br`/`.gz`) 존재 검증, index.html 의 loader 참조 정합 체크.
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
### 포인트 3: 업로드 크기/타입
|
||||||
|
- 경로: `GameUploadController.java:40-42, 121-123, 240-252, 254-304`; `application.properties:15-17`
|
||||||
|
- 현황(code-fact):
|
||||||
|
- 압축해제 누적 크기 상한: `WEBGL_EXTRACTED_MAX_BYTES = 512MB`(40), `copyZipEntry` 가 누적 초과 시 throw(297-299). — 확인됨
|
||||||
|
- 엔트리 수 상한: `WEBGL_MAX_ENTRIES = 8_000`(41), 초과 시 throw(262-263). — 확인됨 (골자 8000 언급과 일치)
|
||||||
|
- **zip 원본(업로드) 크기 상한 — 코드 레벨 명시 없음**. 전역 `spring.servlet.multipart.max-file-size=1GB`·`max-request-size=1GB`(application.properties:15-16) + `server.tomcat.max-swallow-size=-1`(무제한 swallow) 가 유일 한도. webgl-zip 핸들러는 `file.getSize()` 상한 검사 없음(썸네일만 10MB 검사 GameUploadController.java:181). — 확인됨
|
||||||
|
- Content-Type/확장자 이중 체크: `isZipFile` 이 MIME(application/zip·x-zip-compressed·multipart/x-zip) **또는** `.zip` 확장자 — **OR 조건**(둘 중 하나만 충족해도 통과, 240-252). 이중(AND) 아님. — 확인됨
|
||||||
|
- **매직바이트(zip 시그니처 PK\x03\x04) 검증 없음** — 선언 MIME/확장자만 신뢰. — 확인됨(부재)
|
||||||
|
- 정석 보강 방향 후보(해석): webgl-zip 전용 원본 크기 상한(예: 512MB~1GB 명시), zip 매직바이트 검증, MIME+확장자 AND 강화 여부 검토.
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
### 포인트 4: 저장 경로 boundary
|
||||||
|
- 경로: `GameUploadController.java:44-45, 218-220, 126-130`; `dev/db.properties:6`; `application.properties:23`
|
||||||
|
- 현황(code-fact):
|
||||||
|
- 설정 주입: `@Value("${app.upload.game-storage-path:src/main/resources/static}")`(44), default `src/main/resources/static`. dev 프로파일은 `app.upload.game-storage-path=src/main/resources/static/game`(dev/db.properties:6), `spring.config.import=optional:.../db.properties`(application.properties:23) 로 로드. — 확인됨
|
||||||
|
- `gameRoot()` = `Paths.get(uploadStoragePath).toAbsolutePath().normalize().resolve("game").normalize()`(218-219). — 확인됨
|
||||||
|
- **주의(해석)**: dev 설정값이 이미 `.../static/game` 인데 `gameRoot()` 가 추가로 `.resolve("game")` → 실제 루트가 `.../static/game/game` 로 **이중 중첩** 가능. default 값(`.../static`)에는 맞으나 dev override 와 어긋남. 운영 배포값 미확인 — 의도/실수 판별 필요. — code-fact(경로조합) + 해석(중첩 의심)
|
||||||
|
- boundary 검증: webgl-zip 은 `targetDir = root.resolve(gameUuid).normalize(); if(!targetDir.startsWith(root))`(127-128) — 단 gameUuid 는 `UUID.randomUUID()`(126) 라 외부주입 아님 → 이 경로의 탈출 위험 없음. thumbnail/POST-root 는 외부 path/gameUuid 받되 normalize+startsWith 검증(199-202, 396-411). — 확인됨
|
||||||
|
- 외부 주입 경로 탈출: `uploadStoragePath` 자체는 운영자 설정값(외부 사용자 주입 아님). 사용자 입력 path 는 POST-root 의 `path` 파라미터뿐이며 `resolveTargetFile` 가 normalize+startsWith(root) 검증(406-409). — 확인됨
|
||||||
|
- 정석 보강 방향 후보(해석): dev 의 이중 `game` 중첩 정리(설정값을 `.../static` 으로 통일하거나 `gameRoot()` 의 `.resolve("game")` 제거), 운영 배포 경로 문서화.
|
||||||
|
- 신뢰도: 확인됨 (단 경로 중첩의 의도 여부는 미확인 — 운영 배포값 부재)
|
||||||
|
|
||||||
|
### 포인트 5: /game/** 정적 핸들러 등록 (QG-3)
|
||||||
|
- 경로: `GameAssetController.java:31-69`; `UploadResourceConfig.java:18-24`
|
||||||
|
- 현황(code-fact):
|
||||||
|
- `UploadResourceConfig.addResourceHandlers` 는 **`/profile/**` 만** 등록(22-23), `/game/**` ResourceHandler **미등록**(골자 주장 일치). — 확인됨
|
||||||
|
- **그러나** `/game/{gameUuid}/**` 는 전용 컨트롤러 `GameAssetController.gameAsset`(`@GetMapping`, 31)이 직접 서빙: UUID 정규화(37-41)·디렉터리 boundary(101-103)·Content-Type 매핑(130-172, wasm/js/data/json/html/css/이미지)·Content-Encoding(br/gz, 119-128)·CSP+X-Content-Type-Options+Cache-Control(57-66) 처리. — 확인됨
|
||||||
|
- → WebGL 서빙은 **실제 동작하며 의도된 설계**. 골자의 "서빙 미보장(QG-3, 의도/버그 미확인)" 전제는 **stale** — 버그 아님. — code-fact (concern 으로 격상)
|
||||||
|
- 정석 보강 방향 후보(해석): 전용 컨트롤러 방식 유지 타당(보안헤더·인코딩 협상 필요해 ResourceHandler 보다 적합). 단 ResourceHandler 미등록이 정상임을 골자에 명시.
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
### 포인트 6: 업로드 권한 게이트
|
||||||
|
- 경로: `GameUploadController.java:59-65, 106-117, 170-177`; `InterceptorConfig.java:19`; `security/PermissionKeys.java`
|
||||||
|
- 현황(code-fact):
|
||||||
|
- webgl-zip: CSRF(106-108) + 로그인(113-117)만. 권한 검사 없음. — 확인됨
|
||||||
|
- thumbnail: CSRF(170-172) + 로그인(173-177)만. — 확인됨
|
||||||
|
- POST-root(`uploadGameFiles`): CSRF(59-61) + 로그인(62-65)만. — 확인됨
|
||||||
|
- W1 RBAC 실재: `PermissionGate`·`RbacInterceptor`(클래스명 obfuscated 흔적 있으나 InterceptorConfig 는 정상 `RbacInterceptor` import)·`PermissionKeys`(GAME_JAM_MANAGE·POST_WRITE 정의). — 확인됨
|
||||||
|
- `InterceptorConfig.addInterceptors` 는 `registry.addInterceptor(rbacInterceptor).addPathPatterns("/admin/**")` — **`/admin/**` 전용**(19). `/api/game-files/**` 미보호. — 확인됨
|
||||||
|
- 업로드 전용 권한키 부재: PermissionKeys 에 GAME_UPLOAD 류 키 없음(GAME_JAM_MANAGE·POST_WRITE 만). — 확인됨(부재)
|
||||||
|
- 정석 보강 방향 후보(해석): 업로드 권한키 신설 + RbacInterceptor 경로 확장 또는 컨트롤러 내 PermissionGate 명시 호출. W1 게이트를 얹을 자리는 명확.
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
### 포인트 7: UUID 재사용/덮어쓰기 정책
|
||||||
|
- 경로: `GameUploadController.java:126`; `GameController.java:176-228, 240-269, 348-352`; `game-register.jsp:649-653`
|
||||||
|
- 현황(code-fact):
|
||||||
|
- **모든 webgl-zip 업로드는 무조건 `UUID.randomUUID()` 신규 생성**(126) — 재업로드/편집 여부와 무관. 기존 UUID 재사용·덮어쓰기 로직 없음. — 확인됨
|
||||||
|
- 편집(edit) 흐름: `game-register.jsp` 는 새 zip 업로드 시 응답의 새 gameUuid/webglPath 로 hidden 필드 교체(649-653) → `updateGame`(GameController.java:176-228) 이 새 webgl_path 로 DB UPDATE. **기존 `/game/{old-uuid}/` 디렉터리는 삭제되지 않음 → 고아 파일.** — 확인됨
|
||||||
|
- 게임 삭제: `deleteGame` 은 `softDeleteGame`(soft delete) + 연관 soft-delete/like 삭제만(GameController.java:259-262). **파일시스템 정리 없음** → `/game/{uuid}/` 영구 잔존. — 확인됨
|
||||||
|
- `deleteRecursively`(GameUploadController.java:385) 는 **업로드 실패 롤백 시에만** 호출(137,155,158). 정상 교체/삭제 시 미호출. — 확인됨
|
||||||
|
- 멱등성: 동일 zip 재업로드 시 매번 다른 UUID·다른 디렉터리 → 비멱등. — 확인됨
|
||||||
|
- 정석 보강 방향 후보(해석): 편집 시 기존 UUID 유지+덮어쓰기 또는 신규 후 구버전 GC, 게임 삭제 시 자산 디렉터리 정리(soft-delete 와의 정합 — 즉시삭제 vs 유예GC), 고아 파일 배치 청소.
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
## 종합 판단
|
||||||
|
- **상위 패턴**: 업로드 파이프라인은 "정규화 prefix 검증 + 사이즈/엔트리 상한 + 전용 서빙 컨트롤러"로 1차 골격은 갖췄으나, (1) 심링크 방어 (2) Unity 빌드 포맷 검증 (3) 권한 게이트 (4) 자산 생명주기(고아 파일) 4개 축이 비어 있다.
|
||||||
|
- **충돌/정정**: 골자(2026-06-17) §코드현황5 의 "`/game/**` 미등록 → 서빙 미보장(QG-3)" 는 GameAssetController 실재로 뒤집힘 — 설계 진입 전 골자 문구 정정 필요(concern 기록). 골자의 "권한 체크 없음"·"zip-slip·크기상한·UUID 배치 됨" 은 코드와 일치.
|
||||||
|
- **갭(설계 단계로 이월)**: 운영 배포 시 `game-storage-path` 경로 중첩(static/game/game) 의도 여부, soft-delete 와 자산 즉시삭제의 정합 정책.
|
||||||
|
|
||||||
|
## 미해결
|
||||||
|
- 운영(prod) 프로파일의 `app.upload.game-storage-path` 실제값 — repo 에 dev 프로파일만 존재(dev/db.properties). 경로 이중 중첩이 운영에서도 발생하는지 미확인.
|
||||||
|
- 클래스명 obfuscation 흔적(`security.n` 등 rg 매칭) — RbacInterceptor.java 의 `public class n` 표기는 빌드/난독화 산물일 가능성. InterceptorConfig 는 정상 클래스명 사용하므로 enforcement 분석에는 영향 없음(미해결로 남김, W3-5 범위 밖).
|
||||||
|
|
@ -23,6 +23,8 @@ owner: art
|
||||||
|
|
||||||
## 코드 현황 (착수 전 확인된 결합점)
|
## 코드 현황 (착수 전 확인된 결합점)
|
||||||
|
|
||||||
|
> ⚠️ **일부 stale (2026-06-23 정정)** — 아래 #2(리뷰 테이블)·#3(Interceptor)은 W1 착지(커밋 941f9fb) + W3-2 구현 완료로 뒤집혔다. 정정·grounding: [2026-06-23-w2-w4-feature-skeletons.md](./2026-06-23-w2-w4-feature-skeletons.md) §"W3 skeletons 코드 현황 절 stale 정정". 요지: 리뷰 인프라(game_reviews+6축+stats VIEW)·권한 게이트(PermissionGate/RbacInterceptor/PermissionKeys) 모두 **실재**. 단 POST_WRITE·GAME_JAM_MANAGE 키는 enforcement 미연결(소비처 0). #1(태그)·#4·#5 는 유효.
|
||||||
|
|
||||||
골자의 "결합/의존" 정확도를 위해 현 코드베이스를 대조했다. file 근거:
|
골자의 "결합/의존" 정확도를 위해 현 코드베이스를 대조했다. file 근거:
|
||||||
|
|
||||||
1. **`games` 테이블** (`GamesMapper.java`) — id, user_id, name, creator_note, git_url, webgl_path, thumbnail_url, like_count, is_visible, sort_order, created_at, updated_at. **태그 컬럼 없음.** 검색은 name/creator_note ILIKE 방식만.
|
1. **`games` 테이블** (`GamesMapper.java`) — id, user_id, name, creator_note, git_url, webgl_path, thumbnail_url, like_count, is_visible, sort_order, created_at, updated_at. **태그 컬럼 없음.** 검색은 name/creator_note ILIKE 방식만.
|
||||||
|
|
@ -158,7 +160,7 @@ owner: art
|
||||||
|
|
||||||
- **QG-1**: W1 인터셉터 완료 시점과 권한 게이트 착수. **W3-3 은 W1 완료 후 착수(임시 role 직접 체크 안 함)로 확정.** W3-5 업로드 권한은 미결 유지(조사 단계 확정). *잔여*: W1 지연 시 W3-5 임시 체크 허용 여부.
|
- **QG-1**: W1 인터셉터 완료 시점과 권한 게이트 착수. **W3-3 은 W1 완료 후 착수(임시 role 직접 체크 안 함)로 확정.** W3-5 업로드 권한은 미결 유지(조사 단계 확정). *잔여*: W1 지연 시 W3-5 임시 체크 허용 여부.
|
||||||
- **QG-2**: W3-2 기존 닉네임 자유입력 레코드 처리 — 마이그레이션/유지/레거시 표시. **미결 유지 확정**(골자 단계 해소 보류).
|
- **QG-2**: W3-2 기존 닉네임 자유입력 레코드 처리 — 마이그레이션/유지/레거시 표시. **미결 유지 확정**(골자 단계 해소 보류).
|
||||||
- **QG-3**: W3-5 `/game/**` 정적 핸들러 미등록이 의도인지 버그인지 — 현 WebGL 서빙 실제 동작 확인 필요. **코드 확인을 조사 단계로 미룸 확정.**
|
- **QG-3**: ~~W3-5 `/game/**` 정적 핸들러 미등록이 의도인지 버그인지~~ → **해소(2026-06-23 조사)**: `/game/**` 서빙은 `ResourceHandler` 가 아니라 전용 컨트롤러 `GameAssetController`(`@GetMapping("/game/{gameUuid}/**")`, boundary·Content-Type·CSP·br/gz 인코딩 처리)가 담당 — **정상 동작(의도된 설계, 버그 아님)**. `UploadResourceConfig` 가 `/profile/**` 만 등록한 것은 맞으나 WebGL 은 컨트롤러 경로라 무관. 근거: `.atp/work-session/20260623-104307/research/W3-5-upload-research.md`. (W3-5 실 결함은 zip-slip 심링크 구멍·업로드 권한게이트 전무로 재확인됨.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
---
|
||||||
|
kind: work-log
|
||||||
|
title: W2(게임잼) + W4(유저 배지/평판) — 기능별 골자 카탈로그
|
||||||
|
description: W2(게임잼 워크스트림) 6개 서브기능(W2-1 잼 엔티티/라이프사이클 / W2-2 심사위원 역할권한 / W2-3 잼 평가 통합설계·스키마 동결 / W2-4 심사위원 평가 / W2-5 인기투표 / W2-6 시상 집계) + W4(유저 배지/평판)의 골자. W3 skeletons 포맷과 동일 — 기능당 목적·핵심동작·결합/의존(code-fact vs 해석 태깅)·미결질문(비해소)·후속진입점 + 결합표 + 착수순서 + 전체수준 미결(QG). 윤곽 카탈로그 깊이 — 미결질문 해소 안 하고 남김. 당장 구현 아님, 풀설계 착수 전 굳혀두는 기획 메모.
|
||||||
|
status: draft
|
||||||
|
source_roadmap: 2026-06-17-jam-platform-roadmap.md
|
||||||
|
source_grounding: ../../.atp/work-session/20260623-104307/research/W2-W4-grounding.md
|
||||||
|
source_session: 20260623-104307
|
||||||
|
created_at: 2026-06-23
|
||||||
|
owner: art
|
||||||
|
---
|
||||||
|
|
||||||
|
# W2(게임잼) + W4(유저 배지/평판) — 기능별 골자 카탈로그
|
||||||
|
|
||||||
|
상위 로드맵: [2026-06-17-jam-platform-roadmap.md](./2026-06-17-jam-platform-roadmap.md) §W2·§W4.
|
||||||
|
자매 골자(W3): [2026-06-17-w3-feature-skeletons.md](./2026-06-17-w3-feature-skeletons.md).
|
||||||
|
|
||||||
|
**깊이**: 윤곽 카탈로그. 기능당 목적·핵심동작·결합/의존·미결질문·후속진입점만 굳혀둔다. DDL/API 계약/시퀀스/AC매핑은 기능별 착수(풀설계) 단계에서. **미결질문은 해소하지 않고 남긴다** — 골자의 목적은 "무엇을/왜 + 무엇이 미결인지"를 굳히는 것. 당장 구현 아님.
|
||||||
|
|
||||||
|
> 결합/의존의 각 항목은 `code-fact`(파일:라인 근거) / `해석`(설계 판단) 으로 태깅한다. 근거는 grounding 문서: [research/W2-W4-grounding.md](../../.atp/work-session/20260623-104307/research/W2-W4-grounding.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ★ W3 skeletons "코드 현황" 절 stale 정정 (2건)
|
||||||
|
|
||||||
|
W3 skeletons(2026-06-17 작성)의 "코드 현황" 절은 작성 이후 **W1 착지(커밋 941f9fb) + W3-2 구현 완료**로 2건이 뒤집혔다. 이를 명시하지 않으면 후속 설계가 옛 전제로 진행된다.
|
||||||
|
|
||||||
|
1. **skeleton 코드현황 #3 "권한 인프라 — Interceptor 클래스 없음 … W3-3 포스터 게이트 얹힐 자리 미비"** → **정정: 실재(code-fact)**.
|
||||||
|
- `security/PermissionGate`(has/canModerate/isAdmin/require) · `RbacInterceptor`(401 vs 403 분기) · `PermissionKeys`(GAME_JAM_MANAGE/POST_WRITE/CONTENT_MODERATE) · `Roles`(ADMIN/SUBADMIN/USER) · `config/InterceptorConfig` · `config/PermissionCatalogVerifier` · `UserPermissionsMapper` 전부 착지. users 에 `role`·`permissions_epoch` 컬럼 실재, 세션 epoch 전파(`refreshIfStale`) 동작.
|
||||||
|
- **단 enforcement 갭(code-fact)**: 인터셉터는 `/admin/**` + `isAdmin` 만 검사. 권한 키 중 `CONTENT_MODERATE` 만 소비(canModerate, GameCommentController:203 / GameReviewController:422). **`POST_WRITE`·`GAME_JAM_MANAGE` 는 enum 선언만 있고 소비처 0건**.
|
||||||
|
- → W3-3 POST_WRITE·W2-1 GAME_JAM_MANAGE 는 "자리 미비"가 아니라 **"키 실재 + enforcement 연결이 신규 작업"** 으로 정정. (참고: 기존 `RecruitController` POST 도 CSRF+세션userId만, POST_WRITE 미적용 — 현재 모든 로그인 유저 개방. W3-3 은 여기에 게이트를 *추가*하는 것.)
|
||||||
|
|
||||||
|
2. **skeleton 코드현황 #2 "review 테이블·tag 테이블 모두 없음 → W3-1·W3-2 구조 전부 신규"** → **리뷰 부분 정정: 실재(code-fact)**.
|
||||||
|
- `game_reviews`(rating smallint CHECK 1~5 + body + nickname 스냅샷 + `is_rating_manual`) + `game_review_axes`(6축: immersion/creativity/controls/completeness/sound/visual, 리뷰당 6행) + `game_review_stats`(**테이블 아닌 VIEW**, 9컬럼 집계) + 4매퍼(GameReviewsMapper/GameReviewStatsMapper/GameReviewAxesMapper/GameCommentsMapper) 전부 착지. (tag 부분은 여전히 미존재 — W3-1 태그 구조는 신규 유효.)
|
||||||
|
- → W3-1 리뷰수/점수 정렬 · W2-6 유저평점 트랙 · W4 리뷰어 배지는 이 **하이브리드(단일 overall + 6축) + VIEW 집계**를 소비. **단일 평균 가정 금지.** axes 0행 리뷰는 6축평균 NULL(백필 미적용).
|
||||||
|
|
||||||
|
> 안 뒤집힌 항목: 코드현황 #1(games 태그 컬럼 없음 — W3-1 태그 신규 유효), #4(index.jsp 잼 노출 없음 — W3-4 유효)는 그대로. **#5 부분 정정(2026-06-23 W3-5 조사)**: WebGL 업로드 부분구현은 유효하나 "`/game/**` 핸들러 미등록 → 서빙 미보장"은 **틀림** — `GameAssetController`(@GetMapping `/game/{uuid}/**`)가 정상 서빙(의도된 설계). W3-5 실 결함은 zip-slip 심링크 구멍·업로드 권한게이트 전무. (W3 skeletons QG-3 해소.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 코드 현황 (W2/W4 착수 전 확인된 결합점)
|
||||||
|
|
||||||
|
골자의 "결합/의존" 정확도를 위해 현 코드베이스를 대조했다(grounding 문서). W2/W4 가 얹히는 핵심 사실:
|
||||||
|
|
||||||
|
1. **권한 인프라(W1 산출)** — `user_permissions(user_id, permission_key)` **전역 권한 모델**. `scope/resource_id/jam_id` 컬럼 부재(schema.sql:326-347). `PermissionGate.has(session, key)` 리소스 인자 없음(PermissionGate.java:22). → **잼 회차별 역할은 그대로 안 얹힘(W2-2 스코프 갭)**. `GAME_JAM_MANAGE`·`POST_WRITE` 키는 선언만·소비처 0건.
|
||||||
|
2. **리뷰/평점(W3-2 산출)** — 하이브리드: `game_reviews.rating`(1~5) + `game_review_axes`(6축, 리뷰당 6행). `game_review_stats` 는 **VIEW**(avg_rating/review_count/6축평균 9컬럼). 게임당 유저 1리뷰 활성유니크(game_id,user_id). axes 0행 → 6축평균 NULL. (W2-3/W2-6/W4 가 소비.)
|
||||||
|
3. **games 엔티티** — 13컬럼(id/user_id FK/name/creator_note/git_url/webgl_path/thumbnail_url/like_count/is_visible/sort_order/created_at/updated_at/is_delete). **`jam_id`·방문수·`team_id` 모두 부재**(schema.sql:88-100). user_id 단일 FK = 1게임 1유저. (W2-1 이 재사용+확장.)
|
||||||
|
4. **jams 테이블 전무** — `jam_id`·`CREATE TABLE jam` 0 hit. → W2 잼 엔티티 전부 신규.
|
||||||
|
5. **game_likes** — `GameLikesMapper`(get/add/update, DELETE·토글·조회 없음) + `game_likes`(id/game_id FK/`user_key varchar(200)`/created_at, PK(id)만). **1인1표 UNIQUE(game_id,user_key) 부재 — 단 schema.sql 비권위 복원본이라 운영 DB 제약 미확인(추정)**. (W2-5 가 별개로 신설.)
|
||||||
|
6. **배지/평판 구조 전무** — badge/reputation/trust_level 0 hit. → W4 전부 신규. 단 평판 신호 원천(리뷰 6축·좋아요·role·업로드)은 전부 실재.
|
||||||
|
7. **관례** — 게시판=`RecruitController`(읽기=JSP 뷰이름 반환, 쓰기=ResponseEntity JSON + `CsrfTokens.isValid` + 세션 userId + 화이트리스트), RecruitPostsMapper **페이징 없음**(전건). 신규 DDL 권위 = **`docs/*-ddl.sql`**(apply-local-ddl.sh 멱등 적용), schema.sql 은 동기 사본. 매퍼 전부 `#{}`. 표시명=`users.display_name` 단일 출처. 세션 attr 에 `permissions(Set)`·`permsEpoch(long)` 포함.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기능별 골자
|
||||||
|
|
||||||
|
### W2-1 — 게임잼 엔티티 + 라이프사이클 ★크리티컬 패스 기점
|
||||||
|
|
||||||
|
- **목적**: 게임잼 운영·평가·시상의 토대 엔티티. 회차 독립(다중 인스턴스). 출품작 = 기존 `games` 재사용 + 잼 연결.
|
||||||
|
- **핵심 동작**: `jams` 테이블(상태전이 모집→개발→평가→종료, 개발기간/평가기간 필드) + 관리자 잼 CRUD + 잼 목록/상세 페이지. 출품작 이중 노출(잼 전용 뷰 + 일반 게임 허브). 운영 표시 필드(Discord 링크/상금/후원사 — 표시만, 실지급 수동).
|
||||||
|
- **결합/의존**:
|
||||||
|
- C1: `jams` 전무 → 전부 신규 DDL. `docs/*-ddl.sql` 권위 선례(W1 의 docs/rbac-ddl.sql) 따름. — 코드사실(부재) + 해석(배치)
|
||||||
|
- C2: games↔jam 연결 신규 — games 13컬럼에 `jam_id` 부재(schema.sql:88-100). 연결 방식(games.jam_id nullable 컬럼 vs 조인테이블 `jam_entries`)은 미결. — 코드사실 + 해석
|
||||||
|
- C3: 관리자 잼 CRUD = `GAME_JAM_MANAGE` 게이트 소비. **키는 enum 선언만·소비처 0건(enforcement 갭, PermissionKeys.java:4-6)** → 이 키를 enforcement 에 연결하는 게 신규. 임시 role 직접 체크 금지(W1 게이트 위에 얹음). — 코드사실
|
||||||
|
- C4: 잼 목록/상세 = `RecruitController` 패턴(읽기 JSP뷰, 쓰기 JSON+CSRF) 선례 따름(RecruitController.java:36,65,145). 단 RecruitPostsMapper 페이징 없음 → 잼/출품작 목록 페이징은 신규. — 코드사실 + 해석
|
||||||
|
- C5: `games.team_id` 부재 = 1게임 1유저(schema.sql:89). 로드맵 "개인·팀 모두" 와 갭 — 팀 출품 지원 시 신규. — 코드사실
|
||||||
|
- **미결질문** (윤곽 — 비해소):
|
||||||
|
- Q1 games↔jam 연결: `games.jam_id` nullable 컬럼 추가 vs 조인테이블. 1게임 다중잼 출품 허용 여부.
|
||||||
|
- Q2 팀 출품 범위: 개인만 우선 vs 팀 동시. 로드맵 "개인·팀 모두" 의 1차 구현 범위.
|
||||||
|
- Q3 상태전이: 일정 기반 자동 전이 vs 관리자 수동. 기간 필드와 평가기간 게이트(W2-3·W2-5) 관계.
|
||||||
|
- Q4 회차 동시성: 동시 진행 잼 복수 허용? (메인 허브 W3-4 "진행중 잼" 노출과 연동.)
|
||||||
|
- Q5 출품 자격: 잼 기간 중 신규 출품만 vs 기존 게임 사후 연결 허용.
|
||||||
|
- **후속 진입점**: design-advisor
|
||||||
|
|
||||||
|
### W2-2 — 심사위원 역할 권한 ★보안(권한)
|
||||||
|
|
||||||
|
- **목적**: W1 RBAC 위에 얹히는 잼 전용 역할. 심사위원만 심사 점수 입력 가능(W2-4 게이트).
|
||||||
|
- **핵심 동작**: 잼 회차별 심사위원 지정(주체=잼 관리자), 심사위원 점수입력 게이트.
|
||||||
|
- **결합/의존**:
|
||||||
|
- C1: **★스코프 갭** — 현 권한모델 `user_permissions=(user_id, permission_key)` 전역, scope/jam_id 부재(schema.sql:326-347). `PermissionGate.has(session, key)` 리소스 인자 없음(PermissionGate.java:22). → "잼 회차별 역할"이 전역 모델에 그대로 안 얹힘. 스코프 확장 vs 별도 잼-역할 테이블 결정 선행. — 코드사실(전역모델) + 해석(스코프 갭)
|
||||||
|
- C2: W2-1 잼 엔티티 선행(심사위원은 잼에 종속). — 해석
|
||||||
|
- C3: 심사위원 지정 = `GAME_JAM_MANAGE`(잼 관리자) 게이트 위. — 해석
|
||||||
|
- C4: enforcement 패턴은 `CONTENT_MODERATE`(canModerate) 선례 참고 가능하나 그건 전역 판정(GameCommentController:203) — 잼 스코프 판정은 신규 경로. — 코드사실 + 해석
|
||||||
|
- **미결질문** (윤곽 — 비해소):
|
||||||
|
- Q1 **★스코프 모델**: (a) user_permissions 에 scope/jam_id 컬럼 추가 + 게이트 시그니처 확장 (b) 별도 `jam_judges(jam_id, user_id)` 테이블 (c) 하이브리드. → 전체수준 QG 승격(아래 QG-W2-A).
|
||||||
|
- Q2 심사위원 수/자격: 잼당 인원 제한? 일반유저/SUBADMIN/외부 누구나 지정 가능?
|
||||||
|
- Q3 자기출품 충돌: 심사위원이 같은 잼 출품 가능? 충돌 회피 규칙.
|
||||||
|
- Q4 역할 만료: 잼 종료 후 자동 회수 vs 잔존.
|
||||||
|
- **후속 진입점**: design-advisor (W2-1 선행 + 스코프 결정)
|
||||||
|
|
||||||
|
### W2-3 — 잼 평가 통합설계 (스키마 동결) ⚠️결합 클러스터
|
||||||
|
|
||||||
|
> 범위 = **잼 평가만**(심사/투표/시상 스키마). 댓글/리뷰 스키마는 W3-2(이미 구현) — 동결 묶음 아님.
|
||||||
|
|
||||||
|
- **목적**: 심사 점수(W2-4)·잼 투표(W2-5)·시상 집계(W2-6) 스키마를 확정·동결. "W3 리뷰 평점 → 시상 유저평점 트랙" 단방향 집계 계약 + 평가기간 게이트 계약.
|
||||||
|
- **핵심 동작**: 하류 3기능이 의존할 스키마·계약을 먼저 굳혀 재작업 차단(forward phase-gate §2.7 — 결합 클러스터).
|
||||||
|
- **결합/의존**:
|
||||||
|
- C1: W3-2 리뷰 집계가 **하이브리드**(overall rating 1~5 + 6축) + `game_review_stats` **VIEW**(9컬럼). → 시상 유저평점 트랙이 단일평균인지 6축 중 무엇인지 동결 계약이 정의해야 함. **단일평균 가정 금지**. — 코드사실(하이브리드/VIEW) + 해석
|
||||||
|
- C2: axes 0행 리뷰 = 6축평균 NULL, 백필 미적용 → 시상 집계 NULL 처리 계약 필요. — 코드사실
|
||||||
|
- C3: W2-4/W2-5/W2-6 전부 이 동결에 의존 → **동결 전 하류 착수 금지**. — 해석(phase-gate)
|
||||||
|
- C4: 평가기간 게이트 = W2-1 잼 상태(평가기간 필드) 의존. — 해석
|
||||||
|
- C5: 단방향 계약 — 시상이 `game_review_stats` VIEW 를 읽기만, 리뷰 도메인은 잼 무관(game_reviews FK 에 jam 없음). W3-2 골자 ③과 정합. — 코드사실
|
||||||
|
- **미결질문** (윤곽 — 비해소):
|
||||||
|
- Q1 **유저평점 트랙 소스**: avg_rating(overall) vs 6축 평균 vs 특정 축. 동결 계약의 핵심. → QG-W2-B.
|
||||||
|
- Q2 평가기간 한정: 시상 대상 리뷰가 평가기간 작성분만 vs 전체. (리뷰는 잼 무관 상시 작성이므로 기간 필터 방식 결정.)
|
||||||
|
- Q3 동결 범위: 심사점수·투표·시상 3스키마 + 단방향계약 + 기간게이트 중 이번 동결 경계.
|
||||||
|
- Q4 NULL/미달 처리: 리뷰 0개·axes 0행 출품작의 유저평점 트랙 산정.
|
||||||
|
- **후속 진입점**: design-advisor (W2-1 후, W2-4/5/6 선행 게이트)
|
||||||
|
|
||||||
|
### W2-4 — 심사위원 평가
|
||||||
|
|
||||||
|
- **목적**: 권한자(심사위원) 점수 입력 → 심사 트랙 집계.
|
||||||
|
- **핵심 동작**: 심사위원이 출품작 점수 입력, 심사위원 대상 집계.
|
||||||
|
- **결합/의존**:
|
||||||
|
- C1: W2-3 동결 완료 + W2-2 심사위원 권한 의존(둘 다 선행). — 해석(phase-gate)
|
||||||
|
- C2: 점수입력 게이트 = W2-2 스코프 역할(전역 아닌 잼별) → 스코프 결정(QG-W2-A) 종속. — 해석
|
||||||
|
- C3: 집계는 `game_review_stats` VIEW 패턴 재사용 vs 잼 심사용 별도 집계 신규. — 해석
|
||||||
|
- **미결질문** (윤곽 — 비해소):
|
||||||
|
- Q1 점수 척도: 6축(리뷰 axes 재사용) vs 잼 전용 축 vs 단일. 리뷰 6축과 통합/분리.
|
||||||
|
- Q2 집계 방식: 심사위원 평균 vs 가중. 동률 처리.
|
||||||
|
- Q3 수정/재입력: 평가기간 내 수정 허용? 이력.
|
||||||
|
- **후속 진입점**: design-advisor (W2-3 동결 후)
|
||||||
|
|
||||||
|
### W2-5 — 인기투표
|
||||||
|
|
||||||
|
- **목적**: 잼 전용 1인 1표, 평가기간 한정, `game_likes` 와 별개.
|
||||||
|
- **핵심 동작**: 잼 출품작 투표(평가기간만), 1인 1표.
|
||||||
|
- **결합/의존**:
|
||||||
|
- C1: `game_likes` 와 별개(로드맵 명시). game_likes 자체가 1인1표 미보장(UNIQUE 부재 추정)·`user_key varchar`·토글 메서드 부재(GameLikesMapper.java:13-43) → **재활용으로 1인1표 자동 충족 안 됨**. 잼 투표 테이블 신규. — 코드사실(매퍼) + 추정(UNIQUE) + 해석
|
||||||
|
- C2: ⚠️**추정 검증 필요** — game_likes 운영 DB UNIQUE 제약은 schema.sql 비권위 근거뿐(grounding concern). 잼 투표 1인1표는 신규 테이블에 UNIQUE 명시로 보장(설계 시). — 추정
|
||||||
|
- C3: W2-3 동결 의존 + 평가기간 게이트(W2-1 상태). — 해석
|
||||||
|
- C4: 투표 식별자 — 로그인 user_id 기반 1인1표(game_likes user_key varchar 모델과 다를 수 있음). — 해석
|
||||||
|
- **미결질문** (윤곽 — 비해소):
|
||||||
|
- Q1 투표 단위: 출품작당 1표(여러 작품 가능) vs 잼 전체 1표(최애 1개).
|
||||||
|
- Q2 투표 자격: 로그인 유저만 vs 익명. 1인1표 식별자(user_id 가정).
|
||||||
|
- Q3 결과 노출: 실시간 vs 평가기간 종료 후.
|
||||||
|
- Q4 변경/취소: 투표 변경 허용?
|
||||||
|
- **후속 진입점**: design-advisor (W2-3 동결 후)
|
||||||
|
|
||||||
|
### W2-6 — 시상 집계 / 결과 ★크리티컬 패스 종점
|
||||||
|
|
||||||
|
- **목적**: 3트랙(심사 / 유저평점 / 인기) 산정 → 수상 표시.
|
||||||
|
- **핵심 동작**: 심사(W2-4)·유저평점(W3 리뷰 집계)·인기(W2-5) 3트랙 집계, 수상 표시.
|
||||||
|
- **결합/의존**:
|
||||||
|
- C1: 3트랙 = W2-4(심사) + `game_review_stats` VIEW(유저평점) + W2-5(인기) — 전부 의존. — 해석 + 코드사실(VIEW)
|
||||||
|
- C2: 유저평점 트랙이 하이브리드 6축+overall 소비(W2-3 동결 Q1 종속). **단일평균 가정 금지**. — 코드사실(6축) + 해석
|
||||||
|
- C3: NULL 처리 — 리뷰 0/axes 0행 출품작(백필 미적용). — 코드사실
|
||||||
|
- C4: W2-3 동결 계약이 트랙 가중/합산 규칙 정의 선행. — 해석(phase-gate)
|
||||||
|
- **미결질문** (윤곽 — 비해소):
|
||||||
|
- Q1 트랙 가중: 3트랙 가중치 합산 vs 트랙별 개별 수상.
|
||||||
|
- Q2 수상 카테고리: 대상/최우수/인기상 등 구조.
|
||||||
|
- Q3 동점 처리.
|
||||||
|
- Q4 결과 확정 시점·표시 위치(잼 상세 vs 별도 시상 페이지).
|
||||||
|
- **후속 진입점**: design-advisor (W2-4/5 + W2-3 동결 후)
|
||||||
|
|
||||||
|
### W4 — 유저 배지 / 평판
|
||||||
|
|
||||||
|
- **목적**: 커뮤니티 기여 인정(권한 아님 — 평판/배지). 게임잼·RBAC 와 별개 도메인.
|
||||||
|
- **핵심 동작**: 리뷰어 배지(리뷰 활동/품질 기반) + 기술자 배지(개발정보 공유·적극 업로더). 배지 부여·표시.
|
||||||
|
- **결합/의존**:
|
||||||
|
- C1: 배지/평판 스토리지 전무(0 hit) → 전부 신규 DDL. — 코드사실(부재)
|
||||||
|
- C2: 평판 신호 원천 전부 실재 — 리뷰(6축+overall, game_reviews/stats VIEW) · 좋아요(game_likes) · 역할(users.role) · 업로드(games). 신규 배지 테이블이 이들을 집계 소비. — 코드사실 + 해석
|
||||||
|
- C3: 리뷰어 배지 = game_reviews/game_review_stats 활동·품질 소비. "품질" 정의 미결. — 코드사실 + 해석
|
||||||
|
- C4: 표시명 = `users.display_name` 단일 출처(schema.sql:32). 배지 표시는 유저 표면(프로필/리뷰 작성자/게임 카드)에 부착. — 코드사실
|
||||||
|
- C5: 로드맵상 "활동 소스 존재 후" 착수 — 단 소스 이미 실재 → **W2 무관 독립 착수 가능**. — 해석
|
||||||
|
- **미결질문** (윤곽 — 비해소):
|
||||||
|
- Q1 배지 종류·기준: 리뷰어/기술자 외 추가? 부여 임계(리뷰 N개·품질 점수·업로드 M개).
|
||||||
|
- Q2 부여 방식: 자동(임계 도달) vs 수동(운영자) vs 혼합. → QG-W4-A.
|
||||||
|
- Q3 평판 점수: 배지(이산)만 vs 연속 평판 점수 병행.
|
||||||
|
- Q4 "기술자" 명칭(임시) 확정.
|
||||||
|
- Q5 회수/강등: 배지 회수 조건(부정 리뷰 등).
|
||||||
|
- Q6 표시 위치: 프로필·리뷰 작성자·게임 카드 어디에.
|
||||||
|
- **후속 진입점**: design-advisor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 워크스트림 결합 요약
|
||||||
|
|
||||||
|
| 기능 | 결합 대상 | 결합 방식 | 신규/확장 | 독립 출시 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| W2-1 잼 엔티티 | W1(GAME_JAM_MANAGE 미연결 키) + games(jam_id 부재) | 게이트 enforcement 연결 + games 확장 | jams 신규 + games 연결 | W1 후 |
|
||||||
|
| W2-2 심사위원 역할 | W1(전역 권한모델) + W2-1 | **스코프 갭** — 모델 확장/별도테이블 결정 | 신규(스코프 모델) | W2-1 후 |
|
||||||
|
| W2-3 평가 동결 | W3-2(하이브리드 리뷰+VIEW) + W2-4/5/6 | 동결 계약 + 단방향 집계 | 신규(동결) | 결합 클러스터(동결 게이트) |
|
||||||
|
| W2-4 심사위원 평가 | W2-3 동결 + W2-2 권한 | 동결 후 + 스코프 게이트 | 신규 | W2-3 후 |
|
||||||
|
| W2-5 인기투표 | W2-3 동결 + game_likes(별개, 1인1표 추정 갭) | 별개 신규 테이블 | 신규 | W2-3 후 |
|
||||||
|
| W2-6 시상 집계 | W2-4/5 + game_review_stats VIEW | 3트랙 집계 | 신규 | W2-4/5 + 동결 후 |
|
||||||
|
| W4 배지/평판 | 리뷰·좋아요·역할·업로드 소스(전부 실재) | 신규 배지가 소스 집계 소비 | 신규(소스 실재) | 독립(소스 실재) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 착수 순서 제언 (단정 아님 — 의존 사슬 기반)
|
||||||
|
|
||||||
|
> 크리티컬 패스(로드맵): W1(완료) → **W2-1 잼 엔티티 → W2-3 평가 동결 → W2-6 시상 집계**.
|
||||||
|
|
||||||
|
1. **W2-1 게임잼 엔티티** — jams 신규 + games 연결 + GAME_JAM_MANAGE enforcement 연결. 크리티컬 패스 기점. 다른 W2 전부의 선행.
|
||||||
|
2. **W2-2 심사위원 역할** — W2-1 후. **스코프 모델 결정(QG-W2-A) 선행 필수**.
|
||||||
|
3. **W2-3 잼 평가 동결** — ⚠️결합 클러스터 게이트. W2-4/5/6 착수 전 동결 필수(phase-gate). 유저평점 트랙 소스(QG-W2-B) 확정 동반.
|
||||||
|
4. **W2-4 심사위원 평가 / W2-5 인기투표** — 동결 후 **병렬 가능**(독립 자원: 심사 vs 투표).
|
||||||
|
5. **W2-6 시상 집계** — W2-4/5 + 동결 후. 크리티컬 패스 종점.
|
||||||
|
6. **W4 배지/평판** — 평판 소스(리뷰/좋아요/역할/업로드) 전부 실재 → **W2 무관 독립 병렬 가능**. 단 배지 기준이 리뷰/잼 활동 정의에 의존하면 그 부분만 후순위.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 전체 수준 미결질문 (풀설계 착수 전 확정 — QG)
|
||||||
|
|
||||||
|
> 이 QG 들은 골자 단계에서 해소하지 않는다. **풀설계 진입 시 AskUserQuestion 으로 확정**(사용자 제약).
|
||||||
|
|
||||||
|
- **QG-W2-A (★최우선 — 보안/권한)**: W2-2 잼 스코프 권한 모델 — (a) `user_permissions` scope/jam_id 컬럼 추가 + 게이트 시그니처 확장 (b) 별도 `jam_judges(jam_id, user_id)` 테이블 (c) 하이브리드. **W2-2·W2-4 전부 종속.** 현 권한 모델이 전역이라(code-fact) 잼별 역할이 그대로 안 얹힘.
|
||||||
|
- **QG-W2-B**: W2-3 동결 — 시상 유저평점 트랙 소스(overall avg vs 6축 평균 vs 특정 축) + 평가기간 필터 방식. **W2-6 종속.** 리뷰가 하이브리드라(code-fact) 단일평균 가정 불가.
|
||||||
|
- **QG-W2-C**: W2-1 games↔jam 연결 방식(games.jam_id 컬럼 vs 조인테이블) + 팀 출품 범위(games team_id 부재). **W2-1 핵심.**
|
||||||
|
- **QG-W2-D (⚠️추정 검증)**: game_likes 운영 DB UNIQUE(game_id,user_key) 제약 — schema.sql 비권위라 미확인(grounding concern). W2-5 가 별개 신규 테이블이면 game_likes 제약과 무관하나, "별개" 판정의 근거가 되므로 운영 DB 확인 권장.
|
||||||
|
- **QG-W4-A**: W4 배지 부여 기준·방식(자동 임계 vs 수동 vs 혼합) + "기술자" 명칭 확정.
|
||||||
|
|
||||||
|
> W3 측 기존 QG(QG-1 W1 게이트 착수 / QG-2 닉네임 레거시 / QG-3 `/game/**` 핸들러)는 W3 skeletons 에 잔존 — 본 W2/W4 와 직접 충돌 없음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 다음 단계
|
||||||
|
|
||||||
|
1. **골자 합의** (현재) — 본 W2·W4 골자 + stale 정정 2건 합의.
|
||||||
|
2. **각 W 미결질문 확정** — 풀설계 착수하는 기능의 *잔여* 미결 + 관련 QG 를 AskUserQuestion 으로 확정.
|
||||||
|
3. **풀설계** — design-advisor 로 진입, W1-design.md 깊이(DDL/파일영향맵/API계약/시퀀스/AC매핑). 착수 순서대로(크리티컬 패스 우선) 또는 사용자 지정 기능부터.
|
||||||
|
|
||||||
|
**보안 명시점**(풀설계에서 전수 적용): W2-2 권한(잼 스코프 게이트) / W3-3 SSRF(OG 미리보기·유니티블로그 피드 — W3 측) / W3-5 zip-slip(W3 측). W2 상태변경 전수 CSRF + `#{}` 바인딩.
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
---
|
||||||
|
kind: work-log
|
||||||
|
title: W2(게임잼) + W3(잔여) + W4 풀설계 — 통합 요약 / 설계 인덱스
|
||||||
|
description: 남은 W 워크스트림 11개 기능(W2-1~6, W3-1/3-3/3-4/3-5, W4)의 풀설계 산출 인덱스 + 크로스-W 결정 매트릭스 + 교차정합 감사 verdict + 골자 stale 정정 3건. 설계 전용 세션(코드 0줄). 설계 본문은 work-session implementation/ 에, 본 문서는 진입 인덱스. 착수 순서·미해결(운영검증) 포함.
|
||||||
|
status: active
|
||||||
|
source_session: 20260623-104307
|
||||||
|
resumed_from: 20260622-180054
|
||||||
|
source_skeletons:
|
||||||
|
- 2026-06-23-w2-w4-feature-skeletons.md
|
||||||
|
- 2026-06-17-w3-feature-skeletons.md
|
||||||
|
source_roadmap: 2026-06-17-jam-platform-roadmap.md
|
||||||
|
created_at: 2026-06-23
|
||||||
|
owner: art
|
||||||
|
---
|
||||||
|
|
||||||
|
# W2 + W3(잔여) + W4 풀설계 — 통합 요약 / 설계 인덱스
|
||||||
|
|
||||||
|
설계 전용 세션(코드 0줄, src/·pom.xml 무변경). 골자 → 미결 확정(정석 결정) → 풀설계 단계를 거쳐 **11개 기능 풀설계**를 산출했다. 각 설계는 W1-design.md 깊이(목표/DDL/API계약/시퀀스/파일영향맵/대안비교/롤아웃/AC매핑/검증포인트), **오픈질문 0**.
|
||||||
|
|
||||||
|
> 설계 본문: `.atp/work-session/20260623-104307/implementation/<W>-*.md`. 코드 grounding: `.atp/work-session/20260623-104307/research/`. 교차정합 감사: `.atp/work-session/20260623-104307/implementation/_cross-consistency-audit.md`.
|
||||||
|
|
||||||
|
## 미결질문 처리 방식 (이 세션 한정)
|
||||||
|
|
||||||
|
사용자가 "전체 설계를 개입 없이 직접, 빠른길 아닌 **확실하고 정석적**으로" 지시 → orchestrator 가 골자 QG/기능별 미결을 **정석 기준으로 직접 확정**해 design 에 주입(AskUserQuestion 면제, 명시 위임). 각 결정은 해당 설계의 "대안 비교" 표에 근거 보존 → 사용자가 설계도를 리뷰해 사후 override 가능.
|
||||||
|
|
||||||
|
## 설계 인덱스 (11)
|
||||||
|
|
||||||
|
| W | 설계 문서 | 핵심 결정(정석) | 신규 테이블/뷰 | 보안 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| W2-1 잼 엔티티/라이프사이클 | W2-1-jam-entity-design.md | 조인테이블 jam_entries(다회차·정규화) + 개인/팀 XOR + 명시 status+감사 전이 + GAME_JAM_MANAGE enforcement 연결 + keyset 페이징 | jams, jam_teams, jam_team_members, jam_entries, jam_status_log | CSRF·GAME_JAM_MANAGE 게이트 |
|
||||||
|
| W2-2 심사위원 역할 | W2-2-judge-role-design.md | **별도 jam_judges**(전역 RBAC 불변, 잼 스코프 게이트) + JamRoleGate.isJudge + 자기출품 충돌규칙 | jam_judges | ★잼 스코프 권한 게이트 |
|
||||||
|
| W2-3 잼 평가 동결 ⚠️클러스터 권위 | W2-3-eval-freeze-design.md | 동결 스키마 + **평가단위=(jam_id,game_id) 활성 자연키** + 유저평점=avg_rating 단방향(기간/최소리뷰수 임계·NULLS LAST) + 평가기간 게이트 계약 | jam_criteria, jam_scores, jam_votes, jam_awards, jam_score_stats(VIEW) | 평가기간 게이트·VIEW alias |
|
||||||
|
| W2-4 심사위원 평가 | W2-4-judge-scoring-design.md | criterion별 UPSERT + 가중집계 + 3중게이트(isJudge·평가기간·자기출품) | (W2-3 소비, 0) | isJudge+기간+CSRF |
|
||||||
|
| W2-5 인기투표 | W2-5-popular-vote-design.md | jam_votes 1인1표 UNIQUE(잼당 최애1) + 평가기간 게이트 + 종료후 공개(밴드왜건 회피) | (W2-3 소비, 0) | 로그인+기간+UNIQUE+CSRF |
|
||||||
|
| W2-6 시상 집계 | W2-6-award-aggregation-design.md | 3트랙(심사/유저평점/인기) 개별수상 + 가중 GRAND + NULL/임계 제외 + CLOSED 확정 멱등 | (W2-3 jam_awards 소비) | 산정 권한·VIEW alias |
|
||||||
|
| W3-1 태그+검색 | W3-1-tags-search-design.md | 통합 tags+조인 + 운영자/유저생성 하이브리드(sanitize+금칙어) + game_views(dedupe)+view_count + 검색 확장(다중태그 AND/OR·개발자·정렬키) | tags, game_tags, jam_tags, game_views (+games.view_count) | 태그 sanitize/금칙어·CONTENT_MODERATE 게이트·#{} |
|
||||||
|
| W3-3 포스팅 보드 | W3-3-posting-board-design.md | posts/categories + **POST_WRITE enforcement 연결** + commonmark+jsoup sanitize + **공용 SsrfSafeFetcher(9항목)** OG+유니티피드 + @Scheduled 폴링 dedupe | post_categories, posts, unity_feed_sources, unity_feed_items | ★SSRF 9항목·마크다운 sanitize·POST_WRITE·CSRF |
|
||||||
|
| W3-4 메인 허브 | W3-4-main-hub-design.md | keyset 페이징(검색·비검색 단일화) + 진행중 잼 배너→W3-1 `GET /games/search?jam={slug}` 라우팅 + 단계 분리 | (없음, GamesMapper 확장) | 파라미터 #{}·escape |
|
||||||
|
| W3-5 Unity 업로드 | W3-5-upload-design.md | **zip-slip canonical+심링크 거부+zip bomb** + 포맷검증(wasm/data/loader) + 권한게이트 + UUID 원자교체. QG-3 정정 반영(/game/** 정상 서빙) | game_upload_audit_log | ★zip-slip·경로 boundary·권한게이트 |
|
||||||
|
| W4 유저 배지/평판 | W4-badges-design.md | badges/user_badges/reputation_events + 자동임계+수동 하이브리드 + **BADGE_MANAGE 신규키** + 표시(프로필/리뷰/카드) | badges, user_badges, reputation_events (+PermissionKeys BADGE_MANAGE) | 부여/회수 CSRF+BADGE_MANAGE·멱등 UNIQUE |
|
||||||
|
|
||||||
|
## 크로스-W 결정 매트릭스 (정석 확정값)
|
||||||
|
|
||||||
|
| 결정축 | 확정 | 영향 W | 근거(정석) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 잼 스코프 권한 (QG-W2-A) | 별도 jam_judges 테이블 | W2-2/4 | 잼 회차별 역할 = 잼 스코프 조인. 전역 user_permissions(RBAC) 오염 0 |
|
||||||
|
| 평가 단위 | (jam_id, game_id) 활성 자연키 | W2-1/3/4/5/6 | orchestrator 동결 컬럼 일치 + game_review_stats(game_id) join 정합 + entrant 종류 불투명 유지. jam_entries active-UNIQUE 가 1:1 보장(jam_entries.id surrogate FK 아님) |
|
||||||
|
| 유저평점 트랙 (QG-W2-B) | overall avg_rating | W2-3/6 | game_review_stats VIEW 기존 노출. 6축은 표시전용. 최소 review_count 임계 미달 제외·NULLS LAST |
|
||||||
|
| 잼 연결 (QG-W2-C1) | 조인테이블 jam_entries | W2-1 | 정규화·다회차 출품·games 무변경(허브 회귀 0) |
|
||||||
|
| 팀 출품 (QG-W2-C2) | 1차 포함(jam_teams) | W2-1 | 잼은 팀 이벤트 본질. XOR 로 개인/팀 정합 |
|
||||||
|
| 인기투표 단위 | 잼당 1인1표 최애(UNIQUE(jam_id,voter_user_id)) | W2-5 | game_likes(user_key varchar·비권위 UNIQUE) 재활용 안 함. 로그인 user_id 기반 |
|
||||||
|
| 권한키 | GAME_JAM_MANAGE/POST_WRITE/CONTENT_MODERATE(W1) + **BADGE_MANAGE 신규**. 태그=CONTENT_MODERATE 재사용 | W2-1/2, W3-1/3, W4 | POST_WRITE·GAME_JAM_MANAGE = W1 선언키 enforcement 연결(소비처 0→연결 신규). BADGE_MANAGE 만 신규(권한 분리) — **W1 AC-T1 카탈로그 카운트 3→4 갱신 필요** |
|
||||||
|
| 외부 fetch (SSRF) | 공용 SsrfSafeFetcher 9항목 | W3-3 | OG 미리보기+유니티피드 단일화. scheme allowlist·사설IP차단·DNS rebinding·redirect 재검증·size/timeout |
|
||||||
|
| DDL 권위 | docs/<feature>-ddl.sql 멱등 + schema.sql 동기 | 전 W | W1 docs/rbac-ddl.sql 선례. apply-local-ddl.sh 알파벳 멱등 적용 |
|
||||||
|
|
||||||
|
## 교차정합 감사 verdict
|
||||||
|
|
||||||
|
`_cross-consistency-audit.md` (general-purpose opus). **동결 단일권위(W2-3) 유지, 하류 W2-4/5/6 소비 정합 PASS.**
|
||||||
|
- HIGH 1건 → **해소**: W2-1 본문 "평가단위=jam_entries.id" 표현 drift(DDL 은 정합) → (jam_id,game_id) 자연키로 5개소 정정.
|
||||||
|
- MED 0. LOW 3(게이트 순서 표현차·vote mapper 시그니처·경로 예시차 — 전부 동결 허용범위/무해).
|
||||||
|
- Clean: 컬럼 정합·중복 DDL 없음(W2-5 jam_votes 는 참조 인용)·권한키 정합·cross-W 라우트·보안 존재성(잼게이트/SSRF/zip-slip 실재·비자명).
|
||||||
|
|
||||||
|
## 골자 stale 정정 3건 (착지 코드 대비)
|
||||||
|
|
||||||
|
1. **RBAC 인프라 실재** (W3 skeletons #3 "Interceptor 없음" 뒤집힘) — 단 enforcement 갭(POST_WRITE·GAME_JAM_MANAGE 키 선언만, 소비처 0). W2-1/W3-3 이 연결.
|
||||||
|
2. **리뷰 인프라 실재** (W3 skeletons #2 "review 테이블 없음" 뒤집힘) — 하이브리드(overall + 6축) + game_review_stats VIEW. W2-3/6·W3-1·W4 가 소비, 단일평균 가정 금지.
|
||||||
|
3. **/game/** 서빙 정상** (W3 skeletons QG-3 해소) — GameAssetController 가 전용 처리(버그 아님). W3-5 실 결함은 zip-slip 심링크·업로드 권한게이트 전무로 재조준.
|
||||||
|
|
||||||
|
## 착수 순서 (크리티컬 패스 + 병렬)
|
||||||
|
|
||||||
|
크리티컬 패스: W1(완료) → **W2-1 → W2-3 동결 → W2-6**.
|
||||||
|
1. W2-1 잼 엔티티(jams/entries/teams + GAME_JAM_MANAGE 연결)
|
||||||
|
2. W2-2 심사위원(jam_judges) — W2-1 후
|
||||||
|
3. W2-3 동결 — ⚠️클러스터 게이트, W2-4/5/6 착수 전 필수
|
||||||
|
4. W2-4 / W2-5 — 동결 후 병렬
|
||||||
|
5. W2-6 시상 — W2-4/5 후
|
||||||
|
6. 독립 병렬: W3-1(태그검색) · W3-3(포스팅) · W4(배지) — W2 무관. W3-4(허브)는 W3-1 후. W3-5(업로드)는 독립.
|
||||||
|
|
||||||
|
## 미해결 / 운영 검증 필요 (설계로 닫지 못한 것)
|
||||||
|
|
||||||
|
- **game_likes 운영 DB UNIQUE 제약** — schema.sql 비권위라 추정(grounding concern). W2-5 는 신규 jam_votes 로 무관하나, game_likes 정합은 별도 운영 DB 확인 권장.
|
||||||
|
- **구현 단계 점검 항목**(각 설계 concerns): 신규 매퍼 @MockBean full-test(§30), DB-방언 alias(§33), 시그니처 inflate(§11.2), W3-3 pom 의존(commonmark/jsoup)·@EnableScheduling, W4 의 W1 AC-T1 카운트 3→4 재측정, SSRF rebinding 라이브러리 제약 단위테스트.
|
||||||
|
- 이 미결들은 **설계 오픈질문이 아니라 구현/운영 단계 게이트** — 각 설계 concerns 에 이관됨.
|
||||||
|
|
||||||
|
## 다음 단계
|
||||||
|
|
||||||
|
설계 합의 후 착수 순서대로 implementation-advisor 진입(코드·마이그레이션). 착수 기능의 concerns(구현 점검) 를 verification-strategies L레벨로 통과시킨다. **이 세션은 설계까지 — 코드 0줄.**
|
||||||
|
|
@ -5,4 +5,6 @@
|
||||||
## 목록
|
## 목록
|
||||||
|
|
||||||
- [2026-06-17-jam-platform-roadmap.md](./2026-06-17-jam-platform-roadmap.md) — bibimbap 플랫폼 고도화 목적별 워크스트림 로드맵. 4개 W(W1 거버넌스/RBAC · W2 게임잼 · W3 사이트플랫폼 · W4 유저배지)+운영으로 재구조. 게임잼은 W2 하나. 의존성(빌드순서) 유지 + 잼일정 분리가능 여부 명시. S→W 매핑 + 150635 대비 교정 5건 포함. `status: active`, source_session: 20260617-150635, restructured_by: 20260617-162536.
|
- [2026-06-17-jam-platform-roadmap.md](./2026-06-17-jam-platform-roadmap.md) — bibimbap 플랫폼 고도화 목적별 워크스트림 로드맵. 4개 W(W1 거버넌스/RBAC · W2 게임잼 · W3 사이트플랫폼 · W4 유저배지)+운영으로 재구조. 게임잼은 W2 하나. 의존성(빌드순서) 유지 + 잼일정 분리가능 여부 명시. S→W 매핑 + 150635 대비 교정 5건 포함. `status: active`, source_session: 20260617-150635, restructured_by: 20260617-162536.
|
||||||
- [2026-06-17-w3-feature-skeletons.md](./2026-06-17-w3-feature-skeletons.md) — W3(사이트 플랫폼) 5개 서브기능 골자 카탈로그. 기능당 목적·핵심동작·결합/의존·미결질문·후속진입점(윤곽 깊이, 미결 해소 안 함). 코드 현황 대조(tag/review 테이블 신규·Interceptor 미구현·`/game/**` 핸들러 미등록). 결합표 + 착수순서 제언(W3-2 1순위) + QG-1~3. 당장 구현 아님. `status: confirmed`(20260617-174635 세션서 5기능+착수순서 항목별 합의), source_session: 20260617-172407.
|
- [2026-06-17-w3-feature-skeletons.md](./2026-06-17-w3-feature-skeletons.md) — W3(사이트 플랫폼) 5개 서브기능 골자 카탈로그. 기능당 목적·핵심동작·결합/의존·미결질문·후속진입점(윤곽 깊이, 미결 해소 안 함). 코드 현황 대조(tag/review 테이블 신규·Interceptor 미구현·`/game/**` 핸들러 미등록). 결합표 + 착수순서 제언(W3-2 1순위) + QG-1~3. 당장 구현 아님. `status: confirmed`(20260617-174635 세션서 5기능+착수순서 항목별 합의), source_session: 20260617-172407. ⚠️ **코드 현황 #2·#3 stale (2026-06-23 정정)** — w2-w4-skeletons 참조.
|
||||||
|
- [2026-06-23-w2-w4-full-design-summary.md](./2026-06-23-w2-w4-full-design-summary.md) — **W2+W3(잔여)+W4 풀설계 통합 요약/설계 인덱스**. 11기능(W2-1~6·W3-1/3-3/3-4/3-5·W4) 풀설계(W1-design 깊이, 오픈질문 0) 진입 인덱스 + 크로스-W 결정 매트릭스(정석 확정) + 교차정합 감사 verdict(HIGH 1 해소·동결 단일권위 유지) + 골자 stale 정정 3건 + 착수순서. 설계 본문은 `.atp/work-session/20260623-104307/implementation/`. 설계 전용(코드 0줄). `status: active`, source_session: 20260623-104307, resumed_from: 20260622-180054.
|
||||||
|
- [2026-06-23-w2-w4-feature-skeletons.md](./2026-06-23-w2-w4-feature-skeletons.md) — W2(게임잼 6서브: 잼엔티티/심사위원역할/평가동결/심사평가/인기투표/시상집계) + W4(유저 배지/평판) 골자 카탈로그. W3 skeletons 포맷(목적·핵심동작·결합/의존[code-fact vs 해석 태깅]·미결질문[비해소]·후속진입점) + 결합표 + 착수순서 + QG-W2-A~D·W4-A. **W3 skeletons "코드 현황" stale 2건 정정**(RBAC 게이트·리뷰 인프라 실재 — 단 POST_WRITE/GAME_JAM_MANAGE enforcement 미연결, 리뷰는 하이브리드 6축+VIEW). code grounding: `.atp/work-session/20260623-104307/research/W2-W4-grounding.md`. `status: draft`(합의 전), source_session: 20260623-104307.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue