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:
이정수 2026-06-24 10:50:12 +09:00
parent 4168d4de52
commit ccf1e42430
23 changed files with 3699 additions and 1 deletions

View File

@ -366,3 +366,192 @@ BEGIN
END IF;
END
$$;
-- ---------------------------------------------------------------------------
-- 게임잼 W2-1: jams / jam_teams / jam_team_members / jam_entries / jam_status_log
-- (권위 DDL — docs/jam-ddl.sql 와 동일)
-- ---------------------------------------------------------------------------
-- W2-1 게임잼 엔티티/라이프사이클. 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
-- games 변경 없음(연결은 jam_entries 가 보유). 추가만, 파괴 없음.
-- ===========================================================================
-- 1) jams (게임잼 회차. 회차 독립 = 다중 인스턴스)
-- ===========================================================================
CREATE SEQUENCE IF NOT EXISTS "jams_id_seq";
CREATE TABLE IF NOT EXISTS "jams" (
"id" bigint DEFAULT nextval('jams_id_seq'::regclass) NOT NULL,
"slug" character varying(80) NOT NULL,
"title" character varying(200) NOT NULL,
"description" text,
"status" character varying(20) DEFAULT 'RECRUIT' NOT NULL,
"recruit_start_at" timestamp with time zone,
"dev_start_at" timestamp with time zone,
"eval_start_at" timestamp with time zone,
"eval_end_at" timestamp with time zone,
"discord_url" character varying(500),
"prize_info" text,
"sponsor_info" text,
"is_visible" boolean DEFAULT true NOT NULL,
"created_by" bigint,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"is_delete" boolean DEFAULT false NOT NULL,
PRIMARY KEY ("id")
);
ALTER SEQUENCE "jams_id_seq" OWNED BY "jams"."id";
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jams_status_check') THEN
ALTER TABLE "jams"
ADD CONSTRAINT "jams_status_check"
CHECK ("status" IN ('RECRUIT', 'DEV', 'EVAL', 'CLOSED'));
END IF;
END
$$;
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jams_slug_active"
ON "jams" ("slug") WHERE "is_delete" IS NOT TRUE;
CREATE INDEX IF NOT EXISTS "idx_jams_visible_keyset"
ON "jams" ("is_visible", "is_delete", "created_at" DESC, "id" DESC);
-- ===========================================================================
-- 2) jam_teams
-- ===========================================================================
CREATE SEQUENCE IF NOT EXISTS "jam_teams_id_seq";
CREATE TABLE IF NOT EXISTS "jam_teams" (
"id" bigint DEFAULT nextval('jam_teams_id_seq'::regclass) NOT NULL,
"jam_id" bigint NOT NULL,
"name" character varying(120) NOT NULL,
"owner_user_id" bigint NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"is_delete" boolean DEFAULT false NOT NULL,
PRIMARY KEY ("id")
);
ALTER SEQUENCE "jam_teams_id_seq" OWNED BY "jam_teams"."id";
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_teams_jam_id_fkey') THEN
ALTER TABLE "jam_teams" ADD CONSTRAINT "jam_teams_jam_id_fkey"
FOREIGN KEY ("jam_id") REFERENCES "jams" ("id");
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_teams_owner_fkey') THEN
ALTER TABLE "jam_teams" ADD CONSTRAINT "jam_teams_owner_fkey"
FOREIGN KEY ("owner_user_id") REFERENCES "users" ("id");
END IF;
END
$$;
CREATE INDEX IF NOT EXISTS "idx_jam_teams_jam" ON "jam_teams" ("jam_id");
-- ===========================================================================
-- 3) jam_team_members
-- ===========================================================================
CREATE SEQUENCE IF NOT EXISTS "jam_team_members_id_seq";
CREATE TABLE IF NOT EXISTS "jam_team_members" (
"id" bigint DEFAULT nextval('jam_team_members_id_seq'::regclass) NOT NULL,
"jam_team_id" bigint NOT NULL,
"user_id" bigint NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
PRIMARY KEY ("id")
);
ALTER SEQUENCE "jam_team_members_id_seq" OWNED BY "jam_team_members"."id";
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_team_members_team_fkey') THEN
ALTER TABLE "jam_team_members" ADD CONSTRAINT "jam_team_members_team_fkey"
FOREIGN KEY ("jam_team_id") REFERENCES "jam_teams" ("id");
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_team_members_user_fkey') THEN
ALTER TABLE "jam_team_members" ADD CONSTRAINT "jam_team_members_user_fkey"
FOREIGN KEY ("user_id") REFERENCES "users" ("id");
END IF;
END
$$;
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_team_members_team_user"
ON "jam_team_members" ("jam_team_id", "user_id");
-- ===========================================================================
-- 4) jam_entries (출품작 = 잼-게임 연결 조인. 평가 단위 = (jam_id, game_id) 활성 자연키)
-- ===========================================================================
CREATE SEQUENCE IF NOT EXISTS "jam_entries_id_seq";
CREATE TABLE IF NOT EXISTS "jam_entries" (
"id" bigint DEFAULT nextval('jam_entries_id_seq'::regclass) NOT NULL,
"jam_id" bigint NOT NULL,
"game_id" bigint NOT NULL,
"entrant_type" character varying(10) NOT NULL,
"entrant_user_id" bigint,
"jam_team_id" bigint,
"submitted_at" timestamp with time zone DEFAULT now() NOT NULL,
"is_delete" boolean DEFAULT false NOT NULL,
PRIMARY KEY ("id")
);
ALTER SEQUENCE "jam_entries_id_seq" OWNED BY "jam_entries"."id";
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_entries_jam_fkey') THEN
ALTER TABLE "jam_entries" ADD CONSTRAINT "jam_entries_jam_fkey"
FOREIGN KEY ("jam_id") REFERENCES "jams" ("id");
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_entries_game_fkey') THEN
ALTER TABLE "jam_entries" ADD CONSTRAINT "jam_entries_game_fkey"
FOREIGN KEY ("game_id") REFERENCES "games" ("id");
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_entries_team_fkey') THEN
ALTER TABLE "jam_entries" ADD CONSTRAINT "jam_entries_team_fkey"
FOREIGN KEY ("jam_team_id") REFERENCES "jam_teams" ("id");
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_entries_user_fkey') THEN
ALTER TABLE "jam_entries" ADD CONSTRAINT "jam_entries_user_fkey"
FOREIGN KEY ("entrant_user_id") REFERENCES "users" ("id");
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_entries_entrant_type_check') THEN
ALTER TABLE "jam_entries" ADD CONSTRAINT "jam_entries_entrant_type_check"
CHECK ("entrant_type" IN ('USER', 'TEAM'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_entries_entrant_xor_check') THEN
ALTER TABLE "jam_entries" ADD CONSTRAINT "jam_entries_entrant_xor_check"
CHECK (
("entrant_type" = 'USER' AND "entrant_user_id" IS NOT NULL AND "jam_team_id" IS NULL)
OR
("entrant_type" = 'TEAM' AND "jam_team_id" IS NOT NULL AND "entrant_user_id" IS NULL)
);
END IF;
END
$$;
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_entries_jam_game_active"
ON "jam_entries" ("jam_id", "game_id") WHERE "is_delete" IS NOT TRUE;
CREATE INDEX IF NOT EXISTS "idx_jam_entries_jam" ON "jam_entries" ("jam_id");
CREATE INDEX IF NOT EXISTS "idx_jam_entries_game" ON "jam_entries" ("game_id");
-- ===========================================================================
-- 5) jam_status_log
-- ===========================================================================
CREATE SEQUENCE IF NOT EXISTS "jam_status_log_id_seq";
CREATE TABLE IF NOT EXISTS "jam_status_log" (
"id" bigint DEFAULT nextval('jam_status_log_id_seq'::regclass) NOT NULL,
"jam_id" bigint NOT NULL,
"from_status" character varying(20),
"to_status" character varying(20) NOT NULL,
"actor_id" bigint,
"transition_type" character varying(10) DEFAULT 'MANUAL' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
PRIMARY KEY ("id")
);
ALTER SEQUENCE "jam_status_log_id_seq" OWNED BY "jam_status_log"."id";
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_status_log_jam_fkey') THEN
ALTER TABLE "jam_status_log" ADD CONSTRAINT "jam_status_log_jam_fkey"
FOREIGN KEY ("jam_id") REFERENCES "jams" ("id");
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_status_log_to_status_check') THEN
ALTER TABLE "jam_status_log" ADD CONSTRAINT "jam_status_log_to_status_check"
CHECK ("to_status" IN ('RECRUIT', 'DEV', 'EVAL', 'CLOSED'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_status_log_transition_type_check') THEN
ALTER TABLE "jam_status_log" ADD CONSTRAINT "jam_status_log_transition_type_check"
CHECK ("transition_type" IN ('MANUAL', 'AUTO'));
END IF;
END
$$;
CREATE INDEX IF NOT EXISTS "idx_jam_status_log_jam" ON "jam_status_log" ("jam_id", "created_at" DESC);

184
docs/jam-ddl.sql Normal file
View File

@ -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);

View File

@ -16,6 +16,9 @@ public class InterceptorConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rbacInterceptor).addPathPatterns("/admin/**");
// 관리(/admin/jams/**) ADMIN 전용 인터셉터 대신 JamAdminController GAME_JAM_MANAGE 게이트 헬퍼로 보호한다(SUBADMIN+ 통과 허용, D4-A).
registry.addInterceptor(rbacInterceptor)
.addPathPatterns("/admin/**")
.excludePathPatterns("/admin/jams/**");
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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());
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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) &lt; (#{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);
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -5,6 +5,11 @@ import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper;
import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper;
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.mapper.JamEntriesMapper;
import com.pandoli365.bibimbap.mapper.JamStatusLogMapper;
import com.pandoli365.bibimbap.mapper.JamTeamMembersMapper;
import com.pandoli365.bibimbap.mapper.JamTeamsMapper;
import com.pandoli365.bibimbap.mapper.JamsMapper;
import com.pandoli365.bibimbap.mapper.PermissionsMapper;
import com.pandoli365.bibimbap.mapper.RbacAuditMapper;
import com.pandoli365.bibimbap.mapper.RecruitPostsMapper;
@ -57,6 +62,21 @@ class BibimbapApplicationTests {
@MockBean
private RbacAuditMapper rbacAuditMapper;
@MockBean
private JamsMapper jamsMapper;
@MockBean
private JamEntriesMapper jamEntriesMapper;
@MockBean
private JamTeamsMapper jamTeamsMapper;
@MockBean
private JamTeamMembersMapper jamTeamMembersMapper;
@MockBean
private JamStatusLogMapper jamStatusLogMapper;
@MockBean
private PermissionGate permissionGate;

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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));
}
}