feat(jam): W2-1 게임잼 엔티티/라이프사이클 — jams/entries/teams + GAME_JAM_MANAGE enforcement + 공개 목록·상세
- 신규 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) <noreply@anthropic.com>
This commit is contained in:
parent
4168d4de52
commit
ccf1e42430
189
db/schema.sql
189
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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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/**");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> changeStatus(
|
||||
@PathVariable("jamId") long jamId,
|
||||
@RequestParam("toStatus") String toStatus,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
ResponseEntity<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> toggleVisibility(
|
||||
@PathVariable("jamId") long jamId,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
ResponseEntity<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> delete(
|
||||
@PathVariable("jamId") long jamId,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
ResponseEntity<Map<String, Object>> 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<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", HttpStatus.OK.value());
|
||||
body.put("message", "게임잼을 삭제했습니다.");
|
||||
body.put("jamId", jamId);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> 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<Map<String, Object>> response(HttpStatus status, String message) {
|
||||
Map<String, Object> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<JamData> 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<Map<String, Object>> 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<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", 200);
|
||||
body.put("entryId", entry.getId());
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@PostMapping("/jams/{slug}/teams")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> response(HttpStatus status, String message) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", status.value());
|
||||
body.put("message", message);
|
||||
return ResponseEntity.status(status).body(body);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<JamEntryData> 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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<JamTeamData> listByJam(long jamId);
|
||||
}
|
||||
|
|
@ -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("""
|
||||
<script>
|
||||
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_visible IS NOT FALSE
|
||||
AND is_delete IS NOT TRUE
|
||||
<if test="cursorCreatedAt != null and cursorId != null">
|
||||
AND (created_at, id) < (#{cursorCreatedAt}, #{cursorId})
|
||||
</if>
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT #{limit}
|
||||
</script>
|
||||
""")
|
||||
List<JamData> 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<JamData> 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);
|
||||
}
|
||||
|
|
@ -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<JamData> jams = rawJams instanceof List<?> ? (List<JamData>) 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", "");
|
||||
%>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="_csrf" content="<%= csrfTokenHtml %>">
|
||||
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
|
||||
<title>게임잼 관리 | bibimbap</title>
|
||||
<style>
|
||||
html {
|
||||
color-scheme: light;
|
||||
--surface: #faf8f5;
|
||||
--card-bg: #fff;
|
||||
--text: #1a1a1a;
|
||||
--text-muted: #5c5c5c;
|
||||
--accent: #e8a54b;
|
||||
--accent-soft: rgba(232, 165, 75, 0.16);
|
||||
--border: rgba(0, 0, 0, 0.08);
|
||||
--shadow: rgba(0, 0, 0, 0.06);
|
||||
--field-bg: #fff;
|
||||
--button-text: #1a1a1a;
|
||||
}
|
||||
html[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--surface: #121212;
|
||||
--card-bg: #1e1e1e;
|
||||
--text: #ece8e1;
|
||||
--text-muted: #a39e96;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--shadow: rgba(0, 0, 0, 0.35);
|
||||
--field-bg: #181818;
|
||||
--button-text: #1a1a1a;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
.admin-page {
|
||||
max-width: 78rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
|
||||
}
|
||||
.admin-hero {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.admin-hero__eyebrow {
|
||||
margin: 0 0 0.35rem;
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
.admin-hero h1 {
|
||||
margin: 0;
|
||||
font-size: 1.9rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.admin-hero p {
|
||||
margin: 0.45rem 0 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.admin-section {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--card-bg);
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
}
|
||||
.admin-section h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.admin-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.admin-field {
|
||||
display: grid;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.admin-field--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.admin-field label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.admin-field input,
|
||||
.admin-field textarea {
|
||||
box-sizing: border-box;
|
||||
padding: 0.7rem 0.875rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--field-bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.admin-field input {
|
||||
height: 3rem;
|
||||
}
|
||||
.admin-field textarea {
|
||||
min-height: 5rem;
|
||||
resize: vertical;
|
||||
}
|
||||
.admin-form-actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.admin-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 0.65rem 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.admin-table th {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.admin-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
.admin-btn {
|
||||
min-height: 2.25rem;
|
||||
padding: 0 0.85rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--card-bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.admin-btn:hover {
|
||||
border-color: rgba(232, 165, 75, 0.45);
|
||||
}
|
||||
.admin-btn--primary {
|
||||
border-color: transparent;
|
||||
background: var(--accent);
|
||||
color: var(--button-text);
|
||||
}
|
||||
.admin-btn--danger {
|
||||
border-color: rgba(200, 60, 60, 0.45);
|
||||
color: #c83c3c;
|
||||
}
|
||||
.admin-select {
|
||||
min-height: 2.25rem;
|
||||
padding: 0 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--field-bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.admin-muted {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.admin-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<jsp:include page="/WEB-INF/views/header.jsp"/>
|
||||
<main class="admin-page">
|
||||
<section class="admin-hero" aria-labelledby="jam-admin-title">
|
||||
<p class="admin-hero__eyebrow">JAM ADMIN</p>
|
||||
<h1 id="jam-admin-title">게임잼 관리</h1>
|
||||
<p>게임잼을 생성하고 상태 전이, 가시성, 삭제를 관리합니다. 실제 권한 검증은 서버에서 수행됩니다.</p>
|
||||
</section>
|
||||
|
||||
<section class="admin-section" aria-labelledby="jam-create-title">
|
||||
<h2 id="jam-create-title">신규 게임잼 생성</h2>
|
||||
<form id="jam-create-form" action="<%= ctx %>/admin/jams" method="post" autocomplete="off">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfTokenHtml %>" />
|
||||
<div class="admin-form-grid">
|
||||
<div class="admin-field admin-field--full">
|
||||
<label for="jam-title">제목 (필수)</label>
|
||||
<input type="text" id="jam-title" name="title" placeholder="게임잼 제목" required />
|
||||
</div>
|
||||
<div class="admin-field admin-field--full">
|
||||
<label for="jam-description">설명</label>
|
||||
<textarea id="jam-description" name="description" placeholder="게임잼 설명"></textarea>
|
||||
</div>
|
||||
<div class="admin-field">
|
||||
<label for="jam-recruit-start">모집 시작 (ISO)</label>
|
||||
<input type="datetime-local" id="jam-recruit-start" name="recruitStartAt" />
|
||||
</div>
|
||||
<div class="admin-field">
|
||||
<label for="jam-dev-start">개발 시작 (ISO)</label>
|
||||
<input type="datetime-local" id="jam-dev-start" name="devStartAt" />
|
||||
</div>
|
||||
<div class="admin-field">
|
||||
<label for="jam-eval-start">평가 시작 (ISO)</label>
|
||||
<input type="datetime-local" id="jam-eval-start" name="evalStartAt" />
|
||||
</div>
|
||||
<div class="admin-field">
|
||||
<label for="jam-eval-end">평가 종료 (ISO)</label>
|
||||
<input type="datetime-local" id="jam-eval-end" name="evalEndAt" />
|
||||
</div>
|
||||
<div class="admin-field admin-field--full">
|
||||
<label for="jam-discord-url">Discord URL</label>
|
||||
<input type="text" id="jam-discord-url" name="discordUrl" placeholder="https://discord.gg/..." />
|
||||
</div>
|
||||
<div class="admin-field admin-field--full">
|
||||
<label for="jam-prize-info">시상 정보</label>
|
||||
<textarea id="jam-prize-info" name="prizeInfo" placeholder="시상 정보"></textarea>
|
||||
</div>
|
||||
<div class="admin-field admin-field--full">
|
||||
<label for="jam-sponsor-info">후원 정보</label>
|
||||
<textarea id="jam-sponsor-info" name="sponsorInfo" placeholder="후원 정보"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-form-actions">
|
||||
<button class="admin-btn admin-btn--primary" type="submit">게임잼 생성</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-section" aria-labelledby="jam-list-title">
|
||||
<h2 id="jam-list-title">게임잼 목록</h2>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">제목</th>
|
||||
<th scope="col">슬러그</th>
|
||||
<th scope="col">상태</th>
|
||||
<th scope="col">가시성</th>
|
||||
<th scope="col">생성일</th>
|
||||
<th scope="col">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%
|
||||
if (jams == null || jams.isEmpty()) {
|
||||
%>
|
||||
<tr>
|
||||
<td colspan="6"><span class="admin-muted">등록된 잼이 없습니다.</span></td>
|
||||
</tr>
|
||||
<%
|
||||
} 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()));
|
||||
%>
|
||||
<tr>
|
||||
<td><%= jamTitle %></td>
|
||||
<td><%= jamSlug %></td>
|
||||
<td><span class="admin-status"><%= jamStatus %></span></td>
|
||||
<td><%= visible ? "공개" : "비공개" %></td>
|
||||
<td><%= jamCreatedAt %></td>
|
||||
<td>
|
||||
<div class="admin-actions">
|
||||
<select class="admin-select" data-status-select="<%= jamIdAttr %>" aria-label="전이 대상 상태">
|
||||
<option value="RECRUIT"<%= "RECRUIT".equals(rawStatus) ? " selected" : "" %>>RECRUIT</option>
|
||||
<option value="DEV"<%= "DEV".equals(rawStatus) ? " selected" : "" %>>DEV</option>
|
||||
<option value="EVAL"<%= "EVAL".equals(rawStatus) ? " selected" : "" %>>EVAL</option>
|
||||
<option value="CLOSED"<%= "CLOSED".equals(rawStatus) ? " selected" : "" %>>CLOSED</option>
|
||||
</select>
|
||||
<button class="admin-btn" type="button"
|
||||
data-action="status"
|
||||
data-jam-id="<%= jamIdAttr %>">전이</button>
|
||||
<button class="admin-btn" type="button"
|
||||
data-action="visibility"
|
||||
data-jam-id="<%= jamIdAttr %>"><%= visible ? "비공개로" : "공개로" %></button>
|
||||
<button class="admin-btn admin-btn--danger" type="button"
|
||||
data-action="delete"
|
||||
data-jam-id="<%= jamIdAttr %>">삭제</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<%
|
||||
}
|
||||
}
|
||||
%>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
||||
<script>
|
||||
(function () {
|
||||
var ctx = '<%= ctx %>';
|
||||
var CSRF_TOKEN = '<%= csrfTokenJs %>';
|
||||
|
||||
function notify(message) {
|
||||
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
|
||||
window.BibimbapModal.alert({ title: '게임잼 관리', message: message });
|
||||
return;
|
||||
}
|
||||
alert(message);
|
||||
}
|
||||
|
||||
function post(url, params) {
|
||||
var options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-Token': CSRF_TOKEN,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
};
|
||||
if (params) {
|
||||
options.headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
|
||||
options.body = params.toString();
|
||||
}
|
||||
return fetch(url, options);
|
||||
}
|
||||
|
||||
function handleResult(res) {
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
notify('요청을 처리하지 못했습니다. (상태 ' + res.status + ')');
|
||||
}
|
||||
|
||||
function handleError() {
|
||||
notify('요청 중 오류가 발생했습니다.');
|
||||
}
|
||||
|
||||
function transitionStatus(jamId) {
|
||||
var select = document.querySelector('[data-status-select="' + jamId + '"]');
|
||||
var toStatus = select ? select.value : '';
|
||||
if (!toStatus) {
|
||||
notify('전이할 상태를 선택해 주세요.');
|
||||
return;
|
||||
}
|
||||
var params = new URLSearchParams();
|
||||
params.set('toStatus', toStatus);
|
||||
post(ctx + '/admin/jams/' + encodeURIComponent(jamId) + '/status', params)
|
||||
.then(handleResult)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
function toggleVisibility(jamId) {
|
||||
post(ctx + '/admin/jams/' + encodeURIComponent(jamId) + '/visibility')
|
||||
.then(handleResult)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
function removeJam(jamId) {
|
||||
post(ctx + '/admin/jams/' + encodeURIComponent(jamId) + '/delete')
|
||||
.then(handleResult)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
// data-* 속성 + 위임 핸들러 (inline 핸들러에 사용자 데이터 삽입 금지)
|
||||
document.addEventListener('click', function (ev) {
|
||||
var btn = ev.target.closest('[data-action]');
|
||||
if (!btn) {
|
||||
return;
|
||||
}
|
||||
var action = btn.getAttribute('data-action');
|
||||
var jamId = btn.getAttribute('data-jam-id');
|
||||
if (!jamId) {
|
||||
return;
|
||||
}
|
||||
if (action === 'status') {
|
||||
transitionStatus(jamId);
|
||||
} else if (action === 'visibility') {
|
||||
toggleVisibility(jamId);
|
||||
} else if (action === 'delete') {
|
||||
removeJam(jamId);
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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<JamEntryData> entries = (List<JamEntryData>) request.getAttribute("entries");
|
||||
List<JamTeamData> teams = (List<JamTeamData>) 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", "");
|
||||
%>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="_csrf" content="<%= csrfTokenHtml %>">
|
||||
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
|
||||
<title><%= jam == null ? "게임잼" : HtmlUtils.htmlEscape(jam.getTitle()) %> | bibimbap</title>
|
||||
<style>
|
||||
html {
|
||||
color-scheme: light;
|
||||
--surface: #faf8f5;
|
||||
--card-bg: #fff;
|
||||
--text: #1a1a1a;
|
||||
--text-muted: #5c5c5c;
|
||||
--accent: #e8a54b;
|
||||
--accent-soft: rgba(232, 165, 75, 0.16);
|
||||
--border: rgba(0, 0, 0, 0.08);
|
||||
--shadow: rgba(0, 0, 0, 0.06);
|
||||
--field-bg: #fff;
|
||||
--button-text: #1a1a1a;
|
||||
}
|
||||
html[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--surface: #121212;
|
||||
--card-bg: #1e1e1e;
|
||||
--text: #ece8e1;
|
||||
--text-muted: #a39e96;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--shadow: rgba(0, 0, 0, 0.35);
|
||||
--field-bg: #181818;
|
||||
--button-text: #1a1a1a;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
.detail-page {
|
||||
max-width: 64rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
|
||||
}
|
||||
.detail-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 2.25rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
}
|
||||
.detail-back:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.detail-hero {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--card-bg);
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
}
|
||||
.detail-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.detail-badge {
|
||||
min-height: 1.85rem;
|
||||
padding: 0 0.65rem;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
.detail-hero h1 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.detail-hero p {
|
||||
margin: 0.65rem 0 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
.detail-button {
|
||||
min-height: 2.75rem;
|
||||
padding: 0 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--card-bg);
|
||||
color: var(--text);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 900;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.detail-button--primary {
|
||||
border-color: transparent;
|
||||
background: var(--accent);
|
||||
color: var(--button-text);
|
||||
}
|
||||
.detail-section {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--card-bg);
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
}
|
||||
.detail-section h2 {
|
||||
margin: 0 0 0.9rem;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.detail-section p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.entry-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
.entry-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.entry-card img {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.entry-card__body {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.entry-card__name {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 900;
|
||||
color: var(--text);
|
||||
}
|
||||
.entry-card__meta {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.team-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.team-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.team-list__name {
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
}
|
||||
.team-list__count {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.jam-form {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.jam-form label {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.jam-form input {
|
||||
min-height: 2.6rem;
|
||||
padding: 0 0.7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--field-bg);
|
||||
color: var(--text);
|
||||
font-size: 0.95rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.jam-form button {
|
||||
justify-self: start;
|
||||
}
|
||||
.empty-note {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.detail-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
.detail-button {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<jsp:include page="/WEB-INF/views/header.jsp"/>
|
||||
<main class="detail-page">
|
||||
<a class="detail-back" href="<%= ctx %>/jams">← 게임잼 목록</a>
|
||||
<%
|
||||
if (jam == null) {
|
||||
%>
|
||||
<section class="detail-section">
|
||||
<p class="empty-note">잼 정보를 찾을 수 없습니다.</p>
|
||||
</section>
|
||||
<%
|
||||
} 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();
|
||||
%>
|
||||
<section class="detail-hero" aria-labelledby="jam-title">
|
||||
<div class="detail-badges">
|
||||
<%
|
||||
if (!status.isBlank()) {
|
||||
%>
|
||||
<span class="detail-badge"><%= HtmlUtils.htmlEscape(status) %></span>
|
||||
<%
|
||||
}
|
||||
%>
|
||||
</div>
|
||||
<h1 id="jam-title"><%= HtmlUtils.htmlEscape(jam.getTitle() == null ? "" : jam.getTitle()) %></h1>
|
||||
<p><%= description.isBlank() ? "소개가 아직 없습니다." : HtmlUtils.htmlEscape(description) %></p>
|
||||
<%
|
||||
if (discordUrl != null && !discordUrl.isBlank()) {
|
||||
%>
|
||||
<div class="detail-actions">
|
||||
<a class="detail-button detail-button--primary" href="<%= HtmlUtils.htmlEscape(discordUrl) %>" target="_blank" rel="noopener noreferrer">Discord 참여</a>
|
||||
</div>
|
||||
<%
|
||||
}
|
||||
%>
|
||||
</section>
|
||||
|
||||
<%
|
||||
if ((prizeInfo != null && !prizeInfo.isBlank()) || (sponsorInfo != null && !sponsorInfo.isBlank())) {
|
||||
%>
|
||||
<section class="detail-section" aria-labelledby="jam-ops">
|
||||
<h2 id="jam-ops">운영 안내</h2>
|
||||
<%
|
||||
if (prizeInfo != null && !prizeInfo.isBlank()) {
|
||||
%>
|
||||
<p><strong>상금/시상</strong><br><%= HtmlUtils.htmlEscape(prizeInfo) %></p>
|
||||
<%
|
||||
}
|
||||
if (sponsorInfo != null && !sponsorInfo.isBlank()) {
|
||||
%>
|
||||
<p style="margin-top:0.75rem;"><strong>후원</strong><br><%= HtmlUtils.htmlEscape(sponsorInfo) %></p>
|
||||
<%
|
||||
}
|
||||
%>
|
||||
</section>
|
||||
<%
|
||||
}
|
||||
%>
|
||||
|
||||
<section class="detail-section" aria-labelledby="jam-entries">
|
||||
<h2 id="jam-entries">출품작</h2>
|
||||
<%
|
||||
if (entries == null || entries.isEmpty()) {
|
||||
%>
|
||||
<p class="empty-note">아직 출품작이 없습니다.</p>
|
||||
<%
|
||||
} else {
|
||||
%>
|
||||
<div class="entry-grid">
|
||||
<%
|
||||
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) {
|
||||
%>
|
||||
<a class="entry-card" href="<%= ctx %>/game/<%= gameId %>">
|
||||
<%
|
||||
} else {
|
||||
%>
|
||||
<div class="entry-card">
|
||||
<%
|
||||
}
|
||||
%>
|
||||
<%
|
||||
if (thumbnailUrl != null && !thumbnailUrl.isBlank()) {
|
||||
%>
|
||||
<img src="<%= HtmlUtils.htmlEscape(thumbnailUrl) %>" alt="">
|
||||
<%
|
||||
}
|
||||
%>
|
||||
<div class="entry-card__body">
|
||||
<p class="entry-card__name"><%= HtmlUtils.htmlEscape(gameName) %></p>
|
||||
<%
|
||||
if (entrantName != null && !entrantName.isBlank()) {
|
||||
%>
|
||||
<p class="entry-card__meta"><%= entrantType != null && !entrantType.isBlank() ? HtmlUtils.htmlEscape(entrantType) + " · " : "" %><%= HtmlUtils.htmlEscape(entrantName) %></p>
|
||||
<%
|
||||
} else if (entrantType != null && !entrantType.isBlank()) {
|
||||
%>
|
||||
<p class="entry-card__meta"><%= HtmlUtils.htmlEscape(entrantType) %></p>
|
||||
<%
|
||||
}
|
||||
%>
|
||||
</div>
|
||||
<%
|
||||
if (hasGameLink) {
|
||||
%>
|
||||
</a>
|
||||
<%
|
||||
} else {
|
||||
%>
|
||||
</div>
|
||||
<%
|
||||
}
|
||||
%>
|
||||
<%
|
||||
}
|
||||
%>
|
||||
</div>
|
||||
<%
|
||||
}
|
||||
%>
|
||||
</section>
|
||||
|
||||
<%
|
||||
if (teams != null && !teams.isEmpty()) {
|
||||
%>
|
||||
<section class="detail-section" aria-labelledby="jam-teams">
|
||||
<h2 id="jam-teams">참가 팀</h2>
|
||||
<ul class="team-list">
|
||||
<%
|
||||
for (JamTeamData team : teams) {
|
||||
if (team == null) {
|
||||
continue;
|
||||
}
|
||||
Integer memberCount = team.getMemberCount();
|
||||
%>
|
||||
<li>
|
||||
<span class="team-list__name"><%= HtmlUtils.htmlEscape(team.getName() == null ? "" : team.getName()) %></span>
|
||||
<span class="team-list__count">멤버 <%= memberCount == null ? 0 : memberCount.intValue() %>명</span>
|
||||
</li>
|
||||
<%
|
||||
}
|
||||
%>
|
||||
</ul>
|
||||
</section>
|
||||
<%
|
||||
}
|
||||
%>
|
||||
|
||||
<section class="detail-section" aria-labelledby="jam-entry-form">
|
||||
<h2 id="jam-entry-form">출품하기</h2>
|
||||
<form class="jam-form" method="post" action="<%= ctx %>/jams/<%= slugEsc %>/entries">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfTokenHtml %>">
|
||||
<label>
|
||||
게임 ID
|
||||
<input type="number" name="gameId" min="1" required>
|
||||
</label>
|
||||
<label>
|
||||
팀 ID (개인 출품이면 비워두세요)
|
||||
<input type="number" name="jamTeamId" min="1">
|
||||
</label>
|
||||
<button type="submit" class="detail-button detail-button--primary">출품 등록</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="detail-section" aria-labelledby="jam-team-form">
|
||||
<h2 id="jam-team-form">팀 만들기</h2>
|
||||
<form class="jam-form" method="post" action="<%= ctx %>/jams/<%= slugEsc %>/teams">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfTokenHtml %>">
|
||||
<label>
|
||||
팀 이름
|
||||
<input type="text" name="name" maxlength="100" required>
|
||||
</label>
|
||||
<button type="submit" class="detail-button detail-button--primary">팀 생성</button>
|
||||
</form>
|
||||
</section>
|
||||
<%
|
||||
}
|
||||
%>
|
||||
</main>
|
||||
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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<JamData> jams = (List<JamData>) request.getAttribute("jams");
|
||||
Object rawNext = request.getAttribute("nextCursor");
|
||||
String nextCursor = rawNext == null ? null : String.valueOf(rawNext);
|
||||
%>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
|
||||
<title>게임잼 | bibimbap</title>
|
||||
<style>
|
||||
html {
|
||||
color-scheme: light;
|
||||
--surface: #faf8f5;
|
||||
--card-bg: #fff;
|
||||
--text: #1a1a1a;
|
||||
--text-muted: #5c5c5c;
|
||||
--accent: #e8a54b;
|
||||
--accent-soft: rgba(232, 165, 75, 0.16);
|
||||
--border: rgba(0, 0, 0, 0.08);
|
||||
--shadow: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
html[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--surface: #121212;
|
||||
--card-bg: #1e1e1e;
|
||||
--text: #ece8e1;
|
||||
--text-muted: #a39e96;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--shadow: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
.jam-page {
|
||||
max-width: 64rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
|
||||
}
|
||||
.jam-page > h1 {
|
||||
margin: 0 0 1.25rem;
|
||||
font-size: 2rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.jam-empty {
|
||||
padding: 2rem 1.25rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--card-bg);
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
font-weight: 800;
|
||||
}
|
||||
.jam-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.jam-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--card-bg);
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.jam-card:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.jam-badge {
|
||||
align-self: flex-start;
|
||||
min-height: 1.85rem;
|
||||
padding: 0 0.65rem;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
.jam-card h2 {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.jam-card p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.jam-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.jam-more a {
|
||||
min-height: 2.75rem;
|
||||
padding: 0 1.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--card-bg);
|
||||
color: var(--text);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 900;
|
||||
text-decoration: none;
|
||||
}
|
||||
.jam-more a:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<jsp:include page="/WEB-INF/views/header.jsp"/>
|
||||
<main class="jam-page">
|
||||
<h1>게임잼</h1>
|
||||
<%
|
||||
if (jams == null || jams.isEmpty()) {
|
||||
%>
|
||||
<div class="jam-empty">진행 중인 게임잼이 없습니다.</div>
|
||||
<%
|
||||
} else {
|
||||
%>
|
||||
<div class="jam-grid">
|
||||
<%
|
||||
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();
|
||||
%>
|
||||
<a class="jam-card" href="<%= ctx %>/jams/<%= HtmlUtils.htmlEscape(slug) %>">
|
||||
<%
|
||||
if (!status.isBlank()) {
|
||||
%>
|
||||
<span class="jam-badge"><%= HtmlUtils.htmlEscape(status) %></span>
|
||||
<%
|
||||
}
|
||||
%>
|
||||
<h2><%= HtmlUtils.htmlEscape(title) %></h2>
|
||||
<p><%= HtmlUtils.htmlEscape(description) %></p>
|
||||
</a>
|
||||
<%
|
||||
}
|
||||
%>
|
||||
</div>
|
||||
<%
|
||||
if (nextCursor != null) {
|
||||
String encodedCursor = URLEncoder.encode(nextCursor, StandardCharsets.UTF_8);
|
||||
%>
|
||||
<div class="jam-more">
|
||||
<a href="<%= ctx %>/jams?cursor=<%= encodedCursor %>">더 보기</a>
|
||||
</div>
|
||||
<%
|
||||
}
|
||||
}
|
||||
%>
|
||||
</main>
|
||||
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<JamData> jams = (List<JamData>) 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<JamData> jams = (List<JamData>) 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<JamData> jams(int count) {
|
||||
List<JamData> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue