From 3508a4b3bb10108aab0cb0fcbe94b985669ee253 Mon Sep 17 00:00:00 2001 From: pandoli365 Date: Sun, 3 May 2026 17:56:40 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=9E=91=EC=97=85=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/db-update-query-generator.md | 97 ++++ docs/user-signup-schema.md | 183 ++++++ pom.xml | 12 +- .../bibimbap/controller/WebMvcController.java | 11 +- .../controller/api/GameController.java | 3 +- .../controller/api/UserController.java | 323 +++++++++++ .../bibimbap/data/GameCommentData.java | 61 ++ .../pandoli365/bibimbap/data/GameData.java | 115 ++++ .../bibimbap/data/GameLikeData.java | 43 ++ .../bibimbap/data/UserAuthIdentityData.java | 106 ++++ .../pandoli365/bibimbap/data/UserData.java | 88 +++ .../pandoli365/bibimbap/game/GameCatalog.java | 76 +-- .../bibimbap/mapper/GameCommentsMapper.java | 49 ++ .../bibimbap/mapper/GameLikesMapper.java | 44 ++ .../bibimbap/mapper/GamesMapper.java | 68 +++ .../mapper/UserAuthIdentitiesMapper.java | 91 +++ .../bibimbap/mapper/UsersMapper.java | 62 +++ src/main/webapp/WEB-INF/views/game-detail.jsp | 2 +- src/main/webapp/WEB-INF/views/header.jsp | 3 +- src/main/webapp/WEB-INF/views/login.jsp | 319 +++++++++++ src/main/webapp/WEB-INF/views/modal.jsp | 128 +++++ src/main/webapp/WEB-INF/views/signup.jsp | 333 +++++++++++ .../bibimbap/DbUpdateQueryGeneratorTest.java | 527 ++++++++++++++++++ 23 files changed, 2667 insertions(+), 77 deletions(-) create mode 100644 docs/db-update-query-generator.md create mode 100644 docs/user-signup-schema.md create mode 100644 src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java create mode 100644 src/main/java/com/pandoli365/bibimbap/data/GameCommentData.java create mode 100644 src/main/java/com/pandoli365/bibimbap/data/GameData.java create mode 100644 src/main/java/com/pandoli365/bibimbap/data/GameLikeData.java create mode 100644 src/main/java/com/pandoli365/bibimbap/data/UserAuthIdentityData.java create mode 100644 src/main/java/com/pandoli365/bibimbap/data/UserData.java create mode 100644 src/main/java/com/pandoli365/bibimbap/mapper/GameCommentsMapper.java create mode 100644 src/main/java/com/pandoli365/bibimbap/mapper/GameLikesMapper.java create mode 100644 src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java create mode 100644 src/main/java/com/pandoli365/bibimbap/mapper/UserAuthIdentitiesMapper.java create mode 100644 src/main/java/com/pandoli365/bibimbap/mapper/UsersMapper.java create mode 100644 src/main/webapp/WEB-INF/views/login.jsp create mode 100644 src/main/webapp/WEB-INF/views/modal.jsp create mode 100644 src/main/webapp/WEB-INF/views/signup.jsp create mode 100644 src/test/java/com/pandoli365/bibimbap/DbUpdateQueryGeneratorTest.java diff --git a/docs/db-update-query-generator.md b/docs/db-update-query-generator.md new file mode 100644 index 0000000..4d75fb5 --- /dev/null +++ b/docs/db-update-query-generator.md @@ -0,0 +1,97 @@ +# DB 업데이트 쿼리 생성기 사용법 + +`DbUpdateQueryGeneratorTest`는 `dev` 스키마와 `live` 스키마의 테이블 구조를 비교해서, `live`에 필요한 DDL 쿼리를 생성하는 테스트 유틸이다. + +현재 설정은 코드에 하드코딩되어 있다. + +```java +select = dev +update = live +``` + +## 생성되는 파일 + +테스트를 실행하면 아래 파일이 생성된다. + +```text +src/test/db/dev-to-live-update.sql +``` + +이 파일에는 `live`에 적용할 수 있는 테이블 생성 쿼리가 저장된다. + +## 생성되는 쿼리 + +`dev`에는 있지만 `live`에는 없는 테이블이 있으면 다음 정보를 포함한 쿼리를 만든다. + +- `CREATE SEQUENCE IF NOT EXISTS` +- `CREATE TABLE` +- 컬럼 타입 +- `NOT NULL` +- `DEFAULT` +- `PRIMARY KEY` +- 컬럼 `COMMENT` + +예시: + +```sql +CREATE SEQUENCE IF NOT EXISTS "users_id_seq"; +CREATE TABLE "users" ( + "id" bigint DEFAULT nextval('users_id_seq'::regclass) NOT NULL, + "display_name" character varying(80) NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY ("id") +); +COMMENT ON COLUMN "users"."id" IS '사용자 고유 ID'; +``` + +## 생성하지 않는 쿼리 + +데이터 복제 쿼리는 생성하지 않는다. + +아래와 같은 쿼리는 만들지 않는다. + +```sql +INSERT INTO "games" (...) SELECT ... FROM "dev"."games"; +UPDATE "games" SET ...; +SELECT setval(...); +``` + +즉, 이 유틸은 데이터 복사가 아니라 스키마 구조 업데이트용이다. + +## 실행 방법 + +IntelliJ에서 실행할 때: + +1. `src/test/java/com/pandoli365/bibimbap/DbUpdateQueryGeneratorTest.java`를 연다. +2. `printRequiredUpdateQueries()` 테스트를 실행한다. +3. 콘솔에 `SQL file saved: ...` 메시지가 나오는지 확인한다. +4. `src/test/db/dev-to-live-update.sql` 파일을 열어 생성된 SQL을 확인한다. + +Maven으로 실행할 때: + +```powershell +$env:JAVA_HOME='C:\Users\acst0\.jdks\azul-21.0.10' +.\mvnw.cmd -Dtest=DbUpdateQueryGeneratorTest test +``` + +## 적용 방법 + +생성된 SQL은 바로 운영 DB에 적용하지 말고 먼저 내용을 확인한다. + +확인할 항목: + +- 생성 대상 테이블이 맞는지 +- `DEFAULT nextval(...)` 시퀀스 이름이 의도한 이름인지 +- `NOT NULL`, `DEFAULT`, `PRIMARY KEY`가 맞는지 +- 컬럼 코멘트가 깨지지 않았는지 + +문제가 없으면 `live` DB에 접속한 상태에서 `src/test/db/dev-to-live-update.sql`의 내용을 실행한다. + +## 주의사항 + +- 이 유틸은 DB에 직접 변경을 적용하지 않는다. +- 테스트 실행 시 DB에는 읽기 쿼리만 수행한다. +- 생성된 SQL에는 `"live".` 스키마 prefix를 붙이지 않는다. +- 따라서 SQL을 실행할 때는 반드시 `live` 스키마가 기본 스키마로 잡힌 연결에서 실행해야 한다. +- `dev` DB 접속 정보는 `src/main/resources/dev/db.properties`를 사용한다. +- `live` DB 접속 정보는 `src/main/resources/live/db.properties`를 사용한다. diff --git a/docs/user-signup-schema.md b/docs/user-signup-schema.md new file mode 100644 index 0000000..5040fbb --- /dev/null +++ b/docs/user-signup-schema.md @@ -0,0 +1,183 @@ +# 회원가입 정보 구조 + +이 문서는 `users`와 `user_auth_identities` 테이블 구조를 기준으로, 회원가입 시 필요한 정보와 저장 방식을 정리한다. + +## 기본 개념 + +`users`는 서비스 내부의 사용자 계정이다. + +`user_auth_identities`는 사용자가 어떤 방식으로 로그인했는지를 저장한다. 예를 들어 게스트 로그인, 구글 로그인, 카카오 로그인은 모두 이 테이블에 저장된다. + +하나의 `users` 계정에는 여러 로그인 방식이 연결될 수 있다. + +예시: + +```text +users.id = 1 +- guest +- google +- kakao +``` + +단, 현재 구조에서는 같은 로그인 제공자는 하나만 연결할 수 있다. + +예를 들어 하나의 사용자 계정에 구글 계정 2개를 동시에 연결할 수는 없다. + +## 회원가입 공통 입력값 + +회원가입 요청은 로그인 제공자와 무관하게 아래 정보를 기준으로 처리한다. + +| 필드 | 필수 | 설명 | +| --- | --- | --- | +| `provider` | 필수 | 로그인 제공자. `guest`, `google`, `email`, `kakao`, `naver`, `github`, `apple` 중 하나 | +| `providerUserId` | 필수 | 로그인 제공자가 내려준 사용자 고유 ID | +| `displayName` | 필수 | 서비스에서 표시할 사용자 이름 | +| `email` | 선택 | 로그인 제공자에서 받은 이메일 | +| `avatarUrl` | 선택 | 로그인 제공자에서 받은 프로필 이미지 URL | + +## 게스트 회원가입 + +게스트 회원가입은 외부 제공자가 없기 때문에 서버가 `providerUserId`를 생성한다. + +추천 입력값: + +```json +{ + "provider": "guest", + "displayName": "익명" +} +``` + +서버 처리: + +| 저장 위치 | 값 | +| --- | --- | +| `users.display_name` | 요청의 `displayName`, 없으면 `익명` | +| `users.canonical_email` | `null` | +| `users.avatar_url` | `null` | +| `users.role` | `USER` | +| `users.status` | `ACTIVE` | +| `user_auth_identities.provider` | `guest` | +| `user_auth_identities.provider_user_id` | 서버가 생성한 게스트 ID | +| `user_auth_identities.email` | `null` | +| `user_auth_identities.display_name` | 요청의 `displayName`, 없으면 `익명` | +| `user_auth_identities.avatar_url` | `null` | + +게스트 ID는 UUID 같은 충돌 가능성이 낮은 값으로 생성하는 것을 권장한다. + +예시: + +```text +guest:550e8400-e29b-41d4-a716-446655440000 +``` + +## 소셜 회원가입 + +소셜 회원가입은 클라이언트가 소셜 로그인 완료 후 받은 사용자 정보를 서버에 전달하거나, 서버가 토큰을 검증한 뒤 사용자 정보를 조회해서 저장한다. + +추천 입력값: + +```json +{ + "provider": "google", + "providerUserId": "109876543210123456789", + "displayName": "홍길동", + "email": "user@example.com", + "avatarUrl": "https://example.com/avatar.png" +} +``` + +서버 처리: + +| 저장 위치 | 값 | +| --- | --- | +| `users.display_name` | 요청의 `displayName` | +| `users.canonical_email` | 요청의 `email` | +| `users.avatar_url` | 요청의 `avatarUrl` | +| `users.role` | `USER` | +| `users.status` | `ACTIVE` | +| `user_auth_identities.provider` | 요청의 `provider` | +| `user_auth_identities.provider_user_id` | 요청의 `providerUserId` | +| `user_auth_identities.email` | 요청의 `email` | +| `user_auth_identities.display_name` | 요청의 `displayName` | +| `user_auth_identities.avatar_url` | 요청의 `avatarUrl` | + +## 이메일 기준 처리 + +`users.canonical_email`은 대표 이메일이다. + +소셜 로그인 제공자가 이메일을 내려주면 `canonical_email`에 저장할 수 있다. + +주의할 점: + +- 이메일이 없는 제공자도 있을 수 있다. +- 이메일 인증 여부가 불명확한 경우 곧바로 계정 병합 기준으로 쓰면 위험할 수 있다. +- 같은 이메일로 이미 가입된 사용자가 있더라도 자동 병합은 신중하게 처리해야 한다. + +## 중복 가입 판단 + +회원가입 또는 로그인 시 먼저 `user_auth_identities`에서 아래 조건으로 기존 연결 정보를 찾는다. + +```sql +select * +from user_auth_identities +where provider = :provider + and provider_user_id = :providerUserId; +``` + +결과가 있으면 신규 회원가입이 아니라 기존 사용자 로그인으로 처리한다. + +결과가 없으면 신규 `users`를 생성하고, 이어서 `user_auth_identities`를 생성한다. + +## 계정 연결 + +이미 로그인한 사용자가 추가 소셜 계정을 연결하는 경우에는 새 `users`를 만들지 않는다. + +대신 현재 로그인한 `users.id`로 `user_auth_identities`만 추가한다. + +예시: + +```text +현재 로그인 사용자: users.id = 1 +추가 연결 요청: google + +처리 결과: +user_auth_identities.user_id = 1 +user_auth_identities.provider = 'google' +``` + +현재 구조에서는 같은 사용자에게 같은 provider를 중복 연결할 수 없다. + +## 추천 API 형태 + +### 게스트 회원가입 + +```http +POST /api/auth/guest +Content-Type: application/json +``` + +```json +{ + "displayName": "익명" +} +``` + +### 소셜 회원가입 또는 로그인 + +```http +POST /api/auth/social +Content-Type: application/json +``` + +```json +{ + "provider": "google", + "providerUserId": "109876543210123456789", + "displayName": "홍길동", + "email": "user@example.com", + "avatarUrl": "https://example.com/avatar.png" +} +``` + +실제 구현에서는 클라이언트가 넘긴 `providerUserId`를 그대로 신뢰하기보다, 가능하면 서버에서 소셜 로그인 토큰을 검증한 뒤 provider 사용자 ID를 확정하는 방식을 권장한다. diff --git a/pom.xml b/pom.xml index 826cd78..9df1a5d 100644 --- a/pom.xml +++ b/pom.xml @@ -27,6 +27,7 @@ UTF-8 UTF-8 3.5.14-SNAPSHOT + 3.0.5 1.18.44 3.4.1 3.14.1 @@ -53,11 +54,20 @@ org.springframework.boot spring-boot-starter-web + + org.mybatis.spring.boot + mybatis-spring-boot-starter + ${mybatis-spring-boot.version} + + + org.postgresql + postgresql + runtime + org.apache.tomcat.embed tomcat-embed-jasper - org.projectlombok lombok diff --git a/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java b/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java index 241df06..c62e8b4 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java @@ -5,6 +5,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import org.springframework.boot.web.servlet.error.ErrorController; import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -27,9 +28,9 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController { return mv; } - @RequestMapping("/{pageName}") + @GetMapping("/{pageName}") public ModelAndView mainView(@PathVariable("pageName") String pageName, - @RequestParam(required = false) String id, + @RequestParam(name = "id", required = false) String id, HttpSession session, HttpServletRequest request) { ModelAndView mv = new ModelAndView(); @@ -44,6 +45,12 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController { mv.addObject("statusCode", status); } break; + case "login": + mv.setViewName("/login"); + break; + case "signup": + mv.setViewName("/signup"); + break; default: mv.setViewName("/index"); break; diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java index 98757a0..e40718a 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java @@ -26,8 +26,7 @@ public class GameController { model.addAttribute("likeCountFormatted", String.format("%,d", GameCatalog.LIKE_COUNTS[idx])); model.addAttribute("creatorNote", GameCatalog.CREATOR_NOTES[idx]); model.addAttribute("gitUrl", GameCatalog.GIT_URLS[idx]); - /* 데모: 공통 플레이스홀더. 실제 빌드는 static/webgl/game-{id}/ 에 두고 아래 한 줄을 webglUrlForGame(id) 로 바꾸면 됩니다. */ - model.addAttribute("webglUrl", "/webgl/placeholder/index.html"); + model.addAttribute("webglUrl", webglUrlForGame(id)); model.addAttribute("webglDeployPath", webglUrlForGame(id)); return "game-detail"; } diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java new file mode 100644 index 0000000..544d0ed --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java @@ -0,0 +1,323 @@ +package com.pandoli365.bibimbap.controller.api; + +import com.pandoli365.bibimbap.data.UserAuthIdentityData; +import com.pandoli365.bibimbap.data.UserData; +import com.pandoli365.bibimbap.mapper.UserAuthIdentitiesMapper; +import com.pandoli365.bibimbap.mapper.UsersMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +@Controller +public class UserController { + + private static final String PROVIDER_EMAIL = "email"; + private static final String ROLE_USER = "USER"; + private static final String STATUS_ACTIVE = "ACTIVE"; + private static final int PASSWORD_MIN_LENGTH = 8; + private static final int PASSWORD_ITERATIONS = 210_000; + private static final int PASSWORD_KEY_LENGTH = 256; + private static final int PASSWORD_SALT_BYTES = 16; + private static final int DEFAULT_SESSION_SECONDS = 60 * 30; + private static final int REMEMBER_SESSION_SECONDS = 60 * 60 * 24 * 30; + + private final UsersMapper usersMapper; + private final UserAuthIdentitiesMapper userAuthIdentitiesMapper; + private final SecureRandom secureRandom = new SecureRandom(); + + public UserController(UsersMapper usersMapper, UserAuthIdentitiesMapper userAuthIdentitiesMapper) { + this.usersMapper = usersMapper; + this.userAuthIdentitiesMapper = userAuthIdentitiesMapper; + } + + @PostMapping("/signup") + @Transactional + public ResponseEntity signup( + @RequestParam(name = "displayName") String displayName, + @RequestParam(name = "email") String email, + @RequestParam(name = "password") String password, + @RequestParam(name = "passwordConfirm") String passwordConfirm, + @RequestParam(name = "termsAccepted", required = false) String termsAccepted, + HttpServletRequest request + ) { + boolean json = wantsJson(request); + String normalizedEmail = normalizeEmail(email); + String normalizedDisplayName = normalizeDisplayName(displayName, null); + + if (normalizedEmail == null + || normalizedDisplayName == null + || !isUsablePassword(password) + || !password.equals(passwordConfirm) + || termsAccepted == null) { + return signupError(json, request, "invalid", HttpStatus.BAD_REQUEST, "입력값을 다시 확인해 주세요."); + } + + UserAuthIdentityData existingIdentity = userAuthIdentitiesMapper.getUserAuthIdentityByProvider( + PROVIDER_EMAIL, + normalizedEmail + ); + if (existingIdentity != null) { + return signupError(json, request, "duplicate", HttpStatus.CONFLICT, "이미 가입된 이메일입니다."); + } + + try { + OffsetDateTime now = now(); + UserData user = createUser(normalizedDisplayName, normalizedEmail, null, now); + createIdentity( + user.getId(), + PROVIDER_EMAIL, + normalizedEmail, + normalizedEmail, + hashPassword(password), + normalizedDisplayName, + null, + now + ); + + if (json) { + return ResponseEntity.ok(new AuthResult(200, "계정 생성이 완료되었습니다.")); + } + return redirect(request, "/signup?created=1"); + } catch (DataIntegrityViolationException e) { + return signupError(json, request, "duplicate", HttpStatus.CONFLICT, "이미 가입된 이메일입니다."); + } + } + + @PostMapping("/login") + @Transactional + public ResponseEntity login( + @RequestParam(name = "email", required = false) String email, + @RequestParam(name = "password", required = false) String password, + @RequestParam(name = "remember", required = false) String remember, + HttpServletRequest request + ) { + boolean json = wantsJson(request); + String normalizedEmail = normalizeEmail(email); + if (normalizedEmail == null || password == null || password.isBlank()) { + return authError(json, request, "/login", "invalid", HttpStatus.UNAUTHORIZED, "이메일 또는 비밀번호를 확인해 주세요."); + } + + UserAuthIdentityData identity = userAuthIdentitiesMapper.getUserAuthIdentityByProvider( + PROVIDER_EMAIL, + normalizedEmail + ); + if (identity == null || identity.getPasswordHash() == null || !verifyPassword(password, identity.getPasswordHash())) { + return authError(json, request, "/login", "invalid", HttpStatus.UNAUTHORIZED, "이메일 또는 비밀번호를 확인해 주세요."); + } + + UserData user = usersMapper.getUser(identity.getUserId()); + if (user == null || !STATUS_ACTIVE.equals(user.getStatus())) { + return authError(json, request, "/login", "inactive", HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다."); + } + + OffsetDateTime loginAt = now(); + user.setLastLoginAt(loginAt); + identity.setLastLoginAt(loginAt); + usersMapper.updateUser(user); + userAuthIdentitiesMapper.updateUserAuthIdentity(identity); + + HttpSession session = request.getSession(); + request.changeSessionId(); + applyRememberOption(session, remember); + saveLoginSession(session, user, identity); + + if (json) { + return ResponseEntity.ok(new AuthResult(200, "로그인되었습니다.")); + } + return redirect(request, "/"); + } + + @PostMapping("/logout") + public String logout(HttpSession session) { + session.invalidate(); + return "redirect:/"; + } + + private UserData createUser(String displayName, String canonicalEmail, String avatarUrl, OffsetDateTime loginAt) { + UserData user = new UserData(); + user.setDisplayName(displayName); + user.setCanonicalEmail(canonicalEmail); + user.setAvatarUrl(avatarUrl); + user.setRole(ROLE_USER); + user.setStatus(STATUS_ACTIVE); + user.setLastLoginAt(loginAt); + usersMapper.addUser(user); + return user; + } + + private UserAuthIdentityData createIdentity( + Long userId, + String provider, + String providerUserId, + String email, + String passwordHash, + String displayName, + String avatarUrl, + OffsetDateTime loginAt + ) { + UserAuthIdentityData identity = new UserAuthIdentityData(); + identity.setUserId(userId); + identity.setProvider(provider); + identity.setProviderUserId(providerUserId); + identity.setEmail(email); + identity.setPasswordHash(passwordHash); + identity.setDisplayName(displayName); + identity.setAvatarUrl(avatarUrl); + identity.setLastLoginAt(loginAt); + userAuthIdentitiesMapper.addUserAuthIdentity(identity); + return identity; + } + + private void applyRememberOption(HttpSession session, String remember) { + session.setMaxInactiveInterval(isChecked(remember) ? REMEMBER_SESSION_SECONDS : DEFAULT_SESSION_SECONDS); + } + + private boolean isChecked(String value) { + return "true".equalsIgnoreCase(value) || "on".equalsIgnoreCase(value); + } + + private void saveLoginSession(HttpSession session, UserData user, UserAuthIdentityData identity) { + session.setAttribute("id", user.getId()); + session.setAttribute("userId", user.getId()); + session.setAttribute("displayName", user.getDisplayName()); + session.setAttribute("email", user.getCanonicalEmail()); + session.setAttribute("avatarUrl", user.getAvatarUrl()); + session.setAttribute("role", user.getRole()); + session.setAttribute("status", user.getStatus()); + session.setAttribute("authProvider", identity.getProvider()); + session.setAttribute("authIdentityId", identity.getId()); + + Map account = new LinkedHashMap<>(); + account.put("id", user.getId()); + account.put("displayName", user.getDisplayName()); + account.put("email", user.getCanonicalEmail()); + account.put("avatarUrl", user.getAvatarUrl()); + account.put("role", user.getRole()); + account.put("status", user.getStatus()); + account.put("authProvider", identity.getProvider()); + account.put("authIdentityId", identity.getId()); + session.setAttribute("account", account); + } + + private String normalizeEmail(String email) { + if (email == null) { + return null; + } + String normalized = email.trim().toLowerCase(Locale.ROOT); + return normalized.isBlank() ? null : normalized; + } + + private String normalizeDisplayName(String displayName, String defaultValue) { + String normalized = displayName == null ? "" : displayName.trim(); + if (normalized.isBlank()) { + normalized = defaultValue == null ? "" : defaultValue; + } + if (normalized.isBlank()) { + return null; + } + return normalized.length() > 32 ? normalized.substring(0, 32) : normalized; + } + + private boolean isUsablePassword(String password) { + return password != null && password.length() >= PASSWORD_MIN_LENGTH; + } + + private String hashPassword(String password) { + byte[] salt = new byte[PASSWORD_SALT_BYTES]; + secureRandom.nextBytes(salt); + byte[] hash = pbkdf2(password.toCharArray(), salt, PASSWORD_ITERATIONS, PASSWORD_KEY_LENGTH); + return "pbkdf2_sha256$" + PASSWORD_ITERATIONS + "$" + + Base64.getEncoder().encodeToString(salt) + "$" + + Base64.getEncoder().encodeToString(hash); + } + + private boolean verifyPassword(String password, String passwordHash) { + String[] parts = passwordHash.split("\\$"); + if (parts.length != 4 || !"pbkdf2_sha256".equals(parts[0])) { + return false; + } + + try { + int iterations = Integer.parseInt(parts[1]); + byte[] salt = Base64.getDecoder().decode(parts[2]); + byte[] expected = Base64.getDecoder().decode(parts[3]); + byte[] actual = pbkdf2(password.toCharArray(), salt, iterations, expected.length * 8); + return MessageDigest.isEqual(expected, actual); + } catch (IllegalArgumentException e) { + return false; + } + } + + private byte[] pbkdf2(char[] password, byte[] salt, int iterations, int keyLength) { + try { + PBEKeySpec spec = new PBEKeySpec(password, salt, iterations, keyLength); + SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); + return factory.generateSecret(spec).getEncoded(); + } catch (Exception e) { + throw new IllegalStateException("Cannot process password", e); + } + } + + private OffsetDateTime now() { + return OffsetDateTime.now(ZoneOffset.UTC); + } + + private boolean wantsJson(HttpServletRequest request) { + String accept = request.getHeader(HttpHeaders.ACCEPT); + String requestedWith = request.getHeader("X-Requested-With"); + return (accept != null && accept.contains("application/json")) + || "XMLHttpRequest".equalsIgnoreCase(requestedWith); + } + + private ResponseEntity signupError( + boolean json, + HttpServletRequest request, + String code, + HttpStatus status, + String message + ) { + return authError(json, request, "/signup", code, status, message); + } + + private ResponseEntity authError( + boolean json, + HttpServletRequest request, + String path, + String code, + HttpStatus status, + String message + ) { + if (json) { + return ResponseEntity.status(status).body(new AuthResult(status.value(), message)); + } + return redirect(request, path + "?error=" + code); + } + + private ResponseEntity redirect(HttpServletRequest request, String path) { + return ResponseEntity + .status(HttpStatus.SEE_OTHER) + .header(HttpHeaders.LOCATION, request.getContextPath() + path) + .build(); + } + + public record AuthResult(int status, String message) { + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/data/GameCommentData.java b/src/main/java/com/pandoli365/bibimbap/data/GameCommentData.java new file mode 100644 index 0000000..3850fef --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/GameCommentData.java @@ -0,0 +1,61 @@ +package com.pandoli365.bibimbap.data; + +import java.time.OffsetDateTime; + +public class GameCommentData { + + private Long id; + private Long gameId; + private String nickname; + private String content; + private OffsetDateTime createdAt; + private OffsetDateTime deletedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getGameId() { + return gameId; + } + + public void setGameId(Long gameId) { + this.gameId = gameId; + } + + public String getNickname() { + return nickname; + } + + public void setNickname(String nickname) { + this.nickname = nickname; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getDeletedAt() { + return deletedAt; + } + + public void setDeletedAt(OffsetDateTime deletedAt) { + this.deletedAt = deletedAt; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/data/GameData.java b/src/main/java/com/pandoli365/bibimbap/data/GameData.java new file mode 100644 index 0000000..db3e6f5 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/GameData.java @@ -0,0 +1,115 @@ +package com.pandoli365.bibimbap.data; + +import java.time.OffsetDateTime; + +public class GameData { + + private Long id; + private String name; + private String creator; + private String creatorNote; + private String gitUrl; + private String webglPath; + private String thumbnailUrl; + private Integer likeCount; + 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 String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCreator() { + return creator; + } + + public void setCreator(String creator) { + this.creator = creator; + } + + public String getCreatorNote() { + return creatorNote; + } + + public void setCreatorNote(String creatorNote) { + this.creatorNote = creatorNote; + } + + public String getGitUrl() { + return gitUrl; + } + + public void setGitUrl(String gitUrl) { + this.gitUrl = gitUrl; + } + + public String getWebglPath() { + return webglPath; + } + + public void setWebglPath(String webglPath) { + this.webglPath = webglPath; + } + + public String getThumbnailUrl() { + return thumbnailUrl; + } + + public void setThumbnailUrl(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + } + + public Integer getLikeCount() { + return likeCount; + } + + public void setLikeCount(Integer likeCount) { + this.likeCount = likeCount; + } + + 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/data/GameLikeData.java b/src/main/java/com/pandoli365/bibimbap/data/GameLikeData.java new file mode 100644 index 0000000..2fc83e2 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/GameLikeData.java @@ -0,0 +1,43 @@ +package com.pandoli365.bibimbap.data; + +import java.time.OffsetDateTime; + +public class GameLikeData { + + private Long id; + private Long gameId; + private String userKey; + private OffsetDateTime createdAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getGameId() { + return gameId; + } + + public void setGameId(Long gameId) { + this.gameId = gameId; + } + + public String getUserKey() { + return userKey; + } + + public void setUserKey(String userKey) { + this.userKey = userKey; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/data/UserAuthIdentityData.java b/src/main/java/com/pandoli365/bibimbap/data/UserAuthIdentityData.java new file mode 100644 index 0000000..52f16cf --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/UserAuthIdentityData.java @@ -0,0 +1,106 @@ +package com.pandoli365.bibimbap.data; + +import java.time.OffsetDateTime; + +public class UserAuthIdentityData { + + private Long id; + private Long userId; + private String provider; + private String providerUserId; + private String email; + private String passwordHash; + private String displayName; + private String avatarUrl; + private OffsetDateTime lastLoginAt; + 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 getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public String getProviderUserId() { + return providerUserId; + } + + public void setProviderUserId(String providerUserId) { + this.providerUserId = providerUserId; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPasswordHash() { + return passwordHash; + } + + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + + public OffsetDateTime getLastLoginAt() { + return lastLoginAt; + } + + public void setLastLoginAt(OffsetDateTime lastLoginAt) { + this.lastLoginAt = lastLoginAt; + } + + 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/data/UserData.java b/src/main/java/com/pandoli365/bibimbap/data/UserData.java new file mode 100644 index 0000000..f40ae2e --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/UserData.java @@ -0,0 +1,88 @@ +package com.pandoli365.bibimbap.data; + +import java.time.OffsetDateTime; + +public class UserData { + + private Long id; + private String displayName; + private String canonicalEmail; + private String avatarUrl; + private String role; + private String status; + private OffsetDateTime lastLoginAt; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getCanonicalEmail() { + return canonicalEmail; + } + + public void setCanonicalEmail(String canonicalEmail) { + this.canonicalEmail = canonicalEmail; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public OffsetDateTime getLastLoginAt() { + return lastLoginAt; + } + + public void setLastLoginAt(OffsetDateTime lastLoginAt) { + this.lastLoginAt = lastLoginAt; + } + + 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/game/GameCatalog.java b/src/main/java/com/pandoli365/bibimbap/game/GameCatalog.java index c3dd4a2..f05cb89 100644 --- a/src/main/java/com/pandoli365/bibimbap/game/GameCatalog.java +++ b/src/main/java/com/pandoli365/bibimbap/game/GameCatalog.java @@ -1,85 +1,21 @@ package com.pandoli365.bibimbap.game; -/** - * 데모용 게임 메타데이터. DB 연동 시 저장소로 대체하면 됩니다. - */ public final class GameCatalog { private GameCatalog() { } - public static final int COUNT = 20; + public static final String[] NAMES = {}; - public static final String[] NAMES = { - "별빛 정원", "던전 카페", "픽셀 레이서", "구름 위를 걷다", - "마지막 타임라인", "요리하는 용", "네온 시티", "작은 숲의 집", - "역전 재판인", "달무리 탐정", "코드 러너", "바다 노래", - "시간 상자", "불꽃 학원", "조용한 우주", "종이 비행기", - "거울 미로", "손끝 RPG", "노을 역", "꿈의 도서관" - }; + public static final String[] CREATORS = {}; - public static final String[] CREATORS = { - "Studio Luna", "김민재", "PixelCat", "이하늘", - "Team Horizon", "별작업실", "NEON LAB", "숲그림", - "Court Games", "달무리", "dev.han", "wave.sound", - "BoxSoft", "학원제작소", "COSMOS", "PaperFly", - "mirror.inc", "손끝게임즈", "노을팀", "책벌레" - }; + public static final int[] LIKE_COUNTS = {}; - public static final int[] LIKE_COUNTS = { - 1284, 56, 8921, 234, - 1205, 445, 678, 9012, - 3400, 12, 567, 89, - 4456, 223, 7777, 156, - 990, 34, 2100, 888 - }; + public static final String[] CREATOR_NOTES = {}; - /** 제작자 한마디 (상세 페이지 하단) */ - public static final String[] CREATOR_NOTES = { - "밤하늘을 걸으며 힐링하고 싶어서 만들었습니다. 플레이 해 주셔서 감사합니다.", - "카페 던전은 사랑입니다. 버그 제보는 Git 이슈로 부탁드려요.", - "속도감 있는 레이싱! 컨트롤은 WASD, 모바일은 추후 지원 예정이에요.", - "구름 위를 걷는 기분을 WebGL로 담아봤습니다.", - "스토리에 집중했습니다. 엔딩까지 플레이해 주세요.", - "요리 도트에 진심입니다. 레시피 아이디어 환영합니다.", - "네온 사인이 마음에 드셨다면 별 하나 부탁드려요.", - "작은 집에서 시작하는 하루. 소소한 상호작용을 즐겨 주세요.", - "반전 스토리, 스포일러는 삼가 주세요!", - "추리는 끝이 없어요. 힌트는 커뮤니티에 올려 두었습니다.", - "코딩하듯 플레이하는 러너. PR도 환영합니다.", - "바다 소리를 들으며 플레이해 보세요. 이어폰 추천!", - "시간 루프가 헷갈리면 메모를 추천합니다.", - "학원물이지만 가볍게 즐겨 주세요.", - "우주는 넓고 할 일은 많습니다. 업데이트 예정이에요.", - "종이비행기처럼 가볍게 날아가 보세요.", - "거울 방향이 헷갈릴 수 있어요. 인내심을…", - "손끝으로 즐기는 턴제 RPG입니다.", - "노을이 지는 역에서 만나요.", - "책 속 세계로 오신 걸 환영합니다." - }; + public static final String[] GIT_URLS = {}; - public static final String[] GIT_URLS = { - "https://github.com/example/starlight-garden", - "https://github.com/example/dungeon-cafe", - "https://github.com/example/pixel-racer", - "https://github.com/example/cloud-walk", - "https://github.com/example/last-timeline", - "https://github.com/example/cooking-dragon", - "https://github.com/example/neon-city", - "https://github.com/example/small-forest", - "https://github.com/example/court-game", - "https://github.com/example/moon-detective", - "https://github.com/example/code-runner", - "https://github.com/example/sea-song", - "https://github.com/example/time-box", - "https://github.com/example/flame-school", - "https://github.com/example/quiet-space", - "https://github.com/example/paper-plane", - "https://github.com/example/mirror-maze", - "https://github.com/example/fingertip-rpg", - "https://github.com/example/sunset-station", - "https://github.com/example/dream-library" - }; + public static final int COUNT = NAMES.length; public static boolean isValidId(int id) { return id >= 1 && id <= COUNT; diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/GameCommentsMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/GameCommentsMapper.java new file mode 100644 index 0000000..b5e8279 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/GameCommentsMapper.java @@ -0,0 +1,49 @@ +package com.pandoli365.bibimbap.mapper; + +import com.pandoli365.bibimbap.data.GameCommentData; +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 org.apache.ibatis.annotations.Update; + +@Mapper +public interface GameCommentsMapper { + + @Select(""" + SELECT + id, + game_id AS gameId, + nickname, + content, + created_at AS createdAt, + deleted_at AS deletedAt + FROM game_comments + WHERE id = #{id} + """) + GameCommentData getGameComment(long id); + + @Insert(""" + INSERT INTO game_comments ( + game_id, + nickname, + content + ) VALUES ( + #{gameId}, + #{nickname}, + #{content} + ) + """) + @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id") + int addGameComment(GameCommentData gameComment); + + @Update(""" + UPDATE game_comments + SET + nickname = #{nickname}, + content = #{content}, + deleted_at = #{deletedAt} + WHERE id = #{id} + """) + int updateGameComment(GameCommentData gameComment); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/GameLikesMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/GameLikesMapper.java new file mode 100644 index 0000000..fbd962c --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/GameLikesMapper.java @@ -0,0 +1,44 @@ +package com.pandoli365.bibimbap.mapper; + +import com.pandoli365.bibimbap.data.GameLikeData; +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 org.apache.ibatis.annotations.Update; + +@Mapper +public interface GameLikesMapper { + + @Select(""" + SELECT + id, + game_id AS gameId, + user_key AS userKey, + created_at AS createdAt + FROM game_likes + WHERE id = #{id} + """) + GameLikeData getGameLike(long id); + + @Insert(""" + INSERT INTO game_likes ( + game_id, + user_key + ) VALUES ( + #{gameId}, + #{userKey} + ) + """) + @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id") + int addGameLike(GameLikeData gameLike); + + @Update(""" + UPDATE game_likes + SET + game_id = #{gameId}, + user_key = #{userKey} + WHERE id = #{id} + """) + int updateGameLike(GameLikeData gameLike); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java new file mode 100644 index 0000000..190dbed --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java @@ -0,0 +1,68 @@ +package com.pandoli365.bibimbap.mapper; + +import com.pandoli365.bibimbap.data.GameData; +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 org.apache.ibatis.annotations.Update; + +@Mapper +public interface GamesMapper { + + @Select(""" + SELECT + id, + name, + creator, + creator_note AS creatorNote, + git_url AS gitUrl, + webgl_path AS webglPath, + thumbnail_url AS thumbnailUrl, + like_count AS likeCount, + is_visible AS visible, + sort_order AS sortOrder, + created_at AS createdAt, + updated_at AS updatedAt + FROM games + WHERE id = #{id} + """) + GameData getGame(long id); + + @Insert(""" + INSERT INTO games ( + name, + creator, + creator_note, + git_url, + webgl_path, + thumbnail_url, + sort_order + ) VALUES ( + #{name}, + #{creator}, + #{creatorNote}, + #{gitUrl}, + #{webglPath}, + #{thumbnailUrl}, + #{sortOrder} + ) + """) + @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id") + int addGame(GameData game); + + @Update(""" + UPDATE games + SET + name = #{name}, + creator = #{creator}, + creator_note = #{creatorNote}, + git_url = #{gitUrl}, + webgl_path = #{webglPath}, + thumbnail_url = #{thumbnailUrl}, + is_visible = #{visible}, + sort_order = #{sortOrder}, + WHERE id = #{id} + """) + int updateGame(GameData game); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/UserAuthIdentitiesMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/UserAuthIdentitiesMapper.java new file mode 100644 index 0000000..954a216 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/UserAuthIdentitiesMapper.java @@ -0,0 +1,91 @@ +package com.pandoli365.bibimbap.mapper; + +import com.pandoli365.bibimbap.data.UserAuthIdentityData; +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; + +@Mapper +public interface UserAuthIdentitiesMapper { + + @Select(""" + SELECT + id, + user_id AS userId, + provider, + provider_user_id AS providerUserId, + email, + password_hash AS passwordHash, + display_name AS displayName, + avatar_url AS avatarUrl, + last_login_at AS lastLoginAt, + created_at AS createdAt, + updated_at AS updatedAt + FROM user_auth_identities + WHERE id = #{id} + """) + UserAuthIdentityData getUserAuthIdentity(long id); + + @Select(""" + SELECT + id, + user_id AS userId, + provider, + provider_user_id AS providerUserId, + email, + password_hash AS passwordHash, + display_name AS displayName, + avatar_url AS avatarUrl, + last_login_at AS lastLoginAt, + created_at AS createdAt, + updated_at AS updatedAt + FROM user_auth_identities + WHERE provider = #{provider} + AND provider_user_id = #{providerUserId} + AND is_delete = false + """) + UserAuthIdentityData getUserAuthIdentityByProvider( + @Param("provider") String provider, + @Param("providerUserId") String providerUserId + ); + + @Insert(""" + INSERT INTO user_auth_identities ( + user_id, + provider, + provider_user_id, + email, + password_hash, + display_name, + avatar_url, + last_login_at + ) VALUES ( + #{userId}, + #{provider}, + #{providerUserId}, + #{email}, + #{passwordHash}, + #{displayName}, + #{avatarUrl}, + #{lastLoginAt} + ) + """) + @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id") + int addUserAuthIdentity(UserAuthIdentityData userAuthIdentity); + + @Update(""" + UPDATE user_auth_identities + SET + email = #{email}, + password_hash = #{passwordHash}, + display_name = #{displayName}, + avatar_url = #{avatarUrl}, + last_login_at = #{lastLoginAt}, + updated_at = now() + WHERE id = #{id} + """) + int updateUserAuthIdentity(UserAuthIdentityData userAuthIdentity); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/UsersMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/UsersMapper.java new file mode 100644 index 0000000..1231799 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/UsersMapper.java @@ -0,0 +1,62 @@ +package com.pandoli365.bibimbap.mapper; + +import com.pandoli365.bibimbap.data.UserData; +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 org.apache.ibatis.annotations.Update; + +@Mapper +public interface UsersMapper { + + @Select(""" + SELECT + id, + display_name AS displayName, + canonical_email AS canonicalEmail, + avatar_url AS avatarUrl, + role, + status, + last_login_at AS lastLoginAt, + created_at AS createdAt, + updated_at AS updatedAt + FROM users + WHERE id = #{id} + """) + UserData getUser(long id); + + @Insert(""" + INSERT INTO users ( + display_name, + canonical_email, + avatar_url, + role, + status, + last_login_at + ) VALUES ( + #{displayName}, + #{canonicalEmail}, + #{avatarUrl}, + #{role}, + #{status}, + #{lastLoginAt} + ) + """) + @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id") + int addUser(UserData user); + + @Update(""" + UPDATE users + SET + display_name = #{displayName}, + canonical_email = #{canonicalEmail}, + avatar_url = #{avatarUrl}, + role = #{role}, + status = #{status}, + last_login_at = #{lastLoginAt}, + updated_at = now() + WHERE id = #{id} + """) + int updateUser(UserData user); +} diff --git a/src/main/webapp/WEB-INF/views/game-detail.jsp b/src/main/webapp/WEB-INF/views/game-detail.jsp index ae85c29..5efef8c 100644 --- a/src/main/webapp/WEB-INF/views/game-detail.jsp +++ b/src/main/webapp/WEB-INF/views/game-detail.jsp @@ -822,7 +822,7 @@ var emptyEl = document.getElementById('game-comments-empty'); var form = document.getElementById('game-comment-form'); var input = document.getElementById('game-comment-input'); - var DEFAULT_NICK = 'test'; + var DEFAULT_NICK = '익명'; function renderComments() { var items = getComments().slice().sort(function (a, b) { diff --git a/src/main/webapp/WEB-INF/views/header.jsp b/src/main/webapp/WEB-INF/views/header.jsp index ece1ca1..b5993ae 100644 --- a/src/main/webapp/WEB-INF/views/header.jsp +++ b/src/main/webapp/WEB-INF/views/header.jsp @@ -144,7 +144,7 @@ - + + + + + 로그인 | bibimbap + + + + + <% + String ctx = request.getContextPath(); + %> +
+
+
+ +
+

