diff --git a/README.md b/README.md index 8e3e4ce8..0ac83b82 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,168 @@ -# ๐Ÿš€ STARLIGHT_BE Server +# Starlight Server +๋Œ€ํ•™์ƒ IT๊ฒฝ์˜ํ•™ํšŒ ํ์‹œ์ฆ˜ 32๊ธฐ ๋ฐ‹์—… ํ”„๋กœ์ ํŠธ 4์กฐ Starlight ๋ฐฑ์—”๋“œ ๋ ˆํฌ์ง€ํ† ๋ฆฌ +1 -[![Java](https://img.shields.io/badge/Java-21-ED8B00?style=flat-square&logo=openjdk&logoColor=white)](https://openjdk.java.net/) -[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.4.9-6DB33F?style=flat-square&logo=spring&logoColor=white)](https://spring.io/projects/spring-boot) +

-> **STARLIGHT ์„œ๋น„์Šค ๋ฐฑ์—”๋“œ API ์„œ๋ฒ„** +## ๐Ÿ‘ฌ Member +| ์ •์„ฑํ˜ธ | ์ดํ˜ธ๊ทผ | +| :------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------: | +| ์ •์„ฑํ˜ธ | ์ดํ˜ธ๊ทผ | +| [@SeongHo5356](https://github.com/SeongHo5356) | [@2ghrms](https://github.com/2ghrms) | -## ๐ŸŽฏ ํ”„๋กœ์ ํŠธ ์†Œ๊ฐœ +

-Spring Boot๋กœ ๋งŒ๋“  REST API ์„œ๋ฒ„์ž…๋‹ˆ๋‹ค. +## ๐Ÿ“ Technology Stack +| Category | Technology | +|----------------------|---------------------------------------------------------------------------| +| **Language** | Java 21 | +| **Framework** | Spring Boot 3.3.10 | +| **Databases** | Postgresql, Redis | +| **Authentication** | JWT, Spring Security, OAuth2.0 | +| **Development Tools**| Lombok | +| **API Documentation**| Swagger UI (SpringDoc) | +| **Storage** | AWS S3, Naver Object Storage | +| **Infrastructure** | Terraform, NCP Server, HashiCorp Vault | +| **Build Tools** | Gradle | +| **Monitoring** | Prometheus, Grafana, Loki, Promtail | -## ๐Ÿ‘ฅ ํŒ€ ์ž‘์—… ๋ฐฉ์‹ +

-> **Issue ๊ธฐ๋ฐ˜ ๊ฐœ๋ฐœ ์›Œํฌํ”Œ๋กœ์šฐ**๋ฅผ ๋”ฐ๋ฆ…๋‹ˆ๋‹ค. ๋ชจ๋“  ์ž‘์—…์€ Issue์—์„œ ์‹œ์ž‘ํ•˜์—ฌ PR๋กœ ์™„๋ฃŒ๋ฉ๋‹ˆ๋‹ค. +## ๐Ÿ“… ERD +https://www.erdcloud.com/d/bEeEkcvDoau3kf7W5 +แ„‰แ…ณแ„แ…กแ„…แ…กแ„‹แ…ตแ„แ…ณ ERD แ„Œแ…ฆแ„Žแ…ฎแ†ฏแ„‡แ…ฉแ†ซ (1) -### ๐ŸŒณ ๋ธŒ๋žœ์น˜ ์‚ฌ์šฉ๋ฒ• -> JIRA๋ฅผ ํ†ตํ•ด์„œ ์ž๋™์ƒ์„ฑ +

-### ๐Ÿ’ฌ ์ปค๋ฐ‹ ๋ฉ”์‹œ์ง€ (Issue ๋ฒˆํ˜ธ ํฌํ•จ) -> Docs: (SRLT-25) README ์—…๋ฐ์ดํŠธ -> Feat: (SRLT-12) ๋กœ๊ทธ์ธ ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•œ๋‹ค -> Fix: (SRLT-23) ํšŒ์›๊ฐ€์ž… ์˜ค๋ฅ˜๋ฅผ ์ˆ˜์ •ํ•œ๋‹ค +## ๐Ÿ”จ Project Architecture +image (2) +

-### ๐Ÿ’ฌ PR ๋ฐ ISSUE ์ œ๋ชฉ -> [SRLT-25] ๋กœ๊ทธ์ธ ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•œ๋‹ค -> [SRLT-23] ํšŒ์›๊ฐ€์ž… ์˜ค๋ฅ˜๋ฅผ ์ˆ˜์ •ํ•œ๋‹ค -> [SRLT-12] README ์—…๋ฐ์ดํŠธ +## โญ๏ธ ๊ธฐ์ˆ ์Šคํƒ/์„ ์ •์ด์œ  -## ๐Ÿ”„ ๊ฐœ๋ฐœ ํ๋ฆ„ (Issue โ†’ Branch โ†’ PR) +**1๏ธโƒฃย Java 21** -> 1. **Issue ์ƒ์„ฑ/ํ™•์ธ** โ†’ GitHub Issues์—์„œ ์ž‘์—…ํ•  ์ด์Šˆ ์ƒ์„ฑ ๋˜๋Š” ํ• ๋‹น๋ฐ›๊ธฐ -> 2. **๋ธŒ๋žœ์น˜ ์ƒ์„ฑ** โ†’ `feat/์ด์Šˆ๋ฒˆํ˜ธ-๊ธฐ๋Šฅ๋ช…` ์œผ๋กœ Issue ๊ธฐ๋ฐ˜ ๋ธŒ๋žœ์น˜ ๋งŒ๋“ค๊ธฐ -> 3. **์ฝ”๋”ฉ** โ†’ Issue ์š”๊ตฌ์‚ฌํ•ญ์— ๋งž๋Š” ๊ธฐ๋Šฅ ๊ฐœ๋ฐœ -> 4. **ํ…Œ์ŠคํŠธ** โ†’ ๋กœ์ปฌ์—์„œ ์ž˜ ๋Œ์•„๊ฐ€๋Š”์ง€ ํ™•์ธ -> 5. **PR ์ƒ์„ฑ** โ†’ Pull Request ์˜ฌ๋ฆฌ๊ธฐ (Issue ๋ฒˆํ˜ธ ์—ฐ๊ฒฐ) -> 6. **์ฝ”๋“œ ๋ฆฌ๋ทฐ** โ†’ ํŒ€์›๋“ค์ด ์ฝ”๋“œ ํ™•์ธ -> 7. **๋จธ์ง€** โ†’ dev ๋ธŒ๋žœ์น˜์— ํ•ฉ์น˜๊ธฐ -> main ๋ธŒ๋žœ์น˜์— ๋ฐฐํฌ +- Java 21์€ ์ตœ์‹  ์–ธ์–ด ๊ธฐ๋Šฅ(์˜ˆ: ํŒจํ„ด ๋งค์นญ, ๋ ˆ์ฝ”๋“œ, ๊ฐ€์ƒ ์Šค๋ ˆ๋“œ ๋“ฑ)์„ ์ œ๊ณตํ•˜์—ฌ ์ฝ”๋“œ์˜ ๊ฐ€๋…์„ฑ๊ณผ ์œ ์ง€๋ณด์ˆ˜์„ฑ์„ ๋†’์ด๋ฉฐ, ๊ฐœ๋ฐœ ์ƒ์‚ฐ์„ฑ์„ ํ–ฅ์ƒ์‹œํ‚ต๋‹ˆ๋‹ค. +- ์ตœ์‹  ๋ฒ„์ „์˜ ์ž๋ฐ”๋Š” ์„ฑ๋Šฅ ์ตœ์ ํ™”์™€ ํšจ์œจ์ ์ธ ๋ฉ”๋ชจ๋ฆฌ ๊ด€๋ฆฌ ๊ธฐ๋Šฅ์ด ๊ฐœ์„ ๋˜์–ด, ๋Œ€๊ทœ๋ชจ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ๋„ ์•ˆ์ •์ ์ด๊ณ  ๋น ๋ฅธ ์‹คํ–‰์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. +- ์žฅ๊ธฐ ์ง€์› ๋ฒ„์ „์ด๋ฏ€๋กœ, ์•ž์œผ๋กœ์˜ ์œ ์ง€๋ณด์ˆ˜์™€ ์•ˆ์ •์„ฑ ์ธก๋ฉด์—์„œ ์‹ ๋ขฐํ•  ์ˆ˜ ์žˆ๋Š” ๊ธฐ๋ฐ˜์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. +**2๏ธโƒฃ SpringBoot 3.4.9** +- ํด๋ผ์šฐ๋“œ ๋„ค์ดํ‹ฐ๋ธŒ ์ตœ์ ํ™”: Jakarta EE ๊ธฐ๋ฐ˜์˜ ๋†’์€ ์„ฑ์ˆ™๋„๋ฅผ ๊ฐ–์ถ”๊ณ  ์žˆ์œผ๋ฉฐ, ํ—ฌ์Šค์ฒดํฌยทActuatorยทMicrometer ๋“ฑ ์ปจํ…Œ์ด๋„ˆ ๋ฐ ํด๋ผ์šฐ๋“œ ์šด์˜์— ํ•„์ˆ˜์ ์ธ ๊ด€์ธก์„ฑ ๊ธฐ๋Šฅ์„ ๊ธฐ๋ณธ์œผ๋กœ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. +- ์„ฑ๋Šฅ ๋ฐ ๋ณด์•ˆ ๊ฐ•ํ™”: ๊ฐœ์„ ๋œ AOT/Native ์ด๋ฏธ์ง€ ์ง€์›์„ ํ†ตํ•ด ์ฝœ๋“œ์Šคํƒ€ํŠธ๋ฅผ ์ตœ์ ํ™”ํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์‹ ์†ํ•œ ๋ณด์•ˆ ํŒจ์น˜๋กœ ์•ˆ์ •์ ์ธ ์„œ๋น„์Šค ์šด์˜์„ ๋•์Šต๋‹ˆ๋‹ค. +- ์ƒ์‚ฐ์„ฑ ๋ฐ ์œ ์ง€๋ณด์ˆ˜: ํ‘œ์ค€ํ™”๋œ ์Šคํƒ€ํ„ฐ์™€ ๊ฐ•๋ ฅํ•œ ์ž๋™ ์„ค์ •(Auto Configuration)์œผ๋กœ ๊ฐœ๋ฐœ ๋ณต์žก๋„๋ฅผ ๋‚ฎ์ถ”๊ณ , ํŒ€ ์˜จ๋ณด๋”ฉ ๋ฐ ์œ ์ง€๋ณด์ˆ˜ ๋น„์šฉ์„ ์ตœ์†Œํ™”ํ•ฉ๋‹ˆ๋‹ค. + +**3๏ธโƒฃย SpringData JPA** + +- Spring Data JPA๋Š” ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์™€์˜ ์ธํ„ฐ๋ž™์…˜์„ ๋‹จ์ˆœํ™”ํ•˜๊ณ , ๋ถˆํ•„์š”ํ•œ ๋ณด์ผ๋Ÿฌํ”Œ๋ ˆ์ดํŠธ ์ฝ”๋“œ๋ฅผ ์ค„์—ฌ ๊ฐœ๋ฐœ ํšจ์œจ์„ฑ์„ ๋†’์—ฌ์ค๋‹ˆ๋‹ค. + +**4๏ธโƒฃ Spring AI** +- LLM ํ†ตํ•ฉ ๋‹จ์ˆœํ™”: Spring ์ƒํƒœ๊ณ„์— ํ†ตํ•ฉ๋œ AI ํ”„๋ ˆ์ž„์›Œํฌ๋กœ, ๋ณต์žกํ•œ LLM ์—ฐ๋™ ๊ณผ์ •์„ ๊ฐ„์†Œํ™”ํ•ฉ๋‹ˆ๋‹ค. +- ์œ ์—ฐํ•œ ์ถ”์ƒํ™” ๋ ˆ์ด์–ด: OpenAI, Pinecone ๋“ฑ ๋‹ค์–‘ํ•œ ์ œ๊ณต์—…์ฒด์— ๋Œ€ํ•œ ์ถ”์ƒํ™” ๋ ˆ์ด์–ด๋ฅผ ์ œ๊ณตํ•˜์—ฌ ๋ฒค๋” ๊ต์ฒด๊ฐ€ ์šฉ์ดํ•ฉ๋‹ˆ๋‹ค. +- ๋†’์€ ์ƒ์‚ฐ์„ฑ: Spring Boot ์ž๋™ ์„ค์ • ๋ฐ WebFlux๋ฅผ ์ง€์›ํ•˜๋ฉฐ, ๋ฒกํ„ฐ ๊ฒ€์ƒ‰ยทํ”„๋กฌํ”„ํŠธ ํ…œํ”Œ๋ฆฟยท์ฒด์ด๋‹ ๋“ฑ RAG ํŒจํ„ด์„ ์‰ฝ๊ฒŒ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +**5๏ธโƒฃ MySQL** +- ๊ฒ€์ฆ๋œ ์•ˆ์ •์„ฑ: ์„ฑ์ˆ™ํ•œ InnoDB ์—”์ง„๊ณผ ํ’๋ถ€ํ•œ ์šด์˜ ๋ ˆํผ๋Ÿฐ์Šค๋ฅผ ๋ณด์œ ํ•˜๊ณ  ์žˆ์œผ๋ฉฐ, TCO(์ด ์†Œ์œ  ๋น„์šฉ)๊ฐ€ ๋‚ฎ์Šต๋‹ˆ๋‹ค. +- ๊ธฐ๋Šฅ ๋ฐ ์ƒํƒœ๊ณ„: 8.x ๋ฒ„์ „์˜ CTE์™€ ์œˆ๋„์šฐ ํ•จ์ˆ˜๋กœ ๋ณต์žกํ•œ ์ฟผ๋ฆฌ์— ๋Œ€์‘ ๊ฐ€๋Šฅํ•˜๋ฉฐ, ๋ฆฌํ”Œ๋ฆฌ์ผ€์ด์…˜ยท๋ฐฑ์—…ยท๋ชจ๋‹ˆํ„ฐ๋ง ๋„๊ตฌ๊ฐ€ ์ž˜ ๊ฐ–์ถฐ์ ธ ์žˆ์Šต๋‹ˆ๋‹ค. +- OLTP ์ตœ์ ํ™”: HikariCP ์ปค๋„ฅ์…˜ ํ’€๊ณผ ์ ์ ˆํ•œ ์ธ๋ฑ์‹ฑ์„ ํ†ตํ•ด ๋Œ€๊ทœ๋ชจ ํŠธ๋ž˜ํ”ฝ์—์„œ๋„ ์•ˆ์ •์ ์ธ ์šด์˜์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. + +**6๏ธโƒฃ NCP (CLOVA Studio, Server, Object Storage)** +- ์•ˆ์ •์  ์ธํ”„๋ผ: ๋„ค์ด๋ฒ„ ํด๋ผ์šฐ๋“œ ํ”Œ๋žซํผ(NCP)์˜ ์„œ๋ฒ„ ์ธํ”„๋ผ์™€ Object Storage๋ฅผ ํ™œ์šฉํ•˜์—ฌ ์•ˆ์ •์ ์ด๊ณ  ๋ณด์•ˆ์„ฑ์ด ๋›ฐ์–ด๋‚œ ํด๋ผ์šฐ๋“œ ํ™˜๊ฒฝ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. +- AI ์„œ๋น„์Šค ์—ฐ๋™: CLOVA Studio์™€์˜ ์—ฐ๋™์„ ํ†ตํ•ด ํ”„๋กœ์ ํŠธ์˜ AI ๊ธฐ๋Šฅ์„ ํšจ์œจ์ ์œผ๋กœ ์ง€์›ํ•˜๊ณ  ์šด์˜ ํšจ์œจ์„ฑ์„ ๋†’์ž…๋‹ˆ๋‹ค. + +**7๏ธโƒฃ ArgoCD, K3s** +- ArgoCD (GitOps): Pull ๊ธฐ๋ฐ˜ ๋ฐฐํฌ๋กœ 'Git ์ƒํƒœ=๋ฐฐํฌ ์ƒํƒœ'๋ฅผ ๋ณด์žฅํ•˜๋ฉฐ, ์ž๋™ ๋™๊ธฐํ™” ๋ฐ ์ž๊ฐ€ ์น˜์œ (Self-healing)๋กœ ์šด์˜ ๋ณต์žก๋„๋ฅผ ์ค„์ž…๋‹ˆ๋‹ค. +- K3s (๊ฒฝ๋Ÿ‰ K8s): ๋ฆฌ์†Œ์Šค๊ฐ€ ์ œํ•œ๋œ ํ™˜๊ฒฝ์ด๋‚˜ ์Šคํ…Œ์ด์ง•/์†Œ๊ทœ๋ชจ ์„œ๋น„์Šค์— ์ตœ์ ํ™”๋œ ๊ฐ€๋ฒผ์šด ์ฟ ๋ฒ„๋„คํ‹ฐ์Šค์ž…๋‹ˆ๋‹ค. +- ํ‘œ์ค€ ํ˜ธํ™˜์„ฑ: Helm, Ingress ๋“ฑ ํ‘œ์ค€ K8s ๋„๊ตฌ๋ฅผ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์–ด ์ง„์ž… ์žฅ๋ฒฝ์ด ๋‚ฎ๊ณ , ์ถ”ํ›„ ๋งค๋‹ˆ์ง€๋“œ ์„œ๋น„์Šค๋กœ์˜ ํ™•์žฅ์ด ์‰ฝ์Šต๋‹ˆ๋‹ค. + +**8๏ธโƒฃ Promtail, Loki, Prometheus, Grafana** +- ๋กœ๊ทธ ์ˆ˜์ง‘ ๋ฐ ๊ฒ€์ƒ‰ (Loki): Promtail์ด ์ˆ˜์ง‘ํ•œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋ฐ ์‹œ์Šคํ…œ ๋กœ๊ทธ๋ฅผ Loki๋กœ ์ „์†กํ•˜์—ฌ ๋Œ€์šฉ๋Ÿ‰ ๋กœ๊ทธ๋ฅผ ํšจ์œจ์ ์œผ๋กœ ์ธ๋ฑ์‹ฑํ•˜๊ณ  ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค. +- ๋ฉ”ํŠธ๋ฆญ ๋ชจ๋‹ˆํ„ฐ๋ง (Prometheus): Pull ๋ฐฉ์‹์„ ํ†ตํ•ด API ์‘๋‹ต ์‹œ๊ฐ„, CPU, ๋ฉ”๋ชจ๋ฆฌ ๋“ฑ์˜ ์‹œ๊ณ„์—ด ๋ฐ์ดํ„ฐ๋ฅผ ์ˆ˜์ง‘ํ•ฉ๋‹ˆ๋‹ค. +- ๋ฐ์ดํ„ฐ ์‹œ๊ฐํ™” (Grafana): ์ˆ˜์ง‘๋œ ๋กœ๊ทธ์™€ ๋ฉ”ํŠธ๋ฆญ ๋ฐ์ดํ„ฐ๋ฅผ ํ†ตํ•ฉ ๋Œ€์‹œ๋ณด๋“œ๋กœ ์‹œ๊ฐํ™”ํ•˜์—ฌ ์‹œ์Šคํ…œ ์ƒํƒœ๋ฅผ ํ•œ๋ˆˆ์— ํŒŒ์•…ํ•ฉ๋‹ˆ๋‹ค. + +**9๏ธโƒฃ Flannel + WireGuard VPN** +- ๋„คํŠธ์›Œํฌ ์—ฐ๊ฒฐ์„ฑ: ์„œ๋กœ ๋‹ค๋ฅธ ๋„คํŠธ์›Œํฌ(VPC, IDC, ๊ณต์ธ๋ง)์— ๋ถ„์‚ฐ๋œ ๋…ธ๋“œ ๊ฐ„์˜ ํ†ต์‹ ์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค. +- ๋ณด์•ˆ ํ†ต์‹  ๋ณด์žฅ: K3s ํด๋Ÿฌ์Šคํ„ฐ ๋‚ด์—์„œ WireGuard VPN์„ ํ†ตํ•ด ๋น ๋ฅด๊ณ  ์•ˆ์ „ํ•œ Pod-to-Pod ๋ณด์•ˆ ํ†ต์‹ ์„ ๋ณด์žฅํ•ฉ๋‹ˆ๋‹ค. + +**๐Ÿ”Ÿ Nginx** +- ๊ณ ์„ฑ๋Šฅ ๋ฆฌ๋ฒ„์Šค ํ”„๋ก์‹œ: ๊ฒฝ๋Ÿ‰ํ™”๋œ ๊ตฌ์กฐ๋กœ TLS ์ข…๋ฃŒ, ์ •์  ์ž์‚ฐ ์„œ๋น™, ๋ผ์šฐํŒ… ๋ฐ ๋ฆฌ๋ผ์ดํŠธ ์ฒ˜๋ฆฌ์— ๊ฐ•์ ์„ ๊ฐ€์ง‘๋‹ˆ๋‹ค. +- ๋ฐฑ์—”๋“œ ๋ณดํ˜ธ: ํ—ฌ์Šค์ฒดํฌ, ํƒ€์ž„์•„์›ƒ, ๋ฒ„ํผ๋ง ๋“ฑ์˜ ์„ธ๋ฐ€ํ•œ ํŠœ๋‹์„ ํ†ตํ•ด ๋ฐฑ์—”๋“œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์˜ ๋ถ€ํ•˜๋ฅผ ์ค„์ด๊ณ  ์„ฑ๋Šฅ์„ ๋ณด์žฅํ•ฉ๋‹ˆ๋‹ค. +- ์œ ์—ฐํ•œ ๋ฐฐ์น˜: ์ฟ ๋ฒ„๋„คํ‹ฐ์Šค ํ™˜๊ฒฝ์—์„œ Ingress Controller ๋˜๋Š” ์—ฃ์ง€ ํ”„๋ก์‹œ๋กœ ํ™œ์šฉํ•˜์—ฌ ํŠธ๋ž˜ํ”ฝ์„ ํšจ์œจ์ ์œผ๋กœ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. + +**1๏ธโƒฃ1๏ธโƒฃ K6** +- ์‚ฌ์šฉ์ž ํ๋ฆ„ ํ…Œ์ŠคํŠธ: JavaScript๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์‹ค์ œ ์‚ฌ์šฉ์ž ํ–‰๋™(๋กœ๊ทธ์ธ โ†’ API ํ˜ธ์ถœ โ†’ ์Šค์ผ€์ค„๋ง)์„ ์Šคํฌ๋ฆฝํŠธํ™”ํ•˜๊ณ  REST API ์„ฑ๋Šฅ์„ ์ธก์ •ํ•ฉ๋‹ˆ๋‹ค. +- ์ปค์Šคํ…€ ์‹œ๋‚˜๋ฆฌ์˜ค: vus(๊ฐ€์ƒ ์‚ฌ์šฉ์ž), stages ๋“ฑ์˜ ์˜ต์…˜์œผ๋กœ ์ŠคํŒŒ์ดํฌ ํ…Œ์ŠคํŠธ๋‚˜ ์ง€์†์„ฑ ํ…Œ์ŠคํŠธ ๋“ฑ ๋‹ค์–‘ํ•œ ๋ถ€ํ•˜ ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค. +- ๊ฒฐ๊ณผ ์‹œ๊ฐํ™”: ์‘๋‹ต ์‹œ๊ฐ„, ์ฒ˜๋ฆฌ๋Ÿ‰, ์—๋Ÿฌ์œจ ๋“ฑ์˜ ๋ฉ”ํŠธ๋ฆญ์„ ์ˆ˜์ง‘ํ•˜๊ณ  Grafana์™€ ์—ฐ๋™ํ•˜์—ฌ ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ๋ฅผ ์‹œ๊ฐ์ ์œผ๋กœ ๋ถ„์„ํ•ฉ๋‹ˆ๋‹ค. + + +

+ +## ๐Ÿ’ฌ Convention + +**commit convention**
+`#์ด์Šˆ๋ฒˆํ˜ธ conventionType: ๊ตฌํ˜„ํ•œ ๋‚ด์šฉ`

+ + +**convention Type**
+| convention type | description | +| --- | --- | +| `feat` | ์ƒˆ๋กœ์šด ๊ธฐ๋Šฅ ๊ตฌํ˜„ | +| `chore` | ๋ถ€์ˆ˜์ ์ธ ์ฝ”๋“œ ์ˆ˜์ • ๋ฐ ๊ธฐํƒ€ ๋ณ€๊ฒฝ์‚ฌํ•ญ | +| `docs` | ๋ฌธ์„œ ์ถ”๊ฐ€ ๋ฐ ์ˆ˜์ •, ์‚ญ์ œ | +| `fix` | ๋ฒ„๊ทธ ์ˆ˜์ • | +| `test` | ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ถ”๊ฐ€ ๋ฐ ์ˆ˜์ •, ์‚ญ์ œ | +| `refactor` | ์ฝ”๋“œ ๋ฆฌํŒฉํ† ๋ง | + +

