From ccf1e4243077927f63f2a680343937b96fd20abe Mon Sep 17 00:00:00 2001 From: art Date: Wed, 24 Jun 2026 10:50:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(jam):=20W2-1=20=EA=B2=8C=EC=9E=84=EC=9E=BC?= =?UTF-8?q?=20=EC=97=94=ED=8B=B0=ED=8B=B0/=EB=9D=BC=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=ED=81=B4=20=E2=80=94=20jams/entries/teams=20?= =?UTF-8?q?+=20GAME=5FJAM=5FMANAGE=20enforcement=20+=20=EA=B3=B5=EA=B0=9C?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=C2=B7=EC=83=81=EC=84=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 신규 5테이블(jams/jam_teams/jam_team_members/jam_entries/jam_status_log) DDL: docs/jam-ddl.sql 권위 + db/schema.sql 동기 사본, 멱등(IF NOT EXISTS/DO $$ guard) - 잼-게임 연결 = 조인테이블 jam_entries(games 무변경). 평가단위 = (jam_id,game_id) 활성 자연키. 잼당 게임 1회 활성 UNIQUE - 출품 주체 개인/팀 XOR(entrant_type + XOR CHECK). jam_teams/jam_team_members - 라이프사이클 4상태(RECRUIT/DEV/EVAL/CLOSED) + JamLifecycle 전이 그래프·기간정합 + jam_status_log 감사(수동/자동 공통 코어) - GAME_JAM_MANAGE enforcement: JamAdminController 진입부 requireJamManage 게이트 + InterceptorConfig /admin/jams/** exclude(SUBADMIN+키 통과) - 공개 JamController: keyset 페이징 목록(/jams) + 상세(/jams/{slug}) + 출품/팀 액션(CSRF). 3 JSP - 신규 5매퍼 #{} only, snake→camel alias. BibimbapApplicationTests @MockBean 5건 검증: ./mvnw -o test 97/97 GREEN(신규 32: JamControllerTest 14·JamAdminControllerTest 10·JamLifecycleTest 8 + contextLoads), 회귀 0. L2 dev DB contract PASS(XOR/status CHECK·활성 UNIQUE·keyset row-comparison). 집합전수 AC-T1~6 PASS. Co-Authored-By: Claude Opus 4.8 (1M context) --- db/schema.sql | 189 ++++++++ docs/jam-ddl.sql | 184 +++++++ .../bibimbap/config/InterceptorConfig.java | 5 +- .../controller/JamAdminController.java | 403 +++++++++++++++ .../bibimbap/controller/JamController.java | 276 +++++++++++ .../com/pandoli365/bibimbap/data/JamData.java | 160 ++++++ .../bibimbap/data/JamEntryData.java | 106 ++++ .../pandoli365/bibimbap/data/JamTeamData.java | 70 +++ .../pandoli365/bibimbap/jam/JamLifecycle.java | 40 ++ .../com/pandoli365/bibimbap/jam/JamSlugs.java | 44 ++ .../pandoli365/bibimbap/jam/JamStatus.java | 31 ++ .../bibimbap/mapper/JamEntriesMapper.java | 66 +++ .../bibimbap/mapper/JamStatusLogMapper.java | 30 ++ .../bibimbap/mapper/JamTeamMembersMapper.java | 31 ++ .../bibimbap/mapper/JamTeamsMapper.java | 57 +++ .../bibimbap/mapper/JamsMapper.java | 184 +++++++ .../webapp/WEB-INF/views/admin-jam-list.jsp | 434 +++++++++++++++++ src/main/webapp/WEB-INF/views/jam-detail.jsp | 459 ++++++++++++++++++ src/main/webapp/WEB-INF/views/jam-list.jsp | 189 ++++++++ .../bibimbap/BibimbapApplicationTests.java | 20 + .../controller/JamAdminControllerTest.java | 278 +++++++++++ .../controller/JamControllerTest.java | 358 ++++++++++++++ .../bibimbap/jam/JamLifecycleTest.java | 86 ++++ 23 files changed, 3699 insertions(+), 1 deletion(-) create mode 100644 docs/jam-ddl.sql create mode 100644 src/main/java/com/pandoli365/bibimbap/controller/JamAdminController.java create mode 100644 src/main/java/com/pandoli365/bibimbap/controller/JamController.java create mode 100644 src/main/java/com/pandoli365/bibimbap/data/JamData.java create mode 100644 src/main/java/com/pandoli365/bibimbap/data/JamEntryData.java create mode 100644 src/main/java/com/pandoli365/bibimbap/data/JamTeamData.java create mode 100644 src/main/java/com/pandoli365/bibimbap/jam/JamLifecycle.java create mode 100644 src/main/java/com/pandoli365/bibimbap/jam/JamSlugs.java create mode 100644 src/main/java/com/pandoli365/bibimbap/jam/JamStatus.java create mode 100644 src/main/java/com/pandoli365/bibimbap/mapper/JamEntriesMapper.java create mode 100644 src/main/java/com/pandoli365/bibimbap/mapper/JamStatusLogMapper.java create mode 100644 src/main/java/com/pandoli365/bibimbap/mapper/JamTeamMembersMapper.java create mode 100644 src/main/java/com/pandoli365/bibimbap/mapper/JamTeamsMapper.java create mode 100644 src/main/java/com/pandoli365/bibimbap/mapper/JamsMapper.java create mode 100644 src/main/webapp/WEB-INF/views/admin-jam-list.jsp create mode 100644 src/main/webapp/WEB-INF/views/jam-detail.jsp create mode 100644 src/main/webapp/WEB-INF/views/jam-list.jsp create mode 100644 src/test/java/com/pandoli365/bibimbap/controller/JamAdminControllerTest.java create mode 100644 src/test/java/com/pandoli365/bibimbap/controller/JamControllerTest.java create mode 100644 src/test/java/com/pandoli365/bibimbap/jam/JamLifecycleTest.java diff --git a/db/schema.sql b/db/schema.sql index 9b4704b..0a5611f 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -366,3 +366,192 @@ BEGIN END IF; END $$; + +-- --------------------------------------------------------------------------- +-- 게임잼 W2-1: jams / jam_teams / jam_team_members / jam_entries / jam_status_log +-- (권위 DDL — docs/jam-ddl.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, + "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, + "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"; + +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 +$$; + +CREATE UNIQUE INDEX IF NOT EXISTS "ux_jams_slug_active" + ON "jams" ("slug") WHERE "is_delete" IS NOT TRUE; +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 +-- =========================================================================== +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) 활성 자연키) +-- =========================================================================== +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, + "entrant_user_id" bigint, + "jam_team_id" bigint, + "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; + 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; + 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 +$$; +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), + "to_status" character varying(20) NOT NULL, + "actor_id" bigint, + "transition_type" character varying(10) DEFAULT 'MANUAL' NOT NULL, + "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); diff --git a/docs/jam-ddl.sql b/docs/jam-ddl.sql new file mode 100644 index 0000000..f17ed7d --- /dev/null +++ b/docs/jam-ddl.sql @@ -0,0 +1,184 @@ +-- 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, + "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, + "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"; + +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 +$$; + +CREATE UNIQUE INDEX IF NOT EXISTS "ux_jams_slug_active" + ON "jams" ("slug") WHERE "is_delete" IS NOT TRUE; +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 +-- =========================================================================== +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) 활성 자연키) +-- =========================================================================== +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, + "entrant_user_id" bigint, + "jam_team_id" bigint, + "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; + 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; + 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 +$$; +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), + "to_status" character varying(20) NOT NULL, + "actor_id" bigint, + "transition_type" character varying(10) DEFAULT 'MANUAL' NOT NULL, + "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); diff --git a/src/main/java/com/pandoli365/bibimbap/config/InterceptorConfig.java b/src/main/java/com/pandoli365/bibimbap/config/InterceptorConfig.java index ecf838f..ccdcb90 100644 --- a/src/main/java/com/pandoli365/bibimbap/config/InterceptorConfig.java +++ b/src/main/java/com/pandoli365/bibimbap/config/InterceptorConfig.java @@ -16,6 +16,9 @@ public class InterceptorConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(rbacInterceptor).addPathPatterns("/admin/**"); + // 잼 관리(/admin/jams/**)는 ADMIN 전용 인터셉터 대신 JamAdminController 의 GAME_JAM_MANAGE 게이트 헬퍼로 보호한다(SUBADMIN+키 통과 허용, D4-A). + registry.addInterceptor(rbacInterceptor) + .addPathPatterns("/admin/**") + .excludePathPatterns("/admin/jams/**"); } } diff --git a/src/main/java/com/pandoli365/bibimbap/controller/JamAdminController.java b/src/main/java/com/pandoli365/bibimbap/controller/JamAdminController.java new file mode 100644 index 0000000..534e890 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/controller/JamAdminController.java @@ -0,0 +1,403 @@ +package com.pandoli365.bibimbap.controller; + +import com.pandoli365.bibimbap.data.JamData; +import com.pandoli365.bibimbap.jam.JamLifecycle; +import com.pandoli365.bibimbap.jam.JamSlugs; +import com.pandoli365.bibimbap.jam.JamStatus; +import com.pandoli365.bibimbap.mapper.JamStatusLogMapper; +import com.pandoli365.bibimbap.mapper.JamsMapper; +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.ui.Model; +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.time.OffsetDateTime; +import java.time.format.DateTimeParseException; +import java.util.LinkedHashMap; +import java.util.Map; + +@Controller +public class JamAdminController { + + private static final int MAX_TITLE = 200; + private static final int MAX_DESCRIPTION = 5000; + private static final int MAX_DISCORD_URL = 500; + private static final int MAX_PRIZE_INFO = 5000; + private static final int MAX_SPONSOR_INFO = 5000; + private static final int MAX_SLUG_ATTEMPTS = 20; + + private final JamsMapper jamsMapper; + private final JamStatusLogMapper jamStatusLogMapper; + private final PermissionGate gate; + private final JamLifecycle jamLifecycle; + + public JamAdminController(JamsMapper jamsMapper, + JamStatusLogMapper jamStatusLogMapper, + PermissionGate gate, + JamLifecycle jamLifecycle) { + this.jamsMapper = jamsMapper; + this.jamStatusLogMapper = jamStatusLogMapper; + this.gate = gate; + this.jamLifecycle = jamLifecycle; + } + + @GetMapping("/admin/jams") + public String console(Model model, HttpServletRequest request, HttpSession session) { + if (!gate.isAuthenticated(session)) { + return "redirect:/login"; + } + if (!gate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())) { + return "redirect:/"; + } + model.addAttribute("jams", jamsMapper.listAllForAdmin()); + model.addAttribute("csrfToken", CsrfTokens.getOrCreate(request.getSession())); + return "admin-jam-list"; + } + + @PostMapping("/admin/jams") + @Transactional + public ResponseEntity> create( + @RequestParam(name = "title", required = false) String title, + @RequestParam(name = "description", required = false) String description, + @RequestParam(name = "recruitStartAt", required = false) String recruitStartAt, + @RequestParam(name = "devStartAt", required = false) String devStartAt, + @RequestParam(name = "evalStartAt", required = false) String evalStartAt, + @RequestParam(name = "evalEndAt", required = false) String evalEndAt, + @RequestParam(name = "discordUrl", required = false) String discordUrl, + @RequestParam(name = "prizeInfo", required = false) String prizeInfo, + @RequestParam(name = "sponsorInfo", required = false) String sponsorInfo, + 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()); + } + + String normalizedTitle = trimToNull(title); + if (normalizedTitle == null || normalizedTitle.length() > MAX_TITLE) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "제목을 200자 이내로 입력해 주세요."); + } + String normalizedDescription = trimToNull(description); + if (normalizedDescription != null && normalizedDescription.length() > MAX_DESCRIPTION) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "상세 설명이 너무 깁니다."); + } + String normalizedDiscordUrl = trimToNull(discordUrl); + if (normalizedDiscordUrl != null && normalizedDiscordUrl.length() > MAX_DISCORD_URL) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "디스코드 URL이 너무 깁니다."); + } + String normalizedPrizeInfo = trimToNull(prizeInfo); + if (normalizedPrizeInfo != null && normalizedPrizeInfo.length() > MAX_PRIZE_INFO) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "시상 정보가 너무 깁니다."); + } + String normalizedSponsorInfo = trimToNull(sponsorInfo); + if (normalizedSponsorInfo != null && normalizedSponsorInfo.length() > MAX_SPONSOR_INFO) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "후원 정보가 너무 깁니다."); + } + + OffsetDateTime recruitStart; + OffsetDateTime devStart; + OffsetDateTime evalStart; + OffsetDateTime evalEnd; + try { + recruitStart = parseOffset(recruitStartAt); + devStart = parseOffset(devStartAt); + evalStart = parseOffset(evalStartAt); + evalEnd = parseOffset(evalEndAt); + } catch (DateTimeParseException e) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "기간 형식을 확인해 주세요."); + } + if (evalStart != null && evalEnd != null && evalStart.isAfter(evalEnd)) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "평가 종료가 시작보다 빠를 수 없습니다."); + } + + Long actorId = sessionUserId(session); + JamData jam = new JamData(); + jam.setTitle(normalizedTitle); + jam.setDescription(normalizedDescription); + jam.setRecruitStartAt(recruitStart); + jam.setDevStartAt(devStart); + jam.setEvalStartAt(evalStart); + jam.setEvalEndAt(evalEnd); + jam.setDiscordUrl(normalizedDiscordUrl); + jam.setPrizeInfo(normalizedPrizeInfo); + jam.setSponsorInfo(normalizedSponsorInfo); + jam.setIsVisible(true); + jam.setStatus(JamStatus.RECRUIT.name()); + jam.setCreatedBy(actorId); + + String baseSlug = JamSlugs.generate(normalizedTitle); + boolean inserted = false; + jam.setSlug(baseSlug); + // slug 활성 유니크(ux_jams_slug_active) 충돌 시 -2, -3, ... 접미사로 재시도 + for (int attempt = 1; attempt <= MAX_SLUG_ATTEMPTS && !inserted; attempt++) { + if (attempt > 1) { + jam.setSlug(JamSlugs.withSuffix(baseSlug, attempt)); + } + try { + jamsMapper.insertJam(jam); + inserted = true; + } catch (DuplicateKeyException e) { + jam.setId(null); + } + } + if (!inserted || jam.getId() == null) { + return response(HttpStatus.INTERNAL_SERVER_ERROR, "게임잼 식별자 생성에 실패했습니다."); + } + + jamStatusLogMapper.insert(jam.getId(), null, JamStatus.RECRUIT.name(), actorId, "MANUAL"); + + Map body = new LinkedHashMap<>(); + body.put("status", HttpStatus.OK.value()); + body.put("message", "게임잼을 생성했습니다."); + body.put("jamId", jam.getId()); + body.put("slug", jam.getSlug()); + return ResponseEntity.ok(body); + } + + @PostMapping("/admin/jams/{jamId}") + @Transactional + public ResponseEntity> update( + @PathVariable("jamId") long jamId, + @RequestParam(name = "title", required = false) String title, + @RequestParam(name = "description", required = false) String description, + @RequestParam(name = "recruitStartAt", required = false) String recruitStartAt, + @RequestParam(name = "devStartAt", required = false) String devStartAt, + @RequestParam(name = "evalStartAt", required = false) String evalStartAt, + @RequestParam(name = "evalEndAt", required = false) String evalEndAt, + @RequestParam(name = "discordUrl", required = false) String discordUrl, + @RequestParam(name = "prizeInfo", required = false) String prizeInfo, + @RequestParam(name = "sponsorInfo", required = false) String sponsorInfo, + 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, "게임잼을 찾을 수 없습니다."); + } + + String normalizedTitle = trimToNull(title); + if (normalizedTitle == null || normalizedTitle.length() > MAX_TITLE) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "제목을 200자 이내로 입력해 주세요."); + } + String normalizedDescription = trimToNull(description); + if (normalizedDescription != null && normalizedDescription.length() > MAX_DESCRIPTION) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "상세 설명이 너무 깁니다."); + } + String normalizedDiscordUrl = trimToNull(discordUrl); + if (normalizedDiscordUrl != null && normalizedDiscordUrl.length() > MAX_DISCORD_URL) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "디스코드 URL이 너무 깁니다."); + } + String normalizedPrizeInfo = trimToNull(prizeInfo); + if (normalizedPrizeInfo != null && normalizedPrizeInfo.length() > MAX_PRIZE_INFO) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "시상 정보가 너무 깁니다."); + } + String normalizedSponsorInfo = trimToNull(sponsorInfo); + if (normalizedSponsorInfo != null && normalizedSponsorInfo.length() > MAX_SPONSOR_INFO) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "후원 정보가 너무 깁니다."); + } + + OffsetDateTime recruitStart; + OffsetDateTime devStart; + OffsetDateTime evalStart; + OffsetDateTime evalEnd; + try { + recruitStart = parseOffset(recruitStartAt); + devStart = parseOffset(devStartAt); + evalStart = parseOffset(evalStartAt); + evalEnd = parseOffset(evalEndAt); + } catch (DateTimeParseException e) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "기간 형식을 확인해 주세요."); + } + if (evalStart != null && evalEnd != null && evalStart.isAfter(evalEnd)) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "평가 종료가 시작보다 빠를 수 없습니다."); + } + + jam.setId(jamId); + jam.setTitle(normalizedTitle); + jam.setDescription(normalizedDescription); + jam.setRecruitStartAt(recruitStart); + jam.setDevStartAt(devStart); + jam.setEvalStartAt(evalStart); + jam.setEvalEndAt(evalEnd); + jam.setDiscordUrl(normalizedDiscordUrl); + jam.setPrizeInfo(normalizedPrizeInfo); + jam.setSponsorInfo(normalizedSponsorInfo); + jamsMapper.updateJam(jam); + + Map body = new LinkedHashMap<>(); + body.put("status", HttpStatus.OK.value()); + body.put("message", "게임잼을 수정했습니다."); + body.put("jamId", jamId); + return ResponseEntity.ok(body); + } + + @PostMapping("/admin/jams/{jamId}/status") + @Transactional + public ResponseEntity> changeStatus( + @PathVariable("jamId") long jamId, + @RequestParam("toStatus") String toStatus, + 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, "게임잼을 찾을 수 없습니다."); + } + + JamStatus to = JamStatus.from(toStatus); + if (to == null) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "알 수 없는 상태입니다."); + } + JamStatus from = JamStatus.from(jam.getStatus()); + if (!jamLifecycle.isAllowed(from, to)) { + return response(HttpStatus.CONFLICT, "허용되지 않는 전이입니다."); + } + if (!jamLifecycle.isPeriodReady(to, jam)) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "전이에 필요한 기간 필드가 설정되지 않았습니다."); + } + + Long actorId = sessionUserId(session); + jamsMapper.updateStatus(jamId, to.name()); + jamStatusLogMapper.insert(jamId, jam.getStatus(), to.name(), actorId, "MANUAL"); + + Map body = new LinkedHashMap<>(); + body.put("status", HttpStatus.OK.value()); + body.put("message", "상태를 전이했습니다."); + body.put("jamId", jamId); + body.put("jamStatus", to.name()); + return ResponseEntity.ok(body); + } + + @PostMapping("/admin/jams/{jamId}/visibility") + @Transactional + public ResponseEntity> toggleVisibility( + @PathVariable("jamId") long jamId, + 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, "게임잼을 찾을 수 없습니다."); + } + + boolean next = !Boolean.TRUE.equals(jam.getIsVisible()); + jamsMapper.updateVisibility(jamId, next); + + Map body = new LinkedHashMap<>(); + body.put("status", HttpStatus.OK.value()); + body.put("message", next ? "게임잼을 노출했습니다." : "게임잼을 숨겼습니다."); + body.put("jamId", jamId); + body.put("visible", next); + return ResponseEntity.ok(body); + } + + @PostMapping("/admin/jams/{jamId}/delete") + @Transactional + public ResponseEntity> delete( + @PathVariable("jamId") long jamId, + 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, "게임잼을 찾을 수 없습니다."); + } + + jamsMapper.softDelete(jamId); + + Map body = new LinkedHashMap<>(); + body.put("status", HttpStatus.OK.value()); + body.put("message", "게임잼을 삭제했습니다."); + body.put("jamId", jamId); + 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; + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String text = value.trim(); + return text.isBlank() ? null : text; + } + + private OffsetDateTime parseOffset(String raw) { + String text = trimToNull(raw); + if (text == null) { + return null; + } + return OffsetDateTime.parse(text); + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/controller/JamController.java b/src/main/java/com/pandoli365/bibimbap/controller/JamController.java new file mode 100644 index 0000000..96e5d25 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/controller/JamController.java @@ -0,0 +1,276 @@ +package com.pandoli365.bibimbap.controller; + +import com.pandoli365.bibimbap.data.GameData; +import com.pandoli365.bibimbap.data.JamData; +import com.pandoli365.bibimbap.data.JamEntryData; +import com.pandoli365.bibimbap.data.JamTeamData; +import com.pandoli365.bibimbap.mapper.GamesMapper; +import com.pandoli365.bibimbap.mapper.JamEntriesMapper; +import com.pandoli365.bibimbap.mapper.JamTeamMembersMapper; +import com.pandoli365.bibimbap.mapper.JamTeamsMapper; +import com.pandoli365.bibimbap.mapper.JamsMapper; +import com.pandoli365.bibimbap.security.CsrfTokens; +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.ui.Model; +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.time.OffsetDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Controller +public class JamController { + + private static final int PAGE_SIZE = 20; + + private final JamsMapper jamsMapper; + private final JamEntriesMapper jamEntriesMapper; + private final JamTeamsMapper jamTeamsMapper; + private final JamTeamMembersMapper jamTeamMembersMapper; + private final GamesMapper gamesMapper; + + public JamController( + JamsMapper jamsMapper, + JamEntriesMapper jamEntriesMapper, + JamTeamsMapper jamTeamsMapper, + JamTeamMembersMapper jamTeamMembersMapper, + GamesMapper gamesMapper + ) { + this.jamsMapper = jamsMapper; + this.jamEntriesMapper = jamEntriesMapper; + this.jamTeamsMapper = jamTeamsMapper; + this.jamTeamMembersMapper = jamTeamMembersMapper; + this.gamesMapper = gamesMapper; + } + + @GetMapping("/jams") + public String list(@RequestParam(name = "cursor", required = false) String cursor, Model model) { + OffsetDateTime cursorCreatedAt = null; + Long cursorId = null; + if (cursor != null && !cursor.isBlank()) { + int separator = cursor.lastIndexOf('_'); + if (separator > 0 && separator < cursor.length() - 1) { + try { + cursorCreatedAt = OffsetDateTime.parse(cursor.substring(0, separator)); + cursorId = Long.parseLong(cursor.substring(separator + 1)); + } catch (RuntimeException e) { + cursorCreatedAt = null; + cursorId = null; + } + } + } + + List jams = jamsMapper.listVisibleKeyset(cursorCreatedAt, cursorId, PAGE_SIZE + 1); + boolean hasNext = jams.size() > PAGE_SIZE; + if (hasNext) { + jams = jams.subList(0, PAGE_SIZE); + } + String nextCursor = null; + if (hasNext) { + JamData lastJam = jams.get(jams.size() - 1); + nextCursor = lastJam.getCreatedAt().toString() + "_" + lastJam.getId(); + } + + model.addAttribute("jams", jams); + model.addAttribute("nextCursor", nextCursor); + return "jam-list"; + } + + @GetMapping("/jams/{slug}") + public String detail(@PathVariable("slug") String slug, Model model, HttpServletRequest request) { + JamData jam = jamsMapper.getBySlug(slug); + if (jam == null || Boolean.FALSE.equals(jam.getIsVisible())) { + return "redirect:/jams"; + } + model.addAttribute("jam", jam); + model.addAttribute("entries", jamEntriesMapper.listByJam(jam.getId())); + model.addAttribute("teams", jamTeamsMapper.listByJam(jam.getId())); + model.addAttribute("csrfToken", CsrfTokens.getOrCreate(request.getSession())); + return "jam-detail"; + } + + @PostMapping("/jams/{slug}/entries") + @Transactional + public ResponseEntity> submitEntry( + @PathVariable("slug") String slug, + @RequestParam("gameId") long gameId, + @RequestParam(name = "jamTeamId", required = false) Long jamTeamId, + HttpServletRequest request, + HttpSession session + ) { + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); + } + Long userId = sessionUserId(session); + if (userId == null) { + return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + } + JamData jam = jamsMapper.getBySlug(slug); + if (jam == null) { + return response(HttpStatus.NOT_FOUND, "잼을 찾을 수 없습니다."); + } + if (!"RECRUIT".equals(jam.getStatus()) && !"DEV".equals(jam.getStatus())) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "지금은 출품할 수 없는 상태입니다."); + } + GameData game = gamesMapper.getGame(gameId); + if (game == null) { + return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다."); + } + if (jamEntriesMapper.exists(jam.getId(), gameId)) { + return response(HttpStatus.CONFLICT, "이미 출품된 게임입니다."); + } + + String entrantType; + Long entrantUserId; + if (jamTeamId == null) { + entrantType = "USER"; + if (!userId.equals(game.getUserId())) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "본인 게임만 출품할 수 있습니다."); + } + entrantUserId = userId; + } else { + entrantType = "TEAM"; + if (!jamTeamMembersMapper.exists(jamTeamId, userId)) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "팀 멤버만 출품할 수 있습니다."); + } + entrantUserId = null; + } + + JamEntryData entry = new JamEntryData(); + entry.setJamId(jam.getId()); + entry.setGameId(gameId); + entry.setEntrantType(entrantType); + entry.setEntrantUserId(entrantUserId); + entry.setJamTeamId(jamTeamId); + try { + jamEntriesMapper.insert(entry); + } catch (DuplicateKeyException e) { + return response(HttpStatus.CONFLICT, "이미 출품된 게임입니다."); + } + + Map body = new LinkedHashMap<>(); + body.put("status", 200); + body.put("entryId", entry.getId()); + return ResponseEntity.ok(body); + } + + @PostMapping("/jams/{slug}/teams") + @Transactional + public ResponseEntity> submitTeam( + @PathVariable("slug") String slug, + @RequestParam("name") String name, + HttpServletRequest request, + HttpSession session + ) { + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); + } + Long userId = sessionUserId(session); + if (userId == null) { + return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + } + JamData jam = jamsMapper.getBySlug(slug); + if (jam == null) { + return response(HttpStatus.NOT_FOUND, "잼을 찾을 수 없습니다."); + } + if (!"RECRUIT".equals(jam.getStatus()) && !"DEV".equals(jam.getStatus())) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "지금은 출품할 수 없는 상태입니다."); + } + String normalizedName = trimToNull(name); + if (normalizedName == null || normalizedName.length() > 120) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "팀 이름을 120자 이내로 입력해 주세요."); + } + + JamTeamData team = new JamTeamData(); + team.setJamId(jam.getId()); + team.setName(normalizedName); + team.setOwnerUserId(userId); + jamTeamsMapper.insert(team); + + Map body = new LinkedHashMap<>(); + body.put("status", 200); + body.put("jamTeamId", team.getId()); + return ResponseEntity.ok(body); + } + + @PostMapping("/jams/{slug}/teams/{teamId}/members") + @Transactional + public ResponseEntity> addMember( + @PathVariable("slug") String slug, + @PathVariable("teamId") long teamId, + @RequestParam(name = "userId") long memberUserId, + HttpServletRequest request, + HttpSession session + ) { + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); + } + Long actorId = sessionUserId(session); + if (actorId == null) { + return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + } + JamData jam = jamsMapper.getBySlug(slug); + if (jam == null) { + return response(HttpStatus.NOT_FOUND, "잼을 찾을 수 없습니다."); + } + JamTeamData team = jamTeamsMapper.getById(teamId); + if (team == null) { + return response(HttpStatus.NOT_FOUND, "팀을 찾을 수 없습니다."); + } + if (!actorId.equals(team.getOwnerUserId())) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "팀장만 멤버를 추가할 수 있습니다."); + } + if (jamTeamMembersMapper.exists(teamId, memberUserId)) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "이미 팀 멤버입니다."); + } + try { + jamTeamMembersMapper.insert(teamId, memberUserId); + } catch (DuplicateKeyException e) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "이미 팀 멤버입니다."); + } + return response(HttpStatus.OK, "팀 멤버가 추가되었습니다."); + } + + 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; + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String text = value.trim(); + return text.isBlank() ? null : text; + } + + 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); + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/data/JamData.java b/src/main/java/com/pandoli365/bibimbap/data/JamData.java new file mode 100644 index 0000000..9b4546a --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/JamData.java @@ -0,0 +1,160 @@ +package com.pandoli365.bibimbap.data; + +import java.time.OffsetDateTime; + +public class JamData { + + private Long id; + private String slug; + private String title; + private String description; + private String status; + private OffsetDateTime recruitStartAt; + private OffsetDateTime devStartAt; + private OffsetDateTime evalStartAt; + private OffsetDateTime evalEndAt; + private String discordUrl; + private String prizeInfo; + private String sponsorInfo; + private Boolean isVisible; + private Long createdBy; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + private Boolean isDelete; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getSlug() { + return slug; + } + + public void setSlug(String slug) { + this.slug = slug; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public OffsetDateTime getRecruitStartAt() { + return recruitStartAt; + } + + public void setRecruitStartAt(OffsetDateTime recruitStartAt) { + this.recruitStartAt = recruitStartAt; + } + + public OffsetDateTime getDevStartAt() { + return devStartAt; + } + + public void setDevStartAt(OffsetDateTime devStartAt) { + this.devStartAt = devStartAt; + } + + public OffsetDateTime getEvalStartAt() { + return evalStartAt; + } + + public void setEvalStartAt(OffsetDateTime evalStartAt) { + this.evalStartAt = evalStartAt; + } + + public OffsetDateTime getEvalEndAt() { + return evalEndAt; + } + + public void setEvalEndAt(OffsetDateTime evalEndAt) { + this.evalEndAt = evalEndAt; + } + + public String getDiscordUrl() { + return discordUrl; + } + + public void setDiscordUrl(String discordUrl) { + this.discordUrl = discordUrl; + } + + public String getPrizeInfo() { + return prizeInfo; + } + + public void setPrizeInfo(String prizeInfo) { + this.prizeInfo = prizeInfo; + } + + public String getSponsorInfo() { + return sponsorInfo; + } + + public void setSponsorInfo(String sponsorInfo) { + this.sponsorInfo = sponsorInfo; + } + + public Boolean getIsVisible() { + return isVisible; + } + + public void setIsVisible(Boolean isVisible) { + this.isVisible = isVisible; + } + + public Long getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(Long createdBy) { + this.createdBy = createdBy; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public Boolean getIsDelete() { + return isDelete; + } + + public void setIsDelete(Boolean isDelete) { + this.isDelete = isDelete; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/data/JamEntryData.java b/src/main/java/com/pandoli365/bibimbap/data/JamEntryData.java new file mode 100644 index 0000000..a370717 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/JamEntryData.java @@ -0,0 +1,106 @@ +package com.pandoli365.bibimbap.data; + +import java.time.OffsetDateTime; + +public class JamEntryData { + + private Long id; + private Long jamId; + private Long gameId; + private String entrantType; + private Long entrantUserId; + private Long jamTeamId; + private OffsetDateTime submittedAt; + private Boolean isDelete; + private String gameName; + private String thumbnailUrl; + private String entrantName; + + 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 getGameId() { + return gameId; + } + + public void setGameId(Long gameId) { + this.gameId = gameId; + } + + public String getEntrantType() { + return entrantType; + } + + public void setEntrantType(String entrantType) { + this.entrantType = entrantType; + } + + public Long getEntrantUserId() { + return entrantUserId; + } + + public void setEntrantUserId(Long entrantUserId) { + this.entrantUserId = entrantUserId; + } + + public Long getJamTeamId() { + return jamTeamId; + } + + public void setJamTeamId(Long jamTeamId) { + this.jamTeamId = jamTeamId; + } + + public OffsetDateTime getSubmittedAt() { + return submittedAt; + } + + public void setSubmittedAt(OffsetDateTime submittedAt) { + this.submittedAt = submittedAt; + } + + public Boolean getIsDelete() { + return isDelete; + } + + public void setIsDelete(Boolean isDelete) { + this.isDelete = isDelete; + } + + public String getGameName() { + return gameName; + } + + public void setGameName(String gameName) { + this.gameName = gameName; + } + + public String getThumbnailUrl() { + return thumbnailUrl; + } + + public void setThumbnailUrl(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + } + + public String getEntrantName() { + return entrantName; + } + + public void setEntrantName(String entrantName) { + this.entrantName = entrantName; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/data/JamTeamData.java b/src/main/java/com/pandoli365/bibimbap/data/JamTeamData.java new file mode 100644 index 0000000..74bff95 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/JamTeamData.java @@ -0,0 +1,70 @@ +package com.pandoli365.bibimbap.data; + +import java.time.OffsetDateTime; + +public class JamTeamData { + + private Long id; + private Long jamId; + private String name; + private Long ownerUserId; + private OffsetDateTime createdAt; + private Boolean isDelete; + private Integer memberCount; + + 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 String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Long getOwnerUserId() { + return ownerUserId; + } + + public void setOwnerUserId(Long ownerUserId) { + this.ownerUserId = ownerUserId; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public Boolean getIsDelete() { + return isDelete; + } + + public void setIsDelete(Boolean isDelete) { + this.isDelete = isDelete; + } + + public Integer getMemberCount() { + return memberCount; + } + + public void setMemberCount(Integer memberCount) { + this.memberCount = memberCount; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/jam/JamLifecycle.java b/src/main/java/com/pandoli365/bibimbap/jam/JamLifecycle.java new file mode 100644 index 0000000..5419580 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/jam/JamLifecycle.java @@ -0,0 +1,40 @@ +package com.pandoli365.bibimbap.jam; + +import com.pandoli365.bibimbap.data.JamData; +import org.springframework.stereotype.Component; + +import java.time.OffsetDateTime; + +@Component +public class JamLifecycle { + + public boolean isAllowed(JamStatus from, JamStatus to) { + if (from == null || to == null) { + return false; + } + return switch (from) { + case RECRUIT -> to == JamStatus.DEV; + case DEV -> to == JamStatus.EVAL || to == JamStatus.RECRUIT; + case EVAL -> to == JamStatus.CLOSED || to == JamStatus.DEV; + case CLOSED -> false; + }; + } + + public boolean isPeriodReady(JamStatus to, OffsetDateTime evalStartAt, OffsetDateTime evalEndAt) { + if (to == null) { + return false; + } + return switch (to) { + case EVAL -> evalStartAt != null; + case CLOSED -> evalEndAt != null; + case RECRUIT, DEV -> true; + }; + } + + public boolean isPeriodReady(JamStatus to, JamData jam) { + if (jam == null) { + return false; + } + return isPeriodReady(to, jam.getEvalStartAt(), jam.getEvalEndAt()); + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/jam/JamSlugs.java b/src/main/java/com/pandoli365/bibimbap/jam/JamSlugs.java new file mode 100644 index 0000000..7b56796 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/jam/JamSlugs.java @@ -0,0 +1,44 @@ +package com.pandoli365.bibimbap.jam; + +import java.util.regex.Pattern; + +public final class JamSlugs { + + private static final int MAX_BASE_LENGTH = 72; + private static final int MAX_SLUG_LENGTH = 80; + private static final String FALLBACK = "jam"; + + private static final Pattern DISALLOWED = Pattern.compile("[^\\p{IsHangul}a-z0-9]+"); + private static final Pattern MULTI_HYPHEN = Pattern.compile("-{2,}"); + private static final Pattern TRIM_HYPHEN = Pattern.compile("^-+|-+$"); + + private JamSlugs() { + } + + public static String generate(String title) { + String base = title == null ? "" : title; + String lowered = base.toLowerCase(); + String hyphenated = DISALLOWED.matcher(lowered).replaceAll("-"); + String collapsed = MULTI_HYPHEN.matcher(hyphenated).replaceAll("-"); + String trimmed = TRIM_HYPHEN.matcher(collapsed).replaceAll(""); + + String truncated = trimmed; + if (truncated.length() > MAX_BASE_LENGTH) { + truncated = truncated.substring(0, MAX_BASE_LENGTH); + truncated = TRIM_HYPHEN.matcher(truncated).replaceAll(""); + } + + return truncated.isEmpty() ? FALLBACK : truncated; + } + + public static String withSuffix(String baseSlug, int n) { + String suffix = "-" + n; + int allowedBase = MAX_SLUG_LENGTH - suffix.length(); + String trimmedBase = baseSlug; + if (trimmedBase.length() > allowedBase) { + trimmedBase = trimmedBase.substring(0, allowedBase); + trimmedBase = TRIM_HYPHEN.matcher(trimmedBase).replaceAll(""); + } + return trimmedBase + suffix; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/jam/JamStatus.java b/src/main/java/com/pandoli365/bibimbap/jam/JamStatus.java new file mode 100644 index 0000000..d5e0fc6 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/jam/JamStatus.java @@ -0,0 +1,31 @@ +package com.pandoli365.bibimbap.jam; + +public enum JamStatus { + RECRUIT, + DEV, + EVAL, + CLOSED; + + public static boolean isValid(String key) { + if (key == null) { + return false; + } + try { + valueOf(key); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } + + public static JamStatus from(String key) { + if (key == null) { + return null; + } + try { + return valueOf(key); + } catch (IllegalArgumentException e) { + return null; + } + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/JamEntriesMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/JamEntriesMapper.java new file mode 100644 index 0000000..3146cca --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/JamEntriesMapper.java @@ -0,0 +1,66 @@ +package com.pandoli365.bibimbap.mapper; + +import com.pandoli365.bibimbap.data.JamEntryData; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface JamEntriesMapper { + + @Insert(""" + INSERT INTO jam_entries ( + jam_id, + game_id, + entrant_type, + entrant_user_id, + jam_team_id + ) VALUES ( + #{jamId}, + #{gameId}, + #{entrantType}, + #{entrantUserId}, + #{jamTeamId} + ) + """) + @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id") + int insert(JamEntryData entry); + + @Select(""" + SELECT + e.id, + e.jam_id AS jamId, + e.game_id AS gameId, + e.entrant_type AS entrantType, + e.entrant_user_id AS entrantUserId, + e.jam_team_id AS jamTeamId, + e.submitted_at AS submittedAt, + e.is_delete AS isDelete, + g.name AS gameName, + g.thumbnail_url AS thumbnailUrl, + COALESCE(t.name, eu.display_name) AS entrantName + FROM jam_entries e + JOIN games g ON g.id = e.game_id AND g.is_delete IS NOT TRUE + LEFT JOIN users eu ON eu.id = e.entrant_user_id + LEFT JOIN jam_teams t ON t.id = e.jam_team_id + WHERE e.jam_id = #{jamId} + AND e.is_delete IS NOT TRUE + ORDER BY e.submitted_at DESC, e.id DESC + """) + List listByJam(long jamId); + + @Select(""" + SELECT EXISTS( + SELECT 1 + FROM jam_entries + WHERE jam_id = #{jamId} + AND game_id = #{gameId} + AND is_delete IS NOT TRUE + ) + """) + boolean exists(@Param("jamId") long jamId, @Param("gameId") long gameId); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/JamStatusLogMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/JamStatusLogMapper.java new file mode 100644 index 0000000..712e06c --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/JamStatusLogMapper.java @@ -0,0 +1,30 @@ +package com.pandoli365.bibimbap.mapper; + +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface JamStatusLogMapper { + + @Insert(""" + INSERT INTO jam_status_log ( + jam_id, + from_status, + to_status, + actor_id, + transition_type + ) VALUES ( + #{jamId}, + #{fromStatus}, + #{toStatus}, + #{actorId}, + #{transitionType} + ) + """) + int insert(@Param("jamId") long jamId, + @Param("fromStatus") String fromStatus, + @Param("toStatus") String toStatus, + @Param("actorId") Long actorId, + @Param("transitionType") String transitionType); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/JamTeamMembersMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/JamTeamMembersMapper.java new file mode 100644 index 0000000..69a935c --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/JamTeamMembersMapper.java @@ -0,0 +1,31 @@ +package com.pandoli365.bibimbap.mapper; + +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface JamTeamMembersMapper { + + @Insert(""" + INSERT INTO jam_team_members ( + jam_team_id, + user_id + ) VALUES ( + #{jamTeamId}, + #{userId} + ) + """) + int insert(@Param("jamTeamId") long jamTeamId, @Param("userId") long userId); + + @Select(""" + SELECT EXISTS( + SELECT 1 + FROM jam_team_members + WHERE jam_team_id = #{jamTeamId} + AND user_id = #{userId} + ) + """) + boolean exists(@Param("jamTeamId") long jamTeamId, @Param("userId") long userId); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/JamTeamsMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/JamTeamsMapper.java new file mode 100644 index 0000000..d01886e --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/JamTeamsMapper.java @@ -0,0 +1,57 @@ +package com.pandoli365.bibimbap.mapper; + +import com.pandoli365.bibimbap.data.JamTeamData; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface JamTeamsMapper { + + @Insert(""" + INSERT INTO jam_teams ( + jam_id, + name, + owner_user_id + ) VALUES ( + #{jamId}, + #{name}, + #{ownerUserId} + ) + """) + @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id") + int insert(JamTeamData team); + + @Select(""" + SELECT + id, + jam_id AS jamId, + name, + owner_user_id AS ownerUserId, + created_at AS createdAt, + is_delete AS isDelete + FROM jam_teams + WHERE id = #{teamId} + AND is_delete IS NOT TRUE + """) + JamTeamData getById(long teamId); + + @Select(""" + SELECT + t.id, + t.jam_id AS jamId, + t.name, + t.owner_user_id AS ownerUserId, + t.created_at AS createdAt, + t.is_delete AS isDelete, + (SELECT COUNT(*) FROM jam_team_members m WHERE m.jam_team_id = t.id) AS memberCount + FROM jam_teams t + WHERE t.jam_id = #{jamId} + AND t.is_delete IS NOT TRUE + ORDER BY t.created_at ASC, t.id ASC + """) + List listByJam(long jamId); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/JamsMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/JamsMapper.java new file mode 100644 index 0000000..3ec848d --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/JamsMapper.java @@ -0,0 +1,184 @@ +package com.pandoli365.bibimbap.mapper; + +import com.pandoli365.bibimbap.data.JamData; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.time.OffsetDateTime; +import java.util.List; + +@Mapper +public interface JamsMapper { + + @Select(""" + SELECT id, slug, title, description, status, + recruit_start_at AS recruitStartAt, + dev_start_at AS devStartAt, + eval_start_at AS evalStartAt, + eval_end_at AS evalEndAt, + discord_url AS discordUrl, + prize_info AS prizeInfo, + sponsor_info AS sponsorInfo, + is_visible AS isVisible, + created_by AS createdBy, + created_at AS createdAt, + updated_at AS updatedAt, + is_delete AS isDelete + FROM jams + WHERE id = #{jamId} + AND is_delete IS NOT TRUE + """) + JamData getById(long jamId); + + @Select(""" + SELECT id, slug, title, description, status, + recruit_start_at AS recruitStartAt, + dev_start_at AS devStartAt, + eval_start_at AS evalStartAt, + eval_end_at AS evalEndAt, + discord_url AS discordUrl, + prize_info AS prizeInfo, + sponsor_info AS sponsorInfo, + is_visible AS isVisible, + created_by AS createdBy, + created_at AS createdAt, + updated_at AS updatedAt, + is_delete AS isDelete + FROM jams + WHERE slug = #{slug} + AND is_delete IS NOT TRUE + """) + JamData getBySlug(String slug); + + @Select(""" + + """) + List listVisibleKeyset(@Param("cursorCreatedAt") OffsetDateTime cursorCreatedAt, + @Param("cursorId") Long cursorId, + @Param("limit") int limit); + + @Select(""" + SELECT id, slug, title, description, status, + recruit_start_at AS recruitStartAt, + dev_start_at AS devStartAt, + eval_start_at AS evalStartAt, + eval_end_at AS evalEndAt, + discord_url AS discordUrl, + prize_info AS prizeInfo, + sponsor_info AS sponsorInfo, + is_visible AS isVisible, + created_by AS createdBy, + created_at AS createdAt, + updated_at AS updatedAt, + is_delete AS isDelete + FROM jams + WHERE is_delete IS NOT TRUE + ORDER BY created_at DESC, id DESC + """) + List listAllForAdmin(); + + @Insert(""" + INSERT INTO jams ( + slug, + title, + description, + status, + recruit_start_at, + dev_start_at, + eval_start_at, + eval_end_at, + discord_url, + prize_info, + sponsor_info, + is_visible, + created_by + ) VALUES ( + #{slug}, + #{title}, + #{description}, + #{status}, + #{recruitStartAt}, + #{devStartAt}, + #{evalStartAt}, + #{evalEndAt}, + #{discordUrl}, + #{prizeInfo}, + #{sponsorInfo}, + #{isVisible}, + #{createdBy} + ) + """) + @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id") + int insertJam(JamData jam); + + @Update(""" + UPDATE jams SET + title = #{title}, + description = #{description}, + recruit_start_at = #{recruitStartAt}, + dev_start_at = #{devStartAt}, + eval_start_at = #{evalStartAt}, + eval_end_at = #{evalEndAt}, + discord_url = #{discordUrl}, + prize_info = #{prizeInfo}, + sponsor_info = #{sponsorInfo}, + is_visible = #{isVisible}, + updated_at = now() + WHERE id = #{id} + AND is_delete IS NOT TRUE + """) + int updateJam(JamData jam); + + @Update(""" + UPDATE jams SET + status = #{status}, + updated_at = now() + WHERE id = #{jamId} + AND is_delete IS NOT TRUE + """) + int updateStatus(@Param("jamId") long jamId, @Param("status") String status); + + @Update(""" + UPDATE jams SET + is_visible = #{isVisible}, + updated_at = now() + WHERE id = #{jamId} + AND is_delete IS NOT TRUE + """) + int updateVisibility(@Param("jamId") long jamId, @Param("isVisible") boolean isVisible); + + @Update(""" + UPDATE jams SET + is_delete = true, + updated_at = now() + WHERE id = #{jamId} + AND is_delete IS NOT TRUE + """) + int softDelete(long jamId); +} diff --git a/src/main/webapp/WEB-INF/views/admin-jam-list.jsp b/src/main/webapp/WEB-INF/views/admin-jam-list.jsp new file mode 100644 index 0000000..2447dc8 --- /dev/null +++ b/src/main/webapp/WEB-INF/views/admin-jam-list.jsp @@ -0,0 +1,434 @@ +<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %> +<%@ page import="java.util.List" %> +<%@ page import="com.pandoli365.bibimbap.data.JamData" %> +<%@ page import="org.springframework.web.util.HtmlUtils" %> +<% + String ctx = request.getContextPath(); + + Object rawJams = request.getAttribute("jams"); + List jams = rawJams instanceof List ? (List) rawJams : null; + + Object rawCsrf = request.getAttribute("csrfToken"); + String csrfToken = rawCsrf == null ? "" : String.valueOf(rawCsrf); + String csrfTokenHtml = HtmlUtils.htmlEscape(csrfToken); + // JS 문자열 컨텍스트용: 따옴표/역슬래시/스크립트 종료 시퀀스 차단 + String csrfTokenJs = csrfToken + .replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\"", "\\\"") + .replace("<", "\\u003C") + .replace(">", "\\u003E") + .replace("\r", "") + .replace("\n", ""); +%> + + + + + + + + 게임잼 관리 | bibimbap + + + + +
+
+

JAM ADMIN

+

게임잼 관리

+

게임잼을 생성하고 상태 전이, 가시성, 삭제를 관리합니다. 실제 권한 검증은 서버에서 수행됩니다.

+
+ +
+

신규 게임잼 생성

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+ +
+

게임잼 목록

+
+ + + + + + + + + + + + + <% + if (jams == null || jams.isEmpty()) { + %> + + + + <% + } else { + for (JamData jam : jams) { + if (jam == null || jam.getId() == null) { + continue; + } + String jamId = String.valueOf(jam.getId()); + String jamIdAttr = HtmlUtils.htmlEscape(jamId); + String jamTitle = HtmlUtils.htmlEscape(jam.getTitle() == null || jam.getTitle().isBlank() ? "(제목 없음)" : jam.getTitle()); + String jamSlug = HtmlUtils.htmlEscape(jam.getSlug() == null || jam.getSlug().isBlank() ? "-" : jam.getSlug()); + String rawStatus = jam.getStatus() == null ? "" : jam.getStatus(); + String jamStatus = HtmlUtils.htmlEscape(rawStatus.isBlank() ? "-" : rawStatus); + boolean visible = Boolean.TRUE.equals(jam.getIsVisible()); + String jamCreatedAt = HtmlUtils.htmlEscape(jam.getCreatedAt() == null ? "-" : String.valueOf(jam.getCreatedAt())); + %> + + + + + + + + + <% + } + } + %> + +
제목슬러그상태가시성생성일액션
등록된 잼이 없습니다.
<%= jamTitle %><%= jamSlug %><%= jamStatus %><%= visible ? "공개" : "비공개" %><%= jamCreatedAt %> +
+ + + + +
+
+
+
+
+ + + + diff --git a/src/main/webapp/WEB-INF/views/jam-detail.jsp b/src/main/webapp/WEB-INF/views/jam-detail.jsp new file mode 100644 index 0000000..75cde31 --- /dev/null +++ b/src/main/webapp/WEB-INF/views/jam-detail.jsp @@ -0,0 +1,459 @@ +<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %> +<%@ page import="java.util.List" %> +<%@ page import="com.pandoli365.bibimbap.data.JamData" %> +<%@ page import="com.pandoli365.bibimbap.data.JamEntryData" %> +<%@ page import="com.pandoli365.bibimbap.data.JamTeamData" %> +<%@ page import="org.springframework.web.util.HtmlUtils" %> +<% + String ctx = request.getContextPath(); + JamData jam = (JamData) request.getAttribute("jam"); + List entries = (List) request.getAttribute("entries"); + List teams = (List) request.getAttribute("teams"); + Object rawCsrf = request.getAttribute("csrfToken"); + String csrfToken = rawCsrf == null ? "" : String.valueOf(rawCsrf); + String csrfTokenHtml = HtmlUtils.htmlEscape(csrfToken); + // JS 문자열 컨텍스트용: 따옴표/역슬래시/스크립트 종료 시퀀스 차단 (admin-console.jsp 동형) + String csrfTokenJs = csrfToken + .replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\"", "\\\"") + .replace("<", "\\u003C") + .replace(">", "\\u003E") + .replace("\r", "") + .replace("\n", ""); +%> + + + + + + + + <%= jam == null ? "게임잼" : HtmlUtils.htmlEscape(jam.getTitle()) %> | bibimbap + + + + +
+ ← 게임잼 목록 +<% + if (jam == null) { +%> +
+

잼 정보를 찾을 수 없습니다.

+
+<% + } else { + String slugEsc = HtmlUtils.htmlEscape(jam.getSlug() == null ? "" : jam.getSlug()); + String status = jam.getStatus() == null ? "" : jam.getStatus(); + String description = jam.getDescription() == null ? "" : jam.getDescription(); + String discordUrl = jam.getDiscordUrl(); + String prizeInfo = jam.getPrizeInfo(); + String sponsorInfo = jam.getSponsorInfo(); +%> +
+
+<% + if (!status.isBlank()) { +%> + <%= HtmlUtils.htmlEscape(status) %> +<% + } +%> +
+

<%= HtmlUtils.htmlEscape(jam.getTitle() == null ? "" : jam.getTitle()) %>

+

<%= description.isBlank() ? "소개가 아직 없습니다." : HtmlUtils.htmlEscape(description) %>

+<% + if (discordUrl != null && !discordUrl.isBlank()) { +%> + +<% + } +%> +
+ +<% + if ((prizeInfo != null && !prizeInfo.isBlank()) || (sponsorInfo != null && !sponsorInfo.isBlank())) { +%> +
+

운영 안내

+<% + if (prizeInfo != null && !prizeInfo.isBlank()) { +%> +

상금/시상
<%= HtmlUtils.htmlEscape(prizeInfo) %>

+<% + } + if (sponsorInfo != null && !sponsorInfo.isBlank()) { +%> +

후원
<%= HtmlUtils.htmlEscape(sponsorInfo) %>

+<% + } +%> +
+<% + } +%> + +
+

출품작

+<% + if (entries == null || entries.isEmpty()) { +%> +

아직 출품작이 없습니다.

+<% + } else { +%> +
+<% + for (JamEntryData entry : entries) { + if (entry == null) { + continue; + } + String gameName = entry.getGameName() == null || entry.getGameName().isBlank() ? "이름 없는 게임" : entry.getGameName(); + String thumbnailUrl = entry.getThumbnailUrl(); + String entrantType = entry.getEntrantType(); + String entrantName = entry.getEntrantName(); + Long gameId = entry.getGameId(); + boolean hasGameLink = gameId != null; +%> +<% + if (hasGameLink) { +%> + +<% + } else { +%> +
+<% + } +%> +<% + if (thumbnailUrl != null && !thumbnailUrl.isBlank()) { +%> + +<% + } +%> +
+

<%= HtmlUtils.htmlEscape(gameName) %>

+<% + if (entrantName != null && !entrantName.isBlank()) { +%> +

<%= entrantType != null && !entrantType.isBlank() ? HtmlUtils.htmlEscape(entrantType) + " · " : "" %><%= HtmlUtils.htmlEscape(entrantName) %>

+<% + } else if (entrantType != null && !entrantType.isBlank()) { +%> +

<%= HtmlUtils.htmlEscape(entrantType) %>

+<% + } +%> +
+<% + if (hasGameLink) { +%> +
+<% + } else { +%> +
+<% + } +%> +<% + } +%> +
+<% + } +%> +
+ +<% + if (teams != null && !teams.isEmpty()) { +%> +
+

참가 팀

+
    +<% + for (JamTeamData team : teams) { + if (team == null) { + continue; + } + Integer memberCount = team.getMemberCount(); +%> +
  • + <%= HtmlUtils.htmlEscape(team.getName() == null ? "" : team.getName()) %> + 멤버 <%= memberCount == null ? 0 : memberCount.intValue() %>명 +
  • +<% + } +%> +
+
+<% + } +%> + +
+

출품하기

+
+ + + + +
+
+ +
+

팀 만들기

+
+ + + +
+
+<% + } +%> +
+ + + diff --git a/src/main/webapp/WEB-INF/views/jam-list.jsp b/src/main/webapp/WEB-INF/views/jam-list.jsp new file mode 100644 index 0000000..3f92841 --- /dev/null +++ b/src/main/webapp/WEB-INF/views/jam-list.jsp @@ -0,0 +1,189 @@ +<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %> +<%@ page import="java.util.List" %> +<%@ page import="java.net.URLEncoder" %> +<%@ page import="java.nio.charset.StandardCharsets" %> +<%@ page import="com.pandoli365.bibimbap.data.JamData" %> +<%@ page import="org.springframework.web.util.HtmlUtils" %> +<% + String ctx = request.getContextPath(); + List jams = (List) request.getAttribute("jams"); + Object rawNext = request.getAttribute("nextCursor"); + String nextCursor = rawNext == null ? null : String.valueOf(rawNext); +%> + + + + + + + 게임잼 | bibimbap + + + + +
+

게임잼

+<% + if (jams == null || jams.isEmpty()) { +%> +
진행 중인 게임잼이 없습니다.
+<% + } else { +%> +
+<% + for (JamData jam : jams) { + String title = jam.getTitle() == null ? "" : jam.getTitle(); + String status = jam.getStatus() == null ? "" : jam.getStatus(); + String description = jam.getDescription() == null ? "" : jam.getDescription(); + if (description.length() > 120) { + description = description.substring(0, 120) + "…"; + } + String slug = jam.getSlug() == null ? "" : jam.getSlug(); +%> + +<% + if (!status.isBlank()) { +%> + <%= HtmlUtils.htmlEscape(status) %> +<% + } +%> +

<%= HtmlUtils.htmlEscape(title) %>

+

<%= HtmlUtils.htmlEscape(description) %>

+
+<% + } +%> +
+<% + if (nextCursor != null) { + String encodedCursor = URLEncoder.encode(nextCursor, StandardCharsets.UTF_8); +%> + +<% + } + } +%> +
+ + + diff --git a/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java b/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java index 5146b96..0b994a7 100644 --- a/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java +++ b/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java @@ -5,6 +5,11 @@ import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper; import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper; import com.pandoli365.bibimbap.mapper.GameReviewsMapper; import com.pandoli365.bibimbap.mapper.GamesMapper; +import com.pandoli365.bibimbap.mapper.JamEntriesMapper; +import com.pandoli365.bibimbap.mapper.JamStatusLogMapper; +import com.pandoli365.bibimbap.mapper.JamTeamMembersMapper; +import com.pandoli365.bibimbap.mapper.JamTeamsMapper; +import com.pandoli365.bibimbap.mapper.JamsMapper; import com.pandoli365.bibimbap.mapper.PermissionsMapper; import com.pandoli365.bibimbap.mapper.RbacAuditMapper; import com.pandoli365.bibimbap.mapper.RecruitPostsMapper; @@ -57,6 +62,21 @@ class BibimbapApplicationTests { @MockBean private RbacAuditMapper rbacAuditMapper; + @MockBean + private JamsMapper jamsMapper; + + @MockBean + private JamEntriesMapper jamEntriesMapper; + + @MockBean + private JamTeamsMapper jamTeamsMapper; + + @MockBean + private JamTeamMembersMapper jamTeamMembersMapper; + + @MockBean + private JamStatusLogMapper jamStatusLogMapper; + @MockBean private PermissionGate permissionGate; diff --git a/src/test/java/com/pandoli365/bibimbap/controller/JamAdminControllerTest.java b/src/test/java/com/pandoli365/bibimbap/controller/JamAdminControllerTest.java new file mode 100644 index 0000000..0570a28 --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/controller/JamAdminControllerTest.java @@ -0,0 +1,278 @@ +package com.pandoli365.bibimbap.controller; + +import com.pandoli365.bibimbap.data.JamData; +import com.pandoli365.bibimbap.jam.JamLifecycle; +import com.pandoli365.bibimbap.jam.JamStatus; +import com.pandoli365.bibimbap.mapper.JamStatusLogMapper; +import com.pandoli365.bibimbap.mapper.JamsMapper; +import com.pandoli365.bibimbap.security.CsrfTokens; +import com.pandoli365.bibimbap.security.PermissionGate; +import com.pandoli365.bibimbap.security.PermissionKeys; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.Model; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JamAdminControllerTest { + + private static final String GAME_JAM_MANAGE = PermissionKeys.GAME_JAM_MANAGE.name(); + private static final long ACTOR_ID = 99L; + private static final long JAM_ID = 7L; + + @Mock + private JamsMapper jamsMapper; + + @Mock + private JamStatusLogMapper jamStatusLogMapper; + + @Mock + private PermissionGate gate; + + @Mock + private JamLifecycle jamLifecycle; + + // ---- VP-1: console 게이트 (미인증 → /login, 미인가 → /) ---- + + @Test + void consoleRedirectsToLoginWhenUnauthenticated() { + JamAdminController controller = controller(); + MockHttpSession session = managerSession(ACTOR_ID); + MockHttpServletRequest request = csrfPost(session); + Model model = new ExtendedModelMap(); + when(gate.isAuthenticated(session)).thenReturn(false); + + String view = controller.console(model, request, session); + + assertThat(view).isEqualTo("redirect:/login"); + verify(jamsMapper, never()).listAllForAdmin(); + } + + @Test + void consoleRedirectsToRootWhenLacksPermission() { + JamAdminController controller = controller(); + MockHttpSession session = managerSession(ACTOR_ID); + MockHttpServletRequest request = csrfPost(session); + Model model = new ExtendedModelMap(); + when(gate.isAuthenticated(session)).thenReturn(true); + when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(false); + + String view = controller.console(model, request, session); + + assertThat(view).isEqualTo("redirect:/"); + verify(jamsMapper, never()).listAllForAdmin(); + } + + @Test + void consoleRendersListWhenAuthorized() { + JamAdminController controller = controller(); + MockHttpSession session = managerSession(ACTOR_ID); + MockHttpServletRequest request = csrfPost(session); + Model model = new ExtendedModelMap(); + when(gate.isAuthenticated(session)).thenReturn(true); + when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(true); + when(jamsMapper.listAllForAdmin()).thenReturn(List.of(jam(JAM_ID, "RECRUIT"))); + + String view = controller.console(model, request, session); + + assertThat(view).isEqualTo("admin-jam-list"); + assertThat(model.getAttribute("jams")).isNotNull(); + assertThat(model.getAttribute("csrfToken")).isNotNull(); + verify(jamsMapper).listAllForAdmin(); + } + + // ---- VP-1: create 게이트 401/403 ---- + + @Test + void createReturns401WhenUnauthenticated() { + JamAdminController controller = controller(); + MockHttpSession session = managerSession(ACTOR_ID); + MockHttpServletRequest request = csrfPost(session); + when(gate.isAuthenticated(session)).thenReturn(false); + + ResponseEntity> response = + controller.create("게임잼", null, null, null, null, null, null, null, null, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(response.getBody()).containsEntry("status", HttpStatus.UNAUTHORIZED.value()); + verify(jamsMapper, never()).insertJam(any()); + } + + @Test + void createReturns403WhenLacksPermission() { + JamAdminController controller = controller(); + MockHttpSession session = managerSession(ACTOR_ID); + MockHttpServletRequest request = csrfPost(session); + when(gate.isAuthenticated(session)).thenReturn(true); + when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(false); + + ResponseEntity> response = + controller.create("게임잼", null, null, null, null, null, null, null, null, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).containsEntry("status", HttpStatus.FORBIDDEN.value()); + verify(jamsMapper, never()).insertJam(any()); + } + + // ---- VP-5: create CSRF 실패 (게이트 통과 후 매퍼 접근 전 차단) ---- + + @Test + void createRejectsMissingCsrfBeforeMapperAccess() { + JamAdminController controller = controller(); + MockHttpSession session = managerSession(ACTOR_ID); + MockHttpServletRequest request = noCsrfPost(session); + when(gate.isAuthenticated(session)).thenReturn(true); + when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(true); + + ResponseEntity> response = + controller.create("게임잼", null, null, null, null, null, null, null, null, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).containsEntry("status", 403); + verifyNoInteractions(jamsMapper); + verifyNoInteractions(jamStatusLogMapper); + } + + // ---- VP-2: create 성공 + 감사 로그 (from=null, to=RECRUIT) ---- + + @Test + void createInsertsJamAndAuditsRecruitTransition() { + JamAdminController controller = controller(); + MockHttpSession session = managerSession(ACTOR_ID); + MockHttpServletRequest request = csrfPost(session); + when(gate.isAuthenticated(session)).thenReturn(true); + when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(true); + doAnswer(inv -> { + inv.getArgument(0, JamData.class).setId(JAM_ID); + return 1; + }).when(jamsMapper).insertJam(any(JamData.class)); + + ResponseEntity> response = + controller.create("새 게임잼", null, null, null, null, null, null, null, null, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsEntry("status", HttpStatus.OK.value()); + assertThat(response.getBody()).containsEntry("jamId", JAM_ID); + assertThat(response.getBody().get("slug")).isNotNull(); + verify(jamsMapper).insertJam(any(JamData.class)); + verify(jamStatusLogMapper).insert(eq(JAM_ID), isNull(), eq("RECRUIT"), eq(ACTOR_ID), eq("MANUAL")); + } + + // ---- VP-2: changeStatus 허용 전이 + 감사 로그 (from=RECRUIT, to=DEV) ---- + + @Test + void changeStatusAppliesAllowedTransitionAndAudits() { + JamAdminController controller = controller(); + MockHttpSession session = managerSession(ACTOR_ID); + MockHttpServletRequest request = csrfPost(session); + when(gate.isAuthenticated(session)).thenReturn(true); + when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(true); + when(jamsMapper.getById(JAM_ID)).thenReturn(jam(JAM_ID, "RECRUIT")); + when(jamLifecycle.isAllowed(JamStatus.RECRUIT, JamStatus.DEV)).thenReturn(true); + when(jamLifecycle.isPeriodReady(eq(JamStatus.DEV), any(JamData.class))).thenReturn(true); + + ResponseEntity> response = + controller.changeStatus(JAM_ID, "DEV", request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsEntry("status", HttpStatus.OK.value()); + assertThat(response.getBody()).containsEntry("jamId", JAM_ID); + assertThat(response.getBody()).containsEntry("jamStatus", "DEV"); + verify(jamsMapper).updateStatus(eq(JAM_ID), eq("DEV")); + verify(jamStatusLogMapper).insert(eq(JAM_ID), eq("RECRUIT"), eq("DEV"), eq(ACTOR_ID), eq("MANUAL")); + } + + // ---- VP-2: changeStatus 거부 전이 (409, updateStatus 미호출) ---- + + @Test + void changeStatusReturns409WhenTransitionDisallowed() { + JamAdminController controller = controller(); + MockHttpSession session = managerSession(ACTOR_ID); + MockHttpServletRequest request = csrfPost(session); + when(gate.isAuthenticated(session)).thenReturn(true); + when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(true); + when(jamsMapper.getById(JAM_ID)).thenReturn(jam(JAM_ID, "RECRUIT")); + when(jamLifecycle.isAllowed(JamStatus.RECRUIT, JamStatus.EVAL)).thenReturn(false); + + ResponseEntity> response = + controller.changeStatus(JAM_ID, "EVAL", request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + verify(jamsMapper, never()).updateStatus(anyLong(), anyString()); + verify(jamStatusLogMapper, never()).insert(anyLong(), any(), anyString(), any(), anyString()); + } + + // ---- changeStatus 잼 없음 (404) ---- + + @Test + void changeStatusReturns404WhenJamMissing() { + JamAdminController controller = controller(); + MockHttpSession session = managerSession(ACTOR_ID); + MockHttpServletRequest request = csrfPost(session); + when(gate.isAuthenticated(session)).thenReturn(true); + when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(true); + when(jamsMapper.getById(JAM_ID)).thenReturn(null); + + ResponseEntity> response = + controller.changeStatus(JAM_ID, "DEV", request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + verify(jamsMapper, never()).updateStatus(anyLong(), anyString()); + } + + // ---- helpers ---- + + private JamAdminController controller() { + return new JamAdminController(jamsMapper, jamStatusLogMapper, gate, jamLifecycle); + } + + private JamData jam(long id, String status) { + JamData data = new JamData(); + data.setId(id); + data.setStatus(status); + data.setTitle("게임잼"); + data.setIsVisible(true); + return data; + } + + private MockHttpSession managerSession(long actorId) { + MockHttpSession session = new MockHttpSession(); + session.setAttribute("userId", actorId); + CsrfTokens.getOrCreate(session); + return session; + } + + private MockHttpServletRequest csrfPost(MockHttpSession session) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSession(session); + request.addHeader(CsrfTokens.HEADER_NAME, (String) session.getAttribute(CsrfTokens.SESSION_ATTRIBUTE)); + return request; + } + + private MockHttpServletRequest noCsrfPost(MockHttpSession session) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSession(session); + return request; + } +} diff --git a/src/test/java/com/pandoli365/bibimbap/controller/JamControllerTest.java b/src/test/java/com/pandoli365/bibimbap/controller/JamControllerTest.java new file mode 100644 index 0000000..16e420a --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/controller/JamControllerTest.java @@ -0,0 +1,358 @@ +package com.pandoli365.bibimbap.controller; + +import com.pandoli365.bibimbap.data.GameData; +import com.pandoli365.bibimbap.data.JamData; +import com.pandoli365.bibimbap.data.JamEntryData; +import com.pandoli365.bibimbap.data.JamTeamData; +import com.pandoli365.bibimbap.mapper.GamesMapper; +import com.pandoli365.bibimbap.mapper.JamEntriesMapper; +import com.pandoli365.bibimbap.mapper.JamTeamMembersMapper; +import com.pandoli365.bibimbap.mapper.JamTeamsMapper; +import com.pandoli365.bibimbap.mapper.JamsMapper; +import com.pandoli365.bibimbap.security.CsrfTokens; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.Model; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JamControllerTest { + + private static final long USER_ID = 7L; + private static final long GAME_ID = 55L; + private static final long JAM_ID = 3L; + private static final long TEAM_ID = 11L; + private static final String SLUG = "spring-jam"; + + @Mock + private JamsMapper jamsMapper; + + @Mock + private JamEntriesMapper jamEntriesMapper; + + @Mock + private JamTeamsMapper jamTeamsMapper; + + @Mock + private JamTeamMembersMapper jamTeamMembersMapper; + + @Mock + private GamesMapper gamesMapper; + + // ---- list (VP-4 keyset) ---- + + @Test + void listFirstPageWithoutNextCursorWhenUnderPageSize() { + JamController controller = controller(); + Model model = new ExtendedModelMap(); + when(jamsMapper.listVisibleKeyset(isNull(), isNull(), eq(21))).thenReturn(jams(20)); + + String view = controller.list(null, model); + + assertThat(view).isEqualTo("jam-list"); + assertThat(model.getAttribute("nextCursor")).isNull(); + @SuppressWarnings("unchecked") + List jams = (List) model.getAttribute("jams"); + assertThat(jams).hasSize(20); + } + + @Test + void listTrimsToPageSizeAndEmitsNextCursorWhenMoreExist() { + JamController controller = controller(); + Model model = new ExtendedModelMap(); + when(jamsMapper.listVisibleKeyset(isNull(), isNull(), eq(21))).thenReturn(jams(21)); + + String view = controller.list(null, model); + + assertThat(view).isEqualTo("jam-list"); + @SuppressWarnings("unchecked") + List jams = (List) model.getAttribute("jams"); + assertThat(jams).hasSize(20); + JamData last = jams.get(19); + String expectedCursor = last.getCreatedAt().toString() + "_" + last.getId(); + assertThat(model.getAttribute("nextCursor")).isEqualTo(expectedCursor); + } + + // ---- detail ---- + + @Test + void detailRedirectsWhenJamMissing() { + JamController controller = controller(); + when(jamsMapper.getBySlug(SLUG)).thenReturn(null); + + String view = controller.detail(SLUG, new ExtendedModelMap(), new MockHttpServletRequest()); + + assertThat(view).isEqualTo("redirect:/jams"); + } + + @Test + void detailRedirectsWhenJamNotVisible() { + JamController controller = controller(); + JamData jam = jam(JAM_ID, "RECRUIT", false); + when(jamsMapper.getBySlug(SLUG)).thenReturn(jam); + + String view = controller.detail(SLUG, new ExtendedModelMap(), new MockHttpServletRequest()); + + assertThat(view).isEqualTo("redirect:/jams"); + } + + @Test + void detailRendersVisibleJam() { + JamController controller = controller(); + JamData jam = jam(JAM_ID, "RECRUIT", true); + when(jamsMapper.getBySlug(SLUG)).thenReturn(jam); + when(jamEntriesMapper.listByJam(JAM_ID)).thenReturn(List.of()); + when(jamTeamsMapper.listByJam(JAM_ID)).thenReturn(List.of()); + Model model = new ExtendedModelMap(); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSession(new MockHttpSession()); + + String view = controller.detail(SLUG, model, request); + + assertThat(view).isEqualTo("jam-detail"); + assertThat(model.getAttribute("jam")).isSameAs(jam); + assertThat(model.getAttribute("entries")).isNotNull(); + assertThat(model.getAttribute("teams")).isNotNull(); + assertThat(model.getAttribute("csrfToken")).isNotNull(); + } + + // ---- submitEntry (VP-3 출품 무결성 + VP-5 CSRF) ---- + + @Test + void submitEntryRejectsMissingCsrf() { + JamController controller = controller(); + MockHttpSession session = userSession(USER_ID); + MockHttpServletRequest request = noCsrfPost(session); + + ResponseEntity> response = + controller.submitEntry(SLUG, GAME_ID, null, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + verify(jamEntriesMapper, never()).insert(any()); + } + + @Test + void submitEntryRejectsUnauthenticated() { + JamController controller = controller(); + MockHttpSession session = new MockHttpSession(); + CsrfTokens.getOrCreate(session); + MockHttpServletRequest request = csrfPost(session); + + ResponseEntity> response = + controller.submitEntry(SLUG, GAME_ID, null, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verify(jamEntriesMapper, never()).insert(any()); + } + + @Test + void submitEntrySucceedsForOwnedSoloGame() { + JamController controller = controller(); + MockHttpSession session = userSession(USER_ID); + MockHttpServletRequest request = csrfPost(session); + when(jamsMapper.getBySlug(SLUG)).thenReturn(jam(JAM_ID, "RECRUIT", true)); + when(gamesMapper.getGame(GAME_ID)).thenReturn(game(GAME_ID, USER_ID)); + when(jamEntriesMapper.exists(JAM_ID, GAME_ID)).thenReturn(false); + doAnswer(invocation -> { + JamEntryData entry = invocation.getArgument(0); + entry.setId(900L); + return 1; + }).when(jamEntriesMapper).insert(any(JamEntryData.class)); + + ResponseEntity> response = + controller.submitEntry(SLUG, GAME_ID, null, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsEntry("entryId", 900L); + verify(jamEntriesMapper).insert(any(JamEntryData.class)); + } + + @Test + void submitEntryRejectsNonOwnedSoloGame() { + JamController controller = controller(); + MockHttpSession session = userSession(USER_ID); + MockHttpServletRequest request = csrfPost(session); + when(jamsMapper.getBySlug(SLUG)).thenReturn(jam(JAM_ID, "RECRUIT", true)); + when(gamesMapper.getGame(GAME_ID)).thenReturn(game(GAME_ID, 99L)); + when(jamEntriesMapper.exists(JAM_ID, GAME_ID)).thenReturn(false); + + ResponseEntity> response = + controller.submitEntry(SLUG, GAME_ID, null, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY); + verify(jamEntriesMapper, never()).insert(any()); + } + + @Test + void submitEntryRejectsDuplicateEntry() { + JamController controller = controller(); + MockHttpSession session = userSession(USER_ID); + MockHttpServletRequest request = csrfPost(session); + when(jamsMapper.getBySlug(SLUG)).thenReturn(jam(JAM_ID, "RECRUIT", true)); + when(gamesMapper.getGame(GAME_ID)).thenReturn(game(GAME_ID, USER_ID)); + when(jamEntriesMapper.exists(JAM_ID, GAME_ID)).thenReturn(true); + + ResponseEntity> response = + controller.submitEntry(SLUG, GAME_ID, null, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + verify(jamEntriesMapper, never()).insert(any()); + } + + @Test + void submitEntryRejectsNonTeamMember() { + JamController controller = controller(); + MockHttpSession session = userSession(USER_ID); + MockHttpServletRequest request = csrfPost(session); + when(jamsMapper.getBySlug(SLUG)).thenReturn(jam(JAM_ID, "RECRUIT", true)); + when(gamesMapper.getGame(GAME_ID)).thenReturn(game(GAME_ID, USER_ID)); + when(jamEntriesMapper.exists(JAM_ID, GAME_ID)).thenReturn(false); + when(jamTeamMembersMapper.exists(TEAM_ID, USER_ID)).thenReturn(false); + + ResponseEntity> response = + controller.submitEntry(SLUG, GAME_ID, TEAM_ID, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY); + verify(jamEntriesMapper, never()).insert(any()); + } + + @Test + void submitEntryRejectsWhenJamNotInSubmittableState() { + JamController controller = controller(); + MockHttpSession session = userSession(USER_ID); + MockHttpServletRequest request = csrfPost(session); + when(jamsMapper.getBySlug(SLUG)).thenReturn(jam(JAM_ID, "EVAL", true)); + + ResponseEntity> response = + controller.submitEntry(SLUG, GAME_ID, null, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY); + verify(gamesMapper, never()).getGame(anyLong()); + verify(jamEntriesMapper, never()).insert(any()); + } + + // ---- submitTeam ---- + + @Test + void submitTeamSucceeds() { + JamController controller = controller(); + MockHttpSession session = userSession(USER_ID); + MockHttpServletRequest request = csrfPost(session); + when(jamsMapper.getBySlug(SLUG)).thenReturn(jam(JAM_ID, "RECRUIT", true)); + doAnswer(invocation -> { + JamTeamData team = invocation.getArgument(0); + team.setId(700L); + return 1; + }).when(jamTeamsMapper).insert(any(JamTeamData.class)); + + ResponseEntity> response = + controller.submitTeam(SLUG, "드림팀", request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsEntry("jamTeamId", 700L); + verify(jamTeamsMapper).insert(any(JamTeamData.class)); + } + + // ---- addMember ---- + + @Test + void addMemberRejectsNonOwner() { + JamController controller = controller(); + MockHttpSession session = userSession(2L); + MockHttpServletRequest request = csrfPost(session); + when(jamsMapper.getBySlug(SLUG)).thenReturn(jam(JAM_ID, "RECRUIT", true)); + when(jamTeamsMapper.getById(TEAM_ID)).thenReturn(team(TEAM_ID, 1L)); + + ResponseEntity> response = + controller.addMember(SLUG, TEAM_ID, USER_ID, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY); + verify(jamTeamMembersMapper, never()).insert(anyLong(), anyLong()); + } + + // ---- helpers ---- + + private JamController controller() { + return new JamController(jamsMapper, jamEntriesMapper, jamTeamsMapper, jamTeamMembersMapper, gamesMapper); + } + + private MockHttpSession userSession(long userId) { + MockHttpSession session = new MockHttpSession(); + session.setAttribute("userId", userId); + CsrfTokens.getOrCreate(session); + return session; + } + + private MockHttpServletRequest csrfPost(MockHttpSession session) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSession(session); + request.addHeader(CsrfTokens.HEADER_NAME, (String) session.getAttribute(CsrfTokens.SESSION_ATTRIBUTE)); + return request; + } + + private MockHttpServletRequest noCsrfPost(MockHttpSession session) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSession(session); + return request; + } + + private JamData jam(long id, String status, boolean visible) { + JamData jam = new JamData(); + jam.setId(id); + jam.setSlug(SLUG); + jam.setStatus(status); + jam.setIsVisible(visible); + jam.setCreatedAt(OffsetDateTime.parse("2026-06-01T00:00:00Z")); + return jam; + } + + private GameData game(long id, long userId) { + GameData game = new GameData(); + game.setId(id); + game.setUserId(userId); + return game; + } + + private JamTeamData team(long id, long ownerUserId) { + JamTeamData team = new JamTeamData(); + team.setId(id); + team.setOwnerUserId(ownerUserId); + return team; + } + + private List jams(int count) { + List jams = new ArrayList<>(count); + OffsetDateTime base = OffsetDateTime.parse("2026-06-01T00:00:00Z"); + for (int i = 0; i < count; i++) { + JamData jam = new JamData(); + jam.setId((long) (count - i)); + jam.setSlug("jam-" + i); + jam.setStatus("RECRUIT"); + jam.setIsVisible(true); + jam.setCreatedAt(base.minusDays(i)); + jams.add(jam); + } + return jams; + } +} diff --git a/src/test/java/com/pandoli365/bibimbap/jam/JamLifecycleTest.java b/src/test/java/com/pandoli365/bibimbap/jam/JamLifecycleTest.java new file mode 100644 index 0000000..6de843b --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/jam/JamLifecycleTest.java @@ -0,0 +1,86 @@ +package com.pandoli365.bibimbap.jam; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.pandoli365.bibimbap.data.JamData; +import java.time.OffsetDateTime; +import org.junit.jupiter.api.Test; + +class JamLifecycleTest { + + private final JamLifecycle lifecycle = new JamLifecycle(); + + @Test + void allowedTransitionsAreAccepted() { + assertTrue(lifecycle.isAllowed(JamStatus.RECRUIT, JamStatus.DEV)); + assertTrue(lifecycle.isAllowed(JamStatus.DEV, JamStatus.EVAL)); + assertTrue(lifecycle.isAllowed(JamStatus.DEV, JamStatus.RECRUIT)); + assertTrue(lifecycle.isAllowed(JamStatus.EVAL, JamStatus.CLOSED)); + assertTrue(lifecycle.isAllowed(JamStatus.EVAL, JamStatus.DEV)); + } + + @Test + void disallowedTransitionsAreRejected() { + assertFalse(lifecycle.isAllowed(JamStatus.RECRUIT, JamStatus.EVAL)); + assertFalse(lifecycle.isAllowed(JamStatus.RECRUIT, JamStatus.CLOSED)); + assertFalse(lifecycle.isAllowed(JamStatus.DEV, JamStatus.CLOSED)); + assertFalse(lifecycle.isAllowed(JamStatus.CLOSED, JamStatus.RECRUIT)); + assertFalse(lifecycle.isAllowed(JamStatus.CLOSED, JamStatus.DEV)); + assertFalse(lifecycle.isAllowed(JamStatus.CLOSED, JamStatus.EVAL)); + assertFalse(lifecycle.isAllowed(JamStatus.EVAL, JamStatus.RECRUIT)); + } + + @Test + void sameStateTransitionsAreRejected() { + assertFalse(lifecycle.isAllowed(JamStatus.RECRUIT, JamStatus.RECRUIT)); + assertFalse(lifecycle.isAllowed(JamStatus.DEV, JamStatus.DEV)); + assertFalse(lifecycle.isAllowed(JamStatus.EVAL, JamStatus.EVAL)); + assertFalse(lifecycle.isAllowed(JamStatus.CLOSED, JamStatus.CLOSED)); + } + + @Test + void nullArgumentsAreRejected() { + assertFalse(lifecycle.isAllowed(null, JamStatus.DEV)); + assertFalse(lifecycle.isAllowed(JamStatus.RECRUIT, null)); + } + + @Test + void periodReadyEnforcesEvalStartForEval() { + OffsetDateTime now = OffsetDateTime.now(); + assertTrue(lifecycle.isPeriodReady(JamStatus.EVAL, now, null)); + assertFalse(lifecycle.isPeriodReady(JamStatus.EVAL, null, null)); + } + + @Test + void periodReadyEnforcesEvalEndForClosed() { + OffsetDateTime now = OffsetDateTime.now(); + assertTrue(lifecycle.isPeriodReady(JamStatus.CLOSED, null, now)); + assertFalse(lifecycle.isPeriodReady(JamStatus.CLOSED, null, null)); + } + + @Test + void periodReadyAlwaysTrueForRecruitAndDev() { + assertTrue(lifecycle.isPeriodReady(JamStatus.RECRUIT, null, null)); + assertTrue(lifecycle.isPeriodReady(JamStatus.DEV, null, null)); + } + + @Test + void periodReadyOverloadDelegatesToJamGetters() { + OffsetDateTime now = OffsetDateTime.now(); + + JamData evalReady = new JamData(); + evalReady.setEvalStartAt(now); + assertTrue(lifecycle.isPeriodReady(JamStatus.EVAL, evalReady)); + + JamData evalMissing = new JamData(); + assertFalse(lifecycle.isPeriodReady(JamStatus.EVAL, evalMissing)); + + JamData closedReady = new JamData(); + closedReady.setEvalEndAt(now); + assertTrue(lifecycle.isPeriodReady(JamStatus.CLOSED, closedReady)); + + JamData closedMissing = new JamData(); + assertFalse(lifecycle.isPeriodReady(JamStatus.CLOSED, closedMissing)); + } +}