From 2c3a8a5fb66ab1da3f4e737af06860e0181262b1 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Sun, 10 May 2026 10:35:16 +0900 Subject: [PATCH 01/19] =?UTF-8?q?chore:=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 현재 필요한 의존성 추가 validation, security, amqp, flyway, openapi, s3, jwt, archunit, testcontainer 추가 --- backend/build.gradle | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/backend/build.gradle b/backend/build.gradle index 79807023..7b7a9eac 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -28,6 +28,34 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' runtimeOnly 'org.postgresql:postgresql' + + // Request DTO validation + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // API authentication and authorization + implementation 'org.springframework.boot:spring-boot-starter-security' + + // RabbitMQ messaging + implementation 'org.springframework.boot:spring-boot-starter-amqp' + + // Health checks and operational endpoints + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // Database migration + implementation 'org.springframework.boot:spring-boot-flyway' + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-database-postgresql' + + // OpenAPI documentation + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3' + + // S3 and MinIO object storage + implementation platform('software.amazon.awssdk:bom:2.37.3') + implementation 'software.amazon.awssdk:s3' + + // JWT creation and validation + implementation 'com.nimbusds:nimbus-jose-jwt:10.8' + // lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' @@ -42,9 +70,19 @@ dependencies { // test testImplementation 'org.springframework.boot:spring-boot-starter-test' + + // Architecture rule tests + testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' + + // Integration test containers + testImplementation platform('org.testcontainers:testcontainers-bom:1.21.3') + testImplementation 'org.testcontainers:postgresql' + testImplementation 'org.testcontainers:rabbitmq' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } tasks.named('test') { useJUnitPlatform() + systemProperty 'spring.profiles.active', 'test' } From 3a8db13b1fabf554141052c797c7420999578e48 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Sun, 10 May 2026 10:43:00 +0900 Subject: [PATCH 02/19] =?UTF-8?q?chore:=20application=20yml=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=8C=8C=EC=9D=BC=20=EC=84=A4=EC=A0=95=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit yml 설정값 common/properties로 관리 --- .../stackup/stackup/StackupApplication.java | 2 + .../config/properties/CorsProperties.java | 13 +++++ .../properties/GithubOAuthProperties.java | 17 ++++++ .../config/properties/S3Properties.java | 20 +++++++ .../config/properties/SecurityProperties.java | 16 ++++++ .../config/properties/SseProperties.java | 27 +++++++++ .../src/main/resources/application-local.yml | 18 ++++++ .../src/main/resources/application-test.yml | 17 ++++++ .../src/main/resources/application.properties | 1 - backend/src/main/resources/application.yml | 57 +++++++++++++++++++ 10 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/com/stackup/stackup/common/config/properties/CorsProperties.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/config/properties/GithubOAuthProperties.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/config/properties/S3Properties.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/config/properties/SecurityProperties.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/config/properties/SseProperties.java create mode 100644 backend/src/main/resources/application-local.yml create mode 100644 backend/src/main/resources/application-test.yml delete mode 100644 backend/src/main/resources/application.properties create mode 100644 backend/src/main/resources/application.yml diff --git a/backend/src/main/java/com/stackup/stackup/StackupApplication.java b/backend/src/main/java/com/stackup/stackup/StackupApplication.java index 086eb908..e40cee24 100644 --- a/backend/src/main/java/com/stackup/stackup/StackupApplication.java +++ b/backend/src/main/java/com/stackup/stackup/StackupApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; @SpringBootApplication +@ConfigurationPropertiesScan public class StackupApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/com/stackup/stackup/common/config/properties/CorsProperties.java b/backend/src/main/java/com/stackup/stackup/common/config/properties/CorsProperties.java new file mode 100644 index 00000000..ebe33ae1 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/config/properties/CorsProperties.java @@ -0,0 +1,13 @@ +package com.stackup.stackup.common.config.properties; + +import jakarta.validation.constraints.NotEmpty; +import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Validated +@ConfigurationProperties(prefix = "app.cors") +public record CorsProperties( + @NotEmpty List allowedOrigins +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/common/config/properties/GithubOAuthProperties.java b/backend/src/main/java/com/stackup/stackup/common/config/properties/GithubOAuthProperties.java new file mode 100644 index 00000000..2f13e3c7 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/config/properties/GithubOAuthProperties.java @@ -0,0 +1,17 @@ +package com.stackup.stackup.common.config.properties; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.net.URI; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Validated +@ConfigurationProperties(prefix = "app.github") +public record GithubOAuthProperties( + @NotBlank String clientId, + @NotBlank String clientSecret, + @NotNull + URI redirectUri +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/common/config/properties/S3Properties.java b/backend/src/main/java/com/stackup/stackup/common/config/properties/S3Properties.java new file mode 100644 index 00000000..04ede814 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/config/properties/S3Properties.java @@ -0,0 +1,20 @@ +package com.stackup.stackup.common.config.properties; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.net.URI; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Validated +@ConfigurationProperties(prefix = "app.s3") +public record S3Properties( + @NotNull + URI endpoint, + @NotBlank String accessKey, + @NotBlank String secretKey, + @NotBlank String bucket, + @NotBlank String region, + boolean pathStyle +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/common/config/properties/SecurityProperties.java b/backend/src/main/java/com/stackup/stackup/common/config/properties/SecurityProperties.java new file mode 100644 index 00000000..c88be9b1 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/config/properties/SecurityProperties.java @@ -0,0 +1,16 @@ +package com.stackup.stackup.common.config.properties; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Validated +@ConfigurationProperties(prefix = "app.security") +public record SecurityProperties( + @NotBlank String jwtSecret, + @NotBlank String encryptionKey, + @Positive long accessTokenTtlSeconds, + @Positive long refreshTokenTtlSeconds +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/common/config/properties/SseProperties.java b/backend/src/main/java/com/stackup/stackup/common/config/properties/SseProperties.java new file mode 100644 index 00000000..9d779ea2 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/config/properties/SseProperties.java @@ -0,0 +1,27 @@ +package com.stackup.stackup.common.config.properties; + +import jakarta.validation.constraints.NotNull; +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Validated +@ConfigurationProperties(prefix = "app.sse") +public record SseProperties( + @NotNull Duration timeout, + @NotNull Duration keepAliveInterval, + @NotNull Duration streamTokenTtl +) +{ + public SseProperties { + validatePositive("timeout", timeout); + validatePositive("keepAliveInterval", keepAliveInterval); + validatePositive("streamTokenTtl", streamTokenTtl); + } + + private static void validatePositive(String name, Duration value) { + if (value != null && !value.isPositive()) { + throw new IllegalArgumentException(name + " must be positive"); + } + } +} diff --git a/backend/src/main/resources/application-local.yml b/backend/src/main/resources/application-local.yml new file mode 100644 index 00000000..e7d1c4d7 --- /dev/null +++ b/backend/src/main/resources/application-local.yml @@ -0,0 +1,18 @@ +spring: + datasource: + url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:stackup} + username: ${POSTGRES_USER:stackup} + password: ${POSTGRES_PASSWORD:stackup} + rabbitmq: + host: ${RABBITMQ_HOST:localhost} + port: ${RABBITMQ_PORT:5672} + username: ${RABBITMQ_USER:stackup} + password: ${RABBITMQ_PASSWORD:stackup} + +app: + security: + jwt-secret: ${JWT_SECRET:local-development-jwt-secret-must-be-replaced} + encryption-key: ${ENCRYPTION_KEY:local-development-encryption-key-must-be-replaced} + github: + client-id: ${GITHUB_OAUTH_CLIENT_ID:local-github-client-id} + client-secret: ${GITHUB_OAUTH_CLIENT_SECRET:local-github-client-secret} diff --git a/backend/src/main/resources/application-test.yml b/backend/src/main/resources/application-test.yml new file mode 100644 index 00000000..19e71fb4 --- /dev/null +++ b/backend/src/main/resources/application-test.yml @@ -0,0 +1,17 @@ +spring: + autoconfigure: + exclude: + - org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration + - org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration + - org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration + jpa: + hibernate: + ddl-auto: validate + +app: + security: + jwt-secret: test-jwt-secret + encryption-key: test-encryption-key + github: + client-id: test-github-client-id + client-secret: test-github-client-secret diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties deleted file mode 100644 index 356f9477..00000000 --- a/backend/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=stackup diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 00000000..0504c898 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,57 @@ +spring: + application: + name: stackup + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + format_sql: true + flyway: + enabled: true + locations: classpath:db/migration + servlet: + multipart: + max-file-size: 20MB + max-request-size: 20MB + +management: + endpoints: + web: + exposure: + include: health,info + endpoint: + health: + probes: + enabled: true + +springdoc: + api-docs: + path: /api/v3/api-docs + swagger-ui: + path: /api/swagger-ui.html + +app: + security: + jwt-secret: ${JWT_SECRET:} + access-token-ttl-seconds: ${JWT_ACCESS_TTL_SECONDS:900} + refresh-token-ttl-seconds: ${JWT_REFRESH_TTL_SECONDS:1209600} + encryption-key: ${ENCRYPTION_KEY:} + github: + client-id: ${GITHUB_OAUTH_CLIENT_ID:} + client-secret: ${GITHUB_OAUTH_CLIENT_SECRET:} + redirect-uri: ${GITHUB_OAUTH_REDIRECT_URI:http://localhost:5173/auth/callback} + s3: + endpoint: ${S3_ENDPOINT:http://localhost:9000} + access-key: ${S3_ACCESS_KEY:minioadmin} + secret-key: ${S3_SECRET_KEY:minioadmin} + bucket: ${S3_BUCKET:stackup} + region: ${S3_REGION:us-east-1} + path-style: ${S3_PATH_STYLE:true} + sse: + timeout: 30m + keep-alive-interval: 30s + stream-token-ttl: 60s + cors: + allowed-origins: + - http://localhost:5173 From 3acdb09c51e42edc59121e8db8fa9d1e4745a0ef Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Sun, 10 May 2026 11:00:44 +0900 Subject: [PATCH 03/19] =?UTF-8?q?chore:=20Flyway=20V1=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs의 database.md를 통해 flyway V1 추가 핵심 테이블에 대해서 인덱스 반영 --- .../db/migration/V1__init_schema.sql | 229 ++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V1__init_schema.sql diff --git a/backend/src/main/resources/db/migration/V1__init_schema.sql b/backend/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 00000000..14719781 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1,229 @@ +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + github_id BIGINT NOT NULL UNIQUE, + github_username VARCHAR(100) NOT NULL, + email VARCHAR(255), + avatar_url VARCHAR(500), + encrypted_github_access_token VARCHAR(1000) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_deleted BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE INDEX idx_users_active ON users (id) WHERE is_deleted = FALSE; + +CREATE TABLE refresh_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users (id), + token_hash VARCHAR(500) NOT NULL UNIQUE, + device_info VARCHAR(500), + expires_at TIMESTAMPTZ NOT NULL, + is_revoked BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens (user_id); +CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens (expires_at); + +CREATE TABLE user_consents ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users (id), + consent_type VARCHAR(50) NOT NULL, + consent_version VARCHAR(20) NOT NULL, + is_agreed BOOLEAN NOT NULL DEFAULT TRUE, + agreed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_at TIMESTAMPTZ, + ip_address VARCHAR(45), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_user_consents_consent_type + CHECK (consent_type IN ('TOS', 'PRIVACY', 'MARKETING')) +); + +CREATE INDEX idx_user_consents_user_id ON user_consents (user_id); +CREATE INDEX idx_user_consents_user_type ON user_consents (user_id, consent_type); + +CREATE TABLE repositories ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users (id), + github_repo_id BIGINT NOT NULL, + repo_name VARCHAR(255) NOT NULL, + repo_full_name VARCHAR(500) NOT NULL, + repo_url VARCHAR(500) NOT NULL, + default_branch VARCHAR(100) DEFAULT 'main', + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + last_synced_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT uq_repositories_user_github_repo UNIQUE (user_id, github_repo_id), + CONSTRAINT chk_repositories_status + CHECK (status IN ('PENDING', 'ANALYZING', 'ANALYZED', 'FAILED')) +); + +CREATE INDEX idx_repositories_active ON repositories (user_id) WHERE is_deleted = FALSE; +CREATE INDEX idx_repositories_status ON repositories (status); + +CREATE TABLE resumes ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users (id), + original_filename VARCHAR(500) NOT NULL, + file_path VARCHAR(1000) NOT NULL, + file_type VARCHAR(20) NOT NULL, + file_size BIGINT, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT chk_resumes_file_type CHECK (file_type IN ('PDF')), + CONSTRAINT chk_resumes_status + CHECK (status IN ('PENDING', 'ANALYZING', 'ANALYZED', 'FAILED')) +); + +CREATE INDEX idx_resumes_active ON resumes (user_id) WHERE is_deleted = FALSE; +CREATE INDEX idx_resumes_status ON resumes (status); + +CREATE TABLE analyzed_documents ( + id BIGSERIAL PRIMARY KEY, + resume_id BIGINT REFERENCES resumes (id), + repository_id BIGINT REFERENCES repositories (id), + document_path VARCHAR(1000) NOT NULL, + summary VARCHAR(2000), + tech_stack JSONB, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT chk_analyzed_documents_status CHECK (status IN ('ACTIVE', 'ARCHIVED')), + CONSTRAINT chk_analyzed_documents_single_source CHECK ( + (resume_id IS NOT NULL AND repository_id IS NULL) + OR + (resume_id IS NULL AND repository_id IS NOT NULL) + ) +); + +CREATE INDEX idx_analyzed_documents_resume_id ON analyzed_documents (resume_id); +CREATE INDEX idx_analyzed_documents_repository_id ON analyzed_documents (repository_id); +CREATE INDEX idx_analyzed_documents_active ON analyzed_documents (id) WHERE is_deleted = FALSE; + +CREATE TABLE interview_sessions ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users (id), + title VARCHAR(200), + memo TEXT, + mode VARCHAR(20) NOT NULL, + interview_type VARCHAR(30) NOT NULL, + job_category VARCHAR(30) NOT NULL, + max_questions INT NOT NULL DEFAULT 10, + max_duration_minutes INT NOT NULL DEFAULT 60, + status VARCHAR(20) NOT NULL DEFAULT 'READY', + total_question_count INT DEFAULT 0, + started_at TIMESTAMPTZ, + ended_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT chk_interview_sessions_mode CHECK (mode IN ('ONLINE', 'OFFLINE')), + CONSTRAINT chk_interview_sessions_interview_type + CHECK (interview_type IN ('PERSONALITY', 'TECHNICAL', 'LIVE_CODING', 'INTEGRATED')), + CONSTRAINT chk_interview_sessions_job_category + CHECK (job_category IN ('FRONTEND', 'BACKEND', 'INFRA', 'DBA')), + CONSTRAINT chk_interview_sessions_status + CHECK (status IN ('READY', 'IN_PROGRESS', 'INTERRUPTED', 'COMPLETED', 'CANCELLED')) +); + +CREATE INDEX idx_interview_sessions_active + ON interview_sessions (user_id, created_at DESC) WHERE is_deleted = FALSE; +CREATE INDEX idx_interview_sessions_user_status ON interview_sessions (user_id, status); + +CREATE TABLE session_contexts ( + id BIGSERIAL PRIMARY KEY, + session_id BIGINT NOT NULL REFERENCES interview_sessions (id), + document_id BIGINT NOT NULL REFERENCES analyzed_documents (id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_session_contexts_session_document UNIQUE (session_id, document_id) +); + +CREATE INDEX idx_session_contexts_document_id ON session_contexts (document_id); + +CREATE TABLE interview_messages ( + id BIGSERIAL PRIMARY KEY, + session_id BIGINT NOT NULL REFERENCES interview_sessions (id), + sequence_number INT NOT NULL, + role VARCHAR(20) NOT NULL, + content TEXT, + audio_file_path VARCHAR(1000), + parent_message_id BIGINT REFERENCES interview_messages (id), + status VARCHAR(20) NOT NULL DEFAULT 'CREATED', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_interview_messages_session_sequence UNIQUE (session_id, sequence_number), + CONSTRAINT chk_interview_messages_role + CHECK (role IN ('INTERVIEWER', 'INTERVIEWEE', 'SYSTEM')), + CONSTRAINT chk_interview_messages_status + CHECK (status IN ('CREATED', 'COMPLETED', 'FAILED')), + CONSTRAINT chk_interview_messages_content_or_audio + CHECK (content IS NOT NULL OR audio_file_path IS NOT NULL) +); + +CREATE INDEX idx_interview_messages_parent_message_id ON interview_messages (parent_message_id); + +CREATE TABLE message_voice_analyses ( + id BIGSERIAL PRIMARY KEY, + message_id BIGINT NOT NULL UNIQUE REFERENCES interview_messages (id), + speaking_rate_wpm DOUBLE PRECISION, + silence_duration_sec DOUBLE PRECISION, + filler_word_counts JSONB, + pronunciation_accuracy DOUBLE PRECISION, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE session_feedbacks ( + id BIGSERIAL PRIMARY KEY, + session_id BIGINT NOT NULL UNIQUE REFERENCES interview_sessions (id), + overall_score DOUBLE PRECISION, + technical_accuracy DOUBLE PRECISION, + logic_score DOUBLE PRECISION, + communication_score DOUBLE PRECISION, + strengths_summary TEXT, + weaknesses_summary TEXT, + improvement_keywords JSONB, + report_file_path VARCHAR(1000), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_deleted BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE activity_logs ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT REFERENCES users (id), + action VARCHAR(50) NOT NULL, + resource_type VARCHAR(30), + resource_id BIGINT, + detail JSONB, + ip_address VARCHAR(45), + user_agent VARCHAR(500), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_activity_logs_user_created_at ON activity_logs (user_id, created_at DESC); +CREATE INDEX idx_activity_logs_action_created_at ON activity_logs (action, created_at DESC); +CREATE INDEX idx_activity_logs_created_at ON activity_logs (created_at DESC); + +CREATE TABLE ai_request_logs ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT REFERENCES users (id), + session_id BIGINT REFERENCES interview_sessions (id), + request_type VARCHAR(50) NOT NULL, + model_name VARCHAR(100), + input_tokens INT, + output_tokens INT, + latency_ms INT, + status VARCHAR(20) NOT NULL, + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_ai_request_logs_status CHECK (status IN ('SUCCESS', 'FAILED', 'TIMEOUT')) +); + +CREATE INDEX idx_ai_request_logs_user_created_at ON ai_request_logs (user_id, created_at DESC); +CREATE INDEX idx_ai_request_logs_session_id ON ai_request_logs (session_id); +CREATE INDEX idx_ai_request_logs_request_type_created_at ON ai_request_logs (request_type, created_at DESC); +CREATE INDEX idx_ai_request_logs_created_at ON ai_request_logs (created_at DESC); From 304f74a83f3f3c479fbe0b6e8384bc4652c56f06 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Sun, 10 May 2026 11:41:03 +0900 Subject: [PATCH 04/19] =?UTF-8?q?refactor:=20common=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=8B=9C=EA=B0=84=20=ED=83=80=EC=9E=85=20Instant?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stackup/common/entity/BaseTimeAndUpdateEntity.java | 4 ++-- .../com/stackup/stackup/common/entity/BaseTimeEntity.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/com/stackup/stackup/common/entity/BaseTimeAndUpdateEntity.java b/backend/src/main/java/com/stackup/stackup/common/entity/BaseTimeAndUpdateEntity.java index fbd0a6f1..c3d95109 100644 --- a/backend/src/main/java/com/stackup/stackup/common/entity/BaseTimeAndUpdateEntity.java +++ b/backend/src/main/java/com/stackup/stackup/common/entity/BaseTimeAndUpdateEntity.java @@ -5,7 +5,7 @@ import lombok.Getter; import org.hibernate.annotations.UpdateTimestamp; -import java.time.LocalDateTime; +import java.time.Instant; @Getter @MappedSuperclass @@ -13,5 +13,5 @@ public abstract class BaseTimeAndUpdateEntity extends BaseTimeEntity { @UpdateTimestamp @Column(name = "updated_at", nullable = false) - protected LocalDateTime updatedAt; + protected Instant updatedAt; } diff --git a/backend/src/main/java/com/stackup/stackup/common/entity/BaseTimeEntity.java b/backend/src/main/java/com/stackup/stackup/common/entity/BaseTimeEntity.java index 4c8af302..c009c7e0 100644 --- a/backend/src/main/java/com/stackup/stackup/common/entity/BaseTimeEntity.java +++ b/backend/src/main/java/com/stackup/stackup/common/entity/BaseTimeEntity.java @@ -5,7 +5,7 @@ import lombok.Getter; import org.hibernate.annotations.CreationTimestamp; -import java.time.LocalDateTime; +import java.time.Instant; @Getter @MappedSuperclass @@ -13,5 +13,5 @@ public abstract class BaseTimeEntity { @CreationTimestamp @Column(name = "created_at", nullable = false, updatable = false) - protected LocalDateTime createdAt; + protected Instant createdAt; } From 28beb355f35b361a9cbb36f44f6a6249929e2af1 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Sun, 10 May 2026 11:45:35 +0900 Subject: [PATCH 05/19] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EB=B0=8F=20=EC=9D=B8=EC=A6=9D=20=EC=8A=A4=ED=82=A4=EB=A7=88?= =?UTF-8?q?=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit user token을 encryptedToken으로 변경 consentType을 enum으로 정렬 시간 필드 Instant로 변경 --- .../com/stackup/stackup/auth/domain/RefreshToken.java | 4 ++-- .../java/com/stackup/stackup/user/domain/User.java | 4 ++-- .../stackup/user/domain/consent/ConsentType.java | 7 +++++++ .../stackup/user/domain/consent/UserConsent.java | 11 +++++++---- 4 files changed, 18 insertions(+), 8 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/user/domain/consent/ConsentType.java diff --git a/backend/src/main/java/com/stackup/stackup/auth/domain/RefreshToken.java b/backend/src/main/java/com/stackup/stackup/auth/domain/RefreshToken.java index 6314c084..e305c16b 100644 --- a/backend/src/main/java/com/stackup/stackup/auth/domain/RefreshToken.java +++ b/backend/src/main/java/com/stackup/stackup/auth/domain/RefreshToken.java @@ -16,7 +16,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; -import java.time.LocalDateTime; +import java.time.Instant; @Getter @Entity @@ -46,7 +46,7 @@ public class RefreshToken extends BaseTimeEntity { private String deviceInfo; @Column(name = "expires_at", nullable = false) - private LocalDateTime expiresAt; + private Instant expiresAt; @Column(name = "is_revoked", nullable = false) private boolean revoked = false; diff --git a/backend/src/main/java/com/stackup/stackup/user/domain/User.java b/backend/src/main/java/com/stackup/stackup/user/domain/User.java index f4c922e2..88eaf6d3 100644 --- a/backend/src/main/java/com/stackup/stackup/user/domain/User.java +++ b/backend/src/main/java/com/stackup/stackup/user/domain/User.java @@ -39,6 +39,6 @@ public class User extends BaseSoftDeleteEntity { @Column(name = "avatar_url", length = 500) private String avatarUrl; - @Column(name = "github_access_token", nullable = false, length = 500) - private String githubAccessToken; + @Column(name = "encrypted_github_access_token", nullable = false, length = 1000) + private String encryptedGithubAccessToken; } diff --git a/backend/src/main/java/com/stackup/stackup/user/domain/consent/ConsentType.java b/backend/src/main/java/com/stackup/stackup/user/domain/consent/ConsentType.java new file mode 100644 index 00000000..d27ad388 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/user/domain/consent/ConsentType.java @@ -0,0 +1,7 @@ +package com.stackup.stackup.user.domain.consent; + +public enum ConsentType { + TOS, + PRIVACY, + MARKETING +} diff --git a/backend/src/main/java/com/stackup/stackup/user/domain/consent/UserConsent.java b/backend/src/main/java/com/stackup/stackup/user/domain/consent/UserConsent.java index 2cd85720..19036541 100644 --- a/backend/src/main/java/com/stackup/stackup/user/domain/consent/UserConsent.java +++ b/backend/src/main/java/com/stackup/stackup/user/domain/consent/UserConsent.java @@ -4,6 +4,8 @@ import com.stackup.stackup.user.domain.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -16,7 +18,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; -import java.time.LocalDateTime; +import java.time.Instant; @Getter @Entity @@ -39,7 +41,8 @@ public class UserConsent extends BaseTimeEntity { private User user; @Column(name = "consent_type", nullable = false, length = 50) - private String consentType; + @Enumerated(EnumType.STRING) + private ConsentType consentType; @Column(name = "consent_version", nullable = false, length = 20) private String consentVersion; @@ -48,10 +51,10 @@ public class UserConsent extends BaseTimeEntity { private boolean agreed = true; @Column(name = "agreed_at", nullable = false) - private LocalDateTime agreedAt; + private Instant agreedAt; @Column(name = "revoked_at") - private LocalDateTime revokedAt; + private Instant revokedAt; @Column(name = "ip_address", length = 45) private String ipAddress; From 305b593316daea3aeb7b7ff597a2bc0119ab317b Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Sun, 10 May 2026 11:47:26 +0900 Subject: [PATCH 06/19] =?UTF-8?q?refactor:=20Github=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20=EC=8A=A4=ED=82=A4=EB=A7=88=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit status enum으로 변경 시간 Instant로 변경 --- .../stackup/github/domain/GithubRepository.java | 13 ++++++++++--- .../stackup/github/domain/RepositoryStatus.java | 8 ++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/github/domain/RepositoryStatus.java diff --git a/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java b/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java index fdb0b180..0e1e0b61 100644 --- a/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java +++ b/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java @@ -4,6 +4,8 @@ import com.stackup.stackup.user.domain.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -12,16 +14,20 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; -import java.time.LocalDateTime; +import java.time.Instant; @Getter @Entity @Table( name = "repositories", + uniqueConstraints = { + @UniqueConstraint(name = "uq_repositories_user_github_repo", columnNames = {"user_id", "github_repo_id"}) + }, indexes = { @Index(name = "idx_repositories_user_id", columnList = "user_id") } @@ -53,8 +59,9 @@ public class GithubRepository extends BaseSoftDeleteEntity { private String defaultBranch = "main"; @Column(nullable = false, length = 20) - private String status = "PENDING"; + @Enumerated(EnumType.STRING) + private RepositoryStatus status = RepositoryStatus.PENDING; @Column(name = "last_synced_at") - private LocalDateTime lastSyncedAt; + private Instant lastSyncedAt; } diff --git a/backend/src/main/java/com/stackup/stackup/github/domain/RepositoryStatus.java b/backend/src/main/java/com/stackup/stackup/github/domain/RepositoryStatus.java new file mode 100644 index 00000000..d4f02b86 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/domain/RepositoryStatus.java @@ -0,0 +1,8 @@ +package com.stackup.stackup.github.domain; + +public enum RepositoryStatus { + PENDING, + ANALYZING, + ANALYZED, + FAILED +} From 5bff197df75a42c1dd0874aea2c0840da9543827 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Sun, 10 May 2026 11:50:16 +0900 Subject: [PATCH 07/19] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=A0=A5=EC=84=9C?= =?UTF-8?q?=20=EB=B0=8F=20=EB=B6=84=EC=84=9D=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=82=A4=EB=A7=88=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit status enum 변경 AnalyzedDocument source 부분 제거 후 FK관계로 정리 --- .../document/domain/AnalyzedDocument.java | 23 ++++++++++++++----- .../document/domain/DocumentStatus.java | 6 +++++ .../stackup/stackup/resume/domain/Resume.java | 8 +++++-- .../stackup/resume/domain/ResumeFileType.java | 5 ++++ .../stackup/resume/domain/ResumeStatus.java | 8 +++++++ 5 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/document/domain/DocumentStatus.java create mode 100644 backend/src/main/java/com/stackup/stackup/resume/domain/ResumeFileType.java create mode 100644 backend/src/main/java/com/stackup/stackup/resume/domain/ResumeStatus.java diff --git a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java index e57bf4ef..10cdbd11 100644 --- a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java +++ b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java @@ -1,12 +1,19 @@ package com.stackup.stackup.document.domain; import com.stackup.stackup.common.entity.BaseSoftDeleteEntity; +import com.stackup.stackup.github.domain.GithubRepository; +import com.stackup.stackup.resume.domain.Resume; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; @@ -17,7 +24,8 @@ @Table( name = "analyzed_documents", indexes = { - @Index(name = "idx_analyzed_docs_source", columnList = "source_type, source_id") + @Index(name = "idx_analyzed_documents_resume_id", columnList = "resume_id"), + @Index(name = "idx_analyzed_documents_repository_id", columnList = "repository_id") } ) @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -27,11 +35,13 @@ public class AnalyzedDocument extends BaseSoftDeleteEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(name = "source_type", nullable = false, length = 20) - private String sourceType; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "resume_id") + private Resume resume; - @Column(name = "source_id", nullable = false) - private Long sourceId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "repository_id") + private GithubRepository repository; @Column(name = "document_path", nullable = false, length = 1000) private String documentPath; @@ -43,5 +53,6 @@ public class AnalyzedDocument extends BaseSoftDeleteEntity { private String techStack; @Column(nullable = false, length = 20) - private String status = "ACTIVE"; + @Enumerated(EnumType.STRING) + private DocumentStatus status = DocumentStatus.ACTIVE; } diff --git a/backend/src/main/java/com/stackup/stackup/document/domain/DocumentStatus.java b/backend/src/main/java/com/stackup/stackup/document/domain/DocumentStatus.java new file mode 100644 index 00000000..57e21621 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/domain/DocumentStatus.java @@ -0,0 +1,6 @@ +package com.stackup.stackup.document.domain; + +public enum DocumentStatus { + ACTIVE, + ARCHIVED +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java b/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java index 2467c9c2..bd07ad0a 100644 --- a/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java +++ b/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java @@ -4,6 +4,8 @@ import com.stackup.stackup.user.domain.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -42,11 +44,13 @@ public class Resume extends BaseSoftDeleteEntity { private String filePath; @Column(name = "file_type", nullable = false, length = 20) - private String fileType; + @Enumerated(EnumType.STRING) + private ResumeFileType fileType; @Column(name = "file_size") private Long fileSize; @Column(nullable = false, length = 20) - private String status = "PENDING"; + @Enumerated(EnumType.STRING) + private ResumeStatus status = ResumeStatus.PENDING; } diff --git a/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeFileType.java b/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeFileType.java new file mode 100644 index 00000000..bc6fa787 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeFileType.java @@ -0,0 +1,5 @@ +package com.stackup.stackup.resume.domain; + +public enum ResumeFileType { + PDF +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeStatus.java b/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeStatus.java new file mode 100644 index 00000000..91c21240 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeStatus.java @@ -0,0 +1,8 @@ +package com.stackup.stackup.resume.domain; + +public enum ResumeStatus { + PENDING, + ANALYZING, + ANALYZED, + FAILED +} From a6cc8836457b66c67f0e6fc97c6a6603c67af4c4 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Sun, 10 May 2026 11:55:35 +0900 Subject: [PATCH 08/19] =?UTF-8?q?refactor:=20=EB=A9=B4=EC=A0=91=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=8A=A4=ED=82=A4=EB=A7=88=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit status 를 enum으로 변경 및 시간 Instant로 변경 --- .../session/domain/InterviewMessage.java | 12 +++++++++-- .../session/domain/InterviewSession.java | 20 ++++++++++++------- .../stackup/session/domain/InterviewType.java | 8 ++++++++ .../stackup/session/domain/JobCategory.java | 8 ++++++++ .../stackup/session/domain/MessageRole.java | 7 +++++++ .../stackup/session/domain/MessageStatus.java | 7 +++++++ .../session/domain/SessionContext.java | 2 +- .../stackup/session/domain/SessionMode.java | 6 ++++++ .../stackup/session/domain/SessionStatus.java | 9 +++++++++ 9 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/session/domain/InterviewType.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/domain/JobCategory.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/domain/MessageRole.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/domain/MessageStatus.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/domain/SessionMode.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/domain/SessionStatus.java diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java index 04ef86e5..c175add6 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java @@ -3,6 +3,8 @@ import com.stackup.stackup.common.entity.BaseTimeEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -11,6 +13,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -19,6 +22,9 @@ @Entity @Table( name = "interview_messages", + uniqueConstraints = { + @UniqueConstraint(name = "uq_interview_messages_session_sequence", columnNames = {"session_id", "sequence_number"}) + }, indexes = { @Index(name = "idx_messages_session", columnList = "session_id, sequence_number"), @Index(name = "idx_messages_parent", columnList = "parent_message_id") @@ -39,7 +45,8 @@ public class InterviewMessage extends BaseTimeEntity { private Integer sequenceNumber; @Column(nullable = false, length = 20) - private String role; + @Enumerated(EnumType.STRING) + private MessageRole role; @Column(columnDefinition = "text") private String content; @@ -52,5 +59,6 @@ public class InterviewMessage extends BaseTimeEntity { private InterviewMessage parentMessage; @Column(nullable = false, length = 20) - private String status = "CREATED"; + @Enumerated(EnumType.STRING) + private MessageStatus status = MessageStatus.CREATED; } diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java index 2e19b27f..6f2dfdac 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java @@ -4,6 +4,8 @@ import com.stackup.stackup.user.domain.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -16,7 +18,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; -import java.time.LocalDateTime; +import java.time.Instant; @Getter @Entity @@ -45,13 +47,16 @@ public class InterviewSession extends BaseSoftDeleteEntity { private String memo; @Column(nullable = false, length = 20) - private String mode; + @Enumerated(EnumType.STRING) + private SessionMode mode; @Column(name = "interview_type", nullable = false, length = 30) - private String interviewType; + @Enumerated(EnumType.STRING) + private InterviewType interviewType; @Column(name = "job_category", nullable = false, length = 30) - private String jobCategory; + @Enumerated(EnumType.STRING) + private JobCategory jobCategory; @Column(name = "max_questions", nullable = false) private Integer maxQuestions = 10; @@ -60,14 +65,15 @@ public class InterviewSession extends BaseSoftDeleteEntity { private Integer maxDurationMinutes = 60; @Column(nullable = false, length = 20) - private String status = "READY"; + @Enumerated(EnumType.STRING) + private SessionStatus status = SessionStatus.READY; @Column(name = "total_question_count") private Integer totalQuestionCount = 0; @Column(name = "started_at") - private LocalDateTime startedAt; + private Instant startedAt; @Column(name = "ended_at") - private LocalDateTime endedAt; + private Instant endedAt; } diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewType.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewType.java new file mode 100644 index 00000000..5f9eca33 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewType.java @@ -0,0 +1,8 @@ +package com.stackup.stackup.session.domain; + +public enum InterviewType { + PERSONALITY, + TECHNICAL, + LIVE_CODING, + INTEGRATED +} diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/JobCategory.java b/backend/src/main/java/com/stackup/stackup/session/domain/JobCategory.java new file mode 100644 index 00000000..a976cd34 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/domain/JobCategory.java @@ -0,0 +1,8 @@ +package com.stackup.stackup.session.domain; + +public enum JobCategory { + FRONTEND, + BACKEND, + INFRA, + DBA +} diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/MessageRole.java b/backend/src/main/java/com/stackup/stackup/session/domain/MessageRole.java new file mode 100644 index 00000000..86fc0cc1 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/domain/MessageRole.java @@ -0,0 +1,7 @@ +package com.stackup.stackup.session.domain; + +public enum MessageRole { + INTERVIEWER, + INTERVIEWEE, + SYSTEM +} diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/MessageStatus.java b/backend/src/main/java/com/stackup/stackup/session/domain/MessageStatus.java new file mode 100644 index 00000000..dced7832 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/domain/MessageStatus.java @@ -0,0 +1,7 @@ +package com.stackup.stackup.session.domain; + +public enum MessageStatus { + CREATED, + COMPLETED, + FAILED +} diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/SessionContext.java b/backend/src/main/java/com/stackup/stackup/session/domain/SessionContext.java index ad1e53f8..09e759b9 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/SessionContext.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/SessionContext.java @@ -24,7 +24,7 @@ @UniqueConstraint(name = "idx_session_contexts_unique", columnNames = {"session_id", "document_id"}) }, indexes = { - @Index(name = "idx_session_contexts_session", columnList = "session_id") + @Index(name = "idx_session_contexts_document_id", columnList = "document_id") } ) @NoArgsConstructor(access = AccessLevel.PROTECTED) diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/SessionMode.java b/backend/src/main/java/com/stackup/stackup/session/domain/SessionMode.java new file mode 100644 index 00000000..99bae07f --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/domain/SessionMode.java @@ -0,0 +1,6 @@ +package com.stackup.stackup.session.domain; + +public enum SessionMode { + ONLINE, + OFFLINE +} diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/SessionStatus.java b/backend/src/main/java/com/stackup/stackup/session/domain/SessionStatus.java new file mode 100644 index 00000000..69da2e8a --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/domain/SessionStatus.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.session.domain; + +public enum SessionStatus { + READY, + IN_PROGRESS, + INTERRUPTED, + COMPLETED, + CANCELLED +} From d51df16b596bd65a1fb02a7754304af774f2d058 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Sun, 10 May 2026 11:56:31 +0900 Subject: [PATCH 09/19] =?UTF-8?q?refactor:=20AI=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=8A=A4=ED=82=A4=EB=A7=88=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit status enum으로 변경 및 제약 조건 반영 --- .../com/stackup/stackup/log/ai/domain/AiRequestLog.java | 5 ++++- .../com/stackup/stackup/log/ai/domain/AiRequestStatus.java | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/com/stackup/stackup/log/ai/domain/AiRequestStatus.java diff --git a/backend/src/main/java/com/stackup/stackup/log/ai/domain/AiRequestLog.java b/backend/src/main/java/com/stackup/stackup/log/ai/domain/AiRequestLog.java index 59422d2b..31694cd1 100644 --- a/backend/src/main/java/com/stackup/stackup/log/ai/domain/AiRequestLog.java +++ b/backend/src/main/java/com/stackup/stackup/log/ai/domain/AiRequestLog.java @@ -5,6 +5,8 @@ import com.stackup.stackup.user.domain.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -59,7 +61,8 @@ public class AiRequestLog extends BaseTimeEntity { private Integer latencyMs; @Column(nullable = false, length = 20) - private String status; + @Enumerated(EnumType.STRING) + private AiRequestStatus status; @Column(name = "error_message", columnDefinition = "text") private String errorMessage; diff --git a/backend/src/main/java/com/stackup/stackup/log/ai/domain/AiRequestStatus.java b/backend/src/main/java/com/stackup/stackup/log/ai/domain/AiRequestStatus.java new file mode 100644 index 00000000..df350317 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/log/ai/domain/AiRequestStatus.java @@ -0,0 +1,7 @@ +package com.stackup.stackup.log.ai.domain; + +public enum AiRequestStatus { + SUCCESS, + FAILED, + TIMEOUT +} From 5ffaace49564197765d76baf69657a906acea128 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Sun, 10 May 2026 12:03:32 +0900 Subject: [PATCH 10/19] =?UTF-8?q?feat:=20=EA=B0=81=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=B3=84=20=EA=B8=B0=EB=B3=B8=20JPA=20respository?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/domain/RefreshTokenRepository.java | 11 +++++++++++ .../domain/AnalyzedDocumentRepository.java | 17 +++++++++++++++++ .../domain/GithubRepositoryRepository.java | 12 ++++++++++++ .../activity/domain/ActivityLogRepository.java | 9 +++++++++ .../log/ai/domain/AiRequestLogRepository.java | 9 +++++++++ .../stackup/resume/domain/ResumeRepository.java | 12 ++++++++++++ .../domain/InterviewMessageRepository.java | 9 +++++++++ .../domain/InterviewSessionRepository.java | 12 ++++++++++++ .../domain/MessageVoiceAnalysisRepository.java | 9 +++++++++ .../domain/SessionContextRepository.java | 9 +++++++++ .../domain/SessionFeedbackRepository.java | 9 +++++++++ .../stackup/user/domain/UserRepository.java | 11 +++++++++++ .../domain/consent/UserConsentRepository.java | 9 +++++++++ 13 files changed, 138 insertions(+) create mode 100644 backend/src/main/java/com/stackup/stackup/auth/domain/RefreshTokenRepository.java create mode 100644 backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepository.java create mode 100644 backend/src/main/java/com/stackup/stackup/github/domain/GithubRepositoryRepository.java create mode 100644 backend/src/main/java/com/stackup/stackup/log/activity/domain/ActivityLogRepository.java create mode 100644 backend/src/main/java/com/stackup/stackup/log/ai/domain/AiRequestLogRepository.java create mode 100644 backend/src/main/java/com/stackup/stackup/resume/domain/ResumeRepository.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/domain/MessageVoiceAnalysisRepository.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/domain/SessionContextRepository.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/domain/SessionFeedbackRepository.java create mode 100644 backend/src/main/java/com/stackup/stackup/user/domain/UserRepository.java create mode 100644 backend/src/main/java/com/stackup/stackup/user/domain/consent/UserConsentRepository.java diff --git a/backend/src/main/java/com/stackup/stackup/auth/domain/RefreshTokenRepository.java b/backend/src/main/java/com/stackup/stackup/auth/domain/RefreshTokenRepository.java new file mode 100644 index 00000000..afae25aa --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/domain/RefreshTokenRepository.java @@ -0,0 +1,11 @@ +package com.stackup.stackup.auth.domain; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RefreshTokenRepository extends JpaRepository { + + Optional findByTokenHash(String tokenHash); + + void deleteByUser_Id(Long userId); +} diff --git a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepository.java b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepository.java new file mode 100644 index 00000000..981382f6 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepository.java @@ -0,0 +1,17 @@ +package com.stackup.stackup.document.domain; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AnalyzedDocumentRepository extends JpaRepository { + + List findByResume_User_IdOrRepository_User_Id(Long resumeUserId, Long repositoryUserId); + + Optional findByIdAndResume_User_IdOrIdAndRepository_User_Id( + Long resumeDocumentId, + Long resumeUserId, + Long repositoryDocumentId, + Long repositoryUserId + ); +} diff --git a/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepositoryRepository.java b/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepositoryRepository.java new file mode 100644 index 00000000..8301dc61 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepositoryRepository.java @@ -0,0 +1,12 @@ +package com.stackup.stackup.github.domain; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface GithubRepositoryRepository extends JpaRepository { + + List findByUser_IdAndDeletedFalse(Long userId); + + Optional findByIdAndUser_IdAndDeletedFalse(Long id, Long userId); +} diff --git a/backend/src/main/java/com/stackup/stackup/log/activity/domain/ActivityLogRepository.java b/backend/src/main/java/com/stackup/stackup/log/activity/domain/ActivityLogRepository.java new file mode 100644 index 00000000..9bf5e177 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/log/activity/domain/ActivityLogRepository.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.log.activity.domain; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ActivityLogRepository extends JpaRepository { + + List findTop100ByUser_IdOrderByCreatedAtDesc(Long userId); +} diff --git a/backend/src/main/java/com/stackup/stackup/log/ai/domain/AiRequestLogRepository.java b/backend/src/main/java/com/stackup/stackup/log/ai/domain/AiRequestLogRepository.java new file mode 100644 index 00000000..5f34be59 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/log/ai/domain/AiRequestLogRepository.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.log.ai.domain; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AiRequestLogRepository extends JpaRepository { + + List findTop100ByUser_IdOrderByCreatedAtDesc(Long userId); +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeRepository.java b/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeRepository.java new file mode 100644 index 00000000..321a2d2e --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeRepository.java @@ -0,0 +1,12 @@ +package com.stackup.stackup.resume.domain; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ResumeRepository extends JpaRepository { + + List findByUser_IdAndDeletedFalse(Long userId); + + Optional findByIdAndUser_IdAndDeletedFalse(Long id, Long userId); +} diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java new file mode 100644 index 00000000..075483db --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.session.domain; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface InterviewMessageRepository extends JpaRepository { + + List findBySession_IdOrderBySequenceNumberAsc(Long sessionId); +} diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java new file mode 100644 index 00000000..b098f659 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java @@ -0,0 +1,12 @@ +package com.stackup.stackup.session.domain; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface InterviewSessionRepository extends JpaRepository { + + List findByUser_Id(Long userId); + + Optional findByIdAndUser_Id(Long id, Long userId); +} diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/MessageVoiceAnalysisRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/MessageVoiceAnalysisRepository.java new file mode 100644 index 00000000..09303ba0 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/domain/MessageVoiceAnalysisRepository.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.session.domain; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MessageVoiceAnalysisRepository extends JpaRepository { + + Optional findByMessage_Id(Long messageId); +} diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/SessionContextRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/SessionContextRepository.java new file mode 100644 index 00000000..6334bbec --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/domain/SessionContextRepository.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.session.domain; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SessionContextRepository extends JpaRepository { + + Optional findBySession_Id(Long sessionId); +} diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/SessionFeedbackRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/SessionFeedbackRepository.java new file mode 100644 index 00000000..b1f34d20 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/domain/SessionFeedbackRepository.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.session.domain; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SessionFeedbackRepository extends JpaRepository { + + Optional findBySession_Id(Long sessionId); +} diff --git a/backend/src/main/java/com/stackup/stackup/user/domain/UserRepository.java b/backend/src/main/java/com/stackup/stackup/user/domain/UserRepository.java new file mode 100644 index 00000000..98cd2e75 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/user/domain/UserRepository.java @@ -0,0 +1,11 @@ +package com.stackup.stackup.user.domain; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { + + Optional findByGithubId(Long githubId); + + Optional findByGithubUsername(String githubUsername); +} diff --git a/backend/src/main/java/com/stackup/stackup/user/domain/consent/UserConsentRepository.java b/backend/src/main/java/com/stackup/stackup/user/domain/consent/UserConsentRepository.java new file mode 100644 index 00000000..e3ea3908 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/user/domain/consent/UserConsentRepository.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.user.domain.consent; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserConsentRepository extends JpaRepository { + + List findByUser_Id(Long userId); +} From 5546acbca1e910ab1ef6c19b2cd12402ad22295c Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Sun, 10 May 2026 12:35:16 +0900 Subject: [PATCH 11/19] =?UTF-8?q?feat:=20=EA=B3=B5=ED=86=B5=20API=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=9D=91=EB=8B=B5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/exception/ApiErrorCode.java | 57 +++++++++ .../common/exception/DomainException.java | 54 +++++++++ .../exception/GlobalExceptionHandler.java | 108 ++++++++++++++++++ .../common/response/ApiErrorResponse.java | 13 +++ .../stackup/common/response/PageResponse.java | 27 +++++ 5 files changed, 259 insertions(+) create mode 100644 backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/exception/DomainException.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/exception/GlobalExceptionHandler.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/response/ApiErrorResponse.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/response/PageResponse.java diff --git a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java new file mode 100644 index 00000000..337fda27 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java @@ -0,0 +1,57 @@ +package com.stackup.stackup.common.exception; + +import org.springframework.http.HttpStatus; + +public enum ApiErrorCode { + AUTH_INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), + AUTH_EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."), + AUTH_REVOKED_TOKEN(HttpStatus.UNAUTHORIZED, "폐기된 토큰입니다."), + AUTH_GITHUB_OAUTH_FAILED(HttpStatus.UNAUTHORIZED, "GitHub OAuth 인증에 실패했습니다."), + AUTH_CONSENT_REQUIRED(HttpStatus.FORBIDDEN, "필수 동의가 필요합니다."), + + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."), + USER_ALREADY_DELETED(HttpStatus.GONE, "이미 탈퇴한 사용자입니다."), + + RESUME_INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "PDF 파일만 업로드할 수 있습니다."), + RESUME_FILE_TOO_LARGE(HttpStatus.BAD_REQUEST, "업로드 파일 크기가 너무 큽니다."), + RESUME_EMPTY_FILE(HttpStatus.BAD_REQUEST, "빈 파일은 업로드할 수 없습니다."), + RESUME_NOT_FOUND(HttpStatus.NOT_FOUND, "이력서를 찾을 수 없습니다."), + RESUME_IN_USE(HttpStatus.CONFLICT, "사용 중인 이력서입니다."), + + REPO_NOT_FOUND(HttpStatus.NOT_FOUND, "레포지토리를 찾을 수 없습니다."), + REPO_ALREADY_REGISTERED(HttpStatus.CONFLICT, "이미 등록된 레포지토리입니다."), + REPO_GITHUB_API_FAILED(HttpStatus.BAD_GATEWAY, "GitHub API 요청에 실패했습니다."), + REPO_PRIVATE_NO_ACCESS(HttpStatus.FORBIDDEN, "비공개 레포지토리에 접근할 수 없습니다."), + + DOC_NOT_ANALYZED(HttpStatus.UNPROCESSABLE_CONTENT, "아직 분석되지 않은 문서입니다."), + DOC_ANALYSIS_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "문서 분석에 실패했습니다."), + DOC_NOT_FOUND(HttpStatus.NOT_FOUND, "분석 문서를 찾을 수 없습니다."), + + SESSION_INVALID_STATE(HttpStatus.UNPROCESSABLE_CONTENT, "세션 상태가 올바르지 않습니다."), + SESSION_MAX_REACHED(HttpStatus.UNPROCESSABLE_CONTENT, "세션 제한에 도달했습니다."), + SESSION_NOT_FOUND(HttpStatus.NOT_FOUND, "세션을 찾을 수 없습니다."), + SESSION_FORBIDDEN(HttpStatus.FORBIDDEN, "세션에 접근할 수 없습니다."), + + VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "요청 값이 올바르지 않습니다."), + ACCESS_DENIED(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."), + + SYS_RATE_LIMITED(HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많습니다."), + SYS_DEPENDENCY_DOWN(HttpStatus.SERVICE_UNAVAILABLE, "필수 외부 의존성이 응답하지 않습니다."), + SYS_INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류가 발생했습니다."); + + private final HttpStatus status; + private final String defaultMessage; + + ApiErrorCode(HttpStatus status, String defaultMessage) { + this.status = status; + this.defaultMessage = defaultMessage; + } + + public HttpStatus getStatus() { + return status; + } + + public String getDefaultMessage() { + return defaultMessage; + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/exception/DomainException.java b/backend/src/main/java/com/stackup/stackup/common/exception/DomainException.java new file mode 100644 index 00000000..9c3bbd18 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/exception/DomainException.java @@ -0,0 +1,54 @@ +package com.stackup.stackup.common.exception; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public class DomainException extends RuntimeException { + + private final ApiErrorCode errorCode; + private final Map details; + + public DomainException(ApiErrorCode errorCode) { + this(errorCode, errorCode.getDefaultMessage(), Map.of()); + } + + public DomainException(ApiErrorCode errorCode, Map details) { + this(errorCode, errorCode.getDefaultMessage(), details); + } + + public DomainException(ApiErrorCode errorCode, String message) { + this(errorCode, message, Map.of()); + } + + public DomainException(ApiErrorCode errorCode, String message, Map details) { + super(message); + this.errorCode = errorCode; + this.details = copyDetails(details); + } + + public DomainException(ApiErrorCode errorCode, String message, Throwable cause) { + this(errorCode, message, cause, Map.of()); + } + + public DomainException(ApiErrorCode errorCode, String message, Throwable cause, Map details) { + super(message, cause); + this.errorCode = errorCode; + this.details = copyDetails(details); + } + + public ApiErrorCode getErrorCode() { + return errorCode; + } + + public Map getDetails() { + return details; + } + + private Map copyDetails(Map details) { + if (details == null || details.isEmpty()) { + return Map.of(); + } + return Collections.unmodifiableMap(new LinkedHashMap<>(details)); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/stackup/stackup/common/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..fd175202 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,108 @@ +package com.stackup.stackup.common.exception; + +import com.stackup.stackup.common.response.ApiErrorResponse; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.slf4j.MDC; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(DomainException.class) + public ResponseEntity handleDomainException(DomainException exception) { + ApiErrorCode errorCode = exception.getErrorCode(); + return buildResponse(errorCode, resolveMessage(exception, errorCode), exception.getDetails()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException exception) { + List> errors = exception.getBindingResult() + .getFieldErrors() + .stream() + .map(this::toFieldErrorDetail) + .toList(); + + return buildResponse(ApiErrorCode.VALIDATION_ERROR, ApiErrorCode.VALIDATION_ERROR.getDefaultMessage(), + Map.of("errors", errors)); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolation(ConstraintViolationException exception) { + List> errors = exception.getConstraintViolations() + .stream() + .map(this::toConstraintViolationDetail) + .toList(); + + return buildResponse(ApiErrorCode.VALIDATION_ERROR, ApiErrorCode.VALIDATION_ERROR.getDefaultMessage(), + Map.of("errors", errors)); + } + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity handleAuthentication(AuthenticationException exception) { + return buildResponse(ApiErrorCode.AUTH_INVALID_TOKEN, ApiErrorCode.AUTH_INVALID_TOKEN.getDefaultMessage(), + Map.of()); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDenied(AccessDeniedException exception) { + return buildResponse(ApiErrorCode.ACCESS_DENIED, ApiErrorCode.ACCESS_DENIED.getDefaultMessage(), Map.of()); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception exception) { + return buildResponse(ApiErrorCode.SYS_INTERNAL_ERROR, ApiErrorCode.SYS_INTERNAL_ERROR.getDefaultMessage(), + Map.of()); + } + + private ResponseEntity buildResponse( + ApiErrorCode errorCode, + String message, + Map details + ) { + ApiErrorResponse response = new ApiErrorResponse( + errorCode.name(), + message, + MDC.get("traceId"), + Instant.now(), + details == null ? Map.of() : details + ); + + return ResponseEntity.status(errorCode.getStatus()).body(response); + } + + private String resolveMessage(DomainException exception, ApiErrorCode errorCode) { + String message = exception.getMessage(); + if (message == null || message.isBlank()) { + return errorCode.getDefaultMessage(); + } + return message; + } + + private Map toFieldErrorDetail(FieldError error) { + Map detail = new LinkedHashMap<>(); + detail.put("field", error.getField()); + detail.put("message", Objects.toString(error.getDefaultMessage(), "")); + detail.put("rejectedValue", Objects.toString(error.getRejectedValue(), null)); + return detail; + } + + private Map toConstraintViolationDetail(ConstraintViolation violation) { + Map detail = new LinkedHashMap<>(); + detail.put("field", violation.getPropertyPath().toString()); + detail.put("message", violation.getMessage()); + detail.put("rejectedValue", Objects.toString(violation.getInvalidValue(), null)); + return detail; + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/response/ApiErrorResponse.java b/backend/src/main/java/com/stackup/stackup/common/response/ApiErrorResponse.java new file mode 100644 index 00000000..25f78536 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/response/ApiErrorResponse.java @@ -0,0 +1,13 @@ +package com.stackup.stackup.common.response; + +import java.time.Instant; +import java.util.Map; + +public record ApiErrorResponse( + String code, + String message, + String traceId, + Instant timestamp, + Map details +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/common/response/PageResponse.java b/backend/src/main/java/com/stackup/stackup/common/response/PageResponse.java new file mode 100644 index 00000000..28c31d93 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/response/PageResponse.java @@ -0,0 +1,27 @@ +package com.stackup.stackup.common.response; + +import java.util.List; +import org.springframework.data.domain.Page; + +public record PageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages, + boolean first, + boolean last +) { + + public static PageResponse from(Page page) { + return new PageResponse<>( + page.getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages(), + page.isFirst(), + page.isLast() + ); + } +} From 4d378a1180b2d0d982939bbbcb49897b4b66597d Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Sun, 10 May 2026 12:44:29 +0900 Subject: [PATCH 12/19] =?UTF-8?q?feat:=20=EC=9A=94=EC=B2=AD=20=EC=B6=94?= =?UTF-8?q?=EC=A0=81=20=EB=B0=8F=20=EB=A1=9C=EA=B7=B8=20=EB=A7=88=EC=8A=A4?= =?UTF-8?q?=ED=82=B9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTP 요청에 x-trace-id 부여 MDC에 저장하는 TraceFilter 추가 응답 헤더 및 어레 응답에 traceID를 포함해 ID로 추적가능 email 등등 로그에 남지 않도록 PilMasker 추가 --- .../exception/GlobalExceptionHandler.java | 4 +- .../stackup/stackup/common/log/PiiMasker.java | 105 ++++++++++++++++++ .../stackup/common/trace/TraceContext.java | 24 ++++ .../stackup/common/trace/TraceIdFilter.java | 39 +++++++ 4 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/common/log/PiiMasker.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/trace/TraceContext.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/trace/TraceIdFilter.java diff --git a/backend/src/main/java/com/stackup/stackup/common/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/stackup/stackup/common/exception/GlobalExceptionHandler.java index fd175202..ea9c4e63 100644 --- a/backend/src/main/java/com/stackup/stackup/common/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/stackup/stackup/common/exception/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package com.stackup.stackup.common.exception; import com.stackup.stackup.common.response.ApiErrorResponse; +import com.stackup.stackup.common.trace.TraceContext; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; import java.time.Instant; @@ -8,7 +9,6 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import org.slf4j.MDC; import org.springframework.http.ResponseEntity; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.AuthenticationException; @@ -74,7 +74,7 @@ private ResponseEntity buildResponse( ApiErrorResponse response = new ApiErrorResponse( errorCode.name(), message, - MDC.get("traceId"), + TraceContext.getTraceId(), Instant.now(), details == null ? Map.of() : details ); diff --git a/backend/src/main/java/com/stackup/stackup/common/log/PiiMasker.java b/backend/src/main/java/com/stackup/stackup/common/log/PiiMasker.java new file mode 100644 index 00000000..232771c6 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/log/PiiMasker.java @@ -0,0 +1,105 @@ +package com.stackup.stackup.common.log; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class PiiMasker { + + private static final Pattern EMAIL_PATTERN = Pattern.compile( + "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b" + ); + private static final Pattern JWT_PATTERN = Pattern.compile( + "\\b[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\b" + ); + private static final Pattern GITHUB_TOKEN_PATTERN = Pattern.compile( + "\\b(?:ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{20,}\\b" + ); + private static final Pattern PHONE_PATTERN = Pattern.compile( + "(? Date: Sun, 10 May 2026 13:01:24 +0900 Subject: [PATCH 13/19] =?UTF-8?q?test:=20=EC=95=84=ED=82=A4=ED=85=8D?= =?UTF-8?q?=EC=B2=98=20=EA=B7=9C=EC=B9=99=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 각 계층별 의존 방향, Entitiy 위치 등 아키텍처 규칙을 ArchUnit으로 검증 --- .../architecture/ArchitectureTest.java | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 backend/src/test/java/com/stackup/stackup/architecture/ArchitectureTest.java diff --git a/backend/src/test/java/com/stackup/stackup/architecture/ArchitectureTest.java b/backend/src/test/java/com/stackup/stackup/architecture/ArchitectureTest.java new file mode 100644 index 00000000..f71c6716 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/architecture/ArchitectureTest.java @@ -0,0 +1,201 @@ +package com.stackup.stackup.architecture; + +import com.tngtech.archunit.core.domain.Dependency; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import jakarta.persistence.Entity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +@AnalyzeClasses( + packages = "com.stackup.stackup", + importOptions = ImportOption.DoNotIncludeTests.class +) +class ArchitectureTest { + + private static final String BASE_PACKAGE = "com.stackup.stackup"; + private static final String COMMON_SLICE = "common"; + + @ArchTest + static final ArchRule domain_layer_does_not_depend_on_outer_layers = noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat().resideInAnyPackage( + "..application..", + "..presentation..", + "..infrastructure.." + ); + + @ArchTest + static final ArchRule application_layer_does_not_depend_on_presentation = noClasses() + .that().resideInAPackage("..application..") + .should().dependOnClassesThat().resideInAPackage("..presentation..") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule presentation_layer_does_not_use_jpa_repositories_directly = noClasses() + .that().resideInAPackage("..presentation..") + .should().dependOnClassesThat().areAssignableTo(JpaRepository.class) + .allowEmptyShould(true); + + @ArchTest + static final ArchRule entities_reside_in_domain_packages = classes() + .that().areAnnotatedWith(Entity.class) + .should().resideInAPackage("..domain.."); + + @ArchTest + static final ArchRule entities_do_not_have_public_setters = classes() + .that().areAnnotatedWith(Entity.class) + .should(notHavePublicSetters()); + + @ArchTest + static final ArchRule transactional_classes_are_only_in_application_layer = classes() + .that().areAnnotatedWith(Transactional.class) + .should().resideInAPackage("..application..") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule transactional_methods_are_only_in_application_layer = methods() + .that().areAnnotatedWith(Transactional.class) + .should().beDeclaredInClassesThat().resideInAPackage("..application..") + .allowEmptyShould(true); + + @ArchTest + static void top_level_domain_slices_are_free_of_cycles(JavaClasses importedClasses) { + Map> dependencies = collectTopLevelDependencies(importedClasses); + Optional> cycle = findCycle(dependencies); + + if (cycle.isPresent()) { + throw new AssertionError("Cycle between top-level domains found: " + + String.join(" -> ", cycle.get())); + } + } + + private static ArchCondition notHavePublicSetters() { + return new ArchCondition<>("not have public setters") { + @Override + public void check(JavaClass item, ConditionEvents events) { + item.getMethods().stream() + .filter(method -> method.getModifiers().stream() + .anyMatch(modifier -> modifier.name().equals("PUBLIC"))) + .filter(method -> method.getName().matches("set[A-Z].*")) + .forEach(method -> events.add(SimpleConditionEvent.violated( + method, + item.getName() + " has public setter " + method.getName() + ))); + } + }; + } + + private static Map> collectTopLevelDependencies(JavaClasses importedClasses) { + Map> dependencies = new LinkedHashMap<>(); + + for (JavaClass javaClass : importedClasses) { + topLevelSlice(javaClass).ifPresent(slice -> dependencies.putIfAbsent(slice, new LinkedHashSet<>())); + + for (Dependency dependency : javaClass.getDirectDependenciesFromSelf()) { + Optional origin = topLevelSlice(dependency.getOriginClass()); + Optional target = topLevelSlice(dependency.getTargetClass()); + + if (origin.isEmpty() || target.isEmpty() || origin.equals(target)) { + continue; + } + + dependencies.computeIfAbsent(origin.get(), ignored -> new LinkedHashSet<>()).add(target.get()); + } + } + + return dependencies; + } + + private static Optional topLevelSlice(JavaClass javaClass) { + String packageName = javaClass.getPackageName(); + if (!packageName.startsWith(BASE_PACKAGE + ".")) { + return Optional.empty(); + } + + String remainder = packageName.substring((BASE_PACKAGE + ".").length()); + String slice = remainder.split("\\.")[0]; + if (slice.isBlank() || COMMON_SLICE.equals(slice)) { + return Optional.empty(); + } + + return Optional.of(slice); + } + + private static Optional> findCycle(Map> dependencies) { + Set visiting = new HashSet<>(); + Set visited = new HashSet<>(); + ArrayDeque path = new ArrayDeque<>(); + + for (String slice : dependencies.keySet()) { + Optional> cycle = findCycle(slice, dependencies, visiting, visited, path); + if (cycle.isPresent()) { + return cycle; + } + } + + return Optional.empty(); + } + + private static Optional> findCycle( + String slice, + Map> dependencies, + Set visiting, + Set visited, + ArrayDeque path + ) { + if (visited.contains(slice)) { + return Optional.empty(); + } + + if (visiting.contains(slice)) { + return Optional.of(cyclePath(path, slice)); + } + + visiting.add(slice); + path.addLast(slice); + + for (String dependency : dependencies.getOrDefault(slice, Set.of())) { + Optional> cycle = findCycle(dependency, dependencies, visiting, visited, path); + if (cycle.isPresent()) { + return cycle; + } + } + + path.removeLast(); + visiting.remove(slice); + visited.add(slice); + return Optional.empty(); + } + + private static List cyclePath(ArrayDeque path, String repeatedSlice) { + List cycle = new ArrayList<>(path) + .stream() + .dropWhile(slice -> !slice.equals(repeatedSlice)) + .collect(Collectors.toCollection(ArrayList::new)); + cycle.add(repeatedSlice); + return cycle; + } +} From 56b73b969968ed3d1a106915743834a7f30c22b6 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Sun, 10 May 2026 13:25:53 +0900 Subject: [PATCH 14/19] =?UTF-8?q?feat:=20JWT=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B3=B4=EC=95=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JWT access token 생성/검증 Bearer token 인증 필터 추가 API 스켈레톤으로 구성 --- .../stackup/auth/application/AuthService.java | 66 ++++++++++ .../application/dto/GithubCallbackResult.java | 9 ++ .../application/dto/GithubLoginResult.java | 7 ++ .../application/dto/RefreshTokenResult.java | 7 ++ .../application/dto/StreamTokenResult.java | 6 + .../infrastructure/GithubOAuthClient.java | 25 ++++ .../auth/presentation/AuthController.java | 78 ++++++++++++ .../presentation/dto/GithubAuthRequest.java | 7 ++ .../dto/GithubCallbackResponse.java | 9 ++ .../presentation/dto/GithubLoginResponse.java | 7 ++ .../auth/presentation/dto/LogoutRequest.java | 6 + .../presentation/dto/RefreshTokenRequest.java | 6 + .../dto/RefreshTokenResponse.java | 7 ++ .../presentation/dto/StreamTokenResponse.java | 6 + .../security/JwtAuthenticationFilter.java | 60 ++++++++++ .../common/security/JwtTokenProvider.java | 101 ++++++++++++++++ .../common/security/SecurityConfig.java | 101 ++++++++++++++++ .../common/security/StreamTokenProvider.java | 113 ++++++++++++++++++ .../common/security/UserPrincipal.java | 52 ++++++++ 19 files changed, 673 insertions(+) create mode 100644 backend/src/main/java/com/stackup/stackup/auth/application/AuthService.java create mode 100644 backend/src/main/java/com/stackup/stackup/auth/application/dto/GithubCallbackResult.java create mode 100644 backend/src/main/java/com/stackup/stackup/auth/application/dto/GithubLoginResult.java create mode 100644 backend/src/main/java/com/stackup/stackup/auth/application/dto/RefreshTokenResult.java create mode 100644 backend/src/main/java/com/stackup/stackup/auth/application/dto/StreamTokenResult.java create mode 100644 backend/src/main/java/com/stackup/stackup/auth/infrastructure/GithubOAuthClient.java create mode 100644 backend/src/main/java/com/stackup/stackup/auth/presentation/AuthController.java create mode 100644 backend/src/main/java/com/stackup/stackup/auth/presentation/dto/GithubAuthRequest.java create mode 100644 backend/src/main/java/com/stackup/stackup/auth/presentation/dto/GithubCallbackResponse.java create mode 100644 backend/src/main/java/com/stackup/stackup/auth/presentation/dto/GithubLoginResponse.java create mode 100644 backend/src/main/java/com/stackup/stackup/auth/presentation/dto/LogoutRequest.java create mode 100644 backend/src/main/java/com/stackup/stackup/auth/presentation/dto/RefreshTokenRequest.java create mode 100644 backend/src/main/java/com/stackup/stackup/auth/presentation/dto/RefreshTokenResponse.java create mode 100644 backend/src/main/java/com/stackup/stackup/auth/presentation/dto/StreamTokenResponse.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/security/JwtAuthenticationFilter.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/security/JwtTokenProvider.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/security/SecurityConfig.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/security/StreamTokenProvider.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/security/UserPrincipal.java diff --git a/backend/src/main/java/com/stackup/stackup/auth/application/AuthService.java b/backend/src/main/java/com/stackup/stackup/auth/application/AuthService.java new file mode 100644 index 00000000..6a5d737f --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/application/AuthService.java @@ -0,0 +1,66 @@ +package com.stackup.stackup.auth.application; + +import com.stackup.stackup.auth.infrastructure.GithubOAuthClient; +import com.stackup.stackup.auth.application.dto.GithubCallbackResult; +import com.stackup.stackup.auth.application.dto.GithubLoginResult; +import com.stackup.stackup.auth.application.dto.RefreshTokenResult; +import com.stackup.stackup.auth.application.dto.StreamTokenResult; +import com.stackup.stackup.common.security.JwtTokenProvider; +import com.stackup.stackup.common.security.StreamTokenProvider; +import java.util.UUID; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.stereotype.Service; + +@Service +public class AuthService { + + private static final long SKELETON_USER_ID = 1L; + private static final String SKELETON_GITHUB_USERNAME = "stackup-user"; + + private final GithubOAuthClient githubOAuthClient; + private final JwtTokenProvider jwtTokenProvider; + private final StreamTokenProvider streamTokenProvider; + + public AuthService( + GithubOAuthClient githubOAuthClient, + JwtTokenProvider jwtTokenProvider, + StreamTokenProvider streamTokenProvider + ) { + this.githubOAuthClient = githubOAuthClient; + this.jwtTokenProvider = jwtTokenProvider; + this.streamTokenProvider = streamTokenProvider; + } + + public GithubLoginResult startGithubLogin() { + String state = UUID.randomUUID().toString(); + return new GithubLoginResult(githubOAuthClient.buildAuthorizationUrl(state), state); + } + + public GithubCallbackResult completeGithubLogin(String code, String state) { + return new GithubCallbackResult( + jwtTokenProvider.createAccessToken(SKELETON_USER_ID), + createRefreshTokenStub(), + SKELETON_USER_ID, + SKELETON_GITHUB_USERNAME + ); + } + + public RefreshTokenResult refresh(String refreshToken) { + return new RefreshTokenResult(jwtTokenProvider.createAccessToken(SKELETON_USER_ID), createRefreshTokenStub()); + } + + public void logout(String refreshToken) { + // Refresh token revocation is intentionally deferred to the auth implementation task. + } + + public StreamTokenResult createStreamToken(Long userId) { + if (userId == null) { + throw new BadCredentialsException("Authentication is required to create stream token"); + } + return new StreamTokenResult(streamTokenProvider.createStreamToken(userId)); + } + + private String createRefreshTokenStub() { + return "refresh-token-stub-" + UUID.randomUUID(); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/application/dto/GithubCallbackResult.java b/backend/src/main/java/com/stackup/stackup/auth/application/dto/GithubCallbackResult.java new file mode 100644 index 00000000..b8e13cf9 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/application/dto/GithubCallbackResult.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.auth.application.dto; + +public record GithubCallbackResult( + String accessToken, + String refreshToken, + long userId, + String githubUsername +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/application/dto/GithubLoginResult.java b/backend/src/main/java/com/stackup/stackup/auth/application/dto/GithubLoginResult.java new file mode 100644 index 00000000..edbd7b92 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/application/dto/GithubLoginResult.java @@ -0,0 +1,7 @@ +package com.stackup.stackup.auth.application.dto; + +public record GithubLoginResult( + String authorizationUrl, + String state +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/application/dto/RefreshTokenResult.java b/backend/src/main/java/com/stackup/stackup/auth/application/dto/RefreshTokenResult.java new file mode 100644 index 00000000..e2dce410 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/application/dto/RefreshTokenResult.java @@ -0,0 +1,7 @@ +package com.stackup.stackup.auth.application.dto; + +public record RefreshTokenResult( + String accessToken, + String refreshToken +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/application/dto/StreamTokenResult.java b/backend/src/main/java/com/stackup/stackup/auth/application/dto/StreamTokenResult.java new file mode 100644 index 00000000..3491a923 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/application/dto/StreamTokenResult.java @@ -0,0 +1,6 @@ +package com.stackup.stackup.auth.application.dto; + +public record StreamTokenResult( + String streamToken +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/infrastructure/GithubOAuthClient.java b/backend/src/main/java/com/stackup/stackup/auth/infrastructure/GithubOAuthClient.java new file mode 100644 index 00000000..00baa979 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/infrastructure/GithubOAuthClient.java @@ -0,0 +1,25 @@ +package com.stackup.stackup.auth.infrastructure; + +import com.stackup.stackup.common.config.properties.GithubOAuthProperties; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +@Component +public class GithubOAuthClient { + + private final GithubOAuthProperties githubOAuthProperties; + + public GithubOAuthClient(GithubOAuthProperties githubOAuthProperties) { + this.githubOAuthProperties = githubOAuthProperties; + } + + public String buildAuthorizationUrl(String state) { + return UriComponentsBuilder.fromUriString("https://github.com/login/oauth/authorize") + .queryParam("client_id", githubOAuthProperties.clientId()) + .queryParam("redirect_uri", githubOAuthProperties.redirectUri()) + .queryParam("scope", "read:user user:email repo") + .queryParam("state", state) + .build() + .toUriString(); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/presentation/AuthController.java b/backend/src/main/java/com/stackup/stackup/auth/presentation/AuthController.java new file mode 100644 index 00000000..60048a5d --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/presentation/AuthController.java @@ -0,0 +1,78 @@ +package com.stackup.stackup.auth.presentation; + +import com.stackup.stackup.auth.application.AuthService; +import com.stackup.stackup.auth.application.dto.GithubCallbackResult; +import com.stackup.stackup.auth.application.dto.GithubLoginResult; +import com.stackup.stackup.auth.application.dto.RefreshTokenResult; +import com.stackup.stackup.auth.application.dto.StreamTokenResult; +import com.stackup.stackup.auth.presentation.dto.GithubAuthRequest; +import com.stackup.stackup.auth.presentation.dto.GithubCallbackResponse; +import com.stackup.stackup.auth.presentation.dto.GithubLoginResponse; +import com.stackup.stackup.auth.presentation.dto.LogoutRequest; +import com.stackup.stackup.auth.presentation.dto.RefreshTokenRequest; +import com.stackup.stackup.auth.presentation.dto.RefreshTokenResponse; +import com.stackup.stackup.auth.presentation.dto.StreamTokenResponse; +import com.stackup.stackup.common.security.UserPrincipal; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + private final AuthService authService; + + public AuthController(AuthService authService) { + this.authService = authService; + } + + @PostMapping("/github") + public ResponseEntity startGithubLogin(@RequestBody(required = false) GithubAuthRequest request) { + GithubLoginResult result = authService.startGithubLogin(); + return ResponseEntity.ok(new GithubLoginResponse(result.authorizationUrl(), result.state())); + } + + @GetMapping("/github/callback") + public ResponseEntity githubCallback( + @RequestParam(required = false) String code, + @RequestParam(required = false) String state + ) { + GithubCallbackResult result = authService.completeGithubLogin(code, state); + return ResponseEntity.ok(new GithubCallbackResponse( + result.accessToken(), + result.refreshToken(), + result.userId(), + result.githubUsername() + )); + } + + @PostMapping("/refresh") + public ResponseEntity refresh(@RequestBody(required = false) RefreshTokenRequest request) { + String refreshToken = request == null ? null : request.refreshToken(); + RefreshTokenResult result = authService.refresh(refreshToken); + return ResponseEntity.ok(new RefreshTokenResponse(result.accessToken(), result.refreshToken())); + } + + @DeleteMapping("/logout") + public ResponseEntity logout(@RequestBody(required = false) LogoutRequest request) { + String refreshToken = request == null ? null : request.refreshToken(); + authService.logout(refreshToken); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/stream-token") + public ResponseEntity createStreamToken( + @AuthenticationPrincipal UserPrincipal principal + ) { + Long userId = principal == null ? null : principal.userId(); + StreamTokenResult result = authService.createStreamToken(userId); + return ResponseEntity.ok(new StreamTokenResponse(result.streamToken())); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/GithubAuthRequest.java b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/GithubAuthRequest.java new file mode 100644 index 00000000..7f907d48 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/GithubAuthRequest.java @@ -0,0 +1,7 @@ +package com.stackup.stackup.auth.presentation.dto; + +public record GithubAuthRequest( + String code, + String state +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/GithubCallbackResponse.java b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/GithubCallbackResponse.java new file mode 100644 index 00000000..5f3bb7fa --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/GithubCallbackResponse.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.auth.presentation.dto; + +public record GithubCallbackResponse( + String accessToken, + String refreshToken, + long userId, + String githubUsername +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/GithubLoginResponse.java b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/GithubLoginResponse.java new file mode 100644 index 00000000..66ddc41d --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/GithubLoginResponse.java @@ -0,0 +1,7 @@ +package com.stackup.stackup.auth.presentation.dto; + +public record GithubLoginResponse( + String authorizationUrl, + String state +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/LogoutRequest.java b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/LogoutRequest.java new file mode 100644 index 00000000..7d859702 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/LogoutRequest.java @@ -0,0 +1,6 @@ +package com.stackup.stackup.auth.presentation.dto; + +public record LogoutRequest( + String refreshToken +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/RefreshTokenRequest.java b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/RefreshTokenRequest.java new file mode 100644 index 00000000..c739e4fb --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/RefreshTokenRequest.java @@ -0,0 +1,6 @@ +package com.stackup.stackup.auth.presentation.dto; + +public record RefreshTokenRequest( + String refreshToken +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/RefreshTokenResponse.java b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/RefreshTokenResponse.java new file mode 100644 index 00000000..eaa4de45 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/RefreshTokenResponse.java @@ -0,0 +1,7 @@ +package com.stackup.stackup.auth.presentation.dto; + +public record RefreshTokenResponse( + String accessToken, + String refreshToken +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/StreamTokenResponse.java b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/StreamTokenResponse.java new file mode 100644 index 00000000..436b056a --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/StreamTokenResponse.java @@ -0,0 +1,6 @@ +package com.stackup.stackup.auth.presentation.dto; + +public record StreamTokenResponse( + String streamToken +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/common/security/JwtAuthenticationFilter.java b/backend/src/main/java/com/stackup/stackup/common/security/JwtAuthenticationFilter.java new file mode 100644 index 00000000..261b557a --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/security/JwtAuthenticationFilter.java @@ -0,0 +1,60 @@ +package com.stackup.stackup.common.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtTokenProvider jwtTokenProvider; + + public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) { + this.jwtTokenProvider = jwtTokenProvider; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + String token = resolveBearerToken(request); + + if (token != null) { + try { + Long userId = jwtTokenProvider.getUserId(token); + List authorities = List.of(new SimpleGrantedAuthority("ROLE_USER")); + UserPrincipal principal = new UserPrincipal(userId, null, authorities); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(principal, token, principal.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (RuntimeException exception) { + SecurityContextHolder.clearContext(); + } + } + + filterChain.doFilter(request, response); + } + + private String resolveBearerToken(HttpServletRequest request) { + String authorization = request.getHeader(AUTHORIZATION_HEADER); + if (authorization == null || !authorization.startsWith(BEARER_PREFIX)) { + return null; + } + + String token = authorization.substring(BEARER_PREFIX.length()).trim(); + return token.isBlank() ? null : token; + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/security/JwtTokenProvider.java b/backend/src/main/java/com/stackup/stackup/common/security/JwtTokenProvider.java new file mode 100644 index 00000000..5e791ff3 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/security/JwtTokenProvider.java @@ -0,0 +1,101 @@ +package com.stackup.stackup.common.security; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jose.crypto.MACVerifier; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.stackup.stackup.common.config.properties.SecurityProperties; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.text.ParseException; +import java.time.Instant; +import java.util.Date; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.stereotype.Component; + +@Component +public class JwtTokenProvider { + + private static final String USER_ID_CLAIM = "userId"; + private static final String TOKEN_TYPE_CLAIM = "tokenType"; + private static final String ACCESS_TOKEN_TYPE = "ACCESS"; + + private final SecurityProperties securityProperties; + private final byte[] secretKey; + + public JwtTokenProvider(SecurityProperties securityProperties) { + this.securityProperties = securityProperties; + this.secretKey = sha256(securityProperties.jwtSecret()); + } + + public String createAccessToken(Long userId) { + Instant now = Instant.now(); + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .subject(String.valueOf(userId)) + .claim(USER_ID_CLAIM, userId) + .claim(TOKEN_TYPE_CLAIM, ACCESS_TOKEN_TYPE) + .issueTime(Date.from(now)) + .expirationTime(Date.from(now.plusSeconds(securityProperties.accessTokenTtlSeconds()))) + .build(); + + return sign(claimsSet); + } + + public JWTClaimsSet parseAndValidate(String token) { + try { + SignedJWT signedJwt = SignedJWT.parse(token); + if (!signedJwt.verify(new MACVerifier(secretKey))) { + throw invalidToken(); + } + + JWTClaimsSet claimsSet = signedJwt.getJWTClaimsSet(); + if (claimsSet.getExpirationTime() == null || claimsSet.getExpirationTime().before(new Date())) { + throw invalidToken(); + } + if (!ACCESS_TOKEN_TYPE.equals(claimsSet.getStringClaim(TOKEN_TYPE_CLAIM))) { + throw invalidToken(); + } + if (claimsSet.getLongClaim(USER_ID_CLAIM) == null) { + throw invalidToken(); + } + + return claimsSet; + } catch (ParseException | JOSEException exception) { + throw invalidToken(); + } + } + + public Long getUserId(String token) { + try { + return parseAndValidate(token).getLongClaim(USER_ID_CLAIM); + } catch (ParseException exception) { + throw invalidToken(); + } + } + + private String sign(JWTClaimsSet claimsSet) { + try { + SignedJWT signedJwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claimsSet); + signedJwt.sign(new MACSigner(secretKey)); + return signedJwt.serialize(); + } catch (JOSEException exception) { + throw new IllegalStateException("Failed to create JWT", exception); + } + } + + private BadCredentialsException invalidToken() { + return new BadCredentialsException("Invalid JWT token"); + } + + private static byte[] sha256(String value) { + try { + return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 algorithm is not available", exception); + } + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/security/SecurityConfig.java b/backend/src/main/java/com/stackup/stackup/common/security/SecurityConfig.java new file mode 100644 index 00000000..e7fc1f92 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/security/SecurityConfig.java @@ -0,0 +1,101 @@ +package com.stackup.stackup.common.security; + +import com.stackup.stackup.common.config.properties.CorsProperties; +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.trace.TraceContext; +import jakarta.servlet.http.HttpServletResponse; +import java.util.List; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CorsProperties corsProperties; + + public SecurityConfig( + JwtAuthenticationFilter jwtAuthenticationFilter, + CorsProperties corsProperties + ) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + this.corsProperties = corsProperties; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(exception -> exception.authenticationEntryPoint(authenticationEntryPoint())) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers( + "/api/auth/github", + "/api/auth/github/callback", + "/api/auth/refresh", + "/api/auth/logout", + "/api/system/live", + "/api/system/ready", + "/api/system/health", + "/api/v3/api-docs/**", + "/api/swagger-ui/**", + "/api/swagger-ui.html", + "/actuator/**" + ).permitAll() + .requestMatchers("/api/**").authenticated() + .anyRequest().permitAll() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(corsProperties.allowedOrigins()); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setExposedHeaders(List.of(TraceContext.TRACE_ID_HEADER, HttpHeaders.AUTHORIZATION)); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + @Bean + public AuthenticationEntryPoint authenticationEntryPoint() { + return (request, response, authException) -> { + ApiErrorCode errorCode = ApiErrorCode.AUTH_INVALID_TOKEN; + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write(""" + {"code":"%s","message":"%s","traceId":"%s","timestamp":"%s","details":{}} + """.formatted( + escape(errorCode.name()), + escape(errorCode.getDefaultMessage()), + escape(TraceContext.getTraceId()), + escape(java.time.Instant.now().toString()) + )); + }; + } + + private static String escape(String value) { + if (value == null) { + return ""; + } + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/security/StreamTokenProvider.java b/backend/src/main/java/com/stackup/stackup/common/security/StreamTokenProvider.java new file mode 100644 index 00000000..c03f6aa8 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/security/StreamTokenProvider.java @@ -0,0 +1,113 @@ +package com.stackup.stackup.common.security; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jose.crypto.MACVerifier; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.stackup.stackup.common.config.properties.SecurityProperties; +import com.stackup.stackup.common.config.properties.SseProperties; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.text.ParseException; +import java.time.Instant; +import java.util.Date; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.stereotype.Component; + +@Component +public class StreamTokenProvider { + + private static final String USER_ID_CLAIM = "userId"; + private static final String SCOPE_CLAIM = "scope"; + private static final String SSE_CONNECT_SCOPE = "SSE_CONNECT"; + private static final String TOKEN_TYPE_CLAIM = "tokenType"; + private static final String STREAM_TOKEN_TYPE = "STREAM"; + + private final SseProperties sseProperties; + private final byte[] secretKey; + + public StreamTokenProvider(SecurityProperties securityProperties, SseProperties sseProperties) { + this.sseProperties = sseProperties; + this.secretKey = sha256(securityProperties.jwtSecret()); + } + + public String createStreamToken(Long userId) { + Instant now = Instant.now(); + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .subject(String.valueOf(userId)) + .claim(USER_ID_CLAIM, userId) + .claim(TOKEN_TYPE_CLAIM, STREAM_TOKEN_TYPE) + .claim(SCOPE_CLAIM, SSE_CONNECT_SCOPE) + .issueTime(Date.from(now)) + .expirationTime(Date.from(now.plus(sseProperties.streamTokenTtl()))) + .build(); + + return sign(claimsSet); + } + + public boolean validateStreamToken(String token) { + parseAndValidate(token); + return true; + } + + public Long getUserId(String token) { + try { + return parseAndValidate(token).getLongClaim(USER_ID_CLAIM); + } catch (ParseException exception) { + throw invalidToken(); + } + } + + private JWTClaimsSet parseAndValidate(String token) { + try { + SignedJWT signedJwt = SignedJWT.parse(token); + if (!signedJwt.verify(new MACVerifier(secretKey))) { + throw invalidToken(); + } + + JWTClaimsSet claimsSet = signedJwt.getJWTClaimsSet(); + if (claimsSet.getExpirationTime() == null || claimsSet.getExpirationTime().before(new Date())) { + throw invalidToken(); + } + if (!STREAM_TOKEN_TYPE.equals(claimsSet.getStringClaim(TOKEN_TYPE_CLAIM))) { + throw invalidToken(); + } + if (!SSE_CONNECT_SCOPE.equals(claimsSet.getStringClaim(SCOPE_CLAIM))) { + throw invalidToken(); + } + if (claimsSet.getLongClaim(USER_ID_CLAIM) == null) { + throw invalidToken(); + } + + return claimsSet; + } catch (ParseException | JOSEException exception) { + throw invalidToken(); + } + } + + private String sign(JWTClaimsSet claimsSet) { + try { + SignedJWT signedJwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claimsSet); + signedJwt.sign(new MACSigner(secretKey)); + return signedJwt.serialize(); + } catch (JOSEException exception) { + throw new IllegalStateException("Failed to create stream token", exception); + } + } + + private BadCredentialsException invalidToken() { + return new BadCredentialsException("Invalid stream token"); + } + + private static byte[] sha256(String value) { + try { + return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 algorithm is not available", exception); + } + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/security/UserPrincipal.java b/backend/src/main/java/com/stackup/stackup/common/security/UserPrincipal.java new file mode 100644 index 00000000..03896494 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/security/UserPrincipal.java @@ -0,0 +1,52 @@ +package com.stackup.stackup.common.security; + +import java.util.Collection; +import java.util.List; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +public record UserPrincipal( + Long userId, + String githubUsername, + Collection authorities +) implements UserDetails { + + public UserPrincipal { + authorities = authorities == null ? List.of() : List.copyOf(authorities); + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public String getPassword() { + return ""; + } + + @Override + public String getUsername() { + return githubUsername == null ? String.valueOf(userId) : githubUsername; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} From ddb05f03fc2f8fe32a5cbdc971d1f582d316f7bc Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Sun, 10 May 2026 13:27:33 +0900 Subject: [PATCH 15/19] =?UTF-8?q?feat:=20sse,=20RabbitMQ=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSE emitter, event model 등추가 core AI간 비동기 메세지 기반 생성 --- .../common/messaging/MessageContext.java | 28 +++++ .../common/messaging/MessageEnvelope.java | 15 +++ .../messaging/RabbitMessagePublisher.java | 54 +++++++++ .../common/messaging/RabbitMqConfig.java | 98 +++++++++++++++ .../stackup/common/messaging/RoutingKeys.java | 17 +++ .../stackup/common/sse/SseConfiguration.java | 9 ++ .../stackup/common/sse/SseController.java | 38 ++++++ .../common/sse/SseEmitterRegistry.java | 114 ++++++++++++++++++ .../stackup/stackup/common/sse/SseEvent.java | 12 ++ .../stackup/common/sse/SseEventPublisher.java | 34 ++++++ .../stackup/common/sse/SseEventType.java | 11 ++ .../common/sse/SseKeepAliveScheduler.java | 19 +++ 12 files changed, 449 insertions(+) create mode 100644 backend/src/main/java/com/stackup/stackup/common/messaging/MessageContext.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/messaging/MessageEnvelope.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMessagePublisher.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/messaging/RoutingKeys.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/sse/SseConfiguration.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/sse/SseController.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/sse/SseEmitterRegistry.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/sse/SseEvent.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/sse/SseEventPublisher.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/sse/SseEventType.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/sse/SseKeepAliveScheduler.java diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/MessageContext.java b/backend/src/main/java/com/stackup/stackup/common/messaging/MessageContext.java new file mode 100644 index 00000000..9ff13749 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/messaging/MessageContext.java @@ -0,0 +1,28 @@ +package com.stackup.stackup.common.messaging; + +public record MessageContext( + Long userId, + Long sessionId, + Long documentId, + Long repositoryId +) { + public static MessageContext empty() { + return new MessageContext(null, null, null, null); + } + + public static MessageContext ofUser(Long userId) { + return new MessageContext(userId, null, null, null); + } + + public MessageContext withSession(Long newSessionId) { + return new MessageContext(userId, newSessionId, documentId, repositoryId); + } + + public MessageContext withDocument(Long newDocumentId) { + return new MessageContext(userId, sessionId, newDocumentId, repositoryId); + } + + public MessageContext withRepository(Long newRepositoryId) { + return new MessageContext(userId, sessionId, documentId, newRepositoryId); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/MessageEnvelope.java b/backend/src/main/java/com/stackup/stackup/common/messaging/MessageEnvelope.java new file mode 100644 index 00000000..f319a827 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/messaging/MessageEnvelope.java @@ -0,0 +1,15 @@ +package com.stackup.stackup.common.messaging; + +import java.time.Instant; + +public record MessageEnvelope( + String messageId, + String messageType, + String version, + String traceId, + Instant publishedAt, + String publisher, + T payload, + MessageContext context +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMessagePublisher.java b/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMessagePublisher.java new file mode 100644 index 00000000..ca9404d6 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMessagePublisher.java @@ -0,0 +1,54 @@ +package com.stackup.stackup.common.messaging; + +import com.stackup.stackup.common.trace.TraceContext; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.UUID; +import org.springframework.amqp.core.MessagePostProcessor; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Component; + +@Component +public class RabbitMessagePublisher { + + private static final String VERSION = "v1"; + private static final String PUBLISHER = "core-server"; + + private final RabbitTemplate rabbitTemplate; + + public RabbitMessagePublisher(RabbitTemplate rabbitTemplate) { + this.rabbitTemplate = rabbitTemplate; + } + + public MessageEnvelope publishToAi(String routingKey, T payload, MessageContext context) { + MessageEnvelope envelope = new MessageEnvelope<>( + UUID.randomUUID().toString(), + routingKey, + VERSION, + TraceContext.getTraceId(), + Instant.now(), + PUBLISHER, + payload, + context == null ? MessageContext.empty() : context + ); + + rabbitTemplate.convertAndSend( + RoutingKeys.CORE_TO_AI_EXCHANGE, + routingKey, + envelope, + withEnvelopeHeaders(envelope) + ); + return envelope; + } + + private MessagePostProcessor withEnvelopeHeaders(MessageEnvelope envelope) { + return message -> { + message.getMessageProperties().setContentType("application/json"); + message.getMessageProperties().setContentEncoding(StandardCharsets.UTF_8.name()); + message.getMessageProperties().setMessageId(envelope.messageId()); + message.getMessageProperties().setCorrelationId(envelope.messageId()); + message.getMessageProperties().setHeader("x-trace-id", envelope.traceId()); + return message; + }; + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java b/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java new file mode 100644 index 00000000..6ed91223 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java @@ -0,0 +1,98 @@ +package com.stackup.stackup.common.messaging; + +import org.springframework.amqp.core.BindingBuilder; +import org.springframework.amqp.core.Declarables; +import org.springframework.amqp.core.Queue; +import org.springframework.amqp.core.TopicExchange; +import org.springframework.amqp.rabbit.connection.ConnectionFactory; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RabbitMqConfig { + + @Bean + public TopicExchange coreToAiExchange() { + return new TopicExchange(RoutingKeys.CORE_TO_AI_EXCHANGE, true, false); + } + + @Bean + public TopicExchange aiToCoreExchange() { + return new TopicExchange(RoutingKeys.AI_TO_CORE_EXCHANGE, true, false); + } + + @Bean + public Queue aiAnalyzeResumeQueue() { + return new Queue("ai.analyze.resume", true); + } + + @Bean + public Queue aiAnalyzeRepositoryQueue() { + return new Queue("ai.analyze.repository", true); + } + + @Bean + public Queue aiGenerateQuestionsQueue() { + return new Queue("ai.generate.questions", true); + } + + @Bean + public Queue aiGenerateFollowupQueue() { + return new Queue("ai.generate.followup", true); + } + + @Bean + public Queue coreCallbackAnalysisQueue() { + return new Queue("core.callback.analysis", true); + } + + @Bean + public Queue coreCallbackQuestionsQueue() { + return new Queue("core.callback.questions", true); + } + + @Bean + public Declarables rabbitDeclarables( + TopicExchange coreToAiExchange, + TopicExchange aiToCoreExchange, + Queue aiAnalyzeResumeQueue, + Queue aiAnalyzeRepositoryQueue, + Queue aiGenerateQuestionsQueue, + Queue aiGenerateFollowupQueue, + Queue coreCallbackAnalysisQueue, + Queue coreCallbackQuestionsQueue + ) { + return new Declarables( + coreToAiExchange, + aiToCoreExchange, + aiAnalyzeResumeQueue, + aiAnalyzeRepositoryQueue, + aiGenerateQuestionsQueue, + aiGenerateFollowupQueue, + coreCallbackAnalysisQueue, + coreCallbackQuestionsQueue, + BindingBuilder.bind(aiAnalyzeResumeQueue).to(coreToAiExchange).with(RoutingKeys.ANALYZE_RESUME), + BindingBuilder.bind(aiAnalyzeRepositoryQueue).to(coreToAiExchange).with(RoutingKeys.ANALYZE_REPOSITORY), + BindingBuilder.bind(aiGenerateQuestionsQueue).to(coreToAiExchange).with(RoutingKeys.GENERATE_QUESTIONS), + BindingBuilder.bind(aiGenerateFollowupQueue).to(coreToAiExchange).with(RoutingKeys.GENERATE_FOLLOWUP), + BindingBuilder.bind(coreCallbackAnalysisQueue).to(aiToCoreExchange).with(RoutingKeys.CALLBACK_ANALYSIS), + BindingBuilder.bind(coreCallbackQuestionsQueue).to(aiToCoreExchange).with(RoutingKeys.CALLBACK_QUESTIONS) + ); + } + + @Bean + public MessageConverter messageConverter() { + return new Jackson2JsonMessageConverter(); + } + + @Bean + public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory, MessageConverter messageConverter) { + RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory); + rabbitTemplate.setMessageConverter(messageConverter); + rabbitTemplate.setMandatory(true); + return rabbitTemplate; + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/RoutingKeys.java b/backend/src/main/java/com/stackup/stackup/common/messaging/RoutingKeys.java new file mode 100644 index 00000000..2f1cfb00 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/messaging/RoutingKeys.java @@ -0,0 +1,17 @@ +package com.stackup.stackup.common.messaging; + +public final class RoutingKeys { + + public static final String CORE_TO_AI_EXCHANGE = "stackup.core-to-ai"; + public static final String AI_TO_CORE_EXCHANGE = "stackup.ai-to-core"; + + public static final String ANALYZE_RESUME = "analyze.resume"; + public static final String ANALYZE_REPOSITORY = "analyze.repository"; + public static final String GENERATE_QUESTIONS = "generate.questions"; + public static final String GENERATE_FOLLOWUP = "generate.followup"; + public static final String CALLBACK_ANALYSIS = "callback.analysis"; + public static final String CALLBACK_QUESTIONS = "callback.questions"; + + private RoutingKeys() { + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/sse/SseConfiguration.java b/backend/src/main/java/com/stackup/stackup/common/sse/SseConfiguration.java new file mode 100644 index 00000000..06b24c1b --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/sse/SseConfiguration.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.common.sse; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SseConfiguration { +} diff --git a/backend/src/main/java/com/stackup/stackup/common/sse/SseController.java b/backend/src/main/java/com/stackup/stackup/common/sse/SseController.java new file mode 100644 index 00000000..28c87fc4 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/sse/SseController.java @@ -0,0 +1,38 @@ +package com.stackup.stackup.common.sse; + +import com.stackup.stackup.common.security.UserPrincipal; +import java.util.Objects; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@RestController +@RequestMapping("/api/stream") +public class SseController { + + private final SseEmitterRegistry registry; + + public SseController(SseEmitterRegistry registry) { + this.registry = registry; + } + + @GetMapping(value = "/me", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter streamMe(@AuthenticationPrincipal UserPrincipal principal) { + UserPrincipal authenticatedPrincipal = Objects.requireNonNull(principal, "authenticated principal is required"); + return registry.registerUser(authenticatedPrincipal.userId()); + } + + @GetMapping(value = "/sessions/{sessionId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter streamSession(@PathVariable Long sessionId) { + return registry.registerSession(sessionId); + } + + @GetMapping(value = "/documents/{documentId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter streamDocument(@PathVariable Long documentId) { + return registry.registerDocument(documentId); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/sse/SseEmitterRegistry.java b/backend/src/main/java/com/stackup/stackup/common/sse/SseEmitterRegistry.java new file mode 100644 index 00000000..08de4c98 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/sse/SseEmitterRegistry.java @@ -0,0 +1,114 @@ +package com.stackup.stackup.common.sse; + +import com.stackup.stackup.common.config.properties.SseProperties; +import java.io.IOException; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Component +public class SseEmitterRegistry { + + private final SseProperties sseProperties; + private final Map> userEmitters = new ConcurrentHashMap<>(); + private final Map> sessionEmitters = new ConcurrentHashMap<>(); + private final Map> documentEmitters = new ConcurrentHashMap<>(); + + public SseEmitterRegistry(SseProperties sseProperties) { + this.sseProperties = sseProperties; + } + + public SseEmitter registerUser(Long userId) { + return register(userEmitters, userId); + } + + public SseEmitter registerSession(Long sessionId) { + return register(sessionEmitters, sessionId); + } + + public SseEmitter registerDocument(Long documentId) { + return register(documentEmitters, documentId); + } + + public void sendToUser(Long userId, SseEvent event) { + send(userEmitters.get(userId), event); + } + + public void sendToSession(Long sessionId, SseEvent event) { + send(sessionEmitters.get(sessionId), event); + } + + public void sendToDocument(Long documentId, SseEvent event) { + send(documentEmitters.get(documentId), event); + } + + public void remove(SseEmitter emitter) { + if (emitter == null) { + return; + } + removeFrom(userEmitters, emitter); + removeFrom(sessionEmitters, emitter); + removeFrom(documentEmitters, emitter); + } + + public void keepAliveAll() { + SseEvent event = new SseEvent(null, SseEventType.KEEP_ALIVE, null, java.time.Instant.now(), null); + userEmitters.values().forEach(emitters -> send(emitters, event)); + sessionEmitters.values().forEach(emitters -> send(emitters, event)); + documentEmitters.values().forEach(emitters -> send(emitters, event)); + } + + private SseEmitter register(Map> registry, Long key) { + Objects.requireNonNull(key, "key must not be null"); + + SseEmitter emitter = new SseEmitter(sseProperties.timeout().toMillis()); + registry.computeIfAbsent(key, ignored -> new CopyOnWriteArrayList<>()).add(emitter); + + emitter.onCompletion(() -> remove(emitter)); + emitter.onTimeout(() -> remove(emitter)); + emitter.onError(throwable -> remove(emitter)); + + sendInitialEvent(emitter); + return emitter; + } + + private void sendInitialEvent(SseEmitter emitter) { + try { + emitter.send(SseEmitter.event() + .name(SseEventType.KEEP_ALIVE.name()) + .data(new SseEvent(null, SseEventType.KEEP_ALIVE, "connected", java.time.Instant.now(), null), + MediaType.APPLICATION_JSON)); + } catch (IOException ex) { + remove(emitter); + } + } + + private void send(CopyOnWriteArrayList emitters, SseEvent event) { + if (emitters == null || emitters.isEmpty()) { + return; + } + for (SseEmitter emitter : emitters) { + try { + emitter.send(SseEmitter.event() + .id(event.id()) + .name(event.type().name()) + .data(event, MediaType.APPLICATION_JSON)); + } catch (IOException | IllegalStateException ex) { + remove(emitter); + } + } + } + + private void removeFrom(Map> registry, SseEmitter emitter) { + registry.forEach((key, emitters) -> { + emitters.remove(emitter); + if (emitters.isEmpty()) { + registry.remove(key, emitters); + } + }); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/sse/SseEvent.java b/backend/src/main/java/com/stackup/stackup/common/sse/SseEvent.java new file mode 100644 index 00000000..e22641e3 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/sse/SseEvent.java @@ -0,0 +1,12 @@ +package com.stackup.stackup.common.sse; + +import java.time.Instant; + +public record SseEvent( + String id, + SseEventType type, + Object payload, + Instant timestamp, + String traceId +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/common/sse/SseEventPublisher.java b/backend/src/main/java/com/stackup/stackup/common/sse/SseEventPublisher.java new file mode 100644 index 00000000..58bc063d --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/sse/SseEventPublisher.java @@ -0,0 +1,34 @@ +package com.stackup.stackup.common.sse; + +import com.stackup.stackup.common.trace.TraceContext; +import java.time.Instant; +import java.util.UUID; +import org.springframework.stereotype.Component; + +@Component +public class SseEventPublisher { + + private final SseEmitterRegistry registry; + + public SseEventPublisher(SseEmitterRegistry registry) { + this.registry = registry; + } + + public SseEvent publishToUser(Long userId, SseEventType type, Object payload) { + SseEvent event = new SseEvent(UUID.randomUUID().toString(), type, payload, Instant.now(), TraceContext.getTraceId()); + registry.sendToUser(userId, event); + return event; + } + + public SseEvent publishToSession(Long sessionId, SseEventType type, Object payload) { + SseEvent event = new SseEvent(UUID.randomUUID().toString(), type, payload, Instant.now(), TraceContext.getTraceId()); + registry.sendToSession(sessionId, event); + return event; + } + + public SseEvent publishToDocument(Long documentId, SseEventType type, Object payload) { + SseEvent event = new SseEvent(UUID.randomUUID().toString(), type, payload, Instant.now(), TraceContext.getTraceId()); + registry.sendToDocument(documentId, event); + return event; + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/sse/SseEventType.java b/backend/src/main/java/com/stackup/stackup/common/sse/SseEventType.java new file mode 100644 index 00000000..ad643719 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/sse/SseEventType.java @@ -0,0 +1,11 @@ +package com.stackup.stackup.common.sse; + +public enum SseEventType { + DOC_STATE, + REPO_STATE, + SESSION_MESSAGE, + SESSION_STATE, + FEEDBACK_READY, + ERROR, + KEEP_ALIVE +} diff --git a/backend/src/main/java/com/stackup/stackup/common/sse/SseKeepAliveScheduler.java b/backend/src/main/java/com/stackup/stackup/common/sse/SseKeepAliveScheduler.java new file mode 100644 index 00000000..1a5213bc --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/sse/SseKeepAliveScheduler.java @@ -0,0 +1,19 @@ +package com.stackup.stackup.common.sse; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +public class SseKeepAliveScheduler { + + private final SseEmitterRegistry registry; + + public SseKeepAliveScheduler(SseEmitterRegistry registry) { + this.registry = registry; + } + + @Scheduled(fixedDelayString = "${app.sse.keep-alive-interval}") + public void sendKeepAlive() { + registry.keepAliveAll(); + } +} From 5f5a5d1d1f1ff5bd04a3432e38181ba6ef3df101 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Sun, 10 May 2026 13:28:57 +0900 Subject: [PATCH 16/19] =?UTF-8?q?test:=20sse,=20rabbitMQ=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messaging/RabbitMessagePublisherTest.java | 77 +++++++++++++++++++ .../common/sse/SseEventPublisherTest.java | 47 +++++++++++ 2 files changed, 124 insertions(+) create mode 100644 backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java create mode 100644 backend/src/test/java/com/stackup/stackup/common/sse/SseEventPublisherTest.java diff --git a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java new file mode 100644 index 00000000..9cf9ee27 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java @@ -0,0 +1,77 @@ +package com.stackup.stackup.common.messaging; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.amqp.core.Message; +import org.springframework.amqp.core.MessageProperties; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import com.stackup.stackup.common.trace.TraceContext; + +@ExtendWith(MockitoExtension.class) +class RabbitMessagePublisherTest { + + @Mock + private RabbitTemplate rabbitTemplate; + + @InjectMocks + private RabbitMessagePublisher rabbitMessagePublisher; + + @BeforeEach + void setUp() { + TraceContext.clear(); + } + + @Test + void publishToAi_setsEnvelopeMetadataAndRabbitHeaders() { + TraceContext.setTraceId("trace-123"); + + MessageEnvelope> envelope = rabbitMessagePublisher.publishToAi( + RoutingKeys.ANALYZE_RESUME, + Map.of("resumeId", 1L), + MessageContext.ofUser(99L) + ); + + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(Object.class); + @SuppressWarnings("unchecked") + ArgumentCaptor postProcessorCaptor = + ArgumentCaptor.forClass(org.springframework.amqp.core.MessagePostProcessor.class); + + verify(rabbitTemplate).convertAndSend( + eq(RoutingKeys.CORE_TO_AI_EXCHANGE), + eq(RoutingKeys.ANALYZE_RESUME), + payloadCaptor.capture(), + postProcessorCaptor.capture() + ); + + assertThat(payloadCaptor.getValue()).isEqualTo(envelope); + assertThat(envelope.messageType()).isEqualTo(RoutingKeys.ANALYZE_RESUME); + assertThat(envelope.version()).isEqualTo("v1"); + assertThat(envelope.traceId()).isEqualTo("trace-123"); + assertThat(envelope.publishedAt()).isNotNull(); + assertThat(envelope.publisher()).isEqualTo("core-server"); + assertThat(envelope.context().userId()).isEqualTo(99L); + + Message message = postProcessorCaptor.getValue().postProcessMessage( + new Message("{}".getBytes(StandardCharsets.UTF_8), new MessageProperties()) + ); + + assertThat(message.getMessageProperties().getContentType()).isEqualTo("application/json"); + assertThat(message.getMessageProperties().getContentEncoding()).isEqualTo(StandardCharsets.UTF_8.name()); + assertThat(message.getMessageProperties().getMessageId()).isEqualTo(envelope.messageId()); + assertThat(message.getMessageProperties().getCorrelationId()).isEqualTo(envelope.messageId()); + assertThat(message.getMessageProperties().getHeaders()).containsEntry("x-trace-id", "trace-123"); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/common/sse/SseEventPublisherTest.java b/backend/src/test/java/com/stackup/stackup/common/sse/SseEventPublisherTest.java new file mode 100644 index 00000000..fb726e93 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/common/sse/SseEventPublisherTest.java @@ -0,0 +1,47 @@ +package com.stackup.stackup.common.sse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; + +import com.stackup.stackup.common.trace.TraceContext; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SseEventPublisherTest { + + @Mock + private SseEmitterRegistry registry; + + @InjectMocks + private SseEventPublisher sseEventPublisher; + + @BeforeEach + void setUp() { + TraceContext.clear(); + } + + @Test + void publishToUser_wrapsPayloadWithTraceMetadata() { + TraceContext.setTraceId("trace-777"); + + sseEventPublisher.publishToUser(7L, SseEventType.SESSION_STATE, Map.of("state", "READY")); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(SseEvent.class); + verify(registry).sendToUser(eq(7L), eventCaptor.capture()); + + SseEvent event = eventCaptor.getValue(); + assertThat(event.id()).isNotBlank(); + assertThat(event.type()).isEqualTo(SseEventType.SESSION_STATE); + assertThat(event.payload()).isEqualTo(Map.of("state", "READY")); + assertThat(event.timestamp()).isNotNull(); + assertThat(event.traceId()).isEqualTo("trace-777"); + } +} From 7fa2f09c04de699dcb7ef00f5b48e0cc232cf74e Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Sun, 10 May 2026 13:30:40 +0900 Subject: [PATCH 17/19] =?UTF-8?q?feat:=20system=20health=20endpoint=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit linve, ready, health 엔드 포인트 추가 --- .../application/SystemHealthService.java | 95 +++++++++++++++ .../dto/ComponentHealthResponse.java | 10 ++ .../application/dto/SystemHealthResponse.java | 11 ++ .../application/dto/SystemLiveResponse.java | 9 ++ .../system/presentation/SystemController.java | 35 ++++++ .../application/SystemHealthServiceTest.java | 112 ++++++++++++++++++ 6 files changed, 272 insertions(+) create mode 100644 backend/src/main/java/com/stackup/stackup/system/application/SystemHealthService.java create mode 100644 backend/src/main/java/com/stackup/stackup/system/application/dto/ComponentHealthResponse.java create mode 100644 backend/src/main/java/com/stackup/stackup/system/application/dto/SystemHealthResponse.java create mode 100644 backend/src/main/java/com/stackup/stackup/system/application/dto/SystemLiveResponse.java create mode 100644 backend/src/main/java/com/stackup/stackup/system/presentation/SystemController.java create mode 100644 backend/src/test/java/com/stackup/stackup/system/application/SystemHealthServiceTest.java diff --git a/backend/src/main/java/com/stackup/stackup/system/application/SystemHealthService.java b/backend/src/main/java/com/stackup/stackup/system/application/SystemHealthService.java new file mode 100644 index 00000000..b006c3d0 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/system/application/SystemHealthService.java @@ -0,0 +1,95 @@ +package com.stackup.stackup.system.application; + +import com.stackup.stackup.system.application.dto.ComponentHealthResponse; +import com.stackup.stackup.system.application.dto.SystemHealthResponse; +import com.stackup.stackup.system.application.dto.SystemLiveResponse; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.springframework.boot.health.actuate.endpoint.HealthDescriptor; +import org.springframework.boot.health.actuate.endpoint.HealthEndpoint; +import org.springframework.boot.health.contributor.Status; +import org.springframework.stereotype.Service; + +@Service +public class SystemHealthService { + + private static final ComponentSpec DATABASE = new ComponentSpec("database", "db"); + private static final ComponentSpec RABBITMQ = new ComponentSpec("rabbitmq", "rabbitmq"); + private static final ComponentSpec S3 = new ComponentSpec("s3", "s3"); + private static final ComponentSpec AI_SERVER = new ComponentSpec("aiServer", "aiServer"); + + private final HealthEndpoint healthEndpoint; + + public SystemHealthService(HealthEndpoint healthEndpoint) { + this.healthEndpoint = healthEndpoint; + } + + public SystemLiveResponse live() { + return new SystemLiveResponse(Status.UP.getCode(), Instant.now()); + } + + public SystemHealthResponse ready() { + return buildResponse(List.of(DATABASE, RABBITMQ)); + } + + public SystemHealthResponse health() { + return buildResponse(List.of(DATABASE, RABBITMQ, S3, AI_SERVER)); + } + + private SystemHealthResponse buildResponse(List specs) { + Map components = new LinkedHashMap<>(); + for (ComponentSpec spec : specs) { + components.put(spec.name(), resolveComponent(spec)); + } + return new SystemHealthResponse( + aggregateStatus(components.values()), + Instant.now(), + components + ); + } + + private ComponentHealthResponse resolveComponent(ComponentSpec spec) { + HealthDescriptor descriptor = resolveDescriptor(spec.actuatorPath()); + if (descriptor == null) { + return new ComponentHealthResponse(spec.name(), Status.UNKNOWN.getCode(), Map.of()); + } + + Map details = extractDetails(descriptor); + return new ComponentHealthResponse(spec.name(), descriptor.getStatus().getCode(), details); + } + + protected HealthDescriptor resolveDescriptor(String path) { + try { + return healthEndpoint.healthForPath(path); + } catch (RuntimeException ex) { + return null; + } + } + + private Map extractDetails(HealthDescriptor descriptor) { + try { + return Map.copyOf((Map) descriptor.getClass().getMethod("getDetails").invoke(descriptor)); + } catch (ReflectiveOperationException | ClassCastException ex) { + return Map.of(); + } + } + + private String aggregateStatus(Iterable components) { + boolean hasUnknown = false; + for (ComponentHealthResponse component : components) { + String status = component.status(); + if (Status.DOWN.getCode().equals(status) || Status.OUT_OF_SERVICE.getCode().equals(status)) { + return Status.DOWN.getCode(); + } + if (!Status.UP.getCode().equals(status)) { + hasUnknown = true; + } + } + return hasUnknown ? Status.UNKNOWN.getCode() : Status.UP.getCode(); + } + + private record ComponentSpec(String name, String actuatorPath) { + } +} diff --git a/backend/src/main/java/com/stackup/stackup/system/application/dto/ComponentHealthResponse.java b/backend/src/main/java/com/stackup/stackup/system/application/dto/ComponentHealthResponse.java new file mode 100644 index 00000000..5af952ab --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/system/application/dto/ComponentHealthResponse.java @@ -0,0 +1,10 @@ +package com.stackup.stackup.system.application.dto; + +import java.util.Map; + +public record ComponentHealthResponse( + String name, + String status, + Map details +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/system/application/dto/SystemHealthResponse.java b/backend/src/main/java/com/stackup/stackup/system/application/dto/SystemHealthResponse.java new file mode 100644 index 00000000..7aaed571 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/system/application/dto/SystemHealthResponse.java @@ -0,0 +1,11 @@ +package com.stackup.stackup.system.application.dto; + +import java.time.Instant; +import java.util.Map; + +public record SystemHealthResponse( + String status, + Instant timestamp, + Map components +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/system/application/dto/SystemLiveResponse.java b/backend/src/main/java/com/stackup/stackup/system/application/dto/SystemLiveResponse.java new file mode 100644 index 00000000..335cc3d2 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/system/application/dto/SystemLiveResponse.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.system.application.dto; + +import java.time.Instant; + +public record SystemLiveResponse( + String status, + Instant timestamp +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/system/presentation/SystemController.java b/backend/src/main/java/com/stackup/stackup/system/presentation/SystemController.java new file mode 100644 index 00000000..cdcbd631 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/system/presentation/SystemController.java @@ -0,0 +1,35 @@ +package com.stackup.stackup.system.presentation; + +import com.stackup.stackup.system.application.SystemHealthService; +import com.stackup.stackup.system.application.dto.SystemHealthResponse; +import com.stackup.stackup.system.application.dto.SystemLiveResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/system") +public class SystemController { + + private final SystemHealthService systemHealthService; + + public SystemController(SystemHealthService systemHealthService) { + this.systemHealthService = systemHealthService; + } + + @GetMapping("/live") + public ResponseEntity live() { + return ResponseEntity.ok(systemHealthService.live()); + } + + @GetMapping("/ready") + public ResponseEntity ready() { + return ResponseEntity.ok(systemHealthService.ready()); + } + + @GetMapping("/health") + public ResponseEntity health() { + return ResponseEntity.ok(systemHealthService.health()); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/system/application/SystemHealthServiceTest.java b/backend/src/test/java/com/stackup/stackup/system/application/SystemHealthServiceTest.java new file mode 100644 index 00000000..4d86803d --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/system/application/SystemHealthServiceTest.java @@ -0,0 +1,112 @@ +package com.stackup.stackup.system.application; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.health.actuate.endpoint.AdditionalHealthEndpointPath; +import org.springframework.boot.health.actuate.endpoint.HealthEndpoint; +import org.springframework.boot.health.actuate.endpoint.HealthEndpointGroup; +import org.springframework.boot.health.actuate.endpoint.HealthEndpointGroups; +import org.springframework.boot.health.actuate.endpoint.HttpCodeStatusMapper; +import org.springframework.boot.health.actuate.endpoint.SimpleHttpCodeStatusMapper; +import org.springframework.boot.health.actuate.endpoint.SimpleStatusAggregator; +import org.springframework.boot.health.actuate.endpoint.StatusAggregator; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.boot.health.contributor.Status; +import org.springframework.boot.health.registry.DefaultHealthContributorRegistry; +import org.springframework.boot.health.registry.DefaultReactiveHealthContributorRegistry; +import org.springframework.boot.health.registry.HealthContributorRegistry; +import org.springframework.boot.health.registry.ReactiveHealthContributorRegistry; +import org.springframework.boot.actuate.endpoint.SecurityContext; + +class SystemHealthServiceTest { + + @Test + void live_returnsUpStatus() { + SystemHealthService systemHealthService = new SystemHealthService(null); + + var response = systemHealthService.live(); + + assertThat(response.status()).isEqualTo(Status.UP.getCode()); + assertThat(response.timestamp()).isNotNull(); + } + + @Test + void ready_usesDatabaseAndRabbitmqIndicators() { + HealthEndpoint healthEndpoint = healthEndpoint(Map.of( + "db", indicator(Status.UP, Map.of("connections", 12)), + "rabbitmq", indicator(Status.UP, Map.of("host", "localhost")) + )); + SystemHealthService systemHealthService = new SystemHealthService(healthEndpoint); + + var response = systemHealthService.ready(); + + assertThat(response.status()).isEqualTo(Status.UP.getCode()); + assertThat(response.components()).containsKeys("database", "rabbitmq"); + assertThat(response.components()).doesNotContainKeys("s3", "aiServer"); + assertThat(response.components().get("database").details()).containsEntry("connections", 12); + } + + @Test + void health_includesDatabaseRabbitmqS3AndAiServer() { + HealthEndpoint healthEndpoint = healthEndpoint(Map.of( + "db", indicator(Status.UP, Map.of()), + "rabbitmq", indicator(Status.DOWN, Map.of("reachable", false)) + )); + SystemHealthService systemHealthService = new SystemHealthService(healthEndpoint); + + var response = systemHealthService.health(); + + assertThat(response.status()).isEqualTo(Status.DOWN.getCode()); + assertThat(response.components()).containsKeys("database", "rabbitmq", "s3", "aiServer"); + assertThat(response.components().get("s3").status()).isEqualTo(Status.UNKNOWN.getCode()); + assertThat(response.components().get("aiServer").status()).isEqualTo(Status.UNKNOWN.getCode()); + } + + private static HealthEndpoint healthEndpoint(Map indicators) { + HealthContributorRegistry registry = new DefaultHealthContributorRegistry(); + indicators.forEach(registry::registerContributor); + + ReactiveHealthContributorRegistry reactiveRegistry = new DefaultReactiveHealthContributorRegistry(); + HealthEndpointGroup primaryGroup = new HealthEndpointGroup() { + @Override + public boolean isMember(String name) { + return true; + } + + @Override + public boolean showComponents(SecurityContext securityContext) { + return true; + } + + @Override + public boolean showDetails(SecurityContext securityContext) { + return true; + } + + @Override + public StatusAggregator getStatusAggregator() { + return new SimpleStatusAggregator(); + } + + @Override + public HttpCodeStatusMapper getHttpCodeStatusMapper() { + return new SimpleHttpCodeStatusMapper(); + } + + @Override + public AdditionalHealthEndpointPath getAdditionalPath() { + return null; + } + }; + + return new HealthEndpoint(registry, reactiveRegistry, HealthEndpointGroups.of(primaryGroup, Map.of()), Duration.ZERO); + } + + private static HealthIndicator indicator(Status status, Map details) { + return () -> Health.status(status).withDetails(details).build(); + } +} From 9412f66b96dc09d2a7e5dbc6e46f94ab979a2b47 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Sun, 10 May 2026 13:31:23 +0900 Subject: [PATCH 18/19] =?UTF-8?q?feat:=20S3=20ObjectStroage=20=EC=B6=94?= =?UTF-8?q?=EC=83=81=ED=99=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S3 및 MinIO 의존하지 않도록 추상화 하여 추가 --- .../common/storage/ObjectStorageClient.java | 16 ++ .../common/storage/S3ObjectStorageClient.java | 142 ++++++++++++++++++ .../common/storage/StorageException.java | 12 ++ .../stackup/common/storage/StoredObject.java | 9 ++ 4 files changed, 179 insertions(+) create mode 100644 backend/src/main/java/com/stackup/stackup/common/storage/ObjectStorageClient.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/storage/S3ObjectStorageClient.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/storage/StorageException.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/storage/StoredObject.java diff --git a/backend/src/main/java/com/stackup/stackup/common/storage/ObjectStorageClient.java b/backend/src/main/java/com/stackup/stackup/common/storage/ObjectStorageClient.java new file mode 100644 index 00000000..b9ff699a --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/storage/ObjectStorageClient.java @@ -0,0 +1,16 @@ +package com.stackup.stackup.common.storage; + +import java.io.InputStream; +import java.net.URI; +import java.time.Duration; + +public interface ObjectStorageClient { + + StoredObject put(String key, InputStream content, long size, String contentType); + + InputStream get(String key); + + void delete(String key); + + URI createPresignedGetUrl(String key, Duration ttl); +} diff --git a/backend/src/main/java/com/stackup/stackup/common/storage/S3ObjectStorageClient.java b/backend/src/main/java/com/stackup/stackup/common/storage/S3ObjectStorageClient.java new file mode 100644 index 00000000..0e37549a --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/storage/S3ObjectStorageClient.java @@ -0,0 +1,142 @@ +package com.stackup.stackup.common.storage; + +import com.stackup.stackup.common.config.properties.S3Properties; +import jakarta.annotation.PreDestroy; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Duration; +import java.util.Objects; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.core.exception.SdkException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Component +public class S3ObjectStorageClient implements ObjectStorageClient { + + private final S3Properties properties; + private final S3Client s3Client; + private final S3Presigner s3Presigner; + + public S3ObjectStorageClient(S3Properties properties) { + this.properties = properties; + StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create( + AwsBasicCredentials.create(properties.accessKey(), properties.secretKey()) + ); + this.s3Client = S3Client.builder() + .endpointOverride(properties.endpoint()) + .region(Region.of(properties.region())) + .credentialsProvider(credentialsProvider) + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(properties.pathStyle()) + .build()) + .build(); + this.s3Presigner = S3Presigner.builder() + .endpointOverride(properties.endpoint()) + .region(Region.of(properties.region())) + .credentialsProvider(credentialsProvider) + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(properties.pathStyle()) + .build()) + .build(); + } + + @Override + public StoredObject put(String key, InputStream content, long size, String contentType) { + requireKey(key); + Objects.requireNonNull(content, "content must not be null"); + try { + PutObjectRequest.Builder requestBuilder = PutObjectRequest.builder() + .bucket(properties.bucket()) + .key(key) + .contentLength(size); + if (hasText(contentType)) { + requestBuilder.contentType(contentType); + } + s3Client.putObject(requestBuilder.build(), RequestBody.fromInputStream(content, size)); + return new StoredObject(properties.bucket(), key, size, contentType); + } catch (SdkException e) { + throw new StorageException("Failed to upload object to S3", e); + } + } + + @Override + public InputStream get(String key) { + requireKey(key); + try { + ResponseInputStream response = s3Client.getObject( + GetObjectRequest.builder() + .bucket(properties.bucket()) + .key(key) + .build() + ); + return response; + } catch (SdkException e) { + throw new StorageException("Failed to download object from S3", e); + } + } + + @Override + public void delete(String key) { + requireKey(key); + try { + s3Client.deleteObject( + DeleteObjectRequest.builder() + .bucket(properties.bucket()) + .key(key) + .build() + ); + } catch (SdkException e) { + throw new StorageException("Failed to delete object from S3", e); + } + } + + @Override + public URI createPresignedGetUrl(String key, Duration ttl) { + requireKey(key); + Objects.requireNonNull(ttl, "ttl must not be null"); + try { + PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject( + GetObjectPresignRequest.builder() + .signatureDuration(ttl) + .getObjectRequest(GetObjectRequest.builder() + .bucket(properties.bucket()) + .key(key) + .build()) + .build() + ); + return presignedRequest.url().toURI(); + } catch (SdkException | URISyntaxException e) { + throw new StorageException("Failed to create presigned URL for S3 object", e); + } + } + + @PreDestroy + public void close() { + s3Client.close(); + s3Presigner.close(); + } + + private static void requireKey(String key) { + if (!hasText(key)) { + throw new StorageException("Object key must not be blank"); + } + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/storage/StorageException.java b/backend/src/main/java/com/stackup/stackup/common/storage/StorageException.java new file mode 100644 index 00000000..4a7fab09 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/storage/StorageException.java @@ -0,0 +1,12 @@ +package com.stackup.stackup.common.storage; + +public class StorageException extends RuntimeException { + + public StorageException(String message) { + super(message); + } + + public StorageException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/storage/StoredObject.java b/backend/src/main/java/com/stackup/stackup/common/storage/StoredObject.java new file mode 100644 index 00000000..feee58e9 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/storage/StoredObject.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.common.storage; + +public record StoredObject( + String bucket, + String key, + long size, + String contentType +) { +} From 3362fa0efa81b1e29055fd89c88a1e652e6e4c05 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Wed, 13 May 2026 01:23:42 +0900 Subject: [PATCH 19/19] =?UTF-8?q?refactor:=20RabbitMQ=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EB=B3=80=EC=88=98=EB=93=A4=20yml=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit application: rabbit MQ message관련 정의 apllicaion-local: Rabbit MQ 접속을 위한 값 --- .../config/properties/RabbitMqProperties.java | 77 +++++++++++++++++++ .../messaging/RabbitMessagePublisher.java | 21 +++-- .../common/messaging/RabbitMqConfig.java | 45 +++++++---- .../stackup/common/messaging/RoutingKeys.java | 17 ---- backend/src/main/resources/application.yml | 32 ++++++++ .../messaging/RabbitMessagePublisherTest.java | 47 +++++++++-- 6 files changed, 188 insertions(+), 51 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java delete mode 100644 backend/src/main/java/com/stackup/stackup/common/messaging/RoutingKeys.java diff --git a/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java b/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java new file mode 100644 index 00000000..f82438d2 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java @@ -0,0 +1,77 @@ +package com.stackup.stackup.common.config.properties; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Validated +@ConfigurationProperties(prefix = "app.messaging.rabbitmq") +public record RabbitMqProperties( + @NotBlank String publisher, + @NotBlank String version, + @NotNull + Message message, + @NotNull + Template template, + @NotNull + Exchanges exchanges, + @NotNull + Queues queues, + @NotNull + RoutingKeyProperties routingKeys +) { + + public record Message( + @NotBlank String contentType, + @NotBlank String contentEncoding, + @NotBlank String traceIdHeader + ) { + } + + public record Template( + boolean mandatory + ) { + } + + public record Exchanges( + boolean durable, + boolean autoDelete, + @NotNull + Names names + ) { + + public record Names( + @NotBlank String coreToAi, + @NotBlank String aiToCore + ) { + } + } + + public record Queues( + boolean durable, + @NotNull + Names names + ) { + + public record Names( + @NotBlank String aiAnalyzeResume, + @NotBlank String aiAnalyzeRepository, + @NotBlank String aiGenerateQuestions, + @NotBlank String aiGenerateFollowup, + @NotBlank String coreCallbackAnalysis, + @NotBlank String coreCallbackQuestions + ) { + } + } + + public record RoutingKeyProperties( + @NotBlank String analyzeResume, + @NotBlank String analyzeRepository, + @NotBlank String generateQuestions, + @NotBlank String generateFollowup, + @NotBlank String callbackAnalysis, + @NotBlank String callbackQuestions + ) { + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMessagePublisher.java b/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMessagePublisher.java index ca9404d6..56876f4d 100644 --- a/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMessagePublisher.java +++ b/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMessagePublisher.java @@ -1,7 +1,7 @@ package com.stackup.stackup.common.messaging; +import com.stackup.stackup.common.config.properties.RabbitMqProperties; import com.stackup.stackup.common.trace.TraceContext; -import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.UUID; import org.springframework.amqp.core.MessagePostProcessor; @@ -11,12 +11,11 @@ @Component public class RabbitMessagePublisher { - private static final String VERSION = "v1"; - private static final String PUBLISHER = "core-server"; - + private final RabbitMqProperties properties; private final RabbitTemplate rabbitTemplate; - public RabbitMessagePublisher(RabbitTemplate rabbitTemplate) { + public RabbitMessagePublisher(RabbitMqProperties properties, RabbitTemplate rabbitTemplate) { + this.properties = properties; this.rabbitTemplate = rabbitTemplate; } @@ -24,16 +23,16 @@ public MessageEnvelope publishToAi(String routingKey, T payload, MessageC MessageEnvelope envelope = new MessageEnvelope<>( UUID.randomUUID().toString(), routingKey, - VERSION, + properties.version(), TraceContext.getTraceId(), Instant.now(), - PUBLISHER, + properties.publisher(), payload, context == null ? MessageContext.empty() : context ); rabbitTemplate.convertAndSend( - RoutingKeys.CORE_TO_AI_EXCHANGE, + properties.exchanges().names().coreToAi(), routingKey, envelope, withEnvelopeHeaders(envelope) @@ -43,11 +42,11 @@ public MessageEnvelope publishToAi(String routingKey, T payload, MessageC private MessagePostProcessor withEnvelopeHeaders(MessageEnvelope envelope) { return message -> { - message.getMessageProperties().setContentType("application/json"); - message.getMessageProperties().setContentEncoding(StandardCharsets.UTF_8.name()); + message.getMessageProperties().setContentType(properties.message().contentType()); + message.getMessageProperties().setContentEncoding(properties.message().contentEncoding()); message.getMessageProperties().setMessageId(envelope.messageId()); message.getMessageProperties().setCorrelationId(envelope.messageId()); - message.getMessageProperties().setHeader("x-trace-id", envelope.traceId()); + message.getMessageProperties().setHeader(properties.message().traceIdHeader(), envelope.traceId()); return message; }; } diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java b/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java index 6ed91223..1498a1d1 100644 --- a/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java +++ b/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java @@ -1,5 +1,6 @@ package com.stackup.stackup.common.messaging; +import com.stackup.stackup.common.config.properties.RabbitMqProperties; import org.springframework.amqp.core.BindingBuilder; import org.springframework.amqp.core.Declarables; import org.springframework.amqp.core.Queue; @@ -14,44 +15,58 @@ @Configuration public class RabbitMqConfig { + private final RabbitMqProperties properties; + + public RabbitMqConfig(RabbitMqProperties properties) { + this.properties = properties; + } + @Bean public TopicExchange coreToAiExchange() { - return new TopicExchange(RoutingKeys.CORE_TO_AI_EXCHANGE, true, false); + return new TopicExchange( + properties.exchanges().names().coreToAi(), + properties.exchanges().durable(), + properties.exchanges().autoDelete() + ); } @Bean public TopicExchange aiToCoreExchange() { - return new TopicExchange(RoutingKeys.AI_TO_CORE_EXCHANGE, true, false); + return new TopicExchange( + properties.exchanges().names().aiToCore(), + properties.exchanges().durable(), + properties.exchanges().autoDelete() + ); } @Bean public Queue aiAnalyzeResumeQueue() { - return new Queue("ai.analyze.resume", true); + return new Queue(properties.queues().names().aiAnalyzeResume(), properties.queues().durable()); } @Bean public Queue aiAnalyzeRepositoryQueue() { - return new Queue("ai.analyze.repository", true); + return new Queue(properties.queues().names().aiAnalyzeRepository(), properties.queues().durable()); } @Bean public Queue aiGenerateQuestionsQueue() { - return new Queue("ai.generate.questions", true); + return new Queue(properties.queues().names().aiGenerateQuestions(), properties.queues().durable()); } @Bean public Queue aiGenerateFollowupQueue() { - return new Queue("ai.generate.followup", true); + return new Queue(properties.queues().names().aiGenerateFollowup(), properties.queues().durable()); } @Bean public Queue coreCallbackAnalysisQueue() { - return new Queue("core.callback.analysis", true); + return new Queue(properties.queues().names().coreCallbackAnalysis(), properties.queues().durable()); } @Bean public Queue coreCallbackQuestionsQueue() { - return new Queue("core.callback.questions", true); + return new Queue(properties.queues().names().coreCallbackQuestions(), properties.queues().durable()); } @Bean @@ -74,12 +89,12 @@ public Declarables rabbitDeclarables( aiGenerateFollowupQueue, coreCallbackAnalysisQueue, coreCallbackQuestionsQueue, - BindingBuilder.bind(aiAnalyzeResumeQueue).to(coreToAiExchange).with(RoutingKeys.ANALYZE_RESUME), - BindingBuilder.bind(aiAnalyzeRepositoryQueue).to(coreToAiExchange).with(RoutingKeys.ANALYZE_REPOSITORY), - BindingBuilder.bind(aiGenerateQuestionsQueue).to(coreToAiExchange).with(RoutingKeys.GENERATE_QUESTIONS), - BindingBuilder.bind(aiGenerateFollowupQueue).to(coreToAiExchange).with(RoutingKeys.GENERATE_FOLLOWUP), - BindingBuilder.bind(coreCallbackAnalysisQueue).to(aiToCoreExchange).with(RoutingKeys.CALLBACK_ANALYSIS), - BindingBuilder.bind(coreCallbackQuestionsQueue).to(aiToCoreExchange).with(RoutingKeys.CALLBACK_QUESTIONS) + BindingBuilder.bind(aiAnalyzeResumeQueue).to(coreToAiExchange).with(properties.routingKeys().analyzeResume()), + BindingBuilder.bind(aiAnalyzeRepositoryQueue).to(coreToAiExchange).with(properties.routingKeys().analyzeRepository()), + BindingBuilder.bind(aiGenerateQuestionsQueue).to(coreToAiExchange).with(properties.routingKeys().generateQuestions()), + BindingBuilder.bind(aiGenerateFollowupQueue).to(coreToAiExchange).with(properties.routingKeys().generateFollowup()), + BindingBuilder.bind(coreCallbackAnalysisQueue).to(aiToCoreExchange).with(properties.routingKeys().callbackAnalysis()), + BindingBuilder.bind(coreCallbackQuestionsQueue).to(aiToCoreExchange).with(properties.routingKeys().callbackQuestions()) ); } @@ -92,7 +107,7 @@ public MessageConverter messageConverter() { public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory, MessageConverter messageConverter) { RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory); rabbitTemplate.setMessageConverter(messageConverter); - rabbitTemplate.setMandatory(true); + rabbitTemplate.setMandatory(properties.template().mandatory()); return rabbitTemplate; } } diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/RoutingKeys.java b/backend/src/main/java/com/stackup/stackup/common/messaging/RoutingKeys.java deleted file mode 100644 index 2f1cfb00..00000000 --- a/backend/src/main/java/com/stackup/stackup/common/messaging/RoutingKeys.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.stackup.stackup.common.messaging; - -public final class RoutingKeys { - - public static final String CORE_TO_AI_EXCHANGE = "stackup.core-to-ai"; - public static final String AI_TO_CORE_EXCHANGE = "stackup.ai-to-core"; - - public static final String ANALYZE_RESUME = "analyze.resume"; - public static final String ANALYZE_REPOSITORY = "analyze.repository"; - public static final String GENERATE_QUESTIONS = "generate.questions"; - public static final String GENERATE_FOLLOWUP = "generate.followup"; - public static final String CALLBACK_ANALYSIS = "callback.analysis"; - public static final String CALLBACK_QUESTIONS = "callback.questions"; - - private RoutingKeys() { - } -} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 0504c898..b4b34973 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -48,6 +48,38 @@ app: bucket: ${S3_BUCKET:stackup} region: ${S3_REGION:us-east-1} path-style: ${S3_PATH_STYLE:true} + messaging: + rabbitmq: + publisher: core-server + version: v1 + message: + content-type: application/json + content-encoding: UTF-8 + trace-id-header: x-trace-id + template: + mandatory: true + exchanges: + durable: true + auto-delete: false + names: + core-to-ai: stackup.core-to-ai + ai-to-core: stackup.ai-to-core + queues: + durable: true + names: + ai-analyze-resume: ai.analyze.resume + ai-analyze-repository: ai.analyze.repository + ai-generate-questions: ai.generate.questions + ai-generate-followup: ai.generate.followup + core-callback-analysis: core.callback.analysis + core-callback-questions: core.callback.questions + routing-keys: + analyze-resume: analyze.resume + analyze-repository: analyze.repository + generate-questions: generate.questions + generate-followup: generate.followup + callback-analysis: callback.analysis + callback-questions: callback.questions sse: timeout: 30m keep-alive-interval: 30s diff --git a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java index 9cf9ee27..8d602d07 100644 --- a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java +++ b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java @@ -1,8 +1,6 @@ package com.stackup.stackup.common.messaging; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; @@ -12,12 +10,12 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.amqp.core.Message; import org.springframework.amqp.core.MessageProperties; import org.springframework.amqp.rabbit.core.RabbitTemplate; +import com.stackup.stackup.common.config.properties.RabbitMqProperties; import com.stackup.stackup.common.trace.TraceContext; @ExtendWith(MockitoExtension.class) @@ -26,12 +24,12 @@ class RabbitMessagePublisherTest { @Mock private RabbitTemplate rabbitTemplate; - @InjectMocks private RabbitMessagePublisher rabbitMessagePublisher; @BeforeEach void setUp() { TraceContext.clear(); + rabbitMessagePublisher = new RabbitMessagePublisher(rabbitMqProperties(), rabbitTemplate); } @Test @@ -39,7 +37,7 @@ void publishToAi_setsEnvelopeMetadataAndRabbitHeaders() { TraceContext.setTraceId("trace-123"); MessageEnvelope> envelope = rabbitMessagePublisher.publishToAi( - RoutingKeys.ANALYZE_RESUME, + "analyze.resume", Map.of("resumeId", 1L), MessageContext.ofUser(99L) ); @@ -50,14 +48,14 @@ void publishToAi_setsEnvelopeMetadataAndRabbitHeaders() { ArgumentCaptor.forClass(org.springframework.amqp.core.MessagePostProcessor.class); verify(rabbitTemplate).convertAndSend( - eq(RoutingKeys.CORE_TO_AI_EXCHANGE), - eq(RoutingKeys.ANALYZE_RESUME), + eq("stackup.core-to-ai"), + eq("analyze.resume"), payloadCaptor.capture(), postProcessorCaptor.capture() ); assertThat(payloadCaptor.getValue()).isEqualTo(envelope); - assertThat(envelope.messageType()).isEqualTo(RoutingKeys.ANALYZE_RESUME); + assertThat(envelope.messageType()).isEqualTo("analyze.resume"); assertThat(envelope.version()).isEqualTo("v1"); assertThat(envelope.traceId()).isEqualTo("trace-123"); assertThat(envelope.publishedAt()).isNotNull(); @@ -74,4 +72,37 @@ void publishToAi_setsEnvelopeMetadataAndRabbitHeaders() { assertThat(message.getMessageProperties().getCorrelationId()).isEqualTo(envelope.messageId()); assertThat(message.getMessageProperties().getHeaders()).containsEntry("x-trace-id", "trace-123"); } + + private RabbitMqProperties rabbitMqProperties() { + return new RabbitMqProperties( + "core-server", + "v1", + new RabbitMqProperties.Message("application/json", StandardCharsets.UTF_8.name(), "x-trace-id"), + new RabbitMqProperties.Template(true), + new RabbitMqProperties.Exchanges( + true, + false, + new RabbitMqProperties.Exchanges.Names("stackup.core-to-ai", "stackup.ai-to-core") + ), + new RabbitMqProperties.Queues( + true, + new RabbitMqProperties.Queues.Names( + "ai.analyze.resume", + "ai.analyze.repository", + "ai.generate.questions", + "ai.generate.followup", + "core.callback.analysis", + "core.callback.questions" + ) + ), + new RabbitMqProperties.RoutingKeyProperties( + "analyze.resume", + "analyze.repository", + "generate.questions", + "generate.followup", + "callback.analysis", + "callback.questions" + ) + ); + } }