+ +## ๐Ÿชต Branch +### +- `์ปจ๋ฒค์…˜๋ช…/#์ด์Šˆ๋ฒˆํ˜ธ-์ž‘์—…๋‚ด์šฉ` +- pull request๋ฅผ ํ†ตํ•ด develop branch์— merge ํ›„, branch delete +- ๋ถ€๋“์ดํ•˜๊ฒŒ develop branch์— ์ง์ ‘ commit ํ•ด์•ผ ํ•  ๊ฒฝ์šฐ, `!hotfix:` ์‚ฌ์šฉ + +

+ +## ๐Ÿ“ Directory + +```PlainText +src/ +โ”œโ”€โ”€ main/ +โ”‚ โ”œโ”€โ”€ domain/ +โ”‚ โ”‚ โ”œโ”€โ”€ entity/ +โ”‚ โ”‚ โ”œโ”€โ”€ controller/ +โ”‚ โ”‚ โ”œโ”€โ”€ service/ +โ”‚ โ”‚ โ”œโ”€โ”€ repository/ +โ”‚ โ”‚ โ””โ”€โ”€ dto/ + โ”œโ”€โ”€ request/ + โ””โ”€โ”€ response/ +โ”‚ โ”œโ”€โ”€ global/ +โ”‚ โ”‚ โ”œโ”€โ”€ apiPayload/ +โ”‚ โ”‚ โ”œโ”€โ”€ config/ +โ”‚ โ”‚ โ”œโ”€โ”€ security/ + +``` + +

