From 4a79555d1696d7213f673536ef0d7a54abdbc8d5 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 4 Sep 2026 16:40:31 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=99=95=EC=9D=B8=20=EC=99=84=EB=A3=8C?= =?UTF-8?q?=20=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FEEDBACK_DATA_FLOW.md | 528 ++++++++++++ FEEDBACK_SYNC_GUIDE.md | 259 ++++++ GITEA_VARIABLES.md | 291 +++---- README.md | 319 ++------ STAGING_DEPLOYMENT_CHECKLIST.md | 221 ++--- .../feedback/ui/feedback-detail-sheet.ui.tsx | 20 - .../lib/support-feedback-status.ts | 46 ++ .../ui/support-status-badge.ui.tsx | 28 +- .../[ticketId]/completion-confirmation.ts | 56 ++ .../support/[workspaceCode]/[ticketId].tsx | 206 +++-- .../pages/support/[workspaceCode]/list.tsx | 5 +- .../src/pages/support/[workspaceCode]/new.tsx | 45 +- apps/web/src/server/support-types.ts | 18 +- ...ration-feedback-requirements-2026-09-02.md | 766 ++++++++++++++++++ 14 files changed, 2114 insertions(+), 694 deletions(-) create mode 100644 FEEDBACK_DATA_FLOW.md create mode 100644 FEEDBACK_SYNC_GUIDE.md create mode 100644 apps/web/src/features/support-portal/lib/support-feedback-status.ts create mode 100644 apps/web/src/pages/api/support/tickets/[ticketId]/completion-confirmation.ts create mode 100644 docs/admin-console-integration-feedback-requirements-2026-09-02.md diff --git a/FEEDBACK_DATA_FLOW.md b/FEEDBACK_DATA_FLOW.md new file mode 100644 index 0000000..aa456db --- /dev/null +++ b/FEEDBACK_DATA_FLOW.md @@ -0,0 +1,528 @@ +# 피드백 작성 페이지와 관리페이지 데이터 흐름 + +## 1. 전체 구조 + +현재 구조는 피드백 작성 웹과 관리 콘솔이 분리되어 있습니다. 작성 웹은 직접 ABC API나 DB에 접근하지 않고, Next.js 서버 Proxy를 통해 외부 관리 콘솔 API에 요청합니다. + +```text +피드백 작성 페이지 +/support/{workspaceCode}/new + │ + │ POST /api/support/workspaces/{workspaceCode}/submit + ▼ +apps/web의 Next.js API Proxy + │ + │ POST /api/support/workspaces/{workspaceCode}/tickets + ▼ +외부 관리 콘솔 API +https://feedback.hmac.kr/api/support + │ + ├─ Secretary DB: support_tickets 저장 + ├─ ABC API: feedbacks 저장 + ├─ abc_feedback_mappings: 두 ID 연결 + └─ 첨부파일: Secretary Local/R2 저장 + ▼ +관리 콘솔에서 조회·처리 +``` + +피드백 전용 배포에서는 브라우저 요청을 same-origin으로 유지하고, 외부 관리 콘솔 주소는 서버 환경변수로만 사용합니다. + +관련 설정: + +- `NEXT_PUBLIC_FEEDBACK_ONLY=true` +- `SUPPORT_CONSOLE_API_BASE_URL=https://feedback.hmac.kr/api/support` +- `NEXT_PUBLIC_API_BASE_URL`은 빈 값으로 설정 + +## 1.1 전체 데이터 흐름도 + +```mermaid +flowchart LR + U[사용자] --> W[피드백 작성 페이지
/support/{workspaceCode}/new] + W -->|FormData
제목·내용·필드·첨부파일| P[Next.js 서버 Proxy] + P -->|POST /api/support/workspaces/{workspaceCode}/tickets| S[외부 관리 콘솔 API
Secretary] + + S -->|1. 내부 티켓 저장| T[(Secretary DB
baron_support)] + S -->|2. ABC 피드백 생성| A[ABC API] + A --> F[(ABC MySQL
feedbacks)] + S --> M[(abc_feedback_mappings)] + M -. ticket_id ↔ feedback_id .-> T + M -. ticket_id ↔ feedback_id .-> F + S -->|첨부파일 저장| X[(Secretary Local/R2
+ attachments 메타데이터)] + + T --> C[관리 콘솔 목록·상세 화면] + F --> C + X --> C +``` + +### 핵심 저장 위치 + +```mermaid +flowchart TD + D[피드백 등록 데이터] + D --> D1[ABC MySQL
feedbacks.data JSON
원본 피드백 본문] + D --> D2[Secretary DB
support_tickets
관리용 티켓·상태·권한] + D --> D3[Secretary DB
abc_feedback_mappings
두 시스템 ID 연결] + D --> D4[Secretary Local/R2
첨부파일 바이너리] + D --> D5[Secretary DB
attachments
첨부파일 메타데이터] + D1 -. 검색 활성화 시 .-> D6[OpenSearch
검색용 색인] +``` + +### ID 연결 구조 + +```mermaid +flowchart LR + W[workspace_code
예: EGBIM_DEMO] + W --> ST[Secretary ticket_id] + ST --> MAP[abc_feedback_mappings] + MAP --> AF[ABC feedback_id] + AF --> CH[ABC project_id / channel_id] +``` + +`ticket_id`와 `feedback_id`는 서로 다른 시스템에서 생성되는 별도 ID입니다. 관리 콘솔은 `abc_feedback_mappings` 또는 응답의 `extra_fields.abc_feedback_id`를 이용해 두 데이터를 결합합니다. + +## 1.2 네이버웍스 알림 흐름도 + +피드백 등록 자체는 먼저 ABC와 Secretary에 저장되고, 저장 완료 후 ABC 백엔드 이벤트를 통해 네이버웍스 알림이 발송됩니다. + +```mermaid +sequenceDiagram + participant User as 사용자 + participant Web as 작성 웹 + participant Secretary as Secretary API + participant ABC as ABC API + participant ABCDB as ABC MySQL + participant Notify as 알림 서비스 + participant NW as 네이버웍스 API + participant Log as notification_deliveries + + User->>Web: 피드백 등록 + Web->>Secretary: POST /workspaces/{code}/tickets + Secretary->>Secretary: support_tickets 저장 + Secretary->>ABC: feedback 생성 요청 + ABC->>ABCDB: feedbacks 저장 + ABC-->>Notify: FEEDBACK_CREATION 이벤트 + Notify->>Notify: 담당자·프로젝트 관리자·작성자 계산 + Notify->>NW: 이메일로 사용자 ID 조회 + NW-->>Notify: 네이버웍스 userId + Notify->>NW: Bot 개인 메시지 발송 + Notify->>Log: SENT / FAILED / SKIPPED 기록 + ABC-->>Secretary: abc_feedback_id 반환 + Secretary-->>Web: ticket_id·동기화 상태 반환 +``` + +## 2. 피드백 작성 페이지 + +작성 페이지는 다음 파일입니다. + +`apps/web/src/pages/support/[workspaceCode]/new.tsx` + +### 양식 조회 + +페이지 진입 시 workspace별 양식을 조회합니다. + +```text +GET /api/support/workspaces/{workspaceCode}/form-template +``` + +Next.js API Proxy는 이를 외부 관리 콘솔 API로 전달합니다. + +```text +GET {SUPPORT_CONSOLE_API_BASE_URL}/workspaces/{workspaceCode}/form-template +``` + +양식의 workspace code는 프로젝트와 ABC 프로젝트·채널을 연결하는 식별자입니다. + +### 등록 요청 + +사용자가 등록하면 브라우저는 `FormData`를 생성합니다. + +주요 필드는 다음과 같습니다. + +- `title` +- `description` +- `category_code` +- `is_secret` +- `ticket_type` +- `requires_approval` +- `extra_fields` +- `attachments` + +작성 페이지는 다음 내부 API를 호출합니다. + +```text +POST /api/support/workspaces/{workspaceCode}/submit +``` + +관련 파일: + +`apps/web/src/pages/api/support/workspaces/[workspaceCode]/submit.ts` + +이 API는 multipart 요청을 파싱한 후 외부 관리 콘솔로 다시 전달합니다. + +```text +POST {SUPPORT_CONSOLE_API_BASE_URL}/workspaces/{workspaceCode}/tickets +``` + +인증 정보는 브라우저에서 별도 입력받지 않고, 현재 로그인된 BARON-SSO 세션의 Authorization 헤더를 Proxy가 전달합니다. + +## 3. Secretary API에서의 저장 + +Secretary API의 티켓 생성 로직은 다음 파일에 있습니다. + +`apps/secretary-api/app/services/ticket_service.py` + +티켓 등록 시 다음 순서로 처리됩니다. + +1. `support_tickets`에 내부 관리 티켓을 생성합니다. +2. workspace에 매핑된 ABC 프로젝트와 채널을 확인합니다. +3. ABC API에 피드백을 생성합니다. +4. `abc_feedback_mappings`에 Secretary 티켓과 ABC 피드백의 연결 정보를 저장합니다. +5. 첨부파일을 Secretary 저장소에 저장합니다. +6. 동기화 상태를 `SYNCED`로 변경합니다. +7. 티켓 ID와 동기화 상태를 작성 웹에 반환합니다. + +API 라우트: + +```text +POST /api/workspaces/{workspaceCode}/tickets +``` + +구현 파일: + +`apps/secretary-api/app/api/routes/tickets.py` + +## 4. 데이터베이스 구조 + +### Secretary DB + +Secretary 쪽에는 관리 업무용 데이터가 저장됩니다. + +| 테이블 | 역할 | +| --- | --- | +| `support_tickets` | 내부 관리 티켓 본체 | +| `abc_feedback_mappings` | Secretary 티켓 ID와 ABC 피드백 ID 연결 | +| `ticket_comments` | 사용자 댓글 및 내부 메모 | +| `attachments` | 첨부파일 메타데이터 | +| `user_workspace_access` | 사용자별 workspace 권한 | + +주요 모델은 다음 파일에 정의되어 있습니다. + +`apps/secretary-api/app/db/models.py` + +### ABC DB + +ABC 쪽에는 기존 피드백 데이터가 저장됩니다. + +| 테이블 | 역할 | +| --- | --- | +| `feedbacks` | 피드백 본문과 동적 필드를 `data` JSON으로 저장 | +| `channels` | 피드백이 소속된 채널 | +| `feedbacks_issues_issues` | 피드백과 이슈의 다대다 연결 | + +ABC 피드백 엔티티: + +`apps/api/src/domains/admin/feedback/feedback.entity.ts` + +## 5. 티켓 ID와 피드백 ID + +Secretary 티켓 ID와 ABC 피드백 ID는 서로 다른 식별자입니다. + +```text +Secretary ticket_id = 관리 티켓 식별자 +ABC feedback_id = ABC 피드백 식별자 +abc_feedback_mappings = 두 식별자를 연결하는 매핑 +``` + +관리 API 응답에서는 보통 다음과 같이 ABC ID가 함께 노출됩니다. + +```json +{ + "ticket_id": 101, + "extra_fields": { + "abc_feedback_id": "345" + } +} +``` + +이 매핑을 이용해 관리 콘솔은 티켓 정보와 ABC 피드백 정보, 이슈 연결 정보를 함께 처리합니다. + +## 6. 관리페이지 조회 흐름 + +실제 운영 관리 콘솔은 Secretary API를 기준으로 티켓 목록을 조회합니다. + +```text +GET /api/support/workspaces/{workspaceCode}/tickets + │ + ├─ support_tickets 조회 + ├─ abc_feedback_mappings 확인 + ├─ 담당자·권한 정보 결합 + ├─ 댓글·첨부파일 정보 결합 + └─ ABC 피드백과 상태 동기화 +``` + +피드백 전용 모드에서 이 저장소의 목록 Proxy는 외부 관리 콘솔 API를 그대로 호출합니다. + +관련 파일: + +`apps/web/src/pages/api/support/workspaces/[workspaceCode]/tickets.ts` + +관리 콘솔에서 처리하는 주요 데이터는 다음과 같습니다. + +- 피드백 목록과 상세 내용 +- 피드백 처리 상태 +- 담당자 지정 +- 사용자 댓글 +- 내부 메모 +- 첨부파일 +- ABC 이슈 연결 상태 + +## 7. 이 저장소에 있는 기존 ABC 관리자 화면 + +이 저장소에는 기존 ABC 관리자 화면도 남아 있습니다. + +```text +/main/project/{projectId}/feedback +``` + +페이지: + +`apps/web/src/pages/main/project/[projectId]/feedback.tsx` + +이 화면은 Secretary 티켓 목록이 아니라 ABC 관리자 API를 직접 조회합니다. + +```text +POST /api/admin/projects/{projectId}/channels/{channelId}/feedbacks/search +``` + +ABC API의 검색 요청은 다음 서버 로직으로 처리됩니다. + +`apps/api/src/domains/admin/feedback/feedback.service.ts` + +검색 데이터는 설정에 따라 다음 중 하나에서 조회됩니다. + +- ABC MySQL의 `feedbacks` 테이블 +- OpenSearch의 피드백 인덱스 + +따라서 이 화면은 실제 외부 관리 콘솔의 Secretary 기반 관리 화면과 데이터 조회 기준이 다를 수 있습니다. + +## 8. 화면별 역할 비교 + +| 화면 | 주요 데이터 원천 | 역할 | +| --- | --- | --- | +| `/support/{workspaceCode}/new` | 외부 관리 콘솔 등록 API | 피드백 작성 | +| 외부 관리 콘솔 `feedback.hmac.kr` | Secretary + ABC | 실제 운영 관리 | +| `/main/project/{projectId}/feedback` | ABC API 직접 조회 | 기존 ABC 관리자 화면 | +| `/admin/issues` | Secretary 티켓 API 또는 스텁 | 데모·보조 관리 화면 | +| `/ops` | Secretary 티켓 API 또는 스텁 | 승인 큐 데모 화면 | + +피드백 전용 전환 문서의 목표는 이 저장소에서 작성 화면만 제공하고, 목록·상세·관리 기능은 외부 관리 콘솔에서 유지하는 것입니다. + +## 9. 첨부파일 흐름 + +작성 페이지에서 첨부파일을 선택하면 브라우저의 `FormData`에 포함됩니다. + +```text +브라우저 +→ Next.js Proxy +→ 외부 관리 콘솔/Secretary API +→ Secretary Local 또는 R2 저장소 +→ attachments 테이블에 메타데이터 저장 +``` + +현재 작성 화면의 제한은 다음과 같습니다. + +- 파일당 최대 30MB +- 전체 최대 30MB +- 최대 10개 +- 이미지 외 일반 파일도 업로드 가능 + +첨부파일은 피드백 본문 JSON에 직접 저장되지 않고 별도 저장소와 `attachments` 메타데이터로 관리됩니다. + +## 10. 네이버웍스 알림 흐름 + +피드백 작성 웹은 네이버웍스 API를 직접 호출하지 않습니다. 피드백 등록 후 ABC API와 기존 관리 콘솔의 백엔드 알림 파이프라인에서 네이버웍스 알림을 처리합니다. + +```text +피드백 작성 +→ Secretary가 내부 티켓 저장 +→ ABC API가 feedbacks 저장 +→ FEEDBACK_CREATION 이벤트 발생 +→ 알림 수신자 계산 +→ 네이버웍스 사용자 ID 조회 +→ 네이버웍스 Bot 메시지 발송 +→ notification_deliveries에 결과 저장 +``` + +### 10.1 알림 이벤트 발생 + +ABC의 [`FeedbackService.create`](apps/api/src/domains/admin/feedback/feedback.service.ts)는 피드백 저장 후 `FEEDBACK_CREATION` 이벤트를 발생시킵니다. + +```text +apps/api/src/domains/admin/feedback/feedback.service.ts + │ + │ EventTypeEnum.FEEDBACK_CREATION + ▼ +apps/api/src/domains/admin/project/webhook/notification-internal.listener.ts +``` + +트랜잭션 커밋 전에 알림이 조회되지 않도록, 생성 이벤트 처리 시 저장 완료 후 피드백을 다시 조회합니다. + +알림 이벤트에는 다음 정보가 포함됩니다. + +- `projectId` +- `channelId` +- `feedbackId` +- 피드백 제목 +- 작성자 이메일 +- 담당자 이메일 +- 현재 피드백 상태 +- 비밀글 여부 + +### 10.2 알림 대상 + +수신자 계산은 [`notification-recipient.router.ts`](apps/api/src/domains/admin/project/webhook/notification-recipient.router.ts)에서 수행합니다. + +신규 피드백과 상태 변경 시 대상은 다음과 같습니다. + +- 해당 피드백의 담당자 +- 해당 프로젝트의 `PROJECT_MANAGER` 또는 `Admin` 역할 사용자 +- 피드백 작성자 + +관리자 댓글 등록 시에는 해당 피드백 작성자에게만 보냅니다. + +같은 사용자가 여러 역할에 해당하면 이메일 기준으로 중복 제거하여 한 번만 발송합니다. 프로젝트 관리자는 ABC의 프로젝트 멤버와 Secretary의 관리자 조회 결과를 함께 사용합니다. + +```text +ABC project/channel + │ + ├─ ABC DB의 프로젝트 관리자 조회 + ├─ Secretary API의 프로젝트 관리자 조회 + ├─ 피드백 담당자 추가 + └─ 피드백 작성자 추가 + │ + ▼ + 이메일 기준 중복 제거 +``` + +수신자 이메일은 네이버웍스 API에서 실제 사용자 ID로 변환됩니다. 이메일 또는 사용자 매핑이 확인되지 않으면 임의 사용자나 전체 방으로 대체 발송하지 않습니다. + +### 10.3 네이버웍스 발송 + +실제 발송은 [`NaverWorksClient`](apps/api/src/domains/admin/project/webhook/naver-works.client.ts)에서 수행합니다. + +1. 네이버웍스 Access Token을 확인합니다. +2. 설정된 이메일로 네이버웍스 사용자 ID를 조회합니다. +3. Bot ID와 사용자 ID를 사용해 개인 메시지를 발송합니다. +4. 발송 성공·실패 결과를 저장합니다. + +인증 방식은 두 가지를 지원합니다. + +- `NAVER_WORKS_ACCESS_TOKEN` 직접 사용 +- Client ID, Client Secret, Service Account, Private Key를 이용한 JWT Bearer 인증 + +관련 환경변수는 API 서버에만 설정해야 합니다. + +```text +NAVER_WORKS_ENABLED +NAVER_WORKS_API_BASE_URL +NAVER_WORKS_AUTH_URL +NAVER_WORKS_ACCESS_TOKEN +NAVER_WORKS_BOT_ID +NAVER_WORKS_DEFAULT_ROOM_ID +NAVER_WORKS_CLIENT_ID +NAVER_WORKS_CLIENT_SECRET +NAVER_WORKS_SERVICE_ACCOUNT +NAVER_WORKS_PRIVATE_KEY +``` + +피드백 전용 작성 웹에는 이 값들을 넣지 않습니다. + +### 10.4 메시지 구성 + +메시지는 [`notification-message.builder.ts`](apps/api/src/domains/admin/project/webhook/notification-message.builder.ts)에서 생성합니다. + +기본 메시지에는 다음 정보가 포함됩니다. + +- 이벤트 종류 +- 피드백 ID +- 피드백 제목 +- 상태 변경 전·후 상태 +- 관리자 공개 댓글 내용 +- 기존 관리자 상세 페이지 링크 + +비밀글 작성자에게 보내는 메시지는 제목·본문·댓글 원문을 노출하지 않고 피드백 ID 중심으로 최소화합니다. 전화번호, IP 주소, MAC 주소는 기본 알림에 포함하지 않습니다. + +### 10.5 알림 대상 이벤트 + +| 이벤트 | 기본 수신자 | 조건 | +| --- | --- | --- | +| `FEEDBACK_CREATION` | 담당자, 프로젝트 관리자, 작성자 | 신규 피드백 등록 | +| `FEEDBACK_STATUS_CHANGE` | 담당자, 프로젝트 관리자, 작성자 | 이전 상태와 현재 상태가 다름 | +| `FEEDBACK_COMMENT_CREATION` | 작성자 | 관리자의 공개 댓글 등록 | + +다음 이벤트는 현재 네이버웍스 피드백 알림 대상이 아닙니다. + +- 내부 메모 등록·수정·삭제 +- 관리자 공개 댓글 수정·삭제 +- 이슈 상태 변경 +- Gitea 상태 변경 +- 같은 상태로 다시 저장한 상태 변경 + +### 10.6 중복 및 실패 처리 + +알림 서비스는 `notification_deliveries` 테이블에 이벤트별 발송 결과를 기록합니다. + +주요 상태: + +- `RECEIVED`: 발송 처리 대상 등록 +- `SENT`: 발송 성공 +- `SKIPPED`: 대상 없음 또는 알림 제외 +- `FAILED`: 발송 실패 + +이벤트와 수신자 조합으로 Idempotency Key를 생성하므로 동일 이벤트가 반복 수신되어도 같은 사용자에게 중복 발송하지 않습니다. + +네이버웍스 발송은 네트워크 오류, `408`, `429`, `5xx` 응답에 대해 제한적으로 재시도합니다. `400`, `403`, 사용자 매핑 실패와 같은 영구 오류는 반복하지 않고 실패 이력으로 남깁니다. + +발송 실패는 피드백 등록 실패와 분리됩니다. 즉, 피드백이 ABC와 Secretary에 정상 저장된 뒤 네이버웍스 알림만 실패할 수 있습니다. + +## 11. 현재 운영 기준 요약 + +```text +사용자 입력 +→ /support/{workspaceCode}/new +→ /api/support/workspaces/{workspaceCode}/submit +→ 외부 관리 콘솔 API +→ Secretary support_tickets 저장 +→ ABC feedbacks 저장 +→ abc_feedback_mappings로 연결 +→ 외부 관리 콘솔에서 조회·상태변경·댓글·이슈처리 +``` + +정리하면 작성 페이지와 관리페이지는 프론트엔드 상태를 직접 공유하지 않습니다. 두 시스템은 다음 정보를 기준으로 서버 데이터에서 연결됩니다. + +```text +workspace_code +→ Secretary ticket_id +→ abc_feedback_id +→ ABC project/channel +``` + +## 참고 파일 + +- `FEEDBACK_ONLY_TASKS.md` +- `apps/web/src/pages/support/[workspaceCode]/new.tsx` +- `apps/web/src/pages/api/support/workspaces/[workspaceCode]/submit.ts` +- `apps/web/src/pages/api/support/workspaces/[workspaceCode]/tickets.ts` +- `apps/web/src/server/support-external.ts` +- `apps/web/src/server/support-auth.ts` +- `apps/secretary-api/app/api/routes/tickets.py` +- `apps/secretary-api/app/services/ticket_service.py` +- `apps/secretary-api/app/db/models.py` +- `apps/api/src/domains/admin/feedback/feedback.entity.ts` +- `apps/api/src/domains/admin/feedback/feedback.service.ts` +- `apps/api/src/domains/admin/project/webhook/notification-internal.listener.ts` +- `apps/api/src/domains/admin/project/webhook/notification-recipient.router.ts` +- `apps/api/src/domains/admin/project/webhook/webhook-notification.service.ts` +- `apps/api/src/domains/admin/project/webhook/notification-message.builder.ts` +- `apps/api/src/domains/admin/project/webhook/naver-works.client.ts` +- `apps/api/src/domains/admin/project/webhook/notification-delivery.entity.ts` diff --git a/FEEDBACK_SYNC_GUIDE.md b/FEEDBACK_SYNC_GUIDE.md new file mode 100644 index 0000000..e180913 --- /dev/null +++ b/FEEDBACK_SYNC_GUIDE.md @@ -0,0 +1,259 @@ +# 피드백 작성 서버 ↔ 관리 콘솔 동기화 운영 가이드 + +이 문서는 이 저장소의 보안형 피드백 작성 페이지를 `https://feedback.hmac.kr/`의 기존 관리 콘솔과 연결하고, Gitea Actions로 스테이징에 배포하는 방법을 설명합니다. + +## 1. 먼저 이해할 구조 + +이 연동은 별도 배치 작업이나 DB 복제가 아닙니다. 사용자의 작성 요청마다 작성 서버가 관리 콘솔의 Support API를 호출하는 서버사이드 proxy 방식입니다. + +```text +브라우저 + │ same-origin 요청 + ▼ +작성 서버의 Next.js API + │ Authorization 전달 + multipart 중계 + ▼ +https://feedback.hmac.kr/api/support + │ + ├─ Secretary: 관리 티켓·권한·첨부파일 메타데이터 + ├─ ABC: 피드백 원본 + └─ 관리 콘솔: 목록·상세·상태·댓글·내부 메모 +``` + +따라서 작성 서버에는 운영용 ABC API, Secretary API, MySQL, OpenSearch를 함께 배포하지 않습니다. 피드백 저장의 최종 확인 지점은 `feedback.hmac.kr`입니다. + +## 2. 연동 전 준비 + +### 2.1 관리 콘솔 API 계약 + +관리 콘솔은 다음 경로를 제공해야 합니다. + +```text +GET /api/support/access +GET /api/support/workspaces/{workspaceCode}/form-template +POST /api/support/workspaces/{workspaceCode}/tickets +POST /api/support/tickets/{ticketId}/completion-confirmation?workspaceCode={workspaceCode} +``` + +작성 서버가 브라우저에 제공하는 내부 경로는 다음과 같습니다. + +```text +GET /api/support/access +GET /api/support/workspaces/{workspaceCode}/form-template +POST /api/support/workspaces/{workspaceCode}/submit +POST /api/support/tickets/{ticketId}/completion-confirmation?workspaceCode={workspaceCode} +``` + +`submit`은 multipart 요청을 파싱한 뒤 외부의 `/tickets`로 다시 전송합니다. 외부 API의 URL은 `SUPPORT_CONSOLE_API_BASE_URL`에서만 읽습니다. + +완료 확인 요청은 작성자가 처리 결과를 확인했을 때 호출합니다. 요청 본문은 `{}`이며 작성 서버 proxy가 현재 로그인 세션의 Bearer 토큰과 `workspaceCode`를 관리 콘솔로 전달합니다. 관리 콘솔은 토큰의 `user_id`, `tenant_id`와 Q&A 작성자를 비교하고, 진행중·완료 안내 댓글·연결 이슈 완료·보류 여부를 검증해야 합니다. 같은 요청은 중복 상태 변경 없이 멱등하게 처리해야 합니다. + +성공 응답은 다음 필드를 포함합니다. + +```json +{ + "feedback_id": 47, + "feedback_status": "COMPLETED", + "completed_at": "2026-09-04T12:00:00.000Z" +} +``` + +`409`는 진행중 상태가 아니거나 완료 안내 댓글이 없거나 연결 이슈가 아직 완료되지 않은 경우입니다. 기존 상태값을 사용하는 환경은 `COMPLETED` 대신 `RESOLVED`를 반환할 수 있으며, 작성페이지는 두 값을 완료로 표시합니다. + +### 2.2 BARON-SSO RP 등록 + +작성 서버 전용 confidential client를 BARON-SSO에 등록합니다. 관리 콘솔의 client secret을 재사용하지 않습니다. + +현재 스테이징 기본 주소를 직접 사용하는 경우 redirect URI는 다음과 같습니다. + +```text +http://10.13.10.4:8864/api/auth/baron-sso/callback +``` + +실제 공개 주소가 HTTPS 도메인이라면 그 주소로 바꾸고, scheme·host·port·path를 한 글자도 다르게 등록하지 않습니다. 코드가 요청의 `Host`와 `X-Forwarded-Proto`를 이용해 callback URI를 만들므로, 앞단 reverse proxy가 원래 scheme을 전달해야 합니다. + +`10.13.10.4:8864`는 내부 스테이징 주소로 취급합니다. 인터넷에 직접 노출하지 말고 외부 공개가 필요하면 TLS reverse proxy를 앞에 두어 HTTPS, `Host`, `X-Forwarded-Proto`를 일관되게 전달합니다. + +현재 production Compose와 workflow에는 `SSO_REDIRECT_URI`를 전달하지 않습니다. 고정 callback URI를 사용해야 하는 경우 BARON-SSO만 수정하지 말고 `docker-compose.prod.yml`, workflow, `local-sso.ts`의 동작을 함께 변경한 뒤 검증합니다. + +### 2.3 JWT 계약 + +작성 서버는 BARON-SSO userinfo를 바탕으로 HS256 세션 JWT를 만들고 `HttpOnly` 쿠키에 넣습니다. proxy는 이 JWT를 `Authorization: Bearer ...`로 관리 콘솔에 전달합니다. + +다음 두 조건이 모두 맞아야 합니다. + +- 작성 서버의 `JWT_SECRET`이 설정되어 있어야 합니다. +- `feedback.hmac.kr`의 Support API가 같은 `JWT_SECRET`으로 토큰을 검증해야 합니다. + +두 서버의 값이 다르면 SSO callback까지 성공해도 `/api/support/access`에서 `401`이 발생합니다. secret은 문서·이슈·로그에 기록하지 않고 보안 채널로 교환합니다. + +### 2.4 workspace와 프로젝트 매핑 + +작성 URL의 `{workspaceCode}`는 관리 콘솔의 workspace 식별자입니다. 첫 예시는 다음입니다. + +```text +/support/EGBIM_DEMO/new +``` + +이전 연동 확인에서 `EGBIM_DEMO`는 관리 콘솔의 ABC `projectId=8`, `channelId=9`에 매핑되었습니다. 이 값은 환경별 데이터에 속하므로 배포 때 숫자를 코드에 고정하지 말고, 관리 콘솔에서 현재 mapping을 다시 확인합니다. 프로젝트나 채널 이름이 중복되거나 workspace가 비활성 상태면 양식 조회와 등록이 실패할 수 있습니다. + +## 3. 작성 서버 환경값 + +운영에서 중요한 값은 다음과 같습니다. + +```text +NEXT_PUBLIC_API_BASE_URL= +NEXT_PUBLIC_FEEDBACK_ONLY=true +SUPPORT_CONSOLE_API_BASE_URL=https://feedback.hmac.kr/api/support +SSO_ISSUER=https://sso.hmac.kr/oidc +SSO_AUTHORIZATION_ENDPOINT=https://sso.hmac.kr/oidc/oauth2/auth +SSO_TOKEN_ENDPOINT=https://sso.hmac.kr/oidc/oauth2/token +SSO_USERINFO_ENDPOINT=https://sso.hmac.kr/oidc/userinfo +SSO_SCOPE=openid profile email +SSO_CLIENT_ID= +SSO_CLIENT_SECRET= +JWT_SECRET=<관리 콘솔 Support API와 공유하는 HS256 secret> +SUPPORT_TENANT_ID= +WEB_PORT=8864 +``` + +`NEXT_PUBLIC_` 접두사가 붙은 값은 브라우저 빌드에 포함될 수 있습니다. `SSO_CLIENT_SECRET`, `JWT_SECRET`은 절대 `NEXT_PUBLIC_`로 시작하게 만들지 않습니다. 운영에서 외부 API URL을 브라우저용 `NEXT_PUBLIC_API_BASE_URL`에 넣으면 CORS와 secret 노출 경계가 깨집니다. + +## 4. Gitea Actions 배포 + +등록해야 하는 키와 `Variables`/`Secrets` 구분은 [`GITEA_VARIABLES.md`](./GITEA_VARIABLES.md)에 있습니다. workflow는 다음 순서로 실행됩니다. + +1. `main` push 또는 수동 실행으로 시작합니다. +2. `STAGING_HOST`, `WEB_PORT`가 현재 고정된 스테이징 대상과 맞는지 검사합니다. +3. SSH private key와 `known_hosts`를 runner 임시 디렉터리에 만들고 strict host key 검증으로 접속합니다. +4. `.git`, `node_modules`, `.next`, `dist`, `uploads`, `.venv` 등을 제외한 소스를 원격 서버로 전송합니다. +5. 환경값을 base64로 인코딩해 원격 Bash에 전달하고, 서버 디스크에 `.env` 파일로 저장하지 않습니다. +6. `docker compose ... config --quiet` 후 `up -d --build`를 실행합니다. +7. Web의 `/api/health`를 최대 60회 확인하고 실패하면 Web 상태와 최근 로그를 출력합니다. + +원격 사용자의 login shell이 zsh이어도 동작하도록 마지막 배포 스크립트는 명시적으로 `bash -s`로 실행합니다. 이 방식은 이전에 대형 명령 문자열을 SSH로 전달할 때 발생한 중첩 따옴표·shell 문법 오류를 피하기 위한 것입니다. + +## 5. 최초 배포 절차 + +### 5.1 코드와 설정 검사 + +```bash +git status --short +pnpm lint +pnpm typecheck +pnpm build +``` + +`.env`, `.env.*` 실제 환경파일, API key, secret, private key가 변경 목록에 없는지 확인합니다. `apps/web/.env.example`에는 빈 값 또는 설명용 placeholder만 둡니다. + +### 5.2 Gitea 등록 + +Gitea 저장소의 Actions 변수/Secret 화면에서 이름을 정확히 입력합니다. 따옴표를 값에 포함하지 않습니다. multiline private key와 `known_hosts`는 줄바꿈을 보존합니다. + +최소 등록 세트: + +```text +Variables: STAGING_HOST, WEB_PORT, SUPPORT_CONSOLE_API_BASE_URL, + SSO_ISSUER, SSO_AUTHORIZATION_ENDPOINT, SSO_TOKEN_ENDPOINT, + SSO_USERINFO_ENDPOINT, SSO_SCOPE, SSO_CLIENT_ID +Secrets: STAGING_USER, STAGING_SSH_PRIVATE_KEY, STAGING_SSH_KNOWN_HOSTS, + SSO_CLIENT_SECRET, JWT_SECRET +``` + +값의 기본값, 선택 여부, 등록 화면은 [`GITEA_VARIABLES.md`](./GITEA_VARIABLES.md)를 기준으로 합니다. + +### 5.3 BARON-SSO redirect와 네트워크 확인 + +- Gitea runner에서 `10.13.10.4:22`로 SSH 접속할 수 있어야 합니다. +- 스테이징 서버의 배포 사용자가 Docker 명령을 실행할 수 있어야 합니다. +- 스테이징 서버에서 `feedback.hmac.kr`와 `sso.hmac.kr`로 HTTPS 요청을 보낼 수 있어야 합니다. +- BARON-SSO에 실제 접속 주소의 callback URI가 등록되어 있어야 합니다. + +### 5.4 배포 실행 + +`main`에 push하거나 Gitea Actions에서 `workflow_dispatch`를 실행합니다. workflow가 성공해도 기능 검증은 별도로 진행합니다. + +수동으로 원격에서 실행할 때는 secret을 shell history나 로그에 남기지 말고, 동일한 runtime 환경을 주입합니다. + +```bash +docker compose -f docker/docker-compose.prod.yml config --quiet +docker compose -f docker/docker-compose.prod.yml up -d --build +docker compose -f docker/docker-compose.prod.yml ps +``` + +## 6. 배포 후 검증 + +1. `http://10.13.10.4:8864/api/health`가 `{ "status": "ok" }`를 반환하는지 확인합니다. +2. `/support/EGBIM_DEMO/new` 접속 시 로그인 화면으로 이동하는지 확인합니다. +3. 로그인 후 양식이 관리 콘솔의 workspace template에서 표시되는지 확인합니다. +4. 작성페이지에서 제목·구분·내용만 입력할 수 있는지 확인합니다. IP·MAC 필드는 작성자 화면에 표시하지 않습니다. +5. 이미지뿐 아니라 PDF·문서 등 일반 파일도 첨부합니다. +6. 성공 응답의 `ticket_id`, `sync_status`를 확인합니다. +7. `feedback.hmac.kr`에서 같은 제목·작성자·첨부파일을 조회합니다. +8. 관리 콘솔에서 처리 완료 안내 댓글을 등록한 뒤 작성자 상세페이지에 완료 확인 버튼과 요청 시각이 표시되는지 확인합니다. +9. 완료 확인 버튼을 한 번 눌러 `COMPLETED`(또는 호환 환경의 `RESOLVED`)와 `completed_at` 응답을 확인합니다. +10. 같은 버튼 요청을 반복해도 중복 완료 처리 없이 성공 응답이 유지되는지 확인합니다. +11. 파일당 30MB 초과, 전체 30MB 초과, 10개 초과가 `413`으로 차단되는지 확인합니다. + +관리 콘솔에 표시되지 않으면 작성 서버에서 데이터를 재생성하거나 DB를 초기화하지 않습니다. 브라우저 응답, 관리 콘솔 API 로그, workspace mapping, 두 서버의 JWT secret을 순서대로 확인합니다. + +## 7. 시행착오에서 정리한 원칙 + +### `localhost` API 주소를 production build에 넣지 않기 + +처음에는 브라우저용 `NEXT_PUBLIC_API_BASE_URL`과 서버 내부 주소가 섞여 CORS 또는 잘못된 대상 호출이 발생할 수 있었습니다. 현재 피드백 전용 빌드는 브라우저 API base URL을 빈 값으로 고정하고, 외부 주소는 서버 전용 변수로만 전달합니다. + +### 관리 콘솔 login을 재사용하지 않기 + +작성 페이지 주소가 달라졌다고 관리 콘솔의 브라우저 세션을 그대로 사용하면 callback과 cookie 범위가 꼬입니다. 작성 서버가 별도 BARON-SSO RP로 code 교환을 수행하고, 관리 콘솔 API에는 세션 JWT만 전달합니다. + +### API key를 작성 브라우저에 넣지 않기 + +피드백 전용 작성 흐름은 사용자의 SSO 세션으로 관리 콘솔 API를 호출합니다. `MASTER_API_KEY`, `SECRETARY_ABC_API_KEY`, ABC API key를 브라우저 bundle이나 운영 Compose에 추가하지 않습니다. API key가 필요한 별도 서버 연동은 관리 콘솔 서버의 책임입니다. + +### 첨부파일은 JSON이 아닌 multipart로 전달하기 + +첨부파일이 이미지에만 한정되거나 JSON으로 변환되면 PDF·문서 등록이 깨질 수 있습니다. 작성 서버의 proxy는 `bodyParser: false`와 formidable을 사용해 multipart를 읽고, 외부 API에 `FormData`로 전달합니다. 파일 제한은 클라이언트와 서버 양쪽에서 확인합니다. + +### 원격 shell의 quoting에 의존하지 않기 + +SSH 한 줄 명령 안에 secret, 배열, loop를 모두 넣으면 login shell에 따라 문법이 달라질 수 있습니다. 현재 workflow는 SSH 전송 후 명시적인 `bash -s` 블록에서 환경을 복원하고 Compose를 실행합니다. 배포 workflow를 수정할 때 이 경계를 유지합니다. + +### 작업 이력에서 확인할 수 있는 전환점 + +다음 commit들이 현재 운영 방식을 결정한 주요 기록입니다. + +| commit | 의미 | +| --- | --- | +| `33453ec` | 최초 스테이징 배포 구성 | +| `aaddfc7` | 피드백 작성 API와 서버사이드 SSO/proxy 적용 | +| `483f5a0` | 첨부파일 전달 경로 수정 | +| `090e9fd` | 원격 login shell 차이를 피하기 위해 Bash script 실행 방식 수정 | + +이전 workflow는 API·Secretary·DB 환경값을 대량으로 전송하고 ABC API key를 요구했지만, 현재 피드백 전용 workflow는 외부 관리 콘솔의 API 계약만 사용합니다. 과거 문서의 `172.16.10.175:3030`, `NEXT_PUBLIC_API_BASE_URL` 외부 주소, `MASTER_API_KEY` 필수 표기는 현재 배포 기준으로 사용하지 않습니다. + +## 8. 장애 대응과 롤백 + +```bash +docker compose -f docker/docker-compose.prod.yml ps +docker compose -f docker/docker-compose.prod.yml logs --tail=200 web +curl -i http://127.0.0.1:8864/api/health +``` + +- `401`: SSO userinfo의 `sub`/`tenant_id`, `JWT_SECRET` 일치 여부, 관리 콘솔 access API를 확인합니다. +- `404`: 외부 base URL에 `/api/support`가 포함되는지와 workspace code를 확인합니다. +- `413`: 파일 개수·용량 제한을 확인합니다. +- `502`: 작성 서버에서 관리 콘솔로의 네트워크, DNS, TLS, 외부 API 상태를 확인합니다. +- callback 오류: BARON-SSO redirect URI와 `Host`/`X-Forwarded-Proto`를 확인합니다. + +롤백은 마지막 정상 commit을 기준으로 다시 배포합니다. 작성 서버에는 운영 DB가 없으므로 DB rollback을 수행하지 않지만, 관리 콘솔 DB migration이 포함된 버전과 이 서버의 피드백 전용 배포를 혼동하지 않습니다. 기존 데이터가 있는 어떤 서버에서도 `docker compose down -v`를 무심코 실행하지 않습니다. + +## 9. 변경 시 함께 확인할 파일 + +- `docker/docker-compose.prod.yml`: production이 `web`만 실행하는지 +- `.gitea/workflows/deploy-staging.yml`: 실제로 읽는 Variables/Secrets와 기본값 +- `docker/web.dockerfile`: build-time 공개 변수와 runtime server 변수의 경계 +- `apps/web/src/server/local-sso.ts`: callback, state, 세션 JWT, cookie +- `apps/web/src/server/support-external.ts`: 외부 Support API URL 조립 +- `apps/web/src/pages/api/support/workspaces/[workspaceCode]/submit.ts`: multipart 중계와 제한 +- `FEEDBACK_ONLY_TASKS.md`: 작업 당시의 완료/보류 기록 diff --git a/GITEA_VARIABLES.md b/GITEA_VARIABLES.md index c07e74c..07af06d 100644 --- a/GITEA_VARIABLES.md +++ b/GITEA_VARIABLES.md @@ -1,210 +1,147 @@ -# Gitea 스테이징 등록 변수 +# Gitea Actions 변수·Secret 등록 가이드 -BARON User Feedback와 secretary-api를 `172.16.10.175:3030`으로 배포할 때 Gitea에 등록할 환경변수와 Secret 정리입니다. +이 문서는 현재 [`deploy-staging.yml`](./.gitea/workflows/deploy-staging.yml)이 사용하는 피드백 작성 전용 배포 설정입니다. 이 workflow는 `web` 컨테이너만 `10.13.10.4:8864`에 배포합니다. ABC API·Secretary API·MySQL을 배포하던 이전 전체 플랫폼용 변수 목록과 섞어 등록하지 않습니다. -## 1. 스테이징 기본값 +## 1. 등록 위치와 구분 -### Variables - -| 이름 | 등록값 | 용도 | -| ---------------------------- | ----------------------------------------------- | ----------------------------------------------- | -| `APP_ENV` | `staging` | 실행 환경 | -| `WEB_PORT` | `3030` | 외부 웹 포트 | -| `API_PORT` | `4000` | Docker 내부 API 포트 (host에 공개하지 않음) | -| `SECRETARY_API_PORT` | `8010` | Docker 내부 FastAPI 포트 (host에 공개하지 않음) | -| `MYSQL_PORT` | 등록 불필요 | prod Compose는 DB host port를 공개하지 않음 | -| `MYSQL_SECRETARY_PORT` | 등록 불필요 | prod Compose는 DB host port를 공개하지 않음 | -| `NEXT_PUBLIC_API_BASE_URL` | `http://172.16.10.175:3030` | Web reverse proxy를 통한 브라우저 API 주소 | -| `ADMIN_WEB_URL` | `http://172.16.10.175:3030` | 관리자 웹 주소 및 OAuth 기준 주소 | -| `BASE_URL` | `http://172.16.10.175:3030` | Web reverse proxy를 통한 API 기준 주소 | -| `SMTP_ENABLED` | `false` (현재 SSO 전용) 또는 `true` (SMTP 사용) | 이메일 기능 활성화 여부 | -| `SMTP_HOST` | 사내 SMTP 호스트 | 메일 서버 | -| `SMTP_PORT` | `25` 또는 사내 SMTP 포트 | 메일 서버 포트 | -| `SMTP_SENDER` | 사내 발신 이메일 | 메일 발신자 | -| `SMTP_TLS` | `false` 또는 `true` | SMTP TLS 사용 여부 | -| `SMTP_CIPHER_SPEC` | 사내 SMTP 정책값 | TLS cipher 설정 | -| `SMTP_OPPORTUNISTIC_TLS` | `false` 또는 `true` | SMTP opportunistic TLS | -| `ACCESS_TOKEN_EXPIRED_TIME` | `10m` | Access Token 만료시간 | -| `REFRESH_TOKEN_EXPIRED_TIME` | `1h` | Refresh Token 만료시간 | -| `AUTO_MIGRATION` | `true` | API 시작 시 마이그레이션 | -| `OPENSEARCH_USE` | `false` | OpenSearch 사용 여부 | -| `OPENSEARCH_NODE` | 빈 값 | OpenSearch 미사용 | -| `OPENSEARCH_USERNAME` | 빈 값 | OpenSearch 미사용 | -| `OPENSEARCH_PASSWORD` | 빈 값 | OpenSearch 미사용 | - -> 스테이징 DB는 외부 host port를 사용하지 않습니다. 컨테이너 내부에서는 `mysql:3306`, `mysql-secretary:3306`으로 접근합니다. 외부 DB 접속이 필요하면 SSH 터널을 사용합니다. -> | `SSO_ISSUER` | `https://sso.hmac.kr/oidc` | secretary-api용 SSO issuer | -> | `SSO_CLIENT_ID` | `838cd69d-e722-41da-9f79-b3c42a509ef2` | BARON-SSO Client ID | - -`INTERNAL_API_BASE_URL`, `SUPPORT_API_BASE_URL`, `ABC_API_BASE_URL`는 Docker 내부 주소이므로 Gitea에 별도 등록하지 않습니다. +Gitea 저장소 `b24014/egbim_qa_platform`에서 다음 메뉴를 엽니다. ```text -INTERNAL_API_BASE_URL=http://api:4000 -SUPPORT_API_BASE_URL=http://secretary-api:8010 -ABC_API_BASE_URL=http://api:4000 +Repository Settings → Actions → Variables / Secrets ``` -다음 값도 Compose에 고정되어 있으므로 Gitea에 별도 등록하지 않습니다. +Gitea 버전에 따라 메뉴명이 `Actions secrets and variables`로 보일 수 있습니다. workflow의 표현식 기준은 다음과 같습니다. -| 이름 | 고정값 | -| ------------------- | -------------------------------------------------------------------------------- | -| `APP_NAME` | `secretary-api` | -| `APP_HOST` | `0.0.0.0` | -| `APP_PORT` | `8010` | -| `UPLOAD_ROOT_DIR` | `/app/uploads` | -| `MYSQL_PRIMARY_URL` | `mysql://userfeedback:userfeedback@mysql:3306/userfeedback` | -| `DATABASE_URL` | `mysql+pymysql://baron_support:baron_support@mysql-secretary:3306/baron_support` | +```yaml +${{ vars.NAME }} # 일반 Variables +${{ secrets.NAME }} # 마스킹되는 Secrets +``` -다음은 선택 기능을 사용할 때만 추가합니다. +등록할 때 변수 이름은 대문자·underscore까지 아래 표와 똑같이 입력합니다. 값에 따옴표를 붙이지 않습니다. Secret은 채팅, 이슈, commit, workflow 로그에 기록하지 않습니다. -| 이름 | 등록값 | -| ------------------------------------ | --------------------------- | -| `MYSQL_SECONDARY_URLS` | 보조 MySQL URL JSON 배열 | -| `AUTO_FEEDBACK_DELETION_ENABLED` | `false` | -| `AUTO_FEEDBACK_DELETION_PERIOD_DAYS` | 자동 삭제 사용 시 보존 일수 | +## 2. Variables -## 2. Gitea Secrets +다음은 공개되어도 되는 주소·포트·동작 설정입니다. 표의 `기본값`은 Gitea에 생략해도 workflow가 사용하는 값입니다. -### API 및 관리자 인증 +| 이름 | 필수 | 권장값/기본값 | 용도 | +| --- | --- | --- | --- | +| `STAGING_HOST` | 선택 | `10.13.10.4` | SSH 대상. 현재 workflow가 이 값만 허용 | +| `STAGING_PORT` | 선택 | `22` | SSH 포트 | +| `STAGING_APP_DIR` | 선택 | `/home/user/egbim_qa_platform` | 원격 배포 디렉터리 | +| `WEB_PORT` | 선택 | `8864` | 외부 Web 포트. 현재 workflow가 이 값만 허용 | +| `SUPPORT_CONSOLE_API_BASE_URL` | 선택 | `https://feedback.hmac.kr/api/support` | 관리 콘솔 Support API | +| `SSO_ISSUER` | 선택 | `https://sso.hmac.kr/oidc` | BARON-SSO issuer | +| `SSO_AUTHORIZATION_ENDPOINT` | 선택 | `https://sso.hmac.kr/oidc/oauth2/auth` | OAuth authorization endpoint | +| `SSO_TOKEN_ENDPOINT` | 선택 | `https://sso.hmac.kr/oidc/oauth2/token` | 서버 간 code 교환 endpoint | +| `SSO_USERINFO_ENDPOINT` | 선택 | `https://sso.hmac.kr/oidc/userinfo` | 로그인 사용자 정보 endpoint | +| `SSO_SCOPE` | 선택 | `openid profile email` | BARON-SSO scope | +| `SSO_CLIENT_ID` | 권장 | BARON-SSO 작성 서버 Client ID | 공개 식별자. Variable 등록 권장 | +| `SUPPORT_TENANT_ID` | 선택 | 빈 값 | userinfo에 `tenant_id`가 없을 때 사용할 tenant | -| 이름 | 등록값 | -| ---------------------------------- | -------------------------------------------------------- | -| `JWT_SECRET` | 긴 무작위 문자열 | -| `MASTER_API_KEY` | ABC 전체 API 관리용 무작위 키 | -| `INITIAL_SUPER_ADMIN_PHONE_NUMBER` | 선택값: 초기 SUPER 관리자 자동 지정용 BARON-SSO 전화번호 | -| `ADMIN_CANDIDATE_EMAILS` | 관리자 후보 이메일 목록을 쉼표로 연결 | +`STAGING_HOST`와 `WEB_PORT`는 선택으로 표시했지만 다른 값으로 바꾸면 현재 workflow의 사전 검사를 통과하지 못합니다. 스테이징 대상이나 포트를 변경하려면 workflow의 고정 검사를 코드와 함께 변경하고 BARON-SSO redirect URI도 다시 등록합니다. -예시: +### `SSO_CLIENT_ID`를 Secret에 넣은 경우 + +현재 workflow는 호환성을 위해 `secrets.SSO_CLIENT_ID`를 `vars.SSO_CLIENT_ID`보다 우선합니다. 새로 등록할 때는 Client ID를 Variable에만 등록합니다. 두 위치에 동시에 넣으면 어느 값이 적용되는지 혼동할 수 있습니다. + +## 3. Secrets + +다음 5개는 workflow가 필수로 검사합니다. + +| 이름 | 필수 | 등록 내용 | 주의 | +| --- | --- | --- | --- | +| `STAGING_USER` | 필수 | 스테이징 서버 SSH 사용자 | Docker 명령 실행 권한 필요 | +| `STAGING_SSH_PRIVATE_KEY` | 필수 | 배포용 Ed25519 private key 전체 | passphrase가 없는 키를 사용해야 함 | +| `STAGING_SSH_KNOWN_HOSTS` | 필수 | 스테이징 서버의 검증된 known_hosts 한 줄 이상 | 줄바꿈 보존, 임의 값 금지 | +| `SSO_CLIENT_SECRET` | 필수 | 작성 서버 전용 BARON-SSO confidential client secret | 관리 콘솔 client secret과 분리 | +| `JWT_SECRET` | 필수 | 작성 서버와 관리 콘솔 Support API가 공유하는 HS256 secret | 두 서버 값이 반드시 같아야 함 | + +### SSH key 등록 + +private key는 로컬 파일 내용을 그대로 복사합니다. 앞뒤 공백이나 줄바꿈을 임의로 제거하지 않습니다. passphrase가 있는 키는 현재 workflow의 `ssh-keygen -y` 검사와 비대화형 SSH에서 실패할 수 있습니다. + +공개키는 스테이징 서버의 해당 사용자의 `~/.ssh/authorized_keys`에 등록합니다. private key는 Gitea Secret에만 둡니다. + +`known_hosts`는 다음처럼 수집할 수 있지만, 결과 fingerprint를 서버 관리자나 별도 신뢰 채널로 확인한 뒤 등록합니다. + +```bash +ssh-keyscan -p 22 10.13.10.4 +``` + +현재 workflow는 `StrictHostKeyChecking=yes`를 사용하므로 `STAGING_SSH_KNOWN_HOSTS`가 틀리거나 누락되면 배포하지 않습니다. 이 검사를 끄거나 `accept-new`로 완화하지 않습니다. + +### JWT secret 등록 + +작성 서버는 BARON-SSO token을 그대로 브라우저에 노출하지 않고, userinfo를 기반으로 자체 HS256 JWT를 만듭니다. 관리 콘솔의 Support API가 검증하는 secret과 같은 값을 사용해야 합니다. ```text -ADMIN_CANDIDATE_EMAILS=admin1@example.com,admin2@example.com +작성 서버 JWT_SECRET ─┐ + ├─ 같은 값 +관리 콘솔 Support JWT 검증 ─┘ ``` -현재 로컬에 등록된 후보 이메일은 다음과 같습니다. 스테이징에서도 동일하게 사용할 때만 등록합니다. +값이 다르면 로그인 callback은 끝나도 `/api/support/access`가 `401 Unauthorized`를 반환합니다. 이 secret은 새로 발급하거나 변경할 때 양쪽을 같은 변경 창에 갱신하고 기존 세션 만료를 고려합니다. + +## 4. 현재 등록하지 않는 변수 + +피드백 작성 전용 `docker/docker-compose.prod.yml`에는 `web`만 있으므로 다음은 이 workflow에 등록할 필요가 없습니다. ```text -cyhan@samaneng.com,hsmoon@hanmaceng.co.kr,hikim2@samaneng.com,thlee3@samaneng.com +ABC_API_KEY +SECRETARY_ABC_API_KEY +MASTER_API_KEY +GITEA_API_URL +GITEA_API_TOKEN +GITHUB_API_TOKEN +JIRA_API_TOKEN +SMTP_USERNAME +SMTP_PASSWORD +NAVER_WORKS_ACCESS_TOKEN +MYSQL_* / DATABASE_URL +OPENSEARCH_* ``` -### SMTP +이 값들은 과거 전체 플랫폼 배포 또는 관리 콘솔 서버의 책임입니다. 작성 서버에 추가하면 보안 경계와 배포 목적이 흐려집니다. 특히 API key를 `NEXT_PUBLIC_*` 변수나 Web 이미지 build arg로 만들지 않습니다. -| 이름 | 등록값 | -| --------------- | ------------- | -| `SMTP_USERNAME` | SMTP 계정 | -| `SMTP_PASSWORD` | SMTP 비밀번호 | +또한 현재 workflow는 `SSO_REDIRECT_URI`를 전달하지 않습니다. callback은 요청의 `Host`와 `X-Forwarded-Proto`로 동적으로 계산됩니다. 고정 URI가 필요하면 코드·Compose·workflow를 먼저 일관되게 변경해야 합니다. -SMTP를 인증 없이 사용하면 두 값은 빈 값으로 둡니다. +## 5. 등록 후 확인 -### BARON-SSO +### 등록 체크리스트 -| 이름 | 등록값 | -| ------------------- | ----------------------- | -| `SSO_CLIENT_SECRET` | BARON-SSO Client Secret | +- [ ] `STAGING_HOST=10.13.10.4` +- [ ] `WEB_PORT=8864` +- [ ] `SUPPORT_CONSOLE_API_BASE_URL=https://feedback.hmac.kr/api/support` +- [ ] SSO endpoint 4개가 `https://sso.hmac.kr/oidc` 계열인지 확인 +- [ ] `SSO_CLIENT_ID`는 Variable에 등록하고 Secret에는 중복 등록하지 않음 +- [ ] `STAGING_USER`가 올바른 SSH 사용자임 +- [ ] private key에 대응하는 public key가 원격 `authorized_keys`에 있음 +- [ ] 검증된 host key가 `STAGING_SSH_KNOWN_HOSTS`에 있음 +- [ ] 작성 서버 전용 `SSO_CLIENT_SECRET`이 맞음 +- [ ] `JWT_SECRET`이 관리 콘솔 Support API 설정과 같음 +- [ ] BARON-SSO에 실제 접속 주소의 callback URI가 등록됨 -BARON-SSO에 다음 Redirect URI도 등록해야 합니다. +Gitea Actions에서 `Deploy feedback demo`를 수동 실행합니다. 첫 단계의 `Validate deployment settings`가 secret 값을 출력하지 않고 설정 존재 여부만 통과해야 합니다. -```text -http://172.16.10.175:3030/api/auth/baron-sso/callback +성공 후 스테이징 서버에서 확인합니다. + +```bash +docker compose -f docker/docker-compose.prod.yml ps +curl -fsS http://127.0.0.1:8864/api/health ``` -## 3. ABC API Key와 workspace 매핑 +그 다음 브라우저에서 `/support/EGBIM_DEMO/new`에 로그인해 양식 조회·피드백 등록·첨부파일 등록을 확인하고, 최종 데이터가 `https://feedback.hmac.kr/`에 보이는지 검증합니다. -Gitea에는 ABC API Key와 내부 매핑 조회용 `MASTER_API_KEY`를 Secret으로 등록합니다. 프로젝트·채널 ID는 Gitea 변수나 수동 SQL로 관리하지 않고, 로그인 시 API가 ABC DB의 프로젝트·채널을 workspace code 기준으로 조회하여 `baron_support.workspace_channel_mappings`에 자동 등록·갱신합니다. +## 6. 자주 발생한 실패와 대응 -```text -SECRETARY_ABC_API_KEY=<스테이징 ABC API Key> -MASTER_API_KEY= -``` +| 증상 | 원인 후보 | 확인 | +| --- | --- | --- | +| workflow 사전 검사에서 `Missing Gitea secret` | 이름 오타, Variables/Secrets 위치 오류, 빈 값 | workflow의 `${{ vars.* }}`/`${{ secrets.* }}`와 표 대조 | +| SSH host key 오류 | known_hosts 불일치 또는 서버 재설치 | fingerprint를 확인한 뒤 Secret 갱신 | +| callback 이후 `401` | `JWT_SECRET` 불일치, userinfo tenant 누락 | 두 서버 설정과 `SUPPORT_TENANT_ID` 확인 | +| 로그인 URL 또는 API가 localhost로 감 | 공개 API base URL을 build에 주입 | `NEXT_PUBLIC_API_BASE_URL`을 빈 값으로 빌드했는지 확인 | +| 양식 조회 `404` | 잘못된 Support base URL 또는 workspace code | `/api/support` suffix와 mapping 확인 | +| 첨부파일 `413` | 파일당/전체 30MB 또는 10개 초과 | 파일 수와 용량 확인 | +| 배포 script shell syntax 오류 | 원격 login shell이 zsh 등으로 실행 | workflow의 `bash -s` 경계를 유지 | -자동 매핑 대상은 다음 조건을 만족해야 합니다. - -- workspace code와 동일한 이름의 ABC 프로젝트가 정확히 1개일 것 -- 해당 프로젝트의 채널이 정확히 1개이거나, workspace code/프로젝트 이름과 일치하는 채널이 정확히 1개일 것 -- 조건을 만족하면 기존 매핑은 보완하고, 매핑 행이 없으면 새로 INSERT할 것 - -현재 매핑 확인: - -```sql -SELECT - w.id, - w.workspace_code, - w.workspace_name, - m.id AS mapping_id, - m.abc_project_id, - m.abc_channel_id, - m.is_active -FROM workspaces w -LEFT JOIN workspace_channel_mappings m - ON m.workspace_id = w.id -WHERE w.is_active = 1 -ORDER BY w.id, m.id; -``` - -배포 후 사용자가 로그아웃/로그인하거나 `/api/access/me`를 호출하면 EGBIM, TOVA 등의 누락 매핑이 자동으로 채워집니다. 프로젝트·채널 이름이 여러 개로 모호하면 잘못된 연결을 막기 위해 자동 등록하지 않으므로, 이 경우 ABC DB의 이름을 확인해야 합니다. - -## 4. DB 접속값 - -Docker 내부 서비스 간 접속값은 다음과 같습니다. - -```text -# ABC 내장 DB -MYSQL_PRIMARY_URL=mysql://userfeedback:userfeedback@mysql:3306/userfeedback - -# secretary-api 구축 DB -DATABASE_URL=mysql+pymysql://baron_support:baron_support@mysql-secretary:3306/baron_support -``` - -현재 `docker-compose.prod.yml`에는 DB 계정과 비밀번호가 직접 작성되어 있습니다. - -```text -ABC DB: userfeedback / userfeedback -구축 DB: baron_support / baron_support -``` - -운영 배포 전에는 다음 값을 Gitea Secret으로 분리하고 Compose에서 참조하도록 변경하는 것을 권장합니다. - -```text -MYSQL_ROOT_PASSWORD -MYSQL_PASSWORD -SECRETARY_MYSQL_ROOT_PASSWORD -SECRETARY_MYSQL_PASSWORD -``` - -## 5. CI/CD 배포용 변수 - -Gitea Actions에서 SSH 배포 방식을 사용할 경우 다음 값을 등록합니다. - -### Variables - -| 이름 | 등록값 | -| ---------------------- | ----------------------------------------------------------------- | -| `STAGING_HOST` | `172.16.10.175` (미등록 시 workflow 기본값 사용) | -| `STAGING_PORT` | `22` (미등록 시 workflow가 기본값으로 사용) | -| `STAGING_APP_DIR` | `/home/user/baron_qa` (미등록 시 workflow 기본값 사용) | -| `STAGING_COMPOSE_FILE` | `docker/docker-compose.prod.yml` (미등록 시 workflow 기본값 사용) | -| `STAGING_WEB_PORT` | `3030` | - -### Secrets - -| 이름 | 등록값 | -| ------------------------- | -------------------------------- | -| `STAGING_USER` | 스테이징 서버 SSH 사용자 | -| `STAGING_SSH_PRIVATE_KEY` | 배포용 SSH private key | -| `STAGING_SSH_KNOWN_HOSTS` | 스테이징 서버의 `known_hosts` 값 | - -Docker Registry를 사용하는 경우 추가로 등록합니다. - -```text -REGISTRY_URL -REGISTRY_USERNAME -REGISTRY_PASSWORD -``` - -## 6. 등록 전 확인사항 - -- 현재 스테이징은 `SMTP_ENABLED=false`로 등록하면 SMTP 없이 BARON-SSO 로그인과 권한 분기만 검증할 수 있습니다. 추후 SMTP 정보를 확보하면 `true`로 변경합니다. -- Gitea Secret에는 API Key, `MASTER_API_KEY`, JWT Secret, SMTP 비밀번호, SSO Client Secret을 등록합니다. -- ABC 프로젝트·채널 매핑은 로그인 시 ABC DB와 자동 동기화되며, 이름이 모호한 경우에만 ABC DB의 프로젝트·채널 이름을 확인합니다. -- `INITIAL_SUPER_ADMIN_PHONE_NUMBER`는 선택값입니다. 등록하면 해당 BARON-SSO 전화번호 사용자를 초기 SUPER 관리자로 자동 승격하며, 미등록 시에도 API는 정상 기동합니다. -- 외부 공개 포트는 Web `3030` 하나이며, Web이 Docker 내부 `api:4000`과 `secretary-api:8010`으로 reverse proxy합니다. -- 로컬 Compose에 존재하는 API Key와 DB 비밀번호를 스테이징에 재사용하지 말고 스테이징용 Secret을 별도로 발급합니다. +문제 해결 중 secret을 `echo`, `set -x`, `docker compose config` 출력으로 노출하지 않습니다. 필요한 로그는 값이 아닌 변수 이름과 HTTP 상태만 남깁니다. diff --git a/README.md b/README.md index 54334f3..f8f34d2 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,75 @@ -# ABC User Feedback +# 피드백 작성 페이지 서버 -ABC User Feedback는 BARON-SSO로 인증한 사용자가 피드백을 등록하고, 관리자가 피드백·이슈·댓글·첨부파일을 처리하는 통합 지원 플랫폼입니다. 이 저장소는 ABC User Feedback를 기반으로 한 관리자 콘솔과 Secretary 업무 API를 함께 관리합니다. +이 저장소는 BARON-SSO로 로그인한 사용자가 피드백을 작성하는 보안형 웹 서버입니다. 피드백 목록·상세 관리와 데이터 저장은 기존 관리 콘솔인 [`feedback.hmac.kr`](https://feedback.hmac.kr/)에서 담당합니다. -이 문서는 저장소의 시작점입니다. 상세 설계와 작업 이력은 [`docs/`](./docs/) 아래에 보존하고, 여기에는 실제 실행·검증·배포 순서와 문서 선택 기준을 정리합니다. +이 저장소를 관리 콘솔의 복사본이나 독립적인 피드백 DB로 운영하지 않습니다. 작성 페이지는 브라우저의 요청을 같은 출처(same-origin)로 유지하고, Next.js 서버가 관리 콘솔의 Support API로 요청을 중계합니다. -## 1. 빠른 시작 +## 운영 구조 -### 로컬 실행 +```text +사용자 브라우저 + │ BARON-SSO 로그인 / 피드백 작성 + ▼ +피드백 작성 웹 (Next.js, 이 저장소) + │ 서버사이드 proxy + │ https://feedback.hmac.kr/api/support + ▼ +관리 콘솔의 Secretary/ABC API + ├─ 관리 티켓·권한·첨부파일 저장 + ├─ ABC 피드백 원본 저장 + └─ 관리 콘솔에서 목록·상세·댓글·상태 처리 +``` + +운영 Compose에는 `web` 서비스만 포함됩니다. + +| 항목 | 값 | +| --- | --- | +| 작성 페이지 | `/support/{workspaceCode}/new` | +| 기본 예시 workspace | `EGBIM_DEMO` | +| 외부 API | `https://feedback.hmac.kr/api/support` | +| 기본 스테이징 주소 | `http://10.13.10.4:8864` | +| 외부 공개 컨테이너 | `web` 하나, 기본 host port `8864` | +| health check | `/api/health` | + +## 동기화와 보안 경계 + +피드백 등록 요청은 다음 순서로 처리됩니다. + +1. 사용자가 이 서버의 `/api/auth/baron-sso/login`에서 BARON-SSO에 로그인합니다. +2. callback에서 이 서버가 authorization code를 서버 간 통신으로 교환하고 사용자 정보를 조회합니다. +3. 이 서버가 자체 세션 JWT를 `HttpOnly` 쿠키에 저장합니다. `SSO_CLIENT_SECRET`과 `JWT_SECRET`은 서버에서만 사용합니다. +4. 브라우저는 `/api/support/access`, `/form-template`, `/submit` 같은 same-origin API를 호출합니다. +5. Next.js proxy가 현재 세션의 `Authorization`을 관리 콘솔 API로 전달합니다. +6. 관리 콘솔이 Secretary와 ABC에 저장하고, 관리 콘솔에서 조회할 수 있게 합니다. + +브라우저가 ABC API, Secretary API, DB, 첨부파일 저장소에 직접 접근하지 않도록 하는 것이 핵심입니다. `NEXT_PUBLIC_API_BASE_URL`은 운영에서 빈 값이어야 하며, `SUPPORT_CONSOLE_API_BASE_URL`은 서버 runtime 변수로만 주입합니다. + +첨부파일은 파일당·전체 합계 최대 30MB, 최대 10개입니다. 작성 서버가 파일을 임시 파싱한 뒤 관리 콘솔 API로 multipart 요청을 전달하고, 실제 영구 저장은 관리 콘솔/Secretary가 담당합니다. + +## 로컬 개발 필요 조건: -- Node.js와 `pnpm@10.32.1` +- Node.js `>=24.14.1` +- `pnpm@10.32.1` - Docker 및 Docker Compose -- `apps/secretary-api/.venv`와 Secretary API 의존성 -권장 실행 명령은 루트의 `start-local.sh`입니다. 이 스크립트가 로컬 MySQL 2개와 smtp4dev를 올린 뒤 Web, ABC API, Secretary API를 함께 실행합니다. +전체 플랫폼을 로컬에서 실행할 때: ```bash pnpm install ./start-local.sh ``` -접속 주소: - -- Web: -- ABC API: -- ABC Swagger: -- ABC 관리자 Swagger: -- Secretary API: -- Secretary Swagger: -- SMTP 테스트함: - -이미 인프라를 실행한 상태에서 개발 서버만 시작하려면 다음을 사용할 수 있습니다. +작성 웹만 기존 관리 콘솔에 연결해 확인하려면 `apps/web/.env.local`에 실제 테스트용 값을 넣고 다음을 실행합니다. ```bash pnpm dev:local ``` -포트 `3100`, `4000`, `8010`을 사용하는 프로세스가 있으면 스크립트가 임의로 종료하지 않고 중단합니다. 먼저 사용 중인 프로세스를 확인한 뒤 종료하고 다시 실행합니다. +실제 secret, API key, 개인키, 운영용 JWT secret은 `.env` 파일이나 저장소에 넣지 않습니다. 로컬에서는 별도 테스트용 값과 테스트용 BARON-SSO RP를 사용합니다. -```bash -lsof -nP -iTCP:3100 -sTCP:LISTEN -lsof -nP -iTCP:4000 -sTCP:LISTEN -lsof -nP -iTCP:8010 -sTCP:LISTEN -``` - -### 로컬 검증 +## 검증 명령 ```bash pnpm lint @@ -53,229 +77,54 @@ pnpm typecheck pnpm build ``` -E2E는 테스트용 데이터베이스를 초기화할 수 있으므로 로컬 개발 데이터와 분리된 환경에서 실행합니다. +E2E는 DB를 초기화할 수 있으므로 개발 데이터와 분리된 환경에서만 실행합니다. ```bash pnpm test:e2e ``` -## 2. 서비스 구조 +## 스테이징 배포 -```text -브라우저 - │ - ▼ -apps/web Next.js 화면 및 내부 BFF - ├─▶ apps/api NestJS, ABC 피드백·이슈 API - │ └─▶ ABC MySQL - └─▶ apps/secretary-api FastAPI, SSO 접근·워크스페이스·업무 보조 API - └─▶ Secretary MySQL +`main` 브랜치 push 또는 Gitea Actions의 수동 실행으로 [`.gitea/workflows/deploy-staging.yml`](./.gitea/workflows/deploy-staging.yml)이 실행됩니다. -외부 연동: BARON-SSO · Gitea · SMTP · R2/S3 호환 스토리지 · OpenSearch(선택) -``` +workflow는 다음 작업만 수행합니다. -| 구성요소 | 책임 | 기본 포트 | -| --- | --- | ---: | -| `apps/web` | 관리자 콘솔, 사용자 피드백 화면, Secretary 내부 BFF | `3100` 로컬 / `3030` Docker | -| `apps/api` | ABC 프로젝트·채널·피드백·이슈·댓글·필드·통계 API | `4000` | -| `apps/secretary-api` | SSO 접근권한, 워크스페이스, 지원 업무 API | `8010` | -| ABC MySQL | 피드백과 이슈 원본 데이터 | `13306` 로컬 | -| Secretary MySQL | 접근권한과 업무 보조 데이터 | `13308` 로컬 | +1. 저장소를 checkout합니다. +2. 필수 Gitea Variables/Secrets가 있는지 검사합니다. +3. SSH로 스테이징 서버에 소스를 전송합니다. +4. 원격에서 `docker compose -f docker/docker-compose.prod.yml config --quiet`를 실행합니다. +5. `web` 이미지를 빌드하고 컨테이너를 재기동합니다. +6. `http://127.0.0.1:${WEB_PORT}/api/health`가 응답할 때까지 확인합니다. -## 3. 데이터와 SSOT 원칙 +환경값은 스테이징 서버의 `.env`를 자동으로 갱신하는 방식이 아니라 workflow에서 원격 `docker compose` 명령의 runtime 환경으로 전달됩니다. 수동 배포 시에도 같은 값을 명시해야 합니다. -피드백의 원본은 관리자 화면이 아니라 ABC API/ABC DB입니다. 관리자 콘솔과 사용자 페이지 모두 같은 ABC API를 조회·작성·수정·삭제에 사용해야 합니다. +Gitea 등록 절차와 키의 위치는 [`GITEA_VARIABLES.md`](./GITEA_VARIABLES.md), 배포 후 점검 순서는 [`STAGING_DEPLOYMENT_CHECKLIST.md`](./STAGING_DEPLOYMENT_CHECKLIST.md), 데이터 흐름은 [`FEEDBACK_DATA_FLOW.md`](./FEEDBACK_DATA_FLOW.md), 전체 동기화 운영 절차는 [`FEEDBACK_SYNC_GUIDE.md`](./FEEDBACK_SYNC_GUIDE.md)를 참고합니다. -ABC가 보유하는 원본: +## 가장 중요한 유의사항 -- 피드백 ID, 제목, 내용, 생성일, 수정일 -- 작성자 이름·이메일·부서·연락처와 SSO 식별자 -- 카테고리, 비밀글 여부, IP 주소, MAC 주소 -- 중요도와 피드백 처리 상태 -- 첨부파일, 댓글, 내부 메모 -- ABC 이슈 연결 관계 +- `NEXT_PUBLIC_API_BASE_URL`에 `localhost`, `127.0.0.1`, 내부 Docker 주소를 넣지 않습니다. 운영 브라우저 요청은 빈 값으로 same-origin을 사용합니다. +- `SUPPORT_CONSOLE_API_BASE_URL`은 `https://feedback.hmac.kr/api/support`여야 합니다. 끝의 `/api/support`를 생략하면 proxy가 `/api`를 보완하지만, 운영에서는 명시된 기본값을 유지합니다. +- 작성 서버와 관리 콘솔이 검증하는 JWT의 `JWT_SECRET`은 같은 값이어야 합니다. 임의로 서로 다른 값을 만들면 로그인은 성공해도 `/api/support/access`가 `401`이 됩니다. +- BARON-SSO에 등록한 redirect URI의 scheme, host, port, path가 실제 접속 주소와 정확히 같아야 합니다: `/api/auth/baron-sso/callback`. +- `10.13.10.4:8864`는 내부 스테이징 기준 주소입니다. 외부 공개 시에는 TLS reverse proxy 뒤에 두고 `X-Forwarded-Proto`와 redirect URI를 HTTPS 기준으로 맞춥니다. +- 운영 Compose는 API·Secretary·MySQL을 띄우지 않습니다. 이 서버에 DB migration, `MASTER_API_KEY`, `SECRETARY_ABC_API_KEY`를 추가하는 것으로 동기화가 해결되지 않습니다. +- 기존 데이터가 있는 서버에서 `docker compose down -v`를 실행하지 않습니다. DB를 직접 관리하는 콘솔 서버와 이 작성 서버를 혼동하지 않습니다. +- Gitea Actions 로그에 secret을 출력하거나 `docker compose config` 결과를 공개 로그에 남기지 않습니다. -Secretary가 보유하는 데이터: +## 문제 해결 순서 -- SSO 접근권한과 역할 -- 프로젝트/워크스페이스 접근 설정 -- 업무용 담당자·승인·알림·매핑 메타데이터 - -Secretary DB에 피드백 제목·내용·상태를 별도로 복제하지 않습니다. 연결이 필요하면 `abc_feedback_id` 같은 식별자만 보조 데이터로 사용합니다. 피드백 상태와 이슈 상태도 서로 독립적으로 관리합니다. - -## 4. 인증과 권한 흐름 - -1. 사용자가 BARON-SSO OAuth/OIDC 로그인 화면으로 이동합니다. -2. Web의 callback이 인증 코드를 ABC API에 전달합니다. -3. ABC API가 SSO 프로필을 조회하고 사용자·테넌트 정보를 반영한 JWT를 발급합니다. -4. Web은 세션 쿠키로 JWT를 유지합니다. -5. 접근 가능한 프로젝트와 워크스페이스를 조회한 뒤 관리자 통합 대시보드 또는 사용자 피드백 화면으로 분기합니다. - -관련 구현 위치: - -- SSO callback: [`apps/web/src/features/auth/sign-in-with-oauth/lib/use-oauth-callback.ts`](./apps/web/src/features/auth/sign-in-with-oauth/lib/use-oauth-callback.ts) -- API 인증: [`apps/api/src/domains/admin/auth/`](./apps/api/src/domains/admin/auth/) -- Web 접근 제어: [`apps/web/src/proxy.ts`](./apps/web/src/proxy.ts) -- Secretary 접근 정보: [`apps/secretary-api/`](./apps/secretary-api/) - -SSO 프로필의 `name`, `email`, `phones`, `employee_id`, `status` 등의 필드를 사용할 때는 BARON-SSO의 `profile` scope와 실제 callback 응답을 함께 확인합니다. 토큰, client secret, API key는 코드나 README에 기록하지 않습니다. - -## 5. 주요 기능 사용 가이드 - -### 사용자 피드백 - -사용자는 프로젝트/채널의 필드 설정에 따라 피드백을 등록합니다. 현재 확장 필드에는 구분, 중요도, IP, MAC 주소 등이 포함될 수 있으며, 비밀글과 첨부파일을 지원합니다. 사용자 목록은 제목·내용·작성자 검색, 작성자 본인 글 필터, 구분 필터, 정렬, 10건 단위 페이지 이동을 제공합니다. - -### 관리자 피드백 처리 - -관리자는 피드백 상태, 중요도, 피드백 담당자를 관리하고 댓글과 내부 메모를 구분해 기록합니다. 내부 메모는 관리자에게만 노출되며 일반 댓글과 동시에 저장되지 않아야 합니다. 피드백을 이슈에 연결해도 피드백 상태와 이슈 상태는 각각 별도로 처리합니다. - -### 이슈와 Gitea - -이슈를 연결한 뒤 이슈 담당자를 지정하고 Gitea 이슈를 생성·연결·동기화할 수 있습니다. 하나의 이슈에 여러 피드백을 연결할 수 있으며, 연결된 피드백 목록과 Gitea 상태를 확인합니다. - -### 통합 관리자 대시보드 - -여러 프로젝트의 관리자에게 지정된 사용자는 홈 버튼을 통해 통합 대시보드로 이동합니다. 피드백 처리 탭에서는 담당자 지정·댓글·피드백 상태를, 이슈 처리 탭에서는 Gitea 연결·이슈 상태·연결 피드백을 프로젝트별로 처리합니다. 대시보드의 집계와 Todo는 권한이 있는 프로젝트만 대상으로 합니다. - -## 6. API와 Swagger - -ABC API 문서는 공개 연동 API와 관리자 API를 분리합니다. - -| 구분 | 로컬 경로 | 주요 인증 | -| --- | --- | --- | -| 공개 API Swagger | `/docs` | API Key가 필요한 엔드포인트는 API Key | -| 관리자 API Swagger | `/admin-docs` | JWT 및 권한 | -| 공개 OpenAPI JSON | `/docs-json` | 환경 설정에 따름 | -| 관리자 OpenAPI JSON | `/admin-docs-json` | 환경 설정에 따름 | -| Secretary Swagger | `:8010/docs` | FastAPI 설정에 따름 | - -로컬에서는 API 포트로 직접 확인합니다. Docker 스테이징에서는 API 컨테이너 포트를 외부에 별도로 열지 않고 Web reverse proxy를 통해 다음 경로를 사용합니다. 최신 Web 이미지가 배포된 뒤 동작합니다. - -- -- -- -- Secretary OpenAPI JSON: - -API 패키징 시에는 화면용 Next.js `/api/support/*` BFF를 외부 계약으로 사용하지 않고, NestJS 공개/관리자 API와 Secretary API를 공식 경계로 취급합니다. API 버전, 페이지네이션, 동적 필드, 오류 형식, 댓글 공개 범위는 [`docs/api-packaging-tasks.md`](<./docs/api-packaging-tasks.md>)를 기준으로 확정합니다. - -## 7. 환경변수와 비밀값 - -환경별 값을 코드와 문서에 하드코딩하지 않습니다. 스테이징에서는 Gitea Actions 변수/Secret 또는 서버의 실제 환경 주입 방식을 사용합니다. - -주요 변수 그룹: - -- 실행: `APP_ENV`, `WEB_PORT`, `APP_PORT`, `SECRETARY_API_PORT` -- Web/API 주소: `NEXT_PUBLIC_API_BASE_URL`, `ADMIN_WEB_URL`, `BASE_URL` -- 인증: `JWT_SECRET`, `MASTER_API_KEY`, `SSO_ISSUER`, `SSO_CLIENT_ID`, `SSO_CLIENT_SECRET` -- 연동: `GITEA_API_URL`, `GITEA_API_TOKEN`, SMTP 변수, `SECRETARY_ABC_API_KEY` -- 운영: `AUTO_MIGRATION`, `OPENSEARCH_USE` 및 OpenSearch 변수 - -Docker 내부 서비스는 `api:4000`, `secretary-api:8010`, `mysql:3306`, `mysql-secretary:3306`으로 통신합니다. 브라우저에 노출되는 외부 포트는 Web 포트 하나로 제한합니다. 환경변수 전체 목록과 Gitea 등록 규칙은 [`GITEA_VARIABLES.md`](./GITEA_VARIABLES.md)를 확인합니다. - -R2/S3 호환 첨부파일은 버킷과 endpoint를 환경 또는 관리자 설정으로 주입합니다. Access Key, Secret Key, API Token은 절대 커밋하지 않습니다. 이미지·첨부파일 저장 원칙은 [`GUIDE.md`](./GUIDE.md)를 참고합니다. - -## 8. 스테이징 배포 - -배포 전 로컬: +1. 브라우저 주소와 BARON-SSO redirect URI를 확인합니다. +2. 브라우저 Network에서 `401`, `404`, `413`, `502` 응답을 확인합니다. +3. `401`이면 `SSO_CLIENT_ID`, `SSO_CLIENT_SECRET`, `JWT_SECRET`, 관리 콘솔의 JWT 검증 설정을 확인합니다. +4. `404`이면 `SUPPORT_CONSOLE_API_BASE_URL`과 workspace code를 확인합니다. +5. `413`이면 파일당 30MB·전체 30MB·최대 10개 제한을 확인합니다. +6. `502`이면 작성 서버에서 관리 콘솔로 나가는 네트워크와 외부 API 로그를 확인합니다. +7. 컨테이너 상태와 로그를 확인합니다. ```bash -git status -pnpm lint -pnpm typecheck -pnpm build +docker compose -f docker/docker-compose.prod.yml ps +docker compose -f docker/docker-compose.prod.yml logs --tail=200 web +curl -fsS http://127.0.0.1:8864/api/health ``` -스테이징 서버에서는 실제 환경 파일 또는 배포 시스템이 제공하는 환경을 사용해 Compose 설정을 먼저 검증합니다. - -```bash -cd ~/baron_qa -docker compose --env-file <실제-환경파일> \ - -f docker/docker-compose.prod.yml config - -docker compose --env-file <실제-환경파일> \ - -f docker/docker-compose.prod.yml up -d --build - -docker compose --env-file <실제-환경파일> \ - -f docker/docker-compose.prod.yml ps -``` - -기존 데이터가 있는 서버에서 `docker compose down -v`를 실행하지 않습니다. `-v`는 DB 볼륨을 삭제할 수 있습니다. 최초 배포나 스키마 변경 시 백업, `AUTO_MIGRATION`, 로그, health check를 확인합니다. - -상세 순서는 [`STAGING_DEPLOYMENT_CHECKLIST.md`](./STAGING_DEPLOYMENT_CHECKLIST.md)를 따릅니다. - -## 9. 문제 해결 순서 - -1. 브라우저 주소가 `localhost`/`127.0.0.1`인지, 스테이징 도메인인지 확인합니다. -2. Network에서 요청 URL과 응답 코드, 특히 `401`, `404`, `500`을 확인합니다. -3. Web이 API를 `localhost`가 아닌 Docker 내부 `api:4000`으로 호출하는지 확인합니다. -4. SSO callback URL의 host, scheme, path가 BARON-SSO 등록값과 같은지 확인합니다. -5. 프로젝트/채널/워크스페이스 이름과 ABC 매핑을 확인합니다. -6. 서버 로그를 확인합니다. - -```bash -docker compose --env-file <실제-환경파일> \ - -f docker/docker-compose.prod.yml logs --tail=200 web api secretary-api -``` - -데이터가 보이지 않는다고 DB를 초기화하거나 볼륨을 삭제하지 않습니다. 먼저 API 응답의 프로젝트 ID, 채널 ID, 테넌트, 권한을 확인합니다. - -## 10. 상세 문서 목차 - -### 운영·배포 - -- [`STAGING_DEPLOYMENT_CHECKLIST.md`](./STAGING_DEPLOYMENT_CHECKLIST.md): 정적 검사, 환경변수, Compose 배포, 배포 후 검증 -- [`GITEA_VARIABLES.md`](./GITEA_VARIABLES.md): Gitea Actions 변수/Secret 및 스테이징 매핑 -- [`GUIDE.md`](./GUIDE.md): S3/S3 호환 스토리지와 Webhook 관련 기본 가이드 - -### API·SSOT·기능 작업 - -- [`api-packaging-tasks.md`](<./docs/api-packaging-tasks.md>): 외부 패키지용 API 경계, Swagger, DTO, 버전 정책 -- [`ssot-feedback-rearchitecture-tasks.md`](<./docs/ssot-feedback-rearchitecture-tasks.md>): ABC DB를 피드백 SSOT로 통일한 단계별 작업 -- [`qna-platform-prototype-2-feedback-tasks.md`](<./docs/qna-platform-prototype-2-feedback-tasks.md>): Q&A 관리자 콘솔 기능과 API 작업 이력 - -### 통합 아키텍처 - -- [`architecture_secretary_sso_components_v2.md`](<./docs/architecture_secretary_sso_components_v2.md>): 현재 통합 구조를 설명하는 우선 참고 문서 -- [`architecture_secretary_sso_role_access.md`](<./docs/architecture_secretary_sso_role_access.md>): 역할, 테넌트, 로그인 후 접근 분기 -- [`architecture_secretary_sso_user_scenarios.md`](<./docs/architecture_secretary_sso_user_scenarios.md>): 사용자 유형별 업무 시나리오 -- [`architecture.md`](<./docs/architecture.md>): 초기 통합 지원 플랫폼 설계 -- [`architecture_secretary_sso_components.md`](<./docs/architecture_secretary_sso_components.md>): 통합 컴포넌트 설계 초안 - -### SSO - -- [`BARON-SSO server-side-app guide.md`](<./docs/BARON-SSO server-side-app guide.md>): 서버 애플리케이션의 BARON-SSO 연동 예시 -- [`Back-Channel Logout.md`](<./docs/Back-Channel Logout.md>): Back-Channel Logout 처리 순서와 구현 위치 -- [`architecture_secretary_sso_setup_tasks_v2.md`](<./docs/architecture_secretary_sso_setup_tasks_v2.md>): 최신 SSO 연계 셋업 및 남은 작업 - -### 데이터 이관·통합 대시보드 - -- [`egbim_to_secretary_staging_migration_tasks.md`](<./docs/egbim_to_secretary_staging_migration_tasks.md>): EGBIM/Secretary 데이터 이관 범위와 검증 -- [`multi-project-admin-dashboard-design.md`](<./docs/multi-project-admin-dashboard-design.md>): 다중 프로젝트 관리자 통합 대시보드 설계 - -### 이전 버전·참고 문서 - -다음 문서는 앞선 설계 버전 또는 중복된 셋업 문서입니다. 현재 구현과 충돌할 경우 코드와 위의 v2/SSOT 문서를 우선합니다. - -- [`architecture_secretary.md`](<./docs/architecture_secretary.md>) -- [`architecture_secretary_sso.md`](<./docs/architecture_secretary_sso.md>) -- [`architecture_secretary_sso_setup_tasks.md`](<./docs/architecture_secretary_sso_setup_tasks.md>) - -문서의 작업 완료 표시는 당시 기준의 기록입니다. 배포 전에는 반드시 현재 코드, Compose 파일, 환경변수와 함께 대조합니다. - -## 11. 저장소 구조 - -```text -apps/ - api/ NestJS ABC API - web/ Next.js Web/BFF - secretary-api/ FastAPI Secretary API - e2e/ Playwright E2E - docs/ Docusaurus 기반 일반 제품 문서 -docs/ 프로젝트 설계·작업·운영 보조 문서 -docker/ 로컬/스테이징 Compose 및 Dockerfile -scripts/ 로컬 실행·이관·개발 보조 스크립트 -uploads/ 로컬 첨부파일 마운트 경로 -``` - -기여 방법은 [`CONTRIBUTING.md`](./CONTRIBUTING.md), 라이선스는 [`LICENSE`](./LICENSE)를 확인합니다. +상세 운영 절차는 [`FEEDBACK_SYNC_GUIDE.md`](./FEEDBACK_SYNC_GUIDE.md)를 기준으로 합니다. diff --git a/STAGING_DEPLOYMENT_CHECKLIST.md b/STAGING_DEPLOYMENT_CHECKLIST.md index 9d22eaa..f9e5b7b 100644 --- a/STAGING_DEPLOYMENT_CHECKLIST.md +++ b/STAGING_DEPLOYMENT_CHECKLIST.md @@ -1,180 +1,93 @@ -# 스테이징 배포 체크리스트 +# 피드백 작성 서버 스테이징 배포 체크리스트 -현재 로컬 변경사항을 스테이징 서버에 배포한 뒤 테스트하기 위한 절차입니다. +현재 스테이징 대상은 피드백 작성용 `web` 컨테이너입니다. 관리 콘솔의 API·DB를 이 서버에 함께 배포하지 않습니다. -## 1. 배포 전 로컬 확인 +## 1. 배포 전 -E2E 테스트는 배포 후 진행합니다. 배포 전에는 정적 검사와 빌드만 확인합니다. +- [ ] 변경 범위가 작성 화면, Next.js proxy, SSO, Docker/workflow인지 확인 +- [ ] `git status --short`에서 `.env`, API key, secret, private key가 없는지 확인 +- [ ] `pnpm lint` 통과 +- [ ] `pnpm typecheck` 통과 +- [ ] `pnpm build` 통과 +- [ ] `NEXT_PUBLIC_API_BASE_URL`을 운영 build에 넣지 않는지 확인 +- [ ] `SUPPORT_CONSOLE_API_BASE_URL`이 `https://feedback.hmac.kr/api/support`인지 확인 +- [ ] BARON-SSO에 실제 callback URI가 등록되어 있는지 확인 +- [ ] 관리 콘솔의 Support API와 `JWT_SECRET`이 같은지 확인 ```bash -git status -git diff --stat +git status --short pnpm lint pnpm typecheck pnpm build ``` -빌드가 성공하면 변경사항을 커밋하고 스테이징 배포 브랜치에 푸시합니다. +## 2. Gitea 설정 + +상세 등록 방법은 [`GITEA_VARIABLES.md`](./GITEA_VARIABLES.md)를 기준으로 합니다. + +- [ ] `STAGING_HOST=10.13.10.4` +- [ ] `WEB_PORT=8864` +- [ ] `STAGING_USER` 등록 +- [ ] `STAGING_SSH_PRIVATE_KEY` 등록 +- [ ] 검증된 `STAGING_SSH_KNOWN_HOSTS` 등록 +- [ ] `SSO_CLIENT_ID`는 Variable에 등록 +- [ ] `SSO_CLIENT_SECRET`는 Secret에 등록 +- [ ] `JWT_SECRET`는 관리 콘솔과 동일한 Secret으로 등록 +- [ ] `SUPPORT_TENANT_ID`가 필요한 환경인지 확인 + +## 3. 배포 + +`main` push 또는 Gitea Actions의 `Deploy feedback demo` 수동 실행을 사용합니다. workflow는 SSH로 소스를 전송한 뒤 원격에서 다음을 수행합니다. ```bash -git add . -git commit -m "Update feedback workflow" -git push origin +docker compose -f docker/docker-compose.prod.yml config --quiet +docker compose -f docker/docker-compose.prod.yml up -d --build ``` -`.env` 파일, 비밀번호, API 키, E2E 결과 파일은 커밋하지 않습니다. +workflow가 성공하기 전에 다음을 확인합니다. -## 2. 스테이징 환경변수 준비 +- [ ] `Validate deployment settings`가 필수 값 존재 검사를 통과 +- [ ] SSH host key 검증 통과 +- [ ] 원격 Docker build 성공 +- [ ] `/api/health` check 통과 -스테이징 서버에 `.env.staging` 파일을 준비하고 다음 항목을 설정합니다. - -- `NEXT_PUBLIC_API_BASE_URL` -- `ADMIN_WEB_URL` -- `BASE_URL` -- `JWT_SECRET` -- `MASTER_API_KEY` -- `SECRETARY_ABC_API_KEY` -- SSO issuer, client ID, client secret -- Gitea API URL 및 token -- SMTP 설정 -- OpenSearch 설정 -- 최초 배포 시 `AUTO_MIGRATION=true` - -웹 환경변수는 Docker 이미지 빌드 시 `apps/web/.env.build`에서 사용됩니다. - -```text -apps/web/.env.build -``` - -스테이징 API 주소가 설정되어 있어야 하며, `localhost` 또는 `127.0.0.1` 주소가 남아 있으면 안 됩니다. - -## 3. 스테이징 서버에서 코드 갱신 +## 4. 배포 후 확인 ```bash -git pull origin +docker compose -f docker/docker-compose.prod.yml ps +docker compose -f docker/docker-compose.prod.yml logs --tail=200 web +curl -i http://127.0.0.1:8864/api/health ``` -배포 전에 Compose 설정을 검증합니다. +브라우저에서 다음 순서로 확인합니다. -```bash -docker compose \ - --env-file .env.staging \ - -f docker/docker-compose.prod.yml config -``` +- [ ] `/support/EGBIM_DEMO/new` 접속 시 BARON-SSO 로그인으로 이동 +- [ ] callback 후 작성 페이지로 복귀 +- [ ] `/api/support/access`가 200 응답 +- [ ] 관리 콘솔 workspace 양식 조회 +- [ ] 제목·내용 등록 +- [ ] IP 주소·MAC 주소 포함 등록 +- [ ] 이미지와 PDF/문서 첨부 등록 +- [ ] 파일당 30MB 초과, 전체 30MB 초과, 10개 초과 차단 +- [ ] 등록 결과가 `feedback.hmac.kr` 관리 콘솔에 표시 +- [ ] 첨부파일이 관리 콘솔에서 열림 -## 4. 스테이징 배포 +## 5. 상태 코드별 확인 -```bash -docker compose \ - --env-file .env.staging \ - -f docker/docker-compose.prod.yml \ - up -d --build -``` +| 상태 | 확인할 내용 | +| --- | --- | +| `401` | SSO client, `JWT_SECRET`, userinfo의 `sub`/tenant, 관리 콘솔 access API | +| `404` | Support API base URL, workspace code, 관리 콘솔 mapping | +| `413` | 파일당 30MB·전체 30MB·최대 10개 제한 | +| `502` | 작성 서버→관리 콘솔 네트워크, DNS, TLS, 외부 API 상태 | +| callback 오류 | BARON-SSO redirect URI, `Host`, `X-Forwarded-Proto` | -컨테이너 상태와 로그를 확인합니다. +## 6. 롤백과 금지 명령 -```bash -docker compose \ - --env-file .env.staging \ - -f docker/docker-compose.prod.yml ps +- [ ] 문제가 있으면 마지막 정상 commit으로 다시 workflow 실행 +- [ ] 기존 관리 콘솔 데이터와 작성 서버 배포를 구분 +- [ ] 관리 콘솔 DB migration을 이 서버에 적용하지 않음 +- [ ] 기존 데이터가 있는 서버에서 `docker compose down -v`를 실행하지 않음 +- [ ] 장애 확인을 위해 secret을 로그에 출력하지 않음 -docker compose \ - --env-file .env.staging \ - -f docker/docker-compose.prod.yml \ - logs --tail=200 api secretary-api web -``` - -기존 스테이징 데이터가 있다면 다음 명령은 실행하지 않습니다. - -```bash -docker compose down -v -``` - -`-v` 옵션은 데이터베이스 볼륨을 삭제할 수 있습니다. 기존 데이터가 있다면 배포 전에 DB 백업도 진행합니다. - -## 5. 배포 후 테스트 순서 - -### 로그인 및 권한 - -- 관리자 SSO 로그인 및 callback URL 확인 -- 관리자 계정이 콘솔/대시보드로 이동하는지 확인 -- 일반 사용자 계정이 피드백 리스트로 이동하는지 확인 -- 브라우저 콘솔과 Network에 401/500 오류가 없는지 확인 - -### 피드백 목록 - -- 리스트에 10개씩 표시되는지 확인 -- 첫 페이지, 다음 페이지, 마지막 페이지 이동 확인 -- ID, 제목, 내용, 이슈, 상태, Created, Updated 순서 확인 -- 정렬 기능 확인 -- 리스트/칸반 전환 확인 -- 상태별 조회 확인 -- 중요도 표시와 중요도별 배경색 확인 - -### 피드백 상태 및 상세 - -- 피드백 상태가 6단계로 표시되는지 확인 -- 칸반에서 드래그하여 상태 변경되는지 확인 -- 드래그 중 카드가 마우스를 따라가는 모션 확인 -- 상태별 배경색 확인 -- 피드백 상태와 이슈 상태가 독립적으로 변경되는지 확인 -- IP 주소와 MAC 주소가 상세 페이지에 표시되는지 확인 -- 수정 페이지에서 IP 주소와 MAC 주소를 수정할 수 있는지 확인 -- 입력 항목의 툴팁 안내문구 확인 - -### 내부 메모 및 댓글 - -- 내부 메모가 관리자 화면에서만 표시되는지 확인 -- 내부 메모 저장 시 외부 댓글이 동시에 등록되지 않는지 확인 -- 외부 댓글 등록 시 내부 메모가 중복 생성되지 않는지 확인 - -### 이슈 및 Gitea 연동 - -- 피드백에서 이슈를 연결할 수 있는지 확인 -- 하나의 이슈에 여러 피드백을 연결할 수 있는지 확인 -- 추가로 연결한 피드백도 Gitea 이슈에 반영되는지 확인 -- 이슈 연결 후 이슈 관리자를 지정할 수 있는지 확인 -- 이슈 관리자 목록이 설정 페이지의 등록 목록과 일치하는지 확인 -- 이슈 상태는 Gitea 처리 기준으로 독립적으로 변경되는지 확인 - -## 6. 문제 발생 시 확인할 로그 - -```bash -docker compose \ - --env-file .env.staging \ - -f docker/docker-compose.prod.yml \ - logs -f api - -docker compose \ - --env-file .env.staging \ - -f docker/docker-compose.prod.yml \ - logs -f secretary-api - -docker compose \ - --env-file .env.staging \ - -f docker/docker-compose.prod.yml \ - logs -f web -``` - -브라우저에서는 다음을 함께 확인합니다. - -- Console 오류 -- Network 요청 URL -- 응답 상태 코드 -- 401 Unauthorized 여부 -- API callback 및 redirect 주소 -- Gitea, SMTP, OpenSearch 연결 오류 - -## 7. 롤백 시 주의사항 - -- 이전 정상 커밋 또는 태그를 유지합니다. -- DB 백업을 먼저 확보합니다. -- 애플리케이션 롤백 시에도 DB 볼륨은 삭제하지 않습니다. -- 마이그레이션이 포함된 배포는 애플리케이션만 무조건 이전 버전으로 되돌리지 않습니다. - -현재 로컬 서버는 `pnpm dev:local`로 실행 중이며, 스테이징 배포와는 별개입니다. 스테이징 배포는 로컬 작업 디렉터리에서 직접 실행하지 말고, 커밋 후 스테이징 서버에서 코드를 갱신한 뒤 진행합니다. - -배포 기준 Compose 파일: - -- `docker/docker-compose.prod.yml` -- `docker/web.dockerfile` +이 서버의 health check가 정상이어도 외부 관리 콘솔 등록까지 성공했다는 뜻은 아닙니다. 반드시 실제 SSO 로그인, 양식 조회, 피드백 등록, 관리 콘솔 조회를 모두 확인합니다. diff --git a/apps/web/src/entities/feedback/ui/feedback-detail-sheet.ui.tsx b/apps/web/src/entities/feedback/ui/feedback-detail-sheet.ui.tsx index c1ee9a1..c244bd5 100644 --- a/apps/web/src/entities/feedback/ui/feedback-detail-sheet.ui.tsx +++ b/apps/web/src/entities/feedback/ui/feedback-detail-sheet.ui.tsx @@ -1018,26 +1018,6 @@ const FeedbackDetailSheet = (props: Props) => { )}

