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