From c0dcfd8aa69e2c7e316fd19b2c35527541e59906 Mon Sep 17 00:00:00 2001 From: "hanyoung.park" Date: Mon, 2 Feb 2026 01:26:59 +0900 Subject: [PATCH 01/29] remove: deprecated codeguide --- .codeguide/loopers-1-week.md | 45 ------------------------------------ 1 file changed, 45 deletions(-) delete mode 100644 .codeguide/loopers-1-week.md diff --git a/.codeguide/loopers-1-week.md b/.codeguide/loopers-1-week.md deleted file mode 100644 index a8ace53e5..000000000 --- a/.codeguide/loopers-1-week.md +++ /dev/null @@ -1,45 +0,0 @@ -## ๐Ÿงช Implementation Quest - -> ์ง€์ •๋œ **๋‹จ์œ„ ํ…Œ์ŠคํŠธ / ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ / E2E ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค**๋ฅผ ํ•„์ˆ˜๋กœ ๊ตฌํ˜„ํ•˜๊ณ , ๋ชจ๋“  ํ…Œ์ŠคํŠธ๋ฅผ ํ†ต๊ณผ์‹œํ‚ค๋Š” ๊ฒƒ์„ ๋ชฉํ‘œ๋กœ ํ•ฉ๋‹ˆ๋‹ค. - -### ํšŒ์› ๊ฐ€์ž… - -**๐Ÿงฑ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ** - -- [ ] ID ๊ฐ€ `์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. -- [ ] ์ด๋ฉ”์ผ์ด `xx@yy.zz` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. -- [ ] ์ƒ๋…„์›”์ผ์ด `yyyy-MM-dd` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [ ] ํšŒ์› ๊ฐ€์ž…์‹œ User ์ €์žฅ์ด ์ˆ˜ํ–‰๋œ๋‹ค. ( spy ๊ฒ€์ฆ ) -- [ ] ์ด๋ฏธ ๊ฐ€์ž…๋œ ID ๋กœ ํšŒ์›๊ฐ€์ž… ์‹œ๋„ ์‹œ, ์‹คํŒจํ•œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [ ] ํšŒ์› ๊ฐ€์ž…์ด ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒ์„ฑ๋œ ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [ ] ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - -### ๋‚ด ์ •๋ณด ์กฐํšŒ - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ํšŒ์› ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [ ] ๋‚ด ์ •๋ณด ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ํ•ด๋‹นํ•˜๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [ ] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ID ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - -### ํฌ์ธํŠธ ์กฐํšŒ - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [ ] ํฌ์ธํŠธ ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [ ] `X-USER-ID` ํ—ค๋”๊ฐ€ ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. From 8f73709503ccd35478f0218ce2e5a712e4f33db5 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 6 Feb 2026 01:31:33 +0900 Subject: [PATCH 02/29] =?UTF-8?q?chore:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EA=B8=B0=EB=B0=98=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAUDE.md ์ถ”๊ฐ€ (ํ”„๋กœ์ ํŠธ ์ปจํ…์ŠคํŠธ ๋ฐ ๊ฐœ๋ฐœ ๊ทœ์น™) - spring-security-crypto ์˜์กด์„ฑ ์ถ”๊ฐ€ - ErrorType์— UNAUTHORIZED, USER_NOT_FOUND, PASSWORD_MISMATCH ์ถ”๊ฐ€ - MySqlTestContainersConfig์— MYSQL_ROOT_PASSWORD ํ™˜๊ฒฝ๋ณ€์ˆ˜ ์ถ”๊ฐ€ Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 132 ++++++++++++++++++ apps/commerce-api/build.gradle.kts | 3 + .../com/loopers/support/error/ErrorType.java | 7 +- .../MySqlTestContainersConfig.java | 1 + 4 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..a88a85fa1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,132 @@ +# CLAUDE.md + +์ด ํŒŒ์ผ์€ Claude Code๊ฐ€ ํ”„๋กœ์ ํŠธ๋ฅผ ์ดํ•ดํ•˜๋Š” ๋ฐ ํ•„์š”ํ•œ ์ปจํ…์ŠคํŠธ๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. + +## ํ”„๋กœ์ ํŠธ ๊ฐœ์š” + +**ํ”„๋กœ์ ํŠธ๋ช…**: loopers-java-spring-template +**๊ทธ๋ฃน ID**: com.loopers +**๋ผ์ด์„ ์Šค**: LICENSE ํŒŒ์ผ ์ฐธ์กฐ + +์ปค๋จธ์Šค ๋„๋ฉ”์ธ์„ ์œ„ํ•œ Java/Spring Boot ๊ธฐ๋ฐ˜ ๋ฉ€ํ‹ฐ ๋ชจ๋“ˆ ๋ฐฑ์—”๋“œ ํ…œํ”Œ๋ฆฟ ํ”„๋กœ์ ํŠธ์ž…๋‹ˆ๋‹ค. + +## ๊ธฐ์ˆ  ์Šคํƒ ๋ฐ ๋ฒ„์ „ + +| ๊ธฐ์ˆ  | ๋ฒ„์ „ | +|------|------| +| Java | 21 | +| Spring Boot | 3.4.4 | +| Spring Cloud Dependencies | 2024.0.1 | +| Spring Dependency Management | 1.1.7 | +| Lombok | Spring Boot BOM | +| QueryDSL | Spring Boot BOM (Jakarta) | +| SpringDoc OpenAPI | 2.7.0 | +| Micrometer | Spring Boot BOM | +| Testcontainers | Spring Boot BOM | +| JUnit 5 | Spring Boot BOM | +| Mockito | 5.14.0 | +| SpringMockK | 4.0.2 | +| Instancio JUnit | 5.0.2 | +| Slack Appender | 1.6.1 | + +## ๋ชจ๋“ˆ ๊ตฌ์กฐ + +``` +loopers-java-spring-template/ +โ”œโ”€โ”€ apps/ # ์‹คํ–‰ ๊ฐ€๋Šฅํ•œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ (BootJar) +โ”‚ โ”œโ”€โ”€ commerce-api/ # REST API ์„œ๋ฒ„ (Web, OpenAPI) +โ”‚ โ”œโ”€โ”€ commerce-streamer/ # Kafka ์ŠคํŠธ๋ฆผ ์ฒ˜๋ฆฌ ์„œ๋ฒ„ +โ”‚ โ””โ”€โ”€ commerce-batch/ # Spring Batch ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ +โ”‚ +โ”œโ”€โ”€ modules/ # ๊ณต์œ  ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ๋ชจ๋“ˆ +โ”‚ โ”œโ”€โ”€ jpa/ # JPA + QueryDSL + MySQL +โ”‚ โ”œโ”€โ”€ redis/ # Spring Data Redis +โ”‚ โ””โ”€โ”€ kafka/ # Spring Kafka +โ”‚ +โ”œโ”€โ”€ supports/ # ํšก๋‹จ ๊ด€์‹ฌ์‚ฌ ์ง€์› ๋ชจ๋“ˆ +โ”‚ โ”œโ”€โ”€ jackson/ # Jackson ์ง๋ ฌํ™” ์„ค์ • +โ”‚ โ”œโ”€โ”€ logging/ # ๋กœ๊น… + Slack Appender +โ”‚ โ””โ”€โ”€ monitoring/ # Prometheus + Micrometer +โ”‚ +โ”œโ”€โ”€ docker/ # Docker ๊ด€๋ จ ์„ค์ • +โ””โ”€โ”€ http/ # HTTP ์š”์ฒญ ํŒŒ์ผ (IntelliJ HTTP Client) +``` + +### ๋ชจ๋“ˆ ์˜์กด์„ฑ ๊ด€๊ณ„ + +- **commerce-api**: jpa, redis, jackson, logging, monitoring +- **commerce-streamer**: jpa, redis, kafka, jackson, logging, monitoring +- **commerce-batch**: jpa, redis, jackson, logging, monitoring + +## ๋นŒ๋“œ ๋ฐ ์‹คํ–‰ + +```bash +# ์ „์ฒด ๋นŒ๋“œ +./gradlew build + +# ํŠน์ • ์•ฑ ์‹คํ–‰ +./gradlew :apps:commerce-api:bootRun +./gradlew :apps:commerce-streamer:bootRun +./gradlew :apps:commerce-batch:bootRun + +# ํ…Œ์ŠคํŠธ ์‹คํ–‰ +./gradlew test +``` + +## ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ + +- ํ…Œ์ŠคํŠธ ์‹œ `Asia/Seoul` ํƒ€์ž„์กด ์‚ฌ์šฉ +- ํ…Œ์ŠคํŠธ ํ”„๋กœํŒŒ์ผ: `test` +- Testcontainers ์‚ฌ์šฉ (MySQL, Redis, Kafka) +- JaCoCo ์ฝ”๋“œ ์ปค๋ฒ„๋ฆฌ์ง€ ๋ฆฌํฌํŠธ ์ƒ์„ฑ (XML ํฌ๋งท) + +## ์ฃผ์š” ์„ค์ • + +- **๋ฒ„์ „ ๊ด€๋ฆฌ**: Git ์ปค๋ฐ‹ ํ•ด์‹œ๋ฅผ ๊ธฐ๋ณธ ๋ฒ„์ „์œผ๋กœ ์‚ฌ์šฉ +- **๋นŒ๋“œ ํƒ€์ž…**: + - `apps/*` ๋ชจ๋“ˆ: BootJar (์‹คํ–‰ ๊ฐ€๋Šฅํ•œ JAR) + - `modules/*`, `supports/*` ๋ชจ๋“ˆ: ์ผ๋ฐ˜ JAR (๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ) + +## ์ฝ”๋“œ ์Šคํƒ€์ผ + +- Lombok ์‚ฌ์šฉ +- Jackson JSR310 ๋ชจ๋“ˆ๋กœ Java Time API ์ง๋ ฌํ™” +- QueryDSL Jakarta ์ŠคํŽ™ ์‚ฌ์šฉ + + +## ๊ฐœ๋ฐœ ๊ทœ์น™ +### ์ง„ํ–‰ Workflow - ์ฆ๊ฐ• ์ฝ”๋”ฉ +- **๋Œ€์›์น™** : ๋ฐฉํ–ฅ์„ฑ ๋ฐ ์ฃผ์š” ์˜์‚ฌ ๊ฒฐ์ •์€ ๊ฐœ๋ฐœ์ž์—๊ฒŒ ์ œ์•ˆ๋งŒ ํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์ตœ์ข… ์Šน์ธ๋œ ์‚ฌํ•ญ์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์ž‘์—…์„ ์ˆ˜ํ–‰. +- **์ค‘๊ฐ„ ๊ฒฐ๊ณผ ๋ณด๊ณ ** : AI ๊ฐ€ ๋ฐ˜๋ณต์ ์ธ ๋™์ž‘์„ ํ•˜๊ฑฐ๋‚˜, ์š”์ฒญํ•˜์ง€ ์•Š์€ ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„, ํ…Œ์ŠคํŠธ ์‚ญ์ œ๋ฅผ ์ž„์˜๋กœ ์ง„ํ–‰ํ•  ๊ฒฝ์šฐ ๊ฐœ๋ฐœ์ž๊ฐ€ ๊ฐœ์ž…. +- **์„ค๊ณ„ ์ฃผ๋„๊ถŒ ์œ ์ง€** : AI ๊ฐ€ ์ž„์˜ํŒ๋‹จ์„ ํ•˜์ง€ ์•Š๊ณ , ๋ฐฉํ–ฅ์„ฑ์— ๋Œ€ํ•œ ์ œ์•ˆ ๋“ฑ์„ ์ง„ํ–‰ํ•  ์ˆ˜ ์žˆ์œผ๋‚˜ ๊ฐœ๋ฐœ์ž์˜ ์Šน์ธ์„ ๋ฐ›์€ ํ›„ ์ˆ˜ํ–‰. + +### ๊ฐœ๋ฐœ Workflow - TDD (Red > Green > Refactor) +- ๋ชจ๋“  ํ…Œ์ŠคํŠธ๋Š” 3A ์›์น™์œผ๋กœ ์ž‘์„ฑํ•  ๊ฒƒ (Arrange - Act - Assert) +#### 1. Red Phase : ์‹คํŒจํ•˜๋Š” ํ…Œ์ŠคํŠธ ๋จผ์ € ์ž‘์„ฑ +- ์š”๊ตฌ์‚ฌํ•ญ์„ ๋งŒ์กฑํ•˜๋Š” ๊ธฐ๋Šฅ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค ์ž‘์„ฑ +- ํ…Œ์ŠคํŠธ ์˜ˆ์‹œ +#### 2. Green Phase : ํ…Œ์ŠคํŠธ๋ฅผ ํ†ต๊ณผํ•˜๋Š” ์ฝ”๋“œ ์ž‘์„ฑ +- Red Phase ์˜ ํ…Œ์ŠคํŠธ๊ฐ€ ๋ชจ๋‘ ํ†ต๊ณผํ•  ์ˆ˜ ์žˆ๋Š” ์ฝ”๋“œ ์ž‘์„ฑ +- ์˜ค๋ฒ„์—”์ง€๋‹ˆ์–ด๋ง ๊ธˆ์ง€ +#### 3. Refactor Phase : ๋ถˆํ•„์š”ํ•œ ์ฝ”๋“œ ์ œ๊ฑฐ ๋ฐ ํ’ˆ์งˆ ๊ฐœ์„  +- ๋ถˆํ•„์š”ํ•œ private ํ•จ์ˆ˜ ์ง€์–‘, ๊ฐ์ฒด์ง€ํ–ฅ์  ์ฝ”๋“œ ์ž‘์„ฑ +- unused import ์ œ๊ฑฐ +- ์„ฑ๋Šฅ ์ตœ์ ํ™” +- ๋ชจ๋“  ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๊ฐ€ ํ†ต๊ณผํ•ด์•ผ ํ•จ +## ์ฃผ์˜์‚ฌํ•ญ +### 1. Never Do +- ์‹ค์ œ ๋™์ž‘ํ•˜์ง€ ์•Š๋Š” ์ฝ”๋“œ, ๋ถˆํ•„์š”ํ•œ Mock ๋ฐ์ดํ„ฐ๋ฅผ ์ด์š”ํ•œ ๊ตฌํ˜„์„ ํ•˜์ง€ ๋ง ๊ฒƒ +- null-safety ํ•˜์ง€ ์•Š๊ฒŒ ์ฝ”๋“œ ์ž‘์„ฑํ•˜์ง€ ๋ง ๊ฒƒ (Java ์˜ ๊ฒฝ์šฐ, Optional ์„ ํ™œ์šฉํ•  ๊ฒƒ) +- println ์ฝ”๋“œ ๋‚จ๊ธฐ์ง€ ๋ง ๊ฒƒ + +### 2. Recommendation +- ์‹ค์ œ API ๋ฅผ ํ˜ธ์ถœํ•ด ํ™•์ธํ•˜๋Š” E2E ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ž‘์„ฑ +- ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๊ฐ์ฒด ์„ค๊ณ„ +- ์„ฑ๋Šฅ ์ตœ์ ํ™”์— ๋Œ€ํ•œ ๋Œ€์•ˆ ๋ฐ ์ œ์•ˆ +- ๊ฐœ๋ฐœ ์™„๋ฃŒ๋œ API ์˜ ๊ฒฝ์šฐ, `.http/**.http` ์— ๋ถ„๋ฅ˜ํ•ด ์ž‘์„ฑ + +### 3. Priority +1. ์‹ค์ œ ๋™์ž‘ํ•˜๋Š” ํ•ด๊ฒฐ์ฑ…๋งŒ ๊ณ ๋ ค +2. null-safety, thread-safety ๊ณ ๋ ค +3. ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๋กœ ์„ค๊ณ„ +4. ๊ธฐ์กด ์ฝ”๋“œ ํŒจํ„ด ๋ถ„์„ ํ›„ ์ผ๊ด€์„ฑ ์œ ์ง€ \ No newline at end of file diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f02..6acd86062 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -11,6 +11,9 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") + // security + implementation("org.springframework.security:spring-security-crypto") + // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") annotationProcessor("jakarta.persistence:jakarta.persistence-api") diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 5d142efbf..d64c6b491 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -11,7 +11,12 @@ public enum ErrorType { INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "์ผ์‹œ์ ์ธ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "์ž˜๋ชป๋œ ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), - CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ฆฌ์†Œ์Šค์ž…๋‹ˆ๋‹ค."); + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ฆฌ์†Œ์Šค์ž…๋‹ˆ๋‹ค."), + + /** ์ธ์ฆ ๊ด€๋ จ ์—๋Ÿฌ */ + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "์ธ์ฆ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), + USER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "USER_NOT_FOUND", "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค."), + PASSWORD_MISMATCH(HttpStatus.UNAUTHORIZED, "PASSWORD_MISMATCH", "๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); private final HttpStatus status; private final String code; diff --git a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java index 9c41edacc..0495cb5b6 100644 --- a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java +++ b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java @@ -14,6 +14,7 @@ public class MySqlTestContainersConfig { .withDatabaseName("loopers") .withUsername("test") .withPassword("test") + .withEnv("MYSQL_ROOT_PASSWORD", "test") .withExposedPorts(3306) .withCommand( "--character-set-server=utf8mb4", From 9180d46950b2933dcc413c985e87236ef32a306d Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 6 Feb 2026 01:31:47 +0900 Subject: [PATCH 03/29] =?UTF-8?q?feat:=20User=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EA=B3=84=EC=B8=B5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - User ์—”ํ‹ฐํ‹ฐ (ํ•„๋“œ ๊ฒ€์ฆ, BCrypt ์•”ํ˜ธํ™”, ์ด๋ฆ„ ๋งˆ์Šคํ‚น) - UserRepository ์ธํ„ฐํŽ˜์ด์Šค - UserService (ํšŒ์›๊ฐ€์ž…, ์กฐํšŒ, ์ธ์ฆ, ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ) - UserTest ๋‹จ์œ„ ํ…Œ์ŠคํŠธ 47๊ฑด Co-Authored-By: Claude Opus 4.5 --- .../java/com/loopers/domain/user/User.java | 177 +++++++++ .../loopers/domain/user/UserRepository.java | 9 + .../com/loopers/domain/user/UserService.java | 46 +++ .../com/loopers/domain/user/UserTest.java | 364 ++++++++++++++++++ 4 files changed, 596 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/User.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java new file mode 100644 index 000000000..5ea7523e0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -0,0 +1,177 @@ +package com.loopers.domain.user; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.format.ResolverStyle; +import java.util.regex.Pattern; + +@Entity +@Table(name = "users") +public class User extends BaseEntity { + + private static final PasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(); + private static final Pattern LOGIN_ID_PATTERN = Pattern.compile("^[a-zA-Z0-9]+$"); + private static final Pattern NAME_PATTERN = Pattern.compile("^[๊ฐ€-ํžฃa-zA-Z]+$"); + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); + private static final Pattern PASSWORD_PATTERN = Pattern.compile("^[a-zA-Z0-9!@#$%^&*]+$"); + private static final DateTimeFormatter BIRTH_DATE_FORMATTER = DateTimeFormatter.ofPattern("uuuuMMdd") + .withResolverStyle(ResolverStyle.STRICT); + + private static final int MAX_LOGIN_ID_BYTES = 30; + private static final int MAX_NAME_BYTES = 30; + private static final int MIN_PASSWORD_LENGTH = 8; + private static final int MAX_PASSWORD_LENGTH = 16; + private static final int BIRTH_DATE_SUBSTRING_LENGTH = 4; + + @Column(name = "login_id", nullable = false, unique = true) + private String loginId; + + @Column(name = "password", nullable = false) + private String password; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "birth_date", nullable = false) + private String birthDate; + + @Column(name = "email", nullable = false) + private String email; + + protected User() {} + + public User(String loginId, String password, String name, String birthDate, String email) { + validateLoginId(loginId); + validateName(name); + validateBirthDate(birthDate); + validateEmail(email); + validatePassword(password, birthDate); + + this.loginId = loginId; + this.password = PASSWORD_ENCODER.encode(password); + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public String getLoginId() { + return loginId; + } + + public String getName() { + return name; + } + + public String getBirthDate() { + return birthDate; + } + + public String getEmail() { + return email; + } + + public boolean matchPassword(String rawPassword) { + return PASSWORD_ENCODER.matches(rawPassword, this.password); + } + + public void changePassword(String newPassword) { + if (matchPassword(newPassword)) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๋‹ค๋ฅด๊ฒŒ ์„ค์ •ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + validatePassword(newPassword, this.birthDate); + this.password = PASSWORD_ENCODER.encode(newPassword); + } + + public String getMaskedName() { + if (name.length() <= 1) { + return name; + } + char first = name.charAt(0); + char last = name.charAt(name.length() - 1); + String middle = "*".repeat(name.length() - 2); + return first + middle + last; + } + + private void validateLoginId(String loginId) { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋กœ๊ทธ์ธ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (!LOGIN_ID_PATTERN.matcher(loginId).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋กœ๊ทธ์ธ ID๋Š” ์˜๋ฌธ๊ณผ ์ˆซ์ž๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."); + } + if (loginId.getBytes(StandardCharsets.UTF_8).length > MAX_LOGIN_ID_BYTES) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋กœ๊ทธ์ธ ID๋Š” 30๋ฐ”์ดํŠธ๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฆ„์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (!NAME_PATTERN.matcher(name).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฆ„์€ ํ•œ๊ธ€๊ณผ ์˜๋ฌธ๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."); + } + if (name.getBytes(StandardCharsets.UTF_8).length > MAX_NAME_BYTES) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฆ„์€ 30๋ฐ”์ดํŠธ๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + } + + private void validateBirthDate(String birthDate) { + if (birthDate == null || birthDate.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (birthDate.length() != 8 || !birthDate.matches("\\d{8}")) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ์€ YYYYMMDD ํ˜•์‹์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + try { + LocalDate.parse(birthDate, BIRTH_DATE_FORMATTER); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "์œ ํšจํ•˜์ง€ ์•Š์€ ๋‚ ์งœ์ž…๋‹ˆ๋‹ค."); + } + } + + private void validateEmail(String email) { + if (email == null || email.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (!EMAIL_PATTERN.matcher(email).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์œ ํšจํ•˜์ง€ ์•Š์€ ์ด๋ฉ”์ผ ํ˜•์‹์ž…๋‹ˆ๋‹ค."); + } + } + + private void validatePassword(String password, String birthDate) { + if (password == null || password.length() < MIN_PASSWORD_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (password.length() > MAX_PASSWORD_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 16์ž ์ดํ•˜์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (!PASSWORD_PATTERN.matcher(password).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž(!@#$%^&*)๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."); + } + if (containsBirthDateSubstring(password, birthDate)) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ ์ •๋ณด๋ฅผ ํฌํ•จํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + } + + private boolean containsBirthDateSubstring(String password, String birthDate) { + for (int i = 0; i <= birthDate.length() - BIRTH_DATE_SUBSTRING_LENGTH; i++) { + String substring = birthDate.substring(i, i + BIRTH_DATE_SUBSTRING_LENGTH); + if (password.contains(substring)) { + return true; + } + } + return false; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java new file mode 100644 index 000000000..15889936f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.user; + +import java.util.Optional; + +public interface UserRepository { + User save(User user); + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java new file mode 100644 index 000000000..5fb27a419 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,46 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class UserService { + + private final UserRepository userRepository; + + @Transactional + public User register(String loginId, String password, String name, String birthDate, String email) { + if (userRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ์‚ฌ์šฉ ์ค‘์ธ ๋กœ๊ทธ์ธ ID์ž…๋‹ˆ๋‹ค."); + } + User user = new User(loginId, password, name, birthDate, email); + return userRepository.save(user); + } + + @Transactional(readOnly = true) + public User getUserByLoginId(String loginId) { + return userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.USER_NOT_FOUND, "[loginId = " + loginId + "] ์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } + + @Transactional(readOnly = true) + public User authenticate(String loginId, String password) { + User user = userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.USER_NOT_FOUND, "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.")); + + if (!user.matchPassword(password)) { + throw new CoreException(ErrorType.PASSWORD_MISMATCH, "๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + return user; + } + + @Transactional + public void changePassword(String loginId, String currentPassword, String newPassword) { + User user = authenticate(loginId, currentPassword); + user.changePassword(newPassword); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java new file mode 100644 index 000000000..427487637 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -0,0 +1,364 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class UserTest { + + @DisplayName("User๋ฅผ ์ƒ์„ฑํ•  ๋•Œ,") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ž…๋ ฅ์ด ์ฃผ์–ด์ง€๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsUser_whenValidInputIsProvided() { + // arrange + String loginId = "testuser123"; + String password = "Test1234!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + User user = new User(loginId, password, name, birthDate, email); + + // assert + assertAll( + () -> assertThat(user.getLoginId()).isEqualTo(loginId), + () -> assertThat(user.getName()).isEqualTo(name), + () -> assertThat(user.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(user.getEmail()).isEqualTo(email) + ); + } + + @DisplayName("loginId๊ฐ€ null์ด๊ฑฐ๋‚˜ ๋นˆ ๋ฌธ์ž์—ด์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @NullAndEmptySource + void throwsBadRequestException_whenLoginIdIsNullOrEmpty(String loginId) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User(loginId, "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("loginId๊ฐ€ ์˜๋ฌธ+์ˆซ์ž ์™ธ ๋ฌธ์ž๋ฅผ ํฌํ•จํ•˜๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = {"test@user", "test user", "ํ…Œ์ŠคํŠธ์œ ์ €", "test_user"}) + void throwsBadRequestException_whenLoginIdContainsInvalidChars(String loginId) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User(loginId, "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("loginId๊ฐ€ 30๋ฐ”์ดํŠธ๋ฅผ ์ดˆ๊ณผํ•˜๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenLoginIdExceeds30Bytes() { + // arrange + String loginId = "a".repeat(31); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User(loginId, "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name์ด null์ด๊ฑฐ๋‚˜ ๋นˆ ๋ฌธ์ž์—ด์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @NullAndEmptySource + void throwsBadRequestException_whenNameIsNullOrEmpty(String name) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", name, "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name์ด ํ•œ๊ธ€+์˜๋ฌธ ์™ธ ๋ฌธ์ž๋ฅผ ํฌํ•จํ•˜๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = {"ํ™๊ธธ๋™123", "ํ™๊ธธ๋™!", "ํ™ ๊ธธ๋™"}) + void throwsBadRequestException_whenNameContainsInvalidChars(String name) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", name, "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name์ด 30๋ฐ”์ดํŠธ๋ฅผ ์ดˆ๊ณผํ•˜๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenNameExceeds30Bytes() { + // arrange - ํ•œ๊ธ€ 1์ž = 3๋ฐ”์ดํŠธ, 11์ž = 33๋ฐ”์ดํŠธ + String name = "๊ฐ€".repeat(11); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", name, "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("birthDate๊ฐ€ null์ด๊ฑฐ๋‚˜ ๋นˆ ๋ฌธ์ž์—ด์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @NullAndEmptySource + void throwsBadRequestException_whenBirthDateIsNullOrEmpty(String birthDate) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", "ํ™๊ธธ๋™", birthDate, "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("birthDate๊ฐ€ YYYYMMDD ํฌ๋งท์ด ์•„๋‹ˆ๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = {"1990-01-01", "19901", "990101", "2000/01/01"}) + void throwsBadRequestException_whenBirthDateHasInvalidFormat(String birthDate) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", "ํ™๊ธธ๋™", birthDate, "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("birthDate๊ฐ€ ์œ ํšจํ•˜์ง€ ์•Š์€ ๋‚ ์งœ๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = {"19901301", "19900132", "20000230", "19000001"}) + void throwsBadRequestException_whenBirthDateIsInvalidDate(String birthDate) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", "ํ™๊ธธ๋™", birthDate, "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("email์ด null์ด๊ฑฐ๋‚˜ ๋นˆ ๋ฌธ์ž์—ด์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @NullAndEmptySource + void throwsBadRequestException_whenEmailIsNullOrEmpty(String email) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("email์ด ์œ ํšจํ•˜์ง€ ์•Š์€ ํ˜•์‹์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = {"test", "test@", "@example.com", "test@.com", "test@com"}) + void throwsBadRequestException_whenEmailHasInvalidFormat(String email) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("password๊ฐ€ 8์ž ๋ฏธ๋งŒ์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenPasswordIsTooShort() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test12!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("password๊ฐ€ 16์ž๋ฅผ ์ดˆ๊ณผํ•˜๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenPasswordIsTooLong() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234567890123!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("password์— ํ—ˆ์šฉ๋˜์ง€ ์•Š์€ ๋ฌธ์ž๊ฐ€ ํฌํ•จ๋˜๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = {"Test1234~", "Test1234()", "Test1234<>"}) + void throwsBadRequestException_whenPasswordContainsInvalidChars(String password) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", password, "ํ™๊ธธ๋™", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("password์— ์ƒ๋…„์›”์ผ 4์ž๋ฆฌ ์ด์ƒ ๋ถ€๋ถ„๋ฌธ์ž์—ด์ด ํฌํ•จ๋˜๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = {"Test1990!", "Test0101!", "Test9001!"}) + void throwsBadRequestException_whenPasswordContainsBirthDateSubstring(String password) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", password, "ํ™๊ธธ๋™", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๊ฒ€์ฆํ•  ๋•Œ,") + @Nested + class MatchPassword { + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜๋ฉด, true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsTrue_whenPasswordMatches() { + // arrange + User user = new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + boolean result = user.matchPassword("Test1234!"); + + // assert + assertThat(result).isTrue(); + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์œผ๋ฉด, false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsFalse_whenPasswordDoesNotMatch() { + // arrange + User user = new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + boolean result = user.matchPassword("WrongPass1!"); + + // assert + assertThat(result).isFalse(); + } + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๋ณ€๊ฒฝํ•  ๋•Œ,") + @Nested + class ChangePassword { + + @DisplayName("์œ ํšจํ•œ ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ๋ณ€๊ฒฝํ•˜๋ฉด, ์„ฑ๊ณตํ•œ๋‹ค.") + @Test + void succeeds_whenNewPasswordIsValid() { + // arrange + User user = new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + user.changePassword("NewPass12!"); + + // assert + assertThat(user.matchPassword("NewPass12!")).isTrue(); + } + + @DisplayName("ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๋™์ผํ•˜๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenNewPasswordIsSameAsCurrent() { + // arrange + User user = new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + user.changePassword("Test1234!"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๊ทœ์น™์„ ์œ„๋ฐ˜ํ•˜๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenNewPasswordViolatesRules() { + // arrange + User user = new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + user.changePassword("short"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("์ด๋ฆ„์„ ๋งˆ์Šคํ‚นํ•  ๋•Œ,") + @Nested + class GetMaskedName { + + @DisplayName("2๊ธ€์ž ์ด์ƒ์ด๋ฉด, ์ฒซ ๊ธ€์ž์™€ ๋งˆ์ง€๋ง‰ ๊ธ€์ž๋งŒ ๋ณด์ด๊ณ  ์ค‘๊ฐ„์€ *๋กœ ๋งˆ์Šคํ‚น๋œ๋‹ค.") + @Test + void masksMiddleCharacters_whenNameHasTwoOrMoreCharacters() { + // arrange + User user = new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + String result = user.getMaskedName(); + + // assert + assertThat(result).isEqualTo("ํ™*๋™"); + } + + @DisplayName("1๊ธ€์ž์ด๋ฉด, ๊ทธ๋Œ€๋กœ ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnsAsIs_whenNameHasOneCharacter() { + // arrange + User user = new User("testuser", "Test1234!", "๊น€", "19900101", "test@example.com"); + + // act + String result = user.getMaskedName(); + + // assert + assertThat(result).isEqualTo("๊น€"); + } + + @DisplayName("์˜๋ฌธ ์ด๋ฆ„๋„ ๋งˆ์Šคํ‚น๋œ๋‹ค.") + @Test + void masksEnglishName() { + // arrange + User user = new User("testuser", "Test1234!", "John", "19900101", "test@example.com"); + + // act + String result = user.getMaskedName(); + + // assert + assertThat(result).isEqualTo("J**n"); + } + } +} From 5e5db91f57ea36b72ce4dbf7560db941940e92f9 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 6 Feb 2026 01:31:58 +0900 Subject: [PATCH 04/29] =?UTF-8?q?feat:=20User=20Infrastructure=20=EA=B3=84?= =?UTF-8?q?=EC=B8=B5=20=EB=B0=8F=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserJpaRepository (Spring Data JPA) - UserRepositoryImpl (Repository ๊ตฌํ˜„์ฒด) - UserServiceIntegrationTest ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ 9๊ฑด Co-Authored-By: Claude Opus 4.5 --- .../user/UserJpaRepository.java | 11 + .../user/UserRepositoryImpl.java | 30 +++ .../user/UserServiceIntegrationTest.java | 209 ++++++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java new file mode 100644 index 000000000..fb0e51c3c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserJpaRepository extends JpaRepository { + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java new file mode 100644 index 000000000..9a9ed24a6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class UserRepositoryImpl implements UserRepository { + + private final UserJpaRepository userJpaRepository; + + @Override + public User save(User user) { + return userJpaRepository.save(user); + } + + @Override + public Optional findByLoginId(String loginId) { + return userJpaRepository.findByLoginId(loginId); + } + + @Override + public boolean existsByLoginId(String loginId) { + return userJpaRepository.existsByLoginId(loginId); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java new file mode 100644 index 000000000..c87e0e65a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -0,0 +1,209 @@ +package com.loopers.domain.user; + +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class UserServiceIntegrationTest { + + @Autowired + private UserService userService; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("ํšŒ์›๊ฐ€์ž…ํ•  ๋•Œ,") + @Nested + class Register { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๋กœ ๊ฐ€์ž…ํ•˜๋ฉด, ์‚ฌ์šฉ์ž๊ฐ€ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsUser_whenValidInfoIsProvided() { + // arrange + String loginId = "testuser"; + String password = "Test1234!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + User result = userService.register(loginId, password, name, birthDate, email); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.getLoginId()).isEqualTo(loginId), + () -> assertThat(result.getName()).isEqualTo(name), + () -> assertThat(result.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(result.getEmail()).isEqualTo(email) + ); + } + + @DisplayName("์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋กœ๊ทธ์ธ ID๋กœ ๊ฐ€์ž…ํ•˜๋ฉด, CONFLICT ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsConflictException_whenLoginIdAlreadyExists() { + // arrange + String loginId = "testuser"; + userService.register(loginId, "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.register(loginId, "Another12!", "๊น€์ฒ ์ˆ˜", "19950505", "another@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("์‚ฌ์šฉ์ž๋ฅผ ์กฐํšŒํ•  ๋•Œ,") + @Nested + class GetUserByLoginId { + + @DisplayName("์กด์žฌํ•˜๋Š” ๋กœ๊ทธ์ธ ID๋กœ ์กฐํšŒํ•˜๋ฉด, ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsUser_whenLoginIdExists() { + // arrange + String loginId = "testuser"; + userService.register(loginId, "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + User result = userService.getUserByLoginId(loginId); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getLoginId()).isEqualTo(loginId) + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋กœ๊ทธ์ธ ID๋กœ ์กฐํšŒํ•˜๋ฉด, USER_NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsUserNotFoundException_whenLoginIdDoesNotExist() { + // arrange + String loginId = "nonexistent"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.getUserByLoginId(loginId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.USER_NOT_FOUND); + } + } + + @DisplayName("์ธ์ฆํ•  ๋•Œ,") + @Nested + class Authenticate { + + @DisplayName("์˜ฌ๋ฐ”๋ฅธ ๋กœ๊ทธ์ธ ID์™€ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ์ธ์ฆํ•˜๋ฉด, ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsUser_whenCredentialsAreValid() { + // arrange + String loginId = "testuser"; + String password = "Test1234!"; + userService.register(loginId, password, "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + User result = userService.authenticate(loginId, password); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getLoginId()).isEqualTo(loginId) + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋กœ๊ทธ์ธ ID๋กœ ์ธ์ฆํ•˜๋ฉด, USER_NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsUserNotFoundException_whenLoginIdDoesNotExist() { + // arrange + String loginId = "nonexistent"; + String password = "Test1234!"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.authenticate(loginId, password); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.USER_NOT_FOUND); + } + + @DisplayName("์ž˜๋ชป๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ์ธ์ฆํ•˜๋ฉด, PASSWORD_MISMATCH ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsPasswordMismatchException_whenPasswordIsWrong() { + // arrange + String loginId = "testuser"; + userService.register(loginId, "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.authenticate(loginId, "WrongPass1!"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_MISMATCH); + } + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๋ณ€๊ฒฝํ•  ๋•Œ,") + @Nested + class ChangePassword { + + @DisplayName("์˜ฌ๋ฐ”๋ฅธ ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ์œ ํšจํ•œ ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ๋ณ€๊ฒฝํ•˜๋ฉด, ์„ฑ๊ณตํ•œ๋‹ค.") + @Test + void succeeds_whenCurrentPasswordIsCorrectAndNewPasswordIsValid() { + // arrange + String loginId = "testuser"; + String currentPassword = "Test1234!"; + String newPassword = "NewPass12!"; + userService.register(loginId, currentPassword, "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + userService.changePassword(loginId, currentPassword, newPassword); + + // assert + User updatedUser = userService.authenticate(loginId, newPassword); + assertThat(updatedUser).isNotNull(); + } + + @DisplayName("์ž˜๋ชป๋œ ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ๋ณ€๊ฒฝํ•˜๋ฉด, PASSWORD_MISMATCH ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsPasswordMismatchException_whenCurrentPasswordIsWrong() { + // arrange + String loginId = "testuser"; + userService.register(loginId, "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.changePassword(loginId, "WrongPass1!", "NewPass12!"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_MISMATCH); + } + } +} From 81ad178f60af5a7e67abf7bfd77adbd9ef69ec61 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 6 Feb 2026 01:32:09 +0900 Subject: [PATCH 05/29] =?UTF-8?q?feat:=20Application=20=EA=B3=84=EC=B8=B5?= =?UTF-8?q?=20=EB=B0=8F=20=ED=97=A4=EB=8D=94=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserFacade, UserInfo (Application ๊ณ„์ธต) - AuthenticatedUser, AuthenticatedUserArgumentResolver (ํ—ค๋” ์ธ์ฆ) - WebMvcConfig (ArgumentResolver ๋“ฑ๋ก) Co-Authored-By: Claude Opus 4.5 --- .../loopers/application/user/UserFacade.java | 27 ++++++++++++ .../loopers/application/user/UserInfo.java | 23 ++++++++++ .../java/com/loopers/config/WebMvcConfig.java | 21 ++++++++++ .../api/auth/AuthenticatedUser.java | 4 ++ .../AuthenticatedUserArgumentResolver.java | 42 +++++++++++++++++++ 5 files changed, 117 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUser.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUserArgumentResolver.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java new file mode 100644 index 000000000..56fdc56d1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -0,0 +1,27 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class UserFacade { + + private final UserService userService; + + public UserInfo register(String loginId, String password, String name, String birthDate, String email) { + User user = userService.register(loginId, password, name, birthDate, email); + return UserInfo.from(user); + } + + public UserInfo getMyInfo(String loginId, String password) { + User user = userService.authenticate(loginId, password); + return UserInfo.from(user); + } + + public void changePassword(String loginId, String currentPassword, String newPassword) { + userService.changePassword(loginId, currentPassword, newPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java new file mode 100644 index 000000000..ab17729e6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -0,0 +1,23 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; + +public record UserInfo( + Long id, + String loginId, + String name, + String maskedName, + String birthDate, + String email +) { + public static UserInfo from(User user) { + return new UserInfo( + user.getId(), + user.getLoginId(), + user.getName(), + user.getMaskedName(), + user.getBirthDate(), + user.getEmail() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java new file mode 100644 index 000000000..9fa63b863 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java @@ -0,0 +1,21 @@ +package com.loopers.config; + +import com.loopers.interfaces.api.auth.AuthenticatedUserArgumentResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@RequiredArgsConstructor +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final AuthenticatedUserArgumentResolver authenticatedUserArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authenticatedUserArgumentResolver); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUser.java new file mode 100644 index 000000000..6933472f2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUser.java @@ -0,0 +1,4 @@ +package com.loopers.interfaces.api.auth; + +public record AuthenticatedUser(String loginId, String password) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUserArgumentResolver.java new file mode 100644 index 000000000..a4a25634c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUserArgumentResolver.java @@ -0,0 +1,42 @@ +package com.loopers.interfaces.api.auth; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +public class AuthenticatedUserArgumentResolver implements HandlerMethodArgumentResolver { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(AuthenticatedUser.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + String loginId = webRequest.getHeader(HEADER_LOGIN_ID); + String password = webRequest.getHeader(HEADER_LOGIN_PW); + + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "X-Loopers-LoginId ํ—ค๋”๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."); + } + if (password == null || password.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "X-Loopers-LoginPw ํ—ค๋”๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."); + } + + return new AuthenticatedUser(loginId, password); + } +} From beaf1a117e5b44a9450c2b801a5e9767e6b9c724 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 6 Feb 2026 01:32:20 +0900 Subject: [PATCH 06/29] =?UTF-8?q?feat:=20User=20API=20=EA=B3=84=EC=B8=B5?= =?UTF-8?q?=20=EB=B0=8F=20E2E=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserV1Controller (POST /users, GET /users/me, PATCH /users/me/password) - UserV1Dto (์š”์ฒญ/์‘๋‹ต DTO) - UserV1ApiSpec (OpenAPI ์ŠคํŽ™) - UserV1ApiE2ETest E2E ํ…Œ์ŠคํŠธ 12๊ฑด - user-v1.http (IntelliJ HTTP Client) Co-Authored-By: Claude Opus 4.5 --- .../interfaces/api/user/UserV1ApiSpec.java | 34 +++ .../interfaces/api/user/UserV1Controller.java | 61 ++++ .../interfaces/api/user/UserV1Dto.java | 47 +++ .../interfaces/api/UserV1ApiE2ETest.java | 289 ++++++++++++++++++ http/commerce-api/user-v1.http | 27 ++ 5 files changed, 458 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java create mode 100644 http/commerce-api/user-v1.http diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java new file mode 100644 index 000000000..944fb2944 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -0,0 +1,34 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthenticatedUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "User V1 API", description = "์‚ฌ์šฉ์ž ๊ด€๋ จ API์ž…๋‹ˆ๋‹ค.") +public interface UserV1ApiSpec { + + @Operation( + summary = "ํšŒ์›๊ฐ€์ž…", + description = "์ƒˆ๋กœ์šด ์‚ฌ์šฉ์ž๋ฅผ ๋“ฑ๋กํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse register(UserV1Dto.RegisterRequest request); + + @Operation( + summary = "๋‚ด ์ •๋ณด ์กฐํšŒ", + description = "ํ˜„์žฌ ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž์˜ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse getMe( + @Parameter(hidden = true) AuthenticatedUser authenticatedUser + ); + + @Operation( + summary = "๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ", + description = "ํ˜„์žฌ ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž์˜ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse changePassword( + @Parameter(hidden = true) AuthenticatedUser authenticatedUser, + UserV1Dto.ChangePasswordRequest request + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java new file mode 100644 index 000000000..05660b0a8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -0,0 +1,61 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthenticatedUser; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/users") +public class UserV1Controller implements UserV1ApiSpec { + + private final UserFacade userFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse register(@RequestBody UserV1Dto.RegisterRequest request) { + UserInfo info = userFacade.register( + request.loginId(), + request.password(), + request.name(), + request.birthDate(), + request.email() + ); + return ApiResponse.success(UserV1Dto.RegisterResponse.from(info)); + } + + @GetMapping("/me") + @Override + public ApiResponse getMe(AuthenticatedUser authenticatedUser) { + UserInfo info = userFacade.getMyInfo( + authenticatedUser.loginId(), + authenticatedUser.password() + ); + return ApiResponse.success(UserV1Dto.MeResponse.from(info)); + } + + @PatchMapping("/me/password") + @Override + public ApiResponse changePassword( + AuthenticatedUser authenticatedUser, + @RequestBody UserV1Dto.ChangePasswordRequest request + ) { + userFacade.changePassword( + authenticatedUser.loginId(), + request.currentPassword(), + request.newPassword() + ); + return ApiResponse.success(UserV1Dto.ChangePasswordResponse.success()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java new file mode 100644 index 000000000..67bc1be17 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -0,0 +1,47 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserInfo; + +public class UserV1Dto { + + public record RegisterRequest( + String loginId, + String password, + String name, + String birthDate, + String email + ) {} + + public record RegisterResponse(Long userId) { + public static RegisterResponse from(UserInfo info) { + return new RegisterResponse(info.id()); + } + } + + public record MeResponse( + String loginId, + String name, + String birthDate, + String email + ) { + public static MeResponse from(UserInfo info) { + return new MeResponse( + info.loginId(), + info.maskedName(), + info.birthDate(), + info.email() + ); + } + } + + public record ChangePasswordRequest( + String currentPassword, + String newPassword + ) {} + + public record ChangePasswordResponse(String message) { + public static ChangePasswordResponse success() { + return new ChangePasswordResponse("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java new file mode 100644 index 000000000..47d86b790 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -0,0 +1,289 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.user.User; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class UserV1ApiE2ETest { + + private static final String ENDPOINT_REGISTER = "/api/v1/users"; + private static final String ENDPOINT_ME = "/api/v1/users/me"; + private static final String ENDPOINT_CHANGE_PASSWORD = "/api/v1/users/me/password"; + + private final TestRestTemplate testRestTemplate; + private final UserJpaRepository userJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public UserV1ApiE2ETest( + TestRestTemplate testRestTemplate, + UserJpaRepository userJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.userJpaRepository = userJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/users") + @Nested + class Register { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๋กœ ํšŒ์›๊ฐ€์ž…ํ•˜๋ฉด, 201 CREATED์™€ userId๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returns201AndUserId_whenValidInfoIsProvided() { + // arrange + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + "testuser", + "Test1234!", + "ํ™๊ธธ๋™", + "19900101", + "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().userId()).isNotNull() + ); + } + + @DisplayName("์ด๋ฏธ ์กด์žฌํ•˜๋Š” loginId๋กœ ํšŒ์›๊ฐ€์ž…ํ•˜๋ฉด, 409 CONFLICT๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returns409Conflict_whenLoginIdAlreadyExists() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com")); + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + "testuser", + "Another12!", + "๊น€์ฒ ์ˆ˜", + "19950505", + "another@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @DisplayName("์œ ํšจํ•˜์ง€ ์•Š์€ ์ž…๋ ฅ์œผ๋กœ ํšŒ์›๊ฐ€์ž…ํ•˜๋ฉด, 400 BAD_REQUEST๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returns400BadRequest_whenInputIsInvalid() { + // arrange + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + "test@user", // ์ž˜๋ชป๋œ loginId + "Test1234!", + "ํ™๊ธธ๋™", + "19900101", + "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api/v1/users/me") + @Nested + class GetMe { + + @DisplayName("์œ ํšจํ•œ ์ธ์ฆ ์ •๋ณด๋กœ ์กฐํšŒํ•˜๋ฉด, 200 OK์™€ ๋งˆ์Šคํ‚น๋œ ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returns200AndMaskedUserInfo_whenCredentialsAreValid() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com")); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser"), + () -> assertThat(response.getBody().data().name()).isEqualTo("ํ™*๋™"), + () -> assertThat(response.getBody().data().birthDate()).isEqualTo("19900101"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + ); + } + + @DisplayName("์ธ์ฆ ํ—ค๋”๊ฐ€ ๋ˆ„๋ฝ๋˜๋ฉด, 400 BAD_REQUEST๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returns400BadRequest_whenAuthHeaderIsMissing() { + // arrange + HttpHeaders headers = new HttpHeaders(); + // ํ—ค๋” ์—†์Œ + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž๋กœ ์กฐํšŒํ•˜๋ฉด, 401 UNAUTHORIZED๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returns401Unauthorized_whenUserNotFound() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "nonexistent"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ‹€๋ฆฌ๋ฉด, 401 UNAUTHORIZED๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returns401Unauthorized_whenPasswordIsWrong() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com")); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("PATCH /api/v1/users/me/password") + @Nested + class ChangePassword { + + @DisplayName("์œ ํšจํ•œ ์ธ์ฆ๊ณผ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ๋ณ€๊ฒฝํ•˜๋ฉด, 200 OK์™€ ์„ฑ๊ณต ๋ฉ”์‹œ์ง€๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returns200AndSuccessMessage_whenCredentialsAndPasswordAreValid() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com")); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("Test1234!", "NewPass12!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().message()).isEqualTo("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") + ); + } + + @DisplayName("ํ—ค๋” ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ body currentPassword๊ฐ€ ๋‹ค๋ฅด๋ฉด, 401 UNAUTHORIZED๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returns401Unauthorized_whenCurrentPasswordIsWrong() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com")); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("WrongPass1!", "NewPass12!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๊ทœ์น™์„ ์œ„๋ฐ˜ํ•˜๋ฉด, 400 BAD_REQUEST๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returns400BadRequest_whenNewPasswordIsInvalid() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com")); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("Test1234!", "short"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๋™์ผํ•˜๋ฉด, 400 BAD_REQUEST๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returns400BadRequest_whenNewPasswordIsSameAsCurrent() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com")); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("Test1234!", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +} diff --git a/http/commerce-api/user-v1.http b/http/commerce-api/user-v1.http new file mode 100644 index 000000000..a392a8816 --- /dev/null +++ b/http/commerce-api/user-v1.http @@ -0,0 +1,27 @@ +### ํšŒ์›๊ฐ€์ž… +POST {{commerce-api}}/api/v1/users +Content-Type: application/json + +{ + "loginId": "testuser", + "password": "Test1234!", + "name": "ํ™๊ธธ๋™", + "birthDate": "19900101", + "email": "test@example.com" +} + +### ๋‚ด ์ •๋ณด ์กฐํšŒ +GET {{commerce-api}}/api/v1/users/me +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ +PATCH {{commerce-api}}/api/v1/users/me/password +Content-Type: application/json +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +{ + "currentPassword": "Test1234!", + "newPassword": "NewPass12!" +} From 61db53c8d9c6170dcdb32e55e7682281b6f0b6e1 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 6 Feb 2026 01:32:31 +0900 Subject: [PATCH 07/29] =?UTF-8?q?chore:=20PR=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20Claude=20Code=20=EC=BB=A4=EB=A7=A8?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .claude/commands/create-pr.md (PR ํ…œํ”Œ๋ฆฟ ๊ธฐ๋ฐ˜ ์ž๋™ ์ƒ์„ฑ ์Šคํ‚ฌ) Co-Authored-By: Claude Opus 4.5 --- .claude/commands/create-pr.md | 49 +++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .claude/commands/create-pr.md diff --git a/.claude/commands/create-pr.md b/.claude/commands/create-pr.md new file mode 100644 index 000000000..00b1102be --- /dev/null +++ b/.claude/commands/create-pr.md @@ -0,0 +1,49 @@ +ํ˜„์žฌ ๋ธŒ๋žœ์น˜์˜ ๋ณ€๊ฒฝ์‚ฌํ•ญ์„ ๋ถ„์„ํ•˜์—ฌ `.github/pull_request_template.md` ์–‘์‹์— ๋งž๋Š” PR์„ ์ž๋™ ์ƒ์„ฑํ•œ๋‹ค. + +## ์ˆ˜ํ–‰ ์ ˆ์ฐจ + +### 1๋‹จ๊ณ„: ๋ณ€๊ฒฝ์‚ฌํ•ญ ๋ถ„์„ +์•„๋ž˜ ๋ช…๋ น์–ด๋ฅผ **๋ณ‘๋ ฌ๋กœ** ์‹คํ–‰ํ•˜์—ฌ ์ •๋ณด๋ฅผ ์ˆ˜์ง‘ํ•œ๋‹ค: +- `git status` (๋ณ€๊ฒฝ๋œ ํŒŒ์ผ ๋ชฉ๋ก) +- `git log main..HEAD --oneline` (ํ˜„์žฌ ๋ธŒ๋žœ์น˜์˜ ์ปค๋ฐ‹ ๋‚ด์—ญ) +- `git diff main...HEAD --stat` (๋ณ€๊ฒฝ๋œ ํŒŒ์ผ ํ†ต๊ณ„) +- `git diff main...HEAD` (์ „์ฒด ๋ณ€๊ฒฝ ๋‚ด์šฉ) + +### 2๋‹จ๊ณ„: PR ๋ณธ๋ฌธ ์ž‘์„ฑ +`.github/pull_request_template.md` ์–‘์‹์„ ์ฝ๊ณ , ์ˆ˜์ง‘ํ•œ ์ •๋ณด๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ์•„๋ž˜ ๊ทœ์น™์— ๋”ฐ๋ผ ๋ณธ๋ฌธ์„ ์ž‘์„ฑํ•œ๋‹ค. + +#### ๐Ÿ“Œ Summary +- **๋ฐฐ๊ฒฝ**: ์ด ๋ณ€๊ฒฝ์ด ํ•„์š”ํ•œ ์ด์œ  (๊ธฐ์กด ๋ฌธ์ œ, ์š”๊ตฌ์‚ฌํ•ญ) +- **๋ชฉํ‘œ**: ์ด๋ฒˆ PR์—์„œ ๋‹ฌ์„ฑํ•˜๋ ค๋Š” ๊ฒƒ +- **๊ฒฐ๊ณผ**: ๋ณ€๊ฒฝ ํ›„ ๋‹ฌ๋ผ์ง€๋Š” ์  + +#### ๐Ÿงญ Context & Decision +- **๋ฌธ์ œ ์ •์˜**: ํ˜„์žฌ ๋™์ž‘/์ œ์•ฝ, ๋ฌธ์ œ(๋ฆฌ์Šคํฌ), ์„ฑ๊ณต ๊ธฐ์ค€์„ ๊ตฌ์ฒด์ ์œผ๋กœ ๊ธฐ์ˆ  +- **์„ ํƒ์ง€์™€ ๊ฒฐ์ •**: ์ฝ”๋“œ์—์„œ ์‹ค์ œ ์‚ฌ์šฉ๋œ ๊ธฐ์ˆ ์  ์„ ํƒ(ํŒจํ„ด, ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ, ๊ตฌ์กฐ)๊ณผ ๊ทธ ์ด์œ ๋ฅผ ๊ธฐ์ˆ . ๋Œ€์•ˆ์ด ๋ช…ํ™•ํ•˜์ง€ ์•Š์œผ๋ฉด "๋‹จ์ผ ์ ‘๊ทผ" ์œผ๋กœ ํ‘œ๊ธฐ + +#### ๐Ÿ—๏ธ Design Overview +- **๋ณ€๊ฒฝ ๋ฒ”์œ„**: ์‹ค์ œ ๋ณ€๊ฒฝ๋œ ๋ชจ๋“ˆ/๋„๋ฉ”์ธ, ์‹ ๊ทœ ์ถ”๊ฐ€ ํŒŒ์ผ, ์ œ๊ฑฐ/๋Œ€์ฒด๋œ ํŒŒ์ผ์„ ๋‚˜์—ด +- **์ฃผ์š” ์ปดํฌ๋„ŒํŠธ ์ฑ…์ž„**: ๋ณ€๊ฒฝ๋œ ์ฃผ์š” ํด๋ž˜์Šค/ํŒŒ์ผ์˜ ์—ญํ• ์„ `ComponentName`: ์„ค๋ช… ํ˜•ํƒœ๋กœ ๊ธฐ์ˆ  + +#### ๐Ÿ” Flow Diagram +- **ํ•ต์‹ฌ API ํ๋ฆ„๋งˆ๋‹ค** Mermaid `sequenceDiagram`์„ ์ž‘์„ฑํ•œ๋‹ค +- ์ฐธ์—ฌ์ž(participant)๋Š” ์‹ค์ œ ํด๋ž˜์Šค๋ช…์„ ์‚ฌ์šฉํ•œ๋‹ค +- `autonumber`๋ฅผ ํฌํ•จํ•œ๋‹ค +- ์ •์ƒ ํ๋ฆ„๊ณผ ์˜ˆ์™ธ ํ๋ฆ„(alt/else)์„ ๋ชจ๋‘ ํฌํ•จํ•œ๋‹ค +- API๊ฐ€ ์—ฌ๋Ÿฌ ๊ฐœ๋ฉด ๊ฐ๊ฐ ๋ณ„๋„ ๋‹ค์ด์–ด๊ทธ๋žจ์œผ๋กœ ์ž‘์„ฑํ•œ๋‹ค + +### 3๋‹จ๊ณ„: PR ์ƒ์„ฑ +- ๋ธŒ๋žœ์น˜๊ฐ€ ๋ฆฌ๋ชจํŠธ์— push๋˜์ง€ ์•Š์•˜์œผ๋ฉด `git push -u origin ` ์‹คํ–‰ +- `gh pr create` ๋ช…๋ น์–ด๋กœ PR ์ƒ์„ฑ +- PR ์ œ๋ชฉ์€ 70์ž ์ด๋‚ด, ๋ณ€๊ฒฝ์˜ ํ•ต์‹ฌ์„ ์š”์•ฝ +- PR ๋ณธ๋ฌธ์€ HEREDOC์œผ๋กœ ์ „๋‹ฌ + +```bash +gh pr create --title "PR ์ œ๋ชฉ" --body "$(cat <<'EOF' +... ์ž‘์„ฑ๋œ PR ๋ณธ๋ฌธ ... +EOF +)" +``` + +### 4๋‹จ๊ณ„: ๊ฒฐ๊ณผ ๋ณด๊ณ  +- ์ƒ์„ฑ๋œ PR URL์„ ์‚ฌ์šฉ์ž์—๊ฒŒ ๋ฐ˜ํ™˜ํ•œ๋‹ค From 44bfc368022317b7ceb2990fabbd617848243cb5 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 13 Feb 2026 15:33:13 +0900 Subject: [PATCH 08/29] =?UTF-8?q?docs:=20=EC=BB=A4=EB=A8=B8=EC=8A=A4=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=9A=94=EA=B5=AC=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EB=B6=84=EC=84=9D=20=EB=B0=8F=20=EC=84=A4=EA=B3=84?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 01-requirements.md: ๋„๋ฉ”์ธ๋ณ„ ํ•„๋“œ/๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™, ์œ ์ € ์‹œ๋‚˜๋ฆฌ์˜ค - 02-sequence-diagrams.md: ์ฃผ๋ฌธ/์ข‹์•„์š”/๋ธŒ๋žœ๋“œ ์‚ญ์ œ ์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ - 03-class-diagram.md: ๊ณ„์ธต๋ณ„ ํด๋ž˜์Šค ๊ตฌ์กฐ ๋‹ค์ด์–ด๊ทธ๋žจ - 04-erd.md: ํ…Œ์ด๋ธ” ์Šคํ‚ค๋งˆ, ์ธ๋ฑ์Šค, FK ์ •์ฑ… Co-Authored-By: Claude Opus 4.5 --- .claude/skills/requirements-analysis/SKILL.md | 77 ++ .docs/design/01-requirements.md | 431 +++++++++++ .docs/design/02-sequence-diagrams.md | 437 +++++++++++ .docs/design/03-class-diagram.md | 700 ++++++++++++++++++ .docs/design/04-erd.md | 419 +++++++++++ 5 files changed, 2064 insertions(+) create mode 100644 .claude/skills/requirements-analysis/SKILL.md create mode 100644 .docs/design/01-requirements.md create mode 100644 .docs/design/02-sequence-diagrams.md create mode 100644 .docs/design/03-class-diagram.md create mode 100644 .docs/design/04-erd.md diff --git a/.claude/skills/requirements-analysis/SKILL.md b/.claude/skills/requirements-analysis/SKILL.md new file mode 100644 index 000000000..3485a8af8 --- /dev/null +++ b/.claude/skills/requirements-analysis/SKILL.md @@ -0,0 +1,77 @@ +--- +name: requirements-analysis +description: + ์ œ๊ณต๋œ ์š”๊ตฌ์‚ฌํ•ญ์„ ๋ถ„์„ํ•˜๊ณ , ๊ฐœ๋ฐœ์ž์™€์˜ ์งˆ๋ฌธ/๋Œ€๋‹ต์„ ํ†ตํ•ด ์• ๋งคํ•œ ์š”๊ตฌ์‚ฌํ•ญ์„ ๋ช…ํ™•ํžˆ ํ•˜์—ฌ ์ •๋ฆฌํ•ฉ๋‹ˆ๋‹ค. + ๋ชจ๋“  ์ •๋ฆฌ๊ฐ€ ๋๋‚˜๋ฉด, ์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ, ํด๋ž˜์Šค ๋‹ค์ด์–ด๊ทธ๋žจ, ERD ๋“ฑ์„ Mermaid ๋ฌธ๋ฒ•์œผ๋กœ ์ž‘์„ฑํ•œ๋‹ค. + ์š”๊ตฌ์‚ฌํ•ญ์ด ์ œ๊ณต๋˜์—ˆ์„ ๋•Œ, ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•˜๊ธฐ ์ „ ์ด๋ฅผ ๋ช…ํ™•ํžˆ ํ•˜๋Š” ๋ฐ์— ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. +--- +์š”๊ตฌ์‚ฌํ•ญ์„ ๋ถ„์„ํ•  ๋•Œ ๋ฐ˜๋“œ์‹œ ๋‹ค์Œ ํ๋ฆ„์„ ๋”ฐ๋ฅธ๋‹ค. +### 1๏ธโƒฃ ์š”๊ตฌ์‚ฌํ•ญ์„ ๊ทธ๋Œ€๋กœ ๋ฏฟ์ง€ ๋ง๊ณ , ๋ฌธ์ œ ์ƒํ™ฉ์œผ๋กœ ๋‹ค์‹œ ์„ค๋ช…ํ•œ๋‹ค. +- ์š”๊ตฌ์‚ฌํ•ญ ๋ฌธ์žฅ์„ ์ •๋ฆฌํ•˜๋Š” ๋ฐ์„œ ๋๋‚ด์ง€ ์•Š๋Š”๋‹ค. +- "๋ฌด์—‡์„ ๋งŒ๋“ค๊นŒ?"๊ฐ€ ์•„๋‹ˆ๋ผ "์ง€๊ธˆ ์–ด๋–ค ๋ฌธ์ œ๊ฐ€ ์žˆ๊ณ , ๊ทธ๊ฑธ ์™œ ํ•ด๊ฒฐํ•˜๋ ค๋Š”๊ฐ€?" ๋กœ ์žฌํ•ด์„ํ•œ๋‹ค. +- ๋‹ค์Œ ๊ด€์ ์„ ๋ถ„๋ฆฌํ•ด์„œ ์ •๋ฆฌํ•œ๋‹ค: + - ์‚ฌ์šฉ์ž ๊ด€์  + - ๋น„์ฆˆ๋‹ˆ์Šค ๊ด€์  + - ์‹œ์Šคํ…œ ๊ด€์  +> ์˜ˆ์‹œ +> "์ฃผ๋ฌธ ์‹คํŒจ ์‹œ ๊ฒฐ์ œ๋ฅผ ์ทจ์†Œํ•œ๋‹ค" โ†’ "๊ฒฐ์ œ ์„ฑ๊ณต/์‹คํŒจ์™€ ์ฃผ๋ฌธ ์ƒํƒœ๊ฐ€ ์–ด๊ธ‹๋‚˜์ง€ ์•Š๋„๋ก ์ผ๊ด€์„ฑ์„ ์œ ์ง€ํ•˜๋ ค๋Š” ๋ฌธ์ œ" + +### 2๏ธโƒฃ ์• ๋งคํ•œ ์š”๊ตฌ์‚ฌํ•ญ์„ ์ˆจ๊ธฐ์ง€ ๋ง๊ณ  ๋“œ๋Ÿฌ๋‚ธ๋‹ค +- ์ถ”์ธกํ•˜๊ฑฐ๋‚˜ ์•Œ์•„์„œ ๊ฒฐ์ •ํ•˜์ง€ ์•Š๋Š”๋‹ค. +- ์š”๊ตฌ์‚ฌํ•ญ์—์„œ ๊ฒฐ์ •๋˜์ง€ ์•Š์€ ๋ถ€๋ถ„์„ ๋ช…์‹œ์ ์œผ๋กœ ๋‚˜์—ดํ•œ๋‹ค. + **๋‹ค์Œ ์œ ํ˜•์˜ ์งˆ๋ฌธ์„ ๋ฐ˜๋“œ์‹œ ํฌํ•จํ•œ๋‹ค:** +- ์ •์ฑ… ์งˆ๋ฌธ: ๊ธฐ์ค€ ์‹œ์ , ์„ฑ๊ณต/์‹คํŒจ ์กฐ๊ฑด, ์˜ˆ์™ธ ์ฒ˜๋ฆฌ ๊ทœ์น™ +- ๊ฒฝ๊ณ„ ์งˆ๋ฌธ: ์–ด๋””๊นŒ์ง€๊ฐ€ ํ•œ ์ฑ…์ž„์ธ๊ฐ€, ์–ด๋””์„œ ๋ถ„๋ฆฌ๋˜๋Š”๊ฐ€ +- ํ™•์žฅ ์งˆ๋ฌธ: ๋‚˜์ค‘์— ๋ฐ”๋€” ๊ฐ€๋Šฅ์„ฑ์ด ์žˆ๋Š”๊ฐ€ + +### 3๏ธโƒฃ ์š”๊ตฌ์‚ฌํ•ญ ๋ช…ํ™•ํ™”๋ฅผ ์œ„ํ•œ ์งˆ๋ฌธ์„ ๊ฐœ๋ฐœ์ž ๋‹ต๋ณ€์ด ์‰ฌ์šด ํ˜•ํƒœ๋กœ ์ œ์‹œํ•œ๋‹ค +- ์งˆ๋ฌธ์€ ์šฐ์„ ์ˆœ์œ„๋ฅผ ๊ฐ€์ง„๋‹ค (์ค‘์š”ํ•œ ๊ฒƒ๋ถ€ํ„ฐ). +- ์„ ํƒ์ง€๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ, ์˜ต์…˜ + ์˜ํ–ฅ๋„๋ฅผ ํ•จ๊ป˜ ์ œ์‹œํ•œ๋‹ค. +> ํ˜•์‹ ์˜ˆ์‹œ: +- ์„ ํƒ์ง€ A: ํ•˜๋‚˜์˜ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์ฒ˜๋ฆฌ โ†’ ๊ตฌํ˜„ ๋‹จ์ˆœ, ํ™•์žฅ์„ฑ ๋‚ฎ์Œ +- ์„ ํƒ์ง€ B: ๋‹จ๊ณ„๋ณ„ ๋ถ„๋ฆฌ โ†’ ๊ตฌ์กฐ ๋ณต์žก, ํ™•์žฅ/๋ณด์ƒ ์ฒ˜๋ฆฌ ์œ ๋ฆฌ + +### 4๏ธโƒฃ ํ•ฉ์˜๋œ ๋‚ด์šฉ์„ ๋ฐ”ํƒ•์œผ๋กœ ๊ฐœ๋… ๋ชจ๋ธ๋ถ€ํ„ฐ ์žก๋Š”๋‹ค +- ๋ฐ”๋กœ ์ฝ”๋“œ๋‚˜ ๊ธฐ์ˆ  ์–˜๊ธฐ๋กœ ๋“ค์–ด๊ฐ€์ง€ ์•Š๋Š”๋‹ค. +- ๋จผ์ € ๋‹ค์Œ์„ ์ •์˜ํ•œ๋‹ค: + - ์•กํ„ฐ (์‚ฌ์šฉ์ž, ์™ธ๋ถ€ ์‹œ์Šคํ…œ) + - ํ•ต์‹ฌ ๋„๋ฉ”์ธ + - ๋ณด์กฐ/์™ธ๋ถ€ ์‹œ์Šคํ…œ +- ์ด ๋‹จ๊ณ„๋Š” โ€œ๊ตฌํ˜„โ€์ด ์•„๋‹ˆ๋ผ ์„ค๊ณ„ ์‚ฌ๊ณ  ์ •๋ ฌ์ด ๋ชฉ์ ์ด๋‹ค. + +### 5๏ธโƒฃ ๋‹ค์ด์–ด๊ทธ๋žจ์€ ํ•ญ์ƒ ์ด์œ  โ†’ ๋‹ค์ด์–ด๊ทธ๋žจ โ†’ ํ•ด์„ ์ˆœ์„œ๋กœ ์ œ์‹œํ•œ๋‹ค +**๋‹ค์ด์–ด๊ทธ๋žจ์„ ๊ทธ๋ฆฌ๊ธฐ ์ „์— ๋ฐ˜๋“œ์‹œ ์„ค๋ช…ํ•œ๋‹ค** +- ์™œ ์ด ๋‹ค์ด์–ด๊ทธ๋žจ์ด ํ•„์š”ํ•œ์ง€ +- ์ด ๋‹ค์ด์–ด๊ทธ๋žจ์œผ๋กœ ๋ฌด์—‡์„ ๊ฒ€์ฆํ•˜๋ ค๋Š”์ง€ + +**๋‹ค์ด์–ด๊ทธ๋žจ์€ Mermaid ๋ฌธ๋ฒ•์œผ๋กœ ์ž‘์„ฑํ•œ๋‹ค** +์‚ฌ์šฉ ๊ธฐ์ค€: +- **์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ** + - ์ฑ…์ž„ ๋ถ„๋ฆฌ + - ํ˜ธ์ถœ ์ˆœ์„œ + - ํŠธ๋žœ์žญ์…˜ ๊ฒฝ๊ณ„ ํ™•์ธ +- **ํด๋ž˜์Šค ๋‹ค์ด์–ด๊ทธ๋žจ** + - ๋„๋ฉ”์ธ ์ฑ…์ž„ + - ์˜์กด ๋ฐฉํ–ฅ + - ์‘์ง‘๋„ ํ™•์ธ +- **ERD** + - ์˜์†์„ฑ ๊ตฌ์กฐ + - ๊ด€๊ณ„์˜ ์ฃผ์ธ + - ์ •๊ทœํ™” ์—ฌ๋ถ€ + +### 6๏ธโƒฃ ๋‹ค์ด์–ด๊ทธ๋žจ์„ ๋˜์ง€๊ณ  ๋๋‚ด์ง€ ๋ง๊ณ  ์ฝ๋Š” ๋ฒ•์„ ์งš์–ด์ค€๋‹ค +- "์ด ๊ตฌ์กฐ์—์„œ ํŠนํžˆ ๋ด์•ผ ํ•  ํฌ์ธํŠธ"๋ฅผ 2~3์ค„๋กœ ์„ค๋ช…ํ•œ๋‹ค. +- ์„ค๊ณ„ ์˜๋„๊ฐ€ ๋“œ๋Ÿฌ๋‚˜๋„๋ก ํ•ด์„์„ ๋ถ™์ธ๋‹ค. + +### 7๏ธโƒฃ ์„ค๊ณ„์˜ ์ž ์žฌ ๋ฆฌ์Šคํฌ๋ฅผ ๋ฐ˜๋“œ์‹œ ์–ธ๊ธ‰ํ•œ๋‹ค +- ํ˜„์žฌ ์„ค๊ณ„๊ฐ€ ๊ฐ€์งˆ ์ˆ˜ ์žˆ๋Š” ์œ„ํ—˜์„ ์ˆจ๊ธฐ์ง€ ์•Š๋Š”๋‹ค. + - ํŠธ๋žœ์žญ์…˜ ๋น„๋Œ€ํ™” + - ๋„๋ฉ”์ธ ๊ฐ„ ๊ฒฐํ•ฉ๋„ ์ฆ๊ฐ€ + - ์ •์ฑ… ๋ณ€๊ฒฝ ์‹œ ์˜ํ–ฅ ๋ฒ”์œ„ ํ™•๋Œ€ +- ํ•ด๊ฒฐ์ฑ…์€ ์ •๋‹ต์ฒ˜๋Ÿผ ๋งํ•˜์ง€ ์•Š๊ณ  ์„ ํƒ์ง€๋กœ ์ œ์‹œํ•œ๋‹ค. + +### ํ†ค & ์Šคํƒ€์ผ ๊ฐ€์ด๋“œ +- ๊ฐ•์˜์ฒ˜๋Ÿผ ์„ค๋ช…ํ•˜์ง€ ๋ง๊ณ  ์„ค๊ณ„ ๋ฆฌ๋ทฐ ํ†ค์„ ์œ ์ง€ํ•œ๋‹ค +- ์ •๋‹ต์ด๋ผ๊ณ  ์ œ์‹œํ•˜๊ธฐ๋ณด๋‹ค, ๋‹ค๋ฅธ ์„ ํƒ์ง€๊ฐ€ ์žˆ๋‹ค๋ฉด ์ด๋ฅผ ์ œ๊ณตํ•˜๋„๋ก ํ•œ๋‹ค. +- ์ฝ”๋“œ๋ณด๋‹ค ์˜๋„, ์ฑ…์ž„, ๊ฒฝ๊ณ„๋ฅผ ๋” ์ค‘์š”ํ•˜๊ฒŒ ๋‹ค๋ฃฌ๋‹ค +- ๊ตฌํ˜„ ์ „์— ์ƒ๊ฐํ•ด์•ผ ํ•  ๊ฒƒ์„ ๋Œ์–ด๋‚ด๋Š” ๋ฐ ์ง‘์ค‘ํ•œ๋‹ค \ No newline at end of file diff --git a/.docs/design/01-requirements.md b/.docs/design/01-requirements.md new file mode 100644 index 000000000..baa2e2eca --- /dev/null +++ b/.docs/design/01-requirements.md @@ -0,0 +1,431 @@ +# ์ปค๋จธ์Šค ๋„๋ฉ”์ธ ์š”๊ตฌ์‚ฌํ•ญ ์ •์˜์„œ + +## 1. ๊ฐœ์š” + +### 1.1 ๋ฌธ์„œ ๋ชฉ์  +Java/Spring Boot ๋ฉ€ํ‹ฐ ๋ชจ๋“ˆ ์ปค๋จธ์Šค ๋ฐฑ์—”๋“œ์˜ Brand, Product, ProductLike, Order, OrderItem ๋„๋ฉ”์ธ์— ๋Œ€ํ•œ +์ƒ์„ธ ์š”๊ตฌ์‚ฌํ•ญ์„ ์ •์˜ํ•œ๋‹ค. + +### 1.2 ๊ธฐ์กด ํŒจํ„ด ์ฐธ์กฐ +- ์•„ํ‚คํ…์ฒ˜: Layered Architecture (interfaces โ†’ application โ†’ domain โ†’ infrastructure) +- ์ธ์ฆ: ํ—ค๋” ๊ธฐ๋ฐ˜ ์ธ์ฆ (X-Loopers-LoginId, X-Loopers-LoginPw) +- ์‘๋‹ต ํ˜•์‹: ApiResponse (meta + data) +- ์˜ˆ์™ธ ์ฒ˜๋ฆฌ: CoreException + ErrorType enum + +### 1.3 ์•กํ„ฐ ์ •์˜ + +| ์•กํ„ฐ | ์„ค๋ช… | ์ธ์ฆ ๋ฐฉ์‹ | +|------|------|----------| +| ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž | ์ƒํ’ˆ ์กฐํšŒ, ์ข‹์•„์š”, ์ฃผ๋ฌธ ๊ฐ€๋Šฅ | X-Loopers-LoginId + X-Loopers-LoginPw | +| ์–ด๋“œ๋ฏผ | ๋ธŒ๋žœ๋“œ/์ƒํ’ˆ CRUD ๊ด€๋ฆฌ | X-Loopers-Ldap: loopers.admin | + +--- + +## 2. ๋„๋ฉ”์ธ๋ณ„ ์ƒ์„ธ ์š”๊ตฌ์‚ฌํ•ญ + +### 2.1 Brand (๋ธŒ๋žœ๋“œ) + +#### 2.1.1 ํ•„๋“œ ์ •์˜ +| ํ•„๋“œ | ํƒ€์ž… | ํ•„์ˆ˜ | ์ œ์•ฝ์กฐ๊ฑด | +|------|------|------|----------| +| id | Long | Y | ์ž๋™ ์ƒ์„ฑ (PK) | +| name | String | Y | 1-100์ž, ๊ณต๋ฐฑ ๋ถˆ๊ฐ€, ์ค‘๋ณต ๋ถˆ๊ฐ€ | +| description | String | N | ์ตœ๋Œ€ 500์ž | +| logoUrl | String | N | URL ํ˜•์‹ ๊ฒ€์ฆ, ์ตœ๋Œ€ 500์ž | +| createdAt | ZonedDateTime | Y | ์ž๋™ ์ƒ์„ฑ | +| updatedAt | ZonedDateTime | Y | ์ž๋™ ๊ฐฑ์‹  | +| deletedAt | ZonedDateTime | N | Soft Delete | + +*๋ธŒ๋žœ๋“œ ์ฃผ์†Œ, ๋Œ€ํ‘œ๋ช…, ๋ธŒ๋žœ๋“œ ์‚ฌ์ดํŠธ URL ๊ฐ™์€ ์ปฌ๋Ÿผ์„ ๋„ฃ์„์ง€ ๋ง์ง€ ๊ณ ๋ฏผํ–ˆ์ง€๋งŒ ์„ค๊ณ„์— ์ง‘์ค‘ํ•˜๊ณ  ์‹ถ์–ด์„œ ๋„ฃ์ง€ ์•Š์•˜๋‹ค.* + +#### 2.1.2 ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ +- **BR-BRAND-001**: ๋ธŒ๋žœ๋“œ๋ช…์€ ์‹œ์Šคํ…œ ๋‚ด ์œ ์ผํ•ด์•ผ ํ•œ๋‹ค +- **BR-BRAND-002**: ๋ธŒ๋žœ๋“œ ์‚ญ์ œ ์‹œ ํ•ด๋‹น ๋ธŒ๋žœ๋“œ์˜ ๋ชจ๋“  ์ƒํ’ˆ์ด Cascade ์‚ญ์ œ๋œ๋‹ค +- **BR-BRAND-003**: ์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ๋Š” ์กฐํšŒ๋˜์ง€ ์•Š๋Š”๋‹ค (Soft Delete) + +#### 2.1.3 ๊ฒ€์ฆ ๊ทœ์น™ +``` +name ๊ฒ€์ฆ: +- null ๋˜๋Š” blank ๋ถˆ๊ฐ€ โ†’ "๋ธŒ๋žœ๋“œ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค." +- 100์ž ์ดˆ๊ณผ โ†’ "๋ธŒ๋žœ๋“œ๋ช…์€ 100์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." +- ์ค‘๋ณต โ†’ "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ธŒ๋žœ๋“œ๋ช…์ž…๋‹ˆ๋‹ค." (409 CONFLICT) + +description ๊ฒ€์ฆ: +- 500์ž ์ดˆ๊ณผ โ†’ "๋ธŒ๋žœ๋“œ ์„ค๋ช…์€ 500์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." + +logoUrl ๊ฒ€์ฆ: +- URL ํ˜•์‹ ๋ถˆ์ผ์น˜ โ†’ "์œ ํšจํ•˜์ง€ ์•Š์€ URL ํ˜•์‹์ž…๋‹ˆ๋‹ค." +- 500์ž ์ดˆ๊ณผ โ†’ "๋กœ๊ณ  URL์€ 500์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." +``` + +--- + +### 2.2 Product (์ƒํ’ˆ) + +#### 2.2.1 ํ•„๋“œ ์ •์˜ +| ํ•„๋“œ | ํƒ€์ž… | ํ•„์ˆ˜ | ์ œ์•ฝ์กฐ๊ฑด | +|------|------|------|----------| +| id | Long | Y | ์ž๋™ ์ƒ์„ฑ (PK) | +| brandId | Long | Y | Brand FK, ์กด์žฌ ๊ฒ€์ฆ | +| name | String | Y | 1-200์ž | +| description | String | N | ์ตœ๋Œ€ 2000์ž | +| price | Long | Y | 0 ์ด์ƒ | +| stock | Integer | Y | 0 ์ด์ƒ | +| imageUrl | String | N | URL ํ˜•์‹, ์ตœ๋Œ€ 500์ž | +| createdAt | ZonedDateTime | Y | ์ž๋™ ์ƒ์„ฑ | +| updatedAt | ZonedDateTime | Y | ์ž๋™ ๊ฐฑ์‹  | +| deletedAt | ZonedDateTime | N | Soft Delete | + +#### 2.2.2 ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ +- **BR-PRODUCT-001**: ์ƒํ’ˆ์€ ๋ฐ˜๋“œ์‹œ ํ•˜๋‚˜์˜ ๋ธŒ๋žœ๋“œ์— ์†ํ•ด์•ผ ํ•œ๋‹ค +- **BR-PRODUCT-002**: ์ƒํ’ˆ ๋“ฑ๋ก ํ›„ ๋ธŒ๋žœ๋“œ ๋ณ€๊ฒฝ ๋ถˆ๊ฐ€ +- **BR-PRODUCT-003**: ์ƒํ’ˆ ์‚ญ์ œ ์‹œ ํ•ด๋‹น ์ƒํ’ˆ์˜ ๋ชจ๋“  ์ข‹์•„์š”๊ฐ€ Cascade ์‚ญ์ œ๋œ๋‹ค +- **BR-PRODUCT-004**: ์‚ญ์ œ๋œ ์ƒํ’ˆ์€ ๋ชฉ๋ก ์กฐํšŒ ์‹œ ์ œ์™ธ๋œ๋‹ค +- **BR-PRODUCT-005**: ์žฌ๊ณ ๊ฐ€ 0์ธ ์ƒํ’ˆ๋„ ์กฐํšŒ๋Š” ๊ฐ€๋Šฅํ•˜๋‹ค + +#### 2.2.3 ๊ฒ€์ฆ ๊ทœ์น™ +``` +name ๊ฒ€์ฆ: +- null ๋˜๋Š” blank ๋ถˆ๊ฐ€ โ†’ "์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค." +- 200์ž ์ดˆ๊ณผ โ†’ "์ƒํ’ˆ๋ช…์€ 200์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." + +price ๊ฒ€์ฆ: +- null ๋ถˆ๊ฐ€ โ†’ "๊ฐ€๊ฒฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค." +- ์Œ์ˆ˜ โ†’ "๊ฐ€๊ฒฉ์€ 0์› ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค." + +stock ๊ฒ€์ฆ: +- null ๋ถˆ๊ฐ€ โ†’ "์žฌ๊ณ ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค." +- ์Œ์ˆ˜ โ†’ "์žฌ๊ณ ๋Š” 0๊ฐœ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค." + +brandId ๊ฒ€์ฆ: +- ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ธŒ๋žœ๋“œ โ†’ "์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ธŒ๋žœ๋“œ์ž…๋‹ˆ๋‹ค." (404 NOT_FOUND) +- ์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ โ†’ "์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ์ž…๋‹ˆ๋‹ค." (400 BAD_REQUEST) +``` + +--- + +### 2.3 ProductLike (์ƒํ’ˆ ์ข‹์•„์š”) + +#### 2.3.1 ํ•„๋“œ ์ •์˜ +| ํ•„๋“œ | ํƒ€์ž… | ํ•„์ˆ˜ | ์ œ์•ฝ์กฐ๊ฑด | +|------|------|------|----------| +| id | Long | Y | ์ž๋™ ์ƒ์„ฑ (PK) | +| userId | Long | Y | User FK | +| productId | Long | Y | Product FK | +| createdAt | ZonedDateTime | Y | ์ž๋™ ์ƒ์„ฑ | + +#### 2.3.2 ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ +- **BR-LIKE-001**: ์ข‹์•„์š” ๋“ฑ๋ก ์‹œ ์ด๋ฏธ ์ข‹์•„์š”๊ฐ€ ์กด์žฌํ•˜๋ฉด ์ข‹์•„์š” ์ทจ์†Œ ์ฒ˜๋ฆฌ (ํ† ๊ธ€ ๋ฐฉ์‹) +- **BR-LIKE-002**: ์ข‹์•„์š” ๊ฐœ์ˆ˜๋Š” ์‹ค์‹œ๊ฐ„ COUNT ์ง‘๊ณ„ +- **BR-LIKE-003**: ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ข‹์•„์š” ์ทจ์†Œ ์‹œ ๋ฉฑ๋“ฑ ์ฒ˜๋ฆฌ (์—๋Ÿฌ ์—†์ด ์„ฑ๊ณต ์‘๋‹ต) +- **BR-LIKE-004**: ์‚ญ์ œ๋œ ์ƒํ’ˆ์—๋Š” ์ข‹์•„์š” ๋ถˆ๊ฐ€ + +#### 2.3.3 ๊ฒ€์ฆ ๊ทœ์น™ +``` +์ข‹์•„์š” ๋“ฑ๋ก: +- ์‚ญ์ œ๋œ ์ƒํ’ˆ โ†’ "์‚ญ์ œ๋œ ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค." (400 BAD_REQUEST) +- ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ โ†’ "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค." (404 NOT_FOUND) +- ์ค‘๋ณต ์ข‹์•„์š” โ†’ ์ข‹์•„์š” ์ทจ์†Œ ์ฒ˜๋ฆฌ (ํ† ๊ธ€) + +์ข‹์•„์š” ์ทจ์†Œ: +- ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ข‹์•„์š” โ†’ ๋ฉฑ๋“ฑ ์ฒ˜๋ฆฌ (์„ฑ๊ณต ์‘๋‹ต) +``` + +--- + +### 2.4 Order (์ฃผ๋ฌธ) + +#### 2.4.1 ํ•„๋“œ ์ •์˜ +| ํ•„๋“œ | ํƒ€์ž… | ํ•„์ˆ˜ | ์ œ์•ฝ์กฐ๊ฑด | +|------|------|------|----------| +| id | Long | Y | ์ž๋™ ์ƒ์„ฑ (PK) | +| userId | Long | Y | User FK | +| totalPrice | Long | Y | 0 ์ด์ƒ, ๊ณ„์‚ฐ๋œ ๊ฐ’ | +| status | OrderStatus | Y | PENDING, COMPLETED, CANCELLED | +| createdAt | ZonedDateTime | Y | ์ž๋™ ์ƒ์„ฑ | +| updatedAt | ZonedDateTime | Y | ์ž๋™ ๊ฐฑ์‹  | + +#### 2.4.2 OrderStatus ์ƒํƒœ ์ •์˜ +```java +public enum OrderStatus { + PENDING, // ์ฃผ๋ฌธ ๋Œ€๊ธฐ + COMPLETED, // ์ฃผ๋ฌธ ์™„๋ฃŒ + CANCELLED // ์ฃผ๋ฌธ ์ทจ์†Œ +} +``` + +#### 2.4.3 ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ +- **BR-ORDER-001**: ๋‹ค๊ฑด ์ƒํ’ˆ ์ฃผ๋ฌธ ์ง€์› (OrderItem 1:N ๊ด€๊ณ„) +- **BR-ORDER-002**: ์ „์ฒด ์‹คํŒจ ์ •์ฑ… - ํ•˜๋‚˜๋ผ๋„ ์‹คํŒจ ์‹œ ์ „์ฒด ์ฃผ๋ฌธ ๋กค๋ฐฑ +- **BR-ORDER-003**: ์žฌ๊ณ  ๊ฒ€์ฆ ํ›„ ์ฐจ๊ฐ์€ ์›์ž์ ์œผ๋กœ ์ˆ˜ํ–‰ (๋น„๊ด€์  ๋ฝ) +- **BR-ORDER-004**: totalPrice๋Š” OrderItem๋“ค์˜ (price * quantity) ํ•ฉ๊ณ„ + +--- + +### 2.5 OrderItem (์ฃผ๋ฌธ ํ•ญ๋ชฉ) + +#### 2.5.1 ํ•„๋“œ ์ •์˜ +| ํ•„๋“œ | ํƒ€์ž… | ํ•„์ˆ˜ | ์ œ์•ฝ์กฐ๊ฑด | +|------|------|------|----------| +| id | Long | Y | ์ž๋™ ์ƒ์„ฑ (PK) | +| orderId | Long | Y | Order FK | +| productId | Long | Y | Product FK | +| quantity | Integer | Y | 1 ์ด์ƒ | +| price | Long | Y | ์ฃผ๋ฌธ ์‹œ์  ์ƒํ’ˆ ๊ฐ€๊ฒฉ (์Šค๋ƒ…์ƒท) | +| createdAt | ZonedDateTime | Y | ์ž๋™ ์ƒ์„ฑ | + +#### 2.5.2 ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ +- **BR-ORDERITEM-001**: ์ฃผ๋ฌธ ์‹œ์ ์˜ ์ƒํ’ˆ ๊ฐ€๊ฒฉ์„ ์Šค๋ƒ…์ƒท์œผ๋กœ ์ €์žฅ +- **BR-ORDERITEM-002**: ๋™์ผ ์ฃผ๋ฌธ ๋‚ด ๋™์ผ ์ƒํ’ˆ ์ค‘๋ณต ๋ถˆ๊ฐ€ (orderId + productId UNIQUE) +- **BR-ORDERITEM-003**: ์ˆ˜๋Ÿ‰์€ ์ตœ์†Œ 1๊ฐœ ์ด์ƒ + +--- + +## 3. ์œ ์ € ์‹œ๋‚˜๋ฆฌ์˜ค + +### 3.1 ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž ์‹œ๋‚˜๋ฆฌ์˜ค + +#### US-001: ๋ธŒ๋žœ๋“œ ์ •๋ณด ์กฐํšŒ +``` +Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ +When: GET /api/v1/brands/{brandId} ์š”์ฒญ +Then: ๋ธŒ๋žœ๋“œ ์ •๋ณด(name, description, logoUrl) ๋ฐ˜ํ™˜ +``` + +#### US-002: ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ +``` +Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ +When: GET /api/v1/products ์š”์ฒญ (์ •๋ ฌ/ํ•„ํ„ฐ/ํŽ˜์ด์ง• ์˜ต์…˜) +Then: ์ƒํ’ˆ ๋ชฉ๋ก๊ณผ ์ข‹์•„์š” ์ˆ˜ ๋ฐ˜ํ™˜ + +์ •๋ ฌ ์˜ต์…˜: +- latest (๊ธฐ๋ณธ๊ฐ’): ์ตœ์‹ ์ˆœ +- price_asc: ๊ฐ€๊ฒฉ ๋‚ฎ์€์ˆœ +- like_desc: ์ข‹์•„์š” ๋งŽ์€์ˆœ + +ํ•„ํ„ฐ ์˜ต์…˜: +- brandId: ํŠน์ • ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ + +ํŽ˜์ด์ง•: +- page: ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ (0๋ถ€ํ„ฐ ์‹œ์ž‘) +- size: ํŽ˜์ด์ง€ ํฌ๊ธฐ (๊ธฐ๋ณธ 20) +``` + +#### US-003: ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ +``` +Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ +When: GET /api/v1/products/{productId} ์š”์ฒญ +Then: ์ƒํ’ˆ ์ƒ์„ธ ์ •๋ณด์™€ ์ข‹์•„์š” ์ˆ˜ ๋ฐ˜ํ™˜ +``` + +#### US-004: ์ข‹์•„์š” ๋“ฑ๋ก/ํ† ๊ธ€ +``` +Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ +When: POST /api/v1/products/{productId}/likes ์š”์ฒญ +Then: + - ์ข‹์•„์š”๊ฐ€ ์—†์œผ๋ฉด โ†’ ์ข‹์•„์š” ๋“ฑ๋ก + - ์ข‹์•„์š”๊ฐ€ ์žˆ์œผ๋ฉด โ†’ ์ข‹์•„์š” ์ทจ์†Œ (ํ† ๊ธ€) +``` + +#### US-005: ์ข‹์•„์š” ์ทจ์†Œ +``` +Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ +When: DELETE /api/v1/products/{productId}/likes ์š”์ฒญ +Then: ์ข‹์•„์š” ์‚ญ์ œ (์กด์žฌํ•˜์ง€ ์•Š์•„๋„ ์„ฑ๊ณต) +``` + +#### US-006: ๋‚ด๊ฐ€ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ ๋ชฉ๋ก +``` +Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ +When: GET /api/v1/users/{userId}/likes ์š”์ฒญ +Then: ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ ๋ชฉ๋ก ๋ฐ˜ํ™˜ +``` + +#### US-007: ์ฃผ๋ฌธ ์ƒ์„ฑ +``` +Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ, ์ฃผ๋ฌธํ•  ์ƒํ’ˆ๋“ค ์„ ํƒ +When: POST /api/v1/orders ์š”์ฒญ (items: [{productId, quantity}]) +Then: + - ์žฌ๊ณ  ๊ฒ€์ฆ (๋ชจ๋“  ์ƒํ’ˆ) + - ์žฌ๊ณ  ์ฐจ๊ฐ (์›์ž์ ) + - ์ฃผ๋ฌธ ์ƒ์„ฑ ๋ฐ ID ๋ฐ˜ํ™˜ + - ์‹คํŒจ ์‹œ ์ „์ฒด ๋กค๋ฐฑ +``` + +#### US-008: ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ +``` +Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ +When: GET /api/v1/orders ์š”์ฒญ (์„ ํƒ์  ๊ธฐ๊ฐ„ ํ•„ํ„ฐ) +Then: ๋ณธ์ธ์˜ ์ฃผ๋ฌธ ๋ชฉ๋ก ๋ฐ˜ํ™˜ +``` + +#### US-009: ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ +``` +Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ +When: GET /api/v1/orders/{orderId} ์š”์ฒญ +Then: + - ๋ณธ์ธ ์ฃผ๋ฌธ์ธ ๊ฒฝ์šฐ: ์ฃผ๋ฌธ ์ƒ์„ธ ๋ฐ˜ํ™˜ + - ํƒ€์ธ ์ฃผ๋ฌธ์ธ ๊ฒฝ์šฐ: 403 FORBIDDEN +``` + +--- + +### 3.2 ์–ด๋“œ๋ฏผ ์‹œ๋‚˜๋ฆฌ์˜ค + +#### AS-001: ๋ธŒ๋žœ๋“œ ๋ชฉ๋ก ์กฐํšŒ (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin ํ—ค๋” +When: GET /api-admin/v1/brands ์š”์ฒญ +Then: ์ „์ฒด ๋ธŒ๋žœ๋“œ ๋ชฉ๋ก ๋ฐ˜ํ™˜ +``` + +#### AS-002: ๋ธŒ๋žœ๋“œ ๋“ฑ๋ก (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin ํ—ค๋” +When: POST /api-admin/v1/brands ์š”์ฒญ +Then: ๋ธŒ๋žœ๋“œ ๋“ฑ๋ก ๋ฐ ID ๋ฐ˜ํ™˜ +``` + +#### AS-003: ๋ธŒ๋žœ๋“œ ์ˆ˜์ • (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin ํ—ค๋” +When: PUT /api-admin/v1/brands/{brandId} ์š”์ฒญ +Then: ๋ธŒ๋žœ๋“œ ์ •๋ณด ์ˆ˜์ • +``` + +#### AS-004: ๋ธŒ๋žœ๋“œ ์‚ญ์ œ (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin ํ—ค๋” +When: DELETE /api-admin/v1/brands/{brandId} ์š”์ฒญ +Then: + - ๋ธŒ๋žœ๋“œ Soft Delete + - ํ•ด๋‹น ๋ธŒ๋žœ๋“œ์˜ ๋ชจ๋“  ์ƒํ’ˆ Cascade Soft Delete + - ์ƒํ’ˆ๋“ค์˜ ์ข‹์•„์š” Cascade Hard Delete +``` + +#### AS-005: ์ƒํ’ˆ ๋“ฑ๋ก (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin ํ—ค๋” +When: POST /api-admin/v1/products ์š”์ฒญ +Then: ์ƒํ’ˆ ๋“ฑ๋ก ๋ฐ ID ๋ฐ˜ํ™˜ +``` + +#### AS-006: ์ƒํ’ˆ ์ˆ˜์ • (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin ํ—ค๋” +When: PUT /api-admin/v1/products/{productId} ์š”์ฒญ +Then: + - brandId ์ œ์™ธ ํ•„๋“œ ์ˆ˜์ • ๊ฐ€๋Šฅ + - brandId ๋ณ€๊ฒฝ ์‹œ๋„ ์‹œ: "๋ธŒ๋žœ๋“œ ๋ณ€๊ฒฝ์€ ๋ถˆ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค." (400 BAD_REQUEST) +``` + +#### AS-007: ์ƒํ’ˆ ์‚ญ์ œ (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin ํ—ค๋” +When: DELETE /api-admin/v1/products/{productId} ์š”์ฒญ +Then: + - ์ƒํ’ˆ Soft Delete + - ์ข‹์•„์š” Cascade Hard Delete +``` + +--- + +## 4. API ๋ช…์„ธ + +### 4.1 ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž API + +| Method | Endpoint | ์„ค๋ช… | +|--------|----------|------| +| GET | /api/v1/brands/{brandId} | ๋ธŒ๋žœ๋“œ ์ •๋ณด ์กฐํšŒ | +| GET | /api/v1/products | ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ | +| GET | /api/v1/products/{productId} | ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ | +| POST | /api/v1/products/{productId}/likes | ์ข‹์•„์š” ๋“ฑ๋ก | +| DELETE | /api/v1/products/{productId}/likes | ์ข‹์•„์š” ์ทจ์†Œ | +| GET | /api/v1/users/{userId}/likes | ๋‚ด๊ฐ€ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ ๋ชฉ๋ก | +| POST | /api/v1/orders | ์ฃผ๋ฌธ ์ƒ์„ฑ | +| GET | /api/v1/orders | ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ | +| GET | /api/v1/orders/{orderId} | ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ | + +### 4.2 ์–ด๋“œ๋ฏผ API + +| Method | Endpoint | ์„ค๋ช… | +|--------|----------|------| +| GET | /api-admin/v1/brands | ๋ธŒ๋žœ๋“œ ๋ชฉ๋ก ์กฐํšŒ | +| GET | /api-admin/v1/brands/{brandId} | ๋ธŒ๋žœ๋“œ ์ƒ์„ธ ์กฐํšŒ | +| POST | /api-admin/v1/brands | ๋ธŒ๋žœ๋“œ ๋“ฑ๋ก | +| PUT | /api-admin/v1/brands/{brandId} | ๋ธŒ๋žœ๋“œ ์ˆ˜์ • | +| DELETE | /api-admin/v1/brands/{brandId} | ๋ธŒ๋žœ๋“œ ์‚ญ์ œ | +| GET | /api-admin/v1/products | ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ | +| GET | /api-admin/v1/products/{productId} | ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ | +| POST | /api-admin/v1/products | ์ƒํ’ˆ ๋“ฑ๋ก | +| PUT | /api-admin/v1/products/{productId} | ์ƒํ’ˆ ์ˆ˜์ • | +| DELETE | /api-admin/v1/products/{productId} | ์ƒํ’ˆ ์‚ญ์ œ | + +--- + +## 5. ์ธ์ฆ ์ฒด๊ณ„ + +### 5.1 ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž ์ธ์ฆ +``` +Headers: + X-Loopers-LoginId: {loginId} + X-Loopers-LoginPw: {password} + +์ฒ˜๋ฆฌ: AuthenticatedUserArgumentResolver +๋Œ€์ƒ: /api/v1/** ์—”๋“œํฌ์ธํŠธ +``` + +### 5.2 ์–ด๋“œ๋ฏผ ์ธ์ฆ +``` +Headers: + X-Loopers-Ldap: loopers.admin + +์ฒ˜๋ฆฌ: AdminAuthInterceptor + AdminUserArgumentResolver +๋Œ€์ƒ: /api-admin/v1/** ์—”๋“œํฌ์ธํŠธ +``` + +--- + +## 6. ์—๋Ÿฌ ํƒ€์ž… ์ •์˜ + +```java +// ErrorType.java์— ์ถ”๊ฐ€ํ•  ํƒ€์ž… +BRAND_NOT_FOUND(HttpStatus.NOT_FOUND, "BRAND_NOT_FOUND", "์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ธŒ๋žœ๋“œ์ž…๋‹ˆ๋‹ค."), +BRAND_ALREADY_EXISTS(HttpStatus.CONFLICT, "BRAND_ALREADY_EXISTS", "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ธŒ๋žœ๋“œ๋ช…์ž…๋‹ˆ๋‹ค."), +BRAND_DELETED(HttpStatus.BAD_REQUEST, "BRAND_DELETED", "์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ์ž…๋‹ˆ๋‹ค."), +PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "PRODUCT_NOT_FOUND", "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค."), +PRODUCT_DELETED(HttpStatus.BAD_REQUEST, "PRODUCT_DELETED", "์‚ญ์ œ๋œ ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค."), +INSUFFICIENT_STOCK(HttpStatus.BAD_REQUEST, "INSUFFICIENT_STOCK", "์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."), +ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "ORDER_NOT_FOUND", "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ฃผ๋ฌธ์ž…๋‹ˆ๋‹ค."), +ORDER_ACCESS_DENIED(HttpStatus.FORBIDDEN, "ORDER_ACCESS_DENIED", "์ฃผ๋ฌธ ์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."), +ADMIN_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "ADMIN_UNAUTHORIZED", "๊ด€๋ฆฌ์ž ๊ถŒํ•œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."), +BRAND_CHANGE_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "BRAND_CHANGE_NOT_ALLOWED", "๋ธŒ๋žœ๋“œ ๋ณ€๊ฒฝ์€ ๋ถˆ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค."); +``` + +--- + +## 7. API ์‘๋‹ต ํ˜•์‹ + +### 7.1 ์„ฑ๊ณต ์‘๋‹ต +```json +{ + "meta": { + "result": "SUCCESS", + "errorCode": null, + "message": null + }, + "data": { ... } +} +``` + +### 7.2 ์‹คํŒจ ์‘๋‹ต +```json +{ + "meta": { + "result": "FAIL", + "errorCode": "PRODUCT_NOT_FOUND", + "message": "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค." + }, + "data": null +} +``` diff --git a/.docs/design/02-sequence-diagrams.md b/.docs/design/02-sequence-diagrams.md new file mode 100644 index 000000000..faa32ccc6 --- /dev/null +++ b/.docs/design/02-sequence-diagrams.md @@ -0,0 +1,437 @@ +# ์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ + +## ๋‹ค์ด์–ด๊ทธ๋žจ ๋ชฉ์  +์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ์„ ํ†ตํ•ด ๋‹ค์Œ์„ ๊ฒ€์ฆํ•œ๋‹ค: +- ์ฑ…์ž„ ๋ถ„๋ฆฌ: ๊ฐ ๊ฐ์ฒด๊ฐ€ ๋งก์€ ์—ญํ• ์ด ๋ช…ํ™•ํ•œ๊ฐ€ +- ํ˜ธ์ถœ ์ˆœ์„œ: ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์˜ ํ๋ฆ„์ด ์˜ฌ๋ฐ”๋ฅธ๊ฐ€ +- ํŠธ๋žœ์žญ์…˜ ๊ฒฝ๊ณ„: ์›์ž์„ฑ์ด ๋ณด์žฅ๋˜๋Š” ๋ฒ”์œ„๊ฐ€ ์ ์ ˆํ•œ๊ฐ€ + +--- + +## 1. ์ฃผ๋ฌธ ์ƒ์„ฑ ์‹œํ€€์Šค + +### 1.1 ์ •์ƒ ํ๋ฆ„ (๋‹ค๊ฑด ์ฃผ๋ฌธ) + +**๋ชฉ์ **: ์žฌ๊ณ  ๊ฒ€์ฆ, ๋น„๊ด€์  ๋ฝ, ํŠธ๋žœ์žญ์…˜ ๊ฒฝ๊ณ„ ํ™•์ธ + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant Ctrl as OrderV1Controller + participant F as OrderFacade + participant OS as OrderService + participant PS as ProductService + participant OR as OrderRepository + participant PR as ProductRepository + participant DB as Database + + C->>+Ctrl: POST /api/v1/orders + Note over C,Ctrl: Headers: X-Loopers-LoginId, X-Loopers-LoginPw + Note over C,Ctrl: Body: { items: [{productId, quantity}] } + + Ctrl->>+F: createOrder(userId, items) + + F->>+OS: createOrder(userId, items) + + Note over OS: @Transactional ์‹œ์ž‘ + + loop ๊ฐ ์ฃผ๋ฌธ ํ•ญ๋ชฉ์— ๋Œ€ํ•ด + OS->>+PS: getProductForOrder(productId) + PS->>+PR: findByIdWithLock(productId) + PR->>+DB: SELECT ... FOR UPDATE + DB-->>-PR: Product + PR-->>-PS: Product + PS-->>-OS: Product + + OS->>OS: ์žฌ๊ณ  ๊ฒ€์ฆ (stock >= quantity) + + alt ์žฌ๊ณ  ๋ถ€์กฑ + OS-->>F: throw CoreException(INSUFFICIENT_STOCK) + Note over OS,DB: ์ „์ฒด ๋กค๋ฐฑ + end + + OS->>+PS: decreaseStock(productId, quantity) + PS->>+PR: save(product) + PR->>+DB: UPDATE products SET stock = stock - quantity + DB-->>-PR: OK + PR-->>-PS: Product + PS-->>-OS: void + end + + OS->>OS: totalPrice ๊ณ„์‚ฐ + OS->>OS: Order ์—”ํ‹ฐํ‹ฐ ์ƒ์„ฑ + OS->>OS: OrderItem ์—”ํ‹ฐํ‹ฐ๋“ค ์ƒ์„ฑ (๊ฐ€๊ฒฉ ์Šค๋ƒ…์ƒท) + + OS->>+OR: save(order) + OR->>+DB: INSERT orders, order_items + DB-->>-OR: Order (with ID) + OR-->>-OS: Order + + Note over OS: @Transactional ์ปค๋ฐ‹ + + OS-->>-F: Order + F->>F: OrderInfo.from(order) + F-->>-Ctrl: OrderInfo + + Ctrl->>Ctrl: OrderV1Dto.CreateResponse.from(info) + Ctrl-->>-C: 201 Created + { orderId, totalPrice } +``` + +**ํ•ต์‹ฌ ํฌ์ธํŠธ:** +- `SELECT ... FOR UPDATE`๋กœ ๋น„๊ด€์  ๋ฝ ํš๋“ โ†’ ๋™์‹œ ์ฃผ๋ฌธ ์‹œ ์žฌ๊ณ  ๊ฒฝ์Ÿ ๋ฐฉ์ง€ +- ๋ชจ๋“  ์ƒํ’ˆ ๊ฒ€์ฆ ํ›„ ์ฐจ๊ฐ โ†’ ํ•˜๋‚˜๋ผ๋„ ์‹คํŒจ ์‹œ ์ „์ฒด ๋กค๋ฐฑ +- OrderItem์— ๊ฐ€๊ฒฉ ์Šค๋ƒ…์ƒท ์ €์žฅ โ†’ ์ƒํ’ˆ ๊ฐ€๊ฒฉ ๋ณ€๊ฒฝ ์‹œ์—๋„ ์ฃผ๋ฌธ ๊ฐ€๊ฒฉ ์œ ์ง€ + +--- + +### 1.2 ์žฌ๊ณ  ๋ถ€์กฑ ์‹คํŒจ ํ๋ฆ„ + +**๋ชฉ์ **: ์ „์ฒด ์‹คํŒจ ์ •์ฑ…, ๋กค๋ฐฑ ๋™์ž‘ ํ™•์ธ + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant Ctrl as OrderV1Controller + participant F as OrderFacade + participant OS as OrderService + participant PS as ProductService + participant PR as ProductRepository + participant DB as Database + + C->>+Ctrl: POST /api/v1/orders + Note over C,Ctrl: items: [{productId: 1, qty: 10}, {productId: 2, qty: 5}] + + Ctrl->>+F: createOrder(userId, items) + F->>+OS: createOrder(userId, items) + + Note over OS: @Transactional ์‹œ์ž‘ + + OS->>+PS: getProductForOrder(productId: 1) + PS->>+PR: findByIdWithLock(productId: 1) + PR->>+DB: SELECT ... FOR UPDATE + DB-->>-PR: Product (stock: 10) + PR-->>-PS: Product + PS-->>-OS: Product + OS->>OS: ์žฌ๊ณ  ๊ฒ€์ฆ ํ†ต๊ณผ (10 >= 10) + OS->>PS: decreaseStock(1, 10) + + OS->>+PS: getProductForOrder(productId: 2) + PS->>+PR: findByIdWithLock(productId: 2) + PR->>+DB: SELECT ... FOR UPDATE + DB-->>-PR: Product (stock: 3) + PR-->>-PS: Product + PS-->>-OS: Product + + OS->>OS: ์žฌ๊ณ  ๊ฒ€์ฆ ์‹คํŒจ (3 < 5) + + OS-->>-F: throw CoreException(INSUFFICIENT_STOCK) + Note over OS,DB: ์ „์ฒด ๋กค๋ฐฑ (์ƒํ’ˆ1 ์žฌ๊ณ  ๋ณต๊ตฌ) + + F-->>-Ctrl: throw CoreException + Ctrl-->>-C: 400 Bad Request + INSUFFICIENT_STOCK +``` + +**ํ•ต์‹ฌ ํฌ์ธํŠธ:** +- ๋‘ ๋ฒˆ์งธ ์ƒํ’ˆ ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ ์ฒซ ๋ฒˆ์งธ ์ƒํ’ˆ ์žฌ๊ณ  ์ฐจ๊ฐ๋„ ๋กค๋ฐฑ +- ํŠธ๋žœ์žญ์…˜ ๋‹จ์œ„๋กœ ์›์ž์„ฑ ๋ณด์žฅ + +--- + +## 2. ์ข‹์•„์š” ๋“ฑ๋ก ์‹œํ€€์Šค (ํ† ๊ธ€ ๋ฐฉ์‹) + +### 2.1 ์‹ ๊ทœ ์ข‹์•„์š” ๋“ฑ๋ก + +**๋ชฉ์ **: ์ƒํ’ˆ ์œ ํšจ์„ฑ ๊ฒ€์ฆ ๋ฐ ์ข‹์•„์š” ๋“ฑ๋ก ํ™•์ธ + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant Ctrl as ProductLikeV1Controller + participant F as ProductLikeFacade + participant LS as ProductLikeService + participant PS as ProductService + participant LR as ProductLikeRepository + participant DB as Database + + C->>+Ctrl: POST /api/v1/products/{productId}/likes + Note over C,Ctrl: Headers: X-Loopers-LoginId, X-Loopers-LoginPw + + Ctrl->>+F: like(userId, productId) + + F->>+LS: like(userId, productId) + + LS->>+PS: getProduct(productId) + PS-->>-LS: Product + + LS->>LS: ์ƒํ’ˆ ์‚ญ์ œ ์—ฌ๋ถ€ ๊ฒ€์ฆ + + LS->>+LR: findByUserIdAndProductId(userId, productId) + LR->>+DB: SELECT FROM product_likes + DB-->>-LR: null (๋ฏธ์กด์žฌ) + LR-->>-LS: Optional (empty) + + LS->>LS: ProductLike ์—”ํ‹ฐํ‹ฐ ์ƒ์„ฑ (์‹ ๊ทœ ๋“ฑ๋ก) + + LS->>+LR: save(productLike) + LR->>+DB: INSERT product_likes + DB-->>-LR: ProductLike + LR-->>-LS: ProductLike + + LS-->>-F: ProductLike + F-->>-Ctrl: void + + Ctrl-->>-C: 200 OK + { message: "์ข‹์•„์š”๊ฐ€ ๋“ฑ๋ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค." } +``` +**ํ•ต์‹ฌ ํฌ์ธํŠธ:** + +- *์ข‹์•„์š” ๊ธฐ๋Šฅ ์„ค๊ณ„ ์‹œ ์•„๋ž˜์™€ ๊ฐ™์€ ๋‘ ๊ฐ€์ง€ ์„ ํƒ์ง€๊ฐ€ ์žˆ์—ˆ๋‹ค. ์ •๋ ฌ ์ฟผ๋ฆฌ๋ฅผ ์œ„ํ•ด Product ๋‚ด๋ถ€์— ์ข‹์•„์š” ํ•„๋“œ๋ฅผ ๋‘๋Š” ๋ฐฉ๋ฒ•๊ณผ ์ข‹์•„์š” ํ…Œ์ด๋ธ”์„ ๋”ฐ๋กœ ๋‘๋Š” ์„ ํƒ์ง€ ์ค‘ ์ •ํ•ฉ์„ฑ์„ ๋†’์ด๋Š” ๋ฐฉ์‹์„ ์„ ํƒํ–ˆ๋‹ค.* + +*1. ๋น„์ •๊ทœํ™”: Product์— likeCount ํ•„๋“œ๋ฅผ ๋‘๊ณ  ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ ์‹œ ๋™๊ธฐ ์—…๋ฐ์ดํŠธ. ์ •๋ ฌ ์ฟผ๋ฆฌ ์„ฑ๋Šฅ ์šฐ์ˆ˜* + +*2. ์‹ค์‹œ๊ฐ„ ์ง‘๊ณ„(์ ์šฉ): ์ข‹์•„์š” ํ…Œ์ด๋ธ”์—์„œ COUNT ์ง‘๊ณ„. ์ •ํ•ฉ์„ฑ ๋†’์œผ๋‚˜ ์ •๋ ฌ ์‹œ ์ฟผ๋ฆฌ ๋น„์šฉ ์ฆ๊ฐ€* + + + + +- ์ƒํ’ˆ ์กด์žฌ ๋ฐ ์‚ญ์ œ ์—ฌ๋ถ€ ๋จผ์ € ๊ฒ€์ฆ +- ๊ธฐ์กด ์ข‹์•„์š”๊ฐ€ ์—†์œผ๋ฉด ์‹ ๊ทœ ๋“ฑ๋ก + +--- + +### 2.2 ๊ธฐ์กด ์ข‹์•„์š” ์กด์žฌ ์‹œ (ํ† ๊ธ€ - ์ทจ์†Œ ์ฒ˜๋ฆฌ) + +**๋ชฉ์ **: ํ† ๊ธ€ ๋ฐฉ์‹ ๋™์ž‘ ํ™•์ธ - ์ด๋ฏธ ์ข‹์•„์š”๊ฐ€ ์žˆ์œผ๋ฉด ์ทจ์†Œ + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant Ctrl as ProductLikeV1Controller + participant F as ProductLikeFacade + participant LS as ProductLikeService + participant PS as ProductService + participant LR as ProductLikeRepository + participant DB as Database + + C->>+Ctrl: POST /api/v1/products/{productId}/likes + + Ctrl->>+F: like(userId, productId) + F->>+LS: like(userId, productId) + + LS->>+PS: getProduct(productId) + PS-->>-LS: Product + + LS->>+LR: findByUserIdAndProductId(userId, productId) + LR->>+DB: SELECT FROM product_likes + DB-->>-LR: ProductLike (์กด์žฌ) + LR-->>-LS: Optional (present) + + Note over LS: ์ด๋ฏธ ์กด์žฌํ•˜๋ฏ€๋กœ ์ข‹์•„์š” ์ทจ์†Œ (ํ† ๊ธ€) + + LS->>+LR: delete(productLike) + LR->>+DB: DELETE FROM product_likes + DB-->>-LR: OK + LR-->>-LS: void + + LS-->>-F: void + F-->>-Ctrl: void + + Ctrl-->>-C: 200 OK + { message: "์ข‹์•„์š”๊ฐ€ ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." } +``` + +**ํ•ต์‹ฌ ํฌ์ธํŠธ:** +- ์ข‹์•„์š”๊ฐ€ ์ด๋ฏธ ์กด์žฌํ•˜๋ฉด ์‚ญ์ œ (ํ† ๊ธ€ ๋ฐฉ์‹) +- POST ์š”์ฒญ ํ•œ ๋ฒˆ์œผ๋กœ ๋“ฑ๋ก/์ทจ์†Œ ๋ชจ๋‘ ์ฒ˜๋ฆฌ + +--- + +## 3. ๋ธŒ๋žœ๋“œ ์‚ญ์ œ ์‹œํ€€์Šค (Cascade ์‚ญ์ œ) + +**๋ชฉ์ **: Cascade ์‚ญ์ œ ์ˆœ์„œ, ํŠธ๋žœ์žญ์…˜ ๊ฒฝ๊ณ„ ํ™•์ธ + +```mermaid +sequenceDiagram + autonumber + participant C as Admin Client + participant Int as AdminAuthInterceptor + participant Ctrl as BrandAdminV1Controller + participant F as BrandFacade + participant BS as BrandService + participant PS as ProductService + participant LS as ProductLikeService + participant BR as BrandRepository + participant PR as ProductRepository + participant LR as ProductLikeRepository + participant DB as Database + + C->>+Int: DELETE /api-admin/v1/brands/{brandId} + Note over C,Int: Headers: X-Loopers-Ldap: loopers.admin + + Int->>Int: Admin ๊ถŒํ•œ ๊ฒ€์ฆ + Int->>+Ctrl: ์š”์ฒญ ์ „๋‹ฌ + + Ctrl->>+F: deleteBrand(brandId) + F->>+BS: deleteBrand(brandId) + + Note over BS: @Transactional ์‹œ์ž‘ + + BS->>+BR: findById(brandId) + BR->>+DB: SELECT FROM brands + DB-->>-BR: Brand + BR-->>-BS: Brand + + alt ๋ธŒ๋žœ๋“œ ์—†์Œ + BS-->>F: throw CoreException(BRAND_NOT_FOUND) + end + + BS->>+PS: getProductsByBrandId(brandId) + PS->>+PR: findAllByBrandId(brandId) + PR->>+DB: SELECT FROM products WHERE brand_id = ? + DB-->>-PR: List + PR-->>-PS: List + PS-->>-BS: List + + loop ๊ฐ ์ƒํ’ˆ์— ๋Œ€ํ•ด + BS->>+LS: deleteAllByProductId(productId) + LS->>+LR: deleteAllByProductId(productId) + LR->>+DB: DELETE FROM product_likes WHERE product_id = ? + DB-->>-LR: OK + LR-->>-LS: void + LS-->>-BS: void + + BS->>+PS: deleteProduct(productId) + PS->>PS: product.delete() + PS->>+PR: save(product) + PR->>+DB: UPDATE products SET deleted_at = NOW() + DB-->>-PR: OK + PR-->>-PS: Product + PS-->>-BS: void + end + + BS->>BS: brand.delete() + BS->>+BR: save(brand) + BR->>+DB: UPDATE brands SET deleted_at = NOW() + DB-->>-BR: OK + BR-->>-BS: Brand + + Note over BS: @Transactional ์ปค๋ฐ‹ + + BS-->>-F: void + F-->>-Ctrl: void + + Ctrl-->>-C: 200 OK + { message: "๋ธŒ๋žœ๋“œ๊ฐ€ ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." } +``` + +**ํ•ต์‹ฌ ํฌ์ธํŠธ:** +- ์‚ญ์ œ ์ˆœ์„œ: ์ข‹์•„์š”(Hard) โ†’ ์ƒํ’ˆ(Soft) โ†’ ๋ธŒ๋žœ๋“œ(Soft) +- ๋‹จ์ผ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์›์ž์„ฑ ๋ณด์žฅ +- ์ข‹์•„์š”๋Š” Hard Delete, ์ƒํ’ˆ/๋ธŒ๋žœ๋“œ๋Š” Soft Delete + +--- + +## 4. ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ ์‹œํ€€์Šค (์ข‹์•„์š” ์ˆ˜ ํฌํ•จ) + +**๋ชฉ์ **: ์ข‹์•„์š” ์‹ค์‹œ๊ฐ„ ์ง‘๊ณ„, ์ •๋ ฌ ์˜ต์…˜ ์ฒ˜๋ฆฌ ํ™•์ธ + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant Ctrl as ProductV1Controller + participant F as ProductFacade + participant PS as ProductService + participant LS as ProductLikeService + participant PR as ProductRepository + participant LR as ProductLikeRepository + participant DB as Database + + C->>+Ctrl: GET /api/v1/products?sort=like_desc&brandId=1&page=0&size=20 + Note over C,Ctrl: Headers: X-Loopers-LoginId, X-Loopers-LoginPw + + Ctrl->>+F: getProducts(sort, brandId, pageable) + + F->>+PS: getProducts(sort, brandId, pageable) + + alt sort = like_desc (์ข‹์•„์š” ๋งŽ์€์ˆœ) + PS->>+PR: findAllOrderByLikeCountDesc(brandId, pageable) + PR->>+DB: SELECT p.*, COUNT(pl.id) as like_count
FROM products p
LEFT JOIN product_likes pl
GROUP BY p.id
ORDER BY like_count DESC + DB-->>-PR: Page + PR-->>-PS: Page + else sort = latest | price_asc + PS->>+PR: findAll(brandId, pageable, sort) + PR->>+DB: SELECT FROM products WHERE ... + DB-->>-PR: Page + PR-->>-PS: Page + + PS->>+LS: getLikeCounts(productIds) + LS->>+LR: countByProductIdIn(productIds) + LR->>+DB: SELECT product_id, COUNT(*)
FROM product_likes
WHERE product_id IN (...)
GROUP BY product_id + DB-->>-LR: Map + LR-->>-LS: Map + LS-->>-PS: Map + end + + PS-->>-F: Page + + F->>F: List.from(products) + F-->>-Ctrl: Page + + Ctrl->>Ctrl: ProductV1Dto.ListResponse.from(page) + Ctrl-->>-C: 200 OK + { products: [...], pageInfo: {...} } +``` + +**ํ•ต์‹ฌ ํฌ์ธํŠธ:** +- `like_desc` ์ •๋ ฌ ์‹œ JOIN + COUNT๋กœ ํ•œ ๋ฒˆ์— ์กฐํšŒ +- ๋‹ค๋ฅธ ์ •๋ ฌ ์‹œ ์ƒํ’ˆ ์กฐํšŒ ํ›„ ์ข‹์•„์š” ์ˆ˜ ๋ณ„๋„ ์กฐํšŒ (N+1 ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด IN ์ฟผ๋ฆฌ ์‚ฌ์šฉ) +- *์ฟ ํŒก๊ณผ ์˜ค๋Š˜์˜ ์ง‘์—์„œ ํ•˜๋“ฏ์ด, ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ ์‹œ ๊ธฐ๊ฐ„(startAt, endAt)์œผ๋กœ ์กฐํšŒํ•˜๋Š” ๋ฐฉ์•ˆ์„ ๊ฒ€ํ† ํ•ด ๋ณด์•˜์œผ๋‚˜ ์„ค๊ณ„์— ์ง‘์ค‘ํ•˜๊ธฐ ์œ„ํ•ด ๋„ฃ์ง€ ์•Š์•˜๋‹ค.* +--- + +## 5. ์–ด๋“œ๋ฏผ ์ธ์ฆ ํ๋ฆ„ + +**๋ชฉ์ **: Interceptor + ArgumentResolver ์กฐํ•ฉ ํ™•์ธ + +```mermaid +sequenceDiagram + autonumber + participant C as Admin Client + participant F as Filter Chain + participant Int as AdminAuthInterceptor + participant AR as AdminUserArgumentResolver + participant Ctrl as AdminController + + C->>+F: Request to /api-admin/v1/** + Note over C,F: Headers: X-Loopers-Ldap: loopers.admin + + F->>+Int: preHandle() + + Int->>Int: Extract X-Loopers-Ldap header + + alt Header missing + Int-->>C: 401 Unauthorized
ADMIN_UNAUTHORIZED + else Header != "loopers.admin" + Int-->>C: 401 Unauthorized
ADMIN_UNAUTHORIZED + else Header = "loopers.admin" + Int-->>-F: true (continue) + end + + F->>+AR: resolveArgument() + Note over AR: AdminUser ํŒŒ๋ผ๋ฏธํ„ฐ ์กด์žฌ ์‹œ + AR->>AR: Create AdminUser object + AR-->>-F: AdminUser + + F->>+Ctrl: Controller method + Ctrl-->>-F: Response + F-->>-C: Response +``` + +**ํ•ต์‹ฌ ํฌ์ธํŠธ:** +- Interceptor๊ฐ€ 1์ฐจ ๋ฐฉ์–ด์„  (ํ—ค๋” ๋ˆ„๋ฝ/๋ถˆ์ผ์น˜ ์‹œ 401) +- ArgumentResolver๋Š” ์ปจํŠธ๋กค๋Ÿฌ์— AdminUser ๊ฐ์ฒด ์ฃผ์ž… +- ์ด์ค‘ ์•ˆ์ „์žฅ์น˜๋กœ ๋ณด์•ˆ์„ฑ ๊ฐ•ํ™” +- *Interceptor ๋ฐฉ์‹์€ ์ปจํŠธ๋กค๋Ÿฌ์—์„œ ์–ด๋“œ๋ฏผ ์ •๋ณด ์ ‘๊ทผ ์‹œ Request์—์„œ ๋‹ค์‹œ ์ถ”์ถœ ํ•„์š”ํ•˜๊ณ  ํŠน์ • ๋ฉ”์„œ๋“œ๋งŒ ์˜ˆ์™ธ ์ฒ˜๋ฆฌํ•˜๋ ค๋ฉด ์ถ”๊ฐ€ ๋กœ์ง ํ•„์š”ํ•œ ๋ฌธ์ œ* +- *ArgumentResolver๋Š” ๋ชจ๋“  ๋ฉ”์„œ๋“œ์— @AdminAuth ํŒŒ๋ผ๋ฏธํ„ฐ ์ถ”๊ฐ€ ํ•„์š”ํ•˜๊ณ , ์‹ค์ˆ˜๋กœ ์–ด๋…ธํ…Œ์ด์…˜์„ ๋ˆ„๋ฝํ•˜๋ฉด ๋ณด์•ˆ ์œ„ํ—˜ํ•œ ๋ฌธ์ œ* + +*-> Interceptor + ArgumentResolver ์กฐํ•ฉ์œผ๋กœ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ–ˆ๋‹ค.* \ No newline at end of file diff --git a/.docs/design/03-class-diagram.md b/.docs/design/03-class-diagram.md new file mode 100644 index 000000000..09f1c4596 --- /dev/null +++ b/.docs/design/03-class-diagram.md @@ -0,0 +1,700 @@ +# ํด๋ž˜์Šค ๋‹ค์ด์–ด๊ทธ๋žจ + +## ๋‹ค์ด์–ด๊ทธ๋žจ ๋ชฉ์  +ํด๋ž˜์Šค ๋‹ค์ด์–ด๊ทธ๋žจ์„ ํ†ตํ•ด ๋‹ค์Œ์„ ๊ฒ€์ฆํ•œ๋‹ค: +- ๋„๋ฉ”์ธ ์ฑ…์ž„: ๊ฐ ๋„๋ฉ”์ธ์˜ ์—ญํ• ์ด ๋ช…ํ™•ํ•œ๊ฐ€ +- ์˜์กด ๋ฐฉํ–ฅ: ์ƒ์œ„ ๊ณ„์ธต์ด ํ•˜์œ„ ๊ณ„์ธต์—๋งŒ ์˜์กดํ•˜๋Š”๊ฐ€ +- ์‘์ง‘๋„: ๊ด€๋ จ ๊ธฐ๋Šฅ์ด ์ ์ ˆํžˆ ๊ทธ๋ฃนํ™”๋˜์–ด ์žˆ๋Š”๊ฐ€ + +--- + +## 1. ์ „์ฒด ๊ณ„์ธต ๊ตฌ์กฐ ๊ฐœ์š” + +```mermaid +classDiagram + direction TB + + namespace Interfaces { + class Controller + class ApiSpec + class Dto + class ArgumentResolver + class Interceptor + } + + namespace Application { + class Facade + class Info + } + + namespace Domain { + class Entity + class Service + class Repository + } + + namespace Infrastructure { + class RepositoryImpl + class JpaRepository + } + + Controller --> Facade : uses + Controller --> Dto : uses + Facade --> Service : uses + Facade --> Info : returns + Service --> Repository : uses + Service --> Entity : uses + RepositoryImpl ..|> Repository : implements + RepositoryImpl --> JpaRepository : uses +``` + +**๊ณ„์ธต๋ณ„ ์ฑ…์ž„:** +- **Interfaces**: HTTP ์š”์ฒญ/์‘๋‹ต ์ฒ˜๋ฆฌ, DTO ๋ณ€ํ™˜, ์ธ์ฆ ์ฒ˜๋ฆฌ +- **Application**: ์œ ์Šค์ผ€์ด์Šค ์กฐ์œจ, ๋„๋ฉ”์ธ โ†” ํ”„๋ ˆ์  ํ…Œ์ด์…˜ ๋ณ€ํ™˜ +- **Domain**: ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง, ์—”ํ‹ฐํ‹ฐ ๊ฒ€์ฆ, ๋„๋ฉ”์ธ ๊ทœ์น™ +- **Infrastructure**: ๋ฐ์ดํ„ฐ ์ ‘๊ทผ, ์™ธ๋ถ€ ์‹œ์Šคํ…œ ์—ฐ๋™ + +--- + +## 2. Brand ๋„๋ฉ”์ธ ํด๋ž˜์Šค + +```mermaid +classDiagram + direction TB + + %% Interfaces Layer + class BrandV1Controller { + -BrandFacade brandFacade + +getBrand(Long brandId) ApiResponse~BrandDto.Response~ + } + + class BrandAdminV1Controller { + -BrandFacade brandFacade + +getBrands(Pageable) ApiResponse~Page~ + +getBrand(Long brandId) ApiResponse~BrandDto.Response~ + +createBrand(CreateRequest) ApiResponse~CreateResponse~ + +updateBrand(Long, UpdateRequest) ApiResponse~Response~ + +deleteBrand(Long brandId) ApiResponse~Void~ + } + + class BrandV1Dto { + <> + } + class Response { + <> + +Long id + +String name + +String description + +String logoUrl + +from(BrandInfo) Response + } + class CreateRequest { + <> + +String name + +String description + +String logoUrl + } + class CreateResponse { + <> + +Long brandId + } + class UpdateRequest { + <> + +String name + +String description + +String logoUrl + } + + %% Application Layer + class BrandFacade { + -BrandService brandService + +getBrand(Long brandId) BrandInfo + +getBrands(Pageable) Page~BrandInfo~ + +createBrand(String, String, String) BrandInfo + +updateBrand(Long, String, String, String) BrandInfo + +deleteBrand(Long brandId) void + } + + class BrandInfo { + <> + +Long id + +String name + +String description + +String logoUrl + +from(Brand) BrandInfo + } + + %% Domain Layer + class Brand { + -String name + -String description + -String logoUrl + +Brand(String, String, String) + +update(String, String, String) void + #guard() void + } + + class BrandService { + -BrandRepository brandRepository + +getBrand(Long brandId) Brand + +getBrands(Pageable) Page~Brand~ + +createBrand(String, String, String) Brand + +updateBrand(Long, String, String, String) Brand + +deleteBrand(Long brandId) void + } + + class BrandRepository { + <> + +findById(Long) Optional~Brand~ + +findAll(Pageable) Page~Brand~ + +save(Brand) Brand + +existsByName(String) boolean + } + + %% Infrastructure Layer + class BrandRepositoryImpl { + -BrandJpaRepository brandJpaRepository + } + + class BrandJpaRepository { + <> + +findByName(String) Optional~Brand~ + +existsByNameAndDeletedAtIsNull(String) boolean + } + + %% Relationships + BrandV1Controller --> BrandFacade + BrandAdminV1Controller --> BrandFacade + BrandFacade --> BrandService + BrandFacade --> BrandInfo + BrandService --> BrandRepository + BrandService --> Brand + BrandRepositoryImpl ..|> BrandRepository + BrandRepositoryImpl --> BrandJpaRepository + Brand --|> BaseEntity + + BrandV1Dto ..> Response + BrandV1Dto ..> CreateRequest + BrandV1Dto ..> CreateResponse + BrandV1Dto ..> UpdateRequest +``` + +--- + +## 3. Product ๋„๋ฉ”์ธ ํด๋ž˜์Šค + +```mermaid +classDiagram + direction TB + + %% Interfaces Layer + class ProductV1Controller { + -ProductFacade productFacade + +getProducts(String sort, Long brandId, Pageable) ApiResponse~Page~ + +getProduct(Long productId) ApiResponse~DetailResponse~ + } + + class ProductAdminV1Controller { + -ProductFacade productFacade + +getProducts(Pageable, Long brandId) ApiResponse~Page~ + +getProduct(Long productId) ApiResponse~AdminDetailResponse~ + +createProduct(CreateRequest) ApiResponse~CreateResponse~ + +updateProduct(Long, UpdateRequest) ApiResponse~Response~ + +deleteProduct(Long productId) ApiResponse~Void~ + } + + %% Application Layer + class ProductFacade { + -ProductService productService + -ProductLikeService productLikeService + -BrandService brandService + +getProducts(String, Long, Pageable) Page~ProductInfo~ + +getProduct(Long productId) ProductInfo + +createProduct(Long, String, String, Long, Integer, String) ProductInfo + +updateProduct(Long, String, String, Long, Integer, String) ProductInfo + +deleteProduct(Long productId) void + } + + class ProductInfo { + <> + +Long id + +Long brandId + +String brandName + +String name + +String description + +Long price + +Integer stock + +String imageUrl + +Long likeCount + +from(Product, Long) ProductInfo + } + + %% Domain Layer + class Product { + -Long brandId + -String name + -String description + -Long price + -Integer stock + -String imageUrl + +Product(Long, String, String, Long, Integer, String) + +update(String, String, Long, Integer, String) void + +decreaseStock(int quantity) void + +increaseStock(int quantity) void + #guard() void + } + + class ProductService { + -ProductRepository productRepository + -BrandRepository brandRepository + +getProduct(Long productId) Product + +getProductForOrder(Long productId) Product + +getProducts(String, Long, Pageable) Page~Product~ + +getProductsByBrandId(Long brandId) List~Product~ + +createProduct(...) Product + +updateProduct(...) Product + +deleteProduct(Long productId) void + +decreaseStock(Long productId, int quantity) void + } + + class ProductRepository { + <> + +findById(Long) Optional~Product~ + +findByIdWithLock(Long) Optional~Product~ + +findAll(String, Long, Pageable) Page~Product~ + +findAllByBrandId(Long) List~Product~ + +findAllOrderByLikeCountDesc(Long, Pageable) Page~Object[]~ + +save(Product) Product + } + + %% Infrastructure Layer + class ProductRepositoryImpl { + -ProductJpaRepository productJpaRepository + -JPAQueryFactory queryFactory + } + + class ProductJpaRepository { + <> + } + + %% Relationships + ProductV1Controller --> ProductFacade + ProductAdminV1Controller --> ProductFacade + ProductFacade --> ProductService + ProductFacade --> ProductInfo + ProductService --> ProductRepository + ProductService --> Product + ProductRepositoryImpl ..|> ProductRepository + ProductRepositoryImpl --> ProductJpaRepository + Product --|> BaseEntity +``` + +--- + +## 4. ProductLike ๋„๋ฉ”์ธ ํด๋ž˜์Šค + +```mermaid +classDiagram + direction TB + + %% Interfaces Layer + class ProductLikeV1Controller { + -ProductLikeFacade productLikeFacade + +like(AuthenticatedUser, Long productId) ApiResponse~Void~ + +unlike(AuthenticatedUser, Long productId) ApiResponse~Void~ + +getMyLikes(AuthenticatedUser, Long userId) ApiResponse~List~ + } + + %% Application Layer + class ProductLikeFacade { + -ProductLikeService productLikeService + -ProductService productService + -UserService userService + +like(Long userId, Long productId) void + +unlike(Long userId, Long productId) void + +getMyLikes(Long userId) List~ProductLikeInfo~ + } + + class ProductLikeInfo { + <> + +Long productId + +String productName + +Long price + +String imageUrl + +ZonedDateTime likedAt + } + + %% Domain Layer + class ProductLike { + -Long userId + -Long productId + +ProductLike(Long userId, Long productId) + +getUserId() Long + +getProductId() Long + } + + class ProductLikeService { + -ProductLikeRepository productLikeRepository + +like(Long userId, Long productId) ProductLike + +unlike(Long userId, Long productId) void + +existsByUserIdAndProductId(Long, Long) boolean + +countByProductId(Long productId) Long + +getLikeCounts(List~Long~ productIds) Map~Long_Long~ + +getByUserId(Long userId) List~ProductLike~ + +deleteAllByProductId(Long productId) void + } + + class ProductLikeRepository { + <> + +findByUserIdAndProductId(Long, Long) Optional~ProductLike~ + +existsByUserIdAndProductId(Long, Long) boolean + +countByProductId(Long) Long + +countByProductIdIn(List~Long~) List~Object[]~ + +findAllByUserId(Long) List~ProductLike~ + +deleteByUserIdAndProductId(Long, Long) void + +deleteAllByProductId(Long) void + +save(ProductLike) ProductLike + } + + %% Infrastructure Layer + class ProductLikeRepositoryImpl { + -ProductLikeJpaRepository productLikeJpaRepository + } + + class ProductLikeJpaRepository { + <> + } + + %% Relationships + ProductLikeV1Controller --> ProductLikeFacade + ProductLikeFacade --> ProductLikeService + ProductLikeFacade --> ProductLikeInfo + ProductLikeService --> ProductLikeRepository + ProductLikeService --> ProductLike + ProductLikeRepositoryImpl ..|> ProductLikeRepository + ProductLikeRepositoryImpl --> ProductLikeJpaRepository + ProductLike --|> BaseEntity +``` + +--- + +## 5. Order ๋„๋ฉ”์ธ ํด๋ž˜์Šค + +```mermaid +classDiagram + direction TB + + %% Interfaces Layer + class OrderV1Controller { + -OrderFacade orderFacade + +createOrder(AuthenticatedUser, CreateRequest) ApiResponse~CreateResponse~ + +getOrders(AuthenticatedUser, LocalDate, LocalDate, Pageable) ApiResponse~Page~ + +getOrder(AuthenticatedUser, Long orderId) ApiResponse~DetailResponse~ + } + + class OrderV1Dto { + <> + } + + class CreateRequest { + <> + +List~OrderItemRequest~ items + } + + class OrderItemRequest { + <> + +Long productId + +Integer quantity + } + + class CreateResponse { + <> + +Long orderId + +Long totalPrice + } + + class ListResponse { + <> + +Long orderId + +Long totalPrice + +String status + +ZonedDateTime createdAt + } + + class DetailResponse { + <> + +Long orderId + +Long totalPrice + +String status + +List~OrderItemResponse~ items + +ZonedDateTime createdAt + } + + class OrderItemResponse { + <> + +Long productId + +String productName + +Integer quantity + +Long price + } + + %% Application Layer + class OrderFacade { + -OrderService orderService + -ProductService productService + -UserService userService + +createOrder(Long userId, List~OrderItemRequest~) OrderInfo + +getOrders(Long userId, LocalDate, LocalDate, Pageable) Page~OrderInfo~ + +getOrder(Long userId, Long orderId) OrderInfo + } + + class OrderInfo { + <> + +Long id + +Long userId + +Long totalPrice + +OrderStatus status + +List~OrderItemInfo~ items + +ZonedDateTime createdAt + +from(Order) OrderInfo + } + + class OrderItemInfo { + <> + +Long productId + +String productName + +Integer quantity + +Long price + +from(OrderItem) OrderItemInfo + } + + %% Domain Layer + class Order { + -Long userId + -Long totalPrice + -OrderStatus status + -List~OrderItem~ orderItems + +Order(Long userId) + +addItem(OrderItem item) void + +calculateTotalPrice() void + +complete() void + +cancel() void + } + + class OrderItem { + -Order order + -Long productId + -String productName + -Integer quantity + -Long price + +OrderItem(Long, String, Integer, Long) + +setOrder(Order order) void + +getSubtotal() Long + } + + class OrderStatus { + <> + PENDING + COMPLETED + CANCELLED + } + + class OrderService { + -OrderRepository orderRepository + -ProductService productService + +createOrder(Long userId, List~OrderItemRequest~) Order + +getOrder(Long orderId) Order + +getOrders(Long userId, LocalDate, LocalDate, Pageable) Page~Order~ + +validateOrderAccess(Long userId, Order order) void + } + + class OrderRepository { + <> + +findById(Long) Optional~Order~ + +findByUserId(Long, Pageable) Page~Order~ + +findByUserIdAndCreatedAtBetween(...) Page~Order~ + +save(Order) Order + } + + %% Infrastructure Layer + class OrderRepositoryImpl { + -OrderJpaRepository orderJpaRepository + } + + class OrderJpaRepository { + <> + } + + %% Relationships + OrderV1Controller --> OrderFacade + OrderFacade --> OrderService + OrderFacade --> OrderInfo + OrderService --> OrderRepository + OrderService --> Order + Order --> OrderItem + Order --> OrderStatus + OrderRepositoryImpl ..|> OrderRepository + OrderRepositoryImpl --> OrderJpaRepository + Order --|> BaseEntity + OrderItem --|> BaseEntity + + OrderV1Dto ..> CreateRequest + OrderV1Dto ..> OrderItemRequest + OrderV1Dto ..> CreateResponse + OrderV1Dto ..> ListResponse + OrderV1Dto ..> DetailResponse + OrderV1Dto ..> OrderItemResponse +``` + +--- + +## 6. ์ธ์ฆ ๊ด€๋ จ ํด๋ž˜์Šค + +```mermaid +classDiagram + direction TB + + %% ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž ์ธ์ฆ (๊ธฐ์กด) + class AuthenticatedUser { + <> + +String loginId + +String password + } + + class AuthenticatedUserArgumentResolver { + -UserService userService + +supportsParameter(MethodParameter) boolean + +resolveArgument(...) Object + } + + %% ์–ด๋“œ๋ฏผ ์ธ์ฆ (์‹ ๊ทœ) + class AdminUser { + <> + +String ldapId + } + + class AdminAuthInterceptor { + -String ADMIN_LDAP_HEADER + -String ADMIN_LDAP_VALUE + +preHandle(HttpServletRequest, HttpServletResponse, Object) boolean + } + + class AdminUserArgumentResolver { + +supportsParameter(MethodParameter) boolean + +resolveArgument(...) Object + } + + %% WebMvcConfig + class WebMvcConfig { + -AuthenticatedUserArgumentResolver authResolver + -AdminUserArgumentResolver adminResolver + -AdminAuthInterceptor adminInterceptor + +addArgumentResolvers(List) void + +addInterceptors(InterceptorRegistry) void + } + + %% Interfaces + class HandlerMethodArgumentResolver { + <> + } + + class HandlerInterceptor { + <> + } + + %% Relationships + WebMvcConfig --> AuthenticatedUserArgumentResolver + WebMvcConfig --> AdminUserArgumentResolver + WebMvcConfig --> AdminAuthInterceptor + AuthenticatedUserArgumentResolver ..|> HandlerMethodArgumentResolver + AdminUserArgumentResolver ..|> HandlerMethodArgumentResolver + AdminAuthInterceptor ..|> HandlerInterceptor +``` + +**ํ•ต์‹ฌ ํฌ์ธํŠธ:** +- **AdminAuthInterceptor**: `/api-admin/**` ๊ฒฝ๋กœ์— ๋Œ€ํ•ด ํ—ค๋” ๊ฒ€์ฆ (1์ฐจ ๋ฐฉ์–ด์„ ) +- **AdminUserArgumentResolver**: ์ปจํŠธ๋กค๋Ÿฌ์— AdminUser ๊ฐ์ฒด ์ฃผ์ž… + +--- + +## 7. ๊ณตํ†ต ํด๋ž˜์Šค + +```mermaid +classDiagram + direction TB + + class BaseEntity { + <> + #Long id + #ZonedDateTime createdAt + #ZonedDateTime updatedAt + #ZonedDateTime deletedAt + #guard() void + +delete() void + +restore() void + +isDeleted() boolean + } + + class ApiResponse~T~ { + <> + +Metadata meta + +T data + +success() ApiResponse~Object~ + +success(T data) ApiResponse~T~ + +fail(String, String) ApiResponse~Object~ + } + + class Metadata { + <> + +Result result + +String errorCode + +String message + } + + class Result { + <> + SUCCESS + FAIL + } + + class CoreException { + -ErrorType errorType + -String customMessage + +CoreException(ErrorType) + +CoreException(ErrorType, String) + +getErrorType() ErrorType + } + + class ErrorType { + <> + INTERNAL_ERROR + BAD_REQUEST + NOT_FOUND + CONFLICT + UNAUTHORIZED + USER_NOT_FOUND + PASSWORD_MISMATCH + BRAND_NOT_FOUND + BRAND_ALREADY_EXISTS + BRAND_DELETED + PRODUCT_NOT_FOUND + PRODUCT_DELETED + INSUFFICIENT_STOCK + ORDER_NOT_FOUND + ORDER_ACCESS_DENIED + ADMIN_UNAUTHORIZED + BRAND_CHANGE_NOT_ALLOWED + -HttpStatus status + -String code + -String message + } + + ApiResponse --> Metadata + Metadata --> Result + CoreException --> ErrorType +``` + +**ํ•ต์‹ฌ ํฌ์ธํŠธ:** +- **BaseEntity**: ๋ชจ๋“  ์—”ํ‹ฐํ‹ฐ์˜ ๊ณตํ†ต ํ•„๋“œ (id, timestamps, soft delete) +- **ApiResponse**: ํ†ต์ผ๋œ API ์‘๋‹ต ํ˜•์‹ +- **ErrorType**: ๋„๋ฉ”์ธ๋ณ„ ์—๋Ÿฌ ์ฝ”๋“œ ์ •์˜ diff --git a/.docs/design/04-erd.md b/.docs/design/04-erd.md new file mode 100644 index 000000000..50851256c --- /dev/null +++ b/.docs/design/04-erd.md @@ -0,0 +1,419 @@ +# ERD (Entity Relationship Diagram) + +## ๋‹ค์ด์–ด๊ทธ๋žจ ๋ชฉ์  +ERD๋ฅผ ํ†ตํ•ด ๋‹ค์Œ์„ ๊ฒ€์ฆํ•œ๋‹ค: +- ์˜์†์„ฑ ๊ตฌ์กฐ: ๋ฐ์ดํ„ฐ๊ฐ€ ์–ด๋–ป๊ฒŒ ์ €์žฅ๋˜๋Š”๊ฐ€ +- ๊ด€๊ณ„์˜ ์ฃผ์ธ: FK๊ฐ€ ์–ด๋””์— ์œ„์น˜ํ•˜๋Š”๊ฐ€ +- ์ •๊ทœํ™” ์—ฌ๋ถ€: ๋ฐ์ดํ„ฐ ์ค‘๋ณต์ด ์ตœ์†Œํ™”๋˜์—ˆ๋Š”๊ฐ€ +- ์ •ํ•ฉ์„ฑ: ์ œ์•ฝ์กฐ๊ฑด์ด ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™์„ ๋ฐ˜์˜ํ•˜๋Š”๊ฐ€ + +--- + +## 1. ์ „์ฒด ERD + +```mermaid +erDiagram + users ||--o{ orders : "places" + users ||--o{ product_likes : "likes" + brands ||--o{ products : "has" + products ||--o{ product_likes : "has" + products ||--o{ order_items : "ordered_in" + orders ||--|{ order_items : "contains" + + users { + bigint id PK "AUTO_INCREMENT" + varchar_30 login_id UK "NOT NULL" + varchar_255 password "NOT NULL, BCrypt" + varchar_30 name "NOT NULL" + varchar_8 birth_date "NOT NULL, YYYYMMDD" + varchar_100 email "NOT NULL" + timestamp created_at "NOT NULL" + timestamp updated_at "NOT NULL" + timestamp deleted_at "NULL, Soft Delete" + } + + brands { + bigint id PK "AUTO_INCREMENT" + varchar_100 name UK "NOT NULL" + varchar_500 description "NULL" + varchar_500 logo_url "NULL" + timestamp created_at "NOT NULL" + timestamp updated_at "NOT NULL" + timestamp deleted_at "NULL, Soft Delete" + } + + products { + bigint id PK "AUTO_INCREMENT" + bigint brand_id FK "NOT NULL" + varchar_200 name "NOT NULL" + varchar_2000 description "NULL" + bigint price "NOT NULL, >= 0" + int stock "NOT NULL, >= 0" + varchar_500 image_url "NULL" + timestamp created_at "NOT NULL" + timestamp updated_at "NOT NULL" + timestamp deleted_at "NULL, Soft Delete" + } + + product_likes { + bigint id PK "AUTO_INCREMENT" + bigint user_id FK "NOT NULL" + bigint product_id FK "NOT NULL" + timestamp created_at "NOT NULL" + } + + orders { + bigint id PK "AUTO_INCREMENT" + bigint user_id FK "NOT NULL" + bigint total_price "NOT NULL, >= 0" + varchar_20 status "NOT NULL, ENUM" + timestamp created_at "NOT NULL" + timestamp updated_at "NOT NULL" + } + + order_items { + bigint id PK "AUTO_INCREMENT" + bigint order_id FK "NOT NULL" + bigint product_id FK "NOT NULL" + varchar_200 product_name "NOT NULL, ์Šค๋ƒ…์ƒท" + int quantity "NOT NULL, >= 1" + bigint price "NOT NULL, ์Šค๋ƒ…์ƒท" + timestamp created_at "NOT NULL" + } +``` + +--- + +## 2. ํ…Œ์ด๋ธ” ์ƒ์„ธ ์Šคํ‚ค๋งˆ + +### 2.1 users ํ…Œ์ด๋ธ” (๊ธฐ์กด) + +```sql +CREATE TABLE users ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + login_id VARCHAR(30) NOT NULL, + password VARCHAR(255) NOT NULL, + name VARCHAR(30) NOT NULL, + birth_date VARCHAR(8) NOT NULL, + email VARCHAR(100) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + CONSTRAINT uk_users_login_id UNIQUE (login_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +--- + +### 2.2 brands ํ…Œ์ด๋ธ” + +```sql +CREATE TABLE brands ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description VARCHAR(500) NULL, + logo_url VARCHAR(500) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + CONSTRAINT uk_brands_name UNIQUE (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ์ธ๋ฑ์Šค +CREATE INDEX idx_brands_deleted_at ON brands (deleted_at); +CREATE INDEX idx_brands_created_at ON brands (created_at); +``` + +**์ธ๋ฑ์Šค ์„ค๊ณ„ ์˜๋„:** +| ์ธ๋ฑ์Šค | ์šฉ๋„ | +|--------|------| +| `uk_brands_name` | ๋ธŒ๋žœ๋“œ๋ช… ์ค‘๋ณต ๋ฐฉ์ง€ | +| `idx_brands_deleted_at` | Soft Delete ํ•„ํ„ฐ๋ง ์ตœ์ ํ™” | +| `idx_brands_created_at` | ์ตœ์‹ ์ˆœ ์ •๋ ฌ ์ตœ์ ํ™” | + +--- + +### 2.3 products ํ…Œ์ด๋ธ” + +```sql +CREATE TABLE products ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + brand_id BIGINT NOT NULL, + name VARCHAR(200) NOT NULL, + description VARCHAR(2000) NULL, + price BIGINT NOT NULL, + stock INT NOT NULL DEFAULT 0, + image_url VARCHAR(500) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + CONSTRAINT fk_products_brand_id FOREIGN KEY (brand_id) + REFERENCES brands(id) ON DELETE RESTRICT, + + CONSTRAINT chk_products_price CHECK (price >= 0), + CONSTRAINT chk_products_stock CHECK (stock >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ์ธ๋ฑ์Šค +CREATE INDEX idx_products_brand_id ON products (brand_id); +CREATE INDEX idx_products_deleted_at ON products (deleted_at); +CREATE INDEX idx_products_price ON products (price); +CREATE INDEX idx_products_created_at ON products (created_at DESC); + +-- ๋ณตํ•ฉ ์ธ๋ฑ์Šค: ๋ธŒ๋žœ๋“œ๋ณ„ ์ƒํ’ˆ ์กฐํšŒ ์ตœ์ ํ™” +CREATE INDEX idx_products_brand_deleted_created + ON products (brand_id, deleted_at, created_at DESC); +``` + +**์ธ๋ฑ์Šค ์„ค๊ณ„ ์˜๋„:** +| ์ธ๋ฑ์Šค | ์šฉ๋„ | +|--------|------| +| `fk_products_brand_id` | ๋ธŒ๋žœ๋“œ-์ƒํ’ˆ ๊ด€๊ณ„ ๋ฌด๊ฒฐ์„ฑ | +| `idx_products_brand_id` | ๋ธŒ๋žœ๋“œ๋ณ„ ์ƒํ’ˆ ์กฐํšŒ | +| `idx_products_deleted_at` | Soft Delete ํ•„ํ„ฐ๋ง | +| `idx_products_price` | ๊ฐ€๊ฒฉ์ˆœ ์ •๋ ฌ ์ตœ์ ํ™” | +| `idx_products_created_at` | ์ตœ์‹ ์ˆœ ์ •๋ ฌ ์ตœ์ ํ™” | +| `idx_products_brand_deleted_created` | ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ + ์‚ญ์ œ ํ•„ํ„ฐ + ์ตœ์‹ ์ˆœ ๋ณตํ•ฉ ์ฟผ๋ฆฌ | + +--- + +### 2.4 product_likes ํ…Œ์ด๋ธ” + +```sql +CREATE TABLE product_likes ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_product_likes_user_id FOREIGN KEY (user_id) + REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_product_likes_product_id FOREIGN KEY (product_id) + REFERENCES products(id) ON DELETE CASCADE, + + CONSTRAINT uk_product_likes_user_product UNIQUE (user_id, product_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ์ธ๋ฑ์Šค +CREATE INDEX idx_product_likes_product_id ON product_likes (product_id); +CREATE INDEX idx_product_likes_user_id ON product_likes (user_id); +``` + +**์ธ๋ฑ์Šค ์„ค๊ณ„ ์˜๋„:** +| ์ธ๋ฑ์Šค | ์šฉ๋„ | +|--------|------| +| `uk_product_likes_user_product` | ์ค‘๋ณต ์ข‹์•„์š” ๋ฐฉ์ง€ + ํŠน์ • ์‚ฌ์šฉ์ž์˜ ํŠน์ • ์ƒํ’ˆ ์ข‹์•„์š” ์—ฌ๋ถ€ ์กฐํšŒ | +| `idx_product_likes_product_id` | ์ƒํ’ˆ๋ณ„ ์ข‹์•„์š” ์ˆ˜ COUNT ์ตœ์ ํ™” | +| `idx_product_likes_user_id` | ์‚ฌ์šฉ์ž๋ณ„ ์ข‹์•„์š” ๋ชฉ๋ก ์กฐํšŒ ์ตœ์ ํ™” | + +**CASCADE ์‚ญ์ œ:** +- User ์‚ญ์ œ ์‹œ ํ•ด๋‹น ์‚ฌ์šฉ์ž์˜ ์ข‹์•„์š” ์ž๋™ ์‚ญ์ œ +- Product ์‚ญ์ œ ์‹œ ํ•ด๋‹น ์ƒํ’ˆ์˜ ์ข‹์•„์š” ์ž๋™ ์‚ญ์ œ + +--- + +### 2.5 orders ํ…Œ์ด๋ธ” + +```sql +CREATE TABLE orders ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + total_price BIGINT NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'COMPLETED', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT fk_orders_user_id FOREIGN KEY (user_id) + REFERENCES users(id) ON DELETE RESTRICT, + + CONSTRAINT chk_orders_total_price CHECK (total_price >= 0), + CONSTRAINT chk_orders_status CHECK (status IN ('PENDING', 'COMPLETED', 'CANCELLED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ์ธ๋ฑ์Šค +CREATE INDEX idx_orders_user_id ON orders (user_id); +CREATE INDEX idx_orders_created_at ON orders (created_at DESC); +CREATE INDEX idx_orders_status ON orders (status); + +-- ๋ณตํ•ฉ ์ธ๋ฑ์Šค: ์‚ฌ์šฉ์ž๋ณ„ ๊ธฐ๊ฐ„ ์กฐํšŒ ์ตœ์ ํ™” +CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC); +``` + +**์ธ๋ฑ์Šค ์„ค๊ณ„ ์˜๋„:** +| ์ธ๋ฑ์Šค | ์šฉ๋„ | +|--------|------| +| `idx_orders_user_id` | ์‚ฌ์šฉ์ž๋ณ„ ์ฃผ๋ฌธ ์กฐํšŒ | +| `idx_orders_created_at` | ์ตœ์‹ ์ˆœ ์ •๋ ฌ | +| `idx_orders_status` | ์ƒํƒœ๋ณ„ ํ•„ํ„ฐ๋ง | +| `idx_orders_user_created` | ์‚ฌ์šฉ์ž์˜ ์ฃผ๋ฌธ ๊ธฐ๊ฐ„ ์กฐํšŒ ์ตœ์ ํ™” | + +--- + +### 2.6 order_items ํ…Œ์ด๋ธ” + +```sql +CREATE TABLE order_items ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + order_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + product_name VARCHAR(200) NOT NULL COMMENT '์ฃผ๋ฌธ ์‹œ์  ์ƒํ’ˆ๋ช… ์Šค๋ƒ…์ƒท', + quantity INT NOT NULL, + price BIGINT NOT NULL COMMENT '์ฃผ๋ฌธ ์‹œ์  ์ƒํ’ˆ ๊ฐ€๊ฒฉ ์Šค๋ƒ…์ƒท', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_order_items_order_id FOREIGN KEY (order_id) + REFERENCES orders(id) ON DELETE CASCADE, + CONSTRAINT fk_order_items_product_id FOREIGN KEY (product_id) + REFERENCES products(id) ON DELETE RESTRICT, + + CONSTRAINT uk_order_items_order_product UNIQUE (order_id, product_id), + CONSTRAINT chk_order_items_quantity CHECK (quantity >= 1), + CONSTRAINT chk_order_items_price CHECK (price >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ์ธ๋ฑ์Šค +CREATE INDEX idx_order_items_order_id ON order_items (order_id); +CREATE INDEX idx_order_items_product_id ON order_items (product_id); +``` + +**์ธ๋ฑ์Šค ์„ค๊ณ„ ์˜๋„:** +| ์ธ๋ฑ์Šค | ์šฉ๋„ | +|--------|------| +| `uk_order_items_order_product` | ๋™์ผ ์ฃผ๋ฌธ ๋‚ด ๋™์ผ ์ƒํ’ˆ ์ค‘๋ณต ๋ฐฉ์ง€ | +| `idx_order_items_order_id` | ์ฃผ๋ฌธ๋ณ„ ํ•ญ๋ชฉ ์กฐํšŒ | +| `idx_order_items_product_id` | ์ƒํ’ˆ๋ณ„ ์ฃผ๋ฌธ ์ด๋ ฅ ์กฐํšŒ | + +**๊ฐ€๊ฒฉ/์ƒํ’ˆ๋ช… ์Šค๋ƒ…์ƒท:** +- `price`, `product_name` ํ•„๋“œ๋Š” ์ฃผ๋ฌธ ์‹œ์ ์˜ ๊ฐ’์„ ์ €์žฅ +- ์ƒํ’ˆ ๊ฐ€๊ฒฉ/์ด๋ฆ„ ๋ณ€๊ฒฝ ์‹œ์—๋„ ๊ธฐ์กด ์ฃผ๋ฌธ์˜ ์ •๋ณด๋Š” ์œ ์ง€ + +--- + +## 3. ๊ด€๊ณ„ ์ •์˜ + +### 3.1 ๊ด€๊ณ„ ์š”์•ฝ + +| ๊ด€๊ณ„ | ํƒ€์ž… | ์„ค๋ช… | +|------|------|------| +| users : orders | 1:N | ์‚ฌ์šฉ์ž๋Š” ์—ฌ๋Ÿฌ ์ฃผ๋ฌธ ๊ฐ€๋Šฅ | +| users : product_likes | 1:N | ์‚ฌ์šฉ์ž๋Š” ์—ฌ๋Ÿฌ ์ƒํ’ˆ์— ์ข‹์•„์š” ๊ฐ€๋Šฅ | +| brands : products | 1:N | ๋ธŒ๋žœ๋“œ๋Š” ์—ฌ๋Ÿฌ ์ƒํ’ˆ ๋ณด์œ  | +| products : product_likes | 1:N | ์ƒํ’ˆ์€ ์—ฌ๋Ÿฌ ์ข‹์•„์š” ๋ณด์œ  | +| products : order_items | 1:N | ์ƒํ’ˆ์€ ์—ฌ๋Ÿฌ ์ฃผ๋ฌธ์— ํฌํ•จ ๊ฐ€๋Šฅ | +| orders : order_items | 1:N | ์ฃผ๋ฌธ์€ ์—ฌ๋Ÿฌ ์ฃผ๋ฌธ ํ•ญ๋ชฉ ํฌํ•จ | + +### 3.2 FK ์‚ญ์ œ ์ •์ฑ… + +| FK | ์ •์ฑ… | ์ด์œ  | +|----|------|------| +| products.brand_id | RESTRICT | ๋ธŒ๋žœ๋“œ ์‚ญ์ œ ์‹œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ Cascade ์ฒ˜๋ฆฌ | +| product_likes.user_id | CASCADE | ์‚ฌ์šฉ์ž ์‚ญ์ œ ์‹œ ์ข‹์•„์š” ์ž๋™ ์‚ญ์ œ | +| product_likes.product_id | CASCADE | ์ƒํ’ˆ ์‚ญ์ œ ์‹œ ์ข‹์•„์š” ์ž๋™ ์‚ญ์ œ | +| orders.user_id | RESTRICT | ์ฃผ๋ฌธ ์ด๋ ฅ ๋ณด์กด (์‚ฌ์šฉ์ž ์‚ญ์ œ ๋ถˆ๊ฐ€) | +| order_items.order_id | CASCADE | ์ฃผ๋ฌธ ์‚ญ์ œ ์‹œ ํ•ญ๋ชฉ ์ž๋™ ์‚ญ์ œ | +| order_items.product_id | RESTRICT | ์ƒํ’ˆ ์‚ญ์ œ ์‹œ์—๋„ ์ฃผ๋ฌธ ์ด๋ ฅ ๋ณด์กด | + +--- + +## 4. ์ฟผ๋ฆฌ ์ตœ์ ํ™” ๊ฐ€์ด๋“œ + +### 4.1 ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ (์ข‹์•„์š”์ˆœ ์ •๋ ฌ) + +```sql +-- ์ข‹์•„์š” ๋งŽ์€์ˆœ ์ •๋ ฌ (์„œ๋ธŒ์ฟผ๋ฆฌ ๋ฐฉ์‹) +SELECT + p.*, + COALESCE(like_counts.cnt, 0) as like_count +FROM products p +LEFT JOIN ( + SELECT product_id, COUNT(*) as cnt + FROM product_likes + GROUP BY product_id +) like_counts ON p.id = like_counts.product_id +WHERE p.deleted_at IS NULL + AND (p.brand_id = :brandId OR :brandId IS NULL) +ORDER BY like_count DESC, p.created_at DESC +LIMIT :limit OFFSET :offset; +``` + +### 4.2 ์žฌ๊ณ  ์ฐจ๊ฐ (๋น„๊ด€์  ๋ฝ) + +```sql +-- ๋น„๊ด€์  ๋ฝ์œผ๋กœ ์žฌ๊ณ  ์กฐํšŒ +SELECT * FROM products +WHERE id = :productId AND deleted_at IS NULL +FOR UPDATE; + +-- ์žฌ๊ณ  ๊ฒ€์ฆ ํ›„ ์ฐจ๊ฐ +UPDATE products +SET stock = stock - :quantity, updated_at = NOW() +WHERE id = :productId AND stock >= :quantity; +``` + +### 4.3 ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ (Fetch Join) + +```sql +SELECT o.*, oi.* +FROM orders o +JOIN order_items oi ON o.id = oi.order_id +WHERE o.id = :orderId AND o.user_id = :userId; +``` + +### 4.4 ์‚ฌ์šฉ์ž๋ณ„ ์ข‹์•„์š” ์ƒํ’ˆ ๋ชฉ๋ก + +```sql +SELECT p.*, pl.created_at as liked_at +FROM product_likes pl +JOIN products p ON pl.product_id = p.id +WHERE pl.user_id = :userId AND p.deleted_at IS NULL +ORDER BY pl.created_at DESC; +``` + +### 4.5 ์‚ฌ์šฉ์ž ์ฃผ๋ฌธ ๊ธฐ๊ฐ„ ์กฐํšŒ + +```sql +SELECT o.*, oi.* +FROM orders o +LEFT JOIN order_items oi ON o.id = oi.order_id +WHERE o.user_id = :userId + AND (:startAt IS NULL OR o.created_at >= :startAt) + AND (:endAt IS NULL OR o.created_at < :endAt + INTERVAL 1 DAY) +ORDER BY o.created_at DESC +LIMIT :limit OFFSET :offset; +``` + +--- + +## 5. ๋ฐ์ดํ„ฐ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ˆœ์„œ + +``` +V1__Create_users_table.sql (๊ธฐ์กด) +V2__Create_brands_table.sql +V3__Create_products_table.sql +V4__Create_product_likes_table.sql +V5__Create_orders_table.sql +V6__Create_order_items_table.sql +V7__Add_indexes.sql +``` + +--- + +## 6. ๋ฐ์ดํ„ฐ ์ •ํ•ฉ์„ฑ ๊ณ ๋ ค์‚ฌํ•ญ + +### 6.1 ๋™์‹œ์„ฑ ์ œ์–ด +- **์žฌ๊ณ  ์ฐจ๊ฐ**: `SELECT ... FOR UPDATE` ๋น„๊ด€์  ๋ฝ +- **์ข‹์•„์š” ์ค‘๋ณต**: `UNIQUE (user_id, product_id)` ์ œ์•ฝ์กฐ๊ฑด +- **์ฃผ๋ฌธ ํ•ญ๋ชฉ ์ค‘๋ณต**: `UNIQUE (order_id, product_id)` ์ œ์•ฝ์กฐ๊ฑด + +### 6.2 Soft Delete ์ฒ˜๋ฆฌ +- brands, products, users: `deleted_at` ํ•„๋“œ ์‚ฌ์šฉ +- ์กฐํšŒ ์‹œ `WHERE deleted_at IS NULL` ์กฐ๊ฑด ํ•„์ˆ˜ +- product_likes, orders, order_items: Hard Delete + +### 6.3 ์Šค๋ƒ…์ƒท ๋ฐ์ดํ„ฐ +- order_items.price: ์ฃผ๋ฌธ ์‹œ์  ์ƒํ’ˆ ๊ฐ€๊ฒฉ +- order_items.product_name: ์ฃผ๋ฌธ ์‹œ์  ์ƒํ’ˆ๋ช… +- ์ƒํ’ˆ ์ •๋ณด ๋ณ€๊ฒฝ๊ณผ ๋ฌด๊ด€ํ•˜๊ฒŒ ์ฃผ๋ฌธ ์ด๋ ฅ ๋ณด์กด From 54860bede52f7c8d5eff1cc292a387d5e2b8d17b Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 13 Feb 2026 15:50:39 +0900 Subject: [PATCH 09/29] =?UTF-8?q?Revert=20"docs:=20=EC=BB=A4=EB=A8=B8?= =?UTF-8?q?=EC=8A=A4=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EB=B6=84=EC=84=9D=20=EB=B0=8F=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 44bfc368022317b7ceb2990fabbd617848243cb5. --- .claude/skills/requirements-analysis/SKILL.md | 77 -- .docs/design/01-requirements.md | 431 ----------- .docs/design/02-sequence-diagrams.md | 437 ----------- .docs/design/03-class-diagram.md | 700 ------------------ .docs/design/04-erd.md | 419 ----------- 5 files changed, 2064 deletions(-) delete mode 100644 .claude/skills/requirements-analysis/SKILL.md delete mode 100644 .docs/design/01-requirements.md delete mode 100644 .docs/design/02-sequence-diagrams.md delete mode 100644 .docs/design/03-class-diagram.md delete mode 100644 .docs/design/04-erd.md diff --git a/.claude/skills/requirements-analysis/SKILL.md b/.claude/skills/requirements-analysis/SKILL.md deleted file mode 100644 index 3485a8af8..000000000 --- a/.claude/skills/requirements-analysis/SKILL.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -name: requirements-analysis -description: - ์ œ๊ณต๋œ ์š”๊ตฌ์‚ฌํ•ญ์„ ๋ถ„์„ํ•˜๊ณ , ๊ฐœ๋ฐœ์ž์™€์˜ ์งˆ๋ฌธ/๋Œ€๋‹ต์„ ํ†ตํ•ด ์• ๋งคํ•œ ์š”๊ตฌ์‚ฌํ•ญ์„ ๋ช…ํ™•ํžˆ ํ•˜์—ฌ ์ •๋ฆฌํ•ฉ๋‹ˆ๋‹ค. - ๋ชจ๋“  ์ •๋ฆฌ๊ฐ€ ๋๋‚˜๋ฉด, ์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ, ํด๋ž˜์Šค ๋‹ค์ด์–ด๊ทธ๋žจ, ERD ๋“ฑ์„ Mermaid ๋ฌธ๋ฒ•์œผ๋กœ ์ž‘์„ฑํ•œ๋‹ค. - ์š”๊ตฌ์‚ฌํ•ญ์ด ์ œ๊ณต๋˜์—ˆ์„ ๋•Œ, ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•˜๊ธฐ ์ „ ์ด๋ฅผ ๋ช…ํ™•ํžˆ ํ•˜๋Š” ๋ฐ์— ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. ---- -์š”๊ตฌ์‚ฌํ•ญ์„ ๋ถ„์„ํ•  ๋•Œ ๋ฐ˜๋“œ์‹œ ๋‹ค์Œ ํ๋ฆ„์„ ๋”ฐ๋ฅธ๋‹ค. -### 1๏ธโƒฃ ์š”๊ตฌ์‚ฌํ•ญ์„ ๊ทธ๋Œ€๋กœ ๋ฏฟ์ง€ ๋ง๊ณ , ๋ฌธ์ œ ์ƒํ™ฉ์œผ๋กœ ๋‹ค์‹œ ์„ค๋ช…ํ•œ๋‹ค. -- ์š”๊ตฌ์‚ฌํ•ญ ๋ฌธ์žฅ์„ ์ •๋ฆฌํ•˜๋Š” ๋ฐ์„œ ๋๋‚ด์ง€ ์•Š๋Š”๋‹ค. -- "๋ฌด์—‡์„ ๋งŒ๋“ค๊นŒ?"๊ฐ€ ์•„๋‹ˆ๋ผ "์ง€๊ธˆ ์–ด๋–ค ๋ฌธ์ œ๊ฐ€ ์žˆ๊ณ , ๊ทธ๊ฑธ ์™œ ํ•ด๊ฒฐํ•˜๋ ค๋Š”๊ฐ€?" ๋กœ ์žฌํ•ด์„ํ•œ๋‹ค. -- ๋‹ค์Œ ๊ด€์ ์„ ๋ถ„๋ฆฌํ•ด์„œ ์ •๋ฆฌํ•œ๋‹ค: - - ์‚ฌ์šฉ์ž ๊ด€์  - - ๋น„์ฆˆ๋‹ˆ์Šค ๊ด€์  - - ์‹œ์Šคํ…œ ๊ด€์  -> ์˜ˆ์‹œ -> "์ฃผ๋ฌธ ์‹คํŒจ ์‹œ ๊ฒฐ์ œ๋ฅผ ์ทจ์†Œํ•œ๋‹ค" โ†’ "๊ฒฐ์ œ ์„ฑ๊ณต/์‹คํŒจ์™€ ์ฃผ๋ฌธ ์ƒํƒœ๊ฐ€ ์–ด๊ธ‹๋‚˜์ง€ ์•Š๋„๋ก ์ผ๊ด€์„ฑ์„ ์œ ์ง€ํ•˜๋ ค๋Š” ๋ฌธ์ œ" - -### 2๏ธโƒฃ ์• ๋งคํ•œ ์š”๊ตฌ์‚ฌํ•ญ์„ ์ˆจ๊ธฐ์ง€ ๋ง๊ณ  ๋“œ๋Ÿฌ๋‚ธ๋‹ค -- ์ถ”์ธกํ•˜๊ฑฐ๋‚˜ ์•Œ์•„์„œ ๊ฒฐ์ •ํ•˜์ง€ ์•Š๋Š”๋‹ค. -- ์š”๊ตฌ์‚ฌํ•ญ์—์„œ ๊ฒฐ์ •๋˜์ง€ ์•Š์€ ๋ถ€๋ถ„์„ ๋ช…์‹œ์ ์œผ๋กœ ๋‚˜์—ดํ•œ๋‹ค. - **๋‹ค์Œ ์œ ํ˜•์˜ ์งˆ๋ฌธ์„ ๋ฐ˜๋“œ์‹œ ํฌํ•จํ•œ๋‹ค:** -- ์ •์ฑ… ์งˆ๋ฌธ: ๊ธฐ์ค€ ์‹œ์ , ์„ฑ๊ณต/์‹คํŒจ ์กฐ๊ฑด, ์˜ˆ์™ธ ์ฒ˜๋ฆฌ ๊ทœ์น™ -- ๊ฒฝ๊ณ„ ์งˆ๋ฌธ: ์–ด๋””๊นŒ์ง€๊ฐ€ ํ•œ ์ฑ…์ž„์ธ๊ฐ€, ์–ด๋””์„œ ๋ถ„๋ฆฌ๋˜๋Š”๊ฐ€ -- ํ™•์žฅ ์งˆ๋ฌธ: ๋‚˜์ค‘์— ๋ฐ”๋€” ๊ฐ€๋Šฅ์„ฑ์ด ์žˆ๋Š”๊ฐ€ - -### 3๏ธโƒฃ ์š”๊ตฌ์‚ฌํ•ญ ๋ช…ํ™•ํ™”๋ฅผ ์œ„ํ•œ ์งˆ๋ฌธ์„ ๊ฐœ๋ฐœ์ž ๋‹ต๋ณ€์ด ์‰ฌ์šด ํ˜•ํƒœ๋กœ ์ œ์‹œํ•œ๋‹ค -- ์งˆ๋ฌธ์€ ์šฐ์„ ์ˆœ์œ„๋ฅผ ๊ฐ€์ง„๋‹ค (์ค‘์š”ํ•œ ๊ฒƒ๋ถ€ํ„ฐ). -- ์„ ํƒ์ง€๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ, ์˜ต์…˜ + ์˜ํ–ฅ๋„๋ฅผ ํ•จ๊ป˜ ์ œ์‹œํ•œ๋‹ค. -> ํ˜•์‹ ์˜ˆ์‹œ: -- ์„ ํƒ์ง€ A: ํ•˜๋‚˜์˜ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์ฒ˜๋ฆฌ โ†’ ๊ตฌํ˜„ ๋‹จ์ˆœ, ํ™•์žฅ์„ฑ ๋‚ฎ์Œ -- ์„ ํƒ์ง€ B: ๋‹จ๊ณ„๋ณ„ ๋ถ„๋ฆฌ โ†’ ๊ตฌ์กฐ ๋ณต์žก, ํ™•์žฅ/๋ณด์ƒ ์ฒ˜๋ฆฌ ์œ ๋ฆฌ - -### 4๏ธโƒฃ ํ•ฉ์˜๋œ ๋‚ด์šฉ์„ ๋ฐ”ํƒ•์œผ๋กœ ๊ฐœ๋… ๋ชจ๋ธ๋ถ€ํ„ฐ ์žก๋Š”๋‹ค -- ๋ฐ”๋กœ ์ฝ”๋“œ๋‚˜ ๊ธฐ์ˆ  ์–˜๊ธฐ๋กœ ๋“ค์–ด๊ฐ€์ง€ ์•Š๋Š”๋‹ค. -- ๋จผ์ € ๋‹ค์Œ์„ ์ •์˜ํ•œ๋‹ค: - - ์•กํ„ฐ (์‚ฌ์šฉ์ž, ์™ธ๋ถ€ ์‹œ์Šคํ…œ) - - ํ•ต์‹ฌ ๋„๋ฉ”์ธ - - ๋ณด์กฐ/์™ธ๋ถ€ ์‹œ์Šคํ…œ -- ์ด ๋‹จ๊ณ„๋Š” โ€œ๊ตฌํ˜„โ€์ด ์•„๋‹ˆ๋ผ ์„ค๊ณ„ ์‚ฌ๊ณ  ์ •๋ ฌ์ด ๋ชฉ์ ์ด๋‹ค. - -### 5๏ธโƒฃ ๋‹ค์ด์–ด๊ทธ๋žจ์€ ํ•ญ์ƒ ์ด์œ  โ†’ ๋‹ค์ด์–ด๊ทธ๋žจ โ†’ ํ•ด์„ ์ˆœ์„œ๋กœ ์ œ์‹œํ•œ๋‹ค -**๋‹ค์ด์–ด๊ทธ๋žจ์„ ๊ทธ๋ฆฌ๊ธฐ ์ „์— ๋ฐ˜๋“œ์‹œ ์„ค๋ช…ํ•œ๋‹ค** -- ์™œ ์ด ๋‹ค์ด์–ด๊ทธ๋žจ์ด ํ•„์š”ํ•œ์ง€ -- ์ด ๋‹ค์ด์–ด๊ทธ๋žจ์œผ๋กœ ๋ฌด์—‡์„ ๊ฒ€์ฆํ•˜๋ ค๋Š”์ง€ - -**๋‹ค์ด์–ด๊ทธ๋žจ์€ Mermaid ๋ฌธ๋ฒ•์œผ๋กœ ์ž‘์„ฑํ•œ๋‹ค** -์‚ฌ์šฉ ๊ธฐ์ค€: -- **์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ** - - ์ฑ…์ž„ ๋ถ„๋ฆฌ - - ํ˜ธ์ถœ ์ˆœ์„œ - - ํŠธ๋žœ์žญ์…˜ ๊ฒฝ๊ณ„ ํ™•์ธ -- **ํด๋ž˜์Šค ๋‹ค์ด์–ด๊ทธ๋žจ** - - ๋„๋ฉ”์ธ ์ฑ…์ž„ - - ์˜์กด ๋ฐฉํ–ฅ - - ์‘์ง‘๋„ ํ™•์ธ -- **ERD** - - ์˜์†์„ฑ ๊ตฌ์กฐ - - ๊ด€๊ณ„์˜ ์ฃผ์ธ - - ์ •๊ทœํ™” ์—ฌ๋ถ€ - -### 6๏ธโƒฃ ๋‹ค์ด์–ด๊ทธ๋žจ์„ ๋˜์ง€๊ณ  ๋๋‚ด์ง€ ๋ง๊ณ  ์ฝ๋Š” ๋ฒ•์„ ์งš์–ด์ค€๋‹ค -- "์ด ๊ตฌ์กฐ์—์„œ ํŠนํžˆ ๋ด์•ผ ํ•  ํฌ์ธํŠธ"๋ฅผ 2~3์ค„๋กœ ์„ค๋ช…ํ•œ๋‹ค. -- ์„ค๊ณ„ ์˜๋„๊ฐ€ ๋“œ๋Ÿฌ๋‚˜๋„๋ก ํ•ด์„์„ ๋ถ™์ธ๋‹ค. - -### 7๏ธโƒฃ ์„ค๊ณ„์˜ ์ž ์žฌ ๋ฆฌ์Šคํฌ๋ฅผ ๋ฐ˜๋“œ์‹œ ์–ธ๊ธ‰ํ•œ๋‹ค -- ํ˜„์žฌ ์„ค๊ณ„๊ฐ€ ๊ฐ€์งˆ ์ˆ˜ ์žˆ๋Š” ์œ„ํ—˜์„ ์ˆจ๊ธฐ์ง€ ์•Š๋Š”๋‹ค. - - ํŠธ๋žœ์žญ์…˜ ๋น„๋Œ€ํ™” - - ๋„๋ฉ”์ธ ๊ฐ„ ๊ฒฐํ•ฉ๋„ ์ฆ๊ฐ€ - - ์ •์ฑ… ๋ณ€๊ฒฝ ์‹œ ์˜ํ–ฅ ๋ฒ”์œ„ ํ™•๋Œ€ -- ํ•ด๊ฒฐ์ฑ…์€ ์ •๋‹ต์ฒ˜๋Ÿผ ๋งํ•˜์ง€ ์•Š๊ณ  ์„ ํƒ์ง€๋กœ ์ œ์‹œํ•œ๋‹ค. - -### ํ†ค & ์Šคํƒ€์ผ ๊ฐ€์ด๋“œ -- ๊ฐ•์˜์ฒ˜๋Ÿผ ์„ค๋ช…ํ•˜์ง€ ๋ง๊ณ  ์„ค๊ณ„ ๋ฆฌ๋ทฐ ํ†ค์„ ์œ ์ง€ํ•œ๋‹ค -- ์ •๋‹ต์ด๋ผ๊ณ  ์ œ์‹œํ•˜๊ธฐ๋ณด๋‹ค, ๋‹ค๋ฅธ ์„ ํƒ์ง€๊ฐ€ ์žˆ๋‹ค๋ฉด ์ด๋ฅผ ์ œ๊ณตํ•˜๋„๋ก ํ•œ๋‹ค. -- ์ฝ”๋“œ๋ณด๋‹ค ์˜๋„, ์ฑ…์ž„, ๊ฒฝ๊ณ„๋ฅผ ๋” ์ค‘์š”ํ•˜๊ฒŒ ๋‹ค๋ฃฌ๋‹ค -- ๊ตฌํ˜„ ์ „์— ์ƒ๊ฐํ•ด์•ผ ํ•  ๊ฒƒ์„ ๋Œ์–ด๋‚ด๋Š” ๋ฐ ์ง‘์ค‘ํ•œ๋‹ค \ No newline at end of file diff --git a/.docs/design/01-requirements.md b/.docs/design/01-requirements.md deleted file mode 100644 index baa2e2eca..000000000 --- a/.docs/design/01-requirements.md +++ /dev/null @@ -1,431 +0,0 @@ -# ์ปค๋จธ์Šค ๋„๋ฉ”์ธ ์š”๊ตฌ์‚ฌํ•ญ ์ •์˜์„œ - -## 1. ๊ฐœ์š” - -### 1.1 ๋ฌธ์„œ ๋ชฉ์  -Java/Spring Boot ๋ฉ€ํ‹ฐ ๋ชจ๋“ˆ ์ปค๋จธ์Šค ๋ฐฑ์—”๋“œ์˜ Brand, Product, ProductLike, Order, OrderItem ๋„๋ฉ”์ธ์— ๋Œ€ํ•œ -์ƒ์„ธ ์š”๊ตฌ์‚ฌํ•ญ์„ ์ •์˜ํ•œ๋‹ค. - -### 1.2 ๊ธฐ์กด ํŒจํ„ด ์ฐธ์กฐ -- ์•„ํ‚คํ…์ฒ˜: Layered Architecture (interfaces โ†’ application โ†’ domain โ†’ infrastructure) -- ์ธ์ฆ: ํ—ค๋” ๊ธฐ๋ฐ˜ ์ธ์ฆ (X-Loopers-LoginId, X-Loopers-LoginPw) -- ์‘๋‹ต ํ˜•์‹: ApiResponse (meta + data) -- ์˜ˆ์™ธ ์ฒ˜๋ฆฌ: CoreException + ErrorType enum - -### 1.3 ์•กํ„ฐ ์ •์˜ - -| ์•กํ„ฐ | ์„ค๋ช… | ์ธ์ฆ ๋ฐฉ์‹ | -|------|------|----------| -| ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž | ์ƒํ’ˆ ์กฐํšŒ, ์ข‹์•„์š”, ์ฃผ๋ฌธ ๊ฐ€๋Šฅ | X-Loopers-LoginId + X-Loopers-LoginPw | -| ์–ด๋“œ๋ฏผ | ๋ธŒ๋žœ๋“œ/์ƒํ’ˆ CRUD ๊ด€๋ฆฌ | X-Loopers-Ldap: loopers.admin | - ---- - -## 2. ๋„๋ฉ”์ธ๋ณ„ ์ƒ์„ธ ์š”๊ตฌ์‚ฌํ•ญ - -### 2.1 Brand (๋ธŒ๋žœ๋“œ) - -#### 2.1.1 ํ•„๋“œ ์ •์˜ -| ํ•„๋“œ | ํƒ€์ž… | ํ•„์ˆ˜ | ์ œ์•ฝ์กฐ๊ฑด | -|------|------|------|----------| -| id | Long | Y | ์ž๋™ ์ƒ์„ฑ (PK) | -| name | String | Y | 1-100์ž, ๊ณต๋ฐฑ ๋ถˆ๊ฐ€, ์ค‘๋ณต ๋ถˆ๊ฐ€ | -| description | String | N | ์ตœ๋Œ€ 500์ž | -| logoUrl | String | N | URL ํ˜•์‹ ๊ฒ€์ฆ, ์ตœ๋Œ€ 500์ž | -| createdAt | ZonedDateTime | Y | ์ž๋™ ์ƒ์„ฑ | -| updatedAt | ZonedDateTime | Y | ์ž๋™ ๊ฐฑ์‹  | -| deletedAt | ZonedDateTime | N | Soft Delete | - -*๋ธŒ๋žœ๋“œ ์ฃผ์†Œ, ๋Œ€ํ‘œ๋ช…, ๋ธŒ๋žœ๋“œ ์‚ฌ์ดํŠธ URL ๊ฐ™์€ ์ปฌ๋Ÿผ์„ ๋„ฃ์„์ง€ ๋ง์ง€ ๊ณ ๋ฏผํ–ˆ์ง€๋งŒ ์„ค๊ณ„์— ์ง‘์ค‘ํ•˜๊ณ  ์‹ถ์–ด์„œ ๋„ฃ์ง€ ์•Š์•˜๋‹ค.* - -#### 2.1.2 ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ -- **BR-BRAND-001**: ๋ธŒ๋žœ๋“œ๋ช…์€ ์‹œ์Šคํ…œ ๋‚ด ์œ ์ผํ•ด์•ผ ํ•œ๋‹ค -- **BR-BRAND-002**: ๋ธŒ๋žœ๋“œ ์‚ญ์ œ ์‹œ ํ•ด๋‹น ๋ธŒ๋žœ๋“œ์˜ ๋ชจ๋“  ์ƒํ’ˆ์ด Cascade ์‚ญ์ œ๋œ๋‹ค -- **BR-BRAND-003**: ์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ๋Š” ์กฐํšŒ๋˜์ง€ ์•Š๋Š”๋‹ค (Soft Delete) - -#### 2.1.3 ๊ฒ€์ฆ ๊ทœ์น™ -``` -name ๊ฒ€์ฆ: -- null ๋˜๋Š” blank ๋ถˆ๊ฐ€ โ†’ "๋ธŒ๋žœ๋“œ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค." -- 100์ž ์ดˆ๊ณผ โ†’ "๋ธŒ๋žœ๋“œ๋ช…์€ 100์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." -- ์ค‘๋ณต โ†’ "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ธŒ๋žœ๋“œ๋ช…์ž…๋‹ˆ๋‹ค." (409 CONFLICT) - -description ๊ฒ€์ฆ: -- 500์ž ์ดˆ๊ณผ โ†’ "๋ธŒ๋žœ๋“œ ์„ค๋ช…์€ 500์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." - -logoUrl ๊ฒ€์ฆ: -- URL ํ˜•์‹ ๋ถˆ์ผ์น˜ โ†’ "์œ ํšจํ•˜์ง€ ์•Š์€ URL ํ˜•์‹์ž…๋‹ˆ๋‹ค." -- 500์ž ์ดˆ๊ณผ โ†’ "๋กœ๊ณ  URL์€ 500์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." -``` - ---- - -### 2.2 Product (์ƒํ’ˆ) - -#### 2.2.1 ํ•„๋“œ ์ •์˜ -| ํ•„๋“œ | ํƒ€์ž… | ํ•„์ˆ˜ | ์ œ์•ฝ์กฐ๊ฑด | -|------|------|------|----------| -| id | Long | Y | ์ž๋™ ์ƒ์„ฑ (PK) | -| brandId | Long | Y | Brand FK, ์กด์žฌ ๊ฒ€์ฆ | -| name | String | Y | 1-200์ž | -| description | String | N | ์ตœ๋Œ€ 2000์ž | -| price | Long | Y | 0 ์ด์ƒ | -| stock | Integer | Y | 0 ์ด์ƒ | -| imageUrl | String | N | URL ํ˜•์‹, ์ตœ๋Œ€ 500์ž | -| createdAt | ZonedDateTime | Y | ์ž๋™ ์ƒ์„ฑ | -| updatedAt | ZonedDateTime | Y | ์ž๋™ ๊ฐฑ์‹  | -| deletedAt | ZonedDateTime | N | Soft Delete | - -#### 2.2.2 ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ -- **BR-PRODUCT-001**: ์ƒํ’ˆ์€ ๋ฐ˜๋“œ์‹œ ํ•˜๋‚˜์˜ ๋ธŒ๋žœ๋“œ์— ์†ํ•ด์•ผ ํ•œ๋‹ค -- **BR-PRODUCT-002**: ์ƒํ’ˆ ๋“ฑ๋ก ํ›„ ๋ธŒ๋žœ๋“œ ๋ณ€๊ฒฝ ๋ถˆ๊ฐ€ -- **BR-PRODUCT-003**: ์ƒํ’ˆ ์‚ญ์ œ ์‹œ ํ•ด๋‹น ์ƒํ’ˆ์˜ ๋ชจ๋“  ์ข‹์•„์š”๊ฐ€ Cascade ์‚ญ์ œ๋œ๋‹ค -- **BR-PRODUCT-004**: ์‚ญ์ œ๋œ ์ƒํ’ˆ์€ ๋ชฉ๋ก ์กฐํšŒ ์‹œ ์ œ์™ธ๋œ๋‹ค -- **BR-PRODUCT-005**: ์žฌ๊ณ ๊ฐ€ 0์ธ ์ƒํ’ˆ๋„ ์กฐํšŒ๋Š” ๊ฐ€๋Šฅํ•˜๋‹ค - -#### 2.2.3 ๊ฒ€์ฆ ๊ทœ์น™ -``` -name ๊ฒ€์ฆ: -- null ๋˜๋Š” blank ๋ถˆ๊ฐ€ โ†’ "์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค." -- 200์ž ์ดˆ๊ณผ โ†’ "์ƒํ’ˆ๋ช…์€ 200์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." - -price ๊ฒ€์ฆ: -- null ๋ถˆ๊ฐ€ โ†’ "๊ฐ€๊ฒฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค." -- ์Œ์ˆ˜ โ†’ "๊ฐ€๊ฒฉ์€ 0์› ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค." - -stock ๊ฒ€์ฆ: -- null ๋ถˆ๊ฐ€ โ†’ "์žฌ๊ณ ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค." -- ์Œ์ˆ˜ โ†’ "์žฌ๊ณ ๋Š” 0๊ฐœ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค." - -brandId ๊ฒ€์ฆ: -- ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ธŒ๋žœ๋“œ โ†’ "์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ธŒ๋žœ๋“œ์ž…๋‹ˆ๋‹ค." (404 NOT_FOUND) -- ์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ โ†’ "์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ์ž…๋‹ˆ๋‹ค." (400 BAD_REQUEST) -``` - ---- - -### 2.3 ProductLike (์ƒํ’ˆ ์ข‹์•„์š”) - -#### 2.3.1 ํ•„๋“œ ์ •์˜ -| ํ•„๋“œ | ํƒ€์ž… | ํ•„์ˆ˜ | ์ œ์•ฝ์กฐ๊ฑด | -|------|------|------|----------| -| id | Long | Y | ์ž๋™ ์ƒ์„ฑ (PK) | -| userId | Long | Y | User FK | -| productId | Long | Y | Product FK | -| createdAt | ZonedDateTime | Y | ์ž๋™ ์ƒ์„ฑ | - -#### 2.3.2 ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ -- **BR-LIKE-001**: ์ข‹์•„์š” ๋“ฑ๋ก ์‹œ ์ด๋ฏธ ์ข‹์•„์š”๊ฐ€ ์กด์žฌํ•˜๋ฉด ์ข‹์•„์š” ์ทจ์†Œ ์ฒ˜๋ฆฌ (ํ† ๊ธ€ ๋ฐฉ์‹) -- **BR-LIKE-002**: ์ข‹์•„์š” ๊ฐœ์ˆ˜๋Š” ์‹ค์‹œ๊ฐ„ COUNT ์ง‘๊ณ„ -- **BR-LIKE-003**: ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ข‹์•„์š” ์ทจ์†Œ ์‹œ ๋ฉฑ๋“ฑ ์ฒ˜๋ฆฌ (์—๋Ÿฌ ์—†์ด ์„ฑ๊ณต ์‘๋‹ต) -- **BR-LIKE-004**: ์‚ญ์ œ๋œ ์ƒํ’ˆ์—๋Š” ์ข‹์•„์š” ๋ถˆ๊ฐ€ - -#### 2.3.3 ๊ฒ€์ฆ ๊ทœ์น™ -``` -์ข‹์•„์š” ๋“ฑ๋ก: -- ์‚ญ์ œ๋œ ์ƒํ’ˆ โ†’ "์‚ญ์ œ๋œ ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค." (400 BAD_REQUEST) -- ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ โ†’ "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค." (404 NOT_FOUND) -- ์ค‘๋ณต ์ข‹์•„์š” โ†’ ์ข‹์•„์š” ์ทจ์†Œ ์ฒ˜๋ฆฌ (ํ† ๊ธ€) - -์ข‹์•„์š” ์ทจ์†Œ: -- ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ข‹์•„์š” โ†’ ๋ฉฑ๋“ฑ ์ฒ˜๋ฆฌ (์„ฑ๊ณต ์‘๋‹ต) -``` - ---- - -### 2.4 Order (์ฃผ๋ฌธ) - -#### 2.4.1 ํ•„๋“œ ์ •์˜ -| ํ•„๋“œ | ํƒ€์ž… | ํ•„์ˆ˜ | ์ œ์•ฝ์กฐ๊ฑด | -|------|------|------|----------| -| id | Long | Y | ์ž๋™ ์ƒ์„ฑ (PK) | -| userId | Long | Y | User FK | -| totalPrice | Long | Y | 0 ์ด์ƒ, ๊ณ„์‚ฐ๋œ ๊ฐ’ | -| status | OrderStatus | Y | PENDING, COMPLETED, CANCELLED | -| createdAt | ZonedDateTime | Y | ์ž๋™ ์ƒ์„ฑ | -| updatedAt | ZonedDateTime | Y | ์ž๋™ ๊ฐฑ์‹  | - -#### 2.4.2 OrderStatus ์ƒํƒœ ์ •์˜ -```java -public enum OrderStatus { - PENDING, // ์ฃผ๋ฌธ ๋Œ€๊ธฐ - COMPLETED, // ์ฃผ๋ฌธ ์™„๋ฃŒ - CANCELLED // ์ฃผ๋ฌธ ์ทจ์†Œ -} -``` - -#### 2.4.3 ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ -- **BR-ORDER-001**: ๋‹ค๊ฑด ์ƒํ’ˆ ์ฃผ๋ฌธ ์ง€์› (OrderItem 1:N ๊ด€๊ณ„) -- **BR-ORDER-002**: ์ „์ฒด ์‹คํŒจ ์ •์ฑ… - ํ•˜๋‚˜๋ผ๋„ ์‹คํŒจ ์‹œ ์ „์ฒด ์ฃผ๋ฌธ ๋กค๋ฐฑ -- **BR-ORDER-003**: ์žฌ๊ณ  ๊ฒ€์ฆ ํ›„ ์ฐจ๊ฐ์€ ์›์ž์ ์œผ๋กœ ์ˆ˜ํ–‰ (๋น„๊ด€์  ๋ฝ) -- **BR-ORDER-004**: totalPrice๋Š” OrderItem๋“ค์˜ (price * quantity) ํ•ฉ๊ณ„ - ---- - -### 2.5 OrderItem (์ฃผ๋ฌธ ํ•ญ๋ชฉ) - -#### 2.5.1 ํ•„๋“œ ์ •์˜ -| ํ•„๋“œ | ํƒ€์ž… | ํ•„์ˆ˜ | ์ œ์•ฝ์กฐ๊ฑด | -|------|------|------|----------| -| id | Long | Y | ์ž๋™ ์ƒ์„ฑ (PK) | -| orderId | Long | Y | Order FK | -| productId | Long | Y | Product FK | -| quantity | Integer | Y | 1 ์ด์ƒ | -| price | Long | Y | ์ฃผ๋ฌธ ์‹œ์  ์ƒํ’ˆ ๊ฐ€๊ฒฉ (์Šค๋ƒ…์ƒท) | -| createdAt | ZonedDateTime | Y | ์ž๋™ ์ƒ์„ฑ | - -#### 2.5.2 ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ -- **BR-ORDERITEM-001**: ์ฃผ๋ฌธ ์‹œ์ ์˜ ์ƒํ’ˆ ๊ฐ€๊ฒฉ์„ ์Šค๋ƒ…์ƒท์œผ๋กœ ์ €์žฅ -- **BR-ORDERITEM-002**: ๋™์ผ ์ฃผ๋ฌธ ๋‚ด ๋™์ผ ์ƒํ’ˆ ์ค‘๋ณต ๋ถˆ๊ฐ€ (orderId + productId UNIQUE) -- **BR-ORDERITEM-003**: ์ˆ˜๋Ÿ‰์€ ์ตœ์†Œ 1๊ฐœ ์ด์ƒ - ---- - -## 3. ์œ ์ € ์‹œ๋‚˜๋ฆฌ์˜ค - -### 3.1 ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž ์‹œ๋‚˜๋ฆฌ์˜ค - -#### US-001: ๋ธŒ๋žœ๋“œ ์ •๋ณด ์กฐํšŒ -``` -Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ -When: GET /api/v1/brands/{brandId} ์š”์ฒญ -Then: ๋ธŒ๋žœ๋“œ ์ •๋ณด(name, description, logoUrl) ๋ฐ˜ํ™˜ -``` - -#### US-002: ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ -``` -Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ -When: GET /api/v1/products ์š”์ฒญ (์ •๋ ฌ/ํ•„ํ„ฐ/ํŽ˜์ด์ง• ์˜ต์…˜) -Then: ์ƒํ’ˆ ๋ชฉ๋ก๊ณผ ์ข‹์•„์š” ์ˆ˜ ๋ฐ˜ํ™˜ - -์ •๋ ฌ ์˜ต์…˜: -- latest (๊ธฐ๋ณธ๊ฐ’): ์ตœ์‹ ์ˆœ -- price_asc: ๊ฐ€๊ฒฉ ๋‚ฎ์€์ˆœ -- like_desc: ์ข‹์•„์š” ๋งŽ์€์ˆœ - -ํ•„ํ„ฐ ์˜ต์…˜: -- brandId: ํŠน์ • ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ - -ํŽ˜์ด์ง•: -- page: ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ (0๋ถ€ํ„ฐ ์‹œ์ž‘) -- size: ํŽ˜์ด์ง€ ํฌ๊ธฐ (๊ธฐ๋ณธ 20) -``` - -#### US-003: ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ -``` -Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ -When: GET /api/v1/products/{productId} ์š”์ฒญ -Then: ์ƒํ’ˆ ์ƒ์„ธ ์ •๋ณด์™€ ์ข‹์•„์š” ์ˆ˜ ๋ฐ˜ํ™˜ -``` - -#### US-004: ์ข‹์•„์š” ๋“ฑ๋ก/ํ† ๊ธ€ -``` -Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ -When: POST /api/v1/products/{productId}/likes ์š”์ฒญ -Then: - - ์ข‹์•„์š”๊ฐ€ ์—†์œผ๋ฉด โ†’ ์ข‹์•„์š” ๋“ฑ๋ก - - ์ข‹์•„์š”๊ฐ€ ์žˆ์œผ๋ฉด โ†’ ์ข‹์•„์š” ์ทจ์†Œ (ํ† ๊ธ€) -``` - -#### US-005: ์ข‹์•„์š” ์ทจ์†Œ -``` -Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ -When: DELETE /api/v1/products/{productId}/likes ์š”์ฒญ -Then: ์ข‹์•„์š” ์‚ญ์ œ (์กด์žฌํ•˜์ง€ ์•Š์•„๋„ ์„ฑ๊ณต) -``` - -#### US-006: ๋‚ด๊ฐ€ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ ๋ชฉ๋ก -``` -Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ -When: GET /api/v1/users/{userId}/likes ์š”์ฒญ -Then: ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ ๋ชฉ๋ก ๋ฐ˜ํ™˜ -``` - -#### US-007: ์ฃผ๋ฌธ ์ƒ์„ฑ -``` -Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ, ์ฃผ๋ฌธํ•  ์ƒํ’ˆ๋“ค ์„ ํƒ -When: POST /api/v1/orders ์š”์ฒญ (items: [{productId, quantity}]) -Then: - - ์žฌ๊ณ  ๊ฒ€์ฆ (๋ชจ๋“  ์ƒํ’ˆ) - - ์žฌ๊ณ  ์ฐจ๊ฐ (์›์ž์ ) - - ์ฃผ๋ฌธ ์ƒ์„ฑ ๋ฐ ID ๋ฐ˜ํ™˜ - - ์‹คํŒจ ์‹œ ์ „์ฒด ๋กค๋ฐฑ -``` - -#### US-008: ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ -``` -Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ -When: GET /api/v1/orders ์š”์ฒญ (์„ ํƒ์  ๊ธฐ๊ฐ„ ํ•„ํ„ฐ) -Then: ๋ณธ์ธ์˜ ์ฃผ๋ฌธ ๋ชฉ๋ก ๋ฐ˜ํ™˜ -``` - -#### US-009: ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ -``` -Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ -When: GET /api/v1/orders/{orderId} ์š”์ฒญ -Then: - - ๋ณธ์ธ ์ฃผ๋ฌธ์ธ ๊ฒฝ์šฐ: ์ฃผ๋ฌธ ์ƒ์„ธ ๋ฐ˜ํ™˜ - - ํƒ€์ธ ์ฃผ๋ฌธ์ธ ๊ฒฝ์šฐ: 403 FORBIDDEN -``` - ---- - -### 3.2 ์–ด๋“œ๋ฏผ ์‹œ๋‚˜๋ฆฌ์˜ค - -#### AS-001: ๋ธŒ๋žœ๋“œ ๋ชฉ๋ก ์กฐํšŒ (Admin) -``` -Given: X-Loopers-Ldap: loopers.admin ํ—ค๋” -When: GET /api-admin/v1/brands ์š”์ฒญ -Then: ์ „์ฒด ๋ธŒ๋žœ๋“œ ๋ชฉ๋ก ๋ฐ˜ํ™˜ -``` - -#### AS-002: ๋ธŒ๋žœ๋“œ ๋“ฑ๋ก (Admin) -``` -Given: X-Loopers-Ldap: loopers.admin ํ—ค๋” -When: POST /api-admin/v1/brands ์š”์ฒญ -Then: ๋ธŒ๋žœ๋“œ ๋“ฑ๋ก ๋ฐ ID ๋ฐ˜ํ™˜ -``` - -#### AS-003: ๋ธŒ๋žœ๋“œ ์ˆ˜์ • (Admin) -``` -Given: X-Loopers-Ldap: loopers.admin ํ—ค๋” -When: PUT /api-admin/v1/brands/{brandId} ์š”์ฒญ -Then: ๋ธŒ๋žœ๋“œ ์ •๋ณด ์ˆ˜์ • -``` - -#### AS-004: ๋ธŒ๋žœ๋“œ ์‚ญ์ œ (Admin) -``` -Given: X-Loopers-Ldap: loopers.admin ํ—ค๋” -When: DELETE /api-admin/v1/brands/{brandId} ์š”์ฒญ -Then: - - ๋ธŒ๋žœ๋“œ Soft Delete - - ํ•ด๋‹น ๋ธŒ๋žœ๋“œ์˜ ๋ชจ๋“  ์ƒํ’ˆ Cascade Soft Delete - - ์ƒํ’ˆ๋“ค์˜ ์ข‹์•„์š” Cascade Hard Delete -``` - -#### AS-005: ์ƒํ’ˆ ๋“ฑ๋ก (Admin) -``` -Given: X-Loopers-Ldap: loopers.admin ํ—ค๋” -When: POST /api-admin/v1/products ์š”์ฒญ -Then: ์ƒํ’ˆ ๋“ฑ๋ก ๋ฐ ID ๋ฐ˜ํ™˜ -``` - -#### AS-006: ์ƒํ’ˆ ์ˆ˜์ • (Admin) -``` -Given: X-Loopers-Ldap: loopers.admin ํ—ค๋” -When: PUT /api-admin/v1/products/{productId} ์š”์ฒญ -Then: - - brandId ์ œ์™ธ ํ•„๋“œ ์ˆ˜์ • ๊ฐ€๋Šฅ - - brandId ๋ณ€๊ฒฝ ์‹œ๋„ ์‹œ: "๋ธŒ๋žœ๋“œ ๋ณ€๊ฒฝ์€ ๋ถˆ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค." (400 BAD_REQUEST) -``` - -#### AS-007: ์ƒํ’ˆ ์‚ญ์ œ (Admin) -``` -Given: X-Loopers-Ldap: loopers.admin ํ—ค๋” -When: DELETE /api-admin/v1/products/{productId} ์š”์ฒญ -Then: - - ์ƒํ’ˆ Soft Delete - - ์ข‹์•„์š” Cascade Hard Delete -``` - ---- - -## 4. API ๋ช…์„ธ - -### 4.1 ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž API - -| Method | Endpoint | ์„ค๋ช… | -|--------|----------|------| -| GET | /api/v1/brands/{brandId} | ๋ธŒ๋žœ๋“œ ์ •๋ณด ์กฐํšŒ | -| GET | /api/v1/products | ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ | -| GET | /api/v1/products/{productId} | ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ | -| POST | /api/v1/products/{productId}/likes | ์ข‹์•„์š” ๋“ฑ๋ก | -| DELETE | /api/v1/products/{productId}/likes | ์ข‹์•„์š” ์ทจ์†Œ | -| GET | /api/v1/users/{userId}/likes | ๋‚ด๊ฐ€ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ ๋ชฉ๋ก | -| POST | /api/v1/orders | ์ฃผ๋ฌธ ์ƒ์„ฑ | -| GET | /api/v1/orders | ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ | -| GET | /api/v1/orders/{orderId} | ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ | - -### 4.2 ์–ด๋“œ๋ฏผ API - -| Method | Endpoint | ์„ค๋ช… | -|--------|----------|------| -| GET | /api-admin/v1/brands | ๋ธŒ๋žœ๋“œ ๋ชฉ๋ก ์กฐํšŒ | -| GET | /api-admin/v1/brands/{brandId} | ๋ธŒ๋žœ๋“œ ์ƒ์„ธ ์กฐํšŒ | -| POST | /api-admin/v1/brands | ๋ธŒ๋žœ๋“œ ๋“ฑ๋ก | -| PUT | /api-admin/v1/brands/{brandId} | ๋ธŒ๋žœ๋“œ ์ˆ˜์ • | -| DELETE | /api-admin/v1/brands/{brandId} | ๋ธŒ๋žœ๋“œ ์‚ญ์ œ | -| GET | /api-admin/v1/products | ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ | -| GET | /api-admin/v1/products/{productId} | ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ | -| POST | /api-admin/v1/products | ์ƒํ’ˆ ๋“ฑ๋ก | -| PUT | /api-admin/v1/products/{productId} | ์ƒํ’ˆ ์ˆ˜์ • | -| DELETE | /api-admin/v1/products/{productId} | ์ƒํ’ˆ ์‚ญ์ œ | - ---- - -## 5. ์ธ์ฆ ์ฒด๊ณ„ - -### 5.1 ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž ์ธ์ฆ -``` -Headers: - X-Loopers-LoginId: {loginId} - X-Loopers-LoginPw: {password} - -์ฒ˜๋ฆฌ: AuthenticatedUserArgumentResolver -๋Œ€์ƒ: /api/v1/** ์—”๋“œํฌ์ธํŠธ -``` - -### 5.2 ์–ด๋“œ๋ฏผ ์ธ์ฆ -``` -Headers: - X-Loopers-Ldap: loopers.admin - -์ฒ˜๋ฆฌ: AdminAuthInterceptor + AdminUserArgumentResolver -๋Œ€์ƒ: /api-admin/v1/** ์—”๋“œํฌ์ธํŠธ -``` - ---- - -## 6. ์—๋Ÿฌ ํƒ€์ž… ์ •์˜ - -```java -// ErrorType.java์— ์ถ”๊ฐ€ํ•  ํƒ€์ž… -BRAND_NOT_FOUND(HttpStatus.NOT_FOUND, "BRAND_NOT_FOUND", "์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ธŒ๋žœ๋“œ์ž…๋‹ˆ๋‹ค."), -BRAND_ALREADY_EXISTS(HttpStatus.CONFLICT, "BRAND_ALREADY_EXISTS", "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ธŒ๋žœ๋“œ๋ช…์ž…๋‹ˆ๋‹ค."), -BRAND_DELETED(HttpStatus.BAD_REQUEST, "BRAND_DELETED", "์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ์ž…๋‹ˆ๋‹ค."), -PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "PRODUCT_NOT_FOUND", "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค."), -PRODUCT_DELETED(HttpStatus.BAD_REQUEST, "PRODUCT_DELETED", "์‚ญ์ œ๋œ ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค."), -INSUFFICIENT_STOCK(HttpStatus.BAD_REQUEST, "INSUFFICIENT_STOCK", "์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."), -ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "ORDER_NOT_FOUND", "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ฃผ๋ฌธ์ž…๋‹ˆ๋‹ค."), -ORDER_ACCESS_DENIED(HttpStatus.FORBIDDEN, "ORDER_ACCESS_DENIED", "์ฃผ๋ฌธ ์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."), -ADMIN_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "ADMIN_UNAUTHORIZED", "๊ด€๋ฆฌ์ž ๊ถŒํ•œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."), -BRAND_CHANGE_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "BRAND_CHANGE_NOT_ALLOWED", "๋ธŒ๋žœ๋“œ ๋ณ€๊ฒฝ์€ ๋ถˆ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค."); -``` - ---- - -## 7. API ์‘๋‹ต ํ˜•์‹ - -### 7.1 ์„ฑ๊ณต ์‘๋‹ต -```json -{ - "meta": { - "result": "SUCCESS", - "errorCode": null, - "message": null - }, - "data": { ... } -} -``` - -### 7.2 ์‹คํŒจ ์‘๋‹ต -```json -{ - "meta": { - "result": "FAIL", - "errorCode": "PRODUCT_NOT_FOUND", - "message": "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค." - }, - "data": null -} -``` diff --git a/.docs/design/02-sequence-diagrams.md b/.docs/design/02-sequence-diagrams.md deleted file mode 100644 index faa32ccc6..000000000 --- a/.docs/design/02-sequence-diagrams.md +++ /dev/null @@ -1,437 +0,0 @@ -# ์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ - -## ๋‹ค์ด์–ด๊ทธ๋žจ ๋ชฉ์  -์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ์„ ํ†ตํ•ด ๋‹ค์Œ์„ ๊ฒ€์ฆํ•œ๋‹ค: -- ์ฑ…์ž„ ๋ถ„๋ฆฌ: ๊ฐ ๊ฐ์ฒด๊ฐ€ ๋งก์€ ์—ญํ• ์ด ๋ช…ํ™•ํ•œ๊ฐ€ -- ํ˜ธ์ถœ ์ˆœ์„œ: ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์˜ ํ๋ฆ„์ด ์˜ฌ๋ฐ”๋ฅธ๊ฐ€ -- ํŠธ๋žœ์žญ์…˜ ๊ฒฝ๊ณ„: ์›์ž์„ฑ์ด ๋ณด์žฅ๋˜๋Š” ๋ฒ”์œ„๊ฐ€ ์ ์ ˆํ•œ๊ฐ€ - ---- - -## 1. ์ฃผ๋ฌธ ์ƒ์„ฑ ์‹œํ€€์Šค - -### 1.1 ์ •์ƒ ํ๋ฆ„ (๋‹ค๊ฑด ์ฃผ๋ฌธ) - -**๋ชฉ์ **: ์žฌ๊ณ  ๊ฒ€์ฆ, ๋น„๊ด€์  ๋ฝ, ํŠธ๋žœ์žญ์…˜ ๊ฒฝ๊ณ„ ํ™•์ธ - -```mermaid -sequenceDiagram - autonumber - participant C as Client - participant Ctrl as OrderV1Controller - participant F as OrderFacade - participant OS as OrderService - participant PS as ProductService - participant OR as OrderRepository - participant PR as ProductRepository - participant DB as Database - - C->>+Ctrl: POST /api/v1/orders - Note over C,Ctrl: Headers: X-Loopers-LoginId, X-Loopers-LoginPw - Note over C,Ctrl: Body: { items: [{productId, quantity}] } - - Ctrl->>+F: createOrder(userId, items) - - F->>+OS: createOrder(userId, items) - - Note over OS: @Transactional ์‹œ์ž‘ - - loop ๊ฐ ์ฃผ๋ฌธ ํ•ญ๋ชฉ์— ๋Œ€ํ•ด - OS->>+PS: getProductForOrder(productId) - PS->>+PR: findByIdWithLock(productId) - PR->>+DB: SELECT ... FOR UPDATE - DB-->>-PR: Product - PR-->>-PS: Product - PS-->>-OS: Product - - OS->>OS: ์žฌ๊ณ  ๊ฒ€์ฆ (stock >= quantity) - - alt ์žฌ๊ณ  ๋ถ€์กฑ - OS-->>F: throw CoreException(INSUFFICIENT_STOCK) - Note over OS,DB: ์ „์ฒด ๋กค๋ฐฑ - end - - OS->>+PS: decreaseStock(productId, quantity) - PS->>+PR: save(product) - PR->>+DB: UPDATE products SET stock = stock - quantity - DB-->>-PR: OK - PR-->>-PS: Product - PS-->>-OS: void - end - - OS->>OS: totalPrice ๊ณ„์‚ฐ - OS->>OS: Order ์—”ํ‹ฐํ‹ฐ ์ƒ์„ฑ - OS->>OS: OrderItem ์—”ํ‹ฐํ‹ฐ๋“ค ์ƒ์„ฑ (๊ฐ€๊ฒฉ ์Šค๋ƒ…์ƒท) - - OS->>+OR: save(order) - OR->>+DB: INSERT orders, order_items - DB-->>-OR: Order (with ID) - OR-->>-OS: Order - - Note over OS: @Transactional ์ปค๋ฐ‹ - - OS-->>-F: Order - F->>F: OrderInfo.from(order) - F-->>-Ctrl: OrderInfo - - Ctrl->>Ctrl: OrderV1Dto.CreateResponse.from(info) - Ctrl-->>-C: 201 Created + { orderId, totalPrice } -``` - -**ํ•ต์‹ฌ ํฌ์ธํŠธ:** -- `SELECT ... FOR UPDATE`๋กœ ๋น„๊ด€์  ๋ฝ ํš๋“ โ†’ ๋™์‹œ ์ฃผ๋ฌธ ์‹œ ์žฌ๊ณ  ๊ฒฝ์Ÿ ๋ฐฉ์ง€ -- ๋ชจ๋“  ์ƒํ’ˆ ๊ฒ€์ฆ ํ›„ ์ฐจ๊ฐ โ†’ ํ•˜๋‚˜๋ผ๋„ ์‹คํŒจ ์‹œ ์ „์ฒด ๋กค๋ฐฑ -- OrderItem์— ๊ฐ€๊ฒฉ ์Šค๋ƒ…์ƒท ์ €์žฅ โ†’ ์ƒํ’ˆ ๊ฐ€๊ฒฉ ๋ณ€๊ฒฝ ์‹œ์—๋„ ์ฃผ๋ฌธ ๊ฐ€๊ฒฉ ์œ ์ง€ - ---- - -### 1.2 ์žฌ๊ณ  ๋ถ€์กฑ ์‹คํŒจ ํ๋ฆ„ - -**๋ชฉ์ **: ์ „์ฒด ์‹คํŒจ ์ •์ฑ…, ๋กค๋ฐฑ ๋™์ž‘ ํ™•์ธ - -```mermaid -sequenceDiagram - autonumber - participant C as Client - participant Ctrl as OrderV1Controller - participant F as OrderFacade - participant OS as OrderService - participant PS as ProductService - participant PR as ProductRepository - participant DB as Database - - C->>+Ctrl: POST /api/v1/orders - Note over C,Ctrl: items: [{productId: 1, qty: 10}, {productId: 2, qty: 5}] - - Ctrl->>+F: createOrder(userId, items) - F->>+OS: createOrder(userId, items) - - Note over OS: @Transactional ์‹œ์ž‘ - - OS->>+PS: getProductForOrder(productId: 1) - PS->>+PR: findByIdWithLock(productId: 1) - PR->>+DB: SELECT ... FOR UPDATE - DB-->>-PR: Product (stock: 10) - PR-->>-PS: Product - PS-->>-OS: Product - OS->>OS: ์žฌ๊ณ  ๊ฒ€์ฆ ํ†ต๊ณผ (10 >= 10) - OS->>PS: decreaseStock(1, 10) - - OS->>+PS: getProductForOrder(productId: 2) - PS->>+PR: findByIdWithLock(productId: 2) - PR->>+DB: SELECT ... FOR UPDATE - DB-->>-PR: Product (stock: 3) - PR-->>-PS: Product - PS-->>-OS: Product - - OS->>OS: ์žฌ๊ณ  ๊ฒ€์ฆ ์‹คํŒจ (3 < 5) - - OS-->>-F: throw CoreException(INSUFFICIENT_STOCK) - Note over OS,DB: ์ „์ฒด ๋กค๋ฐฑ (์ƒํ’ˆ1 ์žฌ๊ณ  ๋ณต๊ตฌ) - - F-->>-Ctrl: throw CoreException - Ctrl-->>-C: 400 Bad Request + INSUFFICIENT_STOCK -``` - -**ํ•ต์‹ฌ ํฌ์ธํŠธ:** -- ๋‘ ๋ฒˆ์งธ ์ƒํ’ˆ ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ ์ฒซ ๋ฒˆ์งธ ์ƒํ’ˆ ์žฌ๊ณ  ์ฐจ๊ฐ๋„ ๋กค๋ฐฑ -- ํŠธ๋žœ์žญ์…˜ ๋‹จ์œ„๋กœ ์›์ž์„ฑ ๋ณด์žฅ - ---- - -## 2. ์ข‹์•„์š” ๋“ฑ๋ก ์‹œํ€€์Šค (ํ† ๊ธ€ ๋ฐฉ์‹) - -### 2.1 ์‹ ๊ทœ ์ข‹์•„์š” ๋“ฑ๋ก - -**๋ชฉ์ **: ์ƒํ’ˆ ์œ ํšจ์„ฑ ๊ฒ€์ฆ ๋ฐ ์ข‹์•„์š” ๋“ฑ๋ก ํ™•์ธ - -```mermaid -sequenceDiagram - autonumber - participant C as Client - participant Ctrl as ProductLikeV1Controller - participant F as ProductLikeFacade - participant LS as ProductLikeService - participant PS as ProductService - participant LR as ProductLikeRepository - participant DB as Database - - C->>+Ctrl: POST /api/v1/products/{productId}/likes - Note over C,Ctrl: Headers: X-Loopers-LoginId, X-Loopers-LoginPw - - Ctrl->>+F: like(userId, productId) - - F->>+LS: like(userId, productId) - - LS->>+PS: getProduct(productId) - PS-->>-LS: Product - - LS->>LS: ์ƒํ’ˆ ์‚ญ์ œ ์—ฌ๋ถ€ ๊ฒ€์ฆ - - LS->>+LR: findByUserIdAndProductId(userId, productId) - LR->>+DB: SELECT FROM product_likes - DB-->>-LR: null (๋ฏธ์กด์žฌ) - LR-->>-LS: Optional (empty) - - LS->>LS: ProductLike ์—”ํ‹ฐํ‹ฐ ์ƒ์„ฑ (์‹ ๊ทœ ๋“ฑ๋ก) - - LS->>+LR: save(productLike) - LR->>+DB: INSERT product_likes - DB-->>-LR: ProductLike - LR-->>-LS: ProductLike - - LS-->>-F: ProductLike - F-->>-Ctrl: void - - Ctrl-->>-C: 200 OK + { message: "์ข‹์•„์š”๊ฐ€ ๋“ฑ๋ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค." } -``` -**ํ•ต์‹ฌ ํฌ์ธํŠธ:** - -- *์ข‹์•„์š” ๊ธฐ๋Šฅ ์„ค๊ณ„ ์‹œ ์•„๋ž˜์™€ ๊ฐ™์€ ๋‘ ๊ฐ€์ง€ ์„ ํƒ์ง€๊ฐ€ ์žˆ์—ˆ๋‹ค. ์ •๋ ฌ ์ฟผ๋ฆฌ๋ฅผ ์œ„ํ•ด Product ๋‚ด๋ถ€์— ์ข‹์•„์š” ํ•„๋“œ๋ฅผ ๋‘๋Š” ๋ฐฉ๋ฒ•๊ณผ ์ข‹์•„์š” ํ…Œ์ด๋ธ”์„ ๋”ฐ๋กœ ๋‘๋Š” ์„ ํƒ์ง€ ์ค‘ ์ •ํ•ฉ์„ฑ์„ ๋†’์ด๋Š” ๋ฐฉ์‹์„ ์„ ํƒํ–ˆ๋‹ค.* - -*1. ๋น„์ •๊ทœํ™”: Product์— likeCount ํ•„๋“œ๋ฅผ ๋‘๊ณ  ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ ์‹œ ๋™๊ธฐ ์—…๋ฐ์ดํŠธ. ์ •๋ ฌ ์ฟผ๋ฆฌ ์„ฑ๋Šฅ ์šฐ์ˆ˜* - -*2. ์‹ค์‹œ๊ฐ„ ์ง‘๊ณ„(์ ์šฉ): ์ข‹์•„์š” ํ…Œ์ด๋ธ”์—์„œ COUNT ์ง‘๊ณ„. ์ •ํ•ฉ์„ฑ ๋†’์œผ๋‚˜ ์ •๋ ฌ ์‹œ ์ฟผ๋ฆฌ ๋น„์šฉ ์ฆ๊ฐ€* - - - - -- ์ƒํ’ˆ ์กด์žฌ ๋ฐ ์‚ญ์ œ ์—ฌ๋ถ€ ๋จผ์ € ๊ฒ€์ฆ -- ๊ธฐ์กด ์ข‹์•„์š”๊ฐ€ ์—†์œผ๋ฉด ์‹ ๊ทœ ๋“ฑ๋ก - ---- - -### 2.2 ๊ธฐ์กด ์ข‹์•„์š” ์กด์žฌ ์‹œ (ํ† ๊ธ€ - ์ทจ์†Œ ์ฒ˜๋ฆฌ) - -**๋ชฉ์ **: ํ† ๊ธ€ ๋ฐฉ์‹ ๋™์ž‘ ํ™•์ธ - ์ด๋ฏธ ์ข‹์•„์š”๊ฐ€ ์žˆ์œผ๋ฉด ์ทจ์†Œ - -```mermaid -sequenceDiagram - autonumber - participant C as Client - participant Ctrl as ProductLikeV1Controller - participant F as ProductLikeFacade - participant LS as ProductLikeService - participant PS as ProductService - participant LR as ProductLikeRepository - participant DB as Database - - C->>+Ctrl: POST /api/v1/products/{productId}/likes - - Ctrl->>+F: like(userId, productId) - F->>+LS: like(userId, productId) - - LS->>+PS: getProduct(productId) - PS-->>-LS: Product - - LS->>+LR: findByUserIdAndProductId(userId, productId) - LR->>+DB: SELECT FROM product_likes - DB-->>-LR: ProductLike (์กด์žฌ) - LR-->>-LS: Optional (present) - - Note over LS: ์ด๋ฏธ ์กด์žฌํ•˜๋ฏ€๋กœ ์ข‹์•„์š” ์ทจ์†Œ (ํ† ๊ธ€) - - LS->>+LR: delete(productLike) - LR->>+DB: DELETE FROM product_likes - DB-->>-LR: OK - LR-->>-LS: void - - LS-->>-F: void - F-->>-Ctrl: void - - Ctrl-->>-C: 200 OK + { message: "์ข‹์•„์š”๊ฐ€ ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." } -``` - -**ํ•ต์‹ฌ ํฌ์ธํŠธ:** -- ์ข‹์•„์š”๊ฐ€ ์ด๋ฏธ ์กด์žฌํ•˜๋ฉด ์‚ญ์ œ (ํ† ๊ธ€ ๋ฐฉ์‹) -- POST ์š”์ฒญ ํ•œ ๋ฒˆ์œผ๋กœ ๋“ฑ๋ก/์ทจ์†Œ ๋ชจ๋‘ ์ฒ˜๋ฆฌ - ---- - -## 3. ๋ธŒ๋žœ๋“œ ์‚ญ์ œ ์‹œํ€€์Šค (Cascade ์‚ญ์ œ) - -**๋ชฉ์ **: Cascade ์‚ญ์ œ ์ˆœ์„œ, ํŠธ๋žœ์žญ์…˜ ๊ฒฝ๊ณ„ ํ™•์ธ - -```mermaid -sequenceDiagram - autonumber - participant C as Admin Client - participant Int as AdminAuthInterceptor - participant Ctrl as BrandAdminV1Controller - participant F as BrandFacade - participant BS as BrandService - participant PS as ProductService - participant LS as ProductLikeService - participant BR as BrandRepository - participant PR as ProductRepository - participant LR as ProductLikeRepository - participant DB as Database - - C->>+Int: DELETE /api-admin/v1/brands/{brandId} - Note over C,Int: Headers: X-Loopers-Ldap: loopers.admin - - Int->>Int: Admin ๊ถŒํ•œ ๊ฒ€์ฆ - Int->>+Ctrl: ์š”์ฒญ ์ „๋‹ฌ - - Ctrl->>+F: deleteBrand(brandId) - F->>+BS: deleteBrand(brandId) - - Note over BS: @Transactional ์‹œ์ž‘ - - BS->>+BR: findById(brandId) - BR->>+DB: SELECT FROM brands - DB-->>-BR: Brand - BR-->>-BS: Brand - - alt ๋ธŒ๋žœ๋“œ ์—†์Œ - BS-->>F: throw CoreException(BRAND_NOT_FOUND) - end - - BS->>+PS: getProductsByBrandId(brandId) - PS->>+PR: findAllByBrandId(brandId) - PR->>+DB: SELECT FROM products WHERE brand_id = ? - DB-->>-PR: List - PR-->>-PS: List - PS-->>-BS: List - - loop ๊ฐ ์ƒํ’ˆ์— ๋Œ€ํ•ด - BS->>+LS: deleteAllByProductId(productId) - LS->>+LR: deleteAllByProductId(productId) - LR->>+DB: DELETE FROM product_likes WHERE product_id = ? - DB-->>-LR: OK - LR-->>-LS: void - LS-->>-BS: void - - BS->>+PS: deleteProduct(productId) - PS->>PS: product.delete() - PS->>+PR: save(product) - PR->>+DB: UPDATE products SET deleted_at = NOW() - DB-->>-PR: OK - PR-->>-PS: Product - PS-->>-BS: void - end - - BS->>BS: brand.delete() - BS->>+BR: save(brand) - BR->>+DB: UPDATE brands SET deleted_at = NOW() - DB-->>-BR: OK - BR-->>-BS: Brand - - Note over BS: @Transactional ์ปค๋ฐ‹ - - BS-->>-F: void - F-->>-Ctrl: void - - Ctrl-->>-C: 200 OK + { message: "๋ธŒ๋žœ๋“œ๊ฐ€ ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." } -``` - -**ํ•ต์‹ฌ ํฌ์ธํŠธ:** -- ์‚ญ์ œ ์ˆœ์„œ: ์ข‹์•„์š”(Hard) โ†’ ์ƒํ’ˆ(Soft) โ†’ ๋ธŒ๋žœ๋“œ(Soft) -- ๋‹จ์ผ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์›์ž์„ฑ ๋ณด์žฅ -- ์ข‹์•„์š”๋Š” Hard Delete, ์ƒํ’ˆ/๋ธŒ๋žœ๋“œ๋Š” Soft Delete - ---- - -## 4. ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ ์‹œํ€€์Šค (์ข‹์•„์š” ์ˆ˜ ํฌํ•จ) - -**๋ชฉ์ **: ์ข‹์•„์š” ์‹ค์‹œ๊ฐ„ ์ง‘๊ณ„, ์ •๋ ฌ ์˜ต์…˜ ์ฒ˜๋ฆฌ ํ™•์ธ - -```mermaid -sequenceDiagram - autonumber - participant C as Client - participant Ctrl as ProductV1Controller - participant F as ProductFacade - participant PS as ProductService - participant LS as ProductLikeService - participant PR as ProductRepository - participant LR as ProductLikeRepository - participant DB as Database - - C->>+Ctrl: GET /api/v1/products?sort=like_desc&brandId=1&page=0&size=20 - Note over C,Ctrl: Headers: X-Loopers-LoginId, X-Loopers-LoginPw - - Ctrl->>+F: getProducts(sort, brandId, pageable) - - F->>+PS: getProducts(sort, brandId, pageable) - - alt sort = like_desc (์ข‹์•„์š” ๋งŽ์€์ˆœ) - PS->>+PR: findAllOrderByLikeCountDesc(brandId, pageable) - PR->>+DB: SELECT p.*, COUNT(pl.id) as like_count
FROM products p
LEFT JOIN product_likes pl
GROUP BY p.id
ORDER BY like_count DESC - DB-->>-PR: Page - PR-->>-PS: Page - else sort = latest | price_asc - PS->>+PR: findAll(brandId, pageable, sort) - PR->>+DB: SELECT FROM products WHERE ... - DB-->>-PR: Page - PR-->>-PS: Page - - PS->>+LS: getLikeCounts(productIds) - LS->>+LR: countByProductIdIn(productIds) - LR->>+DB: SELECT product_id, COUNT(*)
FROM product_likes
WHERE product_id IN (...)
GROUP BY product_id - DB-->>-LR: Map - LR-->>-LS: Map - LS-->>-PS: Map - end - - PS-->>-F: Page - - F->>F: List.from(products) - F-->>-Ctrl: Page - - Ctrl->>Ctrl: ProductV1Dto.ListResponse.from(page) - Ctrl-->>-C: 200 OK + { products: [...], pageInfo: {...} } -``` - -**ํ•ต์‹ฌ ํฌ์ธํŠธ:** -- `like_desc` ์ •๋ ฌ ์‹œ JOIN + COUNT๋กœ ํ•œ ๋ฒˆ์— ์กฐํšŒ -- ๋‹ค๋ฅธ ์ •๋ ฌ ์‹œ ์ƒํ’ˆ ์กฐํšŒ ํ›„ ์ข‹์•„์š” ์ˆ˜ ๋ณ„๋„ ์กฐํšŒ (N+1 ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด IN ์ฟผ๋ฆฌ ์‚ฌ์šฉ) -- *์ฟ ํŒก๊ณผ ์˜ค๋Š˜์˜ ์ง‘์—์„œ ํ•˜๋“ฏ์ด, ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ ์‹œ ๊ธฐ๊ฐ„(startAt, endAt)์œผ๋กœ ์กฐํšŒํ•˜๋Š” ๋ฐฉ์•ˆ์„ ๊ฒ€ํ† ํ•ด ๋ณด์•˜์œผ๋‚˜ ์„ค๊ณ„์— ์ง‘์ค‘ํ•˜๊ธฐ ์œ„ํ•ด ๋„ฃ์ง€ ์•Š์•˜๋‹ค.* ---- - -## 5. ์–ด๋“œ๋ฏผ ์ธ์ฆ ํ๋ฆ„ - -**๋ชฉ์ **: Interceptor + ArgumentResolver ์กฐํ•ฉ ํ™•์ธ - -```mermaid -sequenceDiagram - autonumber - participant C as Admin Client - participant F as Filter Chain - participant Int as AdminAuthInterceptor - participant AR as AdminUserArgumentResolver - participant Ctrl as AdminController - - C->>+F: Request to /api-admin/v1/** - Note over C,F: Headers: X-Loopers-Ldap: loopers.admin - - F->>+Int: preHandle() - - Int->>Int: Extract X-Loopers-Ldap header - - alt Header missing - Int-->>C: 401 Unauthorized
ADMIN_UNAUTHORIZED - else Header != "loopers.admin" - Int-->>C: 401 Unauthorized
ADMIN_UNAUTHORIZED - else Header = "loopers.admin" - Int-->>-F: true (continue) - end - - F->>+AR: resolveArgument() - Note over AR: AdminUser ํŒŒ๋ผ๋ฏธํ„ฐ ์กด์žฌ ์‹œ - AR->>AR: Create AdminUser object - AR-->>-F: AdminUser - - F->>+Ctrl: Controller method - Ctrl-->>-F: Response - F-->>-C: Response -``` - -**ํ•ต์‹ฌ ํฌ์ธํŠธ:** -- Interceptor๊ฐ€ 1์ฐจ ๋ฐฉ์–ด์„  (ํ—ค๋” ๋ˆ„๋ฝ/๋ถˆ์ผ์น˜ ์‹œ 401) -- ArgumentResolver๋Š” ์ปจํŠธ๋กค๋Ÿฌ์— AdminUser ๊ฐ์ฒด ์ฃผ์ž… -- ์ด์ค‘ ์•ˆ์ „์žฅ์น˜๋กœ ๋ณด์•ˆ์„ฑ ๊ฐ•ํ™” -- *Interceptor ๋ฐฉ์‹์€ ์ปจํŠธ๋กค๋Ÿฌ์—์„œ ์–ด๋“œ๋ฏผ ์ •๋ณด ์ ‘๊ทผ ์‹œ Request์—์„œ ๋‹ค์‹œ ์ถ”์ถœ ํ•„์š”ํ•˜๊ณ  ํŠน์ • ๋ฉ”์„œ๋“œ๋งŒ ์˜ˆ์™ธ ์ฒ˜๋ฆฌํ•˜๋ ค๋ฉด ์ถ”๊ฐ€ ๋กœ์ง ํ•„์š”ํ•œ ๋ฌธ์ œ* -- *ArgumentResolver๋Š” ๋ชจ๋“  ๋ฉ”์„œ๋“œ์— @AdminAuth ํŒŒ๋ผ๋ฏธํ„ฐ ์ถ”๊ฐ€ ํ•„์š”ํ•˜๊ณ , ์‹ค์ˆ˜๋กœ ์–ด๋…ธํ…Œ์ด์…˜์„ ๋ˆ„๋ฝํ•˜๋ฉด ๋ณด์•ˆ ์œ„ํ—˜ํ•œ ๋ฌธ์ œ* - -*-> Interceptor + ArgumentResolver ์กฐํ•ฉ์œผ๋กœ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ–ˆ๋‹ค.* \ No newline at end of file diff --git a/.docs/design/03-class-diagram.md b/.docs/design/03-class-diagram.md deleted file mode 100644 index 09f1c4596..000000000 --- a/.docs/design/03-class-diagram.md +++ /dev/null @@ -1,700 +0,0 @@ -# ํด๋ž˜์Šค ๋‹ค์ด์–ด๊ทธ๋žจ - -## ๋‹ค์ด์–ด๊ทธ๋žจ ๋ชฉ์  -ํด๋ž˜์Šค ๋‹ค์ด์–ด๊ทธ๋žจ์„ ํ†ตํ•ด ๋‹ค์Œ์„ ๊ฒ€์ฆํ•œ๋‹ค: -- ๋„๋ฉ”์ธ ์ฑ…์ž„: ๊ฐ ๋„๋ฉ”์ธ์˜ ์—ญํ• ์ด ๋ช…ํ™•ํ•œ๊ฐ€ -- ์˜์กด ๋ฐฉํ–ฅ: ์ƒ์œ„ ๊ณ„์ธต์ด ํ•˜์œ„ ๊ณ„์ธต์—๋งŒ ์˜์กดํ•˜๋Š”๊ฐ€ -- ์‘์ง‘๋„: ๊ด€๋ จ ๊ธฐ๋Šฅ์ด ์ ์ ˆํžˆ ๊ทธ๋ฃนํ™”๋˜์–ด ์žˆ๋Š”๊ฐ€ - ---- - -## 1. ์ „์ฒด ๊ณ„์ธต ๊ตฌ์กฐ ๊ฐœ์š” - -```mermaid -classDiagram - direction TB - - namespace Interfaces { - class Controller - class ApiSpec - class Dto - class ArgumentResolver - class Interceptor - } - - namespace Application { - class Facade - class Info - } - - namespace Domain { - class Entity - class Service - class Repository - } - - namespace Infrastructure { - class RepositoryImpl - class JpaRepository - } - - Controller --> Facade : uses - Controller --> Dto : uses - Facade --> Service : uses - Facade --> Info : returns - Service --> Repository : uses - Service --> Entity : uses - RepositoryImpl ..|> Repository : implements - RepositoryImpl --> JpaRepository : uses -``` - -**๊ณ„์ธต๋ณ„ ์ฑ…์ž„:** -- **Interfaces**: HTTP ์š”์ฒญ/์‘๋‹ต ์ฒ˜๋ฆฌ, DTO ๋ณ€ํ™˜, ์ธ์ฆ ์ฒ˜๋ฆฌ -- **Application**: ์œ ์Šค์ผ€์ด์Šค ์กฐ์œจ, ๋„๋ฉ”์ธ โ†” ํ”„๋ ˆ์  ํ…Œ์ด์…˜ ๋ณ€ํ™˜ -- **Domain**: ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง, ์—”ํ‹ฐํ‹ฐ ๊ฒ€์ฆ, ๋„๋ฉ”์ธ ๊ทœ์น™ -- **Infrastructure**: ๋ฐ์ดํ„ฐ ์ ‘๊ทผ, ์™ธ๋ถ€ ์‹œ์Šคํ…œ ์—ฐ๋™ - ---- - -## 2. Brand ๋„๋ฉ”์ธ ํด๋ž˜์Šค - -```mermaid -classDiagram - direction TB - - %% Interfaces Layer - class BrandV1Controller { - -BrandFacade brandFacade - +getBrand(Long brandId) ApiResponse~BrandDto.Response~ - } - - class BrandAdminV1Controller { - -BrandFacade brandFacade - +getBrands(Pageable) ApiResponse~Page~ - +getBrand(Long brandId) ApiResponse~BrandDto.Response~ - +createBrand(CreateRequest) ApiResponse~CreateResponse~ - +updateBrand(Long, UpdateRequest) ApiResponse~Response~ - +deleteBrand(Long brandId) ApiResponse~Void~ - } - - class BrandV1Dto { - <> - } - class Response { - <> - +Long id - +String name - +String description - +String logoUrl - +from(BrandInfo) Response - } - class CreateRequest { - <> - +String name - +String description - +String logoUrl - } - class CreateResponse { - <> - +Long brandId - } - class UpdateRequest { - <> - +String name - +String description - +String logoUrl - } - - %% Application Layer - class BrandFacade { - -BrandService brandService - +getBrand(Long brandId) BrandInfo - +getBrands(Pageable) Page~BrandInfo~ - +createBrand(String, String, String) BrandInfo - +updateBrand(Long, String, String, String) BrandInfo - +deleteBrand(Long brandId) void - } - - class BrandInfo { - <> - +Long id - +String name - +String description - +String logoUrl - +from(Brand) BrandInfo - } - - %% Domain Layer - class Brand { - -String name - -String description - -String logoUrl - +Brand(String, String, String) - +update(String, String, String) void - #guard() void - } - - class BrandService { - -BrandRepository brandRepository - +getBrand(Long brandId) Brand - +getBrands(Pageable) Page~Brand~ - +createBrand(String, String, String) Brand - +updateBrand(Long, String, String, String) Brand - +deleteBrand(Long brandId) void - } - - class BrandRepository { - <> - +findById(Long) Optional~Brand~ - +findAll(Pageable) Page~Brand~ - +save(Brand) Brand - +existsByName(String) boolean - } - - %% Infrastructure Layer - class BrandRepositoryImpl { - -BrandJpaRepository brandJpaRepository - } - - class BrandJpaRepository { - <> - +findByName(String) Optional~Brand~ - +existsByNameAndDeletedAtIsNull(String) boolean - } - - %% Relationships - BrandV1Controller --> BrandFacade - BrandAdminV1Controller --> BrandFacade - BrandFacade --> BrandService - BrandFacade --> BrandInfo - BrandService --> BrandRepository - BrandService --> Brand - BrandRepositoryImpl ..|> BrandRepository - BrandRepositoryImpl --> BrandJpaRepository - Brand --|> BaseEntity - - BrandV1Dto ..> Response - BrandV1Dto ..> CreateRequest - BrandV1Dto ..> CreateResponse - BrandV1Dto ..> UpdateRequest -``` - ---- - -## 3. Product ๋„๋ฉ”์ธ ํด๋ž˜์Šค - -```mermaid -classDiagram - direction TB - - %% Interfaces Layer - class ProductV1Controller { - -ProductFacade productFacade - +getProducts(String sort, Long brandId, Pageable) ApiResponse~Page~ - +getProduct(Long productId) ApiResponse~DetailResponse~ - } - - class ProductAdminV1Controller { - -ProductFacade productFacade - +getProducts(Pageable, Long brandId) ApiResponse~Page~ - +getProduct(Long productId) ApiResponse~AdminDetailResponse~ - +createProduct(CreateRequest) ApiResponse~CreateResponse~ - +updateProduct(Long, UpdateRequest) ApiResponse~Response~ - +deleteProduct(Long productId) ApiResponse~Void~ - } - - %% Application Layer - class ProductFacade { - -ProductService productService - -ProductLikeService productLikeService - -BrandService brandService - +getProducts(String, Long, Pageable) Page~ProductInfo~ - +getProduct(Long productId) ProductInfo - +createProduct(Long, String, String, Long, Integer, String) ProductInfo - +updateProduct(Long, String, String, Long, Integer, String) ProductInfo - +deleteProduct(Long productId) void - } - - class ProductInfo { - <> - +Long id - +Long brandId - +String brandName - +String name - +String description - +Long price - +Integer stock - +String imageUrl - +Long likeCount - +from(Product, Long) ProductInfo - } - - %% Domain Layer - class Product { - -Long brandId - -String name - -String description - -Long price - -Integer stock - -String imageUrl - +Product(Long, String, String, Long, Integer, String) - +update(String, String, Long, Integer, String) void - +decreaseStock(int quantity) void - +increaseStock(int quantity) void - #guard() void - } - - class ProductService { - -ProductRepository productRepository - -BrandRepository brandRepository - +getProduct(Long productId) Product - +getProductForOrder(Long productId) Product - +getProducts(String, Long, Pageable) Page~Product~ - +getProductsByBrandId(Long brandId) List~Product~ - +createProduct(...) Product - +updateProduct(...) Product - +deleteProduct(Long productId) void - +decreaseStock(Long productId, int quantity) void - } - - class ProductRepository { - <> - +findById(Long) Optional~Product~ - +findByIdWithLock(Long) Optional~Product~ - +findAll(String, Long, Pageable) Page~Product~ - +findAllByBrandId(Long) List~Product~ - +findAllOrderByLikeCountDesc(Long, Pageable) Page~Object[]~ - +save(Product) Product - } - - %% Infrastructure Layer - class ProductRepositoryImpl { - -ProductJpaRepository productJpaRepository - -JPAQueryFactory queryFactory - } - - class ProductJpaRepository { - <> - } - - %% Relationships - ProductV1Controller --> ProductFacade - ProductAdminV1Controller --> ProductFacade - ProductFacade --> ProductService - ProductFacade --> ProductInfo - ProductService --> ProductRepository - ProductService --> Product - ProductRepositoryImpl ..|> ProductRepository - ProductRepositoryImpl --> ProductJpaRepository - Product --|> BaseEntity -``` - ---- - -## 4. ProductLike ๋„๋ฉ”์ธ ํด๋ž˜์Šค - -```mermaid -classDiagram - direction TB - - %% Interfaces Layer - class ProductLikeV1Controller { - -ProductLikeFacade productLikeFacade - +like(AuthenticatedUser, Long productId) ApiResponse~Void~ - +unlike(AuthenticatedUser, Long productId) ApiResponse~Void~ - +getMyLikes(AuthenticatedUser, Long userId) ApiResponse~List~ - } - - %% Application Layer - class ProductLikeFacade { - -ProductLikeService productLikeService - -ProductService productService - -UserService userService - +like(Long userId, Long productId) void - +unlike(Long userId, Long productId) void - +getMyLikes(Long userId) List~ProductLikeInfo~ - } - - class ProductLikeInfo { - <> - +Long productId - +String productName - +Long price - +String imageUrl - +ZonedDateTime likedAt - } - - %% Domain Layer - class ProductLike { - -Long userId - -Long productId - +ProductLike(Long userId, Long productId) - +getUserId() Long - +getProductId() Long - } - - class ProductLikeService { - -ProductLikeRepository productLikeRepository - +like(Long userId, Long productId) ProductLike - +unlike(Long userId, Long productId) void - +existsByUserIdAndProductId(Long, Long) boolean - +countByProductId(Long productId) Long - +getLikeCounts(List~Long~ productIds) Map~Long_Long~ - +getByUserId(Long userId) List~ProductLike~ - +deleteAllByProductId(Long productId) void - } - - class ProductLikeRepository { - <> - +findByUserIdAndProductId(Long, Long) Optional~ProductLike~ - +existsByUserIdAndProductId(Long, Long) boolean - +countByProductId(Long) Long - +countByProductIdIn(List~Long~) List~Object[]~ - +findAllByUserId(Long) List~ProductLike~ - +deleteByUserIdAndProductId(Long, Long) void - +deleteAllByProductId(Long) void - +save(ProductLike) ProductLike - } - - %% Infrastructure Layer - class ProductLikeRepositoryImpl { - -ProductLikeJpaRepository productLikeJpaRepository - } - - class ProductLikeJpaRepository { - <> - } - - %% Relationships - ProductLikeV1Controller --> ProductLikeFacade - ProductLikeFacade --> ProductLikeService - ProductLikeFacade --> ProductLikeInfo - ProductLikeService --> ProductLikeRepository - ProductLikeService --> ProductLike - ProductLikeRepositoryImpl ..|> ProductLikeRepository - ProductLikeRepositoryImpl --> ProductLikeJpaRepository - ProductLike --|> BaseEntity -``` - ---- - -## 5. Order ๋„๋ฉ”์ธ ํด๋ž˜์Šค - -```mermaid -classDiagram - direction TB - - %% Interfaces Layer - class OrderV1Controller { - -OrderFacade orderFacade - +createOrder(AuthenticatedUser, CreateRequest) ApiResponse~CreateResponse~ - +getOrders(AuthenticatedUser, LocalDate, LocalDate, Pageable) ApiResponse~Page~ - +getOrder(AuthenticatedUser, Long orderId) ApiResponse~DetailResponse~ - } - - class OrderV1Dto { - <> - } - - class CreateRequest { - <> - +List~OrderItemRequest~ items - } - - class OrderItemRequest { - <> - +Long productId - +Integer quantity - } - - class CreateResponse { - <> - +Long orderId - +Long totalPrice - } - - class ListResponse { - <> - +Long orderId - +Long totalPrice - +String status - +ZonedDateTime createdAt - } - - class DetailResponse { - <> - +Long orderId - +Long totalPrice - +String status - +List~OrderItemResponse~ items - +ZonedDateTime createdAt - } - - class OrderItemResponse { - <> - +Long productId - +String productName - +Integer quantity - +Long price - } - - %% Application Layer - class OrderFacade { - -OrderService orderService - -ProductService productService - -UserService userService - +createOrder(Long userId, List~OrderItemRequest~) OrderInfo - +getOrders(Long userId, LocalDate, LocalDate, Pageable) Page~OrderInfo~ - +getOrder(Long userId, Long orderId) OrderInfo - } - - class OrderInfo { - <> - +Long id - +Long userId - +Long totalPrice - +OrderStatus status - +List~OrderItemInfo~ items - +ZonedDateTime createdAt - +from(Order) OrderInfo - } - - class OrderItemInfo { - <> - +Long productId - +String productName - +Integer quantity - +Long price - +from(OrderItem) OrderItemInfo - } - - %% Domain Layer - class Order { - -Long userId - -Long totalPrice - -OrderStatus status - -List~OrderItem~ orderItems - +Order(Long userId) - +addItem(OrderItem item) void - +calculateTotalPrice() void - +complete() void - +cancel() void - } - - class OrderItem { - -Order order - -Long productId - -String productName - -Integer quantity - -Long price - +OrderItem(Long, String, Integer, Long) - +setOrder(Order order) void - +getSubtotal() Long - } - - class OrderStatus { - <> - PENDING - COMPLETED - CANCELLED - } - - class OrderService { - -OrderRepository orderRepository - -ProductService productService - +createOrder(Long userId, List~OrderItemRequest~) Order - +getOrder(Long orderId) Order - +getOrders(Long userId, LocalDate, LocalDate, Pageable) Page~Order~ - +validateOrderAccess(Long userId, Order order) void - } - - class OrderRepository { - <> - +findById(Long) Optional~Order~ - +findByUserId(Long, Pageable) Page~Order~ - +findByUserIdAndCreatedAtBetween(...) Page~Order~ - +save(Order) Order - } - - %% Infrastructure Layer - class OrderRepositoryImpl { - -OrderJpaRepository orderJpaRepository - } - - class OrderJpaRepository { - <> - } - - %% Relationships - OrderV1Controller --> OrderFacade - OrderFacade --> OrderService - OrderFacade --> OrderInfo - OrderService --> OrderRepository - OrderService --> Order - Order --> OrderItem - Order --> OrderStatus - OrderRepositoryImpl ..|> OrderRepository - OrderRepositoryImpl --> OrderJpaRepository - Order --|> BaseEntity - OrderItem --|> BaseEntity - - OrderV1Dto ..> CreateRequest - OrderV1Dto ..> OrderItemRequest - OrderV1Dto ..> CreateResponse - OrderV1Dto ..> ListResponse - OrderV1Dto ..> DetailResponse - OrderV1Dto ..> OrderItemResponse -``` - ---- - -## 6. ์ธ์ฆ ๊ด€๋ จ ํด๋ž˜์Šค - -```mermaid -classDiagram - direction TB - - %% ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž ์ธ์ฆ (๊ธฐ์กด) - class AuthenticatedUser { - <> - +String loginId - +String password - } - - class AuthenticatedUserArgumentResolver { - -UserService userService - +supportsParameter(MethodParameter) boolean - +resolveArgument(...) Object - } - - %% ์–ด๋“œ๋ฏผ ์ธ์ฆ (์‹ ๊ทœ) - class AdminUser { - <> - +String ldapId - } - - class AdminAuthInterceptor { - -String ADMIN_LDAP_HEADER - -String ADMIN_LDAP_VALUE - +preHandle(HttpServletRequest, HttpServletResponse, Object) boolean - } - - class AdminUserArgumentResolver { - +supportsParameter(MethodParameter) boolean - +resolveArgument(...) Object - } - - %% WebMvcConfig - class WebMvcConfig { - -AuthenticatedUserArgumentResolver authResolver - -AdminUserArgumentResolver adminResolver - -AdminAuthInterceptor adminInterceptor - +addArgumentResolvers(List) void - +addInterceptors(InterceptorRegistry) void - } - - %% Interfaces - class HandlerMethodArgumentResolver { - <> - } - - class HandlerInterceptor { - <> - } - - %% Relationships - WebMvcConfig --> AuthenticatedUserArgumentResolver - WebMvcConfig --> AdminUserArgumentResolver - WebMvcConfig --> AdminAuthInterceptor - AuthenticatedUserArgumentResolver ..|> HandlerMethodArgumentResolver - AdminUserArgumentResolver ..|> HandlerMethodArgumentResolver - AdminAuthInterceptor ..|> HandlerInterceptor -``` - -**ํ•ต์‹ฌ ํฌ์ธํŠธ:** -- **AdminAuthInterceptor**: `/api-admin/**` ๊ฒฝ๋กœ์— ๋Œ€ํ•ด ํ—ค๋” ๊ฒ€์ฆ (1์ฐจ ๋ฐฉ์–ด์„ ) -- **AdminUserArgumentResolver**: ์ปจํŠธ๋กค๋Ÿฌ์— AdminUser ๊ฐ์ฒด ์ฃผ์ž… - ---- - -## 7. ๊ณตํ†ต ํด๋ž˜์Šค - -```mermaid -classDiagram - direction TB - - class BaseEntity { - <> - #Long id - #ZonedDateTime createdAt - #ZonedDateTime updatedAt - #ZonedDateTime deletedAt - #guard() void - +delete() void - +restore() void - +isDeleted() boolean - } - - class ApiResponse~T~ { - <> - +Metadata meta - +T data - +success() ApiResponse~Object~ - +success(T data) ApiResponse~T~ - +fail(String, String) ApiResponse~Object~ - } - - class Metadata { - <> - +Result result - +String errorCode - +String message - } - - class Result { - <> - SUCCESS - FAIL - } - - class CoreException { - -ErrorType errorType - -String customMessage - +CoreException(ErrorType) - +CoreException(ErrorType, String) - +getErrorType() ErrorType - } - - class ErrorType { - <> - INTERNAL_ERROR - BAD_REQUEST - NOT_FOUND - CONFLICT - UNAUTHORIZED - USER_NOT_FOUND - PASSWORD_MISMATCH - BRAND_NOT_FOUND - BRAND_ALREADY_EXISTS - BRAND_DELETED - PRODUCT_NOT_FOUND - PRODUCT_DELETED - INSUFFICIENT_STOCK - ORDER_NOT_FOUND - ORDER_ACCESS_DENIED - ADMIN_UNAUTHORIZED - BRAND_CHANGE_NOT_ALLOWED - -HttpStatus status - -String code - -String message - } - - ApiResponse --> Metadata - Metadata --> Result - CoreException --> ErrorType -``` - -**ํ•ต์‹ฌ ํฌ์ธํŠธ:** -- **BaseEntity**: ๋ชจ๋“  ์—”ํ‹ฐํ‹ฐ์˜ ๊ณตํ†ต ํ•„๋“œ (id, timestamps, soft delete) -- **ApiResponse**: ํ†ต์ผ๋œ API ์‘๋‹ต ํ˜•์‹ -- **ErrorType**: ๋„๋ฉ”์ธ๋ณ„ ์—๋Ÿฌ ์ฝ”๋“œ ์ •์˜ diff --git a/.docs/design/04-erd.md b/.docs/design/04-erd.md deleted file mode 100644 index 50851256c..000000000 --- a/.docs/design/04-erd.md +++ /dev/null @@ -1,419 +0,0 @@ -# ERD (Entity Relationship Diagram) - -## ๋‹ค์ด์–ด๊ทธ๋žจ ๋ชฉ์  -ERD๋ฅผ ํ†ตํ•ด ๋‹ค์Œ์„ ๊ฒ€์ฆํ•œ๋‹ค: -- ์˜์†์„ฑ ๊ตฌ์กฐ: ๋ฐ์ดํ„ฐ๊ฐ€ ์–ด๋–ป๊ฒŒ ์ €์žฅ๋˜๋Š”๊ฐ€ -- ๊ด€๊ณ„์˜ ์ฃผ์ธ: FK๊ฐ€ ์–ด๋””์— ์œ„์น˜ํ•˜๋Š”๊ฐ€ -- ์ •๊ทœํ™” ์—ฌ๋ถ€: ๋ฐ์ดํ„ฐ ์ค‘๋ณต์ด ์ตœ์†Œํ™”๋˜์—ˆ๋Š”๊ฐ€ -- ์ •ํ•ฉ์„ฑ: ์ œ์•ฝ์กฐ๊ฑด์ด ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™์„ ๋ฐ˜์˜ํ•˜๋Š”๊ฐ€ - ---- - -## 1. ์ „์ฒด ERD - -```mermaid -erDiagram - users ||--o{ orders : "places" - users ||--o{ product_likes : "likes" - brands ||--o{ products : "has" - products ||--o{ product_likes : "has" - products ||--o{ order_items : "ordered_in" - orders ||--|{ order_items : "contains" - - users { - bigint id PK "AUTO_INCREMENT" - varchar_30 login_id UK "NOT NULL" - varchar_255 password "NOT NULL, BCrypt" - varchar_30 name "NOT NULL" - varchar_8 birth_date "NOT NULL, YYYYMMDD" - varchar_100 email "NOT NULL" - timestamp created_at "NOT NULL" - timestamp updated_at "NOT NULL" - timestamp deleted_at "NULL, Soft Delete" - } - - brands { - bigint id PK "AUTO_INCREMENT" - varchar_100 name UK "NOT NULL" - varchar_500 description "NULL" - varchar_500 logo_url "NULL" - timestamp created_at "NOT NULL" - timestamp updated_at "NOT NULL" - timestamp deleted_at "NULL, Soft Delete" - } - - products { - bigint id PK "AUTO_INCREMENT" - bigint brand_id FK "NOT NULL" - varchar_200 name "NOT NULL" - varchar_2000 description "NULL" - bigint price "NOT NULL, >= 0" - int stock "NOT NULL, >= 0" - varchar_500 image_url "NULL" - timestamp created_at "NOT NULL" - timestamp updated_at "NOT NULL" - timestamp deleted_at "NULL, Soft Delete" - } - - product_likes { - bigint id PK "AUTO_INCREMENT" - bigint user_id FK "NOT NULL" - bigint product_id FK "NOT NULL" - timestamp created_at "NOT NULL" - } - - orders { - bigint id PK "AUTO_INCREMENT" - bigint user_id FK "NOT NULL" - bigint total_price "NOT NULL, >= 0" - varchar_20 status "NOT NULL, ENUM" - timestamp created_at "NOT NULL" - timestamp updated_at "NOT NULL" - } - - order_items { - bigint id PK "AUTO_INCREMENT" - bigint order_id FK "NOT NULL" - bigint product_id FK "NOT NULL" - varchar_200 product_name "NOT NULL, ์Šค๋ƒ…์ƒท" - int quantity "NOT NULL, >= 1" - bigint price "NOT NULL, ์Šค๋ƒ…์ƒท" - timestamp created_at "NOT NULL" - } -``` - ---- - -## 2. ํ…Œ์ด๋ธ” ์ƒ์„ธ ์Šคํ‚ค๋งˆ - -### 2.1 users ํ…Œ์ด๋ธ” (๊ธฐ์กด) - -```sql -CREATE TABLE users ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - login_id VARCHAR(30) NOT NULL, - password VARCHAR(255) NOT NULL, - name VARCHAR(30) NOT NULL, - birth_date VARCHAR(8) NOT NULL, - email VARCHAR(100) NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL, - - CONSTRAINT uk_users_login_id UNIQUE (login_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -``` - ---- - -### 2.2 brands ํ…Œ์ด๋ธ” - -```sql -CREATE TABLE brands ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(100) NOT NULL, - description VARCHAR(500) NULL, - logo_url VARCHAR(500) NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL, - - CONSTRAINT uk_brands_name UNIQUE (name) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- ์ธ๋ฑ์Šค -CREATE INDEX idx_brands_deleted_at ON brands (deleted_at); -CREATE INDEX idx_brands_created_at ON brands (created_at); -``` - -**์ธ๋ฑ์Šค ์„ค๊ณ„ ์˜๋„:** -| ์ธ๋ฑ์Šค | ์šฉ๋„ | -|--------|------| -| `uk_brands_name` | ๋ธŒ๋žœ๋“œ๋ช… ์ค‘๋ณต ๋ฐฉ์ง€ | -| `idx_brands_deleted_at` | Soft Delete ํ•„ํ„ฐ๋ง ์ตœ์ ํ™” | -| `idx_brands_created_at` | ์ตœ์‹ ์ˆœ ์ •๋ ฌ ์ตœ์ ํ™” | - ---- - -### 2.3 products ํ…Œ์ด๋ธ” - -```sql -CREATE TABLE products ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - brand_id BIGINT NOT NULL, - name VARCHAR(200) NOT NULL, - description VARCHAR(2000) NULL, - price BIGINT NOT NULL, - stock INT NOT NULL DEFAULT 0, - image_url VARCHAR(500) NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL, - - CONSTRAINT fk_products_brand_id FOREIGN KEY (brand_id) - REFERENCES brands(id) ON DELETE RESTRICT, - - CONSTRAINT chk_products_price CHECK (price >= 0), - CONSTRAINT chk_products_stock CHECK (stock >= 0) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- ์ธ๋ฑ์Šค -CREATE INDEX idx_products_brand_id ON products (brand_id); -CREATE INDEX idx_products_deleted_at ON products (deleted_at); -CREATE INDEX idx_products_price ON products (price); -CREATE INDEX idx_products_created_at ON products (created_at DESC); - --- ๋ณตํ•ฉ ์ธ๋ฑ์Šค: ๋ธŒ๋žœ๋“œ๋ณ„ ์ƒํ’ˆ ์กฐํšŒ ์ตœ์ ํ™” -CREATE INDEX idx_products_brand_deleted_created - ON products (brand_id, deleted_at, created_at DESC); -``` - -**์ธ๋ฑ์Šค ์„ค๊ณ„ ์˜๋„:** -| ์ธ๋ฑ์Šค | ์šฉ๋„ | -|--------|------| -| `fk_products_brand_id` | ๋ธŒ๋žœ๋“œ-์ƒํ’ˆ ๊ด€๊ณ„ ๋ฌด๊ฒฐ์„ฑ | -| `idx_products_brand_id` | ๋ธŒ๋žœ๋“œ๋ณ„ ์ƒํ’ˆ ์กฐํšŒ | -| `idx_products_deleted_at` | Soft Delete ํ•„ํ„ฐ๋ง | -| `idx_products_price` | ๊ฐ€๊ฒฉ์ˆœ ์ •๋ ฌ ์ตœ์ ํ™” | -| `idx_products_created_at` | ์ตœ์‹ ์ˆœ ์ •๋ ฌ ์ตœ์ ํ™” | -| `idx_products_brand_deleted_created` | ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ + ์‚ญ์ œ ํ•„ํ„ฐ + ์ตœ์‹ ์ˆœ ๋ณตํ•ฉ ์ฟผ๋ฆฌ | - ---- - -### 2.4 product_likes ํ…Œ์ด๋ธ” - -```sql -CREATE TABLE product_likes ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - user_id BIGINT NOT NULL, - product_id BIGINT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT fk_product_likes_user_id FOREIGN KEY (user_id) - REFERENCES users(id) ON DELETE CASCADE, - CONSTRAINT fk_product_likes_product_id FOREIGN KEY (product_id) - REFERENCES products(id) ON DELETE CASCADE, - - CONSTRAINT uk_product_likes_user_product UNIQUE (user_id, product_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- ์ธ๋ฑ์Šค -CREATE INDEX idx_product_likes_product_id ON product_likes (product_id); -CREATE INDEX idx_product_likes_user_id ON product_likes (user_id); -``` - -**์ธ๋ฑ์Šค ์„ค๊ณ„ ์˜๋„:** -| ์ธ๋ฑ์Šค | ์šฉ๋„ | -|--------|------| -| `uk_product_likes_user_product` | ์ค‘๋ณต ์ข‹์•„์š” ๋ฐฉ์ง€ + ํŠน์ • ์‚ฌ์šฉ์ž์˜ ํŠน์ • ์ƒํ’ˆ ์ข‹์•„์š” ์—ฌ๋ถ€ ์กฐํšŒ | -| `idx_product_likes_product_id` | ์ƒํ’ˆ๋ณ„ ์ข‹์•„์š” ์ˆ˜ COUNT ์ตœ์ ํ™” | -| `idx_product_likes_user_id` | ์‚ฌ์šฉ์ž๋ณ„ ์ข‹์•„์š” ๋ชฉ๋ก ์กฐํšŒ ์ตœ์ ํ™” | - -**CASCADE ์‚ญ์ œ:** -- User ์‚ญ์ œ ์‹œ ํ•ด๋‹น ์‚ฌ์šฉ์ž์˜ ์ข‹์•„์š” ์ž๋™ ์‚ญ์ œ -- Product ์‚ญ์ œ ์‹œ ํ•ด๋‹น ์ƒํ’ˆ์˜ ์ข‹์•„์š” ์ž๋™ ์‚ญ์ œ - ---- - -### 2.5 orders ํ…Œ์ด๋ธ” - -```sql -CREATE TABLE orders ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - user_id BIGINT NOT NULL, - total_price BIGINT NOT NULL DEFAULT 0, - status VARCHAR(20) NOT NULL DEFAULT 'COMPLETED', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - CONSTRAINT fk_orders_user_id FOREIGN KEY (user_id) - REFERENCES users(id) ON DELETE RESTRICT, - - CONSTRAINT chk_orders_total_price CHECK (total_price >= 0), - CONSTRAINT chk_orders_status CHECK (status IN ('PENDING', 'COMPLETED', 'CANCELLED')) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- ์ธ๋ฑ์Šค -CREATE INDEX idx_orders_user_id ON orders (user_id); -CREATE INDEX idx_orders_created_at ON orders (created_at DESC); -CREATE INDEX idx_orders_status ON orders (status); - --- ๋ณตํ•ฉ ์ธ๋ฑ์Šค: ์‚ฌ์šฉ์ž๋ณ„ ๊ธฐ๊ฐ„ ์กฐํšŒ ์ตœ์ ํ™” -CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC); -``` - -**์ธ๋ฑ์Šค ์„ค๊ณ„ ์˜๋„:** -| ์ธ๋ฑ์Šค | ์šฉ๋„ | -|--------|------| -| `idx_orders_user_id` | ์‚ฌ์šฉ์ž๋ณ„ ์ฃผ๋ฌธ ์กฐํšŒ | -| `idx_orders_created_at` | ์ตœ์‹ ์ˆœ ์ •๋ ฌ | -| `idx_orders_status` | ์ƒํƒœ๋ณ„ ํ•„ํ„ฐ๋ง | -| `idx_orders_user_created` | ์‚ฌ์šฉ์ž์˜ ์ฃผ๋ฌธ ๊ธฐ๊ฐ„ ์กฐํšŒ ์ตœ์ ํ™” | - ---- - -### 2.6 order_items ํ…Œ์ด๋ธ” - -```sql -CREATE TABLE order_items ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - order_id BIGINT NOT NULL, - product_id BIGINT NOT NULL, - product_name VARCHAR(200) NOT NULL COMMENT '์ฃผ๋ฌธ ์‹œ์  ์ƒํ’ˆ๋ช… ์Šค๋ƒ…์ƒท', - quantity INT NOT NULL, - price BIGINT NOT NULL COMMENT '์ฃผ๋ฌธ ์‹œ์  ์ƒํ’ˆ ๊ฐ€๊ฒฉ ์Šค๋ƒ…์ƒท', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT fk_order_items_order_id FOREIGN KEY (order_id) - REFERENCES orders(id) ON DELETE CASCADE, - CONSTRAINT fk_order_items_product_id FOREIGN KEY (product_id) - REFERENCES products(id) ON DELETE RESTRICT, - - CONSTRAINT uk_order_items_order_product UNIQUE (order_id, product_id), - CONSTRAINT chk_order_items_quantity CHECK (quantity >= 1), - CONSTRAINT chk_order_items_price CHECK (price >= 0) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- ์ธ๋ฑ์Šค -CREATE INDEX idx_order_items_order_id ON order_items (order_id); -CREATE INDEX idx_order_items_product_id ON order_items (product_id); -``` - -**์ธ๋ฑ์Šค ์„ค๊ณ„ ์˜๋„:** -| ์ธ๋ฑ์Šค | ์šฉ๋„ | -|--------|------| -| `uk_order_items_order_product` | ๋™์ผ ์ฃผ๋ฌธ ๋‚ด ๋™์ผ ์ƒํ’ˆ ์ค‘๋ณต ๋ฐฉ์ง€ | -| `idx_order_items_order_id` | ์ฃผ๋ฌธ๋ณ„ ํ•ญ๋ชฉ ์กฐํšŒ | -| `idx_order_items_product_id` | ์ƒํ’ˆ๋ณ„ ์ฃผ๋ฌธ ์ด๋ ฅ ์กฐํšŒ | - -**๊ฐ€๊ฒฉ/์ƒํ’ˆ๋ช… ์Šค๋ƒ…์ƒท:** -- `price`, `product_name` ํ•„๋“œ๋Š” ์ฃผ๋ฌธ ์‹œ์ ์˜ ๊ฐ’์„ ์ €์žฅ -- ์ƒํ’ˆ ๊ฐ€๊ฒฉ/์ด๋ฆ„ ๋ณ€๊ฒฝ ์‹œ์—๋„ ๊ธฐ์กด ์ฃผ๋ฌธ์˜ ์ •๋ณด๋Š” ์œ ์ง€ - ---- - -## 3. ๊ด€๊ณ„ ์ •์˜ - -### 3.1 ๊ด€๊ณ„ ์š”์•ฝ - -| ๊ด€๊ณ„ | ํƒ€์ž… | ์„ค๋ช… | -|------|------|------| -| users : orders | 1:N | ์‚ฌ์šฉ์ž๋Š” ์—ฌ๋Ÿฌ ์ฃผ๋ฌธ ๊ฐ€๋Šฅ | -| users : product_likes | 1:N | ์‚ฌ์šฉ์ž๋Š” ์—ฌ๋Ÿฌ ์ƒํ’ˆ์— ์ข‹์•„์š” ๊ฐ€๋Šฅ | -| brands : products | 1:N | ๋ธŒ๋žœ๋“œ๋Š” ์—ฌ๋Ÿฌ ์ƒํ’ˆ ๋ณด์œ  | -| products : product_likes | 1:N | ์ƒํ’ˆ์€ ์—ฌ๋Ÿฌ ์ข‹์•„์š” ๋ณด์œ  | -| products : order_items | 1:N | ์ƒํ’ˆ์€ ์—ฌ๋Ÿฌ ์ฃผ๋ฌธ์— ํฌํ•จ ๊ฐ€๋Šฅ | -| orders : order_items | 1:N | ์ฃผ๋ฌธ์€ ์—ฌ๋Ÿฌ ์ฃผ๋ฌธ ํ•ญ๋ชฉ ํฌํ•จ | - -### 3.2 FK ์‚ญ์ œ ์ •์ฑ… - -| FK | ์ •์ฑ… | ์ด์œ  | -|----|------|------| -| products.brand_id | RESTRICT | ๋ธŒ๋žœ๋“œ ์‚ญ์ œ ์‹œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ Cascade ์ฒ˜๋ฆฌ | -| product_likes.user_id | CASCADE | ์‚ฌ์šฉ์ž ์‚ญ์ œ ์‹œ ์ข‹์•„์š” ์ž๋™ ์‚ญ์ œ | -| product_likes.product_id | CASCADE | ์ƒํ’ˆ ์‚ญ์ œ ์‹œ ์ข‹์•„์š” ์ž๋™ ์‚ญ์ œ | -| orders.user_id | RESTRICT | ์ฃผ๋ฌธ ์ด๋ ฅ ๋ณด์กด (์‚ฌ์šฉ์ž ์‚ญ์ œ ๋ถˆ๊ฐ€) | -| order_items.order_id | CASCADE | ์ฃผ๋ฌธ ์‚ญ์ œ ์‹œ ํ•ญ๋ชฉ ์ž๋™ ์‚ญ์ œ | -| order_items.product_id | RESTRICT | ์ƒํ’ˆ ์‚ญ์ œ ์‹œ์—๋„ ์ฃผ๋ฌธ ์ด๋ ฅ ๋ณด์กด | - ---- - -## 4. ์ฟผ๋ฆฌ ์ตœ์ ํ™” ๊ฐ€์ด๋“œ - -### 4.1 ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ (์ข‹์•„์š”์ˆœ ์ •๋ ฌ) - -```sql --- ์ข‹์•„์š” ๋งŽ์€์ˆœ ์ •๋ ฌ (์„œ๋ธŒ์ฟผ๋ฆฌ ๋ฐฉ์‹) -SELECT - p.*, - COALESCE(like_counts.cnt, 0) as like_count -FROM products p -LEFT JOIN ( - SELECT product_id, COUNT(*) as cnt - FROM product_likes - GROUP BY product_id -) like_counts ON p.id = like_counts.product_id -WHERE p.deleted_at IS NULL - AND (p.brand_id = :brandId OR :brandId IS NULL) -ORDER BY like_count DESC, p.created_at DESC -LIMIT :limit OFFSET :offset; -``` - -### 4.2 ์žฌ๊ณ  ์ฐจ๊ฐ (๋น„๊ด€์  ๋ฝ) - -```sql --- ๋น„๊ด€์  ๋ฝ์œผ๋กœ ์žฌ๊ณ  ์กฐํšŒ -SELECT * FROM products -WHERE id = :productId AND deleted_at IS NULL -FOR UPDATE; - --- ์žฌ๊ณ  ๊ฒ€์ฆ ํ›„ ์ฐจ๊ฐ -UPDATE products -SET stock = stock - :quantity, updated_at = NOW() -WHERE id = :productId AND stock >= :quantity; -``` - -### 4.3 ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ (Fetch Join) - -```sql -SELECT o.*, oi.* -FROM orders o -JOIN order_items oi ON o.id = oi.order_id -WHERE o.id = :orderId AND o.user_id = :userId; -``` - -### 4.4 ์‚ฌ์šฉ์ž๋ณ„ ์ข‹์•„์š” ์ƒํ’ˆ ๋ชฉ๋ก - -```sql -SELECT p.*, pl.created_at as liked_at -FROM product_likes pl -JOIN products p ON pl.product_id = p.id -WHERE pl.user_id = :userId AND p.deleted_at IS NULL -ORDER BY pl.created_at DESC; -``` - -### 4.5 ์‚ฌ์šฉ์ž ์ฃผ๋ฌธ ๊ธฐ๊ฐ„ ์กฐํšŒ - -```sql -SELECT o.*, oi.* -FROM orders o -LEFT JOIN order_items oi ON o.id = oi.order_id -WHERE o.user_id = :userId - AND (:startAt IS NULL OR o.created_at >= :startAt) - AND (:endAt IS NULL OR o.created_at < :endAt + INTERVAL 1 DAY) -ORDER BY o.created_at DESC -LIMIT :limit OFFSET :offset; -``` - ---- - -## 5. ๋ฐ์ดํ„ฐ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ˆœ์„œ - -``` -V1__Create_users_table.sql (๊ธฐ์กด) -V2__Create_brands_table.sql -V3__Create_products_table.sql -V4__Create_product_likes_table.sql -V5__Create_orders_table.sql -V6__Create_order_items_table.sql -V7__Add_indexes.sql -``` - ---- - -## 6. ๋ฐ์ดํ„ฐ ์ •ํ•ฉ์„ฑ ๊ณ ๋ ค์‚ฌํ•ญ - -### 6.1 ๋™์‹œ์„ฑ ์ œ์–ด -- **์žฌ๊ณ  ์ฐจ๊ฐ**: `SELECT ... FOR UPDATE` ๋น„๊ด€์  ๋ฝ -- **์ข‹์•„์š” ์ค‘๋ณต**: `UNIQUE (user_id, product_id)` ์ œ์•ฝ์กฐ๊ฑด -- **์ฃผ๋ฌธ ํ•ญ๋ชฉ ์ค‘๋ณต**: `UNIQUE (order_id, product_id)` ์ œ์•ฝ์กฐ๊ฑด - -### 6.2 Soft Delete ์ฒ˜๋ฆฌ -- brands, products, users: `deleted_at` ํ•„๋“œ ์‚ฌ์šฉ -- ์กฐํšŒ ์‹œ `WHERE deleted_at IS NULL` ์กฐ๊ฑด ํ•„์ˆ˜ -- product_likes, orders, order_items: Hard Delete - -### 6.3 ์Šค๋ƒ…์ƒท ๋ฐ์ดํ„ฐ -- order_items.price: ์ฃผ๋ฌธ ์‹œ์  ์ƒํ’ˆ ๊ฐ€๊ฒฉ -- order_items.product_name: ์ฃผ๋ฌธ ์‹œ์  ์ƒํ’ˆ๋ช… -- ์ƒํ’ˆ ์ •๋ณด ๋ณ€๊ฒฝ๊ณผ ๋ฌด๊ด€ํ•˜๊ฒŒ ์ฃผ๋ฌธ ์ด๋ ฅ ๋ณด์กด From 356b6ec32d08e6b9a85019e507f30ea502712a5b Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 13 Feb 2026 16:03:51 +0900 Subject: [PATCH 10/29] =?UTF-8?q?docs:=20=EC=BB=A4=EB=A8=B8=EC=8A=A4=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=9A=94=EA=B5=AC=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EB=AA=85=EC=84=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Brand/Product/ProductLike/Order/OrderItem ๋„๋ฉ”์ธ ํ•„๋“œ ์ •์˜ - ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ (BR-*) ๋ฐ ๊ฒ€์ฆ ๊ทœ์น™ ์ •์˜ - ์œ ์ € ์‹œ๋‚˜๋ฆฌ์˜ค 9๊ฐœ (US-001~009), ์–ด๋“œ๋ฏผ ์‹œ๋‚˜๋ฆฌ์˜ค 7๊ฐœ (AS-001~007) - API ๋ช…์„ธ ๋ฐ ์—๋Ÿฌ ํƒ€์ž… ์ •์˜ Co-Authored-By: Claude Opus 4.5 --- .docs/design/01-requirements.md | 431 ++++++++++++++++++++++++++++++++ 1 file changed, 431 insertions(+) create mode 100644 .docs/design/01-requirements.md diff --git a/.docs/design/01-requirements.md b/.docs/design/01-requirements.md new file mode 100644 index 000000000..87009de6e --- /dev/null +++ b/.docs/design/01-requirements.md @@ -0,0 +1,431 @@ +# ์ปค๋จธ์Šค ๋„๋ฉ”์ธ ์š”๊ตฌ์‚ฌํ•ญ ์ •์˜์„œ + +## 1. ๊ฐœ์š” + +### 1.1 ๋ฌธ์„œ ๋ชฉ์  +Java/Spring Boot ๋ฉ€ํ‹ฐ ๋ชจ๋“ˆ ์ปค๋จธ์Šค ๋ฐฑ์—”๋“œ์˜ Brand, Product, ProductLike, Order, OrderItem ๋„๋ฉ”์ธ์— ๋Œ€ํ•œ +์ƒ์„ธ ์š”๊ตฌ์‚ฌํ•ญ์„ ์ •์˜ํ•œ๋‹ค. + +### 1.2 ๊ธฐ์กด ํŒจํ„ด ์ฐธ์กฐ +- ์•„ํ‚คํ…์ฒ˜: Layered Architecture (interfaces โ†’ application โ†’ domain โ†’ infrastructure) +- ์ธ์ฆ: ํ—ค๋” ๊ธฐ๋ฐ˜ ์ธ์ฆ (X-Loopers-LoginId, X-Loopers-LoginPw) +- ์‘๋‹ต ํ˜•์‹: ApiResponse (meta + data) +- ์˜ˆ์™ธ ์ฒ˜๋ฆฌ: CoreException + ErrorType enum + +### 1.3 ์•กํ„ฐ ์ •์˜ + +| ์•กํ„ฐ | ์„ค๋ช… | ์ธ์ฆ ๋ฐฉ์‹ | +|------|------|----------| +| ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž | ์ƒํ’ˆ ์กฐํšŒ, ์ข‹์•„์š”, ์ฃผ๋ฌธ ๊ฐ€๋Šฅ | X-Loopers-LoginId + X-Loopers-LoginPw | +| ์–ด๋“œ๋ฏผ | ๋ธŒ๋žœ๋“œ/์ƒํ’ˆ CRUD ๊ด€๋ฆฌ | X-Loopers-Ldap: loopers.admin | + +--- + +## 2. ๋„๋ฉ”์ธ๋ณ„ ์ƒ์„ธ ์š”๊ตฌ์‚ฌํ•ญ + +### 2.1 Brand (๋ธŒ๋žœ๋“œ) + +#### 2.1.1 ํ•„๋“œ ์ •์˜ +| ํ•„๋“œ | ํƒ€์ž… | ํ•„์ˆ˜ | ์ œ์•ฝ์กฐ๊ฑด | +|------|------|------|----------| +| id | Long | Y | ์ž๋™ ์ƒ์„ฑ (PK) | +| name | String | Y | 1-100์ž, ๊ณต๋ฐฑ ๋ถˆ๊ฐ€, ์ค‘๋ณต ๋ถˆ๊ฐ€ | +| description | String | N | ์ตœ๋Œ€ 500์ž | +| logoUrl | String | N | URL ํ˜•์‹ ๊ฒ€์ฆ, ์ตœ๋Œ€ 500์ž | +| createdAt | ZonedDateTime | Y | ์ž๋™ ์ƒ์„ฑ | +| updatedAt | ZonedDateTime | Y | ์ž๋™ ๊ฐฑ์‹  | +| deletedAt | ZonedDateTime | N | Soft Delete | + +*๋ธŒ๋žœ๋“œ ์ฃผ์†Œ, ๋Œ€ํ‘œ๋ช…, ๋ธŒ๋žœ๋“œ ์‚ฌ์ดํŠธ URL ๊ฐ™์€ ์ปฌ๋Ÿผ์„ ๋„ฃ์„์ง€ ๋ง์ง€ ๊ณ ๋ฏผํ–ˆ์ง€๋งŒ ์„ค๊ณ„์— ์ง‘์ค‘ํ•˜๊ณ  ์‹ถ์–ด์„œ ๋„ฃ์ง€ ์•Š์•˜๋‹ค.* + +#### 2.1.2 ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ +- **BR-BRAND-001**: ๋ธŒ๋žœ๋“œ๋ช…์€ ์‹œ์Šคํ…œ ๋‚ด ์œ ์ผํ•ด์•ผ ํ•œ๋‹ค +- **BR-BRAND-002**: ๋ธŒ๋žœ๋“œ ์‚ญ์ œ ์‹œ ํ•ด๋‹น ๋ธŒ๋žœ๋“œ์˜ ๋ชจ๋“  ์ƒํ’ˆ์ด Cascade ์‚ญ์ œ๋œ๋‹ค +- **BR-BRAND-003**: ์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ๋Š” ์กฐํšŒ๋˜์ง€ ์•Š๋Š”๋‹ค (Soft Delete) + +#### 2.1.3 ๊ฒ€์ฆ ๊ทœ์น™ +``` +name ๊ฒ€์ฆ: +- null ๋˜๋Š” blank ๋ถˆ๊ฐ€ โ†’ "๋ธŒ๋žœ๋“œ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค." +- 100์ž ์ดˆ๊ณผ โ†’ "๋ธŒ๋žœ๋“œ๋ช…์€ 100์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." +- ์ค‘๋ณต โ†’ "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ธŒ๋žœ๋“œ๋ช…์ž…๋‹ˆ๋‹ค." (409 CONFLICT) + +description ๊ฒ€์ฆ: +- 500์ž ์ดˆ๊ณผ โ†’ "๋ธŒ๋žœ๋“œ ์„ค๋ช…์€ 500์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." + +logoUrl ๊ฒ€์ฆ: +- URL ํ˜•์‹ ๋ถˆ์ผ์น˜ โ†’ "์œ ํšจํ•˜์ง€ ์•Š์€ URL ํ˜•์‹์ž…๋‹ˆ๋‹ค." +- 500์ž ์ดˆ๊ณผ โ†’ "๋กœ๊ณ  URL์€ 500์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." +``` + +--- + +### 2.2 Product (์ƒํ’ˆ) + +#### 2.2.1 ํ•„๋“œ ์ •์˜ +| ํ•„๋“œ | ํƒ€์ž… | ํ•„์ˆ˜ | ์ œ์•ฝ์กฐ๊ฑด | +|------|------|------|----------| +| id | Long | Y | ์ž๋™ ์ƒ์„ฑ (PK) | +| brandId | Long | Y | Brand FK, ์กด์žฌ ๊ฒ€์ฆ | +| name | String | Y | 1-200์ž | +| description | String | N | ์ตœ๋Œ€ 2000์ž | +| price | Long | Y | 0 ์ด์ƒ | +| stock | Integer | Y | 0 ์ด์ƒ | +| imageUrl | String | N | URL ํ˜•์‹, ์ตœ๋Œ€ 500์ž | +| createdAt | ZonedDateTime | Y | ์ž๋™ ์ƒ์„ฑ | +| updatedAt | ZonedDateTime | Y | ์ž๋™ ๊ฐฑ์‹  | +| deletedAt | ZonedDateTime | N | Soft Delete | + +#### 2.2.2 ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ +- **BR-PRODUCT-001**: ์ƒํ’ˆ์€ ๋ฐ˜๋“œ์‹œ ํ•˜๋‚˜์˜ ๋ธŒ๋žœ๋“œ์— ์†ํ•ด์•ผ ํ•œ๋‹ค +- **BR-PRODUCT-002**: ์ƒํ’ˆ ๋“ฑ๋ก ํ›„ ๋ธŒ๋žœ๋“œ ๋ณ€๊ฒฝ ๋ถˆ๊ฐ€ +- **BR-PRODUCT-003**: ์ƒํ’ˆ ์‚ญ์ œ ์‹œ ํ•ด๋‹น ์ƒํ’ˆ์˜ ๋ชจ๋“  ์ข‹์•„์š”๊ฐ€ Cascade ์‚ญ์ œ๋œ๋‹ค +- **BR-PRODUCT-004**: ์‚ญ์ œ๋œ ์ƒํ’ˆ์€ ๋ชฉ๋ก ์กฐํšŒ ์‹œ ์ œ์™ธ๋œ๋‹ค +- **BR-PRODUCT-005**: ์žฌ๊ณ ๊ฐ€ 0์ธ ์ƒํ’ˆ๋„ ์กฐํšŒ๋Š” ๊ฐ€๋Šฅํ•˜๋‹ค + +#### 2.2.3 ๊ฒ€์ฆ ๊ทœ์น™ +``` +name ๊ฒ€์ฆ: +- null ๋˜๋Š” blank ๋ถˆ๊ฐ€ โ†’ "์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค." +- 200์ž ์ดˆ๊ณผ โ†’ "์ƒํ’ˆ๋ช…์€ 200์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." + +price ๊ฒ€์ฆ: +- null ๋ถˆ๊ฐ€ โ†’ "๊ฐ€๊ฒฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค." +- ์Œ์ˆ˜ โ†’ "๊ฐ€๊ฒฉ์€ 0์› ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค." + +stock ๊ฒ€์ฆ: +- null ๋ถˆ๊ฐ€ โ†’ "์žฌ๊ณ ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค." +- ์Œ์ˆ˜ โ†’ "์žฌ๊ณ ๋Š” 0๊ฐœ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค." + +brandId ๊ฒ€์ฆ: +- ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ธŒ๋žœ๋“œ โ†’ "์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ธŒ๋žœ๋“œ์ž…๋‹ˆ๋‹ค." (404 NOT_FOUND) +- ์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ โ†’ "์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ์ž…๋‹ˆ๋‹ค." (400 BAD_REQUEST) +``` + +--- + +### 2.3 ProductLike (์ƒํ’ˆ ์ข‹์•„์š”) + +#### 2.3.1 ํ•„๋“œ ์ •์˜ +| ํ•„๋“œ | ํƒ€์ž… | ํ•„์ˆ˜ | ์ œ์•ฝ์กฐ๊ฑด | +|------|------|------|----------| +| id | Long | Y | ์ž๋™ ์ƒ์„ฑ (PK) | +| userId | Long | Y | User FK | +| productId | Long | Y | Product FK | +| createdAt | ZonedDateTime | Y | ์ž๋™ ์ƒ์„ฑ | + +#### 2.3.2 ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ +- **BR-LIKE-001**: ์ข‹์•„์š” ๋“ฑ๋ก ์‹œ ์ด๋ฏธ ์ข‹์•„์š”๊ฐ€ ์กด์žฌํ•˜๋ฉด ์ข‹์•„์š” ์ทจ์†Œ ์ฒ˜๋ฆฌ (ํ† ๊ธ€ ๋ฐฉ์‹) +- **BR-LIKE-002**: ์ข‹์•„์š” ๊ฐœ์ˆ˜๋Š” ์‹ค์‹œ๊ฐ„ COUNT ์ง‘๊ณ„ +- **BR-LIKE-003**: ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ข‹์•„์š” ์ทจ์†Œ ์‹œ ๋ฉฑ๋“ฑ ์ฒ˜๋ฆฌ (์—๋Ÿฌ ์—†์ด ์„ฑ๊ณต ์‘๋‹ต) +- **BR-LIKE-004**: ์‚ญ์ œ๋œ ์ƒํ’ˆ์—๋Š” ์ข‹์•„์š” ๋ถˆ๊ฐ€ + +#### 2.3.3 ๊ฒ€์ฆ ๊ทœ์น™ +``` +์ข‹์•„์š” ๋“ฑ๋ก: +- ์‚ญ์ œ๋œ ์ƒํ’ˆ โ†’ "์‚ญ์ œ๋œ ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค." (400 BAD_REQUEST) +- ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ โ†’ "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค." (404 NOT_FOUND) +- ์ค‘๋ณต ์ข‹์•„์š” โ†’ ์ข‹์•„์š” ์ทจ์†Œ ์ฒ˜๋ฆฌ (ํ† ๊ธ€) + +์ข‹์•„์š” ์ทจ์†Œ: +- ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ข‹์•„์š” โ†’ ๋ฉฑ๋“ฑ ์ฒ˜๋ฆฌ (์„ฑ๊ณต ์‘๋‹ต) +``` + +--- + +### 2.4 Order (์ฃผ๋ฌธ) + +#### 2.4.1 ํ•„๋“œ ์ •์˜ +| ํ•„๋“œ | ํƒ€์ž… | ํ•„์ˆ˜ | ์ œ์•ฝ์กฐ๊ฑด | +|------|------|------|----------| +| id | Long | Y | ์ž๋™ ์ƒ์„ฑ (PK) | +| userId | Long | Y | User FK | +| totalPrice | Long | Y | 0 ์ด์ƒ, ๊ณ„์‚ฐ๋œ ๊ฐ’ | +| status | OrderStatus | Y | PENDING, COMPLETED, CANCELLED | +| createdAt | ZonedDateTime | Y | ์ž๋™ ์ƒ์„ฑ | +| updatedAt | ZonedDateTime | Y | ์ž๋™ ๊ฐฑ์‹  | + +#### 2.4.2 OrderStatus ์ƒํƒœ ์ •์˜ +```java +public enum OrderStatus { + PENDING, // ์ฃผ๋ฌธ ๋Œ€๊ธฐ + COMPLETED, // ์ฃผ๋ฌธ ์™„๋ฃŒ + CANCELLED // ์ฃผ๋ฌธ ์ทจ์†Œ +} +``` + +#### 2.4.3 ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ +- **BR-ORDER-001**: ๋‹ค๊ฑด ์ƒํ’ˆ ์ฃผ๋ฌธ ์ง€์› (OrderItem 1:N ๊ด€๊ณ„) +- **BR-ORDER-002**: ์ „์ฒด ์‹คํŒจ ์ •์ฑ… - ํ•˜๋‚˜๋ผ๋„ ์‹คํŒจ ์‹œ ์ „์ฒด ์ฃผ๋ฌธ ๋กค๋ฐฑ +- **BR-ORDER-003**: ์žฌ๊ณ  ๊ฒ€์ฆ ํ›„ ์ฐจ๊ฐ์€ ์›์ž์ ์œผ๋กœ ์ˆ˜ํ–‰ (๋น„๊ด€์  ๋ฝ) +- **BR-ORDER-004**: totalPrice๋Š” OrderItem๋“ค์˜ (price * quantity) ํ•ฉ๊ณ„ + +--- + +### 2.5 OrderItem (์ฃผ๋ฌธ ํ•ญ๋ชฉ) + +#### 2.5.1 ํ•„๋“œ ์ •์˜ +| ํ•„๋“œ | ํƒ€์ž… | ํ•„์ˆ˜ | ์ œ์•ฝ์กฐ๊ฑด | +|------|------|------|----------| +| id | Long | Y | ์ž๋™ ์ƒ์„ฑ (PK) | +| orderId | Long | Y | Order FK | +| productId | Long | Y | Product FK | +| quantity | Integer | Y | 1 ์ด์ƒ | +| price | Long | Y | ์ฃผ๋ฌธ ์‹œ์  ์ƒํ’ˆ ๊ฐ€๊ฒฉ (์Šค๋ƒ…์ƒท) | +| createdAt | ZonedDateTime | Y | ์ž๋™ ์ƒ์„ฑ | + +#### 2.5.2 ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ +- **BR-ORDERITEM-001**: ์ฃผ๋ฌธ ์‹œ์ ์˜ ์ƒํ’ˆ ๊ฐ€๊ฒฉ์„ ์Šค๋ƒ…์ƒท์œผ๋กœ ์ €์žฅ +- **BR-ORDERITEM-002**: ๋™์ผ ์ฃผ๋ฌธ ๋‚ด ๋™์ผ ์ƒํ’ˆ ์ค‘๋ณต ๋ถˆ๊ฐ€ (orderId + productId UNIQUE) +- **BR-ORDERITEM-003**: ์ˆ˜๋Ÿ‰์€ ์ตœ์†Œ 1๊ฐœ ์ด์ƒ + +--- + +## 3. ์œ ์ € ์‹œ๋‚˜๋ฆฌ์˜ค + +### 3.1 ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž ์‹œ๋‚˜๋ฆฌ์˜ค + +#### US-001: ๋ธŒ๋žœ๋“œ ์ •๋ณด ์กฐํšŒ +``` +Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ +When: GET /api/v1/brands/{brandId} ์š”์ฒญ +Then: ๋ธŒ๋žœ๋“œ ์ •๋ณด(name, description, logoUrl) ๋ฐ˜ํ™˜ +``` + +#### US-002: ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ +``` +Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ +When: GET /api/v1/products ์š”์ฒญ (์ •๋ ฌ/ํ•„ํ„ฐ/ํŽ˜์ด์ง• ์˜ต์…˜) +Then: ์ƒํ’ˆ ๋ชฉ๋ก๊ณผ ์ข‹์•„์š” ์ˆ˜ ๋ฐ˜ํ™˜ + +์ •๋ ฌ ์˜ต์…˜: +- latest (๊ธฐ๋ณธ๊ฐ’): ์ตœ์‹ ์ˆœ +- price_asc: ๊ฐ€๊ฒฉ ๋‚ฎ์€์ˆœ +- like_desc: ์ข‹์•„์š” ๋งŽ์€์ˆœ + +ํ•„ํ„ฐ ์˜ต์…˜: +- brandId: ํŠน์ • ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ + +ํŽ˜์ด์ง•: +- page: ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ (0๋ถ€ํ„ฐ ์‹œ์ž‘) +- size: ํŽ˜์ด์ง€ ํฌ๊ธฐ (๊ธฐ๋ณธ 20) +``` + +#### US-003: ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ +``` +Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ +When: GET /api/v1/products/{productId} ์š”์ฒญ +Then: ์ƒํ’ˆ ์ƒ์„ธ ์ •๋ณด์™€ ์ข‹์•„์š” ์ˆ˜ ๋ฐ˜ํ™˜ +``` + +#### US-004: ์ข‹์•„์š” ๋“ฑ๋ก/ํ† ๊ธ€ +``` +Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ +When: POST /api/v1/products/{productId}/likes ์š”์ฒญ +Then: + - ์ข‹์•„์š”๊ฐ€ ์—†์œผ๋ฉด โ†’ ์ข‹์•„์š” ๋“ฑ๋ก + - ์ข‹์•„์š”๊ฐ€ ์žˆ์œผ๋ฉด โ†’ ์ข‹์•„์š” ์ทจ์†Œ (ํ† ๊ธ€) +``` + +#### US-005: ์ข‹์•„์š” ์ทจ์†Œ +``` +Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ +When: DELETE /api/v1/products/{productId}/likes ์š”์ฒญ +Then: ์ข‹์•„์š” ์‚ญ์ œ (์กด์žฌํ•˜์ง€ ์•Š์•„๋„ ์„ฑ๊ณต) +``` + +#### US-006: ๋‚ด๊ฐ€ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ ๋ชฉ๋ก +``` +Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ +When: GET /api/v1/users/{userId}/likes ์š”์ฒญ +Then: ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ ๋ชฉ๋ก ๋ฐ˜ํ™˜ +``` + +#### US-007: ์ฃผ๋ฌธ ์ƒ์„ฑ +``` +Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ, ์ฃผ๋ฌธํ•  ์ƒํ’ˆ๋“ค ์„ ํƒ +When: POST /api/v1/orders ์š”์ฒญ (items: [{productId, quantity}]) +Then: + - ์žฌ๊ณ  ๊ฒ€์ฆ (๋ชจ๋“  ์ƒํ’ˆ) + - ์žฌ๊ณ  ์ฐจ๊ฐ (์›์ž์ ) + - ์ฃผ๋ฌธ ์ƒ์„ฑ ๋ฐ ID ๋ฐ˜ํ™˜ + - ์‹คํŒจ ์‹œ ์ „์ฒด ๋กค๋ฐฑ +``` + +#### US-008: ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ +``` +Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ +When: GET /api/v1/orders ์š”์ฒญ (์„ ํƒ์  ๊ธฐ๊ฐ„ ํ•„ํ„ฐ) +Then: ๋ณธ์ธ์˜ ์ฃผ๋ฌธ ๋ชฉ๋ก ๋ฐ˜ํ™˜ +``` + +#### US-009: ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ +``` +Given: ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ƒํƒœ +When: GET /api/v1/orders/{orderId} ์š”์ฒญ +Then: + - ๋ณธ์ธ ์ฃผ๋ฌธ์ธ ๊ฒฝ์šฐ: ์ฃผ๋ฌธ ์ƒ์„ธ ๋ฐ˜ํ™˜ + - ํƒ€์ธ ์ฃผ๋ฌธ์ธ ๊ฒฝ์šฐ: 403 FORBIDDEN +``` + +--- + +### 3.2 ์–ด๋“œ๋ฏผ ์‹œ๋‚˜๋ฆฌ์˜ค + +#### AS-001: ๋ธŒ๋žœ๋“œ ๋ชฉ๋ก ์กฐํšŒ (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin ํ—ค๋” +When: GET /api-admin/v1/brands ์š”์ฒญ +Then: ์ „์ฒด ๋ธŒ๋žœ๋“œ ๋ชฉ๋ก ๋ฐ˜ํ™˜ +``` + +#### AS-002: ๋ธŒ๋žœ๋“œ ๋“ฑ๋ก (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin ํ—ค๋” +When: POST /api-admin/v1/brands ์š”์ฒญ +Then: ๋ธŒ๋žœ๋“œ ๋“ฑ๋ก ๋ฐ ID ๋ฐ˜ํ™˜ +``` + +#### AS-003: ๋ธŒ๋žœ๋“œ ์ˆ˜์ • (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin ํ—ค๋” +When: PUT /api-admin/v1/brands/{brandId} ์š”์ฒญ +Then: ๋ธŒ๋žœ๋“œ ์ •๋ณด ์ˆ˜์ • +``` + +#### AS-004: ๋ธŒ๋žœ๋“œ ์‚ญ์ œ (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin ํ—ค๋” +When: DELETE /api-admin/v1/brands/{brandId} ์š”์ฒญ +Then: + - ๋ธŒ๋žœ๋“œ Soft Delete + - ํ•ด๋‹น ๋ธŒ๋žœ๋“œ์˜ ๋ชจ๋“  ์ƒํ’ˆ Cascade Soft Delete + - ์ƒํ’ˆ๋“ค์˜ ์ข‹์•„์š” Cascade Hard Delete +``` + +#### AS-005: ์ƒํ’ˆ ๋“ฑ๋ก (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin ํ—ค๋” +When: POST /api-admin/v1/products ์š”์ฒญ +Then: ์ƒํ’ˆ ๋“ฑ๋ก ๋ฐ ID ๋ฐ˜ํ™˜ +``` + +#### AS-006: ์ƒํ’ˆ ์ˆ˜์ • (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin ํ—ค๋” +When: PUT /api-admin/v1/products/{productId} ์š”์ฒญ +Then: + - brandId ์ œ์™ธ ํ•„๋“œ ์ˆ˜์ • ๊ฐ€๋Šฅ + - brandId ๋ณ€๊ฒฝ ์‹œ๋„ ์‹œ: "๋ธŒ๋žœ๋“œ ๋ณ€๊ฒฝ์€ ๋ถˆ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค." (400 BAD_REQUEST) +``` + +#### AS-007: ์ƒํ’ˆ ์‚ญ์ œ (Admin) +``` +Given: X-Loopers-Ldap: loopers.admin ํ—ค๋” +When: DELETE /api-admin/v1/products/{productId} ์š”์ฒญ +Then: + - ์ƒํ’ˆ Soft Delete + - ์ข‹์•„์š” Cascade Hard Delete +``` + +--- + +## 4. API ๋ช…์„ธ + +### 4.1 ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž API + +| Method | Endpoint | ์„ค๋ช… | +|--------|----------|------| +| GET | /api/v1/brands/{brandId} | ๋ธŒ๋žœ๋“œ ์ •๋ณด ์กฐํšŒ | +| GET | /api/v1/products | ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ | +| GET | /api/v1/products/{productId} | ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ | +| POST | /api/v1/products/{productId}/likes | ์ข‹์•„์š” ๋“ฑ๋ก | +| DELETE | /api/v1/products/{productId}/likes | ์ข‹์•„์š” ์ทจ์†Œ | +| GET | /api/v1/users/{userId}/likes | ๋‚ด๊ฐ€ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ ๋ชฉ๋ก | +| POST | /api/v1/orders | ์ฃผ๋ฌธ ์ƒ์„ฑ | +| GET | /api/v1/orders | ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ | +| GET | /api/v1/orders/{orderId} | ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ | + +### 4.2 ์–ด๋“œ๋ฏผ API + +| Method | Endpoint | ์„ค๋ช… | +|--------|----------|------| +| GET | /api-admin/v1/brands | ๋ธŒ๋žœ๋“œ ๋ชฉ๋ก ์กฐํšŒ | +| GET | /api-admin/v1/brands/{brandId} | ๋ธŒ๋žœ๋“œ ์ƒ์„ธ ์กฐํšŒ | +| POST | /api-admin/v1/brands | ๋ธŒ๋žœ๋“œ ๋“ฑ๋ก | +| PUT | /api-admin/v1/brands/{brandId} | ๋ธŒ๋žœ๋“œ ์ˆ˜์ • | +| DELETE | /api-admin/v1/brands/{brandId} | ๋ธŒ๋žœ๋“œ ์‚ญ์ œ | +| GET | /api-admin/v1/products | ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ | +| GET | /api-admin/v1/products/{productId} | ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ | +| POST | /api-admin/v1/products | ์ƒํ’ˆ ๋“ฑ๋ก | +| PUT | /api-admin/v1/products/{productId} | ์ƒํ’ˆ ์ˆ˜์ • | +| DELETE | /api-admin/v1/products/{productId} | ์ƒํ’ˆ ์‚ญ์ œ | + +--- + +## 5. ์ธ์ฆ ์ฒด๊ณ„ + +### 5.1 ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž ์ธ์ฆ +``` +Headers: + X-Loopers-LoginId: {loginId} + X-Loopers-LoginPw: {password} + +์ฒ˜๋ฆฌ: AuthenticatedUserArgumentResolver +๋Œ€์ƒ: /api/v1/** ์—”๋“œํฌ์ธํŠธ +``` + +### 5.2 ์–ด๋“œ๋ฏผ ์ธ์ฆ +``` +Headers: + X-Loopers-Ldap: loopers.admin + +์ฒ˜๋ฆฌ: AdminAuthInterceptor + AdminUserArgumentResolver +๋Œ€์ƒ: /api-admin/v1/** ์—”๋“œํฌ์ธํŠธ +``` + +--- + +## 6. ์—๋Ÿฌ ํƒ€์ž… ์ •์˜ + +```java +// ErrorType.java์— ์ถ”๊ฐ€ํ•  ํƒ€์ž… +BRAND_NOT_FOUND(HttpStatus.NOT_FOUND, "BRAND_NOT_FOUND", "์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ธŒ๋žœ๋“œ์ž…๋‹ˆ๋‹ค."), +BRAND_ALREADY_EXISTS(HttpStatus.CONFLICT, "BRAND_ALREADY_EXISTS", "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ธŒ๋žœ๋“œ๋ช…์ž…๋‹ˆ๋‹ค."), +BRAND_DELETED(HttpStatus.BAD_REQUEST, "BRAND_DELETED", "์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ์ž…๋‹ˆ๋‹ค."), +PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "PRODUCT_NOT_FOUND", "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค."), +PRODUCT_DELETED(HttpStatus.BAD_REQUEST, "PRODUCT_DELETED", "์‚ญ์ œ๋œ ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค."), +INSUFFICIENT_STOCK(HttpStatus.BAD_REQUEST, "INSUFFICIENT_STOCK", "์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."), +ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "ORDER_NOT_FOUND", "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ฃผ๋ฌธ์ž…๋‹ˆ๋‹ค."), +ORDER_ACCESS_DENIED(HttpStatus.FORBIDDEN, "ORDER_ACCESS_DENIED", "์ฃผ๋ฌธ ์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."), +ADMIN_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "ADMIN_UNAUTHORIZED", "๊ด€๋ฆฌ์ž ๊ถŒํ•œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."), +BRAND_CHANGE_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "BRAND_CHANGE_NOT_ALLOWED", "๋ธŒ๋žœ๋“œ ๋ณ€๊ฒฝ์€ ๋ถˆ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค."); +``` + +--- + +## 7. API ์‘๋‹ต ํ˜•์‹ + +### 7.1 ์„ฑ๊ณต ์‘๋‹ต +```json +{ + "meta": { + "result": "SUCCESS", + "errorCode": null, + "message": null + }, + "data": { ... } +} +``` + +### 7.2 ์‹คํŒจ ์‘๋‹ต +```json +{ + "meta": { + "result": "FAIL", + "errorCode": "PRODUCT_NOT_FOUND", + "message": "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค." + }, + "data": null +} +``` \ No newline at end of file From 5d86f4eac31388992c08971e71c0e8b7d2cb6458 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 13 Feb 2026 16:04:16 +0900 Subject: [PATCH 11/29] =?UTF-8?q?docs:=20=EC=BB=A4=EB=A8=B8=EC=8A=A4=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=8B=9C=ED=80=80=EC=8A=A4=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ์ฃผ๋ฌธ ์ƒ์„ฑ ์‹œํ€€์Šค (์ •์ƒ/์žฌ๊ณ  ๋ถ€์กฑ ํ”Œ๋กœ์šฐ) - ์ข‹์•„์š” ๋“ฑ๋ก ์‹œํ€€์Šค (ํ† ๊ธ€ ๋ฐฉ์‹: ์‹ ๊ทœ/์ทจ์†Œ) - ๋ธŒ๋žœ๋“œ ์‚ญ์ œ ์‹œํ€€์Šค (Cascade ์‚ญ์ œ) - ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ ์‹œํ€€์Šค (์ข‹์•„์š” ์ˆ˜ ํฌํ•จ) - ์–ด๋“œ๋ฏผ ์ธ์ฆ ํ”Œ๋กœ์šฐ (Interceptor + ArgumentResolver) Co-Authored-By: Claude Opus 4.5 --- .docs/design/02-sequence-diagrams.md | 437 +++++++++++++++++++++++++++ 1 file changed, 437 insertions(+) create mode 100644 .docs/design/02-sequence-diagrams.md diff --git a/.docs/design/02-sequence-diagrams.md b/.docs/design/02-sequence-diagrams.md new file mode 100644 index 000000000..faa32ccc6 --- /dev/null +++ b/.docs/design/02-sequence-diagrams.md @@ -0,0 +1,437 @@ +# ์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ + +## ๋‹ค์ด์–ด๊ทธ๋žจ ๋ชฉ์  +์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ์„ ํ†ตํ•ด ๋‹ค์Œ์„ ๊ฒ€์ฆํ•œ๋‹ค: +- ์ฑ…์ž„ ๋ถ„๋ฆฌ: ๊ฐ ๊ฐ์ฒด๊ฐ€ ๋งก์€ ์—ญํ• ์ด ๋ช…ํ™•ํ•œ๊ฐ€ +- ํ˜ธ์ถœ ์ˆœ์„œ: ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์˜ ํ๋ฆ„์ด ์˜ฌ๋ฐ”๋ฅธ๊ฐ€ +- ํŠธ๋žœ์žญ์…˜ ๊ฒฝ๊ณ„: ์›์ž์„ฑ์ด ๋ณด์žฅ๋˜๋Š” ๋ฒ”์œ„๊ฐ€ ์ ์ ˆํ•œ๊ฐ€ + +--- + +## 1. ์ฃผ๋ฌธ ์ƒ์„ฑ ์‹œํ€€์Šค + +### 1.1 ์ •์ƒ ํ๋ฆ„ (๋‹ค๊ฑด ์ฃผ๋ฌธ) + +**๋ชฉ์ **: ์žฌ๊ณ  ๊ฒ€์ฆ, ๋น„๊ด€์  ๋ฝ, ํŠธ๋žœ์žญ์…˜ ๊ฒฝ๊ณ„ ํ™•์ธ + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant Ctrl as OrderV1Controller + participant F as OrderFacade + participant OS as OrderService + participant PS as ProductService + participant OR as OrderRepository + participant PR as ProductRepository + participant DB as Database + + C->>+Ctrl: POST /api/v1/orders + Note over C,Ctrl: Headers: X-Loopers-LoginId, X-Loopers-LoginPw + Note over C,Ctrl: Body: { items: [{productId, quantity}] } + + Ctrl->>+F: createOrder(userId, items) + + F->>+OS: createOrder(userId, items) + + Note over OS: @Transactional ์‹œ์ž‘ + + loop ๊ฐ ์ฃผ๋ฌธ ํ•ญ๋ชฉ์— ๋Œ€ํ•ด + OS->>+PS: getProductForOrder(productId) + PS->>+PR: findByIdWithLock(productId) + PR->>+DB: SELECT ... FOR UPDATE + DB-->>-PR: Product + PR-->>-PS: Product + PS-->>-OS: Product + + OS->>OS: ์žฌ๊ณ  ๊ฒ€์ฆ (stock >= quantity) + + alt ์žฌ๊ณ  ๋ถ€์กฑ + OS-->>F: throw CoreException(INSUFFICIENT_STOCK) + Note over OS,DB: ์ „์ฒด ๋กค๋ฐฑ + end + + OS->>+PS: decreaseStock(productId, quantity) + PS->>+PR: save(product) + PR->>+DB: UPDATE products SET stock = stock - quantity + DB-->>-PR: OK + PR-->>-PS: Product + PS-->>-OS: void + end + + OS->>OS: totalPrice ๊ณ„์‚ฐ + OS->>OS: Order ์—”ํ‹ฐํ‹ฐ ์ƒ์„ฑ + OS->>OS: OrderItem ์—”ํ‹ฐํ‹ฐ๋“ค ์ƒ์„ฑ (๊ฐ€๊ฒฉ ์Šค๋ƒ…์ƒท) + + OS->>+OR: save(order) + OR->>+DB: INSERT orders, order_items + DB-->>-OR: Order (with ID) + OR-->>-OS: Order + + Note over OS: @Transactional ์ปค๋ฐ‹ + + OS-->>-F: Order + F->>F: OrderInfo.from(order) + F-->>-Ctrl: OrderInfo + + Ctrl->>Ctrl: OrderV1Dto.CreateResponse.from(info) + Ctrl-->>-C: 201 Created + { orderId, totalPrice } +``` + +**ํ•ต์‹ฌ ํฌ์ธํŠธ:** +- `SELECT ... FOR UPDATE`๋กœ ๋น„๊ด€์  ๋ฝ ํš๋“ โ†’ ๋™์‹œ ์ฃผ๋ฌธ ์‹œ ์žฌ๊ณ  ๊ฒฝ์Ÿ ๋ฐฉ์ง€ +- ๋ชจ๋“  ์ƒํ’ˆ ๊ฒ€์ฆ ํ›„ ์ฐจ๊ฐ โ†’ ํ•˜๋‚˜๋ผ๋„ ์‹คํŒจ ์‹œ ์ „์ฒด ๋กค๋ฐฑ +- OrderItem์— ๊ฐ€๊ฒฉ ์Šค๋ƒ…์ƒท ์ €์žฅ โ†’ ์ƒํ’ˆ ๊ฐ€๊ฒฉ ๋ณ€๊ฒฝ ์‹œ์—๋„ ์ฃผ๋ฌธ ๊ฐ€๊ฒฉ ์œ ์ง€ + +--- + +### 1.2 ์žฌ๊ณ  ๋ถ€์กฑ ์‹คํŒจ ํ๋ฆ„ + +**๋ชฉ์ **: ์ „์ฒด ์‹คํŒจ ์ •์ฑ…, ๋กค๋ฐฑ ๋™์ž‘ ํ™•์ธ + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant Ctrl as OrderV1Controller + participant F as OrderFacade + participant OS as OrderService + participant PS as ProductService + participant PR as ProductRepository + participant DB as Database + + C->>+Ctrl: POST /api/v1/orders + Note over C,Ctrl: items: [{productId: 1, qty: 10}, {productId: 2, qty: 5}] + + Ctrl->>+F: createOrder(userId, items) + F->>+OS: createOrder(userId, items) + + Note over OS: @Transactional ์‹œ์ž‘ + + OS->>+PS: getProductForOrder(productId: 1) + PS->>+PR: findByIdWithLock(productId: 1) + PR->>+DB: SELECT ... FOR UPDATE + DB-->>-PR: Product (stock: 10) + PR-->>-PS: Product + PS-->>-OS: Product + OS->>OS: ์žฌ๊ณ  ๊ฒ€์ฆ ํ†ต๊ณผ (10 >= 10) + OS->>PS: decreaseStock(1, 10) + + OS->>+PS: getProductForOrder(productId: 2) + PS->>+PR: findByIdWithLock(productId: 2) + PR->>+DB: SELECT ... FOR UPDATE + DB-->>-PR: Product (stock: 3) + PR-->>-PS: Product + PS-->>-OS: Product + + OS->>OS: ์žฌ๊ณ  ๊ฒ€์ฆ ์‹คํŒจ (3 < 5) + + OS-->>-F: throw CoreException(INSUFFICIENT_STOCK) + Note over OS,DB: ์ „์ฒด ๋กค๋ฐฑ (์ƒํ’ˆ1 ์žฌ๊ณ  ๋ณต๊ตฌ) + + F-->>-Ctrl: throw CoreException + Ctrl-->>-C: 400 Bad Request + INSUFFICIENT_STOCK +``` + +**ํ•ต์‹ฌ ํฌ์ธํŠธ:** +- ๋‘ ๋ฒˆ์งธ ์ƒํ’ˆ ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ ์ฒซ ๋ฒˆ์งธ ์ƒํ’ˆ ์žฌ๊ณ  ์ฐจ๊ฐ๋„ ๋กค๋ฐฑ +- ํŠธ๋žœ์žญ์…˜ ๋‹จ์œ„๋กœ ์›์ž์„ฑ ๋ณด์žฅ + +--- + +## 2. ์ข‹์•„์š” ๋“ฑ๋ก ์‹œํ€€์Šค (ํ† ๊ธ€ ๋ฐฉ์‹) + +### 2.1 ์‹ ๊ทœ ์ข‹์•„์š” ๋“ฑ๋ก + +**๋ชฉ์ **: ์ƒํ’ˆ ์œ ํšจ์„ฑ ๊ฒ€์ฆ ๋ฐ ์ข‹์•„์š” ๋“ฑ๋ก ํ™•์ธ + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant Ctrl as ProductLikeV1Controller + participant F as ProductLikeFacade + participant LS as ProductLikeService + participant PS as ProductService + participant LR as ProductLikeRepository + participant DB as Database + + C->>+Ctrl: POST /api/v1/products/{productId}/likes + Note over C,Ctrl: Headers: X-Loopers-LoginId, X-Loopers-LoginPw + + Ctrl->>+F: like(userId, productId) + + F->>+LS: like(userId, productId) + + LS->>+PS: getProduct(productId) + PS-->>-LS: Product + + LS->>LS: ์ƒํ’ˆ ์‚ญ์ œ ์—ฌ๋ถ€ ๊ฒ€์ฆ + + LS->>+LR: findByUserIdAndProductId(userId, productId) + LR->>+DB: SELECT FROM product_likes + DB-->>-LR: null (๋ฏธ์กด์žฌ) + LR-->>-LS: Optional (empty) + + LS->>LS: ProductLike ์—”ํ‹ฐํ‹ฐ ์ƒ์„ฑ (์‹ ๊ทœ ๋“ฑ๋ก) + + LS->>+LR: save(productLike) + LR->>+DB: INSERT product_likes + DB-->>-LR: ProductLike + LR-->>-LS: ProductLike + + LS-->>-F: ProductLike + F-->>-Ctrl: void + + Ctrl-->>-C: 200 OK + { message: "์ข‹์•„์š”๊ฐ€ ๋“ฑ๋ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค." } +``` +**ํ•ต์‹ฌ ํฌ์ธํŠธ:** + +- *์ข‹์•„์š” ๊ธฐ๋Šฅ ์„ค๊ณ„ ์‹œ ์•„๋ž˜์™€ ๊ฐ™์€ ๋‘ ๊ฐ€์ง€ ์„ ํƒ์ง€๊ฐ€ ์žˆ์—ˆ๋‹ค. ์ •๋ ฌ ์ฟผ๋ฆฌ๋ฅผ ์œ„ํ•ด Product ๋‚ด๋ถ€์— ์ข‹์•„์š” ํ•„๋“œ๋ฅผ ๋‘๋Š” ๋ฐฉ๋ฒ•๊ณผ ์ข‹์•„์š” ํ…Œ์ด๋ธ”์„ ๋”ฐ๋กœ ๋‘๋Š” ์„ ํƒ์ง€ ์ค‘ ์ •ํ•ฉ์„ฑ์„ ๋†’์ด๋Š” ๋ฐฉ์‹์„ ์„ ํƒํ–ˆ๋‹ค.* + +*1. ๋น„์ •๊ทœํ™”: Product์— likeCount ํ•„๋“œ๋ฅผ ๋‘๊ณ  ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ ์‹œ ๋™๊ธฐ ์—…๋ฐ์ดํŠธ. ์ •๋ ฌ ์ฟผ๋ฆฌ ์„ฑ๋Šฅ ์šฐ์ˆ˜* + +*2. ์‹ค์‹œ๊ฐ„ ์ง‘๊ณ„(์ ์šฉ): ์ข‹์•„์š” ํ…Œ์ด๋ธ”์—์„œ COUNT ์ง‘๊ณ„. ์ •ํ•ฉ์„ฑ ๋†’์œผ๋‚˜ ์ •๋ ฌ ์‹œ ์ฟผ๋ฆฌ ๋น„์šฉ ์ฆ๊ฐ€* + + + + +- ์ƒํ’ˆ ์กด์žฌ ๋ฐ ์‚ญ์ œ ์—ฌ๋ถ€ ๋จผ์ € ๊ฒ€์ฆ +- ๊ธฐ์กด ์ข‹์•„์š”๊ฐ€ ์—†์œผ๋ฉด ์‹ ๊ทœ ๋“ฑ๋ก + +--- + +### 2.2 ๊ธฐ์กด ์ข‹์•„์š” ์กด์žฌ ์‹œ (ํ† ๊ธ€ - ์ทจ์†Œ ์ฒ˜๋ฆฌ) + +**๋ชฉ์ **: ํ† ๊ธ€ ๋ฐฉ์‹ ๋™์ž‘ ํ™•์ธ - ์ด๋ฏธ ์ข‹์•„์š”๊ฐ€ ์žˆ์œผ๋ฉด ์ทจ์†Œ + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant Ctrl as ProductLikeV1Controller + participant F as ProductLikeFacade + participant LS as ProductLikeService + participant PS as ProductService + participant LR as ProductLikeRepository + participant DB as Database + + C->>+Ctrl: POST /api/v1/products/{productId}/likes + + Ctrl->>+F: like(userId, productId) + F->>+LS: like(userId, productId) + + LS->>+PS: getProduct(productId) + PS-->>-LS: Product + + LS->>+LR: findByUserIdAndProductId(userId, productId) + LR->>+DB: SELECT FROM product_likes + DB-->>-LR: ProductLike (์กด์žฌ) + LR-->>-LS: Optional (present) + + Note over LS: ์ด๋ฏธ ์กด์žฌํ•˜๋ฏ€๋กœ ์ข‹์•„์š” ์ทจ์†Œ (ํ† ๊ธ€) + + LS->>+LR: delete(productLike) + LR->>+DB: DELETE FROM product_likes + DB-->>-LR: OK + LR-->>-LS: void + + LS-->>-F: void + F-->>-Ctrl: void + + Ctrl-->>-C: 200 OK + { message: "์ข‹์•„์š”๊ฐ€ ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." } +``` + +**ํ•ต์‹ฌ ํฌ์ธํŠธ:** +- ์ข‹์•„์š”๊ฐ€ ์ด๋ฏธ ์กด์žฌํ•˜๋ฉด ์‚ญ์ œ (ํ† ๊ธ€ ๋ฐฉ์‹) +- POST ์š”์ฒญ ํ•œ ๋ฒˆ์œผ๋กœ ๋“ฑ๋ก/์ทจ์†Œ ๋ชจ๋‘ ์ฒ˜๋ฆฌ + +--- + +## 3. ๋ธŒ๋žœ๋“œ ์‚ญ์ œ ์‹œํ€€์Šค (Cascade ์‚ญ์ œ) + +**๋ชฉ์ **: Cascade ์‚ญ์ œ ์ˆœ์„œ, ํŠธ๋žœ์žญ์…˜ ๊ฒฝ๊ณ„ ํ™•์ธ + +```mermaid +sequenceDiagram + autonumber + participant C as Admin Client + participant Int as AdminAuthInterceptor + participant Ctrl as BrandAdminV1Controller + participant F as BrandFacade + participant BS as BrandService + participant PS as ProductService + participant LS as ProductLikeService + participant BR as BrandRepository + participant PR as ProductRepository + participant LR as ProductLikeRepository + participant DB as Database + + C->>+Int: DELETE /api-admin/v1/brands/{brandId} + Note over C,Int: Headers: X-Loopers-Ldap: loopers.admin + + Int->>Int: Admin ๊ถŒํ•œ ๊ฒ€์ฆ + Int->>+Ctrl: ์š”์ฒญ ์ „๋‹ฌ + + Ctrl->>+F: deleteBrand(brandId) + F->>+BS: deleteBrand(brandId) + + Note over BS: @Transactional ์‹œ์ž‘ + + BS->>+BR: findById(brandId) + BR->>+DB: SELECT FROM brands + DB-->>-BR: Brand + BR-->>-BS: Brand + + alt ๋ธŒ๋žœ๋“œ ์—†์Œ + BS-->>F: throw CoreException(BRAND_NOT_FOUND) + end + + BS->>+PS: getProductsByBrandId(brandId) + PS->>+PR: findAllByBrandId(brandId) + PR->>+DB: SELECT FROM products WHERE brand_id = ? + DB-->>-PR: List + PR-->>-PS: List + PS-->>-BS: List + + loop ๊ฐ ์ƒํ’ˆ์— ๋Œ€ํ•ด + BS->>+LS: deleteAllByProductId(productId) + LS->>+LR: deleteAllByProductId(productId) + LR->>+DB: DELETE FROM product_likes WHERE product_id = ? + DB-->>-LR: OK + LR-->>-LS: void + LS-->>-BS: void + + BS->>+PS: deleteProduct(productId) + PS->>PS: product.delete() + PS->>+PR: save(product) + PR->>+DB: UPDATE products SET deleted_at = NOW() + DB-->>-PR: OK + PR-->>-PS: Product + PS-->>-BS: void + end + + BS->>BS: brand.delete() + BS->>+BR: save(brand) + BR->>+DB: UPDATE brands SET deleted_at = NOW() + DB-->>-BR: OK + BR-->>-BS: Brand + + Note over BS: @Transactional ์ปค๋ฐ‹ + + BS-->>-F: void + F-->>-Ctrl: void + + Ctrl-->>-C: 200 OK + { message: "๋ธŒ๋žœ๋“œ๊ฐ€ ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." } +``` + +**ํ•ต์‹ฌ ํฌ์ธํŠธ:** +- ์‚ญ์ œ ์ˆœ์„œ: ์ข‹์•„์š”(Hard) โ†’ ์ƒํ’ˆ(Soft) โ†’ ๋ธŒ๋žœ๋“œ(Soft) +- ๋‹จ์ผ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์›์ž์„ฑ ๋ณด์žฅ +- ์ข‹์•„์š”๋Š” Hard Delete, ์ƒํ’ˆ/๋ธŒ๋žœ๋“œ๋Š” Soft Delete + +--- + +## 4. ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ ์‹œํ€€์Šค (์ข‹์•„์š” ์ˆ˜ ํฌํ•จ) + +**๋ชฉ์ **: ์ข‹์•„์š” ์‹ค์‹œ๊ฐ„ ์ง‘๊ณ„, ์ •๋ ฌ ์˜ต์…˜ ์ฒ˜๋ฆฌ ํ™•์ธ + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant Ctrl as ProductV1Controller + participant F as ProductFacade + participant PS as ProductService + participant LS as ProductLikeService + participant PR as ProductRepository + participant LR as ProductLikeRepository + participant DB as Database + + C->>+Ctrl: GET /api/v1/products?sort=like_desc&brandId=1&page=0&size=20 + Note over C,Ctrl: Headers: X-Loopers-LoginId, X-Loopers-LoginPw + + Ctrl->>+F: getProducts(sort, brandId, pageable) + + F->>+PS: getProducts(sort, brandId, pageable) + + alt sort = like_desc (์ข‹์•„์š” ๋งŽ์€์ˆœ) + PS->>+PR: findAllOrderByLikeCountDesc(brandId, pageable) + PR->>+DB: SELECT p.*, COUNT(pl.id) as like_count
FROM products p
LEFT JOIN product_likes pl
GROUP BY p.id
ORDER BY like_count DESC + DB-->>-PR: Page + PR-->>-PS: Page + else sort = latest | price_asc + PS->>+PR: findAll(brandId, pageable, sort) + PR->>+DB: SELECT FROM products WHERE ... + DB-->>-PR: Page + PR-->>-PS: Page + + PS->>+LS: getLikeCounts(productIds) + LS->>+LR: countByProductIdIn(productIds) + LR->>+DB: SELECT product_id, COUNT(*)
FROM product_likes
WHERE product_id IN (...)
GROUP BY product_id + DB-->>-LR: Map + LR-->>-LS: Map + LS-->>-PS: Map + end + + PS-->>-F: Page + + F->>F: List.from(products) + F-->>-Ctrl: Page + + Ctrl->>Ctrl: ProductV1Dto.ListResponse.from(page) + Ctrl-->>-C: 200 OK + { products: [...], pageInfo: {...} } +``` + +**ํ•ต์‹ฌ ํฌ์ธํŠธ:** +- `like_desc` ์ •๋ ฌ ์‹œ JOIN + COUNT๋กœ ํ•œ ๋ฒˆ์— ์กฐํšŒ +- ๋‹ค๋ฅธ ์ •๋ ฌ ์‹œ ์ƒํ’ˆ ์กฐํšŒ ํ›„ ์ข‹์•„์š” ์ˆ˜ ๋ณ„๋„ ์กฐํšŒ (N+1 ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด IN ์ฟผ๋ฆฌ ์‚ฌ์šฉ) +- *์ฟ ํŒก๊ณผ ์˜ค๋Š˜์˜ ์ง‘์—์„œ ํ•˜๋“ฏ์ด, ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ ์‹œ ๊ธฐ๊ฐ„(startAt, endAt)์œผ๋กœ ์กฐํšŒํ•˜๋Š” ๋ฐฉ์•ˆ์„ ๊ฒ€ํ† ํ•ด ๋ณด์•˜์œผ๋‚˜ ์„ค๊ณ„์— ์ง‘์ค‘ํ•˜๊ธฐ ์œ„ํ•ด ๋„ฃ์ง€ ์•Š์•˜๋‹ค.* +--- + +## 5. ์–ด๋“œ๋ฏผ ์ธ์ฆ ํ๋ฆ„ + +**๋ชฉ์ **: Interceptor + ArgumentResolver ์กฐํ•ฉ ํ™•์ธ + +```mermaid +sequenceDiagram + autonumber + participant C as Admin Client + participant F as Filter Chain + participant Int as AdminAuthInterceptor + participant AR as AdminUserArgumentResolver + participant Ctrl as AdminController + + C->>+F: Request to /api-admin/v1/** + Note over C,F: Headers: X-Loopers-Ldap: loopers.admin + + F->>+Int: preHandle() + + Int->>Int: Extract X-Loopers-Ldap header + + alt Header missing + Int-->>C: 401 Unauthorized
ADMIN_UNAUTHORIZED + else Header != "loopers.admin" + Int-->>C: 401 Unauthorized
ADMIN_UNAUTHORIZED + else Header = "loopers.admin" + Int-->>-F: true (continue) + end + + F->>+AR: resolveArgument() + Note over AR: AdminUser ํŒŒ๋ผ๋ฏธํ„ฐ ์กด์žฌ ์‹œ + AR->>AR: Create AdminUser object + AR-->>-F: AdminUser + + F->>+Ctrl: Controller method + Ctrl-->>-F: Response + F-->>-C: Response +``` + +**ํ•ต์‹ฌ ํฌ์ธํŠธ:** +- Interceptor๊ฐ€ 1์ฐจ ๋ฐฉ์–ด์„  (ํ—ค๋” ๋ˆ„๋ฝ/๋ถˆ์ผ์น˜ ์‹œ 401) +- ArgumentResolver๋Š” ์ปจํŠธ๋กค๋Ÿฌ์— AdminUser ๊ฐ์ฒด ์ฃผ์ž… +- ์ด์ค‘ ์•ˆ์ „์žฅ์น˜๋กœ ๋ณด์•ˆ์„ฑ ๊ฐ•ํ™” +- *Interceptor ๋ฐฉ์‹์€ ์ปจํŠธ๋กค๋Ÿฌ์—์„œ ์–ด๋“œ๋ฏผ ์ •๋ณด ์ ‘๊ทผ ์‹œ Request์—์„œ ๋‹ค์‹œ ์ถ”์ถœ ํ•„์š”ํ•˜๊ณ  ํŠน์ • ๋ฉ”์„œ๋“œ๋งŒ ์˜ˆ์™ธ ์ฒ˜๋ฆฌํ•˜๋ ค๋ฉด ์ถ”๊ฐ€ ๋กœ์ง ํ•„์š”ํ•œ ๋ฌธ์ œ* +- *ArgumentResolver๋Š” ๋ชจ๋“  ๋ฉ”์„œ๋“œ์— @AdminAuth ํŒŒ๋ผ๋ฏธํ„ฐ ์ถ”๊ฐ€ ํ•„์š”ํ•˜๊ณ , ์‹ค์ˆ˜๋กœ ์–ด๋…ธํ…Œ์ด์…˜์„ ๋ˆ„๋ฝํ•˜๋ฉด ๋ณด์•ˆ ์œ„ํ—˜ํ•œ ๋ฌธ์ œ* + +*-> Interceptor + ArgumentResolver ์กฐํ•ฉ์œผ๋กœ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ–ˆ๋‹ค.* \ No newline at end of file From 12f94c3d56f280b8b00af936a0f67c3bda9a0981 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 13 Feb 2026 16:04:29 +0900 Subject: [PATCH 12/29] =?UTF-8?q?docs:=20=EC=BB=A4=EB=A8=B8=EC=8A=A4=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ์ „์ฒด ๊ณ„์ธต ๊ตฌ์กฐ ๊ฐœ์š” (Layered Architecture) - Brand/Product/ProductLike/Order ๋„๋ฉ”์ธ ํด๋ž˜์Šค - ์ธ์ฆ ๊ด€๋ จ ํด๋ž˜์Šค (AdminAuthInterceptor, AdminUserArgumentResolver) - ๊ณตํ†ต ํด๋ž˜์Šค (BaseEntity, ApiResponse, CoreException, ErrorType) Co-Authored-By: Claude Opus 4.5 --- .docs/design/03-class-diagram.md | 700 +++++++++++++++++++++++++++++++ 1 file changed, 700 insertions(+) create mode 100644 .docs/design/03-class-diagram.md diff --git a/.docs/design/03-class-diagram.md b/.docs/design/03-class-diagram.md new file mode 100644 index 000000000..bb4152a67 --- /dev/null +++ b/.docs/design/03-class-diagram.md @@ -0,0 +1,700 @@ +# ํด๋ž˜์Šค ๋‹ค์ด์–ด๊ทธ๋žจ + +## ๋‹ค์ด์–ด๊ทธ๋žจ ๋ชฉ์  +ํด๋ž˜์Šค ๋‹ค์ด์–ด๊ทธ๋žจ์„ ํ†ตํ•ด ๋‹ค์Œ์„ ๊ฒ€์ฆํ•œ๋‹ค: +- ๋„๋ฉ”์ธ ์ฑ…์ž„: ๊ฐ ๋„๋ฉ”์ธ์˜ ์—ญํ• ์ด ๋ช…ํ™•ํ•œ๊ฐ€ +- ์˜์กด ๋ฐฉํ–ฅ: ์ƒ์œ„ ๊ณ„์ธต์ด ํ•˜์œ„ ๊ณ„์ธต์—๋งŒ ์˜์กดํ•˜๋Š”๊ฐ€ +- ์‘์ง‘๋„: ๊ด€๋ จ ๊ธฐ๋Šฅ์ด ์ ์ ˆํžˆ ๊ทธ๋ฃนํ™”๋˜์–ด ์žˆ๋Š”๊ฐ€ + +--- + +## 1. ์ „์ฒด ๊ณ„์ธต ๊ตฌ์กฐ ๊ฐœ์š” + +```mermaid +classDiagram + direction TB + + namespace Interfaces { + class Controller + class ApiSpec + class Dto + class ArgumentResolver + class Interceptor + } + + namespace Application { + class Facade + class Info + } + + namespace Domain { + class Entity + class Service + class Repository + } + + namespace Infrastructure { + class RepositoryImpl + class JpaRepository + } + + Controller --> Facade : uses + Controller --> Dto : uses + Facade --> Service : uses + Facade --> Info : returns + Service --> Repository : uses + Service --> Entity : uses + RepositoryImpl ..|> Repository : implements + RepositoryImpl --> JpaRepository : uses +``` + +**๊ณ„์ธต๋ณ„ ์ฑ…์ž„:** +- **Interfaces**: HTTP ์š”์ฒญ/์‘๋‹ต ์ฒ˜๋ฆฌ, DTO ๋ณ€ํ™˜, ์ธ์ฆ ์ฒ˜๋ฆฌ +- **Application**: ์œ ์Šค์ผ€์ด์Šค ์กฐ์œจ, ๋„๋ฉ”์ธ โ†” ํ”„๋ ˆ์  ํ…Œ์ด์…˜ ๋ณ€ํ™˜ +- **Domain**: ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง, ์—”ํ‹ฐํ‹ฐ ๊ฒ€์ฆ, ๋„๋ฉ”์ธ ๊ทœ์น™ +- **Infrastructure**: ๋ฐ์ดํ„ฐ ์ ‘๊ทผ, ์™ธ๋ถ€ ์‹œ์Šคํ…œ ์—ฐ๋™ + +--- + +## 2. Brand ๋„๋ฉ”์ธ ํด๋ž˜์Šค + +```mermaid +classDiagram + direction TB + + %% Interfaces Layer + class BrandV1Controller { + -BrandFacade brandFacade + +getBrand(Long brandId) ApiResponse~BrandDto.Response~ + } + + class BrandAdminV1Controller { + -BrandFacade brandFacade + +getBrands(Pageable) ApiResponse~Page~ + +getBrand(Long brandId) ApiResponse~BrandDto.Response~ + +createBrand(CreateRequest) ApiResponse~CreateResponse~ + +updateBrand(Long, UpdateRequest) ApiResponse~Response~ + +deleteBrand(Long brandId) ApiResponse~Void~ + } + + class BrandV1Dto { + <> + } + class Response { + <> + +Long id + +String name + +String description + +String logoUrl + +from(BrandInfo) Response + } + class CreateRequest { + <> + +String name + +String description + +String logoUrl + } + class CreateResponse { + <> + +Long brandId + } + class UpdateRequest { + <> + +String name + +String description + +String logoUrl + } + + %% Application Layer + class BrandFacade { + -BrandService brandService + +getBrand(Long brandId) BrandInfo + +getBrands(Pageable) Page~BrandInfo~ + +createBrand(String, String, String) BrandInfo + +updateBrand(Long, String, String, String) BrandInfo + +deleteBrand(Long brandId) void + } + + class BrandInfo { + <> + +Long id + +String name + +String description + +String logoUrl + +from(Brand) BrandInfo + } + + %% Domain Layer + class Brand { + -String name + -String description + -String logoUrl + +Brand(String, String, String) + +update(String, String, String) void + #guard() void + } + + class BrandService { + -BrandRepository brandRepository + +getBrand(Long brandId) Brand + +getBrands(Pageable) Page~Brand~ + +createBrand(String, String, String) Brand + +updateBrand(Long, String, String, String) Brand + +deleteBrand(Long brandId) void + } + + class BrandRepository { + <> + +findById(Long) Optional~Brand~ + +findAll(Pageable) Page~Brand~ + +save(Brand) Brand + +existsByName(String) boolean + } + + %% Infrastructure Layer + class BrandRepositoryImpl { + -BrandJpaRepository brandJpaRepository + } + + class BrandJpaRepository { + <> + +findByName(String) Optional~Brand~ + +existsByNameAndDeletedAtIsNull(String) boolean + } + + %% Relationships + BrandV1Controller --> BrandFacade + BrandAdminV1Controller --> BrandFacade + BrandFacade --> BrandService + BrandFacade --> BrandInfo + BrandService --> BrandRepository + BrandService --> Brand + BrandRepositoryImpl ..|> BrandRepository + BrandRepositoryImpl --> BrandJpaRepository + Brand --|> BaseEntity + + BrandV1Dto ..> Response + BrandV1Dto ..> CreateRequest + BrandV1Dto ..> CreateResponse + BrandV1Dto ..> UpdateRequest +``` + +--- + +## 3. Product ๋„๋ฉ”์ธ ํด๋ž˜์Šค + +```mermaid +classDiagram + direction TB + + %% Interfaces Layer + class ProductV1Controller { + -ProductFacade productFacade + +getProducts(String sort, Long brandId, Pageable) ApiResponse~Page~ + +getProduct(Long productId) ApiResponse~DetailResponse~ + } + + class ProductAdminV1Controller { + -ProductFacade productFacade + +getProducts(Pageable, Long brandId) ApiResponse~Page~ + +getProduct(Long productId) ApiResponse~AdminDetailResponse~ + +createProduct(CreateRequest) ApiResponse~CreateResponse~ + +updateProduct(Long, UpdateRequest) ApiResponse~Response~ + +deleteProduct(Long productId) ApiResponse~Void~ + } + + %% Application Layer + class ProductFacade { + -ProductService productService + -ProductLikeService productLikeService + -BrandService brandService + +getProducts(String, Long, Pageable) Page~ProductInfo~ + +getProduct(Long productId) ProductInfo + +createProduct(Long, String, String, Long, Integer, String) ProductInfo + +updateProduct(Long, String, String, Long, Integer, String) ProductInfo + +deleteProduct(Long productId) void + } + + class ProductInfo { + <> + +Long id + +Long brandId + +String brandName + +String name + +String description + +Long price + +Integer stock + +String imageUrl + +Long likeCount + +from(Product, Long) ProductInfo + } + + %% Domain Layer + class Product { + -Long brandId + -String name + -String description + -Long price + -Integer stock + -String imageUrl + +Product(Long, String, String, Long, Integer, String) + +update(String, String, Long, Integer, String) void + +decreaseStock(int quantity) void + +increaseStock(int quantity) void + #guard() void + } + + class ProductService { + -ProductRepository productRepository + -BrandRepository brandRepository + +getProduct(Long productId) Product + +getProductForOrder(Long productId) Product + +getProducts(String, Long, Pageable) Page~Product~ + +getProductsByBrandId(Long brandId) List~Product~ + +createProduct(...) Product + +updateProduct(...) Product + +deleteProduct(Long productId) void + +decreaseStock(Long productId, int quantity) void + } + + class ProductRepository { + <> + +findById(Long) Optional~Product~ + +findByIdWithLock(Long) Optional~Product~ + +findAll(String, Long, Pageable) Page~Product~ + +findAllByBrandId(Long) List~Product~ + +findAllOrderByLikeCountDesc(Long, Pageable) Page~Object[]~ + +save(Product) Product + } + + %% Infrastructure Layer + class ProductRepositoryImpl { + -ProductJpaRepository productJpaRepository + -JPAQueryFactory queryFactory + } + + class ProductJpaRepository { + <> + } + + %% Relationships + ProductV1Controller --> ProductFacade + ProductAdminV1Controller --> ProductFacade + ProductFacade --> ProductService + ProductFacade --> ProductInfo + ProductService --> ProductRepository + ProductService --> Product + ProductRepositoryImpl ..|> ProductRepository + ProductRepositoryImpl --> ProductJpaRepository + Product --|> BaseEntity +``` + +--- + +## 4. ProductLike ๋„๋ฉ”์ธ ํด๋ž˜์Šค + +```mermaid +classDiagram + direction TB + + %% Interfaces Layer + class ProductLikeV1Controller { + -ProductLikeFacade productLikeFacade + +like(AuthenticatedUser, Long productId) ApiResponse~Void~ + +unlike(AuthenticatedUser, Long productId) ApiResponse~Void~ + +getMyLikes(AuthenticatedUser, Long userId) ApiResponse~List~ + } + + %% Application Layer + class ProductLikeFacade { + -ProductLikeService productLikeService + -ProductService productService + -UserService userService + +like(Long userId, Long productId) void + +unlike(Long userId, Long productId) void + +getMyLikes(Long userId) List~ProductLikeInfo~ + } + + class ProductLikeInfo { + <> + +Long productId + +String productName + +Long price + +String imageUrl + +ZonedDateTime likedAt + } + + %% Domain Layer + class ProductLike { + -Long userId + -Long productId + +ProductLike(Long userId, Long productId) + +getUserId() Long + +getProductId() Long + } + + class ProductLikeService { + -ProductLikeRepository productLikeRepository + +like(Long userId, Long productId) ProductLike + +unlike(Long userId, Long productId) void + +existsByUserIdAndProductId(Long, Long) boolean + +countByProductId(Long productId) Long + +getLikeCounts(List~Long~ productIds) Map~Long_Long~ + +getByUserId(Long userId) List~ProductLike~ + +deleteAllByProductId(Long productId) void + } + + class ProductLikeRepository { + <> + +findByUserIdAndProductId(Long, Long) Optional~ProductLike~ + +existsByUserIdAndProductId(Long, Long) boolean + +countByProductId(Long) Long + +countByProductIdIn(List~Long~) List~Object[]~ + +findAllByUserId(Long) List~ProductLike~ + +deleteByUserIdAndProductId(Long, Long) void + +deleteAllByProductId(Long) void + +save(ProductLike) ProductLike + } + + %% Infrastructure Layer + class ProductLikeRepositoryImpl { + -ProductLikeJpaRepository productLikeJpaRepository + } + + class ProductLikeJpaRepository { + <> + } + + %% Relationships + ProductLikeV1Controller --> ProductLikeFacade + ProductLikeFacade --> ProductLikeService + ProductLikeFacade --> ProductLikeInfo + ProductLikeService --> ProductLikeRepository + ProductLikeService --> ProductLike + ProductLikeRepositoryImpl ..|> ProductLikeRepository + ProductLikeRepositoryImpl --> ProductLikeJpaRepository + ProductLike --|> BaseEntity +``` + +--- + +## 5. Order ๋„๋ฉ”์ธ ํด๋ž˜์Šค + +```mermaid +classDiagram + direction TB + + %% Interfaces Layer + class OrderV1Controller { + -OrderFacade orderFacade + +createOrder(AuthenticatedUser, CreateRequest) ApiResponse~CreateResponse~ + +getOrders(AuthenticatedUser, LocalDate, LocalDate, Pageable) ApiResponse~Page~ + +getOrder(AuthenticatedUser, Long orderId) ApiResponse~DetailResponse~ + } + + class OrderV1Dto { + <> + } + + class CreateRequest { + <> + +List~OrderItemRequest~ items + } + + class OrderItemRequest { + <> + +Long productId + +Integer quantity + } + + class CreateResponse { + <> + +Long orderId + +Long totalPrice + } + + class ListResponse { + <> + +Long orderId + +Long totalPrice + +String status + +ZonedDateTime createdAt + } + + class DetailResponse { + <> + +Long orderId + +Long totalPrice + +String status + +List~OrderItemResponse~ items + +ZonedDateTime createdAt + } + + class OrderItemResponse { + <> + +Long productId + +String productName + +Integer quantity + +Long price + } + + %% Application Layer + class OrderFacade { + -OrderService orderService + -ProductService productService + -UserService userService + +createOrder(Long userId, List~OrderItemRequest~) OrderInfo + +getOrders(Long userId, LocalDate, LocalDate, Pageable) Page~OrderInfo~ + +getOrder(Long userId, Long orderId) OrderInfo + } + + class OrderInfo { + <> + +Long id + +Long userId + +Long totalPrice + +OrderStatus status + +List~OrderItemInfo~ items + +ZonedDateTime createdAt + +from(Order) OrderInfo + } + + class OrderItemInfo { + <> + +Long productId + +String productName + +Integer quantity + +Long price + +from(OrderItem) OrderItemInfo + } + + %% Domain Layer + class Order { + -Long userId + -Long totalPrice + -OrderStatus status + -List~OrderItem~ orderItems + +Order(Long userId) + +addItem(OrderItem item) void + +calculateTotalPrice() void + +complete() void + +cancel() void + } + + class OrderItem { + -Order order + -Long productId + -String productName + -Integer quantity + -Long price + +OrderItem(Long, String, Integer, Long) + +setOrder(Order order) void + +getSubtotal() Long + } + + class OrderStatus { + <> + PENDING + COMPLETED + CANCELLED + } + + class OrderService { + -OrderRepository orderRepository + -ProductService productService + +createOrder(Long userId, List~OrderItemRequest~) Order + +getOrder(Long orderId) Order + +getOrders(Long userId, LocalDate, LocalDate, Pageable) Page~Order~ + +validateOrderAccess(Long userId, Order order) void + } + + class OrderRepository { + <> + +findById(Long) Optional~Order~ + +findByUserId(Long, Pageable) Page~Order~ + +findByUserIdAndCreatedAtBetween(...) Page~Order~ + +save(Order) Order + } + + %% Infrastructure Layer + class OrderRepositoryImpl { + -OrderJpaRepository orderJpaRepository + } + + class OrderJpaRepository { + <> + } + + %% Relationships + OrderV1Controller --> OrderFacade + OrderFacade --> OrderService + OrderFacade --> OrderInfo + OrderService --> OrderRepository + OrderService --> Order + Order --> OrderItem + Order --> OrderStatus + OrderRepositoryImpl ..|> OrderRepository + OrderRepositoryImpl --> OrderJpaRepository + Order --|> BaseEntity + OrderItem --|> BaseEntity + + OrderV1Dto ..> CreateRequest + OrderV1Dto ..> OrderItemRequest + OrderV1Dto ..> CreateResponse + OrderV1Dto ..> ListResponse + OrderV1Dto ..> DetailResponse + OrderV1Dto ..> OrderItemResponse +``` + +--- + +## 6. ์ธ์ฆ ๊ด€๋ จ ํด๋ž˜์Šค + +```mermaid +classDiagram + direction TB + + %% ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž ์ธ์ฆ (๊ธฐ์กด) + class AuthenticatedUser { + <> + +String loginId + +String password + } + + class AuthenticatedUserArgumentResolver { + -UserService userService + +supportsParameter(MethodParameter) boolean + +resolveArgument(...) Object + } + + %% ์–ด๋“œ๋ฏผ ์ธ์ฆ (์‹ ๊ทœ) + class AdminUser { + <> + +String ldapId + } + + class AdminAuthInterceptor { + -String ADMIN_LDAP_HEADER + -String ADMIN_LDAP_VALUE + +preHandle(HttpServletRequest, HttpServletResponse, Object) boolean + } + + class AdminUserArgumentResolver { + +supportsParameter(MethodParameter) boolean + +resolveArgument(...) Object + } + + %% WebMvcConfig + class WebMvcConfig { + -AuthenticatedUserArgumentResolver authResolver + -AdminUserArgumentResolver adminResolver + -AdminAuthInterceptor adminInterceptor + +addArgumentResolvers(List) void + +addInterceptors(InterceptorRegistry) void + } + + %% Interfaces + class HandlerMethodArgumentResolver { + <> + } + + class HandlerInterceptor { + <> + } + + %% Relationships + WebMvcConfig --> AuthenticatedUserArgumentResolver + WebMvcConfig --> AdminUserArgumentResolver + WebMvcConfig --> AdminAuthInterceptor + AuthenticatedUserArgumentResolver ..|> HandlerMethodArgumentResolver + AdminUserArgumentResolver ..|> HandlerMethodArgumentResolver + AdminAuthInterceptor ..|> HandlerInterceptor +``` + +**ํ•ต์‹ฌ ํฌ์ธํŠธ:** +- **AdminAuthInterceptor**: `/api-admin/**` ๊ฒฝ๋กœ์— ๋Œ€ํ•ด ํ—ค๋” ๊ฒ€์ฆ (1์ฐจ ๋ฐฉ์–ด์„ ) +- **AdminUserArgumentResolver**: ์ปจํŠธ๋กค๋Ÿฌ์— AdminUser ๊ฐ์ฒด ์ฃผ์ž… + +--- + +## 7. ๊ณตํ†ต ํด๋ž˜์Šค + +```mermaid +classDiagram + direction TB + + class BaseEntity { + <> + #Long id + #ZonedDateTime createdAt + #ZonedDateTime updatedAt + #ZonedDateTime deletedAt + #guard() void + +delete() void + +restore() void + +isDeleted() boolean + } + + class ApiResponse~T~ { + <> + +Metadata meta + +T data + +success() ApiResponse~Object~ + +success(T data) ApiResponse~T~ + +fail(String, String) ApiResponse~Object~ + } + + class Metadata { + <> + +Result result + +String errorCode + +String message + } + + class Result { + <> + SUCCESS + FAIL + } + + class CoreException { + -ErrorType errorType + -String customMessage + +CoreException(ErrorType) + +CoreException(ErrorType, String) + +getErrorType() ErrorType + } + + class ErrorType { + <> + INTERNAL_ERROR + BAD_REQUEST + NOT_FOUND + CONFLICT + UNAUTHORIZED + USER_NOT_FOUND + PASSWORD_MISMATCH + BRAND_NOT_FOUND + BRAND_ALREADY_EXISTS + BRAND_DELETED + PRODUCT_NOT_FOUND + PRODUCT_DELETED + INSUFFICIENT_STOCK + ORDER_NOT_FOUND + ORDER_ACCESS_DENIED + ADMIN_UNAUTHORIZED + BRAND_CHANGE_NOT_ALLOWED + -HttpStatus status + -String code + -String message + } + + ApiResponse --> Metadata + Metadata --> Result + CoreException --> ErrorType +``` + +**ํ•ต์‹ฌ ํฌ์ธํŠธ:** +- **BaseEntity**: ๋ชจ๋“  ์—”ํ‹ฐํ‹ฐ์˜ ๊ณตํ†ต ํ•„๋“œ (id, timestamps, soft delete) +- **ApiResponse**: ํ†ต์ผ๋œ API ์‘๋‹ต ํ˜•์‹ +- **ErrorType**: ๋„๋ฉ”์ธ๋ณ„ ์—๋Ÿฌ ์ฝ”๋“œ ์ •์˜ \ No newline at end of file From b3b74aa599732826043d3f6742e34f680b0e7381 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 13 Feb 2026 16:04:43 +0900 Subject: [PATCH 13/29] =?UTF-8?q?docs:=20=EC=BB=A4=EB=A8=B8=EC=8A=A4=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20ERD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 6๊ฐœ ํ…Œ์ด๋ธ” ์Šคํ‚ค๋งˆ (users, brands, products, product_likes, orders, order_items) - ์ธ๋ฑ์Šค ์„ค๊ณ„ ๋ฐ FK ์‚ญ์ œ ์ •์ฑ… - ์ฟผ๋ฆฌ ์ตœ์ ํ™” ๊ฐ€์ด๋“œ (์ข‹์•„์š”์ˆœ ์ •๋ ฌ, ๋น„๊ด€์  ๋ฝ) Co-Authored-By: Claude Opus 4.5 --- .docs/design/04-erd.md | 419 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 419 insertions(+) create mode 100644 .docs/design/04-erd.md diff --git a/.docs/design/04-erd.md b/.docs/design/04-erd.md new file mode 100644 index 000000000..d4cf61030 --- /dev/null +++ b/.docs/design/04-erd.md @@ -0,0 +1,419 @@ +# ERD (Entity Relationship Diagram) + +## ๋‹ค์ด์–ด๊ทธ๋žจ ๋ชฉ์  +ERD๋ฅผ ํ†ตํ•ด ๋‹ค์Œ์„ ๊ฒ€์ฆํ•œ๋‹ค: +- ์˜์†์„ฑ ๊ตฌ์กฐ: ๋ฐ์ดํ„ฐ๊ฐ€ ์–ด๋–ป๊ฒŒ ์ €์žฅ๋˜๋Š”๊ฐ€ +- ๊ด€๊ณ„์˜ ์ฃผ์ธ: FK๊ฐ€ ์–ด๋””์— ์œ„์น˜ํ•˜๋Š”๊ฐ€ +- ์ •๊ทœํ™” ์—ฌ๋ถ€: ๋ฐ์ดํ„ฐ ์ค‘๋ณต์ด ์ตœ์†Œํ™”๋˜์—ˆ๋Š”๊ฐ€ +- ์ •ํ•ฉ์„ฑ: ์ œ์•ฝ์กฐ๊ฑด์ด ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™์„ ๋ฐ˜์˜ํ•˜๋Š”๊ฐ€ + +--- + +## 1. ์ „์ฒด ERD + +```mermaid +erDiagram + users ||--o{ orders : "places" + users ||--o{ product_likes : "likes" + brands ||--o{ products : "has" + products ||--o{ product_likes : "has" + products ||--o{ order_items : "ordered_in" + orders ||--|{ order_items : "contains" + + users { + bigint id PK "AUTO_INCREMENT" + varchar_30 login_id UK "NOT NULL" + varchar_255 password "NOT NULL, BCrypt" + varchar_30 name "NOT NULL" + varchar_8 birth_date "NOT NULL, YYYYMMDD" + varchar_100 email "NOT NULL" + timestamp created_at "NOT NULL" + timestamp updated_at "NOT NULL" + timestamp deleted_at "NULL, Soft Delete" + } + + brands { + bigint id PK "AUTO_INCREMENT" + varchar_100 name UK "NOT NULL" + varchar_500 description "NULL" + varchar_500 logo_url "NULL" + timestamp created_at "NOT NULL" + timestamp updated_at "NOT NULL" + timestamp deleted_at "NULL, Soft Delete" + } + + products { + bigint id PK "AUTO_INCREMENT" + bigint brand_id FK "NOT NULL" + varchar_200 name "NOT NULL" + varchar_2000 description "NULL" + bigint price "NOT NULL, >= 0" + int stock "NOT NULL, >= 0" + varchar_500 image_url "NULL" + timestamp created_at "NOT NULL" + timestamp updated_at "NOT NULL" + timestamp deleted_at "NULL, Soft Delete" + } + + product_likes { + bigint id PK "AUTO_INCREMENT" + bigint user_id FK "NOT NULL" + bigint product_id FK "NOT NULL" + timestamp created_at "NOT NULL" + } + + orders { + bigint id PK "AUTO_INCREMENT" + bigint user_id FK "NOT NULL" + bigint total_price "NOT NULL, >= 0" + varchar_20 status "NOT NULL, ENUM" + timestamp created_at "NOT NULL" + timestamp updated_at "NOT NULL" + } + + order_items { + bigint id PK "AUTO_INCREMENT" + bigint order_id FK "NOT NULL" + bigint product_id FK "NOT NULL" + varchar_200 product_name "NOT NULL, ์Šค๋ƒ…์ƒท" + int quantity "NOT NULL, >= 1" + bigint price "NOT NULL, ์Šค๋ƒ…์ƒท" + timestamp created_at "NOT NULL" + } +``` + +--- + +## 2. ํ…Œ์ด๋ธ” ์ƒ์„ธ ์Šคํ‚ค๋งˆ + +### 2.1 users ํ…Œ์ด๋ธ” (๊ธฐ์กด) + +```sql +CREATE TABLE users ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + login_id VARCHAR(30) NOT NULL, + password VARCHAR(255) NOT NULL, + name VARCHAR(30) NOT NULL, + birth_date VARCHAR(8) NOT NULL, + email VARCHAR(100) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + CONSTRAINT uk_users_login_id UNIQUE (login_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +--- + +### 2.2 brands ํ…Œ์ด๋ธ” + +```sql +CREATE TABLE brands ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description VARCHAR(500) NULL, + logo_url VARCHAR(500) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + CONSTRAINT uk_brands_name UNIQUE (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ์ธ๋ฑ์Šค +CREATE INDEX idx_brands_deleted_at ON brands (deleted_at); +CREATE INDEX idx_brands_created_at ON brands (created_at); +``` + +**์ธ๋ฑ์Šค ์„ค๊ณ„ ์˜๋„:** +| ์ธ๋ฑ์Šค | ์šฉ๋„ | +|--------|------| +| `uk_brands_name` | ๋ธŒ๋žœ๋“œ๋ช… ์ค‘๋ณต ๋ฐฉ์ง€ | +| `idx_brands_deleted_at` | Soft Delete ํ•„ํ„ฐ๋ง ์ตœ์ ํ™” | +| `idx_brands_created_at` | ์ตœ์‹ ์ˆœ ์ •๋ ฌ ์ตœ์ ํ™” | + +--- + +### 2.3 products ํ…Œ์ด๋ธ” + +```sql +CREATE TABLE products ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + brand_id BIGINT NOT NULL, + name VARCHAR(200) NOT NULL, + description VARCHAR(2000) NULL, + price BIGINT NOT NULL, + stock INT NOT NULL DEFAULT 0, + image_url VARCHAR(500) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + CONSTRAINT fk_products_brand_id FOREIGN KEY (brand_id) + REFERENCES brands(id) ON DELETE RESTRICT, + + CONSTRAINT chk_products_price CHECK (price >= 0), + CONSTRAINT chk_products_stock CHECK (stock >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ์ธ๋ฑ์Šค +CREATE INDEX idx_products_brand_id ON products (brand_id); +CREATE INDEX idx_products_deleted_at ON products (deleted_at); +CREATE INDEX idx_products_price ON products (price); +CREATE INDEX idx_products_created_at ON products (created_at DESC); + +-- ๋ณตํ•ฉ ์ธ๋ฑ์Šค: ๋ธŒ๋žœ๋“œ๋ณ„ ์ƒํ’ˆ ์กฐํšŒ ์ตœ์ ํ™” +CREATE INDEX idx_products_brand_deleted_created + ON products (brand_id, deleted_at, created_at DESC); +``` + +**์ธ๋ฑ์Šค ์„ค๊ณ„ ์˜๋„:** +| ์ธ๋ฑ์Šค | ์šฉ๋„ | +|--------|------| +| `fk_products_brand_id` | ๋ธŒ๋žœ๋“œ-์ƒํ’ˆ ๊ด€๊ณ„ ๋ฌด๊ฒฐ์„ฑ | +| `idx_products_brand_id` | ๋ธŒ๋žœ๋“œ๋ณ„ ์ƒํ’ˆ ์กฐํšŒ | +| `idx_products_deleted_at` | Soft Delete ํ•„ํ„ฐ๋ง | +| `idx_products_price` | ๊ฐ€๊ฒฉ์ˆœ ์ •๋ ฌ ์ตœ์ ํ™” | +| `idx_products_created_at` | ์ตœ์‹ ์ˆœ ์ •๋ ฌ ์ตœ์ ํ™” | +| `idx_products_brand_deleted_created` | ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ + ์‚ญ์ œ ํ•„ํ„ฐ + ์ตœ์‹ ์ˆœ ๋ณตํ•ฉ ์ฟผ๋ฆฌ | + +--- + +### 2.4 product_likes ํ…Œ์ด๋ธ” + +```sql +CREATE TABLE product_likes ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_product_likes_user_id FOREIGN KEY (user_id) + REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_product_likes_product_id FOREIGN KEY (product_id) + REFERENCES products(id) ON DELETE CASCADE, + + CONSTRAINT uk_product_likes_user_product UNIQUE (user_id, product_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ์ธ๋ฑ์Šค +CREATE INDEX idx_product_likes_product_id ON product_likes (product_id); +CREATE INDEX idx_product_likes_user_id ON product_likes (user_id); +``` + +**์ธ๋ฑ์Šค ์„ค๊ณ„ ์˜๋„:** +| ์ธ๋ฑ์Šค | ์šฉ๋„ | +|--------|------| +| `uk_product_likes_user_product` | ์ค‘๋ณต ์ข‹์•„์š” ๋ฐฉ์ง€ + ํŠน์ • ์‚ฌ์šฉ์ž์˜ ํŠน์ • ์ƒํ’ˆ ์ข‹์•„์š” ์—ฌ๋ถ€ ์กฐํšŒ | +| `idx_product_likes_product_id` | ์ƒํ’ˆ๋ณ„ ์ข‹์•„์š” ์ˆ˜ COUNT ์ตœ์ ํ™” | +| `idx_product_likes_user_id` | ์‚ฌ์šฉ์ž๋ณ„ ์ข‹์•„์š” ๋ชฉ๋ก ์กฐํšŒ ์ตœ์ ํ™” | + +**CASCADE ์‚ญ์ œ:** +- User ์‚ญ์ œ ์‹œ ํ•ด๋‹น ์‚ฌ์šฉ์ž์˜ ์ข‹์•„์š” ์ž๋™ ์‚ญ์ œ +- Product ์‚ญ์ œ ์‹œ ํ•ด๋‹น ์ƒํ’ˆ์˜ ์ข‹์•„์š” ์ž๋™ ์‚ญ์ œ + +--- + +### 2.5 orders ํ…Œ์ด๋ธ” + +```sql +CREATE TABLE orders ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + total_price BIGINT NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'COMPLETED', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT fk_orders_user_id FOREIGN KEY (user_id) + REFERENCES users(id) ON DELETE RESTRICT, + + CONSTRAINT chk_orders_total_price CHECK (total_price >= 0), + CONSTRAINT chk_orders_status CHECK (status IN ('PENDING', 'COMPLETED', 'CANCELLED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ์ธ๋ฑ์Šค +CREATE INDEX idx_orders_user_id ON orders (user_id); +CREATE INDEX idx_orders_created_at ON orders (created_at DESC); +CREATE INDEX idx_orders_status ON orders (status); + +-- ๋ณตํ•ฉ ์ธ๋ฑ์Šค: ์‚ฌ์šฉ์ž๋ณ„ ๊ธฐ๊ฐ„ ์กฐํšŒ ์ตœ์ ํ™” +CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC); +``` + +**์ธ๋ฑ์Šค ์„ค๊ณ„ ์˜๋„:** +| ์ธ๋ฑ์Šค | ์šฉ๋„ | +|--------|------| +| `idx_orders_user_id` | ์‚ฌ์šฉ์ž๋ณ„ ์ฃผ๋ฌธ ์กฐํšŒ | +| `idx_orders_created_at` | ์ตœ์‹ ์ˆœ ์ •๋ ฌ | +| `idx_orders_status` | ์ƒํƒœ๋ณ„ ํ•„ํ„ฐ๋ง | +| `idx_orders_user_created` | ์‚ฌ์šฉ์ž์˜ ์ฃผ๋ฌธ ๊ธฐ๊ฐ„ ์กฐํšŒ ์ตœ์ ํ™” | + +--- + +### 2.6 order_items ํ…Œ์ด๋ธ” + +```sql +CREATE TABLE order_items ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + order_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + product_name VARCHAR(200) NOT NULL COMMENT '์ฃผ๋ฌธ ์‹œ์  ์ƒํ’ˆ๋ช… ์Šค๋ƒ…์ƒท', + quantity INT NOT NULL, + price BIGINT NOT NULL COMMENT '์ฃผ๋ฌธ ์‹œ์  ์ƒํ’ˆ ๊ฐ€๊ฒฉ ์Šค๋ƒ…์ƒท', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_order_items_order_id FOREIGN KEY (order_id) + REFERENCES orders(id) ON DELETE CASCADE, + CONSTRAINT fk_order_items_product_id FOREIGN KEY (product_id) + REFERENCES products(id) ON DELETE RESTRICT, + + CONSTRAINT uk_order_items_order_product UNIQUE (order_id, product_id), + CONSTRAINT chk_order_items_quantity CHECK (quantity >= 1), + CONSTRAINT chk_order_items_price CHECK (price >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ์ธ๋ฑ์Šค +CREATE INDEX idx_order_items_order_id ON order_items (order_id); +CREATE INDEX idx_order_items_product_id ON order_items (product_id); +``` + +**์ธ๋ฑ์Šค ์„ค๊ณ„ ์˜๋„:** +| ์ธ๋ฑ์Šค | ์šฉ๋„ | +|--------|------| +| `uk_order_items_order_product` | ๋™์ผ ์ฃผ๋ฌธ ๋‚ด ๋™์ผ ์ƒํ’ˆ ์ค‘๋ณต ๋ฐฉ์ง€ | +| `idx_order_items_order_id` | ์ฃผ๋ฌธ๋ณ„ ํ•ญ๋ชฉ ์กฐํšŒ | +| `idx_order_items_product_id` | ์ƒํ’ˆ๋ณ„ ์ฃผ๋ฌธ ์ด๋ ฅ ์กฐํšŒ | + +**๊ฐ€๊ฒฉ/์ƒํ’ˆ๋ช… ์Šค๋ƒ…์ƒท:** +- `price`, `product_name` ํ•„๋“œ๋Š” ์ฃผ๋ฌธ ์‹œ์ ์˜ ๊ฐ’์„ ์ €์žฅ +- ์ƒํ’ˆ ๊ฐ€๊ฒฉ/์ด๋ฆ„ ๋ณ€๊ฒฝ ์‹œ์—๋„ ๊ธฐ์กด ์ฃผ๋ฌธ์˜ ์ •๋ณด๋Š” ์œ ์ง€ + +--- + +## 3. ๊ด€๊ณ„ ์ •์˜ + +### 3.1 ๊ด€๊ณ„ ์š”์•ฝ + +| ๊ด€๊ณ„ | ํƒ€์ž… | ์„ค๋ช… | +|------|------|------| +| users : orders | 1:N | ์‚ฌ์šฉ์ž๋Š” ์—ฌ๋Ÿฌ ์ฃผ๋ฌธ ๊ฐ€๋Šฅ | +| users : product_likes | 1:N | ์‚ฌ์šฉ์ž๋Š” ์—ฌ๋Ÿฌ ์ƒํ’ˆ์— ์ข‹์•„์š” ๊ฐ€๋Šฅ | +| brands : products | 1:N | ๋ธŒ๋žœ๋“œ๋Š” ์—ฌ๋Ÿฌ ์ƒํ’ˆ ๋ณด์œ  | +| products : product_likes | 1:N | ์ƒํ’ˆ์€ ์—ฌ๋Ÿฌ ์ข‹์•„์š” ๋ณด์œ  | +| products : order_items | 1:N | ์ƒํ’ˆ์€ ์—ฌ๋Ÿฌ ์ฃผ๋ฌธ์— ํฌํ•จ ๊ฐ€๋Šฅ | +| orders : order_items | 1:N | ์ฃผ๋ฌธ์€ ์—ฌ๋Ÿฌ ์ฃผ๋ฌธ ํ•ญ๋ชฉ ํฌํ•จ | + +### 3.2 FK ์‚ญ์ œ ์ •์ฑ… + +| FK | ์ •์ฑ… | ์ด์œ  | +|----|------|------| +| products.brand_id | RESTRICT | ๋ธŒ๋žœ๋“œ ์‚ญ์ œ ์‹œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ Cascade ์ฒ˜๋ฆฌ | +| product_likes.user_id | CASCADE | ์‚ฌ์šฉ์ž ์‚ญ์ œ ์‹œ ์ข‹์•„์š” ์ž๋™ ์‚ญ์ œ | +| product_likes.product_id | CASCADE | ์ƒํ’ˆ ์‚ญ์ œ ์‹œ ์ข‹์•„์š” ์ž๋™ ์‚ญ์ œ | +| orders.user_id | RESTRICT | ์ฃผ๋ฌธ ์ด๋ ฅ ๋ณด์กด (์‚ฌ์šฉ์ž ์‚ญ์ œ ๋ถˆ๊ฐ€) | +| order_items.order_id | CASCADE | ์ฃผ๋ฌธ ์‚ญ์ œ ์‹œ ํ•ญ๋ชฉ ์ž๋™ ์‚ญ์ œ | +| order_items.product_id | RESTRICT | ์ƒํ’ˆ ์‚ญ์ œ ์‹œ์—๋„ ์ฃผ๋ฌธ ์ด๋ ฅ ๋ณด์กด | + +--- + +## 4. ์ฟผ๋ฆฌ ์ตœ์ ํ™” ๊ฐ€์ด๋“œ + +### 4.1 ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ (์ข‹์•„์š”์ˆœ ์ •๋ ฌ) + +```sql +-- ์ข‹์•„์š” ๋งŽ์€์ˆœ ์ •๋ ฌ (์„œ๋ธŒ์ฟผ๋ฆฌ ๋ฐฉ์‹) +SELECT + p.*, + COALESCE(like_counts.cnt, 0) as like_count +FROM products p +LEFT JOIN ( + SELECT product_id, COUNT(*) as cnt + FROM product_likes + GROUP BY product_id +) like_counts ON p.id = like_counts.product_id +WHERE p.deleted_at IS NULL + AND (p.brand_id = :brandId OR :brandId IS NULL) +ORDER BY like_count DESC, p.created_at DESC +LIMIT :limit OFFSET :offset; +``` + +### 4.2 ์žฌ๊ณ  ์ฐจ๊ฐ (๋น„๊ด€์  ๋ฝ) + +```sql +-- ๋น„๊ด€์  ๋ฝ์œผ๋กœ ์žฌ๊ณ  ์กฐํšŒ +SELECT * FROM products +WHERE id = :productId AND deleted_at IS NULL +FOR UPDATE; + +-- ์žฌ๊ณ  ๊ฒ€์ฆ ํ›„ ์ฐจ๊ฐ +UPDATE products +SET stock = stock - :quantity, updated_at = NOW() +WHERE id = :productId AND stock >= :quantity; +``` + +### 4.3 ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ (Fetch Join) + +```sql +SELECT o.*, oi.* +FROM orders o +JOIN order_items oi ON o.id = oi.order_id +WHERE o.id = :orderId AND o.user_id = :userId; +``` + +### 4.4 ์‚ฌ์šฉ์ž๋ณ„ ์ข‹์•„์š” ์ƒํ’ˆ ๋ชฉ๋ก + +```sql +SELECT p.*, pl.created_at as liked_at +FROM product_likes pl +JOIN products p ON pl.product_id = p.id +WHERE pl.user_id = :userId AND p.deleted_at IS NULL +ORDER BY pl.created_at DESC; +``` + +### 4.5 ์‚ฌ์šฉ์ž ์ฃผ๋ฌธ ๊ธฐ๊ฐ„ ์กฐํšŒ + +```sql +SELECT o.*, oi.* +FROM orders o +LEFT JOIN order_items oi ON o.id = oi.order_id +WHERE o.user_id = :userId + AND (:startAt IS NULL OR o.created_at >= :startAt) + AND (:endAt IS NULL OR o.created_at < :endAt + INTERVAL 1 DAY) +ORDER BY o.created_at DESC +LIMIT :limit OFFSET :offset; +``` + +--- + +## 5. ๋ฐ์ดํ„ฐ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ˆœ์„œ + +``` +V1__Create_users_table.sql (๊ธฐ์กด) +V2__Create_brands_table.sql +V3__Create_products_table.sql +V4__Create_product_likes_table.sql +V5__Create_orders_table.sql +V6__Create_order_items_table.sql +V7__Add_indexes.sql +``` + +--- + +## 6. ๋ฐ์ดํ„ฐ ์ •ํ•ฉ์„ฑ ๊ณ ๋ ค์‚ฌํ•ญ + +### 6.1 ๋™์‹œ์„ฑ ์ œ์–ด +- **์žฌ๊ณ  ์ฐจ๊ฐ**: `SELECT ... FOR UPDATE` ๋น„๊ด€์  ๋ฝ +- **์ข‹์•„์š” ์ค‘๋ณต**: `UNIQUE (user_id, product_id)` ์ œ์•ฝ์กฐ๊ฑด +- **์ฃผ๋ฌธ ํ•ญ๋ชฉ ์ค‘๋ณต**: `UNIQUE (order_id, product_id)` ์ œ์•ฝ์กฐ๊ฑด + +### 6.2 Soft Delete ์ฒ˜๋ฆฌ +- brands, products, users: `deleted_at` ํ•„๋“œ ์‚ฌ์šฉ +- ์กฐํšŒ ์‹œ `WHERE deleted_at IS NULL` ์กฐ๊ฑด ํ•„์ˆ˜ +- product_likes, orders, order_items: Hard Delete + +### 6.3 ์Šค๋ƒ…์ƒท ๋ฐ์ดํ„ฐ +- order_items.price: ์ฃผ๋ฌธ ์‹œ์  ์ƒํ’ˆ ๊ฐ€๊ฒฉ +- order_items.product_name: ์ฃผ๋ฌธ ์‹œ์  ์ƒํ’ˆ๋ช… +- ์ƒํ’ˆ ์ •๋ณด ๋ณ€๊ฒฝ๊ณผ ๋ฌด๊ด€ํ•˜๊ฒŒ ์ฃผ๋ฌธ ์ด๋ ฅ ๋ณด์กด \ No newline at end of file From 5100a9a08a5470ce50885db3e8501de169cb0246 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 13 Feb 2026 16:04:52 +0900 Subject: [PATCH 14/29] =?UTF-8?q?chore:=20=EC=9A=94=EA=B5=AC=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EB=B6=84=EC=84=9D=20=EC=8A=A4=ED=82=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - requirements-analysis ์Šคํ‚ฌ ์ •์˜ - ์š”๊ตฌ์‚ฌํ•ญ ๋ถ„์„ ์›Œํฌํ”Œ๋กœ์šฐ ๊ฐ€์ด๋“œ๋ผ์ธ Co-Authored-By: Claude Opus 4.5 --- .claude/skills/SKILL.md | 77 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 .claude/skills/SKILL.md diff --git a/.claude/skills/SKILL.md b/.claude/skills/SKILL.md new file mode 100644 index 000000000..3485a8af8 --- /dev/null +++ b/.claude/skills/SKILL.md @@ -0,0 +1,77 @@ +--- +name: requirements-analysis +description: + ์ œ๊ณต๋œ ์š”๊ตฌ์‚ฌํ•ญ์„ ๋ถ„์„ํ•˜๊ณ , ๊ฐœ๋ฐœ์ž์™€์˜ ์งˆ๋ฌธ/๋Œ€๋‹ต์„ ํ†ตํ•ด ์• ๋งคํ•œ ์š”๊ตฌ์‚ฌํ•ญ์„ ๋ช…ํ™•ํžˆ ํ•˜์—ฌ ์ •๋ฆฌํ•ฉ๋‹ˆ๋‹ค. + ๋ชจ๋“  ์ •๋ฆฌ๊ฐ€ ๋๋‚˜๋ฉด, ์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ, ํด๋ž˜์Šค ๋‹ค์ด์–ด๊ทธ๋žจ, ERD ๋“ฑ์„ Mermaid ๋ฌธ๋ฒ•์œผ๋กœ ์ž‘์„ฑํ•œ๋‹ค. + ์š”๊ตฌ์‚ฌํ•ญ์ด ์ œ๊ณต๋˜์—ˆ์„ ๋•Œ, ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•˜๊ธฐ ์ „ ์ด๋ฅผ ๋ช…ํ™•ํžˆ ํ•˜๋Š” ๋ฐ์— ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. +--- +์š”๊ตฌ์‚ฌํ•ญ์„ ๋ถ„์„ํ•  ๋•Œ ๋ฐ˜๋“œ์‹œ ๋‹ค์Œ ํ๋ฆ„์„ ๋”ฐ๋ฅธ๋‹ค. +### 1๏ธโƒฃ ์š”๊ตฌ์‚ฌํ•ญ์„ ๊ทธ๋Œ€๋กœ ๋ฏฟ์ง€ ๋ง๊ณ , ๋ฌธ์ œ ์ƒํ™ฉ์œผ๋กœ ๋‹ค์‹œ ์„ค๋ช…ํ•œ๋‹ค. +- ์š”๊ตฌ์‚ฌํ•ญ ๋ฌธ์žฅ์„ ์ •๋ฆฌํ•˜๋Š” ๋ฐ์„œ ๋๋‚ด์ง€ ์•Š๋Š”๋‹ค. +- "๋ฌด์—‡์„ ๋งŒ๋“ค๊นŒ?"๊ฐ€ ์•„๋‹ˆ๋ผ "์ง€๊ธˆ ์–ด๋–ค ๋ฌธ์ œ๊ฐ€ ์žˆ๊ณ , ๊ทธ๊ฑธ ์™œ ํ•ด๊ฒฐํ•˜๋ ค๋Š”๊ฐ€?" ๋กœ ์žฌํ•ด์„ํ•œ๋‹ค. +- ๋‹ค์Œ ๊ด€์ ์„ ๋ถ„๋ฆฌํ•ด์„œ ์ •๋ฆฌํ•œ๋‹ค: + - ์‚ฌ์šฉ์ž ๊ด€์  + - ๋น„์ฆˆ๋‹ˆ์Šค ๊ด€์  + - ์‹œ์Šคํ…œ ๊ด€์  +> ์˜ˆ์‹œ +> "์ฃผ๋ฌธ ์‹คํŒจ ์‹œ ๊ฒฐ์ œ๋ฅผ ์ทจ์†Œํ•œ๋‹ค" โ†’ "๊ฒฐ์ œ ์„ฑ๊ณต/์‹คํŒจ์™€ ์ฃผ๋ฌธ ์ƒํƒœ๊ฐ€ ์–ด๊ธ‹๋‚˜์ง€ ์•Š๋„๋ก ์ผ๊ด€์„ฑ์„ ์œ ์ง€ํ•˜๋ ค๋Š” ๋ฌธ์ œ" + +### 2๏ธโƒฃ ์• ๋งคํ•œ ์š”๊ตฌ์‚ฌํ•ญ์„ ์ˆจ๊ธฐ์ง€ ๋ง๊ณ  ๋“œ๋Ÿฌ๋‚ธ๋‹ค +- ์ถ”์ธกํ•˜๊ฑฐ๋‚˜ ์•Œ์•„์„œ ๊ฒฐ์ •ํ•˜์ง€ ์•Š๋Š”๋‹ค. +- ์š”๊ตฌ์‚ฌํ•ญ์—์„œ ๊ฒฐ์ •๋˜์ง€ ์•Š์€ ๋ถ€๋ถ„์„ ๋ช…์‹œ์ ์œผ๋กœ ๋‚˜์—ดํ•œ๋‹ค. + **๋‹ค์Œ ์œ ํ˜•์˜ ์งˆ๋ฌธ์„ ๋ฐ˜๋“œ์‹œ ํฌํ•จํ•œ๋‹ค:** +- ์ •์ฑ… ์งˆ๋ฌธ: ๊ธฐ์ค€ ์‹œ์ , ์„ฑ๊ณต/์‹คํŒจ ์กฐ๊ฑด, ์˜ˆ์™ธ ์ฒ˜๋ฆฌ ๊ทœ์น™ +- ๊ฒฝ๊ณ„ ์งˆ๋ฌธ: ์–ด๋””๊นŒ์ง€๊ฐ€ ํ•œ ์ฑ…์ž„์ธ๊ฐ€, ์–ด๋””์„œ ๋ถ„๋ฆฌ๋˜๋Š”๊ฐ€ +- ํ™•์žฅ ์งˆ๋ฌธ: ๋‚˜์ค‘์— ๋ฐ”๋€” ๊ฐ€๋Šฅ์„ฑ์ด ์žˆ๋Š”๊ฐ€ + +### 3๏ธโƒฃ ์š”๊ตฌ์‚ฌํ•ญ ๋ช…ํ™•ํ™”๋ฅผ ์œ„ํ•œ ์งˆ๋ฌธ์„ ๊ฐœ๋ฐœ์ž ๋‹ต๋ณ€์ด ์‰ฌ์šด ํ˜•ํƒœ๋กœ ์ œ์‹œํ•œ๋‹ค +- ์งˆ๋ฌธ์€ ์šฐ์„ ์ˆœ์œ„๋ฅผ ๊ฐ€์ง„๋‹ค (์ค‘์š”ํ•œ ๊ฒƒ๋ถ€ํ„ฐ). +- ์„ ํƒ์ง€๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ, ์˜ต์…˜ + ์˜ํ–ฅ๋„๋ฅผ ํ•จ๊ป˜ ์ œ์‹œํ•œ๋‹ค. +> ํ˜•์‹ ์˜ˆ์‹œ: +- ์„ ํƒ์ง€ A: ํ•˜๋‚˜์˜ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์ฒ˜๋ฆฌ โ†’ ๊ตฌํ˜„ ๋‹จ์ˆœ, ํ™•์žฅ์„ฑ ๋‚ฎ์Œ +- ์„ ํƒ์ง€ B: ๋‹จ๊ณ„๋ณ„ ๋ถ„๋ฆฌ โ†’ ๊ตฌ์กฐ ๋ณต์žก, ํ™•์žฅ/๋ณด์ƒ ์ฒ˜๋ฆฌ ์œ ๋ฆฌ + +### 4๏ธโƒฃ ํ•ฉ์˜๋œ ๋‚ด์šฉ์„ ๋ฐ”ํƒ•์œผ๋กœ ๊ฐœ๋… ๋ชจ๋ธ๋ถ€ํ„ฐ ์žก๋Š”๋‹ค +- ๋ฐ”๋กœ ์ฝ”๋“œ๋‚˜ ๊ธฐ์ˆ  ์–˜๊ธฐ๋กœ ๋“ค์–ด๊ฐ€์ง€ ์•Š๋Š”๋‹ค. +- ๋จผ์ € ๋‹ค์Œ์„ ์ •์˜ํ•œ๋‹ค: + - ์•กํ„ฐ (์‚ฌ์šฉ์ž, ์™ธ๋ถ€ ์‹œ์Šคํ…œ) + - ํ•ต์‹ฌ ๋„๋ฉ”์ธ + - ๋ณด์กฐ/์™ธ๋ถ€ ์‹œ์Šคํ…œ +- ์ด ๋‹จ๊ณ„๋Š” โ€œ๊ตฌํ˜„โ€์ด ์•„๋‹ˆ๋ผ ์„ค๊ณ„ ์‚ฌ๊ณ  ์ •๋ ฌ์ด ๋ชฉ์ ์ด๋‹ค. + +### 5๏ธโƒฃ ๋‹ค์ด์–ด๊ทธ๋žจ์€ ํ•ญ์ƒ ์ด์œ  โ†’ ๋‹ค์ด์–ด๊ทธ๋žจ โ†’ ํ•ด์„ ์ˆœ์„œ๋กœ ์ œ์‹œํ•œ๋‹ค +**๋‹ค์ด์–ด๊ทธ๋žจ์„ ๊ทธ๋ฆฌ๊ธฐ ์ „์— ๋ฐ˜๋“œ์‹œ ์„ค๋ช…ํ•œ๋‹ค** +- ์™œ ์ด ๋‹ค์ด์–ด๊ทธ๋žจ์ด ํ•„์š”ํ•œ์ง€ +- ์ด ๋‹ค์ด์–ด๊ทธ๋žจ์œผ๋กœ ๋ฌด์—‡์„ ๊ฒ€์ฆํ•˜๋ ค๋Š”์ง€ + +**๋‹ค์ด์–ด๊ทธ๋žจ์€ Mermaid ๋ฌธ๋ฒ•์œผ๋กœ ์ž‘์„ฑํ•œ๋‹ค** +์‚ฌ์šฉ ๊ธฐ์ค€: +- **์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ** + - ์ฑ…์ž„ ๋ถ„๋ฆฌ + - ํ˜ธ์ถœ ์ˆœ์„œ + - ํŠธ๋žœ์žญ์…˜ ๊ฒฝ๊ณ„ ํ™•์ธ +- **ํด๋ž˜์Šค ๋‹ค์ด์–ด๊ทธ๋žจ** + - ๋„๋ฉ”์ธ ์ฑ…์ž„ + - ์˜์กด ๋ฐฉํ–ฅ + - ์‘์ง‘๋„ ํ™•์ธ +- **ERD** + - ์˜์†์„ฑ ๊ตฌ์กฐ + - ๊ด€๊ณ„์˜ ์ฃผ์ธ + - ์ •๊ทœํ™” ์—ฌ๋ถ€ + +### 6๏ธโƒฃ ๋‹ค์ด์–ด๊ทธ๋žจ์„ ๋˜์ง€๊ณ  ๋๋‚ด์ง€ ๋ง๊ณ  ์ฝ๋Š” ๋ฒ•์„ ์งš์–ด์ค€๋‹ค +- "์ด ๊ตฌ์กฐ์—์„œ ํŠนํžˆ ๋ด์•ผ ํ•  ํฌ์ธํŠธ"๋ฅผ 2~3์ค„๋กœ ์„ค๋ช…ํ•œ๋‹ค. +- ์„ค๊ณ„ ์˜๋„๊ฐ€ ๋“œ๋Ÿฌ๋‚˜๋„๋ก ํ•ด์„์„ ๋ถ™์ธ๋‹ค. + +### 7๏ธโƒฃ ์„ค๊ณ„์˜ ์ž ์žฌ ๋ฆฌ์Šคํฌ๋ฅผ ๋ฐ˜๋“œ์‹œ ์–ธ๊ธ‰ํ•œ๋‹ค +- ํ˜„์žฌ ์„ค๊ณ„๊ฐ€ ๊ฐ€์งˆ ์ˆ˜ ์žˆ๋Š” ์œ„ํ—˜์„ ์ˆจ๊ธฐ์ง€ ์•Š๋Š”๋‹ค. + - ํŠธ๋žœ์žญ์…˜ ๋น„๋Œ€ํ™” + - ๋„๋ฉ”์ธ ๊ฐ„ ๊ฒฐํ•ฉ๋„ ์ฆ๊ฐ€ + - ์ •์ฑ… ๋ณ€๊ฒฝ ์‹œ ์˜ํ–ฅ ๋ฒ”์œ„ ํ™•๋Œ€ +- ํ•ด๊ฒฐ์ฑ…์€ ์ •๋‹ต์ฒ˜๋Ÿผ ๋งํ•˜์ง€ ์•Š๊ณ  ์„ ํƒ์ง€๋กœ ์ œ์‹œํ•œ๋‹ค. + +### ํ†ค & ์Šคํƒ€์ผ ๊ฐ€์ด๋“œ +- ๊ฐ•์˜์ฒ˜๋Ÿผ ์„ค๋ช…ํ•˜์ง€ ๋ง๊ณ  ์„ค๊ณ„ ๋ฆฌ๋ทฐ ํ†ค์„ ์œ ์ง€ํ•œ๋‹ค +- ์ •๋‹ต์ด๋ผ๊ณ  ์ œ์‹œํ•˜๊ธฐ๋ณด๋‹ค, ๋‹ค๋ฅธ ์„ ํƒ์ง€๊ฐ€ ์žˆ๋‹ค๋ฉด ์ด๋ฅผ ์ œ๊ณตํ•˜๋„๋ก ํ•œ๋‹ค. +- ์ฝ”๋“œ๋ณด๋‹ค ์˜๋„, ์ฑ…์ž„, ๊ฒฝ๊ณ„๋ฅผ ๋” ์ค‘์š”ํ•˜๊ฒŒ ๋‹ค๋ฃฌ๋‹ค +- ๊ตฌํ˜„ ์ „์— ์ƒ๊ฐํ•ด์•ผ ํ•  ๊ฒƒ์„ ๋Œ์–ด๋‚ด๋Š” ๋ฐ ์ง‘์ค‘ํ•œ๋‹ค \ No newline at end of file From 4ba30cbe4c22d9b115bb69dfa4c714f958b979b2 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 27 Feb 2026 13:52:35 +0900 Subject: [PATCH 15/29] =?UTF-8?q?feat(domain):=20Value=20Objects=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(Money,=20Stock,=20Quantity)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Money: ๊ธˆ์•ก VO, 0 ์ด์ƒ ๊ฒ€์ฆ, add/multiply ์—ฐ์‚ฐ - Stock: ์žฌ๊ณ  VO, ์ฐจ๊ฐ/์ฆ๊ฐ€ ์‹œ ๋ถˆ๋ณ€์‹ ๊ฒ€์ฆ - Quantity: ์ˆ˜๋Ÿ‰ VO, 1 ์ด์ƒ ๊ฒ€์ฆ - ProductSort: ์ƒํ’ˆ ์ •๋ ฌ Enum (LATEST, PRICE_ASC, LIKES_DESC) - ๊ฐ VO์— ๋Œ€ํ•œ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ ํฌํ•จ Co-Authored-By: Claude Opus 4.5 --- .../java/com/loopers/domain/common/Money.java | 43 ++++ .../com/loopers/domain/common/Quantity.java | 17 ++ .../loopers/domain/product/ProductSort.java | 22 ++ .../com/loopers/domain/product/Stock.java | 31 +++ .../com/loopers/domain/common/MoneyTest.java | 194 +++++++++++++++++ .../loopers/domain/common/QuantityTest.java | 74 +++++++ .../com/loopers/domain/product/StockTest.java | 197 ++++++++++++++++++ 7 files changed, 578 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSort.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/common/QuantityTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/StockTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java new file mode 100644 index 000000000..70f90feb3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java @@ -0,0 +1,43 @@ +package com.loopers.domain.common; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +/** + * ๊ธˆ์•ก์„ ๋‚˜ํƒ€๋‚ด๋Š” Value Object. + * ๋ถˆ๋ณ€ ๊ฐ์ฒด๋กœ ๋ชจ๋“  ์—ฐ์‚ฐ์€ ์ƒˆ๋กœ์šด Money ์ธ์Šคํ„ด์Šค๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. + */ +public record Money(long amount) { + + public static final Money ZERO = new Money(0); + + public Money { + if (amount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "๊ธˆ์•ก์€ 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + } + + /** + * ๋‘ ๊ธˆ์•ก์„ ๋”ํ•œ๋‹ค. + * + * @param other ๋”ํ•  ๊ธˆ์•ก + * @return ํ•ฉ์‚ฐ๋œ ๊ธˆ์•ก + */ + public Money add(Money other) { + return new Money(this.amount + other.amount); + } + + /** + * ๊ธˆ์•ก์— ์ˆ˜๋Ÿ‰์„ ๊ณฑํ•œ๋‹ค. + * + * @param quantity ๊ณฑํ•  ์ˆ˜๋Ÿ‰ (1 ์ด์ƒ) + * @return ๊ณฑํ•ด์ง„ ๊ธˆ์•ก + * @throws CoreException ์ˆ˜๋Ÿ‰์ด 0 ์ดํ•˜์ธ ๊ฒฝ์šฐ + */ + public Money multiply(int quantity) { + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ˆ˜๋Ÿ‰์€ 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + return new Money(this.amount * quantity); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java new file mode 100644 index 000000000..c7764ecee --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java @@ -0,0 +1,17 @@ +package com.loopers.domain.common; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +/** + * ์ˆ˜๋Ÿ‰์„ ๋‚˜ํƒ€๋‚ด๋Š” Value Object. + * ์ˆ˜๋Ÿ‰์€ ํ•ญ์ƒ 1 ์ด์ƒ์ด์–ด์•ผ ํ•œ๋‹ค. + */ +public record Quantity(int value) { + + public Quantity { + if (value <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ˆ˜๋Ÿ‰์€ 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSort.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSort.java new file mode 100644 index 000000000..afb60c7b7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSort.java @@ -0,0 +1,22 @@ +package com.loopers.domain.product; + +/** + * ์ƒํ’ˆ ๋ชฉ๋ก ์ •๋ ฌ ์กฐ๊ฑด. + */ +public enum ProductSort { + /** + * ์ตœ์‹ ์ˆœ (createdAt DESC) + */ + LATEST, + + /** + * ๊ฐ€๊ฒฉ ๋‚ฎ์€ ์ˆœ (price ASC) + */ + PRICE_ASC, + + /** + * ์ข‹์•„์š” ๋งŽ์€ ์ˆœ (likeCount DESC) + * Application Layer์—์„œ Like BC ๋ฐ์ดํ„ฐ์™€ ์กฐํ•ฉํ•˜์—ฌ ์ฒ˜๋ฆฌ + */ + LIKES_DESC +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java new file mode 100644 index 000000000..5e7a6f820 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java @@ -0,0 +1,31 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record Stock(int quantity) { + + public Stock { + if (quantity < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์žฌ๊ณ ๋Š” 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + } + + public Stock decrease(int amount) { + if (amount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฐจ๊ฐ ์ˆ˜๋Ÿ‰์€ 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (this.quantity < amount) { + throw new CoreException(ErrorType.INSUFFICIENT_STOCK, + String.format("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค. (ํ˜„์žฌ: %d, ์š”์ฒญ: %d)", this.quantity, amount)); + } + return new Stock(this.quantity - amount); + } + + public Stock increase(int amount) { + if (amount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฆ๊ฐ€ ์ˆ˜๋Ÿ‰์€ 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + return new Stock(this.quantity + amount); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java new file mode 100644 index 000000000..e6a0b6370 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java @@ -0,0 +1,194 @@ +package com.loopers.domain.common; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class MoneyTest { + + @DisplayName("Money๋ฅผ ์ƒ์„ฑํ•  ๋•Œ,") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ๊ธˆ์•ก์œผ๋กœ ์ƒ์„ฑํ•˜๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsMoney_whenAmountIsValid() { + // arrange + long amount = 10000; + + // act + Money money = new Money(amount); + + // assert + assertThat(money.amount()).isEqualTo(10000); + } + + @DisplayName("๊ธˆ์•ก์ด 0์ด๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsMoney_whenAmountIsZero() { + // arrange + long amount = 0; + + // act + Money money = new Money(amount); + + // assert + assertThat(money.amount()).isEqualTo(0); + } + + @DisplayName("๊ธˆ์•ก์ด ์Œ์ˆ˜์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenAmountIsNegative() { + // arrange + long amount = -1; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new Money(amount); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("๊ธˆ์•ก์„ ๋”ํ•  ๋•Œ,") + @Nested + class Add { + + @DisplayName("๋‘ ๊ธˆ์•ก์„ ๋”ํ•˜๋ฉด, ํ•ฉ์‚ฐ๋œ ๊ธˆ์•ก์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnsAddedMoney_whenAddingTwoAmounts() { + // arrange + Money money1 = new Money(10000); + Money money2 = new Money(5000); + + // act + Money result = money1.add(money2); + + // assert + assertThat(result.amount()).isEqualTo(15000); + } + + @DisplayName("ZERO์— ๊ธˆ์•ก์„ ๋”ํ•˜๋ฉด, ํ•ด๋‹น ๊ธˆ์•ก์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnsOriginalMoney_whenAddingToZero() { + // arrange + Money money = new Money(10000); + + // act + Money result = Money.ZERO.add(money); + + // assert + assertThat(result.amount()).isEqualTo(10000); + } + + @DisplayName("์›๋ณธ Money๋Š” ๋ณ€๊ฒฝ๋˜์ง€ ์•Š๋Š”๋‹ค. (๋ถˆ๋ณ€์„ฑ)") + @Test + void originalMoneyRemainsUnchanged() { + // arrange + Money original = new Money(10000); + + // act + Money added = original.add(new Money(5000)); + + // assert + assertThat(original.amount()).isEqualTo(10000); + assertThat(added.amount()).isEqualTo(15000); + } + } + + @DisplayName("๊ธˆ์•ก์— ์ˆ˜๋Ÿ‰์„ ๊ณฑํ•  ๋•Œ,") + @Nested + class Multiply { + + @DisplayName("์œ ํšจํ•œ ์ˆ˜๋Ÿ‰์„ ๊ณฑํ•˜๋ฉด, ๊ณฑํ•ด์ง„ ๊ธˆ์•ก์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnsMultipliedMoney_whenQuantityIsValid() { + // arrange + Money money = new Money(10000); + int quantity = 3; + + // act + Money result = money.multiply(quantity); + + // assert + assertThat(result.amount()).isEqualTo(30000); + } + + @DisplayName("์ˆ˜๋Ÿ‰์ด 1์ด๋ฉด, ์›๋ž˜ ๊ธˆ์•ก์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnsSameMoney_whenQuantityIsOne() { + // arrange + Money money = new Money(10000); + + // act + Money result = money.multiply(1); + + // assert + assertThat(result.amount()).isEqualTo(10000); + } + + @DisplayName("์ˆ˜๋Ÿ‰์ด 0์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenQuantityIsZero() { + // arrange + Money money = new Money(10000); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + money.multiply(0); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("์ˆ˜๋Ÿ‰์ด ์Œ์ˆ˜์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenQuantityIsNegative() { + // arrange + Money money = new Money(10000); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + money.multiply(-1); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("์›๋ณธ Money๋Š” ๋ณ€๊ฒฝ๋˜์ง€ ์•Š๋Š”๋‹ค. (๋ถˆ๋ณ€์„ฑ)") + @Test + void originalMoneyRemainsUnchanged() { + // arrange + Money original = new Money(10000); + + // act + Money multiplied = original.multiply(3); + + // assert + assertThat(original.amount()).isEqualTo(10000); + assertThat(multiplied.amount()).isEqualTo(30000); + } + } + + @DisplayName("ZERO ์ƒ์ˆ˜๋Š”,") + @Nested + class ZeroConstant { + + @DisplayName("๊ธˆ์•ก์ด 0์ธ Money์ด๋‹ค.") + @Test + void hasZeroAmount() { + // assert + assertThat(Money.ZERO.amount()).isEqualTo(0); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/common/QuantityTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/common/QuantityTest.java new file mode 100644 index 000000000..a9bfb8f0b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/common/QuantityTest.java @@ -0,0 +1,74 @@ +package com.loopers.domain.common; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class QuantityTest { + + @DisplayName("Quantity๋ฅผ ์ƒ์„ฑํ•  ๋•Œ,") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ˆ˜๋Ÿ‰์œผ๋กœ ์ƒ์„ฑํ•˜๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsQuantity_whenValueIsValid() { + // arrange + int value = 10; + + // act + Quantity quantity = new Quantity(value); + + // assert + assertThat(quantity.value()).isEqualTo(10); + } + + @DisplayName("์ˆ˜๋Ÿ‰์ด 1์ด๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsQuantity_whenValueIsOne() { + // arrange + int value = 1; + + // act + Quantity quantity = new Quantity(value); + + // assert + assertThat(quantity.value()).isEqualTo(1); + } + + @DisplayName("์ˆ˜๋Ÿ‰์ด 0์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenValueIsZero() { + // arrange + int value = 0; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new Quantity(value); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("์ˆ˜๋Ÿ‰์ด ์Œ์ˆ˜์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenValueIsNegative() { + // arrange + int value = -1; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new Quantity(value); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/StockTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/StockTest.java new file mode 100644 index 000000000..176bb1f7a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/StockTest.java @@ -0,0 +1,197 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class StockTest { + + @DisplayName("Stock์„ ์ƒ์„ฑํ•  ๋•Œ,") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ˆ˜๋Ÿ‰์œผ๋กœ ์ƒ์„ฑํ•˜๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsStock_whenQuantityIsValid() { + // arrange + int quantity = 100; + + // act + Stock stock = new Stock(quantity); + + // assert + assertThat(stock.quantity()).isEqualTo(100); + } + + @DisplayName("์ˆ˜๋Ÿ‰์ด 0์ด๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsStock_whenQuantityIsZero() { + // arrange + int quantity = 0; + + // act + Stock stock = new Stock(quantity); + + // assert + assertThat(stock.quantity()).isEqualTo(0); + } + + @DisplayName("์ˆ˜๋Ÿ‰์ด ์Œ์ˆ˜์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenQuantityIsNegative() { + // arrange + int quantity = -1; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new Stock(quantity); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("์žฌ๊ณ ๋ฅผ ์ฐจ๊ฐํ•  ๋•Œ,") + @Nested + class Decrease { + + @DisplayName("์ถฉ๋ถ„ํ•œ ์žฌ๊ณ ๊ฐ€ ์žˆ์œผ๋ฉด, ์ฐจ๊ฐ๋œ Stock์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsDecreasedStock_whenStockIsSufficient() { + // arrange + Stock stock = new Stock(100); + + // act + Stock result = stock.decrease(30); + + // assert + assertThat(result.quantity()).isEqualTo(70); + } + + @DisplayName("์žฌ๊ณ ์™€ ์ •ํ™•ํžˆ ๊ฐ™์€ ์ˆ˜๋Ÿ‰์„ ์ฐจ๊ฐํ•˜๋ฉด, 0์ด ๋œ๋‹ค.") + @Test + void returnsZeroStock_whenDecreasingExactAmount() { + // arrange + Stock stock = new Stock(50); + + // act + Stock result = stock.decrease(50); + + // assert + assertThat(result.quantity()).isEqualTo(0); + } + + @DisplayName("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•˜๋ฉด, INSUFFICIENT_STOCK ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsInsufficientStockException_whenStockIsInsufficient() { + // arrange + Stock stock = new Stock(10); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + stock.decrease(20); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.INSUFFICIENT_STOCK); + } + + @DisplayName("์ฐจ๊ฐ ์ˆ˜๋Ÿ‰์ด 0์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenAmountIsZero() { + // arrange + Stock stock = new Stock(100); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + stock.decrease(0); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("์ฐจ๊ฐ ์ˆ˜๋Ÿ‰์ด ์Œ์ˆ˜์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenAmountIsNegative() { + // arrange + Stock stock = new Stock(100); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + stock.decrease(-10); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("์›๋ณธ Stock์€ ๋ณ€๊ฒฝ๋˜์ง€ ์•Š๋Š”๋‹ค. (๋ถˆ๋ณ€์„ฑ)") + @Test + void originalStockRemainsUnchanged() { + // arrange + Stock original = new Stock(100); + + // act + Stock decreased = original.decrease(30); + + // assert + assertThat(original.quantity()).isEqualTo(100); + assertThat(decreased.quantity()).isEqualTo(70); + } + } + + @DisplayName("์žฌ๊ณ ๋ฅผ ์ฆ๊ฐ€์‹œํ‚ฌ ๋•Œ,") + @Nested + class Increase { + + @DisplayName("์œ ํšจํ•œ ์ˆ˜๋Ÿ‰์œผ๋กœ ์ฆ๊ฐ€์‹œํ‚ค๋ฉด, ์ฆ๊ฐ€๋œ Stock์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsIncreasedStock_whenAmountIsValid() { + // arrange + Stock stock = new Stock(100); + + // act + Stock result = stock.increase(50); + + // assert + assertThat(result.quantity()).isEqualTo(150); + } + + @DisplayName("์ฆ๊ฐ€ ์ˆ˜๋Ÿ‰์ด 0์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenAmountIsZero() { + // arrange + Stock stock = new Stock(100); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + stock.increase(0); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("์ฆ๊ฐ€ ์ˆ˜๋Ÿ‰์ด ์Œ์ˆ˜์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenAmountIsNegative() { + // arrange + Stock stock = new Stock(100); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + stock.increase(-10); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} From 4661f7c7fbff776f539a6757a08f7c0796200bf1 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 27 Feb 2026 13:52:58 +0900 Subject: [PATCH 16/29] =?UTF-8?q?feat(domain):=20Brand=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Domain Layer (์ˆœ์ˆ˜ Java): - Brand: ๋ธŒ๋žœ๋“œ ์—”ํ‹ฐํ‹ฐ, create/reconstitute ์ •์  ํŒฉํ† ๋ฆฌ - BrandRepository: Repository ์ธํ„ฐํŽ˜์ด์Šค - BrandDomainService: ์ค‘๋ณต ์ด๋ฆ„ ๊ฒ€์ฆ, CRUD ์ •์ฑ… - BrandValidator: ๋ธŒ๋žœ๋“œ ์กด์žฌ ๊ฒ€์ฆ Infrastructure Layer: - BrandJpaEntity: JPA ์—”ํ‹ฐํ‹ฐ (@Entity) - BrandMapper: Domain โ†” JPA ๋ณ€ํ™˜ - BrandRepositoryImpl: Repository Adapter Test: - FakeBrandRepository: Map ๊ธฐ๋ฐ˜ in-memory ๊ตฌํ˜„ - BrandTest, BrandInfoTest: ๋„๋ฉ”์ธ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ Co-Authored-By: Claude Opus 4.5 --- .../java/com/loopers/domain/brand/Brand.java | 115 +++++++++++ .../domain/brand/BrandDomainService.java | 44 +++++ .../com/loopers/domain/brand/BrandInfo.java | 53 ++++++ .../loopers/domain/brand/BrandRepository.java | 24 +++ .../loopers/domain/brand/BrandValidator.java | 25 +++ .../persistence/jpa/brand/BrandJpaEntity.java | 56 ++++++ .../jpa/brand/BrandJpaRepository.java | 21 ++ .../persistence/jpa/brand/BrandMapper.java | 55 ++++++ .../jpa/brand/BrandRepositoryImpl.java | 68 +++++++ .../loopers/domain/brand/BrandInfoTest.java | 179 ++++++++++++++++++ .../com/loopers/domain/brand/BrandTest.java | 160 ++++++++++++++++ .../com/loopers/fake/FakeBrandRepository.java | 87 +++++++++ 12 files changed, 887 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandDomainService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandValidator.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandJpaEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandMapper.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandInfoTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/fake/FakeBrandRepository.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java new file mode 100644 index 000000000..a29022a50 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,115 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.ZonedDateTime; + +/** + * ๋ธŒ๋žœ๋“œ ๋„๋ฉ”์ธ ์—”ํ‹ฐํ‹ฐ. + * ์ˆœ์ˆ˜ Java ๊ฐ์ฒด๋กœ JPA/Spring ์˜์กด์„ฑ ์—†์Œ. + */ +public class Brand { + + private Long id; + private String name; + private String description; + private String logoUrl; + private ZonedDateTime createdAt; + private ZonedDateTime updatedAt; + private ZonedDateTime deletedAt; + + private Brand() {} + + /** + * ์ƒˆ ๋ธŒ๋žœ๋“œ ์ƒ์„ฑ. + */ + public static Brand create(String name, String description, String logoUrl) { + Brand brand = new Brand(); + brand.name = name; + brand.description = description; + brand.logoUrl = logoUrl; + ZonedDateTime now = ZonedDateTime.now(); + brand.createdAt = now; + brand.updatedAt = now; + return brand; + } + + /** + * DB์—์„œ ๋ณต์› (Infrastructure์—์„œ ์‚ฌ์šฉ). + */ + public static Brand reconstitute(Long id, String name, String description, + String logoUrl, ZonedDateTime createdAt, ZonedDateTime updatedAt, ZonedDateTime deletedAt) { + Brand brand = new Brand(); + brand.id = id; + brand.name = name; + brand.description = description; + brand.logoUrl = logoUrl; + brand.createdAt = createdAt; + brand.updatedAt = updatedAt; + brand.deletedAt = deletedAt; + return brand; + } + + /** + * ๋ธŒ๋žœ๋“œ ์ •๋ณด ์ˆ˜์ •. + * + * @throws CoreException ์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ์ธ ๊ฒฝ์šฐ + */ + public void update(String name, String description, String logoUrl) { + guardDeleted(); + this.name = name; + this.description = description; + this.logoUrl = logoUrl; + this.updatedAt = ZonedDateTime.now(); + } + + /** + * ๋ธŒ๋žœ๋“œ ์‚ญ์ œ (Soft Delete). + * ๋ฉฑ๋“ฑํ•˜๊ฒŒ ๋™์ž‘ํ•œ๋‹ค. + */ + public void delete() { + if (this.deletedAt == null) { + this.deletedAt = ZonedDateTime.now(); + } + } + + public boolean isDeleted() { + return deletedAt != null; + } + + private void guardDeleted() { + if (isDeleted()) { + throw new CoreException(ErrorType.BRAND_DELETED); + } + } + + // Getters + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getLogoUrl() { + return logoUrl; + } + + public ZonedDateTime getCreatedAt() { + return createdAt; + } + + public ZonedDateTime getUpdatedAt() { + return updatedAt; + } + + public ZonedDateTime getDeletedAt() { + return deletedAt; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandDomainService.java new file mode 100644 index 000000000..09054a07e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandDomainService.java @@ -0,0 +1,44 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class BrandDomainService { + + private final BrandRepository brandRepository; + private final BrandValidator brandValidator; + + public Brand create(BrandInfo info) { + brandValidator.validateNameNotDuplicated(info.name()); + Brand brand = Brand.create(info.name(), info.description(), info.logoUrl()); + return brandRepository.save(brand); + } + + public Brand update(Long id, BrandInfo info) { + Brand brand = findById(id); + brandValidator.validateNameNotDuplicatedExcept(info.name(), id); + brand.update(info.name(), info.description(), info.logoUrl()); + return brandRepository.save(brand); + } + + public Brand findById(Long id) { + return brandRepository.findByIdActive(id) + .orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND)); + } + + public List findAll() { + return brandRepository.findAllActive(); + } + + public void delete(Long id) { + Brand brand = findById(id); + brand.delete(); + brandRepository.save(brand); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandInfo.java new file mode 100644 index 000000000..d2cbf3032 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandInfo.java @@ -0,0 +1,53 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.regex.Pattern; + +public record BrandInfo( + String name, + String description, + String logoUrl +) { + private static final int MAX_NAME_LENGTH = 100; + private static final int MAX_DESCRIPTION_LENGTH = 500; + private static final int MAX_LOGO_URL_LENGTH = 500; + private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*"); + + public BrandInfo { + validateName(name); + validateDescription(description); + validateLogoUrl(logoUrl); + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋ธŒ๋žœ๋“œ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (name.length() > MAX_NAME_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("๋ธŒ๋žœ๋“œ๋ช…์€ %d์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", MAX_NAME_LENGTH)); + } + } + + private void validateDescription(String description) { + if (description != null && description.length() > MAX_DESCRIPTION_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("๋ธŒ๋žœ๋“œ ์„ค๋ช…์€ %d์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", MAX_DESCRIPTION_LENGTH)); + } + } + + private void validateLogoUrl(String logoUrl) { + if (logoUrl == null) { + return; + } + if (!URL_PATTERN.matcher(logoUrl).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋กœ๊ณ  URL์€ http ๋˜๋Š” https๋กœ ์‹œ์ž‘ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (logoUrl.length() > MAX_LOGO_URL_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("๋กœ๊ณ  URL์€ %d์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", MAX_LOGO_URL_LENGTH)); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java new file mode 100644 index 000000000..1dc2d0dd4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,24 @@ +package com.loopers.domain.brand; + +import java.util.List; +import java.util.Optional; + +/** + * ๋ธŒ๋žœ๋“œ Repository ์ธํ„ฐํŽ˜์ด์Šค. + * ์ˆœ์ˆ˜ Java ์ธํ„ฐํŽ˜์ด์Šค๋กœ Spring/JPA ์˜์กด์„ฑ ์—†์Œ. + * ๊ตฌํ˜„์ฒด๋Š” Infrastructure Layer์— ์œ„์น˜. + */ +public interface BrandRepository { + + Brand save(Brand brand); + + Optional findById(Long id); + + Optional findByIdActive(Long id); + + List findAllActive(); + + boolean existsByName(String name); + + boolean existsByNameAndIdNot(String name, Long id); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandValidator.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandValidator.java new file mode 100644 index 000000000..774046ff2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandValidator.java @@ -0,0 +1,25 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class BrandValidator { + + private final BrandRepository brandRepository; + + public void validateNameNotDuplicated(String name) { + if (brandRepository.existsByName(name)) { + throw new CoreException(ErrorType.BRAND_ALREADY_EXISTS); + } + } + + public void validateNameNotDuplicatedExcept(String name, Long excludeId) { + if (brandRepository.existsByNameAndIdNot(name, excludeId)) { + throw new CoreException(ErrorType.BRAND_ALREADY_EXISTS); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandJpaEntity.java new file mode 100644 index 000000000..88751a3b7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandJpaEntity.java @@ -0,0 +1,56 @@ +package com.loopers.infrastructure.persistence.jpa.brand; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +/** + * ๋ธŒ๋žœ๋“œ JPA ์—”ํ‹ฐํ‹ฐ. + * Infrastructure Layer์— ์œ„์น˜ํ•˜๋ฉฐ ์˜์†์„ฑ์„ ๋‹ด๋‹น. + */ +@Entity +@Table(name = "brands") +public class BrandJpaEntity extends BaseEntity { + + @Column(name = "name", nullable = false, unique = true, length = 100) + private String name; + + @Column(name = "description", length = 500) + private String description; + + @Column(name = "logo_url", length = 500) + private String logoUrl; + + protected BrandJpaEntity() {} + + public BrandJpaEntity(String name, String description, String logoUrl) { + this.name = name; + this.description = description; + this.logoUrl = logoUrl; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getLogoUrl() { + return logoUrl; + } + + public void setName(String name) { + this.name = name; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setLogoUrl(String logoUrl) { + this.logoUrl = logoUrl; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandJpaRepository.java new file mode 100644 index 000000000..8ff316720 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandJpaRepository.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.persistence.jpa.brand; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +/** + * Brand JPA Repository. + * Spring Data JPA๋ฅผ ์‚ฌ์šฉํ•œ ์˜์†์„ฑ ๊ณ„์ธต. + */ +public interface BrandJpaRepository extends JpaRepository { + + Optional findByIdAndDeletedAtIsNull(Long id); + + boolean existsByName(String name); + + boolean existsByNameAndIdNot(String name, Long id); + + List findAllByDeletedAtIsNull(); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandMapper.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandMapper.java new file mode 100644 index 000000000..d83b73954 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandMapper.java @@ -0,0 +1,55 @@ +package com.loopers.infrastructure.persistence.jpa.brand; + +import com.loopers.domain.brand.Brand; + +/** + * Brand ๋„๋ฉ”์ธ ๊ฐ์ฒด์™€ JPA ์—”ํ‹ฐํ‹ฐ ๊ฐ„ ๋ณ€ํ™˜์„ ๋‹ด๋‹น. + */ +public class BrandMapper { + + private BrandMapper() {} + + /** + * JPA ์—”ํ‹ฐํ‹ฐ๋ฅผ ๋„๋ฉ”์ธ ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜. + */ + public static Brand toDomain(BrandJpaEntity entity) { + if (entity == null) { + return null; + } + return Brand.reconstitute( + entity.getId(), + entity.getName(), + entity.getDescription(), + entity.getLogoUrl(), + entity.getCreatedAt(), + entity.getUpdatedAt(), + entity.getDeletedAt() + ); + } + + /** + * ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ์ƒˆ JPA ์—”ํ‹ฐํ‹ฐ๋กœ ๋ณ€ํ™˜ (์‹ ๊ทœ ์ €์žฅ์šฉ). + */ + public static BrandJpaEntity toJpaEntity(Brand domain) { + if (domain == null) { + return null; + } + return new BrandJpaEntity( + domain.getName(), + domain.getDescription(), + domain.getLogoUrl() + ); + } + + /** + * ๊ธฐ์กด JPA ์—”ํ‹ฐํ‹ฐ๋ฅผ ๋„๋ฉ”์ธ ๊ฐ์ฒด๋กœ ์—…๋ฐ์ดํŠธ. + */ + public static void updateJpaEntity(BrandJpaEntity entity, Brand domain) { + entity.setName(domain.getName()); + entity.setDescription(domain.getDescription()); + entity.setLogoUrl(domain.getLogoUrl()); + if (domain.isDeleted() && entity.getDeletedAt() == null) { + entity.delete(); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandRepositoryImpl.java new file mode 100644 index 000000000..2d466046d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/brand/BrandRepositoryImpl.java @@ -0,0 +1,68 @@ +package com.loopers.infrastructure.persistence.jpa.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * BrandRepository ๊ตฌํ˜„์ฒด. + * JPA๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ Brand ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ์˜์†ํ™”. + * Domain โ†” JPA Entity ๋ณ€ํ™˜์€ BrandMapper๋ฅผ ํ†ตํ•ด ์ˆ˜ํ–‰. + */ +@Repository +@RequiredArgsConstructor +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository jpaRepository; + + @Override + public Brand save(Brand brand) { + BrandJpaEntity entity; + + if (brand.getId() == null) { + // ์‹ ๊ทœ ์ƒ์„ฑ + entity = BrandMapper.toJpaEntity(brand); + } else { + // ๊ธฐ์กด ์—”ํ‹ฐํ‹ฐ ์—…๋ฐ์ดํŠธ + entity = jpaRepository.findById(brand.getId()) + .orElseGet(() -> BrandMapper.toJpaEntity(brand)); + BrandMapper.updateJpaEntity(entity, brand); + } + + BrandJpaEntity saved = jpaRepository.save(entity); + return BrandMapper.toDomain(saved); + } + + @Override + public Optional findById(Long id) { + return jpaRepository.findById(id) + .map(BrandMapper::toDomain); + } + + @Override + public Optional findByIdActive(Long id) { + return jpaRepository.findByIdAndDeletedAtIsNull(id) + .map(BrandMapper::toDomain); + } + + @Override + public List findAllActive() { + return jpaRepository.findAllByDeletedAtIsNull().stream() + .map(BrandMapper::toDomain) + .toList(); + } + + @Override + public boolean existsByName(String name) { + return jpaRepository.existsByName(name); + } + + @Override + public boolean existsByNameAndIdNot(String name, Long id) { + return jpaRepository.existsByNameAndIdNot(name, id); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandInfoTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandInfoTest.java new file mode 100644 index 000000000..0afb7e7b6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandInfoTest.java @@ -0,0 +1,179 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BrandInfoTest { + + @DisplayName("BrandInfo๋ฅผ ์ƒ์„ฑํ•  ๋•Œ,") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ž…๋ ฅ์ด ์ฃผ์–ด์ง€๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsBrandInfo_whenValidInputIsProvided() { + // arrange + String name = "Nike"; + String description = "Just Do It"; + String logoUrl = "https://example.com/logo.png"; + + // act + BrandInfo brandInfo = new BrandInfo(name, description, logoUrl); + + // assert + assertAll( + () -> assertThat(brandInfo.name()).isEqualTo(name), + () -> assertThat(brandInfo.description()).isEqualTo(description), + () -> assertThat(brandInfo.logoUrl()).isEqualTo(logoUrl) + ); + } + + @DisplayName("description๊ณผ logoUrl์ด null์ด์–ด๋„, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsBrandInfo_whenOptionalFieldsAreNull() { + // arrange + String name = "Nike"; + + // act + BrandInfo brandInfo = new BrandInfo(name, null, null); + + // assert + assertAll( + () -> assertThat(brandInfo.name()).isEqualTo(name), + () -> assertThat(brandInfo.description()).isNull(), + () -> assertThat(brandInfo.logoUrl()).isNull() + ); + } + + @DisplayName("name์ด null์ด๊ฑฐ๋‚˜ ๋นˆ ๋ฌธ์ž์—ด์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @NullAndEmptySource + void throwsBadRequestException_whenNameIsNullOrEmpty(String name) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new BrandInfo(name, "description", "https://example.com/logo.png"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name์ด ๊ณต๋ฐฑ ๋ฌธ์ž์—ด์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = {" ", "\t", "\n"}) + void throwsBadRequestException_whenNameIsBlank(String name) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new BrandInfo(name, "description", "https://example.com/logo.png"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name์ด 100์ž๋ฅผ ์ดˆ๊ณผํ•˜๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenNameExceeds100Characters() { + // arrange + String name = "a".repeat(101); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new BrandInfo(name, "description", "https://example.com/logo.png"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name์ด ์ •ํ™•ํžˆ 100์ž๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsBrandInfo_whenNameIsExactly100Characters() { + // arrange + String name = "a".repeat(100); + + // act + BrandInfo brandInfo = new BrandInfo(name, null, null); + + // assert + assertThat(brandInfo.name()).isEqualTo(name); + } + + @DisplayName("description์ด 500์ž๋ฅผ ์ดˆ๊ณผํ•˜๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenDescriptionExceeds500Characters() { + // arrange + String description = "a".repeat(501); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new BrandInfo("Nike", description, "https://example.com/logo.png"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("description์ด ์ •ํ™•ํžˆ 500์ž๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsBrandInfo_whenDescriptionIsExactly500Characters() { + // arrange + String description = "a".repeat(500); + + // act + BrandInfo brandInfo = new BrandInfo("Nike", description, null); + + // assert + assertThat(brandInfo.description()).isEqualTo(description); + } + + @DisplayName("logoUrl์ด URL ํ˜•์‹์ด ์•„๋‹ˆ๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = {"not-a-url", "ftp://example.com", "example.com/logo.png"}) + void throwsBadRequestException_whenLogoUrlIsNotValidUrl(String logoUrl) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new BrandInfo("Nike", "description", logoUrl); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("logoUrl์ด http ๋˜๋Š” https๋กœ ์‹œ์ž‘ํ•˜๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = {"http://example.com/logo.png", "https://example.com/logo.png"}) + void createsBrandInfo_whenLogoUrlStartsWithHttpOrHttps(String logoUrl) { + // act + BrandInfo brandInfo = new BrandInfo("Nike", "description", logoUrl); + + // assert + assertThat(brandInfo.logoUrl()).isEqualTo(logoUrl); + } + + @DisplayName("logoUrl์ด 500์ž๋ฅผ ์ดˆ๊ณผํ•˜๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenLogoUrlExceeds500Characters() { + // arrange + String logoUrl = "https://example.com/" + "a".repeat(481); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new BrandInfo("Nike", "description", logoUrl); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java new file mode 100644 index 000000000..077d6819f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -0,0 +1,160 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BrandTest { + + @DisplayName("Brand๋ฅผ ์ƒ์„ฑํ•  ๋•Œ,") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๋กœ ์ƒ์„ฑํ•˜๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsBrand_whenInfoIsValid() { + // arrange & act + Brand brand = Brand.create("Nike", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ", "https://example.com/nike.png"); + + // assert + assertThat(brand.getName()).isEqualTo("Nike"); + assertThat(brand.getDescription()).isEqualTo("์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + assertThat(brand.getLogoUrl()).isEqualTo("https://example.com/nike.png"); + assertThat(brand.getCreatedAt()).isNotNull(); + assertThat(brand.isDeleted()).isFalse(); + } + + @DisplayName("ID๋Š” null๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsWithNullId() { + // act + Brand brand = Brand.create("Nike", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ", "https://example.com/nike.png"); + + // assert + assertThat(brand.getId()).isNull(); + } + } + + @DisplayName("Brand๋ฅผ ๋ณต์›ํ•  ๋•Œ,") + @Nested + class Reconstitute { + + @DisplayName("๋ชจ๋“  ํ•„๋“œ๊ฐ€ ๋ณต์›๋œ๋‹ค.") + @Test + void reconstitutesAllFields() { + // arrange + Long id = 1L; + String name = "Nike"; + String description = "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"; + String logoUrl = "https://example.com/nike.png"; + ZonedDateTime createdAt = ZonedDateTime.now().minusDays(1); + ZonedDateTime updatedAt = ZonedDateTime.now(); + ZonedDateTime deletedAt = null; + + // act + Brand brand = Brand.reconstitute(id, name, description, logoUrl, createdAt, updatedAt, deletedAt); + + // assert + assertThat(brand.getId()).isEqualTo(id); + assertThat(brand.getName()).isEqualTo(name); + assertThat(brand.getDescription()).isEqualTo(description); + assertThat(brand.getLogoUrl()).isEqualTo(logoUrl); + assertThat(brand.getCreatedAt()).isEqualTo(createdAt); + assertThat(brand.getUpdatedAt()).isEqualTo(updatedAt); + assertThat(brand.getDeletedAt()).isNull(); + } + + @DisplayName("์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ๋„ ๋ณต์›๋œ๋‹ค.") + @Test + void reconstitutesDeletedBrand() { + // arrange + ZonedDateTime now = ZonedDateTime.now(); + ZonedDateTime deletedAt = now; + + // act + Brand brand = Brand.reconstitute(1L, "Nike", "์„ค๋ช…", "url", now, now, deletedAt); + + // assert + assertThat(brand.isDeleted()).isTrue(); + assertThat(brand.getDeletedAt()).isEqualTo(deletedAt); + } + } + + @DisplayName("Brand๋ฅผ ์ˆ˜์ •ํ•  ๋•Œ,") + @Nested + class Update { + + @DisplayName("ํ™œ์„ฑ ๋ธŒ๋žœ๋“œ๋Š” ์ •์ƒ์ ์œผ๋กœ ์ˆ˜์ •๋œ๋‹ค.") + @Test + void updatesBrand_whenBrandIsActive() { + // arrange + Brand brand = Brand.create("Nike", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ", "https://example.com/nike.png"); + + // act + brand.update("Adidas", "๋…์ผ ์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ", "https://example.com/adidas.png"); + + // assert + assertThat(brand.getName()).isEqualTo("Adidas"); + assertThat(brand.getDescription()).isEqualTo("๋…์ผ ์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + assertThat(brand.getLogoUrl()).isEqualTo("https://example.com/adidas.png"); + } + + @DisplayName("์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ๋Š” ์ˆ˜์ •ํ•  ์ˆ˜ ์—†๋‹ค.") + @Test + void throwsException_whenBrandIsDeleted() { + // arrange + Brand brand = Brand.create("Nike", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ", "https://example.com/nike.png"); + brand.delete(); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brand.update("Adidas", "์„ค๋ช…", "url"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BRAND_DELETED); + } + } + + @DisplayName("Brand๋ฅผ ์‚ญ์ œํ•  ๋•Œ,") + @Nested + class Delete { + + @DisplayName("ํ™œ์„ฑ ๋ธŒ๋žœ๋“œ๋Š” ์‚ญ์ œ๋œ๋‹ค.") + @Test + void deletesBrand_whenBrandIsActive() { + // arrange + Brand brand = Brand.create("Nike", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ", "https://example.com/nike.png"); + + // act + brand.delete(); + + // assert + assertThat(brand.isDeleted()).isTrue(); + assertThat(brand.getDeletedAt()).isNotNull(); + } + + @DisplayName("์ด๋ฏธ ์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ๋ฅผ ์‚ญ์ œํ•ด๋„ ๋ฉฑ๋“ฑํ•˜๊ฒŒ ๋™์ž‘ํ•œ๋‹ค.") + @Test + void isIdempotent_whenBrandIsAlreadyDeleted() { + // arrange + Brand brand = Brand.create("Nike", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ", "https://example.com/nike.png"); + brand.delete(); + ZonedDateTime firstDeletedAt = brand.getDeletedAt(); + + // act + assertDoesNotThrow(() -> brand.delete()); + + // assert + assertThat(brand.getDeletedAt()).isEqualTo(firstDeletedAt); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeBrandRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeBrandRepository.java new file mode 100644 index 000000000..112faad39 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeBrandRepository.java @@ -0,0 +1,87 @@ +package com.loopers.fake; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +/** + * ํ…Œ์ŠคํŠธ์šฉ Fake BrandRepository. + * Map ๊ธฐ๋ฐ˜ in-memory ๊ตฌํ˜„. + */ +public class FakeBrandRepository implements BrandRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public Brand save(Brand brand) { + Long id = brand.getId(); + if (id == null) { + id = idGenerator.getAndIncrement(); + // reconstitute๋ฅผ ํ†ตํ•ด ID๊ฐ€ ํ• ๋‹น๋œ ์ƒˆ ๊ฐ์ฒด ์ƒ์„ฑ + brand = Brand.reconstitute( + id, + brand.getName(), + brand.getDescription(), + brand.getLogoUrl(), + brand.getCreatedAt(), + brand.getUpdatedAt(), + brand.getDeletedAt() + ); + } + store.put(id, brand); + return brand; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public Optional findByIdActive(Long id) { + return findById(id).filter(brand -> !brand.isDeleted()); + } + + @Override + public List findAllActive() { + return store.values().stream() + .filter(brand -> !brand.isDeleted()) + .toList(); + } + + @Override + public boolean existsByName(String name) { + return store.values().stream() + .filter(brand -> !brand.isDeleted()) + .anyMatch(brand -> brand.getName().equals(name)); + } + + @Override + public boolean existsByNameAndIdNot(String name, Long id) { + return store.values().stream() + .filter(brand -> !brand.isDeleted()) + .filter(brand -> !brand.getId().equals(id)) + .anyMatch(brand -> brand.getName().equals(name)); + } + + /** + * ํ…Œ์ŠคํŠธ์šฉ: ์ €์žฅ์†Œ ์ดˆ๊ธฐํ™” + */ + public void clear() { + store.clear(); + idGenerator.set(1); + } + + /** + * ํ…Œ์ŠคํŠธ์šฉ: ์ €์žฅ๋œ ๋ธŒ๋žœ๋“œ ์ˆ˜ ์กฐํšŒ + */ + public int size() { + return store.size(); + } +} \ No newline at end of file From d80786f6a029bfa109272683325adca773621cb3 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 27 Feb 2026 13:53:47 +0900 Subject: [PATCH 17/29] =?UTF-8?q?feat(domain):=20Product=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Domain Layer (์ˆœ์ˆ˜ Java): - Product: ์ƒํ’ˆ ์—”ํ‹ฐํ‹ฐ, Money/Stock VO ์‚ฌ์šฉ - ProductRepository: Repository ์ธํ„ฐํŽ˜์ด์Šค (๋น„๊ด€์  ๋ฝ ํฌํ•จ) - ProductDomainService: ์žฌ๊ณ  ์ฐจ๊ฐ, CRUD ์ •์ฑ… - ProductValidator: ๋ธŒ๋žœ๋“œ ์กด์žฌ ๊ฒ€์ฆ Infrastructure Layer: - ProductJpaEntity: JPA ์—”ํ‹ฐํ‹ฐ - ProductMapper: Domain โ†” JPA ๋ณ€ํ™˜ - ProductRepositoryImpl: Repository Adapter Test: - FakeProductRepository: Map ๊ธฐ๋ฐ˜ in-memory ๊ตฌํ˜„ - ProductTest: ์žฌ๊ณ  ์ฐจ๊ฐ, soft delete ํ…Œ์ŠคํŠธ Co-Authored-By: Claude Opus 4.5 --- .../com/loopers/domain/product/Product.java | 153 +++++++++++ .../domain/product/ProductDomainService.java | 87 ++++++ .../loopers/domain/product/ProductInfo.java | 83 ++++++ .../domain/product/ProductRepository.java | 32 +++ .../domain/product/ProductValidator.java | 24 ++ .../jpa/product/ProductJpaEntity.java | 93 +++++++ .../jpa/product/ProductJpaRepository.java | 37 +++ .../jpa/product/ProductMapper.java | 66 +++++ .../jpa/product/ProductRepositoryImpl.java | 111 ++++++++ .../loopers/domain/product/ProductTest.java | 258 ++++++++++++++++++ .../loopers/fake/FakeProductRepository.java | 134 +++++++++ 11 files changed, 1078 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductValidator.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductMapper.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/fake/FakeProductRepository.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java new file mode 100644 index 000000000..58903f2fd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,153 @@ +package com.loopers.domain.product; + +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.ZonedDateTime; + +/** + * ์ƒํ’ˆ ๋„๋ฉ”์ธ ์—”ํ‹ฐํ‹ฐ. + * ์ˆœ์ˆ˜ Java ๊ฐ์ฒด๋กœ JPA/Spring ์˜์กด์„ฑ ์—†์Œ. + */ +public class Product { + + private Long id; + private Long brandId; + private String name; + private String description; + private Money price; + private Stock stock; + private String imageUrl; + private ZonedDateTime createdAt; + private ZonedDateTime updatedAt; + private ZonedDateTime deletedAt; + + private Product() {} + + /** + * ์ƒˆ ์ƒํ’ˆ ์ƒ์„ฑ. + */ + public static Product create(Long brandId, String name, String description, + Money price, Stock stock, String imageUrl) { + Product product = new Product(); + product.brandId = brandId; + product.name = name; + product.description = description; + product.price = price; + product.stock = stock; + product.imageUrl = imageUrl; + ZonedDateTime now = ZonedDateTime.now(); + product.createdAt = now; + product.updatedAt = now; + return product; + } + + /** + * DB์—์„œ ๋ณต์› (Infrastructure์—์„œ ์‚ฌ์šฉ). + */ + public static Product reconstitute(Long id, Long brandId, String name, String description, + Money price, Stock stock, String imageUrl, + ZonedDateTime createdAt, ZonedDateTime updatedAt, ZonedDateTime deletedAt) { + Product product = new Product(); + product.id = id; + product.brandId = brandId; + product.name = name; + product.description = description; + product.price = price; + product.stock = stock; + product.imageUrl = imageUrl; + product.createdAt = createdAt; + product.updatedAt = updatedAt; + product.deletedAt = deletedAt; + return product; + } + + /** + * ์žฌ๊ณ  ์ฐจ๊ฐ. + * + * @param quantity ์ฐจ๊ฐํ•  ์ˆ˜๋Ÿ‰ + * @throws CoreException ์‚ญ์ œ๋œ ์ƒํ’ˆ์ด๊ฑฐ๋‚˜ ์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•œ ๊ฒฝ์šฐ + */ + public void decreaseStock(int quantity) { + guardDeleted(); + this.stock = this.stock.decrease(quantity); + this.updatedAt = ZonedDateTime.now(); + } + + /** + * ์ƒํ’ˆ ์ •๋ณด ์ˆ˜์ •. + * + * @throws CoreException ์‚ญ์ œ๋œ ์ƒํ’ˆ์ธ ๊ฒฝ์šฐ + */ + public void update(String name, String description, Money price, Stock stock, String imageUrl) { + guardDeleted(); + this.name = name; + this.description = description; + this.price = price; + this.stock = stock; + this.imageUrl = imageUrl; + this.updatedAt = ZonedDateTime.now(); + } + + /** + * ์ƒํ’ˆ ์‚ญ์ œ (Soft Delete). + * ๋ฉฑ๋“ฑํ•˜๊ฒŒ ๋™์ž‘ํ•œ๋‹ค. + */ + public void delete() { + if (this.deletedAt == null) { + this.deletedAt = ZonedDateTime.now(); + } + } + + public boolean isDeleted() { + return deletedAt != null; + } + + private void guardDeleted() { + if (isDeleted()) { + throw new CoreException(ErrorType.PRODUCT_DELETED); + } + } + + // Getters + public Long getId() { + return id; + } + + public Long getBrandId() { + return brandId; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public Money getPrice() { + return price; + } + + public Stock getStock() { + return stock; + } + + public String getImageUrl() { + return imageUrl; + } + + public ZonedDateTime getCreatedAt() { + return createdAt; + } + + public ZonedDateTime getUpdatedAt() { + return updatedAt; + } + + public ZonedDateTime getDeletedAt() { + return deletedAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java new file mode 100644 index 000000000..429c20361 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java @@ -0,0 +1,87 @@ +package com.loopers.domain.product; + +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class ProductDomainService { + + private final ProductRepository productRepository; + private final ProductValidator productValidator; + + public Product create(ProductInfo info) { + productValidator.validateBrandExists(info.brandId()); + Product product = Product.create( + info.brandId(), + info.name(), + info.description(), + new Money(info.price()), + new Stock(info.stock()), + info.imageUrl() + ); + return productRepository.save(product); + } + + public Product update(Long id, ProductInfo info) { + Product product = findById(id); + product.update( + info.name(), + info.description(), + new Money(info.price()), + new Stock(info.stock()), + info.imageUrl() + ); + return productRepository.save(product); + } + + public Product findById(Long id) { + return productRepository.findByIdActive(id) + .orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND)); + } + + public Product findByIdWithLock(Long id) { + return productRepository.findByIdWithLock(id) + .orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND)); + } + + public List findAll(ProductSort sort, int offset, int limit) { + return productRepository.findAllActive(sort, offset, limit); + } + + public List findAllByBrandId(Long brandId, ProductSort sort, int offset, int limit) { + return productRepository.findAllByBrandIdActive(brandId, sort, offset, limit); + } + + public long countAll() { + return productRepository.countActive(); + } + + public long countByBrandId(Long brandId) { + return productRepository.countByBrandIdActive(brandId); + } + + public void decreaseStock(Product product, int quantity) { + product.decreaseStock(quantity); + productRepository.save(product); + } + + public void delete(Long id) { + Product product = findById(id); + product.delete(); + productRepository.save(product); + } + + public void deleteAllByBrandId(Long brandId) { + List products = productRepository.findAllByBrandIdActive(brandId); + products.forEach(product -> { + product.delete(); + productRepository.save(product); + }); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductInfo.java new file mode 100644 index 000000000..1936572d9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductInfo.java @@ -0,0 +1,83 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.regex.Pattern; + +public record ProductInfo( + Long brandId, + String name, + String description, + Long price, + Integer stock, + String imageUrl +) { + private static final int MAX_NAME_LENGTH = 200; + private static final int MAX_DESCRIPTION_LENGTH = 2000; + private static final int MAX_IMAGE_URL_LENGTH = 500; + private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*"); + + public ProductInfo { + validateBrandId(brandId); + validateName(name); + validateDescription(description); + validatePrice(price); + validateStock(stock); + validateImageUrl(imageUrl); + } + + private void validateBrandId(Long brandId) { + if (brandId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋ธŒ๋žœ๋“œ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (name.length() > MAX_NAME_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("์ƒํ’ˆ๋ช…์€ %d์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", MAX_NAME_LENGTH)); + } + } + + private void validateDescription(String description) { + if (description != null && description.length() > MAX_DESCRIPTION_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("์ƒํ’ˆ ์„ค๋ช…์€ %d์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", MAX_DESCRIPTION_LENGTH)); + } + } + + private void validatePrice(Long price) { + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "๊ฐ€๊ฒฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (price < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "๊ฐ€๊ฒฉ์€ 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + } + + private void validateStock(Integer stock) { + if (stock == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์žฌ๊ณ ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (stock < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์žฌ๊ณ ๋Š” 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + } + + private void validateImageUrl(String imageUrl) { + if (imageUrl == null) { + return; + } + if (!URL_PATTERN.matcher(imageUrl).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฏธ์ง€ URL์€ http ๋˜๋Š” https๋กœ ์‹œ์ž‘ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (imageUrl.length() > MAX_IMAGE_URL_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("์ด๋ฏธ์ง€ URL์€ %d์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", MAX_IMAGE_URL_LENGTH)); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java new file mode 100644 index 000000000..0ae63b4c8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,32 @@ +package com.loopers.domain.product; + +import java.util.List; +import java.util.Optional; + +/** + * ์ƒํ’ˆ Repository ์ธํ„ฐํŽ˜์ด์Šค. + * ์ˆœ์ˆ˜ Java ์ธํ„ฐํŽ˜์ด์Šค๋กœ Spring/JPA ์˜์กด์„ฑ ์—†์Œ. + * ๊ตฌํ˜„์ฒด๋Š” Infrastructure Layer์— ์œ„์น˜. + */ +public interface ProductRepository { + + Product save(Product product); + + Optional findById(Long id); + + Optional findByIdActive(Long id); + + Optional findByIdWithLock(Long id); + + List findAllActive(ProductSort sort, int offset, int limit); + + List findAllByBrandIdActive(Long brandId, ProductSort sort, int offset, int limit); + + List findAllByBrandIdActive(Long brandId); + + List findAllByIds(List ids); + + long countActive(); + + long countByBrandIdActive(Long brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductValidator.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductValidator.java new file mode 100644 index 000000000..d2aa28960 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductValidator.java @@ -0,0 +1,24 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ProductValidator { + + private final BrandRepository brandRepository; + + public void validateBrandExists(Long brandId) { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND)); + + if (brand.getDeletedAt() != null) { + throw new CoreException(ErrorType.BRAND_DELETED); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaEntity.java new file mode 100644 index 000000000..e90f2cdbd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaEntity.java @@ -0,0 +1,93 @@ +package com.loopers.infrastructure.persistence.jpa.product; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +/** + * ์ƒํ’ˆ JPA ์—”ํ‹ฐํ‹ฐ. + * Infrastructure Layer์— ์œ„์น˜ํ•˜๋ฉฐ ์˜์†์„ฑ์„ ๋‹ด๋‹น. + */ +@Entity +@Table(name = "products") +public class ProductJpaEntity extends BaseEntity { + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "name", nullable = false, length = 200) + private String name; + + @Column(name = "description", length = 2000) + private String description; + + @Column(name = "price", nullable = false) + private Long price; + + @Column(name = "stock", nullable = false) + private Integer stock; + + @Column(name = "image_url", length = 500) + private String imageUrl; + + protected ProductJpaEntity() {} + + public ProductJpaEntity(Long brandId, String name, String description, + Long price, Integer stock, String imageUrl) { + this.brandId = brandId; + this.name = name; + this.description = description; + this.price = price; + this.stock = stock; + this.imageUrl = imageUrl; + } + + public Long getBrandId() { + return brandId; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public Long getPrice() { + return price; + } + + public Integer getStock() { + return stock; + } + + public String getImageUrl() { + return imageUrl; + } + + public void setBrandId(Long brandId) { + this.brandId = brandId; + } + + public void setName(String name) { + this.name = name; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setPrice(Long price) { + this.price = price; + } + + public void setStock(Integer stock) { + this.stock = stock; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaRepository.java new file mode 100644 index 000000000..2090c5a69 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaRepository.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.persistence.jpa.product; + +import jakarta.persistence.LockModeType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +/** + * Product JPA Repository. + * Spring Data JPA๋ฅผ ์‚ฌ์šฉํ•œ ์˜์†์„ฑ ๊ณ„์ธต. + */ +public interface ProductJpaRepository extends JpaRepository { + + Optional findByIdAndDeletedAtIsNull(Long id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM ProductJpaEntity p WHERE p.id = :id AND p.deletedAt IS NULL") + Optional findByIdWithLock(@Param("id") Long id); + + Page findAllByDeletedAtIsNull(Pageable pageable); + + Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); + + List findAllByBrandIdAndDeletedAtIsNull(Long brandId); + + List findAllByIdIn(List ids); + + long countByDeletedAtIsNull(); + + long countByBrandIdAndDeletedAtIsNull(Long brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductMapper.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductMapper.java new file mode 100644 index 000000000..dde389264 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductMapper.java @@ -0,0 +1,66 @@ +package com.loopers.infrastructure.persistence.jpa.product; + +import com.loopers.domain.common.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.Stock; + +/** + * Product ๋„๋ฉ”์ธ ๊ฐ์ฒด์™€ JPA ์—”ํ‹ฐํ‹ฐ ๊ฐ„ ๋ณ€ํ™˜์„ ๋‹ด๋‹น. + */ +public class ProductMapper { + + private ProductMapper() {} + + /** + * JPA ์—”ํ‹ฐํ‹ฐ๋ฅผ ๋„๋ฉ”์ธ ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜. + */ + public static Product toDomain(ProductJpaEntity entity) { + if (entity == null) { + return null; + } + return Product.reconstitute( + entity.getId(), + entity.getBrandId(), + entity.getName(), + entity.getDescription(), + new Money(entity.getPrice()), + new Stock(entity.getStock()), + entity.getImageUrl(), + entity.getCreatedAt(), + entity.getUpdatedAt(), + entity.getDeletedAt() + ); + } + + /** + * ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ์ƒˆ JPA ์—”ํ‹ฐํ‹ฐ๋กœ ๋ณ€ํ™˜ (์‹ ๊ทœ ์ €์žฅ์šฉ). + */ + public static ProductJpaEntity toJpaEntity(Product domain) { + if (domain == null) { + return null; + } + return new ProductJpaEntity( + domain.getBrandId(), + domain.getName(), + domain.getDescription(), + domain.getPrice().amount(), + domain.getStock().quantity(), + domain.getImageUrl() + ); + } + + /** + * ๊ธฐ์กด JPA ์—”ํ‹ฐํ‹ฐ๋ฅผ ๋„๋ฉ”์ธ ๊ฐ์ฒด๋กœ ์—…๋ฐ์ดํŠธ. + */ + public static void updateJpaEntity(ProductJpaEntity entity, Product domain) { + entity.setBrandId(domain.getBrandId()); + entity.setName(domain.getName()); + entity.setDescription(domain.getDescription()); + entity.setPrice(domain.getPrice().amount()); + entity.setStock(domain.getStock().quantity()); + entity.setImageUrl(domain.getImageUrl()); + if (domain.isDeleted() && entity.getDeletedAt() == null) { + entity.delete(); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductRepositoryImpl.java new file mode 100644 index 000000000..9efa9aba5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductRepositoryImpl.java @@ -0,0 +1,111 @@ +package com.loopers.infrastructure.persistence.jpa.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductSort; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * ProductRepository ๊ตฌํ˜„์ฒด. + * JPA๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ Product ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ์˜์†ํ™”. + * Domain โ†” JPA Entity ๋ณ€ํ™˜์€ ProductMapper๋ฅผ ํ†ตํ•ด ์ˆ˜ํ–‰. + */ +@Repository +@RequiredArgsConstructor +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository jpaRepository; + + @Override + public Product save(Product product) { + ProductJpaEntity entity; + + if (product.getId() == null) { + // ์‹ ๊ทœ ์ƒ์„ฑ + entity = ProductMapper.toJpaEntity(product); + } else { + // ๊ธฐ์กด ์—”ํ‹ฐํ‹ฐ ์—…๋ฐ์ดํŠธ + entity = jpaRepository.findById(product.getId()) + .orElseGet(() -> ProductMapper.toJpaEntity(product)); + ProductMapper.updateJpaEntity(entity, product); + } + + ProductJpaEntity saved = jpaRepository.save(entity); + return ProductMapper.toDomain(saved); + } + + @Override + public Optional findById(Long id) { + return jpaRepository.findById(id) + .map(ProductMapper::toDomain); + } + + @Override + public Optional findByIdActive(Long id) { + return jpaRepository.findByIdAndDeletedAtIsNull(id) + .map(ProductMapper::toDomain); + } + + @Override + public Optional findByIdWithLock(Long id) { + return jpaRepository.findByIdWithLock(id) + .map(ProductMapper::toDomain); + } + + @Override + public List findAllActive(ProductSort sort, int offset, int limit) { + Pageable pageable = createPageable(sort, offset, limit); + return jpaRepository.findAllByDeletedAtIsNull(pageable).stream() + .map(ProductMapper::toDomain) + .toList(); + } + + @Override + public List findAllByBrandIdActive(Long brandId, ProductSort sort, int offset, int limit) { + Pageable pageable = createPageable(sort, offset, limit); + return jpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageable).stream() + .map(ProductMapper::toDomain) + .toList(); + } + + @Override + public List findAllByBrandIdActive(Long brandId) { + return jpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId).stream() + .map(ProductMapper::toDomain) + .toList(); + } + + @Override + public List findAllByIds(List ids) { + return jpaRepository.findAllByIdIn(ids).stream() + .map(ProductMapper::toDomain) + .toList(); + } + + @Override + public long countActive() { + return jpaRepository.countByDeletedAtIsNull(); + } + + @Override + public long countByBrandIdActive(Long brandId) { + return jpaRepository.countByBrandIdAndDeletedAtIsNull(brandId); + } + + private Pageable createPageable(ProductSort sort, int offset, int limit) { + int page = offset / limit; + Sort jpaSort = switch (sort) { + case LATEST -> Sort.by(Sort.Direction.DESC, "createdAt"); + case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "price"); + case LIKES_DESC -> Sort.by(Sort.Direction.DESC, "createdAt"); // likes_desc๋Š” Application์—์„œ ์ฒ˜๋ฆฌ + }; + return PageRequest.of(page, limit, jpaSort); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java new file mode 100644 index 000000000..0bf2ff3b8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,258 @@ +package com.loopers.domain.product; + +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ProductTest { + + @DisplayName("Product๋ฅผ ์ƒ์„ฑํ•  ๋•Œ,") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๋กœ ์ƒ์„ฑํ•˜๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsProduct_whenInfoIsValid() { + // arrange & act + Product product = Product.create( + 1L, + "Air Max 90", + "ํด๋ž˜์‹ ์Šค๋‹ˆ์ปค์ฆˆ", + new Money(150000), + new Stock(100), + "https://example.com/airmax.png" + ); + + // assert + assertThat(product.getBrandId()).isEqualTo(1L); + assertThat(product.getName()).isEqualTo("Air Max 90"); + assertThat(product.getDescription()).isEqualTo("ํด๋ž˜์‹ ์Šค๋‹ˆ์ปค์ฆˆ"); + assertThat(product.getPrice().amount()).isEqualTo(150000); + assertThat(product.getStock().quantity()).isEqualTo(100); + assertThat(product.getImageUrl()).isEqualTo("https://example.com/airmax.png"); + assertThat(product.getCreatedAt()).isNotNull(); + assertThat(product.isDeleted()).isFalse(); + } + + @DisplayName("ID๋Š” null๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsWithNullId() { + // act + Product product = Product.create(1L, "์ƒํ’ˆ", "์„ค๋ช…", new Money(10000), new Stock(10), "url"); + + // assert + assertThat(product.getId()).isNull(); + } + } + + @DisplayName("Product๋ฅผ ๋ณต์›ํ•  ๋•Œ,") + @Nested + class Reconstitute { + + @DisplayName("๋ชจ๋“  ํ•„๋“œ๊ฐ€ ๋ณต์›๋œ๋‹ค.") + @Test + void reconstitutesAllFields() { + // arrange + Long id = 1L; + Long brandId = 10L; + String name = "Air Max 90"; + String description = "ํด๋ž˜์‹ ์Šค๋‹ˆ์ปค์ฆˆ"; + Money price = new Money(150000); + Stock stock = new Stock(100); + String imageUrl = "https://example.com/airmax.png"; + ZonedDateTime createdAt = ZonedDateTime.now().minusDays(1); + ZonedDateTime updatedAt = ZonedDateTime.now(); + ZonedDateTime deletedAt = null; + + // act + Product product = Product.reconstitute( + id, brandId, name, description, price, stock, imageUrl, createdAt, updatedAt, deletedAt + ); + + // assert + assertThat(product.getId()).isEqualTo(id); + assertThat(product.getBrandId()).isEqualTo(brandId); + assertThat(product.getName()).isEqualTo(name); + assertThat(product.getDescription()).isEqualTo(description); + assertThat(product.getPrice()).isEqualTo(price); + assertThat(product.getStock()).isEqualTo(stock); + assertThat(product.getImageUrl()).isEqualTo(imageUrl); + assertThat(product.getCreatedAt()).isEqualTo(createdAt); + assertThat(product.getUpdatedAt()).isEqualTo(updatedAt); + assertThat(product.getDeletedAt()).isNull(); + } + } + + @DisplayName("์žฌ๊ณ ๋ฅผ ์ฐจ๊ฐํ•  ๋•Œ,") + @Nested + class DecreaseStock { + + @DisplayName("์ถฉ๋ถ„ํ•œ ์žฌ๊ณ ๊ฐ€ ์žˆ์œผ๋ฉด, ์žฌ๊ณ ๊ฐ€ ์ฐจ๊ฐ๋œ๋‹ค.") + @Test + void decreasesStock_whenStockIsSufficient() { + // arrange + Product product = Product.create(1L, "์ƒํ’ˆ", "์„ค๋ช…", new Money(10000), new Stock(100), "url"); + + // act + product.decreaseStock(30); + + // assert + assertThat(product.getStock().quantity()).isEqualTo(70); + } + + @DisplayName("์žฌ๊ณ ์™€ ์ •ํ™•ํžˆ ๊ฐ™์€ ์ˆ˜๋Ÿ‰์„ ์ฐจ๊ฐํ•˜๋ฉด, ์žฌ๊ณ ๊ฐ€ 0์ด ๋œ๋‹ค.") + @Test + void decreasesStockToZero_whenDecreasingExactAmount() { + // arrange + Product product = Product.create(1L, "์ƒํ’ˆ", "์„ค๋ช…", new Money(10000), new Stock(50), "url"); + + // act + product.decreaseStock(50); + + // assert + assertThat(product.getStock().quantity()).isEqualTo(0); + } + + @DisplayName("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•˜๋ฉด, INSUFFICIENT_STOCK ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsInsufficientStockException_whenStockIsInsufficient() { + // arrange + Product product = Product.create(1L, "์ƒํ’ˆ", "์„ค๋ช…", new Money(10000), new Stock(10), "url"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + product.decreaseStock(20); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.INSUFFICIENT_STOCK); + } + + @DisplayName("์ฐจ๊ฐ ์ˆ˜๋Ÿ‰์ด 0์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenQuantityIsZero() { + // arrange + Product product = Product.create(1L, "์ƒํ’ˆ", "์„ค๋ช…", new Money(10000), new Stock(100), "url"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + product.decreaseStock(0); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("์ฐจ๊ฐ ์ˆ˜๋Ÿ‰์ด ์Œ์ˆ˜์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenQuantityIsNegative() { + // arrange + Product product = Product.create(1L, "์ƒํ’ˆ", "์„ค๋ช…", new Money(10000), new Stock(100), "url"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + product.decreaseStock(-10); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("์‚ญ์ œ๋œ ์ƒํ’ˆ์˜ ์žฌ๊ณ ๋ฅผ ์ฐจ๊ฐํ•˜๋ฉด, PRODUCT_DELETED ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsProductDeletedException_whenProductIsDeleted() { + // arrange + Product product = Product.create(1L, "์ƒํ’ˆ", "์„ค๋ช…", new Money(10000), new Stock(100), "url"); + product.delete(); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + product.decreaseStock(10); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.PRODUCT_DELETED); + } + } + + @DisplayName("Product๋ฅผ ์ˆ˜์ •ํ•  ๋•Œ,") + @Nested + class Update { + + @DisplayName("ํ™œ์„ฑ ์ƒํ’ˆ์€ ์ •์ƒ์ ์œผ๋กœ ์ˆ˜์ •๋œ๋‹ค.") + @Test + void updatesProduct_whenProductIsActive() { + // arrange + Product product = Product.create(1L, "์ƒํ’ˆ", "์„ค๋ช…", new Money(10000), new Stock(100), "url"); + + // act + product.update("์ˆ˜์ •๋œ ์ƒํ’ˆ", "์ˆ˜์ •๋œ ์„ค๋ช…", new Money(20000), new Stock(50), "new_url"); + + // assert + assertThat(product.getName()).isEqualTo("์ˆ˜์ •๋œ ์ƒํ’ˆ"); + assertThat(product.getDescription()).isEqualTo("์ˆ˜์ •๋œ ์„ค๋ช…"); + assertThat(product.getPrice().amount()).isEqualTo(20000); + assertThat(product.getStock().quantity()).isEqualTo(50); + assertThat(product.getImageUrl()).isEqualTo("new_url"); + } + + @DisplayName("์‚ญ์ œ๋œ ์ƒํ’ˆ์€ ์ˆ˜์ •ํ•  ์ˆ˜ ์—†๋‹ค.") + @Test + void throwsException_whenProductIsDeleted() { + // arrange + Product product = Product.create(1L, "์ƒํ’ˆ", "์„ค๋ช…", new Money(10000), new Stock(100), "url"); + product.delete(); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + product.update("์ˆ˜์ •", "์„ค๋ช…", new Money(20000), new Stock(50), "url"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.PRODUCT_DELETED); + } + } + + @DisplayName("Product๋ฅผ ์‚ญ์ œํ•  ๋•Œ,") + @Nested + class Delete { + + @DisplayName("ํ™œ์„ฑ ์ƒํ’ˆ์€ ์‚ญ์ œ๋œ๋‹ค.") + @Test + void deletesProduct_whenProductIsActive() { + // arrange + Product product = Product.create(1L, "์ƒํ’ˆ", "์„ค๋ช…", new Money(10000), new Stock(100), "url"); + + // act + product.delete(); + + // assert + assertThat(product.isDeleted()).isTrue(); + assertThat(product.getDeletedAt()).isNotNull(); + } + + @DisplayName("์ด๋ฏธ ์‚ญ์ œ๋œ ์ƒํ’ˆ์„ ์‚ญ์ œํ•ด๋„ ๋ฉฑ๋“ฑํ•˜๊ฒŒ ๋™์ž‘ํ•œ๋‹ค.") + @Test + void isIdempotent_whenProductIsAlreadyDeleted() { + // arrange + Product product = Product.create(1L, "์ƒํ’ˆ", "์„ค๋ช…", new Money(10000), new Stock(100), "url"); + product.delete(); + ZonedDateTime firstDeletedAt = product.getDeletedAt(); + + // act + assertDoesNotThrow(() -> product.delete()); + + // assert + assertThat(product.getDeletedAt()).isEqualTo(firstDeletedAt); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeProductRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeProductRepository.java new file mode 100644 index 000000000..92228ac6c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeProductRepository.java @@ -0,0 +1,134 @@ +package com.loopers.fake; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductSort; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +/** + * ํ…Œ์ŠคํŠธ์šฉ Fake ProductRepository. + * Map ๊ธฐ๋ฐ˜ in-memory ๊ตฌํ˜„. + */ +public class FakeProductRepository implements ProductRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public Product save(Product product) { + Long id = product.getId(); + if (id == null) { + id = idGenerator.getAndIncrement(); + product = Product.reconstitute( + id, + product.getBrandId(), + product.getName(), + product.getDescription(), + product.getPrice(), + product.getStock(), + product.getImageUrl(), + product.getCreatedAt(), + product.getUpdatedAt(), + product.getDeletedAt() + ); + } + store.put(id, product); + return product; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public Optional findByIdActive(Long id) { + return findById(id).filter(product -> !product.isDeleted()); + } + + @Override + public Optional findByIdWithLock(Long id) { + // Fake์—์„œ๋Š” ๋ฝ ์—†์ด ๋™์ผํ•˜๊ฒŒ ๋™์ž‘ + return findByIdActive(id); + } + + @Override + public List findAllActive(ProductSort sort, int offset, int limit) { + return store.values().stream() + .filter(product -> !product.isDeleted()) + .sorted(getComparator(sort)) + .skip(offset) + .limit(limit) + .toList(); + } + + @Override + public List findAllByBrandIdActive(Long brandId, ProductSort sort, int offset, int limit) { + return store.values().stream() + .filter(product -> !product.isDeleted()) + .filter(product -> product.getBrandId().equals(brandId)) + .sorted(getComparator(sort)) + .skip(offset) + .limit(limit) + .toList(); + } + + @Override + public List findAllByBrandIdActive(Long brandId) { + return store.values().stream() + .filter(product -> !product.isDeleted()) + .filter(product -> product.getBrandId().equals(brandId)) + .toList(); + } + + @Override + public List findAllByIds(List ids) { + return store.values().stream() + .filter(product -> ids.contains(product.getId())) + .toList(); + } + + @Override + public long countActive() { + return store.values().stream() + .filter(product -> !product.isDeleted()) + .count(); + } + + @Override + public long countByBrandIdActive(Long brandId) { + return store.values().stream() + .filter(product -> !product.isDeleted()) + .filter(product -> product.getBrandId().equals(brandId)) + .count(); + } + + private Comparator getComparator(ProductSort sort) { + return switch (sort) { + case LATEST -> Comparator.comparing(Product::getCreatedAt).reversed(); + case PRICE_ASC -> Comparator.comparing(p -> p.getPrice().amount()); + case LIKES_DESC -> Comparator.comparing(Product::getCreatedAt).reversed(); // Application์—์„œ ์ฒ˜๋ฆฌ + }; + } + + /** + * ํ…Œ์ŠคํŠธ์šฉ: ์ €์žฅ์†Œ ์ดˆ๊ธฐํ™” + */ + public void clear() { + store.clear(); + idGenerator.set(1); + } + + /** + * ํ…Œ์ŠคํŠธ์šฉ: ์ €์žฅ๋œ ์ƒํ’ˆ ์ˆ˜ ์กฐํšŒ + */ + public int size() { + return store.size(); + } +} From 3cb787e07cac129da22038a52f8de5030a86c002 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 27 Feb 2026 13:54:31 +0900 Subject: [PATCH 18/29] =?UTF-8?q?feat(domain):=20Like=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Domain Layer (์ˆœ์ˆ˜ Java): - Like: ์ข‹์•„์š” ์—”ํ‹ฐํ‹ฐ (userId + productId) - LikeId: ๋ณตํ•ฉํ‚ค VO - LikeRepository: Repository ์ธํ„ฐํŽ˜์ด์Šค - LikeDomainService: ์ค‘๋ณต ์ข‹์•„์š” ๋ฐฉ์ง€, ๋ฉฑ๋“ฑ ์ทจ์†Œ ์ •์ฑ… Infrastructure Layer: - LikeJpaEntity: JPA ์—”ํ‹ฐํ‹ฐ (unique constraint) - LikeMapper: Domain โ†” JPA ๋ณ€ํ™˜ - LikeRepositoryImpl: Repository Adapter Test: - FakeLikeRepository: Map ๊ธฐ๋ฐ˜ in-memory ๊ตฌํ˜„ - LikeTest, LikeDomainServiceTest: ๋„๋ฉ”์ธ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ ์ •์ฑ…: - ์ค‘๋ณต ์ข‹์•„์š” ์‹œ CONFLICT ์˜ˆ์™ธ - ์ข‹์•„์š” ์ทจ์†Œ๋Š” ๋ฉฑ๋“ฑ (์—†์–ด๋„ ์˜ˆ์™ธ ์—†์Œ) Co-Authored-By: Claude Opus 4.5 --- .../java/com/loopers/domain/like/Like.java | 58 ++++++ .../domain/like/LikeDomainService.java | 69 +++++++ .../java/com/loopers/domain/like/LikeId.java | 15 ++ .../loopers/domain/like/LikeRepository.java | 25 +++ .../persistence/jpa/like/LikeJpaEntity.java | 71 +++++++ .../jpa/like/LikeJpaRepository.java | 26 +++ .../persistence/jpa/like/LikeMapper.java | 39 ++++ .../jpa/like/LikeRepositoryImpl.java | 69 +++++++ .../domain/like/LikeDomainServiceTest.java | 195 ++++++++++++++++++ .../com/loopers/domain/like/LikeTest.java | 67 ++++++ .../com/loopers/fake/FakeLikeRepository.java | 84 ++++++++ 11 files changed, 718 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeDomainService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeId.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeJpaEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeMapper.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeDomainServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/fake/FakeLikeRepository.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java new file mode 100644 index 000000000..0a64cd8c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,58 @@ +package com.loopers.domain.like; + +import java.time.ZonedDateTime; + +/** + * ์ข‹์•„์š” ๋„๋ฉ”์ธ ์—”ํ‹ฐํ‹ฐ. + * ์‚ฌ์šฉ์ž-์ƒํ’ˆ ๊ด€๊ณ„๋ฅผ ๋‚˜ํƒ€๋‚ธ๋‹ค. + * ์ˆœ์ˆ˜ Java ๊ฐ์ฒด๋กœ JPA/Spring ์˜์กด์„ฑ ์—†์Œ. + */ +public class Like { + + private Long id; + private Long userId; + private Long productId; + private ZonedDateTime createdAt; + + private Like() {} + + /** + * ์ƒˆ ์ข‹์•„์š” ์ƒ์„ฑ. + */ + public static Like create(Long userId, Long productId) { + Like like = new Like(); + like.userId = userId; + like.productId = productId; + like.createdAt = ZonedDateTime.now(); + return like; + } + + /** + * DB์—์„œ ๋ณต์› (Infrastructure์—์„œ ์‚ฌ์šฉ). + */ + public static Like reconstitute(Long id, Long userId, Long productId, ZonedDateTime createdAt) { + Like like = new Like(); + like.id = id; + like.userId = userId; + like.productId = productId; + like.createdAt = createdAt; + return like; + } + + // Getters + public Long getId() { + return id; + } + + public Long getUserId() { + return userId; + } + + public Long getProductId() { + return productId; + } + + public ZonedDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeDomainService.java new file mode 100644 index 000000000..6bac34be1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeDomainService.java @@ -0,0 +1,69 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * ์ข‹์•„์š” ๋„๋ฉ”์ธ ์„œ๋น„์Šค. + * ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ ์ •์ฑ…์„ ์บก์Аํ™”. + * ์ƒํƒœ ์—†์ด ์ž…๋ ฅ/์ถœ๋ ฅ์ด ๋ช…ํ™•ํ•œ "ํ•จ์ˆ˜์˜ ๊ฐ์ฒดํ™”". + */ +@Component +@RequiredArgsConstructor +public class LikeDomainService { + + private final LikeRepository likeRepository; + + /** + * ์ข‹์•„์š” ๋“ฑ๋ก. + * ์ค‘๋ณต ์ข‹์•„์š” ์‹œ CONFLICT ์˜ˆ์™ธ ๋ฐœ์ƒ. + * + * @param userId ์‚ฌ์šฉ์ž ID + * @param productId ์ƒํ’ˆ ID + * @return ์ƒ์„ฑ๋œ Like + * @throws CoreException ์ด๋ฏธ ์ข‹์•„์š”ํ•œ ๊ฒฝ์šฐ + */ + public Like like(Long userId, Long productId) { + if (likeRepository.exists(userId, productId)) { + throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค."); + } + return likeRepository.save(Like.create(userId, productId)); + } + + /** + * ์ข‹์•„์š” ์ทจ์†Œ. + * ๋ฉฑ๋“ฑํ•˜๊ฒŒ ๋™์ž‘ - ์กด์žฌํ•˜์ง€ ์•Š์•„๋„ ์˜ˆ์™ธ ์—†์ด ์ฒ˜๋ฆฌ. + * + * @param userId ์‚ฌ์šฉ์ž ID + * @param productId ์ƒํ’ˆ ID + */ + public void unlike(Long userId, Long productId) { + likeRepository.findByUserIdAndProductId(userId, productId) + .ifPresent(likeRepository::delete); + } + + /** + * ์ƒํ’ˆ์˜ ์ข‹์•„์š” ์ˆ˜ ์กฐํšŒ. + * + * @param productId ์ƒํ’ˆ ID + * @return ์ข‹์•„์š” ์ˆ˜ + */ + public long countByProductId(Long productId) { + return likeRepository.countByProductId(productId); + } + + /** + * ์—ฌ๋Ÿฌ ์ƒํ’ˆ์˜ ์ข‹์•„์š” ์ˆ˜ ์ผ๊ด„ ์กฐํšŒ. + * + * @param productIds ์ƒํ’ˆ ID ๋ชฉ๋ก + * @return ์ƒํ’ˆ ID โ†’ ์ข‹์•„์š” ์ˆ˜ Map + */ + public Map countByProductIds(List productIds) { + return likeRepository.countByProductIds(productIds); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeId.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeId.java new file mode 100644 index 000000000..663c9da88 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeId.java @@ -0,0 +1,15 @@ +package com.loopers.domain.like; + +import java.util.Objects; + +/** + * ์ข‹์•„์š” ๋ณตํ•ฉํ‚ค Value Object. + * userId์™€ productId์˜ ์กฐํ•ฉ์œผ๋กœ ์œ ์ผ์„ฑ์„ ๋ณด์žฅ. + */ +public record LikeId(Long userId, Long productId) { + + public LikeId { + Objects.requireNonNull(userId, "userId๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + Objects.requireNonNull(productId, "productId๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java new file mode 100644 index 000000000..ba1ca305d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,25 @@ +package com.loopers.domain.like; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * ์ข‹์•„์š” Repository ์ธํ„ฐํŽ˜์ด์Šค. + * ์ˆœ์ˆ˜ Java ์ธํ„ฐํŽ˜์ด์Šค๋กœ Spring/JPA ์˜์กด์„ฑ ์—†์Œ. + * ๊ตฌํ˜„์ฒด๋Š” Infrastructure Layer์— ์œ„์น˜. + */ +public interface LikeRepository { + + Like save(Like like); + + void delete(Like like); + + Optional findByUserIdAndProductId(Long userId, Long productId); + + boolean exists(Long userId, Long productId); + + long countByProductId(Long productId); + + Map countByProductIds(List productIds); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeJpaEntity.java new file mode 100644 index 000000000..f69a2ddf1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeJpaEntity.java @@ -0,0 +1,71 @@ +package com.loopers.infrastructure.persistence.jpa.like; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import java.time.ZonedDateTime; + +/** + * ์ข‹์•„์š” JPA ์—”ํ‹ฐํ‹ฐ. + * Infrastructure Layer์— ์œ„์น˜ํ•˜๋ฉฐ ์˜์†์„ฑ์„ ๋‹ด๋‹น. + */ +@Entity +@Table( + name = "likes", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "product_id"}) + }, + indexes = { + @Index(name = "idx_likes_product_id", columnList = "product_id") + } +) +public class LikeJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + protected LikeJpaEntity() {} + + public LikeJpaEntity(Long userId, Long productId) { + this.userId = userId; + this.productId = productId; + } + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } + + public Long getId() { + return id; + } + + public Long getUserId() { + return userId; + } + + public Long getProductId() { + return productId; + } + + public ZonedDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeJpaRepository.java new file mode 100644 index 000000000..5f5a05a42 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeJpaRepository.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.persistence.jpa.like; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +/** + * Like JPA Repository. + * Spring Data JPA๋ฅผ ์‚ฌ์šฉํ•œ ์˜์†์„ฑ ๊ณ„์ธต. + */ +public interface LikeJpaRepository extends JpaRepository { + + Optional findByUserIdAndProductId(Long userId, Long productId); + + boolean existsByUserIdAndProductId(Long userId, Long productId); + + long countByProductId(Long productId); + + @Query("SELECT l.productId, COUNT(l) FROM LikeJpaEntity l " + + "WHERE l.productId IN :productIds " + + "GROUP BY l.productId") + List countByProductIdIn(@Param("productIds") List productIds); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeMapper.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeMapper.java new file mode 100644 index 000000000..cc11e05f2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeMapper.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.persistence.jpa.like; + +import com.loopers.domain.like.Like; + +/** + * Like ๋„๋ฉ”์ธ ๊ฐ์ฒด์™€ JPA ์—”ํ‹ฐํ‹ฐ ๊ฐ„ ๋ณ€ํ™˜์„ ๋‹ด๋‹น. + */ +public class LikeMapper { + + private LikeMapper() {} + + /** + * JPA ์—”ํ‹ฐํ‹ฐ๋ฅผ ๋„๋ฉ”์ธ ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜. + */ + public static Like toDomain(LikeJpaEntity entity) { + if (entity == null) { + return null; + } + return Like.reconstitute( + entity.getId(), + entity.getUserId(), + entity.getProductId(), + entity.getCreatedAt() + ); + } + + /** + * ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ JPA ์—”ํ‹ฐํ‹ฐ๋กœ ๋ณ€ํ™˜ (์‹ ๊ทœ ์ €์žฅ์šฉ). + */ + public static LikeJpaEntity toJpaEntity(Like domain) { + if (domain == null) { + return null; + } + return new LikeJpaEntity( + domain.getUserId(), + domain.getProductId() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..48bc1ca35 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/like/LikeRepositoryImpl.java @@ -0,0 +1,69 @@ +package com.loopers.infrastructure.persistence.jpa.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * LikeRepository ๊ตฌํ˜„์ฒด. + * JPA๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ Like ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ์˜์†ํ™”. + * Domain โ†” JPA Entity ๋ณ€ํ™˜์€ LikeMapper๋ฅผ ํ†ตํ•ด ์ˆ˜ํ–‰. + */ +@Repository +@RequiredArgsConstructor +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository jpaRepository; + + @Override + public Like save(Like like) { + LikeJpaEntity entity = LikeMapper.toJpaEntity(like); + LikeJpaEntity saved = jpaRepository.save(entity); + return LikeMapper.toDomain(saved); + } + + @Override + public void delete(Like like) { + jpaRepository.deleteById(like.getId()); + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return jpaRepository.findByUserIdAndProductId(userId, productId) + .map(LikeMapper::toDomain); + } + + @Override + public boolean exists(Long userId, Long productId) { + return jpaRepository.existsByUserIdAndProductId(userId, productId); + } + + @Override + public long countByProductId(Long productId) { + return jpaRepository.countByProductId(productId); + } + + @Override + public Map countByProductIds(List productIds) { + if (productIds == null || productIds.isEmpty()) { + return Map.of(); + } + + List results = jpaRepository.countByProductIdIn(productIds); + Map countMap = new HashMap<>(); + + for (Object[] row : results) { + Long productId = (Long) row[0]; + Long count = (Long) row[1]; + countMap.put(productId, count); + } + + return countMap; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeDomainServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeDomainServiceTest.java new file mode 100644 index 000000000..ef3ea5810 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeDomainServiceTest.java @@ -0,0 +1,195 @@ +package com.loopers.domain.like; + +import com.loopers.fake.FakeLikeRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LikeDomainServiceTest { + + private FakeLikeRepository fakeRepository; + private LikeDomainService service; + + @BeforeEach + void setUp() { + fakeRepository = new FakeLikeRepository(); + service = new LikeDomainService(fakeRepository); + } + + @DisplayName("์ข‹์•„์š”๋ฅผ ๋“ฑ๋กํ•  ๋•Œ,") + @Nested + class LikeMethod { + + @DisplayName("์ƒˆ๋กœ์šด ์ข‹์•„์š”๋ฉด, ์ •์ƒ์ ์œผ๋กœ ๋“ฑ๋ก๋œ๋‹ค.") + @Test + void registersLike_whenLikeIsNew() { + // arrange + Long userId = 1L; + Long productId = 100L; + + // act + Like like = service.like(userId, productId); + + // assert + assertThat(like.getUserId()).isEqualTo(userId); + assertThat(like.getProductId()).isEqualTo(productId); + assertThat(like.getId()).isNotNull(); + assertThat(fakeRepository.exists(userId, productId)).isTrue(); + } + + @DisplayName("์ด๋ฏธ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ์ด๋ฉด, CONFLICT ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsConflictException_whenLikeAlreadyExists() { + // arrange + Long userId = 1L; + Long productId = 100L; + service.like(userId, productId); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + service.like(userId, productId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + + @DisplayName("๋‹ค๋ฅธ ์‚ฌ์šฉ์ž๊ฐ€ ๊ฐ™์€ ์ƒํ’ˆ์— ์ข‹์•„์š”ํ•˜๋ฉด, ์ •์ƒ์ ์œผ๋กœ ๋“ฑ๋ก๋œ๋‹ค.") + @Test + void registersLike_whenDifferentUserLikesSameProduct() { + // arrange + service.like(1L, 100L); + + // act + Like like = service.like(2L, 100L); + + // assert + assertThat(like.getUserId()).isEqualTo(2L); + assertThat(fakeRepository.exists(2L, 100L)).isTrue(); + } + + @DisplayName("๊ฐ™์€ ์‚ฌ์šฉ์ž๊ฐ€ ๋‹ค๋ฅธ ์ƒํ’ˆ์— ์ข‹์•„์š”ํ•˜๋ฉด, ์ •์ƒ์ ์œผ๋กœ ๋“ฑ๋ก๋œ๋‹ค.") + @Test + void registersLike_whenSameUserLikesDifferentProduct() { + // arrange + service.like(1L, 100L); + + // act + Like like = service.like(1L, 200L); + + // assert + assertThat(like.getProductId()).isEqualTo(200L); + assertThat(fakeRepository.exists(1L, 200L)).isTrue(); + } + } + + @DisplayName("์ข‹์•„์š”๋ฅผ ์ทจ์†Œํ•  ๋•Œ,") + @Nested + class Unlike { + + @DisplayName("์กด์žฌํ•˜๋Š” ์ข‹์•„์š”๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์‚ญ์ œ๋œ๋‹ค.") + @Test + void deletesLike_whenLikeExists() { + // arrange + Long userId = 1L; + Long productId = 100L; + service.like(userId, productId); + assertThat(fakeRepository.exists(userId, productId)).isTrue(); + + // act + service.unlike(userId, productId); + + // assert + assertThat(fakeRepository.exists(userId, productId)).isFalse(); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ข‹์•„์š”๋ฅผ ์ทจ์†Œํ•ด๋„ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜์ง€ ์•Š๋Š”๋‹ค. (๋ฉฑ๋“ฑ)") + @Test + void doesNotThrowException_whenLikeDoesNotExist() { + // arrange + Long userId = 1L; + Long productId = 100L; + + // act & assert + assertDoesNotThrow(() -> service.unlike(userId, productId)); + } + + @DisplayName("์—ฌ๋Ÿฌ ๋ฒˆ ์ทจ์†Œํ•ด๋„ ๋ฉฑ๋“ฑํ•˜๊ฒŒ ๋™์ž‘ํ•œ๋‹ค.") + @Test + void isIdempotent_whenUnlikingMultipleTimes() { + // arrange + service.like(1L, 100L); + + // act + service.unlike(1L, 100L); + service.unlike(1L, 100L); + service.unlike(1L, 100L); + + // assert + assertThat(fakeRepository.exists(1L, 100L)).isFalse(); + } + } + + @DisplayName("์ข‹์•„์š” ์ˆ˜๋ฅผ ์กฐํšŒํ•  ๋•Œ,") + @Nested + class CountByProductId { + + @DisplayName("์ƒํ’ˆ์— ์ข‹์•„์š”๊ฐ€ ์žˆ์œผ๋ฉด, ๊ฐœ์ˆ˜๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsCount_whenLikesExist() { + // arrange + Long productId = 100L; + service.like(1L, productId); + service.like(2L, productId); + service.like(3L, productId); + + // act + long count = service.countByProductId(productId); + + // assert + assertThat(count).isEqualTo(3); + } + + @DisplayName("์ƒํ’ˆ์— ์ข‹์•„์š”๊ฐ€ ์—†์œผ๋ฉด, 0์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsZero_whenNoLikesExist() { + // act + long count = service.countByProductId(100L); + + // assert + assertThat(count).isEqualTo(0); + } + } + + @DisplayName("์—ฌ๋Ÿฌ ์ƒํ’ˆ์˜ ์ข‹์•„์š” ์ˆ˜๋ฅผ ์กฐํšŒํ•  ๋•Œ,") + @Nested + class CountByProductIds { + + @DisplayName("๊ฐ ์ƒํ’ˆ๋ณ„ ์ข‹์•„์š” ์ˆ˜๊ฐ€ Map์œผ๋กœ ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnsCountMap_forEachProduct() { + // arrange + service.like(1L, 100L); + service.like(2L, 100L); + service.like(1L, 200L); + + // act + Map counts = service.countByProductIds(List.of(100L, 200L, 300L)); + + // assert + assertThat(counts.get(100L)).isEqualTo(2); + assertThat(counts.get(200L)).isEqualTo(1); + assertThat(counts.get(300L)).isNull(); // ์ข‹์•„์š” ์—†๋Š” ์ƒํ’ˆ + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java new file mode 100644 index 000000000..c8e3538de --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java @@ -0,0 +1,67 @@ +package com.loopers.domain.like; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class LikeTest { + + @DisplayName("Like๋ฅผ ์ƒ์„ฑํ•  ๋•Œ,") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๋กœ ์ƒ์„ฑํ•˜๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsLike_whenInfoIsValid() { + // arrange + Long userId = 1L; + Long productId = 100L; + + // act + Like like = Like.create(userId, productId); + + // assert + assertThat(like.getUserId()).isEqualTo(userId); + assertThat(like.getProductId()).isEqualTo(productId); + assertThat(like.getCreatedAt()).isNotNull(); + } + + @DisplayName("ID๋Š” null๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsWithNullId() { + // act + Like like = Like.create(1L, 100L); + + // assert + assertThat(like.getId()).isNull(); + } + } + + @DisplayName("Like๋ฅผ ๋ณต์›ํ•  ๋•Œ,") + @Nested + class Reconstitute { + + @DisplayName("๋ชจ๋“  ํ•„๋“œ๊ฐ€ ๋ณต์›๋œ๋‹ค.") + @Test + void reconstitutesAllFields() { + // arrange + Long id = 1L; + Long userId = 10L; + Long productId = 100L; + ZonedDateTime createdAt = ZonedDateTime.now().minusDays(1); + + // act + Like like = Like.reconstitute(id, userId, productId, createdAt); + + // assert + assertThat(like.getId()).isEqualTo(id); + assertThat(like.getUserId()).isEqualTo(userId); + assertThat(like.getProductId()).isEqualTo(productId); + assertThat(like.getCreatedAt()).isEqualTo(createdAt); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeLikeRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeLikeRepository.java new file mode 100644 index 000000000..8f5eeb4d5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeLikeRepository.java @@ -0,0 +1,84 @@ +package com.loopers.fake; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +/** + * ํ…Œ์ŠคํŠธ์šฉ Fake LikeRepository. + * Map ๊ธฐ๋ฐ˜ in-memory ๊ตฌํ˜„. + */ +public class FakeLikeRepository implements LikeRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public Like save(Like like) { + Long id = idGenerator.getAndIncrement(); + Like saved = Like.reconstitute( + id, + like.getUserId(), + like.getProductId(), + like.getCreatedAt() + ); + store.put(id, saved); + return saved; + } + + @Override + public void delete(Like like) { + store.remove(like.getId()); + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return store.values().stream() + .filter(l -> l.getUserId().equals(userId) && l.getProductId().equals(productId)) + .findFirst(); + } + + @Override + public boolean exists(Long userId, Long productId) { + return store.values().stream() + .anyMatch(l -> l.getUserId().equals(userId) && l.getProductId().equals(productId)); + } + + @Override + public long countByProductId(Long productId) { + return store.values().stream() + .filter(l -> l.getProductId().equals(productId)) + .count(); + } + + @Override + public Map countByProductIds(List productIds) { + return store.values().stream() + .filter(l -> productIds.contains(l.getProductId())) + .collect(Collectors.groupingBy( + Like::getProductId, + Collectors.counting() + )); + } + + /** + * ํ…Œ์ŠคํŠธ์šฉ: ์ €์žฅ์†Œ ์ดˆ๊ธฐํ™” + */ + public void clear() { + store.clear(); + idGenerator.set(1); + } + + /** + * ํ…Œ์ŠคํŠธ์šฉ: ์ €์žฅ๋œ ์ข‹์•„์š” ์ˆ˜ ์กฐํšŒ + */ + public int size() { + return store.size(); + } +} From 1a2f907c7d4f6e78a943d98b7d8d748c7c82c778 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 27 Feb 2026 13:54:52 +0900 Subject: [PATCH 19/29] =?UTF-8?q?feat(domain):=20Order=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EA=B5=AC=ED=98=84=20(Aggregate=20Root)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Domain Layer (์ˆœ์ˆ˜ Java): - Order: ์ฃผ๋ฌธ Aggregate Root, ์ด์•ก ์ž๋™ ๊ณ„์‚ฐ - OrderItem: ์ฃผ๋ฌธ ํ•ญ๋ชฉ, ๊ฐ€๊ฒฉ ์Šค๋ƒ…์ƒท ๋ณด๊ด€ - OrderStatus: ์ฃผ๋ฌธ ์ƒํƒœ Enum - OrderRepository: Repository ์ธํ„ฐํŽ˜์ด์Šค Infrastructure Layer: - OrderJpaEntity: JPA ์—”ํ‹ฐํ‹ฐ (CascadeType.ALL) - OrderItemJpaEntity: JPA ์—”ํ‹ฐํ‹ฐ - OrderMapper: Domain โ†” JPA ๋ณ€ํ™˜ - OrderRepositoryImpl: Repository Adapter Test: - FakeOrderRepository: Map ๊ธฐ๋ฐ˜ in-memory ๊ตฌํ˜„ - OrderTest, OrderItemTest: ๋„๋ฉ”์ธ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ ๋ถˆ๋ณ€์‹: - ์ฃผ๋ฌธ ํ•ญ๋ชฉ์€ 1๊ฐœ ์ด์ƒ - ์ด์•ก์€ OrderItem ํ•ฉ์‚ฐ์œผ๋กœ ๊ณ„์‚ฐ - OrderItem์€ ๋ถˆ๋ณ€ ๋ฆฌ์ŠคํŠธ๋กœ ๋ณดํ˜ธ Co-Authored-By: Claude Opus 4.5 --- .../java/com/loopers/domain/order/Order.java | 92 +++++++++++ .../com/loopers/domain/order/OrderItem.java | 88 +++++++++++ .../loopers/domain/order/OrderRepository.java | 22 +++ .../com/loopers/domain/order/OrderStatus.java | 31 ++++ .../jpa/order/OrderItemJpaEntity.java | 79 ++++++++++ .../persistence/jpa/order/OrderJpaEntity.java | 95 ++++++++++++ .../jpa/order/OrderJpaRepository.java | 26 ++++ .../persistence/jpa/order/OrderMapper.java | 61 ++++++++ .../jpa/order/OrderRepositoryImpl.java | 57 +++++++ .../loopers/domain/order/OrderItemTest.java | 129 ++++++++++++++++ .../com/loopers/domain/order/OrderTest.java | 144 ++++++++++++++++++ .../com/loopers/fake/FakeOrderRepository.java | 96 ++++++++++++ 12 files changed, 920 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderItemJpaEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderJpaEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderMapper.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/fake/FakeOrderRepository.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java new file mode 100644 index 000000000..bf6eb968e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,92 @@ +package com.loopers.domain.order; + +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * ์ฃผ๋ฌธ ๋„๋ฉ”์ธ ์—”ํ‹ฐํ‹ฐ (Aggregate Root). + * ์ˆœ์ˆ˜ Java ๊ฐ์ฒด๋กœ JPA/Spring ์˜์กด์„ฑ ์—†์Œ. + */ +public class Order { + + private Long id; + private Long userId; + private List items; + private Money totalPrice; + private OrderStatus status; + private ZonedDateTime createdAt; + + private Order() {} + + /** + * ์ƒˆ ์ฃผ๋ฌธ ์ƒ์„ฑ. + * + * @param userId ์‚ฌ์šฉ์ž ID + * @param items ์ฃผ๋ฌธ ํ•ญ๋ชฉ ๋ชฉ๋ก (1๊ฐœ ์ด์ƒ) + * @return ์ƒ์„ฑ๋œ Order + * @throws CoreException ์ฃผ๋ฌธ ํ•ญ๋ชฉ์ด ๋น„์–ด์žˆ๋Š” ๊ฒฝ์šฐ + */ + public static Order create(Long userId, List items) { + if (items == null || items.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ ํ•ญ๋ชฉ์ด ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค."); + } + + Money total = items.stream() + .map(OrderItem::getSubtotal) + .reduce(Money.ZERO, Money::add); + + Order order = new Order(); + order.userId = userId; + order.items = new ArrayList<>(items); + order.totalPrice = total; + order.status = OrderStatus.CREATED; + order.createdAt = ZonedDateTime.now(); + return order; + } + + /** + * DB์—์„œ ๋ณต์› (Infrastructure์—์„œ ์‚ฌ์šฉ). + */ + public static Order reconstitute(Long id, Long userId, List items, + Money totalPrice, OrderStatus status, ZonedDateTime createdAt) { + Order order = new Order(); + order.id = id; + order.userId = userId; + order.items = new ArrayList<>(items); + order.totalPrice = totalPrice; + order.status = status; + order.createdAt = createdAt; + return order; + } + + // Getters + public Long getId() { + return id; + } + + public Long getUserId() { + return userId; + } + + public List getItems() { + return Collections.unmodifiableList(items); + } + + public Money getTotalPrice() { + return totalPrice; + } + + public OrderStatus getStatus() { + return status; + } + + public ZonedDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java new file mode 100644 index 000000000..dfd93958b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,88 @@ +package com.loopers.domain.order; + +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +/** + * ์ฃผ๋ฌธ ํ•ญ๋ชฉ ๋„๋ฉ”์ธ ์—”ํ‹ฐํ‹ฐ. + * ์ฃผ๋ฌธ ์‹œ์ ์˜ ์ƒํ’ˆ ์ •๋ณด ์Šค๋ƒ…์ƒท์„ ํฌํ•จ. + * ์ˆœ์ˆ˜ Java ๊ฐ์ฒด๋กœ JPA/Spring ์˜์กด์„ฑ ์—†์Œ. + */ +public class OrderItem { + + private Long id; + private Long productId; + private String productName; // ์Šค๋ƒ…์ƒท + private int quantity; + private Money priceSnapshot; // ์ฃผ๋ฌธ ์‹œ์  ๋‹จ๊ฐ€ + + private OrderItem() {} + + /** + * ์ƒˆ ์ฃผ๋ฌธ ํ•ญ๋ชฉ ์ƒ์„ฑ. + * + * @param productId ์ƒํ’ˆ ID + * @param productName ์ƒํ’ˆ๋ช… (์Šค๋ƒ…์ƒท) + * @param quantity ์ˆ˜๋Ÿ‰ (1 ์ด์ƒ) + * @param price ๋‹จ๊ฐ€ (์Šค๋ƒ…์ƒท) + * @return ์ƒ์„ฑ๋œ OrderItem + * @throws CoreException ์ˆ˜๋Ÿ‰์ด 0 ์ดํ•˜์ธ ๊ฒฝ์šฐ + */ + public static OrderItem create(Long productId, String productName, int quantity, Money price) { + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ˆ˜๋Ÿ‰์€ 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + + OrderItem item = new OrderItem(); + item.productId = productId; + item.productName = productName; + item.quantity = quantity; + item.priceSnapshot = price; + return item; + } + + /** + * DB์—์„œ ๋ณต์› (Infrastructure์—์„œ ์‚ฌ์šฉ). + */ + public static OrderItem reconstitute(Long id, Long productId, String productName, + int quantity, Money priceSnapshot) { + OrderItem item = new OrderItem(); + item.id = id; + item.productId = productId; + item.productName = productName; + item.quantity = quantity; + item.priceSnapshot = priceSnapshot; + return item; + } + + /** + * ์†Œ๊ณ„ ๊ณ„์‚ฐ (๋‹จ๊ฐ€ ร— ์ˆ˜๋Ÿ‰). + * + * @return ์†Œ๊ณ„ ๊ธˆ์•ก + */ + public Money getSubtotal() { + return priceSnapshot.multiply(quantity); + } + + // Getters + public Long getId() { + return id; + } + + public Long getProductId() { + return productId; + } + + public String getProductName() { + return productName; + } + + public int getQuantity() { + return quantity; + } + + public Money getPriceSnapshot() { + return priceSnapshot; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java new file mode 100644 index 000000000..0e1f3fba6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,22 @@ +package com.loopers.domain.order; + +import java.util.List; +import java.util.Optional; + +/** + * ์ฃผ๋ฌธ Repository ์ธํ„ฐํŽ˜์ด์Šค. + * ์ˆœ์ˆ˜ Java ์ธํ„ฐํŽ˜์ด์Šค๋กœ Spring/JPA ์˜์กด์„ฑ ์—†์Œ. + * ๊ตฌํ˜„์ฒด๋Š” Infrastructure Layer์— ์œ„์น˜. + */ +public interface OrderRepository { + + Order save(Order order); + + Optional findById(Long id); + + Optional findByIdAndUserId(Long id, Long userId); + + List findAllByUserId(Long userId, int offset, int limit); + + long countByUserId(Long userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java new file mode 100644 index 000000000..777a36c86 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,31 @@ +package com.loopers.domain.order; + +/** + * ์ฃผ๋ฌธ ์ƒํƒœ. + */ +public enum OrderStatus { + /** + * ์ฃผ๋ฌธ ์ƒ์„ฑ๋จ + */ + CREATED, + + /** + * ๊ฒฐ์ œ ์™„๋ฃŒ + */ + PAID, + + /** + * ๋ฐฐ์†ก์ค‘ + */ + SHIPPED, + + /** + * ๋ฐฐ์†ก ์™„๋ฃŒ + */ + DELIVERED, + + /** + * ์ทจ์†Œ๋จ + */ + CANCELLED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderItemJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderItemJpaEntity.java new file mode 100644 index 000000000..9589df92b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderItemJpaEntity.java @@ -0,0 +1,79 @@ +package com.loopers.infrastructure.persistence.jpa.order; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +/** + * ์ฃผ๋ฌธ ํ•ญ๋ชฉ JPA ์—”ํ‹ฐํ‹ฐ. + * Infrastructure Layer์— ์œ„์น˜ํ•˜๋ฉฐ ์˜์†์„ฑ์„ ๋‹ด๋‹น. + */ +@Entity +@Table(name = "order_items") +public class OrderItemJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", nullable = false) + private OrderJpaEntity order; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "product_name", nullable = false, length = 200) + private String productName; + + @Column(name = "quantity", nullable = false) + private Integer quantity; + + @Column(name = "price_snapshot", nullable = false) + private Long priceSnapshot; + + protected OrderItemJpaEntity() {} + + public OrderItemJpaEntity(OrderJpaEntity order, Long productId, String productName, + Integer quantity, Long priceSnapshot) { + this.order = order; + this.productId = productId; + this.productName = productName; + this.quantity = quantity; + this.priceSnapshot = priceSnapshot; + } + + public Long getId() { + return id; + } + + public OrderJpaEntity getOrder() { + return order; + } + + public Long getProductId() { + return productId; + } + + public String getProductName() { + return productName; + } + + public Integer getQuantity() { + return quantity; + } + + public Long getPriceSnapshot() { + return priceSnapshot; + } + + void setOrder(OrderJpaEntity order) { + this.order = order; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderJpaEntity.java new file mode 100644 index 000000000..85c896859 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderJpaEntity.java @@ -0,0 +1,95 @@ +package com.loopers.infrastructure.persistence.jpa.order; + +import com.loopers.domain.order.OrderStatus; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.OneToMany; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * ์ฃผ๋ฌธ JPA ์—”ํ‹ฐํ‹ฐ. + * Infrastructure Layer์— ์œ„์น˜ํ•˜๋ฉฐ ์˜์†์„ฑ์„ ๋‹ด๋‹น. + */ +@Entity +@Table( + name = "orders", + indexes = { + @Index(name = "idx_orders_user_id", columnList = "user_id") + } +) +public class OrderJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) + private List items = new ArrayList<>(); + + @Column(name = "total_price", nullable = false) + private Long totalPrice; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private OrderStatus status; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + protected OrderJpaEntity() {} + + public OrderJpaEntity(Long userId, Long totalPrice, OrderStatus status) { + this.userId = userId; + this.totalPrice = totalPrice; + this.status = status; + } + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } + + public void addItem(OrderItemJpaEntity item) { + items.add(item); + item.setOrder(this); + } + + public Long getId() { + return id; + } + + public Long getUserId() { + return userId; + } + + public List getItems() { + return items; + } + + public Long getTotalPrice() { + return totalPrice; + } + + public OrderStatus getStatus() { + return status; + } + + public ZonedDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderJpaRepository.java new file mode 100644 index 000000000..246f10815 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderJpaRepository.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.persistence.jpa.order; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +/** + * Order JPA Repository. + * Spring Data JPA ์ธํ„ฐํŽ˜์ด์Šค. + */ +public interface OrderJpaRepository extends JpaRepository { + + @Query("SELECT o FROM OrderJpaEntity o LEFT JOIN FETCH o.items WHERE o.id = :id") + Optional findByIdWithItems(@Param("id") Long id); + + @Query("SELECT o FROM OrderJpaEntity o LEFT JOIN FETCH o.items WHERE o.id = :id AND o.userId = :userId") + Optional findByIdAndUserIdWithItems(@Param("id") Long id, @Param("userId") Long userId); + + Page findAllByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); + + long countByUserId(Long userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderMapper.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderMapper.java new file mode 100644 index 000000000..856921b00 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderMapper.java @@ -0,0 +1,61 @@ +package com.loopers.infrastructure.persistence.jpa.order; + +import com.loopers.domain.common.Money; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; + +import java.util.List; + +/** + * Order Domain โ†” JPA Entity ๋ณ€ํ™˜ Mapper. + */ +public class OrderMapper { + + private OrderMapper() {} + + public static Order toDomain(OrderJpaEntity entity) { + List items = entity.getItems().stream() + .map(OrderMapper::toOrderItemDomain) + .toList(); + + return Order.reconstitute( + entity.getId(), + entity.getUserId(), + items, + new Money(entity.getTotalPrice()), + entity.getStatus(), + entity.getCreatedAt() + ); + } + + public static OrderItem toOrderItemDomain(OrderItemJpaEntity entity) { + return OrderItem.reconstitute( + entity.getId(), + entity.getProductId(), + entity.getProductName(), + entity.getQuantity(), + new Money(entity.getPriceSnapshot()) + ); + } + + public static OrderJpaEntity toJpaEntity(Order domain) { + OrderJpaEntity entity = new OrderJpaEntity( + domain.getUserId(), + domain.getTotalPrice().amount(), + domain.getStatus() + ); + + for (OrderItem item : domain.getItems()) { + OrderItemJpaEntity itemEntity = new OrderItemJpaEntity( + entity, + item.getProductId(), + item.getProductName(), + item.getQuantity(), + item.getPriceSnapshot().amount() + ); + entity.addItem(itemEntity); + } + + return entity; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..d7f57df51 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderRepositoryImpl.java @@ -0,0 +1,57 @@ +package com.loopers.infrastructure.persistence.jpa.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * OrderRepository ๊ตฌํ˜„์ฒด (Adapter). + * Domain Repository ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ JPA๋กœ ๊ตฌํ˜„. + */ +@Repository +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository jpaRepository; + + public OrderRepositoryImpl(OrderJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + @Override + public Order save(Order order) { + OrderJpaEntity entity = OrderMapper.toJpaEntity(order); + OrderJpaEntity saved = jpaRepository.save(entity); + return OrderMapper.toDomain(saved); + } + + @Override + public Optional findById(Long id) { + return jpaRepository.findByIdWithItems(id) + .map(OrderMapper::toDomain); + } + + @Override + public Optional findByIdAndUserId(Long id, Long userId) { + return jpaRepository.findByIdAndUserIdWithItems(id, userId) + .map(OrderMapper::toDomain); + } + + @Override + public List findAllByUserId(Long userId, int offset, int limit) { + int page = offset / limit; + return jpaRepository.findAllByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(page, limit)) + .getContent() + .stream() + .map(OrderMapper::toDomain) + .toList(); + } + + @Override + public long countByUserId(Long userId) { + return jpaRepository.countByUserId(userId); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java new file mode 100644 index 000000000..3cef4b88d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java @@ -0,0 +1,129 @@ +package com.loopers.domain.order; + +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OrderItemTest { + + @DisplayName("OrderItem์„ ์ƒ์„ฑํ•  ๋•Œ,") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๋กœ ์ƒ์„ฑํ•˜๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsOrderItem_whenInfoIsValid() { + // arrange + Long productId = 100L; + String productName = "Air Max 90"; + int quantity = 2; + Money price = new Money(150000); + + // act + OrderItem item = OrderItem.create(productId, productName, quantity, price); + + // assert + assertThat(item.getProductId()).isEqualTo(productId); + assertThat(item.getProductName()).isEqualTo(productName); + assertThat(item.getQuantity()).isEqualTo(quantity); + assertThat(item.getPriceSnapshot()).isEqualTo(price); + } + + @DisplayName("ID๋Š” null๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsWithNullId() { + // act + OrderItem item = OrderItem.create(100L, "์ƒํ’ˆ", 1, new Money(10000)); + + // assert + assertThat(item.getId()).isNull(); + } + + @DisplayName("์ˆ˜๋Ÿ‰์ด 0์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenQuantityIsZero() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + OrderItem.create(100L, "์ƒํ’ˆ", 0, new Money(10000)); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("์ˆ˜๋Ÿ‰์ด ์Œ์ˆ˜์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenQuantityIsNegative() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + OrderItem.create(100L, "์ƒํ’ˆ", -1, new Money(10000)); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("์†Œ๊ณ„(subtotal)๋ฅผ ๊ณ„์‚ฐํ•  ๋•Œ,") + @Nested + class GetSubtotal { + + @DisplayName("๋‹จ๊ฐ€ ร— ์ˆ˜๋Ÿ‰์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnsMultipliedPrice() { + // arrange + OrderItem item = OrderItem.create(100L, "์ƒํ’ˆ", 3, new Money(10000)); + + // act + Money subtotal = item.getSubtotal(); + + // assert + assertThat(subtotal.amount()).isEqualTo(30000); + } + + @DisplayName("์ˆ˜๋Ÿ‰์ด 1์ด๋ฉด, ๋‹จ๊ฐ€๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnsPriceWhenQuantityIsOne() { + // arrange + OrderItem item = OrderItem.create(100L, "์ƒํ’ˆ", 1, new Money(10000)); + + // act + Money subtotal = item.getSubtotal(); + + // assert + assertThat(subtotal.amount()).isEqualTo(10000); + } + } + + @DisplayName("OrderItem์„ ๋ณต์›ํ•  ๋•Œ,") + @Nested + class Reconstitute { + + @DisplayName("๋ชจ๋“  ํ•„๋“œ๊ฐ€ ๋ณต์›๋œ๋‹ค.") + @Test + void reconstitutesAllFields() { + // arrange + Long id = 1L; + Long productId = 100L; + String productName = "Air Max 90"; + int quantity = 2; + Money priceSnapshot = new Money(150000); + + // act + OrderItem item = OrderItem.reconstitute(id, productId, productName, quantity, priceSnapshot); + + // assert + assertThat(item.getId()).isEqualTo(id); + assertThat(item.getProductId()).isEqualTo(productId); + assertThat(item.getProductName()).isEqualTo(productName); + assertThat(item.getQuantity()).isEqualTo(quantity); + assertThat(item.getPriceSnapshot()).isEqualTo(priceSnapshot); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java new file mode 100644 index 000000000..b6918a5b5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,144 @@ +package com.loopers.domain.order; + +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OrderTest { + + @DisplayName("Order๋ฅผ ์ƒ์„ฑํ•  ๋•Œ,") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๋กœ ์ƒ์„ฑํ•˜๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsOrder_whenInfoIsValid() { + // arrange + Long userId = 1L; + List items = List.of( + OrderItem.create(100L, "์ƒํ’ˆ1", 2, new Money(10000)), + OrderItem.create(200L, "์ƒํ’ˆ2", 1, new Money(20000)) + ); + + // act + Order order = Order.create(userId, items); + + // assert + assertThat(order.getUserId()).isEqualTo(userId); + assertThat(order.getItems()).hasSize(2); + assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED); + assertThat(order.getCreatedAt()).isNotNull(); + } + + @DisplayName("ID๋Š” null๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsWithNullId() { + // arrange + List items = List.of( + OrderItem.create(100L, "์ƒํ’ˆ", 1, new Money(10000)) + ); + + // act + Order order = Order.create(1L, items); + + // assert + assertThat(order.getId()).isNull(); + } + + @DisplayName("์ด์•ก์ด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๊ณ„์‚ฐ๋œ๋‹ค.") + @Test + void calculatesTotalPriceCorrectly() { + // arrange + List items = List.of( + OrderItem.create(100L, "์ƒํ’ˆ1", 2, new Money(10000)), // 20000 + OrderItem.create(200L, "์ƒํ’ˆ2", 1, new Money(20000)), // 20000 + OrderItem.create(300L, "์ƒํ’ˆ3", 3, new Money(5000)) // 15000 + ); + + // act + Order order = Order.create(1L, items); + + // assert + assertThat(order.getTotalPrice().amount()).isEqualTo(55000); + } + + @DisplayName("์ฃผ๋ฌธ ํ•ญ๋ชฉ์ด null์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenItemsIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + Order.create(1L, null); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("์ฃผ๋ฌธ ํ•ญ๋ชฉ์ด ๋น„์–ด์žˆ์œผ๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenItemsIsEmpty() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + Order.create(1L, Collections.emptyList()); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("์ฃผ๋ฌธ ํ•ญ๋ชฉ์ด ๋ถˆ๋ณ€ ๋ฆฌ์ŠคํŠธ๋กœ ๋ณดํ˜ธ๋œ๋‹ค.") + @Test + void itemsAreImmutable() { + // arrange + List items = List.of( + OrderItem.create(100L, "์ƒํ’ˆ", 1, new Money(10000)) + ); + Order order = Order.create(1L, items); + + // act & assert + assertThrows(UnsupportedOperationException.class, () -> { + order.getItems().add(OrderItem.create(200L, "์ถ”๊ฐ€์ƒํ’ˆ", 1, new Money(20000))); + }); + } + } + + @DisplayName("Order๋ฅผ ๋ณต์›ํ•  ๋•Œ,") + @Nested + class Reconstitute { + + @DisplayName("๋ชจ๋“  ํ•„๋“œ๊ฐ€ ๋ณต์›๋œ๋‹ค.") + @Test + void reconstitutesAllFields() { + // arrange + Long id = 1L; + Long userId = 10L; + List items = List.of( + OrderItem.reconstitute(1L, 100L, "์ƒํ’ˆ", 2, new Money(10000)) + ); + Money totalPrice = new Money(20000); + OrderStatus status = OrderStatus.PAID; + ZonedDateTime createdAt = ZonedDateTime.now().minusDays(1); + + // act + Order order = Order.reconstitute(id, userId, items, totalPrice, status, createdAt); + + // assert + assertThat(order.getId()).isEqualTo(id); + assertThat(order.getUserId()).isEqualTo(userId); + assertThat(order.getItems()).hasSize(1); + assertThat(order.getTotalPrice()).isEqualTo(totalPrice); + assertThat(order.getStatus()).isEqualTo(status); + assertThat(order.getCreatedAt()).isEqualTo(createdAt); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeOrderRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeOrderRepository.java new file mode 100644 index 000000000..fe7ed0b2f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeOrderRepository.java @@ -0,0 +1,96 @@ +package com.loopers.fake; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderRepository; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +/** + * ํ…Œ์ŠคํŠธ์šฉ Fake OrderRepository. + * Map ๊ธฐ๋ฐ˜ in-memory ๊ตฌํ˜„. + */ +public class FakeOrderRepository implements OrderRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + private final AtomicLong itemIdGenerator = new AtomicLong(1); + + @Override + public Order save(Order order) { + Long orderId = order.getId(); + if (orderId == null) { + orderId = idGenerator.getAndIncrement(); + // OrderItem์—๋„ ID ๋ถ€์—ฌ + List itemsWithIds = order.getItems().stream() + .map(item -> OrderItem.reconstitute( + itemIdGenerator.getAndIncrement(), + item.getProductId(), + item.getProductName(), + item.getQuantity(), + item.getPriceSnapshot() + )) + .toList(); + + order = Order.reconstitute( + orderId, + order.getUserId(), + itemsWithIds, + order.getTotalPrice(), + order.getStatus(), + order.getCreatedAt() + ); + } + store.put(orderId, order); + return order; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public Optional findByIdAndUserId(Long id, Long userId) { + return findById(id) + .filter(order -> order.getUserId().equals(userId)); + } + + @Override + public List findAllByUserId(Long userId, int offset, int limit) { + return store.values().stream() + .filter(order -> order.getUserId().equals(userId)) + .sorted(Comparator.comparing(Order::getCreatedAt).reversed()) + .skip(offset) + .limit(limit) + .toList(); + } + + @Override + public long countByUserId(Long userId) { + return store.values().stream() + .filter(order -> order.getUserId().equals(userId)) + .count(); + } + + /** + * ํ…Œ์ŠคํŠธ์šฉ: ์ €์žฅ์†Œ ์ดˆ๊ธฐํ™” + */ + public void clear() { + store.clear(); + idGenerator.set(1); + itemIdGenerator.set(1); + } + + /** + * ํ…Œ์ŠคํŠธ์šฉ: ์ €์žฅ๋œ ์ฃผ๋ฌธ ์ˆ˜ ์กฐํšŒ + */ + public int size() { + return store.size(); + } +} From 0e38bc1e8f6c1507c443daccb2410f1551ed20be Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 27 Feb 2026 13:55:13 +0900 Subject: [PATCH 20/29] =?UTF-8?q?feat(application):=20Application=20Servic?= =?UTF-8?q?es=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brand: - BrandService: ๋ธŒ๋žœ๋“œ CRUD ์œ ์Šค์ผ€์ด์Šค - BrandResult: ์‘๋‹ต DTO Product: - ProductService: ์ƒํ’ˆ CRUD, ๋ชฉ๋ก ์กฐํšŒ - ProductResult: ์‘๋‹ต DTO Like: - LikeApplicationService: ์ƒํ’ˆ ๊ฒ€์ฆ + ์ข‹์•„์š”/์ทจ์†Œ - LikeResult: ์‘๋‹ต DTO Order: - OrderApplicationService: ์žฌ๊ณ  ์ฐจ๊ฐ + ์ฃผ๋ฌธ ์ƒ์„ฑ - OrderResult, OrderItemResult: ์‘๋‹ต DTO - OrderItemRequest: ์š”์ฒญ DTO Test: - BrandServiceIntegrationTest: ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ - LikeApplicationServiceTest: Fake ๊ธฐ๋ฐ˜ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ - OrderApplicationServiceTest: Fake ๊ธฐ๋ฐ˜ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ ํŠธ๋žœ์žญ์…˜: - ์ฃผ๋ฌธ ์‹œ ๋น„๊ด€์  ๋ฝ์œผ๋กœ ์žฌ๊ณ  ์ฐจ๊ฐ - @Transactional ๊ฒฝ๊ณ„ ๊ด€๋ฆฌ Co-Authored-By: Claude Opus 4.5 --- .../application/brand/BrandResult.java | 25 ++ .../application/brand/BrandService.java | 52 ++++ .../like/LikeApplicationService.java | 54 ++++ .../loopers/application/like/LikeResult.java | 24 ++ .../order/OrderApplicationService.java | 112 +++++++ .../application/order/OrderItemRequest.java | 10 + .../application/order/OrderItemResult.java | 26 ++ .../application/order/OrderResult.java | 34 ++ .../application/product/ProductResult.java | 31 ++ .../application/product/ProductService.java | 70 +++++ .../brand/BrandServiceIntegrationTest.java | 291 ++++++++++++++++++ .../like/LikeApplicationServiceTest.java | 120 ++++++++ .../order/OrderApplicationServiceTest.java | 253 +++++++++++++++ 13 files changed, 1102 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemRequest.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/like/LikeApplicationServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandResult.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandResult.java new file mode 100644 index 000000000..6300f81de --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandResult.java @@ -0,0 +1,25 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; + +import java.time.ZonedDateTime; + +public record BrandResult( + Long id, + String name, + String description, + String logoUrl, + ZonedDateTime createdAt, + ZonedDateTime updatedAt +) { + public static BrandResult from(Brand brand) { + return new BrandResult( + brand.getId(), + brand.getName(), + brand.getDescription(), + brand.getLogoUrl(), + brand.getCreatedAt(), + brand.getUpdatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java new file mode 100644 index 000000000..b9cd90415 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -0,0 +1,52 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandDomainService; +import com.loopers.domain.brand.BrandInfo; +import com.loopers.domain.product.ProductDomainService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BrandService { + + private final BrandDomainService brandDomainService; + private final ProductDomainService productDomainService; + + @Transactional(readOnly = true) + public BrandResult findById(Long id) { + Brand brand = brandDomainService.findById(id); + return BrandResult.from(brand); + } + + @Transactional(readOnly = true) + public List findAll() { + return brandDomainService.findAll().stream() + .map(BrandResult::from) + .toList(); + } + + @Transactional + public BrandResult create(BrandInfo info) { + Brand brand = brandDomainService.create(info); + return BrandResult.from(brand); + } + + @Transactional + public BrandResult update(Long id, BrandInfo info) { + Brand brand = brandDomainService.update(id, info); + return BrandResult.from(brand); + } + + @Transactional + public void delete(Long brandId) { + // ํ•ด๋‹น ๋ธŒ๋žœ๋“œ์˜ ๋ชจ๋“  ์ƒํ’ˆ soft delete (๋‹ค๋ฅธ BC) + productDomainService.deleteAllByBrandId(brandId); + // Brand soft delete + brandDomainService.delete(brandId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java new file mode 100644 index 000000000..dcf340db3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java @@ -0,0 +1,54 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeDomainService; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * ์ข‹์•„์š” Application Service. + * ์—ฌ๋Ÿฌ BC ์กฐํ•ฉ ๋ฐ ํŠธ๋žœ์žญ์…˜ ๊ฒฝ๊ณ„ ๋‹ด๋‹น. + */ +@Service +@RequiredArgsConstructor +public class LikeApplicationService { + + private final LikeDomainService likeDomainService; + private final ProductRepository productRepository; + + /** + * ์ข‹์•„์š” ๋“ฑ๋ก. + * ์ƒํ’ˆ ์กด์žฌ ์—ฌ๋ถ€ ๊ฒ€์ฆ ํ›„ ๋„๋ฉ”์ธ ์„œ๋น„์Šค ํ˜ธ์ถœ. + * + * @param userId ์‚ฌ์šฉ์ž ID + * @param productId ์ƒํ’ˆ ID + * @return ์ƒ์„ฑ๋œ ์ข‹์•„์š” ๊ฒฐ๊ณผ + */ + @Transactional + public LikeResult like(Long userId, Long productId) { + validateProductExists(productId); + Like like = likeDomainService.like(userId, productId); + return LikeResult.from(like); + } + + /** + * ์ข‹์•„์š” ์ทจ์†Œ. + * ๋ฉฑ๋“ฑํ•˜๊ฒŒ ๋™์ž‘ - ์กด์žฌํ•˜์ง€ ์•Š์•„๋„ ์˜ˆ์™ธ ์—†์ด ์ฒ˜๋ฆฌ. + * + * @param userId ์‚ฌ์šฉ์ž ID + * @param productId ์ƒํ’ˆ ID + */ + @Transactional + public void unlike(Long userId, Long productId) { + likeDomainService.unlike(userId, productId); + } + + private void validateProductExists(Long productId) { + productRepository.findByIdActive(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeResult.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeResult.java new file mode 100644 index 000000000..6e209e0a2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeResult.java @@ -0,0 +1,24 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; + +import java.time.ZonedDateTime; + +/** + * ์ข‹์•„์š” ์‘๋‹ต DTO. + */ +public record LikeResult( + Long id, + Long userId, + Long productId, + ZonedDateTime createdAt +) { + public static LikeResult from(Like like) { + return new LikeResult( + like.getId(), + like.getUserId(), + like.getProductId(), + like.getCreatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java new file mode 100644 index 000000000..43a9c9128 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java @@ -0,0 +1,112 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * ์ฃผ๋ฌธ Application Service. + * ์—ฌ๋Ÿฌ BC ์กฐํ•ฉ ๋ฐ ํŠธ๋žœ์žญ์…˜ ๊ฒฝ๊ณ„ ๋‹ด๋‹น. + */ +@Service +@RequiredArgsConstructor +public class OrderApplicationService { + + private final ProductRepository productRepository; + private final OrderRepository orderRepository; + + /** + * ์ฃผ๋ฌธ ์ƒ์„ฑ. + * 1. ์ƒํ’ˆ ์กฐํšŒ (๋น„๊ด€์  ๋ฝ) + * 2. ์žฌ๊ณ  ์ฐจ๊ฐ + * 3. ์ฃผ๋ฌธ ์ƒ์„ฑ + * + * @param userId ์‚ฌ์šฉ์ž ID + * @param items ์ฃผ๋ฌธ ํ•ญ๋ชฉ ์š”์ฒญ ๋ชฉ๋ก + * @return ์ƒ์„ฑ๋œ ์ฃผ๋ฌธ ๊ฒฐ๊ณผ + */ + @Transactional + public OrderResult placeOrder(Long userId, List items) { + if (items == null || items.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ ํ•ญ๋ชฉ์ด ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค."); + } + + List orderItems = new ArrayList<>(); + + for (OrderItemRequest req : items) { + // 1) ๋น„๊ด€์  ๋ฝ์œผ๋กœ ์ƒํ’ˆ ์กฐํšŒ + Product product = productRepository.findByIdWithLock(req.productId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + // 2) ์žฌ๊ณ  ์ฐจ๊ฐ (๋„๋ฉ”์ธ ๊ทœ์น™) + product.decreaseStock(req.quantity()); + productRepository.save(product); + + // 3) OrderItem ์ƒ์„ฑ (๊ฐ€๊ฒฉ ์Šค๋ƒ…์ƒท) + orderItems.add(OrderItem.create( + product.getId(), + product.getName(), + req.quantity(), + product.getPrice() + )); + } + + // 4) Order ์ƒ์„ฑ/์ €์žฅ + Order order = Order.create(userId, orderItems); + Order saved = orderRepository.save(order); + + return OrderResult.from(saved); + } + + /** + * ์ฃผ๋ฌธ ์กฐํšŒ. + * + * @param orderId ์ฃผ๋ฌธ ID + * @param userId ์‚ฌ์šฉ์ž ID + * @return ์ฃผ๋ฌธ ๊ฒฐ๊ณผ + */ + @Transactional(readOnly = true) + public OrderResult getOrder(Long orderId, Long userId) { + Order order = orderRepository.findByIdAndUserId(orderId, userId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ฃผ๋ฌธ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + return OrderResult.from(order); + } + + /** + * ์‚ฌ์šฉ์ž์˜ ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ. + * + * @param userId ์‚ฌ์šฉ์ž ID + * @param offset ์‹œ์ž‘ ์œ„์น˜ + * @param limit ์กฐํšŒ ๊ฐœ์ˆ˜ + * @return ์ฃผ๋ฌธ ๊ฒฐ๊ณผ ๋ชฉ๋ก + */ + @Transactional(readOnly = true) + public List getOrders(Long userId, int offset, int limit) { + return orderRepository.findAllByUserId(userId, offset, limit).stream() + .map(OrderResult::from) + .toList(); + } + + /** + * ์‚ฌ์šฉ์ž์˜ ์ฃผ๋ฌธ ์ˆ˜ ์กฐํšŒ. + * + * @param userId ์‚ฌ์šฉ์ž ID + * @return ์ฃผ๋ฌธ ์ˆ˜ + */ + @Transactional(readOnly = true) + public long countOrders(Long userId) { + return orderRepository.countByUserId(userId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemRequest.java new file mode 100644 index 000000000..3180995f0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemRequest.java @@ -0,0 +1,10 @@ +package com.loopers.application.order; + +/** + * ์ฃผ๋ฌธ ํ•ญ๋ชฉ ์š”์ฒญ DTO. + */ +public record OrderItemRequest( + Long productId, + int quantity +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemResult.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemResult.java new file mode 100644 index 000000000..5169e6623 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemResult.java @@ -0,0 +1,26 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItem; + +/** + * ์ฃผ๋ฌธ ํ•ญ๋ชฉ ์‘๋‹ต DTO. + */ +public record OrderItemResult( + Long id, + Long productId, + String productName, + int quantity, + Long priceSnapshot, + Long subtotal +) { + public static OrderItemResult from(OrderItem item) { + return new OrderItemResult( + item.getId(), + item.getProductId(), + item.getProductName(), + item.getQuantity(), + item.getPriceSnapshot().amount(), + item.getSubtotal().amount() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderResult.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderResult.java new file mode 100644 index 000000000..d56cf66ee --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderResult.java @@ -0,0 +1,34 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; + +import java.time.ZonedDateTime; +import java.util.List; + +/** + * ์ฃผ๋ฌธ ์‘๋‹ต DTO. + */ +public record OrderResult( + Long id, + Long userId, + List items, + Long totalPrice, + OrderStatus status, + ZonedDateTime createdAt +) { + public static OrderResult from(Order order) { + List itemResults = order.getItems().stream() + .map(OrderItemResult::from) + .toList(); + + return new OrderResult( + order.getId(), + order.getUserId(), + itemResults, + order.getTotalPrice().amount(), + order.getStatus(), + order.getCreatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductResult.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductResult.java new file mode 100644 index 000000000..3c5a22006 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductResult.java @@ -0,0 +1,31 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Product; + +import java.time.ZonedDateTime; + +public record ProductResult( + Long id, + Long brandId, + String name, + String description, + Long price, + Integer stock, + String imageUrl, + ZonedDateTime createdAt, + ZonedDateTime updatedAt +) { + public static ProductResult from(Product product) { + return new ProductResult( + product.getId(), + product.getBrandId(), + product.getName(), + product.getDescription(), + product.getPrice().amount(), + product.getStock().quantity(), + product.getImageUrl(), + product.getCreatedAt(), + product.getUpdatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java new file mode 100644 index 000000000..d91ed0c0f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -0,0 +1,70 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductDomainService; +import com.loopers.domain.product.ProductInfo; +import com.loopers.domain.product.ProductSort; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ProductService { + + private final ProductDomainService productDomainService; + + @Transactional(readOnly = true) + public ProductResult findById(Long id) { + Product product = productDomainService.findById(id); + return ProductResult.from(product); + } + + @Transactional(readOnly = true) + public Page findAll(Long brandId, Pageable pageable) { + // ๊ธฐ๋ณธ ์ •๋ ฌ์€ LATEST + ProductSort sort = ProductSort.LATEST; + + int offset = (int) pageable.getOffset(); + int limit = pageable.getPageSize(); + + List products; + long total; + + if (brandId != null) { + products = productDomainService.findAllByBrandId(brandId, sort, offset, limit); + total = productDomainService.countByBrandId(brandId); + } else { + products = productDomainService.findAll(sort, offset, limit); + total = productDomainService.countAll(); + } + + List results = products.stream() + .map(ProductResult::from) + .toList(); + + return new PageImpl<>(results, pageable, total); + } + + @Transactional + public ProductResult create(ProductInfo info) { + Product product = productDomainService.create(info); + return ProductResult.from(product); + } + + @Transactional + public ProductResult update(Long id, ProductInfo info) { + Product product = productDomainService.update(id, info); + return ProductResult.from(product); + } + + @Transactional + public void delete(Long id) { + productDomainService.delete(id); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java new file mode 100644 index 000000000..e99a87f0a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java @@ -0,0 +1,291 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandInfo; +import com.loopers.infrastructure.persistence.jpa.brand.BrandJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class BrandServiceIntegrationTest { + + @Autowired + private BrandService brandService; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("๋ธŒ๋žœ๋“œ๋ฅผ ์ƒ์„ฑํ•  ๋•Œ,") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๋กœ ์ƒ์„ฑํ•˜๋ฉด, ๋ธŒ๋žœ๋“œ๊ฐ€ ์ €์žฅ๋œ๋‹ค.") + @Test + void createsBrand_whenValidInfoIsProvided() { + // arrange + BrandInfo info = new BrandInfo("Nike", "Just Do It", "https://example.com/nike.png"); + + // act + BrandResult result = brandService.create(info); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.id()).isNotNull(), + () -> assertThat(result.name()).isEqualTo("Nike"), + () -> assertThat(result.description()).isEqualTo("Just Do It"), + () -> assertThat(result.logoUrl()).isEqualTo("https://example.com/nike.png"), + () -> assertThat(result.createdAt()).isNotNull(), + () -> assertThat(result.updatedAt()).isNotNull() + ); + } + + @DisplayName("์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ธŒ๋žœ๋“œ๋ช…์œผ๋กœ ์ƒ์„ฑํ•˜๋ฉด, BRAND_ALREADY_EXISTS ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBrandAlreadyExistsException_whenNameAlreadyExists() { + // arrange + BrandInfo info = new BrandInfo("Nike", "Just Do It", null); + brandService.create(info); + + BrandInfo duplicateInfo = new BrandInfo("Nike", "Another description", null); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.create(duplicateInfo); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BRAND_ALREADY_EXISTS); + } + } + + @DisplayName("๋ธŒ๋žœ๋“œ๋ฅผ ์กฐํšŒํ•  ๋•Œ,") + @Nested + class FindById { + + @DisplayName("์กด์žฌํ•˜๋Š” ID๋กœ ์กฐํšŒํ•˜๋ฉด, ๋ธŒ๋žœ๋“œ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsBrand_whenIdExists() { + // arrange + BrandInfo info = new BrandInfo("Nike", "Just Do It", "https://example.com/nike.png"); + BrandResult created = brandService.create(info); + + // act + BrandResult result = brandService.findById(created.id()); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.id()).isEqualTo(created.id()), + () -> assertThat(result.name()).isEqualTo("Nike") + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ID๋กœ ์กฐํšŒํ•˜๋ฉด, BRAND_NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBrandNotFoundException_whenIdDoesNotExist() { + // arrange + Long nonExistentId = 999L; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.findById(nonExistentId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BRAND_NOT_FOUND); + } + + @DisplayName("์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ๋ฅผ ์กฐํšŒํ•˜๋ฉด, BRAND_NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBrandNotFoundException_whenBrandIsDeleted() { + // arrange + BrandInfo info = new BrandInfo("Nike", "Just Do It", null); + BrandResult created = brandService.create(info); + brandService.delete(created.id()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.findById(created.id()); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BRAND_NOT_FOUND); + } + } + + @DisplayName("๋ธŒ๋žœ๋“œ ๋ชฉ๋ก์„ ์กฐํšŒํ•  ๋•Œ,") + @Nested + class FindAll { + + @DisplayName("๋ธŒ๋žœ๋“œ๊ฐ€ ์กด์žฌํ•˜๋ฉด, ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsBrandList_whenBrandsExist() { + // arrange + brandService.create(new BrandInfo("Nike", null, null)); + brandService.create(new BrandInfo("Adidas", null, null)); + brandService.create(new BrandInfo("Puma", null, null)); + + // act + List result = brandService.findAll(); + + // assert + assertThat(result).hasSize(3); + } + + @DisplayName("์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ๋Š” ๋ชฉ๋ก์—์„œ ์ œ์™ธ๋œ๋‹ค.") + @Test + void excludesDeletedBrands_fromList() { + // arrange + BrandResult nike = brandService.create(new BrandInfo("Nike", null, null)); + brandService.create(new BrandInfo("Adidas", null, null)); + brandService.delete(nike.id()); + + // act + List result = brandService.findAll(); + + // assert + assertAll( + () -> assertThat(result).hasSize(1), + () -> assertThat(result.get(0).name()).isEqualTo("Adidas") + ); + } + } + + @DisplayName("๋ธŒ๋žœ๋“œ๋ฅผ ์ˆ˜์ •ํ•  ๋•Œ,") + @Nested + class Update { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๋กœ ์ˆ˜์ •ํ•˜๋ฉด, ๋ธŒ๋žœ๋“œ๊ฐ€ ์—…๋ฐ์ดํŠธ๋œ๋‹ค.") + @Test + void updatesBrand_whenValidInfoIsProvided() { + // arrange + BrandInfo info = new BrandInfo("Nike", "Original", null); + BrandResult created = brandService.create(info); + + BrandInfo updateInfo = new BrandInfo("Nike Updated", "Updated description", "https://example.com/updated.png"); + + // act + BrandResult result = brandService.update(created.id(), updateInfo); + + // assert + assertAll( + () -> assertThat(result.name()).isEqualTo("Nike Updated"), + () -> assertThat(result.description()).isEqualTo("Updated description"), + () -> assertThat(result.logoUrl()).isEqualTo("https://example.com/updated.png") + ); + } + + @DisplayName("์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋‹ค๋ฅธ ๋ธŒ๋žœ๋“œ๋ช…์œผ๋กœ ์ˆ˜์ •ํ•˜๋ฉด, BRAND_ALREADY_EXISTS ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBrandAlreadyExistsException_whenUpdatingToExistingName() { + // arrange + brandService.create(new BrandInfo("Nike", null, null)); + BrandResult adidas = brandService.create(new BrandInfo("Adidas", null, null)); + + BrandInfo updateInfo = new BrandInfo("Nike", null, null); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.update(adidas.id(), updateInfo); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BRAND_ALREADY_EXISTS); + } + + @DisplayName("๊ฐ™์€ ์ด๋ฆ„์œผ๋กœ ์ˆ˜์ •ํ•˜๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์—…๋ฐ์ดํŠธ๋œ๋‹ค.") + @Test + void updatesBrand_whenUpdatingWithSameName() { + // arrange + BrandInfo info = new BrandInfo("Nike", "Original", null); + BrandResult created = brandService.create(info); + + BrandInfo updateInfo = new BrandInfo("Nike", "Updated description", null); + + // act + BrandResult result = brandService.update(created.id(), updateInfo); + + // assert + assertAll( + () -> assertThat(result.name()).isEqualTo("Nike"), + () -> assertThat(result.description()).isEqualTo("Updated description") + ); + } + + @DisplayName("์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ๋ฅผ ์ˆ˜์ •ํ•˜๋ฉด, BRAND_NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBrandNotFoundException_whenUpdatingDeletedBrand() { + // arrange + BrandInfo info = new BrandInfo("Nike", null, null); + BrandResult created = brandService.create(info); + brandService.delete(created.id()); + + BrandInfo updateInfo = new BrandInfo("Nike Updated", null, null); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.update(created.id(), updateInfo); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BRAND_NOT_FOUND); + } + } + + @DisplayName("๋ธŒ๋žœ๋“œ๋ฅผ ์‚ญ์ œํ•  ๋•Œ,") + @Nested + class Delete { + + @DisplayName("์กด์žฌํ•˜๋Š” ๋ธŒ๋žœ๋“œ๋ฅผ ์‚ญ์ œํ•˜๋ฉด, soft delete ์ฒ˜๋ฆฌ๋œ๋‹ค.") + @Test + void softDeletesBrand_whenBrandExists() { + // arrange + BrandInfo info = new BrandInfo("Nike", null, null); + BrandResult created = brandService.create(info); + + // act + brandService.delete(created.id()); + + // assert + assertThat(brandJpaRepository.findById(created.id())) + .isPresent() + .hasValueSatisfying(brand -> assertThat(brand.getDeletedAt()).isNotNull()); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ธŒ๋žœ๋“œ๋ฅผ ์‚ญ์ œํ•˜๋ฉด, BRAND_NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBrandNotFoundException_whenBrandDoesNotExist() { + // arrange + Long nonExistentId = 999L; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.delete(nonExistentId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BRAND_NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeApplicationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeApplicationServiceTest.java new file mode 100644 index 000000000..4a8dbe945 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeApplicationServiceTest.java @@ -0,0 +1,120 @@ +package com.loopers.application.like; + +import com.loopers.domain.common.Money; +import com.loopers.domain.like.LikeDomainService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.Stock; +import com.loopers.fake.FakeLikeRepository; +import com.loopers.fake.FakeProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DisplayName("LikeApplicationService ํ…Œ์ŠคํŠธ") +class LikeApplicationServiceTest { + + private FakeLikeRepository fakeLikeRepository; + private FakeProductRepository fakeProductRepository; + private LikeDomainService likeDomainService; + private LikeApplicationService likeApplicationService; + + @BeforeEach + void setUp() { + fakeLikeRepository = new FakeLikeRepository(); + fakeProductRepository = new FakeProductRepository(); + likeDomainService = new LikeDomainService(fakeLikeRepository); + likeApplicationService = new LikeApplicationService(likeDomainService, fakeProductRepository); + } + + private Product createAndSaveProduct() { + Product product = Product.create(1L, "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", "์„ค๋ช…", + new Money(10000), new Stock(100), "http://image.url"); + return fakeProductRepository.save(product); + } + + @Nested + @DisplayName("์ข‹์•„์š” ๋“ฑ๋ก") + class Like { + + @Test + @DisplayName("์„ฑ๊ณต - ์ƒํ’ˆ์ด ์กด์žฌํ•˜๊ณ  ์ฒ˜์Œ ์ข‹์•„์š”ํ•˜๋Š” ๊ฒฝ์šฐ") + void ์ข‹์•„์š”_๋“ฑ๋ก_์„ฑ๊ณต() { + // Arrange + Product product = createAndSaveProduct(); + Long userId = 1L; + + // Act + LikeResult result = likeApplicationService.like(userId, product.getId()); + + // Assert + assertThat(result.userId()).isEqualTo(userId); + assertThat(result.productId()).isEqualTo(product.getId()); + assertThat(result.createdAt()).isNotNull(); + } + + @Test + @DisplayName("์‹คํŒจ - ์ƒํ’ˆ์ด ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ") + void ์ƒํ’ˆ_๋ฏธ์กด์žฌ_์˜ˆ์™ธ() { + // Arrange + Long userId = 1L; + Long nonExistentProductId = 999L; + + // Act & Assert + CoreException ex = assertThrows(CoreException.class, + () -> likeApplicationService.like(userId, nonExistentProductId)); + assertThat(ex.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("์‹คํŒจ - ์ด๋ฏธ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ์ธ ๊ฒฝ์šฐ") + void ์ค‘๋ณต_์ข‹์•„์š”_์˜ˆ์™ธ() { + // Arrange + Product product = createAndSaveProduct(); + Long userId = 1L; + likeApplicationService.like(userId, product.getId()); + + // Act & Assert + CoreException ex = assertThrows(CoreException.class, + () -> likeApplicationService.like(userId, product.getId())); + assertThat(ex.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @Nested + @DisplayName("์ข‹์•„์š” ์ทจ์†Œ") + class Unlike { + + @Test + @DisplayName("์„ฑ๊ณต - ์ข‹์•„์š”๊ฐ€ ์กด์žฌํ•˜๋Š” ๊ฒฝ์šฐ") + void ์ข‹์•„์š”_์ทจ์†Œ_์„ฑ๊ณต() { + // Arrange + Product product = createAndSaveProduct(); + Long userId = 1L; + likeApplicationService.like(userId, product.getId()); + + // Act + likeApplicationService.unlike(userId, product.getId()); + + // Assert + assertThat(fakeLikeRepository.exists(userId, product.getId())).isFalse(); + } + + @Test + @DisplayName("์„ฑ๊ณต - ์ข‹์•„์š”๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์•„๋„ ๋ฉฑ๋“ฑํ•˜๊ฒŒ ๋™์ž‘") + void ์ข‹์•„์š”_๋ฏธ์กด์žฌ_๋ฉฑ๋“ฑ์„ฑ() { + // Arrange + Long userId = 1L; + Long productId = 999L; + + // Act & Assert - ์˜ˆ์™ธ ์—†์ด ์„ฑ๊ณต + assertDoesNotThrow(() -> likeApplicationService.unlike(userId, productId)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceTest.java new file mode 100644 index 000000000..e64b91ccd --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceTest.java @@ -0,0 +1,253 @@ +package com.loopers.application.order; + +import com.loopers.domain.common.Money; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.Stock; +import com.loopers.fake.FakeOrderRepository; +import com.loopers.fake.FakeProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DisplayName("OrderApplicationService ํ…Œ์ŠคํŠธ") +class OrderApplicationServiceTest { + + private FakeProductRepository fakeProductRepository; + private FakeOrderRepository fakeOrderRepository; + private OrderApplicationService orderApplicationService; + + @BeforeEach + void setUp() { + fakeProductRepository = new FakeProductRepository(); + fakeOrderRepository = new FakeOrderRepository(); + orderApplicationService = new OrderApplicationService(fakeProductRepository, fakeOrderRepository); + } + + private Product createAndSaveProduct(String name, long price, int stock) { + Product product = Product.create(1L, name, "์„ค๋ช…", + new Money(price), new Stock(stock), "http://image.url"); + return fakeProductRepository.save(product); + } + + @Nested + @DisplayName("์ฃผ๋ฌธ ์ƒ์„ฑ") + class PlaceOrder { + + @Test + @DisplayName("์„ฑ๊ณต - ๋‹จ์ผ ์ƒํ’ˆ ์ฃผ๋ฌธ") + void ๋‹จ์ผ_์ƒํ’ˆ_์ฃผ๋ฌธ_์„ฑ๊ณต() { + // Arrange + Product product = createAndSaveProduct("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", 10000, 100); + Long userId = 1L; + List items = List.of( + new OrderItemRequest(product.getId(), 2) + ); + + // Act + OrderResult result = orderApplicationService.placeOrder(userId, items); + + // Assert + assertThat(result.userId()).isEqualTo(userId); + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).productId()).isEqualTo(product.getId()); + assertThat(result.items().get(0).quantity()).isEqualTo(2); + assertThat(result.items().get(0).priceSnapshot()).isEqualTo(10000); + assertThat(result.totalPrice()).isEqualTo(20000); // 10000 * 2 + assertThat(result.status()).isEqualTo(OrderStatus.CREATED); + + // ์žฌ๊ณ  ์ฐจ๊ฐ ํ™•์ธ + Product updatedProduct = fakeProductRepository.findById(product.getId()).orElseThrow(); + assertThat(updatedProduct.getStock().quantity()).isEqualTo(98); // 100 - 2 + } + + @Test + @DisplayName("์„ฑ๊ณต - ๋ณต์ˆ˜ ์ƒํ’ˆ ์ฃผ๋ฌธ") + void ๋ณต์ˆ˜_์ƒํ’ˆ_์ฃผ๋ฌธ_์„ฑ๊ณต() { + // Arrange + Product product1 = createAndSaveProduct("์ƒํ’ˆ1", 10000, 100); + Product product2 = createAndSaveProduct("์ƒํ’ˆ2", 20000, 50); + Long userId = 1L; + List items = List.of( + new OrderItemRequest(product1.getId(), 2), + new OrderItemRequest(product2.getId(), 1) + ); + + // Act + OrderResult result = orderApplicationService.placeOrder(userId, items); + + // Assert + assertThat(result.items()).hasSize(2); + assertThat(result.totalPrice()).isEqualTo(40000); // 10000*2 + 20000*1 + } + + @Test + @DisplayName("์‹คํŒจ - ์ฃผ๋ฌธ ํ•ญ๋ชฉ์ด ๋น„์–ด์žˆ๋Š” ๊ฒฝ์šฐ") + void ์ฃผ๋ฌธํ•ญ๋ชฉ_๋น„์–ด์žˆ์Œ_์˜ˆ์™ธ() { + // Arrange + Long userId = 1L; + + // Act & Assert + CoreException ex = assertThrows(CoreException.class, + () -> orderApplicationService.placeOrder(userId, List.of())); + assertThat(ex.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("์‹คํŒจ - ์ฃผ๋ฌธ ํ•ญ๋ชฉ์ด null์ธ ๊ฒฝ์šฐ") + void ์ฃผ๋ฌธํ•ญ๋ชฉ_null_์˜ˆ์™ธ() { + // Arrange + Long userId = 1L; + + // Act & Assert + CoreException ex = assertThrows(CoreException.class, + () -> orderApplicationService.placeOrder(userId, null)); + assertThat(ex.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("์‹คํŒจ - ์ƒํ’ˆ์ด ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ") + void ์ƒํ’ˆ_๋ฏธ์กด์žฌ_์˜ˆ์™ธ() { + // Arrange + Long userId = 1L; + List items = List.of( + new OrderItemRequest(999L, 1) + ); + + // Act & Assert + CoreException ex = assertThrows(CoreException.class, + () -> orderApplicationService.placeOrder(userId, items)); + assertThat(ex.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("์‹คํŒจ - ์žฌ๊ณ  ๋ถ€์กฑ") + void ์žฌ๊ณ _๋ถ€์กฑ_์˜ˆ์™ธ() { + // Arrange + Product product = createAndSaveProduct("์žฌ๊ณ  ์ ์€ ์ƒํ’ˆ", 10000, 5); + Long userId = 1L; + List items = List.of( + new OrderItemRequest(product.getId(), 10) // ์žฌ๊ณ  5์ธ๋ฐ 10๊ฐœ ์ฃผ๋ฌธ + ); + + // Act & Assert + CoreException ex = assertThrows(CoreException.class, + () -> orderApplicationService.placeOrder(userId, items)); + assertThat(ex.getErrorType()).isEqualTo(ErrorType.INSUFFICIENT_STOCK); + } + } + + @Nested + @DisplayName("์ฃผ๋ฌธ ์กฐํšŒ") + class GetOrder { + + @Test + @DisplayName("์„ฑ๊ณต - ์ฃผ๋ฌธ ID์™€ ์‚ฌ์šฉ์ž ID๋กœ ์กฐํšŒ") + void ์ฃผ๋ฌธ_์กฐํšŒ_์„ฑ๊ณต() { + // Arrange + Product product = createAndSaveProduct("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", 10000, 100); + Long userId = 1L; + List items = List.of(new OrderItemRequest(product.getId(), 2)); + OrderResult created = orderApplicationService.placeOrder(userId, items); + + // Act + OrderResult result = orderApplicationService.getOrder(created.id(), userId); + + // Assert + assertThat(result.id()).isEqualTo(created.id()); + assertThat(result.userId()).isEqualTo(userId); + } + + @Test + @DisplayName("์‹คํŒจ - ์ฃผ๋ฌธ์ด ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ") + void ์ฃผ๋ฌธ_๋ฏธ์กด์žฌ_์˜ˆ์™ธ() { + // Arrange + Long userId = 1L; + Long nonExistentOrderId = 999L; + + // Act & Assert + CoreException ex = assertThrows(CoreException.class, + () -> orderApplicationService.getOrder(nonExistentOrderId, userId)); + assertThat(ex.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("์‹คํŒจ - ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž์˜ ์ฃผ๋ฌธ ์กฐํšŒ ์‹œ") + void ํƒ€์ธ_์ฃผ๋ฌธ_์กฐํšŒ_์˜ˆ์™ธ() { + // Arrange + Product product = createAndSaveProduct("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", 10000, 100); + Long userId = 1L; + Long otherUserId = 2L; + List items = List.of(new OrderItemRequest(product.getId(), 2)); + OrderResult created = orderApplicationService.placeOrder(userId, items); + + // Act & Assert + CoreException ex = assertThrows(CoreException.class, + () -> orderApplicationService.getOrder(created.id(), otherUserId)); + assertThat(ex.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @Nested + @DisplayName("์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ") + class GetOrders { + + @Test + @DisplayName("์„ฑ๊ณต - ์‚ฌ์šฉ์ž์˜ ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ") + void ์ฃผ๋ฌธ_๋ชฉ๋ก_์กฐํšŒ_์„ฑ๊ณต() { + // Arrange + Product product = createAndSaveProduct("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", 10000, 100); + Long userId = 1L; + orderApplicationService.placeOrder(userId, List.of(new OrderItemRequest(product.getId(), 1))); + orderApplicationService.placeOrder(userId, List.of(new OrderItemRequest(product.getId(), 2))); + + // Act + List results = orderApplicationService.getOrders(userId, 0, 10); + + // Assert + assertThat(results).hasSize(2); + } + + @Test + @DisplayName("์„ฑ๊ณต - ์ฃผ๋ฌธ์ด ์—†๋Š” ๊ฒฝ์šฐ ๋นˆ ๋ชฉ๋ก ๋ฐ˜ํ™˜") + void ์ฃผ๋ฌธ_์—†์Œ_๋นˆ๋ชฉ๋ก() { + // Arrange + Long userId = 1L; + + // Act + List results = orderApplicationService.getOrders(userId, 0, 10); + + // Assert + assertThat(results).isEmpty(); + } + } + + @Nested + @DisplayName("์ฃผ๋ฌธ ์ˆ˜ ์กฐํšŒ") + class CountOrders { + + @Test + @DisplayName("์„ฑ๊ณต - ์‚ฌ์šฉ์ž์˜ ์ฃผ๋ฌธ ์ˆ˜ ์กฐํšŒ") + void ์ฃผ๋ฌธ_์ˆ˜_์กฐํšŒ_์„ฑ๊ณต() { + // Arrange + Product product = createAndSaveProduct("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", 10000, 100); + Long userId = 1L; + orderApplicationService.placeOrder(userId, List.of(new OrderItemRequest(product.getId(), 1))); + orderApplicationService.placeOrder(userId, List.of(new OrderItemRequest(product.getId(), 2))); + + // Act + long count = orderApplicationService.countOrders(userId); + + // Assert + assertThat(count).isEqualTo(2); + } + } +} From 3cf6afddd9061f326bb1d7b0e791cc7806ee5aba Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 27 Feb 2026 13:55:32 +0900 Subject: [PATCH 21/29] =?UTF-8?q?feat(api):=20REST=20API=20=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brand API: - BrandV1Controller: ๋ธŒ๋žœ๋“œ ๋ชฉ๋ก/์ƒ์„ธ ์กฐํšŒ - BrandAdminV1Controller: ๋ธŒ๋žœ๋“œ CRUD (๊ด€๋ฆฌ์ž) - BrandV1Dto: ์š”์ฒญ/์‘๋‹ต DTO - BrandV1ApiSpec, BrandAdminV1ApiSpec: OpenAPI ๋ฌธ์„œํ™” Product API: - ProductV1Controller: ์ƒํ’ˆ ๋ชฉ๋ก/์ƒ์„ธ ์กฐํšŒ - ProductAdminV1Controller: ์ƒํ’ˆ CRUD (๊ด€๋ฆฌ์ž) - ProductV1Dto: ์š”์ฒญ/์‘๋‹ต DTO - ProductV1ApiSpec, ProductAdminV1ApiSpec: OpenAPI ๋ฌธ์„œํ™” HTTP ํ…Œ์ŠคํŠธ ํŒŒ์ผ: - brand-api.http, brand-admin-api.http - product-api.http, product-admin-api.http Co-Authored-By: Claude Opus 4.5 --- .../api/brand/BrandAdminV1ApiSpec.java | 41 ++++++++ .../api/brand/BrandAdminV1Controller.java | 69 ++++++++++++++ .../interfaces/api/brand/BrandV1ApiSpec.java | 15 +++ .../api/brand/BrandV1Controller.java | 25 +++++ .../interfaces/api/brand/BrandV1Dto.java | 63 +++++++++++++ .../api/product/ProductAdminV1ApiSpec.java | 41 ++++++++ .../api/product/ProductAdminV1Controller.java | 73 +++++++++++++++ .../api/product/ProductV1ApiSpec.java | 23 +++++ .../api/product/ProductV1Controller.java | 39 ++++++++ .../interfaces/api/product/ProductV1Dto.java | 93 +++++++++++++++++++ http/brand-admin-api.http | 36 +++++++ http/brand-api.http | 5 + http/product-admin-api.http | 42 +++++++++ http/product-api.http | 17 ++++ 14 files changed, 582 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java create mode 100644 http/brand-admin-api.http create mode 100644 http/brand-api.http create mode 100644 http/product-admin-api.http create mode 100644 http/product-api.http diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiSpec.java new file mode 100644 index 000000000..1c513ca01 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiSpec.java @@ -0,0 +1,41 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.util.List; + +@Tag(name = "Brand Admin V1 API", description = "๋ธŒ๋žœ๋“œ ๊ด€๋ฆฌ API์ž…๋‹ˆ๋‹ค.") +public interface BrandAdminV1ApiSpec { + + @Operation( + summary = "๋ธŒ๋žœ๋“œ ๋ชฉ๋ก ์กฐํšŒ", + description = "๋ชจ๋“  ๋ธŒ๋žœ๋“œ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse> getAllBrands(); + + @Operation( + summary = "๋ธŒ๋žœ๋“œ ์ƒ์„ธ ์กฐํšŒ", + description = "๋ธŒ๋žœ๋“œ ID๋กœ ๋ธŒ๋žœ๋“œ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse getBrand(Long brandId); + + @Operation( + summary = "๋ธŒ๋žœ๋“œ ๋“ฑ๋ก", + description = "์ƒˆ๋กœ์šด ๋ธŒ๋žœ๋“œ๋ฅผ ๋“ฑ๋กํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse createBrand(BrandV1Dto.BrandCreateRequest request); + + @Operation( + summary = "๋ธŒ๋žœ๋“œ ์ˆ˜์ •", + description = "๋ธŒ๋žœ๋“œ ์ •๋ณด๋ฅผ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse updateBrand(Long brandId, BrandV1Dto.BrandUpdateRequest request); + + @Operation( + summary = "๋ธŒ๋žœ๋“œ ์‚ญ์ œ", + description = "๋ธŒ๋žœ๋“œ๋ฅผ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. (Soft Delete)" + ) + ApiResponse deleteBrand(Long brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java new file mode 100644 index 000000000..5a3b34a0d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java @@ -0,0 +1,69 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandResult; +import com.loopers.application.brand.BrandService; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +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; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/brands") +public class BrandAdminV1Controller implements BrandAdminV1ApiSpec { + + private final BrandService brandService; + + @GetMapping + @Override + public ApiResponse> getAllBrands() { + List results = brandService.findAll(); + List responses = results.stream() + .map(BrandV1Dto.BrandResponse::from) + .toList(); + return ApiResponse.success(responses); + } + + @GetMapping("/{brandId}") + @Override + public ApiResponse getBrand(@PathVariable Long brandId) { + BrandResult result = brandService.findById(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(result)); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse createBrand(@Valid @RequestBody BrandV1Dto.BrandCreateRequest request) { + BrandResult result = brandService.create(request.toInfo()); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(result)); + } + + @PutMapping("/{brandId}") + @Override + public ApiResponse updateBrand( + @PathVariable Long brandId, + @Valid @RequestBody BrandV1Dto.BrandUpdateRequest request + ) { + BrandResult result = brandService.update(brandId, request.toInfo()); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(result)); + } + + @DeleteMapping("/{brandId}") + @Override + public ApiResponse deleteBrand(@PathVariable Long brandId) { + brandService.delete(brandId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java new file mode 100644 index 000000000..ee20a2ce7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java @@ -0,0 +1,15 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Brand V1 API", description = "๋ธŒ๋žœ๋“œ ๊ด€๋ จ API์ž…๋‹ˆ๋‹ค.") +public interface BrandV1ApiSpec { + + @Operation( + summary = "๋ธŒ๋žœ๋“œ ์ƒ์„ธ ์กฐํšŒ", + description = "๋ธŒ๋žœ๋“œ ID๋กœ ๋ธŒ๋žœ๋“œ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse getBrand(Long brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java new file mode 100644 index 000000000..5211a7c32 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandResult; +import com.loopers.application.brand.BrandService; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +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; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/brands") +public class BrandV1Controller implements BrandV1ApiSpec { + + private final BrandService brandService; + + @GetMapping("/{brandId}") + @Override + public ApiResponse getBrand(@PathVariable Long brandId) { + BrandResult result = brandService.findById(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(result)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java new file mode 100644 index 000000000..15f96a564 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java @@ -0,0 +1,63 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandResult; +import com.loopers.domain.brand.BrandInfo; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.time.ZonedDateTime; + +public class BrandV1Dto { + + public record BrandResponse( + Long id, + String name, + String description, + String logoUrl, + ZonedDateTime createdAt, + ZonedDateTime updatedAt + ) { + public static BrandResponse from(BrandResult result) { + return new BrandResponse( + result.id(), + result.name(), + result.description(), + result.logoUrl(), + result.createdAt(), + result.updatedAt() + ); + } + } + + public record BrandCreateRequest( + @NotBlank(message = "๋ธŒ๋žœ๋“œ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Size(max = 100, message = "๋ธŒ๋žœ๋“œ๋ช…์€ 100์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + String name, + + @Size(max = 500, message = "๋ธŒ๋žœ๋“œ ์„ค๋ช…์€ 500์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + String description, + + @Size(max = 500, message = "๋กœ๊ณ  URL์€ 500์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + String logoUrl + ) { + public BrandInfo toInfo() { + return new BrandInfo(name, description, logoUrl); + } + } + + public record BrandUpdateRequest( + @NotBlank(message = "๋ธŒ๋žœ๋“œ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Size(max = 100, message = "๋ธŒ๋žœ๋“œ๋ช…์€ 100์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + String name, + + @Size(max = 500, message = "๋ธŒ๋žœ๋“œ ์„ค๋ช…์€ 500์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + String description, + + @Size(max = 500, message = "๋กœ๊ณ  URL์€ 500์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + String logoUrl + ) { + public BrandInfo toInfo() { + return new BrandInfo(name, description, logoUrl); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java new file mode 100644 index 000000000..f7e7d9a44 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java @@ -0,0 +1,41 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@Tag(name = "Product Admin V1 API", description = "์ƒํ’ˆ ๊ด€๋ฆฌ API์ž…๋‹ˆ๋‹ค.") +public interface ProductAdminV1ApiSpec { + + @Operation( + summary = "์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ", + description = "๋ชจ๋“  ์ƒํ’ˆ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse> getAllProducts(Long brandId, Pageable pageable); + + @Operation( + summary = "์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ", + description = "์ƒํ’ˆ ID๋กœ ์ƒํ’ˆ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse getProduct(Long productId); + + @Operation( + summary = "์ƒํ’ˆ ๋“ฑ๋ก", + description = "์ƒˆ๋กœ์šด ์ƒํ’ˆ์„ ๋“ฑ๋กํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse createProduct(ProductV1Dto.ProductCreateRequest request); + + @Operation( + summary = "์ƒํ’ˆ ์ˆ˜์ •", + description = "์ƒํ’ˆ ์ •๋ณด๋ฅผ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse updateProduct(Long productId, ProductV1Dto.ProductUpdateRequest request); + + @Operation( + summary = "์ƒํ’ˆ ์‚ญ์ œ", + description = "์ƒํ’ˆ์„ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. (Soft Delete)" + ) + ApiResponse deleteProduct(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java new file mode 100644 index 000000000..71e8456c4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java @@ -0,0 +1,73 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductResult; +import com.loopers.application.product.ProductService; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/products") +public class ProductAdminV1Controller implements ProductAdminV1ApiSpec { + + private final ProductService productService; + + @GetMapping + @Override + public ApiResponse> getAllProducts( + @RequestParam(required = false) Long brandId, + Pageable pageable + ) { + Page results = productService.findAll(brandId, pageable); + Page responses = results.map(ProductV1Dto.ProductResponse::from); + return ApiResponse.success(responses); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getProduct(@PathVariable Long productId) { + ProductResult result = productService.findById(productId); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(result)); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse createProduct( + @Valid @RequestBody ProductV1Dto.ProductCreateRequest request + ) { + ProductResult result = productService.create(request.toInfo()); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(result)); + } + + @PutMapping("/{productId}") + @Override + public ApiResponse updateProduct( + @PathVariable Long productId, + @Valid @RequestBody ProductV1Dto.ProductUpdateRequest request + ) { + ProductResult result = productService.update(productId, request.toInfo()); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(result)); + } + + @DeleteMapping("/{productId}") + @Override + public ApiResponse deleteProduct(@PathVariable Long productId) { + productService.delete(productId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java new file mode 100644 index 000000000..7ca75aae6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -0,0 +1,23 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@Tag(name = "Product V1 API", description = "์ƒํ’ˆ ๊ด€๋ จ API์ž…๋‹ˆ๋‹ค.") +public interface ProductV1ApiSpec { + + @Operation( + summary = "์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ", + description = "์ƒํ’ˆ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. brandId๋กœ ํ•„ํ„ฐ๋งํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค." + ) + ApiResponse> getProducts(Long brandId, Pageable pageable); + + @Operation( + summary = "์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ", + description = "์ƒํ’ˆ ID๋กœ ์ƒํ’ˆ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse getProduct(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java new file mode 100644 index 000000000..876633f99 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,39 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductResult; +import com.loopers.application.product.ProductService; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +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; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller implements ProductV1ApiSpec { + + private final ProductService productService; + + @GetMapping + @Override + public ApiResponse> getProducts( + @RequestParam(required = false) Long brandId, + Pageable pageable + ) { + Page results = productService.findAll(brandId, pageable); + Page responses = results.map(ProductV1Dto.ProductResponse::from); + return ApiResponse.success(responses); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getProduct(@PathVariable Long productId) { + ProductResult result = productService.findById(productId); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(result)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java new file mode 100644 index 000000000..3aac721c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,93 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductResult; +import com.loopers.domain.product.ProductInfo; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.ZonedDateTime; + +public class ProductV1Dto { + + public record ProductResponse( + Long id, + Long brandId, + String name, + String description, + Long price, + Integer stock, + String imageUrl, + ZonedDateTime createdAt, + ZonedDateTime updatedAt + ) { + public static ProductResponse from(ProductResult result) { + return new ProductResponse( + result.id(), + result.brandId(), + result.name(), + result.description(), + result.price(), + result.stock(), + result.imageUrl(), + result.createdAt(), + result.updatedAt() + ); + } + } + + public record ProductCreateRequest( + @NotNull(message = "๋ธŒ๋žœ๋“œ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + Long brandId, + + @NotBlank(message = "์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Size(max = 200, message = "์ƒํ’ˆ๋ช…์€ 200์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + String name, + + @Size(max = 2000, message = "์ƒํ’ˆ ์„ค๋ช…์€ 2000์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + String description, + + @NotNull(message = "๊ฐ€๊ฒฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Min(value = 0, message = "๊ฐ€๊ฒฉ์€ 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + Long price, + + @NotNull(message = "์žฌ๊ณ ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Min(value = 0, message = "์žฌ๊ณ ๋Š” 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + Integer stock, + + @Size(max = 500, message = "์ด๋ฏธ์ง€ URL์€ 500์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + String imageUrl + ) { + public ProductInfo toInfo() { + return new ProductInfo(brandId, name, description, price, stock, imageUrl); + } + } + + public record ProductUpdateRequest( + @NotNull(message = "๋ธŒ๋žœ๋“œ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + Long brandId, + + @NotBlank(message = "์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Size(max = 200, message = "์ƒํ’ˆ๋ช…์€ 200์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + String name, + + @Size(max = 2000, message = "์ƒํ’ˆ ์„ค๋ช…์€ 2000์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + String description, + + @NotNull(message = "๊ฐ€๊ฒฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Min(value = 0, message = "๊ฐ€๊ฒฉ์€ 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + Long price, + + @NotNull(message = "์žฌ๊ณ ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Min(value = 0, message = "์žฌ๊ณ ๋Š” 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + Integer stock, + + @Size(max = 500, message = "์ด๋ฏธ์ง€ URL์€ 500์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + String imageUrl + ) { + public ProductInfo toInfo() { + return new ProductInfo(brandId, name, description, price, stock, imageUrl); + } + } +} diff --git a/http/brand-admin-api.http b/http/brand-admin-api.http new file mode 100644 index 000000000..2f1edfd63 --- /dev/null +++ b/http/brand-admin-api.http @@ -0,0 +1,36 @@ +### ๋ธŒ๋žœ๋“œ ๋ชฉ๋ก ์กฐํšŒ (Admin) +GET http://localhost:8080/api-admin/v1/brands +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +### ๋ธŒ๋žœ๋“œ ์ƒ์„ธ ์กฐํšŒ (Admin) +GET http://localhost:8080/api-admin/v1/brands/1 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +### ๋ธŒ๋žœ๋“œ ๋“ฑ๋ก (Admin) +POST http://localhost:8080/api-admin/v1/brands +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "name": "Nike", + "description": "Just Do It", + "logoUrl": "https://example.com/nike.png" +} + +### ๋ธŒ๋žœ๋“œ ์ˆ˜์ • (Admin) +PUT http://localhost:8080/api-admin/v1/brands/1 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "name": "Nike Updated", + "description": "Updated description", + "logoUrl": "https://example.com/nike-updated.png" +} + +### ๋ธŒ๋žœ๋“œ ์‚ญ์ œ (Admin) +DELETE http://localhost:8080/api-admin/v1/brands/1 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin diff --git a/http/brand-api.http b/http/brand-api.http new file mode 100644 index 000000000..f21596f54 --- /dev/null +++ b/http/brand-api.http @@ -0,0 +1,5 @@ +### ๋ธŒ๋žœ๋“œ ์ƒ์„ธ ์กฐํšŒ +GET http://localhost:8080/api/v1/brands/1 +Content-Type: application/json +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! diff --git a/http/product-admin-api.http b/http/product-admin-api.http new file mode 100644 index 000000000..0e97dd5ba --- /dev/null +++ b/http/product-admin-api.http @@ -0,0 +1,42 @@ +### ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ (Admin) +GET http://localhost:8080/api-admin/v1/products?page=0&size=10 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +### ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ (Admin) +GET http://localhost:8080/api-admin/v1/products/1 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +### ์ƒํ’ˆ ๋“ฑ๋ก (Admin) +POST http://localhost:8080/api-admin/v1/products +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "brandId": 1, + "name": "Air Max 90", + "description": "Classic sneakers", + "price": 150000, + "stock": 100, + "imageUrl": "https://example.com/airmax90.png" +} + +### ์ƒํ’ˆ ์ˆ˜์ • (Admin) +PUT http://localhost:8080/api-admin/v1/products/1 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "brandId": 1, + "name": "Air Max 90 Updated", + "description": "Updated description", + "price": 160000, + "stock": 50, + "imageUrl": "https://example.com/airmax90-updated.png" +} + +### ์ƒํ’ˆ ์‚ญ์ œ (Admin) +DELETE http://localhost:8080/api-admin/v1/products/1 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin diff --git a/http/product-api.http b/http/product-api.http new file mode 100644 index 000000000..a51643b32 --- /dev/null +++ b/http/product-api.http @@ -0,0 +1,17 @@ +### ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ +GET http://localhost:8080/api/v1/products?page=0&size=10 +Content-Type: application/json +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ (๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ) +GET http://localhost:8080/api/v1/products?brandId=1&page=0&size=10 +Content-Type: application/json +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ +GET http://localhost:8080/api/v1/products/1 +Content-Type: application/json +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! From 72a6501dac53d845c959121164590297d00349db Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 27 Feb 2026 13:55:51 +0900 Subject: [PATCH 22/29] =?UTF-8?q?docs:=20DDD=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EB=AA=85=EC=84=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - domain-implements-task.md: ์ˆœ์ˆ˜ DDD ๊ตฌํ˜„ ๊ณ„ํš์„œ - Domain Layer ์ˆœ์ˆ˜ Java ์›์น™ - ์• ๊ทธ๋ฆฌ๊ฒŒ์ž‡ ์„ค๊ณ„ (Order, Product, Brand, Like) - Value Object ์„ค๊ณ„ (Money, Stock, Quantity) - Repository Interface/Impl ๋ถ„๋ฆฌ - Fake Repository ํ…Œ์ŠคํŠธ ์ „๋žต Co-Authored-By: Claude Opus 4.5 --- .docs/prompt/domain-implements-task.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .docs/prompt/domain-implements-task.md diff --git a/.docs/prompt/domain-implements-task.md b/.docs/prompt/domain-implements-task.md new file mode 100644 index 000000000..e69de29bb From 3db16d34301329765fde8b57b2ed202e0d17d0e1 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 27 Feb 2026 14:00:46 +0900 Subject: [PATCH 23/29] =?UTF-8?q?feat(support):=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=EB=B3=84=20=EC=97=90=EB=9F=AC=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brand: - BRAND_NOT_FOUND: ๋ธŒ๋žœ๋“œ ๋ฏธ์กด์žฌ - BRAND_ALREADY_EXISTS: ๋ธŒ๋žœ๋“œ๋ช… ์ค‘๋ณต - BRAND_DELETED: ์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ Product: - PRODUCT_NOT_FOUND: ์ƒํ’ˆ ๋ฏธ์กด์žฌ - PRODUCT_DELETED: ์‚ญ์ œ๋œ ์ƒํ’ˆ - INSUFFICIENT_STOCK: ์žฌ๊ณ  ๋ถ€์กฑ Order: - ORDER_NOT_FOUND: ์ฃผ๋ฌธ ๋ฏธ์กด์žฌ - ORDER_ACCESS_DENIED: ์ฃผ๋ฌธ ์ ‘๊ทผ ๊ถŒํ•œ ์—†์Œ Admin: - ADMIN_UNAUTHORIZED: ๊ด€๋ฆฌ์ž ๊ถŒํ•œ ํ•„์š” Co-Authored-By: Claude Opus 4.5 --- .../com/loopers/support/error/ErrorType.java | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index d64c6b491..e77f8f407 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -16,7 +16,24 @@ public enum ErrorType { /** ์ธ์ฆ ๊ด€๋ จ ์—๋Ÿฌ */ UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "์ธ์ฆ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), USER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "USER_NOT_FOUND", "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค."), - PASSWORD_MISMATCH(HttpStatus.UNAUTHORIZED, "PASSWORD_MISMATCH", "๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + PASSWORD_MISMATCH(HttpStatus.UNAUTHORIZED, "PASSWORD_MISMATCH", "๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + + /** ๋ธŒ๋žœ๋“œ ๊ด€๋ จ ์—๋Ÿฌ */ + BRAND_NOT_FOUND(HttpStatus.NOT_FOUND, "BRAND_NOT_FOUND", "์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ธŒ๋žœ๋“œ์ž…๋‹ˆ๋‹ค."), + BRAND_ALREADY_EXISTS(HttpStatus.CONFLICT, "BRAND_ALREADY_EXISTS", "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ธŒ๋žœ๋“œ๋ช…์ž…๋‹ˆ๋‹ค."), + BRAND_DELETED(HttpStatus.BAD_REQUEST, "BRAND_DELETED", "์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ์ž…๋‹ˆ๋‹ค."), + + /** ์ƒํ’ˆ ๊ด€๋ จ ์—๋Ÿฌ */ + PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "PRODUCT_NOT_FOUND", "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค."), + PRODUCT_DELETED(HttpStatus.BAD_REQUEST, "PRODUCT_DELETED", "์‚ญ์ œ๋œ ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค."), + INSUFFICIENT_STOCK(HttpStatus.BAD_REQUEST, "INSUFFICIENT_STOCK", "์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."), + + /** ์ฃผ๋ฌธ ๊ด€๋ จ ์—๋Ÿฌ */ + ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "ORDER_NOT_FOUND", "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ฃผ๋ฌธ์ž…๋‹ˆ๋‹ค."), + ORDER_ACCESS_DENIED(HttpStatus.FORBIDDEN, "ORDER_ACCESS_DENIED", "์ฃผ๋ฌธ ์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."), + + /** ๊ด€๋ฆฌ์ž ๊ด€๋ จ ์—๋Ÿฌ */ + ADMIN_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "ADMIN_UNAUTHORIZED", "๊ด€๋ฆฌ์ž ๊ถŒํ•œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."); private final HttpStatus status; private final String code; From 46c90c4511b9b3931f0b8183defd6eca7d4777cd Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 6 Mar 2026 15:27:18 +0900 Subject: [PATCH 24/29] =?UTF-8?q?feat(domain):=20=EC=BF=A0=ED=8F=B0/?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CouponTemplate: ์ฟ ํฐ ์ •์˜ Aggregate Root (์ •์•ก/์ •๋ฅ  ํƒ€์ž…) - IssuedCoupon: ๋ฐœ๊ธ‰๋œ ์ฟ ํฐ ์—”ํ‹ฐํ‹ฐ (์‚ฌ์šฉ/๋งŒ๋ฃŒ ์ƒํƒœ ๊ด€๋ฆฌ) - CouponDiscountPolicy: ํ• ์ธ ๊ณ„์‚ฐ ์ „๋žต ํŒจํ„ด - UserPoint: ํฌ์ธํŠธ ๋„๋ฉ”์ธ (์ถฉ์ „/์‚ฌ์šฉ) - Money.subtract(): ํ• ์ธ ๊ณ„์‚ฐ์šฉ ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€ Co-Authored-By: Claude Opus 4.5 --- .../skills/analyze-query/SKILL.md | 0 .../java/com/loopers/domain/common/Money.java | 11 + .../domain/coupon/CouponDiscountPolicy.java | 19 ++ .../coupon/CouponDiscountPolicyFactory.java | 29 ++ .../domain/coupon/CouponDisplayStatus.java | 11 + .../loopers/domain/coupon/CouponTemplate.java | 203 +++++++++++++ .../coupon/CouponTemplateRepository.java | 22 ++ .../com/loopers/domain/coupon/CouponType.java | 11 + .../coupon/FixedCouponDiscountPolicy.java | 15 + .../loopers/domain/coupon/IssuedCoupon.java | 131 ++++++++ .../domain/coupon/IssuedCouponRepository.java | 28 ++ .../domain/coupon/IssuedCouponStatus.java | 11 + .../coupon/RateCouponDiscountPolicy.java | 19 ++ .../com/loopers/domain/point/UserPoint.java | 108 +++++++ .../domain/point/UserPointRepository.java | 15 + .../com/loopers/domain/common/MoneyTest.java | 62 ++++ .../coupon/CouponDiscountPolicyTest.java | 150 +++++++++ .../domain/coupon/CouponTemplateTest.java | 285 ++++++++++++++++++ .../domain/coupon/IssuedCouponTest.java | 221 ++++++++++++++ .../loopers/domain/point/UserPointTest.java | 222 ++++++++++++++ 20 files changed, 1573 insertions(+) rename .docs/prompt/domain-implements-task.md => .claude/skills/analyze-query/SKILL.md (100%) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDiscountPolicy.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDiscountPolicyFactory.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDisplayStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponTemplate.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponTemplateRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/FixedCouponDiscountPolicy.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/IssuedCoupon.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/IssuedCouponRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/IssuedCouponStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/RateCouponDiscountPolicy.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/UserPoint.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/UserPointRepository.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponDiscountPolicyTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTemplateTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/coupon/IssuedCouponTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/point/UserPointTest.java diff --git a/.docs/prompt/domain-implements-task.md b/.claude/skills/analyze-query/SKILL.md similarity index 100% rename from .docs/prompt/domain-implements-task.md rename to .claude/skills/analyze-query/SKILL.md diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java index 70f90feb3..1aa229460 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java @@ -40,4 +40,15 @@ public Money multiply(int quantity) { } return new Money(this.amount * quantity); } + + /** + * ๊ธˆ์•ก์„ ๋บ€๋‹ค. + * + * @param other ๋บ„ ๊ธˆ์•ก + * @return ์ฐจ๊ฐ๋œ ๊ธˆ์•ก + * @throws CoreException ๊ฒฐ๊ณผ๊ฐ€ ์Œ์ˆ˜์ธ ๊ฒฝ์šฐ + */ + public Money subtract(Money other) { + return new Money(this.amount - other.amount); + } } \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDiscountPolicy.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDiscountPolicy.java new file mode 100644 index 000000000..06600ffb7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDiscountPolicy.java @@ -0,0 +1,19 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.common.Money; + +/** + * ์ฟ ํฐ ํ• ์ธ ์ •์ฑ… ์ธํ„ฐํŽ˜์ด์Šค. + */ +public interface CouponDiscountPolicy { + + /** + * ํ• ์ธ ๊ธˆ์•ก ๊ณ„์‚ฐ. + * + * @param orderAmount ์ฃผ๋ฌธ ๊ธˆ์•ก + * @param couponValue ์ฟ ํฐ ๊ฐ’ (์ •์•ก: ๊ธˆ์•ก, ์ •๋ฅ : ํผ์„ผํŠธ*100) + * @param maxDiscountAmount ์ตœ๋Œ€ ํ• ์ธ ๊ธˆ์•ก (์ •๋ฅ  ์ „์šฉ, nullable) + * @return ํ• ์ธ ๊ธˆ์•ก + */ + Money calculateDiscount(Money orderAmount, Money couponValue, Integer maxDiscountAmount); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDiscountPolicyFactory.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDiscountPolicyFactory.java new file mode 100644 index 000000000..0efbf3c09 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDiscountPolicyFactory.java @@ -0,0 +1,29 @@ +package com.loopers.domain.coupon; + +import java.util.EnumMap; +import java.util.Map; + +/** + * ์ฟ ํฐ ํƒ€์ž…์— ๋”ฐ๋ฅธ ํ• ์ธ ์ •์ฑ… ํŒฉํ† ๋ฆฌ. + */ +public class CouponDiscountPolicyFactory { + + private static final Map POLICIES = new EnumMap<>(CouponType.class); + + static { + POLICIES.put(CouponType.FIXED, new FixedCouponDiscountPolicy()); + POLICIES.put(CouponType.RATE, new RateCouponDiscountPolicy()); + } + + private CouponDiscountPolicyFactory() {} + + /** + * ์ฟ ํฐ ํƒ€์ž…์— ๋งž๋Š” ํ• ์ธ ์ •์ฑ… ๋ฐ˜ํ™˜. + * + * @param type ์ฟ ํฐ ํƒ€์ž… + * @return ํ• ์ธ ์ •์ฑ… + */ + public static CouponDiscountPolicy getPolicy(CouponType type) { + return POLICIES.get(type); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDisplayStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDisplayStatus.java new file mode 100644 index 000000000..bb580b281 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDisplayStatus.java @@ -0,0 +1,11 @@ +package com.loopers.domain.coupon; + +/** + * ์ฟ ํฐ ํ‘œ์‹œ ์ƒํƒœ. + * API ์‘๋‹ต์—์„œ ์‚ฌ์šฉ์ž์—๊ฒŒ ๋ณด์—ฌ์ค„ ์ƒํƒœ. + */ +public enum CouponDisplayStatus { + AVAILABLE, // ์‚ฌ์šฉ ๊ฐ€๋Šฅ + USED, // ์‚ฌ์šฉ ์™„๋ฃŒ + EXPIRED // ๋งŒ๋ฃŒ๋จ +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponTemplate.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponTemplate.java new file mode 100644 index 000000000..42eae67e7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponTemplate.java @@ -0,0 +1,203 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.ZonedDateTime; + +/** + * ์ฟ ํฐ ํ…œํ”Œ๋ฆฟ ๋„๋ฉ”์ธ ์—”ํ‹ฐํ‹ฐ (Aggregate Root). + * ๊ด€๋ฆฌ์ž๊ฐ€ ์ƒ์„ฑํ•˜๋Š” ์ฟ ํฐ ์ •์˜. + */ +public class CouponTemplate { + + private Long id; + private String name; + private CouponType type; + private Money value; + private Money minOrderAmount; + private Integer maxDiscountAmount; + private Integer maxIssueCount; + private int issuedCount; + private ZonedDateTime expiredAt; + private ZonedDateTime createdAt; + private ZonedDateTime deletedAt; + + private CouponTemplate() {} + + /** + * ์ •์•ก ํ• ์ธ ์ฟ ํฐ ์ƒ์„ฑ. + * + * @param name ์ฟ ํฐ๋ช… + * @param value ํ• ์ธ ๊ธˆ์•ก + * @param minOrderAmount ์ตœ์†Œ ์ฃผ๋ฌธ ๊ธˆ์•ก + * @param maxIssueCount ์ตœ๋Œ€ ๋ฐœ๊ธ‰ ์ˆ˜ (null์ด๋ฉด ๋ฌด์ œํ•œ) + * @param expiredAt ๋งŒ๋ฃŒ์ผ์‹œ + * @return ์ƒ์„ฑ๋œ ์ฟ ํฐ ํ…œํ”Œ๋ฆฟ + */ + public static CouponTemplate createFixed(String name, Money value, Money minOrderAmount, + Integer maxIssueCount, ZonedDateTime expiredAt) { + CouponTemplate template = new CouponTemplate(); + template.name = name; + template.type = CouponType.FIXED; + template.value = value; + template.minOrderAmount = minOrderAmount; + template.maxDiscountAmount = null; + template.maxIssueCount = maxIssueCount; + template.issuedCount = 0; + template.expiredAt = expiredAt; + template.createdAt = ZonedDateTime.now(); + template.deletedAt = null; + return template; + } + + /** + * ์ •๋ฅ  ํ• ์ธ ์ฟ ํฐ ์ƒ์„ฑ. + * + * @param name ์ฟ ํฐ๋ช… + * @param ratePercent ํ• ์ธ์œจ (1~100) + * @param minOrderAmount ์ตœ์†Œ ์ฃผ๋ฌธ ๊ธˆ์•ก + * @param maxDiscountAmount ์ตœ๋Œ€ ํ• ์ธ ๊ธˆ์•ก (null์ด๋ฉด ๋ฌด์ œํ•œ) + * @param maxIssueCount ์ตœ๋Œ€ ๋ฐœ๊ธ‰ ์ˆ˜ (null์ด๋ฉด ๋ฌด์ œํ•œ) + * @param expiredAt ๋งŒ๋ฃŒ์ผ์‹œ + * @return ์ƒ์„ฑ๋œ ์ฟ ํฐ ํ…œํ”Œ๋ฆฟ + */ + public static CouponTemplate createRate(String name, int ratePercent, Money minOrderAmount, + Integer maxDiscountAmount, Integer maxIssueCount, ZonedDateTime expiredAt) { + CouponTemplate template = new CouponTemplate(); + template.name = name; + template.type = CouponType.RATE; + template.value = new Money(ratePercent * 100L); // ํผ์„ผํŠธ * 100 + template.minOrderAmount = minOrderAmount; + template.maxDiscountAmount = maxDiscountAmount; + template.maxIssueCount = maxIssueCount; + template.issuedCount = 0; + template.expiredAt = expiredAt; + template.createdAt = ZonedDateTime.now(); + template.deletedAt = null; + return template; + } + + /** + * DB์—์„œ ๋ณต์› (Infrastructure์—์„œ ์‚ฌ์šฉ). + */ + public static CouponTemplate reconstitute(Long id, String name, CouponType type, Money value, + Money minOrderAmount, Integer maxDiscountAmount, Integer maxIssueCount, + int issuedCount, ZonedDateTime expiredAt, ZonedDateTime createdAt, ZonedDateTime deletedAt) { + CouponTemplate template = new CouponTemplate(); + template.id = id; + template.name = name; + template.type = type; + template.value = value; + template.minOrderAmount = minOrderAmount; + template.maxDiscountAmount = maxDiscountAmount; + template.maxIssueCount = maxIssueCount; + template.issuedCount = issuedCount; + template.expiredAt = expiredAt; + template.createdAt = createdAt; + template.deletedAt = deletedAt; + return template; + } + + /** + * ์ฟ ํฐ ๋ฐœ๊ธ‰ ๊ฐ€๋Šฅ ์—ฌ๋ถ€. + */ + public boolean canIssue() { + if (maxIssueCount == null) { + return true; + } + return issuedCount < maxIssueCount; + } + + /** + * ๋ฐœ๊ธ‰ ์ˆ˜ ์ฆ๊ฐ€. + * + * @throws CoreException ๋ฐœ๊ธ‰ ๋ถˆ๊ฐ€๋Šฅํ•œ ๊ฒฝ์šฐ + */ + public void incrementIssuedCount() { + if (!canIssue()) { + throw new CoreException(ErrorType.COUPON_EXHAUSTED); + } + issuedCount++; + } + + /** + * ํ• ์ธ ๊ธˆ์•ก ๊ณ„์‚ฐ. + * + * @param orderAmount ์ฃผ๋ฌธ ๊ธˆ์•ก + * @return ํ• ์ธ ๊ธˆ์•ก + */ + public Money calculateDiscount(Money orderAmount) { + if (type == CouponType.FIXED) { + long discount = Math.min(value.amount(), orderAmount.amount()); + return new Money(discount); + } else { + // RATE: value๋Š” ํผ์„ผํŠธ * 100 (์˜ˆ: 10% = 1000) + long discount = orderAmount.amount() * value.amount() / 10000; + if (maxDiscountAmount != null && discount > maxDiscountAmount) { + discount = maxDiscountAmount; + } + return new Money(discount); + } + } + + /** + * ์ฟ ํฐ ์‚ญ์ œ (soft delete). + */ + public void delete() { + this.deletedAt = ZonedDateTime.now(); + } + + /** + * ์‚ญ์ œ ์—ฌ๋ถ€ ํ™•์ธ. + */ + public boolean isDeleted() { + return deletedAt != null; + } + + // Getters + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public CouponType getType() { + return type; + } + + public Money getValue() { + return value; + } + + public Money getMinOrderAmount() { + return minOrderAmount; + } + + public Integer getMaxDiscountAmount() { + return maxDiscountAmount; + } + + public Integer getMaxIssueCount() { + return maxIssueCount; + } + + public int getIssuedCount() { + return issuedCount; + } + + public ZonedDateTime getExpiredAt() { + return expiredAt; + } + + public ZonedDateTime getCreatedAt() { + return createdAt; + } + + public ZonedDateTime getDeletedAt() { + return deletedAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponTemplateRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponTemplateRepository.java new file mode 100644 index 000000000..56e8b525c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponTemplateRepository.java @@ -0,0 +1,22 @@ +package com.loopers.domain.coupon; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +/** + * CouponTemplate Repository ์ธํ„ฐํŽ˜์ด์Šค. + */ +public interface CouponTemplateRepository { + + CouponTemplate save(CouponTemplate template); + + Optional findById(Long id); + + Optional findByIdActive(Long id); + + Optional findByIdWithLock(Long id); + + Page findAllActive(Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java new file mode 100644 index 000000000..df6ad8f2d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java @@ -0,0 +1,11 @@ +package com.loopers.domain.coupon; + +/** + * ์ฟ ํฐ ํ• ์ธ ์œ ํ˜•. + */ +public enum CouponType { + /** ์ •์•ก ํ• ์ธ */ + FIXED, + /** ์ •๋ฅ  ํ• ์ธ (ํผ์„ผํŠธ) */ + RATE +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/FixedCouponDiscountPolicy.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/FixedCouponDiscountPolicy.java new file mode 100644 index 000000000..3a80043be --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/FixedCouponDiscountPolicy.java @@ -0,0 +1,15 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.common.Money; + +/** + * ์ •์•ก ํ• ์ธ ์ •์ฑ…. + */ +public class FixedCouponDiscountPolicy implements CouponDiscountPolicy { + + @Override + public Money calculateDiscount(Money orderAmount, Money couponValue, Integer maxDiscountAmount) { + long discount = Math.min(orderAmount.amount(), couponValue.amount()); + return new Money(discount); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/IssuedCoupon.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/IssuedCoupon.java new file mode 100644 index 000000000..eac6bcd63 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/IssuedCoupon.java @@ -0,0 +1,131 @@ +package com.loopers.domain.coupon; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.ZonedDateTime; + +/** + * ๋ฐœ๊ธ‰๋œ ์ฟ ํฐ ๋„๋ฉ”์ธ ์—”ํ‹ฐํ‹ฐ. + * ์‚ฌ์šฉ์ž์—๊ฒŒ ๋ฐœ๊ธ‰๋œ ์ฟ ํฐ ์ธ์Šคํ„ด์Šค. + */ +public class IssuedCoupon { + + private Long id; + private Long userId; + private Long couponTemplateId; + private IssuedCouponStatus status; + private ZonedDateTime usedAt; + private ZonedDateTime issuedAt; + private ZonedDateTime expiredAt; + + private IssuedCoupon() {} + + /** + * ์ƒˆ ์ฟ ํฐ ๋ฐœ๊ธ‰. + * + * @param userId ์‚ฌ์šฉ์ž ID + * @param template ์ฟ ํฐ ํ…œํ”Œ๋ฆฟ + * @return ๋ฐœ๊ธ‰๋œ ์ฟ ํฐ + */ + public static IssuedCoupon create(Long userId, CouponTemplate template) { + IssuedCoupon coupon = new IssuedCoupon(); + coupon.userId = userId; + coupon.couponTemplateId = template.getId(); + coupon.status = IssuedCouponStatus.AVAILABLE; + coupon.usedAt = null; + coupon.issuedAt = ZonedDateTime.now(); + coupon.expiredAt = template.getExpiredAt(); + return coupon; + } + + /** + * DB์—์„œ ๋ณต์› (Infrastructure์—์„œ ์‚ฌ์šฉ). + */ + public static IssuedCoupon reconstitute(Long id, Long userId, Long couponTemplateId, + IssuedCouponStatus status, ZonedDateTime usedAt, + ZonedDateTime issuedAt, ZonedDateTime expiredAt) { + IssuedCoupon coupon = new IssuedCoupon(); + coupon.id = id; + coupon.userId = userId; + coupon.couponTemplateId = couponTemplateId; + coupon.status = status; + coupon.usedAt = usedAt; + coupon.issuedAt = issuedAt; + coupon.expiredAt = expiredAt; + return coupon; + } + + /** + * ์‚ฌ์šฉ ๊ฐ€๋Šฅ ์—ฌ๋ถ€. + */ + public boolean isUsable() { + return status == IssuedCouponStatus.AVAILABLE && !isExpired(); + } + + /** + * ๋งŒ๋ฃŒ ์—ฌ๋ถ€. + */ + public boolean isExpired() { + return ZonedDateTime.now().isAfter(expiredAt); + } + + /** + * ํ‘œ์‹œ ์ƒํƒœ ์กฐํšŒ. + * USED > EXPIRED > AVAILABLE ์šฐ์„ ์ˆœ์œ„๋กœ ๊ฒฐ์ •. + */ + public CouponDisplayStatus getDisplayStatus() { + if (status == IssuedCouponStatus.USED) { + return CouponDisplayStatus.USED; + } + if (isExpired()) { + return CouponDisplayStatus.EXPIRED; + } + return CouponDisplayStatus.AVAILABLE; + } + + /** + * ์ฟ ํฐ ์‚ฌ์šฉ. + * + * @throws CoreException ์ด๋ฏธ ์‚ฌ์šฉ๋˜์—ˆ๊ฑฐ๋‚˜ ๋งŒ๋ฃŒ๋œ ๊ฒฝ์šฐ + */ + public void use() { + if (status == IssuedCouponStatus.USED) { + throw new CoreException(ErrorType.COUPON_ALREADY_USED); + } + if (isExpired()) { + throw new CoreException(ErrorType.COUPON_EXPIRED); + } + this.status = IssuedCouponStatus.USED; + this.usedAt = ZonedDateTime.now(); + } + + // Getters + public Long getId() { + return id; + } + + public Long getUserId() { + return userId; + } + + public Long getCouponTemplateId() { + return couponTemplateId; + } + + public IssuedCouponStatus getStatus() { + return status; + } + + public ZonedDateTime getUsedAt() { + return usedAt; + } + + public ZonedDateTime getIssuedAt() { + return issuedAt; + } + + public ZonedDateTime getExpiredAt() { + return expiredAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/IssuedCouponRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/IssuedCouponRepository.java new file mode 100644 index 000000000..491a3ff4f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/IssuedCouponRepository.java @@ -0,0 +1,28 @@ +package com.loopers.domain.coupon; + +import java.util.List; +import java.util.Optional; + +/** + * IssuedCoupon Repository ์ธํ„ฐํŽ˜์ด์Šค. + */ +public interface IssuedCouponRepository { + + IssuedCoupon save(IssuedCoupon coupon); + + Optional findById(Long id); + + Optional findByIdWithLock(Long id); + + Optional findByIdAndUserId(Long id, Long userId); + + List findAllByUserId(Long userId, int offset, int limit); + + boolean existsByUserIdAndCouponTemplateId(Long userId, Long couponTemplateId); + + long countByUserId(Long userId); + + List findAllByCouponTemplateId(Long couponTemplateId, int offset, int limit); + + long countByCouponTemplateId(Long couponTemplateId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/IssuedCouponStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/IssuedCouponStatus.java new file mode 100644 index 000000000..ae1df6173 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/IssuedCouponStatus.java @@ -0,0 +1,11 @@ +package com.loopers.domain.coupon; + +/** + * ๋ฐœ๊ธ‰๋œ ์ฟ ํฐ์˜ ์ƒํƒœ. + */ +public enum IssuedCouponStatus { + /** ์‚ฌ์šฉ ๊ฐ€๋Šฅ */ + AVAILABLE, + /** ์‚ฌ์šฉ๋จ */ + USED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/RateCouponDiscountPolicy.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/RateCouponDiscountPolicy.java new file mode 100644 index 000000000..de07b044f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/RateCouponDiscountPolicy.java @@ -0,0 +1,19 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.common.Money; + +/** + * ์ •๋ฅ  ํ• ์ธ ์ •์ฑ…. + */ +public class RateCouponDiscountPolicy implements CouponDiscountPolicy { + + @Override + public Money calculateDiscount(Money orderAmount, Money rateValue, Integer maxDiscountAmount) { + // rateValue๋Š” ํผ์„ผํŠธ * 100 (์˜ˆ: 10% = 1000) + long discount = orderAmount.amount() * rateValue.amount() / 10000; + if (maxDiscountAmount != null && discount > maxDiscountAmount) { + discount = maxDiscountAmount; + } + return new Money(discount); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/UserPoint.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/UserPoint.java new file mode 100644 index 000000000..9d9317e7c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/UserPoint.java @@ -0,0 +1,108 @@ +package com.loopers.domain.point; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.ZonedDateTime; + +/** + * ์‚ฌ์šฉ์ž ํฌ์ธํŠธ ๋„๋ฉ”์ธ ์—”ํ‹ฐํ‹ฐ. + */ +public class UserPoint { + + private Long id; + private Long userId; + private long balance; + private ZonedDateTime updatedAt; + + private UserPoint() {} + + /** + * ์ƒˆ ํฌ์ธํŠธ ์ƒ์„ฑ. + * + * @param userId ์‚ฌ์šฉ์ž ID + * @param initialBalance ์ดˆ๊ธฐ ์ž”์•ก + * @return ์ƒ์„ฑ๋œ UserPoint + * @throws CoreException ์ดˆ๊ธฐ ์ž”์•ก์ด ์Œ์ˆ˜์ธ ๊ฒฝ์šฐ + */ + public static UserPoint create(Long userId, long initialBalance) { + if (initialBalance < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ž”์•ก์€ 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + UserPoint point = new UserPoint(); + point.userId = userId; + point.balance = initialBalance; + point.updatedAt = ZonedDateTime.now(); + return point; + } + + /** + * DB์—์„œ ๋ณต์› (Infrastructure์—์„œ ์‚ฌ์šฉ). + */ + public static UserPoint reconstitute(Long id, Long userId, long balance, ZonedDateTime updatedAt) { + UserPoint point = new UserPoint(); + point.id = id; + point.userId = userId; + point.balance = balance; + point.updatedAt = updatedAt; + return point; + } + + /** + * ํฌ์ธํŠธ ์ถฉ์ „. + * + * @param amount ์ถฉ์ „ ๊ธˆ์•ก + * @throws CoreException ์ถฉ์ „ ๊ธˆ์•ก์ด 0 ์ดํ•˜์ธ ๊ฒฝ์šฐ + */ + public void charge(long amount) { + if (amount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ถฉ์ „ ๊ธˆ์•ก์€ 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + this.balance += amount; + this.updatedAt = ZonedDateTime.now(); + } + + /** + * ํฌ์ธํŠธ ์‚ฌ์šฉ. + * + * @param amount ์‚ฌ์šฉ ๊ธˆ์•ก + * @throws CoreException ์‚ฌ์šฉ ๊ธˆ์•ก์ด 0 ์ดํ•˜์ด๊ฑฐ๋‚˜ ์ž”์•ก ๋ถ€์กฑ์ธ ๊ฒฝ์šฐ + */ + public void use(long amount) { + if (amount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ ๊ธˆ์•ก์€ 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (!hasEnough(amount)) { + throw new CoreException(ErrorType.INSUFFICIENT_POINT); + } + this.balance -= amount; + this.updatedAt = ZonedDateTime.now(); + } + + /** + * ์ž”์•ก ์ถฉ๋ถ„ ์—ฌ๋ถ€ ํ™•์ธ. + * + * @param amount ํ™•์ธํ•  ๊ธˆ์•ก + * @return ์ถฉ๋ถ„ ์—ฌ๋ถ€ + */ + public boolean hasEnough(long amount) { + return balance >= amount; + } + + // Getters + public Long getId() { + return id; + } + + public Long getUserId() { + return userId; + } + + public long getBalance() { + return balance; + } + + public ZonedDateTime getUpdatedAt() { + return updatedAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/UserPointRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/UserPointRepository.java new file mode 100644 index 000000000..105c04fc4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/UserPointRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.point; + +import java.util.Optional; + +/** + * UserPoint Repository ์ธํ„ฐํŽ˜์ด์Šค. + */ +public interface UserPointRepository { + + UserPoint save(UserPoint point); + + Optional findByUserId(Long userId); + + Optional findByUserIdWithLock(Long userId); +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java index e6a0b6370..8f8ffc670 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java @@ -180,6 +180,68 @@ void originalMoneyRemainsUnchanged() { } } + @DisplayName("๊ธˆ์•ก์„ ๋บ„ ๋•Œ,") + @Nested + class Subtract { + + @DisplayName("ํฐ ๊ธˆ์•ก์—์„œ ์ž‘์€ ๊ธˆ์•ก์„ ๋นผ๋ฉด, ์ฐจ์•ก์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnsSubtractedMoney_whenSubtractingSmaller() { + // arrange + Money money1 = new Money(10000); + Money money2 = new Money(3000); + + // act + Money result = money1.subtract(money2); + + // assert + assertThat(result.amount()).isEqualTo(7000); + } + + @DisplayName("๊ฐ™์€ ๊ธˆ์•ก์„ ๋นผ๋ฉด, ZERO๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnsZero_whenSubtractingSameAmount() { + // arrange + Money money = new Money(5000); + + // act + Money result = money.subtract(money); + + // assert + assertThat(result.amount()).isEqualTo(0); + } + + @DisplayName("๊ฒฐ๊ณผ๊ฐ€ ์Œ์ˆ˜๊ฐ€ ๋˜๋ฉด, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsException_whenResultIsNegative() { + // arrange + Money money1 = new Money(3000); + Money money2 = new Money(5000); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + money1.subtract(money2); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("์›๋ณธ Money๋Š” ๋ณ€๊ฒฝ๋˜์ง€ ์•Š๋Š”๋‹ค. (๋ถˆ๋ณ€์„ฑ)") + @Test + void originalMoneyRemainsUnchanged() { + // arrange + Money original = new Money(10000); + + // act + Money subtracted = original.subtract(new Money(3000)); + + // assert + assertThat(original.amount()).isEqualTo(10000); + assertThat(subtracted.amount()).isEqualTo(7000); + } + } + @DisplayName("ZERO ์ƒ์ˆ˜๋Š”,") @Nested class ZeroConstant { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponDiscountPolicyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponDiscountPolicyTest.java new file mode 100644 index 000000000..3e929066a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponDiscountPolicyTest.java @@ -0,0 +1,150 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.common.Money; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CouponDiscountPolicyTest { + + @DisplayName("์ •์•ก ํ• ์ธ ์ •์ฑ…์„ ์ ์šฉํ•  ๋•Œ,") + @Nested + class FixedDiscountPolicyTest { + + private final CouponDiscountPolicy policy = new FixedCouponDiscountPolicy(); + + @DisplayName("์ฃผ๋ฌธ ๊ธˆ์•ก์ด ์ฟ ํฐ ๊ธˆ์•ก๋ณด๋‹ค ํฌ๋ฉด, ์ฟ ํฐ ๊ธˆ์•ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsCouponValue_whenOrderAmountIsGreater() { + // arrange + Money orderAmount = new Money(20000); + Money couponValue = new Money(5000); + + // act + Money discount = policy.calculateDiscount(orderAmount, couponValue, null); + + // assert + assertThat(discount.amount()).isEqualTo(5000); + } + + @DisplayName("์ฃผ๋ฌธ ๊ธˆ์•ก์ด ์ฟ ํฐ ๊ธˆ์•ก๋ณด๋‹ค ์ž‘์œผ๋ฉด, ์ฃผ๋ฌธ ๊ธˆ์•ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsOrderAmount_whenOrderAmountIsLess() { + // arrange + Money orderAmount = new Money(3000); + Money couponValue = new Money(5000); + + // act + Money discount = policy.calculateDiscount(orderAmount, couponValue, null); + + // assert + assertThat(discount.amount()).isEqualTo(3000); + } + + @DisplayName("์ฃผ๋ฌธ ๊ธˆ์•ก๊ณผ ์ฟ ํฐ ๊ธˆ์•ก์ด ๊ฐ™์œผ๋ฉด, ํ•ด๋‹น ๊ธˆ์•ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsSameAmount_whenEqual() { + // arrange + Money orderAmount = new Money(5000); + Money couponValue = new Money(5000); + + // act + Money discount = policy.calculateDiscount(orderAmount, couponValue, null); + + // assert + assertThat(discount.amount()).isEqualTo(5000); + } + } + + @DisplayName("์ •๋ฅ  ํ• ์ธ ์ •์ฑ…์„ ์ ์šฉํ•  ๋•Œ,") + @Nested + class RateDiscountPolicyTest { + + private final CouponDiscountPolicy policy = new RateCouponDiscountPolicy(); + + @DisplayName("์ตœ๋Œ€ ํ• ์ธ ๊ธˆ์•ก์ด ์—†์œผ๋ฉด, ๋น„์œจ๋Œ€๋กœ ํ• ์ธํ•œ๋‹ค.") + @Test + void returnsRateDiscount_whenNoMaxLimit() { + // arrange + Money orderAmount = new Money(30000); + Money rateValue = new Money(1000); // 10% = 1000 + + // act + Money discount = policy.calculateDiscount(orderAmount, rateValue, null); + + // assert + assertThat(discount.amount()).isEqualTo(3000); // 30000 * 10% + } + + @DisplayName("ํ• ์ธ์•ก์ด ์ตœ๋Œ€ ํ• ์ธ ๊ธˆ์•ก๋ณด๋‹ค ์ž‘์œผ๋ฉด, ๊ณ„์‚ฐ๋œ ํ• ์ธ์•ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsCalculatedDiscount_whenBelowMaxLimit() { + // arrange + Money orderAmount = new Money(20000); + Money rateValue = new Money(1000); // 10% = 1000 + Integer maxDiscountAmount = 5000; + + // act + Money discount = policy.calculateDiscount(orderAmount, rateValue, maxDiscountAmount); + + // assert + assertThat(discount.amount()).isEqualTo(2000); // 20000 * 10% = 2000 < 5000 + } + + @DisplayName("ํ• ์ธ์•ก์ด ์ตœ๋Œ€ ํ• ์ธ ๊ธˆ์•ก๋ณด๋‹ค ํฌ๋ฉด, ์ตœ๋Œ€ ํ• ์ธ ๊ธˆ์•ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsMaxDiscount_whenAboveMaxLimit() { + // arrange + Money orderAmount = new Money(100000); + Money rateValue = new Money(2000); // 20% = 2000 + Integer maxDiscountAmount = 10000; + + // act + Money discount = policy.calculateDiscount(orderAmount, rateValue, maxDiscountAmount); + + // assert + assertThat(discount.amount()).isEqualTo(10000); // 100000 * 20% = 20000 > 10000 + } + + @DisplayName("์ฃผ๋ฌธ ๊ธˆ์•ก์ด 0์ด๋ฉด, 0์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsZero_whenOrderAmountIsZero() { + // arrange + Money orderAmount = Money.ZERO; + Money rateValue = new Money(1500); // 15% + + // act + Money discount = policy.calculateDiscount(orderAmount, rateValue, null); + + // assert + assertThat(discount.amount()).isEqualTo(0); + } + } + + @DisplayName("์ฟ ํฐ ํƒ€์ž…์— ๋”ฐ๋ผ ์ •์ฑ…์„ ์„ ํƒํ•  ๋•Œ,") + @Nested + class PolicyFactoryTest { + + @DisplayName("FIXED ํƒ€์ž…์ด๋ฉด, FixedCouponDiscountPolicy๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsFixedPolicy_forFixedType() { + // act + CouponDiscountPolicy policy = CouponDiscountPolicyFactory.getPolicy(CouponType.FIXED); + + // assert + assertThat(policy).isInstanceOf(FixedCouponDiscountPolicy.class); + } + + @DisplayName("RATE ํƒ€์ž…์ด๋ฉด, RateCouponDiscountPolicy๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsRatePolicy_forRateType() { + // act + CouponDiscountPolicy policy = CouponDiscountPolicyFactory.getPolicy(CouponType.RATE); + + // assert + assertThat(policy).isInstanceOf(RateCouponDiscountPolicy.class); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTemplateTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTemplateTest.java new file mode 100644 index 000000000..60087e6d1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTemplateTest.java @@ -0,0 +1,285 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CouponTemplateTest { + + @DisplayName("์ •์•ก ์ฟ ํฐ์„ ์ƒ์„ฑํ•  ๋•Œ,") + @Nested + class CreateFixed { + + @DisplayName("์œ ํšจํ•œ ๊ฐ’์œผ๋กœ ์ƒ์„ฑํ•˜๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsFixedCoupon_whenValuesAreValid() { + // arrange + String name = "์‹ ๊ทœ ๊ฐ€์ž… ์ฟ ํฐ"; + Money value = new Money(5000); + Money minOrderAmount = new Money(10000); + int maxIssueCount = 100; + ZonedDateTime expiredAt = ZonedDateTime.now().plusDays(30); + + // act + CouponTemplate coupon = CouponTemplate.createFixed( + name, value, minOrderAmount, maxIssueCount, expiredAt + ); + + // assert + assertThat(coupon.getName()).isEqualTo(name); + assertThat(coupon.getType()).isEqualTo(CouponType.FIXED); + assertThat(coupon.getValue()).isEqualTo(value); + assertThat(coupon.getMinOrderAmount()).isEqualTo(minOrderAmount); + assertThat(coupon.getMaxIssueCount()).isEqualTo(maxIssueCount); + assertThat(coupon.getIssuedCount()).isEqualTo(0); + assertThat(coupon.getExpiredAt()).isEqualTo(expiredAt); + } + + @DisplayName("์ตœ๋Œ€ ๋ฐœ๊ธ‰ ์ˆ˜๊ฐ€ null์ด๋ฉด, ๋ฌด์ œํ•œ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsUnlimitedCoupon_whenMaxIssueCountIsNull() { + // arrange + String name = "๋ฌด์ œํ•œ ์ฟ ํฐ"; + Money value = new Money(3000); + Money minOrderAmount = Money.ZERO; + ZonedDateTime expiredAt = ZonedDateTime.now().plusDays(7); + + // act + CouponTemplate coupon = CouponTemplate.createFixed( + name, value, minOrderAmount, null, expiredAt + ); + + // assert + assertThat(coupon.getMaxIssueCount()).isNull(); + assertThat(coupon.canIssue()).isTrue(); + } + } + + @DisplayName("์ •๋ฅ  ์ฟ ํฐ์„ ์ƒ์„ฑํ•  ๋•Œ,") + @Nested + class CreateRate { + + @DisplayName("์œ ํšจํ•œ ๊ฐ’์œผ๋กœ ์ƒ์„ฑํ•˜๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsRateCoupon_whenValuesAreValid() { + // arrange + String name = "10% ํ• ์ธ ์ฟ ํฐ"; + int ratePercent = 10; + Money minOrderAmount = new Money(20000); + Integer maxDiscountAmount = 5000; + int maxIssueCount = 50; + ZonedDateTime expiredAt = ZonedDateTime.now().plusDays(14); + + // act + CouponTemplate coupon = CouponTemplate.createRate( + name, ratePercent, minOrderAmount, maxDiscountAmount, maxIssueCount, expiredAt + ); + + // assert + assertThat(coupon.getName()).isEqualTo(name); + assertThat(coupon.getType()).isEqualTo(CouponType.RATE); + assertThat(coupon.getValue().amount()).isEqualTo(1000); // 10% = 1000 (ํผ์„ผํŠธ*100) + assertThat(coupon.getMinOrderAmount()).isEqualTo(minOrderAmount); + assertThat(coupon.getMaxDiscountAmount()).isEqualTo(maxDiscountAmount); + assertThat(coupon.getMaxIssueCount()).isEqualTo(maxIssueCount); + } + + @DisplayName("์ตœ๋Œ€ ํ• ์ธ ๊ธˆ์•ก์ด null์ด๋ฉด, ๋ฌด์ œํ•œ ํ• ์ธ์ด๋‹ค.") + @Test + void createsUnlimitedDiscountCoupon_whenMaxDiscountIsNull() { + // arrange + String name = "VIP 20% ์ฟ ํฐ"; + int ratePercent = 20; + Money minOrderAmount = new Money(50000); + ZonedDateTime expiredAt = ZonedDateTime.now().plusDays(30); + + // act + CouponTemplate coupon = CouponTemplate.createRate( + name, ratePercent, minOrderAmount, null, 10, expiredAt + ); + + // assert + assertThat(coupon.getMaxDiscountAmount()).isNull(); + } + } + + @DisplayName("์ฟ ํฐ ๋ฐœ๊ธ‰ ๊ฐ€๋Šฅ ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•  ๋•Œ,") + @Nested + class CanIssue { + + @DisplayName("๋ฐœ๊ธ‰ ์ˆ˜๊ฐ€ ์ตœ๋Œ€ ๋ฐœ๊ธ‰ ์ˆ˜ ๋ฏธ๋งŒ์ด๋ฉด, true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsTrue_whenIssuedCountIsBelowMax() { + // arrange + CouponTemplate coupon = CouponTemplate.createFixed( + "ํ…Œ์ŠคํŠธ ์ฟ ํฐ", new Money(1000), Money.ZERO, 10, ZonedDateTime.now().plusDays(1) + ); + + // act & assert + assertThat(coupon.canIssue()).isTrue(); + } + + @DisplayName("๋ฐœ๊ธ‰ ์ˆ˜๊ฐ€ ์ตœ๋Œ€ ๋ฐœ๊ธ‰ ์ˆ˜์™€ ๊ฐ™์œผ๋ฉด, false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsFalse_whenIssuedCountEqualsMax() { + // arrange + CouponTemplate coupon = CouponTemplate.reconstitute( + 1L, "ํ…Œ์ŠคํŠธ ์ฟ ํฐ", CouponType.FIXED, new Money(1000), + Money.ZERO, null, 10, 10, + ZonedDateTime.now().plusDays(1), ZonedDateTime.now(), null + ); + + // act & assert + assertThat(coupon.canIssue()).isFalse(); + } + + @DisplayName("์ตœ๋Œ€ ๋ฐœ๊ธ‰ ์ˆ˜๊ฐ€ null์ด๋ฉด, ํ•ญ์ƒ true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsTrue_whenMaxIssueCountIsNull() { + // arrange + CouponTemplate coupon = CouponTemplate.reconstitute( + 1L, "๋ฌด์ œํ•œ ์ฟ ํฐ", CouponType.FIXED, new Money(1000), + Money.ZERO, null, null, 999999, + ZonedDateTime.now().plusDays(1), ZonedDateTime.now(), null + ); + + // act & assert + assertThat(coupon.canIssue()).isTrue(); + } + } + + @DisplayName("๋ฐœ๊ธ‰ ์ˆ˜๋ฅผ ์ฆ๊ฐ€์‹œํ‚ฌ ๋•Œ,") + @Nested + class IncrementIssuedCount { + + @DisplayName("๋ฐœ๊ธ‰ ๊ฐ€๋Šฅํ•˜๋ฉด, ๋ฐœ๊ธ‰ ์ˆ˜๊ฐ€ 1 ์ฆ๊ฐ€ํ•œ๋‹ค.") + @Test + void incrementsCount_whenCanIssue() { + // arrange + CouponTemplate coupon = CouponTemplate.createFixed( + "ํ…Œ์ŠคํŠธ ์ฟ ํฐ", new Money(1000), Money.ZERO, 10, ZonedDateTime.now().plusDays(1) + ); + + // act + coupon.incrementIssuedCount(); + + // assert + assertThat(coupon.getIssuedCount()).isEqualTo(1); + } + + @DisplayName("๋ฐœ๊ธ‰ ๋ถˆ๊ฐ€๋Šฅํ•˜๋ฉด, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsException_whenCannotIssue() { + // arrange + CouponTemplate coupon = CouponTemplate.reconstitute( + 1L, "ํ…Œ์ŠคํŠธ ์ฟ ํฐ", CouponType.FIXED, new Money(1000), + Money.ZERO, null, 1, 1, + ZonedDateTime.now().plusDays(1), ZonedDateTime.now(), null + ); + + // act + CoreException result = assertThrows(CoreException.class, coupon::incrementIssuedCount); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.COUPON_EXHAUSTED); + } + } + + @DisplayName("ํ• ์ธ ๊ธˆ์•ก์„ ๊ณ„์‚ฐํ•  ๋•Œ,") + @Nested + class CalculateDiscount { + + @DisplayName("์ •์•ก ์ฟ ํฐ์ด๋ฉด, ์ฟ ํฐ ๊ธˆ์•ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsFixedAmount_forFixedCoupon() { + // arrange + CouponTemplate coupon = CouponTemplate.createFixed( + "5000์› ํ• ์ธ", new Money(5000), Money.ZERO, 10, ZonedDateTime.now().plusDays(1) + ); + Money orderAmount = new Money(20000); + + // act + Money discount = coupon.calculateDiscount(orderAmount); + + // assert + assertThat(discount.amount()).isEqualTo(5000); + } + + @DisplayName("์ •์•ก ์ฟ ํฐ์—์„œ ์ฃผ๋ฌธ๊ธˆ์•ก์ด ์ฟ ํฐ๊ธˆ์•ก๋ณด๋‹ค ์ž‘์œผ๋ฉด, ์ฃผ๋ฌธ๊ธˆ์•ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsOrderAmount_whenOrderAmountLessThanCouponValue() { + // arrange + CouponTemplate coupon = CouponTemplate.createFixed( + "5000์› ํ• ์ธ", new Money(5000), Money.ZERO, 10, ZonedDateTime.now().plusDays(1) + ); + Money orderAmount = new Money(3000); + + // act + Money discount = coupon.calculateDiscount(orderAmount); + + // assert + assertThat(discount.amount()).isEqualTo(3000); + } + + @DisplayName("์ •๋ฅ  ์ฟ ํฐ์ด๋ฉด, ๋น„์œจ์— ๋”ฐ๋ฅธ ๊ธˆ์•ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsRateAmount_forRateCoupon() { + // arrange + CouponTemplate coupon = CouponTemplate.createRate( + "10% ํ• ์ธ", 10, Money.ZERO, null, 10, ZonedDateTime.now().plusDays(1) + ); + Money orderAmount = new Money(30000); + + // act + Money discount = coupon.calculateDiscount(orderAmount); + + // assert + assertThat(discount.amount()).isEqualTo(3000); // 30000 * 10% + } + + @DisplayName("์ •๋ฅ  ์ฟ ํฐ์—์„œ ์ตœ๋Œ€ ํ• ์ธ ๊ธˆ์•ก์ด ์„ค์ •๋˜๋ฉด, ์ตœ๋Œ€ ๊ธˆ์•ก์œผ๋กœ ์ œํ•œ๋œ๋‹ค.") + @Test + void capsAtMaxDiscount_forRateCoupon() { + // arrange + CouponTemplate coupon = CouponTemplate.createRate( + "20% ํ• ์ธ (์ตœ๋Œ€ 5000์›)", 20, Money.ZERO, 5000, 10, ZonedDateTime.now().plusDays(1) + ); + Money orderAmount = new Money(50000); + + // act + Money discount = coupon.calculateDiscount(orderAmount); + + // assert + assertThat(discount.amount()).isEqualTo(5000); // 50000 * 20% = 10000 but capped at 5000 + } + } + + @DisplayName("์‚ญ์ œํ•  ๋•Œ,") + @Nested + class Delete { + + @DisplayName("deletedAt์ด ์„ค์ •๋œ๋‹ค.") + @Test + void setsDeletedAt() { + // arrange + CouponTemplate coupon = CouponTemplate.createFixed( + "์‚ญ์ œ ๋Œ€์ƒ ์ฟ ํฐ", new Money(1000), Money.ZERO, 10, ZonedDateTime.now().plusDays(1) + ); + + // act + coupon.delete(); + + // assert + assertThat(coupon.getDeletedAt()).isNotNull(); + assertThat(coupon.isDeleted()).isTrue(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/IssuedCouponTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/IssuedCouponTest.java new file mode 100644 index 000000000..eefbf291a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/IssuedCouponTest.java @@ -0,0 +1,221 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class IssuedCouponTest { + + @DisplayName("์ฟ ํฐ์„ ๋ฐœ๊ธ‰ํ•  ๋•Œ,") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ํ…œํ”Œ๋ฆฟ์œผ๋กœ ๋ฐœ๊ธ‰ํ•˜๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsIssuedCoupon_whenTemplateIsValid() { + // arrange + Long userId = 1L; + CouponTemplate template = CouponTemplate.createFixed( + "ํ…Œ์ŠคํŠธ ์ฟ ํฐ", new Money(5000), Money.ZERO, 10, ZonedDateTime.now().plusDays(7) + ); + template = CouponTemplate.reconstitute( + 100L, template.getName(), template.getType(), template.getValue(), + template.getMinOrderAmount(), template.getMaxDiscountAmount(), + template.getMaxIssueCount(), template.getIssuedCount(), + template.getExpiredAt(), template.getCreatedAt(), null + ); + + // act + IssuedCoupon coupon = IssuedCoupon.create(userId, template); + + // assert + assertThat(coupon.getUserId()).isEqualTo(userId); + assertThat(coupon.getCouponTemplateId()).isEqualTo(100L); + assertThat(coupon.getStatus()).isEqualTo(IssuedCouponStatus.AVAILABLE); + assertThat(coupon.getIssuedAt()).isNotNull(); + assertThat(coupon.getExpiredAt()).isEqualTo(template.getExpiredAt()); + assertThat(coupon.getUsedAt()).isNull(); + } + } + + @DisplayName("์ฟ ํฐ ์‚ฌ์šฉ ๊ฐ€๋Šฅ ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•  ๋•Œ,") + @Nested + class IsUsable { + + @DisplayName("์ƒํƒœ๊ฐ€ AVAILABLE์ด๊ณ  ๋งŒ๋ฃŒ ์ „์ด๋ฉด, true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsTrue_whenAvailableAndNotExpired() { + // arrange + IssuedCoupon coupon = IssuedCoupon.reconstitute( + 1L, 1L, 100L, IssuedCouponStatus.AVAILABLE, + null, ZonedDateTime.now(), ZonedDateTime.now().plusDays(1) + ); + + // act & assert + assertThat(coupon.isUsable()).isTrue(); + } + + @DisplayName("์ƒํƒœ๊ฐ€ USED์ด๋ฉด, false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsFalse_whenAlreadyUsed() { + // arrange + IssuedCoupon coupon = IssuedCoupon.reconstitute( + 1L, 1L, 100L, IssuedCouponStatus.USED, + ZonedDateTime.now(), ZonedDateTime.now().minusDays(1), ZonedDateTime.now().plusDays(1) + ); + + // act & assert + assertThat(coupon.isUsable()).isFalse(); + } + + @DisplayName("๋งŒ๋ฃŒ๋˜์—ˆ์œผ๋ฉด, false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsFalse_whenExpired() { + // arrange + IssuedCoupon coupon = IssuedCoupon.reconstitute( + 1L, 1L, 100L, IssuedCouponStatus.AVAILABLE, + null, ZonedDateTime.now().minusDays(2), ZonedDateTime.now().minusDays(1) + ); + + // act & assert + assertThat(coupon.isUsable()).isFalse(); + } + } + + @DisplayName("์ฟ ํฐ์„ ์‚ฌ์šฉํ•  ๋•Œ,") + @Nested + class Use { + + @DisplayName("์‚ฌ์šฉ ๊ฐ€๋Šฅํ•˜๋ฉด, ์ƒํƒœ๊ฐ€ USED๋กœ ๋ณ€๊ฒฝ๋œ๋‹ค.") + @Test + void changesStatusToUsed_whenUsable() { + // arrange + IssuedCoupon coupon = IssuedCoupon.reconstitute( + 1L, 1L, 100L, IssuedCouponStatus.AVAILABLE, + null, ZonedDateTime.now(), ZonedDateTime.now().plusDays(1) + ); + + // act + coupon.use(); + + // assert + assertThat(coupon.getStatus()).isEqualTo(IssuedCouponStatus.USED); + assertThat(coupon.getUsedAt()).isNotNull(); + } + + @DisplayName("์ด๋ฏธ ์‚ฌ์šฉ๋œ ์ฟ ํฐ์ด๋ฉด, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsException_whenAlreadyUsed() { + // arrange + IssuedCoupon coupon = IssuedCoupon.reconstitute( + 1L, 1L, 100L, IssuedCouponStatus.USED, + ZonedDateTime.now(), ZonedDateTime.now().minusDays(1), ZonedDateTime.now().plusDays(1) + ); + + // act + CoreException result = assertThrows(CoreException.class, coupon::use); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.COUPON_ALREADY_USED); + } + + @DisplayName("๋งŒ๋ฃŒ๋œ ์ฟ ํฐ์ด๋ฉด, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsException_whenExpired() { + // arrange + IssuedCoupon coupon = IssuedCoupon.reconstitute( + 1L, 1L, 100L, IssuedCouponStatus.AVAILABLE, + null, ZonedDateTime.now().minusDays(2), ZonedDateTime.now().minusDays(1) + ); + + // act + CoreException result = assertThrows(CoreException.class, coupon::use); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.COUPON_EXPIRED); + } + } + + @DisplayName("๋งŒ๋ฃŒ ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•  ๋•Œ,") + @Nested + class IsExpired { + + @DisplayName("ํ˜„์žฌ ์‹œ๊ฐ„์ด ๋งŒ๋ฃŒ์ผ ์ด์ „์ด๋ฉด, false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsFalse_whenBeforeExpiredAt() { + // arrange + IssuedCoupon coupon = IssuedCoupon.reconstitute( + 1L, 1L, 100L, IssuedCouponStatus.AVAILABLE, + null, ZonedDateTime.now(), ZonedDateTime.now().plusDays(1) + ); + + // act & assert + assertThat(coupon.isExpired()).isFalse(); + } + + @DisplayName("ํ˜„์žฌ ์‹œ๊ฐ„์ด ๋งŒ๋ฃŒ์ผ ์ดํ›„์ด๋ฉด, true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsTrue_whenAfterExpiredAt() { + // arrange + IssuedCoupon coupon = IssuedCoupon.reconstitute( + 1L, 1L, 100L, IssuedCouponStatus.AVAILABLE, + null, ZonedDateTime.now().minusDays(2), ZonedDateTime.now().minusDays(1) + ); + + // act & assert + assertThat(coupon.isExpired()).isTrue(); + } + } + + @DisplayName("ํ‘œ์‹œ ์ƒํƒœ๋ฅผ ์กฐํšŒํ•  ๋•Œ,") + @Nested + class GetDisplayStatus { + + @DisplayName("์ƒํƒœ๊ฐ€ USED์ด๋ฉด, USED๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsUsed_whenStatusIsUsed() { + // arrange + IssuedCoupon coupon = IssuedCoupon.reconstitute( + 1L, 1L, 100L, IssuedCouponStatus.USED, + ZonedDateTime.now(), ZonedDateTime.now().minusDays(1), ZonedDateTime.now().plusDays(1) + ); + + // act & assert + assertThat(coupon.getDisplayStatus()).isEqualTo(CouponDisplayStatus.USED); + } + + @DisplayName("์ƒํƒœ๊ฐ€ AVAILABLE์ด๊ณ  ๋งŒ๋ฃŒ๋˜์—ˆ์œผ๋ฉด, EXPIRED๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsExpired_whenAvailableButExpired() { + // arrange + IssuedCoupon coupon = IssuedCoupon.reconstitute( + 1L, 1L, 100L, IssuedCouponStatus.AVAILABLE, + null, ZonedDateTime.now().minusDays(2), ZonedDateTime.now().minusDays(1) + ); + + // act & assert + assertThat(coupon.getDisplayStatus()).isEqualTo(CouponDisplayStatus.EXPIRED); + } + + @DisplayName("์ƒํƒœ๊ฐ€ AVAILABLE์ด๊ณ  ๋งŒ๋ฃŒ ์ „์ด๋ฉด, AVAILABLE์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsAvailable_whenAvailableAndNotExpired() { + // arrange + IssuedCoupon coupon = IssuedCoupon.reconstitute( + 1L, 1L, 100L, IssuedCouponStatus.AVAILABLE, + null, ZonedDateTime.now(), ZonedDateTime.now().plusDays(1) + ); + + // act & assert + assertThat(coupon.getDisplayStatus()).isEqualTo(CouponDisplayStatus.AVAILABLE); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/UserPointTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/UserPointTest.java new file mode 100644 index 000000000..f71865dde --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/UserPointTest.java @@ -0,0 +1,222 @@ +package com.loopers.domain.point; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class UserPointTest { + + @DisplayName("ํฌ์ธํŠธ๋ฅผ ์ƒ์„ฑํ•  ๋•Œ,") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ๊ฐ’์œผ๋กœ ์ƒ์„ฑํ•˜๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsUserPoint_whenValuesAreValid() { + // arrange + Long userId = 1L; + long initialBalance = 10000L; + + // act + UserPoint point = UserPoint.create(userId, initialBalance); + + // assert + assertThat(point.getUserId()).isEqualTo(userId); + assertThat(point.getBalance()).isEqualTo(initialBalance); + assertThat(point.getUpdatedAt()).isNotNull(); + } + + @DisplayName("์ดˆ๊ธฐ ์ž”์•ก์ด 0์ด๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsUserPoint_whenInitialBalanceIsZero() { + // arrange + Long userId = 1L; + + // act + UserPoint point = UserPoint.create(userId, 0); + + // assert + assertThat(point.getBalance()).isEqualTo(0); + } + + @DisplayName("์ดˆ๊ธฐ ์ž”์•ก์ด ์Œ์ˆ˜์ด๋ฉด, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsException_whenInitialBalanceIsNegative() { + // arrange + Long userId = 1L; + long negativeBalance = -1000L; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + UserPoint.create(userId, negativeBalance); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ํ•  ๋•Œ,") + @Nested + class Charge { + + @DisplayName("์œ ํšจํ•œ ๊ธˆ์•ก์„ ์ถฉ์ „ํ•˜๋ฉด, ์ž”์•ก์ด ์ฆ๊ฐ€ํ•œ๋‹ค.") + @Test + void increasesBalance_whenChargeAmountIsValid() { + // arrange + UserPoint point = UserPoint.create(1L, 5000L); + + // act + point.charge(3000L); + + // assert + assertThat(point.getBalance()).isEqualTo(8000L); + } + + @DisplayName("์ถฉ์ „ ๊ธˆ์•ก์ด 0์ด๋ฉด, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsException_whenChargeAmountIsZero() { + // arrange + UserPoint point = UserPoint.create(1L, 5000L); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + point.charge(0); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("์ถฉ์ „ ๊ธˆ์•ก์ด ์Œ์ˆ˜์ด๋ฉด, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsException_whenChargeAmountIsNegative() { + // arrange + UserPoint point = UserPoint.create(1L, 5000L); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + point.charge(-1000); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("ํฌ์ธํŠธ๋ฅผ ์‚ฌ์šฉํ•  ๋•Œ,") + @Nested + class Use { + + @DisplayName("์ž”์•ก์ด ์ถฉ๋ถ„ํ•˜๋ฉด, ์ž”์•ก์ด ๊ฐ์†Œํ•œ๋‹ค.") + @Test + void decreasesBalance_whenSufficientBalance() { + // arrange + UserPoint point = UserPoint.create(1L, 10000L); + + // act + point.use(3000L); + + // assert + assertThat(point.getBalance()).isEqualTo(7000L); + } + + @DisplayName("์ž”์•ก์ด ์ •ํ™•ํžˆ ๊ฐ™์œผ๋ฉด, ์ž”์•ก์ด 0์ด ๋œ๋‹ค.") + @Test + void becomesZero_whenUsingExactBalance() { + // arrange + UserPoint point = UserPoint.create(1L, 5000L); + + // act + point.use(5000L); + + // assert + assertThat(point.getBalance()).isEqualTo(0L); + } + + @DisplayName("์ž”์•ก์ด ๋ถ€์กฑํ•˜๋ฉด, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsException_whenInsufficientBalance() { + // arrange + UserPoint point = UserPoint.create(1L, 3000L); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + point.use(5000L); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.INSUFFICIENT_POINT); + } + + @DisplayName("์‚ฌ์šฉ ๊ธˆ์•ก์ด 0์ด๋ฉด, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsException_whenUseAmountIsZero() { + // arrange + UserPoint point = UserPoint.create(1L, 5000L); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + point.use(0); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("์‚ฌ์šฉ ๊ธˆ์•ก์ด ์Œ์ˆ˜์ด๋ฉด, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsException_whenUseAmountIsNegative() { + // arrange + UserPoint point = UserPoint.create(1L, 5000L); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + point.use(-1000); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("์ž”์•ก ์ถฉ๋ถ„ ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•  ๋•Œ,") + @Nested + class HasEnough { + + @DisplayName("์ž”์•ก์ด ์ถฉ๋ถ„ํ•˜๋ฉด, true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsTrue_whenSufficientBalance() { + // arrange + UserPoint point = UserPoint.create(1L, 10000L); + + // act & assert + assertThat(point.hasEnough(5000L)).isTrue(); + } + + @DisplayName("์ž”์•ก์ด ์ •ํ™•ํžˆ ๊ฐ™์œผ๋ฉด, true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsTrue_whenExactBalance() { + // arrange + UserPoint point = UserPoint.create(1L, 5000L); + + // act & assert + assertThat(point.hasEnough(5000L)).isTrue(); + } + + @DisplayName("์ž”์•ก์ด ๋ถ€์กฑํ•˜๋ฉด, false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsFalse_whenInsufficientBalance() { + // arrange + UserPoint point = UserPoint.create(1L, 3000L); + + // act & assert + assertThat(point.hasEnough(5000L)).isFalse(); + } + } +} From c257ec8cf9992d818028575658e4a7268bf2bd37 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 6 Mar 2026 15:27:34 +0900 Subject: [PATCH 25/29] =?UTF-8?q?feat(infra):=20=EC=BF=A0=ED=8F=B0/?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=98=81=EC=86=8D=EC=84=B1=20?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CouponTemplate/IssuedCoupon JPA ์—”ํ‹ฐํ‹ฐ ๋ฐ Repository - UserPoint JPA ์—”ํ‹ฐํ‹ฐ ๋ฐ Repository - ๋น„๊ด€์  ๋ฝ(PESSIMISTIC_WRITE) ์ ์šฉ: findByIdWithLock - Order ์—”ํ‹ฐํ‹ฐ ํ• ์ธ ํ•„๋“œ ํ™•์žฅ (originalAmount, couponDiscount, pointDiscount) Co-Authored-By: Claude Opus 4.5 --- .../jpa/coupon/CouponTemplateJpaEntity.java | 123 ++++++++++++++++++ .../coupon/CouponTemplateJpaRepository.java | 25 ++++ .../jpa/coupon/CouponTemplateMapper.java | 55 ++++++++ .../coupon/CouponTemplateRepositoryImpl.java | 65 +++++++++ .../jpa/coupon/IssuedCouponJpaEntity.java | 103 +++++++++++++++ .../jpa/coupon/IssuedCouponJpaRepository.java | 33 +++++ .../jpa/coupon/IssuedCouponMapper.java | 37 ++++++ .../coupon/IssuedCouponRepositoryImpl.java | 86 ++++++++++++ .../persistence/jpa/order/OrderJpaEntity.java | 43 ++++++ .../persistence/jpa/order/OrderMapper.java | 42 +++++- .../jpa/point/UserPointJpaEntity.java | 76 +++++++++++ .../jpa/point/UserPointJpaRepository.java | 21 +++ .../jpa/point/UserPointMapper.java | 31 +++++ .../jpa/point/UserPointRepositoryImpl.java | 46 +++++++ 14 files changed, 781 insertions(+), 5 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/CouponTemplateJpaEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/CouponTemplateJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/CouponTemplateMapper.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/CouponTemplateRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/IssuedCouponJpaEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/IssuedCouponJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/IssuedCouponMapper.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/IssuedCouponRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/point/UserPointJpaEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/point/UserPointJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/point/UserPointMapper.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/point/UserPointRepositoryImpl.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/CouponTemplateJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/CouponTemplateJpaEntity.java new file mode 100644 index 000000000..339490ab4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/CouponTemplateJpaEntity.java @@ -0,0 +1,123 @@ +package com.loopers.infrastructure.persistence.jpa.coupon; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.coupon.CouponType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; + +import java.time.ZonedDateTime; + +/** + * ์ฟ ํฐ ํ…œํ”Œ๋ฆฟ JPA ์—”ํ‹ฐํ‹ฐ. + */ +@Entity +@Table(name = "coupon_templates") +public class CouponTemplateJpaEntity extends BaseEntity { + + @Column(name = "name", nullable = false, length = 200) + private String name; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 20) + private CouponType type; + + @Column(name = "value", nullable = false) + private Long value; + + @Column(name = "min_order_amount", nullable = false) + private Long minOrderAmount; + + @Column(name = "max_discount_amount") + private Integer maxDiscountAmount; + + @Column(name = "max_issue_count") + private Integer maxIssueCount; + + @Column(name = "issued_count", nullable = false) + private Integer issuedCount; + + @Column(name = "expired_at", nullable = false) + private ZonedDateTime expiredAt; + + protected CouponTemplateJpaEntity() {} + + public CouponTemplateJpaEntity(String name, CouponType type, Long value, + Long minOrderAmount, Integer maxDiscountAmount, + Integer maxIssueCount, int issuedCount, ZonedDateTime expiredAt) { + this.name = name; + this.type = type; + this.value = value; + this.minOrderAmount = minOrderAmount; + this.maxDiscountAmount = maxDiscountAmount; + this.maxIssueCount = maxIssueCount; + this.issuedCount = issuedCount; + this.expiredAt = expiredAt; + } + + public String getName() { + return name; + } + + public CouponType getType() { + return type; + } + + public Long getValue() { + return value; + } + + public Long getMinOrderAmount() { + return minOrderAmount; + } + + public Integer getMaxDiscountAmount() { + return maxDiscountAmount; + } + + public Integer getMaxIssueCount() { + return maxIssueCount; + } + + public Integer getIssuedCount() { + return issuedCount; + } + + public ZonedDateTime getExpiredAt() { + return expiredAt; + } + + public void setName(String name) { + this.name = name; + } + + public void setType(CouponType type) { + this.type = type; + } + + public void setValue(Long value) { + this.value = value; + } + + public void setMinOrderAmount(Long minOrderAmount) { + this.minOrderAmount = minOrderAmount; + } + + public void setMaxDiscountAmount(Integer maxDiscountAmount) { + this.maxDiscountAmount = maxDiscountAmount; + } + + public void setMaxIssueCount(Integer maxIssueCount) { + this.maxIssueCount = maxIssueCount; + } + + public void setIssuedCount(Integer issuedCount) { + this.issuedCount = issuedCount; + } + + public void setExpiredAt(ZonedDateTime expiredAt) { + this.expiredAt = expiredAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/CouponTemplateJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/CouponTemplateJpaRepository.java new file mode 100644 index 000000000..8827cd218 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/CouponTemplateJpaRepository.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.persistence.jpa.coupon; + +import jakarta.persistence.LockModeType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +/** + * CouponTemplate JPA Repository. + */ +public interface CouponTemplateJpaRepository extends JpaRepository { + + Optional findByIdAndDeletedAtIsNull(Long id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT ct FROM CouponTemplateJpaEntity ct WHERE ct.id = :id AND ct.deletedAt IS NULL") + Optional findByIdWithLock(@Param("id") Long id); + + Page findAllByDeletedAtIsNull(Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/CouponTemplateMapper.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/CouponTemplateMapper.java new file mode 100644 index 000000000..c8b884524 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/CouponTemplateMapper.java @@ -0,0 +1,55 @@ +package com.loopers.infrastructure.persistence.jpa.coupon; + +import com.loopers.domain.common.Money; +import com.loopers.domain.coupon.CouponTemplate; + +/** + * CouponTemplate Domain โ†” JPA Entity ๋ณ€ํ™˜ Mapper. + */ +public class CouponTemplateMapper { + + private CouponTemplateMapper() {} + + public static CouponTemplate toDomain(CouponTemplateJpaEntity entity) { + return CouponTemplate.reconstitute( + entity.getId(), + entity.getName(), + entity.getType(), + new Money(entity.getValue()), + new Money(entity.getMinOrderAmount()), + entity.getMaxDiscountAmount(), + entity.getMaxIssueCount(), + entity.getIssuedCount(), + entity.getExpiredAt(), + entity.getCreatedAt(), + entity.getDeletedAt() + ); + } + + public static CouponTemplateJpaEntity toJpaEntity(CouponTemplate domain) { + return new CouponTemplateJpaEntity( + domain.getName(), + domain.getType(), + domain.getValue().amount(), + domain.getMinOrderAmount().amount(), + domain.getMaxDiscountAmount(), + domain.getMaxIssueCount(), + domain.getIssuedCount(), + domain.getExpiredAt() + ); + } + + public static void updateJpaEntity(CouponTemplateJpaEntity entity, CouponTemplate domain) { + entity.setName(domain.getName()); + entity.setType(domain.getType()); + entity.setValue(domain.getValue().amount()); + entity.setMinOrderAmount(domain.getMinOrderAmount().amount()); + entity.setMaxDiscountAmount(domain.getMaxDiscountAmount()); + entity.setMaxIssueCount(domain.getMaxIssueCount()); + entity.setIssuedCount(domain.getIssuedCount()); + entity.setExpiredAt(domain.getExpiredAt()); + if (domain.isDeleted()) { + entity.delete(); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/CouponTemplateRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/CouponTemplateRepositoryImpl.java new file mode 100644 index 000000000..80fdf2166 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/CouponTemplateRepositoryImpl.java @@ -0,0 +1,65 @@ +package com.loopers.infrastructure.persistence.jpa.coupon; + +import com.loopers.domain.coupon.CouponTemplate; +import com.loopers.domain.coupon.CouponTemplateRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * CouponTemplateRepository ๊ตฌํ˜„์ฒด. + */ +@Repository +@RequiredArgsConstructor +public class CouponTemplateRepositoryImpl implements CouponTemplateRepository { + + private final CouponTemplateJpaRepository jpaRepository; + + @Override + public CouponTemplate save(CouponTemplate template) { + CouponTemplateJpaEntity entity; + + if (template.getId() == null) { + entity = CouponTemplateMapper.toJpaEntity(template); + } else { + entity = jpaRepository.findById(template.getId()) + .orElseGet(() -> CouponTemplateMapper.toJpaEntity(template)); + CouponTemplateMapper.updateJpaEntity(entity, template); + } + + CouponTemplateJpaEntity saved = jpaRepository.save(entity); + return CouponTemplateMapper.toDomain(saved); + } + + @Override + public Optional findById(Long id) { + return jpaRepository.findById(id) + .map(CouponTemplateMapper::toDomain); + } + + @Override + public Optional findByIdActive(Long id) { + return jpaRepository.findByIdAndDeletedAtIsNull(id) + .map(CouponTemplateMapper::toDomain); + } + + @Override + public Optional findByIdWithLock(Long id) { + return jpaRepository.findByIdWithLock(id) + .map(CouponTemplateMapper::toDomain); + } + + @Override + public Page findAllActive(Pageable pageable) { + Page page = jpaRepository.findAllByDeletedAtIsNull(pageable); + return new PageImpl<>( + page.getContent().stream().map(CouponTemplateMapper::toDomain).toList(), + pageable, + page.getTotalElements() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/IssuedCouponJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/IssuedCouponJpaEntity.java new file mode 100644 index 000000000..dc1d28d34 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/IssuedCouponJpaEntity.java @@ -0,0 +1,103 @@ +package com.loopers.infrastructure.persistence.jpa.coupon; + +import com.loopers.domain.coupon.IssuedCouponStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; + +import java.time.ZonedDateTime; + +/** + * ๋ฐœ๊ธ‰๋œ ์ฟ ํฐ JPA ์—”ํ‹ฐํ‹ฐ. + */ +@Entity +@Table( + name = "issued_coupons", + indexes = { + @Index(name = "idx_issued_coupons_user_id_status", columnList = "user_id, status"), + @Index(name = "idx_issued_coupons_user_template", columnList = "user_id, coupon_template_id") + } +) +public class IssuedCouponJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "coupon_template_id", nullable = false) + private Long couponTemplateId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private IssuedCouponStatus status; + + @Column(name = "used_at") + private ZonedDateTime usedAt; + + @Column(name = "issued_at", nullable = false, updatable = false) + private ZonedDateTime issuedAt; + + @Column(name = "expired_at", nullable = false) + private ZonedDateTime expiredAt; + + protected IssuedCouponJpaEntity() {} + + public IssuedCouponJpaEntity(Long userId, Long couponTemplateId, + IssuedCouponStatus status, ZonedDateTime expiredAt) { + this.userId = userId; + this.couponTemplateId = couponTemplateId; + this.status = status; + this.expiredAt = expiredAt; + } + + @PrePersist + private void prePersist() { + this.issuedAt = ZonedDateTime.now(); + } + + public Long getId() { + return id; + } + + public Long getUserId() { + return userId; + } + + public Long getCouponTemplateId() { + return couponTemplateId; + } + + public IssuedCouponStatus getStatus() { + return status; + } + + public ZonedDateTime getUsedAt() { + return usedAt; + } + + public ZonedDateTime getIssuedAt() { + return issuedAt; + } + + public ZonedDateTime getExpiredAt() { + return expiredAt; + } + + public void setStatus(IssuedCouponStatus status) { + this.status = status; + } + + public void setUsedAt(ZonedDateTime usedAt) { + this.usedAt = usedAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/IssuedCouponJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/IssuedCouponJpaRepository.java new file mode 100644 index 000000000..a632ab3e6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/IssuedCouponJpaRepository.java @@ -0,0 +1,33 @@ +package com.loopers.infrastructure.persistence.jpa.coupon; + +import jakarta.persistence.LockModeType; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +/** + * IssuedCoupon JPA Repository. + */ +public interface IssuedCouponJpaRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT ic FROM IssuedCouponJpaEntity ic WHERE ic.id = :id") + Optional findByIdWithLock(@Param("id") Long id); + + Optional findByIdAndUserId(Long id, Long userId); + + List findAllByUserId(Long userId, Pageable pageable); + + boolean existsByUserIdAndCouponTemplateId(Long userId, Long couponTemplateId); + + long countByUserId(Long userId); + + List findAllByCouponTemplateId(Long couponTemplateId, Pageable pageable); + + long countByCouponTemplateId(Long couponTemplateId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/IssuedCouponMapper.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/IssuedCouponMapper.java new file mode 100644 index 000000000..5ea327f6d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/IssuedCouponMapper.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.persistence.jpa.coupon; + +import com.loopers.domain.coupon.IssuedCoupon; + +/** + * IssuedCoupon Domain โ†” JPA Entity ๋ณ€ํ™˜ Mapper. + */ +public class IssuedCouponMapper { + + private IssuedCouponMapper() {} + + public static IssuedCoupon toDomain(IssuedCouponJpaEntity entity) { + return IssuedCoupon.reconstitute( + entity.getId(), + entity.getUserId(), + entity.getCouponTemplateId(), + entity.getStatus(), + entity.getUsedAt(), + entity.getIssuedAt(), + entity.getExpiredAt() + ); + } + + public static IssuedCouponJpaEntity toJpaEntity(IssuedCoupon domain) { + return new IssuedCouponJpaEntity( + domain.getUserId(), + domain.getCouponTemplateId(), + domain.getStatus(), + domain.getExpiredAt() + ); + } + + public static void updateJpaEntity(IssuedCouponJpaEntity entity, IssuedCoupon domain) { + entity.setStatus(domain.getStatus()); + entity.setUsedAt(domain.getUsedAt()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/IssuedCouponRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/IssuedCouponRepositoryImpl.java new file mode 100644 index 000000000..781ef1e5b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/coupon/IssuedCouponRepositoryImpl.java @@ -0,0 +1,86 @@ +package com.loopers.infrastructure.persistence.jpa.coupon; + +import com.loopers.domain.coupon.IssuedCoupon; +import com.loopers.domain.coupon.IssuedCouponRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * IssuedCouponRepository ๊ตฌํ˜„์ฒด. + */ +@Repository +@RequiredArgsConstructor +public class IssuedCouponRepositoryImpl implements IssuedCouponRepository { + + private final IssuedCouponJpaRepository jpaRepository; + + @Override + public IssuedCoupon save(IssuedCoupon coupon) { + IssuedCouponJpaEntity entity; + + if (coupon.getId() == null) { + entity = IssuedCouponMapper.toJpaEntity(coupon); + } else { + entity = jpaRepository.findById(coupon.getId()) + .orElseGet(() -> IssuedCouponMapper.toJpaEntity(coupon)); + IssuedCouponMapper.updateJpaEntity(entity, coupon); + } + + IssuedCouponJpaEntity saved = jpaRepository.save(entity); + return IssuedCouponMapper.toDomain(saved); + } + + @Override + public Optional findById(Long id) { + return jpaRepository.findById(id) + .map(IssuedCouponMapper::toDomain); + } + + @Override + public Optional findByIdWithLock(Long id) { + return jpaRepository.findByIdWithLock(id) + .map(IssuedCouponMapper::toDomain); + } + + @Override + public Optional findByIdAndUserId(Long id, Long userId) { + return jpaRepository.findByIdAndUserId(id, userId) + .map(IssuedCouponMapper::toDomain); + } + + @Override + public List findAllByUserId(Long userId, int offset, int limit) { + PageRequest pageable = PageRequest.of(offset / limit, limit, Sort.by(Sort.Direction.DESC, "issuedAt")); + return jpaRepository.findAllByUserId(userId, pageable).stream() + .map(IssuedCouponMapper::toDomain) + .toList(); + } + + @Override + public boolean existsByUserIdAndCouponTemplateId(Long userId, Long couponTemplateId) { + return jpaRepository.existsByUserIdAndCouponTemplateId(userId, couponTemplateId); + } + + @Override + public long countByUserId(Long userId) { + return jpaRepository.countByUserId(userId); + } + + @Override + public List findAllByCouponTemplateId(Long couponTemplateId, int offset, int limit) { + PageRequest pageable = PageRequest.of(offset / limit, limit, Sort.by(Sort.Direction.DESC, "issuedAt")); + return jpaRepository.findAllByCouponTemplateId(couponTemplateId, pageable).stream() + .map(IssuedCouponMapper::toDomain) + .toList(); + } + + @Override + public long countByCouponTemplateId(Long couponTemplateId) { + return jpaRepository.countByCouponTemplateId(couponTemplateId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderJpaEntity.java index 85c896859..74f096f5f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderJpaEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderJpaEntity.java @@ -44,6 +44,18 @@ public class OrderJpaEntity { @Column(name = "total_price", nullable = false) private Long totalPrice; + @Column(name = "original_amount") + private Long originalAmount; + + @Column(name = "coupon_discount") + private Long couponDiscount; + + @Column(name = "point_discount") + private Long pointDiscount; + + @Column(name = "coupon_id") + private Long couponId; + @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false, length = 20) private OrderStatus status; @@ -56,6 +68,21 @@ protected OrderJpaEntity() {} public OrderJpaEntity(Long userId, Long totalPrice, OrderStatus status) { this.userId = userId; this.totalPrice = totalPrice; + this.originalAmount = totalPrice; + this.couponDiscount = 0L; + this.pointDiscount = 0L; + this.couponId = null; + this.status = status; + } + + public OrderJpaEntity(Long userId, Long totalPrice, Long originalAmount, + Long couponDiscount, Long pointDiscount, Long couponId, OrderStatus status) { + this.userId = userId; + this.totalPrice = totalPrice; + this.originalAmount = originalAmount; + this.couponDiscount = couponDiscount; + this.pointDiscount = pointDiscount; + this.couponId = couponId; this.status = status; } @@ -92,4 +119,20 @@ public OrderStatus getStatus() { public ZonedDateTime getCreatedAt() { return createdAt; } + + public Long getOriginalAmount() { + return originalAmount; + } + + public Long getCouponDiscount() { + return couponDiscount; + } + + public Long getPointDiscount() { + return pointDiscount; + } + + public Long getCouponId() { + return couponId; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderMapper.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderMapper.java index 856921b00..460f43e41 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderMapper.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/order/OrderMapper.java @@ -18,6 +18,23 @@ public static Order toDomain(OrderJpaEntity entity) { .map(OrderMapper::toOrderItemDomain) .toList(); + // ํ• ์ธ ์ •๋ณด๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ + if (entity.getOriginalAmount() != null && entity.getCouponDiscount() != null) { + return Order.reconstituteWithDiscount( + entity.getId(), + entity.getUserId(), + items, + new Money(entity.getTotalPrice()), + new Money(entity.getOriginalAmount()), + new Money(entity.getCouponDiscount()), + new Money(entity.getPointDiscount() != null ? entity.getPointDiscount() : 0L), + entity.getCouponId(), + entity.getStatus(), + entity.getCreatedAt() + ); + } + + // ๊ธฐ์กด ์ฃผ๋ฌธ (ํ• ์ธ ์ •๋ณด ์—†์Œ) return Order.reconstitute( entity.getId(), entity.getUserId(), @@ -39,11 +56,26 @@ public static OrderItem toOrderItemDomain(OrderItemJpaEntity entity) { } public static OrderJpaEntity toJpaEntity(Order domain) { - OrderJpaEntity entity = new OrderJpaEntity( - domain.getUserId(), - domain.getTotalPrice().amount(), - domain.getStatus() - ); + OrderJpaEntity entity; + + // ํ• ์ธ ์ •๋ณด๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ + if (domain.getOriginalAmount() != null) { + entity = new OrderJpaEntity( + domain.getUserId(), + domain.getTotalPrice().amount(), + domain.getOriginalAmount().amount(), + domain.getCouponDiscount() != null ? domain.getCouponDiscount().amount() : 0L, + domain.getPointDiscount() != null ? domain.getPointDiscount().amount() : 0L, + domain.getCouponId(), + domain.getStatus() + ); + } else { + entity = new OrderJpaEntity( + domain.getUserId(), + domain.getTotalPrice().amount(), + domain.getStatus() + ); + } for (OrderItem item : domain.getItems()) { OrderItemJpaEntity itemEntity = new OrderItemJpaEntity( diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/point/UserPointJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/point/UserPointJpaEntity.java new file mode 100644 index 000000000..a084342b1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/point/UserPointJpaEntity.java @@ -0,0 +1,76 @@ +package com.loopers.infrastructure.persistence.jpa.point; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; + +import java.time.ZonedDateTime; + +/** + * ์‚ฌ์šฉ์ž ํฌ์ธํŠธ JPA ์—”ํ‹ฐํ‹ฐ. + */ +@Entity +@Table( + name = "user_points", + indexes = { + @Index(name = "idx_user_points_user_id", columnList = "user_id", unique = true) + } +) +public class UserPointJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false, unique = true) + private Long userId; + + @Column(name = "balance", nullable = false) + private Long balance; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; + + protected UserPointJpaEntity() {} + + public UserPointJpaEntity(Long userId, Long balance) { + this.userId = userId; + this.balance = balance; + } + + @PrePersist + private void prePersist() { + this.updatedAt = ZonedDateTime.now(); + } + + @PreUpdate + private void preUpdate() { + this.updatedAt = ZonedDateTime.now(); + } + + public Long getId() { + return id; + } + + public Long getUserId() { + return userId; + } + + public Long getBalance() { + return balance; + } + + public ZonedDateTime getUpdatedAt() { + return updatedAt; + } + + public void setBalance(Long balance) { + this.balance = balance; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/point/UserPointJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/point/UserPointJpaRepository.java new file mode 100644 index 000000000..4df62af42 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/point/UserPointJpaRepository.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.persistence.jpa.point; + +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +/** + * UserPoint JPA Repository. + */ +public interface UserPointJpaRepository extends JpaRepository { + + Optional findByUserId(Long userId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT up FROM UserPointJpaEntity up WHERE up.userId = :userId") + Optional findByUserIdWithLock(@Param("userId") Long userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/point/UserPointMapper.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/point/UserPointMapper.java new file mode 100644 index 000000000..76705b7fa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/point/UserPointMapper.java @@ -0,0 +1,31 @@ +package com.loopers.infrastructure.persistence.jpa.point; + +import com.loopers.domain.point.UserPoint; + +/** + * UserPoint Domain โ†” JPA Entity ๋ณ€ํ™˜ Mapper. + */ +public class UserPointMapper { + + private UserPointMapper() {} + + public static UserPoint toDomain(UserPointJpaEntity entity) { + return UserPoint.reconstitute( + entity.getId(), + entity.getUserId(), + entity.getBalance(), + entity.getUpdatedAt() + ); + } + + public static UserPointJpaEntity toJpaEntity(UserPoint domain) { + return new UserPointJpaEntity( + domain.getUserId(), + domain.getBalance() + ); + } + + public static void updateJpaEntity(UserPointJpaEntity entity, UserPoint domain) { + entity.setBalance(domain.getBalance()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/point/UserPointRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/point/UserPointRepositoryImpl.java new file mode 100644 index 000000000..a2b0249d4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/point/UserPointRepositoryImpl.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.persistence.jpa.point; + +import com.loopers.domain.point.UserPoint; +import com.loopers.domain.point.UserPointRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * UserPointRepository ๊ตฌํ˜„์ฒด. + */ +@Repository +@RequiredArgsConstructor +public class UserPointRepositoryImpl implements UserPointRepository { + + private final UserPointJpaRepository jpaRepository; + + @Override + public UserPoint save(UserPoint point) { + UserPointJpaEntity entity; + + if (point.getId() == null) { + entity = UserPointMapper.toJpaEntity(point); + } else { + entity = jpaRepository.findById(point.getId()) + .orElseGet(() -> UserPointMapper.toJpaEntity(point)); + UserPointMapper.updateJpaEntity(entity, point); + } + + UserPointJpaEntity saved = jpaRepository.save(entity); + return UserPointMapper.toDomain(saved); + } + + @Override + public Optional findByUserId(Long userId) { + return jpaRepository.findByUserId(userId) + .map(UserPointMapper::toDomain); + } + + @Override + public Optional findByUserIdWithLock(Long userId) { + return jpaRepository.findByUserIdWithLock(userId) + .map(UserPointMapper::toDomain); + } +} From 2483fcdf40cba642a175f827bd07c0f183621874 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 6 Mar 2026 15:27:47 +0900 Subject: [PATCH 26/29] =?UTF-8?q?feat(app):=20=EC=BF=A0=ED=8F=B0/=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8/=EC=A3=BC=EB=AC=B8=20Application=20Service?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CouponAdminService: ๊ด€๋ฆฌ์ž ์ฟ ํฐ CRUD - CouponUserService: ์‚ฌ์šฉ์ž ์ฟ ํฐ ๋ฐœ๊ธ‰/์กฐํšŒ (๋น„๊ด€์  ๋ฝ) - UserPointService: ํฌ์ธํŠธ ์กฐํšŒ/์ถฉ์ „ - OrderApplicationService.placeOrderWithDiscount(): ํ• ์ธ ์ ์šฉ ์ฃผ๋ฌธ - Order.createWithDiscount(): ํ• ์ธ ์ •๋ณด ํฌํ•จ ์ฃผ๋ฌธ ์ƒ์„ฑ - ๋„๋ฉ”์ธ๋ณ„ ์—๋Ÿฌ ํƒ€์ž… ์ถ”๊ฐ€ (COUPON_*, POINT_*, ORDER_*) Co-Authored-By: Claude Opus 4.5 --- .../coupon/CouponAdminService.java | 109 +++++++++ .../coupon/CouponTemplateResult.java | 37 ++++ .../application/coupon/CouponUserService.java | 91 ++++++++ .../coupon/CreateCouponRequest.java | 18 ++ .../coupon/IssuedCouponResult.java | 67 ++++++ .../order/OrderApplicationService.java | 100 ++++++++- .../application/order/OrderResult.java | 8 + .../order/PlaceOrderWithDiscountRequest.java | 20 ++ .../application/point/UserPointResult.java | 24 ++ .../application/point/UserPointService.java | 43 ++++ .../java/com/loopers/domain/order/Order.java | 86 ++++++++ .../com/loopers/support/error/ErrorType.java | 18 +- .../coupon/CouponAdminServiceTest.java | 181 +++++++++++++++ .../coupon/CouponUserServiceTest.java | 207 ++++++++++++++++++ .../order/OrderApplicationServiceTest.java | 204 ++++++++++++++++- .../com/loopers/domain/order/OrderTest.java | 91 ++++++++ .../fake/FakeCouponTemplateRepository.java | 86 ++++++++ .../fake/FakeIssuedCouponRepository.java | 105 +++++++++ .../com/loopers/fake/FakeOrderRepository.java | 7 +- .../loopers/fake/FakeUserPointRepository.java | 56 +++++ 20 files changed, 1554 insertions(+), 4 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponAdminService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponTemplateResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponUserService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/coupon/CreateCouponRequest.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/coupon/IssuedCouponResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/PlaceOrderWithDiscountRequest.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/point/UserPointResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/point/UserPointService.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponAdminServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponUserServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/fake/FakeCouponTemplateRepository.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/fake/FakeIssuedCouponRepository.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/fake/FakeUserPointRepository.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponAdminService.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponAdminService.java new file mode 100644 index 000000000..886570def --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponAdminService.java @@ -0,0 +1,109 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.common.Money; +import com.loopers.domain.coupon.CouponTemplate; +import com.loopers.domain.coupon.CouponTemplateRepository; +import com.loopers.domain.coupon.CouponType; +import com.loopers.domain.coupon.IssuedCouponRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * ์ฟ ํฐ ๊ด€๋ฆฌ์ž ์„œ๋น„์Šค. + */ +@Service +@RequiredArgsConstructor +public class CouponAdminService { + + private final CouponTemplateRepository couponTemplateRepository; + private final IssuedCouponRepository issuedCouponRepository; + + /** + * ์ฟ ํฐ ์ƒ์„ฑ. + */ + @Transactional + public CouponTemplateResult create(CreateCouponRequest request) { + CouponTemplate template; + + if (request.type() == CouponType.FIXED) { + template = CouponTemplate.createFixed( + request.name(), + new Money(request.value()), + new Money(request.minOrderAmount()), + request.maxIssueCount(), + request.expiredAt() + ); + } else { + template = CouponTemplate.createRate( + request.name(), + request.value().intValue(), + new Money(request.minOrderAmount()), + request.maxDiscountAmount(), + request.maxIssueCount(), + request.expiredAt() + ); + } + + CouponTemplate saved = couponTemplateRepository.save(template); + return CouponTemplateResult.from(saved); + } + + /** + * ์ฟ ํฐ ์กฐํšŒ. + */ + @Transactional(readOnly = true) + public CouponTemplateResult findById(Long id) { + CouponTemplate template = couponTemplateRepository.findByIdActive(id) + .orElseThrow(() -> new CoreException(ErrorType.COUPON_NOT_FOUND)); + return CouponTemplateResult.from(template); + } + + /** + * ํ™œ์„ฑ ์ฟ ํฐ ๋ชฉ๋ก ์กฐํšŒ. + */ + @Transactional(readOnly = true) + public Page findAll(Pageable pageable) { + return couponTemplateRepository.findAllActive(pageable) + .map(CouponTemplateResult::from); + } + + /** + * ์ฟ ํฐ ์‚ญ์ œ. + */ + @Transactional + public void delete(Long id) { + CouponTemplate template = couponTemplateRepository.findByIdActive(id) + .orElseThrow(() -> new CoreException(ErrorType.COUPON_NOT_FOUND)); + + template.delete(); + couponTemplateRepository.save(template); + } + + /** + * ์ฟ ํฐ ๋ฐœ๊ธ‰ ๋‚ด์—ญ ์กฐํšŒ. + */ + @Transactional(readOnly = true) + public List findIssues(Long couponId, int offset, int limit) { + couponTemplateRepository.findByIdActive(couponId) + .orElseThrow(() -> new CoreException(ErrorType.COUPON_NOT_FOUND)); + + return issuedCouponRepository.findAllByCouponTemplateId(couponId, offset, limit).stream() + .map(IssuedCouponResult::simple) + .toList(); + } + + /** + * ์ฟ ํฐ ๋ฐœ๊ธ‰ ์ˆ˜ ์กฐํšŒ. + */ + @Transactional(readOnly = true) + public long countIssues(Long couponId) { + return issuedCouponRepository.countByCouponTemplateId(couponId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponTemplateResult.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponTemplateResult.java new file mode 100644 index 000000000..54a9a9b1a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponTemplateResult.java @@ -0,0 +1,37 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.CouponTemplate; +import com.loopers.domain.coupon.CouponType; + +import java.time.ZonedDateTime; + +/** + * ์ฟ ํฐ ํ…œํ”Œ๋ฆฟ ์กฐํšŒ ๊ฒฐ๊ณผ. + */ +public record CouponTemplateResult( + Long id, + String name, + CouponType type, + long value, + long minOrderAmount, + Integer maxDiscountAmount, + Integer maxIssueCount, + int issuedCount, + ZonedDateTime expiredAt, + ZonedDateTime createdAt +) { + public static CouponTemplateResult from(CouponTemplate template) { + return new CouponTemplateResult( + template.getId(), + template.getName(), + template.getType(), + template.getValue().amount(), + template.getMinOrderAmount().amount(), + template.getMaxDiscountAmount(), + template.getMaxIssueCount(), + template.getIssuedCount(), + template.getExpiredAt(), + template.getCreatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponUserService.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponUserService.java new file mode 100644 index 000000000..bd44b2b24 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponUserService.java @@ -0,0 +1,91 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.CouponTemplate; +import com.loopers.domain.coupon.CouponTemplateRepository; +import com.loopers.domain.coupon.IssuedCoupon; +import com.loopers.domain.coupon.IssuedCouponRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * ์ฟ ํฐ ์‚ฌ์šฉ์ž ์„œ๋น„์Šค. + */ +@Service +@RequiredArgsConstructor +public class CouponUserService { + + private final CouponTemplateRepository couponTemplateRepository; + private final IssuedCouponRepository issuedCouponRepository; + + /** + * ์ฟ ํฐ ๋ฐœ๊ธ‰. + */ + @Transactional + public IssuedCouponResult issue(Long userId, Long couponTemplateId) { + // 1. ์ฟ ํฐ ํ…œํ”Œ๋ฆฟ ์กฐํšŒ (๋น„๊ด€์  ๋ฝ) + CouponTemplate template = couponTemplateRepository.findByIdWithLock(couponTemplateId) + .orElseThrow(() -> new CoreException(ErrorType.COUPON_NOT_FOUND)); + + // 2. ์ค‘๋ณต ๋ฐœ๊ธ‰ ํ™•์ธ + if (issuedCouponRepository.existsByUserIdAndCouponTemplateId(userId, couponTemplateId)) { + throw new CoreException(ErrorType.COUPON_ALREADY_ISSUED); + } + + // 3. ๋ฐœ๊ธ‰ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ํ™•์ธ ๋ฐ ๋ฐœ๊ธ‰ ์ˆ˜ ์ฆ๊ฐ€ + template.incrementIssuedCount(); + couponTemplateRepository.save(template); + + // 4. ๋ฐœ๊ธ‰๋œ ์ฟ ํฐ ์ƒ์„ฑ + IssuedCoupon issued = IssuedCoupon.create(userId, template); + IssuedCoupon saved = issuedCouponRepository.save(issued); + + return IssuedCouponResult.from(saved, template); + } + + /** + * ๋‚ด ์ฟ ํฐ ๋ชฉ๋ก ์กฐํšŒ. + */ + @Transactional(readOnly = true) + public List findMyCoupons(Long userId, int offset, int limit) { + List coupons = issuedCouponRepository.findAllByUserId(userId, offset, limit); + + return coupons.stream() + .map(coupon -> { + CouponTemplate template = couponTemplateRepository.findById(coupon.getCouponTemplateId()) + .orElseThrow(() -> new CoreException(ErrorType.COUPON_NOT_FOUND)); + return IssuedCouponResult.from(coupon, template); + }) + .toList(); + } + + /** + * ๋‚ด ์ฟ ํฐ ์ƒ์„ธ ์กฐํšŒ. + */ + @Transactional(readOnly = true) + public IssuedCouponResult findMyCouponById(Long userId, Long couponId) { + IssuedCoupon coupon = issuedCouponRepository.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.COUPON_NOT_FOUND)); + + if (!coupon.getUserId().equals(userId)) { + throw new CoreException(ErrorType.COUPON_ACCESS_DENIED); + } + + CouponTemplate template = couponTemplateRepository.findById(coupon.getCouponTemplateId()) + .orElseThrow(() -> new CoreException(ErrorType.COUPON_NOT_FOUND)); + + return IssuedCouponResult.from(coupon, template); + } + + /** + * ๋‚ด ์ฟ ํฐ ์ˆ˜ ์กฐํšŒ. + */ + @Transactional(readOnly = true) + public long countMyCoupons(Long userId) { + return issuedCouponRepository.countByUserId(userId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CreateCouponRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CreateCouponRequest.java new file mode 100644 index 000000000..c1b0008b3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CreateCouponRequest.java @@ -0,0 +1,18 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.CouponType; + +import java.time.ZonedDateTime; + +/** + * ์ฟ ํฐ ์ƒ์„ฑ ์š”์ฒญ. + */ +public record CreateCouponRequest( + String name, + CouponType type, + Long value, + Long minOrderAmount, + Integer maxDiscountAmount, + Integer maxIssueCount, + ZonedDateTime expiredAt +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/IssuedCouponResult.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/IssuedCouponResult.java new file mode 100644 index 000000000..963a6ae78 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/IssuedCouponResult.java @@ -0,0 +1,67 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.CouponDisplayStatus; +import com.loopers.domain.coupon.CouponTemplate; +import com.loopers.domain.coupon.CouponType; +import com.loopers.domain.coupon.IssuedCoupon; +import com.loopers.domain.coupon.IssuedCouponStatus; + +import java.time.ZonedDateTime; + +/** + * ๋ฐœ๊ธ‰๋œ ์ฟ ํฐ ์กฐํšŒ ๊ฒฐ๊ณผ. + */ +public record IssuedCouponResult( + Long id, + Long userId, + Long couponTemplateId, + String couponName, + CouponType couponType, + long couponValue, + long minOrderAmount, + Integer maxDiscountAmount, + IssuedCouponStatus status, + CouponDisplayStatus displayStatus, + boolean usable, + ZonedDateTime usedAt, + ZonedDateTime issuedAt, + ZonedDateTime expiredAt +) { + public static IssuedCouponResult from(IssuedCoupon coupon, CouponTemplate template) { + return new IssuedCouponResult( + coupon.getId(), + coupon.getUserId(), + coupon.getCouponTemplateId(), + template.getName(), + template.getType(), + template.getValue().amount(), + template.getMinOrderAmount().amount(), + template.getMaxDiscountAmount(), + coupon.getStatus(), + coupon.getDisplayStatus(), + coupon.isUsable(), + coupon.getUsedAt(), + coupon.getIssuedAt(), + coupon.getExpiredAt() + ); + } + + public static IssuedCouponResult simple(IssuedCoupon coupon) { + return new IssuedCouponResult( + coupon.getId(), + coupon.getUserId(), + coupon.getCouponTemplateId(), + null, + null, + 0, + 0, + null, + coupon.getStatus(), + coupon.getDisplayStatus(), + coupon.isUsable(), + coupon.getUsedAt(), + coupon.getIssuedAt(), + coupon.getExpiredAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java index 43a9c9128..f7edcec50 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java @@ -1,8 +1,15 @@ package com.loopers.application.order; +import com.loopers.domain.common.Money; +import com.loopers.domain.coupon.CouponTemplate; +import com.loopers.domain.coupon.CouponTemplateRepository; +import com.loopers.domain.coupon.IssuedCoupon; +import com.loopers.domain.coupon.IssuedCouponRepository; import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderItem; import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.point.UserPoint; +import com.loopers.domain.point.UserPointRepository; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; import com.loopers.support.error.CoreException; @@ -13,7 +20,6 @@ import java.util.ArrayList; import java.util.List; -import java.util.Optional; /** * ์ฃผ๋ฌธ Application Service. @@ -25,6 +31,9 @@ public class OrderApplicationService { private final ProductRepository productRepository; private final OrderRepository orderRepository; + private final CouponTemplateRepository couponTemplateRepository; + private final IssuedCouponRepository issuedCouponRepository; + private final UserPointRepository userPointRepository; /** * ์ฃผ๋ฌธ ์ƒ์„ฑ. @@ -109,4 +118,93 @@ public List getOrders(Long userId, int offset, int limit) { public long countOrders(Long userId) { return orderRepository.countByUserId(userId); } + + /** + * ์ฟ ํฐ/ํฌ์ธํŠธ ํ• ์ธ์ด ์ ์šฉ๋œ ์ฃผ๋ฌธ ์ƒ์„ฑ. + * + * @param userId ์‚ฌ์šฉ์ž ID + * @param request ์ฃผ๋ฌธ ์š”์ฒญ (์ฟ ํฐ, ํฌ์ธํŠธ ํฌํ•จ) + * @return ์ƒ์„ฑ๋œ ์ฃผ๋ฌธ ๊ฒฐ๊ณผ + */ + @Transactional + public OrderResult placeOrderWithDiscount(Long userId, PlaceOrderWithDiscountRequest request) { + if (request.items() == null || request.items().isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ ํ•ญ๋ชฉ์ด ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค."); + } + + // 1. ์ฟ ํฐ ์กฐํšŒ ๋ฐ ๋ฝ (nullable) + IssuedCoupon coupon = null; + CouponTemplate template = null; + if (request.hasCoupon()) { + coupon = issuedCouponRepository.findByIdWithLock(request.couponId()) + .orElseThrow(() -> new CoreException(ErrorType.COUPON_NOT_FOUND)); + + if (!coupon.getUserId().equals(userId)) { + throw new CoreException(ErrorType.COUPON_ACCESS_DENIED); + } + if (!coupon.isUsable()) { + throw new CoreException(ErrorType.COUPON_NOT_AVAILABLE); + } + + template = couponTemplateRepository.findById(coupon.getCouponTemplateId()) + .orElseThrow(() -> new CoreException(ErrorType.COUPON_NOT_FOUND)); + } + + // 2. ์ƒํ’ˆ ์กฐํšŒ ๋ฐ ์žฌ๊ณ  ์ฐจ๊ฐ (๋น„๊ด€์  ๋ฝ) + List orderItems = new ArrayList<>(); + for (OrderItemRequest req : request.items()) { + Product product = productRepository.findByIdWithLock(req.productId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + product.decreaseStock(req.quantity()); + productRepository.save(product); + + orderItems.add(OrderItem.create( + product.getId(), + product.getName(), + req.quantity(), + product.getPrice() + )); + } + + // 3. ์›๊ฐ€ ๊ณ„์‚ฐ + Money originalAmount = orderItems.stream() + .map(OrderItem::getSubtotal) + .reduce(Money.ZERO, Money::add); + + // 4. ์ฟ ํฐ ํ• ์ธ์•ก ๊ณ„์‚ฐ + Money couponDiscount = Money.ZERO; + if (coupon != null && template != null) { + // ์ตœ์†Œ ์ฃผ๋ฌธ ๊ธˆ์•ก ๊ฒ€์ฆ + if (originalAmount.amount() < template.getMinOrderAmount().amount()) { + throw new CoreException(ErrorType.ORDER_AMOUNT_TOO_LOW); + } + couponDiscount = template.calculateDiscount(originalAmount); + } + + // 5. ํฌ์ธํŠธ ์ฐจ๊ฐ (๋น„๊ด€์  ๋ฝ) + Money pointDiscount = Money.ZERO; + if (request.hasPoint()) { + UserPoint userPoint = userPointRepository.findByUserIdWithLock(userId) + .orElseThrow(() -> new CoreException(ErrorType.POINT_NOT_FOUND)); + + userPoint.use(request.pointAmount()); + userPointRepository.save(userPoint); + pointDiscount = new Money(request.pointAmount()); + } + + // 6. ์ฟ ํฐ ์‚ฌ์šฉ ์ฒ˜๋ฆฌ + Long couponId = null; + if (coupon != null) { + coupon.use(); + issuedCouponRepository.save(coupon); + couponId = coupon.getId(); + } + + // 7. ์ฃผ๋ฌธ ์ƒ์„ฑ + Order order = Order.createWithDiscount(userId, orderItems, couponId, couponDiscount, pointDiscount); + Order saved = orderRepository.save(order); + + return OrderResult.from(saved); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderResult.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderResult.java index d56cf66ee..421ebf729 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderResult.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderResult.java @@ -14,6 +14,10 @@ public record OrderResult( Long userId, List items, Long totalPrice, + Long originalAmount, + Long couponDiscount, + Long pointDiscount, + Long couponId, OrderStatus status, ZonedDateTime createdAt ) { @@ -27,6 +31,10 @@ public static OrderResult from(Order order) { order.getUserId(), itemResults, order.getTotalPrice().amount(), + order.getOriginalAmount() != null ? order.getOriginalAmount().amount() : order.getTotalPrice().amount(), + order.getCouponDiscount() != null ? order.getCouponDiscount().amount() : 0L, + order.getPointDiscount() != null ? order.getPointDiscount().amount() : 0L, + order.getCouponId(), order.getStatus(), order.getCreatedAt() ); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/PlaceOrderWithDiscountRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/order/PlaceOrderWithDiscountRequest.java new file mode 100644 index 000000000..983779483 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/PlaceOrderWithDiscountRequest.java @@ -0,0 +1,20 @@ +package com.loopers.application.order; + +import java.util.List; + +/** + * ํ• ์ธ์ด ์ ์šฉ๋œ ์ฃผ๋ฌธ ์ƒ์„ฑ ์š”์ฒญ. + */ +public record PlaceOrderWithDiscountRequest( + List items, + Long couponId, + Long pointAmount +) { + public boolean hasCoupon() { + return couponId != null; + } + + public boolean hasPoint() { + return pointAmount != null && pointAmount > 0; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/UserPointResult.java b/apps/commerce-api/src/main/java/com/loopers/application/point/UserPointResult.java new file mode 100644 index 000000000..3b341df4a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/UserPointResult.java @@ -0,0 +1,24 @@ +package com.loopers.application.point; + +import com.loopers.domain.point.UserPoint; + +import java.time.ZonedDateTime; + +/** + * ์‚ฌ์šฉ์ž ํฌ์ธํŠธ ์กฐํšŒ ๊ฒฐ๊ณผ. + */ +public record UserPointResult( + Long id, + Long userId, + long balance, + ZonedDateTime updatedAt +) { + public static UserPointResult from(UserPoint point) { + return new UserPointResult( + point.getId(), + point.getUserId(), + point.getBalance(), + point.getUpdatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/UserPointService.java b/apps/commerce-api/src/main/java/com/loopers/application/point/UserPointService.java new file mode 100644 index 000000000..436ba2afa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/UserPointService.java @@ -0,0 +1,43 @@ +package com.loopers.application.point; + +import com.loopers.domain.point.UserPoint; +import com.loopers.domain.point.UserPointRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * ์‚ฌ์šฉ์ž ํฌ์ธํŠธ ์„œ๋น„์Šค. + */ +@Service +@RequiredArgsConstructor +public class UserPointService { + + private final UserPointRepository userPointRepository; + + /** + * ํฌ์ธํŠธ ์กฐํšŒ. + */ + @Transactional(readOnly = true) + public UserPointResult getPoint(Long userId) { + UserPoint point = userPointRepository.findByUserId(userId) + .orElseGet(() -> UserPoint.create(userId, 0)); + return UserPointResult.from(point); + } + + /** + * ํฌ์ธํŠธ ์ถฉ์ „. + */ + @Transactional + public UserPointResult charge(Long userId, long amount) { + UserPoint point = userPointRepository.findByUserIdWithLock(userId) + .orElseGet(() -> UserPoint.create(userId, 0)); + + point.charge(amount); + UserPoint saved = userPointRepository.save(point); + + return UserPointResult.from(saved); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index bf6eb968e..496e88334 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -22,6 +22,12 @@ public class Order { private OrderStatus status; private ZonedDateTime createdAt; + // ํ• ์ธ ๊ด€๋ จ ํ•„๋“œ + private Money originalAmount; + private Money couponDiscount; + private Money pointDiscount; + private Long couponId; + private Order() {} /** @@ -45,6 +51,46 @@ public static Order create(Long userId, List items) { order.userId = userId; order.items = new ArrayList<>(items); order.totalPrice = total; + order.originalAmount = total; + order.couponDiscount = Money.ZERO; + order.pointDiscount = Money.ZERO; + order.couponId = null; + order.status = OrderStatus.CREATED; + order.createdAt = ZonedDateTime.now(); + return order; + } + + /** + * ํ• ์ธ์ด ์ ์šฉ๋œ ์ฃผ๋ฌธ ์ƒ์„ฑ. + * + * @param userId ์‚ฌ์šฉ์ž ID + * @param items ์ฃผ๋ฌธ ํ•ญ๋ชฉ ๋ชฉ๋ก + * @param couponId ์‚ฌ์šฉ๋œ ์ฟ ํฐ ID (nullable) + * @param couponDiscount ์ฟ ํฐ ํ• ์ธ์•ก + * @param pointDiscount ํฌ์ธํŠธ ํ• ์ธ์•ก + * @return ์ƒ์„ฑ๋œ Order + */ + public static Order createWithDiscount(Long userId, List items, + Long couponId, Money couponDiscount, Money pointDiscount) { + if (items == null || items.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ ํ•ญ๋ชฉ์ด ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค."); + } + + Money originalAmount = items.stream() + .map(OrderItem::getSubtotal) + .reduce(Money.ZERO, Money::add); + + Money totalDiscount = couponDiscount.add(pointDiscount); + Money finalAmount = originalAmount.subtract(totalDiscount); + + Order order = new Order(); + order.userId = userId; + order.items = new ArrayList<>(items); + order.originalAmount = originalAmount; + order.couponDiscount = couponDiscount; + order.pointDiscount = pointDiscount; + order.totalPrice = finalAmount; + order.couponId = couponId; order.status = OrderStatus.CREATED; order.createdAt = ZonedDateTime.now(); return order; @@ -60,6 +106,30 @@ public static Order reconstitute(Long id, Long userId, List items, order.userId = userId; order.items = new ArrayList<>(items); order.totalPrice = totalPrice; + order.originalAmount = totalPrice; + order.couponDiscount = Money.ZERO; + order.pointDiscount = Money.ZERO; + order.couponId = null; + order.status = status; + order.createdAt = createdAt; + return order; + } + + /** + * DB์—์„œ ๋ณต์› (ํ• ์ธ ์ •๋ณด ํฌํ•จ). + */ + public static Order reconstituteWithDiscount(Long id, Long userId, List items, + Money totalPrice, Money originalAmount, Money couponDiscount, Money pointDiscount, + Long couponId, OrderStatus status, ZonedDateTime createdAt) { + Order order = new Order(); + order.id = id; + order.userId = userId; + order.items = new ArrayList<>(items); + order.totalPrice = totalPrice; + order.originalAmount = originalAmount; + order.couponDiscount = couponDiscount; + order.pointDiscount = pointDiscount; + order.couponId = couponId; order.status = status; order.createdAt = createdAt; return order; @@ -89,4 +159,20 @@ public OrderStatus getStatus() { public ZonedDateTime getCreatedAt() { return createdAt; } + + public Money getOriginalAmount() { + return originalAmount; + } + + public Money getCouponDiscount() { + return couponDiscount; + } + + public Money getPointDiscount() { + return pointDiscount; + } + + public Long getCouponId() { + return couponId; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index e77f8f407..f013192b2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -33,7 +33,23 @@ public enum ErrorType { ORDER_ACCESS_DENIED(HttpStatus.FORBIDDEN, "ORDER_ACCESS_DENIED", "์ฃผ๋ฌธ ์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."), /** ๊ด€๋ฆฌ์ž ๊ด€๋ จ ์—๋Ÿฌ */ - ADMIN_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "ADMIN_UNAUTHORIZED", "๊ด€๋ฆฌ์ž ๊ถŒํ•œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."); + ADMIN_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "ADMIN_UNAUTHORIZED", "๊ด€๋ฆฌ์ž ๊ถŒํ•œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."), + + /** ์ฟ ํฐ ๊ด€๋ จ ์—๋Ÿฌ */ + COUPON_NOT_FOUND(HttpStatus.NOT_FOUND, "COUPON_NOT_FOUND", "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ฟ ํฐ์ž…๋‹ˆ๋‹ค."), + COUPON_EXHAUSTED(HttpStatus.BAD_REQUEST, "COUPON_EXHAUSTED", "์ฟ ํฐ ๋ฐœ๊ธ‰์ด ๋งˆ๊ฐ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + COUPON_ALREADY_ISSUED(HttpStatus.CONFLICT, "COUPON_ALREADY_ISSUED", "์ด๋ฏธ ๋ฐœ๊ธ‰๋ฐ›์€ ์ฟ ํฐ์ž…๋‹ˆ๋‹ค."), + COUPON_EXPIRED(HttpStatus.BAD_REQUEST, "COUPON_EXPIRED", "๋งŒ๋ฃŒ๋œ ์ฟ ํฐ์ž…๋‹ˆ๋‹ค."), + COUPON_NOT_AVAILABLE(HttpStatus.BAD_REQUEST, "COUPON_NOT_AVAILABLE", "์‚ฌ์šฉํ•  ์ˆ˜ ์—†๋Š” ์ฟ ํฐ์ž…๋‹ˆ๋‹ค."), + COUPON_ALREADY_USED(HttpStatus.BAD_REQUEST, "COUPON_ALREADY_USED", "์ด๋ฏธ ์‚ฌ์šฉ๋œ ์ฟ ํฐ์ž…๋‹ˆ๋‹ค."), + COUPON_ACCESS_DENIED(HttpStatus.FORBIDDEN, "COUPON_ACCESS_DENIED", "์ฟ ํฐ ์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."), + + /** ์ฃผ๋ฌธ ํ™•์žฅ ์—๋Ÿฌ */ + ORDER_AMOUNT_TOO_LOW(HttpStatus.BAD_REQUEST, "ORDER_AMOUNT_TOO_LOW", "์ตœ์†Œ ์ฃผ๋ฌธ ๊ธˆ์•ก์„ ์ถฉ์กฑํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค."), + + /** ํฌ์ธํŠธ ๊ด€๋ จ ์—๋Ÿฌ */ + POINT_NOT_FOUND(HttpStatus.NOT_FOUND, "POINT_NOT_FOUND", "ํฌ์ธํŠธ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + INSUFFICIENT_POINT(HttpStatus.BAD_REQUEST, "INSUFFICIENT_POINT", "ํฌ์ธํŠธ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); private final HttpStatus status; private final String code; diff --git a/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponAdminServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponAdminServiceTest.java new file mode 100644 index 000000000..aef910165 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponAdminServiceTest.java @@ -0,0 +1,181 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.common.Money; +import com.loopers.domain.coupon.CouponTemplate; +import com.loopers.domain.coupon.CouponType; +import com.loopers.fake.FakeCouponTemplateRepository; +import com.loopers.fake.FakeIssuedCouponRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +import java.time.ZonedDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CouponAdminServiceTest { + + private CouponAdminService couponAdminService; + private FakeCouponTemplateRepository couponTemplateRepository; + private FakeIssuedCouponRepository issuedCouponRepository; + + @BeforeEach + void setUp() { + couponTemplateRepository = new FakeCouponTemplateRepository(); + issuedCouponRepository = new FakeIssuedCouponRepository(); + couponAdminService = new CouponAdminService(couponTemplateRepository, issuedCouponRepository); + } + + @DisplayName("์ฟ ํฐ์„ ์ƒ์„ฑํ•  ๋•Œ,") + @Nested + class CreateCoupon { + + @DisplayName("์ •์•ก ์ฟ ํฐ์ด ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsFixedCoupon() { + // arrange + var request = new CreateCouponRequest( + "5000์› ํ• ์ธ ์ฟ ํฐ", + CouponType.FIXED, + 5000L, + 10000L, + null, + 100, + ZonedDateTime.now().plusDays(30) + ); + + // act + CouponTemplateResult result = couponAdminService.create(request); + + // assert + assertThat(result.id()).isNotNull(); + assertThat(result.name()).isEqualTo("5000์› ํ• ์ธ ์ฟ ํฐ"); + assertThat(result.type()).isEqualTo(CouponType.FIXED); + assertThat(result.value()).isEqualTo(5000L); + assertThat(result.minOrderAmount()).isEqualTo(10000L); + assertThat(result.maxIssueCount()).isEqualTo(100); + } + + @DisplayName("์ •๋ฅ  ์ฟ ํฐ์ด ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsRateCoupon() { + // arrange + var request = new CreateCouponRequest( + "10% ํ• ์ธ ์ฟ ํฐ", + CouponType.RATE, + 10L, // 10% + 20000L, + 5000, // ์ตœ๋Œ€ ํ• ์ธ 5000์› + 50, + ZonedDateTime.now().plusDays(14) + ); + + // act + CouponTemplateResult result = couponAdminService.create(request); + + // assert + assertThat(result.id()).isNotNull(); + assertThat(result.type()).isEqualTo(CouponType.RATE); + assertThat(result.value()).isEqualTo(1000L); // 10% = 1000 + assertThat(result.maxDiscountAmount()).isEqualTo(5000); + } + } + + @DisplayName("์ฟ ํฐ์„ ์กฐํšŒํ•  ๋•Œ,") + @Nested + class FindCoupon { + + @DisplayName("์กด์žฌํ•˜๋Š” ์ฟ ํฐ์ด ์กฐํšŒ๋œ๋‹ค.") + @Test + void findsCoupon_whenExists() { + // arrange + CouponTemplate saved = couponTemplateRepository.save( + CouponTemplate.createFixed("ํ…Œ์ŠคํŠธ ์ฟ ํฐ", new Money(3000), Money.ZERO, 10, ZonedDateTime.now().plusDays(7)) + ); + + // act + CouponTemplateResult result = couponAdminService.findById(saved.getId()); + + // assert + assertThat(result.id()).isEqualTo(saved.getId()); + assertThat(result.name()).isEqualTo("ํ…Œ์ŠคํŠธ ์ฟ ํฐ"); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ฟ ํฐ ์กฐํšŒ ์‹œ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsException_whenNotFound() { + // act + CoreException result = assertThrows(CoreException.class, + () -> couponAdminService.findById(999L)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.COUPON_NOT_FOUND); + } + } + + @DisplayName("์ฟ ํฐ ๋ชฉ๋ก์„ ์กฐํšŒํ•  ๋•Œ,") + @Nested + class FindAllCoupons { + + @DisplayName("ํ™œ์„ฑ ์ฟ ํฐ ๋ชฉ๋ก์ด ์กฐํšŒ๋œ๋‹ค.") + @Test + void findsActiveCoupons() { + // arrange + couponTemplateRepository.save( + CouponTemplate.createFixed("์ฟ ํฐ1", new Money(1000), Money.ZERO, 10, ZonedDateTime.now().plusDays(7)) + ); + couponTemplateRepository.save( + CouponTemplate.createFixed("์ฟ ํฐ2", new Money(2000), Money.ZERO, 20, ZonedDateTime.now().plusDays(14)) + ); + CouponTemplate deletedCoupon = couponTemplateRepository.save( + CouponTemplate.createFixed("์‚ญ์ œ๋œ ์ฟ ํฐ", new Money(3000), Money.ZERO, 30, ZonedDateTime.now().plusDays(30)) + ); + deletedCoupon.delete(); + couponTemplateRepository.save(deletedCoupon); + + // act + Page result = couponAdminService.findAll(PageRequest.of(0, 10)); + + // assert + assertThat(result.getTotalElements()).isEqualTo(2); + } + } + + @DisplayName("์ฟ ํฐ์„ ์‚ญ์ œํ•  ๋•Œ,") + @Nested + class DeleteCoupon { + + @DisplayName("์กด์žฌํ•˜๋Š” ์ฟ ํฐ์ด ์‚ญ์ œ๋œ๋‹ค.") + @Test + void deletesCoupon_whenExists() { + // arrange + CouponTemplate saved = couponTemplateRepository.save( + CouponTemplate.createFixed("์‚ญ์ œ ๋Œ€์ƒ ์ฟ ํฐ", new Money(5000), Money.ZERO, 10, ZonedDateTime.now().plusDays(7)) + ); + + // act + couponAdminService.delete(saved.getId()); + + // assert + CouponTemplate deleted = couponTemplateRepository.findById(saved.getId()).orElseThrow(); + assertThat(deleted.isDeleted()).isTrue(); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ฟ ํฐ ์‚ญ์ œ ์‹œ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsException_whenNotFound() { + // act + CoreException result = assertThrows(CoreException.class, + () -> couponAdminService.delete(999L)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.COUPON_NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponUserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponUserServiceTest.java new file mode 100644 index 000000000..aa7d7253b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponUserServiceTest.java @@ -0,0 +1,207 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.common.Money; +import com.loopers.domain.coupon.CouponTemplate; +import com.loopers.domain.coupon.IssuedCoupon; +import com.loopers.domain.coupon.IssuedCouponStatus; +import com.loopers.fake.FakeCouponTemplateRepository; +import com.loopers.fake.FakeIssuedCouponRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CouponUserServiceTest { + + private CouponUserService couponUserService; + private FakeCouponTemplateRepository couponTemplateRepository; + private FakeIssuedCouponRepository issuedCouponRepository; + + @BeforeEach + void setUp() { + couponTemplateRepository = new FakeCouponTemplateRepository(); + issuedCouponRepository = new FakeIssuedCouponRepository(); + couponUserService = new CouponUserService(couponTemplateRepository, issuedCouponRepository); + } + + @DisplayName("์ฟ ํฐ์„ ๋ฐœ๊ธ‰๋ฐ›์„ ๋•Œ,") + @Nested + class IssueCoupon { + + @DisplayName("์œ ํšจํ•œ ์ฟ ํฐ์ด ์ •์ƒ์ ์œผ๋กœ ๋ฐœ๊ธ‰๋œ๋‹ค.") + @Test + void issuesCoupon_whenValid() { + // arrange + Long userId = 1L; + CouponTemplate template = couponTemplateRepository.save( + CouponTemplate.createFixed("ํ…Œ์ŠคํŠธ ์ฟ ํฐ", new Money(5000), Money.ZERO, 10, ZonedDateTime.now().plusDays(7)) + ); + + // act + IssuedCouponResult result = couponUserService.issue(userId, template.getId()); + + // assert + assertThat(result.id()).isNotNull(); + assertThat(result.userId()).isEqualTo(userId); + assertThat(result.couponTemplateId()).isEqualTo(template.getId()); + assertThat(result.status()).isEqualTo(IssuedCouponStatus.AVAILABLE); + assertThat(result.usable()).isTrue(); + } + + @DisplayName("๋ฐœ๊ธ‰ ์‹œ ํ…œํ”Œ๋ฆฟ์˜ ๋ฐœ๊ธ‰ ์ˆ˜๊ฐ€ ์ฆ๊ฐ€ํ•œ๋‹ค.") + @Test + void incrementsIssuedCount() { + // arrange + Long userId = 1L; + CouponTemplate template = couponTemplateRepository.save( + CouponTemplate.createFixed("ํ…Œ์ŠคํŠธ ์ฟ ํฐ", new Money(5000), Money.ZERO, 10, ZonedDateTime.now().plusDays(7)) + ); + + // act + couponUserService.issue(userId, template.getId()); + + // assert + CouponTemplate updated = couponTemplateRepository.findById(template.getId()).orElseThrow(); + assertThat(updated.getIssuedCount()).isEqualTo(1); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ฟ ํฐ ๋ฐœ๊ธ‰ ์‹œ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsException_whenCouponNotFound() { + // act + CoreException result = assertThrows(CoreException.class, + () -> couponUserService.issue(1L, 999L)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.COUPON_NOT_FOUND); + } + + @DisplayName("์ด๋ฏธ ๋ฐœ๊ธ‰๋ฐ›์€ ์ฟ ํฐ ์žฌ๋ฐœ๊ธ‰ ์‹œ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsException_whenAlreadyIssued() { + // arrange + Long userId = 1L; + CouponTemplate template = couponTemplateRepository.save( + CouponTemplate.createFixed("ํ…Œ์ŠคํŠธ ์ฟ ํฐ", new Money(5000), Money.ZERO, 10, ZonedDateTime.now().plusDays(7)) + ); + couponUserService.issue(userId, template.getId()); + + // act + CoreException result = assertThrows(CoreException.class, + () -> couponUserService.issue(userId, template.getId())); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.COUPON_ALREADY_ISSUED); + } + + @DisplayName("๋ฐœ๊ธ‰ ํ•œ๋„์— ๋„๋‹ฌํ•œ ์ฟ ํฐ ๋ฐœ๊ธ‰ ์‹œ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsException_whenExhausted() { + // arrange + CouponTemplate template = CouponTemplate.reconstitute( + 1L, "ํ•œ๋„ ๋„๋‹ฌ ์ฟ ํฐ", com.loopers.domain.coupon.CouponType.FIXED, + new Money(5000), Money.ZERO, null, 1, 1, + ZonedDateTime.now().plusDays(7), ZonedDateTime.now(), null + ); + couponTemplateRepository.save(template); + + // act + CoreException result = assertThrows(CoreException.class, + () -> couponUserService.issue(1L, template.getId())); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.COUPON_EXHAUSTED); + } + } + + @DisplayName("๋‚ด ์ฟ ํฐ ๋ชฉ๋ก์„ ์กฐํšŒํ•  ๋•Œ,") + @Nested + class FindMyCoupons { + + @DisplayName("๋ฐœ๊ธ‰๋ฐ›์€ ์ฟ ํฐ ๋ชฉ๋ก์ด ์กฐํšŒ๋œ๋‹ค.") + @Test + void findsMyCoupons() { + // arrange + Long userId = 1L; + CouponTemplate template1 = couponTemplateRepository.save( + CouponTemplate.createFixed("์ฟ ํฐ1", new Money(1000), Money.ZERO, 100, ZonedDateTime.now().plusDays(7)) + ); + CouponTemplate template2 = couponTemplateRepository.save( + CouponTemplate.createFixed("์ฟ ํฐ2", new Money(2000), Money.ZERO, 100, ZonedDateTime.now().plusDays(14)) + ); + + couponUserService.issue(userId, template1.getId()); + couponUserService.issue(userId, template2.getId()); + + // act + List result = couponUserService.findMyCoupons(userId, 0, 10); + + // assert + assertThat(result).hasSize(2); + } + + @DisplayName("๋‹ค๋ฅธ ์‚ฌ์šฉ์ž์˜ ์ฟ ํฐ์€ ์กฐํšŒ๋˜์ง€ ์•Š๋Š”๋‹ค.") + @Test + void doesNotFindOtherUsersCoupons() { + // arrange + CouponTemplate template = couponTemplateRepository.save( + CouponTemplate.createFixed("์ฟ ํฐ", new Money(1000), Money.ZERO, 100, ZonedDateTime.now().plusDays(7)) + ); + couponUserService.issue(1L, template.getId()); + + // act + List result = couponUserService.findMyCoupons(2L, 0, 10); + + // assert + assertThat(result).isEmpty(); + } + } + + @DisplayName("์ฟ ํฐ ์ƒ์„ธ๋ฅผ ์กฐํšŒํ•  ๋•Œ,") + @Nested + class FindCouponDetail { + + @DisplayName("๋ณธ์ธ ์ฟ ํฐ์ด ์ •์ƒ์ ์œผ๋กœ ์กฐํšŒ๋œ๋‹ค.") + @Test + void findsCouponDetail() { + // arrange + Long userId = 1L; + CouponTemplate template = couponTemplateRepository.save( + CouponTemplate.createFixed("ํ…Œ์ŠคํŠธ ์ฟ ํฐ", new Money(5000), Money.ZERO, 10, ZonedDateTime.now().plusDays(7)) + ); + IssuedCouponResult issued = couponUserService.issue(userId, template.getId()); + + // act + IssuedCouponResult result = couponUserService.findMyCouponById(userId, issued.id()); + + // assert + assertThat(result.id()).isEqualTo(issued.id()); + assertThat(result.couponName()).isEqualTo("ํ…Œ์ŠคํŠธ ์ฟ ํฐ"); + } + + @DisplayName("๋‹ค๋ฅธ ์‚ฌ์šฉ์ž์˜ ์ฟ ํฐ ์กฐํšŒ ์‹œ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsException_whenAccessDenied() { + // arrange + CouponTemplate template = couponTemplateRepository.save( + CouponTemplate.createFixed("ํ…Œ์ŠคํŠธ ์ฟ ํฐ", new Money(5000), Money.ZERO, 10, ZonedDateTime.now().plusDays(7)) + ); + IssuedCouponResult issued = couponUserService.issue(1L, template.getId()); + + // act + CoreException result = assertThrows(CoreException.class, + () -> couponUserService.findMyCouponById(2L, issued.id())); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.COUPON_ACCESS_DENIED); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceTest.java index e64b91ccd..403382374 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceTest.java @@ -1,11 +1,18 @@ package com.loopers.application.order; import com.loopers.domain.common.Money; +import com.loopers.domain.coupon.CouponTemplate; +import com.loopers.domain.coupon.IssuedCoupon; +import com.loopers.domain.coupon.IssuedCouponStatus; import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.point.UserPoint; import com.loopers.domain.product.Product; import com.loopers.domain.product.Stock; +import com.loopers.fake.FakeCouponTemplateRepository; +import com.loopers.fake.FakeIssuedCouponRepository; import com.loopers.fake.FakeOrderRepository; import com.loopers.fake.FakeProductRepository; +import com.loopers.fake.FakeUserPointRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.BeforeEach; @@ -13,6 +20,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.time.ZonedDateTime; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -23,13 +31,25 @@ class OrderApplicationServiceTest { private FakeProductRepository fakeProductRepository; private FakeOrderRepository fakeOrderRepository; + private FakeCouponTemplateRepository fakeCouponTemplateRepository; + private FakeIssuedCouponRepository fakeIssuedCouponRepository; + private FakeUserPointRepository fakeUserPointRepository; private OrderApplicationService orderApplicationService; @BeforeEach void setUp() { fakeProductRepository = new FakeProductRepository(); fakeOrderRepository = new FakeOrderRepository(); - orderApplicationService = new OrderApplicationService(fakeProductRepository, fakeOrderRepository); + fakeCouponTemplateRepository = new FakeCouponTemplateRepository(); + fakeIssuedCouponRepository = new FakeIssuedCouponRepository(); + fakeUserPointRepository = new FakeUserPointRepository(); + orderApplicationService = new OrderApplicationService( + fakeProductRepository, + fakeOrderRepository, + fakeCouponTemplateRepository, + fakeIssuedCouponRepository, + fakeUserPointRepository + ); } private Product createAndSaveProduct(String name, long price, int stock) { @@ -250,4 +270,186 @@ class CountOrders { assertThat(count).isEqualTo(2); } } + + @Nested + @DisplayName("์ฟ ํฐ/ํฌ์ธํŠธ ํ• ์ธ ์ฃผ๋ฌธ") + class PlaceOrderWithDiscount { + + @Test + @DisplayName("์„ฑ๊ณต - ์ฟ ํฐ๋งŒ ์ ์šฉ") + void ์ฟ ํฐ๋งŒ_์ ์šฉ_์ฃผ๋ฌธ_์„ฑ๊ณต() { + // Arrange + Product product = createAndSaveProduct("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", 50000, 100); + Long userId = 1L; + + // ์ฟ ํฐ ์„ค์ • + CouponTemplate template = fakeCouponTemplateRepository.save( + CouponTemplate.createFixed("5000์› ํ• ์ธ", new Money(5000), new Money(10000), 100, ZonedDateTime.now().plusDays(7)) + ); + IssuedCoupon issued = fakeIssuedCouponRepository.save( + IssuedCoupon.create(userId, template) + ); + + var request = new PlaceOrderWithDiscountRequest( + List.of(new OrderItemRequest(product.getId(), 1)), + issued.getId(), + null + ); + + // Act + OrderResult result = orderApplicationService.placeOrderWithDiscount(userId, request); + + // Assert + assertThat(result.originalAmount()).isEqualTo(50000); + assertThat(result.couponDiscount()).isEqualTo(5000); + assertThat(result.pointDiscount()).isEqualTo(0); + assertThat(result.totalPrice()).isEqualTo(45000); + assertThat(result.couponId()).isEqualTo(issued.getId()); + + // ์ฟ ํฐ ์‚ฌ์šฉ ์ฒ˜๋ฆฌ ํ™•์ธ + IssuedCoupon usedCoupon = fakeIssuedCouponRepository.findById(issued.getId()).orElseThrow(); + assertThat(usedCoupon.getStatus()).isEqualTo(IssuedCouponStatus.USED); + } + + @Test + @DisplayName("์„ฑ๊ณต - ํฌ์ธํŠธ๋งŒ ์ ์šฉ") + void ํฌ์ธํŠธ๋งŒ_์ ์šฉ_์ฃผ๋ฌธ_์„ฑ๊ณต() { + // Arrange + Product product = createAndSaveProduct("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", 30000, 100); + Long userId = 1L; + + // ํฌ์ธํŠธ ์„ค์ • + fakeUserPointRepository.save(UserPoint.create(userId, 10000)); + + var request = new PlaceOrderWithDiscountRequest( + List.of(new OrderItemRequest(product.getId(), 1)), + null, + 3000L + ); + + // Act + OrderResult result = orderApplicationService.placeOrderWithDiscount(userId, request); + + // Assert + assertThat(result.originalAmount()).isEqualTo(30000); + assertThat(result.couponDiscount()).isEqualTo(0); + assertThat(result.pointDiscount()).isEqualTo(3000); + assertThat(result.totalPrice()).isEqualTo(27000); + + // ํฌ์ธํŠธ ์ฐจ๊ฐ ํ™•์ธ + UserPoint updatedPoint = fakeUserPointRepository.findByUserId(userId).orElseThrow(); + assertThat(updatedPoint.getBalance()).isEqualTo(7000); + } + + @Test + @DisplayName("์„ฑ๊ณต - ์ฟ ํฐ๊ณผ ํฌ์ธํŠธ ๋ชจ๋‘ ์ ์šฉ") + void ์ฟ ํฐ_ํฌ์ธํŠธ_๋™์‹œ_์ ์šฉ_์„ฑ๊ณต() { + // Arrange + Product product = createAndSaveProduct("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", 50000, 100); + Long userId = 1L; + + // ์ฟ ํฐ ์„ค์ • + CouponTemplate template = fakeCouponTemplateRepository.save( + CouponTemplate.createFixed("5000์› ํ• ์ธ", new Money(5000), Money.ZERO, 100, ZonedDateTime.now().plusDays(7)) + ); + IssuedCoupon issued = fakeIssuedCouponRepository.save( + IssuedCoupon.create(userId, template) + ); + + // ํฌ์ธํŠธ ์„ค์ • + fakeUserPointRepository.save(UserPoint.create(userId, 10000)); + + var request = new PlaceOrderWithDiscountRequest( + List.of(new OrderItemRequest(product.getId(), 1)), + issued.getId(), + 2000L + ); + + // Act + OrderResult result = orderApplicationService.placeOrderWithDiscount(userId, request); + + // Assert + assertThat(result.originalAmount()).isEqualTo(50000); + assertThat(result.couponDiscount()).isEqualTo(5000); + assertThat(result.pointDiscount()).isEqualTo(2000); + assertThat(result.totalPrice()).isEqualTo(43000); + } + + @Test + @DisplayName("์‹คํŒจ - ์ตœ์†Œ ์ฃผ๋ฌธ ๊ธˆ์•ก ๋ฏธ๋‹ฌ") + void ์ตœ์†Œ_์ฃผ๋ฌธ_๊ธˆ์•ก_๋ฏธ๋‹ฌ_์˜ˆ์™ธ() { + // Arrange + Product product = createAndSaveProduct("์ €๊ฐ€ ์ƒํ’ˆ", 5000, 100); + Long userId = 1L; + + // ์ตœ์†Œ ์ฃผ๋ฌธ ๊ธˆ์•ก 10000์› ์ฟ ํฐ + CouponTemplate template = fakeCouponTemplateRepository.save( + CouponTemplate.createFixed("3000์› ํ• ์ธ", new Money(3000), new Money(10000), 100, ZonedDateTime.now().plusDays(7)) + ); + IssuedCoupon issued = fakeIssuedCouponRepository.save( + IssuedCoupon.create(userId, template) + ); + + var request = new PlaceOrderWithDiscountRequest( + List.of(new OrderItemRequest(product.getId(), 1)), + issued.getId(), + null + ); + + // Act & Assert + CoreException ex = assertThrows(CoreException.class, + () -> orderApplicationService.placeOrderWithDiscount(userId, request)); + assertThat(ex.getErrorType()).isEqualTo(ErrorType.ORDER_AMOUNT_TOO_LOW); + } + + @Test + @DisplayName("์‹คํŒจ - ํƒ€์ธ ์ฟ ํฐ ์‚ฌ์šฉ ์‹œ๋„") + void ํƒ€์ธ_์ฟ ํฐ_์‚ฌ์šฉ_์˜ˆ์™ธ() { + // Arrange + Product product = createAndSaveProduct("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", 50000, 100); + Long userId = 1L; + Long otherUserId = 2L; + + // ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž์˜ ์ฟ ํฐ + CouponTemplate template = fakeCouponTemplateRepository.save( + CouponTemplate.createFixed("5000์› ํ• ์ธ", new Money(5000), Money.ZERO, 100, ZonedDateTime.now().plusDays(7)) + ); + IssuedCoupon issued = fakeIssuedCouponRepository.save( + IssuedCoupon.create(otherUserId, template) + ); + + var request = new PlaceOrderWithDiscountRequest( + List.of(new OrderItemRequest(product.getId(), 1)), + issued.getId(), + null + ); + + // Act & Assert + CoreException ex = assertThrows(CoreException.class, + () -> orderApplicationService.placeOrderWithDiscount(userId, request)); + assertThat(ex.getErrorType()).isEqualTo(ErrorType.COUPON_ACCESS_DENIED); + } + + @Test + @DisplayName("์‹คํŒจ - ํฌ์ธํŠธ ๋ถ€์กฑ") + void ํฌ์ธํŠธ_๋ถ€์กฑ_์˜ˆ์™ธ() { + // Arrange + Product product = createAndSaveProduct("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", 30000, 100); + Long userId = 1L; + + // ํฌ์ธํŠธ ๋ถ€์กฑ + fakeUserPointRepository.save(UserPoint.create(userId, 1000)); + + var request = new PlaceOrderWithDiscountRequest( + List.of(new OrderItemRequest(product.getId(), 1)), + null, + 5000L // 1000์›๋ฐ–์— ์—†๋Š”๋ฐ 5000์› ์‚ฌ์šฉ ์‹œ๋„ + ); + + // Act & Assert + CoreException ex = assertThrows(CoreException.class, + () -> orderApplicationService.placeOrderWithDiscount(userId, request)); + assertThat(ex.getErrorType()).isEqualTo(ErrorType.INSUFFICIENT_POINT); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java index b6918a5b5..a980b40ba 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -141,4 +141,95 @@ void reconstitutesAllFields() { assertThat(order.getCreatedAt()).isEqualTo(createdAt); } } + + @DisplayName("ํ• ์ธ์ด ์ ์šฉ๋œ Order๋ฅผ ์ƒ์„ฑํ•  ๋•Œ,") + @Nested + class CreateWithDiscount { + + @DisplayName("์ฟ ํฐ ํ• ์ธ๋งŒ ์ ์šฉ๋œ ์ฃผ๋ฌธ์ด ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsOrder_withCouponDiscountOnly() { + // arrange + Long userId = 1L; + List items = List.of( + OrderItem.create(100L, "์ƒํ’ˆ1", 2, new Money(10000)), // 20000 + OrderItem.create(200L, "์ƒํ’ˆ2", 1, new Money(30000)) // 30000 + ); + Long couponId = 999L; + Money couponDiscount = new Money(5000); + Money pointDiscount = Money.ZERO; + + // act + Order order = Order.createWithDiscount(userId, items, couponId, couponDiscount, pointDiscount); + + // assert + assertThat(order.getOriginalAmount().amount()).isEqualTo(50000); + assertThat(order.getCouponDiscount().amount()).isEqualTo(5000); + assertThat(order.getPointDiscount()).isEqualTo(Money.ZERO); + assertThat(order.getTotalPrice().amount()).isEqualTo(45000); + assertThat(order.getCouponId()).isEqualTo(couponId); + } + + @DisplayName("ํฌ์ธํŠธ ํ• ์ธ๋งŒ ์ ์šฉ๋œ ์ฃผ๋ฌธ์ด ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsOrder_withPointDiscountOnly() { + // arrange + Long userId = 1L; + List items = List.of( + OrderItem.create(100L, "์ƒํ’ˆ", 1, new Money(30000)) + ); + Money couponDiscount = Money.ZERO; + Money pointDiscount = new Money(3000); + + // act + Order order = Order.createWithDiscount(userId, items, null, couponDiscount, pointDiscount); + + // assert + assertThat(order.getOriginalAmount().amount()).isEqualTo(30000); + assertThat(order.getCouponDiscount()).isEqualTo(Money.ZERO); + assertThat(order.getPointDiscount().amount()).isEqualTo(3000); + assertThat(order.getTotalPrice().amount()).isEqualTo(27000); + assertThat(order.getCouponId()).isNull(); + } + + @DisplayName("์ฟ ํฐ๊ณผ ํฌ์ธํŠธ ํ• ์ธ์ด ๋ชจ๋‘ ์ ์šฉ๋œ ์ฃผ๋ฌธ์ด ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsOrder_withBothDiscounts() { + // arrange + Long userId = 1L; + List items = List.of( + OrderItem.create(100L, "์ƒํ’ˆ", 1, new Money(50000)) + ); + Long couponId = 123L; + Money couponDiscount = new Money(5000); + Money pointDiscount = new Money(2000); + + // act + Order order = Order.createWithDiscount(userId, items, couponId, couponDiscount, pointDiscount); + + // assert + assertThat(order.getOriginalAmount().amount()).isEqualTo(50000); + assertThat(order.getCouponDiscount().amount()).isEqualTo(5000); + assertThat(order.getPointDiscount().amount()).isEqualTo(2000); + assertThat(order.getTotalPrice().amount()).isEqualTo(43000); + assertThat(order.getCouponId()).isEqualTo(couponId); + } + + @DisplayName("ํ• ์ธ ์—†์ด ์ƒ์„ฑ๋œ ์ฃผ๋ฌธ์€ ์›๊ฐ€์™€ ์ตœ์ข…๊ฐ€๊ฐ€ ๊ฐ™๋‹ค.") + @Test + void createsOrder_withNoDiscount() { + // arrange + Long userId = 1L; + List items = List.of( + OrderItem.create(100L, "์ƒํ’ˆ", 1, new Money(20000)) + ); + + // act + Order order = Order.createWithDiscount(userId, items, null, Money.ZERO, Money.ZERO); + + // assert + assertThat(order.getOriginalAmount().amount()).isEqualTo(20000); + assertThat(order.getTotalPrice().amount()).isEqualTo(20000); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeCouponTemplateRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeCouponTemplateRepository.java new file mode 100644 index 000000000..b469d3a4a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeCouponTemplateRepository.java @@ -0,0 +1,86 @@ +package com.loopers.fake; + +import com.loopers.domain.coupon.CouponTemplate; +import com.loopers.domain.coupon.CouponTemplateRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +/** + * ํ…Œ์ŠคํŠธ์šฉ Fake CouponTemplateRepository. + */ +public class FakeCouponTemplateRepository implements CouponTemplateRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public CouponTemplate save(CouponTemplate template) { + Long id = template.getId(); + if (id == null) { + id = idGenerator.getAndIncrement(); + template = CouponTemplate.reconstitute( + id, + template.getName(), + template.getType(), + template.getValue(), + template.getMinOrderAmount(), + template.getMaxDiscountAmount(), + template.getMaxIssueCount(), + template.getIssuedCount(), + template.getExpiredAt(), + template.getCreatedAt(), + template.getDeletedAt() + ); + } + store.put(id, template); + return template; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public Optional findByIdActive(Long id) { + return findById(id) + .filter(t -> !t.isDeleted()); + } + + @Override + public Optional findByIdWithLock(Long id) { + return findByIdActive(id); + } + + @Override + public Page findAllActive(Pageable pageable) { + var list = store.values().stream() + .filter(t -> !t.isDeleted()) + .sorted(Comparator.comparing(CouponTemplate::getCreatedAt).reversed()) + .skip(pageable.getOffset()) + .limit(pageable.getPageSize()) + .toList(); + + long total = store.values().stream() + .filter(t -> !t.isDeleted()) + .count(); + + return new PageImpl<>(list, pageable, total); + } + + public void clear() { + store.clear(); + idGenerator.set(1); + } + + public int size() { + return store.size(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeIssuedCouponRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeIssuedCouponRepository.java new file mode 100644 index 000000000..4a37b9437 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeIssuedCouponRepository.java @@ -0,0 +1,105 @@ +package com.loopers.fake; + +import com.loopers.domain.coupon.IssuedCoupon; +import com.loopers.domain.coupon.IssuedCouponRepository; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +/** + * ํ…Œ์ŠคํŠธ์šฉ Fake IssuedCouponRepository. + */ +public class FakeIssuedCouponRepository implements IssuedCouponRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public IssuedCoupon save(IssuedCoupon coupon) { + Long id = coupon.getId(); + if (id == null) { + id = idGenerator.getAndIncrement(); + coupon = IssuedCoupon.reconstitute( + id, + coupon.getUserId(), + coupon.getCouponTemplateId(), + coupon.getStatus(), + coupon.getUsedAt(), + coupon.getIssuedAt(), + coupon.getExpiredAt() + ); + } + store.put(id, coupon); + return coupon; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public Optional findByIdWithLock(Long id) { + return findById(id); + } + + @Override + public Optional findByIdAndUserId(Long id, Long userId) { + return findById(id) + .filter(c -> c.getUserId().equals(userId)); + } + + @Override + public List findAllByUserId(Long userId, int offset, int limit) { + return store.values().stream() + .filter(c -> c.getUserId().equals(userId)) + .sorted(Comparator.comparing(IssuedCoupon::getIssuedAt).reversed()) + .skip(offset) + .limit(limit) + .toList(); + } + + @Override + public boolean existsByUserIdAndCouponTemplateId(Long userId, Long couponTemplateId) { + return store.values().stream() + .anyMatch(c -> c.getUserId().equals(userId) + && c.getCouponTemplateId().equals(couponTemplateId)); + } + + @Override + public long countByUserId(Long userId) { + return store.values().stream() + .filter(c -> c.getUserId().equals(userId)) + .count(); + } + + @Override + public List findAllByCouponTemplateId(Long couponTemplateId, int offset, int limit) { + return store.values().stream() + .filter(c -> c.getCouponTemplateId().equals(couponTemplateId)) + .sorted(Comparator.comparing(IssuedCoupon::getIssuedAt).reversed()) + .skip(offset) + .limit(limit) + .toList(); + } + + @Override + public long countByCouponTemplateId(Long couponTemplateId) { + return store.values().stream() + .filter(c -> c.getCouponTemplateId().equals(couponTemplateId)) + .count(); + } + + public void clear() { + store.clear(); + idGenerator.set(1); + } + + public int size() { + return store.size(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeOrderRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeOrderRepository.java index fe7ed0b2f..979e6d06b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/fake/FakeOrderRepository.java +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeOrderRepository.java @@ -37,11 +37,16 @@ public Order save(Order order) { )) .toList(); - order = Order.reconstitute( + // ํ• ์ธ ์ •๋ณด ํฌํ•จํ•˜์—ฌ reconstitute + order = Order.reconstituteWithDiscount( orderId, order.getUserId(), itemsWithIds, order.getTotalPrice(), + order.getOriginalAmount(), + order.getCouponDiscount(), + order.getPointDiscount(), + order.getCouponId(), order.getStatus(), order.getCreatedAt() ); diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeUserPointRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeUserPointRepository.java new file mode 100644 index 000000000..df4015ae7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeUserPointRepository.java @@ -0,0 +1,56 @@ +package com.loopers.fake; + +import com.loopers.domain.point.UserPoint; +import com.loopers.domain.point.UserPointRepository; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +/** + * ํ…Œ์ŠคํŠธ์šฉ Fake UserPointRepository. + */ +public class FakeUserPointRepository implements UserPointRepository { + + private final Map storeById = new HashMap<>(); + private final Map storeByUserId = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public UserPoint save(UserPoint point) { + Long id = point.getId(); + if (id == null) { + id = idGenerator.getAndIncrement(); + point = UserPoint.reconstitute( + id, + point.getUserId(), + point.getBalance(), + point.getUpdatedAt() + ); + } + storeById.put(id, point); + storeByUserId.put(point.getUserId(), point); + return point; + } + + @Override + public Optional findByUserId(Long userId) { + return Optional.ofNullable(storeByUserId.get(userId)); + } + + @Override + public Optional findByUserIdWithLock(Long userId) { + return findByUserId(userId); + } + + public void clear() { + storeById.clear(); + storeByUserId.clear(); + idGenerator.set(1); + } + + public int size() { + return storeById.size(); + } +} From 99fb42b11e94760525910f9b9d9a39f105038b4b Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 6 Mar 2026 15:27:56 +0900 Subject: [PATCH 27/29] =?UTF-8?q?feat(api):=20=EC=BF=A0=ED=8F=B0/=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8/=EC=A3=BC=EB=AC=B8=20REST=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CouponAdminV1Controller: /api-admin/v1/coupons/* - CouponV1Controller: /api/v1/coupons/*/issue, /api/v1/users/me/coupons - PointV1Controller: /api/v1/users/me/points - OrderV1Controller: /api/v1/orders/with-discount ์ถ”๊ฐ€ - @UserId + UserIdArgumentResolver: X-User-Id ํ—ค๋” ์ธ์ฆ ์ฒ˜๋ฆฌ Co-Authored-By: Claude Opus 4.5 --- .../java/com/loopers/config/WebMvcConfig.java | 3 + .../api/coupon/CouponAdminV1Controller.java | 98 +++++++++++ .../api/coupon/CouponV1Controller.java | 66 +++++++ .../interfaces/api/coupon/CouponV1Dto.java | 161 ++++++++++++++++++ .../api/order/OrderV1Controller.java | 78 +++++++++ .../interfaces/api/order/OrderV1Dto.java | 119 +++++++++++++ .../api/point/PointV1Controller.java | 43 +++++ .../interfaces/api/point/PointV1Dto.java | 37 ++++ .../interfaces/api/support/UserId.java | 14 ++ .../api/support/UserIdArgumentResolver.java | 44 +++++ 10 files changed, 663 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/UserId.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/UserIdArgumentResolver.java diff --git a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java index 9fa63b863..1f24ee362 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java @@ -1,6 +1,7 @@ package com.loopers.config; import com.loopers.interfaces.api.auth.AuthenticatedUserArgumentResolver; +import com.loopers.interfaces.api.support.UserIdArgumentResolver; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; @@ -13,9 +14,11 @@ public class WebMvcConfig implements WebMvcConfigurer { private final AuthenticatedUserArgumentResolver authenticatedUserArgumentResolver; + private final UserIdArgumentResolver userIdArgumentResolver; @Override public void addArgumentResolvers(List resolvers) { resolvers.add(authenticatedUserArgumentResolver); + resolvers.add(userIdArgumentResolver); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java new file mode 100644 index 000000000..734369512 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java @@ -0,0 +1,98 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.application.coupon.CouponAdminService; +import com.loopers.application.coupon.CouponTemplateResult; +import com.loopers.application.coupon.IssuedCouponResult; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * ์ฟ ํฐ ๊ด€๋ฆฌ์ž API. + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/coupons") +public class CouponAdminV1Controller { + + private final CouponAdminService couponAdminService; + + /** + * ์ฟ ํฐ ์ƒ์„ฑ. + */ + @PostMapping + public ApiResponse createCoupon( + @RequestBody CouponV1Dto.CreateCouponRequestDto request) { + CouponTemplateResult result = couponAdminService.create(request.toServiceRequest()); + return ApiResponse.success(CouponV1Dto.CouponTemplateResponseDto.from(result)); + } + + /** + * ์ฟ ํฐ ๋ชฉ๋ก ์กฐํšŒ. + */ + @GetMapping + public ApiResponse getCoupons( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + Page resultPage = couponAdminService.findAll(PageRequest.of(page, size)); + + List coupons = resultPage.getContent().stream() + .map(CouponV1Dto.CouponTemplateResponseDto::from) + .toList(); + + return ApiResponse.success(new CouponV1Dto.CouponTemplateListResponseDto( + coupons, + resultPage.getNumber(), + resultPage.getSize(), + resultPage.getTotalElements(), + resultPage.getTotalPages() + )); + } + + /** + * ์ฟ ํฐ ์ƒ์„ธ ์กฐํšŒ. + */ + @GetMapping("/{couponId}") + public ApiResponse getCoupon(@PathVariable Long couponId) { + CouponTemplateResult result = couponAdminService.findById(couponId); + return ApiResponse.success(CouponV1Dto.CouponTemplateResponseDto.from(result)); + } + + /** + * ์ฟ ํฐ ์‚ญ์ œ. + */ + @DeleteMapping("/{couponId}") + public ApiResponse deleteCoupon(@PathVariable Long couponId) { + couponAdminService.delete(couponId); + return ApiResponse.success(); + } + + /** + * ์ฟ ํฐ ๋ฐœ๊ธ‰ ๋‚ด์—ญ ์กฐํšŒ. + */ + @GetMapping("/{couponId}/issues") + public ApiResponse getIssues( + @PathVariable Long couponId, + @RequestParam(defaultValue = "0") int offset, + @RequestParam(defaultValue = "20") int limit) { + List issues = couponAdminService.findIssues(couponId, offset, limit); + long totalCount = couponAdminService.countIssues(couponId); + + List dtos = issues.stream() + .map(CouponV1Dto.IssuedCouponSimpleResponseDto::from) + .toList(); + + return ApiResponse.success(new CouponV1Dto.IssuedCouponListResponseDto(dtos, totalCount)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java new file mode 100644 index 000000000..2404244b8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java @@ -0,0 +1,66 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.application.coupon.CouponUserService; +import com.loopers.application.coupon.IssuedCouponResult; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.support.UserId; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * ์ฟ ํฐ ์‚ฌ์šฉ์ž API. + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1") +public class CouponV1Controller { + + private final CouponUserService couponUserService; + + /** + * ์ฟ ํฐ ๋ฐœ๊ธ‰. + */ + @PostMapping("/coupons/{couponId}/issue") + public ApiResponse issueCoupon( + @PathVariable Long couponId, + @UserId Long userId) { + IssuedCouponResult result = couponUserService.issue(userId, couponId); + return ApiResponse.success(CouponV1Dto.IssuedCouponResponseDto.from(result)); + } + + /** + * ๋‚ด ์ฟ ํฐ ๋ชฉ๋ก ์กฐํšŒ. + */ + @GetMapping("/users/me/coupons") + public ApiResponse getMyCoupons( + @UserId Long userId, + @RequestParam(defaultValue = "0") int offset, + @RequestParam(defaultValue = "20") int limit) { + List coupons = couponUserService.findMyCoupons(userId, offset, limit); + long totalCount = couponUserService.countMyCoupons(userId); + + List dtos = coupons.stream() + .map(CouponV1Dto.IssuedCouponResponseDto::from) + .toList(); + + return ApiResponse.success(new CouponV1Dto.MyCouponListResponseDto(dtos, totalCount)); + } + + /** + * ๋‚ด ์ฟ ํฐ ์ƒ์„ธ ์กฐํšŒ. + */ + @GetMapping("/users/me/coupons/{couponId}") + public ApiResponse getMyCoupon( + @UserId Long userId, + @PathVariable Long couponId) { + IssuedCouponResult result = couponUserService.findMyCouponById(userId, couponId); + return ApiResponse.success(CouponV1Dto.IssuedCouponResponseDto.from(result)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java new file mode 100644 index 000000000..31546c144 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java @@ -0,0 +1,161 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.application.coupon.CouponTemplateResult; +import com.loopers.application.coupon.CreateCouponRequest; +import com.loopers.application.coupon.IssuedCouponResult; +import com.loopers.domain.coupon.CouponDisplayStatus; +import com.loopers.domain.coupon.CouponType; +import com.loopers.domain.coupon.IssuedCouponStatus; + +import java.time.ZonedDateTime; +import java.util.List; + +/** + * ์ฟ ํฐ API DTO. + */ +public class CouponV1Dto { + + private CouponV1Dto() {} + + // ===== ๊ด€๋ฆฌ์ž API ===== + + /** + * ์ฟ ํฐ ์ƒ์„ฑ ์š”์ฒญ. + */ + public record CreateCouponRequestDto( + String name, + CouponType type, + Long value, + Long minOrderAmount, + Integer maxDiscountAmount, + Integer maxIssueCount, + ZonedDateTime expiredAt + ) { + public CreateCouponRequest toServiceRequest() { + return new CreateCouponRequest( + name, type, value, minOrderAmount, maxDiscountAmount, maxIssueCount, expiredAt + ); + } + } + + /** + * ์ฟ ํฐ ํ…œํ”Œ๋ฆฟ ์‘๋‹ต. + */ + public record CouponTemplateResponseDto( + Long id, + String name, + CouponType type, + long value, + long minOrderAmount, + Integer maxDiscountAmount, + Integer maxIssueCount, + int issuedCount, + ZonedDateTime expiredAt, + ZonedDateTime createdAt + ) { + public static CouponTemplateResponseDto from(CouponTemplateResult result) { + return new CouponTemplateResponseDto( + result.id(), + result.name(), + result.type(), + result.value(), + result.minOrderAmount(), + result.maxDiscountAmount(), + result.maxIssueCount(), + result.issuedCount(), + result.expiredAt(), + result.createdAt() + ); + } + } + + /** + * ์ฟ ํฐ ํ…œํ”Œ๋ฆฟ ๋ชฉ๋ก ์‘๋‹ต. + */ + public record CouponTemplateListResponseDto( + List coupons, + int page, + int size, + long totalElements, + int totalPages + ) {} + + /** + * ๋ฐœ๊ธ‰๋œ ์ฟ ํฐ ๋ชฉ๋ก ์‘๋‹ต (๊ด€๋ฆฌ์ž์šฉ). + */ + public record IssuedCouponListResponseDto( + List issues, + long totalCount + ) {} + + public record IssuedCouponSimpleResponseDto( + Long id, + Long userId, + IssuedCouponStatus status, + CouponDisplayStatus displayStatus, + boolean usable, + ZonedDateTime usedAt, + ZonedDateTime issuedAt, + ZonedDateTime expiredAt + ) { + public static IssuedCouponSimpleResponseDto from(IssuedCouponResult result) { + return new IssuedCouponSimpleResponseDto( + result.id(), + result.userId(), + result.status(), + result.displayStatus(), + result.usable(), + result.usedAt(), + result.issuedAt(), + result.expiredAt() + ); + } + } + + // ===== ์‚ฌ์šฉ์ž API ===== + + /** + * ๋ฐœ๊ธ‰๋œ ์ฟ ํฐ ์‘๋‹ต (์‚ฌ์šฉ์ž์šฉ). + */ + public record IssuedCouponResponseDto( + Long id, + Long couponTemplateId, + String couponName, + CouponType couponType, + long couponValue, + long minOrderAmount, + Integer maxDiscountAmount, + IssuedCouponStatus status, + CouponDisplayStatus displayStatus, + boolean usable, + ZonedDateTime usedAt, + ZonedDateTime issuedAt, + ZonedDateTime expiredAt + ) { + public static IssuedCouponResponseDto from(IssuedCouponResult result) { + return new IssuedCouponResponseDto( + result.id(), + result.couponTemplateId(), + result.couponName(), + result.couponType(), + result.couponValue(), + result.minOrderAmount(), + result.maxDiscountAmount(), + result.status(), + result.displayStatus(), + result.usable(), + result.usedAt(), + result.issuedAt(), + result.expiredAt() + ); + } + } + + /** + * ๋‚ด ์ฟ ํฐ ๋ชฉ๋ก ์‘๋‹ต. + */ + public record MyCouponListResponseDto( + List coupons, + long totalCount + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java new file mode 100644 index 000000000..520646fc8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,78 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderApplicationService; +import com.loopers.application.order.OrderResult; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.support.UserId; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * ์ฃผ๋ฌธ ์‚ฌ์šฉ์ž API. + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class OrderV1Controller { + + private final OrderApplicationService orderApplicationService; + + /** + * ์ฃผ๋ฌธ ์ƒ์„ฑ. + */ + @PostMapping + public ApiResponse placeOrder( + @UserId Long userId, + @RequestBody OrderV1Dto.PlaceOrderRequestDto request) { + OrderResult result = orderApplicationService.placeOrder(userId, request.toServiceRequest()); + return ApiResponse.success(OrderV1Dto.OrderResponseDto.from(result)); + } + + /** + * ํ• ์ธ ์ ์šฉ ์ฃผ๋ฌธ ์ƒ์„ฑ. + */ + @PostMapping("/with-discount") + public ApiResponse placeOrderWithDiscount( + @UserId Long userId, + @RequestBody OrderV1Dto.PlaceOrderWithDiscountRequestDto request) { + OrderResult result = orderApplicationService.placeOrderWithDiscount(userId, request.toServiceRequest()); + return ApiResponse.success(OrderV1Dto.OrderResponseDto.from(result)); + } + + /** + * ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ. + */ + @GetMapping("/{orderId}") + public ApiResponse getOrder( + @UserId Long userId, + @PathVariable Long orderId) { + OrderResult result = orderApplicationService.getOrder(orderId, userId); + return ApiResponse.success(OrderV1Dto.OrderResponseDto.from(result)); + } + + /** + * ๋‚ด ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ. + */ + @GetMapping + public ApiResponse getOrders( + @UserId Long userId, + @RequestParam(defaultValue = "0") int offset, + @RequestParam(defaultValue = "20") int limit) { + List orders = orderApplicationService.getOrders(userId, offset, limit); + long totalCount = orderApplicationService.countOrders(userId); + + List dtos = orders.stream() + .map(OrderV1Dto.OrderResponseDto::from) + .toList(); + + return ApiResponse.success(new OrderV1Dto.OrderListResponseDto(dtos, totalCount)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java new file mode 100644 index 000000000..9bd5bbd7c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,119 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderItemRequest; +import com.loopers.application.order.OrderItemResult; +import com.loopers.application.order.OrderResult; +import com.loopers.application.order.PlaceOrderWithDiscountRequest; +import com.loopers.domain.order.OrderStatus; + +import java.time.ZonedDateTime; +import java.util.List; + +/** + * ์ฃผ๋ฌธ API DTO. + */ +public class OrderV1Dto { + + private OrderV1Dto() {} + + /** + * ์ฃผ๋ฌธ ์ƒ์„ฑ ์š”์ฒญ. + */ + public record PlaceOrderRequestDto( + List items + ) { + public List toServiceRequest() { + return items.stream() + .map(item -> new OrderItemRequest(item.productId(), item.quantity())) + .toList(); + } + } + + /** + * ์ฃผ๋ฌธ ํ•ญ๋ชฉ ์š”์ฒญ. + */ + public record OrderItemRequestDto( + Long productId, + int quantity + ) {} + + /** + * ํ• ์ธ ์ ์šฉ ์ฃผ๋ฌธ ์ƒ์„ฑ ์š”์ฒญ. + */ + public record PlaceOrderWithDiscountRequestDto( + List items, + Long couponId, + Long pointAmount + ) { + public PlaceOrderWithDiscountRequest toServiceRequest() { + List itemRequests = items.stream() + .map(item -> new OrderItemRequest(item.productId(), item.quantity())) + .toList(); + return new PlaceOrderWithDiscountRequest(itemRequests, couponId, pointAmount); + } + } + + /** + * ์ฃผ๋ฌธ ์‘๋‹ต. + */ + public record OrderResponseDto( + Long id, + Long userId, + List items, + Long totalPrice, + Long originalAmount, + Long couponDiscount, + Long pointDiscount, + Long couponId, + OrderStatus status, + ZonedDateTime createdAt + ) { + public static OrderResponseDto from(OrderResult result) { + List items = result.items().stream() + .map(OrderItemResponseDto::from) + .toList(); + + return new OrderResponseDto( + result.id(), + result.userId(), + items, + result.totalPrice(), + result.originalAmount(), + result.couponDiscount(), + result.pointDiscount(), + result.couponId(), + result.status(), + result.createdAt() + ); + } + } + + /** + * ์ฃผ๋ฌธ ํ•ญ๋ชฉ ์‘๋‹ต. + */ + public record OrderItemResponseDto( + Long id, + Long productId, + String productName, + int quantity, + Long priceSnapshot + ) { + public static OrderItemResponseDto from(OrderItemResult result) { + return new OrderItemResponseDto( + result.id(), + result.productId(), + result.productName(), + result.quantity(), + result.priceSnapshot() + ); + } + } + + /** + * ์ฃผ๋ฌธ ๋ชฉ๋ก ์‘๋‹ต. + */ + public record OrderListResponseDto( + List orders, + long totalCount + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java new file mode 100644 index 000000000..2118f6c32 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java @@ -0,0 +1,43 @@ +package com.loopers.interfaces.api.point; + +import com.loopers.application.point.UserPointResult; +import com.loopers.application.point.UserPointService; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.support.UserId; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * ํฌ์ธํŠธ ์‚ฌ์šฉ์ž API. + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/users/me/points") +public class PointV1Controller { + + private final UserPointService userPointService; + + /** + * ๋‚ด ํฌ์ธํŠธ ์กฐํšŒ. + */ + @GetMapping + public ApiResponse getMyPoint(@UserId Long userId) { + UserPointResult result = userPointService.getPoint(userId); + return ApiResponse.success(PointV1Dto.PointResponseDto.from(result)); + } + + /** + * ํฌ์ธํŠธ ์ถฉ์ „. + */ + @PostMapping("/charge") + public ApiResponse chargePoint( + @UserId Long userId, + @RequestBody PointV1Dto.ChargePointRequestDto request) { + UserPointResult result = userPointService.charge(userId, request.amount()); + return ApiResponse.success(PointV1Dto.PointResponseDto.from(result)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java new file mode 100644 index 000000000..22d8120ba --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java @@ -0,0 +1,37 @@ +package com.loopers.interfaces.api.point; + +import com.loopers.application.point.UserPointResult; + +import java.time.ZonedDateTime; + +/** + * ํฌ์ธํŠธ API DTO. + */ +public class PointV1Dto { + + private PointV1Dto() {} + + /** + * ํฌ์ธํŠธ ์‘๋‹ต. + */ + public record PointResponseDto( + Long userId, + long balance, + ZonedDateTime updatedAt + ) { + public static PointResponseDto from(UserPointResult result) { + return new PointResponseDto( + result.userId(), + result.balance(), + result.updatedAt() + ); + } + } + + /** + * ํฌ์ธํŠธ ์ถฉ์ „ ์š”์ฒญ. + */ + public record ChargePointRequestDto( + long amount + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/UserId.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/UserId.java new file mode 100644 index 000000000..fecfa4cbb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/UserId.java @@ -0,0 +1,14 @@ +package com.loopers.interfaces.api.support; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * X-User-Id ํ—ค๋”์—์„œ ์‚ฌ์šฉ์ž ID๋ฅผ ์ถ”์ถœํ•˜๋Š” ์–ด๋…ธํ…Œ์ด์…˜. + */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface UserId { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/UserIdArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/UserIdArgumentResolver.java new file mode 100644 index 000000000..15811421d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/UserIdArgumentResolver.java @@ -0,0 +1,44 @@ +package com.loopers.interfaces.api.support; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +/** + * X-User-Id ํ—ค๋”์—์„œ ์‚ฌ์šฉ์ž ID๋ฅผ ์ถ”์ถœํ•˜๋Š” ArgumentResolver. + */ +@Component +public class UserIdArgumentResolver implements HandlerMethodArgumentResolver { + + private static final String USER_ID_HEADER = "X-User-Id"; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(UserId.class) + && parameter.getParameterType().equals(Long.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + String userIdHeader = webRequest.getHeader(USER_ID_HEADER); + + if (userIdHeader == null || userIdHeader.isBlank()) { + throw new CoreException(ErrorType.UNAUTHORIZED, "X-User-Id ํ—ค๋”๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."); + } + + try { + return Long.parseLong(userIdHeader); + } catch (NumberFormatException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "X-User-Id ํ—ค๋” ๊ฐ’์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + } +} From 7f7f53826fa27ff3db121fa06470035cfe893304 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 6 Mar 2026 15:28:03 +0900 Subject: [PATCH 28/29] =?UTF-8?q?test:=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OrderConcurrencyTest: ์žฌ๊ณ /์ฟ ํฐ/ํฌ์ธํŠธ ๋™์‹œ ์ฐจ๊ฐ ์ •ํ•ฉ์„ฑ - CouponConcurrencyTest: ์ฟ ํฐ ๋ฐœ๊ธ‰ ํ•œ๋„ ๋ฐ ์ค‘๋ณต ๋ฐœ๊ธ‰ ๋ฐฉ์ง€ - LikeConcurrencyTest: ์ข‹์•„์š” ๋™์‹œ ์š”์ฒญ ์ •ํ•ฉ์„ฑ Co-Authored-By: Claude Opus 4.5 --- .../concurrency/CouponConcurrencyTest.java | 123 ++++++++ .../concurrency/LikeConcurrencyTest.java | 176 ++++++++++++ .../concurrency/OrderConcurrencyTest.java | 262 ++++++++++++++++++ 3 files changed, 561 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/concurrency/CouponConcurrencyTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/concurrency/OrderConcurrencyTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/concurrency/CouponConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/concurrency/CouponConcurrencyTest.java new file mode 100644 index 000000000..9ac3c32d0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/concurrency/CouponConcurrencyTest.java @@ -0,0 +1,123 @@ +package com.loopers.concurrency; + +import com.loopers.application.coupon.CouponUserService; +import com.loopers.domain.common.Money; +import com.loopers.domain.coupon.CouponTemplate; +import com.loopers.domain.coupon.CouponTemplateRepository; +import com.loopers.domain.coupon.IssuedCouponRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.ZonedDateTime; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayName("์ฟ ํฐ ๋™์‹œ์„ฑ ํ…Œ์ŠคํŠธ") +class CouponConcurrencyTest { + + @Autowired + private CouponUserService couponUserService; + + @Autowired + private CouponTemplateRepository couponTemplateRepository; + + @Autowired + private IssuedCouponRepository issuedCouponRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("๋™์‹œ์— ์ฟ ํฐ์„ ๋ฐœ๊ธ‰๋ฐ›์•„๋„ ๋ฐœ๊ธ‰ ํ•œ๋„๊ฐ€ ์œ ์ง€๋œ๋‹ค") + void ๋™์‹œ_์ฟ ํฐ_๋ฐœ๊ธ‰_ํ•œ๋„_์œ ์ง€() throws InterruptedException { + // Arrange: ์ตœ๋Œ€ 10๊ฐœ ๋ฐœ๊ธ‰ ๊ฐ€๋Šฅํ•œ ์ฟ ํฐ + CouponTemplate template = couponTemplateRepository.save( + CouponTemplate.createFixed("ํ•œ์ • ์ฟ ํฐ", new Money(5000), Money.ZERO, 10, ZonedDateTime.now().plusDays(7)) + ); + + int threadCount = 20; // 20๋ช…์ด ๋™์‹œ์— ๋ฐœ๊ธ‰ ์‹œ๋„ + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failCount = new AtomicInteger(); + + // Act + for (int i = 0; i < threadCount; i++) { + long userId = i + 1; + executor.submit(() -> { + try { + couponUserService.issue(userId, template.getId()); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // Assert: 10๋ช…๋งŒ ์„ฑ๊ณต + assertThat(successCount.get()).isEqualTo(10); + assertThat(failCount.get()).isEqualTo(10); + + // ๋ฐœ๊ธ‰ ์ˆ˜ ํ™•์ธ + CouponTemplate updated = couponTemplateRepository.findById(template.getId()).orElseThrow(); + assertThat(updated.getIssuedCount()).isEqualTo(10); + } + + @Test + @DisplayName("๊ฐ™์€ ์‚ฌ์šฉ์ž๊ฐ€ ๋™์‹œ์— ๊ฐ™์€ ์ฟ ํฐ์„ ๋ฐœ๊ธ‰๋ฐ›์•„๋„ ์ค‘๋ณต ๋ฐœ๊ธ‰๋˜์ง€ ์•Š๋Š”๋‹ค") + void ๋™์‹œ_์ค‘๋ณต_๋ฐœ๊ธ‰_๋ฐฉ์ง€() throws InterruptedException { + // Arrange + CouponTemplate template = couponTemplateRepository.save( + CouponTemplate.createFixed("ํ…Œ์ŠคํŠธ ์ฟ ํฐ", new Money(3000), Money.ZERO, 100, ZonedDateTime.now().plusDays(7)) + ); + + Long userId = 1L; + int threadCount = 10; // ๊ฐ™์€ ์‚ฌ์šฉ์ž๊ฐ€ 10๋ฒˆ ๋™์‹œ ๋ฐœ๊ธ‰ ์‹œ๋„ + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failCount = new AtomicInteger(); + + // Act + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + couponUserService.issue(userId, template.getId()); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // Assert: 1๋ฒˆ๋งŒ ์„ฑ๊ณต + assertThat(successCount.get()).isEqualTo(1); + assertThat(failCount.get()).isEqualTo(9); + + // ๋ฐœ๊ธ‰๋œ ์ฟ ํฐ ์ˆ˜ ํ™•์ธ + long issuedCount = issuedCouponRepository.countByUserId(userId); + assertThat(issuedCount).isEqualTo(1); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java new file mode 100644 index 000000000..80086e4ec --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java @@ -0,0 +1,176 @@ +package com.loopers.concurrency; + +import com.loopers.application.like.LikeApplicationService; +import com.loopers.domain.common.Money; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.Stock; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayName("์ข‹์•„์š” ๋™์‹œ์„ฑ ํ…Œ์ŠคํŠธ") +class LikeConcurrencyTest { + + @Autowired + private LikeApplicationService likeApplicationService; + + @Autowired + private LikeRepository likeRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("์—ฌ๋Ÿฌ ์‚ฌ์šฉ์ž๊ฐ€ ๋™์‹œ์— ๊ฐ™์€ ์ƒํ’ˆ์— ์ข‹์•„์š”๋ฅผ ํ•ด๋„ ์ข‹์•„์š” ์ˆ˜๊ฐ€ ์ •์ƒ ๋ฐ˜์˜๋œ๋‹ค") + void ๋™์‹œ_์ข‹์•„์š”_์ •์ƒ_๋ฐ˜์˜() throws InterruptedException { + // Arrange + Product product = productRepository.save( + Product.create(1L, "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", "์„ค๋ช…", new Money(10000), new Stock(100), "http://image.url") + ); + + int userCount = 50; // 50๋ช…์ด ๋™์‹œ์— ์ข‹์•„์š” + ExecutorService executor = Executors.newFixedThreadPool(userCount); + CountDownLatch latch = new CountDownLatch(userCount); + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failCount = new AtomicInteger(); + + // Act + for (int i = 0; i < userCount; i++) { + long userId = i + 1; + executor.submit(() -> { + try { + likeApplicationService.like(userId, product.getId()); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // Assert: ๋ชจ๋“  ์ข‹์•„์š”๊ฐ€ ์„ฑ๊ณตํ•ด์•ผ ํ•จ + assertThat(successCount.get()).isEqualTo(userCount); + assertThat(failCount.get()).isEqualTo(0); + + // ์ข‹์•„์š” ์ˆ˜ ํ™•์ธ + long likeCount = likeRepository.countByProductId(product.getId()); + assertThat(likeCount).isEqualTo(userCount); + } + + @Test + @DisplayName("๊ฐ™์€ ์‚ฌ์šฉ์ž๊ฐ€ ๋™์‹œ์— ์ข‹์•„์š”๋ฅผ ํ•ด๋„ 1๋ฒˆ๋งŒ ์„ฑ๊ณตํ•œ๋‹ค") + void ๋™์‹œ_์ค‘๋ณต_์ข‹์•„์š”_๋ฐฉ์ง€() throws InterruptedException { + // Arrange + Product product = productRepository.save( + Product.create(1L, "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", "์„ค๋ช…", new Money(10000), new Stock(100), "http://image.url") + ); + + Long userId = 1L; + int threadCount = 10; // ๊ฐ™์€ ์‚ฌ์šฉ์ž๊ฐ€ 10๋ฒˆ ๋™์‹œ ์ข‹์•„์š” ์‹œ๋„ + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failCount = new AtomicInteger(); + + // Act + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + likeApplicationService.like(userId, product.getId()); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // Assert: 1๋ฒˆ๋งŒ ์„ฑ๊ณตํ•ด์•ผ ํ•จ + assertThat(successCount.get()).isEqualTo(1); + assertThat(failCount.get()).isEqualTo(9); + + // ์ข‹์•„์š” ์ˆ˜ ํ™•์ธ + long likeCount = likeRepository.countByProductId(product.getId()); + assertThat(likeCount).isEqualTo(1); + } + + @Test + @DisplayName("์—ฌ๋Ÿฌ ์‚ฌ์šฉ์ž๊ฐ€ ์ข‹์•„์š”์™€ ์ทจ์†Œ๋ฅผ ๋™์‹œ์— ํ•ด๋„ ์ตœ์ข… ์ข‹์•„์š” ์ˆ˜๊ฐ€ ์ •ํ™•ํ•˜๋‹ค") + void ๋™์‹œ_์ข‹์•„์š”_์ทจ์†Œ_์ •ํ•ฉ์„ฑ() throws InterruptedException { + // Arrange + Product product = productRepository.save( + Product.create(1L, "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", "์„ค๋ช…", new Money(10000), new Stock(100), "http://image.url") + ); + + // ๋จผ์ € 10๋ช…์ด ์ข‹์•„์š” ๋“ฑ๋ก + int initialLikeCount = 10; + for (int i = 1; i <= initialLikeCount; i++) { + likeApplicationService.like((long) i, product.getId()); + } + + // ๋™์‹œ์—: ๊ธฐ์กด 10๋ช… ์ทจ์†Œ + ์ƒˆ๋กœ์šด 10๋ช… ์ข‹์•„์š” + int threadCount = 20; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger likeSuccessCount = new AtomicInteger(); + AtomicInteger unlikeSuccessCount = new AtomicInteger(); + + // Act + for (int i = 0; i < threadCount; i++) { + final int index = i; + executor.submit(() -> { + try { + if (index < 10) { + // ๊ธฐ์กด ์‚ฌ์šฉ์ž 1~10 ์ทจ์†Œ + likeApplicationService.unlike((long) (index + 1), product.getId()); + unlikeSuccessCount.incrementAndGet(); + } else { + // ์ƒˆ ์‚ฌ์šฉ์ž 11~20 ์ข‹์•„์š” + likeApplicationService.like((long) (index + 1), product.getId()); + likeSuccessCount.incrementAndGet(); + } + } catch (Exception e) { + // ์˜ˆ์™ธ ๋ฌด์‹œ + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // Assert: ๊ธฐ์กด 10๋ช… ์ทจ์†Œ, ์ƒˆ๋กœ์šด 10๋ช… ์ข‹์•„์š” โ†’ ์ตœ์ข… 10๊ฐœ + assertThat(unlikeSuccessCount.get()).isEqualTo(10); + assertThat(likeSuccessCount.get()).isEqualTo(10); + + long finalLikeCount = likeRepository.countByProductId(product.getId()); + assertThat(finalLikeCount).isEqualTo(10); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/concurrency/OrderConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/concurrency/OrderConcurrencyTest.java new file mode 100644 index 000000000..77737540c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/concurrency/OrderConcurrencyTest.java @@ -0,0 +1,262 @@ +package com.loopers.concurrency; + +import com.loopers.application.order.OrderApplicationService; +import com.loopers.application.order.OrderItemRequest; +import com.loopers.application.order.PlaceOrderWithDiscountRequest; +import com.loopers.domain.common.Money; +import com.loopers.domain.coupon.CouponTemplate; +import com.loopers.domain.coupon.CouponTemplateRepository; +import com.loopers.domain.coupon.IssuedCoupon; +import com.loopers.domain.coupon.IssuedCouponRepository; +import com.loopers.domain.coupon.IssuedCouponStatus; +import com.loopers.domain.point.UserPoint; +import com.loopers.domain.point.UserPointRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.Stock; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayName("์ฃผ๋ฌธ ๋™์‹œ์„ฑ ํ…Œ์ŠคํŠธ") +class OrderConcurrencyTest { + + @Autowired + private OrderApplicationService orderApplicationService; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private CouponTemplateRepository couponTemplateRepository; + + @Autowired + private IssuedCouponRepository issuedCouponRepository; + + @Autowired + private UserPointRepository userPointRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("๋™์‹œ์— ์žฌ๊ณ ๋ฅผ ์ฐจ๊ฐํ•ด๋„ ์ •ํ•ฉ์„ฑ์ด ์œ ์ง€๋œ๋‹ค") + void ๋™์‹œ_์žฌ๊ณ _์ฐจ๊ฐ_์ •ํ•ฉ์„ฑ() throws InterruptedException { + // Arrange: ์žฌ๊ณ  10๊ฐœ ์ƒํ’ˆ + Product product = productRepository.save( + Product.create(1L, "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", "์„ค๋ช…", new Money(10000), new Stock(10), "http://image.url") + ); + + int threadCount = 20; // 20๋ช…์ด ๋™์‹œ์— 1๊ฐœ์”ฉ ์ฃผ๋ฌธ + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failCount = new AtomicInteger(); + + // Act + for (int i = 0; i < threadCount; i++) { + long userId = i + 1; + executor.submit(() -> { + try { + orderApplicationService.placeOrder(userId, List.of( + new OrderItemRequest(product.getId(), 1) + )); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // Assert: 10๊ฐœ๋งŒ ์„ฑ๊ณต, ๋‚˜๋จธ์ง€ 10๊ฐœ๋Š” ์žฌ๊ณ  ๋ถ€์กฑ์œผ๋กœ ์‹คํŒจ + assertThat(successCount.get()).isEqualTo(10); + assertThat(failCount.get()).isEqualTo(10); + + // ์žฌ๊ณ ๊ฐ€ 0์ธ์ง€ ํ™•์ธ + Product updated = productRepository.findById(product.getId()).orElseThrow(); + assertThat(updated.getStock().quantity()).isEqualTo(0); + } + + @Test + @DisplayName("๊ฐ™์€ ์ฟ ํฐ์„ ๋™์‹œ์— ์‚ฌ์šฉํ•ด๋„ ํ•œ ๋ฒˆ๋งŒ ์‚ฌ์šฉ๋œ๋‹ค") + void ๋™์‹œ_์ฟ ํฐ_์‚ฌ์šฉ_์ค‘๋ณต_๋ฐฉ์ง€() throws InterruptedException { + // Arrange: ์ƒํ’ˆ๊ณผ ์ฟ ํฐ ์„ค์ • + Product product = productRepository.save( + Product.create(1L, "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", "์„ค๋ช…", new Money(50000), new Stock(100), "http://image.url") + ); + + CouponTemplate template = couponTemplateRepository.save( + CouponTemplate.createFixed("5000์› ํ• ์ธ", new Money(5000), Money.ZERO, 100, ZonedDateTime.now().plusDays(7)) + ); + + // ์‚ฌ์šฉ์ž 1์—๊ฒŒ ์ฟ ํฐ ๋ฐœ๊ธ‰ + Long userId = 1L; + IssuedCoupon coupon = issuedCouponRepository.save(IssuedCoupon.create(userId, template)); + + int threadCount = 10; // 10๋ฒˆ ๋™์‹œ ์‚ฌ์šฉ ์‹œ๋„ + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failCount = new AtomicInteger(); + + // Act + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + orderApplicationService.placeOrderWithDiscount(userId, + new PlaceOrderWithDiscountRequest( + List.of(new OrderItemRequest(product.getId(), 1)), + coupon.getId(), + null + ) + ); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // Assert: ์ •ํ™•ํžˆ 1๋ฒˆ๋งŒ ์„ฑ๊ณต + assertThat(successCount.get()).isEqualTo(1); + assertThat(failCount.get()).isEqualTo(9); + + // ์ฟ ํฐ ์ƒํƒœ ํ™•์ธ + IssuedCoupon used = issuedCouponRepository.findById(coupon.getId()).orElseThrow(); + assertThat(used.getStatus()).isEqualTo(IssuedCouponStatus.USED); + } + + @Test + @DisplayName("๋™์‹œ์— ํฌ์ธํŠธ๋ฅผ ์‚ฌ์šฉํ•ด๋„ ์ •ํ•ฉ์„ฑ์ด ์œ ์ง€๋œ๋‹ค") + void ๋™์‹œ_ํฌ์ธํŠธ_์ฐจ๊ฐ_์ •ํ•ฉ์„ฑ() throws InterruptedException { + // Arrange: 10000 ํฌ์ธํŠธ ์„ค์ • + Long userId = 1L; + userPointRepository.save(UserPoint.create(userId, 10000)); + + Product product = productRepository.save( + Product.create(1L, "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", "์„ค๋ช…", new Money(5000), new Stock(100), "http://image.url") + ); + + int threadCount = 10; // 10๋ฒˆ ๋™์‹œ์— 1000ํฌ์ธํŠธ์”ฉ ์‚ฌ์šฉ ์‹œ๋„ + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failCount = new AtomicInteger(); + + // Act + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + orderApplicationService.placeOrderWithDiscount(userId, + new PlaceOrderWithDiscountRequest( + List.of(new OrderItemRequest(product.getId(), 1)), + null, + 1000L // 1000 ํฌ์ธํŠธ ์‚ฌ์šฉ + ) + ); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // Assert: 10๋ฒˆ ๋ชจ๋‘ ์„ฑ๊ณต (10000 ํฌ์ธํŠธ โ†’ ๊ฐ 1000์”ฉ 10๋ฒˆ) + assertThat(successCount.get()).isEqualTo(10); + assertThat(failCount.get()).isEqualTo(0); + + // ํฌ์ธํŠธ๊ฐ€ 0์ธ์ง€ ํ™•์ธ + UserPoint updated = userPointRepository.findByUserId(userId).orElseThrow(); + assertThat(updated.getBalance()).isEqualTo(0); + } + + @Test + @DisplayName("์ฃผ๋ฌธ ์‹คํŒจ ์‹œ ์ฟ ํฐ์ด ๋กค๋ฐฑ๋œ๋‹ค") + void ์ฃผ๋ฌธ_์‹คํŒจ_์‹œ_์ฟ ํฐ_๋กค๋ฐฑ() throws InterruptedException { + // Arrange: ์žฌ๊ณ  0๊ฐœ ์ƒํ’ˆ๊ณผ ์ฟ ํฐ ์„ค์ • + Product product = productRepository.save( + Product.create(1L, "ํ’ˆ์ ˆ ์ƒํ’ˆ", "์„ค๋ช…", new Money(50000), new Stock(0), "http://image.url") + ); + + CouponTemplate template = couponTemplateRepository.save( + CouponTemplate.createFixed("5000์› ํ• ์ธ", new Money(5000), Money.ZERO, 100, ZonedDateTime.now().plusDays(7)) + ); + + Long userId = 1L; + IssuedCoupon coupon = issuedCouponRepository.save(IssuedCoupon.create(userId, template)); + + // Act: ์žฌ๊ณ  ๋ถ€์กฑ์œผ๋กœ ์‹คํŒจํ•ด์•ผ ํ•จ + try { + orderApplicationService.placeOrderWithDiscount(userId, + new PlaceOrderWithDiscountRequest( + List.of(new OrderItemRequest(product.getId(), 1)), + coupon.getId(), + null + ) + ); + } catch (Exception ignored) { + } + + // Assert: ์ฟ ํฐ์ด AVAILABLE ์ƒํƒœ๋กœ ์œ ์ง€ + IssuedCoupon unchanged = issuedCouponRepository.findById(coupon.getId()).orElseThrow(); + assertThat(unchanged.getStatus()).isEqualTo(IssuedCouponStatus.AVAILABLE); + } + + @Test + @DisplayName("์ฃผ๋ฌธ ์‹คํŒจ ์‹œ ํฌ์ธํŠธ๊ฐ€ ๋กค๋ฐฑ๋œ๋‹ค") + void ์ฃผ๋ฌธ_์‹คํŒจ_์‹œ_ํฌ์ธํŠธ_๋กค๋ฐฑ() throws InterruptedException { + // Arrange: ํฌ์ธํŠธ์™€ ํ’ˆ์ ˆ ์ƒํ’ˆ + Long userId = 1L; + userPointRepository.save(UserPoint.create(userId, 10000)); + + Product product = productRepository.save( + Product.create(1L, "ํ’ˆ์ ˆ ์ƒํ’ˆ", "์„ค๋ช…", new Money(50000), new Stock(0), "http://image.url") + ); + + // Act: ์žฌ๊ณ  ๋ถ€์กฑ์œผ๋กœ ์‹คํŒจํ•ด์•ผ ํ•จ + try { + orderApplicationService.placeOrderWithDiscount(userId, + new PlaceOrderWithDiscountRequest( + List.of(new OrderItemRequest(product.getId(), 1)), + null, + 5000L + ) + ); + } catch (Exception ignored) { + } + + // Assert: ํฌ์ธํŠธ๊ฐ€ ์›๋ž˜๋Œ€๋กœ ์œ ์ง€ + UserPoint unchanged = userPointRepository.findByUserId(userId).orElseThrow(); + assertThat(unchanged.getBalance()).isEqualTo(10000); + } +} From 613a96a7849e16c427ccb99bab847c5eb7c0b8ae Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 6 Mar 2026 15:28:14 +0900 Subject: [PATCH 29/29] =?UTF-8?q?docs:=20HTTP=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - coupon-admin.http: ๊ด€๋ฆฌ์ž ์ฟ ํฐ API - coupon-user.http: ์‚ฌ์šฉ์ž ์ฟ ํฐ API - point.http: ํฌ์ธํŠธ API - order-with-coupon.http: ํ• ์ธ ์ ์šฉ ์ฃผ๋ฌธ API Co-Authored-By: Claude Opus 4.5 --- http/coupon-admin.http | 39 ++++++++++++++++++++++++++++++ http/coupon-user.http | 11 +++++++++ http/order-with-coupon.http | 47 +++++++++++++++++++++++++++++++++++++ http/point.http | 12 ++++++++++ 4 files changed, 109 insertions(+) create mode 100644 http/coupon-admin.http create mode 100644 http/coupon-user.http create mode 100644 http/order-with-coupon.http create mode 100644 http/point.http diff --git a/http/coupon-admin.http b/http/coupon-admin.http new file mode 100644 index 000000000..23660408a --- /dev/null +++ b/http/coupon-admin.http @@ -0,0 +1,39 @@ +### ์ฟ ํฐ ์ƒ์„ฑ - ์ •์•ก ํ• ์ธ +POST http://localhost:8080/api-admin/v1/coupons +Content-Type: application/json + +{ + "name": "์‹ ๊ทœ ๊ฐ€์ž… 5000์› ํ• ์ธ ์ฟ ํฐ", + "type": "FIXED", + "value": 5000, + "minOrderAmount": 10000, + "maxDiscountAmount": null, + "maxIssueCount": 100, + "expiredAt": "2026-04-06T23:59:59+09:00" +} + +### ์ฟ ํฐ ์ƒ์„ฑ - ์ •๋ฅ  ํ• ์ธ +POST http://localhost:8080/api-admin/v1/coupons +Content-Type: application/json + +{ + "name": "VIP 10% ํ• ์ธ ์ฟ ํฐ", + "type": "RATE", + "value": 10, + "minOrderAmount": 20000, + "maxDiscountAmount": 5000, + "maxIssueCount": 50, + "expiredAt": "2026-04-06T23:59:59+09:00" +} + +### ์ฟ ํฐ ๋ชฉ๋ก ์กฐํšŒ +GET http://localhost:8080/api-admin/v1/coupons?page=0&size=20 + +### ์ฟ ํฐ ์ƒ์„ธ ์กฐํšŒ +GET http://localhost:8080/api-admin/v1/coupons/1 + +### ์ฟ ํฐ ๋ฐœ๊ธ‰ ๋‚ด์—ญ ์กฐํšŒ +GET http://localhost:8080/api-admin/v1/coupons/1/issues?offset=0&limit=20 + +### ์ฟ ํฐ ์‚ญ์ œ +DELETE http://localhost:8080/api-admin/v1/coupons/1 diff --git a/http/coupon-user.http b/http/coupon-user.http new file mode 100644 index 000000000..2af2fe430 --- /dev/null +++ b/http/coupon-user.http @@ -0,0 +1,11 @@ +### ์ฟ ํฐ ๋ฐœ๊ธ‰ +POST http://localhost:8080/api/v1/coupons/1/issue +X-User-Id: 1 + +### ๋‚ด ์ฟ ํฐ ๋ชฉ๋ก ์กฐํšŒ +GET http://localhost:8080/api/v1/users/me/coupons?offset=0&limit=20 +X-User-Id: 1 + +### ๋‚ด ์ฟ ํฐ ์ƒ์„ธ ์กฐํšŒ +GET http://localhost:8080/api/v1/users/me/coupons/1 +X-User-Id: 1 diff --git a/http/order-with-coupon.http b/http/order-with-coupon.http new file mode 100644 index 000000000..feaa4b069 --- /dev/null +++ b/http/order-with-coupon.http @@ -0,0 +1,47 @@ +### ์ฃผ๋ฌธ ์ƒ์„ฑ (์ฟ ํฐ๋งŒ ์ ์šฉ) +POST http://localhost:8080/api/v1/orders/with-discount +Content-Type: application/json +X-User-Id: 1 + +{ + "items": [ + { + "productId": 1, + "quantity": 2 + } + ], + "couponId": 1, + "pointAmount": null +} + +### ์ฃผ๋ฌธ ์ƒ์„ฑ (ํฌ์ธํŠธ๋งŒ ์ ์šฉ) +POST http://localhost:8080/api/v1/orders/with-discount +Content-Type: application/json +X-User-Id: 1 + +{ + "items": [ + { + "productId": 1, + "quantity": 1 + } + ], + "couponId": null, + "pointAmount": 3000 +} + +### ์ฃผ๋ฌธ ์ƒ์„ฑ (์ฟ ํฐ + ํฌ์ธํŠธ ์ ์šฉ) +POST http://localhost:8080/api/v1/orders/with-discount +Content-Type: application/json +X-User-Id: 1 + +{ + "items": [ + { + "productId": 1, + "quantity": 1 + } + ], + "couponId": 1, + "pointAmount": 2000 +} diff --git a/http/point.http b/http/point.http new file mode 100644 index 000000000..9170aa5d0 --- /dev/null +++ b/http/point.http @@ -0,0 +1,12 @@ +### ๋‚ด ํฌ์ธํŠธ ์กฐํšŒ +GET http://localhost:8080/api/v1/users/me/points +X-User-Id: 1 + +### ํฌ์ธํŠธ ์ถฉ์ „ +POST http://localhost:8080/api/v1/users/me/points/charge +Content-Type: application/json +X-User-Id: 1 + +{ + "amount": 10000 +}