로그인 페이지 작업완료
This commit is contained in:
parent
23f594c3c5
commit
3508a4b3bb
|
|
@ -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`를 사용한다.
|
||||||
|
|
@ -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를 확정하는 방식을 권장한다.
|
||||||
12
pom.xml
12
pom.xml
|
|
@ -27,6 +27,7 @@
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||||
<spring-boot.version>3.5.14-SNAPSHOT</spring-boot.version>
|
<spring-boot.version>3.5.14-SNAPSHOT</spring-boot.version>
|
||||||
|
<mybatis-spring-boot.version>3.0.5</mybatis-spring-boot.version>
|
||||||
<lombok.version>1.18.44</lombok.version>
|
<lombok.version>1.18.44</lombok.version>
|
||||||
<maven-clean-plugin.version>3.4.1</maven-clean-plugin.version>
|
<maven-clean-plugin.version>3.4.1</maven-clean-plugin.version>
|
||||||
<maven-compiler-plugin.version>3.14.1</maven-compiler-plugin.version>
|
<maven-compiler-plugin.version>3.14.1</maven-compiler-plugin.version>
|
||||||
|
|
@ -53,11 +54,20 @@
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mybatis.spring.boot</groupId>
|
||||||
|
<artifactId>mybatis-spring-boot-starter</artifactId>
|
||||||
|
<version>${mybatis-spring-boot.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.postgresql</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.apache.tomcat.embed</groupId>
|
<groupId>org.apache.tomcat.embed</groupId>
|
||||||
<artifactId>tomcat-embed-jasper</artifactId>
|
<artifactId>tomcat-embed-jasper</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpSession;
|
import jakarta.servlet.http.HttpSession;
|
||||||
import org.springframework.boot.web.servlet.error.ErrorController;
|
import org.springframework.boot.web.servlet.error.ErrorController;
|
||||||
import org.springframework.stereotype.Controller;
|
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.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
@ -27,9 +28,9 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
|
||||||
return mv;
|
return mv;
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequestMapping("/{pageName}")
|
@GetMapping("/{pageName}")
|
||||||
public ModelAndView mainView(@PathVariable("pageName") String pageName,
|
public ModelAndView mainView(@PathVariable("pageName") String pageName,
|
||||||
@RequestParam(required = false) String id,
|
@RequestParam(name = "id", required = false) String id,
|
||||||
HttpSession session,
|
HttpSession session,
|
||||||
HttpServletRequest request) {
|
HttpServletRequest request) {
|
||||||
ModelAndView mv = new ModelAndView();
|
ModelAndView mv = new ModelAndView();
|
||||||
|
|
@ -44,6 +45,12 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
|
||||||
mv.addObject("statusCode", status);
|
mv.addObject("statusCode", status);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case "login":
|
||||||
|
mv.setViewName("/login");
|
||||||
|
break;
|
||||||
|
case "signup":
|
||||||
|
mv.setViewName("/signup");
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
mv.setViewName("/index");
|
mv.setViewName("/index");
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,7 @@ public class GameController {
|
||||||
model.addAttribute("likeCountFormatted", String.format("%,d", GameCatalog.LIKE_COUNTS[idx]));
|
model.addAttribute("likeCountFormatted", String.format("%,d", GameCatalog.LIKE_COUNTS[idx]));
|
||||||
model.addAttribute("creatorNote", GameCatalog.CREATOR_NOTES[idx]);
|
model.addAttribute("creatorNote", GameCatalog.CREATOR_NOTES[idx]);
|
||||||
model.addAttribute("gitUrl", GameCatalog.GIT_URLS[idx]);
|
model.addAttribute("gitUrl", GameCatalog.GIT_URLS[idx]);
|
||||||
/* 데모: 공통 플레이스홀더. 실제 빌드는 static/webgl/game-{id}/ 에 두고 아래 한 줄을 webglUrlForGame(id) 로 바꾸면 됩니다. */
|
model.addAttribute("webglUrl", webglUrlForGame(id));
|
||||||
model.addAttribute("webglUrl", "/webgl/placeholder/index.html");
|
|
||||||
model.addAttribute("webglDeployPath", webglUrlForGame(id));
|
model.addAttribute("webglDeployPath", webglUrlForGame(id));
|
||||||
return "game-detail";
|
return "game-detail";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<String, Object> 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<Void> 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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,85 +1,21 @@
|
||||||
package com.pandoli365.bibimbap.game;
|
package com.pandoli365.bibimbap.game;
|
||||||
|
|
||||||
/**
|
|
||||||
* 데모용 게임 메타데이터. DB 연동 시 저장소로 대체하면 됩니다.
|
|
||||||
*/
|
|
||||||
public final class GameCatalog {
|
public final class GameCatalog {
|
||||||
|
|
||||||
private GameCatalog() {
|
private GameCatalog() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static final int COUNT = 20;
|
public static final String[] NAMES = {};
|
||||||
|
|
||||||
public static final String[] NAMES = {
|
public static final String[] CREATORS = {};
|
||||||
"별빛 정원", "던전 카페", "픽셀 레이서", "구름 위를 걷다",
|
|
||||||
"마지막 타임라인", "요리하는 용", "네온 시티", "작은 숲의 집",
|
|
||||||
"역전 재판인", "달무리 탐정", "코드 러너", "바다 노래",
|
|
||||||
"시간 상자", "불꽃 학원", "조용한 우주", "종이 비행기",
|
|
||||||
"거울 미로", "손끝 RPG", "노을 역", "꿈의 도서관"
|
|
||||||
};
|
|
||||||
|
|
||||||
public static final String[] CREATORS = {
|
public static final int[] LIKE_COUNTS = {};
|
||||||
"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 String[] CREATOR_NOTES = {};
|
||||||
1284, 56, 8921, 234,
|
|
||||||
1205, 445, 678, 9012,
|
|
||||||
3400, 12, 567, 89,
|
|
||||||
4456, 223, 7777, 156,
|
|
||||||
990, 34, 2100, 888
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 제작자 한마디 (상세 페이지 하단) */
|
public static final String[] GIT_URLS = {};
|
||||||
public static final String[] CREATOR_NOTES = {
|
|
||||||
"밤하늘을 걸으며 힐링하고 싶어서 만들었습니다. 플레이 해 주셔서 감사합니다.",
|
|
||||||
"카페 던전은 사랑입니다. 버그 제보는 Git 이슈로 부탁드려요.",
|
|
||||||
"속도감 있는 레이싱! 컨트롤은 WASD, 모바일은 추후 지원 예정이에요.",
|
|
||||||
"구름 위를 걷는 기분을 WebGL로 담아봤습니다.",
|
|
||||||
"스토리에 집중했습니다. 엔딩까지 플레이해 주세요.",
|
|
||||||
"요리 도트에 진심입니다. 레시피 아이디어 환영합니다.",
|
|
||||||
"네온 사인이 마음에 드셨다면 별 하나 부탁드려요.",
|
|
||||||
"작은 집에서 시작하는 하루. 소소한 상호작용을 즐겨 주세요.",
|
|
||||||
"반전 스토리, 스포일러는 삼가 주세요!",
|
|
||||||
"추리는 끝이 없어요. 힌트는 커뮤니티에 올려 두었습니다.",
|
|
||||||
"코딩하듯 플레이하는 러너. PR도 환영합니다.",
|
|
||||||
"바다 소리를 들으며 플레이해 보세요. 이어폰 추천!",
|
|
||||||
"시간 루프가 헷갈리면 메모를 추천합니다.",
|
|
||||||
"학원물이지만 가볍게 즐겨 주세요.",
|
|
||||||
"우주는 넓고 할 일은 많습니다. 업데이트 예정이에요.",
|
|
||||||
"종이비행기처럼 가볍게 날아가 보세요.",
|
|
||||||
"거울 방향이 헷갈릴 수 있어요. 인내심을…",
|
|
||||||
"손끝으로 즐기는 턴제 RPG입니다.",
|
|
||||||
"노을이 지는 역에서 만나요.",
|
|
||||||
"책 속 세계로 오신 걸 환영합니다."
|
|
||||||
};
|
|
||||||
|
|
||||||
public static final String[] GIT_URLS = {
|
public static final int COUNT = NAMES.length;
|
||||||
"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 boolean isValidId(int id) {
|
public static boolean isValidId(int id) {
|
||||||
return id >= 1 && id <= COUNT;
|
return id >= 1 && id <= COUNT;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -822,7 +822,7 @@
|
||||||
var emptyEl = document.getElementById('game-comments-empty');
|
var emptyEl = document.getElementById('game-comments-empty');
|
||||||
var form = document.getElementById('game-comment-form');
|
var form = document.getElementById('game-comment-form');
|
||||||
var input = document.getElementById('game-comment-input');
|
var input = document.getElementById('game-comment-input');
|
||||||
var DEFAULT_NICK = 'test';
|
var DEFAULT_NICK = '익명';
|
||||||
|
|
||||||
function renderComments() {
|
function renderComments() {
|
||||||
var items = getComments().slice().sort(function (a, b) {
|
var items = getComments().slice().sort(function (a, b) {
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@
|
||||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<a class="site-header__profile" href="${pageContext.request.contextPath}/#" aria-label="프로필" title="프로필">
|
<a class="site-header__profile" href="${pageContext.request.contextPath}/login" aria-label="프로필" title="프로필">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||||
<circle cx="12" cy="7" r="4"/>
|
<circle cx="12" cy="7" r="4"/>
|
||||||
|
|
@ -153,6 +153,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<jsp:include page="/WEB-INF/views/modal.jsp"/>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var btn = document.getElementById('theme-toggle');
|
var btn = document.getElementById('theme-toggle');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,319 @@
|
||||||
|
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
|
||||||
|
<title>로그인 | bibimbap</title>
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
color-scheme: light;
|
||||||
|
--surface: #faf8f5;
|
||||||
|
--card-bg: #fff;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--text-muted: #5c5c5c;
|
||||||
|
--accent: #e8a54b;
|
||||||
|
--border: rgba(0, 0, 0, 0.08);
|
||||||
|
--card-shadow: rgba(0, 0, 0, 0.06);
|
||||||
|
--button-text: #1a1a1a;
|
||||||
|
--field-bg: #fff;
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--surface: #121212;
|
||||||
|
--card-bg: #1e1e1e;
|
||||||
|
--text: #ece8e1;
|
||||||
|
--text-muted: #a39e96;
|
||||||
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
|
--card-shadow: rgba(0, 0, 0, 0.35);
|
||||||
|
--button-text: #1a1a1a;
|
||||||
|
--field-bg: #181818;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.auth-main {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 72rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.auth-panel {
|
||||||
|
width: min(100%, 28rem);
|
||||||
|
padding: 2rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
box-shadow: 0 2px 8px var(--card-shadow);
|
||||||
|
}
|
||||||
|
.auth-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.875rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.auth-brand__logo {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.auth-panel__eyebrow {
|
||||||
|
margin: 0 0 0.2rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.auth-panel__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.auth-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
.auth-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
.auth-field__label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.auth-field__input {
|
||||||
|
width: 100%;
|
||||||
|
height: 3rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 0.875rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--field-bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.auth-field__input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.auth-field__input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(232, 165, 75, 0.25);
|
||||||
|
}
|
||||||
|
.auth-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: -0.125rem;
|
||||||
|
}
|
||||||
|
.auth-check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.auth-check input {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
.auth-button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 3rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0 1rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.auth-button:hover {
|
||||||
|
border-color: rgba(232, 165, 75, 0.45);
|
||||||
|
box-shadow: 0 4px 12px var(--card-shadow);
|
||||||
|
}
|
||||||
|
.auth-button:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
.auth-button--primary {
|
||||||
|
border-color: transparent;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--button-text);
|
||||||
|
}
|
||||||
|
.auth-panel__footer {
|
||||||
|
margin: 1.25rem 0 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.auth-link {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.auth-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.auth-main {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
}
|
||||||
|
.auth-panel {
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
}
|
||||||
|
.auth-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<jsp:include page="/WEB-INF/views/header.jsp"/>
|
||||||
|
<%
|
||||||
|
String ctx = request.getContextPath();
|
||||||
|
%>
|
||||||
|
<main class="auth-main">
|
||||||
|
<section class="auth-panel" aria-labelledby="login-title">
|
||||||
|
<div class="auth-brand">
|
||||||
|
<img class="auth-brand__logo" src="<%= ctx %>/images/logo.png" alt="" width="120" height="120" />
|
||||||
|
<div>
|
||||||
|
<p class="auth-panel__eyebrow">BIBIMBAP</p>
|
||||||
|
<h1 class="auth-panel__title" id="login-title">로그인</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="auth-form" action="<%= ctx %>/login" method="post" id="login-form">
|
||||||
|
<div class="auth-field">
|
||||||
|
<label class="auth-field__label" for="login-email">이메일</label>
|
||||||
|
<input class="auth-field__input" type="email" id="login-email" name="email" placeholder="name@example.com" autocomplete="username" required />
|
||||||
|
</div>
|
||||||
|
<div class="auth-field">
|
||||||
|
<label class="auth-field__label" for="login-password">비밀번호</label>
|
||||||
|
<input class="auth-field__input" type="password" id="login-password" name="password" autocomplete="current-password" required />
|
||||||
|
</div>
|
||||||
|
<div class="auth-row">
|
||||||
|
<label class="auth-check">
|
||||||
|
<input type="checkbox" name="remember" value="true" />
|
||||||
|
<span>로그인 유지</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button class="auth-button auth-button--primary" type="submit">로그인</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="auth-panel__footer">
|
||||||
|
계정이 없나요?
|
||||||
|
<a class="auth-link" href="<%= ctx %>/signup">회원가입</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var ctx = '<%= ctx %>';
|
||||||
|
var form = document.getElementById('login-form');
|
||||||
|
var submitBtn = form ? form.querySelector('button[type="submit"]') : null;
|
||||||
|
|
||||||
|
function openModal(title, message, confirmText, onConfirm) {
|
||||||
|
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
|
||||||
|
window.BibimbapModal.alert({
|
||||||
|
title: title,
|
||||||
|
message: message,
|
||||||
|
confirmText: confirmText || '확인',
|
||||||
|
onConfirm: onConfirm
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert(message);
|
||||||
|
if (typeof onConfirm === 'function') {
|
||||||
|
onConfirm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
if (!form.checkValidity()) {
|
||||||
|
form.reportValidity();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams(new FormData(form))
|
||||||
|
}).then(function (res) {
|
||||||
|
if (res.ok) {
|
||||||
|
openModal('로그인 완료', '로그인되었습니다.', '홈으로 이동', function () {
|
||||||
|
window.location.href = ctx + '/';
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json().catch(function () {
|
||||||
|
return { message: '로그인을 처리하지 못했습니다.' };
|
||||||
|
}).then(function (data) {
|
||||||
|
throw new Error(data && data.message ? data.message : '로그인을 처리하지 못했습니다.');
|
||||||
|
});
|
||||||
|
}).catch(function (err) {
|
||||||
|
openModal('로그인 실패', err.message || '로그인을 처리하지 못했습니다.');
|
||||||
|
}).finally(function () {
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get('error')) {
|
||||||
|
var messages = {
|
||||||
|
invalid: '이메일 또는 비밀번호를 확인해 주세요.',
|
||||||
|
inactive: '사용할 수 없는 계정입니다.'
|
||||||
|
};
|
||||||
|
openModal('로그인 실패', messages[params.get('error')] || '로그인을 처리하지 못했습니다.');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
|
||||||
|
<style>
|
||||||
|
.site-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(0, 0, 0, 0.48);
|
||||||
|
}
|
||||||
|
.site-modal[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.site-modal__panel {
|
||||||
|
width: min(100%, 24rem);
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px solid var(--modal-border, rgba(0, 0, 0, 0.08));
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--modal-bg, #fff);
|
||||||
|
color: var(--modal-text, #1a1a1a);
|
||||||
|
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .site-modal__panel {
|
||||||
|
--modal-bg: #1e1e1e;
|
||||||
|
--modal-text: #ece8e1;
|
||||||
|
--modal-muted: #a39e96;
|
||||||
|
--modal-border: rgba(255, 255, 255, 0.1);
|
||||||
|
box-shadow: 0 18px 56px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
html:not([data-theme="dark"]) .site-modal__panel {
|
||||||
|
--modal-muted: #5c5c5c;
|
||||||
|
}
|
||||||
|
.site-modal__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
letter-spacing: 0;
|
||||||
|
color: var(--modal-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
.site-modal__message {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--modal-muted, #5c5c5c);
|
||||||
|
}
|
||||||
|
.site-modal__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
.site-modal__button {
|
||||||
|
min-width: 5rem;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #e8a54b;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.site-modal__button:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="site-modal" id="site-modal" hidden role="dialog" aria-modal="true" aria-labelledby="site-modal-title" aria-describedby="site-modal-message">
|
||||||
|
<div class="site-modal__panel" role="document">
|
||||||
|
<h2 class="site-modal__title" id="site-modal-title"></h2>
|
||||||
|
<p class="site-modal__message" id="site-modal-message"></p>
|
||||||
|
<div class="site-modal__actions">
|
||||||
|
<button class="site-modal__button" type="button" id="site-modal-confirm">확인</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var root = document.getElementById('site-modal');
|
||||||
|
var titleEl = document.getElementById('site-modal-title');
|
||||||
|
var messageEl = document.getElementById('site-modal-message');
|
||||||
|
var confirmBtn = document.getElementById('site-modal-confirm');
|
||||||
|
var onConfirm = null;
|
||||||
|
var lastFocused = null;
|
||||||
|
|
||||||
|
if (!root || !titleEl || !messageEl || !confirmBtn) return;
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
root.hidden = true;
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
var callback = onConfirm;
|
||||||
|
onConfirm = null;
|
||||||
|
if (lastFocused && typeof lastFocused.focus === 'function') {
|
||||||
|
lastFocused.focus();
|
||||||
|
}
|
||||||
|
if (typeof callback === 'function') {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(ev) {
|
||||||
|
if (ev.key === 'Enter') {
|
||||||
|
ev.preventDefault();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmBtn.addEventListener('click', close);
|
||||||
|
|
||||||
|
window.BibimbapModal = {
|
||||||
|
alert: function (options) {
|
||||||
|
var opts = options || {};
|
||||||
|
lastFocused = document.activeElement;
|
||||||
|
titleEl.textContent = opts.title || '알림';
|
||||||
|
messageEl.textContent = opts.message || '';
|
||||||
|
confirmBtn.textContent = opts.confirmText || '확인';
|
||||||
|
onConfirm = opts.onConfirm || null;
|
||||||
|
root.hidden = false;
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
confirmBtn.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,333 @@
|
||||||
|
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
|
||||||
|
<title>회원가입 | bibimbap</title>
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
color-scheme: light;
|
||||||
|
--surface: #faf8f5;
|
||||||
|
--card-bg: #fff;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--text-muted: #5c5c5c;
|
||||||
|
--accent: #e8a54b;
|
||||||
|
--border: rgba(0, 0, 0, 0.08);
|
||||||
|
--card-shadow: rgba(0, 0, 0, 0.06);
|
||||||
|
--button-text: #1a1a1a;
|
||||||
|
--field-bg: #fff;
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--surface: #121212;
|
||||||
|
--card-bg: #1e1e1e;
|
||||||
|
--text: #ece8e1;
|
||||||
|
--text-muted: #a39e96;
|
||||||
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
|
--card-shadow: rgba(0, 0, 0, 0.35);
|
||||||
|
--button-text: #1a1a1a;
|
||||||
|
--field-bg: #181818;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.auth-main {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 72rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.auth-panel {
|
||||||
|
width: min(100%, 30rem);
|
||||||
|
padding: 2rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
box-shadow: 0 2px 8px var(--card-shadow);
|
||||||
|
}
|
||||||
|
.auth-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.875rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.auth-brand__logo {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.auth-panel__eyebrow {
|
||||||
|
margin: 0 0 0.2rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.auth-panel__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.auth-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
.auth-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
.auth-field__label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.auth-field__input {
|
||||||
|
width: 100%;
|
||||||
|
height: 3rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 0.875rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--field-bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.auth-field__input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.auth-field__input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(232, 165, 75, 0.25);
|
||||||
|
}
|
||||||
|
.auth-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
.auth-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.auth-check input {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
.auth-link {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.auth-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.auth-button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 3rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0 1rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.auth-button:hover {
|
||||||
|
border-color: rgba(232, 165, 75, 0.45);
|
||||||
|
box-shadow: 0 4px 12px var(--card-shadow);
|
||||||
|
}
|
||||||
|
.auth-button:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
.auth-button--primary {
|
||||||
|
border-color: transparent;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--button-text);
|
||||||
|
}
|
||||||
|
.auth-panel__footer {
|
||||||
|
margin: 1.25rem 0 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.auth-main {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
}
|
||||||
|
.auth-panel {
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
}
|
||||||
|
.auth-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<jsp:include page="/WEB-INF/views/header.jsp"/>
|
||||||
|
<%
|
||||||
|
String ctx = request.getContextPath();
|
||||||
|
%>
|
||||||
|
<main class="auth-main">
|
||||||
|
<section class="auth-panel" aria-labelledby="signup-title">
|
||||||
|
<div class="auth-brand">
|
||||||
|
<img class="auth-brand__logo" src="<%= ctx %>/images/logo.png" alt="" width="120" height="120" />
|
||||||
|
<div>
|
||||||
|
<p class="auth-panel__eyebrow">BIBIMBAP</p>
|
||||||
|
<h1 class="auth-panel__title" id="signup-title">회원가입</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="auth-form" action="<%= ctx %>/signup" method="post" id="signup-form">
|
||||||
|
<input type="hidden" name="provider" value="email" />
|
||||||
|
<input type="hidden" name="providerUserId" id="signup-provider-user-id" />
|
||||||
|
<div class="auth-field">
|
||||||
|
<label class="auth-field__label" for="signup-display-name">표시 이름</label>
|
||||||
|
<input class="auth-field__input" type="text" id="signup-display-name" name="displayName" autocomplete="nickname" maxlength="32" required />
|
||||||
|
</div>
|
||||||
|
<div class="auth-field">
|
||||||
|
<label class="auth-field__label" for="signup-email">이메일</label>
|
||||||
|
<input class="auth-field__input" type="email" id="signup-email" name="email" placeholder="name@example.com" autocomplete="email" required />
|
||||||
|
</div>
|
||||||
|
<div class="auth-grid">
|
||||||
|
<div class="auth-field">
|
||||||
|
<label class="auth-field__label" for="signup-password">비밀번호</label>
|
||||||
|
<input class="auth-field__input" type="password" id="signup-password" name="password" autocomplete="new-password" minlength="8" required />
|
||||||
|
</div>
|
||||||
|
<div class="auth-field">
|
||||||
|
<label class="auth-field__label" for="signup-password-confirm">비밀번호 확인</label>
|
||||||
|
<input class="auth-field__input" type="password" id="signup-password-confirm" name="passwordConfirm" autocomplete="new-password" minlength="8" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="auth-check">
|
||||||
|
<input type="checkbox" name="termsAccepted" value="true" required />
|
||||||
|
<span>이용약관과 개인정보 처리방침에 동의합니다.</span>
|
||||||
|
</label>
|
||||||
|
<button class="auth-button auth-button--primary" type="submit">회원가입</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="auth-panel__footer">
|
||||||
|
이미 계정이 있나요?
|
||||||
|
<a class="auth-link" href="<%= ctx %>/login">로그인</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var ctx = '<%= ctx %>';
|
||||||
|
var form = document.getElementById('signup-form');
|
||||||
|
var emailInput = document.getElementById('signup-email');
|
||||||
|
var providerUserId = document.getElementById('signup-provider-user-id');
|
||||||
|
var submitBtn = form ? form.querySelector('button[type="submit"]') : null;
|
||||||
|
|
||||||
|
function openModal(title, message, confirmText, onConfirm) {
|
||||||
|
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
|
||||||
|
window.BibimbapModal.alert({
|
||||||
|
title: title,
|
||||||
|
message: message,
|
||||||
|
confirmText: confirmText || '확인',
|
||||||
|
onConfirm: onConfirm
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert(message);
|
||||||
|
if (typeof onConfirm === 'function') {
|
||||||
|
onConfirm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form && emailInput && providerUserId) {
|
||||||
|
form.addEventListener('submit', function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
if (!form.checkValidity()) {
|
||||||
|
form.reportValidity();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
providerUserId.value = emailInput.value.trim();
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams(new FormData(form))
|
||||||
|
}).then(function (res) {
|
||||||
|
if (res.ok) {
|
||||||
|
openModal('계정 생성 완료', '계정 생성이 완료되었습니다.', '로그인으로 이동', function () {
|
||||||
|
window.location.href = ctx + '/login';
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json().catch(function () {
|
||||||
|
return { message: '회원가입을 처리하지 못했습니다.' };
|
||||||
|
}).then(function (data) {
|
||||||
|
throw new Error(data && data.message ? data.message : '회원가입을 처리하지 못했습니다.');
|
||||||
|
});
|
||||||
|
}).catch(function (err) {
|
||||||
|
openModal('회원가입 실패', err.message || '회원가입을 처리하지 못했습니다.');
|
||||||
|
}).finally(function () {
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get('created') === '1') {
|
||||||
|
openModal('계정 생성 완료', '계정 생성이 완료되었습니다.', '로그인으로 이동', function () {
|
||||||
|
window.location.href = ctx + '/login';
|
||||||
|
});
|
||||||
|
} else if (params.get('error')) {
|
||||||
|
var messages = {
|
||||||
|
invalid: '입력값을 다시 확인해 주세요.',
|
||||||
|
duplicate: '이미 가입된 이메일입니다.'
|
||||||
|
};
|
||||||
|
openModal('회원가입 실패', messages[params.get('error')] || '회원가입을 처리하지 못했습니다.');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -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<String> 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<String> generateRequiredUpdateQueries(Connection selectConnection, Connection updateConnection)
|
||||||
|
throws SQLException {
|
||||||
|
Map<String, TableInfo> selectTables = loadTables(selectConnection, SELECT_SCHEMA);
|
||||||
|
Map<String, TableInfo> updateTables = loadTables(updateConnection, UPDATE_SCHEMA);
|
||||||
|
|
||||||
|
List<String> 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<ColumnInfo> commonColumns = selectTable.columns().stream()
|
||||||
|
.filter(column -> updateTable.columnNames().contains(column.name()))
|
||||||
|
.toList();
|
||||||
|
List<ColumnInfo> 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<String> 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<String> 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<String> 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<String> sqlLines,
|
||||||
|
Map<String, TableInfo> selectTables,
|
||||||
|
Map<String, TableInfo> 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<String> sqlLines,
|
||||||
|
Connection selectConnection,
|
||||||
|
Connection updateConnection,
|
||||||
|
TableInfo table,
|
||||||
|
List<ColumnInfo> columns,
|
||||||
|
List<ColumnInfo> 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<ColumnInfo> 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<ColumnInfo> 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<ColumnInfo> changedColumns) {
|
||||||
|
StringJoiner columns = new StringJoiner(", ");
|
||||||
|
for (ColumnInfo column : changedColumns) {
|
||||||
|
columns.add(column.name());
|
||||||
|
}
|
||||||
|
return columns.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Row findTargetRow(
|
||||||
|
Connection updateConnection,
|
||||||
|
TableInfo table,
|
||||||
|
List<ColumnInfo> columns,
|
||||||
|
List<ColumnInfo> 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<ColumnInfo> columns) throws SQLException {
|
||||||
|
Map<String, Object> values = new LinkedHashMap<>();
|
||||||
|
for (ColumnInfo column : columns) {
|
||||||
|
values.put(column.name(), resultSet.getObject(column.name()));
|
||||||
|
}
|
||||||
|
return new Row(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, TableInfo> loadTables(Connection connection, String schema) throws SQLException {
|
||||||
|
Map<String, List<ColumnInfo>> tableColumns = loadColumns(connection, schema);
|
||||||
|
Map<String, Set<String>> primaryKeys = loadPrimaryKeys(connection, schema);
|
||||||
|
Map<String, TableInfo> tables = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
for (Map.Entry<String, List<ColumnInfo>> entry : tableColumns.entrySet()) {
|
||||||
|
tables.put(entry.getKey(), new TableInfo(
|
||||||
|
entry.getKey(),
|
||||||
|
entry.getValue(),
|
||||||
|
primaryKeys.getOrDefault(entry.getKey(), Set.of())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return tables;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, List<ColumnInfo>> 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<String, List<ColumnInfo>> 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<String, Set<String>> 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<String, Set<String>> 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<ColumnInfo> 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<ColumnInfo> columns, Set<String> primaryKeys) {
|
||||||
|
private Set<String> columnNames() {
|
||||||
|
Set<String> names = new LinkedHashSet<>();
|
||||||
|
for (ColumnInfo column : columns) {
|
||||||
|
names.add(column.name());
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Row(Map<String, Object> values) {
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue