로그인 페이지 작업완료

This commit is contained in:
pandoli365 2026-05-03 17:56:40 +09:00
parent 23f594c3c5
commit 3508a4b3bb
23 changed files with 2667 additions and 77 deletions

View File

@ -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`를 사용한다.

183
docs/user-signup-schema.md Normal file
View File

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

@ -27,6 +27,7 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<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>
<maven-clean-plugin.version>3.4.1</maven-clean-plugin.version>
<maven-compiler-plugin.version>3.14.1</maven-compiler-plugin.version>
@ -53,11 +54,20 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</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>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>

View File

@ -5,6 +5,7 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@ -27,9 +28,9 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
return mv;
}
@RequestMapping("/{pageName}")
@GetMapping("/{pageName}")
public ModelAndView mainView(@PathVariable("pageName") String pageName,
@RequestParam(required = false) String id,
@RequestParam(name = "id", required = false) String id,
HttpSession session,
HttpServletRequest request) {
ModelAndView mv = new ModelAndView();
@ -44,6 +45,12 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
mv.addObject("statusCode", status);
}
break;
case "login":
mv.setViewName("/login");
break;
case "signup":
mv.setViewName("/signup");
break;
default:
mv.setViewName("/index");
break;

View File

@ -26,8 +26,7 @@ public class GameController {
model.addAttribute("likeCountFormatted", String.format("%,d", GameCatalog.LIKE_COUNTS[idx]));
model.addAttribute("creatorNote", GameCatalog.CREATOR_NOTES[idx]);
model.addAttribute("gitUrl", GameCatalog.GIT_URLS[idx]);
/* 데모: 공통 플레이스홀더. 실제 빌드는 static/webgl/game-{id}/ 에 두고 아래 한 줄을 webglUrlForGame(id) 로 바꾸면 됩니다. */
model.addAttribute("webglUrl", "/webgl/placeholder/index.html");
model.addAttribute("webglUrl", webglUrlForGame(id));
model.addAttribute("webglDeployPath", webglUrlForGame(id));
return "game-detail";
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,85 +1,21 @@
package com.pandoli365.bibimbap.game;
/**
* 데모용 게임 메타데이터. DB 연동 저장소로 대체하면 됩니다.
*/
public final class GameCatalog {
private GameCatalog() {
}
public static final int COUNT = 20;
public static final String[] NAMES = {};
public static final String[] NAMES = {
"별빛 정원", "던전 카페", "픽셀 레이서", "구름 위를 걷다",
"마지막 타임라인", "요리하는 용", "네온 시티", "작은 숲의 집",
"역전 재판인", "달무리 탐정", "코드 러너", "바다 노래",
"시간 상자", "불꽃 학원", "조용한 우주", "종이 비행기",
"거울 미로", "손끝 RPG", "노을 역", "꿈의 도서관"
};
public static final String[] CREATORS = {};
public static final String[] CREATORS = {
"Studio Luna", "김민재", "PixelCat", "이하늘",
"Team Horizon", "별작업실", "NEON LAB", "숲그림",
"Court Games", "달무리", "dev.han", "wave.sound",
"BoxSoft", "학원제작소", "COSMOS", "PaperFly",
"mirror.inc", "손끝게임즈", "노을팀", "책벌레"
};
public static final int[] LIKE_COUNTS = {};
public static final int[] LIKE_COUNTS = {
1284, 56, 8921, 234,
1205, 445, 678, 9012,
3400, 12, 567, 89,
4456, 223, 7777, 156,
990, 34, 2100, 888
};
public static final String[] CREATOR_NOTES = {};
/** 제작자 한마디 (상세 페이지 하단) */
public static final String[] CREATOR_NOTES = {
"밤하늘을 걸으며 힐링하고 싶어서 만들었습니다. 플레이 해 주셔서 감사합니다.",
"카페 던전은 사랑입니다. 버그 제보는 Git 이슈로 부탁드려요.",
"속도감 있는 레이싱! 컨트롤은 WASD, 모바일은 추후 지원 예정이에요.",
"구름 위를 걷는 기분을 WebGL로 담아봤습니다.",
"스토리에 집중했습니다. 엔딩까지 플레이해 주세요.",
"요리 도트에 진심입니다. 레시피 아이디어 환영합니다.",
"네온 사인이 마음에 드셨다면 별 하나 부탁드려요.",
"작은 집에서 시작하는 하루. 소소한 상호작용을 즐겨 주세요.",
"반전 스토리, 스포일러는 삼가 주세요!",
"추리는 끝이 없어요. 힌트는 커뮤니티에 올려 두었습니다.",
"코딩하듯 플레이하는 러너. PR도 환영합니다.",
"바다 소리를 들으며 플레이해 보세요. 이어폰 추천!",
"시간 루프가 헷갈리면 메모를 추천합니다.",
"학원물이지만 가볍게 즐겨 주세요.",
"우주는 넓고 할 일은 많습니다. 업데이트 예정이에요.",
"종이비행기처럼 가볍게 날아가 보세요.",
"거울 방향이 헷갈릴 수 있어요. 인내심을…",
"손끝으로 즐기는 턴제 RPG입니다.",
"노을이 지는 역에서 만나요.",
"책 속 세계로 오신 걸 환영합니다."
};
public static final String[] GIT_URLS = {};
public static final String[] GIT_URLS = {
"https://github.com/example/starlight-garden",
"https://github.com/example/dungeon-cafe",
"https://github.com/example/pixel-racer",
"https://github.com/example/cloud-walk",
"https://github.com/example/last-timeline",
"https://github.com/example/cooking-dragon",
"https://github.com/example/neon-city",
"https://github.com/example/small-forest",
"https://github.com/example/court-game",
"https://github.com/example/moon-detective",
"https://github.com/example/code-runner",
"https://github.com/example/sea-song",
"https://github.com/example/time-box",
"https://github.com/example/flame-school",
"https://github.com/example/quiet-space",
"https://github.com/example/paper-plane",
"https://github.com/example/mirror-maze",
"https://github.com/example/fingertip-rpg",
"https://github.com/example/sunset-station",
"https://github.com/example/dream-library"
};
public static final int COUNT = NAMES.length;
public static boolean isValidId(int id) {
return id >= 1 && id <= COUNT;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -822,7 +822,7 @@
var emptyEl = document.getElementById('game-comments-empty');
var form = document.getElementById('game-comment-form');
var input = document.getElementById('game-comment-input');
var DEFAULT_NICK = 'test';
var DEFAULT_NICK = '익명';
function renderComments() {
var items = getComments().slice().sort(function (a, b) {

View File

@ -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"/>
</svg>
</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">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
@ -153,6 +153,7 @@
</div>
</div>
</header>
<jsp:include page="/WEB-INF/views/modal.jsp"/>
<script>
(function () {
var btn = document.getElementById('theme-toggle');

View File

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

View File

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

View File

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

View File

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