Compare commits
96 Commits
0ad09e5b67
...
v0.1.56
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5199475d67 | ||
|
|
bd8d82dd5d | ||
|
|
bc83923261 | ||
|
|
f17ba9e37a | ||
|
|
7789671fbc | ||
|
|
c5b0216a37 | ||
|
|
40e448fe95 | ||
|
|
8a21646031 | ||
|
|
52090057de | ||
|
|
d73947444f | ||
|
|
c1050f3abd | ||
|
|
a504bf8ee5 | ||
|
|
f1164b63c5 | ||
|
|
47020fd649 | ||
|
|
88bbf3ca25 | ||
|
|
8152b71119 | ||
|
|
d6ee62230e | ||
|
|
cf1055bdf9 | ||
|
|
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
|
||||
.venv/
|
||||
.env
|
||||
.ch-backup/
|
||||
node_modules/
|
||||
.next/
|
||||
.env.local
|
||||
@@ -13,3 +14,9 @@ backend-java/.gradle/
|
||||
|
||||
# K8s secrets (never commit)
|
||||
k8s/secrets.yaml
|
||||
|
||||
# OS / misc
|
||||
.DS_Store
|
||||
backend/cookies.txt
|
||||
backend-java/cookies.txt
|
||||
**/cookies.txt
|
||||
|
||||
430
CHANGELOG.md
Normal file
430
CHANGELOG.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# Tasteby 작업 기록
|
||||
|
||||
> 작업 내용, 이슈, 해결 방법을 기록하는 문서. 커밋/배포 시 참고용.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-16
|
||||
|
||||
### ⏪ NaverMap 임시 비활성, 한국도 GoogleMap fallback (v0.1.55)
|
||||
- NaverMapView 골격이 실 운영에서 지도/마커 렌더 실패 (정확한 원인 추후 진단)
|
||||
- NEXT_PUBLIC_NAVER_MAP_CLIENT_ID 빈 값으로 dispatcher가 GoogleMap fallback (회귀 0)
|
||||
- NaverMapView 코드는 유지 — 안정화 후 환경변수 채우면 재활성
|
||||
|
||||
### 🐛 /api/stats/visits 500 — StatsMapper resultType int → long (v0.1.54)
|
||||
- StatsMapper interface는 `long` 반환인데 XML resultType이 `int` → Integer를 long에 cast 실패
|
||||
- ClassCastException: Integer → Long. resultType만 long으로 교정
|
||||
|
||||
### 🐛 NaverMap 인증 파라미터 ncpClientId → ncpKeyId (v0.1.53)
|
||||
- NCLOUD 신 정책: `ncpKeyId` 사용 (옛 `ncpClientId`는 NAVER Developers용)
|
||||
- 인증 200/Failed의 진짜 원인 — 도메인 등록은 정확했으나 파라미터 이름 차이로 키 인식 실패
|
||||
- 새 NCLOUD Maps Client ID(`fg01bipxbo`)로 prod 재빌드
|
||||
- 참고: https://github.com/navermaps/maps.js.ncp/blob/master/index.html
|
||||
|
||||
### 🗺️ #363 메인 지도 SDK 국내(네이버)/해외(구글) 분기 (v0.1.52)
|
||||
- MapView를 dispatcher로 전환: 좌표가 KR bbox + NAVER_MAP_CLIENT_ID 설정 시 NaverMapView, 그 외 GoogleMapView
|
||||
- NaverMapView 신규 (네이버 v3 직접 wrapper, Supercluster 재사용, 마커/클러스터/flyTo)
|
||||
- GoogleMapView 신규 (기존 MapView 내용 rename)
|
||||
- MapView.types.ts 공용 (MapBounds/FlyTo/MapViewProps + isKoreaCoord)
|
||||
- Dockerfile + deploy.sh: NEXT_PUBLIC_NAVER_MAP_CLIENT_ID build-arg 추가
|
||||
- 키 미설정 시 GoogleMap fallback (회귀 0)
|
||||
- 설계서: docs/design/363-map-sdk-branch/README.md
|
||||
- Refs: #363
|
||||
|
||||
### 🗺️ 식당 상세 지도 링크 국내/해외 분기 (v0.1.51)
|
||||
- 좌표 기반 한국 판정 (WGS84 KR bbox 33~38.7°N, 124~132°E)
|
||||
- 국내: 네이버 지도 primary + Google Maps 보조 (네이버 URL은 신 도메인 /p/search/)
|
||||
- 해외: Google Maps 단독
|
||||
- 좌표 없으면 region 첫 토큰 fallback (구 데이터 호환)
|
||||
- frontend-only 배포
|
||||
|
||||
## 2026-06-15
|
||||
|
||||
### 🐛 캐치테이블 URL 패턴 수정 (v0.1.50)
|
||||
- 실제 catchtable URL은 `app.catchtable.co.kr/ct/shop/...` 형식 (옛 `/shop/`, `/dining/`은 매칭 실패)
|
||||
- 첫 회차(v0.1.49) 캐치테이블 벌크 결과 1044건 전부 미발견(매핑 0%)의 원인
|
||||
- 패턴을 `catchtable.co.kr/ct/shop/`, `catchtable.co.kr/ct/dining/`로 교정 후 NONE 해제 + 재실행
|
||||
|
||||
### 🐛 WebSearchService HTTP timeout 추가 (v0.1.49)
|
||||
- 벌크 백필 중 특정 검색에서 무한 hang → backend executor virtual thread 점유로 후속 작업 중단 (90건 처리 후 멈춤)
|
||||
- connectTimeout=5s + request timeout=15s (Naver/DDG 둘 다)
|
||||
- 해당 식당은 HttpTimeoutException → notfound로 안전 처리
|
||||
|
||||
### ⏱️ bulk-tabling/catchtable SSE timeout 10분 → 3시간 (v0.1.48)
|
||||
- 대량 백필(724건 ≈ 100분) 시 10분 SSE timeout으로 중간 끊김 → 3시간으로 확장
|
||||
- 백엔드 작업은 virtual thread로 별도 진행됐지만 emit() 예외로 마지막 cache.flush + complete 누락이슈 해소
|
||||
|
||||
### 🐛 #357 후속 — tabling-url validation에 www. 호스트 허용 (v0.1.47)
|
||||
- Naver/DDG 결과가 `https://www.tabling.co.kr/...` 형태인데 #290 validation은 `tabling.co.kr/`만 허용 → 단건 매핑 PUT 거부
|
||||
- bulk-tabling SSE는 validation 없이 service.update 직접 호출이라 통과 → 단일/벌크 불일치
|
||||
- `www.tabling.co.kr` prefix도 허용 (catchtable은 이미 app/www 둘 다 허용)
|
||||
- 시연 등록: bbq 부천은하마을점 → BBQ 치킨 부천은하마을점
|
||||
|
||||
### 🔍 #359 1단계 — google_place_id 중복 조회 API (v0.1.46)
|
||||
- GET /api/admin/restaurants/duplicates/place-id (어드민 전용)
|
||||
- 응답: 그룹별 식당 + video/review/memo 카운트 (병합 의사결정 자료)
|
||||
- 정리/병합 + UNIQUE 제약은 별도 PR (데이터 위험 분리)
|
||||
- 설계서: docs/design/359a-duplicate-place-id-view/README.md
|
||||
- Refs: #359 (조회 단계 완료, 후속 분리 유지)
|
||||
|
||||
### 📋 #358 RestaurantUpdateDTO + @Valid 표준화 (v0.1.45)
|
||||
- dto/RestaurantUpdateDTO record 신규 (15 필드, 모두 nullable)
|
||||
- Bean Validation: @Size/@Pattern(URL or NONE)/@DecimalMin·Max/@Min·Max
|
||||
- RestaurantController.update 시그니처 Map → @Valid DTO 교체
|
||||
- toFieldMap()으로 null 제외 후 기존 Service.update 호출 (회귀 0)
|
||||
- #332 ALLOWED_UPDATE_FIELDS Set 제거 (DTO 필드 자체가 화이트리스트)
|
||||
- 설계서: docs/design/358-restaurant-update-dto/README.md
|
||||
- Refs: #358 (close)
|
||||
|
||||
### 🔎 #357 DDG → Naver Search 정식 API + DDG 폴백 (v0.1.44)
|
||||
- WebSearchService 신규 (Naver webkr.json 우선, 키 미설정/실패 시 DDG 폴백)
|
||||
- RestaurantController.searchTabling/searchCatchtable 내부 호출 교체, DDG 인라인 80줄 제거
|
||||
- application.yml: app.naver.client-id/secret (NAVER_CLIENT_ID/SECRET 환경변수)
|
||||
- k8s/secrets.yaml.template에 NAVER_CLIENT_ID/SECRET 항목 추가
|
||||
- 미사용 import 정리 (HttpClient/URI/URLEncoder/Pattern 등 RestaurantController에서)
|
||||
- 설계서: docs/design/357-web-search-api/README.md
|
||||
- Refs: #357 (close)
|
||||
|
||||
### 🎯 #356 영상-식당 관련도 LLM 평가 (v0.1.43)
|
||||
- DB: video_restaurants 컬럼 추가 (relevance/relevance_reason/relevance_evaluated_at) + idx_vr_relevance
|
||||
- VideoRelevanceService 신규 (#322 RestaurantVerifyService 패턴 모방, @Async verifyAsync/verify/verifyAll)
|
||||
- PipelineService.processExtract — linkVideoRestaurant 후 verifyAsync(linkId) 자동 트리거
|
||||
- GET /api/restaurants/{id}/videos: 기본 strong/unknown만 응답 (안전 기본값), ?include_weak=true 시 모두
|
||||
- AdminVideoRelevanceController 신규 (pending/all/{id}/evaluate/{id} PATCH)
|
||||
- 응답 매핑: relevance, relevance_reason 필드 동봉
|
||||
- 기존 1244 링크는 'unknown' 시작 → 어드민 백필로 점진 평가
|
||||
- 설계서: docs/design/356-video-relevance-llm/README.md
|
||||
- Refs: #356 (close)
|
||||
|
||||
### 🧹 #351 admin SSE 6곳 consumeSseStream 통일 (v0.1.42)
|
||||
- VideosPanel 4곳(bulkTranscript/Extract, rebuildVectors, remapCuisine, remapFoods)
|
||||
- RestaurantsPanel 2곳(bulkTabling, bulkCatchtable)
|
||||
- response.body?.getReader 직접 호출 0건 (lib/admin-utils.ts의 consumeSseStream 활용)
|
||||
- 149줄 삭제 → 74줄 압축, npm test 13/13 통과
|
||||
- Refs: #351 (close)
|
||||
|
||||
### 🧪 #343 Jest+RTL 인프라 + ARIA Tabs + remotePatterns (v0.1.40)
|
||||
- Jest 30 + jest-environment-jsdom + RTL + jest-dom matchers 도입
|
||||
- next/jest 자동 SWC 통합, jest.config.ts + jest.setup.ts (setupFilesAfterEnv)
|
||||
- npm scripts: test, test:watch
|
||||
- 샘플 테스트 3개 13/13 통과: i18n/config(5), Stars(5), admin-utils(4)
|
||||
- MyReviewsList: role=tablist/tab/aria-selected/aria-controls/tabIndex + tabpanel
|
||||
- next.config.ts remotePatterns: Google avatar + YouTube thumbnail/avatar
|
||||
- 후속: 전체 컴포넌트 테스트 확장, 백엔드 JUnit, E2E(Playwright), CI 통합
|
||||
- 설계서: docs/design/343-frontend-test-infra/README.md
|
||||
- Refs: #343 (close)
|
||||
|
||||
### 🔤 #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-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)
|
||||
implementation 'com.oracle.database.jdbc:ojdbc11:23.7.0.25.01'
|
||||
implementation 'com.oracle.database.security:oraclepki:23.7.0.25.01'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.tasteby;
|
||||
|
||||
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
@@ -8,6 +9,8 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
@SpringBootApplication
|
||||
@EnableAsync
|
||||
@EnableScheduling
|
||||
// #335 — defaultLockAtMostFor: 어떤 작업이 lockAtMostFor 명시 안 해도 보호 (안전 마진)
|
||||
@EnableSchedulerLock(defaultLockAtMostFor = "PT15M")
|
||||
public class TastebyApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(TastebyApplication.class, args);
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
package com.tasteby.config;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.event.EventListener;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
@Configuration
|
||||
public class DataSourceConfig {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DataSourceConfig.class);
|
||||
|
||||
@Value("${app.oracle.wallet-path:}")
|
||||
private String walletPath;
|
||||
|
||||
private final DataSource dataSource;
|
||||
|
||||
public DataSourceConfig(DataSource dataSource) {
|
||||
this.dataSource = dataSource;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void configureWallet() {
|
||||
if (walletPath != null && !walletPath.isBlank()) {
|
||||
@@ -18,4 +31,23 @@ public class DataSourceConfig {
|
||||
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
|
||||
// Public endpoints
|
||||
.requestMatchers("/api/health").permitAll()
|
||||
.requestMatchers("/api/version").permitAll() // #338 — 빌드 정보 공개
|
||||
.requestMatchers("/api/auth/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/restaurants/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/channels").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/search").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/restaurants/*/reviews").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)
|
||||
.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,90 @@
|
||||
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);
|
||||
}
|
||||
|
||||
// #359 1단계 — google_place_id 중복 조회 (정리/UNIQUE는 후속).
|
||||
@GetMapping("/duplicates/place-id")
|
||||
public Map<String, Object> duplicatePlaceIds() {
|
||||
var admin = AuthUtil.requireAdmin();
|
||||
var groups = restaurantService.findDuplicatePlaceIdGroups();
|
||||
log.info("[ADMIN] {} duplicate place_id groups: {}", admin.getSubject(), groups.size());
|
||||
return Map.of("groups", groups, "group_count", groups.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 어드민용 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;
|
||||
|
||||
import com.tasteby.domain.Memo;
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import com.tasteby.domain.Review;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.MemoService;
|
||||
import com.tasteby.service.ReviewService;
|
||||
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.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -13,18 +20,22 @@ import java.util.Map;
|
||||
@RequestMapping("/api/admin/users")
|
||||
public class AdminUserController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AdminUserController.class);
|
||||
private final UserService userService;
|
||||
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.reviewService = reviewService;
|
||||
this.memoService = memoService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public Map<String, Object> listUsers(
|
||||
@RequestParam(defaultValue = "50") int limit,
|
||||
@RequestParam(defaultValue = "0") int offset) {
|
||||
AuthUtil.requireAdmin();
|
||||
var users = userService.findAllWithCounts(limit, offset);
|
||||
int total = userService.countAll();
|
||||
return Map.of("users", users, "total", total);
|
||||
@@ -32,11 +43,32 @@ public class AdminUserController {
|
||||
|
||||
@GetMapping("/{userId}/favorites")
|
||||
public List<Restaurant> userFavorites(@PathVariable String userId) {
|
||||
AuthUtil.requireAdmin();
|
||||
return reviewService.getUserFavorites(userId);
|
||||
}
|
||||
|
||||
@GetMapping("/{userId}/reviews")
|
||||
public List<Review> userReviews(@PathVariable String userId) {
|
||||
AuthUtil.requireAdmin();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.RestaurantService;
|
||||
import com.tasteby.service.VideoRelevanceService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* #356 영상-식당 관련도 LLM 평가 어드민 API.
|
||||
* - 미평가 카운트 / 일괄 백필 / 단건 재평가 / 수동 토글
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/video-relevance")
|
||||
public class AdminVideoRelevanceController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AdminVideoRelevanceController.class);
|
||||
private static final Set<String> VALID = Set.of("strong", "weak", "incidental", "unknown");
|
||||
|
||||
private final RestaurantService restaurantService;
|
||||
private final VideoRelevanceService relevanceService;
|
||||
|
||||
public AdminVideoRelevanceController(RestaurantService restaurantService, VideoRelevanceService relevanceService) {
|
||||
this.restaurantService = restaurantService;
|
||||
this.relevanceService = relevanceService;
|
||||
}
|
||||
|
||||
@GetMapping("/pending")
|
||||
public Map<String, Object> pendingCount() {
|
||||
var admin = AuthUtil.requireAdmin();
|
||||
int n = restaurantService.countUnevaluatedLinks();
|
||||
log.info("[ADMIN] {} video-relevance pending: {}", admin.getSubject(), n);
|
||||
return Map.of("pending", n);
|
||||
}
|
||||
|
||||
@PostMapping("/all")
|
||||
public Map<String, Object> verifyAll(@RequestParam(defaultValue = "10") int batchSize) {
|
||||
var admin = AuthUtil.requireAdmin();
|
||||
log.info("[ADMIN] {} triggered video-relevance verifyAll(batchSize={})", admin.getSubject(), batchSize);
|
||||
int processed = relevanceService.verifyAll(batchSize);
|
||||
return Map.of("processed", processed);
|
||||
}
|
||||
|
||||
@PostMapping("/{linkId}/evaluate")
|
||||
public Map<String, Object> evaluateOne(@PathVariable String linkId) {
|
||||
var admin = AuthUtil.requireAdmin();
|
||||
log.info("[ADMIN] {} video-relevance evaluate({})", admin.getSubject(), linkId);
|
||||
relevanceService.verify(linkId);
|
||||
return Map.of("success", true, "linkId", linkId);
|
||||
}
|
||||
|
||||
@PatchMapping("/{linkId}")
|
||||
public Map<String, Object> setRelevance(@PathVariable String linkId, @RequestBody Map<String, Object> body) {
|
||||
var admin = AuthUtil.requireAdmin();
|
||||
Object relObj = body.get("relevance");
|
||||
if (!(relObj instanceof String relevance) || !VALID.contains(relevance)) {
|
||||
return Map.of("success", false, "error", "relevance must be one of strong|weak|incidental|unknown");
|
||||
}
|
||||
String reason = body.get("reason") instanceof String s ? s : "manual";
|
||||
restaurantService.updateLinkRelevance(linkId, relevance, reason);
|
||||
log.info("[ADMIN] {} manual relevance={} for link {}", admin.getSubject(), relevance, linkId);
|
||||
return Map.of("success", true, "linkId", linkId, "relevance", relevance);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import com.tasteby.domain.Channel;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.CacheService;
|
||||
import com.tasteby.service.ChannelService;
|
||||
import com.tasteby.service.YouTubeService;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
@@ -18,11 +20,14 @@ import java.util.Map;
|
||||
public class ChannelController {
|
||||
|
||||
private final ChannelService channelService;
|
||||
private final YouTubeService youtubeService;
|
||||
private final CacheService cache;
|
||||
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.youtubeService = youtubeService;
|
||||
this.cache = cache;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
@@ -48,18 +53,45 @@ public class ChannelController {
|
||||
String channelId = body.get("channel_id");
|
||||
String channelName = body.get("channel_name");
|
||||
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 {
|
||||
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);
|
||||
} catch (Exception e) {
|
||||
if (e.getMessage() != null && e.getMessage().toUpperCase().contains("UQ_CHANNELS_CID")) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Channel already exists");
|
||||
}
|
||||
throw e;
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
// #295 — 유니크 충돌을 메시지 문자열 매칭 대신 typed 예외로 감지 (제약명 변경에도 견고).
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Channel already exists");
|
||||
}
|
||||
}
|
||||
|
||||
@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}")
|
||||
public Map<String, Object> delete(@PathVariable String channelId) {
|
||||
AuthUtil.requireAdmin();
|
||||
|
||||
@@ -19,6 +19,8 @@ public class DaemonController {
|
||||
|
||||
@GetMapping("/config")
|
||||
public DaemonConfig getConfig() {
|
||||
// #275 — 데몬 운영 설정은 admin 전용 (이전: 공개 노출 — 정보 누출 위험)
|
||||
AuthUtil.requireAdmin();
|
||||
DaemonConfig config = daemonConfigService.getConfig();
|
||||
return config != null ? config : DaemonConfig.builder().build();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@@ -8,8 +9,20 @@ import java.util.Map;
|
||||
@RestController
|
||||
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")
|
||||
public Map<String, String> health() {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,27 +4,50 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.dto.RestaurantUpdateDTO;
|
||||
import com.tasteby.service.CacheService;
|
||||
import com.tasteby.service.GeocodingService;
|
||||
import com.tasteby.service.RestaurantService;
|
||||
import com.tasteby.service.WebSearchService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/restaurants")
|
||||
public class RestaurantController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RestaurantController.class);
|
||||
|
||||
private final RestaurantService restaurantService;
|
||||
private final GeocodingService geocodingService;
|
||||
private final CacheService cache;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final WebSearchService webSearch;
|
||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
public RestaurantController(RestaurantService restaurantService, CacheService cache, ObjectMapper objectMapper) {
|
||||
public RestaurantController(RestaurantService restaurantService, GeocodingService geocodingService, CacheService cache, ObjectMapper objectMapper, WebSearchService webSearch) {
|
||||
this.restaurantService = restaurantService;
|
||||
this.geocodingService = geocodingService;
|
||||
this.cache = cache;
|
||||
this.objectMapper = objectMapper;
|
||||
this.webSearch = webSearch;
|
||||
}
|
||||
|
||||
// #290 — Bean 종료 시 virtual thread executor를 정리하여 리소스 누수 방지.
|
||||
@PreDestroy
|
||||
public void shutdownExecutor() {
|
||||
executor.shutdown();
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -41,7 +64,7 @@ public class RestaurantController {
|
||||
if (cached != null) {
|
||||
try {
|
||||
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);
|
||||
cache.set(key, result);
|
||||
@@ -55,7 +78,7 @@ public class RestaurantController {
|
||||
if (cached != null) {
|
||||
try {
|
||||
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);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||
@@ -64,15 +87,54 @@ public class RestaurantController {
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
|
||||
public Map<String, Object> update(@PathVariable String id, @Valid @RequestBody RestaurantUpdateDTO dto) {
|
||||
AuthUtil.requireAdmin();
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||
restaurantService.update(id, body);
|
||||
|
||||
// #358 — DTO → Map (null 제외). 화이트리스트는 DTO 필드 자체로 표현.
|
||||
Map<String, Object> sanitized = dto.toFieldMap();
|
||||
|
||||
// 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();
|
||||
return Map.of("ok", true);
|
||||
var updated = restaurantService.findById(id);
|
||||
return Map.of("ok", true, "restaurant", updated);
|
||||
}
|
||||
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public Map<String, Object> delete(@PathVariable String id) {
|
||||
AuthUtil.requireAdmin();
|
||||
@@ -83,19 +145,308 @@ public class RestaurantController {
|
||||
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(10_800_000L); // 3h — 대량 백필 대응
|
||||
|
||||
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 차단. 빈 문자열은 매핑 해제로 허용.
|
||||
// Naver/DDG 결과가 www.tabling.co.kr 형태로도 옴.
|
||||
if (url != null && !url.isBlank()
|
||||
&& !url.startsWith("https://tabling.co.kr/")
|
||||
&& !url.startsWith("https://www.tabling.co.kr/")) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "테이블링 URL은 https://(www.)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(10_800_000L); // 3h — 대량 백필 대응
|
||||
|
||||
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")
|
||||
public List<Map<String, Object>> videos(@PathVariable String id) {
|
||||
String key = cache.makeKey("restaurant_videos", id);
|
||||
public List<Map<String, Object>> videos(
|
||||
@PathVariable String id,
|
||||
@RequestParam(name = "include_weak", defaultValue = "false") boolean includeWeak) {
|
||||
String key = cache.makeKey("restaurant_videos", id, includeWeak ? "all" : "strong");
|
||||
String cached = cache.getRaw(key);
|
||||
if (cached != null) {
|
||||
try {
|
||||
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);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||
var result = restaurantService.findVideoLinks(id);
|
||||
var result = restaurantService.findVideoLinks(id, includeWeak);
|
||||
cache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── 예약 사이트 URL 검색 (#357 WebSearchService: Naver primary + DDG fallback) ───
|
||||
|
||||
private List<Map<String, Object>> searchTabling(String restaurantName) {
|
||||
return webSearch.search(
|
||||
"site:tabling.co.kr " + restaurantName,
|
||||
"tabling.co.kr/restaurant/", "tabling.co.kr/place/"
|
||||
);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> searchCatchtable(String restaurantName) {
|
||||
// 실제 캐치테이블 URL은 /ct/shop/ 형식. 옛 /dining/ /shop/ 패턴은 매칭 실패.
|
||||
return webSearch.search(
|
||||
"site:app.catchtable.co.kr " + restaurantName,
|
||||
"catchtable.co.kr/ct/shop/", "catchtable.co.kr/ct/dining/"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 식당 이름과 검색 결과 제목의 유사도 검사.
|
||||
* 한쪽 이름이 다른쪽에 포함되거나, 공통 글자 비율이 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,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String userId = AuthUtil.getUserId();
|
||||
double rating = ((Number) body.get("rating")).doubleValue();
|
||||
double rating = requireRating(body.get("rating"));
|
||||
String text = (String) body.get("review_text");
|
||||
LocalDate visitedAt = body.get("visited_at") != null
|
||||
? LocalDate.parse((String) body.get("visited_at")) : null;
|
||||
@@ -51,8 +51,7 @@ public class ReviewController {
|
||||
@PathVariable String reviewId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String userId = AuthUtil.getUserId();
|
||||
Double rating = body.get("rating") != null
|
||||
? ((Number) body.get("rating")).doubleValue() : null;
|
||||
Double rating = body.get("rating") != null ? requireRating(body.get("rating")) : null;
|
||||
String text = (String) body.get("review_text");
|
||||
LocalDate visitedAt = body.get("visited_at") != null
|
||||
? LocalDate.parse((String) body.get("visited_at")) : null;
|
||||
@@ -94,4 +93,18 @@ public class ReviewController {
|
||||
public List<Restaurant> myFavorites() {
|
||||
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.service.SearchService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -21,7 +23,12 @@ public class SearchController {
|
||||
@RequestParam String q,
|
||||
@RequestParam(defaultValue = "keyword") String mode,
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
// #293 — q 빈값 가드: '%%' LIKE로 응답 폭발 차단
|
||||
if (q == null || q.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "검색어가 필요합니다");
|
||||
}
|
||||
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;
|
||||
|
||||
import com.tasteby.domain.SiteVisitStats;
|
||||
import com.tasteby.service.RateLimitService;
|
||||
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 java.util.Map;
|
||||
@@ -10,20 +15,51 @@ import java.util.Map;
|
||||
@RequestMapping("/api/stats")
|
||||
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.rateLimitService = rateLimitService;
|
||||
}
|
||||
|
||||
@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();
|
||||
return Map.of("ok", true);
|
||||
return Map.of("ok", true, "counted", true);
|
||||
}
|
||||
|
||||
@GetMapping("/visits")
|
||||
public SiteVisitStats 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());
|
||||
}
|
||||
|
||||
/** 클라이언트(브라우저)에서 가져온 트랜스크립트를 저장 */
|
||||
@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")
|
||||
public Map<String, Object> getExtractPrompt() {
|
||||
return Map.of("prompt", extractorService.getPrompt());
|
||||
@@ -234,6 +252,34 @@ public class VideoController {
|
||||
if (body.containsKey(key)) restFields.put(key, body.get(key));
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,10 @@ import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
/**
|
||||
* SSE streaming endpoints for bulk operations.
|
||||
@@ -26,6 +28,7 @@ public class VideoSseController {
|
||||
private final VideoService videoService;
|
||||
private final RestaurantService restaurantService;
|
||||
private final PipelineService pipelineService;
|
||||
private final YouTubeService youTubeService;
|
||||
private final OciGenAiService genAi;
|
||||
private final CacheService cache;
|
||||
private final ObjectMapper mapper;
|
||||
@@ -34,27 +37,120 @@ public class VideoSseController {
|
||||
public VideoSseController(VideoService videoService,
|
||||
RestaurantService restaurantService,
|
||||
PipelineService pipelineService,
|
||||
YouTubeService youTubeService,
|
||||
OciGenAiService genAi,
|
||||
CacheService cache,
|
||||
ObjectMapper mapper) {
|
||||
this.videoService = videoService;
|
||||
this.restaurantService = restaurantService;
|
||||
this.pipelineService = pipelineService;
|
||||
this.youTubeService = youTubeService;
|
||||
this.genAi = genAi;
|
||||
this.cache = cache;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@PostMapping("/bulk-transcript")
|
||||
public SseEmitter bulkTranscript() {
|
||||
public SseEmitter bulkTranscript(@RequestBody(required = false) Map<String, Object> body) {
|
||||
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(() -> {
|
||||
try {
|
||||
// TODO: Implement when transcript extraction is available in Java
|
||||
emit(emitter, Map.of("type", "start", "total", 0));
|
||||
emit(emitter, Map.of("type", "complete", "total", 0, "success", 0));
|
||||
var videos = selectedIds != null && !selectedIds.isEmpty()
|
||||
? videoService.findVideosByIds(selectedIds)
|
||||
: 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();
|
||||
} catch (Exception e) {
|
||||
log.error("Bulk transcript error", e);
|
||||
@@ -65,13 +161,20 @@ public class VideoSseController {
|
||||
}
|
||||
|
||||
@PostMapping("/bulk-extract")
|
||||
public SseEmitter bulkExtract() {
|
||||
public SseEmitter bulkExtract(@RequestBody(required = false) Map<String, Object> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
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(() -> {
|
||||
try {
|
||||
var rows = videoService.findVideosForBulkExtract();
|
||||
var rows = selectedIds != null && !selectedIds.isEmpty()
|
||||
? videoService.findVideosForExtractByIds(selectedIds)
|
||||
: videoService.findVideosForBulkExtract();
|
||||
|
||||
int total = rows.size();
|
||||
int totalRestaurants = 0;
|
||||
@@ -80,7 +183,8 @@ public class VideoSseController {
|
||||
for (int i = 0; i < total; i++) {
|
||||
var v = rows.get(i);
|
||||
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));
|
||||
Thread.sleep(delay);
|
||||
}
|
||||
@@ -245,13 +349,15 @@ public class VideoSseController {
|
||||
@PostMapping("/rebuild-vectors")
|
||||
public SseEmitter rebuildVectors() {
|
||||
AuthUtil.requireAdmin();
|
||||
SseEmitter emitter = new SseEmitter(600_000L);
|
||||
SseEmitter emitter = new SseEmitter(60_000L);
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
emit(emitter, Map.of("type", "start"));
|
||||
// TODO: Implement full vector rebuild using VectorService
|
||||
emit(emitter, Map.of("type", "complete", "total", 0));
|
||||
// #325 — 운영자에게 미구현 상태 명시 (이전: 즉시 complete(total=0) → 무반응 인상)
|
||||
emit(emitter, Map.of(
|
||||
"type", "not_implemented",
|
||||
"message", "벡터 재생성은 아직 구현되지 않았습니다. 후속 이슈(#325/#331)에서 처리 예정입니다."
|
||||
));
|
||||
emitter.complete();
|
||||
} catch (Exception e) {
|
||||
emitter.completeWithError(e);
|
||||
|
||||
@@ -14,6 +14,9 @@ public class Channel {
|
||||
private String channelId;
|
||||
private String channelName;
|
||||
private String titleFilter;
|
||||
private String description;
|
||||
private String tags;
|
||||
private Integer sortOrder;
|
||||
private int videoCount;
|
||||
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 website;
|
||||
private String googlePlaceId;
|
||||
private String tablingUrl;
|
||||
private String catchtableUrl;
|
||||
private String businessStatus;
|
||||
private Double rating;
|
||||
private Integer ratingCount;
|
||||
private Date updatedAt;
|
||||
|
||||
// #322 LLM 검증
|
||||
private Boolean hidden;
|
||||
private String hiddenReason;
|
||||
private Date verifiedAt;
|
||||
|
||||
// Transient enrichment fields
|
||||
private List<String> channels;
|
||||
private List<String> foodsMentioned;
|
||||
|
||||
@@ -10,6 +10,7 @@ import lombok.NoArgsConstructor;
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SiteVisitStats {
|
||||
private int today;
|
||||
private int total;
|
||||
// #274 — long으로 변경 (21억 이상 누적 시 int 오버플로 방지)
|
||||
private long today;
|
||||
private long total;
|
||||
}
|
||||
|
||||
@@ -22,4 +22,5 @@ public class UserInfo {
|
||||
private String createdAt;
|
||||
private int favoriteCount;
|
||||
private int reviewCount;
|
||||
private int memoCount;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.tasteby.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import jakarta.validation.constraints.DecimalMax;
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* #358 식당 부분 업데이트 DTO.
|
||||
* - null = 변경 없음 (toFieldMap에서 제외).
|
||||
* - 화이트리스트는 record 필드로 표현 — Jackson SNAKE_CASE 매핑 유지.
|
||||
* - URL: http(s) / "NONE" / 빈 문자열만 허용 ("NONE"은 DDG/Naver 매칭 실패 마킹).
|
||||
*/
|
||||
public record RestaurantUpdateDTO(
|
||||
@Size(min = 1, max = 200)
|
||||
String name,
|
||||
|
||||
@Size(max = 500)
|
||||
String address,
|
||||
|
||||
@Size(max = 100)
|
||||
String region,
|
||||
|
||||
@JsonProperty("cuisine_type")
|
||||
@Size(max = 50)
|
||||
String cuisineType,
|
||||
|
||||
@JsonProperty("price_range")
|
||||
@Min(1) @Max(5)
|
||||
Integer priceRange,
|
||||
|
||||
@Size(max = 50)
|
||||
String phone,
|
||||
|
||||
@Pattern(regexp = "^(https?://.*|NONE|)$")
|
||||
String website,
|
||||
|
||||
@JsonProperty("tabling_url")
|
||||
@Pattern(regexp = "^(https?://.*|NONE|)$")
|
||||
String tablingUrl,
|
||||
|
||||
@JsonProperty("catchtable_url")
|
||||
@Pattern(regexp = "^(https?://.*|NONE|)$")
|
||||
String catchtableUrl,
|
||||
|
||||
@DecimalMin("-90.0") @DecimalMax("90.0")
|
||||
BigDecimal latitude,
|
||||
|
||||
@DecimalMin("-180.0") @DecimalMax("180.0")
|
||||
BigDecimal longitude,
|
||||
|
||||
@JsonProperty("google_place_id")
|
||||
@Size(max = 200)
|
||||
String googlePlaceId,
|
||||
|
||||
@JsonProperty("business_status")
|
||||
@Size(max = 50)
|
||||
String businessStatus,
|
||||
|
||||
@DecimalMin("0.0") @DecimalMax("5.0")
|
||||
BigDecimal rating,
|
||||
|
||||
@JsonProperty("rating_count")
|
||||
@Min(0)
|
||||
Integer ratingCount
|
||||
) {
|
||||
/** null이 아닌 필드만 DB 컬럼명 키로 변환. */
|
||||
public Map<String, Object> toFieldMap() {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
if (name != null) m.put("name", name);
|
||||
if (address != null) m.put("address", address);
|
||||
if (region != null) m.put("region", region);
|
||||
if (cuisineType != null) m.put("cuisine_type", cuisineType);
|
||||
if (priceRange != null) m.put("price_range", priceRange);
|
||||
if (phone != null) m.put("phone", phone);
|
||||
if (website != null) m.put("website", website);
|
||||
if (tablingUrl != null) m.put("tabling_url", tablingUrl);
|
||||
if (catchtableUrl != null) m.put("catchtable_url", catchtableUrl);
|
||||
if (latitude != null) m.put("latitude", latitude);
|
||||
if (longitude != null) m.put("longitude", longitude);
|
||||
if (googlePlaceId != null) m.put("google_place_id", googlePlaceId);
|
||||
if (businessStatus != null) m.put("business_status", businessStatus);
|
||||
if (rating != null) m.put("rating", rating);
|
||||
if (ratingCount != null) m.put("rating_count", ratingCount);
|
||||
return m;
|
||||
}
|
||||
}
|
||||
@@ -21,4 +21,9 @@ public interface ChannelMapper {
|
||||
int deactivateById(@Param("id") String id);
|
||||
|
||||
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,11 +14,38 @@ public interface RestaurantMapper {
|
||||
@Param("offset") int offset,
|
||||
@Param("cuisine") String cuisine,
|
||||
@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();
|
||||
|
||||
// #356 영상-식당 관련도
|
||||
void updateLinkRelevance(@Param("linkId") String linkId,
|
||||
@Param("relevance") String relevance,
|
||||
@Param("reason") String reason);
|
||||
|
||||
Map<String, Object> findLinkContext(@Param("linkId") String linkId);
|
||||
|
||||
List<Map<String, Object>> findUnevaluatedLinks(@Param("limit") int limit);
|
||||
|
||||
int countUnevaluatedLinks();
|
||||
|
||||
// #359 1단계 — google_place_id 중복 조회
|
||||
List<Map<String, Object>> findDuplicatePlaceIdRows();
|
||||
|
||||
Restaurant findById(@Param("id") String id);
|
||||
|
||||
List<Map<String, Object>> findVideoLinks(@Param("restaurantId") String restaurantId);
|
||||
List<Map<String, Object>> findVideoLinks(@Param("restaurantId") String restaurantId,
|
||||
@Param("includeWeak") boolean includeWeak);
|
||||
|
||||
void insertRestaurant(Restaurant r);
|
||||
|
||||
@@ -55,6 +82,14 @@ public interface RestaurantMapper {
|
||||
|
||||
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>> findForRemapFoods();
|
||||
|
||||
@@ -7,7 +7,7 @@ public interface StatsMapper {
|
||||
|
||||
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>> findVideosByIds(@Param("ids") List<String> ids);
|
||||
|
||||
List<Map<String, Object>> findVideosForExtractByIds(@Param("ids") List<String> ids);
|
||||
|
||||
void updateVideoRestaurantFields(@Param("videoId") String videoId,
|
||||
@Param("restaurantId") String restaurantId,
|
||||
@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.tasteby.domain.UserInfo;
|
||||
import com.tasteby.security.JwtTokenProvider;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -17,6 +19,8 @@ import java.util.Map;
|
||||
@Service
|
||||
public class AuthService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AuthService.class);
|
||||
|
||||
private final UserService userService;
|
||||
private final JwtTokenProvider jwtProvider;
|
||||
private final GoogleIdTokenVerifier verifier;
|
||||
@@ -58,7 +62,10 @@ public class AuthService {
|
||||
} catch (ResponseStatusException e) {
|
||||
throw 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.LoggerFactory;
|
||||
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.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Set;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
@Service
|
||||
public class CacheService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CacheService.class);
|
||||
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 ObjectMapper mapper;
|
||||
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,
|
||||
@Value("${app.cache.ttl-seconds:600}") int ttlSeconds) {
|
||||
this.redis = redis;
|
||||
this.mapper = mapper;
|
||||
this.ttl = Duration.ofSeconds(ttlSeconds);
|
||||
try {
|
||||
redis.getConnectionFactory().getConnection().ping();
|
||||
log.info("Redis connected");
|
||||
} catch (Exception e) {
|
||||
log.warn("Redis unavailable ({}), caching disabled", e.getMessage());
|
||||
disabled = true;
|
||||
}
|
||||
this.disabled = !pingOk();
|
||||
if (!disabled) log.info("Redis connected");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -48,7 +62,7 @@ public class CacheService {
|
||||
return mapper.readValue(val, type);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Cache get error: {}", e.getMessage());
|
||||
recordError("get", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -58,7 +72,7 @@ public class CacheService {
|
||||
try {
|
||||
return redis.opsForValue().get(key);
|
||||
} catch (Exception e) {
|
||||
log.debug("Cache get error: {}", e.getMessage());
|
||||
recordError("getRaw", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -69,20 +83,114 @@ public class CacheService {
|
||||
String json = mapper.writeValueAsString(value);
|
||||
redis.opsForValue().set(key, json, ttl);
|
||||
} 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() {
|
||||
if (disabled) return;
|
||||
try {
|
||||
Set<String> keys = redis.keys(PREFIX + "*");
|
||||
if (keys != null && !keys.isEmpty()) {
|
||||
redis.delete(keys);
|
||||
Integer count = redis.execute((org.springframework.data.redis.core.RedisCallback<Integer>) conn -> {
|
||||
List<byte[]> batch = new ArrayList<>(SCAN_BATCH);
|
||||
int deleted = 0;
|
||||
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);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
recordError("flush:scan", e);
|
||||
}
|
||||
log.info("Cache flushed");
|
||||
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) {
|
||||
log.debug("Cache flush error: {}", e.getMessage());
|
||||
// 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) {
|
||||
// Try deactivate by channel_id first, then by DB id
|
||||
int rows = mapper.deactivateByChannelId(channelId);
|
||||
if (rows == 0) {
|
||||
rows = mapper.deactivateById(channelId);
|
||||
}
|
||||
if (channelId == null || channelId.isBlank()) return false;
|
||||
// #295 — 입력 형식으로 명시적 분기:
|
||||
// "UC..."(24 chars) 형식 → YouTube channel_id로 비활성화
|
||||
// 그 외(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;
|
||||
}
|
||||
|
||||
public Channel findByChannelId(String 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.mapper.DaemonConfigMapper;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@@ -27,20 +29,33 @@ public class DaemonConfigService {
|
||||
current.setScanEnabled(Boolean.TRUE.equals(body.get("scan_enabled")));
|
||||
}
|
||||
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")) {
|
||||
current.setProcessEnabled(Boolean.TRUE.equals(body.get("process_enabled")));
|
||||
}
|
||||
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")) {
|
||||
current.setProcessLimit(((Number) body.get("process_limit")).intValue());
|
||||
current.setProcessLimit(requirePositiveInt(body.get("process_limit"), "process_limit"));
|
||||
}
|
||||
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() {
|
||||
mapper.updateLastScan();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.domain.DaemonConfig;
|
||||
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -22,6 +24,9 @@ public class DaemonScheduler {
|
||||
private final PipelineService pipelineService;
|
||||
private final CacheService cacheService;
|
||||
|
||||
@Value("${app.daemon.enabled:true}")
|
||||
private boolean instanceEnabled;
|
||||
|
||||
public DaemonScheduler(DaemonConfigService daemonConfigService,
|
||||
YouTubeService youTubeService,
|
||||
PipelineService pipelineService,
|
||||
@@ -33,7 +38,15 @@ public class DaemonScheduler {
|
||||
}
|
||||
|
||||
@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() {
|
||||
// 인스턴스 차원 차단(dev/prod 동일 DB 공유 환경에서 dev 쪽 동시 폴링 방지).
|
||||
// dev .env: DAEMON_ENABLED=false → 이 인스턴스는 스케줄러 동작 안 함.
|
||||
// prod: 미설정 → 기본 true.
|
||||
if (!instanceEnabled) return;
|
||||
try {
|
||||
var config = getConfig();
|
||||
if (config == null) return;
|
||||
@@ -42,8 +55,13 @@ public class DaemonScheduler {
|
||||
Instant lastScan = config.getLastScanAt() != null ? config.getLastScanAt().toInstant() : null;
|
||||
if (lastScan == null || Instant.now().isAfter(lastScan.plus(config.getScanIntervalMin(), ChronoUnit.MINUTES))) {
|
||||
log.info("Running scheduled channel scan...");
|
||||
int newVideos = youTubeService.scanAllChannels();
|
||||
daemonConfigService.updateLastScan();
|
||||
int newVideos = 0;
|
||||
try {
|
||||
newVideos = youTubeService.scanAllChannels();
|
||||
} finally {
|
||||
// #275 — 외부 호출 예외 시에도 last_scan_at을 갱신해 다음 cron까지의 backoff를 보장
|
||||
daemonConfigService.updateLastScan();
|
||||
}
|
||||
if (newVideos > 0) {
|
||||
cacheService.flush();
|
||||
log.info("Scan completed: {} new videos", newVideos);
|
||||
@@ -55,8 +73,12 @@ public class DaemonScheduler {
|
||||
Instant lastProcess = config.getLastProcessAt() != null ? config.getLastProcessAt().toInstant() : null;
|
||||
if (lastProcess == null || Instant.now().isAfter(lastProcess.plus(config.getProcessIntervalMin(), ChronoUnit.MINUTES))) {
|
||||
log.info("Running scheduled video processing (limit={})...", config.getProcessLimit());
|
||||
int restaurants = pipelineService.processPending(config.getProcessLimit());
|
||||
daemonConfigService.updateLastProcess();
|
||||
int restaurants = 0;
|
||||
try {
|
||||
restaurants = pipelineService.processPending(config.getProcessLimit());
|
||||
} finally {
|
||||
daemonConfigService.updateLastProcess();
|
||||
}
|
||||
if (restaurants > 0) {
|
||||
cacheService.flush();
|
||||
log.info("Processing completed: {} restaurants extracted", restaurants);
|
||||
|
||||
@@ -38,7 +38,7 @@ public class ExtractorService {
|
||||
%s
|
||||
- price_range: 가격대 (예: 1만원대, 2-3만원) (string | null)
|
||||
- foods_mentioned: 언급된 대표 메뉴 (string[], 최대 10개, 우선순위 높은 순, 반드시 한글로 작성)
|
||||
- evaluation: 평가 내용 (string | null)
|
||||
- evaluation: 평가 내용을 100자 이내로 요약 (string | null)
|
||||
- guests: 함께한 게스트 (string[])
|
||||
|
||||
영상 제목: {title}
|
||||
@@ -62,6 +62,10 @@ public class ExtractorService {
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
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
|
||||
if (transcript.length() > 8000) {
|
||||
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) {
|
||||
try {
|
||||
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 jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -45,6 +46,8 @@ public class OciGenAiService {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
private ConfigFileAuthenticationDetailsProvider authProvider;
|
||||
private GenerativeAiInferenceClient chatClient;
|
||||
private GenerativeAiInferenceClient embedClient;
|
||||
|
||||
public OciGenAiService(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
@@ -55,45 +58,50 @@ public class OciGenAiService {
|
||||
try {
|
||||
ConfigFileReader.ConfigFile configFile = ConfigFileReader.parseDefault();
|
||||
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) {
|
||||
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).
|
||||
*/
|
||||
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 userMessage = UserMessage.builder().content(List.of(textContent)).build();
|
||||
|
||||
var textContent = TextContent.builder().text(prompt).build();
|
||||
var userMessage = UserMessage.builder().content(List.of(textContent)).build();
|
||||
var chatRequest = GenericChatRequest.builder()
|
||||
.messages(List.of(userMessage))
|
||||
.maxTokens(maxTokens)
|
||||
.temperature(0.0)
|
||||
.build();
|
||||
|
||||
var chatRequest = GenericChatRequest.builder()
|
||||
.messages(List.of(userMessage))
|
||||
.maxTokens(maxTokens)
|
||||
.temperature(0.0)
|
||||
.build();
|
||||
var chatDetails = ChatDetails.builder()
|
||||
.compartmentId(compartmentId)
|
||||
.servingMode(OnDemandServingMode.builder().modelId(chatModelId).build())
|
||||
.chatRequest(chatRequest)
|
||||
.build();
|
||||
|
||||
var chatDetails = ChatDetails.builder()
|
||||
.compartmentId(compartmentId)
|
||||
.servingMode(OnDemandServingMode.builder().modelId(chatModelId).build())
|
||||
.chatRequest(chatRequest)
|
||||
.build();
|
||||
ChatResponse response = chatClient.chat(
|
||||
ChatRequest.builder().chatDetails(chatDetails).build());
|
||||
|
||||
ChatResponse response = client.chat(
|
||||
ChatRequest.builder().chatDetails(chatDetails).build());
|
||||
|
||||
var chatResult = (GenericChatResponse) response.getChatResult().getChatResponse();
|
||||
var choice = chatResult.getChoices().get(0);
|
||||
var content = ((TextContent) choice.getMessage().getContent().get(0)).getText();
|
||||
return content.trim();
|
||||
}
|
||||
var chatResult = (GenericChatResponse) response.getChatResult().getChatResponse();
|
||||
var choice = chatResult.getChoices().get(0);
|
||||
var content = ((TextContent) choice.getMessage().getContent().get(0)).getText();
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,25 +119,22 @@ public class OciGenAiService {
|
||||
}
|
||||
|
||||
private List<List<Double>> embedBatch(List<String> texts) {
|
||||
try (var client = GenerativeAiInferenceClient.builder()
|
||||
.endpoint(embedEndpoint)
|
||||
.build(authProvider)) {
|
||||
if (embedClient == null) throw new IllegalStateException("OCI GenAI not configured");
|
||||
|
||||
var embedDetails = EmbedTextDetails.builder()
|
||||
.inputs(texts)
|
||||
.servingMode(OnDemandServingMode.builder().modelId(embedModelId).build())
|
||||
.compartmentId(compartmentId)
|
||||
.inputType(EmbedTextDetails.InputType.SearchDocument)
|
||||
.build();
|
||||
var embedDetails = EmbedTextDetails.builder()
|
||||
.inputs(texts)
|
||||
.servingMode(OnDemandServingMode.builder().modelId(embedModelId).build())
|
||||
.compartmentId(compartmentId)
|
||||
.inputType(EmbedTextDetails.InputType.SearchDocument)
|
||||
.build();
|
||||
|
||||
EmbedTextResponse response = client.embedText(
|
||||
EmbedTextRequest.builder().embedTextDetails(embedDetails).build());
|
||||
EmbedTextResponse response = embedClient.embedText(
|
||||
EmbedTextRequest.builder().embedTextDetails(embedDetails).build());
|
||||
|
||||
return response.getEmbedTextResult().getEmbeddings()
|
||||
.stream()
|
||||
.map(emb -> emb.stream().map(Number::doubleValue).toList())
|
||||
.toList();
|
||||
}
|
||||
return response.getEmbedTextResult().getEmbeddings()
|
||||
.stream()
|
||||
.map(emb -> emb.stream().map(Number::doubleValue).toList())
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,26 +150,25 @@ public class OciGenAiService {
|
||||
return mapper.readValue(raw, Object.class);
|
||||
} 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("[")) {
|
||||
List<Object> items = new ArrayList<>();
|
||||
int idx = raw.indexOf('[') + 1;
|
||||
while (idx < raw.length()) {
|
||||
while (idx < raw.length() && " \t\n\r,".indexOf(raw.charAt(idx)) >= 0) idx++;
|
||||
if (idx >= raw.length() || raw.charAt(idx) == ']') break;
|
||||
if (raw.charAt(idx) != '{') break; // 객체 시작이 아니면 복구 중단
|
||||
|
||||
// Try to parse next object
|
||||
boolean found = false;
|
||||
for (int end = idx + 1; end <= raw.length(); end++) {
|
||||
try {
|
||||
Object obj = mapper.readValue(raw.substring(idx, end), Object.class);
|
||||
items.add(obj);
|
||||
idx = end;
|
||||
found = true;
|
||||
break;
|
||||
} catch (Exception ignored2) {}
|
||||
int end = findObjectEnd(raw, idx);
|
||||
if (end < 0) break; // 잘린 객체 — 거기서 멈춤
|
||||
try {
|
||||
Object obj = mapper.readValue(raw.substring(idx, end + 1), Object.class);
|
||||
items.add(obj);
|
||||
} catch (Exception ignored2) {
|
||||
break; // 불가해 객체 — 멈춤
|
||||
}
|
||||
if (!found) break;
|
||||
idx = end + 1;
|
||||
}
|
||||
if (!items.isEmpty()) {
|
||||
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())));
|
||||
}
|
||||
|
||||
/**
|
||||
* #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,8 @@ public class PipelineService {
|
||||
private final VideoService videoService;
|
||||
private final VectorService vectorService;
|
||||
private final CacheService cacheService;
|
||||
private final RestaurantVerifyService verifyService;
|
||||
private final VideoRelevanceService relevanceService;
|
||||
|
||||
public PipelineService(YouTubeService youTubeService,
|
||||
ExtractorService extractorService,
|
||||
@@ -35,7 +37,9 @@ public class PipelineService {
|
||||
RestaurantService restaurantService,
|
||||
VideoService videoService,
|
||||
VectorService vectorService,
|
||||
CacheService cacheService) {
|
||||
CacheService cacheService,
|
||||
RestaurantVerifyService verifyService,
|
||||
VideoRelevanceService relevanceService) {
|
||||
this.youTubeService = youTubeService;
|
||||
this.extractorService = extractorService;
|
||||
this.geocodingService = geocodingService;
|
||||
@@ -43,6 +47,8 @@ public class PipelineService {
|
||||
this.videoService = videoService;
|
||||
this.vectorService = vectorService;
|
||||
this.cacheService = cacheService;
|
||||
this.verifyService = verifyService;
|
||||
this.relevanceService = relevanceService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,6 +90,9 @@ public class PipelineService {
|
||||
String videoDbId = (String) video.get("id");
|
||||
String title = (String) video.get("title");
|
||||
|
||||
// #292 — 외부 가시성을 위해 진입 시 processing 전이 (이미 processing이면 no-op)
|
||||
updateVideoStatus(videoDbId, "processing", null, null);
|
||||
|
||||
var result = extractorService.extractRestaurants(title, transcript, customPrompt);
|
||||
if (result.restaurants().isEmpty()) {
|
||||
updateVideoStatus(videoDbId, "done", null, result.rawResponse());
|
||||
@@ -102,18 +111,26 @@ public class PipelineService {
|
||||
// Build upsert data
|
||||
var data = new HashMap<String, Object>();
|
||||
data.put("name", name);
|
||||
data.put("address", geo != null ? geo.get("formatted_address") : restData.get("address"));
|
||||
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("price_range", restData.get("price_range"));
|
||||
data.put("google_place_id", geo != null ? geo.get("google_place_id") : null);
|
||||
data.put("phone", geo != null ? geo.get("phone") : null);
|
||||
data.put("website", geo != null ? geo.get("website") : null);
|
||||
data.put("business_status", geo != null ? geo.get("business_status") : null);
|
||||
data.put("rating", geo != null ? geo.get("rating") : null);
|
||||
data.put("rating_count", geo != null ? geo.get("rating_count") : null);
|
||||
// #292 — geocode 실패(geo==null) 시 좌표/주소/place_id 등 기존 값 보존하기 위해
|
||||
// null을 명시적으로 put하지 않는다. upsert 측에서 누락 컬럼은 그대로 유지.
|
||||
if (geo != null) {
|
||||
data.put("address", geo.get("formatted_address"));
|
||||
data.put("latitude", geo.get("latitude"));
|
||||
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);
|
||||
|
||||
@@ -131,13 +148,16 @@ public class PipelineService {
|
||||
evaluationJson = JsonUtil.toJson(s);
|
||||
}
|
||||
|
||||
restaurantService.linkVideoRestaurant(
|
||||
String linkId = restaurantService.linkVideoRestaurant(
|
||||
videoDbId, restId,
|
||||
foods instanceof List<?> ? (List<String>) foods : null,
|
||||
evaluationJson,
|
||||
guests instanceof List<?> ? (List<String>) guests : null
|
||||
);
|
||||
|
||||
// #356 — 신규 등록 직후 비동기 관련도 평가
|
||||
relevanceService.verifyAsync(linkId);
|
||||
|
||||
// Vector embeddings
|
||||
var chunks = VectorService.buildChunks(name, restData, title);
|
||||
if (!chunks.isEmpty()) {
|
||||
@@ -150,6 +170,9 @@ public class PipelineService {
|
||||
|
||||
count++;
|
||||
log.info("Saved restaurant: {} (geocoded={})", name, geo != null);
|
||||
|
||||
// #322 — 등록 직후 비동기 LLM 검증
|
||||
verifyService.verifyAsync(restId);
|
||||
}
|
||||
|
||||
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) {
|
||||
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);
|
||||
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) {
|
||||
Restaurant restaurant = mapper.findById(id);
|
||||
if (restaurant == null) return null;
|
||||
@@ -34,7 +77,11 @@ public class RestaurantService {
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findVideoLinks(String restaurantId) {
|
||||
var rows = mapper.findVideoLinks(restaurantId);
|
||||
return findVideoLinks(restaurantId, false);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findVideoLinks(String restaurantId, boolean includeWeak) {
|
||||
var rows = mapper.findVideoLinks(restaurantId, includeWeak);
|
||||
return rows.stream().map(row -> {
|
||||
var m = JsonUtil.lowerKeys(row);
|
||||
m.put("foods_mentioned", JsonUtil.parseStringList(m.get("foods_mentioned")));
|
||||
@@ -44,6 +91,43 @@ public class RestaurantService {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// #356 영상-식당 관련도
|
||||
public void updateLinkRelevance(String linkId, String relevance, String reason) {
|
||||
mapper.updateLinkRelevance(linkId, relevance, reason);
|
||||
}
|
||||
|
||||
public Map<String, Object> findLinkContext(String linkId) {
|
||||
var row = mapper.findLinkContext(linkId);
|
||||
return row != null ? JsonUtil.lowerKeys(row) : null;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findUnevaluatedLinks(int limit) {
|
||||
return mapper.findUnevaluatedLinks(limit).stream()
|
||||
.map(JsonUtil::lowerKeys)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public int countUnevaluatedLinks() {
|
||||
return mapper.countUnevaluatedLinks();
|
||||
}
|
||||
|
||||
// #359 1단계 — google_place_id 중복 그룹 (참조 카운트 동봉)
|
||||
public List<Map<String, Object>> findDuplicatePlaceIdGroups() {
|
||||
var rows = mapper.findDuplicatePlaceIdRows().stream()
|
||||
.map(JsonUtil::lowerKeys)
|
||||
.toList();
|
||||
Map<String, List<Map<String, Object>>> grouped = new LinkedHashMap<>();
|
||||
for (var r : rows) {
|
||||
String key = (String) r.get("google_place_id");
|
||||
grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(r);
|
||||
}
|
||||
List<Map<String, Object>> out = new ArrayList<>(grouped.size());
|
||||
for (var e : grouped.entrySet()) {
|
||||
out.add(Map.of("google_place_id", e.getKey(), "items", e.getValue()));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public void update(String id, Map<String, Object> fields) {
|
||||
mapper.updateFields(id, fields);
|
||||
}
|
||||
@@ -95,11 +179,13 @@ public class RestaurantService {
|
||||
}
|
||||
}
|
||||
|
||||
public void linkVideoRestaurant(String videoId, String restaurantId, List<String> foods, String evaluation, List<String> guests) {
|
||||
public String linkVideoRestaurant(String videoId, String restaurantId, List<String> foods, String evaluation, List<String> guests) {
|
||||
String id = IdGenerator.newId();
|
||||
String foodsJson = foods != null ? JsonUtil.toJson(foods) : 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);
|
||||
return id;
|
||||
}
|
||||
|
||||
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.util.IdGenerator;
|
||||
import com.tasteby.util.JsonUtil;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -37,11 +38,13 @@ public class ReviewService {
|
||||
return mapper.findById(id);
|
||||
}
|
||||
|
||||
@Transactional // #334 — 단일 SQL이지만 어노테이션 일관성
|
||||
public boolean update(String reviewId, String userId, Double rating, String reviewText, LocalDate visitedAt) {
|
||||
String visitedStr = visitedAt != null ? visitedAt.toString() : null;
|
||||
return mapper.updateReview(reviewId, userId, rating, reviewText, visitedStr) > 0;
|
||||
}
|
||||
|
||||
@Transactional // #334 — 단일 SQL이지만 어노테이션 일관성
|
||||
public boolean delete(String reviewId, String userId) {
|
||||
return mapper.deleteReview(reviewId, userId) > 0;
|
||||
}
|
||||
@@ -60,10 +63,15 @@ public class ReviewService {
|
||||
if (existingId != null) {
|
||||
mapper.deleteFavorite(userId, restaurantId);
|
||||
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) {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
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.mapper.SearchMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
@@ -12,12 +15,17 @@ import java.util.*;
|
||||
public class SearchService {
|
||||
|
||||
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 RestaurantService restaurantService;
|
||||
private final VectorService vectorService;
|
||||
private final CacheService cache;
|
||||
|
||||
@Value("${app.search.max-distance:0.57}")
|
||||
private double maxDistance;
|
||||
|
||||
public SearchService(SearchMapper searchMapper,
|
||||
RestaurantService restaurantService,
|
||||
VectorService vectorService,
|
||||
@@ -33,8 +41,8 @@ public class SearchService {
|
||||
String cached = cache.getRaw(key);
|
||||
if (cached != null) {
|
||||
try {
|
||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
return mapper.readValue(cached, new com.fasterxml.jackson.core.type.TypeReference<List<Restaurant>>() {});
|
||||
// #293 — ObjectMapper 재사용 (필드 static)
|
||||
return JSON.readValue(cached, LIST_TYPE);
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
@@ -44,13 +52,20 @@ public class SearchService {
|
||||
case "hybrid" -> {
|
||||
var kw = keywordSearch(q, limit);
|
||||
var sem = semanticSearch(q, limit);
|
||||
// #293 — semantic 결과에도 channels 부착 (이전: keyword에만 부착되어 hybrid에서 sem 결과는 채널 누락)
|
||||
if (!sem.isEmpty()) attachChannels(sem);
|
||||
Set<String> seen = new HashSet<>();
|
||||
var merged = new ArrayList<Restaurant>();
|
||||
for (var r : kw) { 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;
|
||||
}
|
||||
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);
|
||||
@@ -58,7 +73,10 @@ public class SearchService {
|
||||
}
|
||||
|
||||
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);
|
||||
if (!results.isEmpty()) {
|
||||
attachChannels(results);
|
||||
@@ -68,7 +86,7 @@ public class SearchService {
|
||||
|
||||
private List<Restaurant> semanticSearch(String q, int limit) {
|
||||
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();
|
||||
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
|
||||
@@ -2,11 +2,16 @@ package com.tasteby.service;
|
||||
|
||||
import com.tasteby.domain.SiteVisitStats;
|
||||
import com.tasteby.mapper.StatsMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class StatsService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(StatsService.class);
|
||||
|
||||
private final StatsMapper mapper;
|
||||
|
||||
public StatsService(StatsMapper mapper) {
|
||||
@@ -14,7 +19,19 @@ public class StatsService {
|
||||
}
|
||||
|
||||
public void recordVisit() {
|
||||
mapper.recordVisit();
|
||||
// #274 — 자정 경계 동시성: 두 트랜잭션이 동시에 'NOT MATCHED' 판정 → 둘 다 INSERT
|
||||
// → PK/UNIQUE 충돌 시 한 쪽 500. 1회 재시도(다음엔 MATCHED → UPDATE 분기).
|
||||
try {
|
||||
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() {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.util.IdGenerator;
|
||||
import com.tasteby.util.JsonUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
@@ -27,12 +29,15 @@ public class VectorService {
|
||||
*/
|
||||
public List<Map<String, Object>> searchSimilar(String query, int topK, double maxDistance) {
|
||||
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
|
||||
float[] queryVec = new float[embeddings.getFirst().size()];
|
||||
float[] queryVec = new float[first.size()];
|
||||
for (int i = 0; i < queryVec.length; i++) {
|
||||
queryVec[i] = embeddings.getFirst().get(i).floatValue();
|
||||
queryVec[i] = first.get(i).floatValue();
|
||||
}
|
||||
|
||||
String sql = """
|
||||
@@ -61,6 +66,9 @@ public class VectorService {
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
if (chunks.isEmpty()) return;
|
||||
@@ -72,19 +80,20 @@ public class VectorService {
|
||||
VALUES (:id, :rid, :chunk, :emb)
|
||||
""";
|
||||
|
||||
SqlParameterSource[] batch = new SqlParameterSource[chunks.size()];
|
||||
for (int i = 0; i < chunks.size(); i++) {
|
||||
String id = UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase();
|
||||
float[] vec = new float[embeddings.get(i).size()];
|
||||
List<Double> emb = embeddings.get(i);
|
||||
float[] vec = new float[emb.size()];
|
||||
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();
|
||||
params.addValue("id", id);
|
||||
params.addValue("rid", restaurantId);
|
||||
params.addValue("chunk", chunks.get(i));
|
||||
params.addValue("emb", vec);
|
||||
jdbc.update(sql, params);
|
||||
batch[i] = new MapSqlParameterSource()
|
||||
.addValue("id", IdGenerator.newId())
|
||||
.addValue("rid", restaurantId)
|
||||
.addValue("chunk", chunks.get(i))
|
||||
.addValue("emb", vec);
|
||||
}
|
||||
jdbc.batchUpdate(sql, batch);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* #356 영상-식당 관련도 LLM 평가.
|
||||
* 설계서: docs/design/356-video-relevance-llm/README.md
|
||||
*
|
||||
* 신규 등록 시 자동 평가 + 어드민 백필. 결과는 video_restaurants.relevance에 저장.
|
||||
* - strong: 본격 다룸 (방문 리뷰, 메뉴 평가)
|
||||
* - weak: 잠깐 언급, 비교 대상
|
||||
* - incidental: 일반 토픽 중 단순 언급, 입점 전
|
||||
* - unknown: 미평가 or LLM 실패 (안전 기본값으로 표시 유지)
|
||||
*/
|
||||
@Service
|
||||
public class VideoRelevanceService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(VideoRelevanceService.class);
|
||||
private static final Set<String> VALID = Set.of("strong", "weak", "incidental", "unknown");
|
||||
private static final long BACKFILL_SLEEP_MS = 200;
|
||||
|
||||
private final RestaurantService restaurantService;
|
||||
private final OciGenAiService genAi;
|
||||
private final ObjectMapper jsonMapper = new ObjectMapper();
|
||||
|
||||
public VideoRelevanceService(RestaurantService restaurantService, OciGenAiService genAi) {
|
||||
this.restaurantService = restaurantService;
|
||||
this.genAi = genAi;
|
||||
}
|
||||
|
||||
@Async
|
||||
public void verifyAsync(String linkId) {
|
||||
try {
|
||||
verify(linkId);
|
||||
} catch (Exception e) {
|
||||
log.warn("verifyAsync failed for link {}: {}", linkId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void verify(String linkId) {
|
||||
Map<String, Object> ctx = restaurantService.findLinkContext(linkId);
|
||||
if (ctx == null) return;
|
||||
VerifyResult result;
|
||||
try {
|
||||
String prompt = buildPrompt(ctx);
|
||||
String response = genAi.chat(prompt, 120);
|
||||
result = parseRelevance(response);
|
||||
} catch (Exception e) {
|
||||
log.warn("verify({}) LLM failed: {} — keeping unknown", linkId, e.getMessage());
|
||||
return;
|
||||
}
|
||||
restaurantService.updateLinkRelevance(linkId, result.relevance(), truncate(result.reason(), 120));
|
||||
}
|
||||
|
||||
public int verifyAll(int batchSize) {
|
||||
int total = 0;
|
||||
List<Map<String, Object>> batch;
|
||||
while (!(batch = restaurantService.findUnevaluatedLinks(batchSize)).isEmpty()) {
|
||||
for (Map<String, Object> row : batch) {
|
||||
String linkId = (String) row.get("link_id");
|
||||
if (linkId == null) continue;
|
||||
try {
|
||||
verify(linkId);
|
||||
} catch (Exception e) {
|
||||
log.warn("verifyAll({}) failed: {}", linkId, 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 ----
|
||||
|
||||
String buildPrompt(Map<String, Object> ctx) {
|
||||
String foods = safeStr(ctx.get("foods_mentioned"));
|
||||
String evaluation = safeStr(ctx.get("evaluation"));
|
||||
return "다음 YouTube 영상이 이 식당을 어떻게 다루는지 판정하라.\n\n" +
|
||||
"식당명: " + safeStr(ctx.get("restaurant_name")) + "\n" +
|
||||
"주소: " + safeStr(ctx.get("address")) + "\n" +
|
||||
"음식 분류: " + safeStr(ctx.get("cuisine_type")) + "\n" +
|
||||
"언급된 음식: " + (foods.isEmpty() ? "(없음)" : foods) + "\n\n" +
|
||||
"영상 제목: " + safeStr(ctx.get("video_title")) + "\n" +
|
||||
"영상 채널: " + safeStr(ctx.get("channel_name")) + "\n" +
|
||||
"영상에 등장한 평가: " + (evaluation.isEmpty() ? "(없음)" : evaluation) + "\n\n" +
|
||||
"응답 형식(JSON만, 다른 텍스트 없이):\n" +
|
||||
"{\"relevance\": \"strong\"|\"weak\"|\"incidental\", \"reason\": \"20자 이내 한국어\"}\n\n" +
|
||||
"가이드:\n" +
|
||||
"- strong: 영상이 이 식당을 본격 다룸 (방문 리뷰, 메뉴 평가).\n" +
|
||||
"- weak: 잠깐 언급, 다른 식당과 비교 대상으로 등장.\n" +
|
||||
"- incidental: 일반 토픽 중 단순 언급, 식당 입점 전 영상.\n" +
|
||||
"- 판단 모호 시 strong (보수적 — 사용자에게 표시 유지).";
|
||||
}
|
||||
|
||||
private static final Pattern JSON_BLOCK = Pattern.compile("\\{[^{}]*\\}", Pattern.DOTALL);
|
||||
|
||||
VerifyResult parseRelevance(String raw) {
|
||||
if (raw == null) return VerifyResult.unknown();
|
||||
String trimmed = raw.trim();
|
||||
String json = (trimmed.startsWith("{") && trimmed.endsWith("}")) ? trimmed : null;
|
||||
if (json == null) {
|
||||
Matcher m = JSON_BLOCK.matcher(raw);
|
||||
if (m.find()) json = m.group();
|
||||
}
|
||||
if (json == null) return VerifyResult.unknown();
|
||||
try {
|
||||
JsonNode node = jsonMapper.readTree(json);
|
||||
String rel = node.path("relevance").asText("unknown").toLowerCase();
|
||||
if (!VALID.contains(rel)) rel = "unknown";
|
||||
String reason = node.path("reason").asText("");
|
||||
return new VerifyResult(rel, reason);
|
||||
} catch (Exception e) {
|
||||
return VerifyResult.unknown();
|
||||
}
|
||||
}
|
||||
|
||||
private static String safeStr(Object o) {
|
||||
return o == null ? "" : o.toString();
|
||||
}
|
||||
|
||||
private static String truncate(String s, int max) {
|
||||
return s == null ? null : (s.length() <= max ? s : s.substring(0, max));
|
||||
}
|
||||
|
||||
public record VerifyResult(String relevance, String reason) {
|
||||
public static VerifyResult unknown() { return new VerifyResult("unknown", "parse_failed"); }
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,9 @@ public class VideoService {
|
||||
VideoDetail detail = mapper.findDetail(id);
|
||||
if (detail == null) return null;
|
||||
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());
|
||||
return detail;
|
||||
}
|
||||
@@ -59,6 +62,7 @@ public class VideoService {
|
||||
mapper.cleanupOrphanRestaurant(restaurantId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int saveVideosBatch(String channelId, List<Map<String, Object>> videos) {
|
||||
Set<String> existing = new HashSet<>(mapper.getExistingVideoIds(channelId));
|
||||
int saved = 0;
|
||||
@@ -111,6 +115,22 @@ public class VideoService {
|
||||
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,
|
||||
String foodsJson, String evaluation, String guestsJson) {
|
||||
mapper.updateVideoRestaurantFields(videoId, restaurantId, foodsJson, evaluation, guestsJson);
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.net.URI;
|
||||
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.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* #357 웹 검색 추상화.
|
||||
* - Naver Search webkr.json 우선 (한국 식당 정확도 높음, 무료 일 25k).
|
||||
* - 키 미설정 또는 5xx/timeout 시 DDG HTML 파싱으로 폴백.
|
||||
* - 결과는 urlPatterns로 필터링 (기존 searchDuckDuckGo와 동일 인터페이스).
|
||||
*/
|
||||
@Service
|
||||
public class WebSearchService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WebSearchService.class);
|
||||
private static final int MAX_RESULTS = 5;
|
||||
|
||||
private static final Duration REQ_TIMEOUT = Duration.ofSeconds(15);
|
||||
private static final HttpClient HTTP = HttpClient.newBuilder()
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.connectTimeout(Duration.ofSeconds(5))
|
||||
.build();
|
||||
|
||||
private static final Pattern DDG_RESULT = Pattern.compile(
|
||||
"<a[^>]+class=\"result__a\"[^>]+href=\"([^\"]+)\"[^>]*>(.*?)</a>",
|
||||
Pattern.DOTALL);
|
||||
|
||||
private final ObjectMapper json = new ObjectMapper();
|
||||
private final String naverClientId;
|
||||
private final String naverClientSecret;
|
||||
|
||||
public WebSearchService(
|
||||
@Value("${app.naver.client-id:}") String naverClientId,
|
||||
@Value("${app.naver.client-secret:}") String naverClientSecret) {
|
||||
this.naverClientId = naverClientId == null ? "" : naverClientId.trim();
|
||||
this.naverClientSecret = naverClientSecret == null ? "" : naverClientSecret.trim();
|
||||
log.info("WebSearchService init — Naver={}", naverClientId.isEmpty() ? "off" : "on");
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> search(String query, String... urlPatterns) {
|
||||
if (!naverClientId.isEmpty() && !naverClientSecret.isEmpty()) {
|
||||
try {
|
||||
List<Map<String, Object>> n = searchNaver(query, urlPatterns);
|
||||
if (!n.isEmpty()) return n;
|
||||
} catch (Exception e) {
|
||||
log.warn("[NaverSearch] failed, falling back to DDG: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
try {
|
||||
return searchDdg(query, urlPatterns);
|
||||
} catch (Exception e) {
|
||||
log.warn("[DDG] failed: {}", e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Naver ───
|
||||
|
||||
List<Map<String, Object>> searchNaver(String query, String... urlPatterns) throws Exception {
|
||||
String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
|
||||
String url = "https://openapi.naver.com/v1/search/webkr.json?query=" + encoded + "&display=30";
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.timeout(REQ_TIMEOUT)
|
||||
.header("X-Naver-Client-Id", naverClientId)
|
||||
.header("X-Naver-Client-Secret", naverClientSecret)
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() >= 400) {
|
||||
throw new RuntimeException("Naver " + resp.statusCode());
|
||||
}
|
||||
JsonNode root = json.readTree(resp.body());
|
||||
JsonNode items = root.path("items");
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
Set<String> seen = new HashSet<>();
|
||||
for (JsonNode it : items) {
|
||||
if (out.size() >= MAX_RESULTS) break;
|
||||
String link = it.path("link").asText("");
|
||||
String title = stripTags(it.path("title").asText(""));
|
||||
if (link.isEmpty() || !matchesPattern(link, urlPatterns)) continue;
|
||||
if (seen.add(link)) out.add(Map.of("title", title, "url", link));
|
||||
}
|
||||
log.info("[NaverSearch] '{}' → {}", query, out.size());
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── DDG ───
|
||||
|
||||
List<Map<String, Object>> searchDdg(String query, String... urlPatterns) throws Exception {
|
||||
String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
|
||||
String url = "https://html.duckduckgo.com/html/?q=" + encoded;
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.timeout(REQ_TIMEOUT)
|
||||
.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> resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
String html = resp.body();
|
||||
Matcher m = DDG_RESULT.matcher(html);
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
Set<String> seen = new HashSet<>();
|
||||
while (m.find() && out.size() < MAX_RESULTS) {
|
||||
String href = m.group(1);
|
||||
String title = m.group(2).replaceAll("<[^>]+>", "").trim();
|
||||
String actual = extractDdgUrl(href);
|
||||
if (actual == null || !matchesPattern(actual, urlPatterns)) continue;
|
||||
if (seen.add(actual)) out.add(Map.of("title", title, "url", actual));
|
||||
}
|
||||
log.info("[DDG] '{}' → {}", query, out.size());
|
||||
return out;
|
||||
}
|
||||
|
||||
private String extractDdgUrl(String ddgHref) {
|
||||
try {
|
||||
if (ddgHref.contains("uddg=")) {
|
||||
String p = ddgHref.substring(ddgHref.indexOf("uddg=") + 5);
|
||||
int amp = p.indexOf('&');
|
||||
if (amp > 0) p = p.substring(0, amp);
|
||||
return URLDecoder.decode(p, StandardCharsets.UTF_8);
|
||||
}
|
||||
if (ddgHref.startsWith("http")) return ddgHref;
|
||||
} catch (Exception e) {
|
||||
log.debug("[DDG] url extract failed: {}", ddgHref);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String stripTags(String s) {
|
||||
return s == null ? "" : s.replaceAll("<[^>]+>", "").trim();
|
||||
}
|
||||
|
||||
static boolean matchesPattern(String url, String[] patterns) {
|
||||
if (patterns == null || patterns.length == 0) return true;
|
||||
for (String p : patterns) {
|
||||
if (url.contains(p)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -50,10 +50,80 @@ public class YouTubeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch videos from a YouTube channel, page by page.
|
||||
* Returns all pages merged into one list.
|
||||
* Fetch videos from a YouTube channel using the uploads playlist (UC→UU).
|
||||
* 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) {
|
||||
// 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<>();
|
||||
String nextPage = null;
|
||||
|
||||
@@ -98,7 +168,7 @@ public class YouTubeService {
|
||||
|
||||
nextPage = data.has("nextPageToken") ? data.path("nextPageToken").asText() : null;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to parse YouTube API response", e);
|
||||
log.error("Failed to parse YouTube Search API response", e);
|
||||
break;
|
||||
}
|
||||
} while (nextPage != null);
|
||||
@@ -108,33 +178,39 @@ public class YouTubeService {
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
String ids = String.join(",", videos.stream().map(v -> (String) v.get("video_id")).toList());
|
||||
String response = webClient.get()
|
||||
.uri(uriBuilder -> uriBuilder.path("/videos")
|
||||
.queryParam("key", apiKey)
|
||||
.queryParam("id", ids)
|
||||
.queryParam("part", "contentDetails")
|
||||
.build())
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofSeconds(30));
|
||||
Map<String, Integer> durations = new HashMap<>();
|
||||
List<String> allIds = videos.stream().map(v -> (String) v.get("video_id")).toList();
|
||||
|
||||
try {
|
||||
JsonNode data = mapper.readTree(response);
|
||||
Map<String, Integer> durations = new HashMap<>();
|
||||
for (JsonNode item : data.path("items")) {
|
||||
String duration = item.path("contentDetails").path("duration").asText();
|
||||
durations.put(item.path("id").asText(), parseDuration(duration));
|
||||
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()
|
||||
.uri(uriBuilder -> uriBuilder.path("/videos")
|
||||
.queryParam("key", apiKey)
|
||||
.queryParam("id", ids)
|
||||
.queryParam("part", "contentDetails")
|
||||
.build())
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofSeconds(30));
|
||||
|
||||
JsonNode data = mapper.readTree(response);
|
||||
for (JsonNode item : data.path("items")) {
|
||||
String duration = item.path("contentDetails").path("duration").asText();
|
||||
durations.put(item.path("id").asText(), parseDuration(duration));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to fetch video durations for batch starting at {}", i, e);
|
||||
}
|
||||
return videos.stream()
|
||||
.filter(v -> durations.getOrDefault(v.get("video_id"), 0) > 60)
|
||||
.toList();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to filter shorts", e);
|
||||
return videos;
|
||||
}
|
||||
|
||||
return videos.stream()
|
||||
.filter(v -> durations.getOrDefault(v.get("video_id"), 61) > 60)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private int parseDuration(String dur) {
|
||||
@@ -202,22 +278,33 @@ public class YouTubeService {
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
if (mode == null) mode = "auto";
|
||||
|
||||
// 1) Fast path: youtube-transcript-api
|
||||
TranscriptResult apiResult = getTranscriptApi(videoId, mode);
|
||||
if (apiResult != null) return apiResult;
|
||||
// 1) Playwright headed browser (봇 판정 회피)
|
||||
TranscriptResult browserResult = getTranscriptBrowser(videoId);
|
||||
if (browserResult != null) return browserResult;
|
||||
|
||||
// 2) Fallback: Playwright browser
|
||||
log.warn("API failed for {}, trying Playwright browser", videoId);
|
||||
return getTranscriptBrowser(videoId);
|
||||
// 2) Fallback: youtube-transcript-api
|
||||
log.warn("Browser failed for {}, trying API", videoId);
|
||||
return getTranscriptApi(videoId, mode);
|
||||
}
|
||||
|
||||
private TranscriptResult getTranscriptApi(String videoId, String mode) {
|
||||
public TranscriptResult getTranscriptApi(String videoId, String mode) {
|
||||
TranscriptList transcriptList;
|
||||
try {
|
||||
transcriptList = transcriptApi.listTranscripts(videoId);
|
||||
@@ -262,163 +349,195 @@ 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")
|
||||
private TranscriptResult getTranscriptBrowser(String videoId) {
|
||||
try (Playwright pw = Playwright.create()) {
|
||||
BrowserType.LaunchOptions launchOpts = new BrowserType.LaunchOptions()
|
||||
.setHeadless(false)
|
||||
.setArgs(List.of("--disable-blink-features=AutomationControlled"));
|
||||
|
||||
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})");
|
||||
|
||||
log.info("[TRANSCRIPT] Opening YouTube page for {}", videoId);
|
||||
page.navigate("https://www.youtube.com/watch?v=" + videoId,
|
||||
new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED).setTimeout(30000));
|
||||
page.waitForTimeout(5000);
|
||||
|
||||
// Skip ads if present
|
||||
skipAds(page);
|
||||
|
||||
page.waitForTimeout(2000);
|
||||
log.info("[TRANSCRIPT] Page loaded, looking for transcript button");
|
||||
|
||||
// Click "더보기" (expand description)
|
||||
page.evaluate("""
|
||||
() => {
|
||||
const moreBtn = document.querySelector('tp-yt-paper-button#expand');
|
||||
if (moreBtn) moreBtn.click();
|
||||
}
|
||||
""");
|
||||
page.waitForTimeout(2000);
|
||||
|
||||
// Click transcript button
|
||||
Object clicked = page.evaluate("""
|
||||
() => {
|
||||
// Method 1: aria-label
|
||||
for (const label of ['스크립트 표시', 'Show transcript']) {
|
||||
const btns = document.querySelectorAll(`button[aria-label="${label}"]`);
|
||||
for (const b of btns) { b.click(); return 'aria-label: ' + label; }
|
||||
}
|
||||
// Method 2: text content
|
||||
const allBtns = document.querySelectorAll('button');
|
||||
for (const b of allBtns) {
|
||||
const text = b.textContent.trim();
|
||||
if (text === '스크립트 표시' || text === 'Show transcript') {
|
||||
b.click();
|
||||
return 'text: ' + text;
|
||||
}
|
||||
}
|
||||
// Method 3: engagement panel buttons
|
||||
const engBtns = document.querySelectorAll('ytd-button-renderer button, ytd-button-renderer a');
|
||||
for (const b of engBtns) {
|
||||
const text = b.textContent.trim().toLowerCase();
|
||||
if (text.includes('transcript') || text.includes('스크립트')) {
|
||||
b.click();
|
||||
return 'engagement: ' + text;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
""");
|
||||
log.info("[TRANSCRIPT] Clicked transcript button: {}", clicked);
|
||||
|
||||
if (Boolean.FALSE.equals(clicked)) {
|
||||
Object btnLabels = page.evaluate("""
|
||||
() => {
|
||||
const btns = document.querySelectorAll('button[aria-label]');
|
||||
return Array.from(btns).map(b => b.getAttribute('aria-label')).slice(0, 30);
|
||||
}
|
||||
""");
|
||||
log.warn("[TRANSCRIPT] Transcript button not found. Available buttons: {}", btnLabels);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Wait for transcript segments to appear (max ~40s)
|
||||
page.waitForTimeout(3000);
|
||||
for (int attempt = 0; attempt < 12; attempt++) {
|
||||
page.waitForTimeout(3000);
|
||||
Object count = page.evaluate(
|
||||
"() => document.querySelectorAll('ytd-transcript-segment-renderer').length");
|
||||
int segCount = count instanceof Number n ? n.intValue() : 0;
|
||||
log.info("[TRANSCRIPT] Wait {}s: {} segments", (attempt + 1) * 3 + 3, segCount);
|
||||
if (segCount > 0) break;
|
||||
}
|
||||
|
||||
// Select Korean if available
|
||||
selectKorean(page);
|
||||
|
||||
// Scroll transcript panel and collect segments
|
||||
Object segmentsObj = page.evaluate("""
|
||||
async () => {
|
||||
const container = document.querySelector(
|
||||
'ytd-transcript-segment-list-renderer #segments-container, ' +
|
||||
'ytd-transcript-renderer #body'
|
||||
);
|
||||
if (!container) {
|
||||
const segs = document.querySelectorAll('ytd-transcript-segment-renderer');
|
||||
return Array.from(segs).map(s => {
|
||||
const txt = s.querySelector('.segment-text, yt-formatted-string.segment-text');
|
||||
return txt ? txt.textContent.trim() : '';
|
||||
}).filter(t => t);
|
||||
}
|
||||
|
||||
let prevCount = 0;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
const segs = document.querySelectorAll('ytd-transcript-segment-renderer');
|
||||
if (segs.length === prevCount && i > 3) break;
|
||||
prevCount = segs.length;
|
||||
}
|
||||
|
||||
const segs = document.querySelectorAll('ytd-transcript-segment-renderer');
|
||||
return Array.from(segs).map(s => {
|
||||
const txt = s.querySelector('.segment-text, yt-formatted-string.segment-text');
|
||||
return txt ? txt.textContent.trim() : '';
|
||||
}).filter(t => t);
|
||||
}
|
||||
""");
|
||||
|
||||
if (segmentsObj instanceof List<?> segments && !segments.isEmpty()) {
|
||||
String text = segments.stream()
|
||||
.map(Object::toString)
|
||||
.collect(Collectors.joining(" "));
|
||||
log.info("[TRANSCRIPT] Browser success: {} chars from {} segments", text.length(), segments.size());
|
||||
return new TranscriptResult(text, "browser");
|
||||
}
|
||||
|
||||
log.warn("[TRANSCRIPT] No segments found via browser for {}", videoId);
|
||||
return null;
|
||||
}
|
||||
try (BrowserSession session = createBrowserSession()) {
|
||||
return fetchTranscriptFromPage(session.page(), videoId);
|
||||
} catch (Exception e) {
|
||||
log.error("[TRANSCRIPT] Playwright failed for {}: {}", videoId, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private TranscriptResult fetchTranscriptFromPage(Page page, String videoId) {
|
||||
try {
|
||||
log.info("[TRANSCRIPT] Opening YouTube page for {}", videoId);
|
||||
page.navigate("https://www.youtube.com/watch?v=" + videoId,
|
||||
new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED).setTimeout(30000));
|
||||
page.waitForTimeout(3000);
|
||||
|
||||
skipAds(page);
|
||||
|
||||
page.waitForTimeout(1000);
|
||||
log.info("[TRANSCRIPT] Page loaded, looking for transcript button");
|
||||
|
||||
// Click "더보기" (expand description)
|
||||
page.evaluate("""
|
||||
() => {
|
||||
const moreBtn = document.querySelector('tp-yt-paper-button#expand');
|
||||
if (moreBtn) moreBtn.click();
|
||||
}
|
||||
""");
|
||||
page.waitForTimeout(2000);
|
||||
|
||||
// Click transcript button
|
||||
Object clicked = page.evaluate("""
|
||||
() => {
|
||||
// Method 1: aria-label
|
||||
for (const label of ['스크립트 표시', 'Show transcript']) {
|
||||
const btns = document.querySelectorAll(`button[aria-label="${label}"]`);
|
||||
for (const b of btns) { b.click(); return 'aria-label: ' + label; }
|
||||
}
|
||||
// Method 2: text content
|
||||
const allBtns = document.querySelectorAll('button');
|
||||
for (const b of allBtns) {
|
||||
const text = b.textContent.trim();
|
||||
if (text === '스크립트 표시' || text === 'Show transcript') {
|
||||
b.click();
|
||||
return 'text: ' + text;
|
||||
}
|
||||
}
|
||||
// Method 3: engagement panel buttons
|
||||
const engBtns = document.querySelectorAll('ytd-button-renderer button, ytd-button-renderer a');
|
||||
for (const b of engBtns) {
|
||||
const text = b.textContent.trim().toLowerCase();
|
||||
if (text.includes('transcript') || text.includes('스크립트')) {
|
||||
b.click();
|
||||
return 'engagement: ' + text;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
""");
|
||||
log.info("[TRANSCRIPT] Clicked transcript button: {}", clicked);
|
||||
|
||||
if (Boolean.FALSE.equals(clicked)) {
|
||||
Object btnLabels = page.evaluate("""
|
||||
() => {
|
||||
const btns = document.querySelectorAll('button[aria-label]');
|
||||
return Array.from(btns).map(b => b.getAttribute('aria-label')).slice(0, 30);
|
||||
}
|
||||
""");
|
||||
log.warn("[TRANSCRIPT] Transcript button not found. Available buttons: {}", btnLabels);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Wait for transcript segments to appear (max ~15s)
|
||||
page.waitForTimeout(2000);
|
||||
for (int attempt = 0; attempt < 10; attempt++) {
|
||||
page.waitForTimeout(1500);
|
||||
Object count = page.evaluate(
|
||||
"() => document.querySelectorAll('ytd-transcript-segment-renderer').length");
|
||||
int segCount = count instanceof Number n ? n.intValue() : 0;
|
||||
log.info("[TRANSCRIPT] Wait {}s: {} segments", (attempt + 1) * 1.5 + 2, segCount);
|
||||
if (segCount > 0) break;
|
||||
}
|
||||
|
||||
selectKorean(page);
|
||||
|
||||
// Scroll transcript panel and collect segments
|
||||
Object segmentsObj = page.evaluate("""
|
||||
async () => {
|
||||
const container = document.querySelector(
|
||||
'ytd-transcript-segment-list-renderer #segments-container, ' +
|
||||
'ytd-transcript-renderer #body'
|
||||
);
|
||||
if (!container) {
|
||||
const segs = document.querySelectorAll('ytd-transcript-segment-renderer');
|
||||
return Array.from(segs).map(s => {
|
||||
const txt = s.querySelector('.segment-text, yt-formatted-string.segment-text');
|
||||
return txt ? txt.textContent.trim() : '';
|
||||
}).filter(t => t);
|
||||
}
|
||||
|
||||
let prevCount = 0;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
const segs = document.querySelectorAll('ytd-transcript-segment-renderer');
|
||||
if (segs.length === prevCount && i > 3) break;
|
||||
prevCount = segs.length;
|
||||
}
|
||||
|
||||
const segs = document.querySelectorAll('ytd-transcript-segment-renderer');
|
||||
return Array.from(segs).map(s => {
|
||||
const txt = s.querySelector('.segment-text, yt-formatted-string.segment-text');
|
||||
return txt ? txt.textContent.trim() : '';
|
||||
}).filter(t => t);
|
||||
}
|
||||
""");
|
||||
|
||||
if (segmentsObj instanceof List<?> segments && !segments.isEmpty()) {
|
||||
String text = segments.stream()
|
||||
.map(Object::toString)
|
||||
.collect(Collectors.joining(" "));
|
||||
log.info("[TRANSCRIPT] Browser success: {} chars from {} segments", text.length(), segments.size());
|
||||
return new TranscriptResult(text, "browser");
|
||||
}
|
||||
|
||||
log.warn("[TRANSCRIPT] No segments found via browser for {}", videoId);
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
log.error("[TRANSCRIPT] Page fetch failed for {}: {}", videoId, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void skipAds(Page page) {
|
||||
for (int i = 0; i < 12; i++) {
|
||||
for (int i = 0; i < 30; i++) {
|
||||
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');
|
||||
if (skipBtn) { skipBtn.click(); return 'skipped'; }
|
||||
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');
|
||||
if (adBadge && adBadge.textContent) return 'badge';
|
||||
return 'none';
|
||||
@@ -428,10 +547,10 @@ public class YouTubeService {
|
||||
if ("none".equals(status)) break;
|
||||
log.info("[TRANSCRIPT] Ad detected: {}, waiting...", status);
|
||||
if ("skipped".equals(status)) {
|
||||
page.waitForTimeout(2000);
|
||||
page.waitForTimeout(1000);
|
||||
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
|
||||
|
||||
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:
|
||||
wallet-path: ${ORACLE_WALLET:}
|
||||
@@ -56,9 +56,33 @@ app:
|
||||
youtube-api-key: ${YOUTUBE_DATA_API_KEY}
|
||||
client-id: ${GOOGLE_CLIENT_ID:635551099330-2l003d3ernjmkqavd4f6s78r8r405iml.apps.googleusercontent.com}
|
||||
|
||||
# #357 — Naver Search API (Tabling/Catchtable URL 매칭). 미설정 시 DDG 폴백.
|
||||
naver:
|
||||
client-id: ${NAVER_CLIENT_ID:}
|
||||
client-secret: ${NAVER_CLIENT_SECRET:}
|
||||
|
||||
cache:
|
||||
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:
|
||||
mapper-locations: classpath:mybatis/mapper/*.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);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- #356 영상-식당 관련도 LLM 평가
|
||||
ALTER TABLE video_restaurants ADD (
|
||||
relevance VARCHAR2(16) DEFAULT 'unknown' NOT NULL,
|
||||
relevance_reason VARCHAR2(120),
|
||||
relevance_evaluated_at TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_vr_relevance ON video_restaurants(relevance);
|
||||
@@ -7,17 +7,20 @@
|
||||
<result property="channelId" column="channel_id"/>
|
||||
<result property="channelName" column="channel_name"/>
|
||||
<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="lastVideoAt" column="last_video_at"/>
|
||||
</resultMap>
|
||||
|
||||
<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 MAX(v.published_at) FROM videos v WHERE v.channel_id = c.id) AS last_video_at
|
||||
FROM channels c
|
||||
WHERE c.is_active = 1
|
||||
ORDER BY c.channel_name
|
||||
ORDER BY c.sort_order, c.channel_name
|
||||
</select>
|
||||
|
||||
<insert id="insert">
|
||||
@@ -35,8 +38,14 @@
|
||||
WHERE id = #{id} AND is_active = 1
|
||||
</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, 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
|
||||
WHERE channel_id = #{channelId} AND is_active = 1
|
||||
</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="website" column="website"/>
|
||||
<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="rating" column="rating"/>
|
||||
<result property="ratingCount" column="rating_count"/>
|
||||
<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>
|
||||
|
||||
<!-- ===== Queries ===== -->
|
||||
|
||||
<select id="findAll" resultMap="restaurantMap">
|
||||
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.business_status, r.rating, r.rating_count, r.updated_at
|
||||
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.hidden, r.hidden_reason, r.verified_at
|
||||
FROM restaurants r
|
||||
<if test="channel != null and channel != ''">
|
||||
JOIN video_restaurants vr_f ON vr_f.restaurant_id = r.id
|
||||
@@ -37,6 +43,9 @@
|
||||
<where>
|
||||
r.latitude IS NOT NULL
|
||||
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 != ''">
|
||||
AND r.cuisine_type = #{cuisine}
|
||||
</if>
|
||||
@@ -54,20 +63,26 @@
|
||||
<select id="findById" resultMap="restaurantMap">
|
||||
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.business_status, r.rating, r.rating_count
|
||||
r.tabling_url, r.catchtable_url, r.business_status, r.rating, r.rating_count
|
||||
FROM restaurants r
|
||||
WHERE r.id = #{id}
|
||||
</select>
|
||||
|
||||
<select id="findVideoLinks" resultType="map">
|
||||
SELECT v.video_id, v.title, v.url,
|
||||
<!-- #356 — relevance 컬럼 SELECT + includeWeak 가드 -->
|
||||
SELECT vr.id AS link_id,
|
||||
v.video_id, v.title, v.url,
|
||||
TO_CHAR(v.published_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS published_at,
|
||||
vr.foods_mentioned, vr.evaluation, vr.guests,
|
||||
vr.relevance, vr.relevance_reason,
|
||||
c.channel_name, c.channel_id
|
||||
FROM video_restaurants vr
|
||||
JOIN videos v ON v.id = vr.video_id
|
||||
JOIN channels c ON c.id = v.channel_id
|
||||
WHERE vr.restaurant_id = #{restaurantId}
|
||||
<if test="includeWeak == null or !includeWeak">
|
||||
AND vr.relevance IN ('strong', 'unknown')
|
||||
</if>
|
||||
ORDER BY v.published_at DESC
|
||||
</select>
|
||||
|
||||
@@ -129,12 +144,30 @@
|
||||
<if test="fields.containsKey('website')">
|
||||
website = #{fields.website},
|
||||
</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')">
|
||||
latitude = #{fields.latitude},
|
||||
</if>
|
||||
<if test="fields.containsKey('longitude')">
|
||||
longitude = #{fields.longitude},
|
||||
</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,
|
||||
</trim>
|
||||
WHERE id = #{id}
|
||||
@@ -201,6 +234,32 @@
|
||||
</foreach>
|
||||
</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 ===== -->
|
||||
|
||||
<update id="updateCuisineType">
|
||||
@@ -231,4 +290,84 @@
|
||||
ORDER BY r.name
|
||||
</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>
|
||||
|
||||
<!-- ===== #356 영상-식당 관련도 ===== -->
|
||||
|
||||
<update id="updateLinkRelevance">
|
||||
UPDATE video_restaurants
|
||||
SET relevance = #{relevance},
|
||||
relevance_reason = #{reason,jdbcType=VARCHAR},
|
||||
relevance_evaluated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = #{linkId}
|
||||
</update>
|
||||
|
||||
<select id="findLinkContext" resultType="map">
|
||||
<!-- LLM 평가에 필요한 정보 -->
|
||||
SELECT vr.id AS link_id, vr.foods_mentioned, vr.evaluation,
|
||||
r.id AS restaurant_id, r.name AS restaurant_name, r.address, r.cuisine_type,
|
||||
v.title AS video_title, c.channel_name
|
||||
FROM video_restaurants vr
|
||||
JOIN restaurants r ON r.id = vr.restaurant_id
|
||||
JOIN videos v ON v.id = vr.video_id
|
||||
JOIN channels c ON c.id = v.channel_id
|
||||
WHERE vr.id = #{linkId}
|
||||
</select>
|
||||
|
||||
<select id="findUnevaluatedLinks" resultType="map">
|
||||
SELECT id AS link_id FROM video_restaurants
|
||||
WHERE relevance_evaluated_at IS NULL
|
||||
FETCH FIRST #{limit} ROWS ONLY
|
||||
</select>
|
||||
|
||||
<select id="countUnevaluatedLinks" resultType="int">
|
||||
SELECT COUNT(*) FROM video_restaurants WHERE relevance_evaluated_at IS NULL
|
||||
</select>
|
||||
|
||||
<!-- #359 1단계 — google_place_id 중복 조회 (그룹 식당 + 참조 카운트) -->
|
||||
<select id="findDuplicatePlaceIdRows" resultType="map">
|
||||
SELECT r.id, r.google_place_id, r.name, r.address,
|
||||
TO_CHAR(r.created_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS created_at,
|
||||
r.hidden,
|
||||
(SELECT COUNT(*) FROM video_restaurants vr WHERE vr.restaurant_id = r.id) AS video_count,
|
||||
(SELECT COUNT(*) FROM user_reviews rv WHERE rv.restaurant_id = r.id) AS review_count,
|
||||
(SELECT COUNT(*) FROM user_memos mm WHERE mm.restaurant_id = r.id) AS memo_count
|
||||
FROM restaurants r
|
||||
WHERE r.google_place_id IN (
|
||||
SELECT google_place_id FROM restaurants
|
||||
WHERE google_place_id IS NOT NULL
|
||||
GROUP BY google_place_id HAVING COUNT(*) > 1
|
||||
)
|
||||
ORDER BY r.google_place_id, r.created_at
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -79,7 +79,8 @@
|
||||
</select>
|
||||
|
||||
<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
|
||||
WHERE restaurant_id = #{restaurantId}
|
||||
</select>
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
<result property="longitude" column="longitude"/>
|
||||
<result property="cuisineType" column="cuisine_type"/>
|
||||
<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="tablingUrl" column="tabling_url"/>
|
||||
<result property="catchtableUrl" column="catchtable_url"/>
|
||||
<result property="businessStatus" column="business_status"/>
|
||||
<result property="rating" column="rating"/>
|
||||
<result property="ratingCount" column="rating_count"/>
|
||||
@@ -19,18 +23,20 @@
|
||||
|
||||
<select id="keywordSearch" resultMap="restaurantMap">
|
||||
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
|
||||
FROM restaurants r
|
||||
JOIN video_restaurants vr ON vr.restaurant_id = r.id
|
||||
JOIN videos v ON v.id = vr.video_id
|
||||
WHERE r.latitude IS NOT NULL
|
||||
AND (UPPER(r.name) LIKE UPPER(#{query})
|
||||
OR UPPER(r.address) LIKE UPPER(#{query})
|
||||
OR UPPER(r.region) LIKE UPPER(#{query})
|
||||
OR UPPER(r.cuisine_type) LIKE UPPER(#{query})
|
||||
OR UPPER(vr.foods_mentioned) LIKE UPPER(#{query})
|
||||
OR UPPER(v.title) LIKE UPPER(#{query}))
|
||||
<!-- #293 — ESCAPE 절로 사용자 입력의 %, _ 와일드카드 의도 우회 차단 -->
|
||||
AND (UPPER(r.name) LIKE UPPER(#{query}) ESCAPE '\'
|
||||
OR UPPER(r.address) LIKE UPPER(#{query}) ESCAPE '\'
|
||||
OR UPPER(r.region) LIKE UPPER(#{query}) ESCAPE '\'
|
||||
OR UPPER(r.cuisine_type) LIKE UPPER(#{query}) ESCAPE '\'
|
||||
OR UPPER(vr.foods_mentioned) LIKE UPPER(#{query}) ESCAPE '\'
|
||||
OR UPPER(v.title) LIKE UPPER(#{query}) ESCAPE '\')
|
||||
FETCH FIRST #{limit} ROWS ONLY
|
||||
</select>
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
WHEN NOT MATCHED THEN INSERT (visit_date, visit_count) VALUES (src.d, 1)
|
||||
</update>
|
||||
|
||||
<select id="getTodayVisits" resultType="int">
|
||||
<select id="getTodayVisits" resultType="long">
|
||||
SELECT NVL(visit_count, 0)
|
||||
FROM site_visits
|
||||
WHERE visit_date = TRUNC(SYSDATE)
|
||||
</select>
|
||||
|
||||
<select id="getTotalVisits" resultType="int">
|
||||
<select id="getTotalVisits" resultType="long">
|
||||
SELECT NVL(SUM(visit_count), 0)
|
||||
FROM site_visits
|
||||
</select>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<result property="createdAt" column="created_at"/>
|
||||
<result property="favoriteCount" column="favorite_count"/>
|
||||
<result property="reviewCount" column="review_count"/>
|
||||
<result property="memoCount" column="memo_count"/>
|
||||
</resultMap>
|
||||
|
||||
<select id="findByProviderAndProviderId" resultMap="userResultMap">
|
||||
@@ -38,10 +39,12 @@
|
||||
<select id="findAllWithCounts" resultMap="userResultMap">
|
||||
SELECT u.id, u.email, u.nickname, u.avatar_url, u.provider, u.created_at,
|
||||
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
|
||||
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_memos GROUP BY user_id) memo ON memo.user_id = u.id
|
||||
ORDER BY u.created_at DESC
|
||||
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
|
||||
</select>
|
||||
|
||||
@@ -186,7 +186,8 @@
|
||||
|
||||
<insert id="insertVideo">
|
||||
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>
|
||||
|
||||
<select id="getExistingVideoIds" resultType="string">
|
||||
@@ -194,7 +195,7 @@
|
||||
</select>
|
||||
|
||||
<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}
|
||||
</select>
|
||||
|
||||
@@ -220,10 +221,30 @@
|
||||
SELECT id, video_id, title, url
|
||||
FROM videos
|
||||
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
|
||||
</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 video_restaurants
|
||||
SET foods_mentioned = #{foodsJson,jdbcType=CLOB},
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
<setting name="mapUnderscoreToCamelCase" value="true"/>
|
||||
<setting name="callSettersOnNulls" value="true"/>
|
||||
<setting name="returnInstanceForEmptyRow" value="true"/>
|
||||
<setting name="jdbcTypeForNull" value="VARCHAR"/>
|
||||
</settings>
|
||||
</configuration>
|
||||
|
||||
@@ -62,15 +62,18 @@ if [[ "$TARGET" == "all" || "$TARGET" == "frontend" ]]; then
|
||||
# Read build args from env or .env file
|
||||
MAPS_KEY="${NEXT_PUBLIC_GOOGLE_MAPS_API_KEY:-}"
|
||||
CLIENT_ID="${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}"
|
||||
NAVER_MAP_ID="${NEXT_PUBLIC_NAVER_MAP_CLIENT_ID:-}"
|
||||
|
||||
if [[ -f frontend/.env.local ]]; then
|
||||
MAPS_KEY="${MAPS_KEY:-$(grep NEXT_PUBLIC_GOOGLE_MAPS_API_KEY frontend/.env.local 2>/dev/null | cut -d= -f2)}"
|
||||
CLIENT_ID="${CLIENT_ID:-$(grep NEXT_PUBLIC_GOOGLE_CLIENT_ID frontend/.env.local 2>/dev/null | cut -d= -f2)}"
|
||||
NAVER_MAP_ID="${NAVER_MAP_ID:-$(grep NEXT_PUBLIC_NAVER_MAP_CLIENT_ID frontend/.env.local 2>/dev/null | cut -d= -f2)}"
|
||||
fi
|
||||
|
||||
docker build --platform "$PLATFORM" \
|
||||
--build-arg NEXT_PUBLIC_GOOGLE_MAPS_API_KEY="$MAPS_KEY" \
|
||||
--build-arg NEXT_PUBLIC_GOOGLE_CLIENT_ID="$CLIENT_ID" \
|
||||
--build-arg NEXT_PUBLIC_NAVER_MAP_CLIENT_ID="$NAVER_MAP_ID" \
|
||||
-t "$REGISTRY/frontend:$TAG" \
|
||||
-t "$REGISTRY/frontend:latest" \
|
||||
frontend/
|
||||
|
||||
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 전환) 시점.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user