diff --git a/.github/ISSUE_TEMPLATE/Enhancement.md b/.github/ISSUE_TEMPLATE/Enhancement.md new file mode 100644 index 000000000..c87b3c3d1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Enhancement.md @@ -0,0 +1,20 @@ +--- +name: 'πŸ”₯Enhancement Issue' +about: 'κΈ°μ‘΄ κΈ°λŠ₯을 κ°œμ„ ν•©λ‹ˆλ‹€.' +title: '[domain] - issue title' +labels: 'enhancement' +assignees: '' + +--- + +### πŸ“ κΈ°μ‘΄ κΈ°λŠ₯ + +- + +### πŸ”¨ κ°œμ„  이유 및 κ°œμ„  사항 + +- + +### ✏️ μž‘μ—…ν•  λ‚΄μš© + +- [ ] \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/NewFeature.md b/.github/ISSUE_TEMPLATE/NewFeature.md new file mode 100644 index 000000000..a14741165 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/NewFeature.md @@ -0,0 +1,16 @@ +--- +name: '✨New Feature' +about: 'μƒˆλ‘œμš΄ κΈ°λŠ₯을 μΆ”κ°€ν•©λ‹ˆλ‹€.' +title: '[domain] - issue title' +labels: 'feature' +assignees: '' + +--- + +### 🎯 μƒˆλ‘œμš΄ κΈ°λŠ₯ + +- + +### ✏️ μž‘μ—…ν•  λ‚΄μš© + +- [ ] \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/Refactor.md b/.github/ISSUE_TEMPLATE/Refactor.md new file mode 100644 index 000000000..207e8c66b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Refactor.md @@ -0,0 +1,18 @@ +--- +name: '♻️Refactoring issue' +about: 'λ¦¬νŽ™ν† λ§ 이슈λ₯Ό μΆ”κ°€ν•©λ‹ˆλ‹€.' +title: '[domain] - issue title' +labels: 'refactor' +assignees: '' + +--- + +### ♿️ λ¦¬νŽ™ν† λ§ λ°°κ²½ + +- + +- + +### 🚚️ μž‘μ—…ν•  λ‚΄μš© + +- [ ] \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..ffd8caa8d --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ +### πŸ’» μž‘μ—… λ‚΄μš© + +- + +### ✨ 리뷰 포인트 + + +### πŸ“ λ©”λͺ¨ + + +### 🎯 κ΄€λ ¨ 이슈 + +closed #이슈번호 + + \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..9abdd7716 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,71 @@ +name: CI pipeline + +on: + pull_request: + branches: + - develop + - main + workflow_dispatch: +jobs: + ################################################# + # λ‹¨μœ„ ν…ŒμŠ€νŠΈ + ################################################# + unit-test: + runs-on: ubuntu-24.04 + permissions: + contents: read + statuses: write + + steps: + # 1) μ†ŒμŠ€ 체크아웃 + - name: Checkout branch HEAD + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + persist-credentials: true + + # 1-2) race condition λŒ€λΉ„μš© pull + - name: Ensure latest commit + run: | + git pull --ff-only origin "${{ github.head_ref }}" + + # 2) JDK μ„€μΉ˜ + Gradle 캐싱 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: 17 + cache: 'gradle' + + # 3) Gradle Wrapper κΆŒν•œ λΆ€μ—¬ + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + # 4) λ‹¨μœ„ ν…ŒμŠ€νŠΈ + - name: Run unit tests + run: ./gradlew --no-daemon test + + # ν…ŒμŠ€νŠΈ 리포트 μ—…λ‘œλ“œ + - name: Upload Test Report (HTML) + uses: actions/upload-artifact@v4 + with: + name: junit-report + path: build/reports/tests/test/**/*.html + - name: Upload Test Report (XML) + uses: actions/upload-artifact@v4 + with: + name: junit-xml-report + path: build/test-results/test/**/*.xml + + # 5) λΉŒλ“œ(JAR 파일 생성) + - name: Build jar + run: ./gradlew --no-daemon build -x test --stacktrace + + # λΉŒλ“œ μ‹€νŒ¨ μ‹œ 둜그 μ—…λ‘œλ“œ + - name: Upload Build Logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: build-logs + path: build/reports/ \ No newline at end of file diff --git a/.github/workflows/code-review.yml b/.github/workflows/code-review.yml new file mode 100644 index 000000000..0433fdca2 --- /dev/null +++ b/.github/workflows/code-review.yml @@ -0,0 +1,24 @@ +name: Claude Auto PR Review +on: + pull_request: + types: [opened, reopened] + +permissions: + contents: read + pull-requests: write + checks: write + id-token: write + +jobs: + review: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Simple LLM Code Review + uses: codingbaraGo/simple-llm-code-review@latest + with: + anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} + language: korean \ No newline at end of file diff --git a/src/main/java/app/db/UserRepository.java b/src/main/java/app/db/UserRepository.java new file mode 100644 index 000000000..d87b525d1 --- /dev/null +++ b/src/main/java/app/db/UserRepository.java @@ -0,0 +1,11 @@ +package app.db; + +import app.model.User; +import database.ConnectionManager; +import database.CrudRepository; + +public class UserRepository extends CrudRepository { + public UserRepository(ConnectionManager connectionManager) { + super(connectionManager, User.class); + } +} diff --git a/src/main/java/app/handler/RegisterWithPost.java b/src/main/java/app/handler/RegisterWithPost.java index 066683b9b..410dc497d 100644 --- a/src/main/java/app/handler/RegisterWithPost.java +++ b/src/main/java/app/handler/RegisterWithPost.java @@ -1,7 +1,8 @@ package app.handler; -import app.db.Database; +import app.db.UserRepository; import app.model.User; +import config.VariableConfig; import exception.ErrorCode; import exception.ServiceException; import http.HttpMethod; @@ -14,19 +15,58 @@ import web.response.RedirectResponse; public class RegisterWithPost extends SingleArgHandler { + private static final String EMAIL = "email"; + private static final String NICKNAME = "nickname"; + private static final String PASSWORD = "password"; + private static final Logger log = LoggerFactory.getLogger(RegisterWithPost.class); - public RegisterWithPost() { + private final UserRepository userRepository; + + public RegisterWithPost(UserRepository userRepository) { super(HttpMethod.POST, "/user/create"); + this.userRepository = userRepository; } @Override public HandlerResponse handle(QueryParameters params) { - String email = params.getQueryValue("email").orElseThrow(()-> new ServiceException(ErrorCode.MISSING_REGISTER_TOKEN, "email required")); - String nickname = params.getQueryValue("nickname").orElseThrow(()-> new ServiceException(ErrorCode.MISSING_REGISTER_TOKEN, "nickname required")); - String password = params.getQueryValue("password").orElseThrow(()-> new ServiceException(ErrorCode.MISSING_REGISTER_TOKEN, "password required")); - Database.addUser(new User(password, nickname, email, UserRole.MEMBER.toString())); - log.info("Registered - password:{}, nickname:{}, email:{}", password, nickname, email); + String email = getRequired(params, EMAIL); + String nickname = getRequired(params, NICKNAME); + String password = getRequired(params, PASSWORD); + + validate(email, nickname, password); + + User saved = userRepository.save( + new User(password, nickname, email, UserRole.MEMBER.toString())); + + log.info("Registered id:{}, email:{}, nickname:{}, password:{}", + saved.getId(), email, nickname, password); return RedirectResponse.to("/login"); } + + private void validate(String email, String nickname, String password) { + validateDuplicate(email, nickname); + validateLength(email, VariableConfig.EMAIL_MIN, VariableConfig.EMAIL_MAX, ErrorCode.EMAIL_LENGTH_INVALID); + validateLength(nickname, VariableConfig.NICKNAME_MIN, VariableConfig.NICKNAME_MAX, ErrorCode.NICKNAME_LENGTH_INVALID); + validateLength(password, VariableConfig.PASSWORD_MIN, VariableConfig.PASSWORD_MAX, ErrorCode.PASSWORD_LENGTH_INVALID); + } + + private String getRequired(QueryParameters params, String key) { + return params.getQueryValue(key) + .orElseThrow(() -> new ServiceException(ErrorCode.MISSING_REGISTER_TOKEN, key + " required")); + } + + private void validateLength(String value, int min, int max, ErrorCode code) { + int len = value.length(); + if (len < min || len > max) + throw new ServiceException(code); + } + + private void validateDuplicate(String email, String nickname) { + if (!userRepository.findByColumn(EMAIL, email).isEmpty()) + throw new ServiceException(ErrorCode.EMAIL_ALREADY_EXISTS); + + if (!userRepository.findByColumn(NICKNAME, nickname).isEmpty()) + throw new ServiceException(ErrorCode.NICKNAME_ALREADY_EXISTS); + } } diff --git a/src/main/java/app/model/User.java b/src/main/java/app/model/User.java index 936b915e6..7a1dd54bc 100644 --- a/src/main/java/app/model/User.java +++ b/src/main/java/app/model/User.java @@ -14,6 +14,9 @@ public User(String password, String nickname, String email, String userRole) { this.userRole = userRole; } + public User() { + } + public Long getId() { return id; } diff --git a/src/main/java/config/AppConfig.java b/src/main/java/config/AppConfig.java index bc5c4a9cd..da4871a78 100644 --- a/src/main/java/config/AppConfig.java +++ b/src/main/java/config/AppConfig.java @@ -1,5 +1,6 @@ package config; +import app.db.UserRepository; import app.handler.*; import database.ConnectionManager; import database.H2DbManager; @@ -120,8 +121,7 @@ public RegisterWithGet registerWithGet() { public RegisterWithPost registerWithPost() { return getOrCreate( "registerWithPost", - RegisterWithPost::new - ); + () -> new RegisterWithPost(userRepository())); } public LoginWithPost loginWithPost() { @@ -233,8 +233,9 @@ public QueryParamsResolver queryParamsResolver() { } public MultipartFormResolver multipartFormResolver(){ - return getOrCreate("multipartFormResolver", () -> - new MultipartFormResolver(multipartFormParser())); + return getOrCreate("multipartFormResolver", + () -> new MultipartFormResolver(multipartFormParser())); + } public MultipartFormParser multipartFormParser(){ @@ -250,10 +251,7 @@ public ExceptionHandlerMapping exceptionHandlerMapping() { List.of( serviceExceptionHandler(), errorExceptionHandler(), - unhandledErrorHandler() - ) - ) - ); + unhandledErrorHandler()))); } public ServiceExceptionHandler serviceExceptionHandler() { @@ -325,8 +323,13 @@ public H2DbManager h2DbManager(){ } public DdlGenerator ddlGenerator(){ - return getOrCreate(DdlGenerator.class.getSimpleName(), () -> - new DdlGenerator(connectionManager())); + return getOrCreate(DdlGenerator.class.getSimpleName(), + () -> new DdlGenerator(connectionManager())); + } + + public UserRepository userRepository(){ + return getOrCreate(UserRepository.class.getSimpleName(), + ()-> new UserRepository(connectionManager())); } } diff --git a/src/main/java/config/DdlGenerator.java b/src/main/java/config/DdlGenerator.java index 3dfb27dbf..0d06d7d7b 100644 --- a/src/main/java/config/DdlGenerator.java +++ b/src/main/java/config/DdlGenerator.java @@ -57,7 +57,7 @@ private String buildDdlForEntity(Class entityClass, String tableName) { continue; } - String columnName = field.getName(); + String columnName = toColumnName(field.getName()); Class fieldType = field.getType(); if (!firstColumn) { @@ -108,4 +108,9 @@ private String toTableName(Class clazz) { } return name; } + + private String toColumnName(String str){ + String snake = str.replaceAll("(? findAll() { } public List findByColumn(String columnName, Object value) { - String sql = "SELECT * FROM " + tableName + " WHERE " + columnName + " = ?"; + String sql = "SELECT * FROM " + tableName + " WHERE " + toColumnName(columnName) + " = ?"; try (Connection conn = connectionManager.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { @@ -145,7 +145,7 @@ public List findByColumn(String columnName, Object value) { } } catch (SQLException e) { - throw new ErrorException("μ—”ν‹°ν‹° 쑰회 쀑 였λ₯˜ (column=" + columnName + ", value=" + value + ")", e); + throw new ErrorException("μ—”ν‹°ν‹° 쑰회 쀑 였λ₯˜ (column=" + toColumnName(columnName) + ", value=" + value + ")", e); } } @@ -167,10 +167,10 @@ public void update(T entity) { if (Modifier.isStatic(field.getModifiers())) { continue; } - if ("id".equals(field.getName())) { + if ("id".equals(toColumnName(field.getName()))) { continue; } - sql.append(field.getName()).append(" = ?, "); + sql.append(toColumnName(field.getName())).append(" = ?, "); updateFields.add(field); } @@ -264,7 +264,7 @@ private T mapRow(ResultSet resultSet) { continue; } - String columnName = field.getName(); + String columnName = toColumnName(field.getName()); field.setAccessible(true); Class fieldType = field.getType(); @@ -311,4 +311,9 @@ private String toTableName(Class clazz) { } return name; } + + private String toColumnName(String str){ + String snake = str.replaceAll("(?