From 71b6b6f32ad133b3394a3e265520eafd549b02e0 Mon Sep 17 00:00:00 2001 From: art Date: Wed, 24 Jun 2026 11:15:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(jam):=20W2-2=20=EC=8B=AC=EC=82=AC=EC=9C=84?= =?UTF-8?q?=EC=9B=90=20=EC=97=AD=ED=95=A0=20=E2=80=94=20jam=5Fjudges=20+?= =?UTF-8?q?=20JamRoleGate(=EC=9E=BC=20=EC=8A=A4=EC=BD=94=ED=94=84=20?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=ED=8A=B8)=20+=20=EC=9E=90=EA=B8=B0=EC=B6=9C?= =?UTF-8?q?=ED=92=88=20=EC=B6=A9=EB=8F=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 신규 jam_judges 테이블(docs/jam-judge-ddl.sql 권위 + db/schema.sql 동기, 멱등). 전역 RBAC(user_permissions) 무변경 — 잼 스코프 권한은 별도 조인 - JamRoleGate.isJudge(잼별 지정 조회) + isOwnEntry(개인 entrant OR 팀멤버 OR-EXISTS) — 자기출품 충돌 판정 헬퍼/계약 제공(enforce=W2-4) - JamJudgeAdminController: 지정/해제/조회 + requireJamManage 게이트 + CSRF. W2-1 /admin/jams/** exclude 가 judges 트리 커버(인터셉터 무수정) - JamEntriesMapper.isOwnEntry 추가(#{} only). admin-jam-list.jsp 심사위원 섹션 - BibimbapApplicationTests @MockBean 2건(JamJudgesMapper/JamRoleGate) 검증: ./mvnw -o test 119/119 GREEN(신규 22: JamRoleGateTest 8·JamJudgeAdminControllerTest 14, 회귀 0). L2 contract PASS(ux_jam_judges UNIQUE 거부·isOwnEntry OR-EXISTS 6/6 정합). 집합전수 AC-T1~7 PASS(전역 RBAC 무변경 단언 포함). Co-Authored-By: Claude Opus 4.8 (1M context) --- db/schema.sql | 39 ++ docs/jam-judge-ddl.sql | 40 ++ .../controller/JamJudgeAdminController.java | 178 +++++++++ .../bibimbap/data/JamJudgeData.java | 61 +++ .../bibimbap/mapper/JamEntriesMapper.java | 16 + .../bibimbap/mapper/JamJudgesMapper.java | 59 +++ .../bibimbap/security/JamRoleGate.java | 52 +++ .../webapp/WEB-INF/views/admin-jam-list.jsp | 170 +++++++++ .../bibimbap/BibimbapApplicationTests.java | 8 + .../JamJudgeAdminControllerTest.java | 355 ++++++++++++++++++ .../bibimbap/security/JamRoleGateTest.java | 124 ++++++ 11 files changed, 1102 insertions(+) create mode 100644 docs/jam-judge-ddl.sql create mode 100644 src/main/java/com/pandoli365/bibimbap/controller/JamJudgeAdminController.java create mode 100644 src/main/java/com/pandoli365/bibimbap/data/JamJudgeData.java create mode 100644 src/main/java/com/pandoli365/bibimbap/mapper/JamJudgesMapper.java create mode 100644 src/main/java/com/pandoli365/bibimbap/security/JamRoleGate.java create mode 100644 src/test/java/com/pandoli365/bibimbap/controller/JamJudgeAdminControllerTest.java create mode 100644 src/test/java/com/pandoli365/bibimbap/security/JamRoleGateTest.java diff --git a/db/schema.sql b/db/schema.sql index 0a5611f..4f3eef3 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -555,3 +555,42 @@ BEGIN END $$; CREATE INDEX IF NOT EXISTS "idx_jam_status_log_jam" ON "jam_status_log" ("jam_id", "created_at" DESC); + +-- =========================================================================== +-- 심사위원 역할 W2-2 (권위 DDL — docs/jam-judge-ddl.sql 와 동일. 잼 스코프 역할) +-- =========================================================================== +-- =========================================================================== +-- 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"); diff --git a/docs/jam-judge-ddl.sql b/docs/jam-judge-ddl.sql new file mode 100644 index 0000000..11dee58 --- /dev/null +++ b/docs/jam-judge-ddl.sql @@ -0,0 +1,40 @@ +-- 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"); diff --git a/src/main/java/com/pandoli365/bibimbap/controller/JamJudgeAdminController.java b/src/main/java/com/pandoli365/bibimbap/controller/JamJudgeAdminController.java new file mode 100644 index 0000000..36527ac --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/controller/JamJudgeAdminController.java @@ -0,0 +1,178 @@ +package com.pandoli365.bibimbap.controller; + +import com.pandoli365.bibimbap.data.JamData; +import com.pandoli365.bibimbap.data.JamJudgeData; +import com.pandoli365.bibimbap.data.UserData; +import com.pandoli365.bibimbap.mapper.JamJudgesMapper; +import com.pandoli365.bibimbap.mapper.JamsMapper; +import com.pandoli365.bibimbap.mapper.UsersMapper; +import com.pandoli365.bibimbap.security.CsrfTokens; +import com.pandoli365.bibimbap.security.PermissionGate; +import com.pandoli365.bibimbap.security.PermissionKeys; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Controller +public class JamJudgeAdminController { + + private final JamsMapper jamsMapper; + private final UsersMapper usersMapper; + private final JamJudgesMapper jamJudgesMapper; + private final PermissionGate gate; + + public JamJudgeAdminController(JamsMapper jamsMapper, + UsersMapper usersMapper, + JamJudgesMapper jamJudgesMapper, + PermissionGate gate) { + this.jamsMapper = jamsMapper; + this.usersMapper = usersMapper; + this.jamJudgesMapper = jamJudgesMapper; + this.gate = gate; + } + + @PostMapping("/admin/jams/{jamId}/judges") + @Transactional + public ResponseEntity> assign( + @PathVariable("jamId") long jamId, + @RequestParam("userId") long userId, + HttpServletRequest request, + HttpSession session) { + ResponseEntity> denied = requireJamManage(session); + if (denied != null) { + return denied; + } + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); + } + JamData jam = jamsMapper.getById(jamId); + if (jam == null) { + return response(HttpStatus.NOT_FOUND, "게임잼을 찾을 수 없습니다."); + } + UserData target = usersMapper.getUser(userId); + if (target == null) { + return response(HttpStatus.NOT_FOUND, "대상 사용자를 찾을 수 없습니다."); + } + if (jamJudgesMapper.exists(jamId, userId)) { + return response(HttpStatus.CONFLICT, "이미 심사위원입니다."); + } + + long actorId = sessionUserId(session); + try { + jamJudgesMapper.insert(jamId, userId, actorId); + } catch (DuplicateKeyException e) { + // ux_jam_judges_jam_user 경합 — exists 통과 후 동시 insert 시 409로 수렴 + return response(HttpStatus.CONFLICT, "이미 심사위원입니다."); + } + + Map body = new LinkedHashMap<>(); + body.put("status", HttpStatus.OK.value()); + body.put("message", "심사위원을 지정했습니다."); + body.put("jamId", jamId); + body.put("userId", userId); + return ResponseEntity.ok(body); + } + + @PostMapping("/admin/jams/{jamId}/judges/{userId}/remove") + @Transactional + public ResponseEntity> remove( + @PathVariable("jamId") long jamId, + @PathVariable("userId") long userId, + HttpServletRequest request, + HttpSession session) { + ResponseEntity> denied = requireJamManage(session); + if (denied != null) { + return denied; + } + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); + } + + int affected = jamJudgesMapper.delete(jamId, userId); + if (affected == 0) { + return response(HttpStatus.NOT_FOUND, "지정된 심사위원이 아닙니다."); + } + + Map body = new LinkedHashMap<>(); + body.put("status", HttpStatus.OK.value()); + body.put("message", "심사위원을 해제했습니다."); + body.put("jamId", jamId); + body.put("userId", userId); + body.put("removed", true); + return ResponseEntity.ok(body); + } + + @GetMapping("/admin/jams/{jamId}/judges") + public ResponseEntity> list( + @PathVariable("jamId") long jamId, + HttpSession session) { + ResponseEntity> denied = requireJamManage(session); + if (denied != null) { + return denied; + } + + List judges = jamJudgesMapper.listByJam(jamId); + List> items = new ArrayList<>(judges.size()); + for (JamJudgeData judge : judges) { + Map item = new LinkedHashMap<>(); + item.put("userId", judge.getUserId()); + item.put("displayName", judge.getDisplayName()); + item.put("assignedBy", judge.getAssignedBy()); + item.put("createdAt", judge.getCreatedAt()); + items.add(item); + } + + Map body = new LinkedHashMap<>(); + body.put("status", HttpStatus.OK.value()); + body.put("judges", items); + return ResponseEntity.ok(body); + } + + private ResponseEntity> requireJamManage(HttpSession session) { + if (!gate.isAuthenticated(session)) { + return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + } + if (!gate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())) { + return response(HttpStatus.FORBIDDEN, "권한이 없습니다."); + } + return null; + } + + private ResponseEntity> response(HttpStatus status, String message) { + Map body = new LinkedHashMap<>(); + body.put("status", status.value()); + body.put("message", message); + return ResponseEntity.status(status).body(body); + } + + private Long sessionUserId(HttpSession session) { + if (session == null) { + return null; + } + Object userId = session.getAttribute("userId"); + if (userId instanceof Number number) { + return number.longValue(); + } + if (userId instanceof String text) { + try { + return Long.parseLong(text); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/data/JamJudgeData.java b/src/main/java/com/pandoli365/bibimbap/data/JamJudgeData.java new file mode 100644 index 0000000..7f2471d --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/JamJudgeData.java @@ -0,0 +1,61 @@ +package com.pandoli365.bibimbap.data; + +import java.time.OffsetDateTime; + +public class JamJudgeData { + + private Long id; + private Long jamId; + private Long userId; + private Long assignedBy; + private OffsetDateTime createdAt; + private String displayName; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getJamId() { + return jamId; + } + + public void setJamId(Long jamId) { + this.jamId = jamId; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public Long getAssignedBy() { + return assignedBy; + } + + public void setAssignedBy(Long assignedBy) { + this.assignedBy = assignedBy; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/JamEntriesMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/JamEntriesMapper.java index 3146cca..ed02fc5 100644 --- a/src/main/java/com/pandoli365/bibimbap/mapper/JamEntriesMapper.java +++ b/src/main/java/com/pandoli365/bibimbap/mapper/JamEntriesMapper.java @@ -63,4 +63,20 @@ public interface JamEntriesMapper { ) """) boolean exists(@Param("jamId") long jamId, @Param("gameId") long gameId); + + @Select(""" + 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} + )) + ) + ) + """) + boolean isOwnEntry(@Param("jamId") long jamId, @Param("gameId") long gameId, @Param("userId") long userId); } diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/JamJudgesMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/JamJudgesMapper.java new file mode 100644 index 0000000..61e1ffa --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/JamJudgesMapper.java @@ -0,0 +1,59 @@ +package com.pandoli365.bibimbap.mapper; + +import com.pandoli365.bibimbap.data.JamJudgeData; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface JamJudgesMapper { + + @Insert(""" + INSERT INTO jam_judges ( + jam_id, + user_id, + assigned_by + ) VALUES ( + #{jamId}, + #{userId}, + #{assignedBy} + ) + """) + int insert(@Param("jamId") long jamId, @Param("userId") long userId, @Param("assignedBy") long assignedBy); + + @Delete(""" + DELETE FROM jam_judges + WHERE jam_id = #{jamId} + AND user_id = #{userId} + """) + int delete(@Param("jamId") long jamId, @Param("userId") long userId); + + @Select(""" + SELECT EXISTS( + SELECT 1 + FROM jam_judges + WHERE jam_id = #{jamId} + AND user_id = #{userId} + ) + """) + boolean exists(@Param("jamId") long jamId, @Param("userId") long userId); + + @Select(""" + SELECT + jj.id, + jj.jam_id AS jamId, + jj.user_id AS userId, + jj.assigned_by AS assignedBy, + jj.created_at AS createdAt, + u.display_name AS displayName + FROM jam_judges jj + JOIN users u ON u.id = jj.user_id AND u.is_delete IS NOT TRUE + WHERE jj.jam_id = #{jamId} + ORDER BY jj.created_at, jj.id + """) + List listByJam(long jamId); +} diff --git a/src/main/java/com/pandoli365/bibimbap/security/JamRoleGate.java b/src/main/java/com/pandoli365/bibimbap/security/JamRoleGate.java new file mode 100644 index 0000000..9bee588 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/security/JamRoleGate.java @@ -0,0 +1,52 @@ +package com.pandoli365.bibimbap.security; + +import com.pandoli365.bibimbap.mapper.JamEntriesMapper; +import com.pandoli365.bibimbap.mapper.JamJudgesMapper; +import jakarta.servlet.http.HttpSession; +import org.springframework.stereotype.Component; + +/** + * 잼 스코프 게이트 — 전역 PermissionGate(권한 키 축)와 별도 축(잼 리소스 역할 축). + * 잼 역할은 요청당 jam_judges 직접 조회로 판정한다(캐시·epoch 없음 — 지정/해제 다음 요청 즉시 반영). + */ +@Component +public class JamRoleGate { + + private final JamJudgesMapper jamJudgesMapper; + private final JamEntriesMapper jamEntriesMapper; + + public JamRoleGate(JamJudgesMapper jamJudgesMapper, JamEntriesMapper jamEntriesMapper) { + this.jamJudgesMapper = jamJudgesMapper; + this.jamEntriesMapper = jamEntriesMapper; + } + + public boolean isJudge(HttpSession session, long jamId) { + Long userId = sessionUserId(session); + if (userId == null) { + return false; + } + return jamJudgesMapper.exists(jamId, userId); + } + + public boolean isOwnEntry(long jamId, long gameId, long judgeUserId) { + return jamEntriesMapper.isOwnEntry(jamId, gameId, judgeUserId); + } + + private Long sessionUserId(HttpSession session) { + if (session == null) { + return null; + } + Object attr = session.getAttribute("userId"); + if (attr instanceof Number number) { + return number.longValue(); + } + if (attr instanceof String s) { + try { + return Long.parseLong(s); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } +} diff --git a/src/main/webapp/WEB-INF/views/admin-jam-list.jsp b/src/main/webapp/WEB-INF/views/admin-jam-list.jsp index 2447dc8..5552aff 100644 --- a/src/main/webapp/WEB-INF/views/admin-jam-list.jsp +++ b/src/main/webapp/WEB-INF/views/admin-jam-list.jsp @@ -330,6 +330,9 @@ + @@ -341,6 +344,40 @@ + +