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 IF;
|
||||||
END
|
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
|
@Override
|
||||||
public void addInterceptors(InterceptorRegistry registry) {
|
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.GameReviewStatsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
|
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
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.PermissionsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.RbacAuditMapper;
|
import com.pandoli365.bibimbap.mapper.RbacAuditMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.RecruitPostsMapper;
|
import com.pandoli365.bibimbap.mapper.RecruitPostsMapper;
|
||||||
|
|
@ -57,6 +62,21 @@ class BibimbapApplicationTests {
|
||||||
@MockBean
|
@MockBean
|
||||||
private RbacAuditMapper rbacAuditMapper;
|
private RbacAuditMapper rbacAuditMapper;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private JamsMapper jamsMapper;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private JamEntriesMapper jamEntriesMapper;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private JamTeamsMapper jamTeamsMapper;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private JamTeamMembersMapper jamTeamMembersMapper;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private JamStatusLogMapper jamStatusLogMapper;
|
||||||
|
|
||||||
@MockBean
|
@MockBean
|
||||||
private PermissionGate permissionGate;
|
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