From 2cca6c72a6c4c8a3af977661588ec22662540e77 Mon Sep 17 00:00:00 2001 From: pandoli365 Date: Sat, 13 Jun 2026 14:43:33 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B9=84=EB=B9=95=EB=B0=A5=20=ED=8C=80?= =?UTF-8?q?=EC=9B=90=20=EB=AA=A8=EC=A7=91=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/recruit-posts-ddl.sql | 75 +++ .../controller/RecruitController.java | 186 ++++++++ .../bibimbap/data/RecruitPostData.java | 178 +++++++ .../bibimbap/mapper/RecruitPostsMapper.java | 116 +++++ src/main/webapp/WEB-INF/views/header.jsp | 37 ++ .../webapp/WEB-INF/views/recruit-detail.jsp | 295 ++++++++++++ .../webapp/WEB-INF/views/recruit-form.jsp | 445 ++++++++++++++++++ .../webapp/WEB-INF/views/recruit-list.jsp | 364 ++++++++++++++ 8 files changed, 1696 insertions(+) create mode 100644 docs/recruit-posts-ddl.sql create mode 100644 src/main/java/com/pandoli365/bibimbap/controller/RecruitController.java create mode 100644 src/main/java/com/pandoli365/bibimbap/data/RecruitPostData.java create mode 100644 src/main/java/com/pandoli365/bibimbap/mapper/RecruitPostsMapper.java create mode 100644 src/main/webapp/WEB-INF/views/recruit-detail.jsp create mode 100644 src/main/webapp/WEB-INF/views/recruit-form.jsp create mode 100644 src/main/webapp/WEB-INF/views/recruit-list.jsp diff --git a/docs/recruit-posts-ddl.sql b/docs/recruit-posts-ddl.sql new file mode 100644 index 0000000..552457a --- /dev/null +++ b/docs/recruit-posts-ddl.sql @@ -0,0 +1,75 @@ +-- Indie game team recruitment posts. +-- PostgreSQL DDL aligned with the existing bibimbap table style. + +CREATE SEQUENCE IF NOT EXISTS "recruit_posts_id_seq"; + +CREATE TABLE IF NOT EXISTS "recruit_posts" ( + "id" bigint DEFAULT nextval('recruit_posts_id_seq'::regclass) NOT NULL, + "user_id" bigint NOT NULL, + "project_name" character varying(200) NOT NULL, + "genre" character varying(80), + "summary" character varying(200) NOT NULL, + "role" character varying(30) NOT NULL, + "project_status" character varying(50) NOT NULL, + "participation_type" character varying(30) NOT NULL, + "expected_period" character varying(80), + "team_members" character varying(200), + "contact" character varying(200) NOT NULL, + "description" text, + "reference_url" character varying(500), + "deadline_at" timestamp with time zone, + "is_visible" boolean DEFAULT true NOT NULL, + "sort_order" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone, + "is_delete" boolean DEFAULT false NOT NULL, + PRIMARY KEY ("id") +); + +ALTER SEQUENCE "recruit_posts_id_seq" OWNED BY "recruit_posts"."id"; + +ALTER TABLE "recruit_posts" + ADD CONSTRAINT "recruit_posts_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users" ("id"); + +ALTER TABLE "recruit_posts" + ADD CONSTRAINT "recruit_posts_role_check" + CHECK ("role" IN ('기획', '아트', '프로그래머')); + +ALTER TABLE "recruit_posts" + ADD CONSTRAINT "recruit_posts_participation_type_check" + CHECK ("participation_type" IN ('취미', '수익쉐어', '유급', '게임잼')); + +CREATE INDEX IF NOT EXISTS "idx_recruit_posts_visible_order" + ON "recruit_posts" ("is_visible", "is_delete", "sort_order", "created_at" DESC, "id" DESC); + +CREATE INDEX IF NOT EXISTS "idx_recruit_posts_role" + ON "recruit_posts" ("role") + WHERE "is_delete" = false AND "is_visible" = true; + +CREATE INDEX IF NOT EXISTS "idx_recruit_posts_participation_type" + ON "recruit_posts" ("participation_type") + WHERE "is_delete" = false AND "is_visible" = true; + +COMMENT ON TABLE "recruit_posts" IS '인디게임 팀원 모집글'; +COMMENT ON COLUMN "recruit_posts"."id" IS '모집글 고유 ID'; +COMMENT ON COLUMN "recruit_posts"."user_id" IS '모집글 작성자 users.id'; +COMMENT ON COLUMN "recruit_posts"."project_name" IS '프로젝트 이름'; +COMMENT ON COLUMN "recruit_posts"."genre" IS '게임 장르'; +COMMENT ON COLUMN "recruit_posts"."summary" IS '한 줄 소개'; +COMMENT ON COLUMN "recruit_posts"."role" IS '모집 역할. 기획, 아트, 프로그래머'; +COMMENT ON COLUMN "recruit_posts"."project_status" IS '프로젝트 진행 상태'; +COMMENT ON COLUMN "recruit_posts"."participation_type" IS '참여 방식. 취미, 수익쉐어, 유급, 게임잼'; +COMMENT ON COLUMN "recruit_posts"."expected_period" IS '예상 작업 기간'; +COMMENT ON COLUMN "recruit_posts"."team_members" IS '현재 팀 구성'; +COMMENT ON COLUMN "recruit_posts"."contact" IS '연락 방법'; +COMMENT ON COLUMN "recruit_posts"."description" IS '상세 설명'; +COMMENT ON COLUMN "recruit_posts"."reference_url" IS '참고 링크'; +COMMENT ON COLUMN "recruit_posts"."deadline_at" IS '모집 마감 시각'; +COMMENT ON COLUMN "recruit_posts"."is_visible" IS '목록 공개 여부'; +COMMENT ON COLUMN "recruit_posts"."sort_order" IS '정렬 우선순위'; +COMMENT ON COLUMN "recruit_posts"."created_at" IS '모집글 생성 시각'; +COMMENT ON COLUMN "recruit_posts"."updated_at" IS '모집글 마지막 수정 시각'; +COMMENT ON COLUMN "recruit_posts"."deleted_at" IS '모집글 삭제 시각'; +COMMENT ON COLUMN "recruit_posts"."is_delete" IS '소프트 삭제 여부'; diff --git a/src/main/java/com/pandoli365/bibimbap/controller/RecruitController.java b/src/main/java/com/pandoli365/bibimbap/controller/RecruitController.java new file mode 100644 index 0000000..7f72d10 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/controller/RecruitController.java @@ -0,0 +1,186 @@ +package com.pandoli365.bibimbap.controller; + +import com.pandoli365.bibimbap.data.RecruitPostData; +import com.pandoli365.bibimbap.mapper.RecruitPostsMapper; +import jakarta.servlet.http.HttpSession; +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.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +@Controller +public class RecruitController { + + private static final Set ROLES = Set.of("기획", "아트", "프로그래머"); + private static final Set PARTICIPATION_TYPES = Set.of("취미", "수익쉐어", "유급", "게임잼"); + private static final Set PROJECT_STATUSES = Set.of("아이디어 단계", "프로토타입 개발 중", "데모 있음", "출시 준비 중"); + + private final RecruitPostsMapper recruitPostsMapper; + + public RecruitController(RecruitPostsMapper recruitPostsMapper) { + this.recruitPostsMapper = recruitPostsMapper; + } + + @GetMapping("/recruit") + public String recruitList(Model model) { + model.addAttribute("recruitPosts", recruitPostsMapper.getVisibleRecruitPosts()); + return "recruit-list"; + } + + @GetMapping("/recruit/new") + public String recruitForm(HttpSession session) { + if (sessionUserId(session) == null) { + return "redirect:/login"; + } + return "recruit-form"; + } + + @PostMapping("/recruit/new") + @Transactional + public ResponseEntity> createRecruitPost( + @RequestParam(name = "projectName", required = false) String projectName, + @RequestParam(name = "genre", required = false) String genre, + @RequestParam(name = "summary", required = false) String summary, + @RequestParam(name = "role", required = false) String role, + @RequestParam(name = "status", required = false) String projectStatus, + @RequestParam(name = "type", required = false) String participationType, + @RequestParam(name = "period", required = false) String expectedPeriod, + @RequestParam(name = "team", required = false) String teamMembers, + @RequestParam(name = "contact", required = false) String contact, + @RequestParam(name = "description", required = false) String description, + HttpSession session + ) { + Long userId = sessionUserId(session); + if (userId == null) { + return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + } + + String normalizedProjectName = trimToNull(projectName); + String normalizedSummary = trimToNull(summary); + String normalizedContact = trimToNull(contact); + String normalizedRole = trimToNull(role); + String normalizedProjectStatus = trimToNull(projectStatus); + String normalizedParticipationType = trimToNull(participationType); + + if (normalizedProjectName == null || normalizedProjectName.length() > 80) { + return response(HttpStatus.BAD_REQUEST, "프로젝트명을 80자 이내로 입력해 주세요."); + } + if (normalizedSummary == null || normalizedSummary.length() > 120) { + return response(HttpStatus.BAD_REQUEST, "한 줄 소개를 120자 이내로 입력해 주세요."); + } + if (normalizedContact == null || normalizedContact.length() > 100) { + return response(HttpStatus.BAD_REQUEST, "연락 방법을 100자 이내로 입력해 주세요."); + } + if (!ROLES.contains(normalizedRole)) { + return response(HttpStatus.BAD_REQUEST, "모집 역할을 확인해 주세요."); + } + if (!PROJECT_STATUSES.contains(normalizedProjectStatus)) { + return response(HttpStatus.BAD_REQUEST, "진행 상태를 확인해 주세요."); + } + if (!PARTICIPATION_TYPES.contains(normalizedParticipationType)) { + return response(HttpStatus.BAD_REQUEST, "참여 방식을 확인해 주세요."); + } + + String normalizedGenre = trimToEmpty(genre); + String normalizedExpectedPeriod = trimToEmpty(expectedPeriod); + String normalizedTeamMembers = trimToEmpty(teamMembers); + String normalizedDescription = trimToEmpty(description); + if (normalizedGenre.length() > 60) { + return response(HttpStatus.BAD_REQUEST, "게임 장르는 60자 이내로 입력해 주세요."); + } + if (normalizedExpectedPeriod.length() > 40) { + return response(HttpStatus.BAD_REQUEST, "예상 기간은 40자 이내로 입력해 주세요."); + } + if (normalizedTeamMembers.length() > 80) { + return response(HttpStatus.BAD_REQUEST, "현재 팀 구성은 80자 이내로 입력해 주세요."); + } + if (normalizedDescription.length() > 1200) { + return response(HttpStatus.BAD_REQUEST, "상세 설명은 1,200자 이내로 입력해 주세요."); + } + + RecruitPostData post = new RecruitPostData(); + post.setUserId(userId); + post.setProjectName(normalizedProjectName); + post.setGenre(normalizedGenre); + post.setSummary(normalizedSummary); + post.setRole(normalizedRole); + post.setProjectStatus(normalizedProjectStatus); + post.setParticipationType(normalizedParticipationType); + post.setExpectedPeriod(normalizedExpectedPeriod); + post.setTeamMembers(normalizedTeamMembers); + post.setContact(normalizedContact); + post.setDescription(normalizedDescription); + post.setReferenceUrl(""); + post.setVisible(true); + post.setSortOrder(recruitPostsMapper.nextSortOrder()); + + recruitPostsMapper.addRecruitPost(post); + if (post.getId() == null) { + return response(HttpStatus.INTERNAL_SERVER_ERROR, "모집글 등록 결과를 확인하지 못했습니다."); + } + + Map body = new LinkedHashMap<>(); + body.put("status", 200); + body.put("message", "모집글 등록이 완료되었습니다."); + body.put("recruitPostId", post.getId()); + body.put("location", "/recruit/" + post.getId()); + return ResponseEntity.ok(body); + } + + @GetMapping("/recruit/{id}") + public String recruitDetail(@PathVariable("id") long id, Model model) { + RecruitPostData post = recruitPostsMapper.getRecruitPost(id); + if (post == null || Boolean.FALSE.equals(post.getVisible())) { + return "redirect:/recruit"; + } + model.addAttribute("post", post); + return "recruit-detail"; + } + + 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 String trimToEmpty(String value) { + String text = trimToNull(value); + return text == null ? "" : text; + } + + private ResponseEntity> response(HttpStatus status, String message) { + Map body = new LinkedHashMap<>(); + body.put("status", status.value()); + body.put("message", message); + return ResponseEntity.status(status).body(body); + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/data/RecruitPostData.java b/src/main/java/com/pandoli365/bibimbap/data/RecruitPostData.java new file mode 100644 index 0000000..7a42190 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/RecruitPostData.java @@ -0,0 +1,178 @@ +package com.pandoli365.bibimbap.data; + +import java.time.OffsetDateTime; + +public class RecruitPostData { + + private Long id; + private Long userId; + private String creator; + private String projectName; + private String genre; + private String summary; + private String role; + private String projectStatus; + private String participationType; + private String expectedPeriod; + private String teamMembers; + private String contact; + private String description; + private String referenceUrl; + private OffsetDateTime deadlineAt; + private Boolean visible; + private Integer sortOrder; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getCreator() { + return creator; + } + + public void setCreator(String creator) { + this.creator = creator; + } + + public String getProjectName() { + return projectName; + } + + public void setProjectName(String projectName) { + this.projectName = projectName; + } + + public String getGenre() { + return genre; + } + + public void setGenre(String genre) { + this.genre = genre; + } + + public String getSummary() { + return summary; + } + + public void setSummary(String summary) { + this.summary = summary; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public String getProjectStatus() { + return projectStatus; + } + + public void setProjectStatus(String projectStatus) { + this.projectStatus = projectStatus; + } + + public String getParticipationType() { + return participationType; + } + + public void setParticipationType(String participationType) { + this.participationType = participationType; + } + + public String getExpectedPeriod() { + return expectedPeriod; + } + + public void setExpectedPeriod(String expectedPeriod) { + this.expectedPeriod = expectedPeriod; + } + + public String getTeamMembers() { + return teamMembers; + } + + public void setTeamMembers(String teamMembers) { + this.teamMembers = teamMembers; + } + + public String getContact() { + return contact; + } + + public void setContact(String contact) { + this.contact = contact; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getReferenceUrl() { + return referenceUrl; + } + + public void setReferenceUrl(String referenceUrl) { + this.referenceUrl = referenceUrl; + } + + public OffsetDateTime getDeadlineAt() { + return deadlineAt; + } + + public void setDeadlineAt(OffsetDateTime deadlineAt) { + this.deadlineAt = deadlineAt; + } + + public Boolean getVisible() { + return visible; + } + + public void setVisible(Boolean visible) { + this.visible = visible; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + 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; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/RecruitPostsMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/RecruitPostsMapper.java new file mode 100644 index 0000000..0d7b648 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/RecruitPostsMapper.java @@ -0,0 +1,116 @@ +package com.pandoli365.bibimbap.mapper; + +import com.pandoli365.bibimbap.data.RecruitPostData; +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 RecruitPostsMapper { + + @Select(""" + SELECT + r.id, + r.user_id AS userId, + u.display_name AS creator, + r.project_name AS projectName, + r.genre, + r.summary, + r.role, + r.project_status AS projectStatus, + r.participation_type AS participationType, + r.expected_period AS expectedPeriod, + r.team_members AS teamMembers, + r.contact, + r.description, + r.reference_url AS referenceUrl, + r.deadline_at AS deadlineAt, + r.is_visible AS visible, + r.sort_order AS sortOrder, + r.created_at AS createdAt, + r.updated_at AS updatedAt + FROM recruit_posts r + JOIN users u ON u.id = r.user_id + WHERE r.id = #{id} + AND r.is_delete IS NOT TRUE + AND u.is_delete IS NOT TRUE + """) + RecruitPostData getRecruitPost(@Param("id") long id); + + @Select(""" + SELECT + r.id, + r.user_id AS userId, + u.display_name AS creator, + r.project_name AS projectName, + r.genre, + r.summary, + r.role, + r.project_status AS projectStatus, + r.participation_type AS participationType, + r.expected_period AS expectedPeriod, + r.team_members AS teamMembers, + r.contact, + r.description, + r.reference_url AS referenceUrl, + r.deadline_at AS deadlineAt, + r.is_visible AS visible, + r.sort_order AS sortOrder, + r.created_at AS createdAt, + r.updated_at AS updatedAt + FROM recruit_posts r + JOIN users u ON u.id = r.user_id + WHERE r.is_visible IS NOT FALSE + AND r.is_delete IS NOT TRUE + AND u.is_delete IS NOT TRUE + ORDER BY r.sort_order ASC, r.created_at DESC, r.id DESC + """) + List getVisibleRecruitPosts(); + + @Insert(""" + INSERT INTO recruit_posts ( + user_id, + project_name, + genre, + summary, + role, + project_status, + participation_type, + expected_period, + team_members, + contact, + description, + reference_url, + is_visible, + sort_order + ) VALUES ( + #{userId}, + #{projectName}, + #{genre}, + #{summary}, + #{role}, + #{projectStatus}, + #{participationType}, + #{expectedPeriod}, + #{teamMembers}, + #{contact}, + #{description}, + #{referenceUrl}, + #{visible}, + #{sortOrder} + ) + """) + @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id") + int addRecruitPost(RecruitPostData post); + + @Select(""" + SELECT COALESCE(MAX(sort_order), 0) + 1 + FROM recruit_posts + WHERE is_delete IS NOT TRUE + """) + int nextSortOrder(); +} diff --git a/src/main/webapp/WEB-INF/views/header.jsp b/src/main/webapp/WEB-INF/views/header.jsp index 416bc19..075fc60 100644 --- a/src/main/webapp/WEB-INF/views/header.jsp +++ b/src/main/webapp/WEB-INF/views/header.jsp @@ -61,6 +61,28 @@ .site-header__brand:hover { color: var(--accent); } + .site-header__nav { + display: flex; + align-items: center; + gap: 0.35rem; + margin-left: auto; + } + .site-header__nav-link { + min-height: 2.25rem; + padding: 0 0.75rem; + border-radius: 10px; + display: inline-flex; + align-items: center; + color: var(--header-text); + font-size: 0.875rem; + font-weight: 800; + text-decoration: none; + white-space: nowrap; + } + .site-header__nav-link:hover { + background: var(--header-btn-hover); + color: var(--accent); + } .site-header__logo { height: 2.25rem; width: auto; @@ -156,6 +178,18 @@ width: 1.35rem; height: 1.35rem; } + @media (max-width: 520px) { + .site-header__inner { + gap: 0.5rem; + } + .site-header__brand span { + display: none; + } + .site-header__nav-link { + padding: 0 0.55rem; + font-size: 0.8125rem; + } + }