Compare commits
83 Commits
f54da90b5f
...
v0.1.31
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
0ad09e5b67 | ||
|
|
7a896c8c56 | ||
|
|
745913ca5b | ||
|
|
293c59060c | ||
|
|
ff4e8d742d | ||
|
|
69e1882c2b | ||
|
|
16bd83c570 | ||
|
|
c16add08c3 | ||
|
|
91d0ad4598 | ||
|
|
a844fd44cc | ||
|
|
6d05be2331 | ||
|
|
161b1383be | ||
|
|
d39b3b8fea | ||
|
|
b3923dcc72 | ||
|
|
6223691b33 | ||
|
|
99660bf07b | ||
|
|
d1ef156f44 | ||
|
|
4a1c8cf1cd | ||
|
|
2cd72d660a | ||
|
|
17489ad9b0 | ||
|
|
08ea282baf | ||
|
|
237c982e6c | ||
|
|
758d87842b | ||
|
|
d4d516a375 | ||
|
|
54d21afd52 | ||
|
|
a5b3598f8a |
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
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -2,7 +2,21 @@ __pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.env
|
||||
.ch-backup/
|
||||
node_modules/
|
||||
.next/
|
||||
.env.local
|
||||
*.log
|
||||
|
||||
# Java backend
|
||||
backend-java/build/
|
||||
backend-java/.gradle/
|
||||
|
||||
# K8s secrets (never commit)
|
||||
k8s/secrets.yaml
|
||||
|
||||
# OS / misc
|
||||
.DS_Store
|
||||
backend/cookies.txt
|
||||
backend-java/cookies.txt
|
||||
**/cookies.txt
|
||||
|
||||
266
CHANGELOG.md
Normal file
266
CHANGELOG.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# Tasteby 작업 기록
|
||||
|
||||
> 작업 내용, 이슈, 해결 방법을 기록하는 문서. 커밋/배포 시 참고용.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-15
|
||||
|
||||
### 🔒 #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` 에서 로드.
|
||||
4
backend-java/.dockerignore
Normal file
4
backend-java/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
build/
|
||||
.gradle/
|
||||
.idea/
|
||||
*.iml
|
||||
16
backend-java/Dockerfile
Normal file
16
backend-java/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
# ── Build stage ──
|
||||
FROM eclipse-temurin:21-jdk AS build
|
||||
WORKDIR /app
|
||||
COPY gradlew settings.gradle build.gradle ./
|
||||
COPY gradle/ gradle/
|
||||
RUN chmod +x gradlew && ./gradlew dependencies --no-daemon || true
|
||||
COPY src/ src/
|
||||
RUN ./gradlew bootJar -x test --no-daemon
|
||||
|
||||
# ── Runtime stage ──
|
||||
FROM eclipse-temurin:21-jre
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/build/libs/*.jar app.jar
|
||||
EXPOSE 8000
|
||||
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC"
|
||||
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
|
||||
77
backend-java/build.gradle
Normal file
77
backend-java/build.gradle
Normal file
@@ -0,0 +1,77 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'org.springframework.boot' version '3.3.5'
|
||||
id 'io.spring.dependency-management' version '1.1.6'
|
||||
}
|
||||
|
||||
group = 'com.tasteby'
|
||||
version = '0.1.0'
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Spring Boot
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
|
||||
// MyBatis
|
||||
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.4'
|
||||
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'
|
||||
|
||||
// JWT
|
||||
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
|
||||
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
|
||||
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
|
||||
|
||||
// Google OAuth2 token verification
|
||||
implementation 'com.google.api-client:google-api-client:2.7.0'
|
||||
|
||||
// OCI SDK (GenAI for LLM + Embeddings)
|
||||
implementation 'com.oracle.oci.sdk:oci-java-sdk-generativeaiinference:3.49.0'
|
||||
implementation 'com.oracle.oci.sdk:oci-java-sdk-common:3.49.0'
|
||||
implementation 'com.oracle.oci.sdk:oci-java-sdk-common-httpclient-jersey3:3.49.0'
|
||||
|
||||
// Jackson for JSON
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind'
|
||||
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
|
||||
|
||||
// YouTube Transcript API (Java port of youtube-transcript-api)
|
||||
implementation 'io.github.thoroldvix:youtube-transcript-api:0.4.0'
|
||||
|
||||
// Playwright (browser-based transcript fallback)
|
||||
implementation 'com.microsoft.playwright:playwright:1.49.0'
|
||||
|
||||
// HTTP client for YouTube/Google APIs
|
||||
implementation 'org.springframework.boot:spring-boot-starter-webflux'
|
||||
|
||||
// Lombok
|
||||
compileOnly 'org.projectlombok:lombok'
|
||||
annotationProcessor 'org.projectlombok:lombok'
|
||||
|
||||
// Test
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
BIN
backend-java/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
backend-java/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
backend-java/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
backend-java/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
248
backend-java/gradlew
vendored
Executable file
248
backend-java/gradlew
vendored
Executable file
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
93
backend-java/gradlew.bat
vendored
Normal file
93
backend-java/gradlew.bat
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
1
backend-java/settings.gradle
Normal file
1
backend-java/settings.gradle
Normal file
@@ -0,0 +1 @@
|
||||
rootProject.name = 'tasteby-api'
|
||||
@@ -0,0 +1,18 @@
|
||||
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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.tasteby.config;
|
||||
|
||||
import org.apache.ibatis.type.BaseTypeHandler;
|
||||
import org.apache.ibatis.type.JdbcType;
|
||||
import org.apache.ibatis.type.MappedJdbcTypes;
|
||||
import org.apache.ibatis.type.MappedTypes;
|
||||
|
||||
import java.io.Reader;
|
||||
import java.sql.*;
|
||||
|
||||
@MappedTypes(String.class)
|
||||
@MappedJdbcTypes(JdbcType.CLOB)
|
||||
public class ClobTypeHandler extends BaseTypeHandler<String> {
|
||||
|
||||
@Override
|
||||
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType)
|
||||
throws SQLException {
|
||||
ps.setString(i, parameter);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
|
||||
return clobToString(rs.getClob(columnName));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
|
||||
return clobToString(rs.getClob(columnIndex));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
|
||||
return clobToString(cs.getClob(columnIndex));
|
||||
}
|
||||
|
||||
private String clobToString(Clob clob) {
|
||||
if (clob == null) return null;
|
||||
try (Reader reader = clob.getCharacterStream()) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
char[] buf = new char[4096];
|
||||
int len;
|
||||
while ((len = reader.read(buf)) != -1) {
|
||||
sb.append(buf, 0, len);
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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()) {
|
||||
System.setProperty("oracle.net.tns_admin", walletPath);
|
||||
System.setProperty("oracle.net.wallet_location", walletPath);
|
||||
}
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void runMigrations() {
|
||||
migrate("ALTER TABLE restaurants ADD (tabling_url VARCHAR2(500))");
|
||||
migrate("ALTER TABLE restaurants ADD (catchtable_url VARCHAR2(500))");
|
||||
}
|
||||
|
||||
private void migrate(String sql) {
|
||||
try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) {
|
||||
stmt.execute(sql);
|
||||
log.info("[MIGRATE] {}", sql);
|
||||
} catch (Exception e) {
|
||||
if (e.getMessage() != null && e.getMessage().contains("ORA-01430")) {
|
||||
log.debug("[MIGRATE] already done: {}", sql);
|
||||
} else {
|
||||
log.warn("[MIGRATE] failed: {} - {}", sql, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.tasteby.config;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
@ExceptionHandler(ResponseStatusException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleStatus(ResponseStatusException ex) {
|
||||
return ResponseEntity.status(ex.getStatusCode())
|
||||
.body(Map.of("detail", ex.getReason() != null ? ex.getReason() : "Error"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<Map<String, Object>> handleGeneral(Exception ex) {
|
||||
log.error("Unhandled exception", ex);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("detail", "Internal server error"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.tasteby.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
@Configuration
|
||||
public class RedisConfig {
|
||||
|
||||
@Bean
|
||||
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
|
||||
return new StringRedisTemplate(connectionFactory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.tasteby.config;
|
||||
|
||||
import com.tasteby.security.JwtAuthenticationFilter;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthenticationFilter jwtFilter;
|
||||
|
||||
public SecurityConfig(JwtAuthenticationFilter jwtFilter) {
|
||||
this.jwtFilter = jwtFilter;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.cors(cors -> {})
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.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()
|
||||
// #275 — /api/daemon/config는 admin-only로 변경 (이전 permitAll 제거)
|
||||
// Everything else requires authentication (controller-level admin checks)
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
32
backend-java/src/main/java/com/tasteby/config/WebConfig.java
Normal file
32
backend-java/src/main/java/com/tasteby/config/WebConfig.java
Normal file
@@ -0,0 +1,32 @@
|
||||
package com.tasteby.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
@Value("${app.cors.allowed-origins}")
|
||||
private String allowedOrigins;
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("*"));
|
||||
config.setAllowCredentials(true);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/api/**", config);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.CacheService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
public class AdminCacheController {
|
||||
|
||||
private final CacheService cacheService;
|
||||
|
||||
public AdminCacheController(CacheService cacheService) {
|
||||
this.cacheService = cacheService;
|
||||
}
|
||||
|
||||
@PostMapping("/cache-flush")
|
||||
public Map<String, Object> flushCache() {
|
||||
AuthUtil.requireAdmin();
|
||||
cacheService.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
/**
|
||||
* #336 — 캐시 상태 가시화: disabled / errorCount / lastError.
|
||||
* 외부 모니터링 도구 도입 전 운영자가 어드민에서 확인 가능.
|
||||
*/
|
||||
@GetMapping("/cache/stats")
|
||||
public CacheService.CacheStats cacheStats() {
|
||||
AuthUtil.requireAdmin();
|
||||
return cacheService.getStats();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.RestaurantService;
|
||||
import com.tasteby.service.RestaurantVerifyService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* #322 LLM 검증 어드민 API.
|
||||
* - hidden 토글
|
||||
* - 일괄 백필
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/restaurants")
|
||||
public class AdminRestaurantController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AdminRestaurantController.class);
|
||||
|
||||
private final RestaurantService restaurantService;
|
||||
private final RestaurantVerifyService verifyService;
|
||||
|
||||
public AdminRestaurantController(RestaurantService restaurantService, RestaurantVerifyService verifyService) {
|
||||
this.restaurantService = restaurantService;
|
||||
this.verifyService = verifyService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 어드민용 검증 안 된 식당 수 조회.
|
||||
*/
|
||||
@GetMapping("/verify/pending")
|
||||
public Map<String, Object> pendingCount() {
|
||||
var admin = AuthUtil.requireAdmin();
|
||||
int n = restaurantService.countUnverified();
|
||||
log.info("[ADMIN] {} pending verify count: {}", admin.getSubject(), n);
|
||||
return Map.of("pending", n);
|
||||
}
|
||||
|
||||
/**
|
||||
* 어드민용 일괄 백필 트리거. 한 번 호출에 모든 미검증 식당을 처리.
|
||||
* 비동기/SSE 없이 동기 응답이라 호출자는 결과까지 기다려야 함(LLM × N).
|
||||
*/
|
||||
@PostMapping("/verify/all")
|
||||
public Map<String, Object> verifyAll(@RequestParam(defaultValue = "10") int batchSize) {
|
||||
var admin = AuthUtil.requireAdmin();
|
||||
log.info("[ADMIN] {} triggered verifyAll(batchSize={})", admin.getSubject(), batchSize);
|
||||
int processed = verifyService.verifyAll(batchSize);
|
||||
return Map.of("processed", processed);
|
||||
}
|
||||
|
||||
/**
|
||||
* 어드민용 단건 재검증.
|
||||
*/
|
||||
@PostMapping("/{id}/verify")
|
||||
public Map<String, Object> verifyOne(@PathVariable String id) {
|
||||
var admin = AuthUtil.requireAdmin();
|
||||
log.info("[ADMIN] {} verifyOne({})", admin.getSubject(), id);
|
||||
verifyService.verify(id);
|
||||
return Map.of("success", true, "id", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 어드민용 hidden 토글.
|
||||
*/
|
||||
@PatchMapping("/{id}/hidden")
|
||||
public Map<String, Object> setHidden(@PathVariable String id, @RequestBody Map<String, Object> body) {
|
||||
var admin = AuthUtil.requireAdmin();
|
||||
boolean hidden = Boolean.TRUE.equals(body.get("hidden"));
|
||||
String reason = body.get("reason") instanceof String s ? s : "manual";
|
||||
if (hidden) {
|
||||
restaurantService.markHidden(id, reason);
|
||||
} else {
|
||||
restaurantService.clearHidden(id);
|
||||
}
|
||||
log.info("[ADMIN] {} set hidden={} for {}", admin.getSubject(), hidden, id);
|
||||
return Map.of("success", true, "id", id, "hidden", hidden);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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;
|
||||
|
||||
@RestController
|
||||
@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, 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);
|
||||
}
|
||||
|
||||
@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,31 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.domain.UserInfo;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.AuthService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
public AuthController(AuthService authService) {
|
||||
this.authService = authService;
|
||||
}
|
||||
|
||||
@PostMapping("/google")
|
||||
public Map<String, Object> loginGoogle(@RequestBody Map<String, String> body) {
|
||||
String idToken = body.get("id_token");
|
||||
return authService.loginGoogle(idToken);
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public UserInfo me() {
|
||||
String userId = AuthUtil.getUserId();
|
||||
return authService.getCurrentUser(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/channels")
|
||||
public class ChannelController {
|
||||
|
||||
private final ChannelService channelService;
|
||||
private final YouTubeService youtubeService;
|
||||
private final CacheService cache;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ChannelController(ChannelService channelService, YouTubeService youtubeService,
|
||||
CacheService cache, ObjectMapper objectMapper) {
|
||||
this.channelService = channelService;
|
||||
this.youtubeService = youtubeService;
|
||||
this.cache = cache;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Channel> list() {
|
||||
String key = cache.makeKey("channels");
|
||||
String cached = cache.getRaw(key);
|
||||
if (cached != null) {
|
||||
try {
|
||||
return objectMapper.readValue(cached, new TypeReference<List<Channel>>() {});
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
var result = channelService.findAllActive();
|
||||
cache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public Map<String, Object> create(@RequestBody Map<String, String> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
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);
|
||||
// #333 — 전체 flush 대신 channels 키만 evict (다른 모듈 캐시 보존)
|
||||
cache.del(cache.makeKey("channels"));
|
||||
return Map.of("id", id, "channel_id", channelId);
|
||||
} 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();
|
||||
if (!channelService.deactivate(channelId)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Channel not found");
|
||||
}
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.domain.DaemonConfig;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.DaemonConfigService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/daemon")
|
||||
public class DaemonController {
|
||||
|
||||
private final DaemonConfigService daemonConfigService;
|
||||
|
||||
public DaemonController(DaemonConfigService daemonConfigService) {
|
||||
this.daemonConfigService = daemonConfigService;
|
||||
}
|
||||
|
||||
@GetMapping("/config")
|
||||
public DaemonConfig getConfig() {
|
||||
// #275 — 데몬 운영 설정은 admin 전용 (이전: 공개 노출 — 정보 누출 위험)
|
||||
AuthUtil.requireAdmin();
|
||||
DaemonConfig config = daemonConfigService.getConfig();
|
||||
return config != null ? config : DaemonConfig.builder().build();
|
||||
}
|
||||
|
||||
@PutMapping("/config")
|
||||
public Map<String, Object> updateConfig(@RequestBody Map<String, Object> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
daemonConfigService.updateConfig(body);
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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;
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
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.service.CacheService;
|
||||
import com.tasteby.service.GeocodingService;
|
||||
import com.tasteby.service.RestaurantService;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
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.util.*;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@RestController
|
||||
@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 ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
public RestaurantController(RestaurantService restaurantService, GeocodingService geocodingService, CacheService cache, ObjectMapper objectMapper) {
|
||||
this.restaurantService = restaurantService;
|
||||
this.geocodingService = geocodingService;
|
||||
this.cache = cache;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
// #290 — Bean 종료 시 virtual thread executor를 정리하여 리소스 누수 방지.
|
||||
@PreDestroy
|
||||
public void shutdownExecutor() {
|
||||
executor.shutdown();
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Restaurant> list(
|
||||
@RequestParam(defaultValue = "100") int limit,
|
||||
@RequestParam(defaultValue = "0") int offset,
|
||||
@RequestParam(required = false) String cuisine,
|
||||
@RequestParam(required = false) String region,
|
||||
@RequestParam(required = false) String channel) {
|
||||
if (limit > 500) limit = 500;
|
||||
String key = cache.makeKey("restaurants", "l=" + limit, "o=" + offset,
|
||||
"c=" + cuisine, "r=" + region, "ch=" + channel);
|
||||
String cached = cache.getRaw(key);
|
||||
if (cached != null) {
|
||||
try {
|
||||
return objectMapper.readValue(cached, new TypeReference<List<Restaurant>>() {});
|
||||
} 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);
|
||||
return result;
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public Restaurant get(@PathVariable String id) {
|
||||
String key = cache.makeKey("restaurant", id);
|
||||
String cached = cache.getRaw(key);
|
||||
if (cached != null) {
|
||||
try {
|
||||
return objectMapper.readValue(cached, Restaurant.class);
|
||||
} 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");
|
||||
cache.set(key, r);
|
||||
return r;
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||
|
||||
// Re-geocode if name or address changed
|
||||
String newName = (String) body.get("name");
|
||||
String newAddress = (String) body.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) {
|
||||
body.put("latitude", geo.get("latitude"));
|
||||
body.put("longitude", geo.get("longitude"));
|
||||
body.put("google_place_id", geo.get("google_place_id"));
|
||||
if (geo.containsKey("formatted_address")) {
|
||||
body.put("address", geo.get("formatted_address"));
|
||||
}
|
||||
if (geo.containsKey("rating")) body.put("rating", geo.get("rating"));
|
||||
if (geo.containsKey("rating_count")) body.put("rating_count", geo.get("rating_count"));
|
||||
if (geo.containsKey("phone")) body.put("phone", geo.get("phone"));
|
||||
if (geo.containsKey("business_status")) body.put("business_status", geo.get("business_status"));
|
||||
|
||||
// formatted_address에서 region 파싱 (예: "대한민국 서울특별시 강남구 ..." → "한국|서울|강남구")
|
||||
String addr = (String) geo.get("formatted_address");
|
||||
if (addr != null) {
|
||||
body.put("region", GeocodingService.parseRegionFromAddress(addr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
restaurantService.update(id, body);
|
||||
cache.flush();
|
||||
var updated = restaurantService.findById(id);
|
||||
return Map.of("ok", true, "restaurant", updated);
|
||||
}
|
||||
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public Map<String, Object> delete(@PathVariable String id) {
|
||||
AuthUtil.requireAdmin();
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||
restaurantService.delete(id);
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
/** 단건 테이블링 URL 검색 */
|
||||
@GetMapping("/{id}/tabling-search")
|
||||
public List<Map<String, Object>> tablingSearch(@PathVariable String id) {
|
||||
AuthUtil.requireAdmin();
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
|
||||
try {
|
||||
return searchTabling(r.getName());
|
||||
} catch (Exception e) {
|
||||
log.error("[TABLING] Search failed for '{}': {}", r.getName(), e.getMessage());
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 테이블링 미연결 식당 목록 */
|
||||
@GetMapping("/tabling-pending")
|
||||
public Map<String, Object> tablingPending() {
|
||||
AuthUtil.requireAdmin();
|
||||
var list = restaurantService.findWithoutTabling();
|
||||
var summary = list.stream()
|
||||
.map(r -> Map.of("id", (Object) r.getId(), "name", (Object) r.getName()))
|
||||
.toList();
|
||||
return Map.of("count", list.size(), "restaurants", summary);
|
||||
}
|
||||
|
||||
/** 벌크 테이블링 검색 (SSE) */
|
||||
@PostMapping("/bulk-tabling")
|
||||
public SseEmitter bulkTabling() {
|
||||
AuthUtil.requireAdmin();
|
||||
SseEmitter emitter = new SseEmitter(600_000L);
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
var restaurants = restaurantService.findWithoutTabling();
|
||||
int total = restaurants.size();
|
||||
emit(emitter, Map.of("type", "start", "total", total));
|
||||
|
||||
if (total == 0) {
|
||||
emit(emitter, Map.of("type", "complete", "total", 0, "linked", 0, "notFound", 0));
|
||||
emitter.complete();
|
||||
return;
|
||||
}
|
||||
|
||||
int linked = 0;
|
||||
int notFound = 0;
|
||||
|
||||
for (int i = 0; i < total; i++) {
|
||||
var r = restaurants.get(i);
|
||||
emit(emitter, Map.of("type", "processing", "current", i + 1,
|
||||
"total", total, "name", r.getName()));
|
||||
|
||||
try {
|
||||
var results = searchTabling(r.getName());
|
||||
if (!results.isEmpty()) {
|
||||
String url = String.valueOf(results.get(0).get("url"));
|
||||
String title = String.valueOf(results.get(0).get("title"));
|
||||
if (isNameSimilar(r.getName(), title)) {
|
||||
restaurantService.update(r.getId(), Map.of("tabling_url", url));
|
||||
linked++;
|
||||
emit(emitter, Map.of("type", "done", "current", i + 1,
|
||||
"name", r.getName(), "url", url, "title", title));
|
||||
} else {
|
||||
restaurantService.update(r.getId(), Map.of("tabling_url", "NONE"));
|
||||
notFound++;
|
||||
log.info("[TABLING] Name mismatch: '{}' vs '{}', skipping", r.getName(), title);
|
||||
emit(emitter, Map.of("type", "notfound", "current", i + 1,
|
||||
"name", r.getName(), "reason", "이름 불일치: " + title));
|
||||
}
|
||||
} else {
|
||||
restaurantService.update(r.getId(), Map.of("tabling_url", "NONE"));
|
||||
notFound++;
|
||||
emit(emitter, Map.of("type", "notfound", "current", i + 1,
|
||||
"name", r.getName()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
notFound++;
|
||||
emit(emitter, Map.of("type", "error", "current", i + 1,
|
||||
"name", r.getName(), "message", e.getMessage()));
|
||||
}
|
||||
|
||||
// 랜덤 딜레이 (2~5초)
|
||||
int delay = ThreadLocalRandom.current().nextInt(2000, 5001);
|
||||
log.info("[TABLING] Waiting {}ms before next search...", delay);
|
||||
Thread.sleep(delay);
|
||||
}
|
||||
|
||||
cache.flush();
|
||||
emit(emitter, Map.of("type", "complete", "total", total, "linked", linked, "notFound", notFound));
|
||||
emitter.complete();
|
||||
} catch (Exception e) {
|
||||
log.error("[TABLING] Bulk search error", e);
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
});
|
||||
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/** 테이블링 URL 저장 */
|
||||
@PutMapping("/{id}/tabling-url")
|
||||
public Map<String, Object> setTablingUrl(@PathVariable String id, @RequestBody Map<String, String> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
String url = body.get("tabling_url");
|
||||
// #290 — javascript:/외부 악성 URL 차단. 빈 문자열은 매핑 해제로 허용.
|
||||
if (url != null && !url.isBlank() && !url.startsWith("https://tabling.co.kr/")) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "테이블링 URL은 https://tabling.co.kr/ 만 허용");
|
||||
}
|
||||
restaurantService.update(id, Map.of("tabling_url", url != null ? url : ""));
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
/** 테이블링/캐치테이블 매핑 초기화 */
|
||||
@DeleteMapping("/reset-tabling")
|
||||
public Map<String, Object> resetTabling() {
|
||||
AuthUtil.requireAdmin();
|
||||
restaurantService.resetTablingUrls();
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
@DeleteMapping("/reset-catchtable")
|
||||
public Map<String, Object> resetCatchtable() {
|
||||
AuthUtil.requireAdmin();
|
||||
restaurantService.resetCatchtableUrls();
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
/** 단건 캐치테이블 URL 검색 */
|
||||
@GetMapping("/{id}/catchtable-search")
|
||||
public List<Map<String, Object>> catchtableSearch(@PathVariable String id) {
|
||||
AuthUtil.requireAdmin();
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
try {
|
||||
return searchCatchtable(r.getName());
|
||||
} catch (Exception e) {
|
||||
log.error("[CATCHTABLE] Search failed for '{}': {}", r.getName(), e.getMessage());
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 캐치테이블 미연결 식당 목록 */
|
||||
@GetMapping("/catchtable-pending")
|
||||
public Map<String, Object> catchtablePending() {
|
||||
AuthUtil.requireAdmin();
|
||||
var list = restaurantService.findWithoutCatchtable();
|
||||
var summary = list.stream()
|
||||
.map(r -> Map.of("id", (Object) r.getId(), "name", (Object) r.getName()))
|
||||
.toList();
|
||||
return Map.of("count", list.size(), "restaurants", summary);
|
||||
}
|
||||
|
||||
/** 벌크 캐치테이블 검색 (SSE) */
|
||||
@PostMapping("/bulk-catchtable")
|
||||
public SseEmitter bulkCatchtable() {
|
||||
AuthUtil.requireAdmin();
|
||||
SseEmitter emitter = new SseEmitter(600_000L);
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
var restaurants = restaurantService.findWithoutCatchtable();
|
||||
int total = restaurants.size();
|
||||
emit(emitter, Map.of("type", "start", "total", total));
|
||||
|
||||
if (total == 0) {
|
||||
emit(emitter, Map.of("type", "complete", "total", 0, "linked", 0, "notFound", 0));
|
||||
emitter.complete();
|
||||
return;
|
||||
}
|
||||
|
||||
int linked = 0;
|
||||
int notFound = 0;
|
||||
|
||||
for (int i = 0; i < total; i++) {
|
||||
var r = restaurants.get(i);
|
||||
emit(emitter, Map.of("type", "processing", "current", i + 1,
|
||||
"total", total, "name", r.getName()));
|
||||
|
||||
try {
|
||||
var results = searchCatchtable(r.getName());
|
||||
if (!results.isEmpty()) {
|
||||
String url = String.valueOf(results.get(0).get("url"));
|
||||
String title = String.valueOf(results.get(0).get("title"));
|
||||
if (isNameSimilar(r.getName(), title)) {
|
||||
restaurantService.update(r.getId(), Map.of("catchtable_url", url));
|
||||
linked++;
|
||||
emit(emitter, Map.of("type", "done", "current", i + 1,
|
||||
"name", r.getName(), "url", url, "title", title));
|
||||
} else {
|
||||
restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE"));
|
||||
notFound++;
|
||||
log.info("[CATCHTABLE] Name mismatch: '{}' vs '{}', skipping", r.getName(), title);
|
||||
emit(emitter, Map.of("type", "notfound", "current", i + 1,
|
||||
"name", r.getName(), "reason", "이름 불일치: " + title));
|
||||
}
|
||||
} else {
|
||||
restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE"));
|
||||
notFound++;
|
||||
emit(emitter, Map.of("type", "notfound", "current", i + 1,
|
||||
"name", r.getName()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
notFound++;
|
||||
emit(emitter, Map.of("type", "error", "current", i + 1,
|
||||
"name", r.getName(), "message", e.getMessage()));
|
||||
}
|
||||
|
||||
int delay = ThreadLocalRandom.current().nextInt(2000, 5001);
|
||||
log.info("[CATCHTABLE] Waiting {}ms before next search...", delay);
|
||||
Thread.sleep(delay);
|
||||
}
|
||||
|
||||
cache.flush();
|
||||
emit(emitter, Map.of("type", "complete", "total", total, "linked", linked, "notFound", notFound));
|
||||
emitter.complete();
|
||||
} catch (Exception e) {
|
||||
log.error("[CATCHTABLE] Bulk search error", e);
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
});
|
||||
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/** 캐치테이블 URL 저장 */
|
||||
@PutMapping("/{id}/catchtable-url")
|
||||
public Map<String, Object> setCatchtableUrl(@PathVariable String id, @RequestBody Map<String, String> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
String url = body.get("catchtable_url");
|
||||
// #290 — javascript:/외부 악성 URL 차단. 빈 문자열은 매핑 해제로 허용.
|
||||
if (url != null && !url.isBlank()
|
||||
&& !url.startsWith("https://app.catchtable.co.kr/")
|
||||
&& !url.startsWith("https://www.catchtable.co.kr/")) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "캐치테이블 URL은 https://(app|www).catchtable.co.kr/ 만 허용");
|
||||
}
|
||||
restaurantService.update(id, Map.of("catchtable_url", url != null ? url : ""));
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/videos")
|
||||
public List<Map<String, Object>> videos(@PathVariable String id) {
|
||||
String key = cache.makeKey("restaurant_videos", id);
|
||||
String cached = cache.getRaw(key);
|
||||
if (cached != null) {
|
||||
try {
|
||||
return objectMapper.readValue(cached, new TypeReference<List<Map<String, Object>>>() {});
|
||||
} 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);
|
||||
cache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── DuckDuckGo HTML search helpers ─────────────────────────────────
|
||||
|
||||
private static final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
|
||||
private static final Pattern DDG_RESULT_PATTERN = Pattern.compile(
|
||||
"<a[^>]+class=\"result__a\"[^>]+href=\"([^\"]+)\"[^>]*>(.*?)</a>",
|
||||
Pattern.DOTALL
|
||||
);
|
||||
|
||||
/**
|
||||
* DuckDuckGo HTML 검색을 통해 특정 사이트의 URL을 찾는다.
|
||||
* html.duckduckgo.com은 서버사이드 렌더링이라 봇 판정 없이 HTTP 요청만으로 결과를 파싱할 수 있다.
|
||||
*/
|
||||
private List<Map<String, Object>> searchDuckDuckGo(String query, String... urlPatterns) throws Exception {
|
||||
String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
|
||||
String searchUrl = "https://html.duckduckgo.com/html/?q=" + encoded;
|
||||
log.info("[DDG] Searching: {}", query);
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(searchUrl))
|
||||
.header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
|
||||
.header("Accept", "text/html,application/xhtml+xml")
|
||||
.header("Accept-Language", "ko-KR,ko;q=0.9")
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
String html = response.body();
|
||||
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
Set<String> seen = new HashSet<>();
|
||||
Matcher matcher = DDG_RESULT_PATTERN.matcher(html);
|
||||
|
||||
while (matcher.find() && results.size() < 5) {
|
||||
String href = matcher.group(1);
|
||||
String title = matcher.group(2).replaceAll("<[^>]+>", "").trim();
|
||||
|
||||
// DDG 링크에서 실제 URL 추출 (uddg 파라미터)
|
||||
String actualUrl = extractDdgUrl(href);
|
||||
if (actualUrl == null) continue;
|
||||
|
||||
boolean matches = false;
|
||||
for (String pattern : urlPatterns) {
|
||||
if (actualUrl.contains(pattern)) {
|
||||
matches = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matches && !seen.contains(actualUrl)) {
|
||||
seen.add(actualUrl);
|
||||
results.add(Map.of("title", title, "url", actualUrl));
|
||||
}
|
||||
}
|
||||
|
||||
log.info("[DDG] Found {} results for '{}'", results.size(), query);
|
||||
return results;
|
||||
}
|
||||
|
||||
/** DDG 리다이렉트 URL에서 실제 URL 추출 */
|
||||
private String extractDdgUrl(String ddgHref) {
|
||||
try {
|
||||
// //duckduckgo.com/l/?uddg=ENCODED_URL&rut=...
|
||||
if (ddgHref.contains("uddg=")) {
|
||||
String uddgParam = ddgHref.substring(ddgHref.indexOf("uddg=") + 5);
|
||||
int ampIdx = uddgParam.indexOf('&');
|
||||
if (ampIdx > 0) uddgParam = uddgParam.substring(0, ampIdx);
|
||||
return URLDecoder.decode(uddgParam, StandardCharsets.UTF_8);
|
||||
}
|
||||
// 직접 URL인 경우
|
||||
if (ddgHref.startsWith("http")) return ddgHref;
|
||||
} catch (Exception e) {
|
||||
log.debug("[DDG] Failed to extract URL from: {}", ddgHref);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> searchTabling(String restaurantName) throws Exception {
|
||||
return searchDuckDuckGo(
|
||||
"site:tabling.co.kr " + restaurantName,
|
||||
"tabling.co.kr/restaurant/", "tabling.co.kr/place/"
|
||||
);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> searchCatchtable(String restaurantName) throws Exception {
|
||||
return searchDuckDuckGo(
|
||||
"site:app.catchtable.co.kr " + restaurantName,
|
||||
"catchtable.co.kr/dining/", "catchtable.co.kr/shop/"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 식당 이름과 검색 결과 제목의 유사도 검사.
|
||||
* 한쪽 이름이 다른쪽에 포함되거나, 공통 글자 비율이 40% 이상이면 유사하다고 판단.
|
||||
*/
|
||||
private boolean isNameSimilar(String restaurantName, String resultTitle) {
|
||||
String a = normalize(restaurantName);
|
||||
String b = normalize(resultTitle);
|
||||
if (a.isEmpty() || b.isEmpty()) return false;
|
||||
|
||||
// 포함 관계 체크
|
||||
if (a.contains(b) || b.contains(a)) return true;
|
||||
|
||||
// 공통 문자 비율 (Jaccard-like)
|
||||
var setA = a.chars().boxed().collect(java.util.stream.Collectors.toSet());
|
||||
var setB = b.chars().boxed().collect(java.util.stream.Collectors.toSet());
|
||||
long common = setA.stream().filter(setB::contains).count();
|
||||
double ratio = (double) common / Math.max(setA.size(), setB.size());
|
||||
return ratio >= 0.4;
|
||||
}
|
||||
|
||||
private String normalize(String s) {
|
||||
if (s == null) return "";
|
||||
return s.replaceAll("[\\s·\\-_()()\\[\\]【】]", "").toLowerCase();
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import com.tasteby.domain.Review;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.ReviewService;
|
||||
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 ReviewController {
|
||||
|
||||
private final ReviewService reviewService;
|
||||
|
||||
public ReviewController(ReviewService reviewService) {
|
||||
this.reviewService = reviewService;
|
||||
}
|
||||
|
||||
@GetMapping("/restaurants/{restaurantId}/reviews")
|
||||
public Map<String, Object> listRestaurantReviews(
|
||||
@PathVariable String restaurantId,
|
||||
@RequestParam(defaultValue = "20") int limit,
|
||||
@RequestParam(defaultValue = "0") int offset) {
|
||||
var reviews = reviewService.findByRestaurant(restaurantId, limit, offset);
|
||||
var stats = reviewService.getAvgRating(restaurantId);
|
||||
return Map.of("reviews", reviews, "avg_rating", stats.get("avg_rating"),
|
||||
"review_count", stats.get("review_count"));
|
||||
}
|
||||
|
||||
@PostMapping("/restaurants/{restaurantId}/reviews")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public Review createReview(
|
||||
@PathVariable String restaurantId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String userId = AuthUtil.getUserId();
|
||||
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;
|
||||
return reviewService.create(userId, restaurantId, rating, text, visitedAt);
|
||||
}
|
||||
|
||||
@PutMapping("/reviews/{reviewId}")
|
||||
public Map<String, Object> updateReview(
|
||||
@PathVariable String reviewId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String userId = AuthUtil.getUserId();
|
||||
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;
|
||||
if (!reviewService.update(reviewId, userId, rating, text, visitedAt)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Review not found or not yours");
|
||||
}
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
@DeleteMapping("/reviews/{reviewId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteReview(@PathVariable String reviewId) {
|
||||
String userId = AuthUtil.getUserId();
|
||||
if (!reviewService.delete(reviewId, userId)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Review not found or not yours");
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/users/me/reviews")
|
||||
public List<Review> myReviews(
|
||||
@RequestParam(defaultValue = "20") int limit,
|
||||
@RequestParam(defaultValue = "0") int offset) {
|
||||
return reviewService.findByUser(AuthUtil.getUserId(), limit, offset);
|
||||
}
|
||||
|
||||
// Favorites
|
||||
@GetMapping("/restaurants/{restaurantId}/favorite")
|
||||
public Map<String, Object> favoriteStatus(@PathVariable String restaurantId) {
|
||||
return Map.of("favorited", reviewService.isFavorited(AuthUtil.getUserId(), restaurantId));
|
||||
}
|
||||
|
||||
@PostMapping("/restaurants/{restaurantId}/favorite")
|
||||
public Map<String, Object> toggleFavorite(@PathVariable String restaurantId) {
|
||||
boolean result = reviewService.toggleFavorite(AuthUtil.getUserId(), restaurantId);
|
||||
return Map.of("favorited", result);
|
||||
}
|
||||
|
||||
@GetMapping("/users/me/favorites")
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/search")
|
||||
public class SearchController {
|
||||
|
||||
private final SearchService searchService;
|
||||
|
||||
public SearchController(SearchService searchService) {
|
||||
this.searchService = searchService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Restaurant> search(
|
||||
@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;
|
||||
if (limit < 1) limit = 1;
|
||||
return searchService.search(q.trim(), mode, limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/stats")
|
||||
public class StatsController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(StatsController.class);
|
||||
|
||||
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(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, "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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.domain.VideoDetail;
|
||||
import com.tasteby.domain.VideoSummary;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.*;
|
||||
import com.tasteby.util.JsonUtil;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/videos")
|
||||
public class VideoController {
|
||||
|
||||
private final VideoService videoService;
|
||||
private final CacheService cache;
|
||||
private final YouTubeService youTubeService;
|
||||
private final PipelineService pipelineService;
|
||||
private final ExtractorService extractorService;
|
||||
private final RestaurantService restaurantService;
|
||||
private final GeocodingService geocodingService;
|
||||
|
||||
public VideoController(VideoService videoService, CacheService cache,
|
||||
YouTubeService youTubeService, PipelineService pipelineService,
|
||||
ExtractorService extractorService, RestaurantService restaurantService,
|
||||
GeocodingService geocodingService) {
|
||||
this.videoService = videoService;
|
||||
this.cache = cache;
|
||||
this.youTubeService = youTubeService;
|
||||
this.pipelineService = pipelineService;
|
||||
this.extractorService = extractorService;
|
||||
this.restaurantService = restaurantService;
|
||||
this.geocodingService = geocodingService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<VideoSummary> list(@RequestParam(required = false) String status) {
|
||||
return videoService.findAll(status);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public VideoDetail detail(@PathVariable String id) {
|
||||
var video = videoService.findDetail(id);
|
||||
if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found");
|
||||
return video;
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public Map<String, Object> updateTitle(@PathVariable String id, @RequestBody Map<String, String> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
String title = body.get("title");
|
||||
if (title == null || title.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "title is required");
|
||||
}
|
||||
videoService.updateTitle(id, title);
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/skip")
|
||||
public Map<String, Object> skip(@PathVariable String id) {
|
||||
AuthUtil.requireAdmin();
|
||||
videoService.updateStatus(id, "skip");
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public Map<String, Object> delete(@PathVariable String id) {
|
||||
AuthUtil.requireAdmin();
|
||||
videoService.delete(id);
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{videoId}/restaurants/{restaurantId}")
|
||||
public Map<String, Object> deleteVideoRestaurant(
|
||||
@PathVariable String videoId, @PathVariable String restaurantId) {
|
||||
AuthUtil.requireAdmin();
|
||||
videoService.deleteVideoRestaurant(videoId, restaurantId);
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/fetch-transcript")
|
||||
public Map<String, Object> fetchTranscript(@PathVariable String id,
|
||||
@RequestParam(defaultValue = "auto") String mode) {
|
||||
AuthUtil.requireAdmin();
|
||||
var video = videoService.findDetail(id);
|
||||
if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found");
|
||||
|
||||
var result = youTubeService.getTranscript(video.getVideoId(), mode);
|
||||
if (result == null || result.text() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No transcript available");
|
||||
}
|
||||
|
||||
videoService.updateTranscript(id, result.text());
|
||||
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());
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/extract")
|
||||
public Map<String, Object> extract(@PathVariable String id,
|
||||
@RequestBody(required = false) Map<String, String> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
var video = videoService.findDetail(id);
|
||||
if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found");
|
||||
if (video.getTranscriptText() == null || video.getTranscriptText().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No transcript");
|
||||
}
|
||||
|
||||
String customPrompt = body != null ? body.get("prompt") : null;
|
||||
var videoMap = Map.<String, Object>of("id", id, "video_id", video.getVideoId(), "title", video.getTitle());
|
||||
int count = pipelineService.processExtract(videoMap, video.getTranscriptText(), customPrompt);
|
||||
if (count > 0) cache.flush();
|
||||
return Map.of("ok", true, "restaurants_extracted", count);
|
||||
}
|
||||
|
||||
@GetMapping("/bulk-extract/pending")
|
||||
public Map<String, Object> bulkExtractPending() {
|
||||
var videos = videoService.findVideosForBulkExtract();
|
||||
var summary = videos.stream().map(v -> Map.of("id", v.get("id"), "title", v.get("title"))).toList();
|
||||
return Map.of("count", videos.size(), "videos", summary);
|
||||
}
|
||||
|
||||
@GetMapping("/bulk-transcript/pending")
|
||||
public Map<String, Object> bulkTranscriptPending() {
|
||||
var videos = videoService.findVideosWithoutTranscript();
|
||||
var summary = videos.stream().map(v -> Map.of("id", v.get("id"), "title", v.get("title"))).toList();
|
||||
return Map.of("count", videos.size(), "videos", summary);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@PostMapping("/{videoId}/restaurants/manual")
|
||||
public Map<String, Object> addManualRestaurant(@PathVariable String videoId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
String name = (String) body.get("name");
|
||||
if (name == null || name.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "name is required");
|
||||
}
|
||||
|
||||
// Geocode
|
||||
var geo = geocodingService.geocodeRestaurant(name, (String) body.get("address"));
|
||||
var data = new HashMap<String, Object>();
|
||||
data.put("name", name);
|
||||
data.put("address", geo != null ? geo.get("formatted_address") : body.get("address"));
|
||||
data.put("region", body.get("region"));
|
||||
data.put("latitude", geo != null ? geo.get("latitude") : null);
|
||||
data.put("longitude", geo != null ? geo.get("longitude") : null);
|
||||
data.put("cuisine_type", body.get("cuisine_type"));
|
||||
data.put("price_range", body.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);
|
||||
|
||||
String restId = restaurantService.upsert(data);
|
||||
|
||||
// Parse foods and guests
|
||||
List<String> foods = null;
|
||||
Object foodsRaw = body.get("foods_mentioned");
|
||||
if (foodsRaw instanceof List<?>) {
|
||||
foods = ((List<?>) foodsRaw).stream().map(Object::toString).toList();
|
||||
} else if (foodsRaw instanceof String s && !s.isBlank()) {
|
||||
foods = List.of(s.split("\\s*,\\s*"));
|
||||
}
|
||||
|
||||
List<String> guests = null;
|
||||
Object guestsRaw = body.get("guests");
|
||||
if (guestsRaw instanceof List<?>) {
|
||||
guests = ((List<?>) guestsRaw).stream().map(Object::toString).toList();
|
||||
} else if (guestsRaw instanceof String s && !s.isBlank()) {
|
||||
guests = List.of(s.split("\\s*,\\s*"));
|
||||
}
|
||||
|
||||
String evaluation = body.get("evaluation") instanceof String s ? s : null;
|
||||
|
||||
restaurantService.linkVideoRestaurant(videoId, restId, foods, evaluation, guests);
|
||||
cache.flush();
|
||||
return Map.of("ok", true, "restaurant_id", restId);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@PutMapping("/{videoId}/restaurants/{restaurantId}")
|
||||
public Map<String, Object> updateVideoRestaurant(@PathVariable String videoId,
|
||||
@PathVariable String restaurantId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
|
||||
// Update link fields (foods_mentioned, evaluation, guests)
|
||||
List<String> foods = null;
|
||||
Object foodsRaw = body.get("foods_mentioned");
|
||||
if (foodsRaw instanceof List<?>) {
|
||||
foods = ((List<?>) foodsRaw).stream().map(Object::toString).toList();
|
||||
} else if (foodsRaw instanceof String s && !s.isBlank()) {
|
||||
foods = List.of(s.split("\\s*,\\s*"));
|
||||
}
|
||||
|
||||
List<String> guests = null;
|
||||
Object guestsRaw = body.get("guests");
|
||||
if (guestsRaw instanceof List<?>) {
|
||||
guests = ((List<?>) guestsRaw).stream().map(Object::toString).toList();
|
||||
} else if (guestsRaw instanceof String s && !s.isBlank()) {
|
||||
guests = List.of(s.split("\\s*,\\s*"));
|
||||
}
|
||||
|
||||
// evaluation must be valid JSON for DB IS JSON constraint
|
||||
String evaluationJson = null;
|
||||
Object evalRaw = body.get("evaluation");
|
||||
if (evalRaw instanceof String s && !s.isBlank()) {
|
||||
evaluationJson = s.trim().startsWith("{") || s.trim().startsWith("\"") ? s : JsonUtil.toJson(s);
|
||||
} else if (evalRaw instanceof Map<?, ?>) {
|
||||
evaluationJson = JsonUtil.toJson(evalRaw);
|
||||
}
|
||||
String foodsJson = foods != null ? JsonUtil.toJson(foods) : null;
|
||||
String guestsJson = guests != null ? JsonUtil.toJson(guests) : null;
|
||||
videoService.updateVideoRestaurantFields(videoId, restaurantId, foodsJson, evaluationJson, guestsJson);
|
||||
|
||||
// Update restaurant fields if provided
|
||||
var restFields = new HashMap<String, Object>();
|
||||
for (var key : List.of("name", "address", "region", "cuisine_type", "price_range")) {
|
||||
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);
|
||||
}
|
||||
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.*;
|
||||
import com.tasteby.util.CuisineTypes;
|
||||
import com.tasteby.util.JsonUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/videos")
|
||||
public class VideoSseController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(VideoSseController.class);
|
||||
|
||||
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;
|
||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
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(@RequestBody(required = false) Map<String, Object> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
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 {
|
||||
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);
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
});
|
||||
return emitter;
|
||||
}
|
||||
|
||||
@PostMapping("/bulk-extract")
|
||||
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 = selectedIds != null && !selectedIds.isEmpty()
|
||||
? videoService.findVideosForExtractByIds(selectedIds)
|
||||
: videoService.findVideosForBulkExtract();
|
||||
|
||||
int total = rows.size();
|
||||
int totalRestaurants = 0;
|
||||
emit(emitter, Map.of("type", "start", "total", total));
|
||||
|
||||
for (int i = 0; i < total; i++) {
|
||||
var v = rows.get(i);
|
||||
if (i > 0) {
|
||||
// #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);
|
||||
}
|
||||
emit(emitter, Map.of("type", "processing", "index", i, "title", v.get("title")));
|
||||
try {
|
||||
int count = pipelineService.processExtract(v, (String) v.get("transcript"), null);
|
||||
totalRestaurants += count;
|
||||
emit(emitter, Map.of("type", "done", "index", i, "title", v.get("title"), "restaurants", count));
|
||||
} catch (Exception e) {
|
||||
log.error("Bulk extract error for {}: {}", v.get("video_id"), e.getMessage());
|
||||
emit(emitter, Map.of("type", "error", "index", i, "title", v.get("title"), "message", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
if (totalRestaurants > 0) cache.flush();
|
||||
emit(emitter, Map.of("type", "complete", "total", total, "total_restaurants", totalRestaurants));
|
||||
emitter.complete();
|
||||
} catch (Exception e) {
|
||||
log.error("Bulk extract error", e);
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
});
|
||||
return emitter;
|
||||
}
|
||||
|
||||
@PostMapping("/remap-cuisine")
|
||||
@SuppressWarnings("unchecked")
|
||||
public SseEmitter remapCuisine() {
|
||||
AuthUtil.requireAdmin();
|
||||
SseEmitter emitter = new SseEmitter(600_000L);
|
||||
int BATCH = 20;
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
var rows = restaurantService.findForRemapCuisine();
|
||||
rows = rows.stream().map(JsonUtil::lowerKeys).toList();
|
||||
|
||||
int total = rows.size();
|
||||
emit(emitter, Map.of("type", "start", "total", total));
|
||||
int updated = 0;
|
||||
var allMissed = new ArrayList<Map<String, Object>>();
|
||||
|
||||
// Pass 1
|
||||
for (int i = 0; i < total; i += BATCH) {
|
||||
var batch = rows.subList(i, Math.min(i + BATCH, total));
|
||||
emit(emitter, Map.of("type", "processing", "current", Math.min(i + BATCH, total), "total", total, "pass", 1));
|
||||
try {
|
||||
var result = applyRemapBatch(batch);
|
||||
updated += result.updated;
|
||||
allMissed.addAll(result.missed);
|
||||
emit(emitter, Map.of("type", "batch_done", "current", Math.min(i + BATCH, total), "total", total, "updated", updated, "missed", allMissed.size()));
|
||||
} catch (Exception e) {
|
||||
allMissed.addAll(batch);
|
||||
emit(emitter, Map.of("type", "error", "message", e.getMessage(), "current", i));
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: retry missed (up to 3 attempts with smaller batches)
|
||||
if (!allMissed.isEmpty()) {
|
||||
emit(emitter, Map.of("type", "retry", "missed", allMissed.size()));
|
||||
for (int attempt = 0; attempt < 3 && !allMissed.isEmpty(); attempt++) {
|
||||
var retryList = new ArrayList<>(allMissed);
|
||||
allMissed.clear();
|
||||
for (int i = 0; i < retryList.size(); i += 5) {
|
||||
var batch = retryList.subList(i, Math.min(i + 5, retryList.size()));
|
||||
try {
|
||||
var result = applyRemapBatch(batch);
|
||||
updated += result.updated;
|
||||
allMissed.addAll(result.missed);
|
||||
} catch (Exception e) {
|
||||
log.warn("Remap cuisine retry failed (attempt {}): {}", attempt + 1, e.getMessage());
|
||||
allMissed.addAll(batch);
|
||||
}
|
||||
}
|
||||
if (!allMissed.isEmpty()) {
|
||||
emit(emitter, Map.of("type", "retry", "attempt", attempt + 2, "missed", allMissed.size()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cache.flush();
|
||||
emit(emitter, Map.of("type", "complete", "total", total, "updated", updated, "missed", allMissed.size()));
|
||||
emitter.complete();
|
||||
} catch (Exception e) {
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
});
|
||||
return emitter;
|
||||
}
|
||||
|
||||
@PostMapping("/remap-foods")
|
||||
@SuppressWarnings("unchecked")
|
||||
public SseEmitter remapFoods() {
|
||||
AuthUtil.requireAdmin();
|
||||
SseEmitter emitter = new SseEmitter(600_000L);
|
||||
int BATCH = 15;
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
var rows = restaurantService.findForRemapFoods();
|
||||
rows = rows.stream().map(r -> {
|
||||
var m = JsonUtil.lowerKeys(r);
|
||||
// foods_mentioned is now TO_CHAR'd in SQL, parse as string
|
||||
Object fm = m.get("foods_mentioned");
|
||||
m.put("foods", JsonUtil.parseStringList(fm));
|
||||
return m;
|
||||
}).toList();
|
||||
|
||||
int total = rows.size();
|
||||
emit(emitter, Map.of("type", "start", "total", total));
|
||||
int updated = 0;
|
||||
var allMissed = new ArrayList<Map<String, Object>>();
|
||||
|
||||
for (int i = 0; i < total; i += BATCH) {
|
||||
var batch = rows.subList(i, Math.min(i + BATCH, total));
|
||||
emit(emitter, Map.of("type", "processing", "current", Math.min(i + BATCH, total), "total", total));
|
||||
try {
|
||||
var result = applyFoodsBatch(batch);
|
||||
updated += result.updated;
|
||||
allMissed.addAll(result.missed);
|
||||
emit(emitter, Map.of("type", "batch_done", "current", Math.min(i + BATCH, total), "total", total, "updated", updated));
|
||||
} catch (Exception e) {
|
||||
allMissed.addAll(batch);
|
||||
log.warn("Remap foods batch error at {}: {}", i, e.getMessage());
|
||||
emit(emitter, Map.of("type", "error", "message", e.getMessage(), "current", i));
|
||||
}
|
||||
}
|
||||
|
||||
// Retry missed (up to 3 attempts with smaller batches)
|
||||
if (!allMissed.isEmpty()) {
|
||||
emit(emitter, Map.of("type", "retry", "missed", allMissed.size()));
|
||||
for (int attempt = 0; attempt < 3 && !allMissed.isEmpty(); attempt++) {
|
||||
var retryList = new ArrayList<>(allMissed);
|
||||
allMissed.clear();
|
||||
for (int i = 0; i < retryList.size(); i += 5) {
|
||||
var batch = retryList.subList(i, Math.min(i + 5, retryList.size()));
|
||||
try {
|
||||
var r = applyFoodsBatch(batch);
|
||||
updated += r.updated;
|
||||
allMissed.addAll(r.missed);
|
||||
} catch (Exception e) {
|
||||
log.warn("Remap foods retry failed (attempt {}): {}", attempt + 1, e.getMessage());
|
||||
allMissed.addAll(batch);
|
||||
}
|
||||
}
|
||||
if (!allMissed.isEmpty()) {
|
||||
emit(emitter, Map.of("type", "retry", "attempt", attempt + 2, "missed", allMissed.size()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cache.flush();
|
||||
emit(emitter, Map.of("type", "complete", "total", total, "updated", updated, "missed", allMissed.size()));
|
||||
emitter.complete();
|
||||
} catch (Exception e) {
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
});
|
||||
return emitter;
|
||||
}
|
||||
|
||||
@PostMapping("/rebuild-vectors")
|
||||
public SseEmitter rebuildVectors() {
|
||||
AuthUtil.requireAdmin();
|
||||
SseEmitter emitter = new SseEmitter(60_000L);
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
// #325 — 운영자에게 미구현 상태 명시 (이전: 즉시 complete(total=0) → 무반응 인상)
|
||||
emit(emitter, Map.of(
|
||||
"type", "not_implemented",
|
||||
"message", "벡터 재생성은 아직 구현되지 않았습니다. 후속 이슈(#325/#331)에서 처리 예정입니다."
|
||||
));
|
||||
emitter.complete();
|
||||
} catch (Exception e) {
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
});
|
||||
return emitter;
|
||||
}
|
||||
|
||||
@PostMapping("/process")
|
||||
public Map<String, Object> process(@RequestParam(defaultValue = "5") int limit) {
|
||||
AuthUtil.requireAdmin();
|
||||
int count = pipelineService.processPending(limit);
|
||||
if (count > 0) cache.flush();
|
||||
return Map.of("restaurants_extracted", count);
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private record BatchResult(int updated, List<Map<String, Object>> missed) {}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private BatchResult applyRemapBatch(List<Map<String, Object>> batch) throws Exception {
|
||||
var items = batch.stream().map(b -> Map.of(
|
||||
"id", b.get("id"), "name", b.get("name"),
|
||||
"current_cuisine_type", b.get("cuisine_type"),
|
||||
"foods_mentioned", b.get("foods_mentioned")
|
||||
)).toList();
|
||||
|
||||
String prompt = """
|
||||
아래 식당들의 cuisine_type을 표준 분류로 매핑하세요.
|
||||
|
||||
표준 분류 목록 (반드시 이 중 하나를 선택):
|
||||
%s
|
||||
|
||||
식당 목록:
|
||||
%s
|
||||
|
||||
규칙:
|
||||
- 모든 식당에 대해 빠짐없이 결과를 반환 (총 %d개 모두 반환해야 함)
|
||||
- 반드시 위 표준 분류 목록의 값을 그대로 복사하여 사용 (오타 금지)
|
||||
- JSON 배열만 반환, 설명 없음
|
||||
- 형식: [{"id": "식당ID", "cuisine_type": "한식|국밥/해장국"}, ...]
|
||||
|
||||
JSON 배열:""".formatted(CuisineTypes.CUISINE_LIST_TEXT, mapper.writeValueAsString(items), items.size());
|
||||
|
||||
String raw = genAi.chat(prompt, 4096);
|
||||
Object parsed = genAi.parseJson(raw);
|
||||
List<Map<String, Object>> results = parsed instanceof List<?> ? (List<Map<String, Object>>) parsed : List.of();
|
||||
|
||||
Map<String, String> resultMap = new HashMap<>();
|
||||
for (var item : results) {
|
||||
String id = (String) item.get("id");
|
||||
String type = (String) item.get("cuisine_type");
|
||||
if (id != null && type != null) resultMap.put(id, type);
|
||||
}
|
||||
|
||||
int updated = 0;
|
||||
var missed = new ArrayList<Map<String, Object>>();
|
||||
for (var b : batch) {
|
||||
String id = (String) b.get("id");
|
||||
String newType = resultMap.get(id);
|
||||
if (newType == null || !CuisineTypes.isValid(newType)) {
|
||||
missed.add(b);
|
||||
continue;
|
||||
}
|
||||
restaurantService.updateCuisineType(id, newType);
|
||||
updated++;
|
||||
}
|
||||
return new BatchResult(updated, missed);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private BatchResult applyFoodsBatch(List<Map<String, Object>> batch) throws Exception {
|
||||
var items = batch.stream().map(b -> Map.of(
|
||||
"id", b.get("id"), "name", b.get("name"),
|
||||
"current_foods", b.get("foods"), "cuisine_type", b.get("cuisine_type")
|
||||
)).toList();
|
||||
|
||||
String prompt = """
|
||||
아래 식당들의 대표 메뉴 태그를 다시 만들어주세요.
|
||||
|
||||
규칙:
|
||||
- 반드시 한글로 작성
|
||||
- 각 식당당 최대 10개의 대표 메뉴/음식 태그
|
||||
- 우선순위: 시그니처 메뉴 > 자주 언급된 메뉴 > 일반 메뉴
|
||||
- 너무 일반적인 태그(밥, 반찬 등)는 제외
|
||||
- 모든 식당에 대해 빠짐없이 결과 반환 (총 %d개)
|
||||
- JSON 배열만 반환, 설명 없음
|
||||
- 형식: [{"id": "식당ID", "foods": ["메뉴1", "메뉴2", ...]}]
|
||||
|
||||
식당 목록:
|
||||
%s
|
||||
|
||||
JSON 배열:""".formatted(items.size(), mapper.writeValueAsString(items));
|
||||
|
||||
String raw = genAi.chat(prompt, 4096);
|
||||
Object parsed = genAi.parseJson(raw);
|
||||
List<Map<String, Object>> results = parsed instanceof List<?> ? (List<Map<String, Object>>) parsed : List.of();
|
||||
|
||||
Map<String, List<String>> resultMap = new HashMap<>();
|
||||
for (var item : results) {
|
||||
String id = (String) item.get("id");
|
||||
Object foods = item.get("foods");
|
||||
if (id != null && foods instanceof List<?> list) {
|
||||
resultMap.put(id, list.stream().map(Object::toString).limit(10).toList());
|
||||
}
|
||||
}
|
||||
|
||||
int updated = 0;
|
||||
var missed = new ArrayList<Map<String, Object>>();
|
||||
for (var b : batch) {
|
||||
String id = (String) b.get("id");
|
||||
List<String> newFoods = resultMap.get(id);
|
||||
if (newFoods == null) {
|
||||
missed.add(b);
|
||||
continue;
|
||||
}
|
||||
restaurantService.updateFoodsMentioned(id, mapper.writeValueAsString(newFoods));
|
||||
updated++;
|
||||
}
|
||||
return new BatchResult(updated, missed);
|
||||
}
|
||||
|
||||
private void emit(SseEmitter emitter, Map<String, Object> data) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().data(mapper.writeValueAsString(data)));
|
||||
} catch (Exception e) {
|
||||
log.debug("SSE emit failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
22
backend-java/src/main/java/com/tasteby/domain/Channel.java
Normal file
22
backend-java/src/main/java/com/tasteby/domain/Channel.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 Channel {
|
||||
private String id;
|
||||
private String channelId;
|
||||
private String channelName;
|
||||
private String titleFilter;
|
||||
private String description;
|
||||
private String tags;
|
||||
private Integer sortOrder;
|
||||
private int videoCount;
|
||||
private String lastVideoAt;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.tasteby.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DaemonConfig {
|
||||
private int id;
|
||||
private boolean scanEnabled;
|
||||
private int scanIntervalMin;
|
||||
private boolean processEnabled;
|
||||
private int processIntervalMin;
|
||||
private int processLimit;
|
||||
private Date lastScanAt;
|
||||
private Date lastProcessAt;
|
||||
private Date updatedAt;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.tasteby.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Restaurant {
|
||||
private String id;
|
||||
private String name;
|
||||
private String address;
|
||||
private String region;
|
||||
private Double latitude;
|
||||
private Double longitude;
|
||||
private String cuisineType;
|
||||
private String priceRange;
|
||||
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;
|
||||
}
|
||||
24
backend-java/src/main/java/com/tasteby/domain/Review.java
Normal file
24
backend-java/src/main/java/com/tasteby/domain/Review.java
Normal file
@@ -0,0 +1,24 @@
|
||||
package com.tasteby.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Review {
|
||||
private String id;
|
||||
private String userId;
|
||||
private String restaurantId;
|
||||
private double rating;
|
||||
private String reviewText;
|
||||
private String visitedAt;
|
||||
private String createdAt;
|
||||
private String updatedAt;
|
||||
private String userNickname;
|
||||
private String userAvatarUrl;
|
||||
private String restaurantName;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.tasteby.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SiteVisitStats {
|
||||
// #274 — long으로 변경 (21억 이상 누적 시 int 오버플로 방지)
|
||||
private long today;
|
||||
private long total;
|
||||
}
|
||||
26
backend-java/src/main/java/com/tasteby/domain/UserInfo.java
Normal file
26
backend-java/src/main/java/com/tasteby/domain/UserInfo.java
Normal file
@@ -0,0 +1,26 @@
|
||||
package com.tasteby.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class UserInfo {
|
||||
private String id;
|
||||
private String email;
|
||||
private String nickname;
|
||||
private String avatarUrl;
|
||||
@JsonProperty("is_admin")
|
||||
private boolean admin;
|
||||
private String provider;
|
||||
private String providerId;
|
||||
private String createdAt;
|
||||
private int favoriteCount;
|
||||
private int reviewCount;
|
||||
private int memoCount;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.tasteby.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class VectorSearchResult {
|
||||
private String restaurantId;
|
||||
private String chunkText;
|
||||
private double distance;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.tasteby.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class VideoDetail {
|
||||
private String id;
|
||||
private String videoId;
|
||||
private String title;
|
||||
private String url;
|
||||
private String status;
|
||||
private String publishedAt;
|
||||
private String channelName;
|
||||
private boolean hasTranscript;
|
||||
private boolean hasLlm;
|
||||
private int restaurantCount;
|
||||
private int matchedCount;
|
||||
@JsonProperty("transcript")
|
||||
private String transcriptText;
|
||||
private List<VideoRestaurantLink> restaurants;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.tasteby.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonRawValue;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class VideoRestaurantLink {
|
||||
private String restaurantId;
|
||||
private String name;
|
||||
private String address;
|
||||
private String cuisineType;
|
||||
private String priceRange;
|
||||
private String region;
|
||||
@JsonRawValue
|
||||
private String foodsMentioned;
|
||||
@JsonRawValue
|
||||
private String evaluation;
|
||||
@JsonRawValue
|
||||
private String guests;
|
||||
private String googlePlaceId;
|
||||
private Double latitude;
|
||||
private Double longitude;
|
||||
|
||||
public boolean isHasLocation() {
|
||||
return latitude != null && longitude != null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.tasteby.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class VideoSummary {
|
||||
private String id;
|
||||
private String videoId;
|
||||
private String title;
|
||||
private String url;
|
||||
private String status;
|
||||
private String publishedAt;
|
||||
private String channelName;
|
||||
private boolean hasTranscript;
|
||||
private boolean hasLlm;
|
||||
private int restaurantCount;
|
||||
private int matchedCount;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.tasteby.mapper;
|
||||
|
||||
import com.tasteby.domain.Channel;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ChannelMapper {
|
||||
|
||||
List<Channel> findAllActive();
|
||||
|
||||
void insert(@Param("id") String id,
|
||||
@Param("channelId") String channelId,
|
||||
@Param("channelName") String channelName,
|
||||
@Param("titleFilter") String titleFilter);
|
||||
|
||||
int deactivateByChannelId(@Param("channelId") String channelId);
|
||||
|
||||
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,16 @@
|
||||
package com.tasteby.mapper;
|
||||
|
||||
import com.tasteby.domain.DaemonConfig;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface DaemonConfigMapper {
|
||||
|
||||
DaemonConfig getConfig();
|
||||
|
||||
void updateConfig(DaemonConfig config);
|
||||
|
||||
void updateLastScan();
|
||||
|
||||
void updateLastProcess();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.tasteby.mapper;
|
||||
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Mapper
|
||||
public interface RestaurantMapper {
|
||||
|
||||
List<Restaurant> findAll(@Param("limit") int limit,
|
||||
@Param("offset") int offset,
|
||||
@Param("cuisine") String cuisine,
|
||||
@Param("region") String region,
|
||||
@Param("channel") String channel,
|
||||
@Param("includeHidden") boolean includeHidden);
|
||||
|
||||
// #322 LLM 검증: hidden 표시 갱신
|
||||
void updateVerification(@Param("id") String id,
|
||||
@Param("hidden") int hidden,
|
||||
@Param("hiddenReason") String hiddenReason);
|
||||
|
||||
void clearHidden(@Param("id") String id);
|
||||
|
||||
List<Restaurant> findUnverified(@Param("limit") int limit);
|
||||
|
||||
int countUnverified();
|
||||
|
||||
Restaurant findById(@Param("id") String id);
|
||||
|
||||
List<Map<String, Object>> findVideoLinks(@Param("restaurantId") String restaurantId);
|
||||
|
||||
void insertRestaurant(Restaurant r);
|
||||
|
||||
void updateRestaurant(Restaurant r);
|
||||
|
||||
void updateFields(@Param("id") String id, @Param("fields") Map<String, Object> fields);
|
||||
|
||||
void deleteVectors(@Param("id") String id);
|
||||
|
||||
void deleteReviews(@Param("id") String id);
|
||||
|
||||
void deleteFavorites(@Param("id") String id);
|
||||
|
||||
void deleteVideoRestaurants(@Param("id") String id);
|
||||
|
||||
void deleteRestaurant(@Param("id") String id);
|
||||
|
||||
void linkVideoRestaurant(@Param("id") String id,
|
||||
@Param("videoId") String videoId,
|
||||
@Param("restaurantId") String restaurantId,
|
||||
@Param("foods") String foods,
|
||||
@Param("evaluation") String evaluation,
|
||||
@Param("guests") String guests);
|
||||
|
||||
String findIdByPlaceId(@Param("placeId") String placeId);
|
||||
|
||||
String findIdByName(@Param("name") String name);
|
||||
|
||||
List<Map<String, Object>> findChannelsByRestaurantIds(@Param("ids") List<String> ids);
|
||||
|
||||
List<Map<String, Object>> findFoodsByRestaurantIds(@Param("ids") List<String> ids);
|
||||
|
||||
void updateCuisineType(@Param("id") String id, @Param("cuisineType") String cuisineType);
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.tasteby.mapper;
|
||||
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import com.tasteby.domain.Review;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Mapper
|
||||
public interface ReviewMapper {
|
||||
|
||||
void insertReview(@Param("id") String id,
|
||||
@Param("userId") String userId,
|
||||
@Param("restaurantId") String restaurantId,
|
||||
@Param("rating") double rating,
|
||||
@Param("reviewText") String reviewText,
|
||||
@Param("visitedAt") String visitedAt);
|
||||
|
||||
int updateReview(@Param("id") String id,
|
||||
@Param("userId") String userId,
|
||||
@Param("rating") Double rating,
|
||||
@Param("reviewText") String reviewText,
|
||||
@Param("visitedAt") String visitedAt);
|
||||
|
||||
int deleteReview(@Param("id") String id, @Param("userId") String userId);
|
||||
|
||||
Review findById(@Param("id") String id);
|
||||
|
||||
List<Review> findByRestaurant(@Param("restaurantId") String restaurantId,
|
||||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
Map<String, Object> getAvgRating(@Param("restaurantId") String restaurantId);
|
||||
|
||||
List<Review> findByUser(@Param("userId") String userId,
|
||||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
int countFavorite(@Param("userId") String userId, @Param("restaurantId") String restaurantId);
|
||||
|
||||
void insertFavorite(@Param("id") String id,
|
||||
@Param("userId") String userId,
|
||||
@Param("restaurantId") String restaurantId);
|
||||
|
||||
int deleteFavorite(@Param("userId") String userId, @Param("restaurantId") String restaurantId);
|
||||
|
||||
String findFavoriteId(@Param("userId") String userId, @Param("restaurantId") String restaurantId);
|
||||
|
||||
List<Restaurant> getUserFavorites(@Param("userId") String userId);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.tasteby.mapper;
|
||||
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Mapper
|
||||
public interface SearchMapper {
|
||||
|
||||
List<Restaurant> keywordSearch(@Param("query") String query, @Param("limit") int limit);
|
||||
|
||||
List<Map<String, Object>> findChannelsByRestaurantIds(@Param("ids") List<String> ids);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.tasteby.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface StatsMapper {
|
||||
|
||||
void recordVisit();
|
||||
|
||||
long getTodayVisits();
|
||||
|
||||
long getTotalVisits();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.tasteby.mapper;
|
||||
|
||||
import com.tasteby.domain.UserInfo;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface UserMapper {
|
||||
|
||||
UserInfo findByProviderAndProviderId(@Param("provider") String provider,
|
||||
@Param("providerId") String providerId);
|
||||
|
||||
void updateLastLogin(@Param("id") String id);
|
||||
|
||||
void insert(UserInfo user);
|
||||
|
||||
UserInfo findById(@Param("id") String id);
|
||||
|
||||
List<UserInfo> findAllWithCounts(@Param("limit") int limit, @Param("offset") int offset);
|
||||
|
||||
int countAll();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.tasteby.mapper;
|
||||
|
||||
import com.tasteby.domain.VectorSearchResult;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface VectorMapper {
|
||||
|
||||
List<VectorSearchResult> searchSimilar(@Param("queryVec") String queryVec,
|
||||
@Param("topK") int topK,
|
||||
@Param("maxDistance") double maxDistance);
|
||||
|
||||
void insertVector(@Param("id") String id,
|
||||
@Param("restaurantId") String restaurantId,
|
||||
@Param("chunkText") String chunkText,
|
||||
@Param("embedding") String embedding);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.tasteby.mapper;
|
||||
|
||||
import com.tasteby.domain.VideoDetail;
|
||||
import com.tasteby.domain.VideoRestaurantLink;
|
||||
import com.tasteby.domain.VideoSummary;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Mapper
|
||||
public interface VideoMapper {
|
||||
|
||||
List<VideoSummary> findAll(@Param("status") String status);
|
||||
|
||||
VideoDetail findDetail(@Param("id") String id);
|
||||
|
||||
List<VideoRestaurantLink> findVideoRestaurants(@Param("videoId") String videoId);
|
||||
|
||||
void updateStatus(@Param("id") String id, @Param("status") String status);
|
||||
|
||||
void updateTitle(@Param("id") String id, @Param("title") String title);
|
||||
|
||||
void updateTranscript(@Param("id") String id, @Param("transcript") String transcript);
|
||||
|
||||
void deleteVectorsByVideoOnly(@Param("videoId") String videoId);
|
||||
|
||||
void deleteReviewsByVideoOnly(@Param("videoId") String videoId);
|
||||
|
||||
void deleteFavoritesByVideoOnly(@Param("videoId") String videoId);
|
||||
|
||||
void deleteRestaurantsByVideoOnly(@Param("videoId") String videoId);
|
||||
|
||||
void deleteVideoRestaurants(@Param("videoId") String videoId);
|
||||
|
||||
void deleteVideo(@Param("videoId") String videoId);
|
||||
|
||||
void deleteOneVideoRestaurant(@Param("videoId") String videoId, @Param("restaurantId") String restaurantId);
|
||||
|
||||
void cleanupOrphanVectors(@Param("restaurantId") String restaurantId);
|
||||
|
||||
void cleanupOrphanReviews(@Param("restaurantId") String restaurantId);
|
||||
|
||||
void cleanupOrphanFavorites(@Param("restaurantId") String restaurantId);
|
||||
|
||||
void cleanupOrphanRestaurant(@Param("restaurantId") String restaurantId);
|
||||
|
||||
void insertVideo(@Param("id") String id,
|
||||
@Param("channelId") String channelId,
|
||||
@Param("videoId") String videoId,
|
||||
@Param("title") String title,
|
||||
@Param("url") String url,
|
||||
@Param("publishedAt") String publishedAt);
|
||||
|
||||
List<String> getExistingVideoIds(@Param("channelId") String channelId);
|
||||
|
||||
String getLatestVideoDate(@Param("channelId") String channelId);
|
||||
|
||||
List<Map<String, Object>> findPendingVideos(@Param("limit") int limit);
|
||||
|
||||
void updateVideoFields(@Param("id") String id,
|
||||
@Param("status") String status,
|
||||
@Param("transcript") String transcript,
|
||||
@Param("llmResponse") String llmResponse);
|
||||
|
||||
List<Map<String, Object>> findVideosForBulkExtract();
|
||||
|
||||
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,
|
||||
@Param("evaluation") String evaluation,
|
||||
@Param("guestsJson") String guestsJson);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.tasteby.security;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* Utility to extract current user info from SecurityContext.
|
||||
*/
|
||||
public final class AuthUtil {
|
||||
|
||||
private AuthUtil() {}
|
||||
|
||||
public static Claims getCurrentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !(auth.getPrincipal() instanceof Claims)) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
|
||||
}
|
||||
return (Claims) auth.getPrincipal();
|
||||
}
|
||||
|
||||
public static Claims getCurrentUserOrNull() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !(auth.getPrincipal() instanceof Claims)) {
|
||||
return null;
|
||||
}
|
||||
return (Claims) auth.getPrincipal();
|
||||
}
|
||||
|
||||
public static Claims requireAdmin() {
|
||||
Claims user = getCurrentUser();
|
||||
if (!Boolean.TRUE.equals(user.get("is_admin", Boolean.class))) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "관리자 권한이 필요합니다");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
public static String getUserId() {
|
||||
return getCurrentUser().getSubject();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.tasteby.security;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtTokenProvider tokenProvider;
|
||||
|
||||
public JwtAuthenticationFilter(JwtTokenProvider tokenProvider) {
|
||||
this.tokenProvider = tokenProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String header = request.getHeader("Authorization");
|
||||
if (header != null && header.startsWith("Bearer ")) {
|
||||
String token = header.substring(7).trim();
|
||||
if (tokenProvider.isValid(token)) {
|
||||
Claims claims = tokenProvider.parseToken(token);
|
||||
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
|
||||
authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
|
||||
if (Boolean.TRUE.equals(claims.get("is_admin", Boolean.class))) {
|
||||
authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
|
||||
}
|
||||
|
||||
var auth = new UsernamePasswordAuthenticationToken(claims, null, authorities);
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
}
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.tasteby.security;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class JwtTokenProvider {
|
||||
|
||||
private final SecretKey key;
|
||||
private final int expirationDays;
|
||||
|
||||
public JwtTokenProvider(
|
||||
@Value("${app.jwt.secret}") String secret,
|
||||
@Value("${app.jwt.expiration-days}") int expirationDays) {
|
||||
// Pad secret to at least 32 bytes for HS256
|
||||
String padded = secret.length() < 32
|
||||
? secret + "0".repeat(32 - secret.length())
|
||||
: secret;
|
||||
this.key = Keys.hmacShaKeyFor(padded.getBytes(StandardCharsets.UTF_8));
|
||||
this.expirationDays = expirationDays;
|
||||
}
|
||||
|
||||
public String createToken(Map<String, Object> userInfo) {
|
||||
Instant now = Instant.now();
|
||||
Instant exp = now.plus(expirationDays, ChronoUnit.DAYS);
|
||||
|
||||
return Jwts.builder()
|
||||
.subject((String) userInfo.get("id"))
|
||||
.claim("email", userInfo.get("email"))
|
||||
.claim("nickname", userInfo.get("nickname"))
|
||||
.claim("is_admin", userInfo.get("is_admin"))
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(exp))
|
||||
.signWith(key)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Claims parseToken(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(key)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
}
|
||||
|
||||
public boolean isValid(String token) {
|
||||
try {
|
||||
parseToken(token);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
|
||||
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
|
||||
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;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Collections;
|
||||
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;
|
||||
|
||||
public AuthService(UserService userService, JwtTokenProvider jwtProvider,
|
||||
@Value("${app.google.client-id}") String googleClientId) {
|
||||
this.userService = userService;
|
||||
this.jwtProvider = jwtProvider;
|
||||
this.verifier = new GoogleIdTokenVerifier.Builder(
|
||||
new NetHttpTransport(), GsonFactory.getDefaultInstance())
|
||||
.setAudience(Collections.singletonList(googleClientId))
|
||||
.build();
|
||||
}
|
||||
|
||||
public Map<String, Object> loginGoogle(String idTokenString) {
|
||||
try {
|
||||
GoogleIdToken idToken = verifier.verify(idTokenString);
|
||||
if (idToken == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid Google token");
|
||||
}
|
||||
GoogleIdToken.Payload payload = idToken.getPayload();
|
||||
|
||||
UserInfo user = userService.findOrCreate(
|
||||
"google",
|
||||
payload.getSubject(),
|
||||
payload.getEmail(),
|
||||
(String) payload.get("name"),
|
||||
(String) payload.get("picture"));
|
||||
|
||||
// Convert to Map for JWT
|
||||
Map<String, Object> userMap = Map.of(
|
||||
"id", user.getId(),
|
||||
"email", user.getEmail() != null ? user.getEmail() : "",
|
||||
"nickname", user.getNickname() != null ? user.getNickname() : "",
|
||||
"is_admin", user.isAdmin()
|
||||
);
|
||||
String accessToken = jwtProvider.createToken(userMap);
|
||||
return Map.of("access_token", accessToken, "user", user);
|
||||
} catch (ResponseStatusException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
// #266 — 외부에는 고정 메시지만, 상세는 로그로 (Google verifier 내부 네트워크/공개키
|
||||
// 조회 실패 메시지가 클라이언트에 노출되지 않도록)
|
||||
log.warn("Google token verification failed: {}", e.getMessage());
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid Google token");
|
||||
}
|
||||
}
|
||||
|
||||
public UserInfo getCurrentUser(String userId) {
|
||||
UserInfo user = userService.findById(userId);
|
||||
if (user == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
196
backend-java/src/main/java/com/tasteby/service/CacheService.java
Normal file
196
backend-java/src/main/java/com/tasteby/service/CacheService.java
Normal file
@@ -0,0 +1,196 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
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.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;
|
||||
|
||||
// #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);
|
||||
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);
|
||||
}
|
||||
|
||||
public <T> T get(String key, Class<T> type) {
|
||||
if (disabled) return null;
|
||||
try {
|
||||
String val = redis.opsForValue().get(key);
|
||||
if (val != null) {
|
||||
return mapper.readValue(val, type);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
recordError("get", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public String getRaw(String key) {
|
||||
if (disabled) return null;
|
||||
try {
|
||||
return redis.opsForValue().get(key);
|
||||
} catch (Exception e) {
|
||||
recordError("getRaw", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void set(String key, Object value) {
|
||||
if (disabled) return;
|
||||
try {
|
||||
String json = mapper.writeValueAsString(value);
|
||||
redis.opsForValue().set(key, json, ttl);
|
||||
} catch (JsonProcessingException e) {
|
||||
recordError("set:serialize", e);
|
||||
} catch (Exception e) {
|
||||
recordError("set", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #336 — KEYS 블로킹 명령 대체.
|
||||
* SCAN으로 cursor 순회 후 UNLINK(논블로킹 삭제)로 일괄 삭제.
|
||||
*/
|
||||
public void flush() {
|
||||
if (disabled) return;
|
||||
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);
|
||||
}
|
||||
return deleted;
|
||||
});
|
||||
log.info("Cache flushed ({} keys via SCAN+UNLINK)", count == null ? 0 : count);
|
||||
}
|
||||
|
||||
private int unlinkBatch(org.springframework.data.redis.connection.RedisConnection conn, List<byte[]> keys) {
|
||||
try {
|
||||
Long n = conn.keyCommands().unlink(keys.toArray(new byte[0][]));
|
||||
return n == null ? 0 : n.intValue();
|
||||
} catch (Exception e) {
|
||||
// UNLINK 미지원 환경 대비 DEL 폴백
|
||||
recordError("flush:unlink", e);
|
||||
try {
|
||||
Long n = conn.keyCommands().del(keys.toArray(new byte[0][]));
|
||||
return n == null ? 0 : n.intValue();
|
||||
} catch (Exception delErr) {
|
||||
recordError("flush:del", delErr);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void del(String key) {
|
||||
if (disabled) return;
|
||||
try {
|
||||
redis.delete(key);
|
||||
} catch (Exception e) {
|
||||
recordError("del", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #336 — Redis 다운 → disabled=true, 재기동되면 자동으로 disabled=false.
|
||||
* 30초마다 ping 한 번(<1ms)이라 부하 미미.
|
||||
*/
|
||||
@Scheduled(fixedDelay = 30_000L)
|
||||
public void checkHealth() {
|
||||
boolean ok = pingOk();
|
||||
if (ok && disabled) {
|
||||
disabled = false;
|
||||
log.info("Redis recovered, caching re-enabled");
|
||||
} else if (!ok && !disabled) {
|
||||
disabled = true;
|
||||
log.warn("Redis lost, caching disabled");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean pingOk() {
|
||||
RedisConnectionFactory factory = redis.getConnectionFactory();
|
||||
if (factory == null) return false;
|
||||
try (var conn = factory.getConnection()) {
|
||||
conn.ping();
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
lastError = "ping: " + e.getMessage();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void recordError(String op, Exception e) {
|
||||
long n = errorCount.incrementAndGet();
|
||||
String msg = e.getMessage();
|
||||
lastError = op + ": " + (msg == null ? e.getClass().getSimpleName() : msg);
|
||||
// 한 번씩만 WARN, 나머지는 DEBUG로 (운영 로그 폭주 방지 — 단순한 throttle)
|
||||
if (n == 1 || n % 100 == 0) {
|
||||
log.warn("Cache {} error #{}: {}", op, n, lastError);
|
||||
} else {
|
||||
log.debug("Cache {} error #{}: {}", op, n, lastError);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isDisabled() {
|
||||
return disabled;
|
||||
}
|
||||
|
||||
public CacheStats getStats() {
|
||||
return new CacheStats(disabled, errorCount.get(), lastError);
|
||||
}
|
||||
|
||||
public record CacheStats(boolean disabled, long errorCount, String lastError) {}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.domain.Channel;
|
||||
import com.tasteby.mapper.ChannelMapper;
|
||||
import com.tasteby.util.IdGenerator;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class ChannelService {
|
||||
|
||||
private final ChannelMapper mapper;
|
||||
|
||||
public ChannelService(ChannelMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public List<Channel> findAllActive() {
|
||||
return mapper.findAllActive();
|
||||
}
|
||||
|
||||
public String create(String channelId, String channelName, String titleFilter) {
|
||||
String id = IdGenerator.newId();
|
||||
mapper.insert(id, channelId, channelName, titleFilter);
|
||||
return id;
|
||||
}
|
||||
|
||||
public boolean deactivate(String 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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;
|
||||
|
||||
@Service
|
||||
public class DaemonConfigService {
|
||||
|
||||
private final DaemonConfigMapper mapper;
|
||||
|
||||
public DaemonConfigService(DaemonConfigMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public DaemonConfig getConfig() {
|
||||
return mapper.getConfig();
|
||||
}
|
||||
|
||||
public void updateConfig(Map<String, Object> body) {
|
||||
DaemonConfig current = mapper.getConfig();
|
||||
if (current == null) return;
|
||||
|
||||
if (body.containsKey("scan_enabled")) {
|
||||
current.setScanEnabled(Boolean.TRUE.equals(body.get("scan_enabled")));
|
||||
}
|
||||
if (body.containsKey("scan_interval_min")) {
|
||||
// #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(requirePositiveInt(body.get("process_interval_min"), "process_interval_min"));
|
||||
}
|
||||
if (body.containsKey("process_limit")) {
|
||||
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();
|
||||
}
|
||||
|
||||
public void updateLastProcess() {
|
||||
mapper.updateLastProcess();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
|
||||
/**
|
||||
* Background daemon that periodically scans channels and processes pending videos.
|
||||
*/
|
||||
@Service
|
||||
public class DaemonScheduler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DaemonScheduler.class);
|
||||
|
||||
private final DaemonConfigService daemonConfigService;
|
||||
private final YouTubeService youTubeService;
|
||||
private final PipelineService pipelineService;
|
||||
private final CacheService cacheService;
|
||||
|
||||
@Value("${app.daemon.enabled:true}")
|
||||
private boolean instanceEnabled;
|
||||
|
||||
public DaemonScheduler(DaemonConfigService daemonConfigService,
|
||||
YouTubeService youTubeService,
|
||||
PipelineService pipelineService,
|
||||
CacheService cacheService) {
|
||||
this.daemonConfigService = daemonConfigService;
|
||||
this.youTubeService = youTubeService;
|
||||
this.pipelineService = pipelineService;
|
||||
this.cacheService = cacheService;
|
||||
}
|
||||
|
||||
@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;
|
||||
|
||||
if (config.isScanEnabled()) {
|
||||
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 = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.isProcessEnabled()) {
|
||||
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 = 0;
|
||||
try {
|
||||
restaurants = pipelineService.processPending(config.getProcessLimit());
|
||||
} finally {
|
||||
daemonConfigService.updateLastProcess();
|
||||
}
|
||||
if (restaurants > 0) {
|
||||
cacheService.flush();
|
||||
log.info("Processing completed: {} restaurants extracted", restaurants);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Daemon scheduler error: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private DaemonConfig getConfig() {
|
||||
try {
|
||||
return daemonConfigService.getConfig();
|
||||
} catch (Exception e) {
|
||||
log.debug("Cannot read daemon config: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.util.CuisineTypes;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class ExtractorService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ExtractorService.class);
|
||||
|
||||
private final OciGenAiService genAi;
|
||||
|
||||
private static final String EXTRACT_PROMPT = """
|
||||
다음은 유튜브 먹방/맛집 영상의 자막입니다.
|
||||
이 영상에서 언급된 모든 식당 정보를 추출하세요.
|
||||
|
||||
규칙:
|
||||
- 식당이 없으면 빈 배열 [] 반환
|
||||
- 각 식당에 대해 아래 필드를 JSON 배열로 반환
|
||||
- 확실하지 않은 정보는 null
|
||||
- 추가 설명 없이 JSON만 반환
|
||||
- 무조건 한글로 만들어주세요
|
||||
|
||||
필드:
|
||||
- name: 식당 이름 (string, 필수)
|
||||
- address: 주소 또는 위치 힌트 (string | null)
|
||||
- region: 지역을 "나라|시/도|구/군/시" 파이프(|) 구분 형식으로 작성 (string | null)
|
||||
- 한국 예시: "한국|서울|강남구", "한국|부산|해운대구", "한국|제주", "한국|강원|강릉시"
|
||||
- 해외 예시: "일본|도쿄", "일본|오사카", "싱가포르", "미국|뉴욕", "태국|방콕"
|
||||
- 나라는 한글로, 해외 도시도 한글로 표기
|
||||
- cuisine_type: 아래 목록에서 가장 적합한 것을 선택 (string, 필수). 반드시 아래 목록 중 하나를 사용:
|
||||
%s
|
||||
- price_range: 가격대 (예: 1만원대, 2-3만원) (string | null)
|
||||
- foods_mentioned: 언급된 대표 메뉴 (string[], 최대 10개, 우선순위 높은 순, 반드시 한글로 작성)
|
||||
- evaluation: 평가 내용을 100자 이내로 요약 (string | null)
|
||||
- guests: 함께한 게스트 (string[])
|
||||
|
||||
영상 제목: {title}
|
||||
자막:
|
||||
{transcript}
|
||||
|
||||
JSON 배열:""".formatted(CuisineTypes.CUISINE_LIST_TEXT);
|
||||
|
||||
public ExtractorService(OciGenAiService genAi) {
|
||||
this.genAi = genAi;
|
||||
}
|
||||
|
||||
public String getPrompt() {
|
||||
return EXTRACT_PROMPT;
|
||||
}
|
||||
|
||||
public record ExtractionResult(List<Map<String, Object>> restaurants, String rawResponse) {}
|
||||
|
||||
/**
|
||||
* Extract restaurant info from a video transcript using LLM.
|
||||
*/
|
||||
@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);
|
||||
}
|
||||
|
||||
String template = customPrompt != null ? customPrompt : EXTRACT_PROMPT;
|
||||
String prompt = template.replace("{title}", title).replace("{transcript}", transcript);
|
||||
|
||||
try {
|
||||
String raw = genAi.chat(prompt, 8192);
|
||||
Object result = genAi.parseJson(raw);
|
||||
if (result instanceof List<?> list) {
|
||||
return new ExtractionResult((List<Map<String, Object>>) list, raw);
|
||||
}
|
||||
if (result instanceof Map<?, ?> map) {
|
||||
return new ExtractionResult(List.of((Map<String, Object>) map), raw);
|
||||
}
|
||||
return new ExtractionResult(Collections.emptyList(), raw);
|
||||
} catch (Exception e) {
|
||||
log.error("Restaurant extraction failed: {}", e.getMessage());
|
||||
return new ExtractionResult(Collections.emptyList(), "");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
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 org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class GeocodingService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GeocodingService.class);
|
||||
|
||||
private final WebClient webClient;
|
||||
private final ObjectMapper mapper;
|
||||
private final String apiKey;
|
||||
|
||||
public GeocodingService(ObjectMapper mapper,
|
||||
@Value("${app.google.maps-api-key}") String apiKey) {
|
||||
this.webClient = WebClient.builder()
|
||||
.baseUrl("https://maps.googleapis.com/maps/api")
|
||||
.build();
|
||||
this.mapper = mapper;
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up restaurant coordinates via Google Maps.
|
||||
* Tries Places Text Search first, falls back to Geocoding API.
|
||||
*/
|
||||
public Map<String, Object> geocodeRestaurant(String name, String address) {
|
||||
String query = name;
|
||||
if (address != null && !address.isBlank()) {
|
||||
query += " " + address;
|
||||
}
|
||||
|
||||
// Try Places Text Search
|
||||
Map<String, Object> result = placesTextSearch(query);
|
||||
if (result != null) return result;
|
||||
|
||||
// Fallback: Geocoding
|
||||
return geocode(query);
|
||||
}
|
||||
|
||||
private Map<String, Object> placesTextSearch(String query) {
|
||||
try {
|
||||
String response = webClient.get()
|
||||
.uri(uriBuilder -> uriBuilder.path("/place/textsearch/json")
|
||||
.queryParam("query", query)
|
||||
.queryParam("key", apiKey)
|
||||
.queryParam("language", "ko")
|
||||
.queryParam("type", "restaurant")
|
||||
.build())
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofSeconds(10));
|
||||
|
||||
JsonNode data = mapper.readTree(response);
|
||||
if (!"OK".equals(data.path("status").asText()) || !data.path("results").has(0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
JsonNode place = data.path("results").get(0);
|
||||
JsonNode loc = place.path("geometry").path("location");
|
||||
|
||||
var result = new HashMap<String, Object>();
|
||||
result.put("latitude", loc.path("lat").asDouble());
|
||||
result.put("longitude", loc.path("lng").asDouble());
|
||||
result.put("formatted_address", place.path("formatted_address").asText(""));
|
||||
result.put("google_place_id", place.path("place_id").asText(""));
|
||||
|
||||
if (!place.path("business_status").isMissingNode()) {
|
||||
result.put("business_status", place.path("business_status").asText());
|
||||
}
|
||||
if (!place.path("rating").isMissingNode()) {
|
||||
result.put("rating", place.path("rating").asDouble());
|
||||
}
|
||||
if (!place.path("user_ratings_total").isMissingNode()) {
|
||||
result.put("rating_count", place.path("user_ratings_total").asInt());
|
||||
}
|
||||
|
||||
// Fetch phone/website from Place Details
|
||||
String placeId = place.path("place_id").asText(null);
|
||||
if (placeId != null) {
|
||||
var details = placeDetails(placeId);
|
||||
if (details != null) {
|
||||
result.putAll(details);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
log.warn("Places text search failed for '{}': {}", query, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> placeDetails(String placeId) {
|
||||
try {
|
||||
String response = webClient.get()
|
||||
.uri(uriBuilder -> uriBuilder.path("/place/details/json")
|
||||
.queryParam("place_id", placeId)
|
||||
.queryParam("key", apiKey)
|
||||
.queryParam("language", "ko")
|
||||
.queryParam("fields", "formatted_phone_number,website")
|
||||
.build())
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofSeconds(10));
|
||||
|
||||
JsonNode data = mapper.readTree(response);
|
||||
if (!"OK".equals(data.path("status").asText())) return null;
|
||||
|
||||
JsonNode res = data.path("result");
|
||||
var details = new HashMap<String, Object>();
|
||||
if (!res.path("formatted_phone_number").isMissingNode()) {
|
||||
details.put("phone", res.path("formatted_phone_number").asText());
|
||||
}
|
||||
if (!res.path("website").isMissingNode()) {
|
||||
details.put("website", res.path("website").asText());
|
||||
}
|
||||
return details;
|
||||
} catch (Exception e) {
|
||||
log.warn("Place details failed for '{}': {}", placeId, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
.uri(uriBuilder -> uriBuilder.path("/geocode/json")
|
||||
.queryParam("address", query)
|
||||
.queryParam("key", apiKey)
|
||||
.queryParam("language", "ko")
|
||||
.build())
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofSeconds(10));
|
||||
|
||||
JsonNode data = mapper.readTree(response);
|
||||
if (!"OK".equals(data.path("status").asText()) || !data.path("results").has(0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
JsonNode result = data.path("results").get(0);
|
||||
JsonNode loc = result.path("geometry").path("location");
|
||||
|
||||
var map = new HashMap<String, Object>();
|
||||
map.put("latitude", loc.path("lat").asDouble());
|
||||
map.put("longitude", loc.path("lng").asDouble());
|
||||
map.put("formatted_address", result.path("formatted_address").asText(""));
|
||||
map.put("google_place_id", "");
|
||||
return map;
|
||||
} catch (Exception e) {
|
||||
log.warn("Geocoding failed for '{}': {}", query, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.oracle.bmc.ConfigFileReader;
|
||||
import com.oracle.bmc.auth.ConfigFileAuthenticationDetailsProvider;
|
||||
import com.oracle.bmc.generativeaiinference.GenerativeAiInferenceClient;
|
||||
import com.oracle.bmc.generativeaiinference.model.*;
|
||||
import com.oracle.bmc.generativeaiinference.requests.ChatRequest;
|
||||
import com.oracle.bmc.generativeaiinference.requests.EmbedTextRequest;
|
||||
import com.oracle.bmc.generativeaiinference.responses.ChatResponse;
|
||||
import com.oracle.bmc.generativeaiinference.responses.EmbedTextResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Service
|
||||
public class OciGenAiService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OciGenAiService.class);
|
||||
private static final int EMBED_BATCH_SIZE = 96;
|
||||
|
||||
@Value("${app.oci.compartment-id}")
|
||||
private String compartmentId;
|
||||
|
||||
@Value("${app.oci.chat-endpoint}")
|
||||
private String chatEndpoint;
|
||||
|
||||
@Value("${app.oci.embed-endpoint}")
|
||||
private String embedEndpoint;
|
||||
|
||||
@Value("${app.oci.chat-model-id}")
|
||||
private String chatModelId;
|
||||
|
||||
@Value("${app.oci.embed-model-id}")
|
||||
private String embedModelId;
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
private ConfigFileAuthenticationDetailsProvider authProvider;
|
||||
private GenerativeAiInferenceClient chatClient;
|
||||
private GenerativeAiInferenceClient embedClient;
|
||||
|
||||
public OciGenAiService(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
try {
|
||||
ConfigFileReader.ConfigFile configFile = ConfigFileReader.parseDefault();
|
||||
authProvider = new ConfigFileAuthenticationDetailsProvider(configFile);
|
||||
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 (chatClient == null) throw new IllegalStateException("OCI GenAI not configured");
|
||||
|
||||
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 chatDetails = ChatDetails.builder()
|
||||
.compartmentId(compartmentId)
|
||||
.servingMode(OnDemandServingMode.builder().modelId(chatModelId).build())
|
||||
.chatRequest(chatRequest)
|
||||
.build();
|
||||
|
||||
ChatResponse response = chatClient.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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate embeddings for a list of texts.
|
||||
*/
|
||||
public List<List<Double>> embedTexts(List<String> texts) {
|
||||
if (authProvider == null) throw new IllegalStateException("OCI GenAI not configured");
|
||||
|
||||
List<List<Double>> allEmbeddings = new ArrayList<>();
|
||||
for (int i = 0; i < texts.size(); i += EMBED_BATCH_SIZE) {
|
||||
List<String> batch = texts.subList(i, Math.min(i + EMBED_BATCH_SIZE, texts.size()));
|
||||
allEmbeddings.addAll(embedBatch(batch));
|
||||
}
|
||||
return allEmbeddings;
|
||||
}
|
||||
|
||||
private List<List<Double>> embedBatch(List<String> texts) {
|
||||
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();
|
||||
|
||||
EmbedTextResponse response = embedClient.embedText(
|
||||
EmbedTextRequest.builder().embedTextDetails(embedDetails).build());
|
||||
|
||||
return response.getEmbedTextResult().getEmbeddings()
|
||||
.stream()
|
||||
.map(emb -> emb.stream().map(Number::doubleValue).toList())
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse LLM response as JSON (handles markdown code blocks, truncated arrays, etc.)
|
||||
*/
|
||||
public Object parseJson(String raw) {
|
||||
// Strip markdown code blocks
|
||||
raw = raw.replaceAll("(?m)^```(?:json)?\\s*|\\s*```$", "").trim();
|
||||
// Remove trailing commas
|
||||
raw = raw.replaceAll(",\\s*([}\\]])", "$1");
|
||||
|
||||
try {
|
||||
return mapper.readValue(raw, Object.class);
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
// Try to recover truncated array
|
||||
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;
|
||||
|
||||
// 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) {}
|
||||
}
|
||||
if (!found) break;
|
||||
}
|
||||
if (!items.isEmpty()) {
|
||||
log.info("Recovered {} items from truncated JSON", items.size());
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException("JSON parse failed: " + raw.substring(0, Math.min(80, raw.length())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.util.JsonUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Data pipeline: process pending videos end-to-end.
|
||||
* 1. Fetch transcript
|
||||
* 2. Extract restaurant info via LLM
|
||||
* 3. Geocode each restaurant
|
||||
* 4. Save to DB + generate vector embeddings
|
||||
*/
|
||||
@Service
|
||||
public class PipelineService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PipelineService.class);
|
||||
|
||||
private final YouTubeService youTubeService;
|
||||
private final ExtractorService extractorService;
|
||||
private final GeocodingService geocodingService;
|
||||
private final RestaurantService restaurantService;
|
||||
private final VideoService videoService;
|
||||
private final VectorService vectorService;
|
||||
private final CacheService cacheService;
|
||||
private final RestaurantVerifyService verifyService;
|
||||
|
||||
public PipelineService(YouTubeService youTubeService,
|
||||
ExtractorService extractorService,
|
||||
GeocodingService geocodingService,
|
||||
RestaurantService restaurantService,
|
||||
VideoService videoService,
|
||||
VectorService vectorService,
|
||||
CacheService cacheService,
|
||||
RestaurantVerifyService verifyService) {
|
||||
this.youTubeService = youTubeService;
|
||||
this.extractorService = extractorService;
|
||||
this.geocodingService = geocodingService;
|
||||
this.restaurantService = restaurantService;
|
||||
this.videoService = videoService;
|
||||
this.vectorService = vectorService;
|
||||
this.cacheService = cacheService;
|
||||
this.verifyService = verifyService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single pending video. Returns number of restaurants found.
|
||||
*/
|
||||
public int processVideo(Map<String, Object> video) {
|
||||
String videoDbId = (String) video.get("id");
|
||||
String videoId = (String) video.get("video_id");
|
||||
String title = (String) video.get("title");
|
||||
|
||||
log.info("Processing video: {} ({})", title, videoId);
|
||||
updateVideoStatus(videoDbId, "processing", null, null);
|
||||
|
||||
try {
|
||||
// 1. Transcript
|
||||
var transcript = youTubeService.getTranscript(videoId, "auto");
|
||||
if (transcript == null || transcript.text() == null) {
|
||||
log.warn("No transcript for {}, marking done", videoId);
|
||||
updateVideoStatus(videoDbId, "done", null, null);
|
||||
return 0;
|
||||
}
|
||||
updateVideoStatus(videoDbId, "processing", transcript.text(), null);
|
||||
|
||||
// 2. LLM extraction + geocode + save
|
||||
return processExtract(video, transcript.text(), null);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Pipeline error for {}: {}", videoId, e.getMessage(), e);
|
||||
updateVideoStatus(videoDbId, "error", null, null);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run LLM extraction + geocode + save on existing transcript.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public int processExtract(Map<String, Object> video, String transcript, String customPrompt) {
|
||||
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());
|
||||
return 0;
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
for (var restData : result.restaurants()) {
|
||||
String name = (String) restData.get("name");
|
||||
if (name == null) continue;
|
||||
|
||||
// Geocode
|
||||
var geo = geocodingService.geocodeRestaurant(
|
||||
name, (String) restData.get("address"));
|
||||
|
||||
// Build upsert data
|
||||
var data = new HashMap<String, Object>();
|
||||
data.put("name", name);
|
||||
data.put("region", restData.get("region"));
|
||||
data.put("cuisine_type", restData.get("cuisine_type"));
|
||||
data.put("price_range", restData.get("price_range"));
|
||||
// #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);
|
||||
|
||||
// Link video <-> restaurant
|
||||
var foods = restData.get("foods_mentioned");
|
||||
var evaluationRaw = restData.get("evaluation");
|
||||
var guests = restData.get("guests");
|
||||
|
||||
// evaluation must be stored as valid JSON (DB has IS JSON check constraint)
|
||||
// Store as JSON string literal: "평가 내용" (valid JSON)
|
||||
String evaluationJson = null;
|
||||
if (evaluationRaw instanceof Map<?, ?>) {
|
||||
evaluationJson = JsonUtil.toJson(evaluationRaw);
|
||||
} else if (evaluationRaw instanceof String s && !s.isBlank()) {
|
||||
evaluationJson = JsonUtil.toJson(s);
|
||||
}
|
||||
|
||||
restaurantService.linkVideoRestaurant(
|
||||
videoDbId, restId,
|
||||
foods instanceof List<?> ? (List<String>) foods : null,
|
||||
evaluationJson,
|
||||
guests instanceof List<?> ? (List<String>) guests : null
|
||||
);
|
||||
|
||||
// Vector embeddings
|
||||
var chunks = VectorService.buildChunks(name, restData, title);
|
||||
if (!chunks.isEmpty()) {
|
||||
try {
|
||||
vectorService.saveRestaurantVectors(restId, chunks);
|
||||
} catch (Exception e) {
|
||||
log.warn("Vector save failed for {}: {}", name, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
count++;
|
||||
log.info("Saved restaurant: {} (geocoded={})", name, geo != null);
|
||||
|
||||
// #322 — 등록 직후 비동기 LLM 검증
|
||||
verifyService.verifyAsync(restId);
|
||||
}
|
||||
|
||||
updateVideoStatus(videoDbId, "done", null, result.rawResponse());
|
||||
log.info("Video {} done: {} restaurants", video.get("video_id"), count);
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process up to `limit` pending videos.
|
||||
*/
|
||||
public int processPending(int limit) {
|
||||
var videos = videoService.findPendingVideos(limit);
|
||||
if (videos.isEmpty()) {
|
||||
log.info("No pending videos");
|
||||
return 0;
|
||||
}
|
||||
int total = 0;
|
||||
for (var v : videos) {
|
||||
total += processVideo(v);
|
||||
}
|
||||
if (total > 0) cacheService.flush();
|
||||
return total;
|
||||
}
|
||||
|
||||
private void updateVideoStatus(String videoDbId, String status, String transcript, String llmRaw) {
|
||||
videoService.updateVideoFields(videoDbId, status, transcript, llmRaw);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import com.tasteby.mapper.RestaurantMapper;
|
||||
import com.tasteby.util.IdGenerator;
|
||||
import com.tasteby.util.JsonUtil;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class RestaurantService {
|
||||
|
||||
private final RestaurantMapper mapper;
|
||||
|
||||
public RestaurantService(RestaurantMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String 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;
|
||||
enrichRestaurants(List.of(restaurant));
|
||||
return restaurant;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findVideoLinks(String restaurantId) {
|
||||
var rows = mapper.findVideoLinks(restaurantId);
|
||||
return rows.stream().map(row -> {
|
||||
var m = JsonUtil.lowerKeys(row);
|
||||
m.put("foods_mentioned", JsonUtil.parseStringList(m.get("foods_mentioned")));
|
||||
m.put("evaluation", JsonUtil.parseMap(m.get("evaluation")));
|
||||
m.put("guests", JsonUtil.parseStringList(m.get("guests")));
|
||||
return m;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
public void update(String id, Map<String, Object> fields) {
|
||||
mapper.updateFields(id, fields);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(String id) {
|
||||
mapper.deleteVectors(id);
|
||||
mapper.deleteReviews(id);
|
||||
mapper.deleteFavorites(id);
|
||||
mapper.deleteVideoRestaurants(id);
|
||||
mapper.deleteRestaurant(id);
|
||||
}
|
||||
|
||||
public String upsert(Map<String, Object> data) {
|
||||
String placeId = (String) data.get("google_place_id");
|
||||
String existingId = null;
|
||||
if (placeId != null && !placeId.isBlank()) {
|
||||
existingId = mapper.findIdByPlaceId(placeId);
|
||||
}
|
||||
if (existingId == null) {
|
||||
existingId = mapper.findIdByName((String) data.get("name"));
|
||||
}
|
||||
|
||||
Restaurant r = Restaurant.builder()
|
||||
.name(truncateBytes((String) data.get("name"), 200))
|
||||
.address(truncateBytes((String) data.get("address"), 500))
|
||||
.region((String) data.get("region"))
|
||||
.latitude(data.get("latitude") instanceof Number n ? n.doubleValue() : null)
|
||||
.longitude(data.get("longitude") instanceof Number n ? n.doubleValue() : null)
|
||||
.cuisineType((String) data.get("cuisine_type"))
|
||||
.priceRange((String) data.get("price_range"))
|
||||
.googlePlaceId(placeId)
|
||||
.phone((String) data.get("phone"))
|
||||
.website((String) data.get("website"))
|
||||
.businessStatus((String) data.get("business_status"))
|
||||
.rating(data.get("rating") instanceof Number n ? n.doubleValue() : null)
|
||||
.ratingCount(data.get("rating_count") instanceof Number n ? n.intValue() : null)
|
||||
.build();
|
||||
|
||||
if (existingId != null) {
|
||||
r.setId(existingId);
|
||||
mapper.updateRestaurant(r);
|
||||
return existingId;
|
||||
} else {
|
||||
String newId = IdGenerator.newId();
|
||||
r.setId(newId);
|
||||
mapper.insertRestaurant(r);
|
||||
return newId;
|
||||
}
|
||||
}
|
||||
|
||||
public void 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;
|
||||
String evalJson = JsonUtil.normalizeEvaluation(evaluation);
|
||||
mapper.linkVideoRestaurant(id, videoId, restaurantId, foodsJson, evalJson, guestsJson);
|
||||
}
|
||||
|
||||
public void updateCuisineType(String id, String cuisineType) {
|
||||
mapper.updateCuisineType(id, cuisineType);
|
||||
}
|
||||
|
||||
public void updateFoodsMentioned(String id, String foods) {
|
||||
mapper.updateFoodsMentioned(id, foods);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findForRemapCuisine() {
|
||||
return mapper.findForRemapCuisine();
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findForRemapFoods() {
|
||||
return mapper.findForRemapFoods();
|
||||
}
|
||||
|
||||
private void enrichRestaurants(List<Restaurant> restaurants) {
|
||||
if (restaurants.isEmpty()) return;
|
||||
List<String> ids = restaurants.stream().map(Restaurant::getId).filter(Objects::nonNull).toList();
|
||||
if (ids.isEmpty()) return;
|
||||
|
||||
// Channels
|
||||
List<Map<String, Object>> channelRows = mapper.findChannelsByRestaurantIds(ids);
|
||||
Map<String, List<String>> channelMap = new HashMap<>();
|
||||
for (var row : channelRows) {
|
||||
String rid = (String) row.getOrDefault("restaurant_id", row.get("RESTAURANT_ID"));
|
||||
String ch = (String) row.getOrDefault("channel_name", row.get("CHANNEL_NAME"));
|
||||
if (rid != null && ch != null) {
|
||||
channelMap.computeIfAbsent(rid, k -> new ArrayList<>()).add(ch);
|
||||
}
|
||||
}
|
||||
|
||||
// Foods
|
||||
List<Map<String, Object>> foodRows = mapper.findFoodsByRestaurantIds(ids);
|
||||
Map<String, Set<String>> foodMap = new HashMap<>();
|
||||
for (var row : foodRows) {
|
||||
String rid = (String) row.getOrDefault("restaurant_id", row.get("RESTAURANT_ID"));
|
||||
Object foods = row.getOrDefault("foods_mentioned", row.get("FOODS_MENTIONED"));
|
||||
if (rid != null && foods != null) {
|
||||
List<String> parsed = JsonUtil.parseStringList(foods);
|
||||
foodMap.computeIfAbsent(rid, k -> new LinkedHashSet<>()).addAll(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
for (var r : restaurants) {
|
||||
r.setChannels(channelMap.getOrDefault(r.getId(), List.of()));
|
||||
Set<String> foods = foodMap.get(r.getId());
|
||||
r.setFoodsMentioned(foods != null ? new ArrayList<>(foods) : List.of());
|
||||
}
|
||||
}
|
||||
|
||||
private String truncateBytes(String s, int maxBytes) {
|
||||
if (s == null) return null;
|
||||
byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length <= maxBytes) return s;
|
||||
return new String(bytes, 0, maxBytes, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -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"); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.domain.Restaurant;
|
||||
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;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
public class ReviewService {
|
||||
|
||||
private final ReviewMapper mapper;
|
||||
|
||||
public ReviewService(ReviewMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public List<Review> findByRestaurant(String restaurantId, int limit, int offset) {
|
||||
return mapper.findByRestaurant(restaurantId, limit, offset);
|
||||
}
|
||||
|
||||
public Map<String, Object> getAvgRating(String restaurantId) {
|
||||
Map<String, Object> result = mapper.getAvgRating(restaurantId);
|
||||
return result != null ? JsonUtil.lowerKeys(result) : Map.of("avg_rating", 0.0, "review_count", 0);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Review create(String userId, String restaurantId, double rating, String reviewText, LocalDate visitedAt) {
|
||||
String id = IdGenerator.newId();
|
||||
String visitedStr = visitedAt != null ? visitedAt.toString() : null;
|
||||
mapper.insertReview(id, userId, restaurantId, rating, reviewText, visitedStr);
|
||||
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;
|
||||
}
|
||||
|
||||
public List<Review> findByUser(String userId, int limit, int offset) {
|
||||
return mapper.findByUser(userId, limit, offset);
|
||||
}
|
||||
|
||||
public boolean isFavorited(String userId, String restaurantId) {
|
||||
return mapper.countFavorite(userId, restaurantId) > 0;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean toggleFavorite(String userId, String restaurantId) {
|
||||
String existingId = mapper.findFavoriteId(userId, restaurantId);
|
||||
if (existingId != null) {
|
||||
mapper.deleteFavorite(userId, restaurantId);
|
||||
return false;
|
||||
}
|
||||
// #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) {
|
||||
return mapper.getUserFavorites(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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.*;
|
||||
|
||||
@Service
|
||||
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,
|
||||
CacheService cache) {
|
||||
this.searchMapper = searchMapper;
|
||||
this.restaurantService = restaurantService;
|
||||
this.vectorService = vectorService;
|
||||
this.cache = cache;
|
||||
}
|
||||
|
||||
public List<Restaurant> search(String q, String mode, int limit) {
|
||||
String key = cache.makeKey("search", "q=" + q, "m=" + mode, "l=" + limit);
|
||||
String cached = cache.getRaw(key);
|
||||
if (cached != null) {
|
||||
try {
|
||||
// #293 — ObjectMapper 재사용 (필드 static)
|
||||
return JSON.readValue(cached, LIST_TYPE);
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
List<Restaurant> result;
|
||||
switch (mode) {
|
||||
case "semantic" -> result = semanticSearch(q, limit);
|
||||
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;
|
||||
}
|
||||
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);
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<Restaurant> keywordSearch(String q, int limit) {
|
||||
// #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);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private List<Restaurant> semanticSearch(String q, int limit) {
|
||||
try {
|
||||
var similar = vectorService.searchSimilar(q, Math.max(30, limit * 3), maxDistance);
|
||||
if (similar.isEmpty()) return List.of();
|
||||
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
for (var s : similar) {
|
||||
seen.add((String) s.get("restaurant_id"));
|
||||
}
|
||||
|
||||
List<Restaurant> results = new ArrayList<>();
|
||||
for (String rid : seen) {
|
||||
if (results.size() >= limit) break;
|
||||
var r = restaurantService.findById(rid);
|
||||
if (r != null && r.getLatitude() != null) {
|
||||
results.add(r);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
} catch (Exception e) {
|
||||
log.warn("Semantic search failed, falling back to keyword: {}", e.getMessage());
|
||||
return keywordSearch(q, limit);
|
||||
}
|
||||
}
|
||||
|
||||
private void attachChannels(List<Restaurant> restaurants) {
|
||||
List<String> ids = restaurants.stream().map(Restaurant::getId).filter(Objects::nonNull).toList();
|
||||
if (ids.isEmpty()) return;
|
||||
|
||||
var channelRows = searchMapper.findChannelsByRestaurantIds(ids);
|
||||
Map<String, List<String>> chMap = new HashMap<>();
|
||||
for (var row : channelRows) {
|
||||
String rid = (String) row.getOrDefault("restaurant_id", row.get("RESTAURANT_ID"));
|
||||
String ch = (String) row.getOrDefault("channel_name", row.get("CHANNEL_NAME"));
|
||||
if (rid != null && ch != null) {
|
||||
chMap.computeIfAbsent(rid, k -> new ArrayList<>()).add(ch);
|
||||
}
|
||||
}
|
||||
for (var r : restaurants) {
|
||||
r.setChannels(chMap.getOrDefault(r.getId(), List.of()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public void 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() {
|
||||
return SiteVisitStats.builder()
|
||||
.today(mapper.getTodayVisits())
|
||||
.total(mapper.getTotalVisits())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user