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` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. diff --git a/.cursor/rules/domain-coupon.mdc b/.cursor/rules/domain-coupon.mdc new file mode 100644 index 000000000..e54c033c8 --- /dev/null +++ b/.cursor/rules/domain-coupon.mdc @@ -0,0 +1,37 @@ +--- +description: Coupon ๋„๋ฉ”์ธ ์ž‘์—… ์‹œ ์ ์šฉ. ์ฟ ํฐ ํ…œํ”Œ๋ฆฟ, ๋ฐœ๊ธ‰ ์ฟ ํฐ, ์ฃผ๋ฌธ ํ• ์ธ ๊ด€๋ จ. +globs: ["**/domain/coupon/**", "**/application/coupon/**", "**/infrastructure/coupon/**", "**/interfaces/api/coupon/**"] +alwaysApply: false +--- + +# Coupon ๋„๋ฉ”์ธ ๊ทœ์น™ + +- `domain/coupon/README.md`, `CLAUDE.md` ์ฐธ์กฐ + +## ๋„๋ฉ”์ธ ๋ชจ๋ธ + +- **CouponTemplate**: Admin์ด ์ƒ์„ฑํ•˜๋Š” ์ฟ ํฐ ์ข…๋ฅ˜ ์ •์˜ (์ •์•ก FIXED / ์ •๋ฅ  RATE) +- **IssuedCoupon**: ์‚ฌ์šฉ์ž์—๊ฒŒ ๋ฐœ๊ธ‰๋œ ๊ฐœ๋ณ„ ์ฟ ํฐ. ์ƒํƒœ ๊ด€๋ฆฌ (AVAILABLE โ†’ USED) +- CouponTemplate๊ณผ IssuedCoupon์€ ID ์ฐธ์กฐ ๊ด€๊ณ„ (JPA ์—ฐ๊ด€๊ด€๊ณ„ X) + +## ํ•ต์‹ฌ ๊ทœ์น™ + +- ์ฟ ํฐ์€ 1ํšŒ๋งŒ ์‚ฌ์šฉ ๊ฐ€๋Šฅ. `IssuedCoupon.use()`๋กœ ์ƒํƒœ ์ „์ด +- ์ฟ ํฐ ์‚ฌ์šฉ ์‹œ ๋ฐ˜๋“œ์‹œ **๋น„๊ด€์  ๋ฝ**(`findByIdForUpdate`) ์‚ฌ์šฉ +- ์ฟ ํฐ ์†Œ์œ ์ž ๊ฒ€์ฆ: `validateOwnership(memberId)` +- ์ฟ ํฐ ๋งŒ๋ฃŒ ๊ฒ€์ฆ: `CouponTemplate.isExpired()` +- ์ตœ์†Œ ์ฃผ๋ฌธ ๊ธˆ์•ก ๊ฒ€์ฆ: `CouponTemplate.validateMinOrderAmount()` +- ํ• ์ธ ๊ณ„์‚ฐ: `CouponTemplate.calculateDiscount(orderAmount)` + - FIXED: min(value, orderAmount) + - RATE: orderAmount * value / 100 + +## ์ฃผ๋ฌธ๊ณผ์˜ ๊ด€๊ณ„ + +- ์ฟ ํฐ์€ Order ๋„๋ฉ”์ธ๊ณผ ๋‹ค๋ฅธ ๋„๋ฉ”์ธ. ์กฐํ•ฉ์€ `OrderService`(Application Layer)์—์„œ ๋‹ด๋‹น +- Order ์—”ํ‹ฐํ‹ฐ์— `couponId`, `originalAmount`, `discountAmount` ์Šค๋ƒ…์ƒท ์ €์žฅ +- ์ฃผ๋ฌธ ์‹คํŒจ ์‹œ ํŠธ๋žœ์žญ์…˜ ๋กค๋ฐฑ์œผ๋กœ ์ฟ ํฐ ์‚ฌ์šฉ๋„ ํ•จ๊ป˜ ๋กค๋ฐฑ + +## ๋™์‹œ์„ฑ + +- IssuedCoupon: `PESSIMISTIC_WRITE` ๋ฝ์œผ๋กœ ๋™์‹œ ์‚ฌ์šฉ ๋ฐฉ์ง€ +- ๋™์ผ ์ฟ ํฐ์œผ๋กœ ๋™์‹œ ์ฃผ๋ฌธ ์‹œ 1๊ฑด๋งŒ ์„ฑ๊ณต, ๋‚˜๋จธ์ง€ ์‹คํŒจ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..daad56c7c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,187 @@ +# CLAUDE.md + +AI ์–ด์‹œ์Šคํ„ดํŠธ๊ฐ€ ๋ณธ ํ”„๋กœ์ ํŠธ์˜ ์ฝ”๋”ฉ ๊ทœ์น™, ์•„ํ‚คํ…์ฒ˜, ๋„๋ฉ”์ธ ์„ค๊ณ„ ์ „๋žต์„ ์ค€์ˆ˜ํ•˜๋„๋ก ์•ˆ๋‚ดํ•˜๋Š” ๋ฌธ์„œ์ž…๋‹ˆ๋‹ค. + +--- + +## ๊ฐ์ฒด์ง€ํ–ฅ & ๋„๋ฉ”์ธ ๋ชจ๋ธ๋ง ๊ทœ์น™ + +### ํ•ต์‹ฌ ์›์น™ + +- **๋„๋ฉ”์ธ ๊ฐ์ฒด๋Š” ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™์„ ์บก์Аํ™”**ํ•œ๋‹ค. ๋ฐ์ดํ„ฐ ์ €์žฅ์†Œ๊ฐ€ ์•„๋‹Œ, ๊ทœ์น™๊ณผ ํ–‰์œ„๋ฅผ ๊ฐ€์ง„ ๊ฐ์ฒด๋กœ ์„ค๊ณ„ํ•œ๋‹ค. +- **์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„œ๋น„์Šค**๋Š” ์„œ๋กœ ๋‹ค๋ฅธ ๋„๋ฉ”์ธ์„ ์กฐ๋ฆฝํ•˜๊ณ , ๋„๋ฉ”์ธ ๋กœ์ง์„ ์กฐ์ •ํ•˜์—ฌ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•œ๋‹ค. ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™์„ ์ง์ ‘ ๊ตฌํ˜„ํ•˜์ง€ ์•Š๋Š”๋‹ค. +- **๊ทœ์น™์ด ์—ฌ๋Ÿฌ ์„œ๋น„์Šค์— ๋ฐ˜๋ณต๋˜๋ฉด** ๋„๋ฉ”์ธ ๊ฐ์ฒด(Entity, VO, Domain Service)๋กœ ์˜ฎ๊ธธ ๊ฐ€๋Šฅ์„ฑ์ด ๋†’๋‹ค. +- ๊ฐ ๊ธฐ๋Šฅ์˜ **์ฑ…์ž„๊ณผ ๊ฒฐํ•ฉ๋„**๋ฅผ ๋ช…ํ™•ํžˆ ํ•˜๊ณ , ๊ฐœ๋ฐœ ์˜๋„์— ๋งž๊ฒŒ ์„ค๊ณ„ํ•œ๋‹ค. + +### Entity ์„ค๊ณ„ + +- **๊ณ ์œ  ์‹๋ณ„์ž(ID)**๋ฅผ ๊ฐ€์ง€๋ฉฐ, ์ž์‹ ์˜ ์ƒํƒœ๋ฅผ ๋ณ€๊ฒฝํ•˜๋Š” **ํ–‰์œ„ ๋ฉ”์„œ๋“œ**๋ฅผ ์ œ๊ณตํ•œ๋‹ค. +- `changePassword()`, `cancelOrder()`, `decreaseStock()`์ฒ˜๋Ÿผ **์˜๋„๊ฐ€ ๋“œ๋Ÿฌ๋‚˜๋Š” ๋ฉ”์„œ๋“œ๋ช…**์„ ์‚ฌ์šฉํ•œ๋‹ค. +- **๋ฌด๋ถ„๋ณ„ํ•œ Setter ์‚ฌ์šฉ์„ ๊ธˆ์ง€**ํ•œ๋‹ค. ์ƒํƒœ ๋ณ€๊ฒฝ์€ ๋ฐ˜๋“œ์‹œ ๋„๋ฉ”์ธ ๋ฉ”์„œ๋“œ๋ฅผ ํ†ตํ•ด ์ด๋ฃจ์–ด์ง„๋‹ค. +- ์ƒ์„ฑ์ž์™€ ๋น„์ฆˆ๋‹ˆ์Šค ๋ฉ”์„œ๋“œ ๋‚ด๋ถ€์—์„œ **ํ•„์ˆ˜ ๊ฒ€์ฆ**์„ ์ˆ˜ํ–‰ํ•œ๋‹ค. + +### Value Object (VO) ์„ค๊ณ„ + +- **์‹๋ณ„์ž๊ฐ€ ์—†๋Š”** ๊ฐ’ ๊ฐ์ฒด์ด๋‹ค. +- **๋ถˆ๋ณ€(Immutable)**์œผ๋กœ ์„ค๊ณ„ํ•˜๊ณ , ์ƒ์„ฑ ์‹œ์ ์— **์ž์ฒด ๊ฒ€์ฆ ๋กœ์ง**์„ ํฌํ•จํ•œ๋‹ค. +- `Money`, `Quantity`, `LoginId`, `Email`์ฒ˜๋Ÿผ ์˜๋ฏธ ์žˆ๋Š” ๋‹จ์œ„๋กœ ๋ถ„๋ฆฌํ•œ๋‹ค. +- ๋™์ผ์„ฑ์€ **๊ฐ’์˜ ๋™๋“ฑ์„ฑ**์œผ๋กœ ํŒ๋‹จํ•œ๋‹ค. + +### Domain Service ์„ค๊ณ„ + +- **ํŠน์ • ์—”ํ‹ฐํ‹ฐ์— ๋‘๊ธฐ ์–ด๋ ค์šด** ์—ฌ๋Ÿฌ ์—”ํ‹ฐํ‹ฐ ๊ฐ„ ์กฐ์œจ์ด๋‚˜ ๋ณต์žกํ•œ ๋„๋ฉ”์ธ ์ •์ฑ…์„ ์ฒ˜๋ฆฌํ•œ๋‹ค. +- **๋ฌด์ƒํƒœ(Stateless)**๋กœ ์„ค๊ณ„ํ•œ๋‹ค. +- **๋™์ผํ•œ ๋„๋ฉ”์ธ ๊ฒฝ๊ณ„ ๋‚ด**์˜ ๋„๋ฉ”์ธ ๊ฐ์ฒด ํ˜‘๋ ฅ์— ์ง‘์ค‘ํ•œ๋‹ค. +- ๋„๋ฉ”์ธ ๋‚ด๋ถ€ ๊ทœ์น™์€ Domain Service์— ๋‘๊ณ , Application Layer๋Š” ์กฐํ•ฉ๋งŒ ๋‹ด๋‹นํ•œ๋‹ค. + +### ๋นˆ์•ฝํ•œ ๋„๋ฉ”์ธ ๋ชจ๋ธ ์ง€์–‘ + +- ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ Application Service์— ๋‘์ง€ ๋ง๊ณ , **Entity์™€ VO ๋‚ด๋ถ€์— ์‘์ง‘**์‹œํ‚จ๋‹ค. +- Getter/Setter๋งŒ ์žˆ๋Š” ๋ฐ์ดํ„ฐ ํด๋ž˜์Šค๋Š” ์ง€์–‘ํ•˜๊ณ , **ํ–‰์œ„๊ฐ€ ๋“œ๋Ÿฌ๋‚˜๋Š” ๋ฉ”์„œ๋“œ**๋ฅผ ์šฐ์„ ํ•œ๋‹ค. + +--- + +## ์•„ํ‚คํ…์ฒ˜ ์ „๋žต & ํŒจํ‚ค์ง€ ๊ตฌ์„ฑ + +### ๋ ˆ์ด์–ด๋“œ ์•„ํ‚คํ…์ฒ˜ + DIP + +- ๋ณธ ํ”„๋กœ์ ํŠธ๋Š” **๋ ˆ์ด์–ด๋“œ ์•„ํ‚คํ…์ฒ˜**๋ฅผ ๋”ฐ๋ฅด๋ฉฐ, **DIP(์˜์กด์„ฑ ์—ญ์ „ ์›์น™)**๋ฅผ ์ค€์ˆ˜ํ•œ๋‹ค. +- ์˜์กด์„ฑ ๋ฐฉํ–ฅ: **Infrastructure โ†’ Domain โ† Application** +- **Domain Layer**๋Š” ์™ธ๋ถ€ ๊ธฐ์ˆ (JPA, Spring ๋“ฑ)์— ์˜์กดํ•˜์ง€ ์•Š๋Š”๋‹ค. + +### ๊ณ„์ธต ๊ตฌ์กฐ + +``` +Application โ”€โ”€โ†’ Domain โ†โ”€โ”€ Infrastructure + โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ โ””โ”€โ”€ Repository ๊ตฌํ˜„์ฒด, JPA Entity, Mapper + โ”‚ โ””โ”€โ”€ Entity, VO, Domain Service, Repository Interface + โ””โ”€โ”€ Service, Facade (๋„๋ฉ”์ธ ์กฐํ•ฉ, ํ๋ฆ„ ์ œ์–ด) +``` + +- **Interfaces (Presentation)**: HTTP ์š”์ฒญ/์‘๋‹ต, Request/Response DTO, ์ž…๋ ฅ ๊ฒ€์ฆ. ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ์—†์Œ. +- **Application**: ํŠธ๋žœ์žญ์…˜ ๊ด€๋ฆฌ, ๋„๋ฉ”์ธ ์กฐํ•ฉ, ํ๋ฆ„ ์ œ์–ด. ๋กœ์ง์€ ๋„๋ฉ”์ธ์— ์œ„์ž„. +- **Domain**: ์ˆœ์ˆ˜ ๋„๋ฉ”์ธ ๊ฐ์ฒด, Repository Interface. ์™ธ๋ถ€ ์˜์กด์„ฑ 0%. +- **Infrastructure**: Repository ๊ตฌํ˜„์ฒด, JPA Entity, DB/Redis ๋“ฑ ๊ธฐ์ˆ  ๊ตฌํ˜„. + +### DTO ๋ถ„๋ฆฌ + +- **API Request/Response DTO**์™€ **Application Layer DTO**๋Š” ๋ถ„๋ฆฌํ•ด ์ž‘์„ฑํ•œ๋‹ค. +- API DTO๋Š” `interfaces.api.*`์—, Application DTO๋Š” `application.*`์— ์œ„์น˜ํ•œ๋‹ค. + +### ํŒจํ‚ค์ง€ ๊ตฌ์„ฑ + +4๊ฐœ ๋ ˆ์ด์–ด ํŒจํ‚ค์ง€๋ฅผ ๋‘๊ณ , ํ•˜์œ„์— **๋„๋ฉ”์ธ๋ณ„**๋กœ ํŒจํ‚ค์ง•ํ•œ๋‹ค. + +``` +/interfaces/api/{domain} # Presentation - API Controller, API DTO +/application/{domain} # Application - Service, Facade, Application DTO +/domain/{domain} # Domain - Entity, VO, Domain Service, Repository Interface +/infrastructure/{domain} # Infrastructure - Repository ๊ตฌํ˜„์ฒด, JPA Entity, Mapper +``` + +**์˜ˆ์‹œ** + +``` +com.loopers +โ”œโ”€โ”€ interfaces +โ”‚ โ””โ”€โ”€ api +โ”‚ โ”œโ”€โ”€ product +โ”‚ โ”œโ”€โ”€ order +โ”‚ โ””โ”€โ”€ like +โ”œโ”€โ”€ application +โ”‚ โ”œโ”€โ”€ product +โ”‚ โ”‚ โ”œโ”€โ”€ ProductFacade +โ”‚ โ”‚ โ””โ”€โ”€ ProductInfo +โ”‚ โ””โ”€โ”€ order +โ”‚ โ””โ”€โ”€ OrderService +โ”œโ”€โ”€ domain +โ”‚ โ”œโ”€โ”€ product +โ”‚ โ”‚ โ”œโ”€โ”€ Product +โ”‚ โ”‚ โ”œโ”€โ”€ Brand +โ”‚ โ”‚ โ”œโ”€โ”€ Stock +โ”‚ โ”‚ โ””โ”€โ”€ ProductRepository +โ”‚ โ”œโ”€โ”€ like +โ”‚ โ”‚ โ”œโ”€โ”€ Like +โ”‚ โ”‚ โ””โ”€โ”€ LikeRepository +โ”‚ โ””โ”€โ”€ order +โ”‚ โ”œโ”€โ”€ Order +โ”‚ โ”œโ”€โ”€ OrderLine +โ”‚ โ””โ”€โ”€ OrderRepository +โ””โ”€โ”€ infrastructure + โ”œโ”€โ”€ product + โ”‚ โ”œโ”€โ”€ ProductRepositoryImpl + โ”‚ โ””โ”€โ”€ ProductJpaRepository + โ””โ”€โ”€ order + โ””โ”€โ”€ OrderRepositoryImpl +``` + +### Service vs Facade + +- **Service**: ํŠธ๋žœ์žญ์…˜ ๊ด€๋ฆฌ, **์ƒํƒœ ๋ณ€๊ฒฝ**์ด ์žˆ๋Š” ๋ณต์žกํ•œ ๋น„์ฆˆ๋‹ˆ์Šค ํ๋ฆ„. ๋„๋ฉ”์ธ ๊ฐ์ฒด์— ์œ„์ž„. +- **Facade**: **์ƒํƒœ ๋ณ€๊ฒฝ ์—†์ด** ์—ฌ๋Ÿฌ ๋„๋ฉ”์ธ์˜ ๋ฐ์ดํ„ฐ๋ฅผ ์กฐํšŒยท์กฐํ•ฉ(Aggregation)ํ•˜์—ฌ ๋ฐ˜ํ™˜. + +--- + +## ๋„๋ฉ”์ธ ์„ค๊ณ„ ๊ฐ€์ด๋“œ (Product, Brand, Like, Order) + +### Product / Brand + +- ์ƒํ’ˆ ์ •๋ณด๋Š” **๋ธŒ๋žœ๋“œ ์ •๋ณด**, **์ข‹์•„์š” ์ˆ˜**๋ฅผ ํฌํ•จํ•œ๋‹ค. +- ์ƒํ’ˆ ์ •๋ ฌ ์กฐ๊ฑด(`latest`, `price_asc`, `likes_desc`)์€ **์กฐํšŒ ์‹œ์ **์— ์ ์šฉํ•œ๋‹ค. +- ์ƒํ’ˆ์€ **์žฌ๊ณ (Stock)**๋ฅผ ๊ฐ€์ง€๋ฉฐ, ์ฃผ๋ฌธ ์‹œ **๋„๋ฉ”์ธ ๋ ˆ๋ฒจ**์—์„œ ์ฐจ๊ฐํ•œ๋‹ค. +- **์žฌ๊ณ  ์Œ์ˆ˜ ๋ฐฉ์ง€**๋Š” Entity ๋˜๋Š” Domain Service์—์„œ ์ฒ˜๋ฆฌํ•œ๋‹ค. + +### Like + +- ์ข‹์•„์š”๋Š” **์œ ์ €์™€ ์ƒํ’ˆ ๊ฐ„ ๊ด€๊ณ„**๋กœ ๋ณ„๋„ ๋„๋ฉ”์ธ์œผ๋กœ ๋ถ„๋ฆฌํ•œ๋‹ค. +- ์ƒํ’ˆ์˜ ์ข‹์•„์š” ์ˆ˜๋Š” **์กฐํšŒ ์‹œ์ ์— ์ง‘๊ณ„**ํ•˜์—ฌ ์ƒํ’ˆ ์ƒ์„ธ/๋ชฉ๋ก์— ํ•จ๊ป˜ ์ œ๊ณตํ•œ๋‹ค. +- ์ƒํ’ˆ์ด ์ข‹์•„์š” ์ˆ˜๋ฅผ **์ง์ ‘ ๊ด€๋ฆฌํ•˜์ง€ ์•Š๋Š”๋‹ค**. Like ๋„๋ฉ”์ธ์ด ์ง‘๊ณ„๋ฅผ ๋‹ด๋‹นํ•œ๋‹ค. + +### Order + +- ์ฃผ๋ฌธ์€ **์—ฌ๋Ÿฌ ์ƒํ’ˆ**์„ ํฌํ•จํ•˜๋ฉฐ, ๊ฐ ์ƒํ’ˆ์˜ **์ˆ˜๋Ÿ‰**์„ ๋ช…์‹œํ•œ๋‹ค. +- ์ฃผ๋ฌธ ์‹œ **์ƒํ’ˆ ์žฌ๊ณ  ์ฐจ๊ฐ**์„ ์ˆ˜ํ–‰ํ•œ๋‹ค. ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ ์˜ˆ์™ธ ์ฒ˜๋ฆฌํ•œ๋‹ค. +- Order, Product, Stock ๊ฐ„ ํ˜‘๋ ฅ์€ **Domain Service**์—์„œ ์กฐ์œจํ•œ๋‹ค. + +--- + +## Feature Suggestions (์„ค๊ณ„ ์˜์‚ฌ๊ฒฐ์ • ๊ฐ€์ด๋“œ) + +### Q1. ์ƒํ’ˆ์ด ์ข‹์•„์š” ์ˆ˜๋ฅผ ์ง์ ‘ ๊ด€๋ฆฌํ•ด์•ผ ํ• ๊นŒ? + +**์•„๋‹ˆ์˜ค.** ์ข‹์•„์š” ์ˆ˜๋Š” Like ๋„๋ฉ”์ธ์—์„œ ์ง‘๊ณ„ํ•œ๋‹ค. Product๋Š” `likeCount`๋ฅผ ํ•„๋“œ๋กœ ๊ฐ€์ง€์ง€ ์•Š๊ณ , ์กฐํšŒ ์‹œ์ ์— Application Layer ๋˜๋Š” Facade์—์„œ Product + Like๋ฅผ ์กฐํ•ฉํ•ด ์ œ๊ณตํ•œ๋‹ค. ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ ์‹œ Product๋ฅผ ์ˆ˜์ •ํ•  ํ•„์š”๊ฐ€ ์—†๊ณ , Like ๋„๋ฉ”์ธ์ด ๋‹จ์ผ ์ฑ…์ž„์„ ๊ฐ€์ง„๋‹ค. + +### Q2. ์ƒํ’ˆ ์ƒ์„ธ์—์„œ ๋ธŒ๋žœ๋“œ๋ฅผ ํ•จ๊ป˜ ์ œ๊ณตํ•˜๋ ค๋ฉด ๋ˆ„๊ฐ€ ์กฐํ•ฉํ•ด์•ผ ํ• ๊นŒ? + +**Application Layer (ProductFacade)**๊ฐ€ ์กฐํ•ฉํ•œ๋‹ค. `ProductFacade.getProductDetail(productId)`์—์„œ Product, Brand, Like๋ฅผ ์กฐํšŒํ•ด ํ•˜๋‚˜์˜ DTO๋กœ ์กฐํ•ฉํ•œ๋‹ค. Domain Layer๋Š” ๊ฐ์ž ์ž์‹ ์˜ ์ฑ…์ž„๋งŒ ์ˆ˜ํ–‰ํ•˜๊ณ , ์กฐํ•ฉ์€ Application์˜ ์—ญํ• ์ด๋‹ค. + +### Q3. VO๋ฅผ ๋„์ž…ํ•œ ์ด์œ ๋Š” ๋ฌด์—‡์ด๋ฉฐ, ์–ด๋А ์‹œ์ ์—์„œ ์œ ๋ฆฌํ•˜๊ฒŒ ์ž‘์šฉํ–ˆ๋Š”๊ฐ€? + +- **๊ฒ€์ฆ ๋กœ์ง ์‘์ง‘**: `Money`, `Quantity`์ฒ˜๋Ÿผ ์ƒ์„ฑ ์‹œ์ ์— ์œ ํšจ์„ฑ ๊ฒ€์ฆ์„ ์บก์Аํ™”ํ•œ๋‹ค. +- **๋ถˆ๋ณ€์„ฑ ๋ณด์žฅ**: ๊ฐ’์ด ๋ณ€๊ฒฝ๋˜์ง€ ์•Š์•„ ๋ถ€์ž‘์šฉ์„ ์ค„์ธ๋‹ค. +- **์˜๋ฏธ ํ‘œํ˜„**: `Price price`๊ฐ€ `long price`๋ณด๋‹ค ์˜๋„๋ฅผ ์ž˜ ๋“œ๋Ÿฌ๋‚ธ๋‹ค. +- **์žฌ์‚ฌ์šฉ**: ์—ฌ๋Ÿฌ Entity์—์„œ ๋™์ผํ•œ VO๋ฅผ ์‚ฌ์šฉํ•ด ์ผ๊ด€๋œ ๊ทœ์น™์„ ์ ์šฉํ•œ๋‹ค. + +### Q4. Order, Product, User ์ค‘ ๋ˆ„๊ฐ€ ์–ด๋–ค ์ฑ…์ž„์„ ๊ฐ–๋Š” ๊ฒƒ์ด ์ž์—ฐ์Šค๋Ÿฌ์› ๋‚˜? + +- **Order**: ์ฃผ๋ฌธ ์ƒ์„ฑ, ์ฃผ๋ฌธ ๋ผ์ธ ๊ด€๋ฆฌ, ์ฃผ๋ฌธ ์ƒํƒœ ๋ณ€๊ฒฝ. "์ฃผ๋ฌธํ•œ๋‹ค"๋Š” Order์˜ ์ฑ…์ž„. +- **Product**: ์ƒํ’ˆ ์ •๋ณด, ์žฌ๊ณ  ์ฐจ๊ฐ(`decreaseStock()`). "์žฌ๊ณ ๋ฅผ ์ค„์ธ๋‹ค"๋Š” Product(๋˜๋Š” Stock)์˜ ์ฑ…์ž„. +- **User/Member**: ํšŒ์› ์ •๋ณด, ์ธ์ฆ. ์ฃผ๋ฌธ ์‹œ์—๋Š” ์‹๋ณ„์ž๋งŒ ์ฐธ์กฐํ•œ๋‹ค. +- **Domain Service**: Order์™€ Product ๊ฐ„ ์žฌ๊ณ  ์ฐจ๊ฐยท๊ฒ€์ฆ ๋“ฑ **์—ฌ๋Ÿฌ ์—”ํ‹ฐํ‹ฐ ํ˜‘๋ ฅ**์€ Domain Service๊ฐ€ ์กฐ์œจํ•œ๋‹ค. + +### Q5. Repository Interface๋ฅผ Domain Layer์— ๋‘๋Š” ์ด์œ ๋Š”? + +**DIP ์ ์šฉ**์„ ์œ„ํ•ด์„œ๋‹ค. Application/Domain์€ "์ €์žฅยท์กฐํšŒ" ์ธํ„ฐํŽ˜์ด์Šค๋งŒ ์•Œ๊ณ , ์‹ค์ œ ๊ตฌํ˜„(JDBC, JPA ๋“ฑ)์€ Infrastructure์— ๋‘”๋‹ค. Domain์ด Infrastructure์— ์˜์กดํ•˜์ง€ ์•Š๋„๋ก ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ Domain์— ๋‘๊ณ , ๊ตฌํ˜„์ฒด๊ฐ€ ์ด๋ฅผ ๋”ฐ๋ฅธ๋‹ค. + +### Q6. ์ฒ˜์Œ์—” ๋„๋ฉ”์ธ์— ๋‘๋ ค ํ–ˆ์ง€๋งŒ, ๊ฒฐ๊ตญ Application Layer๋กœ ์˜ฎ๊ธด ์ด์œ ๋Š”? + +- **ํŠธ๋žœ์žญ์…˜ ๊ฒฝ๊ณ„**: `@Transactional`์€ Application Layer์—์„œ ๊ด€๋ฆฌํ•˜๋Š” ๊ฒƒ์ด ์ž์—ฐ์Šค๋Ÿฝ๋‹ค. +- **์—ฌ๋Ÿฌ ๋„๋ฉ”์ธ ์กฐํ•ฉ**: Product + Brand + Like ์กฐํ•ฉ์€ ๋‹จ์ผ ๋„๋ฉ”์ธ ์ฑ…์ž„์„ ๋„˜์–ด์„œ๋ฏ€๋กœ Application(Facade)์— ๋‘”๋‹ค. +- **์™ธ๋ถ€ ์˜์กด์„ฑ**: Domain์€ Spring, JPA ๋“ฑ์— ์˜์กดํ•˜์ง€ ์•Š์•„์•ผ ํ•˜๋ฏ€๋กœ, ํŠธ๋žœ์žญ์…˜ ์–ด๋…ธํ…Œ์ด์…˜์„ ์“ฐ๋Š” ํด๋ž˜์Šค๋Š” Application์— ๋‘”๋‹ค. + +### Q7. ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๋ฅผ ๋งŒ๋“ค๊ธฐ ์œ„ํ•ด ๊ฐ€์žฅ ๋จผ์ € ๊ณ ๋ คํ•œ ๊ฑด ๋ฌด์—‡์ด์—ˆ๋‚˜? + +- **Repository Interface ๋ถ„๋ฆฌ**: Domain์— ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ๋‘๊ณ , ๋‹จ์œ„ ํ…Œ์ŠคํŠธ์—์„œ๋Š” **Fake/Stub ๊ตฌํ˜„์ฒด**๋ฅผ ์ฃผ์ž…ํ•œ๋‹ค. +- **๋„๋ฉ”์ธ ๋กœ์ง ์ˆœ์ˆ˜์„ฑ**: Entity, VO, Domain Service๊ฐ€ ์™ธ๋ถ€ ์˜์กด ์—†์ด ๋™์ž‘ํ•˜๋„๋ก ์„ค๊ณ„ํ•ด, **๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋งŒ์œผ๋กœ** ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™์„ ๊ฒ€์ฆํ•œ๋‹ค. +- **์˜์กด์„ฑ ์ฃผ์ž…**: Service๊ฐ€ Repository ์ธํ„ฐํŽ˜์ด์Šค์— ์˜์กดํ•˜๋„๋ก ํ•ด, ํ…Œ์ŠคํŠธ ์‹œ Mock/Fake๋กœ ๋Œ€์ฒด ๊ฐ€๋Šฅํ•˜๊ฒŒ ํ•œ๋‹ค. diff --git a/SKILLS.md b/SKILLS.md new file mode 100644 index 000000000..7ab588e47 --- /dev/null +++ b/SKILLS.md @@ -0,0 +1,97 @@ +# 4์ฃผ์ฐจ: ๋™์‹œ์„ฑ๊ณผ ๋ฝ (Concurrency & Locking) + +> ๋ถ€ํŠธ์บ ํ”„ 4์ฃผ์ฐจ ํ•™์Šต ๋กœ๋“œ๋งต + +--- + +## ํ•™์Šต ๋ชฉํ‘œ + +์žฌ๊ณ  ์ฐจ๊ฐ ์‹œ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋Š” **๋™์‹œ์„ฑ ๋ฌธ์ œ**๋ฅผ ์ดํ•ดํ•˜๊ณ , ๋‹ค์–‘ํ•œ **๋ฝ(Lock) ์ „๋žต**์„ ์ง์ ‘ ๊ตฌํ˜„ํ•ด๋ณธ๋‹ค. + +--- + +## ํ•™์Šต ๋‹จ๊ณ„ + +### Phase 1: ๋ฌธ์ œ ์ธ์‹ +- [ ] **Step 1.1** - ๋™์‹œ์„ฑ ๋ฌธ์ œ๋ž€ ๋ฌด์—‡์ธ๊ฐ€? +- [ ] **Step 1.2** - ํ˜„์žฌ ์ฝ”๋“œ์—์„œ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•˜๋Š” ์‹œ๋‚˜๋ฆฌ์˜ค ์ดํ•ด +- [ ] **Step 1.3** - ๋™์‹œ์„ฑ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ž‘์„ฑ (๋ฌธ์ œ ์žฌํ˜„) + +### Phase 2: ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ฝ +- [ ] **Step 2.1** - ๋น„๊ด€์  ๋ฝ (Pessimistic Lock) ์ดํ•ด +- [ ] **Step 2.2** - ๋น„๊ด€์  ๋ฝ ๊ตฌํ˜„ +- [ ] **Step 2.3** - ๋น„๊ด€์  ๋ฝ ํ…Œ์ŠคํŠธ + +- [ ] **Step 2.4** - ๋‚™๊ด€์  ๋ฝ (Optimistic Lock) ์ดํ•ด +- [ ] **Step 2.5** - ๋‚™๊ด€์  ๋ฝ ๊ตฌํ˜„ +- [ ] **Step 2.6** - ๋‚™๊ด€์  ๋ฝ ํ…Œ์ŠคํŠธ + +### Phase 3: ๋ถ„์‚ฐ ๋ฝ +- [ ] **Step 3.1** - ๋ถ„์‚ฐ ๋ฝ์ด ํ•„์š”ํ•œ ์ด์œ  +- [ ] **Step 3.2** - Redis ๋ถ„์‚ฐ ๋ฝ ๊ตฌํ˜„ +- [ ] **Step 3.3** - ๋ถ„์‚ฐ ๋ฝ ํ…Œ์ŠคํŠธ + +### Phase 4: ๋น„๊ต ๋ฐ ์ •๋ฆฌ +- [ ] **Step 4.1** - ๊ฐ ๋ฐฉ์‹ ์„ฑ๋Šฅ ๋น„๊ต +- [ ] **Step 4.2** - ์ƒํ™ฉ๋ณ„ ์„ ํƒ ๊ธฐ์ค€ ์ •๋ฆฌ +- [ ] **Step 4.3** - ๋ฉด์ ‘ ๋Œ€๋น„ ์ •๋ฆฌ + +--- + +## ํ˜„์žฌ ์ง„ํ–‰ ์ƒํ™ฉ + +**ํ˜„์žฌ ๋‹จ๊ณ„**: Step 1.1 - ๋™์‹œ์„ฑ ๋ฌธ์ œ๋ž€ ๋ฌด์—‡์ธ๊ฐ€? + +--- + +## Step 1.1 - ๋™์‹œ์„ฑ ๋ฌธ์ œ๋ž€ ๋ฌด์—‡์ธ๊ฐ€? + +### ํ•ต์‹ฌ ๊ฐœ๋… + +**๋™์‹œ์„ฑ ๋ฌธ์ œ (Concurrency Problem)** +- ์—ฌ๋Ÿฌ ์Šค๋ ˆ๋“œ/์š”์ฒญ์ด **๋™์‹œ์— ๊ฐ™์€ ๋ฐ์ดํ„ฐ**๋ฅผ ์ˆ˜์ •ํ•  ๋•Œ ๋ฐœ์ƒ +- ์˜ˆ์ƒ๊ณผ ๋‹ค๋ฅธ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ค๋Š” ํ˜„์ƒ + +### ๋Œ€ํ‘œ์ ์ธ ๋ฌธ์ œ: Lost Update (๊ฐฑ์‹  ์†์‹ค) + +``` +[์ƒํ™ฉ] ์žฌ๊ณ  10๊ฐœ์ธ ์ƒํ’ˆ์— 2๋ช…์ด ๋™์‹œ์— 1๊ฐœ์”ฉ ์ฃผ๋ฌธ + +[๊ธฐ๋Œ€ ๊ฒฐ๊ณผ] ์žฌ๊ณ  = 8๊ฐœ + +[์‹ค์ œ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋Š” ๊ฒฐ๊ณผ] + ์‹œ๊ฐ„ | User A | User B | DB ์žฌ๊ณ  + โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + T1 | ์žฌ๊ณ  ์กฐํšŒ (10) | | 10 + T2 | | ์žฌ๊ณ  ์กฐํšŒ (10) | 10 + T3 | ์žฌ๊ณ  = 10-1 = 9 | | 10 + T4 | ์ €์žฅ (9) | | 9 + T5 | | ์žฌ๊ณ  = 10-1 = 9 | 9 + T6 | | ์ €์žฅ (9) | 9 โ† ๋ฌธ์ œ! + +[๊ฒฐ๊ณผ] ์žฌ๊ณ  = 9๊ฐœ (1๊ฐœ ์†์‹ค!) +``` + +### ํ˜„์žฌ ์šฐ๋ฆฌ ์ฝ”๋“œ์˜ ๋ฌธ์ œ์  + +```java +// OrderDomainService.java +public Order placeOrder(Long memberId, List items) { + for (OrderLineRequest item : items) { + Product product = productRepository.findById(item.productId()); // โ‘  ์กฐํšŒ + product.decreaseStock(item.quantity()); // โ‘ก ์ฐจ๊ฐ (๋ฉ”๋ชจ๋ฆฌ์—์„œ) + // โ‘ข ํŠธ๋žœ์žญ์…˜ ์ข…๋ฃŒ ์‹œ ์ €์žฅ โ†’ ์ด ์‚ฌ์ด์— ๋‹ค๋ฅธ ์š”์ฒญ์ด ๋ผ์–ด๋“ค ์ˆ˜ ์žˆ์Œ! + } + ... +} +``` + +**โ‘  ~ โ‘ข ์‚ฌ์ด์— ๋‹ค๋ฅธ ์š”์ฒญ์ด ๊ฐ™์€ ์ƒํ’ˆ์„ ์กฐํšŒํ•˜๋ฉด?** โ†’ Lost Update ๋ฐœ์ƒ! + +--- + +## ๋‹ค์Œ ๋‹จ๊ณ„ + +Step 1.2์—์„œ๋Š” ์ด ๋ฌธ์ œ๋ฅผ **์ง์ ‘ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋กœ ์žฌํ˜„**ํ•ด๋ด…๋‹ˆ๋‹ค. + +์ค€๋น„๋˜๋ฉด ๋ง์”€ํ•ด์ฃผ์„ธ์š”! diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f02..cb54a44be 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -8,6 +8,7 @@ dependencies { // web implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.security:spring-security-crypto") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") diff --git a/apps/commerce-api/docs/README.md b/apps/commerce-api/docs/README.md new file mode 100644 index 000000000..04099d0bd --- /dev/null +++ b/apps/commerce-api/docs/README.md @@ -0,0 +1,28 @@ +# Commerce API ๋ฌธ์„œ + +Claude Code / AI ์–ด์‹œ์Šคํ„ดํŠธ ์ž‘์—… ์‹œ ์ฐธ๊ณ ํ•  ๋„๋ฉ”์ธ๋ณ„ ๋ฌธ์„œ์ž…๋‹ˆ๋‹ค. + +## ๋ฌธ์„œ ์œ„์น˜ + +๊ฐ ๊ตฌํ˜„์ฒด(๋„๋ฉ”์ธ, Application Layer) ํด๋”์— README.md๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. + +| ์˜์—ญ | ๊ฒฝ๋กœ | +|------|------| +| Product/Brand ๋„๋ฉ”์ธ | `src/main/java/com/loopers/domain/product/README.md` | +| Like ๋„๋ฉ”์ธ | `src/main/java/com/loopers/domain/like/README.md` | +| Order ๋„๋ฉ”์ธ | `src/main/java/com/loopers/domain/order/README.md` | +| Product Application | `src/main/java/com/loopers/application/product/README.md` | +| Like Application | `src/main/java/com/loopers/application/like/README.md` | +| Order Application | `src/main/java/com/loopers/application/order/README.md` | + +## Cursor Rules + +`.cursor/rules/`์— ๋„๋ฉ”์ธ๋ณ„ ๊ทœ์น™์ด ๋“ฑ๋ก๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ํ•ด๋‹น ๊ฒฝ๋กœ์˜ ํŒŒ์ผ์„ ํŽธ์ง‘ํ•  ๋•Œ ์ž๋™์œผ๋กœ ์ ์šฉ๋ฉ๋‹ˆ๋‹ค. + +- `domain-product.mdc` โ€” Product, Brand ๊ด€๋ จ +- `domain-like.mdc` โ€” Like ๊ด€๋ จ +- `domain-order.mdc` โ€” Order ๊ด€๋ จ + +## ์ „์ฒด ์•„ํ‚คํ…์ฒ˜ + +ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ์˜ `CLAUDE.md`๋ฅผ ์ฐธ๊ณ ํ•˜์„ธ์š”. 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..10c0cd3a1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponAdminService.java @@ -0,0 +1,78 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.CouponTemplate; +import com.loopers.domain.coupon.CouponTemplateRepository; +import com.loopers.domain.coupon.CouponType; +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.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class CouponAdminService { + + private final CouponTemplateRepository couponTemplateRepository; + private final IssuedCouponRepository issuedCouponRepository; + + @Transactional + public CouponInfo.CouponTemplateInfo create(String name, CouponType type, long value, + Long minOrderAmount, ZonedDateTime expiredAt) { + CouponTemplate template = new CouponTemplate(name, type, value, minOrderAmount, expiredAt); + CouponTemplate saved = couponTemplateRepository.save(template); + return CouponInfo.CouponTemplateInfo.from(saved); + } + + @Transactional(readOnly = true) + public CouponInfo.CouponTemplateInfo getTemplate(Long couponId) { + CouponTemplate template = couponTemplateRepository.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.COUPON_NOT_FOUND)); + return CouponInfo.CouponTemplateInfo.from(template); + } + + @Transactional(readOnly = true) + public List getTemplates(int page, int size) { + return couponTemplateRepository.findAll(page, size).stream() + .map(CouponInfo.CouponTemplateInfo::from) + .toList(); + } + + @Transactional(readOnly = true) + public long getTemplateCount() { + return couponTemplateRepository.count(); + } + + @Transactional + public CouponInfo.CouponTemplateInfo update(Long couponId, String name, CouponType type, long value, + Long minOrderAmount, ZonedDateTime expiredAt) { + CouponTemplate template = couponTemplateRepository.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.COUPON_NOT_FOUND)); + template.update(name, type, value, minOrderAmount, expiredAt); + return CouponInfo.CouponTemplateInfo.from(template); + } + + @Transactional + public void delete(Long couponId) { + CouponTemplate template = couponTemplateRepository.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.COUPON_NOT_FOUND)); + template.delete(); + } + + @Transactional(readOnly = true) + public List getIssuedCoupons(Long couponId, int page, int size) { + return issuedCouponRepository.findByCouponTemplateId(couponId, page, size).stream() + .map(CouponInfo.IssuedCouponDetailInfo::from) + .toList(); + } + + @Transactional(readOnly = true) + public long getIssuedCouponCount(Long couponId) { + return issuedCouponRepository.countByCouponTemplateId(couponId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java new file mode 100644 index 000000000..923e0a0fd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java @@ -0,0 +1,31 @@ +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.Component; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class CouponFacade { + + private final IssuedCouponRepository issuedCouponRepository; + private final CouponTemplateRepository couponTemplateRepository; + + public List getMyCoupons(Long memberId) { + List issuedCoupons = issuedCouponRepository.findByMemberId(memberId); + return issuedCoupons.stream() + .map(issued -> { + CouponTemplate template = couponTemplateRepository.findById(issued.getCouponTemplateId()) + .orElseThrow(() -> new CoreException(ErrorType.COUPON_NOT_FOUND)); + return CouponInfo.IssuedCouponInfo.of(issued, template); + }) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponInfo.java new file mode 100644 index 000000000..06504c0c4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponInfo.java @@ -0,0 +1,81 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.CouponStatus; +import com.loopers.domain.coupon.CouponTemplate; +import com.loopers.domain.coupon.CouponType; +import com.loopers.domain.coupon.IssuedCoupon; + +import java.time.ZonedDateTime; + +public class CouponInfo { + + public record IssuedCouponInfo( + Long issuedCouponId, + Long couponTemplateId, + String name, + CouponType type, + long value, + Long minOrderAmount, + CouponStatus status, + ZonedDateTime expiredAt, + ZonedDateTime usedAt + ) { + public static IssuedCouponInfo of(IssuedCoupon issued, CouponTemplate template) { + CouponStatus effectiveStatus = issued.getStatus(); + if (effectiveStatus == CouponStatus.AVAILABLE && template.isExpired()) { + effectiveStatus = CouponStatus.EXPIRED; + } + return new IssuedCouponInfo( + issued.getId(), + template.getId(), + template.getName(), + template.getType(), + template.getValue(), + template.getMinOrderAmount(), + effectiveStatus, + template.getExpiredAt(), + issued.getUsedAt() + ); + } + } + + public record CouponTemplateInfo( + Long id, + String name, + CouponType type, + long value, + Long minOrderAmount, + ZonedDateTime expiredAt, + ZonedDateTime createdAt + ) { + public static CouponTemplateInfo from(CouponTemplate template) { + return new CouponTemplateInfo( + template.getId(), + template.getName(), + template.getType(), + template.getValue(), + template.getMinOrderAmount(), + template.getExpiredAt(), + template.getCreatedAt() + ); + } + } + + public record IssuedCouponDetailInfo( + Long issuedCouponId, + Long memberId, + CouponStatus status, + ZonedDateTime usedAt, + ZonedDateTime createdAt + ) { + public static IssuedCouponDetailInfo from(IssuedCoupon issued) { + return new IssuedCouponDetailInfo( + issued.getId(), + issued.getMemberId(), + issued.getStatus(), + issued.getUsedAt(), + issued.getCreatedAt() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java new file mode 100644 index 000000000..95fbd73a9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java @@ -0,0 +1,36 @@ +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.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class CouponService { + + private final CouponTemplateRepository couponTemplateRepository; + private final IssuedCouponRepository issuedCouponRepository; + + @Transactional + public CouponInfo.IssuedCouponInfo issue(Long memberId, Long couponTemplateId) { + CouponTemplate template = couponTemplateRepository.findById(couponTemplateId) + .orElseThrow(() -> new CoreException(ErrorType.COUPON_NOT_FOUND)); + + if (template.isExpired()) { + throw new CoreException(ErrorType.COUPON_EXPIRED); + } + + if (issuedCouponRepository.existsByMemberIdAndCouponTemplateId(memberId, couponTemplateId)) { + throw new CoreException(ErrorType.COUPON_ALREADY_ISSUED); + } + + IssuedCoupon issuedCoupon = issuedCouponRepository.save(new IssuedCoupon(couponTemplateId, memberId)); + return CouponInfo.IssuedCouponInfo.of(issuedCoupon, template); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java new file mode 100644 index 000000000..4bb889d4d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java @@ -0,0 +1,33 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +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.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class LikeService { + + private final LikeRepository likeRepository; + private final ProductRepository productRepository; + + @Transactional + public void like(Long memberId, Long productId) { + if (likeRepository.existsByMemberIdAndProductId(memberId, productId)) { + return; + } + productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + productId + "] ์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + likeRepository.save(new Like(memberId, productId)); + } + + @Transactional + public void unlike(Long memberId, Long productId) { + likeRepository.deleteByMemberIdAndProductId(memberId, productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/README.md b/apps/commerce-api/src/main/java/com/loopers/application/like/README.md new file mode 100644 index 000000000..e500a78d5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/README.md @@ -0,0 +1,29 @@ +# Like Application Layer + +> Claude Code ์ž‘์—… ์‹œ ์ด ์˜์—ญ์˜ ์„ค๊ณ„ ์˜๋„์™€ ๊ทœ์น™์„ ์ฐธ๊ณ ํ•˜์„ธ์š”. + +## ์ฑ…์ž„ + +- **LikeService**: ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ. ํŠธ๋žœ์žญ์…˜ ๊ด€๋ฆฌ, ๋„๋ฉ”์ธ์— ์œ„์ž„. + +## ์„ค๊ณ„ ๊ทœ์น™ + +1. **Service = ํŠธ๋žœ์žญ์…˜ + ํ๋ฆ„ ์ œ์–ด** + ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์€ Like, Product ๋„๋ฉ”์ธ์— ์œ„์ž„. + +2. **๋ฉฑ๋“ฑ์„ฑ** + `like()` โ€” ์ด๋ฏธ ์กด์žฌํ•˜๋ฉด ๋ฌด์‹œ. `unlike()` โ€” ์—†์–ด๋„ ์˜ˆ์™ธ ์—†์Œ. + +3. **Product ์กด์žฌ ๊ฒ€์ฆ** + ์ข‹์•„์š” ๋“ฑ๋ก ์ „ `productRepository.findById()`๋กœ ์ƒํ’ˆ ์กด์žฌ ํ™•์ธ. + +## ์ฃผ์š” ํด๋ž˜์Šค + +| ํด๋ž˜์Šค | ์—ญํ•  | +|--------|------| +| LikeService | like(), unlike() | + +## ์ฐธ์กฐ + +- [domain/like README](../../domain/like/README.md) โ€” Like ๋„๋ฉ”์ธ ๊ทœ์น™ +- [CLAUDE.md](/CLAUDE.md) โ€” ์ „์ฒด ์•„ํ‚คํ…์ฒ˜ ๊ทœ์น™ diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java new file mode 100644 index 000000000..20bf8e98d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -0,0 +1,138 @@ +package com.loopers.application.order; + +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.OrderDomainService; +import com.loopers.domain.order.OrderDomainService.OrderLineRequest; +import com.loopers.domain.order.OrderLine; +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; + +import java.util.List; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class OrderService { + + private final OrderDomainService orderDomainService; + private final IssuedCouponRepository issuedCouponRepository; + private final CouponTemplateRepository couponTemplateRepository; + + /** + * ์ฃผ๋ฌธ ์ฒ˜๋ฆฌ: ์ฟ ํฐ ๊ฒ€์ฆ โ†’ ์žฌ๊ณ  ์ฐจ๊ฐ โ†’ ์ฃผ๋ฌธ ์ƒ์„ฑ์„ ํ•˜๋‚˜์˜ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ๋ฌถ๋Š”๋‹ค. + * + * โ”€โ”€โ”€ ํŠธ๋žœ์žญ์…˜ ๋ฒ”์œ„ ์„ค๊ณ„ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + * ํŠธ๋žœ์žญ์…˜ ๋ฒ”์œ„๋ฅผ ํฌ๊ฒŒ ์žก์œผ๋ฉด ๋ฝ ๋ณด์œ  ์‹œ๊ฐ„์ด ๊ธธ์–ด์ ธ ์ฒ˜๋ฆฌ๋Ÿ‰(throughput)์ด ๋‚ฎ์•„์ง„๋‹ค. + * ํŠธ๋žœ์žญ์…˜ ๋ฒ”์œ„๋ฅผ ์ž‘๊ฒŒ ์žก์œผ๋ฉด ์›์ž์„ฑ์ด ๊นจ์งˆ ์ˆ˜ ์žˆ๋‹ค. + * + * ์ด ๋ฉ”์„œ๋“œ๊ฐ€ ํŠธ๋žœ์žญ์…˜ ๊ฒฝ๊ณ„๋ฅผ ๋‹ด๋‹นํ•˜๋Š” ์ด์œ : + * 1. ์žฌ๊ณ  ์ฐจ๊ฐ, ์ฟ ํฐ ์‚ฌ์šฉ, ์ฃผ๋ฌธ ์ƒ์„ฑ์€ ํ•˜๋‚˜๋ผ๋„ ์‹คํŒจํ•˜๋ฉด ๋ชจ๋‘ ๋กค๋ฐฑํ•ด์•ผ ํ•œ๋‹ค. + * โ†’ ์„ธ ์ž‘์—…์ด ๋ฐ˜๋“œ์‹œ ๊ฐ™์€ ํŠธ๋žœ์žญ์…˜ ์•ˆ์— ์žˆ์–ด์•ผ ํ•œ๋‹ค. + * 2. ๋‹จ์ˆœ ๊ฒ€์ฆ(์ฟ ํฐ ๋งŒ๋ฃŒ ์—ฌ๋ถ€, ์†Œ์œ ์ž ํ™•์ธ)์€ ์ƒํƒœ๋ฅผ ๋ณ€๊ฒฝํ•˜์ง€ ์•Š๋Š”๋‹ค. + * โ†’ ๊ฐ€๋Šฅํ•˜๋ฉด ๋ฝ ํš๋“ ์ „ ๋˜๋Š” ํŠธ๋žœ์žญ์…˜ ์‹œ์ž‘ ์งํ›„์— ์ฒ˜๋ฆฌํ•ด ๋ฝ ๋ณด์œ  ์‹œ๊ฐ„์„ ์ค„์ธ๋‹ค. + * + * โ”€โ”€โ”€ ๋ฝ ํš๋“ ์ˆœ์„œ์™€ ๋ฐ๋“œ๋ฝ ๋ฐฉ์ง€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + * ์ฟ ํฐ ๋ฝ๊ณผ ์žฌ๊ณ  ๋ฝ์ด ๋™์‹œ์— ํ•„์š”ํ•  ๋•Œ, ํš๋“ ์ˆœ์„œ๊ฐ€ ์ผ์ •ํ•˜์ง€ ์•Š์œผ๋ฉด ๋ฐ๋“œ๋ฝ ๋ฐœ์ƒ ๊ฐ€๋Šฅ. + * + * ์„ค๊ณ„ ์›์น™: ์ฟ ํฐ ๋ฝ โ†’ ์ƒํ’ˆ ๋ฝ ์ˆœ์„œ๋ฅผ ํ•ญ์ƒ ์œ ์ง€ํ•œ๋‹ค. + * - ์ฟ ํฐ์€ 1๊ฑด์ด๋ฏ€๋กœ ๋จผ์ € ํš๋“ํ•œ๋‹ค. + * - ์ƒํ’ˆ์€ ์—ฌ๋Ÿฌ ๊ฐœ์ผ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ, productId ์˜ค๋ฆ„์ฐจ์ˆœ์œผ๋กœ ์ˆœ์ฐจ ํš๋“ํ•œ๋‹ค. + * (OrderDomainService.prepareOrderLines ๋‚ด๋ถ€์—์„œ ์ฒ˜๋ฆฌ) + * + * ์ด ์ˆœ์„œ๋ฅผ ๋ชจ๋“  ํŠธ๋žœ์žญ์…˜์—์„œ ๋™์ผํ•˜๊ฒŒ ์œ ์ง€ํ•˜๋ฉด, ์ˆœํ™˜ ๋Œ€๊ธฐ ์กฐ๊ฑด์ด ์„ฑ๋ฆฝํ•˜์ง€ ์•Š์•„ + * ๋ฐ๋“œ๋ฝ์ด ๋ฐœ์ƒํ•˜์ง€ ์•Š๋Š”๋‹ค. + * + * โ”€โ”€โ”€ ์ฟ ํฐ: ๋น„๊ด€์  ๋ฝ ์„ ํƒ ๊ทผ๊ฑฐ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + * ์ฟ ํฐ์€ "๋‹จ 1ํšŒ๋งŒ ์‚ฌ์šฉ ๊ฐ€๋Šฅ"์ด๋ผ๋Š” ์—„๊ฒฉํ•œ ์ œ์•ฝ์ด ์žˆ๋‹ค. + * ๊ฐ™์€ ์ฟ ํฐ์œผ๋กœ ์—ฌ๋Ÿฌ ๊ธฐ๊ธฐ์—์„œ ๋™์‹œ์— ์ฃผ๋ฌธ์„ ์‹œ๋„ํ•˜๋ฉด Lost Update ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค: + * Thread 1: coupon ์กฐํšŒ(status=AVAILABLE) โ†’ Thread 2: coupon ์กฐํšŒ(status=AVAILABLE) + * โ†’ ๋‘˜ ๋‹ค ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•˜๋‹ค๊ณ  ํŒ๋‹จ โ†’ ๋‘˜ ๋‹ค USED๋กœ ๋ณ€๊ฒฝ โ†’ ์ค‘๋ณต ์‚ฌ์šฉ + * + * findByIdForUpdate๋กœ ์กฐํšŒ ์‹œ DB ๋ ˆ๋ฒจ ํ–‰ ์ž ๊ธˆ ํš๋“. + * Thread 2๋Š” Thread 1์˜ ํŠธ๋žœ์žญ์…˜์ด ๋๋‚  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐํ•˜๊ณ , + * Thread 1 ์ปค๋ฐ‹ ํ›„ Thread 2๊ฐ€ ์ฝ์œผ๋ฉด status=USED โ†’ ์˜ˆ์™ธ ๋ฐœ์ƒ โ†’ ์ค‘๋ณต ์‚ฌ์šฉ ๋ฐฉ์ง€. + * + * ๋‚™๊ด€์  ๋ฝ(@Version) ๊ณ ๋ ค: + * ๊ฐ€๋Šฅ์€ ํ•˜์ง€๋งŒ, ์ฟ ํฐ ์ค‘๋ณต ์‚ฌ์šฉ์€ "์‹คํŒจ"๋ณด๋‹ค "๋ฐฉ์ง€"๊ฐ€ ์šฐ์„ ์ด๋‹ค. + * OptimisticLockException ํ›„ ์žฌ์‹œ๋„ ์‹œ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ฟ ํฐ์ด ์—†์œผ๋ฉด ๊ฒฐ๊ตญ ์‹คํŒจ์ด๋ฏ€๋กœ + * ๋น„๊ด€์  ๋ฝ์ด ๋” ๋ช…ํ™•ํ•œ ์˜๋„๋ฅผ ๋“œ๋Ÿฌ๋‚ธ๋‹ค. + */ + @Transactional + public OrderResult placeOrder(Long memberId, List items, Long couponId) { + IssuedCoupon issuedCoupon = null; + CouponTemplate couponTemplate = null; + + if (couponId != null) { + // 1๋‹จ๊ณ„: ์ฟ ํฐ ๋น„๊ด€์  ๋ฝ ํš๋“ (SELECT FOR UPDATE) + // ์ด ์‹œ์  ์ดํ›„๋กœ ๋‹ค๋ฅธ ํŠธ๋žœ์žญ์…˜์€ ๊ฐ™์€ ์ฟ ํฐ ํ–‰ ์ˆ˜์ • ๋ถˆ๊ฐ€ + issuedCoupon = issuedCouponRepository.findByIdForUpdate(couponId) + .orElseThrow(() -> new CoreException(ErrorType.COUPON_NOT_FOUND)); + + // ์†Œ์œ ์ž ๊ฒ€์ฆ: ๋„๋ฉ”์ธ ๋ฉ”์„œ๋“œ์— ์œ„์ž„ (๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ ์บก์Аํ™”) + issuedCoupon.validateOwnership(memberId); + + // ์‚ฌ์šฉ ๊ฐ€๋Šฅ ์ƒํƒœ ํ™•์ธ: USED ๋˜๋Š” EXPIRED๋ฉด ์ฃผ๋ฌธ ๊ฑฐ๋ถ€ + if (!issuedCoupon.isUsable()) { + throw new CoreException(ErrorType.COUPON_UNAVAILABLE); + } + + // ์ฟ ํฐ ํ…œํ”Œ๋ฆฟ ์กฐํšŒ: ํ• ์ธ ๊ณ„์‚ฐ์— ํ•„์š” (๋ฝ ๋ถˆํ•„์š” - ์ฝ๊ธฐ ์ „์šฉ) + couponTemplate = couponTemplateRepository.findById(issuedCoupon.getCouponTemplateId()) + .orElseThrow(() -> new CoreException(ErrorType.COUPON_NOT_FOUND)); + + if (couponTemplate.isExpired()) { + throw new CoreException(ErrorType.COUPON_EXPIRED); + } + } + + // 2๋‹จ๊ณ„: ์žฌ๊ณ  ์ฐจ๊ฐ (productId ์˜ค๋ฆ„์ฐจ์ˆœ ๋น„๊ด€์  ๋ฝ ํš๋“) + // ๋ฐ๋“œ๋ฝ ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด OrderDomainService ๋‚ด๋ถ€์—์„œ ์ •๋ ฌ ํ›„ ์ฒ˜๋ฆฌ + List orderLines = orderDomainService.prepareOrderLines(items); + + // 3๋‹จ๊ณ„: ํ• ์ธ ๊ณ„์‚ฐ ๋ฐ ์ฃผ๋ฌธ ์ƒ์„ฑ + // ์ฟ ํฐ ์‹ค์ œ ์‚ฌ์šฉ ์ฒ˜๋ฆฌ(use())๋Š” ์ฃผ๋ฌธ ์ƒ์„ฑ๊ณผ ํ•จ๊ป˜ ๋ฌถ์–ด์„œ ์›์ž์„ฑ ๋ณด์žฅ + // ์ตœ์†Œ ์ฃผ๋ฌธ ๊ธˆ์•ก ๊ฒ€์ฆ๋„ ์ด ์‹œ์ ์— ์‹คํ–‰ + long originalAmount = orderLines.stream().mapToLong(OrderLine::getTotalPrice).sum(); + + Order order; + if (issuedCoupon != null) { + couponTemplate.validateMinOrderAmount(originalAmount); + long discountAmount = couponTemplate.calculateDiscount(originalAmount); + order = orderDomainService.createOrderWithCoupon(memberId, orderLines, couponId, discountAmount); + + // ์ฟ ํฐ ์ƒํƒœ ๋ณ€๊ฒฝ: dirty checking์œผ๋กœ ํŠธ๋žœ์žญ์…˜ ์ปค๋ฐ‹ ์‹œ ์ž๋™ UPDATE + // ์ด ์‹œ์ ์— issuedCoupon์€ ์˜์†์„ฑ ์ปจํ…์ŠคํŠธ์— ์กด์žฌํ•˜๋ฏ€๋กœ ๋ณ„๋„ save() ๋ถˆํ•„์š” + issuedCoupon.use(); + } else { + order = orderDomainService.createOrder(memberId, orderLines); + } + + List resultLines = order.getOrderLines().stream() + .map(ol -> new OrderLineInfo(ol.getProductId(), ol.getQuantity(), ol.getUnitPrice())) + .collect(Collectors.toList()); + + return new OrderResult( + order.getId(), order.getStatus(), + order.getOriginalAmount(), order.getDiscountAmount(), order.getTotalAmount(), + resultLines + ); + } + + public record OrderResult( + Long orderId, + String status, + long originalAmount, + long discountAmount, + long totalAmount, + List orderLines + ) {} + + public record OrderLineInfo(Long productId, int quantity, long unitPrice) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/README.md b/apps/commerce-api/src/main/java/com/loopers/application/order/README.md new file mode 100644 index 000000000..8149cb557 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/README.md @@ -0,0 +1,28 @@ +# Order Application Layer + +> Claude Code ์ž‘์—… ์‹œ ์ด ์˜์—ญ์˜ ์„ค๊ณ„ ์˜๋„์™€ ๊ทœ์น™์„ ์ฐธ๊ณ ํ•˜์„ธ์š”. + +## ์ฑ…์ž„ + +- **OrderService**: ์ฃผ๋ฌธ ์ƒ์„ฑ. ํŠธ๋žœ์žญ์…˜ ๊ด€๋ฆฌ, OrderDomainService์— ์œ„์ž„. + +## ์„ค๊ณ„ ๊ทœ์น™ + +1. **Service = ํŠธ๋žœ์žญ์…˜ + ์œ„์ž„** + `placeOrder()` โ†’ `orderDomainService.placeOrder()` ํ˜ธ์ถœ. + ๋„๋ฉ”์ธ ๋กœ์ง์€ OrderDomainService, Product, Order์— ์œ„์ž„. + +2. **OrderResult ๋ณ€ํ™˜** + Order ์—”ํ‹ฐํ‹ฐ โ†’ OrderResult (orderId, status, totalAmount, orderLines) โ†’ API DTO. + +## ์ฃผ์š” ํด๋ž˜์Šค + +| ํด๋ž˜์Šค | ์—ญํ•  | +|--------|------| +| OrderService | placeOrder() | +| OrderResult, OrderLineInfo | Application DTO | + +## ์ฐธ์กฐ + +- [domain/order README](../../domain/order/README.md) โ€” Order ๋„๋ฉ”์ธ ๊ทœ์น™ +- [CLAUDE.md](/CLAUDE.md) โ€” ์ „์ฒด ์•„ํ‚คํ…์ฒ˜ ๊ทœ์น™ diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java new file mode 100644 index 000000000..b43abe96f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java @@ -0,0 +1,27 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Brand; +import com.loopers.domain.product.Product; + +public record ProductDetailInfo( + Long id, + String name, + Long price, + int stockQuantity, + BrandInfo brand, + long likeCount +) { + public record BrandInfo(Long id, String name) {} + + public static ProductDetailInfo of(Product product, Brand brand, long likeCount) { + BrandInfo brandInfo = brand != null ? new BrandInfo(brand.getId(), brand.getName()) : null; + return new ProductDetailInfo( + product.getId(), + product.getName(), + product.getPrice(), + product.getStockQuantity(), + brandInfo, + likeCount + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java new file mode 100644 index 000000000..771d85bd4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,59 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Brand; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.SortCondition; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.BrandRepository; +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; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class ProductFacade { + + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + private final LikeRepository likeRepository; + + public ProductDetailInfo getProductDetail(Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + productId + "] ์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + Brand brand = product.getBrandId() != null + ? brandRepository.findById(product.getBrandId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "๋ธŒ๋žœ๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")) + : null; + long likeCount = likeRepository.countByProductId(productId); + return ProductDetailInfo.of(product, brand, likeCount); + } + + public List getProductList(SortCondition sort) { + List products = productRepository.findAll(sort); + if (products.isEmpty()) { + return List.of(); + } + + List brandIds = products.stream() + .map(Product::getBrandId) + .filter(id -> id != null) + .distinct() + .toList(); + Map brandMap = brandIds.stream() + .flatMap(id -> brandRepository.findById(id).stream()) + .collect(Collectors.toMap(Brand::getId, b -> b)); + + List productIds = products.stream().map(Product::getId).toList(); + Map likeCountMap = likeRepository.countByProductIds(productIds); + + return products.stream() + .map(p -> ProductListInfo.of(p, brandMap, likeCountMap)) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListInfo.java new file mode 100644 index 000000000..0bc57d49a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListInfo.java @@ -0,0 +1,31 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Brand; +import com.loopers.domain.product.Product; + +import java.util.Map; + +public record ProductListInfo( + Long id, + String name, + Long price, + String brandName, + long likeCount +) { + public static ProductListInfo of(Product product, Brand brand, long likeCount) { + String brandName = brand != null ? brand.getName() : ""; + return new ProductListInfo( + product.getId(), + product.getName(), + product.getPrice(), + brandName, + likeCount + ); + } + + public static ProductListInfo of(Product product, Map brandMap, Map likeCountMap) { + Brand brand = product.getBrandId() != null ? brandMap.get(product.getBrandId()) : null; + long likeCount = likeCountMap.getOrDefault(product.getId(), 0L); + return of(product, brand, likeCount); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/README.md b/apps/commerce-api/src/main/java/com/loopers/application/product/README.md new file mode 100644 index 000000000..32709364b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/README.md @@ -0,0 +1,35 @@ +# Product Application Layer + +> Claude Code ์ž‘์—… ์‹œ ์ด ์˜์—ญ์˜ ์„ค๊ณ„ ์˜๋„์™€ ๊ทœ์น™์„ ์ฐธ๊ณ ํ•˜์„ธ์š”. + +## ์ฑ…์ž„ + +- **ProductFacade**: ์ƒํƒœ ๋ณ€๊ฒฝ ์—†์ด Product + Brand + Like๋ฅผ ์กฐํšŒยท์กฐํ•ฉํ•˜์—ฌ ๋ฐ˜ํ™˜. +- **ProductDetailInfo, ProductListInfo**: Application DTO (API DTO์™€ ๋ถ„๋ฆฌ). + +## ์„ค๊ณ„ ๊ทœ์น™ + +1. **Facade = ์กฐํ•ฉ ์ „์šฉ** + ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ์—†์Œ. Domain Repository์—์„œ ์กฐํšŒ ํ›„ DTO๋กœ ๋ณ€ํ™˜. + +2. **์ƒํ’ˆ ์ƒ์„ธ ์กฐํ•ฉ** + `getProductDetail(productId)` โ†’ Product + Brand + likeCount ์กฐํ•ฉ. + +3. **์ƒํ’ˆ ๋ชฉ๋ก ์กฐํ•ฉ** + `getProductList(sort)` โ†’ ProductRepository.findAll(sort) + Brand ๋งต + Like ์ง‘๊ณ„ ๋งต โ†’ ProductListInfo ๋ฆฌ์ŠคํŠธ. + +4. **์ •๋ ฌ** + `latest`, `price_asc`, `likes_desc` โ€” ProductRepository์— ์œ„์ž„. + +## ์ฃผ์š” ํด๋ž˜์Šค + +| ํด๋ž˜์Šค | ์—ญํ•  | +|--------|------| +| ProductFacade | getProductDetail, getProductList | +| ProductDetailInfo | ์ƒ์„ธ ์กฐํšŒ์šฉ DTO | +| ProductListInfo | ๋ชฉ๋ก ์กฐํšŒ์šฉ DTO | + +## ์ฐธ์กฐ + +- [domain/product README](../../domain/product/README.md) โ€” Product ๋„๋ฉ”์ธ ๊ทœ์น™ +- [CLAUDE.md](/CLAUDE.md) โ€” ์ „์ฒด ์•„ํ‚คํ…์ฒ˜ ๊ทœ์น™ diff --git a/apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java new file mode 100644 index 000000000..d42feb176 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java @@ -0,0 +1,15 @@ +package com.loopers.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponStatus.java new file mode 100644 index 000000000..6924faf8e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponStatus.java @@ -0,0 +1,7 @@ +package com.loopers.domain.coupon; + +public enum CouponStatus { + 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..b0f727e4b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponTemplate.java @@ -0,0 +1,109 @@ +package com.loopers.domain.coupon; + +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.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "coupon_template") +public class CouponTemplate extends BaseEntity { + + @Column(nullable = false) + private String name; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private CouponType type; + + @Column(nullable = false) + private long value; + + private Long minOrderAmount; + + @Column(nullable = false) + private ZonedDateTime expiredAt; + + protected CouponTemplate() {} + + public CouponTemplate(String name, CouponType type, long value, Long minOrderAmount, ZonedDateTime expiredAt) { + validateName(name); + validateType(type); + validateValue(type, value); + validateExpiredAt(expiredAt); + this.name = name; + this.type = type; + this.value = value; + this.minOrderAmount = minOrderAmount; + this.expiredAt = expiredAt; + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฟ ํฐ ์ด๋ฆ„์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + } + + private void validateType(CouponType type) { + if (type == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฟ ํฐ ํƒ€์ž…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + } + + private void validateValue(CouponType type, long value) { + if (value <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฟ ํฐ ๊ฐ’์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (type == CouponType.RATE && value > 100) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ •๋ฅ  ์ฟ ํฐ์˜ ํ• ์ธ์œจ์€ 100%๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + } + + private void validateExpiredAt(ZonedDateTime expiredAt) { + if (expiredAt == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฟ ํฐ ๋งŒ๋ฃŒ์ผ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + } + + public long calculateDiscount(long orderAmount) { + if (type == CouponType.FIXED) { + return Math.min(value, orderAmount); + } + return orderAmount * value / 100; + } + + public boolean isExpired() { + return ZonedDateTime.now().isAfter(expiredAt); + } + + public void validateMinOrderAmount(long orderAmount) { + if (minOrderAmount != null && orderAmount < minOrderAmount) { + throw new CoreException(ErrorType.COUPON_MIN_ORDER_AMOUNT, + "์ตœ์†Œ ์ฃผ๋ฌธ ๊ธˆ์•ก " + minOrderAmount + "์› ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + } + + public void update(String name, CouponType type, long value, Long minOrderAmount, ZonedDateTime expiredAt) { + validateName(name); + validateType(type); + validateValue(type, value); + validateExpiredAt(expiredAt); + this.name = name; + this.type = type; + this.value = value; + this.minOrderAmount = minOrderAmount; + this.expiredAt = expiredAt; + } + + public String getName() { return name; } + public CouponType getType() { return type; } + public long getValue() { return value; } + public Long getMinOrderAmount() { return minOrderAmount; } + public ZonedDateTime getExpiredAt() { return expiredAt; } +} 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..a97266a2a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponTemplateRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.coupon; + +import java.util.List; +import java.util.Optional; + +public interface CouponTemplateRepository { + + CouponTemplate save(CouponTemplate couponTemplate); + + Optional findById(Long id); + + List findAll(int page, int size); + + long count(); +} 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..ed11e42f5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java @@ -0,0 +1,6 @@ +package com.loopers.domain.coupon; + +public enum CouponType { + FIXED, + RATE +} 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..3435eb4fe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/IssuedCoupon.java @@ -0,0 +1,81 @@ +package com.loopers.domain.coupon; + +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.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "issued_coupon", uniqueConstraints = { + @UniqueConstraint(columnNames = {"member_id", "coupon_template_id"}) +}) +public class IssuedCoupon extends BaseEntity { + + @Column(name = "coupon_template_id", nullable = false) + private Long couponTemplateId; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private CouponStatus status; + + @Column(name = "used_at") + private ZonedDateTime usedAt; + + protected IssuedCoupon() {} + + public IssuedCoupon(Long couponTemplateId, Long memberId) { + validateCouponTemplateId(couponTemplateId); + validateMemberId(memberId); + this.couponTemplateId = couponTemplateId; + this.memberId = memberId; + this.status = CouponStatus.AVAILABLE; + } + + private void validateCouponTemplateId(Long couponTemplateId) { + if (couponTemplateId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฟ ํฐ ํ…œํ”Œ๋ฆฟ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + } + + private void validateMemberId(Long memberId) { + if (memberId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "ํšŒ์› ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + } + + public void use() { + if (status == CouponStatus.USED) { + throw new CoreException(ErrorType.COUPON_ALREADY_USED); + } + if (status == CouponStatus.EXPIRED) { + throw new CoreException(ErrorType.COUPON_EXPIRED); + } + this.status = CouponStatus.USED; + this.usedAt = ZonedDateTime.now(); + } + + public boolean isUsable() { + return status == CouponStatus.AVAILABLE; + } + + public void validateOwnership(Long requestMemberId) { + if (!this.memberId.equals(requestMemberId)) { + throw new CoreException(ErrorType.COUPON_NOT_OWNED); + } + } + + public Long getCouponTemplateId() { return couponTemplateId; } + public Long getMemberId() { return memberId; } + public CouponStatus getStatus() { return status; } + public ZonedDateTime getUsedAt() { return usedAt; } +} 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..2ac91bf21 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/IssuedCouponRepository.java @@ -0,0 +1,21 @@ +package com.loopers.domain.coupon; + +import java.util.List; +import java.util.Optional; + +public interface IssuedCouponRepository { + + IssuedCoupon save(IssuedCoupon issuedCoupon); + + Optional findById(Long id); + + Optional findByIdForUpdate(Long id); + + List findByMemberId(Long memberId); + + boolean existsByMemberIdAndCouponTemplateId(Long memberId, Long couponTemplateId); + + List findByCouponTemplateId(Long couponTemplateId, int page, int size); + + long countByCouponTemplateId(Long couponTemplateId); +} 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..e62fd686b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,47 @@ +package com.loopers.domain.like; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +@Entity +@Table(name = "product_like", uniqueConstraints = { + @UniqueConstraint(columnNames = {"member_id", "product_id"}) +}) +public class Like extends BaseEntity { + + private Long memberId; + private Long productId; + + protected Like() {} + + public Like(Long memberId, Long productId) { + validateMemberId(memberId); + validateProductId(productId); + this.memberId = memberId; + this.productId = productId; + } + + private void validateMemberId(Long memberId) { + if (memberId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "ํšŒ์› ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + } + + private void validateProductId(Long productId) { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + } + + public Long getMemberId() { + return memberId; + } + + public Long getProductId() { + return 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..6d4cacfd2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,17 @@ +package com.loopers.domain.like; + +import java.util.List; +import java.util.Map; + +public interface LikeRepository { + + Like save(Like like); + + void deleteByMemberIdAndProductId(Long memberId, Long productId); + + boolean existsByMemberIdAndProductId(Long memberId, Long productId); + + long countByProductId(Long productId); + + Map countByProductIds(List productIds); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/README.md b/apps/commerce-api/src/main/java/com/loopers/domain/like/README.md new file mode 100644 index 000000000..9ddd1844d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/README.md @@ -0,0 +1,35 @@ +# Like ๋„๋ฉ”์ธ + +> Claude Code ์ž‘์—… ์‹œ ์ด ๋„๋ฉ”์ธ์˜ ์„ค๊ณ„ ์˜๋„์™€ ๊ทœ์น™์„ ์ฐธ๊ณ ํ•˜์„ธ์š”. + +## ์ฑ…์ž„ + +- **Like**: ํšŒ์›(Member)๊ณผ ์ƒํ’ˆ(Product) ๊ฐ„์˜ ์ข‹์•„์š” ๊ด€๊ณ„. +- ๋ณ„๋„ ๋„๋ฉ”์ธ์œผ๋กœ ๋ถ„๋ฆฌํ•˜์—ฌ Product๊ฐ€ ์ข‹์•„์š” ์ˆ˜๋ฅผ ์ง์ ‘ ๊ด€๋ฆฌํ•˜์ง€ ์•Š์Œ. + +## ์„ค๊ณ„ ๊ทœ์น™ + +1. **(member_id, product_id) UNIQUE** + ํ•œ ํšŒ์›์ด ํ•œ ์ƒํ’ˆ์— ํ•œ ๋ฒˆ๋งŒ ์ข‹์•„์š” ๊ฐ€๋Šฅ. + +2. **์ข‹์•„์š” ์ˆ˜ ์ง‘๊ณ„** + `LikeRepository.countByProductId()`, `countByProductIds()` โ€” ์กฐํšŒ ์‹œ์ ์— ์ง‘๊ณ„. + +3. **Product์™€ ๋ถ„๋ฆฌ** + Product ์—”ํ‹ฐํ‹ฐ์— likeCount ํ•„๋“œ ์—†์Œ. Application Layer์—์„œ ์กฐํ•ฉ. + +## ์ฃผ์š” ํด๋ž˜์Šค + +| ํด๋ž˜์Šค | ์—ญํ•  | +|--------|------| +| Like | ์ข‹์•„์š” ์—”ํ‹ฐํ‹ฐ (memberId, productId) | +| LikeRepository | ์ €์žฅ, ์‚ญ์ œ, ์กด์žฌ ์—ฌ๋ถ€, ์ง‘๊ณ„ | + +## API ํ๋ฆ„ + +- **๋“ฑ๋ก**: `LikeService.like()` โ†’ ์ค‘๋ณต ์‹œ ๋ฉฑ๋“ฑ, Product ์กด์žฌ ๊ฒ€์ฆ ํ›„ ์ €์žฅ +- **์ทจ์†Œ**: `LikeService.unlike()` โ†’ `deleteByMemberIdAndProductId()` + +## ์ฐธ์กฐ + +- [CLAUDE.md](/CLAUDE.md) โ€” ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ์˜ ์ „์ฒด ์•„ํ‚คํ…์ฒ˜ ๊ทœ์น™ diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java new file mode 100644 index 000000000..7e6c17b88 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -0,0 +1,81 @@ +package com.loopers.domain.member; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "member") +public class Member extends BaseEntity { + + private String loginId; + private String encryptedPassword; + private String name; + private String birthDate; + private String email; + + protected Member() {} + + public Member(String loginId, String encryptedPassword, String name, + String birthDate, String email) { + validateLoginId(loginId); + validatePassword(encryptedPassword); + validateName(name); + validateBirthDate(birthDate); + validateEmail(email); + + this.loginId = loginId; + this.encryptedPassword = encryptedPassword; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + private void validateLoginId(String loginId) { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋กœ๊ทธ์ธID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค"); + } + } + + private void validatePassword(String password) { + if (password == null || password.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค"); + } + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฆ„์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค"); + } + } + + private void validateBirthDate(String birthDate) { + if (birthDate == null || birthDate.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค"); + } + } + + private void validateEmail(String email) { + if (email == null || email.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค"); + } + } + + public String getLoginId() { + return loginId; + } + + public String getName() { + return name; + } + + public String getBirthDate() { + return birthDate; + } + + public String getEmail() { + return email; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java new file mode 100644 index 000000000..f06cecd27 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.member; + +import java.util.Optional; + +public interface MemberRepository { + + Member save(Member member); + + Optional findByLoginId(String loginId); + + boolean existsByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java new file mode 100644 index 000000000..cf76b7e5f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -0,0 +1,27 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class MemberService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public Member signUp(String loginId, String rawPassword, String name, String birthDate, String email) { + if (memberRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ์‚ฌ์šฉ ์ค‘์ธ ๋กœ๊ทธ์ธID์ž…๋‹ˆ๋‹ค."); + } + + String encryptedPassword = passwordEncoder.encode(rawPassword); + Member member = new Member(loginId, encryptedPassword, name, birthDate, email); + return memberRepository.save(member); + } +} 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..175173e24 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,129 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Entity +@Table(name = "orders") +public class Order extends BaseEntity { + + private static final String STATUS_ORDERED = "ORDERED"; + + private Long memberId; + private String status; + + @Column(name = "coupon_id") + private Long couponId; + + @Column(name = "original_amount", nullable = false) + private long originalAmount; + + @Column(name = "discount_amount", nullable = false) + private long discountAmount; + + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) + private List orderLines = new ArrayList<>(); + + protected Order() {} + + private Order(Long memberId, List orderLines, Long couponId, long discountAmount) { + validateMemberId(memberId); + validateOrderLines(orderLines); + this.memberId = memberId; + this.status = STATUS_ORDERED; + this.couponId = couponId; + for (OrderLine line : orderLines) { + this.orderLines.add(new OrderLineEntity(this, line.productId(), line.quantity(), line.unitPrice())); + } + this.originalAmount = calculateOriginalAmount(); + this.discountAmount = Math.min(discountAmount, this.originalAmount); + } + + public static Order create(Long memberId, List orderLines) { + return new Order(memberId, orderLines, null, 0); + } + + public static Order createWithCoupon(Long memberId, List orderLines, Long couponId, long discountAmount) { + return new Order(memberId, orderLines, couponId, discountAmount); + } + + private void validateMemberId(Long memberId) { + if (memberId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "ํšŒ์› ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + } + + private void validateOrderLines(List orderLines) { + if (orderLines == null || orderLines.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ ํ•ญ๋ชฉ์ด ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + } + + private long calculateOriginalAmount() { + return orderLines.stream() + .mapToLong(OrderLineEntity::getTotalPrice) + .sum(); + } + + public Long getMemberId() { return memberId; } + public String getStatus() { return status; } + public Long getCouponId() { return couponId; } + public long getOriginalAmount() { return originalAmount; } + public long getDiscountAmount() { return discountAmount; } + + public long getTotalAmount() { + return originalAmount - discountAmount; + } + + public List getOrderLines() { + return Collections.unmodifiableList(orderLines); + } + + @jakarta.persistence.Entity + @jakarta.persistence.Table(name = "order_line") + public static class OrderLineEntity { + @jakarta.persistence.Id + @jakarta.persistence.GeneratedValue(strategy = jakarta.persistence.GenerationType.IDENTITY) + private Long id; + + @jakarta.persistence.ManyToOne(fetch = jakarta.persistence.FetchType.LAZY) + @jakarta.persistence.JoinColumn(name = "order_id", nullable = false) + private Order order; + + @jakarta.persistence.Column(name = "product_id", nullable = false) + private Long productId; + + @jakarta.persistence.Column(nullable = false) + private int quantity; + + @jakarta.persistence.Column(name = "unit_price", nullable = false) + private long unitPrice; + + protected OrderLineEntity() {} + + OrderLineEntity(Order order, Long productId, int quantity, long unitPrice) { + this.order = order; + this.productId = productId; + this.quantity = quantity; + this.unitPrice = unitPrice; + } + + public long getTotalPrice() { + return (long) quantity * unitPrice; + } + + public Long getProductId() { return productId; } + public int getQuantity() { return quantity; } + public long getUnitPrice() { return unitPrice; } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java new file mode 100644 index 000000000..2375e8a9d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java @@ -0,0 +1,81 @@ +package com.loopers.domain.order; + +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.Component; + +import java.util.ArrayList; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderDomainService { + + private final ProductRepository productRepository; + private final OrderRepository orderRepository; + + /** + * ์žฌ๊ณ ๋ฅผ ์ฐจ๊ฐํ•˜๊ณ  ์ฃผ๋ฌธ ๋ผ์ธ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. + * + * โ”€โ”€โ”€ ๋น„๊ด€์  ๋ฝ(Pessimistic Lock) ์„ ํƒ ๊ทผ๊ฑฐ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + * ์žฌ๊ณ  ์ฐจ๊ฐ์€ "์ฝ๊ธฐ โ†’ ์กฐ๊ฑด ํ™•์ธ โ†’ ์“ฐ๊ธฐ"๊ฐ€ ํ•˜๋‚˜์˜ ์›์ž ๋‹จ์œ„์—ฌ์•ผ ํ•œ๋‹ค. + * + * ๋‚™๊ด€์  ๋ฝ(@Version)์€ "์ผ๋‹จ ์ฝ๊ณ , ์ปค๋ฐ‹ ์‹œ์ ์— ์ถฉ๋Œ์„ ๊ฐ์ง€"ํ•˜๋Š” ๋ฐฉ์‹์ด๋‹ค. + * ์žฌ๊ณ ์ฒ˜๋Ÿผ ๋™์‹œ ๊ฒฝํ•ฉ์ด ๋†’์€ ์ƒํ™ฉ์—์„œ ๋‚™๊ด€์  ๋ฝ์„ ์‚ฌ์šฉํ•˜๋ฉด: + * - ์—ฌ๋Ÿฌ ์Šค๋ ˆ๋“œ๊ฐ€ ๋™์‹œ์— ๊ฐ™์€ stock ๊ฐ’์„ ์ฝ์–ด ์กฐ๊ฑด์„ ํ†ต๊ณผํ•œ๋‹ค. + * - ์ปค๋ฐ‹ ์‹œ์ ์— OptimisticLockException์ด ๋ฐœ์ƒํ•ด ๋กค๋ฐฑ๋œ๋‹ค. + * - Application Layer์—์„œ ์žฌ์‹œ๋„ ๋กœ์ง์ด ํ•„์š”ํ•ด ๋ณต์žก๋„๊ฐ€ ๋†’์•„์ง„๋‹ค. + * - ์žฌ๊ณ  10๊ฐœ์ธ ์ƒํ’ˆ์— 10๋ช…์ด ๋™์‹œ ์ฃผ๋ฌธํ•˜๋ฉด ๋Œ€๋ถ€๋ถ„ ์‹คํŒจ โ†’ ์žฌ์‹œ๋„ ์ง€์˜ฅ + * + * ๋น„๊ด€์  ๋ฝ(SELECT ... FOR UPDATE)์€ ์กฐํšŒ ์‹œ์ ์— DB ํ–‰ ์ž ๊ธˆ์„ ํš๋“ํ•ด + * ๋‹ค๋ฅธ ํŠธ๋žœ์žญ์…˜์ด ์ปค๋ฐ‹ ์ „๊นŒ์ง€ ํ•ด๋‹น ํ–‰์„ ์ˆ˜์ •ํ•˜์ง€ ๋ชปํ•˜๊ฒŒ ๋ง‰๋Š”๋‹ค. + * ์ถฉ๋Œ์ด ์žฆ์€ ๊ฒฝ์šฐ ์žฌ์‹œ๋„ ์—†์ด๋„ ์ˆœ์ฐจ ์ฒ˜๋ฆฌ๊ฐ€ ๋ณด์žฅ๋œ๋‹ค. + * + * โ”€โ”€โ”€ ๋ฐ๋“œ๋ฝ(Deadlock) ๋ฐฉ์ง€ ์ „๋žต โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + * ์—ฌ๋Ÿฌ ์ƒํ’ˆ์„ ํ•œ ๋ฒˆ์— ์ฃผ๋ฌธํ•  ๋•Œ, ํŠธ๋žœ์žญ์…˜ ๊ฐ„ ๋ฝ ํš๋“ ์ˆœ์„œ๊ฐ€ ๋‹ค๋ฅด๋ฉด ๋ฐ๋“œ๋ฝ์ด ๋ฐœ์ƒํ•œ๋‹ค. + * + * ์‹œ๋‚˜๋ฆฌ์˜ค ์˜ˆ์‹œ: + * Thread A: product(id=1) ๋ฝ ํš๋“ โ†’ product(id=2) ๋ฝ ๋Œ€๊ธฐ + * Thread B: product(id=2) ๋ฝ ํš๋“ โ†’ product(id=1) ๋ฝ ๋Œ€๊ธฐ + * โ†’ ์„œ๋กœ๋ฅผ ๊ธฐ๋‹ค๋ฆฌ๋Š” ์ˆœํ™˜ ๋Œ€๊ธฐ โ†’ ๋ฐ๋“œ๋ฝ ๋ฐœ์ƒ + * + * ํ•ด๊ฒฐ์ฑ…: ๋ชจ๋“  ํŠธ๋žœ์žญ์…˜์ด productId ์˜ค๋ฆ„์ฐจ์ˆœ์œผ๋กœ ๋ฝ์„ ํš๋“ํ•˜๋„๋ก ๊ฐ•์ œํ•œ๋‹ค. + * ๋ฝ ์š”์ฒญ ์ˆœ์„œ๊ฐ€ ์ „์—ญ์ ์œผ๋กœ ์ผ์น˜ํ•˜๋ฉด ์ˆœํ™˜ ๋Œ€๊ธฐ ์กฐ๊ฑด์ด ์„ฑ๋ฆฝํ•˜์ง€ ์•Š๋Š”๋‹ค. + */ + public List prepareOrderLines(List items) { + // ๋ฐ๋“œ๋ฝ ๋ฐฉ์ง€: productId ์˜ค๋ฆ„์ฐจ์ˆœ์œผ๋กœ ์ •๋ ฌ ํ›„ ์ˆœ์ฐจ์ ์œผ๋กœ ๋ฝ ํš๋“ + List sorted = items.stream() + .sorted((a, b) -> Long.compare(a.productId(), b.productId())) + .toList(); + + List orderLines = new ArrayList<>(); + for (OrderLineRequest item : sorted) { + // SELECT ... FOR UPDATE: ํ•ด๋‹น ์ƒํ’ˆ ํ–‰์— ๋…์  ๋ฝ ํš๋“ + // ๋ฝ์„ ๋ณด์œ ํ•œ ํŠธ๋žœ์žญ์…˜์ด ์ปค๋ฐ‹ ๋˜๋Š” ๋กค๋ฐฑํ•  ๋•Œ๊นŒ์ง€ ๋‹ค๋ฅธ ํŠธ๋žœ์žญ์…˜์€ ๋Œ€๊ธฐ + Product product = productRepository.findByIdForUpdate(item.productId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + "[id = " + item.productId() + "] ์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + // ์žฌ๊ณ  ์Œ์ˆ˜ ๋ฐฉ์ง€ ๊ฒ€์ฆ์€ Product ๋„๋ฉ”์ธ ๋‚ด๋ถ€์— ์บก์Аํ™” + // Application Layer๊ฐ€ ์žฌ๊ณ  ๊ทœ์น™์„ ์ง์ ‘ ์•Œ์ง€ ์•Š์•„๋„ ๋œ๋‹ค. + product.decreaseStock(item.quantity()); + orderLines.add(new OrderLine(item.productId(), item.quantity(), product.getPrice())); + } + return orderLines; + } + + public Order createOrder(Long memberId, List orderLines) { + Order order = Order.create(memberId, orderLines); + return orderRepository.save(order); + } + + public Order createOrderWithCoupon(Long memberId, List orderLines, Long couponId, long discountAmount) { + Order order = Order.createWithCoupon(memberId, orderLines, couponId, discountAmount); + return orderRepository.save(order); + } + + public record OrderLineRequest(Long productId, int quantity) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderLine.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderLine.java new file mode 100644 index 000000000..622b7464e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderLine.java @@ -0,0 +1,23 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record OrderLine(Long productId, int quantity, long unitPrice) { + + public OrderLine { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ˆ˜๋Ÿ‰์€ 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (unitPrice < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋‹จ๊ฐ€๋Š” 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + } + + public long getTotalPrice() { + return (long) quantity * unitPrice; + } +} 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..cfd052c8a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.order; + +import java.util.Optional; + +public interface OrderRepository { + + Order save(Order order); + + Optional findById(Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/README.md b/apps/commerce-api/src/main/java/com/loopers/domain/order/README.md new file mode 100644 index 000000000..04037e6b9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/README.md @@ -0,0 +1,42 @@ +# Order ๋„๋ฉ”์ธ + +> Claude Code ์ž‘์—… ์‹œ ์ด ๋„๋ฉ”์ธ์˜ ์„ค๊ณ„ ์˜๋„์™€ ๊ทœ์น™์„ ์ฐธ๊ณ ํ•˜์„ธ์š”. + +## ์ฑ…์ž„ + +- **Order**: ์ฃผ๋ฌธ ์—”ํ‹ฐํ‹ฐ. ์—ฌ๋Ÿฌ OrderLine ํฌํ•จ. +- **OrderLine**: ์ฃผ๋ฌธ ํ•ญ๋ชฉ VO (productId, quantity, unitPrice ์Šค๋ƒ…์ƒท). +- **OrderDomainService**: Order์™€ Product ๊ฐ„ ์žฌ๊ณ  ์ฐจ๊ฐยท์ฃผ๋ฌธ ์ƒ์„ฑ ์กฐ์œจ. + +## ์„ค๊ณ„ ๊ทœ์น™ + +1. **์ฃผ๋ฌธ ์‹œ ์žฌ๊ณ  ์ฐจ๊ฐ** + `OrderDomainService.placeOrder()`์—์„œ ๊ฐ Product์— `decreaseStock()` ํ˜ธ์ถœ. + ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ ์˜ˆ์™ธ โ†’ Order ๋ฏธ์ƒ์„ฑ (ํŠธ๋žœ์žญ์…˜ ๋กค๋ฐฑ). + +2. **๊ฐ€๊ฒฉ ์Šค๋ƒ…์ƒท** + OrderLine์— ์ฃผ๋ฌธ ์‹œ์  `unitPrice` ์ €์žฅ. ์ดํ›„ Product ๊ฐ€๊ฒฉ ๋ณ€๊ฒฝ๊ณผ ๋ฌด๊ด€. + +3. **๋„๋ฉ”์ธ ์„œ๋น„์Šค** + Order, Product ๊ฐ„ ํ˜‘๋ ฅ์€ `OrderDomainService`์—์„œ ์ฒ˜๋ฆฌ. + Application Layer(OrderService)๋Š” ํŠธ๋žœ์žญ์…˜๋งŒ ๊ด€๋ฆฌ. + +## ์ฃผ์š” ํด๋ž˜์Šค + +| ํด๋ž˜์Šค | ์—ญํ•  | +|--------|------| +| Order | ์ฃผ๋ฌธ ์—”ํ‹ฐํ‹ฐ, `Order.create()` ์ •์  ํŒฉํ† ๋ฆฌ | +| OrderLine | ์ฃผ๋ฌธ ํ•ญ๋ชฉ VO (๋ถˆ๋ณ€) | +| OrderDomainService | placeOrder โ€” ์žฌ๊ณ  ์ฐจ๊ฐ + Order ์ƒ์„ฑ | +| OrderRepository | ์ฃผ๋ฌธ ์ €์žฅ/์กฐํšŒ ์ธํ„ฐํŽ˜์ด์Šค | + +## ์ฃผ๋ฌธ ํ๋ฆ„ + +1. Product ์กฐํšŒ ๋ฐ ์žฌ๊ณ  ๊ฒ€์ฆ +2. `product.decreaseStock(quantity)` โ€” ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ ์˜ˆ์™ธ +3. `Order.create(memberId, orderLines)` โ€” Order + OrderLine ์ƒ์„ฑ +4. `orderRepository.save(order)` + +## ์ฐธ์กฐ + +- [CLAUDE.md](/CLAUDE.md) โ€” ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ์˜ ์ „์ฒด ์•„ํ‚คํ…์ฒ˜ ๊ทœ์น™ diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Brand.java new file mode 100644 index 000000000..687015ea8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Brand.java @@ -0,0 +1,31 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "brand") +public class Brand extends BaseEntity { + + private String name; + + protected Brand() {} + + public Brand(String name) { + validateName(name); + this.name = name; + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋ธŒ๋žœ๋“œ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + } + + public String getName() { + return name; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/BrandRepository.java new file mode 100644 index 000000000..3491f67d4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/BrandRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.product; + +import java.util.Optional; + +public interface BrandRepository { + + Brand save(Brand brand); + + Optional findById(Long id); +} 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..a0c55b125 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,81 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "product") +public class Product extends BaseEntity { + + private Long brandId; + private String name; + private Long price; + private int stockQuantity; + + protected Product() {} + + public Product(Long brandId, String name, Long price, int stockQuantity) { + validateBrandId(brandId); + validateName(name); + validatePrice(price); + validateStockQuantity(stockQuantity); + + this.brandId = brandId; + this.name = name; + this.price = price; + this.stockQuantity = stockQuantity; + } + + 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, "์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + } + + private void validatePrice(Long price) { + if (price == null || price < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "๊ฐ€๊ฒฉ์€ 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + } + + private void validateStockQuantity(int stockQuantity) { + if (stockQuantity < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์žฌ๊ณ ๋Š” 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + } + + public void decreaseStock(int quantity) { + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฐจ๊ฐ ์ˆ˜๋Ÿ‰์€ 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (stockQuantity < quantity) { + throw new CoreException(ErrorType.INSUFFICIENT_STOCK, "์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); + } + this.stockQuantity -= quantity; + } + + public Long getBrandId() { + return brandId; + } + + public String getName() { + return name; + } + + public Long getPrice() { + return price; + } + + public int getStockQuantity() { + return stockQuantity; + } +} 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..9c2219f70 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.product; + +import java.util.List; +import java.util.Optional; + +public interface ProductRepository { + + Product save(Product product); + + Optional findById(Long id); + + Optional findByIdForUpdate(Long id); + + List findAll(SortCondition sort); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/README.md b/apps/commerce-api/src/main/java/com/loopers/domain/product/README.md new file mode 100644 index 000000000..c6a0f561a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/README.md @@ -0,0 +1,37 @@ +# Product / Brand ๋„๋ฉ”์ธ + +> Claude Code ์ž‘์—… ์‹œ ์ด ๋„๋ฉ”์ธ์˜ ์„ค๊ณ„ ์˜๋„์™€ ๊ทœ์น™์„ ์ฐธ๊ณ ํ•˜์„ธ์š”. + +## ์ฑ…์ž„ + +- **Product**: ์ƒํ’ˆ ์ •๋ณด, ์žฌ๊ณ  ๊ด€๋ฆฌ. `decreaseStock()`์œผ๋กœ ์ฃผ๋ฌธ ์‹œ ์žฌ๊ณ  ์ฐจ๊ฐ. +- **Brand**: ๋ธŒ๋žœ๋“œ ์ •๋ณด. Product๊ฐ€ `brandId`๋กœ ์ฐธ์กฐ. + +## ์„ค๊ณ„ ๊ทœ์น™ + +1. **Product๋Š” likeCount๋ฅผ ์ง์ ‘ ๊ฐ€์ง€์ง€ ์•Š์Œ** + ์ข‹์•„์š” ์ˆ˜๋Š” Like ๋„๋ฉ”์ธ์—์„œ ์ง‘๊ณ„ํ•˜๋ฉฐ, ์กฐํšŒ ์‹œ์ ์— Application Layer์—์„œ ์กฐํ•ฉ. + +2. **์žฌ๊ณ  ์ฐจ๊ฐ์€ ๋„๋ฉ”์ธ ๋ ˆ๋ฒจ์—์„œ ์ฒ˜๋ฆฌ** + `Product.decreaseStock(quantity)` ๋‚ด๋ถ€์—์„œ ์Œ์ˆ˜ ๋ฐฉ์ง€. + ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ `CoreException(ErrorType.INSUFFICIENT_STOCK)` ๋ฐœ์ƒ. + +3. **ํ–‰์œ„ ๋ฉ”์„œ๋“œ ์‚ฌ์šฉ** + Setter ๊ธˆ์ง€. `decreaseStock()` ๋“ฑ ์˜๋„๊ฐ€ ๋“œ๋Ÿฌ๋‚˜๋Š” ๋ฉ”์„œ๋“œ ์‚ฌ์šฉ. + +4. **์ •๋ ฌ ์กฐ๊ฑด (SortCondition)** + `latest`, `price_asc`, `likes_desc` โ€” Infrastructure์—์„œ ๊ตฌํ˜„. + +## ์ฃผ์š” ํด๋ž˜์Šค + +| ํด๋ž˜์Šค | ์—ญํ•  | +|--------|------| +| Product | ์ƒํ’ˆ ์—”ํ‹ฐํ‹ฐ, `decreaseStock()` | +| Brand | ๋ธŒ๋žœ๋“œ ์—”ํ‹ฐํ‹ฐ | +| ProductRepository | ์ƒํ’ˆ ์ €์žฅ/์กฐํšŒ ์ธํ„ฐํŽ˜์ด์Šค | +| BrandRepository | ๋ธŒ๋žœ๋“œ ์ €์žฅ/์กฐํšŒ ์ธํ„ฐํŽ˜์ด์Šค | +| SortCondition | ์ •๋ ฌ ์กฐ๊ฑด enum | + +## ์ฐธ์กฐ + +- [CLAUDE.md](/CLAUDE.md) โ€” ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ์˜ ์ „์ฒด ์•„ํ‚คํ…์ฒ˜ ๊ทœ์น™ diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/SortCondition.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/SortCondition.java new file mode 100644 index 000000000..4d71bddb4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/SortCondition.java @@ -0,0 +1,7 @@ +package com.loopers.domain.product; + +public enum SortCondition { + latest, + price_asc, + likes_desc +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponTemplateJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponTemplateJpaRepository.java new file mode 100644 index 000000000..a7491c867 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponTemplateJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponTemplate; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CouponTemplateJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponTemplateRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponTemplateRepositoryImpl.java new file mode 100644 index 000000000..f301ca162 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponTemplateRepositoryImpl.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponTemplate; +import com.loopers.domain.coupon.CouponTemplateRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class CouponTemplateRepositoryImpl implements CouponTemplateRepository { + + private final CouponTemplateJpaRepository couponTemplateJpaRepository; + + @Override + public CouponTemplate save(CouponTemplate couponTemplate) { + return couponTemplateJpaRepository.save(couponTemplate); + } + + @Override + public Optional findById(Long id) { + return couponTemplateJpaRepository.findById(id); + } + + @Override + public List findAll(int page, int size) { + PageRequest pageRequest = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + return couponTemplateJpaRepository.findAll(pageRequest).getContent(); + } + + @Override + public long count() { + return couponTemplateJpaRepository.count(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponJpaRepository.java new file mode 100644 index 000000000..a541c8b79 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponJpaRepository.java @@ -0,0 +1,54 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.IssuedCoupon; +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; + +public interface IssuedCouponJpaRepository extends JpaRepository { + + /** + * ๋น„๊ด€์  ์“ฐ๊ธฐ ๋ฝ(PESSIMISTIC_WRITE)์œผ๋กœ ๋ฐœ๊ธ‰ ์ฟ ํฐ์„ ์กฐํšŒํ•œ๋‹ค. + * โ†’ ๋‚ด๋ถ€์ ์œผ๋กœ SELECT ... FOR UPDATE ์ฟผ๋ฆฌ๊ฐ€ ์‹คํ–‰๋œ๋‹ค. + * + * โ”€โ”€โ”€ ์ฟ ํฐ์— ๋น„๊ด€์  ๋ฝ์„ ์ ์šฉํ•œ ์ด์œ  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + * ์ฟ ํฐ์€ "๋‹จ 1ํšŒ๋งŒ ์‚ฌ์šฉ ๊ฐ€๋Šฅ"์ด๋ผ๋Š” ์—„๊ฒฉํ•œ ์ œ์•ฝ์„ ๊ฐ€์ง„๋‹ค. + * ์ด ์ œ์•ฝ์„ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋ ˆ๋ฒจ(์ฝ”๋“œ)๋งŒ์œผ๋กœ ๋ณด์žฅํ•˜๋ฉด Lost Update ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. + * + * ์‹œ๋‚˜๋ฆฌ์˜ค (๋ฝ ์—†์„ ๋•Œ): + * T1: SELECT โ†’ status=AVAILABLE (์‚ฌ์šฉ ๊ฐ€๋Šฅ ํ™•์ธ) + * T2: SELECT โ†’ status=AVAILABLE (๋™์‹œ์— ์‚ฌ์šฉ ๊ฐ€๋Šฅ ํ™•์ธ) + * T1: UPDATE status=USED (์ฟ ํฐ ์‚ฌ์šฉ) + * T2: UPDATE status=USED (์ด๋ฏธ ์‚ฌ์šฉ๋œ ์ฟ ํฐ์„ ๋˜ ์‚ฌ์šฉ โ†’ ์ค‘๋ณต!) + * + * ๋น„๊ด€์  ๋ฝ ์ ์šฉ ํ›„: + * T1: SELECT FOR UPDATE โ†’ ํ–‰ ์ž ๊ธˆ ํš๋“ + * T2: SELECT FOR UPDATE โ†’ T1 ์ข…๋ฃŒ๊นŒ์ง€ ๋Œ€๊ธฐ + * T1: UPDATE status=USED, COMMIT โ†’ ์ž ๊ธˆ ํ•ด์ œ + * T2: ๋ฝ ํš๋“ ํ›„ SELECT โ†’ status=USED โ†’ ์˜ˆ์™ธ ๋ฐœ์ƒ โ†’ ์ค‘๋ณต ์‚ฌ์šฉ ๋ฐฉ์ง€ + * + * โ”€โ”€โ”€ ๋‚™๊ด€์  ๋ฝ(@Version) ๋Œ€์‹  ๋น„๊ด€์  ๋ฝ์„ ์„ ํƒํ•œ ์ด์œ  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + * ๋‚™๊ด€์  ๋ฝ๋„ ์ค‘๋ณต ์‚ฌ์šฉ์„ ๋ง‰์„ ์ˆ˜ ์žˆ๋‹ค. ๊ทธ๋Ÿฌ๋‚˜: + * - ์ฟ ํฐ ์ค‘๋ณต ์‚ฌ์šฉ์€ "์‹คํŒจ ๊ฐ์ง€"๋ณด๋‹ค "์‚ฌ์ „ ์ฐจ๋‹จ"์ด ์ค‘์š”ํ•˜๋‹ค. + * - ๋‚™๊ด€์  ๋ฝ์€ ์ปค๋ฐ‹ ์‹œ์ ์— ์‹คํŒจ๋ฅผ ์•Œ๊ฒŒ ๋˜๋ฏ€๋กœ DB ์ž‘์—…(์žฌ๊ณ  ์ฐจ๊ฐ ๋“ฑ)์„ ์ด๋ฏธ ์ˆ˜ํ–‰ํ•œ ํ›„๋‹ค. + * - ๋น„๊ด€์  ๋ฝ์€ ์กฐํšŒ ์‹œ์ ์— ๋…์ ๊ถŒ์„ ํ™•๋ณดํ•˜๋ฏ€๋กœ ๋ถˆํ•„์š”ํ•œ ์ž‘์—… ์ˆ˜ํ–‰ ์ž์ฒด๋ฅผ ๋ง‰๋Š”๋‹ค. + * - ์ฟ ํฐ 1๊ฑด์— ๋Œ€ํ•œ ๋™์‹œ ์š”์ฒญ ๋นˆ๋„๋Š” ๋†’์ง€ ์•Š์•„ ๋Œ€๊ธฐ ๋น„์šฉ์ด ํฌ์ง€ ์•Š๋‹ค. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT c FROM IssuedCoupon c WHERE c.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + + List findByMemberId(Long memberId); + + boolean existsByMemberIdAndCouponTemplateId(Long memberId, Long couponTemplateId); + + List findByCouponTemplateId(Long couponTemplateId, Pageable pageable); + + long countByCouponTemplateId(Long couponTemplateId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponRepositoryImpl.java new file mode 100644 index 000000000..35a73841c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponRepositoryImpl.java @@ -0,0 +1,54 @@ +package com.loopers.infrastructure.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.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class IssuedCouponRepositoryImpl implements IssuedCouponRepository { + + private final IssuedCouponJpaRepository issuedCouponJpaRepository; + + @Override + public IssuedCoupon save(IssuedCoupon issuedCoupon) { + return issuedCouponJpaRepository.save(issuedCoupon); + } + + @Override + public Optional findById(Long id) { + return issuedCouponJpaRepository.findById(id); + } + + @Override + public Optional findByIdForUpdate(Long id) { + return issuedCouponJpaRepository.findByIdForUpdate(id); + } + + @Override + public List findByMemberId(Long memberId) { + return issuedCouponJpaRepository.findByMemberId(memberId); + } + + @Override + public boolean existsByMemberIdAndCouponTemplateId(Long memberId, Long couponTemplateId) { + return issuedCouponJpaRepository.existsByMemberIdAndCouponTemplateId(memberId, couponTemplateId); + } + + @Override + public List findByCouponTemplateId(Long couponTemplateId, int page, int size) { + PageRequest pageRequest = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + return issuedCouponJpaRepository.findByCouponTemplateId(couponTemplateId, pageRequest); + } + + @Override + public long countByCouponTemplateId(Long couponTemplateId) { + return issuedCouponJpaRepository.countByCouponTemplateId(couponTemplateId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java new file mode 100644 index 000000000..530964b3e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,23 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface LikeJpaRepository extends JpaRepository { + + boolean existsByMemberIdAndProductId(Long memberId, Long productId); + + long countByProductId(Long productId); + + @Modifying + @Query("DELETE FROM Like l WHERE l.memberId = :memberId AND l.productId = :productId") + void deleteByMemberIdAndProductId(@Param("memberId") Long memberId, @Param("productId") Long productId); + + @Query("SELECT l.productId, COUNT(l) FROM Like l WHERE l.productId IN :productIds GROUP BY l.productId") + List countByProductIdsGroupByProductId(@Param("productIds") List productIds); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..64bdd7d19 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,54 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public Like save(Like like) { + return likeJpaRepository.save(like); + } + + @Override + public void deleteByMemberIdAndProductId(Long memberId, Long productId) { + likeJpaRepository.deleteByMemberIdAndProductId(memberId, productId); + } + + @Override + public boolean existsByMemberIdAndProductId(Long memberId, Long productId) { + return likeJpaRepository.existsByMemberIdAndProductId(memberId, productId); + } + + @Override + public long countByProductId(Long productId) { + return likeJpaRepository.countByProductId(productId); + } + + @Override + public Map countByProductIds(List productIds) { + if (productIds == null || productIds.isEmpty()) { + return Map.of(); + } + List results = likeJpaRepository.countByProductIdsGroupByProductId(productIds); + Map map = results.stream() + .collect(Collectors.toMap( + row -> (Long) row[0], + row -> ((Number) row[1]).longValue() + )); + for (Long productId : productIds) { + map.putIfAbsent(productId, 0L); + } + return map; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java new file mode 100644 index 000000000..beebc4eca --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberJpaRepository extends JpaRepository { + + Optional findByLoginId(String loginId); + + boolean existsByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java new file mode 100644 index 000000000..6116d454e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class MemberRepositoryImpl implements MemberRepository { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public Member save(Member member) { + return memberJpaRepository.save(member); + } + + @Override + public Optional findByLoginId(String loginId) { + return memberJpaRepository.findByLoginId(loginId); + } + + @Override + public boolean existsByLoginId(String loginId) { + return memberJpaRepository.existsByLoginId(loginId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java new file mode 100644 index 000000000..f2ee62050 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..309265e06 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findById(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/BrandJpaRepository.java new file mode 100644 index 000000000..d532f2d35 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/BrandJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Brand; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BrandJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/BrandRepositoryImpl.java new file mode 100644 index 000000000..ff9fedafc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/BrandRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Brand; +import com.loopers.domain.product.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + @Override + public Brand save(Brand brand) { + return brandJpaRepository.save(brand); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java new file mode 100644 index 000000000..d244c2d5c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,45 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +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.List; +import java.util.Optional; + +public interface ProductJpaRepository extends JpaRepository { + + /** + * ๋น„๊ด€์  ์“ฐ๊ธฐ ๋ฝ(PESSIMISTIC_WRITE)์œผ๋กœ ์ƒํ’ˆ์„ ์กฐํšŒํ•œ๋‹ค. + * โ†’ ๋‚ด๋ถ€์ ์œผ๋กœ SELECT ... FOR UPDATE ์ฟผ๋ฆฌ๊ฐ€ ์‹คํ–‰๋œ๋‹ค. + * + * โ”€โ”€โ”€ ๋‚™๊ด€์  ๋ฝ vs ๋น„๊ด€์  ๋ฝ ์„ ํƒ ๊ธฐ์ค€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + * [๋‚™๊ด€์  ๋ฝ - @Version ๊ธฐ๋ฐ˜] + * - ์ฝ๊ธฐ ์‹œ ๋ฝ์„ ์žก์ง€ ์•Š๊ณ , ์ปค๋ฐ‹ ์‹œ version ๊ฐ’์„ ๋น„๊ตํ•ด ์ถฉ๋Œ์„ ๊ฐ์ง€ + * - ์ถฉ๋Œ์ด ๋“œ๋ฌผ๊ณ , ์ถฉ๋Œ ๋ฐœ์ƒ ์‹œ ์žฌ์‹œ๋„๊ฐ€ ์ €๋ ดํ•œ ๊ฒฝ์šฐ ์œ ๋ฆฌ + * - ์ถฉ๋Œ ๋ฐœ์ƒ โ†’ OptimisticLockException โ†’ ๋กค๋ฐฑ โ†’ ์žฌ์‹œ๋„ ํ•„์š” + * - ์žฌ๊ณ ์ฒ˜๋Ÿผ ๋™์‹œ ์š”์ฒญ์ด ๋งŽ์œผ๋ฉด ๋Œ€๋ถ€๋ถ„ ์‹คํŒจ ํ›„ ์žฌ์‹œ๋„ โ†’ ์„ฑ๋Šฅ ์ €ํ•˜ + * + * [๋น„๊ด€์  ๋ฝ - SELECT FOR UPDATE] + * - ์กฐํšŒ ์‹œ์ ์— DB ํ–‰ ์ž ๊ธˆ ํš๋“ โ†’ ๋‹ค๋ฅธ ํŠธ๋žœ์žญ์…˜์€ ํŠธ๋žœ์žญ์…˜ ์ข…๋ฃŒ๊นŒ์ง€ ๋Œ€๊ธฐ + * - ์ถฉ๋Œ์ด ์žฆ๊ณ , ์‹คํŒจ ๋น„์šฉ(์‚ฌ์šฉ์ž ์žฌ์ฃผ๋ฌธ)์ด ๋†’์€ ๊ฒฝ์šฐ ์œ ๋ฆฌ + * - ์žฌ๊ณ  ์ฐจ๊ฐ์ฒ˜๋Ÿผ "์ค„์–ด๋“œ๋Š” ์ž์›"์€ ๊ฒฝํ•ฉ์ด ๋†’์„์ˆ˜๋ก ๋‚™๊ด€์  ๋ฝ์˜ ์žฌ์‹œ๋„ ๋น„์šฉ์ด ์ปค์ง + * + * ๊ฒฐ๋ก : ์žฌ๊ณ ๋Š” ๋น„๊ด€์  ๋ฝ์ด ๋” ์˜ˆ์ธก ๊ฐ€๋Šฅํ•˜๊ณ  ๊ตฌํ˜„์ด ๋‹จ์ˆœํ•˜๋‹ค. + * + * โ”€โ”€โ”€ ๋ฐ๋“œ๋ฝ ์œ„ํ—˜๊ณผ ๋Œ€์‘ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + * SELECT FOR UPDATE๋ฅผ ์—ฌ๋Ÿฌ ํ–‰์— ๊ฑธ์ณ ์‚ฌ์šฉํ•  ๊ฒฝ์šฐ ๋ฝ ํš๋“ ์ˆœ์„œ๊ฐ€ ๋‹ค๋ฅด๋ฉด ๋ฐ๋“œ๋ฝ ๋ฐœ์ƒ. + * ์ด ์ฟผ๋ฆฌ๋ฅผ ํ˜ธ์ถœํ•˜๋Š” OrderDomainService.prepareOrderLines()์—์„œ + * productId ์˜ค๋ฆ„์ฐจ์ˆœ ์ •๋ ฌ ํ›„ ํ˜ธ์ถœํ•˜์—ฌ ๋ฝ ์ˆœ์„œ๋ฅผ ์ „์—ญ์ ์œผ๋กœ ํ†ต์ผํ•œ๋‹ค. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM Product p WHERE p.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + + @Query("SELECT p FROM Product p LEFT JOIN com.loopers.domain.like.Like l ON l.productId = p.id " + + "WHERE p.deletedAt IS NULL GROUP BY p ORDER BY COUNT(l) DESC") + List findAllOrderByLikesDesc(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java new file mode 100644 index 000000000..bada6fe34 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,42 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.SortCondition; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id); + } + + @Override + public Optional findByIdForUpdate(Long id) { + return productJpaRepository.findByIdForUpdate(id); + } + + @Override + public List findAll(SortCondition sort) { + return switch (sort) { + case latest -> productJpaRepository.findAll(Sort.by(Sort.Direction.DESC, "createdAt")); + case price_asc -> productJpaRepository.findAll(Sort.by(Sort.Direction.ASC, "price")); + case likes_desc -> productJpaRepository.findAllOrderByLikesDesc(); + }; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java index 33b77b529..6eeab16da 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java @@ -15,8 +15,9 @@ public static Metadata fail(String errorCode, String errorMessage) { } } - public static ApiResponse success() { - return new ApiResponse<>(Metadata.success(), null); + @SuppressWarnings("unchecked") + public static ApiResponse success() { + return (ApiResponse) new ApiResponse<>(Metadata.success(), null); } public static ApiResponse success(T data) { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1ApiSpec.java new file mode 100644 index 000000000..ff6ba8df7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1ApiSpec.java @@ -0,0 +1,27 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Coupon Admin V1 API", description = "์ฟ ํฐ ๊ด€๋ฆฌ์ž API") +public interface CouponAdminV1ApiSpec { + + @Operation(summary = "์ฟ ํฐ ํ…œํ”Œ๋ฆฟ ๋ชฉ๋ก ์กฐํšŒ") + ApiResponse getTemplates(int page, int size); + + @Operation(summary = "์ฟ ํฐ ํ…œํ”Œ๋ฆฟ ์ƒ์„ธ ์กฐํšŒ") + ApiResponse getTemplate(Long couponId); + + @Operation(summary = "์ฟ ํฐ ํ…œํ”Œ๋ฆฟ ๋“ฑ๋ก") + ApiResponse createTemplate(CouponAdminV1Dto.CreateRequest request); + + @Operation(summary = "์ฟ ํฐ ํ…œํ”Œ๋ฆฟ ์ˆ˜์ •") + ApiResponse updateTemplate(Long couponId, CouponAdminV1Dto.UpdateRequest request); + + @Operation(summary = "์ฟ ํฐ ํ…œํ”Œ๋ฆฟ ์‚ญ์ œ") + ApiResponse deleteTemplate(Long couponId); + + @Operation(summary = "์ฟ ํฐ ๋ฐœ๊ธ‰ ๋‚ด์—ญ ์กฐํšŒ") + ApiResponse getIssuedCoupons(Long couponId, int page, int size); +} 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..e3519ad39 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java @@ -0,0 +1,92 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.application.coupon.CouponAdminService; +import com.loopers.application.coupon.CouponInfo; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +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.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/coupons") +public class CouponAdminV1Controller implements CouponAdminV1ApiSpec { + + private final CouponAdminService couponAdminService; + + @GetMapping + @Override + public ApiResponse getTemplates( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + List coupons = couponAdminService.getTemplates(page, size).stream() + .map(CouponAdminV1Dto.CouponTemplateResponse::from) + .toList(); + long totalCount = couponAdminService.getTemplateCount(); + return ApiResponse.success(new CouponAdminV1Dto.CouponTemplateListResponse(coupons, totalCount, page, size)); + } + + @GetMapping("/{couponId}") + @Override + public ApiResponse getTemplate(@PathVariable Long couponId) { + CouponInfo.CouponTemplateInfo info = couponAdminService.getTemplate(couponId); + return ApiResponse.success(CouponAdminV1Dto.CouponTemplateResponse.from(info)); + } + + @PostMapping + @Override + public ApiResponse createTemplate( + @Valid @RequestBody CouponAdminV1Dto.CreateRequest request + ) { + CouponInfo.CouponTemplateInfo info = couponAdminService.create( + request.name(), request.type(), request.value(), + request.minOrderAmount(), request.expiredAt() + ); + return ApiResponse.success(CouponAdminV1Dto.CouponTemplateResponse.from(info)); + } + + @PutMapping("/{couponId}") + @Override + public ApiResponse updateTemplate( + @PathVariable Long couponId, + @Valid @RequestBody CouponAdminV1Dto.UpdateRequest request + ) { + CouponInfo.CouponTemplateInfo info = couponAdminService.update( + couponId, request.name(), request.type(), request.value(), + request.minOrderAmount(), request.expiredAt() + ); + return ApiResponse.success(CouponAdminV1Dto.CouponTemplateResponse.from(info)); + } + + @DeleteMapping("/{couponId}") + @Override + public ApiResponse deleteTemplate(@PathVariable Long couponId) { + couponAdminService.delete(couponId); + return ApiResponse.success(); + } + + @GetMapping("/{couponId}/issues") + @Override + public ApiResponse getIssuedCoupons( + @PathVariable Long couponId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + List issues = couponAdminService.getIssuedCoupons(couponId, page, size).stream() + .map(CouponAdminV1Dto.IssuedCouponResponse::from) + .toList(); + long totalCount = couponAdminService.getIssuedCouponCount(couponId); + return ApiResponse.success(new CouponAdminV1Dto.IssuedCouponListResponse(issues, totalCount, page, size)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Dto.java new file mode 100644 index 000000000..f7041c1be --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Dto.java @@ -0,0 +1,94 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.application.coupon.CouponInfo; +import com.loopers.domain.coupon.CouponStatus; +import com.loopers.domain.coupon.CouponType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +import java.time.ZonedDateTime; +import java.util.List; + +public class CouponAdminV1Dto { + + public record CreateRequest( + @NotBlank(message = "์ฟ ํฐ ์ด๋ฆ„์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + String name, + + @NotNull(message = "์ฟ ํฐ ํƒ€์ž…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + CouponType type, + + @NotNull(message = "์ฟ ํฐ ๊ฐ’์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + @Positive(message = "์ฟ ํฐ ๊ฐ’์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค") + Long value, + + Long minOrderAmount, + + @NotNull(message = "๋งŒ๋ฃŒ์ผ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + ZonedDateTime expiredAt + ) {} + + public record UpdateRequest( + @NotBlank(message = "์ฟ ํฐ ์ด๋ฆ„์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + String name, + + @NotNull(message = "์ฟ ํฐ ํƒ€์ž…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + CouponType type, + + @NotNull(message = "์ฟ ํฐ ๊ฐ’์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + @Positive(message = "์ฟ ํฐ ๊ฐ’์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค") + Long value, + + Long minOrderAmount, + + @NotNull(message = "๋งŒ๋ฃŒ์ผ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + ZonedDateTime expiredAt + ) {} + + public record CouponTemplateResponse( + Long id, + String name, + CouponType type, + long value, + Long minOrderAmount, + ZonedDateTime expiredAt, + ZonedDateTime createdAt + ) { + public static CouponTemplateResponse from(CouponInfo.CouponTemplateInfo info) { + return new CouponTemplateResponse( + info.id(), info.name(), info.type(), info.value(), + info.minOrderAmount(), info.expiredAt(), info.createdAt() + ); + } + } + + public record CouponTemplateListResponse( + List coupons, + long totalCount, + int page, + int size + ) {} + + public record IssuedCouponResponse( + Long issuedCouponId, + Long memberId, + CouponStatus status, + ZonedDateTime usedAt, + ZonedDateTime createdAt + ) { + public static IssuedCouponResponse from(CouponInfo.IssuedCouponDetailInfo info) { + return new IssuedCouponResponse( + info.issuedCouponId(), info.memberId(), info.status(), + info.usedAt(), info.createdAt() + ); + } + } + + public record IssuedCouponListResponse( + List issues, + long totalCount, + int page, + int size + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1ApiSpec.java new file mode 100644 index 000000000..82d7a7322 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1ApiSpec.java @@ -0,0 +1,15 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Coupon V1 API", description = "์ฟ ํฐ ์‚ฌ์šฉ์ž API") +public interface CouponV1ApiSpec { + + @Operation(summary = "์ฟ ํฐ ๋ฐœ๊ธ‰", description = "์‚ฌ์šฉ์ž์—๊ฒŒ ์ฟ ํฐ์„ ๋ฐœ๊ธ‰ํ•ฉ๋‹ˆ๋‹ค.") + ApiResponse issueCoupon(Long memberId, Long couponId); + + @Operation(summary = "๋‚ด ์ฟ ํฐ ๋ชฉ๋ก ์กฐํšŒ", description = "์‚ฌ์šฉ์ž์˜ ์ฟ ํฐ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + ApiResponse getMyCoupons(Long memberId); +} 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..1dedc843d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java @@ -0,0 +1,39 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.application.coupon.CouponFacade; +import com.loopers.application.coupon.CouponInfo; +import com.loopers.application.coupon.CouponService; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +public class CouponV1Controller implements CouponV1ApiSpec { + + private final CouponService couponService; + private final CouponFacade couponFacade; + + @PostMapping("/api/v1/coupons/{couponId}/issue") + @Override + public ApiResponse issueCoupon( + @RequestParam Long memberId, + @PathVariable Long couponId + ) { + CouponInfo.IssuedCouponInfo info = couponService.issue(memberId, couponId); + return ApiResponse.success(CouponV1Dto.IssuedCouponResponse.from(info)); + } + + @GetMapping("/api/v1/users/me/coupons") + @Override + public ApiResponse getMyCoupons(@RequestParam Long memberId) { + List infos = couponFacade.getMyCoupons(memberId); + return ApiResponse.success(CouponV1Dto.MyCouponsResponse.from(infos)); + } +} 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..75c781c1f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java @@ -0,0 +1,45 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.application.coupon.CouponInfo; +import com.loopers.domain.coupon.CouponStatus; +import com.loopers.domain.coupon.CouponType; + +import java.time.ZonedDateTime; +import java.util.List; + +public class CouponV1Dto { + + public record IssuedCouponResponse( + Long issuedCouponId, + Long couponTemplateId, + String name, + CouponType type, + long value, + Long minOrderAmount, + CouponStatus status, + ZonedDateTime expiredAt, + ZonedDateTime usedAt + ) { + public static IssuedCouponResponse from(CouponInfo.IssuedCouponInfo info) { + return new IssuedCouponResponse( + info.issuedCouponId(), + info.couponTemplateId(), + info.name(), + info.type(), + info.value(), + info.minOrderAmount(), + info.status(), + info.expiredAt(), + info.usedAt() + ); + } + } + + public record MyCouponsResponse(List coupons) { + public static MyCouponsResponse from(List infos) { + return new MyCouponsResponse( + infos.stream().map(IssuedCouponResponse::from).toList() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java new file mode 100644 index 000000000..b689957af --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.DeleteMapping; +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; + +@Tag(name = "Like V1 API", description = "์ข‹์•„์š” API") +@RequestMapping("/api/v1/likes") +public interface LikeV1ApiSpec { + + @Operation(summary = "์ข‹์•„์š” ๋“ฑ๋ก", description = "์ƒํ’ˆ์— ์ข‹์•„์š”๋ฅผ ๋“ฑ๋กํ•ฉ๋‹ˆ๋‹ค.") + ApiResponse like(@Valid @RequestBody LikeV1Dto.LikeRequest request); + + @Operation(summary = "์ข‹์•„์š” ์ทจ์†Œ", description = "์ƒํ’ˆ ์ข‹์•„์š”๋ฅผ ์ทจ์†Œํ•ฉ๋‹ˆ๋‹ค.") + ApiResponse unlike( + @RequestParam Long memberId, + @RequestParam Long productId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java new file mode 100644 index 000000000..2bc2fbcad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,37 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeService; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +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; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/likes") +public class LikeV1Controller implements LikeV1ApiSpec { + + private final LikeService likeService; + + @PostMapping + @Override + public ApiResponse like(@Valid @RequestBody LikeV1Dto.LikeRequest request) { + likeService.like(request.memberId(), request.productId()); + return ApiResponse.success(); + } + + @DeleteMapping + @Override + public ApiResponse unlike( + @RequestParam Long memberId, + @RequestParam Long productId + ) { + likeService.unlike(memberId, productId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java new file mode 100644 index 000000000..0b7432bd8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,14 @@ +package com.loopers.interfaces.api.like; + +import jakarta.validation.constraints.NotNull; + +public class LikeV1Dto { + + public record LikeRequest( + @NotNull(message = "ํšŒ์› ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + Long memberId, + + @NotNull(message = "์ƒํ’ˆ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + Long productId + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java new file mode 100644 index 000000000..c93074ef0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java @@ -0,0 +1,17 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +@Tag(name = "Member V1 API", description = "ํšŒ์› API") +@RequestMapping("/api/v1/members") +public interface MemberV1ApiSpec { + + @Operation(summary = "ํšŒ์›๊ฐ€์ž…", description = "์ƒˆ ํšŒ์›์„ ๋“ฑ๋กํ•ฉ๋‹ˆ๋‹ค.") + ApiResponse signUp(@Valid @RequestBody MemberV1Dto.SignUpRequest request); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java new file mode 100644 index 000000000..e71ec02d2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberService; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +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; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/members") +public class MemberV1Controller implements MemberV1ApiSpec { + + private final MemberService memberService; + + @PostMapping("/signup") + @Override + public ApiResponse signUp(@Valid @RequestBody MemberV1Dto.SignUpRequest request) { + Member member = memberService.signUp( + request.loginId(), + request.password(), + request.name(), + request.birthDate(), + request.email() + ); + return ApiResponse.success(MemberV1Dto.SignUpResponse.from(member)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java new file mode 100644 index 000000000..ca2906884 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -0,0 +1,38 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.domain.member.Member; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public class MemberV1Dto { + + public record SignUpRequest( + @NotBlank(message = "๋กœ๊ทธ์ธID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + String loginId, + + @NotBlank(message = "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + String password, + + @NotBlank(message = "์ด๋ฆ„์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + String name, + + @NotBlank(message = "์ƒ๋…„์›”์ผ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + String birthDate, + + @NotBlank(message = "์ด๋ฉ”์ผ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + @Email(message = "์˜ฌ๋ฐ”๋ฅธ ์ด๋ฉ”์ผ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค") + String email + ) {} + + public record SignUpResponse(Long id, String loginId, String name, String birthDate, String email) { + public static SignUpResponse from(Member member) { + return new SignUpResponse( + member.getId(), + member.getLoginId(), + member.getName(), + member.getBirthDate(), + member.getEmail() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java new file mode 100644 index 000000000..5d6a0e31f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -0,0 +1,17 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +@Tag(name = "Order V1 API", description = "์ฃผ๋ฌธ API") +@RequestMapping("/api/v1/orders") +public interface OrderV1ApiSpec { + + @Operation(summary = "์ฃผ๋ฌธ ์ƒ์„ฑ", description = "์ƒํ’ˆ์„ ์ฃผ๋ฌธํ•ฉ๋‹ˆ๋‹ค.") + ApiResponse createOrder(@Valid @RequestBody OrderV1Dto.OrderCreateRequest request); +} 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..257b70296 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderService; +import com.loopers.domain.order.OrderDomainService; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +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; + +import java.util.List; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class OrderV1Controller implements OrderV1ApiSpec { + + private final OrderService orderService; + + @PostMapping + @Override + public ApiResponse createOrder(@Valid @RequestBody OrderV1Dto.OrderCreateRequest request) { + List items = request.items().stream() + .map(item -> new OrderDomainService.OrderLineRequest(item.productId(), item.quantity())) + .collect(Collectors.toList()); + OrderService.OrderResult result = orderService.placeOrder(request.memberId(), items, request.couponId()); + return ApiResponse.success(OrderV1Dto.OrderCreateResponse.from(result)); + } +} 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..5b0d7955d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,56 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public class OrderV1Dto { + + public record OrderCreateRequest( + @NotNull(message = "ํšŒ์› ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + Long memberId, + + @Valid + @NotNull(message = "์ฃผ๋ฌธ ํ•ญ๋ชฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + List items, + + Long couponId + ) {} + + public record OrderLineRequest( + @NotNull(message = "์ƒํ’ˆ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + Long productId, + + @NotNull(message = "์ˆ˜๋Ÿ‰์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + @Min(value = 1, message = "์ˆ˜๋Ÿ‰์€ 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค") + Integer quantity + ) {} + + public record OrderCreateResponse( + Long orderId, + String status, + long originalAmount, + long discountAmount, + long totalAmount, + List orderLines + ) { + public static OrderCreateResponse from(OrderService.OrderResult result) { + List lines = result.orderLines().stream() + .map(ol -> new OrderLineResponse(ol.productId(), ol.quantity(), ol.unitPrice())) + .toList(); + return new OrderCreateResponse( + result.orderId(), + result.status(), + result.originalAmount(), + result.discountAmount(), + result.totalAmount(), + lines + ); + } + } + + public record OrderLineResponse(Long productId, int quantity, long unitPrice) {} +} 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..6f2e0e75d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -0,0 +1,21 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.domain.product.SortCondition; +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.util.List; + +@Tag(name = "Product V1 API", description = "์ƒํ’ˆ API") +public interface ProductV1ApiSpec { + + @Operation(summary = "์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ", description = "์ •๋ ฌ ์กฐ๊ฑด์— ๋”ฐ๋ผ ์ƒํ’ˆ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + ApiResponse> getProductList( + @Parameter(description = "์ •๋ ฌ ์กฐ๊ฑด: latest, price_asc, likes_desc") SortCondition sort + ); + + @Operation(summary = "์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ", description = "ID๋กœ ์ƒํ’ˆ ์ƒ์„ธ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + ApiResponse getProductDetail(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..842a57626 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,44 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductDetailInfo; +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductListInfo; +import com.loopers.domain.product.SortCondition; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller implements ProductV1ApiSpec { + + private final ProductFacade productFacade; + + @GetMapping + @Override + public ApiResponse> getProductList( + @RequestParam(defaultValue = "latest") SortCondition sort + ) { + List productList = productFacade.getProductList(sort); + List response = productList.stream() + .map(ProductV1Dto.ProductListResponse::from) + .toList(); + return ApiResponse.success(response); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getProductDetail( + @PathVariable Long productId + ) { + ProductDetailInfo info = productFacade.getProductDetail(productId); + return ApiResponse.success(ProductV1Dto.ProductDetailResponse.from(info)); + } +} 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..16a4b8a31 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,50 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductDetailInfo; +import com.loopers.application.product.ProductListInfo; + +public class ProductV1Dto { + + public record ProductDetailResponse( + Long id, + String name, + Long price, + int stockQuantity, + BrandResponse brand, + long likeCount + ) { + public static ProductDetailResponse from(ProductDetailInfo info) { + BrandResponse brandResponse = info.brand() != null + ? new BrandResponse(info.brand().id(), info.brand().name()) + : null; + return new ProductDetailResponse( + info.id(), + info.name(), + info.price(), + info.stockQuantity(), + brandResponse, + info.likeCount() + ); + } + } + + public record BrandResponse(Long id, String name) {} + + public record ProductListResponse( + Long id, + String name, + Long price, + String brandName, + long likeCount + ) { + public static ProductListResponse from(ProductListInfo info) { + return new ProductListResponse( + info.id(), + info.name(), + info.price(), + info.brandName(), + info.likeCount() + ); + } + } +} 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..8d57ce252 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,17 @@ 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(), "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ฆฌ์†Œ์Šค์ž…๋‹ˆ๋‹ค."), + FORBIDDEN(HttpStatus.FORBIDDEN, HttpStatus.FORBIDDEN.getReasonPhrase(), "์ ‘๊ทผ์ด ๊ฑฐ๋ถ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + INSUFFICIENT_STOCK(HttpStatus.BAD_REQUEST, "INSUFFICIENT_STOCK", "์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."), + + COUPON_NOT_FOUND(HttpStatus.NOT_FOUND, "COUPON_NOT_FOUND", "์ฟ ํฐ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + COUPON_ALREADY_USED(HttpStatus.BAD_REQUEST, "COUPON_ALREADY_USED", "์ด๋ฏธ ์‚ฌ์šฉ๋œ ์ฟ ํฐ์ž…๋‹ˆ๋‹ค."), + COUPON_EXPIRED(HttpStatus.BAD_REQUEST, "COUPON_EXPIRED", "๋งŒ๋ฃŒ๋œ ์ฟ ํฐ์ž…๋‹ˆ๋‹ค."), + COUPON_NOT_OWNED(HttpStatus.FORBIDDEN, "COUPON_NOT_OWNED", "๋ณธ์ธ์˜ ์ฟ ํฐ๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."), + COUPON_MIN_ORDER_AMOUNT(HttpStatus.BAD_REQUEST, "COUPON_MIN_ORDER_AMOUNT", "์ตœ์†Œ ์ฃผ๋ฌธ ๊ธˆ์•ก์„ ์ถฉ์กฑํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + COUPON_ALREADY_ISSUED(HttpStatus.CONFLICT, "COUPON_ALREADY_ISSUED", "์ด๋ฏธ ๋ฐœ๊ธ‰๋ฐ›์€ ์ฟ ํฐ์ž…๋‹ˆ๋‹ค."), + COUPON_UNAVAILABLE(HttpStatus.BAD_REQUEST, "COUPON_UNAVAILABLE", "์‚ฌ์šฉํ•  ์ˆ˜ ์—†๋Š” ์ฟ ํฐ์ž…๋‹ˆ๋‹ค."); private final HttpStatus status; private final String code; diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java new file mode 100644 index 000000000..d16d693eb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java @@ -0,0 +1,175 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +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 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.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LikeServiceTest { + + private LikeService likeService; + private FakeLikeRepository fakeLikeRepository; + private FakeProductRepository fakeProductRepository; + + @BeforeEach + void setUp() { + fakeLikeRepository = new FakeLikeRepository(); + fakeProductRepository = new FakeProductRepository(); + likeService = new LikeService(fakeLikeRepository, fakeProductRepository); + } + + @DisplayName("์ข‹์•„์š” ๋“ฑ๋ก") + @Nested + class LikeTest { + + @DisplayName("์กด์žฌํ•˜๋Š” ์ƒํ’ˆ์— ์ข‹์•„์š”๋ฅผ ๋“ฑ๋กํ•˜๋ฉด ์„ฑ๊ณตํ•œ๋‹ค") + @Test + void success() { + Long memberId = 1L; + fakeProductRepository.save(new Product(1L, "์ƒํ’ˆ", 10_000L, 10)); + Long productId = 1L; + + likeService.like(memberId, productId); + + assertThat(fakeLikeRepository.existsByMemberIdAndProductId(memberId, productId)).isTrue(); + } + + @DisplayName("์ด๋ฏธ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ์— ๋‹ค์‹œ ์ข‹์•„์š”ํ•ด๋„ ๋ฉฑ๋“ฑํ•˜๊ฒŒ ๋™์ž‘ํ•œ๋‹ค") + @Test + void idempotent() { + Long memberId = 1L; + fakeProductRepository.save(new Product(1L, "์ƒํ’ˆ", 10_000L, 10)); + Long productId = 1L; + + likeService.like(memberId, productId); + likeService.like(memberId, productId); + + assertThat(fakeLikeRepository.countByProductId(productId)).isEqualTo(1); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์— ์ข‹์•„์š”ํ•˜๋ฉด NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenProductNotFound() { + Long memberId = 1L; + Long nonExistentProductId = 999L; + + assertThatThrownBy(() -> likeService.like(memberId, nonExistentProductId)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + } + + @DisplayName("์ข‹์•„์š” ์ทจ์†Œ") + @Nested + class Unlike { + + @DisplayName("์ข‹์•„์š”ํ•œ ์ƒํ’ˆ์˜ ์ข‹์•„์š”๋ฅผ ์ทจ์†Œํ•˜๋ฉด ์„ฑ๊ณตํ•œ๋‹ค") + @Test + void success() { + Long memberId = 1L; + fakeProductRepository.save(new Product(1L, "์ƒํ’ˆ", 10_000L, 10)); + Long productId = 1L; + fakeLikeRepository.save(new Like(memberId, productId)); + + likeService.unlike(memberId, productId); + + assertThat(fakeLikeRepository.existsByMemberIdAndProductId(memberId, productId)).isFalse(); + } + + @DisplayName("์ข‹์•„์š”ํ•˜์ง€ ์•Š์€ ์ƒํ’ˆ์˜ ์ข‹์•„์š”๋ฅผ ์ทจ์†Œํ•ด๋„ ์˜ˆ์™ธ ์—†์ด ๋™์ž‘ํ•œ๋‹ค") + @Test + void idempotent() { + Long memberId = 1L; + Long productId = 100L; + + likeService.unlike(memberId, productId); + + assertThat(fakeLikeRepository.countByProductId(productId)).isEqualTo(0); + } + } + + static class FakeProductRepository implements ProductRepository { + private final Map store = new ConcurrentHashMap<>(); + private long nextId = 1; + + @Override + public Product save(Product product) { + Product toSave = new Product( + product.getBrandId(), + product.getName(), + product.getPrice(), + product.getStockQuantity() + ); + long id = nextId++; + store.put(id, toSave); + return toSave; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public Optional findByIdForUpdate(Long id) { + return findById(id); + } + + @Override + public java.util.List findAll(com.loopers.domain.product.SortCondition sort) { + return new ArrayList<>(store.values()); + } + } + + static class FakeLikeRepository implements LikeRepository { + private final List store = new ArrayList<>(); + private long id = 1; + + @Override + public Like save(Like like) { + Like toSave = new Like(like.getMemberId(), like.getProductId()); + store.add(toSave); + id++; + return toSave; + } + + @Override + public void deleteByMemberIdAndProductId(Long memberId, Long productId) { + store.removeIf(l -> l.getMemberId().equals(memberId) && l.getProductId().equals(productId)); + } + + @Override + public boolean existsByMemberIdAndProductId(Long memberId, Long productId) { + return store.stream() + .anyMatch(l -> l.getMemberId().equals(memberId) && l.getProductId().equals(productId)); + } + + @Override + public long countByProductId(Long productId) { + return store.stream() + .filter(l -> l.getProductId().equals(productId)) + .count(); + } + + @Override + public Map countByProductIds(List productIds) { + return productIds.stream() + .collect(java.util.stream.Collectors.toMap(id -> id, this::countByProductId)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java new file mode 100644 index 000000000..89e06ca6a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java @@ -0,0 +1,278 @@ +package com.loopers.application.order; + +import com.loopers.domain.coupon.CouponTemplate; +import com.loopers.domain.coupon.CouponTemplateRepository; +import com.loopers.domain.coupon.CouponType; +import com.loopers.domain.coupon.IssuedCoupon; +import com.loopers.domain.coupon.IssuedCouponRepository; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderDomainService; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.SortCondition; +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.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrderServiceTest { + + private OrderService orderService; + private FakeProductRepository fakeProductRepository; + private FakeOrderRepository fakeOrderRepository; + private FakeIssuedCouponRepository fakeIssuedCouponRepository; + private FakeCouponTemplateRepository fakeCouponTemplateRepository; + + @BeforeEach + void setUp() { + fakeProductRepository = new FakeProductRepository(); + fakeOrderRepository = new FakeOrderRepository(); + fakeIssuedCouponRepository = new FakeIssuedCouponRepository(); + fakeCouponTemplateRepository = new FakeCouponTemplateRepository(); + OrderDomainService orderDomainService = new OrderDomainService(fakeProductRepository, fakeOrderRepository); + orderService = new OrderService(orderDomainService, fakeIssuedCouponRepository, fakeCouponTemplateRepository); + } + + @DisplayName("์ฃผ๋ฌธ ์ƒ์„ฑ") + @Nested + class PlaceOrder { + + @DisplayName("์žฌ๊ณ ๊ฐ€ ์ถฉ๋ถ„ํ•˜๋ฉด ์ฃผ๋ฌธ์ด ์„ฑ๊ณตํ•œ๋‹ค") + @Test + void success() { + Long memberId = 1L; + fakeProductRepository.save(new Product(1L, "์ƒํ’ˆ", 10_000L, 10)); + List items = List.of( + new OrderDomainService.OrderLineRequest(1L, 3) + ); + + OrderService.OrderResult result = orderService.placeOrder(memberId, items, null); + + assertThat(result.orderId()).isNotNull(); + assertThat(result.status()).isEqualTo("ORDERED"); + assertThat(result.totalAmount()).isEqualTo(30_000L); + assertThat(result.originalAmount()).isEqualTo(30_000L); + assertThat(result.discountAmount()).isEqualTo(0L); + assertThat(result.orderLines()).hasSize(1); + } + + @DisplayName("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenInsufficientStock() { + Long memberId = 1L; + fakeProductRepository.save(new Product(1L, "์ƒํ’ˆ", 10_000L, 2)); + + assertThatThrownBy(() -> orderService.placeOrder(memberId, List.of( + new OrderDomainService.OrderLineRequest(1L, 5) + ), null)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.INSUFFICIENT_STOCK); + } + } + + @DisplayName("์ฟ ํฐ ์ ์šฉ ์ฃผ๋ฌธ") + @Nested + class PlaceOrderWithCoupon { + + @DisplayName("์ •์•ก ์ฟ ํฐ ์ ์šฉ ์‹œ ํ• ์ธ์ด ๋ฐ˜์˜๋œ๋‹ค") + @Test + void fixedCouponDiscount() { + Long memberId = 1L; + fakeProductRepository.save(new Product(1L, "์ƒํ’ˆ", 10_000L, 10)); + + CouponTemplate template = fakeCouponTemplateRepository.save( + new CouponTemplate("1000์› ํ• ์ธ", CouponType.FIXED, 1000, null, ZonedDateTime.now().plusDays(30)) + ); + IssuedCoupon issued = fakeIssuedCouponRepository.save(new IssuedCoupon(1L, memberId)); + + OrderService.OrderResult result = orderService.placeOrder(memberId, List.of( + new OrderDomainService.OrderLineRequest(1L, 3) + ), 1L); + + assertThat(result.originalAmount()).isEqualTo(30_000L); + assertThat(result.discountAmount()).isEqualTo(1_000L); + assertThat(result.totalAmount()).isEqualTo(29_000L); + } + + @DisplayName("์ •๋ฅ  ์ฟ ํฐ ์ ์šฉ ์‹œ ํ• ์ธ์ด ๋ฐ˜์˜๋œ๋‹ค") + @Test + void rateCouponDiscount() { + Long memberId = 1L; + fakeProductRepository.save(new Product(1L, "์ƒํ’ˆ", 10_000L, 10)); + + fakeCouponTemplateRepository.save( + new CouponTemplate("10% ํ• ์ธ", CouponType.RATE, 10, null, ZonedDateTime.now().plusDays(30)) + ); + fakeIssuedCouponRepository.save(new IssuedCoupon(1L, memberId)); + + OrderService.OrderResult result = orderService.placeOrder(memberId, List.of( + new OrderDomainService.OrderLineRequest(1L, 3) + ), 1L); + + assertThat(result.originalAmount()).isEqualTo(30_000L); + assertThat(result.discountAmount()).isEqualTo(3_000L); + assertThat(result.totalAmount()).isEqualTo(27_000L); + } + + @DisplayName("์ด๋ฏธ ์‚ฌ์šฉ๋œ ์ฟ ํฐ์€ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†๋‹ค") + @Test + void failsWhenCouponAlreadyUsed() { + Long memberId = 1L; + fakeProductRepository.save(new Product(1L, "์ƒํ’ˆ", 10_000L, 10)); + fakeCouponTemplateRepository.save( + new CouponTemplate("ํ• ์ธ", CouponType.FIXED, 1000, null, ZonedDateTime.now().plusDays(30)) + ); + IssuedCoupon issued = fakeIssuedCouponRepository.save(new IssuedCoupon(1L, memberId)); + issued.use(); + + assertThatThrownBy(() -> orderService.placeOrder(memberId, List.of( + new OrderDomainService.OrderLineRequest(1L, 1) + ), 1L)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.COUPON_UNAVAILABLE); + } + + @DisplayName("ํƒ€์ธ ์ฟ ํฐ์€ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†๋‹ค") + @Test + void failsWhenNotOwned() { + Long memberId = 1L; + Long otherMemberId = 2L; + fakeProductRepository.save(new Product(1L, "์ƒํ’ˆ", 10_000L, 10)); + fakeCouponTemplateRepository.save( + new CouponTemplate("ํ• ์ธ", CouponType.FIXED, 1000, null, ZonedDateTime.now().plusDays(30)) + ); + fakeIssuedCouponRepository.save(new IssuedCoupon(1L, otherMemberId)); + + assertThatThrownBy(() -> orderService.placeOrder(memberId, List.of( + new OrderDomainService.OrderLineRequest(1L, 1) + ), 1L)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.COUPON_NOT_OWNED); + } + } + + static class FakeProductRepository implements ProductRepository { + private final Map store = new ConcurrentHashMap<>(); + private long nextId = 1; + + @Override + public Product save(Product product) { + long id = nextId++; + store.put(id, product); + return product; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public Optional findByIdForUpdate(Long id) { + return findById(id); + } + + @Override + public List findAll(SortCondition sort) { + return new ArrayList<>(store.values()); + } + } + + static class FakeOrderRepository implements OrderRepository { + private final List store = new ArrayList<>(); + + @Override + public Order save(Order order) { + store.add(order); + return order; + } + + @Override + public Optional findById(Long id) { + return Optional.empty(); + } + } + + static class FakeIssuedCouponRepository implements IssuedCouponRepository { + private final Map store = new ConcurrentHashMap<>(); + private long nextId = 1; + + @Override + public IssuedCoupon save(IssuedCoupon issuedCoupon) { + long id = nextId++; + store.put(id, issuedCoupon); + return issuedCoupon; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public Optional findByIdForUpdate(Long id) { + return findById(id); + } + + @Override + public List findByMemberId(Long memberId) { + return store.values().stream().filter(c -> c.getMemberId().equals(memberId)).toList(); + } + + @Override + public boolean existsByMemberIdAndCouponTemplateId(Long memberId, Long couponTemplateId) { + return store.values().stream() + .anyMatch(c -> c.getMemberId().equals(memberId) && c.getCouponTemplateId().equals(couponTemplateId)); + } + + @Override + public List findByCouponTemplateId(Long couponTemplateId, int page, int size) { + return store.values().stream().filter(c -> c.getCouponTemplateId().equals(couponTemplateId)).toList(); + } + + @Override + public long countByCouponTemplateId(Long couponTemplateId) { + return store.values().stream().filter(c -> c.getCouponTemplateId().equals(couponTemplateId)).count(); + } + } + + static class FakeCouponTemplateRepository implements CouponTemplateRepository { + private final Map store = new ConcurrentHashMap<>(); + private long nextId = 1; + + @Override + public CouponTemplate save(CouponTemplate couponTemplate) { + long id = nextId++; + store.put(id, couponTemplate); + return couponTemplate; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public List findAll(int page, int size) { + return new ArrayList<>(store.values()); + } + + @Override + public long count() { + return store.size(); + } + } +} 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..ef454e75e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/concurrency/CouponConcurrencyTest.java @@ -0,0 +1,108 @@ +package com.loopers.concurrency; + +import com.loopers.application.order.OrderService; +import com.loopers.domain.coupon.CouponStatus; +import com.loopers.domain.coupon.CouponTemplate; +import com.loopers.domain.coupon.CouponTemplateRepository; +import com.loopers.domain.coupon.CouponType; +import com.loopers.domain.coupon.IssuedCoupon; +import com.loopers.domain.coupon.IssuedCouponRepository; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.order.OrderDomainService; +import com.loopers.domain.product.Brand; +import com.loopers.domain.product.BrandRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +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.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class CouponConcurrencyTest { + + @Autowired + private OrderService orderService; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private CouponTemplateRepository couponTemplateRepository; + + @Autowired + private IssuedCouponRepository issuedCouponRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("๋™์ผํ•œ ์ฟ ํฐ์œผ๋กœ 5๊ฐœ ์Šค๋ ˆ๋“œ๊ฐ€ ๋™์‹œ์— ์ฃผ๋ฌธํ•ด๋„ ์ฟ ํฐ์€ ๋‹จ 1๋ฒˆ๋งŒ ์‚ฌ์šฉ๋œ๋‹ค") + @Test + void concurrentCouponUse_onlyOneSucceeds() throws InterruptedException { + Brand brand = brandRepository.save(new Brand("๋ธŒ๋žœ๋“œ")); + Product product = productRepository.save(new Product(brand.getId(), "์ƒํ’ˆ", 10_000L, 100)); + Long productId = product.getId(); + + Member member = memberRepository.save(new Member("user1", "password", "์‚ฌ์šฉ์ž", "2000-01-01", "user@test.com")); + Long memberId = member.getId(); + + CouponTemplate template = couponTemplateRepository.save( + new CouponTemplate("1000์› ํ• ์ธ", CouponType.FIXED, 1000, null, ZonedDateTime.now().plusDays(30)) + ); + IssuedCoupon issuedCoupon = issuedCouponRepository.save(new IssuedCoupon(template.getId(), memberId)); + Long couponId = issuedCoupon.getId(); + + int threadCount = 5; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + List results = Collections.synchronizedList(new ArrayList<>()); + + for (int i = 0; i < threadCount; i++) { + executorService.submit(() -> { + try { + orderService.placeOrder(memberId, List.of( + new OrderDomainService.OrderLineRequest(productId, 1) + ), couponId); + results.add(true); + } catch (Exception e) { + results.add(false); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + long successCount = results.stream().filter(r -> r).count(); + assertThat(successCount).isEqualTo(1); + + IssuedCoupon updatedCoupon = issuedCouponRepository.findById(couponId).orElseThrow(); + assertThat(updatedCoupon.getStatus()).isEqualTo(CouponStatus.USED); + } +} 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..75c456b07 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java @@ -0,0 +1,139 @@ +package com.loopers.concurrency; + +import com.loopers.application.like.LikeService; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.product.Brand; +import com.loopers.domain.product.BrandRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +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.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class LikeConcurrencyTest { + + @Autowired + private LikeService likeService; + + @Autowired + private LikeRepository likeRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("100๋ช…์ด ๋™์‹œ์— ๊ฐ™์€ ์ƒํ’ˆ์— ์ข‹์•„์š”ํ•˜๋ฉด ์ข‹์•„์š” ์ˆ˜๊ฐ€ 100์ด ๋œ๋‹ค") + @Test + void concurrentLikes() throws InterruptedException { + Brand brand = brandRepository.save(new Brand("๋ธŒ๋žœ๋“œ")); + Product product = productRepository.save(new Product(brand.getId(), "์ƒํ’ˆ", 10_000L, 100)); + Long productId = product.getId(); + + int threadCount = 100; + ExecutorService executorService = Executors.newFixedThreadPool(20); + CountDownLatch latch = new CountDownLatch(threadCount); + List results = Collections.synchronizedList(new ArrayList<>()); + + for (int i = 0; i < threadCount; i++) { + final long memberId = i + 1; + memberRepository.save(new Member("user" + memberId, "password", "์‚ฌ์šฉ์ž" + memberId, "2000-01-01", "user" + memberId + "@test.com")); + executorService.submit(() -> { + try { + likeService.like(memberId, productId); + results.add(true); + } catch (Exception e) { + results.add(false); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + long likeCount = likeRepository.countByProductId(productId); + assertThat(likeCount).isEqualTo(100); + } + + @DisplayName("50๋ช…์ด ์ข‹์•„์š”ํ•˜๊ณ  ๋‹ค๋ฅธ 50๋ช…์ด ์ข‹์•„์š” ํ›„ ์ทจ์†Œํ•˜๋ฉด ์ข‹์•„์š” ์ˆ˜๊ฐ€ 50์ด ๋œ๋‹ค") + @Test + void concurrentLikesAndUnlikes() throws InterruptedException { + Brand brand = brandRepository.save(new Brand("๋ธŒ๋žœ๋“œ")); + Product product = productRepository.save(new Product(brand.getId(), "์ƒํ’ˆ", 10_000L, 100)); + Long productId = product.getId(); + + for (int i = 51; i <= 100; i++) { + final long memberId = i; + memberRepository.save(new Member("user" + memberId, "password", "์‚ฌ์šฉ์ž" + memberId, "2000-01-01", "user" + memberId + "@test.com")); + likeService.like(memberId, productId); + } + + int threadCount = 100; + ExecutorService executorService = Executors.newFixedThreadPool(20); + CountDownLatch latch = new CountDownLatch(threadCount); + + for (int i = 1; i <= 50; i++) { + final long memberId = i; + memberRepository.save(new Member("newuser" + memberId, "password", "์‹ ๊ทœ" + memberId, "2000-01-01", "new" + memberId + "@test.com")); + } + + for (int i = 1; i <= 50; i++) { + final long newMemberId = 100 + i; + final long existingMemberId = 50 + i; + + executorService.submit(() -> { + try { + Member newMember = memberRepository.findByLoginId("newuser" + (newMemberId - 100)).orElseThrow(); + likeService.like(newMember.getId(), productId); + } catch (Exception ignored) { + } finally { + latch.countDown(); + } + }); + + executorService.submit(() -> { + try { + likeService.unlike(existingMemberId, productId); + } catch (Exception ignored) { + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + long likeCount = likeRepository.countByProductId(productId); + assertThat(likeCount).isEqualTo(50); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/concurrency/StockConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/concurrency/StockConcurrencyTest.java new file mode 100644 index 000000000..c99515e76 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/concurrency/StockConcurrencyTest.java @@ -0,0 +1,129 @@ +package com.loopers.concurrency; + +import com.loopers.application.order.OrderService; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.order.OrderDomainService; +import com.loopers.domain.product.Brand; +import com.loopers.domain.product.BrandRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +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.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class StockConcurrencyTest { + + @Autowired + private OrderService orderService; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("์žฌ๊ณ  10๊ฐœ์ธ ์ƒํ’ˆ์— 10๋ช…์ด ๋™์‹œ์— 1๊ฐœ์”ฉ ์ฃผ๋ฌธํ•˜๋ฉด ์žฌ๊ณ ๊ฐ€ 0์ด ๋œ๋‹ค") + @Test + void concurrentStockDecrease_allSucceed() throws InterruptedException { + Brand brand = brandRepository.save(new Brand("ํ…Œ์ŠคํŠธ ๋ธŒ๋žœ๋“œ")); + Product product = productRepository.save(new Product(brand.getId(), "์ƒํ’ˆ", 10_000L, 10)); + Long productId = product.getId(); + + int threadCount = 10; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + List results = Collections.synchronizedList(new ArrayList<>()); + + for (int i = 0; i < threadCount; i++) { + final long memberId = i + 1; + memberRepository.save(new Member("user" + memberId, "password", "์‚ฌ์šฉ์ž" + memberId, "2000-01-01", "user" + memberId + "@test.com")); + executorService.submit(() -> { + try { + orderService.placeOrder(memberId, List.of( + new OrderDomainService.OrderLineRequest(productId, 1) + ), null); + results.add(true); + } catch (Exception e) { + results.add(false); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + long successCount = results.stream().filter(r -> r).count(); + assertThat(successCount).isEqualTo(10); + + Product updated = productRepository.findById(productId).orElseThrow(); + assertThat(updated.getStockQuantity()).isEqualTo(0); + } + + @DisplayName("์žฌ๊ณ  5๊ฐœ์ธ ์ƒํ’ˆ์— 10๋ช…์ด ๋™์‹œ์— 1๊ฐœ์”ฉ ์ฃผ๋ฌธํ•˜๋ฉด 5๊ฑด๋งŒ ์„ฑ๊ณตํ•˜๊ณ  ์žฌ๊ณ ๊ฐ€ 0์ด ๋œ๋‹ค") + @Test + void concurrentStockDecrease_partialSuccess() throws InterruptedException { + Brand brand = brandRepository.save(new Brand("ํ…Œ์ŠคํŠธ ๋ธŒ๋žœ๋“œ")); + Product product = productRepository.save(new Product(brand.getId(), "์ƒํ’ˆ", 10_000L, 5)); + Long productId = product.getId(); + + int threadCount = 10; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + List results = Collections.synchronizedList(new ArrayList<>()); + + for (int i = 0; i < threadCount; i++) { + final long memberId = i + 1; + memberRepository.save(new Member("user" + memberId, "password", "์‚ฌ์šฉ์ž" + memberId, "2000-01-01", "user" + memberId + "@test.com")); + executorService.submit(() -> { + try { + orderService.placeOrder(memberId, List.of( + new OrderDomainService.OrderLineRequest(productId, 1) + ), null); + results.add(true); + } catch (Exception e) { + results.add(false); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + long successCount = results.stream().filter(r -> r).count(); + long failCount = results.stream().filter(r -> !r).count(); + assertThat(successCount).isEqualTo(5); + assertThat(failCount).isEqualTo(5); + + Product updated = productRepository.findById(productId).orElseThrow(); + assertThat(updated.getStockQuantity()).isEqualTo(0); + } +} 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..dacdab7d5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTemplateTest.java @@ -0,0 +1,177 @@ +package com.loopers.domain.coupon; + +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.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class CouponTemplateTest { + + @DisplayName("์ฟ ํฐ ํ…œํ”Œ๋ฆฟ ์ƒ์„ฑ") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ •์•ก ์ฟ ํฐ ํ…œํ”Œ๋ฆฟ์„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void fixedCoupon() { + CouponTemplate template = new CouponTemplate( + "1000์› ํ• ์ธ", CouponType.FIXED, 1000, 10000L, ZonedDateTime.now().plusDays(30) + ); + + assertAll( + () -> assertThat(template.getName()).isEqualTo("1000์› ํ• ์ธ"), + () -> assertThat(template.getType()).isEqualTo(CouponType.FIXED), + () -> assertThat(template.getValue()).isEqualTo(1000), + () -> assertThat(template.getMinOrderAmount()).isEqualTo(10000L) + ); + } + + @DisplayName("์œ ํšจํ•œ ์ •๋ฅ  ์ฟ ํฐ ํ…œํ”Œ๋ฆฟ์„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void rateCoupon() { + CouponTemplate template = new CouponTemplate( + "10% ํ• ์ธ", CouponType.RATE, 10, null, ZonedDateTime.now().plusDays(30) + ); + + assertAll( + () -> assertThat(template.getType()).isEqualTo(CouponType.RATE), + () -> assertThat(template.getValue()).isEqualTo(10), + () -> assertThat(template.getMinOrderAmount()).isNull() + ); + } + + @DisplayName("์ด๋ฆ„์ด ์—†์œผ๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenNameIsBlank() { + assertThatThrownBy(() -> new CouponTemplate("", CouponType.FIXED, 1000, null, ZonedDateTime.now().plusDays(30))) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("ํƒ€์ž…์ด ์—†์œผ๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenTypeIsNull() { + assertThatThrownBy(() -> new CouponTemplate("ํ• ์ธ", null, 1000, null, ZonedDateTime.now().plusDays(30))) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("๊ฐ’์ด 0 ์ดํ•˜์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenValueIsZero() { + assertThatThrownBy(() -> new CouponTemplate("ํ• ์ธ", CouponType.FIXED, 0, null, ZonedDateTime.now().plusDays(30))) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("์ •๋ฅ  ์ฟ ํฐ์ด 100%๋ฅผ ์ดˆ๊ณผํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenRateExceeds100() { + assertThatThrownBy(() -> new CouponTemplate("ํ• ์ธ", CouponType.RATE, 101, null, ZonedDateTime.now().plusDays(30))) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("๋งŒ๋ฃŒ์ผ์ด ์—†์œผ๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenExpiredAtIsNull() { + assertThatThrownBy(() -> new CouponTemplate("ํ• ์ธ", CouponType.FIXED, 1000, null, null)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + } + + @DisplayName("ํ• ์ธ ๊ธˆ์•ก ๊ณ„์‚ฐ") + @Nested + class CalculateDiscount { + + @DisplayName("์ •์•ก ์ฟ ํฐ์€ ๊ณ ์ • ๊ธˆ์•ก์„ ํ• ์ธํ•œ๋‹ค") + @Test + void fixedDiscount() { + CouponTemplate template = new CouponTemplate( + "3000์› ํ• ์ธ", CouponType.FIXED, 3000, null, ZonedDateTime.now().plusDays(30) + ); + + assertThat(template.calculateDiscount(10_000)).isEqualTo(3_000); + } + + @DisplayName("์ •์•ก ์ฟ ํฐ์˜ ํ• ์ธ์€ ์ฃผ๋ฌธ ๊ธˆ์•ก์„ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†๋‹ค") + @Test + void fixedDiscountCappedByOrderAmount() { + CouponTemplate template = new CouponTemplate( + "5000์› ํ• ์ธ", CouponType.FIXED, 5000, null, ZonedDateTime.now().plusDays(30) + ); + + assertThat(template.calculateDiscount(3_000)).isEqualTo(3_000); + } + + @DisplayName("์ •๋ฅ  ์ฟ ํฐ์€ ๋น„์œจ๋งŒํผ ํ• ์ธํ•œ๋‹ค") + @Test + void rateDiscount() { + CouponTemplate template = new CouponTemplate( + "10% ํ• ์ธ", CouponType.RATE, 10, null, ZonedDateTime.now().plusDays(30) + ); + + assertThat(template.calculateDiscount(30_000)).isEqualTo(3_000); + } + } + + @DisplayName("๋งŒ๋ฃŒ ํ™•์ธ") + @Nested + class Expiry { + + @DisplayName("๋งŒ๋ฃŒ์ผ์ด ์ง€๋‚ฌ์œผ๋ฉด true") + @Test + void expired() { + CouponTemplate template = new CouponTemplate( + "ํ• ์ธ", CouponType.FIXED, 1000, null, ZonedDateTime.now().minusDays(1) + ); + + assertThat(template.isExpired()).isTrue(); + } + + @DisplayName("๋งŒ๋ฃŒ์ผ์ด ์ง€๋‚˜์ง€ ์•Š์•˜์œผ๋ฉด false") + @Test + void notExpired() { + CouponTemplate template = new CouponTemplate( + "ํ• ์ธ", CouponType.FIXED, 1000, null, ZonedDateTime.now().plusDays(30) + ); + + assertThat(template.isExpired()).isFalse(); + } + } + + @DisplayName("์ตœ์†Œ ์ฃผ๋ฌธ ๊ธˆ์•ก ๊ฒ€์ฆ") + @Nested + class MinOrderAmount { + + @DisplayName("์ฃผ๋ฌธ ๊ธˆ์•ก์ด ์ตœ์†Œ ์ฃผ๋ฌธ ๊ธˆ์•ก ๋ฏธ๋งŒ์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenBelowMinimum() { + CouponTemplate template = new CouponTemplate( + "ํ• ์ธ", CouponType.FIXED, 1000, 10_000L, ZonedDateTime.now().plusDays(30) + ); + + assertThatThrownBy(() -> template.validateMinOrderAmount(5_000)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.COUPON_MIN_ORDER_AMOUNT); + } + + @DisplayName("์ตœ์†Œ ์ฃผ๋ฌธ ๊ธˆ์•ก์ด ์—†์œผ๋ฉด ์–ด๋–ค ๊ธˆ์•ก์ด๋“  ํ†ต๊ณผํ•œ๋‹ค") + @Test + void noMinOrderAmount() { + CouponTemplate template = new CouponTemplate( + "ํ• ์ธ", CouponType.FIXED, 1000, null, ZonedDateTime.now().plusDays(30) + ); + + template.validateMinOrderAmount(100); + } + } +} 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..f04668674 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/IssuedCouponTest.java @@ -0,0 +1,122 @@ +package com.loopers.domain.coupon; + +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.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class IssuedCouponTest { + + @DisplayName("๋ฐœ๊ธ‰ ์ฟ ํฐ ์ƒ์„ฑ") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ฟ ํฐ์„ ๋ฐœ๊ธ‰ํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void success() { + IssuedCoupon coupon = new IssuedCoupon(1L, 100L); + + assertAll( + () -> assertThat(coupon.getCouponTemplateId()).isEqualTo(1L), + () -> assertThat(coupon.getMemberId()).isEqualTo(100L), + () -> assertThat(coupon.getStatus()).isEqualTo(CouponStatus.AVAILABLE), + () -> assertThat(coupon.getUsedAt()).isNull() + ); + } + + @DisplayName("ํ…œํ”Œ๋ฆฟ ID๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenTemplateIdIsNull() { + assertThatThrownBy(() -> new IssuedCoupon(null, 100L)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("ํšŒ์› ID๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenMemberIdIsNull() { + assertThatThrownBy(() -> new IssuedCoupon(1L, null)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + } + + @DisplayName("์ฟ ํฐ ์‚ฌ์šฉ") + @Nested + class Use { + + @DisplayName("AVAILABLE ์ƒํƒœ์˜ ์ฟ ํฐ์„ ์‚ฌ์šฉํ•˜๋ฉด USED๋กœ ๋ณ€๊ฒฝ๋œ๋‹ค") + @Test + void success() { + IssuedCoupon coupon = new IssuedCoupon(1L, 100L); + + coupon.use(); + + assertAll( + () -> assertThat(coupon.getStatus()).isEqualTo(CouponStatus.USED), + () -> assertThat(coupon.getUsedAt()).isNotNull() + ); + } + + @DisplayName("์ด๋ฏธ ์‚ฌ์šฉ๋œ ์ฟ ํฐ์„ ์‚ฌ์šฉํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenAlreadyUsed() { + IssuedCoupon coupon = new IssuedCoupon(1L, 100L); + coupon.use(); + + assertThatThrownBy(coupon::use) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.COUPON_ALREADY_USED); + } + } + + @DisplayName("์‚ฌ์šฉ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ํ™•์ธ") + @Nested + class IsUsable { + + @DisplayName("AVAILABLE์ด๋ฉด ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•˜๋‹ค") + @Test + void available() { + IssuedCoupon coupon = new IssuedCoupon(1L, 100L); + + assertThat(coupon.isUsable()).isTrue(); + } + + @DisplayName("USED์ด๋ฉด ์‚ฌ์šฉ ๋ถˆ๊ฐ€ํ•˜๋‹ค") + @Test + void used() { + IssuedCoupon coupon = new IssuedCoupon(1L, 100L); + coupon.use(); + + assertThat(coupon.isUsable()).isFalse(); + } + } + + @DisplayName("์†Œ์œ ์ž ๊ฒ€์ฆ") + @Nested + class ValidateOwnership { + + @DisplayName("์†Œ์œ ์ž๊ฐ€ ๋งž์œผ๋ฉด ์˜ˆ์™ธ ์—†์ด ํ†ต๊ณผํ•œ๋‹ค") + @Test + void success() { + IssuedCoupon coupon = new IssuedCoupon(1L, 100L); + + coupon.validateOwnership(100L); + } + + @DisplayName("์†Œ์œ ์ž๊ฐ€ ๋‹ค๋ฅด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenNotOwner() { + IssuedCoupon coupon = new IssuedCoupon(1L, 100L); + + assertThatThrownBy(() -> coupon.validateOwnership(200L)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.COUPON_NOT_OWNED); + } + } +} 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..cc803461e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java @@ -0,0 +1,49 @@ +package com.loopers.domain.like; + +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.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class LikeTest { + + @DisplayName("์ข‹์•„์š”๋ฅผ ์ƒ์„ฑํ•  ๋•Œ") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ํšŒ์›ID์™€ ์ƒํ’ˆID๊ฐ€ ์ฃผ์–ด์ง€๋ฉด ์„ฑ๊ณตํ•œ๋‹ค") + @Test + void success() { + Long memberId = 1L; + Long productId = 100L; + + Like like = new Like(memberId, productId); + + assertAll( + () -> assertThat(like.getMemberId()).isEqualTo(memberId), + () -> assertThat(like.getProductId()).isEqualTo(productId) + ); + } + + @DisplayName("ํšŒ์›ID๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenMemberIdIsNull() { + assertThatThrownBy(() -> new Like(null, 1L)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("์ƒํ’ˆID๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenProductIdIsNull() { + assertThatThrownBy(() -> new Like(1L, null)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberRepositoryTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberRepositoryTest.java new file mode 100644 index 000000000..88792c22b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberRepositoryTest.java @@ -0,0 +1,118 @@ +package com.loopers.domain.member; + +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.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +class MemberRepositoryTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("ํšŒ์›์„ ์ €์žฅํ•  ๋•Œ") + @Nested + class Save { + + @DisplayName("์œ ํšจํ•œ ํšŒ์› ์ •๋ณด๋ฅผ ์ €์žฅํ•˜๋ฉด ์„ฑ๊ณตํ•œ๋‹ค") + @Test + void saveMember() { + Member member = new Member( + "ymcho", + "ymcho123", + "์กฐ์šฉ๋ฏผ", + "1991-07-03", + "ymcho@example.com" + ); + + Member saved = memberRepository.save(member); + + assertAll( + () -> assertThat(saved.getId()).isNotNull(), + () -> assertThat(saved.getLoginId()).isEqualTo("ymcho"), + () -> assertThat(saved.getName()).isEqualTo("์กฐ์šฉ๋ฏผ") + ); + } + } + + @DisplayName("๋กœ๊ทธ์ธID๋กœ ํšŒ์›์„ ์กฐํšŒํ•  ๋•Œ") + @Nested + class FindByLoginId { + + @DisplayName("์กด์žฌํ•˜๋Š” ๋กœ๊ทธ์ธID๋กœ ์กฐํšŒํ•˜๋ฉด ํšŒ์›์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void findExistingMember() { + Member member = new Member( + "ymcho", + "ymcho123", + "์กฐ์šฉ๋ฏผ", + "1991-07-03", + "ymcho@example.com" + ); + memberRepository.save(member); + + Optional found = memberRepository.findByLoginId("ymcho"); + + assertAll( + () -> assertThat(found).isPresent(), + () -> assertThat(found.get().getLoginId()).isEqualTo("ymcho"), + () -> assertThat(found.get().getName()).isEqualTo("์กฐ์šฉ๋ฏผ") + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋กœ๊ทธ์ธID๋กœ ์กฐํšŒํ•˜๋ฉด ๋นˆ ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void findNonExistingMember() { + Optional found = memberRepository.findByLoginId("nonexistent"); + + assertThat(found).isEmpty(); + } + } + + @DisplayName("๋กœ๊ทธ์ธID ์ค‘๋ณต์„ ํ™•์ธํ•  ๋•Œ") + @Nested + class ExistsByLoginId { + + @DisplayName("์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋กœ๊ทธ์ธID๋ฉด true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void existingLoginId() { + Member member = new Member( + "ymcho", + "ymcho123", + "์กฐ์šฉ๋ฏผ", + "1991-07-03", + "ymcho@example.com" + ); + memberRepository.save(member); + + boolean exists = memberRepository.existsByLoginId("ymcho"); + + assertThat(exists).isTrue(); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋กœ๊ทธ์ธID๋ฉด false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void nonExistingLoginId() { + boolean exists = memberRepository.existsByLoginId("nonexistent"); + + assertThat(exists).isFalse(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java new file mode 100644 index 000000000..8ec239ca7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java @@ -0,0 +1,92 @@ +package com.loopers.domain.member; + +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.*; +import static org.junit.jupiter.api.Assertions.assertAll; + +class MemberTest { + + @DisplayName("ํšŒ์›์„ ์ƒ์„ฑํ•  ๋•Œ") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๊ฐ€ ์ฃผ์–ด์ง€๋ฉด ์„ฑ๊ณตํ•œ๋‹ค") + @Test + void success() { + String loginId = "ymcho"; + String password = "ymcho123"; + String name = "์กฐ์šฉ๋ฏผ"; + String birthDate = "1991-07-03"; + String email = "ymcho@example.com"; + + Member member = new Member(loginId, password, name, birthDate, email); + + assertAll( + () -> assertThat(member.getLoginId()).isEqualTo(loginId), + () -> assertThat(member.getName()).isEqualTo(name), + () -> assertThat(member.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(member.getEmail()).isEqualTo(email) + ); + } + + @DisplayName("๋กœ๊ทธ์ธID๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenLoginIdIsNull() { + assertThatThrownBy(() -> + new Member(null, "ymcho123", "์กฐ์šฉ๋ฏผ", "1991-07-03", "ymcho@example.com") + ) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("๋กœ๊ทธ์ธID๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenLoginIdIsBlank() { + assertThatThrownBy(() -> + new Member(" ", "ymcho123", "์กฐ์šฉ๋ฏผ", "1991-07-03", "ymcho@example.com") + ) + .isInstanceOf(CoreException.class); + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenPasswordIsNull() { + assertThatThrownBy(() -> + new Member("ymcho", null, "์กฐ์šฉ๋ฏผ", "1991-07-03", "ymcho@example.com") + ) + .isInstanceOf(CoreException.class); + } + + @DisplayName("์ด๋ฆ„์ด null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenNameIsNull() { + assertThatThrownBy(() -> + new Member("ymcho", "ymcho123", null, "1991-07-03", "ymcho@example.com") + ) + .isInstanceOf(CoreException.class); + } + + @DisplayName("์ƒ๋…„์›”์ผ์ด null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenBirthDateIsNull() { + assertThatThrownBy(() -> + new Member("ymcho", "ymcho123", "์กฐ์šฉ๋ฏผ", null, "ymcho@example.com") + ) + .isInstanceOf(CoreException.class); + } + + @DisplayName("์ด๋ฉ”์ผ์ด null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenEmailIsNull() { + assertThatThrownBy(() -> + new Member("ymcho", "ymcho123", "์กฐ์šฉ๋ฏผ", "1991-07-03", null) + ) + .isInstanceOf(CoreException.class); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceTest.java new file mode 100644 index 000000000..18340fb0d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceTest.java @@ -0,0 +1,139 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.SortCondition; +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.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrderDomainServiceTest { + + private OrderDomainService orderDomainService; + private FakeProductRepository fakeProductRepository; + private FakeOrderRepository fakeOrderRepository; + + @BeforeEach + void setUp() { + fakeProductRepository = new FakeProductRepository(); + fakeOrderRepository = new FakeOrderRepository(); + orderDomainService = new OrderDomainService(fakeProductRepository, fakeOrderRepository); + } + + @DisplayName("์ฃผ๋ฌธ ์ƒ์„ฑ") + @Nested + class PlaceOrder { + + @DisplayName("์žฌ๊ณ ๊ฐ€ ์ถฉ๋ถ„ํ•˜๋ฉด ์ฃผ๋ฌธ์ด ์„ฑ๊ณตํ•˜๊ณ  ์žฌ๊ณ ๊ฐ€ ์ฐจ๊ฐ๋œ๋‹ค") + @Test + void success() { + Long memberId = 1L; + fakeProductRepository.save(new Product(1L, "์ƒํ’ˆ", 10_000L, 10)); + Long productId = 1L; + + List orderLines = orderDomainService.prepareOrderLines(List.of( + new OrderDomainService.OrderLineRequest(productId, 3) + )); + Order order = orderDomainService.createOrder(memberId, orderLines); + + assertThat(order.getMemberId()).isEqualTo(memberId); + assertThat(order.getOrderLines()).hasSize(1); + assertThat(order.getTotalAmount()).isEqualTo(30_000L); + + Product updatedProduct = fakeProductRepository.findById(productId).orElseThrow(); + assertThat(updatedProduct.getStockQuantity()).isEqualTo(7); + } + + @DisplayName("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•˜๋ฉด INSUFFICIENT_STOCK ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜๊ณ  ์ฃผ๋ฌธ์ด ์ƒ์„ฑ๋˜์ง€ ์•Š๋Š”๋‹ค") + @Test + void failsWhenInsufficientStock() { + Long memberId = 1L; + fakeProductRepository.save(new Product(1L, "์ƒํ’ˆ", 10_000L, 5)); + Long productId = 1L; + + assertThatThrownBy(() -> orderDomainService.prepareOrderLines(List.of( + new OrderDomainService.OrderLineRequest(productId, 10) + ))) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.INSUFFICIENT_STOCK); + + assertThat(fakeOrderRepository.count()).isEqualTo(0); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์ด ํฌํ•จ๋˜๋ฉด NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenProductNotFound() { + Long nonExistentProductId = 999L; + + assertThatThrownBy(() -> orderDomainService.prepareOrderLines(List.of( + new OrderDomainService.OrderLineRequest(nonExistentProductId, 1) + ))) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + } + + static class FakeProductRepository implements ProductRepository { + private final Map store = new ConcurrentHashMap<>(); + private long nextId = 1; + + @Override + public Product save(Product product) { + Product toSave = new Product( + product.getBrandId(), + product.getName(), + product.getPrice(), + product.getStockQuantity() + ); + long id = nextId++; + store.put(id, toSave); + return toSave; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public Optional findByIdForUpdate(Long id) { + return findById(id); + } + + @Override + public List findAll(SortCondition sort) { + return new ArrayList<>(store.values()); + } + } + + static class FakeOrderRepository implements OrderRepository { + private final List store = new ArrayList<>(); + + @Override + public Order save(Order order) { + store.add(order); + return order; + } + + @Override + public Optional findById(Long id) { + return Optional.empty(); + } + + int count() { + return store.size(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderLineTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderLineTest.java new file mode 100644 index 000000000..cc80f4fe6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderLineTest.java @@ -0,0 +1,41 @@ +package com.loopers.domain.order; + +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.assertj.core.api.Assertions.assertThatThrownBy; + +class OrderLineTest { + + @DisplayName("OrderLine์„ ์ƒ์„ฑํ•  ๋•Œ") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ๊ฐ’์ด ์ฃผ์–ด์ง€๋ฉด ์„ฑ๊ณตํ•œ๋‹ค") + @Test + void success() { + OrderLine orderLine = new OrderLine(1L, 2, 10_000L); + + assertThat(orderLine.getTotalPrice()).isEqualTo(20_000L); + } + + @DisplayName("์ƒํ’ˆID๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenProductIdIsNull() { + assertThatThrownBy(() -> new OrderLine(null, 1, 10_000L)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("์ˆ˜๋Ÿ‰์ด 0 ์ดํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenQuantityIsZeroOrNegative() { + assertThatThrownBy(() -> new OrderLine(1L, 0, 10_000L)) + .isInstanceOf(CoreException.class); + } + } +} 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..20fd0813d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,58 @@ +package com.loopers.domain.order; + +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.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class OrderTest { + + @DisplayName("์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•  ๋•Œ") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ํšŒ์›ID์™€ ์ฃผ๋ฌธ ํ•ญ๋ชฉ์ด ์ฃผ์–ด์ง€๋ฉด ์„ฑ๊ณตํ•œ๋‹ค") + @Test + void success() { + Long memberId = 1L; + List orderLines = List.of( + new OrderLine(1L, 2, 10_000L), + new OrderLine(2L, 1, 5_000L) + ); + + Order order = Order.create(memberId, orderLines); + + assertAll( + () -> assertThat(order.getMemberId()).isEqualTo(memberId), + () -> assertThat(order.getStatus()).isEqualTo("ORDERED"), + () -> assertThat(order.getOrderLines()).hasSize(2), + () -> assertThat(order.getTotalAmount()).isEqualTo(25_000L) + ); + } + + @DisplayName("ํšŒ์›ID๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenMemberIdIsNull() { + List orderLines = List.of(new OrderLine(1L, 1, 10_000L)); + + assertThatThrownBy(() -> Order.create(null, orderLines)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("์ฃผ๋ฌธ ํ•ญ๋ชฉ์ด ๋น„์–ด์žˆ์œผ๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenOrderLinesIsEmpty() { + assertThatThrownBy(() -> Order.create(1L, List.of())) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/BrandTest.java new file mode 100644 index 000000000..144e7e308 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/BrandTest.java @@ -0,0 +1,47 @@ +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.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class BrandTest { + + @DisplayName("๋ธŒ๋žœ๋“œ๋ฅผ ์ƒ์„ฑํ•  ๋•Œ") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ด๋ฆ„์ด ์ฃผ์–ด์ง€๋ฉด ์„ฑ๊ณตํ•œ๋‹ค") + @Test + void success() { + String name = "๋‚˜์ดํ‚ค"; + + Brand brand = new Brand(name); + + assertAll( + () -> assertThat(brand.getName()).isEqualTo(name) + ); + } + + @DisplayName("์ด๋ฆ„์ด null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenNameIsNull() { + assertThatThrownBy(() -> new Brand(null)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("์ด๋ฆ„์ด ๋นˆ ๋ฌธ์ž์—ด์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenNameIsBlank() { + assertThatThrownBy(() -> new Brand(" ")) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + } +} 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..9e4a99085 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,116 @@ +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.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class ProductTest { + + @DisplayName("์ƒํ’ˆ์„ ์ƒ์„ฑํ•  ๋•Œ") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๊ฐ€ ์ฃผ์–ด์ง€๋ฉด ์„ฑ๊ณตํ•œ๋‹ค") + @Test + void success() { + Long brandId = 1L; + String name = "์—์–ด๋งฅ์Šค"; + Long price = 150_000L; + int stockQuantity = 10; + + Product product = new Product(brandId, name, price, stockQuantity); + + assertAll( + () -> assertThat(product.getBrandId()).isEqualTo(brandId), + () -> assertThat(product.getName()).isEqualTo(name), + () -> assertThat(product.getPrice()).isEqualTo(price), + () -> assertThat(product.getStockQuantity()).isEqualTo(stockQuantity) + ); + } + + @DisplayName("์ƒํ’ˆ๋ช…์ด ๋น„์–ด์žˆ์œผ๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenNameIsBlank() { + assertThatThrownBy(() -> new Product(1L, " ", 1000L, 5)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("๊ฐ€๊ฒฉ์ด ์Œ์ˆ˜์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenPriceIsNegative() { + assertThatThrownBy(() -> new Product(1L, "์ƒํ’ˆ", -1L, 5)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("์žฌ๊ณ ๊ฐ€ ์Œ์ˆ˜์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenStockQuantityIsNegative() { + assertThatThrownBy(() -> new Product(1L, "์ƒํ’ˆ", 1000L, -1)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + } + + @DisplayName("์žฌ๊ณ ๋ฅผ ์ฐจ๊ฐํ•  ๋•Œ") + @Nested + class DecreaseStock { + + @DisplayName("์žฌ๊ณ ๊ฐ€ ์ถฉ๋ถ„ํ•˜๋ฉด ์ •์ƒ ์ฐจ๊ฐ๋œ๋‹ค") + @Test + void success() { + Product product = new Product(1L, "์ƒํ’ˆ", 10_000L, 10); + + product.decreaseStock(3); + + assertThat(product.getStockQuantity()).isEqualTo(7); + } + + @DisplayName("์žฌ๊ณ ์™€ ๋™์ผํ•œ ์ˆ˜๋Ÿ‰์„ ์ฐจ๊ฐํ•˜๋ฉด 0์ด ๋œ๋‹ค") + @Test + void successWhenExactStock() { + Product product = new Product(1L, "์ƒํ’ˆ", 10_000L, 5); + + product.decreaseStock(5); + + assertThat(product.getStockQuantity()).isEqualTo(0); + } + + @DisplayName("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•˜๋ฉด INSUFFICIENT_STOCK ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenInsufficientStock() { + Product product = new Product(1L, "์ƒํ’ˆ", 10_000L, 5); + + assertThatThrownBy(() -> product.decreaseStock(10)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.INSUFFICIENT_STOCK); + } + + @DisplayName("์ฐจ๊ฐ ์ˆ˜๋Ÿ‰์ด 0์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenQuantityIsZero() { + Product product = new Product(1L, "์ƒํ’ˆ", 10_000L, 10); + + assertThatThrownBy(() -> product.decreaseStock(0)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("์ฐจ๊ฐ ์ˆ˜๋Ÿ‰์ด ์Œ์ˆ˜์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenQuantityIsNegative() { + Product product = new Product(1L, "์ƒํ’ˆ", 10_000L, 10); + + assertThatThrownBy(() -> product.decreaseStock(-1)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8a..dc167f2e7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -42,6 +42,7 @@ subprojects { dependencyManagement { imports { mavenBom("org.springframework.cloud:spring-cloud-dependencies:${project.properties["springCloudDependenciesVersion"]}") + mavenBom("org.testcontainers:testcontainers-bom:${project.properties["testcontainersVersion"]}") } } diff --git a/gradle.properties b/gradle.properties index 142d7120f..5ae37ac99 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,6 +10,7 @@ springBootVersion=3.4.4 springDependencyManagementVersion=1.1.7 springCloudDependenciesVersion=2024.0.1 ### Library versions ### +testcontainersVersion=2.0.2 springDocOpenApiVersion=2.7.0 springMockkVersion=4.0.2 mockitoVersion=5.14.0