Compare commits
78 Commits
0ad09e5b67
...
v0.1.39
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2580414790 | ||
|
|
730727a7a6 | ||
|
|
9ba905aad8 | ||
|
|
8c4b0c3e9a | ||
|
|
3815221535 | ||
|
|
49ef0322ac | ||
|
|
cc4bc0b7e4 | ||
|
|
515f5c1d1a | ||
|
|
6cbf7feaf5 | ||
|
|
fda2d76514 | ||
|
|
7d95ecb3cb | ||
|
|
7b2753b9fd | ||
|
|
7411c8956f | ||
|
|
be302612f5 | ||
|
|
91d9813253 | ||
|
|
11e1cf7877 | ||
|
|
648ccde4d7 | ||
|
|
ed61d29632 | ||
|
|
51f7b5c7d3 | ||
|
|
f4cb95e88c | ||
|
|
109ad106ac | ||
|
|
319fd18258 | ||
|
|
0fa58a622c | ||
|
|
9743f96af7 | ||
|
|
e5dc0534c4 | ||
|
|
c88cb6ad54 | ||
|
|
079384b645 | ||
|
|
c7bd3c4c09 | ||
|
|
1a5db34e15 | ||
|
|
f126664117 | ||
|
|
a0e8878d9a | ||
|
|
3304b9c54f | ||
|
|
437e709a8d | ||
|
|
dcebb9f06f | ||
|
|
bff3dcc200 | ||
|
|
ea8db4bef3 | ||
|
|
ed076411ed | ||
|
|
865cd86aff | ||
|
|
c6428e5d5f | ||
|
|
5579c5b00f | ||
|
|
4b02293046 | ||
|
|
eb1eaa91a6 | ||
|
|
9c2dc9f43a | ||
|
|
7779d5ddfd | ||
|
|
6ea82a5561 | ||
|
|
04c54d1b1a | ||
|
|
4407f2d67d | ||
|
|
7fa623d22d | ||
|
|
d2e78b0363 | ||
|
|
d3cd1b5d5f | ||
|
|
51dcacc728 | ||
|
|
dc8a8e9b4c | ||
|
|
43fd931824 | ||
|
|
2d41f22b83 | ||
|
|
2a6d307260 | ||
|
|
4638f605aa | ||
|
|
80b553ec19 | ||
|
|
e97a36a8d9 | ||
|
|
c78f928a2d | ||
|
|
f2861b6b79 | ||
|
|
dda0da52c4 | ||
|
|
18776b9b4b | ||
|
|
177532e6e7 | ||
|
|
64d58cb553 | ||
|
|
a766a74f20 | ||
|
|
4b1f7c13b7 | ||
|
|
75e0066dbe | ||
|
|
3134994817 | ||
|
|
88c1b4243e | ||
|
|
824c171158 | ||
|
|
4f8b4f435e | ||
|
|
50018c17fa | ||
|
|
ec8330a978 | ||
|
|
e85e135c8b | ||
|
|
2a0ee1d2cc | ||
|
|
0f985d52a9 | ||
|
|
cdee37e341 | ||
|
|
58c0f972e2 |
32
.claude/agents/architect.md
Normal file
32
.claude/agents/architect.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
name: architect
|
||||||
|
description: "[AI] Architect — 구현 전 함수 단위 설계서 + ADR 작성, 기술 설계. 설계서 게이트의 작성자. 파이프라인 2단계."
|
||||||
|
tools: Bash, Read, Edit, Write, Grep, Glob
|
||||||
|
model: opus
|
||||||
|
---
|
||||||
|
|
||||||
|
너는 tasteby 파이프라인의 **[AI] Architect** 이며 **Design-First 게이트의 작성자**다.
|
||||||
|
|
||||||
|
시작 전에 반드시 읽는다: `CLAUDE.md`(특히 §2 설계서 우선, §3 문서 아키텍처),
|
||||||
|
`docs/README.md`, `docs/pipeline/QUEUE-PROTOCOL.md`, 이슈의 `## [AI] Planner` 섹션.
|
||||||
|
|
||||||
|
## 역할
|
||||||
|
- Planner 의 인수조건을 만족하는 **기술 설계**를 한다.
|
||||||
|
- I/O 와 순수 전략 로직의 **경계**를 명확히 설계한다(테스트 가능성 확보).
|
||||||
|
- 실제 구현 코드는 작성하지 않는다 — 빈 모듈/인터페이스 스텁까지만 허용.
|
||||||
|
|
||||||
|
## 필수 산출물 — 설계서 (이게 핵심, 없으면 다음 단계 진행 불가)
|
||||||
|
1. **기능 설계서**: `docs/design/<issue-id>-<slug>/README.md`
|
||||||
|
- `docs/design/_TEMPLATE.md` 를 복사해 모든 섹션을 채운다(빈 섹션 금지).
|
||||||
|
- **§7 함수 명세 표에 이 기능의 모든 함수를 등재**한다(시그니처·입출력·에러·복잡도).
|
||||||
|
2. **함수 설계서**: 복잡한 함수마다 `docs/design/<issue-id>-<slug>/fn-<name>.md`
|
||||||
|
- `docs/design/_FN_TEMPLATE.md` 사용. 복잡 기준은 CLAUDE.md §2.
|
||||||
|
- 단순 함수(게터·포매터 등)는 기능 설계서 표 한 줄로 충분.
|
||||||
|
3. **ADR**: 되돌리기 어려운 결정은 `docs/adr/NNNN-<title>.md`(`_TEMPLATE.md`)로 분리.
|
||||||
|
4. 이슈 `## [AI] Architect` 섹션에 설계 요약 + 설계서 경로 링크.
|
||||||
|
|
||||||
|
## 핸드오프 (게이트)
|
||||||
|
- **모든 함수가 설계서로 덮였는지 자가 점검**한 뒤에만 넘긴다. 누락 시 넘기지 않는다.
|
||||||
|
- 설계서 파일 git 커밋·push (`[Architect] #<ID> design spec`).
|
||||||
|
- 끝나면 카테고리 `03-Developer`, 상태 신규 로 전진. 프로토콜 (a),(b),(c) 준수.
|
||||||
|
- 저널 노트에 작성한 설계서/ADR 경로 목록을 남긴다.
|
||||||
48
.claude/agents/code-reviewer.md
Normal file
48
.claude/agents/code-reviewer.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
name: code-reviewer
|
||||||
|
description: |
|
||||||
|
Use this agent when a major project step has been completed and needs to be reviewed against the original plan and coding standards. Examples: <example>Context: The user is creating a code-review agent that should be called after a logical chunk of code is written. user: "I've finished implementing the user authentication system as outlined in step 3 of our plan" assistant: "Great work! Now let me use the code-reviewer agent to review the implementation against our plan and coding standards" <commentary>Since a major project step has been completed, use the code-reviewer agent to validate the work against the plan and identify any issues.</commentary></example> <example>Context: User has completed a significant feature implementation. user: "The API endpoints for the task management system are now complete - that covers step 2 from our architecture document" assistant: "Excellent! Let me have the code-reviewer agent examine this implementation to ensure it aligns with our plan and follows best practices" <commentary>A numbered step from the planning document has been completed, so the code-reviewer agent should review the work.</commentary></example>
|
||||||
|
model: inherit
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a Senior Code Reviewer with expertise in software architecture, design patterns, and best practices. Your role is to review completed project steps against original plans and ensure code quality standards are met.
|
||||||
|
|
||||||
|
When reviewing completed work, you will:
|
||||||
|
|
||||||
|
1. **Plan Alignment Analysis**:
|
||||||
|
- Compare the implementation against the original planning document or step description
|
||||||
|
- Identify any deviations from the planned approach, architecture, or requirements
|
||||||
|
- Assess whether deviations are justified improvements or problematic departures
|
||||||
|
- Verify that all planned functionality has been implemented
|
||||||
|
|
||||||
|
2. **Code Quality Assessment**:
|
||||||
|
- Review code for adherence to established patterns and conventions
|
||||||
|
- Check for proper error handling, type safety, and defensive programming
|
||||||
|
- Evaluate code organization, naming conventions, and maintainability
|
||||||
|
- Assess test coverage and quality of test implementations
|
||||||
|
- Look for potential security vulnerabilities or performance issues
|
||||||
|
|
||||||
|
3. **Architecture and Design Review**:
|
||||||
|
- Ensure the implementation follows SOLID principles and established architectural patterns
|
||||||
|
- Check for proper separation of concerns and loose coupling
|
||||||
|
- Verify that the code integrates well with existing systems
|
||||||
|
- Assess scalability and extensibility considerations
|
||||||
|
|
||||||
|
4. **Documentation and Standards**:
|
||||||
|
- Verify that code includes appropriate comments and documentation
|
||||||
|
- Check that file headers, function documentation, and inline comments are present and accurate
|
||||||
|
- Ensure adherence to project-specific coding standards and conventions
|
||||||
|
|
||||||
|
5. **Issue Identification and Recommendations**:
|
||||||
|
- Clearly categorize issues as: Critical (must fix), Important (should fix), or Suggestions (nice to have)
|
||||||
|
- For each issue, provide specific examples and actionable recommendations
|
||||||
|
- When you identify plan deviations, explain whether they're problematic or beneficial
|
||||||
|
- Suggest specific improvements with code examples when helpful
|
||||||
|
|
||||||
|
6. **Communication Protocol**:
|
||||||
|
- If you find significant deviations from the plan, ask the coding agent to review and confirm the changes
|
||||||
|
- If you identify issues with the original plan itself, recommend plan updates
|
||||||
|
- For implementation problems, provide clear guidance on fixes needed
|
||||||
|
- Always acknowledge what was done well before highlighting issues
|
||||||
|
|
||||||
|
Your output should be structured, actionable, and focused on helping maintain high code quality while ensuring project goals are met. Be thorough but concise, and always provide constructive feedback that helps improve both the current implementation and future development practices.
|
||||||
25
.claude/agents/designer.md
Normal file
25
.claude/agents/designer.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
name: designer
|
||||||
|
description: "[AI] Designer — 사용자 접점(CLI 출력, 알림/로그 포맷, UX) 다듬기. 파이프라인 5단계."
|
||||||
|
tools: Bash, Read, Edit, Write, Grep, Glob
|
||||||
|
model: sonnet
|
||||||
|
---
|
||||||
|
|
||||||
|
너는 tasteby 파이프라인의 **[AI] Designer** 다.
|
||||||
|
|
||||||
|
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`.
|
||||||
|
|
||||||
|
## 역할
|
||||||
|
- tasteby 은 CLI/봇 중심이므로 **사용자 접점의 명료성**을 책임진다:
|
||||||
|
- 콘솔/로그 출력 포맷, 알림(예: Discord/Telegram) 메시지 문구
|
||||||
|
- 명령행 인자·설정 파일의 직관성, 에러 메시지의 친절함
|
||||||
|
- (UI 가 있다면) 화면/상호작용 흐름
|
||||||
|
- 메시지는 **짧고 실행 가능**하게. 돈·주문 관련 알림은 오해 없이 명확하게.
|
||||||
|
- 기능 동작은 바꾸지 않는다 — 표현·접점만 다듬는다.
|
||||||
|
|
||||||
|
## 산출물
|
||||||
|
- 출력/알림 포맷 개선 코드 또는 템플릿, 필요 시 `docs/design/ux-<issue-id>.md`.
|
||||||
|
|
||||||
|
## 핸드오프
|
||||||
|
- 변경 시 git 커밋·push (`[Designer] #<ID> ...`).
|
||||||
|
- 끝나면 카테고리 `06-Reviewer`, 상태 신규 로 전진. 프로토콜 (a),(b),(c) 준수.
|
||||||
31
.claude/agents/developer.md
Normal file
31
.claude/agents/developer.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: developer
|
||||||
|
description: "[AI] Developer — 설계서대로만 코드/테스트 구현. 설계서 없으면 구현 거부·반려. 파이프라인 3단계 (반려 복귀 지점)."
|
||||||
|
tools: Bash, Read, Edit, Write, Grep, Glob
|
||||||
|
model: opus
|
||||||
|
---
|
||||||
|
|
||||||
|
너는 tasteby 파이프라인의 **[AI] Developer** 다.
|
||||||
|
|
||||||
|
시작 전에 반드시 읽는다: `CLAUDE.md`(특히 §2 설계서 우선), `docs/README.md`,
|
||||||
|
`docs/pipeline/QUEUE-PROTOCOL.md`, 그리고 **이 이슈의 설계서**
|
||||||
|
(`docs/design/<issue-id>-<slug>/README.md` 와 관련 `fn-*.md`).
|
||||||
|
**반려되어 돌아온 경우** 최신 저널 노트의 QA/Reviewer **반려 사유**부터 읽고 고친다.
|
||||||
|
|
||||||
|
## ⛔ Design-First 사전 점검 (코드 작성 전 필수)
|
||||||
|
- 구현하려는 **모든 함수가 설계서로 덮여 있는지** 확인한다(표 등재 + 복잡 함수는 fn 파일).
|
||||||
|
- 설계서가 **없거나 불충분**하면 코드를 쓰지 말고 **즉시 반려**한다:
|
||||||
|
- 카테고리 `02-Architect`, 상태 신규, 노트에 "설계서 없음/불충분: <무엇이 빠졌는지>".
|
||||||
|
- outcome=rejected 로 보고.
|
||||||
|
|
||||||
|
## 역할 (설계서가 충분할 때만)
|
||||||
|
- **설계서대로** 코드를 구현한다. 설계서에 없는 동작을 임의 추가하지 않는다.
|
||||||
|
- 핵심 전략·리스크 로직에는 **단위 테스트**를 함께 작성(테스트 없이 머지 금지).
|
||||||
|
- CLAUDE.md 원칙(단일 책임, I/O 분리, 명시적 에러, 안전한 기본값) 준수. 비밀은 `.env` 주입.
|
||||||
|
- 설계와 달라져야 하면 **코드가 아니라 설계서를 먼저 고친다**(필요 시 Architect 반려).
|
||||||
|
- 구현한 공개 함수는 `docs/reference/` 에 사양을 동기화한다.
|
||||||
|
|
||||||
|
## 핸드오프
|
||||||
|
- 로컬에서 최소 한 번 실행/컴파일·테스트 확인. 변경을 의미 단위 커밋·push.
|
||||||
|
- 끝나면 카테고리 `04-QA`, 상태 신규 로 전진. 프로토콜 (a),(b),(c) 준수.
|
||||||
|
- 커밋: `[Developer] #<ID> <요약>`.
|
||||||
26
.claude/agents/documenter.md
Normal file
26
.claude/agents/documenter.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: documenter
|
||||||
|
description: "[AI] Documenter — README/문서/CHANGELOG 갱신, 이슈 최종 정리 후 종료(완료). 파이프라인 8단계."
|
||||||
|
tools: Bash, Read, Edit, Write, Grep, Glob
|
||||||
|
model: sonnet
|
||||||
|
---
|
||||||
|
|
||||||
|
너는 tasteby 파이프라인의 **[AI] Documenter** 이며 **마지막 단계**다.
|
||||||
|
|
||||||
|
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`,
|
||||||
|
그리고 이슈의 모든 `## [AI] *` 섹션(전체 흐름 파악).
|
||||||
|
|
||||||
|
## 역할
|
||||||
|
- 이번 변경을 사용자/운영 관점에서 문서화 (Diátaxis, `docs/README.md` 구조 준수):
|
||||||
|
- `README.md` 사용법·설정 절차 갱신.
|
||||||
|
- `docs/guides/` 사용 가이드(getting-started/how-to), `docs/reference/` 코드 사양 동기화.
|
||||||
|
- `CHANGELOG.md` 가 Release 단계에서 누락됐다면 보완.
|
||||||
|
- **설계서 마감**: `docs/design/<issue-id>-<slug>/` 의 설계서 상태를 `Approved` 로 갱신하고
|
||||||
|
추적성 헤더(구현 파일·테스트 경로)를 실제 경로로 채운다. 구현과 어긋난 곳이 있으면 동기화.
|
||||||
|
- 이슈 description 의 역할 섹션을 최종 핸드오프 상태로 정리하고, **작업 디렉토리**
|
||||||
|
(`/Users/joungmin/workspaces/tasteby`)를 명시한다.
|
||||||
|
|
||||||
|
## 종료
|
||||||
|
- 문서 변경 git 커밋·push (`[Documenter] #<ID> ...`).
|
||||||
|
- 프로토콜 §6 에 따라 카테고리 `09-Done`, 상태 **완료(5)**, done_ratio 100 으로 닫는다.
|
||||||
|
- 마지막 저널 노트에 전체 요약(무엇을·왜·어떻게 검증)을 남긴다.
|
||||||
26
.claude/agents/planner.md
Normal file
26
.claude/agents/planner.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: planner
|
||||||
|
description: "[AI] Planner — 기능 요구를 인수조건이 있는 실행 가능한 작업으로 분해. 파이프라인 1단계."
|
||||||
|
tools: Bash, Read, Edit, Write, Grep, Glob
|
||||||
|
model: opus
|
||||||
|
---
|
||||||
|
|
||||||
|
너는 tasteby 파이프라인의 **[AI] Planner** 다.
|
||||||
|
|
||||||
|
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`.
|
||||||
|
|
||||||
|
## 역할
|
||||||
|
- 이슈의 기능 요구를 명확한 **범위(scope)** 와 **인수조건(acceptance criteria)** 으로 정리.
|
||||||
|
- 너무 크면 하위 작업으로 쪼갠다(필요 시 Redmine 자식 이슈 생성).
|
||||||
|
- 무엇을 만들지 결정하되, **어떻게**(설계)·**코드**는 다음 페르소나에게 맡긴다.
|
||||||
|
|
||||||
|
## 산출물 (이슈 description 의 `## [AI] Planner` 섹션에 기록)
|
||||||
|
- 목표 1줄
|
||||||
|
- 인수조건 체크리스트 (검증 가능한 항목)
|
||||||
|
- 범위 밖(out of scope) 명시
|
||||||
|
- 리스크/가정
|
||||||
|
|
||||||
|
## 핸드오프
|
||||||
|
- 코드 변경이 없으면 git 커밋은 생략 가능하나, 이슈 description 갱신은 필수.
|
||||||
|
- 끝나면 카테고리를 `02-Architect`, 상태 신규 로 전진.
|
||||||
|
- 프로토콜의 "결과 남기기 (b),(c)" 를 따른다.
|
||||||
28
.claude/agents/qa.md
Normal file
28
.claude/agents/qa.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: qa
|
||||||
|
description: "[AI] QA — 테스트 작성/실행, 인수조건 검증. 통과 시 Designer, 실패 시 Developer 반려. 파이프라인 4단계 게이트."
|
||||||
|
tools: Bash, Read, Edit, Write, Grep, Glob
|
||||||
|
model: sonnet
|
||||||
|
---
|
||||||
|
|
||||||
|
너는 tasteby 파이프라인의 **[AI] QA** 이며 **품질 게이트**다.
|
||||||
|
|
||||||
|
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`,
|
||||||
|
이슈의 `## [AI] Planner` 인수조건.
|
||||||
|
|
||||||
|
## 역할
|
||||||
|
- Planner 의 **인수조건을 하나씩 검증**한다.
|
||||||
|
- **설계서 일치 검증**: 구현이 `docs/design/<issue-id>-<slug>/` 의 함수 명세(시그니처·
|
||||||
|
입출력·에러·엣지)와 일치하는지, 설계서의 테스트 케이스가 실제로 존재·통과하는지 확인.
|
||||||
|
- 테스트를 실행하고, 누락된 경계/회귀 테스트는 추가한다.
|
||||||
|
- 거래소 API 등 외부 의존은 가능한 한 모킹/드라이런으로 검증.
|
||||||
|
- 결과는 **PASS/FAIL** 로 명확히 판정한다. 애매하면 FAIL.
|
||||||
|
|
||||||
|
## 게이트 결정 (둘 중 하나)
|
||||||
|
- **PASS**: 모든 인수조건 충족 + 테스트 통과 → 카테고리 `05-Designer`, 상태 신규.
|
||||||
|
- **FAIL**: 하나라도 불충족 → 카테고리 `03-Developer`, 상태 신규 로 **반려**,
|
||||||
|
저널 노트에 **재현 절차 + 실패 항목 + 기대값/실제값**을 구체적으로 남긴다.
|
||||||
|
|
||||||
|
## 핸드오프
|
||||||
|
- 테스트 파일을 추가했으면 git 커밋·push (`[QA] #<ID> ...`).
|
||||||
|
- 프로토콜의 (a),(b),(c) 또는 §5(반려) 를 따른다.
|
||||||
25
.claude/agents/release.md
Normal file
25
.claude/agents/release.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
name: release
|
||||||
|
description: "[AI] Release — 버전 태그, 빌드/배포 산출물, 릴리스 노트, git 태그 push. 파이프라인 7단계."
|
||||||
|
tools: Bash, Read, Edit, Write, Grep, Glob
|
||||||
|
model: sonnet
|
||||||
|
---
|
||||||
|
|
||||||
|
너는 tasteby 파이프라인의 **[AI] Release** 다.
|
||||||
|
|
||||||
|
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`.
|
||||||
|
|
||||||
|
## 역할
|
||||||
|
- 머지 가능한 상태를 **릴리스**로 묶는다:
|
||||||
|
- 필요 시 `feature/*` → `main` 병합, 빌드/패키징 실행·확인.
|
||||||
|
- 시맨틱 버전 결정 후 **git 태그** 생성 + Gitea push (`vX.Y.Z`).
|
||||||
|
- `CHANGELOG.md` 에 이번 변경 항목 추가.
|
||||||
|
- 실행/배포 절차(필요 런타임 파일, 시작 커맨드)를 이슈에 명시.
|
||||||
|
- 실거래 영향이 있는 변경은 배포 절차에 **안전장치/롤백**을 적는다.
|
||||||
|
|
||||||
|
## 산출물
|
||||||
|
- git 태그, CHANGELOG 항목, 릴리스 노트(이슈 `## [AI] Release` 섹션).
|
||||||
|
|
||||||
|
## 핸드오프
|
||||||
|
- 커밋·태그 push (`[Release] #<ID> ...`).
|
||||||
|
- 끝나면 카테고리 `08-Documenter`, 상태 신규 로 전진. 프로토콜 (a),(b),(c) 준수.
|
||||||
28
.claude/agents/reviewer.md
Normal file
28
.claude/agents/reviewer.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: reviewer
|
||||||
|
description: "[AI] Reviewer — 정확성·보안·표준 코드리뷰. 승인 시 Release, 위반 시 Developer 반려. 파이프라인 6단계 게이트."
|
||||||
|
tools: Bash, Read, Edit, Write, Grep, Glob
|
||||||
|
model: opus
|
||||||
|
---
|
||||||
|
|
||||||
|
너는 tasteby 파이프라인의 **[AI] Reviewer** 이며 **최종 코드 게이트**다.
|
||||||
|
|
||||||
|
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`.
|
||||||
|
`git log`/`git diff` 로 이 이슈에서 변경된 내용을 검토한다.
|
||||||
|
|
||||||
|
## 검토 관점
|
||||||
|
- **정확성**: 로직 버그, 엣지케이스, 레이스, 잘못된 가정.
|
||||||
|
- **설계서 일치**: 구현이 `docs/design/` 설계서와 일치하는가. 설계서에 없는 임의 동작은
|
||||||
|
없는가. 설계가 바뀌었다면 설계서가 먼저 갱신됐는가. 큰 결정이 ADR 로 기록됐는가.
|
||||||
|
- **리스크/보안**: 비밀 노출, 주문/리스크 경로의 안전성, 입력 검증, 레이트리밋·재시도.
|
||||||
|
- **표준 준수**: CLAUDE.md 원칙(단일 책임, I/O 분리, 명시적 에러, 안전한 기본값).
|
||||||
|
- **테스트 충분성**: 핵심 로직이 테스트로 덮였는가.
|
||||||
|
|
||||||
|
## 게이트 결정 (둘 중 하나)
|
||||||
|
- **승인**: 문제 없음 → 카테고리 `07-Release`, 상태 신규. 승인 요지를 노트에 기록.
|
||||||
|
- **반려**: 결함 발견 → 카테고리 `03-Developer`, 상태 신규 로 반려,
|
||||||
|
노트에 **파일:라인 + 문제 + 권고 수정**을 구체적으로 남긴다.
|
||||||
|
- 사소한 스타일은 직접 고치고 승인해도 되나, 동작/보안 변경은 반드시 Developer 반려.
|
||||||
|
|
||||||
|
## 핸드오프
|
||||||
|
- 직접 수정 시 git 커밋·push (`[Reviewer] #<ID> ...`). 프로토콜 (a),(b),(c) 또는 §5.
|
||||||
5
.claude/commands/brainstorm.md
Normal file
5
.claude/commands/brainstorm.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
description: "Deprecated - use the superpowers:brainstorming skill instead"
|
||||||
|
---
|
||||||
|
|
||||||
|
Tell your human partner that this command is deprecated and will be removed in the next major release. They should ask you to use the "superpowers brainstorming" skill instead.
|
||||||
5
.claude/commands/execute-plan.md
Normal file
5
.claude/commands/execute-plan.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
description: "Deprecated - use the superpowers:executing-plans skill instead"
|
||||||
|
---
|
||||||
|
|
||||||
|
Tell your human partner that this command is deprecated and will be removed in the next major release. They should ask you to use the "superpowers executing-plans" skill instead.
|
||||||
5
.claude/commands/write-plan.md
Normal file
5
.claude/commands/write-plan.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
description: "Deprecated - use the superpowers:writing-plans skill instead"
|
||||||
|
---
|
||||||
|
|
||||||
|
Tell your human partner that this command is deprecated and will be removed in the next major release. They should ask you to use the "superpowers writing-plans" skill instead.
|
||||||
16
.claude/hooks.json
Normal file
16
.claude/hooks.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "startup|resume|clear|compact",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "'${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd' session-start",
|
||||||
|
"async": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
16
.claude/hooks/hooks.json
Normal file
16
.claude/hooks/hooks.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "startup|resume|clear|compact",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "'${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd' session-start",
|
||||||
|
"async": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
46
.claude/hooks/run-hook.cmd
Executable file
46
.claude/hooks/run-hook.cmd
Executable file
@@ -0,0 +1,46 @@
|
|||||||
|
: << 'CMDBLOCK'
|
||||||
|
@echo off
|
||||||
|
REM Cross-platform polyglot wrapper for hook scripts.
|
||||||
|
REM On Windows: cmd.exe runs the batch portion, which finds and calls bash.
|
||||||
|
REM On Unix: the shell interprets this as a script (: is a no-op in bash).
|
||||||
|
REM
|
||||||
|
REM Hook scripts use extensionless filenames (e.g. "session-start" not
|
||||||
|
REM "session-start.sh") so Claude Code's Windows auto-detection -- which
|
||||||
|
REM prepends "bash" to any command containing .sh -- doesn't interfere.
|
||||||
|
REM
|
||||||
|
REM Usage: run-hook.cmd <script-name> [args...]
|
||||||
|
|
||||||
|
if "%~1"=="" (
|
||||||
|
echo run-hook.cmd: missing script name >&2
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
set "HOOK_DIR=%~dp0"
|
||||||
|
|
||||||
|
REM Try Git for Windows bash in standard locations
|
||||||
|
if exist "C:\Program Files\Git\bin\bash.exe" (
|
||||||
|
"C:\Program Files\Git\bin\bash.exe" "%HOOK_DIR%%~1" %2 %3 %4 %5 %6 %7 %8 %9
|
||||||
|
exit /b %ERRORLEVEL%
|
||||||
|
)
|
||||||
|
if exist "C:\Program Files (x86)\Git\bin\bash.exe" (
|
||||||
|
"C:\Program Files (x86)\Git\bin\bash.exe" "%HOOK_DIR%%~1" %2 %3 %4 %5 %6 %7 %8 %9
|
||||||
|
exit /b %ERRORLEVEL%
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Try bash on PATH (e.g. user-installed Git Bash, MSYS2, Cygwin)
|
||||||
|
where bash >nul 2>nul
|
||||||
|
if %ERRORLEVEL% equ 0 (
|
||||||
|
bash "%HOOK_DIR%%~1" %2 %3 %4 %5 %6 %7 %8 %9
|
||||||
|
exit /b %ERRORLEVEL%
|
||||||
|
)
|
||||||
|
|
||||||
|
REM No bash found - exit silently rather than error
|
||||||
|
REM (plugin still works, just without SessionStart context injection)
|
||||||
|
exit /b 0
|
||||||
|
CMDBLOCK
|
||||||
|
|
||||||
|
# Unix: run the named script directly
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
SCRIPT_NAME="$1"
|
||||||
|
shift
|
||||||
|
exec bash "${SCRIPT_DIR}/${SCRIPT_NAME}" "$@"
|
||||||
51
.claude/hooks/session-start
Executable file
51
.claude/hooks/session-start
Executable file
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# SessionStart hook for superpowers plugin
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Determine plugin root directory
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
|
||||||
|
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
|
||||||
|
# Check if legacy skills directory exists and build warning
|
||||||
|
warning_message=""
|
||||||
|
legacy_skills_dir="${HOME}/.config/superpowers/skills"
|
||||||
|
if [ -d "$legacy_skills_dir" ]; then
|
||||||
|
warning_message="\n\n<important-reminder>IN YOUR FIRST REPLY AFTER SEEING THIS MESSAGE YOU MUST TELL THE USER:⚠️ **WARNING:** Superpowers now uses Claude Code's skills system. Custom skills in ~/.config/superpowers/skills will not be read. Move custom skills to ~/.claude/skills instead. To make this message go away, remove ~/.config/superpowers/skills</important-reminder>"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Read using-superpowers content
|
||||||
|
using_superpowers_content=$(cat "${PLUGIN_ROOT}/skills/using-superpowers/SKILL.md" 2>&1 || echo "Error reading using-superpowers skill")
|
||||||
|
|
||||||
|
# Escape string for JSON embedding using bash parameter substitution.
|
||||||
|
# Each ${s//old/new} is a single C-level pass - orders of magnitude
|
||||||
|
# faster than the character-by-character loop this replaces.
|
||||||
|
escape_for_json() {
|
||||||
|
local s="$1"
|
||||||
|
s="${s//\\/\\\\}"
|
||||||
|
s="${s//\"/\\\"}"
|
||||||
|
s="${s//$'\n'/\\n}"
|
||||||
|
s="${s//$'\r'/\\r}"
|
||||||
|
s="${s//$'\t'/\\t}"
|
||||||
|
printf '%s' "$s"
|
||||||
|
}
|
||||||
|
|
||||||
|
using_superpowers_escaped=$(escape_for_json "$using_superpowers_content")
|
||||||
|
warning_escaped=$(escape_for_json "$warning_message")
|
||||||
|
session_context="<EXTREMELY_IMPORTANT>\nYou have superpowers.\n\n**Below is the full content of your 'superpowers:using-superpowers' skill - your introduction to using skills. For all other skills, use the 'Skill' tool:**\n\n${using_superpowers_escaped}\n\n${warning_escaped}\n</EXTREMELY_IMPORTANT>"
|
||||||
|
|
||||||
|
# Output context injection as JSON.
|
||||||
|
# Keep both shapes for compatibility:
|
||||||
|
# - Cursor hooks expect additional_context.
|
||||||
|
# - Claude hooks expect hookSpecificOutput.additionalContext.
|
||||||
|
cat <<EOF
|
||||||
|
{
|
||||||
|
"additional_context": "${session_context}",
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "SessionStart",
|
||||||
|
"additionalContext": "${session_context}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
exit 0
|
||||||
213
.claude/lib/brainstorm-server/frame-template.html
Normal file
213
.claude/lib/brainstorm-server/frame-template.html
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Superpowers Brainstorming</title>
|
||||||
|
<style>
|
||||||
|
/*
|
||||||
|
* BRAINSTORM COMPANION FRAME TEMPLATE
|
||||||
|
*
|
||||||
|
* This template provides a consistent frame with:
|
||||||
|
* - OS-aware light/dark theming
|
||||||
|
* - Fixed header and selection indicator bar
|
||||||
|
* - Scrollable main content area
|
||||||
|
* - CSS helpers for common UI patterns
|
||||||
|
*
|
||||||
|
* Content is injected via placeholder comment in #claude-content.
|
||||||
|
*/
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body { height: 100%; overflow: hidden; }
|
||||||
|
|
||||||
|
/* ===== THEME VARIABLES ===== */
|
||||||
|
:root {
|
||||||
|
--bg-primary: #f5f5f7;
|
||||||
|
--bg-secondary: #ffffff;
|
||||||
|
--bg-tertiary: #e5e5e7;
|
||||||
|
--border: #d1d1d6;
|
||||||
|
--text-primary: #1d1d1f;
|
||||||
|
--text-secondary: #86868b;
|
||||||
|
--text-tertiary: #aeaeb2;
|
||||||
|
--accent: #0071e3;
|
||||||
|
--accent-hover: #0077ed;
|
||||||
|
--success: #34c759;
|
||||||
|
--warning: #ff9f0a;
|
||||||
|
--error: #ff3b30;
|
||||||
|
--selected-bg: #e8f4fd;
|
||||||
|
--selected-border: #0071e3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg-primary: #1d1d1f;
|
||||||
|
--bg-secondary: #2d2d2f;
|
||||||
|
--bg-tertiary: #3d3d3f;
|
||||||
|
--border: #424245;
|
||||||
|
--text-primary: #f5f5f7;
|
||||||
|
--text-secondary: #86868b;
|
||||||
|
--text-tertiary: #636366;
|
||||||
|
--accent: #0a84ff;
|
||||||
|
--accent-hover: #409cff;
|
||||||
|
--selected-bg: rgba(10, 132, 255, 0.15);
|
||||||
|
--selected-border: #0a84ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== FRAME STRUCTURE ===== */
|
||||||
|
.header {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.header h1 { font-size: 0.85rem; font-weight: 500; color: var(--text-secondary); }
|
||||||
|
.header .status { font-size: 0.7rem; color: var(--success); display: flex; align-items: center; gap: 0.4rem; }
|
||||||
|
.header .status::before { content: ''; width: 6px; height: 6px; background: var(--success); border-radius: 50%; }
|
||||||
|
|
||||||
|
.main { flex: 1; overflow-y: auto; }
|
||||||
|
#claude-content { padding: 2rem; min-height: 100%; }
|
||||||
|
|
||||||
|
.indicator-bar {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.indicator-bar span {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.indicator-bar .selected-text {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== TYPOGRAPHY ===== */
|
||||||
|
h2 { font-size: 1.5rem; font-weight: 600; margin-bottom: 0.5rem; }
|
||||||
|
h3 { font-size: 1.1rem; font-weight: 600; margin-bottom: 0.25rem; }
|
||||||
|
.subtitle { color: var(--text-secondary); margin-bottom: 1.5rem; }
|
||||||
|
.section { margin-bottom: 2rem; }
|
||||||
|
.label { font-size: 0.7rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
|
||||||
|
|
||||||
|
/* ===== OPTIONS (for A/B/C choices) ===== */
|
||||||
|
.options { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
.option {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.option:hover { border-color: var(--accent); }
|
||||||
|
.option.selected { background: var(--selected-bg); border-color: var(--selected-border); }
|
||||||
|
.option .letter {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
width: 1.75rem; height: 1.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-weight: 600; font-size: 0.85rem; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.option.selected .letter { background: var(--accent); color: white; }
|
||||||
|
.option .content { flex: 1; }
|
||||||
|
.option .content h3 { font-size: 0.95rem; margin-bottom: 0.15rem; }
|
||||||
|
.option .content p { color: var(--text-secondary); font-size: 0.85rem; margin: 0; }
|
||||||
|
|
||||||
|
/* ===== CARDS (for showing designs/mockups) ===== */
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; }
|
||||||
|
.card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.card:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||||
|
.card.selected { border-color: var(--selected-border); border-width: 2px; }
|
||||||
|
.card-image { background: var(--bg-tertiary); aspect-ratio: 16/10; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.card-body { padding: 1rem; }
|
||||||
|
.card-body h3 { margin-bottom: 0.25rem; }
|
||||||
|
.card-body p { color: var(--text-secondary); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
/* ===== MOCKUP CONTAINER ===== */
|
||||||
|
.mockup {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.mockup-header {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.mockup-body { padding: 1.5rem; }
|
||||||
|
|
||||||
|
/* ===== SPLIT VIEW (side-by-side comparison) ===== */
|
||||||
|
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
|
||||||
|
@media (max-width: 700px) { .split { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* ===== PROS/CONS ===== */
|
||||||
|
.pros-cons { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 1rem 0; }
|
||||||
|
.pros, .cons { background: var(--bg-secondary); border-radius: 8px; padding: 1rem; }
|
||||||
|
.pros h4 { color: var(--success); font-size: 0.85rem; margin-bottom: 0.5rem; }
|
||||||
|
.cons h4 { color: var(--error); font-size: 0.85rem; margin-bottom: 0.5rem; }
|
||||||
|
.pros ul, .cons ul { margin-left: 1.25rem; font-size: 0.85rem; color: var(--text-secondary); }
|
||||||
|
.pros li, .cons li { margin-bottom: 0.25rem; }
|
||||||
|
|
||||||
|
/* ===== PLACEHOLDER (for mockup areas) ===== */
|
||||||
|
.placeholder {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== INLINE MOCKUP ELEMENTS ===== */
|
||||||
|
.mock-nav { background: var(--accent); color: white; padding: 0.75rem 1rem; display: flex; gap: 1.5rem; font-size: 0.9rem; }
|
||||||
|
.mock-sidebar { background: var(--bg-tertiary); padding: 1rem; min-width: 180px; }
|
||||||
|
.mock-content { padding: 1.5rem; flex: 1; }
|
||||||
|
.mock-button { background: var(--accent); color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.85rem; }
|
||||||
|
.mock-input { background: var(--bg-primary); border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem; width: 100%; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1><a href="https://github.com/obra/superpowers" style="color: inherit; text-decoration: none;">Superpowers Brainstorming</a></h1>
|
||||||
|
<div class="status">Connected</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<div id="claude-content">
|
||||||
|
<!-- CONTENT -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="indicator-bar">
|
||||||
|
<span id="indicator-text">Click an option above, then return to the terminal</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
88
.claude/lib/brainstorm-server/helper.js
Normal file
88
.claude/lib/brainstorm-server/helper.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
(function() {
|
||||||
|
const WS_URL = 'ws://' + window.location.host;
|
||||||
|
let ws = null;
|
||||||
|
let eventQueue = [];
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
ws = new WebSocket(WS_URL);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
eventQueue.forEach(e => ws.send(JSON.stringify(e)));
|
||||||
|
eventQueue = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (msg) => {
|
||||||
|
const data = JSON.parse(msg.data);
|
||||||
|
if (data.type === 'reload') {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setTimeout(connect, 1000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendEvent(event) {
|
||||||
|
event.timestamp = Date.now();
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify(event));
|
||||||
|
} else {
|
||||||
|
eventQueue.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture clicks on choice elements
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const target = e.target.closest('[data-choice]');
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
sendEvent({
|
||||||
|
type: 'click',
|
||||||
|
text: target.textContent.trim(),
|
||||||
|
choice: target.dataset.choice,
|
||||||
|
id: target.id || null
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update indicator bar (defer so toggleSelect runs first)
|
||||||
|
setTimeout(() => {
|
||||||
|
const indicator = document.getElementById('indicator-text');
|
||||||
|
if (!indicator) return;
|
||||||
|
const container = target.closest('.options') || target.closest('.cards');
|
||||||
|
const selected = container ? container.querySelectorAll('.selected') : [];
|
||||||
|
if (selected.length === 0) {
|
||||||
|
indicator.textContent = 'Click an option above, then return to the terminal';
|
||||||
|
} else if (selected.length === 1) {
|
||||||
|
const label = selected[0].querySelector('h3, .content h3, .card-body h3')?.textContent?.trim() || selected[0].dataset.choice;
|
||||||
|
indicator.innerHTML = '<span class="selected-text">' + label + ' selected</span> — return to terminal to continue';
|
||||||
|
} else {
|
||||||
|
indicator.innerHTML = '<span class="selected-text">' + selected.length + ' selected</span> — return to terminal to continue';
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Frame UI: selection tracking
|
||||||
|
window.selectedChoice = null;
|
||||||
|
|
||||||
|
window.toggleSelect = function(el) {
|
||||||
|
const container = el.closest('.options') || el.closest('.cards');
|
||||||
|
const multi = container && container.dataset.multiselect !== undefined;
|
||||||
|
if (container && !multi) {
|
||||||
|
container.querySelectorAll('.option, .card').forEach(o => o.classList.remove('selected'));
|
||||||
|
}
|
||||||
|
if (multi) {
|
||||||
|
el.classList.toggle('selected');
|
||||||
|
} else {
|
||||||
|
el.classList.add('selected');
|
||||||
|
}
|
||||||
|
window.selectedChoice = el.dataset.choice;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expose API for explicit use
|
||||||
|
window.brainstorm = {
|
||||||
|
send: sendEvent,
|
||||||
|
choice: (value, metadata = {}) => sendEvent({ type: 'choice', value, ...metadata })
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
})();
|
||||||
141
.claude/lib/brainstorm-server/index.js
Normal file
141
.claude/lib/brainstorm-server/index.js
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const http = require('http');
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
const chokidar = require('chokidar');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const PORT = process.env.BRAINSTORM_PORT || (49152 + Math.floor(Math.random() * 16383));
|
||||||
|
const HOST = process.env.BRAINSTORM_HOST || '127.0.0.1';
|
||||||
|
const URL_HOST = process.env.BRAINSTORM_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST);
|
||||||
|
const SCREEN_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm';
|
||||||
|
|
||||||
|
if (!fs.existsSync(SCREEN_DIR)) {
|
||||||
|
fs.mkdirSync(SCREEN_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load frame template and helper script once at startup
|
||||||
|
const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8');
|
||||||
|
const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');
|
||||||
|
const helperInjection = `<script>\n${helperScript}\n</script>`;
|
||||||
|
|
||||||
|
// Detect whether content is a full HTML document or a bare fragment
|
||||||
|
function isFullDocument(html) {
|
||||||
|
const trimmed = html.trimStart().toLowerCase();
|
||||||
|
return trimmed.startsWith('<!doctype') || trimmed.startsWith('<html');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap a content fragment in the frame template
|
||||||
|
function wrapInFrame(content) {
|
||||||
|
return frameTemplate.replace('<!-- CONTENT -->', content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the newest .html file in the directory by mtime
|
||||||
|
function getNewestScreen() {
|
||||||
|
const files = fs.readdirSync(SCREEN_DIR)
|
||||||
|
.filter(f => f.endsWith('.html'))
|
||||||
|
.map(f => ({
|
||||||
|
name: f,
|
||||||
|
path: path.join(SCREEN_DIR, f),
|
||||||
|
mtime: fs.statSync(path.join(SCREEN_DIR, f)).mtime.getTime()
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.mtime - a.mtime);
|
||||||
|
|
||||||
|
return files.length > 0 ? files[0].path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WAITING_PAGE = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Brainstorm Companion</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
|
||||||
|
h1 { color: #333; }
|
||||||
|
p { color: #666; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Brainstorm Companion</h1>
|
||||||
|
<p>Waiting for Claude to push a screen...</p>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const server = http.createServer(app);
|
||||||
|
const wss = new WebSocket.Server({ server });
|
||||||
|
|
||||||
|
const clients = new Set();
|
||||||
|
|
||||||
|
wss.on('connection', (ws) => {
|
||||||
|
clients.add(ws);
|
||||||
|
ws.on('close', () => clients.delete(ws));
|
||||||
|
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
const event = JSON.parse(data.toString());
|
||||||
|
console.log(JSON.stringify({ source: 'user-event', ...event }));
|
||||||
|
// Write user events to .events file for Claude to read
|
||||||
|
if (event.choice) {
|
||||||
|
const eventsFile = path.join(SCREEN_DIR, '.events');
|
||||||
|
fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve newest screen with helper.js injected
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
const screenFile = getNewestScreen();
|
||||||
|
let html;
|
||||||
|
|
||||||
|
if (!screenFile) {
|
||||||
|
html = WAITING_PAGE;
|
||||||
|
} else {
|
||||||
|
const raw = fs.readFileSync(screenFile, 'utf-8');
|
||||||
|
html = isFullDocument(raw) ? raw : wrapInFrame(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject helper script
|
||||||
|
if (html.includes('</body>')) {
|
||||||
|
html = html.replace('</body>', `${helperInjection}\n</body>`);
|
||||||
|
} else {
|
||||||
|
html += helperInjection;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.type('html').send(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch for new or changed .html files
|
||||||
|
chokidar.watch(SCREEN_DIR, { ignoreInitial: true })
|
||||||
|
.on('add', (filePath) => {
|
||||||
|
if (filePath.endsWith('.html')) {
|
||||||
|
// Clear events from previous screen
|
||||||
|
const eventsFile = path.join(SCREEN_DIR, '.events');
|
||||||
|
if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
|
||||||
|
console.log(JSON.stringify({ type: 'screen-added', file: filePath }));
|
||||||
|
clients.forEach(ws => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'reload' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on('change', (filePath) => {
|
||||||
|
if (filePath.endsWith('.html')) {
|
||||||
|
console.log(JSON.stringify({ type: 'screen-updated', file: filePath }));
|
||||||
|
clients.forEach(ws => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'reload' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, HOST, () => {
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
type: 'server-started',
|
||||||
|
port: PORT,
|
||||||
|
host: HOST,
|
||||||
|
url_host: URL_HOST,
|
||||||
|
url: `http://${URL_HOST}:${PORT}`,
|
||||||
|
screen_dir: SCREEN_DIR
|
||||||
|
}));
|
||||||
|
});
|
||||||
1036
.claude/lib/brainstorm-server/package-lock.json
generated
Normal file
1036
.claude/lib/brainstorm-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
11
.claude/lib/brainstorm-server/package.json
Normal file
11
.claude/lib/brainstorm-server/package.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "brainstorm-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Visual brainstorming companion server for Claude Code",
|
||||||
|
"main": "index.js",
|
||||||
|
"dependencies": {
|
||||||
|
"chokidar": "^3.5.3",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"ws": "^8.14.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
129
.claude/lib/brainstorm-server/start-server.sh
Executable file
129
.claude/lib/brainstorm-server/start-server.sh
Executable file
@@ -0,0 +1,129 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Start the brainstorm server and output connection info
|
||||||
|
# Usage: start-server.sh [--project-dir <path>] [--host <bind-host>] [--url-host <display-host>] [--foreground] [--background]
|
||||||
|
#
|
||||||
|
# Starts server on a random high port, outputs JSON with URL.
|
||||||
|
# Each session gets its own directory to avoid conflicts.
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --project-dir <path> Store session files under <path>/.superpowers/brainstorm/
|
||||||
|
# instead of /tmp. Files persist after server stops.
|
||||||
|
# --host <bind-host> Host/interface to bind (default: 127.0.0.1).
|
||||||
|
# Use 0.0.0.0 in remote/containerized environments.
|
||||||
|
# --url-host <host> Hostname shown in returned URL JSON.
|
||||||
|
# --foreground Run server in the current terminal (no backgrounding).
|
||||||
|
# --background Force background mode (overrides Codex auto-foreground).
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
PROJECT_DIR=""
|
||||||
|
FOREGROUND="false"
|
||||||
|
FORCE_BACKGROUND="false"
|
||||||
|
BIND_HOST="127.0.0.1"
|
||||||
|
URL_HOST=""
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--project-dir)
|
||||||
|
PROJECT_DIR="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--host)
|
||||||
|
BIND_HOST="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--url-host)
|
||||||
|
URL_HOST="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--foreground|--no-daemon)
|
||||||
|
FOREGROUND="true"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--background|--daemon)
|
||||||
|
FORCE_BACKGROUND="true"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "{\"error\": \"Unknown argument: $1\"}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$URL_HOST" ]]; then
|
||||||
|
if [[ "$BIND_HOST" == "127.0.0.1" || "$BIND_HOST" == "localhost" ]]; then
|
||||||
|
URL_HOST="localhost"
|
||||||
|
else
|
||||||
|
URL_HOST="$BIND_HOST"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Codex environments may reap detached/background processes. Prefer foreground by default.
|
||||||
|
if [[ -n "${CODEX_CI:-}" && "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then
|
||||||
|
FOREGROUND="true"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Generate unique session directory
|
||||||
|
SESSION_ID="$$-$(date +%s)"
|
||||||
|
|
||||||
|
if [[ -n "$PROJECT_DIR" ]]; then
|
||||||
|
SCREEN_DIR="${PROJECT_DIR}/.superpowers/brainstorm/${SESSION_ID}"
|
||||||
|
else
|
||||||
|
SCREEN_DIR="/tmp/brainstorm-${SESSION_ID}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
PID_FILE="${SCREEN_DIR}/.server.pid"
|
||||||
|
LOG_FILE="${SCREEN_DIR}/.server.log"
|
||||||
|
|
||||||
|
# Create fresh session directory
|
||||||
|
mkdir -p "$SCREEN_DIR"
|
||||||
|
|
||||||
|
# Kill any existing server
|
||||||
|
if [[ -f "$PID_FILE" ]]; then
|
||||||
|
old_pid=$(cat "$PID_FILE")
|
||||||
|
kill "$old_pid" 2>/dev/null
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# Foreground mode for environments that reap detached/background processes.
|
||||||
|
if [[ "$FOREGROUND" == "true" ]]; then
|
||||||
|
echo "$$" > "$PID_FILE"
|
||||||
|
env BRAINSTORM_DIR="$SCREEN_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" node index.js
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start server, capturing output to log file
|
||||||
|
# Use nohup to survive shell exit; disown to remove from job table
|
||||||
|
nohup env BRAINSTORM_DIR="$SCREEN_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" node index.js > "$LOG_FILE" 2>&1 &
|
||||||
|
SERVER_PID=$!
|
||||||
|
disown "$SERVER_PID" 2>/dev/null
|
||||||
|
echo "$SERVER_PID" > "$PID_FILE"
|
||||||
|
|
||||||
|
# Wait for server-started message (check log file)
|
||||||
|
for i in {1..50}; do
|
||||||
|
if grep -q "server-started" "$LOG_FILE" 2>/dev/null; then
|
||||||
|
# Verify server is still alive after a short window (catches process reapers)
|
||||||
|
alive="true"
|
||||||
|
for _ in {1..20}; do
|
||||||
|
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||||
|
alive="false"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
if [[ "$alive" != "true" ]]; then
|
||||||
|
echo "{\"error\": \"Server started but was killed. Retry in a persistent terminal with: $SCRIPT_DIR/start-server.sh${PROJECT_DIR:+ --project-dir $PROJECT_DIR} --host $BIND_HOST --url-host $URL_HOST --foreground\"}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep "server-started" "$LOG_FILE" | head -1
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Timeout - server didn't start
|
||||||
|
echo '{"error": "Server failed to start within 5 seconds"}'
|
||||||
|
exit 1
|
||||||
31
.claude/lib/brainstorm-server/stop-server.sh
Executable file
31
.claude/lib/brainstorm-server/stop-server.sh
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Stop the brainstorm server and clean up
|
||||||
|
# Usage: stop-server.sh <screen_dir>
|
||||||
|
#
|
||||||
|
# Kills the server process. Only deletes session directory if it's
|
||||||
|
# under /tmp (ephemeral). Persistent directories (.superpowers/) are
|
||||||
|
# kept so mockups can be reviewed later.
|
||||||
|
|
||||||
|
SCREEN_DIR="$1"
|
||||||
|
|
||||||
|
if [[ -z "$SCREEN_DIR" ]]; then
|
||||||
|
echo '{"error": "Usage: stop-server.sh <screen_dir>"}'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PID_FILE="${SCREEN_DIR}/.server.pid"
|
||||||
|
|
||||||
|
if [[ -f "$PID_FILE" ]]; then
|
||||||
|
pid=$(cat "$PID_FILE")
|
||||||
|
kill "$pid" 2>/dev/null
|
||||||
|
rm -f "$PID_FILE" "${SCREEN_DIR}/.server.log"
|
||||||
|
|
||||||
|
# Only delete ephemeral /tmp directories
|
||||||
|
if [[ "$SCREEN_DIR" == /tmp/* ]]; then
|
||||||
|
rm -rf "$SCREEN_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo '{"status": "stopped"}'
|
||||||
|
else
|
||||||
|
echo '{"status": "not_running"}'
|
||||||
|
fi
|
||||||
208
.claude/lib/skills-core.js
Normal file
208
.claude/lib/skills-core.js
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract YAML frontmatter from a skill file.
|
||||||
|
* Current format:
|
||||||
|
* ---
|
||||||
|
* name: skill-name
|
||||||
|
* description: Use when [condition] - [what it does]
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* @param {string} filePath - Path to SKILL.md file
|
||||||
|
* @returns {{name: string, description: string}}
|
||||||
|
*/
|
||||||
|
function extractFrontmatter(filePath) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
let inFrontmatter = false;
|
||||||
|
let name = '';
|
||||||
|
let description = '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trim() === '---') {
|
||||||
|
if (inFrontmatter) break;
|
||||||
|
inFrontmatter = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inFrontmatter) {
|
||||||
|
const match = line.match(/^(\w+):\s*(.*)$/);
|
||||||
|
if (match) {
|
||||||
|
const [, key, value] = match;
|
||||||
|
switch (key) {
|
||||||
|
case 'name':
|
||||||
|
name = value.trim();
|
||||||
|
break;
|
||||||
|
case 'description':
|
||||||
|
description = value.trim();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name, description };
|
||||||
|
} catch (error) {
|
||||||
|
return { name: '', description: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all SKILL.md files in a directory recursively.
|
||||||
|
*
|
||||||
|
* @param {string} dir - Directory to search
|
||||||
|
* @param {string} sourceType - 'personal' or 'superpowers' for namespacing
|
||||||
|
* @param {number} maxDepth - Maximum recursion depth (default: 3)
|
||||||
|
* @returns {Array<{path: string, name: string, description: string, sourceType: string}>}
|
||||||
|
*/
|
||||||
|
function findSkillsInDir(dir, sourceType, maxDepth = 3) {
|
||||||
|
const skills = [];
|
||||||
|
|
||||||
|
if (!fs.existsSync(dir)) return skills;
|
||||||
|
|
||||||
|
function recurse(currentDir, depth) {
|
||||||
|
if (depth > maxDepth) return;
|
||||||
|
|
||||||
|
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(currentDir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
// Check for SKILL.md in this directory
|
||||||
|
const skillFile = path.join(fullPath, 'SKILL.md');
|
||||||
|
if (fs.existsSync(skillFile)) {
|
||||||
|
const { name, description } = extractFrontmatter(skillFile);
|
||||||
|
skills.push({
|
||||||
|
path: fullPath,
|
||||||
|
skillFile: skillFile,
|
||||||
|
name: name || entry.name,
|
||||||
|
description: description || '',
|
||||||
|
sourceType: sourceType
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse into subdirectories
|
||||||
|
recurse(fullPath, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recurse(dir, 0);
|
||||||
|
return skills;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a skill name to its file path, handling shadowing
|
||||||
|
* (personal skills override superpowers skills).
|
||||||
|
*
|
||||||
|
* @param {string} skillName - Name like "superpowers:brainstorming" or "my-skill"
|
||||||
|
* @param {string} superpowersDir - Path to superpowers skills directory
|
||||||
|
* @param {string} personalDir - Path to personal skills directory
|
||||||
|
* @returns {{skillFile: string, sourceType: string, skillPath: string} | null}
|
||||||
|
*/
|
||||||
|
function resolveSkillPath(skillName, superpowersDir, personalDir) {
|
||||||
|
// Strip superpowers: prefix if present
|
||||||
|
const forceSuperpowers = skillName.startsWith('superpowers:');
|
||||||
|
const actualSkillName = forceSuperpowers ? skillName.replace(/^superpowers:/, '') : skillName;
|
||||||
|
|
||||||
|
// Try personal skills first (unless explicitly superpowers:)
|
||||||
|
if (!forceSuperpowers && personalDir) {
|
||||||
|
const personalPath = path.join(personalDir, actualSkillName);
|
||||||
|
const personalSkillFile = path.join(personalPath, 'SKILL.md');
|
||||||
|
if (fs.existsSync(personalSkillFile)) {
|
||||||
|
return {
|
||||||
|
skillFile: personalSkillFile,
|
||||||
|
sourceType: 'personal',
|
||||||
|
skillPath: actualSkillName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try superpowers skills
|
||||||
|
if (superpowersDir) {
|
||||||
|
const superpowersPath = path.join(superpowersDir, actualSkillName);
|
||||||
|
const superpowersSkillFile = path.join(superpowersPath, 'SKILL.md');
|
||||||
|
if (fs.existsSync(superpowersSkillFile)) {
|
||||||
|
return {
|
||||||
|
skillFile: superpowersSkillFile,
|
||||||
|
sourceType: 'superpowers',
|
||||||
|
skillPath: actualSkillName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a git repository has updates available.
|
||||||
|
*
|
||||||
|
* @param {string} repoDir - Path to git repository
|
||||||
|
* @returns {boolean} - True if updates are available
|
||||||
|
*/
|
||||||
|
function checkForUpdates(repoDir) {
|
||||||
|
try {
|
||||||
|
// Quick check with 3 second timeout to avoid delays if network is down
|
||||||
|
const output = execSync('git fetch origin && git status --porcelain=v1 --branch', {
|
||||||
|
cwd: repoDir,
|
||||||
|
timeout: 3000,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: 'pipe'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse git status output to see if we're behind
|
||||||
|
const statusLines = output.split('\n');
|
||||||
|
for (const line of statusLines) {
|
||||||
|
if (line.startsWith('## ') && line.includes('[behind ')) {
|
||||||
|
return true; // We're behind remote
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false; // Up to date
|
||||||
|
} catch (error) {
|
||||||
|
// Network down, git error, timeout, etc. - don't block bootstrap
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip YAML frontmatter from skill content, returning just the content.
|
||||||
|
*
|
||||||
|
* @param {string} content - Full content including frontmatter
|
||||||
|
* @returns {string} - Content without frontmatter
|
||||||
|
*/
|
||||||
|
function stripFrontmatter(content) {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
let inFrontmatter = false;
|
||||||
|
let frontmatterEnded = false;
|
||||||
|
const contentLines = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trim() === '---') {
|
||||||
|
if (inFrontmatter) {
|
||||||
|
frontmatterEnded = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
inFrontmatter = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frontmatterEnded || !inFrontmatter) {
|
||||||
|
contentLines.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return contentLines.join('\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
extractFrontmatter,
|
||||||
|
findSkillsInDir,
|
||||||
|
resolveSkillPath,
|
||||||
|
checkForUpdates,
|
||||||
|
stripFrontmatter
|
||||||
|
};
|
||||||
35
.claude/settings.json
Normal file
35
.claude/settings.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash",
|
||||||
|
"Read",
|
||||||
|
"Edit",
|
||||||
|
"Write",
|
||||||
|
"WebFetch",
|
||||||
|
"WebSearch",
|
||||||
|
"mcp__searxng__web_search",
|
||||||
|
"mcp__oracle-26ai-vector__search_similar"
|
||||||
|
],
|
||||||
|
"deny": [
|
||||||
|
"Bash(rm -rf /)",
|
||||||
|
"Bash(rm -rf /*)",
|
||||||
|
"Bash(rm -rf ~)",
|
||||||
|
"Bash(rm -rf ~/*)",
|
||||||
|
"Bash(sudo rm -rf *)",
|
||||||
|
"Edit(//Users/joungmin/.claude/settings.json)",
|
||||||
|
"Write(//Users/joungmin/.claude/settings.json)",
|
||||||
|
"Edit(//Users/joungmin/.claude/settings.local.json)",
|
||||||
|
"Write(//Users/joungmin/.claude/settings.local.json)",
|
||||||
|
"Edit(//Users/joungmin/.claude/agents/**)",
|
||||||
|
"Write(//Users/joungmin/.claude/agents/**)",
|
||||||
|
"Edit(//Users/joungmin/.claude/commands/**)",
|
||||||
|
"Write(//Users/joungmin/.claude/commands/**)",
|
||||||
|
"Edit(//Users/joungmin/.claude/CLAUDE.md)",
|
||||||
|
"Write(//Users/joungmin/.claude/CLAUDE.md)",
|
||||||
|
"Edit(//Users/joungmin/.gitconfig)",
|
||||||
|
"Write(//Users/joungmin/.gitconfig)",
|
||||||
|
"Edit(.git/**)",
|
||||||
|
"Write(.git/**)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
89
.claude/workflows/persona-pipeline.js
Normal file
89
.claude/workflows/persona-pipeline.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
export const meta = {
|
||||||
|
name: 'persona-pipeline',
|
||||||
|
description: 'tasteby: Redmine 큐의 열린 이슈를 8개 AI 페르소나 단계로 자동 통과시킨다 (Planner→...→Documenter, QA/Reviewer 반려 루프 포함)',
|
||||||
|
phases: [
|
||||||
|
{ title: 'Scan', detail: 'Redmine tasteby 의 열린 이슈와 현재 단계 수집' },
|
||||||
|
{ title: 'Pipeline', detail: '각 이슈를 현재 단계 페르소나부터 Done 까지 구동' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 단계 정의 ──────────────────────────────────────────────
|
||||||
|
const ORDER = ['01-Planner','02-Architect','03-Developer','04-QA','05-Designer','06-Reviewer','07-Release','08-Documenter']
|
||||||
|
const PERSONA = {
|
||||||
|
'01-Planner':'planner', '02-Architect':'architect', '03-Developer':'developer', '04-QA':'qa',
|
||||||
|
'05-Designer':'designer', '06-Reviewer':'reviewer', '07-Release':'release', '08-Documenter':'documenter',
|
||||||
|
}
|
||||||
|
const DONE = '09-Done'
|
||||||
|
const nextOf = (s) => { const i = ORDER.indexOf(s); return i < 0 ? '01-Planner' : (i+1 < ORDER.length ? ORDER[i+1] : DONE) }
|
||||||
|
|
||||||
|
const SCAN_SCHEMA = {
|
||||||
|
type: 'object', additionalProperties: false,
|
||||||
|
required: ['issues'],
|
||||||
|
properties: { issues: { type: 'array', items: {
|
||||||
|
type: 'object', additionalProperties: false, required: ['id','subject','currentStage'],
|
||||||
|
properties: { id: {type:'integer'}, subject: {type:'string'}, currentStage: {type:'string'} },
|
||||||
|
} } },
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGE_SCHEMA = {
|
||||||
|
type: 'object', additionalProperties: false,
|
||||||
|
required: ['issueId','persona','outcome','nextStage','summary'],
|
||||||
|
properties: {
|
||||||
|
issueId: { type: 'integer' },
|
||||||
|
persona: { type: 'string' },
|
||||||
|
outcome: { type: 'string', enum: ['advanced','rejected','done','blocked'] },
|
||||||
|
nextStage: { type: 'string', description: '다음 Redmine 카테고리명 (예: 04-QA, 03-Developer, 09-Done)' },
|
||||||
|
summary: { type: 'string' },
|
||||||
|
commitSha: { type: 'string' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. 큐 스캔 ─────────────────────────────────────────────
|
||||||
|
phase('Scan')
|
||||||
|
const scan = await agent(
|
||||||
|
`너는 tasteby 파이프라인 디스패처다. \`.env\` 를 로드(set -a; . ./.env; set +a)해서 ` +
|
||||||
|
`REDMINE_URL/REDMINE_API_KEY 를 얻은 뒤, 프로젝트 tasteby 에서 **열린(open)** 이슈를 모두 조회한다:\n` +
|
||||||
|
` curl -s -H "X-Redmine-API-Key: $REDMINE_API_KEY" "$REDMINE_URL/issues.json?project_id=tasteby&status_id=open&limit=100"\n` +
|
||||||
|
`각 이슈의 현재 단계 = 카테고리 이름(category.name). 카테고리가 없으면 "01-Planner" 로 본다.\n` +
|
||||||
|
`09-Done 이거나 닫힌 이슈는 제외한다. id·subject·currentStage 목록을 반환하라.`,
|
||||||
|
{ schema: SCAN_SCHEMA, phase: 'Scan', label: 'scan-queue' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const queue = (scan && scan.issues) ? scan.issues : []
|
||||||
|
if (!queue.length) {
|
||||||
|
log('큐가 비어 있다. 처리할 열린 이슈가 없음. Redmine 에 작업 이슈를 추가한 뒤 다시 실행하라.')
|
||||||
|
return { processed: 0, message: 'empty queue' }
|
||||||
|
}
|
||||||
|
log(`큐에 ${queue.length}개 이슈: ` + queue.map(i => `#${i.id}(${i.currentStage})`).join(', '))
|
||||||
|
|
||||||
|
// ── 2. 각 이슈를 단계별로 구동 (이슈끼리는 병렬) ─────────────
|
||||||
|
phase('Pipeline')
|
||||||
|
const MAX_STEPS = 24 // 반려 핑퐁 등 무한루프 방지
|
||||||
|
|
||||||
|
const results = await parallel(queue.map((issue) => async () => {
|
||||||
|
let stage = issue.currentStage || '01-Planner'
|
||||||
|
if (!ORDER.includes(stage)) stage = '01-Planner'
|
||||||
|
const trail = []
|
||||||
|
for (let step = 0; step < MAX_STEPS && stage !== DONE; step++) {
|
||||||
|
const persona = PERSONA[stage]
|
||||||
|
if (!persona) { log(`#${issue.id}: 알 수 없는 단계 ${stage} — 중단`); break }
|
||||||
|
const res = await agent(
|
||||||
|
`Redmine 이슈 #${issue.id} ("${issue.subject}") 를 처리하라. 현재 단계: ${stage}.\n` +
|
||||||
|
`너의 역할 정의와 docs/pipeline/QUEUE-PROTOCOL.md 를 따라 작업하고, ` +
|
||||||
|
`결과(파일 변경 git 커밋/push, Redmine 저널 노트, 다음 단계로 카테고리·상태 전진/반려)를 모두 수행하라.\n` +
|
||||||
|
`완료 후 구조화 결과를 반환하라. nextStage 는 네가 실제로 Redmine 에 설정한 카테고리명이어야 한다.`,
|
||||||
|
{ agentType: persona, schema: STAGE_SCHEMA, phase: 'Pipeline', label: `${persona}#${issue.id}` }
|
||||||
|
)
|
||||||
|
if (!res) { log(`#${issue.id}: ${persona} 단계 결과 없음 — 중단`); break }
|
||||||
|
trail.push(`${persona}:${res.outcome}`)
|
||||||
|
log(`#${issue.id} ${persona} → ${res.outcome} (${res.summary || ''})`.slice(0, 200))
|
||||||
|
if (res.outcome === 'blocked') { log(`#${issue.id}: ${stage} 에서 블록됨 — ${res.summary}`); break }
|
||||||
|
const reported = (res.nextStage || '').trim()
|
||||||
|
stage = (ORDER.includes(reported) || reported === DONE) ? reported : nextOf(stage)
|
||||||
|
}
|
||||||
|
return { id: issue.id, finalStage: stage, done: stage === DONE, trail }
|
||||||
|
}))
|
||||||
|
|
||||||
|
const summary = results.filter(Boolean)
|
||||||
|
log('완료: ' + summary.map(r => `#${r.id}=${r.done ? 'DONE' : r.finalStage}`).join(', '))
|
||||||
|
return { processed: summary.length, results: summary }
|
||||||
8
.env.example
Normal file
8
.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
REDMINE_URL=
|
||||||
|
REDMINE_API_KEY=
|
||||||
|
REDMINE_PROJECT=tasteby
|
||||||
|
GITEA_URL=
|
||||||
|
GITEA_USER=
|
||||||
|
GITEA_EMAIL=
|
||||||
|
GITEA_PASSWORD=
|
||||||
|
GITEA_REPO=tasteby
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -2,6 +2,7 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
.venv/
|
.venv/
|
||||||
.env
|
.env
|
||||||
|
.ch-backup/
|
||||||
node_modules/
|
node_modules/
|
||||||
.next/
|
.next/
|
||||||
.env.local
|
.env.local
|
||||||
@@ -13,3 +14,9 @@ backend-java/.gradle/
|
|||||||
|
|
||||||
# K8s secrets (never commit)
|
# K8s secrets (never commit)
|
||||||
k8s/secrets.yaml
|
k8s/secrets.yaml
|
||||||
|
|
||||||
|
# OS / misc
|
||||||
|
.DS_Store
|
||||||
|
backend/cookies.txt
|
||||||
|
backend-java/cookies.txt
|
||||||
|
**/cookies.txt
|
||||||
|
|||||||
322
CHANGELOG.md
Normal file
322
CHANGELOG.md
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
# Tasteby 작업 기록
|
||||||
|
|
||||||
|
> 작업 내용, 이슈, 해결 방법을 기록하는 문서. 커밋/배포 시 참고용.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-06-15
|
||||||
|
|
||||||
|
### 🔤 #348 isNameSimilar 한국어 자모 + Sørensen-Dice (v0.1.38)
|
||||||
|
- HangulSimilarity 유틸 신규 (Unicode NFD 분해 + bigram Sørensen-Dice)
|
||||||
|
- RestaurantController.isNameSimilar 교체, 임계값 0.45
|
||||||
|
- 짧은 한국어 이름 매칭 정확도 향상 (예: "스타벅스 강남" vs "스타벅스 강남점")
|
||||||
|
- 후속 분리: #357(DDG→정식 API), #358(DTO+@Valid), #359(UNIQUE+데이터 정리)
|
||||||
|
- 설계서: docs/design/348-name-similarity/README.md
|
||||||
|
- Refs: #348 (close)
|
||||||
|
|
||||||
|
### 🌐 #352 i18n 뼈대 ko/en/ja/es (v0.1.37)
|
||||||
|
- next-intl 5.x 도입
|
||||||
|
- src/i18n/{config,LocaleProvider} + src/messages/{ko,en,ja,es}.json (30 키)
|
||||||
|
- LanguageSwitcher 컴포넌트 (헤더, ARIA listbox, 44px, 국기+네이티브명)
|
||||||
|
- localStorage tasteby_locale + 브라우저 언어 감지 + ko fallback
|
||||||
|
- 설계서: docs/design/352-i18n-skeleton/README.md
|
||||||
|
- 미적용: URL 라우팅 i18n, SEO meta, 사용자 콘텐츠 번역, 어드민(한국어 유지)
|
||||||
|
- Refs: #352 (close)
|
||||||
|
|
||||||
|
### 🧹 #329 admin/page.tsx 분리 (v0.1.35→v0.1.36 운영 반영)
|
||||||
|
- page.tsx 2817 → 107 LOC (탭 라우팅 + 헤더만)
|
||||||
|
- _panels/{Channels,Videos,Restaurants,Users,Daemon}Panel.tsx 5개 분리
|
||||||
|
- localStorage.getItem 10곳 → getAdminToken() (admin-utils.ts)
|
||||||
|
- SSE 통일은 후속 #351 분리
|
||||||
|
- 설계서: docs/design/329-admin-split/README.md
|
||||||
|
- Refs: #329 (close)
|
||||||
|
|
||||||
|
### ⚡ #331 VectorService batchUpdate (v0.1.34)
|
||||||
|
- saveRestaurantVectors: N+1 단건 INSERT → 단일 jdbc.batchUpdate(SqlParameterSource[])
|
||||||
|
- UUID 인라인 변환 제거 → IdGenerator.newId() 공통화
|
||||||
|
- 현재 N=1이지만 chunk 분할 도입 시 효과 본격화
|
||||||
|
- 설계서: docs/design/331-vector-batch-insert/README.md
|
||||||
|
- Refs: #331 (close)
|
||||||
|
|
||||||
|
### ⚡ #326 parseJson 단일 패스 (v0.1.33)
|
||||||
|
- OciGenAiService.parseJson 잘린 배열 복구를 brace depth counter 단일 패스로 교체
|
||||||
|
- 이전 O(N²) + Jackson 예외 양산 → O(N) + 명시적 에러 경로
|
||||||
|
- 문자열/escape 처리 정확
|
||||||
|
- 설계서: docs/design/326-parsejson-optimization/README.md
|
||||||
|
- Refs: #326 (close)
|
||||||
|
|
||||||
|
### 🛡️ #332 Restaurant PUT 화이트리스트 명시 (v0.1.32)
|
||||||
|
- ALLOWED_UPDATE_FIELDS set으로 PUT /api/restaurants/{id} body 필터
|
||||||
|
- 허용 외 키 silent drop + DEBUG 로그
|
||||||
|
- sanitized.isEmpty()면 200 + no-op
|
||||||
|
- 후속 분리: #348 (DDG → 정식 API, isNameSimilar 한국어, DTO 표준화)
|
||||||
|
- Refs: #332 (close)
|
||||||
|
|
||||||
|
### 🛡️ #337 통계 봇 필터 + 레이트리밋 (v0.1.31)
|
||||||
|
- BotDetector: UA 정규식 (bot|crawler|spider|slurp|scrap|fetch|monitor|preview|lighthouse)
|
||||||
|
- RateLimitService: Redis SET NX EX(60s) 패턴, fail-open (의존성 최소화)
|
||||||
|
- StatsController.recordVisit: X-Forwarded-For 우선 IP + 봇/IP 가드
|
||||||
|
- 응답: {ok, counted:bool} — 차단도 200 (사용자 페이지 지장 X)
|
||||||
|
- application.yml: app.rate-limit.visit-window-seconds (기본 60)
|
||||||
|
- 운영 검증: Googlebot/Mozilla/즉시 재호출 인수조건 모두 충족
|
||||||
|
- 설계서: docs/design/337-stats-bot-ratelimit/README.md
|
||||||
|
- Refs: #337 (close)
|
||||||
|
|
||||||
|
### 🔒 #335 데몬 분산 락 ShedLock+Redis (v0.1.30)
|
||||||
|
- shedlock-spring 5.16.0 + shedlock-provider-redis-spring
|
||||||
|
- @EnableSchedulerLock(defaultLockAtMostFor=PT15M)
|
||||||
|
- DaemonScheduler.run: @SchedulerLock(name="daemon-runner", lockAtMostFor=PT15M, lockAtLeastFor=PT30S)
|
||||||
|
- ShedLockConfig: RedisLockProvider Bean (in-cluster Redis 재사용)
|
||||||
|
- 멀티 파드(RollingUpdate) + dev/prod ATP 공유 환경에서 데몬 중복 실행 차단
|
||||||
|
- 설계서: docs/design/335-daemon-distributed-lock/README.md
|
||||||
|
- Refs: #335 (close)
|
||||||
|
|
||||||
|
### 💾 #336 캐시 SCAN/UNLINK + 자동 복구 + 에러 메트릭 (v0.1.29)
|
||||||
|
- CacheService.flush: redis.keys() 블로킹 → SCAN cursor + UNLINK 논블로킹 (500 batch)
|
||||||
|
- @Scheduled(30s) checkHealth: Redis ping → disabled 자동 토글 (재기동 시 자동 복구)
|
||||||
|
- AtomicLong errorCount + volatile lastError + 로그 throttle (n==1 또는 n%100==0)
|
||||||
|
- GET /api/admin/cache/stats: disabled/errorCount/lastError 노출 (admin only)
|
||||||
|
- 설계서: docs/design/336-cache-scan-recovery/README.md
|
||||||
|
- Refs: #336 (close)
|
||||||
|
|
||||||
|
### 🔧 P5-2 작은 후속 (v0.1.26)
|
||||||
|
- #338: /api/version 신규 (HealthController + permitAll), application.yml app.build.{version,commit} env 주입 준비
|
||||||
|
- #320: findRegionFromCoords 거리 보정 (유클리드 → cos(lat) 가중치)
|
||||||
|
- #340: MapView 클러스터/마커/범례에 role/aria-label
|
||||||
|
- #333: ChannelController cache.flush() → cache.del("channels") (다른 모듈 캐시 보존)
|
||||||
|
- Refs: #338 #320 #340 #333 (close)
|
||||||
|
|
||||||
|
### 🧹 P5-1 작은 후속 묶음 (v0.1.24)
|
||||||
|
- #325: ThreadLocalRandom 통일, rebuildVectors not_implemented 이벤트, getTranscript JavaDoc 명세
|
||||||
|
- #319: buildSearchQuery 헬퍼 + fn-doc(BottomSheet snap 정책)
|
||||||
|
- #344: --z-bottom-sheet/--z-filter-sheet/--z-modal CSS 변수 + LoginMenu zIndex 99999 → var(--z-modal)
|
||||||
|
- Refs: #319 #325 #344 (close)
|
||||||
|
|
||||||
|
### ⭐ P4-4 별점 공통화 + 로그인 모달 접근성 (v0.1.23)
|
||||||
|
- #281: 공통 Stars 컴포넌트 (0.5단위 절반 채우기), StarSelector role=radiogroup + 44px + 반쪽 별 ⯨, try/catch + alert
|
||||||
|
- #283: LoginMenu에 useEscapeKey/useFocusTrap/useBodyScrollLock 훅 적용, role=dialog/aria-modal/aria-labelledby, onError 인라인 alert
|
||||||
|
- MyReviewsList: Math.round → Stars (0.5단위 정확 렌더)
|
||||||
|
- 후속 분리: #343(next/image, ARIA Tabs, 테스트), #344(z-index 토큰, i18n)
|
||||||
|
- Refs: #281 #283 (close)
|
||||||
|
|
||||||
|
### 🔐 P4-3 인증 메시지 + 지도 접근성 (v0.1.22)
|
||||||
|
- #266: Google verifier 실패 메시지 고정 + log.warn (정보 누출 차단)
|
||||||
|
- #278: boundsTimerRef cleanup, '내 위치' 44px + aria-label, dead code 제거
|
||||||
|
- #277: 결함 모두 후속(#338) — deep health/version/테스트는 별도
|
||||||
|
- 후속 분리: #338(deep health), #339(브랜드 토큰화/마커 ARIA), #340(다중 audience)
|
||||||
|
- Refs: #266 #277 #278 (close)
|
||||||
|
|
||||||
|
### ⚙️ P4-2 데몬/캐시/통계 결함 (v0.1.21)
|
||||||
|
- #275: updateConfig 가드(1+ 정수), Scheduler try-finally updateLastX, GET config admin-only
|
||||||
|
- #276: ping try-with-resources + ConnectionFactory null 가드, makeKey null 가드
|
||||||
|
- #274: SiteVisitStats int → long, recordVisit DataIntegrityViolationException 1회 재시도
|
||||||
|
- 후속 분리: #335 (분산락), #336 (SCAN/자동복구), #337 (봇/레이트리밋)
|
||||||
|
- Refs: #275 #276 #274 (close)
|
||||||
|
|
||||||
|
### 🧱 P4-1 백엔드 CRUD 결함 (v0.1.20)
|
||||||
|
- #294: MemoService/ReviewService 동시성 DuplicateKeyException 가드, rating 0~5 검증, getAvgRating NVL
|
||||||
|
- #295: 유니크 충돌 typed exception, channel_id "UC..." 형식 명시 분기, findByChannelId 컬럼 보완, body null 가드
|
||||||
|
- #290: @PreDestroy executor shutdown, 캐시 silent → log.warn + cache.del, tabling/catchtable URL 스킴 화이트리스트
|
||||||
|
- 후속 분리: #332(#290), #333(#295), #334(#294) — DTO/DDG/세분화/테스트
|
||||||
|
- Refs: #290 #294 #295 (close)
|
||||||
|
|
||||||
|
### 🔍 #293 검색/벡터 결함 7건 (v0.1.19)
|
||||||
|
- SearchController: q 빈값 400 가드 (`%%` 응답 폭발 차단)
|
||||||
|
- SearchService: LIKE 와일드카드 escape (%, _, \), hybrid 모드에서 sem 결과에도 채널 부착
|
||||||
|
- SearchService: ObjectMapper/TypeReference static 재사용, 알 수 없는 mode warn 로그
|
||||||
|
- SearchService: maxDistance를 @Value("${app.search.max-distance:0.57}") 외부화 (env SEARCH_MAX_DISTANCE)
|
||||||
|
- SearchMapper.xml: LIKE 절에 ESCAPE '\' 추가
|
||||||
|
- VectorService: embeddings null/empty 가드 (NPE 차단)
|
||||||
|
- 후속 분리: #331 (batch insert + 테스트)
|
||||||
|
- Refs: #293 (close)
|
||||||
|
|
||||||
|
### 🛠 #304+#323 어드민 LLM 검증 UI + 공통 유틸 (v0.1.18)
|
||||||
|
- 신규 frontend/src/lib/admin-utils.ts:
|
||||||
|
- getAdminToken / authHeaders / consumeSseStream
|
||||||
|
- api.ts: Restaurant 타입에 hidden/hidden_reason/verified_at + verify/setRestaurantHidden API 4개
|
||||||
|
- RestaurantsPanel:
|
||||||
|
- 헤더: "미검증 N건 + LLM 검증" 버튼
|
||||||
|
- 테이블: 검증 컬럼 (숨김/OK/미검증 배지 + 클릭으로 토글)
|
||||||
|
- colSpan 7로 수정
|
||||||
|
- 후속 분리: #329 (admin 전체 파일 분리 + localStorage/SSE 11+곳 통일)
|
||||||
|
- Refs: #304 #323 #322 (close)
|
||||||
|
|
||||||
|
### 🔧 #291+#292 백엔드 결함 일괄 수정 (v0.1.17)
|
||||||
|
- ExtractorService: transcript null/blank 가드 (NPE 방지)
|
||||||
|
- PipelineService.processExtract: 진입 시 status='processing' 명시 전이 (SSE/사용자 가시성)
|
||||||
|
- PipelineService: geocode 실패 시 좌표/place_id/주소 컬럼을 data에 put하지 않아 upsert COALESCE 보존 의도 명확화
|
||||||
|
- GeocodingService.parseRegionFromAddress: 빈 토큰을 region 문자열에서 제거 ('한국||구' 깨짐 방지)
|
||||||
|
- VideoService.saveVideosBatch: @Transactional 추가 → batch insert 원자성
|
||||||
|
- .gitignore: backend-java/cookies.txt 및 **/cookies.txt
|
||||||
|
- 후속 분리: #325 (#291 잔여 MINOR), #326 (parseJson 최적화 + #292 MINOR)
|
||||||
|
- Refs: #291 #292 (close)
|
||||||
|
|
||||||
|
### 🧹 #322 LLM 검증으로 잘못된/프랜차이즈 식당 자동 숨김 (v0.1.16)
|
||||||
|
- DB 마이그레이션: restaurants에 hidden(NUMBER(1)), hidden_reason(VARCHAR2(120)), verified_at(TIMESTAMP) + idx_restaurants_hidden
|
||||||
|
- 도메인/Mapper/Service 확장: includeHidden 옵션, updateVerification, findUnverified 등
|
||||||
|
- 신규 RestaurantVerifyService:
|
||||||
|
- verifyAsync (신규 등록 자동 검증)
|
||||||
|
- verifyAll (백필, 식당당 200ms sleep)
|
||||||
|
- parseVerifyResponse (안전 기본값: 파싱 실패 시 valid=true → hidden 유지)
|
||||||
|
- PipelineService.processExtract 끝에 verifyAsync(restId) 자동 호출
|
||||||
|
- AdminRestaurantController 신규 (requireAdmin):
|
||||||
|
- GET /api/admin/restaurants/verify/pending
|
||||||
|
- POST /api/admin/restaurants/verify/all?batchSize=10
|
||||||
|
- POST /api/admin/restaurants/{id}/verify
|
||||||
|
- PATCH /api/admin/restaurants/{id}/hidden
|
||||||
|
- 어드민 UI는 후속 #323으로 분리
|
||||||
|
- Refs: #322 (close)
|
||||||
|
|
||||||
|
### 📺 #291 publishedAfter 페이징 조기 종료 버그 (v0.1.15) + dev/prod 데몬 분리
|
||||||
|
- YouTubeService.fetchChannelVideos: stopPaging 플래그로 조기 종료 정확화 → 백필 효율 + YouTube API quota 절약
|
||||||
|
- DaemonScheduler에 app.daemon.enabled (env DAEMON_ENABLED) 플래그
|
||||||
|
- dev/prod가 같은 Oracle ATP를 공유하는 환경에서 dev DAEMON_ENABLED=false로 중복 폴링 차단
|
||||||
|
- Refs: #291 #275 #321
|
||||||
|
|
||||||
|
### ♿ #301+#302 모달 접근성 + race condition + 필터 상태 동기화 (v0.1.14)
|
||||||
|
- 공통 훅 `frontend/src/lib/hooks/useModalA11y.ts` 신규 (useEscapeKey, useFocusTrap, useBodyScrollLock)
|
||||||
|
- BottomSheet/FilterSheet: role='dialog', aria-modal, aria-label/labelledby, ESC 닫기, focus trap
|
||||||
|
- RestaurantDetail: useEffect cancelled 플래그로 restaurant.id 변경 시 race condition 차단
|
||||||
|
- page.tsx: `exitSearchMode` 헬퍼 → 검색결과 모드에서 필터 변경 시 자동 검색 모드 해제 + 원본 재로드
|
||||||
|
- 후속 분리: #319 (BottomSheet 매직넘버/UX), #320 (필터 정밀도/접근성/테스트)
|
||||||
|
- Refs: #301 #302 (close)
|
||||||
|
|
||||||
|
### 🔧 #316 — backend resource request 재산정 + RollingUpdate 정책 복귀
|
||||||
|
- **변경 전**: cpu 500m/1, mem 768Mi/1536Mi, strategy maxSurge=0/maxUnavailable=1 (임시 패치)
|
||||||
|
- **변경 후**: cpu 300m/800m, mem 512Mi/1024Mi, strategy 25%/25% (기본 복귀)
|
||||||
|
- **근거**: 실측 idle 0.7% CPU, RSS ~305 MB. peak 30-40% 추정 안에서 안전.
|
||||||
|
- **검증**: rollout 후 노드 잔여 330m → 다음 배포 시 두 Pod 공존 가능, 무중단 RollingUpdate 회복.
|
||||||
|
- **다운타임**: 이번 1회 ~25초 (구 Pod 500m 점유 해제 위해 강제 종료). 다음 배포부터 0초.
|
||||||
|
- **설계서**: `docs/design/316-backend-resource-rightsize/README.md` (Approved).
|
||||||
|
- Refs: #316 (close)
|
||||||
|
|
||||||
|
### 🏗 OKE 인프라 — 노드 다운사이징 + LB 정리
|
||||||
|
- **Orphan Classic LB 삭제**: 132.226.175.247 (100Mbps shape, OKEclusterName 태그만 남고 DNS/Service 참조 없음) → 비용 절감
|
||||||
|
- **노드풀 교체 (블루-그린)**: `pool1` (2 노드 × 2 OCPU / 8 GB) → `pool2` (2 노드 × 1 OCPU / 6 GB)
|
||||||
|
- 사유: ARM64 Always Free 쿼터 변경 (4 OCPU/24 GB → 2 OCPU/12 GB)
|
||||||
|
- 절차: 새 노드풀 생성 → 기존 노드 cordon + drain → 기존 노드풀 삭제 → 무중단 확인
|
||||||
|
- **backend Deployment strategy 임시 패치**: `maxSurge: 25% → 0`, `maxUnavailable: 25% → 1`
|
||||||
|
- 노드당 1 OCPU 환경에서 backend(500m 요청) 두 Pod 공존 불가 → rollingUpdate 데드락 회피
|
||||||
|
- **⚠️ 다음 배포 시 ~30초 다운타임** 발생. 후속 이슈에서 resource request 재산정 권고.
|
||||||
|
|
||||||
|
### 🚀 운영 배포 v0.1.13
|
||||||
|
- 보안 핫픽스 #267 배포 (백엔드만)
|
||||||
|
- OCIR push + kubectl rolling update + git tag v0.1.13 완료
|
||||||
|
- 검증: `Anonymous /api/admin/users → 403`, `Bad-token → 403`, `정상 동작 영향 없음`
|
||||||
|
|
||||||
|
### 🔴 보안 핫픽스 #267 — AdminUserController GET 4종 권한 우회
|
||||||
|
- `listUsers`, `userFavorites`, `userReviews`, `userMemos`가 인증만 요구하고 admin 검사를 하지 않아 일반 사용자 토큰으로 전체 사용자 목록 및 타인 활동 조회 가능했음
|
||||||
|
- 4개 메서드 첫 줄에 `AuthUtil.requireAdmin()` 추가 → non-admin 호출 시 403
|
||||||
|
- 설계서 §3 인수조건에 `/api/admin/users/**` 권한 강제 항목 추가
|
||||||
|
- Refs: #267 (현행화 Reviewer 반려 → Developer 수정 → 다시 통과)
|
||||||
|
|
||||||
|
### ch-bootstrap 적용 (페르소나 파이프라인 + Design-First)
|
||||||
|
- Redmine 8단계 페르소나 큐(`01-Planner` ~ `09-Done`) + 9개 카테고리 자동 생성
|
||||||
|
- Design-First 게이트(설계서 없으면 코드 작성 금지) 도입
|
||||||
|
- `.claude/agents/` 8개 페르소나 + `.claude/workflows/persona-pipeline.js`
|
||||||
|
- 안전-최대 권한 정책(`.claude/settings.json`)
|
||||||
|
- `docs/{design,adr,pipeline}/` 골격 + `scripts/enqueue.sh`
|
||||||
|
- 기존 Tasteby 고유 규칙(존댓말, CHANGELOG, 디자인 패턴, CORS, PM2)은 `CLAUDE.md` 0/7/8장으로 보존
|
||||||
|
- Redmine 프로젝트 description + Wiki 4페이지(Overview/Dev-Env/Prod-Env/Deploy) 작성
|
||||||
|
|
||||||
|
### tasteby 기존 18개 기능 Design-First 현행화
|
||||||
|
- 백엔드 12개(auth/user/restaurant/video/extract-pipeline/search/review-memo/channel/stats/daemon/cache/health) + 프론트 6개(map/restaurant-detail/filter/review-memo/admin/login)
|
||||||
|
- 각 기능별 `docs/design/<issue>-<slug>/README.md` 12개 섹션 채움 (총 3,830줄)
|
||||||
|
- 추적성: 각 설계서가 구현 파일/Redmine 이슈/커밋 SHA와 연결됨
|
||||||
|
- **Reviewer 결과**: 17 PASS w/notes, 1 REJECT (#267 admin 권한 critical)
|
||||||
|
- 후속 17개 개선 이슈(#289~#305) 자동 등록 — 결함 총 124건(critical 3 / major 46 / minor 75) 백로그 반영
|
||||||
|
- 코드 변경 없음 — 문서화 + 백로그화 전용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-04-04
|
||||||
|
|
||||||
|
### 코드 리뷰 스크립트 추가 + 리뷰 지적사항 반영
|
||||||
|
- `scripts/code_review.py`: 페르소나 기반 코드 리뷰 스크립트 (OpenRouter API, 프론트/백엔드/보안/아키텍처 4관점)
|
||||||
|
- `UserService.updateAdmin()`: 존재하지 않는 userId에 대해 404 응답 추가
|
||||||
|
- `AdminUserController.updateAdmin()`: 자기 자신 admin 권한 변경 차단 + 감사 로그 추가 + 응답에 변경 결과 포함
|
||||||
|
- `JsonUtil.normalizeEvaluation()`: evaluation 정규화 로직을 공통 유틸로 통합 (RestaurantService, VideoService 중복 제거)
|
||||||
|
- `RestaurantService.linkVideoRestaurant()`: evaluation 저장 시 평문→JSON 정규화 + 300자 제한
|
||||||
|
|
||||||
|
### 가격대 필터 5단계 세분화
|
||||||
|
- 기존 3단계(저렴/보통/고가) → 5단계(저렴/가성비/보통/프리미엄/럭셔리)
|
||||||
|
- `PRICE_GROUPS` 상수 수정, 정규식 패턴 세분화
|
||||||
|
|
||||||
|
### 모바일 터치 영역 개선 (44×44px 통일)
|
||||||
|
- **별점 선택기**: 0.5단위 10개 숫자 버튼(24px) → 별 아이콘 5개(44px), 탭으로 정수/반점수 전환
|
||||||
|
- **FilterSheet 닫기 버튼**: `p-1` → `p-2` (터치 영역 확대)
|
||||||
|
- **RestaurantDetail 찜 버튼**: 패딩 추가 + `touch-manipulation` 적용
|
||||||
|
- **필터 초기화 X 버튼**: 아이콘 12px → 14px + 패딩 추가
|
||||||
|
|
||||||
|
### 채널 필터 시 식당이 3개만 나오는 버그 수정
|
||||||
|
- **원인**: 전체 식당 500개만 가져와서 클라이언트 필터링 → 특정 채널 식당이 상위 500개에 일부만 포함
|
||||||
|
- **수정**: `page.tsx`에서 채널 필터 변경 시 서버에 `channel` 파라미터를 보내 서버 사이드 필터링 적용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-03-29
|
||||||
|
|
||||||
|
### 식당 평가(evaluation) 표시 안 되는 버그 수정
|
||||||
|
- **원인**: LLM이 추출한 evaluation이 대부분 평문 문자열로 DB에 저장되어 있었으나, 프론트에서 `evaluation.text`로 접근하여 표시되지 않음
|
||||||
|
- **수정**:
|
||||||
|
- `JsonUtil.parseMap()`: JSON 파싱 실패 시 `{"text":"원본문자열"}`로 감싸서 반환
|
||||||
|
- `VideoService.findDetail()`: `VideoRestaurantLink`의 evaluation 평문을 JSON 객체로 정규화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-03-16
|
||||||
|
|
||||||
|
### Admin 유저 관리 — 관리자 권한 토글 기능 추가
|
||||||
|
- **Backend**
|
||||||
|
- `UserMapper.xml`: `findAllWithCounts`에 `is_admin` 컬럼 추가, `updateAdmin` 쿼리 추가
|
||||||
|
- `UserMapper.java`: `updateAdmin()` 메서드 추가
|
||||||
|
- `UserService.java`: `updateAdmin()` 메서드 추가
|
||||||
|
- `AdminUserController.java`: `PATCH /api/admin/users/{userId}/admin` 엔드포인트 추가
|
||||||
|
- **Frontend**
|
||||||
|
- `api.ts`: `updateAdminUserAdmin()` API 함수 추가, 유저 타입에 `is_admin` 필드 추가
|
||||||
|
- `admin/page.tsx`: 유저 테이블에 "관리자" 컬럼 + ON/OFF 토글 버튼 추가
|
||||||
|
|
||||||
|
### CORS PATCH 메서드 허용
|
||||||
|
- **문제**: PATCH 요청 시 CORS preflight(OPTIONS)에서 403 차단
|
||||||
|
- **원인**: `WebConfig.java`의 `allowedMethods`에 `PATCH`가 빠져 있었음
|
||||||
|
- **해결**: `List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")` → `"PATCH"` 추가
|
||||||
|
|
||||||
|
### Icon 시스템 개선
|
||||||
|
- Material Symbols `sake` 아이콘 종횡비 문제 수정 — `width`/`height`를 `fontSize`와 동일하게 고정 + `overflow: hidden`
|
||||||
|
- 이자카야 아이콘: `sake` → `local_bar` (술잔 모양으로 변경)
|
||||||
|
- 삼겹살/돼지구이, 족발/보쌈, 돈카츠: `PiggyBank` → `food:pig` (커스텀 돼지 SVG)
|
||||||
|
|
||||||
|
### LLM 추출 프롬프트 수정
|
||||||
|
- `ExtractorService.java`: `evaluation` 필드 → "평가 내용을 100자 이내로 요약"으로 변경
|
||||||
|
|
||||||
|
### 브랜드 가이드 문서 생성
|
||||||
|
- `frontend/docs/brand-guide.md`: 브랜드 아이덴티티, 컬러, 타이포, 아이콘 정책 등 정리
|
||||||
|
|
||||||
|
### PM2 프론트엔드 포트 고정
|
||||||
|
- **문제**: `pm2 restart` 후 Next.js가 3000(Gitea 포트)으로 fallback → nginx 502
|
||||||
|
- **해결**: PM2에 `PORT=3001` 환경변수 고정하여 재등록 + `pm2 save`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-03-14
|
||||||
|
|
||||||
|
### 홈 탭 장르 카드 픽토그램 적용
|
||||||
|
- Phosphor Icons (`@phosphor-icons/react`) + 커스텀 SVG FoodIcon 시스템 구축
|
||||||
|
- `cuisine-icons.ts`에 `getPhosphorCuisineIcon()` 함수 추가 (46개 소분류 매핑)
|
||||||
|
- `FoodIcon.tsx` 생성 — jjigae, tteok, noodle, tempura, pig 커스텀 SVG 아이콘
|
||||||
|
- `food:` 접두어로 Phosphor vs 커스텀 SVG 분기 처리
|
||||||
|
|
||||||
|
### 지역 필터 추가 + 배포
|
||||||
|
- 홈 탭에 지역 필터 드롭다운 추가
|
||||||
|
- v0.1.11로 OKE 배포 완료
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참고: 주의사항
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 새 HTTP 메서드 추가 시 | `WebConfig.java`의 CORS `allowedMethods`에 반드시 추가 |
|
||||||
|
| 백엔드 코드 수정 후 | `bootJar` 빌드 성공 확인 → `pm2 restart tasteby-api` |
|
||||||
|
| 프론트엔드 dev 포트 | 3001 고정 (3000은 Gitea) |
|
||||||
|
| tasteby-web 실행 방식 | `npm run dev` (standalone 아님) |
|
||||||
61
CLAUDE.md
Normal file
61
CLAUDE.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# tasteby — Engineering Standards & AI Persona Pipeline
|
||||||
|
|
||||||
|
이 파일은 모든 AI 페르소나가 따르는 **단일 진실 기준(SoT)** 이다.
|
||||||
|
|
||||||
|
## 0. 필수 참조 (Tasteby 고유)
|
||||||
|
- 모든 작업 시작 전에 `/skills` 슬래시 커맨드를 실행하거나, `mcp__oracle-26ai-vector__search_similar`로 관련 스킬을 검색하여 적용할 규칙을 확인할 것
|
||||||
|
- 작업 완료 후 새로운 스킬/지식이 생기면 벡터 스토어에 기록할 것
|
||||||
|
- 코드 변경 시 `CHANGELOG.md` 업데이트 필수
|
||||||
|
|
||||||
|
## 1. 잘 설계된 코드 원칙
|
||||||
|
- 작게·단일 책임(함수 ≤ ~40줄), I/O 와 순수 로직 분리(테스트 가능성).
|
||||||
|
- 설정·비밀은 `.env` 에서 주입(하드코딩 금지). 명시적 에러 처리(삼키지 말 것).
|
||||||
|
- 외부 입력은 경계에서 검증. 의도를 드러내는 네이밍. 주석은 '왜'만.
|
||||||
|
- 핵심 로직은 테스트 없이 머지 금지. 모든 변경은 Redmine 이슈 ↔ 설계서 ↔ git 커밋으로 연결.
|
||||||
|
- 디자인 패턴 적용 (메모리의 feedback_design_patterns.md 참조).
|
||||||
|
|
||||||
|
## 2. 설계서 우선 (Design-First — 하드 게이트) ⛔
|
||||||
|
> **설계서 없이는 코드 없음.** 함수가 설계서로 덮이기 전엔 구현하지 않는다.
|
||||||
|
- Architect 가 구현 전 `docs/design/<issue-id>-<slug>/README.md`(`_TEMPLATE.md`)에 모든 함수를 등재.
|
||||||
|
- 복잡 함수(분기/상태·외부 I/O·리스크 경로·비자명 알고리즘)는 `fn-<name>.md`(`_FN_TEMPLATE.md`).
|
||||||
|
- Developer 는 설계서 없으면 구현 거부 → `02-Architect` 로 반려.
|
||||||
|
- 코드가 설계와 달라지면 **설계서를 먼저** 고친다. 되돌리기 어려운 결정은 ADR(`docs/adr/`).
|
||||||
|
|
||||||
|
## 3. 문서 아키텍처
|
||||||
|
Diátaxis + ADR + 설계서. 지도: `docs/README.md`.
|
||||||
|
| 종류 | 위치 | 시점 |
|
||||||
|
|------|------|------|
|
||||||
|
| 설계서 | `docs/design/` | 구현 전 |
|
||||||
|
| ADR | `docs/adr/` | 결정 시 |
|
||||||
|
| 레퍼런스 | `docs/reference/` | 구현 후 |
|
||||||
|
| 가이드 | `docs/guides/` | 릴리스 시 |
|
||||||
|
|
||||||
|
## 4. Git 규율
|
||||||
|
- 모든 산출물 = git 커밋 + Gitea push("추적 안 된 변경" 금지).
|
||||||
|
- 커밋: `[<Persona>] #<issue-id> <요약>` ... `Refs #<issue-id>`.
|
||||||
|
- `.env` 등 비밀 커밋 금지(`.gitignore` 차단).
|
||||||
|
|
||||||
|
## 5. AI 페르소나 파이프라인 (완전 자동)
|
||||||
|
```
|
||||||
|
[01 Planner]→[02 Architect]→[03 Developer]→[04 QA]→[05 Designer]→[06 Reviewer]→[07 Release]→[08 Documenter]→[09 Done]
|
||||||
|
(설계서) (설계서 게이트) └──── 반려 ────┘ (QA/Reviewer/설계서누락 시)
|
||||||
|
```
|
||||||
|
- 작업 큐 = Redmine 이슈(project `tasteby`). 프로토콜: `docs/pipeline/QUEUE-PROTOCOL.md`.
|
||||||
|
- 현재 단계 = 이슈 **카테고리**(`01-Planner`…`09-Done`). 수명주기 = 이슈 **상태**.
|
||||||
|
- 페르소나: `.claude/agents/`. 오케스트레이터: `.claude/workflows/persona-pipeline.js`.
|
||||||
|
|
||||||
|
## 6. 게이트
|
||||||
|
- 설계서 게이트(02→03), QA 게이트(04), Reviewer 게이트(06). 우회 금지, 반려 사유는 저널 노트.
|
||||||
|
|
||||||
|
## 7. 응대 규칙
|
||||||
|
- 존댓말 사용 (반말 금지).
|
||||||
|
|
||||||
|
## 8. 개발 환경 (Tasteby 고유)
|
||||||
|
- 새 HTTP 메서드 추가 시 `WebConfig.java` CORS allowedMethods 확인.
|
||||||
|
- 백엔드 코드 수정 후 빌드 성공 확인 → PM2 재시작.
|
||||||
|
- dev 환경 PM2 설정 변경 금지 (tasteby-web: PORT=3001, tasteby-api: 8000).
|
||||||
|
|
||||||
|
## 9. 작업 환경
|
||||||
|
- Gitea: https://gittea.cloud-handson.com/joungmin/tasteby (branch `main`)
|
||||||
|
- Redmine: https://redmine.cloud-handson.com/projects/tasteby
|
||||||
|
- 자격증명은 `.env` 에서 로드.
|
||||||
@@ -28,6 +28,12 @@ dependencies {
|
|||||||
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
|
|
||||||
|
// #335 — 분산 락 (RollingUpdate 시 멀티 파드 공존 중 데몬 중복 실행 차단)
|
||||||
|
implementation 'net.javacrumbs.shedlock:shedlock-spring:5.16.0'
|
||||||
|
implementation 'net.javacrumbs.shedlock:shedlock-provider-redis-spring:5.16.0'
|
||||||
|
|
||||||
|
// #337 — IP 레이트리밋은 Redis SET NX EX 패턴으로 자체 구현 (기존 spring-data-redis 활용)
|
||||||
|
|
||||||
// Oracle JDBC + Security (Wallet support for Oracle ADB)
|
// Oracle JDBC + Security (Wallet support for Oracle ADB)
|
||||||
implementation 'com.oracle.database.jdbc:ojdbc11:23.7.0.25.01'
|
implementation 'com.oracle.database.jdbc:ojdbc11:23.7.0.25.01'
|
||||||
implementation 'com.oracle.database.security:oraclepki:23.7.0.25.01'
|
implementation 'com.oracle.database.security:oraclepki:23.7.0.25.01'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.tasteby;
|
package com.tasteby;
|
||||||
|
|
||||||
|
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.scheduling.annotation.EnableAsync;
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
@@ -8,6 +9,8 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
|||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableAsync
|
@EnableAsync
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
|
// #335 — defaultLockAtMostFor: 어떤 작업이 lockAtMostFor 명시 안 해도 보호 (안전 마진)
|
||||||
|
@EnableSchedulerLock(defaultLockAtMostFor = "PT15M")
|
||||||
public class TastebyApplication {
|
public class TastebyApplication {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(TastebyApplication.class, args);
|
SpringApplication.run(TastebyApplication.class, args);
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
package com.tasteby.config;
|
package com.tasteby.config;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class DataSourceConfig {
|
public class DataSourceConfig {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(DataSourceConfig.class);
|
||||||
|
|
||||||
@Value("${app.oracle.wallet-path:}")
|
@Value("${app.oracle.wallet-path:}")
|
||||||
private String walletPath;
|
private String walletPath;
|
||||||
|
|
||||||
|
private final DataSource dataSource;
|
||||||
|
|
||||||
|
public DataSourceConfig(DataSource dataSource) {
|
||||||
|
this.dataSource = dataSource;
|
||||||
|
}
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void configureWallet() {
|
public void configureWallet() {
|
||||||
if (walletPath != null && !walletPath.isBlank()) {
|
if (walletPath != null && !walletPath.isBlank()) {
|
||||||
@@ -18,4 +31,23 @@ public class DataSourceConfig {
|
|||||||
System.setProperty("oracle.net.wallet_location", walletPath);
|
System.setProperty("oracle.net.wallet_location", walletPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void runMigrations() {
|
||||||
|
migrate("ALTER TABLE restaurants ADD (tabling_url VARCHAR2(500))");
|
||||||
|
migrate("ALTER TABLE restaurants ADD (catchtable_url VARCHAR2(500))");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void migrate(String sql) {
|
||||||
|
try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) {
|
||||||
|
stmt.execute(sql);
|
||||||
|
log.info("[MIGRATE] {}", sql);
|
||||||
|
} catch (Exception e) {
|
||||||
|
if (e.getMessage() != null && e.getMessage().contains("ORA-01430")) {
|
||||||
|
log.debug("[MIGRATE] already done: {}", sql);
|
||||||
|
} else {
|
||||||
|
log.warn("[MIGRATE] failed: {} - {}", sql, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,13 +30,14 @@ public class SecurityConfig {
|
|||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
// Public endpoints
|
// Public endpoints
|
||||||
.requestMatchers("/api/health").permitAll()
|
.requestMatchers("/api/health").permitAll()
|
||||||
|
.requestMatchers("/api/version").permitAll() // #338 — 빌드 정보 공개
|
||||||
.requestMatchers("/api/auth/**").permitAll()
|
.requestMatchers("/api/auth/**").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/restaurants/**").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/restaurants/**").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/channels").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/channels").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/search").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/search").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/restaurants/*/reviews").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/restaurants/*/reviews").permitAll()
|
||||||
.requestMatchers("/api/stats/**").permitAll()
|
.requestMatchers("/api/stats/**").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/daemon/config").permitAll()
|
// #275 — /api/daemon/config는 admin-only로 변경 (이전 permitAll 제거)
|
||||||
// Everything else requires authentication (controller-level admin checks)
|
// Everything else requires authentication (controller-level admin checks)
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.tasteby.config;
|
||||||
|
|
||||||
|
import net.javacrumbs.shedlock.core.LockProvider;
|
||||||
|
import net.javacrumbs.shedlock.provider.redis.spring.RedisLockProvider;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #335 — ShedLock LockProvider (Redis 기반).
|
||||||
|
*
|
||||||
|
* 데몬 스케줄러가 다중 파드 환경에서 한 번에 하나만 실행되도록 보장.
|
||||||
|
* key prefix는 ShedLock 기본 ("lock:")을 사용 → Redis 키는 `lock:daemon-runner`.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class ShedLockConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public LockProvider lockProvider(RedisConnectionFactory connectionFactory) {
|
||||||
|
return new RedisLockProvider(connectionFactory);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.tasteby.controller;
|
||||||
|
|
||||||
|
import com.tasteby.security.AuthUtil;
|
||||||
|
import com.tasteby.service.CacheService;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin")
|
||||||
|
public class AdminCacheController {
|
||||||
|
|
||||||
|
private final CacheService cacheService;
|
||||||
|
|
||||||
|
public AdminCacheController(CacheService cacheService) {
|
||||||
|
this.cacheService = cacheService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/cache-flush")
|
||||||
|
public Map<String, Object> flushCache() {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
|
cacheService.flush();
|
||||||
|
return Map.of("ok", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #336 — 캐시 상태 가시화: disabled / errorCount / lastError.
|
||||||
|
* 외부 모니터링 도구 도입 전 운영자가 어드민에서 확인 가능.
|
||||||
|
*/
|
||||||
|
@GetMapping("/cache/stats")
|
||||||
|
public CacheService.CacheStats cacheStats() {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
|
return cacheService.getStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.tasteby.controller;
|
||||||
|
|
||||||
|
import com.tasteby.security.AuthUtil;
|
||||||
|
import com.tasteby.service.RestaurantService;
|
||||||
|
import com.tasteby.service.RestaurantVerifyService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #322 LLM 검증 어드민 API.
|
||||||
|
* - hidden 토글
|
||||||
|
* - 일괄 백필
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/restaurants")
|
||||||
|
public class AdminRestaurantController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AdminRestaurantController.class);
|
||||||
|
|
||||||
|
private final RestaurantService restaurantService;
|
||||||
|
private final RestaurantVerifyService verifyService;
|
||||||
|
|
||||||
|
public AdminRestaurantController(RestaurantService restaurantService, RestaurantVerifyService verifyService) {
|
||||||
|
this.restaurantService = restaurantService;
|
||||||
|
this.verifyService = verifyService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 어드민용 검증 안 된 식당 수 조회.
|
||||||
|
*/
|
||||||
|
@GetMapping("/verify/pending")
|
||||||
|
public Map<String, Object> pendingCount() {
|
||||||
|
var admin = AuthUtil.requireAdmin();
|
||||||
|
int n = restaurantService.countUnverified();
|
||||||
|
log.info("[ADMIN] {} pending verify count: {}", admin.getSubject(), n);
|
||||||
|
return Map.of("pending", n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 어드민용 일괄 백필 트리거. 한 번 호출에 모든 미검증 식당을 처리.
|
||||||
|
* 비동기/SSE 없이 동기 응답이라 호출자는 결과까지 기다려야 함(LLM × N).
|
||||||
|
*/
|
||||||
|
@PostMapping("/verify/all")
|
||||||
|
public Map<String, Object> verifyAll(@RequestParam(defaultValue = "10") int batchSize) {
|
||||||
|
var admin = AuthUtil.requireAdmin();
|
||||||
|
log.info("[ADMIN] {} triggered verifyAll(batchSize={})", admin.getSubject(), batchSize);
|
||||||
|
int processed = verifyService.verifyAll(batchSize);
|
||||||
|
return Map.of("processed", processed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 어드민용 단건 재검증.
|
||||||
|
*/
|
||||||
|
@PostMapping("/{id}/verify")
|
||||||
|
public Map<String, Object> verifyOne(@PathVariable String id) {
|
||||||
|
var admin = AuthUtil.requireAdmin();
|
||||||
|
log.info("[ADMIN] {} verifyOne({})", admin.getSubject(), id);
|
||||||
|
verifyService.verify(id);
|
||||||
|
return Map.of("success", true, "id", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 어드민용 hidden 토글.
|
||||||
|
*/
|
||||||
|
@PatchMapping("/{id}/hidden")
|
||||||
|
public Map<String, Object> setHidden(@PathVariable String id, @RequestBody Map<String, Object> body) {
|
||||||
|
var admin = AuthUtil.requireAdmin();
|
||||||
|
boolean hidden = Boolean.TRUE.equals(body.get("hidden"));
|
||||||
|
String reason = body.get("reason") instanceof String s ? s : "manual";
|
||||||
|
if (hidden) {
|
||||||
|
restaurantService.markHidden(id, reason);
|
||||||
|
} else {
|
||||||
|
restaurantService.clearHidden(id);
|
||||||
|
}
|
||||||
|
log.info("[ADMIN] {} set hidden={} for {}", admin.getSubject(), hidden, id);
|
||||||
|
return Map.of("success", true, "id", id, "hidden", hidden);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
package com.tasteby.controller;
|
package com.tasteby.controller;
|
||||||
|
|
||||||
|
import com.tasteby.domain.Memo;
|
||||||
import com.tasteby.domain.Restaurant;
|
import com.tasteby.domain.Restaurant;
|
||||||
import com.tasteby.domain.Review;
|
import com.tasteby.domain.Review;
|
||||||
|
import com.tasteby.security.AuthUtil;
|
||||||
|
import com.tasteby.service.MemoService;
|
||||||
import com.tasteby.service.ReviewService;
|
import com.tasteby.service.ReviewService;
|
||||||
import com.tasteby.service.UserService;
|
import com.tasteby.service.UserService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -13,18 +20,22 @@ import java.util.Map;
|
|||||||
@RequestMapping("/api/admin/users")
|
@RequestMapping("/api/admin/users")
|
||||||
public class AdminUserController {
|
public class AdminUserController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AdminUserController.class);
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final ReviewService reviewService;
|
private final ReviewService reviewService;
|
||||||
|
private final MemoService memoService;
|
||||||
|
|
||||||
public AdminUserController(UserService userService, ReviewService reviewService) {
|
public AdminUserController(UserService userService, ReviewService reviewService, MemoService memoService) {
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
this.reviewService = reviewService;
|
this.reviewService = reviewService;
|
||||||
|
this.memoService = memoService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public Map<String, Object> listUsers(
|
public Map<String, Object> listUsers(
|
||||||
@RequestParam(defaultValue = "50") int limit,
|
@RequestParam(defaultValue = "50") int limit,
|
||||||
@RequestParam(defaultValue = "0") int offset) {
|
@RequestParam(defaultValue = "0") int offset) {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
var users = userService.findAllWithCounts(limit, offset);
|
var users = userService.findAllWithCounts(limit, offset);
|
||||||
int total = userService.countAll();
|
int total = userService.countAll();
|
||||||
return Map.of("users", users, "total", total);
|
return Map.of("users", users, "total", total);
|
||||||
@@ -32,11 +43,32 @@ public class AdminUserController {
|
|||||||
|
|
||||||
@GetMapping("/{userId}/favorites")
|
@GetMapping("/{userId}/favorites")
|
||||||
public List<Restaurant> userFavorites(@PathVariable String userId) {
|
public List<Restaurant> userFavorites(@PathVariable String userId) {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
return reviewService.getUserFavorites(userId);
|
return reviewService.getUserFavorites(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{userId}/reviews")
|
@GetMapping("/{userId}/reviews")
|
||||||
public List<Review> userReviews(@PathVariable String userId) {
|
public List<Review> userReviews(@PathVariable String userId) {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
return reviewService.findByUser(userId, 100, 0);
|
return reviewService.findByUser(userId, 100, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{userId}/memos")
|
||||||
|
public List<Memo> userMemos(@PathVariable String userId) {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
|
return memoService.findByUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{userId}/admin")
|
||||||
|
public Map<String, Object> updateAdmin(@PathVariable String userId, @RequestBody Map<String, Boolean> body) {
|
||||||
|
var currentUser = AuthUtil.requireAdmin();
|
||||||
|
if (userId.equals(currentUser.getSubject())) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST, "자기 자신의 관리자 권한은 변경할 수 없습니다");
|
||||||
|
}
|
||||||
|
boolean admin = Boolean.TRUE.equals(body.get("admin"));
|
||||||
|
userService.updateAdmin(userId, admin);
|
||||||
|
log.info("[ADMIN] User {} set admin={} for user {}", currentUser.getSubject(), admin, userId);
|
||||||
|
return Map.of("success", true, "user_id", userId, "is_admin", admin);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import com.tasteby.domain.Channel;
|
|||||||
import com.tasteby.security.AuthUtil;
|
import com.tasteby.security.AuthUtil;
|
||||||
import com.tasteby.service.CacheService;
|
import com.tasteby.service.CacheService;
|
||||||
import com.tasteby.service.ChannelService;
|
import com.tasteby.service.ChannelService;
|
||||||
|
import com.tasteby.service.YouTubeService;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
@@ -18,11 +20,14 @@ import java.util.Map;
|
|||||||
public class ChannelController {
|
public class ChannelController {
|
||||||
|
|
||||||
private final ChannelService channelService;
|
private final ChannelService channelService;
|
||||||
|
private final YouTubeService youtubeService;
|
||||||
private final CacheService cache;
|
private final CacheService cache;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public ChannelController(ChannelService channelService, CacheService cache, ObjectMapper objectMapper) {
|
public ChannelController(ChannelService channelService, YouTubeService youtubeService,
|
||||||
|
CacheService cache, ObjectMapper objectMapper) {
|
||||||
this.channelService = channelService;
|
this.channelService = channelService;
|
||||||
|
this.youtubeService = youtubeService;
|
||||||
this.cache = cache;
|
this.cache = cache;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
@@ -48,16 +53,43 @@ public class ChannelController {
|
|||||||
String channelId = body.get("channel_id");
|
String channelId = body.get("channel_id");
|
||||||
String channelName = body.get("channel_name");
|
String channelName = body.get("channel_name");
|
||||||
String titleFilter = body.get("title_filter");
|
String titleFilter = body.get("title_filter");
|
||||||
|
// #295 — body 필수값 가드 (NOT NULL 컬럼에 빈 값 들어가 500 나는 것 방지)
|
||||||
|
if (channelId == null || channelId.isBlank()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "channel_id는 필수입니다");
|
||||||
|
}
|
||||||
|
if (channelName == null || channelName.isBlank()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "channel_name은 필수입니다");
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
String id = channelService.create(channelId, channelName, titleFilter);
|
String id = channelService.create(channelId, channelName, titleFilter);
|
||||||
cache.flush();
|
// #333 — 전체 flush 대신 channels 키만 evict (다른 모듈 캐시 보존)
|
||||||
|
cache.del(cache.makeKey("channels"));
|
||||||
return Map.of("id", id, "channel_id", channelId);
|
return Map.of("id", id, "channel_id", channelId);
|
||||||
} catch (Exception e) {
|
} catch (DataIntegrityViolationException e) {
|
||||||
if (e.getMessage() != null && e.getMessage().toUpperCase().contains("UQ_CHANNELS_CID")) {
|
// #295 — 유니크 충돌을 메시지 문자열 매칭 대신 typed 예외로 감지 (제약명 변경에도 견고).
|
||||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Channel already exists");
|
throw new ResponseStatusException(HttpStatus.CONFLICT, "Channel already exists");
|
||||||
}
|
}
|
||||||
throw e;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{channelId}/scan")
|
||||||
|
public Map<String, Object> scan(@PathVariable String channelId,
|
||||||
|
@RequestParam(defaultValue = "false") boolean full) {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
|
var result = youtubeService.scanChannel(channelId, full);
|
||||||
|
if (result == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Channel not found");
|
||||||
|
}
|
||||||
|
cache.flush();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
|
Integer sortOrder = body.get("sort_order") != null ? ((Number) body.get("sort_order")).intValue() : null;
|
||||||
|
channelService.update(id, (String) body.get("description"), (String) body.get("tags"), sortOrder);
|
||||||
|
cache.flush();
|
||||||
|
return Map.of("ok", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{channelId}")
|
@DeleteMapping("/{channelId}")
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ public class DaemonController {
|
|||||||
|
|
||||||
@GetMapping("/config")
|
@GetMapping("/config")
|
||||||
public DaemonConfig getConfig() {
|
public DaemonConfig getConfig() {
|
||||||
|
// #275 — 데몬 운영 설정은 admin 전용 (이전: 공개 노출 — 정보 누출 위험)
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
DaemonConfig config = daemonConfigService.getConfig();
|
DaemonConfig config = daemonConfigService.getConfig();
|
||||||
return config != null ? config : DaemonConfig.builder().build();
|
return config != null ? config : DaemonConfig.builder().build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.tasteby.controller;
|
package com.tasteby.controller;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
@@ -8,8 +9,20 @@ import java.util.Map;
|
|||||||
@RestController
|
@RestController
|
||||||
public class HealthController {
|
public class HealthController {
|
||||||
|
|
||||||
|
// #338 — 배포 시 set되는 빌드 정보. 미설정 시 "dev"로 표시.
|
||||||
|
@Value("${app.build.version:dev}")
|
||||||
|
private String version;
|
||||||
|
|
||||||
|
@Value("${app.build.commit:unknown}")
|
||||||
|
private String commit;
|
||||||
|
|
||||||
@GetMapping("/api/health")
|
@GetMapping("/api/health")
|
||||||
public Map<String, String> health() {
|
public Map<String, String> health() {
|
||||||
return Map.of("status", "ok");
|
return Map.of("status", "ok");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/version")
|
||||||
|
public Map<String, String> version() {
|
||||||
|
return Map.of("version", version, "commit", commit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.tasteby.controller;
|
||||||
|
|
||||||
|
import com.tasteby.domain.Memo;
|
||||||
|
import com.tasteby.security.AuthUtil;
|
||||||
|
import com.tasteby.service.MemoService;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api")
|
||||||
|
public class MemoController {
|
||||||
|
|
||||||
|
private final MemoService memoService;
|
||||||
|
|
||||||
|
public MemoController(MemoService memoService) {
|
||||||
|
this.memoService = memoService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/restaurants/{restaurantId}/memo")
|
||||||
|
public Memo getMemo(@PathVariable String restaurantId) {
|
||||||
|
String userId = AuthUtil.getUserId();
|
||||||
|
Memo memo = memoService.findByUserAndRestaurant(userId, restaurantId);
|
||||||
|
if (memo == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No memo");
|
||||||
|
}
|
||||||
|
return memo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/restaurants/{restaurantId}/memo")
|
||||||
|
public Memo upsertMemo(@PathVariable String restaurantId,
|
||||||
|
@RequestBody Map<String, Object> body) {
|
||||||
|
String userId = AuthUtil.getUserId();
|
||||||
|
Double rating = body.get("rating") != null
|
||||||
|
? ((Number) body.get("rating")).doubleValue() : null;
|
||||||
|
String text = (String) body.get("memo_text");
|
||||||
|
LocalDate visitedAt = body.get("visited_at") != null
|
||||||
|
? LocalDate.parse((String) body.get("visited_at")) : null;
|
||||||
|
return memoService.upsert(userId, restaurantId, rating, text, visitedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/users/me/memos")
|
||||||
|
public List<Memo> myMemos() {
|
||||||
|
return memoService.findByUser(AuthUtil.getUserId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/restaurants/{restaurantId}/memo")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void deleteMemo(@PathVariable String restaurantId) {
|
||||||
|
String userId = AuthUtil.getUserId();
|
||||||
|
if (!memoService.delete(userId, restaurantId)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No memo");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,28 +5,55 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import com.tasteby.domain.Restaurant;
|
import com.tasteby.domain.Restaurant;
|
||||||
import com.tasteby.security.AuthUtil;
|
import com.tasteby.security.AuthUtil;
|
||||||
import com.tasteby.service.CacheService;
|
import com.tasteby.service.CacheService;
|
||||||
|
import com.tasteby.service.GeocodingService;
|
||||||
import com.tasteby.service.RestaurantService;
|
import com.tasteby.service.RestaurantService;
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
import java.util.List;
|
import java.net.URI;
|
||||||
import java.util.Map;
|
import java.net.URLDecoder;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/restaurants")
|
@RequestMapping("/api/restaurants")
|
||||||
public class RestaurantController {
|
public class RestaurantController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(RestaurantController.class);
|
||||||
|
|
||||||
private final RestaurantService restaurantService;
|
private final RestaurantService restaurantService;
|
||||||
|
private final GeocodingService geocodingService;
|
||||||
private final CacheService cache;
|
private final CacheService cache;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||||
|
|
||||||
public RestaurantController(RestaurantService restaurantService, CacheService cache, ObjectMapper objectMapper) {
|
public RestaurantController(RestaurantService restaurantService, GeocodingService geocodingService, CacheService cache, ObjectMapper objectMapper) {
|
||||||
this.restaurantService = restaurantService;
|
this.restaurantService = restaurantService;
|
||||||
|
this.geocodingService = geocodingService;
|
||||||
this.cache = cache;
|
this.cache = cache;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #290 — Bean 종료 시 virtual thread executor를 정리하여 리소스 누수 방지.
|
||||||
|
@PreDestroy
|
||||||
|
public void shutdownExecutor() {
|
||||||
|
executor.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<Restaurant> list(
|
public List<Restaurant> list(
|
||||||
@RequestParam(defaultValue = "100") int limit,
|
@RequestParam(defaultValue = "100") int limit,
|
||||||
@@ -41,7 +68,7 @@ public class RestaurantController {
|
|||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
try {
|
try {
|
||||||
return objectMapper.readValue(cached, new TypeReference<List<Restaurant>>() {});
|
return objectMapper.readValue(cached, new TypeReference<List<Restaurant>>() {});
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
|
||||||
}
|
}
|
||||||
var result = restaurantService.findAll(limit, offset, cuisine, region, channel);
|
var result = restaurantService.findAll(limit, offset, cuisine, region, channel);
|
||||||
cache.set(key, result);
|
cache.set(key, result);
|
||||||
@@ -55,7 +82,7 @@ public class RestaurantController {
|
|||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
try {
|
try {
|
||||||
return objectMapper.readValue(cached, Restaurant.class);
|
return objectMapper.readValue(cached, Restaurant.class);
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
|
||||||
}
|
}
|
||||||
var r = restaurantService.findById(id);
|
var r = restaurantService.findById(id);
|
||||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||||
@@ -63,15 +90,70 @@ public class RestaurantController {
|
|||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #332 — Restaurant 업데이트 화이트리스트 (SQL updateFields의 컬럼 가드와 1:1).
|
||||||
|
// 허용되지 않은 키는 무시(silent drop). DTO 도입은 후속 작업.
|
||||||
|
private static final java.util.Set<String> ALLOWED_UPDATE_FIELDS = java.util.Set.of(
|
||||||
|
"name", "address", "region", "cuisine_type", "price_range",
|
||||||
|
"phone", "website", "tabling_url", "catchtable_url",
|
||||||
|
"latitude", "longitude", "google_place_id",
|
||||||
|
"business_status", "rating", "rating_count"
|
||||||
|
);
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
|
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
|
||||||
AuthUtil.requireAdmin();
|
AuthUtil.requireAdmin();
|
||||||
var r = restaurantService.findById(id);
|
var r = restaurantService.findById(id);
|
||||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||||
restaurantService.update(id, body);
|
|
||||||
cache.flush();
|
// #332 — 입력 body를 허용 키만 통과시킨 가변 Map으로 정규화
|
||||||
return Map.of("ok", true);
|
Map<String, Object> sanitized = new java.util.LinkedHashMap<>();
|
||||||
|
for (var e : body.entrySet()) {
|
||||||
|
if (ALLOWED_UPDATE_FIELDS.contains(e.getKey())) {
|
||||||
|
sanitized.put(e.getKey(), e.getValue());
|
||||||
|
} else {
|
||||||
|
log.debug("Ignoring non-whitelisted update field: {}", e.getKey());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-geocode if name or address changed
|
||||||
|
String newName = (String) sanitized.get("name");
|
||||||
|
String newAddress = (String) sanitized.get("address");
|
||||||
|
boolean nameChanged = newName != null && !newName.equals(r.getName());
|
||||||
|
boolean addressChanged = newAddress != null && !newAddress.equals(r.getAddress());
|
||||||
|
if (nameChanged || addressChanged) {
|
||||||
|
String geoName = newName != null ? newName : r.getName();
|
||||||
|
String geoAddr = newAddress != null ? newAddress : r.getAddress();
|
||||||
|
var geo = geocodingService.geocodeRestaurant(geoName, geoAddr);
|
||||||
|
if (geo != null) {
|
||||||
|
sanitized.put("latitude", geo.get("latitude"));
|
||||||
|
sanitized.put("longitude", geo.get("longitude"));
|
||||||
|
sanitized.put("google_place_id", geo.get("google_place_id"));
|
||||||
|
if (geo.containsKey("formatted_address")) {
|
||||||
|
sanitized.put("address", geo.get("formatted_address"));
|
||||||
|
}
|
||||||
|
if (geo.containsKey("rating")) sanitized.put("rating", geo.get("rating"));
|
||||||
|
if (geo.containsKey("rating_count")) sanitized.put("rating_count", geo.get("rating_count"));
|
||||||
|
if (geo.containsKey("phone")) sanitized.put("phone", geo.get("phone"));
|
||||||
|
if (geo.containsKey("business_status")) sanitized.put("business_status", geo.get("business_status"));
|
||||||
|
|
||||||
|
String addr = (String) geo.get("formatted_address");
|
||||||
|
if (addr != null) {
|
||||||
|
sanitized.put("region", GeocodingService.parseRegionFromAddress(addr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sanitized.isEmpty()) {
|
||||||
|
// 허용 키가 하나도 없으면 no-op
|
||||||
|
return Map.of("ok", true, "restaurant", r);
|
||||||
|
}
|
||||||
|
|
||||||
|
restaurantService.update(id, sanitized);
|
||||||
|
cache.flush();
|
||||||
|
var updated = restaurantService.findById(id);
|
||||||
|
return Map.of("ok", true, "restaurant", updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
public Map<String, Object> delete(@PathVariable String id) {
|
public Map<String, Object> delete(@PathVariable String id) {
|
||||||
@@ -83,6 +165,253 @@ public class RestaurantController {
|
|||||||
return Map.of("ok", true);
|
return Map.of("ok", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 단건 테이블링 URL 검색 */
|
||||||
|
@GetMapping("/{id}/tabling-search")
|
||||||
|
public List<Map<String, Object>> tablingSearch(@PathVariable String id) {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
|
var r = restaurantService.findById(id);
|
||||||
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return searchTabling(r.getName());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[TABLING] Search failed for '{}': {}", r.getName(), e.getMessage());
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 테이블링 미연결 식당 목록 */
|
||||||
|
@GetMapping("/tabling-pending")
|
||||||
|
public Map<String, Object> tablingPending() {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
|
var list = restaurantService.findWithoutTabling();
|
||||||
|
var summary = list.stream()
|
||||||
|
.map(r -> Map.of("id", (Object) r.getId(), "name", (Object) r.getName()))
|
||||||
|
.toList();
|
||||||
|
return Map.of("count", list.size(), "restaurants", summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 벌크 테이블링 검색 (SSE) */
|
||||||
|
@PostMapping("/bulk-tabling")
|
||||||
|
public SseEmitter bulkTabling() {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
|
SseEmitter emitter = new SseEmitter(600_000L);
|
||||||
|
|
||||||
|
executor.execute(() -> {
|
||||||
|
try {
|
||||||
|
var restaurants = restaurantService.findWithoutTabling();
|
||||||
|
int total = restaurants.size();
|
||||||
|
emit(emitter, Map.of("type", "start", "total", total));
|
||||||
|
|
||||||
|
if (total == 0) {
|
||||||
|
emit(emitter, Map.of("type", "complete", "total", 0, "linked", 0, "notFound", 0));
|
||||||
|
emitter.complete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int linked = 0;
|
||||||
|
int notFound = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < total; i++) {
|
||||||
|
var r = restaurants.get(i);
|
||||||
|
emit(emitter, Map.of("type", "processing", "current", i + 1,
|
||||||
|
"total", total, "name", r.getName()));
|
||||||
|
|
||||||
|
try {
|
||||||
|
var results = searchTabling(r.getName());
|
||||||
|
if (!results.isEmpty()) {
|
||||||
|
String url = String.valueOf(results.get(0).get("url"));
|
||||||
|
String title = String.valueOf(results.get(0).get("title"));
|
||||||
|
if (isNameSimilar(r.getName(), title)) {
|
||||||
|
restaurantService.update(r.getId(), Map.of("tabling_url", url));
|
||||||
|
linked++;
|
||||||
|
emit(emitter, Map.of("type", "done", "current", i + 1,
|
||||||
|
"name", r.getName(), "url", url, "title", title));
|
||||||
|
} else {
|
||||||
|
restaurantService.update(r.getId(), Map.of("tabling_url", "NONE"));
|
||||||
|
notFound++;
|
||||||
|
log.info("[TABLING] Name mismatch: '{}' vs '{}', skipping", r.getName(), title);
|
||||||
|
emit(emitter, Map.of("type", "notfound", "current", i + 1,
|
||||||
|
"name", r.getName(), "reason", "이름 불일치: " + title));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
restaurantService.update(r.getId(), Map.of("tabling_url", "NONE"));
|
||||||
|
notFound++;
|
||||||
|
emit(emitter, Map.of("type", "notfound", "current", i + 1,
|
||||||
|
"name", r.getName()));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
notFound++;
|
||||||
|
emit(emitter, Map.of("type", "error", "current", i + 1,
|
||||||
|
"name", r.getName(), "message", e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 랜덤 딜레이 (2~5초)
|
||||||
|
int delay = ThreadLocalRandom.current().nextInt(2000, 5001);
|
||||||
|
log.info("[TABLING] Waiting {}ms before next search...", delay);
|
||||||
|
Thread.sleep(delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.flush();
|
||||||
|
emit(emitter, Map.of("type", "complete", "total", total, "linked", linked, "notFound", notFound));
|
||||||
|
emitter.complete();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[TABLING] Bulk search error", e);
|
||||||
|
emitter.completeWithError(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 테이블링 URL 저장 */
|
||||||
|
@PutMapping("/{id}/tabling-url")
|
||||||
|
public Map<String, Object> setTablingUrl(@PathVariable String id, @RequestBody Map<String, String> body) {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
|
var r = restaurantService.findById(id);
|
||||||
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||||
|
String url = body.get("tabling_url");
|
||||||
|
// #290 — javascript:/외부 악성 URL 차단. 빈 문자열은 매핑 해제로 허용.
|
||||||
|
if (url != null && !url.isBlank() && !url.startsWith("https://tabling.co.kr/")) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "테이블링 URL은 https://tabling.co.kr/ 만 허용");
|
||||||
|
}
|
||||||
|
restaurantService.update(id, Map.of("tabling_url", url != null ? url : ""));
|
||||||
|
cache.flush();
|
||||||
|
return Map.of("ok", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 테이블링/캐치테이블 매핑 초기화 */
|
||||||
|
@DeleteMapping("/reset-tabling")
|
||||||
|
public Map<String, Object> resetTabling() {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
|
restaurantService.resetTablingUrls();
|
||||||
|
cache.flush();
|
||||||
|
return Map.of("ok", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/reset-catchtable")
|
||||||
|
public Map<String, Object> resetCatchtable() {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
|
restaurantService.resetCatchtableUrls();
|
||||||
|
cache.flush();
|
||||||
|
return Map.of("ok", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 단건 캐치테이블 URL 검색 */
|
||||||
|
@GetMapping("/{id}/catchtable-search")
|
||||||
|
public List<Map<String, Object>> catchtableSearch(@PathVariable String id) {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
|
var r = restaurantService.findById(id);
|
||||||
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||||
|
try {
|
||||||
|
return searchCatchtable(r.getName());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[CATCHTABLE] Search failed for '{}': {}", r.getName(), e.getMessage());
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 캐치테이블 미연결 식당 목록 */
|
||||||
|
@GetMapping("/catchtable-pending")
|
||||||
|
public Map<String, Object> catchtablePending() {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
|
var list = restaurantService.findWithoutCatchtable();
|
||||||
|
var summary = list.stream()
|
||||||
|
.map(r -> Map.of("id", (Object) r.getId(), "name", (Object) r.getName()))
|
||||||
|
.toList();
|
||||||
|
return Map.of("count", list.size(), "restaurants", summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 벌크 캐치테이블 검색 (SSE) */
|
||||||
|
@PostMapping("/bulk-catchtable")
|
||||||
|
public SseEmitter bulkCatchtable() {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
|
SseEmitter emitter = new SseEmitter(600_000L);
|
||||||
|
|
||||||
|
executor.execute(() -> {
|
||||||
|
try {
|
||||||
|
var restaurants = restaurantService.findWithoutCatchtable();
|
||||||
|
int total = restaurants.size();
|
||||||
|
emit(emitter, Map.of("type", "start", "total", total));
|
||||||
|
|
||||||
|
if (total == 0) {
|
||||||
|
emit(emitter, Map.of("type", "complete", "total", 0, "linked", 0, "notFound", 0));
|
||||||
|
emitter.complete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int linked = 0;
|
||||||
|
int notFound = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < total; i++) {
|
||||||
|
var r = restaurants.get(i);
|
||||||
|
emit(emitter, Map.of("type", "processing", "current", i + 1,
|
||||||
|
"total", total, "name", r.getName()));
|
||||||
|
|
||||||
|
try {
|
||||||
|
var results = searchCatchtable(r.getName());
|
||||||
|
if (!results.isEmpty()) {
|
||||||
|
String url = String.valueOf(results.get(0).get("url"));
|
||||||
|
String title = String.valueOf(results.get(0).get("title"));
|
||||||
|
if (isNameSimilar(r.getName(), title)) {
|
||||||
|
restaurantService.update(r.getId(), Map.of("catchtable_url", url));
|
||||||
|
linked++;
|
||||||
|
emit(emitter, Map.of("type", "done", "current", i + 1,
|
||||||
|
"name", r.getName(), "url", url, "title", title));
|
||||||
|
} else {
|
||||||
|
restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE"));
|
||||||
|
notFound++;
|
||||||
|
log.info("[CATCHTABLE] Name mismatch: '{}' vs '{}', skipping", r.getName(), title);
|
||||||
|
emit(emitter, Map.of("type", "notfound", "current", i + 1,
|
||||||
|
"name", r.getName(), "reason", "이름 불일치: " + title));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE"));
|
||||||
|
notFound++;
|
||||||
|
emit(emitter, Map.of("type", "notfound", "current", i + 1,
|
||||||
|
"name", r.getName()));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
notFound++;
|
||||||
|
emit(emitter, Map.of("type", "error", "current", i + 1,
|
||||||
|
"name", r.getName(), "message", e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
int delay = ThreadLocalRandom.current().nextInt(2000, 5001);
|
||||||
|
log.info("[CATCHTABLE] Waiting {}ms before next search...", delay);
|
||||||
|
Thread.sleep(delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.flush();
|
||||||
|
emit(emitter, Map.of("type", "complete", "total", total, "linked", linked, "notFound", notFound));
|
||||||
|
emitter.complete();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[CATCHTABLE] Bulk search error", e);
|
||||||
|
emitter.completeWithError(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 캐치테이블 URL 저장 */
|
||||||
|
@PutMapping("/{id}/catchtable-url")
|
||||||
|
public Map<String, Object> setCatchtableUrl(@PathVariable String id, @RequestBody Map<String, String> body) {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
|
var r = restaurantService.findById(id);
|
||||||
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||||
|
String url = body.get("catchtable_url");
|
||||||
|
// #290 — javascript:/외부 악성 URL 차단. 빈 문자열은 매핑 해제로 허용.
|
||||||
|
if (url != null && !url.isBlank()
|
||||||
|
&& !url.startsWith("https://app.catchtable.co.kr/")
|
||||||
|
&& !url.startsWith("https://www.catchtable.co.kr/")) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "캐치테이블 URL은 https://(app|www).catchtable.co.kr/ 만 허용");
|
||||||
|
}
|
||||||
|
restaurantService.update(id, Map.of("catchtable_url", url != null ? url : ""));
|
||||||
|
cache.flush();
|
||||||
|
return Map.of("ok", true);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/videos")
|
@GetMapping("/{id}/videos")
|
||||||
public List<Map<String, Object>> videos(@PathVariable String id) {
|
public List<Map<String, Object>> videos(@PathVariable String id) {
|
||||||
String key = cache.makeKey("restaurant_videos", id);
|
String key = cache.makeKey("restaurant_videos", id);
|
||||||
@@ -90,7 +419,7 @@ public class RestaurantController {
|
|||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
try {
|
try {
|
||||||
return objectMapper.readValue(cached, new TypeReference<List<Map<String, Object>>>() {});
|
return objectMapper.readValue(cached, new TypeReference<List<Map<String, Object>>>() {});
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
|
||||||
}
|
}
|
||||||
var r = restaurantService.findById(id);
|
var r = restaurantService.findById(id);
|
||||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||||
@@ -98,4 +427,116 @@ public class RestaurantController {
|
|||||||
cache.set(key, result);
|
cache.set(key, result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── DuckDuckGo HTML search helpers ─────────────────────────────────
|
||||||
|
|
||||||
|
private static final HttpClient httpClient = HttpClient.newBuilder()
|
||||||
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
private static final Pattern DDG_RESULT_PATTERN = Pattern.compile(
|
||||||
|
"<a[^>]+class=\"result__a\"[^>]+href=\"([^\"]+)\"[^>]*>(.*?)</a>",
|
||||||
|
Pattern.DOTALL
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DuckDuckGo HTML 검색을 통해 특정 사이트의 URL을 찾는다.
|
||||||
|
* html.duckduckgo.com은 서버사이드 렌더링이라 봇 판정 없이 HTTP 요청만으로 결과를 파싱할 수 있다.
|
||||||
|
*/
|
||||||
|
private List<Map<String, Object>> searchDuckDuckGo(String query, String... urlPatterns) throws Exception {
|
||||||
|
String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
|
||||||
|
String searchUrl = "https://html.duckduckgo.com/html/?q=" + encoded;
|
||||||
|
log.info("[DDG] Searching: {}", query);
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(searchUrl))
|
||||||
|
.header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
|
||||||
|
.header("Accept", "text/html,application/xhtml+xml")
|
||||||
|
.header("Accept-Language", "ko-KR,ko;q=0.9")
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
String html = response.body();
|
||||||
|
|
||||||
|
List<Map<String, Object>> results = new ArrayList<>();
|
||||||
|
Set<String> seen = new HashSet<>();
|
||||||
|
Matcher matcher = DDG_RESULT_PATTERN.matcher(html);
|
||||||
|
|
||||||
|
while (matcher.find() && results.size() < 5) {
|
||||||
|
String href = matcher.group(1);
|
||||||
|
String title = matcher.group(2).replaceAll("<[^>]+>", "").trim();
|
||||||
|
|
||||||
|
// DDG 링크에서 실제 URL 추출 (uddg 파라미터)
|
||||||
|
String actualUrl = extractDdgUrl(href);
|
||||||
|
if (actualUrl == null) continue;
|
||||||
|
|
||||||
|
boolean matches = false;
|
||||||
|
for (String pattern : urlPatterns) {
|
||||||
|
if (actualUrl.contains(pattern)) {
|
||||||
|
matches = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matches && !seen.contains(actualUrl)) {
|
||||||
|
seen.add(actualUrl);
|
||||||
|
results.add(Map.of("title", title, "url", actualUrl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("[DDG] Found {} results for '{}'", results.size(), query);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DDG 리다이렉트 URL에서 실제 URL 추출 */
|
||||||
|
private String extractDdgUrl(String ddgHref) {
|
||||||
|
try {
|
||||||
|
// //duckduckgo.com/l/?uddg=ENCODED_URL&rut=...
|
||||||
|
if (ddgHref.contains("uddg=")) {
|
||||||
|
String uddgParam = ddgHref.substring(ddgHref.indexOf("uddg=") + 5);
|
||||||
|
int ampIdx = uddgParam.indexOf('&');
|
||||||
|
if (ampIdx > 0) uddgParam = uddgParam.substring(0, ampIdx);
|
||||||
|
return URLDecoder.decode(uddgParam, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
// 직접 URL인 경우
|
||||||
|
if (ddgHref.startsWith("http")) return ddgHref;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("[DDG] Failed to extract URL from: {}", ddgHref);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Map<String, Object>> searchTabling(String restaurantName) throws Exception {
|
||||||
|
return searchDuckDuckGo(
|
||||||
|
"site:tabling.co.kr " + restaurantName,
|
||||||
|
"tabling.co.kr/restaurant/", "tabling.co.kr/place/"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Map<String, Object>> searchCatchtable(String restaurantName) throws Exception {
|
||||||
|
return searchDuckDuckGo(
|
||||||
|
"site:app.catchtable.co.kr " + restaurantName,
|
||||||
|
"catchtable.co.kr/dining/", "catchtable.co.kr/shop/"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 식당 이름과 검색 결과 제목의 유사도 검사.
|
||||||
|
* 한쪽 이름이 다른쪽에 포함되거나, 공통 글자 비율이 40% 이상이면 유사하다고 판단.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* #348 — 한국어 자모 분해 + Sørensen-Dice bigram 유사도(임계값 0.45).
|
||||||
|
* 짧은 한국어 이름에서 이전 Jaccard-like(set 비율) 방식보다 정확.
|
||||||
|
*/
|
||||||
|
private boolean isNameSimilar(String restaurantName, String resultTitle) {
|
||||||
|
return com.tasteby.util.HangulSimilarity.similarity(restaurantName, resultTitle) >= 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void emit(SseEmitter emitter, Map<String, Object> data) {
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event().data(objectMapper.writeValueAsString(data)));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("SSE emit error: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public class ReviewController {
|
|||||||
@PathVariable String restaurantId,
|
@PathVariable String restaurantId,
|
||||||
@RequestBody Map<String, Object> body) {
|
@RequestBody Map<String, Object> body) {
|
||||||
String userId = AuthUtil.getUserId();
|
String userId = AuthUtil.getUserId();
|
||||||
double rating = ((Number) body.get("rating")).doubleValue();
|
double rating = requireRating(body.get("rating"));
|
||||||
String text = (String) body.get("review_text");
|
String text = (String) body.get("review_text");
|
||||||
LocalDate visitedAt = body.get("visited_at") != null
|
LocalDate visitedAt = body.get("visited_at") != null
|
||||||
? LocalDate.parse((String) body.get("visited_at")) : null;
|
? LocalDate.parse((String) body.get("visited_at")) : null;
|
||||||
@@ -51,8 +51,7 @@ public class ReviewController {
|
|||||||
@PathVariable String reviewId,
|
@PathVariable String reviewId,
|
||||||
@RequestBody Map<String, Object> body) {
|
@RequestBody Map<String, Object> body) {
|
||||||
String userId = AuthUtil.getUserId();
|
String userId = AuthUtil.getUserId();
|
||||||
Double rating = body.get("rating") != null
|
Double rating = body.get("rating") != null ? requireRating(body.get("rating")) : null;
|
||||||
? ((Number) body.get("rating")).doubleValue() : null;
|
|
||||||
String text = (String) body.get("review_text");
|
String text = (String) body.get("review_text");
|
||||||
LocalDate visitedAt = body.get("visited_at") != null
|
LocalDate visitedAt = body.get("visited_at") != null
|
||||||
? LocalDate.parse((String) body.get("visited_at")) : null;
|
? LocalDate.parse((String) body.get("visited_at")) : null;
|
||||||
@@ -94,4 +93,18 @@ public class ReviewController {
|
|||||||
public List<Restaurant> myFavorites() {
|
public List<Restaurant> myFavorites() {
|
||||||
return reviewService.getUserFavorites(AuthUtil.getUserId());
|
return reviewService.getUserFavorites(AuthUtil.getUserId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #294 — rating 검증: null/비숫자/범위 외 입력은 400.
|
||||||
|
*/
|
||||||
|
private static double requireRating(Object raw) {
|
||||||
|
if (!(raw instanceof Number n)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "rating은 숫자여야 합니다");
|
||||||
|
}
|
||||||
|
double v = n.doubleValue();
|
||||||
|
if (v < 0.0 || v > 5.0 || Double.isNaN(v)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "rating은 0.0 ~ 5.0 범위여야 합니다");
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package com.tasteby.controller;
|
|||||||
|
|
||||||
import com.tasteby.domain.Restaurant;
|
import com.tasteby.domain.Restaurant;
|
||||||
import com.tasteby.service.SearchService;
|
import com.tasteby.service.SearchService;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -21,7 +23,12 @@ public class SearchController {
|
|||||||
@RequestParam String q,
|
@RequestParam String q,
|
||||||
@RequestParam(defaultValue = "keyword") String mode,
|
@RequestParam(defaultValue = "keyword") String mode,
|
||||||
@RequestParam(defaultValue = "20") int limit) {
|
@RequestParam(defaultValue = "20") int limit) {
|
||||||
|
// #293 — q 빈값 가드: '%%' LIKE로 응답 폭발 차단
|
||||||
|
if (q == null || q.isBlank()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "검색어가 필요합니다");
|
||||||
|
}
|
||||||
if (limit > 100) limit = 100;
|
if (limit > 100) limit = 100;
|
||||||
return searchService.search(q, mode, limit);
|
if (limit < 1) limit = 1;
|
||||||
|
return searchService.search(q.trim(), mode, limit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
package com.tasteby.controller;
|
package com.tasteby.controller;
|
||||||
|
|
||||||
import com.tasteby.domain.SiteVisitStats;
|
import com.tasteby.domain.SiteVisitStats;
|
||||||
|
import com.tasteby.service.RateLimitService;
|
||||||
import com.tasteby.service.StatsService;
|
import com.tasteby.service.StatsService;
|
||||||
|
import com.tasteby.util.BotDetector;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -10,20 +15,51 @@ import java.util.Map;
|
|||||||
@RequestMapping("/api/stats")
|
@RequestMapping("/api/stats")
|
||||||
public class StatsController {
|
public class StatsController {
|
||||||
|
|
||||||
private final StatsService statsService;
|
private static final Logger log = LoggerFactory.getLogger(StatsController.class);
|
||||||
|
|
||||||
public StatsController(StatsService statsService) {
|
private final StatsService statsService;
|
||||||
|
private final RateLimitService rateLimitService;
|
||||||
|
|
||||||
|
public StatsController(StatsService statsService, RateLimitService rateLimitService) {
|
||||||
this.statsService = statsService;
|
this.statsService = statsService;
|
||||||
|
this.rateLimitService = rateLimitService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/visit")
|
@PostMapping("/visit")
|
||||||
public Map<String, Object> recordVisit() {
|
public Map<String, Object> recordVisit(HttpServletRequest req) {
|
||||||
|
// #337 — 봇 UA + IP 레이트리밋. 모두 통과해야 카운트 진행.
|
||||||
|
String ua = req.getHeader("User-Agent");
|
||||||
|
if (BotDetector.isBot(ua)) {
|
||||||
|
log.debug("visit skipped (bot): {}", ua);
|
||||||
|
return Map.of("ok", true, "counted", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
String clientIp = resolveClientIp(req);
|
||||||
|
if (!rateLimitService.tryConsume(clientIp)) {
|
||||||
|
log.debug("visit skipped (rate-limit): {}", clientIp);
|
||||||
|
return Map.of("ok", true, "counted", false);
|
||||||
|
}
|
||||||
|
|
||||||
statsService.recordVisit();
|
statsService.recordVisit();
|
||||||
return Map.of("ok", true);
|
return Map.of("ok", true, "counted", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/visits")
|
@GetMapping("/visits")
|
||||||
public SiteVisitStats getVisits() {
|
public SiteVisitStats getVisits() {
|
||||||
return statsService.getVisits();
|
return statsService.getVisits();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #337 — X-Forwarded-For 우선 (Nginx Ingress 뒤). chain이면 첫 번째(원본).
|
||||||
|
* 없으면 RemoteAddr 폴백.
|
||||||
|
*/
|
||||||
|
private static String resolveClientIp(HttpServletRequest req) {
|
||||||
|
String fwd = req.getHeader("X-Forwarded-For");
|
||||||
|
if (fwd != null && !fwd.isBlank()) {
|
||||||
|
int comma = fwd.indexOf(',');
|
||||||
|
return (comma > 0 ? fwd.substring(0, comma) : fwd).trim();
|
||||||
|
}
|
||||||
|
String addr = req.getRemoteAddr();
|
||||||
|
return addr != null ? addr : "unknown";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,24 @@ public class VideoController {
|
|||||||
return Map.of("ok", true, "length", result.text().length(), "source", result.source());
|
return Map.of("ok", true, "length", result.text().length(), "source", result.source());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 클라이언트(브라우저)에서 가져온 트랜스크립트를 저장 */
|
||||||
|
@PostMapping("/{id}/upload-transcript")
|
||||||
|
public Map<String, Object> uploadTranscript(@PathVariable String id,
|
||||||
|
@RequestBody Map<String, String> body) {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
|
var video = videoService.findDetail(id);
|
||||||
|
if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found");
|
||||||
|
|
||||||
|
String text = body.get("text");
|
||||||
|
if (text == null || text.isBlank()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "text is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
videoService.updateTranscript(id, text);
|
||||||
|
String source = body.getOrDefault("source", "browser");
|
||||||
|
return Map.of("ok", true, "length", text.length(), "source", source);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/extract/prompt")
|
@GetMapping("/extract/prompt")
|
||||||
public Map<String, Object> getExtractPrompt() {
|
public Map<String, Object> getExtractPrompt() {
|
||||||
return Map.of("prompt", extractorService.getPrompt());
|
return Map.of("prompt", extractorService.getPrompt());
|
||||||
@@ -234,6 +252,34 @@ public class VideoController {
|
|||||||
if (body.containsKey(key)) restFields.put(key, body.get(key));
|
if (body.containsKey(key)) restFields.put(key, body.get(key));
|
||||||
}
|
}
|
||||||
if (!restFields.isEmpty()) {
|
if (!restFields.isEmpty()) {
|
||||||
|
// Re-geocode if name or address changed
|
||||||
|
var existing = restaurantService.findById(restaurantId);
|
||||||
|
String newName = (String) restFields.get("name");
|
||||||
|
String newAddr = (String) restFields.get("address");
|
||||||
|
boolean nameChanged = newName != null && existing != null && !newName.equals(existing.getName());
|
||||||
|
boolean addrChanged = newAddr != null && existing != null && !newAddr.equals(existing.getAddress());
|
||||||
|
if (nameChanged || addrChanged) {
|
||||||
|
String geoName = newName != null ? newName : existing.getName();
|
||||||
|
String geoAddr = newAddr != null ? newAddr : existing.getAddress();
|
||||||
|
var geo = geocodingService.geocodeRestaurant(geoName, geoAddr);
|
||||||
|
if (geo != null) {
|
||||||
|
restFields.put("latitude", geo.get("latitude"));
|
||||||
|
restFields.put("longitude", geo.get("longitude"));
|
||||||
|
restFields.put("google_place_id", geo.get("google_place_id"));
|
||||||
|
if (geo.containsKey("formatted_address")) {
|
||||||
|
restFields.put("address", geo.get("formatted_address"));
|
||||||
|
}
|
||||||
|
if (geo.containsKey("rating")) restFields.put("rating", geo.get("rating"));
|
||||||
|
if (geo.containsKey("rating_count")) restFields.put("rating_count", geo.get("rating_count"));
|
||||||
|
if (geo.containsKey("phone")) restFields.put("phone", geo.get("phone"));
|
||||||
|
if (geo.containsKey("business_status")) restFields.put("business_status", geo.get("business_status"));
|
||||||
|
// Parse region from address
|
||||||
|
String addr = (String) geo.get("formatted_address");
|
||||||
|
if (addr != null) {
|
||||||
|
restFields.put("region", GeocodingService.parseRegionFromAddress(addr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
restaurantService.update(restaurantId, restFields);
|
restaurantService.update(restaurantId, restFields);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SSE streaming endpoints for bulk operations.
|
* SSE streaming endpoints for bulk operations.
|
||||||
@@ -26,6 +28,7 @@ public class VideoSseController {
|
|||||||
private final VideoService videoService;
|
private final VideoService videoService;
|
||||||
private final RestaurantService restaurantService;
|
private final RestaurantService restaurantService;
|
||||||
private final PipelineService pipelineService;
|
private final PipelineService pipelineService;
|
||||||
|
private final YouTubeService youTubeService;
|
||||||
private final OciGenAiService genAi;
|
private final OciGenAiService genAi;
|
||||||
private final CacheService cache;
|
private final CacheService cache;
|
||||||
private final ObjectMapper mapper;
|
private final ObjectMapper mapper;
|
||||||
@@ -34,27 +37,120 @@ public class VideoSseController {
|
|||||||
public VideoSseController(VideoService videoService,
|
public VideoSseController(VideoService videoService,
|
||||||
RestaurantService restaurantService,
|
RestaurantService restaurantService,
|
||||||
PipelineService pipelineService,
|
PipelineService pipelineService,
|
||||||
|
YouTubeService youTubeService,
|
||||||
OciGenAiService genAi,
|
OciGenAiService genAi,
|
||||||
CacheService cache,
|
CacheService cache,
|
||||||
ObjectMapper mapper) {
|
ObjectMapper mapper) {
|
||||||
this.videoService = videoService;
|
this.videoService = videoService;
|
||||||
this.restaurantService = restaurantService;
|
this.restaurantService = restaurantService;
|
||||||
this.pipelineService = pipelineService;
|
this.pipelineService = pipelineService;
|
||||||
|
this.youTubeService = youTubeService;
|
||||||
this.genAi = genAi;
|
this.genAi = genAi;
|
||||||
this.cache = cache;
|
this.cache = cache;
|
||||||
this.mapper = mapper;
|
this.mapper = mapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/bulk-transcript")
|
@PostMapping("/bulk-transcript")
|
||||||
public SseEmitter bulkTranscript() {
|
public SseEmitter bulkTranscript(@RequestBody(required = false) Map<String, Object> body) {
|
||||||
AuthUtil.requireAdmin();
|
AuthUtil.requireAdmin();
|
||||||
SseEmitter emitter = new SseEmitter(600_000L); // 10 min timeout
|
SseEmitter emitter = new SseEmitter(1_800_000L); // 30 min timeout
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<String> selectedIds = body != null && body.containsKey("ids")
|
||||||
|
? ((List<?>) body.get("ids")).stream().map(Object::toString).toList()
|
||||||
|
: null;
|
||||||
|
|
||||||
executor.execute(() -> {
|
executor.execute(() -> {
|
||||||
try {
|
try {
|
||||||
// TODO: Implement when transcript extraction is available in Java
|
var videos = selectedIds != null && !selectedIds.isEmpty()
|
||||||
emit(emitter, Map.of("type", "start", "total", 0));
|
? videoService.findVideosByIds(selectedIds)
|
||||||
emit(emitter, Map.of("type", "complete", "total", 0, "success", 0));
|
: videoService.findVideosWithoutTranscript();
|
||||||
|
int total = videos.size();
|
||||||
|
emit(emitter, Map.of("type", "start", "total", total));
|
||||||
|
|
||||||
|
if (total == 0) {
|
||||||
|
emit(emitter, Map.of("type", "complete", "total", 0, "success", 0, "failed", 0));
|
||||||
|
emitter.complete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int success = 0;
|
||||||
|
int failed = 0;
|
||||||
|
|
||||||
|
// Pass 1: 브라우저 우선 (봇 탐지 회피)
|
||||||
|
var apiNeeded = new ArrayList<Integer>();
|
||||||
|
try (var session = youTubeService.createBrowserSession()) {
|
||||||
|
for (int i = 0; i < total; i++) {
|
||||||
|
var v = videos.get(i);
|
||||||
|
String videoId = (String) v.get("video_id");
|
||||||
|
String title = (String) v.get("title");
|
||||||
|
String id = (String) v.get("id");
|
||||||
|
|
||||||
|
emit(emitter, Map.of("type", "processing", "index", i, "title", title, "method", "browser"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
var result = youTubeService.getTranscriptWithPage(session.page(), videoId);
|
||||||
|
if (result != null) {
|
||||||
|
videoService.updateTranscript(id, result.text());
|
||||||
|
success++;
|
||||||
|
emit(emitter, Map.of("type", "done", "index", i,
|
||||||
|
"title", title, "source", result.source(),
|
||||||
|
"length", result.text().length()));
|
||||||
|
} else {
|
||||||
|
apiNeeded.add(i);
|
||||||
|
emit(emitter, Map.of("type", "skip", "index", i,
|
||||||
|
"title", title, "message", "브라우저 실패, API로 재시도 예정"));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
apiNeeded.add(i);
|
||||||
|
log.warn("[BULK-TRANSCRIPT] Browser failed for {}: {}", videoId, e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 봇 판정 방지 랜덤 딜레이 (3~8초)
|
||||||
|
if (i < total - 1) {
|
||||||
|
int delay = ThreadLocalRandom.current().nextInt(3000, 8001);
|
||||||
|
log.info("[BULK-TRANSCRIPT] Waiting {}ms before next...", delay);
|
||||||
|
session.page().waitForTimeout(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: 브라우저 실패분만 API로 재시도
|
||||||
|
if (!apiNeeded.isEmpty()) {
|
||||||
|
emit(emitter, Map.of("type", "api_pass", "count", apiNeeded.size()));
|
||||||
|
for (int i : apiNeeded) {
|
||||||
|
var v = videos.get(i);
|
||||||
|
String videoId = (String) v.get("video_id");
|
||||||
|
String title = (String) v.get("title");
|
||||||
|
String id = (String) v.get("id");
|
||||||
|
|
||||||
|
emit(emitter, Map.of("type", "processing", "index", i, "title", title, "method", "api"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
var result = youTubeService.getTranscriptApi(videoId, "auto");
|
||||||
|
if (result != null) {
|
||||||
|
videoService.updateTranscript(id, result.text());
|
||||||
|
success++;
|
||||||
|
emit(emitter, Map.of("type", "done", "index", i,
|
||||||
|
"title", title, "source", result.source(),
|
||||||
|
"length", result.text().length()));
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
videoService.updateStatus(id, "no_transcript");
|
||||||
|
emit(emitter, Map.of("type", "error", "index", i,
|
||||||
|
"title", title, "message", "자막을 찾을 수 없음"));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
failed++;
|
||||||
|
videoService.updateStatus(id, "no_transcript");
|
||||||
|
log.error("[BULK-TRANSCRIPT] API error for {}: {}", videoId, e.getMessage());
|
||||||
|
emit(emitter, Map.of("type", "error", "index", i,
|
||||||
|
"title", title, "message", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(emitter, Map.of("type", "complete", "total", total, "success", success, "failed", failed));
|
||||||
emitter.complete();
|
emitter.complete();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Bulk transcript error", e);
|
log.error("Bulk transcript error", e);
|
||||||
@@ -65,13 +161,20 @@ public class VideoSseController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/bulk-extract")
|
@PostMapping("/bulk-extract")
|
||||||
public SseEmitter bulkExtract() {
|
public SseEmitter bulkExtract(@RequestBody(required = false) Map<String, Object> body) {
|
||||||
AuthUtil.requireAdmin();
|
AuthUtil.requireAdmin();
|
||||||
SseEmitter emitter = new SseEmitter(600_000L);
|
SseEmitter emitter = new SseEmitter(600_000L);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<String> selectedIds = body != null && body.containsKey("ids")
|
||||||
|
? ((List<?>) body.get("ids")).stream().map(Object::toString).toList()
|
||||||
|
: null;
|
||||||
|
|
||||||
executor.execute(() -> {
|
executor.execute(() -> {
|
||||||
try {
|
try {
|
||||||
var rows = videoService.findVideosForBulkExtract();
|
var rows = selectedIds != null && !selectedIds.isEmpty()
|
||||||
|
? videoService.findVideosForExtractByIds(selectedIds)
|
||||||
|
: videoService.findVideosForBulkExtract();
|
||||||
|
|
||||||
int total = rows.size();
|
int total = rows.size();
|
||||||
int totalRestaurants = 0;
|
int totalRestaurants = 0;
|
||||||
@@ -80,7 +183,8 @@ public class VideoSseController {
|
|||||||
for (int i = 0; i < total; i++) {
|
for (int i = 0; i < total; i++) {
|
||||||
var v = rows.get(i);
|
var v = rows.get(i);
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
long delay = (long) (3000 + Math.random() * 5000);
|
// #325 — ThreadLocalRandom으로 통일 (bulkTranscript와 일관성)
|
||||||
|
long delay = 3000L + ThreadLocalRandom.current().nextLong(5000);
|
||||||
emit(emitter, Map.of("type", "wait", "index", i, "delay", delay / 1000.0));
|
emit(emitter, Map.of("type", "wait", "index", i, "delay", delay / 1000.0));
|
||||||
Thread.sleep(delay);
|
Thread.sleep(delay);
|
||||||
}
|
}
|
||||||
@@ -245,13 +349,15 @@ public class VideoSseController {
|
|||||||
@PostMapping("/rebuild-vectors")
|
@PostMapping("/rebuild-vectors")
|
||||||
public SseEmitter rebuildVectors() {
|
public SseEmitter rebuildVectors() {
|
||||||
AuthUtil.requireAdmin();
|
AuthUtil.requireAdmin();
|
||||||
SseEmitter emitter = new SseEmitter(600_000L);
|
SseEmitter emitter = new SseEmitter(60_000L);
|
||||||
|
|
||||||
executor.execute(() -> {
|
executor.execute(() -> {
|
||||||
try {
|
try {
|
||||||
emit(emitter, Map.of("type", "start"));
|
// #325 — 운영자에게 미구현 상태 명시 (이전: 즉시 complete(total=0) → 무반응 인상)
|
||||||
// TODO: Implement full vector rebuild using VectorService
|
emit(emitter, Map.of(
|
||||||
emit(emitter, Map.of("type", "complete", "total", 0));
|
"type", "not_implemented",
|
||||||
|
"message", "벡터 재생성은 아직 구현되지 않았습니다. 후속 이슈(#325/#331)에서 처리 예정입니다."
|
||||||
|
));
|
||||||
emitter.complete();
|
emitter.complete();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
emitter.completeWithError(e);
|
emitter.completeWithError(e);
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ public class Channel {
|
|||||||
private String channelId;
|
private String channelId;
|
||||||
private String channelName;
|
private String channelName;
|
||||||
private String titleFilter;
|
private String titleFilter;
|
||||||
|
private String description;
|
||||||
|
private String tags;
|
||||||
|
private Integer sortOrder;
|
||||||
private int videoCount;
|
private int videoCount;
|
||||||
private String lastVideoAt;
|
private String lastVideoAt;
|
||||||
}
|
}
|
||||||
|
|||||||
22
backend-java/src/main/java/com/tasteby/domain/Memo.java
Normal file
22
backend-java/src/main/java/com/tasteby/domain/Memo.java
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package com.tasteby.domain;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class Memo {
|
||||||
|
private String id;
|
||||||
|
private String userId;
|
||||||
|
private String restaurantId;
|
||||||
|
private Double rating;
|
||||||
|
private String memoText;
|
||||||
|
private String visitedAt;
|
||||||
|
private String createdAt;
|
||||||
|
private String updatedAt;
|
||||||
|
private String restaurantName;
|
||||||
|
}
|
||||||
@@ -24,11 +24,18 @@ public class Restaurant {
|
|||||||
private String phone;
|
private String phone;
|
||||||
private String website;
|
private String website;
|
||||||
private String googlePlaceId;
|
private String googlePlaceId;
|
||||||
|
private String tablingUrl;
|
||||||
|
private String catchtableUrl;
|
||||||
private String businessStatus;
|
private String businessStatus;
|
||||||
private Double rating;
|
private Double rating;
|
||||||
private Integer ratingCount;
|
private Integer ratingCount;
|
||||||
private Date updatedAt;
|
private Date updatedAt;
|
||||||
|
|
||||||
|
// #322 LLM 검증
|
||||||
|
private Boolean hidden;
|
||||||
|
private String hiddenReason;
|
||||||
|
private Date verifiedAt;
|
||||||
|
|
||||||
// Transient enrichment fields
|
// Transient enrichment fields
|
||||||
private List<String> channels;
|
private List<String> channels;
|
||||||
private List<String> foodsMentioned;
|
private List<String> foodsMentioned;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import lombok.NoArgsConstructor;
|
|||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class SiteVisitStats {
|
public class SiteVisitStats {
|
||||||
private int today;
|
// #274 — long으로 변경 (21억 이상 누적 시 int 오버플로 방지)
|
||||||
private int total;
|
private long today;
|
||||||
|
private long total;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,5 @@ public class UserInfo {
|
|||||||
private String createdAt;
|
private String createdAt;
|
||||||
private int favoriteCount;
|
private int favoriteCount;
|
||||||
private int reviewCount;
|
private int reviewCount;
|
||||||
|
private int memoCount;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,4 +21,9 @@ public interface ChannelMapper {
|
|||||||
int deactivateById(@Param("id") String id);
|
int deactivateById(@Param("id") String id);
|
||||||
|
|
||||||
Channel findByChannelId(@Param("channelId") String channelId);
|
Channel findByChannelId(@Param("channelId") String channelId);
|
||||||
|
|
||||||
|
void updateChannel(@Param("id") String id,
|
||||||
|
@Param("description") String description,
|
||||||
|
@Param("tags") String tags,
|
||||||
|
@Param("sortOrder") Integer sortOrder);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.tasteby.mapper;
|
||||||
|
|
||||||
|
import com.tasteby.domain.Memo;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface MemoMapper {
|
||||||
|
|
||||||
|
Memo findByUserAndRestaurant(@Param("userId") String userId,
|
||||||
|
@Param("restaurantId") String restaurantId);
|
||||||
|
|
||||||
|
void insertMemo(@Param("id") String id,
|
||||||
|
@Param("userId") String userId,
|
||||||
|
@Param("restaurantId") String restaurantId,
|
||||||
|
@Param("rating") Double rating,
|
||||||
|
@Param("memoText") String memoText,
|
||||||
|
@Param("visitedAt") String visitedAt);
|
||||||
|
|
||||||
|
int updateMemo(@Param("userId") String userId,
|
||||||
|
@Param("restaurantId") String restaurantId,
|
||||||
|
@Param("rating") Double rating,
|
||||||
|
@Param("memoText") String memoText,
|
||||||
|
@Param("visitedAt") String visitedAt);
|
||||||
|
|
||||||
|
int deleteMemo(@Param("userId") String userId,
|
||||||
|
@Param("restaurantId") String restaurantId);
|
||||||
|
|
||||||
|
List<Memo> findByUser(@Param("userId") String userId);
|
||||||
|
}
|
||||||
@@ -14,7 +14,19 @@ public interface RestaurantMapper {
|
|||||||
@Param("offset") int offset,
|
@Param("offset") int offset,
|
||||||
@Param("cuisine") String cuisine,
|
@Param("cuisine") String cuisine,
|
||||||
@Param("region") String region,
|
@Param("region") String region,
|
||||||
@Param("channel") String channel);
|
@Param("channel") String channel,
|
||||||
|
@Param("includeHidden") boolean includeHidden);
|
||||||
|
|
||||||
|
// #322 LLM 검증: hidden 표시 갱신
|
||||||
|
void updateVerification(@Param("id") String id,
|
||||||
|
@Param("hidden") int hidden,
|
||||||
|
@Param("hiddenReason") String hiddenReason);
|
||||||
|
|
||||||
|
void clearHidden(@Param("id") String id);
|
||||||
|
|
||||||
|
List<Restaurant> findUnverified(@Param("limit") int limit);
|
||||||
|
|
||||||
|
int countUnverified();
|
||||||
|
|
||||||
Restaurant findById(@Param("id") String id);
|
Restaurant findById(@Param("id") String id);
|
||||||
|
|
||||||
@@ -55,6 +67,14 @@ public interface RestaurantMapper {
|
|||||||
|
|
||||||
void updateFoodsMentioned(@Param("id") String id, @Param("foods") String foods);
|
void updateFoodsMentioned(@Param("id") String id, @Param("foods") String foods);
|
||||||
|
|
||||||
|
List<Restaurant> findWithoutTabling();
|
||||||
|
|
||||||
|
List<Restaurant> findWithoutCatchtable();
|
||||||
|
|
||||||
|
void resetTablingUrls();
|
||||||
|
|
||||||
|
void resetCatchtableUrls();
|
||||||
|
|
||||||
List<Map<String, Object>> findForRemapCuisine();
|
List<Map<String, Object>> findForRemapCuisine();
|
||||||
|
|
||||||
List<Map<String, Object>> findForRemapFoods();
|
List<Map<String, Object>> findForRemapFoods();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ public interface StatsMapper {
|
|||||||
|
|
||||||
void recordVisit();
|
void recordVisit();
|
||||||
|
|
||||||
int getTodayVisits();
|
long getTodayVisits();
|
||||||
|
|
||||||
int getTotalVisits();
|
long getTotalVisits();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ public interface VideoMapper {
|
|||||||
|
|
||||||
List<Map<String, Object>> findVideosWithoutTranscript();
|
List<Map<String, Object>> findVideosWithoutTranscript();
|
||||||
|
|
||||||
|
List<Map<String, Object>> findVideosByIds(@Param("ids") List<String> ids);
|
||||||
|
|
||||||
|
List<Map<String, Object>> findVideosForExtractByIds(@Param("ids") List<String> ids);
|
||||||
|
|
||||||
void updateVideoRestaurantFields(@Param("videoId") String videoId,
|
void updateVideoRestaurantFields(@Param("videoId") String videoId,
|
||||||
@Param("restaurantId") String restaurantId,
|
@Param("restaurantId") String restaurantId,
|
||||||
@Param("foodsJson") String foodsJson,
|
@Param("foodsJson") String foodsJson,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import com.google.api.client.http.javanet.NetHttpTransport;
|
|||||||
import com.google.api.client.json.gson.GsonFactory;
|
import com.google.api.client.json.gson.GsonFactory;
|
||||||
import com.tasteby.domain.UserInfo;
|
import com.tasteby.domain.UserInfo;
|
||||||
import com.tasteby.security.JwtTokenProvider;
|
import com.tasteby.security.JwtTokenProvider;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -17,6 +19,8 @@ import java.util.Map;
|
|||||||
@Service
|
@Service
|
||||||
public class AuthService {
|
public class AuthService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AuthService.class);
|
||||||
|
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final JwtTokenProvider jwtProvider;
|
private final JwtTokenProvider jwtProvider;
|
||||||
private final GoogleIdTokenVerifier verifier;
|
private final GoogleIdTokenVerifier verifier;
|
||||||
@@ -58,7 +62,10 @@ public class AuthService {
|
|||||||
} catch (ResponseStatusException e) {
|
} catch (ResponseStatusException e) {
|
||||||
throw e;
|
throw e;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid Google token: " + e.getMessage());
|
// #266 — 외부에는 고정 메시지만, 상세는 로그로 (Google verifier 내부 네트워크/공개키
|
||||||
|
// 조회 실패 메시지가 클라이언트에 노출되지 않도록)
|
||||||
|
log.warn("Google token verification failed: {}", e.getMessage());
|
||||||
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid Google token");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,38 +5,52 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||||
|
import org.springframework.data.redis.core.Cursor;
|
||||||
|
import org.springframework.data.redis.core.ScanOptions;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Set;
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class CacheService {
|
public class CacheService {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(CacheService.class);
|
private static final Logger log = LoggerFactory.getLogger(CacheService.class);
|
||||||
private static final String PREFIX = "tasteby:";
|
private static final String PREFIX = "tasteby:";
|
||||||
|
private static final String SCAN_PATTERN = PREFIX + "*";
|
||||||
|
private static final int SCAN_BATCH = 500;
|
||||||
|
|
||||||
private final StringRedisTemplate redis;
|
private final StringRedisTemplate redis;
|
||||||
private final ObjectMapper mapper;
|
private final ObjectMapper mapper;
|
||||||
private final Duration ttl;
|
private final Duration ttl;
|
||||||
private boolean disabled = false;
|
|
||||||
|
// #336 — disabled/errorCount/lastError는 헬스체크와 다른 호출 스레드 사이에서 안전하게 공유.
|
||||||
|
private volatile boolean disabled = false;
|
||||||
|
private final AtomicLong errorCount = new AtomicLong(0);
|
||||||
|
private volatile String lastError = null;
|
||||||
|
|
||||||
public CacheService(StringRedisTemplate redis, ObjectMapper mapper,
|
public CacheService(StringRedisTemplate redis, ObjectMapper mapper,
|
||||||
@Value("${app.cache.ttl-seconds:600}") int ttlSeconds) {
|
@Value("${app.cache.ttl-seconds:600}") int ttlSeconds) {
|
||||||
this.redis = redis;
|
this.redis = redis;
|
||||||
this.mapper = mapper;
|
this.mapper = mapper;
|
||||||
this.ttl = Duration.ofSeconds(ttlSeconds);
|
this.ttl = Duration.ofSeconds(ttlSeconds);
|
||||||
try {
|
this.disabled = !pingOk();
|
||||||
redis.getConnectionFactory().getConnection().ping();
|
if (!disabled) log.info("Redis connected");
|
||||||
log.info("Redis connected");
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Redis unavailable ({}), caching disabled", e.getMessage());
|
|
||||||
disabled = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public String makeKey(String... parts) {
|
public String makeKey(String... parts) {
|
||||||
|
if (parts == null || parts.length == 0) {
|
||||||
|
throw new IllegalArgumentException("makeKey requires at least one part");
|
||||||
|
}
|
||||||
|
for (String p : parts) {
|
||||||
|
if (p == null) throw new IllegalArgumentException("makeKey parts must not be null");
|
||||||
|
}
|
||||||
return PREFIX + String.join(":", parts);
|
return PREFIX + String.join(":", parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +62,7 @@ public class CacheService {
|
|||||||
return mapper.readValue(val, type);
|
return mapper.readValue(val, type);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.debug("Cache get error: {}", e.getMessage());
|
recordError("get", e);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -58,7 +72,7 @@ public class CacheService {
|
|||||||
try {
|
try {
|
||||||
return redis.opsForValue().get(key);
|
return redis.opsForValue().get(key);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.debug("Cache get error: {}", e.getMessage());
|
recordError("getRaw", e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,20 +83,114 @@ public class CacheService {
|
|||||||
String json = mapper.writeValueAsString(value);
|
String json = mapper.writeValueAsString(value);
|
||||||
redis.opsForValue().set(key, json, ttl);
|
redis.opsForValue().set(key, json, ttl);
|
||||||
} catch (JsonProcessingException e) {
|
} catch (JsonProcessingException e) {
|
||||||
log.debug("Cache set error: {}", e.getMessage());
|
recordError("set:serialize", e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
recordError("set", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #336 — KEYS 블로킹 명령 대체.
|
||||||
|
* SCAN으로 cursor 순회 후 UNLINK(논블로킹 삭제)로 일괄 삭제.
|
||||||
|
*/
|
||||||
public void flush() {
|
public void flush() {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
try {
|
Integer count = redis.execute((org.springframework.data.redis.core.RedisCallback<Integer>) conn -> {
|
||||||
Set<String> keys = redis.keys(PREFIX + "*");
|
List<byte[]> batch = new ArrayList<>(SCAN_BATCH);
|
||||||
if (keys != null && !keys.isEmpty()) {
|
int deleted = 0;
|
||||||
redis.delete(keys);
|
try (Cursor<byte[]> cursor = conn.keyCommands().scan(
|
||||||
|
ScanOptions.scanOptions().match(SCAN_PATTERN).count(SCAN_BATCH).build())) {
|
||||||
|
while (cursor.hasNext()) {
|
||||||
|
batch.add(cursor.next());
|
||||||
|
if (batch.size() >= SCAN_BATCH) {
|
||||||
|
deleted += unlinkBatch(conn, batch);
|
||||||
|
batch.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!batch.isEmpty()) {
|
||||||
|
deleted += unlinkBatch(conn, batch);
|
||||||
}
|
}
|
||||||
log.info("Cache flushed");
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.debug("Cache flush error: {}", e.getMessage());
|
recordError("flush:scan", e);
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
});
|
||||||
|
log.info("Cache flushed ({} keys via SCAN+UNLINK)", count == null ? 0 : count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int unlinkBatch(org.springframework.data.redis.connection.RedisConnection conn, List<byte[]> keys) {
|
||||||
|
try {
|
||||||
|
Long n = conn.keyCommands().unlink(keys.toArray(new byte[0][]));
|
||||||
|
return n == null ? 0 : n.intValue();
|
||||||
|
} catch (Exception e) {
|
||||||
|
// UNLINK 미지원 환경 대비 DEL 폴백
|
||||||
|
recordError("flush:unlink", e);
|
||||||
|
try {
|
||||||
|
Long n = conn.keyCommands().del(keys.toArray(new byte[0][]));
|
||||||
|
return n == null ? 0 : n.intValue();
|
||||||
|
} catch (Exception delErr) {
|
||||||
|
recordError("flush:del", delErr);
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void del(String key) {
|
||||||
|
if (disabled) return;
|
||||||
|
try {
|
||||||
|
redis.delete(key);
|
||||||
|
} catch (Exception e) {
|
||||||
|
recordError("del", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #336 — Redis 다운 → disabled=true, 재기동되면 자동으로 disabled=false.
|
||||||
|
* 30초마다 ping 한 번(<1ms)이라 부하 미미.
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelay = 30_000L)
|
||||||
|
public void checkHealth() {
|
||||||
|
boolean ok = pingOk();
|
||||||
|
if (ok && disabled) {
|
||||||
|
disabled = false;
|
||||||
|
log.info("Redis recovered, caching re-enabled");
|
||||||
|
} else if (!ok && !disabled) {
|
||||||
|
disabled = true;
|
||||||
|
log.warn("Redis lost, caching disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean pingOk() {
|
||||||
|
RedisConnectionFactory factory = redis.getConnectionFactory();
|
||||||
|
if (factory == null) return false;
|
||||||
|
try (var conn = factory.getConnection()) {
|
||||||
|
conn.ping();
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
lastError = "ping: " + e.getMessage();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void recordError(String op, Exception e) {
|
||||||
|
long n = errorCount.incrementAndGet();
|
||||||
|
String msg = e.getMessage();
|
||||||
|
lastError = op + ": " + (msg == null ? e.getClass().getSimpleName() : msg);
|
||||||
|
// 한 번씩만 WARN, 나머지는 DEBUG로 (운영 로그 폭주 방지 — 단순한 throttle)
|
||||||
|
if (n == 1 || n % 100 == 0) {
|
||||||
|
log.warn("Cache {} error #{}: {}", op, n, lastError);
|
||||||
|
} else {
|
||||||
|
log.debug("Cache {} error #{}: {}", op, n, lastError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDisabled() {
|
||||||
|
return disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CacheStats getStats() {
|
||||||
|
return new CacheStats(disabled, errorCount.get(), lastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CacheStats(boolean disabled, long errorCount, String lastError) {}
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,15 +27,24 @@ public class ChannelService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public boolean deactivate(String channelId) {
|
public boolean deactivate(String channelId) {
|
||||||
// Try deactivate by channel_id first, then by DB id
|
if (channelId == null || channelId.isBlank()) return false;
|
||||||
int rows = mapper.deactivateByChannelId(channelId);
|
// #295 — 입력 형식으로 명시적 분기:
|
||||||
if (rows == 0) {
|
// "UC..."(24 chars) 형식 → YouTube channel_id로 비활성화
|
||||||
rows = mapper.deactivateById(channelId);
|
// 그 외(32-char hex UUID 등) → DB id로 비활성화
|
||||||
}
|
// 이전: channel_id 시도 → 0이면 id 시도. 우연히 UC가 hex와 같을 확률은 0이지만
|
||||||
|
// 가독성/의도 명확성 + 잘못된 폴백 차단을 위해 명시화.
|
||||||
|
boolean looksLikeYouTubeId = channelId.startsWith("UC") && channelId.length() == 24;
|
||||||
|
int rows = looksLikeYouTubeId
|
||||||
|
? mapper.deactivateByChannelId(channelId)
|
||||||
|
: mapper.deactivateById(channelId);
|
||||||
return rows > 0;
|
return rows > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Channel findByChannelId(String channelId) {
|
public Channel findByChannelId(String channelId) {
|
||||||
return mapper.findByChannelId(channelId);
|
return mapper.findByChannelId(channelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void update(String id, String description, String tags, Integer sortOrder) {
|
||||||
|
mapper.updateChannel(id, description, tags, sortOrder);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package com.tasteby.service;
|
|||||||
|
|
||||||
import com.tasteby.domain.DaemonConfig;
|
import com.tasteby.domain.DaemonConfig;
|
||||||
import com.tasteby.mapper.DaemonConfigMapper;
|
import com.tasteby.mapper.DaemonConfigMapper;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -27,20 +29,33 @@ public class DaemonConfigService {
|
|||||||
current.setScanEnabled(Boolean.TRUE.equals(body.get("scan_enabled")));
|
current.setScanEnabled(Boolean.TRUE.equals(body.get("scan_enabled")));
|
||||||
}
|
}
|
||||||
if (body.containsKey("scan_interval_min")) {
|
if (body.containsKey("scan_interval_min")) {
|
||||||
current.setScanIntervalMin(((Number) body.get("scan_interval_min")).intValue());
|
// #275 — 0/음수 입력으로 30초 사이클 폭주 방지. ClassCastException 대신 400.
|
||||||
|
current.setScanIntervalMin(requirePositiveInt(body.get("scan_interval_min"), "scan_interval_min"));
|
||||||
}
|
}
|
||||||
if (body.containsKey("process_enabled")) {
|
if (body.containsKey("process_enabled")) {
|
||||||
current.setProcessEnabled(Boolean.TRUE.equals(body.get("process_enabled")));
|
current.setProcessEnabled(Boolean.TRUE.equals(body.get("process_enabled")));
|
||||||
}
|
}
|
||||||
if (body.containsKey("process_interval_min")) {
|
if (body.containsKey("process_interval_min")) {
|
||||||
current.setProcessIntervalMin(((Number) body.get("process_interval_min")).intValue());
|
current.setProcessIntervalMin(requirePositiveInt(body.get("process_interval_min"), "process_interval_min"));
|
||||||
}
|
}
|
||||||
if (body.containsKey("process_limit")) {
|
if (body.containsKey("process_limit")) {
|
||||||
current.setProcessLimit(((Number) body.get("process_limit")).intValue());
|
current.setProcessLimit(requirePositiveInt(body.get("process_limit"), "process_limit"));
|
||||||
}
|
}
|
||||||
mapper.updateConfig(current);
|
mapper.updateConfig(current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** #275 — 양의 정수 가드. 비숫자/0/음수는 400. */
|
||||||
|
private static int requirePositiveInt(Object raw, String field) {
|
||||||
|
if (!(raw instanceof Number n)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + "은(는) 정수여야 합니다");
|
||||||
|
}
|
||||||
|
int v = n.intValue();
|
||||||
|
if (v < 1) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + "은(는) 1 이상이어야 합니다 (폭주 방지)");
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
public void updateLastScan() {
|
public void updateLastScan() {
|
||||||
mapper.updateLastScan();
|
mapper.updateLastScan();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package com.tasteby.service;
|
package com.tasteby.service;
|
||||||
|
|
||||||
import com.tasteby.domain.DaemonConfig;
|
import com.tasteby.domain.DaemonConfig;
|
||||||
|
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -22,6 +24,9 @@ public class DaemonScheduler {
|
|||||||
private final PipelineService pipelineService;
|
private final PipelineService pipelineService;
|
||||||
private final CacheService cacheService;
|
private final CacheService cacheService;
|
||||||
|
|
||||||
|
@Value("${app.daemon.enabled:true}")
|
||||||
|
private boolean instanceEnabled;
|
||||||
|
|
||||||
public DaemonScheduler(DaemonConfigService daemonConfigService,
|
public DaemonScheduler(DaemonConfigService daemonConfigService,
|
||||||
YouTubeService youTubeService,
|
YouTubeService youTubeService,
|
||||||
PipelineService pipelineService,
|
PipelineService pipelineService,
|
||||||
@@ -33,7 +38,15 @@ public class DaemonScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Scheduled(fixedDelay = 30_000) // Check every 30 seconds
|
@Scheduled(fixedDelay = 30_000) // Check every 30 seconds
|
||||||
|
// #335 — 분산 락: 멀티 파드 환경에서 한 인스턴스만 실행. Redis 키 `lock:daemon-runner`.
|
||||||
|
// lockAtMostFor: 작업이 비정상 종료돼도 15분 후 강제 해제 (다음 cron이 잡을 수 있게)
|
||||||
|
// lockAtLeastFor: 빨리 끝나도 30초 동안 유지 (즉시 다른 cron이 같은 작업 잡는 것 방지)
|
||||||
|
@SchedulerLock(name = "daemon-runner", lockAtMostFor = "PT15M", lockAtLeastFor = "PT30S")
|
||||||
public void run() {
|
public void run() {
|
||||||
|
// 인스턴스 차원 차단(dev/prod 동일 DB 공유 환경에서 dev 쪽 동시 폴링 방지).
|
||||||
|
// dev .env: DAEMON_ENABLED=false → 이 인스턴스는 스케줄러 동작 안 함.
|
||||||
|
// prod: 미설정 → 기본 true.
|
||||||
|
if (!instanceEnabled) return;
|
||||||
try {
|
try {
|
||||||
var config = getConfig();
|
var config = getConfig();
|
||||||
if (config == null) return;
|
if (config == null) return;
|
||||||
@@ -42,8 +55,13 @@ public class DaemonScheduler {
|
|||||||
Instant lastScan = config.getLastScanAt() != null ? config.getLastScanAt().toInstant() : null;
|
Instant lastScan = config.getLastScanAt() != null ? config.getLastScanAt().toInstant() : null;
|
||||||
if (lastScan == null || Instant.now().isAfter(lastScan.plus(config.getScanIntervalMin(), ChronoUnit.MINUTES))) {
|
if (lastScan == null || Instant.now().isAfter(lastScan.plus(config.getScanIntervalMin(), ChronoUnit.MINUTES))) {
|
||||||
log.info("Running scheduled channel scan...");
|
log.info("Running scheduled channel scan...");
|
||||||
int newVideos = youTubeService.scanAllChannels();
|
int newVideos = 0;
|
||||||
|
try {
|
||||||
|
newVideos = youTubeService.scanAllChannels();
|
||||||
|
} finally {
|
||||||
|
// #275 — 외부 호출 예외 시에도 last_scan_at을 갱신해 다음 cron까지의 backoff를 보장
|
||||||
daemonConfigService.updateLastScan();
|
daemonConfigService.updateLastScan();
|
||||||
|
}
|
||||||
if (newVideos > 0) {
|
if (newVideos > 0) {
|
||||||
cacheService.flush();
|
cacheService.flush();
|
||||||
log.info("Scan completed: {} new videos", newVideos);
|
log.info("Scan completed: {} new videos", newVideos);
|
||||||
@@ -55,8 +73,12 @@ public class DaemonScheduler {
|
|||||||
Instant lastProcess = config.getLastProcessAt() != null ? config.getLastProcessAt().toInstant() : null;
|
Instant lastProcess = config.getLastProcessAt() != null ? config.getLastProcessAt().toInstant() : null;
|
||||||
if (lastProcess == null || Instant.now().isAfter(lastProcess.plus(config.getProcessIntervalMin(), ChronoUnit.MINUTES))) {
|
if (lastProcess == null || Instant.now().isAfter(lastProcess.plus(config.getProcessIntervalMin(), ChronoUnit.MINUTES))) {
|
||||||
log.info("Running scheduled video processing (limit={})...", config.getProcessLimit());
|
log.info("Running scheduled video processing (limit={})...", config.getProcessLimit());
|
||||||
int restaurants = pipelineService.processPending(config.getProcessLimit());
|
int restaurants = 0;
|
||||||
|
try {
|
||||||
|
restaurants = pipelineService.processPending(config.getProcessLimit());
|
||||||
|
} finally {
|
||||||
daemonConfigService.updateLastProcess();
|
daemonConfigService.updateLastProcess();
|
||||||
|
}
|
||||||
if (restaurants > 0) {
|
if (restaurants > 0) {
|
||||||
cacheService.flush();
|
cacheService.flush();
|
||||||
log.info("Processing completed: {} restaurants extracted", restaurants);
|
log.info("Processing completed: {} restaurants extracted", restaurants);
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ public class ExtractorService {
|
|||||||
%s
|
%s
|
||||||
- price_range: 가격대 (예: 1만원대, 2-3만원) (string | null)
|
- price_range: 가격대 (예: 1만원대, 2-3만원) (string | null)
|
||||||
- foods_mentioned: 언급된 대표 메뉴 (string[], 최대 10개, 우선순위 높은 순, 반드시 한글로 작성)
|
- foods_mentioned: 언급된 대표 메뉴 (string[], 최대 10개, 우선순위 높은 순, 반드시 한글로 작성)
|
||||||
- evaluation: 평가 내용 (string | null)
|
- evaluation: 평가 내용을 100자 이내로 요약 (string | null)
|
||||||
- guests: 함께한 게스트 (string[])
|
- guests: 함께한 게스트 (string[])
|
||||||
|
|
||||||
영상 제목: {title}
|
영상 제목: {title}
|
||||||
@@ -62,6 +62,10 @@ public class ExtractorService {
|
|||||||
*/
|
*/
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public ExtractionResult extractRestaurants(String title, String transcript, String customPrompt) {
|
public ExtractionResult extractRestaurants(String title, String transcript, String customPrompt) {
|
||||||
|
// #292 — transcript null/blank 가드 (NPE 방지)
|
||||||
|
if (transcript == null || transcript.isBlank()) {
|
||||||
|
return new ExtractionResult(List.of(), "");
|
||||||
|
}
|
||||||
// Truncate very long transcripts
|
// Truncate very long transcripts
|
||||||
if (transcript.length() > 8000) {
|
if (transcript.length() > 8000) {
|
||||||
transcript = transcript.substring(0, 7000) + "\n...(중략)...\n" + transcript.substring(transcript.length() - 1000);
|
transcript = transcript.substring(0, 7000) + "\n...(중략)...\n" + transcript.substring(transcript.length() - 1000);
|
||||||
|
|||||||
@@ -131,6 +131,42 @@ public class GeocodingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Korean address into region format "나라|시/도|구/군".
|
||||||
|
* Example: "대한민국 서울특별시 강남구 역삼동 123" → "한국|서울|강남구"
|
||||||
|
*/
|
||||||
|
public static String parseRegionFromAddress(String address) {
|
||||||
|
if (address == null || address.isBlank()) return null;
|
||||||
|
String[] parts = address.split("\\s+");
|
||||||
|
String country = "";
|
||||||
|
String city = "";
|
||||||
|
String district = "";
|
||||||
|
|
||||||
|
for (String p : parts) {
|
||||||
|
if (p.equals("대한민국") || p.equals("South Korea")) {
|
||||||
|
country = "한국";
|
||||||
|
} else if (p.endsWith("특별시") || p.endsWith("광역시") || p.endsWith("특별자치시")) {
|
||||||
|
city = p.replace("특별시", "").replace("광역시", "").replace("특별자치시", "");
|
||||||
|
} else if (p.endsWith("도") && !p.endsWith("동") && p.length() <= 5) {
|
||||||
|
city = p;
|
||||||
|
} else if (p.endsWith("구") || p.endsWith("군") || (p.endsWith("시") && !city.isEmpty())) {
|
||||||
|
if (district.isEmpty()) district = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (country.isEmpty() && !city.isEmpty()) country = "한국";
|
||||||
|
if (country.isEmpty()) return null;
|
||||||
|
// #292 — 빈 토큰은 region 문자열에 포함시키지 않는다(`한국||구` 형식 방지).
|
||||||
|
StringBuilder sb = new StringBuilder(country);
|
||||||
|
if (!city.isEmpty()) {
|
||||||
|
sb.append('|').append(city);
|
||||||
|
if (!district.isEmpty()) sb.append('|').append(district);
|
||||||
|
} else if (!district.isEmpty()) {
|
||||||
|
// city 없이 district만 있는 경우는 정확도 낮으므로 무시
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
private Map<String, Object> geocode(String query) {
|
private Map<String, Object> geocode(String query) {
|
||||||
try {
|
try {
|
||||||
String response = webClient.get()
|
String response = webClient.get()
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.tasteby.service;
|
||||||
|
|
||||||
|
import com.tasteby.domain.Memo;
|
||||||
|
import com.tasteby.mapper.MemoMapper;
|
||||||
|
import com.tasteby.util.IdGenerator;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class MemoService {
|
||||||
|
|
||||||
|
private final MemoMapper mapper;
|
||||||
|
|
||||||
|
public MemoService(MemoMapper mapper) {
|
||||||
|
this.mapper = mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Memo findByUserAndRestaurant(String userId, String restaurantId) {
|
||||||
|
return mapper.findByUserAndRestaurant(userId, restaurantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Memo upsert(String userId, String restaurantId, Double rating, String memoText, LocalDate visitedAt) {
|
||||||
|
String visitedStr = visitedAt != null ? visitedAt.toString() : null;
|
||||||
|
// #294 — 동시성 가드: 사전 SELECT → 분기 INSERT/UPDATE 패턴은 두 트랜잭션이 동시에 미존재
|
||||||
|
// 판정 후 둘 다 INSERT → UNIQUE 충돌(500). INSERT 우선 시도 후 DuplicateKeyException 시 UPDATE.
|
||||||
|
Memo existing = mapper.findByUserAndRestaurant(userId, restaurantId);
|
||||||
|
if (existing != null) {
|
||||||
|
mapper.updateMemo(userId, restaurantId, rating, memoText, visitedStr);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
mapper.insertMemo(IdGenerator.newId(), userId, restaurantId, rating, memoText, visitedStr);
|
||||||
|
} catch (DuplicateKeyException e) {
|
||||||
|
// 동시 INSERT 충돌 → UPDATE로 폴백
|
||||||
|
mapper.updateMemo(userId, restaurantId, rating, memoText, visitedStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mapper.findByUserAndRestaurant(userId, restaurantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean delete(String userId, String restaurantId) {
|
||||||
|
return mapper.deleteMemo(userId, restaurantId) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Memo> findByUser(String userId) {
|
||||||
|
return mapper.findByUser(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import org.springframework.beans.factory.annotation.Value;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -45,6 +46,8 @@ public class OciGenAiService {
|
|||||||
|
|
||||||
private final ObjectMapper mapper;
|
private final ObjectMapper mapper;
|
||||||
private ConfigFileAuthenticationDetailsProvider authProvider;
|
private ConfigFileAuthenticationDetailsProvider authProvider;
|
||||||
|
private GenerativeAiInferenceClient chatClient;
|
||||||
|
private GenerativeAiInferenceClient embedClient;
|
||||||
|
|
||||||
public OciGenAiService(ObjectMapper mapper) {
|
public OciGenAiService(ObjectMapper mapper) {
|
||||||
this.mapper = mapper;
|
this.mapper = mapper;
|
||||||
@@ -55,21 +58,27 @@ public class OciGenAiService {
|
|||||||
try {
|
try {
|
||||||
ConfigFileReader.ConfigFile configFile = ConfigFileReader.parseDefault();
|
ConfigFileReader.ConfigFile configFile = ConfigFileReader.parseDefault();
|
||||||
authProvider = new ConfigFileAuthenticationDetailsProvider(configFile);
|
authProvider = new ConfigFileAuthenticationDetailsProvider(configFile);
|
||||||
log.info("OCI GenAI auth configured");
|
chatClient = GenerativeAiInferenceClient.builder()
|
||||||
|
.endpoint(chatEndpoint).build(authProvider);
|
||||||
|
embedClient = GenerativeAiInferenceClient.builder()
|
||||||
|
.endpoint(embedEndpoint).build(authProvider);
|
||||||
|
log.info("OCI GenAI auth configured (clients initialized)");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("OCI config not found, GenAI features disabled: {}", e.getMessage());
|
log.warn("OCI config not found, GenAI features disabled: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
public void destroy() {
|
||||||
|
if (chatClient != null) chatClient.close();
|
||||||
|
if (embedClient != null) embedClient.close();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call OCI GenAI LLM (Chat).
|
* Call OCI GenAI LLM (Chat).
|
||||||
*/
|
*/
|
||||||
public String chat(String prompt, int maxTokens) {
|
public String chat(String prompt, int maxTokens) {
|
||||||
if (authProvider == null) throw new IllegalStateException("OCI GenAI not configured");
|
if (chatClient == null) throw new IllegalStateException("OCI GenAI not configured");
|
||||||
|
|
||||||
try (var client = GenerativeAiInferenceClient.builder()
|
|
||||||
.endpoint(chatEndpoint)
|
|
||||||
.build(authProvider)) {
|
|
||||||
|
|
||||||
var textContent = TextContent.builder().text(prompt).build();
|
var textContent = TextContent.builder().text(prompt).build();
|
||||||
var userMessage = UserMessage.builder().content(List.of(textContent)).build();
|
var userMessage = UserMessage.builder().content(List.of(textContent)).build();
|
||||||
@@ -86,7 +95,7 @@ public class OciGenAiService {
|
|||||||
.chatRequest(chatRequest)
|
.chatRequest(chatRequest)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
ChatResponse response = client.chat(
|
ChatResponse response = chatClient.chat(
|
||||||
ChatRequest.builder().chatDetails(chatDetails).build());
|
ChatRequest.builder().chatDetails(chatDetails).build());
|
||||||
|
|
||||||
var chatResult = (GenericChatResponse) response.getChatResult().getChatResponse();
|
var chatResult = (GenericChatResponse) response.getChatResult().getChatResponse();
|
||||||
@@ -94,7 +103,6 @@ public class OciGenAiService {
|
|||||||
var content = ((TextContent) choice.getMessage().getContent().get(0)).getText();
|
var content = ((TextContent) choice.getMessage().getContent().get(0)).getText();
|
||||||
return content.trim();
|
return content.trim();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate embeddings for a list of texts.
|
* Generate embeddings for a list of texts.
|
||||||
@@ -111,9 +119,7 @@ public class OciGenAiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private List<List<Double>> embedBatch(List<String> texts) {
|
private List<List<Double>> embedBatch(List<String> texts) {
|
||||||
try (var client = GenerativeAiInferenceClient.builder()
|
if (embedClient == null) throw new IllegalStateException("OCI GenAI not configured");
|
||||||
.endpoint(embedEndpoint)
|
|
||||||
.build(authProvider)) {
|
|
||||||
|
|
||||||
var embedDetails = EmbedTextDetails.builder()
|
var embedDetails = EmbedTextDetails.builder()
|
||||||
.inputs(texts)
|
.inputs(texts)
|
||||||
@@ -122,7 +128,7 @@ public class OciGenAiService {
|
|||||||
.inputType(EmbedTextDetails.InputType.SearchDocument)
|
.inputType(EmbedTextDetails.InputType.SearchDocument)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
EmbedTextResponse response = client.embedText(
|
EmbedTextResponse response = embedClient.embedText(
|
||||||
EmbedTextRequest.builder().embedTextDetails(embedDetails).build());
|
EmbedTextRequest.builder().embedTextDetails(embedDetails).build());
|
||||||
|
|
||||||
return response.getEmbedTextResult().getEmbeddings()
|
return response.getEmbedTextResult().getEmbeddings()
|
||||||
@@ -130,7 +136,6 @@ public class OciGenAiService {
|
|||||||
.map(emb -> emb.stream().map(Number::doubleValue).toList())
|
.map(emb -> emb.stream().map(Number::doubleValue).toList())
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse LLM response as JSON (handles markdown code blocks, truncated arrays, etc.)
|
* Parse LLM response as JSON (handles markdown code blocks, truncated arrays, etc.)
|
||||||
@@ -145,26 +150,25 @@ public class OciGenAiService {
|
|||||||
return mapper.readValue(raw, Object.class);
|
return mapper.readValue(raw, Object.class);
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception ignored) {}
|
||||||
|
|
||||||
// Try to recover truncated array
|
// #326 — Recover truncated array. Brace depth counter로 단일 패스 O(N).
|
||||||
|
// 이전: 각 idx에서 end를 1씩 늘려가며 매번 readValue → O(N²) + 예외 스택트레이스 양산.
|
||||||
if (raw.trim().startsWith("[")) {
|
if (raw.trim().startsWith("[")) {
|
||||||
List<Object> items = new ArrayList<>();
|
List<Object> items = new ArrayList<>();
|
||||||
int idx = raw.indexOf('[') + 1;
|
int idx = raw.indexOf('[') + 1;
|
||||||
while (idx < raw.length()) {
|
while (idx < raw.length()) {
|
||||||
while (idx < raw.length() && " \t\n\r,".indexOf(raw.charAt(idx)) >= 0) idx++;
|
while (idx < raw.length() && " \t\n\r,".indexOf(raw.charAt(idx)) >= 0) idx++;
|
||||||
if (idx >= raw.length() || raw.charAt(idx) == ']') break;
|
if (idx >= raw.length() || raw.charAt(idx) == ']') break;
|
||||||
|
if (raw.charAt(idx) != '{') break; // 객체 시작이 아니면 복구 중단
|
||||||
|
|
||||||
// Try to parse next object
|
int end = findObjectEnd(raw, idx);
|
||||||
boolean found = false;
|
if (end < 0) break; // 잘린 객체 — 거기서 멈춤
|
||||||
for (int end = idx + 1; end <= raw.length(); end++) {
|
|
||||||
try {
|
try {
|
||||||
Object obj = mapper.readValue(raw.substring(idx, end), Object.class);
|
Object obj = mapper.readValue(raw.substring(idx, end + 1), Object.class);
|
||||||
items.add(obj);
|
items.add(obj);
|
||||||
idx = end;
|
} catch (Exception ignored2) {
|
||||||
found = true;
|
break; // 불가해 객체 — 멈춤
|
||||||
break;
|
|
||||||
} catch (Exception ignored2) {}
|
|
||||||
}
|
}
|
||||||
if (!found) break;
|
idx = end + 1;
|
||||||
}
|
}
|
||||||
if (!items.isEmpty()) {
|
if (!items.isEmpty()) {
|
||||||
log.info("Recovered {} items from truncated JSON", items.size());
|
log.info("Recovered {} items from truncated JSON", items.size());
|
||||||
@@ -174,4 +178,27 @@ public class OciGenAiService {
|
|||||||
|
|
||||||
throw new RuntimeException("JSON parse failed: " + raw.substring(0, Math.min(80, raw.length())));
|
throw new RuntimeException("JSON parse failed: " + raw.substring(0, Math.min(80, raw.length())));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #326 — JSON 객체 시작 위치(`{`)에서 매칭되는 닫는 `}` 인덱스를 반환.
|
||||||
|
* 문자열 안의 `{` `}`와 escape는 무시. 매칭 못 찾으면 -1.
|
||||||
|
*/
|
||||||
|
private static int findObjectEnd(String raw, int start) {
|
||||||
|
int depth = 0;
|
||||||
|
boolean inString = false;
|
||||||
|
boolean escaped = false;
|
||||||
|
for (int i = start; i < raw.length(); i++) {
|
||||||
|
char c = raw.charAt(i);
|
||||||
|
if (escaped) { escaped = false; continue; }
|
||||||
|
if (c == '\\') { escaped = true; continue; }
|
||||||
|
if (c == '"') { inString = !inString; continue; }
|
||||||
|
if (inString) continue;
|
||||||
|
if (c == '{') depth++;
|
||||||
|
else if (c == '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth == 0) return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public class PipelineService {
|
|||||||
private final VideoService videoService;
|
private final VideoService videoService;
|
||||||
private final VectorService vectorService;
|
private final VectorService vectorService;
|
||||||
private final CacheService cacheService;
|
private final CacheService cacheService;
|
||||||
|
private final RestaurantVerifyService verifyService;
|
||||||
|
|
||||||
public PipelineService(YouTubeService youTubeService,
|
public PipelineService(YouTubeService youTubeService,
|
||||||
ExtractorService extractorService,
|
ExtractorService extractorService,
|
||||||
@@ -35,7 +36,8 @@ public class PipelineService {
|
|||||||
RestaurantService restaurantService,
|
RestaurantService restaurantService,
|
||||||
VideoService videoService,
|
VideoService videoService,
|
||||||
VectorService vectorService,
|
VectorService vectorService,
|
||||||
CacheService cacheService) {
|
CacheService cacheService,
|
||||||
|
RestaurantVerifyService verifyService) {
|
||||||
this.youTubeService = youTubeService;
|
this.youTubeService = youTubeService;
|
||||||
this.extractorService = extractorService;
|
this.extractorService = extractorService;
|
||||||
this.geocodingService = geocodingService;
|
this.geocodingService = geocodingService;
|
||||||
@@ -43,6 +45,7 @@ public class PipelineService {
|
|||||||
this.videoService = videoService;
|
this.videoService = videoService;
|
||||||
this.vectorService = vectorService;
|
this.vectorService = vectorService;
|
||||||
this.cacheService = cacheService;
|
this.cacheService = cacheService;
|
||||||
|
this.verifyService = verifyService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,6 +87,9 @@ public class PipelineService {
|
|||||||
String videoDbId = (String) video.get("id");
|
String videoDbId = (String) video.get("id");
|
||||||
String title = (String) video.get("title");
|
String title = (String) video.get("title");
|
||||||
|
|
||||||
|
// #292 — 외부 가시성을 위해 진입 시 processing 전이 (이미 processing이면 no-op)
|
||||||
|
updateVideoStatus(videoDbId, "processing", null, null);
|
||||||
|
|
||||||
var result = extractorService.extractRestaurants(title, transcript, customPrompt);
|
var result = extractorService.extractRestaurants(title, transcript, customPrompt);
|
||||||
if (result.restaurants().isEmpty()) {
|
if (result.restaurants().isEmpty()) {
|
||||||
updateVideoStatus(videoDbId, "done", null, result.rawResponse());
|
updateVideoStatus(videoDbId, "done", null, result.rawResponse());
|
||||||
@@ -102,18 +108,26 @@ public class PipelineService {
|
|||||||
// Build upsert data
|
// Build upsert data
|
||||||
var data = new HashMap<String, Object>();
|
var data = new HashMap<String, Object>();
|
||||||
data.put("name", name);
|
data.put("name", name);
|
||||||
data.put("address", geo != null ? geo.get("formatted_address") : restData.get("address"));
|
|
||||||
data.put("region", restData.get("region"));
|
data.put("region", restData.get("region"));
|
||||||
data.put("latitude", geo != null ? geo.get("latitude") : null);
|
|
||||||
data.put("longitude", geo != null ? geo.get("longitude") : null);
|
|
||||||
data.put("cuisine_type", restData.get("cuisine_type"));
|
data.put("cuisine_type", restData.get("cuisine_type"));
|
||||||
data.put("price_range", restData.get("price_range"));
|
data.put("price_range", restData.get("price_range"));
|
||||||
data.put("google_place_id", geo != null ? geo.get("google_place_id") : null);
|
// #292 — geocode 실패(geo==null) 시 좌표/주소/place_id 등 기존 값 보존하기 위해
|
||||||
data.put("phone", geo != null ? geo.get("phone") : null);
|
// null을 명시적으로 put하지 않는다. upsert 측에서 누락 컬럼은 그대로 유지.
|
||||||
data.put("website", geo != null ? geo.get("website") : null);
|
if (geo != null) {
|
||||||
data.put("business_status", geo != null ? geo.get("business_status") : null);
|
data.put("address", geo.get("formatted_address"));
|
||||||
data.put("rating", geo != null ? geo.get("rating") : null);
|
data.put("latitude", geo.get("latitude"));
|
||||||
data.put("rating_count", geo != null ? geo.get("rating_count") : null);
|
data.put("longitude", geo.get("longitude"));
|
||||||
|
data.put("google_place_id", geo.get("google_place_id"));
|
||||||
|
data.put("phone", geo.get("phone"));
|
||||||
|
data.put("website", geo.get("website"));
|
||||||
|
data.put("business_status", geo.get("business_status"));
|
||||||
|
data.put("rating", geo.get("rating"));
|
||||||
|
data.put("rating_count", geo.get("rating_count"));
|
||||||
|
} else {
|
||||||
|
// geocode 실패한 첫 등록 케이스에서 최소한의 주소(LLM이 추출한 원시값)는 저장
|
||||||
|
Object rawAddr = restData.get("address");
|
||||||
|
if (rawAddr != null) data.put("address", rawAddr);
|
||||||
|
}
|
||||||
|
|
||||||
String restId = restaurantService.upsert(data);
|
String restId = restaurantService.upsert(data);
|
||||||
|
|
||||||
@@ -150,6 +164,9 @@ public class PipelineService {
|
|||||||
|
|
||||||
count++;
|
count++;
|
||||||
log.info("Saved restaurant: {} (geocoded={})", name, geo != null);
|
log.info("Saved restaurant: {} (geocoded={})", name, geo != null);
|
||||||
|
|
||||||
|
// #322 — 등록 직후 비동기 LLM 검증
|
||||||
|
verifyService.verifyAsync(restId);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateVideoStatus(videoDbId, "done", null, result.rawResponse());
|
updateVideoStatus(videoDbId, "done", null, result.rawResponse());
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.tasteby.service;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #337 — IP 기반 레이트리밋 (방문 카운트 어뷰즈 차단).
|
||||||
|
*
|
||||||
|
* 단순 Redis SETIFABSENT(SET NX EX) 패턴:
|
||||||
|
* - 첫 호출 시 키 등록 + TTL → 허용
|
||||||
|
* - TTL 동안 다음 호출은 키 존재로 차단
|
||||||
|
*
|
||||||
|
* Redis 다운 시 fail-open (true 반환) — 사용자 페이지 로드 우선.
|
||||||
|
* 멀티 파드 + Redis 단일 인스턴스 환경에서 자연스럽게 동작.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class RateLimitService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(RateLimitService.class);
|
||||||
|
private static final String PREFIX = "ratelimit:visit:";
|
||||||
|
|
||||||
|
private final StringRedisTemplate redis;
|
||||||
|
|
||||||
|
@Value("${app.rate-limit.visit-window-seconds:60}")
|
||||||
|
private long visitWindowSeconds;
|
||||||
|
|
||||||
|
public RateLimitService(StringRedisTemplate redis) {
|
||||||
|
this.redis = redis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 IP의 visit 호출 허용 여부.
|
||||||
|
* @return true = 허용 (첫 호출 또는 윈도우 만료), false = 차단 (윈도우 안 재호출)
|
||||||
|
*/
|
||||||
|
public boolean tryConsume(String ipKey) {
|
||||||
|
try {
|
||||||
|
String key = PREFIX + ipKey;
|
||||||
|
Boolean ok = redis.opsForValue().setIfAbsent(key, "1", Duration.ofSeconds(visitWindowSeconds));
|
||||||
|
return Boolean.TRUE.equals(ok);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// fail-open: Redis 문제로 통계가 약간 부풀어도 사용자 영향 X
|
||||||
|
log.debug("RateLimit error (fail-open): {}", e.getMessage());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,11 +21,54 @@ public class RestaurantService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel) {
|
public List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel) {
|
||||||
List<Restaurant> restaurants = mapper.findAll(limit, offset, cuisine, region, channel);
|
return findAll(limit, offset, cuisine, region, channel, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel, boolean includeHidden) {
|
||||||
|
List<Restaurant> restaurants = mapper.findAll(limit, offset, cuisine, region, channel, includeHidden);
|
||||||
enrichRestaurants(restaurants);
|
enrichRestaurants(restaurants);
|
||||||
return restaurants;
|
return restaurants;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #322 — 검증 상태 갱신
|
||||||
|
public void markHidden(String id, String reason) {
|
||||||
|
mapper.updateVerification(id, 1, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markVerifiedClean(String id) {
|
||||||
|
mapper.updateVerification(id, 0, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clearHidden(String id) {
|
||||||
|
mapper.clearHidden(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Restaurant> findUnverified(int limit) {
|
||||||
|
return mapper.findUnverified(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int countUnverified() {
|
||||||
|
return mapper.countUnverified();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Restaurant> findWithoutTabling() {
|
||||||
|
return mapper.findWithoutTabling();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Restaurant> findWithoutCatchtable() {
|
||||||
|
return mapper.findWithoutCatchtable();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void resetTablingUrls() {
|
||||||
|
mapper.resetTablingUrls();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void resetCatchtableUrls() {
|
||||||
|
mapper.resetCatchtableUrls();
|
||||||
|
}
|
||||||
|
|
||||||
public Restaurant findById(String id) {
|
public Restaurant findById(String id) {
|
||||||
Restaurant restaurant = mapper.findById(id);
|
Restaurant restaurant = mapper.findById(id);
|
||||||
if (restaurant == null) return null;
|
if (restaurant == null) return null;
|
||||||
@@ -99,7 +142,8 @@ public class RestaurantService {
|
|||||||
String id = IdGenerator.newId();
|
String id = IdGenerator.newId();
|
||||||
String foodsJson = foods != null ? JsonUtil.toJson(foods) : null;
|
String foodsJson = foods != null ? JsonUtil.toJson(foods) : null;
|
||||||
String guestsJson = guests != null ? JsonUtil.toJson(guests) : null;
|
String guestsJson = guests != null ? JsonUtil.toJson(guests) : null;
|
||||||
mapper.linkVideoRestaurant(id, videoId, restaurantId, foodsJson, evaluation, guestsJson);
|
String evalJson = JsonUtil.normalizeEvaluation(evaluation);
|
||||||
|
mapper.linkVideoRestaurant(id, videoId, restaurantId, foodsJson, evalJson, guestsJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void updateCuisineType(String id, String cuisineType) {
|
public void updateCuisineType(String id, String cuisineType) {
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package com.tasteby.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.tasteby.domain.Restaurant;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #322 LLM 검증으로 잘못된/프랜차이즈 식당 자동 숨김.
|
||||||
|
* 설계서: docs/design/322-restaurant-llm-verify/README.md
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class RestaurantVerifyService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(RestaurantVerifyService.class);
|
||||||
|
|
||||||
|
private final RestaurantService restaurantService;
|
||||||
|
private final OciGenAiService genAi;
|
||||||
|
private final ObjectMapper jsonMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
// 백필 시 LLM rate-limit 보호용 sleep (ms)
|
||||||
|
private static final long BACKFILL_SLEEP_MS = 200;
|
||||||
|
|
||||||
|
public RestaurantVerifyService(RestaurantService restaurantService, OciGenAiService genAi) {
|
||||||
|
this.restaurantService = restaurantService;
|
||||||
|
this.genAi = genAi;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Async
|
||||||
|
public void verifyAsync(String restaurantId) {
|
||||||
|
try {
|
||||||
|
verify(restaurantId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("verifyAsync failed for {}: {}", restaurantId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void verify(String restaurantId) {
|
||||||
|
Restaurant r = restaurantService.findById(restaurantId);
|
||||||
|
if (r == null) return;
|
||||||
|
VerifyResult result;
|
||||||
|
try {
|
||||||
|
String prompt = buildPrompt(r);
|
||||||
|
String response = genAi.chat(prompt, 120);
|
||||||
|
result = parseVerifyResponse(response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 안전한 기본값: LLM 실패 시 공개 유지(=hidden=0). verified_at은 미설정으로 남겨 재시도 가능.
|
||||||
|
log.warn("verify({}) LLM failed: {} — keeping visible", restaurantId, e.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyResult(restaurantId, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미검증(verified_at IS NULL) 식당을 배치로 검증. 운영 trigger 용.
|
||||||
|
* 반환: 이번 호출에서 처리한 개수.
|
||||||
|
*/
|
||||||
|
public int verifyAll(int batchSize) {
|
||||||
|
int total = 0;
|
||||||
|
List<Restaurant> batch;
|
||||||
|
while (!(batch = restaurantService.findUnverified(batchSize)).isEmpty()) {
|
||||||
|
for (Restaurant r : batch) {
|
||||||
|
try {
|
||||||
|
verify(r.getId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("verifyAll({}) failed: {}", r.getId(), e.getMessage());
|
||||||
|
}
|
||||||
|
total++;
|
||||||
|
try { Thread.sleep(BACKFILL_SLEEP_MS); } catch (InterruptedException ie) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (batch.size() < batchSize) break;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- pure helpers (tested separately) ----
|
||||||
|
|
||||||
|
String buildPrompt(Restaurant r) {
|
||||||
|
String foods = r.getFoodsMentioned() == null || r.getFoodsMentioned().isEmpty()
|
||||||
|
? "(없음)" : String.join(", ", r.getFoodsMentioned());
|
||||||
|
return "당신은 식당 데이터 큐레이터다. 다음 식당이 (1) 실제 운영 식당인지, (2) 흔한 프랜차이즈인지 판정하라.\n\n" +
|
||||||
|
"식당명: " + safe(r.getName()) + "\n" +
|
||||||
|
"주소: " + safe(r.getAddress()) + "\n" +
|
||||||
|
"지역: " + safe(r.getRegion()) + "\n" +
|
||||||
|
"음식 분류: " + safe(r.getCuisineType()) + "\n" +
|
||||||
|
"언급된 음식: " + foods + "\n\n" +
|
||||||
|
"응답 형식(JSON만, 다른 텍스트 없이):\n" +
|
||||||
|
"{\"valid\": true|false, \"is_franchise\": true|false, \"reason\": \"20자 이내\"}\n\n" +
|
||||||
|
"가이드:\n" +
|
||||||
|
"- valid=false: 식당 이름이 사람 이름, 영상 제목 일부, 일반 명사(\"점심\", \"맛집\"), " +
|
||||||
|
"영문 prefix(\"name:\", \"title:\") 등 분명히 식당이 아닌 경우.\n" +
|
||||||
|
"- is_franchise=true: 스타벅스, 맥도날드, 버거킹, 김밥천국, 본죽 등 전국 50개 이상 매장의 흔한 체인.\n" +
|
||||||
|
"- 판단이 모호하면 valid=true, is_franchise=false (보수적).";
|
||||||
|
}
|
||||||
|
|
||||||
|
VerifyResult parseVerifyResponse(String raw) {
|
||||||
|
if (raw == null) return VerifyResult.safeDefault();
|
||||||
|
String json = extractJson(raw);
|
||||||
|
if (json == null) return VerifyResult.safeDefault();
|
||||||
|
try {
|
||||||
|
JsonNode node = jsonMapper.readTree(json);
|
||||||
|
boolean valid = node.path("valid").asBoolean(true);
|
||||||
|
boolean isFranchise = node.path("is_franchise").asBoolean(false);
|
||||||
|
String reason = node.path("reason").asText("");
|
||||||
|
if (reason.length() > 100) reason = reason.substring(0, 100);
|
||||||
|
return new VerifyResult(valid, isFranchise, reason);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return VerifyResult.safeDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyResult(String id, VerifyResult r) {
|
||||||
|
if (!r.valid()) {
|
||||||
|
restaurantService.markHidden(id, truncate("not_restaurant: " + r.reason(), 120));
|
||||||
|
} else if (r.isFranchise()) {
|
||||||
|
restaurantService.markHidden(id, truncate("franchise: " + r.reason(), 120));
|
||||||
|
} else {
|
||||||
|
restaurantService.markVerifiedClean(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Pattern JSON_BLOCK = Pattern.compile("\\{[^{}]*\\}", Pattern.DOTALL);
|
||||||
|
|
||||||
|
private static String extractJson(String raw) {
|
||||||
|
// 우선 그대로 시도
|
||||||
|
String trimmed = raw.trim();
|
||||||
|
if (trimmed.startsWith("{") && trimmed.endsWith("}")) return trimmed;
|
||||||
|
// 마크다운 코드블록 또는 다른 텍스트에 감싸진 경우 정규식 추출
|
||||||
|
Matcher m = JSON_BLOCK.matcher(raw);
|
||||||
|
return m.find() ? m.group() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safe(String s) { return s == null ? "(미상)" : s; }
|
||||||
|
private static String truncate(String s, int max) { return s.length() <= max ? s : s.substring(0, max); }
|
||||||
|
|
||||||
|
public record VerifyResult(boolean valid, boolean isFranchise, String reason) {
|
||||||
|
public static VerifyResult safeDefault() { return new VerifyResult(true, false, "parse_failed"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import com.tasteby.domain.Review;
|
|||||||
import com.tasteby.mapper.ReviewMapper;
|
import com.tasteby.mapper.ReviewMapper;
|
||||||
import com.tasteby.util.IdGenerator;
|
import com.tasteby.util.IdGenerator;
|
||||||
import com.tasteby.util.JsonUtil;
|
import com.tasteby.util.JsonUtil;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -37,11 +38,13 @@ public class ReviewService {
|
|||||||
return mapper.findById(id);
|
return mapper.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional // #334 — 단일 SQL이지만 어노테이션 일관성
|
||||||
public boolean update(String reviewId, String userId, Double rating, String reviewText, LocalDate visitedAt) {
|
public boolean update(String reviewId, String userId, Double rating, String reviewText, LocalDate visitedAt) {
|
||||||
String visitedStr = visitedAt != null ? visitedAt.toString() : null;
|
String visitedStr = visitedAt != null ? visitedAt.toString() : null;
|
||||||
return mapper.updateReview(reviewId, userId, rating, reviewText, visitedStr) > 0;
|
return mapper.updateReview(reviewId, userId, rating, reviewText, visitedStr) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional // #334 — 단일 SQL이지만 어노테이션 일관성
|
||||||
public boolean delete(String reviewId, String userId) {
|
public boolean delete(String reviewId, String userId) {
|
||||||
return mapper.deleteReview(reviewId, userId) > 0;
|
return mapper.deleteReview(reviewId, userId) > 0;
|
||||||
}
|
}
|
||||||
@@ -60,10 +63,15 @@ public class ReviewService {
|
|||||||
if (existingId != null) {
|
if (existingId != null) {
|
||||||
mapper.deleteFavorite(userId, restaurantId);
|
mapper.deleteFavorite(userId, restaurantId);
|
||||||
return false;
|
return false;
|
||||||
} else {
|
|
||||||
mapper.insertFavorite(IdGenerator.newId(), userId, restaurantId);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
// #294 — 동시성 가드: 동시 INSERT 시 UNIQUE 충돌 → 한 쪽 500.
|
||||||
|
// INSERT 시도 후 DuplicateKeyException은 "이미 추가됨"으로 간주 (토글 의도는 ON).
|
||||||
|
try {
|
||||||
|
mapper.insertFavorite(IdGenerator.newId(), userId, restaurantId);
|
||||||
|
} catch (DuplicateKeyException ignored) {
|
||||||
|
// 다른 트랜잭션이 먼저 INSERT 함 — 결과는 어쨌든 즐겨찾기 ON.
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Restaurant> getUserFavorites(String userId) {
|
public List<Restaurant> getUserFavorites(String userId) {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.tasteby.service;
|
package com.tasteby.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.tasteby.domain.Restaurant;
|
import com.tasteby.domain.Restaurant;
|
||||||
import com.tasteby.mapper.SearchMapper;
|
import com.tasteby.mapper.SearchMapper;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
@@ -12,12 +15,17 @@ import java.util.*;
|
|||||||
public class SearchService {
|
public class SearchService {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(SearchService.class);
|
private static final Logger log = LoggerFactory.getLogger(SearchService.class);
|
||||||
|
private static final ObjectMapper JSON = new ObjectMapper();
|
||||||
|
private static final TypeReference<List<Restaurant>> LIST_TYPE = new TypeReference<>() {};
|
||||||
|
|
||||||
private final SearchMapper searchMapper;
|
private final SearchMapper searchMapper;
|
||||||
private final RestaurantService restaurantService;
|
private final RestaurantService restaurantService;
|
||||||
private final VectorService vectorService;
|
private final VectorService vectorService;
|
||||||
private final CacheService cache;
|
private final CacheService cache;
|
||||||
|
|
||||||
|
@Value("${app.search.max-distance:0.57}")
|
||||||
|
private double maxDistance;
|
||||||
|
|
||||||
public SearchService(SearchMapper searchMapper,
|
public SearchService(SearchMapper searchMapper,
|
||||||
RestaurantService restaurantService,
|
RestaurantService restaurantService,
|
||||||
VectorService vectorService,
|
VectorService vectorService,
|
||||||
@@ -33,8 +41,8 @@ public class SearchService {
|
|||||||
String cached = cache.getRaw(key);
|
String cached = cache.getRaw(key);
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
try {
|
try {
|
||||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
// #293 — ObjectMapper 재사용 (필드 static)
|
||||||
return mapper.readValue(cached, new com.fasterxml.jackson.core.type.TypeReference<List<Restaurant>>() {});
|
return JSON.readValue(cached, LIST_TYPE);
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception ignored) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,13 +52,20 @@ public class SearchService {
|
|||||||
case "hybrid" -> {
|
case "hybrid" -> {
|
||||||
var kw = keywordSearch(q, limit);
|
var kw = keywordSearch(q, limit);
|
||||||
var sem = semanticSearch(q, limit);
|
var sem = semanticSearch(q, limit);
|
||||||
|
// #293 — semantic 결과에도 channels 부착 (이전: keyword에만 부착되어 hybrid에서 sem 결과는 채널 누락)
|
||||||
|
if (!sem.isEmpty()) attachChannels(sem);
|
||||||
Set<String> seen = new HashSet<>();
|
Set<String> seen = new HashSet<>();
|
||||||
var merged = new ArrayList<Restaurant>();
|
var merged = new ArrayList<Restaurant>();
|
||||||
for (var r : kw) { if (seen.add(r.getId())) merged.add(r); }
|
for (var r : kw) { if (seen.add(r.getId())) merged.add(r); }
|
||||||
for (var r : sem) { if (seen.add(r.getId())) merged.add(r); }
|
for (var r : sem) { if (seen.add(r.getId())) merged.add(r); }
|
||||||
result = merged.size() > limit ? merged.subList(0, limit) : merged;
|
result = merged.size() > limit ? merged.subList(0, limit) : merged;
|
||||||
}
|
}
|
||||||
default -> result = keywordSearch(q, limit);
|
case "keyword" -> result = keywordSearch(q, limit);
|
||||||
|
default -> {
|
||||||
|
// #293 — 알 수 없는 mode는 silent fallback 대신 경고 로그
|
||||||
|
log.warn("Unknown search mode '{}', falling back to keyword", mode);
|
||||||
|
result = keywordSearch(q, limit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cache.set(key, result);
|
cache.set(key, result);
|
||||||
@@ -58,7 +73,10 @@ public class SearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private List<Restaurant> keywordSearch(String q, int limit) {
|
private List<Restaurant> keywordSearch(String q, int limit) {
|
||||||
String pattern = "%" + q + "%";
|
// #293 — LIKE 와일드카드 escape: 사용자 입력의 %, _, \ 를 리터럴로 처리.
|
||||||
|
// SQL에서는 ESCAPE '\\' 절을 사용 (SearchMapper.xml).
|
||||||
|
String escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
|
||||||
|
String pattern = "%" + escaped + "%";
|
||||||
List<Restaurant> results = searchMapper.keywordSearch(pattern, limit);
|
List<Restaurant> results = searchMapper.keywordSearch(pattern, limit);
|
||||||
if (!results.isEmpty()) {
|
if (!results.isEmpty()) {
|
||||||
attachChannels(results);
|
attachChannels(results);
|
||||||
@@ -68,7 +86,7 @@ public class SearchService {
|
|||||||
|
|
||||||
private List<Restaurant> semanticSearch(String q, int limit) {
|
private List<Restaurant> semanticSearch(String q, int limit) {
|
||||||
try {
|
try {
|
||||||
var similar = vectorService.searchSimilar(q, Math.max(30, limit * 3), 0.57);
|
var similar = vectorService.searchSimilar(q, Math.max(30, limit * 3), maxDistance);
|
||||||
if (similar.isEmpty()) return List.of();
|
if (similar.isEmpty()) return List.of();
|
||||||
|
|
||||||
Set<String> seen = new LinkedHashSet<>();
|
Set<String> seen = new LinkedHashSet<>();
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ package com.tasteby.service;
|
|||||||
|
|
||||||
import com.tasteby.domain.SiteVisitStats;
|
import com.tasteby.domain.SiteVisitStats;
|
||||||
import com.tasteby.mapper.StatsMapper;
|
import com.tasteby.mapper.StatsMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class StatsService {
|
public class StatsService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(StatsService.class);
|
||||||
|
|
||||||
private final StatsMapper mapper;
|
private final StatsMapper mapper;
|
||||||
|
|
||||||
public StatsService(StatsMapper mapper) {
|
public StatsService(StatsMapper mapper) {
|
||||||
@@ -14,7 +19,19 @@ public class StatsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void recordVisit() {
|
public void recordVisit() {
|
||||||
|
// #274 — 자정 경계 동시성: 두 트랜잭션이 동시에 'NOT MATCHED' 판정 → 둘 다 INSERT
|
||||||
|
// → PK/UNIQUE 충돌 시 한 쪽 500. 1회 재시도(다음엔 MATCHED → UPDATE 분기).
|
||||||
|
try {
|
||||||
mapper.recordVisit();
|
mapper.recordVisit();
|
||||||
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
log.debug("recordVisit conflict (midnight race), retry once: {}", e.getMessage());
|
||||||
|
try {
|
||||||
|
mapper.recordVisit();
|
||||||
|
} catch (DataIntegrityViolationException retryFail) {
|
||||||
|
// 두 번째 시도도 실패: 카운트 1건 손실은 수용 (운영 영향 미미)
|
||||||
|
log.warn("recordVisit double-conflict, dropping one count: {}", retryFail.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public SiteVisitStats getVisits() {
|
public SiteVisitStats getVisits() {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.tasteby.service;
|
package com.tasteby.service;
|
||||||
|
|
||||||
|
import com.tasteby.util.IdGenerator;
|
||||||
import com.tasteby.util.JsonUtil;
|
import com.tasteby.util.JsonUtil;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
@@ -27,12 +29,15 @@ public class VectorService {
|
|||||||
*/
|
*/
|
||||||
public List<Map<String, Object>> searchSimilar(String query, int topK, double maxDistance) {
|
public List<Map<String, Object>> searchSimilar(String query, int topK, double maxDistance) {
|
||||||
List<List<Double>> embeddings = genAi.embedTexts(List.of(query));
|
List<List<Double>> embeddings = genAi.embedTexts(List.of(query));
|
||||||
if (embeddings.isEmpty()) return List.of();
|
// #293 — embeddings 빈/null 가드 (NPE/IndexOutOfBoundsException 방지)
|
||||||
|
if (embeddings == null || embeddings.isEmpty()) return List.of();
|
||||||
|
List<Double> first = embeddings.getFirst();
|
||||||
|
if (first == null || first.isEmpty()) return List.of();
|
||||||
|
|
||||||
// Convert to float array for Oracle VECTOR type
|
// Convert to float array for Oracle VECTOR type
|
||||||
float[] queryVec = new float[embeddings.getFirst().size()];
|
float[] queryVec = new float[first.size()];
|
||||||
for (int i = 0; i < queryVec.length; i++) {
|
for (int i = 0; i < queryVec.length; i++) {
|
||||||
queryVec[i] = embeddings.getFirst().get(i).floatValue();
|
queryVec[i] = first.get(i).floatValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
String sql = """
|
String sql = """
|
||||||
@@ -61,6 +66,9 @@ public class VectorService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Save vector embeddings for a restaurant.
|
* Save vector embeddings for a restaurant.
|
||||||
|
*
|
||||||
|
* #331 — N개 청크를 단일 batchUpdate 호출로 처리 (이전: N+1 INSERT round-trip).
|
||||||
|
* UUID 생성은 IdGenerator.newId() 공통 유틸 사용 (인라인 변환 코드 제거).
|
||||||
*/
|
*/
|
||||||
public void saveRestaurantVectors(String restaurantId, List<String> chunks) {
|
public void saveRestaurantVectors(String restaurantId, List<String> chunks) {
|
||||||
if (chunks.isEmpty()) return;
|
if (chunks.isEmpty()) return;
|
||||||
@@ -72,19 +80,20 @@ public class VectorService {
|
|||||||
VALUES (:id, :rid, :chunk, :emb)
|
VALUES (:id, :rid, :chunk, :emb)
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
SqlParameterSource[] batch = new SqlParameterSource[chunks.size()];
|
||||||
for (int i = 0; i < chunks.size(); i++) {
|
for (int i = 0; i < chunks.size(); i++) {
|
||||||
String id = UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase();
|
List<Double> emb = embeddings.get(i);
|
||||||
float[] vec = new float[embeddings.get(i).size()];
|
float[] vec = new float[emb.size()];
|
||||||
for (int j = 0; j < vec.length; j++) {
|
for (int j = 0; j < vec.length; j++) {
|
||||||
vec[j] = embeddings.get(i).get(j).floatValue();
|
vec[j] = emb.get(j).floatValue();
|
||||||
}
|
}
|
||||||
var params = new MapSqlParameterSource();
|
batch[i] = new MapSqlParameterSource()
|
||||||
params.addValue("id", id);
|
.addValue("id", IdGenerator.newId())
|
||||||
params.addValue("rid", restaurantId);
|
.addValue("rid", restaurantId)
|
||||||
params.addValue("chunk", chunks.get(i));
|
.addValue("chunk", chunks.get(i))
|
||||||
params.addValue("emb", vec);
|
.addValue("emb", vec);
|
||||||
jdbc.update(sql, params);
|
|
||||||
}
|
}
|
||||||
|
jdbc.batchUpdate(sql, batch);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ public class VideoService {
|
|||||||
VideoDetail detail = mapper.findDetail(id);
|
VideoDetail detail = mapper.findDetail(id);
|
||||||
if (detail == null) return null;
|
if (detail == null) return null;
|
||||||
List<VideoRestaurantLink> restaurants = mapper.findVideoRestaurants(id);
|
List<VideoRestaurantLink> restaurants = mapper.findVideoRestaurants(id);
|
||||||
|
if (restaurants != null) {
|
||||||
|
restaurants.forEach(r -> r.setEvaluation(JsonUtil.normalizeEvaluation(r.getEvaluation())));
|
||||||
|
}
|
||||||
detail.setRestaurants(restaurants != null ? restaurants : List.of());
|
detail.setRestaurants(restaurants != null ? restaurants : List.of());
|
||||||
return detail;
|
return detail;
|
||||||
}
|
}
|
||||||
@@ -59,6 +62,7 @@ public class VideoService {
|
|||||||
mapper.cleanupOrphanRestaurant(restaurantId);
|
mapper.cleanupOrphanRestaurant(restaurantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
public int saveVideosBatch(String channelId, List<Map<String, Object>> videos) {
|
public int saveVideosBatch(String channelId, List<Map<String, Object>> videos) {
|
||||||
Set<String> existing = new HashSet<>(mapper.getExistingVideoIds(channelId));
|
Set<String> existing = new HashSet<>(mapper.getExistingVideoIds(channelId));
|
||||||
int saved = 0;
|
int saved = 0;
|
||||||
@@ -111,6 +115,22 @@ public class VideoService {
|
|||||||
return rows.stream().map(JsonUtil::lowerKeys).toList();
|
return rows.stream().map(JsonUtil::lowerKeys).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> findVideosByIds(List<String> ids) {
|
||||||
|
var rows = mapper.findVideosByIds(ids);
|
||||||
|
return rows.stream().map(JsonUtil::lowerKeys).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> findVideosForExtractByIds(List<String> ids) {
|
||||||
|
var rows = mapper.findVideosForExtractByIds(ids);
|
||||||
|
return rows.stream().map(row -> {
|
||||||
|
var r = JsonUtil.lowerKeys(row);
|
||||||
|
Object transcript = r.get("transcript_text");
|
||||||
|
r.put("transcript", JsonUtil.readClob(transcript));
|
||||||
|
r.remove("transcript_text");
|
||||||
|
return r;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
public void updateVideoRestaurantFields(String videoId, String restaurantId,
|
public void updateVideoRestaurantFields(String videoId, String restaurantId,
|
||||||
String foodsJson, String evaluation, String guestsJson) {
|
String foodsJson, String evaluation, String guestsJson) {
|
||||||
mapper.updateVideoRestaurantFields(videoId, restaurantId, foodsJson, evaluation, guestsJson);
|
mapper.updateVideoRestaurantFields(videoId, restaurantId, foodsJson, evaluation, guestsJson);
|
||||||
|
|||||||
@@ -50,10 +50,80 @@ public class YouTubeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch videos from a YouTube channel, page by page.
|
* Fetch videos from a YouTube channel using the uploads playlist (UC→UU).
|
||||||
* Returns all pages merged into one list.
|
* This returns ALL videos unlike the Search API which caps results.
|
||||||
|
* Falls back to Search API if playlist approach fails.
|
||||||
*/
|
*/
|
||||||
public List<Map<String, Object>> fetchChannelVideos(String channelId, String publishedAfter, boolean excludeShorts) {
|
public List<Map<String, Object>> fetchChannelVideos(String channelId, String publishedAfter, boolean excludeShorts) {
|
||||||
|
// Convert channel ID UC... → uploads playlist UU...
|
||||||
|
String uploadsPlaylistId = "UU" + channelId.substring(2);
|
||||||
|
List<Map<String, Object>> allVideos = new ArrayList<>();
|
||||||
|
String nextPage = null;
|
||||||
|
boolean stopPaging = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
String pageToken = nextPage;
|
||||||
|
String response = webClient.get()
|
||||||
|
.uri(uriBuilder -> {
|
||||||
|
var b = uriBuilder.path("/playlistItems")
|
||||||
|
.queryParam("key", apiKey)
|
||||||
|
.queryParam("playlistId", uploadsPlaylistId)
|
||||||
|
.queryParam("part", "snippet")
|
||||||
|
.queryParam("maxResults", 50);
|
||||||
|
if (pageToken != null) b.queryParam("pageToken", pageToken);
|
||||||
|
return b.build();
|
||||||
|
})
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(String.class)
|
||||||
|
.block(Duration.ofSeconds(30));
|
||||||
|
|
||||||
|
JsonNode data = mapper.readTree(response);
|
||||||
|
List<Map<String, Object>> pageVideos = new ArrayList<>();
|
||||||
|
|
||||||
|
for (JsonNode item : data.path("items")) {
|
||||||
|
JsonNode snippet = item.path("snippet");
|
||||||
|
String vid = snippet.path("resourceId").path("videoId").asText();
|
||||||
|
String publishedAt = snippet.path("publishedAt").asText();
|
||||||
|
|
||||||
|
// publishedAfter 필터: 이미 스캔한 영상 이후만
|
||||||
|
if (publishedAfter != null && publishedAt.compareTo(publishedAfter) <= 0) {
|
||||||
|
// 업로드 재생목록은 최신순이므로 이전 날짜 만나면 중단
|
||||||
|
stopPaging = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
pageVideos.add(Map.of(
|
||||||
|
"video_id", vid,
|
||||||
|
"title", snippet.path("title").asText(),
|
||||||
|
"published_at", publishedAt,
|
||||||
|
"url", "https://www.youtube.com/watch?v=" + vid
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (excludeShorts && !pageVideos.isEmpty()) {
|
||||||
|
pageVideos = filterShorts(pageVideos);
|
||||||
|
}
|
||||||
|
allVideos.addAll(pageVideos);
|
||||||
|
|
||||||
|
if (stopPaging) {
|
||||||
|
nextPage = null;
|
||||||
|
} else {
|
||||||
|
nextPage = data.has("nextPageToken") ? data.path("nextPageToken").asText() : null;
|
||||||
|
}
|
||||||
|
} while (nextPage != null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("PlaylistItems API failed for {}, falling back to Search API", channelId, e);
|
||||||
|
return fetchChannelVideosViaSearch(channelId, publishedAfter, excludeShorts);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allVideos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback: fetch via Search API (may not return all videos).
|
||||||
|
*/
|
||||||
|
private List<Map<String, Object>> fetchChannelVideosViaSearch(String channelId, String publishedAfter, boolean excludeShorts) {
|
||||||
List<Map<String, Object>> allVideos = new ArrayList<>();
|
List<Map<String, Object>> allVideos = new ArrayList<>();
|
||||||
String nextPage = null;
|
String nextPage = null;
|
||||||
|
|
||||||
@@ -98,7 +168,7 @@ public class YouTubeService {
|
|||||||
|
|
||||||
nextPage = data.has("nextPageToken") ? data.path("nextPageToken").asText() : null;
|
nextPage = data.has("nextPageToken") ? data.path("nextPageToken").asText() : null;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Failed to parse YouTube API response", e);
|
log.error("Failed to parse YouTube Search API response", e);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} while (nextPage != null);
|
} while (nextPage != null);
|
||||||
@@ -108,9 +178,16 @@ public class YouTubeService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter out YouTube Shorts (<=60s duration).
|
* Filter out YouTube Shorts (<=60s duration).
|
||||||
|
* YouTube /videos API accepts max 50 IDs per request, so we batch.
|
||||||
*/
|
*/
|
||||||
private List<Map<String, Object>> filterShorts(List<Map<String, Object>> videos) {
|
private List<Map<String, Object>> filterShorts(List<Map<String, Object>> videos) {
|
||||||
String ids = String.join(",", videos.stream().map(v -> (String) v.get("video_id")).toList());
|
Map<String, Integer> durations = new HashMap<>();
|
||||||
|
List<String> allIds = videos.stream().map(v -> (String) v.get("video_id")).toList();
|
||||||
|
|
||||||
|
for (int i = 0; i < allIds.size(); i += 50) {
|
||||||
|
List<String> batch = allIds.subList(i, Math.min(i + 50, allIds.size()));
|
||||||
|
String ids = String.join(",", batch);
|
||||||
|
try {
|
||||||
String response = webClient.get()
|
String response = webClient.get()
|
||||||
.uri(uriBuilder -> uriBuilder.path("/videos")
|
.uri(uriBuilder -> uriBuilder.path("/videos")
|
||||||
.queryParam("key", apiKey)
|
.queryParam("key", apiKey)
|
||||||
@@ -121,22 +198,21 @@ public class YouTubeService {
|
|||||||
.bodyToMono(String.class)
|
.bodyToMono(String.class)
|
||||||
.block(Duration.ofSeconds(30));
|
.block(Duration.ofSeconds(30));
|
||||||
|
|
||||||
try {
|
|
||||||
JsonNode data = mapper.readTree(response);
|
JsonNode data = mapper.readTree(response);
|
||||||
Map<String, Integer> durations = new HashMap<>();
|
|
||||||
for (JsonNode item : data.path("items")) {
|
for (JsonNode item : data.path("items")) {
|
||||||
String duration = item.path("contentDetails").path("duration").asText();
|
String duration = item.path("contentDetails").path("duration").asText();
|
||||||
durations.put(item.path("id").asText(), parseDuration(duration));
|
durations.put(item.path("id").asText(), parseDuration(duration));
|
||||||
}
|
}
|
||||||
return videos.stream()
|
|
||||||
.filter(v -> durations.getOrDefault(v.get("video_id"), 0) > 60)
|
|
||||||
.toList();
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Failed to filter shorts", e);
|
log.warn("Failed to fetch video durations for batch starting at {}", i, e);
|
||||||
return videos;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return videos.stream()
|
||||||
|
.filter(v -> durations.getOrDefault(v.get("video_id"), 61) > 60)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
private int parseDuration(String dur) {
|
private int parseDuration(String dur) {
|
||||||
Matcher m = DURATION_PATTERN.matcher(dur != null ? dur : "");
|
Matcher m = DURATION_PATTERN.matcher(dur != null ? dur : "");
|
||||||
if (!m.matches()) return 0;
|
if (!m.matches()) return 0;
|
||||||
@@ -202,22 +278,33 @@ public class YouTubeService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch transcript for a YouTube video.
|
* Fetch transcript for a YouTube video.
|
||||||
* Tries API first (fast), then falls back to Playwright browser extraction.
|
*
|
||||||
* @param mode "auto" = manual first then generated, "manual" = manual only, "generated" = generated only
|
* 흐름: (1) Playwright headed 브라우저 추출 → (2) 실패 시 youtube-transcript-api 폴백.
|
||||||
|
*
|
||||||
|
* <p>#325 — mode 인자 명세:
|
||||||
|
* <ul>
|
||||||
|
* <li>"auto" (기본): manual → generated 순서로 시도</li>
|
||||||
|
* <li>"manual": manual(사람이 쓴 자막)만</li>
|
||||||
|
* <li>"generated": 자동 생성 자막만</li>
|
||||||
|
* </ul>
|
||||||
|
* 주의: mode 인자는 <b>youtube-transcript-api 폴백 경로에서만 사용</b>됩니다.
|
||||||
|
* 브라우저 추출은 YouTube가 노출하는 자막 트랙 전체를 그대로 수신하므로 mode 무관.
|
||||||
|
*
|
||||||
|
* @param mode 위 설명 참조. null이면 "auto"로 간주.
|
||||||
*/
|
*/
|
||||||
public TranscriptResult getTranscript(String videoId, String mode) {
|
public TranscriptResult getTranscript(String videoId, String mode) {
|
||||||
if (mode == null) mode = "auto";
|
if (mode == null) mode = "auto";
|
||||||
|
|
||||||
// 1) Fast path: youtube-transcript-api
|
// 1) Playwright headed browser (봇 판정 회피)
|
||||||
TranscriptResult apiResult = getTranscriptApi(videoId, mode);
|
TranscriptResult browserResult = getTranscriptBrowser(videoId);
|
||||||
if (apiResult != null) return apiResult;
|
if (browserResult != null) return browserResult;
|
||||||
|
|
||||||
// 2) Fallback: Playwright browser
|
// 2) Fallback: youtube-transcript-api
|
||||||
log.warn("API failed for {}, trying Playwright browser", videoId);
|
log.warn("Browser failed for {}, trying API", videoId);
|
||||||
return getTranscriptBrowser(videoId);
|
return getTranscriptApi(videoId, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
private TranscriptResult getTranscriptApi(String videoId, String mode) {
|
public TranscriptResult getTranscriptApi(String videoId, String mode) {
|
||||||
TranscriptList transcriptList;
|
TranscriptList transcriptList;
|
||||||
try {
|
try {
|
||||||
transcriptList = transcriptApi.listTranscripts(videoId);
|
transcriptList = transcriptApi.listTranscripts(videoId);
|
||||||
@@ -262,39 +349,63 @@ public class YouTubeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Playwright browser fallback ───────────────────────────────────────────
|
// ─── Playwright browser ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch transcript using an existing Playwright Page (for bulk reuse).
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public TranscriptResult getTranscriptWithPage(Page page, String videoId) {
|
||||||
|
return fetchTranscriptFromPage(page, videoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Playwright browser + context + page for transcript fetching.
|
||||||
|
* Caller must close the returned resources (Playwright, Browser).
|
||||||
|
*/
|
||||||
|
public record BrowserSession(Playwright playwright, Browser browser, Page page) implements AutoCloseable {
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
try { browser.close(); } catch (Exception ignored) {}
|
||||||
|
try { playwright.close(); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public BrowserSession createBrowserSession() {
|
||||||
|
Playwright pw = Playwright.create();
|
||||||
|
Browser browser = pw.chromium().launch(new BrowserType.LaunchOptions()
|
||||||
|
.setHeadless(false)
|
||||||
|
.setArgs(List.of("--disable-blink-features=AutomationControlled")));
|
||||||
|
BrowserContext ctx = browser.newContext(new Browser.NewContextOptions()
|
||||||
|
.setLocale("ko-KR")
|
||||||
|
.setViewportSize(1280, 900));
|
||||||
|
loadCookies(ctx);
|
||||||
|
Page page = ctx.newPage();
|
||||||
|
page.addInitScript("Object.defineProperty(navigator, 'webdriver', {get: () => false})");
|
||||||
|
return new BrowserSession(pw, browser, page);
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private TranscriptResult getTranscriptBrowser(String videoId) {
|
private TranscriptResult getTranscriptBrowser(String videoId) {
|
||||||
try (Playwright pw = Playwright.create()) {
|
try (BrowserSession session = createBrowserSession()) {
|
||||||
BrowserType.LaunchOptions launchOpts = new BrowserType.LaunchOptions()
|
return fetchTranscriptFromPage(session.page(), videoId);
|
||||||
.setHeadless(false)
|
} catch (Exception e) {
|
||||||
.setArgs(List.of("--disable-blink-features=AutomationControlled"));
|
log.error("[TRANSCRIPT] Playwright failed for {}: {}", videoId, e.getMessage());
|
||||||
|
return null;
|
||||||
try (Browser browser = pw.chromium().launch(launchOpts)) {
|
}
|
||||||
Browser.NewContextOptions ctxOpts = new Browser.NewContextOptions()
|
}
|
||||||
.setLocale("ko-KR")
|
|
||||||
.setViewportSize(1280, 900);
|
|
||||||
|
|
||||||
BrowserContext ctx = browser.newContext(ctxOpts);
|
|
||||||
|
|
||||||
// Load YouTube cookies if available
|
|
||||||
loadCookies(ctx);
|
|
||||||
|
|
||||||
Page page = ctx.newPage();
|
|
||||||
|
|
||||||
// Hide webdriver flag to reduce bot detection
|
|
||||||
page.addInitScript("Object.defineProperty(navigator, 'webdriver', {get: () => false})");
|
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private TranscriptResult fetchTranscriptFromPage(Page page, String videoId) {
|
||||||
|
try {
|
||||||
log.info("[TRANSCRIPT] Opening YouTube page for {}", videoId);
|
log.info("[TRANSCRIPT] Opening YouTube page for {}", videoId);
|
||||||
page.navigate("https://www.youtube.com/watch?v=" + videoId,
|
page.navigate("https://www.youtube.com/watch?v=" + videoId,
|
||||||
new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED).setTimeout(30000));
|
new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED).setTimeout(30000));
|
||||||
page.waitForTimeout(5000);
|
page.waitForTimeout(3000);
|
||||||
|
|
||||||
// Skip ads if present
|
|
||||||
skipAds(page);
|
skipAds(page);
|
||||||
|
|
||||||
page.waitForTimeout(2000);
|
page.waitForTimeout(1000);
|
||||||
log.info("[TRANSCRIPT] Page loaded, looking for transcript button");
|
log.info("[TRANSCRIPT] Page loaded, looking for transcript button");
|
||||||
|
|
||||||
// Click "더보기" (expand description)
|
// Click "더보기" (expand description)
|
||||||
@@ -348,18 +459,17 @@ public class YouTubeService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for transcript segments to appear (max ~40s)
|
// Wait for transcript segments to appear (max ~15s)
|
||||||
page.waitForTimeout(3000);
|
page.waitForTimeout(2000);
|
||||||
for (int attempt = 0; attempt < 12; attempt++) {
|
for (int attempt = 0; attempt < 10; attempt++) {
|
||||||
page.waitForTimeout(3000);
|
page.waitForTimeout(1500);
|
||||||
Object count = page.evaluate(
|
Object count = page.evaluate(
|
||||||
"() => document.querySelectorAll('ytd-transcript-segment-renderer').length");
|
"() => document.querySelectorAll('ytd-transcript-segment-renderer').length");
|
||||||
int segCount = count instanceof Number n ? n.intValue() : 0;
|
int segCount = count instanceof Number n ? n.intValue() : 0;
|
||||||
log.info("[TRANSCRIPT] Wait {}s: {} segments", (attempt + 1) * 3 + 3, segCount);
|
log.info("[TRANSCRIPT] Wait {}s: {} segments", (attempt + 1) * 1.5 + 2, segCount);
|
||||||
if (segCount > 0) break;
|
if (segCount > 0) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select Korean if available
|
|
||||||
selectKorean(page);
|
selectKorean(page);
|
||||||
|
|
||||||
// Scroll transcript panel and collect segments
|
// Scroll transcript panel and collect segments
|
||||||
@@ -404,21 +514,30 @@ public class YouTubeService {
|
|||||||
|
|
||||||
log.warn("[TRANSCRIPT] No segments found via browser for {}", videoId);
|
log.warn("[TRANSCRIPT] No segments found via browser for {}", videoId);
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[TRANSCRIPT] Playwright failed for {}: {}", videoId, e.getMessage());
|
log.error("[TRANSCRIPT] Page fetch failed for {}: {}", videoId, e.getMessage());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void skipAds(Page page) {
|
private void skipAds(Page page) {
|
||||||
for (int i = 0; i < 12; i++) {
|
for (int i = 0; i < 30; i++) {
|
||||||
Object adStatus = page.evaluate("""
|
Object adStatus = page.evaluate("""
|
||||||
() => {
|
() => {
|
||||||
const skipBtn = document.querySelector('.ytp-skip-ad-button, .ytp-ad-skip-button, .ytp-ad-skip-button-modern, button.ytp-ad-skip-button-modern');
|
const skipBtn = document.querySelector('.ytp-skip-ad-button, .ytp-ad-skip-button, .ytp-ad-skip-button-modern, button.ytp-ad-skip-button-modern');
|
||||||
if (skipBtn) { skipBtn.click(); return 'skipped'; }
|
if (skipBtn) { skipBtn.click(); return 'skipped'; }
|
||||||
const adOverlay = document.querySelector('.ytp-ad-player-overlay, .ad-showing');
|
const adOverlay = document.querySelector('.ytp-ad-player-overlay, .ad-showing');
|
||||||
if (adOverlay) return 'playing';
|
if (adOverlay) {
|
||||||
|
// 광고 중: 뮤트 + 끝으로 이동 시도
|
||||||
|
const video = document.querySelector('video');
|
||||||
|
if (video) {
|
||||||
|
video.muted = true;
|
||||||
|
if (video.duration && isFinite(video.duration)) {
|
||||||
|
video.currentTime = video.duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'playing';
|
||||||
|
}
|
||||||
const adBadge = document.querySelector('.ytp-ad-text');
|
const adBadge = document.querySelector('.ytp-ad-text');
|
||||||
if (adBadge && adBadge.textContent) return 'badge';
|
if (adBadge && adBadge.textContent) return 'badge';
|
||||||
return 'none';
|
return 'none';
|
||||||
@@ -428,10 +547,10 @@ public class YouTubeService {
|
|||||||
if ("none".equals(status)) break;
|
if ("none".equals(status)) break;
|
||||||
log.info("[TRANSCRIPT] Ad detected: {}, waiting...", status);
|
log.info("[TRANSCRIPT] Ad detected: {}, waiting...", status);
|
||||||
if ("skipped".equals(status)) {
|
if ("skipped".equals(status)) {
|
||||||
page.waitForTimeout(2000);
|
page.waitForTimeout(1000);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
page.waitForTimeout(5000);
|
page.waitForTimeout(1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
25
backend-java/src/main/java/com/tasteby/util/BotDetector.java
Normal file
25
backend-java/src/main/java/com/tasteby/util/BotDetector.java
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package com.tasteby.util;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #337 — User-Agent 기반 봇 패턴 매칭.
|
||||||
|
*
|
||||||
|
* Googlebot / bingbot / facebookexternalhit / 일반 crawler/spider 등을 일괄 차단.
|
||||||
|
* 빈 UA는 봇으로 간주하지 않음(모바일 앱 등 정상 케이스 보호).
|
||||||
|
*/
|
||||||
|
public final class BotDetector {
|
||||||
|
|
||||||
|
private BotDetector() {}
|
||||||
|
|
||||||
|
// 일반적인 봇/크롤러 패턴. 케이스 무시.
|
||||||
|
private static final Pattern BOT_PATTERN = Pattern.compile(
|
||||||
|
"bot|crawler|spider|slurp|scrap|fetch|monitor|preview|lighthouse",
|
||||||
|
Pattern.CASE_INSENSITIVE
|
||||||
|
);
|
||||||
|
|
||||||
|
public static boolean isBot(String userAgent) {
|
||||||
|
if (userAgent == null || userAgent.isBlank()) return false;
|
||||||
|
return BOT_PATTERN.matcher(userAgent).find();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.tasteby.util;
|
||||||
|
|
||||||
|
import java.text.Normalizer;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #348 — 한국어 자모 분해(Unicode NFD) + Sørensen-Dice bigram 유사도.
|
||||||
|
*
|
||||||
|
* 음절 단위 Jaccard보다 짧은 한국어 이름에 정확. 예:
|
||||||
|
* similarity("스타벅스 강남", "스타벅스 강남점") ≈ 0.85+
|
||||||
|
* similarity("스타벅스 강남", "스타벅스 종로") ≈ 0.55~0.85
|
||||||
|
* similarity("스타벅스", "맥도날드") < 0.20
|
||||||
|
*
|
||||||
|
* 공백/구두점은 제거하고 소문자화한 뒤 NFD 분해.
|
||||||
|
*/
|
||||||
|
public final class HangulSimilarity {
|
||||||
|
|
||||||
|
private HangulSimilarity() {}
|
||||||
|
|
||||||
|
/** 공백/구두점 제거 + 소문자화 + NFD 분해(한글 음절 → 자모). */
|
||||||
|
public static String decompose(String s) {
|
||||||
|
if (s == null || s.isEmpty()) return "";
|
||||||
|
String stripped = s.replaceAll("[\\s·\\-_()()\\[\\]【】]", "").toLowerCase();
|
||||||
|
return Normalizer.normalize(stripped, Normalizer.Form.NFD);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sørensen-Dice 계수 (bigram multiset 기반). 0.0~1.0.
|
||||||
|
* 동일 문자열 → 1.0. 빈 입력 → 0.0.
|
||||||
|
*/
|
||||||
|
public static double similarity(String a, String b) {
|
||||||
|
String da = decompose(a);
|
||||||
|
String db = decompose(b);
|
||||||
|
if (da.isEmpty() || db.isEmpty()) return 0.0;
|
||||||
|
if (da.equals(db)) return 1.0;
|
||||||
|
|
||||||
|
// 포함 관계는 강한 신호로 1.0 처리 (기존 동작과 일관)
|
||||||
|
if (da.contains(db) || db.contains(da)) return 1.0;
|
||||||
|
|
||||||
|
if (da.length() < 2 || db.length() < 2) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Integer> bigramsA = bigrams(da);
|
||||||
|
Map<String, Integer> bigramsB = bigrams(db);
|
||||||
|
int common = 0;
|
||||||
|
for (var e : bigramsA.entrySet()) {
|
||||||
|
Integer countB = bigramsB.get(e.getKey());
|
||||||
|
if (countB != null) {
|
||||||
|
common += Math.min(e.getValue(), countB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int sizeA = da.length() - 1;
|
||||||
|
int sizeB = db.length() - 1;
|
||||||
|
return (2.0 * common) / (sizeA + sizeB);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Integer> bigrams(String s) {
|
||||||
|
Map<String, Integer> map = new HashMap<>();
|
||||||
|
for (int i = 0; i < s.length() - 1; i++) {
|
||||||
|
String gram = s.substring(i, i + 2);
|
||||||
|
map.merge(gram, 1, Integer::sum);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ app:
|
|||||||
expiration-days: 7
|
expiration-days: 7
|
||||||
|
|
||||||
cors:
|
cors:
|
||||||
allowed-origins: http://localhost:3000,http://localhost:3001,https://www.tasteby.net,https://tasteby.net
|
allowed-origins: http://localhost:3000,http://localhost:3001,https://www.tasteby.net,https://tasteby.net,https://dev.tasteby.net
|
||||||
|
|
||||||
oracle:
|
oracle:
|
||||||
wallet-path: ${ORACLE_WALLET:}
|
wallet-path: ${ORACLE_WALLET:}
|
||||||
@@ -59,6 +59,25 @@ app:
|
|||||||
cache:
|
cache:
|
||||||
ttl-seconds: 600
|
ttl-seconds: 600
|
||||||
|
|
||||||
|
search:
|
||||||
|
# #293 — 벡터 검색 cosine distance 임계값 (0.0=완전일치, 1.0=직교).
|
||||||
|
# 0.57은 cohere embed-v4 한국어 시맨틱 적합도 기준 경험값.
|
||||||
|
max-distance: ${SEARCH_MAX_DISTANCE:0.57}
|
||||||
|
|
||||||
|
rate-limit:
|
||||||
|
# #337 — 같은 IP에서 visit 카운트 허용 간격(초). 기본 60.
|
||||||
|
visit-window-seconds: ${VISIT_WINDOW_SECONDS:60}
|
||||||
|
|
||||||
|
build:
|
||||||
|
# #338 — 배포 시 deploy.sh가 env로 주입. dev에서는 dev/unknown.
|
||||||
|
version: ${APP_VERSION:dev}
|
||||||
|
commit: ${APP_COMMIT:unknown}
|
||||||
|
|
||||||
|
daemon:
|
||||||
|
# 인스턴스 차원 스케줄러 활성화. dev/prod가 같은 DB를 공유하므로
|
||||||
|
# dev .env에 DAEMON_ENABLED=false를 설정해 dev 폴링을 끄고 prod만 동작시킨다.
|
||||||
|
enabled: ${DAEMON_ENABLED:true}
|
||||||
|
|
||||||
mybatis:
|
mybatis:
|
||||||
mapper-locations: classpath:mybatis/mapper/*.xml
|
mapper-locations: classpath:mybatis/mapper/*.xml
|
||||||
config-location: classpath:mybatis/mybatis-config.xml
|
config-location: classpath:mybatis/mybatis-config.xml
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- #322 LLM 검증 — restaurants에 hidden/검증 컬럼 추가
|
||||||
|
ALTER TABLE restaurants ADD (
|
||||||
|
hidden NUMBER(1) DEFAULT 0 NOT NULL,
|
||||||
|
hidden_reason VARCHAR2(120),
|
||||||
|
verified_at TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_restaurants_hidden ON restaurants(hidden);
|
||||||
@@ -7,17 +7,20 @@
|
|||||||
<result property="channelId" column="channel_id"/>
|
<result property="channelId" column="channel_id"/>
|
||||||
<result property="channelName" column="channel_name"/>
|
<result property="channelName" column="channel_name"/>
|
||||||
<result property="titleFilter" column="title_filter"/>
|
<result property="titleFilter" column="title_filter"/>
|
||||||
|
<result property="description" column="description"/>
|
||||||
|
<result property="tags" column="tags"/>
|
||||||
|
<result property="sortOrder" column="sort_order"/>
|
||||||
<result property="videoCount" column="video_count"/>
|
<result property="videoCount" column="video_count"/>
|
||||||
<result property="lastVideoAt" column="last_video_at"/>
|
<result property="lastVideoAt" column="last_video_at"/>
|
||||||
</resultMap>
|
</resultMap>
|
||||||
|
|
||||||
<select id="findAllActive" resultMap="channelResultMap">
|
<select id="findAllActive" resultMap="channelResultMap">
|
||||||
SELECT c.id, c.channel_id, c.channel_name, c.title_filter,
|
SELECT c.id, c.channel_id, c.channel_name, c.title_filter, c.description, c.tags, c.sort_order,
|
||||||
(SELECT COUNT(*) FROM videos v WHERE v.channel_id = c.id) AS video_count,
|
(SELECT COUNT(*) FROM videos v WHERE v.channel_id = c.id) AS video_count,
|
||||||
(SELECT MAX(v.published_at) FROM videos v WHERE v.channel_id = c.id) AS last_video_at
|
(SELECT MAX(v.published_at) FROM videos v WHERE v.channel_id = c.id) AS last_video_at
|
||||||
FROM channels c
|
FROM channels c
|
||||||
WHERE c.is_active = 1
|
WHERE c.is_active = 1
|
||||||
ORDER BY c.channel_name
|
ORDER BY c.sort_order, c.channel_name
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="insert">
|
<insert id="insert">
|
||||||
@@ -35,8 +38,14 @@
|
|||||||
WHERE id = #{id} AND is_active = 1
|
WHERE id = #{id} AND is_active = 1
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
<update id="updateChannel">
|
||||||
|
UPDATE channels SET description = #{description}, tags = #{tags}, sort_order = #{sortOrder}
|
||||||
|
WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
<select id="findByChannelId" resultMap="channelResultMap">
|
<select id="findByChannelId" resultMap="channelResultMap">
|
||||||
SELECT id, channel_id, channel_name, title_filter
|
<!-- #295 — findAllActive와 동일하게 description/tags/sort_order까지 SELECT -->
|
||||||
|
SELECT id, channel_id, channel_name, title_filter, description, tags, sort_order
|
||||||
FROM channels
|
FROM channels
|
||||||
WHERE channel_id = #{channelId} AND is_active = 1
|
WHERE channel_id = #{channelId} AND is_active = 1
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.tasteby.mapper.MemoMapper">
|
||||||
|
|
||||||
|
<resultMap id="memoResultMap" type="com.tasteby.domain.Memo">
|
||||||
|
<id property="id" column="id"/>
|
||||||
|
<result property="userId" column="user_id"/>
|
||||||
|
<result property="restaurantId" column="restaurant_id"/>
|
||||||
|
<result property="rating" column="rating"/>
|
||||||
|
<result property="memoText" column="memo_text" typeHandler="com.tasteby.config.ClobTypeHandler"/>
|
||||||
|
<result property="visitedAt" column="visited_at"/>
|
||||||
|
<result property="createdAt" column="created_at"/>
|
||||||
|
<result property="updatedAt" column="updated_at"/>
|
||||||
|
<result property="restaurantName" column="restaurant_name"/>
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<select id="findByUserAndRestaurant" resultMap="memoResultMap">
|
||||||
|
SELECT id, user_id, restaurant_id, rating, memo_text,
|
||||||
|
visited_at, created_at, updated_at
|
||||||
|
FROM user_memos
|
||||||
|
WHERE user_id = #{userId} AND restaurant_id = #{restaurantId}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<insert id="insertMemo">
|
||||||
|
INSERT INTO user_memos (id, user_id, restaurant_id, rating, memo_text, visited_at)
|
||||||
|
VALUES (#{id}, #{userId}, #{restaurantId}, #{rating}, #{memoText},
|
||||||
|
<choose>
|
||||||
|
<when test="visitedAt != null">TO_DATE(#{visitedAt}, 'YYYY-MM-DD')</when>
|
||||||
|
<otherwise>NULL</otherwise>
|
||||||
|
</choose>)
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<update id="updateMemo">
|
||||||
|
UPDATE user_memos SET
|
||||||
|
rating = #{rating},
|
||||||
|
memo_text = #{memoText},
|
||||||
|
visited_at = <choose>
|
||||||
|
<when test="visitedAt != null">TO_DATE(#{visitedAt}, 'YYYY-MM-DD')</when>
|
||||||
|
<otherwise>NULL</otherwise>
|
||||||
|
</choose>,
|
||||||
|
updated_at = SYSTIMESTAMP
|
||||||
|
WHERE user_id = #{userId} AND restaurant_id = #{restaurantId}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<delete id="deleteMemo">
|
||||||
|
DELETE FROM user_memos WHERE user_id = #{userId} AND restaurant_id = #{restaurantId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<select id="findByUser" resultMap="memoResultMap">
|
||||||
|
SELECT m.id, m.user_id, m.restaurant_id, m.rating, m.memo_text,
|
||||||
|
m.visited_at, m.created_at, m.updated_at,
|
||||||
|
r.name AS restaurant_name
|
||||||
|
FROM user_memos m
|
||||||
|
LEFT JOIN restaurants r ON r.id = m.restaurant_id
|
||||||
|
WHERE m.user_id = #{userId}
|
||||||
|
ORDER BY m.updated_at DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -16,18 +16,24 @@
|
|||||||
<result property="phone" column="phone"/>
|
<result property="phone" column="phone"/>
|
||||||
<result property="website" column="website"/>
|
<result property="website" column="website"/>
|
||||||
<result property="googlePlaceId" column="google_place_id"/>
|
<result property="googlePlaceId" column="google_place_id"/>
|
||||||
|
<result property="tablingUrl" column="tabling_url"/>
|
||||||
|
<result property="catchtableUrl" column="catchtable_url"/>
|
||||||
<result property="businessStatus" column="business_status"/>
|
<result property="businessStatus" column="business_status"/>
|
||||||
<result property="rating" column="rating"/>
|
<result property="rating" column="rating"/>
|
||||||
<result property="ratingCount" column="rating_count"/>
|
<result property="ratingCount" column="rating_count"/>
|
||||||
<result property="updatedAt" column="updated_at"/>
|
<result property="updatedAt" column="updated_at"/>
|
||||||
|
<result property="hidden" column="hidden" javaType="java.lang.Boolean"/>
|
||||||
|
<result property="hiddenReason" column="hidden_reason"/>
|
||||||
|
<result property="verifiedAt" column="verified_at"/>
|
||||||
</resultMap>
|
</resultMap>
|
||||||
|
|
||||||
<!-- ===== Queries ===== -->
|
<!-- ===== Queries ===== -->
|
||||||
|
|
||||||
<select id="findAll" resultMap="restaurantMap">
|
<select id="findAll" resultMap="restaurantMap">
|
||||||
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
|
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
|
||||||
r.cuisine_type, r.price_range, r.google_place_id,
|
r.cuisine_type, r.price_range, r.google_place_id, r.tabling_url, r.catchtable_url,
|
||||||
r.business_status, r.rating, r.rating_count, r.updated_at
|
r.business_status, r.rating, r.rating_count, r.updated_at,
|
||||||
|
r.hidden, r.hidden_reason, r.verified_at
|
||||||
FROM restaurants r
|
FROM restaurants r
|
||||||
<if test="channel != null and channel != ''">
|
<if test="channel != null and channel != ''">
|
||||||
JOIN video_restaurants vr_f ON vr_f.restaurant_id = r.id
|
JOIN video_restaurants vr_f ON vr_f.restaurant_id = r.id
|
||||||
@@ -37,6 +43,9 @@
|
|||||||
<where>
|
<where>
|
||||||
r.latitude IS NOT NULL
|
r.latitude IS NOT NULL
|
||||||
AND EXISTS (SELECT 1 FROM video_restaurants vr0 WHERE vr0.restaurant_id = r.id)
|
AND EXISTS (SELECT 1 FROM video_restaurants vr0 WHERE vr0.restaurant_id = r.id)
|
||||||
|
<if test="includeHidden == null or !includeHidden">
|
||||||
|
AND r.hidden = 0
|
||||||
|
</if>
|
||||||
<if test="cuisine != null and cuisine != ''">
|
<if test="cuisine != null and cuisine != ''">
|
||||||
AND r.cuisine_type = #{cuisine}
|
AND r.cuisine_type = #{cuisine}
|
||||||
</if>
|
</if>
|
||||||
@@ -54,7 +63,7 @@
|
|||||||
<select id="findById" resultMap="restaurantMap">
|
<select id="findById" resultMap="restaurantMap">
|
||||||
SELECT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
|
SELECT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
|
||||||
r.cuisine_type, r.price_range, r.phone, r.website, r.google_place_id,
|
r.cuisine_type, r.price_range, r.phone, r.website, r.google_place_id,
|
||||||
r.business_status, r.rating, r.rating_count
|
r.tabling_url, r.catchtable_url, r.business_status, r.rating, r.rating_count
|
||||||
FROM restaurants r
|
FROM restaurants r
|
||||||
WHERE r.id = #{id}
|
WHERE r.id = #{id}
|
||||||
</select>
|
</select>
|
||||||
@@ -129,12 +138,30 @@
|
|||||||
<if test="fields.containsKey('website')">
|
<if test="fields.containsKey('website')">
|
||||||
website = #{fields.website},
|
website = #{fields.website},
|
||||||
</if>
|
</if>
|
||||||
|
<if test="fields.containsKey('tabling_url')">
|
||||||
|
tabling_url = #{fields.tabling_url},
|
||||||
|
</if>
|
||||||
|
<if test="fields.containsKey('catchtable_url')">
|
||||||
|
catchtable_url = #{fields.catchtable_url},
|
||||||
|
</if>
|
||||||
<if test="fields.containsKey('latitude')">
|
<if test="fields.containsKey('latitude')">
|
||||||
latitude = #{fields.latitude},
|
latitude = #{fields.latitude},
|
||||||
</if>
|
</if>
|
||||||
<if test="fields.containsKey('longitude')">
|
<if test="fields.containsKey('longitude')">
|
||||||
longitude = #{fields.longitude},
|
longitude = #{fields.longitude},
|
||||||
</if>
|
</if>
|
||||||
|
<if test="fields.containsKey('google_place_id')">
|
||||||
|
google_place_id = #{fields.google_place_id},
|
||||||
|
</if>
|
||||||
|
<if test="fields.containsKey('business_status')">
|
||||||
|
business_status = #{fields.business_status},
|
||||||
|
</if>
|
||||||
|
<if test="fields.containsKey('rating')">
|
||||||
|
rating = #{fields.rating},
|
||||||
|
</if>
|
||||||
|
<if test="fields.containsKey('rating_count')">
|
||||||
|
rating_count = #{fields.rating_count},
|
||||||
|
</if>
|
||||||
updated_at = SYSTIMESTAMP,
|
updated_at = SYSTIMESTAMP,
|
||||||
</trim>
|
</trim>
|
||||||
WHERE id = #{id}
|
WHERE id = #{id}
|
||||||
@@ -201,6 +228,32 @@
|
|||||||
</foreach>
|
</foreach>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<select id="findWithoutTabling" resultMap="restaurantMap">
|
||||||
|
SELECT r.id, r.name, r.address, r.region
|
||||||
|
FROM restaurants r
|
||||||
|
WHERE r.tabling_url IS NULL
|
||||||
|
AND r.latitude IS NOT NULL
|
||||||
|
AND EXISTS (SELECT 1 FROM video_restaurants vr WHERE vr.restaurant_id = r.id)
|
||||||
|
ORDER BY r.name
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="findWithoutCatchtable" resultMap="restaurantMap">
|
||||||
|
SELECT r.id, r.name, r.address, r.region
|
||||||
|
FROM restaurants r
|
||||||
|
WHERE r.catchtable_url IS NULL
|
||||||
|
AND r.latitude IS NOT NULL
|
||||||
|
AND EXISTS (SELECT 1 FROM video_restaurants vr WHERE vr.restaurant_id = r.id)
|
||||||
|
ORDER BY r.name
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="resetTablingUrls">
|
||||||
|
UPDATE restaurants SET tabling_url = NULL WHERE tabling_url IS NOT NULL
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="resetCatchtableUrls">
|
||||||
|
UPDATE restaurants SET catchtable_url = NULL WHERE catchtable_url IS NOT NULL
|
||||||
|
</update>
|
||||||
|
|
||||||
<!-- ===== Remap operations ===== -->
|
<!-- ===== Remap operations ===== -->
|
||||||
|
|
||||||
<update id="updateCuisineType">
|
<update id="updateCuisineType">
|
||||||
@@ -231,4 +284,35 @@
|
|||||||
ORDER BY r.name
|
ORDER BY r.name
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- ===== #322 LLM 검증 ===== -->
|
||||||
|
|
||||||
|
<update id="updateVerification">
|
||||||
|
UPDATE restaurants
|
||||||
|
SET hidden = #{hidden},
|
||||||
|
hidden_reason = #{hiddenReason,jdbcType=VARCHAR},
|
||||||
|
verified_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="clearHidden">
|
||||||
|
UPDATE restaurants
|
||||||
|
SET hidden = 0,
|
||||||
|
hidden_reason = NULL,
|
||||||
|
verified_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<select id="findUnverified" resultMap="restaurantMap">
|
||||||
|
SELECT r.id, r.name, r.address, r.region, r.cuisine_type, r.price_range,
|
||||||
|
r.hidden, r.hidden_reason, r.verified_at
|
||||||
|
FROM restaurants r
|
||||||
|
WHERE r.verified_at IS NULL
|
||||||
|
ORDER BY r.updated_at DESC
|
||||||
|
FETCH FIRST #{limit} ROWS ONLY
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="countUnverified" resultType="int">
|
||||||
|
SELECT COUNT(*) FROM restaurants WHERE verified_at IS NULL
|
||||||
|
</select>
|
||||||
|
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -79,7 +79,8 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="getAvgRating" resultType="map">
|
<select id="getAvgRating" resultType="map">
|
||||||
SELECT ROUND(AVG(rating), 1) AS avg_rating, COUNT(*) AS review_count
|
<!-- #294 — review 0건이면 AVG는 NULL → 클라이언트 NaN 처리 부담. NVL로 0.0 보장. -->
|
||||||
|
SELECT NVL(ROUND(AVG(rating), 1), 0) AS avg_rating, COUNT(*) AS review_count
|
||||||
FROM user_reviews
|
FROM user_reviews
|
||||||
WHERE restaurant_id = #{restaurantId}
|
WHERE restaurant_id = #{restaurantId}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -11,7 +11,11 @@
|
|||||||
<result property="longitude" column="longitude"/>
|
<result property="longitude" column="longitude"/>
|
||||||
<result property="cuisineType" column="cuisine_type"/>
|
<result property="cuisineType" column="cuisine_type"/>
|
||||||
<result property="priceRange" column="price_range"/>
|
<result property="priceRange" column="price_range"/>
|
||||||
|
<result property="phone" column="phone"/>
|
||||||
|
<result property="website" column="website"/>
|
||||||
<result property="googlePlaceId" column="google_place_id"/>
|
<result property="googlePlaceId" column="google_place_id"/>
|
||||||
|
<result property="tablingUrl" column="tabling_url"/>
|
||||||
|
<result property="catchtableUrl" column="catchtable_url"/>
|
||||||
<result property="businessStatus" column="business_status"/>
|
<result property="businessStatus" column="business_status"/>
|
||||||
<result property="rating" column="rating"/>
|
<result property="rating" column="rating"/>
|
||||||
<result property="ratingCount" column="rating_count"/>
|
<result property="ratingCount" column="rating_count"/>
|
||||||
@@ -19,18 +23,20 @@
|
|||||||
|
|
||||||
<select id="keywordSearch" resultMap="restaurantMap">
|
<select id="keywordSearch" resultMap="restaurantMap">
|
||||||
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
|
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
|
||||||
r.cuisine_type, r.price_range, r.google_place_id,
|
r.cuisine_type, r.price_range, r.phone, r.website, r.google_place_id,
|
||||||
|
r.tabling_url, r.catchtable_url,
|
||||||
r.business_status, r.rating, r.rating_count
|
r.business_status, r.rating, r.rating_count
|
||||||
FROM restaurants r
|
FROM restaurants r
|
||||||
JOIN video_restaurants vr ON vr.restaurant_id = r.id
|
JOIN video_restaurants vr ON vr.restaurant_id = r.id
|
||||||
JOIN videos v ON v.id = vr.video_id
|
JOIN videos v ON v.id = vr.video_id
|
||||||
WHERE r.latitude IS NOT NULL
|
WHERE r.latitude IS NOT NULL
|
||||||
AND (UPPER(r.name) LIKE UPPER(#{query})
|
<!-- #293 — ESCAPE 절로 사용자 입력의 %, _ 와일드카드 의도 우회 차단 -->
|
||||||
OR UPPER(r.address) LIKE UPPER(#{query})
|
AND (UPPER(r.name) LIKE UPPER(#{query}) ESCAPE '\'
|
||||||
OR UPPER(r.region) LIKE UPPER(#{query})
|
OR UPPER(r.address) LIKE UPPER(#{query}) ESCAPE '\'
|
||||||
OR UPPER(r.cuisine_type) LIKE UPPER(#{query})
|
OR UPPER(r.region) LIKE UPPER(#{query}) ESCAPE '\'
|
||||||
OR UPPER(vr.foods_mentioned) LIKE UPPER(#{query})
|
OR UPPER(r.cuisine_type) LIKE UPPER(#{query}) ESCAPE '\'
|
||||||
OR UPPER(v.title) LIKE UPPER(#{query}))
|
OR UPPER(vr.foods_mentioned) LIKE UPPER(#{query}) ESCAPE '\'
|
||||||
|
OR UPPER(v.title) LIKE UPPER(#{query}) ESCAPE '\')
|
||||||
FETCH FIRST #{limit} ROWS ONLY
|
FETCH FIRST #{limit} ROWS ONLY
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<result property="createdAt" column="created_at"/>
|
<result property="createdAt" column="created_at"/>
|
||||||
<result property="favoriteCount" column="favorite_count"/>
|
<result property="favoriteCount" column="favorite_count"/>
|
||||||
<result property="reviewCount" column="review_count"/>
|
<result property="reviewCount" column="review_count"/>
|
||||||
|
<result property="memoCount" column="memo_count"/>
|
||||||
</resultMap>
|
</resultMap>
|
||||||
|
|
||||||
<select id="findByProviderAndProviderId" resultMap="userResultMap">
|
<select id="findByProviderAndProviderId" resultMap="userResultMap">
|
||||||
@@ -38,10 +39,12 @@
|
|||||||
<select id="findAllWithCounts" resultMap="userResultMap">
|
<select id="findAllWithCounts" resultMap="userResultMap">
|
||||||
SELECT u.id, u.email, u.nickname, u.avatar_url, u.provider, u.created_at,
|
SELECT u.id, u.email, u.nickname, u.avatar_url, u.provider, u.created_at,
|
||||||
NVL(fav.cnt, 0) AS favorite_count,
|
NVL(fav.cnt, 0) AS favorite_count,
|
||||||
NVL(rev.cnt, 0) AS review_count
|
NVL(rev.cnt, 0) AS review_count,
|
||||||
|
NVL(memo.cnt, 0) AS memo_count
|
||||||
FROM tasteby_users u
|
FROM tasteby_users u
|
||||||
LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_favorites GROUP BY user_id) fav ON fav.user_id = u.id
|
LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_favorites GROUP BY user_id) fav ON fav.user_id = u.id
|
||||||
LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_reviews GROUP BY user_id) rev ON rev.user_id = u.id
|
LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_reviews GROUP BY user_id) rev ON rev.user_id = u.id
|
||||||
|
LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_memos GROUP BY user_id) memo ON memo.user_id = u.id
|
||||||
ORDER BY u.created_at DESC
|
ORDER BY u.created_at DESC
|
||||||
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
|
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -186,7 +186,8 @@
|
|||||||
|
|
||||||
<insert id="insertVideo">
|
<insert id="insertVideo">
|
||||||
INSERT INTO videos (id, channel_id, video_id, title, url, published_at)
|
INSERT INTO videos (id, channel_id, video_id, title, url, published_at)
|
||||||
VALUES (#{id}, #{channelId}, #{videoId}, #{title}, #{url}, #{publishedAt})
|
VALUES (#{id}, #{channelId}, #{videoId}, #{title}, #{url},
|
||||||
|
TO_TIMESTAMP(#{publishedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<select id="getExistingVideoIds" resultType="string">
|
<select id="getExistingVideoIds" resultType="string">
|
||||||
@@ -194,7 +195,7 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="getLatestVideoDate" resultType="string">
|
<select id="getLatestVideoDate" resultType="string">
|
||||||
SELECT TO_CHAR(MAX(published_at), 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
SELECT TO_CHAR(MAX(published_at), 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS latest_date
|
||||||
FROM videos WHERE channel_id = #{channelId}
|
FROM videos WHERE channel_id = #{channelId}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
@@ -220,10 +221,30 @@
|
|||||||
SELECT id, video_id, title, url
|
SELECT id, video_id, title, url
|
||||||
FROM videos
|
FROM videos
|
||||||
WHERE (transcript_text IS NULL OR dbms_lob.getlength(transcript_text) = 0)
|
WHERE (transcript_text IS NULL OR dbms_lob.getlength(transcript_text) = 0)
|
||||||
AND status != 'skip'
|
AND status NOT IN ('skip', 'no_transcript')
|
||||||
ORDER BY created_at
|
ORDER BY created_at
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<select id="findVideosByIds" resultType="map">
|
||||||
|
SELECT id, video_id, title, url
|
||||||
|
FROM videos
|
||||||
|
WHERE id IN
|
||||||
|
<foreach item="id" collection="ids" open="(" separator="," close=")">
|
||||||
|
#{id}
|
||||||
|
</foreach>
|
||||||
|
ORDER BY created_at
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="findVideosForExtractByIds" resultType="map">
|
||||||
|
SELECT v.id, v.video_id, v.title, v.url, v.transcript_text
|
||||||
|
FROM videos v
|
||||||
|
WHERE v.id IN
|
||||||
|
<foreach item="id" collection="ids" open="(" separator="," close=")">
|
||||||
|
#{id}
|
||||||
|
</foreach>
|
||||||
|
ORDER BY v.published_at DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
<update id="updateVideoRestaurantFields">
|
<update id="updateVideoRestaurantFields">
|
||||||
UPDATE video_restaurants
|
UPDATE video_restaurants
|
||||||
SET foods_mentioned = #{foodsJson,jdbcType=CLOB},
|
SET foods_mentioned = #{foodsJson,jdbcType=CLOB},
|
||||||
|
|||||||
@@ -5,5 +5,6 @@
|
|||||||
<setting name="mapUnderscoreToCamelCase" value="true"/>
|
<setting name="mapUnderscoreToCamelCase" value="true"/>
|
||||||
<setting name="callSettersOnNulls" value="true"/>
|
<setting name="callSettersOnNulls" value="true"/>
|
||||||
<setting name="returnInstanceForEmptyRow" value="true"/>
|
<setting name="returnInstanceForEmptyRow" value="true"/>
|
||||||
|
<setting name="jdbcTypeForNull" value="VARCHAR"/>
|
||||||
</settings>
|
</settings>
|
||||||
</configuration>
|
</configuration>
|
||||||
|
|||||||
93
docs/README.md
Normal file
93
docs/README.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# tasteby 문서 아키텍처 (Documentation Map)
|
||||||
|
|
||||||
|
이 프로젝트의 문서는 **Diátaxis** 프레임워크 + **ADR** + **설계서(Design Spec)** 를
|
||||||
|
결합한 구조를 따른다. 모든 페르소나는 문서를 만들거나 참조할 때 이 지도를 기준으로 한다.
|
||||||
|
|
||||||
|
## 디렉토리 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/
|
||||||
|
README.md ← (이 파일) 문서 지도 · 인덱스
|
||||||
|
design/ ← 설계서: 구현 "전"에 작성하는 필수 산출물 (Design-First 게이트)
|
||||||
|
_TEMPLATE.md 기능 설계서 템플릿
|
||||||
|
_FN_TEMPLATE.md 함수별 설계서 템플릿
|
||||||
|
<issue-id>-<slug>/ 기능 1개(이슈 1개)당 폴더
|
||||||
|
README.md 기능 설계서 (전체 설계 + 함수 명세 표)
|
||||||
|
fn-<name>.md 복잡한 함수만 개별 함수 설계서
|
||||||
|
adr/ ← Architecture Decision Records: 가로지르는 결정 기록
|
||||||
|
_TEMPLATE.md
|
||||||
|
NNNN-<title>.md
|
||||||
|
reference/ ← 레퍼런스: 구현된 모듈/함수/설정 사양 (구현 "후" 동기화)
|
||||||
|
guides/ ← How-to / 사용 가이드 / 튜토리얼 (사용자·운영자 대상)
|
||||||
|
pipeline/ ← 개발 프로세스 문서 (큐 프로토콜·런북)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Diátaxis 사분면 매핑
|
||||||
|
|
||||||
|
| 사분면 | 목적 | 여기서 위치 |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| **Tutorials** (학습) | 처음 사용자가 따라하기 | `guides/` (getting-started) |
|
||||||
|
| **How-to** (문제해결) | 특정 작업 수행 | `guides/` |
|
||||||
|
| **Reference** (정보) | 정확한 사양 조회 | `reference/` |
|
||||||
|
| **Explanation** (이해) | 왜 이렇게 설계했나 | `design/`, `adr/` |
|
||||||
|
|
||||||
|
## 문서 종류와 책임
|
||||||
|
|
||||||
|
| 문서 | 작성 페르소나 | 시점 | 한 줄 |
|
||||||
|
|------|---------------|------|-------|
|
||||||
|
| 기능 설계서 `design/<id>/README.md` | **Architect** | 구현 **전** | 무엇을·어떻게 만들지의 청사진 |
|
||||||
|
| 함수 설계서 `design/<id>/fn-*.md` | **Architect** | 구현 **전** | 복잡 함수의 계약·알고리즘·테스트 |
|
||||||
|
| ADR `adr/NNNN-*.md` | **Architect** | 결정 시 | 되돌리기 어려운 선택과 근거 |
|
||||||
|
| 레퍼런스 `reference/*` | **Developer/Documenter** | 구현 **후** | 실제 코드 사양 |
|
||||||
|
| 가이드 `guides/*` | **Documenter** | 릴리스 시 | 사용/운영 방법 |
|
||||||
|
|
||||||
|
## 핵심 규칙 — Design-First (하드 게이트)
|
||||||
|
|
||||||
|
> **설계서 없이는 코드 없음.** 어떤 함수든 구현 전에 그 함수가 설계서로 덮여 있어야 한다
|
||||||
|
> (단순 함수: 기능 설계서의 함수 명세 표 / 복잡 함수: 개별 `fn-*.md`).
|
||||||
|
> Developer 는 설계서가 없으면 구현을 거부하고 Architect 단계로 반려한다.
|
||||||
|
> 자세한 기준은 `CLAUDE.md` §2 참조.
|
||||||
|
|
||||||
|
## 명명 · 추적성 규칙
|
||||||
|
|
||||||
|
- 설계서 폴더: `design/<issue-id>-<kebab-slug>/` (예: `design/45-trailing-stop/`).
|
||||||
|
- 함수 설계서: `fn-<function_name>.md` (예: `fn-calc_trailing_stop.md`).
|
||||||
|
- ADR: 4자리 일련번호 `adr/0001-<title>.md`, 번호 재사용 금지.
|
||||||
|
- 모든 설계서·ADR 상단에 **추적성 헤더**(Redmine 이슈, 관련 ADR, 구현 파일, 테스트)를 둔다.
|
||||||
|
- 코드 ↔ 설계서 양방향 링크: 설계서는 구현 파일 경로를, 코드 주석/문서는 설계서 경로를 가리킨다.
|
||||||
|
|
||||||
|
## 문서 수명주기
|
||||||
|
|
||||||
|
`Draft`(작성) → `Approved`(QA/Reviewer 통과 후) → `Superseded`(대체 시 상단 표기, 삭제 금지).
|
||||||
|
구현이 설계서와 달라지면 **코드가 아니라 설계서를 먼저 고치고** 다시 구현한다.
|
||||||
|
|
||||||
|
## 현존 설계서 인덱스 (2026-06-15 현행화)
|
||||||
|
|
||||||
|
### 백엔드 (12)
|
||||||
|
| Issue | 기능 | 설계서 |
|
||||||
|
|-------|------|--------|
|
||||||
|
| #266 | 인증/로그인 | [`design/266-backend-auth/README.md`](design/266-backend-auth/README.md) |
|
||||||
|
| #267 | 사용자 관리 | [`design/267-backend-user/README.md`](design/267-backend-user/README.md) |
|
||||||
|
| #268 | 식당 CRUD | [`design/268-backend-restaurant/README.md`](design/268-backend-restaurant/README.md) |
|
||||||
|
| #269 | 영상 관리 + SSE | [`design/269-backend-video/README.md`](design/269-backend-video/README.md) |
|
||||||
|
| #270 | 영상→식당 추출 파이프라인 | [`design/270-backend-extract-pipeline/README.md`](design/270-backend-extract-pipeline/README.md) |
|
||||||
|
| #271 | 검색/벡터 추천 | [`design/271-backend-search/README.md`](design/271-backend-search/README.md) |
|
||||||
|
| #272 | 리뷰/메모 | [`design/272-backend-review-memo/README.md`](design/272-backend-review-memo/README.md) |
|
||||||
|
| #273 | 채널 관리 | [`design/273-backend-channel/README.md`](design/273-backend-channel/README.md) |
|
||||||
|
| #274 | 통계/대시보드 | [`design/274-backend-stats/README.md`](design/274-backend-stats/README.md) |
|
||||||
|
| #275 | 데몬/스케줄러 | [`design/275-backend-daemon/README.md`](design/275-backend-daemon/README.md) |
|
||||||
|
| #276 | 캐시 관리 | [`design/276-backend-cache/README.md`](design/276-backend-cache/README.md) |
|
||||||
|
| #277 | Health/모니터링 | [`design/277-backend-health/README.md`](design/277-backend-health/README.md) |
|
||||||
|
|
||||||
|
### 프론트 (6)
|
||||||
|
| Issue | 기능 | 설계서 |
|
||||||
|
|-------|------|--------|
|
||||||
|
| #278 | 지도 뷰 | [`design/278-frontend-map/README.md`](design/278-frontend-map/README.md) |
|
||||||
|
| #279 | 식당 상세 시트 | [`design/279-frontend-restaurant-detail/README.md`](design/279-frontend-restaurant-detail/README.md) |
|
||||||
|
| #280 | 필터 시스템 | [`design/280-frontend-filter/README.md`](design/280-frontend-filter/README.md) |
|
||||||
|
| #281 | 리뷰/메모 UI | [`design/281-frontend-review-memo/README.md`](design/281-frontend-review-memo/README.md) |
|
||||||
|
| #282 | 어드민 페이지 | [`design/282-frontend-admin/README.md`](design/282-frontend-admin/README.md) |
|
||||||
|
| #283 | 로그인 메뉴 | [`design/283-frontend-login/README.md`](design/283-frontend-login/README.md) |
|
||||||
|
|
||||||
|
후속 개선 이슈는 Redmine 백로그(#289~#305)에서 추적.
|
||||||
|
```
|
||||||
24
docs/adr/_TEMPLATE.md
Normal file
24
docs/adr/_TEMPLATE.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!-- ADR 템플릿. 복사해서 adr/NNNN-<kebab-title>.md (4자리 일련번호). -->
|
||||||
|
|
||||||
|
# ADR-NNNN: <제목>
|
||||||
|
|
||||||
|
> **상태**: Proposed <!-- Proposed | Accepted | Superseded by ADR-XXXX -->
|
||||||
|
> **날짜**: <YYYY-MM-DD> · **결정자**: [AI] Architect · **관련 이슈**: #<id>
|
||||||
|
|
||||||
|
## 맥락 (Context)
|
||||||
|
무엇이 이 결정을 강제하는가. 배경·제약·요구.
|
||||||
|
|
||||||
|
## 결정 (Decision)
|
||||||
|
우리는 무엇을 하기로 했는가. (명확한 한 문단)
|
||||||
|
|
||||||
|
## 근거 (Rationale)
|
||||||
|
왜 이 선택인가. 핵심 트레이드오프.
|
||||||
|
|
||||||
|
## 결과 (Consequences)
|
||||||
|
- **긍정**: ...
|
||||||
|
- **부정 / 비용**: ...
|
||||||
|
- **후속 작업**: ...
|
||||||
|
|
||||||
|
## 검토한 대안 (Alternatives Considered)
|
||||||
|
- **<대안 A>** — 기각 사유: ...
|
||||||
|
- **<대안 B>** — 기각 사유: ...
|
||||||
262
docs/deployment-guide.md
Normal file
262
docs/deployment-guide.md
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
# Tasteby 배포 가이드
|
||||||
|
|
||||||
|
## 환경 요약
|
||||||
|
|
||||||
|
| 항목 | Dev (개발) | Prod (운영) |
|
||||||
|
|------|-----------|-------------|
|
||||||
|
| URL | dev.tasteby.net | www.tasteby.net |
|
||||||
|
| 호스트 | 로컬 Mac mini | OKE (Oracle Kubernetes Engine) |
|
||||||
|
| 프로세스 관리 | PM2 | Kubernetes Deployment |
|
||||||
|
| 프론트엔드 실행 | `npm run dev` (Next.js dev server) | `node server.js` (standalone 빌드) |
|
||||||
|
| 백엔드 실행 | `./gradlew bootRun` | `java -jar app.jar` (bootJar 빌드) |
|
||||||
|
| Redis | 로컬 Redis 서버 | K8s Pod (redis:7-alpine) |
|
||||||
|
| TLS | Nginx(192.168.0.147) + Certbot | cert-manager + Let's Encrypt |
|
||||||
|
| 리버스 프록시 | Nginx (192.168.0.147 → 192.168.0.208) | Nginx Ingress Controller (K8s) |
|
||||||
|
| 도메인 DNS | dev.tasteby.net → Mac mini IP | www.tasteby.net → OCI NLB 217.142.131.194 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Dev 환경 (dev.tasteby.net)
|
||||||
|
|
||||||
|
### 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
브라우저 → dev.tasteby.net (HTTPS)
|
||||||
|
↓
|
||||||
|
Nginx (192.168.0.147) — Certbot Let's Encrypt TLS
|
||||||
|
├── /api/* → proxy_pass http://192.168.0.208:8000 (tasteby-api)
|
||||||
|
└── /* → proxy_pass http://192.168.0.208:3001 (tasteby-web)
|
||||||
|
↓
|
||||||
|
Mac mini (192.168.0.208) — PM2 프로세스 매니저
|
||||||
|
├── tasteby-api → ./gradlew bootRun (:8000)
|
||||||
|
└── tasteby-web → npm run dev (:3001)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **192.168.0.147**: Nginx 리버스 프록시 서버 (TLS 종료, Certbot 자동 갱신)
|
||||||
|
- **192.168.0.208**: Mac mini (실제 앱 서버, PM2 관리)
|
||||||
|
|
||||||
|
### PM2 프로세스 구성 (ecosystem.config.js)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: "tasteby-api",
|
||||||
|
cwd: "/Users/joungmin/workspaces/tasteby/backend-java",
|
||||||
|
script: "./start.sh", // gradlew bootRun 실행
|
||||||
|
interpreter: "/bin/bash",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tasteby-web",
|
||||||
|
cwd: "/Users/joungmin/workspaces/tasteby/frontend",
|
||||||
|
script: "npm",
|
||||||
|
args: "run dev", // ⚠️ 절대 standalone으로 바꾸지 말 것!
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 백엔드 start.sh
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
export JAVA_HOME="/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home"
|
||||||
|
export PATH="/opt/homebrew/opt/openjdk@21/bin:$PATH"
|
||||||
|
set -a
|
||||||
|
source /Users/joungmin/workspaces/tasteby/backend/.env # 환경변수 로드
|
||||||
|
set +a
|
||||||
|
exec ./gradlew bootRun
|
||||||
|
```
|
||||||
|
|
||||||
|
### 코드 수정 후 반영 방법
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 프론트엔드: npm run dev라서 코드 수정 시 자동 Hot Reload (재시작 불필요)
|
||||||
|
|
||||||
|
# 백엔드: 코드 수정 후 재시작 필요
|
||||||
|
pm2 restart tasteby-api
|
||||||
|
|
||||||
|
# 전체 재시작
|
||||||
|
pm2 restart tasteby-api tasteby-web
|
||||||
|
|
||||||
|
# PM2 상태 확인
|
||||||
|
pm2 status
|
||||||
|
|
||||||
|
# 로그 확인
|
||||||
|
pm2 logs tasteby-api --lines 50
|
||||||
|
pm2 logs tasteby-web --lines 50
|
||||||
|
```
|
||||||
|
|
||||||
|
### 주의사항
|
||||||
|
|
||||||
|
- `tasteby-web`은 반드시 `npm run dev`로 실행 (dev server)
|
||||||
|
- standalone 모드(`node .next/standalone/server.js`)로 바꾸면 static/public 파일을 못 찾아서 404 발생
|
||||||
|
- standalone은 prod(Docker/K8s) 전용
|
||||||
|
- dev 포트: 프론트 3001, 백엔드 8000 (3000은 Gitea가 사용 중)
|
||||||
|
- 환경변수는 `backend/.env`에서 로드
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Prod 환경 (www.tasteby.net)
|
||||||
|
|
||||||
|
### 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
브라우저 → www.tasteby.net (HTTPS)
|
||||||
|
↓
|
||||||
|
OCI Network Load Balancer (217.142.131.194)
|
||||||
|
↓ 80→NodePort:32530, 443→NodePort:31437
|
||||||
|
Nginx Ingress Controller (K8s)
|
||||||
|
├── /api/* → backend Service (:8000)
|
||||||
|
└── /* → frontend Service (:3001)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 클러스터 정보
|
||||||
|
|
||||||
|
- **OKE 클러스터**: tasteby-cluster-prod
|
||||||
|
- **노드**: ARM64 × 2 (2 CPU / 8GB)
|
||||||
|
- **네임스페이스**: tasteby
|
||||||
|
- **K8s context**: `context-c6ap7ecrdeq`
|
||||||
|
|
||||||
|
### Pod 구성
|
||||||
|
|
||||||
|
| Pod | Image | Port | 리소스 |
|
||||||
|
|-----|-------|------|--------|
|
||||||
|
| backend | `icn.ocir.io/idyhsdamac8c/tasteby/backend:TAG` | 8000 | 500m~1 CPU, 768Mi~1536Mi |
|
||||||
|
| frontend | `icn.ocir.io/idyhsdamac8c/tasteby/frontend:TAG` | 3001 | 200m~500m CPU, 256Mi~512Mi |
|
||||||
|
| redis | `docker.io/library/redis:7-alpine` | 6379 | 100m~200m CPU, 128Mi~256Mi |
|
||||||
|
|
||||||
|
### 배포 명령어 (deploy.sh)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 전체 배포 (백엔드 + 프론트엔드)
|
||||||
|
./deploy.sh "배포 메시지"
|
||||||
|
|
||||||
|
# 백엔드만 배포
|
||||||
|
./deploy.sh --backend-only "백엔드 수정 사항"
|
||||||
|
|
||||||
|
# 프론트엔드만 배포
|
||||||
|
./deploy.sh --frontend-only "프론트 수정 사항"
|
||||||
|
|
||||||
|
# 드라이런 (실제 배포 없이 확인)
|
||||||
|
./deploy.sh --dry-run "테스트"
|
||||||
|
```
|
||||||
|
|
||||||
|
### deploy.sh 동작 순서
|
||||||
|
|
||||||
|
1. **버전 계산**: 최신 git tag에서 patch +1 (v0.1.9 → v0.1.10)
|
||||||
|
2. **Docker 빌드**: Colima로 `linux/arm64` 이미지 빌드 (로컬 Mac에서)
|
||||||
|
- 백엔드: `backend-java/Dockerfile` → multi-stage (JDK build → JRE runtime)
|
||||||
|
- 프론트: `frontend/Dockerfile` → multi-stage (node build → standalone runtime)
|
||||||
|
3. **OCIR Push**: `icn.ocir.io/idyhsdamac8c/tasteby/{backend,frontend}:TAG` + `:latest`
|
||||||
|
4. **K8s 배포**: `kubectl set image` → `kubectl rollout status` (롤링 업데이트)
|
||||||
|
5. **Git tag**: `vX.Y.Z` 태그 생성 후 origin push
|
||||||
|
|
||||||
|
### Docker 빌드 상세
|
||||||
|
|
||||||
|
**백엔드 Dockerfile** (multi-stage):
|
||||||
|
```dockerfile
|
||||||
|
# Build: eclipse-temurin:21-jdk에서 gradlew bootJar
|
||||||
|
# Runtime: eclipse-temurin:21-jre에서 java -jar app.jar
|
||||||
|
# JVM 옵션: -XX:MaxRAMPercentage=75.0 -XX:+UseG1GC
|
||||||
|
```
|
||||||
|
|
||||||
|
**프론트엔드 Dockerfile** (multi-stage):
|
||||||
|
```dockerfile
|
||||||
|
# Build: node:22-alpine에서 npm ci + npm run build
|
||||||
|
# Runtime: node:22-alpine에서 standalone 출력물 복사 + node server.js
|
||||||
|
# ⚠️ standalone 모드는 Docker(prod) 전용. .next/static과 public을 직접 복사해야 함
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ingress 설정
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 주요 annotation
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod # 자동 TLS 인증서
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true" # HTTP → HTTPS 리다이렉트
|
||||||
|
nginx.ingress.kubernetes.io/from-to-www-redirect: "true" # tasteby.net → www 리다이렉트
|
||||||
|
|
||||||
|
# 라우팅
|
||||||
|
www.tasteby.net/api/* → backend:8000
|
||||||
|
www.tasteby.net/* → frontend:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
### TLS 인증서 (cert-manager)
|
||||||
|
|
||||||
|
- ClusterIssuer: `letsencrypt-prod`
|
||||||
|
- HTTP-01 challenge 방식 (포트 80 필수)
|
||||||
|
- Secret: `tasteby-tls`
|
||||||
|
- 인증서 상태 확인: `kubectl get certificate -n tasteby`
|
||||||
|
|
||||||
|
### 운영 확인 명령어
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pod 상태
|
||||||
|
kubectl get pods -n tasteby
|
||||||
|
|
||||||
|
# 로그 확인
|
||||||
|
kubectl logs -f deployment/backend -n tasteby
|
||||||
|
kubectl logs -f deployment/frontend -n tasteby
|
||||||
|
|
||||||
|
# 인증서 상태
|
||||||
|
kubectl get certificate -n tasteby
|
||||||
|
|
||||||
|
# Ingress 상태
|
||||||
|
kubectl get ingress -n tasteby
|
||||||
|
|
||||||
|
# 롤백 (이전 이미지로)
|
||||||
|
kubectl rollout undo deployment/backend -n tasteby
|
||||||
|
kubectl rollout undo deployment/frontend -n tasteby
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. OCI 네트워크 구성
|
||||||
|
|
||||||
|
### VCN 서브넷
|
||||||
|
|
||||||
|
| 서브넷 | CIDR | 용도 |
|
||||||
|
|--------|------|------|
|
||||||
|
| oke-k8sApiEndpoint-subnet | 10.0.0.0/28 | K8s API 서버 |
|
||||||
|
| oke-nodesubnet | 10.0.10.0/24 | 워커 노드 |
|
||||||
|
| oke-svclbsubnet | 10.0.20.0/24 | NLB (로드밸런서) |
|
||||||
|
|
||||||
|
### 보안 리스트 (Security List)
|
||||||
|
|
||||||
|
**LB 서브넷** (oke-svclbsubnet):
|
||||||
|
- Ingress: `0.0.0.0/0` → TCP 80, 443
|
||||||
|
- Egress: `10.0.10.0/24` → all (노드 서브넷 전체 허용)
|
||||||
|
|
||||||
|
**노드 서브넷** (oke-nodesubnet):
|
||||||
|
- Ingress: `10.0.10.0/24` → all (노드 간 통신)
|
||||||
|
- Ingress: `10.0.0.0/28` → TCP all (API 서버)
|
||||||
|
- Ingress: `0.0.0.0/0` → TCP 22 (SSH)
|
||||||
|
- Ingress: `10.0.20.0/24` → TCP 30000-32767 (LB → NodePort)
|
||||||
|
- Ingress: `0.0.0.0/0` → TCP 30000-32767 (NLB preserve-source 대응)
|
||||||
|
|
||||||
|
> ⚠️ NLB `is-preserve-source: true` 설정으로 클라이언트 원본 IP가 보존됨.
|
||||||
|
> 따라서 노드 서브넷에 `0.0.0.0/0` → NodePort 인바운드가 반드시 필요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. OCIR (컨테이너 레지스트리) 인증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 로그인
|
||||||
|
docker login icn.ocir.io -u idyhsdamac8c/oracleidentitycloudservice/<email> -p <auth-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Registry: `icn.ocir.io/idyhsdamac8c/tasteby/`
|
||||||
|
- K8s imagePullSecret: `ocir-secret` (namespace: tasteby)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 자주 하는 실수 / 주의사항
|
||||||
|
|
||||||
|
| 실수 | 원인 | 해결 |
|
||||||
|
|------|------|------|
|
||||||
|
| dev에서 static 404 | PM2를 standalone 모드로 바꿈 | `npm run dev`로 원복 |
|
||||||
|
| prod HTTPS 타임아웃 | NLB 보안 리스트 NodePort 불일치 | egress를 노드 서브넷 all 허용 |
|
||||||
|
| 인증서 발급 실패 | 포트 80 방화벽 차단 | LB 서브넷 ingress 80 + 노드 서브넷 NodePort 허용 |
|
||||||
|
| OKE에서 이미지 pull 실패 | CRI-O short name 불가 | `docker.io/library/` 풀네임 사용 |
|
||||||
|
| NLB 헬스체크 실패 | preserve-source + 노드 보안 리스트 | 0.0.0.0/0 → NodePort 인바운드 추가 |
|
||||||
173
docs/design/266-backend-auth/README.md
Normal file
173
docs/design/266-backend-auth/README.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
|
||||||
|
|
||||||
|
# 설계서: 백엔드 - 인증/로그인 (#266)
|
||||||
|
|
||||||
|
> **상태**: Approved <!-- Draft | Approved | Superseded -->
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #266 · 관련 ADR: 없음
|
||||||
|
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/AuthService.java`, `backend-java/src/main/java/com/tasteby/controller/AuthController.java` · 테스트: TBD (현재 없음)
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
Tasteby 사용자가 Google 계정으로 1탭 로그인하여 즐겨찾기/리뷰/메모 등 개인화 기능을 사용할 수 있도록 한다. 자체 가입/비밀번호 운영 부담을 제거하고 검증된 ID 토큰 기반으로 안전한 세션 토큰(JWT)을 발급한다.
|
||||||
|
|
||||||
|
## 2. 범위 (Scope)
|
||||||
|
- **포함**
|
||||||
|
- Google OAuth ID Token 검증 후 사용자 조회/생성(Upsert) → 자체 JWT 발급 (`POST /api/auth/google`).
|
||||||
|
- 현재 로그인 사용자 정보 반환 (`GET /api/auth/me`).
|
||||||
|
- Google 검증 실패/사용자 미존재 시 표준 HTTP 에러 매핑.
|
||||||
|
- **제외 (out of scope)**
|
||||||
|
- 자체 ID/PW 회원가입·비밀번호 재설정.
|
||||||
|
- Apple/Kakao/Naver 등 추가 소셜 로그인.
|
||||||
|
- 리프레시 토큰, 토큰 회수(blacklist).
|
||||||
|
- 로그아웃 처리(클라이언트 토큰 삭제로 처리).
|
||||||
|
- 권한 부여(role) 변경 — 사용자 관리(#267) 책임.
|
||||||
|
|
||||||
|
## 3. 인수조건 (Acceptance Criteria)
|
||||||
|
- [ ] 유효한 Google ID Token으로 `POST /api/auth/google` 호출 시 `access_token`(JWT)과 `user` 객체를 반환한다.
|
||||||
|
- [ ] 신규 Google 계정 첫 로그인 시 `tasteby_users` 행이 생성되고, 재로그인 시 `last_login_at`이 갱신된다.
|
||||||
|
- [ ] Google ID Token이 위조/만료/오디언스 불일치인 경우 HTTP 401을 반환한다.
|
||||||
|
- [ ] 발급된 JWT를 `Authorization: Bearer ...`로 `GET /api/auth/me` 호출 시 본인 정보를 반환한다.
|
||||||
|
- [ ] JWT의 sub가 존재하지 않는 사용자 ID인 경우 `GET /api/auth/me`는 HTTP 404를 반환한다.
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
- **의존성**
|
||||||
|
- `google-api-client`의 `GoogleIdTokenVerifier`(`NetHttpTransport` + `GsonFactory`).
|
||||||
|
- `UserService` → `UserMapper`(MyBatis) → Oracle 23ai (`tasteby_users`).
|
||||||
|
- 자체 `JwtTokenProvider` (HMAC 서명 가정), `AuthUtil`(SecurityContext에서 userId 추출).
|
||||||
|
- **제약**
|
||||||
|
- Google Client ID는 `app.google.client-id` 프로퍼티로 단일 audience로 고정. 모바일/웹 다중 클라이언트 ID는 현 시점 미지원.
|
||||||
|
- JWT 만료/서명 정책은 `JwtTokenProvider`에서 관리(본 설계서 범위 외).
|
||||||
|
- CORS는 `WebConfig`에서 `POST`/`GET` 허용 필요(이미 적용).
|
||||||
|
- 모든 외부 호출은 동기 HTTP, 실패 시 401로 합쳐서 반환.
|
||||||
|
- **가정**
|
||||||
|
- Google ID Token의 `sub`는 영구 고유 식별자이며, 동일 사용자가 이메일을 바꾸어도 `(provider='google', providerId=sub)`로 식별 가능.
|
||||||
|
- 사용자 객체의 `nickname`/`avatarUrl`은 최초 생성 시 Google payload 값을 그대로 저장(이후 사용자 편집은 본 기능 범위 외).
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
- **모듈/파일**
|
||||||
|
- `controller/AuthController.java` — HTTP 진입점 (thin).
|
||||||
|
- `service/AuthService.java` — Google 검증 + JWT 발급 오케스트레이션.
|
||||||
|
- `service/UserService.java#findOrCreate/findById` — DB 조회/upsert.
|
||||||
|
- `security/JwtTokenProvider`, `security/AuthUtil` — 토큰 생성 / SecurityContext 추출.
|
||||||
|
- **데이터 흐름**
|
||||||
|
|
||||||
|
```
|
||||||
|
[Client]
|
||||||
|
│ POST /api/auth/google { id_token }
|
||||||
|
▼
|
||||||
|
AuthController.loginGoogle
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
AuthService.loginGoogle
|
||||||
|
├─ GoogleIdTokenVerifier.verify(idToken) ── (외부 I/O: Google 공개키 검증)
|
||||||
|
├─ UserService.findOrCreate(provider, sub, email, name, picture)
|
||||||
|
│ └─ UserMapper.findByProviderAndProviderId / insert / updateLastLogin
|
||||||
|
└─ JwtTokenProvider.createToken(userMap)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
{ access_token, user }
|
||||||
|
|
||||||
|
|
||||||
|
[Client] GET /api/auth/me (Authorization: Bearer <jwt>)
|
||||||
|
▼
|
||||||
|
AuthController.me → AuthUtil.getUserId() → AuthService.getCurrentUser
|
||||||
|
▼ UserService.findById → UserMapper.findById
|
||||||
|
▼
|
||||||
|
UserInfo
|
||||||
|
```
|
||||||
|
|
||||||
|
- **I/O ↔ 순수 로직 경계**
|
||||||
|
- I/O: Google 토큰 검증, DB 조회/저장.
|
||||||
|
- 순수: payload → `UserInfo` 매핑, `Map<String,Object>` 클레임 빌드.
|
||||||
|
|
||||||
|
## 6. 데이터 모델
|
||||||
|
- **입력**
|
||||||
|
- `POST /api/auth/google` body: `{ "id_token": string(JWT, Google issued) }`.
|
||||||
|
- `GET /api/auth/me`: 헤더 `Authorization: Bearer <accessToken>`.
|
||||||
|
- **출력**
|
||||||
|
- `loginGoogle`: `{ access_token: string, user: UserInfo }`.
|
||||||
|
- `me`: `UserInfo`.
|
||||||
|
- **`UserInfo`(domain/UserInfo.java)**
|
||||||
|
- `id: String(32 hex)`, `email: String`, `nickname: String`, `avatarUrl: String`, `admin: boolean (@JsonProperty("is_admin"))`, `provider: String`, `providerId: String`, `createdAt: String`, `favoriteCount/reviewCount/memoCount: int`.
|
||||||
|
- **저장(`tasteby_users`)**
|
||||||
|
- PK: `id` (`IdGenerator.newId()`, 32-char uppercase hex), 유니크 가정: `(provider, provider_id)`.
|
||||||
|
- `is_admin NUMBER`(0/1), `last_login_at TIMESTAMP`.
|
||||||
|
- **경계 검증**
|
||||||
|
- `id_token` 비어있거나 null → Google verifier가 `null` 리턴 → 401.
|
||||||
|
- JWT 클레임 내 `email`/`nickname`이 null이면 빈 문자열로 정규화.
|
||||||
|
|
||||||
|
## 7. 함수 명세 (Function Specs)
|
||||||
|
|
||||||
|
| 함수 | 책임(1줄) | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? |
|
||||||
|
|------|-----------|----------|------|------|-----------|-------|
|
||||||
|
| `AuthService(UserService, JwtTokenProvider, String)` | 의존성 주입 및 GoogleIdTokenVerifier 초기화 | `AuthService(UserService userService, JwtTokenProvider jwtProvider, @Value("${app.google.client-id}") String googleClientId)` | DI 빈, client-id | 인스턴스 | 프로퍼티 누락 시 빈 생성 실패 | 단순 |
|
||||||
|
| `AuthService.loginGoogle` | Google ID Token 검증 → 사용자 upsert → JWT 발급 | `Map<String,Object> loginGoogle(String idTokenString)` | Google id_token 문자열 | `{ access_token, user }` | 검증 실패/예외 → 401 `ResponseStatusException` | **복잡** (외부 I/O + DB upsert + 토큰 발급) |
|
||||||
|
| `AuthService.getCurrentUser` | JWT sub로 사용자 조회 | `UserInfo getCurrentUser(String userId)` | userId | `UserInfo` | 미존재 → 404 | 단순 |
|
||||||
|
| `AuthController(AuthService)` | DI | 생성자 | DI 빈 | 인스턴스 | 없음 | 단순 |
|
||||||
|
| `AuthController.loginGoogle` | `/api/auth/google` 엔드포인트 | `Map<String,Object> loginGoogle(@RequestBody Map<String,String> body)` | body.id_token | `{ access_token, user }` | AuthService 예외 위임 | 단순 |
|
||||||
|
| `AuthController.me` | `/api/auth/me` 엔드포인트 | `UserInfo me()` | (헤더에서 userId 자동 추출) | `UserInfo` | 인증 실패 → 401, 미존재 → 404 | 단순 |
|
||||||
|
|
||||||
|
> 복잡 표시 함수(`loginGoogle`)는 흐름이 8장에 상세 기술되어 있어 별도 `fn-loginGoogle.md`는 생략 가능.
|
||||||
|
|
||||||
|
## 8. 흐름 / 알고리즘
|
||||||
|
**시나리오 A — Google 로그인**
|
||||||
|
1. 클라이언트가 Google Identity Services로 ID Token을 발급받아 `POST /api/auth/google {id_token}` 호출.
|
||||||
|
2. `AuthService`가 `GoogleIdTokenVerifier.verify`로 서명/만료/aud 검증. null이면 401.
|
||||||
|
3. payload에서 `sub`, `email`, `name`, `picture` 추출.
|
||||||
|
4. `UserService.findOrCreate("google", sub, email, name, picture)` 호출.
|
||||||
|
- 기존 유저: `updateLastLogin` 후 최신 사용자 반환.
|
||||||
|
- 신규 유저: `IdGenerator.newId()`로 PK 발급 → insert → 재조회 반환.
|
||||||
|
5. `UserInfo`의 핵심 필드를 `Map`으로 패키징하여 `JwtTokenProvider.createToken` 호출.
|
||||||
|
6. `{ access_token, user }` 응답.
|
||||||
|
|
||||||
|
**시나리오 B — 현재 사용자 조회 (`/api/auth/me`)**
|
||||||
|
1. Spring Security 필터가 Bearer 토큰을 검증해 `SecurityContext`에 principal(userId) 설정.
|
||||||
|
2. `AuthUtil.getUserId()`로 sub 추출.
|
||||||
|
3. `AuthService.getCurrentUser` → `UserService.findById` → `UserMapper.findById`.
|
||||||
|
4. 없으면 404, 있으면 `UserInfo` 반환.
|
||||||
|
|
||||||
|
## 9. 엣지케이스 & 에러 처리
|
||||||
|
- **id_token이 null/공백**: Verifier가 null 또는 예외 발생 → 401 "Invalid Google token".
|
||||||
|
- **Google 공개키 조회 실패(네트워크/타임아웃)**: catch-all로 401에 메시지 포함. 재시도/백오프 없음(클라이언트가 재시도).
|
||||||
|
- **audience 불일치**: Verifier가 null 반환 → 401.
|
||||||
|
- **신규 사용자 insert 중 충돌**: 트랜잭션(`@Transactional`)으로 묶여 있으며, (provider, provider_id) 유니크 위반 시 예외 발생 → 상위에서 500 변환(전역 예외 처리에 의존). 동시 첫 로그인은 드물어 별도 재시도 없음.
|
||||||
|
- **`findById` race**: insert 직후 즉시 재조회 — 동일 트랜잭션 가시성 가정.
|
||||||
|
- **JWT 클레임 내 email/nickname null**: 빈 문자열로 정규화 후 토큰에 포함.
|
||||||
|
- **`/api/auth/me`에서 sub가 존재하지 않는 ID(사용자 삭제 등)**: 404.
|
||||||
|
- **안전한 기본값**: 어떤 실패든 401/404로 매핑, 500은 예외적.
|
||||||
|
|
||||||
|
## 10. 테스트 계획
|
||||||
|
- **현 상태**: 자동화 테스트 없음 (TBD).
|
||||||
|
- **추가 권장 단위 테스트** (Mockito 기반)
|
||||||
|
- `AuthService.loginGoogle`
|
||||||
|
- 유효 토큰 → `UserService.findOrCreate` 호출 + `JwtTokenProvider.createToken` 결과 포함.
|
||||||
|
- Verifier null 반환 → 401.
|
||||||
|
- Verifier 예외 → 401.
|
||||||
|
- email/nickname null payload → 토큰 클레임에 빈 문자열.
|
||||||
|
- `AuthService.getCurrentUser`
|
||||||
|
- 존재 → `UserInfo` 반환.
|
||||||
|
- 미존재 → 404.
|
||||||
|
- **통합 테스트** (`@SpringBootTest` + MockMvc)
|
||||||
|
- `POST /api/auth/google` happy path (Google verifier 모킹).
|
||||||
|
- `GET /api/auth/me` 인증 헤더 유효/무효.
|
||||||
|
- **모킹 전략**: `GoogleIdTokenVerifier`는 `@MockBean`으로 교체. JWT는 실제 `JwtTokenProvider` 사용해 round-trip 검증.
|
||||||
|
|
||||||
|
## 11. 리스크 & 대안 검토
|
||||||
|
- **선택**: Google 단일 IdP + 자체 단기 JWT.
|
||||||
|
- 장점: 구현 단순, 비밀번호 미관리, 즉시 사용 가능.
|
||||||
|
- 단점: 리프레시 토큰 없음 → 만료 시 재로그인 필요.
|
||||||
|
- **대안 1**: Spring Security OAuth2 Client + 세션 쿠키.
|
||||||
|
- 트레이드오프: 백엔드 세션 저장소 추가, SPA-친화 낮음. 현재 거부.
|
||||||
|
- **대안 2**: 리프레시 토큰 + 회수 리스트(Redis).
|
||||||
|
- 트레이드오프: 복잡도 ↑. 향후 필요 시 도입.
|
||||||
|
- **되돌리기 어려운 결정**: `(provider, provider_id)` 식별 스키마. → 변경 시 ADR 필요.
|
||||||
|
- **보안 리스크**
|
||||||
|
- JWT 시크릿 유출 시 위조 가능. 시크릿은 `k8s/secrets.yaml`로 관리.
|
||||||
|
- audience 단일 — 모바일/웹 client_id 분리 시 verifier 다중 audience 지원 필요.
|
||||||
|
|
||||||
|
## 12. 미해결 질문 (Open Questions)
|
||||||
|
- 리프레시 토큰을 도입할 것인가, 단기 만료 + 재로그인을 유지할 것인가?
|
||||||
|
- 사용자 닉네임/프로필 사진을 매 로그인마다 Google 값으로 덮어쓸지(현 코드는 첫 생성만 반영) 여부.
|
||||||
|
- 어드민 권한 부여 정책(최초 가입자 admin, 환경변수 화이트리스트 등)을 어디서 결정할지.
|
||||||
|
- 멀티 클라이언트(웹/iOS/Android)별 Google Client ID 분리 시점.
|
||||||
|
- 토큰 만료/서명 알고리즘(HS256 → RS256 전환) 시점.
|
||||||
205
docs/design/267-backend-user/README.md
Normal file
205
docs/design/267-backend-user/README.md
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
|
||||||
|
|
||||||
|
# 설계서: 백엔드 - 사용자 관리 (#267)
|
||||||
|
|
||||||
|
> **상태**: Approved <!-- Draft | Approved | Superseded -->
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #267 · 관련 ADR: 없음
|
||||||
|
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/UserService.java`, `backend-java/src/main/java/com/tasteby/controller/AdminUserController.java`, `backend-java/src/main/java/com/tasteby/mapper/UserMapper.java`, `backend-java/src/main/resources/mybatis/mapper/UserMapper.xml` · 테스트: TBD (현재 없음)
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
관리자(`is_admin=1`)가 가입 사용자 목록과 각 사용자의 활동(즐겨찾기·리뷰·메모)을 조회하고, 관리자 권한을 부여/회수할 수 있도록 한다. 또한 인증(#266) 흐름에서 호출되는 사용자 upsert/조회의 단일 책임 지점을 제공한다.
|
||||||
|
|
||||||
|
## 2. 범위 (Scope)
|
||||||
|
- **포함**
|
||||||
|
- 사용자 upsert/조회 도메인 서비스 (`UserService.findOrCreate`, `findById`).
|
||||||
|
- 관리자 전용 사용자 목록 조회(페이징, 활동 카운트 포함) — `GET /api/admin/users`.
|
||||||
|
- 사용자별 즐겨찾기/리뷰/메모 조회 — `GET /api/admin/users/{userId}/{favorites|reviews|memos}`.
|
||||||
|
- 관리자 권한 토글 — `PATCH /api/admin/users/{userId}/admin`.
|
||||||
|
- **제외 (out of scope)**
|
||||||
|
- 회원 탈퇴/익명화, 개인정보 수정 API.
|
||||||
|
- 일반 사용자가 자기 프로필을 수정하는 API.
|
||||||
|
- 사용자 자체 검색(이름/이메일).
|
||||||
|
- 즐겨찾기/리뷰/메모의 CRUD (각 도메인 서비스 책임).
|
||||||
|
- Google 토큰 검증 (#266 책임).
|
||||||
|
|
||||||
|
## 3. 인수조건 (Acceptance Criteria)
|
||||||
|
- [x] 관리자 토큰으로 `GET /api/admin/users?limit=&offset=` 호출 시 `{ users:[…], total:n }` 구조와 각 사용자의 `favoriteCount/reviewCount/memoCount`가 포함된다.
|
||||||
|
- [x] `/api/admin/users/**` 모든 엔드포인트(GET 4종 + PATCH)는 진입 시 `AuthUtil.requireAdmin()`을 호출하여 비관리자 토큰에 대해 403을 반환한다. (보안 핫픽스 2026-06-15)
|
||||||
|
- [ ] `findOrCreate`는 `(provider, providerId)`로 기존 사용자가 있으면 `last_login_at`만 갱신하고, 없으면 신규 PK로 INSERT한다.
|
||||||
|
- [ ] `PATCH /api/admin/users/{userId}/admin {admin: true|false}` 호출 시 자기 자신의 권한을 변경하면 400을 반환한다.
|
||||||
|
- [ ] 존재하지 않는 사용자 ID로 `updateAdmin` 호출 시 404를 반환한다.
|
||||||
|
- [ ] 관리자 권한 변경 후 응답에 `{ success:true, user_id, is_admin }`이 포함되고, 서버 로그에 변경자/대상/값이 기록된다.
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
- **의존성**
|
||||||
|
- Oracle 23ai 테이블: `tasteby_users`, `user_favorites`, `user_reviews`, `user_memos`.
|
||||||
|
- MyBatis (`UserMapper.xml`) — resultMap으로 UPPERCASE 컬럼 → 도메인 매핑.
|
||||||
|
- `ReviewService`, `MemoService` — 사용자별 활동 조회 위임.
|
||||||
|
- `AuthUtil.requireAdmin()` — JWT의 admin 클레임 검사.
|
||||||
|
- **제약**
|
||||||
|
- 모든 `/api/admin/**` 엔드포인트는 관리자만 호출 가능.
|
||||||
|
- 페이징 기본 `limit=50, offset=0`. 상한 강제 없음(향후 캡 고려).
|
||||||
|
- `is_admin`은 Oracle NUMBER(0/1) → Java boolean (`@JsonProperty("is_admin")`).
|
||||||
|
- 트랜잭션: upsert 및 권한 변경은 `@Transactional`.
|
||||||
|
- **가정**
|
||||||
|
- `UserInfo.id`는 `IdGenerator.newId()` 32-char hex로 사전 발급.
|
||||||
|
- `findAllWithCounts`는 LEFT JOIN으로 활동 미존재 시 0 반환.
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
- **모듈/파일**
|
||||||
|
- `controller/AdminUserController.java` — 관리자 전용 엔드포인트.
|
||||||
|
- `service/UserService.java` — upsert·조회·권한 변경.
|
||||||
|
- `mapper/UserMapper.java` + `resources/mybatis/mapper/UserMapper.xml` — SQL 매핑.
|
||||||
|
- `domain/UserInfo.java` — Lombok DTO.
|
||||||
|
- **데이터 흐름**
|
||||||
|
|
||||||
|
```
|
||||||
|
[Admin Client]
|
||||||
|
│ GET /api/admin/users?limit&offset (Bearer JWT, is_admin=true)
|
||||||
|
▼
|
||||||
|
AdminUserController.listUsers
|
||||||
|
├─ UserService.findAllWithCounts(limit, offset)
|
||||||
|
│ └─ UserMapper.xml: SELECT u.* + LEFT JOIN (fav/rev/memo COUNT)
|
||||||
|
└─ UserService.countAll
|
||||||
|
▼
|
||||||
|
{ users:[UserInfo], total }
|
||||||
|
|
||||||
|
[Admin Client]
|
||||||
|
│ PATCH /api/admin/users/{id}/admin {admin}
|
||||||
|
▼
|
||||||
|
AdminUserController.updateAdmin
|
||||||
|
├─ AuthUtil.requireAdmin() (자기 자신 변경 거부)
|
||||||
|
└─ UserService.updateAdmin → UserMapper.updateAdmin
|
||||||
|
▼
|
||||||
|
{ success, user_id, is_admin }
|
||||||
|
|
||||||
|
[AuthService (#266)]
|
||||||
|
▼ UserService.findOrCreate
|
||||||
|
├─ findByProviderAndProviderId → updateLastLogin
|
||||||
|
└─ insert + findById
|
||||||
|
```
|
||||||
|
|
||||||
|
- **I/O ↔ 순수 로직 경계**
|
||||||
|
- I/O: MyBatis Mapper(DB).
|
||||||
|
- 순수: 권한 변경 시 자기 자신 검사, boolean→int 변환.
|
||||||
|
|
||||||
|
## 6. 데이터 모델
|
||||||
|
- **`UserInfo`** (`domain/UserInfo.java`)
|
||||||
|
- `id: String`, `email: String`, `nickname: String`, `avatarUrl: String`, `admin: boolean(@JsonProperty("is_admin"))`, `provider: String`, `providerId: String`, `createdAt: String`, `favoriteCount/reviewCount/memoCount: int`.
|
||||||
|
- **저장(`tasteby_users`)**
|
||||||
|
- `id PK(32)`, `provider VARCHAR`, `provider_id VARCHAR`, `email`, `nickname`, `avatar_url`, `is_admin NUMBER(1)`, `created_at TIMESTAMP`, `last_login_at TIMESTAMP`.
|
||||||
|
- 유니크(가정): `(provider, provider_id)`.
|
||||||
|
- **입력**
|
||||||
|
- `GET /api/admin/users`: query `limit:int(=50)`, `offset:int(=0)`.
|
||||||
|
- `PATCH /api/admin/users/{userId}/admin`: body `{ admin: boolean }`.
|
||||||
|
- **출력**
|
||||||
|
- 목록: `{ users: UserInfo[], total: int }`.
|
||||||
|
- 사용자 즐겨찾기/리뷰/메모: `Restaurant[]`, `Review[]`, `Memo[]`.
|
||||||
|
- 권한 변경: `{ success:true, user_id, is_admin }`.
|
||||||
|
- **경계 검증**
|
||||||
|
- `limit/offset` 정수, 음수 가드 없음 — 호출자 신뢰. Oracle FETCH NEXT가 0/음수 시 결과 0건.
|
||||||
|
- body.admin이 null → `Boolean.TRUE.equals(null)` = false 처리.
|
||||||
|
|
||||||
|
## 7. 함수 명세 (Function Specs)
|
||||||
|
|
||||||
|
### UserService (public)
|
||||||
|
|
||||||
|
| 함수 | 책임(1줄) | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? |
|
||||||
|
|------|-----------|----------|------|------|-----------|-------|
|
||||||
|
| `UserService(UserMapper)` | DI | 생성자 | DI 빈 | 인스턴스 | 없음 | 단순 |
|
||||||
|
| `findOrCreate` | provider+providerId로 사용자 upsert + 마지막 로그인 갱신 | `UserInfo findOrCreate(String provider, String providerId, String email, String nickname, String avatarUrl)` | 5개 문자열 | `UserInfo` | DB 예외 → 500 | **복잡** (분기 + 트랜잭션) |
|
||||||
|
| `findById` | PK로 단건 조회 | `UserInfo findById(String userId)` | userId | `UserInfo` or null | DB 예외 → 500 | 단순 |
|
||||||
|
| `findAllWithCounts` | 활동 카운트 포함 페이징 목록 | `List<UserInfo> findAllWithCounts(int limit, int offset)` | 페이징 | 사용자 리스트 | DB 예외 → 500 | 단순 |
|
||||||
|
| `countAll` | 전체 사용자 수 | `int countAll()` | 없음 | int | DB 예외 → 500 | 단순 |
|
||||||
|
| `updateAdmin` | 관리자 플래그 변경 | `void updateAdmin(String userId, boolean admin)` | userId, boolean | void | 미존재 → 404 | 단순 |
|
||||||
|
|
||||||
|
### UserMapper (public, MyBatis)
|
||||||
|
|
||||||
|
| 함수 | 책임 | 시그니처 | 출력 |
|
||||||
|
|------|------|----------|------|
|
||||||
|
| `findByProviderAndProviderId` | provider+providerId 조회 | `UserInfo findByProviderAndProviderId(String provider, String providerId)` | `UserInfo`/null |
|
||||||
|
| `updateLastLogin` | last_login_at = SYSTIMESTAMP | `void updateLastLogin(String id)` | void |
|
||||||
|
| `insert` | 신규 사용자 INSERT | `void insert(UserInfo user)` | void |
|
||||||
|
| `findById` | PK 조회 | `UserInfo findById(String id)` | `UserInfo`/null |
|
||||||
|
| `findAllWithCounts` | 활동 COUNT 조인 페이징 | `List<UserInfo> findAllWithCounts(int limit, int offset)` | 목록 |
|
||||||
|
| `countAll` | 전체 카운트 | `int countAll()` | int |
|
||||||
|
| `updateAdmin` | `is_admin` UPDATE | `int updateAdmin(String id, int admin)` | 영향 행 수 |
|
||||||
|
|
||||||
|
### AdminUserController (public)
|
||||||
|
|
||||||
|
| 함수 | 책임 | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? |
|
||||||
|
|------|------|----------|------|------|-----------|-------|
|
||||||
|
| `AdminUserController(...)` | DI | 생성자 | DI 빈 | 인스턴스 | 없음 | 단순 |
|
||||||
|
| `listUsers` | `GET /api/admin/users` | `Map<String,Object> listUsers(int limit=50, int offset=0)` | 페이징 | `{users,total}` | 인증 실패 → 401/403 | 단순 |
|
||||||
|
| `userFavorites` | `GET …/{userId}/favorites` | `List<Restaurant> userFavorites(String userId)` | userId | 즐겨찾기 식당 | 인증/위임 | 단순 |
|
||||||
|
| `userReviews` | `GET …/{userId}/reviews` | `List<Review> userReviews(String userId)` | userId | 리뷰 100건 | 인증/위임 | 단순 |
|
||||||
|
| `userMemos` | `GET …/{userId}/memos` | `List<Memo> userMemos(String userId)` | userId | 메모 | 인증/위임 | 단순 |
|
||||||
|
| `updateAdmin` | `PATCH …/{userId}/admin` | `Map<String,Object> updateAdmin(String userId, Map<String,Boolean> body)` | userId, `{admin}` | `{success,user_id,is_admin}` | 자기 자신 → 400, 미존재 → 404 | **복잡** (정책 분기) |
|
||||||
|
|
||||||
|
## 8. 흐름 / 알고리즘
|
||||||
|
**A. Upsert (`findOrCreate`)**
|
||||||
|
1. `findByProviderAndProviderId(provider, providerId)` 호출.
|
||||||
|
2. 존재 → `updateLastLogin(id)` → `findById(id)` 반환.
|
||||||
|
3. 미존재 → `IdGenerator.newId()`로 PK 발급 → `UserInfo` 빌드 → `insert` → `findById(newId)` 반환.
|
||||||
|
4. 전체 트랜잭션(`@Transactional`)으로 묶여 부분 실패 시 롤백.
|
||||||
|
|
||||||
|
**B. 관리자 목록 (`listUsers`)**
|
||||||
|
1. 컨트롤러에서 limit/offset 기본값 적용.
|
||||||
|
2. `findAllWithCounts`로 `LEFT JOIN ( user_favorites|user_reviews|user_memos GROUP BY user_id )` + `ORDER BY created_at DESC OFFSET ? FETCH NEXT ?`.
|
||||||
|
3. `countAll`로 전체 수 합산하여 `{ users, total }` 반환.
|
||||||
|
|
||||||
|
**C. 권한 변경 (`updateAdmin`)**
|
||||||
|
1. `AuthUtil.requireAdmin()` — JWT의 admin 클레임 미보유 시 403.
|
||||||
|
2. `userId == currentUser.subject` → 400 ("자기 자신의 관리자 권한은 변경할 수 없습니다").
|
||||||
|
3. body.admin → boolean (null=false).
|
||||||
|
4. `UserService.updateAdmin` → Mapper에서 `UPDATE … WHERE id = ?`.
|
||||||
|
5. 영향 행 0 → 404, 1 → 감사 로그 `[ADMIN] User {} set admin={} for user {}` 출력 후 성공 응답.
|
||||||
|
|
||||||
|
## 9. 엣지케이스 & 에러 처리
|
||||||
|
- **자기 자신 권한 변경**: 명시적으로 400으로 차단(마지막 관리자 사고 방지).
|
||||||
|
- **존재하지 않는 사용자 권한 변경**: Mapper 영향 행 0 → 404.
|
||||||
|
- **음수 limit/offset**: Oracle은 OFFSET 0 ROWS FETCH NEXT 음수 시 결과 0건. 클라이언트 신뢰.
|
||||||
|
- **활동 카운트 0**: `NVL(…, 0)`으로 0으로 노출.
|
||||||
|
- **`findById` race(인증 직후 삭제)**: null 반환 → 호출자(AuthService)에서 404.
|
||||||
|
- **email/nickname null** (Google에서 일부 제공 안 함): 컬럼 NULL 허용 가정. UI에서 빈 값 처리.
|
||||||
|
- **권한 변경 동시성**: 트랜잭션 + 단일 UPDATE이므로 마지막 쓰기 승리(last-write-wins). 감사 로그로 추적.
|
||||||
|
- **안전한 기본값**: 권한 변경 실패 시 변경 없음.
|
||||||
|
|
||||||
|
## 10. 테스트 계획
|
||||||
|
- **현 상태**: 자동화 테스트 없음 (TBD).
|
||||||
|
- **단위 테스트** (Mockito)
|
||||||
|
- `UserService.findOrCreate`
|
||||||
|
- 기존 사용자 → `updateLastLogin` + `findById` 호출 검증.
|
||||||
|
- 신규 사용자 → `insert` + `findById(newId)` 호출 검증.
|
||||||
|
- `UserService.updateAdmin`
|
||||||
|
- 영향 행 1 → 정상.
|
||||||
|
- 영향 행 0 → 404 예외.
|
||||||
|
- **컨트롤러 통합 테스트** (MockMvc + `@MockBean`)
|
||||||
|
- `GET /api/admin/users` 정상/페이징 파라미터.
|
||||||
|
- `PATCH /admin` 자기 자신 → 400, 미존재 → 404, 정상 → 200 + 응답 구조.
|
||||||
|
- 비-관리자 토큰 → 403.
|
||||||
|
- **Mapper 통합** (`@MybatisTest` + Testcontainers Oracle)
|
||||||
|
- `findByProviderAndProviderId` 일치/미일치.
|
||||||
|
- `findAllWithCounts` 활동 카운트 정확성.
|
||||||
|
- **모킹 전략**: `AuthUtil` 정적 메서드는 `Mockito.mockStatic`으로 stub.
|
||||||
|
|
||||||
|
## 11. 리스크 & 대안 검토
|
||||||
|
- **선택**: 관리자 전용 엔드포인트 분리 + 일반 사용자용 프로필 API 없음.
|
||||||
|
- 장점: 권한 경계 단순.
|
||||||
|
- 단점: 일반 사용자가 자기 프로필 수정 시 별도 API 신설 필요.
|
||||||
|
- **대안 1**: `is_admin` 외 RBAC(role 테이블).
|
||||||
|
- 트레이드오프: 복잡도 ↑. 현 규모에서는 과설계.
|
||||||
|
- **대안 2**: 활동 카운트를 캐시/뷰로 분리.
|
||||||
|
- 트레이드오프: 사용자 수 증가 시 JOIN 비용 ↑ — 그때 도입.
|
||||||
|
- **되돌리기 어려운 결정**: `is_admin` boolean → 다중 역할로 확장 시 마이그레이션 필요.
|
||||||
|
- **운영 리스크**
|
||||||
|
- 마지막 관리자 권한 회수: 자기 자신 차단으로 부분 보호. 다른 관리자가 회수하면 무관리자 상태 가능 → 향후 "최소 1명 admin 유지" 가드 고려.
|
||||||
|
- 사용자 페이징에 상한 없음 → 큰 limit으로 메모리 압박 가능.
|
||||||
|
|
||||||
|
## 12. 미해결 질문 (Open Questions)
|
||||||
|
- 일반 사용자가 자기 프로필(닉네임/아바타)을 수정하는 API를 어디에 둘 것인지(`UserController` 신설 vs `/api/auth/me PATCH`).
|
||||||
|
- 사용자 검색(이메일/닉네임 부분 일치)을 관리자 화면에 추가할 것인지.
|
||||||
|
- "최소 1명 admin 유지" 가드를 도입할지, 운영 정책으로만 둘지.
|
||||||
|
- 활동 카운트 페이지 캐싱 TTL을 도입할지(현재 Redis 캐시 미적용).
|
||||||
|
- 회원 탈퇴/익명화 정책과 개인정보 보관기간.
|
||||||
277
docs/design/268-backend-restaurant/README.md
Normal file
277
docs/design/268-backend-restaurant/README.md
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
|
||||||
|
|
||||||
|
# 설계서: 백엔드 - 식당 CRUD (#268)
|
||||||
|
|
||||||
|
> **상태**: Approved <!-- Draft | Approved | Superseded -->
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #268 · 관련 ADR: 없음
|
||||||
|
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/RestaurantService.java`, `backend-java/src/main/java/com/tasteby/controller/RestaurantController.java` · 테스트: TBD (현재 없음)
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
사용자에게 식당 목록/상세를 빠르게 제공하고, 관리자에게는 식당 정보를 안전하게 수정/삭제하며 외부 예약 채널(테이블링·캐치테이블) URL을 자동/수동으로 연결할 수 있도록 한다. 추출기 파이프라인이 호출하는 식당 upsert 및 영상-식당 링크 생성의 단일 책임 지점을 제공한다.
|
||||||
|
|
||||||
|
## 2. 범위 (Scope)
|
||||||
|
- **포함**
|
||||||
|
- 목록/상세 조회 (`GET /api/restaurants`, `GET /api/restaurants/{id}`) — Redis 캐시.
|
||||||
|
- 식당 수정/삭제 (관리자 전용) — 이름·주소 변경 시 재지오코딩.
|
||||||
|
- 식당별 영상 연결 조회 (`GET /api/restaurants/{id}/videos`).
|
||||||
|
- 테이블링/캐치테이블 단건 검색, 미연결 목록, SSE 벌크 자동 연결, URL 저장, 초기화.
|
||||||
|
- 추출기 파이프라인이 호출하는 upsert(`upsert`), 영상-식당 링크(`linkVideoRestaurant`), 분류 보정(`updateCuisineType`, `updateFoodsMentioned`).
|
||||||
|
- **제외 (out of scope)**
|
||||||
|
- 식당 신규 등록 전용 엔드포인트(POST) — 등록은 추출기 파이프라인(`upsert`) 경유.
|
||||||
|
- YouTube 자막/메타 추출, 지오코딩 자체 로직 (각 서비스 책임).
|
||||||
|
- 즐겨찾기·리뷰·메모 CRUD.
|
||||||
|
- 식당 검색(이름/지역/메뉴 키워드 검색 API) — 본 설계서 미포함.
|
||||||
|
- 벡터 임베딩 생성 (`VectorService` 책임).
|
||||||
|
|
||||||
|
## 3. 인수조건 (Acceptance Criteria)
|
||||||
|
- [ ] `GET /api/restaurants?limit=&offset=&cuisine=®ion=&channel=` 결과는 캐시되며, `channels`/`foodsMentioned`가 채워진 `Restaurant` 리스트를 반환한다.
|
||||||
|
- [ ] `PUT /api/restaurants/{id}`에서 `name` 또는 `address`가 변경된 경우 Geocoding을 재호출하여 좌표·`google_place_id`·rating 등을 갱신한다.
|
||||||
|
- [ ] `DELETE /api/restaurants/{id}`는 `tasteby_restaurants`와 함께 벡터/리뷰/즐겨찾기/영상 링크를 모두 삭제한다.
|
||||||
|
- [ ] 관리자 미인증 사용자가 PUT/DELETE/관리자 엔드포인트 호출 시 403/401을 반환한다.
|
||||||
|
- [ ] `POST /api/restaurants/bulk-tabling`(SSE) 호출 시 미연결 식당에 대해 DuckDuckGo로 검색 → 유사도 ≥ 0.4면 URL 저장, 아니면 `NONE` 기록, 진행 상황을 이벤트로 스트리밍한다.
|
||||||
|
- [ ] `upsert`는 `google_place_id` 또는 동일 `name`이 있으면 UPDATE, 없으면 신규 ID로 INSERT 한다.
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
- **의존성**
|
||||||
|
- Oracle 23ai: `tasteby_restaurants`, `video_restaurant_links`, `tasteby_restaurant_vectors`, `user_favorites`, `user_reviews`.
|
||||||
|
- MyBatis `RestaurantMapper`.
|
||||||
|
- `CacheService` (Redis) — 목록/상세/영상 캐시 + 변경 시 `flush()`.
|
||||||
|
- `GeocodingService` — 이름/주소 → 좌표/place_id/주소/평점.
|
||||||
|
- 외부 HTTP: `html.duckduckgo.com` (테이블링/캐치테이블 검색).
|
||||||
|
- `AuthUtil.requireAdmin()`.
|
||||||
|
- 가상 스레드 풀(`Executors.newVirtualThreadPerTaskExecutor()`) — SSE 비동기.
|
||||||
|
- **제약**
|
||||||
|
- 목록 limit 최대 500으로 캡.
|
||||||
|
- 벌크 SSE 타임아웃 600초, 각 검색 사이 2~5초 랜덤 딜레이.
|
||||||
|
- 캐시 key 패턴: `restaurants:…`, `restaurant:{id}`, `restaurant_videos:{id}`.
|
||||||
|
- `name` 200바이트, `address` 500바이트 UTF-8 트렁케이션.
|
||||||
|
- 외부 검색은 비공식 스크래핑(DDG HTML) — Rate limit/봇 차단 가능성.
|
||||||
|
- 권한: 조회는 익명 허용, 수정/삭제/외부 검색/벌크는 관리자.
|
||||||
|
- **가정**
|
||||||
|
- `RestaurantMapper`의 동적 SQL이 `cuisine/region/channel` 필터를 지원.
|
||||||
|
- `evaluation` 필드는 `JsonUtil.normalizeEvaluation`으로 300자 제한 + JSON 래핑.
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
- **모듈/파일**
|
||||||
|
- `controller/RestaurantController.java` — HTTP/SSE.
|
||||||
|
- `service/RestaurantService.java` — 도메인 로직 + enrichment.
|
||||||
|
- `mapper/RestaurantMapper`(MyBatis) — SQL.
|
||||||
|
- `service/GeocodingService`, `service/CacheService` — 외부 협력.
|
||||||
|
- `util/JsonUtil`, `util/IdGenerator` — 공통.
|
||||||
|
- **데이터 흐름**
|
||||||
|
|
||||||
|
```
|
||||||
|
[Client]
|
||||||
|
│ GET /api/restaurants?…
|
||||||
|
▼
|
||||||
|
RestaurantController.list
|
||||||
|
├─ CacheService.getRaw(key) ── hit ─▶ deserialize
|
||||||
|
└─ miss ─▶ RestaurantService.findAll
|
||||||
|
├─ RestaurantMapper.findAll(limit,offset,cuisine,region,channel)
|
||||||
|
└─ enrichRestaurants
|
||||||
|
├─ findChannelsByRestaurantIds
|
||||||
|
└─ findFoodsByRestaurantIds (JsonUtil.parseStringList)
|
||||||
|
▼
|
||||||
|
CacheService.set(key, result)
|
||||||
|
|
||||||
|
[Admin] PUT /api/restaurants/{id}
|
||||||
|
▼ AuthUtil.requireAdmin
|
||||||
|
▼ RestaurantService.findById (404)
|
||||||
|
▼ if name/address changed → GeocodingService.geocodeRestaurant → body 보강
|
||||||
|
▼ RestaurantService.update → Mapper.updateFields
|
||||||
|
▼ CacheService.flush → findById → 응답
|
||||||
|
|
||||||
|
[Admin] POST /api/restaurants/bulk-tabling (SSE)
|
||||||
|
▼ findWithoutTabling → for each:
|
||||||
|
searchTabling(name) ─▶ DDG HTML (외부 I/O)
|
||||||
|
→ isNameSimilar? YES: update tabling_url
|
||||||
|
NO : update 'NONE'
|
||||||
|
→ emit event, sleep 2~5s
|
||||||
|
▼ cache.flush, complete
|
||||||
|
|
||||||
|
[Extractor pipeline]
|
||||||
|
▼ RestaurantService.upsert(map)
|
||||||
|
├─ findIdByPlaceId / findIdByName
|
||||||
|
└─ insertRestaurant or updateRestaurant
|
||||||
|
▼ linkVideoRestaurant(videoId, restaurantId, foods, eval, guests)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **I/O ↔ 순수 로직 경계**
|
||||||
|
- I/O: MyBatis, Redis, GeocodingService, DDG HTTP.
|
||||||
|
- 순수: `enrichRestaurants` 매핑, `truncateBytes`, `isNameSimilar`, `normalize`, `extractDdgUrl`.
|
||||||
|
|
||||||
|
## 6. 데이터 모델
|
||||||
|
- **`Restaurant`** (`domain/Restaurant.java`)
|
||||||
|
- `id, name, address, region, latitude(Double), longitude(Double), cuisineType, priceRange, phone, website, googlePlaceId, tablingUrl, catchtableUrl, businessStatus, rating(Double), ratingCount(Integer), updatedAt(Date)`.
|
||||||
|
- Transient: `channels: List<String>`, `foodsMentioned: List<String>`.
|
||||||
|
- **저장 테이블**
|
||||||
|
- `tasteby_restaurants` (PK `id`, 후보 키 `google_place_id` / 동명 폴백).
|
||||||
|
- `video_restaurant_links` (`id PK`, `video_id`, `restaurant_id`, `foods_mentioned CLOB`, `evaluation CLOB`, `guests CLOB`).
|
||||||
|
- 부속: `tasteby_restaurant_vectors`, `user_favorites`, `user_reviews` (DELETE 캐스케이드 수동).
|
||||||
|
- **입력**
|
||||||
|
- 목록 query: `limit(=100,≤500)`, `offset(=0)`, `cuisine?`, `region?`, `channel?`.
|
||||||
|
- 수정 body: 자유 형 `Map<String,Object>` (name/address/cuisine_type/price_range/website/phone/tabling_url 등).
|
||||||
|
- 권한 변경 / URL 저장: `{ tabling_url | catchtable_url: string }`.
|
||||||
|
- **출력**
|
||||||
|
- 목록/상세: `Restaurant`(Jackson SNAKE_CASE 직렬화).
|
||||||
|
- 영상 링크: `List<Map>` — `foods_mentioned/evaluation/guests`는 파싱 후 객체.
|
||||||
|
- SSE 이벤트 타입: `start | processing | done | notfound | error | complete`.
|
||||||
|
- **경계 검증**
|
||||||
|
- `name`/`address` UTF-8 200/500바이트로 잘라 저장.
|
||||||
|
- `evaluation`은 `JsonUtil.normalizeEvaluation`(평문 → JSON, 300자 제한).
|
||||||
|
- `latitude/longitude/rating/rating_count`는 Number → primitive 변환 시 null 안전.
|
||||||
|
|
||||||
|
## 7. 함수 명세 (Function Specs)
|
||||||
|
|
||||||
|
### RestaurantService (public)
|
||||||
|
|
||||||
|
| 함수 | 책임 | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? |
|
||||||
|
|------|------|----------|------|------|-----------|-------|
|
||||||
|
| `RestaurantService(RestaurantMapper)` | DI | 생성자 | DI 빈 | 인스턴스 | 없음 | 단순 |
|
||||||
|
| `findAll` | 필터+페이징 목록 + 채널/메뉴 enrich | `List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel)` | 페이징/필터 | 식당 리스트 | DB 예외 → 500 | **복잡** (조인 enrich) |
|
||||||
|
| `findWithoutTabling` | tabling_url 미연결 식당 | `List<Restaurant> findWithoutTabling()` | 없음 | 리스트 | DB 예외 | 단순 |
|
||||||
|
| `findWithoutCatchtable` | catchtable_url 미연결 식당 | `List<Restaurant> findWithoutCatchtable()` | 없음 | 리스트 | DB 예외 | 단순 |
|
||||||
|
| `resetTablingUrls` | 모든 tabling_url 초기화 | `void resetTablingUrls()` | 없음 | void | DB 예외 | 단순 |
|
||||||
|
| `resetCatchtableUrls` | 모든 catchtable_url 초기화 | `void resetCatchtableUrls()` | 없음 | void | DB 예외 | 단순 |
|
||||||
|
| `findById` | 단건 조회 + enrich | `Restaurant findById(String id)` | id | `Restaurant`/null | DB 예외 | 단순 |
|
||||||
|
| `findVideoLinks` | 영상-식당 링크 + JSON 파싱 | `List<Map<String,Object>> findVideoLinks(String restaurantId)` | restaurantId | foods/eval/guests 파싱된 리스트 | DB 예외 | 단순 |
|
||||||
|
| `update` | 임의 필드 부분 업데이트 | `void update(String id, Map<String,Object> fields)` | id, fields | void | DB 예외 | 단순 |
|
||||||
|
| `delete` | 식당 및 종속 데이터 일괄 삭제 | `void delete(String id)` | id | void | DB 예외 → 롤백 | **복잡** (5개 테이블 캐스케이드) |
|
||||||
|
| `upsert` | place_id/name으로 기존 매칭, 없으면 INSERT | `String upsert(Map<String,Object> data)` | 추출 결과 | restaurantId | DB 예외 | **복잡** (분기 + 트렁케이션) |
|
||||||
|
| `linkVideoRestaurant` | 영상-식당 N:M 링크 + JSON 직렬화 | `void linkVideoRestaurant(String videoId, String restaurantId, List<String> foods, String evaluation, List<String> guests)` | 5개 | void | DB 예외 | 단순 |
|
||||||
|
| `updateCuisineType` | 분류 보정 | `void updateCuisineType(String id, String cuisineType)` | id, type | void | DB 예외 | 단순 |
|
||||||
|
| `updateFoodsMentioned` | 메뉴 목록 보정 | `void updateFoodsMentioned(String id, String foods)` | id, foods | void | DB 예외 | 단순 |
|
||||||
|
| `findForRemapCuisine` | 재분류 대상 조회 | `List<Map<String,Object>> findForRemapCuisine()` | 없음 | 행 리스트 | DB 예외 | 단순 |
|
||||||
|
| `findForRemapFoods` | 재분류 대상 조회 | `List<Map<String,Object>> findForRemapFoods()` | 없음 | 행 리스트 | DB 예외 | 단순 |
|
||||||
|
|
||||||
|
> private: `enrichRestaurants`, `truncateBytes` — 표 외 처리.
|
||||||
|
|
||||||
|
### RestaurantController (public)
|
||||||
|
|
||||||
|
| 함수 | 책임/엔드포인트 | 시그니처 | 권한 | 출력 | 에러 | 복잡? |
|
||||||
|
|------|------|----------|------|------|------|-------|
|
||||||
|
| 생성자 | DI | `RestaurantController(...)` | — | 인스턴스 | 없음 | 단순 |
|
||||||
|
| `list` | `GET /api/restaurants` (캐시) | `List<Restaurant> list(int limit=100, int offset=0, String cuisine?, String region?, String channel?)` | 익명 | 목록 | 캐시 역직렬화 실패 시 silent fallback | **복잡** (캐시 미스/히트 분기) |
|
||||||
|
| `get` | `GET /{id}` (캐시) | `Restaurant get(String id)` | 익명 | `Restaurant` | 미존재 → 404 | 단순 |
|
||||||
|
| `update` | `PUT /{id}` (조건부 재지오코딩) | `Map update(String id, Map body)` | admin | `{ok, restaurant}` | 404 / 권한 | **복잡** (지오코딩 분기 + cache flush) |
|
||||||
|
| `delete` | `DELETE /{id}` | `Map delete(String id)` | admin | `{ok}` | 404 / 권한 | 단순 |
|
||||||
|
| `tablingSearch` | `GET /{id}/tabling-search` | `List<Map> tablingSearch(String id)` | admin | DDG 결과 | 404 / 502 | **복잡** (외부 I/O) |
|
||||||
|
| `tablingPending` | `GET /tabling-pending` | `Map tablingPending()` | admin | `{count, restaurants[]}` | 권한 | 단순 |
|
||||||
|
| `bulkTabling` | `POST /bulk-tabling` (SSE) | `SseEmitter bulkTabling()` | admin | SSE 스트림 | per-item error 이벤트, 최종 complete | **복잡** (장기 비동기 + 외부 I/O + 상태 전이) |
|
||||||
|
| `setTablingUrl` | `PUT /{id}/tabling-url` | `Map setTablingUrl(String id, Map body)` | admin | `{ok}` | 404 / 권한 | 단순 |
|
||||||
|
| `resetTabling` | `DELETE /reset-tabling` | `Map resetTabling()` | admin | `{ok}` | 권한 | 단순 |
|
||||||
|
| `resetCatchtable` | `DELETE /reset-catchtable` | `Map resetCatchtable()` | admin | `{ok}` | 권한 | 단순 |
|
||||||
|
| `catchtableSearch` | `GET /{id}/catchtable-search` | `List<Map> catchtableSearch(String id)` | admin | DDG 결과 | 404 / 502 | **복잡** |
|
||||||
|
| `catchtablePending` | `GET /catchtable-pending` | `Map catchtablePending()` | admin | `{count,…}` | 권한 | 단순 |
|
||||||
|
| `bulkCatchtable` | `POST /bulk-catchtable` (SSE) | `SseEmitter bulkCatchtable()` | admin | SSE 스트림 | 동상 | **복잡** |
|
||||||
|
| `setCatchtableUrl` | `PUT /{id}/catchtable-url` | `Map setCatchtableUrl(String id, Map body)` | admin | `{ok}` | 404 / 권한 | 단순 |
|
||||||
|
| `videos` | `GET /{id}/videos` (캐시) | `List<Map> videos(String id)` | 익명 | 영상 링크 | 404 | 단순 |
|
||||||
|
|
||||||
|
> private 유틸: `searchDuckDuckGo`, `extractDdgUrl`, `searchTabling`, `searchCatchtable`, `isNameSimilar`, `normalize`, `emit` — 표 외. 외부 I/O 동반.
|
||||||
|
|
||||||
|
## 8. 흐름 / 알고리즘
|
||||||
|
**A. 목록 조회 (캐시 적용)**
|
||||||
|
1. limit > 500이면 500으로 캡.
|
||||||
|
2. `CacheService.makeKey("restaurants", l=…, o=…, c=…, r=…, ch=…)` 생성.
|
||||||
|
3. Redis HIT → 역직렬화 반환(역직렬화 실패는 무시 후 미스 처리).
|
||||||
|
4. MISS → `findAll` 호출 → `enrichRestaurants`로 채널/메뉴 채움 → 캐시 set.
|
||||||
|
|
||||||
|
**B. 수정(`PUT /{id}`) — 조건부 재지오코딩**
|
||||||
|
1. 관리자 확인 → 404 가드.
|
||||||
|
2. body의 `name`/`address`가 기존과 다르면 `geocodeRestaurant` 호출.
|
||||||
|
3. 결과 좌표/`google_place_id`/`rating`/`phone`/`business_status`/`formatted_address` 보강.
|
||||||
|
4. `formatted_address`에서 `parseRegionFromAddress`로 `region` 재계산.
|
||||||
|
5. `Mapper.updateFields(id, body)` 부분 업데이트 → `cache.flush()` → 재조회 응답.
|
||||||
|
|
||||||
|
**C. 삭제(`DELETE /{id}`)**
|
||||||
|
- 순서: `deleteVectors → deleteReviews → deleteFavorites → deleteVideoRestaurants → deleteRestaurant` (외래 무결성 보호). `@Transactional`로 원자성.
|
||||||
|
|
||||||
|
**D. 벌크 테이블링/캐치테이블 (SSE)**
|
||||||
|
1. 관리자 확인. `SseEmitter(timeout=600s)` 생성.
|
||||||
|
2. 가상 스레드에서: `findWithoutTabling()` → for-each.
|
||||||
|
3. `emit("processing")` → `searchTabling(name)` → DDG HTML 검색 → 결과 5개 이내 추출.
|
||||||
|
4. 결과 있고 `isNameSimilar(name, top.title) == true`면 `update(tabling_url)` + `emit("done")`.
|
||||||
|
5. 결과 없거나 유사도 불충분 → `update("NONE")` + `emit("notfound")`.
|
||||||
|
6. 예외 → `emit("error")`.
|
||||||
|
7. 각 검색 후 `Thread.sleep(2000~5000ms)` 랜덤 딜레이.
|
||||||
|
8. 전체 완료 → `cache.flush()` → `emit("complete")` → `emitter.complete()`.
|
||||||
|
|
||||||
|
**E. Upsert (`upsert`)**
|
||||||
|
1. `google_place_id` 우선 매칭 → `findIdByPlaceId`.
|
||||||
|
2. 없으면 `findIdByName`.
|
||||||
|
3. name/address UTF-8 트렁케이션, Number 필드 안전 변환.
|
||||||
|
4. 기존 ID 있으면 UPDATE, 없으면 새 `IdGenerator.newId()`로 INSERT. 반환: restaurantId.
|
||||||
|
|
||||||
|
**F. 영상-식당 링크 (`linkVideoRestaurant`)**
|
||||||
|
- `foods/guests` → JSON 직렬화, `evaluation` → `JsonUtil.normalizeEvaluation` 후 INSERT.
|
||||||
|
|
||||||
|
**G. 이름 유사도 (`isNameSimilar`)**
|
||||||
|
- normalize: 공백·구두점·괄호 제거, lowercase.
|
||||||
|
- 포함 관계 또는 문자 집합 Jaccard-like 비율 ≥ 0.4.
|
||||||
|
|
||||||
|
## 9. 엣지케이스 & 에러 처리
|
||||||
|
- **limit > 500**: 500으로 강제 캡.
|
||||||
|
- **캐시 역직렬화 실패**: 무시하고 DB로 폴백(catch `Exception ignored`).
|
||||||
|
- **`findById` null**: 일반 GET/PUT/DELETE에서 404.
|
||||||
|
- **수정 시 지오코딩 실패(null 반환)**: body 그대로 update — 좌표 미갱신 허용.
|
||||||
|
- **삭제 캐스케이드 부분 실패**: 트랜잭션 롤백.
|
||||||
|
- **upsert place_id 동일·다른 이름**: place_id로 매칭 → UPDATE.
|
||||||
|
- **DDG 검색 결과 0건/이름 불일치**: `tabling_url='NONE'`(검색 다시 시도 방지 sentinel).
|
||||||
|
- **DDG HTTP 실패/예외**: 단건은 502, 벌크는 per-item error 이벤트.
|
||||||
|
- **벌크 SSE 클라이언트 단절**: `emitter.send` 예외 catch → 디버그 로그, 작업 진행.
|
||||||
|
- **레이트리밋/봇 차단**: 2~5초 랜덤 딜레이 + User-Agent 위장. 차단 발생 시 대량 'NONE' 기록 위험 → 운영자 모니터링 필요.
|
||||||
|
- **이름 트렁케이션 손실**: UTF-8 200/500 바이트로 잘라 멀티바이트 안전.
|
||||||
|
- **evaluation 평문 입력**: `normalizeEvaluation`이 JSON 래핑 + 300자 제한.
|
||||||
|
- **안전한 기본값**: 외부 I/O 실패 시 DB 변경 없음(검색 단건의 경우). 벌크는 진행하면서 실패 이벤트 emit.
|
||||||
|
|
||||||
|
## 10. 테스트 계획
|
||||||
|
- **현 상태**: 자동화 테스트 없음 (TBD).
|
||||||
|
- **단위 테스트** (Mockito)
|
||||||
|
- `RestaurantService.upsert`
|
||||||
|
- place_id 매칭 → UPDATE 경로.
|
||||||
|
- name 매칭 → UPDATE.
|
||||||
|
- 미매칭 → INSERT + 새 ID.
|
||||||
|
- name 250바이트 → 200바이트 트렁케이션.
|
||||||
|
- `RestaurantService.delete`
|
||||||
|
- 5개 mapper delete 순서 호출 검증.
|
||||||
|
- `RestaurantService.enrichRestaurants` (private이지만 `findAll` 경유)
|
||||||
|
- 채널/메뉴 매핑 정확성, null 처리.
|
||||||
|
- `RestaurantController.isNameSimilar` (정적·private)
|
||||||
|
- 포함/제외/유사도 경계 0.4.
|
||||||
|
- **통합 테스트** (`@SpringBootTest` + MockMvc)
|
||||||
|
- `GET /api/restaurants` 캐시 HIT/MISS 동작 (Redis embedded 또는 Testcontainers).
|
||||||
|
- `PUT /{id}` 이름/주소 변경 시 GeocodingService 호출 검증 (`@MockBean`).
|
||||||
|
- `DELETE /{id}` 트랜잭션 롤백 (예외 주입).
|
||||||
|
- 관리자 권한 가드 401/403.
|
||||||
|
- **SSE 테스트**
|
||||||
|
- `bulkTabling` 0건/일부 매칭/불일치/에러 시나리오 — `@MockBean`으로 DDG 결과 stub.
|
||||||
|
- 진행 이벤트 순서 검증.
|
||||||
|
- **모킹 전략**
|
||||||
|
- `httpClient`(DDG)는 인스턴스 추출 가능하도록 리팩토링 후 `@MockBean` (현재 static — 테스트 가능성 낮음, 향후 개선).
|
||||||
|
- `CacheService`/`GeocodingService`는 `@MockBean`.
|
||||||
|
|
||||||
|
## 11. 리스크 & 대안 검토
|
||||||
|
- **DDG HTML 스크래핑**
|
||||||
|
- 장점: API 키 불필요, 즉시 사용.
|
||||||
|
- 위험: HTML 구조 변경/봇 차단 시 대량 `NONE` 마킹 → 실 데이터 손상 가능.
|
||||||
|
- 대안: 테이블링/캐치테이블 비공식 API 직접 호출, 또는 검색 API(Bing/Naver) 도입 — 비용·약관 검토 필요.
|
||||||
|
- **캐시 무효화 전략**: 변경 시 전체 `flush()`.
|
||||||
|
- 장점: 단순.
|
||||||
|
- 단점: 무관한 키도 일괄 무효화. 트래픽 큰 시점에 부담.
|
||||||
|
- 대안: 키 prefix 기반 부분 삭제(`scan + del`).
|
||||||
|
- **`PUT /{id}` body가 `Map<String,Object>`**: 타입 안전성 낮음, 임의 컬럼 업데이트 허용.
|
||||||
|
- 대안: DTO + 화이트리스트. 보안/감사 향상.
|
||||||
|
- **벌크 SSE 600초 타임아웃**: 식당 수가 많을 경우 부족. 청크 분할/재개 기능 미지원.
|
||||||
|
- **이름 유사도 임계값 0.4**: 한글 짧은 이름에서 오탐 가능. 향후 ngram·자모 분해 기반 알고리즘 검토.
|
||||||
|
- **upsert의 동명 매칭**: place_id 없는 데이터에서 동명이체 식당이 합쳐질 수 있음 — 추출기 단계에서 place_id 보장 필요.
|
||||||
|
- **트랜잭션 경계**: `delete`는 트랜잭션, `upsert`/`update`는 메서드 단위 트랜잭션 없음(MyBatis 단일 SQL이므로 영향 작음).
|
||||||
|
|
||||||
|
## 12. 미해결 질문 (Open Questions)
|
||||||
|
- `region` 필터 값 컨벤션(`"한국|서울|강남구"`)을 enum/마스터 테이블로 표준화할지.
|
||||||
|
- DDG 스크래핑을 정식 검색 API로 대체할 시점/예산.
|
||||||
|
- `tabling_url = 'NONE'` sentinel을 별도 컬럼/플래그로 분리할지(현재 URL 컬럼에 의미 오버로드).
|
||||||
|
- 관리자 수정 PUT을 화이트리스트 DTO로 강제할지.
|
||||||
|
- 벌크 SSE 작업을 큐(Redis Streams) + 워커로 분리해 재개 가능하게 만들지.
|
||||||
|
- 이름 유사도 알고리즘을 한국어 특화(자모, 초성)로 교체할지.
|
||||||
|
- 캐시 키 그룹별 부분 무효화 도입 여부.
|
||||||
214
docs/design/269-backend-video/README.md
Normal file
214
docs/design/269-backend-video/README.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
|
||||||
|
|
||||||
|
# 설계서: 백엔드 - 영상 관리 + SSE (#269)
|
||||||
|
|
||||||
|
> **상태**: Approved <!-- Draft | Approved | Superseded -->
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #269 · 관련 ADR: 없음
|
||||||
|
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/VideoService.java`, `backend-java/src/main/java/com/tasteby/service/YouTubeService.java`, `backend-java/src/main/java/com/tasteby/controller/VideoController.java`, `backend-java/src/main/java/com/tasteby/controller/VideoSseController.java` · 테스트: TBD (현재 없음)
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
유튜브 채널의 영상 메타데이터를 스캔·저장하고, 자막(transcript)을 확보하며, 식당 추출 파이프라인 진입까지의 영상 생명주기를 관리한다. 다건 처리 진행 상황은 SSE 로 실시간 스트리밍하여 운영자가 어드민에서 모니터링한다.
|
||||||
|
|
||||||
|
## 2. 범위 (Scope)
|
||||||
|
- **포함**:
|
||||||
|
- 영상 목록/상세 조회, 제목 수정, 상태(`pending|processing|done|skip|no_transcript|error`) 변경, 삭제.
|
||||||
|
- 영상-식당 링크 단건 삭제 + 고아 식당/벡터/리뷰/즐겨찾기 정리.
|
||||||
|
- YouTube Data API v3 기반 채널 스캔 (PlaylistItems 우선, Search API 폴백, Shorts 60초 이하 필터).
|
||||||
|
- 자막 확보: Playwright Headed 브라우저(쿠키 로드, 광고 스킵, 한국어 우선) → 실패 시 `youtube-transcript-api` 폴백.
|
||||||
|
- 운영자가 브라우저 확장 등으로 수집한 transcript 업로드.
|
||||||
|
- SSE 스트림: `bulk-transcript`, `bulk-extract`, `remap-cuisine`, `remap-foods`, `rebuild-vectors`.
|
||||||
|
- 단건 추출 트리거 (`POST /api/videos/{id}/extract`)와 수동 식당 추가 (`/restaurants/manual`).
|
||||||
|
- **제외 (out of scope)**:
|
||||||
|
- LLM 기반 식당 추출 본체와 Geocoding (→ #270).
|
||||||
|
- 검색/벡터 추천 질의 (→ #271).
|
||||||
|
- 채널 마스터 CRUD (→ #273).
|
||||||
|
- 프론트엔드 어드민 UI (→ #282).
|
||||||
|
|
||||||
|
## 3. 인수조건 (Acceptance Criteria)
|
||||||
|
- [ ] `GET /api/videos?status=pending` 호출 시 상태별 영상 목록을 반환하고, 상세 (`GET /api/videos/{id}`)에는 transcript 와 식당 링크 배열이 포함된다 (`evaluation` 은 `JsonUtil.normalizeEvaluation` 으로 정규화).
|
||||||
|
- [ ] `DELETE /api/videos/{id}` 가 단일 트랜잭션으로 벡터·리뷰·즐겨찾기·식당·링크·영상 순으로 정리해 고아 레코드를 남기지 않는다.
|
||||||
|
- [ ] 채널 스캔(`YouTubeService.scanChannel`)은 PlaylistItems API 로 전체 업로드를 페이징하며, `publishedAfter` 이후 영상만 가져오고 Shorts(60초 이하)를 제거한 뒤 `saveVideosBatch` 로 중복 없이 저장한다.
|
||||||
|
- [ ] `POST /api/videos/{id}/fetch-transcript` 가 브라우저 → API 순으로 자막을 시도하고, 성공 시 길이/소스(`browser`/`manual (ko)`/`generated (en)` 등)를 응답에 포함한다.
|
||||||
|
- [ ] `POST /api/videos/bulk-transcript` SSE 가 `start → processing → done|skip|error → api_pass → complete` 이벤트 시퀀스를 JSON 으로 송출하고, 30분 타임아웃 + 3~8초 랜덤 딜레이로 봇 탐지를 회피한다.
|
||||||
|
- [ ] 모든 admin 엔드포인트는 `AuthUtil.requireAdmin()` 가드를 통과해야 하며 캐시 변경 후 `CacheService.flush()` 가 호출된다.
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
- **의존성**:
|
||||||
|
- DB: Oracle 23ai (videos, video_restaurants, restaurants, restaurant_vectors, reviews, favorites).
|
||||||
|
- 외부 API: YouTube Data API v3 (`app.google.youtube-api-key`).
|
||||||
|
- 자막 라이브러리: `io.github.thoroldvix.api.YoutubeTranscriptApi` + Playwright Chromium.
|
||||||
|
- 내부 서비스: `PipelineService`, `ExtractorService`, `RestaurantService`, `GeocodingService`, `OciGenAiService`, `CacheService` (`#270`/`#271`/`#276`).
|
||||||
|
- **제약**:
|
||||||
|
- YouTube API quota (PlaylistItems 1 unit/페이지, Search 100 unit/페이지 → 우선 PlaylistItems 사용).
|
||||||
|
- Playwright Headed 모드는 Mac mini Dev 환경 가정 (`pm2 tasteby-api`); 헤드리스 환경(OKE prod) 미지원이므로 SSE bulk-transcript 는 dev 에서만 사용한다.
|
||||||
|
- SSE Emitter 타임아웃: transcript 30 분, extract/remap 10 분.
|
||||||
|
- LLM 호출 비용 → bulk 작업 시 3~8초 랜덤 딜레이로 호출량 제어.
|
||||||
|
- transcript CLOB 저장, `MyBatis ClobTypeHandler` 로 매핑.
|
||||||
|
- **가정**:
|
||||||
|
- 영상 ID(`videos.id`)는 32-char UUID(`IdGenerator.newId()`), `video_id` 는 YouTube 11자 ID.
|
||||||
|
- admin 권한 사용자만 모든 mutation/SSE 를 호출한다.
|
||||||
|
- 운영자는 `cookies.txt` 를 백엔드 작업 디렉토리에 두어 Playwright 로그인을 우회한다.
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
- 모듈/파일:
|
||||||
|
- `controller/VideoController.java` — 동기 CRUD/단건 작업.
|
||||||
|
- `controller/VideoSseController.java` — SSE 다건 작업 (Virtual Thread executor).
|
||||||
|
- `service/VideoService.java` — Mapper 위임 + transcript/evaluation 정규화.
|
||||||
|
- `service/YouTubeService.java` — YouTube API + Playwright + transcript-api.
|
||||||
|
- `mapper/VideoMapper.java` (+ `mybatis/mapper/VideoMapper.xml`) — DB 접근.
|
||||||
|
- 도메인: `VideoSummary`, `VideoDetail`, `VideoRestaurantLink`.
|
||||||
|
- I/O ↔ 순수 로직 경계:
|
||||||
|
- **I/O**: YouTube REST 호출, Playwright 브라우저, DB INSERT/UPDATE, SSE emit.
|
||||||
|
- **순수 로직**: `parseDuration`, `filterShorts` 필터 조건, `evaluation` JSON 정규화 (`JsonUtil.normalizeEvaluation`), 페이지네이션 중단 조건(`publishedAfter` 이전 발견 시 break).
|
||||||
|
|
||||||
|
```
|
||||||
|
[Admin UI] --HTTP--> VideoController ---> VideoService ---> VideoMapper ---> Oracle
|
||||||
|
\---> YouTubeService --(WebClient)--> YouTube Data API v3
|
||||||
|
--(Playwright)--> youtube.com
|
||||||
|
--(transcript-api)--> timedtext
|
||||||
|
|
||||||
|
[Admin UI] --SSE--> VideoSseController --(VirtualThread)--> {
|
||||||
|
YouTubeService.createBrowserSession + getTranscriptWithPage
|
||||||
|
PipelineService.processExtract (#270)
|
||||||
|
OciGenAiService.chat (cuisine/foods remap)
|
||||||
|
RestaurantService.update* (#268)
|
||||||
|
} --emit JSON event--> Admin UI
|
||||||
|
|
||||||
|
[Pipeline scan] cron/daemon --> YouTubeService.scanAllChannels
|
||||||
|
--> ChannelService + VideoService.saveVideosBatch
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 데이터 모델
|
||||||
|
- **입력**:
|
||||||
|
- `POST /{id}/fetch-transcript` 쿼리: `mode ∈ {auto, manual, generated}`.
|
||||||
|
- `POST /{id}/upload-transcript` body: `{ text: string(≥1), source?: string }`.
|
||||||
|
- `POST /{id}/extract` body(옵션): `{ prompt?: string }`.
|
||||||
|
- `POST /{videoId}/restaurants/manual` body: `{ name(필수), address?, region?, cuisine_type?, price_range?, foods_mentioned?: string|string[], guests?: string|string[], evaluation?: string }`.
|
||||||
|
- `PUT /{videoId}/restaurants/{restaurantId}` body: 위 필드 + 이름/주소 변경 시 재-geocode.
|
||||||
|
- SSE body: `{ ids?: string[] }` (없으면 전체 pending).
|
||||||
|
- **출력**:
|
||||||
|
- `VideoSummary` 목록 (id, videoId, title, url, status, publishedAt, channelName, hasTranscript, hasLlm, restaurantCount, matchedCount).
|
||||||
|
- `VideoDetail` = summary + `transcript`(CLOB) + `restaurants: VideoRestaurantLink[]`.
|
||||||
|
- `VideoRestaurantLink`: restaurantId, name, address, cuisineType, priceRange, region, foodsMentioned(@JsonRawValue JSON), evaluation(@JsonRawValue JSON), guests(@JsonRawValue JSON), googlePlaceId, lat/lng, `hasLocation` 파생.
|
||||||
|
- SSE 이벤트 공통 키: `type ∈ {start, processing, done, skip, error, api_pass, wait, batch_done, retry, complete}`.
|
||||||
|
- **저장**:
|
||||||
|
- `videos(id PK, channel_id FK, video_id, title, url, published_at, status, transcript_text CLOB, llm_response CLOB)`.
|
||||||
|
- `video_restaurants(video_id, restaurant_id, foods_mentioned IS JSON, evaluation IS JSON, guests IS JSON)` — `evaluation` 컬럼은 DB CHECK `IS JSON` 제약.
|
||||||
|
- **검증 규칙**:
|
||||||
|
- `title`, `text` blank 금지 → 400.
|
||||||
|
- `evaluation` 문자열은 JSON 리터럴(`{`/`"` 시작)이 아니면 `JsonUtil.toJson` 으로 문자열 래핑.
|
||||||
|
- transcript 8000자 초과는 ExtractorService 가 머리/꼬리만 남기고 절단(`#270`).
|
||||||
|
|
||||||
|
## 7. 함수 명세 (Function Specs)
|
||||||
|
|
||||||
|
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|
||||||
|
|------|-----------|----------------|------|------|-----------|-------|
|
||||||
|
| `VideoController.list` | 상태별 영상 목록 | `list(String status)` | status (옵션) | `List<VideoSummary>` | 없음 (빈 배열) | 단순 |
|
||||||
|
| `VideoController.detail` | 영상 상세 + 식당 링크 | `detail(String id)` | id | `VideoDetail` | 404 NotFound | 단순 |
|
||||||
|
| `VideoController.updateTitle` | 제목 수정 | `updateTitle(id, body)` | id, title | `{ok}` | 400 blank, 403 admin | 단순 |
|
||||||
|
| `VideoController.skip` | 영상 skip 처리 | `skip(id)` | id | `{ok}` | 403 | 단순 |
|
||||||
|
| `VideoController.delete` | 영상 + 종속 cascade 삭제 | `delete(id)` | id | `{ok}` | 403, TX rollback | **복잡** |
|
||||||
|
| `VideoController.deleteVideoRestaurant` | 영상-식당 링크 + 고아 정리 | `deleteVideoRestaurant(videoId, restaurantId)` | id 2종 | `{ok}` | 403 | **복잡** |
|
||||||
|
| `VideoController.fetchTranscript` | 자막 자동 수집(browser→api) | `fetchTranscript(id, mode)` | id, mode | `{ok,length,source}` | 400 자막없음, 404 | **복잡** |
|
||||||
|
| `VideoController.uploadTranscript` | 외부 수집 자막 저장 | `uploadTranscript(id, body)` | id, text | `{ok,length,source}` | 400, 404 | 단순 |
|
||||||
|
| `VideoController.getExtractPrompt` | LLM 추출 프롬프트 조회 | `getExtractPrompt()` | — | `{prompt}` | 없음 | 단순 |
|
||||||
|
| `VideoController.extract` | 단건 LLM 추출 실행 | `extract(id, body)` | id, prompt? | `{ok,count}` | 400 transcript 없음 | **복잡** |
|
||||||
|
| `VideoController.bulkExtractPending` | 추출 대상 영상 목록 | `bulkExtractPending()` | — | `{count,videos}` | 없음 | 단순 |
|
||||||
|
| `VideoController.bulkTranscriptPending` | 자막 미보유 영상 목록 | `bulkTranscriptPending()` | — | `{count,videos}` | 없음 | 단순 |
|
||||||
|
| `VideoController.addManualRestaurant` | 수동 식당 추가 + geocode + 링크 | `addManualRestaurant(videoId, body)` | videoId, body | `{ok, restaurant_id}` | 400 name 없음 | **복잡** |
|
||||||
|
| `VideoController.updateVideoRestaurant` | 링크/식당 필드 수정 + 재-geocode | `updateVideoRestaurant(videoId, restaurantId, body)` | id 2종, fields | `{ok}` | 403 | **복잡** |
|
||||||
|
| `VideoSseController.bulkTranscript` | SSE 다건 자막 (browser→api 2pass) | `bulkTranscript(body)` | ids? | `SseEmitter` | emit error/skip | **복잡** |
|
||||||
|
| `VideoSseController.bulkExtract` | SSE 다건 LLM 추출 | `bulkExtract(body)` | ids? | `SseEmitter` | emit error | **복잡** |
|
||||||
|
| `VideoSseController.remapCuisine` | cuisine_type 재분류 (배치+retry) | `remapCuisine()` | — | `SseEmitter` | LLM 실패 시 retry | **복잡** |
|
||||||
|
| `VideoSseController.remapFoods` | foods_mentioned 재생성 | `remapFoods()` | — | `SseEmitter` | LLM 실패 시 retry | **복잡** |
|
||||||
|
| `VideoSseController.rebuildVectors` | 벡터 재생성 자리 | `rebuildVectors()` | — | `SseEmitter` | TODO 비어있음 | 단순 |
|
||||||
|
| `VideoSseController.process` | 동기 N건 파이프라인 | `process(limit)` | limit (≤?) | `{count}` | 없음 | 단순 |
|
||||||
|
| `VideoService.findDetail` | 상세 + 링크 + evaluation 정규화 | `findDetail(id)` | id | `VideoDetail|null` | null 가능 | 단순 |
|
||||||
|
| `VideoService.delete` | 트랜잭션 6단계 정리 | `delete(id)` | id | void | TX rollback | **복잡** |
|
||||||
|
| `VideoService.deleteVideoRestaurant` | 링크 + 고아 cleanup | `deleteVideoRestaurant(...)` | id 2종 | void | TX rollback | **복잡** |
|
||||||
|
| `VideoService.saveVideosBatch` | 중복 제외 신규 영상 일괄 insert | `saveVideosBatch(channelId, videos)` | dbId, list | 저장 건수 | 부분 실패 시 catch 없음 | 단순 |
|
||||||
|
| `VideoService.findPendingVideos` | pending 상태 영상 N개 | `findPendingVideos(limit)` | limit | `List<Map>` | 없음 | 단순 |
|
||||||
|
| `VideoService.findVideosForBulkExtract` | transcript 보유/추출 미실행 | `findVideosForBulkExtract()` | — | `List<Map>` (CLOB 읽음) | CLOB read 실패 | 단순 |
|
||||||
|
| `VideoService.findVideosWithoutTranscript` | 자막 미보유 영상 | `findVideosWithoutTranscript()` | — | `List<Map>` | 없음 | 단순 |
|
||||||
|
| `VideoService.updateTranscript` / `updateStatus` / `updateTitle` / `updateVideoFields` | 컬럼 갱신 | — | id + values | void | 없음 | 단순 |
|
||||||
|
| `VideoService.updateVideoRestaurantFields` | foods/evaluation/guests JSON 갱신 | — | ids + 3 JSON | void | DB JSON 제약 위반 시 throw | 단순 |
|
||||||
|
| `YouTubeService.fetchChannelVideos` | 업로드 플레이리스트 페이징 | `(channelId, after, excludeShorts)` | params | `List<Map>` | 예외 시 Search 폴백 | **복잡** |
|
||||||
|
| `YouTubeService.fetchChannelVideosViaSearch` | Search API 폴백 | 동일 | params | `List<Map>` | 파싱 실패 시 break | **복잡** |
|
||||||
|
| `YouTubeService.filterShorts` | 50개씩 duration 조회 후 60초↑ 필터 | `(videos)` | list | filtered list | 배치 실패 시 default 61 (포함) | **복잡** |
|
||||||
|
| `YouTubeService.parseDuration` | ISO8601 → 초 | `(dur)` | `PT#H#M#S` | int | regex unmatch → 0 | 단순 |
|
||||||
|
| `YouTubeService.scanChannel` | 채널 단건 스캔 + 저장 | `(channelId, full)` | id, full | `{total_fetched,new_videos,filtered}` | 채널 미존재 → null | **복잡** |
|
||||||
|
| `YouTubeService.scanAllChannels` | 활성 채널 전부 스캔 | `()` | — | int | 채널별 예외 catch+log | **복잡** |
|
||||||
|
| `YouTubeService.getTranscript` | 브라우저 → API 자막 | `(videoId, mode)` | id, mode | `TranscriptResult|null` | null 반환 | **복잡** |
|
||||||
|
| `YouTubeService.getTranscriptApi` | thoroldvix API 호출 | `(videoId, mode)` | id, mode | `TranscriptResult|null` | 예외 시 null | **복잡** |
|
||||||
|
| `YouTubeService.getTranscriptWithPage` | 기존 Page 재사용 | `(page, videoId)` | page, id | 동일 | 동일 | **복잡** |
|
||||||
|
| `YouTubeService.createBrowserSession` | Playwright+Browser+Page lifecycle | `()` | — | `BrowserSession` (`AutoCloseable`) | 실패 시 throw | **복잡** |
|
||||||
|
| `YouTubeService.fetchTranscriptFromPage` | 페이지 조작 + 세그먼트 스크롤 수집 | `(page, videoId)` | page, id | result|null | 다단계 catch | **복잡** |
|
||||||
|
| `YouTubeService.skipAds` / `selectKorean` / `loadCookies` | 페이지 보조 동작 | — | page | void | 무시 가능 | 단순 |
|
||||||
|
|
||||||
|
> 복잡 표시 함수는 외부 I/O + 다단계 폴백/상태기계 포함. 별도 `fn-*.md` 가 필요한 경우 우선순위는 `fetchTranscriptFromPage`, `bulkTranscript`, `delete`(영상 cascade), `scanChannel`.
|
||||||
|
|
||||||
|
## 8. 흐름 / 알고리즘
|
||||||
|
1. **채널 스캔 (daemon/cron):**
|
||||||
|
`scanAllChannels` → 각 채널에 `scanChannel(false)` → `fetchChannelVideos(channelId, latestPublishedAt, true)`. PlaylistItems(UC→UU) 50건 페이지 반복, `publishedAfter` 이전 항목 발견 시 즉시 중단. titleFilter + 기존 video_id 셋 비교 후 `saveVideosBatch` 로 신규만 insert.
|
||||||
|
2. **단건 자막 수집:**
|
||||||
|
`getTranscript` → `getTranscriptBrowser` (Playwright headed, cookies.txt 로드, `--disable-blink-features=AutomationControlled`). `skipAds` (광고 스킵/음소거/끝 이동), 더보기 클릭, "스크립트 표시" 버튼 탐색(aria-label → text → engagement panel), 세그먼트 0→폴링 10회×1.5s, `selectKorean` 시도, 컨테이너 스크롤 50회로 전체 수집. 실패 시 `getTranscriptApi` (manual→generated, ko→en).
|
||||||
|
3. **단건 추출:**
|
||||||
|
`VideoController.extract` → transcript 검증 → `PipelineService.processExtract(video, transcript, prompt)` 호출(상세 흐름 #270). 결과 식당 수 응답.
|
||||||
|
4. **SSE bulk-transcript:**
|
||||||
|
대상 결정(ids vs 전체) → `start{total}` emit → Pass1: 단일 `BrowserSession` 으로 순회, 각 영상 `processing{method=browser}` → `done` 또는 `skip` 후 `apiNeeded` 누적, 3~8s 랜덤 sleep. Pass2: `api_pass{count}` → 실패분만 `getTranscriptApi`, 결과에 따라 `done`/`error`+`status=no_transcript`. 최종 `complete{success,failed}`.
|
||||||
|
5. **SSE bulk-extract:**
|
||||||
|
대상 `findVideosForBulkExtract` (transcript 있고 추출 미실행) → 영상별 3~8s `wait` → `processExtract` → `done{restaurants}` / `error`. 총 결과 > 0 이면 `cache.flush()`.
|
||||||
|
6. **SSE remap-cuisine / remap-foods:**
|
||||||
|
대상 식당을 BATCH(20/15)로 묶어 LLM 일괄 분류 호출 → 결과 매핑 후 누락 식당은 `missed` 로 격리. 누락 항목은 size 5 배치로 최대 3회 retry. 각 단계마다 `batch_done`/`retry`/`complete` emit, 종료 시 `cache.flush()`.
|
||||||
|
7. **단일 영상 삭제 cascade:** `deleteVectorsByVideoOnly` → `deleteReviewsByVideoOnly` → `deleteFavoritesByVideoOnly` → `deleteRestaurantsByVideoOnly` → `deleteVideoRestaurants` → `deleteVideo` (단일 `@Transactional`).
|
||||||
|
|
||||||
|
## 9. 엣지케이스 & 에러 처리
|
||||||
|
- **PlaylistItems 실패**: try/catch → Search API 폴백. Search 도 실패하면 빈 리스트 → 신규 0.
|
||||||
|
- **publishedAfter 이전 영상 발견**: 업로드 재생목록은 시간 역순이므로 즉시 nextPage=null 로 페이지 종료 (불필요 호출 차단).
|
||||||
|
- **Shorts duration API 실패**: 해당 배치의 모든 video 는 `default=61` (포함) 으로 처리해 누락 방지.
|
||||||
|
- **transcript 없음**: 단건은 400, bulk Pass2 실패 시 status=`no_transcript`, error emit.
|
||||||
|
- **YouTube 봇 탐지**: Pass1 에서 cookies.txt 로드 + headed + 3~8s 랜덤 지연 + navigator.webdriver=false 마스킹.
|
||||||
|
- **광고 무한 루프**: `skipAds` 최대 30회 (≈30s) 후 강제 진행.
|
||||||
|
- **세그먼트 미수신**: 1.5s × 10회 폴링 후 0이면 빈 응답 → API 폴백.
|
||||||
|
- **CLOB 직렬화**: `JsonUtil.readClob` 으로 안전 변환, `@JsonRawValue` 로 JSON 컬럼은 원형 유지.
|
||||||
|
- **evaluation 형식 깨짐**: `JsonUtil.normalizeEvaluation` 으로 평문→JSON 문자열 래핑 + 300자 제한.
|
||||||
|
- **SSE 클라이언트 중단**: `emit` 내부 `Exception` 은 debug 로그만 남기고 emitter 종료. timeout(30/10분) 초과 시 자동 종료.
|
||||||
|
- **LLM 응답 누락**: remap 시 `CuisineTypes.isValid` 가 false 이면 missed 로 옮겨 retry, 끝까지 실패하면 그대로 노출 (`missed` 카운트).
|
||||||
|
- **DB IS JSON 제약**: `evaluation` 문자열 → `{`/`"` 검사 후 `JsonUtil.toJson` 래핑.
|
||||||
|
- **고아 데이터 차단**: 영상 삭제와 링크 단건 삭제 모두 `cleanupOrphan*` 호출.
|
||||||
|
- **안전 기본값**: YouTube API 통째 실패 시 빈 결과, transcript 실패 시 상태만 변경, LLM/Geocoding 실패는 식당 미생성으로 종결 (DB 손상 차단).
|
||||||
|
|
||||||
|
## 10. 테스트 계획
|
||||||
|
- **단위(JUnit5 + Mockito) — VideoService**
|
||||||
|
- `delete` 호출 순서: 6개 mapper 메서드 호출 검증 (벡터→리뷰→즐겨찾기→식당→링크→영상).
|
||||||
|
- `findDetail` null/빈 restaurants 케이스 normalizeEvaluation 호출 검증.
|
||||||
|
- `saveVideosBatch` 중복 비율 (existing set hit 시 0, 미스 시 새 ID 생성).
|
||||||
|
- **단위 — YouTubeService**
|
||||||
|
- `parseDuration` 경계값 (`PT60S=60`, `PT1M1S=61`, `PT1H=3600`, 빈 문자열=0, 오작동 입력=0).
|
||||||
|
- `filterShorts`: duration map 60 이하 제외, 누락 ID 는 기본 61 (포함).
|
||||||
|
- `fetchChannelVideos` 페이징 중단 (publishedAfter 이전 발견 즉시 break).
|
||||||
|
- `getTranscriptApi` mode 분기 (manual/generated/auto).
|
||||||
|
- **통합 (Spring + WireMock/MockWebServer)**
|
||||||
|
- YouTube API 모킹 → `scanChannel` 가 신규 N개 저장.
|
||||||
|
- LLM 모킹 → SSE `bulkExtract` 가 start/processing/done/complete 시퀀스 emit.
|
||||||
|
- `bulkTranscript` 는 Playwright 모킹이 어려우므로 `getTranscriptWithPage` 를 Mockito 로 대체.
|
||||||
|
- **E2E (수동 dev)**
|
||||||
|
- Playwright headed transcript 수집 1건 / bulk 10건.
|
||||||
|
- `DELETE /api/videos/{id}` 후 식당/링크/벡터 카운트 0 확인 (SQL).
|
||||||
|
- **인수조건 매핑**: AC1↔detail unit, AC2↔delete cascade unit+SQL, AC3↔scanChannel 통합, AC4↔fetchTranscript 통합, AC5↔bulkTranscript E2E, AC6↔모든 admin 엔드포인트 403 unit.
|
||||||
|
- **모킹/드라이런**: YouTube/Google API → MockWebServer, `OciGenAiService.chat` → Mockito stub (고정 JSON 반환).
|
||||||
|
|
||||||
|
## 11. 리스크 & 대안 검토
|
||||||
|
- **Playwright Headed (선택)**: ko 자막 정확도/봇 탐지 회피 우수. 단, Mac mini Dev 환경 의존. 대안: youtube-transcript-api (제한 많음), Whisper STT (비용/시간). → 운영(OKE)에서는 사용 안 함, 자막은 dev 에서 사전 확보.
|
||||||
|
- **SSE (선택)**: 30분 작업 진행 표시 단순. 대안: WebSocket(과한 양방향), 폴링(부정확). 트레이드오프: 한 작업이 emitter 1개 점유 → 동시 다발 사용 시 메모리 압박 (현재 admin 단독 사용 가정).
|
||||||
|
- **LLM 재분류 단일 트랜잭션 없음**: 결과 즉시 update + missed 별도 retry → 부분 성공 허용. 대안: 전부 임시 테이블 stage → 검토 후 swap (운영 부담 증가). 현재 데이터 양 < 수천 식당이라 부분 적용 수용.
|
||||||
|
- **video cascade 삭제**: 향후 ON DELETE CASCADE FK 적용 시 mapper 6단계 → 1단계로 단순화 가능 → **ADR 후보** (`adr/0001-video-cascade.md`).
|
||||||
|
- **transcript CLOB 크기**: 8000자 truncate 는 ExtractorService 가 담당, DB 는 CLOB 그대로 보관.
|
||||||
|
|
||||||
|
## 12. 미해결 질문 (Open Questions)
|
||||||
|
- `rebuildVectors` SSE 가 TODO 상태 — 전 식당 벡터 재계산 시 OCI GenAI 호출 비용/시간 산정 필요.
|
||||||
|
- `scanAllChannels` 일정 (daemon 주기? cron?) 은 #275 에서 확정 예정.
|
||||||
|
- `bulkTranscript` 가 Playwright 헤드모드를 요구해 prod 미지원 — 헤드리스 우회/Whisper STT 도입 여부.
|
||||||
|
- `evaluation`/`foods_mentioned` JSON 스키마 표준화 (현재 문자열/배열 혼재).
|
||||||
|
- Search API 폴백 quota 초과 시 사용자 메시지 (UI 표시) 부재.
|
||||||
247
docs/design/270-backend-extract-pipeline/README.md
Normal file
247
docs/design/270-backend-extract-pipeline/README.md
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
|
||||||
|
|
||||||
|
# 설계서: 백엔드 - 영상→식당 추출 파이프라인 (LLM+Geocoding) (#270)
|
||||||
|
|
||||||
|
> **상태**: Approved <!-- Draft | Approved | Superseded -->
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #270 · 관련 ADR: 없음
|
||||||
|
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/ExtractorService.java`, `backend-java/src/main/java/com/tasteby/service/PipelineService.java`, `backend-java/src/main/java/com/tasteby/service/OciGenAiService.java`, `backend-java/src/main/java/com/tasteby/service/GeocodingService.java` · 테스트: TBD (현재 없음)
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
유튜브 영상 자막에서 식당 정보를 LLM 으로 구조화하고, Google Maps 로 좌표/메타데이터를 보강한 뒤 DB+벡터 인덱스에 저장하여 지도/검색이 즉시 노출되도록 한다. 운영자가 단건/대량 모두 동일한 멱등 파이프라인을 호출할 수 있어야 한다.
|
||||||
|
|
||||||
|
## 2. 범위 (Scope)
|
||||||
|
- **포함**:
|
||||||
|
- LLM 프롬프트 정의(`ExtractorService.EXTRACT_PROMPT`) — 7개 필드 추출, `CuisineTypes` 표준 카테고리 강제, 한국어 응답 강제.
|
||||||
|
- OCI Generative AI (Cohere/Llama 계열) Chat & Embed 호출 + 결과 JSON 견고 파싱 (마크다운 블록 제거, 트레일링 콤마 제거, 부분 array 복구).
|
||||||
|
- Google Maps Places Text Search → Place Details → Geocoding API 폴백.
|
||||||
|
- 한국어 주소 → `나라|시/도|구/군` 형식 region 파싱.
|
||||||
|
- 추출 결과로 식당 upsert + 영상↔식당 링크 + 벡터 임베딩 저장.
|
||||||
|
- 파이프라인 상태 전이: `pending → processing → done/error`.
|
||||||
|
- **제외 (out of scope)**:
|
||||||
|
- 자막 확보(`YouTubeService`) — #269.
|
||||||
|
- 검색/추천 질의 (`VectorService.searchSimilar`) — #271.
|
||||||
|
- 식당 CRUD/병합 — #268.
|
||||||
|
- 어드민 UI 트리거 화면 — #282.
|
||||||
|
|
||||||
|
## 3. 인수조건 (Acceptance Criteria)
|
||||||
|
- [ ] `ExtractorService.extractRestaurants(title, transcript, prompt?)` 는 transcript 8000자 초과 시 머리 7000 + 꼬리 1000 으로 절단하고, LLM 응답이 JSON array/object/빈값 어떤 형태든 `List<Map>` 으로 정규화한다.
|
||||||
|
- [ ] `PipelineService.processExtract` 는 추출된 각 식당에 대해 (a) Geocoding → (b) `RestaurantService.upsert` → (c) `linkVideoRestaurant` → (d) `VectorService.saveRestaurantVectors` 순으로 실행하고, 0건이어도 영상 상태를 `done` 으로 갱신한다.
|
||||||
|
- [ ] `OciGenAiService.parseJson` 은 ```json``` 코드 블록, 트레일링 콤마, 잘린 array 를 자동 복구하며, 끝내 실패하면 `RuntimeException("JSON parse failed: ...")` 을 던진다.
|
||||||
|
- [ ] `GeocodingService.geocodeRestaurant` 는 Places Text Search 성공 시 phone/website 까지 채워 반환하고, 실패 시 Geocoding API 로 폴백하며, 둘 다 실패하면 `null` 을 반환한다 (식당은 좌표 없이 저장됨).
|
||||||
|
- [ ] `evaluation` 필드는 항상 DB 의 `IS JSON` 제약을 통과하도록 JSON 문자열 리터럴(`"..."`) 또는 객체 JSON 으로 변환된 뒤 저장된다.
|
||||||
|
- [ ] `processVideo` 가 자막 미존재 시 status=`done` 으로 종결하고, 예외 발생 시 `error` 로 마킹하여 다음 daemon 실행을 차단하지 않는다.
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
- **의존성**:
|
||||||
|
- OCI Generative AI Inference SDK (`com.oracle.bmc.generativeaiinference`) — `~/.oci/config` 기반 인증, compartment/endpoint/model 은 `app.oci.*` 프로퍼티.
|
||||||
|
- Google Maps Platform — `app.google.maps-api-key` (Places + Geocoding 동일 키).
|
||||||
|
- Oracle 23ai (`restaurants`, `video_restaurants`, `restaurant_vectors` VECTOR 컬럼).
|
||||||
|
- 내부: `YouTubeService` (transcript), `RestaurantService.upsert/linkVideoRestaurant`, `VectorService.saveRestaurantVectors`, `VideoService.updateVideoFields`, `CacheService.flush`.
|
||||||
|
- 유틸: `CuisineTypes.CUISINE_LIST_TEXT`, `JsonUtil.toJson`.
|
||||||
|
- **제약**:
|
||||||
|
- OCI Chat `maxTokens=8192`, `temperature=0.0`, Embed batch 최대 96.
|
||||||
|
- Google Maps 호출당 10초 타임아웃, 일일 quota/요금 관리 필요.
|
||||||
|
- transcript 8000자 절단(LLM context 한계 회피).
|
||||||
|
- `restaurant_vectors.embedding` 은 Oracle VECTOR(float[]), `MapSqlParameterSource` 로 직접 바인딩 (#271 VectorService).
|
||||||
|
- LLM 응답이 비결정적이므로 cuisine_type 검증/재맵핑은 사후 워크플로(`remap-cuisine` SSE)에 의존.
|
||||||
|
- **가정**:
|
||||||
|
- 영상 1건당 식당 평균 1~5개, 전체 transcript 평균 ~3000자.
|
||||||
|
- OCI 인증이 없으면 (`PostConstruct` 경고) chat/embed 호출은 `IllegalStateException` → 추출 파이프라인 전체 실패 (`processVideo` 가 `error` 로 마킹).
|
||||||
|
- Google 한국어(`language=ko`) 결과를 신뢰; 해외 식당은 Places 결과 그대로 사용.
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
- 모듈/파일:
|
||||||
|
- `service/ExtractorService.java` — 프롬프트 + LLM 호출 + 결과 정규화.
|
||||||
|
- `service/PipelineService.java` — 워크플로 오케스트레이션 + 상태 전이.
|
||||||
|
- `service/OciGenAiService.java` — OCI GenAI SDK 어댑터 (chat/embed/JSON 복구).
|
||||||
|
- `service/GeocodingService.java` — Google Maps WebClient 클라이언트 + 주소 region 파싱.
|
||||||
|
- 협력: `YouTubeService` (#269), `RestaurantService` (#268), `VectorService` (#271), `VideoService` (#269), `CacheService` (#276), `util/CuisineTypes`, `util/JsonUtil`.
|
||||||
|
- I/O ↔ 순수 로직 경계:
|
||||||
|
- **I/O**: OCI GenAI 호출, Google Maps HTTP, DB 쓰기, transcript 호출.
|
||||||
|
- **순수 로직**: `EXTRACT_PROMPT` 합성, transcript 절단, `parseJson` 복구 로직, `parseRegionFromAddress`, `VectorService.buildChunks`, evaluation JSON 정규화.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌──────────────────┐
|
||||||
|
│ daemon/cron │ ─────▶ │ PipelineService │
|
||||||
|
└─────────────┘ │ processVideo() │
|
||||||
|
└────────┬─────────┘
|
||||||
|
│ 1. transcript
|
||||||
|
▼
|
||||||
|
┌──────────────────┐
|
||||||
|
│ YouTubeService │ (#269)
|
||||||
|
└────────┬─────────┘
|
||||||
|
│ 2. LLM extract
|
||||||
|
▼
|
||||||
|
┌──────────────────┐ chat(prompt, 8192)
|
||||||
|
│ ExtractorService │───────────────────────▶ OCI GenAI
|
||||||
|
└────────┬─────────┘ parseJson(raw) (Chat model)
|
||||||
|
│ List<Map<String,Object>>
|
||||||
|
▼
|
||||||
|
┌──────────── PipelineService.processExtract ────────────┐
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────┐ ┌──────────────────┐
|
||||||
|
│ GeocodingService │──HTTP──▶ Google Maps │ ExtractorService │
|
||||||
|
│ placesTextSearch │ Places + Geocode │ (재사용 가능) │
|
||||||
|
│ → placeDetails │ └──────────────────┘
|
||||||
|
│ → geocode(폴백) │
|
||||||
|
└────────┬─────────┘
|
||||||
|
│ {lat,lng,formatted_address,phone,...}
|
||||||
|
▼
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│ RestaurantService.upsert │──▶ Oracle restaurants
|
||||||
|
│ + linkVideoRestaurant │──▶ Oracle video_restaurants (IS JSON)
|
||||||
|
└────────┬─────────────────┘
|
||||||
|
│ restId
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐ embedTexts(chunks)
|
||||||
|
│ VectorService.saveRestVectors│───────────────────────▶ OCI GenAI Embed
|
||||||
|
│ buildChunks(...) │ → Oracle restaurant_vectors
|
||||||
|
└────────┬─────────────────────┘
|
||||||
|
│ count
|
||||||
|
▼
|
||||||
|
videoService.updateVideoFields(status=done, llmRaw)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
cacheService.flush()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 데이터 모델
|
||||||
|
- **입력 (LLM 프롬프트 출력 = 파이프라인 입력)**:
|
||||||
|
```jsonc
|
||||||
|
[{
|
||||||
|
"name": "string (필수)",
|
||||||
|
"address": "string|null",
|
||||||
|
"region": "나라|시/도|구/군 (string|null)",
|
||||||
|
"cuisine_type": "CuisineTypes.CUISINE_LIST_TEXT 중 하나",
|
||||||
|
"price_range": "string|null",
|
||||||
|
"foods_mentioned": ["string", ...] // 최대 10, 한글
|
||||||
|
"evaluation": "string ≤ 100자",
|
||||||
|
"guests": ["string", ...]
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
- **중간 데이터 (Geocoding 결과)**:
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"latitude": double,
|
||||||
|
"longitude": double,
|
||||||
|
"formatted_address": "string",
|
||||||
|
"google_place_id": "string",
|
||||||
|
"business_status": "OPERATIONAL|CLOSED_TEMPORARILY|...",
|
||||||
|
"rating": double,
|
||||||
|
"rating_count": int,
|
||||||
|
"phone": "string",
|
||||||
|
"website": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **저장 구조**:
|
||||||
|
- `restaurants`: name, address(=geo.formatted_address || LLM.address), region(LLM 우선), latitude/longitude, cuisine_type, price_range, google_place_id, phone, website, business_status, rating, rating_count.
|
||||||
|
- `video_restaurants(video_id, restaurant_id, foods_mentioned IS JSON, evaluation IS JSON, guests IS JSON)`.
|
||||||
|
- `restaurant_vectors(id, restaurant_id, chunk_text CLOB, embedding VECTOR)` — `VectorService.buildChunks` 결과(name/region/cuisine/foods/evaluation/price/video_title)를 한 chunk 로 임베딩.
|
||||||
|
- `videos.status, transcript_text CLOB, llm_response CLOB`.
|
||||||
|
- **검증 규칙**:
|
||||||
|
- `name`이 null 인 식당 항목은 skip.
|
||||||
|
- transcript > 8000 → 절단.
|
||||||
|
- evaluation: 객체→`JsonUtil.toJson`, 문자열→`JsonUtil.toJson(s)` (DB IS JSON 통과 보장).
|
||||||
|
- cuisine_type 표준 목록 위반은 저장은 허용하되 `remap-cuisine` SSE 로 사후 보정.
|
||||||
|
- `transcript_text` is blank → ExtractorService 호출 전 단건 API 가 400 반환 (#269).
|
||||||
|
|
||||||
|
## 7. 함수 명세 (Function Specs)
|
||||||
|
|
||||||
|
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|
||||||
|
|------|-----------|----------------|------|------|-----------|-------|
|
||||||
|
| `ExtractorService.getPrompt` | 기본 프롬프트 반환 | `String getPrompt()` | — | EXTRACT_PROMPT | 없음 | 단순 |
|
||||||
|
| `ExtractorService.extractRestaurants` | LLM 추출 + JSON 정규화 | `ExtractionResult extractRestaurants(title, transcript, prompt?)` | title, transcript, prompt | `{restaurants: List<Map>, rawResponse}` | catch 후 빈 결과 + log | **복잡** |
|
||||||
|
| `PipelineService.processVideo` | 단건 영상 end-to-end | `int processVideo(Map<String,Object>)` | video map | 식당 수 | 예외 → status=error | **복잡** |
|
||||||
|
| `PipelineService.processExtract` | 기존 transcript 로 LLM+저장 | `int processExtract(video, transcript, prompt?)` | 동일 | 식당 수 | 부분 실패 catch (vector save) | **복잡** |
|
||||||
|
| `PipelineService.processPending` | N건 일괄 처리 | `int processPending(int limit)` | limit | 총 식당 수 | 빈 결과시 0 | 단순 |
|
||||||
|
| `PipelineService.updateVideoStatus` (private) | 상태/transcript/llm 갱신 | `void(...)` | id, status, ... | void | DB 예외 throw | 단순 |
|
||||||
|
| `OciGenAiService.init` (PostConstruct) | OCI 클라이언트 초기화 | `void init()` | — | void | 인증 실패 시 log warn | 단순 |
|
||||||
|
| `OciGenAiService.destroy` (PreDestroy) | 클라이언트 종료 | `void destroy()` | — | void | 없음 | 단순 |
|
||||||
|
| `OciGenAiService.chat` | LLM Chat 호출 | `String chat(prompt, maxTokens)` | prompt, max | text | SDK 예외, null client | **복잡** |
|
||||||
|
| `OciGenAiService.embedTexts` | 텍스트 임베딩 (96 배치) | `List<List<Double>> embedTexts(texts)` | texts | 임베딩 매트릭스 | SDK 예외 | **복잡** |
|
||||||
|
| `OciGenAiService.embedBatch` (private) | 단일 배치 임베딩 | `(texts)` | ≤96 texts | 임베딩 | SDK 예외 | 단순 |
|
||||||
|
| `OciGenAiService.parseJson` | LLM 응답 견고 파싱 | `Object parseJson(String raw)` | raw text | List/Map/scalar | 부분 복구 후 throw | **복잡** |
|
||||||
|
| `GeocodingService.geocodeRestaurant` | Places → Geocoding 폴백 | `Map geocodeRestaurant(name, address)` | name+addr | geo map\|null | 두 단계 모두 catch | **복잡** |
|
||||||
|
| `GeocodingService.placesTextSearch` (private) | Places Text Search | `(query)` | string | map\|null | 4xx/5xx catch | **복잡** |
|
||||||
|
| `GeocodingService.placeDetails` (private) | phone/website 보강 | `(placeId)` | string | map\|null | catch | 단순 |
|
||||||
|
| `GeocodingService.geocode` (private) | Geocoding API | `(query)` | string | map\|null | catch | 단순 |
|
||||||
|
| `GeocodingService.parseRegionFromAddress` (static) | 한국 주소 → region 코드 | `(address)` | string | `한국\|시\|구` 또는 null | 빈 입력 → null | 단순 |
|
||||||
|
|
||||||
|
> 복잡 표시 함수: 모두 외부 I/O + 다단계 복구 경로. `parseJson` 과 `processExtract` 는 동작 다이어그램 별도 작성 권장.
|
||||||
|
|
||||||
|
## 8. 흐름 / 알고리즘
|
||||||
|
1. **transcript 확보 (단건 daemon 경로)**: `processVideo` → `updateVideoStatus(processing)` → `YouTubeService.getTranscript(videoId, "auto")`. null/blank → `done` 마킹 후 0 반환.
|
||||||
|
2. **transcript 컨텍스트 정리**: `ExtractorService.extractRestaurants` 입장에서 길이>8000 이면 `head(0,7000) + "...(중략)..." + tail(len-1000)` 으로 가운데를 잘라낸다.
|
||||||
|
3. **프롬프트 합성**: `customPrompt ?: EXTRACT_PROMPT` 에 `{title}`, `{transcript}` 단순 치환. `CUISINE_LIST_TEXT` 는 컴파일 타임에 포맷됨.
|
||||||
|
4. **LLM 호출**: `OciGenAiService.chat(prompt, 8192)` — `GenericChatRequest(temperature=0.0)` 으로 `UserMessage(TextContent)` 전송. 응답에서 `GenericChatResponse.choices[0].message.content[0].text` 추출 후 trim.
|
||||||
|
5. **JSON 복구**: `parseJson` 절차
|
||||||
|
1) ` ```(json)? ... ``` ` 제거.
|
||||||
|
2) `, (?=[}\]])` 트레일링 콤마 제거.
|
||||||
|
3) `mapper.readValue` 1차 시도.
|
||||||
|
4) 실패 + `[`로 시작하면 인덱스 스캔으로 객체 단위 점진 파싱 → 최대한 많이 복구. 마지막에도 0건이면 `RuntimeException`.
|
||||||
|
6. **결과 정규화**: List → 그대로, Map → 단건 List 로 감쌈, 그 외 → 빈 List + raw 반환.
|
||||||
|
7. **식당 단위 후처리 (processExtract for-each)**:
|
||||||
|
a) `name == null` skip.
|
||||||
|
b) `geocodeRestaurant(name, address)` → Places Text Search (language=ko, type=restaurant) 1순위 결과 → `place/details` 로 phone/website 보강. 실패 시 Geocoding API 폴백, 그것도 실패 시 null.
|
||||||
|
c) `data` 빌드: geo 우선 (`formatted_address`, lat/lng, place_id, business_status, rating, rating_count, phone, website), 나머지는 LLM 값.
|
||||||
|
d) `RestaurantService.upsert(data)` → restId.
|
||||||
|
e) `evaluation` 정규화 (Map→JSON, String→JSON 리터럴) 후 `linkVideoRestaurant(videoDbId, restId, foods, evaluationJson, guests)`.
|
||||||
|
f) `VectorService.buildChunks(name, restData, videoTitle)` → 한 줄로 합쳐진 단일 chunk → `saveRestaurantVectors` (Embed batch, INSERT VECTOR). 실패 시 warn 만.
|
||||||
|
g) `count++` 로그.
|
||||||
|
8. **종료**: `updateVideoStatus(done, null, rawResponse)`. `processPending` 호출자는 총합>0 이면 `cache.flush()`.
|
||||||
|
9. **상태 전이**: `pending → processing(transcript 도착) → done` (LLM 결과 0/N) | `error` (예외) | `skip`(운영 수동, #269).
|
||||||
|
10. **Geocoding 한국 주소 region 파싱**: `parseRegionFromAddress` 가 토큰 단위로 "대한민국|특별/광역/도|구/군/시" 추출, 결과는 `한국|서울|강남구` 형식. 해외 식당은 LLM 이 직접 region 을 지정하므로 보조적 용도.
|
||||||
|
|
||||||
|
## 9. 엣지케이스 & 에러 처리
|
||||||
|
- **OCI 인증 미설정**: PostConstruct 가 warn 후 chatClient/embedClient null → 호출 시 `IllegalStateException` → `processExtract` 가 try/catch 없이 호출 스택을 상위(`processVideo`)로 전파, 상태 `error`.
|
||||||
|
- **LLM JSON 파싱 완전 실패**: `parseJson` throw → `extractRestaurants` catch → `ExtractionResult(empty, "")`. `processExtract` 는 0건 종결 + `done`.
|
||||||
|
- **트레일링 콤마/마크다운**: 자동 sanitize.
|
||||||
|
- **잘린 array (`maxTokens` 도달)**: 부분 복구 후 사용; 잘린 항목은 폐기.
|
||||||
|
- **식당 이름 누락**: skip (저장 안 함).
|
||||||
|
- **Geocoding 모두 실패**: 식당은 `latitude/longitude=null` 로 저장 → 지도 노출 제외, 검색은 가능.
|
||||||
|
- **evaluation 형식 다양성**: Map/String 모두 처리, null 그대로 통과.
|
||||||
|
- **transcript blank**: `processVideo` 가 self-check 후 `done` 반환 (recall=0 허용).
|
||||||
|
- **Vector 저장 실패**: warn 만 (식당은 이미 저장됨, 추후 `rebuild-vectors` SSE 로 복구 — #269).
|
||||||
|
- **OCI Embed 96 한도**: 자동 분할 호출.
|
||||||
|
- **Google API rate limit (`OVER_QUERY_LIMIT`)**: status != OK 면 null 반환 → 좌표 없이 저장.
|
||||||
|
- **place details 실패**: phone/website 누락만, 좌표는 유지.
|
||||||
|
- **temperature=0.0** 이지만 모델 비결정성 일부 잔존 → 같은 영상 재실행 시 결과 약간 다를 수 있음 (멱등 보장은 `upsert` 키 = google_place_id/name+address 조합에 의존, #268).
|
||||||
|
- **안전 기본값**: 외부 I/O 실패 시 전 항목 폐기 대신 부분 저장 (좌표 없는 식당이라도 유지) — 운영자가 어드민에서 수동 보정 가능.
|
||||||
|
|
||||||
|
## 10. 테스트 계획
|
||||||
|
- **단위 — ExtractorService**
|
||||||
|
- transcript 절단 임계 (7999 → 그대로 / 8001 → head+중략+tail).
|
||||||
|
- LLM 응답 케이스: 정상 array / 단일 object / `[]` / 깨진 JSON → 각각 정상 List / 단건 List / 빈 List / 빈 List + log.
|
||||||
|
- **단위 — OciGenAiService.parseJson**
|
||||||
|
- ` ```json [...] ``` `, 트레일링 콤마, 잘린 array, 완전 비-JSON → 시나리오별 검증.
|
||||||
|
- **단위 — GeocodingService**
|
||||||
|
- Places OK + details OK → 전 필드 채움.
|
||||||
|
- Places ZERO_RESULTS → Geocoding 폴백 호출 검증 (WireMock).
|
||||||
|
- 둘 다 실패 → null.
|
||||||
|
- `parseRegionFromAddress`: 서울특별시/경기도/광역시/특별자치시/외국 주소 → 각각 기대 region 또는 null.
|
||||||
|
- **단위 — PipelineService.processExtract**
|
||||||
|
- 식당 N개 mock 추출 → upsert N회, linkVideoRestaurant N회, saveRestaurantVectors N회 호출 검증.
|
||||||
|
- vector save 예외 → 식당은 저장, warn 로그.
|
||||||
|
- name=null 항목은 skip (count 미증가).
|
||||||
|
- **통합 (Spring + WireMock)**: Google Maps 모킹 + OCI mock → `processPending(3)` 실행 후 videos.status, video_restaurants, restaurant_vectors 행수 검증.
|
||||||
|
- **드라이런**: prod 호출 비용 차단을 위해 `app.oci.*` 미설정 시 chat/embed 가 즉시 throw → `processVideo` 가 status=`error` 마킹하고 다음 영상으로 진행 (`processPending` 루프).
|
||||||
|
- **인수조건 매핑**: AC1↔ExtractorService 단위, AC2↔processExtract 통합, AC3↔parseJson 단위, AC4↔GeocodingService 단위, AC5↔evaluation 정규화 단위, AC6↔processVideo 단위.
|
||||||
|
|
||||||
|
## 11. 리스크 & 대안 검토
|
||||||
|
- **OCI GenAI 단일 벤더 잠금**: 대안 OpenAI/Anthropic. 트레이드오프: OCI 는 동일 테넌시 내 IAM 통합/내한권 결제. → **ADR 후보** (`adr/0002-llm-provider.md`).
|
||||||
|
- **transcript 절단 (선택)**: 8000자 hard cut. 대안: 청크 + map-reduce 요약 (지연/비용↑) 또는 더 큰 context 모델. 현재 영상 평균 < 8000자라 단순 cut 채택.
|
||||||
|
- **Geocoding Places vs Geocoding 폴백 순서**: Places 가 phone/rating 까지 주므로 1순위. 대안: 카카오/네이버 로컬 API (한국 정확도↑) — 향후 옵션.
|
||||||
|
- **벡터 chunk 1개/식당**: 검색 정확도 vs 비용 트레이드오프. 대안: 메뉴별 분할 chunk → 임베딩 수 N배, FETCH 시 중복 제거 필요. 현재 토픽이 좁아 단일 chunk 유지.
|
||||||
|
- **temperature=0.0**: 재현성↑. 대안: 약간 ↑ 시 다양한 메뉴 추출 가능 — 일관성 우선.
|
||||||
|
- **evaluation JSON 강제**: DB CHECK 제약을 만족시키는 가장 단순한 방법 (JSON 리터럴 wrap). 향후 정형화(`{summary, rating, ...}`) 이전 가능.
|
||||||
|
- **부분 실패 허용**: 식당 일부만 저장되는 시나리오 수용 → 운영자 검토 비용. 대안: 전부 임시 영역 → 검토 후 swap (구현 복잡).
|
||||||
|
|
||||||
|
## 12. 미해결 질문 (Open Questions)
|
||||||
|
- `cuisine_type` 표준 목록 위반 비율이 얼마나 되는가? 사전 검증(`CuisineTypes.isValid`) 후 자동 폴백을 LLM 단계에서 적용할지.
|
||||||
|
- transcript 8000자 cut 대신 슬라이딩 윈도우 multi-pass 요약 도입 여부 (비용/정확도 검토).
|
||||||
|
- Geocoding 결과 중 `business_status=CLOSED_*` 인 식당의 처리 정책 (자동 제외 vs 표시).
|
||||||
|
- 영상에 동일 식당이 중복 언급될 때 upsert 키와 link 중복 방지 (현재 `RestaurantService.upsert` 키 정책에 의존).
|
||||||
|
- Embed cosine 임계(`maxDistance`)는 #271 에서 0.57 — 학습 데이터 누적 후 재조정 필요.
|
||||||
|
- 다국어 영상 (예: 일본 식당) 의 region 파싱 강건성 (현재 한국 주소 패턴 위주).
|
||||||
201
docs/design/271-backend-search/README.md
Normal file
201
docs/design/271-backend-search/README.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
|
||||||
|
|
||||||
|
# 설계서: 백엔드 - 검색/벡터 추천 (#271)
|
||||||
|
|
||||||
|
> **상태**: Approved <!-- Draft | Approved | Superseded -->
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #271 · 관련 ADR: 없음
|
||||||
|
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/SearchService.java`, `backend-java/src/main/java/com/tasteby/service/VectorService.java`, `backend-java/src/main/java/com/tasteby/controller/SearchController.java` · 테스트: TBD (현재 없음)
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
사용자가 식당명/메뉴/지역 키워드로 빠르게 후보를 찾고, 의미 기반 추천(예: "혼술하기 좋은 이자카야")도 받을 수 있도록 키워드 SQL + Oracle 23ai VECTOR 코사인 거리 검색을 동일 엔드포인트로 제공한다.
|
||||||
|
|
||||||
|
## 2. 범위 (Scope)
|
||||||
|
- **포함**:
|
||||||
|
- `GET /api/search?q=&mode=&limit=` 단일 엔드포인트.
|
||||||
|
- 모드: `keyword`(기본, LIKE), `semantic`(벡터), `hybrid`(키워드 + 벡터 union).
|
||||||
|
- 결과 캐싱 (`CacheService`, key = `search:q=..:m=..:l=..`).
|
||||||
|
- 채널명 부착(`attachChannels`) — 검색 결과에 어떤 채널들이 다뤘는지 표시.
|
||||||
|
- 벡터 인덱스 운영용 API: 추출 파이프라인에서 호출되는 `saveRestaurantVectors`, 검색용 `searchSimilar`.
|
||||||
|
- **제외 (out of scope)**:
|
||||||
|
- 식당 상세/지도 노출 — #268/#278.
|
||||||
|
- 벡터 재생성 SSE (`rebuild-vectors`) — #269 (TODO).
|
||||||
|
- 사용자별 개인화 추천/로그.
|
||||||
|
- 채널 마스터 데이터 — #273.
|
||||||
|
- 임베딩 모델 학습/튜닝.
|
||||||
|
|
||||||
|
## 3. 인수조건 (Acceptance Criteria)
|
||||||
|
- [ ] `GET /api/search?q=족발&mode=keyword&limit=20` 이 `restaurants.name/foods_mentioned/...` 에 `%족발%` LIKE 매칭된 식당을 최대 limit(상한 100)개 반환하고, 각 결과의 `channels` 배열에 출연 채널명이 채워진다.
|
||||||
|
- [ ] `mode=semantic` 호출 시 OCI Embed 로 쿼리 임베딩 → `VECTOR_DISTANCE(... COSINE)` 로 `maxDistance ≤ 0.57` 인 chunk 상위 `max(30, limit*3)` 개를 가져와, restaurant_id 중복 제거 후 좌표 있는 식당만 limit 개 반환한다.
|
||||||
|
- [ ] `mode=hybrid` 는 키워드 결과 우선 + 의미 결과를 뒤에 union 하며, 동일 식당 중복 제거 후 limit 로 컷한다.
|
||||||
|
- [ ] 동일 (q, mode, limit) 두 번째 호출은 Redis 캐시에서 즉시 반환 (DB/OCI 호출 0회).
|
||||||
|
- [ ] semantic 호출 중 OCI 실패 시 keyword 결과로 자동 폴백하며 500 을 던지지 않는다.
|
||||||
|
- [ ] `VectorService.saveRestaurantVectors` 가 chunks 리스트를 96개 단위 배치 임베딩 후 한 INSERT/chunk 로 Oracle VECTOR 컬럼에 저장한다.
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
- **의존성**:
|
||||||
|
- Oracle 23ai VECTOR 타입 + `VECTOR_DISTANCE(..., COSINE)` 함수.
|
||||||
|
- `OciGenAiService.embedTexts` (Cohere/embed-v4 등, 96 배치).
|
||||||
|
- `RestaurantService.findById` — semantic 결과 1차 행 조회.
|
||||||
|
- `CacheService` (Redis) — 직렬화는 Jackson ObjectMapper(local 인스턴스).
|
||||||
|
- `SearchMapper.keywordSearch`, `SearchMapper.findChannelsByRestaurantIds`.
|
||||||
|
- **제약**:
|
||||||
|
- `limit ≤ 100` (Controller 가드).
|
||||||
|
- 임베딩 비용 → 동일 쿼리 캐시 hit 시 0 호출, miss 시 1 호출.
|
||||||
|
- VECTOR 컬럼 바인딩은 `NamedParameterJdbcTemplate + float[]` 직접 바인딩 (MyBatis 미지원이라 JDBC 사용).
|
||||||
|
- hybrid mode 는 union 후 limit 만 적용 — 가중치 랭킹은 미구현 (단순 keyword 우선).
|
||||||
|
- **가정**:
|
||||||
|
- 검색 빈도는 식당 추출보다 훨씬 잦지만 임베딩 호출은 캐시로 대부분 흡수된다.
|
||||||
|
- 식당 1건당 vector chunk 1개 (`VectorService.buildChunks`) — 좌표 없는 식당은 semantic 결과에서 자동 제거.
|
||||||
|
- cosine distance 임계 0.57 은 운영 관측치 기반 (조정 가능).
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
- 모듈/파일:
|
||||||
|
- `controller/SearchController.java` — REST 엔드포인트, limit clamp.
|
||||||
|
- `service/SearchService.java` — 모드 분기, 캐시, 채널 부착.
|
||||||
|
- `service/VectorService.java` — 임베딩 + Oracle VECTOR 검색/저장 (JDBC).
|
||||||
|
- `mapper/SearchMapper.java` (+ XML) — `keywordSearch`, `findChannelsByRestaurantIds`.
|
||||||
|
- 협력: `OciGenAiService` (#270), `RestaurantService` (#268), `CacheService` (#276).
|
||||||
|
- I/O ↔ 순수 로직 경계:
|
||||||
|
- **I/O**: Oracle (LIKE + VECTOR), Redis 캐시, OCI Embed.
|
||||||
|
- **순수 로직**: 모드 분기, 중복 제거(LinkedHashSet), keyword 우선 union, `buildChunks` 텍스트 합성, 좌표 필터(`r.getLatitude() != null`).
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────┐
|
||||||
|
GET /api/search?q=&mode=&limit= │ SearchController │
|
||||||
|
────────────────────────────────▶ search(q, mode, limit) │
|
||||||
|
│ limit = min(limit,100) │
|
||||||
|
└──────────┬─────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ SearchService.search │
|
||||||
|
│ cache.get("search:q=..:m=..") │ hit ──▶ return List<Restaurant>
|
||||||
|
└──────────┬───────────────────────┘ miss
|
||||||
|
┌────────────┬─────────┴────────────┬─────────────┐
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
keywordSearch semanticSearch hybrid: cache.set
|
||||||
|
│ │ kw + sem union │
|
||||||
|
▼ ▼ │
|
||||||
|
SearchMapper VectorService.searchSimilar │
|
||||||
|
LIKE %q% ├── OciGenAiService.embedTexts(query) │
|
||||||
|
attachChannels │ → float[] qvec │
|
||||||
|
├── jdbc.query VECTOR_DISTANCE(... COSINE) │
|
||||||
|
│ ≤0.57, ORDER BY dist FETCH FIRST k │
|
||||||
|
└── RestaurantService.findById(rid) [coord!=null]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
◀────────────── Response
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 데이터 모델
|
||||||
|
- **입력**:
|
||||||
|
- 쿼리 파라미터: `q: string (필수)`, `mode ∈ {keyword, semantic, hybrid}`(기본 keyword), `limit: int (기본 20, 상한 100)`.
|
||||||
|
- **출력**: `List<Restaurant>` — id, name, address, region, latitude, longitude, cuisineType, priceRange, phone, website, googlePlaceId, businessStatus, rating, ratingCount, updatedAt, `channels: string[]` (검색 결과에만 채움), `foodsMentioned` (옵션).
|
||||||
|
- **벡터 인덱스 저장 (`restaurant_vectors`)**:
|
||||||
|
- `id` 32자 UUID(hex upper), `restaurant_id` FK, `chunk_text` CLOB, `embedding` VECTOR.
|
||||||
|
- chunk 본문 예시:
|
||||||
|
```
|
||||||
|
식당: <name>
|
||||||
|
지역: <region>
|
||||||
|
음식 종류: <cuisine_type>
|
||||||
|
메뉴: a, b, c
|
||||||
|
평가: <evaluation>
|
||||||
|
가격대: <price>
|
||||||
|
영상: <video_title>
|
||||||
|
```
|
||||||
|
- **캐시 키**: `search:q=<q>:m=<mode>:l=<limit>` (`CacheService.makeKey`). 값은 `List<Restaurant>` Jackson JSON.
|
||||||
|
- **검증 규칙**:
|
||||||
|
- `q` 가 비어 있으면 Controller 가드(현재 미존재) — 빈 문자열은 `%%` LIKE 로 모든 식당 매칭되므로 클라이언트에서 빈 쿼리 차단 권장. (Open Question 참조)
|
||||||
|
- limit > 100 → 100 으로 clamp.
|
||||||
|
- semantic 결과는 `latitude != null` 인 식당만 — 지도 노출 가능한 후보로 한정.
|
||||||
|
|
||||||
|
## 7. 함수 명세 (Function Specs)
|
||||||
|
|
||||||
|
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|
||||||
|
|------|-----------|----------------|------|------|-----------|-------|
|
||||||
|
| `SearchController.search` | REST 엔드포인트 + limit clamp | `List<Restaurant> search(q, mode, limit)` | q, mode, limit | List<Restaurant> | 없음 | 단순 |
|
||||||
|
| `SearchService.search` | 모드 분기 + 캐싱 | `List<Restaurant> search(q, mode, limit)` | 동일 | 동일 | 캐시 직렬화 catch | **복잡** |
|
||||||
|
| `SearchService.keywordSearch` (private) | LIKE 검색 + 채널 부착 | `(q, limit)` | %q% pattern | List<Restaurant> | 빈 결과 빈 list | 단순 |
|
||||||
|
| `SearchService.semanticSearch` (private) | 벡터 검색 + 식당 조회 | `(q, limit)` | q, limit | List<Restaurant> | catch → keyword 폴백 | **복잡** |
|
||||||
|
| `SearchService.attachChannels` (private) | restaurant_id ↔ 채널명 부착 | `(restaurants)` | List | void (mutate) | 매핑 누락 시 빈 list | 단순 |
|
||||||
|
| `VectorService.searchSimilar` | OCI embed + Oracle VECTOR 질의 | `(query, topK, maxDistance)` | text, k, dist | `List<Map>` (restaurant_id, chunk_text, distance) | embed 실패 throw | **복잡** |
|
||||||
|
| `VectorService.saveRestaurantVectors` | chunk 배열 임베딩 + INSERT | `(restaurantId, chunks)` | rid, chunks | void | chunk 별 update 예외 throw | **복잡** |
|
||||||
|
| `VectorService.buildChunks` (static) | 식당 데이터 → 임베딩 텍스트 | `(name, data, videoTitle)` | name + Map + title | `List<String>`(1) | 없음 | 단순 |
|
||||||
|
|
||||||
|
> 복잡 표시 함수는 외부 I/O + 폴백 또는 비-MyBatis 경로(JDBC + VECTOR 바인딩). 별도 `fn-*.md` 우선순위: `searchSimilar`, `semanticSearch`.
|
||||||
|
|
||||||
|
## 8. 흐름 / 알고리즘
|
||||||
|
1. **요청 진입**: Controller 가 `limit > 100` 이면 100 으로 clamp → `SearchService.search(q, mode, limit)` 호출.
|
||||||
|
2. **캐시 조회**: key=`search:q=<q>:m=<mode>:l=<limit>` → `cache.getRaw` hit 시 Jackson 역직렬화 후 즉시 반환. 직렬화 예외는 무시하고 본 로직 진행.
|
||||||
|
3. **모드 분기**:
|
||||||
|
- `keyword` (default): `SearchMapper.keywordSearch("%q%", limit)` → 결과에 `attachChannels` 적용.
|
||||||
|
- `semantic`: `VectorService.searchSimilar(q, max(30, limit*3), 0.57)` → 결과의 `restaurant_id` 를 `LinkedHashSet` 으로 중복 제거 → `restaurantService.findById(rid)` 로 행 조회, `latitude != null` 인 것만 limit 개까지 누적.
|
||||||
|
- `hybrid`: keyword 결과 + semantic 결과를 순서대로 union (`HashSet seen` 으로 중복 제거), limit 초과시 subList(0, limit). (현재 채널 부착은 keyword 결과에만 적용됨.)
|
||||||
|
4. **벡터 검색 내부 (`searchSimilar`)**:
|
||||||
|
a) `OciGenAiService.embedTexts([query])` → `List<List<Double>>` → 첫 임베딩을 `float[]` 로 변환.
|
||||||
|
b) SQL:
|
||||||
|
```sql
|
||||||
|
SELECT rv.restaurant_id, rv.chunk_text,
|
||||||
|
VECTOR_DISTANCE(rv.embedding, :qvec, COSINE) AS dist
|
||||||
|
FROM restaurant_vectors rv
|
||||||
|
WHERE VECTOR_DISTANCE(rv.embedding, :qvec2, COSINE) <= :max_dist
|
||||||
|
ORDER BY dist
|
||||||
|
FETCH FIRST :k ROWS ONLY
|
||||||
|
```
|
||||||
|
`:qvec`/`:qvec2` 동일 배열 두 번 바인딩 (SELECT 와 WHERE 에 각각 사용).
|
||||||
|
c) RowMapper: `RESTAURANT_ID`, `CHUNK_TEXT`(CLOB → `JsonUtil.readClob`), `DIST`.
|
||||||
|
5. **캐시 저장**: 최종 결과를 `cache.set(key, result)` (Jackson JSON 직렬화, CacheService 가 TTL 관리 — #276).
|
||||||
|
6. **벡터 저장 (`saveRestaurantVectors`)**: chunks 빈 리스트면 즉시 종료. `embedTexts(chunks)` 후 chunks.size 만큼 반복하며 각 row 에 새 UUID + 변환된 float[] + chunk_text 를 `INSERT` (단건 update). 예외는 호출자(`PipelineService`) 에서 warn 처리.
|
||||||
|
7. **채널 부착**: `SearchMapper.findChannelsByRestaurantIds(ids)` → row 의 `restaurant_id`(소문자 또는 `RESTAURANT_ID` 대문자) 두 키 모두 지원 → `Map<restId, List<channelName>>` 구성 → 각 Restaurant 의 `channels` 필드 set (없으면 빈 리스트).
|
||||||
|
|
||||||
|
## 9. 엣지케이스 & 에러 처리
|
||||||
|
- **빈 쿼리**: `q=""` → keyword 는 모든 식당 매칭(LIKE `%%`), semantic 은 의미 약함. 현재 Controller 가드 없음 → Open Questions 참조.
|
||||||
|
- **특수문자/와일드카드**: q 에 `%`/`_` 가 있으면 LIKE 부작용. 현재 escape 미적용 (Open Question).
|
||||||
|
- **OCI Embed 미설정**: `IllegalStateException` → `semanticSearch` catch → keyword 폴백, 사용자에겐 200 응답.
|
||||||
|
- **임베딩 빈 결과**: `searchSimilar` 가 빈 list 반환 → semantic 결과 0 → `keywordSearch` 폴백은 발생하지 않음 (현재 코드: semantic 0이면 빈 결과 반환 가능). hybrid 모드는 키워드 결과로 채워짐.
|
||||||
|
- **좌표 없는 식당**: semantic 결과에서 자동 제외 (지도 무관 검색 시 누락 가능 — 의도).
|
||||||
|
- **캐시 직렬화 실패**: getRaw 후 `mapper.readValue` 예외는 무시 후 DB 재조회.
|
||||||
|
- **VECTOR 거리 임계 미달**: `WHERE dist <= 0.57` 로 0건 가능 → 빈 list. 임계 낮추거나 키워드 사용 권장.
|
||||||
|
- **채널 매핑 row 키 대소문자 차이**: row.getOrDefault 로 두 케이스 모두 처리 — Oracle 컬럼 대문자/lowerKeys 미적용 시 대비.
|
||||||
|
- **CLOB chunk_text**: `JsonUtil.readClob` 으로 안전 변환.
|
||||||
|
- **DB 연결 실패**: `jdbc.query` 예외 → SearchService 의 `semanticSearch` catch → keyword 폴백.
|
||||||
|
- **부분 결과**: hybrid 에서 keyword 만 결과가 있고 semantic 이 throw 시, keyword 결과만 반환되도록 catch 위치는 `semanticSearch` 내부 → 그대로 빈 리스트가 hybrid union 의 절반으로 사용됨 (장애 격리).
|
||||||
|
- **안전 기본값**: 모든 외부 실패는 keyword 결과 또는 빈 list 로 수렴; 500 응답을 피한다.
|
||||||
|
|
||||||
|
## 10. 테스트 계획
|
||||||
|
- **단위 — SearchService**
|
||||||
|
- 캐시 hit → mapper/vector 미호출, 결과 동일.
|
||||||
|
- 캐시 miss + keyword 모드 → `keywordSearch` 1회, `attachChannels` 호출.
|
||||||
|
- semantic 모드 → vector 결과 K*3, dedup, 좌표 없는 식당 제외 검증.
|
||||||
|
- hybrid 중복 제거 순서 (kw 우선) 검증.
|
||||||
|
- vector 예외 → keyword 폴백.
|
||||||
|
- **단위 — VectorService**
|
||||||
|
- `buildChunks` 입력 누락 필드 (region/cuisine/foods 등) 가 출력에서 자연스럽게 생략.
|
||||||
|
- `saveRestaurantVectors` empty chunks → no-op.
|
||||||
|
- **통합 (Spring + Testcontainers Oracle 또는 in-memory mock)**
|
||||||
|
- `restaurant_vectors` 에 샘플 데이터 삽입 → `searchSimilar("족발", 10, 0.57)` 거리 정렬 검증.
|
||||||
|
- `keywordSearch` LIKE 매칭 + 채널 부착.
|
||||||
|
- **계약 테스트**
|
||||||
|
- `GET /api/search?q=&limit=200` → limit clamp 100.
|
||||||
|
- mode 오타 → default(keyword).
|
||||||
|
- **드라이런/모킹**: OCI Embed → 고정 vector 반환 stub. Oracle VECTOR 함수 모킹 어려움 → Testcontainers 23ai (free profile) 사용 권장.
|
||||||
|
- **인수조건 매핑**: AC1↔keyword 통합, AC2↔searchSimilar 통합, AC3↔hybrid 단위, AC4↔캐시 단위, AC5↔폴백 단위, AC6↔saveRestaurantVectors 통합.
|
||||||
|
|
||||||
|
## 11. 리스크 & 대안 검토
|
||||||
|
- **Oracle 23ai VECTOR (선택)**: DB 내장이라 별도 인프라 불필요. 대안: pgvector, Pinecone, Qdrant — 운영 부담↑. → 트레이드오프: 23ai 라이선스/리전 의존.
|
||||||
|
- **NamedParameterJdbcTemplate 직접 사용**: MyBatis 가 VECTOR 직렬화 미지원 → JDBC 가 가장 단순. 대안: TypeHandler 작성 (구현 비용). 현 단계에서 단일 메서드라 직접 JDBC 유지.
|
||||||
|
- **단일 chunk/식당**: 비용 절감 + 단순. 대안: 메뉴/리뷰/장르 분리 chunk → recall↑, 비용/저장↑. 후속 ADR 후보.
|
||||||
|
- **hybrid union 단순 합치기**: 가중치 랭킹 부재 → semantic 일치 식당이 뒤에 묻힘. 대안: RRF (Reciprocal Rank Fusion) 또는 distance/score 정규화 후 정렬.
|
||||||
|
- **maxDistance=0.57 하드코딩**: 운영 관측치 변동 시 코드 수정 필요. 대안: 환경변수/설정으로 빼기.
|
||||||
|
- **캐시 무효화**: 식당/링크 변경 시 `CacheService.flush()` 전체 플러시 (현재 정책) → 콜드스타트 비용. 대안: 키 prefix 별 무효화.
|
||||||
|
- **빈 쿼리 가드 부재**: 운영 사고 시 모든 식당 반환 → 응답 크기 폭발. 트레이드오프: 가드 추가 (저비용).
|
||||||
|
|
||||||
|
## 12. 미해결 질문 (Open Questions)
|
||||||
|
- 빈 쿼리/공백 쿼리는 400 반환할지, 인기 식당 fallback 으로 응답할지.
|
||||||
|
- LIKE 와일드카드(`%`/`_`) escape 정책.
|
||||||
|
- hybrid 모드 랭킹 알고리즘 (RRF 도입 여부, semantic 가중치).
|
||||||
|
- semantic 모드에서 좌표 없는 식당도 노출할지 (검색 결과 vs 지도 마커 분리).
|
||||||
|
- 임계 `maxDistance=0.57` 의 모니터링/튜닝 방법 (사용자 클릭률 로그 필요).
|
||||||
|
- `restaurant_vectors` 중복(같은 식당 여러 chunk) 정책 — 현재 `saveRestaurantVectors` 가 추가만 함, 재추출 시 누적될 가능성.
|
||||||
|
- 검색 결과에 `foods_mentioned`/거리/평점 동시 노출 방식 (#278 와 합의 필요).
|
||||||
153
docs/design/272-backend-review-memo/README.md
Normal file
153
docs/design/272-backend-review-memo/README.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<!-- 기능 설계서. design/272-backend-review-memo/README.md
|
||||||
|
작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
|
||||||
|
|
||||||
|
# 설계서: 백엔드 - 리뷰/메모 (#272)
|
||||||
|
|
||||||
|
> **상태**: Approved <!-- Draft | Approved | Superseded -->
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #272 · 관련 ADR: 없음
|
||||||
|
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/ReviewService.java`, `backend-java/src/main/java/com/tasteby/service/MemoService.java`, `backend-java/src/main/java/com/tasteby/controller/ReviewController.java`, `backend-java/src/main/java/com/tasteby/controller/MemoController.java` · 테스트: TBD (현재 없음)
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
사용자가 식당에 대한 공개 리뷰(평점/방문일/텍스트)와 개인 메모(비공개 평점/기록)를 남기고, 즐겨찾기로 관심 식당을 관리하도록 한다. 식당 상세 페이지의 사회적 신뢰도(평균 평점, 리뷰 수) 및 마이페이지의 개인화 콘텐츠 핵심을 제공한다.
|
||||||
|
|
||||||
|
## 2. 범위 (Scope)
|
||||||
|
- **포함**:
|
||||||
|
- 식당별 리뷰 목록/평균 평점/리뷰 수 조회
|
||||||
|
- 리뷰 생성/수정/삭제 (본인 글만)
|
||||||
|
- 식당별 개인 메모 단건 조회/upsert/삭제 (사용자×식당 유니크)
|
||||||
|
- 즐겨찾기 토글/상태 조회/내 즐겨찾기 목록
|
||||||
|
- 내 리뷰/내 메모 목록
|
||||||
|
- **제외 (out of scope)**:
|
||||||
|
- 리뷰 이미지 첨부, 좋아요/신고 등 사회적 상호작용
|
||||||
|
- 리뷰 기반 추천/랭킹 로직
|
||||||
|
- 댓글, 대댓글
|
||||||
|
- 메모 공개 전환 (private only)
|
||||||
|
|
||||||
|
## 3. 인수조건 (Acceptance Criteria)
|
||||||
|
- [x] `GET /api/restaurants/{id}/reviews?limit&offset` 호출 시 `reviews[]` + `avg_rating` + `review_count` 동시 반환
|
||||||
|
- [x] 인증된 사용자만 `POST /api/restaurants/{id}/reviews`로 리뷰 작성 가능, 응답은 HTTP 201 + 생성된 Review
|
||||||
|
- [x] 작성자 본인이 아닌 `PUT/DELETE /api/reviews/{id}` 시도 시 HTTP 404 ("Review not found or not yours")
|
||||||
|
- [x] `POST /api/restaurants/{id}/memo` 동일 (user_id, restaurant_id) 재호출 시 INSERT가 아닌 UPDATE (upsert), 단건 보장
|
||||||
|
- [x] `POST /api/restaurants/{id}/favorite` 호출 시 기존 레코드 존재 → 삭제(false), 미존재 → 삽입(true) 토글 동작
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
- **DB**: Oracle 23ai. 테이블 `reviews`, `memos`, `favorites`. ID는 32자 UUID(`IdGenerator.newId()`).
|
||||||
|
- **MyBatis**: `ReviewMapper`, `MemoMapper` XML (`src/main/resources/mybatis/mapper/`). resultMap으로 UPPERCASE 컬럼 → camelCase 매핑.
|
||||||
|
- **권한**: 모든 쓰기 엔드포인트는 `AuthUtil.getUserId()`로 인증된 사용자 필요 (Spring Security 필터). 관리자 권한은 불필요.
|
||||||
|
- **트랜잭션**: `create`, `upsert`, `toggleFavorite`는 `@Transactional` 명시.
|
||||||
|
- **유니크 제약**: `memos`는 `(user_id, restaurant_id)` 유니크. `favorites`도 동일하게 1쌍 1행.
|
||||||
|
- **반환 포맷**: Jackson SNAKE_CASE (`review_text`, `visited_at`, `avg_rating`, `review_count`).
|
||||||
|
- **가정**: `restaurants.id`는 사전에 존재 (FK 참조). `visited_at`은 ISO-8601 (`YYYY-MM-DD`) 문자열.
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
- 모듈/파일 구조:
|
||||||
|
- `controller/ReviewController.java` (REST 엔드포인트, 8개)
|
||||||
|
- `controller/MemoController.java` (REST 엔드포인트, 4개)
|
||||||
|
- `service/ReviewService.java` (리뷰 + 즐겨찾기 비즈니스 로직)
|
||||||
|
- `service/MemoService.java` (메모 upsert 로직)
|
||||||
|
- `mapper/ReviewMapper.java` + XML, `mapper/MemoMapper.java` + XML
|
||||||
|
- `domain/Review.java`, `domain/Memo.java`
|
||||||
|
- `security/AuthUtil.java` (사용자 ID 추출)
|
||||||
|
- I/O ↔ 순수 로직 경계: Controller는 입력 파싱 + 인증, Service는 트랜잭션·도메인 규칙, Mapper는 SQL I/O.
|
||||||
|
|
||||||
|
```
|
||||||
|
[Client]
|
||||||
|
│ HTTP (JSON)
|
||||||
|
▼
|
||||||
|
[ReviewController | MemoController] ← AuthUtil.getUserId()
|
||||||
|
│ DTO/Map 파싱, LocalDate 변환
|
||||||
|
▼
|
||||||
|
[ReviewService | MemoService] ← @Transactional, upsert/토글 분기
|
||||||
|
│ IdGenerator.newId(), JsonUtil.lowerKeys()
|
||||||
|
▼
|
||||||
|
[ReviewMapper | MemoMapper] (MyBatis XML)
|
||||||
|
│ SQL
|
||||||
|
▼
|
||||||
|
[Oracle 23ai: reviews / memos / favorites]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 데이터 모델
|
||||||
|
- **Review** (`domain/Review.java`): `id`, `userId`, `restaurantId`, `rating(double)`, `reviewText`, `visitedAt(String)`, `createdAt`, `updatedAt`, `userNickname`, `userAvatarUrl`, `restaurantName`.
|
||||||
|
- **Memo** (`domain/Memo.java`): `id`, `userId`, `restaurantId`, `rating(Double, nullable)`, `memoText`, `visitedAt(String)`, `createdAt`, `updatedAt`, `restaurantName`.
|
||||||
|
- **avg_rating 응답** (`Map`): `{ avg_rating: double, review_count: int }` — null 시 기본값 `{0.0, 0}`.
|
||||||
|
- **favorite 응답**: `{ favorited: boolean }`.
|
||||||
|
- **경계 검증**:
|
||||||
|
- `rating`: 0.0 ~ 5.0 권장 (DB CHECK 권장, 현 구현은 검증 없음 — 향후 ADR 검토).
|
||||||
|
- `reviewText` / `memoText`: 길이 제한은 DB 컬럼 길이에 위임 (현재 명시적 검증 없음).
|
||||||
|
- `visitedAt`: `LocalDate.parse` 실패 시 `DateTimeParseException` 전파 → 400.
|
||||||
|
|
||||||
|
## 7. 함수 명세 (Function Specs)
|
||||||
|
|
||||||
|
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|
||||||
|
|------|-----------|----------------|------|------|-----------|-------|
|
||||||
|
| `ReviewService.findByRestaurant` | 식당별 리뷰 페이지 조회 | `List<Review>(restaurantId, limit, offset)` | 식당ID, 페이지 | List<Review> | DB 오류 → 전파 | 단순 |
|
||||||
|
| `ReviewService.getAvgRating` | 평균 평점/리뷰 수 집계 | `Map<String,Object>(restaurantId)` | 식당ID | `{avg_rating, review_count}` | null 시 기본값 | 단순 |
|
||||||
|
| `ReviewService.create` | 신규 리뷰 작성 | `Review(userId, restaurantId, rating, text, visitedAt)` | 사용자/식당/평점 | 생성된 Review | DB 제약 위반 → 전파 | 단순 |
|
||||||
|
| `ReviewService.update` | 본인 리뷰 수정 | `boolean(reviewId, userId, rating?, text?, visitedAt?)` | ID + 부분 필드 | 성공 여부 | 0행 → false | 단순 |
|
||||||
|
| `ReviewService.delete` | 본인 리뷰 삭제 | `boolean(reviewId, userId)` | ID, 사용자 | 성공 여부 | 0행 → false | 단순 |
|
||||||
|
| `ReviewService.findByUser` | 내 리뷰 목록 | `List<Review>(userId, limit, offset)` | 사용자, 페이지 | List<Review> | DB 오류 → 전파 | 단순 |
|
||||||
|
| `ReviewService.isFavorited` | 즐겨찾기 여부 | `boolean(userId, restaurantId)` | 사용자/식당 | true/false | DB 오류 → 전파 | 단순 |
|
||||||
|
| `ReviewService.toggleFavorite` | 즐겨찾기 토글 | `boolean(userId, restaurantId)` | 사용자/식당 | 토글 후 상태 | 동시성 시 유니크 충돌 가능 | **복잡** |
|
||||||
|
| `ReviewService.getUserFavorites` | 내 즐겨찾기 식당 목록 | `List<Restaurant>(userId)` | 사용자 | List<Restaurant> | DB 오류 → 전파 | 단순 |
|
||||||
|
| `MemoService.findByUserAndRestaurant` | 메모 단건 조회 | `Memo(userId, restaurantId)` | 사용자/식당 | Memo or null | 없음 | 단순 |
|
||||||
|
| `MemoService.upsert` | 메모 신규/갱신 | `Memo(userId, restaurantId, rating?, text, visitedAt?)` | 사용자/식당/내용 | 저장된 Memo | 동시성 시 유니크 충돌 가능 | **복잡** |
|
||||||
|
| `MemoService.delete` | 메모 삭제 | `boolean(userId, restaurantId)` | 사용자/식당 | 성공 여부 | 0행 → false | 단순 |
|
||||||
|
| `MemoService.findByUser` | 내 메모 목록 | `List<Memo>(userId)` | 사용자 | List<Memo> | DB 오류 → 전파 | 단순 |
|
||||||
|
| `ReviewController.*` | REST 어댑팅 | `@RestController` | HTTP | JSON | 401/404/400 | 단순 |
|
||||||
|
| `MemoController.*` | REST 어댑팅 | `@RestController` | HTTP | JSON | 401/404/400 | 단순 |
|
||||||
|
|
||||||
|
> 복잡 표시된 `toggleFavorite`, `upsert`는 분기 + 동시성 + 트랜잭션 경계 존재. 별도 fn 설계서 권장.
|
||||||
|
|
||||||
|
## 8. 흐름 / 알고리즘
|
||||||
|
1. **리뷰 작성**: AuthUtil.getUserId() → IdGenerator.newId() → INSERT → findById로 재조회하여 반환.
|
||||||
|
2. **평균 평점 조회**: `mapper.getAvgRating` → null 체크 → `JsonUtil.lowerKeys()`로 키 소문자화 → 응답 머지.
|
||||||
|
3. **메모 upsert**:
|
||||||
|
- 사전 SELECT (user_id, restaurant_id) →
|
||||||
|
- 존재하면 UPDATE, 미존재하면 INSERT (IdGenerator로 새 ID) →
|
||||||
|
- 최종 SELECT 후 반환.
|
||||||
|
4. **즐겨찾기 토글**:
|
||||||
|
- `findFavoriteId(userId, restaurantId)` →
|
||||||
|
- 존재하면 DELETE → false 반환, 미존재하면 INSERT → true 반환.
|
||||||
|
5. **권한 검증 (수정/삭제)**: WHERE 절에 `user_id = ?`를 함께 포함하여 본인 행만 영향. 영향행 0이면 NOT_FOUND (의도된 모호화: 권한/존재 동시 처리).
|
||||||
|
|
||||||
|
## 9. 엣지케이스 & 에러 처리
|
||||||
|
- **타인 리뷰 수정/삭제 시도**: WHERE 사용자 ID 불일치 → 0행 영향 → HTTP 404. 권한 누설 방지.
|
||||||
|
- **존재하지 않는 식당 ID로 리뷰 작성**: FK 제약 위반 → SQLException → 500 (현재 별도 매핑 없음, 향후 400 매핑 검토).
|
||||||
|
- **rating 음수/범위 초과**: 현재 미검증, DB에 그대로 저장. → Bean Validation 추가 권장.
|
||||||
|
- **메모 동시 upsert 경합**: 양쪽 트랜잭션이 SELECT에서 미존재 판정 → 둘 다 INSERT → 유니크 제약 위반. → 한쪽 500 전파.
|
||||||
|
- **즐겨찾기 동시 토글**: 동일 패턴, 유니크 충돌 가능. 트랜잭션 격리 SERIALIZABLE 또는 unique upsert 재시도 권장.
|
||||||
|
- **빈 텍스트/null 리뷰**: 현재 허용. 공백 정규화 미적용.
|
||||||
|
- **visited_at 파싱 실패**: `DateTimeParseException` → Spring 기본 400 응답.
|
||||||
|
- **인증 누락**: `AuthUtil.getUserId()`가 401 throw (필터 단계에서 차단 가정).
|
||||||
|
|
||||||
|
## 10. 테스트 계획
|
||||||
|
- **단위**
|
||||||
|
- `ReviewService.toggleFavorite` 기존 존재/미존재 분기 (Mapper 모킹)
|
||||||
|
- `ReviewService.getAvgRating` null 반환 시 기본값 처리
|
||||||
|
- `MemoService.upsert` 신규 INSERT vs UPDATE 분기
|
||||||
|
- `ReviewService.update/delete` 0행 시 false 반환
|
||||||
|
- **통합 (MyBatis + Testcontainers Oracle 또는 H2 Oracle mode)**
|
||||||
|
- 리뷰 작성 → 평균 평점이 (기존 평균 × N + 새 평점)/(N+1) 일치
|
||||||
|
- 메모 동일 (user, restaurant) 재요청 시 행 수 1 유지, 내용만 갱신
|
||||||
|
- 즐겨찾기 토글 두 번 호출 → 원상 복귀 (행 수 0)
|
||||||
|
- 타 사용자 ID로 update 시 404
|
||||||
|
- **API**: MockMvc로 권한/페이지네이션/응답 키(snake_case) 검증.
|
||||||
|
- 현재 테스트 디렉토리 없음 → TBD.
|
||||||
|
|
||||||
|
## 11. 리스크 & 대안 검토
|
||||||
|
- **선택**: upsert/토글을 애플리케이션 레벨 SELECT → IF로 분기.
|
||||||
|
- 대안 A: Oracle `MERGE` 문 단일 SQL → 동시성 안전.
|
||||||
|
- 대안 B: 유니크 충돌 시 재시도 루프 → 코드 복잡도.
|
||||||
|
- 트레이드오프: 현재 방식은 명확하지만 경합 시 500. 다중 사용자 동시성이 낮은 현 단계에서 수용 가능. 트래픽 증가 시 MERGE 전환 ADR 후보.
|
||||||
|
- **권한 검증**: WHERE 절에 user_id 포함 vs 사전 SELECT 검증.
|
||||||
|
- 현재(WHERE 포함)는 1쿼리로 처리 + 권한/미존재 모호화. 단점: 감사 로그용 구분 어려움.
|
||||||
|
- **rating 검증 부재**: Bean Validation (`@Min(0) @Max(5)`) 도입 권장 — 별도 작업 분리.
|
||||||
|
- **N+1 가능성**: 리뷰 목록에서 `user_nickname/avatar_url`을 join으로 fetch (XML 조인 가정). 다국어/대량 사용자 시 캐시 검토.
|
||||||
|
|
||||||
|
## 12. 미해결 질문 (Open Questions)
|
||||||
|
- 리뷰 작성 시 평점 범위 검증을 서비스 레벨로 끌어올릴지, DB CHECK 제약으로 위임할지?
|
||||||
|
- 리뷰 이미지/사진 첨부 도입 시 별도 테이블 + 스토리지 정책 (#TBD).
|
||||||
|
- 같은 사용자가 한 식당에 리뷰를 여러 개 작성 가능? (현재 무제한) 정책 결정 필요.
|
||||||
|
- 즐겨찾기/메모를 단일 "내 식당" 개념으로 통합할지, 분리 유지할지?
|
||||||
|
- 리뷰 신고/모더레이션 워크플로 도입 시 status 컬럼 + 관리자 UI 필요.
|
||||||
170
docs/design/273-backend-channel/README.md
Normal file
170
docs/design/273-backend-channel/README.md
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<!-- 기능 설계서. design/273-backend-channel/README.md
|
||||||
|
작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
|
||||||
|
|
||||||
|
# 설계서: 백엔드 - 채널 관리 (#273)
|
||||||
|
|
||||||
|
> **상태**: Approved <!-- Draft | Approved | Superseded -->
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #273 · 관련 ADR: 없음
|
||||||
|
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/ChannelService.java`, `backend-java/src/main/java/com/tasteby/controller/ChannelController.java` · 테스트: TBD (현재 없음)
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
Tasteby가 식당 정보를 수집하는 YouTube 채널을 관리(등록/수정/비활성화/스캔)하여, 추출 파이프라인의 데이터 원천을 통제 가능하게 한다. 채널은 사용자 프론트엔드의 "채널 필터" UI 데이터 소스이기도 하다.
|
||||||
|
|
||||||
|
## 2. 범위 (Scope)
|
||||||
|
- **포함**:
|
||||||
|
- 활성 채널 목록 조회 (공개 API, 캐시 적용)
|
||||||
|
- 채널 등록 (관리자 전용)
|
||||||
|
- 채널 메타데이터 수정 (description/tags/sort_order, 관리자 전용)
|
||||||
|
- 채널 비활성화 (soft delete, 관리자 전용)
|
||||||
|
- 채널 영상 스캔 트리거 (관리자 전용, `YouTubeService.scanChannel` 위임)
|
||||||
|
- **제외 (out of scope)**:
|
||||||
|
- 채널 영상 자체의 추출/요약 로직 (#270 추출 파이프라인)
|
||||||
|
- 채널 통계/대시보드 (#274 통계)
|
||||||
|
- YouTube API 인증/쿼터 관리 세부사항 (YouTubeService 책임)
|
||||||
|
- 채널 카테고리 트리/계층화
|
||||||
|
|
||||||
|
## 3. 인수조건 (Acceptance Criteria)
|
||||||
|
- [x] `GET /api/channels` 호출 시 활성 채널 목록 반환, 캐시 hit/miss 모두 동일 결과
|
||||||
|
- [x] 관리자 외 사용자가 `POST /api/channels` 호출 시 권한 거부 (`AuthUtil.requireAdmin()` throw)
|
||||||
|
- [x] 동일 `channel_id`로 중복 등록 시 HTTP 409 + "Channel already exists" (유니크 제약 `UQ_CHANNELS_CID`)
|
||||||
|
- [x] `DELETE /api/channels/{channelId}` 시 `channel_id` 우선 매칭, 실패 시 DB `id`로 재시도 (양쪽 모두 실패 → 404)
|
||||||
|
- [x] 채널 관련 쓰기 작업 후 `cache.flush()` 호출되어 다음 GET에서 최신 데이터 반환
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
- **DB**: Oracle 23ai. 테이블 `channels`. 유니크 제약 `UQ_CHANNELS_CID` on `channel_id`.
|
||||||
|
- **외부 의존**:
|
||||||
|
- `YouTubeService.scanChannel(channelId, full)` (#270 추출 파이프라인)
|
||||||
|
- `CacheService` (Redis 캐시, `makeKey/getRaw/set/flush`)
|
||||||
|
- **권한**: 조회(GET)는 공개, 그 외 모두 `AuthUtil.requireAdmin()`로 관리자만.
|
||||||
|
- **캐시**: 목록 응답은 Redis에 JSON 직렬화 저장. 쓰기 시 flush.
|
||||||
|
- **Soft delete**: 비활성화는 `active = 0` UPDATE (물리 삭제 아님).
|
||||||
|
- **가정**: `channel_id`는 YouTube의 외부 ID (`UCxxxx...`). DB `id`는 32자 UUID.
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
- 모듈/파일 구조:
|
||||||
|
- `controller/ChannelController.java` (5개 엔드포인트)
|
||||||
|
- `service/ChannelService.java` (CRUD 비즈니스)
|
||||||
|
- `service/YouTubeService.java` (스캔 위임, 외부)
|
||||||
|
- `service/CacheService.java` (Redis 캐시, 외부)
|
||||||
|
- `mapper/ChannelMapper.java` + XML
|
||||||
|
- `domain/Channel.java`
|
||||||
|
- `security/AuthUtil.java`
|
||||||
|
- I/O ↔ 순수 로직 경계: Controller는 캐시 hit/miss + 권한, Service는 식별자 매칭 폴백 로직, Mapper는 SQL.
|
||||||
|
|
||||||
|
```
|
||||||
|
[Client]
|
||||||
|
│ HTTP
|
||||||
|
▼
|
||||||
|
[ChannelController] ← AuthUtil.requireAdmin() (쓰기)
|
||||||
|
│ cache hit? ─┐
|
||||||
|
│ ▼
|
||||||
|
│ [CacheService(Redis)] ← GET/SET/FLUSH
|
||||||
|
│ miss
|
||||||
|
▼
|
||||||
|
[ChannelService] ← deactivate: channel_id → id 폴백
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[ChannelMapper] (MyBatis XML)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[Oracle 23ai: channels]
|
||||||
|
|
||||||
|
[ChannelController.scan] → [YouTubeService.scanChannel] → (영상 수집 파이프라인)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 데이터 모델
|
||||||
|
- **Channel** (`domain/Channel.java`):
|
||||||
|
- `id: String` (32자 UUID, PK)
|
||||||
|
- `channelId: String` (YouTube 외부 ID, 유니크)
|
||||||
|
- `channelName: String`
|
||||||
|
- `titleFilter: String` (정규식/포함 문자열, 영상 제목 필터)
|
||||||
|
- `description: String`
|
||||||
|
- `tags: String` (콤마 구분)
|
||||||
|
- `sortOrder: Integer`
|
||||||
|
- `videoCount: int` (조인 집계)
|
||||||
|
- `lastVideoAt: String` (조인 집계)
|
||||||
|
- **POST 요청 본문**: `{ channel_id, channel_name, title_filter }`
|
||||||
|
- **PUT 요청 본문**: `{ description, tags, sort_order }`
|
||||||
|
- **POST 응답**: `{ id, channel_id }`
|
||||||
|
- **scan 응답**: `YouTubeService.scanChannel` 반환 Map (영상 수, 신규 추출 수 등)
|
||||||
|
- **경계 검증**: 현재 명시적 검증 없음. `channel_id` 형식(`UC` prefix) 검증 미적용. 길이 제한은 DB 컬럼 의존.
|
||||||
|
|
||||||
|
## 7. 함수 명세 (Function Specs)
|
||||||
|
|
||||||
|
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|
||||||
|
|------|-----------|----------------|------|------|-----------|-------|
|
||||||
|
| `ChannelService.findAllActive` | 활성 채널 목록 조회 | `List<Channel>()` | 없음 | List<Channel> | DB 오류 → 전파 | 단순 |
|
||||||
|
| `ChannelService.create` | 채널 신규 등록 | `String(channelId, channelName, titleFilter)` | YouTube ID/이름/필터 | 생성 PK | 유니크 충돌 → SQLException | 단순 |
|
||||||
|
| `ChannelService.deactivate` | soft delete (폴백) | `boolean(channelId)` | channel_id 또는 DB id | 성공 여부 | 둘 다 0행 → false | **복잡** |
|
||||||
|
| `ChannelService.findByChannelId` | 단건 조회 | `Channel(channelId)` | channel_id | Channel or null | 없음 | 단순 |
|
||||||
|
| `ChannelService.update` | 메타데이터 부분 갱신 | `void(id, description?, tags?, sortOrder?)` | DB id + 필드 | 없음 | DB 오류 → 전파 | 단순 |
|
||||||
|
| `ChannelController.list` | 캐시 우선 목록 응답 | `List<Channel>()` | 없음 | List<Channel> | 캐시 파싱 실패 → 무시, DB 조회 | 단순 |
|
||||||
|
| `ChannelController.create` | 등록 + 캐시 flush | `Map(body)` | body | `{id, channel_id}` | 유니크 → 409, 그 외 → 전파 | **복잡** |
|
||||||
|
| `ChannelController.scan` | 채널 스캔 트리거 | `Map(channelId, full)` | channelId, full | 스캔 결과 Map | 미존재 → 404 | **복잡** |
|
||||||
|
| `ChannelController.update` | 메타 갱신 + flush | `Map(id, body)` | id, body | `{ok:true}` | 권한 → 403 | 단순 |
|
||||||
|
| `ChannelController.delete` | 비활성화 + flush | `Map(channelId)` | channelId | `{ok:true}` | 미존재 → 404 | 단순 |
|
||||||
|
|
||||||
|
> `deactivate` (이중 매칭 폴백), `create` (충돌 → 메시지 파싱), `scan` (외부 위임)은 복잡. fn 설계서 후보.
|
||||||
|
|
||||||
|
## 8. 흐름 / 알고리즘
|
||||||
|
1. **목록 조회 (캐시)**:
|
||||||
|
```
|
||||||
|
key = cache.makeKey("channels")
|
||||||
|
if cache.getRaw(key) != null:
|
||||||
|
try return objectMapper.readValue(cached)
|
||||||
|
catch: fall through
|
||||||
|
result = mapper.findAllActive()
|
||||||
|
cache.set(key, result)
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
2. **채널 등록**: 관리자 검증 → IdGenerator.newId() → INSERT → `cache.flush()` → `{id, channel_id}` 응답. SQL 예외의 message에 `UQ_CHANNELS_CID` 포함되면 409로 매핑.
|
||||||
|
3. **비활성화 폴백**:
|
||||||
|
- `mapper.deactivateByChannelId(channelId)` 시도 →
|
||||||
|
- 0행이면 `mapper.deactivateById(channelId)` 시도 →
|
||||||
|
- 둘 다 0행 → false → 404.
|
||||||
|
- 이유: 운영자가 YouTube ID 또는 DB UUID 중 어느 것으로도 비활성화 가능.
|
||||||
|
4. **스캔 트리거**: 관리자 검증 → `YouTubeService.scanChannel(channelId, full)` 호출 → null 응답 시 404 → 성공 시 `cache.flush()` (영상 추가로 채널 메타 변동 가능성) → 결과 반환.
|
||||||
|
5. **메타 갱신**: PUT body의 sort_order는 Number → int 변환. `tags`, `description`, `sort_order` 부분 갱신.
|
||||||
|
|
||||||
|
## 9. 엣지케이스 & 에러 처리
|
||||||
|
- **캐시 직렬화 깨짐**: `objectMapper.readValue` 실패 시 catch 무시 → DB 폴백. 안전한 기본값.
|
||||||
|
- **유니크 충돌 감지**: 예외 메시지에 `UQ_CHANNELS_CID` 문자열 의존. DB 제약명이 바뀌면 감지 실패 → 500. → 향후 SQLState 또는 DataIntegrityViolationException 기반 매핑 권장.
|
||||||
|
- **deactivate 이중 시도**: 동일 channel_id가 DB id와 우연히 충돌하면 의도치 않은 행 비활성화 가능 (UUID 충돌 확률 매우 낮음).
|
||||||
|
- **scan 미존재 채널**: `YouTubeService`가 null 반환 → 404. YouTube API 자체 장애는 상위로 전파 (현재 별도 매핑 없음).
|
||||||
|
- **권한 누락**: `AuthUtil.requireAdmin()` 예외 → 403.
|
||||||
|
- **빈 본문 / 필수값 누락**: `body.get("channel_id")` 가 null → INSERT 시 NOT NULL 제약 위반 → 500. → 명시적 400 매핑 권장.
|
||||||
|
- **캐시 flush 실패**: Redis 다운 시 예외 전파. 운영 안전성 위해 try-catch + WARN 로깅 검토.
|
||||||
|
|
||||||
|
## 10. 테스트 계획
|
||||||
|
- **단위**
|
||||||
|
- `ChannelService.deactivate`: by-channelId 성공 → true / by-channelId 실패 + by-id 성공 → true / 둘 다 실패 → false
|
||||||
|
- `ChannelController.list`: 캐시 hit 시 ObjectMapper 호출, miss 시 mapper 호출
|
||||||
|
- `ChannelController.create`: 유니크 메시지 포함 예외 → 409
|
||||||
|
- `ChannelController.scan`: YouTubeService null → 404
|
||||||
|
- **통합**
|
||||||
|
- 채널 등록 후 GET 목록에 반영 (캐시 flush 검증)
|
||||||
|
- 중복 channel_id 등록 시 409
|
||||||
|
- 비관리자 인증으로 POST 시 403
|
||||||
|
- update 후 sort_order 반영 + 캐시 무효화
|
||||||
|
- **모킹**: `YouTubeService`, `CacheService` 모킹. Redis는 embedded-redis 또는 testcontainers.
|
||||||
|
- 현재 테스트 디렉토리 없음 → TBD.
|
||||||
|
|
||||||
|
## 11. 리스크 & 대안 검토
|
||||||
|
- **유니크 충돌 감지를 메시지 문자열로 판정**: 깨지기 쉬움.
|
||||||
|
- 대안 A: Spring의 `DuplicateKeyException` catch → 깔끔.
|
||||||
|
- 대안 B: 사전 SELECT 후 INSERT → 경합 시 여전히 위험.
|
||||||
|
- 트레이드오프: 현 방식은 빠르지만 fragile. ADR 후보.
|
||||||
|
- **deactivate 폴백 패턴**: 유연성 vs 명확성.
|
||||||
|
- 대안: 별도 엔드포인트 (`/by-id`, `/by-yt-id`)로 분리. 운영 UI 합의 필요.
|
||||||
|
- **캐시 정책**: 전체 flush vs 키 단위 invalidate.
|
||||||
|
- 현재 flush는 다른 모듈(예: 식당 목록)까지 영향. 채널 키만 무효화하도록 개선 가능.
|
||||||
|
- **스캔의 동기 호출**: 대량 영상 채널은 응답 지연 가능.
|
||||||
|
- 대안: 비동기 큐 + 작업 상태 폴링 (#275 데몬과 통합).
|
||||||
|
|
||||||
|
## 12. 미해결 질문 (Open Questions)
|
||||||
|
- 채널 활성화 복구(reactivate) API가 필요한지? 현재는 DB 직접 수정만 가능.
|
||||||
|
- `title_filter`는 정규식인지 단순 contains인지 명세 부재 — 코드 확인 필요.
|
||||||
|
- 채널 단위 권한 (소유자 개념)을 도입할지? 현재는 글로벌 관리자만.
|
||||||
|
- 스캔 작업의 진행률/실패 재시도 정책 — 데몬(#275)과 통합 범위.
|
||||||
|
- 캐시 TTL 설정값 (현재 코드에 명시 없음, CacheService 정책 의존).
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user