diff --git a/.gitignore b/.gitignore index b8b7680..0586e1b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,81 @@ -# Local configuration files -application-local.properties +# Global Ignore + +HELP.md +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +# STS / Eclipse +.apt_generated +.classpath +.factorypath +.project +.settings/ +.springBeans/ +.sts4-cache/ +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +# IntelliJ IDEA +.idea/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +# VS Code +.vscode/ + +# NetBeans +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +# Gradle +gradle-app.setting +/gradle.properties + +# Logs & Temp +logs/ +*.log +*.tmp +*.swp + +# OS-specific +.DS_Store +Thumbs.db + +# Docker +/docker-data/ +/docker-volume/ +/docker/volumes/ + +# Build artifacts (server module) +/server/build/ +/server/.gradle/ +/server/.idea/ + +# Security: Sensitive Config Files + +# 루트 .env .env +.env.* + +# server 내부 src/main/resources/.env +server/src/main/resources/.env + +# application-local*, application-prod* (민감정보) +**/application-local.yml +**/application-local.properties +**/application-prod.yml +**/application-prod.properties +# Unique Project Settings +*.pid diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b58b603 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8799ad9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,54 @@ +version: "3.9" + +services: + spring: + container_name: spring_app + build: + context: ./server + ports: + - "9000:9000" + env_file: + - ./server/.env + environment: + SPRING_PROFILES_ACTIVE: prod + SERVER_PORT: 9000 # 서버 포트 강제 + depends_on: + - ai_backend + - ai_mongo + networks: + - ai_network + + ai_backend: + container_name: ai_backend + build: + context: ../oba_AI + ports: + - "8000:8000" + env_file: + - ../oba_AI/.env + command: [ + "uvicorn", "app:app", + "--host", "0.0.0.0", + "--port", "8000" + ] + networks: + - ai_network + + ai_mongo: + image: mongo:7.0 + container_name: ai_mongo + ports: + - "27017:27017" + volumes: + - mongo_data:/data/db + environment: + MONGO_INITDB_DATABASE: OneBitArticle + networks: + - ai_network + +networks: + ai_network: + driver: bridge + +volumes: + mongo_data: \ No newline at end of file diff --git a/server/.env.example b/server/.env.example deleted file mode 100644 index 8efa412..0000000 --- a/server/.env.example +++ /dev/null @@ -1,25 +0,0 @@ -# Database Config -DB_URL=YOUR_DB_URL -DB_USERNAME=YOUR_DB_USERNAME -DB_PASSWORD=YOUR_DB_PASSWORD - -# MongoDB -MONGODB_URI=YOUR_MONGODB_URI -MONGODB_DATABASE=YOUR_MONGODB_DATABASE - -# JWT -JWT_SECRET=YOUR_JWT_SECRET -JWT_ACCESS_EXP=1800000 -JWT_REFRESH_EXP=604800000 - -# OAuth Google -GOOGLE_CLIENT_ID=YOUR_GOOGLE_ID -GOOGLE_CLIENT_SECRET=YOUR_GOOGLE_SECRET - -# OAuth Kakao -KAKAO_CLIENT_ID=YOUR_KAKAO_ID -KAKAO_CLIENT_SECRET=YOUR_KAKAO_SECRET - -# OAuth Naver -NAVER_CLIENT_ID=YOUR_NAVER_ID -NAVER_CLIENT_SECRET=YOUR_NAVER_SECRET diff --git a/server/.gitignore b/server/.gitignore deleted file mode 100644 index 9d20f01..0000000 --- a/server/.gitignore +++ /dev/null @@ -1,58 +0,0 @@ -HELP.md -.gradle -build/ -!gradle/wrapper/gradle-wrapper.jar -!**/src/main/**/build/ -!**/src/test/**/build/ - -### STS ### -.apt_generated -.classpath -.factorypath -.project -.settings -.springBeans -.sts4-cache -bin/ -!**/src/main/**/bin/ -!**/src/test/**/bin/ - -### IntelliJ IDEA ### -.idea/ -*.iws -*.iml -*.ipr -out/ -!**/src/main/**/out/ -!**/src/test/**/out/ - -### NetBeans ### -/nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ - -### VS Code ### -.vscode/ - -### Gradle ### -/gradle.properties - -### Logs & Temp ### -*.log -*.tmp -*.swp - -### OS-specific ### -.DS_Store -Thumbs.db - -### 🔒 민감정보 (환경설정) ### -# application 설정 파일 (민감정보 포함 가능) -src/main/resources/application.yml -src/main/resources/application.properties - -# 로컬 환경변수 파일 (.env 등) -.env -.env.local diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..4141dea --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,13 @@ +FROM gradle:8.5-jdk17 AS builder +WORKDIR /app +COPY build.gradle settings.gradle ./ +COPY gradle ./gradle +RUN gradle dependencies --no-daemon || true +COPY src ./src +RUN gradle bootJar --no-daemon + +FROM eclipse-temurin:17-jdk +WORKDIR /app +COPY --from=builder /app/build/libs/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/server/build.gradle b/server/build.gradle index a0f8657..835c65c 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -1,57 +1,63 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.5.4' - id 'io.spring.dependency-management' version '1.1.7' + id 'org.springframework.boot' version '3.5.4' + id 'io.spring.dependency-management' version '1.1.5' + id 'java' } group = 'oba.backend' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } -configurations { - compileOnly { - extendsFrom annotationProcessor - } -} repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-web' - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + /* -------------------- Google OAuth / ID Token -------------------- */ + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'com.google.api-client:google-api-client:2.2.0' + implementation 'com.google.http-client:google-http-client-gson:1.43.3' + /* -------------------- Spring Core -------------------- */ + implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' - // MySQL 라인을 추가 - runtimeOnly 'com.mysql:mysql-connector-j' + /* -------------------- Cache -------------------- */ + implementation 'org.springframework.boot:spring-boot-starter-cache' + implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8' + + /* -------------------- Database -------------------- */ + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' + runtimeOnly 'com.mysql:mysql-connector-j:8.0.33' - // JWT Library (jjwt) + /* -------------------- JWT -------------------- */ implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' - // testDB -> h2 - testRuntimeOnly 'com.h2database:h2' + /* -------------------- Scheduler -------------------- */ + implementation 'org.springframework.boot:spring-boot-starter-quartz' - implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' - implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' - implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' -} + /* -------------------- Dotenv -------------------- */ + implementation 'io.github.cdimascio:dotenv-java:3.0.0' + /* -------------------- Lombok (최신 버전으로 업데이트) -------------------- */ + compileOnly 'org.projectlombok:lombok:1.18.34' + annotationProcessor 'org.projectlombok:lombok:1.18.34' + + /* -------------------- Test -------------------- */ + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'com.h2database:h2' +} tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } diff --git a/server/gradlew b/server/gradlew old mode 100644 new mode 100755 diff --git a/server/netsh b/server/netsh new file mode 100644 index 0000000..bda8043 --- /dev/null +++ b/server/netsh @@ -0,0 +1,8 @@ + PID PPID PGID WINPID TTY UID STIME COMMAND + 1483 1 1483 319596 cons2 197609 20:42:39 /usr/bin/bash + 2954 1 2954 336312 cons3 197609 03:29:20 /usr/bin/bash + 3495 3487 3487 276496 cons2 197609 10:33:18 /c/Program Files/nodejs/node + 3487 1483 3487 386764 cons2 197609 10:33:16 /usr/bin/bash + 3646 2954 3646 394660 cons3 197609 10:40:08 /usr/bin/PS + 1394 1 1394 226624 cons0 197609 20:42:33 /usr/bin/bash + 1399 1 1399 324196 cons1 197609 20:42:34 /usr/bin/bash diff --git a/server/select b/server/select new file mode 100644 index 0000000..75da021 --- /dev/null +++ b/server/select @@ -0,0 +1,300 @@ +mysql Ver 9.5.0 for macos26.1 on arm64 (Homebrew) +Copyright (c) 2000, 2025, Oracle and/or its affiliates. + +Oracle is a registered trademark of Oracle Corporation and/or its +affiliates. Other names may be trademarks of their respective +owners. + +Usage: mysql [OPTIONS] [database] + -?, --help Display this help and exit. + -I, --help Synonym for -? + --auto-rehash Enable automatic rehashing. One doesn't need to use + 'rehash' to get table and field completion, but startup + and reconnecting may take a longer time. Disable with + --disable-auto-rehash. + (Defaults to on; use --skip-auto-rehash to disable.) + -A, --no-auto-rehash + No automatic rehashing. One has to use 'rehash' to get + table and field completion. This gives a quicker start of + mysql and disables rehashing on reconnect. + --auto-vertical-output + Automatically switch to vertical output mode if the + result is wider than the terminal width. + -B, --batch Don't use history file. Disable interactive behavior. + (Enables --silent.) + --bind-address=name IP address to bind to. + --binary-as-hex Print binary data as hex. Enabled by default for + interactive terminals. + --character-sets-dir=name + Directory for character set files. + --column-type-info Display column type information. + --commands Enable or disable processing of local mysql commands. + -c, --comments Preserve comments. Send comments to the server. The + default is --comments (keep comments), disable with + --skip-comments. + (Defaults to on; use --skip-comments to disable.) + -C, --compress Use compression in server/client protocol. + -#, --debug[=#] This is a non-debug version. Catch this and exit. + --debug-check This is a non-debug version. Catch this and exit. + -T, --debug-info This is a non-debug version. Catch this and exit. + -D, --database=name Database to use. + --default-character-set=name + Set the default character set. + --delimiter=name Delimiter to be used. + --enable-cleartext-plugin + Enable/disable the clear text authentication plugin. + -e, --execute=name Execute command and quit. (Disables --force and history + file.) + -E, --vertical Print the output of a query (rows) vertically. + -f, --force Continue even if we get an SQL error. + --histignore=name A colon-separated list of patterns to keep statements + from getting logged into syslog and mysql history. + -G, --named-commands + Enable named commands. Named commands mean this program's + internal commands; see mysql> help . When enabled, the + named commands can be used from any line of the query, + otherwise only from the first line, before an enter. + Disable with --disable-named-commands. This option is + disabled by default. + -i, --ignore-spaces Ignore space after function names. + --init-command=name Single SQL Command to execute when connecting to MySQL + server. Will automatically be re-executed when + reconnecting. + --init-command-add=name + Add SQL command to the list to execute when connecting to + MySQL server. Will automatically be re-executed when + reconnecting. + --local-infile Enable/disable LOAD DATA LOCAL INFILE. + -b, --no-beep Turn off beep on error. + -h, --host=name Connect to host. + --dns-srv-name=name Connect to a DNS SRV resource + -H, --html Produce HTML output. + -X, --xml Produce XML output. + --line-numbers Write line numbers for errors. + (Defaults to on; use --skip-line-numbers to disable.) + -L, --skip-line-numbers + Don't write line number for errors. + -n, --unbuffered Flush buffer after each query. + --column-names Write column names in results. + (Defaults to on; use --skip-column-names to disable.) + -N, --skip-column-names + Don't write column names in results. + --sigint-ignore Ignore SIGINT (CTRL-C). + -o, --one-database Ignore statements except those that occur while the + default database is the one named at the command line. + --pager[=name] Pager to use to display results. If you don't supply an + option, the default pager is taken from your ENV variable + PAGER. Valid pagers are less, more, cat [> filename], + etc. See interactive help (\h) also. This option does not + work in batch mode. Disable with --disable-pager. This + option is disabled by default. + -p, --password[=name] + Password to use when connecting to server. If password is + not given it's asked from the tty. + --password1[=name] Password for first factor authentication plugin. + --password2[=name] Password for second factor authentication plugin. + --password3[=name] Password for third factor authentication plugin. + -P, --port=# Port number to use for connection or 0 for default to, in + order of preference, my.cnf, $MYSQL_TCP_PORT, + /etc/services, built-in default (3306). + --prompt=name Set the mysql prompt to this value. + --protocol=name The protocol to use for connection (tcp, socket, pipe, + memory). + -q, --quick Don't cache result, print it row by row. This may slow + down the server if the output is suspended. Doesn't use + history file. + -r, --raw Write fields without conversion. Used with --batch. + --reconnect Reconnect if the connection is lost. Disable with + --disable-reconnect. This option is enabled by default. + (Defaults to on; use --skip-reconnect to disable.) + -s, --silent Be more silent. Print results with a tab as separator, + each row on new line. + -S, --socket=name The socket file to use for connection. + --server-public-key-path=name + File path to the server public RSA key in PEM format. + --get-server-public-key + Get server public key + --ssl-mode=name SSL connection mode. + --ssl-ca=name CA file in PEM format. + --ssl-capath=name CA directory. + --ssl-cert=name X509 cert in PEM format. + --ssl-cipher=name SSL cipher to use. + --ssl-key=name X509 key in PEM format. + --ssl-crl=name Certificate revocation list. + --ssl-crlpath=name Certificate revocation list path. + --tls-version=name TLS version to use, permitted values are: TLSv1.2, + TLSv1.3 + --ssl-fips-mode=name + SSL FIPS mode (applies only for OpenSSL); permitted + values are: OFF, ON, STRICT + --tls-ciphersuites=name + TLS v1.3 cipher to use. + --ssl-session-data=name + Session data file to use to enable ssl session reuse + --ssl-session-data-continue-on-failed-reuse + If set to ON, this option will allow connection to + succeed even if session data cannot be reused. + --tls-sni-servername=name + The SNI server name to pass to server + -t, --table Output in table format. + --tee=name Append everything into outfile. See interactive help (\h) + also. Does not work in batch mode. Disable with + --disable-tee. This option is disabled by default. + -u, --user=name User for login if not current user. + -U, --safe-updates Only allow UPDATE and DELETE that uses keys. + -U, --i-am-a-dummy Synonym for option --safe-updates, -U. + -v, --verbose Write more. (-v -v -v gives the table output format). + -V, --version Output version information and exit. + -w, --wait Wait and retry if connection is down. + --connect-timeout=# Number of seconds before connection timeout. + --max-allowed-packet=# + The maximum packet length to send to or receive from + server. + --net-buffer-length=# + The buffer size for TCP/IP and socket communication. + --select-limit=# Automatic limit for SELECT when using --safe-updates. + --max-join-size=# Automatic limit for rows in a join when using + --safe-updates. + --show-warnings Show warnings after every statement. + -j, --syslog Log filtered interactive commands to syslog. Filtering of + commands depends on the patterns supplied via histignore + option besides the default patterns. + --plugin-dir=name Directory for client-side plugins. + --default-auth=name Default authentication client-side plugin to use. + --binary-mode By default, ASCII '\0' is disallowed and '\r\n' is + translated to '\n'. This switch turns off both features, + and also turns off parsing of all clientcommands except + \C and DELIMITER, in non-interactive mode (for input + piped to mysql or loaded using the 'source' command). + This is necessary when processing output from mysqlbinlog + that may contain blobs. + --connect-expired-password + Notify the server that this client is prepared to handle + expired password sandbox mode. + --compression-algorithms=name + Use compression algorithm in server/client protocol. + Valid values are any combination of + 'zstd','zlib','uncompressed'. + --zstd-compression-level=# + Use this compression level in the client/server protocol, + in case --compression-algorithms=zstd. Valid range is + between 1 and 22, inclusive. Default is 3. + --load-data-local-dir=name + Directory path safe for LOAD DATA LOCAL INFILE to read + from. + --authentication-oci-client-config-profile=name + Specifies the configuration profile whose configuration + options are to be read from the OCI configuration file. + Default is DEFAULT. + --oci-config-file=name + Specifies the location of the OCI configuration file. + Default for Linux is ~/.oci/config and %HOME/.oci/config + on Windows. + --authentication-openid-connect-client-id-token-file=name + Specifies the location of the ID token file. + --telemetry-client Load the telemetry_client plugin. + --plugin-authentication-webauthn-client-preserve-privacy + Allows selection of discoverable credential to be used + for signing challenge. default is false - implies + challenge is signed by all credentials for given relying + party. + --plugin-authentication-webauthn-device=# + Specifies what libfido2 device to use. 0 (the first + device) is the default. + --register-factor=name + Specifies factor for which registration needs to be done + for. + --system-command Enable or disable (by default) the 'system' mysql + command. + +Default options are read from the following files in the given order: +/etc/my.cnf /etc/mysql/my.cnf /opt/homebrew/etc/my.cnf ~/.my.cnf +The following groups are read: mysql client +The following options may be given as the first argument: +--print-defaults Print the program argument list and exit. +--no-defaults Don't read default options from any option file, + except for login file. +--defaults-file=# Only read default options from the given file #. +--defaults-extra-file=# Read this file after the global files are read. +--defaults-group-suffix=# + Also read groups with concat(group, suffix) +--login-path=# Read this path from the login file. +--no-login-paths Don't read login paths from the login path file. + +Variables (--variable-name=value) +and boolean options {FALSE|TRUE} Value (after reading options) +------------------------------------------------------ ------------------- +auto-rehash TRUE +auto-vertical-output FALSE +bind-address (No default value) +binary-as-hex FALSE +character-sets-dir (No default value) +column-type-info FALSE +commands FALSE +comments TRUE +compress FALSE +database (No default value) +default-character-set auto +delimiter ; +enable-cleartext-plugin FALSE +vertical FALSE +force FALSE +histignore (No default value) +named-commands FALSE +ignore-spaces FALSE +local-infile FALSE +no-beep FALSE +host (No default value) +dns-srv-name (No default value) +html FALSE +xml FALSE +line-numbers TRUE +unbuffered FALSE +column-names TRUE +sigint-ignore FALSE +port 0 +prompt mysql> +quick FALSE +raw FALSE +reconnect FALSE +socket (No default value) +server-public-key-path (No default value) +get-server-public-key FALSE +ssl-ca (No default value) +ssl-capath (No default value) +ssl-cert (No default value) +ssl-cipher (No default value) +ssl-key (No default value) +ssl-crl (No default value) +ssl-crlpath (No default value) +tls-version (No default value) +tls-ciphersuites (No default value) +ssl-session-data (No default value) +ssl-session-data-continue-on-failed-reuse FALSE +tls-sni-servername (No default value) +table FALSE +user (No default value) +safe-updates FALSE +i-am-a-dummy FALSE +wait FALSE +connect-timeout 0 +max-allowed-packet 16777216 +net-buffer-length 16384 +select-limit 1000 +max-join-size 1000000 +show-warnings FALSE +plugin-dir (No default value) +default-auth (No default value) +binary-mode FALSE +connect-expired-password FALSE +compression-algorithms (No default value) +zstd-compression-level 3 +load-data-local-dir (No default value) +authentication-oci-client-config-profile (No default value) +oci-config-file (No default value) +authentication-openid-connect-client-id-token-file (No default value) +telemetry-client FALSE +plugin-authentication-webauthn-client-preserve-privacy FALSE +plugin-authentication-webauthn-device 0 +register-factor (No default value) +system-command FALSE diff --git a/server/src/main/java/oba/backend/server/ServerApplication.java b/server/src/main/java/oba/backend/server/ServerApplication.java index e9c2b29..4260785 100644 --- a/server/src/main/java/oba/backend/server/ServerApplication.java +++ b/server/src/main/java/oba/backend/server/ServerApplication.java @@ -2,15 +2,16 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaAuditing -@EnableScheduling // ← 추가 +@EnableScheduling +@EnableCaching public class ServerApplication { public static void main(String[] args) { SpringApplication.run(ServerApplication.class, args); } } - diff --git a/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java b/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java deleted file mode 100644 index 7a1051f..0000000 --- a/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java +++ /dev/null @@ -1,59 +0,0 @@ -package oba.backend.server.common.jwt; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; -import java.util.Arrays; - -@Component -@RequiredArgsConstructor -public class JwtAuthenticationFilter extends OncePerRequestFilter { - - private final JwtProvider jwtProvider; - - @Override - protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { - // ✅ 1. Access Token 먼저 꺼내오기 (쿠키에서) - String token = resolveTokenFromCookies(request); - - if (token != null && jwtProvider.validateToken(token)) { - var claims = jwtProvider.getClaims(token); - - // ✅ 2. Refresh Token이면 인증 불가 → 그냥 다음 필터로 - if ("refresh".equals(claims.get("type"))) { - filterChain.doFilter(request, response); - return; - } - - // ✅ 3. Access Token이면 SecurityContext에 인증정보 저장 - Authentication authentication = jwtProvider.getAuthentication(token); - SecurityContextHolder.getContext().setAuthentication(authentication); - } - - filterChain.doFilter(request, response); - } - - /** - * AccessToken을 HttpOnly 쿠키에서 가져오기 - */ - private String resolveTokenFromCookies(HttpServletRequest request) { - if (request.getCookies() == null) return null; - - return Arrays.stream(request.getCookies()) - .filter(cookie -> "access_token".equals(cookie.getName())) - .map(Cookie::getValue) - .findFirst() - .orElse(null); - } -} diff --git a/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java b/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java deleted file mode 100644 index b0ecd15..0000000 --- a/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java +++ /dev/null @@ -1,87 +0,0 @@ -package oba.backend.server.common.jwt; - -import io.jsonwebtoken.*; -import io.jsonwebtoken.io.Decoders; -import io.jsonwebtoken.security.Keys; -import oba.backend.server.dto.TokenResponse; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.User; -import org.springframework.stereotype.Component; - -import javax.crypto.SecretKey; -import java.util.Collections; -import java.util.Date; -import java.util.List; - -@Component -public class JwtProvider { - - private final SecretKey key; - private final long accessTokenValidity; // AccessToken 유효기간 - private final long refreshTokenValidity; // RefreshToken 유효기간 - - // ✅ application.properties / 환경변수에서 값 주입 - public JwtProvider( - @Value("${jwt.secret}") String secret, - @Value("${jwt.access-token-expiration-ms:1800000}") long accessTokenValidity, - @Value("${jwt.refresh-token-expiration-ms:604800000}") long refreshTokenValidity - ) { - this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); - this.accessTokenValidity = accessTokenValidity; - this.refreshTokenValidity = refreshTokenValidity; - } - - // ✅ 토큰 생성 (Access, Refresh 동시 발급) - public TokenResponse generateToken(Authentication authentication) { - String accessToken = createToken(authentication.getName(), "access", accessTokenValidity); - String refreshToken = createToken(authentication.getName(), "refresh", refreshTokenValidity); - return new TokenResponse(accessToken, refreshToken); - } - - private String createToken(String subject, String type, long validity) { - Date now = new Date(); - Date expiry = new Date(now.getTime() + validity); - - return Jwts.builder() - .setSubject(subject) - .setIssuedAt(now) - .setExpiration(expiry) - .claim("type", type) - .signWith(key, SignatureAlgorithm.HS256) - .compact(); - } - - // ✅ 토큰 검증 - public boolean validateToken(String token) { - try { - Jwts.parserBuilder() - .setSigningKey(key) - .build() - .parseClaimsJws(token); - return true; - } catch (JwtException | IllegalArgumentException e) { - return false; - } - } - - // ✅ Authentication 추출 - public Authentication getAuthentication(String token) { - Claims claims = getClaims(token); - String username = claims.getSubject(); - var authorities = List.of(new SimpleGrantedAuthority("ROLE_USER")); - User principal = new User(username, "", authorities); - return new UsernamePasswordAuthenticationToken(principal, token, authorities); - } - - // ✅ Claims 가져오기 - public Claims getClaims(String token) { - return Jwts.parserBuilder() - .setSigningKey(key) - .build() - .parseClaimsJws(token) - .getBody(); - } -} diff --git a/server/src/main/java/oba/backend/server/config/SecurityConfig.java b/server/src/main/java/oba/backend/server/config/SecurityConfig.java deleted file mode 100644 index 48a3bed..0000000 --- a/server/src/main/java/oba/backend/server/config/SecurityConfig.java +++ /dev/null @@ -1,56 +0,0 @@ -package oba.backend.server.config; - -import lombok.RequiredArgsConstructor; -import oba.backend.server.security.CustomAuthorizationRequestResolver; -import oba.backend.server.security.CustomOAuth2UserService; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.AuthenticationFailureHandler; - -@Configuration -@EnableWebSecurity -@RequiredArgsConstructor -public class SecurityConfig { - - private final CustomOAuth2UserService customOAuth2UserService; - private final CustomAuthorizationRequestResolver customAuthorizationRequestResolver; - - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - - AuthenticationFailureHandler keepSessionFailure = - (request, response, exception) -> - response.sendRedirect("/login?error=" + exception.getClass().getSimpleName()); - - http - .csrf(csrf -> csrf.disable()) - .formLogin(form -> form.disable()) - .httpBasic(b -> b.disable()) - .authorizeHttpRequests(auth -> auth - .requestMatchers("/ai/**").permitAll() - - .requestMatchers("/", "/login", "/error", - "/oauth2/**", "/css/**", "/js/**", "/images/**", "/img/**").permitAll() - - .anyRequest().authenticated() - ) - .oauth2Login(o -> o - .loginPage("/login") - .authorizationEndpoint(a -> a.authorizationRequestResolver(customAuthorizationRequestResolver)) - .userInfoEndpoint(u -> u.userService(customOAuth2UserService)) - .defaultSuccessUrl("/login?loggedIn", true) - .failureHandler(keepSessionFailure) - ) - .logout(l -> l - .logoutUrl("/logout") - .logoutSuccessUrl("/login?logout") - .deleteCookies("JSESSIONID","refresh_token") - .invalidateHttpSession(true) - ); - - return http.build(); - } -} diff --git a/server/src/main/java/oba/backend/server/controller/HomeController.java b/server/src/main/java/oba/backend/server/controller/HomeController.java deleted file mode 100644 index 430e2f8..0000000 --- a/server/src/main/java/oba/backend/server/controller/HomeController.java +++ /dev/null @@ -1,51 +0,0 @@ -package oba.backend.server.controller; - -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; - -import java.util.Map; - -@Controller -public class HomeController { - - @GetMapping("/") - public String root() { - return "redirect:/login"; - } - - @GetMapping("/login") - public String login(@AuthenticationPrincipal OAuth2User user, - HttpServletResponse resp, - Model model) { - // 뒤로가기 캐시 방지(선택) - resp.setHeader("Cache-Control", "no-store, must-revalidate"); - resp.setHeader("Pragma", "no-cache"); - resp.setDateHeader("Expires", 0); - - boolean loggedIn = (user != null); - model.addAttribute("loggedIn", loggedIn); - - if (loggedIn) { - Map attrs = user.getAttributes(); - String name = null; - Object n = attrs.get("name"); // google - if (n == null) { - Object kakaoAcc = attrs.get("kakao_account"); // kakao - if (kakaoAcc instanceof Map,?> kakao) { - Object profile = kakao.get("profile"); - if (profile instanceof Map,?> p) n = p.get("nickname"); - } - } - if (n == null) { - Object naverResp = attrs.get("response"); // naver - if (naverResp instanceof Map,?> respMap) n = respMap.get("name"); - } - model.addAttribute("userName", n != null ? n.toString() : user.getName()); - } - return "login"; - } -} diff --git a/server/src/main/java/oba/backend/server/controller/ProfileController.java b/server/src/main/java/oba/backend/server/controller/ProfileController.java deleted file mode 100644 index 08466d1..0000000 --- a/server/src/main/java/oba/backend/server/controller/ProfileController.java +++ /dev/null @@ -1,17 +0,0 @@ -package oba.backend.server.controller; - -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class ProfileController { - - @GetMapping("/profile") - public Object profile(@AuthenticationPrincipal OAuth2User user) { - // GitHub에서 가져온 전체 프로필 JSON 반환 - // 예: { id: 123, login: "...", email: "...", ... } - return user.getAttributes(); - } -} diff --git a/server/src/main/java/oba/backend/server/controller/AiController.java b/server/src/main/java/oba/backend/server/domain/ai/controller/AiController.java similarity index 59% rename from server/src/main/java/oba/backend/server/controller/AiController.java rename to server/src/main/java/oba/backend/server/domain/ai/controller/AiController.java index f37419d..59133c9 100644 --- a/server/src/main/java/oba/backend/server/controller/AiController.java +++ b/server/src/main/java/oba/backend/server/domain/ai/controller/AiController.java @@ -1,7 +1,7 @@ -package oba.backend.server.controller; +package oba.backend.server.domain.ai.controller; import lombok.RequiredArgsConstructor; -import oba.backend.server.service.AiService; +import oba.backend.server.domain.ai.service.AiService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -12,11 +12,9 @@ public class AiController { private final AiService aiService; - // 수동 실행 API (Postman 테스트용, 관리자용) @PostMapping("/generate/daily") public ResponseEntity runDailyAi() { - System.out.println("[Spring] /ai/generate/daily 요청 들어옴"); - String result = aiService.runDailyAiJob(); + String result = aiService.runDailyGptTask(); return ResponseEntity.ok(result); } } diff --git a/server/src/main/java/oba/backend/server/domain/ai/scheduler/AiScheduler.java b/server/src/main/java/oba/backend/server/domain/ai/scheduler/AiScheduler.java new file mode 100644 index 0000000..3e0d3b9 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/ai/scheduler/AiScheduler.java @@ -0,0 +1,43 @@ +package oba.backend.server.domain.ai.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import oba.backend.server.domain.ai.service.AiService; +import oba.backend.server.global.common.Const; +import org.springframework.cache.CacheManager; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.Objects; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AiScheduler { + + private final AiService aiService; + private final CacheManager cacheManager; + + // 매일 0시 실행 + @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") + public void runDailyAiTask() { + log.info("[Scheduler] Start Daily GPT Task"); + try { + String result = aiService.runDailyGptTask(); + log.info("[Scheduler] Result: {}", result); + + // 데이터가 갱신되었으므로 관련 캐시 초기화 + evictCaches(); + + } catch (Exception e) { + log.error("[Scheduler] Error: ", e); + } + } + + private void evictCaches() { + // 모든 리스트 캐시와 상세 캐시를 날림 (단순화 전략) + Objects.requireNonNull(cacheManager.getCache(Const.CACHE_LATEST_ARTICLES)).clear(); + Objects.requireNonNull(cacheManager.getCache(Const.CACHE_ARTICLE_DETAIL)).clear(); + log.info("[Cache] Article caches evicted."); + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/ai/service/AiService.java b/server/src/main/java/oba/backend/server/domain/ai/service/AiService.java new file mode 100644 index 0000000..d582d62 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/ai/service/AiService.java @@ -0,0 +1,23 @@ +package oba.backend.server.domain.ai.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +@Service +@RequiredArgsConstructor +public class AiService { + + private final RestTemplate restTemplate; + + @Value("${ai.server.url:http://ai_backend:8000/generate_daily_gpt_results}") + private String fastApiUrl; + + public String runDailyGptTask() { + ResponseEntity response = + restTemplate.postForEntity(fastApiUrl, null, String.class); + return response.getBody(); + } +} diff --git a/server/src/main/java/oba/backend/server/domain/article/controller/ArticleController.java b/server/src/main/java/oba/backend/server/domain/article/controller/ArticleController.java new file mode 100644 index 0000000..0d8c56a --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/article/controller/ArticleController.java @@ -0,0 +1,44 @@ +package oba.backend.server.domain.article.controller; + +import lombok.RequiredArgsConstructor; +import oba.backend.server.domain.article.dto.ArticleDetailResponse; +import oba.backend.server.domain.article.dto.ArticleSummaryResponse; +import oba.backend.server.domain.article.service.ArticleDetailService; +import oba.backend.server.domain.article.service.ArticleSummaryService; +import oba.backend.server.global.auth.jwt.JwtProvider; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/articles") +@RequiredArgsConstructor +public class ArticleController { + + private final ArticleSummaryService summaryService; + private final ArticleDetailService detailService; + private final JwtProvider jwtProvider; + + @GetMapping("/latest") // 최종주소: /api/articles/latest + public ResponseEntity> getLatestArticles( + @RequestParam(defaultValue = "10") int limit + ) { + return ResponseEntity.ok(summaryService.getLatestArticles(limit)); + } + + @GetMapping("/{id}") // 최종주소: /api/articles/{id} + public ResponseEntity getArticleDetail( + @PathVariable String id, + @RequestHeader(value = "Authorization", required = false) String token + ) { + Long userId = null; + if (token != null && token.startsWith("Bearer ")) { + String jwt = token.substring(7); + if (jwtProvider.validateToken(jwt)) { + userId = jwtProvider.getUserId(jwt); + } + } + return ResponseEntity.ok(detailService.getArticleDetail(id, userId)); + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/article/dto/ArticleDetailResponse.java b/server/src/main/java/oba/backend/server/domain/article/dto/ArticleDetailResponse.java new file mode 100644 index 0000000..2bdb71e --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/article/dto/ArticleDetailResponse.java @@ -0,0 +1,34 @@ +package oba.backend.server.domain.article.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import oba.backend.server.domain.article.entity.SelectedArticle; + +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ArticleDetailResponse { + + private String articleId; + private String title; + private List content; + private List summaryBullets; + private List keywords; + private String servingDate; + private List quizzes; + private List myQuizResults; + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class KeywordDto { + private String keyword; + private String description; + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/article/dto/ArticleSummaryResponse.java b/server/src/main/java/oba/backend/server/domain/article/dto/ArticleSummaryResponse.java new file mode 100644 index 0000000..dd28392 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/article/dto/ArticleSummaryResponse.java @@ -0,0 +1,15 @@ +package oba.backend.server.domain.article.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class ArticleSummaryResponse { + private String articleId; + private String title; + private List summaryBullets; + private String servingDate; +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/article/entity/SelectedArticle.java b/server/src/main/java/oba/backend/server/domain/article/entity/SelectedArticle.java new file mode 100644 index 0000000..3c328f3 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/article/entity/SelectedArticle.java @@ -0,0 +1,105 @@ +package oba.backend.server.domain.article.entity; + +import lombok.*; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Document(collection = "Selected_Articles") +public class SelectedArticle { + + @Id + private String id; + + @Field("article_id") + private Long articleId; + + private String title; + + @Field("serving_date") + private String servingDate; + + @Field("publish_time") + private String publishTime; + + @Field("content_col") + private List> contentCol; + + @Field("gpt_result") + private GptResult gptResult; + + // --- 편의 메서드 --- + public List getContent() { + if (contentCol == null) return new ArrayList<>(); + return contentCol.stream().flatMap(List::stream).collect(Collectors.toList()); + } + + public List getSummaryBullets() { + if (gptResult != null && gptResult.getSummary() != null) { + String rawSummary = gptResult.getSummary(); + return rawSummary.contains("\n") ? Arrays.asList(rawSummary.split("\n")) : List.of(rawSummary); + } + return new ArrayList<>(); + } + + public List getKeywordItems() { + if (gptResult == null || gptResult.getKeywords() == null) return new ArrayList<>(); + return gptResult.getKeywords(); + } + + public List getQuizzes() { + return (gptResult != null) ? gptResult.quizzes : new ArrayList<>(); + } + + // --- 내부 클래스 --- + @Getter @Setter @NoArgsConstructor @AllArgsConstructor + public static class GptResult { + private String summary; + private List keywords; + private List quizzes; + } + + @Getter @Setter @NoArgsConstructor @AllArgsConstructor + public static class KeywordItem { + private String keyword; + private String description; + } + + @Getter @Setter @NoArgsConstructor @AllArgsConstructor + public static class QuizItem { + private String question; + private List options; + private String answer; + private String explanation; + + public int getAnswerIndex() { + try { + if (answer == null) return -1; + String numericPart = answer.replaceAll("[^0-9]", ""); + if (!numericPart.isEmpty() && numericPart.length() < 3) { + return Integer.parseInt(numericPart) - 1; + } + for (int i = 0; i < options.size(); i++) { + String option = options.get(i).trim(); + String cleanAnswer = answer.trim(); + if (option.equals(cleanAnswer) || option.contains(cleanAnswer) || cleanAnswer.contains(option)) { + return i; + } + } + } catch (Exception e) { + return -1; + } + return -1; + } + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/article/repository/GptMongoRepository.java b/server/src/main/java/oba/backend/server/domain/article/repository/GptMongoRepository.java new file mode 100644 index 0000000..dd96caf --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/article/repository/GptMongoRepository.java @@ -0,0 +1,17 @@ +package oba.backend.server.domain.article.repository; + +import oba.backend.server.domain.article.entity.SelectedArticle; +import org.springframework.data.domain.Pageable; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface GptMongoRepository extends MongoRepository { + List findByOrderByServingDateDesc(Pageable pageable); + + // 숫자 ID로 기사 찾기 (SQL <-> Mongo 매핑용) + Optional findByArticleId(Long articleId); +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/article/repository/SelectedArticleRepository.java b/server/src/main/java/oba/backend/server/domain/article/repository/SelectedArticleRepository.java new file mode 100644 index 0000000..630dc58 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/article/repository/SelectedArticleRepository.java @@ -0,0 +1,8 @@ +package oba.backend.server.domain.article.repository; + +import oba.backend.server.domain.article.entity.SelectedArticle; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface SelectedArticleRepository + extends MongoRepository { +} diff --git a/server/src/main/java/oba/backend/server/domain/article/service/ArticleDetailService.java b/server/src/main/java/oba/backend/server/domain/article/service/ArticleDetailService.java new file mode 100644 index 0000000..ec12883 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/article/service/ArticleDetailService.java @@ -0,0 +1,58 @@ +package oba.backend.server.domain.article.service; + +import lombok.RequiredArgsConstructor; +import oba.backend.server.domain.article.dto.ArticleDetailResponse; +import oba.backend.server.domain.article.entity.SelectedArticle; +import oba.backend.server.domain.article.repository.GptMongoRepository; +import oba.backend.server.domain.quiz.entity.IncorrectQuiz; +import oba.backend.server.domain.quiz.repository.IncorrectQuizRepository; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ArticleDetailService { + + private final GptMongoRepository gptMongoRepository; + private final IncorrectQuizRepository incorrectQuizRepository; + + public ArticleDetailResponse getArticleDetail(String articleId, Long userId) { + // Mongo에서 기사 조회 + SelectedArticle doc = gptMongoRepository.findById(articleId) + .orElseThrow(() -> new IllegalArgumentException("해당 ID의 기사를 찾을 수 없습니다: " + articleId)); + + List myResults = Collections.emptyList(); + + if (userId != null) { + Long numericId = doc.getArticleId(); + if (numericId != null) { + Optional quizRecord = incorrectQuizRepository.findByUserIdAndArticleId(userId, numericId); + if (quizRecord.isPresent()) { + myResults = quizRecord.get().getQuizResults(); + } + } + } + + List keywordDtos = doc.getKeywordItems().stream() + .map(item -> ArticleDetailResponse.KeywordDto.builder() + .keyword(item.getKeyword()) + .description(item.getDescription()) // 설명 필드 매핑 + .build()) + .collect(Collectors.toList()); + + return ArticleDetailResponse.builder() + .articleId(doc.getId()) + .title(doc.getTitle()) + .content(doc.getContent()) + .summaryBullets(doc.getSummaryBullets()) + .keywords(keywordDtos) + .servingDate(doc.getServingDate()) + .quizzes(doc.getQuizzes()) + .myQuizResults(myResults) + .build(); + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/article/service/ArticleSummaryService.java b/server/src/main/java/oba/backend/server/domain/article/service/ArticleSummaryService.java new file mode 100644 index 0000000..a1fa158 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/article/service/ArticleSummaryService.java @@ -0,0 +1,30 @@ +package oba.backend.server.domain.article.service; + +import lombok.RequiredArgsConstructor; +import oba.backend.server.domain.article.dto.ArticleSummaryResponse; +import oba.backend.server.domain.article.entity.SelectedArticle; +import oba.backend.server.domain.article.repository.GptMongoRepository; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ArticleSummaryService { + + private final GptMongoRepository gptMongoRepository; + + public List getLatestArticles(int limit) { + List docs = gptMongoRepository.findByOrderByServingDateDesc(PageRequest.of(0, limit)); + + return docs.stream() + .map(doc -> ArticleSummaryResponse.builder() + .articleId(doc.getId()) + .title(doc.getTitle()) + .summaryBullets(doc.getSummaryBullets()) // 엔티티 메서드 사용 + .servingDate(doc.getServingDate()) // 엔티티 Getter 사용 + .build()) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/log/entity/ArticleLog.java b/server/src/main/java/oba/backend/server/domain/log/entity/ArticleLog.java new file mode 100644 index 0000000..727743e --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/log/entity/ArticleLog.java @@ -0,0 +1,41 @@ +package oba.backend.server.domain.log.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Table(name = "Article_Logs") +@IdClass(ArticleLogId.class) +public class ArticleLog { + + @Id + @Column(name = "user_id") + private Long userId; + + @Id + @Column(name = "article_id") + private Long articleId; + + @Column(name = "is_resolved", nullable = false) + @Builder.Default + private boolean isResolved = false; + + @CreationTimestamp + @Column(name = "initial_at", nullable = false, updatable = false) + private LocalDateTime initialAt; + + @Column(name = "resolve_at") + private LocalDateTime resolveAt; + + public void markAsResolved() { + this.isResolved = true; + this.resolveAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizId.java b/server/src/main/java/oba/backend/server/domain/log/entity/ArticleLogId.java similarity index 51% rename from server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizId.java rename to server/src/main/java/oba/backend/server/domain/log/entity/ArticleLogId.java index 0f71f9c..68fb888 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizId.java +++ b/server/src/main/java/oba/backend/server/domain/log/entity/ArticleLogId.java @@ -1,13 +1,14 @@ -package oba.backend.server.domain.quiz; +package oba.backend.server.domain.log.entity; import lombok.*; - import java.io.Serializable; -@Getter @Setter +@Getter +@Setter @NoArgsConstructor @AllArgsConstructor -public class IncorrectQuizId implements Serializable { +@EqualsAndHashCode +public class ArticleLogId implements Serializable { private Long userId; private Long articleId; -} +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/log/repository/ArticleLogRepository.java b/server/src/main/java/oba/backend/server/domain/log/repository/ArticleLogRepository.java new file mode 100644 index 0000000..fb89472 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/log/repository/ArticleLogRepository.java @@ -0,0 +1,11 @@ +package oba.backend.server.domain.log.repository; + +import oba.backend.server.domain.log.entity.ArticleLog; +import oba.backend.server.domain.log.entity.ArticleLogId; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface ArticleLogRepository extends JpaRepository { + List findByUserId(Long userId); + List findByUserIdAndIsResolvedFalse(Long userId); +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticles.java b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticles.java deleted file mode 100644 index 7dbf9ac..0000000 --- a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticles.java +++ /dev/null @@ -1,26 +0,0 @@ -package oba.backend.server.domain.quiz; - -import jakarta.persistence.*; -import lombok.*; -import oba.backend.server.entity.BaseEntity; - -@Entity -@Getter @Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -@Table(name = "Incorrect_Articles") -@IdClass(IncorrectArticlesId.class) -public class IncorrectArticles { - - @Id - @Column(name = "user_id") - private Long userId; - - @Id - @Column(name = "article_id") - private Long articleId; - - @Column(name = "sol_date") - private java.time.LocalDateTime solDate; -} diff --git a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesId.java b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesId.java deleted file mode 100644 index 74359cf..0000000 --- a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesId.java +++ /dev/null @@ -1,13 +0,0 @@ -package oba.backend.server.domain.quiz; - -import lombok.*; - -import java.io.Serializable; - -@Getter @Setter -@NoArgsConstructor -@AllArgsConstructor -public class IncorrectArticlesId implements Serializable { - private Long userId; - private Long articleId; -} diff --git a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuiz.java b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuiz.java deleted file mode 100644 index ec1c078..0000000 --- a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuiz.java +++ /dev/null @@ -1,28 +0,0 @@ -package oba.backend.server.domain.quiz; - -import jakarta.persistence.*; -import lombok.*; - -@Entity -@Getter @Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -@Table(name = "Incorrect_Quiz") -@IdClass(IncorrectQuizId.class) -public class IncorrectQuiz { - - @Id - @Column(name = "user_id") - private Long userId; - - @Id - @Column(name = "article_id") - private Long articleId; - - private Boolean quiz1; - private Boolean quiz2; - private Boolean quiz3; - private Boolean quiz4; - private Boolean quiz5; -} diff --git a/server/src/main/java/oba/backend/server/domain/quiz/controller/MyQuizController.java b/server/src/main/java/oba/backend/server/domain/quiz/controller/MyQuizController.java new file mode 100644 index 0000000..15a3e2a --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/quiz/controller/MyQuizController.java @@ -0,0 +1,42 @@ +package oba.backend.server.domain.quiz.controller; + +import lombok.RequiredArgsConstructor; +import oba.backend.server.domain.quiz.dto.SolvedArticleResponse; +import oba.backend.server.domain.quiz.dto.WrongArticleResponse; +import oba.backend.server.domain.quiz.service.QuizQueryService; +import oba.backend.server.global.auth.jwt.JwtProvider; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/my") // 혹은 /api/users/me +@RequiredArgsConstructor +public class MyQuizController { + + private final QuizQueryService quizQueryService; + private final JwtProvider jwtProvider; + + // 내가 푼 문제 목록 + @GetMapping("/solved") + public ResponseEntity> getSolved( + @RequestHeader("Authorization") String token) { + Long userId = extractUserId(token); + return ResponseEntity.ok(quizQueryService.getSolved(userId)); + } + + // 나의 오답 노트 + @GetMapping("/wrong") + public ResponseEntity> getWrong( + @RequestHeader("Authorization") String token) { + Long userId = extractUserId(token); + return ResponseEntity.ok(quizQueryService.getWrong(userId)); + } + + // 토큰 파싱 헬퍼 메서드 + private Long extractUserId(String token) { + String jwt = token.startsWith("Bearer ") ? token.substring(7) : token; + return jwtProvider.getUserId(jwt); + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/controller/QuizController.java b/server/src/main/java/oba/backend/server/domain/quiz/controller/QuizController.java new file mode 100644 index 0000000..76c4a54 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/quiz/controller/QuizController.java @@ -0,0 +1,44 @@ +package oba.backend.server.domain.quiz.controller; + +import lombok.RequiredArgsConstructor; +import oba.backend.server.domain.quiz.dto.QuizResultRequest; +import oba.backend.server.domain.quiz.dto.WrongArticleResponse; +import oba.backend.server.domain.quiz.service.QuizQueryService; +import oba.backend.server.domain.quiz.service.QuizResultService; +import oba.backend.server.global.auth.jwt.JwtProvider; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/quiz") +@RequiredArgsConstructor +public class QuizController { + + private final QuizResultService quizResultService; + private final QuizQueryService quizQueryService; // 조회 서비스 추가 + private final JwtProvider jwtProvider; // 토큰 처리용 + + // 1. 퀴즈 결과 저장 + @PostMapping("/result") + public ResponseEntity saveQuizResult( + @RequestHeader("Authorization") String token, + @RequestBody QuizResultRequest request) { + + String accessToken = token.startsWith("Bearer ") ? token.substring(7) : token; + quizResultService.saveQuizResult(accessToken, request); + return ResponseEntity.ok("저장 완료"); + } + + // 2. 오답 노트 조회 + @GetMapping("/wrong") + public ResponseEntity> getWrongArticles( + @RequestHeader("Authorization") String token) { + + String accessToken = token.startsWith("Bearer ") ? token.substring(7) : token; + Long userId = jwtProvider.getUserId(accessToken); + + return ResponseEntity.ok(quizQueryService.getWrong(userId)); + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizResultRequest.java b/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizResultRequest.java new file mode 100644 index 0000000..4278d45 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizResultRequest.java @@ -0,0 +1,12 @@ +package oba.backend.server.domain.quiz.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import java.util.List; + +@Getter +@NoArgsConstructor +public class QuizResultRequest { + private String articleId; + private List results; +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizSubmitRequest.java b/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizSubmitRequest.java new file mode 100644 index 0000000..4719fc7 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizSubmitRequest.java @@ -0,0 +1,12 @@ +package oba.backend.server.domain.quiz.dto; + +import lombok.Getter; +import lombok.Setter; +import java.util.List; + +@Getter +@Setter +public class QuizSubmitRequest { + private String articleId; + private List answers; // 사용자가 선택한 보기 인덱스들 +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/dto/SolvedArticleResponse.java b/server/src/main/java/oba/backend/server/domain/quiz/dto/SolvedArticleResponse.java new file mode 100644 index 0000000..1a3ceaf --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/quiz/dto/SolvedArticleResponse.java @@ -0,0 +1,17 @@ +package oba.backend.server.domain.quiz.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SolvedArticleResponse { + private String articleId; + private String title; + private String summary; + private String solvedAt; +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/dto/WrongArticleResponse.java b/server/src/main/java/oba/backend/server/domain/quiz/dto/WrongArticleResponse.java new file mode 100644 index 0000000..48515eb --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/quiz/dto/WrongArticleResponse.java @@ -0,0 +1,15 @@ +package oba.backend.server.domain.quiz.dto; + +import lombok.*; + +@Getter +@Builder +@AllArgsConstructor +public class WrongArticleResponse { + private String articleId; + private String title; + private String summary; + private String imageUrl; + private String category; + private String solvedAt; +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticlesId.java b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticlesId.java new file mode 100644 index 0000000..6163fb3 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticlesId.java @@ -0,0 +1,29 @@ +package oba.backend.server.domain.quiz.entity; + +import lombok.*; +import java.io.Serializable; +import java.util.Objects; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class IncorrectArticlesId implements Serializable { + + private Long userId; + private String articleId; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof IncorrectArticlesId)) return false; + IncorrectArticlesId that = (IncorrectArticlesId) o; + return Objects.equals(userId, that.userId) && + Objects.equals(articleId, that.articleId); + } + + @Override + public int hashCode() { + return Objects.hash(userId, articleId); + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuiz.java b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuiz.java new file mode 100644 index 0000000..4ef9802 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuiz.java @@ -0,0 +1,61 @@ +package oba.backend.server.domain.quiz.entity; + +import jakarta.persistence.*; +import lombok.*; +import oba.backend.server.domain.log.entity.ArticleLogId; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Table(name = "Incorrect_Quiz") +@IdClass(ArticleLogId.class) +public class IncorrectQuiz { + + @Id + @Column(name = "user_id") + private Long userId; + + @Id + @Column(name = "article_id") + private Long articleId; + + @Column(nullable = false) + private boolean quiz1; + + @Column(nullable = false) + private boolean quiz2; + + @Column(nullable = false) + private boolean quiz3; + + @Column(nullable = false) + private boolean quiz4; + + @Column(nullable = false) + private boolean quiz5; + + // Helper 메서드 + public void setQuizResults(List results) { + if (results == null || results.size() < 5) return; + this.quiz1 = results.get(0); + this.quiz2 = results.get(1); + this.quiz3 = results.get(2); + this.quiz4 = results.get(3); + this.quiz5 = results.get(4); + } + + public List getQuizResults() { + List results = new ArrayList<>(); + results.add(quiz1); + results.add(quiz2); + results.add(quiz3); + results.add(quiz4); + results.add(quiz5); + return results; + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuizId.java b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuizId.java new file mode 100644 index 0000000..abc4d50 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuizId.java @@ -0,0 +1,29 @@ +package oba.backend.server.domain.quiz.entity; + +import lombok.*; +import java.io.Serializable; +import java.util.Objects; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class IncorrectQuizId implements Serializable { + + private Long userId; + private String articleId; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof IncorrectQuizId)) return false; + IncorrectQuizId that = (IncorrectQuizId) o; + return Objects.equals(userId, that.userId) && + Objects.equals(articleId, that.articleId); + } + + @Override + public int hashCode() { + return Objects.hash(userId, articleId); + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectQuizRepository.java b/server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectQuizRepository.java new file mode 100644 index 0000000..897767f --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectQuizRepository.java @@ -0,0 +1,12 @@ +package oba.backend.server.domain.quiz.repository; + +import oba.backend.server.domain.quiz.entity.IncorrectQuiz; +import oba.backend.server.domain.log.entity.ArticleLogId; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; +import java.util.Optional; + +public interface IncorrectQuizRepository extends JpaRepository { + List findByUserId(Long userId); + Optional findByUserIdAndArticleId(Long userId, Long articleId); +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/service/QuizQueryService.java b/server/src/main/java/oba/backend/server/domain/quiz/service/QuizQueryService.java new file mode 100644 index 0000000..8aeb51a --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/quiz/service/QuizQueryService.java @@ -0,0 +1,83 @@ +package oba.backend.server.domain.quiz.service; + +import lombok.RequiredArgsConstructor; +import oba.backend.server.domain.article.entity.SelectedArticle; +import oba.backend.server.domain.article.repository.GptMongoRepository; +import oba.backend.server.domain.quiz.dto.SolvedArticleResponse; +import oba.backend.server.domain.quiz.dto.WrongArticleResponse; +import oba.backend.server.domain.quiz.entity.IncorrectQuiz; +import oba.backend.server.domain.quiz.repository.IncorrectQuizRepository; +import oba.backend.server.global.auth.jwt.JwtProvider; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class QuizQueryService { + + private final IncorrectQuizRepository incorrectQuizRepository; + private final GptMongoRepository gptMongoRepository; + private final JwtProvider jwtProvider; + + // 1. 내가 푼 문제 (SQL Long ID -> Mongo 조회 -> String ID 반환) + public List getSolved(Long userId) { + return incorrectQuizRepository.findByUserId(userId).stream() // findAllByUserId -> findByUserId + .map(record -> { + // Long ID로 Mongo 문서 찾기 + SelectedArticle article = gptMongoRepository.findByArticleId(record.getArticleId()) + .orElse(null); + + String title = (article != null) ? article.getTitle() : "삭제된 기사"; + String mongoId = (article != null) ? article.getId() : ""; + + return SolvedArticleResponse.builder() + .articleId(mongoId) // 프론트엔드용 String ID 반환 + .title(title) + .solvedAt(LocalDate.now().toString()) + .build(); + }) + .collect(Collectors.toList()); + } + + // 2. 오답 노트 + public List getWrong(Long userId) { + List records = incorrectQuizRepository.findByUserId(userId); + List responseList = new ArrayList<>(); + + for (IncorrectQuiz record : records) { + // 오답이 하나라도 있으면 + if (record.getQuizResults().contains(false)) { + // Long ID -> Mongo Document + SelectedArticle article = gptMongoRepository.findByArticleId(record.getArticleId()) + .orElse(null); + + if (article != null) { + String summary = (article.getSummaryBullets() != null && !article.getSummaryBullets().isEmpty()) + ? article.getSummaryBullets().get(0) : "요약 없음"; + + responseList.add(WrongArticleResponse.builder() + .articleId(article.getId()) // Mongo ID (String) + .title(article.getTitle()) + .summary(summary) + .category("Tech") + .solvedAt(LocalDate.now().toString()) + .build()); + } + } + } + return responseList; + } + + public List getWeeklyLog(Long userId) { + // 임시 더미 데이터 (UserStats와 연동 필요) + List weeklyLog = new ArrayList<>(); + for (int i = 0; i < 7; i++) weeklyLog.add(false); + return weeklyLog; + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/service/QuizResultService.java b/server/src/main/java/oba/backend/server/domain/quiz/service/QuizResultService.java new file mode 100644 index 0000000..962e946 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/quiz/service/QuizResultService.java @@ -0,0 +1,64 @@ +package oba.backend.server.domain.quiz.service; + +import lombok.RequiredArgsConstructor; +import oba.backend.server.domain.article.entity.SelectedArticle; +import oba.backend.server.domain.article.repository.GptMongoRepository; +import oba.backend.server.domain.log.entity.ArticleLog; +import oba.backend.server.domain.log.repository.ArticleLogRepository; +import oba.backend.server.domain.quiz.dto.QuizResultRequest; +import oba.backend.server.domain.quiz.entity.IncorrectQuiz; +import oba.backend.server.domain.quiz.repository.IncorrectQuizRepository; +import oba.backend.server.domain.user.entity.User; +import oba.backend.server.domain.user.repository.UserRepository; +import oba.backend.server.global.auth.jwt.JwtProvider; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class QuizResultService { + + private final JwtProvider jwtProvider; + private final UserRepository userRepository; + private final IncorrectQuizRepository incorrectQuizRepository; + private final ArticleLogRepository articleLogRepository; + private final GptMongoRepository gptMongoRepository; + + @Transactional + public void saveQuizResult(String token, QuizResultRequest request) { + Long userId = jwtProvider.getUserId(token); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("유저 없음")); + user.updateStreak(); // UserStats 갱신 + + // Mongo ID -> Numeric ID 변환 + SelectedArticle article = gptMongoRepository.findById(request.getArticleId()) + .orElseThrow(() -> new IllegalArgumentException("기사를 찾을 수 없습니다.")); + Long numericArticleId = article.getArticleId(); + + // 학습 로그 (Article_Logs) 저장/갱신 + ArticleLog log = articleLogRepository.findById(new oba.backend.server.domain.log.entity.ArticleLogId(userId, numericArticleId)) + .orElseGet(() -> ArticleLog.builder() + .userId(userId) + .articleId(numericArticleId) + .build()); + + // 정답 여부 체크 (모두 true일 때 해결 처리) + if (!request.getResults().contains(false)) { + log.markAsResolved(); + } + articleLogRepository.save(log); + + // 오답 상세 (Incorrect_Quiz) 저장 + IncorrectQuiz quizRecord = incorrectQuizRepository + .findByUserIdAndArticleId(userId, numericArticleId) + .orElseGet(() -> IncorrectQuiz.builder() + .userId(userId) + .articleId(numericArticleId) + .build()); + + quizRecord.setQuizResults(request.getResults()); + incorrectQuizRepository.save(quizRecord); + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/service/QuizService.java b/server/src/main/java/oba/backend/server/domain/quiz/service/QuizService.java new file mode 100644 index 0000000..78e9dda --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/quiz/service/QuizService.java @@ -0,0 +1,67 @@ +package oba.backend.server.domain.quiz.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import oba.backend.server.domain.article.entity.SelectedArticle; +import oba.backend.server.domain.article.repository.GptMongoRepository; +import oba.backend.server.domain.quiz.dto.QuizSubmitRequest; +import oba.backend.server.domain.quiz.entity.IncorrectQuiz; +import oba.backend.server.domain.quiz.repository.IncorrectQuizRepository; +import oba.backend.server.global.auth.jwt.JwtProvider; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class QuizService { + + private final IncorrectQuizRepository incorrectQuizRepository; + private final GptMongoRepository gptMongoRepository; + private final JwtProvider jwtProvider; + + @Transactional + public void submit(String jwt, QuizSubmitRequest request) { + Long userId = jwtProvider.getUserId(jwt); + + // Mongo에서 기사 조회 (String ID 사용) + SelectedArticle article = gptMongoRepository.findById(request.getArticleId()) + .orElseThrow(() -> new IllegalArgumentException("해당 기사 없음")); + + // 숫자 ID 추출 (MySQL 저장용) + Long numericArticleId = article.getArticleId(); + if (numericArticleId == null) { + throw new IllegalArgumentException("기사 ID(숫자)가 존재하지 않습니다."); + } + + // 정답 판별 + List userAnswers = request.getAnswers(); + List quizzes = article.getQuizzes(); + + if (userAnswers.size() != quizzes.size()) { + throw new IllegalArgumentException("답변 수와 문제 수가 일치하지 않음"); + } + + List results = new ArrayList<>(); + for (int i = 0; i < quizzes.size(); i++) { + int userIndex = userAnswers.get(i); + int correctIndex = quizzes.get(i).getAnswerIndex(); + boolean isCorrect = (userIndex == correctIndex); + results.add(isCorrect); + } + + // 저장 (Long ID 사용) + IncorrectQuiz incorrectQuiz = IncorrectQuiz.builder() + .userId(userId) + .articleId(numericArticleId) + .build(); + + // Helper 메서드로 결과 주입 + incorrectQuiz.setQuizResults(results); + + incorrectQuizRepository.save(incorrectQuiz); + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/stats/entity/UserCategoryId.java b/server/src/main/java/oba/backend/server/domain/stats/entity/UserCategoryId.java new file mode 100644 index 0000000..a88e22f --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/stats/entity/UserCategoryId.java @@ -0,0 +1,14 @@ +package oba.backend.server.domain.stats.entity; + +import lombok.*; +import java.io.Serializable; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class UserCategoryId implements Serializable { + private Long userId; + private Integer categoryId; +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/stats/entity/UserCategoryStats.java b/server/src/main/java/oba/backend/server/domain/stats/entity/UserCategoryStats.java new file mode 100644 index 0000000..ca7f1b1 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/stats/entity/UserCategoryStats.java @@ -0,0 +1,42 @@ +package oba.backend.server.domain.stats.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Table(name = "User_Category_Stats") +@IdClass(UserCategoryId.class) +public class UserCategoryStats { + + @Id + @Column(name = "user_id") + private Long userId; + + @Id + @Column(name = "category_id") + private Integer categoryId; + + @Column(name = "total_quizzes") + @Builder.Default + private int totalQuizzes = 0; + + @Column(name = "correct_quizzes") + @Builder.Default + private int correctQuizzes = 0; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + public void addScore(int total, int correct) { + this.totalQuizzes += total; + this.correctQuizzes += correct; + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/stats/entity/UserStats.java b/server/src/main/java/oba/backend/server/domain/stats/entity/UserStats.java new file mode 100644 index 0000000..938cbc5 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/stats/entity/UserStats.java @@ -0,0 +1,56 @@ +package oba.backend.server.domain.stats.entity; + +import jakarta.persistence.*; +import lombok.*; +import oba.backend.server.domain.user.entity.User; + +import java.time.LocalDate; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Table(name = "User_Stats") +public class UserStats { + + @Id + @Column(name = "user_id") + private Long userId; + + @OneToOne(fetch = FetchType.LAZY) + @MapsId + @JoinColumn(name = "user_id") + private User user; + + @Column(name = "current_streak") + @Builder.Default + private int currentStreak = 0; + + @Column(name = "max_streak") + @Builder.Default + private int maxStreak = 0; + + @Column(name = "total_perfect_days") + @Builder.Default + private int totalPerfectDays = 0; + + @Column(name = "last_learned_at") + private LocalDate lastLearnedAt; + + public void updateStreak() { + LocalDate today = LocalDate.now(); + if (lastLearnedAt != null && lastLearnedAt.equals(today)) return; + + if (lastLearnedAt != null && lastLearnedAt.plusDays(1).equals(today)) { + this.currentStreak++; + } else { + this.currentStreak = 1; + } + + if (this.currentStreak > this.maxStreak) { + this.maxStreak = this.currentStreak; + } + this.lastLearnedAt = today; + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/stats/repository/UserCategoryStatsRepository.java b/server/src/main/java/oba/backend/server/domain/stats/repository/UserCategoryStatsRepository.java new file mode 100644 index 0000000..d87be22 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/stats/repository/UserCategoryStatsRepository.java @@ -0,0 +1,10 @@ +package oba.backend.server.domain.stats.repository; + +import oba.backend.server.domain.stats.entity.UserCategoryStats; +import oba.backend.server.domain.stats.entity.UserCategoryId; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface UserCategoryStatsRepository extends JpaRepository { + List findByUserId(Long userId); +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/stats/repository/UserStatsRepository.java b/server/src/main/java/oba/backend/server/domain/stats/repository/UserStatsRepository.java new file mode 100644 index 0000000..8e2cbcf --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/stats/repository/UserStatsRepository.java @@ -0,0 +1,7 @@ +package oba.backend.server.domain.stats.repository; + +import oba.backend.server.domain.stats.entity.UserStats; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserStatsRepository extends JpaRepository { +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/user/ProviderInfo.java b/server/src/main/java/oba/backend/server/domain/user/ProviderInfo.java deleted file mode 100644 index ca1ba4b..0000000 --- a/server/src/main/java/oba/backend/server/domain/user/ProviderInfo.java +++ /dev/null @@ -1,11 +0,0 @@ -package oba.backend.server.domain.user; - -public enum ProviderInfo { - GOOGLE, - KAKAO, - NAVER; - - public static ProviderInfo from(String provider) { - return ProviderInfo.valueOf(provider.toUpperCase()); - } -} diff --git a/server/src/main/java/oba/backend/server/domain/user/Role.java b/server/src/main/java/oba/backend/server/domain/user/Role.java deleted file mode 100644 index bc7bba8..0000000 --- a/server/src/main/java/oba/backend/server/domain/user/Role.java +++ /dev/null @@ -1,6 +0,0 @@ -package oba.backend.server.domain.user; - -public enum Role { - USER, - ADMIN -} diff --git a/server/src/main/java/oba/backend/server/domain/user/User.java b/server/src/main/java/oba/backend/server/domain/user/User.java deleted file mode 100644 index 43f02d0..0000000 --- a/server/src/main/java/oba/backend/server/domain/user/User.java +++ /dev/null @@ -1,43 +0,0 @@ -package oba.backend.server.domain.user; - -import jakarta.persistence.*; -import lombok.*; -import oba.backend.server.entity.BaseEntity; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder -@Table(name = "users") -public class User extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "user_id") // ERD와 정확히 매핑 - private Long id; - - @Column(nullable = false, unique = true) - private String identifier; // 예: "google:123456" - - private String email; - private String name; - - @Column(length = 512) - private String picture; - - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private ProviderInfo provider; - - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private Role role; - - /** 로그인 시 정보 업데이트 */ - public void updateInfo(String email, String name, String picture) { - this.email = email; - this.name = name; - this.picture = picture; - } -} diff --git a/server/src/main/java/oba/backend/server/domain/user/controller/UserController.java b/server/src/main/java/oba/backend/server/domain/user/controller/UserController.java new file mode 100644 index 0000000..46a33fc --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/user/controller/UserController.java @@ -0,0 +1,44 @@ +package oba.backend.server.domain.user.controller; + +import lombok.RequiredArgsConstructor; +import oba.backend.server.domain.quiz.service.QuizQueryService; +import oba.backend.server.domain.user.dto.UserResponse; +import oba.backend.server.domain.user.entity.User; +import oba.backend.server.domain.user.service.UserService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + private final QuizQueryService quizQueryService; + + @GetMapping("/me") + public ResponseEntity getMyInfo(@AuthenticationPrincipal UserDetails userDetails) { + if (userDetails == null) { + return ResponseEntity.status(401).build(); + } + + String identifier = userDetails.getUsername(); + User user = userService.findByIdentifier(identifier); + + if (user == null) { + return ResponseEntity.notFound().build(); + } + + // 이번 주 학습 로그 조회 + List weeklyLog = quizQueryService.getWeeklyLog(user.getId()); + + // DTO 생성 (weeklyLog 포함) + return ResponseEntity.ok(UserResponse.from(user, weeklyLog)); + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/user/dto/UserResponse.java b/server/src/main/java/oba/backend/server/domain/user/dto/UserResponse.java new file mode 100644 index 0000000..a1fb9ec --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/user/dto/UserResponse.java @@ -0,0 +1,38 @@ +package oba.backend.server.domain.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import oba.backend.server.domain.user.entity.User; + +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserResponse { + private Long userId; + private String email; + private String name; + private String picture; + private String authProvider; + private int consecutiveDays; + private List weeklyLog; + + public static UserResponse from(User user, List weeklyLog) { + int streak = (user.getUserStats() != null) ? user.getUserStats().getCurrentStreak() : 0; + + return UserResponse.builder() + .userId(user.getId()) + .email(user.getEmail()) + .name(user.getName()) + .picture(user.getPicture()) + .authProvider(user.getAuthProvider() != null ? + user.getAuthProvider().name() : "UNKNOWN") + .consecutiveDays(streak) + .weeklyLog(weeklyLog) + .build(); + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/user/entity/AuthProvider.java b/server/src/main/java/oba/backend/server/domain/user/entity/AuthProvider.java new file mode 100644 index 0000000..7e6fd8d --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/user/entity/AuthProvider.java @@ -0,0 +1,9 @@ +package oba.backend.server.domain.user.entity; + +public enum AuthProvider { + LOCAL, + GOOGLE, + KAKAO, + NAVER, + MOBILE +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/user/entity/Role.java b/server/src/main/java/oba/backend/server/domain/user/entity/Role.java new file mode 100644 index 0000000..d4024bf --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/user/entity/Role.java @@ -0,0 +1,6 @@ +package oba.backend.server.domain.user.entity; + +public enum Role { + USER, + ADMIN +} diff --git a/server/src/main/java/oba/backend/server/domain/user/entity/User.java b/server/src/main/java/oba/backend/server/domain/user/entity/User.java new file mode 100644 index 0000000..48002a4 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/user/entity/User.java @@ -0,0 +1,71 @@ +package oba.backend.server.domain.user.entity; + +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import oba.backend.server.domain.stats.entity.UserStats; +import oba.backend.server.global.common.BaseEntity; + +@Getter +@NoArgsConstructor +@Entity +@Table(name = "Users") +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + private Long id; + + @Column(nullable = false, unique = true) + private String identifier; + + @Column(nullable = false) + private String email; + + @Column(nullable = false) + private String name; + + @Column(length = 512) + private String picture; + + @Enumerated(EnumType.STRING) + @Column(length = 50) + private Role role; + + @Enumerated(EnumType.STRING) + @Column(name = "provider", length = 50) + private AuthProvider authProvider; + + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + private UserStats userStats; + + @Builder + public User(String identifier, String email, String name, String picture, Role role, AuthProvider authProvider) { + this.identifier = identifier; + this.email = email; + this.name = name; + this.picture = picture; + this.role = role; + this.authProvider = authProvider; + } + + public void updateInfo(String email, String name, String picture) { + this.email = email; + this.name = name; + this.picture = picture; + } + + public void initStats() { + if (this.userStats == null) { + this.userStats = UserStats.builder().user(this).build(); + } + } + + public void updateStreak() { + if (this.userStats != null) { + this.userStats.updateStreak(); + } + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/user/UserRepository.java b/server/src/main/java/oba/backend/server/domain/user/repository/UserRepository.java similarity index 68% rename from server/src/main/java/oba/backend/server/domain/user/UserRepository.java rename to server/src/main/java/oba/backend/server/domain/user/repository/UserRepository.java index 40e5a66..5087d72 100644 --- a/server/src/main/java/oba/backend/server/domain/user/UserRepository.java +++ b/server/src/main/java/oba/backend/server/domain/user/repository/UserRepository.java @@ -1,5 +1,6 @@ -package oba.backend.server.domain.user; +package oba.backend.server.domain.user.repository; +import oba.backend.server.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; diff --git a/server/src/main/java/oba/backend/server/domain/user/service/UserService.java b/server/src/main/java/oba/backend/server/domain/user/service/UserService.java new file mode 100644 index 0000000..b04abcd --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/user/service/UserService.java @@ -0,0 +1,66 @@ +package oba.backend.server.domain.user.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import oba.backend.server.domain.user.entity.Role; +import oba.backend.server.domain.user.entity.User; +import oba.backend.server.domain.user.entity.AuthProvider; +import oba.backend.server.domain.user.repository.UserRepository; +import oba.backend.server.global.auth.oauth.OAuth2UserInfo; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + + // 유저 조회 log + public User findByIdentifier(String identifier) { + log.info("[UserService] findByIdentifier 호출됨. 찾는 ID: '{}'", identifier); + + return userRepository.findByIdentifier(identifier) + .orElseThrow(() -> { + log.error(" [UserService] DB 조회 실패! ID: '{}' 인 유저가 테이블에 없습니다.", identifier); + return new IllegalArgumentException("유저를 찾을 수 없습니다."); + }); + } + + // 유저 등록/수정 log + @Transactional + public User registerOrUpdateUser(OAuth2UserInfo info) { + log.info(" [UserService] registerOrUpdateUser 호출됨. Provider: {}, ID: {}", info.getProvider(), info.getId()); + + AuthProvider providerEnum = AuthProvider.valueOf(info.getProvider().toUpperCase()); + + User user = userRepository.findByIdentifier(info.getId()) + .orElseGet(() -> { + log.info("[UserService] 신규 유저 생성 시작 ID: {}", info.getId()); + User newUser = User.builder() + .identifier(info.getId()) + .email(info.getEmail()) + .name(info.getName()) + .picture(info.getPicture()) + .role(Role.USER) + .authProvider(providerEnum) + .build(); + newUser.initStats(); // 통계 초기화 + return newUser; + }); + + user.updateInfo(info.getEmail(), info.getName(), info.getPicture()); + User savedUser = userRepository.save(user); + + log.info(" [UserService] 저장 완료. DB PK: {}, Identifier: {}", savedUser.getId(), savedUser.getIdentifier()); + return savedUser; + } + + @Transactional + public void updateStreak(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("유저 없음")); + user.updateStreak(); + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/dto/TokenResponse.java b/server/src/main/java/oba/backend/server/dto/TokenResponse.java deleted file mode 100644 index 7b0b16b..0000000 --- a/server/src/main/java/oba/backend/server/dto/TokenResponse.java +++ /dev/null @@ -1,6 +0,0 @@ -package oba.backend.server.dto; - -public record TokenResponse( - String accessToken, - String refreshToken -) {} diff --git a/server/src/main/java/oba/backend/server/global/auth/controller/AuthController.java b/server/src/main/java/oba/backend/server/global/auth/controller/AuthController.java new file mode 100644 index 0000000..dbada86 --- /dev/null +++ b/server/src/main/java/oba/backend/server/global/auth/controller/AuthController.java @@ -0,0 +1,56 @@ +package oba.backend.server.global.auth.controller; + +import lombok.RequiredArgsConstructor; +import oba.backend.server.domain.user.entity.User; +import oba.backend.server.domain.user.service.UserService; +import oba.backend.server.global.auth.dto.LoginRequest; +import oba.backend.server.global.auth.dto.TokenResponse; +import oba.backend.server.global.auth.jwt.JwtProvider; +import oba.backend.server.global.auth.oauth.OAuth2UserInfo; +import oba.backend.server.global.common.Const; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController { + + private final JwtProvider jwtProvider; + private final UserService userService; + + // 모바일 소셜 로그인 + @PostMapping("/mobile/login") + public ResponseEntity mobileLogin(@RequestBody LoginRequest request) { + // OAuth2UserInfo 가방에 담아서 서비스에 전달 + OAuth2UserInfo userInfo = OAuth2UserInfo.builder() + .id(request.getIdToken()) + .email(request.getIdToken() + "@mobile.user") + .name("모바일유저") + .provider("MOBILE") // AuthProvider.MOBILE로 매핑 + .build(); + + User user = userService.registerOrUpdateUser(userInfo); + + return ResponseEntity.ok(jwtProvider.generateTokens(user.getId(), user.getIdentifier())); + } + + // 토큰 재발급 + @PostMapping("/reissue") + public ResponseEntity reissue(@RequestHeader("Authorization") String refreshHeader) { + if (refreshHeader == null || !refreshHeader.startsWith(Const.BEARER_PREFIX)) { + return ResponseEntity.badRequest().build(); + } + + String token = refreshHeader.substring(Const.BEARER_PREFIX.length()); + + if (!jwtProvider.validateToken(token)) { + return ResponseEntity.status(401).build(); + } + + Long userId = jwtProvider.getUserId(token); + String identifier = jwtProvider.getIdentifier(token); + + return ResponseEntity.ok(jwtProvider.generateTokens(userId, identifier)); + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/global/auth/controller/OAuthBridgeController.java b/server/src/main/java/oba/backend/server/global/auth/controller/OAuthBridgeController.java new file mode 100644 index 0000000..f2f32dc --- /dev/null +++ b/server/src/main/java/oba/backend/server/global/auth/controller/OAuthBridgeController.java @@ -0,0 +1,13 @@ +package oba.backend.server.global.auth.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class OAuthBridgeController { + + @GetMapping("/oauth/bridge") + public String oauthBridge() { + return "oauth-bridge"; + } +} diff --git a/server/src/main/java/oba/backend/server/global/auth/dto/LoginRequest.java b/server/src/main/java/oba/backend/server/global/auth/dto/LoginRequest.java new file mode 100644 index 0000000..d7441f8 --- /dev/null +++ b/server/src/main/java/oba/backend/server/global/auth/dto/LoginRequest.java @@ -0,0 +1,8 @@ +package oba.backend.server.global.auth.dto; + +import lombok.Getter; + +@Getter +public class LoginRequest { + private String idToken; +} diff --git a/server/src/main/java/oba/backend/server/global/auth/dto/TokenResponse.java b/server/src/main/java/oba/backend/server/global/auth/dto/TokenResponse.java new file mode 100644 index 0000000..183bc00 --- /dev/null +++ b/server/src/main/java/oba/backend/server/global/auth/dto/TokenResponse.java @@ -0,0 +1,11 @@ +package oba.backend.server.global.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class TokenResponse { + private String accessToken; + private String refreshToken; +} diff --git a/server/src/main/java/oba/backend/server/global/auth/jwt/JwtAuthenticationFilter.java b/server/src/main/java/oba/backend/server/global/auth/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..9c86297 --- /dev/null +++ b/server/src/main/java/oba/backend/server/global/auth/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,40 @@ +package oba.backend.server.global.auth.jwt; + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain chain) + throws ServletException, IOException { + + String token = jwtProvider.resolveToken(request); + + if (token != null && jwtProvider.validateToken(token)) { + Claims claims = jwtProvider.getClaims(token); + String identifier = claims.getSubject(); + Authentication auth = jwtProvider.getAuthentication(identifier); + SecurityContextHolder.getContext().setAuthentication(auth); + } + + chain.doFilter(request, response); + } +} diff --git a/server/src/main/java/oba/backend/server/global/auth/jwt/JwtProvider.java b/server/src/main/java/oba/backend/server/global/auth/jwt/JwtProvider.java new file mode 100644 index 0000000..978bcc0 --- /dev/null +++ b/server/src/main/java/oba/backend/server/global/auth/jwt/JwtProvider.java @@ -0,0 +1,106 @@ +package oba.backend.server.global.auth.jwt; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import oba.backend.server.global.auth.dto.TokenResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpServletRequest; +import javax.crypto.SecretKey; +import java.util.Date; + +@Component +public class JwtProvider { + + private final SecretKey key; + private final long accessTokenValidity; // ms + private final long refreshTokenValidity; // ms + + public JwtProvider( + @Value("${jwt.secret}") String secret, + @Value("${jwt.access-token-expiration-ms}") long accessTokenValidity, + @Value("${jwt.refresh-token-expiration-ms}") long refreshTokenValidity + ) { + this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); + this.accessTokenValidity = accessTokenValidity; + this.refreshTokenValidity = refreshTokenValidity; + } + + private String createToken(Long userId, String identifier, long validityMs) { + long now = System.currentTimeMillis(); + Date issuedAt = new Date(now); + Date expiry = new Date(now + validityMs); + + return Jwts.builder() + .claim("userId", userId) + .setSubject(identifier) + .setIssuedAt(issuedAt) + .setExpiration(expiry) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public String createAccessToken(Long userId, String identifier) { + return createToken(userId, identifier, accessTokenValidity); + } + + public String createRefreshToken(Long userId, String identifier) { + return createToken(userId, identifier, refreshTokenValidity); + } + + public TokenResponse generateTokens(Long userId, String identifier) { + return new TokenResponse( + createAccessToken(userId, identifier), + createRefreshToken(userId, identifier) + ); + } + + public boolean validateToken(String token) { + try { + parseClaims(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + public Claims getClaims(String token) { + return parseClaims(token).getBody(); + } + + private Jws parseClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token); + } + + public Long getUserId(String token) { + return getClaims(token).get("userId", Long.class); + } + + public String getIdentifier(String token) { + return getClaims(token).getSubject(); + } + + public String resolveToken(HttpServletRequest request) { + String bearer = request.getHeader("Authorization"); + if (bearer == null || !bearer.startsWith("Bearer ")) return null; + return bearer.substring(7); + } + + public org.springframework.security.core.Authentication getAuthentication(String identifier) { + org.springframework.security.core.userdetails.UserDetails user = + org.springframework.security.core.userdetails.User.builder() + .username(identifier) + .password("") // not used in JWT auth + .authorities("USER") + .build(); + + return new org.springframework.security.authentication.UsernamePasswordAuthenticationToken( + user, "", user.getAuthorities() + ); + } +} diff --git a/server/src/main/java/oba/backend/server/global/auth/oauth/CustomOAuth2User.java b/server/src/main/java/oba/backend/server/global/auth/oauth/CustomOAuth2User.java new file mode 100644 index 0000000..d0f383e --- /dev/null +++ b/server/src/main/java/oba/backend/server/global/auth/oauth/CustomOAuth2User.java @@ -0,0 +1,36 @@ +package oba.backend.server.global.auth.oauth; + +import lombok.Getter; +import oba.backend.server.domain.user.entity.User; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.Map; + +@Getter +public class CustomOAuth2User implements OAuth2User { + + private final OAuth2User delegate; + private final User user; + + public CustomOAuth2User(OAuth2User delegate, User user) { + this.delegate = delegate; + this.user = user; + } + + @Override + public Map getAttributes() { + return delegate.getAttributes(); + } + + @Override + public Collection extends GrantedAuthority> getAuthorities() { + return delegate.getAuthorities(); + } + + @Override + public String getName() { + return delegate.getName(); + } +} diff --git a/server/src/main/java/oba/backend/server/global/auth/oauth/CustomOAuth2UserService.java b/server/src/main/java/oba/backend/server/global/auth/oauth/CustomOAuth2UserService.java new file mode 100644 index 0000000..a7e3125 --- /dev/null +++ b/server/src/main/java/oba/backend/server/global/auth/oauth/CustomOAuth2UserService.java @@ -0,0 +1,88 @@ +package oba.backend.server.global.auth.oauth; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import oba.backend.server.domain.user.entity.User; +import oba.backend.server.domain.user.service.UserService; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final UserService userService; + + @Override + public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(request); + String registrationId = request.getClientRegistration().getRegistrationId(); // google, kakao, naver + + OAuthAttributes attributes = OAuthAttributes.of(registrationId, oAuth2User.getAttributes()); + + String uniqueIdentifier = registrationId + "_" + attributes.identifier(); + + OAuth2UserInfo userInfo = OAuth2UserInfo.builder() + .id(uniqueIdentifier) // DB에 "google_12345" 형태로 저장 + .email(attributes.email()) + .name(attributes.name()) + .picture(attributes.picture()) + .provider(registrationId.toUpperCase()) + .build(); + + log.info("OAuth2 User Loaded: Identifier={}", userInfo.getId()); + + User user = userService.registerOrUpdateUser(userInfo); + + return new CustomOAuth2User(oAuth2User, user); + } + + private record OAuthAttributes(String identifier, String email, String name, String picture) { + static OAuthAttributes of(String provider, Map attributes) { + return switch (provider.toLowerCase()) { + case "google" -> ofGoogle(attributes); + case "kakao" -> ofKakao(attributes); + case "naver" -> ofNaver(attributes); + default -> throw new IllegalArgumentException("Unsupported provider: " + provider); + }; + } + + private static OAuthAttributes ofGoogle(Map attributes) { + return new OAuthAttributes( + (String) attributes.get("sub"), + (String) attributes.get("email"), + (String) attributes.get("name"), + (String) attributes.get("picture") + ); + } + + @SuppressWarnings("unchecked") + private static OAuthAttributes ofKakao(Map attributes) { + Map account = (Map) attributes.get("kakao_account"); + Map profile = (account != null) ? (Map) account.get("profile") : null; + return new OAuthAttributes( + String.valueOf(attributes.get("id")), + (account != null) ? (String) account.get("email") : null, + (profile != null) ? (String) profile.get("nickname") : null, + (profile != null) ? (String) profile.get("profile_image_url") : null + ); + } + + @SuppressWarnings("unchecked") + private static OAuthAttributes ofNaver(Map attributes) { + Map response = (Map) attributes.get("response"); + return new OAuthAttributes( + (response != null) ? (String) response.get("id") : "", + (response != null) ? (String) response.get("email") : null, + (response != null) ? (String) response.get("name") : null, + (response != null) ? (String) response.get("profile_image") : null + ); + } + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2LoginSuccessHandler.java b/server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2LoginSuccessHandler.java new file mode 100644 index 0000000..7eb7cda --- /dev/null +++ b/server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2LoginSuccessHandler.java @@ -0,0 +1,51 @@ +package oba.backend.server.global.auth.oauth; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import oba.backend.server.domain.user.entity.User; +import oba.backend.server.global.auth.jwt.JwtProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtProvider jwtProvider; + + @Value("${app.mobile-redirect}") + private String mobileRedirectUri; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + + CustomOAuth2User customUser = (CustomOAuth2User) authentication.getPrincipal(); + User user = customUser.getUser(); + + String identifier = user.getIdentifier(); + + String accessToken = jwtProvider.createAccessToken(user.getId(), identifier); + String refreshToken = jwtProvider.createRefreshToken(user.getId(), identifier); + + log.info("OAuth2 Login Success: User={}, Identifier={}", user.getName(), identifier); + + String targetUrl = UriComponentsBuilder.fromUriString(mobileRedirectUri) + .queryParam("access_token", accessToken) + .queryParam("refresh_token", refreshToken) + .build() + .encode(StandardCharsets.UTF_8) + .toUriString(); + + clearAuthenticationAttributes(request); + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2UserInfo.java b/server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2UserInfo.java new file mode 100644 index 0000000..a1fb77e --- /dev/null +++ b/server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2UserInfo.java @@ -0,0 +1,18 @@ +package oba.backend.server.global.auth.oauth; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OAuth2UserInfo { + private String id; + private String email; + private String name; + private String picture; + private String provider; +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/entity/BaseEntity.java b/server/src/main/java/oba/backend/server/global/common/BaseEntity.java similarity index 88% rename from server/src/main/java/oba/backend/server/entity/BaseEntity.java rename to server/src/main/java/oba/backend/server/global/common/BaseEntity.java index 6182351..04cc426 100644 --- a/server/src/main/java/oba/backend/server/entity/BaseEntity.java +++ b/server/src/main/java/oba/backend/server/global/common/BaseEntity.java @@ -1,8 +1,7 @@ -package oba.backend.server.entity; +package oba.backend.server.global.common; import jakarta.persistence.*; import lombok.Getter; -import lombok.Setter; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; @@ -26,7 +25,6 @@ public abstract class BaseEntity { @Column(nullable = false) private Boolean isDeleted = false; - /** 소프트 삭제 수행 */ public void softDelete() { this.isDeleted = true; this.deletedAt = LocalDateTime.now(); diff --git a/server/src/main/java/oba/backend/server/global/common/Const.java b/server/src/main/java/oba/backend/server/global/common/Const.java new file mode 100644 index 0000000..fc0dbb3 --- /dev/null +++ b/server/src/main/java/oba/backend/server/global/common/Const.java @@ -0,0 +1,8 @@ +package oba.backend.server.global.common; + +public class Const { + public static final String CACHE_USER = "userCache"; + public static final String CACHE_ARTICLE_DETAIL = "articleDetail"; + public static final String CACHE_LATEST_ARTICLES = "latestArticles"; + public static final String BEARER_PREFIX = "Bearer "; +} diff --git a/server/src/main/java/oba/backend/server/global/config/CacheConfig.java b/server/src/main/java/oba/backend/server/global/config/CacheConfig.java new file mode 100644 index 0000000..fb12bde --- /dev/null +++ b/server/src/main/java/oba/backend/server/global/config/CacheConfig.java @@ -0,0 +1,36 @@ +package oba.backend.server.global.config; + +import com.github.benmanes.caffeine.cache.Caffeine; +import oba.backend.server.global.common.Const; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.concurrent.TimeUnit; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public Caffeine caffeineConfig() { + return Caffeine.newBuilder() + .initialCapacity(100) + .maximumSize(5000) + .expireAfterWrite(30, TimeUnit.MINUTES) // 캐시 만료 시간 30분 + .recordStats(); + } + + @Bean + public CacheManager cacheManager(Caffeine caffeine) { + CaffeineCacheManager manager = new CaffeineCacheManager( + Const.CACHE_USER, + Const.CACHE_ARTICLE_DETAIL, + Const.CACHE_LATEST_ARTICLES + ); + manager.setCaffeine(caffeine); + return manager; + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/global/config/CorsConfig.java b/server/src/main/java/oba/backend/server/global/config/CorsConfig.java new file mode 100644 index 0000000..d1a81dd --- /dev/null +++ b/server/src/main/java/oba/backend/server/global/config/CorsConfig.java @@ -0,0 +1,30 @@ +package oba.backend.server.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +public class CorsConfig { + + @Value("${cors.allowed-origins}") + private List allowedOrigins; + + @Bean + public WebMvcConfigurer corsConfigurer() { + return new WebMvcConfigurer() { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins(allowedOrigins.toArray(new String[0])) // 명시적 도메인 허용 + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true); // 쿠키/인증정보 포함 허용 + } + }; + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/global/config/MongoCheckRunner.java b/server/src/main/java/oba/backend/server/global/config/MongoCheckRunner.java new file mode 100644 index 0000000..0d7ce0e --- /dev/null +++ b/server/src/main/java/oba/backend/server/global/config/MongoCheckRunner.java @@ -0,0 +1,44 @@ +package oba.backend.server.global.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MongoCheckRunner implements CommandLineRunner { + + private final MongoTemplate mongoTemplate; + + @Override + public void run(String... args) { + log.info("=========================================="); + log.info("[MongoDB Connection Check]"); + + try { + // DB 연결 확인 + String dbName = mongoTemplate.getDb().getName(); + log.info("Connected Database: {}", dbName); + + // 컬렉션 존재 여부 확인 (대소문자 구분 중요!) + String collectionName = "Selected_Articles"; // Entity의 @Document 값과 일치해야 함 + boolean exists = mongoTemplate.collectionExists(collectionName); + + if (exists) { + long count = mongoTemplate.getCollection(collectionName).countDocuments(); + log.info("Collection '{}' FOUND. (Docs: {} count)", collectionName, count); + } else { + log.error("Collection '{}' NOT FOUND!", collectionName); + log.error(" - Check capitalization (Selected_Articles vs selected_articles)"); + log.error(" - Current Collections: {}", mongoTemplate.getCollectionNames()); + } + + } catch (Exception e) { + log.error("MongoDB Connection Failed: ", e); + } + log.info("=========================================="); + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/global/config/RestTemplateConfig.java b/server/src/main/java/oba/backend/server/global/config/RestTemplateConfig.java new file mode 100644 index 0000000..f47e480 --- /dev/null +++ b/server/src/main/java/oba/backend/server/global/config/RestTemplateConfig.java @@ -0,0 +1,14 @@ +package oba.backend.server.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} diff --git a/server/src/main/java/oba/backend/server/global/config/SecurityConfig.java b/server/src/main/java/oba/backend/server/global/config/SecurityConfig.java new file mode 100644 index 0000000..174ce8b --- /dev/null +++ b/server/src/main/java/oba/backend/server/global/config/SecurityConfig.java @@ -0,0 +1,81 @@ +package oba.backend.server.global.config; + +import lombok.RequiredArgsConstructor; +import oba.backend.server.global.auth.oauth.CustomOAuth2UserService; +import oba.backend.server.global.auth.oauth.OAuth2LoginSuccessHandler; +import oba.backend.server.global.auth.jwt.JwtAuthenticationFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; + +import java.util.List; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtFilter; + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + http + // CSRF 비활성화 (JWT 사용 시 필요) + .csrf(AbstractHttpConfigurer::disable) + + // CORS 설정 + .cors(cors -> cors.configurationSource(request -> { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOriginPatterns(List.of("*")); // 개발용 전체 허용 + config.setAllowedHeaders(List.of("*")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + config.setAllowCredentials(true); + return config; + })) + + // 세션 관리 (Stateless) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + // API 접근 권한 설정 + .authorizeHttpRequests(auth -> auth + // 기사 조회(GET)는 로그인 없이 허용 + .requestMatchers(HttpMethod.GET, "/api/articles/**").permitAll() + + // 로그인/인증 관련 경로는 모두 허용 + .requestMatchers( + "/oauth2/**", + "/login/**", + "/auth/**", + "/error", + "/favicon.ico" + ).permitAll() + + // 퀴즈 풀기, 마이페이지 등은 인증 필요 + .requestMatchers("/api/quiz/**", "/api/users/me").authenticated() + + // 그 외 모든 요청은 인증 필요 + .anyRequest().authenticated() + ) + + // OAuth2 로그인 설정 + .oauth2Login(oauth -> oauth + .userInfoEndpoint(info -> info.userService(customOAuth2UserService)) + .successHandler(oAuth2LoginSuccessHandler) + ) + + // JWT 필터 + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/scheduler/AiScheduler.java b/server/src/main/java/oba/backend/server/scheduler/AiScheduler.java deleted file mode 100644 index 2614f3d..0000000 --- a/server/src/main/java/oba/backend/server/scheduler/AiScheduler.java +++ /dev/null @@ -1,25 +0,0 @@ -package oba.backend.server.scheduler; - -import lombok.RequiredArgsConstructor; -import oba.backend.server.service.AiService; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class AiScheduler { - - private final AiService aiService; - - /** - * 매일 새벽 4시 자동 실행 - * cron 형식: 초 분 시 일 월 요일 - * "0 0 4 * * *" = 매일 00:00:00 - */ - @Scheduled(cron = "0 0 0 * * *") - public void autoDailyGptUpdate() { - System.out.println("🔥 [SCHEDULER] Daily GPT Update 실행 시작"); - String result = aiService.runDailyAiJob(); - System.out.println("✅ [SCHEDULER] 실행 완료: " + result); - } -} diff --git a/server/src/main/java/oba/backend/server/security/CustomAuthorizationRequestResolver.java b/server/src/main/java/oba/backend/server/security/CustomAuthorizationRequestResolver.java deleted file mode 100644 index 3073cd9..0000000 --- a/server/src/main/java/oba/backend/server/security/CustomAuthorizationRequestResolver.java +++ /dev/null @@ -1,61 +0,0 @@ -package oba.backend.server.security; - -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import oba.backend.server.domain.user.UserRepository; -import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; -import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; -import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; -import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; -import org.springframework.stereotype.Component; - -import java.util.LinkedHashMap; -import java.util.Map; - -@Component -@RequiredArgsConstructor -public class CustomAuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver { - - private final ClientRegistrationRepository clientRegistrationRepository; - private final UserRepository userRepository; - - private final String authorizationRequestBaseUri = "/oauth2/authorization"; - - @Override - public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { - DefaultOAuth2AuthorizationRequestResolver baseResolver = - new DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository, authorizationRequestBaseUri); - OAuth2AuthorizationRequest req = baseResolver.resolve(request); - if (req == null) return null; - return customize(request, req); - } - - @Override - public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) { - DefaultOAuth2AuthorizationRequestResolver baseResolver = - new DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository, authorizationRequestBaseUri); - OAuth2AuthorizationRequest req = baseResolver.resolve(request, clientRegistrationId); - if (req == null) return null; - return customize(request, req); - } - - private OAuth2AuthorizationRequest customize(HttpServletRequest request, - OAuth2AuthorizationRequest req) { - String uri = request.getRequestURI(); - String registrationId = uri.substring(uri.lastIndexOf('/') + 1); // google|kakao|naver - - // 기본 파라미터 복사 - Map params = new LinkedHashMap<>(req.getAdditionalParameters()); - - // ✅ 항상 동의창 유도 (DB 확인 가능하게 확장 가능) - switch (registrationId) { - case "google" -> params.put("prompt", "consent"); - case "kakao" -> params.put("prompt", "login"); // or consent - case "naver" -> params.put("auth_type", "reprompt"); - } - - return OAuth2AuthorizationRequest.from(req) - .additionalParameters(params) - .build(); - } -} diff --git a/server/src/main/java/oba/backend/server/security/CustomOAuth2User.java b/server/src/main/java/oba/backend/server/security/CustomOAuth2User.java deleted file mode 100644 index b78842b..0000000 --- a/server/src/main/java/oba/backend/server/security/CustomOAuth2User.java +++ /dev/null @@ -1,43 +0,0 @@ -package oba.backend.server.security; - -import lombok.RequiredArgsConstructor; -import oba.backend.server.domain.user.User; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.core.user.OAuth2User; - -import java.util.Collection; -import java.util.List; -import java.util.Map; - -@RequiredArgsConstructor -public class CustomOAuth2User implements OAuth2User { - - private final User user; - private final Map attributes; - - @Override - public Map getAttributes() { - return attributes; - } - - // 🔥 권한 반환 (ROLE_USER, ROLE_ADMIN 방식) - @Override - public Collection extends GrantedAuthority> getAuthorities() { - return List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name())); - } - - @Override - public String getName() { - return user.getName(); - } - - // 🔥 JWT 발급 시 식별자 반환 - public String getIdentifier() { - return user.getIdentifier(); - } - - public User getUser() { - return user; - } -} diff --git a/server/src/main/java/oba/backend/server/security/CustomOAuth2UserService.java b/server/src/main/java/oba/backend/server/security/CustomOAuth2UserService.java deleted file mode 100644 index 3f7a1db..0000000 --- a/server/src/main/java/oba/backend/server/security/CustomOAuth2UserService.java +++ /dev/null @@ -1,57 +0,0 @@ -package oba.backend.server.security; - -import lombok.RequiredArgsConstructor; -import oba.backend.server.domain.user.ProviderInfo; -import oba.backend.server.domain.user.Role; -import oba.backend.server.domain.user.User; -import oba.backend.server.domain.user.UserRepository; -import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class CustomOAuth2UserService extends DefaultOAuth2UserService { - - private final UserRepository userRepository; - - @Override - public OAuth2User loadUser(OAuth2UserRequest request) { - - OAuth2User oAuth2User = super.loadUser(request); - - String provider = request.getClientRegistration().getRegistrationId(); // google, kakao, naver - - OAuthAttributes attributes = OAuthAttributes.of(provider, oAuth2User.getAttributes()); - - // identifier = "google:고유ID" - String identifier = provider + ":" + attributes.id(); - - // DB 조회 - User user = userRepository.findByIdentifier(identifier) - .map(existing -> { - existing.updateInfo( - attributes.email(), - attributes.name(), - attributes.picture() - ); - return existing; - }) - .orElseGet(() -> User.builder() - .identifier(identifier) - .email(attributes.email()) - .name(attributes.name()) - .picture(attributes.picture()) - .provider(ProviderInfo.from(provider)) - .role(Role.USER) - .build() - ); - - // 저장 - userRepository.save(user); - - // OAuth2User 반환 - return new CustomOAuth2User(user, attributes.attributes()); - } -} diff --git a/server/src/main/java/oba/backend/server/security/OAuth2FailureHandler.java b/server/src/main/java/oba/backend/server/security/OAuth2FailureHandler.java deleted file mode 100644 index a9821f2..0000000 --- a/server/src/main/java/oba/backend/server/security/OAuth2FailureHandler.java +++ /dev/null @@ -1,26 +0,0 @@ -package oba.backend.server.security; - -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.authentication.AuthenticationFailureHandler; - -import java.io.IOException; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; - -public class OAuth2FailureHandler implements AuthenticationFailureHandler { - - @Override - public void onAuthenticationFailure( - jakarta.servlet.http.HttpServletRequest request, - HttpServletResponse response, - AuthenticationException exception) throws IOException { - - // 민감한 exception.getMessage() 대신 고정 코드 전달 - String msg = URLEncoder.encode("oauth2_login_failed", StandardCharsets.UTF_8); - response.sendRedirect("/login?error=" + msg); - - // 실제 상세 에러는 서버 로그에만 기록 - // log.warn("OAuth2 login failed", exception); - } -} diff --git a/server/src/main/java/oba/backend/server/security/OAuth2SuccessHandler.java b/server/src/main/java/oba/backend/server/security/OAuth2SuccessHandler.java deleted file mode 100644 index dd61400..0000000 --- a/server/src/main/java/oba/backend/server/security/OAuth2SuccessHandler.java +++ /dev/null @@ -1,48 +0,0 @@ -package oba.backend.server.security; - -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import oba.backend.server.common.jwt.JwtProvider; -import oba.backend.server.dto.TokenResponse; -import org.springframework.security.core.Authentication; -import org.springframework.security.web.authentication.AuthenticationSuccessHandler; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; - -@RequiredArgsConstructor -public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { - - private final JwtProvider jwtProvider; - - @Override - public void onAuthenticationSuccess( - jakarta.servlet.http.HttpServletRequest request, - HttpServletResponse response, - Authentication authentication) throws IOException { - - TokenResponse tokens = jwtProvider.generateToken(authentication); - - // ✅ Refresh Token (7일, HttpOnly + Secure + SameSite) - Cookie refreshCookie = new Cookie("refresh_token", tokens.refreshToken()); - refreshCookie.setHttpOnly(true); - refreshCookie.setSecure(true); - refreshCookie.setPath("/"); - refreshCookie.setMaxAge(7 * 24 * 60 * 60); - refreshCookie.setAttribute("SameSite", "None"); - response.addCookie(refreshCookie); - - // ✅ Access Token (30분, HttpOnly + Secure + SameSite) - Cookie accessCookie = new Cookie("access_token", tokens.accessToken()); - accessCookie.setHttpOnly(true); - accessCookie.setSecure(true); - accessCookie.setPath("/"); - accessCookie.setMaxAge(30 * 60); - accessCookie.setAttribute("SameSite", "None"); - response.addCookie(accessCookie); - - // ✅ 리디렉트 시 토큰 전달 금지 → 상태만 표시 - response.sendRedirect("/login?success=true"); - } -} diff --git a/server/src/main/java/oba/backend/server/security/OAuthAttributes.java b/server/src/main/java/oba/backend/server/security/OAuthAttributes.java deleted file mode 100644 index 497aadb..0000000 --- a/server/src/main/java/oba/backend/server/security/OAuthAttributes.java +++ /dev/null @@ -1,58 +0,0 @@ -package oba.backend.server.security; - -import java.util.Map; - -public record OAuthAttributes( - String id, - String email, - String name, - String picture, - Map attributes -) { - - public static OAuthAttributes of(String provider, Map attributes) { - return switch (provider) { - case "google" -> ofGoogle(attributes); - case "kakao" -> ofKakao(attributes); - case "naver" -> ofNaver(attributes); - default -> throw new IllegalArgumentException("Unknown provider: " + provider); - }; - } - - private static OAuthAttributes ofGoogle(Map attr) { - return new OAuthAttributes( - (String) attr.get("sub"), - (String) attr.get("email"), - (String) attr.get("name"), - (String) attr.get("picture"), - attr - ); - } - - @SuppressWarnings("unchecked") - private static OAuthAttributes ofKakao(Map attr) { - Map account = (Map) attr.get("kakao_account"); - Map profile = (Map) account.get("profile"); - - return new OAuthAttributes( - String.valueOf(attr.get("id")), - (String) account.get("email"), - (String) profile.get("nickname"), - (String) profile.get("profile_image_url"), - attr - ); - } - - @SuppressWarnings("unchecked") - private static OAuthAttributes ofNaver(Map attr) { - Map response = (Map) attr.get("response"); - - return new OAuthAttributes( - (String) response.get("id"), - (String) response.get("email"), - (String) response.get("name"), - (String) response.get("profile_image"), - attr - ); - } -} diff --git a/server/src/main/java/oba/backend/server/security/UserPrincipal.java b/server/src/main/java/oba/backend/server/security/UserPrincipal.java deleted file mode 100644 index e5b3afa..0000000 --- a/server/src/main/java/oba/backend/server/security/UserPrincipal.java +++ /dev/null @@ -1,75 +0,0 @@ -package oba.backend.server.security; - -import lombok.Getter; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.oauth2.core.user.OAuth2User; - -import java.util.Collection; -import java.util.List; -import java.util.Map; - -@Getter -public class UserPrincipal implements OAuth2User, UserDetails { - - private final String id; - private final String email; - private final Map attributes; - private final Collection extends GrantedAuthority> authorities; - - public UserPrincipal(String id, - String email, - Map attributes, - Collection extends GrantedAuthority> authorities) { - this.id = id; - this.email = email; - // ✅ null 방지 처리 (빈 컬렉션/맵으로 초기화) - this.attributes = (attributes == null) ? Map.of() : Map.copyOf(attributes); - this.authorities = (authorities == null) ? List.of() : List.copyOf(authorities); - } - - @Override - public Map getAttributes() { - return attributes; - } - - @Override - public String getName() { - return id; - } - - @Override - public Collection extends GrantedAuthority> getAuthorities() { - return authorities; - } - - @Override - public String getPassword() { - return null; // 소셜 로그인 사용 시 패스워드 불필요 - } - - @Override - public String getUsername() { - return email; - } - - @Override - public boolean isAccountNonExpired() { - return true; - } - - @Override - public boolean isAccountNonLocked() { - return true; - } - - @Override - public boolean isCredentialsNonExpired() { - return true; - } - - @Override - public boolean isEnabled() { - return true; - } -} diff --git a/server/src/main/java/oba/backend/server/service/AiService.java b/server/src/main/java/oba/backend/server/service/AiService.java deleted file mode 100644 index 91d0ee8..0000000 --- a/server/src/main/java/oba/backend/server/service/AiService.java +++ /dev/null @@ -1,29 +0,0 @@ -package oba.backend.server.service; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; -import org.springframework.http.ResponseEntity; - -@Service -@RequiredArgsConstructor -public class AiService { - - private final RestTemplate restTemplate = new RestTemplate(); - - // FastAPI 주소 - private final String FASTAPI_URL = "http://localhost:8000/generate_daily_gpt_results"; - - // FastAPI 호출 로직 - public String runDailyAiJob() { - System.out.println("[Spring] FastAPI 호출 시작 → " + FASTAPI_URL); - - ResponseEntity response = - restTemplate.postForEntity(FASTAPI_URL, null, String.class); - - System.out.println("[Spring] FastAPI 응답 수신:"); - System.out.println(response.getBody()); - - return response.getBody(); - } -} diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml new file mode 100644 index 0000000..ae37d95 --- /dev/null +++ b/server/src/main/resources/application.yml @@ -0,0 +1,84 @@ +server: + port: 9000 + forward-headers-strategy: framework + +spring: + config: + import: optional:file:./.env[.properties] + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + + jpa: + hibernate: + ddl-auto: update + naming: + physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.MySQLDialect + + data: + mongodb: + uri: ${MONGODB_URI} + database: OneBitArticle + + security: + oauth2: + client: + registration: + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + redirect-uri: "http://dev.onebitearticle.com:9000/login/oauth2/code/google" + authorization-grant-type: authorization_code + scope: [email, profile] + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: "http://dev.onebitearticle.com:9000/login/oauth2/code/kakao" + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + scope: [profile_nickname, profile_image, account_email] + naver: + client-id: ${NAVER_CLIENT_ID} + client-secret: ${NAVER_CLIENT_SECRET} + redirect-uri: "http://dev.onebitearticle.com:9000/login/oauth2/code/naver" + authorization-grant-type: authorization_code + scope: [name, email, profile_image] + provider: + google: + authorization-uri: https://accounts.google.com/o/oauth2/v2/auth + token-uri: https://oauth2.googleapis.com/token + user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo + user-name-attribute: sub + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + naver: + authorization-uri: https://nid.naver.com/oauth2.0/authorize + token-uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user-name-attribute: response + +jwt: + secret: ${JWT_SECRET} + access-token-expiration-ms: ${JWT_ACCESS_TOKEN_EXPIRATION_MS} + refresh-token-expiration-ms: ${JWT_REFRESH_TOKEN_EXPIRATION_MS} + +ai: + server: + url: ${AI_SERVER_URL} + +app: + mobile-redirect: ${MOBILE_REDIRECT_URI} + +cors: + allowed-origins: "http://localhost:3000, http://192.168.219.101:8081, https://onebitearticle.com, http://localhost:8081" \ No newline at end of file diff --git a/server/src/main/resources/static/css/login.css b/server/src/main/resources/static/css/login.css deleted file mode 100644 index a512994..0000000 --- a/server/src/main/resources/static/css/login.css +++ /dev/null @@ -1,121 +0,0 @@ -:root{ - --ring:#e2e8f0; --muted:#64748b; --card:#fff; - --g:#4285F4; --k:#FEE500; --n:#03C75A; -} - -*{box-sizing:border-box} -html,body{height:100%} -body{ - margin:0; background:#f8fafc; color:#0f172a; - font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Inter,Apple SD Gothic Neo,Malgun Gothic,맑은고딕,sans-serif; - display:grid; place-items:center; padding:24px; - -webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale; -} - -.card{ - width:min(560px, 92vw); - background:var(--card); - border:1px solid #e5e7eb; - border-radius:18px; - box-shadow:0 8px 30px rgba(2,6,23,.06); - padding:28px; -} - -.row{display:grid; gap:12px; margin-top:18px} - -/* ===== Hero(로고/타이틀) ===== */ -.hero{ text-align:center; margin-bottom:18px; } -.hero-logo{ - width:min(240px, 60vw); - height:auto; - border-radius:20px; - box-shadow:0 12px 32px rgba(2,6,23,.08); -} -.hero-title{ - margin:16px 0 6px; - font-size:22px; - font-weight:600; - letter-spacing:-0.2px; -} -.hero-desc{ - margin:0; - color:#6b7280; - font-size:14px; - font-weight:400; -} - -/* ===== Buttons ===== */ -.btn{ - display:flex; align-items:center; gap:12px; - background:#fff; border:1px solid var(--ring); - border-radius:12px; padding:14px 16px; - text-decoration:none; color:#111827; font-weight:600; - transition:transform .12s ease, box-shadow .12s ease, opacity .12s ease; -} -.btn:hover{transform:translateY(-1px); box-shadow:0 8px 16px rgba(0,0,0,.06)} -.btn:active{opacity:.9} -.btn:focus-visible{ outline:3px solid #60a5fa; outline-offset:2px; } - -.btn .icon{width:22px; height:22px; border-radius:4px; object-fit:cover} - -.btn.google{ color:#111 } -.btn.kakao{ background:var(--k); border-color:#f5e14d; color:#222 } -.btn.naver{ background:var(--n); color:#fff; border-color:#059669 } - -/* ===== 상태/기타 ===== */ -.hr{height:1px; background:#e5e7eb; margin:18px 0; border:0} -.status{margin-top:12px; color:#64748b; font-size:13px} - -.user{ - display:flex; align-items:center; gap:10px; - background:#faffff; padding:10px 12px; - border:1px dashed var(--ring); border-radius:12px; -} -.pill{display:inline-block; padding:2px 8px; border-radius:999px; background:#eef; color:#3b82f6; font-weight:700; font-size:12px} - -.logout{ - display:inline-flex; align-items:center; gap:8px; - background:#111; color:#fff; padding:10px 14px; border-radius:10px; - text-decoration:none; border:0; cursor:pointer; -} - -.footer{margin-top:18px; color:#94a3b8; font-size:12px} -.footer a{color:#3b82f6; text-decoration:none} - -/* /resources/static/css/login.css 의 하단에 추가/수정 */ - -.hero-logo{ - width:min(260px, 70vw); - height:min(260px, 70vw); - border-radius:24px; - display:block; - margin:8px auto 14px; - object-fit:cover; - box-shadow:0 12px 36px rgba(0,0,0,.12); -} - -.app-title{ - text-align:center; - font-weight:700; - font-size:22px; - margin-top:2px; - color:#0f172a; -} - -.hint{ - width:100%; - padding:14px 16px; - border:1px dashed var(--ring); - border-radius:12px; - background:#fafcff; - color:#334155; - font-size:14px; -} - -.switch{ - margin-left:8px; - text-decoration:none; - font-size:14px; - color:#2563eb; - -} diff --git a/server/src/main/resources/static/img/225098696.png b/server/src/main/resources/static/img/225098696.png deleted file mode 100644 index 36279cc..0000000 Binary files a/server/src/main/resources/static/img/225098696.png and /dev/null differ diff --git a/server/src/main/resources/static/img/google.png b/server/src/main/resources/static/img/google.png deleted file mode 100644 index 7e764e3..0000000 Binary files a/server/src/main/resources/static/img/google.png and /dev/null differ diff --git a/server/src/main/resources/static/img/kakao.png b/server/src/main/resources/static/img/kakao.png deleted file mode 100644 index 8a96f5a..0000000 Binary files a/server/src/main/resources/static/img/kakao.png and /dev/null differ diff --git a/server/src/main/resources/static/img/naver.png b/server/src/main/resources/static/img/naver.png deleted file mode 100644 index 952fa42..0000000 Binary files a/server/src/main/resources/static/img/naver.png and /dev/null differ diff --git a/server/src/main/resources/templates/login.html b/server/src/main/resources/templates/login.html deleted file mode 100644 index fe82441..0000000 --- a/server/src/main/resources/templates/login.html +++ /dev/null @@ -1,75 +0,0 @@ - - - - - OneBite 간편 로그인 - - - - - - - - - - OneBite 간편 로그인 - - - - SIGNED IN - user님으로 로그인되었습니다. - - - - - 로그아웃 - 계정 바꾸기 - - - - - - - Google로 계속하기 - - - Kakao로 계속하기 - - - Naver로 계속하기 - - - - - - - Google로 계속하기 - - - Kakao로 계속하기 - - - Naver로 계속하기 - - - - - - 이미 로그인되어 있습니다. 다른 계정으로 로그인하려면 계정 바꾸기를 누르세요. - - - - - - - - 로그인 중 문제가 발생했습니다. 다시 시도해주세요. - (error) - - - - diff --git a/server/src/test/java/oba/backend/server/ServerApplicationTests.java b/server/src/test/java/oba/backend/server/ServerApplicationTests.java index ea0665c..900082f 100644 --- a/server/src/test/java/oba/backend/server/ServerApplicationTests.java +++ b/server/src/test/java/oba/backend/server/ServerApplicationTests.java @@ -2,10 +2,12 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class ServerApplicationTests { - @Test - void contextLoads() { - } + @Test + void contextLoads() { + } } diff --git a/server/src/test/java/oba/backend/server/service/CustomOAuth2UserServiceTest.java b/server/src/test/java/oba/backend/server/service/CustomOAuth2UserServiceTest.java deleted file mode 100644 index 9d33215..0000000 --- a/server/src/test/java/oba/backend/server/service/CustomOAuth2UserServiceTest.java +++ /dev/null @@ -1,97 +0,0 @@ -package oba.backend.server.service; - -import oba.backend.server.domain.user.User; -import oba.backend.server.domain.user.UserRepository; -import oba.backend.server.domain.user.ProviderInfo; -import oba.backend.server.domain.user.Role; -import oba.backend.server.security.CustomOAuth2UserService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; -import org.springframework.security.oauth2.core.user.DefaultOAuth2User; -import org.springframework.security.oauth2.core.user.OAuth2User; - -import java.util.Map; -import java.util.Optional; -import java.util.Set; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -class CustomOAuth2UserServiceTest { - - private UserRepository userRepository; - private CustomOAuth2UserService customOAuth2UserService; - - @BeforeEach - void setUp() { - userRepository = mock(UserRepository.class); - customOAuth2UserService = new CustomOAuth2UserService(userRepository); - } - - @Test - void 새로운_사용자가_DB에_저장된다() { - // given - Map attributes = Map.of( - "sub", "1234567890", - "email", "test@example.com", - "name", "테스트 유저", - "picture", "http://test.com/profile.png" - ); - - // 최소 ROLE_USER 권한 부여 (IllegalArgumentException 방지) - OAuth2User oAuth2User = new DefaultOAuth2User( - Set.of(new SimpleGrantedAuthority("ROLE_USER")), - attributes, - "sub" - ); - assertThat(oAuth2User.getAttributes().get("email")).isEqualTo("test@example.com"); - - OAuth2UserRequest userRequest = mock(OAuth2UserRequest.class); - var clientRegistration = TestOAuth2Utils.createClientRegistration("google"); - when(userRequest.getClientRegistration()).thenReturn(clientRegistration); - - // super.loadUser() Mocking - CustomOAuth2UserService service = new CustomOAuth2UserService(userRepository) { - @Override - public OAuth2User loadUser(OAuth2UserRequest ignored) { - String registrationId = "google"; - - String id = (String) attributes.get("sub"); - String email = (String) attributes.get("email"); - String name = (String) attributes.get("name"); - String picture = (String) attributes.get("picture"); - - User user = userRepository.findByIdentifier(id) - .orElse(User.builder() - .identifier(id) - .provider(ProviderInfo.valueOf(registrationId.toUpperCase())) - .role(Role.USER) - .build()); - - user.updateInfo(email, name, picture); - userRepository.save(user); - - return oAuth2User; - } - }; - - when(userRepository.findByIdentifier("1234567890")).thenReturn(Optional.empty()); - - // when - service.loadUser(userRequest); - - // then - ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); - verify(userRepository, times(1)).save(captor.capture()); - User savedUser = captor.getValue(); - - assertThat(savedUser.getIdentifier()).isEqualTo("1234567890"); - assertThat(savedUser.getEmail()).isEqualTo("test@example.com"); - assertThat(savedUser.getName()).isEqualTo("테스트 유저"); - assertThat(savedUser.getProvider()).isEqualTo(ProviderInfo.GOOGLE); - assertThat(savedUser.getRole()).isEqualTo(Role.USER); - } -} diff --git a/server/src/test/java/oba/backend/server/service/TestOAuth2Utils.java b/server/src/test/java/oba/backend/server/service/TestOAuth2Utils.java deleted file mode 100644 index 256b826..0000000 --- a/server/src/test/java/oba/backend/server/service/TestOAuth2Utils.java +++ /dev/null @@ -1,22 +0,0 @@ -package oba.backend.server.service; - -import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.security.oauth2.core.AuthorizationGrantType; - -public class TestOAuth2Utils { - - public static ClientRegistration createClientRegistration(String registrationId) { - return ClientRegistration.withRegistrationId(registrationId) - .clientId("test-client-id") - .clientSecret("test-client-secret") - .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") - .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) - .scope("email", "profile") - .authorizationUri("http://localhost/auth") - .tokenUri("http://localhost/token") - .userInfoUri("http://localhost/userinfo") - .userNameAttributeName("sub") // Google 기본값 - .clientName("Test " + registrationId) - .build(); - } -} diff --git a/server/src/test/resources/application-test.yml b/server/src/test/resources/application-test.yml new file mode 100644 index 0000000..1866026 --- /dev/null +++ b/server/src/test/resources/application-test.yml @@ -0,0 +1,13 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect diff --git a/server/sudo b/server/sudo new file mode 100644 index 0000000..a17b0f8 --- /dev/null +++ b/server/sudo @@ -0,0 +1,300 @@ +mysql Ver 9.5.0 for macos26.1 on arm64 (Homebrew) +Copyright (c) 2000, 2025, Oracle and/or its affiliates. + +Oracle is a registered trademark of Oracle Corporation and/or its +affiliates. Other names may be trademarks of their respective +owners. + +Usage: mysql [OPTIONS] [database] + -?, --help Display this help and exit. + -I, --help Synonym for -? + --auto-rehash Enable automatic rehashing. One doesn't need to use + 'rehash' to get table and field completion, but startup + and reconnecting may take a longer time. Disable with + --disable-auto-rehash. + (Defaults to on; use --skip-auto-rehash to disable.) + -A, --no-auto-rehash + No automatic rehashing. One has to use 'rehash' to get + table and field completion. This gives a quicker start of + mysql and disables rehashing on reconnect. + --auto-vertical-output + Automatically switch to vertical output mode if the + result is wider than the terminal width. + -B, --batch Don't use history file. Disable interactive behavior. + (Enables --silent.) + --bind-address=name IP address to bind to. + --binary-as-hex Print binary data as hex. Enabled by default for + interactive terminals. + --character-sets-dir=name + Directory for character set files. + --column-type-info Display column type information. + --commands Enable or disable processing of local mysql commands. + -c, --comments Preserve comments. Send comments to the server. The + default is --comments (keep comments), disable with + --skip-comments. + (Defaults to on; use --skip-comments to disable.) + -C, --compress Use compression in server/client protocol. + -#, --debug[=#] This is a non-debug version. Catch this and exit. + --debug-check This is a non-debug version. Catch this and exit. + -T, --debug-info This is a non-debug version. Catch this and exit. + -D, --database=name Database to use. + --default-character-set=name + Set the default character set. + --delimiter=name Delimiter to be used. + --enable-cleartext-plugin + Enable/disable the clear text authentication plugin. + -e, --execute=name Execute command and quit. (Disables --force and history + file.) + -E, --vertical Print the output of a query (rows) vertically. + -f, --force Continue even if we get an SQL error. + --histignore=name A colon-separated list of patterns to keep statements + from getting logged into syslog and mysql history. + -G, --named-commands + Enable named commands. Named commands mean this program's + internal commands; see mysql> help . When enabled, the + named commands can be used from any line of the query, + otherwise only from the first line, before an enter. + Disable with --disable-named-commands. This option is + disabled by default. + -i, --ignore-spaces Ignore space after function names. + --init-command=name Single SQL Command to execute when connecting to MySQL + server. Will automatically be re-executed when + reconnecting. + --init-command-add=name + Add SQL command to the list to execute when connecting to + MySQL server. Will automatically be re-executed when + reconnecting. + --local-infile Enable/disable LOAD DATA LOCAL INFILE. + -b, --no-beep Turn off beep on error. + -h, --host=name Connect to host. + --dns-srv-name=name Connect to a DNS SRV resource + -H, --html Produce HTML output. + -X, --xml Produce XML output. + --line-numbers Write line numbers for errors. + (Defaults to on; use --skip-line-numbers to disable.) + -L, --skip-line-numbers + Don't write line number for errors. + -n, --unbuffered Flush buffer after each query. + --column-names Write column names in results. + (Defaults to on; use --skip-column-names to disable.) + -N, --skip-column-names + Don't write column names in results. + --sigint-ignore Ignore SIGINT (CTRL-C). + -o, --one-database Ignore statements except those that occur while the + default database is the one named at the command line. + --pager[=name] Pager to use to display results. If you don't supply an + option, the default pager is taken from your ENV variable + PAGER. Valid pagers are less, more, cat [> filename], + etc. See interactive help (\h) also. This option does not + work in batch mode. Disable with --disable-pager. This + option is disabled by default. + -p, --password[=name] + Password to use when connecting to server. If password is + not given it's asked from the tty. + --password1[=name] Password for first factor authentication plugin. + --password2[=name] Password for second factor authentication plugin. + --password3[=name] Password for third factor authentication plugin. + -P, --port=# Port number to use for connection or 0 for default to, in + order of preference, my.cnf, $MYSQL_TCP_PORT, + /etc/services, built-in default (3306). + --prompt=name Set the mysql prompt to this value. + --protocol=name The protocol to use for connection (tcp, socket, pipe, + memory). + -q, --quick Don't cache result, print it row by row. This may slow + down the server if the output is suspended. Doesn't use + history file. + -r, --raw Write fields without conversion. Used with --batch. + --reconnect Reconnect if the connection is lost. Disable with + --disable-reconnect. This option is enabled by default. + (Defaults to on; use --skip-reconnect to disable.) + -s, --silent Be more silent. Print results with a tab as separator, + each row on new line. + -S, --socket=name The socket file to use for connection. + --server-public-key-path=name + File path to the server public RSA key in PEM format. + --get-server-public-key + Get server public key + --ssl-mode=name SSL connection mode. + --ssl-ca=name CA file in PEM format. + --ssl-capath=name CA directory. + --ssl-cert=name X509 cert in PEM format. + --ssl-cipher=name SSL cipher to use. + --ssl-key=name X509 key in PEM format. + --ssl-crl=name Certificate revocation list. + --ssl-crlpath=name Certificate revocation list path. + --tls-version=name TLS version to use, permitted values are: TLSv1.2, + TLSv1.3 + --ssl-fips-mode=name + SSL FIPS mode (applies only for OpenSSL); permitted + values are: OFF, ON, STRICT + --tls-ciphersuites=name + TLS v1.3 cipher to use. + --ssl-session-data=name + Session data file to use to enable ssl session reuse + --ssl-session-data-continue-on-failed-reuse + If set to ON, this option will allow connection to + succeed even if session data cannot be reused. + --tls-sni-servername=name + The SNI server name to pass to server + -t, --table Output in table format. + --tee=name Append everything into outfile. See interactive help (\h) + also. Does not work in batch mode. Disable with + --disable-tee. This option is disabled by default. + -u, --user=name User for login if not current user. + -U, --safe-updates Only allow UPDATE and DELETE that uses keys. + -U, --i-am-a-dummy Synonym for option --safe-updates, -U. + -v, --verbose Write more. (-v -v -v gives the table output format). + -V, --version Output version information and exit. + -w, --wait Wait and retry if connection is down. + --connect-timeout=# Number of seconds before connection timeout. + --max-allowed-packet=# + The maximum packet length to send to or receive from + server. + --net-buffer-length=# + The buffer size for TCP/IP and socket communication. + --select-limit=# Automatic limit for SELECT when using --safe-updates. + --max-join-size=# Automatic limit for rows in a join when using + --safe-updates. + --show-warnings Show warnings after every statement. + -j, --syslog Log filtered interactive commands to syslog. Filtering of + commands depends on the patterns supplied via histignore + option besides the default patterns. + --plugin-dir=name Directory for client-side plugins. + --default-auth=name Default authentication client-side plugin to use. + --binary-mode By default, ASCII '\0' is disallowed and '\r\n' is + translated to '\n'. This switch turns off both features, + and also turns off parsing of all clientcommands except + \C and DELIMITER, in non-interactive mode (for input + piped to mysql or loaded using the 'source' command). + This is necessary when processing output from mysqlbinlog + that may contain blobs. + --connect-expired-password + Notify the server that this client is prepared to handle + expired password sandbox mode. + --compression-algorithms=name + Use compression algorithm in server/client protocol. + Valid values are any combination of + 'zstd','zlib','uncompressed'. + --zstd-compression-level=# + Use this compression level in the client/server protocol, + in case --compression-algorithms=zstd. Valid range is + between 1 and 22, inclusive. Default is 3. + --load-data-local-dir=name + Directory path safe for LOAD DATA LOCAL INFILE to read + from. + --authentication-oci-client-config-profile=name + Specifies the configuration profile whose configuration + options are to be read from the OCI configuration file. + Default is DEFAULT. + --oci-config-file=name + Specifies the location of the OCI configuration file. + Default for Linux is ~/.oci/config and %HOME/.oci/config + on Windows. + --authentication-openid-connect-client-id-token-file=name + Specifies the location of the ID token file. + --telemetry-client Load the telemetry_client plugin. + --plugin-authentication-webauthn-client-preserve-privacy + Allows selection of discoverable credential to be used + for signing challenge. default is false - implies + challenge is signed by all credentials for given relying + party. + --plugin-authentication-webauthn-device=# + Specifies what libfido2 device to use. 0 (the first + device) is the default. + --register-factor=name + Specifies factor for which registration needs to be done + for. + --system-command Enable or disable (by default) the 'system' mysql + command. + +Default options are read from the following files in the given order: +/etc/my.cnf /etc/mysql/my.cnf /opt/homebrew/etc/my.cnf ~/.my.cnf +The following groups are read: mysql client +The following options may be given as the first argument: +--print-defaults Print the program argument list and exit. +--no-defaults Don't read default options from any option file, + except for login file. +--defaults-file=# Only read default options from the given file #. +--defaults-extra-file=# Read this file after the global files are read. +--defaults-group-suffix=# + Also read groups with concat(group, suffix) +--login-path=# Read this path from the login file. +--no-login-paths Don't read login paths from the login path file. + +Variables (--variable-name=value) +and boolean options {FALSE|TRUE} Value (after reading options) +------------------------------------------------------ ------------------- +auto-rehash TRUE +auto-vertical-output FALSE +bind-address (No default value) +binary-as-hex FALSE +character-sets-dir (No default value) +column-type-info FALSE +commands FALSE +comments TRUE +compress FALSE +database (No default value) +default-character-set auto +delimiter ; +enable-cleartext-plugin FALSE +vertical FALSE +force FALSE +histignore (No default value) +named-commands FALSE +ignore-spaces TRUE +local-infile FALSE +no-beep FALSE +host (No default value) +dns-srv-name (No default value) +html FALSE +xml FALSE +line-numbers TRUE +unbuffered FALSE +column-names TRUE +sigint-ignore FALSE +port 0 +prompt mysql> +quick FALSE +raw FALSE +reconnect FALSE +socket (No default value) +server-public-key-path (No default value) +get-server-public-key FALSE +ssl-ca (No default value) +ssl-capath (No default value) +ssl-cert (No default value) +ssl-cipher (No default value) +ssl-key (No default value) +ssl-crl (No default value) +ssl-crlpath (No default value) +tls-version (No default value) +tls-ciphersuites (No default value) +ssl-session-data (No default value) +ssl-session-data-continue-on-failed-reuse FALSE +tls-sni-servername (No default value) +table FALSE +user (No default value) +safe-updates FALSE +i-am-a-dummy FALSE +wait FALSE +connect-timeout 0 +max-allowed-packet 16777216 +net-buffer-length 16384 +select-limit 1000 +max-join-size 1000000 +show-warnings FALSE +plugin-dir (No default value) +default-auth (No default value) +binary-mode FALSE +connect-expired-password FALSE +compression-algorithms (No default value) +zstd-compression-level 3 +load-data-local-dir (No default value) +authentication-oci-client-config-profile (No default value) +oci-config-file (No default value) +authentication-openid-connect-client-id-token-file (No default value) +telemetry-client FALSE +plugin-authentication-webauthn-client-preserve-privacy FALSE +plugin-authentication-webauthn-device 0 +register-factor (No default value) +system-command FALSE diff --git a/server/use b/server/use new file mode 100644 index 0000000..e69de29