-
-

사용자 IP

-

- {String( - currentFeedback.IP ?? - ticketExtraFields.ip_address ?? - '-', - )} -

-
-
-

MAC 주소

-

- {String( - currentFeedback.MAC_address ?? - ticketExtraFields.mac_address ?? - '-', - )} -

-
diff --git a/apps/web/src/features/support-portal/lib/support-feedback-status.ts b/apps/web/src/features/support-portal/lib/support-feedback-status.ts new file mode 100644 index 0000000..2d9c161 --- /dev/null +++ b/apps/web/src/features/support-portal/lib/support-feedback-status.ts @@ -0,0 +1,46 @@ +export const SUPPORT_FEEDBACK_STATUS_CODES = [ + 'NEW', + 'RECEIVED', + 'IN_PROGRESS', + 'COMPLETED', + 'ON_HOLD', +] as const; + +export type SupportFeedbackStatusCode = + (typeof SUPPORT_FEEDBACK_STATUS_CODES)[number]; + +const LEGACY_STATUS_TO_CURRENT: Record = { + INIT: 'NEW', + ON_REVIEW: 'RECEIVED', + DETAILED_REVIEW: 'RECEIVED', + IN_PROGRESS: 'IN_PROGRESS', + RESOLVED: 'COMPLETED', + PENDING: 'ON_HOLD', +}; + +const STATUS_LABELS: Record = { + NEW: '신규', + RECEIVED: '접수', + IN_PROGRESS: '진행중', + COMPLETED: '완료', + ON_HOLD: '보류', +}; + +export const normalizeSupportFeedbackStatus = ( + value?: string | null, +): SupportFeedbackStatusCode | null => { + if (!value) return null; + + if ( + (SUPPORT_FEEDBACK_STATUS_CODES as readonly string[]).includes(value) + ) { + return value as SupportFeedbackStatusCode; + } + + return LEGACY_STATUS_TO_CURRENT[value] ?? null; +}; + +export const getSupportFeedbackStatusLabel = (value?: string | null) => { + const normalized = normalizeSupportFeedbackStatus(value); + return normalized ? STATUS_LABELS[normalized] : value || '상태 미상'; +}; diff --git a/apps/web/src/features/support-portal/ui/support-status-badge.ui.tsx b/apps/web/src/features/support-portal/ui/support-status-badge.ui.tsx index fb979f3..7c1be5b 100644 --- a/apps/web/src/features/support-portal/ui/support-status-badge.ui.tsx +++ b/apps/web/src/features/support-portal/ui/support-status-badge.ui.tsx @@ -13,8 +13,16 @@ * License for the specific language governing permissions and limitations * under the License. */ +import { + getSupportFeedbackStatusLabel, + normalizeSupportFeedbackStatus, +} from '../lib/support-feedback-status'; + const statusMap: Record = { + NEW: 'bg-[#f2d5d5] text-[#8c3030]', RECEIVED: 'bg-[#f3ead0] text-[#7d5b16]', + COMPLETED: 'bg-[#dcede3] text-[#2f6f52]', + ON_HOLD: 'bg-[#f7dfd3] text-[#8b4228]', PENDING_APPROVAL: 'bg-[#f7dfd3] text-[#8b4228]', APPROVED: 'bg-[#d6e9d8] text-[#1e5d39]', IN_PROGRESS: 'bg-[#d9e7f5] text-[#204f79]', @@ -31,15 +39,27 @@ const statusMap: Record = { interface Props { label: string; value: string; + normalizeFeedbackStatus?: boolean; } -const SupportStatusBadge = ({ label, value }: Props) => { +const SupportStatusBadge = ({ + label, + value, + normalizeFeedbackStatus = false, +}: Props) => { + const normalizedValue = normalizeFeedbackStatus + ? normalizeSupportFeedbackStatus(value) ?? value + : value; + const displayValue = normalizeFeedbackStatus + ? getSupportFeedbackStatusLabel(value) + : value; + return ( - + {label} - {value} + {displayValue} ); }; -export default SupportStatusBadge; \ No newline at end of file +export default SupportStatusBadge; diff --git a/apps/web/src/pages/api/support/tickets/[ticketId]/completion-confirmation.ts b/apps/web/src/pages/api/support/tickets/[ticketId]/completion-confirmation.ts new file mode 100644 index 0000000..0c521cf --- /dev/null +++ b/apps/web/src/pages/api/support/tickets/[ticketId]/completion-confirmation.ts @@ -0,0 +1,56 @@ +import { createNextApiHandler } from '@/server/api-handler'; +import { getSupportAuthHeaders } from '@/server/support-auth'; +import { supportExternalUrl } from '@/server/support-external'; + +const readJsonOrMessage = async (response: Response) => { + const raw = await response.text(); + if (!raw.trim()) return {}; + + try { + return JSON.parse(raw) as unknown; + } catch { + return { message: raw.trim().slice(0, 1000) }; + } +}; + +const handler = createNextApiHandler({ + POST: async (req, res) => { + const ticketId = Number(req.query.ticketId); + const workspaceCode = + typeof req.query.workspaceCode === 'string' ? + req.query.workspaceCode.trim() + : ''; + + if (!Number.isInteger(ticketId) || ticketId <= 0) { + return res.status(400).json({ message: 'ticketId가 필요합니다.' }); + } + + if (!workspaceCode) { + return res.status(400).json({ message: 'workspaceCode가 필요합니다.' }); + } + + try { + const query = new URLSearchParams({ workspaceCode }); + const response = await fetch( + `${supportExternalUrl(`/tickets/${ticketId}/completion-confirmation`)}?${query.toString()}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getSupportAuthHeaders(req), + }, + body: JSON.stringify({}), + signal: AbortSignal.timeout(30000), + }, + ); + + return res.status(response.status).json(await readJsonOrMessage(response)); + } catch { + return res.status(502).json({ + message: '관리 콘솔 API에서 완료 확인을 처리하지 못했습니다.', + }); + } + }, +}); + +export default handler; diff --git a/apps/web/src/pages/support/[workspaceCode]/[ticketId].tsx b/apps/web/src/pages/support/[workspaceCode]/[ticketId].tsx index 101a22c..470d207 100644 --- a/apps/web/src/pages/support/[workspaceCode]/[ticketId].tsx +++ b/apps/web/src/pages/support/[workspaceCode]/[ticketId].tsx @@ -28,9 +28,14 @@ import { import type { NextPageWithLayout } from '@/shared/types'; import { formatSupportTimestamp } from '@/features/support-portal/lib/format-support-timestamp'; import { getSupportCategoryLabel } from '@/features/support-portal/lib/support-category'; +import { normalizeSupportFeedbackStatus } from '@/features/support-portal/lib/support-feedback-status'; import SupportPortalShell from '@/features/support-portal/ui/support-portal-shell.ui'; +import SupportStatusBadge from '@/features/support-portal/ui/support-status-badge.ui'; -import type { SupportTicketRecord } from '@/server/support-types'; +import type { + SupportCompletionConfirmationResponse, + SupportTicketRecord, +} from '@/server/support-types'; interface SupportTicketCommentRecord { comment_id: number; @@ -40,6 +45,8 @@ interface SupportTicketCommentRecord { author_name: string; content: string; is_internal: boolean; + comment_type?: string; + completion_notice?: boolean; created_at: string; updated_at: string; edited_at: string | null; @@ -69,6 +76,12 @@ const formatAttachmentSize = (fileSize?: number | null) => { return `${Math.max(1, Math.round(fileSize / 1024))} KB`; }; +const isCompletionNoticeComment = ( + comment: Pick, +) => + comment.completion_notice === true || + comment.comment_type === 'COMPLETION_NOTICE'; + const issueStatusLabelMap: Record = { INIT: '신규', ON_REVIEW: '검토중', @@ -110,11 +123,9 @@ const SupportDetailPage: NextPageWithLayout = () => { const [isEditing, setIsEditing] = useState(false); const [draftTitle, setDraftTitle] = useState(''); const [draftDescription, setDraftDescription] = useState(''); - const [draftExtraFields, setDraftExtraFields] = useState< - Record - >({}); const [isSaving, setIsSaving] = useState(false); const [isDeleting, setIsDeleting] = useState(false); + const [isCompletionConfirming, setIsCompletionConfirming] = useState(false); const [comments, setComments] = useState([]); const [commentDraft, setCommentDraft] = useState(''); const [editingCommentId, setEditingCommentId] = useState(null); @@ -186,7 +197,6 @@ const SupportDetailPage: NextPageWithLayout = () => { setTicket(data as SupportTicketRecord); setDraftTitle((data as SupportTicketRecord).title); setDraftDescription((data as SupportTicketRecord).description); - setDraftExtraFields((data as SupportTicketRecord).extra_fields); await fetchComments( ticketId, (data as SupportTicketRecord).extra_fields?.abc_feedback_id, @@ -416,11 +426,7 @@ const SupportDetailPage: NextPageWithLayout = () => { const nextTitle = draftTitle.trim(); const nextDescription = draftDescription.trim(); - const nextExtraFields = { - ...(ticket.extra_fields), - ip_address: draftExtraFields.ip_address?.trim() ?? '', - mac_address: draftExtraFields.mac_address?.trim() ?? '', - }; + const nextExtraFields = { ...ticket.extra_fields }; if (!nextTitle || !nextDescription) { toast.error('제목과 내용을 입력해 주세요.'); @@ -462,7 +468,6 @@ const SupportDetailPage: NextPageWithLayout = () => { setTicket(updatedTicket); setDraftTitle(updatedTicket.title); setDraftDescription(updatedTicket.description); - setDraftExtraFields(updatedTicket.extra_fields); setIsEditing(false); toast.success('문의가 수정되었습니다.'); } catch (error) { @@ -513,6 +518,78 @@ const SupportDetailPage: NextPageWithLayout = () => { } }; + const handleCompletionConfirmation = async () => { + if (!ticket || isCompletionConfirming) return; + + const confirmed = window.confirm( + '처리 결과를 확인했으며 이 문의를 완료 상태로 변경하시겠습니까?', + ); + if (!confirmed) return; + + setIsCompletionConfirming(true); + setErrorMessage(''); + + try { + const response = await fetchWithAuthRefresh( + `/api/support/tickets/${ticket.ticket_id}/completion-confirmation?${new URLSearchParams({ + workspaceCode, + }).toString()}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }, + ); + const data = (await response.json()) as + | SupportCompletionConfirmationResponse + | { message?: string; detail?: string }; + + if (!response.ok) { + throw new Error( + ('message' in data && data.message) || + ('detail' in data && data.detail) || + '완료 확인을 처리하지 못했습니다.', + ); + } + + const completion = data as SupportCompletionConfirmationResponse; + setTicket((current) => + current ? + { + ...current, + feedback_status: completion.feedback_status, + completion_requested: false, + completion_confirmed_at: completion.completed_at, + } + : current, + ); + toast.success('처리 결과 확인이 완료되었습니다.'); + } catch (error) { + const message = + error instanceof Error ? + error.message + : '완료 확인 처리 중 오류가 발생했습니다.'; + setErrorMessage(message); + toast.error(message); + } finally { + setIsCompletionConfirming(false); + } + }; + + const normalizedFeedbackStatus = normalizeSupportFeedbackStatus( + ticket?.feedback_status, + ); + const hasCompletionNoticeComment = comments.some(isCompletionNoticeComment); + const completionRequested = + typeof ticket?.completion_requested === 'boolean' ? + ticket.completion_requested + : hasCompletionNoticeComment; + const isCompletionConfirmationAvailable = + completionRequested && + normalizedFeedbackStatus === 'IN_PROGRESS'; + return ( { 🔒 비밀글 : null} + {isEditing ? { -
-

- 사용자 환경 정보 -

-
- - -
-
{isEditing ?