diff --git a/.github/ISSUE_TEMPLATE/01. main-issue.yml b/.github/ISSUE_TEMPLATE/01. main-issue.yml index 6f55e66..43a3d7d 100644 --- a/.github/ISSUE_TEMPLATE/01. main-issue.yml +++ b/.github/ISSUE_TEMPLATE/01. main-issue.yml @@ -1,6 +1,6 @@ name: Main Issue description: Describe the task list. -title: "[MAIN] " +title: "β˜€οΈ " #labels: [] #projects: [] #assignees: @@ -9,7 +9,7 @@ body: - type: markdown attributes: value: | - Thanks for taking the time to fill out this bug report! + Thanks for taking the time to fill out this issue! # - type: input # id: contact # attributes: diff --git a/.github/ISSUE_TEMPLATE/03. bug-report.yml b/.github/ISSUE_TEMPLATE/03. bug-report.yml new file mode 100644 index 0000000..d445a79 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/03. bug-report.yml @@ -0,0 +1,93 @@ +name: Bug Report +description: Describe the bug you encountered. +title: "🐞 " +labels: ["type: bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: dropdown + id: bug-type + attributes: + label: 🐞 Bug Types (Multiple) + description: | + μ–΄λ–€ μ’…λ₯˜μ˜ λ¬Έμ œμΈκ°€μš”? + + - 🐞 였λ₯˜ (μ—λŸ¬, μ˜ˆμ™Έ λ°œμƒ, 논리적 였λ₯˜ λ“±) + - πŸ— μ»¨λ²€μ…˜Β·λ¦¬νŒ©ν„°λ§ (μ½”λ“œ μŠ€νƒ€μΌ, μ•„ν‚€ν…μ²˜ λ“±) + - ⚑️ μ„±λŠ₯ (μ§€μ—°, κ³Όλ„ν•œ μžμ›, 병λͺ© ν˜„μƒ λ“±) + - ✏️ μ˜€νƒ€Β·μ„€λͺ… 였λ₯˜ (λ¬Έμ„œ, λ¬Έμžμ—΄, 주석, μ‹λ³„μž λ“±) + - πŸ”§ 기타 (λΉŒλ“œ, ν™˜κ²½λ³€μˆ˜ λ“±) + + **❗️ λ³΄μ•ˆ 취약점은 pkw19961027@gmail.com으둜 메일 λΆ€νƒλ“œλ¦½λ‹ˆλ‹€.** + +
\* 닀쀑 선택 + multiple: true + options: + - 🐞 였λ₯˜ + - πŸ— μ»¨λ²€μ…˜Β·λ¦¬νŒ©ν„°λ§ + - ⚑️ μ„±λŠ₯ + - ✏️ μ˜€νƒ€Β·μ„€λͺ… 였λ₯˜ + - πŸ”§ 기타 + default: 0 + validations: + required: true + - type: input + id: summary + attributes: + label:
πŸ’¬ Summary of the Bug + description: 버그λ₯Ό ν•œ λ¬Έμž₯ μ •λ„λ‘œ μš”μ•½ν•΄ μ£Όμ„Έμš”. + placeholder: ex. 둜그인 ν›„ 화면이 멈μΆ₯λ‹ˆλ‹€. + validations: + required: true + - type: textarea + id: steps-to-reproduce + attributes: + label:
πŸ” 였λ₯˜ μž¬ν˜„ (Reproduction Steps) + description: 버그λ₯Ό μž¬ν˜„ν•˜κΈ° μœ„ν•œ ꡬ체적인 단계듀을 μˆœμ„œλŒ€λ‘œ 적어 μ£Όμ„Έμš”. + placeholder: | + 1. + 2. + 3. + + validations: + required: false + - type: textarea + id: logs + attributes: + label:
πŸ“ κ΄€λ ¨ 둜그 (Logs) + description: μ—λŸ¬ λ©”μ‹œμ§€λ‚˜ λ‘œκ·Έκ°€ μžˆλ‹€λ©΄ λ³΅μ‚¬ν•΄μ„œ λΆ™μ—¬ μ£Όμ„Έμš”. + placeholder: | + Error: Cannot read property 'foo' of undefined + at … + render: console + validations: + required: false + - type: textarea + id: expected-behavior + attributes: + label:
🎯 κΈ°λŒ€ λ™μž‘ (Expected Behavior) + description: μ •μƒμ μœΌλ‘œ λ™μž‘ν–ˆμ„ λ•Œ μ–΄λ–€ κ²°κ³Όκ°€ λ‚˜μ™€μ•Ό ν•˜λŠ”μ§€ μ„€λͺ…ν•΄ μ£Όμ„Έμš”. + placeholder: ex. 둜그인 ν›„ λŒ€μ‹œλ³΄λ“œ νŽ˜μ΄μ§€κ°€ 보여야 ν•©λ‹ˆλ‹€. + validations: + required: true + - type: textarea + id: actual-behavior + attributes: + label:
πŸ” μ‹€μ œ λ™μž‘ (Actual Behavior) + description: μ–΄λ–€ λ™μž‘μ΄ λ‚˜νƒ€λ‚˜λŠ”μ§€ μ„€λͺ…ν•΄ μ£Όμ„Έμš”. + placeholder: ex. 둜그인 ν›„ λ²„νŠΌμ΄ λ°˜μ‘ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. + validations: + required: true + - type: textarea + id: additional-note + attributes: + label:
λΆ€μ—° μ„€λͺ… (Additional Notes) + description: κ΄€λ ¨ λΈŒλžœμΉ˜λ‚˜ 컀밋, Active profile, ν™˜κ²½λ³€μˆ˜ λ“± 기타 μ°Έκ³ ν•  λ§Œν•œ 정보가 있으면 적어 μ£Όμ„Έμš”. + placeholder: Tell us any other details you think will help us fix this! + value: | + + + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 6ce1225..2913818 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,5 +1,3 @@ -# Pull Request - ## Issues - Resolves #0 @@ -11,12 +9,26 @@ - μ£Όμš” λ³€κ²½ 사항에 λŒ€ν•œ κ°„λ‹¨ν•œ μ„€λͺ…을 μž‘μ„±ν•΄ μ£Όμ„Έμš”. - κ΄€λ ¨ 이슈 번호λ₯Ό 포함해 μ£Όμ„Έμš” (예: `#123`). +
+ +## Review Points + + + + +- μƒμ„Έν•œ 리뷰λ₯Ό μ›ν•˜λŠ” μ˜μ—­μ€ μž‘μ—… μ˜λ„μ™€ ν•¨κ»˜ μ„€λͺ…ν•΄ μ£Όμ„Έμš”. + +
+ ## How Has This Been Tested? - λ³€κ²½ 사항을 ν…ŒμŠ€νŠΈν•˜λŠ” 방법에 λŒ€ν•΄ μ„€λͺ…ν•΄ μ£Όμ„Έμš”. - μ–΄λ–€ ν™˜κ²½μ—μ„œ ν…ŒμŠ€νŠΈκ°€ μ΄λ£¨μ–΄μ‘ŒλŠ”μ§€ λͺ…μ‹œν•΄ μ£Όμ„Έμš”. + + +
+ ## Additional Notes - 이 PRκ³Ό κ΄€λ ¨λœ 좔가적인 정보가 μžˆλ‹€λ©΄ 여기에 κΈ°μž¬ν•΄ μ£Όμ„Έμš”. - diff --git a/build.gradle.kts b/build.gradle.kts index dcc14bb..1a3bcbc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,9 +10,6 @@ plugins { } allprojects { - group = "me.nettee" - version = "1.0-SNAPSHOT" - repositories { mavenCentral() } @@ -34,7 +31,18 @@ subprojects { } } + configurations { + compileOnly { + extendsFrom(configurations.annotationProcessor.get()) + } + configureEach { + exclude(group = "org.springframework.boot", module = "spring-boot-starter-logging") + } + } + dependencies { + // FIXME determine the placement of logging library later + implementation("org.springframework.boot:spring-boot-starter-log4j2") if(project.name != "common") { api(project(":common")) } diff --git a/core/core.settings.gradle.kts b/core/core.settings.gradle.kts index 242a7ed..da76ea8 100644 --- a/core/core.settings.gradle.kts +++ b/core/core.settings.gradle.kts @@ -9,10 +9,14 @@ include( ":jpa-core", ":exception-handler-core", ":cors-api", - ":cors-webmvc" + ":cors-webmvc", + ":snowflake-id-api", + ":snowflake-id-hibernate", ) project(":jpa-core").projectDir = core["jpa-core"]!! project(":exception-handler-core").projectDir = core["exception-handler-core"]!! project(":cors-webmvc").projectDir = core["nettee-cors-webmvc"]!! -project(":cors-api").projectDir = core["nettee-cors-api"]!! \ No newline at end of file +project(":cors-api").projectDir = core["nettee-cors-api"]!! +project(":snowflake-id-api").projectDir = core["nettee-snowflake-id-api"]!! +project(":snowflake-id-hibernate").projectDir = core["nettee-snowflake-id-hibernate"]!! \ No newline at end of file diff --git a/core/jpa-core/build.gradle.kts b/core/jpa-core/build.gradle.kts index 4af48f6..db413e1 100644 --- a/core/jpa-core/build.gradle.kts +++ b/core/jpa-core/build.gradle.kts @@ -1,5 +1,6 @@ dependencies { - implementation("org.springframework.boot:spring-boot-starter-data-jpa") + api(project(":snowflake-id-hibernate")) + api("org.springframework.boot:spring-boot-starter-data-jpa") // querydsl implementation("com.querydsl:querydsl-jpa:${dependencyManagement.importedProperties["querydsl.version"]}:jakarta") diff --git a/core/jpa-core/src/main/java/nettee/jpa/support/SnowflakeBaseEntity.java b/core/jpa-core/src/main/java/nettee/jpa/support/SnowflakeBaseEntity.java new file mode 100644 index 0000000..a23b765 --- /dev/null +++ b/core/jpa-core/src/main/java/nettee/jpa/support/SnowflakeBaseEntity.java @@ -0,0 +1,16 @@ +package nettee.jpa.support; + +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import nettee.hibenate.annotation.SnowflakeGenerated; + +import java.io.Serializable; + +@Getter +@MappedSuperclass +public abstract class SnowflakeBaseEntity implements Serializable { + @Id + @SnowflakeGenerated + private Long id; +} diff --git a/core/jpa-core/src/main/java/nettee/jpa/support/SnowflakeBaseTimeEntity.java b/core/jpa-core/src/main/java/nettee/jpa/support/SnowflakeBaseTimeEntity.java new file mode 100644 index 0000000..150c6aa --- /dev/null +++ b/core/jpa-core/src/main/java/nettee/jpa/support/SnowflakeBaseTimeEntity.java @@ -0,0 +1,21 @@ +package nettee.jpa.support; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.Instant; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class SnowflakeBaseTimeEntity extends SnowflakeBaseEntity { + @CreatedDate + private Instant createdAt; + + @LastModifiedDate + private Instant updatedAt; +} diff --git a/core/snowflake/nettee-snowflake-id-api/build.gradle.kts b/core/snowflake/nettee-snowflake-id-api/build.gradle.kts new file mode 100644 index 0000000..1355541 --- /dev/null +++ b/core/snowflake/nettee-snowflake-id-api/build.gradle.kts @@ -0,0 +1,3 @@ +dependencies { + compileOnly("org.springframework.boot:spring-boot-autoconfigure:3.4.3") +} \ No newline at end of file diff --git a/core/snowflake/nettee-snowflake-id-api/src/main/java/nettee/snowflake/constants/SnowflakeConstants.java b/core/snowflake/nettee-snowflake-id-api/src/main/java/nettee/snowflake/constants/SnowflakeConstants.java new file mode 100644 index 0000000..6c06c2b --- /dev/null +++ b/core/snowflake/nettee-snowflake-id-api/src/main/java/nettee/snowflake/constants/SnowflakeConstants.java @@ -0,0 +1,25 @@ +package nettee.snowflake.constants; + +public final class SnowflakeConstants { + // ν•œκ΅­ μ‹œκ°„(KST): 2025-03-26 23:40:00 기쀀점 + public static final long NETTEE_EPOCH = 1_743_000_000_000L; + public static final String PREFIX = "nettee.persistence.snowflake"; + + private SnowflakeConstants() {} + + public static final class SnowflakeDefault { + public static final int WORKER_ID_BIT_SIZE = 5; + public static final int DATACENTER_ID_BIT_SIZE = 5; + public static final int SEQUENCE_BIT_SIZE = 12; + + public static final int WORKER_ID_SHIFT = SEQUENCE_BIT_SIZE; + public static final int DATACENTER_ID_SHIFT = SEQUENCE_BIT_SIZE + WORKER_ID_BIT_SIZE; + public static final int TIMESTAMP_LEFT_SHIFT = SEQUENCE_BIT_SIZE + WORKER_ID_BIT_SIZE + DATACENTER_ID_BIT_SIZE; + + public static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BIT_SIZE); + public static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BIT_SIZE); + public static final long MAX_DATACENTER_ID = ~(-1L << DATACENTER_ID_BIT_SIZE); + + private SnowflakeDefault() {} + } +} diff --git a/core/snowflake/nettee-snowflake-id-api/src/main/java/nettee/snowflake/exception/InvalidDatacenterIdException.java b/core/snowflake/nettee-snowflake-id-api/src/main/java/nettee/snowflake/exception/InvalidDatacenterIdException.java new file mode 100644 index 0000000..2877234 --- /dev/null +++ b/core/snowflake/nettee-snowflake-id-api/src/main/java/nettee/snowflake/exception/InvalidDatacenterIdException.java @@ -0,0 +1,18 @@ +package nettee.snowflake.exception; + +import static nettee.snowflake.constants.SnowflakeConstants.SnowflakeDefault.MAX_DATACENTER_ID; + +public class InvalidDatacenterIdException extends RuntimeException { + + private final long datacenterId; + + public InvalidDatacenterIdException(long datacenterId) { + super("Datacenter ID can't be greater than %d or less than 0. Input: %d" + .formatted(MAX_DATACENTER_ID, datacenterId)); + this.datacenterId = datacenterId; + } + + public long getDatacenterId() { + return datacenterId; + } +} diff --git a/core/snowflake/nettee-snowflake-id-api/src/main/java/nettee/snowflake/exception/InvalidWorkerIdException.java b/core/snowflake/nettee-snowflake-id-api/src/main/java/nettee/snowflake/exception/InvalidWorkerIdException.java new file mode 100644 index 0000000..68590f8 --- /dev/null +++ b/core/snowflake/nettee-snowflake-id-api/src/main/java/nettee/snowflake/exception/InvalidWorkerIdException.java @@ -0,0 +1,18 @@ +package nettee.snowflake.exception; + +import static nettee.snowflake.constants.SnowflakeConstants.SnowflakeDefault.MAX_WORKER_ID; + +public class InvalidWorkerIdException extends RuntimeException{ + + private final long workerId; + + public InvalidWorkerIdException(final long workerId) { + super("Worker ID can't be greater than %d or less than 0. Input: %d" + .formatted(MAX_WORKER_ID, workerId)); + this.workerId = workerId; + } + + public long getWorkerId() { + return workerId; + } +} diff --git a/core/snowflake/nettee-snowflake-id-api/src/main/java/nettee/snowflake/persistence/id/Snowflake.java b/core/snowflake/nettee-snowflake-id-api/src/main/java/nettee/snowflake/persistence/id/Snowflake.java new file mode 100644 index 0000000..e7e58f4 --- /dev/null +++ b/core/snowflake/nettee-snowflake-id-api/src/main/java/nettee/snowflake/persistence/id/Snowflake.java @@ -0,0 +1,66 @@ +package nettee.snowflake.persistence.id; + +import nettee.snowflake.properties.SnowflakeProperties; +import nettee.snowflake.validator.SnowflakeConstructingValidator; + +import static nettee.snowflake.constants.SnowflakeConstants.NETTEE_EPOCH; +import static nettee.snowflake.constants.SnowflakeConstants.SnowflakeDefault.*; + +public class Snowflake { + private final long datacenterId; + private final long workerId; + private final long epoch; + + private long sequence = 0L; + private long lastTimestamp = -1L; + + public Snowflake(SnowflakeProperties properties) { + this(properties.datacenterId(), properties.workerId(), properties.epoch()); + } + + public Snowflake(long datacenterId, long workerId, long epoch) { + SnowflakeConstructingValidator.validateDatacenterId(datacenterId); + SnowflakeConstructingValidator.validateWorkerId(workerId); + + this.workerId = workerId; + this.datacenterId = datacenterId; + this.epoch = epoch >= 0 ? epoch : NETTEE_EPOCH; + } + + public synchronized long nextId() { + long timestamp = timeGen(); + + if (timestamp < lastTimestamp) { + throw new RuntimeException(String.format( + "Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp + )); + } + + if (lastTimestamp == timestamp) { + sequence = (sequence + 1) & SEQUENCE_MASK; + if (sequence == 0) { + timestamp = tilNextMillis(lastTimestamp); + } + } else { + sequence = 0L; + } + + lastTimestamp = timestamp; + return ((timestamp - epoch) << TIMESTAMP_LEFT_SHIFT) | + (datacenterId << DATACENTER_ID_SHIFT) | + (workerId << WORKER_ID_SHIFT) | + sequence; + } + + private long tilNextMillis(long lastTimestamp) { + long timestamp = timeGen(); + while (timestamp <= lastTimestamp) { + timestamp = timeGen(); + } + return timestamp; + } + + private long timeGen() { + return System.currentTimeMillis(); + } +} diff --git a/core/snowflake/nettee-snowflake-id-api/src/main/java/nettee/snowflake/properties/SnowflakeProperties.java b/core/snowflake/nettee-snowflake-id-api/src/main/java/nettee/snowflake/properties/SnowflakeProperties.java new file mode 100644 index 0000000..1d65f9a --- /dev/null +++ b/core/snowflake/nettee-snowflake-id-api/src/main/java/nettee/snowflake/properties/SnowflakeProperties.java @@ -0,0 +1,26 @@ +package nettee.snowflake.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.Objects; + +import static nettee.snowflake.constants.SnowflakeConstants.NETTEE_EPOCH; +import static nettee.snowflake.constants.SnowflakeConstants.PREFIX; + +@ConfigurationProperties(PREFIX) +public record SnowflakeProperties( + Long datacenterId, + Long workerId, + Long epoch +) { + public SnowflakeProperties { + Objects.requireNonNull(datacenterId, PREFIX + ".datacenter-id must not be null."); + Objects.requireNonNull(workerId, PREFIX + ".worker-id must not be null."); + + if (epoch == null) { + epoch = NETTEE_EPOCH; + } else if (epoch < 0) { + epoch = NETTEE_EPOCH; + } + } +} diff --git a/core/snowflake/nettee-snowflake-id-api/src/main/java/nettee/snowflake/validator/SnowflakeConstructingValidator.java b/core/snowflake/nettee-snowflake-id-api/src/main/java/nettee/snowflake/validator/SnowflakeConstructingValidator.java new file mode 100644 index 0000000..1d76114 --- /dev/null +++ b/core/snowflake/nettee-snowflake-id-api/src/main/java/nettee/snowflake/validator/SnowflakeConstructingValidator.java @@ -0,0 +1,23 @@ +package nettee.snowflake.validator; + +import nettee.snowflake.exception.InvalidDatacenterIdException; +import nettee.snowflake.exception.InvalidWorkerIdException; + +import static nettee.snowflake.constants.SnowflakeConstants.SnowflakeDefault.MAX_DATACENTER_ID; +import static nettee.snowflake.constants.SnowflakeConstants.SnowflakeDefault.MAX_WORKER_ID; + +public class SnowflakeConstructingValidator { + private SnowflakeConstructingValidator() {} + + public static void validateDatacenterId(long datacenterId) { + if (datacenterId > MAX_DATACENTER_ID || datacenterId < 0) { + throw new InvalidDatacenterIdException(datacenterId); + } + } + + public static void validateWorkerId(long workerId) { + if (workerId > MAX_WORKER_ID || workerId < 0) { + throw new InvalidWorkerIdException(workerId); + } + } +} diff --git a/core/snowflake/nettee-snowflake-id-hibernate/build.gradle.kts b/core/snowflake/nettee-snowflake-id-hibernate/build.gradle.kts new file mode 100644 index 0000000..7f91fd0 --- /dev/null +++ b/core/snowflake/nettee-snowflake-id-hibernate/build.gradle.kts @@ -0,0 +1,4 @@ +dependencies{ + api(project(":snowflake-id-api")) + compileOnly("org.hibernate.orm:hibernate-core") +} \ No newline at end of file diff --git a/core/snowflake/nettee-snowflake-id-hibernate/src/main/java/nettee/hibenate/annotation/SnowflakeGenerated.java b/core/snowflake/nettee-snowflake-id-hibernate/src/main/java/nettee/hibenate/annotation/SnowflakeGenerated.java new file mode 100644 index 0000000..b2cb4df --- /dev/null +++ b/core/snowflake/nettee-snowflake-id-hibernate/src/main/java/nettee/hibenate/annotation/SnowflakeGenerated.java @@ -0,0 +1,16 @@ +package nettee.hibenate.annotation; + +import nettee.hibenate.generator.SnowflakeIdGenerator; +import org.hibernate.annotations.IdGeneratorType; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Retention(RUNTIME) +@Target(FIELD) +@IdGeneratorType(SnowflakeIdGenerator.class) +public @interface SnowflakeGenerated { +} diff --git a/core/snowflake/nettee-snowflake-id-hibernate/src/main/java/nettee/hibenate/generator/SnowflakeIdGenerator.java b/core/snowflake/nettee-snowflake-id-hibernate/src/main/java/nettee/hibenate/generator/SnowflakeIdGenerator.java new file mode 100644 index 0000000..44af57f --- /dev/null +++ b/core/snowflake/nettee-snowflake-id-hibernate/src/main/java/nettee/hibenate/generator/SnowflakeIdGenerator.java @@ -0,0 +1,20 @@ +package nettee.hibenate.generator; + +import nettee.snowflake.persistence.id.Snowflake; +import nettee.snowflake.properties.SnowflakeProperties; +import org.hibernate.engine.spi.SharedSessionContractImplementor; +import org.hibernate.id.IdentifierGenerator; + +public class SnowflakeIdGenerator implements IdentifierGenerator { + + private final Snowflake snowflake; + + public SnowflakeIdGenerator(SnowflakeProperties snowflakeProperties) { + this.snowflake = new Snowflake(snowflakeProperties); + } + + @Override + public Long generate(SharedSessionContractImplementor sharedSessionContractImplementor, Object o) { + return snowflake.nextId(); + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..1fd9672 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,89 @@ +# ⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜ +# ⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜🟦🟦🟦⬜ +# ⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜🟦🟦🟦🟦 +# ⬜⬜⬜⬜🟦🟦🟦🟦🟦🟦⬜⬜⬜⬜🟦🟦 +# ⬜⬜⬜🟦🟦🟦🟦🟦🟦🟦🟦🟦⬜⬜🟦🟦 Gradle properties are classified with 3 categories. +# ⬜⬜🟦⬜🟦🟦🟦🟦🟦🟦⬜🟦🟦🟦🟦🟦 +# ⬜🟦🟦🟦⬜🟦⬜🟦🟦🟦🟦🟦🟦🟦🟦⬜ 1. System prop +# ⬜🟦🟦🟦🟦⬜🟦🟦🟦🟦🟦🟦🟦🟦🟦⬜ 2. Gradle config prop +# 🟦🟦🟦🟦🟦🟦🟦🟦🟦🟦🟦🟦🟦⬜⬜⬜ 3. Project prop +# 🟦🟦🟦🟦🟦🟦🟦🟦🟦🟦🟦🟦⬜⬜⬜⬜ +# 🟦🟦🟦⬜🟦🟦🟦🟦⬜🟦🟦🟦⬜⬜⬜⬜ +# 🟦🟦⬜⬜⬜🟦🟦⬜⬜⬜🟦🟦⬜⬜⬜⬜ +# ⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜ + +## β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’ +## System Properties +## β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’ + +# λ‚΄μž₯된 μ‹œμŠ€ν…œ κ΄€λ ¨ λ³€μˆ˜λ₯Ό μˆ˜μ •ν•  수 μžˆμŠ΅λ‹ˆλ‹€. (λ“œλ¬Όκ²Œ μ‚¬μš©λ˜λ©°, 일반적으둜 μƒλž΅ν•  수 μžˆμŠ΅λ‹ˆλ‹€.) + +# https.protocols=TLSv1.2,TLSv1.3 +# http.proxyHost=www.somehost.org +# http.proxyPort=8080 +# http.proxyUser=userid +# http.proxyPassword=password +# https.proxyHost=... +# https.proxyPort=... +# https.proxyUser=... +# https.proxyPassword=... +# http.nonProxyHosts=*.nonproxyrepos.com|localhost + +## β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’ +## Gradle Configuration Properties +## β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’ + +org.gradle.parallel=true +org.gradle.caching=true + +## β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’ +## Project Common Properties +## β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’ + +# NOTE project.group에 이 λ‚΄μš©μ΄ λ‹΄κΈ°κΈ° λ•Œλ¬Έμ— allprojects { group = "..." } ν‘œκΈ°λ₯Ό μƒλž΅ν•  수 μžˆμŠ΅λ‹ˆλ‹€. +group=me.nettee +version=0.1.0 + +## β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’ +## Service Modules +## β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’β—€β—’ + +# article +article=:article +articleApi=:article:article-api +articleDomain=:article:article-api:article-domain +articleException=:article:article-api:article-exception +articleReadModel=:article:article-api:article-readmodel +articleApplication=:article:article-application +articleRdbAdapter=:article:article-rdb-adapter +articleWebMvcAdapter=:article:article-webmvc + +# board +board=:board +boardApi=:board:board-api +boardDomain=:board:board-api:board-domain +boardException=:board:board-api:board-exception +boardReadModel=:board:board-api:board-readmodel +boardApplication=:board:board-application +boardRdbAdapter=:board:board-rdb-adapter +boardWebMvcAdapter=:board:board-webmvc + +# comment +comment=:comment +commentApi=:comment:comment-api +commentDomain=:comment:comment-api:comment-domain +commentException=:comment:comment-api:comment-exception +commentReadModel=:comment:comment-api:comment-readmodel +commentApplication=:comment:comment-application +commentRdbAdapter=:comment:comment-rdb-adapter +commentWebMvcAdapter=:comment:comment-webmvc + +# views +views=:views +viewsApi=:views:views-api +viewsDomain=:views:views-api:views-domain +viewsException=:views:views-api:views-exception +viewsReadModel=:views:views-api:views-readmodel +viewsApplication=:views:views-application +viewsRdbAdapter=:views:views-rdb-adapter +viewsWebMvcAdapter=:views:views-webmvc diff --git a/monolith/main-runner/build.gradle.kts b/monolith/main-runner/build.gradle.kts index 0098171..27bf804 100644 --- a/monolith/main-runner/build.gradle.kts +++ b/monolith/main-runner/build.gradle.kts @@ -1,5 +1,8 @@ import org.springframework.boot.gradle.tasks.bundling.BootJar +val board: String by project +val comment: String by project + version = "0.0.1-SNAPSHOT" dependencies { @@ -7,15 +10,22 @@ dependencies { implementation(project(":exception-handler-core")) implementation(project(":jpa-core")) implementation(project(":cors-webmvc")) - // service - implementation(project(":board")) + + // services + implementation(project(board)) + implementation(project(comment)) + // webmvc implementation("org.springframework.boot:spring-boot-starter-web") + // db runtimeOnly("org.postgresql:postgresql:42.7.4") + // flyway implementation("org.flywaydb:flyway-database-postgresql") + // test + testImplementation("com.h2database:h2") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("com.fasterxml.jackson.core:jackson-databind") testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin") diff --git a/monolith/main-runner/src/main/resources/application.yml b/monolith/main-runner/src/main/resources/application.yml index 9fe712b..66934f0 100644 --- a/monolith/main-runner/src/main/resources/application.yml +++ b/monolith/main-runner/src/main/resources/application.yml @@ -4,8 +4,10 @@ spring: config: import: - - properties.web/main.cors.yml + - properties/web/main.cors.yml + - properties/persistence/main.snowflake.yml - board.yml + - comment.yml server: port: 5000 diff --git a/monolith/main-runner/src/main/resources/properties/persistence/main.snowflake.yml b/monolith/main-runner/src/main/resources/properties/persistence/main.snowflake.yml new file mode 100644 index 0000000..b097f61 --- /dev/null +++ b/monolith/main-runner/src/main/resources/properties/persistence/main.snowflake.yml @@ -0,0 +1,3 @@ +nettee.persistence.snowflake: + datacenter-id: ${SNOWFLAKE_DC_ID:0} + worker-id: ${SNOWFLAKE_WORKER_ID:0} \ No newline at end of file diff --git a/monolith/main-runner/src/main/resources/properties.web/main.cors.yml b/monolith/main-runner/src/main/resources/properties/web/main.cors.yml similarity index 100% rename from monolith/main-runner/src/main/resources/properties.web/main.cors.yml rename to monolith/main-runner/src/main/resources/properties/web/main.cors.yml diff --git a/monolith/main-runner/src/test/java/nettee/main/sample/entity/Sample.java b/monolith/main-runner/src/test/java/nettee/main/sample/entity/Sample.java new file mode 100644 index 0000000..f5d4cdf --- /dev/null +++ b/monolith/main-runner/src/test/java/nettee/main/sample/entity/Sample.java @@ -0,0 +1,10 @@ +package nettee.main.sample.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import nettee.jpa.support.SnowflakeBaseEntity; + +@Entity +@Table(name = "sample") +public class Sample extends SnowflakeBaseEntity { +} diff --git a/monolith/main-runner/src/test/java/nettee/main/sample/persistence/SampleRepository.java b/monolith/main-runner/src/test/java/nettee/main/sample/persistence/SampleRepository.java new file mode 100644 index 0000000..2accad8 --- /dev/null +++ b/monolith/main-runner/src/test/java/nettee/main/sample/persistence/SampleRepository.java @@ -0,0 +1,7 @@ +package nettee.main.sample.persistence; + +import nettee.main.sample.entity.Sample; +import org.springframework.data.repository.CrudRepository; + +public interface SampleRepository extends CrudRepository { +} \ No newline at end of file diff --git a/monolith/main-runner/src/test/kotlin/nettee/main/snowflake/SnowflakeTest.kt b/monolith/main-runner/src/test/kotlin/nettee/main/snowflake/SnowflakeTest.kt new file mode 100644 index 0000000..52b6ae5 --- /dev/null +++ b/monolith/main-runner/src/test/kotlin/nettee/main/snowflake/SnowflakeTest.kt @@ -0,0 +1,80 @@ +package nettee.main.snowflake + +import io.kotest.core.spec.style.FreeSpec +import io.kotest.extensions.spring.SpringExtension +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.longs.shouldBeGreaterThan +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import kotlinx.coroutines.* +import nettee.main.sample.entity.Sample +import nettee.main.sample.persistence.SampleRepository +import nettee.snowflake.properties.SnowflakeProperties +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import java.util.concurrent.ConcurrentHashMap + +@DataJpaTest +class SnowflakeTest( + @Autowired val repository: SampleRepository +) : FreeSpec({ + + "[POST] Snowflake ID μ±„λ²ˆ μ •μ±…" - { + "Snowflake IDκ°€ 생성이 됐을 λ•Œ" - { + val sample = repository.save(Sample()) + + "Snowflake ID μ •μƒμ μœΌλ‘œ μƒμ„±λœλ‹€" { + sample.id shouldNotBe null + } + + "Snowflake IDκ°€ μ–‘μˆ˜μ΄λ‹€" { + sample.id shouldBeGreaterThan 0L + } + + "Snowflake ID의 ν¬κΈ°λŠ” λΆ€ν˜Έ λΉ„νŠΈλ₯Ό μ œμ™Έν•œ 63bit 이내λ₯Ό μΆ©μ‘±ν•œλ‹€" { + // 63번째 λΉ„νŠΈκ°€ κΊΌμ Έ μžˆλŠ”μ§€(0) ν™•μΈν•˜μ—¬, Snowflake IDκ°€ 63λΉ„νŠΈ 이내(μ–‘μˆ˜ λ²”μœ„)μž„μ„ 보μž₯ν•œλ‹€. + // 0 and 1 => 0 이고 1 and 1 -> 1 + sample.id and (1L.shl(63)) shouldBe 0L + } + } + } + + "[POST] Snowflake ID λ™μ‹œμ„± ν…ŒμŠ€νŠΈ" - { + "[병렬 처리] 100개의 Snowflake ID의 λ™μ‹œ 생성 μš”μ²­" - { + // Set을 μ΄μš©ν•˜μ—¬ ν‚€λ₯Ό μ €μž₯ν•˜κ³  μ€‘λ³΅μ—†λŠ” μ§€λ₯Ό νŒλ‹¨ + val concurrentSet = ConcurrentHashMap.newKeySet() + + // 코루틴 μ‹€ν–‰ + val coroutineScope = CoroutineScope(Dispatchers.Default) + + val jobs = List(100) { + // 병렬 μž‘μ—… 생성 + coroutineScope.launch { + // μƒˆλ‘œμš΄ sample μ—”ν‹°ν‹° μ €μž₯ μš”μ²­ + val sample = repository.save(Sample()) + println("Saved id=${sample.id}, time=${System.currentTimeMillis()}") + concurrentSet.add(sample.id) + } + } + + jobs.joinAll() + + "ID 쀑볡이 μ—†μ–΄μ•Ό ν•œλ‹€" { + concurrentSet shouldHaveSize 100 + } + } + } +}) { + override fun extensions() = listOf(SpringExtension) + + @TestConfiguration + class SnowflakeTestConfig { + + @Bean + fun snowflakeProperties(): SnowflakeProperties { + return SnowflakeProperties(1L, 1L, null) + } + } +} \ No newline at end of file diff --git a/monolith/main-runner/src/test/resources/application.yml b/monolith/main-runner/src/test/resources/application.yml new file mode 100644 index 0000000..edd2dbb --- /dev/null +++ b/monolith/main-runner/src/test/resources/application.yml @@ -0,0 +1,38 @@ +spring: + application.name: multi-module + h2: + console: + enabled: true + path: /h2-console + datasource: + driver-class-name: org.h2.Driver + # https://www.h2database.com/html/features.html#in_memory_databases μ°Έμ‘° + url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE; + username: sa + password: + jpa: + generate-ddl: 'true' + hibernate: + ddl-auto: create + properties: + hibernate: + show_sql: true + format_sql: true + use_sql_comments: true + flyway: + baseline-on-migrate: false + enabled: false + +app.cors.endpoints: + - path: "/**" + allowed: + headers: "*" + methods: "*" + origins: + - http://localhost:3000 + - https://localhost:3000 + credentials: true + exposed: + headers: "*" + max-age: 3_600 + diff --git a/services/board/api/build.gradle.kts b/services/board/api/build.gradle.kts index 0dcf7c2..1f79251 100644 --- a/services/board/api/build.gradle.kts +++ b/services/board/api/build.gradle.kts @@ -1,5 +1,9 @@ +val boardDomain: String by project +val boardException: String by project +val boardReadModel: String by project + dependencies { - api(project(":board:api:domain")) - api(project(":board:api:exception")) - api(project(":board:api:readmodel")) + api(project(boardDomain)) + api(project(boardException)) + api(project(boardReadModel)) } \ No newline at end of file diff --git a/services/board/application/build.gradle.kts b/services/board/application/build.gradle.kts index dd0d3a4..0d3814b 100644 --- a/services/board/application/build.gradle.kts +++ b/services/board/application/build.gradle.kts @@ -1,3 +1,3 @@ dependencies { - api(project(":board:api")) + api(project(":board:board-api")) } \ No newline at end of file diff --git a/services/board/board.settings.gradle.kts b/services/board/board.settings.gradle.kts index 46f080a..9550d88 100644 --- a/services/board/board.settings.gradle.kts +++ b/services/board/board.settings.gradle.kts @@ -1,3 +1,12 @@ +val board: String by settings +val boardApi: String by settings +val boardDomain: String by settings +val boardException: String by settings +val boardReadModel: String by settings +val boardApplication: String by settings +val boardRdbAdapter: String by settings +val boardWebMvcAdapter: String by settings + fun getDirectories(vararg names: String): (String) -> File { var dir = rootDir for (name in names) { @@ -11,25 +20,25 @@ fun getDirectories(vararg names: String): (String) -> File { } } -val board = getDirectories("services", "board") +val boardDirectory = getDirectories("services", "board") // SERVICE/BOARD include( - ":board", - ":board:api", - ":board:api:domain", - ":board:api:exception", - ":board:api:readmodel", - ":board:application", - ":board:rdb-adapter", - ":board:webmvc-adapter", + board, + boardApi, + boardDomain, + boardException, + boardReadModel, + boardApplication, + boardRdbAdapter, + boardWebMvcAdapter, ) -project(":board").projectDir = board("board") -project(":board:api").projectDir = board("api") -project(":board:api:domain").projectDir = board("domain") -project(":board:api:exception").projectDir = board("exception") -project(":board:api:readmodel").projectDir = board("readmodel") -project(":board:application").projectDir = board("application") -project(":board:rdb-adapter").projectDir = board("rdb") -project(":board:webmvc-adapter").projectDir = board("web-mvc") +project(board).projectDir = boardDirectory("board") +project(boardApi).projectDir = boardDirectory("api") +project(boardDomain).projectDir = boardDirectory("domain") +project(boardException).projectDir = boardDirectory("exception") +project(boardReadModel).projectDir = boardDirectory("readmodel") +project(boardApplication).projectDir = boardDirectory("application") +project(boardRdbAdapter).projectDir = boardDirectory("rdb") +project(boardWebMvcAdapter).projectDir = boardDirectory("web-mvc") diff --git a/services/board/build.gradle.kts b/services/board/build.gradle.kts index 3565f94..5f9bb4b 100644 --- a/services/board/build.gradle.kts +++ b/services/board/build.gradle.kts @@ -1,6 +1,11 @@ +val boardApi: String by project +val boardApplication: String by project +val boardRdbAdapter: String by project +val boardWebMvcAdapter: String by project + dependencies { - api(project(":board:api")) - api(project(":board:application")) - api(project(":board:rdb-adapter")) - api(project(":board:webmvc-adapter")) + api(project(boardApi)) + api(project(boardApplication)) + api(project(boardRdbAdapter)) + api(project(boardWebMvcAdapter)) } \ No newline at end of file diff --git a/services/board/driven/rdb/build.gradle.kts b/services/board/driven/rdb/build.gradle.kts index 9617183..34e30c1 100644 --- a/services/board/driven/rdb/build.gradle.kts +++ b/services/board/driven/rdb/build.gradle.kts @@ -1,8 +1,8 @@ dependencies { val bom = dependencyManagement.importedProperties - api(project(":board:api")) - api(project(":board:application")) + api(project(":board:board-api")) + api(project(":board:board-application")) api(project(":jpa-core")) // spring diff --git a/services/board/driven/rdb/src/main/resources/properties/db/board.database-local.yml b/services/board/driven/rdb/src/main/resources/properties/db/board.database-local.yml index 2532871..5e721ef 100644 --- a/services/board/driven/rdb/src/main/resources/properties/db/board.database-local.yml +++ b/services/board/driven/rdb/src/main/resources/properties/db/board.database-local.yml @@ -1,5 +1,11 @@ spring: datasource: + driver-class-name: org.postgresql.Driver url: ${BOARD_POSTGRESQL_URL:jdbc:postgresql://localhost:5433/demo} username: ${BOARD_POSTGRESQL_USERNAME:root} password: ${BOARD_POSTGRESQL_PASSWORD:root} + + flyway: + baseline-on-migrate: true + locations: + - db/postgresql/migration/v1_0 \ No newline at end of file diff --git a/services/board/driving/web-mvc/build.gradle.kts b/services/board/driving/web-mvc/build.gradle.kts index 1f453c7..19a4629 100644 --- a/services/board/driving/web-mvc/build.gradle.kts +++ b/services/board/driving/web-mvc/build.gradle.kts @@ -1,6 +1,6 @@ dependencies { - api(project(":board:api")) - api(project(":board:application")) + api(project(":board:board-api")) + api(project(":board:board-application")) // validation compileOnly("jakarta.validation:jakarta.validation-api") diff --git a/services/board/driving/web-mvc/src/main/java/nettee/board/web/BoardCommandApi.java b/services/board/driving/web-mvc/src/main/java/nettee/board/web/BoardCommandApi.java index 8e5e54b..2f26934 100644 --- a/services/board/driving/web-mvc/src/main/java/nettee/board/web/BoardCommandApi.java +++ b/services/board/driving/web-mvc/src/main/java/nettee/board/web/BoardCommandApi.java @@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("boards") +@RequestMapping("/boards") @RequiredArgsConstructor public class BoardCommandApi { private final BoardCreateUseCase boardCreateUseCase; diff --git a/services/comment/api/build.gradle.kts b/services/comment/api/build.gradle.kts new file mode 100644 index 0000000..f334790 --- /dev/null +++ b/services/comment/api/build.gradle.kts @@ -0,0 +1,9 @@ +val commentDomain: String by project +val commentException: String by project +val commentReadModel: String by project + +dependencies { + api(project(commentDomain)) + api(project(commentException)) + api(project(commentReadModel)) +} \ No newline at end of file diff --git a/services/comment/api/domain/src/main/java/nettee/comment/domain/Comment.java b/services/comment/api/domain/src/main/java/nettee/comment/domain/Comment.java new file mode 100644 index 0000000..50547c3 --- /dev/null +++ b/services/comment/api/domain/src/main/java/nettee/comment/domain/Comment.java @@ -0,0 +1,45 @@ +package nettee.comment.domain; + +import java.time.Instant; +import java.util.Objects; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import nettee.comment.domain.type.CommentStatus; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Comment { + + private Long id; + + private Long boardId; + + private String content; + + private CommentStatus status; + + private Instant createdAt; + + private Instant updatedAt; + + @Builder( + builderClassName = "updateCommentBuilder", + builderMethodName = "prepareUpdate", + buildMethodName = "update" + ) + public void update(String content) { + Objects.requireNonNull(content, "content cannot be null"); + + this.content = content; + this.updatedAt = Instant.now(); + } + + public void softDelete() { + this.status = CommentStatus.REMOVED; + } + +} diff --git a/services/comment/api/domain/src/main/java/nettee/comment/domain/type/CommentStatus.java b/services/comment/api/domain/src/main/java/nettee/comment/domain/type/CommentStatus.java new file mode 100644 index 0000000..e8e64e6 --- /dev/null +++ b/services/comment/api/domain/src/main/java/nettee/comment/domain/type/CommentStatus.java @@ -0,0 +1,18 @@ +package nettee.comment.domain.type; + +import java.util.EnumSet; +import java.util.Set; + +public enum CommentStatus { + + PENDING, + ACTIVE, + REMOVED; + + private static final Set GENERAL_QUERY_STATUS = EnumSet.of(ACTIVE); + + public static Set getGeneralQueryStatus() { + return GENERAL_QUERY_STATUS; + } + +} diff --git a/services/comment/api/domain/src/main/java/nettee/reply/domain/Reply.java b/services/comment/api/domain/src/main/java/nettee/reply/domain/Reply.java new file mode 100644 index 0000000..4830c8e --- /dev/null +++ b/services/comment/api/domain/src/main/java/nettee/reply/domain/Reply.java @@ -0,0 +1,44 @@ +package nettee.reply.domain; + +import java.time.Instant; +import java.util.Objects; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import nettee.reply.domain.type.ReplyStatus; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Reply { + + private Long id; + + private Long commentId; + + private String content; + + private ReplyStatus status; + + private Instant createdAt; + + private Instant updatedAt; + + @Builder( + builderClassName = "updateReplyBuilder", + builderMethodName = "prepareUpdate", + buildMethodName = "update" + ) + public void update(String content) { + Objects.requireNonNull(content, "content cannot be null"); + + this.content = content; + this.updatedAt = Instant.now(); + } + + public void softDelete() { + this.status = ReplyStatus.REMOVED; + } +} diff --git a/services/comment/api/domain/src/main/java/nettee/reply/domain/type/ReplyStatus.java b/services/comment/api/domain/src/main/java/nettee/reply/domain/type/ReplyStatus.java new file mode 100644 index 0000000..90e9e9a --- /dev/null +++ b/services/comment/api/domain/src/main/java/nettee/reply/domain/type/ReplyStatus.java @@ -0,0 +1,18 @@ +package nettee.reply.domain.type; + +import java.util.EnumSet; +import java.util.Set; + +public enum ReplyStatus { + + PENDING, + ACTIVE, + REMOVED; + + private static final Set GENERAL_QUERY_STATUS = EnumSet.of(ACTIVE); + + public static Set getGeneralQueryStatus() { + return GENERAL_QUERY_STATUS; + } + +} diff --git a/services/comment/api/exception/src/main/java/nettee/comment/exception/CommentCommandErrorCode.java b/services/comment/api/exception/src/main/java/nettee/comment/exception/CommentCommandErrorCode.java new file mode 100644 index 0000000..5061370 --- /dev/null +++ b/services/comment/api/exception/src/main/java/nettee/comment/exception/CommentCommandErrorCode.java @@ -0,0 +1,62 @@ +package nettee.comment.exception; + +import java.util.Map; +import java.util.function.Supplier; +import nettee.common.ErrorCode; +import org.springframework.http.HttpStatus; + +public enum CommentCommandErrorCode implements ErrorCode { + COMMENT_NOT_FOUND("λŒ“κΈ€μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.", HttpStatus.NOT_FOUND), + COMMENT_GONE("더 이상 μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” λŒ“κΈ€μž…λ‹ˆλ‹€.", HttpStatus.GONE), + COMMENT_FORBIDDEN("κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€.", HttpStatus.FORBIDDEN), + DEFAULT("λŒ“κΈ€ μ‘°μž‘ 였λ₯˜", HttpStatus.INTERNAL_SERVER_ERROR), + COMMENT_ALREADY_EXIST("λŒ“κΈ€μ΄ 이미 μ‘΄μž¬ν•©λ‹ˆλ‹€.", HttpStatus.CONFLICT); + + private final String message; + private final HttpStatus httpStatus; + + CommentCommandErrorCode(String message, HttpStatus httpStatus) { + this.message = message; + this.httpStatus = httpStatus; + } + + @Override + public String message() { + return message; + } + + @Override + public HttpStatus httpStatus() { + return httpStatus; + } + + @Override + public CommentCommandException exception() { + return new CommentCommandException(this); + } + + @Override + public CommentCommandException exception(Throwable cause) { + return new CommentCommandException(this, cause); + } + + @Override + public RuntimeException exception(Runnable runnable) { + return new CommentCommandException(this, runnable); + } + + @Override + public RuntimeException exception(Runnable runnable, Throwable cause) { + return new CommentCommandException(this, runnable, cause); + } + + @Override + public RuntimeException exception(Supplier> payload) { + return new CommentCommandException(this, payload); + } + + @Override + public RuntimeException exception(Supplier> payload, Throwable cause) { + return new CommentCommandException(this, payload, cause); + } +} diff --git a/services/comment/api/exception/src/main/java/nettee/comment/exception/CommentCommandException.java b/services/comment/api/exception/src/main/java/nettee/comment/exception/CommentCommandException.java new file mode 100644 index 0000000..d6e01ff --- /dev/null +++ b/services/comment/api/exception/src/main/java/nettee/comment/exception/CommentCommandException.java @@ -0,0 +1,36 @@ +package nettee.comment.exception; + +import java.util.Map; +import java.util.function.Supplier; +import nettee.common.CustomException; +import nettee.common.ErrorCode; + +public class CommentCommandException extends CustomException { + + /** + * CommentErrorCodeLazyHolderλ₯Ό νŒŒλΌλ―Έν„°λ‘œ λ°›κΈ° μœ„ν•΄, ErrorCode νƒ€μž…μœΌλ‘œ μž„μ‹œ 섀정함. + */ + public CommentCommandException(ErrorCode errorCode) { + super(errorCode); + } + + public CommentCommandException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } + + public CommentCommandException(ErrorCode errorCode, Runnable runnable) { + super(errorCode, runnable); + } + + public CommentCommandException(ErrorCode errorCode, Runnable runnable, Throwable cause) { + super(errorCode, runnable, cause); + } + + public CommentCommandException(ErrorCode errorCode, Supplier> payload) { + super(errorCode, payload); + } + + public CommentCommandException(ErrorCode errorCode, Supplier> payload, Throwable cause) { + super(errorCode, payload, cause); + } +} diff --git a/services/comment/api/exception/src/main/java/nettee/comment/exception/CommentQueryErrorCode.java b/services/comment/api/exception/src/main/java/nettee/comment/exception/CommentQueryErrorCode.java new file mode 100644 index 0000000..1529b31 --- /dev/null +++ b/services/comment/api/exception/src/main/java/nettee/comment/exception/CommentQueryErrorCode.java @@ -0,0 +1,61 @@ +package nettee.comment.exception; + +import java.util.Map; +import java.util.function.Supplier; +import nettee.common.ErrorCode; +import org.springframework.http.HttpStatus; + +public enum CommentQueryErrorCode implements ErrorCode { + COMMENT_NOT_FOUND("λŒ“κΈ€μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.", HttpStatus.NOT_FOUND), + COMMENT_GONE("더 이상 μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” λŒ“κΈ€μž…λ‹ˆλ‹€.", HttpStatus.GONE), + COMMENT_FORBIDDEN("κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€.", HttpStatus.FORBIDDEN), + DEFAULT("λŒ“κΈ€ μ‘°μž‘ 였λ₯˜", HttpStatus.INTERNAL_SERVER_ERROR); + + private final String message; + private final HttpStatus httpStatus; + + CommentQueryErrorCode(String message, HttpStatus httpStatus) { + this.message = message; + this.httpStatus = httpStatus; + } + + @Override + public String message() { + return message; + } + + @Override + public HttpStatus httpStatus() { + return httpStatus; + } + + @Override + public CommentQueryException exception() { + return new CommentQueryException(this); + } + + @Override + public CommentQueryException exception(Throwable cause) { + return new CommentQueryException(this, cause); + } + + @Override + public RuntimeException exception(Runnable runnable) { + return new CommentQueryException(this, runnable); + } + + @Override + public RuntimeException exception(Runnable runnable, Throwable cause) { + return new CommentQueryException(this, runnable, cause); + } + + @Override + public RuntimeException exception(Supplier> payload) { + return new CommentQueryException(this, payload); + } + + @Override + public RuntimeException exception(Supplier> payload, Throwable cause) { + return new CommentQueryException(this, payload, cause); + } +} diff --git a/services/comment/api/exception/src/main/java/nettee/comment/exception/CommentQueryException.java b/services/comment/api/exception/src/main/java/nettee/comment/exception/CommentQueryException.java new file mode 100644 index 0000000..a5455f0 --- /dev/null +++ b/services/comment/api/exception/src/main/java/nettee/comment/exception/CommentQueryException.java @@ -0,0 +1,31 @@ +package nettee.comment.exception; + +import java.util.Map; +import java.util.function.Supplier; +import nettee.common.CustomException; + +public class CommentQueryException extends CustomException { + public CommentQueryException(CommentQueryErrorCode errorCode) { + super(errorCode); + } + + public CommentQueryException(CommentQueryErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } + + public CommentQueryException(CommentQueryErrorCode errorCode, Runnable runnable) { + super(errorCode, runnable); + } + + public CommentQueryException(CommentQueryErrorCode errorCode, Runnable runnable, Throwable cause) { + super(errorCode, runnable, cause); + } + + public CommentQueryException(CommentQueryErrorCode errorCode, Supplier> payload) { + super(errorCode, payload); + } + + public CommentQueryException(CommentQueryErrorCode errorCode, Supplier> payload, Throwable cause) { + super(errorCode, payload, cause); + } +} diff --git a/services/comment/api/exception/src/main/java/nettee/reply/exception/ReplyCommandErrorCode.java b/services/comment/api/exception/src/main/java/nettee/reply/exception/ReplyCommandErrorCode.java new file mode 100644 index 0000000..110e1d0 --- /dev/null +++ b/services/comment/api/exception/src/main/java/nettee/reply/exception/ReplyCommandErrorCode.java @@ -0,0 +1,62 @@ +package nettee.reply.exception; + +import java.util.Map; +import java.util.function.Supplier; +import nettee.common.ErrorCode; +import org.springframework.http.HttpStatus; + +public enum ReplyCommandErrorCode implements ErrorCode { + REPLY_NOT_FOUND("닡글을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.", HttpStatus.NOT_FOUND), + REPLY_GONE("더 이상 μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” λ‹΅κΈ€μž…λ‹ˆλ‹€.", HttpStatus.GONE), + REPLY_FORBIDDEN("κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€.", HttpStatus.FORBIDDEN), + DEFAULT("λ‹΅κΈ€ μ‘°μž‘ 였λ₯˜", HttpStatus.INTERNAL_SERVER_ERROR), + REPLY_ALREADY_EXIST("닡글이 이미 μ‘΄μž¬ν•©λ‹ˆλ‹€.", HttpStatus.CONFLICT); + + private final String message; + private final HttpStatus httpStatus; + + ReplyCommandErrorCode(String message, HttpStatus httpStatus) { + this.message = message; + this.httpStatus = httpStatus; + } + + @Override + public String message() { + return message; + } + + @Override + public HttpStatus httpStatus() { + return httpStatus; + } + + @Override + public ReplyCommandException exception() { + return new ReplyCommandException(this); + } + + @Override + public ReplyCommandException exception(Throwable cause) { + return new ReplyCommandException(this, cause); + } + + @Override + public RuntimeException exception(Runnable runnable) { + return new ReplyCommandException(this, runnable); + } + + @Override + public RuntimeException exception(Runnable runnable, Throwable cause) { + return new ReplyCommandException(this, runnable, cause); + } + + @Override + public RuntimeException exception(Supplier> payload) { + return new ReplyCommandException(this, payload); + } + + @Override + public RuntimeException exception(Supplier> payload, Throwable cause) { + return new ReplyCommandException(this, payload, cause); + } +} diff --git a/services/comment/api/exception/src/main/java/nettee/reply/exception/ReplyCommandException.java b/services/comment/api/exception/src/main/java/nettee/reply/exception/ReplyCommandException.java new file mode 100644 index 0000000..d9c740c --- /dev/null +++ b/services/comment/api/exception/src/main/java/nettee/reply/exception/ReplyCommandException.java @@ -0,0 +1,36 @@ +package nettee.reply.exception; + +import java.util.Map; +import java.util.function.Supplier; +import nettee.common.CustomException; +import nettee.common.ErrorCode; + +public class ReplyCommandException extends CustomException { + + /** + * ReplyErrorCodeLazyHolderλ₯Ό νŒŒλΌλ―Έν„°λ‘œ λ°›κΈ° μœ„ν•΄, ErrorCode νƒ€μž…μœΌλ‘œ μž„μ‹œ 섀정함. + */ + public ReplyCommandException(ErrorCode errorCode) { + super(errorCode); + } + + public ReplyCommandException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } + + public ReplyCommandException(ErrorCode errorCode, Runnable runnable) { + super(errorCode, runnable); + } + + public ReplyCommandException(ErrorCode errorCode, Runnable runnable, Throwable cause) { + super(errorCode, runnable, cause); + } + + public ReplyCommandException(ErrorCode errorCode, Supplier> payload) { + super(errorCode, payload); + } + + public ReplyCommandException(ErrorCode errorCode, Supplier> payload, Throwable cause) { + super(errorCode, payload, cause); + } +} diff --git a/services/comment/api/exception/src/main/java/nettee/reply/exception/ReplyQueryErrorCode.java b/services/comment/api/exception/src/main/java/nettee/reply/exception/ReplyQueryErrorCode.java new file mode 100644 index 0000000..f8eb5c8 --- /dev/null +++ b/services/comment/api/exception/src/main/java/nettee/reply/exception/ReplyQueryErrorCode.java @@ -0,0 +1,61 @@ +package nettee.reply.exception; + +import java.util.Map; +import java.util.function.Supplier; +import nettee.common.ErrorCode; +import org.springframework.http.HttpStatus; + +public enum ReplyQueryErrorCode implements ErrorCode { + REPLY_NOT_FOUND("닡글을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.", HttpStatus.NOT_FOUND), + REPLY_GONE("더 이상 μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” λ‹΅κΈ€μž…λ‹ˆλ‹€.", HttpStatus.GONE), + REPLY_FORBIDDEN("κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€.", HttpStatus.FORBIDDEN), + DEFAULT("λ‹΅κΈ€ μ‘°μž‘ 였λ₯˜", HttpStatus.INTERNAL_SERVER_ERROR); + + private final String message; + private final HttpStatus httpStatus; + + ReplyQueryErrorCode(String message, HttpStatus httpStatus) { + this.message = message; + this.httpStatus = httpStatus; + } + + @Override + public String message() { + return message; + } + + @Override + public HttpStatus httpStatus() { + return httpStatus; + } + + @Override + public ReplyQueryException exception() { + return new ReplyQueryException(this); + } + + @Override + public ReplyQueryException exception(Throwable cause) { + return new ReplyQueryException(this, cause); + } + + @Override + public RuntimeException exception(Runnable runnable) { + return new ReplyQueryException(this, runnable); + } + + @Override + public RuntimeException exception(Runnable runnable, Throwable cause) { + return new ReplyQueryException(this, runnable, cause); + } + + @Override + public RuntimeException exception(Supplier> payload) { + return new ReplyQueryException(this, payload); + } + + @Override + public RuntimeException exception(Supplier> payload, Throwable cause) { + return new ReplyQueryException(this, payload, cause); + } +} diff --git a/services/comment/api/exception/src/main/java/nettee/reply/exception/ReplyQueryException.java b/services/comment/api/exception/src/main/java/nettee/reply/exception/ReplyQueryException.java new file mode 100644 index 0000000..f2fbdfd --- /dev/null +++ b/services/comment/api/exception/src/main/java/nettee/reply/exception/ReplyQueryException.java @@ -0,0 +1,31 @@ +package nettee.reply.exception; + +import java.util.Map; +import java.util.function.Supplier; +import nettee.common.CustomException; + +public class ReplyQueryException extends CustomException { + public ReplyQueryException(ReplyQueryErrorCode errorCode) { + super(errorCode); + } + + public ReplyQueryException(ReplyQueryErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } + + public ReplyQueryException(ReplyQueryErrorCode errorCode, Runnable runnable) { + super(errorCode, runnable); + } + + public ReplyQueryException(ReplyQueryErrorCode errorCode, Runnable runnable, Throwable cause) { + super(errorCode, runnable, cause); + } + + public ReplyQueryException(ReplyQueryErrorCode errorCode, Supplier> payload) { + super(errorCode, payload); + } + + public ReplyQueryException(ReplyQueryErrorCode errorCode, Supplier> payload, Throwable cause) { + super(errorCode, payload, cause); + } +} diff --git a/services/comment/api/readmodel/build.gradle.kts b/services/comment/api/readmodel/build.gradle.kts new file mode 100644 index 0000000..ddfbece --- /dev/null +++ b/services/comment/api/readmodel/build.gradle.kts @@ -0,0 +1,5 @@ +val commentDomain: String by project + +dependencies { + api(project(commentDomain)) +} \ No newline at end of file diff --git a/services/comment/api/readmodel/src/main/java/nettee/comment/model/CommentQueryModels.java b/services/comment/api/readmodel/src/main/java/nettee/comment/model/CommentQueryModels.java new file mode 100644 index 0000000..d6d89b1 --- /dev/null +++ b/services/comment/api/readmodel/src/main/java/nettee/comment/model/CommentQueryModels.java @@ -0,0 +1,27 @@ +package nettee.comment.model; + +import java.util.List; +import lombok.Builder; + +import java.time.Instant; +import nettee.comment.domain.type.CommentStatus; +import nettee.reply.model.ReplyQueryModels.ReplyDetail; + +public final class CommentQueryModels { + + private CommentQueryModels() { + } + + @Builder + public record CommentDetail( + Long id, + Long boardId, + String content, + CommentStatus status, + Instant createdAt, + Instant updatedAt, + List replies + ) { + } + +} \ No newline at end of file diff --git a/services/comment/api/readmodel/src/main/java/nettee/reply/model/ReplyQueryModels.java b/services/comment/api/readmodel/src/main/java/nettee/reply/model/ReplyQueryModels.java new file mode 100644 index 0000000..c0cf3c5 --- /dev/null +++ b/services/comment/api/readmodel/src/main/java/nettee/reply/model/ReplyQueryModels.java @@ -0,0 +1,23 @@ +package nettee.reply.model; + +import java.time.Instant; +import lombok.Builder; +import nettee.reply.domain.type.ReplyStatus; + +public final class ReplyQueryModels { + + private ReplyQueryModels() { + } + + @Builder + public record ReplyDetail( + Long id, + Long commentId, + String content, + ReplyStatus status, + Instant createdAt, + Instant updatedAt + ) { + } + +} \ No newline at end of file diff --git a/services/comment/application/build.gradle.kts b/services/comment/application/build.gradle.kts new file mode 100644 index 0000000..652be2d --- /dev/null +++ b/services/comment/application/build.gradle.kts @@ -0,0 +1,4 @@ +dependencies { + api(project(":comment:comment-api")) + implementation("org.springframework.boot:spring-boot-starter-data-jpa") +} \ No newline at end of file diff --git a/services/comment/application/src/main/java/nettee/comment/application/port/CommentCommandRepositoryPort.java b/services/comment/application/src/main/java/nettee/comment/application/port/CommentCommandRepositoryPort.java new file mode 100644 index 0000000..f93c888 --- /dev/null +++ b/services/comment/application/src/main/java/nettee/comment/application/port/CommentCommandRepositoryPort.java @@ -0,0 +1,13 @@ +package nettee.comment.application.port; + +import nettee.comment.domain.Comment; +import nettee.comment.domain.type.CommentStatus; + +public interface CommentCommandRepositoryPort { + + Comment save(Comment comment); + + Comment update(Comment comment); + + void updateStatus(Long id, CommentStatus status); +} diff --git a/services/comment/application/src/main/java/nettee/comment/application/port/CommentQueryRepositoryPort.java b/services/comment/application/src/main/java/nettee/comment/application/port/CommentQueryRepositoryPort.java new file mode 100644 index 0000000..39b81da --- /dev/null +++ b/services/comment/application/src/main/java/nettee/comment/application/port/CommentQueryRepositoryPort.java @@ -0,0 +1,13 @@ +package nettee.comment.application.port; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import nettee.comment.model.CommentQueryModels.CommentDetail; + +public interface CommentQueryRepositoryPort { + Optional findById(Long id); + + // board_id에 ν•΄λ‹Ήν•˜λŠ” comment λͺ©λ‘ 쑰회 + List findPageByBoardId(Long boardId, int offset, int size); +} diff --git a/services/comment/application/src/main/java/nettee/comment/application/service/CommentCommandService.java b/services/comment/application/src/main/java/nettee/comment/application/service/CommentCommandService.java new file mode 100644 index 0000000..aef56ea --- /dev/null +++ b/services/comment/application/src/main/java/nettee/comment/application/service/CommentCommandService.java @@ -0,0 +1,35 @@ +package nettee.comment.application.service; + +import lombok.RequiredArgsConstructor; +import nettee.comment.application.usecase.CommentDeleteUseCase; +import nettee.comment.domain.Comment; +import nettee.comment.application.port.CommentCommandRepositoryPort; +import nettee.comment.domain.type.CommentStatus; +import nettee.comment.application.usecase.CommentCreateUseCase; +import nettee.comment.application.usecase.CommentUpdateUseCase; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class CommentCommandService implements CommentCreateUseCase, CommentUpdateUseCase, + CommentDeleteUseCase { + + private final CommentCommandRepositoryPort commentCommandRepositoryPort; + + @Override + public Comment createComment(Comment comment) { + return commentCommandRepositoryPort.save(comment); + } + + @Override + public Comment updateComment(Comment comment) { + return commentCommandRepositoryPort.update(comment); + } + + @Override + public void deleteComment(Long id) { + commentCommandRepositoryPort.updateStatus(id, CommentStatus.REMOVED); + } +} diff --git a/services/comment/application/src/main/java/nettee/comment/application/service/CommentQueryService.java b/services/comment/application/src/main/java/nettee/comment/application/service/CommentQueryService.java new file mode 100644 index 0000000..c7b5f18 --- /dev/null +++ b/services/comment/application/src/main/java/nettee/comment/application/service/CommentQueryService.java @@ -0,0 +1,41 @@ +package nettee.comment.application.service; + +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import nettee.comment.model.CommentQueryModels.CommentDetail; +import nettee.comment.application.port.CommentQueryRepositoryPort; +import nettee.reply.application.port.ReplyQueryRepositoryPort; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CommentQueryService { + + private final CommentQueryRepositoryPort commentQueryRepositoryPort; + private final ReplyQueryRepositoryPort replyQueryRepositoryPort; + + public List getCommentsByBoardId(Long boardId) { + var comments = commentQueryRepositoryPort.findPageByBoardId(boardId, 0, 10); + + // commentλ³„λ‘œ replyλ₯Ό 10κ°œμ”© κ°€μ Έμ˜΄ + // ν˜„μž¬ N+1 μ΄λ―€λ‘œ, μ΅œλŒ€ 11(1+10)개의 쿼리λ₯Ό λ°œμƒμ‹œν‚΄ + var result = comments.stream() + .map(comment -> { + var replies = replyQueryRepositoryPort.findPageByCommentId(comment.id(), 0, 10); + + return CommentDetail.builder() + .id(comment.id()) + .boardId(comment.boardId()) + .content(comment.content()) + .status(comment.status()) + .createdAt(comment.createdAt()) + .updatedAt(comment.updatedAt()) + .replies(replies) + .build(); + }).collect(Collectors.toList()); + + return result; + } + +} diff --git a/services/comment/application/src/main/java/nettee/comment/application/usecase/CommentCreateUseCase.java b/services/comment/application/src/main/java/nettee/comment/application/usecase/CommentCreateUseCase.java new file mode 100644 index 0000000..248350a --- /dev/null +++ b/services/comment/application/src/main/java/nettee/comment/application/usecase/CommentCreateUseCase.java @@ -0,0 +1,7 @@ +package nettee.comment.application.usecase; + +import nettee.comment.domain.Comment; + +public interface CommentCreateUseCase { + Comment createComment(Comment comment); +} diff --git a/services/comment/application/src/main/java/nettee/comment/application/usecase/CommentDeleteUseCase.java b/services/comment/application/src/main/java/nettee/comment/application/usecase/CommentDeleteUseCase.java new file mode 100644 index 0000000..8607c2b --- /dev/null +++ b/services/comment/application/src/main/java/nettee/comment/application/usecase/CommentDeleteUseCase.java @@ -0,0 +1,7 @@ +package nettee.comment.application.usecase; + +import nettee.comment.domain.Comment; + +public interface CommentDeleteUseCase { + void deleteComment(Long id); +} diff --git a/services/comment/application/src/main/java/nettee/comment/application/usecase/CommentUpdateUseCase.java b/services/comment/application/src/main/java/nettee/comment/application/usecase/CommentUpdateUseCase.java new file mode 100644 index 0000000..514e467 --- /dev/null +++ b/services/comment/application/src/main/java/nettee/comment/application/usecase/CommentUpdateUseCase.java @@ -0,0 +1,7 @@ +package nettee.comment.application.usecase; + +import nettee.comment.domain.Comment; + +public interface CommentUpdateUseCase { + Comment updateComment(Comment comment); +} diff --git a/services/comment/application/src/main/java/nettee/reply/application/port/ReplyCommandRepositoryPort.java b/services/comment/application/src/main/java/nettee/reply/application/port/ReplyCommandRepositoryPort.java new file mode 100644 index 0000000..fea7b2e --- /dev/null +++ b/services/comment/application/src/main/java/nettee/reply/application/port/ReplyCommandRepositoryPort.java @@ -0,0 +1,13 @@ +package nettee.reply.application.port; + +import nettee.reply.domain.Reply; +import nettee.reply.domain.type.ReplyStatus; + +public interface ReplyCommandRepositoryPort { + + Reply save(Reply reply); + + Reply update(Reply reply); + + void updateStatus(Long id, ReplyStatus status); +} diff --git a/services/comment/application/src/main/java/nettee/reply/application/port/ReplyQueryRepositoryPort.java b/services/comment/application/src/main/java/nettee/reply/application/port/ReplyQueryRepositoryPort.java new file mode 100644 index 0000000..e83c6e7 --- /dev/null +++ b/services/comment/application/src/main/java/nettee/reply/application/port/ReplyQueryRepositoryPort.java @@ -0,0 +1,17 @@ +package nettee.reply.application.port; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import nettee.reply.model.ReplyQueryModels.ReplyDetail; + +public interface ReplyQueryRepositoryPort { + + Optional findById(Long id); + + // comment_id에 ν•΄λ‹Ήν•˜λŠ” reply λͺ©λ‘ 쑰회 + List findPageByCommentId(Long commentId, int offset, int size); + + // comment_id, ν˜„μž¬ νŽ˜μ΄μ§€μ˜ λ§ˆμ§€λ§‰ μ΄ν›„μ˜ reply λͺ©λ‘ 쑰회 + List findPageByCommentIdAfter(Long commentId, Instant createdAt, int size); +} diff --git a/services/comment/application/src/main/java/nettee/reply/application/service/ReplyCommandService.java b/services/comment/application/src/main/java/nettee/reply/application/service/ReplyCommandService.java new file mode 100644 index 0000000..e53309f --- /dev/null +++ b/services/comment/application/src/main/java/nettee/reply/application/service/ReplyCommandService.java @@ -0,0 +1,34 @@ +package nettee.reply.application.service; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import nettee.reply.domain.Reply; +import nettee.reply.application.port.ReplyCommandRepositoryPort; +import nettee.reply.domain.type.ReplyStatus; +import nettee.reply.application.usecase.ReplyCreateUseCase; +import nettee.reply.application.usecase.ReplyDeleteUseCase; +import nettee.reply.application.usecase.ReplyUpdateUseCase; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Transactional +public class ReplyCommandService implements ReplyCreateUseCase, ReplyUpdateUseCase, ReplyDeleteUseCase { + + private final ReplyCommandRepositoryPort replyCommandRepositoryPort; + + @Override + public Reply createReply(Reply reply) { + return replyCommandRepositoryPort.save(reply); + } + + @Override + public void deleteReply(Long id) { + replyCommandRepositoryPort.updateStatus(id, ReplyStatus.REMOVED); + } + + @Override + public Reply updateReply(Reply reply) { + return replyCommandRepositoryPort.update(reply); + } +} diff --git a/services/comment/application/src/main/java/nettee/reply/application/service/ReplyQueryService.java b/services/comment/application/src/main/java/nettee/reply/application/service/ReplyQueryService.java new file mode 100644 index 0000000..0a37e7b --- /dev/null +++ b/services/comment/application/src/main/java/nettee/reply/application/service/ReplyQueryService.java @@ -0,0 +1,24 @@ +package nettee.reply.application.service; + +import java.time.Instant; +import java.util.List; +import lombok.RequiredArgsConstructor; +import nettee.reply.model.ReplyQueryModels.ReplyDetail; +import nettee.reply.application.port.ReplyQueryRepositoryPort; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ReplyQueryService { + + private final ReplyQueryRepositoryPort replyQueryRepositoryPort; + + public List getReplyListByCommentId(Long commentId) { + return replyQueryRepositoryPort.findPageByCommentId(commentId, 0, 10); + } + + public List getReplyListByCommentIdAfter(Long commentId, Instant createdAt) { + return replyQueryRepositoryPort.findPageByCommentIdAfter(commentId, createdAt, 10); + } + +} diff --git a/services/comment/application/src/main/java/nettee/reply/application/usecase/ReplyCreateUseCase.java b/services/comment/application/src/main/java/nettee/reply/application/usecase/ReplyCreateUseCase.java new file mode 100644 index 0000000..5eecf38 --- /dev/null +++ b/services/comment/application/src/main/java/nettee/reply/application/usecase/ReplyCreateUseCase.java @@ -0,0 +1,7 @@ +package nettee.reply.application.usecase; + +import nettee.reply.domain.Reply; + +public interface ReplyCreateUseCase { + Reply createReply(Reply reply); +} diff --git a/services/comment/application/src/main/java/nettee/reply/application/usecase/ReplyDeleteUseCase.java b/services/comment/application/src/main/java/nettee/reply/application/usecase/ReplyDeleteUseCase.java new file mode 100644 index 0000000..8cb3e8a --- /dev/null +++ b/services/comment/application/src/main/java/nettee/reply/application/usecase/ReplyDeleteUseCase.java @@ -0,0 +1,5 @@ +package nettee.reply.application.usecase; + +public interface ReplyDeleteUseCase { + public void deleteReply(Long id); +} diff --git a/services/comment/application/src/main/java/nettee/reply/application/usecase/ReplyUpdateUseCase.java b/services/comment/application/src/main/java/nettee/reply/application/usecase/ReplyUpdateUseCase.java new file mode 100644 index 0000000..a70ff5e --- /dev/null +++ b/services/comment/application/src/main/java/nettee/reply/application/usecase/ReplyUpdateUseCase.java @@ -0,0 +1,7 @@ +package nettee.reply.application.usecase; + +import nettee.reply.domain.Reply; + +public interface ReplyUpdateUseCase { + public Reply updateReply(Reply reply); +} diff --git a/services/comment/build.gradle.kts b/services/comment/build.gradle.kts new file mode 100644 index 0000000..7ed76de --- /dev/null +++ b/services/comment/build.gradle.kts @@ -0,0 +1,11 @@ +val commentApi: String by project +val commentApplication: String by project +val commentRdbAdapter: String by project +val commentWebMvcAdapter: String by project + +dependencies { + api(project(commentApi)) + api(project(commentApplication)) + api(project(commentRdbAdapter)) + api(project(commentWebMvcAdapter)) +} \ No newline at end of file diff --git a/services/comment/comment.settings.gradle.kts b/services/comment/comment.settings.gradle.kts new file mode 100644 index 0000000..e609685 --- /dev/null +++ b/services/comment/comment.settings.gradle.kts @@ -0,0 +1,45 @@ +val comment: String by settings +val commentApi: String by settings +val commentDomain: String by settings +val commentException: String by settings +val commentReadModel: String by settings +val commentApplication: String by settings +val commentRdbAdapter: String by settings +val commentWebMvcAdapter: String by settings + + +fun getDirectories(vararg names: String): (String) -> File { + var dir = rootDir + for (name in names) { + dir = dir.resolve(name) + } + return { targetName -> + val directory = dir.walkTopDown().maxDepth(3) + .filter(File::isDirectory) + .associateBy { it.name } + directory[targetName] ?: throw Error("그런 폴더가 μ—†μŠ΅λ‹ˆλ‹€: $targetName") + } +} + +val commentDirectory = getDirectories("services", "comment") + +// SERVICE/COMMENT +include( + comment, + commentApi, + commentDomain, + commentException, + commentReadModel, + commentApplication, + commentRdbAdapter, + commentWebMvcAdapter, +) + +project(comment).projectDir = commentDirectory("comment") +project(commentApi).projectDir = commentDirectory("api") +project(commentDomain).projectDir = commentDirectory("domain") +project(commentException).projectDir = commentDirectory("exception") +project(commentReadModel).projectDir = commentDirectory("readmodel") +project(commentApplication).projectDir = commentDirectory("application") +project(commentRdbAdapter).projectDir = commentDirectory("rdb") +project(commentWebMvcAdapter).projectDir = commentDirectory("web-mvc") diff --git a/services/comment/driven/rdb/build.gradle.kts b/services/comment/driven/rdb/build.gradle.kts new file mode 100644 index 0000000..e6322c8 --- /dev/null +++ b/services/comment/driven/rdb/build.gradle.kts @@ -0,0 +1,18 @@ +dependencies { + val bom = dependencyManagement.importedProperties + + api(project(":comment:comment-api")) + api(project(":comment:comment-application")) + api(project(":jpa-core")) + + + // querydsl + implementation("com.querydsl:querydsl-jpa:${bom["querydsl.version"]}:jakarta") + annotationProcessor("com.querydsl:querydsl-apt:${bom["querydsl.version"]}:jakarta") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + + // mapstruct + implementation("org.mapstruct:mapstruct:1.6.3") + annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3") + annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0") +} \ No newline at end of file diff --git a/services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/entity/CommentEntity.java b/services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/entity/CommentEntity.java new file mode 100644 index 0000000..eb7f24a --- /dev/null +++ b/services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/entity/CommentEntity.java @@ -0,0 +1,56 @@ +package nettee.comment.driven.rdb.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import java.util.Objects; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.NoArgsConstructor; +import nettee.comment.driven.rdb.entity.type.CommentEntityStatus; +import nettee.comment.driven.rdb.entity.type.CommentEntityStatusConverter; +import nettee.jpa.support.LongBaseTimeEntity; +import org.hibernate.annotations.DynamicUpdate; + +@DynamicUpdate +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity(name = "comment") +public class CommentEntity extends LongBaseTimeEntity { + + @Column(nullable = false) + public Long boardId; + + public String content; + + @Convert(converter = CommentEntityStatusConverter.class) + public CommentEntityStatus status; + + @Builder + public CommentEntity(Long boardId, String content, CommentEntityStatus status) { + this.boardId = boardId; + this.content = content; + this.status = status; + } + + @Builder( + builderClassName = "updateCommentEntityBuilder", + builderMethodName = "prepareCommentEntityUpdate", + buildMethodName = "update" + ) + public void update(String content) { + Objects.requireNonNull(content, "Content cannot be null!"); + + this.content = content; + } + + @Builder( + builderClassName = "updateStatusCommentEntityBuilder", + builderMethodName = "prepareCommentEntityStatusUpdate", + buildMethodName = "updateStatus" + ) + public void updateStatus(CommentEntityStatus status) { + Objects.requireNonNull(status, "status cannot be null"); + + this.status = status; + } +} diff --git a/services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/entity/type/CommentEntityStatus.java b/services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/entity/type/CommentEntityStatus.java new file mode 100644 index 0000000..77ca59c --- /dev/null +++ b/services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/entity/type/CommentEntityStatus.java @@ -0,0 +1,121 @@ +package nettee.comment.driven.rdb.entity.type; + +import nettee.comment.domain.type.CommentStatus; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import static nettee.comment.exception.CommentCommandErrorCode.DEFAULT; + +public enum CommentEntityStatus { + REMOVED( + SemanticCodeParameters.builder() + .canRead(false) + .classifyingBits(0b0000_0000_0000_0000) + ), + PENDING( + SemanticCodeParameters.builder() + .canRead(false) + .classifyingBits(0b0000_0000_0000_0001) + ), + ACTIVE( + SemanticCodeParameters.builder() + .canRead(true) + .classifyingBits(0b0000_0000_0000_0010) + ); + + /* + R000 0000 0000 0000 0PPP PPPP PPPP PPPP + R: generally readable status (1: readable, 0: unreadable) + 0: classifying bits (16 bits) + P: detailed or padded bits (15 bits) + */ + private static final int TLB_PADDING_SIZE = 31; + private static final int CLASSIFYING_PADDING_SIZE = 15; + + private final int code; + + static { + // NOTE util ν•¨μˆ˜κ°€ μΆ”κ°€λ˜λ©΄ λ¦¬νŒ©ν† λ§ + assert Arrays.stream(values()) + .map(CommentEntityStatus::getCode) + .collect(Collectors.toSet()) + .size() + == values().length + : "CommentEntityStatus의 λͺ¨λ“  code ν•„λ“œκ°€ κ³ μœ ν•΄μ•Ό ν•©λ‹ˆλ‹€."; + } + + CommentEntityStatus(SemanticCodeParameters semanticCodeParameters) { + this( + semanticCodeParameters.canRead, + semanticCodeParameters.classifyingBits, + semanticCodeParameters.detailBits + ); + } + + CommentEntityStatus(boolean canRead, int classifyingBits, int detailBits) { + this.code = (canRead ? 1 << TLB_PADDING_SIZE : 0) + | (classifyingBits << CLASSIFYING_PADDING_SIZE) + | detailBits; + } + + public int getCode() { + return code; + } + + public static CommentEntityStatus valueOf(CommentStatus commentStatus) { + assert Set.of(CommentStatus.REMOVED, CommentStatus.PENDING, CommentStatus.ACTIVE) + .containsAll(Arrays.stream(CommentStatus.values()).collect(Collectors.toSet())) + : "commentStatus 쀑 일뢀가 CommentEntityStatus::valueOf ν•¨μˆ˜μ—μ„œ λ§€ν•‘λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."; + + return switch (commentStatus){ + case REMOVED -> REMOVED; + case PENDING -> PENDING; + case ACTIVE -> ACTIVE; + default -> throw new Error("commentStatus 쀑 일뢀가 CommentEntityStatus::valueOf ν•¨μˆ˜μ—μ„œ λ§€ν•‘λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + }; + } + + public static CommentEntityStatus valueOf(int value) { + return switch (value) { + case 0b0__0000_0000_0000_0000__000_0000_0000_0000 -> REMOVED; + case 0b0__0000_0000_0000_0001__000_0000_0000_0000 -> PENDING; + case 0b1__0000_0000_0000_0010__000_0000_0000_0000 -> ACTIVE; + default -> throw DEFAULT.exception(); + }; + } + + static class SemanticCodeParameters { + boolean canRead; + Integer classifyingBits; + int detailBits; + + private SemanticCodeParameters() {} + + public static SemanticCodeParameters builder() { + return new SemanticCodeParameters<>(); + } + + SemanticCodeParameters classifyingBits(Integer classifyingBits) { + this.classifyingBits = classifyingBits; + return (SemanticCodeParameters) this; + } + + SemanticCodeParameters canRead(boolean canRead) { + this.canRead = canRead; + return (SemanticCodeParameters) this; + } + + SemanticCodeParameters detailBits(int detailBits) { + this.detailBits = detailBits; + return this; + } + + } + + // NOTE move to other module after its place is determined + // Marker interfaces + interface Missing {} + interface Present {} +} \ No newline at end of file diff --git a/services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/entity/type/CommentEntityStatusConverter.java b/services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/entity/type/CommentEntityStatusConverter.java new file mode 100644 index 0000000..1455550 --- /dev/null +++ b/services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/entity/type/CommentEntityStatusConverter.java @@ -0,0 +1,18 @@ +package nettee.comment.driven.rdb.entity.type; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter +public class CommentEntityStatusConverter implements AttributeConverter { + + @Override + public Integer convertToDatabaseColumn(CommentEntityStatus status) { + return status.getCode(); + } + + @Override + public CommentEntityStatus convertToEntityAttribute(Integer value) { + return CommentEntityStatus.valueOf(value); + } +} \ No newline at end of file diff --git a/services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/persistence/CommentCommandAdapter.java b/services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/persistence/CommentCommandAdapter.java new file mode 100644 index 0000000..3ef58d9 --- /dev/null +++ b/services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/persistence/CommentCommandAdapter.java @@ -0,0 +1,55 @@ +package nettee.comment.driven.rdb.persistence; + +import lombok.RequiredArgsConstructor; +import nettee.comment.domain.Comment; +import nettee.comment.driven.rdb.entity.type.CommentEntityStatus; +import nettee.comment.driven.rdb.persistence.mapper.CommentEntityMapper; +import nettee.comment.application.port.CommentCommandRepositoryPort; +import nettee.comment.domain.type.CommentStatus; +import org.springframework.dao.DataAccessException; +import org.springframework.stereotype.Repository; + +import static nettee.comment.exception.CommentCommandErrorCode.COMMENT_NOT_FOUND; +import static nettee.comment.exception.CommentCommandErrorCode.DEFAULT; + +@Repository +@RequiredArgsConstructor +public class CommentCommandAdapter implements CommentCommandRepositoryPort { + + private final CommentJpaRepository commentJpaRepository; + private final CommentEntityMapper commentEntityMapper; + + @Override + public Comment save(Comment comment) { + var commentEntity = commentEntityMapper.toEntity(comment); + try { + var newComment = commentJpaRepository.save(commentEntity); + commentJpaRepository.flush(); + return commentEntityMapper.toDomain(newComment); + } catch (DataAccessException e) { + throw DEFAULT.exception(e); + } + } + + @Override + public Comment update(Comment comment) { + var existComment = commentJpaRepository.findById(comment.getId()) + .orElseThrow(COMMENT_NOT_FOUND::exception); + + existComment.prepareCommentEntityUpdate() + .content(comment.getContent()) + .update(); + + return commentEntityMapper.toDomain(existComment); + } + + @Override + public void updateStatus(Long id, CommentStatus status) { + var existComment = commentJpaRepository.findById(id) + .orElseThrow(COMMENT_NOT_FOUND::exception); + + existComment.prepareCommentEntityStatusUpdate() + .status(CommentEntityStatus.valueOf(status)) + .updateStatus(); + } +} diff --git a/services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/persistence/CommentJpaRepository.java b/services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/persistence/CommentJpaRepository.java new file mode 100644 index 0000000..25937f3 --- /dev/null +++ b/services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/persistence/CommentJpaRepository.java @@ -0,0 +1,8 @@ +package nettee.comment.driven.rdb.persistence; + +import nettee.comment.driven.rdb.entity.CommentEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentJpaRepository extends JpaRepository { + +} diff --git a/services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/persistence/CommentQueryAdapter.java b/services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/persistence/CommentQueryAdapter.java new file mode 100644 index 0000000..3ce159f --- /dev/null +++ b/services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/persistence/CommentQueryAdapter.java @@ -0,0 +1,53 @@ +package nettee.comment.driven.rdb.persistence; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import nettee.comment.driven.rdb.persistence.mapper.CommentEntityMapper; +import nettee.comment.driven.rdb.entity.CommentEntity; +import nettee.comment.model.CommentQueryModels.CommentDetail; +import nettee.comment.application.port.CommentQueryRepositoryPort; +import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport; +import org.springframework.stereotype.Repository; + +import static nettee.comment.driven.rdb.entity.QCommentEntity.commentEntity; + +@Repository +public class CommentQueryAdapter extends QuerydslRepositorySupport implements CommentQueryRepositoryPort { + + private final CommentEntityMapper commentEntityMapper; + + public CommentQueryAdapter(CommentEntityMapper commentEntityMapper) { + super(CommentEntity.class); + this.commentEntityMapper = commentEntityMapper; + } + + @Override + public Optional findById(Long id) { + return commentEntityMapper.toOptionalCommentDetail( + getQuerydsl().createQuery() + .select(commentEntity) + .from(commentEntity) + .where(commentEntity.id.eq(id)) + .fetchOne() + ); + } + + @Override + public List findPageByBoardId(Long boardId, int offset, int size) { + var entityList = getQuerydsl().createQuery() + .select(commentEntity) + .from(commentEntity) + .where(commentEntity.boardId.eq(boardId)) + .offset(offset) + .limit(size) + .orderBy(commentEntity.createdAt.asc()) + .fetch(); + + var result = entityList.stream() + .map(entity -> commentEntityMapper.toCommentDetail(entity)) + .collect(Collectors.toList()); + + return result; + } +} diff --git a/services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/persistence/mapper/CommentEntityMapper.java b/services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/persistence/mapper/CommentEntityMapper.java new file mode 100644 index 0000000..1fb613e --- /dev/null +++ b/services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/persistence/mapper/CommentEntityMapper.java @@ -0,0 +1,19 @@ +package nettee.comment.driven.rdb.persistence.mapper; + +import java.util.Optional; +import nettee.comment.domain.Comment; +import nettee.comment.driven.rdb.entity.CommentEntity; +import nettee.comment.model.CommentQueryModels.CommentDetail; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface CommentEntityMapper { + + Comment toDomain(CommentEntity commentEntity); + CommentDetail toCommentDetail(CommentEntity commentEntity); + CommentEntity toEntity(Comment comment); + + default Optional toOptionalCommentDetail(CommentEntity commentEntity) { + return Optional.ofNullable(toCommentDetail(commentEntity)); + } +} \ No newline at end of file diff --git a/services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/entity/ReplyEntity.java b/services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/entity/ReplyEntity.java new file mode 100644 index 0000000..b9b29c5 --- /dev/null +++ b/services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/entity/ReplyEntity.java @@ -0,0 +1,56 @@ +package nettee.reply.driven.rdb.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import java.util.Objects; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.NoArgsConstructor; +import nettee.jpa.support.LongBaseTimeEntity; +import nettee.reply.driven.rdb.entity.type.ReplyEntityStatus; +import nettee.reply.driven.rdb.entity.type.ReplyEntityStatusConverter; +import org.hibernate.annotations.DynamicUpdate; + +@DynamicUpdate +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity(name = "reply") +public class ReplyEntity extends LongBaseTimeEntity { + + @Column(nullable = false) + public Long commentId; + + public String content; + + @Convert(converter = ReplyEntityStatusConverter.class) + public ReplyEntityStatus status; + + @Builder + public ReplyEntity(Long commentId, String content, ReplyEntityStatus status) { + this.commentId = commentId; + this.content = content; + this.status = status; + } + + @Builder( + builderClassName = "updateReplyEntityBuilder", + builderMethodName = "prepareReplyEntityUpdate", + buildMethodName = "update" + ) + public void update(String content) { + Objects.requireNonNull(content, "Content cannot be null!"); + + this.content = content; + } + + @Builder( + builderClassName = "updateStatusReplyEntityBuilder", + builderMethodName = "prepareReplyEntityStatusUpdate", + buildMethodName = "updateStatus" + ) + public void updateStatus(ReplyEntityStatus status) { + Objects.requireNonNull(status, "status cannot be null"); + + this.status = status; + } +} diff --git a/services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/entity/type/ReplyEntityStatus.java b/services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/entity/type/ReplyEntityStatus.java new file mode 100644 index 0000000..a752b7e --- /dev/null +++ b/services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/entity/type/ReplyEntityStatus.java @@ -0,0 +1,120 @@ +package nettee.reply.driven.rdb.entity.type; + +import static nettee.reply.exception.ReplyCommandErrorCode.DEFAULT; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; +import nettee.reply.domain.type.ReplyStatus; + +public enum ReplyEntityStatus { + REMOVED( + SemanticCodeParameters.builder() + .canRead(false) + .classifyingBits(0b0000_0000_0000_0000) + ), + PENDING( + SemanticCodeParameters.builder() + .canRead(false) + .classifyingBits(0b0000_0000_0000_0001) + ), + ACTIVE( + SemanticCodeParameters.builder() + .canRead(true) + .classifyingBits(0b0000_0000_0000_0010) + ); + + /* + R000 0000 0000 0000 0PPP PPPP PPPP PPPP + R: generally readable status (1: readable, 0: unreadable) + 0: classifying bits (16 bits) + P: detailed or padded bits (15 bits) + */ + private static final int TLB_PADDING_SIZE = 31; + private static final int CLASSIFYING_PADDING_SIZE = 15; + + private final int code; + + static { + // NOTE util ν•¨μˆ˜κ°€ μΆ”κ°€λ˜λ©΄ λ¦¬νŒ©ν† λ§ + assert Arrays.stream(values()) + .map(ReplyEntityStatus::getCode) + .collect(Collectors.toSet()) + .size() + == values().length + : "ReplyEntityStatus의 λͺ¨λ“  code ν•„λ“œκ°€ κ³ μœ ν•΄μ•Ό ν•©λ‹ˆλ‹€."; + } + + ReplyEntityStatus(SemanticCodeParameters semanticCodeParameters) { + this( + semanticCodeParameters.canRead, + semanticCodeParameters.classifyingBits, + semanticCodeParameters.detailBits + ); + } + + ReplyEntityStatus(boolean canRead, int classifyingBits, int detailBits) { + this.code = (canRead ? 1 << TLB_PADDING_SIZE : 0) + | (classifyingBits << CLASSIFYING_PADDING_SIZE) + | detailBits; + } + + public int getCode() { + return code; + } + + public static ReplyEntityStatus valueOf(ReplyStatus replyStatus) { + assert Set.of(ReplyStatus.REMOVED, ReplyStatus.PENDING, ReplyStatus.ACTIVE) + .containsAll(Arrays.stream(replyStatus.values()).collect(Collectors.toSet())) + : "replyStatus 쀑 일뢀가 ReplyEntityStatus::valueOf ν•¨μˆ˜μ—μ„œ λ§€ν•‘λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."; + + return switch (replyStatus){ + case REMOVED -> REMOVED; + case PENDING -> PENDING; + case ACTIVE -> ACTIVE; + default -> throw new Error("replyStatus 쀑 일뢀가 ReplyEntityStatus::valueOf ν•¨μˆ˜μ—μ„œ λ§€ν•‘λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + }; + } + + public static ReplyEntityStatus valueOf(int value) { + return switch (value) { + case 0b0__0000_0000_0000_0000__000_0000_0000_0000 -> REMOVED; + case 0b0__0000_0000_0000_0001__000_0000_0000_0000 -> PENDING; + case 0b1__0000_0000_0000_0010__000_0000_0000_0000 -> ACTIVE; + default -> throw DEFAULT.exception(); + }; + } + + static class SemanticCodeParameters { + boolean canRead; + Integer classifyingBits; + int detailBits; + + private SemanticCodeParameters() {} + + public static SemanticCodeParameters builder() { + return new SemanticCodeParameters<>(); + } + + SemanticCodeParameters classifyingBits(Integer classifyingBits) { + this.classifyingBits = classifyingBits; + return (SemanticCodeParameters) this; + } + + SemanticCodeParameters canRead(boolean canRead) { + this.canRead = canRead; + return (SemanticCodeParameters) this; + } + + SemanticCodeParameters detailBits(int detailBits) { + this.detailBits = detailBits; + return this; + } + + } + + // NOTE move to other module after its place is determined + // Marker interfaces + interface Missing {} + interface Present {} +} \ No newline at end of file diff --git a/services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/entity/type/ReplyEntityStatusConverter.java b/services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/entity/type/ReplyEntityStatusConverter.java new file mode 100644 index 0000000..9fe6b15 --- /dev/null +++ b/services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/entity/type/ReplyEntityStatusConverter.java @@ -0,0 +1,19 @@ +package nettee.reply.driven.rdb.entity.type; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + + +@Converter +public class ReplyEntityStatusConverter implements AttributeConverter { + + @Override + public Integer convertToDatabaseColumn(ReplyEntityStatus status) { + return status.getCode(); + } + + @Override + public ReplyEntityStatus convertToEntityAttribute(Integer value) { + return ReplyEntityStatus.valueOf(value); + } +} \ No newline at end of file diff --git a/services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/persistence/ReplyCommandAdapter.java b/services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/persistence/ReplyCommandAdapter.java new file mode 100644 index 0000000..bf0acb7 --- /dev/null +++ b/services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/persistence/ReplyCommandAdapter.java @@ -0,0 +1,55 @@ +package nettee.reply.driven.rdb.persistence; + +import lombok.RequiredArgsConstructor; +import nettee.reply.domain.Reply; +import nettee.reply.driven.rdb.entity.type.ReplyEntityStatus; +import nettee.reply.driven.rdb.persistence.mapper.ReplyEntityMapper; +import nettee.reply.application.port.ReplyCommandRepositoryPort; +import nettee.reply.domain.type.ReplyStatus; +import org.springframework.dao.DataAccessException; +import org.springframework.stereotype.Repository; + +import static nettee.reply.exception.ReplyCommandErrorCode.DEFAULT; +import static nettee.reply.exception.ReplyCommandErrorCode.REPLY_NOT_FOUND; + +@Repository +@RequiredArgsConstructor +public class ReplyCommandAdapter implements ReplyCommandRepositoryPort { + + private final ReplyJpaRepository replyJpaRepository; + private final ReplyEntityMapper replyEntityMapper; + + @Override + public Reply save(Reply reply) { + var replyEntity = replyEntityMapper.toEntity(reply); + try { + var newReply = replyJpaRepository.save(replyEntity); + replyJpaRepository.flush(); + return replyEntityMapper.toDomain(newReply); + } catch (DataAccessException e) { + throw DEFAULT.exception(e); + } + } + + @Override + public Reply update(Reply reply) { + var existReply = replyJpaRepository.findById(reply.getId()) + .orElseThrow(REPLY_NOT_FOUND::exception); + + existReply.prepareReplyEntityUpdate() + .content(reply.getContent()) + .update(); + + return replyEntityMapper.toDomain(existReply); + } + + @Override + public void updateStatus(Long id, ReplyStatus status) { + var existReply = replyJpaRepository.findById(id) + .orElseThrow(REPLY_NOT_FOUND::exception); + + existReply.prepareReplyEntityStatusUpdate() + .status(ReplyEntityStatus.valueOf(status)) + .updateStatus(); + } +} diff --git a/services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/persistence/ReplyJpaRepository.java b/services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/persistence/ReplyJpaRepository.java new file mode 100644 index 0000000..c3d0054 --- /dev/null +++ b/services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/persistence/ReplyJpaRepository.java @@ -0,0 +1,8 @@ +package nettee.reply.driven.rdb.persistence; + +import nettee.reply.driven.rdb.entity.ReplyEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReplyJpaRepository extends JpaRepository { + +} diff --git a/services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/persistence/ReplyQueryAdapter.java b/services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/persistence/ReplyQueryAdapter.java new file mode 100644 index 0000000..3a7da28 --- /dev/null +++ b/services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/persistence/ReplyQueryAdapter.java @@ -0,0 +1,74 @@ +package nettee.reply.driven.rdb.persistence; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import nettee.reply.driven.rdb.persistence.mapper.ReplyEntityMapper; +import nettee.reply.driven.rdb.entity.ReplyEntity; +import nettee.reply.model.ReplyQueryModels.ReplyDetail; +import nettee.reply.application.port.ReplyQueryRepositoryPort; +import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport; +import org.springframework.stereotype.Repository; + +import static nettee.reply.driven.rdb.entity.QReplyEntity.replyEntity; + +@Repository +public class ReplyQueryAdapter extends QuerydslRepositorySupport implements ReplyQueryRepositoryPort { + + private final ReplyEntityMapper replyEntityMapper; + + public ReplyQueryAdapter(ReplyEntityMapper replyEntityMapper) { + super(ReplyEntity.class); + this.replyEntityMapper = replyEntityMapper; + } + + @Override + public Optional findById(Long id) { + return replyEntityMapper.toOptionalReplyDetail( + getQuerydsl().createQuery() + .select(replyEntity) + .from(replyEntity) + .where(replyEntity.id.eq(id)) + .fetchOne() + ); + } + + @Override + public List findPageByCommentId(Long commentId, int offset, int size) { + var entityList = getQuerydsl().createQuery() + .select(replyEntity) + .from(replyEntity) + .where(replyEntity.commentId.eq(commentId)) + .offset(offset) + .limit(size) + .orderBy(replyEntity.createdAt.asc()) + .fetch(); + + var result = entityList.stream() + .map(entity -> replyEntityMapper.toReplyDetail(entity)) + .collect(Collectors.toList()); + + return result; + } + + @Override + public List findPageByCommentIdAfter(Long commentId, Instant createdAt, int size) { + var entityList = getQuerydsl().createQuery() + .select(replyEntity) + .from(replyEntity) + .where( + replyEntity.commentId.eq(commentId).and( + replyEntity.createdAt.after(createdAt))) + .offset(0) + .limit(size) + .orderBy(replyEntity.createdAt.asc()) + .fetch(); + + var result = entityList.stream() + .map(entity -> replyEntityMapper.toReplyDetail(entity)) + .collect(Collectors.toList()); + + return result; + } +} diff --git a/services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/persistence/mapper/ReplyEntityMapper.java b/services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/persistence/mapper/ReplyEntityMapper.java new file mode 100644 index 0000000..c82ec04 --- /dev/null +++ b/services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/persistence/mapper/ReplyEntityMapper.java @@ -0,0 +1,22 @@ +package nettee.reply.driven.rdb.persistence.mapper; + +import java.util.Optional; +import nettee.reply.domain.Reply; +import nettee.reply.driven.rdb.entity.ReplyEntity; +import nettee.reply.model.ReplyQueryModels.ReplyDetail; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface ReplyEntityMapper { + + Reply toDomain(ReplyEntity replyEntity); + + ReplyDetail toReplyDetail(ReplyEntity replyEntity); + + ReplyEntity toEntity(Reply reply); + + default Optional toOptionalReplyDetail(ReplyEntity replyEntity) { + return Optional.ofNullable(toReplyDetail(replyEntity)); + } + +} \ No newline at end of file diff --git a/services/comment/driven/rdb/src/main/resources/db/postgresql/migration/v1_0/V1_0_3__create_tb_comment.sql b/services/comment/driven/rdb/src/main/resources/db/postgresql/migration/v1_0/V1_0_3__create_tb_comment.sql new file mode 100644 index 0000000..6e5b88f --- /dev/null +++ b/services/comment/driven/rdb/src/main/resources/db/postgresql/migration/v1_0/V1_0_3__create_tb_comment.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS comment ( + id BIGSERIAL, + board_id BIGINT, + content VARCHAR(255), + status VARCHAR(255), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP, + + CONSTRAINT pk_comment PRIMARY KEY (id) +); + +--ν…Œμ΄λΈ” μ½”λ©˜νŠΈ +COMMENT ON TABLE comment IS 'λŒ“κΈ€'; + +-- 컬럼 μ½”λ©˜νŠΈ +COMMENT ON COLUMN comment.content IS 'λ‚΄μš©'; +COMMENT ON COLUMN comment.board_id IS 'κ²Œμ‹œλ¬Ό ID'; +COMMENT ON COLUMN comment.status IS 'μƒνƒœ'; +COMMENT ON COLUMN comment.created_at IS 'μƒμ„±μ‹œκ°„'; +COMMENT ON COLUMN comment.updated_at IS 'λ§ˆμ§€λ§‰ μˆ˜μ •μ‹œκ°„'; \ No newline at end of file diff --git a/services/comment/driven/rdb/src/main/resources/db/postgresql/migration/v1_0/V1_0_4__create_tb_reply.sql b/services/comment/driven/rdb/src/main/resources/db/postgresql/migration/v1_0/V1_0_4__create_tb_reply.sql new file mode 100644 index 0000000..5e3ef33 --- /dev/null +++ b/services/comment/driven/rdb/src/main/resources/db/postgresql/migration/v1_0/V1_0_4__create_tb_reply.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS reply ( + id BIGSERIAL, + comment_id BIGINT, + content VARCHAR(255), + status VARCHAR(255), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP, + + CONSTRAINT pk_reply PRIMARY KEY (id) +); + +--ν…Œμ΄λΈ” μ½”λ©˜νŠΈ +COMMENT ON TABLE reply IS 'λ‹΅κΈ€'; + +-- 컬럼 μ½”λ©˜νŠΈ +COMMENT ON COLUMN reply.comment_id IS 'λΆ€λͺ¨ λŒ“κΈ€ ID'; +COMMENT ON COLUMN reply.content IS 'λ‚΄μš©'; +COMMENT ON COLUMN reply.status IS 'μƒνƒœ'; +COMMENT ON COLUMN reply.created_at IS 'μƒμ„±μ‹œκ°„'; +COMMENT ON COLUMN reply.updated_at IS 'λ§ˆμ§€λ§‰ μˆ˜μ •μ‹œκ°„'; \ No newline at end of file diff --git a/services/comment/driven/rdb/src/main/resources/db/postgresql/migration/v1_0/V1_0_5__alter_comment_status_as_integer.sql b/services/comment/driven/rdb/src/main/resources/db/postgresql/migration/v1_0/V1_0_5__alter_comment_status_as_integer.sql new file mode 100644 index 0000000..9ad7036 --- /dev/null +++ b/services/comment/driven/rdb/src/main/resources/db/postgresql/migration/v1_0/V1_0_5__alter_comment_status_as_integer.sql @@ -0,0 +1 @@ +ALTER TABLE comment ALTER COLUMN status TYPE INTEGER USING status::INTEGER; \ No newline at end of file diff --git a/services/comment/driven/rdb/src/main/resources/db/postgresql/migration/v1_0/V1_0_6__alter_reply_status_as_integer.sql b/services/comment/driven/rdb/src/main/resources/db/postgresql/migration/v1_0/V1_0_6__alter_reply_status_as_integer.sql new file mode 100644 index 0000000..3152301 --- /dev/null +++ b/services/comment/driven/rdb/src/main/resources/db/postgresql/migration/v1_0/V1_0_6__alter_reply_status_as_integer.sql @@ -0,0 +1 @@ +ALTER TABLE reply ALTER COLUMN status TYPE INTEGER USING status::INTEGER; \ No newline at end of file diff --git a/services/comment/driven/rdb/src/main/resources/properties/db/comment.database-local.yml b/services/comment/driven/rdb/src/main/resources/properties/db/comment.database-local.yml new file mode 100644 index 0000000..7422de6 --- /dev/null +++ b/services/comment/driven/rdb/src/main/resources/properties/db/comment.database-local.yml @@ -0,0 +1,11 @@ +spring: + datasource: + driver-class-name: org.postgresql.Driver + url: ${COMMENT_POSTGRESQL_URL:jdbc:postgresql://localhost:5433/demo} + username: ${COMMENT_POSTGRESQL_USERNAME:root} + password: ${COMMENT_POSTGRESQL_PASSWORD:root} + + flyway: + baseline-on-migrate: true + locations: + - db/postgresql/migration/v1_0 \ No newline at end of file diff --git a/services/comment/driven/rdb/src/main/resources/properties/db/comment.database.yml b/services/comment/driven/rdb/src/main/resources/properties/db/comment.database.yml new file mode 100644 index 0000000..1d09b72 --- /dev/null +++ b/services/comment/driven/rdb/src/main/resources/properties/db/comment.database.yml @@ -0,0 +1,11 @@ +spring: + datasource: + driver-class-name: org.postgresql.Driver + url: ${COMMENT_POSTGRESQL_URL} + username: ${COMMENT_POSTGRESQL_USERNAME} + password: ${COMMENT_POSTGRESQL_PASSWORD} + + flyway: + baseline-on-migrate: true + locations: + - db/postgresql/migration/v1_0 \ No newline at end of file diff --git a/services/comment/driving/web-mvc/build.gradle.kts b/services/comment/driving/web-mvc/build.gradle.kts new file mode 100644 index 0000000..ceac9d9 --- /dev/null +++ b/services/comment/driving/web-mvc/build.gradle.kts @@ -0,0 +1,15 @@ +val commentApplication: String by project + +dependencies { + api(project(commentApplication)) + + // validation + compileOnly("jakarta.validation:jakarta.validation-api") + compileOnly("jakarta.annotation:jakarta.annotation-api") + + // mapstruct + compileOnly("org.mapstruct:mapstruct:1.6.3") + annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3") + annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0") +} + diff --git a/services/comment/driving/web-mvc/src/main/java/nettee/comment/web/CommentCommandApi.java b/services/comment/driving/web-mvc/src/main/java/nettee/comment/web/CommentCommandApi.java new file mode 100644 index 0000000..abb7e09 --- /dev/null +++ b/services/comment/driving/web-mvc/src/main/java/nettee/comment/web/CommentCommandApi.java @@ -0,0 +1,60 @@ +package nettee.comment.web; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import nettee.comment.application.usecase.CommentCreateUseCase; +import nettee.comment.application.usecase.CommentDeleteUseCase; +import nettee.comment.application.usecase.CommentUpdateUseCase; +import nettee.comment.web.dto.CommentCommandDto.CommentCommandResponse; +import nettee.comment.web.dto.CommentCommandDto.CommentCreateCommand; +import nettee.comment.web.dto.CommentCommandDto.CommentUpdateCommand; +import nettee.comment.web.mapper.CommentDtoMapper; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/comments") +@RequiredArgsConstructor +public class CommentCommandApi { + + private final CommentCreateUseCase commentCreateUseCase; + private final CommentUpdateUseCase commentUpdateUseCase; + private final CommentDeleteUseCase commentDeleteUseCase; + private final CommentDtoMapper mapper; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public CommentCommandResponse create( + @RequestBody @Valid CommentCreateCommand command + ) { + var comment = mapper.toDomain(command); + return CommentCommandResponse.builder() + .comment(commentCreateUseCase.createComment(comment)) + .build(); + } + + @PatchMapping("/{id}") + @ResponseStatus(HttpStatus.OK) + public CommentCommandResponse update( + @PathVariable("id") Long id, + @RequestBody @Valid CommentUpdateCommand command + ) { + var comment = mapper.toDomain(command); + return CommentCommandResponse.builder() + .comment(commentUpdateUseCase.updateComment(comment)) + .build(); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable("id") Long id) { + commentDeleteUseCase.deleteComment(id); + } +} diff --git a/services/comment/driving/web-mvc/src/main/java/nettee/comment/web/CommentQueryApi.java b/services/comment/driving/web-mvc/src/main/java/nettee/comment/web/CommentQueryApi.java new file mode 100644 index 0000000..cacd224 --- /dev/null +++ b/services/comment/driving/web-mvc/src/main/java/nettee/comment/web/CommentQueryApi.java @@ -0,0 +1,24 @@ +package nettee.comment.web; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import nettee.comment.model.CommentQueryModels.CommentDetail; +import nettee.comment.application.service.CommentQueryService; +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; + +@RestController +@RequestMapping("/comments") +@RequiredArgsConstructor +public class CommentQueryApi { + + private final CommentQueryService commentQueryService; + + @GetMapping("/{boardId}") + public List getCommentsByBoardId(@PathVariable("boardId") Long boardId) { + return commentQueryService.getCommentsByBoardId(boardId); + } + +} diff --git a/services/comment/driving/web-mvc/src/main/java/nettee/comment/web/dto/CommentCommandDto.java b/services/comment/driving/web-mvc/src/main/java/nettee/comment/web/dto/CommentCommandDto.java new file mode 100644 index 0000000..e105b88 --- /dev/null +++ b/services/comment/driving/web-mvc/src/main/java/nettee/comment/web/dto/CommentCommandDto.java @@ -0,0 +1,46 @@ +package nettee.comment.web.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import nettee.comment.domain.Comment; +import nettee.comment.domain.type.CommentStatus; + +public class CommentCommandDto { + + private CommentCommandDto() { + + } + + @Builder + public record CommentCreateCommand( + @NotNull(message = "boardIdλ₯Ό μž…λ ₯ν•˜μ‹­μ‹œμ˜€") + Long boardId, + @NotBlank(message = "본문을 μž…λ ₯ν•˜μ‹­μ‹œμ˜€") + String content, + @NotNull(message = "μƒνƒœλ₯Ό μž…λ ₯ν•˜μ‹­μ‹œμ˜€") + CommentStatus status + ) { + + } + + @Builder + public record CommentUpdateCommand( + @NotNull(message = "idλ₯Ό μž…λ ₯ν•˜μ‹­μ‹œμ˜€") + Long id, + @NotBlank(message = "본문을 μž…λ ₯ν•˜μ‹­μ‹œμ˜€") + String content, + @NotNull(message = "μƒνƒœλ₯Ό μž…λ ₯ν•˜μ‹­μ‹œμ˜€") + CommentStatus status + ){ + + } + + @Builder + public record CommentCommandResponse( + Comment comment + ){ + + } + +} diff --git a/services/comment/driving/web-mvc/src/main/java/nettee/comment/web/mapper/CommentDtoMapper.java b/services/comment/driving/web-mvc/src/main/java/nettee/comment/web/mapper/CommentDtoMapper.java new file mode 100644 index 0000000..cf7e1cf --- /dev/null +++ b/services/comment/driving/web-mvc/src/main/java/nettee/comment/web/mapper/CommentDtoMapper.java @@ -0,0 +1,12 @@ +package nettee.comment.web.mapper; + +import nettee.comment.domain.Comment; +import nettee.comment.web.dto.CommentCommandDto.CommentCreateCommand; +import nettee.comment.web.dto.CommentCommandDto.CommentUpdateCommand; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface CommentDtoMapper { + Comment toDomain(CommentCreateCommand command); + Comment toDomain(CommentUpdateCommand command); +} diff --git a/services/comment/driving/web-mvc/src/main/java/nettee/reply/web/ReplyCommandApi.java b/services/comment/driving/web-mvc/src/main/java/nettee/reply/web/ReplyCommandApi.java new file mode 100644 index 0000000..e907af7 --- /dev/null +++ b/services/comment/driving/web-mvc/src/main/java/nettee/reply/web/ReplyCommandApi.java @@ -0,0 +1,60 @@ +package nettee.reply.web; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import nettee.reply.application.usecase.ReplyCreateUseCase; +import nettee.reply.application.usecase.ReplyDeleteUseCase; +import nettee.reply.application.usecase.ReplyUpdateUseCase; +import nettee.reply.web.dto.ReplyCommandDto.ReplyCommandResponse; +import nettee.reply.web.dto.ReplyCommandDto.ReplyCreateCommand; +import nettee.reply.web.dto.ReplyCommandDto.ReplyUpdateCommand; +import nettee.reply.web.mapper.ReplyDtoMapper; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/replies") +@RequiredArgsConstructor +public class ReplyCommandApi { + + private final ReplyCreateUseCase replyCreateUseCase; + private final ReplyUpdateUseCase replyUpdateUseCase; + private final ReplyDeleteUseCase replyDeleteUseCase; + private final ReplyDtoMapper mapper; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ReplyCommandResponse create( + @RequestBody @Valid ReplyCreateCommand command + ) { + var reply = mapper.toDomain(command); + return ReplyCommandResponse.builder() + .reply(replyCreateUseCase.createReply(reply)) + .build(); + } + + @PatchMapping("/{id}") + @ResponseStatus(HttpStatus.OK) + public ReplyCommandResponse update( + @PathVariable("id") Long id, + @RequestBody @Valid ReplyUpdateCommand command + ) { + var reply = mapper.toDomain(command); + return ReplyCommandResponse.builder() + .reply(replyUpdateUseCase.updateReply(reply)) + .build(); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable("id") Long id) { + replyDeleteUseCase.deleteReply(id); + } +} diff --git a/services/comment/driving/web-mvc/src/main/java/nettee/reply/web/ReplyQueryApi.java b/services/comment/driving/web-mvc/src/main/java/nettee/reply/web/ReplyQueryApi.java new file mode 100644 index 0000000..8d9f2a7 --- /dev/null +++ b/services/comment/driving/web-mvc/src/main/java/nettee/reply/web/ReplyQueryApi.java @@ -0,0 +1,30 @@ +package nettee.reply.web; + +import java.time.Instant; +import java.util.List; +import lombok.RequiredArgsConstructor; +import nettee.reply.model.ReplyQueryModels.ReplyDetail; +import nettee.reply.application.service.ReplyQueryService; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/replies") +@RequiredArgsConstructor +public class ReplyQueryApi { + + private final ReplyQueryService replyQueryService; + + // 'λ‹΅κΈ€ 더보기' μš”μ²­ + @GetMapping("/{commentId}") + public List getRepliesByCommentIdAfter( + @PathVariable("commentId") Long commentId, + @RequestParam("after") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant createdAt + ){ + return replyQueryService.getReplyListByCommentIdAfter(commentId, createdAt); + } +} diff --git a/services/comment/driving/web-mvc/src/main/java/nettee/reply/web/dto/ReplyCommandDto.java b/services/comment/driving/web-mvc/src/main/java/nettee/reply/web/dto/ReplyCommandDto.java new file mode 100644 index 0000000..0b8e246 --- /dev/null +++ b/services/comment/driving/web-mvc/src/main/java/nettee/reply/web/dto/ReplyCommandDto.java @@ -0,0 +1,46 @@ +package nettee.reply.web.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import nettee.reply.domain.Reply; +import nettee.reply.domain.type.ReplyStatus; + +public class ReplyCommandDto { + + private ReplyCommandDto() { + + } + + @Builder + public record ReplyCreateCommand( + @NotNull(message = "commentIdλ₯Ό μž…λ ₯ν•˜μ‹­μ‹œμ˜€") + Long commentId, + @NotBlank(message = "본문을 μž…λ ₯ν•˜μ‹­μ‹œμ˜€") + String content, + @NotNull(message = "μƒνƒœλ₯Ό μž…λ ₯ν•˜μ‹­μ‹œμ˜€") + ReplyStatus status + ){ + + } + + @Builder + public record ReplyUpdateCommand( + @NotNull(message = "idλ₯Ό μž…λ ₯ν•˜μ‹­μ‹œμ˜€") + Long id, + @NotBlank(message = "본문을 μž…λ ₯ν•˜μ‹­μ‹œμ˜€") + String content, + @NotNull(message = "μƒνƒœλ₯Ό μž…λ ₯ν•˜μ‹­μ‹œμ˜€") + ReplyStatus status + ){ + + } + + @Builder + public record ReplyCommandResponse( + Reply reply + ) { + + } + +} diff --git a/services/comment/driving/web-mvc/src/main/java/nettee/reply/web/mapper/ReplyDtoMapper.java b/services/comment/driving/web-mvc/src/main/java/nettee/reply/web/mapper/ReplyDtoMapper.java new file mode 100644 index 0000000..791cef8 --- /dev/null +++ b/services/comment/driving/web-mvc/src/main/java/nettee/reply/web/mapper/ReplyDtoMapper.java @@ -0,0 +1,12 @@ +package nettee.reply.web.mapper; + +import nettee.reply.domain.Reply; +import nettee.reply.web.dto.ReplyCommandDto.ReplyCreateCommand; +import nettee.reply.web.dto.ReplyCommandDto.ReplyUpdateCommand; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface ReplyDtoMapper { + Reply toDomain(ReplyCreateCommand command); + Reply toDomain(ReplyUpdateCommand command); +} diff --git a/services/comment/driving/web-mvc/src/main/resources/comment-web.yml b/services/comment/driving/web-mvc/src/main/resources/comment-web.yml new file mode 100644 index 0000000..8b7a89c --- /dev/null +++ b/services/comment/driving/web-mvc/src/main/resources/comment-web.yml @@ -0,0 +1,3 @@ +spring: + jackson: + default-property-inclusion: non_null \ No newline at end of file diff --git a/services/comment/src/main/resources/comment.yml b/services/comment/src/main/resources/comment.yml new file mode 100644 index 0000000..bf62a73 --- /dev/null +++ b/services/comment/src/main/resources/comment.yml @@ -0,0 +1,5 @@ +spring: + config: + import: + - properties/db/comment.database.yml +# - comment-web.yml \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index d09e594..dd1ad23 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,3 +7,4 @@ apply(from = "core/core.settings.gradle.kts") apply(from = "monolith/monolith.settings.gradle.kts") apply(from = "$services/board/board.settings.gradle.kts") +apply(from = "$services/comment/comment.settings.gradle.kts") \ No newline at end of file