diff --git a/.gitignore b/.gitignore index c2065bc..54b6590 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,16 @@ out/ ### VS Code ### .vscode/ + +### db ### +*.mv.db +*.trace.db +*.sql + +### env ### +/backend/.env.properties +# env files (all environments) +.env +backend/.env.properties +*.env +.env.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b90498 --- /dev/null +++ b/README.md @@ -0,0 +1,308 @@ +# ๐Ÿงถ Knitly - Online Knitting Pattern & Community Platform + +> ์ทจ๋ฏธ๋กœ ๋œจ๊ฐœ์งˆ์„ ์ฆ๊ธฐ๋Š” ์‚ฌ๋žŒ๋“ค์ด ์ž์œ ๋กญ๊ฒŒ ๋„์•ˆ์„ ์ œ์ž‘ํ•˜๊ณ  ํŒ๋งคํ•˜๋ฉฐ, +> ์„œ๋กœ์˜ ์ฐฝ์ž‘๋ฌผ์„ ๊ณต์œ ํ•  ์ˆ˜ ์žˆ๋Š” ์ปค๋ฎค๋‹ˆํ‹ฐ ๊ธฐ๋ฐ˜ ํ”Œ๋žซํผ์ž…๋‹ˆ๋‹ค. +> ๋‹จ์ˆœํ•œ ๋„์•ˆ ํŒ๋งค๋ฅผ ๋„˜์–ด, ์ œ์ž‘์ž์™€ ์†Œ๋น„์ž๊ฐ€ ํ•จ๊ป˜ ์„ฑ์žฅํ•˜๋Š” ์ฐฝ์ž‘ ์ƒํƒœ๊ณ„๋ฅผ ์ง€ํ–ฅํ•ฉ๋‹ˆ๋‹ค. +--- +## โœจ ํ”„๋กœ์ ํŠธ ๊ฐœ์š” + +* **ํ”„๋กœ์ ํŠธ๋ช…:** Knitly +* **์ฃผ์ œ:** ๋œจ๊ฐœ์งˆ ๋„์•ˆ ํŒ๋งค ๋ฐ ์ปค๋ฎค๋‹ˆํ‹ฐ ์„œ๋น„์Šค +* **์ฃผ์ œ ์„ ์ • ์ด์œ :** + โ€œ์ทจ๋ฏธ๋กœ ๋œจ๊ฐœ์งˆ์„ ํ•˜๋Š” ์‚ฌ๋žŒ๋“ค์ด ์ž์œ ๋กญ๊ฒŒ ๋„์•ˆ์„ ์ œ์ž‘ํ•˜๊ณ  ๊ณต์œ ํ•  ์ˆ˜ ์žˆ๋Š” ํ”Œ๋žซํผ์ด ์žˆ์œผ๋ฉด ์ข‹๊ฒ ๋‹ค๋Š” ์•„์ด๋””์–ด์—์„œ ์ถœ๋ฐœํ•˜์˜€์Šต๋‹ˆ๋‹ค.โ€ +* **๊ฐœ๋ฐœ ๋ชฉํ‘œ:** + + * ์บ์‹ฑ๊ณผ ๋ฝ์„ ํ†ตํ•œ **์„ฑ๋Šฅ ํ–ฅ์ƒ ๋ฐ ๋ฐ์ดํ„ฐ ์ผ๊ด€์„ฑ ํ™•๋ณด** + * ์„œ๋น„์Šค ๊ฐ„ **๋ฐ์ดํ„ฐ ํ๋ฆ„ ๋ฐ ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ ๊ตฌํ˜„** + * **Redis ๊ธฐ๋ฐ˜**์˜ ์ธ๊ธฐ์ˆœ ์กฐํšŒ, ๋™์‹œ์„ฑ ์ œ์–ด, ์ฐœ ๊ธฐ๋Šฅ ๊ด€๋ฆฌ + * **์™ธ๋ถ€ API ์—ฐ๋™** (ํ† ์ŠคํŽ˜์ด๋จผ์ธ , OAuth 2.0, PDF ๋ณ€ํ™˜ ๋“ฑ) + * **์˜ˆ์™ธ ์ฒ˜๋ฆฌ ๊ฐ•ํ™” ๋ฐ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ๊ฒ€์ฆ** + * ์ „์ฒด ์„œ๋น„์Šค์˜ **์•ˆ์ •์„ฑ ๋ฐ ์œ ์ง€๋ณด์ˆ˜์„ฑ ํ™•๋ณด** + +--- + +## ๐Ÿ’ป ๊ธฐ์ˆ ์  ํŠน์ง• + +| ๋ถ„๋ฅ˜ | ๊ธฐ์ˆ  ์Šคํƒ | +| ------------------ | ------------------------------------------------------- | +| **Backend** | Spring Boot 3.5.x, Java 17, JPA(Hibernate), MySQL | +| **Infra** | AWS EC2, Docker, Redis, Nginx | +| **API** | OAuth 2.0 (Google Login), Toss Payments API, Swagger UI | +| **DevOps** | GitHub Actions (CI/CD), Docker Compose | +| **Test & Monitor** | JUnit5, MockMvc, Prometheus, Jmeter, NGrinder | +| **Tooling** | IntelliJ, Postman, DBeaver, Slack, Notion | + +แ„‹แ…กแ„แ…ตแ„แ…ฆแ†จแ„Žแ…ฅ drawio + +--- + +## ๐Ÿงฉ ์ฃผ์š” ๊ธฐ๋Šฅ ์š”์•ฝ + +| ๊ตฌ๋ถ„ | ๊ธฐ๋Šฅ | +| -------------- | -------------------------------------------- | +| **ํšŒ์›๊ฐ€์ž… / ๋กœ๊ทธ์ธ** | ๊ตฌ๊ธ€ OAuth 2.0 ๋กœ๊ทธ์ธ, JWT ๊ธฐ๋ฐ˜ ์ธ์ฆ/์ธ๊ฐ€ | +| **๋„์•ˆ ์ œ์ž‘** | 10x10 ๊ฒฉ์ž(Grid) ๊ธฐ๋ฐ˜ UI์—์„œ ๋„์•ˆ ์ œ์ž‘ โ†’ PDF ๋ณ€ํ™˜ ์ €์žฅ | +| **์ƒํ’ˆ ํŒ๋งค** | ์ œ์ž‘ํ•œ ๋„์•ˆ ๋˜๋Š” PDF ์—…๋กœ๋“œ / ๋ฌด๋ฃŒยทํ•œ์ • ํŒ๋งค ์ง€์› | +| **์ƒํ’ˆ ๊ตฌ๋งค** | Queue ๊ธฐ๋ฐ˜ ์ด๋ฉ”์ผ ์ž๋™ ๋ฐœ์†ก / Redis ๋ฝ์œผ๋กœ ์žฌ๊ณ  ๊ด€๋ฆฌ | +| **์ƒํ’ˆ ์กฐํšŒ** | Redis ZSet์œผ๋กœ ์ธ๊ธฐ์ˆœ ์ •๋ ฌ / ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ยท๊ฐ€๊ฒฉ์ˆœยท์ตœ์‹ ์ˆœ | +| **์ฐœ ๋ฐ ๋ฆฌ๋ทฐ** | Redis ์ฐœ ์นด์šดํŠธ / Rabbit Queue ๊ธฐ๋ฐ˜ DB ๋™๊ธฐํ™” / ๋ฆฌ๋ทฐ ์ž‘์„ฑ | +| **์ปค๋ฎค๋‹ˆํ‹ฐ** | ๊ฒŒ์‹œ๊ธ€ ๋ฐ ๋Œ“๊ธ€ CRUD / ์†Œํ”„ํŠธ ๋”œ๋ฆฌํŠธ ๊ธฐ๋ฐ˜ ๊ด€๋ฆฌ | +| **๋งˆ์ดํŽ˜์ด์ง€** | ๊ตฌ๋งค๋‚ด์—ญ, ์ฐœ๋ชฉ๋ก, ์ด๋ฒคํŠธ ์ฐธ์—ฌ๋‚ด์—ญ, ๊ตฌ๋… ๊ด€๋ฆฌ | +| **๊ฒฐ์ œ** | Toss Payments API ์—ฐ๋™ / ๋ชจ์˜๊ฒฐ์ œ ์ง€์› | +| **์ธ์ฆ/์ธ๊ฐ€** | JWT ๊ธฐ๋ฐ˜ ์ธ์ฆ ํ•„ํ„ฐ / ์กฐํšŒ ์™ธ ๋ชจ๋“  API ํ† ํฐ ๊ฒ€์ฆ | +--- + +# ํŒ€์› +|[๊น€์˜ˆ์ง„](https://github.com/dpwls8984)|[๊น€์‹œํ˜„](https://github.com/SiHejt)|[๋‚˜์›…์ฒ ](https://github.com/No-366)|[๋ถ€์ข…์šฐ](https://github.com/Boojw)|[์ •ํ˜œ์—ฐ](https://github.com/hznnoy)| +|:-:|:-:|:-:|:-:|:-:| +|image|image|image|150|image| +|BE, FE|BE, FE|BE, FE|BE, FE|BE, FE| + + + +## ๐Ÿ› ๏ธ ์—ญํ•  ๋ถ„๋‹ด + +| ์ด๋ฆ„ | ๋‹ด๋‹น ๊ธฐ๋Šฅ | +|--------|-----------| +| **๊น€์˜ˆ์ง„** | - ์ƒํ’ˆ ๊ตฌ๋งค(Redis, ๋ถ„์‚ฐ ๋ฝ)
- ์ƒํ’ˆ ํŒ๋งค | +| **๊น€์‹œํ˜„** | - ์ƒํ’ˆ ๋ฆฌ๋ทฐ
- ์ƒํ’ˆ ์ฐœ(Redis & RabbitMQ) | +| **๋‚˜์›…์ฒ ** | - Google ์†Œ์…œ ๋กœ๊ทธ์ธ
- JWT ์ธ์ฆ
- ํŒ๋งค์ž ํŽ˜์ด์ง€ | +| **๋ถ€์ข…์šฐ** | - ์ปค๋ฎค๋‹ˆํ‹ฐ ๊ธ€
- ์ปค๋ฎค๋‹ˆํ‹ฐ ๋Œ“๊ธ€
- ๋งˆ์ดํŽ˜์ด์ง€ ์กฐํšŒ(์ž‘์„ฑ ๊ธ€/๋Œ“๊ธ€) | +| **์ •ํ˜œ์—ฐ** | - ๋„์•ˆ ์ƒ์„ฑ + PDF ์ €์žฅ
- ๋„์•ˆ ์กฐํšŒ
- ์ƒํ’ˆ ์กฐํšŒ(Redis ZSet)
- ์ƒํ’ˆ ๊ฒฐ์ œ(ํ† ์Šค ํŽ˜์ด๋จผ์ธ  ์—ฐ๋™)| +--- + +# ๐Ÿงฉ ๊ธฐ๋Šฅ ์ •์˜์„œ (Feature Definition) + +## ๐Ÿง‘โ€๐Ÿ’ป ํšŒ์›๊ฐ€์ž… / ๋กœ๊ทธ์ธ + +- **์†Œ์…œ ๋กœ๊ทธ์ธ ์ง€์›** + - ๊ตฌ๊ธ€ OAuth 2.0 ๊ธฐ๋ฐ˜ ๋กœ๊ทธ์ธ + - OAuth ์ธ์ฆ ์™„๋ฃŒ ์‹œ ํ”„๋กœ์ ํŠธ ๋‚ด๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ +- **ํšŒ์› ์ •๋ณด ์ˆ˜์ •** + - ๋‹‰๋„ค์ž„ / ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์ˆ˜์ • ๊ธฐ๋Šฅ โŒ (๋น„ํ™œ์„ฑ) +- **ํšŒ์› ํƒˆํ‡ด / ์žฌ๊ฐ€์ž…** + - ํšŒ์›์ •๋ณด ํ…Œ์ด๋ธ”์— `status` ์นผ๋Ÿผ ์ถ”๊ฐ€ํ•˜์—ฌ ์ƒํƒœ ๊ด€๋ฆฌ + - ํƒˆํ‡ด ์‹œ ์†Œ์…œ ๊ณ„์ • ์—ฐ๊ฒฐ ๋Š๊ธฐ ๋กœ์ง ํฌํ•จ + +--- + +## ๐ŸŽจ ๋„์•ˆ ์ œ์ž‘ + +- 10X10 ๊ฒฉ์ž์— ๊ธฐํ˜ธ๋ฅผ ์‚ฝ์ž…ํ•ด ๋„์•ˆ์„ ๋งŒ๋“ค๊ณ  pdf ํŒŒ์ผ๋กœ ์ €์žฅ +- **ํ”„๋ก ํŠธ์—”๋“œ** + - ๊ฒฉ์ž(Grid) UI ๊ธฐ๋ฐ˜ ๋„์•ˆ ์ œ์ž‘ ํ™”๋ฉด + - ์‚ฌ์šฉ์ž์˜ ์ž…๋ ฅ์„ `gridData(JSON)` ํ˜•ํƒœ๋กœ ๋ฐฑ์—”๋“œ์— ์ „์†ก +- **๋ฐฑ์—”๋“œ** + - ์ˆ˜์‹ ํ•œ `gridData`๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ PDF ํŒŒ์ผ ์ƒ์„ฑ ๋ฐ ์ €์žฅ + +--- + +## ๐Ÿ›๏ธ ์ƒํ’ˆ ํŒ๋งค + +- **ํŒ๋งค ๋Œ€์ƒ** + - ์‚ฌ์šฉ์ž๊ฐ€ ์ง์ ‘ ์ œ์ž‘ํ•œ ๋„์•ˆ ๋˜๋Š” ์ผ๋ฐ˜ PDF ์—…๋กœ๋“œ ๊ฐ€๋Šฅ +- **์ƒํ’ˆ ํ˜•ํƒœ** + - ์œ ๋ฃŒ/๋ฌด๋ฃŒ ๋„์•ˆ + ์ƒ์‹œ/ํ•œ์ • ์ˆ˜๋Ÿ‰ ์„ ํƒํ•ด ๋„์•ˆ ์ƒํ’ˆ ํŒ๋งค ์ง€์› +- **์นดํ…Œ๊ณ ๋ฆฌ ๋ถ„๋ฅ˜** + - ์ƒ์˜ / ํ•˜์˜ / ์•„์šฐํ„ฐ / ๊ฐ€๋ฐฉ / ๊ธฐํƒ€ / ๋ฌด๋ฃŒ / ํ•œ์ • + +--- + +## ๐Ÿ’ธ ์ƒํ’ˆ ๊ตฌ๋งค + +- **๊ตฌ๋งค ํ”„๋กœ์„ธ์Šค** + - ์‚ฌ์šฉ์ž๋Š” ๋„์•ˆ์„ ๊ตฌ๋งคํ•˜๋ฉด ์ด๋ฉ”์ผ๋กœ PDF ์ž๋™ ๋ฐœ์†ก + - ์ด๋ฉ”์ผ์€ ์†Œ์…œ ๋กœ๊ทธ์ธ ๊ณ„์ •์œผ๋กœ ๋ฐœ์†ก๋จ +- **๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ** + - Kafka ๋Œ€์‹  **Queue ๊ตฌ์กฐ** ์‚ฌ์šฉ + - ํ์— `(์‚ฌ์šฉ์ž ๊ณ„์ •, ๋„์•ˆ PDF)` ์ •๋ณด ์ €์žฅ ํ›„ ์ž๋™ ์ด๋ฉ”์ผ ์ „์†ก +- **์žฌ๊ณ  ๊ด€๋ฆฌ** + - ํ•œ์ • ์ˆ˜๋Ÿ‰ ๋„์•ˆ์€ **Redis Lock + Queue** ๊ธฐ๋ฐ˜ ๋™์‹œ์„ฑ ์ œ์–ด + +--- + +## ๐Ÿ” ์ƒํ’ˆ ์กฐํšŒ + +- **์ •๋ ฌ ๊ธฐ์ค€** + - ์ธ๊ธฐ์ˆœ (Redis ZSet์œผ๋กœ ๊ตฌ๋งค์ˆ˜ ๊ธฐ๋ฐ˜ ์ •๋ ฌ) + - ์ตœ์‹ ์ˆœ, ๊ฐ€๊ฒฉ์ˆœ ์ •๋ ฌ ์ง€์› + - ๋ฉ”์ธํŽ˜์ด์ง€์—์„œ ์ธ๊ธฐ top5 ์ƒํ’ˆ ์กฐํšŒ +- **ํŽ˜์ด์ง• ์ฒ˜๋ฆฌ** + - Spring ์„œ๋ฒ„์‚ฌ์ด๋“œ ๋ Œ๋”๋ง ๊ธฐ๋ฐ˜ `Paging` ์ฒ˜๋ฆฌ +- **ํ•„ํ„ฐ๋ง** + - ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ / ๋ฌด๋ฃŒ / ํ•œ์ • ๋„์•ˆ ์กฐํšŒ ๊ฐ€๋Šฅ + +--- + +## ๐ŸคŽย **์ƒํ’ˆ ์ฐœ & ๋ฆฌ๋ทฐ** + +- Redis๋ฅผ ์ด์šฉํ•œ ์‹ค์‹œ๊ฐ„ ์ฐœ ์นด์šดํŠธ +- Rabbit ํ๋ฅผ ํ™œ์šฉํ•˜์—ฌ 10๋ถ„๋งˆ๋‹ค DB์— ๋™๊ธฐํ™” +- ์ƒํ’ˆ๋ณ„ ๋ฆฌ๋ทฐ ์ž‘์„ฑ ๋ฐ ํ™•์ธ ๊ฐ€๋Šฅ + +--- + +## ๐Ÿ’ฌ ์ปค๋ฎค๋‹ˆํ‹ฐ + +- **๊ฒŒ์‹œํŒ** + - ๋‹จ์ผ ๊ฒŒ์‹œํŒ ๊ตฌ์กฐ +- **๊ฒŒ์‹œ๊ธ€ ๊ธฐ๋Šฅ** + - ๊ธ€ ๋“ฑ๋ก / ์ˆ˜์ • / ์‚ญ์ œ +- **๋Œ“๊ธ€ ๊ธฐ๋Šฅ** + - ๋Œ“๊ธ€ ๋“ฑ๋ก / ์‚ญ์ œ + +--- + +## ๐Ÿ™‹ ๋งˆ์ดํŽ˜์ด์ง€ + +- ์ฐœ, ๊ตฌ๋งค๋‚ด์—ญ, ์ด๋ฒคํŠธ ์ฐธ์—ฌ๋‚ด์—ญ, ๊ตฌ๋… ๊ด€๋ฆฌ +- ๋งˆ์ดํŽ˜์ด์ง€ ์ž‘์„ฑ๊ธ€/๋Œ“๊ธ€ ํ™•์ธ + +--- + +## ๐Ÿงต ํŒ๋งค์ž ๊ฐœ์ธ ํŽ˜์ด์ง€ + +- **ํŒ๋งค์ž ์ „์šฉ ํŽ˜์ด์ง€** + - ํŒ๋งค์ค‘์ธ ๋„์•ˆ ๋ชฉ๋ก ๋ฐ ํ™˜์˜ ๋ฌธ๊ตฌ ํ‘œ์‹œ +- **๊ตฌ๋… ์—ฐ๊ฒฐ** + - ํŒ๋งค์ž์™€ ๊ตฌ๋… ๊ธฐ๋Šฅ ์—ฐ๊ณ„ (ํŒ๋งค์ž๋ณ„ ๊ตฌ๋… ๊ด€๋ฆฌ) + +--- + +## ๐ŸŽ ์„ ์ฐฉ์ˆœ ์ด๋ฒคํŠธ (์„ ํƒ์‚ฌํ•ญ) + +- **์ด๋ฒคํŠธ ์ƒ์„ฑ** + - ์šด์˜์ง„์ด ์ง์ ‘ ์ƒ์„ฑ (ํ•œ์ •์ˆ˜๋Ÿ‰ / ๋ฌด๋ฃŒ ๋“ฑ) +- **์žฌ๊ณ  ๊ด€๋ฆฌ** + - ํ•œ์ •์ˆ˜๋Ÿ‰ ์ด๋ฒคํŠธ๋Š” Redis Lock ์‚ฌ์šฉ + - ํ•„์š” ์‹œ Queue ๋ณ‘ํ–‰ ์‚ฌ์šฉ + +--- + +## ๐Ÿ“ฌ ๊ตฌ๋… ๊ธฐ๋Šฅ (์„ ํƒ์‚ฌํ•ญ) + +- ํŒ๋งค์ž๋ฅผ ๊ตฌ๋…ํ•œ ์‚ฌ์šฉ์ž๋Š” ํ•œ ๋‹ฌ๊ฐ„ ํ•ด๋‹น ํŒ๋งค์ž์˜ ๋ชจ๋“  ๋„์•ˆ ๋‹ค์šด๋กœ๋“œ ๊ฐ€๋Šฅ + +--- + +## ๐Ÿ’ณ ๊ฒฐ์ œ ๊ธฐ๋Šฅ + +- ํ† ์Šค ํŽ˜์ด๋จผ์ธ  API์™€ ์—ฐ๋™ํ•˜์—ฌ ๊ฒฐ์ œ ๊ฐ€๋Šฅ + +--- + +## ๐Ÿ” ์ธ์ฆ / ์ธ๊ฐ€ + +- **JWT ๊ธฐ๋ฐ˜ ์ธ์ฆ ์ ์šฉ** + - ์กฐํšŒ(READ) API๋ฅผ ์ œ์™ธํ•œ ๋ชจ๋“  Controller์— ํ† ํฐ ๊ฒ€์ฆ ๋กœ์ง ํ•„์ˆ˜ + +--- + +โœ… **์ •๋ฆฌ ์š”์•ฝ** + +- ๊ธฐ์ˆ  ํ‚ค์›Œ๋“œ: `Spring Boot`, `Redis`, `JWT`, `Queue`, `H2`, `MySQL`, `Swagger`, `OAuth 2.0`, `Docker` +- ์ฃผ์š” ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ: **Queue (Kafka ๋Œ€์ฒด)** +- ์ฃผ์š” ๋™์‹œ์„ฑ ์ œ์–ด: **Redis Lock (Lettuce ๊ธฐ๋ฐ˜)** +- ๋ฐ์ดํ„ฐ ์ „๋‹ฌ ํ˜•์‹: **JSON (gridData, API ์‘๋‹ต)** + +```mermaid +flowchart LR + A[๋น„ํšŒ์›] + B1[์†Œ์…œ ๋กœ๊ทธ์ธ - ๊ตฌ๊ธ€ OAuth 2.0] + B2[ํšŒ์›๊ฐ€์ž…] + B3[ํƒˆํ‡ด ๋ฐ ์žฌ๊ฐ€์ž…] + + A --> B1 + B1 --> B2 + B1 --> B3 + + D[๋„์•ˆ ์ œ์ž‘] + D3[PDF ํŒŒ์ผ ์ƒ์„ฑยท์ €์žฅ] + + B2 --> D + D --> D3 + + E[์ƒํ’ˆ] + E1[์ƒํ’ˆ ํŒ๋งค - ์œ ๋ฃŒยท๋ฌด๋ฃŒ / ์ƒ์‹œยทํ•œ์ •] + E2[์ƒํ’ˆ ๊ตฌ๋งค - ์ด๋ฉ”์ผ ๋ฐœ์†ก / ์žฌ๊ณ ๊ด€๋ฆฌ] + E3[์ƒํ’ˆ ์กฐํšŒ - ์ •๋ ฌ] + E4[์ƒํ’ˆ ์ฐœยท๋ฆฌ๋ทฐ] + + B2 --> E + E --> E1 + E --> E2 + E --> E3 + E --> E4 + + F[์ปค๋ฎค๋‹ˆํ‹ฐ] + F1[๊ธ€ ์ž‘์„ฑ/๋Œ“๊ธ€ ์ž‘์„ฑ] + + B2 --> F + F --> F1 + + G[๋งˆ์ดํŽ˜์ด์ง€] + G1[๊ตฌ๋งค๋‚ด์—ญ ํ™•์ธ] + G2[๋ฆฌ๋ทฐ/์ฐœ ํ™•์ธ] + G3[ํŒ๋งค์ž ์Šคํ† ์–ด] + G4[๋‚ด ๋„์•ˆ ๋ชฉ๋ก] + + B2 --> G + G --> G1 + G --> G2 + G --> G3 + G --> G4 + + K[๊ฒฐ์ œ ๊ธฐ๋Šฅ] + K1[ํ† ์Šค ํŽ˜์ด๋จผ์ธ  API ์—ฐ๋™] + K2[๊ฒฐ์ œ ์™„๋ฃŒ ํ›„ ์ด๋ฉ”์ผ ๋ฐœ์†ก] + + B2 --> K + K --> K1 + K1 --> K2 +``` +--- + +## ๐Ÿ“ƒ ์ปค๋ฐ‹ ์ปจ๋ฒค์…˜ & ํ˜‘์—… ๊ทœ์น™ +### GitHub Flow(main/feature + develop) +> ์ด์Šˆ ์ƒ์„ฑ โ†’ ๋ธŒ๋žœ์น˜ ์ƒ์„ฑ โ†’ ๊ตฌํ˜„ โ†’ Commit & Push โ†’ PR ์ƒ์„ฑ โ†’ ์ฝ”๋“œ ๋ฆฌ๋ทฐ โ†’ develop์— Merge + +- `main`: ๋ฐฐํฌ์šฉ ์•ˆ์ • ๋ธŒ๋žœ์น˜ +- `dev`: ๊ธฐ๋Šฅ ํ†ตํ•ฉ ๋ธŒ๋žœ์น˜ +- `feature/{domain}`: ๊ธฐ๋Šฅ ๋‹จ์œ„ ์ž‘์—… ๋ธŒ๋žœ์น˜ +- `hotfix`: ์˜ค๋ฅ˜ ํ•ด๊ฒฐ ๋ธŒ๋žœ์น˜ +- `publishing`: AWS ๋ฐฐํฌ์šฉ ๋ธŒ๋žœ์น˜ + +### ์ปค๋ฐ‹ ์ปจ๋ฒค์…˜ + +|์œ ํ˜• | ์„ค๋ช…| +|---|---| +|init|์ดˆ๊ธฐ์„ค์ •| +|feat| ์ƒˆ๋กœ์šด ๊ธฐ๋Šฅ| +|fix| ๋ฒ„๊ทธ ์ˆ˜์ •| +|docs|๋ฌธ์„œ ๋ณ€๊ฒฝ(README ๋“ฑ)| +|style| ํฌ๋งท/์Šคํƒ€์ผ(๊ธฐ๋Šฅ ๋ณ€๊ฒฝ ์—†์Œ)| +|refactor| ๋ฆฌํŒฉํ† ๋ง(๋™์ž‘ ๋ณ€๊ฒฝ ์—†์Œ)| +|test| ํ…Œ์ŠคํŠธ| +|chore| ๋นŒ๋“œ/์„ค์ •/์˜์กด์„ฑ| +|remove| ํŒŒ์ผ/ํด๋” ์‚ญ์ œ| +|rename| ํŒŒ์ผ/ํด๋”๋ช… ๋ณ€๊ฒฝ| + +### ์ปค๋ฐ‹ ๊ณ ์œ  ๋ฒˆํ˜ธ +- ์†Œ์…œ๋กœ๊ทธ์ธ 100 +- ์ปค๋ฎค๋‹ˆํ‹ฐ 200 +- ์ƒํ’ˆ 300 + - ์ฃผ๋ฌธ 301 + - ํŒ๋งค 302 + - ๋ฆฌ๋ทฐ 303 + - ์ฐœ ๋“ฑ๋ก/์ทจ์†Œ 304 + - ๋ชฉ๋ก ์กฐํšŒ 305 + - ๊ฒฐ์ œ 306 +- ๋งˆ์ดํŽ˜์ด์ง€ 400 + - ํŒ๋งค์ž ํŽ˜์ด์ง€ 401 + - ์กฐํšŒ 402 + - ์ฐœ ์กฐํšŒ 403 +- ๋„์•ˆ 500 + - ์ƒ์„ฑ 501 + - ์กฐํšŒ / ์‚ญ์ œ 502 +- ์ด๋ฒคํŠธ 600 + diff --git a/build.gradle.kts b/backend/build.gradle.kts similarity index 52% rename from build.gradle.kts rename to backend/build.gradle.kts index 979e961..f60ccd7 100644 --- a/build.gradle.kts +++ b/backend/build.gradle.kts @@ -26,9 +26,13 @@ repositories { dependencies { implementation("org.springframework.boot:spring-boot-starter-data-redis") + implementation ("org.redisson:redisson-spring-boot-starter:3.27.2") // Redisson ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-oauth2-client") + implementation("org.springframework.boot:spring-boot-starter-amqp") compileOnly("org.projectlombok:lombok") developmentOnly("org.springframework.boot:spring-boot-devtools") runtimeOnly("com.h2database:h2") @@ -37,6 +41,24 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.security:spring-security-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + implementation("org.apache.pdfbox:pdfbox:2.0.29") // PDF ๋ณ€ํ™˜ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ + implementation("commons-codec:commons-codec:1.16.0") + implementation("org.springframework.boot:spring-boot-starter-mail") // Spring Email + + // JWT ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ (ํ•„์ˆ˜) + implementation("io.jsonwebtoken:jjwt-api:0.12.5") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.5") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.5") + + implementation("org.apache.pdfbox:pdfbox:2.0.29") // PDF ๋ณ€ํ™˜ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ + implementation("commons-codec:commons-codec:1.16.0") + + // Swagger/OpenAPI + // SpringDoc OpenAPI (Swagger 3) - WebMVC ๋ฐ Swagger UI ํฌํ•จ + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.11") + + implementation("com.fasterxml.jackson.core:jackson-databind") + } tasks.withType { diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..cce1629 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,37 @@ +services: + mysql: + image: mysql:8.0 + container_name: knitly_db-mysql + environment: + MYSQL_ROOT_PASSWORD: ${DB_PW} + MYSQL_DATABASE: knitly_db + ports: + - "3306:3306" + command: --default-authentication-plugin=mysql_native_password + volumes: + - mysql_data:/var/lib/mysql + + redis: + image: redis:latest + container_name: knitly_redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + + rabbitmq: + image: rabbitmq:3-management + container_name: knitly_rabbitmq + ports: + - "5672:5672" + - "15672:15672" + environment: + RABBITMQ_DEFAULT_USER: guest + RABBITMQ_DEFAULT_PASS: guest + volumes: + - rabbitmq_data:/var/lib/rabbitmq + +volumes: + mysql_data: + redis_data: + rabbitmq_data: diff --git a/gradle/wrapper/gradle-wrapper.jar b/backend/gradle/wrapper/gradle-wrapper.jar similarity index 100% rename from gradle/wrapper/gradle-wrapper.jar rename to backend/gradle/wrapper/gradle-wrapper.jar diff --git a/gradle/wrapper/gradle-wrapper.properties b/backend/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from gradle/wrapper/gradle-wrapper.properties rename to backend/gradle/wrapper/gradle-wrapper.properties diff --git a/gradlew b/backend/gradlew similarity index 100% rename from gradlew rename to backend/gradlew diff --git a/gradlew.bat b/backend/gradlew.bat similarity index 100% rename from gradlew.bat rename to backend/gradlew.bat diff --git a/settings.gradle.kts b/backend/settings.gradle.kts similarity index 100% rename from settings.gradle.kts rename to backend/settings.gradle.kts diff --git a/backend/src/main/java/com/mysite/knitly/KnitlyApplication.java b/backend/src/main/java/com/mysite/knitly/KnitlyApplication.java new file mode 100644 index 0000000..1336e86 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/KnitlyApplication.java @@ -0,0 +1,20 @@ +package com.mysite.knitly; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@SpringBootApplication +@EnableJpaAuditing +// @ConfigurationPropertiesScan ์—ญํ•  : @ConfigurationProperties๊ฐ€ ๋ถ™์€ ๋ชจ๋“  ํด๋ž˜์Šค๋ฅผ ์ž๋™์œผ๋กœ ์Šค์บ”ํ•˜์—ฌ +// Spring IoC ์ปจํ…Œ์ด๋„ˆ์˜ ๋นˆ(Bean)์œผ๋กœ ๋“ฑ๋กํ•˜๊ณ , +// ์™ธ๋ถ€ ์„ค์ • ํŒŒ์ผ(properties ๋˜๋Š” YAML)์˜ ๊ฐ’๊ณผ ๋ฐ”์ธ๋”ฉํ•˜๋„๋ก ํ™œ์„ฑํ™” +@ConfigurationPropertiesScan +public class KnitlyApplication { + + public static void main(String[] args) { + SpringApplication.run(KnitlyApplication.class, args); + } + +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/comment/controller/CommentController.java b/backend/src/main/java/com/mysite/knitly/domain/community/comment/controller/CommentController.java new file mode 100644 index 0000000..0bcf58a --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/community/comment/controller/CommentController.java @@ -0,0 +1,85 @@ +package com.mysite.knitly.domain.community.comment.controller; + +import com.mysite.knitly.domain.community.comment.dto.CommentCreateRequest; +import com.mysite.knitly.domain.community.comment.dto.CommentResponse; +import com.mysite.knitly.domain.community.comment.dto.CommentTreeResponse; +import com.mysite.knitly.domain.community.comment.dto.CommentUpdateRequest; +import com.mysite.knitly.domain.community.comment.service.CommentService; +import com.mysite.knitly.domain.mypage.dto.PageResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import com.mysite.knitly.domain.user.entity.User; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; +import java.net.URI; + +@RestController +@RequestMapping("/community") +@RequiredArgsConstructor +public class CommentController { + + private final CommentService commentService; + + // ๋Œ“๊ธ€ ๋ชฉ๋ก + @GetMapping("/posts/{postId}/comments") + public ResponseEntity> getComments( + @PathVariable Long postId, + @RequestParam(defaultValue = "asc") String sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @AuthenticationPrincipal User user + ) { + Page result = commentService.getComments(postId, sort, page, size, user); + return ResponseEntity.ok(PageResponse.of(result)); + } + + // ๋Œ“๊ธ€ ๊ฐœ์ˆ˜ + @GetMapping("/posts/{postId}/comments/count") + public ResponseEntity count(@PathVariable Long postId) { + return ResponseEntity.ok(commentService.count(postId)); + } + + // ๋Œ“๊ธ€ ์ž‘์„ฑ (parentId ์žˆ์œผ๋ฉด ๋Œ€๋Œ“๊ธ€) + @PostMapping("/posts/{postId}/comments") + @PreAuthorize("isAuthenticated()") + public ResponseEntity create( + @PathVariable Long postId, + @AuthenticationPrincipal User user, + @Valid @RequestBody CommentCreateRequest request + ) { + if (!postId.equals(request.postId())) { + return ResponseEntity.badRequest().build(); + } + CommentResponse resp = commentService.create(request, user); + // ๋ฆฌ์†Œ์Šค ๊ตฌ์กฐ ์ผ๊ด€์„ฑ: /community/posts/{postId}/comments/{commentId} + return ResponseEntity + .created(URI.create(String.format("/community/posts/%d/comments/%d", postId, resp.id()))) + .body(resp); + } + + // ๋Œ“๊ธ€ ์ˆ˜์ • + @PatchMapping("/comments/{commentId}") + @PreAuthorize("isAuthenticated()") + public ResponseEntity update( + @PathVariable Long commentId, + @Valid @RequestBody CommentUpdateRequest request, + @AuthenticationPrincipal User user + ) { + commentService.update(commentId, request, user); + return ResponseEntity.noContent().build(); + } + + // ๋Œ“๊ธ€ ์‚ญ์ œ + @DeleteMapping("/comments/{commentId}") + @PreAuthorize("isAuthenticated()") + public ResponseEntity delete( + @PathVariable Long commentId, + @AuthenticationPrincipal User user + ) { + commentService.delete(commentId, user); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/comment/dto/CommentCreateRequest.java b/backend/src/main/java/com/mysite/knitly/domain/community/comment/dto/CommentCreateRequest.java new file mode 100644 index 0000000..628e300 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/community/comment/dto/CommentCreateRequest.java @@ -0,0 +1,19 @@ +package com.mysite.knitly.domain.community.comment.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.Positive; + +public record CommentCreateRequest( + + @NotNull(message = "๊ฒŒ์‹œ๊ธ€ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + Long postId, + + @Positive(message = "parentId๋Š” 1 ์ด์ƒ์˜ ๊ฐ’์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + Long parentId, + + @NotBlank(message = "๋Œ“๊ธ€ ๋‚ด์šฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Size(min = 1, max = 300, message = "๋Œ“๊ธ€์€ 1์ž ์ด์ƒ 300์ž ์ดํ•˜๋กœ ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.") + String content +) {} diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/comment/dto/CommentResponse.java b/backend/src/main/java/com/mysite/knitly/domain/community/comment/dto/CommentResponse.java new file mode 100644 index 0000000..2c97d25 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/community/comment/dto/CommentResponse.java @@ -0,0 +1,12 @@ +package com.mysite.knitly.domain.community.comment.dto; + +import java.time.LocalDateTime; + +public record CommentResponse( + Long id, + String content, + Long authorId, + String authorDisplay, + LocalDateTime createdAt, + boolean mine +) {} diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/comment/dto/CommentTreeResponse.java b/backend/src/main/java/com/mysite/knitly/domain/community/comment/dto/CommentTreeResponse.java new file mode 100644 index 0000000..bafcea1 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/community/comment/dto/CommentTreeResponse.java @@ -0,0 +1,16 @@ +package com.mysite.knitly.domain.community.comment.dto; + +import java.time.LocalDateTime; +import java.util.List; + + // ํŠธ๋ฆฌ ์‘๋‹ต ๊ตฌ์กฐ + public record CommentTreeResponse( + Long id, + String content, + Long authorId, + String authorDisplay, + LocalDateTime createdAt, + boolean mine, + Long parentId, + List children +) {} diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/comment/dto/CommentUpdateRequest.java b/backend/src/main/java/com/mysite/knitly/domain/community/comment/dto/CommentUpdateRequest.java new file mode 100644 index 0000000..d4bfe59 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/community/comment/dto/CommentUpdateRequest.java @@ -0,0 +1,11 @@ +package com.mysite.knitly.domain.community.comment.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CommentUpdateRequest( + + @NotBlank(message = "๋Œ“๊ธ€ ๋‚ด์šฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Size(min = 1, max = 300, message = "๋Œ“๊ธ€์€ 1์ž ์ด์ƒ 300์ž ์ดํ•˜๋กœ ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.") + String content +) {} diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/comment/entity/Comment.java b/backend/src/main/java/com/mysite/knitly/domain/community/comment/entity/Comment.java new file mode 100644 index 0000000..febf396 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/community/comment/entity/Comment.java @@ -0,0 +1,63 @@ +package com.mysite.knitly.domain.community.comment.entity; + +import com.mysite.knitly.domain.community.post.entity.Post; +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.global.jpa.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.Where; +import java.util.ArrayList; +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Entity +@Table(name = "comments") +@Where(clause = "is_deleted = false") +public class Comment extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "comment_id") + private Long id; + + @Lob + @Column(nullable = false) + private String content; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User author; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + // ๋Œ€๋Œ“๊ธ€, ์ž๊ธฐ์ฐธ์กฐ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private Comment parent; + + @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("createdAt ASC") + @Builder.Default + private List children = new ArrayList<>(); + + @Builder.Default + @Column(name = "is_deleted", nullable = false) + private boolean deleted = false; + + public void setPost(Post post) { this.post = post; } + public void setParent(Comment parent) { this.parent = parent; } + public boolean isRoot() { return this.parent == null; } + + public void softDelete() { this.deleted = true; } + + public void update(String newContent) { this.content = newContent; } + + public boolean isAuthor(User user) { + return user != null && author != null && author.getUserId().equals(user.getUserId()); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/comment/repository/CommentRepository.java b/backend/src/main/java/com/mysite/knitly/domain/community/comment/repository/CommentRepository.java new file mode 100644 index 0000000..393f1f8 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/community/comment/repository/CommentRepository.java @@ -0,0 +1,53 @@ +package com.mysite.knitly.domain.community.comment.repository; + +import com.mysite.knitly.domain.community.comment.entity.Comment; +import com.mysite.knitly.domain.community.post.entity.Post; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.*; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Collection; + + +public interface CommentRepository extends JpaRepository { + + List findByPost(Post post); + + // ์ •๋ ฌ๏ผˆ๋“ฑ๋ก์ˆœ,์ตœ์‹ ์ˆœ) + @EntityGraph(attributePaths = "author") + Page findByPostAndDeletedFalseOrderByCreatedAtAsc(Post post, Pageable pageable); + @EntityGraph(attributePaths = "author") + Page findByPostAndDeletedFalseOrderByCreatedAtDesc(Post post, Pageable pageable); + + // ๋Œ“๊ธ€ ์ˆ˜ + long countByPostIdAndDeletedFalse(Long postId); + + // ๋ฃจํŠธ ๋Œ“๊ธ€ + @EntityGraph(attributePaths = "author") + Page findByPostAndParentIsNullAndDeletedFalseOrderByCreatedAtAsc(Post post, Pageable pageable); + @EntityGraph(attributePaths = "author") + Page findByPostAndParentIsNullAndDeletedFalseOrderByCreatedAtDesc(Post post, Pageable pageable); + + // ์ž์‹ ๋Œ€๋Œ“๊ธ€ + @EntityGraph(attributePaths = "author") + List findByParentIdAndDeletedFalseOrderByCreatedAtAsc(Long parentId); + + // ์ž์‹ ๋Œ€๋Œ“๊ธ€ ๋ฐฐ์น˜ ์กฐํšŒ (N+1 ์ œ๊ฑฐ) + @EntityGraph(attributePaths = "author") + List findByParentIdInAndDeletedFalseOrderByCreatedAtAsc(Collection parentIds); + + // ๋งˆ์ดํŽ˜์ด์ง€ ๋Œ“๊ธ€ ์กฐํšŒ ์‹œ + Page findByAuthor_UserIdAndDeletedFalseAndContentContainingIgnoreCaseOrderByCreatedAtDesc( + Long userId, String content, Pageable pageable + ); + + @Query("SELECT c.author.userId " + + "FROM Comment c " + + "WHERE c.deleted = false AND c.post.id = :postId " + + "GROUP BY c.author.userId " + + "ORDER BY MIN(c.createdAt) ASC") + + List findAuthorOrderForPost(@Param("postId") Long postId); +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/comment/service/CommentService.java b/backend/src/main/java/com/mysite/knitly/domain/community/comment/service/CommentService.java new file mode 100644 index 0000000..8faac14 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/community/comment/service/CommentService.java @@ -0,0 +1,217 @@ +package com.mysite.knitly.domain.community.comment.service; + +import com.mysite.knitly.domain.community.comment.dto.CommentCreateRequest; +import com.mysite.knitly.domain.community.comment.dto.CommentResponse; +import com.mysite.knitly.domain.community.comment.dto.CommentTreeResponse; +import com.mysite.knitly.domain.community.comment.dto.CommentUpdateRequest; +import com.mysite.knitly.domain.community.comment.entity.Comment; +import com.mysite.knitly.domain.community.comment.repository.CommentRepository; +import com.mysite.knitly.domain.community.post.entity.Post; +import com.mysite.knitly.domain.community.post.repository.PostRepository; +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.global.exception.ErrorCode; +import com.mysite.knitly.global.exception.ServiceException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommentService { + + private final CommentRepository commentRepository; + private final PostRepository postRepository; + + // ๋Œ“๊ธ€ ๋ชฉ๋ก + public Page getComments(Long postId, String sort, int page, int size, User currentUser) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new ServiceException(ErrorCode.POST_NOT_FOUND)); + + Pageable pageable = PageRequest.of(page, size); + + Page roots = ("desc".equalsIgnoreCase(sort)) + ? commentRepository.findByPostAndParentIsNullAndDeletedFalseOrderByCreatedAtDesc(post, pageable) + : commentRepository.findByPostAndParentIsNullAndDeletedFalseOrderByCreatedAtAsc(post, pageable); + + Map authorNoMap = buildAuthorNoMap(postId); + // N+1 ์ œ๊ฑฐ + List parentIds = roots.getContent().stream() + .map(Comment::getId) + .toList(); + Map> childrenMap = parentIds.isEmpty() + ? Map.of() + : commentRepository.findByParentIdInAndDeletedFalseOrderByCreatedAtAsc(parentIds) + .stream() + .collect(Collectors.groupingBy(c -> c.getParent().getId())); + + return roots.map(root -> + toTreeResponseWithGroupedChildren(root, currentUser, authorNoMap, childrenMap) + ); + + } + + // ๋Œ“๊ธ€ ๊ฐœ์ˆ˜ + public long count(Long postId) { + return commentRepository.countByPostIdAndDeletedFalse(postId); + } + + // ๋Œ“๊ธ€ ์ž‘์„ฑ + @Transactional + public CommentResponse create(CommentCreateRequest req, User currentUser) { + if (currentUser == null) { + throw new ServiceException(ErrorCode.COMMENT_UNAUTHORIZED); + } + Post post = postRepository.findById(req.postId()) + .orElseThrow(() -> new ServiceException(ErrorCode.POST_NOT_FOUND)); + // ์ธ์ฆ ์‚ฌ์šฉ์ž ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ + User author = currentUser; + + // parentId๊ฐ€ ์žˆ์œผ๋ฉด ๋™์ผ ๊ฒŒ์‹œ๊ธ€ ์†Œ์†์ธ์ง€ ๊ฒ€์ฆ + Comment parent = null; + if (req.parentId() != null) { + parent = commentRepository.findById(req.parentId()) + .orElseThrow(() -> new ServiceException(ErrorCode.COMMENT_NOT_FOUND)); + if (!parent.getPost().getId().equals(req.postId())) { + throw new ServiceException(ErrorCode.BAD_REQUEST); + } + } + + // content trim & ๊ณต๋ฐฑ๋งŒ ์ž…๋ ฅ ๊ธˆ์ง€ + String trimmed = req.content() == null ? null : req.content().trim(); + if (trimmed == null || trimmed.isBlank()) { + throw new ServiceException(ErrorCode.BAD_REQUEST); + } + + Comment saved = commentRepository.save( + Comment.builder() + .post(post) + .author(author) + .content(trimmed) + .parent(parent) + .build() + ); + + Map authorNoMap = buildAuthorNoMap(req.postId()); + return toFlatResponse(saved, currentUser, authorNoMap); + } + + // ๋Œ“๊ธ€ ์ˆ˜์ • + @Transactional + public void update(Long commentId, CommentUpdateRequest req, User currentUser) { + if (currentUser == null) { + throw new ServiceException(ErrorCode.COMMENT_UNAUTHORIZED); + } + Comment c = commentRepository.findById(commentId) + .orElseThrow(() -> new ServiceException(ErrorCode.COMMENT_NOT_FOUND)); + + if (c.isDeleted()) { + throw new ServiceException(ErrorCode.COMMENT_ALREADY_DELETED); + } + if (!c.isAuthor(currentUser)) { + throw new ServiceException(ErrorCode.COMMENT_UPDATE_FORBIDDEN); + } + + // content trim & ๊ณต๋ฐฑ๋งŒ ์ž…๋ ฅ ๊ธˆ์ง€ + String trimmed = req.content() == null ? null : req.content().trim(); + if (trimmed == null || trimmed.isBlank()) { + throw new ServiceException(ErrorCode.BAD_REQUEST); + } + c.update(trimmed); + } + + // ๋Œ“๊ธ€ ์‚ญ์ œ + @Transactional + public void delete(Long commentId, User currentUser) { + if (currentUser == null) { + throw new ServiceException(ErrorCode.COMMENT_UNAUTHORIZED); + } + Comment c = commentRepository.findById(commentId) + .orElseThrow(() -> new ServiceException(ErrorCode.COMMENT_NOT_FOUND)); + + if (c.isDeleted()) { + throw new ServiceException(ErrorCode.COMMENT_ALREADY_DELETED); + } + if (!c.isAuthor(currentUser)) { + throw new ServiceException(ErrorCode.COMMENT_DELETE_FORBIDDEN); + } + c.softDelete(); + } + + // ์ž‘์„ฑ์ž ์ฒซ ๋Œ“๊ธ€ ์‹œ๊ฐ„ ๊ธฐ์ค€์œผ๋กœ + private Map buildAuthorNoMap(Long postId) { + List order = commentRepository.findAuthorOrderForPost(postId); + Map map = new HashMap<>(); + int n = 1; + for (Long uid : order) { + map.put(uid, n++); + } + return map; + } + + // create ์‘๋‹ต + private CommentResponse toFlatResponse(Comment c, User currentUser, Map authorNoMap) { + Long uid = (c.getAuthor() == null) ? null : c.getAuthor().getUserId(); + int no = (uid != null && authorNoMap.containsKey(uid)) ? authorNoMap.get(uid) : 0; + String display = (no > 0) ? "์ต๋ช…์˜ ํ„ธ์‹ค " + no : "์ต๋ช…์˜ ํ„ธ์‹ค"; + + boolean mine = c.isAuthor(currentUser); + + return new CommentResponse( + c.getId(), + c.getContent(), + uid, + display, + c.getCreatedAt(), + mine + ); + } + // ํŠธ๋ฆฌ ์‘๋‹ต ๋ณ€ํ™˜ + private CommentTreeResponse toTreeResponseWithGroupedChildren( + Comment root, + User currentUser, + Map authorNoMap, + Map> childrenMap + ) { + List children = childrenMap.getOrDefault(root.getId(), List.of()); + return new CommentTreeResponse( + root.getId(), + root.getContent(), + root.getAuthor() == null ? null : root.getAuthor().getUserId(), + displayName(root, authorNoMap), + root.getCreatedAt(), + isMine(root, currentUser), + root.getParent() == null ? null : root.getParent().getId(), + children.stream() + .map(ch -> new CommentTreeResponse( + ch.getId(), + ch.getContent(), + ch.getAuthor() == null ? null : ch.getAuthor().getUserId(), + displayName(ch, authorNoMap), + ch.getCreatedAt(), + isMine(ch, currentUser), + ch.getParent() == null ? null : ch.getParent().getId(), + List.of() + )) + .collect(Collectors.toList()) + ); + } + + private boolean isMine(Comment c, User currentUser) { + return c.isAuthor(currentUser); + } + + private String displayName(Comment c, Map authorNoMap) { + Long uid = (c.getAuthor() == null) ? null : c.getAuthor().getUserId(); + int no = (uid != null && authorNoMap.containsKey(uid)) ? authorNoMap.get(uid) : 0; + return (no > 0) ? "์ต๋ช…์˜ ํ„ธ์‹ค " + no : "์ต๋ช…์˜ ํ„ธ์‹ค"; + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/post/controller/PostController.java b/backend/src/main/java/com/mysite/knitly/domain/community/post/controller/PostController.java new file mode 100644 index 0000000..53fd7ee --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/community/post/controller/PostController.java @@ -0,0 +1,73 @@ +package com.mysite.knitly.domain.community.post.controller; + +import com.mysite.knitly.domain.community.post.dto.PostCreateRequest; +import com.mysite.knitly.domain.community.post.dto.PostListItemResponse; +import com.mysite.knitly.domain.community.post.dto.PostResponse; +import com.mysite.knitly.domain.community.post.dto.PostUpdateRequest; +import com.mysite.knitly.domain.community.post.entity.PostCategory; +import com.mysite.knitly.domain.community.post.service.PostService; +import com.mysite.knitly.domain.user.entity.User; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/community/posts") +@RequiredArgsConstructor +public class PostController { + + private final PostService postService; + + @GetMapping + public ResponseEntity> getPosts( + @RequestParam(required = false) PostCategory category, + @RequestParam(required = false) String query, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + return ResponseEntity.ok(postService.getPostList(category, query, page, size)); + } + + @GetMapping("/{postId}") + public ResponseEntity getPost( + @AuthenticationPrincipal User user, + @PathVariable("postId") Long postId + ) { + return ResponseEntity.ok(postService.getPost(postId, user)); + } + + @PostMapping + @org.springframework.security.access.prepost.PreAuthorize("isAuthenticated()") + public ResponseEntity create( + @Valid @RequestBody PostCreateRequest request + , @AuthenticationPrincipal User user + + ) { + PostResponse res = postService.create(request, user); + return ResponseEntity.status(HttpStatus.CREATED).body(res); + } + + @PutMapping("/{postId}") + @org.springframework.security.access.prepost.PreAuthorize("isAuthenticated()") + public ResponseEntity update( + @PathVariable("postId") Long postId, + @Valid @RequestBody PostUpdateRequest request, + @AuthenticationPrincipal User user + ) { + return ResponseEntity.ok(postService.update(postId, request, user)); + } + + @DeleteMapping("/{postId}") + @org.springframework.security.access.prepost.PreAuthorize("isAuthenticated()") + public ResponseEntity delete( + @PathVariable Long postId, + @AuthenticationPrincipal User user + ) { + postService.delete(postId, user); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostCreateRequest.java b/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostCreateRequest.java new file mode 100644 index 0000000..d64d83b --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostCreateRequest.java @@ -0,0 +1,21 @@ +package com.mysite.knitly.domain.community.post.dto; + +import com.mysite.knitly.domain.community.post.entity.PostCategory; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; + +public record PostCreateRequest( + + @NotNull(message = "์นดํ…Œ๊ณ ๋ฆฌ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + PostCategory category, + + @NotBlank(message = "์ œ๋ชฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + String title, + + @NotBlank(message = "๋‚ด์šฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + String content, + + List imageUrls +) {} diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostListItemResponse.java b/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostListItemResponse.java new file mode 100644 index 0000000..8a00e86 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostListItemResponse.java @@ -0,0 +1,16 @@ +package com.mysite.knitly.domain.community.post.dto; + +import com.mysite.knitly.domain.community.post.entity.PostCategory; +import java.time.LocalDateTime; + +public record PostListItemResponse( + + Long id, + PostCategory category, + String title, + String excerpt, + String authorDisplay, + LocalDateTime createdAt, + Long commentCount, + String thumbnailUrl +) {} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostListRowResponse.java b/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostListRowResponse.java new file mode 100644 index 0000000..6527d39 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostListRowResponse.java @@ -0,0 +1,15 @@ +package com.mysite.knitly.domain.community.post.dto; + +import com.mysite.knitly.domain.community.post.entity.PostCategory; +import java.time.LocalDateTime; + +public record PostListRowResponse( + Long id, + PostCategory category, + String title, + String excerpt, + Long authorId, + LocalDateTime createdAt, + Long commentCount, + String thumbnailUrl +) {} diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostResponse.java b/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostResponse.java new file mode 100644 index 0000000..b0ba1ee --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostResponse.java @@ -0,0 +1,20 @@ +package com.mysite.knitly.domain.community.post.dto; + +import com.mysite.knitly.domain.community.post.entity.PostCategory; +import java.time.LocalDateTime; +import java.util.List; + +// ๊ฒŒ์‹œ๊ธ€ ๋‹จ๊ฑด ์‘๋‹ต DTO +public record PostResponse( + Long id, + PostCategory category, + String title, + String content, + List imageUrls, + Long authorId, + String authorDisplay, + LocalDateTime createdAt, + LocalDateTime updatedAt, + Long commentCount, + boolean mine +) {} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostUpdateRequest.java b/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostUpdateRequest.java new file mode 100644 index 0000000..02a0436 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostUpdateRequest.java @@ -0,0 +1,22 @@ +package com.mysite.knitly.domain.community.post.dto; + +import com.mysite.knitly.domain.community.post.entity.PostCategory; +import jakarta.validation.constraints.*; + +import java.util.List; + +public record PostUpdateRequest( + + @NotNull(message = "์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ ์„ ํƒํ•ด ์ฃผ์„ธ์š”.") + PostCategory category, + + @NotBlank(message = "์ œ๋ชฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Size(max = 100, message = "์ œ๋ชฉ์€ 100์ž ์ดํ•˜๋กœ ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.") + String title, + + @NotBlank(message = "๋‚ด์šฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + String content, + + @Size(max = 5, message = "์ด๋ฏธ์ง€๋Š” ์ตœ๋Œ€ 5๊ฐœ๊นŒ์ง€ ์—…๋กœ๋“œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + List imageUrls +) {} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/post/entity/Post.java b/backend/src/main/java/com/mysite/knitly/domain/community/post/entity/Post.java new file mode 100644 index 0000000..a602788 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/community/post/entity/Post.java @@ -0,0 +1,82 @@ +package com.mysite.knitly.domain.community.post.entity; + +import com.mysite.knitly.domain.community.comment.entity.Comment; +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.global.jpa.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.Where; +import java.util.ArrayList; +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Entity +@Table(name = "posts") +@Where(clause = "is_deleted = false") +public class Post extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "post_id") + private Long id; + + @Column(nullable = false, length = 100) + private String title; + + @Lob + @Column(nullable = false) + private String content; + + // ๋‹ค์ค‘ ์ด๋ฏธ์ง€ URL + @Builder.Default + @ElementCollection + @CollectionTable(name = "post_images", joinColumns = @JoinColumn(name = "post_id")) + @Column(name = "url", nullable = false, length = 512) + @OrderColumn(name = "sort_order") + private List imageUrls = new ArrayList<>(); + + @Enumerated(EnumType.STRING) + @Column(name = "post_category", nullable = false, columnDefinition = "ENUM('FREE','QUESTION','TIP')") + private PostCategory category; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User author; + + @Builder.Default + @Column(name = "is_deleted", nullable = false) + private boolean deleted = false; + + @Builder.Default + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private List comments = new ArrayList<>(); + + public void addComment(Comment comment) { + comments.add(comment); + comment.setPost(this); + } + + public void softDelete() { this.deleted = true; } + + // ์ด๋ฏธ์ง€ ์ˆ˜์ • + public void update(String title, String content, PostCategory category) { + this.title = title; + this.content = content; + this.category = category; + } + + // ์ด๋ฏธ์ง€ ๊ต์ฒด + public void replaceImages(List newUrls) { + this.imageUrls.clear(); + if (newUrls != null) { + this.imageUrls.addAll(newUrls); + } + } + + public boolean isAuthor(User user) { + return user != null && author != null && author.getUserId().equals(user.getUserId()); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/post/entity/PostCategory.java b/backend/src/main/java/com/mysite/knitly/domain/community/post/entity/PostCategory.java new file mode 100644 index 0000000..37cb15d --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/community/post/entity/PostCategory.java @@ -0,0 +1,6 @@ +package com.mysite.knitly.domain.community.post.entity; + +// ์นดํ…Œ๊ณ ๋ฆฌ ๋“œ๋กญ๋‹ค์šด ์ž์œ , ์งˆ๋ฌธ, ํŒ +public enum PostCategory { + FREE, QUESTION, TIP +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/post/repository/PostRepository.java b/backend/src/main/java/com/mysite/knitly/domain/community/post/repository/PostRepository.java new file mode 100644 index 0000000..d4d9de4 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/community/post/repository/PostRepository.java @@ -0,0 +1,59 @@ +package com.mysite.knitly.domain.community.post.repository; + +import com.mysite.knitly.domain.community.post.dto.PostListRowResponse; +import com.mysite.knitly.domain.community.post.entity.Post; +import com.mysite.knitly.domain.community.post.entity.PostCategory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.*; +import org.springframework.data.repository.query.Param; +import java.util.Collection; +import java.util.List; + +public interface PostRepository extends JpaRepository { + + @EntityGraph(attributePaths = "author") + @Query( + "SELECT p " + + "FROM Post p " + + "WHERE (:category IS NULL OR p.category = :category) " + + " AND ( :query IS NULL OR :query = '' " + + " OR LOWER(p.title) LIKE LOWER(CONCAT('%', :query, '%')) " + + " OR p.content LIKE CONCAT('%', :query, '%') ) " + + "ORDER BY p.createdAt DESC" + ) + Page findListRows(@Param("category") PostCategory category, + @Param("query") String query, + Pageable pageable); + + @Query("SELECT COUNT(c.id) FROM Comment c WHERE c.post.id = :postId AND c.deleted = false") + long countCommentsByPostId(@Param("postId") Long postId); + + // ๋Œ“๊ธ€ ์ˆ˜ ์ผ๊ด„ ์กฐํšŒ (N+1 ๋ฌธ์ œ ์ œ๊ฑฐ) + @Query(""" + SELECT c.post.id, COUNT(c.id) + FROM Comment c + WHERE c.post.id IN :postIds AND c.deleted = false + GROUP BY c.post.id + """) + List countCommentsByPostIds(@Param("postIds") List postIds); + + //๋‚ด๊ฐ€ ์“ด ๊ธ€ ๋ชฉ๋ก + ๊ฒ€์ƒ‰ + @Query(""" + select p + from Post p + where p.author.userId = :uid + and ( + :q is null or :q = '' + or lower(p.title) like lower(concat('%', :q, '%')) + or lower(cast(p.content as string)) like lower(concat('%', :q, '%')) + ) + """) + Page findMyPosts( + @Param("uid") Long userId, + @Param("q") String query, + Pageable pageable + ); + + long deleteByIdInAndAuthor_UserId(Collection ids, Long userId); +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/post/service/PostService.java b/backend/src/main/java/com/mysite/knitly/domain/community/post/service/PostService.java new file mode 100644 index 0000000..ec65db1 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/community/post/service/PostService.java @@ -0,0 +1,191 @@ +package com.mysite.knitly.domain.community.post.service; + +import com.mysite.knitly.domain.community.post.dto.PostCreateRequest; +import com.mysite.knitly.domain.community.post.dto.PostListItemResponse; +import com.mysite.knitly.domain.community.post.dto.PostResponse; +import com.mysite.knitly.domain.community.post.dto.PostUpdateRequest; +import com.mysite.knitly.domain.community.post.entity.Post; +import com.mysite.knitly.domain.community.post.entity.PostCategory; +import com.mysite.knitly.domain.community.post.repository.PostRepository; +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.global.exception.ErrorCode; +import com.mysite.knitly.global.exception.ServiceException; +import com.mysite.knitly.global.util.Anonymizer; +import com.mysite.knitly.global.util.ImageValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Objects; +import java.util.Map; +import java.util.stream.Collectors; + +// ์ƒ์„ธ ๊ธฐ๋Šฅ: mine(ํ˜„์žฌ ์‚ฌ์šฉ์ž = ์ž‘์„ฑ์ž) +// ๋“ฑ๋ก,์ˆ˜์ • ๊ธฐ๋Šฅ, ์ด๋ฏธ์ง€ ํ™•์žฅ์ž ๊ฒ€์ฆ(png/jpg/jpeg) + ์—”ํ‹ฐํ‹ฐ ์ €์žฅ/์ˆ˜์ • +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PostService { + + private final PostRepository postRepository; + + public Page getPostList(PostCategory category, String query, int page, int size) { + Pageable pageable = PageRequest.of(page, size); + Page posts = postRepository.findListRows(category, query, pageable); + + // ๋Œ“๊ธ€ ์ˆ˜ ์ผ๊ด„ ์กฐํšŒ๋กœ N+1 ์ œ๊ฑฐ + List ids = posts.getContent().stream().map(Post::getId).toList(); + Map countMap = postRepository.countCommentsByPostIds(ids).stream() + .collect(Collectors.toMap(r -> (Long) r[0], r -> (Long) r[1], Long::sum)); + + return posts.map(p -> { + String exRaw = p.getContent() == null ? "" : p.getContent(); + + // ๊ณต๋ฐฑ/ํŠน์ˆ˜ ๊ณต๋ฐฑ ์ œ๊ฑฐ ํ›„ ์š”์•ฝ ์ƒ์„ฑ + String cleaned = exRaw.replaceAll("[\\s\\u00A0\\u3000]+", ""); + String ex = cleaned.isEmpty() + ? "" + : (cleaned.length() > 10 ? cleaned.substring(0, 10) + "..." : cleaned); + long commentCount = countMap.getOrDefault(p.getId(), 0L); + + String thumbnail = (p.getImageUrls() == null || p.getImageUrls().isEmpty()) + ? null : p.getImageUrls().get(0); + + return new PostListItemResponse( + p.getId(), + p.getCategory(), + p.getTitle(), + ex, + Anonymizer.yarn(p.getAuthor().getUserId()), + p.getCreatedAt(), + commentCount, + thumbnail + ); + }); + } + + public PostResponse getPost(Long id, User currentUserOrNull) { + Post p = postRepository.findById(id) + .orElseThrow(() -> new ServiceException(ErrorCode.POST_NOT_FOUND)); + + long commentCount = postRepository.countCommentsByPostId(id); + + boolean mine = p.isAuthor(currentUserOrNull); + + return new PostResponse( + p.getId(), + p.getCategory(), + p.getTitle(), + p.getContent(), + p.getImageUrls(), + p.getAuthor().getUserId(), + Anonymizer.yarn(p.getAuthor().getUserId()), + p.getCreatedAt(), + p.getUpdatedAt(), + commentCount, + mine + ); + } + + @Transactional + public PostResponse create(PostCreateRequest req, User currentUser) { + if (currentUser == null) { + throw new ServiceException(ErrorCode.POST_UNAUTHORIZED); + } + // ์ œ๋ชฉ ๊ธธ์ด(100์ž ์ดˆ๊ณผ) + if (req.title() != null && req.title().length() > 100) { + throw new ServiceException(ErrorCode.POST_TITLE_LENGTH_INVALID); + } + + List urls = normalizeUrls(req.imageUrls()); + if (urls.size() > 5) { + throw new ServiceException(ErrorCode.POST_IMAGE_COUNT_EXCEEDED); + } + for (String u : urls) { + if (!ImageValidator.isAllowedImageUrl(u)) { + throw new ServiceException(ErrorCode.POST_IMAGE_EXTENSION_INVALID); + } + } + + // ์š”์ฒญ authorId๋Š” ๋ฌด์‹œ, ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž ๊ฐ์ฒด๋ฅผ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ + User author = currentUser; + + Post post = Post.builder() + .category(req.category()) + .title(req.title()) + .content(req.content()) + .author(author) + .build(); + + post.replaceImages(urls); + + Post saved = postRepository.save(post); + return getPost(saved.getId(), currentUser); + } + + @Transactional + public PostResponse update(Long id, PostUpdateRequest req, User currentUser) { + if (currentUser == null) { + throw new ServiceException(ErrorCode.POST_UNAUTHORIZED); + } + // ์ œ๋ชฉ ๊ธธ์ด(100์ž ์ดˆ๊ณผ) + if (req.title() != null && req.title().length() > 100) { + throw new ServiceException(ErrorCode.POST_TITLE_LENGTH_INVALID); + } + Post p = postRepository.findById(id) + .orElseThrow(() -> new ServiceException(ErrorCode.POST_NOT_FOUND)); + + if (!p.isAuthor(currentUser)) { + throw new ServiceException(ErrorCode.POST_UPDATE_FORBIDDEN); + } + + p.update( + req.title(), + req.content(), + req.category() + ); + + if (req.imageUrls() != null) { + List urls = normalizeUrls(req.imageUrls()); + if (urls.size() > 5) { + throw new ServiceException(ErrorCode.POST_IMAGE_COUNT_EXCEEDED); + } + for (String u : urls) { + if (!ImageValidator.isAllowedImageUrl(u)) { + throw new ServiceException(ErrorCode.POST_IMAGE_EXTENSION_INVALID); + } + } + p.replaceImages(urls); + } + + return getPost(p.getId(), currentUser); + } + + @Transactional + public void delete(Long id, User currentUser) { + if (currentUser == null) { + throw new ServiceException(ErrorCode.POST_UNAUTHORIZED); + } + Post p = postRepository.findById(id) + .orElseThrow(() -> new ServiceException(ErrorCode.POST_NOT_FOUND)); + + if (!p.isAuthor(currentUser)) { + throw new ServiceException(ErrorCode.POST_DELETE_FORBIDDEN); + } + + p.softDelete(); + } + + private List normalizeUrls(List raw) { + if (raw == null) return List.of(); + return raw.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/design/controller/DesignController.java b/backend/src/main/java/com/mysite/knitly/domain/design/controller/DesignController.java new file mode 100644 index 0000000..f634ae1 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/design/controller/DesignController.java @@ -0,0 +1,131 @@ +package com.mysite.knitly.domain.design.controller; + +import com.mysite.knitly.domain.design.dto.DesignListResponse; +import com.mysite.knitly.domain.design.dto.DesignRequest; +import com.mysite.knitly.domain.design.dto.DesignResponse; +import com.mysite.knitly.domain.design.dto.DesignUploadRequest; +import com.mysite.knitly.domain.design.entity.Design; +import com.mysite.knitly.domain.design.repository.DesignRepository; +import com.mysite.knitly.domain.design.service.DesignService; +import com.mysite.knitly.domain.design.util.LocalFileStorage; +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.global.exception.ErrorCode; +import com.mysite.knitly.global.exception.ServiceException; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/designs") +public class DesignController { + private final DesignService designService; + private final DesignRepository designRepository; + private final LocalFileStorage localFileStorage; + + // ๋„์•ˆ ์ƒ์„ฑ + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity createDesign( + @AuthenticationPrincipal User user, + @Valid @RequestBody DesignRequest request + + ) { + DesignResponse response = designService.createDesign(user, request); + + return ResponseEntity.ok(response); + } + + + //๊ธฐ์กด PDF ์—…๋กœ๋“œ + @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity uploadDesignPdf( + @AuthenticationPrincipal User user, + @RequestPart("file") MultipartFile file, + @RequestParam(required = false) String designName + ) { + DesignUploadRequest req = new DesignUploadRequest(designName, file); + return ResponseEntity.ok(designService.uploadPdfDesign(user, req)); + } + + // ๋„์•ˆ ๋ชฉ๋ก ์กฐํšŒ + @GetMapping("/my") + public ResponseEntity> getMyDesigns( + @AuthenticationPrincipal User user + ) { + List designs = designService.getMyDesigns(user); + return ResponseEntity.ok(designs); + } + + // ๋„์•ˆ ์‚ญ์ œ + @DeleteMapping("/{designId}") + public ResponseEntity deleteDesign( + @AuthenticationPrincipal User user, + @PathVariable Long designId){ + designService.deleteDesign(user, designId); + return ResponseEntity.noContent().build(); + } + + // ํŒ๋งค ์ค‘์ง€ + @PatchMapping("/{designId}/stop") + public ResponseEntity stopDesignSale( + @AuthenticationPrincipal User user, + @PathVariable Long designId + ) { + designService.stopDesignSale(user, designId); + return ResponseEntity.noContent().build(); + } + + // ํŒ๋งค ์žฌ๊ฐœ + @PatchMapping("/{designId}/relist") + public ResponseEntity relistDesign( + @AuthenticationPrincipal User user, + @PathVariable Long designId + ) { + designService.relistDesign(user, designId); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/{designId}/pdf") + public ResponseEntity downloadDesignPdf( + @PathVariable Long designId + ) throws IOException { + Design design = designRepository.findById(designId) + .orElseThrow(() -> new ServiceException(ErrorCode.DESIGN_NOT_FOUND)); + + // ํŒŒ์ผ ๊ฒฝ๋กœ ๊ฐ€์ ธ์˜ค๊ธฐ + Path filePath = localFileStorage.toAbsolutePathFromUrl(design.getPdfUrl()); + + if (!Files.exists(filePath)) { + throw new ServiceException(ErrorCode.DESIGN_FILE_NOT_FOUND); + } + + // ํŒŒ์ผ์„ Resource๋กœ ๋ณ€ํ™˜ + Resource resource = new UrlResource(filePath.toUri()); + + // Content-Disposition ํ—ค๋” ์„ค์ • (๋ธŒ๋ผ์šฐ์ €์—์„œ ์—ด๊ธฐ) + String contentDisposition = ContentDisposition + .inline() + .filename(design.getDesignName() + ".pdf", StandardCharsets.UTF_8) + .build() + .toString(); + + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_PDF) + .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition) + .body(resource); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/design/dto/DesignListResponse.java b/backend/src/main/java/com/mysite/knitly/domain/design/dto/DesignListResponse.java new file mode 100644 index 0000000..9111f3f --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/design/dto/DesignListResponse.java @@ -0,0 +1,24 @@ +package com.mysite.knitly.domain.design.dto; + +import com.mysite.knitly.domain.design.entity.Design; +import com.mysite.knitly.domain.design.entity.DesignState; + +import java.time.LocalDateTime; + +public record DesignListResponse ( + Long designId, + String designName, + String pdfUrl, + DesignState designState, + LocalDateTime createdAt +){ + public static DesignListResponse from(Design design) { + return new DesignListResponse( + design.getDesignId(), + design.getDesignName(), + design.getPdfUrl(), + design.getDesignState(), + design.getCreatedAt() + ); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/design/dto/DesignRequest.java b/backend/src/main/java/com/mysite/knitly/domain/design/dto/DesignRequest.java new file mode 100644 index 0000000..d3f3136 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/design/dto/DesignRequest.java @@ -0,0 +1,34 @@ +package com.mysite.knitly.domain.design.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public record DesignRequest ( + + @NotBlank(message = "ํŒŒ์ผ ์ด๋ฆ„์€ ํ•„์ˆ˜ ํ•ญ๋ชฉ์ž…๋‹ˆ๋‹ค.") + @Size(max = 30, message = "ํŒŒ์ผ ์ด๋ฆ„์€ 30์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + String designName, + + @NotNull(message = "๋„์•ˆ ๋ฐ์ดํ„ฐ๋Š” ํ•„์ˆ˜ ํ•ญ๋ชฉ์ž…๋‹ˆ๋‹ค.") + @Size(min = 10, max = 10, message = "๋„์•ˆ์€ 10x10 ํฌ๊ธฐ์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + List<@Size(min = 10, max = 10, message = "๊ฐ ํ–‰์€ 10์นธ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") List> gridData, + + @Size(max = 80, message = "ํŒŒ์ผ ์ด๋ฆ„์€ 80์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + String fileName // (์„ ํƒ) ํŒŒ์ผ ์ด๋ฆ„ +) { + // ์œ ํšจ์„ฑ ๊ฒ€์ฆ ๋ฉ”์„œ๋“œ ( 10x10 ํฌ๊ธฐ์ธ์ง€ ํ™•์ธ ) + public boolean isValidGridSize() { + if (gridData == null || gridData.size() != 10) { + return false; + } + for (List row : gridData) { + if (row == null || row.size() != 10) { + return false; + } + } + return true; + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/design/dto/DesignResponse.java b/backend/src/main/java/com/mysite/knitly/domain/design/dto/DesignResponse.java new file mode 100644 index 0000000..35b00a7 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/design/dto/DesignResponse.java @@ -0,0 +1,25 @@ +package com.mysite.knitly.domain.design.dto; + +import com.mysite.knitly.domain.design.entity.Design; +import com.mysite.knitly.domain.design.entity.DesignState; + +import java.time.LocalDateTime; + +public record DesignResponse ( + Long designId, + String designName, + String pdfUrl, + DesignState designState, + LocalDateTime createdAt +){ + public static DesignResponse from(Design design){ + return new DesignResponse( + design.getDesignId(), + design.getDesignName(), + design.getPdfUrl(), + design.getDesignState(), + design.getCreatedAt() + ); + } + +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/design/dto/DesignUploadRequest.java b/backend/src/main/java/com/mysite/knitly/domain/design/dto/DesignUploadRequest.java new file mode 100644 index 0000000..60965a0 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/design/dto/DesignUploadRequest.java @@ -0,0 +1,10 @@ +package com.mysite.knitly.domain.design.dto; + +import jakarta.validation.constraints.Size; +import org.springframework.web.multipart.MultipartFile; + +public record DesignUploadRequest( + @Size(max = 30, message = "๋„์•ˆ๋ช…์€ 30์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + String designName, + MultipartFile pdfFile +) {} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/design/entity/Design.java b/backend/src/main/java/com/mysite/knitly/domain/design/entity/Design.java new file mode 100644 index 0000000..80c3b8b --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/design/entity/Design.java @@ -0,0 +1,96 @@ +package com.mysite.knitly.domain.design.entity; + +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.global.exception.ErrorCode; +import com.mysite.knitly.global.exception.ServiceException; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "designs") +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class Design { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long designId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column + private String pdfUrl; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private DesignState designState; + + @Column(nullable = false, length = 30) + private String designName; + + @Column(name = "grid_data", columnDefinition = "JSON", nullable = false) + private String gridData; + + @CreatedDate + @Column(nullable = false) + private LocalDateTime createdAt; + + // ์‚ญ์ œ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ํ™•์ธ - BEFORE_SALE ์ƒํƒœ์ธ ๊ฒฝ์šฐ์—๋งŒ ์‚ญ์ œ ๊ฐ€๋Šฅ + public boolean isDeletable() { + return this.designState == DesignState.BEFORE_SALE; + } + + // ๋„์•ˆ ์ž‘์„ฑ์ž ํ™•์ธ - userId ๋น„๊ต + public boolean isOwnedBy(Long userId) { + return this.user.getUserId().equals(userId); + } + + // ์ตœ์ดˆ ํŒ๋งค ์‹œ์ž‘ ๋ฉ”์„œ๋“œ + // ์˜ค์ง BEFORE_SALE ์ƒํƒœ์—์„œ๋งŒ ํ˜ธ์ถœ ๊ฐ€๋Šฅ + // ํŒ๋งค ์ „ -> ํŒ๋งค ์ค‘์œผ๋กœ ๋ณ€๊ฒฝ + public void startSale() { + if (this.designState != DesignState.BEFORE_SALE) { + throw new ServiceException(ErrorCode.DESIGN_ALREADY_ON_SALE); + } + this.designState = DesignState.ON_SALE; + } + + // ํŒ๋งค ์ค‘ -> ํŒ๋งค ์ค‘์ง€ ๋ฉ”์„œ๋“œ + // ์˜ค์ง ON_SALE ์ƒํƒœ์—์„œ๋งŒ ํ˜ธ์ถœ ๊ฐ€๋Šฅ + public void stopSale() { + if( this.designState != DesignState.ON_SALE) { + throw new ServiceException(ErrorCode.DESIGN_NOT_ON_SALE); + } + this.designState = DesignState.STOPPED; + } + + // ํŒ๋งค ์ค‘์ง€ -> ํŒ๋งค ์žฌ๊ฐœ ๋ฉ”์„œ๋“œ + // ์˜ค์ง STOPPED ์ƒํƒœ์—์„œ๋งŒ ํ˜ธ์ถœ ๊ฐ€๋Šฅ + public void relist() { + if (this.designState != DesignState.STOPPED) { + throw new ServiceException(ErrorCode.DESIGN_NOT_STOPPED); + } + this.designState = DesignState.ON_SALE; + } +} + + +//CREATE TABLE `designs` ( +// `design_id` BIGINT NOT NULL DEFAULT AUTO_INCREMENT, +// `pdf_url` VARCHAR(255) NULL, +// `design_state` ENUM('ON_SALE', 'STOPPED', 'BEFORE_SALE') NOT NULL DEFAULT BEFORE_SALE, +// `design_name` VARCHAR(30) NOT NULL +//); \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/design/entity/DesignState.java b/backend/src/main/java/com/mysite/knitly/domain/design/entity/DesignState.java new file mode 100644 index 0000000..81ddb58 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/design/entity/DesignState.java @@ -0,0 +1,5 @@ +package com.mysite.knitly.domain.design.entity; + +public enum DesignState { + ON_SALE, STOPPED, BEFORE_SALE // ํŒ๋งค์ค‘, ํŒ๋งค์ค‘์ง€, ํŒ๋งค์ „ +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/design/repository/DesignRepository.java b/backend/src/main/java/com/mysite/knitly/domain/design/repository/DesignRepository.java new file mode 100644 index 0000000..3ba414e --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/design/repository/DesignRepository.java @@ -0,0 +1,15 @@ +package com.mysite.knitly.domain.design.repository; + +import com.mysite.knitly.domain.design.entity.Design; +import com.mysite.knitly.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface DesignRepository extends JpaRepository { + List findByUser(User user); + Optional findByDesignId(Long designId); +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/design/service/DesignService.java b/backend/src/main/java/com/mysite/knitly/domain/design/service/DesignService.java new file mode 100644 index 0000000..74b75eb --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/design/service/DesignService.java @@ -0,0 +1,202 @@ +package com.mysite.knitly.domain.design.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mysite.knitly.domain.design.dto.DesignListResponse; +import com.mysite.knitly.domain.design.dto.DesignRequest; +import com.mysite.knitly.domain.design.dto.DesignResponse; +import com.mysite.knitly.domain.design.dto.DesignUploadRequest; +import com.mysite.knitly.domain.design.entity.Design; +import com.mysite.knitly.domain.design.entity.DesignState; +import com.mysite.knitly.domain.design.repository.DesignRepository; +import com.mysite.knitly.domain.design.util.FileValidator; +import com.mysite.knitly.domain.design.util.LocalFileStorage; +import com.mysite.knitly.domain.design.util.PdfGenerator; +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.domain.user.repository.UserRepository; +import com.mysite.knitly.global.exception.ErrorCode; +import com.mysite.knitly.global.exception.ServiceException; +import com.mysite.knitly.global.util.FileNameUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@Transactional +@RequiredArgsConstructor +@Slf4j +public class DesignService { + private final DesignRepository designRepository; + private final PdfGenerator pdfGenerator; + private final LocalFileStorage localFileStorage; + private final ObjectMapper objectMapper; + private final FileValidator fileValidator; + + // ๋„์•ˆ ์ƒ์„ฑ + @Transactional + public DesignResponse createDesign(User user, DesignRequest request) { + // gridData ์ž…๋ ฅ ๊ฒ€์ฆ + if(!request.isValidGridSize()) throw new ServiceException(ErrorCode.DESIGN_INVALID_GRID_SIZE); + + // PDF ์ƒ์„ฑ + byte[] pdfBytes = pdfGenerator.generate(request.designName(), request.gridData()); + + // ํŒŒ์ผ๋ช… ์ •๋ฆฌ + String base = (request.fileName() == null || request.fileName().isBlank()) + ? request.designName() + : request.fileName(); + String sanitized = FileNameUtils.sanitize(base); + + + // ๋กœ์ปฌ์— ํŒŒ์ผ ์ €์žฅ + String pdfUrl = localFileStorage.savePdfFile(pdfBytes, sanitized); + + // gridData๋ฅผ JSON ๋ฌธ์ž์—ด๋กœ ๋ณ€ํ™˜ + String gridDataJson = convertGridDataToJson(request.gridData()); + + // ๋„์•ˆ ์—”ํ‹ฐํ‹ฐ ์ƒ์„ฑ ๋ฐ ์ €์žฅ + Design design = Design.builder() + .user(user) + .designName(request.designName()) + .pdfUrl(pdfUrl) + .gridData(gridDataJson) + .designState(DesignState.BEFORE_SALE) + .build(); + + Design savedDesign = designRepository.save(design); + + return DesignResponse.from(savedDesign); + + } + + // ๊ธฐ์กด pdf ํŒŒ์ผ ์—…๋กœ๋“œ + public DesignResponse uploadPdfDesign(User user, DesignUploadRequest request){ + + MultipartFile pdfFile = request.pdfFile(); + fileValidator.validatePdfFile(pdfFile); + + byte[] pdfBytes; + try { + pdfBytes = pdfFile.getBytes(); + } catch (IOException e) { + log.error("ํŒŒ์ผ ์ฝ๊ธฐ ์‹คํŒจ: fileName={}", pdfFile.getOriginalFilename(), e); + throw new ServiceException(ErrorCode.DESIGN_FILE_SAVE_FAILED); + } + + String base = (request.designName() == null || request.designName().isBlank()) + ? defaultBaseName(pdfFile.getOriginalFilename()) + : request.designName(); + String sanitized = FileNameUtils.sanitize(base); + + String pdfUrl = localFileStorage.savePdfFile(pdfBytes, sanitized); + String defaultGrid = "{}"; + Design design = Design.builder() + .user(user) + .designName(sanitized) + .pdfUrl(pdfUrl) + .gridData(defaultGrid) + .designState(DesignState.BEFORE_SALE) + .build(); + + Design savedDesign = designRepository.save(design); + + log.info("PDF ์—…๋กœ๋“œ ์™„๋ฃŒ - designId={}", savedDesign.getDesignId()); + + return DesignResponse.from(savedDesign); + } + + // ๋ณธ์ธ ๋„์•ˆ ์กฐํšŒ + @Transactional(readOnly = true) + public List getMyDesigns (User user){ + List designs = designRepository.findByUser(user); + + return designs.stream() + .map(DesignListResponse::from) + .collect(Collectors.toList()); + } + + + // ๋„์•ˆ ์‚ญ์ œ - BEFORE_SALE ์ƒํƒœ์ธ ๋„์•ˆ๋งŒ ์‚ญ์ œ ๊ฐ€๋Šฅ, ON_SALE ๋˜๋Š” STOPPED ์ƒํƒœ์ธ ๋„์•ˆ์€ ์‚ญ์ œ ๋ถˆ๊ฐ€ + public void deleteDesign(User user, Long designId){ + Design design = designRepository.findById(designId) + .orElseThrow(() -> new ServiceException(ErrorCode.DESIGN_NOT_FOUND)); + + // ๋ณธ์ธ ๋„์•ˆ์ธ์ง€ ํ™•์ธ + Long userId = user.getUserId(); + if(!design.isOwnedBy(userId)){ + throw new ServiceException(ErrorCode.DESIGN_UNAUTHORIZED_DELETE); + } + + if(!design.isDeletable()){ + throw new ServiceException(ErrorCode.DESIGN_NOT_DELETABLE); + } + + try { + localFileStorage.deleteFile(design.getPdfUrl()); + } catch (Exception e) { + log.warn("ํŒŒ์ผ ์‚ญ์ œ ์‹คํŒจ (DB๋Š” ์‚ญ์ œ ์ง„ํ–‰): pdfUrl={}", design.getPdfUrl(), e); + // ํŒŒ์ผ ์‚ญ์ œ ์‹คํŒจํ•ด๋„ DB๋Š” ์‚ญ์ œ ์ง„ํ–‰ + } + + designRepository.delete(design); + } + + + private String convertGridDataToJson(Object gridData) { + try { + return objectMapper.writeValueAsString(gridData); + } catch (JsonProcessingException e) { + throw new ServiceException(ErrorCode.DESIGN_INVALID_GRID_SIZE); + } + } + + private String defaultBaseName(String original) { + if (original == null || original.isBlank()) return "design"; + int i = original.lastIndexOf('.'); + return i > 0 ? original.substring(0, i) : original; + } + + // ํŒ๋งค ์ค‘์ง€ - ON_SALE -> STOPPED + @Transactional + public void stopDesignSale(User user, Long designId) { + Design design = designRepository.findById(designId) + .orElseThrow(() -> new ServiceException(ErrorCode.DESIGN_NOT_FOUND)); + + // ๋ณธ์ธ ๋„์•ˆ์ธ์ง€ ํ™•์ธ + if (!design.isOwnedBy(user.getUserId())) { + throw new ServiceException(ErrorCode.DESIGN_UNAUTHORIZED_DELETE); + } + + // Design ์—”ํ‹ฐํ‹ฐ์˜ stopSale() ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ + // ON_SALE ์ƒํƒœ๊ฐ€ ์•„๋‹ˆ๋ฉด ์˜ˆ์™ธ ๋ฐœ์ƒ + design.stopSale(); + + log.info("๋„์•ˆ ํŒ๋งค ์ค‘์ง€ ์™„๋ฃŒ - designId={}, userId={}", designId, user.getUserId()); + } + + // ํŒ๋งค ์žฌ๊ฐœ - STOPPED -> ON_SALE + @Transactional + public void relistDesign(User user, Long designId) { + Design design = designRepository.findById(designId) + .orElseThrow(() -> new ServiceException(ErrorCode.DESIGN_NOT_FOUND)); + + // ๋ณธ์ธ ๋„์•ˆ์ธ์ง€ ํ™•์ธ + if (!design.isOwnedBy(user.getUserId())) { + throw new ServiceException(ErrorCode.DESIGN_UNAUTHORIZED_DELETE); + } + + // Design ์—”ํ‹ฐํ‹ฐ์˜ relist() ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ + // STOPPED ์ƒํƒœ๊ฐ€ ์•„๋‹ˆ๋ฉด ์˜ˆ์™ธ ๋ฐœ์ƒ + design.relist(); + + log.info("๋„์•ˆ ํŒ๋งค ์žฌ๊ฐœ ์™„๋ฃŒ - designId={}, userId={}", designId, user.getUserId()); + } + +} + diff --git a/backend/src/main/java/com/mysite/knitly/domain/design/util/FileValidator.java b/backend/src/main/java/com/mysite/knitly/domain/design/util/FileValidator.java new file mode 100644 index 0000000..6c38ed8 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/design/util/FileValidator.java @@ -0,0 +1,56 @@ +package com.mysite.knitly.domain.design.util; + +import com.mysite.knitly.global.exception.ErrorCode; +import com.mysite.knitly.global.exception.ServiceException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@Component +public class FileValidator { + private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + private static final String ALLOWED_CONTENT_TYPE = "application/pdf"; + private static final String[] ALLOWED_EXTENSIONS = {".pdf"}; + + // pdf ํŒŒ์ผ ์œ ํšจ์„ฑ ๊ฒ€์ฆ + public void validatePdfFile(MultipartFile file) { + // 1. ํŒŒ์ผ ์กด์žฌ ์—ฌ๋ถ€ + if (file == null || file.isEmpty()) { + throw new ServiceException(ErrorCode.DESIGN_FILE_EMPTY); + } + + // 2. ํŒŒ์ผ ํฌ๊ธฐ ๊ฒ€์ฆ + if (file.getSize() > MAX_FILE_SIZE) { + log.warn("ํŒŒ์ผ ํฌ๊ธฐ ์ดˆ๊ณผ: {} bytes (์ตœ๋Œ€: {} bytes)", file.getSize(), MAX_FILE_SIZE); + throw new ServiceException(ErrorCode.DESIGN_FILE_SIZE_EXCEEDED); + } + + // 3. Content-Type ๊ฒ€์ฆ + String contentType = file.getContentType(); + if (contentType == null || !contentType.equals(ALLOWED_CONTENT_TYPE)) { + log.warn("์ž˜๋ชป๋œ ํŒŒ์ผ ํƒ€์ž…: {}", contentType); + throw new ServiceException(ErrorCode.DESIGN_FILE_INVALID_TYPE); + } + + // 4. ํ™•์žฅ์ž ๊ฒ€์ฆ + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null || !hasValidExtension(originalFilename)) { + log.warn("์ž˜๋ชป๋œ ํŒŒ์ผ ํ™•์žฅ์ž: {}", originalFilename); + throw new ServiceException(ErrorCode.DESIGN_FILE_INVALID_TYPE); + } + + log.debug("ํŒŒ์ผ ๊ฒ€์ฆ ํ†ต๊ณผ: name={}, size={} bytes", originalFilename, file.getSize()); + } + + private boolean hasValidExtension(String filename) { + String lowerFilename = filename.toLowerCase(); + for (String ext : ALLOWED_EXTENSIONS) { + if (lowerFilename.endsWith(ext)) { + return true; + } + } + return false; + } + +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/design/util/LocalFileStorage.java b/backend/src/main/java/com/mysite/knitly/domain/design/util/LocalFileStorage.java new file mode 100644 index 0000000..e0dbb56 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/design/util/LocalFileStorage.java @@ -0,0 +1,98 @@ +package com.mysite.knitly.domain.design.util; + +import com.mysite.knitly.global.exception.ErrorCode; +import com.mysite.knitly.global.exception.ServiceException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.time.LocalDate; +import java.util.Objects; +import java.util.UUID; + + +@Slf4j +@Component +public class LocalFileStorage { + + @Value("${file.upload-dir:uploads/designs}") + private String uploadDir; + + @Value("${file.public-prefix:/files}") + private String publicPrefix; + + // ๋ฐ”์ดํŠธ ๋ฐฐ์—ด๋กœ ๋“ค์–ด์˜จ pdf๋ฅผ ์ €์žฅ + // {uuid8}_{sanitizedName}.pdf ํ˜•ํƒœ๋กœ ์ €์žฅ + public String savePdfFile(byte[] fileData, String fileName) { + try { + LocalDate today = LocalDate.now(); + + // ์—…๋กœ๋“œ ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ + Path base = Paths.get(uploadDir).toAbsolutePath().normalize(); + Path dir = base.resolve(Paths.get( + String.valueOf(today.getYear()), + String.format("%02d", today.getMonthValue()), + String.format("%02d", today.getDayOfMonth()) + )); + Files.createDirectories(dir); + + // ๊ณ ์œ  ํŒŒ์ผ๋ช… ์ƒ์„ฑ + String baseName = stripPdfExtension(Objects.toString(fileName, "design")); + String uuid8 = UUID.randomUUID().toString().replace("-", "").substring(0, 8); + String savedName = uuid8 + "_" + baseName + ".pdf"; + + // ์ €์žฅ + Path filePath = dir.resolve(savedName); + Files.write(filePath, fileData, StandardOpenOption.CREATE_NEW); + + // ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ URL ์ƒ์„ฑ + String relativePath = String.join("/", + String.valueOf(today.getYear()), + String.format("%02d", today.getMonthValue()), + String.format("%02d", today.getDayOfMonth()), + savedName + ); + + String url = (publicPrefix.endsWith("/") ? publicPrefix.substring(0, publicPrefix.length()-1) : publicPrefix) + + "/" + relativePath; + + log.info("PDF ์ €์žฅ ์™„๋ฃŒ: {}", filePath); + + return url; // DB์—๋Š” ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ URL๋งŒ ์ €์žฅ + } catch (IOException e) { + log.error("PDF ํŒŒ์ผ ์ €์žฅ ์‹คํŒจ", e); + throw new ServiceException(ErrorCode.DESIGN_FILE_SAVE_FAILED); + } + } + + // PDF URL์—์„œ ์ ˆ๋Œ€ ๊ฒฝ๋กœ ๋ณ€ํ™˜ + public Path toAbsolutePathFromUrl(String pdfUrl) { + String prefix = publicPrefix.endsWith("/") ? publicPrefix : publicPrefix + "/"; + String rel = pdfUrl.startsWith(prefix) ? pdfUrl.substring(prefix.length()) : + (pdfUrl.startsWith(publicPrefix) ? pdfUrl.substring(publicPrefix.length()) : pdfUrl); + if (rel.startsWith("/")) rel = rel.substring(1); + return Paths.get(uploadDir).toAbsolutePath().normalize().resolve(rel).normalize(); + } + + public void deleteFile(String fileUrl) throws IOException { + Path filePath = toAbsolutePathFromUrl(fileUrl); + if (Files.exists(filePath)) { + Files.delete(filePath); + log.info("ํŒŒ์ผ ์‚ญ์ œ ์™„๋ฃŒ: {}", filePath); + } else { + log.warn("์‚ญ์ œํ•  ํŒŒ์ผ์ด ์กด์žฌํ•˜์ง€ ์•Š์Œ: {}", filePath); + } + } + + private String stripPdfExtension(String name) { + if (name.toLowerCase().endsWith(".pdf")) { + return name.substring(0, name.length() - 4); + } + return name; + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/design/util/PdfGenerator.java b/backend/src/main/java/com/mysite/knitly/domain/design/util/PdfGenerator.java new file mode 100644 index 0000000..9e2e0e9 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/design/util/PdfGenerator.java @@ -0,0 +1,174 @@ +package com.mysite.knitly.domain.design.util; + +import com.mysite.knitly.global.exception.ErrorCode; +import com.mysite.knitly.global.exception.ServiceException; +import lombok.extern.slf4j.Slf4j; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentInformation; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType0Font; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import java.awt.*; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.io.ByteArrayOutputStream; +import java.util.List; + +@Slf4j +@Component +public class PdfGenerator { + private static final int GRID_SIZE = 10; + private static final float MARGIN = 36f; // A4 ๊ธฐ์ค€ ์ƒํ•˜์ขŒ์šฐ ์—ฌ๋ฐฑ - 0.5 inch + private static final float TITLE_SIZE = 18f; // ๋„์•ˆ๋ช… - 18pt + private static final float SYMBOL_SIZE = 14f; // ๊ฒฉ์ž ์…€ ์•ˆ ๊ธฐํ˜ธ ํฌ๊ธฐ - 14pt (11pt์—์„œ ์ฆ๊ฐ€) + + public byte[] generate(String designName, List> gridData){ + try (PDDocument doc = new PDDocument()) { + // ๋ฌธ์„œ ๋ฉ”ํƒ€ + PDDocumentInformation info = doc.getDocumentInformation(); + info.setTitle(designName); + info.setAuthor("Knitly"); + + // ํŽ˜์ด์ง€ & ํฐํŠธ + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + + PDType0Font font = loadFont(doc); // ํ•œ๊ธ€ ํฐํŠธ ์ž„๋ฒ ๋”ฉ + + // ์ขŒํ‘œ/์˜์—ญ ๊ณ„์‚ฐ + PDRectangle box = page.getMediaBox(); + float pageWidth = box.getWidth(); + float pageHeight = box.getHeight(); + + float contentLeft = MARGIN; + float contentRight = pageWidth - MARGIN; + float contentTop = pageHeight - MARGIN; + float contentBottom = MARGIN; + + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + + // ์ œ๋ชฉ + float titleY = contentTop - 4; + drawTextCentered(cs, font, TITLE_SIZE, designName, pageWidth / 2, titleY); + + // ๊ทธ๋ฆฌ๋“œ ์˜์—ญ ํฌ๊ธฐ ๊ฒฐ์ • (๊ฐ€๋กœ ํญ ๊ธฐ์ค€์œผ๋กœ ์…€ ํฌ๊ธฐ ์‚ฐ์ •) + float availableWidth = contentRight - contentLeft; + float cellSize = Math.min(36f, availableWidth / GRID_SIZE); // ๊ธฐ๋ณธ 36pt, ํ•„์š” ์‹œ ์ž๋™ ์ถ•์†Œ + float gridWidth = cellSize * GRID_SIZE; + float gridHeight = cellSize * GRID_SIZE; + + // ๊ทธ๋ฆฌ๋“œ ์œ„์น˜(๊ฐ€๋กœ ์ค‘์•™) + float gridLeft = (pageWidth - gridWidth) / 2; + float gridTop = titleY - 24f; // ์ œ๋ชฉ๊ณผ์˜ ๊ฐ„๊ฒฉ + + // ๊ฒฉ์ž ๋ผ์ธ + cs.setStrokingColor(Color.DARK_GRAY); + cs.setLineWidth(0.8f); + // ๊ฐ€๋กœ์ค„ + for (int r = 0; r <= GRID_SIZE; r++) { + float y = gridTop - r * cellSize; + cs.moveTo(gridLeft, y); + cs.lineTo(gridLeft + gridWidth, y); + } + // ์„ธ๋กœ์ค„ + for (int c = 0; c <= GRID_SIZE; c++) { + float x = gridLeft + c * cellSize; + cs.moveTo(x, gridTop); + cs.lineTo(x, gridTop - gridHeight); + } + cs.stroke(); + + // ์…€ ๊ธฐํ˜ธ ์ถœ๋ ฅ (์ค‘์•™์ •๋ ฌ) + cs.setNonStrokingColor(Color.BLACK); + for (int r = 0; r < GRID_SIZE; r++) { + for (int c = 0; c < GRID_SIZE; c++) { + String symbolType = gridData.get(r).get(c); + if (symbolType != null && !symbolType.isBlank()) { + float cx = gridLeft + c * cellSize + cellSize / 2; + float cy = gridTop - r * cellSize - cellSize / 2 + 3f; // ์‚ด์ง ์œ„ ๋ณด์ • + + // ๊ธฐํ˜ธ ํƒ€์ž…์— ๋”ฐ๋ผ ๋‹ค๋ฅธ ์‹ฌ๋ณผ ์ถœ๋ ฅ + String displaySymbol = convertSymbolToDisplay(symbolType); + drawTextCentered(cs, font, SYMBOL_SIZE, displaySymbol, cx, cy); + } + } + } + + // ์ƒ์„ฑ์ผ ํ‘ธํ„ฐ + String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); + drawTextRight(cs, font, 9f, "์ƒ์„ฑ์ผ: " + ts, contentRight, contentBottom); + } + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + doc.save(bos); + byte[] pdf = bos.toByteArray(); + log.info("PDF ์ƒ์„ฑ ์™„๋ฃŒ: name='{}', size={} bytes", designName, pdf.length); + return pdf; + + } catch (Exception e) { + log.error("PDF ์ƒ์„ฑ ์‹คํŒจ - name={}", designName, e); + throw new ServiceException(ErrorCode.DESIGN_PDF_GENERATION_FAILED); + } + } + + /** + * ํ”„๋ก ํŠธ์—”๋“œ์—์„œ ๋ฐ›์€ ๊ธฐํ˜ธ ํƒ€์ž…์„ PDF์— ํ‘œ์‹œํ•  ์‹ค์ œ ๋ฌธ์ž๋กœ ๋ณ€ํ™˜ + */ + private String convertSymbolToDisplay(String symbolType) { + if (symbolType == null) return ""; + + switch (symbolType.toLowerCase()) { + case "empty": + return "โ—‹"; // ๋นˆ ์› (U+25CB) + case "filled": + return "โ—"; // ์ฑ„์›Œ์ง„ ์› (U+25CF) + case "x": + return "ร—"; // ๊ณฑํ•˜๊ธฐ ๊ธฐํ˜ธ (U+00D7) + case "v": + return "V"; // V + case "t": + return "T"; // T + case "plus": + return "+"; // + + case "a": + return "A"; // A + default: + return symbolType; // ์•Œ ์ˆ˜ ์—†๋Š” ๊ฒฝ์šฐ ๊ทธ๋Œ€๋กœ ์ถœ๋ ฅ + } + } + + private PDType0Font loadFont(PDDocument doc) throws Exception { + ClassPathResource res = new ClassPathResource("fonts/NanumGothic-Regular.ttf"); + try (InputStream in = res.getInputStream()) { + return PDType0Font.load(doc, in, true); // true: ํฐํŠธ ์ž„๋ฒ ๋”ฉ + } + } + + private void drawTextCentered(PDPageContentStream cs, PDType0Font font, float size, + String text, float centerX, float centerY) throws Exception { + float textWidth = font.getStringWidth(text) / 1000 * size; + float x = centerX - textWidth / 2; + drawText(cs, font, size, text, x, centerY); + } + + private void drawTextRight(PDPageContentStream cs, PDType0Font font, float size, + String text, float rightX, float y) throws Exception { + float textWidth = font.getStringWidth(text) / 1000 * size; + float x = rightX - textWidth; + drawText(cs, font, size, text, x, y); + } + + private void drawText(PDPageContentStream cs, PDType0Font font, float size, + String text, float x, float y) throws Exception { + cs.beginText(); + cs.setFont(font, size); + cs.newLineAtOffset(x, y); + cs.showText(text); + cs.endText(); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/home/controller/HomeController.java b/backend/src/main/java/com/mysite/knitly/domain/home/controller/HomeController.java new file mode 100644 index 0000000..ec5f7e4 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/home/controller/HomeController.java @@ -0,0 +1,50 @@ +package com.mysite.knitly.domain.home.controller; + +import com.mysite.knitly.domain.home.service.HomeSectionService; +import com.mysite.knitly.domain.product.product.dto.ProductListResponse; +import com.mysite.knitly.domain.home.dto.HomeSummaryResponse; +import com.mysite.knitly.domain.home.dto.LatestPostItem; +import com.mysite.knitly.domain.home.dto.LatestReviewItem; +import com.mysite.knitly.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/home") +public class HomeController { + private final HomeSectionService homeSectionService; + + // ๋ฉ”์ธํ™”๋ฉด: ์ธ๊ธฐ ์ƒํ’ˆ Top5 + public ResponseEntity> popularTop5( + @AuthenticationPrincipal(errorOnInvalidType = false) User user + ) { + return ResponseEntity.ok(homeSectionService.getPopularTop5(user)); + } + + // ์ถ”๊ฐ€: ์ตœ์‹  ๋ฆฌ๋ทฐ + @GetMapping("/latest/reviews") + public ResponseEntity> latestReviews() { + return ResponseEntity.ok(homeSectionService.getLatestReviews(3)); + } + + // ์ถ”๊ฐ€: ์ตœ์‹  ์ปค๋ฎค๋‹ˆํ‹ฐ ๊ธ€ 3๊ฐœ + @GetMapping("/latest/posts") + public ResponseEntity> latestPosts() { + return ResponseEntity.ok(homeSectionService.getLatestPosts(3)); + } + + // ์ถ”๊ฐ€: ํ™ˆ ์š”์•ฝ (ํ•œ ๋ฒˆ ํ˜ธ์ถœ๋กœ 3์„น์…˜ ๋ชจ๋‘) + @GetMapping("/summary") + public ResponseEntity summary( + @AuthenticationPrincipal(errorOnInvalidType = false) User user + ) { + return ResponseEntity.ok(homeSectionService.getHomeSummary(user)); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/home/dto/HomeSummaryResponse.java b/backend/src/main/java/com/mysite/knitly/domain/home/dto/HomeSummaryResponse.java new file mode 100644 index 0000000..3d797e4 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/home/dto/HomeSummaryResponse.java @@ -0,0 +1,10 @@ +package com.mysite.knitly.domain.home.dto; + +import com.mysite.knitly.domain.product.product.dto.ProductListResponse; +import java.util.List; + +public record HomeSummaryResponse( + List popularProducts, // ์ธ๊ธฐ ์ƒํ’ˆ Top5 + List latestReviews, // ์ตœ์‹  ๋ฆฌ๋ทฐ + List latestPosts // ์ตœ์‹  ์ปค๋ฎค๋‹ˆํ‹ฐ ๊ธ€ +) {} diff --git a/backend/src/main/java/com/mysite/knitly/domain/home/dto/LatestPostItem.java b/backend/src/main/java/com/mysite/knitly/domain/home/dto/LatestPostItem.java new file mode 100644 index 0000000..0e3b595 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/home/dto/LatestPostItem.java @@ -0,0 +1,11 @@ +package com.mysite.knitly.domain.home.dto; + +import java.time.LocalDateTime; + +public record LatestPostItem( + Long postId, + String title, + String category, + String thumbnailUrl, + LocalDateTime createdAt +) {} diff --git a/backend/src/main/java/com/mysite/knitly/domain/home/dto/LatestReviewItem.java b/backend/src/main/java/com/mysite/knitly/domain/home/dto/LatestReviewItem.java new file mode 100644 index 0000000..7ee8ddf --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/home/dto/LatestReviewItem.java @@ -0,0 +1,13 @@ +package com.mysite.knitly.domain.home.dto; + +import java.time.LocalDate; + +public record LatestReviewItem( + Long reviewId, + Long productId, + String productTitle, + String productThumbnailUrl, + Integer rating, + String content, + LocalDate createdDate +) {} diff --git a/backend/src/main/java/com/mysite/knitly/domain/home/repository/HomeQueryRepository.java b/backend/src/main/java/com/mysite/knitly/domain/home/repository/HomeQueryRepository.java new file mode 100644 index 0000000..527888a --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/home/repository/HomeQueryRepository.java @@ -0,0 +1,55 @@ +package com.mysite.knitly.domain.home.repository; + +import com.mysite.knitly.domain.home.dto.LatestPostItem; +import com.mysite.knitly.domain.home.dto.LatestReviewItem; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public class HomeQueryRepository { + + @PersistenceContext + private EntityManager em; + + // ์ตœ์‹  ๋ฆฌ๋ทฐ N๊ฐœ + public List findLatestReviews(int limit) { + return em.createQuery(""" + SELECT new com.mysite.knitly.domain.home.dto.LatestReviewItem( + r.reviewId, + p.productId, + p.title, + CAST(NULL AS string), + r.rating, + r.content, + CAST(r.createdAt AS LocalDate) + ) + FROM Review r + JOIN r.product p + WHERE r.isDeleted = false + ORDER BY r.createdAt DESC + """, LatestReviewItem.class) + .setMaxResults(limit) + .getResultList(); + } + + // ์ตœ์‹  ์ปค๋ฎค๋‹ˆํ‹ฐ ๊ธ€ N๊ฐœ (deleted=false, ์ตœ์‹ ์ˆœ) + public List findLatestPosts(int limit) { + return em.createQuery(""" + SELECT new com.mysite.knitly.domain.home.dto.LatestPostItem( + p.id, + p.title, + CAST(p.category AS string), + CAST(NULL AS string), + p.createdAt + ) + FROM Post p + WHERE p.deleted = false + ORDER BY p.createdAt DESC + """, LatestPostItem.class) + .setMaxResults(limit) + .getResultList(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/home/service/HomeSectionService.java b/backend/src/main/java/com/mysite/knitly/domain/home/service/HomeSectionService.java new file mode 100644 index 0000000..857ad9e --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/home/service/HomeSectionService.java @@ -0,0 +1,100 @@ +package com.mysite.knitly.domain.home.service; + +import com.mysite.knitly.domain.home.dto.HomeSummaryResponse; +import com.mysite.knitly.domain.home.dto.LatestPostItem; +import com.mysite.knitly.domain.home.dto.LatestReviewItem; +import com.mysite.knitly.domain.home.repository.HomeQueryRepository; +import com.mysite.knitly.domain.product.like.repository.ProductLikeRepository; +import com.mysite.knitly.domain.product.product.dto.ProductListResponse; +import com.mysite.knitly.domain.product.product.entity.Product; +import com.mysite.knitly.domain.product.product.repository.ProductRepository; +import com.mysite.knitly.domain.product.product.service.RedisProductService; +import com.mysite.knitly.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor + +public class HomeSectionService { + + private final RedisProductService redisProductService; + private final ProductRepository productRepository; + private final HomeQueryRepository homeQueryRepository; + private final ProductLikeRepository productLikeRepository; + + // ์ธ๊ธฐ Top5 ์กฐํšŒ - ํ™ˆ ํ™”๋ฉด์šฉ + public List getPopularTop5(User user) { + List topIds = redisProductService.getTopNPopularProducts(5); + + if (topIds.isEmpty()) { + // Redis์— ๋ฐ์ดํ„ฐ ์—†์œผ๋ฉด DB์—์„œ ์ง์ ‘ ์กฐํšŒ + Pageable top5 = PageRequest.of(0, 5, Sort.by("purchaseCount").descending()); + List products = productRepository.findByIsDeletedFalse(top5).getContent(); + + return mapProductsToResponse(user, products); + } + + List unorderedProducts = productRepository.findByProductIdInAndIsDeletedFalse(topIds); + + // [์ˆ˜์ •] ์ฐœ ์—ฌ๋ถ€ ํ™•์ธ + Set likedProductIds = getLikedProductIds(user, unorderedProducts); + + // Redis ์ˆœ์„œ๋Œ€๋กœ ์ •๋ ฌ + Map productMap = unorderedProducts.stream() + .collect(Collectors.toMap(Product::getProductId, p -> p)); + + // [์ˆ˜์ •] ๋žŒ๋‹ค๋ฅผ ์‚ฌ์šฉํ•ด "from(Product, boolean)" ํ˜ธ์ถœ + return topIds.stream() + .map(productMap::get) + .filter(Objects::nonNull) + .map(product -> ProductListResponse.from( + product, + likedProductIds.contains(product.getProductId()) + )) + .collect(Collectors.toList()); + } + // ์ตœ์‹  ๋ฆฌ๋ทฐ N๊ฐœ + public List getLatestReviews(int limit) { + return homeQueryRepository.findLatestReviews(limit); + } + + // ์ตœ์‹  ์ปค๋ฎค๋‹ˆํ‹ฐ ๊ธ€ N๊ฐœ + public List getLatestPosts(int limit) { + return homeQueryRepository.findLatestPosts(limit); + } + // ํ™ˆ (์ธ๊ธฐ 5 + ์ตœ์‹  ๋ฆฌ๋ทฐ 3 + ์ตœ์‹  ๊ธ€ 3) + public HomeSummaryResponse getHomeSummary(User user) { + var popular = getPopularTop5(user); // user ์ „๋‹ฌ + var reviews = getLatestReviews(3); + var posts = getLatestPosts(3); + return new HomeSummaryResponse(popular, reviews, posts); + } + + private List mapProductsToResponse(User user, List products) { + Set likedProductIds = getLikedProductIds(user, products); + return products.stream() + .map(product -> ProductListResponse.from( + product, + likedProductIds.contains(product.getProductId()) + )) + .toList(); + } + + private Set getLikedProductIds(User user, List products) { + if (user == null || products.isEmpty()) { + return Collections.emptySet(); + } + List productIds = products.stream() + .map(Product::getProductId) + .toList(); + // (๊ฐ€์ •) user.getUserId() + return productLikeRepository.findLikedProductIdsByUserId(user.getUserId(), productIds); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/mypage/controller/MyPageController.java b/backend/src/main/java/com/mysite/knitly/domain/mypage/controller/MyPageController.java new file mode 100644 index 0000000..69494fa --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/mypage/controller/MyPageController.java @@ -0,0 +1,96 @@ +package com.mysite.knitly.domain.mypage.controller; + +import com.mysite.knitly.domain.mypage.dto.*; +import com.mysite.knitly.domain.mypage.service.MyPageService; +import com.mysite.knitly.domain.payment.dto.PaymentDetailResponse; +import com.mysite.knitly.domain.payment.service.PaymentService; +import com.mysite.knitly.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; + + +@RestController +@RequestMapping("/mypage") +@RequiredArgsConstructor +@PreAuthorize("isAuthenticated()") +public class MyPageController { + + private final MyPageService service; + private final PaymentService paymentService; + + // ํ”„๋กœํ•„ ์กฐํšŒ (์ด๋ฆ„ + ์ด๋ฉ”์ผ) + @GetMapping("/profile") + public ProfileResponse profile(@AuthenticationPrincipal User principal) { + return new ProfileResponse(principal.getName(), principal.getEmail()); + } + + // ์ฃผ๋ฌธ ๋‚ด์—ญ ์กฐํšŒ + @GetMapping("/orders") + public PageResponse orders( + @AuthenticationPrincipal User principal, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "3") int size + ) { + return PageResponse.of(service.getOrderCards(principal.getUserId(), PageRequest.of(page, size))); + } + + // ์ฃผ๋ฌธ ๋‚ด์—ญ๋ณ„ ๊ฒฐ์ œ ์ •๋ณด ์กฐํšŒ(์ฃผ๋ฌธ ์นด๋“œ์—์„œ ๊ฒฐ์ œ๋‚ด์—ญ ๋ณด๊ธฐ ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ ํ˜ธ์ถœ) + @GetMapping("/orders/{orderId}/payment") + public ResponseEntity myOrderPayment( + @AuthenticationPrincipal User principal, + @PathVariable Long orderId + ) { + PaymentDetailResponse detail = paymentService.getPaymentDetailByOrder(principal, orderId); + return ResponseEntity.ok(detail); + } + + // ๋‚ด๊ฐ€ ์“ด ๊ธ€ ์กฐํšŒ (๊ฒ€์ƒ‰๊ธฐ๋Šฅ + ์ •๋ ฌ) + @GetMapping("/posts") + public PageResponse myPosts( + @AuthenticationPrincipal User principal, + @RequestParam(required = false) String query, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + var pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + return PageResponse.of(service.getMyPosts(principal.getUserId(), query, pageable)); + } + + // ๋‚ด๊ฐ€ ์“ด ๋Œ“๊ธ€ ์กฐํšŒ (๊ฒ€์ƒ‰ + ์ •๋ ฌ) + @GetMapping("/comments") + public PageResponse myComments( + @AuthenticationPrincipal User principal, + @RequestParam(required = false) String query, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + var pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + return PageResponse.of(service.getMyComments(principal.getUserId(), query, pageable)); + } + // ๋‚ด๊ฐ€ ์ฐœํ•œ ์ƒํ’ˆ ์กฐํšŒ + @GetMapping("/favorites") + public PageResponse myFavorites( + @AuthenticationPrincipal User principal, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + var pageable = PageRequest.of(page, size); + return PageResponse.of(service.getMyFavorites(principal.getUserId(), pageable)); + } + + // ๋‚ด๊ฐ€ ์ž‘์„ฑํ•œ ๋ฆฌ๋ทฐ ์กฐํšŒ + @GetMapping("/reviews") + public PageResponse myReviews( + @AuthenticationPrincipal User principal, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + var pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + return PageResponse.of(service.getMyReviewsV2(principal.getUserId(), pageable)); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/mypage/dto/FavoriteProductItem.java b/backend/src/main/java/com/mysite/knitly/domain/mypage/dto/FavoriteProductItem.java new file mode 100644 index 0000000..56d7560 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/mypage/dto/FavoriteProductItem.java @@ -0,0 +1,14 @@ +package com.mysite.knitly.domain.mypage.dto; + +import java.time.LocalDate; +import java.math.BigDecimal; + + +// ๋งˆ์ดํŽ˜์ด์ง€ - ๋‚ด๊ฐ€ ์ฐœํ•œ ์ƒํ’ˆ ์กฐํšŒ + +public record FavoriteProductItem( + Long productId, + String productTitle, + String sellerName, // (ํŒ๋งค์ž) + String thumbnailUrl +) {} diff --git a/backend/src/main/java/com/mysite/knitly/domain/mypage/dto/MyCommentListItem.java b/backend/src/main/java/com/mysite/knitly/domain/mypage/dto/MyCommentListItem.java new file mode 100644 index 0000000..76b2c56 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/mypage/dto/MyCommentListItem.java @@ -0,0 +1,10 @@ +package com.mysite.knitly.domain.mypage.dto; + +import java.time.LocalDate; + +public record MyCommentListItem( + Long commentId, + Long postId, + LocalDate createdDate, + String preview +) {} diff --git a/backend/src/main/java/com/mysite/knitly/domain/mypage/dto/MyPostListItemResponse.java b/backend/src/main/java/com/mysite/knitly/domain/mypage/dto/MyPostListItemResponse.java new file mode 100644 index 0000000..04ca88d --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/mypage/dto/MyPostListItemResponse.java @@ -0,0 +1,12 @@ +package com.mysite.knitly.domain.mypage.dto; + +import java.time.LocalDateTime; + +public record MyPostListItemResponse( + Long id, + String title, + String excerpt, + String thumbnailUrl, + + LocalDateTime createdAt +) {} diff --git a/backend/src/main/java/com/mysite/knitly/domain/mypage/dto/OrderCardResponse.java b/backend/src/main/java/com/mysite/knitly/domain/mypage/dto/OrderCardResponse.java new file mode 100644 index 0000000..a2a0b16 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/mypage/dto/OrderCardResponse.java @@ -0,0 +1,16 @@ +package com.mysite.knitly.domain.mypage.dto; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +public record OrderCardResponse( + Long orderId, + LocalDateTime orderedAt, + Double totalPrice, + List items +) { + public static OrderCardResponse of(Long orderId, LocalDateTime orderedAt, Double totalPrice) { + return new OrderCardResponse(orderId, orderedAt, totalPrice, new ArrayList<>()); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/mypage/dto/OrderLine.java b/backend/src/main/java/com/mysite/knitly/domain/mypage/dto/OrderLine.java new file mode 100644 index 0000000..9655b90 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/mypage/dto/OrderLine.java @@ -0,0 +1,10 @@ +package com.mysite.knitly.domain.mypage.dto; + +public record OrderLine( + Long orderItemId, + Long productId, + String productTitle, + int quantity, + Double orderPrice, + boolean isReviewed +) {} diff --git a/backend/src/main/java/com/mysite/knitly/domain/mypage/dto/PageResponse.java b/backend/src/main/java/com/mysite/knitly/domain/mypage/dto/PageResponse.java new file mode 100644 index 0000000..0e9a41c --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/mypage/dto/PageResponse.java @@ -0,0 +1,26 @@ +package com.mysite.knitly.domain.mypage.dto; + +import org.springframework.data.domain.Page; +import java.util.List; + +public record PageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages, + boolean first, + boolean last +) { + public static PageResponse of(Page p) { + return new PageResponse<>( + p.getContent(), + p.getNumber(), + p.getSize(), + p.getTotalElements(), + p.getTotalPages(), + p.isFirst(), + p.isLast() + ); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/mypage/dto/ProfileResponse.java b/backend/src/main/java/com/mysite/knitly/domain/mypage/dto/ProfileResponse.java new file mode 100644 index 0000000..344afaf --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/mypage/dto/ProfileResponse.java @@ -0,0 +1,3 @@ +package com.mysite.knitly.domain.mypage.dto; + +public record ProfileResponse(String name, String email) {} diff --git a/backend/src/main/java/com/mysite/knitly/domain/mypage/dto/ReviewListItem.java b/backend/src/main/java/com/mysite/knitly/domain/mypage/dto/ReviewListItem.java new file mode 100644 index 0000000..3bd2f4d --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/mypage/dto/ReviewListItem.java @@ -0,0 +1,15 @@ +package com.mysite.knitly.domain.mypage.dto; + +import java.time.LocalDate; +import java.util.List; + +public record ReviewListItem( + Long reviewId, + Long productId, + String productTitle, + String productThumbnailUrl, + Integer rating, + String content, + List reviewImageUrls,// ํ”„๋ก ํŠธ์—์„œ ์ ‘๊ธฐ/ํŽผ์น˜๊ธฐ + LocalDate createdDate +) {} diff --git a/backend/src/main/java/com/mysite/knitly/domain/mypage/repository/MyPageQueryRepository.java b/backend/src/main/java/com/mysite/knitly/domain/mypage/repository/MyPageQueryRepository.java new file mode 100644 index 0000000..bc9c31e --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/mypage/repository/MyPageQueryRepository.java @@ -0,0 +1,190 @@ +package com.mysite.knitly.domain.mypage.repository; + +import com.mysite.knitly.domain.mypage.dto.*; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Repository +@RequiredArgsConstructor +public class MyPageQueryRepository { + + private final EntityManager em; + + // ์ฃผ๋ฌธ ๋‚ด์—ญ ์กฐํšŒ + public Page findOrderCards(Long userId, Pageable pageable) { + String jpql = """ + select o.orderId, o.createdAt, o.totalPrice, + p.productId, p.title, + oi.quantity, oi.orderPrice, + oi.orderItemId + from Order o + join o.orderItems oi + join oi.product p + where o.user.userId = :uid + order by o.createdAt desc + """; + + List rows = em.createQuery(jpql, Object[].class) + .setParameter("uid", userId) + .setFirstResult((int) pageable.getOffset()) + .setMaxResults(pageable.getPageSize()) + .getResultList(); + + long total = em.createQuery("select count(o) from Order o where o.user.userId = :uid", Long.class) + .setParameter("uid", userId) + .getSingleResult(); + + if (rows.isEmpty()) { + return new PageImpl<>(List.of(), pageable, total); + } + + Set productIdsInOrders = rows.stream() + .map(r -> (Long) r[3]) + .collect(Collectors.toSet()); + + Set orderItemIds = rows.stream() + .map(r -> (Long) r[7]) + .collect(Collectors.toSet()); + + Set reviewedOrderItemIds = new HashSet<>(em.createQuery(""" + select r.orderItem.orderItemId from Review r + where r.user.userId = :userId + and r.orderItem.orderItemId in :orderItemIds + """, Long.class) + .setParameter("userId", userId) + .setParameter("orderItemIds", orderItemIds) + .getResultList()); + + Map orderedAtMap = new LinkedHashMap<>(); + Map totalMap = new LinkedHashMap<>(); + Map> itemsMap = new LinkedHashMap<>(); + + for (Object[] r : rows) { + Long oId = (Long) r[0]; + LocalDateTime orderedAt = (LocalDateTime) r[1]; + Double totalPrice = (r[2] == null) ? 0d : ((Number) r[2]).doubleValue(); + Long productId = (Long) r[3]; + String productTitle = (String) r[4]; + Integer quantity = (Integer) r[5]; + Double orderPrice = (r[6] == null) ? 0d : ((Number) r[6]).doubleValue(); + Long orderItemId = (Long) r[7]; + + orderedAtMap.putIfAbsent(oId, orderedAt); + totalMap.putIfAbsent(oId, totalPrice); + + boolean isReviewed = reviewedOrderItemIds.contains(orderItemId); + + itemsMap.computeIfAbsent(oId, k -> new ArrayList<>()) + .add(new OrderLine(orderItemId, productId, productTitle, quantity, orderPrice, isReviewed)); + } + + List cards = new ArrayList<>(); + for (Long id : itemsMap.keySet()) { + cards.add(new OrderCardResponse( + id, + orderedAtMap.get(id), + totalMap.get(id), + Collections.unmodifiableList(itemsMap.get(id)) + )); + } + + return new PageImpl<>(cards, pageable, total); + } + + // โœ… ์ˆ˜์ •: ๋‚ด๊ฐ€ ์“ด ๊ธ€ ์กฐํšŒ - CAST๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ CLOB โ†’ VARCHAR ๋ณ€ํ™˜ + public Page findMyPosts(Long userId, String query, Pageable pageable) { + String base = """ + SELECT new com.mysite.knitly.domain.mypage.dto.MyPostListItemResponse( + p.id, + p.title, + CASE WHEN LENGTH(CAST(p.content AS string)) > 10 + THEN CONCAT(SUBSTRING(CAST(p.content AS string), 1, 10), '...') + ELSE CAST(p.content AS string) + END, + (SELECT MIN(i) FROM Post p2 JOIN p2.imageUrls i WHERE p2 = p), + p.createdAt + ) + FROM Post p + WHERE p.author.userId = :uid + AND p.deleted = false + """; + + if (query != null && !query.isBlank()) { + base += " AND (LOWER(p.title) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(CAST(p.content AS string)) LIKE LOWER(CONCAT('%', :q, '%')))"; + } + + var q = em.createQuery(base + " ORDER BY p.createdAt DESC", MyPostListItemResponse.class) + .setParameter("uid", userId); + + if (query != null && !query.isBlank()) { + q.setParameter("q", query.trim()); + } + + List list = q + .setFirstResult((int) pageable.getOffset()) + .setMaxResults(pageable.getPageSize()) + .getResultList(); + + long total = em.createQuery(""" + SELECT COUNT(p.id) + FROM Post p + WHERE p.author.userId = :uid AND p.deleted = false + """, Long.class) + .setParameter("uid", userId) + .getSingleResult(); + + return new PageImpl<>(list, pageable, total); + } + + // โœ… ์ตœ์ข… ์ˆ˜์ •: ๋‚ด๊ฐ€ ์“ด ๋Œ“๊ธ€ ์กฐํšŒ - LocalDate๋กœ CAST + public Page findMyComments(Long userId, String query, Pageable pageable) { + String base = """ + SELECT new com.mysite.knitly.domain.mypage.dto.MyCommentListItem( + c.id, + c.post.id, + CAST(c.createdAt AS LocalDate), + CASE WHEN LENGTH(CAST(c.content AS string)) > 30 + THEN CONCAT(SUBSTRING(CAST(c.content AS string), 1, 30), '...') + ELSE CAST(c.content AS string) + END + ) + FROM Comment c + WHERE c.author.userId = :uid + AND c.deleted = false + """; + + if (query != null && !query.isBlank()) { + base += " AND LOWER(CAST(c.content AS string)) LIKE LOWER(CONCAT('%', :q, '%'))"; + } + + var q = em.createQuery(base + " ORDER BY c.createdAt DESC", MyCommentListItem.class) + .setParameter("uid", userId); + + if (query != null && !query.isBlank()) { + q.setParameter("q", query.trim()); + } + + List list = q + .setFirstResult((int) pageable.getOffset()) + .setMaxResults(pageable.getPageSize()) + .getResultList(); + + long total = em.createQuery(""" + SELECT COUNT(c.id) + FROM Comment c + WHERE c.author.userId = :uid AND c.deleted = false + """, Long.class) + .setParameter("uid", userId) + .getSingleResult(); + + return new PageImpl<>(list, pageable, total); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/mypage/service/MyPageService.java b/backend/src/main/java/com/mysite/knitly/domain/mypage/service/MyPageService.java new file mode 100644 index 0000000..2333043 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/mypage/service/MyPageService.java @@ -0,0 +1,96 @@ +package com.mysite.knitly.domain.mypage.service; + +import com.mysite.knitly.domain.mypage.dto.*; +import com.mysite.knitly.domain.mypage.repository.MyPageQueryRepository; +import com.mysite.knitly.domain.product.like.entity.ProductLike; +import com.mysite.knitly.domain.product.like.repository.ProductLikeRepository; +import com.mysite.knitly.domain.product.review.entity.Review; +import com.mysite.knitly.domain.product.review.repository.ReviewRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class MyPageService { + + private final MyPageQueryRepository repo; + private final ReviewRepository reviewRepository; + private final ProductLikeRepository productLikeRepository; + + // ์ฃผ๋ฌธ ๋‚ด์—ญ ์กฐํšŒ + @Transactional(readOnly = true) + public Page getOrderCards(Long userId, Pageable pageable) { + return repo.findOrderCards(userId, pageable); + } + + // ๋‚ด๊ฐ€ ์“ด ๊ธ€ ์กฐํšŒ + @Transactional(readOnly = true) + public Page getMyPosts(Long userId, String query, Pageable pageable) { + return repo.findMyPosts(userId, query, pageable); + } + + // ๋‚ด๊ฐ€ ์“ด ๋Œ“๊ธ€ ์กฐํšŒ + @Transactional(readOnly = true) + public Page getMyComments(Long userId, String query, Pageable pageable) { + return repo.findMyComments(userId, query, pageable); + } + + // ๋‚ด๊ฐ€ ์ฐœํ•œ ์ƒํ’ˆ ์กฐํšŒ + @Transactional(readOnly = true) + public Page getMyFavorites(Long userId, Pageable pageable) { + return productLikeRepository.findByUser_UserId(userId, pageable) + .map(this::convertToDto); + } + + private FavoriteProductItem convertToDto(ProductLike pl) { + var p = pl.getProduct(); + + String thumbnailUrl = p.getProductImages().isEmpty() + ? null + : p.getProductImages().get(0).getProductImageUrl(); // ์ฒซ ๋ฒˆ์งธ ์ด๋ฏธ์ง€ + + return new FavoriteProductItem( + p.getProductId(), + p.getTitle(), + p.getUser().getName(), + thumbnailUrl + ); + } + + // ์‚ฌ์šฉ์ž๊ฐ€ ์ž‘์„ฑํ•œ ๋ฆฌ๋ทฐ ์กฐํšŒ + @Transactional(readOnly = true) + public Page getMyReviewsV2(Long userId, Pageable pageable) { + List reviews = reviewRepository.findByUser_UserIdAndIsDeletedFalse(userId, pageable); + + List list = reviews.stream().map(r -> { + var product = r.getProduct(); + String productThumbnail = product.getProductImages().isEmpty() ? null : + product.getProductImages().get(0).getProductImageUrl(); + + List reviewImages = r.getReviewImages().stream() + .map(ri -> ri.getReviewImageUrl()) + .toList(); + + return new ReviewListItem( + r.getReviewId(), + product.getProductId(), + product.getTitle(), + productThumbnail, + r.getRating(), + r.getContent(), + reviewImages, + r.getCreatedAt().toLocalDate() + ); + }).toList(); + + long total = reviewRepository.countByUser_UserIdAndIsDeletedFalse(userId); + + return new PageImpl<>(list, pageable, total); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/order/controller/OrderController.java b/backend/src/main/java/com/mysite/knitly/domain/order/controller/OrderController.java new file mode 100644 index 0000000..9cabb38 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/order/controller/OrderController.java @@ -0,0 +1,31 @@ +package com.mysite.knitly.domain.order.controller; + +import com.mysite.knitly.domain.order.dto.OrderCreateRequest; +import com.mysite.knitly.domain.order.dto.OrderCreateResponse; +import com.mysite.knitly.domain.order.service.OrderFacade; +import com.mysite.knitly.domain.order.service.OrderService; +import com.mysite.knitly.domain.user.entity.User; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/orders") +public class OrderController { + + private final OrderFacade orderFacade; + @PostMapping + public ResponseEntity createOrder( + @AuthenticationPrincipal User user, + @RequestBody @Valid OrderCreateRequest request + ) { + // Facade์˜ ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•˜๋„๋ก ๋ณ€๊ฒฝ + OrderCreateResponse response = orderFacade.createOrderWithLock(user, request); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/order/dto/EmailNotificationDto.java b/backend/src/main/java/com/mysite/knitly/domain/order/dto/EmailNotificationDto.java new file mode 100644 index 0000000..080049f --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/order/dto/EmailNotificationDto.java @@ -0,0 +1,8 @@ +package com.mysite.knitly.domain.order.dto; + +public record EmailNotificationDto( + Long orderId, + Long userId, + String userEmail +) { +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/order/dto/OrderCreateRequest.java b/backend/src/main/java/com/mysite/knitly/domain/order/dto/OrderCreateRequest.java new file mode 100644 index 0000000..d12c66e --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/order/dto/OrderCreateRequest.java @@ -0,0 +1,8 @@ +package com.mysite.knitly.domain.order.dto; + +import java.util.List; + +public record OrderCreateRequest( + List productIds +) { +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/order/dto/OrderCreateResponse.java b/backend/src/main/java/com/mysite/knitly/domain/order/dto/OrderCreateResponse.java new file mode 100644 index 0000000..61813e1 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/order/dto/OrderCreateResponse.java @@ -0,0 +1,43 @@ +package com.mysite.knitly.domain.order.dto; + +import com.mysite.knitly.domain.order.entity.Order; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +public record OrderCreateResponse( + Long orderId, + String tossOrderId, // ํ† ์ŠคํŽ˜์ด๋จผ์ธ ์šฉ ์ฃผ๋ฌธ๋ฒˆํ˜ธ ์ถ”๊ฐ€ + LocalDateTime orderDate, + Double totalPrice, + List orderItems +) { + public static OrderCreateResponse from(Order order) { + List itemInfos = order.getOrderItems().stream() + .map(OrderItemInfo::from) + .collect(Collectors.toList()); + + return new OrderCreateResponse( + order.getOrderId(), + order.getTossOrderId(), + order.getCreatedAt(), + order.getTotalPrice(), + itemInfos + ); + } + + // ์ฃผ๋ฌธ์— ํฌํ•จ๋œ ๊ฐœ๋ณ„ ์ƒํ’ˆ ์ •๋ณด๋ฅผ ๋‹ด์„ ๋‚ด๋ถ€ ๋ ˆ์ฝ”๋“œ + public record OrderItemInfo( + Long productId, + String title, + Double orderPrice + ) { + public static OrderItemInfo from(com.mysite.knitly.domain.order.entity.OrderItem orderItem) { + return new OrderItemInfo( + orderItem.getProduct().getProductId(), + orderItem.getProduct().getTitle(), + orderItem.getOrderPrice() + ); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/order/entity/Order.java b/backend/src/main/java/com/mysite/knitly/domain/order/entity/Order.java new file mode 100644 index 0000000..b60c314 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/order/entity/Order.java @@ -0,0 +1,106 @@ +package com.mysite.knitly.domain.order.entity; + +import com.mysite.knitly.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "orders") +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class Order { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long orderId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; // ๊ตฌ๋งค์ž + + @CreatedDate + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private Double totalPrice; + + // ํ† ์ŠคํŽ˜์ด๋จผ์ธ  orderId (์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, -, _ ๋งŒ ํ—ˆ์šฉ, 6์ž ์ด์ƒ 64์ž ์ดํ•˜) + @Column(nullable = false, unique = true, length = 64) + private String tossOrderId; + + // Order๊ฐ€ ์ €์žฅ๋  ๋•Œ OrderItem๋„ ํ•จ๊ป˜ ์ €์žฅ๋˜๋„๋ก Cascade ์„ค์ • + // ophanRemoval : OrderItem์ด Order์—์„œ ์ œ๊ฑฐ๋˜๋ฉด DB์—์„œ๋„ ์‚ญ์ œ + @Builder.Default + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) + private List orderItems = new ArrayList<>(); + + @Builder + public Order(User user, Double totalPrice, String tossOrderId) { + this.user = user; + this.totalPrice = totalPrice; + this.tossOrderId = tossOrderId; + } + + //== ์ƒ์„ฑ ๋ฉ”์„œ๋“œ ==// + public static Order create(User user, List orderItems) { + Order order = new Order(); + order.user = user; // ์‚ฌ์šฉ์ž ์ •๋ณด ์„ค์ • + + // ํ† ์ŠคํŽ˜์ด๋จผ์ธ  orderId ์ƒ์„ฑ (UUID ๊ธฐ๋ฐ˜) + order.tossOrderId = generateTossOrderId(); + + // ๋ชจ๋“  ์ฃผ๋ฌธ ์ƒํ’ˆ์„ ์ถ”๊ฐ€ํ•˜๊ณ  ์ด์•ก ๊ณ„์‚ฐ + double totalPrice = 0.0; + for (OrderItem orderItem : orderItems) { + order.addOrderItem(orderItem); + totalPrice += orderItem.getOrderPrice(); + } + order.totalPrice = totalPrice; + + return order; + } + + //== ์—ฐ๊ด€๊ด€๊ณ„ ํŽธ์˜ ๋ฉ”์„œ๋“œ ==// + public void addOrderItem(OrderItem orderItem) { + orderItems.add(orderItem); + orderItem.setOrder(this); // ์–‘๋ฐฉํ–ฅ ๊ด€๊ณ„ ์„ค์ • + } + + /** + * ํ† ์ŠคํŽ˜์ด๋จผ์ธ  orderId ์ƒ์„ฑ + * ๊ทœ์น™: ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž -, _ ๋กœ ์ด๋ฃจ์–ด์ง„ 6์ž ์ด์ƒ 64์ž ์ดํ•˜ + * UUID ๊ธฐ๋ฐ˜์œผ๋กœ ์ƒ์„ฑํ•˜์—ฌ ๊ณ ์œ ์„ฑ ๋ณด์žฅ + */ + private static String generateTossOrderId() { + // UUID ์ƒ์„ฑ (ํ•˜์ดํ”ˆ ํฌํ•จ 36์ž, ํ† ์ŠคํŽ˜์ด๋จผ์ธ  orderId ๊ทœ์น™์— ๋ถ€ํ•ฉ) + String uuid = UUID.randomUUID().toString(); + return uuid; + } +} + +/* +CREATE TABLE `orders` ( + `order_id` BIGINT NOT NULL AUTO_INCREMENT, + `user_id` BINARY(16) NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `total_price` DECIMAL(10, 2) NOT NULL, -- ์ฃผ๋ฌธ ์ด์•ก + `toss_order_id` VARCHAR(64) NOT NULL UNIQUE, -- ํ† ์ŠคํŽ˜์ด๋จผ์ธ  ์ฃผ๋ฌธ๋ฒˆํ˜ธ + PRIMARY KEY (`order_id`) + INDEX idx_toss_order_id (`toss_order_id`) + +); +*/ \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/order/entity/OrderItem.java b/backend/src/main/java/com/mysite/knitly/domain/order/entity/OrderItem.java new file mode 100644 index 0000000..5556925 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/order/entity/OrderItem.java @@ -0,0 +1,59 @@ +package com.mysite.knitly.domain.order.entity; + +import com.mysite.knitly.domain.product.product.entity.Product; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "order_items") +public class OrderItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long orderItemId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id") + private Order order; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id") + private Product product; + + @Column(nullable = false) + private Double orderPrice; // ์ฃผ๋ฌธ ๋‹น์‹œ์˜ ์ƒํ’ˆ ๊ฐ€๊ฒฉ + + @Column(nullable = false) + private int quantity; // ์ˆ˜๋Ÿ‰ (ํ•ญ์ƒ 1) + + @Builder + public OrderItem(Order order, Product product, Double orderPrice, int quantity) { + this.order = order; + this.product = product; + this.orderPrice = orderPrice; + this.quantity = quantity; + } + + //== ์—ฐ๊ด€๊ด€๊ณ„ ํŽธ์˜ ๋ฉ”์„œ๋“œ์šฉ Setter ==// + public void setOrder(Order order) { + this.order = order; + } +} + +/* +CREATE TABLE `order_items` ( + `order_item_id` BIGINT NOT NULL AUTO_INCREMENT, + `order_id` BIGINT NOT NULL, + `product_id` BIGINT NOT NULL, + `order_price` DECIMAL(10, 2) NOT NULL, -- ์ฃผ๋ฌธ ๋‹น์‹œ์˜ ๊ฐœ๋ณ„ ์ƒํ’ˆ ๊ฐ€๊ฒฉ + `quantity` INT NOT NULL, -- ์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰ (ํ•ญ์ƒ 1) +PRIMARY KEY (`order_item_id`), +FOREIGN KEY (`order_id`) REFERENCES `orders` (`order_id`), +FOREIGN KEY (`product_id`) REFERENCES `products` (`product_id`) +); +*/ diff --git a/backend/src/main/java/com/mysite/knitly/domain/order/repository/OrderItemRepository.java b/backend/src/main/java/com/mysite/knitly/domain/order/repository/OrderItemRepository.java new file mode 100644 index 0000000..2eb53e3 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/order/repository/OrderItemRepository.java @@ -0,0 +1,9 @@ +package com.mysite.knitly.domain.order.repository; + +import com.mysite.knitly.domain.order.entity.OrderItem; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface OrderItemRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/order/repository/OrderRepository.java b/backend/src/main/java/com/mysite/knitly/domain/order/repository/OrderRepository.java new file mode 100644 index 0000000..44b5ac9 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/order/repository/OrderRepository.java @@ -0,0 +1,23 @@ +package com.mysite.knitly.domain.order.repository; + +import com.mysite.knitly.domain.order.entity.Order; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface OrderRepository extends JpaRepository { + + // โœ… ์ถ”๊ฐ€: ์ด๋ฉ”์ผ ๋ฐœ์†ก ์‹œ ์‚ฌ์šฉํ•  ์ƒ์„ธ ์ •๋ณด ์กฐํšŒ ์ฟผ๋ฆฌ + @Query("SELECT o FROM Order o " + + "JOIN FETCH o.user u " + + "JOIN FETCH o.orderItems oi " + + "JOIN FETCH oi.product p " + + "JOIN FETCH p.design d " + + "WHERE o.orderId = :orderId") + Optional findOrderWithDetailsById(@Param("orderId") Long orderId); + + // tossOrderId๋กœ ์ฃผ๋ฌธ ์กฐํšŒ (๊ฒฐ์ œ ์Šน์ธ ์‹œ ์‚ฌ์šฉ) + Optional findByTossOrderId(String tossOrderId); +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/order/service/OrderFacade.java b/backend/src/main/java/com/mysite/knitly/domain/order/service/OrderFacade.java new file mode 100644 index 0000000..642cbc7 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/order/service/OrderFacade.java @@ -0,0 +1,61 @@ +package com.mysite.knitly.domain.order.service; + +import com.mysite.knitly.domain.order.dto.OrderCreateRequest; +import com.mysite.knitly.domain.order.dto.OrderCreateResponse; +import com.mysite.knitly.domain.order.entity.Order; +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.global.lock.RedisLockService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class OrderFacade { + + private final RedisLockService redisLockService; + private final OrderService orderService; + + public OrderCreateResponse createOrderWithLock(User user, OrderCreateRequest request) { + // ๋ฝ ํ‚ค๋Š” ์ฒซ ๋ฒˆ์งธ ์ƒํ’ˆ ID๋ฅผ ๊ธฐ์ค€์œผ๋กœ ๋‹จ์ˆœํ•˜๊ฒŒ ์ƒ์„ฑ (ํ˜น์€ ๋ชจ๋“  ID ์กฐํ•ฉ) + String lockKey = generateCompositeLockKey(request.productIds()); + + long startTime = System.currentTimeMillis(); + long waitTimeMillis = 2000; // ์ตœ๋Œ€ 2์ดˆ ๋Œ€๊ธฐ + + while (!redisLockService.tryLock(lockKey)) { + // ํ˜„์žฌ ์‹œ๊ฐ„๊ณผ ์‹œ์ž‘ ์‹œ๊ฐ„์„ ๋น„๊ตํ•˜์—ฌ ๋Œ€๊ธฐ ์‹œ๊ฐ„์„ ์ดˆ๊ณผํ–ˆ๋Š”์ง€ ํ™•์ธ + if (System.currentTimeMillis() - startTime > waitTimeMillis) { + // ๋Œ€๊ธฐ ์‹œ๊ฐ„์„ ์ดˆ๊ณผํ•˜๋ฉด ์˜ˆ์™ธ๋ฅผ ๋ฐœ์ƒ์‹œ์ผœ ์‹คํŒจ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค. + throw new RuntimeException("Lock acquisition timed out for key: " + lockKey); + } + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("๋ฝ ๋Œ€๊ธฐ ์ค‘ ์ธํ„ฐ๋ŸฝํŠธ ๋ฐœ์ƒ", e); + } + } + + try { + Order createdOrder = orderService.createOrder(user, request.productIds()); + return OrderCreateResponse.from(createdOrder); + } finally { + redisLockService.unlock(lockKey); + } + } + + /** + * ์ƒํ’ˆ ID ๋ฆฌ์ŠคํŠธ๋ฅผ ์ •๋ ฌํ•˜์—ฌ ๋ณตํ•ฉ ๋ฝ ํ‚ค๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. + * ์ •๋ ฌํ•˜๋Š” ์ด์œ : [A, B]์™€ [B, A]์— ๋Œ€ํ•ด ๋™์ผํ•œ ๋ฝ ํ‚ค๋ฅผ ๋ณด์žฅํ•˜์—ฌ ๋ฐ๋“œ๋ฝ์„ ๋ฐฉ์ง€ํ•ฉ๋‹ˆ๋‹ค. + */ + private String generateCompositeLockKey(List productIds) { + String sortedIds = productIds.stream() + .sorted() + .map(String::valueOf) + .collect(Collectors.joining(":")); + return "order_lock:" + sortedIds; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/order/service/OrderService.java b/backend/src/main/java/com/mysite/knitly/domain/order/service/OrderService.java new file mode 100644 index 0000000..4bb29d9 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/order/service/OrderService.java @@ -0,0 +1,52 @@ +package com.mysite.knitly.domain.order.service; + +import com.mysite.knitly.domain.order.entity.Order; +import com.mysite.knitly.domain.order.entity.OrderItem; +import com.mysite.knitly.domain.order.repository.OrderRepository; +import com.mysite.knitly.domain.product.product.entity.Product; +import com.mysite.knitly.domain.product.product.repository.ProductRepository; +import com.mysite.knitly.domain.user.entity.User; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + + +@Service +@RequiredArgsConstructor +public class OrderService { + + private final ProductRepository productRepository; + private final OrderRepository orderRepository; + + // Facade์—์„œ๋งŒ ํ˜ธ์ถœ๋  ํ•ต์‹ฌ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง + @Transactional + public Order createOrder(User user, List productIds) { +// 1. ์š”์ฒญ๋œ ์ƒํ’ˆ ID ๋ฆฌ์ŠคํŠธ๋กœ ๋ชจ๋“  Product ์—”ํ‹ฐํ‹ฐ๋ฅผ ์กฐํšŒ + List products = productRepository.findAllById(productIds); + if (products.size() != productIds.size()) { + throw new EntityNotFoundException("์ผ๋ถ€ ์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + // 2. ๊ฐ Product์— ๋Œ€ํ•ด OrderItem์„ ๋นŒ๋”๋กœ ์ƒ์„ฑํ•˜๊ณ , ์žฌ๊ณ ๋ฅผ ์ง์ ‘ ๊ฐ์†Œ์‹œํ‚ด + List orderItems = products.stream() + .map(product -> { + product.decreaseStock(1); // ์žฌ๊ณ  ๊ฐ์†Œ (์ˆ˜๋Ÿ‰์€ 1๋กœ ๊ฐ€์ •) + return OrderItem.builder() + .product(product) + .orderPrice(product.getPrice()) + .quantity(1) + .build(); + }) + .collect(Collectors.toList()); + + // 3. Order ์—”ํ‹ฐํ‹ฐ ์ƒ์„ฑ + Order order = Order.create(user, orderItems); + + // 4. Order ์ €์žฅ (OrderItem์€ CascadeType.ALL์— ์˜ํ•ด ํ•จ๊ป˜ ์ €์žฅ๋จ) + return orderRepository.save(order); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/payment/controller/PaymentController.java b/backend/src/main/java/com/mysite/knitly/domain/payment/controller/PaymentController.java new file mode 100644 index 0000000..da54ba4 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/payment/controller/PaymentController.java @@ -0,0 +1,37 @@ +package com.mysite.knitly.domain.payment.controller; + +import com.mysite.knitly.domain.payment.dto.PaymentCancelRequest; +import com.mysite.knitly.domain.payment.dto.PaymentCancelResponse; +import com.mysite.knitly.domain.payment.dto.PaymentConfirmRequest; +import com.mysite.knitly.domain.payment.dto.PaymentConfirmResponse; +import com.mysite.knitly.domain.payment.service.PaymentService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/payments") +public class PaymentController { + private final PaymentService paymentService; + + //ํ† ์ŠคํŽ˜์ด๋จผ์ธ  ๊ฒฐ์ œ ์Šน์ธ + @PostMapping("/confirm") + public ResponseEntity confirmPayment( + @RequestBody @Valid PaymentConfirmRequest request + ) { + PaymentConfirmResponse response = paymentService.confirmPayment(request); + return ResponseEntity.ok(response); + } + + // ๊ฒฐ์ œ ์ทจ์†Œ + @PostMapping("/{paymentId}/cancel") + public ResponseEntity cancelPayment( + @PathVariable Long paymentId, + @RequestBody @Valid PaymentCancelRequest request + ) { + PaymentCancelResponse response = paymentService.cancelPayment(paymentId, request); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/payment/dto/PaymentCancelRequest.java b/backend/src/main/java/com/mysite/knitly/domain/payment/dto/PaymentCancelRequest.java new file mode 100644 index 0000000..fb559da --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/payment/dto/PaymentCancelRequest.java @@ -0,0 +1,9 @@ +package com.mysite.knitly.domain.payment.dto; + +import jakarta.validation.constraints.NotBlank; + +public record PaymentCancelRequest( + @NotBlank(message = "์ทจ์†Œ ์‚ฌ์œ ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + String cancelReason +) { +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/payment/dto/PaymentCancelResponse.java b/backend/src/main/java/com/mysite/knitly/domain/payment/dto/PaymentCancelResponse.java new file mode 100644 index 0000000..20dda2f --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/payment/dto/PaymentCancelResponse.java @@ -0,0 +1,20 @@ +package com.mysite.knitly.domain.payment.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.mysite.knitly.domain.payment.entity.PaymentStatus; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public record PaymentCancelResponse( + Long paymentId, + String paymentKey, + String orderId, + PaymentStatus status, + Long cancelAmount, + String cancelReason, + LocalDateTime canceledAt +) { +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/payment/dto/PaymentConfirmRequest.java b/backend/src/main/java/com/mysite/knitly/domain/payment/dto/PaymentConfirmRequest.java new file mode 100644 index 0000000..58ec1aa --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/payment/dto/PaymentConfirmRequest.java @@ -0,0 +1,14 @@ +package com.mysite.knitly.domain.payment.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record PaymentConfirmRequest ( + @NotBlank(message = "paymentKey๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + String paymentKey, + @NotBlank(message = "orderId๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + String orderId, + @NotNull(message = "amount๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + Long amount +) { +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/payment/dto/PaymentConfirmResponse.java b/backend/src/main/java/com/mysite/knitly/domain/payment/dto/PaymentConfirmResponse.java new file mode 100644 index 0000000..89717b6 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/payment/dto/PaymentConfirmResponse.java @@ -0,0 +1,68 @@ +package com.mysite.knitly.domain.payment.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.mysite.knitly.domain.payment.entity.Payment; +import com.mysite.knitly.domain.payment.entity.PaymentMethod; +import com.mysite.knitly.domain.payment.entity.PaymentStatus; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public record PaymentConfirmResponse( + Long paymentId, + String paymentKey, + String orderId, + String orderName, + PaymentMethod method, + Long totalAmount, + PaymentStatus status, + LocalDateTime requestedAt, + LocalDateTime approvedAt, + String mid, + CardInfo card, + VirtualAccountInfo virtualAccount, + EasyPayInfo easyPay +) { + + @Builder + public record CardInfo( + String company, + String number, + String installmentPlanMonths, + String approveNo, + String ownerType + ) {} + + @Builder + public record VirtualAccountInfo( + String accountNumber, + String bankCode, + String customerName, + LocalDateTime dueDate + ) {} + + @Builder + public record EasyPayInfo( + String provider, + Long amount + ) {} + + /** + * Payment ์—”ํ‹ฐํ‹ฐ๋กœ๋ถ€ํ„ฐ Response ์ƒ์„ฑ + */ + public static PaymentConfirmResponse from(Payment payment) { + return PaymentConfirmResponse.builder() + .paymentId(payment.getPaymentId()) + .paymentKey(payment.getTossPaymentKey()) + .orderId(payment.getTossOrderId()) + .method(payment.getPaymentMethod()) + .totalAmount(payment.getTotalAmount()) + .status(payment.getPaymentStatus()) + .requestedAt(payment.getRequestedAt()) + .approvedAt(payment.getApprovedAt()) + .mid(payment.getMid()) + .build(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/payment/dto/PaymentDetailResponse.java b/backend/src/main/java/com/mysite/knitly/domain/payment/dto/PaymentDetailResponse.java new file mode 100644 index 0000000..d828172 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/payment/dto/PaymentDetailResponse.java @@ -0,0 +1,48 @@ +package com.mysite.knitly.domain.payment.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.mysite.knitly.domain.payment.entity.Payment; +import com.mysite.knitly.domain.payment.entity.PaymentMethod; +import com.mysite.knitly.domain.payment.entity.PaymentStatus; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public record PaymentDetailResponse( + Long paymentId, + String paymentKey, + String orderId, + String orderName, + String mid, + PaymentMethod method, + Long totalAmount, + PaymentStatus status, + LocalDateTime requestedAt, + LocalDateTime approvedAt, + LocalDateTime canceledAt, + String cancelReason, + Long buyerId, + String buyerName +) { + + // Payment ์—”ํ‹ฐํ‹ฐ๋กœ๋ถ€ํ„ฐ ๊ธฐ๋ณธ ์ •๋ณด ์ƒ์„ฑ + public static PaymentDetailResponse from(Payment payment) { + return PaymentDetailResponse.builder() + .paymentId(payment.getPaymentId()) + .paymentKey(payment.getTossPaymentKey()) + .orderId(payment.getTossOrderId()) + .mid(payment.getMid()) + .method(payment.getPaymentMethod()) + .totalAmount(payment.getTotalAmount()) + .status(payment.getPaymentStatus()) + .requestedAt(payment.getRequestedAt()) + .approvedAt(payment.getApprovedAt()) + .canceledAt(payment.getCanceledAt()) + .cancelReason(payment.getCancelReason()) + .buyerId(payment.getBuyer().getUserId()) + .buyerName(payment.getBuyer().getName()) + .build(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/payment/entity/Payment.java b/backend/src/main/java/com/mysite/knitly/domain/payment/entity/Payment.java new file mode 100644 index 0000000..33583b2 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/payment/entity/Payment.java @@ -0,0 +1,102 @@ +package com.mysite.knitly.domain.payment.entity; + +import com.mysite.knitly.domain.order.entity.Order; +import com.mysite.knitly.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@Builder +@Table(name = "payments") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public class Payment { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long paymentId; + + @Column(nullable = false, unique = true) + private String tossPaymentKey; // ๊ฒฐ์ œ ๊ณ ์œ  ํ‚ค + + @Column(nullable = false) + private String tossOrderId; // ํ† ์Šค์—์„œ ๊ด€๋ฆฌํ•˜๋Š” ์ฃผ๋ฌธ ๋ฒˆํ˜ธ + + @Column(length=100) + private String mid; // ์ƒ์  id + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name= "order_id", nullable=false) + private Order order; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User buyer; // ๊ตฌ๋งค์ž = Order.user + + @Column(nullable = false) + private Long totalAmount; // ์ด ๊ฒฐ์ œ ๊ธˆ์•ก + + @Enumerated(value = EnumType.STRING) + @Column(nullable = false) + private PaymentMethod paymentMethod; // ๊ฒฐ์ œ ์ˆ˜๋‹จ + + @Enumerated(value = EnumType.STRING) + @Column(nullable = false) + private PaymentStatus paymentStatus; // ๊ฒฐ์ œ ์ƒํƒœ + + @CreatedDate + @Column(nullable = false, updatable = false) + private LocalDateTime requestedAt; // ๊ฒฐ์ œ ์š”์ฒญ ์‹œ๊ฐ„ + + @Column + private LocalDateTime approvedAt; // ๊ฒฐ์ œ ์Šน์ธ ์‹œ๊ฐ„ + + @Column + private LocalDateTime canceledAt; // ๊ฒฐ์ œ ์ทจ์†Œ ์‹œ๊ฐ„ + + @Column(length = 500) + private String cancelReason; // ์ทจ์†Œ ์‚ฌ์œ  (์ทจ์†Œ API์—์„œ ์‚ฌ์šฉ) + + + // ๊ฒฐ์ œ ์Šน์ธ ์ฒ˜๋ฆฌ + public void approve(LocalDateTime approvedAt) { + this.paymentStatus = PaymentStatus.DONE; + this.approvedAt = approvedAt; + } + + // ๊ฒฐ์ œ ์ทจ์†Œ ์ฒ˜๋ฆฌ + public void cancel(String cancelReason) { + if (!this.isCancelable()) { + throw new IllegalStateException("์ทจ์†Œํ•  ์ˆ˜ ์—†๋Š” ๊ฒฐ์ œ ์ƒํƒœ์ž…๋‹ˆ๋‹ค: " + this.paymentStatus); + } + this.paymentStatus = PaymentStatus.CANCELED; + this.canceledAt = LocalDateTime.now(); + this.cancelReason = cancelReason; + } + + // ๊ฒฐ์ œ ์‹คํŒจ ์ฒ˜๋ฆฌ + public void fail(String failureReason) { + this.paymentStatus = PaymentStatus.FAILED; + } + + // ๊ฐ€์ƒ๊ณ„์ขŒ ์ž…๊ธˆ ๋Œ€๊ธฐ ์ƒํƒœ๋กœ ๋ณ€๊ฒฝ + public void waitingForDeposit() { + this.paymentStatus = PaymentStatus.WAITING_FOR_DEPOSIT; + } + + // ๊ฒฐ์ œ ์™„๋ฃŒ ์—ฌ๋ถ€ ํ™•์ธ + public boolean isCompleted() { + return this.paymentStatus == PaymentStatus.DONE; + } + + // ์ทจ์†Œ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ํ™•์ธ + public boolean isCancelable() { + return this.paymentStatus.isCancelable(); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/payment/entity/PaymentMethod.java b/backend/src/main/java/com/mysite/knitly/domain/payment/entity/PaymentMethod.java new file mode 100644 index 0000000..a29ff02 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/payment/entity/PaymentMethod.java @@ -0,0 +1,20 @@ +package com.mysite.knitly.domain.payment.entity; + +public enum PaymentMethod { + // ์นด๋“œ, ๊ฐ€์ƒ๊ณ„์ขŒ, ๊ฐ„ํŽธ๊ฒฐ์ œ + CARD, VIRTUAL_ACCOUNT, EASY_PAY; + + // ํ† ์ŠคํŽ˜์ด๋จผ์ธ  API ์‘๋‹ต์˜ method ๊ฐ’์œผ๋กœ๋ถ€ํ„ฐ enum ๋ณ€ํ™˜ + public static PaymentMethod fromString(String method) { + if (method == null) { + throw new IllegalArgumentException("๊ฒฐ์ œ์ˆ˜๋‹จ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค."); + } + + return switch (method.toUpperCase()) { + case "CARD", "์นด๋“œ" -> CARD; + case "VIRTUALACCOUNT", "VIRTUAL_ACCOUNT", "๊ฐ€์ƒ๊ณ„์ขŒ" -> VIRTUAL_ACCOUNT; + case "EASYPAY", "EASY_PAY", "๊ฐ„ํŽธ๊ฒฐ์ œ" -> EASY_PAY; + default -> throw new IllegalArgumentException("์ง€์›ํ•˜์ง€ ์•Š๋Š” ๊ฒฐ์ œ์ˆ˜๋‹จ์ž…๋‹ˆ๋‹ค: " + method); + }; + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/payment/entity/PaymentStatus.java b/backend/src/main/java/com/mysite/knitly/domain/payment/entity/PaymentStatus.java new file mode 100644 index 0000000..a6abccd --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/payment/entity/PaymentStatus.java @@ -0,0 +1,39 @@ +package com.mysite.knitly.domain.payment.entity; + +public enum PaymentStatus { + // ๊ฒฐ์ œ ์ค€๋น„(์Šน์ธ ์ „), ๊ฒฐ์ œ ์ง„ํ–‰ ์ค‘(์ธ์ฆ ํ›„ ์Šน์ธ API ํ˜ธ์ถœ ์ „), ์ž…๊ธˆ ๋Œ€๊ธฐ ์ค‘(๊ฐ€์ƒ๊ณ„์ขŒ), ๊ฒฐ์ œ ์™„๋ฃŒ, ๊ฒฐ์ œ ์ทจ์†Œ, ๊ฒฐ์ œ ์Šน์ธ ์‹คํŒจ + READY, IN_PROGRESS, WAITING_FOR_DEPOSIT, DONE, CANCELED, FAILED; + + // ํ† ์ŠคํŽ˜์ด๋จผ์ธ  API ์‘๋‹ต์˜ status ๊ฐ’์œผ๋กœ๋ถ€ํ„ฐ enum ๋ณ€ํ™˜ + public static PaymentStatus fromString(String status) { + if (status == null) { + throw new IllegalArgumentException("๊ฒฐ์ œ ์ƒํƒœ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค."); + } + + return switch (status.toUpperCase()) { + case "READY" -> READY; + case "IN_PROGRESS" -> IN_PROGRESS; + case "WAITING_FOR_DEPOSIT" -> WAITING_FOR_DEPOSIT; + case "DONE" -> DONE; + case "CANCELED", "PARTIAL_CANCELED" -> CANCELED; // ๋ถ€๋ถ„์ทจ์†Œ๋„ ์ทจ์†Œ๋กœ ํ†ตํ•ฉ + case "ABORTED", "EXPIRED" -> FAILED; // ์ค‘๋‹จ/๋งŒ๋ฃŒ๋„ ์‹คํŒจ๋กœ ํ†ตํ•ฉ + case "FAILED" -> FAILED; + default -> throw new IllegalArgumentException("์ง€์›ํ•˜์ง€ ์•Š๋Š” ๊ฒฐ์ œ ์ƒํƒœ์ž…๋‹ˆ๋‹ค: " + status); + }; + } + + // ๊ฒฐ์ œ๊ฐ€ ์™„๋ฃŒ๋œ ์ƒํƒœ์ธ์ง€ ํ™•์ธ + public boolean isCompleted() { + return this == DONE; + } + + // ๊ฒฐ์ œ๋ฅผ ์ทจ์†Œํ•  ์ˆ˜ ์žˆ๋Š” ์ƒํƒœ์ธ์ง€ ํ™•์ธ + public boolean isCancelable() { + return this == DONE; + } + + // ๊ฒฐ์ œ๊ฐ€ ์ง„ํ–‰ ์ค‘์ธ ์ƒํƒœ์ธ์ง€ ํ™•์ธ + public boolean isInProgress() { + return this == IN_PROGRESS || this == WAITING_FOR_DEPOSIT; + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/payment/repository/PaymentRepository.java b/backend/src/main/java/com/mysite/knitly/domain/payment/repository/PaymentRepository.java new file mode 100644 index 0000000..7848030 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/payment/repository/PaymentRepository.java @@ -0,0 +1,12 @@ +package com.mysite.knitly.domain.payment.repository; + +import com.mysite.knitly.domain.payment.entity.Payment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface PaymentRepository extends JpaRepository { + + Optional findByOrder_OrderId(Long orderId); + Optional findByTossPaymentKey(String tossPaymentKey); +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/payment/service/PaymentService.java b/backend/src/main/java/com/mysite/knitly/domain/payment/service/PaymentService.java new file mode 100644 index 0000000..342055e --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/payment/service/PaymentService.java @@ -0,0 +1,294 @@ +package com.mysite.knitly.domain.payment.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mysite.knitly.domain.order.entity.Order; +import com.mysite.knitly.domain.order.repository.OrderRepository; +import com.mysite.knitly.domain.payment.dto.*; +import com.mysite.knitly.domain.payment.entity.Payment; +import com.mysite.knitly.domain.payment.entity.PaymentMethod; +import com.mysite.knitly.domain.payment.entity.PaymentStatus; +import com.mysite.knitly.domain.payment.repository.PaymentRepository; +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.global.exception.ErrorCode; +import com.mysite.knitly.global.exception.ServiceException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PaymentService { + + private final OrderRepository orderRepository; + private final PaymentRepository paymentRepository; + private final ObjectMapper objectMapper; + + @Value("${payment.toss.secret-key}") + private String tossSecretKey; + + private static final String TOSS_PAYMENT_CONFIRM_URL = "https://api.tosspayments.com/v1/payments/confirm"; + private static final String TOSS_PAYMENT_CANCEL_URL = "https://api.tosspayments.com/v1/payments/%s/cancel"; + + // ๊ฒฐ์ œ ์Šน์ธ + @Transactional + public PaymentConfirmResponse confirmPayment(PaymentConfirmRequest request) { + // 1. ์ฃผ๋ฌธ ์ •๋ณด ์กฐํšŒ ๋ฐ ๊ฒ€์ฆ (tossOrderId๋กœ ์กฐํšŒ) + Order order = orderRepository.findByTossOrderId(request.orderId()) + .orElseThrow(() -> new ServiceException(ErrorCode.ORDER_NOT_FOUND)); + + // 2. ์ฃผ๋ฌธ ๊ธˆ์•ก ๊ฒ€์ฆ + long orderAmount = order.getTotalPrice().longValue(); + if (orderAmount != request.amount()) { + throw new ServiceException(ErrorCode.PAYMENT_AMOUNT_MISMATCH); + } + + // 3. ์ค‘๋ณต ๊ฒฐ์ œ ๋ฐฉ์ง€ + if (paymentRepository.findByOrder_OrderId(order.getOrderId()).isPresent()) { + throw new ServiceException(ErrorCode.PAYMENT_ALREADY_EXISTS); + } + + // 4. ํ† ์ŠคํŽ˜์ด๋จผ์ธ  ๊ฒฐ์ œ ์Šน์ธ API ํ˜ธ์ถœ + try { + JsonNode tossResponse = callTossPaymentConfirmApi(request); + + // 5. Payment ์—”ํ‹ฐํ‹ฐ ์ƒ์„ฑ ๋ฐ ์ €์žฅ + Payment payment = createPaymentFromTossResponse(order, tossResponse); + Payment savedPayment = paymentRepository.save(payment); + + // 6. ์‘๋‹ต ๋ฐ์ดํ„ฐ ์ƒ์„ฑ + PaymentConfirmResponse response = buildPaymentConfirmResponse(savedPayment, tossResponse); + + log.info("๊ฒฐ์ œ ์Šน์ธ ์„ฑ๊ณต - orderId: {}, paymentKey: {}, amount: {}", + request.orderId(), response.paymentKey(), response.totalAmount()); + + return response; + + } catch (IOException e) { + log.error("ํ† ์ŠคํŽ˜์ด๋จผ์ธ  API ํ˜ธ์ถœ ์‹คํŒจ", e); + throw new ServiceException(ErrorCode.PAYMENT_API_CALL_FAILED); + } catch (Exception e) { + log.error("๊ฒฐ์ œ ์Šน์ธ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e); + throw new ServiceException(ErrorCode.PAYMENT_CONFIRM_FAILED); + } + } + + // ๊ฒฐ์ œ ์ทจ์†Œ + @Transactional + public PaymentCancelResponse cancelPayment(Long paymentId, PaymentCancelRequest request) { + // 1. ๊ฒฐ์ œ ์ •๋ณด ์กฐํšŒ + Payment payment = paymentRepository.findById(paymentId) + .orElseThrow(() -> new ServiceException(ErrorCode.PAYMENT_NOT_FOUND)); + + // 2. ์ทจ์†Œ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ํ™•์ธ + if (!payment.isCancelable()) { + throw new ServiceException(ErrorCode.PAYMENT_NOT_CANCELABLE); + } + + // 3. ํ† ์ŠคํŽ˜์ด๋จผ์ธ  ๊ฒฐ์ œ ์ทจ์†Œ API ํ˜ธ์ถœ + try { + callTossPaymentCancelApi(payment.getTossPaymentKey(), request.cancelReason()); + + // 4. ์ทจ์†Œ ์ฒ˜๋ฆฌ + payment.cancel(request.cancelReason()); + paymentRepository.save(payment); + + // 5. ์‘๋‹ต ์ƒ์„ฑ + PaymentCancelResponse response = PaymentCancelResponse.builder() + .paymentId(payment.getPaymentId()) + .paymentKey(payment.getTossPaymentKey()) + .orderId(payment.getTossOrderId()) + .status(payment.getPaymentStatus()) + .cancelAmount(payment.getTotalAmount()) + .cancelReason(request.cancelReason()) + .canceledAt(payment.getCanceledAt()) + .build(); + + log.info("๊ฒฐ์ œ ์ทจ์†Œ ์„ฑ๊ณต - paymentId: {}, amount: {}", paymentId, payment.getTotalAmount()); + + return response; + + } catch (IOException e) { + log.error("ํ† ์ŠคํŽ˜์ด๋จผ์ธ  ์ทจ์†Œ API ํ˜ธ์ถœ ์‹คํŒจ", e); + throw new ServiceException(ErrorCode.PAYMENT_CANCEL_API_FAILED); + } + } + + // ํ† ์ŠคํŽ˜์ด๋จผ์ธ  ๊ฒฐ์ œ ์Šน์ธ API ํ˜ธ์ถœ + private JsonNode callTossPaymentConfirmApi(PaymentConfirmRequest request) throws IOException { + String authorization = createBasicAuthHeader(tossSecretKey); + String requestBody = objectMapper.writeValueAsString(request); + + URL url = new URL(TOSS_PAYMENT_CONFIRM_URL); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestProperty("Authorization", authorization); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + + try (OutputStream os = connection.getOutputStream()) { + os.write(requestBody.getBytes(StandardCharsets.UTF_8)); + } + + return handleTossApiResponse(connection); + } + + // ํ† ์ŠคํŽ˜์ด๋จผ์ธ  ๊ฒฐ์ œ ์ทจ์†Œ API ํ˜ธ์ถœ + private JsonNode callTossPaymentCancelApi(String paymentKey, String cancelReason) throws IOException { + String authorization = createBasicAuthHeader(tossSecretKey); + + Map requestBody = new HashMap<>(); + requestBody.put("cancelReason", cancelReason); + String requestBodyJson = objectMapper.writeValueAsString(requestBody); + + URL url = new URL(String.format(TOSS_PAYMENT_CANCEL_URL, paymentKey)); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestProperty("Authorization", authorization); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + + try (OutputStream os = connection.getOutputStream()) { + os.write(requestBodyJson.getBytes(StandardCharsets.UTF_8)); + } + + return handleTossApiResponse(connection); + } + + // ํ† ์Šค API ์‘๋‹ต ์ฒ˜๋ฆฌ + private JsonNode handleTossApiResponse(HttpURLConnection connection) throws IOException { + int responseCode = connection.getResponseCode(); + boolean isSuccess = responseCode == 200; + + try (InputStream is = isSuccess ? connection.getInputStream() : connection.getErrorStream()) { + JsonNode responseJson = objectMapper.readTree(is); + + if (!isSuccess) { + String errorCode = responseJson.has("code") ? responseJson.get("code").asText() : "UNKNOWN"; + String errorMessage = responseJson.has("message") ? responseJson.get("message").asText() : "API ํ˜ธ์ถœ ์‹คํŒจ"; + log.error("ํ† ์ŠคํŽ˜์ด๋จผ์ธ  API ์‹คํŒจ - code: {}, message: {}", errorCode, errorMessage); + throw new ServiceException(ErrorCode.PAYMENT_API_CALL_FAILED); + } + + return responseJson; + } + } + + // Basic ์ธ์ฆ ํ—ค๋” ์ƒ์„ฑ + private String createBasicAuthHeader(String secretKey) { + String credentials = secretKey + ":"; + String encodedCredentials = Base64.getEncoder() + .encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + return "Basic " + encodedCredentials; + } + + // ํ† ์Šค ์‘๋‹ต์œผ๋กœ๋ถ€ํ„ฐ Payment ์—”ํ‹ฐํ‹ฐ ์ƒ์„ฑ + private Payment createPaymentFromTossResponse(Order order, JsonNode tossResponse) { + String method = tossResponse.get("method").asText(); + String status = tossResponse.get("status").asText(); + + Payment payment = Payment.builder() + .tossPaymentKey(tossResponse.get("paymentKey").asText()) + .tossOrderId(tossResponse.get("orderId").asText()) + .mid(tossResponse.has("mId") ? tossResponse.get("mId").asText() : null) + .order(order) + .buyer(order.getUser()) + .totalAmount(tossResponse.get("totalAmount").asLong()) + .paymentMethod(PaymentMethod.fromString(method)) + .paymentStatus(PaymentStatus.fromString(status)) + .build(); + + if (tossResponse.has("approvedAt")) { + String approvedAtStr = tossResponse.get("approvedAt").asText(); + LocalDateTime approvedAt = LocalDateTime.parse(approvedAtStr, DateTimeFormatter.ISO_DATE_TIME); + payment.approve(approvedAt); + } + + return payment; + } + + // PaymentConfirmResponse ์ƒ์„ฑ + private PaymentConfirmResponse buildPaymentConfirmResponse(Payment payment, JsonNode tossResponse) { + PaymentConfirmResponse.PaymentConfirmResponseBuilder builder = PaymentConfirmResponse.builder() + .paymentId(payment.getPaymentId()) + .paymentKey(payment.getTossPaymentKey()) + .orderId(payment.getTossOrderId()) + .orderName(tossResponse.has("orderName") ? tossResponse.get("orderName").asText() : null) + .method(payment.getPaymentMethod()) + .totalAmount(payment.getTotalAmount()) + .status(payment.getPaymentStatus()) + .requestedAt(payment.getRequestedAt()) + .approvedAt(payment.getApprovedAt()) + .mid(tossResponse.has("mId") ? tossResponse.get("mId").asText() : null); + + // ์นด๋“œ ๊ฒฐ์ œ ์ •๋ณด + if (tossResponse.has("card")) { + JsonNode card = tossResponse.get("card"); + builder.card(PaymentConfirmResponse.CardInfo.builder() + .company(card.has("company") ? card.get("company").asText() : null) + .number(card.has("number") ? card.get("number").asText() : null) + .installmentPlanMonths(card.has("installmentPlanMonths") ? + card.get("installmentPlanMonths").asText() : null) + .approveNo(card.has("approveNo") ? card.get("approveNo").asText() : null) + .ownerType(card.has("ownerType") ? card.get("ownerType").asText() : null) + .build()); + } + + // ๊ฐ€์ƒ๊ณ„์ขŒ ์ •๋ณด + if (tossResponse.has("virtualAccount")) { + JsonNode va = tossResponse.get("virtualAccount"); + PaymentConfirmResponse.VirtualAccountInfo.VirtualAccountInfoBuilder vaBuilder = + PaymentConfirmResponse.VirtualAccountInfo.builder() + .accountNumber(va.has("accountNumber") ? va.get("accountNumber").asText() : null) + .bankCode(va.has("bankCode") ? va.get("bankCode").asText() : null) + .customerName(va.has("customerName") ? va.get("customerName").asText() : null); + + if (va.has("dueDate")) { + String dueDateStr = va.get("dueDate").asText(); + vaBuilder.dueDate(LocalDateTime.parse(dueDateStr, DateTimeFormatter.ISO_DATE_TIME)); + } + + builder.virtualAccount(vaBuilder.build()); + } + + // ๊ฐ„ํŽธ๊ฒฐ์ œ ์ •๋ณด + if (tossResponse.has("easyPay")) { + JsonNode easyPay = tossResponse.get("easyPay"); + builder.easyPay(PaymentConfirmResponse.EasyPayInfo.builder() + .provider(easyPay.has("provider") ? easyPay.get("provider").asText() : null) + .amount(easyPay.has("amount") ? easyPay.get("amount").asLong() : null) + .build()); + } + + return builder.build(); + } + + // ๋งˆ์ดํŽ˜์ด์ง€์—์„œ ์ฃผ๋ฌธ์˜ ๊ฒฐ์ œ ๋‚ด์—ญ ๋‹จ๊ฑด ์กฐํšŒ + + @Transactional(readOnly = true) + public PaymentDetailResponse getPaymentDetailByOrder(User user, Long orderId) { + Payment payment = paymentRepository.findByOrder_OrderId(orderId) + .orElseThrow(() -> new ServiceException(ErrorCode.PAYMENT_NOT_FOUND)); + + // ๋ณธ์ธ ๊ฒฐ์ œ ๋‚ด์—ญ์ธ์ง€ ํ™•์ธ + if (!payment.getBuyer().getUserId().equals(user.getUserId())) { + throw new ServiceException(ErrorCode.PAYMENT_UNAUTHORIZED_ACCESS); + } + return PaymentDetailResponse.from(payment); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/like/consumer/LikeEventConsumer.java b/backend/src/main/java/com/mysite/knitly/domain/product/like/consumer/LikeEventConsumer.java new file mode 100644 index 0000000..4dc727c --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/like/consumer/LikeEventConsumer.java @@ -0,0 +1,100 @@ +package com.mysite.knitly.domain.product.like.consumer; + +import com.mysite.knitly.domain.product.like.dto.LikeEventRequest; +import com.mysite.knitly.domain.product.like.entity.ProductLike; +import com.mysite.knitly.domain.product.like.entity.ProductLikeId; +import com.mysite.knitly.domain.product.like.repository.ProductLikeRepository; +import com.mysite.knitly.domain.product.product.entity.Product; +import com.mysite.knitly.domain.product.product.repository.ProductRepository; +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.domain.user.repository.UserRepository; +import com.mysite.knitly.global.exception.ErrorCode; +import com.mysite.knitly.global.exception.ServiceException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.AmqpRejectAndDontRequeueException; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.amqp.rabbit.annotation.RabbitListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class LikeEventConsumer { + + private final ProductLikeRepository productLikeRepository; + private final UserRepository userRepository; + private final ProductRepository productRepository; + private final RedisTemplate redisTemplate; + + private static final String LIKE_QUEUE_NAME = "like.add.queue"; + private static final String DISLIKE_QUEUE_NAME = "like.delete.queue"; + + @Transactional + @RabbitListener(queues = LIKE_QUEUE_NAME) + public void handleLikeEvent(LikeEventRequest eventDto) { + String redisKey = "likes:product:" + eventDto.productId(); + String userKey = eventDto.userId().toString(); + + try { + User user = userRepository.findById(eventDto.userId()) + .orElseThrow(() -> new ServiceException(ErrorCode.USER_NOT_FOUND)); + + Product product = productRepository.findById(eventDto.productId()) + .orElseThrow(() -> new ServiceException(ErrorCode.PRODUCT_NOT_FOUND)); + + ProductLikeId productLikeId = new ProductLikeId(user.getUserId(), product.getProductId()); + + if (productLikeRepository.existsById(productLikeId)) { + log.info("Like already exists in DB. Skipping: {}", productLikeId); + return; + } + + ProductLike productLike = ProductLike.builder() + .user(user) + .product(product) + .build(); + + product.increaseLikeCount(); + + // 5. DB ์ €์žฅ + productLikeRepository.save(productLike); + log.info("Successfully saved like to DB: {}", productLikeId); + + } catch (Exception e) { + log.error("Failed to save like to DB. Rolling back Redis cache for {}.", redisKey, e); + + redisTemplate.opsForSet().remove(redisKey, userKey); + + throw new AmqpRejectAndDontRequeueException("DB save failed, cache rolled back.", e); + } + } + + @Transactional + @RabbitListener(queues = DISLIKE_QUEUE_NAME) + public void handleDislikeEvent(LikeEventRequest eventDto) { + log.info("[handleDislikeEvent] received event: {}", eventDto); + ProductLikeId productLikeId = new ProductLikeId(eventDto.userId(), eventDto.productId()); + + try { + if (productLikeRepository.existsById(productLikeId)) { + + Product product = productRepository.findById(eventDto.productId()) + .orElseThrow(() -> new ServiceException(ErrorCode.PRODUCT_NOT_FOUND)); + + product.decreaseLikeCount(); + + productLikeRepository.deleteById(productLikeId); + + log.info("[handleDislikeEvent] ProductLike deleted and count decremented: {}", productLikeId); + + } else { + log.warn("[handleDislikeEvent] ProductLike not found in DB, skipping: {}", productLikeId); + } + } catch (Exception e) { + log.error("[handleDislikeEvent] Error processing dislike event: {}", eventDto, e); + throw new AmqpRejectAndDontRequeueException("DB operation failed during dislike.", e); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/like/controller/ProductLikeController.java b/backend/src/main/java/com/mysite/knitly/domain/product/like/controller/ProductLikeController.java new file mode 100644 index 0000000..86d63c3 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/like/controller/ProductLikeController.java @@ -0,0 +1,39 @@ +package com.mysite.knitly.domain.product.like.controller; + +import com.mysite.knitly.domain.product.like.service.ProductLikeService; +import com.mysite.knitly.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/products/{productId}/like") +public class ProductLikeController { + private final ProductLikeService productLikeService; + + @PostMapping + public ResponseEntity addLike( + @AuthenticationPrincipal User user, + @PathVariable Long productId) { + + Long currentUserId = user.getUserId(); + productLikeService.addLike(currentUserId, productId); + + return ResponseEntity.ok().build(); + } + + @DeleteMapping + public ResponseEntity deleteLike( + @AuthenticationPrincipal User user, + @PathVariable Long productId) { + + Long currentUserId = user.getUserId(); + productLikeService.deleteLike(currentUserId, productId); + + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/like/dto/LikeEventRequest.java b/backend/src/main/java/com/mysite/knitly/domain/product/like/dto/LikeEventRequest.java new file mode 100644 index 0000000..4887c80 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/like/dto/LikeEventRequest.java @@ -0,0 +1,5 @@ +package com.mysite.knitly.domain.product.like.dto; + +import java.io.Serializable; + +public record LikeEventRequest(Long userId, Long productId) implements Serializable {} diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/like/entity/ProductLike.java b/backend/src/main/java/com/mysite/knitly/domain/product/like/entity/ProductLike.java new file mode 100644 index 0000000..506593f --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/like/entity/ProductLike.java @@ -0,0 +1,26 @@ +package com.mysite.knitly.domain.product.like.entity; + +import com.mysite.knitly.domain.product.product.entity.Product; +import com.mysite.knitly.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "product_likes") +@IdClass(ProductLikeId.class) // ๋ณตํ•ฉ ํ‚ค ํด๋ž˜์Šค +public class ProductLike { + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id") + private Product product; +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/like/entity/ProductLikeId.java b/backend/src/main/java/com/mysite/knitly/domain/product/like/entity/ProductLikeId.java new file mode 100644 index 0000000..5263006 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/like/entity/ProductLikeId.java @@ -0,0 +1,18 @@ +package com.mysite.knitly.domain.product.like.entity; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.UUID; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class ProductLikeId implements Serializable { + private Long user; + private Long product; +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/like/repository/ProductLikeRepository.java b/backend/src/main/java/com/mysite/knitly/domain/product/like/repository/ProductLikeRepository.java new file mode 100644 index 0000000..9fbeff2 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/like/repository/ProductLikeRepository.java @@ -0,0 +1,30 @@ +package com.mysite.knitly.domain.product.like.repository; + +import com.mysite.knitly.domain.product.like.entity.ProductLike; +import com.mysite.knitly.domain.product.like.entity.ProductLikeId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Set; + +@Repository +public interface ProductLikeRepository extends JpaRepository { + Page findByUser_UserId(Long userId, Pageable pageable); + + default void deleteByUserIdAndProductId(Long userId, Long productId) { + ProductLikeId id = new ProductLikeId(userId, productId); + deleteById(id); + } + + @Query("SELECT pl.product.productId FROM ProductLike pl WHERE pl.user.userId = :userId AND pl.product.productId IN :productIds") + Set findLikedProductIdsByUserId(@Param("userId") Long userId, @Param("productIds") List productIds); + + boolean existsByUser_UserIdAndProduct_ProductId(Long userId, Long productId); +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/like/service/ProductLikeService.java b/backend/src/main/java/com/mysite/knitly/domain/product/like/service/ProductLikeService.java new file mode 100644 index 0000000..717869c --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/like/service/ProductLikeService.java @@ -0,0 +1,48 @@ +package com.mysite.knitly.domain.product.like.service; + +import com.mysite.knitly.domain.product.like.dto.LikeEventRequest; +import com.mysite.knitly.domain.product.like.repository.ProductLikeRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ProductLikeService { + private final RedisTemplate redisTemplate; + private final RabbitTemplate rabbitTemplate; + private final ProductLikeRepository productLikeRepository; + + private static final String EXCHANGE_NAME = "like.exchange"; + + private static final String LIKE_ROUTING_KEY = "like.add.routingkey"; + private static final String DISLIKE_ROUTING_KEY = "like.delete.routingkey"; + + public void addLike(Long userId, Long productId) { + String redisKey = "likes:product:" + productId; + String userKey = userId.toString(); + + redisTemplate.opsForSet().add(redisKey, userKey); + LikeEventRequest eventDto = new LikeEventRequest(userId, productId); + rabbitTemplate.convertAndSend(EXCHANGE_NAME, LIKE_ROUTING_KEY, eventDto); + } + + @Transactional + public void deleteLike(Long userId, Long productId) { + String redisKey = "likes:product:" + productId; + String userKey = userId.toString(); + + // Redis์—์„œ ์ œ๊ฑฐ + redisTemplate.opsForSet().remove(redisKey, userKey); + + // DB ์‚ญ์ œ๋Š” ํ•ญ์ƒ ์ˆ˜ํ–‰ + LikeEventRequest eventDto = new LikeEventRequest(userId, productId); + rabbitTemplate.convertAndSend(EXCHANGE_NAME, DISLIKE_ROUTING_KEY, eventDto); + + log.info("[deleteLike] Deleted like for userId={}, productId={}", userId, productId); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/product/controller/ProductController.java b/backend/src/main/java/com/mysite/knitly/domain/product/product/controller/ProductController.java new file mode 100644 index 0000000..1779924 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/product/controller/ProductController.java @@ -0,0 +1,64 @@ +package com.mysite.knitly.domain.product.product.controller; + + +import com.mysite.knitly.domain.product.product.dto.ProductModifyRequest; +import com.mysite.knitly.domain.product.product.dto.ProductModifyResponse; +import com.mysite.knitly.domain.product.product.dto.ProductRegisterRequest; +import com.mysite.knitly.domain.product.product.dto.ProductRegisterResponse; +import com.mysite.knitly.domain.product.product.service.ProductService; +import com.mysite.knitly.domain.user.entity.User; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/my/products") +public class ProductController { + + private final ProductService productService; + + // ํŒ๋งค ๋“ฑ๋ก + @PostMapping("/{designId}/sale") + public ResponseEntity registerProduct( + @AuthenticationPrincipal User user, + @PathVariable Long designId, + @ModelAttribute @Valid ProductRegisterRequest request + ) { + ProductRegisterResponse response = productService.registerProduct(user, designId, request); + return ResponseEntity.ok(response); + } + + @PatchMapping("/{productId}/modify") + public ResponseEntity modifyProduct( + @AuthenticationPrincipal User user, + @PathVariable Long productId, + @ModelAttribute @Valid ProductModifyRequest request + ) { + ProductModifyResponse response = productService.modifyProduct(user, productId, request); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/{productId}") + public ResponseEntity deleteProduct( + @AuthenticationPrincipal User user, + @PathVariable Long productId + ) { + productService.deleteProduct(user, productId); + return ResponseEntity.noContent().build(); + } + + //์žฌํŒ๋งค + @PostMapping("/{productId}/relist") + public ResponseEntity relistProduct( + @AuthenticationPrincipal User user, + @PathVariable Long productId + ) { + productService.relistProduct(user, productId); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/product/controller/ProductListController.java b/backend/src/main/java/com/mysite/knitly/domain/product/product/controller/ProductListController.java new file mode 100644 index 0000000..5feee3f --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/product/controller/ProductListController.java @@ -0,0 +1,48 @@ +package com.mysite.knitly.domain.product.product.controller; + +import com.mysite.knitly.domain.product.product.dto.ProductDetailResponse; +import com.mysite.knitly.domain.product.product.dto.ProductListResponse; +import com.mysite.knitly.domain.product.product.entity.ProductCategory; +import com.mysite.knitly.domain.product.product.entity.ProductFilterType; +import com.mysite.knitly.domain.product.product.entity.ProductSortType; +import com.mysite.knitly.domain.product.product.service.ProductService; +import com.mysite.knitly.domain.user.entity.User; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/products") +public class ProductListController { + private final ProductService productService; + + // ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ + @GetMapping + public ResponseEntity> getProducts( + @AuthenticationPrincipal User user, + @RequestParam(required = false) ProductCategory category, + @RequestParam(required = false, defaultValue = "ALL") ProductFilterType filter, + @RequestParam(required = false, defaultValue = "LATEST") ProductSortType sort, + @PageableDefault(size = 20) Pageable pageable + ) { + Page response = productService.getProducts(user, category, filter, sort, pageable); + return ResponseEntity.ok(response); + } + + @GetMapping("/{productId}") + public ResponseEntity getProductDetail( + @AuthenticationPrincipal User user, + @PathVariable Long productId + ) { + ProductDetailResponse response = productService.getProductDetail(user, productId); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/product/dto/ProductDetailResponse.java b/backend/src/main/java/com/mysite/knitly/domain/product/product/dto/ProductDetailResponse.java new file mode 100644 index 0000000..74407dc --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/product/dto/ProductDetailResponse.java @@ -0,0 +1,44 @@ +package com.mysite.knitly.domain.product.product.dto; + +import com.mysite.knitly.domain.product.product.entity.Product; +import com.mysite.knitly.domain.product.product.entity.ProductCategory; + +import java.util.List; + +public record ProductDetailResponse( + Long productId, + String title, + String description, + ProductCategory productCategory, + String sizeInfo, + Double price, + String createdAt, + Integer stockQuantity, + + Integer likeCount, + + boolean isLikedByUser, + //Long designId + Double avgReviewRating, + List productImageUrls, + Integer reviewCount +) { + public static ProductDetailResponse from(Product product, List imageUrls, boolean isLikedByUser) { + return new ProductDetailResponse( + product.getProductId(), + product.getTitle(), + product.getDescription(), + product.getProductCategory(), + product.getSizeInfo(), + product.getPrice(), + product.getCreatedAt().toString(), + product.getStockQuantity(), + product.getLikeCount(), + isLikedByUser, + //product.getDesign().getDesignId() + product.getAvgReviewRating(), + imageUrls, + product.getReviewCount() != null ? product.getReviewCount() : 0 // null ๋ฐฉ์ง€ + ); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/product/dto/ProductListResponse.java b/backend/src/main/java/com/mysite/knitly/domain/product/product/dto/ProductListResponse.java new file mode 100644 index 0000000..c65d1db --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/product/dto/ProductListResponse.java @@ -0,0 +1,56 @@ +package com.mysite.knitly.domain.product.product.dto; + +import com.mysite.knitly.domain.product.product.entity.Product; +import com.mysite.knitly.domain.product.product.entity.ProductCategory; + +import java.time.LocalDateTime; + +public record ProductListResponse( + Long productId, + String title, + ProductCategory productCategory, + Double price, + Integer purchaseCount, + Integer likeCount, + boolean isLikedByUser, + Integer stockQuantity, + Double avgReviewRating, + LocalDateTime createdAt, + String thumbnailUrl,// ๋Œ€ํ‘œ ์ด๋ฏธ์ง€ URL (sortOrder = 1) + String sellerName, + Boolean isFree, // ๋ฌด๋ฃŒ ์—ฌ๋ถ€ + Boolean isLimited, // ํ•œ์ •ํŒ๋งค ์—ฌ๋ถ€ + Boolean isSoldOut // ํ’ˆ์ ˆ ์—ฌ๋ถ€ (stockQuantity = 0) +) { + public static ProductListResponse from(Product product, boolean isLikedByUser) { + // Product์˜ ์ฒซ ๋ฒˆ์งธ ์ด๋ฏธ์ง€๋ฅผ thumbnailUrl๋กœ ์‚ฌ์šฉ + String thumbnailUrl = null; + if (product.getProductImages() != null && !product.getProductImages().isEmpty()) { + // sortOrder๊ฐ€ 1์ธ ์ด๋ฏธ์ง€๋ฅผ ์ฐพ๊ฑฐ๋‚˜, ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์ด๋ฏธ์ง€ ์‚ฌ์šฉ + thumbnailUrl = product.getProductImages().stream() + .filter(img -> img.getSortOrder() != null && img.getSortOrder() == 1L) + .findFirst() + .map(img -> img.getProductImageUrl()) + .orElseGet(() -> product.getProductImages().get(0).getProductImageUrl()); + } + + // record๋Š” ์ƒ์„ฑ์ž๋ฅผ ํ†ตํ•ด ํ•„๋“œ๋ฅผ ์ดˆ๊ธฐํ™”ํ•ฉ๋‹ˆ๋‹ค. + return new ProductListResponse( + product.getProductId(), + product.getTitle(), + product.getProductCategory(), + product.getPrice(), + product.getPurchaseCount(), + product.getLikeCount(), + isLikedByUser, + product.getStockQuantity(), + product.getAvgReviewRating(), + product.getCreatedAt(), + thumbnailUrl, // ๐Ÿ”ฅ ์ˆ˜์ •: Product์˜ ์ฒซ ๋ฒˆ์งธ ์ด๋ฏธ์ง€ URL + product.getUser() !=null? product.getUser().getName() : "์•Œ ์ˆ˜ ์—†์Œ", + product.getPrice() == 0.0, + product.getStockQuantity() != null, + product.getStockQuantity() != null && product.getStockQuantity() == 0 + ); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/product/dto/ProductModifyRequest.java b/backend/src/main/java/com/mysite/knitly/domain/product/product/dto/ProductModifyRequest.java new file mode 100644 index 0000000..203fcd9 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/product/dto/ProductModifyRequest.java @@ -0,0 +1,28 @@ +package com.mysite.knitly.domain.product.product.dto; + +import com.mysite.knitly.domain.product.product.entity.ProductCategory; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +public record ProductModifyRequest( + @Pattern(regexp = "^[a-zA-Z0-9ใ„ฑ-ใ…Žใ…-ใ…ฃ๊ฐ€-ํžฃ\\s~!@#$%^&*()_+\\-=\\[\\]{}|;:'\",.<>/?]+$", message = "์‚ฌ์ด์ฆˆ ์ •๋ณด์—๋Š” ํ•œ๊ธ€, ์˜์–ด, ์ˆซ์ž, ์ผ๋ถ€ ํŠน์ˆ˜๋ฌธ์ž๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + String description, + + @NotNull(message = "์ƒํ’ˆ ์นดํ…Œ๊ณ ๋ฆฌ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + ProductCategory productCategory, + + @Pattern(regexp = "^[a-zA-Z0-9ใ„ฑ-ใ…Žใ…-ใ…ฃ๊ฐ€-ํžฃ\\s~!@#$%^&*()_+\\-=\\[\\]{}|;:'\",.<>/?]+$", message = "์‚ฌ์ด์ฆˆ ์ •๋ณด์—๋Š” ํ•œ๊ธ€, ์˜์–ด, ์ˆซ์ž, ์ผ๋ถ€ ํŠน์ˆ˜๋ฌธ์ž๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + String sizeInfo, + + @Size(max = 10, message = "์ƒํ’ˆ ์ด๋ฏธ์ง€๋Š” ์ตœ๋Œ€ 10๊ฐœ๊นŒ์ง€ ๋“ฑ๋กํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + List productImageUrls, + + List existingImageUrls, + + Integer stockQuantity +){ +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/product/dto/ProductModifyResponse.java b/backend/src/main/java/com/mysite/knitly/domain/product/product/dto/ProductModifyResponse.java new file mode 100644 index 0000000..c7d92ac --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/product/dto/ProductModifyResponse.java @@ -0,0 +1,29 @@ +package com.mysite.knitly.domain.product.product.dto; + +import com.mysite.knitly.domain.product.product.entity.Product; +import com.mysite.knitly.domain.product.product.entity.ProductCategory; + +import java.util.List; + +public record ProductModifyResponse ( + Long productId, + String title, + String description, + ProductCategory productCategory, + String sizeInfo, + Integer stockQuantity, + List productImageUrls + +){ + public static ProductModifyResponse from(Product product, List imageUrls) { + return new ProductModifyResponse( + product.getProductId(), + product.getTitle(), + product.getDescription(), + product.getProductCategory(), + product.getSizeInfo(), + product.getStockQuantity(), + imageUrls + ); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/product/dto/ProductRegisterRequest.java b/backend/src/main/java/com/mysite/knitly/domain/product/product/dto/ProductRegisterRequest.java new file mode 100644 index 0000000..6e6e6b5 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/product/dto/ProductRegisterRequest.java @@ -0,0 +1,33 @@ +package com.mysite.knitly.domain.product.product.dto; + +import com.mysite.knitly.domain.product.product.entity.ProductCategory; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +public record ProductRegisterRequest( + @Pattern(regexp = "^[a-zA-Z0-9ใ„ฑ-ใ…Žใ…-ใ…ฃ๊ฐ€-ํžฃ\\s~!@#$%^&*()_+\\-=\\[\\]{}|;:'\",.<>/?]+$", message = "์ƒํ’ˆ ์ด๋ฆ„์—๋Š” ํ•œ๊ธ€, ์˜์–ด, ์ˆซ์ž, ์ผ๋ถ€ ํŠน์ˆ˜๋ฌธ์ž๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + String title, + + @Pattern(regexp = "^[a-zA-Z0-9ใ„ฑ-ใ…Žใ…-ใ…ฃ๊ฐ€-ํžฃ\\s~!@#$%^&*()_+\\-=\\[\\]{}|;:'\",.<>/?]+$", message = "์ƒํ’ˆ ์ด๋ฆ„์—๋Š” ํ•œ๊ธ€, ์˜์–ด, ์ˆซ์ž, ์ผ๋ถ€ ํŠน์ˆ˜๋ฌธ์ž๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + String description, + + @NotNull(message = "์ƒํ’ˆ ์นดํ…Œ๊ณ ๋ฆฌ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + ProductCategory productCategory, + + @Pattern(regexp = "^[a-zA-Z0-9ใ„ฑ-ใ…Žใ…-ใ…ฃ๊ฐ€-ํžฃ\\s~!@#$%^&*()_+\\-=\\[\\]{}|;:'\",.<>/?]+$", message = "์‚ฌ์ด์ฆˆ ์ •๋ณด์—๋Š” ํ•œ๊ธ€, ์˜์–ด, ์ˆซ์ž, ์ผ๋ถ€ ํŠน์ˆ˜๋ฌธ์ž๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + String sizeInfo, + + @Min(value = 0, message = "๊ฐ€๊ฒฉ์€ ๋ฐ˜๋“œ์‹œ 0์› ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + Double price, + + @Size(max = 10, message = "์ƒํ’ˆ ์ด๋ฏธ์ง€๋Š” ์ตœ๋Œ€ 10๊ฐœ๊นŒ์ง€ ๋“ฑ๋กํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + List productImageUrls, + + Integer stockQuantity +) { +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/product/dto/ProductRegisterResponse.java b/backend/src/main/java/com/mysite/knitly/domain/product/product/dto/ProductRegisterResponse.java new file mode 100644 index 0000000..c8203b1 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/product/dto/ProductRegisterResponse.java @@ -0,0 +1,55 @@ +package com.mysite.knitly.domain.product.product.dto; + +import com.mysite.knitly.domain.product.product.entity.Product; +import com.mysite.knitly.domain.product.product.entity.ProductCategory; + +import java.util.List; + +public record ProductRegisterResponse ( + Long productId, + String title, + String description, + ProductCategory productCategory, + String sizeInfo, + Double price, + String createdAt, + //Integer purchaseCount, + Integer stockQuantity, + //Integer likeCount, + Long designId, + List productImageUrls +) { + public static ProductRegisterResponse from(Product product, List imageUrls) { + return new ProductRegisterResponse( + product.getProductId(), + product.getTitle(), + product.getDescription(), + product.getProductCategory(), + product.getSizeInfo(), + product.getPrice(), + product.getCreatedAt().toString(), + //product.getPurchaseCount(), + product.getStockQuantity(), + //product.getLikeCount(), + product.getDesign().getDesignId(), + imageUrls + ); + } +} + +//CREATE TABLE `products` ( +// `product_id` BIGINT NOT NULL DEFAULT AUTO_INCREMENT, +// `title` VARCHAR(30) NOT NULL, +// `description` TEXT NOT NULL, +// `product_category` ENUM('TOP', 'BOTTOM', 'OUTER', 'BAG', 'ETC') NOT NULL COMMENT '์ƒ์˜, ํ•˜์˜, ์•„์šฐํ„ฐ, ๊ฐ€๋ฐฉ, ๊ธฐํƒ€', +// `size_info` VARCHAR(255) NOT NULL, +// `price` DECIMAL(10,2) NOT NULL COMMENT '๋ฌด๋ฃŒ ๊ตฌ๋ถ„', +// `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +// `user_id` BINARY(16) NOT NULL, +// `purchase_count` INT NOT NULL DEFAULT 0 COMMENT '๋ˆ„์ ์ˆ˜ ๋ถ„๋ฆฌ?', +// `is_deleted` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '์†Œํ”„ํŠธ ๋”œ๋ฆฌํŠธ', +// `stock_quantity` INT NULL COMMENT 'null ์ด๋ฉด ์ƒ์‹œ ํŒ๋งค / 0~์ˆซ์ž ๋Š” ํ•œ์ •ํŒ๋งค', +// `like_count` INT NOT NULL DEFAULT 0, +// `design_id` BIGINT NOT NULL DEFAULT AUTO_INCREMENT, +// `avg_review_rating` DECIMAL(3,2) NULL +//); \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/product/dto/ProductWithThumbnailDto.java b/backend/src/main/java/com/mysite/knitly/domain/product/product/dto/ProductWithThumbnailDto.java new file mode 100644 index 0000000..fc416d4 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/product/dto/ProductWithThumbnailDto.java @@ -0,0 +1,49 @@ +package com.mysite.knitly.domain.product.product.dto; + +import com.mysite.knitly.domain.product.product.entity.ProductCategory; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ์šฉ DTO (๋Œ€ํ‘œ ์ด๋ฏธ์ง€ ํฌํ•จ) + * Native Query์˜ ๊ฒฐ๊ณผ๋ฅผ ๋งคํ•‘ + */ +public record ProductWithThumbnailDto (Long productId, + String title, + ProductCategory productCategory, + Double price, + Integer purchaseCount, + Integer likeCount, + Integer stockQuantity, + Double avgReviewRating, + LocalDateTime createdAt, + String thumbnailUrl){ + + + + /** + * ProductListResponse๋กœ ๋ณ€ํ™˜ + */ + public ProductListResponse toResponse(boolean isLikedByUser, String sellerName) { + return new ProductListResponse( + this.productId, + this.title, + this.productCategory, + this.price, + this.purchaseCount, + this.likeCount, + isLikedByUser, + this.stockQuantity, + this.avgReviewRating, + this.createdAt, + this.thumbnailUrl, // thumbnailUrl + sellerName, + // ์ถ”๊ฐ€๋กœ ๊ณ„์‚ฐ๋œ Boolean ํ•„๋“œ๋“ค + this.price == 0.0, // isFree + this.stockQuantity != null, // isLimited + this.stockQuantity != null && this.stockQuantity == 0 // isSoldOut + ); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/product/entity/Product.java b/backend/src/main/java/com/mysite/knitly/domain/product/product/entity/Product.java new file mode 100644 index 0000000..6fa59cd --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/product/entity/Product.java @@ -0,0 +1,169 @@ +package com.mysite.knitly.domain.product.product.entity; + +import com.mysite.knitly.domain.design.entity.Design; +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.global.exception.ErrorCode; +import com.mysite.knitly.global.exception.ServiceException; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "products") +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class Product { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long productId; + + @Column(nullable = false, length = 30) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String description; + + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ProductCategory productCategory; // 'TOP', 'BOTTOM', 'OUTER', 'BAG', 'ETC' + + @Column(nullable = false) + private String sizeInfo; + + @Column(nullable = false, columnDefinition = "DECIMAL(10,2)") + private Double price; // DECIMAL(10,2) + + @Column(nullable = false) + @CreatedDate + private LocalDateTime createdAt; // DATETIME + + @ManyToOne(fetch = FetchType.LAZY) + //Cascade ์•ˆํ•˜๋Š” ์ด์œ  : User ์‚ญ์ œ์‹œ Product๋„ ์‚ญ์ œ๋˜๋ฉด ์•ˆ๋จ + @JoinColumn(name = "user_id", nullable = false) + private User user; // ํŒ๋งค์ž + + @Column(nullable = false) + private Integer purchaseCount; // ๋ˆ„์ ์ˆ˜ + + @Column(nullable = false) + private Boolean isDeleted; // ์†Œํ”„ํŠธ ๋”œ๋ฆฌํŠธ + + @Column + private Integer stockQuantity; // null ์ด๋ฉด ์ƒ์‹œ ํŒ๋งค / 0~์ˆซ์ž ๋Š” ํ•œ์ •ํŒ๋งค + + @Column(nullable = false) + private Integer likeCount; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "design_id", nullable = false) + //Cascade ์•ˆํ•˜๋Š” ์ด์œ  : Design ์‚ญ์ œ์‹œ Product๋„ ์‚ญ์ œ๋˜๋ฉด ์•ˆ๋จ + private Design design; + + @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List productImages = new ArrayList<>(); + + @Column + private Double avgReviewRating; // DECIMAL(3,2) + + @Column + private Integer reviewCount; + + //์ƒํ’ˆ ์ˆ˜์ •ํ•˜๋Š” ๋กœ์ง ์ถ”๊ฐ€ + public void update(String description, ProductCategory productCategory, String sizeInfo, Integer stockQuantity) { + this.description = description; + this.productCategory = productCategory; + this.sizeInfo = sizeInfo; + this.stockQuantity = stockQuantity; + } + + //์†Œํ”„ํŠธ ๋”œ๋ฆฌํŠธ ๋กœ์ง ์ถ”๊ฐ€ + public void softDelete() { + this.isDeleted = true; + } + + //์žฌํŒ๋งค๋ฅผ ์œ„ํ•œ ๋ฉ”์„œ๋“œ (isDeleted ๋ฅผ false ๋กœ ๋ณ€๊ฒฝ) + public void relist() { + if (!this.isDeleted) { + throw new ServiceException(ErrorCode.DESIGN_NOT_STOPPED); + } + this.isDeleted = false; + } + + //์žฌ๊ณ  ์ˆ˜๋Ÿ‰ ๊ฐ์†Œ ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€ + public void decreaseStock(int quantity) { + // 1. ์ƒ์‹œ ํŒ๋งค ์ƒํ’ˆ(์žฌ๊ณ ๊ฐ€ null)์ธ ๊ฒฝ์šฐ๋Š” ๋กœ์ง์„ ์‹คํ–‰ํ•˜์ง€ ์•Š์Œ + if (this.stockQuantity == null) { + return; + } + + // 2. ๋‚จ์€ ์žฌ๊ณ ๋ณด๋‹ค ๋งŽ์€ ์ˆ˜๋Ÿ‰์„ ์ฃผ๋ฌธํ•˜๋ฉด ์˜ˆ์™ธ ๋ฐœ์ƒ + int restStock = this.stockQuantity - quantity; + if (restStock < 0) { + throw new ServiceException(ErrorCode.PRODUCT_STOCK_INSUFFICIENT); + } + + // 3. ์žฌ๊ณ  ์ฐจ๊ฐ + this.stockQuantity = restStock; + } + + // ์ƒํ’ˆ ์ด๋ฏธ์ง€ ์„ค์ • ๋ฉ”์„œ๋“œ + public void addProductImages(List images) { + this.productImages.clear(); + if (images != null) { + this.productImages.addAll(images); + images.forEach(image -> image.setProduct(this)); // ์–‘๋ฐฉํ–ฅ ์—ฐ๊ด€๊ด€๊ณ„ ์„ค์ • + } + } + + public void increaseLikeCount() { + if (this.likeCount == null) { + this.likeCount = 0; + } + this.likeCount += 1; + } + + public void decreaseLikeCount() { + if (this.likeCount == null || this.likeCount <= 0) { + this.likeCount = 0; + } else { + this.likeCount -= 1; + } + } + + // ๋ฆฌ๋ทฐ ๊ฐœ์ˆ˜ ์„ค์ • ๋ฉ”์„œ๋“œ + public void setReviewCount(Integer reviewCount) { + this.reviewCount = reviewCount; + } + +} + +//CREATE TABLE `products` ( +// `product_id` BIGINT NOT NULL DEFAULT AUTO_INCREMENT, +// `title` VARCHAR(30) NOT NULL, +// `description` TEXT NOT NULL, +// `product_category` ENUM('TOP', 'BOTTOM', 'OUTER', 'BAG', 'ETC') NOT NULL COMMENT '์ƒ์˜, ํ•˜์˜, ์•„์šฐํ„ฐ, ๊ฐ€๋ฐฉ, ๊ธฐํƒ€', +// `size_info` VARCHAR(255) NOT NULL, +// `price` DECIMAL(10,2) NOT NULL COMMENT '๋ฌด๋ฃŒ ๊ตฌ๋ถ„', +// `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +// `user_id` BIGINT NOT NULL, +// `purchase_count` INT NOT NULL DEFAULT 0 COMMENT '๋ˆ„์ ์ˆ˜ ๋ถ„๋ฆฌ?', +// `is_deleted` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '์†Œํ”„ํŠธ ๋”œ๋ฆฌํŠธ', +// `stock_quantity` INT NULL COMMENT 'null ์ด๋ฉด ์ƒ์‹œ ํŒ๋งค / 0~์ˆซ์ž ๋Š” ํ•œ์ •ํŒ๋งค', +// `like_count` INT NOT NULL DEFAULT 0, +// `design_id` BIGINT NOT NULL DEFAULT AUTO_INCREMENT, +// `avg_review_rating` DECIMAL(3,2) NULL +//); \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/product/entity/ProductCategory.java b/backend/src/main/java/com/mysite/knitly/domain/product/product/entity/ProductCategory.java new file mode 100644 index 0000000..4ccd5f6 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/product/entity/ProductCategory.java @@ -0,0 +1,5 @@ +package com.mysite.knitly.domain.product.product.entity; + +public enum ProductCategory { + TOP, BOTTOM, OUTER, BAG, ETC +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/product/entity/ProductFilterType.java b/backend/src/main/java/com/mysite/knitly/domain/product/product/entity/ProductFilterType.java new file mode 100644 index 0000000..a43f270 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/product/entity/ProductFilterType.java @@ -0,0 +1,5 @@ +package com.mysite.knitly.domain.product.product.entity; + +public enum ProductFilterType { + ALL, FREE, LIMITED // ์ „์ฒด ๋ฌด๋ฃŒ ํ•œ์ •ํŒ๋งค +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/product/entity/ProductImage.java b/backend/src/main/java/com/mysite/knitly/domain/product/product/entity/ProductImage.java new file mode 100644 index 0000000..7e27846 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/product/entity/ProductImage.java @@ -0,0 +1,34 @@ +package com.mysite.knitly.domain.product.product.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "product_images") +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class ProductImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long productImageId; + + private String productImageUrl; + + private Long sortOrder; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id", nullable = false) + private Product product; + + public void setProduct(Product product) { + this.product = product; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/product/entity/ProductSortType.java b/backend/src/main/java/com/mysite/knitly/domain/product/product/entity/ProductSortType.java new file mode 100644 index 0000000..718984d --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/product/entity/ProductSortType.java @@ -0,0 +1,5 @@ +package com.mysite.knitly.domain.product.product.entity; + +public enum ProductSortType { + POPULAR, LATEST, PRICE_ASC, PRICE_DESC +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/product/repository/ProductImageRepository.java b/backend/src/main/java/com/mysite/knitly/domain/product/product/repository/ProductImageRepository.java new file mode 100644 index 0000000..bbe698e --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/product/repository/ProductImageRepository.java @@ -0,0 +1,7 @@ +package com.mysite.knitly.domain.product.product.repository; + +import com.mysite.knitly.domain.product.product.entity.ProductImage; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductImageRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/product/repository/ProductRepository.java b/backend/src/main/java/com/mysite/knitly/domain/product/product/repository/ProductRepository.java new file mode 100644 index 0000000..d88dec4 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/product/repository/ProductRepository.java @@ -0,0 +1,122 @@ +package com.mysite.knitly.domain.product.product.repository; +import org.springframework.data.repository.query.Param; + +import com.mysite.knitly.domain.product.product.dto.ProductWithThumbnailDto; +import com.mysite.knitly.domain.product.product.entity.Product; +import com.mysite.knitly.domain.product.product.entity.ProductCategory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface ProductRepository extends JpaRepository { + //n+1 ๋ฌธ์ œ ํ•ด๊ฒฐ์„ ์œ„ํ•œ fetch join + @Query("SELECT p FROM Product p JOIN FETCH p.user WHERE p.productId = :productId") + Optional findByIdWithUser(Long productId); + + // ์ „์ฒด ์ƒํ’ˆ ์กฐํšŒ (์‚ญ์ œ๋˜์ง€ ์•Š์€ ๊ฒƒ๋งŒ) + Page findByIsDeletedFalse(Pageable pageable); + + // ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ์กฐํšŒ + Page findByProductCategoryAndIsDeletedFalse( + ProductCategory category, Pageable pageable); + + // ๋ฌด๋ฃŒ ์ƒํ’ˆ ์กฐํšŒ (price = 0) + Page findByPriceAndIsDeletedFalse(Double price, Pageable pageable); + + // ํ•œ์ •ํŒ๋งค ์กฐํšŒ (stockQuantity != null) + Page findByStockQuantityIsNotNullAndIsDeletedFalse(Pageable pageable); + + // productId๋กœ ์—ฌ๋Ÿฌ ๊ฐœ ์กฐํšŒ (์ธ๊ธฐ์ˆœ์šฉ - Redis์—์„œ ๋ฐ›์€ ID๋กœ ์กฐํšŒ) + List findByProductIdInAndIsDeletedFalse(List productIds); + + // User + Design + ProductImages fetch join์œผ๋กœ ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ + @Query("SELECT p FROM Product p " + + "JOIN FETCH p.user " + + "JOIN FETCH p.design " + + "LEFT JOIN FETCH p.productImages " + // ์ด๋ฏธ์ง€๊ฐ€ ์—†์„ ์ˆ˜๋„ ์žˆ์œผ๋ฏ€๋กœ LEFT JOIN + "WHERE p.productId = :productId AND p.isDeleted = false") + Optional findProductDetailById(Long productId); + + /** + * userId๋กœ ํŒ๋งค ์ƒํ’ˆ ์กฐํšŒ (๋Œ€ํ‘œ ์ด๋ฏธ์ง€ ํฌํ•จ) + * + * sortOrder = 1์ธ ๋Œ€ํ‘œ ์ด๋ฏธ์ง€๋งŒ LEFT JOIN + * DTO ํ”„๋กœ์ ์…˜์œผ๋กœ ํ•œ ๋ฒˆ์˜ ์ฟผ๋ฆฌ๋กœ ์กฐํšŒ + */ + @Query(""" + SELECT new com.mysite.knitly.domain.product.product.dto.ProductWithThumbnailDto( + p.productId, + p.title, + p.productCategory, + p.price, + p.purchaseCount, + p.likeCount, + p.stockQuantity, + p.avgReviewRating, + p.createdAt, + pi.productImageUrl + ) + FROM Product p + LEFT JOIN ProductImage pi ON pi.product.productId = p.productId + AND pi.sortOrder = 1 + WHERE p.user.userId = :userId + AND p.isDeleted = false + ORDER BY p.createdAt DESC + """) + Page findByUserIdWithThumbnail(@Param("userId") Long userId, Pageable pageable); + + /** + * ์ „์ฒด ์ƒํ’ˆ ์กฐํšŒ (์ด๋ฏธ์ง€ ํฌํ•จ, N+1 ๋ฐฉ์ง€) + */ + @Query("SELECT DISTINCT p FROM Product p " + + "LEFT JOIN FETCH p.productImages " + + "WHERE p.isDeleted = false") + Page findAllWithImagesAndNotDeleted(Pageable pageable); + + /** + * ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ์กฐํšŒ (์ด๋ฏธ์ง€ ํฌํ•จ, N+1 ๋ฐฉ์ง€) + */ + @Query("SELECT DISTINCT p FROM Product p " + + "LEFT JOIN FETCH p.productImages " + + "WHERE p.productCategory = :category AND p.isDeleted = false") + Page findByCategoryWithImagesAndNotDeleted( + @Param("category") ProductCategory category, + Pageable pageable + ); + + /** + * ๋ฌด๋ฃŒ ์ƒํ’ˆ ์กฐํšŒ (์ด๋ฏธ์ง€ ํฌํ•จ, N+1 ๋ฐฉ์ง€) + */ + @Query("SELECT DISTINCT p FROM Product p " + + "LEFT JOIN FETCH p.productImages " + + "WHERE p.price = :price AND p.isDeleted = false") + Page findByPriceWithImagesAndNotDeleted( + @Param("price") Double price, + Pageable pageable + ); + + /** + * ํ•œ์ •ํŒ๋งค ์กฐํšŒ (์ด๋ฏธ์ง€ ํฌํ•จ, N+1 ๋ฐฉ์ง€) + */ + @Query("SELECT DISTINCT p FROM Product p " + + "LEFT JOIN FETCH p.productImages " + + "WHERE p.stockQuantity IS NOT NULL AND p.isDeleted = false") + Page findLimitedWithImagesAndNotDeleted(Pageable pageable); + + /** + * productId ๋ฆฌ์ŠคํŠธ๋กœ ์—ฌ๋Ÿฌ ๊ฐœ ์กฐํšŒ (์ด๋ฏธ์ง€ ํฌํ•จ, N+1 ๋ฐฉ์ง€) + */ + @Query("SELECT DISTINCT p FROM Product p " + + "LEFT JOIN FETCH p.productImages " + + "WHERE p.productId IN :productIds AND p.isDeleted = false") + List findByProductIdInWithImagesAndNotDeleted( + @Param("productIds") List productIds + ); + +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/product/service/ProductService.java b/backend/src/main/java/com/mysite/knitly/domain/product/product/service/ProductService.java new file mode 100644 index 0000000..ec62b2f --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/product/service/ProductService.java @@ -0,0 +1,415 @@ +package com.mysite.knitly.domain.product.product.service; + +import com.mysite.knitly.domain.design.entity.Design; +import com.mysite.knitly.domain.design.repository.DesignRepository; +import com.mysite.knitly.domain.product.like.repository.ProductLikeRepository; +import com.mysite.knitly.domain.product.product.dto.*; +import com.mysite.knitly.domain.product.product.entity.*; +import com.mysite.knitly.domain.product.product.repository.ProductRepository; +import com.mysite.knitly.domain.product.review.repository.ReviewRepository; +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.domain.user.repository.UserRepository; +import com.mysite.knitly.global.exception.ErrorCode; +import com.mysite.knitly.global.exception.ServiceException; +import com.mysite.knitly.global.util.FileStorageService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Transactional +@RequiredArgsConstructor +public class ProductService { + + private final ProductRepository productRepository; + private final DesignRepository designRepository; + private final RedisProductService redisProductService; + private final FileStorageService fileStorageService; + private final ProductLikeRepository productLikeRepository; + private final ReviewRepository reviewRepository; + private final UserRepository userRepository; + + @Transactional + public ProductRegisterResponse registerProduct(User seller, Long designId, ProductRegisterRequest request) { + + Design design = designRepository.findById(designId) + .orElseThrow(() -> new ServiceException(ErrorCode.DESIGN_NOT_FOUND)); + + // ๋„์•ˆ ๋“ฑ๋ก์‹œ [ํŒ๋งค ์ค‘] ์ƒํƒœ๋กœ ๋ณ€๊ฒฝ + design.startSale(); + + Product product = Product.builder() + .title(request.title()) + .description(request.description()) + .productCategory(request.productCategory()) + .sizeInfo(request.sizeInfo()) + .price(request.price()) + .stockQuantity(request.stockQuantity()) + .user(seller) // ํŒ๋งค์ž ์ •๋ณด ์—ฐ๊ฒฐ + .design(design) // ๋„์•ˆ ์ •๋ณด ์—ฐ๊ฒฐ + .isDeleted(false) // ์ดˆ๊ธฐ ์ƒํƒœ: ํŒ๋งค ์ค‘ + .purchaseCount(0) // ์ดˆ๊ธฐ๊ฐ’ ์„ค์ • + .likeCount(0) // ์ดˆ๊ธฐ๊ฐ’ ์„ค์ • + .build(); + + List productImages = saveProductImages(request.productImageUrls()); + product.addProductImages(productImages); + + Product savedProduct = productRepository.save(product); + + List imageUrls = savedProduct.getProductImages().stream() + .map(ProductImage::getProductImageUrl) + .collect(Collectors.toList()); + + return ProductRegisterResponse.from(savedProduct, imageUrls); + } + + @Transactional + public ProductModifyResponse modifyProduct(User currentUser, Long productId, ProductModifyRequest request) { + Product product = findProductById(productId); + + if (product.getIsDeleted()) { + throw new ServiceException(ErrorCode.PRODUCT_ALREADY_DELETED); + } + + if (!product.getUser().getUserId().equals(currentUser.getUserId())) { + throw new ServiceException(ErrorCode.PRODUCT_MODIFY_UNAUTHORIZED); + } + + product.update( + request.description(), + request.productCategory(), + request.sizeInfo(), + request.stockQuantity() + ); + +// 1. ๊ธฐ์กด ์ด๋ฏธ์ง€ URL ์ „์ฒด + List oldImageUrls = product.getProductImages().stream() + .map(ProductImage::getProductImageUrl) + .collect(Collectors.toList()); + +// 2. ์œ ์ง€ํ•  ๊ธฐ์กด ์ด๋ฏธ์ง€ URL ๋ชฉ๋ก (ํ”„๋ก ํŠธ์—์„œ ์ „๋‹ฌ๋œ ๊ฐ’) + List existingImageUrls = request.existingImageUrls() != null + ? request.existingImageUrls() + : new ArrayList<>(); + +// 3. ์‚ญ์ œํ•  ์ด๋ฏธ์ง€ = oldImageUrls - existingImageUrls + List deletedImageUrls = oldImageUrls.stream() + .filter(url -> !existingImageUrls.contains(url)) + .collect(Collectors.toList()); + +// 4. ์ƒˆ๋กœ์šด ์ด๋ฏธ์ง€ ํŒŒ์ผ์„ ์ €์žฅ + List newProductImages = saveProductImages(request.productImageUrls()); + +// 5. ์œ ์ง€ํ•  ๊ธฐ์กด ์ด๋ฏธ์ง€ + ์ƒˆ ์ด๋ฏธ์ง€ ํ•ฉ์น˜๊ธฐ + List mergedImages = new ArrayList<>(); + +// ๊ธฐ์กด ์ด๋ฏธ์ง€ ์ค‘ ์œ ์ง€ ๋Œ€์ƒ๋งŒ ๋‹ค์‹œ ์ถ”๊ฐ€ + for (ProductImage oldImg : product.getProductImages()) { + if (existingImageUrls.contains(oldImg.getProductImageUrl())) { + mergedImages.add(oldImg); + } + } + +// ์ƒˆ ์ด๋ฏธ์ง€ ์ถ”๊ฐ€ + mergedImages.addAll(newProductImages); + +// 6. ์—”ํ‹ฐํ‹ฐ ๋ฐ˜์˜ (๊ธฐ์กด ์ด๋ฏธ์ง€ ์ค‘ ์œ ์ง€ ๋Œ€์ƒ์€ ๊ทธ๋Œ€๋กœ, ์‚ญ์ œ ๋Œ€์ƒ์€ orphanRemoval๋กœ DB์—์„œ ์ œ๊ฑฐ) + product.addProductImages(mergedImages); + +// 7. ์‚ญ์ œํ•  ์ด๋ฏธ์ง€ ํŒŒ์ผ ์‹ค์ œ ์‚ญ์ œ (S3, ๋กœ์ปฌ ๋“ฑ) + deletedImageUrls.forEach(fileStorageService::deleteFile); + + + List currentImageUrls = product.getProductImages().stream() + .map(ProductImage::getProductImageUrl) + .collect(Collectors.toList()); + + return ProductModifyResponse.from(product, currentImageUrls); + } + + @Transactional + public void deleteProduct(User currentUser, Long productId) { + Product product = findProductById(productId); + + if (!product.getUser().getUserId().equals(currentUser.getUserId())) { + throw new ServiceException(ErrorCode.PRODUCT_DELETE_UNAUTHORIZED); + } + + // ์†Œํ”„ํŠธ ๋”œ๋ฆฌํŠธ ์ฒ˜๋ฆฌ (isDeleted = true) + product.softDelete(); + + // [ํŒ๋งค์ค‘] ๋„์•ˆ์„ [ํŒ๋งค ์ค‘์ง€]๋กœ ๋ณ€๊ฒฝ + product.getDesign().stopSale(); + } + + @Transactional + public void relistProduct(User currentUser, Long productId) { + Product product = findProductById(productId); + + if (!product.getUser().getUserId().equals(currentUser.getUserId())) { + throw new ServiceException(ErrorCode.PRODUCT_MODIFY_UNAUTHORIZED); // ์ˆ˜์ • ๊ถŒํ•œ ์—๋Ÿฌ ์žฌ์‚ฌ์šฉ + } + + // 3. Product์™€ Design ์ƒํƒœ๋ฅผ 'ํŒ๋งค ์ค‘'์œผ๋กœ ์›๋ณต + product.relist(); // Product์˜ isDeleted๋ฅผ false๋กœ ๋ณ€๊ฒฝ + product.getDesign().relist(); // Design์˜ designState๋ฅผ ON_SALE์œผ๋กœ ๋ณ€๊ฒฝ + } + + private List saveProductImages(List imageFiles) { + if (imageFiles == null || imageFiles.isEmpty()) { + return new ArrayList<>(); + } + + List productImages = new ArrayList<>(); + for (MultipartFile file : imageFiles) { + if (file.isEmpty()) continue; + + // FileStorageService์—๊ฒŒ "product" ๋„๋ฉ”์ธ์˜ ํŒŒ์ผ ์ €์žฅ์„ ์œ„์ž„ํ•˜๊ณ  URL๋งŒ ๋ฐ›์Œ + String url = fileStorageService.storeFile(file, "product"); + + ProductImage productImage = ProductImage.builder() + .productImageUrl(url) + .build(); + productImages.add(productImage); + } + return productImages; + } + + private Product findProductById(Long productId){ + return productRepository.findByIdWithUser(productId) + .orElseThrow(() -> new ServiceException(ErrorCode.PRODUCT_NOT_FOUND)); + } + + // ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ + @Transactional(readOnly = true) + public Page getProducts( + User user, // ์ปจํŠธ๋กค๋Ÿฌ์—์„œ ๋ฐ›์€ User ๊ฐ์ฒด + ProductCategory category, + ProductFilterType filterType, + ProductSortType sortType, + Pageable pageable) { + + ProductFilterType effectiveFilter = (filterType == null) ? ProductFilterType.ALL : filterType; + + ProductCategory effectiveCategory = + (effectiveFilter == ProductFilterType.ALL) ? category : null; + + Page productPage; + + if (sortType == ProductSortType.POPULAR) { + productPage = getProductsByPopular(effectiveCategory, effectiveFilter, pageable); + } else { + Pageable sortedPageable = createPageable(pageable, sortType); + productPage = getFilteredProducts(effectiveCategory, effectiveFilter, sortedPageable); + } + + // '์ข‹์•„์š”' ๋ˆ„๋ฅธ ์ƒํ’ˆ ID ๋ชฉ๋ก์„ ํ•œ ๋ฒˆ์— ์กฐํšŒ + Set likedProductIds = getLikedProductIds(user, productPage.getContent()); + + // DTO์˜ from(Product, boolean) ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋ณ€ํ™˜ + return productPage.map(product -> + ProductListResponse.from( + product, + likedProductIds.contains(product.getProductId()) + ) + ); + } + + private Set getLikedProductIds(User user, List products) { + // 1. ๋น„๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž์ด๊ฑฐ๋‚˜ ์ƒํ’ˆ ๋ชฉ๋ก์ด ๋น„์–ด์žˆ์œผ๋ฉด ๋นˆ Set ๋ฐ˜ํ™˜ + if (user == null || products.isEmpty()) { + return Collections.emptySet(); + } + + // 2. ์ƒํ’ˆ ID ๋ชฉ๋ก ์ถ”์ถœ + List productIds = products.stream() + .map(Product::getProductId) + .toList(); + + // 3. [์ˆ˜์ •] ์ œ๊ณต๋œ ๋ ˆํฌ์ง€ํ† ๋ฆฌ์˜ ๋ฉ”์„œ๋“œ๋ช…๊ณผ User์˜ ID ํ•„๋“œ๋ช…(userId)์„ ์‚ฌ์šฉ + // (user.getId() -> user.getUserId()๋กœ ๊ฐ€์ •) + return productLikeRepository.findLikedProductIdsByUserId( + user.getUserId(), // user.userId ํ•„๋“œ์— ์ ‘๊ทผ + productIds + ); + } + + + // ์ธ๊ธฐ์ˆœ - Redis ํ™œ์šฉ + /** + * ์ธ๊ธฐ์ˆœ ์ƒํ’ˆ ์กฐํšŒ (์ด๋ฏธ์ง€ ํฌํ•จ) + */ + private Page getProductsByPopular( + ProductCategory category, + ProductFilterType filterType, + Pageable pageable) { + + // Redis์—์„œ ์ธ๊ธฐ ์ƒํ’ˆ ID ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ + List topIds = redisProductService.getTopNPopularProducts(100); + + List products; + + if (topIds.isEmpty()) { + // Redis์— ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์œผ๋ฉด DB์—์„œ ์ง์ ‘ ์กฐํšŒ (์ด๋ฏธ์ง€ ํฌํ•จ) + Pageable top100 = PageRequest.of(0, 100, Sort.by("purchaseCount").descending()); + products = productRepository.findAllWithImagesAndNotDeleted(top100).getContent(); + } else { + // Redis์—์„œ ๊ฐ€์ ธ์˜จ ID๋กœ ์ƒํ’ˆ ์กฐํšŒ (์ด๋ฏธ์ง€ ํฌํ•จ) + List unordered = productRepository.findByProductIdInWithImagesAndNotDeleted(topIds); + + // Redis์˜ ์ˆœ์„œ๋Œ€๋กœ ์ •๋ ฌ + Map productMap = unordered.stream() + .collect(Collectors.toMap(Product::getProductId, p -> p)); + + products = topIds.stream() + .map(productMap::get) + .filter(Objects::nonNull) + .toList(); + } + + // ํ•„ํ„ฐ๋ง ์ ์šฉ + products = products.stream() + .filter(p -> matchesCondition(p, category, filterType)) + .toList(); + + // ํŽ˜์ด์ง• ์ฒ˜๋ฆฌ + return convertToPage(products, pageable); + } + + + /** + * ์กฐ๊ฑด๋ณ„ ์ƒํ’ˆ ์กฐํšŒ (์ด๋ฏธ์ง€ ํฌํ•จ) + * + * 1. ์นดํ…Œ๊ณ ๋ฆฌ ์กฐํšŒ: ํŠน์ • ์นดํ…Œ๊ณ ๋ฆฌ์˜ ์ƒํ’ˆ๋“ค + * 2. ๋ฌด๋ฃŒ ์ƒํ’ˆ ์กฐํšŒ: ๋ชจ๋“  ์นดํ…Œ๊ณ ๋ฆฌ์˜ ๋ฌด๋ฃŒ ์ƒํ’ˆ + * 3. ํ•œ์ •ํŒ๋งค ์กฐํšŒ: ๋ชจ๋“  ์นดํ…Œ๊ณ ๋ฆฌ์˜ ํ•œ์ •ํŒ๋งค ์ƒํ’ˆ + * 4. ์ „์ฒด ์กฐํšŒ: ๋ชจ๋“  ์ƒํ’ˆ + */ + private Page getFilteredProducts( + ProductCategory category, + ProductFilterType filterType, + Pageable pageable + ) { + + // 1. ์นดํ…Œ๊ณ ๋ฆฌ ์กฐํšŒ (ALL) + if (category != null) { + return productRepository.findByCategoryWithImagesAndNotDeleted(category, pageable); + } + + // 2. ๋ฌด๋ฃŒ ์ƒํ’ˆ ์กฐํšŒ (์นดํ…Œ๊ณ ๋ฆฌ ๋ฌด๊ด€) + if (filterType == ProductFilterType.FREE) { + return productRepository.findByPriceWithImagesAndNotDeleted(0.0, pageable); + } + + // 3. ํ•œ์ •ํŒ๋งค ์กฐํšŒ (์นดํ…Œ๊ณ ๋ฆฌ ๋ฌด๊ด€) + if (filterType == ProductFilterType.LIMITED) { + return productRepository.findLimitedWithImagesAndNotDeleted(pageable); + } + + // 4. ์ „์ฒด ์กฐํšŒ + return productRepository.findAllWithImagesAndNotDeleted(pageable); + } + + /** + * ํŠน์ • ์œ ์ €์˜ ํŒ๋งค ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ (๋Œ€ํ‘œ ์ด๋ฏธ์ง€ ํฌํ•จ) + * + * @param userId ํŒ๋งค์ž ID + * @param pageable ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ •๋ณด + * @return ์ƒํ’ˆ ๋ชฉ๋ก (๋Œ€ํ‘œ ์ด๋ฏธ์ง€ ํฌํ•จ) + */ + public Page findProductsByUserId(Long userId, Pageable pageable) { + User user = userRepository.findById(userId).orElseThrow(() -> new ServiceException(ErrorCode.USER_NOT_FOUND)); + String sellerName= user.getName(); + // Repository์—์„œ DTO๋กœ ์กฐํšŒ + Page dtoPage = productRepository.findByUserIdWithThumbnail(userId, pageable); + + // DTO -> Response ๋ณ€ํ™˜ + Page responsePage = dtoPage.map( + dto -> dto.toResponse(true, sellerName) // ์ฐœํ•œ ๋ชฉ๋ก์ด๋ฏ€๋กœ isLikedByUser = true + ); + + return responsePage; + } + + // ์ •๋ ฌ ์กฐ๊ฑด ์ƒ์„ฑ + private Pageable createPageable(Pageable pageable, ProductSortType sortType) { + Sort sort = switch (sortType) { + case LATEST -> Sort.by("createdAt").descending(); + case PRICE_ASC -> Sort.by("price").ascending(); + case PRICE_DESC -> Sort.by("price").descending(); + default -> Sort.unsorted(); + }; + + return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort); + } + + // ์ƒํ’ˆ์ด ์กฐํšŒ ์กฐ๊ฑด์ด ๋งž๋Š”์ง€ ํ™•์ธ + private boolean matchesCondition(Product product, ProductCategory category, ProductFilterType filterType) { + // ์นดํ…Œ๊ณ ๋ฆฌ ์กฐํšŒ + if (category != null) { + return product.getProductCategory().equals(category); + } + + // ๋ฌด๋ฃŒ ์ƒํ’ˆ ์กฐํšŒ + if (filterType == ProductFilterType.FREE) { + return product.getPrice() == 0.0; + } + + // ํ•œ์ •ํŒ๋งค ์กฐํšŒ + if (filterType == ProductFilterType.LIMITED) { + return product.getStockQuantity() != null; + } + + // ์ „์ฒด ์กฐํšŒ + return true; + } + + // ํŽ˜์ด์ง• ์ฒ˜๋ฆฌ + private Page convertToPage(List products, Pageable pageable) { + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), products.size()); + + if (start > products.size()) { + return new PageImpl<>(Collections.emptyList(), pageable, products.size()); + } + + List pageContent = products.subList(start, end); + + return new PageImpl<>(pageContent, pageable, products.size()); + } + + // ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ ๋กœ์ง ์ถ”๊ฐ€ + @Transactional(readOnly = true) // ๋ฐ์ดํ„ฐ๋ฅผ ์ฝ๊ธฐ๋งŒ ํ•˜๋ฏ€๋กœ readOnly=true๋กœ ์„ฑ๋Šฅ ์ตœ์ ํ™” + public ProductDetailResponse getProductDetail(User user, Long productId) { + // N+1 ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด User, Design ๋“ฑ ์—ฐ๊ด€ ์ •๋ณด๋ฅผ ํ•จ๊ป˜ ๊ฐ€์ ธ์˜ค๋Š” ๊ฒƒ์ด ์ข‹์Œ + Product product = productRepository.findProductDetailById(productId) // ์˜ˆ์‹œ ๋ฉ”์„œ๋“œ + .orElseThrow(() -> new ServiceException(ErrorCode.PRODUCT_NOT_FOUND)); + + // ํŒ๋งค ์ค‘์ง€๋œ ์ƒํ’ˆ์€ ์กฐํšŒ ๋ถˆ๊ฐ€ + if (product.getIsDeleted()) { + throw new ServiceException(ErrorCode.PRODUCT_NOT_FOUND); + } + + List imageUrls = product.getProductImages().stream() + .map(ProductImage::getProductImageUrl) + .collect(Collectors.toList()); + + boolean isLiked = false; + if (user != null) { + Long userId = user.getUserId(); + + isLiked = productLikeRepository.existsByUser_UserIdAndProduct_ProductId(userId, productId); + } + + long reviewCount = reviewRepository.countByProductAndIsDeletedFalse(product); + product.setReviewCount((int) reviewCount); + + return ProductDetailResponse.from(product, imageUrls, isLiked); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/product/service/RedisProductService.java b/backend/src/main/java/com/mysite/knitly/domain/product/product/service/RedisProductService.java new file mode 100644 index 0000000..a4ff1f8 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/product/service/RedisProductService.java @@ -0,0 +1,48 @@ +package com.mysite.knitly.domain.product.product.service; + +import com.mysite.knitly.domain.product.product.entity.Product; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class RedisProductService { + + private final StringRedisTemplate redisTemplate; + private static final String POPULAR_KEY = "product:popular"; + + + // ์ƒํ’ˆ ๊ตฌ๋งค์‹œ ์ธ๊ธฐ๋„ ์ฆ๊ฐ€ + public void incrementPurchaseCount(Long productId) { + redisTemplate.opsForZSet().incrementScore(POPULAR_KEY, productId.toString(), 1); + log.debug("Redis ์ธ๊ธฐ๋„ ์ฆ๊ฐ€: productId={}", productId); + } + + // ์ธ๊ธฐ์ˆœ Top N ์ƒํ’ˆ ์กฐํšŒ + public List getTopNPopularProducts(int n) { + Set top = redisTemplate.opsForZSet().reverseRange(POPULAR_KEY, 0, n - 1); + if (top == null || top.isEmpty()) return Collections.emptyList(); + return top.stream().map(Long::valueOf).toList(); + } + + // DB์˜ purchaseCount๋ฅผ Redis์— ๋™๊ธฐํ™” + public void syncFromDatabase(List products) { + products.forEach(product -> { + redisTemplate.opsForZSet().add( + POPULAR_KEY, + product.getProductId().toString(), + product.getPurchaseCount() + ); + }); + log.info("Redis ๋™๊ธฐํ™” ์™„๋ฃŒ: {} ๊ฐœ ์ƒํ’ˆ", products.size()); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/review/controller/ReviewController.java b/backend/src/main/java/com/mysite/knitly/domain/product/review/controller/ReviewController.java new file mode 100644 index 0000000..2a57686 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/review/controller/ReviewController.java @@ -0,0 +1,83 @@ +package com.mysite.knitly.domain.product.review.controller; + +import com.mysite.knitly.domain.product.review.dto.ReviewCreateRequest; +import com.mysite.knitly.domain.product.review.dto.ReviewCreateResponse; +import com.mysite.knitly.domain.product.review.dto.ReviewDeleteRequest; +import com.mysite.knitly.domain.product.review.dto.ReviewListResponse; +import com.mysite.knitly.domain.product.review.service.ReviewService; +import com.mysite.knitly.domain.user.entity.User; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + + +@RestController +@RequiredArgsConstructor +@RequestMapping +public class ReviewController { + private final ReviewService reviewService; + + @GetMapping("/reviews/form") + public ResponseEntity getReviewInfo(@RequestParam Long orderItemId) { + ReviewCreateResponse response = reviewService.getReviewFormInfo(orderItemId); + return ResponseEntity.ok(response); + } + // โ–ฒโ–ฒโ–ฒ [์ˆ˜์ • 1] โ–ฒโ–ฒโ–ฒ + + // โ–ผโ–ผโ–ผ [์ˆ˜์ • 2] ๋ฆฌ๋ทฐ ๋“ฑ๋ก ์—”๋“œํฌ์ธํŠธ ๋ณ€๊ฒฝ โ–ผโ–ผโ–ผ + // (๊ธฐ์กด) @PostMapping("products/{productId}/reviews") + @PostMapping("/reviews") + public ResponseEntity createReview( + @AuthenticationPrincipal User user, + @RequestParam Long orderItemId, + @Valid @ModelAttribute ReviewCreateRequest request + ) { + reviewService.createReview(orderItemId, user, request); + return ResponseEntity.ok().build(); + } + +// // 1. ๋ฆฌ๋ทฐ ์ž‘์„ฑ ํผ์šฉ ์ƒํ’ˆ ์ •๋ณด ์กฐํšŒ +// @GetMapping("products/{productId}/review") +// public ResponseEntity getReviewInfo(@PathVariable Long productId) { +// ReviewCreateResponse response = reviewService.getReviewFormInfo(productId); +// return ResponseEntity.ok(response); +// } +// +// // 1. ๋ฆฌ๋ทฐ ๋“ฑ๋ก +// @PostMapping("products/{productId}/reviews") +// public ResponseEntity createReview( +// @AuthenticationPrincipal User user, +// @PathVariable Long productId, +// @Valid @ModelAttribute ReviewCreateRequest request +// ) { +// reviewService.createReview(productId, user, request); +// return ResponseEntity.ok().build(); +// } + + // 2๏ธ. ๋ฆฌ๋ทฐ ์†Œํ”„ํŠธ ์‚ญ์ œ(๋งˆ์ด ํŽ˜์ด์ง€์—์„œ) + @DeleteMapping("/reviews/{reviewId}") + public ResponseEntity deleteReview( + @AuthenticationPrincipal User user, + @PathVariable Long reviewId + ) { + reviewService.deleteReview(reviewId, user); + return ResponseEntity.noContent().build(); + } + + // 3. ํŠน์ • ์ƒํ’ˆ ๋ฆฌ๋ทฐ ๋ชฉ๋ก ์กฐํšŒ + @GetMapping("/products/{productId}/reviews") + public ResponseEntity> getReviewsByProduct( + @PathVariable Long productId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + Page reviews = reviewService.getReviewsByProduct(productId, page, size); + return ResponseEntity.ok(reviews); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/review/dto/ReviewCreateRequest.java b/backend/src/main/java/com/mysite/knitly/domain/product/review/dto/ReviewCreateRequest.java new file mode 100644 index 0000000..d9b9655 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/review/dto/ReviewCreateRequest.java @@ -0,0 +1,22 @@ +package com.mysite.knitly.domain.product.review.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +public record ReviewCreateRequest( + @NotNull(message = "๋ฆฌ๋ทฐ ์ ์ˆ˜๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Min(value = 1, message = "ํ‰์ ์€ 1~5 ์‚ฌ์ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + @Max(value = 5, message = "ํ‰์ ์€ 1~5 ์‚ฌ์ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + Integer rating, + + @NotNull(message = "๋ฆฌ๋ทฐ ๋‚ด์šฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + String content, + + @Size(max = 10, message = "๋ฆฌ๋ทฐ ์ด๋ฏธ์ง€๋Š” ์ตœ๋Œ€ 10๊ฐœ๊นŒ์ง€ ๋“ฑ๋กํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + List reviewImageUrls +) {} diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/review/dto/ReviewCreateResponse.java b/backend/src/main/java/com/mysite/knitly/domain/product/review/dto/ReviewCreateResponse.java new file mode 100644 index 0000000..77b0b6a --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/review/dto/ReviewCreateResponse.java @@ -0,0 +1,6 @@ +package com.mysite.knitly.domain.product.review.dto; + +public record ReviewCreateResponse( + String productTitle, + String productThumbnailUrl +) {} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/review/dto/ReviewDeleteRequest.java b/backend/src/main/java/com/mysite/knitly/domain/product/review/dto/ReviewDeleteRequest.java new file mode 100644 index 0000000..388422c --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/review/dto/ReviewDeleteRequest.java @@ -0,0 +1,5 @@ +package com.mysite.knitly.domain.product.review.dto; + +public record ReviewDeleteRequest( + Long userId +) {} diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/review/dto/ReviewListResponse.java b/backend/src/main/java/com/mysite/knitly/domain/product/review/dto/ReviewListResponse.java new file mode 100644 index 0000000..25e1f8e --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/review/dto/ReviewListResponse.java @@ -0,0 +1,26 @@ +package com.mysite.knitly.domain.product.review.dto; + +import com.mysite.knitly.domain.product.review.entity.Review; + +import java.time.LocalDateTime; +import java.util.List; + +public record ReviewListResponse( + Long reviewId, + Integer rating, + String content, + LocalDateTime createdAt, + String userName, + List reviewImageUrls +) { + public static ReviewListResponse from(Review review, List imageUrls) { + return new ReviewListResponse( + review.getReviewId(), + review.getRating(), + review.getContent(), + review.getCreatedAt(), + review.getUser().getName(), + imageUrls + ); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/review/entity/Review.java b/backend/src/main/java/com/mysite/knitly/domain/product/review/entity/Review.java new file mode 100644 index 0000000..10d93c9 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/review/entity/Review.java @@ -0,0 +1,71 @@ +package com.mysite.knitly.domain.product.review.entity; + +import com.mysite.knitly.domain.order.entity.OrderItem; +import com.mysite.knitly.domain.product.product.entity.Product; +import com.mysite.knitly.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "reviews") +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class Review { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long reviewId; + + @Column(nullable = false) + private Integer rating; // TINYINT, 1~5 ๋ฒ”์œ„ + + @Column(nullable = false, length = 300) + private String content; + + @CreatedDate + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_item_id", nullable = false) + private OrderItem orderItem; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id", nullable = false) + private Product product; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false) + @Builder.Default + private Boolean isDeleted = false; + + @OneToMany(mappedBy = "review", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List reviewImages = new ArrayList<>(); + + public void setIsDeleted(boolean isDeleted) { + this.isDeleted = isDeleted; + } + + public void addReviewImages(List images) { + this.reviewImages.clear(); + if (images != null) { + this.reviewImages.addAll(images); + images.forEach(image -> image.setReview(this)); // ์–‘๋ฐฉํ–ฅ ์—ฐ๊ด€๊ด€๊ณ„ ์„ค์ • + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/review/entity/ReviewImage.java b/backend/src/main/java/com/mysite/knitly/domain/product/review/entity/ReviewImage.java new file mode 100644 index 0000000..4eb49f3 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/review/entity/ReviewImage.java @@ -0,0 +1,46 @@ +package com.mysite.knitly.domain.product.review.entity; + +import com.mysite.knitly.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.util.UUID; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "review_images") +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class ReviewImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long reviewImageId; + + private String reviewImageUrl; + + @Builder.Default + private Integer sortOrder = 0; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "review_id", nullable = false) + private Review review; + + public void setReview(Review review) { + this.review = review; + } +} + +//CREATE TABLE `review_images` ( +// `review_image_id` BIGINT NOT NULL DEFAULT AUTO_INCREMENT, +// `review_image_url` VARCHAR(255) NULL, +// `sort_order` INT NULL DEFAULT 0, +// `review_id` BIGINT NOT NULL DEFAULT AUTO_INCREMENT +//); \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/review/repository/ReviewImageRepository.java b/backend/src/main/java/com/mysite/knitly/domain/product/review/repository/ReviewImageRepository.java new file mode 100644 index 0000000..8ca23a3 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/review/repository/ReviewImageRepository.java @@ -0,0 +1,9 @@ +package com.mysite.knitly.domain.product.review.repository; + +import com.mysite.knitly.domain.product.review.entity.ReviewImage; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ReviewImageRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/review/repository/ReviewRepository.java b/backend/src/main/java/com/mysite/knitly/domain/product/review/repository/ReviewRepository.java new file mode 100644 index 0000000..3702131 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/review/repository/ReviewRepository.java @@ -0,0 +1,31 @@ +package com.mysite.knitly.domain.product.review.repository; + +import com.mysite.knitly.domain.product.product.entity.Product; +import com.mysite.knitly.domain.product.review.entity.Review; +import jakarta.persistence.Entity; +import org.springframework.data.domain.Page; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +@Repository +public interface ReviewRepository extends JpaRepository { + @EntityGraph(attributePaths = {"user", "reviewImages"}) + List findByProduct_ProductIdAndIsDeletedFalse(Long productId); + // ์ถ”ํ›„ ํŽ˜์ด์ง•์šฉ + + @EntityGraph(attributePaths = {"user", "reviewImages"}) + Page findByProduct_ProductIdAndIsDeletedFalse(Long productId, Pageable pageable); + + //๋งˆ์ดํŽ˜์ด์ง€ ๋ฆฌ๋ทฐ ์กฐํšŒ + @EntityGraph(attributePaths = {"product", "reviewImages"}) + List findByUser_UserIdAndIsDeletedFalse(Long userId, Pageable pageable); + + long countByUser_UserIdAndIsDeletedFalse(Long userId); + + long countByProductAndIsDeletedFalse(Product product); + +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/product/review/service/ReviewService.java b/backend/src/main/java/com/mysite/knitly/domain/product/review/service/ReviewService.java new file mode 100644 index 0000000..1514b20 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/product/review/service/ReviewService.java @@ -0,0 +1,148 @@ +package com.mysite.knitly.domain.product.review.service; + +import com.mysite.knitly.domain.order.entity.OrderItem; +import com.mysite.knitly.domain.order.repository.OrderItemRepository; +import com.mysite.knitly.domain.product.product.entity.Product; +import com.mysite.knitly.domain.product.product.repository.ProductRepository; +import com.mysite.knitly.domain.product.review.dto.ReviewCreateRequest; +import com.mysite.knitly.domain.product.review.dto.ReviewCreateResponse; +import com.mysite.knitly.domain.product.review.dto.ReviewDeleteRequest; +import com.mysite.knitly.domain.product.review.dto.ReviewListResponse; +import com.mysite.knitly.domain.product.review.entity.Review; +import com.mysite.knitly.domain.product.review.entity.ReviewImage; +import com.mysite.knitly.domain.product.review.repository.ReviewRepository; +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.domain.user.repository.UserRepository; +import com.mysite.knitly.global.exception.ErrorCode; +import com.mysite.knitly.global.exception.ServiceException; +import com.mysite.knitly.global.util.FileNameUtils; +import com.mysite.knitly.global.util.ImageValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.beans.factory.annotation.Value; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class ReviewService { + + private final ReviewRepository reviewRepository; + private final ProductRepository productRepository; + private final UserRepository userRepository; + private final OrderItemRepository orderItemRepository; + + String uploadDir = System.getProperty("user.dir") + "/uploads/review/"; + String urlPrefix = "/review/"; + + + public ReviewCreateResponse getReviewFormInfo(Long orderItemId) { + OrderItem orderItem = orderItemRepository.findById(orderItemId) + .orElseThrow(() -> new ServiceException(ErrorCode.ORDER_ITEM_NOT_FOUND)); // (ErrorCode ์ถ”๊ฐ€ ํ•„์š”) + + Product product = orderItem.getProduct(); + + String thumbnailUrl = null; + if (product.getProductImages() != null && !product.getProductImages().isEmpty()) { + thumbnailUrl = product.getProductImages().get(0).getProductImageUrl(); + } + + return new ReviewCreateResponse(product.getTitle(), thumbnailUrl); + } + + // 1. ๋ฆฌ๋ทฐ ๋“ฑ๋ก + @Transactional + public void createReview(Long orderItemId, User user, ReviewCreateRequest request) { + // 1. orderItemId๋กœ OrderItem์„ ์ฐพ์Šต๋‹ˆ๋‹ค. + OrderItem orderItem = orderItemRepository.findById(orderItemId) + .orElseThrow(() -> new ServiceException(ErrorCode.ORDER_ITEM_NOT_FOUND)); + + // 2. OrderItem์—์„œ Product๋ฅผ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค. + Product product = orderItem.getProduct(); + + Review review = Review.builder() + .user(user) + .product(product) + .orderItem(orderItem) + .rating(request.rating()) + .content(request.content()) + .build(); + + List reviewImages = new ArrayList<>(); + + if (request.reviewImageUrls() != null && !request.reviewImageUrls().isEmpty()) { + new File(uploadDir).mkdirs(); + + List imageFiles = request.reviewImageUrls(); + + for (int i = 0; i < imageFiles.size(); i++) { + MultipartFile file = imageFiles.get(i); + if (file.isEmpty()) continue; + + String originalFilename = file.getOriginalFilename(); + try { + String filename = UUID.randomUUID() + "_" + originalFilename; + Path path = Path.of(uploadDir, filename); + Files.write(path, file.getBytes()); + + String url = urlPrefix + filename; + + ReviewImage reviewImage = ReviewImage.builder() + .review(review) // โœ… ๋ฐ˜๋“œ์‹œ review ์„ค์ • + .reviewImageUrl(url) + .sortOrder(i) + .build(); + reviewImages.add(reviewImage); + + } catch (IOException e) { + throw new ServiceException(ErrorCode.REVIEW_IMAGE_SAVE_FAILED); + } + } + } + + review.addReviewImages(reviewImages); + reviewRepository.save(review); + } + + + // 2. ๋ฆฌ๋ทฐ ์†Œํ”„ํŠธ ์‚ญ์ œ (๋ณธ์ธ ๋ฆฌ๋ทฐ๋งŒ) + @Transactional + public void deleteReview(Long reviewId, User user) { + Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new ServiceException(ErrorCode.REVIEW_NOT_FOUND)); + + if (!review.getUser().getUserId().equals(user.getUserId())) { + throw new ServiceException(ErrorCode.REVIEW_NOT_AUTHORIZED); + } + + review.setIsDeleted(true); + } + + // 3๏ธ. ํŠน์ • ์ƒํ’ˆ ๋ฆฌ๋ทฐ ๋ชฉ๋ก ์กฐํšŒ + @Transactional(readOnly = true) + public Page getReviewsByProduct(Long productId, int page, int size) { + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + + Page reviews = reviewRepository.findByProduct_ProductIdAndIsDeletedFalse(productId, pageable); + + return reviews.map(review -> { + List imageUrls = review.getReviewImages().stream() + .map(ReviewImage::getReviewImageUrl) + .toList(); + return ReviewListResponse.from(review, imageUrls); + }); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/user/controller/UserController.java b/backend/src/main/java/com/mysite/knitly/domain/user/controller/UserController.java new file mode 100644 index 0000000..bf17195 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/user/controller/UserController.java @@ -0,0 +1,203 @@ +// UserController.java์˜ ๋กœ๊ทธ์•„์›ƒ๊ณผ ํšŒ์›ํƒˆํ‡ด ๋ฉ”์„œ๋“œ ์ˆ˜์ • + +package com.mysite.knitly.domain.user.controller; + +import com.mysite.knitly.domain.product.product.dto.ProductListResponse; +import com.mysite.knitly.domain.product.product.service.ProductService; +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.domain.user.service.UserService; +import com.mysite.knitly.utility.auth.service.AuthService; +import com.mysite.knitly.utility.cookie.CookieUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +@Tag(name = "User", description = "์‚ฌ์šฉ์ž ๊ด€๋ฆฌ API") +@Slf4j +@RestController +@RequestMapping("/users") +@RequiredArgsConstructor +public class UserController { + + private final AuthService authService; + private final ProductService productService; + private final CookieUtil cookieUtil; // ์ถ”๊ฐ€! + + private static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken"; // ์ถ”๊ฐ€! + private final UserService userService; + + /** + * ํ˜„์žฌ ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž ์ •๋ณด ์กฐํšŒ (JWT ์ธ์ฆ ํ•„์š”) + * GET /users/me + */ + @Operation( + summary = "๋‚ด ์ •๋ณด ์กฐํšŒ", + description = "ํ˜„์žฌ ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž์˜ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. JWT ํ† ํฐ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "์กฐํšŒ ์„ฑ๊ณต", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "userId": "1", + "email": "user@example.com", + "name": "ํ™๊ธธ๋™", + "provider": "GOOGLE", + "createdAt": "2025-01-20T15:52:58" + } + """) + ) + ), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ - ํ† ํฐ ์—†์Œ ๋˜๋Š” ๋งŒ๋ฃŒ") + }) + @SecurityRequirement(name = "Bearer Authentication") + @GetMapping("/me") + public ResponseEntity> getCurrentUser(@AuthenticationPrincipal User user) { + + if (user == null) { + log.warn("User is null in /api/user/me"); + return ResponseEntity.status(401).build(); + } + + log.info("User info requested - userId: {}", user.getUserId()); + + Map response = new HashMap<>(); + response.put("userId", user.getUserId()); + response.put("email", user.getEmail()); + response.put("name", user.getName()); + response.put("provider", user.getProvider()); + response.put("createdAt", user.getCreatedAt()); + + return ResponseEntity.ok(response); + } + + /** + * ๋กœ๊ทธ์•„์›ƒ (๊ฐœ์„ ๋จ) + * POST /users/logout + * + * Redis์—์„œ Refresh Token ์‚ญ์ œ + HTTP-only ์ฟ ํ‚ค ์‚ญ์ œ + */ + @Operation( + summary = "๋กœ๊ทธ์•„์›ƒ", + description = "๋กœ๊ทธ์•„์›ƒํ•˜๊ณ  Redis์— ์ €์žฅ๋œ Refresh Token๊ณผ HTTP-only ์ฟ ํ‚ค๋ฅผ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. JWT ํ† ํฐ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "๋กœ๊ทธ์•„์›ƒ ์„ฑ๊ณต", + content = @Content( + mediaType = "text/plain", + examples = @ExampleObject(value = "๋กœ๊ทธ์•„์›ƒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") + ) + ), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ") + }) + @SecurityRequirement(name = "Bearer Authentication") + @PostMapping("/logout") + public ResponseEntity logout( + @AuthenticationPrincipal User user, + HttpServletResponse response) { // HttpServletResponse ์ถ”๊ฐ€! + + if (user == null) { + return ResponseEntity.status(401).body("์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."); + } + + log.info("Logout requested - userId: {}", user.getUserId()); + + // 1. Redis์—์„œ Refresh Token ์‚ญ์ œ + authService.logout(user.getUserId()); + log.info("Refresh Token deleted from Redis - userId: {}", user.getUserId()); + + // 2. HTTP-only ์ฟ ํ‚ค ์‚ญ์ œ + cookieUtil.deleteCookie(response, REFRESH_TOKEN_COOKIE_NAME); + log.info("Refresh Token cookie deleted"); + + return ResponseEntity.ok("๋กœ๊ทธ์•„์›ƒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + } + + /** + * ํšŒ์›ํƒˆํ‡ด (๊ฐœ์„ ๋จ) + * DELETE /users/me + * + * DB์—์„œ ์‚ฌ์šฉ์ž ์ •๋ณด ์‚ญ์ œ + Redis์—์„œ Refresh Token ์‚ญ์ œ + HTTP-only ์ฟ ํ‚ค ์‚ญ์ œ + */ + @Operation( + summary = "ํšŒ์›ํƒˆํ‡ด", + description = "ํšŒ์›ํƒˆํ‡ดํ•˜๊ณ  DB์™€ Redis์—์„œ ๋ชจ๋“  ์‚ฌ์šฉ์ž ๋ฐ์ดํ„ฐ๋ฅผ ์‚ญ์ œํ•˜๋ฉฐ, HTTP-only ์ฟ ํ‚ค๋„ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. JWT ํ† ํฐ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "ํƒˆํ‡ด ์„ฑ๊ณต", + content = @Content( + mediaType = "text/plain", + examples = @ExampleObject(value = "ํšŒ์›ํƒˆํ‡ด๊ฐ€ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") + ) + ), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ") + }) + @SecurityRequirement(name = "Bearer Authentication") + @DeleteMapping("/me") + public ResponseEntity deleteAccount( + @AuthenticationPrincipal User user, + HttpServletResponse response) { // HttpServletResponse ์ถ”๊ฐ€! + + if (user == null) { + log.warn("User is null in DELETE /user/me"); + return ResponseEntity.status(401).body("์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."); + } + + log.info("Account deletion requested - userId: {}, email: {}", user.getUserId(), user.getEmail()); + + // 1. DB์—์„œ ์‚ฌ์šฉ์ž ์‚ญ์ œ + Redis์—์„œ Refresh Token ์‚ญ์ œ + authService.deleteAccount(user.getUserId()); + log.info("User account deleted from DB and Redis - userId: {}", user.getUserId()); + + // 2. HTTP-only ์ฟ ํ‚ค ์‚ญ์ œ + cookieUtil.deleteCookie(response, REFRESH_TOKEN_COOKIE_NAME); + log.info("Refresh Token cookie deleted"); + + return ResponseEntity.ok("ํšŒ์›ํƒˆํ‡ด๊ฐ€ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + } + + /** + * ์œ ์ €๊ฐ€ ํŒ๋งคํ•˜๋Š” ์ƒํ’ˆ ์กฐํšŒ (AT ๋ถˆํ•„์š”) + * GET user/{userId}/products + */ + @Operation( + summary = "ํŒ๋งค์ž ์ƒํ’ˆ ์กฐํšŒ", + description = "ํ•ด๋‹น ์œ ์ €๊ฐ€ ํŒ๋งค์ค‘์ธ ์ƒํ’ˆ ๋ชฉ๋ก์„ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค." + ) + @GetMapping("/{userId}/products") + @ResponseBody + public ResponseEntity> getProductsWithUserId( + @PathVariable Long userId, + @PageableDefault(size = 20) Pageable pageable + ){ + Page response = productService.findProductsByUserId(userId, pageable); + log.info("getProductsWithUserId response: {}", response); + return ResponseEntity.ok(response); + } + + + +} \ No newline at end of file diff --git a/src/main/java/com/mysite/knitly/domain/user/entity/UserEntity.java b/backend/src/main/java/com/mysite/knitly/domain/user/entity/Provider.java similarity index 54% rename from src/main/java/com/mysite/knitly/domain/user/entity/UserEntity.java rename to backend/src/main/java/com/mysite/knitly/domain/user/entity/Provider.java index 8f2c1f3..e0fd3e7 100644 --- a/src/main/java/com/mysite/knitly/domain/user/entity/UserEntity.java +++ b/backend/src/main/java/com/mysite/knitly/domain/user/entity/Provider.java @@ -1,4 +1,5 @@ package com.mysite.knitly.domain.user.entity; -public class UserEntity { +public enum Provider { + KAKAO, GOOGLE } diff --git a/backend/src/main/java/com/mysite/knitly/domain/user/entity/User.java b/backend/src/main/java/com/mysite/knitly/domain/user/entity/User.java new file mode 100644 index 0000000..6419bd0 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/user/entity/User.java @@ -0,0 +1,68 @@ +package com.mysite.knitly.domain.user.entity; + +import com.mysite.knitly.domain.userstore.entity.UserStore; +import com.mysite.knitly.global.jpa.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class User extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + private Long userId; // knitly ์„œ๋น„์Šค ๋‚ด์—์„œ์˜ ํ‚ค๊ฐ’ + + @Column(nullable = false, unique = true) + private String socialId; // ๊ตฌ๊ธ€์˜ ๊ณ ์œ  ID (sub) + + @Column(nullable = false) + private String email; // ๊ตฌ๊ธ€ ์ด๋ฉ”์ผ + + @Column(nullable = false, length = 50) + private String name; // ๊ตฌ๊ธ€์—์„œ ๋ฐ›์•„์˜จ ์ด๋ฆ„ + + // UserStore์™€ 1:1 ์–‘๋ฐฉํ–ฅ ๊ด€๊ณ„ + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private UserStore userStore; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private Provider provider; // GOOGLE + + // ์ •์  ํŒฉํ† ๋ฆฌ ๋ฉ”์„œ๋“œ + public static User createGoogleUser(String socialId, String email, String name) { + return User.builder() + .socialId(socialId) + .email(email) + .name(name) + .provider(Provider.GOOGLE) + .build(); + } + + // UserStore ์ดˆ๊ธฐํ™” ๋ฉ”์„œ๋“œ + @PostPersist + public void initializeUserStore() { + if (this.userStore == null) { + this.userStore = UserStore.builder() + .user(this) + .storeDetail("์•ˆ๋…•ํ•˜์„ธ์š”! ์ œ ์Šคํ† ์–ด์— ์˜ค์‹  ๊ฒƒ์„ ํ™˜์˜ํ•ฉ๋‹ˆ๋‹ค.") + .build(); + } + } +} + +//CREATE TABLE `users` ( +// `user_id` BIGINT NOT NULL, +// `social_id` VARCHAR(255) NOT NULL, +// `name` VARCHAR(50) NOT NULL, +// `provider` ENUM('KAKAO', 'GOOGLE') NOT NULL, +// `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +//); diff --git a/backend/src/main/java/com/mysite/knitly/domain/user/repository/UserRepository.java b/backend/src/main/java/com/mysite/knitly/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..9555e2d --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/user/repository/UserRepository.java @@ -0,0 +1,26 @@ +package com.mysite.knitly.domain.user.repository; + +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.domain.user.entity.Provider; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + + /** + * socialId์™€ provider๋กœ ์‚ฌ์šฉ์ž ์กฐํšŒ + * ์˜ˆ: Google์˜ sub ๊ฐ’๊ณผ GOOGLE๋กœ ๊ฒ€์ƒ‰ + */ + Optional findBySocialIdAndProvider(String socialId, Provider provider); + + /** + * ์ด๋ฉ”์ผ๋กœ ์‚ฌ์šฉ์ž ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ --> ์ถ”ํ›„ ์ด๋ฉ”์ผ์ค‘๋ณต ๊ฐ€์ž… ๋ฐฉ์ง€ ๊ธฐ๋Šฅ์œผ๋กœ ํ™•์žฅ๊ฐ€๋Šฅ + */ + boolean existsByEmail(String email); + + Optional findById(Long userId); + Optional findBySocialId(String socialId); +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/user/service/UserService.java b/backend/src/main/java/com/mysite/knitly/domain/user/service/UserService.java new file mode 100644 index 0000000..55b3901 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/user/service/UserService.java @@ -0,0 +1,72 @@ +package com.mysite.knitly.domain.user.service; + +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.domain.user.entity.Provider; +import com.mysite.knitly.domain.user.repository.UserRepository; +import com.mysite.knitly.domain.userstore.entity.UserStore; +import com.mysite.knitly.domain.userstore.repository.UserStoreRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private final UserRepository userRepository; + private final UserStoreRepository userStoreRepository; + /** + * Google OAuth๋กœ ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž ์ฒ˜๋ฆฌ + * - ์‹ ๊ทœ ์‚ฌ์šฉ์ž: ํšŒ์›๊ฐ€์ž… ์ฒ˜๋ฆฌ + * - ๊ธฐ์กด ์‚ฌ์šฉ์ž: ์ •๋ณด ์กฐํšŒ + */ + @Transactional + public User processGoogleUser(String socialId, String email, String name) { + // 1. ์ด๋ฏธ ๊ฐ€์ž…๋œ ์‚ฌ์šฉ์ž์ธ์ง€ ํ™•์ธ + return userRepository.findBySocialIdAndProvider(socialId, Provider.GOOGLE) + .orElseGet(() -> { + // 2. ์‹ ๊ทœ ์‚ฌ์šฉ์ž๋ฉด ํšŒ์›๊ฐ€์ž… + log.info("์‹ ๊ทœ Google ์‚ฌ์šฉ์ž ๊ฐ€์ž…: email={}, name={}", email, name); + + User newUser = User.createGoogleUser(socialId, email, name); + User savedUser = userRepository.save(newUser); + + log.info("ํšŒ์›๊ฐ€์ž… ์™„๋ฃŒ: userId={}", savedUser.getUserId()); + return savedUser; + }); + } + + @Transactional + public void ensureUserStore(User user) { + if (!userStoreRepository.existsByUser(user)) { + log.info("์œ ์ € ์Šคํ† ์–ด ์ƒ์„ฑ: userId={}", user.getUserId()); + userStoreRepository.save( + new UserStore(user,"์•ˆ๋…•ํ•˜์„ธ์š”! ์ œ ์Šคํ† ์–ด์— ์˜ค์‹  ๊ฒƒ์„ ํ™˜์˜ํ•ฉ๋‹ˆ๋‹ค.") + ); + } else { + log.info("๊ธฐ์กด ์Šคํ† ์–ด ์กด์žฌ: userId={}", user.getUserId()); + } + } + + /** + * userId๋กœ ์‚ฌ์šฉ์ž ์กฐํšŒ + */ + public User findById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: " + userId)); + } + + /** + * ํšŒ์›ํƒˆํ‡ด + */ + @Transactional + public void deleteUser(Long userId) { + User user = findById(userId); + userRepository.delete(user); + log.info("ํšŒ์›ํƒˆํ‡ด ์™„๋ฃŒ - userId: {}, email: {}", userId, user.getEmail()); + } + +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/userstore/controller/UserStoreController.java b/backend/src/main/java/com/mysite/knitly/domain/userstore/controller/UserStoreController.java new file mode 100644 index 0000000..31cf29f --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/userstore/controller/UserStoreController.java @@ -0,0 +1,139 @@ +package com.mysite.knitly.domain.userstore.controller; + + +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.domain.user.service.UserService; +import com.mysite.knitly.domain.userstore.dto.StoreDescriptionRequest; +import com.mysite.knitly.domain.userstore.service.UserStoreService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +@Tag(name = "Store", description = "ํŒ๋งค์ž ์Šคํ† ์–ด API") +@Slf4j +@RestController +@RequestMapping("/userstore") +@Controller +@RequiredArgsConstructor +public class UserStoreController { + + private final UserService userService; + private final UserStoreService userStoreService; + + /** + * ์Šคํ† ์–ด ์„ค๋ช… ์กฐํšŒ + * GET /userstore/{userId}/description + */ + @Operation( + summary = "์Šคํ† ์–ด ์„ค๋ช… ์กฐํšŒ", + description = "ํŒ๋งค์ž ์Šคํ† ์–ด์˜ ์„ค๋ช…์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "์กฐํšŒ ์„ฑ๊ณต", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "description": "์•ˆ๋…•ํ•˜์„ธ์š”! ์ œ ์Šคํ† ์–ด์— ์˜ค์‹  ๊ฒƒ์„ ํ™˜์˜ํ•ฉ๋‹ˆ๋‹ค." + } + """) + ) + ), + @ApiResponse(responseCode = "404", description = "์Šคํ† ์–ด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ") + }) + @GetMapping("/{userId}/description") + public ResponseEntity> getStoreDescription( + @PathVariable Long userId) { + + log.info("Fetching store description for userId: {}", userId); + + try { + String description = userStoreService.getStoreDetail(userId); + + Map response = new HashMap<>(); + response.put("description", description); + + return ResponseEntity.ok(response); + } catch (IllegalArgumentException e) { + log.error("Store not found for userId: {}", userId); + return ResponseEntity.status(404) + .body(Map.of("error", "์Šคํ† ์–ด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } + } + + /** + * ์Šคํ† ์–ด ์„ค๋ช… ์—…๋ฐ์ดํŠธ + * PUT /userstore/{userId}/description + */ + @Operation( + summary = "์Šคํ† ์–ด ์„ค๋ช… ์—…๋ฐ์ดํŠธ", + description = "ํŒ๋งค์ž ์Šคํ† ์–ด์˜ ์„ค๋ช…์„ ์—…๋ฐ์ดํŠธํ•ฉ๋‹ˆ๋‹ค. ๋ณธ์ธ์˜ ์Šคํ† ์–ด๋งŒ ์ˆ˜์ • ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "์—…๋ฐ์ดํŠธ ์„ฑ๊ณต", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "message": "์Šคํ† ์–ด ์„ค๋ช…์ด ์—…๋ฐ์ดํŠธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + } + """) + ) + ), + @ApiResponse( + responseCode = "403", + description = "๊ถŒํ•œ ์—†์Œ - ๋ณธ์ธ์˜ ์Šคํ† ์–ด๋งŒ ์ˆ˜์ • ๊ฐ€๋Šฅ" + ), + @ApiResponse( + responseCode = "404", + description = "์Šคํ† ์–ด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ" + ), + @ApiResponse( + responseCode = "401", + description = "์ธ์ฆ ์‹คํŒจ - ๋กœ๊ทธ์ธ ํ•„์š”" + ) + }) + @SecurityRequirement(name = "Bearer Authentication") + @PutMapping("/{userId}/description") + public ResponseEntity updateStoreDescription( + @PathVariable Long userId, + @RequestBody StoreDescriptionRequest request, + @AuthenticationPrincipal User currentUser) { + + // ๐Ÿ”ฅ ๊ถŒํ•œ ๊ฒ€์ฆ: ๋ณธ์ธ๋งŒ ์ˆ˜์ • ๊ฐ€๋Šฅ + if (!userId.equals(currentUser.getUserId())) { + log.warn("Unauthorized store description update attempt - userId: {}, requestUserId: {}", + currentUser.getUserId(), userId); + return ResponseEntity.status(403) + .body(Map.of("error", "๋ณธ์ธ์˜ ์Šคํ† ์–ด๋งŒ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.")); + } + + log.info("Updating store description for userId: {}", userId); + + try { + userStoreService.updateStoreDetail(userId, request.getDescription()); + return ResponseEntity.ok(Map.of("message", "์Šคํ† ์–ด ์„ค๋ช…์ด ์—…๋ฐ์ดํŠธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.")); + } catch (IllegalArgumentException e) { + log.error("Store not found for userId: {}", userId); + return ResponseEntity.status(404) + .body(Map.of("error", "์Šคํ† ์–ด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/domain/userstore/dto/StoreDescriptionRequest.java b/backend/src/main/java/com/mysite/knitly/domain/userstore/dto/StoreDescriptionRequest.java new file mode 100644 index 0000000..dbb06b3 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/userstore/dto/StoreDescriptionRequest.java @@ -0,0 +1,12 @@ +package com.mysite.knitly.domain.userstore.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class StoreDescriptionRequest { + private String description; +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/userstore/entity/UserStore.java b/backend/src/main/java/com/mysite/knitly/domain/userstore/entity/UserStore.java new file mode 100644 index 0000000..6cde250 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/userstore/entity/UserStore.java @@ -0,0 +1,40 @@ +package com.mysite.knitly.domain.userstore.entity; + +import com.mysite.knitly.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "user_stores") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class UserStore { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "store_id", nullable = false) + private Long storeId; + + // store_detail - TEXT, NULL + @Lob + @Column(name = "store_detail") + private String storeDetail; + + // 1:1 ๊ด€๊ณ„ ๋งคํ•‘: user_id ์ปฌ๋Ÿผ์„ ํ†ตํ•ด User ์—”ํ‹ฐํ‹ฐ์™€ ์—ฐ๊ฒฐ + // UserStore๊ฐ€ ๊ด€๊ณ„์˜ ์ฃผ์ธ(Owning Side)์ด ๋ฉ๋‹ˆ๋‹ค. + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + // ์—…๋ฐ์ดํŠธ ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€ + public void updateStoreDetail(String storeDetail) { + this.storeDetail = storeDetail; + } + + public UserStore(User user, String storeDetail) { + this.user = user; + this.storeDetail = storeDetail; + } +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/userstore/repository/UserStoreRepository.java b/backend/src/main/java/com/mysite/knitly/domain/userstore/repository/UserStoreRepository.java new file mode 100644 index 0000000..b6b77be --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/userstore/repository/UserStoreRepository.java @@ -0,0 +1,17 @@ +package com.mysite.knitly.domain.userstore.repository; + +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.domain.userstore.entity.UserStore; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserStoreRepository extends JpaRepository { + + /** + * userId๋กœ UserStore ์กฐํšŒ + */ + Optional findByUser_UserId(Long userId); + + boolean existsByUser(User user); +} diff --git a/backend/src/main/java/com/mysite/knitly/domain/userstore/service/UserStoreService.java b/backend/src/main/java/com/mysite/knitly/domain/userstore/service/UserStoreService.java new file mode 100644 index 0000000..2f8e9f1 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/domain/userstore/service/UserStoreService.java @@ -0,0 +1,50 @@ +package com.mysite.knitly.domain.userstore.service; + +import com.mysite.knitly.domain.userstore.entity.UserStore; +import com.mysite.knitly.domain.userstore.repository.UserStoreRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserStoreService { + + private final UserStoreRepository userStoreRepository; + + /** + * ์Šคํ† ์–ด ์„ค๋ช… ์กฐํšŒ + * + * @param userId ์‚ฌ์šฉ์ž ID + * @return ์Šคํ† ์–ด ์„ค๋ช… + */ + public String getStoreDetail(Long userId) { + UserStore userStore = userStoreRepository.findByUser_UserId(userId) + .orElseThrow(() -> new IllegalArgumentException("์Šคํ† ์–ด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + return userStore.getStoreDetail() != null + ? userStore.getStoreDetail() + : "์•ˆ๋…•ํ•˜์„ธ์š”! ์ œ ์Šคํ† ์–ด์— ์˜ค์‹  ๊ฒƒ์„ ํ™˜์˜ํ•ฉ๋‹ˆ๋‹ค."; // ๊ธฐ๋ณธ๊ฐ’ + } + + /** + * ์Šคํ† ์–ด ์„ค๋ช… ์—…๋ฐ์ดํŠธ + * + * @param userId ์‚ฌ์šฉ์ž ID + * @param storeDetail ์Šคํ† ์–ด ์„ค๋ช… + */ + @Transactional + public void updateStoreDetail(Long userId, String storeDetail) { + log.info("Updating store detail for userId: {}", userId); + + UserStore userStore = userStoreRepository.findByUser_UserId(userId) + .orElseThrow(() -> new IllegalArgumentException("์Šคํ† ์–ด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + userStore.updateStoreDetail(storeDetail); + + log.info("Store detail updated successfully for userId: {}", userId); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/global/config/FileStorageConfig.java b/backend/src/main/java/com/mysite/knitly/global/config/FileStorageConfig.java new file mode 100644 index 0000000..ebd5ec5 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/global/config/FileStorageConfig.java @@ -0,0 +1,29 @@ +package com.mysite.knitly.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.nio.file.Path; +import java.nio.file.Paths; + +@Configuration +public class FileStorageConfig implements WebMvcConfigurer { + @Value("${file.upload-dir:uploads/designs}") + private String uploadDir; + + @Value("${file.public-prefix:/files}") + private String publicPrefix; + + //๋กœ์ปฌ์— ์ €์žฅ๋œ ํŒŒ์ผ์„ HTTP๋กœ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•˜๊ฒŒ ์„ค์ • + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + Path base = Paths.get(uploadDir).toAbsolutePath().normalize(); + String location = "file:" + base.toString() + "/"; + String pattern = publicPrefix.endsWith("/**") ? publicPrefix : publicPrefix + "/**"; + + registry.addResourceHandler(pattern) + .addResourceLocations(location); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/global/config/RabbitMQConfig.java b/backend/src/main/java/com/mysite/knitly/global/config/RabbitMQConfig.java new file mode 100644 index 0000000..219e717 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/global/config/RabbitMQConfig.java @@ -0,0 +1,128 @@ +package com.mysite.knitly.global.config; + +import org.springframework.amqp.core.*; +import org.springframework.amqp.rabbit.connection.ConnectionFactory; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RabbitMQConfig { + + // --- Like ๊ด€๋ จ ์ƒ์ˆ˜ --- + public static final String LIKE_ADD_QUEUE = "like.add.queue"; + public static final String LIKE_DELETE_QUEUE = "like.delete.queue"; + public static final String LIKE_ADD_DLQ = "like.add.dlq"; + public static final String LIKE_DELETE_DLQ = "like.delete.dlq"; + + public static final String LIKE_EXCHANGE = "like.exchange"; + public static final String LIKE_ADD_ROUTING_KEY = "like.add.routingkey"; + public static final String LIKE_DELETE_ROUTING_KEY = "like.delete.routingkey"; + + public static final String DEAD_LETTER_EXCHANGE = "dead-letter.exchange"; + public static final String DEAD_LETTER_ROUTING_KEY_PREFIX = "dead."; // ๋ผ์šฐํŒ… ํ‚ค ์ ‘๋‘์‚ฌ + + @Bean + public DirectExchange deadLetterExchange() { + return new DirectExchange(DEAD_LETTER_EXCHANGE); + } + + @Bean + public TopicExchange likeExchange() { + return new TopicExchange(LIKE_EXCHANGE); + } + + @Bean + public Queue likeAddQueue() { + return QueueBuilder.durable(LIKE_ADD_QUEUE) + .withArgument("x-dead-letter-exchange", DEAD_LETTER_EXCHANGE) + .withArgument("x-dead-letter-routing-key", DEAD_LETTER_ROUTING_KEY_PREFIX + LIKE_ADD_QUEUE) + .build(); + } + + @Bean + public Queue likeDeleteQueue() { + return QueueBuilder.durable(LIKE_DELETE_QUEUE) + .withArgument("x-dead-letter-exchange", DEAD_LETTER_EXCHANGE) + .withArgument("x-dead-letter-routing-key", DEAD_LETTER_ROUTING_KEY_PREFIX + LIKE_DELETE_QUEUE) + .build(); + } + + @Bean + public Queue likeAddDeadLetterQueue() { + return QueueBuilder.durable(LIKE_ADD_DLQ).build(); + } + + @Bean + public Queue likeDeleteDeadLetterQueue() { + return QueueBuilder.durable(LIKE_DELETE_DLQ).build(); + } + + // Binding + @Bean + public Binding likeAddBinding(Queue likeAddQueue, TopicExchange likeExchange) { + return BindingBuilder.bind(likeAddQueue).to(likeExchange).with(LIKE_ADD_ROUTING_KEY); + } + + @Bean + public Binding likeDeleteBinding(Queue likeDeleteQueue, TopicExchange likeExchange) { + return BindingBuilder.bind(likeDeleteQueue).to(likeExchange).with(LIKE_DELETE_ROUTING_KEY); + } + + // DLQ Binding + @Bean + public Binding likeAddDlqBinding(Queue likeAddDeadLetterQueue, DirectExchange deadLetterExchange) { + return BindingBuilder.bind(likeAddDeadLetterQueue).to(deadLetterExchange).with(DEAD_LETTER_ROUTING_KEY_PREFIX + LIKE_ADD_QUEUE); + } + + @Bean + public Binding likeDeleteDlqBinding(Queue likeDeleteDeadLetterQueue, DirectExchange deadLetterExchange) { + return BindingBuilder.bind(likeDeleteDeadLetterQueue).to(deadLetterExchange).with(DEAD_LETTER_ROUTING_KEY_PREFIX + LIKE_DELETE_QUEUE); + } + + + @Bean + public TopicExchange orderExchange() { + return new TopicExchange("order.exchange"); + } + + @Bean + public Queue orderEmailQueue() { + return QueueBuilder.durable("order.email.queue") + .withArgument("x-dead-letter-exchange", DEAD_LETTER_EXCHANGE) + .withArgument("x-dead-letter-routing-key", DEAD_LETTER_ROUTING_KEY_PREFIX + "order.email.queue") + .build(); + } + + @Bean + public Queue orderEmailDeadLetterQueue() { + return QueueBuilder.durable("order.email.queue.dlq").build(); + } + + @Bean + public Binding orderEmailBinding(Queue orderEmailQueue, TopicExchange orderExchange) { + return BindingBuilder.bind(orderEmailQueue).to(orderExchange).with("order.completed"); + } + + @Bean + public Binding orderEmailDlqBinding(Queue orderEmailDeadLetterQueue, DirectExchange deadLetterExchange) { + return BindingBuilder.bind(orderEmailDeadLetterQueue).to(deadLetterExchange).with(DEAD_LETTER_ROUTING_KEY_PREFIX + "order.email.queue"); + } + + // JSON ๋ฉ”์‹œ์ง€ ์ปจ๋ฒ„ํ„ฐ + @Bean + public Jackson2JsonMessageConverter jackson2JsonMessageConverter() { + return new Jackson2JsonMessageConverter(); + } + + // RabbitTemplate์— JSON ์ปจ๋ฒ„ํ„ฐ ์ ์šฉ + @Bean + public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory, + Jackson2JsonMessageConverter converter) { + RabbitTemplate template = new RabbitTemplate(connectionFactory); + template.setMessageConverter(converter); + return template; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/global/config/WebConfig.java b/backend/src/main/java/com/mysite/knitly/global/config/WebConfig.java new file mode 100644 index 0000000..af8d9ec --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/global/config/WebConfig.java @@ -0,0 +1,16 @@ +package com.mysite.knitly.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + String uploadPath = System.getProperty("user.dir") + "/uploads/review/"; + registry.addResourceHandler("/review/**") + .addResourceLocations("file:" + uploadPath); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/global/email/EmailNotificationConsumer.java b/backend/src/main/java/com/mysite/knitly/global/email/EmailNotificationConsumer.java new file mode 100644 index 0000000..5d10c99 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/global/email/EmailNotificationConsumer.java @@ -0,0 +1,28 @@ +package com.mysite.knitly.global.email; + +import com.mysite.knitly.domain.order.dto.EmailNotificationDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class EmailNotificationConsumer { + + private final EmailService emailService; + + @RabbitListener(queues = "order.email.queue") + public void receiveOrderCompletionMessage(EmailNotificationDto emailDto) { + log.info("Received message for order: {}", emailDto.orderId()); + try { + emailService.sendOrderConfirmationEmail(emailDto); + log.info("Successfully sent email for order: {}", emailDto.orderId()); + } catch (Exception e) { + log.error("Failed to send email for order: {}. Error: {}", emailDto.orderId(), e.getMessage()); + // ๐Ÿšจ ์˜ˆ์™ธ๋ฅผ ๋‹ค์‹œ ๋˜์ ธ์„œ RabbitMQ๊ฐ€ ์žฌ์‹œ๋„ํ•˜๊ฑฐ๋‚˜ DLQ๋กœ ๋ณด๋‚ด๋„๋ก ํ•จ + throw new RuntimeException("Email sending failed after processing.", e); + } + } +} diff --git a/backend/src/main/java/com/mysite/knitly/global/email/EmailService.java b/backend/src/main/java/com/mysite/knitly/global/email/EmailService.java new file mode 100644 index 0000000..85e320b --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/global/email/EmailService.java @@ -0,0 +1,67 @@ +package com.mysite.knitly.global.email; + +import com.mysite.knitly.domain.order.dto.EmailNotificationDto; +import com.mysite.knitly.domain.order.entity.Order; +import com.mysite.knitly.domain.order.entity.OrderItem; +import com.mysite.knitly.domain.order.repository.OrderRepository; +import com.mysite.knitly.global.util.FileStorageService; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; + +@Slf4j +@Service +@RequiredArgsConstructor +public class EmailService { + + private final JavaMailSender javaMailSender; + private final OrderRepository orderRepository; + private final FileStorageService fileStorageService; // PDF ํŒŒ์ผ์„ ์ฝ๊ธฐ ์œ„ํ•ด ์ฃผ์ž… + + @Transactional(readOnly = true) // ์ด๋ฉ”์ผ ๋ฐœ์†ก์€ DB๋ฅผ ๋ณ€๊ฒฝํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ readOnly + public void sendOrderConfirmationEmail(EmailNotificationDto emailDto) { + // 1. DTO์˜ ์ •๋ณด๋กœ DB์—์„œ ์ „์ฒด ์ฃผ๋ฌธ ์ •๋ณด๋ฅผ ๋‹ค์‹œ ์กฐํšŒ (OrderItem, Product, Design๊นŒ์ง€ ๋ชจ๋‘) + Order order = orderRepository.findOrderWithDetailsById(emailDto.orderId()) + .orElseThrow(() -> new IllegalArgumentException("Order not found: " + emailDto.orderId())); + + MimeMessage mimeMessage = javaMailSender.createMimeMessage(); + + try { + MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); // true: multipart + mimeMessageHelper.setTo(emailDto.userEmail()); + mimeMessageHelper.setSubject("[Knitly] ์ฃผ๋ฌธํ•˜์‹  ๋„์•ˆ์ด ๋„์ฐฉํ–ˆ์Šต๋‹ˆ๋‹ค."); + + // 2. TODO: Thymeleaf ๊ฐ™์€ ํ…œํ”Œ๋ฆฟ ์—”์ง„์„ ์‚ฌ์šฉํ•˜์—ฌ HTML ์ด๋ฉ”์ผ ๋ณธ๋ฌธ ์ƒ์„ฑ + String emailContent = String.format("

%s๋‹˜, ์ฃผ๋ฌธํ•ด์ฃผ์…”์„œ ๊ฐ์‚ฌํ•ฉ๋‹ˆ๋‹ค.

์ฃผ๋ฌธ ๋ฒˆํ˜ธ: %d

", + order.getUser().getName(), order.getOrderId()); + mimeMessageHelper.setText(emailContent, true); // true: HTML + + // 3. ์ฃผ๋ฌธ๋œ ๋ชจ๋“  ์ƒํ’ˆ์˜ PDF๋ฅผ ์ฒจ๋ถ€ + for (OrderItem item : order.getOrderItems()) { + String pdfUrl = item.getProduct().getDesign().getPdfUrl(); + try { + byte[] pdfBytes = fileStorageService.loadFileAsBytes(pdfUrl); // FileStorageService์— ํŒŒ์ผ ์ฝ๊ธฐ ๊ธฐ๋Šฅ ์ถ”๊ฐ€ + mimeMessageHelper.addAttachment(item.getProduct().getTitle() + ".pdf", new ByteArrayResource(pdfBytes)); + } catch (IOException e) { + log.error("PDF ํŒŒ์ผ ์ฒจ๋ถ€ ์‹คํŒจ: url={}", pdfUrl, e); + // ํ•˜๋‚˜์˜ PDF ์‹คํŒจ๊ฐ€ ์ „์ฒด ์ด๋ฉ”์ผ ๋ฐœ์†ก์„ ๋ง‰์„์ง€, ์•„๋‹ˆ๋ฉด ๊ทธ๋ƒฅ ๋ณด๋‚ผ์ง€ ์ •์ฑ… ๊ฒฐ์ • ํ•„์š” + } + } + + // 4. ์ด๋ฉ”์ผ ๋ฐœ์†ก + javaMailSender.send(mimeMessage); + + } catch (MessagingException e) { + // Consumer๊ฐ€ ์ด ์˜ˆ์™ธ๋ฅผ ๋ฐ›์•„์„œ ์žฌ์‹œ๋„/DLQ ์ฒ˜๋ฆฌ + throw new RuntimeException("MimeMessage ์ƒ์„ฑ ๋˜๋Š” ๋ฐœ์†ก์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.", e); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/global/exception/ErrorCode.java b/backend/src/main/java/com/mysite/knitly/global/exception/ErrorCode.java new file mode 100644 index 0000000..ad8ef68 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/global/exception/ErrorCode.java @@ -0,0 +1,125 @@ +package com.mysite.knitly.global.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum ErrorCode { + // Common 0 + BAD_REQUEST("001", HttpStatus.BAD_REQUEST, "์ž˜๋ชป๋œ ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), // ์—์‹œ, ์‚ญ์ œ๊ฐ€๋Šฅ + + // User 1000 + USER_NOT_FOUND("1001", HttpStatus.NOT_FOUND, "์œ ์ €๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + + // Product 2000 + PRODUCT_NOT_FOUND("2001", HttpStatus.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + PRODUCT_MODIFY_UNAUTHORIZED("2002", HttpStatus.FORBIDDEN, "์ƒํ’ˆ ์ˆ˜์ • ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."), + PRODUCT_DELETE_UNAUTHORIZED("2003", HttpStatus.FORBIDDEN, "์ƒํ’ˆ ์‚ญ์ œ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."), + PRODUCT_ALREADY_DELETED("2004", HttpStatus.BAD_REQUEST, "์ด๋ฏธ ์‚ญ์ œ๋œ ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค."), + PRODUCT_STOCK_INSUFFICIENT("2005", HttpStatus.BAD_REQUEST, "์ƒํ’ˆ ์žฌ๊ณ ๋ณด๋‹ค ๋งŽ์€ ์ˆ˜๋Ÿ‰์„ ์ฃผ๋ฌธํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๋‚จ์€ ์žฌ๊ณ ๋ฅผ ํ™•์ธํ•ด์ฃผ์„ธ์š”."), + LIKE_ALREADY_EXISTS("2401", HttpStatus.CONFLICT, "์ด๋ฏธ ์ฐœํ•œ ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค."), + LIKE_NOT_FOUND("2402", HttpStatus.NOT_FOUND, "์‚ญ์ œํ•  ์ฐœ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + + // Order 3000 + OUT_OF_STOCK("3001", HttpStatus.BAD_REQUEST, "ํ’ˆ์ ˆ๋œ ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค."), + ORDER_NOT_FOUND("3002", HttpStatus.NOT_FOUND, "์ฃผ๋ฌธ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + ORDER_ITEM_NOT_FOUND("3003", HttpStatus.NOT_FOUND, "์ฃผ๋ฌธ ์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + + // Post 4000 + POST_NOT_FOUND("4001", HttpStatus.NOT_FOUND, "๊ฒŒ์‹œ๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + POST_UNAUTHORIZED("4002", HttpStatus.UNAUTHORIZED, "๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•œ ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), + POST_UPDATE_FORBIDDEN("4003", HttpStatus.FORBIDDEN, "๊ฒŒ์‹œ๊ธ€ ์ˆ˜์ • ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."), + POST_DELETE_FORBIDDEN("4004", HttpStatus.FORBIDDEN, "๊ฒŒ์‹œ๊ธ€ ์‚ญ์ œ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."), + POST_ALREADY_DELETED("4005", HttpStatus.BAD_REQUEST, "์ด๋ฏธ ์‚ญ์ œ๋œ ๊ฒŒ์‹œ๊ธ€์ž…๋‹ˆ๋‹ค."), + POST_CONTENT_TOO_SHORT("4006", HttpStatus.BAD_REQUEST, "๊ฒŒ์‹œ๊ธ€ ๋‚ด์šฉ์€ ์ตœ์†Œ ๊ธธ์ด ์š”๊ฑด์„ ์ถฉ์กฑํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."), + POST_IMAGE_EXTENSION_INVALID("4007", HttpStatus.BAD_REQUEST, "์ง€์›ํ•˜์ง€ ์•Š๋Š” ์ด๋ฏธ์ง€ ํ˜•์‹์ž…๋‹ˆ๋‹ค. JPG, JPEG, PNG๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค."), + POST_TITLE_LENGTH_INVALID("4008", HttpStatus.BAD_REQUEST, "๊ฒŒ์‹œ๊ธ€ ์ œ๋ชฉ์€ 1์ž ์ด์ƒ 100์ž ์ดํ•˜๋กœ ์ž‘์„ฑํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."), + POST_IMAGE_COUNT_EXCEEDED("4009", HttpStatus.BAD_REQUEST, "์ด๋ฏธ์ง€๋Š” ์ตœ๋Œ€ 5๊ฐœ๊นŒ์ง€๋งŒ ์—…๋กœ๋“œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."), + VALIDATION_ERROR("4010", HttpStatus.BAD_REQUEST, "์š”์ฒญ ๊ฐ’์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + + // Comment 4050 + COMMENT_NOT_FOUND("4051", HttpStatus.NOT_FOUND, "๋Œ“๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + COMMENT_UNAUTHORIZED("4052", HttpStatus.UNAUTHORIZED, "๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•œ ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), + COMMENT_UPDATE_FORBIDDEN("4053", HttpStatus.FORBIDDEN, "๋Œ“๊ธ€ ์ˆ˜์ • ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."), + COMMENT_DELETE_FORBIDDEN("4054", HttpStatus.FORBIDDEN, "๋Œ“๊ธ€ ์‚ญ์ œ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."), + COMMENT_ALREADY_DELETED("4055", HttpStatus.BAD_REQUEST, "์ด๋ฏธ ์‚ญ์ œ๋œ ๋Œ“๊ธ€์ž…๋‹ˆ๋‹ค."), + COMMENT_CONTENT_TOO_SHORT("4056", HttpStatus.BAD_REQUEST, "๋Œ“๊ธ€์€ 1์ž ์ด์ƒ 300์ž ์ดํ•˜๋กœ ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”."), + + // MyPage 4100 + MP_UNAUTHORIZED("4101", HttpStatus.UNAUTHORIZED, "๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•œ ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), + MP_FORBIDDEN("4102", HttpStatus.FORBIDDEN, "์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."), + MP_PROFILE_NOT_FOUND("4103", HttpStatus.NOT_FOUND, "ํ”„๋กœํ•„ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + MP_ORDER_NOT_FOUND("4104", HttpStatus.NOT_FOUND, "์ฃผ๋ฌธ ๋‚ด์—ญ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + MP_ORDER_LIST_EMPTY("4105", HttpStatus.NO_CONTENT, "์ฃผ๋ฌธ ๋‚ด์—ญ์ด ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค."), + MP_MY_POSTS_EMPTY("4106", HttpStatus.NO_CONTENT, "์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๊ธ€์ด ์—†์Šต๋‹ˆ๋‹ค."), + MP_MY_POSTS_QUERY_TOO_SHORT("4107", HttpStatus.BAD_REQUEST, "๊ฒ€์ƒ‰์–ด๊ฐ€ ๋„ˆ๋ฌด ์งง์Šต๋‹ˆ๋‹ค."), + MP_MY_COMMENTS_EMPTY("4108", HttpStatus.NO_CONTENT, "์ž‘์„ฑํ•œ ๋Œ“๊ธ€์ด ์—†์Šต๋‹ˆ๋‹ค."), + MP_MY_COMMENTS_QUERY_TOO_SHORT("4109", HttpStatus.BAD_REQUEST, "๊ฒ€์ƒ‰์–ด๊ฐ€ ๋„ˆ๋ฌด ์งง์Šต๋‹ˆ๋‹ค."), + MP_FAVORITES_EMPTY("4110", HttpStatus.NO_CONTENT, "์ฐœํ•œ ์ƒํ’ˆ์ด ์—†์Šต๋‹ˆ๋‹ค."), + MP_REVIEWS_EMPTY("4111", HttpStatus.NO_CONTENT, "์ž‘์„ฑํ•œ ๋ฆฌ๋ทฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค."), + MP_INVALID_PAGE_PARAM("4112", HttpStatus.BAD_REQUEST, "page ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + MP_INVALID_SIZE_PARAM("4113", HttpStatus.BAD_REQUEST, "size ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + MP_INVALID_SORT_PARAM("4114", HttpStatus.BAD_REQUEST, "์ •๋ ฌ ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + + + // Review 5000 + + REVIEW_NOT_FOUND("5001", HttpStatus.NOT_FOUND, "๋ฆฌ๋ทฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + REVIEW_NOT_AUTHORIZED("5002", HttpStatus.FORBIDDEN, "๋ฆฌ๋ทฐ ์‚ญ์ œ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."), + + // Design 6000 + DESIGN_NOT_FOUND("2001", HttpStatus.NOT_FOUND, "๋„์•ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + DESIGN_DELETION_NOT_ALLOWED("2002", HttpStatus.BAD_REQUEST, "ํŒ๋งค ์ „ ์ƒํƒœ์˜ ๋„์•ˆ๋งŒ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."), + DESIGN_UNAUTHORIZED("2003", HttpStatus.FORBIDDEN, "๋ณธ์ธ์˜ ๋„์•ˆ๋งŒ ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."), + DESIGN_INVALID_GRID_SIZE("2004", HttpStatus.BAD_REQUEST, "๋„์•ˆ์€ 10x10 ํฌ๊ธฐ์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."), + DESIGN_PDF_GENERATION_FAILED("2005", HttpStatus.INTERNAL_SERVER_ERROR, "PDF ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), + DESIGN_FILE_SAVE_FAILED("2006", HttpStatus.INTERNAL_SERVER_ERROR, "PDF ํŒŒ์ผ ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), // Image 7000 + DESIGN_UNAUTHORIZED_DELETE("2007", HttpStatus.FORBIDDEN, "๋ณธ์ธ์˜ ๋„์•ˆ๋งŒ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."), + DESIGN_NOT_DELETABLE("2008", HttpStatus.BAD_REQUEST, "ํ•ด๋‹น ์ƒํƒœ์˜ ๋„์•ˆ์€ ์‚ญ์ œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + DESIGN_FILE_EMPTY("2009", HttpStatus.BAD_REQUEST, "์—…๋กœ๋“œํ•  ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค."), + DESIGN_FILE_INVALID_TYPE("2010", HttpStatus.BAD_REQUEST, "PDF ํŒŒ์ผ๋งŒ ์—…๋กœ๋“œ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค."), + DESIGN_FILE_SIZE_EXCEEDED("2011", HttpStatus.BAD_REQUEST, "ํŒŒ์ผ ํฌ๊ธฐ๋Š” 10MB๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + DESIGN_FILE_NAME_INVALID("2012", HttpStatus.BAD_REQUEST, "ํŒŒ์ผ๋ช…์ด ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + DESIGN_NOT_ON_SALE("2013", HttpStatus.BAD_REQUEST, "ํŒ๋งค์ค‘์ด ์•„๋‹Œ ๋„์•ˆ์ž…๋‹ˆ๋‹ค."), + DESIGN_ALREADY_ON_SALE("2014", HttpStatus.BAD_REQUEST, "์ด๋ฏธ ํŒ๋งค์ค‘์ธ ๋„์•ˆ์ž…๋‹ˆ๋‹ค."), + DESIGN_NOT_STOPPED("2015", HttpStatus.BAD_REQUEST, "์ด๋ฏธ ํŒ๋งค์ค‘์ง€๋œ ๋„์•ˆ์ž…๋‹ˆ๋‹ค."), + DESIGN_UNAUTHORIZED_ACCESS("2016", HttpStatus.FORBIDDEN, "๋ณธ์ธ์˜ ๋„์•ˆ๋งŒ ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."), + DESIGN_FILE_NOT_FOUND("2017", HttpStatus.NOT_FOUND, "๋„์•ˆ ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + + // Event 6000 + + // Image 7000 + IMAGE_FORMAT_NOT_SUPPORTED("7501", HttpStatus.BAD_REQUEST, "์ง€์›ํ•˜์ง€ ์•Š๋Š” ์ด๋ฏธ์ง€ ํ˜•์‹์ž…๋‹ˆ๋‹ค. JPG, JPEG, PNG๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค."), + REVIEW_IMAGE_SAVE_FAILED("7502", HttpStatus.INTERNAL_SERVER_ERROR, "๋ฆฌ๋ทฐ ์ด๋ฏธ์ง€ ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), + PRODUCT_IMAGE_SAVE_FAILED("7503", HttpStatus.INTERNAL_SERVER_ERROR, "์ƒํ’ˆ ์ด๋ฏธ์ง€ ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), + + + // File 7000 + + // File 7000 + FILE_STORAGE_FAILED("7601", HttpStatus.INTERNAL_SERVER_ERROR, "ํŒŒ์ผ ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), + + // Payment 8000 + PAYMENT_NOT_FOUND("8001", HttpStatus.NOT_FOUND, "๊ฒฐ์ œ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + PAYMENT_AMOUNT_MISMATCH("8002", HttpStatus.BAD_REQUEST, "๊ฒฐ์ œ ๊ธˆ์•ก์ด ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + PAYMENT_API_CALL_FAILED("8003", HttpStatus.INTERNAL_SERVER_ERROR, "๊ฒฐ์ œ API ํ˜ธ์ถœ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), + PAYMENT_CONFIRM_FAILED("8004", HttpStatus.INTERNAL_SERVER_ERROR, "๊ฒฐ์ œ ์Šน์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), + PAYMENT_ALREADY_EXISTS("8005", HttpStatus.CONFLICT, "์ด๋ฏธ ๊ฒฐ์ œ๊ฐ€ ์™„๋ฃŒ๋œ ์ฃผ๋ฌธ์ž…๋‹ˆ๋‹ค."), + PAYMENT_NOT_CANCELABLE("8006", HttpStatus.BAD_REQUEST, "์ทจ์†Œํ•  ์ˆ˜ ์—†๋Š” ๊ฒฐ์ œ ์ƒํƒœ์ž…๋‹ˆ๋‹ค."), + PAYMENT_CANCEL_API_FAILED("8007", HttpStatus.INTERNAL_SERVER_ERROR, "๊ฒฐ์ œ ์ทจ์†Œ API ํ˜ธ์ถœ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), + PAYMENT_CANCEL_FAILED("8008", HttpStatus.INTERNAL_SERVER_ERROR, "๊ฒฐ์ œ ์ทจ์†Œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), + PAYMENT_UNAUTHORIZED_ACCESS("8009", HttpStatus.FORBIDDEN, "๊ฒฐ์ œ ์ •๋ณด์— ์ ‘๊ทผํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."), + // System 9000 + + LOCK_ACQUISITION_FAILED("9001", HttpStatus.INTERNAL_SERVER_ERROR, "๋ฝ ํš๋“์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”."); + + private final String code; + private final HttpStatus status; + private final String message; + + ErrorCode(String code, HttpStatus status, String message) { + this.code = code; + this.status = status; + this.message = message; + } +} diff --git a/backend/src/main/java/com/mysite/knitly/global/exception/ErrorResponse.java b/backend/src/main/java/com/mysite/knitly/global/exception/ErrorResponse.java new file mode 100644 index 0000000..a3505f8 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/global/exception/ErrorResponse.java @@ -0,0 +1,39 @@ +package com.mysite.knitly.global.exception; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.http.HttpStatus; +import org.springframework.validation.BindingResult; + +@Getter +@Builder +public class ErrorResponse { + private final ErrorBody error; + + @Getter + @Builder + public static class ErrorBody{ + private final String code; + private final String status; + private final String message; + } + + public static ErrorResponse errorResponse(ErrorCode errorCode) { + return ErrorResponse.builder() + .error(ErrorBody.builder() + .code(errorCode.name()) + .status(String.valueOf(errorCode.getStatus().value())) + .message(errorCode.getMessage()) + .build()) + .build(); + } + public static ErrorResponse validationError(BindingResult bindingResult) { + return ErrorResponse.builder() + .error(ErrorBody.builder() + .code(ErrorCode.VALIDATION_ERROR.name()) + .status(String.valueOf(HttpStatus.BAD_REQUEST.value())) + .message("์š”์ฒญ ๊ฐ’์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.") + .build()) + .build(); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/global/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/mysite/knitly/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..a50cdc8 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,30 @@ +package com.mysite.knitly.global.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.MethodArgumentNotValidException; + +@RestControllerAdvice +@Order(Ordered.HIGHEST_PRECEDENCE) +public class GlobalExceptionHandler { + + @ExceptionHandler(ServiceException.class) + public ResponseEntity handleServiceException(ServiceException e) { + ErrorCode errorCode = e.getErrorCode(); + return ResponseEntity + .status(errorCode.getStatus()) + .body(ErrorResponse.errorResponse(errorCode)); + } + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException e) { + BindingResult bindingResult = e.getBindingResult(); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.validationError(bindingResult)); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/global/exception/ServiceException.java b/backend/src/main/java/com/mysite/knitly/global/exception/ServiceException.java new file mode 100644 index 0000000..d4546ba --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/global/exception/ServiceException.java @@ -0,0 +1,16 @@ +package com.mysite.knitly.global.exception; + +import lombok.Getter; + +@Getter +public class ServiceException extends RuntimeException { + private final ErrorCode errorCode; + + public ServiceException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/backend/src/main/java/com/mysite/knitly/global/jpa/BaseTimeEntity.java b/backend/src/main/java/com/mysite/knitly/global/jpa/BaseTimeEntity.java new file mode 100644 index 0000000..c834d98 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/global/jpa/BaseTimeEntity.java @@ -0,0 +1,25 @@ +package com.mysite.knitly.global.jpa; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseTimeEntity { + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/mysite/knitly/global/jpa/converter/UUIDBinaryConverter.java b/backend/src/main/java/com/mysite/knitly/global/jpa/converter/UUIDBinaryConverter.java new file mode 100644 index 0000000..d52a5a1 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/global/jpa/converter/UUIDBinaryConverter.java @@ -0,0 +1,30 @@ +package com.mysite.knitly.global.jpa.converter; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +import java.nio.ByteBuffer; +import java.util.UUID; + +// UUID <-> BINARY(16) +@Converter(autoApply = false) // ํ•„์š”ํ•œ ํ•„๋“œ์— @Convert ์‚ฌ์šฉ +public class UUIDBinaryConverter implements AttributeConverter { + + @Override + public byte[] convertToDatabaseColumn(UUID attribute) { + if (attribute == null) return null; + ByteBuffer bb = ByteBuffer.allocate(16); + bb.putLong(attribute.getMostSignificantBits()); + bb.putLong(attribute.getLeastSignificantBits()); + return bb.array(); + } + + @Override + public UUID convertToEntityAttribute(byte[] dbData) { + if (dbData == null) return null; + ByteBuffer bb = ByteBuffer.wrap(dbData); + long most = bb.getLong(); + long least = bb.getLong(); + return new UUID(most, least); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/global/lock/RedisLockService.java b/backend/src/main/java/com/mysite/knitly/global/lock/RedisLockService.java new file mode 100644 index 0000000..fe8800c --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/global/lock/RedisLockService.java @@ -0,0 +1,32 @@ +package com.mysite.knitly.global.lock; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +@RequiredArgsConstructor +public class RedisLockService { + + private final StringRedisTemplate redisTemplate; + + /** + * ๋ฝ ํš๋“ ์‹œ๋„ (3์ดˆ๊ฐ„ ์œ ํšจ) + * @return ๋ฝ ํš๋“ ์„ฑ๊ณต ์—ฌ๋ถ€ + */ + public boolean tryLock(String key) { + // SET key "locked" NX PX 3000 + return Boolean.TRUE.equals( + redisTemplate.opsForValue().setIfAbsent(key, "locked", Duration.ofSeconds(3)) + ); + } + + /** + * ๋ฝ ํ•ด์ œ + */ + public void unlock(String key) { + redisTemplate.delete(key); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/global/util/Anonymizer.java b/backend/src/main/java/com/mysite/knitly/global/util/Anonymizer.java new file mode 100644 index 0000000..2e47717 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/global/util/Anonymizer.java @@ -0,0 +1,17 @@ +package com.mysite.knitly.global.util; + +import java.util.Locale; + +// ๊ฐ™์€ ์‚ฌ์šฉ์ž(UUID)๋ฉด ์–ธ์ œ๋‚˜ ๊ฐ™์€ ๋ณ„์นญ์„ ์–ป๋„๋ก ํ•˜๋Š” ์œ ํ‹ธ. +// ์ต๋ช…์˜ ํ„ธ์‹ค-1234 ํ˜•์‹ (๋Œ“๊ธ€์˜ ์ˆœ๋ฒˆ ๊ทœ์น™์€ ์„œ๋น„์Šค ๋กœ์ง์—์„œ ์ฒ˜๋ฆฌ) +public final class Anonymizer { + private static final String PREFIX = "์ต๋ช…์˜ ํ„ธ์‹ค-"; + + private Anonymizer() {} + + public static String yarn(Long userId) { + if (userId == null) return PREFIX + "0000"; + int hash = Math.abs(userId.toString().toLowerCase(Locale.ROOT).hashCode()); + return PREFIX + String.format("%04d", hash % 10000); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/global/util/FileNameUtils.java b/backend/src/main/java/com/mysite/knitly/global/util/FileNameUtils.java new file mode 100644 index 0000000..094bd6f --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/global/util/FileNameUtils.java @@ -0,0 +1,12 @@ +package com.mysite.knitly.global.util; + +public class FileNameUtils { + public static String sanitize(String input) { + if (input == null || input.isBlank()) return "design"; + String s = input.trim() + .replaceAll("[\\\\/:*?\"<>|]", "_") + .replaceAll("\\s+", " "); + if (!s.toLowerCase().endsWith(".pdf")) s += ".pdf"; + return s.length() > 80 ? s.substring(0, 80) : s; + } +} diff --git a/backend/src/main/java/com/mysite/knitly/global/util/FileStorageService.java b/backend/src/main/java/com/mysite/knitly/global/util/FileStorageService.java new file mode 100644 index 0000000..98ddffc --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/global/util/FileStorageService.java @@ -0,0 +1,94 @@ +package com.mysite.knitly.global.util; + +import com.mysite.knitly.global.exception.ErrorCode; +import com.mysite.knitly.global.exception.ServiceException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.UUID; + +@Slf4j +@Service +public class FileStorageService { + + private final String uploadDir = "resources/static/"; + private final String urlPrefix = "/resources/static/"; + + /** + * ํŒŒ์ผ์„ ์ €์žฅํ•˜๊ณ  ์ ‘๊ทผ URL์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + * @param file ์ €์žฅํ•  MultipartFile + * @param domain 'product', 'review' ๋“ฑ ํŒŒ์ผ์ด ์†ํ•œ ๋„๋ฉ”์ธ + * @return ํŒŒ์ผ์— ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๋Š” URL + */ + public String storeFile(MultipartFile file, String domain) { + String originalFilename = file.getOriginalFilename(); + if (!ImageValidator.isAllowedImageUrl(originalFilename)) { + throw new ServiceException(ErrorCode.IMAGE_FORMAT_NOT_SUPPORTED); + } + + try { + String domainUploadDir = uploadDir + domain + "/"; + new File(domainUploadDir).mkdirs(); // ๋„๋ฉ”์ธ๋ณ„ ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ + + String filename = UUID.randomUUID() + "_" + FileNameUtils.sanitize(originalFilename); + Path path = Paths.get(domainUploadDir, filename); + Files.write(path, file.getBytes()); + + return urlPrefix + domain + "/" + filename; + + } catch (IOException e) { + log.error("ํŒŒ์ผ ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. filename={}", originalFilename, e); + throw new ServiceException(ErrorCode.FILE_STORAGE_FAILED); + } + } + + /** + * ํŒŒ์ผ URL์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์‹ค์ œ ํŒŒ์ผ์„ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. + * @param fileUrl ์‚ญ์ œํ•  ํŒŒ์ผ์˜ URL + */ + public void deleteFile(String fileUrl) { + if (fileUrl == null || fileUrl.isEmpty()) { + return; + } + + try { + // URL์—์„œ ์‹ค์ œ ํŒŒ์ผ ์‹œ์Šคํ…œ ๊ฒฝ๋กœ๋ฅผ ์ถ”์ถœ + // ์˜ˆ: /resources/static/product/abc.jpg -> resources/static/product/abc.jpg + String filePath = fileUrl.replaceFirst(urlPrefix, uploadDir); + Path path = Paths.get(filePath); + + Files.deleteIfExists(path); + log.info("ํŒŒ์ผ ์‚ญ์ œ ์„ฑ๊ณต: {}", filePath); + + } catch (IOException e) { + log.error("ํŒŒ์ผ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. fileUrl={}", fileUrl, e); + // ํŒŒ์ผ ์‚ญ์ œ ์‹คํŒจ๊ฐ€ ์ „์ฒด ํŠธ๋žœ์žญ์…˜์„ ๋กค๋ฐฑ์‹œํ‚ฌ ํ•„์š”๋Š” ์—†์œผ๋ฏ€๋กœ, ์—ฌ๊ธฐ์„œ๋Š” ์˜ˆ์™ธ๋ฅผ ๋‹ค์‹œ ๋˜์ง€์ง€ ์•Š๊ณ  ๋กœ๊ทธ๋งŒ ๋‚จ๊น๋‹ˆ๋‹ค. + } + } + + /** + * ํŒŒ์ผ URL์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์‹ค์ œ ํŒŒ์ผ ๋‚ด์šฉ์„ byte ๋ฐฐ์—ด๋กœ ์ฝ์–ด์˜ต๋‹ˆ๋‹ค. + * @param fileUrl ์ฝ์–ด์˜ฌ ํŒŒ์ผ์˜ URL + * @return ํŒŒ์ผ์˜ byte[] + * @throws IOException ํŒŒ์ผ ์ฝ๊ธฐ ์‹คํŒจ ์‹œ + */ + public byte[] loadFileAsBytes(String fileUrl) throws IOException { + if (fileUrl == null || fileUrl.isEmpty()) { + throw new IOException("์œ ํšจํ•˜์ง€ ์•Š์€ ํŒŒ์ผ URL์ž…๋‹ˆ๋‹ค."); + } + try { + String filePath = fileUrl.replaceFirst(urlPrefix, uploadDir); + Path path = Paths.get(filePath); + return Files.readAllBytes(path); + } catch (IOException e) { + log.error("ํŒŒ์ผ์„ ์ฝ๋Š” ๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. fileUrl={}", fileUrl, e); + throw e; // ์˜ˆ์™ธ๋ฅผ ์ƒ์œ„๋กœ ๋˜์ ธ์„œ Consumer๊ฐ€ ์ฒ˜๋ฆฌํ•˜๋„๋ก ํ•จ + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/global/util/ImageValidator.java b/backend/src/main/java/com/mysite/knitly/global/util/ImageValidator.java new file mode 100644 index 0000000..e5ed0ce --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/global/util/ImageValidator.java @@ -0,0 +1,15 @@ +package com.mysite.knitly.global.util; + +import java.util.regex.Pattern; + +public final class ImageValidator { + private static final Pattern ALLOWED + = Pattern.compile("(?i).+\\.(png|jpg|jpeg)(\\?.*)?$"); + + private ImageValidator() {} + + public static boolean isAllowedImageUrl(String url) { + if (url == null || url.isBlank()) return true; // ์ด๋ฏธ์ง€ ๋ฏธ์ฒจ๋ถ€๋Š” ํ†ต๊ณผ + return ALLOWED.matcher(url).matches(); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/utility/auth/controller/AuthController.java b/backend/src/main/java/com/mysite/knitly/utility/auth/controller/AuthController.java new file mode 100644 index 0000000..2882076 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/utility/auth/controller/AuthController.java @@ -0,0 +1,139 @@ +package com.mysite.knitly.utility.auth.controller; + +import com.mysite.knitly.utility.auth.dto.TokenRefreshResponse; +import com.mysite.knitly.utility.auth.service.AuthService; +import com.mysite.knitly.utility.cookie.CookieUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Auth", description = "์ธ์ฆ ๊ด€๋ จ API") +@Slf4j +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + private final CookieUtil cookieUtil; + + @Value("${custom.jwt.refreshTokenExpireSeconds}") + private int refreshTokenExpireSeconds; + + private static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken"; + + /** + * Access Token ๊ฐฑ์‹  API + * POST /api/auth/refresh + * + * ์ฟ ํ‚ค์—์„œ Refresh Token์„ ์ฝ์–ด ์ƒˆ๋กœ์šด Access Token๊ณผ Refresh Token์„ ๋ฐœ๊ธ‰ + */ + @Operation( + summary = "ํ† ํฐ ๊ฐฑ์‹ ", + description = "HTTP-only ์ฟ ํ‚ค์˜ Refresh Token์„ ์‚ฌ์šฉํ•˜์—ฌ ์ƒˆ๋กœ์šด Access Token๊ณผ Refresh Token์„ ๋ฐœ๊ธ‰๋ฐ›์Šต๋‹ˆ๋‹ค." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "๊ฐฑ์‹  ์„ฑ๊ณต", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "accessToken": "eyJhbGciOiJIUzI1NiJ9...", + "refreshToken": "eyJhbGciOiJIUzI1NiJ9...", + "tokenType": "Bearer", + "expiresIn": 1800 + } + """) + ) + ), + @ApiResponse( + responseCode = "400", + description = "Refresh Token์ด ์—†๊ฑฐ๋‚˜ ์œ ํšจํ•˜์ง€ ์•Š์Œ" + ), + @ApiResponse( + responseCode = "401", + description = "๋งŒ๋ฃŒ๋˜์—ˆ๊ฑฐ๋‚˜ ์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ" + ) + }) + @PostMapping("/refresh") + public ResponseEntity refreshToken( + HttpServletRequest request, + HttpServletResponse response) { + + log.info("Token refresh API called"); + + // 1. ์ฟ ํ‚ค์—์„œ Refresh Token ๊ฐ€์ ธ์˜ค๊ธฐ + String refreshToken = cookieUtil.getCookie(request, REFRESH_TOKEN_COOKIE_NAME) + .orElse(null); + + if (refreshToken == null) { + log.error("Refresh Token not found in cookie"); + return ResponseEntity.badRequest().build(); + } + + log.debug("Refresh Token found in cookie: {}...", refreshToken.substring(0, 20)); + + try { + // 2. ์ƒˆ๋กœ์šด ํ† ํฐ ๋ฐœ๊ธ‰ + TokenRefreshResponse tokenResponse = authService.refreshAccessToken(refreshToken); + + log.info("New tokens created successfully"); + log.debug("New Access Token: {}", tokenResponse.getAccessToken()); + log.debug("New Refresh Token: {}", tokenResponse.getRefreshToken()); + + // 3. ์ƒˆ๋กœ์šด Refresh Token์„ ์ฟ ํ‚ค์— ์ €์žฅ + cookieUtil.addCookie( + response, + REFRESH_TOKEN_COOKIE_NAME, + tokenResponse.getRefreshToken(), + refreshTokenExpireSeconds + ); + + log.info("New Refresh Token saved to cookie"); + + return ResponseEntity.ok(tokenResponse); + + } catch (IllegalArgumentException e) { + log.error("Token refresh failed: {}", e.getMessage()); + + // ์‹คํŒจ ์‹œ ์ฟ ํ‚ค ์‚ญ์ œ + cookieUtil.deleteCookie(response, REFRESH_TOKEN_COOKIE_NAME); + log.info("Invalid Refresh Token removed from cookie"); + + return ResponseEntity.status(401).build(); + } + } + + /** + * ํ…Œ์ŠคํŠธ์šฉ ์—”๋“œํฌ์ธํŠธ + * GET /api/auth/test + */ + @Operation( + summary = "API ํ…Œ์ŠคํŠธ", + description = "Auth API๊ฐ€ ์ •์ƒ ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธํ•˜๋Š” ํ…Œ์ŠคํŠธ ์—”๋“œํฌ์ธํŠธ์ž…๋‹ˆ๋‹ค." + ) + @ApiResponse( + responseCode = "200", + description = "์„ฑ๊ณต", + content = @Content( + mediaType = "text/plain", + examples = @ExampleObject(value = "Auth API is working!") + ) + ) + @GetMapping("/test") + public ResponseEntity test() { + return ResponseEntity.ok("Auth API is working!"); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/utility/auth/dto/TokenRefreshRequest.java b/backend/src/main/java/com/mysite/knitly/utility/auth/dto/TokenRefreshRequest.java new file mode 100644 index 0000000..6316cf4 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/utility/auth/dto/TokenRefreshRequest.java @@ -0,0 +1,13 @@ +package com.mysite.knitly.utility.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class TokenRefreshRequest { + + private String refreshToken; +} diff --git a/backend/src/main/java/com/mysite/knitly/utility/auth/dto/TokenRefreshResponse.java b/backend/src/main/java/com/mysite/knitly/utility/auth/dto/TokenRefreshResponse.java new file mode 100644 index 0000000..cb377d7 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/utility/auth/dto/TokenRefreshResponse.java @@ -0,0 +1,25 @@ +package com.mysite.knitly.utility.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class TokenRefreshResponse { + + private String accessToken; + private String refreshToken; + private String tokenType; + private long expiresIn; + + public static TokenRefreshResponse of(String accessToken, String refreshToken, long expiresIn) { + return TokenRefreshResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .tokenType("Bearer") + .expiresIn(expiresIn) + .build(); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/utility/auth/service/AuthService.java b/backend/src/main/java/com/mysite/knitly/utility/auth/service/AuthService.java new file mode 100644 index 0000000..0231b45 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/utility/auth/service/AuthService.java @@ -0,0 +1,83 @@ +package com.mysite.knitly.utility.auth.service; + +import com.mysite.knitly.domain.user.service.UserService; +import com.mysite.knitly.utility.auth.dto.TokenRefreshResponse; +import com.mysite.knitly.utility.jwt.JwtProperties; +import com.mysite.knitly.utility.jwt.JwtProvider; +import com.mysite.knitly.utility.redis.RefreshTokenService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final JwtProvider jwtProvider; + private final RefreshTokenService refreshTokenService; + private final JwtProperties jwtProperties; + private final UserService userService; + + /** + * Refresh Token์œผ๋กœ Access Token ๊ฐฑ์‹  + */ + public TokenRefreshResponse refreshAccessToken(String refreshToken) { + // 1. Refresh Token ์œ ํšจ์„ฑ ๊ฒ€์ฆ + if (!jwtProvider.validateToken(refreshToken)) { + log.info("์œ ํšจํ•˜์ง€ ์•Š์€ Refresh Token์ž…๋‹ˆ๋‹ค."); + throw new IllegalArgumentException("์œ ํšจํ•˜์ง€ ์•Š์€ Refresh Token์ž…๋‹ˆ๋‹ค."); + } + + // 2. Refresh Token์—์„œ userId ์ถ”์ถœ + Long userId = jwtProvider.getUserIdFromToken(refreshToken); + log.info("Token refresh requested - userId: {}", userId); + + // 3. Redis์— ์ €์žฅ๋œ Refresh Token๊ณผ ๋น„๊ต + if (!refreshTokenService.validateRefreshToken(userId, refreshToken)) { + throw new IllegalArgumentException("Refresh Token์ด ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + // 4. ์ƒˆ๋กœ์šด Access Token ์ƒ์„ฑ + String newAccessToken = jwtProvider.createAccessToken(userId); + + // 5. ์ƒˆ๋กœ์šด Refresh Token ์ƒ์„ฑ (RTR - Refresh Token Rotation) + String newRefreshToken = jwtProvider.createRefreshToken(userId); + + // 6. ์ƒˆ๋กœ์šด Refresh Token์„ Redis์— ์ €์žฅ (๊ธฐ์กด ํ† ํฐ ๋ฎ์–ด์“ฐ๊ธฐ) + refreshTokenService.saveRefreshToken(userId, newRefreshToken); + + log.info("Token refreshed successfully - userId: {}", userId); + + return TokenRefreshResponse.of( + newAccessToken, + newRefreshToken, + jwtProperties.getAccessTokenExpireSeconds() + ); + } + + /** + * ๋กœ๊ทธ์•„์›ƒ + */ + public void logout(Long userId) { + refreshTokenService.deleteRefreshToken(userId); + log.info("User logged out - userId: {}", userId); + } + + /** + * ํšŒ์›ํƒˆํ‡ด + * 1. Refresh Token ์‚ญ์ œ (Redis) + * 2. User ์‚ญ์ œ (DB) + */ + @Transactional + public void deleteAccount(Long userId) { + // 1. Redis์—์„œ Refresh Token ์‚ญ์ œ + refreshTokenService.deleteRefreshToken(userId); + + // 2. DB์—์„œ ์‚ฌ์šฉ์ž ์‚ญ์ œ + userService.deleteUser(userId); + + log.info("Account deleted - userId: {}", userId); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/utility/config/JsonAccessDeniedHandler.java b/backend/src/main/java/com/mysite/knitly/utility/config/JsonAccessDeniedHandler.java new file mode 100644 index 0000000..1bce52b --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/utility/config/JsonAccessDeniedHandler.java @@ -0,0 +1,20 @@ +package com.mysite.knitly.utility.config; + +import jakarta.servlet.http.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; +import java.io.IOException; + +@Component +public class JsonAccessDeniedHandler implements AccessDeniedHandler { + @Override + public void handle(HttpServletRequest req, HttpServletResponse res, + AccessDeniedException ex) throws IOException { + res.setStatus(HttpServletResponse.SC_FORBIDDEN); // 403 + res.setContentType("application/json;charset=UTF-8"); + res.getWriter().write(""" + {"error":{"code":"AUTH_FORBIDDEN","status":403,"message":"์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."}} + """); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/utility/config/JsonAuthEntryPoint.java b/backend/src/main/java/com/mysite/knitly/utility/config/JsonAuthEntryPoint.java new file mode 100644 index 0000000..52e4f2d --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/utility/config/JsonAuthEntryPoint.java @@ -0,0 +1,20 @@ +package com.mysite.knitly.utility.config; + +import jakarta.servlet.http.*; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; +import java.io.IOException; + +@Component +public class JsonAuthEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence(HttpServletRequest req, HttpServletResponse res, + AuthenticationException ex) throws IOException { + res.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 + res.setContentType("application/json;charset=UTF-8"); + res.getWriter().write(""" + {"error":{"code":"AUTH_UNAUTHORIZED","status":401,"message":"๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."}} + """); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/utility/config/SecurityConfig.java b/backend/src/main/java/com/mysite/knitly/utility/config/SecurityConfig.java new file mode 100644 index 0000000..2cdd9a2 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/utility/config/SecurityConfig.java @@ -0,0 +1,152 @@ +package com.mysite.knitly.utility.config; + +import com.mysite.knitly.utility.handler.OAuth2FailureHandler; +import com.mysite.knitly.utility.handler.OAuth2SuccessHandler; +import com.mysite.knitly.utility.jwt.JwtAuthenticationFilter; +import com.mysite.knitly.utility.oauth.CustomOAuth2UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import com.mysite.knitly.utility.config.JsonAuthEntryPoint; // 401 JSON +import com.mysite.knitly.utility.config.JsonAccessDeniedHandler; // 403 JSON + +import java.util.Arrays; + +@Configuration +@EnableWebSecurity +@org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2SuccessHandler oAuth2SuccessHandler; + private final OAuth2FailureHandler oAuth2FailureHandler; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + // 401/403์„ JSON์œผ๋กœ ๋‚ด๋ ค์ฃผ๊ธฐ ์œ„ํ•œ ํ•ธ๋“ค๋Ÿฌ + private final JsonAuthEntryPoint jsonAuthEntryPoint; + private final JsonAccessDeniedHandler jsonAccessDeniedHandler; + + /** + * CORS ์„ค์ • + * ํ”„๋ก ํŠธ์—”๋“œ(localhost:3000)์™€ ๋ฐฑ์—”๋“œ(localhost:8080) ๊ฐ„ ํ†ต์‹  ํ—ˆ์šฉ + */ + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + // ๐Ÿ”ฅ ํ—ˆ์šฉํ•  ์ถœ์ฒ˜ (ํ”„๋ก ํŠธ์—”๋“œ URL) + configuration.setAllowedOrigins(Arrays.asList( + "http://localhost:3000", // ๊ฐœ๋ฐœ ํ™˜๊ฒฝ + "http://localhost:3001", // ๊ฐœ๋ฐœ ํ™˜๊ฒฝ (์ถ”๊ฐ€ ํฌํŠธ) + "https://www.myapp.com" // ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ (์ถ”ํ›„ ๋ณ€๊ฒฝ) + )); + + // ํ—ˆ์šฉํ•  HTTP ๋ฉ”์„œ๋“œ + configuration.setAllowedMethods(Arrays.asList( + "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS" + )); + + // ํ—ˆ์šฉํ•  ํ—ค๋” + configuration.setAllowedHeaders(Arrays.asList("*")); + + // ๐Ÿ”ฅ ์ฟ ํ‚ค ํฌํ•จ ํ—ˆ์šฉ (๋งค์šฐ ์ค‘์š”!) + configuration.setAllowCredentials(true); + + // ๋…ธ์ถœํ•  ํ—ค๋” (ํ”„๋ก ํŠธ์—”๋“œ์—์„œ ์ ‘๊ทผ ๊ฐ€๋Šฅ) + configuration.setExposedHeaders(Arrays.asList( + "Authorization", + "Set-Cookie" + )); + + // Preflight ์š”์ฒญ ์บ์‹œ ์‹œ๊ฐ„ (1์‹œ๊ฐ„) + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + + return source; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + // CORS ์„ค์ • ์ ์šฉ + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + + // CSRF ๋น„ํ™œ์„ฑํ™” (JWT ์‚ฌ์šฉ) + .csrf(csrf -> csrf.disable()) + + // ์„ธ์…˜ ์‚ฌ์šฉ ์•ˆํ•จ (Stateless) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + + // 401/403 ์„ JSON ์‘๋‹ต์œผ๋กœ ๊ณ ์ • + .exceptionHandling(eh -> eh + .authenticationEntryPoint(jsonAuthEntryPoint) // 401 + .accessDeniedHandler(jsonAccessDeniedHandler) // 403 + ) + + // URL ๋ณ„ ๊ถŒํ•œ ์„ค์ • + .authorizeHttpRequests(auth -> auth + // ์ปค๋ฎค๋‹ˆํ‹ฐ ๊ฒŒ์‹œ๊ธ€ ๋ชฉ๋ก/์ƒ์„ธ ์กฐํšŒ๋Š” ๋กœ๊ทธ์ธ ์—†์ด ํ—ˆ์šฉ + .requestMatchers(HttpMethod.GET, "/community/posts/**").permitAll() + .requestMatchers(HttpMethod.GET, "/community/comments/**").permitAll() + // ๋Œ“๊ธ€ ์กฐํšŒ(๊ฒŒ์‹œ๊ธ€ ํ•˜์œ„ ๊ฒฝ๋กœ) ๊ณต๊ฐœ: ๋ชฉ๋ก & count ๋ชจ๋‘ ํฌํ•จ + .requestMatchers(HttpMethod.GET, "/community/posts/*/comments").permitAll() + .requestMatchers(HttpMethod.GET, "/community/posts/*/comments/**").permitAll() + + // ์ปค๋ฎค๋‹ˆํ‹ฐ "์“ฐ๊ธฐ/์ˆ˜์ •/์‚ญ์ œ"๋Š” ์ธ์ฆ ํ•„์š” + .requestMatchers(HttpMethod.POST, "/community/**").authenticated() + .requestMatchers(HttpMethod.PUT, "/community/**").authenticated() + .requestMatchers(HttpMethod.PATCH, "/community/**").authenticated() + .requestMatchers(HttpMethod.DELETE, "/community/**").authenticated() + + // ๋งˆ์ดํŽ˜์ด์ง€๋Š” ์ „๋ถ€ ์ธ์ฆ ํ•„์š” + .requestMatchers("/mypage/**").authenticated() + + .requestMatchers(HttpMethod.GET, "/products", "/products/**", "/users/*/products").permitAll() // ์ƒํ’ˆ ๋ชฉ๋ก API ๊ณต๊ฐœ + .requestMatchers(HttpMethod.GET, "/home/**").permitAll() // ํ™ˆ ํ™”๋ฉด API ๊ณต๊ฐœ + + // ์ธ์ฆ ๋ถˆํ•„์š” + .requestMatchers("/", "/login/**", "/oauth2/**", "/auth/refresh", "/auth/test").permitAll() + + // JWT ์ธ์ฆ ํ•„์š” + .requestMatchers("/users/**").authenticated() + + // Swagger ์‚ฌ์šฉ + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() + + // ์—…๋กœ๋“œํ•œ ๋ฆฌ๋ทฐ ์ด๋ฏธ์ง€ ์กฐํšŒ + .requestMatchers("/review/**").permitAll() + + // ๋‚˜๋จธ์ง€ ๋ชจ๋‘ ์ธ์ฆ ํ•„์š” + .anyRequest().authenticated() + ) + + // OAuth2 ๋กœ๊ทธ์ธ ์„ค์ • + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> + userInfo.userService(customOAuth2UserService) + ) + .successHandler(oAuth2SuccessHandler) + .failureHandler(oAuth2FailureHandler) + ) + + // JWT ์ธ์ฆ ํ•„ํ„ฐ ์ถ”๊ฐ€ + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/utility/config/SwaggerConfig.java b/backend/src/main/java/com/mysite/knitly/utility/config/SwaggerConfig.java new file mode 100644 index 0000000..7c21e52 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/utility/config/SwaggerConfig.java @@ -0,0 +1,35 @@ +package com.mysite.knitly.utility.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info() + .title("Knitly API Documentation") + .description("Google OAuth 2.0 ์†Œ์…œ ๋กœ๊ทธ์ธ API") + .version("v1.0.0") + .contact(new Contact() + .name("Team Knitly") + .email("team@knitly.com"))) + .addSecurityItem(new SecurityRequirement().addList("Bearer Authentication")) + .components(new Components() + .addSecuritySchemes("Bearer Authentication", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("JWT ํ† ํฐ์„ ์ž…๋ ฅํ•˜์„ธ์š” (Bearer ์ œ์™ธ)") + )); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/utility/cookie/CookieUtil.java b/backend/src/main/java/com/mysite/knitly/utility/cookie/CookieUtil.java new file mode 100644 index 0000000..0dc1b62 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/utility/cookie/CookieUtil.java @@ -0,0 +1,100 @@ +package com.mysite.knitly.utility.cookie; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.Optional; + +/** + * HTTP Cookie ์œ ํ‹ธ๋ฆฌํ‹ฐ ํด๋ž˜์Šค + * - HTTP-only ์ฟ ํ‚ค ์ƒ์„ฑ, ์กฐํšŒ, ์‚ญ์ œ ๊ธฐ๋Šฅ ์ œ๊ณต + */ +@Slf4j +@Component +public class CookieUtil { + + /** + * HTTP-only ์ฟ ํ‚ค ์ƒ์„ฑ + * + * @param name ์ฟ ํ‚ค ์ด๋ฆ„ + * @param value ์ฟ ํ‚ค ๊ฐ’ + * @param maxAge ๋งŒ๋ฃŒ ์‹œ๊ฐ„ (์ดˆ ๋‹จ์œ„) + * @return Cookie ๊ฐ์ฒด + */ + public Cookie createCookie(String name, String value, int maxAge) { + Cookie cookie = new Cookie(name, value); + cookie.setHttpOnly(true); // JavaScript ์ ‘๊ทผ ์ฐจ๋‹จ + cookie.setPath("/"); // ๋ชจ๋“  ๊ฒฝ๋กœ์—์„œ ์ ‘๊ทผ ๊ฐ€๋Šฅ + cookie.setMaxAge(maxAge); // ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ์„ค์ • + + // HTTPS ํ™˜๊ฒฝ์—์„œ๋งŒ ์ „์†ก (๊ฐœ๋ฐœ ํ™˜๊ฒฝ์—์„œ๋Š” false) + // ํ”„๋กœ๋•์…˜์—์„œ๋Š” true๋กœ ๋ณ€๊ฒฝ ํ•„์š” + cookie.setSecure(false); + + // TODO : ์ง€๊ธˆ์€ ์•Œ์•„๋งŒ ๋‘˜ ๊ฒƒ (์ง€๊ธˆ ์ค‘์š”ํ•œ ๋‚ด์šฉ์€ ์•„๋‹˜) + // ์•ˆ์ „ํ•œ ์š”์ฒญ์—๋งŒ ์ฟ ํ‚ค ์ „์†ก (๊ฐœ๋ฐœ ํ™˜๊ฒฝ ๊ถŒ์žฅ) + // None: ๋ชจ๋“  ํฌ๋กœ์Šค ๋„๋ฉ”์ธ ์š”์ฒญ์— ์ฟ ํ‚ค ์ „์†ก (Secure=true ํ•„์ˆ˜) + // ์ฐธ๊ณ : Cookie ๊ฐ์ฒด๋Š” SameSite๋ฅผ ์ง์ ‘ ์ง€์›ํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ + // ResponseCookie๋ฅผ ์‚ฌ์šฉํ•˜๊ฑฐ๋‚˜ Set-Cookie ํ—ค๋”๋ฅผ ์ง์ ‘ ์ž‘์„ฑํ•ด์•ผ ํ•จ + // ์˜ˆ์‹œ) + + log.debug("Cookie created - name: {}, maxAge: {} seconds", name, maxAge); + return cookie; + } + + /** + * HTTP-only ์ฟ ํ‚ค๋ฅผ ์‘๋‹ต์— ์ถ”๊ฐ€ + * + * @param response HttpServletResponse + * @param name ์ฟ ํ‚ค ์ด๋ฆ„ + * @param value ์ฟ ํ‚ค ๊ฐ’ + * @param maxAge ๋งŒ๋ฃŒ ์‹œ๊ฐ„ (์ดˆ ๋‹จ์œ„) + */ + public void addCookie(HttpServletResponse response, String name, String value, int maxAge) { + Cookie cookie = createCookie(name, value, maxAge); + response.addCookie(cookie); + log.info("Cookie added to response - name: {}", name); + } + + /** + * ์š”์ฒญ์—์„œ ํŠน์ • ์ฟ ํ‚ค ๊ฐ’ ์กฐํšŒ + * + * @param request HttpServletRequest + * @param name ์ฟ ํ‚ค ์ด๋ฆ„ + * @return Optional ์ฟ ํ‚ค ๊ฐ’ (์—†์œผ๋ฉด empty) + */ + public Optional getCookie(HttpServletRequest request, String name) { + Cookie[] cookies = request.getCookies(); + + if (cookies == null) { + log.debug("No cookies found in request"); + return Optional.empty(); + } + + return Arrays.stream(cookies) + .filter(cookie -> name.equals(cookie.getName())) + .map(Cookie::getValue) + .findFirst(); + } + + /** + * ์ฟ ํ‚ค ์‚ญ์ œ (MaxAge๋ฅผ 0์œผ๋กœ ์„ค์ •) + * + * @param response HttpServletResponse + * @param name ์‚ญ์ œํ•  ์ฟ ํ‚ค ์ด๋ฆ„ + */ + public void deleteCookie(HttpServletResponse response, String name) { + Cookie cookie = new Cookie(name, null); + cookie.setHttpOnly(true); + cookie.setPath("/"); + cookie.setMaxAge(0); // ์ฆ‰์‹œ ๋งŒ๋ฃŒ + cookie.setSecure(false); + + response.addCookie(cookie); + log.info("Cookie deleted - name: {}", name); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/utility/handler/OAuth2FailureHandler.java b/backend/src/main/java/com/mysite/knitly/utility/handler/OAuth2FailureHandler.java new file mode 100644 index 0000000..438e5d2 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/utility/handler/OAuth2FailureHandler.java @@ -0,0 +1,63 @@ +package com.mysite.knitly.utility.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +@Slf4j +@Component +public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler { + + @Value("${frontend.url}") + private String frontendUrl; + + @Override + public void onAuthenticationFailure(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + + log.error("=== OAuth2 Login Failed ==="); + log.error("Exception Type: {}", exception.getClass().getName()); + log.error("Error Message: {}", exception.getMessage()); + + String errorMessage = "๋กœ๊ทธ์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."; + + // OAuth2 ๊ด€๋ จ ์—๋Ÿฌ์ธ ๊ฒฝ์šฐ ์ƒ์„ธ ์ •๋ณด ์ถœ๋ ฅ + if (exception instanceof OAuth2AuthenticationException) { + OAuth2AuthenticationException oauth2Exception = (OAuth2AuthenticationException) exception; + OAuth2Error error = oauth2Exception.getError(); + + log.error("OAuth2 Error Code: {}", error.getErrorCode()); + log.error("OAuth2 Error Description: {}", error.getDescription()); + + errorMessage = error.getDescription() != null ? error.getDescription() : errorMessage; + } + + // ์Šคํƒ ํŠธ๋ ˆ์ด์Šค ์ถœ๋ ฅ (๊ฐœ๋ฐœ ํ™˜๊ฒฝ์šฉ) + log.error("Stack Trace: ", exception); + + // TODO : ํ”„๋ก ํŠธํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ž™ํŠธํ• ๊ฒƒ(์ˆ˜์ •ํ•ด์•ผํ•จ) + // ์—๋Ÿฌ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ + String encodedMessage = URLEncoder.encode(errorMessage, StandardCharsets.UTF_8); + //String targetUrl = "http://localhost:8080/login/error?message=" + encodedMessage; + + // ํ”„๋ก ํŠธ์—”๋“œ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ (์—๋Ÿฌ ํŒŒ๋ผ๋ฏธํ„ฐ ํฌํ•จ) + //String targetUrl = String.format("%s/?error=%s", frontendUrl, encodedMessage); + + // ํ”„๋ก ํŠธ ๋ฉ”์ธ์œผ๋กœ ๋ฆฌ๋””๋ž™์…˜ + String targetUrl = frontendUrl + "/?loginError=true"; + + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/utility/handler/OAuth2SuccessHandler.java b/backend/src/main/java/com/mysite/knitly/utility/handler/OAuth2SuccessHandler.java new file mode 100644 index 0000000..ff1590d --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/utility/handler/OAuth2SuccessHandler.java @@ -0,0 +1,120 @@ +package com.mysite.knitly.utility.handler; + +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.domain.user.service.UserService; +import com.mysite.knitly.utility.cookie.CookieUtil; +import com.mysite.knitly.utility.jwt.JwtProvider; +import com.mysite.knitly.utility.jwt.TokenResponse; +import com.mysite.knitly.utility.oauth.OAuth2UserInfo; +import com.mysite.knitly.utility.redis.RefreshTokenService; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final UserService userService; + private final JwtProvider jwtProvider; + private final RefreshTokenService refreshTokenService; + private final CookieUtil cookieUtil; + + // TODO : yml ํŒŒ์ผ, env ํŒŒ์ผ ์ˆ˜์ •ํ• ๊ฒƒ + // ํ”„๋ก ํŠธ์—”๋“œ URL ์„ค์ • (ํ™˜๊ฒฝ๋ณ€์ˆ˜๋กœ ๊ด€๋ฆฌ ๊ถŒ์žฅ) + @Value("${frontend.url}") // ๊ธฐ๋ณธ๊ฐ’: localhost:3000 + private String frontendUrl; + + @Value("${custom.jwt.refreshTokenExpireSeconds}") + private int refreshTokenExpireSeconds; + + // Refresh Token ์ฟ ํ‚ค ์ด๋ฆ„ ์ƒ์ˆ˜ + private static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken"; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + + // 1. OAuth2User ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ + OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); + Map attributes = oAuth2User.getAttributes(); + + // 2. ์‚ฌ์šฉ์ž ์ •๋ณด ์ถ”์ถœ + OAuth2UserInfo userInfo = OAuth2UserInfo.of("google", attributes); + + log.info("=== OAuth2 Login Success ==="); + log.info("Email: {}", userInfo.getEmail()); + log.info("Name: {}", userInfo.getName()); + log.info("Provider ID: {}", userInfo.getProviderId()); + + // 3. ์‚ฌ์šฉ์ž ์ €์žฅ ๋˜๋Š” ์กฐํšŒ + User user = userService.processGoogleUser( + userInfo.getProviderId(), + userInfo.getEmail(), + userInfo.getName() + ); + + log.info("User processed - userId: {}", user.getUserId()); + + // ์Šคํ† ์–ด ์ค‘๋ณต ์ƒ์„ฑ ๋ฐฉ์ง€ + userService.ensureUserStore(user); + + // 4. JWT ํ† ํฐ ๋ฐœ๊ธ‰ + TokenResponse tokens = jwtProvider.createTokens(user.getUserId()); + + log.info("=== JWT Tokens Created ==="); + log.info("Access Token: {}", tokens.getAccessToken()); + log.info("Refresh Token: {}", tokens.getRefreshToken()); + log.info("Expires In: {} seconds", tokens.getExpiresIn()); + + // 5. Refresh Token์„ Redis์— ์ €์žฅ + refreshTokenService.saveRefreshToken(user.getUserId(), tokens.getRefreshToken()); + log.info("Refresh Token saved to Redis"); + + // 6. Refresh Token์„ HTTP-only ์ฟ ํ‚ค์— ์ €์žฅ + cookieUtil.addCookie( + response, + REFRESH_TOKEN_COOKIE_NAME, + tokens.getRefreshToken(), + refreshTokenExpireSeconds + ); + log.info("Refresh Token saved to HTTP-only cookie"); + +// // 7. ์ž„์‹œ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ (ํ…Œ์ŠคํŠธ์šฉ) - Access Token๋งŒ URL๋กœ ์ „๋‹ฌ +// String targetUrl = String.format( +// "http://localhost:8080/login/success?userId=%s&email=%s&name=%s&accessToken=%s", +// user.getUserId(), +// URLEncoder.encode(user.getEmail(), StandardCharsets.UTF_8), +// URLEncoder.encode(user.getName(), StandardCharsets.UTF_8), +// tokens.getAccessToken() +// ); + + // 7. ํ”„๋ก ํŠธ์—”๋“œ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ (Access Token ํฌํ•จ) + // ๋ณ€๊ฒฝ: localhost:8080 โ†’ frontend.url (localhost:3000) + String targetUrl = String.format( + "%s?userId=%s&email=%s&name=%s&accessToken=%s", + frontendUrl, + user.getUserId(), + URLEncoder.encode(user.getEmail(), StandardCharsets.UTF_8), + URLEncoder.encode(user.getName(), StandardCharsets.UTF_8), + tokens.getAccessToken() + ); + + log.info("Redirecting to: {}", targetUrl); + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/utility/jwt/JwtAuthenticationFilter.java b/backend/src/main/java/com/mysite/knitly/utility/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..8f906c9 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/utility/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,90 @@ +package com.mysite.knitly.utility.jwt; + +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.domain.user.service.UserService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + private final UserService userService; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + try { + // 1. ์š”์ฒญ ํ—ค๋”์—์„œ JWT ํ† ํฐ ์ถ”์ถœ + String token = extractTokenFromRequest(request); + + log.info("===> JWT Filter: token = {}", token != null ? "EXISTS" : "NULL"); + + if (token != null && jwtProvider.validateToken(token)) { + log.info("===> JWT Valid!"); + // 2. ํ† ํฐ์—์„œ userId ์ถ”์ถœ + Long userId = jwtProvider.getUserIdFromToken(token); + + // 3. userId๋กœ ์‚ฌ์šฉ์ž ์กฐํšŒ + User user = userService.findById(userId); + + // 4. Spring Security ์ธ์ฆ ๊ฐ์ฒด ์ƒ์„ฑ + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + user, // principal + null, // credentials + Collections.emptyList() // authorities + ); + + authentication.setDetails( + new WebAuthenticationDetailsSource().buildDetails(request) + ); + + // 5. SecurityContext์— ์ธ์ฆ ์ •๋ณด ์ €์žฅ + SecurityContextHolder.getContext().setAuthentication(authentication); + + log.debug("JWT authenticated - userId: {}", userId); + } else { + log.warn("===> JWT Invalid or null!"); + } + + } catch (Exception e) { + log.error("JWT authentication failed: {}", e.getMessage()); + // ์ธ์ฆ ์‹คํŒจํ•ด๋„ ๋‹ค์Œ ํ•„ํ„ฐ๋กœ ์ง„ํ–‰ (Spring Security๊ฐ€ ์ฒ˜๋ฆฌ) + } + + // ๋‹ค์Œ ํ•„ํ„ฐ๋กœ ์ง„ํ–‰ + filterChain.doFilter(request, response); + } + + /** + * ์š”์ฒญ ํ—ค๋”์—์„œ Bearer ํ† ํฐ ์ถ”์ถœ + * Authorization: Bearer {token} + */ + private String extractTokenFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); // "Bearer " ์ œ๊ฑฐ + } + + return null; + } +} diff --git a/backend/src/main/java/com/mysite/knitly/utility/jwt/JwtProperties.java b/backend/src/main/java/com/mysite/knitly/utility/jwt/JwtProperties.java new file mode 100644 index 0000000..7112a5c --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/utility/jwt/JwtProperties.java @@ -0,0 +1,17 @@ +package com.mysite.knitly.utility.jwt; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "custom.jwt") +public class JwtProperties { + + private String secretKey; + private long accessTokenExpireSeconds; + private long refreshTokenExpireSeconds; +} diff --git a/backend/src/main/java/com/mysite/knitly/utility/jwt/JwtProvider.java b/backend/src/main/java/com/mysite/knitly/utility/jwt/JwtProvider.java new file mode 100644 index 0000000..cff7aa3 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/utility/jwt/JwtProvider.java @@ -0,0 +1,107 @@ +package com.mysite.knitly.utility.jwt; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtProvider { + + private final JwtProperties jwtProperties; + + /** + * SecretKey ์ƒ์„ฑ + */ + private SecretKey getSigningKey() { + byte[] keyBytes = jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8); + return Keys.hmacShaKeyFor(keyBytes); + } + + /** + * Access Token ์ƒ์„ฑ + */ + public String createAccessToken(Long userId) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + jwtProperties.getAccessTokenExpireSeconds() * 1000); + + return Jwts.builder() + .subject(userId.toString()) + .issuedAt(now) + .expiration(expiryDate) + .signWith(getSigningKey()) + .compact(); + } + + /** + * Refresh Token ์ƒ์„ฑ + */ + public String createRefreshToken(Long userId) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + jwtProperties.getRefreshTokenExpireSeconds() * 1000); + + return Jwts.builder() + .subject(userId.toString()) + .issuedAt(now) + .expiration(expiryDate) + .signWith(getSigningKey()) + .compact(); + } + + /** + * ํ† ํฐ์—์„œ userId ์ถ”์ถœ + */ + public Long getUserIdFromToken(String token) { + Claims claims = Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + + return Long.parseLong(claims.getSubject()); + } + + /** + * ํ† ํฐ ์œ ํšจ์„ฑ ๊ฒ€์ฆ + */ + public boolean validateToken(String token) { + try { + Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token); + return true; + } catch (SignatureException | MalformedJwtException e) { + log.error("์ž˜๋ชป๋œ JWT ์„œ๋ช…์ž…๋‹ˆ๋‹ค."); + } catch (ExpiredJwtException e) { + log.error("๋งŒ๋ฃŒ๋œ JWT ํ† ํฐ์ž…๋‹ˆ๋‹ค."); + } catch (UnsupportedJwtException e) { + log.error("์ง€์›๋˜์ง€ ์•Š๋Š” JWT ํ† ํฐ์ž…๋‹ˆ๋‹ค."); + } catch (IllegalArgumentException e) { + log.error("JWT ํ† ํฐ์ด ์ž˜๋ชป๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + } + return false; + } + + /** + * Access Token๊ณผ Refresh Token์„ ํ•จ๊ป˜ ์ƒ์„ฑ + */ + public TokenResponse createTokens(Long userId) { + String accessToken = createAccessToken(userId); + String refreshToken = createRefreshToken(userId); + + return TokenResponse.of( + accessToken, + refreshToken, + jwtProperties.getAccessTokenExpireSeconds() + ); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mysite/knitly/utility/jwt/TokenResponse.java b/backend/src/main/java/com/mysite/knitly/utility/jwt/TokenResponse.java new file mode 100644 index 0000000..a7868ae --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/utility/jwt/TokenResponse.java @@ -0,0 +1,25 @@ +package com.mysite.knitly.utility.jwt; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class TokenResponse { + + private String accessToken; + private String refreshToken; + private String tokenType; + private long expiresIn; // Access Token ๋งŒ๋ฃŒ ์‹œ๊ฐ„ (์ดˆ) + + public static TokenResponse of(String accessToken, String refreshToken, long expiresIn) { + return TokenResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .tokenType("Bearer") + .expiresIn(expiresIn) + .build(); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/utility/oauth/CustomOAuth2UserService.java b/backend/src/main/java/com/mysite/knitly/utility/oauth/CustomOAuth2UserService.java new file mode 100644 index 0000000..6c9d1cc --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/utility/oauth/CustomOAuth2UserService.java @@ -0,0 +1,38 @@ +package com.mysite.knitly.utility.oauth; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; + + +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + // 1. Google๋กœ๋ถ€ํ„ฐ ์‚ฌ์šฉ์ž ์ •๋ณด ๋ฐ›์•„์˜ค๊ธฐ + OAuth2User oAuth2User = super.loadUser(userRequest); + + // 2. ์–ด๋–ค OAuth ์ œ๊ณต์ž์ธ์ง€ ํ™•์ธ (google) + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + + // 3. ์‚ฌ์šฉ์ž ์ •๋ณด ์ถ”์ถœ + Map attributes = oAuth2User.getAttributes(); + OAuth2UserInfo userInfo = OAuth2UserInfo.of(registrationId, attributes); + + // ๋กœ๊ทธ๋กœ ํ™•์ธ + log.info("OAuth2 Login - Provider: {}", registrationId); + log.info("OAuth2 Login - Email: {}", userInfo.getEmail()); + log.info("OAuth2 Login - Name: {}", userInfo.getName()); + + // 4. OAuth2User ๋ฐ˜ํ™˜ (๋‹ค์Œ ๋‹จ๊ณ„ SuccessHandler๋กœ ์ „๋‹ฌ๋จ) + return oAuth2User; + } +} diff --git a/backend/src/main/java/com/mysite/knitly/utility/oauth/OAuth2UserInfo.java b/backend/src/main/java/com/mysite/knitly/utility/oauth/OAuth2UserInfo.java new file mode 100644 index 0000000..d8311fb --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/utility/oauth/OAuth2UserInfo.java @@ -0,0 +1,44 @@ +package com.mysite.knitly.utility.oauth; + +import lombok.Builder; +import lombok.Getter; + +import java.util.Map; + +@Getter +@Builder +public class OAuth2UserInfo { + private String email; + private String name; + private String providerId; // Google์˜ ๊ณ ์œ  ID + + /** + * Google OAuth2 ์‘๋‹ต์—์„œ ์‚ฌ์šฉ์ž ์ •๋ณด ์ถ”์ถœ + */ + public static OAuth2UserInfo of(String registrationId, Map attributes) { + // Google OAuth2 ์‘๋‹ต ์ฒ˜๋ฆฌ + if ("google".equals(registrationId)) { + return ofGoogle(attributes); + } + + throw new IllegalArgumentException("Unsupported provider: " + registrationId); + } + + /** + * Google ์‘๋‹ต ํŒŒ์‹ฑ + * Google ์‘๋‹ต ์˜ˆ์‹œ: + * { + * "sub": "1234567890", + * "name": "ํ™๊ธธ๋™", + * "email": "user@gmail.com", + * "picture": "https://..." + * } + */ + private static OAuth2UserInfo ofGoogle(Map attributes) { + return OAuth2UserInfo.builder() + .providerId((String) attributes.get("sub")) + .email((String) attributes.get("email")) + .name((String) attributes.get("name")) + .build(); + } +} diff --git a/backend/src/main/java/com/mysite/knitly/utility/redis/RedisConfig.java b/backend/src/main/java/com/mysite/knitly/utility/redis/RedisConfig.java new file mode 100644 index 0000000..a23c24d --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/utility/redis/RedisConfig.java @@ -0,0 +1,25 @@ +package com.mysite.knitly.utility.redis; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // Key์™€ Value ๋ชจ๋‘ String์œผ๋กœ ์ง๋ ฌํ™” + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new StringRedisSerializer()); + + return template; + } +} diff --git a/backend/src/main/java/com/mysite/knitly/utility/redis/RefreshTokenService.java b/backend/src/main/java/com/mysite/knitly/utility/redis/RefreshTokenService.java new file mode 100644 index 0000000..c8aa53d --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/utility/redis/RefreshTokenService.java @@ -0,0 +1,91 @@ +package com.mysite.knitly.utility.redis; + +import com.mysite.knitly.utility.jwt.JwtProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RefreshTokenService { + + private final RedisTemplate redisTemplate; + private final JwtProperties jwtProperties; + + private static final String REFRESH_TOKEN_PREFIX = "RT:"; + + /** + * Refresh Token์„ Redis์— ์ €์žฅ + * Key: RT:{userId} + * Value: refreshToken + * TTL: 7์ผ + */ + public void saveRefreshToken(Long userId, String refreshToken) { + String key = REFRESH_TOKEN_PREFIX + userId.toString(); + + redisTemplate.opsForValue().set( + key, + refreshToken, + jwtProperties.getRefreshTokenExpireSeconds(), + TimeUnit.SECONDS + ); + + log.info("==> Refresh Token saved to Redis - userId: {}", userId); + log.info("==> Refresh Token saved to Redis - refreshToken: {}", refreshToken); + } + + /** + * Redis์—์„œ Refresh Token ์กฐํšŒ + */ + public String getRefreshToken(Long userId) { + String key = REFRESH_TOKEN_PREFIX + userId.toString(); + String refreshToken = redisTemplate.opsForValue().get(key); + + if (refreshToken == null) { + log.warn("Refresh Token not found in Redis - userId: {}", userId); + } + + return refreshToken; + } + + /** + * Refresh Token ๊ฒ€์ฆ + * - Redis์— ์ €์žฅ๋œ ํ† ํฐ๊ณผ ์ผ์น˜ํ•˜๋Š”์ง€ ํ™•์ธ + */ + public boolean validateRefreshToken(Long userId, String refreshToken) { + String storedToken = getRefreshToken(userId); + + if (storedToken == null) { + log.warn("No stored refresh token for userId: {}", userId); + return false; + } + + boolean isValid = storedToken.equals(refreshToken); + + if (!isValid) { + log.warn("Refresh token mismatch for userId: {}", userId); + } + + return isValid; + } + + /** + * Refresh Token ์‚ญ์ œ (๋กœ๊ทธ์•„์›ƒ ์‹œ ์‚ฌ์šฉ) + */ + public void deleteRefreshToken(Long userId) { + String key = REFRESH_TOKEN_PREFIX + userId.toString(); + Boolean deleted = redisTemplate.delete(key); + + if (Boolean.TRUE.equals(deleted)) { + log.info("Refresh Token deleted from Redis - userId: {}", userId); + } else { + log.warn("Refresh Token not found for deletion - userId: {}", userId); + } + } + + +} diff --git a/backend/src/main/java/com/mysite/knitly/utility/test/controller/TestController.java b/backend/src/main/java/com/mysite/knitly/utility/test/controller/TestController.java new file mode 100644 index 0000000..25cbae4 --- /dev/null +++ b/backend/src/main/java/com/mysite/knitly/utility/test/controller/TestController.java @@ -0,0 +1,231 @@ +package com.mysite.knitly.utility.test.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +@RestController +public class TestController { + + @GetMapping("/") + public String home() { + return """ +

Google OAuth2 Test

+ Google ๋กœ๊ทธ์ธ + """; + } + + @GetMapping("/login/success") + public String loginSuccess(@RequestParam String userId, + @RequestParam String email, + @RequestParam String name, + @RequestParam String accessToken) { + String decodedEmail = URLDecoder.decode(email, StandardCharsets.UTF_8); + String decodedName = URLDecoder.decode(name, StandardCharsets.UTF_8); + + return String.format(""" + + + + + + +

๐ŸŽ‰ ๋กœ๊ทธ์ธ ์„ฑ๊ณต!

+

User ID: %s

+

์ด๋ฉ”์ผ: %s

+

์ด๋ฆ„: %s

+ +
+

๐Ÿ’ก Access Token (๋ฉ”๋ชจ๋ฆฌ ์ €์žฅ๋จ)

+

์ด ํ† ํฐ์€ JavaScript ๋ณ€์ˆ˜์— ์ €์žฅ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

+

๊ฐœ๋ฐœ์ž๋„๊ตฌ Console์—์„œ window.accessToken์œผ๋กœ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

+
+ +

๐Ÿ”‘ Access Token

+
%s
+ +
+ + + + +
+ +
+ +

๐Ÿช Refresh Token (์ฟ ํ‚ค ์ €์žฅ๋จ)

+

๊ฐœ๋ฐœ์ž๋„๊ตฌ > Application > Cookies์—์„œ refreshToken ํ™•์ธ ๊ฐ€๋Šฅ

+ +
+ ํ™ˆ์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ + + + + + """, userId, decodedEmail, decodedName, accessToken, accessToken); + } + + @GetMapping("/login/error") + public String loginError(@RequestParam(required = false) String message) { + String errorMessage = message != null ? + URLDecoder.decode(message, StandardCharsets.UTF_8) : + "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."; + + return String.format(""" + + + + + + +

โŒ ๋กœ๊ทธ์ธ ์‹คํŒจ

+
+

์˜ค๋ฅ˜: %s

+
+ ๋‹ค์‹œ ์‹œ๋„ํ•˜๊ธฐ + + + """, errorMessage); + } +} \ No newline at end of file diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml new file mode 100644 index 0000000..e3099dc --- /dev/null +++ b/backend/src/main/resources/application-dev.yml @@ -0,0 +1,14 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${DB_URL} + username: ${DB_USER} + password: ${DB_PW} + + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + dialect: org.hibernate.dialect.MySQL8Dialect + show-sql: true diff --git a/backend/src/main/resources/application-test.yml b/backend/src/main/resources/application-test.yml new file mode 100644 index 0000000..41937cd --- /dev/null +++ b/backend/src/main/resources/application-test.yml @@ -0,0 +1,52 @@ +spring: + datasource: + url: jdbc:h2:mem:knitly;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.H2Dialect + sql: + init: + mode: never + +logging: + level: + org.hibernate.SQL: debug + org.hibernate.orm.jdbc.bind: trace + +#spring: +# datasource: +# url: jdbc:h2:mem:knitly;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE +# driver-class-name: org.h2.Driver +# username: sa +# password: +# jpa: +# hibernate: +# ddl-auto: create +# #show-sql: true +# show-sql: false +# properties: +# hibernate: +# format_sql: true +# dialect: org.hibernate.dialect.H2Dialect +# sql: +# init: +# mode: never +# +#logging: +# level: +# root: INFO +# com.mysite.knitly: DEBUG +# org.hibernate.SQL: INFO +# org.springframework.transaction.interceptor: INFO +## org.hibernate.orm.jdbc.bind: trace +# +#frontend: +# url: http://localhost:3000 \ No newline at end of file diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..b207494 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,133 @@ +spring: + config: + import: optional:file:.env.properties + profiles: + active: dev + output: + ansi: + enabled: always + + # --- Datasource / JPA --- + datasource: + hikari: + auto-commit: false + + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + format_sql: true + highlight_sql: true + use_sql_comments: true + dialect: org.hibernate.dialect.MySQL8Dialect + show-sql: true + + # --- Web / MVC --- + mvc: + hiddenmethod: + filter: + enabled: true + web: + resources: + static-locations: + - classpath:/static/ + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + + # OAuth 2.0 ์„ค์ • ์ถ”๊ฐ€ + security: + oauth2: + client: + registration: + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + scope: + - profile + - email + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + provider: + google: + authorization-uri: https://accounts.google.com/o/oauth2/v2/auth + token-uri: https://oauth2.googleapis.com/token + user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo + user-name-attribute: sub + + # Redis ์„ค์ • ์ถ”๊ฐ€ + data: + redis: + host: localhost + port: 6379 + # password: ${REDIS_PASSWORD} # ๋น„๋ฐ€๋ฒˆํ˜ธ ์„ค์ • ์‹œ ์‚ฌ์šฉ + + # --- RabbitMQ ์„ค์ • ์ถ”๊ฐ€ --- + rabbitmq: + host: localhost + port: 5672 + username: guest + password: guest + + listener: + simple: + acknowledge-mode: auto + default-requeue-rejected: false + retry: + enabled: true + max-attempts: 3 + initial-interval: 1000 + multiplier: 2.0 + max-interval: 10000 + template: + mandatory: true + mail: + host: smtp.gmail.com + port: 587 + username: dpwls8972@gmail.com + password: lyis nhtm nxsw snha #๋ฉ”์ผ์„œ๋ฒ„ ์—ฐ๊ฒฐ + properties: + mail: + smtp: + auth: true + starttls: + enable: true + +# --- ํŒŒ์ผ ์—…๋กœ๋“œ --- +file: + upload-dir: uploads/designs + public-prefix: /files + +# --- Springdoc --- +springdoc: + default-produces-media-type: application/json + +# --- Logging --- +logging: + level: + org.hibernate.orm.jdbc.bind: TRACE + org.hibernate.orm.jdbc.extract: TRACE + org.springframework.transaction.interceptor: TRACE + com.rest1: DEBUG + + org.springframework.security: DEBUG + org.springframework.security.oauth2: TRACE + org.springframework.web: DEBUG + +# --- Custom JWT ์„ค์ • --- +custom: + jwt: + # Refresh Token ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ์ถ”๊ฐ€ + accessTokenExpireSeconds: 1800 #{30 * 60} 30๋ถ„ + refreshTokenExpireSeconds: 604800 #{7 * 24 * 60 * 60} 7์ผ + secretKey: ${JWT_SECRET_KEY} # ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋กœ ๊ด€๋ฆฌ + +# --- ํ”„๋ก ํŠธ์—”๋“œ URL ์„ค์ • --- +frontend: + url: ${FRONT_URL} # ๊ฐœ๋ฐœ ํ™˜๊ฒฝ + # url: https://www.myapp.com # ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ +# --- toss ์—ฐ๋™ --- +payment: + toss: + secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 \ No newline at end of file diff --git a/backend/src/main/resources/fonts/NanumGothic-Regular.ttf b/backend/src/main/resources/fonts/NanumGothic-Regular.ttf new file mode 100644 index 0000000..e3c67f4 Binary files /dev/null and b/backend/src/main/resources/fonts/NanumGothic-Regular.ttf differ diff --git a/backend/src/main/resources/static/post/example.jpg b/backend/src/main/resources/static/post/example.jpg new file mode 100644 index 0000000..de7af19 Binary files /dev/null and b/backend/src/main/resources/static/post/example.jpg differ diff --git a/backend/src/main/resources/static/review/img1.jpg b/backend/src/main/resources/static/review/img1.jpg new file mode 100644 index 0000000..12b764f Binary files /dev/null and b/backend/src/main/resources/static/review/img1.jpg differ diff --git a/backend/src/main/resources/static/review/img2.jpg b/backend/src/main/resources/static/review/img2.jpg new file mode 100644 index 0000000..7c0bf0f Binary files /dev/null and b/backend/src/main/resources/static/review/img2.jpg differ diff --git a/backend/src/main/resources/static/review/wrongimg1.txt b/backend/src/main/resources/static/review/wrongimg1.txt new file mode 100644 index 0000000..5b074c6 --- /dev/null +++ b/backend/src/main/resources/static/review/wrongimg1.txt @@ -0,0 +1 @@ +์ž˜๋ชป๋œ ๋ฆฌ๋ทฐ ์ด๋ฏธ์ง€ ํŒŒ์ผ ์˜ˆ์‹œ \ No newline at end of file diff --git a/src/test/java/com/mysite/knitly/KnitlyApplicationTests.java b/backend/src/test/java/com/mysite/knitly/KnitlyApplicationTests.java similarity index 100% rename from src/test/java/com/mysite/knitly/KnitlyApplicationTests.java rename to backend/src/test/java/com/mysite/knitly/KnitlyApplicationTests.java diff --git a/backend/src/test/java/com/mysite/knitly/domain/community/comment/controller/CommentControllerValidationTest.java b/backend/src/test/java/com/mysite/knitly/domain/community/comment/controller/CommentControllerValidationTest.java new file mode 100644 index 0000000..2d3d1f7 --- /dev/null +++ b/backend/src/test/java/com/mysite/knitly/domain/community/comment/controller/CommentControllerValidationTest.java @@ -0,0 +1,147 @@ +package com.mysite.knitly.domain.community.comment.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import com.mysite.knitly.domain.community.post.repository.PostRepository; +import com.mysite.knitly.domain.user.repository.UserRepository; +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.domain.user.entity.Provider; +import com.mysite.knitly.domain.community.post.entity.Post; +import com.mysite.knitly.domain.community.post.entity.PostCategory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.hamcrest.Matchers.startsWith; + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) // ๋ณด์•ˆ ํ•„ํ„ฐ๋Š” ํ•„์š”ํ•˜๋ฉด ๋‚˜์ค‘์—, SecurityContext๋Š” ์ˆ˜๋™ ์ฃผ์ž… +@ActiveProfiles("test") +class CommentControllerValidationTest { + + @Autowired MockMvc mvc; + @Autowired ObjectMapper om; + @Autowired + UserRepository userRepository; + @Autowired PostRepository postRepository; + + private Long postId; + private Long authorId; + private com.mysite.knitly.domain.user.entity.User author; + + @BeforeEach + void setUp() { + + // ํ…Œ์ŠคํŠธ ์‚ฌ์šฉ์ž (email ํ•„์ˆ˜) + author = com.mysite.knitly.domain.user.entity.User.builder() + .socialId("cval-auth-" + java.util.UUID.randomUUID()) + .email("author@test.com") + .name("Author") + .provider(com.mysite.knitly.domain.user.entity.Provider.GOOGLE) + .build(); + authorId = userRepository.save(author).getUserId(); + + // ๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ + var post = com.mysite.knitly.domain.community.post.entity.Post.builder() + .category(com.mysite.knitly.domain.community.post.entity.PostCategory.FREE) + .title("๋Œ“๊ธ€ ๊ฒ€์ฆ์šฉ ๊ธ€") + .content("๋ณธ๋ฌธ") + .imageUrls(java.util.List.of()) + .author(author) + .build(); + postId = postRepository.save(post).getId(); + } + + // SecurityContext์— @AuthenticationPrincipal(User) ์„ธํŒ… + private RequestPostProcessor withAuth(User u) { + return request -> { + var auth = new UsernamePasswordAuthenticationToken(u, null, List.of()); + SecurityContextHolder.getContext().setAuthentication(auth); + return request; + }; + } + + @Test + void create_comment_blank_or_too_long_returns_400() throws Exception { + // ๋นˆ ๋Œ“๊ธ€ -> 400 + var bodyBlank = om.writeValueAsString(Map.of( + "postId", postId, "content", " " + )); + mvc.perform(post("/community/posts/{postId}/comments", postId) + .contentType(MediaType.APPLICATION_JSON) + .content(bodyBlank) + .with(withAuth(author))) + .andExpect(status().isBadRequest()); + + // 301์ž -> 400 + var longText = "๊ฐ€".repeat(301); + var bodyLong = om.writeValueAsString(Map.of( + "postId", postId, "content", longText + )); + mvc.perform(post("/community/posts/{postId}/comments", postId) + .contentType(MediaType.APPLICATION_JSON) + .content(bodyLong) + .with(withAuth(author))) + .andExpect(status().isBadRequest()); + } + + @Test + void create_comment_ok_returns_201() throws Exception { + var body = om.writeValueAsString(Map.of( + "postId", postId, "content", "์ •์ƒ ๋Œ“๊ธ€" + )); + mvc.perform(post("/community/posts/{postId}/comments", postId) + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .with(withAuth(author))) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", startsWith("/community/posts/" + postId + "/comments/"))) + .andExpect(jsonPath("$.content").value("์ •์ƒ ๋Œ“๊ธ€")); + } + + @Test + void create_reply_ok_returns_201() throws Exception { + // ๋ฃจํŠธ ๋Œ“๊ธ€ ์ƒ์„ฑ + var root = new HashMap(); + root.put("postId", postId); + root.put("content", "๋ฃจํŠธ"); + var rootRes = mvc.perform(post("/community/posts/{postId}/comments", postId) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(root)) + .with(withAuth(author))) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", startsWith("/community/posts/" + postId + "/comments/"))) + .andReturn(); + + long rootId = om.readTree(rootRes.getResponse().getContentAsString()).get("id").asLong(); + + // ๋Œ€๋Œ“๊ธ€ ์ƒ์„ฑ parentId ํฌํ•จ ๋จ. + var reply = new HashMap(); + reply.put("postId", postId); + reply.put("parentId", rootId); + reply.put("content", "๋Œ€๋Œ“๊ธ€"); + mvc.perform(post("/community/posts/{postId}/comments", postId) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(reply)) + .with(withAuth(author))) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", startsWith("/community/posts/" + postId + "/comments/"))) + .andExpect(jsonPath("$.content").value("๋Œ€๋Œ“๊ธ€")); + } +} diff --git a/backend/src/test/java/com/mysite/knitly/domain/community/comment/service/CommentServiceTest.java b/backend/src/test/java/com/mysite/knitly/domain/community/comment/service/CommentServiceTest.java new file mode 100644 index 0000000..0b8b04e --- /dev/null +++ b/backend/src/test/java/com/mysite/knitly/domain/community/comment/service/CommentServiceTest.java @@ -0,0 +1,188 @@ +package com.mysite.knitly.domain.community.comment.service; + +import com.mysite.knitly.domain.community.comment.dto.CommentCreateRequest; +import com.mysite.knitly.domain.community.comment.dto.CommentResponse; +import com.mysite.knitly.domain.community.comment.dto.CommentTreeResponse; +import com.mysite.knitly.domain.community.comment.dto.CommentUpdateRequest; +import com.mysite.knitly.domain.community.comment.repository.CommentRepository; +import com.mysite.knitly.domain.community.post.entity.Post; +import com.mysite.knitly.domain.community.post.entity.PostCategory; +import com.mysite.knitly.domain.community.post.repository.PostRepository; +import com.mysite.knitly.domain.user.repository.UserRepository; +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.domain.user.entity.Provider; +import com.mysite.knitly.global.exception.ErrorCode; +import com.mysite.knitly.global.exception.ServiceException; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class CommentServiceTest { + + @Autowired + private CommentService commentService; + + @Autowired + private CommentRepository commentRepository; + + @Autowired + private PostRepository postRepository; + + @Autowired + private UserRepository userRepository; + + private User author1; + private User author2; + private Long postId; + + @BeforeEach + void setUp() { + // ์œ ์ € + author1 = userRepository.save(User.builder() + .socialId("s1") + .email("u1@test.com") + .name("U1") + .provider(Provider.GOOGLE) + .build()); + author2 = userRepository.save(User.builder() + .socialId("s2") + .email("u2@test.com") + .name("U2") + .provider(Provider.KAKAO) + .build()); + + + // ๊ฒŒ์‹œ๊ธ€ + Post post = Post.builder() + .category(PostCategory.FREE) + .title("๋Œ“๊ธ€ ํ…Œ์ŠคํŠธ์šฉ ๊ธ€") + .content("๋ณธ๋ฌธ") + .imageUrls(List.of("https://ex.com/a.jpg")) + .author(author1) + .build(); + postId = postRepository.save(post).getId(); + } + + @Test + void create_success() { + CommentCreateRequest req = new CommentCreateRequest(postId, null, "์ฒซ ๋Œ“๊ธ€!"); + CommentResponse res = commentService.create(req, author1); + + assertThat(res.id()).isNotNull(); + assertThat(res.content()).isEqualTo("์ฒซ ๋Œ“๊ธ€!"); + assertThat(res.authorId()).isEqualTo(author1.getUserId()); + assertThat(res.authorDisplay()).isEqualTo("์ต๋ช…์˜ ํ„ธ์‹ค 1"); // ์ฒซ ๋“ฑ์žฅ ์‚ฌ์šฉ์ž = 1๋ฒˆ + assertThat(res.mine()).isTrue(); + } + + @Test + void update_forbidden_when_not_author() { + CommentResponse created = commentService.create(new CommentCreateRequest(postId, null, "์›๋ณธ"), author1); + assertThatThrownBy(() -> + commentService.update(created.id(), new CommentUpdateRequest("์ˆ˜์ •"), author2) + ) + .isInstanceOf(ServiceException.class) + .hasMessageContaining(ErrorCode.COMMENT_UPDATE_FORBIDDEN.getMessage()); + } + + @Test + void delete_forbidden_when_not_author() { + CommentResponse created = commentService.create(new CommentCreateRequest(postId, null, "์‚ญ์ œ๋Œ€์ƒ"), author1); + assertThatThrownBy(() -> + commentService.delete(created.id(), author2) + ) + .isInstanceOf(ServiceException.class) + .hasMessageContaining(ErrorCode.COMMENT_DELETE_FORBIDDEN.getMessage()); + } + + @Test + void list_sorting_and_count_and_anonymous_numbering() throws Exception { + // ๋ฃจํŠธ ๋Œ“๊ธ€ 3๊ฐœ๋กœ + commentService.create(new CommentCreateRequest(postId, null, "c1"), author1); + Thread.sleep(5); + commentService.create(new CommentCreateRequest(postId, null, "c2"), author2); + Thread.sleep(5); + commentService.create(new CommentCreateRequest(postId, null, "c3"), author1); + + // ๋“ฑ๋ก์ˆœ (๋ฃจํŠธ ํŽ˜์ด์ง•) + var asc = commentService.getComments(postId, "asc", 0, 10, author1); + assertThat(asc.getTotalElements()).isEqualTo(3); + assertThat(asc.getContent().get(0).content()).isEqualTo("c1"); + assertThat(asc.getContent().get(2).content()).isEqualTo("c3"); + + // ์ตœ์‹ ์ˆœ + var desc = commentService.getComments(postId, "desc", 0, 10, author1); + assertThat(desc.getContent().get(0).content()).isEqualTo("c3"); + assertThat(desc.getContent().get(2).content()).isEqualTo("c1"); + + // ๊ฐœ์ˆ˜ + long cnt = commentService.count(postId); + assertThat(cnt).isEqualTo(3); + + // ์ต๋ช… ๋ฒˆํ˜ธ ๋งคํ•‘ + assertThat(asc.getContent().get(0).authorDisplay()).isEqualTo("์ต๋ช…์˜ ํ„ธ์‹ค 1"); + assertThat(asc.getContent().get(1).authorDisplay()).isEqualTo("์ต๋ช…์˜ ํ„ธ์‹ค 2"); + assertThat(asc.getContent().get(2).authorDisplay()).isEqualTo("์ต๋ช…์˜ ํ„ธ์‹ค 1"); + } + + @Test + void reply_tree_is_returned_under_root() { + // ๋ฃจํŠธ ๋Œ“๊ธ€ + var root = commentService.create(new CommentCreateRequest(postId, null, "root"), author1); + + // ๋Œ€๋Œ“๊ธ€ 2๊ฐœ๋กœ + commentService.create(new CommentCreateRequest(postId, root.id(), "re-1"), author2); + commentService.create(new CommentCreateRequest(postId, root.id(), "re-2"), author1); + + var page = commentService.getComments(postId, "asc", 0, 10, author1); + assertThat(page.getTotalElements()).isEqualTo(1); + + CommentTreeResponse first = page.getContent().get(0); + assertThat(first.content()).isEqualTo("root"); + assertThat(first.children()).hasSize(2); + assertThat(first.children().get(0).content()).isEqualTo("re-1"); + assertThat(first.children().get(1).content()).isEqualTo("re-2"); + assertThat(first.children().get(0).parentId()).isEqualTo(root.id()); + } + + @Test + void reply_with_parent_from_other_post_throws_bad_request() { + // ๋‹ค๋ฅธ ๊ฒŒ์‹œ๊ธ€์ผ ๊ฒฝ์šฐ + User owner = userRepository.findById(author1.getUserId()).orElseThrow(); + Post otherPost = Post.builder() + .category(PostCategory.FREE) + .title("๋‹ค๋ฅธ ๊ธ€") + .content("x") + .imageUrls(List.of()) + .author(owner) + .build(); + Long otherPostId = postRepository.save(otherPost).getId(); + + // ๋‹ค๋ฅธ ๊ธ€์˜ ๋ฃจํŠธ ๋Œ“๊ธ€ + var otherRoot = commentService.create(new CommentCreateRequest(otherPostId, null, "other-root"), author1); + + // ํ˜„์žฌ ๊ธ€์— '๋‹ค๋ฅธ ๊ธ€์˜ ๋ถ€๋ชจ'๋กœ ๋Œ€๋Œ“๊ธ€ ์‹œ๋„ -> BAD_REQUEST + assertThatThrownBy(() -> + commentService.create(new CommentCreateRequest(postId, otherRoot.id(), "invalid"), author2) + ) + .isInstanceOf(ServiceException.class) + .hasMessageContaining(ErrorCode.BAD_REQUEST.getMessage()); + } + + @Test + void get_comments_on_missing_post_throws() { + assertThatThrownBy(() -> commentService.getComments(999_999L, "asc", 0, 10, author1)) + .isInstanceOf(ServiceException.class) + .hasMessageContaining(ErrorCode.POST_NOT_FOUND.getMessage()); + } +} + diff --git a/backend/src/test/java/com/mysite/knitly/domain/community/post/controller/PostControllerValidationTest.java b/backend/src/test/java/com/mysite/knitly/domain/community/post/controller/PostControllerValidationTest.java new file mode 100644 index 0000000..db5dfb9 --- /dev/null +++ b/backend/src/test/java/com/mysite/knitly/domain/community/post/controller/PostControllerValidationTest.java @@ -0,0 +1,160 @@ +package com.mysite.knitly.domain.community.post.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mysite.knitly.domain.community.post.entity.PostCategory; +import com.mysite.knitly.domain.user.entity.Provider; +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.domain.user.repository.UserRepository; +import org.apiguardian.api.API; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + + +// ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ API ์ž…๋ ฅ๊ฐ’ ๊ฒ€์ฆ ๋ฐ ๊ถŒํ•œ ํ…Œ์ŠคํŠธ +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@Transactional +class PostControllerValidationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserRepository userRepository; + + // ์ „์—ญ ํ…Œ์ŠคํŠธ ํ•„๋“œ + private User author; + private Long authorId; + private User other; + private Long otherId; + + @BeforeEach + void setUp() { + // ์ž‘์„ฑ์ž + author = User.builder() + .socialId("s1") + .email("author@test.com") + .name("Author") + .provider(Provider.GOOGLE) + .build(); + authorId = userRepository.save(author).getUserId(); + + // ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž + other = User.builder() + .socialId("s2") + .email("other@test.com") + .name("Other") + .provider(Provider.KAKAO) + .build(); + otherId = userRepository.save(other).getUserId(); + + // ์ธ์ฆ ์œ ์ €๋ฅผ SecurityContext์— ์ฃผ์ž… (MockMvc๊ฐ€ @AuthenticationPrincipal๋กœ ์ฝ์„ ์ˆ˜ ์žˆ๊ฒŒ) + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(author, null, List.of()) + ); + } + + + // ์ œ๋ชฉ์ด 100์ž ์ดˆ๊ณผ ์‹œ validation ์‹คํŒจ ํ…Œ์ŠคํŠธ + @Test + void createPost_titleTooLong_returnsBadRequest() throws Exception { + String longTitle = "a".repeat(101); + Map request = Map.of( + "category", PostCategory.FREE.name(), + "title", longTitle, + "content", "๋‚ด์šฉ", + "imageUrls", List.of("https://example.com/a.jpg") + ); + + mockMvc.perform(post("/community/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("POST_TITLE_LENGTH_INVALID")); + } + + + // ์ด๋ฏธ์ง€ 6๊ฐœ ์ด์ƒ์ด๋ฉด validation ์‹คํŒจ ํ…Œ์ŠคํŠธ + @Test + void createPost_tooManyImages_returnsBadRequest() throws Exception { + List sixImages = List.of( + "https://example.com/1.jpg", + "https://example.com/2.jpg", + "https://example.com/3.jpg", + "https://example.com/4.jpg", + "https://example.com/5.jpg", + "https://example.com/6.jpg" + ); + Map request = Map.of( + "category", PostCategory.TIP.name(), + "title", "์ œ๋ชฉ", + "content", "๋‚ด์šฉ", + "imageUrls", sixImages + ); + + mockMvc.perform(post("/community/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("POST_IMAGE_COUNT_EXCEEDED")); + } + + + //ํ•„์ˆ˜๊ฐ’ ๋ˆ„๋ฝ ์‹œ validation ์‹คํŒจ ํ…Œ์ŠคํŠธ + @Test + void createPost_missingFields_returnsBadRequest() throws Exception { + Map request = Map.of( + "category", PostCategory.FREE.name(), + "title", "", + "content", "" + ); + + mockMvc.perform(post("/community/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("VALIDATION_ERROR")); + } + + + // ์ •์ƒ ์š”์ฒญ ์‹œ 201 Created ๋ฐ˜ํ™˜ ํ…Œ์ŠคํŠธ + @Test + void createPost_success_returnsCreated() throws Exception { + Map request = Map.of( + "category", PostCategory.QUESTION.name(), + "title", "์ฒซ ๊ธ€", + "content", "๋‚ด์šฉ", + "imageUrls", List.of("https://example.com/ok.jpg") + ); + + mockMvc.perform(post("/community/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.title").value("์ฒซ ๊ธ€")) + .andExpect(jsonPath("$.authorId").value(authorId)); + + assertThat(userRepository.findById(authorId)).isPresent(); + } +} diff --git a/backend/src/test/java/com/mysite/knitly/domain/community/post/service/PostServiceTest.java b/backend/src/test/java/com/mysite/knitly/domain/community/post/service/PostServiceTest.java new file mode 100644 index 0000000..a397dec --- /dev/null +++ b/backend/src/test/java/com/mysite/knitly/domain/community/post/service/PostServiceTest.java @@ -0,0 +1,150 @@ +package com.mysite.knitly.domain.community.post.service; + +import com.mysite.knitly.domain.community.post.dto.*; +import com.mysite.knitly.domain.community.post.entity.PostCategory; +import com.mysite.knitly.domain.community.post.repository.PostRepository; +import com.mysite.knitly.domain.user.repository.UserRepository; +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.domain.user.entity.Provider; +import com.mysite.knitly.global.exception.ErrorCode; +import com.mysite.knitly.global.exception.ServiceException; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class PostServiceTest { + + @Autowired + private PostService postService; + + @Autowired + private UserRepository userRepository; // ์œ ์ง€ + + @Autowired + private PostRepository postRepository; + + private User author; + private User other; + + @BeforeEach + void setUp() { + // author + author = User.builder() + .socialId("social-author") + .email("author@test.com") + .name("Author") + .provider(Provider.GOOGLE) + .build(); + author = userRepository.save(author); + + // other user + other = User.builder() + .socialId("social-other") + .email("other@test.com") + .name("Other") + .provider(Provider.KAKAO) + .build(); + other = userRepository.save(other); + } + + @Test + void create_success() { + PostCreateRequest req = new PostCreateRequest( + PostCategory.FREE, "์ฒซ ๊ธ€", "๋‚ด์šฉ", + List.of("https://example.com/a.jpg") + ); + + PostResponse res = postService.create(req, author); + + assertThat(res.id()).isNotNull(); + assertThat(res.title()).isEqualTo("์ฒซ ๊ธ€"); + assertThat(res.authorId()).isEqualTo(author.getUserId()); + assertThat(res.mine()).isTrue(); + assertThat(res.imageUrls()).hasSize(1).containsExactly("https://example.com/a.jpg"); + } + + @Test + void create_invalid_image_extension_throws() { + PostCreateRequest req = new PostCreateRequest( + PostCategory.FREE, "์ด๋ฏธ์ง€ ํ™•์žฅ์ž ์‹คํŒจ", "๋‚ด์šฉ", + List.of("http://x/evil.gif") + ); + + assertThatThrownBy(() -> postService.create(req, author)) + .isInstanceOf(ServiceException.class) + .hasMessageContaining(ErrorCode.POST_IMAGE_EXTENSION_INVALID.getMessage()); + } + + @Test + void getPost_not_found_throws() { + assertThatThrownBy(() -> postService.getPost(99999L, author)) + .isInstanceOf(ServiceException.class) + .hasMessageContaining(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + void update_forbidden_when_not_author() { + // given + PostCreateRequest req = new PostCreateRequest( + PostCategory.TIP, "์›๋ณธ", "๋‚ด์šฉ", + List.of("https://example.com/tip.jpg") + ); + PostResponse created = postService.create(req, author); + + PostUpdateRequest update = new PostUpdateRequest( + PostCategory.TIP, "์ˆ˜์ •์ œ๋ชฉ", "์ˆ˜์ •๋‚ด์šฉ", + List.of("https://example.com/new.jpg") + ); + // when + then + assertThatThrownBy(() -> postService.update(created.id(), update, other)) + .isInstanceOf(ServiceException.class) + .hasMessageContaining(ErrorCode.POST_UPDATE_FORBIDDEN.getMessage()); + } + + @Test + void delete_forbidden_when_not_author() { + // given + PostCreateRequest req = new PostCreateRequest( + PostCategory.QUESTION, "์งˆ๋ฌธ", "์งˆ๋ฌธ๋‚ด์šฉ", + List.of("https://example.com/q.jpg") + ); + + PostResponse created = postService.create(req, author); + + // when + then + assertThatThrownBy(() -> postService.delete(created.id(), other)) + .isInstanceOf(ServiceException.class) + .hasMessageContaining(ErrorCode.POST_DELETE_FORBIDDEN.getMessage()); + } + + @Test + void list_paging_and_filter() { + // given: FREE 2๊ฑด, TIP 1๊ฑด + for (int i = 0; i < 2; i++) { + postService.create(new PostCreateRequest( + PostCategory.FREE, "free-" + i, "c", + List.of("https://example.com/i.jpg") + ), author); + } + postService.create(new PostCreateRequest( + PostCategory.TIP, "tip", "c", + List.of("https://example.com/i.jpg") + ), author); + + // when + var page0 = postService.getPostList(PostCategory.FREE, null, 0, 10); + var all = postService.getPostList(null, null, 0, 10); + // then + assertThat(page0.getTotalElements()).isEqualTo(2); + assertThat(all.getTotalElements()).isEqualTo(3); + + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/mysite/knitly/domain/design/service/DesignServiceTest.java b/backend/src/test/java/com/mysite/knitly/domain/design/service/DesignServiceTest.java new file mode 100644 index 0000000..ada73dd --- /dev/null +++ b/backend/src/test/java/com/mysite/knitly/domain/design/service/DesignServiceTest.java @@ -0,0 +1,339 @@ +package com.mysite.knitly.domain.design.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mysite.knitly.domain.design.dto.DesignListResponse; +import com.mysite.knitly.domain.design.dto.DesignRequest; +import com.mysite.knitly.domain.design.dto.DesignResponse; +import com.mysite.knitly.domain.design.dto.DesignUploadRequest; +import com.mysite.knitly.domain.design.entity.Design; +import com.mysite.knitly.domain.design.entity.DesignState; +import com.mysite.knitly.domain.design.repository.DesignRepository; +import com.mysite.knitly.domain.design.util.FileValidator; +import com.mysite.knitly.domain.design.util.LocalFileStorage; +import com.mysite.knitly.domain.design.util.PdfGenerator; +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.domain.user.repository.UserRepository; +import com.mysite.knitly.global.exception.ErrorCode; +import com.mysite.knitly.global.exception.ServiceException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class DesignServiceTest { + + @Mock + DesignRepository designRepository; + @Mock + PdfGenerator pdfGenerator; + @Mock + LocalFileStorage localFileStorage; + @Mock + FileValidator fileValidator; + @Spy + ObjectMapper objectMapper = new ObjectMapper(); + @InjectMocks + DesignService designService; + + + @Test + @DisplayName("๋„์•ˆ ์ƒ์„ฑ - ์ •์ƒ") + void createDesign_ok() { + User user = User.builder().userId(1L).build(); + + DesignRequest req = new DesignRequest( + "ํ•˜ํŠธ ํŒจํ„ด", + fake10x10(), + "ํ•˜ํŠธํŒจํ„ด_์ƒ˜ํ”Œ" + ); + + byte[] pdf = new byte[]{1,2,3}; + when(pdfGenerator.generate(eq("ํ•˜ํŠธ ํŒจํ„ด"), any())).thenReturn(pdf); + when(localFileStorage.savePdfFile(eq(pdf), anyString())) + .thenReturn("/files/2025/10/17/uuid_ํ•˜ํŠธํŒจํ„ด.pdf"); + + Design saved = Design.builder() + .designId(10L) + .user(user) + .designName("ํ•˜ํŠธ ํŒจํ„ด") + .pdfUrl("/files/2025/10/17/uuid_ํ•˜ํŠธํŒจํ„ด.pdf") + .gridData("[]") + .designState(DesignState.BEFORE_SALE) + .build(); + + when(designRepository.save(any(Design.class))).thenReturn(saved); + + DesignResponse res = designService.createDesign(user, req); + + assertThat(res).isNotNull(); + assertThat(res.designId()).isEqualTo(10L); + verify(pdfGenerator).generate(eq("ํ•˜ํŠธ ํŒจํ„ด"), any()); + verify(localFileStorage).savePdfFile(eq(pdf), anyString()); + verify(designRepository).save(any(Design.class)); + } + + @Test + @DisplayName("๋„์•ˆ ์ƒ์„ฑ - ๊ทธ๋ฆฌ๋“œ ํฌ๊ธฐ ๋ถˆ์ผ์น˜ ์‹œ ์‹คํŒจ") + void createDesign_invalidGrid() { + User user = User.builder().userId(1L).build(); + + DesignRequest req = new DesignRequest( + "x", + List.of(List.of("A")), + null + ); + + assertThatThrownBy(() -> designService.createDesign(user, req)) + .isInstanceOf(ServiceException.class) + .extracting("errorCode").isEqualTo(ErrorCode.DESIGN_INVALID_GRID_SIZE); + verifyNoInteractions(pdfGenerator, localFileStorage, designRepository); + } + + private List> fake10x10() { + return java.util.stream.IntStream.range(0,10) + .mapToObj(r -> java.util.Collections.nCopies(10, "โ—ฏ")) + .toList(); + } + + + @Test + @DisplayName("๋ณธ์ธ ๋„์•ˆ ์กฐํšŒ - ์ •์ƒ") + void getMyDesigns_ok() { + User user = User.builder().userId(1L).build(); + + Design design1 = Design.builder() + .designId(1L) + .user(user) + .designName("๋„์•ˆ1") + .pdfUrl("/files/1.pdf") + .designState(DesignState.BEFORE_SALE) + .build(); + + Design design2 = Design.builder() + .designId(2L) + .user(user) + .designName("๋„์•ˆ2") + .pdfUrl("/files/2.pdf") + .designState(DesignState.ON_SALE) + .build(); + + when(designRepository.findByUser(user)).thenReturn(List.of(design1, design2)); + + List list = designService.getMyDesigns(user); + + assertThat(list).hasSize(2); + assertThat(list.get(0).designId()).isEqualTo(1L); + assertThat(list.get(0).designName()).isEqualTo("๋„์•ˆ1"); + assertThat(list.get(1).designId()).isEqualTo(2L); + + } + + @Test + @DisplayName("๋„์•ˆ ์‚ญ์ œ - ๋ณธ์ธ ์†Œ์œ  + BEFORE_SALE โ†’ ํŒŒ์ผ ์‚ญ์ œ ์‹œ๋„ ํ›„ DB ํ•˜๋“œ ์‚ญ์ œ") + void deleteDesign_ok_beforeSale() throws Exception { + User user = User.builder().userId(1L).name("์œ ์ €1").build(); + + Design design = Design.builder() + .designId(1L) + .user(user) + .designName("๋„์•ˆ1") + .pdfUrl("/files/1.pdf") + .designState(DesignState.BEFORE_SALE) + .build(); + + when(designRepository.findById(1L)).thenReturn(Optional.of(design)); + designService.deleteDesign(user, 1L); + + verify(localFileStorage, times(1)).deleteFile("/files/1.pdf"); + verify(designRepository, times(1)).delete(design); + } + + @Test + @DisplayName("๋„์•ˆ ์‚ญ์ œ - ํŒŒ์ผ ์‚ญ์ œ ์‹คํŒจํ•ด๋„ DB ์‚ญ์ œ๋Š” ์ง„ํ–‰") + void deleteDesign_fileDeleteFails_butStillDeletesDb() throws Exception { + User user = User.builder().userId(1L).build(); + + Design design = Design.builder() + .designId(1L) + .user(user) + .designName("๋„์•ˆ1") + .pdfUrl("/files/1.pdf") + .designState(DesignState.BEFORE_SALE) + .build(); + + when(designRepository.findById(1L)).thenReturn(Optional.of(design)); + doThrow(new IOException("io-์‹คํŒจ")).when(localFileStorage).deleteFile("/files/1.pdf"); + + designService.deleteDesign(user, 1L); + + verify(localFileStorage, times(1)).deleteFile("/files/1.pdf"); + verify(designRepository, times(1)).delete(design); + } + + @Test + @DisplayName("๋„์•ˆ ์‚ญ์ œ - ๋ณธ์ธ ์•„๋‹˜ โ†’ DESIGN_UNAUTHORIZED_DELETE") + void deleteDesign_notOwner() { + User owner = User.builder().userId(1L).name("์œ ์ €1").build(); + User other = User.builder().userId(2L).name("์œ ์ €2").build(); + + Design design = Design.builder() + .designId(1L) + .user(owner) + .designName("๋„์•ˆ1") + .pdfUrl("/files/1.pdf") + .designState(DesignState.BEFORE_SALE) + .build(); + when(designRepository.findById(1L)).thenReturn(Optional.of(design)); + + assertThatThrownBy(() -> designService.deleteDesign(other, 1L)) + .isInstanceOf(ServiceException.class) + .extracting("errorCode").isEqualTo(ErrorCode.DESIGN_UNAUTHORIZED_DELETE); + + verify(designRepository, never()).delete(any()); + verifyNoInteractions(localFileStorage); + } + + @Test + @DisplayName("๋„์•ˆ ์‚ญ์ œ - ์ƒํƒœ๊ฐ€ ON_SALE/STOPPED โ†’ DESIGN_NOT_DELETABLE") + void deleteDesign_notDeletable_whenOnSaleOrStopped() { + User user = User.builder().userId(1L).build(); + + Design design = Design.builder() + .designId(2L) + .user(user) + .designName("๋„์•ˆ1") + .pdfUrl("/files/1.pdf") + .designState(DesignState.ON_SALE) + .build(); + when(designRepository.findById(2L)).thenReturn(Optional.of(design)); + + assertThatThrownBy(() -> designService.deleteDesign(user, 2L)) + .isInstanceOf(ServiceException.class) + .extracting("errorCode").isEqualTo(ErrorCode.DESIGN_NOT_DELETABLE); + + verify(designRepository, never()).delete(any()); + verifyNoInteractions(localFileStorage); + } + + @Test + @DisplayName("๊ธฐ์กด PDF ์—…๋กœ๋“œ - ์ •์ƒ") + void uploadPdfDesign_ok() { + User user = User.builder().userId(1L).build(); + + // validator ํ†ต๊ณผ + doNothing().when(fileValidator).validatePdfFile(any()); + + byte[] bytes = "PDF BYTES".getBytes(); + MultipartFile file = new MockMultipartFile("file", "sample.pdf", "application/pdf", bytes); + + when(localFileStorage.savePdfFile(any(byte[].class), eq("์ƒ˜ํ”Œ๋„์•ˆ.pdf"))) + .thenReturn("/files/2025/10/21/abcd1234_sample.pdf"); + + Design saved = Design.builder() + .designId(100L) + .user(user) + .designName("์ƒ˜ํ”Œ๋„์•ˆ") + .pdfUrl("/files/2025/10/21/abcd1234_sample.pdf") + .designState(DesignState.BEFORE_SALE) + .build(); + when(designRepository.save(any(Design.class))).thenReturn(saved); + + DesignUploadRequest req = new DesignUploadRequest("์ƒ˜ํ”Œ๋„์•ˆ", file); + DesignResponse res = designService.uploadPdfDesign(user, req); + + assertThat(res).isNotNull(); + assertThat(res.designId()).isEqualTo(100L); + assertThat(res.pdfUrl()).isEqualTo("/files/2025/10/21/abcd1234_sample.pdf"); + + verify(fileValidator).validatePdfFile(file); + verify(localFileStorage).savePdfFile(any(byte[].class), eq("์ƒ˜ํ”Œ๋„์•ˆ.pdf")); + verify(designRepository).save(any(Design.class)); + } + + @Test + @DisplayName("๊ธฐ์กด PDF ์—…๋กœ๋“œ - ํŒŒ์ผ ์œ ํšจ์„ฑ ์‹คํŒจ โ†’ DESIGN_FILE_INVALID_TYPE") + void uploadPdfDesign_invalidFile() { + User user = User.builder().userId(1L).build(); + + MultipartFile file = new MockMultipartFile("file", "origin-name.pdf", "application/pdf", new byte[]{1, 2, 3}); + doThrow(new ServiceException(ErrorCode.DESIGN_FILE_INVALID_TYPE)) + .when(fileValidator).validatePdfFile(any()); + + DesignUploadRequest req = new DesignUploadRequest("๋„์•ˆ", file); + + assertThatThrownBy(() -> designService.uploadPdfDesign(user, req)) + .isInstanceOf(ServiceException.class) + .extracting("errorCode").isEqualTo(ErrorCode.DESIGN_FILE_INVALID_TYPE); + + verifyNoInteractions(localFileStorage, designRepository); + } + + @Test + @DisplayName("๊ธฐ์กด PDF ์—…๋กœ๋“œ - ํŒŒ์ผ ์ฝ๊ธฐ ์‹คํŒจ(getBytes) โ†’ DESIGN_FILE_SAVE_FAILED") + void uploadPdfDesign_ioOnGetBytes() throws Exception { + User user = User.builder().userId(1L).build(); + + doNothing().when(fileValidator).validatePdfFile(any()); + + MultipartFile file = mock(MultipartFile.class); + when(file.getOriginalFilename()).thenReturn("sample.pdf"); + when(file.getBytes()).thenThrow(new IOException("boom")); + + DesignUploadRequest req = new DesignUploadRequest("๋„์•ˆ", file); + + assertThatThrownBy(() -> designService.uploadPdfDesign(user, req)) + .isInstanceOf(ServiceException.class) + .extracting("errorCode").isEqualTo(ErrorCode.DESIGN_FILE_SAVE_FAILED); + + verifyNoInteractions(localFileStorage, designRepository); + } + + @Test + @DisplayName("๊ธฐ์กด PDF ์—…๋กœ๋“œ - ๋„์•ˆ๋ช… ๊ณต๋ฐฑ โ†’ ์›๋ณธ ํŒŒ์ผ๋ช… ๊ธฐ๋ฐ˜ ์ €์žฅ") + void uploadPdfDesign_blankName() { + User user = User.builder().userId(1L).build(); + + doNothing().when(fileValidator).validatePdfFile(any()); + + MultipartFile file = new MockMultipartFile("file", "origin-name.pdf", "application/pdf", new byte[]{1, 2, 3}); + when(localFileStorage.savePdfFile(any(byte[].class), eq("origin-name.pdf"))) + .thenReturn("/files/2025/10/21/zzzz_origin-name.pdf"); + + when(designRepository.save(any(Design.class))).thenAnswer(inv -> { + Design d = inv.getArgument(0); + return Design.builder() + .designId(11L) + .user(d.getUser()) + .designName(d.getDesignName()) + .pdfUrl(d.getPdfUrl()) + .designState(d.getDesignState()) + .build(); + }); + + DesignUploadRequest req = new DesignUploadRequest(" ", file); + + DesignResponse res = designService.uploadPdfDesign(user, req); + + assertThat(res).isNotNull(); + assertThat(res.designId()).isEqualTo(11L); + verify(localFileStorage).savePdfFile(any(byte[].class), eq("origin-name.pdf")); + } + +} + diff --git a/backend/src/test/java/com/mysite/knitly/domain/design/util/LocalFileStorageTest.java b/backend/src/test/java/com/mysite/knitly/domain/design/util/LocalFileStorageTest.java new file mode 100644 index 0000000..2937df6 --- /dev/null +++ b/backend/src/test/java/com/mysite/knitly/domain/design/util/LocalFileStorageTest.java @@ -0,0 +1,41 @@ +package com.mysite.knitly.domain.design.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +public class LocalFileStorageTest { + @TempDir + Path tempDir; + + @Test + @DisplayName("๋กœ์ปฌ ์ €์žฅ - URL ๋ฐ˜ํ™˜ ๋ฐ URL โ†’ ์‹ค๊ฒฝ๋กœ ๋ณต์› ์„ฑ๊ณต") + void savePdfFile_and_resolve_ok() throws Exception { + LocalFileStorage storage = new LocalFileStorage(); + setField(storage, "uploadDir", tempDir.toString()); + setField(storage, "publicPrefix", "/files"); + + byte[] pdfBytes = new byte[]{1,2,3}; + + String url = storage.savePdfFile(pdfBytes, "testFile"); + + assertThat(url).startsWith("/files/"); + + // URL -> ์‹ค์ œ ๊ฒฝ๋กœ ๋ณต์› + Path abs = storage.toAbsolutePathFromUrl(url); + assertThat(Files.exists(abs)).isTrue(); + assertThat(Files.size(abs)).isEqualTo(3); + } + + private static void setField(Object target, String name, Object value) throws Exception { + Field f = target.getClass().getDeclaredField(name); + f.setAccessible(true); + f.set(target, value); + } +} diff --git a/backend/src/test/java/com/mysite/knitly/domain/design/util/PdfGeneratorTest.java b/backend/src/test/java/com/mysite/knitly/domain/design/util/PdfGeneratorTest.java new file mode 100644 index 0000000..ce47017 --- /dev/null +++ b/backend/src/test/java/com/mysite/knitly/domain/design/util/PdfGeneratorTest.java @@ -0,0 +1,42 @@ +package com.mysite.knitly.domain.design.util; + +import com.mysite.knitly.global.exception.ErrorCode; +import com.mysite.knitly.global.exception.ServiceException; +import org.junit.jupiter.api.DisplayName; +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; + +public class PdfGeneratorTest { + private final PdfGenerator generator = new PdfGenerator(); + + @Test + @DisplayName("PDF ์ƒ์„ฑ - ์ •์ƒ") + void generate_ok_returnsPdfBytes() { + byte[] pdf = generator.generate("ํ…Œ์ŠคํŠธ ๋„์•ˆ", fake10x10()); + + // PDF ์‹œ๊ทธ๋‹ˆ์ฒ˜ %PDF ํ™•์ธ + assertThat(pdf).isNotEmpty(); + assertThat(new String(pdf, 0, 4)).isEqualTo("%PDF"); + // ์‚ฌ์ด์ฆˆ ๋Œ€๋žต ์ฒดํฌ + assertThat(pdf.length).isGreaterThan(100); + } + + @Test + @DisplayName("PDF ์ƒ์„ฑ ์‹คํŒจ - 10x10 ๊ฒฉ์ž ํ˜•์‹ ์ง€ํ‚ค์ง€ ์•Š์€ ๊ฒฝ์šฐ") + void generate_invalidGrid_throwsServiceException() { + assertThatThrownBy(() -> generator.generate("๊นจ์ง„ ๋„์•ˆ", List.of(List.of("A")))) + .isInstanceOf(ServiceException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.DESIGN_PDF_GENERATION_FAILED); + } + + private static List> fake10x10() { + return java.util.stream.IntStream.range(0, 10) + .mapToObj(r -> java.util.Collections.nCopies(10, "โ—ฏ")) + .toList(); + } +} diff --git a/backend/src/test/java/com/mysite/knitly/domain/home/service/HomeSectionServiceTest.java b/backend/src/test/java/com/mysite/knitly/domain/home/service/HomeSectionServiceTest.java new file mode 100644 index 0000000..36e408f --- /dev/null +++ b/backend/src/test/java/com/mysite/knitly/domain/home/service/HomeSectionServiceTest.java @@ -0,0 +1,184 @@ +package com.mysite.knitly.domain.home.service; + +import com.mysite.knitly.domain.home.dto.HomeSummaryResponse; +import com.mysite.knitly.domain.home.dto.LatestPostItem; +import com.mysite.knitly.domain.home.dto.LatestReviewItem; +import com.mysite.knitly.domain.home.repository.HomeQueryRepository; +import com.mysite.knitly.domain.product.product.dto.ProductListResponse; +import com.mysite.knitly.domain.product.product.entity.Product; +import com.mysite.knitly.domain.product.product.entity.ProductCategory; +import com.mysite.knitly.domain.product.product.repository.ProductRepository; +import com.mysite.knitly.domain.product.product.service.RedisProductService; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.*; + +import java.time.LocalDateTime; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class HomeSectionServiceTest { + + @Mock + private RedisProductService redisProductService; + @InjectMocks + private HomeSectionService homeSectionService; + @Mock + private ProductRepository productRepository; + @Mock + private HomeQueryRepository homeQueryRepository; + + private Product product1; // id=1 + private Product product2; // id=2 + private Product product3; // id=3 + + @BeforeEach + void setUp() { + LocalDateTime now = LocalDateTime.now(); + + product1 = Product.builder() + .productId(1L) + .title("์ƒ์˜ ํŒจํ„ด 1") + .productCategory(ProductCategory.TOP) + .price(10000.0) + .purchaseCount(100) + .likeCount(50) + .isDeleted(false) + .createdAt(now.minusDays(1)) + .build(); + + product2 = Product.builder() + .productId(2L) + .title("๋ฌด๋ฃŒ ํŒจํ„ด") + .productCategory(ProductCategory.BOTTOM) + .price(0.0) + .purchaseCount(200) + .likeCount(80) + .isDeleted(false) + .createdAt(now.minusDays(2)) + .build(); + + product3 = Product.builder() + .productId(3L) + .title("ํ•œ์ •ํŒ๋งค ํŒจํ„ด") + .productCategory(ProductCategory.OUTER) + .price(15000.0) + .stockQuantity(10) + .purchaseCount(150) + .likeCount(60) + .isDeleted(false) + .createdAt(now.minusDays(3)) + .build(); + } + + @Test + @DisplayName("์ธ๊ธฐ Top5 ์กฐํšŒ - Redis ๋ฐ์ดํ„ฐ ์žˆ์Œ") + void getTop5Products_WithRedis() { + List top5Ids = Arrays.asList(2L, 3L, 1L); + given(redisProductService.getTopNPopularProducts(5)).willReturn(top5Ids); + given(productRepository.findByProductIdInAndIsDeletedFalse(top5Ids)) + .willReturn(Arrays.asList(product2, product3, product1)); + + List result = homeSectionService.getPopularTop5(); + + assertThat(result).hasSize(3); + assertThat(result).extracting(ProductListResponse::productId) + .containsExactly(2L, 3L, 1L); // edis ZSET ์ˆœ์„œ ๋ณด์กด ๊ฒ€์ฆ + verify(redisProductService).getTopNPopularProducts(5); + verify(productRepository).findByProductIdInAndIsDeletedFalse(top5Ids); + } + + @Test + @DisplayName("์ธ๊ธฐ Top5 ์กฐํšŒ - Redis ๋ฐ์ดํ„ฐ ์—†์Œ (DB ์กฐํšŒ)") + void getTop5Products_WithoutRedis() { + given(redisProductService.getTopNPopularProducts(5)).willReturn(List.of()); + Page top5Page = new PageImpl<>(Arrays.asList(product2, product3, product1)); + given(productRepository.findByIsDeletedFalse(any(Pageable.class))).willReturn(top5Page); + + List result = homeSectionService.getPopularTop5(); + + assertThat(result).hasSize(3); + assertThat(result).extracting(ProductListResponse::productId) + .containsExactly(2L, 3L, 1L); + verify(productRepository).findByIsDeletedFalse(PageRequest.of(0, 5, Sort.by("purchaseCount").descending())); + } + + @Test + @DisplayName("์ตœ์‹  ๋ฆฌ๋ทฐ 3๊ฐœ ์กฐํšŒ - Repository ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜") + void getLatestReviews_ReturnsTop3() { + var r1 = new LatestReviewItem(101L, 10L, "๋‹ˆํŠธ ์Šค์›จํ„ฐ", null, 5, "์•„์ฃผ ์ข‹์•„์š”", LocalDate.now()); + var r2 = new LatestReviewItem(102L, 11L, "์šธ ๋จธํ”Œ๋Ÿฌ", null, 4, "๋”ฐ๋œปํ•ฉ๋‹ˆ๋‹ค", LocalDate.now().minusDays(1)); + var r3 = new LatestReviewItem(103L, 12L, "๊ฐ€๋””๊ฑด", null, 5, "๋ถ€๋“œ๋Ÿฌ์›Œ์š”", LocalDate.now().minusDays(2)); + + given(homeQueryRepository.findLatestReviews(3)).willReturn(List.of(r1, r2, r3)); + + var result = homeSectionService.getLatestReviews(3); + + assertThat(result).hasSize(3); + assertThat(result.get(0).reviewId()).isEqualTo(101L); + assertThat(result.get(1).productTitle()).isEqualTo("์šธ ๋จธํ”Œ๋Ÿฌ"); + verify(homeQueryRepository).findLatestReviews(3); + } + @Test + @DisplayName("์ตœ์‹  ์ปค๋ฎค๋‹ˆํ‹ฐ ๊ธ€ 3๊ฐœ ์กฐํšŒ - Repository ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜") + void getLatestPosts_ReturnsTop3() { + var p1 = new LatestPostItem(201L, "์ฒซ ๊ธ€", "FREE", null, LocalDateTime.now()); + var p2 = new LatestPostItem(202L, "๋‘˜์งธ ๊ธ€", "QUESTION", null, LocalDateTime.now().minusHours(1)); + var p3 = new LatestPostItem(203L, "์…‹์งธ ๊ธ€", "TIP", null, LocalDateTime.now().minusDays(1)); + + given(homeQueryRepository.findLatestPosts(3)).willReturn(List.of(p1, p2, p3)); + + var result = homeSectionService.getLatestPosts(3); + + assertThat(result).hasSize(3); + assertThat(result.get(0).postId()).isEqualTo(201L); + assertThat(result.get(2).category()).isEqualTo("TIP"); + verify(homeQueryRepository).findLatestPosts(3); + } + @Test + @DisplayName("ํ™ˆ ์š”์•ฝ ์กฐํšŒ - ์ธ๊ธฐ Top5 + ์ตœ์‹  ๋ฆฌ๋ทฐ 3 + ์ตœ์‹  ๊ธ€ 3์„ ๋ชจ์•„์„œ ๋ฐ˜ํ™˜") + void getHomeSummary_AggregatesAllSections() { + // popular + List ids = Arrays.asList(2L, 3L, 1L); + given(redisProductService.getTopNPopularProducts(5)).willReturn(ids); + given(productRepository.findByProductIdInAndIsDeletedFalse(ids)) + .willReturn(Arrays.asList(product2, product3, product1)); + + // latest reviews + var r1 = new LatestReviewItem(101L, 10L, "๋‹ˆํŠธ ์Šค์›จํ„ฐ", null, 5, "๊ตฟ", LocalDate.now()); + var r2 = new LatestReviewItem(102L, 11L, "์šธ ๋จธํ”Œ๋Ÿฌ", null, 4, "๋”ฐ๋œป", LocalDate.now()); + var r3 = new LatestReviewItem(103L, 12L, "๊ฐ€๋””๊ฑด", null, 5, "๋ถ€๋“œ๋Ÿฌ์›€", LocalDate.now()); + given(homeQueryRepository.findLatestReviews(3)).willReturn(List.of(r1, r2, r3)); + + // latest posts + var p1 = new LatestPostItem(201L, "์ฒซ ๊ธ€", "FREE", null, LocalDateTime.now()); + var p2 = new LatestPostItem(202L, "๋‘˜์งธ ๊ธ€", "QUESTION", null, LocalDateTime.now()); + var p3 = new LatestPostItem(203L, "์…‹์งธ ๊ธ€", "TIP", null, LocalDateTime.now()); + given(homeQueryRepository.findLatestPosts(3)).willReturn(List.of(p1, p2, p3)); + + HomeSummaryResponse response = homeSectionService.getHomeSummary(); + + assertThat(response.popularProducts()).hasSize(3); + assertThat(response.latestReviews()).hasSize(3); + assertThat(response.latestPosts()).hasSize(3); + assertThat(response.popularProducts()).extracting(ProductListResponse::productId) + .containsExactly(2L, 3L, 1L); + + assertThat(response.latestReviews().get(0).rating()).isEqualTo(5); + assertThat(response.latestPosts().get(1).category()).isEqualTo("QUESTION"); + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/mysite/knitly/domain/mypage/Service/MyPageServiceTest.java b/backend/src/test/java/com/mysite/knitly/domain/mypage/Service/MyPageServiceTest.java new file mode 100644 index 0000000..e1e5ca3 --- /dev/null +++ b/backend/src/test/java/com/mysite/knitly/domain/mypage/Service/MyPageServiceTest.java @@ -0,0 +1,119 @@ +package com.mysite.knitly.domain.mypage.Service; + +import com.mysite.knitly.domain.mypage.dto.*; +import com.mysite.knitly.domain.mypage.repository.MyPageQueryRepository; +import com.mysite.knitly.domain.mypage.service.MyPageService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class MyPageServiceTest { + + @Mock + MyPageQueryRepository repo; + + @InjectMocks + MyPageService service; + + @Test + @DisplayName("์ฃผ๋ฌธ ์นด๋“œ ์กฐํšŒ - Repository ์œ„์ž„ ํ™•์ธ") + void getOrderCards() { + var card = OrderCardResponse.of(1L, LocalDateTime.now(), 10000.0); + var page = new PageImpl<>(List.of(card), PageRequest.of(0, 3), 1); + + given(repo.findOrderCards(eq(10L), any())).willReturn(page); + + var result = service.getOrderCards(10L, PageRequest.of(0, 3)); + + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).orderId()).isEqualTo(1L); + } + + @Test + @DisplayName("๋‚ด๊ฐ€ ์“ด ๊ธ€ ์กฐํšŒ - ๊ฒ€์ƒ‰์–ด ํฌํ•จ") + void getMyPosts() { + var dto = new MyPostListItemResponse(100L, "์ œ๋ชฉ", "์š”์•ฝ", "thumb.jpg", + LocalDateTime.of(2025,1,1,10,0)); + var page = new PageImpl<>(List.of(dto), PageRequest.of(0, 10), 1); + + given(repo.findMyPosts(eq(10L), eq("ํ‚ค์›Œ๋“œ"), any())).willReturn(page); + + var result = service.getMyPosts(10L, "ํ‚ค์›Œ๋“œ", PageRequest.of(0, 10)); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).id()).isEqualTo(100L); + assertThat(result.getContent().get(0).title()).isEqualTo("์ œ๋ชฉ"); + } + + @Test + @DisplayName("๋‚ด๊ฐ€ ์“ด ๋Œ“๊ธ€ ์กฐํšŒ - ๊ฒ€์ƒ‰์–ด ํฌํ•จ") + void getMyComments() { + var c = new MyCommentListItem(7L, 100L, LocalDate.of(2025,1,2), "๋ฏธ๋ฆฌ๋ณด๊ธฐ"); + var page = new PageImpl<>(List.of(c), PageRequest.of(0, 10), 1); + + given(repo.findMyComments(eq(10L), eq("๋‹จ์–ด"), any())).willReturn(page); + + var result = service.getMyComments(10L, "๋‹จ์–ด", PageRequest.of(0, 10)); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).commentId()).isEqualTo(7L); + assertThat(result.getContent().get(0).postId()).isEqualTo(100L); + } + + @Test + @DisplayName("๋‚ด๊ฐ€ ์ฐœํ•œ ์ƒํ’ˆ ์กฐํšŒ") + void getMyFavorites() { + var f = new FavoriteProductItem(9001L, "์ธ๊ธฐ ๋„์•ˆ", "t.jpg", 9900.0, 4.2, LocalDate.of(2025,1,3)); + var page = new PageImpl<>(List.of(f), PageRequest.of(0, 10), 1); + + given(repo.findMyFavoriteProducts(eq(10L), any())).willReturn(page); + + var result = service.getMyFavorites(10L, PageRequest.of(0, 10)); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).productId()).isEqualTo(9001L); + assertThat(result.getContent().get(0).averageRating()).isEqualTo(4.2); + } + + @Test + @DisplayName("๋‚ด๊ฐ€ ๋‚จ๊ธด ๋ฆฌ๋ทฐ ์กฐํšŒ") + void getMyReviews() { + var r = new ReviewListItem( + 301L, + 9001L, + "์ธ๊ธฐ ๋„์•ˆ", + "t.jpg", + 5, + "์ข‹์•„์š”", + List.of("r1.jpg"), + LocalDate.of(2025,1,4), + LocalDate.of(2025,1,2) + ); + var page = new PageImpl<>(List.of(r), PageRequest.of(0, 10), 1); + + given(repo.findMyReviews(eq(10L), any())).willReturn(page); + + var result = service.getMyReviews(10L, PageRequest.of(0, 10)); + + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent().get(0).rating()).isEqualTo(5); + assertThat(result.getContent().get(0).productId()).isEqualTo(9001L); + } +} diff --git a/backend/src/test/java/com/mysite/knitly/domain/mypage/controller/MyPageControllerTest.java b/backend/src/test/java/com/mysite/knitly/domain/mypage/controller/MyPageControllerTest.java new file mode 100644 index 0000000..b6acdac --- /dev/null +++ b/backend/src/test/java/com/mysite/knitly/domain/mypage/controller/MyPageControllerTest.java @@ -0,0 +1,180 @@ +package com.mysite.knitly.domain.mypage.controller; + +import com.mysite.knitly.domain.mypage.dto.*; +import com.mysite.knitly.domain.mypage.service.MyPageService; +import com.mysite.knitly.domain.payment.service.PaymentService; +import com.mysite.knitly.domain.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.hasSize; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class MyPageControllerTest { + + private MockMvc mvc; + private MyPageService service; + private User principal; + private PaymentService paymentService; + + @BeforeEach + void setUp() { + service = Mockito.mock(MyPageService.class); + MyPageController controller = new MyPageController(service, paymentService); + + HandlerMethodArgumentResolver forceUserResolver = new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(org.springframework.core.MethodParameter parameter) { + return parameter.hasParameterAnnotation(org.springframework.security.core.annotation.AuthenticationPrincipal.class) + && parameter.getParameterType().isAssignableFrom(User.class); + } + + @Override + public Object resolveArgument(org.springframework.core.MethodParameter parameter, + org.springframework.web.method.support.ModelAndViewContainer mavContainer, + org.springframework.web.context.request.NativeWebRequest webRequest, + org.springframework.web.bind.support.WebDataBinderFactory binderFactory) { + return principal; + } + }; + + mvc = MockMvcBuilders.standaloneSetup(controller) + .setCustomArgumentResolvers(forceUserResolver) + .build(); + + principal = Mockito.mock(User.class); + given(principal.getUserId()).willReturn(1L); + given(principal.getName()).willReturn("ํ™๊ธธ๋™"); + given(principal.getEmail()).willReturn("hong@example.com"); + } + + @Test + @DisplayName("GET /api/mypage/profile โ†’ ์ด๋ฆ„/์ด๋ฉ”์ผ ๋ฐ˜ํ™˜") + void profile() throws Exception { + mvc.perform(get("/mypage/profile")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("ํ™๊ธธ๋™")) + .andExpect(jsonPath("$.email").value("hong@example.com")); + } + + @Test + @DisplayName("GET /api/mypage/orders โ†’ ์ฃผ๋ฌธ ์นด๋“œ ํŽ˜์ด์ง€") + void orders() throws Exception { + OrderCardResponse card1 = OrderCardResponse.of(101L, LocalDateTime.of(2025, 1, 2, 10, 0), 30000.0); + card1.items().add(new OrderLine(11L, "๋„์•ˆ A", 1, 10000.0)); + card1.items().add(new OrderLine(12L, "๋„์•ˆ B", 2, 20000.0)); + + var page = new PageImpl<>(List.of(card1), PageRequest.of(0, 3), 1); + given(service.getOrderCards(eq(1L), Mockito.any())).willReturn(page); + + mvc.perform(get("/mypage/orders").param("page", "0").param("size", "3")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.size").value(3)) + .andExpect(jsonPath("$.totalElements").value(1)) + .andExpect(jsonPath("$.content", hasSize(1))) + .andExpect(jsonPath("$.content[0].orderId").value(101)) + .andExpect(jsonPath("$.content[0].items", hasSize(2))) + .andExpect(jsonPath("$.content[0].items[0].productTitle").value("๋„์•ˆ A")); + } + + @Test + @DisplayName("GET /api/mypage/posts?query=ํ‚ค์›Œ๋“œ โ†’ ๋‚ด๊ฐ€ ์“ด ๊ธ€ ํŽ˜์ด์ง€") + void myPosts() throws Exception { + var dto = new MyPostListItemResponse( + 501L, "์ œ๋ชฉ1", "์š”์•ฝ1", "thumb1.jpg", LocalDateTime.of(2025, 1, 3, 9, 0) + ); + var page = new PageImpl<>(List.of(dto), PageRequest.of(0, 10), 1); + given(service.getMyPosts(eq(1L), eq("ํ‚ค์›Œ๋“œ"), Mockito.any())).willReturn(page); + + mvc.perform(get("/mypage/posts") + .param("query", "ํ‚ค์›Œ๋“œ") + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].id").value(501)) + .andExpect(jsonPath("$.content[0].title").value("์ œ๋ชฉ1")) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.size").value(10)); + } + + @Test + @DisplayName("GET /api/mypage/comments?query=๋‹จ์–ด โ†’ ๋‚ด๊ฐ€ ์“ด ๋Œ“๊ธ€ ํŽ˜์ด์ง€") + void myComments() throws Exception { + var c1 = new MyCommentListItem(701L, 501L, LocalDate.of(2025, 1, 4), "๋‚ด์šฉ ๋ฏธ๋ฆฌ๋ณด๊ธฐ"); + var page = new PageImpl<>(List.of(c1), PageRequest.of(0, 10), 1); + given(service.getMyComments(eq(1L), eq("๋‹จ์–ด"), Mockito.any())).willReturn(page); + + mvc.perform(get("/mypage/comments") + .param("query", "๋‹จ์–ด") + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].commentId").value(701)) + .andExpect(jsonPath("$.content[0].postId").value(501)) + .andExpect(jsonPath("$.content[0].preview").value("๋‚ด์šฉ ๋ฏธ๋ฆฌ๋ณด๊ธฐ")); + } + + @Test + @DisplayName("GET /api/mypage/favorites โ†’ ๋‚ด๊ฐ€ ์ฐœํ•œ ์ƒํ’ˆ ํŽ˜์ด์ง€") + void myFavorites() throws Exception { + var f1 = new FavoriteProductItem(9001L, "์ธ๊ธฐ ๋„์•ˆ", "t.jpg", 9900.0, 4.5, LocalDate.of(2025, 1, 5)); + var page = new PageImpl<>(List.of(f1), PageRequest.of(0, 10), 1); + given(service.getMyFavorites(eq(1L), Mockito.any())).willReturn(page); + + mvc.perform(get("/mypage/favorites") + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].productId").value(9001)) + .andExpect(jsonPath("$.content[0].productTitle").value("์ธ๊ธฐ ๋„์•ˆ")) + .andExpect(jsonPath("$.content[0].averageRating").value(4.5)); + } + + @Test + @DisplayName("GET /api/mypage/reviews โ†’ ๋‚ด๊ฐ€ ๋‚จ๊ธด ๋ฆฌ๋ทฐ ํŽ˜์ด์ง€") + void myReviews() throws Exception { + var r1 = new ReviewListItem( + 301L, + 9001L, + "์ธ๊ธฐ ๋„์•ˆ", + "t.jpg", + 5, + "์•„์ฃผ ์ข‹์•„์š”", + List.of("r1.jpg", "r2.jpg"), + LocalDate.of(2025, 1, 6), + LocalDate.of(2025, 1, 2) + ); + var page = new PageImpl<>(List.of(r1), PageRequest.of(0, 10), 1); + given(service.getMyReviews(eq(1L), Mockito.any())).willReturn(page); + + mvc.perform(get("/mypage/reviews") + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].reviewId").value(301)) + .andExpect(jsonPath("$.content[0].rating").value(5)) + .andExpect(jsonPath("$.content[0].content").value("์•„์ฃผ ์ข‹์•„์š”")) + .andExpect(jsonPath("$.content[0].reviewImageUrls", hasSize(2))) + .andExpect(jsonPath("$.content[0].purchasedDate", contains(2025, 1, 2))); + } +} + diff --git a/backend/src/test/java/com/mysite/knitly/domain/order/service/OrderServiceTest.java b/backend/src/test/java/com/mysite/knitly/domain/order/service/OrderServiceTest.java new file mode 100644 index 0000000..7a77e27 --- /dev/null +++ b/backend/src/test/java/com/mysite/knitly/domain/order/service/OrderServiceTest.java @@ -0,0 +1,261 @@ +package com.mysite.knitly.domain.order.service; + +import com.mysite.knitly.domain.design.entity.Design; +import com.mysite.knitly.domain.design.entity.DesignState; +import com.mysite.knitly.domain.design.repository.DesignRepository; +import com.mysite.knitly.domain.order.dto.OrderCreateRequest; +import com.mysite.knitly.domain.order.dto.OrderCreateResponse; +import com.mysite.knitly.domain.order.repository.OrderRepository; +import com.mysite.knitly.domain.product.product.entity.Product; +import com.mysite.knitly.domain.product.product.entity.ProductCategory; +import com.mysite.knitly.domain.product.product.repository.ProductRepository; +import com.mysite.knitly.domain.user.entity.Provider; +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.domain.user.repository.UserRepository; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.List; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(properties = "spring.profiles.active=test") +class OrderServiceTest { + + private static final Logger log = LoggerFactory.getLogger(OrderServiceTest.class); + + @Autowired + private OrderService orderService; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private OrderRepository orderRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private DesignRepository designRepository; + + @Autowired + private OrderFacade orderFacade; + + private Product testProduct; + + private Product testProduct2; + private User testUser; + + @BeforeEach + void setUp() { + orderRepository.deleteAll(); + productRepository.deleteAll(); + designRepository.deleteAll(); + userRepository.deleteAll(); + + testUser = userRepository.save( + User.builder() + .email("test@knitly.com") + .name("concurrentUser") + .socialId("concurrentSocialId") + .provider(Provider.GOOGLE) + .build() + ); + + Design testDesign = designRepository.save( + Design.builder() + .user(testUser) + .designName("ํ•˜ํŠธ ํŒจํ„ด") + .pdfUrl("/files/2025/10/17/uuid_ํ•˜ํŠธํŒจํ„ด.pdf") + .gridData("[]") + .designState(DesignState.ON_SALE) + .build() + ); + + testProduct = productRepository.save( + Product.builder() + .title("ํ•œ์ •ํŒ ๋‹ˆํŠธ") + .design(testDesign) + .user(testUser) + .description("ํ•œ์ •ํŒ์œผ๋กœ ์ œ์ž‘๋œ ํŠน๋ณ„ํ•œ ๋‹ˆํŠธ์ž…๋‹ˆ๋‹ค.") + .sizeInfo("Free") + .productCategory(ProductCategory.TOP) + .price(10000.0) + .purchaseCount(0) + .likeCount(50) + .stockQuantity(10) + .isDeleted(false) + .build() + ); + + Design testDesign2 = designRepository.save( + Design.builder() + .user(testUser) + .designName("๋ณ„ ํŒจํ„ด") + .pdfUrl("/files/2025/10/17/uuid_๋ณ„ํŒจํ„ด.pdf") + .gridData("[]") + .designState(DesignState.ON_SALE) + .build() + ); + + testProduct2 = productRepository.save( + Product.builder() + .title("ํ•œ์ •ํŒ ์ฝ”์Šคํ„ฐ") + .design(testDesign2) + .user(testUser) + .description("ํ•œ์ •ํŒ์œผ๋กœ ์ œ์ž‘๋œ ํŠน๋ณ„ํ•œ ์ฝ”์Šคํ„ฐ์ž…๋‹ˆ๋‹ค.") + .sizeInfo("Free") + .productCategory(ProductCategory.ETC) + .price(5000.0) + .purchaseCount(0) + .likeCount(50) + .stockQuantity(3) + .isDeleted(false) + .build() + ); + } + + @Test + @DisplayName("๋™์‹œ์— 100๊ฐœ์˜ ์ฃผ๋ฌธ ์š”์ฒญ์ด ๋“ค์–ด์™€๋„ ์‹ค์ œ ์ฃผ๋ฌธ์€ 10๊ฐœ๋งŒ ์ƒ์„ฑ๋œ๋‹ค.") + void concurrent_order_creation_test() throws InterruptedException { + // given + int threadCount = 100; + ExecutorService executorService = Executors.newFixedThreadPool(32); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); // ์„ฑ๊ณต/์‹คํŒจ ์ถ”์ ์šฉ + AtomicInteger failCount = new AtomicInteger(0); // ์‹คํŒจ ํšŸ์ˆ˜๋„ ์ถ”์  + + // when + for (int i = 0; i < threadCount; i++) { + executorService.submit(() -> { + try { + OrderCreateRequest request = new OrderCreateRequest( + List.of(testProduct.getProductId()) + ); + // Facade๋ฅผ ํ†ตํ•ด ์ฃผ๋ฌธ ์ƒ์„ฑ ๋กœ์ง ํ˜ธ์ถœ + orderFacade.createOrderWithLock(testUser, request); + successCount.incrementAndGet(); + } catch (Exception e) { + // ๋ฝ ํš๋“ ์‹คํŒจ, ์žฌ๊ณ  ๋ถ€์กฑ ๋“ฑ์˜ ์˜ˆ์™ธ๋Š” ์˜๋„๋œ ์‹คํŒจ์ด๋ฏ€๋กœ ๋ฌด์‹œ + // ์‹คํŒจ์‹œ ์นด์šดํŠธ ์ฆ๊ฐ€ + failCount.incrementAndGet(); + // ์‹คํŒจ ๋กœ๊ทธ๋Š” DEBUG ๋ ˆ๋ฒจ๋กœ ๋‚จ๊ฒจ์„œ ํ‰์†Œ์—” ์•ˆ ๋ณด์ด๊ฒŒ ์ฒ˜๋ฆฌ + log.debug("Order failed as expected: {}", e.getMessage()); + } finally { + latch.countDown(); + } + }); + } + + latch.await(10, TimeUnit.SECONDS); //์‹œ๊ฐ„ 3์œผ๋กœ ํ•˜๋ฉด ๋ฌด์กฐ๊ฑด ์‹คํŒจํ•˜๊ณ , 10์œผ๋กœ ํ•˜๋ฉด ์„ฑ๊ณตํ•จ. 3~10 ์‚ฌ์ด์˜ ๊ฐ’ ๋„ฃ์–ด๋ณด๋ฉด์„œ ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅ + executorService.shutdown(); + + // then + Product updatedProduct = productRepository.findById(testProduct.getProductId()).orElseThrow(); + long dbOrderCount = orderRepository.count(); + + log.info(""" + + =========================================================== + [Test Summary] : {} + ----------------------------------------------------------- + - ์ด ์š”์ฒญ ์ˆ˜ : {} + - ์ดˆ๊ธฐ ์žฌ๊ณ  : {} + ----------------------------------------------------------- + - ์„ฑ๊ณต (Atomic): {} + - ์‹คํŒจ (Atomic): {} + - DB์— ์ƒ์„ฑ๋œ ์ฃผ๋ฌธ ์ˆ˜: {} + - ๋‚จ์€ ์ตœ์ข… ์žฌ๊ณ : {} + =========================================================== + """, + "100 requests for 10 stocks", + threadCount, + 10, + successCount.get(), + failCount.get(), + dbOrderCount, + updatedProduct.getStockQuantity() + ); + + // ๊ฒ€์ฆ: ์žฌ๊ณ ๋Š” 0, DB์— ์ €์žฅ๋œ ์ฃผ๋ฌธ์€ 10๊ฐœ, ์„ฑ๊ณต ์นด์šดํŠธ๋„ 10๊ฐœ + assertThat(updatedProduct.getStockQuantity()).isEqualTo(0); + assertThat(orderRepository.count()).isEqualTo(10); + assertThat(successCount.get()).isEqualTo(10); + } + + @Test + @DisplayName("๋™์‹œ์— 200๊ฐœ์˜ ์ฃผ๋ฌธ ์š”์ฒญ์ด ๋“ค์–ด์™€๋„ ์‹ค์ œ ์ฃผ๋ฌธ์€ 3๊ฐœ๋งŒ ์ƒ์„ฑ๋œ๋‹ค.") + void concurrent_order_creation_test2() throws InterruptedException { + // given + int threadCount = 200; + ExecutorService executorService = Executors.newFixedThreadPool(32); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); // ์„ฑ๊ณต/์‹คํŒจ ์ถ”์ ์šฉ + AtomicInteger failCount = new AtomicInteger(0); // ์‹คํŒจ ํšŸ์ˆ˜๋„ ์ถ”์  + + // when + for (int i = 0; i < threadCount; i++) { + executorService.submit(() -> { + try { + OrderCreateRequest request = new OrderCreateRequest( + List.of(testProduct2.getProductId()) + ); + // Facade๋ฅผ ํ†ตํ•ด ์ฃผ๋ฌธ ์ƒ์„ฑ ๋กœ์ง ํ˜ธ์ถœ + orderFacade.createOrderWithLock(testUser, request); + successCount.incrementAndGet(); + } catch (Exception e) { + // ๋ฝ ํš๋“ ์‹คํŒจ, ์žฌ๊ณ  ๋ถ€์กฑ ๋“ฑ์˜ ์˜ˆ์™ธ๋Š” ์˜๋„๋œ ์‹คํŒจ์ด๋ฏ€๋กœ ๋ฌด์‹œ + // ์‹คํŒจ์‹œ ์นด์šดํŠธ ์ฆ๊ฐ€ + failCount.incrementAndGet(); + log.debug("Order failed as expected: {}", e.getMessage()); + } finally { + latch.countDown(); + } + }); + } + + latch.await(10, TimeUnit.SECONDS); + executorService.shutdown(); + + // then + Product updatedProduct = productRepository.findById(testProduct2.getProductId()).orElseThrow(); + long dbOrderCount = orderRepository.count(); + + log.info(""" + + =========================================================== + [Test Summary] : {} + ----------------------------------------------------------- + - ์ด ์š”์ฒญ ์ˆ˜ : {} + - ์ดˆ๊ธฐ ์žฌ๊ณ  : {} + ----------------------------------------------------------- + - ์„ฑ๊ณต (Atomic): {} + - ์‹คํŒจ (Atomic): {} + - DB์— ์ƒ์„ฑ๋œ ์ฃผ๋ฌธ ์ˆ˜: {} + - ๋‚จ์€ ์ตœ์ข… ์žฌ๊ณ : {} + =========================================================== + """, + "200 requests for 3 stocks", + threadCount, + 3, + successCount.get(), + failCount.get(), + dbOrderCount, + updatedProduct.getStockQuantity() + ); + + // ๊ฒ€์ฆ: ์žฌ๊ณ ๋Š” 0, DB์— ์ €์žฅ๋œ ์ฃผ๋ฌธ์€ 3๊ฐœ, ์„ฑ๊ณต ์นด์šดํŠธ๋„ 3๊ฐœ + assertThat(updatedProduct.getStockQuantity()).isEqualTo(0); + assertThat(orderRepository.count()).isEqualTo(3); + assertThat(successCount.get()).isEqualTo(3); + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/mysite/knitly/domain/payment/PaymentServiceTest.java b/backend/src/test/java/com/mysite/knitly/domain/payment/PaymentServiceTest.java new file mode 100644 index 0000000..1aab2f9 --- /dev/null +++ b/backend/src/test/java/com/mysite/knitly/domain/payment/PaymentServiceTest.java @@ -0,0 +1,176 @@ +package com.mysite.knitly.domain.payment; + +import com.mysite.knitly.domain.order.entity.Order; +import com.mysite.knitly.domain.order.repository.OrderRepository; +import com.mysite.knitly.domain.payment.dto.PaymentCancelRequest; +import com.mysite.knitly.domain.payment.dto.PaymentConfirmRequest; +import com.mysite.knitly.domain.payment.dto.PaymentDetailResponse; +import com.mysite.knitly.domain.payment.entity.Payment; +import com.mysite.knitly.domain.payment.entity.PaymentMethod; +import com.mysite.knitly.domain.payment.entity.PaymentStatus; +import com.mysite.knitly.domain.payment.repository.PaymentRepository; +import com.mysite.knitly.domain.payment.service.PaymentService; +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.global.exception.ErrorCode; +import com.mysite.knitly.global.exception.ServiceException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class PaymentServiceTest { + @Mock + PaymentRepository paymentRepository; + + @InjectMocks + PaymentService paymentService; + + @Mock + OrderRepository orderRepository; + + @Test + @DisplayName("๋งˆ์ดํŽ˜์ด์ง€ ๊ฒฐ์ œ๋‚ด์—ญ ๋‹จ๊ฑด ์กฐํšŒ - ์„ฑ๊ณต(๋ณธ์ธ ์ฃผ๋ฌธ)") + void success() { + User buyer = User.builder().userId(10L).name("ํ™๊ธธ๋™").build(); + + Payment payment = Payment.builder() + .paymentId(100L) + .buyer(buyer) + .paymentMethod(PaymentMethod.CARD) + .paymentStatus(PaymentStatus.DONE) + .totalAmount(15000L) + .requestedAt(LocalDateTime.now().minusMinutes(5)) + .approvedAt(LocalDateTime.now().minusMinutes(4)) + .tossPaymentKey("pk_123") + .tossOrderId("order-1") + .mid("MID001") + .build(); + + when(paymentRepository.findByOrder_OrderId(1L)).thenReturn(Optional.of(payment)); + + PaymentDetailResponse res = paymentService.getPaymentDetailByOrder(buyer, 1L); + + assertThat(res.paymentId()).isEqualTo(100L); + assertThat(res.buyerId()).isEqualTo(10L); + assertThat(res.method()).isEqualTo(PaymentMethod.CARD); + assertThat(res.status()).isEqualTo(PaymentStatus.DONE); + verify(paymentRepository).findByOrder_OrderId(1L); + } + + @Test + @DisplayName("๋งˆ์ดํŽ˜์ด์ง€ ๊ฒฐ์ œ๋‚ด์—ญ ๋‹จ๊ฑด ์กฐํšŒ - ๊ฒฐ์ œ ์—†์Œ") + void notFound() { + User buyer = User.builder().userId(10L).name("ํ™๊ธธ๋™").build(); + + when(paymentRepository.findByOrder_OrderId(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> paymentService.getPaymentDetailByOrder(buyer, 1L)) + .isInstanceOf(ServiceException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.PAYMENT_NOT_FOUND); + } + + @Test + @DisplayName("๋งˆ์ดํŽ˜์ด์ง€ ๊ฒฐ์ œ๋‚ด์—ญ ๋‹จ๊ฑด ์กฐํšŒ - ์†Œ์œ ๊ถŒ ๋ถˆ์ผ์น˜(๊ถŒํ•œ์—†์Œ)") + void unauthorized() { + User buyer = User.builder().userId(10L).name("ํ™๊ธธ๋™").build(); + User other = User.builder().userId(20L).name("๊น€๋•ก๋–™").build(); + + Payment payment = Payment.builder() + .paymentId(100L) + .buyer(other) // ๋‹ค๋ฅธ ์‚ฌ๋žŒ ๊ฒฐ์ œ + .paymentMethod(PaymentMethod.CARD) + .paymentStatus(PaymentStatus.DONE) + .totalAmount(1000L) + .build(); + + when(paymentRepository.findByOrder_OrderId(1L)).thenReturn(Optional.of(payment)); + + assertThatThrownBy(() -> paymentService.getPaymentDetailByOrder(buyer, 1L)) + .isInstanceOf(ServiceException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.PAYMENT_UNAUTHORIZED_ACCESS); // ๋„ค ์ฝ”๋“œ์— ๋งž์ถฐ ์‚ฌ์šฉ + } + + @Test + @DisplayName("๊ฒฐ์ œ ์Šน์ธ ์‹คํŒจ - ์ฃผ๋ฌธ ์—†์Œ") + void confirm_orderNotFound() { + PaymentConfirmRequest req = new PaymentConfirmRequest("pk1", "1", 1000L); + when(orderRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> paymentService.confirmPayment(req)) + .isInstanceOf(ServiceException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ORDER_NOT_FOUND); + } + + @Test + @DisplayName("๊ฒฐ์ œ ์Šน์ธ ์‹คํŒจ - ๊ธˆ์•ก ๋ถˆ์ผ์น˜") + void confirm_amountMismatch() { + PaymentConfirmRequest req = new PaymentConfirmRequest("pk1", "1", 2000L); + Order order = Order.builder().orderId(1L).totalPrice(10000.0).build(); + + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + + assertThatThrownBy(() -> paymentService.confirmPayment(req)) + .isInstanceOf(ServiceException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.PAYMENT_AMOUNT_MISMATCH); + } + + @Test + @DisplayName("๊ฒฐ์ œ ์Šน์ธ ์‹คํŒจ - ์ค‘๋ณต ๊ฒฐ์ œ") + void confirm_duplicate() { + PaymentConfirmRequest req = new PaymentConfirmRequest("pk1", "1", 1000L); + Order order = Order.builder().orderId(1L).totalPrice(1000.0).build(); + Payment payment = Payment.builder().build(); + + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + when(paymentRepository.findByOrder_OrderId(1L)).thenReturn(Optional.of(payment)); + + assertThatThrownBy(() -> paymentService.confirmPayment(req)) + .isInstanceOf(ServiceException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.PAYMENT_ALREADY_EXISTS); + } + + @Test + @DisplayName("๊ฒฐ์ œ ์ทจ์†Œ ์‹คํŒจ - ๊ฒฐ์ œ ์—†์Œ") + void cancel_notFound() { + when(paymentRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> paymentService.cancelPayment(99L, new PaymentCancelRequest("์‚ฌ์œ "))) + .isInstanceOf(ServiceException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.PAYMENT_NOT_FOUND); + } + + @Test + @DisplayName("๊ฒฐ์ œ ์ทจ์†Œ ์‹คํŒจ - ์ทจ์†Œ ๋ถˆ๊ฐ€ ์ƒํƒœ") + void cancel_notCancelable() { + Payment p = Payment.builder() + .paymentId(1L) + .paymentMethod(PaymentMethod.CARD) + .paymentStatus(PaymentStatus.FAILED) // ์ทจ์†Œ ๋ถˆ๊ฐ€ + .totalAmount(1000L) + .build(); + + when(paymentRepository.findById(1L)).thenReturn(Optional.of(p)); + + assertThatThrownBy(() -> paymentService.cancelPayment(1L, new PaymentCancelRequest("์‚ฌ์œ "))) + .isInstanceOf(ServiceException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.PAYMENT_NOT_CANCELABLE); + } +} diff --git a/backend/src/test/java/com/mysite/knitly/domain/product/like/consumer/LikeEventConsumerTest.java b/backend/src/test/java/com/mysite/knitly/domain/product/like/consumer/LikeEventConsumerTest.java new file mode 100644 index 0000000..06746ed --- /dev/null +++ b/backend/src/test/java/com/mysite/knitly/domain/product/like/consumer/LikeEventConsumerTest.java @@ -0,0 +1,97 @@ +package com.mysite.knitly.domain.product.like.consumer; + +import com.mysite.knitly.domain.product.like.dto.LikeEventRequest; +import com.mysite.knitly.domain.product.like.entity.ProductLike; +import com.mysite.knitly.domain.product.like.entity.ProductLikeId; +import com.mysite.knitly.domain.product.like.repository.ProductLikeRepository; +import com.mysite.knitly.domain.product.product.entity.Product; +import com.mysite.knitly.domain.product.product.repository.ProductRepository; +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.domain.user.repository.UserRepository; +import com.mysite.knitly.global.exception.ServiceException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LikeEventConsumerTest { + + @Mock + private ProductLikeRepository productLikeRepository; + @Mock + private UserRepository userRepository; + @Mock + private ProductRepository productRepository; + + @InjectMocks + private LikeEventConsumer likeEventConsumer; + + @Test + @DisplayName("์ฐœํ•˜๊ธฐ: ์ •์ƒ") + void handleLikeEvent_Success() { + LikeEventRequest request = new LikeEventRequest(3L, 1L); + User user = User.builder().userId(request.userId()).build(); + Product product = Product.builder().productId(request.productId()).build(); + + when(userRepository.findById(request.userId())).thenReturn(Optional.of(user)); + when(productRepository.findById(request.productId())).thenReturn(Optional.of(product)); + when(productLikeRepository.existsById(any(ProductLikeId.class))).thenReturn(false); + + likeEventConsumer.handleLikeEvent(request); + + verify(productLikeRepository).save(any(ProductLike.class)); + } + + @Test + @DisplayName("์ฐœํ•˜๊ธฐ: ์ด๋ฏธ ์ฐœํ•œ ์ƒํ’ˆ์ผ ๊ฒฝ์šฐ ์˜ˆ์™ธ ๋ฐœ์ƒ") + void handleLikeEvent_AlreadyExists_ThrowsException() { + LikeEventRequest request = new LikeEventRequest(3L, 1L); + User user = User.builder().userId(request.userId()).build(); + Product product = Product.builder().productId(request.productId()).build(); + + when(userRepository.findById(request.userId())).thenReturn(Optional.of(user)); + when(productRepository.findById(request.productId())).thenReturn(Optional.of(product)); + when(productLikeRepository.existsById(any(ProductLikeId.class))).thenReturn(true); + + assertThrows(ServiceException.class, () -> likeEventConsumer.handleLikeEvent(request)); + verify(productLikeRepository, never()).save(any()); + } + + @Test + @DisplayName("์ฐœ ์‚ญ์ œ: ์ •์ƒ") + void handleDislikeEvent_Success() { + LikeEventRequest request = new LikeEventRequest(3L, 1L); + ProductLikeId id = new ProductLikeId(request.userId(), request.productId()); + ProductLike like = ProductLike.builder().build(); + + when(userRepository.existsById(request.userId())).thenReturn(true); + when(productRepository.existsById(request.productId())).thenReturn(true); + when(productLikeRepository.findById(id)).thenReturn(Optional.of(like)); + + likeEventConsumer.handleDislikeEvent(request); + + verify(productLikeRepository).delete(like); + } + + @Test + @DisplayName("์ฐœ ์‚ญ์ œ: ์‚ญ์ œํ•  ์ฐœ์ด ์—†์„ ๊ฒฝ์šฐ ์˜ˆ์™ธ ๋ฐœ์ƒ") + void handleDislikeEvent_NotFound_ThrowsException() { + LikeEventRequest request = new LikeEventRequest(3L, 1L); + ProductLikeId id = new ProductLikeId(request.userId(), request.productId()); + + when(userRepository.existsById(request.userId())).thenReturn(true); + when(productRepository.existsById(request.productId())).thenReturn(true); + when(productLikeRepository.findById(id)).thenReturn(Optional.empty()); + + assertThrows(ServiceException.class, () -> likeEventConsumer.handleDislikeEvent(request)); + verify(productLikeRepository, never()).delete(any()); + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/mysite/knitly/domain/product/like/service/ProductLikeServiceTest.java b/backend/src/test/java/com/mysite/knitly/domain/product/like/service/ProductLikeServiceTest.java new file mode 100644 index 0000000..98c637a --- /dev/null +++ b/backend/src/test/java/com/mysite/knitly/domain/product/like/service/ProductLikeServiceTest.java @@ -0,0 +1,67 @@ +package com.mysite.knitly.domain.product.like.service; + +import com.mysite.knitly.domain.product.like.dto.LikeEventRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.SetOperations; + +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ProductLikeServiceTest { + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private RabbitTemplate rabbitTemplate; + + @Mock + private SetOperations setOperations; + + @InjectMocks + private ProductLikeService productLikeService; + + @BeforeEach + void setUp() { + when(redisTemplate.opsForSet()).thenReturn(setOperations); + } + + @Test + @DisplayName("์ฐœํ•˜๊ธฐ: Redis์— ์ถ”๊ฐ€ํ•˜๊ณ  RabbitMQ์— ๋ฉ”์‹œ์ง€ ๋ฐœํ–‰") + void addLike_ShouldAddToRedisAndPublishMessage() { + Long userId = 3L; + Long productId = 1L; + String redisKey = "likes:product:" + productId; + LikeEventRequest eventDto = new LikeEventRequest(userId, productId); + + productLikeService.addLike(userId, productId); + + verify(setOperations).add(redisKey, userId.toString()); + verify(rabbitTemplate).convertAndSend("like.exchange", "like.add.routingkey", eventDto); + } + + @Test + @DisplayName("์ฐœ ์‚ญ์ œ: Redis์—์„œ ์ œ๊ฑฐํ•˜๊ณ  RabbitMQ์— ๋ฉ”์‹œ์ง€ ๋ฐœํ–‰") + void deleteLike_ShouldRemoveFromRedisAndPublishMessage() { + Long userId = 3L; + Long productId = 1L; + String redisKey = "likes:product:" + productId; + LikeEventRequest eventDto = new LikeEventRequest(userId, productId); + + when(redisTemplate.opsForSet()).thenReturn(setOperations); + when(setOperations.isMember(redisKey, userId.toString())).thenReturn(true); + + productLikeService.deleteLike(userId, productId); + + verify(setOperations).remove(redisKey, userId.toString()); + verify(rabbitTemplate).convertAndSend("like.exchange", "like.delete.routingkey", eventDto); + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/mysite/knitly/domain/product/review/controller/ReviewControllerTest.java b/backend/src/test/java/com/mysite/knitly/domain/product/review/controller/ReviewControllerTest.java new file mode 100644 index 0000000..51bf54b --- /dev/null +++ b/backend/src/test/java/com/mysite/knitly/domain/product/review/controller/ReviewControllerTest.java @@ -0,0 +1,171 @@ +package com.mysite.knitly.domain.product.review.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mysite.knitly.domain.design.entity.Design; +import com.mysite.knitly.domain.design.entity.DesignState; +import com.mysite.knitly.domain.design.repository.DesignRepository; +import com.mysite.knitly.domain.product.product.entity.ProductCategory; +import com.mysite.knitly.domain.user.entity.Provider; +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.domain.user.repository.UserRepository; +import com.mysite.knitly.domain.product.product.entity.Product; +import com.mysite.knitly.domain.product.product.repository.ProductRepository; +import com.mysite.knitly.domain.product.review.entity.Review; +import com.mysite.knitly.domain.product.review.repository.ReviewRepository; +import com.mysite.knitly.utility.jwt.JwtProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional // ๊ฐ ํ…Œ์ŠคํŠธ ํ›„ DB ๋กค๋ฐฑ +class ReviewControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserRepository userRepository; + @Autowired + private ProductRepository productRepository; + @Autowired + private DesignRepository designRepository; + @Autowired + private ReviewRepository reviewRepository; + @Autowired + private JwtProvider jwtProvider; + + private User testUser; + private User otherUser; + private Product testProduct; + private Design testDesign; + private String testUserToken; + private String otherUserToken; + + @BeforeEach + void setUp() { + // 1. ํ…Œ์ŠคํŠธ์šฉ User ์ƒ์„ฑ + testUser = userRepository.save(User.builder() + .socialId("google_123456789") + .email("test@test.com") + .name("testUser") + .provider(Provider.GOOGLE) + .build()); + + otherUser = userRepository.save(User.builder() + .socialId("google_987654321") + .email("other@test.com") + .name("otherUser") + .provider(Provider.GOOGLE) + .build()); + + // 2. ํ…Œ์ŠคํŠธ์šฉ Design ์ƒ์„ฑ (User์— ์˜์กด) + testDesign = designRepository.save(Design.builder() + .user(testUser) + .designName("ํ…Œ์ŠคํŠธ ๋„์•ˆ") + .designState(DesignState.BEFORE_SALE) + .gridData("{}") + .build()); + + // 3. ํ…Œ์ŠคํŠธ์šฉ Product ์ƒ์„ฑ (User์™€ Design์— ์˜์กด) + testProduct = productRepository.save(Product.builder() + .title("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ") + .description("์ด๊ฒƒ์€ ํ…Œ์ŠคํŠธ ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค.") + .productCategory(ProductCategory.TOP) + .sizeInfo("Free") + .price(10000.0) + .user(testUser) + .purchaseCount(0) + .isDeleted(false) + .stockQuantity(100) + .likeCount(0) + .design(testDesign) + .build()); + + // 4. ๊ฐ ์œ ์ €์— ๋Œ€ํ•œ JWT ํ† ํฐ ์ƒ์„ฑ + testUserToken = jwtProvider.createAccessToken(testUser.getUserId()); + otherUserToken = jwtProvider.createAccessToken(otherUser.getUserId()); + } + + @Test + @DisplayName("๋ฆฌ๋ทฐ ๋“ฑ๋ก: ์„ฑ๊ณต") + void createReview_Success() throws Exception { + String content = "๋ฆฌ๋ทฐ ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค."; + int rating = 5; + + ResultActions actions = mockMvc.perform(post("/products/" + testProduct.getProductId() + "/reviews") + .header("Authorization", "Bearer " + testUserToken) + .contentType(MediaType.MULTIPART_FORM_DATA) + .param("content", content) + .param("rating", String.valueOf(rating))); + + actions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value(content)) + .andExpect(jsonPath("$.rating").value(rating)) + .andExpect(jsonPath("$.userName").value(testUser.getName())) + .andExpect(jsonPath("$.reviewId").isNumber()) + .andExpect(jsonPath("$.createdAt").exists()) + .andExpect(jsonPath("$.reviewImageUrls").isArray()); + } + + @Test + @DisplayName("๋ฆฌ๋ทฐ ๋“ฑ๋ก: ์ธ์ฆ ์‹คํŒจ (ํ† ํฐ ์—†์Œ)") + void createReview_Fail_NoToken() throws Exception { + ResultActions actions = mockMvc.perform(post("/products/" + testProduct.getProductId() + "/reviews") + .contentType(MediaType.MULTIPART_FORM_DATA) + .param("content", "๋‚ด์šฉ") + .param("rating", "5")); + + actions.andExpect(status().isFound()); // 302 Redirect + } + + @Test + @DisplayName("๋ฆฌ๋ทฐ ์‚ญ์ œ: ์„ฑ๊ณต") + void deleteReview_Success() throws Exception { + // testUser๊ฐ€ ์ž‘์„ฑํ•œ ๋ฆฌ๋ทฐ๋ฅผ ๋ฏธ๋ฆฌ ์ €์žฅ + Review review = reviewRepository.save(Review.builder() + .user(testUser) + .product(testProduct) + .content("์‚ญ์ œ๋  ๋ฆฌ๋ทฐ") + .rating(5) + .build()); + + ResultActions actions = mockMvc.perform(delete("/reviews/" + review.getReviewId()) + .header("Authorization", "Bearer " + testUserToken)); + + actions.andExpect(status().isNoContent()); + } + + @Test + @DisplayName("๋ฆฌ๋ทฐ ์‚ญ์ œ: ์ธ๊ฐ€ ์‹คํŒจ (๊ถŒํ•œ ์—†๋Š” ์‚ฌ์šฉ์ž)") + void deleteReview_Fail_NotOwner() throws Exception { + // testUser๊ฐ€ ์ž‘์„ฑํ•œ ๋ฆฌ๋ทฐ + Review review = reviewRepository.save(Review.builder() + .user(testUser) + .product(testProduct) + .content("์‚ญ์ œ๋  ๋ฆฌ๋ทฐ") + .rating(5) + .build()); + + // otherUser์˜ ํ† ํฐ์œผ๋กœ testUser์˜ ๋ฆฌ๋ทฐ ์‚ญ์ œ ์‹œ๋„ + ResultActions actions = mockMvc.perform(delete("/reviews/" + review.getReviewId()) + .header("Authorization", "Bearer " + otherUserToken)); + + actions.andExpect(status().isForbidden()); + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/mysite/knitly/domain/product/review/service/ReviewServiceTest.java b/backend/src/test/java/com/mysite/knitly/domain/product/review/service/ReviewServiceTest.java new file mode 100644 index 0000000..a925703 --- /dev/null +++ b/backend/src/test/java/com/mysite/knitly/domain/product/review/service/ReviewServiceTest.java @@ -0,0 +1,237 @@ +package com.mysite.knitly.domain.product.review.service; + +import com.mysite.knitly.domain.product.product.entity.Product; +import com.mysite.knitly.domain.product.product.repository.ProductRepository; +import com.mysite.knitly.domain.product.review.dto.ReviewCreateRequest; +import com.mysite.knitly.domain.product.review.dto.ReviewDeleteRequest; +import com.mysite.knitly.domain.product.review.dto.ReviewListResponse; +import com.mysite.knitly.domain.product.review.entity.Review; +import com.mysite.knitly.domain.product.review.entity.ReviewImage; +import com.mysite.knitly.domain.product.review.repository.ReviewRepository; +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.domain.user.repository.UserRepository; +import com.mysite.knitly.global.exception.ErrorCode; +import com.mysite.knitly.global.exception.ServiceException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.data.domain.Pageable; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ReviewServiceTest { + + @Mock + private ReviewRepository reviewRepository; + + @Mock + private ProductRepository productRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private ReviewService reviewService; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + + // @TempDir์„ ์‚ฌ์šฉํ•˜๋„๋ก uploadDir ์„ค์ • ๋ณ€๊ฒฝ + reviewService.uploadDir = tempDir.toString(); + reviewService.urlPrefix = "/resources/static/review/"; + } + +// @Test +// @DisplayName("๋ฆฌ๋ทฐ ๋“ฑ๋ก: ์ •์ƒ") +// void createReview_ValidInput_ShouldReturnResponse() { +// Long productId = 1L; +// Long userId = 3L; +// ReviewCreateRequest request = new ReviewCreateRequest((int) 5, "์ข‹์•„์š”", Collections.emptyList()); +// +// User user = User.builder().userId(userId).name("ํ™๊ธธ๋™").build(); +// Product product = Product.builder().productId(productId).build(); +// +// when(userRepository.findById(userId)).thenReturn(Optional.of(user)); +// when(productRepository.findById(productId)).thenReturn(Optional.of(product)); +// when(reviewRepository.save(any(Review.class))).thenAnswer(invocation -> { +// Review reviewToSave = invocation.getArgument(0); +// return Review.builder() +// .reviewId(1L) +// .user(reviewToSave.getUser()) +// .product(reviewToSave.getProduct()) +// .rating(reviewToSave.getRating()) +// .content(reviewToSave.getContent()) +// .build(); +// }); +// +// ReviewListResponse response = reviewService.createReview(productId, user, request); +// +// assertThat(response).isNotNull(); +// assertThat(response.reviewId()).isEqualTo(1L); +// assertThat(response.content()).isEqualTo("์ข‹์•„์š”"); +// assertThat(response.userName()).isEqualTo("ํ™๊ธธ๋™"); +// verify(reviewRepository).save(any(Review.class)); +// } + + @Test + @DisplayName("๋ฆฌ๋ทฐ ์‚ญ์ œ: ์ •์ƒ") + void deleteReview_ValidUser_ShouldSetDeleted() { + Long userId = 3L; + Long reviewId = 1L; + + User user = User.builder().userId(userId).build(); + Review review = Review.builder().reviewId(reviewId).user(user).isDeleted(false).build(); + + when(reviewRepository.findById(reviewId)).thenReturn(Optional.of(review)); + + reviewService.deleteReview(reviewId, user); + + assertThat(review.getIsDeleted()).isTrue(); + } + + @Test + @DisplayName("๋ฆฌ๋ทฐ ์‚ญ์ œ: ๊ถŒํ•œ ์—†๋Š” ์œ ์ €๊ฐ€ ์š”์ฒญ์‹œ ์‹คํŒจ") + void deleteReview_NotOwner_ShouldThrowException() { + Long requesterId = 3L; + Long ownerId = 9L; + Long reviewId = 1L; + + User owner = User.builder().userId(ownerId).build(); + Review review = Review.builder().reviewId(reviewId).user(owner).build(); + User requester = User.builder().userId(requesterId).build(); + + when(reviewRepository.findById(reviewId)).thenReturn(Optional.of(review)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> reviewService.deleteReview(reviewId, requester)); + + assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.REVIEW_NOT_AUTHORIZED); + } + +// @Test +// @DisplayName("๋ฆฌ๋ทฐ ๋ชฉ๋ก ์กฐํšŒ: ์‚ญ์ œ๋˜์ง€ ์•Š์€ ๋ฆฌ๋ทฐ๋งŒ ๋ฐ˜ํ™˜") +// void getReviewsByProduct_ShouldReturnOnlyNonDeletedReviews() { +// Long productId = 1L; +// Long userId = 3L; +// User user = User.builder().userId(userId).name("์‚ฌ์šฉ์ž1").build(); +// +// Review review1 = Review.builder() +// .reviewId(1L) +// .content("์ข‹์•„์š”") +// .rating(5) +// .user(user) +// .product(Product.builder().productId(productId).build()) +// .isDeleted(false) +// .build(); +// +// when(reviewRepository.findByProduct_ProductIdAndIsDeletedFalse(eq(productId), any(Pageable.class))) +// .thenReturn(List.of(review1)); +// +// List responses = reviewService.getReviewsByProduct(productId, 0, 10); +// +// assertThat(responses).hasSize(1); +// assertThat(responses.get(0).reviewId()).isEqualTo(1L); +// assertThat(responses.get(0).content()).isEqualTo("์ข‹์•„์š”"); +// } + +// @Test +// @DisplayName("๋ฆฌ๋ทฐ ๋“ฑ๋ก: ์ด๋ฏธ์ง€ URL๊นŒ์ง€ ํฌํ•จํ•ด์„œ ReviewListResponse ๋ฐ˜ํ™˜") +// void createReview_WithImages_ShouldReturnResponseWithUrls() throws Exception { +// Long productId = 1L; +// Long userId = 3L; +// +// MultipartFile mockFile = new MockMultipartFile("file", "image.jpg", "image/jpeg", new byte[]{1, 2, 3}); +// ReviewCreateRequest request = new ReviewCreateRequest(5, "์ด๋ฏธ์ง€ ๋ฆฌ๋ทฐ", List.of(mockFile)); +// +// User user = User.builder().userId(userId).name("ํ™๊ธธ๋™").build(); +// Product product = Product.builder().productId(productId).build(); +// +// when(userRepository.findById(userId)).thenReturn(Optional.of(user)); +// when(productRepository.findById(productId)).thenReturn(Optional.of(product)); +// when(reviewRepository.save(any(Review.class))).thenAnswer(invocation -> invocation.getArgument(0)); +// +// ReviewListResponse response = reviewService.createReview(productId, user, request); +// +// assertThat(response).isNotNull(); +// assertThat(response.content()).isEqualTo("์ด๋ฏธ์ง€ ๋ฆฌ๋ทฐ"); +// assertThat(response.reviewImageUrls()).hasSize(1); +// assertThat(response.reviewImageUrls().get(0)) +// .startsWith("/resources/static/review/") +// .contains("image.jpg"); +// } + +// @Test +// @DisplayName("๋ฆฌ๋ทฐ ๋ชฉ๋ก ์กฐํšŒ: ์‚ญ์ œ๋˜์ง€ ์•Š์€ ๋ฆฌ๋ทฐ์™€ ์ด๋ฏธ์ง€ URL ๋ฐ˜ํ™˜") +// void getReviewsByProduct_ShouldReturnOnlyNonDeletedReviewsWithImages() { +// Long productId = 1L; +// Long userId = 3L; +// User user = User.builder().userId(userId).name("ํ™๊ธธ๋™").build(); +// +// Review review = Review.builder() +// .reviewId(1L) +// .content("์ข‹์•„์š”") +// .rating(5) +// .user(user) +// .isDeleted(false) +// .build(); +// +// ReviewImage image1 = ReviewImage.builder().reviewImageUrl("/static/review/img1.jpg").build(); +// ReviewImage image2 = ReviewImage.builder().reviewImageUrl("/static/review/img2.png").build(); +// review.addReviewImages(List.of(image1, image2)); +// +// when(reviewRepository.findByProduct_ProductIdAndIsDeletedFalse(eq(productId), any(Pageable.class))) +// .thenReturn(List.of(review)); +// // when +// List responses = reviewService.getReviewsByProduct(productId, 0, 10); +// +// // then +// assertThat(responses).hasSize(1); +// ReviewListResponse r = responses.get(0); +// assertThat(r.reviewId()).isEqualTo(1L); +// +// assertThat(r.reviewImageUrls()) +// .containsExactly("/static/review/img1.jpg", "/static/review/img2.png"); +// } + + @Test + @DisplayName("๋ฆฌ๋ทฐ ๋“ฑ๋ก: ์ง€์›ํ•˜์ง€ ์•Š๋Š” ์ด๋ฏธ์ง€ ํ˜•์‹") + void createReview_InvalidImageFormat_ShouldThrowException() throws Exception { + Long productId = 1L; + Long userId = 3L; + + MultipartFile invalidFile = new MockMultipartFile("file", "document.txt", "text/plain", new byte[]{1, 2, 3}); + ReviewCreateRequest request = new ReviewCreateRequest(5, "์ข‹์•„์š”", List.of(invalidFile)); + + User user = User.builder().userId(userId).name("ํ™๊ธธ๋™").build(); + Product product = Product.builder().productId(productId).build(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> reviewService.createReview(productId, user, request)); + + assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.IMAGE_FORMAT_NOT_SUPPORTED); + } +} diff --git a/backend/src/test/java/com/mysite/knitly/domain/product/service/ProductServiceTest.java b/backend/src/test/java/com/mysite/knitly/domain/product/service/ProductServiceTest.java new file mode 100644 index 0000000..c53246f --- /dev/null +++ b/backend/src/test/java/com/mysite/knitly/domain/product/service/ProductServiceTest.java @@ -0,0 +1,437 @@ +package com.mysite.knitly.domain.product.service; + +import com.mysite.knitly.domain.design.entity.Design; +import com.mysite.knitly.domain.design.entity.DesignState; +import com.mysite.knitly.domain.design.repository.DesignRepository; +import com.mysite.knitly.domain.product.product.dto.ProductDetailResponse; +import com.mysite.knitly.domain.product.product.dto.ProductListResponse; +import com.mysite.knitly.domain.product.product.dto.ProductModifyRequest; +import com.mysite.knitly.domain.product.product.dto.ProductRegisterRequest; +import com.mysite.knitly.domain.product.product.entity.*; +import com.mysite.knitly.domain.product.product.repository.ProductRepository; +import com.mysite.knitly.domain.product.product.service.ProductService; +import com.mysite.knitly.domain.product.product.service.RedisProductService; +import com.mysite.knitly.domain.user.entity.User; +import com.mysite.knitly.global.exception.ErrorCode; +import com.mysite.knitly.global.exception.ServiceException; +import com.mysite.knitly.global.util.FileStorageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.*; +import org.springframework.mock.web.MockMultipartFile; + +import java.time.LocalDateTime; +import java.util.*; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ProductServiceTest { + + @Mock + private ProductRepository productRepository; + + @Mock + private RedisProductService redisProductService; + + @Mock + private DesignRepository designRepository; + + @Mock + private FileStorageService fileStorageService; + @InjectMocks + private ProductService productService; + + private User seller; + private Design design; + private Product product1; + private Product product2; + private Product product3; + private Pageable pageable; + + @BeforeEach + void setUp() { + pageable = PageRequest.of(0, 20, Sort.by("createdAt").descending()); + + product1 = Product.builder() + .productId(1L) + .title("์ƒ์˜ ํŒจํ„ด 1") + .productCategory(ProductCategory.TOP) + .price(10000.0) + .purchaseCount(100) + .likeCount(50) + .isDeleted(false) + .build(); + + product2 = Product.builder() + .productId(2L) + .title("๋ฌด๋ฃŒ ํŒจํ„ด") + .productCategory(ProductCategory.BOTTOM) + .price(0.0) + .purchaseCount(200) + .likeCount(80) + .isDeleted(false) + .build(); + + product3 = Product.builder() + .productId(3L) + .title("ํ•œ์ •ํŒ๋งค ํŒจํ„ด") + .productCategory(ProductCategory.OUTER) + .price(15000.0) + .stockQuantity(10) + .purchaseCount(150) + .likeCount(60) + .isDeleted(false) + .build(); + + seller = User.builder() + .userId(1L) + .build(); + + design = Design.builder() + .designId(1L) + .user(seller) + .designState(DesignState.BEFORE_SALE) + .build(); + } + +// @Test +// @DisplayName("์ „์ฒด ์ƒํ’ˆ ์กฐํšŒ - ์ตœ์‹ ์ˆœ") +// void getProducts_All_Latest() { +// Page productPage = new PageImpl<>(Arrays.asList(product1, product2, product3)); +// given(productRepository.findByIsDeletedFalse(any(Pageable.class))) +// .willReturn(productPage); +// +// Page result = productService.getProducts( +// null, ProductFilterType.ALL, ProductSortType.LATEST, pageable); +// +// assertThat(result.getContent()).hasSize(3); +// assertThat(result.getTotalElements()).isEqualTo(3); +// verify(productRepository).findByIsDeletedFalse(any(Pageable.class)); +// } + +// @Test +// @DisplayName("์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ์กฐํšŒ - ์ƒ์˜๋งŒ") +// void getProducts_Category_Top() { +// Page productPage = new PageImpl<>(List.of(product1)); +// given(productRepository.findByProductCategoryAndIsDeletedFalse( +// eq(ProductCategory.TOP), any(Pageable.class))) +// .willReturn(productPage); +// +// Page result = productService.getProducts( +// ProductCategory.TOP, ProductFilterType.ALL, ProductSortType.LATEST, pageable); +// +// assertThat(result.getContent()).hasSize(1); +// assertThat(result.getContent().get(0).productCategory()).isEqualTo(ProductCategory.TOP); +// verify(productRepository).findByProductCategoryAndIsDeletedFalse( +// eq(ProductCategory.TOP), any(Pageable.class)); +// } + +// @Test +// @DisplayName("๋ฌด๋ฃŒ ์ƒํ’ˆ๋งŒ ์กฐํšŒ") +// void getProducts_Free() { +// Page productPage = new PageImpl<>(List.of(product2)); +// given(productRepository.findByPriceAndIsDeletedFalse(eq(0.0), any(Pageable.class))) +// .willReturn(productPage); +// +// Page result = productService.getProducts( +// null, ProductFilterType.FREE, ProductSortType.LATEST, pageable); +// +// assertThat(result.getContent()).hasSize(1); +// assertThat(result.getContent().get(0).price()).isEqualTo(0.0); +// assertThat(result.getContent().get(0).isFree()).isTrue(); +// verify(productRepository).findByPriceAndIsDeletedFalse(eq(0.0), any(Pageable.class)); +// } + +// @Test +// @DisplayName("ํ•œ์ •ํŒ๋งค ์ƒํ’ˆ๋งŒ ์กฐํšŒ") +// void getProducts_Limited() { +// Page productPage = new PageImpl<>(List.of(product3)); +// given(productRepository.findByStockQuantityIsNotNullAndIsDeletedFalse(any(Pageable.class))) +// .willReturn(productPage); +// +// Page result = productService.getProducts( +// null, ProductFilterType.LIMITED, ProductSortType.LATEST, pageable); +// +// assertThat(result.getContent()).hasSize(1); +// assertThat(result.getContent().get(0).stockQuantity()).isNotNull(); +// assertThat(result.getContent().get(0).isLimited()).isTrue(); +// verify(productRepository).findByStockQuantityIsNotNullAndIsDeletedFalse(any(Pageable.class)); +// } + +// @Test +// @DisplayName("์ธ๊ธฐ์ˆœ ์กฐํšŒ - Redis ๋ฐ์ดํ„ฐ ์žˆ์Œ") +// void getProducts_Popular_WithRedis() { +// List popularIds = Arrays.asList(2L, 1L, 3L); // ์ธ๊ธฐ์ˆœ +// given(redisProductService.getTopNPopularProducts(1000)).willReturn(popularIds); +// given(productRepository.findByProductIdInAndIsDeletedFalse(popularIds)) +// .willReturn(Arrays.asList(product2, product1, product3)); +// +// Page result = productService.getProducts( +// null, ProductFilterType.ALL, ProductSortType.POPULAR, pageable); +// +// assertThat(result.getContent()).hasSize(3); +// assertThat(result.getContent().get(0).productId()).isEqualTo(2L); // ๊ฐ€์žฅ ์ธ๊ธฐ์žˆ๋Š” ์ƒํ’ˆ +// verify(redisProductService).getTopNPopularProducts(1000); +// } + + +// @Test +// @DisplayName("๊ฐ€๊ฒฉ ๋‚ฎ์€์ˆœ ์ •๋ ฌ") +// void getProducts_SortByPrice_Asc() { +// Page productPage = new PageImpl<>(Arrays.asList(product2, product1, product3)); +// given(productRepository.findByIsDeletedFalse(any(Pageable.class))) +// .willReturn(productPage); +// +// Page result = productService.getProducts( +// null, ProductFilterType.ALL, ProductSortType.PRICE_ASC, pageable); +// +// assertThat(result.getContent()).hasSize(3); +// verify(productRepository).findByIsDeletedFalse(any(Pageable.class)); +// } + +// @Test +// @DisplayName("filter=FREE์ด๋ฉด ์นดํ…Œ๊ณ ๋ฆฌ ๋ฌด์‹œํ•˜๊ณ  ๋ฌด๋ฃŒ ์ „์ฒด์—์„œ ์กฐํšŒ") +// void freeFilter_ignoresCategory() { +// Pageable pageable = PageRequest.of(0, 20); +// // popular ๋ถ„๊ธฐ ์•ˆ ํƒ€๋Š” ์ผ€์ด์Šค๋กœ ์ตœ์‹  ์ •๋ ฌ ๊ฐ€์ • +// given(productRepository.findByPriceAndIsDeletedFalse(eq(0.0), any(Pageable.class))) +// .willReturn(new PageImpl<>(List.of(product2))); // product2: price 0.0 +// +// Page result = productService.getProducts( +// ProductCategory.TOP, ProductFilterType.FREE, ProductSortType.LATEST, pageable); +// +// assertThat(result.getContent()).hasSize(1); +// assertThat(result.getContent().get(0).isFree()).isTrue(); +// // TOP๋กœ ์ œํ•œ๋˜์ง€ ์•Š์Œ์„ ๊ฐ„์ ‘ ํ™•์ธ(๋ฆฌํฌ ํ˜ธ์ถœ ๊ฒ€์ฆ) +// verify(productRepository).findByPriceAndIsDeletedFalse(eq(0.0), any(Pageable.class)); +// } + + + @Test + @DisplayName("์ƒํ’ˆ ๋“ฑ๋ก ์„ฑ๊ณต - ์ด๋ฏธ์ง€ ํฌํ•จ") + void registerProduct_Success_WithImages() { + // given + MockMultipartFile image1 = new MockMultipartFile("images", "image1.jpg", "image/jpeg", "image1_content".getBytes()); + ProductRegisterRequest request = new ProductRegisterRequest("์ƒˆ ์ƒํ’ˆ", "์„ค๋ช…", ProductCategory.TOP, "M", 10000.0, List.of(image1), 10); + + given(designRepository.findById(1L)).willReturn(Optional.of(design)); + given(productRepository.save(any(Product.class))).willAnswer(invocation -> { + Product productToSave = invocation.getArgument(0); + return Product.builder() + .productId(1L) // ํ…Œ์ŠคํŠธ์šฉ ์ž„์˜ ID + .user(productToSave.getUser()) + .design(productToSave.getDesign()) + .title(productToSave.getTitle()) + .createdAt(LocalDateTime.now()) + .productImages(productToSave.getProductImages()) + .build(); + }); + given(fileStorageService.storeFile(any(MockMultipartFile.class), eq("product"))).willReturn("/static/product/mock-image.jpg"); + + // when + productService.registerProduct(seller, 1L, request); + + // then + verify(designRepository).findById(1L); + verify(productRepository).save(any(Product.class)); + verify(fileStorageService, times(1)).storeFile(any(MockMultipartFile.class), eq("product")); + assertThat(design.getDesignState()).isEqualTo(DesignState.ON_SALE); // Design ์ƒํƒœ ๋ณ€๊ฒฝ ํ™•์ธ + } + + @Test + @DisplayName("์ƒํ’ˆ ๋“ฑ๋ก ์‹คํŒจ(์˜ˆ์™ธ) - ์ด๋ฏธ ํŒ๋งค์ค‘์ธ ๋„์•ˆ์œผ๋กœ ๋“ฑ๋ก ์‹œ๋„") + void registerProduct_Fail_DesignAlreadyOnSale() { + // given + design.startSale(); // ๋„์•ˆ ์ƒํƒœ๋ฅผ ON_SALE์œผ๋กœ ๋ฏธ๋ฆฌ ๋ณ€๊ฒฝ + ProductRegisterRequest request = new ProductRegisterRequest("์ƒˆ ์ƒํ’ˆ", "์„ค๋ช…", ProductCategory.TOP, "M", 10000.0, Collections.emptyList(), 10); + + given(designRepository.findById(1L)).willReturn(Optional.of(design)); + + // when & then + ServiceException exception = assertThrows(ServiceException.class, () -> { + productService.registerProduct(seller, 1L, request); + }); + + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.DESIGN_ALREADY_ON_SALE); + } + + @Test + @DisplayName("์ƒํ’ˆ ์ˆ˜์ • ์„ฑ๊ณต - ์ด๋ฏธ์ง€ ๊ต์ฒด") + void modifyProduct_Success_ImageUpdate() { + // given + Product existingProduct = spy(Product.builder() + .productId(1L) + .user(seller) + .isDeleted(false) + .productImages(new ArrayList<>(List.of(ProductImage.builder().productImageUrl("/static/product/old-image.jpg").build()))) + .build()); + + MockMultipartFile newImage = new MockMultipartFile("images", "new.jpg", "image/jpeg", "new_content".getBytes()); + ProductModifyRequest request = new ProductModifyRequest("์ˆ˜์ •๋œ ์„ค๋ช…", ProductCategory.BOTTOM, "L", List.of(newImage), 20); + + given(productRepository.findByIdWithUser(1L)).willReturn(Optional.of(existingProduct)); + given(fileStorageService.storeFile(any(MockMultipartFile.class), eq("product"))).willReturn("/static/product/new-image.jpg"); + + // when + productService.modifyProduct(seller, 1L, request); + + // then + verify(existingProduct).update("์ˆ˜์ •๋œ ์„ค๋ช…", ProductCategory.BOTTOM, "L", 20); // ์ •๋ณด ์—…๋ฐ์ดํŠธ ํ™•์ธ + verify(fileStorageService).storeFile(any(MockMultipartFile.class), eq("product")); // ์ƒˆ ํŒŒ์ผ ์ €์žฅ ํ™•์ธ + verify(fileStorageService).deleteFile("/static/product/old-image.jpg"); // ๊ธฐ์กด ํŒŒ์ผ ์‚ญ์ œ ํ™•์ธ + } + + @Test + @DisplayName("์ƒํ’ˆ ์ˆ˜์ • ์‹คํŒจ(์˜ˆ์™ธ) - ๋‹ค๋ฅธ ์‚ฌ๋žŒ์˜ ์ƒํ’ˆ ์ˆ˜์ • ์‹œ๋„") + void modifyProduct_Fail_Unauthorized() { + // given + User attacker = User.builder().userId(999L).build(); + Product targetProduct = Product.builder().productId(1L).user(seller).isDeleted(false).build(); + ProductModifyRequest request = new ProductModifyRequest("ํ•ดํ‚น", ProductCategory.ETC, "S", List.of(), 0); + + given(productRepository.findByIdWithUser(1L)).willReturn(Optional.of(targetProduct)); + + // when & then + ServiceException exception = assertThrows(ServiceException.class, () -> { + productService.modifyProduct(attacker, 1L, request); + }); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.PRODUCT_MODIFY_UNAUTHORIZED); + } + + @Test + @DisplayName("์†Œํ”„ํŠธ ์‚ญ์ œ ์„ฑ๊ณต") + void deleteProduct_Success() { + // given + Design designToStop = spy(design); + designToStop.startSale(); // ON_SALE ์ƒํƒœ๋กœ ๋ณ€๊ฒฝ + Product productToDelete = spy(Product.builder().productId(1L).user(seller).design(designToStop).isDeleted(false).build()); + + given(productRepository.findByIdWithUser(1L)).willReturn(Optional.of(productToDelete)); + + // when + productService.deleteProduct(seller, 1L); + + // then + verify(productToDelete).softDelete(); // Product.isDeleted = true ํ™•์ธ + verify(designToStop).stopSale(); // Design.designState = STOPPED ํ™•์ธ + } + + @Test + @DisplayName("์žฌํŒ๋งค ์„ฑ๊ณต") + void relistProduct_Success() { + // given + Design designToRelist = spy(design); + designToRelist.startSale(); + designToRelist.stopSale(); // STOPPED ์ƒํƒœ๋กœ ๋ณ€๊ฒฝ + Product productToRelist = spy(Product.builder().productId(1L).user(seller).design(designToRelist).isDeleted(true).build()); + + given(productRepository.findByIdWithUser(1L)).willReturn(Optional.of(productToRelist)); + + // when + productService.relistProduct(seller, 1L); + + // then + verify(productToRelist).relist(); // Product.isDeleted = false ํ™•์ธ + verify(designToRelist).relist(); // Design.designState = ON_SALE ํ™•์ธ + } + + @Test + @DisplayName("์žฌํŒ๋งค ์‹คํŒจ(์˜ˆ์™ธ) - ์ด๋ฏธ ํŒ๋งค ์ค‘์ธ ์ƒํ’ˆ์„ ์žฌํŒ๋งค ์‹œ๋„") + void relistProduct_Fail_AlreadyOnSale() { + // given + Product productOnSale = Product.builder().productId(1L).user(seller).isDeleted(false).build(); + + given(productRepository.findByIdWithUser(1L)).willReturn(Optional.of(productOnSale)); + + // when & then + // Product.relist() ๋‚ด๋ถ€์—์„œ ๋˜์ง€๋Š” ์˜ˆ์™ธ๋ฅผ ๊ฒ€์ฆ + assertThrows(ServiceException.class, () -> { + productService.relistProduct(seller, 1L); + }); + } + +// @Test +// @DisplayName("์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ ์„ฑ๊ณต") +// void getProductDetail_Success() { +// // given +// Long productId = 1L; +// // ํ…Œ์ŠคํŠธ์— ์‚ฌ์šฉํ•  ์ƒ์„ธ ์ •๋ณด๊ฐ€ ํฌํ•จ๋œ Product ๊ฐ์ฒด ์ƒ์„ฑ +// Product detailedProduct = Product.builder() +// .productId(productId) +// .title("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ") +// .description("์ƒ์„ธ ์„ค๋ช…์ž…๋‹ˆ๋‹ค.") +// .price(20000.0) +// .isDeleted(false) +// .user(seller) // setUp()์—์„œ ์ƒ์„ฑ๋œ ๊ณตํ†ต User ๊ฐ์ฒด ์‚ฌ์šฉ +// .design(design) // setUp()์—์„œ ์ƒ์„ฑ๋œ ๊ณตํ†ต Design ๊ฐ์ฒด ์‚ฌ์šฉ +// .productImages(List.of( +// ProductImage.builder().productImageUrl("/static/img1.jpg").build(), +// ProductImage.builder().productImageUrl("/static/img2.jpg").build() +// )) +// .build(); +// +// // Repository๊ฐ€ findProductDetailById ํ˜ธ์ถœ ์‹œ ์œ„์—์„œ ๋งŒ๋“  ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋„๋ก ์„ค์ • +// given(productRepository.findProductDetailById(productId)).willReturn(Optional.of(detailedProduct)); +// +// // when +// ProductDetailResponse response = productService.getProductDetail(productId); +// +// // then +// assertThat(response).isNotNull(); +// assertThat(response.title()).isEqualTo("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ"); +// assertThat(response.description()).isEqualTo("์ƒ์„ธ ์„ค๋ช…์ž…๋‹ˆ๋‹ค."); +// assertThat(response.productImageUrls()).hasSize(2); +// assertThat(response.productImageUrls()).contains("/static/img1.jpg", "/static/img2.jpg"); +// } + +// @Test +// @DisplayName("์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ ์‹คํŒจ - ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ ID") +// void getProductDetail_Fail_NotFound() { +// // given +// Long nonExistentProductId = 999L; +// // Repository๊ฐ€ ์–ด๋–ค Long ๊ฐ’์„ ๋ฐ›๋”๋ผ๋„ Optional.empty()๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋„๋ก ์„ค์ • +// given(productRepository.findProductDetailById(nonExistentProductId)).willReturn(Optional.empty()); +// +// // when & then +// ServiceException exception = assertThrows(ServiceException.class, () -> { +// productService.getProductDetail(nonExistentProductId); +// }); +// +// assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.PRODUCT_NOT_FOUND); +// } + +// @Test +// @DisplayName("์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ ์‹คํŒจ - ์ด๋ฏธ ์‚ญ์ œ(ํŒ๋งค ์ค‘์ง€)๋œ ์ƒํ’ˆ") +// void getProductDetail_Fail_IsDeleted() { +// // given +// Long productId = 1L; +// Product deletedProduct = Product.builder() +// .productId(productId) +// .isDeleted(true) // โš ๏ธ ์‚ญ์ œ๋œ ์ƒํƒœ์˜ ์ƒํ’ˆ +// .user(seller) +// .design(design) +// .build(); +// +// // Repository๋Š” ์ผ๋‹จ DB์—์„œ ๋ฐ์ดํ„ฐ๋ฅผ ์ฐพ์•˜๋‹ค๊ณ  ๊ฐ€์ • (์„œ๋น„์Šค ๋กœ์ง์˜ ๋ฐฉ์–ด ์ฝ”๋“œ๋ฅผ ํ…Œ์ŠคํŠธํ•˜๊ธฐ ์œ„ํ•จ) +// given(productRepository.findProductDetailById(productId)).willReturn(Optional.of(deletedProduct)); +// +// // when & then +// // Service์˜ if (product.getIsDeleted()) ๋ถ„๊ธฐ์—์„œ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜๋Š”์ง€ ๊ฒ€์ฆ +// ServiceException exception = assertThrows(ServiceException.class, () -> { +// productService.getProductDetail(productId); +// }); +// +// assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.PRODUCT_NOT_FOUND); +// } +} \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000..719cea2 --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,25 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), + { + ignores: [ + "node_modules/**", + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ], + }, +]; + +export default eslintConfig; diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000..e9ffa30 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ +}; + +export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..c52df95 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,6296 @@ +{ + "name": "frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.1.0", + "dependencies": { + "@tosspayments/tosspayments-sdk": "^2.4.0", + "axios": "^1.12.2", + "next": "15.5.6", + "react": "19.1.0", + "react-dom": "19.1.0", + "react-router-dom": "^7.9.4", + "zustand": "^5.0.8" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/react-router-dom": "^5.3.3", + "eslint": "^9", + "eslint-config-next": "15.5.6", + "tailwindcss": "^4", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emnapi/core": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", + "integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz", + "integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.16.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", + "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.38.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz", + "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", + "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.16.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz", + "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", + "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.3" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", + "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", + "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", + "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", + "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", + "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", + "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", + "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", + "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", + "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", + "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", + "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", + "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", + "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", + "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", + "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.5.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", + "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", + "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", + "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.6.tgz", + "integrity": "sha512-3qBGRW+sCGzgbpc5TS1a0p7eNxnOarGVQhZxfvTdnV0gFI61lX7QNtQ4V1TSREctXzYn5NetbUsLvyqwLFJM6Q==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.6.tgz", + "integrity": "sha512-YxDvsT2fwy1j5gMqk3ppXlsgDopHnkM4BoxSVASbvvgh5zgsK8lvWerDzPip8k3WVzsTZ1O7A7si1KNfN4OZfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.6.tgz", + "integrity": "sha512-ES3nRz7N+L5Umz4KoGfZ4XX6gwHplwPhioVRc25+QNsDa7RtUF/z8wJcbuQ2Tffm5RZwuN2A063eapoJ1u4nPg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.6.tgz", + "integrity": "sha512-JIGcytAyk9LQp2/nuVZPAtj8uaJ/zZhsKOASTjxDug0SPU9LAM3wy6nPU735M1OqacR4U20LHVF5v5Wnl9ptTA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.6.tgz", + "integrity": "sha512-qvz4SVKQ0P3/Im9zcS2RmfFL/UCQnsJKJwQSkissbngnB/12c6bZTCB0gHTexz1s6d/mD0+egPKXAIRFVS7hQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.6.tgz", + "integrity": "sha512-FsbGVw3SJz1hZlvnWD+T6GFgV9/NYDeLTNQB2MXoPN5u9VA9OEDy6fJEfePfsUKAhJufFbZLgp0cPxMuV6SV0w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.6.tgz", + "integrity": "sha512-3QnHGFWlnvAgyxFxt2Ny8PTpXtQD7kVEeaFat5oPAHHI192WKYB+VIKZijtHLGdBBvc16tiAkPTDmQNOQ0dyrA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.6.tgz", + "integrity": "sha512-OsGX148sL+TqMK9YFaPFPoIaJKbFJJxFzkXZljIgA9hjMjdruKht6xDCEv1HLtlLNfkx3c5w2GLKhj7veBQizQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.6.tgz", + "integrity": "sha512-ONOMrqWxdzXDJNh2n60H6gGyKed42Ieu6UTVPZteXpuKbLZTH4G4eBMsr5qWgOBA+s7F+uB4OJbZnrkEDnZ5Fg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.6.tgz", + "integrity": "sha512-pxK4VIjFRx1MY92UycLOOw7dTdvccWsNETQ0kDHkBlcFH1GrTLUjSiHU1ohrznnux6TqRHgv5oflhfIWZwVROQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.14.0.tgz", + "integrity": "sha512-WJFej426qe4RWOm9MMtP4V3CV4AucXolQty+GRgAWLgQXmpCuwzs7hEpxxhSc/znXUSxum9d/P/32MW0FlAAlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.14.tgz", + "integrity": "sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.0", + "lightningcss": "1.30.1", + "magic-string": "^0.30.19", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.14" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.14.tgz", + "integrity": "sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.4", + "tar": "^7.5.1" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.14", + "@tailwindcss/oxide-darwin-arm64": "4.1.14", + "@tailwindcss/oxide-darwin-x64": "4.1.14", + "@tailwindcss/oxide-freebsd-x64": "4.1.14", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.14", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.14", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.14", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.14", + "@tailwindcss/oxide-linux-x64-musl": "4.1.14", + "@tailwindcss/oxide-wasm32-wasi": "4.1.14", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.14", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.14" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.14.tgz", + "integrity": "sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.14.tgz", + "integrity": "sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.14.tgz", + "integrity": "sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.14.tgz", + "integrity": "sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.14.tgz", + "integrity": "sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.14.tgz", + "integrity": "sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.14.tgz", + "integrity": "sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.14.tgz", + "integrity": "sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.14.tgz", + "integrity": "sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.14.tgz", + "integrity": "sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.5", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.14.tgz", + "integrity": "sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.14.tgz", + "integrity": "sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.14.tgz", + "integrity": "sha512-BdMjIxy7HUNThK87C7BC8I1rE8BVUsfNQSI5siQ4JK3iIa3w0XyVvVL9SXLWO//CtYTcp1v7zci0fYwJOjB+Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.14", + "@tailwindcss/oxide": "4.1.14", + "postcss": "^8.4.41", + "tailwindcss": "4.1.14" + } + }, + "node_modules/@tosspayments/tosspayments-sdk": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@tosspayments/tosspayments-sdk/-/tosspayments-sdk-2.4.0.tgz", + "integrity": "sha512-Ljs9QCl45+mehyykPcWN2kh562Uo5a4kQIqentKvdv+YQ+yz6L2F2Kgph1vJcoQKk8vZKHckBezlmFW6MGl8Ww==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.22", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.22.tgz", + "integrity": "sha512-hRnu+5qggKDSyWHlnmThnUqg62l29Aj/6vcYgUaSFL9oc7DVjeWEQN3PRgdSc6F8d9QRMWkf36CLMch1Do/+RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", + "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", + "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "dev": true, + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dev": true, + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz", + "integrity": "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.46.1", + "@typescript-eslint/type-utils": "8.46.1", + "@typescript-eslint/utils": "8.46.1", + "@typescript-eslint/visitor-keys": "8.46.1", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.46.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz", + "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.46.1", + "@typescript-eslint/types": "8.46.1", + "@typescript-eslint/typescript-estree": "8.46.1", + "@typescript-eslint/visitor-keys": "8.46.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.1.tgz", + "integrity": "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.46.1", + "@typescript-eslint/types": "^8.46.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.1.tgz", + "integrity": "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.1", + "@typescript-eslint/visitor-keys": "8.46.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.1.tgz", + "integrity": "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.1.tgz", + "integrity": "sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.1", + "@typescript-eslint/typescript-estree": "8.46.1", + "@typescript-eslint/utils": "8.46.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.1.tgz", + "integrity": "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz", + "integrity": "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.46.1", + "@typescript-eslint/tsconfig-utils": "8.46.1", + "@typescript-eslint/types": "8.46.1", + "@typescript-eslint/visitor-keys": "8.46.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.1.tgz", + "integrity": "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.46.1", + "@typescript-eslint/types": "8.46.1", + "@typescript-eslint/typescript-estree": "8.46.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.1.tgz", + "integrity": "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", + "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001751", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", + "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.4", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.38.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", + "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.1", + "@eslint/core": "^0.16.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.38.0", + "@eslint/plugin-kit": "^0.4.0", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.6.tgz", + "integrity": "sha512-cGr3VQlPsZBEv8rtYp4BpG1KNXDqGvPo9VC1iaCgIA11OfziC/vczng+TnAS3WpRIR3Q5ye/6yl+CRUuZ1fPGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "15.5.6", + "@rushstack/eslint-patch": "^1.10.3", + "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^5.0.0" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.12.0.tgz", + "integrity": "sha512-LScr2aNr2FbjAjZh2C6X6BxRx1/x+aTDExct/xyq2XKbYOiG5c0aK7pMsSuyc0brz3ibr/lbQiHD9jzt4lccJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.6.tgz", + "integrity": "sha512-zTxsnI3LQo3c9HSdSf91O1jMNsEzIXDShXd4wVdg9y5shwLqBXi4ZtUUJyB86KGVSJLZx0PFONvO54aheGX8QQ==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.6", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.6", + "@next/swc-darwin-x64": "15.5.6", + "@next/swc-linux-arm64-gnu": "15.5.6", + "@next/swc-linux-arm64-musl": "15.5.6", + "@next/swc-linux-x64-gnu": "15.5.6", + "@next/swc-linux-x64-musl": "15.5.6", + "@next/swc-win32-arm64-msvc": "15.5.6", + "@next/swc-win32-x64-msvc": "15.5.6", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-router": { + "version": "7.9.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz", + "integrity": "sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA==", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.9.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.4.tgz", + "integrity": "sha512-f30P6bIkmYvnHHa5Gcu65deIXoA2+r3Eb6PJIAddvsT9aGlchMatJ51GgpU470aSqRRbFX22T70yQNUGuW3DfA==", + "dependencies": { + "react-router": "7.9.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", + "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.0", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.4", + "@img/sharp-darwin-x64": "0.34.4", + "@img/sharp-libvips-darwin-arm64": "1.2.3", + "@img/sharp-libvips-darwin-x64": "1.2.3", + "@img/sharp-libvips-linux-arm": "1.2.3", + "@img/sharp-libvips-linux-arm64": "1.2.3", + "@img/sharp-libvips-linux-ppc64": "1.2.3", + "@img/sharp-libvips-linux-s390x": "1.2.3", + "@img/sharp-libvips-linux-x64": "1.2.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", + "@img/sharp-libvips-linuxmusl-x64": "1.2.3", + "@img/sharp-linux-arm": "0.34.4", + "@img/sharp-linux-arm64": "0.34.4", + "@img/sharp-linux-ppc64": "0.34.4", + "@img/sharp-linux-s390x": "0.34.4", + "@img/sharp-linux-x64": "0.34.4", + "@img/sharp-linuxmusl-arm64": "0.34.4", + "@img/sharp-linuxmusl-x64": "0.34.4", + "@img/sharp-wasm32": "0.34.4", + "@img/sharp-win32-arm64": "0.34.4", + "@img/sharp-win32-ia32": "0.34.4", + "@img/sharp-win32-x64": "0.34.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz", + "integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", + "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zustand": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1fb6e07 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,32 @@ +{ + "name": "frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build --turbopack", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@tosspayments/tosspayments-sdk": "^2.4.0", + "axios": "^1.12.2", + "next": "15.5.6", + "react": "19.1.0", + "react-dom": "19.1.0", + "react-router-dom": "^7.9.4", + "zustand": "^5.0.8" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/react-router-dom": "^5.3.3", + "eslint": "^9", + "eslint-config-next": "15.5.6", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000..c7bcb4b --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/frontend/public/file.svg b/frontend/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/frontend/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/frontend/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/logo.png b/frontend/public/logo.png new file mode 100644 index 0000000..e59db1e Binary files /dev/null and b/frontend/public/logo.png differ diff --git a/frontend/public/mocks/data/likes.json b/frontend/public/mocks/data/likes.json new file mode 100644 index 0000000..b697c17 --- /dev/null +++ b/frontend/public/mocks/data/likes.json @@ -0,0 +1,35 @@ +[ + { + "productId": 101, + "productTitle": "๋”ฐ๋œปํ•œ ๊ฒจ์šธ ์Šค์›จํ„ฐ", + "thumbnailUrl": "https://placehold.co/300x300/925C4C/fff?text=Product1", + "price": 15000, + "averageRating": 4.8, + "likedAt": "2023-10-12" + }, + { + "productId": 102, + "productTitle": "ํฌ๊ทผํ•œ ๊ฐ€์„ ๋‹ˆํŠธ", + "thumbnailUrl": "https://placehold.co/300x300/EAD9D5/fff?text=Product2", + "price": 22000, + "averageRating": 4.5, + "likedAt": "2023-10-15" + }, + { + "productId": 103, + "productTitle": "์‹ฌํ”Œ ์บ์‹œ๋ฏธ์–ด ๋จธํ”Œ๋Ÿฌ", + "thumbnailUrl": "https://placehold.co/300x300/D5E0EA/fff?text=Product3", + "price": 18000, + "averageRating": 4.2, + "likedAt": "2023-10-18" + }, + { + "productId": 104, + "productTitle": "ํ•ธ๋“œ๋ฉ”์ด๋“œ ์šธ ์žฅ๊ฐ‘", + "thumbnailUrl": "https://placehold.co/300x300/925C4C/fff?text=Product4", + "price": 12000, + "averageRating": 4.7, + "likedAt": "2023-10-20" + } + ] + \ No newline at end of file diff --git a/frontend/public/mocks/data/productReviews.json b/frontend/public/mocks/data/productReviews.json new file mode 100644 index 0000000..9430612 --- /dev/null +++ b/frontend/public/mocks/data/productReviews.json @@ -0,0 +1,44 @@ +[ + { + "reviewId": 201, + "rating": 5, + "content": "์ •๋ง ๋ถ€๋“œ๋Ÿฝ๊ณ  ๋”ฐ๋œปํ•ฉ๋‹ˆ๋‹ค. ๊ฒจ์šธ ๋‚ด๋‚ด ์ž˜ ์ž…์„ ๊ฒƒ ๊ฐ™์•„์š”!", + "createdAt": "2025-10-15T10:30:00", + "userName": "ํ™๊ธธ๋™", + "reviewImageUrls": [ + "https://placehold.co/150x150/925C4C/ffffff?text=Review+1", + "https://placehold.co/150x150/EAD9D5/333333?text=Review+2" + ] + }, + { + "reviewId": 202, + "rating": 4, + "content": "์ด‰๊ฐ์ด ๋ถ€๋“œ๋Ÿฝ์ง€๋งŒ ์ƒ๊ฐ๋ณด๋‹ค ์–‡์•„์š”. ๋ด„/๊ฐ€์„์— ์ข‹์Šต๋‹ˆ๋‹ค.", + "createdAt": "2025-10-14T14:12:00", + "userName": "๊น€์ฒ ์ˆ˜", + "reviewImageUrls": [ + "https://placehold.co/150x150/BDC3C7/333333?text=Review+1" + ] + }, + { + "reviewId": 203, + "rating": 5, + "content": "์„ ๋ฌผ์šฉ์œผ๋กœ ์ƒ€๋Š”๋ฐ ์•„์ฃผ ์ข‹์•„ํ–ˆ์–ด์š”. ํฌ์žฅ๋„ ๊ผผ๊ผผํ•ฉ๋‹ˆ๋‹ค.", + "createdAt": "2025-10-13T09:45:00", + "userName": "์ด์˜ํฌ", + "reviewImageUrls": [] + }, + { + "reviewId": 204, + "rating": 3, + "content": "๋””์ž์ธ์€ ๋งˆ์Œ์— ๋“œ๋Š”๋ฐ, ๋ฐฐ์†ก์ด ์กฐ๊ธˆ ๋Šฆ์—ˆ์Šต๋‹ˆ๋‹ค.", + "createdAt": "2025-10-12T17:20:00", + "userName": "๋ฐ•๋ฏผ์ˆ˜", + "reviewImageUrls": [ + "https://placehold.co/150x150/34495E/ffffff?text=Review+1", + "https://placehold.co/150x150/F3E5AB/333333?text=Review+2", + "https://placehold.co/150x150/BDC3C7/333333?text=Review+3" + ] + } + ] + \ No newline at end of file diff --git a/frontend/public/mocks/data/reviews.json b/frontend/public/mocks/data/reviews.json new file mode 100644 index 0000000..abb0550 --- /dev/null +++ b/frontend/public/mocks/data/reviews.json @@ -0,0 +1,59 @@ +[ + { + "userId": 1, + "reviewId": 101, + "productId": 1, + "productTitle": "ํฌ๊ทผํ•œ ์šธ ์Šค์›จํ„ฐ (๋„ค์ด๋น„)", + "productThumbnailUrl": "https://placehold.co/100x100/34495e/ffffff?text=Knitly", + "rating": 5, + "content": "๋„ˆ๋ฌด ๋”ฐ๋œปํ•˜๊ณ  ์ƒ‰์ƒ๋„ ๋งˆ์Œ์— ๋“ญ๋‹ˆ๋‹ค. ๊ฒจ์šธ ๋‚ด๋‚ด ์ž˜ ์ž…์„ ๊ฒƒ ๊ฐ™์•„์š”! ๋ฐฐ์†ก๋„ ๋นจ๋ž์Šต๋‹ˆ๋‹ค. ์ถ”์ฒœํ•ฉ๋‹ˆ๋‹ค.", + "createdDate": "2025-10-15", + "purchasedDate": "2025-10-10", + "reviewImageUrls": [ + "https://placehold.co/200x200/34495e/ffffff?text=Image1", + "https://placehold.co/200x200/34495e/ffffff?text=Image2" + ] + }, + { + "userId": 1, + "reviewId": 102, + "productId": 3, + "productTitle": "์ฝ”ํŠผ ๋‹ˆํŠธ ๋น„๋‹ˆ (๋ฒ ์ด์ง€)", + "productThumbnailUrl": "https://placehold.co/100x100/f3e5ab/333333?text=Knitly", + "rating": 4, + "content": "์ด‰๊ฐ์ด ๋ถ€๋“œ๋Ÿฌ์›Œ์š”. ๋‹ค๋งŒ ์ƒ๊ฐ๋ณด๋‹ค ์–‡์•„์„œ ํ•œ๊ฒจ์šธ์—๋Š” ์ถ”์šธ ์ˆ˜๋„ ์žˆ๊ฒ ๋„ค์š”. ๋ด„๊ฐ€์„์— ์“ฐ๊ธฐ ์ข‹์Šต๋‹ˆ๋‹ค.", + "createdDate": "2025-10-15", + "purchasedDate": "2025-10-12", + "reviewImageUrls": [ + "https://placehold.co/200x200/f3e5ab/333333?text=Image1" + ] + }, + { + "userId": 1, + "reviewId": 103, + "productId": 5, + "productTitle": "์บ์‹œ๋ฏธ์–ด ๋จธํ”Œ๋Ÿฌ (๊ทธ๋ ˆ์ด)", + "productThumbnailUrl": "https://placehold.co/100x100/bdc3c7/333333?text=Knitly", + "rating": 5, + "content": "์„ ๋ฌผ์šฉ์œผ๋กœ ์ƒ€๋Š”๋ฐ ๋ฐ›๋Š” ๋ถ„์ด ์•„์ฃผ ์ข‹์•„ํ•˜์…จ์Šต๋‹ˆ๋‹ค. ํฌ์žฅ๋„ ๊ผผ๊ผผํ•˜๊ณ  ์žฌ์งˆ๋„ ๊ณ ๊ธ‰์Šค๋Ÿฌ์›Œ์š”.", + "createdDate": "2025-10-20", + "purchasedDate": "2025-10-18", + "reviewImageUrls": [ + "https://placehold.co/200x200/bdc3c7/333333?text=Image1", + "https://placehold.co/200x200/bdc3c7/333333?text=Image2", + "https://placehold.co/200x200/bdc3c7/333333?text=Image3" + ] + }, + { + "userId": 1, + "reviewId": 104, + "productId": 5, + "productTitle": "์บ์‹œ๋ฏธ์–ด ๋จธํ”Œ๋Ÿฌ (๋ธ”๋ž™)", + "productThumbnailUrl": "https://placehold.co/100x100/333333/ffffff?text=Knitly", + "rating": 5, + "content": "์„ ๋ฌผ์šฉ์œผ๋กœ ์ƒ€๋Š”๋ฐ ๋ฐ›๋Š” ๋ถ„์ด ์•„์ฃผ ์ข‹์•„ํ•˜์…จ์Šต๋‹ˆ๋‹ค. ํฌ์žฅ๋„ ๊ผผ๊ผผํ•˜๊ณ  ์žฌ์งˆ๋„ ๊ณ ๊ธ‰์Šค๋Ÿฌ์›Œ์š”.", + "createdDate": "2025-10-20", + "purchasedDate": "2025-10-18", + "reviewImageUrls": [] + } +] diff --git a/frontend/public/next.svg b/frontend/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/frontend/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/frontend/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/window.svg b/frontend/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/frontend/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/app/cart/page.tsx b/frontend/src/app/cart/page.tsx new file mode 100644 index 0000000..8c39e5d --- /dev/null +++ b/frontend/src/app/cart/page.tsx @@ -0,0 +1,228 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuthStore } from '@/lib/store/authStore'; +import { useCartStore, CartStoreItem } from '@/lib/store/cartStore'; + +export default function CartPage() { + const router = useRouter(); + const { user, isAuthenticated, isLoading: isAuthLoading } = useAuthStore(); + + // Zustand ์Šคํ† ์–ด์—์„œ ์ƒํƒœ์™€ ํ•จ์ˆ˜ ๊ฐ€์ ธ์˜ค๊ธฐ + const cartItems = useCartStore((state) => state.items); + const removeFromCart = useCartStore((state) => state.removeFromCart); + const clearCart = useCartStore((state) => state.clearCart); + + // ์ƒํƒœ ๊ด€๋ฆฌ + const [selectedItems, setSelectedItems] = useState>(new Set()); + const [error, setError] = useState(null); + const [isProcessingOrder, setIsProcessingOrder] = useState(false); + + // ํŽ˜์ด์ง€ ๋กœ๋“œ/cartItems ๋ณ€๊ฒฝ ์‹œ ๋ชจ๋“  ์•„์ดํ…œ ์„ ํƒ + useEffect(() => { + const allProductIds = new Set(cartItems.map((item) => item.productId)); + setSelectedItems(allProductIds); + }, [cartItems]); + + // ๊ณ„์‚ฐ ๋กœ์ง + const selectedCartItems = cartItems.filter((item) => + selectedItems.has(item.productId) + ); + const totalAmount = selectedCartItems.reduce( + (sum, item) => sum + item.price, + 0 + ); + + // ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ + const handleCheckboxChange = (productId: number) => { + setSelectedItems((prevSelected) => { + const newSelected = new Set(prevSelected); + if (newSelected.has(productId)) { + newSelected.delete(productId); + } else { + newSelected.add(productId); + } + return newSelected; + }); + }; + + const handleSelectAll = () => { + if (selectedItems.size === cartItems.length) { + setSelectedItems(new Set()); + } else { + const allProductIds = new Set(cartItems.map((item) => item.productId)); + setSelectedItems(allProductIds); + } + }; + + const handleDeleteItem = (productIdToDelete: number) => { + removeFromCart(productIdToDelete); + }; + + // โœจ ๊ฒฐ์ œํ•˜๊ธฐ ํ•ธ๋“ค๋Ÿฌ - ํ† ์ŠคํŽ˜์ด๋จผ์ธ  ๊ฒฐ์ œ ํŽ˜์ด์ง€๋กœ ์ด๋™ + const handleCheckout = () => { + if (selectedItems.size === 0) { + alert('๊ฒฐ์ œํ•  ์ƒํ’ˆ์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”.'); + return; + } + + if (!isAuthenticated) { + alert('๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.'); + return; + } + + // ์„ ํƒ๋œ ์•„์ดํ…œ ์ •๋ณด๋ฅผ ๊ฒฐ์ œ ํŽ˜์ด์ง€๋กœ ์ „๋‹ฌ + const selectedItemsData = selectedCartItems.map(item => ({ + productId: item.productId, + title: item.title, + price: item.price, + imageUrl: item.imageUrl, + })); + + // ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์ธ์ฝ”๋”ฉํ•˜์—ฌ ์ „๋‹ฌ + const itemsParam = encodeURIComponent(JSON.stringify(selectedItemsData)); + router.push(`/checkout?items=${itemsParam}`); + }; + + // ๋ Œ๋”๋ง ๋กœ์ง + if (isAuthLoading) { + return ( +
+
+
+ ); + } + + if (!isAuthenticated && !isAuthLoading) { + return ( +
+

์žฅ๋ฐ”๊ตฌ๋‹ˆ

+
+

์˜ค๋ฅ˜: ์žฅ๋ฐ”๊ตฌ๋‹ˆ๋ฅผ ๋ณด๋ ค๋ฉด ๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

+
+
+ ); + } + + return ( +
+

์žฅ๋ฐ”๊ตฌ๋‹ˆ

+ + {cartItems.length === 0 ? ( +
+ ์žฅ๋ฐ”๊ตฌ๋‹ˆ๊ฐ€ ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค. +
+ ) : ( +
+ {/* ์žฅ๋ฐ”๊ตฌ๋‹ˆ ๋ชฉ๋ก (์™ผ์ชฝ) */} +
+
+ +
+ + {cartItems.map((item) => ( +
+ handleCheckboxChange(item.productId)} + /> + {item.imageUrl && ( + {item.title} { + (e.target as HTMLImageElement).src = + `https://placehold.co/100x100/CCCCCC/FFFFFF?text=No+Image`; + }} + /> + )} +
+

{item.title}

+

+ {item.price.toLocaleString()}์› +

+
+ +
+ ))} +
+ + {/* ์ฃผ๋ฌธ ์š”์•ฝ (์˜ค๋ฅธ์ชฝ) */} +
+
+

์ฃผ๋ฌธ ์š”์•ฝ

+
+ {selectedCartItems.length === 0 ? ( +

์„ ํƒ๋œ ์ƒํ’ˆ์ด ์—†์Šต๋‹ˆ๋‹ค.

+ ) : ( + selectedCartItems.map((item) => ( +
+ {item.title} + + {item.price.toLocaleString()}์› + +
+ )) + )} +
+
+
+ ์ด ์ฃผ๋ฌธ ๊ธˆ์•ก + + {totalAmount.toLocaleString()}์› + +
+ + {error && !isProcessingOrder && ( +

{error}

+ )} +
+
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/checkout/fail/page.tsx b/frontend/src/app/checkout/fail/page.tsx new file mode 100644 index 0000000..d788a45 --- /dev/null +++ b/frontend/src/app/checkout/fail/page.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import Link from 'next/link'; + +export default function PaymentFailPage() { + const searchParams = useSearchParams(); + + const errorCode = searchParams.get('code'); + const errorMessage = searchParams.get('message'); + const orderId = searchParams.get('orderId'); + + // ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ๋งคํ•‘ + const getErrorDisplayMessage = (code: string | null) => { + if (!code) return errorMessage || '์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'; + + const errorMessages: { [key: string]: string } = { + 'PAY_PROCESS_CANCELED': '์‚ฌ์šฉ์ž๊ฐ€ ๊ฒฐ์ œ๋ฅผ ์ทจ์†Œํ–ˆ์Šต๋‹ˆ๋‹ค.', + 'PAY_PROCESS_ABORTED': '๊ฒฐ์ œ ์ง„ํ–‰ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.', + 'REJECT_CARD_COMPANY': '์นด๋“œ์‚ฌ์—์„œ ์Šน์ธ์„ ๊ฑฐ๋ถ€ํ–ˆ์Šต๋‹ˆ๋‹ค.', + 'EXCEED_MAX_CARD_INSTALLMENT_PLAN': '์„ค์ • ๊ฐ€๋Šฅํ•œ ์ตœ๋Œ€ ํ• ๋ถ€ ๊ฐœ์›”์ˆ˜๋ฅผ ์ดˆ๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค.', + 'INVALID_CARD_EXPIRATION': '์œ ํšจํ•˜์ง€ ์•Š์€ ์นด๋“œ ์œ ํšจ๊ธฐ๊ฐ„์ž…๋‹ˆ๋‹ค.', + 'NOT_SUPPORTED_CARD': '์ง€์›ํ•˜์ง€ ์•Š๋Š” ์นด๋“œ์ž…๋‹ˆ๋‹ค.', + 'INCORRECT_BASIC_AUTH_FORMAT': '์ž˜๋ชป๋œ ์ธ์ฆ ์ •๋ณด์ž…๋‹ˆ๋‹ค.', + 'NOT_FOUND_PAYMENT': '์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ฒฐ์ œ ์ •๋ณด์ž…๋‹ˆ๋‹ค.', + 'NOT_FOUND_PAYMENT_SESSION': '๊ฒฐ์ œ ์‹œ๊ฐ„์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + 'FAILED_PAYMENT_INTERNAL_SYSTEM_PROCESSING': '๊ฒฐ์ œ๊ฐ€ ์™„๋ฃŒ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.', + }; + + return errorMessages[code] || errorMessage || '๊ฒฐ์ œ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'; + }; + + return ( +
+
+ {/* ์‹คํŒจ ์•„์ด์ฝ˜ */} +
+
+ + + +
+

๊ฒฐ์ œ ์‹คํŒจ

+

+ {getErrorDisplayMessage(errorCode)} +

+
+ + {/* ์—๋Ÿฌ ์ƒ์„ธ ์ •๋ณด */} + {(errorCode || orderId) && ( +
+ {orderId && ( +
+ ์ฃผ๋ฌธ๋ฒˆํ˜ธ + {orderId} +
+ )} + {errorCode && ( +
+ ์—๋Ÿฌ ์ฝ”๋“œ + {errorCode} +
+ )} +
+ )} + + {/* ์•ˆ๋‚ด ๋ฉ”์‹œ์ง€ */} +
+

+ ๐Ÿ’ก ๊ฒฐ์ œ๊ฐ€ ์ง„ํ–‰๋˜์ง€ ์•Š์•˜์œผ๋ฉฐ, ๊ธˆ์•ก์ด ์ฒญ๊ตฌ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. +

+
+ + {/* ๋ฒ„ํŠผ ์˜์—ญ */} +
+ + ์žฅ๋ฐ”๊ตฌ๋‹ˆ๋กœ ๋Œ์•„๊ฐ€๊ธฐ + + + ํ™ˆ์œผ๋กœ ๊ฐ€๊ธฐ + +
+ + {/* ๊ณ ๊ฐ์„ผํ„ฐ ์•ˆ๋‚ด */} +
+

+ ๋ฌธ์ œ๊ฐ€ ๊ณ„์† ๋ฐœ์ƒํ•˜์‹œ๋‚˜์š”? +

+

+ ๊ณ ๊ฐ์„ผํ„ฐ: 1234-5678 +

+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/checkout/page.tsx b/frontend/src/app/checkout/page.tsx new file mode 100644 index 0000000..0d7035b --- /dev/null +++ b/frontend/src/app/checkout/page.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { loadTossPayments } from '@tosspayments/tosspayments-sdk'; +import { useAuthStore } from '@/lib/store/authStore'; + +// ํ™˜๊ฒฝ ๋ณ€์ˆ˜ +const clientKey = process.env.NEXT_PUBLIC_TOSS_CLIENT_KEY; +const API_URL = process.env.NEXT_PUBLIC_API_URL; + +// ์žฅ๋ฐ”๊ตฌ๋‹ˆ์—์„œ ์ „๋‹ฌ๋ฐ›๋Š” ์•„์ดํ…œ ํƒ€์ž… +interface CheckoutItem { + productId: number; + title: string; + price: number; + imageUrl?: string; +} + +export default function CheckoutPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { user, isAuthenticated } = useAuthStore(); + + const [ready, setReady] = useState(false); + const [items, setItems] = useState([]); + const [tossOrderId, setTossOrderId] = useState(''); // tossOrderId๋กœ ๋ณ€๊ฒฝ + const paymentWidgetRef = useRef(null); + + // 1. ์ธ์ฆ ํ™•์ธ + useEffect(() => { + if (!isAuthenticated) { + alert('๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.'); + router.push('/'); + } + }, [isAuthenticated, router]); + + // 2. ์žฅ๋ฐ”๊ตฌ๋‹ˆ ์•„์ดํ…œ ๋กœ๋“œ + useEffect(() => { + const itemsParam = searchParams.get('items'); + if (itemsParam) { + try { + const parsedItems = JSON.parse(decodeURIComponent(itemsParam)); + setItems(parsedItems); + } catch (error) { + console.error('์•„์ดํ…œ ํŒŒ์‹ฑ ์‹คํŒจ:', error); + alert('์ž˜๋ชป๋œ ๊ฒฐ์ œ ์ •๋ณด์ž…๋‹ˆ๋‹ค.'); + router.push('/cart'); + } + } else { + alert('๊ฒฐ์ œํ•  ์ƒํ’ˆ์ด ์—†์Šต๋‹ˆ๋‹ค.'); + router.push('/cart'); + } + }, [searchParams, router]); + + // 3. ์ฃผ๋ฌธ ์ƒ์„ฑ ๋ฐ ํ† ์ŠคํŽ˜์ด๋จผ์ธ  ์œ„์ ฏ ์ดˆ๊ธฐํ™” + useEffect(() => { + if (!user || items.length === 0) return; + + const initializePayment = async () => { + try { + // 3-1. ์ฃผ๋ฌธ ์ƒ์„ฑ (๋ฐฑ์—”๋“œ์—์„œ tossOrderId ์ž๋™ ์ƒ์„ฑ) + const accessToken = localStorage.getItem('accessToken'); + const productIds = items.map(item => item.productId); + + const orderResponse = await fetch(`${API_URL}/orders`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}`, + }, + body: JSON.stringify({ productIds }), + }); + + if (!orderResponse.ok) { + throw new Error('์ฃผ๋ฌธ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + + const orderData = await orderResponse.json(); + + // โœ… ๋ฐฑ์—”๋“œ์—์„œ ์ƒ์„ฑ๋œ tossOrderId๋ฅผ ์‚ฌ์šฉ + const generatedTossOrderId = orderData.tossOrderId; + if (!generatedTossOrderId) { + throw new Error('์ฃผ๋ฌธ ๋ฒˆํ˜ธ(tossOrderId)๋ฅผ ๋ฐ›์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + + setTossOrderId(generatedTossOrderId); + console.log('์ƒ์„ฑ๋œ tossOrderId:', generatedTossOrderId); + + // 3-2. ํ† ์ŠคํŽ˜์ด๋จผ์ธ  SDK ๋กœ๋“œ ๋ฐ ์œ„์ ฏ ์ดˆ๊ธฐํ™” + const tossPayments = await loadTossPayments(clientKey); + + const totalAmount = items.reduce((sum, item) => sum + item.price, 0); + const orderName = items.length > 1 + ? `${items[0].title} ์™ธ ${items.length - 1}๊ฑด` + : items[0].title; + + // ๊ฒฐ์ œ์œ„์ ฏ ๋ Œ๋”๋ง + const paymentWidget = tossPayments.widgets({ + customerKey: `customer_${user.userId}`, + }); + + await paymentWidget.setAmount({ + currency: 'KRW', + value: totalAmount, + }); + + await Promise.all([ + // ๊ฒฐ์ œ UI ๋ Œ๋”๋ง + paymentWidget.renderPaymentMethods({ + selector: '#payment-method', + variantKey: 'DEFAULT', + }), + // ์ด์šฉ์•ฝ๊ด€ UI ๋ Œ๋”๋ง + paymentWidget.renderAgreement({ + selector: '#agreement', + variantKey: 'AGREEMENT', + }), + ]); + + paymentWidgetRef.current = paymentWidget; + setReady(true); + + } catch (error) { + console.error('๊ฒฐ์ œ ์ดˆ๊ธฐํ™” ์‹คํŒจ:', error); + alert('๊ฒฐ์ œ ์ค€๋น„ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + router.push('/cart'); + } + }; + + initializePayment(); + }, [user, items, router]); + + // 4. ๊ฒฐ์ œ ์š”์ฒญ + const handlePayment = async () => { + if (!paymentWidgetRef.current || !tossOrderId) { + alert('๊ฒฐ์ œ ์ค€๋น„๊ฐ€ ์™„๋ฃŒ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.'); + return; + } + + try { + const totalAmount = items.reduce((sum, item) => sum + item.price, 0); + const orderName = items.length > 1 + ? `${items[0].title} ์™ธ ${items.length - 1}๊ฑด` + : items[0].title; + + // โœ… ๋ฐฑ์—”๋“œ์—์„œ ๋ฐ›์€ tossOrderId๋ฅผ ์‚ฌ์šฉ + console.log('๊ฒฐ์ œ ์š”์ฒญ - tossOrderId:', tossOrderId); + + await paymentWidgetRef.current.requestPayment({ + orderId: tossOrderId, // โœ… ๋ฐฑ์—”๋“œ์—์„œ ์ƒ์„ฑ๋œ ์˜ฌ๋ฐ”๋ฅธ ํ˜•์‹์˜ tossOrderId + orderName: orderName, + successUrl: `${window.location.origin}/checkout/success`, + failUrl: `${window.location.origin}/checkout/fail`, + customerEmail: user?.email, + customerName: user?.username || user?.name, + }); + } catch (error) { + console.error('๊ฒฐ์ œ ์š”์ฒญ ์‹คํŒจ:', error); + alert('๊ฒฐ์ œ ์š”์ฒญ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }; + + // ๋กœ๋”ฉ ์ค‘ + if (!isAuthenticated || items.length === 0) { + return ( +
+
+
+ ); + } + + return ( +
+

๊ฒฐ์ œํ•˜๊ธฐ

+ + {/* ์ฃผ๋ฌธ ์ƒํ’ˆ ๋ชฉ๋ก */} +
+

์ฃผ๋ฌธ ์ƒํ’ˆ

+ {items.map((item, index) => ( +
+ {item.title} + {item.price.toLocaleString()}์› +
+ ))} +
+
+ ์ด ๊ฒฐ์ œ๊ธˆ์•ก + + {items.reduce((sum, item) => sum + item.price, 0).toLocaleString()}์› + +
+
+
+ + {/* ํ† ์ŠคํŽ˜์ด๋จผ์ธ  ๊ฒฐ์ œ UI */} +
+
+
+
+ + {/* ๊ฒฐ์ œํ•˜๊ธฐ ๋ฒ„ํŠผ */} + +
+ ); +} diff --git a/frontend/src/app/checkout/success/page.tsx b/frontend/src/app/checkout/success/page.tsx new file mode 100644 index 0000000..f7c0a69 --- /dev/null +++ b/frontend/src/app/checkout/success/page.tsx @@ -0,0 +1,204 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api/v1'; + +export default function PaymentSuccessPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [isProcessing, setIsProcessing] = useState(true); + const [paymentInfo, setPaymentInfo] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const confirmPayment = async () => { + // URL ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ์—์„œ ๊ฒฐ์ œ ์ •๋ณด ์ถ”์ถœ + const paymentKey = searchParams.get('paymentKey'); + const orderId = searchParams.get('orderId'); + const amount = searchParams.get('amount'); + + if (!paymentKey || !orderId || !amount) { + setError('๊ฒฐ์ œ ์ •๋ณด๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.'); + setIsProcessing(false); + return; + } + + try { + // ๋ฐฑ์—”๋“œ ๊ฒฐ์ œ ์Šน์ธ API ํ˜ธ์ถœ + const response = await fetch(`${API_URL}/payments/confirm`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('accessToken')}`, + }, + body: JSON.stringify({ + paymentKey, + orderId, + amount: parseInt(amount), + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || '๊ฒฐ์ œ ์Šน์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + + const data = await response.json(); + setPaymentInfo(data); + setIsProcessing(false); + + // ์žฅ๋ฐ”๊ตฌ๋‹ˆ์—์„œ ๊ฒฐ์ œ๋œ ์•„์ดํ…œ ์ œ๊ฑฐ (์„ ํƒ์‚ฌํ•ญ) + // localStorage์—์„œ ์žฅ๋ฐ”๊ตฌ๋‹ˆ ๊ด€๋ฆฌํ•˜๋Š” ๊ฒฝ์šฐ + // ์‹ค์ œ ๊ตฌํ˜„์—์„œ๋Š” ๋ฐฑ์—”๋“œ์—์„œ ์ฒ˜๋ฆฌํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค. + + } catch (error: any) { + console.error('๊ฒฐ์ œ ์Šน์ธ ์‹คํŒจ:', error); + setError(error.message || '๊ฒฐ์ œ ์Šน์ธ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + setIsProcessing(false); + } + }; + + confirmPayment(); + }, [searchParams]); + + // ๋กœ๋”ฉ ์ค‘ + if (isProcessing) { + return ( +
+
+
+

๊ฒฐ์ œ๋ฅผ ์ฒ˜๋ฆฌํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค...

+

์ž ์‹œ๋งŒ ๊ธฐ๋‹ค๋ ค์ฃผ์„ธ์š”.

+
+
+ ); + } + + // ์—๋Ÿฌ ๋ฐœ์ƒ + if (error) { + return ( +
+
+
+ + + +
+

๊ฒฐ์ œ ์‹คํŒจ

+

{error}

+
+ + ์žฅ๋ฐ”๊ตฌ๋‹ˆ๋กœ + + + ํ™ˆ์œผ๋กœ + +
+
+
+ ); + } + + // ๊ฒฐ์ œ ์„ฑ๊ณต + return ( +
+
+ {/* ์„ฑ๊ณต ์•„์ด์ฝ˜ */} +
+
+ + + +
+

๊ฒฐ์ œ๊ฐ€ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!

+

์ฃผ๋ฌธ์ด ์ •์ƒ์ ์œผ๋กœ ์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

+
+ + {/* ๊ฒฐ์ œ ์ •๋ณด */} + {paymentInfo && ( +
+
+ ์ฃผ๋ฌธ๋ฒˆํ˜ธ + {paymentInfo.orderId} +
+
+ ๊ฒฐ์ œ๊ธˆ์•ก + + {paymentInfo.totalAmount?.toLocaleString()}์› + +
+
+ ๊ฒฐ์ œ์ˆ˜๋‹จ + + {paymentInfo.method === 'CARD' ? '์นด๋“œ' : + paymentInfo.method === 'TRANSFER' ? '๊ณ„์ขŒ์ด์ฒด' : + paymentInfo.method === 'VIRTUAL_ACCOUNT' ? '๊ฐ€์ƒ๊ณ„์ขŒ' : + paymentInfo.method} + +
+ {paymentInfo.approvedAt && ( +
+ ์Šน์ธ์‹œ๊ฐ„ + + {new Date(paymentInfo.approvedAt).toLocaleString('ko-KR')} + +
+ )} +
+ )} + + {/* ์•ˆ๋‚ด ๋ฉ”์‹œ์ง€ */} +
+

+ ๐Ÿ’ก ์ฃผ๋ฌธ ๋‚ด์—ญ์€ ๋งˆ์ดํŽ˜์ด์ง€์—์„œ ํ™•์ธํ•˜์‹ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. +

+
+ + {/* ๋ฒ„ํŠผ ์˜์—ญ */} +
+ + ์ฃผ๋ฌธ ๋‚ด์—ญ ๋ณด๊ธฐ + + + ์‡ผํ•‘ ๊ณ„์†ํ•˜๊ธฐ + +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/community/free/page.tsx b/frontend/src/app/community/free/page.tsx new file mode 100644 index 0000000..32b0e65 --- /dev/null +++ b/frontend/src/app/community/free/page.tsx @@ -0,0 +1,213 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { getPosts } from '@/lib/api/community.api'; +import { PostListItem, CATEGORY_LABELS } from '@/types/community.types'; + +export default function FreePage() { + const router = useRouter(); + + const [posts, setPosts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const [currentPage, setCurrentPage] = useState(0); + const [totalPages, setTotalPages] = useState(0); + const [isLastPage, setIsLastPage] = useState(false); + + const [searchQuery, setSearchQuery] = useState(''); + const [searchInput, setSearchInput] = useState(''); + + const fetchPosts = async (page: number = 0, query?: string) => { + try { + setIsLoading(true); + setError(null); + + const response = await getPosts('FREE', query, page, 10); + + setPosts(response.content); + setCurrentPage(response.page); + setTotalPages(response.totalPages); + setIsLastPage(response.last); + } catch (err: any) { + console.error('๊ฒŒ์‹œ๊ธ€ ๋ชฉ๋ก ์กฐํšŒ ์‹คํŒจ:', err); + setError(err.response?.data?.message || '๊ฒŒ์‹œ๊ธ€ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchPosts(0, searchQuery); + }, [searchQuery]); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setCurrentPage(0); + setSearchQuery(searchInput); + }; + + const handlePageChange = (page: number) => { + setCurrentPage(page); + fetchPosts(page, searchQuery); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + const handlePostClick = (postId: number) => { + router.push(`/community/posts/${postId}`); + }; + + const handleWriteClick = () => { + router.push('/community/posts/write'); + }; + + return ( +
+
+
+

์ž์œ  ๊ฒŒ์‹œํŒ

+ +
+ +
+ setSearchInput(e.target.value)} + placeholder="๊ฒ€์ƒ‰์–ด๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”" + className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#925C4C]" + /> + +
+
+ +
+ {isLoading && posts.length === 0 ? ( +
+
+
+ ) : error ? ( +
+

{error}

+ +
+ ) : posts.length === 0 ? ( +
+

๊ฒŒ์‹œ๊ธ€์ด ์—†์Šต๋‹ˆ๋‹ค.

+
+ ) : ( + <> +
+ {posts.map((post) => ( +
handlePostClick(post.id)} + className="border border-gray-200 rounded-lg p-4 hover:border-[#925C4C] cursor-pointer transition-colors" + > +
+
+ {post.thumbnailUrl ? ( + {post.title} + ) : ( +
+ ์ด๋ฏธ์ง€ +
+ )} +
+ +
+
+ + {CATEGORY_LABELS[post.category]} + +
+ +

+ {post.title} +

+ +

+ {post.excerpt} +

+ +
+ ์ž‘์„ฑ์ž {post.authorDisplay} + {new Date(post.createdAt).toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + })} + ๋Œ“๊ธ€์ˆ˜ {post.commentCount} +
+
+
+
+ ))} +
+ + {totalPages > 1 && ( +
+ + + {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + const startPage = Math.max(0, Math.min(currentPage - 2, totalPages - 5)); + const page = startPage + i; + + return ( + + ); + })} + + +
+ )} + + )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/community/layout.tsx b/frontend/src/app/community/layout.tsx new file mode 100644 index 0000000..1431b9f --- /dev/null +++ b/frontend/src/app/community/layout.tsx @@ -0,0 +1,57 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { ReactNode } from 'react'; + +interface CommunityLayoutProps { + children: ReactNode; +} + +const menuItems = [ + { label: '์ „์ฒด', href: '/community' }, + { label: '์ž์œ ', href: '/community/free' }, + { label: '์งˆ๋ฌธ', href: '/community/question' }, + { label: 'ํŒ', href: '/community/tip' }, +]; + +export default function CommunityLayout({ children }: CommunityLayoutProps) { + const pathname = usePathname(); + + const isActive = (href: string) => { + return pathname === href; + }; + + return ( +
+
+ {/* ์™ผ์ชฝ ์‚ฌ์ด๋“œ๋ฐ” */} + + + {/* ๋ฉ”์ธ ์ปจํ…์ธ  */} +
+ {children} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/community/page.tsx b/frontend/src/app/community/page.tsx new file mode 100644 index 0000000..bcbbf5a --- /dev/null +++ b/frontend/src/app/community/page.tsx @@ -0,0 +1,230 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { getPosts } from '@/lib/api/community.api'; +import { PostListItem, CATEGORY_LABELS } from '@/types/community.types'; + +export default function CommunityPage() { + const router = useRouter(); + + const [posts, setPosts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // ํŽ˜์ด์ง€๋„ค์ด์…˜ + const [currentPage, setCurrentPage] = useState(0); + const [totalPages, setTotalPages] = useState(0); + const [isLastPage, setIsLastPage] = useState(false); + + // ๊ฒ€์ƒ‰ + const [searchQuery, setSearchQuery] = useState(''); + const [searchInput, setSearchInput] = useState(''); + + // ๊ฒŒ์‹œ๊ธ€ ๋ชฉ๋ก ์กฐํšŒ (์ „์ฒด) + const fetchPosts = async (page: number = 0, query?: string) => { + try { + setIsLoading(true); + setError(null); + + const response = await getPosts(null, query, page, 10); + + setPosts(response.content); + setCurrentPage(response.page); + setTotalPages(response.totalPages); + setIsLastPage(response.last); + } catch (err: any) { + console.error('๊ฒŒ์‹œ๊ธ€ ๋ชฉ๋ก ์กฐํšŒ ์‹คํŒจ:', err); + setError(err.response?.data?.message || '๊ฒŒ์‹œ๊ธ€ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchPosts(0, searchQuery); + }, [searchQuery]); + + // ๊ฒ€์ƒ‰ ์ฒ˜๋ฆฌ + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setCurrentPage(0); + setSearchQuery(searchInput); + }; + + // ํŽ˜์ด์ง€ ๋ณ€๊ฒฝ + const handlePageChange = (page: number) => { + setCurrentPage(page); + fetchPosts(page, searchQuery); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + // ๊ฒŒ์‹œ๊ธ€ ์ƒ์„ธ๋กœ ์ด๋™ + const handlePostClick = (postId: number) => { + router.push(`/community/posts/${postId}`); + }; + + // ๊ธ€์“ฐ๊ธฐ ๋ฒ„ํŠผ + const handleWriteClick = () => { + router.push('/community/posts/write'); + }; + + return ( +
+ {/* ํ—ค๋” */} +
+
+

์ปค๋ฎค๋‹ˆํ‹ฐ

+ +
+ + {/* ๊ฒ€์ƒ‰ */} +
+ setSearchInput(e.target.value)} + placeholder="๊ฒ€์ƒ‰์–ด๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”" + className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#925C4C]" + /> + +
+
+ + {/* ๊ฒŒ์‹œ๊ธ€ ๋ชฉ๋ก */} +
+ {isLoading && posts.length === 0 ? ( +
+
+
+ ) : error ? ( +
+

{error}

+ +
+ ) : posts.length === 0 ? ( +
+

๊ฒŒ์‹œ๊ธ€์ด ์—†์Šต๋‹ˆ๋‹ค.

+
+ ) : ( + <> +
+ {posts.map((post) => ( +
handlePostClick(post.id)} + className="border border-gray-200 rounded-lg p-4 hover:border-[#925C4C] cursor-pointer transition-colors" + > +
+ {/* ์ธ๋„ค์ผ */} +
+ {post.thumbnailUrl ? ( + {post.title} + ) : ( +
+ ์ด๋ฏธ์ง€ +
+ )} +
+ + {/* ๋‚ด์šฉ */} +
+ {/* ์นดํ…Œ๊ณ ๋ฆฌ */} +
+ + {CATEGORY_LABELS[post.category]} + +
+ + {/* ์ œ๋ชฉ */} +

+ {post.title} +

+ + {/* ๋‚ด์šฉ ๋ฏธ๋ฆฌ๋ณด๊ธฐ */} +

+ {post.excerpt} +

+ + {/* ๋ฉ”ํƒ€ ์ •๋ณด */} +
+ ์ž‘์„ฑ์ž {post.authorDisplay} + {new Date(post.createdAt).toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + })} + ๋Œ“๊ธ€์ˆ˜ {post.commentCount} +
+
+
+
+ ))} +
+ + {/* ํŽ˜์ด์ง€๋„ค์ด์…˜ */} + {totalPages > 1 && ( +
+ + + {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + const startPage = Math.max(0, Math.min(currentPage - 2, totalPages - 5)); + const page = startPage + i; + + return ( + + ); + })} + + +
+ )} + + )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/community/question/page.tsx b/frontend/src/app/community/question/page.tsx new file mode 100644 index 0000000..3c1dfec --- /dev/null +++ b/frontend/src/app/community/question/page.tsx @@ -0,0 +1,213 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { getPosts } from '@/lib/api/community.api'; +import { PostListItem, CATEGORY_LABELS } from '@/types/community.types'; + +export default function QuestionPage() { + const router = useRouter(); + + const [posts, setPosts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const [currentPage, setCurrentPage] = useState(0); + const [totalPages, setTotalPages] = useState(0); + const [isLastPage, setIsLastPage] = useState(false); + + const [searchQuery, setSearchQuery] = useState(''); + const [searchInput, setSearchInput] = useState(''); + + const fetchPosts = async (page: number = 0, query?: string) => { + try { + setIsLoading(true); + setError(null); + + const response = await getPosts('QUESTION', query, page, 10); + + setPosts(response.content); + setCurrentPage(response.page); + setTotalPages(response.totalPages); + setIsLastPage(response.last); + } catch (err: any) { + console.error('๊ฒŒ์‹œ๊ธ€ ๋ชฉ๋ก ์กฐํšŒ ์‹คํŒจ:', err); + setError(err.response?.data?.message || '๊ฒŒ์‹œ๊ธ€ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchPosts(0, searchQuery); + }, [searchQuery]); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setCurrentPage(0); + setSearchQuery(searchInput); + }; + + const handlePageChange = (page: number) => { + setCurrentPage(page); + fetchPosts(page, searchQuery); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + const handlePostClick = (postId: number) => { + router.push(`/community/posts/${postId}`); + }; + + const handleWriteClick = () => { + router.push('/community/posts/write'); + }; + + return ( +
+
+
+

์งˆ๋ฌธ ๊ฒŒ์‹œํŒ

+ +
+ +
+ setSearchInput(e.target.value)} + placeholder="๊ฒ€์ƒ‰์–ด๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”" + className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#925C4C]" + /> + +
+
+ +
+ {isLoading && posts.length === 0 ? ( +
+
+
+ ) : error ? ( +
+

{error}

+ +
+ ) : posts.length === 0 ? ( +
+

๊ฒŒ์‹œ๊ธ€์ด ์—†์Šต๋‹ˆ๋‹ค.

+
+ ) : ( + <> +
+ {posts.map((post) => ( +
handlePostClick(post.id)} + className="border border-gray-200 rounded-lg p-4 hover:border-[#925C4C] cursor-pointer transition-colors" + > +
+
+ {post.thumbnailUrl ? ( + {post.title} + ) : ( +
+ ์ด๋ฏธ์ง€ +
+ )} +
+ +
+
+ + {CATEGORY_LABELS[post.category]} + +
+ +

+ {post.title} +

+ +

+ {post.excerpt} +

+ +
+ ์ž‘์„ฑ์ž {post.authorDisplay} + {new Date(post.createdAt).toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + })} + ๋Œ“๊ธ€์ˆ˜ {post.commentCount} +
+
+
+
+ ))} +
+ + {totalPages > 1 && ( +
+ + + {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + const startPage = Math.max(0, Math.min(currentPage - 2, totalPages - 5)); + const page = startPage + i; + + return ( + + ); + })} + + +
+ )} + + )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/community/tip/page.tsx b/frontend/src/app/community/tip/page.tsx new file mode 100644 index 0000000..ffed6b9 --- /dev/null +++ b/frontend/src/app/community/tip/page.tsx @@ -0,0 +1,213 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { getPosts } from '@/lib/api/community.api'; +import { PostListItem, CATEGORY_LABELS } from '@/types/community.types'; + +export default function TipPage() { + const router = useRouter(); + + const [posts, setPosts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const [currentPage, setCurrentPage] = useState(0); + const [totalPages, setTotalPages] = useState(0); + const [isLastPage, setIsLastPage] = useState(false); + + const [searchQuery, setSearchQuery] = useState(''); + const [searchInput, setSearchInput] = useState(''); + + const fetchPosts = async (page: number = 0, query?: string) => { + try { + setIsLoading(true); + setError(null); + + const response = await getPosts('TIP', query, page, 10); + + setPosts(response.content); + setCurrentPage(response.page); + setTotalPages(response.totalPages); + setIsLastPage(response.last); + } catch (err: any) { + console.error('๊ฒŒ์‹œ๊ธ€ ๋ชฉ๋ก ์กฐํšŒ ์‹คํŒจ:', err); + setError(err.response?.data?.message || '๊ฒŒ์‹œ๊ธ€ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchPosts(0, searchQuery); + }, [searchQuery]); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setCurrentPage(0); + setSearchQuery(searchInput); + }; + + const handlePageChange = (page: number) => { + setCurrentPage(page); + fetchPosts(page, searchQuery); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + const handlePostClick = (postId: number) => { + router.push(`/community/posts/${postId}`); + }; + + const handleWriteClick = () => { + router.push('/community/posts/write'); + }; + + return ( +
+
+
+

ํŒ ๊ฒŒ์‹œํŒ

+ +
+ +
+ setSearchInput(e.target.value)} + placeholder="๊ฒ€์ƒ‰์–ด๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”" + className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#925C4C]" + /> + +
+
+ +
+ {isLoading && posts.length === 0 ? ( +
+
+
+ ) : error ? ( +
+

{error}

+ +
+ ) : posts.length === 0 ? ( +
+

๊ฒŒ์‹œ๊ธ€์ด ์—†์Šต๋‹ˆ๋‹ค.

+
+ ) : ( + <> +
+ {posts.map((post) => ( +
handlePostClick(post.id)} + className="border border-gray-200 rounded-lg p-4 hover:border-[#925C4C] cursor-pointer transition-colors" + > +
+
+ {post.thumbnailUrl ? ( + {post.title} + ) : ( +
+ ์ด๋ฏธ์ง€ +
+ )} +
+ +
+
+ + {CATEGORY_LABELS[post.category]} + +
+ +

+ {post.title} +

+ +

+ {post.excerpt} +

+ +
+ ์ž‘์„ฑ์ž {post.authorDisplay} + {new Date(post.createdAt).toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + })} + ๋Œ“๊ธ€์ˆ˜ {post.commentCount} +
+
+
+
+ ))} +
+ + {totalPages > 1 && ( +
+ + + {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + const startPage = Math.max(0, Math.min(currentPage - 2, totalPages - 5)); + const page = startPage + i; + + return ( + + ); + })} + + +
+ )} + + )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/components/CommunityLayout.tsx b/frontend/src/app/components/CommunityLayout.tsx new file mode 100644 index 0000000..bee0bd9 --- /dev/null +++ b/frontend/src/app/components/CommunityLayout.tsx @@ -0,0 +1,60 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { ReactNode } from 'react'; + +interface MyPageLayoutProps { + children: ReactNode; +} + +const menuItems = [ + { label: '์ „์ฒด', href: '/community', exact: true }, + { label: '์ž์œ ', href: '/community?category=FREE', category: 'FREE' }, + { label: '์งˆ๋ฌธ', href: '/community?category=QUESTION', category: 'QUESTION' }, + { label: 'ํŒ', href: '/community?category=TIP', category: 'TIP' }, +]; + +export default function MyPageLayout({ children }: MyPageLayoutProps) { + const pathname = usePathname(); + + const isActive = (href: string, exact?: boolean) => { + if (exact) { + return pathname === href; + } + return pathname.startsWith(href); + }; + + return ( +
+
+ {/* ์™ผ์ชฝ ์‚ฌ์ด๋“œ๋ฐ” */} + + + {/* ๋ฉ”์ธ ์ปจํ…์ธ  */} +
+ {children} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/components/DesignForm.tsx b/frontend/src/app/components/DesignForm.tsx new file mode 100644 index 0000000..388da0f --- /dev/null +++ b/frontend/src/app/components/DesignForm.tsx @@ -0,0 +1,522 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import api from '@/lib/api/axios'; +import { ProductRegisterResponse, ProductModifyResponse } from '@/types/product.types'; + +// 1. ํผ ๋ฐ์ดํ„ฐ ํƒ€์ž… +export interface DesignSalesData { + id: string; // ์ƒํ’ˆ ID (productId) + name: string; // ์ƒํ’ˆ ์ด๋ฆ„ (์‚ฌ์šฉ์ž๊ฐ€ ์ž…๋ ฅ/์ˆ˜์ • ๊ฐ€๋Šฅ) + registeredAt?: string; // (์ˆ˜์ •) ๋“ฑ๋ก์ผ์€ ์ด์ œ ํ•„์ˆ˜๊ฐ€ ์•„๋‹˜ (์ˆ˜์ • ์‹œ์—๋งŒ ํ‘œ์‹œ) + images: string[]; // ๊ธฐ์กด ์ƒ˜ํ”Œ ์ด๋ฏธ์ง€ URL ๋ชฉ๋ก + category: '์ƒ์˜' | 'ํ•˜์˜' | '์•„์šฐํ„ฐ' | '๊ฐ€๋ฐฉ' | '๊ธฐํƒ€' | ''; // (์ˆ˜์ •) '๊ฐ€๋ฐฉ' ์ถ”๊ฐ€ + price: number; + isFree: boolean; + isLimited: boolean; + stock: number; + description: string; + designType: string; // ๊ตฌ๋ถ„ + size: string; // ์‚ฌ์ด์ฆˆ +} + +// (๊ฐ€์ •) 'ํŒ๋งค ๋“ฑ๋ก' ์‹œ ๋ฐ›์•„์˜ฌ *๊ธฐ๋ณธ* ๋„์•ˆ ์ •๋ณด ํƒ€์ž… +// (์ˆ˜์ •) ๋“ฑ๋ก์ผ(registeredAt) ์ œ๊ฑฐ - ์ด์ œ ํ•„์š” ์—†์Œ +export interface BaseDesignData { + id: string; // ๋„์•ˆ ID (designId) + name: string; // ์›๋ณธ ๋„์•ˆ PDF ์ด๋ฆ„ (์ฐธ๊ณ ์šฉ, ์ˆ˜์ • ๋ถˆ๊ฐ€) +} + +// 2. ์ปดํฌ๋„ŒํŠธ Props ์ •์˜ +interface DesignFormProps { + isEditMode: boolean; // true: ์ˆ˜์ • ๋ชจ๋“œ, false: ๋“ฑ๋ก ๋ชจ๋“œ + initialData?: Partial | Partial; // ํƒ€์ž…์€ ๊ทธ๋Œ€๋กœ ์œ ์ง€ + entityId: string; // ๋“ฑ๋ก ์‹œ: designId, ์ˆ˜์ • ์‹œ: productId +} + +const mapCategoryToEnum = ( + category: DesignSalesData['category'] +): string => { + switch (category) { + case '์ƒ์˜': + return 'TOP'; + case 'ํ•˜์˜': + return 'BOTTOM'; + case '์•„์šฐํ„ฐ': + return 'OUTER'; + case '๊ฐ€๋ฐฉ': + return 'BAG'; + case '๊ธฐํƒ€': + return 'ETC'; + default: + return ''; // ํ˜น์€ ์˜ค๋ฅ˜ ์ฒ˜๋ฆฌ + } +}; + +// 3. ์ปดํฌ๋„ŒํŠธ ํ•จ์ˆ˜ ์ด๋ฆ„ +export default function DesignForm({ + isEditMode, + initialData, + entityId, +}: DesignFormProps) { + const router = useRouter(); + + // 4. ํผ ์ƒํƒœ ๊ด€๋ฆฌ + const [name, setName] = useState(''); + const [originalDesignName, setOriginalDesignName] = useState(''); + const [selectedFiles, setSelectedFiles] = useState([]); + const [imagePreviews, setImagePreviews] = useState([]); + const [existingImages, setExistingImages] = useState([]); + const [category, setCategory] = useState(''); + const [price, setPrice] = useState(''); + const [isFree, setIsFree] = useState(false); + const [isLimited, setIsLimited] = useState(false); + const [stock, setStock] = useState(''); + const [description, setDescription] = useState(''); + const [designType, setDesignType] = useState(''); + const [size, setSize] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // 5. ์ดˆ๊ธฐ ๋ฐ์ดํ„ฐ ์„ค์ • (์ˆ˜์ •) + useEffect(() => { + if (initialData) { + // ๋“ฑ๋ก ๋ชจ๋“œ: ์›๋ณธ PDF ์ด๋ฆ„๋งŒ ์ฐธ๊ณ ์šฉ์œผ๋กœ ์ €์žฅ + if (!isEditMode) { + setOriginalDesignName(initialData.name || '์›๋ณธ ์ด๋ฆ„ ๋กœ๋“œ ์‹คํŒจ'); + // ์ƒํ’ˆ ์ด๋ฆ„์€ ๋นˆ ์นธ์œผ๋กœ ์‹œ์ž‘ + setName(''); + } + // ์ˆ˜์ • ๋ชจ๋“œ: ๋ชจ๋“  ๋ฐ์ดํ„ฐ ์ฑ„์šฐ๊ธฐ (์ƒํ’ˆ ์ด๋ฆ„ ํฌํ•จ) + else if ('price' in initialData) { + const data = initialData as DesignSalesData; + setName(data.name || ''); // ์ˆ˜์ • ์‹œ์—๋Š” ๊ธฐ์กด ์ƒํ’ˆ ์ด๋ฆ„ ๋กœ๋“œ + setOriginalDesignName(data.name || ''); // ์ˆ˜์ • ์‹œ ์ฐธ๊ณ ์šฉ ์ด๋ฆ„๋„ ์ผ๋‹จ ์ƒํ’ˆ๋ช…์œผ๋กœ + setExistingImages(data.images || []); + setCategory(data.category || ''); + setPrice(data.price || 0); + setIsFree(data.isFree || false); + setIsLimited(data.isLimited || false); + setStock(data.stock || 0); + setDescription(data.description || ''); + setDesignType(data.designType || ''); + setSize(data.size || ''); + } + } + }, [initialData, isEditMode]); + + // 6. ์ด๋ฏธ์ง€ ํŒŒ์ผ ํ•ธ๋“ค๋Ÿฌ (์ดํ•˜ ๋™์ผ) + const handleImageChange = (e: React.ChangeEvent) => { + if (e.target.files) { + const files = Array.from(e.target.files); + if (files.length + existingImages.length > 10) { + alert('์ƒ˜ํ”Œ ์ด๋ฏธ์ง€๋Š” ์ตœ๋Œ€ 10๊ฐœ๊นŒ์ง€ ๋“ฑ๋กํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.'); + return; + } + const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg']; + const invalidFiles = files.filter( + (file) => !allowedTypes.includes(file.type) + ); + if (invalidFiles.length > 0) { + alert('png, jpg, jpeg ํŒŒ์ผ ํ˜•์‹๋งŒ ๋“ฑ๋กํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.'); + return; + } + setSelectedFiles(files); + const previews = files.map((file) => URL.createObjectURL(file)); + setImagePreviews(previews); + } + }; + + const handleRemoveExistingImage = (index: number) => { + const updated = [...existingImages]; + updated.splice(index, 1); + setExistingImages(updated); + }; + + + // 7. '๋ฌด๋ฃŒ' ์ฒดํฌ๋ฐ•์Šค ํ•ธ๋“ค๋Ÿฌ + const handleFreeCheck = (e: React.ChangeEvent) => { + const checked = e.target.checked; + setIsFree(checked); + if (checked) setPrice(0); + }; + + // 8. 'ํ•œ์ •' ์ฒดํฌ๋ฐ•์Šค ํ•ธ๋“ค๋Ÿฌ + const handleLimitedCheck = (e: React.ChangeEvent) => { + const checked = e.target.checked; + setIsLimited(checked); + if (checked) { + setStock(''); // ํ•œ์ • ์ฒดํฌ ์‹œ ์žฌ๊ณ  ์ž…๋ ฅ์นธ์„ ๋นˆ์นธ์œผ๋กœ ์ดˆ๊ธฐํ™” + } else { + setStock(''); // ํ•œ์ • ํ•ด์ œ ์‹œ ์žฌ๊ณ ๊ฐ’ ์ดˆ๊ธฐํ™” (0์œผ๋กœ ๊ณ ์ • X) + } + }; + + console.log('์นดํ…Œ๊ณ ๋ฆฌ ๊ฐ’:', category); + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!name.trim() && isEditMode) { + alert('์ƒํ’ˆ ์ด๋ฆ„์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.'); + return; + } + if (!category) { + alert('์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ ์„ ํƒํ•ด์ฃผ์„ธ์š”.'); + return; + } + + setIsLoading(true); + setError(null); + + // โ–ผโ–ผโ–ผ [์ˆ˜์ •] Access Token์„ localStorage์—์„œ ๊ฐ€์ ธ์˜ค๋Š” ๋กœ์ง ์ถ”๊ฐ€ โ–ผโ–ผโ–ผ + const accessToken = localStorage.getItem('accessToken'); + + // 1. ํ† ํฐ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ (์—†์œผ๋ฉด ์ธ์ฆ ์‹คํŒจ ์ฒ˜๋ฆฌ) + if (!accessToken) { + setError('๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.'); + setIsLoading(false); + return; + } + + + const formData = new FormData(); + let endpoint = ''; + let method = ''; + + // --- ๋ฐฑ์—”๋“œ ProductRegisterRequest DTO์™€ ํ•„๋“œ๋ช… ์ผ์น˜ --- + + // 1. DTO์˜ 'title' ํ•„๋“œ + formData.append('title', name.trim()); + + // 2. DTO์˜ 'description' ํ•„๋“œ + formData.append('description', description); + + // 3. DTO์˜ 'productCategory' ํ•„๋“œ (Enum ๊ฐ’์œผ๋กœ ๋งคํ•‘) + formData.append('productCategory', mapCategoryToEnum(category)); + + // 4. DTO์˜ 'sizeInfo' ํ•„๋“œ + formData.append('sizeInfo', size); + + // 5. DTO์˜ 'price' ํ•„๋“œ + formData.append('price', String(isFree ? 0 : price)); + + // 6. DTO์˜ 'stockQuantity' ํ•„๋“œ (ํ•œ์ • ํŒ๋งค์ผ ๋•Œ๋งŒ ์ „์†ก) + if (isLimited) { + formData.append('stockQuantity', String(stock)); + } + + // 7. DTO์˜ 'productImageUrls' ํ•„๋“œ (List) + selectedFiles.forEach((file) => { + formData.append('productImageUrls', file); + }); + + try { + if (isEditMode) { + const endpoint = `http://localhost:8080/my/products/${entityId}/modify`; + + existingImages.forEach((url) => { + formData.append('existingImageUrls', url); + }); + + const res = await fetch(endpoint, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + body: formData, // DTO: ProductModifyRequest + }); + + if (!res.ok) throw new Error('์ƒํ’ˆ ์ˆ˜์ • ์‹คํŒจ'); + const responseData: ProductModifyResponse = await res.json(); + alert(`์ƒํ’ˆ(ID: ${responseData.productId}) ์ˆ˜์ •์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.`); + router.push('/mypage/design'); + return; + } + + + + const endpoint = `http://localhost:8080/my/products/${entityId}/sale`; + + const res = await fetch(endpoint, { + method: 'POST', + headers: { + // fetch API์—์„œ FormData๋ฅผ ์‚ฌ์šฉํ•  ๋•Œ Content-Type์€ ๋ช…์‹œํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. + // ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์ž๋™์œผ๋กœ 'multipart/form-data; boundary=...'๋ฅผ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. + 'Authorization': `Bearer ${accessToken}`, // ๐Ÿ‘ˆ ์ธ์ฆ ํ—ค๋”๋งŒ ๋ช…์‹œ์ ์œผ๋กœ ์‚ฝ์ž… + }, + body: formData, // FormData ๊ฐ์ฒด๋ฅผ body์— ์ง์ ‘ ๋„ฃ์Šต๋‹ˆ๋‹ค. + }); + + if (!res.ok) { + if (res.status === 401) { + throw new Error('์ธ์ฆ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ๋กœ๊ทธ์ธํ•ด์ฃผ์„ธ์š”.'); + } + // ๋ฐฑ์—”๋“œ์—์„œ JSON ์—๋Ÿฌ ์‘๋‹ต์„ ์ฃผ์ง€ ์•Š์„ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ์•ˆ์ „ํ•˜๊ฒŒ ์ฒ˜๋ฆฌ + const errorText = await res.text(); + try { + const errorData = JSON.parse(errorText); + throw new Error(errorData.message || `์š”์ฒญ ์‹คํŒจ (Status: ${res.status})`); + } catch { + // JSON ํŒŒ์‹ฑ ์‹คํŒจ ์‹œ ๊ธฐ๋ณธ ๋ฉ”์‹œ์ง€ ์‚ฌ์šฉ + throw new Error(`์š”์ฒญ ์‹คํŒจ (Status: ${res.status})`); + } + } + + // ์„ฑ๊ณต ์‹œ ์‘๋‹ต์„ JSON์œผ๋กœ ํŒŒ์‹ฑ + const responseData: ProductRegisterResponse = await res.json(); + + alert(`์ƒํ’ˆ(ID: ${responseData.productId})์ด ๋“ฑ๋ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค.`); + router.push('/mypage/design'); // (๊ฐ€์ •) ๋“ฑ๋ก ํ›„ ๋‚ด ๋„์•ˆ ๋ชฉ๋ก์œผ๋กœ ์ด๋™ + + } catch (err: any) { + console.error(err); + setError(err.message || '์š”์ฒญ ์ฒ˜๋ฆฌ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } finally { + setIsLoading(false); + } + }; + + // ํผ UI ๋ Œ๋”๋ง + return ( +
+ {/* (์ˆ˜์ •) ๋„์•ˆ ์ด๋ฆ„ -> ์ƒํ’ˆ ์ด๋ฆ„์œผ๋กœ ๋ณ€๊ฒฝ, ์ž…๋ ฅ ๊ฐ€๋Šฅํ•˜๋„๋ก ์ˆ˜์ • */} + + setName(e.target.value)} + placeholder="ํŒ๋งคํ•  ์ƒํ’ˆ์˜ ์ด๋ฆ„์„ ์ž…๋ ฅํ•˜์„ธ์š”" + required // ์ด๋ฆ„์€ ํ•„์ˆ˜ ์ž…๋ ฅ + className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-[#925C4C] focus:border-transparent transition-colors" + /> + {/* (์ถ”๊ฐ€) ์›๋ณธ PDF ์ด๋ฆ„ ์ฐธ๊ณ ์šฉ ํ‘œ์‹œ (๋“ฑ๋ก ์‹œ์—๋งŒ) */} + {!isEditMode && originalDesignName && ( +

+ (์›๋ณธ ๋„์•ˆ ํŒŒ์ผ๋ช…: {originalDesignName}) +

+ )} +
+ + {/* ์ƒ˜ํ”Œ ์ด๋ฏธ์ง€ ๋“ฑ๋ก */} + + +

+ ์ตœ๋Œ€ 10๊ฐœ, png/jpg/jpeg ํ˜•์‹๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. +

+ + {/* โœ… ๊ธฐ์กด ์ด๋ฏธ์ง€ ๋ฏธ๋ฆฌ๋ณด๊ธฐ + ์‚ญ์ œ ๋ฒ„ํŠผ */} +
+ {existingImages?.length > 0 && ( +
+ {existingImages.map((imgUrl, index) => ( +
+ ๊ธฐ์กด ์ด๋ฏธ์ง€ + +
+ ))} +
+ )} + + + {/* โœ… ์ƒˆ๋กœ ์ฒจ๋ถ€ํ•œ ์ด๋ฏธ์ง€ */} + {imagePreviews.map((previewUrl, index) => ( +
+ ์ƒˆ ์ด๋ฏธ์ง€ +
+ ))} +
+
+ + + {/* ์นดํ…Œ๊ณ ๋ฆฌ (์ˆ˜์ •: '๊ฐ€๋ฐฉ' ์ถ”๊ฐ€) */} + + + + + + {/* ๊ฐ€๊ฒฉ */} + +
+ { + const value = e.target.value; + if (value === '') setPrice(''); + else setPrice(Number(value)); + }} + placeholder="๊ฐ€๊ฒฉ์„ ์ž…๋ ฅํ•˜์„ธ์š”" + required={!isFree} + min="0" + disabled={isFree || isEditMode} // โœ… ์ˆ˜์ • ๋ชจ๋“œ/๋ฌด๋ฃŒ์ผ ๋•Œ ๋ชจ๋‘ ๋น„ํ™œ์„ฑํ™” + className={ + isFree || isEditMode + ? 'w-32 p-2 border border-gray-300 rounded-md bg-gray-100 text-gray-500 cursor-not-allowed' + : 'w-32 p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-[#925C4C] focus:border-transparent transition-colors' + } + /> + +
+ + {/* ์•ˆ๋‚ด ๋ฌธ๊ตฌ ์ฒ˜๋ฆฌ */} + {isEditMode ? ( +

+ ๋“ฑ๋ก๋œ ์ƒํ’ˆ์˜ ๊ฐ€๊ฒฉ์€ ์ˆ˜์ •ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. +

+ ) : isFree ? ( +

+ ๋ฌด๋ฃŒ ์ƒํ’ˆ์€ ๊ฐ€๊ฒฉ์„ ์ž…๋ ฅํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. +

+ ) : null} +
+ + + + {/* ํ•œ์ • ์—ฌ๋ถ€ */} + +
+ + + {isLimited && ( + { + const value = e.target.value; + if (value === '') setStock(''); // ์‚ฌ์šฉ์ž๊ฐ€ ๋ชจ๋‘ ์ง€์šฐ๋ฉด ๋นˆ ๋ฌธ์ž์—ด๋กœ ์œ ์ง€ + else setStock(Number(value)); // ์ˆซ์ž ์ž…๋ ฅ ์‹œ ๋ณ€ํ™˜ + }} + placeholder="์žฌ๊ณ  ์ž…๋ ฅ" + required={isLimited} + min="0" + className="w-32 p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-[#925C4C] focus:border-transparent transition-colors" + /> + )} +
+
+ + + {/* ๋„์•ˆ ์„ค๋ช… */} + +