+ +## ๐Ÿ“ˆ ๋ถ€ํ•˜ํ…Œ์ŠคํŠธ +๊ฐ ํ”Œ๋žซํผ(Instagram, Facebook, Threads) API์—๋Š” ๊ณ„์ •/์‹œ๊ฐ„ ๋‹น ๋ฐœํ–‰ ๊ฐ€๋Šฅํ•œ ๊ฒŒ์‹œ๋ฌผ ์ˆ˜์— ์ œํ•œ์ด ์žˆ์–ด, ๋ถ€ํ•˜ ํ…Œ์ŠคํŠธ์—๋Š” ์ œ์•ฝ์ด ์กด์žฌํ•ฉ๋‹ˆ๋‹ค. ์ด์— ๋”ฐ๋ผ ์ €ํฌ๋Š” ์ฆ‰์‹œ ๋ฐœํ–‰์ด ์•„๋‹Œ **"์˜ˆ์•ฝ ๋ฐœํ–‰" API**๋ฅผ ํ™œ์šฉํ•œ ๋ถ€ํ•˜ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ ๋ฐฉ์‹์„ ๊ตฌ์„ฑํ•˜์˜€์Šต๋‹ˆ๋‹ค. + +|์‹œ๋‚˜๋ฆฌ์˜ค โ‘  10๋ช…์ด 1์ดˆ ๋™์•ˆ ์ตœ๋Œ€ํ•œ์˜ ์š”์ฒญ์„ ๋ณด๋‚ธ๋‹ค.| ์‹œ๋‚˜๋ฆฌ์˜ค โ‘ก 2000๋ช…์ด 1์ดˆ ๋™์•ˆ ์ตœ๋Œ€ํ•œ์˜ ์š”์ฒญ์„ ๋ณด๋‚ธ๋‹ค.| +| :-------| :-------| +|![image](https://github.com/user-attachments/assets/026eb04b-4aa3-4e23-8820-5d22f1d94d12)|![image](https://github.com/user-attachments/assets/b73b7838-48e6-4392-99cd-c6497a4958d1)| +|โœ… ์ด 120๊ฐœ์˜ ์š”์ฒญ์ด ๋ฌธ์ œ์—†์ด ์ฒ˜๋ฆฌ๋จ
- ํ‰๊ท  ์š”์ฒญ ์ฒ˜๋ฆฌ ์‹œ๊ฐ„ : 82.09 ms
- ์ตœ์†Œ ์š”์ฒญ ์ฒ˜๋ฆฌ ์‹œ๊ฐ„ : 22.52ms
- ์ตœ๋Œ€ ์š”์ฒญ ์ฒ˜๋ฆฌ ์‹œ๊ฐ„ : 164.64ms |โœ… ์ด 4002๊ฐœ์˜ ์š”์ฒญ์ด ๋ฌธ์ œ์—†์ด ์ฒ˜๋ฆฌ๋จ
- ํ‰๊ท  ์š”์ฒญ ์ฒ˜๋ฆฌ ์‹œ๊ฐ„ : 7.74s
- ์ตœ์†Œ ์š”์ฒญ ์ฒ˜๋ฆฌ ์‹œ๊ฐ„ : 21.9s
- ์ตœ๋Œ€ ์š”์ฒญ ์ฒ˜๋ฆฌ ์‹œ๊ฐ„ : 18.28s
- 95th ํผ์„ผํƒ€์ผ : 14.95s| + +
+ +| ์‹œ๋‚˜๋ฆฌ์˜ค โ‘ข ์‚ฌ์šฉ์ž ์ˆ˜ ๋ณ€๋™ ์‹œ๋‚˜๋ฆฌ์˜ค | ์‹œ๋‚˜๋ฆฌ์˜ค โ‘ฃ ์‘๋‹ต ์‹œ๊ฐ„์ด 5์ดˆ ์ด๋‚ด์ธ ์ตœ๋Œ€ ์š”์ฒญ ์ˆ˜ ํŒŒ์•…| +| :-------|:----| +|![image](https://github.com/user-attachments/assets/c77e54f8-765f-4ef5-a79b-f8896eb761a7)|![image](https://github.com/user-attachments/assets/a856af66-9d1b-47df-b287-156c125bd9b3)| +|0์ดˆ ~ 2์ดˆ : `50๋ช…`, 2์ดˆ ~ 12์ดˆ : `300๋ช…`, 12์ดˆ ~ 17์ดˆ : `1000๋ช…`, 17์ดˆ ~ 18์ดˆ : `500๋ช…`| 5์ดˆ๊ฐ€ ์ง€๋‚  ๊ฒฝ์šฐ ์‚ฌ์šฉ์ž ์ดํƒˆ์ด ๋Š˜์–ด๋‚  ๊ฒƒ์ด๋ผ๊ณ  ํŒ๋‹จํ•˜์—ฌ 1์ดˆ ๋™์•ˆ `1000๋ช…`์˜ ์‚ฌ์šฉ์ž๊ฐ€ ์š”์ฒญ์„ ๋ณด๋‚ด `์š”์ฒญ ์ฒ˜๋ฆฌ ์‹œ๊ฐ„์ด 5์ดˆ ์ด๋‚ด`์ธ ์š”์ฒญ ๊ฐœ์ˆ˜๋ฅผ ํŒŒ์•… | +|โœ… ์ด 3789๊ฐœ์˜ ์š”์ฒญ์ด ๋ฌธ์ œ์—†์ด ์ฒ˜๋ฆฌ๋จ
- ํ‰๊ท  ์š”์ฒญ ์ฒ˜๋ฆฌ ์‹œ๊ฐ„ : 1.94s
- ์ตœ์†Œ ์š”์ฒญ ์ฒ˜๋ฆฌ ์‹œ๊ฐ„ : 20.53ms
- ์ตœ๋Œ€ ์š”์ฒญ ์ฒ˜๋ฆฌ ์‹œ๊ฐ„ : 7.88s |โœ… ์ด 2002๊ฐœ์˜ ์š”์ฒญ์ด ์‹œ๊ฐ„ ๋‚ด ์ฒ˜๋ฆฌ๋จ | + +### ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ ๋ถ„์„ +- ํ˜„์žฌ ์‹œ์Šคํ…œ์€ ๋™์‹œ ์•ฝ `1,000๊ฑด` ์ˆ˜์ค€๊นŒ์ง€๋Š” ์•ˆ์ •์ ์œผ๋กœ ์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋Š” ๊ฒƒ์œผ๋กœ ๋ณด์ž…๋‹ˆ๋‹ค. **์‹œ๋‚˜๋ฆฌ์˜ค โ‘ข**์ฒ˜๋Ÿผ ์‚ฌ์šฉ์ž ์ˆ˜๊ฐ€ ์ ์ฐจ ์ฆ๊ฐ€ํ•˜๋Š” ์ƒํ™ฉ์—์„œ๋„ ํ‰๊ท  ์‘๋‹ต ์‹œ๊ฐ„์€ `1.94์ดˆ`, ์ตœ๋Œ€ ์‘๋‹ต ์‹œ๊ฐ„์€ `7.88์ดˆ`๋กœ, ๋Œ€๋ถ€๋ถ„์˜ ์š”์ฒญ์ด ์ •์ƒ์ ์œผ๋กœ ์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. +- ํ•˜์ง€๋งŒ **์‹œ๋‚˜๋ฆฌ์˜ค โ‘ก**์ฒ˜๋Ÿผ `2,000๋ช…`์˜ ๋™์‹œ ์š”์ฒญ์ด ๋“ค์–ด์˜ค๋ฉด ํ‰๊ท  ์‘๋‹ต ์‹œ๊ฐ„์ด `7.74์ดˆ`, ์ตœ๋Œ€ `18.28์ดˆ`๊นŒ์ง€ ์ฆ๊ฐ€ํ•˜๋ฉด์„œ ์‘๋‹ต ์ง€์—ฐ์ด ๋ฐœ์ƒํ•˜์˜€์Šต๋‹ˆ๋‹ค. ์ด ๊ฒฐ๊ณผ๋Š” ๋Œ€๊ทœ๋ชจ ํŠธ๋ž˜ํ”ฝ์— ๋Œ€ํ•œ ์„ฑ๋Šฅ ํ•œ๊ณ„๊ฐ€ ์žˆ์Œ์„ ๋ณด์—ฌ์ฃผ๋ฉฐ, ์ถ”ํ›„ ์ด๋ฅผ ๊ฐœ์„ ํ•  ํ•„์š”๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. +- **์‹œ๋‚˜๋ฆฌ์˜ค โ‘ฃ**์—์„œ๋Š” `1000๋ช…`์˜ ์‚ฌ์šฉ์ž๊ฐ€ ๋™์‹œ์— ์š”์ฒญ์„ ๋ณด๋‚ธ ๊ฒฝ์šฐ, ์ด `2,002๊ฑด`์˜ ์š”์ฒญ์ด `5์ดˆ` ์ด๋‚ด์— ์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ด๋Š” ํ˜„์žฌ ์‹œ์Šคํ…œ์ด ์‹ค์‹œ๊ฐ„ ๋Œ€์‘๋ณด๋‹ค๋Š” ์˜ˆ์•ฝ ์ฒ˜๋ฆฌ์— ๋” ์ ํ•ฉํ•œ ๊ตฌ์กฐ์ž„์„ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค. +- ์ผ๋ฐ˜์ ์œผ๋กœ ์‚ฌ์šฉ์ž ์ดํƒˆ์ด ๋Š˜๊ธฐ ์‹œ์ž‘ํ•˜๋Š” 5์ดˆ ์ด๋‚ด ์‘๋‹ต์„ ๊ธฐ์ค€์œผ๋กœ ์˜ˆ์ƒ ์ ‘์†์ž ์ˆ˜ ์•ฝ `1,000๋ช…` ์ •๋„์— ๋Œ€ํ•ด์„œ๋Š” ์ถฉ๋ถ„ํžˆ ์•ˆ์ •์ ์ธ ์„ฑ๋Šฅ์„ ์ œ๊ณตํ•  ์ˆ˜ ์žˆ๋‹ค๊ณ  ํŒ๋‹จ๋ฉ๋‹ˆ๋‹ค. diff --git a/build.gradle b/build.gradle index 10bb23b1..d8afb991 100644 --- a/build.gradle +++ b/build.gradle @@ -19,6 +19,7 @@ java { repositories { mavenCentral() + maven { url 'https://jitpack.io' } } apply from: 'gradle/config.gradle' diff --git a/config b/config index 03c0037b..3a581e52 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 03c0037bd23ba12e28ca71e922f0e1fe525f9714 +Subproject commit 3a581e527b6117d5929ba57a0e2c8c8b90d9d14a diff --git a/src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java b/src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java index 62b52455..c90ffb21 100644 --- a/src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java +++ b/src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java @@ -70,7 +70,7 @@ public String generateReport(String content) { return chatClient .prompt(prompt) .options(ChatOptions.builder() - .temperature(0.1) + .temperature(0.0) .topP(0.1) .build()) .advisors(qaAdvisor, slAdvisor) diff --git a/src/main/java/starlight/adapter/aireport/persistence/AiReportRepository.java b/src/main/java/starlight/adapter/aireport/persistence/AiReportRepository.java index db3826b1..31c245e4 100644 --- a/src/main/java/starlight/adapter/aireport/persistence/AiReportRepository.java +++ b/src/main/java/starlight/adapter/aireport/persistence/AiReportRepository.java @@ -6,6 +6,7 @@ import java.util.Optional; public interface AiReportRepository extends JpaRepository { + Optional findByBusinessPlanId(Long businessPlanId); } diff --git a/src/main/java/starlight/adapter/businessplan/webapi/dto/SubSectionCreateRequest.java b/src/main/java/starlight/adapter/businessplan/webapi/dto/SubSectionCreateRequest.java index baf05c77..6c6ba559 100644 --- a/src/main/java/starlight/adapter/businessplan/webapi/dto/SubSectionCreateRequest.java +++ b/src/main/java/starlight/adapter/businessplan/webapi/dto/SubSectionCreateRequest.java @@ -1,6 +1,7 @@ package starlight.adapter.businessplan.webapi.dto; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; @@ -22,27 +23,39 @@ public record Meta( public record Block( @Valid @NotNull BlockMeta meta, - @Valid List<@Valid Content> content) { + @Valid List<@Valid GeneralContent> content) { } public record BlockMeta( @NotBlank String title) { } + // ํ…Œ์ด๋ธ” ์…€ ๋‚ด๋ถ€์—์„œ ์‚ฌ์šฉํ•˜๋Š” ๊ธฐ๋ณธ ์ฝ˜ํ…์ธ  (TextItem, ImageItem๋งŒ) + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", visible = true) + @JsonSubTypes({ + @JsonSubTypes.Type(value = TextItem.class, name = "text"), + @JsonSubTypes.Type(value = ImageItem.class, name = "image") + }) + public sealed interface BasicContent + permits TextItem, ImageItem { + String type(); + } + + // ๋ธ”๋ก์˜ content์—์„œ ์‚ฌ์šฉํ•˜๋Š” ์ผ๋ฐ˜ ์ฝ˜ํ…์ธ  (BasicContent + TableItem) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", visible = true) @JsonSubTypes({ @JsonSubTypes.Type(value = TextItem.class, name = "text"), @JsonSubTypes.Type(value = ImageItem.class, name = "image"), @JsonSubTypes.Type(value = TableItem.class, name = "table") }) - public sealed interface Content + public sealed interface GeneralContent permits TextItem, ImageItem, TableItem { String type(); } public record TextItem( @NotBlank String type, - @NotBlank String value) implements Content { + String value) implements BasicContent, GeneralContent { } public record ImageItem( @@ -50,27 +63,125 @@ public record ImageItem( @NotBlank @Size(max = 1024) String src, @JsonProperty(defaultValue = "400") Integer width, @JsonProperty(defaultValue = "400") Integer height, - @Size(max = 255) String caption) implements Content { + @Size(max = 255) String caption) implements BasicContent, GeneralContent { public ImageItem { width = width != null ? width : 400; height = height != null ? height : 400; } } + // ์ปฌ๋Ÿผ ์ •๋ณด (ํ—ค๋” ์ œ๊ฑฐ, ๊ฐœ์ˆ˜์™€ ๋„ˆ๋น„๋งŒ) + public record TableColumn( + Integer width) { + } + + // ์…€ ๋ฐ์ดํ„ฐ + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + public record TableCell( + @NotEmpty List<@Valid BasicContent> content, + @Min(1) Integer rowspan, + @Min(1) Integer colspan) { + public TableCell { + rowspan = rowspan != null ? rowspan : 1; + colspan = colspan != null ? colspan : 1; + } + } + public record TableItem( @NotBlank String type, - @NotEmpty List<@NotBlank String> columns, - @NotEmpty List<@NotEmpty List> rows) implements Content { + @NotEmpty List<@Valid TableColumn> columns, + @NotEmpty List> rows) implements GeneralContent { - @AssertTrue(message = "table rows must match columns length") + @AssertTrue(message = "table rows must match columns length considering cell spans") @JsonIgnore - public boolean isRectangular() { - int w = (columns == null) ? -1 : columns.size(); - if (w <= 0 || rows == null || rows.isEmpty()) + public boolean isValidCellSpans() { + if (columns == null || rows == null || rows.isEmpty()) return false; - for (var r : rows) - if (r == null || r.size() != w) + + int expectedWidth = columns.size(); + int rowCount = rows.size(); + + // ๊ฐ ์œ„์น˜์—์„œ rowspan์œผ๋กœ ์ฐจ์ง€๋œ ์…€์„ ์ถ”์  + // grid[row][col] = ์œ„์—์„œ ๋‚ด๋ ค์˜จ rowspan ์…€์˜ ๋‚จ์€ rowspan ๊ฐ’ (0์ด๋ฉด ๋น„์–ด์žˆ์Œ) + int[][] grid = new int[rowCount][expectedWidth]; + + for (int rowIndex = 0; rowIndex < rowCount; rowIndex++) { + var row = rows.get(rowIndex); + if (row == null) return false; + + // ํ˜„์žฌ ํ–‰์—์„œ rowspan์œผ๋กœ ์ฐจ์ง€๋œ ์œ„์น˜ ์ˆ˜ ๊ณ„์‚ฐ + int occupiedByRowspan = 0; + for (int col = 0; col < expectedWidth; col++) { + if (grid[rowIndex][col] > 0) { + occupiedByRowspan++; + } + } + + // ๋นˆ ํ–‰์ธ ๊ฒฝ์šฐ (๋ชจ๋“  ์œ„์น˜๊ฐ€ rowspan์œผ๋กœ ์ฐจ์ง€๋จ) + if (row.isEmpty()) { + if (occupiedByRowspan == expectedWidth) { + // ๋นˆ ํ–‰์ด ์œ ํšจํ•จ (๋ชจ๋“  ์œ„์น˜๊ฐ€ rowspan์œผ๋กœ ์ฐจ์ง€๋จ) + continue; + } else { + // ๋นˆ ํ–‰์ธ๋ฐ rowspan์œผ๋กœ ์ฐจ์ง€๋˜์ง€ ์•Š์€ ์œ„์น˜๊ฐ€ ์žˆ์Œ + return false; + } + } + + // ํ˜„์žฌ ํ–‰์˜ ์…€๋“ค์˜ colspan ํ•ฉ ๊ณ„์‚ฐ + int rowCellWidth = 0; + int colIndex = 0; + + for (var cell : row) { + if (cell == null) + return false; + + // rowspan์œผ๋กœ ์ฐจ์ง€๋œ ์œ„์น˜๋Š” ๊ฑด๋„ˆ๋›ฐ๊ธฐ (HTML ํ…Œ์ด๋ธ” ๊ทœ์น™) + while (colIndex < expectedWidth && grid[rowIndex][colIndex] > 0) { + colIndex++; + } + + if (colIndex >= expectedWidth) { + // ์ปฌ๋Ÿผ ๋ฒ”์œ„๋ฅผ ๋ฒ—์–ด๋‚จ + return false; + } + + // colspan ๋ฒ”์œ„ ํ™•์ธ + if (colIndex + cell.colspan() > expectedWidth) { + return false; + } + + // colspan ๋ฒ”์œ„ ๋‚ด์— rowspan์œผ๋กœ ์ฐจ์ง€๋œ ์…€์ด ์žˆ๋Š”์ง€ ํ™•์ธ (HTML ํ…Œ์ด๋ธ” ๊ทœ์น™) + for (int i = 0; i < cell.colspan(); i++) { + if (grid[rowIndex][colIndex + i] > 0) { + // rowspan์œผ๋กœ ์ด๋ฏธ ์ฐจ์ง€๋œ ์œ„์น˜์™€ ๊ฒน์นจ + return false; + } + } + + // ์…€์„ ๊ทธ๋ฆฌ๋“œ์— ๋ฐฐ์น˜ (rowspan์ด ์žˆ์œผ๋ฉด ์•„๋ž˜ ํ–‰๋“ค๋„ ํ‘œ์‹œ) + int cellRowspan = cell.rowspan(); + int cellColspan = cell.colspan(); + + for (int r = 0; r < cellRowspan && rowIndex + r < rowCount; r++) { + for (int c = 0; c < cellColspan; c++) { + if (rowIndex + r < rowCount && colIndex + c < expectedWidth) { + grid[rowIndex + r][colIndex + c] = cellRowspan - r; + } + } + } + + rowCellWidth += cellColspan; + colIndex += cellColspan; + } + + // ๊ฐ ํ–‰์˜ ์‹ค์ œ ๋„ˆ๋น„๋Š” (์ปฌ๋Ÿผ ์ˆ˜ - rowspan์œผ๋กœ ์ฐจ์ง€๋œ ์œ„์น˜ ์ˆ˜)์™€ ์ผ์น˜ํ•ด์•ผ ํ•จ + if (rowCellWidth != expectedWidth - occupiedByRowspan) { + return false; + } + } + return true; } } diff --git a/src/main/java/starlight/adapter/order/persistence/OrderRepositoryJpa.java b/src/main/java/starlight/adapter/order/persistence/OrderRepositoryJpa.java new file mode 100644 index 00000000..f4cf0f5d --- /dev/null +++ b/src/main/java/starlight/adapter/order/persistence/OrderRepositoryJpa.java @@ -0,0 +1,40 @@ +package starlight.adapter.order.persistence; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import starlight.application.order.provided.OrdersQuery; +import starlight.domain.expertReport.entity.ExpertReport; +import starlight.domain.order.exception.OrderErrorType; +import starlight.domain.order.exception.OrderException; +import starlight.domain.order.order.Orders; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class OrderRepositoryJpa implements OrdersQuery { + + private final OrdersRepository repository; + + @Override + public Optional findByOrderCode(String orderCode) { + return repository.findByOrderCode(orderCode); + } + + @Override + public List findAllWithPaymentsByBuyerIdOrderByCreatedAtDesc(Long buyerId) { + return repository.findAllWithPaymentsByBuyerIdOrderByCreatedAtDesc(buyerId); + } + + @Override + public Orders getByOrderCodeOrThrow(String orderCode) { + return repository.findByOrderCode(orderCode) + .orElseThrow(() -> new OrderException(OrderErrorType.ORDER_NOT_FOUND)); + } + + @Override + public Orders save(Orders order) { + return repository.save(order); + } +} diff --git a/src/main/java/starlight/adapter/order/persistence/OrdersRepository.java b/src/main/java/starlight/adapter/order/persistence/OrdersRepository.java new file mode 100644 index 00000000..b6ea0b27 --- /dev/null +++ b/src/main/java/starlight/adapter/order/persistence/OrdersRepository.java @@ -0,0 +1,24 @@ +package starlight.adapter.order.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import starlight.domain.order.order.Orders; + +import java.util.List; +import java.util.Optional; + +public interface OrdersRepository extends JpaRepository { + + Optional findByOrderCode(String orderCode); + + @Query(""" + select distinct o + from Orders o + left join fetch o.payments p + where o.buyerId = :buyerId + order by o.createdAt desc + """) + List findAllWithPaymentsByBuyerIdOrderByCreatedAtDesc(@Param("buyerId") Long buyerId); + +} diff --git a/src/main/java/starlight/adapter/order/toss/TossClient.java b/src/main/java/starlight/adapter/order/toss/TossClient.java new file mode 100644 index 00000000..ee8640ea --- /dev/null +++ b/src/main/java/starlight/adapter/order/toss/TossClient.java @@ -0,0 +1,120 @@ +package starlight.adapter.order.toss; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import starlight.application.order.provided.dto.TossClientResponse; +import starlight.domain.order.exception.OrderErrorType; +import starlight.domain.order.exception.OrderException; + +import java.util.Map; +import java.util.Objects; + +@Slf4j +@Component +public class TossClient { + + private final RestClient restClient; + + public TossClient(@Qualifier("tossRestClient") RestClient restClient) { + this.restClient = restClient; + } + + public TossClientResponse.Confirm confirm(String orderCode, String paymentKey, Long price) { + Map body = Map.of( + "paymentKey", paymentKey, + "orderId", orderCode, + "amount", price + ); + try { + TossClientResponse.Confirm response = restClient.post() + .uri("/v1/payments/confirm") + .header("Idempotency-Key", orderCode) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .body(TossClientResponse.Confirm.class); + + // ์‘๋‹ต ๊ฒ€์ฆ + validateConfirmResponse(response, orderCode, price); + + return response; + + } catch (Exception e) { + log.error("ํ† ์Šค ๊ฒฐ์ œ ์Šน์ธ ์š”์ฒญ ์ค‘ ์—๋Ÿฌ ๋ฐœ์ƒ. orderId: {}, paymentKey: {}, message: {}", orderCode, paymentKey, e.getMessage(), e); + throw new OrderException(OrderErrorType.TOSS_CLIENT_CONFIRM_ERROR); + } + } + + public TossClientResponse.Cancel cancel(String paymentKey, String reason) { + Map body = Map.of( + "cancelReason", reason != null ? reason : "user_request" + ); + try { + TossClientResponse.Cancel response = restClient.post() + .uri("/v1/payments/{paymentKey}/cancel", paymentKey) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .body(TossClientResponse.Cancel.class); + + // ์‘๋‹ต ๊ฒ€์ฆ + validateCancelResponse(response); + + return response; + + } catch (Exception e) { + log.error("ํ† ์Šค ํ™˜๋ถˆ ์š”์ฒญ ์ค‘ ์—๋Ÿฌ๋ฐœ์ƒ: {}", e.getMessage(), e); + log.error("paymentKey: {}, reason: {}", paymentKey, reason); + throw new OrderException(OrderErrorType.TOSS_CLIENT_CONFIRM_ERROR); + } + + } + + /** + * confirm ์‘๋‹ต ๊ฒ€์ฆ + */ + private void validateConfirmResponse(TossClientResponse.Confirm response, String expectedOrderId, Long expectedAmount) { + if (response == null) { + throw new IllegalStateException("PG ์‘๋‹ต์ด null์ž…๋‹ˆ๋‹ค."); + } + + if (!Objects.equals(response.orderId(), expectedOrderId)) { + throw new IllegalStateException( + String.format("PG ์‘๋‹ต์˜ ์ฃผ๋ฌธ๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์˜ˆ์ƒ: %s, ์‹ค์ œ: %s", + expectedOrderId, response.orderId()) + ); + } + + if (!Objects.equals(response.totalAmount(), expectedAmount)) { + throw new IllegalStateException( + String.format("PG ์‘๋‹ต ๊ธˆ์•ก์ด ์ฃผ๋ฌธ ๊ธˆ์•ก๊ณผ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์˜ˆ์ƒ: %d, ์‹ค์ œ: %d", + expectedAmount, response.totalAmount()) + ); + } + + if (!"DONE".equals(response.status())) { + throw new IllegalStateException( + "PG ์‘๋‹ต ์ƒํƒœ๊ฐ€ ์™„๋ฃŒ(DONE)๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค. status=" + response.status() + ); + } + } + + /** + * cancel ์‘๋‹ต ๊ฒ€์ฆ + */ + private void validateCancelResponse(TossClientResponse.Cancel response) { + if (response == null) { + throw new IllegalStateException("PG ์ทจ์†Œ ์‘๋‹ต์ด null์ž…๋‹ˆ๋‹ค."); + } + + // ์ทจ์†Œ ์‘๋‹ต์€ status๊ฐ€ CANCELED์—ฌ์•ผ ํ•จ + if (response.status() != null && !"CANCELED".equals(response.status())) { + throw new IllegalStateException( + "PG ์ทจ์†Œ๊ฐ€ ์™„๋ฃŒ ์ƒํƒœ(CANCELED)๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค. status=" + response.status() + ); + } + } +} diff --git a/src/main/java/starlight/adapter/order/webapi/OrderController.java b/src/main/java/starlight/adapter/order/webapi/OrderController.java new file mode 100644 index 00000000..46829fee --- /dev/null +++ b/src/main/java/starlight/adapter/order/webapi/OrderController.java @@ -0,0 +1,102 @@ +package starlight.adapter.order.webapi; + +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import starlight.adapter.auth.security.auth.AuthDetails; +import starlight.application.order.provided.dto.TossClientResponse; +import starlight.adapter.order.webapi.dto.request.OrderCancelRequest; +import starlight.adapter.order.webapi.dto.request.OrderConfirmRequest; +import starlight.adapter.order.webapi.dto.request.OrderPrepareRequest; +import starlight.adapter.order.webapi.dto.response.OrderCancelResponse; +import starlight.adapter.order.webapi.dto.response.OrderConfirmResponse; +import starlight.adapter.order.webapi.dto.response.OrderPrepareResponse; +import starlight.adapter.order.webapi.dto.response.WalletCheckResponse; +import starlight.application.order.provided.OrderPaymentService; +import starlight.application.order.provided.dto.PaymentHistoryItemDto; +import starlight.application.usage.provided.UsageCreditPort; +import starlight.domain.order.order.Orders; +import starlight.shared.apiPayload.response.ApiResponse; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@Tag(name = "๊ฒฐ์ œ", description = "๊ฒฐ์ œ ๊ด€๋ จ API") +@RequestMapping("/v1/orders") +public class OrderController { + + private final OrderPaymentService orderPaymentService; + private final UsageCreditPort usageCreditPort; + + /** + * ๊ฒฐ์ œ ์ค€๋น„ (์ฃผ๋ฌธ ์ƒ์„ฑ) + * POST /api/toss/request + */ + @PostMapping("/request") + public ApiResponse prepareOrder( + @Valid @RequestBody OrderPrepareRequest request, + @AuthenticationPrincipal AuthDetails authDetails + ) { + Orders order = orderPaymentService.prepare( + request.orderCode(), + authDetails.getMemberId(), + request.productCode() + ); + + OrderPrepareResponse response = OrderPrepareResponse.from(order); + + return ApiResponse.success(response); + } + + /** + * ๊ฒฐ์ œ ์Šน์ธ + * POST /api/toss/confirm + */ + @PostMapping("/confirm") + public ApiResponse confirmPayment( + @Valid @RequestBody OrderConfirmRequest request, + @AuthenticationPrincipal AuthDetails authDetails + ) { + Orders order = orderPaymentService.confirm( + request.orderCode(), + request.paymentKey(), + authDetails.getMemberId() + ); + + OrderConfirmResponse response = OrderConfirmResponse.from(order); + + return ApiResponse.success(response); + } + + /** + * ๊ฒฐ์ œ ์ทจ์†Œ + * POST /api/toss/cancel + */ + @PostMapping("/cancel") + public ApiResponse cancelPayment( + @Valid @RequestBody OrderCancelRequest request + ) { + TossClientResponse.Cancel tossResponse = orderPaymentService.cancel(request); + + OrderCancelResponse response = OrderCancelResponse.from(tossResponse); + + return ApiResponse.success(response); + } + + /** + * ๋‚˜์˜ ๊ฒฐ์ œ ๋‚ด์—ญ ์กฐํšŒ + * GET /api/orders + */ + @GetMapping + public ApiResponse> getMyPayments( + @AuthenticationPrincipal AuthDetails authDetails + ) { + Long memberId = authDetails.getMemberId(); + List history = orderPaymentService.getPaymentHistory(memberId); + + return ApiResponse.success(history); + } +} diff --git a/src/main/java/starlight/adapter/order/webapi/dto/request/OrderCancelRequest.java b/src/main/java/starlight/adapter/order/webapi/dto/request/OrderCancelRequest.java new file mode 100644 index 00000000..7aa386f3 --- /dev/null +++ b/src/main/java/starlight/adapter/order/webapi/dto/request/OrderCancelRequest.java @@ -0,0 +1,11 @@ +package starlight.adapter.order.webapi.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record OrderCancelRequest( + @NotBlank(message = "orderCode๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + String orderCode, + + @NotBlank(message = "์ทจ์†Œ ์‚ฌ์œ ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + String reason +) { } diff --git a/src/main/java/starlight/adapter/order/webapi/dto/request/OrderConfirmRequest.java b/src/main/java/starlight/adapter/order/webapi/dto/request/OrderConfirmRequest.java new file mode 100644 index 00000000..9e246bc0 --- /dev/null +++ b/src/main/java/starlight/adapter/order/webapi/dto/request/OrderConfirmRequest.java @@ -0,0 +1,11 @@ +package starlight.adapter.order.webapi.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record OrderConfirmRequest( + @NotBlank + String paymentKey, + + @NotBlank + String orderCode +) { } diff --git a/src/main/java/starlight/adapter/order/webapi/dto/request/OrderPrepareRequest.java b/src/main/java/starlight/adapter/order/webapi/dto/request/OrderPrepareRequest.java new file mode 100644 index 00000000..60db489f --- /dev/null +++ b/src/main/java/starlight/adapter/order/webapi/dto/request/OrderPrepareRequest.java @@ -0,0 +1,11 @@ +package starlight.adapter.order.webapi.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record OrderPrepareRequest( + @NotBlank + String orderCode, + + @NotBlank + String productCode +) { } diff --git a/src/main/java/starlight/adapter/order/webapi/dto/response/OrderCancelResponse.java b/src/main/java/starlight/adapter/order/webapi/dto/response/OrderCancelResponse.java new file mode 100644 index 00000000..6ffc25cd --- /dev/null +++ b/src/main/java/starlight/adapter/order/webapi/dto/response/OrderCancelResponse.java @@ -0,0 +1,19 @@ +package starlight.adapter.order.webapi.dto.response; + +import starlight.application.order.provided.dto.TossClientResponse; + +public record OrderCancelResponse( + String orderId, + String paymentKey, + String status, + Integer totalAmount +) { + public static OrderCancelResponse from(TossClientResponse.Cancel tossResponse) { + return new OrderCancelResponse( + tossResponse.orderId(), + tossResponse.paymentKey(), + tossResponse.status(), + tossResponse.totalAmount() + ); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/order/webapi/dto/response/OrderConfirmResponse.java b/src/main/java/starlight/adapter/order/webapi/dto/response/OrderConfirmResponse.java new file mode 100644 index 00000000..06c87607 --- /dev/null +++ b/src/main/java/starlight/adapter/order/webapi/dto/response/OrderConfirmResponse.java @@ -0,0 +1,34 @@ +package starlight.adapter.order.webapi.dto.response; + +import starlight.domain.order.order.Orders; +import starlight.domain.order.order.PaymentRecords; + +import java.time.Instant; + +public record OrderConfirmResponse( + Long buyerId, + String paymentKey, + String orderId, + Long amount, + String status, + Instant approvedAt, + String receiptUrl, + String method, + String provider +) { + public static OrderConfirmResponse from(Orders order) { + PaymentRecords done = order.getLatestPaymentOrThrow(); + + return new OrderConfirmResponse( + order.getBuyerId(), + done.getPaymentKey(), + order.getOrderCode(), + order.getPrice(), + order.getStatus().name(), + done.getApprovedAt(), + done.getReceiptUrl(), + done.getMethod(), + done.getProvider() + ); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/order/webapi/dto/response/OrderPrepareResponse.java b/src/main/java/starlight/adapter/order/webapi/dto/response/OrderPrepareResponse.java new file mode 100644 index 00000000..daa67302 --- /dev/null +++ b/src/main/java/starlight/adapter/order/webapi/dto/response/OrderPrepareResponse.java @@ -0,0 +1,17 @@ +package starlight.adapter.order.webapi.dto.response; + +import starlight.domain.order.order.Orders; + +public record OrderPrepareResponse( + String orderCode, + Long amount, + String status +) { + public static OrderPrepareResponse from(Orders order) { + return new OrderPrepareResponse( + order.getOrderCode(), + order.getPrice(), + order.getStatus().name() + ); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/order/webapi/dto/response/WalletCheckResponse.java b/src/main/java/starlight/adapter/order/webapi/dto/response/WalletCheckResponse.java new file mode 100644 index 00000000..325321bf --- /dev/null +++ b/src/main/java/starlight/adapter/order/webapi/dto/response/WalletCheckResponse.java @@ -0,0 +1,11 @@ +package starlight.adapter.order.webapi.dto.response; + +public record WalletCheckResponse( + Boolean hasCredit +) { + public static WalletCheckResponse of(Boolean hasCredit) { + return new WalletCheckResponse( + hasCredit + ); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/usage/persistence/UsageHistoryRepository.java b/src/main/java/starlight/adapter/usage/persistence/UsageHistoryRepository.java new file mode 100644 index 00000000..e055482b --- /dev/null +++ b/src/main/java/starlight/adapter/usage/persistence/UsageHistoryRepository.java @@ -0,0 +1,7 @@ +package starlight.adapter.usage.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; +import starlight.domain.order.wallet.UsageHistory; + +public interface UsageHistoryRepository extends JpaRepository { +} diff --git a/src/main/java/starlight/adapter/usage/persistence/UsageHistoryRepositoryJpa.java b/src/main/java/starlight/adapter/usage/persistence/UsageHistoryRepositoryJpa.java new file mode 100644 index 00000000..803689b9 --- /dev/null +++ b/src/main/java/starlight/adapter/usage/persistence/UsageHistoryRepositoryJpa.java @@ -0,0 +1,18 @@ +package starlight.adapter.usage.persistence; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import starlight.application.usage.provided.UsageHistoryQuery; +import starlight.domain.order.wallet.UsageHistory; + +@Repository +@RequiredArgsConstructor +public class UsageHistoryRepositoryJpa implements UsageHistoryQuery { + + private final UsageHistoryRepository repository; + + @Override + public UsageHistory save(UsageHistory usageHistory){ + return repository.save(usageHistory); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/usage/persistence/UsageWalletRepository.java b/src/main/java/starlight/adapter/usage/persistence/UsageWalletRepository.java new file mode 100644 index 00000000..5f1352a7 --- /dev/null +++ b/src/main/java/starlight/adapter/usage/persistence/UsageWalletRepository.java @@ -0,0 +1,11 @@ +package starlight.adapter.usage.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; +import starlight.domain.order.wallet.UsageWallet; + +import java.util.Optional; + +public interface UsageWalletRepository extends JpaRepository { + + Optional findByUserId(Long userId); +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/usage/persistence/UsageWalletRepositoryJpa.java b/src/main/java/starlight/adapter/usage/persistence/UsageWalletRepositoryJpa.java new file mode 100644 index 00000000..c8bdf4c6 --- /dev/null +++ b/src/main/java/starlight/adapter/usage/persistence/UsageWalletRepositoryJpa.java @@ -0,0 +1,25 @@ +package starlight.adapter.usage.persistence; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import starlight.application.usage.provided.UsageWalletQuery; +import starlight.domain.order.wallet.UsageWallet; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class UsageWalletRepositoryJpa implements UsageWalletQuery { + + private final UsageWalletRepository repository; + + @Override + public Optional findByUserId(Long userId){ + return repository.findByUserId(userId); + } + + @Override + public UsageWallet save(UsageWallet usageWallet){ + return repository.save(usageWallet); + } +} diff --git a/src/main/java/starlight/application/businessplan/util/PlainTextExtractUtils.java b/src/main/java/starlight/application/businessplan/util/PlainTextExtractUtils.java index a01b77ce..80011df2 100644 --- a/src/main/java/starlight/application/businessplan/util/PlainTextExtractUtils.java +++ b/src/main/java/starlight/application/businessplan/util/PlainTextExtractUtils.java @@ -11,17 +11,18 @@ /** * Content(JSON) โ†’ ์ค„๊ธ€ ๋ณ€ํ™˜ (๊ณ ์ • ํฌ๋งท) - * - text : value - * - image : "[์‚ฌ์ง„] {caption}" (์บก์…˜ ์—†์œผ๋ฉด "[์‚ฌ์ง„]") - * - table : "col1: v1, col2: v2, ..." (ํ–‰๋งˆ๋‹ค ํ•œ ์ค„) + * - text : value + * - image : "[์‚ฌ์ง„] {caption}" (์บก์…˜ ์—†์œผ๋ฉด "[์‚ฌ์ง„]") + * - table : "col1: v1, col2: v2, ..." (ํ–‰๋งˆ๋‹ค ํ•œ ์ค„) * * ์ง€์› ์ž…๋ ฅ: - * (1) {"content":[ ... ]} - * (2) {"blocks":[ {"content":[ ... ]}, ... ]} + * (1) {"content":[ ... ]} + * (2) {"blocks":[ {"content":[ ... ]}, ... ]} */ public final class PlainTextExtractUtils { - private PlainTextExtractUtils() {} + private PlainTextExtractUtils() { + } // ๊ณ ์ • ํฌ๋งท ์ƒ์ˆ˜ private static final String IMAGE_TOKEN = "[์‚ฌ์ง„]"; @@ -123,27 +124,112 @@ static List extractTable_new(JsonNode contentItem) { return tableLines; } - // ํ—ค๋” ์ถ”๊ฐ€ - List headers = new ArrayList<>(); - for (JsonNode col : columnArrayNode) { - headers.add("\"" + col.asText() + "\""); + int columnCount = columnArrayNode.size(); + int rowCount = rowArrayNode.size(); + + if (rowCount == 0) { + return tableLines; } - tableLines.add("[" + String.join(", ", headers) + "]"); - // ๊ฐ ํ–‰ ์ถ”๊ฐ€ - for (JsonNode rowNode : rowArrayNode) { - if (rowNode.isArray()) { - List rowValues = new ArrayList<>(); - for (JsonNode cell : rowNode) { - rowValues.add("\"" + cell.asText() + "\""); + // ์ปฌ๋Ÿผ ๊ฐœ์ˆ˜ ํ‘œ์‹œ + tableLines.add("[" + columnCount + " columns]"); + + // rowspan๊ณผ colspan์„ ๊ณ ๋ คํ•˜์—ฌ ์‹ค์ œ ํ…Œ์ด๋ธ” ๊ทธ๋ฆฌ๋“œ ๊ตฌ์„ฑ + // grid[row][col] = ํ•ด๋‹น ์œ„์น˜์˜ ์…€ ํ…์ŠคํŠธ (null์ด๋ฉด rowspan์œผ๋กœ ์ฐจ์ง€๋œ ์œ„์น˜) + String[][] grid = new String[rowCount][columnCount]; + boolean[][] isOccupied = new boolean[rowCount][columnCount]; + + // ๊ฐ ํ–‰์„ ์ˆœํšŒํ•˜๋ฉฐ ์…€ ๋ฐฐ์น˜ + for (int rowIndex = 0; rowIndex < rowCount; rowIndex++) { + JsonNode rowNode = rowArrayNode.get(rowIndex); + if (!rowNode.isArray()) { + continue; + } + + int colIndex = 0; + for (JsonNode cellNode : rowNode) { + // rowspan์œผ๋กœ ์ฐจ์ง€๋œ ์œ„์น˜๋Š” ๊ฑด๋„ˆ๋›ฐ๊ธฐ + while (colIndex < columnCount && isOccupied[rowIndex][colIndex]) { + colIndex++; + } + + if (colIndex >= columnCount) { + break; } - tableLines.add("[" + String.join(", ", rowValues) + "]"); + + // ์…€ ๋‚ด์šฉ ์ถ”์ถœ + String cellText = extractCellContent(cellNode); + + // rowspan๊ณผ colspan ๊ฐ’ ๊ฐ€์ ธ์˜ค๊ธฐ (๊ธฐ๋ณธ๊ฐ’ 1) + int rowspan = resolveSpanValue(cellNode, "rowspan", "rowSpan"); + int colspan = resolveSpanValue(cellNode, "colspan", "colSpan"); + + // colspan ๋ฒ”์œ„ ํ™•์ธ + if (colIndex + colspan > columnCount) { + colspan = columnCount - colIndex; + } + + // ์…€์„ ๊ทธ๋ฆฌ๋“œ์— ๋ฐฐ์น˜ + for (int r = 0; r < rowspan && rowIndex + r < rowCount; r++) { + for (int c = 0; c < colspan && colIndex + c < columnCount; c++) { + if (r == 0 && c == 0) { + // ์ฒซ ๋ฒˆ์งธ ์œ„์น˜์—๋งŒ ํ…์ŠคํŠธ ์ €์žฅ + grid[rowIndex + r][colIndex + c] = cellText; + } + // ๋ชจ๋“  ์œ„์น˜๋ฅผ ์ฐจ์ง€๋œ ๊ฒƒ์œผ๋กœ ํ‘œ์‹œ + isOccupied[rowIndex + r][colIndex + c] = true; + } + } + + colIndex += colspan; } } + // ๊ทธ๋ฆฌ๋“œ๋ฅผ ์ˆœํšŒํ•˜๋ฉฐ ๊ฐ ํ–‰์˜ ํ…์ŠคํŠธ ์ƒ์„ฑ + for (int rowIndex = 0; rowIndex < rowCount; rowIndex++) { + List rowValues = new ArrayList<>(); + for (int colIndex = 0; colIndex < columnCount; colIndex++) { + String cellText = grid[rowIndex][colIndex]; + if (cellText == null) { + // rowspan์œผ๋กœ ์ฐจ์ง€๋œ ์œ„์น˜๋Š” ๋นˆ ๋ฌธ์ž์—ด + cellText = ""; + } + rowValues.add("\"" + cellText + "\""); + } + tableLines.add("[" + String.join(", ", rowValues) + "]"); + } + return tableLines; } + /** ์…€ ๋‚ด๋ถ€์˜ content (BasicContent ๋ฆฌ์ŠคํŠธ)๋ฅผ ํ…์ŠคํŠธ๋กœ ์ถ”์ถœ */ + static String extractCellContent(JsonNode cellNode) { + if (!cellNode.has("content") || !cellNode.path("content").isArray()) { + return ""; + } + + List cellParts = new ArrayList<>(); + for (JsonNode contentItem : cellNode.path("content")) { + String type = contentItem.path("type").asText(""); + switch (type) { + case "text": + String textValue = contentItem.path("value").asText(""); + if (!textValue.isBlank()) { + cellParts.add(textValue); + } + break; + case "image": + Optional imageText = extractImage(contentItem); + imageText.ifPresent(cellParts::add); + break; + default: + break; + } + } + + return String.join(" ", cellParts); + } + /** ํ…Œ์ด๋ธ” ํ•ญ๋ชฉ: ๊ฐ ํ–‰์„ "col1: v1, col2: v2 ..." */ static List extractTable(JsonNode contentItem) { List tableLines = new ArrayList<>(); @@ -200,4 +286,14 @@ static String joinNonBlank(List partsList, String separator) { } return result; } + + private static int resolveSpanValue(JsonNode cellNode, String lowerKey, String camelKey) { + if (cellNode.has(lowerKey)) { + return Math.max(1, cellNode.path(lowerKey).asInt(1)); + } + if (cellNode.has(camelKey)) { + return Math.max(1, cellNode.path(camelKey).asInt(1)); + } + return 1; + } } diff --git a/src/main/java/starlight/application/order/OrderPaymentServiceImpl.java b/src/main/java/starlight/application/order/OrderPaymentServiceImpl.java new file mode 100644 index 00000000..489cb499 --- /dev/null +++ b/src/main/java/starlight/application/order/OrderPaymentServiceImpl.java @@ -0,0 +1,167 @@ +package starlight.application.order; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import starlight.adapter.order.toss.TossClient; +import starlight.application.order.provided.dto.TossClientResponse; +import starlight.adapter.order.webapi.dto.request.OrderCancelRequest; +import starlight.application.order.provided.OrderPaymentService; +import starlight.application.order.provided.OrdersQuery; +import starlight.application.order.provided.dto.PaymentHistoryItemDto; +import starlight.application.usage.provided.UsageCreditPort; +import starlight.domain.order.enumerate.OrderStatus; +import starlight.domain.order.enumerate.UsageProductType; +import starlight.domain.order.exception.OrderErrorType; +import starlight.domain.order.exception.OrderException; +import starlight.domain.order.order.Orders; +import starlight.domain.order.order.PaymentRecords; +import starlight.domain.order.order.vo.Money; +import starlight.domain.order.order.vo.OrderCode; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class OrderPaymentServiceImpl implements OrderPaymentService { + + private final TossClient tossClient; + private final OrdersQuery ordersQuery; + private final UsageCreditPort usageCreditPort; + + /** + * ๊ฒฐ์ œ ์ „ ์ฃผ๋ฌธ ์ค€๋น„ + * - orderCode๋กœ ์ฃผ๋ฌธ์ด ์žˆ์œผ๋ฉด ์žฌ์‚ฌ์šฉ(๊ฒ€์ฆ ํ›„ ๊ฒฐ์ œ ์‹œ๋„ ์ถ”๊ฐ€) + * - ์—†์œผ๋ฉด ์ƒˆ ์ฃผ๋ฌธ ์ƒ์„ฑ ํ›„ ์ฒซ ๊ฒฐ์ œ ์‹œ๋„ ์ถ”๊ฐ€ + * + * @param orderCodeStr ํ”„๋ก ํŠธ์—์„œ ์ƒ์„ฑํ•œ ์ฃผ๋ฌธ๋ฒˆํ˜ธ + * @param buyerId ๊ตฌ๋งค์ž ID + * @param productCode ๊ฒฐ์ œ ๊ธˆ์•ก + * @return Orders ์ค€๋น„๋œ ์ฃผ๋ฌธ + */ + @Override + public Orders prepare(String orderCodeStr, Long buyerId, String productCode) { + UsageProductType product = UsageProductType.fromCode(productCode); + Money money = Money.krw(product.getPrice()); + OrderCode orderCode = OrderCode.of(orderCodeStr); + + return ordersQuery.findByOrderCode(orderCodeStr) + .map(existing -> { + existing.validateSameBuyer(buyerId); + existing.validateSameProduct(product); + existing.addPaymentAttempt(money); + return existing; + }) + .orElseGet(() -> { + Orders newOrder = Orders.newUsageOrder(orderCode, buyerId, money, product); + newOrder.addPaymentAttempt(money); + return ordersQuery.save(newOrder); + }); + } + + /** + * ๊ฒฐ์ œ ์Šน์ธ (Confirm) + * ํ† ์Šค ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ์„ฑ๊ณต ํ›„ ํ˜ธ์ถœ + * + * @param orderCodeStr ์ฃผ๋ฌธ๋ฒˆํ˜ธ + * @param paymentKey ํ† ์Šค ๊ฒฐ์ œํ‚ค + * @return Orders ์Šน์ธ๋œ ์ฃผ๋ฌธ + */ + @Override + public Orders confirm(String orderCodeStr, String paymentKey, Long buyerId) { + + Orders order = ordersQuery.getByOrderCodeOrThrow(orderCodeStr); + + UsageProductType product = UsageProductType.fromCode(order.getUsageProductCode()); + long expectedAmount = product.getPrice(); + + if (!Objects.equals(order.getPrice(), expectedAmount)) { + throw new OrderException(OrderErrorType.PAYMENT_AMOUNT_MISMATCH); + } + + PaymentRecords payment = order.getLatestRequestedOrThrow(); + + TossClientResponse.Confirm response = tossClient.confirm( + orderCodeStr, paymentKey, expectedAmount + ); + + String provider = response.providerOrNull(); + String receiptUrl = response.receiptUrlOrNull(); + Instant approvedAt = response.approvedAtOrNow(); + + payment.markDone( + response.paymentKey(), response.method(), provider, receiptUrl, approvedAt + ); + order.markPaid(); + + usageCreditPort.chargeForOrder( + order.getBuyerId(), + order.getId(), + product.getUsageCount() + ); + + return ordersQuery.save(order); + } + + /** + * ๊ฒฐ์ œ ์ทจ์†Œ + * + * @param request ์ทจ์†Œ ์š”์ฒญ + * @return TossClientResponse.Cancel ์ทจ์†Œ ์‘๋‹ต + */ + @Override + public TossClientResponse.Cancel cancel(OrderCancelRequest request) { + + Orders order = ordersQuery.getByOrderCodeOrThrow(request.orderCode()); + + PaymentRecords payment = order.getLatestDoneOrThrow(); + payment.ensureHasPaymentKey(); + + TossClientResponse.Cancel response = tossClient.cancel( + payment.getPaymentKey(), request.reason() + ); + + payment.markCanceled(); + order.cancel(); + + ordersQuery.save(order); + + return response; + } + + public List getPaymentHistory(Long buyerId) { + // 1) ์ด ํšŒ์›(buyer)์˜ ์ฃผ๋ฌธ ์ „์ฒด๋ฅผ ์ตœ์‹ ์ˆœ์œผ๋กœ ๊ฐ€์ ธ์˜ค๊ธฐ + List orders = ordersQuery.findAllWithPaymentsByBuyerIdOrderByCreatedAtDesc(buyerId); + + return orders.stream() + // ๊ฒฐ์ œ์™„๋ฃŒ(PAID) ์ฃผ๋ฌธ๋งŒ + .filter(o -> o.getStatus() == OrderStatus.PAID) + .map(order -> { + // 2) ํ•ด๋‹น ์ฃผ๋ฌธ์˜ ๊ฐ€์žฅ ์ตœ๊ทผ DONE ๊ฒฐ์ œ + PaymentRecords payment = order.getLatestDoneOrThrow(); + + // 3) ์ƒํ’ˆ๋ช… ๋งคํ•‘ + UsageProductType productType = UsageProductType.fromCode(order.getUsageProductCode()); + String productName = productType.getDescription(); + + // 4) ๊ฒฐ์ œ์ผ (approvedAt ์—†์œผ๋ฉด createdAt) + Instant paidAt = payment.getApprovedAt() != null + ? payment.getApprovedAt() + : payment.getCreatedAt(); + + return PaymentHistoryItemDto.of( + productName, + payment.getMethod(), + payment.getPrice(), + paidAt, + payment.getReceiptUrl() + ); + }) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/application/order/provided/OrderPaymentService.java b/src/main/java/starlight/application/order/provided/OrderPaymentService.java new file mode 100644 index 00000000..ff293f2a --- /dev/null +++ b/src/main/java/starlight/application/order/provided/OrderPaymentService.java @@ -0,0 +1,19 @@ +package starlight.application.order.provided; + +import starlight.application.order.provided.dto.TossClientResponse; +import starlight.adapter.order.webapi.dto.request.OrderCancelRequest; +import starlight.application.order.provided.dto.PaymentHistoryItemDto; +import starlight.domain.order.order.Orders; + +import java.util.List; + +public interface OrderPaymentService{ + + Orders prepare(String orderCodeStr, Long buyerId, String productCode); + + Orders confirm(String orderCodeStr, String paymentKey, Long buyerId); + + TossClientResponse.Cancel cancel(OrderCancelRequest request); + + List getPaymentHistory(Long buyerId); +} diff --git a/src/main/java/starlight/application/order/provided/OrdersQuery.java b/src/main/java/starlight/application/order/provided/OrdersQuery.java new file mode 100644 index 00000000..16b5dfc3 --- /dev/null +++ b/src/main/java/starlight/application/order/provided/OrdersQuery.java @@ -0,0 +1,17 @@ +package starlight.application.order.provided; + +import starlight.domain.order.order.Orders; + +import java.util.List; +import java.util.Optional; + +public interface OrdersQuery { + + Optional findByOrderCode(String orderCode); + + List findAllWithPaymentsByBuyerIdOrderByCreatedAtDesc(Long buyerId); + + Orders getByOrderCodeOrThrow(String orderCode); + + Orders save(Orders order); +} \ No newline at end of file diff --git a/src/main/java/starlight/application/order/provided/dto/PaymentHistoryItemDto.java b/src/main/java/starlight/application/order/provided/dto/PaymentHistoryItemDto.java new file mode 100644 index 00000000..1e0f3e71 --- /dev/null +++ b/src/main/java/starlight/application/order/provided/dto/PaymentHistoryItemDto.java @@ -0,0 +1,27 @@ +package starlight.application.order.provided.dto; + +import java.time.Instant; + +public record PaymentHistoryItemDto( + String productName, + String paymentMethod, + Long price, + Instant paidAt, + String receiptUrl +) { + public static PaymentHistoryItemDto of( + String productName, + String paymentMethod, + Long price, + Instant paidAt, + String receiptUrl + ) { + return new PaymentHistoryItemDto( + productName, + paymentMethod, + price, + paidAt, + receiptUrl + ); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/application/order/provided/dto/TossClientResponse.java b/src/main/java/starlight/application/order/provided/dto/TossClientResponse.java new file mode 100644 index 00000000..97a4c932 --- /dev/null +++ b/src/main/java/starlight/application/order/provided/dto/TossClientResponse.java @@ -0,0 +1,98 @@ +package starlight.application.order.provided.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.extern.slf4j.Slf4j; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.List; + +@Slf4j +public record TossClientResponse ( +) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record Cancel( + // ๊ธฐ๋ณธ ์ •๋ณด + String mId, // ๊ฐ€๋งน์  ID + String lastTransactionKey, // ๋งˆ์ง€๋ง‰ ๊ฑฐ๋ž˜ ํ‚ค + String paymentKey, // ๊ฒฐ์ œ ํ‚ค + String orderId, // ORD20251113-3PNGMYUX + String orderName, // starlight ํฌ๋ ˆ๋”ง 5,000์› + Integer taxExemptionAmount, // ๋น„๊ณผ์„ธ ๊ธˆ์•ก + String status, // (String) CANCELED + String requestedAt, // ์š”์ฒญ ์‹œ๊ฐ + String approvedAt, // ์Šน์ธ ์‹œ๊ฐ + + // ์ทจ์†Œ ๋‚ด์—ญ + List cancels, + + // ๊ฒฐ์ œ ์ •๋ณด + String secret, // ๊ฐ€๋งน์  ์‹œํฌ๋ฆฟ ์ฝ”๋“œ (ps_kYG57Eba3GKvEgqK6GnE3pWDOxmA) + String type, // ๊ฒฐ์ œ ํƒ€์ž… ("NORMAL") + EasyPay easyPay, + + // ์˜์ˆ˜์ฆ + Receipt receipt, + + // ๊ธˆ์•ก ์ •๋ณด + String currency, // "KRW" + Integer totalAmount, // 5000 + Integer balanceAmount, // 0 + Integer suppliedAmount, // 5000 + Integer vat, // 455 + Integer taxFreeAmount, // 0 + String method // "๊ฐ„ํŽธ๊ฒฐ์ œ" + ) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record CancelDetail( + String cancelReason, // "user_request" + String canceledAt, // "2025-11-13T19:09:04+09:00" + String cancelStatus, // "DONE" + Integer cancelAmount // 5000 + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record EasyPay( + String provider, // "์นด์นด์˜คํŽ˜์ด" + Integer amount, // 5000 + Integer discountAmount // 0 + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Receipt( + String url // ์˜์ˆ˜์ฆ URL + ) {} + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Confirm( + String paymentKey, + String orderId, + String status, // e.g., DONE + String method, // CARD/EASY_PAY... + Long totalAmount, + OffsetDateTime approvedAt, + EasyPay easyPay, + Receipt receipt + ) { + public record EasyPay(String provider) {} + public record Receipt(String url) {} + + public String providerOrNull() { + return (easyPay != null) ? easyPay.provider() : null; + } + + public String receiptUrlOrNull() { + return (receipt != null) ? receipt.url() : null; + } + + public Instant approvedAtOrNow() { + if (approvedAt == null) { + log.warn("์Šน์ธ ์‹œ๊ฐ์ด ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค, orderId: {}", orderId); + return Instant.now(); + } + return approvedAt.toInstant(); + } + } +} diff --git a/src/main/java/starlight/application/usage/UsageCreditService.java b/src/main/java/starlight/application/usage/UsageCreditService.java new file mode 100644 index 00000000..5b888b72 --- /dev/null +++ b/src/main/java/starlight/application/usage/UsageCreditService.java @@ -0,0 +1,53 @@ +package starlight.application.usage; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import starlight.application.usage.provided.UsageCreditPort; +import starlight.application.usage.provided.UsageHistoryQuery; +import starlight.application.usage.provided.UsageWalletQuery; +import starlight.domain.order.exception.OrderErrorType; +import starlight.domain.order.exception.OrderException; +import starlight.domain.order.wallet.UsageHistory; +import starlight.domain.order.wallet.UsageWallet; + +@Service +@RequiredArgsConstructor +@Transactional +public class UsageCreditService implements UsageCreditPort { + + private final UsageWalletQuery usageWalletQuery; + private final UsageHistoryQuery usageHistoryQuery; + + /** + * ์ฃผ๋ฌธ ๊ฒฐ์ œ๊ฐ€ ์™„๋ฃŒ๋˜์—ˆ์„ ๋•Œ ์‚ฌ์šฉ๊ถŒ(์ง€๊ฐ‘)์„ ์ถฉ์ „ํ•œ๋‹ค. + * + * @param userId ์ฃผ๋ฌธ์ž ID + * @param orderId ์ฃผ๋ฌธ PK (UsageHistory ์—ฐ๋™์šฉ) + * @param usageCount ๋ช‡ ํšŒ๊ถŒ์ธ์ง€ (1ํšŒ / 2ํšŒ ๋“ฑ) + */ + @Override + public void chargeForOrder(Long userId, Long orderId, int usageCount) { + if (userId == null || usageCount <= 0) { + throw new OrderException(OrderErrorType.INVALID_USAGE_COUNT); + } + + // ์ง€๊ฐ‘ ์กฐํšŒ or ์ƒ์„ฑ + UsageWallet wallet = usageWalletQuery.findByUserId(userId) + .orElseGet(() -> usageWalletQuery.save(UsageWallet.init(userId))); + + // ์‚ฌ์šฉ๊ถŒ ์ถฉ์ „ + wallet.chargeAiReport(usageCount); + usageWalletQuery.save(wallet); + + // ์ด๋ ฅ ๊ธฐ๋ก + usageHistoryQuery.save( + UsageHistory.charged( + userId, + usageCount, + wallet.getAiReportRemainingCount(), + orderId + ) + ); + } +} diff --git a/src/main/java/starlight/application/usage/provided/UsageCreditPort.java b/src/main/java/starlight/application/usage/provided/UsageCreditPort.java new file mode 100644 index 00000000..724eeb4c --- /dev/null +++ b/src/main/java/starlight/application/usage/provided/UsageCreditPort.java @@ -0,0 +1,6 @@ +package starlight.application.usage.provided; + +public interface UsageCreditPort { + + void chargeForOrder(Long userId, Long orderId, int usageCount); +} diff --git a/src/main/java/starlight/application/usage/provided/UsageHistoryQuery.java b/src/main/java/starlight/application/usage/provided/UsageHistoryQuery.java new file mode 100644 index 00000000..2f9c10af --- /dev/null +++ b/src/main/java/starlight/application/usage/provided/UsageHistoryQuery.java @@ -0,0 +1,8 @@ +package starlight.application.usage.provided; + +import starlight.domain.order.wallet.UsageHistory; + +public interface UsageHistoryQuery { + + UsageHistory save(UsageHistory usageHistory); +} diff --git a/src/main/java/starlight/application/usage/provided/UsageWalletQuery.java b/src/main/java/starlight/application/usage/provided/UsageWalletQuery.java new file mode 100644 index 00000000..37f99ee9 --- /dev/null +++ b/src/main/java/starlight/application/usage/provided/UsageWalletQuery.java @@ -0,0 +1,12 @@ +package starlight.application.usage.provided; + +import starlight.domain.order.wallet.UsageWallet; + +import java.util.Optional; + +public interface UsageWalletQuery { + + Optional findByUserId(Long userId); + + UsageWallet save(UsageWallet usageWallet); +} diff --git a/src/main/java/starlight/bootstrap/RestClientConfig.java b/src/main/java/starlight/bootstrap/RestClientConfig.java index 652d7c15..047d4ff5 100644 --- a/src/main/java/starlight/bootstrap/RestClientConfig.java +++ b/src/main/java/starlight/bootstrap/RestClientConfig.java @@ -77,4 +77,24 @@ public RestClient clovaStudioClient( .defaultHeader("Content-Type", "application/json") .build(); } + + @Bean(name = "tossRestClient") + public RestClient tossRestClient( + @Value("${toss.secretKey}") String secretKey, + @Value("${toss.baseUrl}") String baseUrl + ) { + JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(); + factory.setReadTimeout(Duration.ofSeconds(20)); // ํ•„์š” ์‹œ ์กฐ์ • + + String basic = java.util.Base64.getEncoder() + .encodeToString((secretKey + ":").getBytes(java.nio.charset.StandardCharsets.UTF_8)); + + return RestClient.builder() + .baseUrl(baseUrl) + .requestFactory(factory) + .defaultHeader("Authorization", "Basic " + basic) + .defaultHeader("Content-Type", "application/json") + .defaultHeader("Accept", "application/json") + .build(); + } } \ No newline at end of file diff --git a/src/main/java/starlight/bootstrap/SecurityConfig.java b/src/main/java/starlight/bootstrap/SecurityConfig.java index 45856d33..bd76996f 100644 --- a/src/main/java/starlight/bootstrap/SecurityConfig.java +++ b/src/main/java/starlight/bootstrap/SecurityConfig.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.security.servlet.PathRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; @@ -69,7 +70,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/error/**").permitAll() .requestMatchers("/actuator/health").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - .requestMatchers("/", "/index.html", "/ops.html").permitAll() + .requestMatchers("/", "/index.html", "/ops.html", "/payment.html", "/api/payment/**").permitAll() .requestMatchers("/v1/auth/**","/v1/user/**", "/v1/experts").permitAll() .requestMatchers("/login/**", "/oauth2/**", "/login/oauth2/**", "/public/**").permitAll() .requestMatchers("/v3/api-docs/**", "/v1/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/swagger-resources/**").permitAll() @@ -78,6 +79,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(HttpMethod.GET, "/v1/expert-reports/*").permitAll() .requestMatchers(HttpMethod.POST, "/v1/expert-reports/*").permitAll() + .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() .anyRequest().authenticated()) .oauth2Login(oauth -> oauth .loginPage("/login/oauth2/code/kakao") @@ -88,6 +90,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { response.sendError(HttpServletResponse.SC_UNAUTHORIZED); }) ); + return http.build(); } diff --git a/src/main/java/starlight/domain/expert/entity/Expert.java b/src/main/java/starlight/domain/expert/entity/Expert.java index 61bd9e6f..da0c009d 100644 --- a/src/main/java/starlight/domain/expert/entity/Expert.java +++ b/src/main/java/starlight/domain/expert/entity/Expert.java @@ -1,6 +1,7 @@ package starlight.domain.expert.entity; import jakarta.persistence.*; +import jakarta.validation.constraints.Min; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -29,6 +30,8 @@ public class Expert extends AbstractEntity { @Column(nullable = false, length = 320) private String email; + @Min(0) + @Column private Integer mentoringPriceWon; @ElementCollection diff --git a/src/main/java/starlight/domain/order/enumerate/OrderStatus.java b/src/main/java/starlight/domain/order/enumerate/OrderStatus.java new file mode 100644 index 00000000..f5095e8f --- /dev/null +++ b/src/main/java/starlight/domain/order/enumerate/OrderStatus.java @@ -0,0 +1,15 @@ +package starlight.domain.order.enumerate; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum OrderStatus { + + NEW("์ฃผ๋ฌธ ์ƒ์„ฑ๋จ (๊ฒฐ์ œ ์ „)"), + PAID("๊ฒฐ์ œ ์™„๋ฃŒ"), + CANCELED("์ฃผ๋ฌธ/๊ฒฐ์ œ ์ทจ์†Œ"); + + private final String description; +} diff --git a/src/main/java/starlight/domain/order/enumerate/UsageProductType.java b/src/main/java/starlight/domain/order/enumerate/UsageProductType.java new file mode 100644 index 00000000..2c857d24 --- /dev/null +++ b/src/main/java/starlight/domain/order/enumerate/UsageProductType.java @@ -0,0 +1,27 @@ +package starlight.domain.order.enumerate; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import starlight.domain.order.exception.OrderErrorType; +import starlight.domain.order.exception.OrderException; + +@Getter +@RequiredArgsConstructor +public enum UsageProductType { + + AI_REPORT_1("AI_REPORT_1", 1, 49_000L, "LITE ์š”๊ธˆ์ œ"), + AI_REPORT_2("AI_REPORT_2", 2, 89_000L, "STANDARD ์š”๊ธˆ์ œ"); + + private final String code; // ์ƒํ’ˆ ์ฝ”๋“œ + private final int usageCount; // ์‚ฌ์šฉ ๊ฐ€๋Šฅ ํšŸ์ˆ˜ + private final long price; // ๊ฐ€๊ฒฉ + private final String description; // ์„ค๋ช… + + public static UsageProductType fromCode(String code) { + return switch (code) { + case "AI_REPORT_1" -> AI_REPORT_1; + case "AI_REPORT_2" -> AI_REPORT_2; + default -> throw new OrderException(OrderErrorType.INVALID_USAGE_PRODUCT); + }; + } +} diff --git a/src/main/java/starlight/domain/order/exception/OrderErrorType.java b/src/main/java/starlight/domain/order/exception/OrderErrorType.java new file mode 100644 index 00000000..07047987 --- /dev/null +++ b/src/main/java/starlight/domain/order/exception/OrderErrorType.java @@ -0,0 +1,50 @@ +package starlight.domain.order.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import starlight.shared.apiPayload.exception.ErrorType; + +@Getter +@RequiredArgsConstructor +public enum OrderErrorType implements ErrorType { + + // PG ํ†ต์‹  ์—๋Ÿฌ + TOSS_CLIENT_CONFIRM_ERROR(HttpStatus.BAD_REQUEST, "ํ† ์Šค ๊ฒฐ์ œ ์š”์ฒญ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), + TOSS_CLIENT_CANCEL_ERROR(HttpStatus.BAD_REQUEST, "ํ† ์Šค ๊ฒฐ์ œ ์ทจ์†Œ ์š”์ฒญ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), + + // ๋„๋ฉ”์ธ ๊ทœ์น™ ์œ„๋ฐ˜ - ๊ฒฐ์ œ ์ƒํƒœ + ALREADY_PAID(HttpStatus.BAD_REQUEST, "์ด๋ฏธ ๊ฒฐ์ œ๊ฐ€ ์™„๋ฃŒ๋œ ์ฃผ๋ฌธ์ž…๋‹ˆ๋‹ค."), + INVALID_ORDER_STATE_FOR_PAYMENT(HttpStatus.BAD_REQUEST, "์ฃผ๋ฌธ ์ƒ์„ฑ ์ƒํƒœ์—์„œ๋งŒ ๊ฒฐ์ œ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค."), + INVALID_ORDER_STATE_FOR_CANCEL(HttpStatus.BAD_REQUEST, "๊ฒฐ์ œ ์™„๋ฃŒ ์ƒํƒœ์—์„œ๋งŒ ์ทจ์†Œ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค."), + + // ๋„๋ฉ”์ธ ๊ทœ์น™ ์œ„๋ฐ˜ - ๊ธˆ์•ก/์ฃผ๋ฌธ๋ฒˆํ˜ธ + PAYMENT_AMOUNT_MISMATCH(HttpStatus.BAD_REQUEST, "์ฃผ๋ฌธ ๊ธˆ์•ก๊ณผ ๊ฒฐ์ œ ๊ธˆ์•ก์ด ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + ORDER_CODE_BUYER_MISMATCH(HttpStatus.BAD_REQUEST, "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์ฃผ๋ฌธ๋ฒˆํ˜ธ์ž…๋‹ˆ๋‹ค. (๊ตฌ๋งค์ž ์ƒ์ด)"), + ORDER_CODE_BUSINESS_PLAN_MISMATCH(HttpStatus.BAD_REQUEST, "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์ฃผ๋ฌธ๋ฒˆํ˜ธ์ž…๋‹ˆ๋‹ค. (์‚ฌ์—…๊ณ„ํš์„œ ์ƒ์ด)"), + ORDER_PRODUCT_MISMATCH(HttpStatus.BAD_REQUEST, "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์ฃผ๋ฌธ๋ฒˆํ˜ธ์ž…๋‹ˆ๋‹ค. (์ด์šฉ๊ถŒ ๊ธˆ์•ก ์ƒ์ด)"), + + // ๊ฒฐ์ œ ์ด๋ ฅ ์—†์Œ + NO_REQUESTED_PAYMENT(HttpStatus.BAD_REQUEST, "์Šน์ธ ๊ฐ€๋Šฅํ•œ ๊ฒฐ์ œ ์‹œ๋„๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + NO_DONE_PAYMENT(HttpStatus.BAD_REQUEST, "์ทจ์†Œ ๊ฐ€๋Šฅํ•œ ๊ฒฐ์ œ ์ด๋ ฅ์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + NO_PAYMENT_RECORDS(HttpStatus.BAD_REQUEST, "์ฃผ๋ฌธ์— ๊ฒฐ์ œ ์ด๋ ฅ์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + NO_PAYMENT_KEY(HttpStatus.BAD_REQUEST, "paymentKey๊ฐ€ ์—†์–ด PG ์ทจ์†Œ๋ฅผ ์ˆ˜ํ–‰ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + + // ์กฐํšŒ ์‹คํŒจ + ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "์ฃผ๋ฌธ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + ALREADY_PAID_FOR_BUSINESS_PLAN(HttpStatus.BAD_REQUEST, "์ด๋ฏธ ๊ฒฐ์ œ๊ฐ€ ์™„๋ฃŒ๋œ ์‚ฌ์—…๊ณ„ํš์„œ์ž…๋‹ˆ๋‹ค."), + + // ์‚ฌ์šฉ ํšŸ์ˆ˜ ์ง€๊ฐ‘ ๊ด€๋ จ + INVALID_INITIAL_CHARGE_COUNT(HttpStatus.BAD_REQUEST, "์ดˆ๊ธฐ ์ถฉ์ „ ํšŸ์ˆ˜๋Š” 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."), + INVALID_USAGE_COUNT(HttpStatus.BAD_REQUEST, "์‚ฌ์šฉ/์ถฉ์ „ ํšŸ์ˆ˜๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."), + INSUFFICIENT_AI_REPORT_BALANCE(HttpStatus.BAD_REQUEST, "๋‚จ์€ AI ๋ฆฌํฌํŠธ ์‚ฌ์šฉ ๊ฐ€๋Šฅ ํšŸ์ˆ˜๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."), + + // ๊ธฐํƒ€ + UNSUPPORTED_CURRENCY(HttpStatus.BAD_REQUEST, "์ง€์›ํ•˜์ง€ ์•Š๋Š” ํ†ตํ™”์ž…๋‹ˆ๋‹ค."), + INVALID_USAGE_PRODUCT(HttpStatus.BAD_REQUEST, "์œ ํšจํ•˜์ง€ ์•Š์€ ์ด์šฉ๊ถŒ ๊ธˆ์•ก์ž…๋‹ˆ๋‹ค."), + ; + + private final HttpStatus status; + + private final String message; +} diff --git a/src/main/java/starlight/domain/order/exception/OrderException.java b/src/main/java/starlight/domain/order/exception/OrderException.java new file mode 100644 index 00000000..53d7cac8 --- /dev/null +++ b/src/main/java/starlight/domain/order/exception/OrderException.java @@ -0,0 +1,11 @@ +package starlight.domain.order.exception; + +import starlight.shared.apiPayload.exception.ErrorType; +import starlight.shared.apiPayload.exception.GlobalException; + +public class OrderException extends GlobalException { + + public OrderException(ErrorType errorType) { + super(errorType); + } +} diff --git a/src/main/java/starlight/domain/order/order/Orders.java b/src/main/java/starlight/domain/order/order/Orders.java new file mode 100644 index 00000000..a6732db3 --- /dev/null +++ b/src/main/java/starlight/domain/order/order/Orders.java @@ -0,0 +1,161 @@ +package starlight.domain.order.order; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import starlight.domain.order.enumerate.OrderStatus; +import starlight.domain.order.enumerate.UsageProductType; +import starlight.domain.order.exception.OrderErrorType; +import starlight.domain.order.exception.OrderException; +import starlight.domain.order.order.vo.Money; +import starlight.domain.order.order.vo.OrderCode; +import starlight.shared.AbstractEntity; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Orders extends AbstractEntity { + + @Column(length = 64, nullable = false, unique = true) + private String orderCode; + + @Column(name = "buyer_user_id", nullable = false) + private Long buyerId; + + @Enumerated(EnumType.STRING) + @Column(length = 20, nullable = false) + private OrderStatus status = OrderStatus.NEW; + + @Column(length = 3, nullable = false) + private String currency = "KRW"; + + @Column(nullable = false) + private Long price; + + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) + private List payments = new ArrayList<>(); + + @Version + private Long version; + + @Column(length = 50, nullable = false) + private String usageProductCode; // ์–ด๋–ค ์ƒํ’ˆ์ฝ”๋“œ๋กœ ์ƒ€๋Š”์ง€ + + @Column(nullable = false) + private Integer usageCount; // ๋ช‡ ํšŒ๊ถŒ์ธ์ง€ (1 / 2) + + public static Orders newUsageOrder(OrderCode orderCode, Long buyerId, Money money, UsageProductType product) { + Objects.requireNonNull(orderCode, "orderCode๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + Objects.requireNonNull(buyerId, "buyerId๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + Objects.requireNonNull(money, "money๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + Objects.requireNonNull(product, "product๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + + Orders orders = new Orders(); + orders.orderCode = orderCode.getValue(); + orders.buyerId = buyerId; + orders.price = money.getAmount(); + orders.currency = money.getCurrency(); + orders.usageProductCode = product.getCode(); + orders.usageCount = product.getUsageCount(); + orders.status = OrderStatus.NEW; + return orders; + } + + public void validateSameBuyer(Long buyerId) { + if (!Objects.equals(this.buyerId, buyerId)) { + throw new OrderException(OrderErrorType.ORDER_CODE_BUYER_MISMATCH); + } + } + + public void validateSameProduct(UsageProductType product) { + if (!Objects.equals(this.usageProductCode, product.getCode())) { + throw new OrderException(OrderErrorType.ORDER_PRODUCT_MISMATCH); + } + } + + /** + * ๊ฒฐ์ œ ์‹œ๋„ ์ถ”๊ฐ€ + * - ์ด๋ฏธ ๊ฒฐ์ œ ์™„๋ฃŒ๋œ ์ฃผ๋ฌธ์ด๋ฉด ์˜ˆ์™ธ + * - ์ฃผ๋ฌธ ๊ธˆ์•ก๊ณผ ๋‹ค๋ฅธ ๊ธˆ์•ก์ด๋ฉด ์˜ˆ์™ธ + */ + public void addPaymentAttempt(Money paymentMoney) { + // ์ด๋ฏธ ๊ฒฐ์ œ ์™„๋ฃŒ๋œ ์ฃผ๋ฌธ์ด๋ฉด ์‹œ๋„ ๋ถˆ๊ฐ€ + if (this.status == OrderStatus.PAID) { + throw new OrderException(OrderErrorType.ALREADY_PAID); + } + + // ์ฃผ๋ฌธ ๊ธˆ์•ก๊ณผ ๊ฒฐ์ œ ๊ธˆ์•ก์ด ์ผ์น˜ํ•ด์•ผ ํ•จ + Money orderMoney = Money.of(this.price, this.currency); + if (!orderMoney.equals(paymentMoney)) { + throw new OrderException(OrderErrorType.PAYMENT_AMOUNT_MISMATCH); + } + + // ๊ฒฐ์ œ ์‹œ๋„ ์ƒ์„ฑ ๋ฐ ์ถ”๊ฐ€ + PaymentRecords p = PaymentRecords.requestedFor(this, paymentMoney.getAmount()); + this.payments.add(p); + } + + /** + * ๊ฒฐ์ œ ์Šน์ธ ์ฒ˜๋ฆฌ + * - NEW ์ƒํƒœ์—์„œ๋งŒ ๊ฐ€๋Šฅ + */ + public void markPaid() { + if (this.status == OrderStatus.PAID) { + throw new OrderException(OrderErrorType.ALREADY_PAID); + } + if (this.status != OrderStatus.NEW) { + throw new OrderException(OrderErrorType.INVALID_ORDER_STATE_FOR_PAYMENT); + } + this.status = OrderStatus.PAID; + } + + /** + * ์ฃผ๋ฌธ/๊ฒฐ์ œ ์ทจ์†Œ + * - PAID ์ƒํƒœ์—์„œ๋งŒ ๊ฐ€๋Šฅ + */ + public void cancel() { + if (this.status != OrderStatus.PAID) { + throw new OrderException(OrderErrorType.INVALID_ORDER_STATE_FOR_CANCEL); + } + this.status = OrderStatus.CANCELED; + } + + /** + * ๊ฐ€์žฅ ์ตœ๊ทผ REQUESTED ์ƒํƒœ ๊ฒฐ์ œ ์‹œ๋„ ์กฐํšŒ + */ + public PaymentRecords getLatestRequestedOrThrow() { + return payments.stream() + .filter(p -> "REQUESTED".equals(p.getStatus())) + .max(Comparator.comparing(PaymentRecords::getCreatedAt)) + .orElseThrow(() -> new OrderException(OrderErrorType.NO_REQUESTED_PAYMENT)); + } + + /** + * ๊ฐ€์žฅ ์ตœ๊ทผ DONE ์ƒํƒœ ๊ฒฐ์ œ ์‹œ๋„ ์กฐํšŒ + */ + public PaymentRecords getLatestDoneOrThrow() { + return payments.stream() + .filter(p -> "DONE".equals(p.getStatus())) + .max(Comparator.comparing(p -> + p.getApprovedAt() != null ? p.getApprovedAt() : p.getCreatedAt() + )) + .orElseThrow(() -> new OrderException(OrderErrorType.NO_PAYMENT_RECORDS)); + } + + /** + * ๊ฐ€์žฅ ์ตœ๊ทผ ๊ฒฐ์ œ ์ด๋ ฅ ์กฐํšŒ (์Šน์ธ์‹œ๊ฐ„ ๊ธฐ์ค€) + */ + public PaymentRecords getLatestPaymentOrThrow() { + return payments.stream() + .max(Comparator.comparing(p -> + p.getApprovedAt() != null ? p.getApprovedAt() : p.getCreatedAt() + )) + .orElseThrow(() -> new OrderException(OrderErrorType.NO_DONE_PAYMENT)); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/domain/order/order/PaymentRecords.java b/src/main/java/starlight/domain/order/order/PaymentRecords.java new file mode 100644 index 00000000..30e00793 --- /dev/null +++ b/src/main/java/starlight/domain/order/order/PaymentRecords.java @@ -0,0 +1,125 @@ +package starlight.domain.order.order; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import starlight.domain.order.exception.OrderErrorType; +import starlight.domain.order.exception.OrderException; + +import java.time.Instant; +import java.util.Objects; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PaymentRecords { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", nullable = false) + private Orders order; + + @Column(length = 20, nullable = false) + private String pg; + + @Column(length = 128, unique = true) + private String paymentKey; + + @Column(length = 40) + private String method; + + @Column(length = 40) + private String provider; + + @Column(nullable = false) + private Long price; + + @Column(length = 20, nullable = false) + private String status; + + @Column(length = 255) + private String receiptUrl; + + private Instant approvedAt; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + void prePersist() { + createdAt = Instant.now(); + if (status == null) status = "REQUESTED"; + if (pg == null) pg = "TOSS"; + } + + /** + * ๊ฒฐ์ œ ์š”์ฒญ ์ƒ์„ฑ + * @param order ์—ฐ๊ด€๋œ ์ฃผ๋ฌธ + * @param amount ๊ฒฐ์ œ ๊ธˆ์•ก + * @return ์ƒ์„ฑ๋œ PaymentRecords + */ + public static PaymentRecords requestedFor(Orders order, Long amount) { + Objects.requireNonNull(order, "order๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + Objects.requireNonNull(amount, "amount๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + + PaymentRecords payment = new PaymentRecords(); + payment.order = order; + payment.pg = "TOSS"; + payment.status = "REQUESTED"; + payment.price = amount; + + return payment; + } + + /** + * ๊ฒฐ์ œ ์Šน์ธ ์™„๋ฃŒ ์ฒ˜๋ฆฌ + */ + public void markDone(String paymentKey, String method, String provider, + String receiptUrl, Instant approvedAt) { + + validateForCompletion(paymentKey); + + this.paymentKey = paymentKey; + this.method = method; + this.provider = provider; + this.receiptUrl = receiptUrl; + this.approvedAt = approvedAt != null ? approvedAt : Instant.now(); + this.status = "DONE"; + } + + /** + * ๊ฒฐ์ œ ์ทจ์†Œ ์ฒ˜๋ฆฌ + */ + public void markCanceled() { + if (!"DONE".equals(this.status)) { + throw new IllegalStateException( + "DONE ์ƒํƒœ์—์„œ๋งŒ ์ทจ์†Œ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. ํ˜„์žฌ ์ƒํƒœ: " + this.status + ); + } + this.status = "CANCELED"; + } + + /** + * ๊ฒฐ์ œํ‚ค๊ฐ€ ์—†์œผ๋ฉด PG ์ทจ์†Œ ๋ถˆ๊ฐ€๋Šฅ + */ + public void ensureHasPaymentKey() { + if (this.paymentKey == null || this.paymentKey.trim().isEmpty()) { + throw new OrderException(OrderErrorType.NO_PAYMENT_KEY); + } + } + + private void validateForCompletion(String paymentKey) { + if (!"REQUESTED".equals(this.status)) { + throw new IllegalStateException( + "REQUESTED ์ƒํƒœ์—์„œ๋งŒ ์Šน์ธ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. ํ˜„์žฌ ์ƒํƒœ: " + this.status + ); + } + if (paymentKey == null || paymentKey.trim().isEmpty()) { + throw new IllegalArgumentException("paymentKey๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + } +} \ No newline at end of file diff --git a/src/main/java/starlight/domain/order/order/vo/Money.java b/src/main/java/starlight/domain/order/order/vo/Money.java new file mode 100644 index 00000000..97de8cdd --- /dev/null +++ b/src/main/java/starlight/domain/order/order/vo/Money.java @@ -0,0 +1,70 @@ +package starlight.domain.order.order.vo; + +import java.util.Objects; + +/** + * ๊ธˆ์•ก์„ ํ‘œํ˜„ํ•˜๋Š” ๊ฐ’ ๊ฐ์ฒด + * ๋ถˆ๋ณ€ ๊ฐ์ฒด๋กœ ํ†ตํ™”์™€ ๊ธˆ์•ก์„ ํ•จ๊ป˜ ๊ด€๋ฆฌ + */ +public class Money { + + private final Long amount; + private final String currency; + + private Money(Long amount, String currency) { + if (amount == null || amount < 0) { + throw new IllegalArgumentException("๊ธˆ์•ก์€ 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (currency == null || currency.trim().isEmpty()) { + throw new IllegalArgumentException("ํ†ตํ™”๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + this.amount = amount; + this.currency = currency.toUpperCase(); + } + + public static Money of(Long amount, String currency) { + return new Money(amount, currency); + } + + public static Money krw(Long amount) { + return new Money(amount, "KRW"); + } + + public boolean isSameCurrency(Money other) { + return other != null && this.currency.equals(other.currency); + } + + public boolean equalsInSameCurrency(Money other) { + if (!isSameCurrency(other)) { + throw new IllegalArgumentException("๋‹ค๋ฅธ ํ†ตํ™”๋Š” ๋น„๊ตํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + return Objects.equals(this.amount, other.amount); + } + + public Long getAmount() { + return amount; + } + + public String getCurrency() { + return currency; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Money)) return false; + Money money = (Money) o; + return Objects.equals(amount, money.amount) && + Objects.equals(currency, money.currency); + } + + @Override + public String toString() { + return currency + " " + amount; + } + + @Override + public int hashCode() { + return Objects.hash(amount, currency); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/domain/order/order/vo/OrderCode.java b/src/main/java/starlight/domain/order/order/vo/OrderCode.java new file mode 100644 index 00000000..40e0e308 --- /dev/null +++ b/src/main/java/starlight/domain/order/order/vo/OrderCode.java @@ -0,0 +1,58 @@ +package starlight.domain.order.order.vo; + +import java.util.Objects; + +/** + * ์ฃผ๋ฌธ๋ฒˆํ˜ธ๋ฅผ ํ‘œํ˜„ํ•˜๋Š” ๊ฐ’ ๊ฐ์ฒด + * ํ”„๋ก ํŠธ์—”๋“œ์—์„œ ์ƒ์„ฑํ•œ ์ฃผ๋ฌธ๋ฒˆํ˜ธ๋ฅผ ๊ฒ€์ฆํ•˜๊ณ  ์บก์Аํ™” + */ +public class OrderCode { + + private final String value; + + private OrderCode(String value) { + validate(value); + this.value = value; + } + + private void validate(String value) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException("์ฃผ๋ฌธ๋ฒˆํ˜ธ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (value.length() > 64) { + throw new IllegalArgumentException("์ฃผ๋ฌธ๋ฒˆํ˜ธ๋Š” 64์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + if (!value.matches("^[a-zA-Z0-9_-]+$")) { + throw new IllegalArgumentException("์ฃผ๋ฌธ๋ฒˆํ˜ธ๋Š” ์˜๋ฌธ, ์ˆซ์ž, -, _ ๋งŒ ํ—ˆ์šฉ๋ฉ๋‹ˆ๋‹ค."); + } + } + + /** + * ํ”„๋ก ํŠธ์—”๋“œ์—์„œ ์ „๋‹ฌ๋ฐ›์€ ์ฃผ๋ฌธ๋ฒˆํ˜ธ๋กœ ๊ฐ’ ๊ฐ์ฒด ์ƒ์„ฑ + */ + public static OrderCode of(String value) { + return new OrderCode(value); + } + + public String getValue() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof OrderCode)) return false; + OrderCode orderCode = (OrderCode) o; + return Objects.equals(value, orderCode.value); + } + + @Override + public String toString() { + return value; + } + + @Override + public int hashCode() { + return Objects.hash(value); + } +} diff --git a/src/main/java/starlight/domain/order/wallet/UsageHistory.java b/src/main/java/starlight/domain/order/wallet/UsageHistory.java new file mode 100644 index 00000000..670e7f67 --- /dev/null +++ b/src/main/java/starlight/domain/order/wallet/UsageHistory.java @@ -0,0 +1,91 @@ +package starlight.domain.order.wallet; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import starlight.domain.order.exception.OrderErrorType; +import starlight.domain.order.exception.OrderException; + +import java.time.Instant; +import java.util.Objects; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UsageHistory { + + private static final String TYPE_CHARGE = "CHARGE"; + private static final String TYPE_USE = "USE"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long userId; // ๋ˆ„๊ฐ€ + + @Column + private Long businessPlanId; // ์–ด๋–ค ์‚ฌ์—…๊ณ„ํš์„œ์—์„œ (์‚ฌ์šฉ ์‹œ์—๋งŒ ์ฑ„์›€, ์ถฉ์ „์€ null ๊ฐ€๋Šฅ) + + @Column(nullable = false) + private String type; // CHARGE / USE ๋“ฑ + + @Column(nullable = false) + private Integer amount; // ์ด๋ฒˆ์— ์ฆ๊ฐ๋œ ํšŸ์ˆ˜ (์ถฉ์ „: +10, ์‚ฌ์šฉ: -1 ์ด๋Ÿฐ ๋А๋‚Œ) + + @Column(nullable = false) + private Integer balanceAfter; // ์ด ์ด๋ฒคํŠธ ์ดํ›„ ์ง€๊ฐ‘ ์ž”์—ฌ ํšŸ์ˆ˜ + + @Column + private Long orderId; // ์ด ์ถฉ์ „์ด ์–ด๋А ์ฃผ๋ฌธ์—์„œ ์˜จ ๊ฑด์ง€ ๋Œ€๋žต ์—ฐ๊ฒฐํ•˜๊ณ  ์‹ถ์œผ๋ฉด + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + void prePersist() { + if (createdAt == null) createdAt = Instant.now(); + } + + /** + * ์ถฉ์ „ ์ด๋ ฅ ๊ธฐ๋ก + */ + public static UsageHistory charged(Long userId, int amount, int balanceAfter, Long orderId) { + Objects.requireNonNull(userId, "userId๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + + if (amount <= 0) { + throw new OrderException(OrderErrorType.INVALID_USAGE_COUNT); + } + + UsageHistory history = new UsageHistory(); + history.userId = userId; + history.type = TYPE_CHARGE; + history.amount = amount; // ์ถฉ์ „์€ ์–‘์ˆ˜ ๊ทธ๋Œ€๋กœ + history.balanceAfter = balanceAfter; + history.orderId = orderId; // ์—†์œผ๋ฉด null + + return history; + } + + /** + * ์‚ฌ์šฉ ์ด๋ ฅ ๊ธฐ๋ก + */ + public static UsageHistory used(Long userId, Long businessPlanId, int amount, int balanceAfter) { + Objects.requireNonNull(userId, "userId๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + Objects.requireNonNull(businessPlanId, "businessPlanId๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + + if (amount <= 0) { + throw new OrderException(OrderErrorType.INVALID_USAGE_COUNT); + } + + UsageHistory history = new UsageHistory(); + history.userId = userId; + history.businessPlanId = businessPlanId; + history.type = TYPE_USE; + history.amount = -Math.abs(amount); // ์‚ฌ์šฉ์€ ํ•ญ์ƒ ์Œ์ˆ˜๋กœ ์ €์žฅ + history.balanceAfter = balanceAfter; + + return history; + } +} diff --git a/src/main/java/starlight/domain/order/wallet/UsageWallet.java b/src/main/java/starlight/domain/order/wallet/UsageWallet.java new file mode 100644 index 00000000..e86a6b1d --- /dev/null +++ b/src/main/java/starlight/domain/order/wallet/UsageWallet.java @@ -0,0 +1,72 @@ +package starlight.domain.order.wallet; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Version; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import starlight.domain.order.exception.OrderErrorType; +import starlight.domain.order.exception.OrderException; +import starlight.shared.AbstractEntity; + +import java.util.Objects; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UsageWallet extends AbstractEntity { + + @Column(nullable = false, unique = true) + private Long userId; + + @Column(nullable = false) + private Integer aiReportChargedCount; // AI ๋ฆฌํฌํŠธ: ์ง€๊ธˆ๊นŒ์ง€ ์ถฉ์ „๋œ ์ด ํšŸ์ˆ˜ + + @Column(nullable = false) + private Integer aiReportUsedCount; // AI ๋ฆฌํฌํŠธ: ์ง€๊ธˆ๊นŒ์ง€ ์‚ฌ์šฉ๋œ ์ด ํšŸ์ˆ˜ + + @Version + private Long version; + + /** + * ์ง€๊ฐ‘ ์ดˆ๊ธฐํ™” + */ + public static UsageWallet init(Long userId) { + UsageWallet wallet = new UsageWallet(); + wallet.userId = Objects.requireNonNull(userId, "userId๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + wallet.aiReportChargedCount = 0; + wallet.aiReportUsedCount = 0; + return wallet; + } + + /** + * AI ๋ฆฌํฌํŠธ ํšŸ์ˆ˜ ์ถฉ์ „ + */ + public void chargeAiReport(int count) { + if (count <= 0) { + throw new OrderException(OrderErrorType.INVALID_USAGE_COUNT); + } + this.aiReportChargedCount += count; + } + + /** + * ๋‚จ์€ AI ๋ฆฌํฌํŠธ ์‚ฌ์šฉ ๊ฐ€๋Šฅ ํšŸ์ˆ˜ + */ + public int getAiReportRemainingCount() { + return aiReportChargedCount - aiReportUsedCount; + } + + /** + * AI ๋ฆฌํฌํŠธ ์‚ฌ์šฉ + */ + public void useAiReport(int count) { + if (count <= 0) { + throw new OrderException(OrderErrorType.INVALID_USAGE_COUNT); + } + if (getAiReportRemainingCount() < count) { + throw new OrderException(OrderErrorType.INSUFFICIENT_AI_REPORT_BALANCE); + } + this.aiReportUsedCount += count; + } +} diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 0ad59da7..64b094a1 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -169,7 +169,8 @@

OpenAPI JSON Health Ops Dashboard - H2 Console + Toss + IamPort
diff --git a/src/main/resources/static/payment.html b/src/main/resources/static/payment.html new file mode 100644 index 00000000..b9050217 --- /dev/null +++ b/src/main/resources/static/payment.html @@ -0,0 +1,250 @@ + + + + + starlight ํฌ๋ ˆ๋”ง ๊ฒฐ์ œํ•˜๊ธฐ (Popup + postMessage) + + + + + + +
+

starlight ํฌ๋ ˆ๋”ง ๊ฒฐ์ œํ•˜๊ธฐ

+

์ƒํ’ˆ(์ด์šฉ๊ถŒ)์„ ์„ ํƒํ•˜๊ณ , ํŒ์—…์—์„œ ๊ฒฐ์ œ ํ›„ postMessage๋กœ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ›์Šต๋‹ˆ๋‹ค.

+ + +
+ + +
+ +
+
+ +
+ + + +
+ +
+
+ + + + diff --git a/src/main/resources/static/toss/popup.html b/src/main/resources/static/toss/popup.html new file mode 100644 index 00000000..4fa47efe --- /dev/null +++ b/src/main/resources/static/toss/popup.html @@ -0,0 +1,196 @@ + + + + + + ๊ฒฐ์ œ ์ฒ˜๋ฆฌ ์ค‘... + + + +
+

๊ฒฐ์ œ ์ฒ˜๋ฆฌ ์ค‘...

+
+

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

+
+ + + + diff --git a/src/test/java/starlight/application/businessplan/util/PlainTextExtractUtilsTest.java b/src/test/java/starlight/application/businessplan/util/PlainTextExtractUtilsTest.java index 9791ada5..7b8e03ad 100644 --- a/src/test/java/starlight/application/businessplan/util/PlainTextExtractUtilsTest.java +++ b/src/test/java/starlight/application/businessplan/util/PlainTextExtractUtilsTest.java @@ -18,7 +18,14 @@ void extractPlainText_fromContentArray() { "\"content\":[" + "{\"type\":\"text\",\"value\":\"Hello\"}," + "{\"type\":\"image\",\"caption\":\"cap\"}," + - "{\"type\":\"table\",\"columns\":[\"A\",\"B\"],\"rows\":[[\"1\",\"2\"],[\"3\",\"4\"]]}" + + "{\"type\":\"table\",\"columns\":[{\"width\":100},{\"width\":200}]," + + "\"rows\":[" + + "[{\"content\":[{\"type\":\"text\",\"value\":\"1\"}],\"rowspan\":1,\"colspan\":1}," + + "{\"content\":[{\"type\":\"text\",\"value\":\"2\"}],\"rowspan\":1,\"colspan\":1}]," + + "[{\"content\":[{\"type\":\"text\",\"value\":\"3\"}],\"rowspan\":1,\"colspan\":1}," + + "{\"content\":[{\"type\":\"text\",\"value\":\"4\"}],\"rowspan\":1,\"colspan\":1}]" + + "]" + + "}" + "]}"; String result = PlainTextExtractUtils.extractPlainText(mapper, json); @@ -26,7 +33,7 @@ void extractPlainText_fromContentArray() { assertThat(result).isEqualTo(String.join("\n", "Hello", "[์‚ฌ์ง„] cap", - "[\"A\", \"B\"]", + "[2 columns]", "[\"1\", \"2\"]", "[\"3\", \"4\"]")); } @@ -60,5 +67,48 @@ void extractPlainText_imageWithoutCaption() { void extractPlainText_invalidJson_throws() { assertThrows(IllegalArgumentException.class, () -> PlainTextExtractUtils.extractPlainText(mapper, "not-json")); } -} + @Test + @DisplayName("ํ…Œ์ด๋ธ” ์…€์—์„œ rowspan/colspan (lowercase) ์ง€์›") + void extractPlainText_tableWithLowercaseSpans() { + String json = "{" + + "\"content\":[" + + "{\"type\":\"table\",\"columns\":[{\"width\":100},{\"width\":200}]," + + "\"rows\":[" + + "[{\"content\":[{\"type\":\"text\",\"value\":\"A\"}],\"rowspan\":2,\"colspan\":1}," + + "{\"content\":[{\"type\":\"text\",\"value\":\"B\"}],\"rowspan\":1,\"colspan\":1}]," + + "[]" + + "]" + + "}" + + "]}"; + + String result = PlainTextExtractUtils.extractPlainText(mapper, json); + + assertThat(result).isEqualTo(String.join("\n", + "[2 columns]", + "[\"A\", \"B\"]", + "[\"\", \"\"]")); + } + + @Test + @DisplayName("ํ…Œ์ด๋ธ” ์…€์—์„œ rowSpan/colSpan (camelCase) ์ง€์›") + void extractPlainText_tableWithCamelCaseSpans() { + String json = "{" + + "\"content\":[" + + "{\"type\":\"table\",\"columns\":[{\"width\":100},{\"width\":200}]," + + "\"rows\":[" + + "[{\"content\":[{\"type\":\"text\",\"value\":\"X\"}],\"rowSpan\":2,\"colSpan\":1}," + + "{\"content\":[{\"type\":\"text\",\"value\":\"Y\"}],\"rowSpan\":1,\"colSpan\":1}]," + + "[{\"content\":[{\"type\":\"text\",\"value\":\"C\"}],\"rowSpan\":1,\"colSpan\":1}]" + + "]" + + "}" + + "]}"; + + String result = PlainTextExtractUtils.extractPlainText(mapper, json); + + assertThat(result).isEqualTo(String.join("\n", + "[2 columns]", + "[\"X\", \"Y\"]", + "[\"\", \"C\"]")); + } +} diff --git "a/\353\217\204\353\251\224\354\235\270\353\252\250\353\215\270.md" "b/\353\217\204\353\251\224\354\235\270\353\252\250\353\215\270.md" index c39e7641..8e8e6277 100644 --- "a/\353\217\204\353\251\224\354\235\270\353\252\250\353\215\270.md" +++ "b/\353\217\204\353\251\224\354\235\270\353\252\250\353\215\270.md" @@ -1,113 +1,427 @@ # StarLight ๋„๋ฉ”์ธ ๋ชจ๋ธ -## ์˜ˆ์‹œ๋กœ ๋„ฃ์–ด๋†“์€๊ฑฐ๋ผ์„œ ๋‚˜์ค‘์— ์ˆ˜์ •ํ•˜๋ฉด ๋  ๊ฒƒ ๊ฐ™์•„์š” - -## ๋„๋ฉ”์ธ ๋ชจ๋ธ ๋งŒ๋“ค๊ธฐ -1. ๋“ฃ๊ณ  ๋ฐฐ์šฐ๊ธฐ -2. '์ค‘์š”ํ•œ ๊ฒƒ'๋“ค ์ฐพ๊ธฐ (๊ฐœ๋… ์‹๋ณ„) -3. '์—ฐ๊ฒฐ ๊ณ ๋ฆฌ' ์ฐพ๊ธฐ (๊ด€๊ณ„ ์ •์˜) -4. '๊ฒƒ'๋“ค์„ ์„ค๋ช…ํ•˜๊ธฐ (์†์„ฑ ๋ฐ ๊ธฐ๋ณธ ํ–‰์œ„ ๋ช…์‹œ) -5. ๊ทธ๋ ค๋ณด๊ธฐ (์‹œ๊ฐํ™”) -6. ์ด์•ผ๊ธฐ ํ•˜๊ณ  ๋‹ค๋“ฌ๊ธฐ (๋ฐ˜๋ณต) - -## Splearn ๋„๋ฉ”์ธ -- ์Šคํ”„๋Ÿฐ์€ ํšŒ์›์ด ๊ฐ•์˜๋ฅผ ์ˆ˜๊ฐ•ํ•˜๋Š” ์˜จ๋ผ์ธ ์„œ๋น„์Šค์ด๋‹ค. -- ์Šคํ”„๋Ÿฐ์€ ์Šคํ”„๋ง ํ”„๋ ˆ์ž„์›Œํฌ์˜ ์ฒ ํ•™์„ ๋ฐ”ํƒ•์œผ๋กœ ๊ฐœ๋ฐœ๋˜๊ณ  ์šด์˜๋˜๋ฉฐ ๋ฐœ์ „ํ•˜๋Š” ํ•™์Šต ์ƒํƒœ๊ณ„๋ฅผ ๋ชฉํ‘œ๋กœ ํ•œ๋‹ค. - ์ด ์ƒํƒœ๊ณ„๋ฅผ ๊ตฌ์„ฑํ•˜๋Š” ํ•ต์‹ฌ ๊ตฌ์„ฑ ์š”์†Œ๋Š” ํ•™์Šต(์„ฑ์žฅ)ํ•˜๋Š” ํšŒ์›์ด๋‹ค. - - ๊ทธ๋ž˜์„œ ์ด๋ฆ„์ด Spring + Learner = Splearn - - ์œ ์‚ฌํ•œ ์ด๋ฆ„์œผ๋กœ ์ธํ”„๋Ÿฐ์ด ์žˆ๋‹ค. -- ์Šคํ”„๋Ÿฐ์—์„œ ํ™œ๋™ํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ํšŒ์›์œผ๋กœ ๋“ฑ๋กํ•ด์•ผ ํ•œ๋‹ค. - - ๋‹ค๋งŒ, ํšŒ์›์ด ๋˜๊ธฐ ์ „์—๋„ ์Šคํ”„๋Ÿฐ์— ๋Œ€ํ•œ ์†Œ๊ฐœ์™€ ๊ฐ•์˜ ์ •๋ณด๋ฅผ ์‚ดํŽด๋ณผ ์ˆ˜ ์žˆ๋‹ค. - - ๊ฐ•์˜๋ฅผ ์ˆ˜๊ฐ•ํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ๋“ฑ๋ก์„ ์™„๋ฃŒํ•˜๊ณ  ํ™œ๋™ ๊ฐ€๋Šฅํ•œ ํšŒ์›์ด ๋˜์–ด์•ผ ํ•œ๋‹ค. - - ๋“ฑ๋ก ์‹ ์ฒญ์„ ํ•œ ๋’ค ์ •ํ•ด์ง„ ์š”๊ฑด์„ ์ถฉ์กฑํ•˜๋ฉด ๋“ฑ๋ก์ด ์™„๋ฃŒ๋œ๋‹ค. - - ๋“ฑ๋ก์„ ์™„๋ฃŒํ•œ ๊ฒฝ์šฐ์— ํ”„๋กœํ•„ ์ฃผ์†Œ, ์ž๊ธฐ ์†Œ๊ฐœ๋ฅผ ๋“ฑ๋กํ•˜๊ฑฐ๋‚˜ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ๋‹ค. - - ํ”„๋กœํ•„ ์ฃผ์†Œ๋Š” ์•ŒํŒŒ๋ฒณ๊ณผ ์ˆซ์ž๋กœ ๊ตฌ์„ฑ๋œ 15์ž๋ฆฌ ์ด๋‚ด์˜ ์ค‘๋ณต๋˜์ง€ ์•Š์€ ๊ฐ’ - - ํƒˆํ‡ดํ•œ ํšŒ์›์˜ ํ”„๋กœํ•„ ์ฃผ์†Œ์™€ ์ž๊ธฐ ์†Œ๊ฐœ๋Š” ์ˆ˜์ •ํ•  ์ˆ˜ ์—†๋‹ค. - - ๋“ฑ๋ก ์‹œ๊ฐ„, ๋“ฑ๋ก ์™„๋ฃŒ ์‹œ๊ฐ„, ํƒˆํ‡ด ์‹œ๊ฐ„์„ ์ €์žฅํ•œ๋‹ค. -- ์›ํ•˜๋Š” ํšŒ์›์€ ์ž์‹ ์ด ๊ฐ€์ง„ ์ง€์‹๊ณผ ๊ฒฝํ—˜์„ ๊ฐ•์˜๋ผ๋Š” ํ˜•ํƒœ๋กœ ๋‹ค๋ฅธ ํšŒ์›์—๊ฒŒ ์ œ๊ณตํ•˜๋Š” ๊ฐ•์‚ฌ๊ฐ€ ๋  ์ˆ˜๋„ ์žˆ๋‹ค. - - ํšŒ์›์ด ์ตœ์ดˆ๋กœ ๊ฐ•์‚ฌ๊ฐ€ ๋˜๋ ค๋ฉด ๊ฐ•์‚ฌ ์‹ ์ฒญ๊ณผ ์Šน์ธ ๊ณผ์ •์„ ๊ฑฐ์น˜๋„๋ก ํ•œ๋‹ค. - - ์Šน์ธ๋œ ๊ฐ•์‚ฌ๋Š” ๊ฐ•์˜๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ๊ฒ€์ˆ˜๋ฅผ ๊ฑฐ์ณ ๊ฐ•์˜๋ฅผ ๊ณต๊ฐœํ•  ์ˆ˜ ์žˆ๋‹ค. - - ๊ณต๊ฐœ๋œ ๊ฐ•์˜๋Š” ํšŒ์›์—๊ฒŒ ๋…ธ์ถœ๋˜๊ณ , ํšŒ์›์€ ์ด ๊ฐ•์˜๋ฅผ ์ˆ˜๊ฐ•ํ•  ์ˆ˜ ์žˆ๋‹ค. -- ์ˆ˜๊ฐ•์€ ๊ฐ•์˜๋ฅผ ํ•™์Šตํ•˜๋Š” ๊ฒƒ์„ ๋งํ•œ๋‹ค. "๋‚ด๊ฐ€ ์ˆ˜๊ฐ•ํ•œ ๊ฐ•์˜๋Š” a์ด๋‹ค. ๋‚˜๋Š” b ๊ฐ•์˜ ์ˆ˜๊ฐ•์ค‘์ด๋‹ค." - - ์ˆ˜๊ฐ•์„ ์œ„ํ•ด์„œ๋Š” ๋จผ์ € ์ˆ˜๊ฐ• ์‹ ์ฒญ์ด ํ•„์š”ํ•˜๋‹ค. - - ์ˆ˜๊ฐ• ์‹ ์ฒญ์€ ๊ฐ•์˜์˜ ์ˆ˜๊ฐ• ์š”๊ฑด์— ๋”ฐ๋ผ์„œ ์‹ ์ฒญ์„ ํ•ด์•ผ ํ•˜๊ณ  ์ดํ›„ ์ถ”๊ฐ€ ์ ˆ์ฐจ๊ฐ€ ์š”๊ตฌ๋  ์ˆ˜ ์žˆ๋‹ค. - - ์ผ๋ถ€ ๊ฐ•์˜๋Š” ์ˆ˜๊ฐ• ์‹ ์ฒญ๊ณผ ๋™์‹œ์— ์ˆ˜๊ฐ•์ด ๊ฐ€๋Šฅํ•˜๋‹ค. - - ์–ด๋–ค ๊ฐ•์˜๋Š” ์ˆ˜๊ฐ• ์‹ ์ฒญํ›„ ๊ฐ•์˜๋น„ ๊ฒฐ์ œ๋ฅผ ์™„๋ฃŒํ•ด์•ผ ์ˆ˜๊ฐ•์ด ๊ฐ€๋Šฅํ•˜๋‹ค. - - ๊ฐ•์‚ฌ๋Š” ๊ฐ•์˜ ์ˆ˜๊ฐ• ๊ธฐ๊ฐ„์˜ ์ œํ•œ์„ ๋‘˜ ์ˆ˜๋„ ์žˆ๋‹ค. -- ๊ฐ•์˜๋Š” ์˜์ƒ, ๋ฌธ์„œ์™€ ๊ฐ™์€ ์ปจํ…์ธ ๋ฅผ ๊ฐ€์ง„ ํ•˜๋‚˜ ์ด์ƒ์˜ ์ˆ˜์—…์œผ๋กœ ๊ตฌ์„ฑ ๋œ๋‹ค. - - ์ˆ˜์—…์€ ๊ฐฏ์ˆ˜๊ฐ€ ๋งŽ์„ ์ˆ˜ ์žˆ๋‹ค. ๊ทธ๋ž˜์„œ ์ˆ˜์—…์€ ๋‹ค์‹œ ์„น์…˜์œผ๋กœ ๊ตฌ๋ถ„ํ•œ๋‹ค. - - ํ•˜๋‚˜์˜ ๊ฐ•์˜๋Š” ์—ฌ๋Ÿฌ ๊ฐœ์˜ ์„น์…˜๊ณผ ๊ฐ ์„น์…˜์— ์†ํ•œ ์ˆ˜์—…์œผ๋กœ ๊ตฌ์„ฑ๋œ๋‹ค. - - ์ˆ˜์—…๊ณผ ์„น์…˜์€ ํ•™์Šต ์ˆœ์„œ๋ฅผ ๊ฐ€์ง„๋‹ค. - - ํ•™์Šต ์ง„๋„๋Š” ๋งค ์ˆ˜์—… ๋‹จ์œ„๋กœ ๊ธฐ๋ก๋œ๋‹ค. - - ๊ฐ•์˜์˜ ๋ชจ๋“  ์ˆ˜์—…์˜ ํ•™์Šต์„ ๋งˆ์น˜๊ณ  ์ˆ˜์—… ์ง„๋„๊ฐ€ 100%์— ๋„๋‹ฌํ•˜๋ฉด ๊ฐ•์˜ ์ˆ˜๊ฐ•์„ ์™„๋ฃŒํ•œ ๊ฒƒ์ด๋‹ค. + +## StarLight ๋„๋ฉ”์ธ +- StarLight๋Š” ์ฐฝ์—…์ž๊ฐ€ ์‚ฌ์—…๊ณ„ํš์„œ๋ฅผ ์ž‘์„ฑํ•˜๊ณ , ์ „๋ฌธ๊ฐ€์˜ ํ”ผ๋“œ๋ฐฑ๊ณผ AI ๋ถ„์„์„ ๋ฐ›์„ ์ˆ˜ ์žˆ๋Š” ์„œ๋น„์Šค์ด๋‹ค. +- ์ฐฝ์—…์ž(Member with MemberType.FOUNDER)๋Š” ์‚ฌ์—…๊ณ„ํš์„œ๋ฅผ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. +- ์‚ฌ์—…๊ณ„ํš์„œ๋Š” 5๊ฐœ์˜ ์ฃผ์š” ์„น์…˜(๊ฐœ์š”, ๋ฌธ์ œ์ธ์‹, ์‹คํ˜„๊ฐ€๋Šฅ์„ฑ, ์„ฑ์žฅ์ „๋žต, ํŒ€์—ญ๋Ÿ‰)์œผ๋กœ ๊ตฌ์„ฑ๋˜๋ฉฐ, ๊ฐ ์„น์…˜์€ ์—ฌ๋Ÿฌ ์„œ๋ธŒ ์„น์…˜์œผ๋กœ ์„ธ๋ถ„ํ™”๋œ๋‹ค. +- ์ž‘์„ฑ๋œ ์‚ฌ์—…๊ณ„ํš์„œ์— ๋Œ€ํ•ด AI๊ฐ€ ์ž๋™์œผ๋กœ ๋ถ„์„ ๋ฆฌํฌํŠธ๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. +- ์ฐฝ์—…์ž๋Š” ์ „๋ฌธ๊ฐ€์—๊ฒŒ ํ”ผ๋“œ๋ฐฑ์„ ์‹ ์ฒญํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ๋™์ผํ•œ ์‚ฌ์—…๊ณ„ํš์„œ์— ๋Œ€ํ•ด ๋™์ผํ•œ ์ „๋ฌธ๊ฐ€์—๊ฒŒ๋Š” 1ํšŒ๋งŒ ์‹ ์ฒญ์ด ๊ฐ€๋Šฅํ•˜๋‹ค. +- ์ „๋ฌธ๊ฐ€(Expert)๋Š” ํ”ผ๋“œ๋ฐฑ ์‹ ์ฒญ์„ ๋ฐ›์œผ๋ฉด 7์ผ ์ด๋‚ด์— ๋ฆฌํฌํŠธ๋ฅผ ์ž‘์„ฑํ•˜์—ฌ ์ œ์ถœํ•ด์•ผ ํ•œ๋‹ค. +- ์ „๋ฌธ๊ฐ€ ํ”ผ๋“œ๋ฐฑ ์‹ ์ฒญ์€ ๊ฒฐ์ œ๋ฅผ ํ†ตํ•ด ์ด๋ฃจ์–ด์ง€๋ฉฐ, ํ† ์ŠคํŽ˜์ด๋จผ์ธ ๋ฅผ ํ†ตํ•ด ์ฒ˜๋ฆฌ๋œ๋‹ค. ## ๋„๋ฉ”์ธ ๋ชจ๋ธ --- -### [ ํ”ผ๋“œ๋ฐฑ ์‹ ์ฒญ ์–ด๊ทธ๋ฆฌ๊ฑฐํŠธ ] +### [ ํšŒ์› ์–ด๊ทธ๋ฆฌ๊ฑฐํŠธ ] -### ํ”ผ๋“œ๋ฐฑ ์‹ ์ฒญ(ExpertApplication) +### ํšŒ์›(Member) _Aggregate Root_ #### ์†์„ฑ - `id`: `Long` +- `name`: `String` - ์ด๋ฆ„ +- `email`: `String` - ์ด๋ฉ”์ผ +- `profileImageUrl`: `String` - ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ URL +- `phoneNumber`: `String` - ์ „ํ™”๋ฒˆํ˜ธ +- `memberType`: `MemberType` - ํšŒ์› ํƒ€์ž… (FOUNDER, EXPERT) +- `credential`: `Credential` - ์ธ์ฆ ์ •๋ณด (1:1 ๊ด€๊ณ„) +- `provider`: `String` - ์ธ์ฆ ์ œ๊ณต์ž (starlight, kakao, google ๋“ฑ) +- `providerId`: `String` - ์ œ๊ณต์ž ๊ณ ์œ  ID + #### ํ–‰์œ„ -- `static register()`: ํšŒ์› ๋“ฑ๋ก: email, nickname, password, passwordEncoder +- `static create()`: ์ผ๋ฐ˜ ํšŒ์› ์ƒ์„ฑ (์ด๋ฆ„, ์ด๋ฉ”์ผ, ์ „ํ™”๋ฒˆํ˜ธ, ํšŒ์›ํƒ€์ž…, ์ธ์ฆ์ •๋ณด, ํ”„๋กœํ•„์ด๋ฏธ์ง€) +- `static newSocial()`: ์†Œ์…œ ๋กœ๊ทธ์ธ ํšŒ์› ์ƒ์„ฑ (์ด๋ฆ„, ์ด๋ฉ”์ผ, ์ œ๊ณต์ž, ์ œ๊ณต์žID, ์ „ํ™”๋ฒˆํ˜ธ, ํšŒ์›ํƒ€์ž…, ํ”„๋กœํ•„์ด๋ฏธ์ง€) +- `updateProfileImage()`: ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์—…๋ฐ์ดํŠธ + #### ๊ทœ์น™ -- ์‚ฌ์—…๊ณ„ํš์„œ 1๊ฐœ๋‹น ์—ฌ๋Ÿฌ ์ „๋ฌธ๊ฐ€์—๊ฒŒ ํ”ผ๋“œ๋ฐฑ ์‹ ์ฒญ์ด ๊ฐ€๋Šฅํ•˜๋‹ค. -- 1๊ฐœ์— ์‚ฌ์—… ๊ณ„ํš์„œ์— ๋Œ€ํ•ด์„  ๋™์ผํ•œ ์ „๋ฌธ๊ฐ€์—๊ฒŒ ์ค‘๋ณต ์‹ ์ฒญ ๋ถˆ๊ฐ€ํ•˜๋‹ค.(1ํšŒ๋งŒ ํ”ผ๋“œ๋ฐฑ ์‹ ์ฒญ์ด ๊ฐ€๋Šฅํ•˜๋‹ค) +- ํšŒ์›์€ FOUNDER(์ฐฝ์—…์ž) ๋˜๋Š” EXPERT(์ „๋ฌธ๊ฐ€) ํƒ€์ž… ์ค‘ ํ•˜๋‚˜๋ฅผ ๊ฐ€์ง„๋‹ค. +- ์†Œํ”„ํŠธ ์‚ญ์ œ๋ฅผ ์ง€์›ํ•œ๋‹ค. -### ํšŒ์› ์ƒ์„ธ(MemberDetail) +### ์ธ์ฆ ์ •๋ณด(Credential) _Entity_ +#### ์†์„ฑ - `id`: `Long` -- `profile`: ํ”„๋กœํ•„ ์ฃผ์†Œ. ๋ชจ๋“  ํšŒ์›์ด ๊ณ ์œ ํ•œ ํ”„๋กœํ•„ ์ฃผ์†Œ๋ฅผ ๊ฐ€์ ธ์•ผ ํ•œ๋‹ค -- `introduction`: ์ž๊ธฐ ์†Œ๊ฐœ -- `registeredAt`: ๋“ฑ๋ก ์ผ์‹œ -- `activatedAt`: ๋“ฑ๋ก ์™„๋ฃŒ ์ผ์‹œ -- `deactivatedAt`: ํƒˆํ‡ด ์ผ์‹œ -#### ํ–‰์œ„ -- `static create()`: ํšŒ์› ๋“ฑ๋ก. ํ˜„์žฌ ์‹œ๊ฐ„์„ ๋“ฑ๋ก ์ผ์‹œ๋กœ ์ €์žฅํ•œ๋‹ค. -- `activate()`: ๋“ฑ๋ก ์™„๋ฃŒ์™€ ๊ด€๋ จ๋œ ์ž‘์—… ์ˆ˜ํ–‰. ๋“ฑ๋ก ์™„๋ฃŒ ์ผ์‹œ ์ €์žฅ. -- `deactivate()`: ํƒˆํ‡ด์™€ ๊ด€๋ จ๋œ ์ž‘์—… ์ˆ˜์ •. ํƒˆํ‡ด ์ผ์‹œ ์ €์žฅ. -- `updateInfo()`: ์ƒ์„ธ ์ •๋ณด ์ˆ˜์ • - -### ํšŒ์› ์ƒํƒœ(MemberStatus) +- `password`: `String` - ์•”ํ˜ธํ™”๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ + +#### ํ–‰์œ„ +- `static create()`: ์ธ์ฆ ์ •๋ณด ์ƒ์„ฑ (์•”ํ˜ธํ™”๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ) + +### ํšŒ์› ํƒ€์ž…(MemberType) _Enum_ #### ์ƒ์ˆ˜ -- `PENDING`: ๋“ฑ๋ก ๋Œ€๊ธฐ -- `ACTIVE`: ๋“ฑ๋ก ์™„๋ฃŒ -- `DEACTIVATED`: ํƒˆํ‡ด +- `FOUNDER`: ์ฐฝ์—…์ž +- `EXPERT`: ์ „๋ฌธ๊ฐ€ + +--- + +### [ ์ „๋ฌธ๊ฐ€ ์–ด๊ทธ๋ฆฌ๊ฑฐํŠธ ] + +### ์ „๋ฌธ๊ฐ€(Expert) +_Aggregate Root_ +#### ์†์„ฑ +- `id`: `Long` +- `name`: `String` - ์ด๋ฆ„ +- `email`: `String` - ์ด๋ฉ”์ผ +- `workedPeriod`: `Long` - ๊ฒฝ๋ ฅ ๊ธฐ๊ฐ„ +- `profileImageUrl`: `String` - ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ URL +- `mentoringPriceWon`: `Integer` - ๋ฉ˜ํ† ๋ง ๊ฐ€๊ฒฉ (์›) +- `careers`: `List` - ๊ฒฝ๋ ฅ ๋ชฉ๋ก +- `tags`: `Set` - ํƒœ๊ทธ ๋ชฉ๋ก +- `categories`: `Set` - ์ „๋ฌธ ๋ถ„์•ผ ์นดํ…Œ๊ณ ๋ฆฌ + +#### ๊ทœ์น™ +- Member์™€ ๋…๋ฆฝ์ ์œผ๋กœ ์กด์žฌํ•˜๋Š” ๋ณ„๋„์˜ ์—”ํ‹ฐํ‹ฐ์ด๋‹ค. -### DuplicateEmailException -_Exception_ +### ์ „๋ฌธ๊ฐ€ ํƒœ๊ทธ ์นดํ…Œ๊ณ ๋ฆฌ(TagCategory) +_Enum_ +#### ์ƒ์ˆ˜ +- `MARKET_BM`: ์‹œ์žฅ์„ฑ/BM +- `TEAM_CAPABILITY`: ํŒ€ ์—ญ๋Ÿ‰ +- `PROBLEM_DEFINITION`: ๋ฌธ์ œ ์ •์˜ +- `GROWTH_STRATEGY`: ์„ฑ์žฅ ์ „๋žต +- `METRIC_DATA`: ์ง€ํ‘œ/๋ฐ์ดํ„ฐ + +--- + +### [ ์‚ฌ์—…๊ณ„ํš์„œ ์–ด๊ทธ๋ฆฌ๊ฑฐํŠธ ] + +### ์‚ฌ์—…๊ณ„ํš์„œ(BusinessPlan) +_Aggregate Root_ +#### ์†์„ฑ +- `id`: `Long` +- `memberId`: `Long` - ์ž‘์„ฑ์ž ํšŒ์› ID +- `title`: `String` - ์ œ๋ชฉ +- `pdfUrl`: `String` - PDF ํŒŒ์ผ URL (์„ ํƒ) +- `planStatus`: `PlanStatus` - ์‚ฌ์—…๊ณ„ํš์„œ ์ƒํƒœ +- `overview`: `Overview` - ๊ฐœ์š” ์„น์…˜ (1:1 ๊ด€๊ณ„) +- `problemRecognition`: `ProblemRecognition` - ๋ฌธ์ œ ์ธ์‹ ์„น์…˜ (1:1 ๊ด€๊ณ„) +- `feasibility`: `Feasibility` - ์‹คํ˜„ ๊ฐ€๋Šฅ์„ฑ ์„น์…˜ (1:1 ๊ด€๊ณ„) +- `growthTactic`: `GrowthTactic` - ์„ฑ์žฅ ์ „๋žต ์„น์…˜ (1:1 ๊ด€๊ณ„) +- `teamCompetence`: `TeamCompetence` - ํŒ€ ์—ญ๋Ÿ‰ ์„น์…˜ (1:1 ๊ด€๊ณ„) + +#### ํ–‰์œ„ +- `static create()`: ์‚ฌ์—…๊ณ„ํš์„œ ์ƒ์„ฑ (์ œ๋ชฉ, ํšŒ์›ID) - STARTED ์ƒํƒœ๋กœ ์ดˆ๊ธฐํ™” +- `static createWithPdf()`: PDF ๊ธฐ๋ฐ˜ ์‚ฌ์—…๊ณ„ํš์„œ ์ƒ์„ฑ (์ œ๋ชฉ, ํšŒ์›ID, PDF URL) - WRITTEN_COMPLETED ์ƒํƒœ๋กœ ์ดˆ๊ธฐํ™” +- `isOwnedBy()`: ์†Œ์œ ์ž ํ™•์ธ +- `isPdfBased()`: PDF ๊ธฐ๋ฐ˜ ์—ฌ๋ถ€ ํ™•์ธ +- `updateTitle()`: ์ œ๋ชฉ ์—…๋ฐ์ดํŠธ +- `updateStatus()`: ์ƒํƒœ ์—…๋ฐ์ดํŠธ +- `areWritingCompleted()`: ๋ชจ๋“  ์„œ๋ธŒ ์„น์…˜์ด ์ž‘์„ฑ ์™„๋ฃŒ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ + +#### ๊ทœ์น™ +- ์‚ฌ์—…๊ณ„ํš์„œ ์ƒ์„ฑ ์‹œ 5๊ฐœ์˜ ์„น์…˜์ด ์ž๋™์œผ๋กœ ์ดˆ๊ธฐํ™”๋œ๋‹ค. +- ๋ชจ๋“  ์„œ๋ธŒ ์„น์…˜์ด ์ž‘์„ฑ๋˜๋ฉด ์ž‘์„ฑ ์™„๋ฃŒ๋กœ ํŒ๋‹จ๋œ๋‹ค. +- PDF ๊ธฐ๋ฐ˜ ์‚ฌ์—…๊ณ„ํš์„œ๋Š” ๋ณ„๋„๋กœ ์ƒ์„ฑ ๊ฐ€๋Šฅํ•˜๋‹ค. + +### ์„น์…˜ ํƒ€์ž…(SectionType) +_Enum_ +#### ์ƒ์ˆ˜ +- `OVERVIEW`: ๊ฐœ์š” +- `PROBLEM_RECOGNITION`: ๋ฌธ์ œ ์ธ์‹ +- `FEASIBILITY`: ์‹คํ˜„ ๊ฐ€๋Šฅ์„ฑ +- `GROWTH_STRATEGY`: ์„ฑ์žฅ ์ „๋žต +- `TEAM_COMPETENCE`: ํŒ€ ์—ญ๋Ÿ‰ + +### ๊ธฐ๋ณธ ์„น์…˜(BaseSection) +_MappedSuperclass (Abstract)_ +#### ์†์„ฑ +- `id`: `Long` - BusinessPlan๊ณผ ๊ณต์œ ํ•˜๋Š” ๊ธฐ๋ณธํ‚ค +- `businessPlan`: `BusinessPlan` - ์—ฐ๊ด€๋œ ์‚ฌ์—…๊ณ„ํš์„œ -### ๋น„๋ฐ€๋ฒˆํ˜ธ ์ธ์ฝ”๋”(PasswordEncoder) -_Domain Service_ #### ํ–‰์œ„ -- `encode()`: ๋น„๋ฐ€๋ฒˆํ˜ธ ์•”ํ˜ธํ™”ํ•˜๊ธฐ -- `matches()`: ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜๋Š”์ง€ ํ™•์ธ +- `attachBusinessPlan()`: ์‚ฌ์—…๊ณ„ํš์„œ ์—ฐ๊ฒฐ +- `putSubSection()`: ์„œ๋ธŒ ์„น์…˜ ์ถ”๊ฐ€ +- `removeSubSection()`: ์„œ๋ธŒ ์„น์…˜ ์ œ๊ฑฐ +- `getSubSectionByType()`: ํƒ€์ž…๋ณ„ ์„œ๋ธŒ ์„น์…˜ ์กฐํšŒ (์ถ”์ƒ ๋ฉ”์„œ๋“œ) +- `setSubSectionByType()`: ํƒ€์ž…๋ณ„ ์„œ๋ธŒ ์„น์…˜ ์„ค์ • (์ถ”์ƒ ๋ฉ”์„œ๋“œ) +- `areAllSubSectionsCreated()`: ๋ชจ๋“  ์„œ๋ธŒ ์„น์…˜ ์ƒ์„ฑ ์—ฌ๋ถ€ (์ถ”์ƒ ๋ฉ”์„œ๋“œ) + +### ๊ฐœ์š”(Overview) +_Entity (extends BaseSection)_ +#### ์†์„ฑ +- `overviewBasic`: `SubSection` - ๊ธฐ๋ณธ ๊ฐœ์š” ์„œ๋ธŒ ์„น์…˜ (1:1 ๊ด€๊ณ„) + +#### ํ–‰์œ„ +- `static create()`: ๊ฐœ์š” ์„น์…˜ ์ƒ์„ฑ + +### ๋ฌธ์ œ ์ธ์‹(ProblemRecognition) +_Entity (extends BaseSection)_ +#### ์†์„ฑ +- `problemBackground`: `SubSection` - ์ฐฝ์—… ๋ฐฐ๊ฒฝ ๋ฐ ๊ฐœ๋ฐœ๋™๊ธฐ (1:1 ๊ด€๊ณ„) +- `problemPurpose`: `SubSection` - ์ฐฝ์—…์•„์ดํ…œ์˜ ๋ชฉ์  ๋ฐ ํ•„์š”์„ฑ (1:1 ๊ด€๊ณ„) +- `problemMarket`: `SubSection` - ์ฐฝ์—…์•„์ดํ…œ์˜ ๋ชฉํ‘œ์‹œ์žฅ ๋ถ„์„ (1:1 ๊ด€๊ณ„) + +#### ํ–‰์œ„ +- `static create()`: ๋ฌธ์ œ ์ธ์‹ ์„น์…˜ ์ƒ์„ฑ + +### ์‹คํ˜„ ๊ฐ€๋Šฅ์„ฑ(Feasibility) +_Entity (extends BaseSection)_ +#### ์†์„ฑ +- `feasibilityStrategy`: `SubSection` - ์‚ฌ์—…ํ™” ์ „๋žต (1:1 ๊ด€๊ณ„) +- `feasibilityMarket`: `SubSection` - ์‹œ์žฅ๋ถ„์„ ๋ฐ ๊ฒฝ์Ÿ๋ ฅ ํ™•๋ณด ๋ฐฉ์•ˆ (1:1 ๊ด€๊ณ„) + +#### ํ–‰์œ„ +- `static create()`: ์‹คํ˜„ ๊ฐ€๋Šฅ์„ฑ ์„น์…˜ ์ƒ์„ฑ + +### ์„ฑ์žฅ ์ „๋žต(GrowthTactic) +_Entity (extends BaseSection)_ +#### ์†์„ฑ +- `growthModel`: `SubSection` - ๋น„์ฆˆ๋‹ˆ์Šค ๋ชจ๋ธ (1:1 ๊ด€๊ณ„) +- `growthFunding`: `SubSection` - ์ž๊ธˆ์กฐ๋‹ฌ ๊ณ„ํš (1:1 ๊ด€๊ณ„) +- `growthEntry`: `SubSection` - ์‹œ์žฅ์ง„์ž… ๋ฐ ์„ฑ๊ณผ์ฐฝ์ถœ ์ „๋žต (1:1 ๊ด€๊ณ„) + +#### ํ–‰์œ„ +- `static create()`: ์„ฑ์žฅ ์ „๋žต ์„น์…˜ ์ƒ์„ฑ + +### ํŒ€ ์—ญ๋Ÿ‰(TeamCompetence) +_Entity (extends BaseSection)_ +#### ์†์„ฑ +- `teamFounder`: `SubSection` - ์ฐฝ์—…์ž์˜ ์—ญ๋Ÿ‰ (1:1 ๊ด€๊ณ„) +- `teamMembers`: `SubSection` - ํŒ€ ์—ญ๋Ÿ‰ (1:1 ๊ด€๊ณ„) + +#### ํ–‰์œ„ +- `static create()`: ํŒ€ ์—ญ๋Ÿ‰ ์„น์…˜ ์ƒ์„ฑ + +### ์„œ๋ธŒ ์„น์…˜(SubSection) +_Entity_ +#### ์†์„ฑ +- `id`: `Long` +- `subSectionType`: `SubSectionType` - ์„œ๋ธŒ ์„น์…˜ ํƒ€์ž… +- `content`: `String` - ๋‚ด์šฉ (TEXT) +- `rawJson`: `RawJson` - ์›๋ณธ JSON ๋ฐ์ดํ„ฐ (TEXT) +- `checkFirst`: `boolean` - ์ฒดํฌ๋ฆฌ์ŠคํŠธ 1๋ฒˆ +- `checkSecond`: `boolean` - ์ฒดํฌ๋ฆฌ์ŠคํŠธ 2๋ฒˆ +- `checkThird`: `boolean` - ์ฒดํฌ๋ฆฌ์ŠคํŠธ 3๋ฒˆ +- `checkFourth`: `boolean` - ์ฒดํฌ๋ฆฌ์ŠคํŠธ 4๋ฒˆ +- `checkFifth`: `boolean` - ์ฒดํฌ๋ฆฌ์ŠคํŠธ 5๋ฒˆ + +#### ํ–‰์œ„ +- `static create()`: ์„œ๋ธŒ ์„น์…˜ ์ƒ์„ฑ (ํƒ€์ž…, ๋‚ด์šฉ, ์›๋ณธJSON, ์ฒดํฌ๋ฆฌ์ŠคํŠธ) +- `update()`: ์„œ๋ธŒ ์„น์…˜ ์—…๋ฐ์ดํŠธ (๋‚ด์šฉ, ์›๋ณธJSON, ์ฒดํฌ๋ฆฌ์ŠคํŠธ) +- `getChecks()`: ์ฒดํฌ๋ฆฌ์ŠคํŠธ ์กฐํšŒ + +#### ๊ทœ์น™ +- ์ฒดํฌ๋ฆฌ์ŠคํŠธ๋Š” ํ•ญ์ƒ 5๊ฐœ์˜ ํ•ญ๋ชฉ์„ ๊ฐ€์ง„๋‹ค. + +### ์„œ๋ธŒ ์„น์…˜ ํƒ€์ž…(SubSectionType) +_Enum_ +#### ์ƒ์ˆ˜ (๊ฐœ์š”) +- `OVERVIEW_BASIC`: ๊ฐœ์š” + +#### ์ƒ์ˆ˜ (๋ฌธ์ œ ์ธ์‹) +- `PROBLEM_BACKGROUND`: ์ฐฝ์—… ๋ฐฐ๊ฒฝ ๋ฐ ๊ฐœ๋ฐœ๋™๊ธฐ +- `PROBLEM_PURPOSE`: ์ฐฝ์—…์•„์ดํ…œ์˜ ๋ชฉ์  ๋ฐ ํ•„์š”์„ฑ +- `PROBLEM_MARKET`: ์ฐฝ์—…์•„์ดํ…œ์˜ ๋ชฉํ‘œ์‹œ์žฅ ๋ถ„์„ + +#### ์ƒ์ˆ˜ (์‹คํ˜„ ๊ฐ€๋Šฅ์„ฑ) +- `FEASIBILITY_STRATEGY`: ์‚ฌ์—…ํ™” ์ „๋žต +- `FEASIBILITY_MARKET`: ์‹œ์žฅ๋ถ„์„ ๋ฐ ๊ฒฝ์Ÿ๋ ฅ ํ™•๋ณด ๋ฐฉ์•ˆ + +#### ์ƒ์ˆ˜ (์„ฑ์žฅ ์ „๋žต) +- `GROWTH_MODEL`: ๋น„์ฆˆ๋‹ˆ์Šค ๋ชจ๋ธ +- `GROWTH_FUNDING`: ์ž๊ธˆ์กฐ๋‹ฌ ๊ณ„ํš +- `GROWTH_ENTRY`: ์‹œ์žฅ์ง„์ž… ๋ฐ ์„ฑ๊ณผ์ฐฝ์ถœ ์ „๋žต + +#### ์ƒ์ˆ˜ (ํŒ€ ์—ญ๋Ÿ‰) +- `TEAM_FOUNDER`: ์ฐฝ์—…์ž์˜ ์—ญ๋Ÿ‰ +- `TEAM_MEMBERS`: ํŒ€ ์—ญ๋Ÿ‰ + +### ์‚ฌ์—…๊ณ„ํš์„œ ์ƒํƒœ(PlanStatus) +_Enum_ +#### ์ƒ์ˆ˜ +- `STARTED`: ์‹œ์ž‘๋จ +- `WRITTEN_COMPLETED`: ์ž‘์„ฑ ์™„๋ฃŒ +- `AI_REVIEWED`: AI ๋ฆฌ๋ทฐ ์™„๋ฃŒ +- `EXPERT_MATCHED`: ์ „๋ฌธ๊ฐ€ ๋งค์นญ ์™„๋ฃŒ +- `FINALIZED`: ์ตœ์ข… ์™„๋ฃŒ -### ํ”„๋กœํ•„ ์ฃผ์†Œ(Profile) +### ์›์‹œ JSON(RawJson) _Value Object_ #### ์†์„ฑ -- `address`: ํ”„๋กœํ•„ ์ฃผ์†Œ +- `value`: `String` - JSON ๋ฌธ์ž์—ด + +#### ํ–‰์œ„ +- `static create()`: RawJson ์ƒ์„ฑ --- -### Email -_Value Object_ +### [ ํ”ผ๋“œ๋ฐฑ ์‹ ์ฒญ ์–ด๊ทธ๋ฆฌ๊ฑฐํŠธ ] + +### ํ”ผ๋“œ๋ฐฑ ์‹ ์ฒญ(ExpertApplication) +_Aggregate Root_ #### ์†์„ฑ -- `address`: ์ด๋ฉ”์ผ ์ฃผ์†Œ +- `id`: `Long` +- `businessPlanId`: `Long` - ์‚ฌ์—…๊ณ„ํš์„œ ID +- `expertId`: `Long` - ์ „๋ฌธ๊ฐ€ ID + +#### ํ–‰์œ„ +- `static create()`: ํ”ผ๋“œ๋ฐฑ ์‹ ์ฒญ ์ƒ์„ฑ (์‚ฌ์—…๊ณ„ํš์„œID, ์ „๋ฌธ๊ฐ€ID) + +#### ๊ทœ์น™ +- ์‚ฌ์—…๊ณ„ํš์„œ 1๊ฐœ๋‹น ์—ฌ๋Ÿฌ ์ „๋ฌธ๊ฐ€์—๊ฒŒ ํ”ผ๋“œ๋ฐฑ ์‹ ์ฒญ์ด ๊ฐ€๋Šฅํ•˜๋‹ค. +- ๋™์ผํ•œ ์‚ฌ์—…๊ณ„ํš์„œ์— ๋Œ€ํ•ด ๋™์ผํ•œ ์ „๋ฌธ๊ฐ€์—๊ฒŒ๋Š” 1ํšŒ๋งŒ ํ”ผ๋“œ๋ฐฑ ์‹ ์ฒญ์ด ๊ฐ€๋Šฅํ•˜๋‹ค (์œ ๋‹ˆํฌ ์ œ์•ฝ ์กฐ๊ฑด). + --- +### [ ์ „๋ฌธ๊ฐ€ ๋ฆฌํฌํŠธ ์–ด๊ทธ๋ฆฌ๊ฑฐํŠธ ] -### ๊ฐ•์‚ฌ +### ์ „๋ฌธ๊ฐ€ ๋ฆฌํฌํŠธ(ExpertReport) +_Aggregate Root_ +#### ์†์„ฑ +- `id`: `Long` +- `expertId`: `Long` - ์ „๋ฌธ๊ฐ€ ID +- `businessPlanId`: `Long` - ์‚ฌ์—…๊ณ„ํš์„œ ID +- `expiredAt`: `LocalDateTime` - ๋งŒ๋ฃŒ ์ผ์‹œ (์ƒ์„ฑ ํ›„ 7์ผ) +- `token`: `String` - ๋ฆฌํฌํŠธ ์ ‘๊ทผ ํ† ํฐ (์œ ๋‹ˆํฌ) +- `viewCount`: `int` - ์กฐํšŒ ํšŸ์ˆ˜ +- `overallComment`: `String` - ์ „์ฒด ์ฝ”๋ฉ˜ํŠธ (TEXT) +- `submitStatus`: `SubmitStatus` - ์ œ์ถœ ์ƒํƒœ (๊ธฐ๋ณธ๊ฐ’: PENDING) +- `details`: `List` - ๋ฆฌํฌํŠธ ์ƒ์„ธ ๋ชฉ๋ก (1:N ๊ด€๊ณ„) -### ๊ฐ•์˜ +#### ํ–‰์œ„ +- `static create()`: ์ „๋ฌธ๊ฐ€ ๋ฆฌํฌํŠธ ์ƒ์„ฑ (์ „๋ฌธ๊ฐ€ID, ์‚ฌ์—…๊ณ„ํš์„œID, ํ† ํฐ) - ๋งŒ๋ฃŒ์ผ์‹œ๋Š” ์ƒ์„ฑ ํ›„ 7์ผ +- `isExpired()`: ๋งŒ๋ฃŒ ์—ฌ๋ถ€ ํ™•์ธ +- `syncStatus()`: ์ƒํƒœ ๋™๊ธฐํ™” (๋งŒ๋ฃŒ ํ™•์ธ ํ›„ EXPIRED๋กœ ๋ณ€๊ฒฝ) +- `validateCanEdit()`: ์ˆ˜์ • ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ๊ฒ€์ฆ +- `canEdit()`: ์ˆ˜์ • ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ํ™•์ธ +- `temporarySave()`: ์ž„์‹œ ์ €์žฅ +- `submit()`: ๋ฆฌํฌํŠธ ์ œ์ถœ +- `updateOverallComment()`: ์ „์ฒด ์ฝ”๋ฉ˜ํŠธ ์—…๋ฐ์ดํŠธ +- `updateDetails()`: ๋ฆฌํฌํŠธ ์ƒ์„ธ ๋ชฉ๋ก ์—…๋ฐ์ดํŠธ +- `incrementViewCount()`: ์กฐํšŒ ํšŸ์ˆ˜ ์ฆ๊ฐ€ -### ์ˆ˜์—… +#### ๊ทœ์น™ +- ๋ฆฌํฌํŠธ๋Š” ์ƒ์„ฑ ํ›„ 7์ผ์˜ ํ‰๊ฐ€ ๊ธฐํ•œ์„ ๊ฐ€์ง„๋‹ค. +- PENDING, TEMPORARY_SAVED ์ƒํƒœ์—์„œ๋งŒ ์ˆ˜์ • ๊ฐ€๋Šฅํ•˜๋‹ค. +- PENDING, TEMPORARY_SAVED ์ƒํƒœ์—์„œ ์ œ์ถœ ๊ฐ€๋Šฅํ•˜๋ฉฐ, ์ œ์ถœ ์‹œ SUBMITTED ์ƒํƒœ๋กœ ๋ณ€๊ฒฝ๋œ๋‹ค. +- SUBMITTED, EXPIRED ์ƒํƒœ์—์„œ๋Š” ์ˆ˜์ • ๋ถˆ๊ฐ€ํ•˜๋‹ค. +- expiredAt๊ฐ€ ํ˜„์žฌ ์‹œ๊ฐ„๋ณด๋‹ค ์ด์ „์ด๋ฉด EXPIRED ์ƒํƒœ๋กœ ๋ณ€๊ฒฝ๋œ๋‹ค. +- ๋™์ผํ•œ ์‚ฌ์—…๊ณ„ํš์„œ์™€ ์ „๋ฌธ๊ฐ€ ์กฐํ•ฉ์— ๋Œ€ํ•ด 1๊ฐœ์˜ ๋ฆฌํฌํŠธ๋งŒ ์กด์žฌํ•  ์ˆ˜ ์žˆ๋‹ค (์œ ๋‹ˆํฌ ์ œ์•ฝ ์กฐ๊ฑด). + +### ๋ฆฌํฌํŠธ ์ƒ์„ธ(ExpertReportDetail) +_Entity_ +#### ์†์„ฑ +- `id`: `Long` +- `commentType`: `CommentType` - ์ฝ”๋ฉ˜ํŠธ ํƒ€์ž… (STRENGTH, WEAKNESS) +- `content`: `String` - ๋‚ด์šฉ (TEXT) + +#### ํ–‰์œ„ +- `static create()`: ๋ฆฌํฌํŠธ ์ƒ์„ธ ์ƒ์„ฑ (์ฝ”๋ฉ˜ํŠธํƒ€์ž…, ๋‚ด์šฉ) +- `update()`: ๋‚ด์šฉ ์—…๋ฐ์ดํŠธ + +### ์ œ์ถœ ์ƒํƒœ(SubmitStatus) +_Enum_ +#### ์ƒ์ˆ˜ +- `PENDING`: ํ‰๊ฐ€ ์ „ +- `TEMPORARY_SAVED`: ์ž„์‹œ ์ €์žฅ +- `SUBMITTED`: ์ œ์ถœ ์™„๋ฃŒ +- `EXPIRED`: ๋งŒ๋ฃŒ๋จ -### ์„น์…˜ +### ์ฝ”๋ฉ˜ํŠธ ํƒ€์ž…(CommentType) +_Enum_ +#### ์ƒ์ˆ˜ +- `STRENGTH`: ๊ฐ•์  +- `WEAKNESS`: ์•ฝ์  + +--- -### ์ˆ˜๊ฐ• +### [ AI ๋ฆฌํฌํŠธ ์–ด๊ทธ๋ฆฌ๊ฑฐํŠธ ] + +### AI ๋ฆฌํฌํŠธ(AiReport) +_Aggregate Root_ +#### ์†์„ฑ +- `id`: `Long` +- `businessPlanId`: `Long` - ์‚ฌ์—…๊ณ„ํš์„œ ID +- `rawJson`: `RawJson` - ์›๋ณธ JSON ๋ฐ์ดํ„ฐ (TEXT) -### ์ง„๋„ +#### ํ–‰์œ„ +- `static create()`: AI ๋ฆฌํฌํŠธ ์ƒ์„ฑ (์‚ฌ์—…๊ณ„ํš์„œID, ์›๋ณธJSON) +- `update()`: AI ๋ฆฌํฌํŠธ ์—…๋ฐ์ดํŠธ (์›๋ณธJSON) + +#### ๊ทœ์น™ +- ์‚ฌ์—…๊ณ„ํš์„œ๊ฐ€ ์ž‘์„ฑ ์™„๋ฃŒ๋˜์ง€ ์•Š์œผ๋ฉด AI ๋ฆฌํฌํŠธ๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์—†๋‹ค. + +--- + +### [ ์ฃผ๋ฌธ ์–ด๊ทธ๋ฆฌ๊ฑฐํŠธ ] + +### ์ฃผ๋ฌธ(Orders) +_Aggregate Root_ +#### ์†์„ฑ +- `id`: `Long` +- `orderCode`: `String` - ์ฃผ๋ฌธ ์ฝ”๋“œ (์œ ๋‹ˆํฌ) +- `buyerId`: `Long` - ๊ตฌ๋งค์ž ํšŒ์› ID +- `status`: `OrderStatus` - ์ฃผ๋ฌธ ์ƒํƒœ (๊ธฐ๋ณธ๊ฐ’: NEW) +- `currency`: `String` - ํ†ตํ™” (๊ธฐ๋ณธ๊ฐ’: "KRW") +- `price`: `Long` - ์ฃผ๋ฌธ ๊ธˆ์•ก +- `usageProductCode`: `String` - ์‚ฌ์šฉ ์ƒํ’ˆ ์ฝ”๋“œ +- `usageCount`: `Integer` - ์ด์šฉ๊ถŒ ํšŸ์ˆ˜ (1ํšŒ๊ถŒ, 2ํšŒ๊ถŒ ๋“ฑ) +- `payments`: `List` - ๊ฒฐ์ œ ๊ธฐ๋ก ๋ชฉ๋ก (1:N ๊ด€๊ณ„) +- `version`: `Long` - ๋ฒ„์ „ (๋‚™๊ด€์  locking) + +#### ํ–‰์œ„ +- `static newUsageOrder()`: ์ด์šฉ๊ถŒ ์ฃผ๋ฌธ ์ƒ์„ฑ (์ฃผ๋ฌธ์ฝ”๋“œ, ๊ตฌ๋งค์žID, ๊ธˆ์•ก, ์ƒํ’ˆํƒ€์ž…) +- `validateSameBuyer()`: ๋™์ผ ๊ตฌ๋งค์ž ํ™•์ธ +- `validateSameProduct()`: ๋™์ผ ์ƒํ’ˆ ํ™•์ธ +- `addPaymentAttempt()`: ๊ฒฐ์ œ ์‹œ๋„ ์ถ”๊ฐ€ +- `markPaid()`: ๊ฒฐ์ œ ์™„๋ฃŒ ์ฒ˜๋ฆฌ +- `cancel()`: ์ฃผ๋ฌธ/๊ฒฐ์ œ ์ทจ์†Œ +- `getLatestRequestedOrThrow()`: ๊ฐ€์žฅ ์ตœ๊ทผ REQUESTED ์ƒํƒœ ๊ฒฐ์ œ ์‹œ๋„ ์กฐํšŒ +- `getLatestDoneOrThrow()`: ๊ฐ€์žฅ ์ตœ๊ทผ DONE ์ƒํƒœ ๊ฒฐ์ œ ์‹œ๋„ ์กฐํšŒ + +#### ๊ทœ์น™ +- ์ด๋ฏธ ๊ฒฐ์ œ ์™„๋ฃŒ๋œ ์ฃผ๋ฌธ์—๋Š” ๊ฒฐ์ œ ์‹œ๋„๋ฅผ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์—†๋‹ค. +- ์ฃผ๋ฌธ ๊ธˆ์•ก๊ณผ ๊ฒฐ์ œ ๊ธˆ์•ก์ด ์ผ์น˜ํ•ด์•ผ ํ•œ๋‹ค. +- NEW ์ƒํƒœ์—์„œ๋งŒ ๊ฒฐ์ œ ์Šน์ธ์ด ๊ฐ€๋Šฅํ•˜๋‹ค. +- PAID ์ƒํƒœ์—์„œ๋งŒ ์ทจ์†Œ๊ฐ€ ๊ฐ€๋Šฅํ•˜๋‹ค. + +### ๊ฒฐ์ œ ๊ธฐ๋ก(PaymentRecords) +_Entity_ +#### ์†์„ฑ +- `id`: `Long` +- `order`: `Orders` - ์ฃผ๋ฌธ (N:1 ๊ด€๊ณ„) +- `pg`: `String` - PG์‚ฌ (๊ธฐ๋ณธ๊ฐ’: "TOSS") +- `paymentKey`: `String` - ๊ฒฐ์ œ ํ‚ค (์œ ๋‹ˆํฌ) +- `method`: `String` - ๊ฒฐ์ œ ์ˆ˜๋‹จ +- `provider`: `String` - ๊ฒฐ์ œ ์ œ๊ณต์ž +- `price`: `Long` - ๊ฒฐ์ œ ๊ธˆ์•ก +- `status`: `String` - ๊ฒฐ์ œ ์ƒํƒœ (๊ธฐ๋ณธ๊ฐ’: "REQUESTED") +- `receiptUrl`: `String` - ์˜์ˆ˜์ฆ URL +- `approvedAt`: `Instant` - ์Šน์ธ ์ผ์‹œ +- `createdAt`: `Instant` - ์ƒ์„ฑ ์ผ์‹œ + +#### ํ–‰์œ„ +- `static requestedFor()`: ๊ฒฐ์ œ ์š”์ฒญ ์ƒ์„ฑ (์ฃผ๋ฌธ, ๊ธˆ์•ก) +- `markDone()`: ๊ฒฐ์ œ ์™„๋ฃŒ ์ฒ˜๋ฆฌ +- `markFailed()`: ๊ฒฐ์ œ ์‹คํŒจ ์ฒ˜๋ฆฌ + +### ์ฃผ๋ฌธ ์ƒํƒœ(OrderStatus) +_Enum_ +#### ์ƒ์ˆ˜ +- `NEW`: ์ฃผ๋ฌธ ์ƒ์„ฑ๋จ (๊ฒฐ์ œ ์ „) +- `PAID`: ๊ฒฐ์ œ ์™„๋ฃŒ +- `CANCELED`: ์ฃผ๋ฌธ/๊ฒฐ์ œ ์ทจ์†Œ + +### ์‚ฌ์šฉ ์ƒํ’ˆ ํƒ€์ž…(UsageProductType) +_Enum_ +#### ์ƒ์ˆ˜ +- ์‚ฌ์šฉ ํฌ๋ ˆ๋”ง ์ƒํ’ˆ ํƒ€์ž… (1ํšŒ๊ถŒ, 2ํšŒ๊ถŒ ๋“ฑ) + +### ์ฃผ๋ฌธ ์ฝ”๋“œ(OrderCode) +_Value Object_ +#### ์†์„ฑ +- `value`: `String` - ์ฃผ๋ฌธ ์ฝ”๋“œ ๋ฌธ์ž์—ด + +#### ํ–‰์œ„ +- `static of()`: OrderCode ์ƒ์„ฑ + +### ๊ธˆ์•ก(Money) +_Value Object_ +#### ์†์„ฑ +- `amount`: `Long` - ๊ธˆ์•ก +- `currency`: `String` - ํ†ตํ™” + +#### ํ–‰์œ„ +- `static of()`: Money ์ƒ์„ฑ (๊ธˆ์•ก, ํ†ตํ™”) +- `static krw()`: ํ•œ๊ตญ ์›ํ™” Money ์ƒ์„ฑ +--- \ No newline at end of file diff --git "a/\354\232\251\354\226\264\354\202\254\354\240\204.md" "b/\354\232\251\354\226\264\354\202\254\354\240\204.md" index 71f8c9fa..a046a8eb 100644 --- "a/\354\232\251\354\226\264\354\202\254\354\240\204.md" +++ "b/\354\232\251\354\226\264\354\202\254\354\240\204.md" @@ -2,6 +2,29 @@ | **ํ•œ๊ตญ์–ด** | **์˜์–ด** | **์„ค๋ช…** | |--------|---------|------------------------------------------| -| ์ฐฝ์—…์ž | Founder | starLight ์„œ๋น„์Šค๋ฅผ ์ด์šฉํ•˜๋Š” ์‚ฌ์šฉ์ž๋กœ ์‚ฌ์—…๊ณ„ํš์„œ๋ฅผ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. | -| ์ „๋ฌธ๊ฐ€ | Expert | ์‚ฌ์—…๊ณ„ํš์„œ๋ฅผ ๊ฒ€ํ† ํ•˜๋Š” ํšŒ์›. ์ „๋ฌธ๊ฐ€๋Š” ํšŒ์›๊ณผ ๋…๋ฆฝ์ ์œผ๋กœ ์กด์žฌํ•œ๋‹ค | +| ์ฐฝ์—…์ž | Founder | StarLight ์„œ๋น„์Šค๋ฅผ ์ด์šฉํ•˜๋Š” ์‚ฌ์šฉ์ž๋กœ ์‚ฌ์—…๊ณ„ํš์„œ๋ฅผ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. MemberType.FOUNDER๋ฅผ ๊ฐ€์ง„ ํšŒ์›์ด๋‹ค. | +| ์ „๋ฌธ๊ฐ€ | Expert | ์‚ฌ์—…๊ณ„ํš์„œ๋ฅผ ๊ฒ€ํ† ํ•˜๊ณ  ํ”ผ๋“œ๋ฐฑ์„ ์ œ๊ณตํ•˜๋Š” ํšŒ์›. Member์™€ ๋…๋ฆฝ์ ์œผ๋กœ ์กด์žฌํ•˜๋Š” ๋ณ„๋„์˜ ์—”ํ‹ฐํ‹ฐ์ด๋‹ค. | +| ํšŒ์› | Member | StarLight ์„œ๋น„์Šค๋ฅผ ์ด์šฉํ•˜๋Š” ๋ชจ๋“  ์‚ฌ์šฉ์ž. | +| ์‚ฌ์—…๊ณ„ํš์„œ | BusinessPlan | ์ฐฝ์—…์ž๊ฐ€ ์ž‘์„ฑํ•˜๋Š” ์‚ฌ์—… ๊ณ„ํš์„œ. 5๊ฐœ์˜ ์ฃผ์š” ์„น์…˜(๊ฐœ์š”, ๋ฌธ์ œ์ธ์‹, ์‹คํ˜„๊ฐ€๋Šฅ์„ฑ, ์„ฑ์žฅ์ „๋žต, ํŒ€์—ญ๋Ÿ‰)์œผ๋กœ ๊ตฌ์„ฑ๋œ๋‹ค. | +| ์„น์…˜ | Section | ์‚ฌ์—…๊ณ„ํš์„œ๋ฅผ ๊ตฌ์„ฑํ•˜๋Š” 5๊ฐœ์˜ ์ฃผ์š” ์˜์—ญ. Overview, ProblemRecognition, Feasibility, GrowthTactic, TeamCompetence๊ฐ€ ์žˆ๋‹ค. | +| ์„œ๋ธŒ ์„น์…˜ | SubSection | ๊ฐ ์„น์…˜์˜ ์„ธ๋ถ€ ํ•ญ๋ชฉ. ๋‚ด์šฉ, ์›๋ณธ JSON ๋ฐ์ดํ„ฐ, ์ฒดํฌ๋ฆฌ์ŠคํŠธ(5๊ฐœ ํ•ญ๋ชฉ)๋ฅผ ํฌํ•จํ•œ๋‹ค. | +| ๊ฐœ์š” | Overview | ์‚ฌ์—…๊ณ„ํš์„œ์˜ ์ฒซ ๋ฒˆ์งธ ์„น์…˜. ์‚ฌ์—…์˜ ๊ธฐ๋ณธ ์ •๋ณด๋ฅผ ๋‹ด๋Š”๋‹ค. | +| ๋ฌธ์ œ ์ธ์‹ | ProblemRecognition | ์‚ฌ์—…๊ณ„ํš์„œ์˜ ๋‘ ๋ฒˆ์งธ ์„น์…˜. ์ฐฝ์—… ๋ฐฐ๊ฒฝ, ๊ฐœ๋ฐœ ๋™๊ธฐ, ๋ชฉ์  ๋ฐ ํ•„์š”์„ฑ, ๋ชฉํ‘œ์‹œ์žฅ ๋ถ„์„์„ ํฌํ•จํ•œ๋‹ค. | +| ์‹คํ˜„ ๊ฐ€๋Šฅ์„ฑ | Feasibility | ์‚ฌ์—…๊ณ„ํš์„œ์˜ ์„ธ ๋ฒˆ์งธ ์„น์…˜. ์‚ฌ์—…ํ™” ์ „๋žต, ์‹œ์žฅ๋ถ„์„ ๋ฐ ๊ฒฝ์Ÿ๋ ฅ ํ™•๋ณด ๋ฐฉ์•ˆ์„ ํฌํ•จํ•œ๋‹ค. | +| ์„ฑ์žฅ ์ „๋žต | GrowthTactic | ์‚ฌ์—…๊ณ„ํš์„œ์˜ ๋„ค ๋ฒˆ์งธ ์„น์…˜. ๋น„์ฆˆ๋‹ˆ์Šค ๋ชจ๋ธ, ์ž๊ธˆ์กฐ๋‹ฌ ๊ณ„ํš, ์‹œ์žฅ์ง„์ž… ๋ฐ ์„ฑ๊ณผ์ฐฝ์ถœ ์ „๋žต์„ ํฌํ•จํ•œ๋‹ค. | +| ํŒ€ ์—ญ๋Ÿ‰ | TeamCompetence | ์‚ฌ์—…๊ณ„ํš์„œ์˜ ๋‹ค์„ฏ ๋ฒˆ์งธ ์„น์…˜. ์ฐฝ์—…์ž์˜ ์—ญ๋Ÿ‰, ํŒ€ ์—ญ๋Ÿ‰์„ ํฌํ•จํ•œ๋‹ค. | +| ํ”ผ๋“œ๋ฐฑ ์‹ ์ฒญ | ExpertApplication | ์ฐฝ์—…์ž๊ฐ€ ํŠน์ • ์ „๋ฌธ๊ฐ€์—๊ฒŒ ์ž์‹ ์˜ ์‚ฌ์—…๊ณ„ํš์„œ์— ๋Œ€ํ•œ ํ”ผ๋“œ๋ฐฑ์„ ์š”์ฒญํ•˜๋Š” ์‹ ์ฒญ. ๋™์ผํ•œ ์‚ฌ์—…๊ณ„ํš์„œ์— ๋Œ€ํ•ด ๋™์ผํ•œ ์ „๋ฌธ๊ฐ€์—๊ฒŒ๋Š” 1ํšŒ๋งŒ ์‹ ์ฒญ ๊ฐ€๋Šฅํ•˜๋‹ค. | +| ์ „๋ฌธ๊ฐ€ ๋ฆฌํฌํŠธ | ExpertReport | ์ „๋ฌธ๊ฐ€๊ฐ€ ์‚ฌ์—…๊ณ„ํš์„œ์— ๋Œ€ํ•œ ํ”ผ๋“œ๋ฐฑ์„ ์ž‘์„ฑํ•œ ๋ฆฌํฌํŠธ. ์ „์ฒด ์ฝ”๋ฉ˜ํŠธ์™€ ๊ฐ ์„น์…˜๋ณ„ ์ƒ์„ธ ์ฝ”๋ฉ˜ํŠธ(๊ฐ•์ /์•ฝ์ )๋ฅผ ํฌํ•จํ•œ๋‹ค. 7์ผ์˜ ํ‰๊ฐ€ ๊ธฐํ•œ์„ ๊ฐ€์ง„๋‹ค. | +| ๋ฆฌํฌํŠธ ์ƒ์„ธ | ExpertReportDetail | ์ „๋ฌธ๊ฐ€ ๋ฆฌํฌํŠธ์˜ ์„ธ๋ถ€ ๋‚ด์šฉ. ๊ฐ•์ (STRENGTH) ๋˜๋Š” ์•ฝ์ (WEAKNESS) ํƒ€์ž…์˜ ์ฝ”๋ฉ˜ํŠธ๋ฅผ ํฌํ•จํ•œ๋‹ค. | +| AI ๋ฆฌํฌํŠธ | AiReport | AI๊ฐ€ ์‚ฌ์—…๊ณ„ํš์„œ๋ฅผ ๋ถ„์„ํ•˜์—ฌ ์ž๋™์œผ๋กœ ์ƒ์„ฑํ•œ ๋ฆฌํฌํŠธ. JSON ํ˜•ํƒœ๋กœ ์ €์žฅ๋œ๋‹ค. | +| ์ฃผ๋ฌธ | Orders | ์ „๋ฌธ๊ฐ€ ํ”ผ๋“œ๋ฐฑ ์‹ ์ฒญ์„ ์œ„ํ•œ ๊ฒฐ์ œ ์ฃผ๋ฌธ. ํ† ์ŠคํŽ˜์ด๋จผ์ธ ๋ฅผ ํ†ตํ•ด ๊ฒฐ์ œ๋ฅผ ์ฒ˜๋ฆฌํ•œ๋‹ค. | +| ๊ฒฐ์ œ ๊ธฐ๋ก | PaymentRecords | ์ฃผ๋ฌธ์— ๋Œ€ํ•œ ๊ฒฐ์ œ ์‹œ๋„ ๋ฐ ์™„๋ฃŒ ๊ธฐ๋ก. ์—ฌ๋Ÿฌ ๋ฒˆ์˜ ๊ฒฐ์ œ ์‹œ๋„๋ฅผ ๊ธฐ๋กํ•  ์ˆ˜ ์žˆ๋‹ค. | +| ์‚ฌ์šฉ ํฌ๋ ˆ๋”ง | UsageCredit | ์ „๋ฌธ๊ฐ€ ํ”ผ๋“œ๋ฐฑ ์‹ ์ฒญ์— ์‚ฌ์šฉํ•˜๋Š” ํฌ๋ ˆ๋”ง(์ด์šฉ๊ถŒ). 1ํšŒ๊ถŒ, 2ํšŒ๊ถŒ ๋“ฑ์˜ ์ƒํ’ˆ์ด ์žˆ๋‹ค. | +| ์‚ฌ์—…๊ณ„ํš์„œ ์ƒํƒœ | PlanStatus | ์‚ฌ์—…๊ณ„ํš์„œ์˜ ์ง„ํ–‰ ์ƒํƒœ. STARTED(์‹œ์ž‘๋จ), WRITTEN_COMPLETED(์ž‘์„ฑ ์™„๋ฃŒ), AI_REVIEWED(AI ๋ฆฌ๋ทฐ ์™„๋ฃŒ), EXPERT_MATCHED(์ „๋ฌธ๊ฐ€ ๋งค์นญ ์™„๋ฃŒ), FINALIZED(์ตœ์ข… ์™„๋ฃŒ)๊ฐ€ ์žˆ๋‹ค. | +| ์ œ์ถœ ์ƒํƒœ | SubmitStatus | ์ „๋ฌธ๊ฐ€ ๋ฆฌํฌํŠธ์˜ ์ œ์ถœ ์ƒํƒœ. PENDING(ํ‰๊ฐ€ ์ „), TEMPORARY_SAVED(์ž„์‹œ ์ €์žฅ), SUBMITTED(์ œ์ถœ ์™„๋ฃŒ), EXPIRED(๋งŒ๋ฃŒ๋จ)๊ฐ€ ์žˆ๋‹ค. | +| ์ฝ”๋ฉ˜ํŠธ ํƒ€์ž… | CommentType | ์ „๋ฌธ๊ฐ€ ๋ฆฌํฌํŠธ ์ƒ์„ธ์˜ ์ฝ”๋ฉ˜ํŠธ ํƒ€์ž…. STRENGTH(๊ฐ•์ ), WEAKNESS(์•ฝ์ )์ด ์žˆ๋‹ค. | +| ์ „๋ฌธ๊ฐ€ ํƒœ๊ทธ ์นดํ…Œ๊ณ ๋ฆฌ | TagCategory | ์ „๋ฌธ๊ฐ€์˜ ์ „๋ฌธ ๋ถ„์•ผ ์นดํ…Œ๊ณ ๋ฆฌ. MARKET_BM(์‹œ์žฅ์„ฑ/BM), TEAM_CAPABILITY(ํŒ€ ์—ญ๋Ÿ‰), PROBLEM_DEFINITION(๋ฌธ์ œ ์ •์˜), GROWTH_STRATEGY(์„ฑ์žฅ ์ „๋žต), METRIC_DATA(์ง€ํ‘œ/๋ฐ์ดํ„ฐ)๊ฐ€ ์žˆ๋‹ค. | +| ์ฃผ๋ฌธ ์ƒํƒœ | OrderStatus | ์ฃผ๋ฌธ์˜ ๊ฒฐ์ œ ์ƒํƒœ. NEW(์ฃผ๋ฌธ ์ƒ์„ฑ๋จ, ๊ฒฐ์ œ ์ „), PAID(๊ฒฐ์ œ ์™„๋ฃŒ), CANCELED(์ฃผ๋ฌธ/๊ฒฐ์ œ ์ทจ์†Œ)๊ฐ€ ์žˆ๋‹ค. | +| ์ธ์ฆ ์ •๋ณด | Credential | ํšŒ์›์˜ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋“ฑ ์ธ์ฆ ๊ด€๋ จ ์ •๋ณด. Member์™€ 1:1 ๊ด€๊ณ„์ด๋‹ค. | +| ์›์‹œ JSON | RawJson | ์›๋ณธ ๋ฐ์ดํ„ฐ๋ฅผ JSON ํ˜•ํƒœ๋กœ ์ €์žฅํ•˜๊ธฐ ์œ„ํ•œ ๊ฐ’ ๊ฐ์ฒด. |