BIBIMBAP

+

로그인

+
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+ +
+
+
+ + + + diff --git a/src/main/webapp/WEB-INF/views/modal.jsp b/src/main/webapp/WEB-INF/views/modal.jsp new file mode 100644 index 0000000..50b366d --- /dev/null +++ b/src/main/webapp/WEB-INF/views/modal.jsp @@ -0,0 +1,128 @@ +<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %> + + + diff --git a/src/main/webapp/WEB-INF/views/signup.jsp b/src/main/webapp/WEB-INF/views/signup.jsp new file mode 100644 index 0000000..1b92985 --- /dev/null +++ b/src/main/webapp/WEB-INF/views/signup.jsp @@ -0,0 +1,333 @@ +<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %> + + + + + + + 회원가입 | bibimbap + + + + + <% + String ctx = request.getContextPath(); + %> +
+
+
+ +
+

BIBIMBAP

+

회원가입

+
+
+ +
+ + +
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ + + + diff --git a/src/test/java/com/pandoli365/bibimbap/DbUpdateQueryGeneratorTest.java b/src/test/java/com/pandoli365/bibimbap/DbUpdateQueryGeneratorTest.java new file mode 100644 index 0000000..59ca9f4 --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/DbUpdateQueryGeneratorTest.java @@ -0,0 +1,527 @@ +package com.pandoli365.bibimbap; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.Date; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Time; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.temporal.TemporalAccessor; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Properties; +import java.util.Set; +import java.util.StringJoiner; + +class DbUpdateQueryGeneratorTest { + + private static final String SELECT_PROFILE = "dev"; + private static final String UPDATE_PROFILE = "live"; + private static final String SELECT_SCHEMA = "dev"; + private static final String UPDATE_SCHEMA = "live"; + private static final Path OUTPUT_PATH = Path.of("src", "test", "db", "dev-to-live-update.sql"); + + @Test + void printRequiredUpdateQueries() throws Exception { + DbConfig selectConfig = loadDbConfig(SELECT_PROFILE); + DbConfig updateConfig = loadDbConfig(UPDATE_PROFILE); + + Class.forName(selectConfig.driverClassName()); + + try ( + Connection selectConnection = DriverManager.getConnection( + selectConfig.url(), selectConfig.username(), selectConfig.password()); + Connection updateConnection = DriverManager.getConnection( + updateConfig.url(), updateConfig.username(), updateConfig.password()) + ) { + List sqlLines = generateRequiredUpdateQueries(selectConnection, updateConnection); + Files.createDirectories(OUTPUT_PATH.getParent()); + Files.write(OUTPUT_PATH, sqlLines, StandardCharsets.UTF_8); + + System.out.println("SQL file saved: " + OUTPUT_PATH.toAbsolutePath()); + } + } + + private List generateRequiredUpdateQueries(Connection selectConnection, Connection updateConnection) + throws SQLException { + Map selectTables = loadTables(selectConnection, SELECT_SCHEMA); + Map updateTables = loadTables(updateConnection, UPDATE_SCHEMA); + + List sqlLines = new ArrayList<>(); + sqlLines.add("-- select = " + SELECT_SCHEMA); + sqlLines.add("-- update = " + UPDATE_SCHEMA); + sqlLines.add("-- generated SQL only. Review before running."); + sqlLines.add(""); + + addSchemaDiff(sqlLines, selectTables, updateTables); + + for (TableInfo selectTable : selectTables.values()) { + TableInfo updateTable = updateTables.get(selectTable.name()); + if (updateTable == null) { + addCreateAndCopyTableQueries(sqlLines, selectTable); + continue; + } + + if (selectTable.primaryKeys().isEmpty()) { + sqlLines.add("-- SKIP " + SELECT_SCHEMA + "." + selectTable.name() + + ": primary key is required to compare rows."); + continue; + } + + List commonColumns = selectTable.columns().stream() + .filter(column -> updateTable.columnNames().contains(column.name())) + .toList(); + List commonPrimaryKeys = commonColumns.stream() + .filter(column -> selectTable.primaryKeys().contains(column.name())) + .toList(); + + if (commonPrimaryKeys.size() != selectTable.primaryKeys().size()) { + sqlLines.add("-- SKIP " + SELECT_SCHEMA + "." + selectTable.name() + + ": update schema does not have all primary key columns."); + continue; + } + + addTableDiffComments(sqlLines, selectConnection, updateConnection, selectTable, commonColumns, commonPrimaryKeys); + } + + return sqlLines; + } + + private void addCreateAndCopyTableQueries(List sqlLines, TableInfo table) { + StringJoiner createColumns = new StringJoiner("," + System.lineSeparator()); + for (ColumnInfo column : table.columns()) { + createColumns.add(" " + columnDefinition(column)); + } + + sqlLines.add("-- " + table.name() + " table does not exist."); + addSequenceCreateQueries(sqlLines, table); + sqlLines.add("CREATE TABLE " + tableName(table.name()) + + " (" + System.lineSeparator() + + createColumns + + primaryKeyDefinition(table) + + System.lineSeparator() + + ");"); + addColumnCommentQueries(sqlLines, table); + sqlLines.add(""); + } + + private void addSequenceCreateQueries(List sqlLines, TableInfo table) { + for (ColumnInfo column : table.columns()) { + String sequenceName = sequenceName(column.defaultValue()); + if (sequenceName == null) { + continue; + } + sqlLines.add("CREATE SEQUENCE IF NOT EXISTS " + quoteIdentifier(sequenceName) + ";"); + } + } + + private String columnDefinition(ColumnInfo column) { + StringBuilder definition = new StringBuilder() + .append(quoteIdentifier(column.name())) + .append(" ") + .append(column.dataType()); + + if (column.defaultValue() != null && !column.defaultValue().isBlank()) { + definition.append(" DEFAULT ").append(normalizeDefaultValue(column.defaultValue())); + } + if (column.notNull()) { + definition.append(" NOT NULL"); + } + + return definition.toString(); + } + + private String primaryKeyDefinition(TableInfo table) { + if (table.primaryKeys().isEmpty()) { + return ""; + } + + StringJoiner primaryKeys = new StringJoiner(", "); + for (String primaryKey : table.primaryKeys()) { + primaryKeys.add(quoteIdentifier(primaryKey)); + } + return "," + System.lineSeparator() + " PRIMARY KEY (" + primaryKeys + ")"; + } + + private void addColumnCommentQueries(List sqlLines, TableInfo table) { + for (ColumnInfo column : table.columns()) { + if (column.comment() == null || column.comment().isBlank()) { + continue; + } + sqlLines.add("COMMENT ON COLUMN " + tableName(table.name()) + "." + quoteIdentifier(column.name()) + + " IS '" + escapeSql(column.comment()) + "';"); + } + } + + private void addSchemaDiff( + List sqlLines, + Map selectTables, + Map updateTables + ) { + for (String tableName : selectTables.keySet()) { + TableInfo updateTable = updateTables.get(tableName); + if (updateTable == null) { + sqlLines.add("-- ONLY IN " + SELECT_SCHEMA + ": " + tableName); + continue; + } + + TableInfo selectTable = selectTables.get(tableName); + for (String columnName : selectTable.columnNames()) { + if (!updateTable.columnNames().contains(columnName)) { + sqlLines.add("-- ONLY IN " + SELECT_SCHEMA + "." + tableName + ": " + columnName); + } + } + for (String columnName : updateTable.columnNames()) { + if (!selectTable.columnNames().contains(columnName)) { + sqlLines.add("-- ONLY IN " + UPDATE_SCHEMA + "." + tableName + ": " + columnName); + } + } + } + + for (String tableName : updateTables.keySet()) { + if (!selectTables.containsKey(tableName)) { + sqlLines.add("-- ONLY IN " + UPDATE_SCHEMA + ": " + tableName); + } + } + sqlLines.add(""); + } + + private void addTableDiffComments( + List sqlLines, + Connection selectConnection, + Connection updateConnection, + TableInfo table, + List columns, + List primaryKeys + ) throws SQLException { + String selectSql = "SELECT " + columnList(columns) + " FROM " + qualified(SELECT_SCHEMA, table.name()) + + " ORDER BY " + columnList(primaryKeys); + + try ( + Statement statement = selectConnection.createStatement(); + ResultSet selectRows = statement.executeQuery(selectSql) + ) { + while (selectRows.next()) { + Row sourceRow = readRow(selectRows, columns); + Row targetRow = findTargetRow(updateConnection, table, columns, primaryKeys, sourceRow); + + if (targetRow == null) { + sqlLines.add("-- DATA DIFF " + table.name() + ": row exists only in " + SELECT_SCHEMA + + " where " + primaryKeyComment(primaryKeys, sourceRow)); + continue; + } + + List changedColumns = columns.stream() + .filter(column -> !table.primaryKeys().contains(column.name())) + .filter(column -> !sameValue(sourceRow.values().get(column.name()), targetRow.values().get(column.name()))) + .toList(); + + if (!changedColumns.isEmpty()) { + sqlLines.add("-- DATA DIFF " + table.name() + ": row differs where " + + primaryKeyComment(primaryKeys, sourceRow) + + " columns " + changedColumnComment(changedColumns)); + } + } + } + } + + private String primaryKeyComment(List primaryKeys, Row row) { + StringJoiner where = new StringJoiner(", "); + for (ColumnInfo primaryKey : primaryKeys) { + where.add(primaryKey.name() + "=" + row.values().get(primaryKey.name())); + } + return where.toString(); + } + + private String changedColumnComment(List changedColumns) { + StringJoiner columns = new StringJoiner(", "); + for (ColumnInfo column : changedColumns) { + columns.add(column.name()); + } + return columns.toString(); + } + + private Row findTargetRow( + Connection updateConnection, + TableInfo table, + List columns, + List primaryKeys, + Row sourceRow + ) throws SQLException { + StringJoiner where = new StringJoiner(" AND "); + for (ColumnInfo primaryKey : primaryKeys) { + where.add(quoteIdentifier(primaryKey.name()) + " = " + + sqlLiteral(sourceRow.values().get(primaryKey.name()), primaryKey.sqlType())); + } + + String sql = "SELECT " + columnList(columns) + " FROM " + qualified(UPDATE_SCHEMA, table.name()) + + " WHERE " + where; + + try ( + Statement statement = updateConnection.createStatement(); + ResultSet targetRows = statement.executeQuery(sql) + ) { + if (!targetRows.next()) { + return null; + } + return readRow(targetRows, columns); + } + } + + private Row readRow(ResultSet resultSet, List columns) throws SQLException { + Map values = new LinkedHashMap<>(); + for (ColumnInfo column : columns) { + values.put(column.name(), resultSet.getObject(column.name())); + } + return new Row(values); + } + + private Map loadTables(Connection connection, String schema) throws SQLException { + Map> tableColumns = loadColumns(connection, schema); + Map> primaryKeys = loadPrimaryKeys(connection, schema); + Map tables = new LinkedHashMap<>(); + + for (Map.Entry> entry : tableColumns.entrySet()) { + tables.put(entry.getKey(), new TableInfo( + entry.getKey(), + entry.getValue(), + primaryKeys.getOrDefault(entry.getKey(), Set.of()) + )); + } + return tables; + } + + private Map> loadColumns(Connection connection, String schema) throws SQLException { + String sql = """ + SELECT + c.relname AS table_name, + a.attname AS column_name, + pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type, + a.attnotnull AS not_null, + pg_catalog.pg_get_expr(ad.adbin, ad.adrelid) AS column_default, + pg_catalog.col_description(a.attrelid, a.attnum) AS column_comment + FROM pg_catalog.pg_namespace n + JOIN pg_catalog.pg_class c + ON c.relnamespace = n.oid + JOIN pg_catalog.pg_attribute a + ON a.attrelid = c.oid + LEFT JOIN pg_catalog.pg_attrdef ad + ON ad.adrelid = a.attrelid + AND ad.adnum = a.attnum + WHERE n.nspname = '%s' + AND c.relkind = 'r' + AND a.attnum > 0 + AND NOT a.attisdropped + ORDER BY c.relname, a.attnum + """.formatted(escapeSql(schema)); + + Map> tableColumns = new LinkedHashMap<>(); + try ( + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(sql) + ) { + while (resultSet.next()) { + String tableName = resultSet.getString("table_name"); + String columnName = resultSet.getString("column_name"); + int sqlType = resolveSqlType(connection, schema, tableName, columnName); + tableColumns.computeIfAbsent(tableName, ignored -> new ArrayList<>()) + .add(new ColumnInfo( + columnName, + resultSet.getString("data_type"), + resultSet.getBoolean("not_null"), + resultSet.getString("column_default"), + resultSet.getString("column_comment"), + sqlType + )); + } + } + return tableColumns; + } + + private Map> loadPrimaryKeys(Connection connection, String schema) throws SQLException { + String sql = """ + SELECT kcu.table_name, kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_schema = kcu.constraint_schema + AND tc.constraint_name = kcu.constraint_name + AND tc.table_name = kcu.table_name + WHERE tc.table_schema = '%s' + AND tc.constraint_type = 'PRIMARY KEY' + ORDER BY kcu.table_name, kcu.ordinal_position + """.formatted(escapeSql(schema)); + + Map> primaryKeys = new LinkedHashMap<>(); + try ( + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(sql) + ) { + while (resultSet.next()) { + primaryKeys.computeIfAbsent(resultSet.getString("table_name"), ignored -> new LinkedHashSet<>()) + .add(resultSet.getString("column_name")); + } + } + return primaryKeys; + } + + private int resolveSqlType(Connection connection, String schema, String tableName, String columnName) + throws SQLException { + String sql = "SELECT " + quoteIdentifier(columnName) + " FROM " + qualified(schema, tableName) + " WHERE 1 = 0"; + try ( + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(sql) + ) { + ResultSetMetaData metaData = resultSet.getMetaData(); + return metaData.getColumnType(1); + } + } + + private static DbConfig loadDbConfig(String profile) throws IOException { + Properties properties = new Properties(); + String path = profile + "/db.properties"; + try (InputStream inputStream = DbUpdateQueryGeneratorTest.class.getClassLoader().getResourceAsStream(path)) { + if (inputStream == null) { + throw new IOException("Cannot find " + path); + } + properties.load(inputStream); + } + + return new DbConfig( + properties.getProperty("spring.datasource.driver-class-name"), + properties.getProperty("spring.datasource.url"), + properties.getProperty("spring.datasource.username"), + properties.getProperty("spring.datasource.password") + ); + } + + private static boolean sameValue(Object left, Object right) { + if (left instanceof BigDecimal leftDecimal && right instanceof BigDecimal rightDecimal) { + return leftDecimal.compareTo(rightDecimal) == 0; + } + return Objects.equals(left, right); + } + + private static String columnList(List columns) { + StringJoiner joiner = new StringJoiner(", "); + for (ColumnInfo column : columns) { + joiner.add(quoteIdentifier(column.name())); + } + return joiner.toString(); + } + + private static String qualified(String schema, String tableName) { + return quoteIdentifier(schema) + "." + quoteIdentifier(tableName); + } + + private static String tableName(String tableName) { + return quoteIdentifier(tableName); + } + + private static String normalizeDefaultValue(String defaultValue) { + String normalized = defaultValue.replace("'" + SELECT_SCHEMA + ".", "'"); + normalized = normalized.replace("\"" + SELECT_SCHEMA + "\".", ""); + normalized = normalized.replace(SELECT_SCHEMA + ".", ""); + return normalized; + } + + private static String sequenceName(String defaultValue) { + if (defaultValue == null || !defaultValue.startsWith("nextval('")) { + return null; + } + + int start = "nextval('".length(); + int end = defaultValue.indexOf("'", start); + if (end < 0) { + return null; + } + + String rawSequenceName = defaultValue.substring(start, end); + int schemaSeparator = rawSequenceName.lastIndexOf('.'); + if (schemaSeparator >= 0) { + return rawSequenceName.substring(schemaSeparator + 1); + } + return rawSequenceName; + } + + private static String quoteIdentifier(String identifier) { + return "\"" + identifier.replace("\"", "\"\"") + "\""; + } + + private static String sqlLiteral(Object value, int sqlType) { + if (value == null) { + return "NULL"; + } + + return switch (sqlType) { + case Types.BIGINT, Types.DECIMAL, Types.DOUBLE, Types.FLOAT, Types.INTEGER, + Types.NUMERIC, Types.REAL, Types.SMALLINT, Types.TINYINT -> value.toString(); + case Types.BOOLEAN, Types.BIT -> Boolean.TRUE.equals(value) ? "TRUE" : "FALSE"; + case Types.BINARY, Types.BLOB, Types.LONGVARBINARY, Types.VARBINARY -> + "'\\x" + HexFormat.of().formatHex((byte[]) value) + "'"; + case Types.DATE -> "'" + ((Date) value).toLocalDate() + "'"; + case Types.TIME, Types.TIME_WITH_TIMEZONE -> "'" + value + "'"; + case Types.TIMESTAMP, Types.TIMESTAMP_WITH_TIMEZONE -> "'" + timestampValue(value) + "'"; + default -> "'" + escapeSql(value.toString()) + "'"; + }; + } + + private static String timestampValue(Object value) { + if (value instanceof Timestamp timestamp) { + return timestamp.toInstant().toString(); + } + if (value instanceof Time time) { + return time.toLocalTime().toString(); + } + if (value instanceof TemporalAccessor) { + return value.toString(); + } + return escapeSql(value.toString()); + } + + private static String escapeSql(String value) { + return value.replace("'", "''"); + } + + private record DbConfig(String driverClassName, String url, String username, String password) { + } + + private record ColumnInfo( + String name, + String dataType, + boolean notNull, + String defaultValue, + String comment, + int sqlType + ) { + } + + private record TableInfo(String name, List columns, Set primaryKeys) { + private Set columnNames() { + Set names = new LinkedHashSet<>(); + for (ColumnInfo column : columns) { + names.add(column.name()); + } + return names; + } + } + + private record Row(Map values) { + } +}