Merge pull request 'feat: 확인 완료 버튼 추가' (#1) from refactor/feedback-writer-slim into main
Deploy feedback demo / deploy (push) Successful in 45s
Deploy feedback demo / deploy (push) Successful in 45s
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -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[피드백 작성 페이지<br/>/support/{workspaceCode}/new]
|
||||
W -->|FormData<br/>제목·내용·필드·첨부파일| P[Next.js 서버 Proxy]
|
||||
P -->|POST /api/support/workspaces/{workspaceCode}/tickets| S[외부 관리 콘솔 API<br/>Secretary]
|
||||
|
||||
S -->|1. 내부 티켓 저장| T[(Secretary DB<br/>baron_support)]
|
||||
S -->|2. ABC 피드백 생성| A[ABC API]
|
||||
A --> F[(ABC MySQL<br/>feedbacks)]
|
||||
S --> M[(abc_feedback_mappings)]
|
||||
M -. ticket_id ↔ feedback_id .-> T
|
||||
M -. ticket_id ↔ feedback_id .-> F
|
||||
S -->|첨부파일 저장| X[(Secretary Local/R2<br/>+ attachments 메타데이터)]
|
||||
|
||||
T --> C[관리 콘솔 목록·상세 화면]
|
||||
F --> C
|
||||
X --> C
|
||||
```
|
||||
|
||||
### 핵심 저장 위치
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
D[피드백 등록 데이터]
|
||||
D --> D1[ABC MySQL<br/>feedbacks.data JSON<br/>원본 피드백 본문]
|
||||
D --> D2[Secretary DB<br/>support_tickets<br/>관리용 티켓·상태·권한]
|
||||
D --> D3[Secretary DB<br/>abc_feedback_mappings<br/>두 시스템 ID 연결]
|
||||
D --> D4[Secretary Local/R2<br/>첨부파일 바이너리]
|
||||
D --> D5[Secretary DB<br/>attachments<br/>첨부파일 메타데이터]
|
||||
D1 -. 검색 활성화 시 .-> D6[OpenSearch<br/>검색용 색인]
|
||||
```
|
||||
|
||||
### ID 연결 구조
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
W[workspace_code<br/>예: 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`
|
||||
@@ -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=<BARON-SSO 작성 서버 client id>
|
||||
SSO_CLIENT_SECRET=<BARON-SSO 작성 서버 client secret>
|
||||
JWT_SECRET=<관리 콘솔 Support API와 공유하는 HS256 secret>
|
||||
SUPPORT_TENANT_ID=<userinfo에 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`: 작업 당시의 완료/보류 기록
|
||||
+114
-177
@@ -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=<API와 secretary-api가 공유하는 내부 인증 키>
|
||||
```
|
||||
| 증상 | 원인 후보 | 확인 |
|
||||
| --- | --- | --- |
|
||||
| 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 상태만 남깁니다.
|
||||
|
||||
@@ -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: <http://127.0.0.1:3100>
|
||||
- ABC API: <http://127.0.0.1:4000>
|
||||
- ABC Swagger: <http://127.0.0.1:4000/docs>
|
||||
- ABC 관리자 Swagger: <http://127.0.0.1:4000/admin-docs>
|
||||
- Secretary API: <http://127.0.0.1:8010>
|
||||
- Secretary Swagger: <http://127.0.0.1:8010/docs>
|
||||
- SMTP 테스트함: <http://127.0.0.1:5080>
|
||||
|
||||
이미 인프라를 실행한 상태에서 개발 서버만 시작하려면 다음을 사용할 수 있습니다.
|
||||
작성 웹만 기존 관리 콘솔에 연결해 확인하려면 `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 이미지가 배포된 뒤 동작합니다.
|
||||
|
||||
- <https://feedback.hmac.kr/docs>
|
||||
- <https://feedback.hmac.kr/admin-docs>
|
||||
- <https://feedback.hmac.kr/secretary-docs>
|
||||
- Secretary OpenAPI JSON: <https://feedback.hmac.kr/secretary-docs/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)를 기준으로 합니다.
|
||||
|
||||
+67
-154
@@ -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 <staging-branch>
|
||||
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 <staging-branch>
|
||||
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 로그인, 양식 조회, 피드백 등록, 관리 콘솔 조회를 모두 확인합니다.
|
||||
|
||||
@@ -1018,26 +1018,6 @@ const FeedbackDetailSheet = (props: Props) => {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-neutral-secondary text-xs">사용자 IP</p>
|
||||
<p className="mt-1 font-medium">
|
||||
{String(
|
||||
currentFeedback.IP ??
|
||||
ticketExtraFields.ip_address ??
|
||||
'-',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-neutral-secondary text-xs">MAC 주소</p>
|
||||
<p className="mt-1 font-medium">
|
||||
{String(
|
||||
currentFeedback.MAC_address ??
|
||||
ticketExtraFields.mac_address ??
|
||||
'-',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<string, SupportFeedbackStatusCode> = {
|
||||
INIT: 'NEW',
|
||||
ON_REVIEW: 'RECEIVED',
|
||||
DETAILED_REVIEW: 'RECEIVED',
|
||||
IN_PROGRESS: 'IN_PROGRESS',
|
||||
RESOLVED: 'COMPLETED',
|
||||
PENDING: 'ON_HOLD',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<SupportFeedbackStatusCode, string> = {
|
||||
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 || '상태 미상';
|
||||
};
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<span className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-medium ${statusMap[value] ?? 'bg-[#ece8e1] text-[#5f5649]'}`}>
|
||||
<span className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-medium ${statusMap[normalizedValue] ?? 'bg-[#ece8e1] text-[#5f5649]'}`}>
|
||||
<span className="uppercase tracking-[0.16em] opacity-70">{label}</span>
|
||||
<span>{value}</span>
|
||||
<span>{displayValue}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupportStatusBadge;
|
||||
export default SupportStatusBadge;
|
||||
|
||||
@@ -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;
|
||||
@@ -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<SupportTicketCommentRecord, 'comment_type' | 'completion_notice'>,
|
||||
) =>
|
||||
comment.completion_notice === true ||
|
||||
comment.comment_type === 'COMPLETION_NOTICE';
|
||||
|
||||
const issueStatusLabelMap: Record<string, string> = {
|
||||
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<string, string>
|
||||
>({});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isCompletionConfirming, setIsCompletionConfirming] = useState(false);
|
||||
const [comments, setComments] = useState<SupportTicketCommentRecord[]>([]);
|
||||
const [commentDraft, setCommentDraft] = useState('');
|
||||
const [editingCommentId, setEditingCommentId] = useState<number | null>(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 (
|
||||
<SupportPortalShell
|
||||
workspaceCode={workspaceCode || 'Q&A_Platform'}
|
||||
@@ -549,6 +626,11 @@ const SupportDetailPage: NextPageWithLayout = () => {
|
||||
🔒 비밀글
|
||||
</span>
|
||||
: null}
|
||||
<SupportStatusBadge
|
||||
label="상태"
|
||||
value={ticket.feedback_status}
|
||||
normalizeFeedbackStatus
|
||||
/>
|
||||
{isEditing ?
|
||||
<input
|
||||
value={draftTitle}
|
||||
@@ -592,53 +674,6 @@ const SupportDetailPage: NextPageWithLayout = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-[8px] border border-[#ececec] bg-[#fafafa] p-4">
|
||||
<p className="text-sm font-semibold text-[#222]">
|
||||
사용자 환경 정보
|
||||
</p>
|
||||
<div className="mt-3 grid gap-3 text-sm text-[#555] sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-medium text-[#333]">
|
||||
사용자 IP 주소
|
||||
</span>
|
||||
{isEditing ?
|
||||
<input
|
||||
value={draftExtraFields.ip_address ?? ''}
|
||||
onChange={(event) =>
|
||||
setDraftExtraFields((current) => ({
|
||||
...current,
|
||||
ip_address: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="예: 192.168.0.10"
|
||||
className="h-[40px] rounded-[4px] border border-[#d8d8d8] bg-white px-3 text-sm outline-none focus:border-[#888]"
|
||||
/>
|
||||
: <span>
|
||||
{ticket.extra_fields.ip_address?.trim() ?? '-'}
|
||||
</span>
|
||||
}
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-medium text-[#333]">MAC 주소</span>
|
||||
{isEditing ?
|
||||
<input
|
||||
value={draftExtraFields.mac_address ?? ''}
|
||||
onChange={(event) =>
|
||||
setDraftExtraFields((current) => ({
|
||||
...current,
|
||||
mac_address: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="예: 00:1A:2B:3C:4D:5E"
|
||||
className="h-[40px] rounded-[4px] border border-[#d8d8d8] bg-white px-3 text-sm outline-none focus:border-[#888]"
|
||||
/>
|
||||
: <span>
|
||||
{ticket.extra_fields.mac_address?.trim() ?? '-'}
|
||||
</span>
|
||||
}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
{isEditing ?
|
||||
<textarea
|
||||
@@ -653,6 +688,47 @@ const SupportDetailPage: NextPageWithLayout = () => {
|
||||
}
|
||||
</div>
|
||||
|
||||
{(isCompletionConfirmationAvailable ||
|
||||
ticket.completion_confirmed_at) && (
|
||||
<div className="mt-6 flex flex-wrap items-center justify-between gap-3 rounded-[8px] border border-[#cddff5] bg-[#f3f8ff] p-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[#234d7e]">
|
||||
{isCompletionConfirmationAvailable ?
|
||||
'처리 결과 확인 대기'
|
||||
: '처리 결과 확인 완료'}
|
||||
</p>
|
||||
{isCompletionConfirmationAvailable &&
|
||||
ticket.completion_requested_at && (
|
||||
<p className="mt-1 text-xs text-[#55708f]">
|
||||
요청 시각:{' '}
|
||||
{formatSupportTimestamp(
|
||||
ticket.completion_requested_at,
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{!isCompletionConfirmationAvailable &&
|
||||
ticket.completion_confirmed_at && (
|
||||
<p className="mt-1 text-xs text-[#55708f]">
|
||||
확인 시각:{' '}
|
||||
{formatSupportTimestamp(
|
||||
ticket.completion_confirmed_at,
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isCompletionConfirmationAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCompletionConfirmation()}
|
||||
disabled={isCompletionConfirming}
|
||||
className="rounded-[4px] bg-[#315f9a] px-5 py-3 text-sm font-semibold text-white transition hover:bg-[#264c7d] disabled:cursor-not-allowed disabled:bg-[#91a9c5]"
|
||||
>
|
||||
{isCompletionConfirming ? '확인 처리중...' : '처리 결과 확인'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 border-t border-[#ececec] pt-5">
|
||||
<p className="text-sm font-semibold text-[#222]">첨부파일</p>
|
||||
{ticket.attachments && ticket.attachments.length > 0 ?
|
||||
@@ -690,7 +766,6 @@ const SupportDetailPage: NextPageWithLayout = () => {
|
||||
onClick={() => {
|
||||
setDraftTitle(ticket.title);
|
||||
setDraftDescription(ticket.description);
|
||||
setDraftExtraFields(ticket.extra_fields);
|
||||
setIsEditing(false);
|
||||
}}
|
||||
className="rounded-[4px] border border-[#d0d0d0] bg-white px-5 py-3 text-sm font-medium text-[#333] transition hover:bg-[#f7f7f7]"
|
||||
@@ -761,9 +836,16 @@ const SupportDetailPage: NextPageWithLayout = () => {
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[#222]">
|
||||
{comment.author_name}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-semibold text-[#222]">
|
||||
{comment.author_name}
|
||||
</p>
|
||||
{isCompletionNoticeComment(comment) && (
|
||||
<span className="rounded-full bg-[#e8f1ff] px-2 py-1 text-[11px] font-medium text-[#315f9a]">
|
||||
처리 완료 안내
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[#777]">
|
||||
{formatSupportTimestamp(comment.created_at)}
|
||||
{comment.edited_at ? ' · 수정됨' : ''}
|
||||
|
||||
@@ -201,7 +201,7 @@ const SupportListPage: NextPageWithLayout = () => {
|
||||
case 'title':
|
||||
return ticket.title;
|
||||
case 'status':
|
||||
return ticket.status_code;
|
||||
return ticket.feedback_status;
|
||||
case 'created_at':
|
||||
default:
|
||||
return Date.parse(ticket.created_at) || 0;
|
||||
@@ -430,7 +430,8 @@ const SupportListPage: NextPageWithLayout = () => {
|
||||
<td className="px-4 py-4 align-top">
|
||||
<SupportStatusBadge
|
||||
label="status"
|
||||
value={ticket.status_code}
|
||||
value={ticket.feedback_status}
|
||||
normalizeFeedbackStatus
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -22,7 +22,6 @@ import { toast } from '@ufb/react';
|
||||
|
||||
import {
|
||||
DEFAULT_SUPPORT_WORKSPACE_CODE,
|
||||
DescriptionTooltip,
|
||||
fetchWithAuthRefresh,
|
||||
} from '@/shared';
|
||||
import type { NextPageWithLayout } from '@/shared/types';
|
||||
@@ -75,14 +74,6 @@ const minimalFieldCopy: Record<string, { label: string; placeholder: string }> =
|
||||
label: '내용',
|
||||
placeholder: '문의 내용을 자세히 입력해 주세요.',
|
||||
},
|
||||
ip_address: {
|
||||
label: '사용자 IP 주소',
|
||||
placeholder: '예: 192.168.0.10',
|
||||
},
|
||||
mac_address: {
|
||||
label: 'MAC 주소',
|
||||
placeholder: '예: 00:1A:2B:3C:4D:5E',
|
||||
},
|
||||
};
|
||||
|
||||
const getFieldCopy = (fieldCode: string, fallbackLabel: string) => {
|
||||
@@ -98,8 +89,6 @@ const allowedFieldCodes = [
|
||||
'title',
|
||||
'description',
|
||||
'category',
|
||||
'ip_address',
|
||||
'mac_address',
|
||||
];
|
||||
|
||||
const demoCompatibilityFields: WorkspaceFormField[] = [
|
||||
@@ -126,18 +115,6 @@ const demoCompatibilityFields: WorkspaceFormField[] = [
|
||||
field_type: 'textarea',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
field_code: 'ip_address',
|
||||
label: '사용자 IP 주소',
|
||||
field_type: 'text',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
field_code: 'mac_address',
|
||||
label: 'MAC 주소',
|
||||
field_type: 'text',
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
|
||||
const normalizeDemoTemplate = (
|
||||
@@ -163,13 +140,6 @@ const normalizeDemoTemplate = (
|
||||
};
|
||||
};
|
||||
|
||||
const fieldHelp: Record<string, string> = {
|
||||
ip_address:
|
||||
'Windows: 명령 프롬프트에서 ipconfig를 실행한 뒤 IPv4 주소를 확인하세요.\nmacOS: 시스템 설정 > 네트워크 > 연결된 네트워크 > 세부사항 > TCP/IP에서 확인하세요.',
|
||||
mac_address:
|
||||
'Windows: 명령 프롬프트에서 ipconfig /all을 실행한 뒤 Physical Address를 확인하세요.\nmacOS: 시스템 설정 > 네트워크 > 연결된 네트워크 > 세부사항 > 하드웨어에서 확인하세요.',
|
||||
};
|
||||
|
||||
const SupportNewPage: NextPageWithLayout = () => {
|
||||
const router = useRouter();
|
||||
const workspaceCode =
|
||||
@@ -342,7 +312,12 @@ const SupportNewPage: NextPageWithLayout = () => {
|
||||
'requires_approval',
|
||||
String(template?.requires_approval ?? false),
|
||||
);
|
||||
formData.append('extra_fields', JSON.stringify(fieldValues));
|
||||
const submittedFieldValues = Object.fromEntries(
|
||||
Object.entries(fieldValues).filter(([fieldCode]) =>
|
||||
allowedFieldCodes.includes(fieldCode),
|
||||
),
|
||||
);
|
||||
formData.append('extra_fields', JSON.stringify(submittedFieldValues));
|
||||
|
||||
for (const attachment of attachments) {
|
||||
formData.append('attachments', attachment.file, attachment.file.name);
|
||||
@@ -531,14 +506,6 @@ const SupportNewPage: NextPageWithLayout = () => {
|
||||
placeholder={copy.placeholder}
|
||||
className="h-[40px] min-w-0 flex-1 rounded-[4px] border border-[#d8d8d8] px-3 text-[14px] text-[#222] outline-none placeholder:text-[#a0a0a0] focus:border-[#888]"
|
||||
/>
|
||||
{fieldHelp[field.field_code] ?
|
||||
<DescriptionTooltip
|
||||
description={
|
||||
fieldHelp[field.field_code] ?? ''
|
||||
}
|
||||
side="right"
|
||||
/>
|
||||
: null}
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
|
||||
@@ -45,6 +45,22 @@ export interface WorkspaceFormTemplateResponse {
|
||||
fields: WorkspaceFormField[];
|
||||
}
|
||||
|
||||
export interface SupportFeedbackAutomationMetadata {
|
||||
completion_requested?: boolean;
|
||||
completion_requested_at?: string | null;
|
||||
completion_notice_comment_id?: number | null;
|
||||
completion_expires_at?: string | null;
|
||||
completion_confirmed_at?: string | null;
|
||||
completion_confirmed_by?: string | null;
|
||||
is_new?: boolean;
|
||||
}
|
||||
|
||||
export interface SupportCompletionConfirmationResponse {
|
||||
feedback_id: number;
|
||||
feedback_status: string;
|
||||
completed_at: string;
|
||||
}
|
||||
|
||||
export interface SupportActivityItem {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -61,7 +77,7 @@ export interface SupportAttachmentRecord {
|
||||
download_url?: string;
|
||||
}
|
||||
|
||||
export interface SupportTicketRecord {
|
||||
export interface SupportTicketRecord extends SupportFeedbackAutomationMetadata {
|
||||
ticket_id: number;
|
||||
workspace_code: string;
|
||||
workspace_name: string;
|
||||
|
||||
@@ -0,0 +1,766 @@
|
||||
# 관리페이지 통합 관리 피드백 정리
|
||||
|
||||
- 작성일: 2026-09-02
|
||||
- 대상: 관리페이지의 프로젝트 통합 관리페이지(`/main/overview`)
|
||||
- 목적: 오늘 피드백을 기준으로 통합 관리페이지 및 관리자 설정의 수정 범위를 먼저 정의한다.
|
||||
- 문서 상태: 요구사항 및 자동화 시퀀스 초안
|
||||
|
||||
## 1. 반영 범위
|
||||
|
||||
이번 범위는 다음 화면을 중심으로 한다.
|
||||
|
||||
- 프로젝트 통합 관리페이지
|
||||
- 피드백 처리 탭
|
||||
- 이슈 처리 탭
|
||||
- 상단 요약 지표
|
||||
- 피드백 상세 팝업
|
||||
- 관리자 설정
|
||||
- 관리자 목록
|
||||
- 프로젝트별 기본 관리자 지정
|
||||
- 공통 상단 네비게이션
|
||||
- 프로필 메뉴
|
||||
- 언어 설정
|
||||
- 설정 메뉴
|
||||
- 프로젝트 목록 및 프로젝트 선택 영역
|
||||
|
||||
## 2. 요구사항 목록
|
||||
|
||||
### 2.1 상단 및 공통 네비게이션
|
||||
|
||||
#### 1) 관리자 Todo 부연 설명 문구
|
||||
|
||||
- 관리자 Todo 아래의 부연 설명 문구를 제거한다.
|
||||
- 상태: **추후 적용 / 별도 지시 전까지 보류**
|
||||
- 별도 지시가 있을 때까지 현재 문구의 적용 상태는 유지한다.
|
||||
|
||||
#### 2) 상단 요약 데이터 박스
|
||||
|
||||
- 다음 항목을 제거한다.
|
||||
- 이슈 연결률
|
||||
- 평균 처리 시간
|
||||
- 나머지 6개 항목은 한 줄에 표시한다.
|
||||
- 6개 박스가 화면 너비를 균등하게 사용할 수 있도록 width와 grid를 조정한다.
|
||||
- 현재 유지 대상 항목:
|
||||
- 오늘 등록된 피드백
|
||||
- 오늘 답변 대기
|
||||
- 담당자 미지정
|
||||
- 피드백 상태 처리 필요
|
||||
- 전체 피드백
|
||||
- 답변 대기
|
||||
|
||||
#### 3) 언어 설정 위치
|
||||
|
||||
- 최상단에 별도로 노출된 언어 설정 항목을 제거한다.
|
||||
- 프로필 아이콘 하위 메뉴의 옵션으로 이동한다.
|
||||
- 언어 설정 항목의 아이콘은 제거한다.
|
||||
|
||||
#### 4) 테넌트 설정 및 화면 색상 설정 위치
|
||||
|
||||
- 테넌트 설정을 `Setting` 메뉴 하위 항목으로 이동한다.
|
||||
- 화면 색상 설정을 `Setting` 메뉴 하위 항목으로 이동한다.
|
||||
- 최상단에 직접 노출된 기존 항목은 제거한다.
|
||||
|
||||
#### 5) Setting 메뉴 위치 및 아이콘
|
||||
|
||||
- `Setting` 메뉴를 톱니바퀴 아이콘으로 표시한다.
|
||||
- 프로필 아이콘의 오른쪽에 정렬한다.
|
||||
- 기존 `Setting` 텍스트 노출 여부는 아이콘 중심으로 재검토한다.
|
||||
|
||||
### 2.2 통합 관리페이지 피드백 처리 탭
|
||||
|
||||
#### 6) Like 검색
|
||||
|
||||
- 피드백 처리 탭 상단에 Like 검색 기능을 추가한다.
|
||||
- 검색 입력과 검색 실행 영역은 기존 리스트 상단 필터 영역과 함께 배치한다.
|
||||
- 검색 대상과 검색 방식은 현재 피드백 API의 검색 조건을 재사용할 수 있도록 설계한다.
|
||||
|
||||
#### 7) 담당자 및 피드백 상태 표시 방식
|
||||
|
||||
- 리스트의 담당자와 피드백 상태 항목은 현재 값만 표시한다.
|
||||
- 리스트 안에서 직접 변경하는 컨트롤은 제거한다.
|
||||
- 담당자 지정 및 피드백 상태 변경은 피드백 상세 팝업 안에서만 제공한다.
|
||||
- 상세 팝업에서 변경한 결과는 리스트에 즉시 반영한다.
|
||||
|
||||
#### 8) 관리자 댓글 컬럼
|
||||
|
||||
- 피드백 리스트의 관리자 댓글 컬럼을 삭제한다.
|
||||
- 관리자 댓글 작성 및 조회는 상세 팝업에서 처리한다.
|
||||
|
||||
#### 9) 생성일 및 업데이트일
|
||||
|
||||
- 생성일 옆에 업데이트일을 추가한다.
|
||||
- 업데이트일은 다음 이벤트가 발생했을 때 변경되어야 한다.
|
||||
- 피드백 설정 변경
|
||||
- 관리자 댓글 등록
|
||||
- 업데이트일은 리스트와 상세 팝업에서 동일한 기준으로 표시한다.
|
||||
- 관리자 댓글이 별도 테이블에 저장되는 현재 구조를 고려해, 댓글 등록 시 피드백의 최종 업데이트 시각을 갱신하거나 별도 통합 업데이트 시각을 제공해야 한다.
|
||||
|
||||
### 2.3 프로젝트 통합 구조
|
||||
|
||||
#### 10) 통합 관리페이지의 프로젝트 편입
|
||||
|
||||
- 프로젝트 통합 관리페이지를 프로젝트 목록의 프로젝트 항목으로 편입한다.
|
||||
- 프로젝트 목록 최상단에 고정한다.
|
||||
- 통합 관리페이지의 프로젝트 번호는 `0`으로 고정한다.
|
||||
- 프로젝트 번호 정렬 및 표시 로직은 `0`번 프로젝트가 항상 최상단에 오도록 조정한다.
|
||||
- 추후 프로젝트 코드는 4자리 형식으로 변경될 예정이므로, 프로젝트 ID와 표시용 프로젝트 코드를 분리할 수 있도록 설계한다.
|
||||
- 일반 프로젝트의 기존 이동 및 선택 동작은 유지한다.
|
||||
|
||||
### 2.4 관리자 설정 및 기본 관리자
|
||||
|
||||
#### 11) 프로젝트별 기본 관리자 지정
|
||||
|
||||
- 관리자 설정에 프로젝트별 기본 관리자 지정 기능을 추가한다.
|
||||
- 각 프로젝트에 기본 관리자를 지정할 수 있어야 한다.
|
||||
- 관리자 설정 화면에서 현재 지정된 기본 관리자를 확인하고 변경할 수 있어야 한다.
|
||||
|
||||
#### 12) 기본 관리자 자동 담당자 지정
|
||||
|
||||
- 프로젝트에 기본 관리자가 지정되어 있으면, 피드백에 별도 담당자가 없을 때 기본 관리자를 담당자로 사용한다.
|
||||
- 사용자가 별도로 담당자를 지정한 경우에는 별도 지정값을 우선한다.
|
||||
- 프로젝트에 기본 관리자가 없으면 현재처럼 담당자 항목을 공란으로 표시한다.
|
||||
- 자동 지정 시점은 피드백 생성 시점과 조회 시점의 데이터 일관성을 고려해 결정한다.
|
||||
|
||||
### 2.5 신규 피드백 표시 및 정렬
|
||||
|
||||
#### 13) 읽지 않은 피드백 New 표시
|
||||
|
||||
- 누구든지 피드백을 한 번이라도 읽으면 해당 피드백을 읽은 상태로 기록한다.
|
||||
- 한 명이라도 읽은 피드백은 신규 강조 대상에서 제외한다.
|
||||
- 아직 아무도 읽지 않은 피드백은 피드백 제목 옆에 `NEW`를 표시한다.
|
||||
- `NEW`는 빨간색으로 강조한다.
|
||||
- 피드백 상세 팝업 진입 또는 읽음 처리 API 호출 시 읽음 상태가 저장되어야 한다.
|
||||
- 프로젝트 통합 관리페이지의 목록 갱신 후에도 읽음 상태가 유지되어야 한다.
|
||||
|
||||
#### 14) 피드백 리스트 정렬
|
||||
|
||||
- 피드백 리스트 상단에 정렬 기능을 추가한다.
|
||||
- 정렬 기준과 오름차순/내림차순을 선택할 수 있어야 한다.
|
||||
- 최소 정렬 기준 후보:
|
||||
- 생성일
|
||||
- 업데이트일
|
||||
- 피드백 상태
|
||||
- 담당자
|
||||
- 우선순위
|
||||
- 기본 정렬 기준과 정렬 상태 유지 범위는 구현 단계에서 확정한다.
|
||||
|
||||
### 2.6 피드백 상세 팝업 개선
|
||||
|
||||
#### 16) 상세 팝업 이슈 처리
|
||||
|
||||
- 피드백 상세 팝업에서 연결된 이슈를 확인할 수 있어야 한다.
|
||||
- 상세 팝업에서 기존 이슈를 피드백에 연결하거나 연결 해제할 수 있어야 한다.
|
||||
- 연결된 이슈별 상태를 상세 팝업에서 변경할 수 있어야 한다.
|
||||
- 이슈 연결/해제 및 상태 변경 결과는 목록에 즉시 반영되어야 한다.
|
||||
- 이슈 연결/해제는 `feedback_issue_update`, 이슈 상태 변경은 `issue_update` 권한을 따른다.
|
||||
|
||||
#### 17) 이슈 상태 및 피드백 상태 배치
|
||||
|
||||
- 상세 팝업의 `처리 상태` 제목과 설명 문구를 제거한다.
|
||||
- `이슈 상태`와 `피드백 상태`를 한 줄의 동일한 너비(각 50%)로 배치한다.
|
||||
- 상태 변경 컨트롤은 각 영역 안에서 제공한다.
|
||||
|
||||
#### 18) 사용자 정보 표시
|
||||
|
||||
- 피드백 상세 팝업의 사용자 정보에서 `부서` 항목을 제외한다.
|
||||
- 이름, 이메일, 전화번호 및 접속 정보 등 나머지 사용자 정보는 유지한다.
|
||||
|
||||
#### 19) 댓글/내부 메모가 없는 상세 팝업 높이
|
||||
|
||||
- 최초 댓글과 내부 메모가 모두 없는 경우 빈 목록 영역 때문에 불필요한 세로 스크롤이 생기지 않아야 한다.
|
||||
- 상세 팝업은 실제 콘텐츠 높이에 맞춰 표시하되, 화면 높이를 초과하는 경우에만 내부 스크롤을 제공한다.
|
||||
- 관리자 댓글 목록과 댓글 작성 영역은 한 화면에서 함께 확인할 수 있도록 배치한다.
|
||||
|
||||
#### 20) 상세 팝업 보조 설명 문구
|
||||
|
||||
- 첨부파일, 담당자, 내부 메모, 관리자 댓글 등 각 항목의 추가 부연 설명 문구를 제거한다.
|
||||
- 항목명과 실제 데이터/입력 영역을 우선 노출해 관리자 댓글 작성 영역이 가려지지 않도록 한다.
|
||||
|
||||
## 3. 현재 구현과의 연결 지점
|
||||
|
||||
현재 통합 관리페이지는 다음 구조를 사용한다.
|
||||
|
||||
- 화면: `apps/web/src/pages/main/overview.tsx`
|
||||
- 통합 대시보드 조회: `GET /api/admin/dashboard/overview`
|
||||
- 피드백 Todo 목록: 대시보드 API의 `todos` 응답
|
||||
- 관리자/담당자 권한 조회: `secretary-api`의 `/api/access/*`
|
||||
- 관리자 설정 화면: `apps/web/src/widgets/setting-menu/ui/tenant/admin-permission-setting.ui.tsx`
|
||||
- 현재 리스트에서 담당자와 피드백 상태를 직접 변경하는 UI가 존재한다.
|
||||
- 현재 리스트에 관리자 댓글 컬럼과 댓글 입력 흐름이 존재한다.
|
||||
- 현재 피드백의 `createdAt`, `updatedAt`은 기본 피드백 데이터 기준이며 관리자 댓글 변경 시각과의 통합 여부를 별도로 보완해야 한다.
|
||||
|
||||
## 4. 데이터 및 API 변경 검토사항
|
||||
|
||||
### 4.1 관리자 기본값
|
||||
|
||||
- 프로젝트와 기본 관리자 간 매핑 저장 위치를 결정한다.
|
||||
- 기본 관리자를 1명으로 제한할지 여러 명 허용할지 결정한다.
|
||||
- 기본 관리자 지정/변경/해제 API가 필요하다.
|
||||
- 관리자 설정 응답에 프로젝트별 기본 관리자 정보를 포함해야 한다.
|
||||
|
||||
### 4.2 피드백 읽음 상태
|
||||
|
||||
- 피드백별 읽음 상태 저장 테이블 또는 필드가 필요하다.
|
||||
- “누구든지 한 번이라도 읽음”을 판정할 수 있어야 한다.
|
||||
- 읽은 사용자, 읽은 시각을 감사 추적용으로 저장할지 결정한다.
|
||||
- 목록 조회 시 `isNew` 또는 동등한 필드를 반환하는 방식을 검토한다.
|
||||
|
||||
### 4.3 업데이트일
|
||||
|
||||
- 피드백 본문/설정 변경과 관리자 댓글 등록을 하나의 업데이트 시각으로 통합할지 결정한다.
|
||||
- 기존 `feedback.updated_at`을 갱신하는 방식과 이벤트/이력 기반 방식 중 하나를 선택한다.
|
||||
- 외부 ABC 데이터와 내부 Secretary 데이터의 업데이트 시각 기준을 구분해야 한다.
|
||||
|
||||
### 4.4 검색 및 정렬
|
||||
|
||||
- Like 검색의 대상 필드를 확정한다.
|
||||
- 제목
|
||||
- 내용
|
||||
- 요청자 정보
|
||||
- 카테고리
|
||||
- 기타 동적 필드
|
||||
- 검색을 서버 API 쿼리로 처리할지, 현재 조회 결과의 클라이언트 필터로 처리할지 결정한다.
|
||||
- 정렬 가능한 필드와 null 값 정렬 규칙을 API에 정의한다.
|
||||
- 페이지네이션 및 최대 조회 건수 제한을 정렬/검색과 함께 검토한다.
|
||||
|
||||
### 4.5 통합 프로젝트 0번
|
||||
|
||||
- 프로젝트 목록 API가 가상 프로젝트 `0`을 반환할지, 프론트에서 고정 항목으로 삽입할지 결정한다.
|
||||
- 통합 관리페이지를 일반 프로젝트와 동일한 타입으로 다룰지 별도 타입으로 둘지 결정한다.
|
||||
- 기존 `projectId`가 필요한 API 호출과 통합 페이지용 API 호출을 분리한다.
|
||||
- 향후 4자리 프로젝트 코드 변경을 고려해 `projectId`, `projectCode`, `displayOrder`를 혼용하지 않는다.
|
||||
|
||||
## 5. 우선순위 제안
|
||||
|
||||
### 1순위: 목록 사용성 및 데이터 표시
|
||||
|
||||
- 2. 요약 박스 6개 한 줄 표시
|
||||
- 7. 담당자/상태 읽기 전용 표시 및 상세 팝업 제어
|
||||
- 8. 관리자 댓글 컬럼 삭제
|
||||
- 9. 업데이트일 표시
|
||||
- 14. 리스트 정렬
|
||||
- 6. Like 검색
|
||||
|
||||
### 2순위: 권한 및 자동화
|
||||
|
||||
- 11. 프로젝트별 기본 관리자 지정
|
||||
- 12. 기본 관리자 자동 담당자 지정
|
||||
- 13. 읽지 않은 피드백 `NEW` 표시
|
||||
|
||||
### 3순위: 정보 구조 및 네비게이션
|
||||
|
||||
- 3. 언어 설정 이동
|
||||
- 4. 테넌트/화면 색상 설정 이동
|
||||
- 5. Setting 아이콘 정렬
|
||||
- 10. 통합 관리페이지의 프로젝트 편입 및 프로젝트 번호 `0`
|
||||
|
||||
### 보류
|
||||
|
||||
- 1. 관리자 Todo 부연 설명 문구 제거
|
||||
- 별도 지시 전까지 보류
|
||||
|
||||
## 6. 완료 기준
|
||||
|
||||
- 요구사항별 변경 범위와 API/데이터 변경점이 구현 전에 확정되어야 한다.
|
||||
- 통합 관리페이지에서 피드백 검색, 정렬, 읽음 상태, 업데이트일이 동일한 기간/프로젝트 범위 기준으로 동작해야 한다.
|
||||
- 담당자와 피드백 상태는 리스트에서 수정할 수 없고 상세 팝업에서만 수정할 수 있어야 한다.
|
||||
- 이슈 연결/해제 및 이슈 상태 변경은 피드백 상세 팝업에서 처리할 수 있어야 한다.
|
||||
- 상세 팝업은 댓글/내부 메모가 없을 때 불필요한 스크롤을 만들지 않아야 한다.
|
||||
- 상세 팝업의 항목별 보조 설명 문구는 노출하지 않아야 한다.
|
||||
- 관리자 댓글은 리스트 컬럼에 노출되지 않고 상세 팝업에서만 처리되어야 한다.
|
||||
- 기본 관리자가 지정된 프로젝트의 미지정 피드백에는 기본 담당자가 표시되어야 한다.
|
||||
- 프로젝트 통합 관리페이지는 프로젝트 목록 최상단에서 프로젝트 번호 `0`으로 식별되어야 한다.
|
||||
- 1번 항목은 별도 지시 전까지 구현하지 않는다.
|
||||
|
||||
## 7. 구현 전 확정이 필요한 질문
|
||||
|
||||
1. Like 검색의 대상 필드는 제목/내용만으로 할지, 요청자·카테고리까지 포함할지?
|
||||
2. 프로젝트별 기본 관리자는 1명만 허용할지, 여러 명을 허용할지?
|
||||
3. 기본 관리자 자동 지정은 피드백 생성 시 실제 데이터를 저장할지, 조회 시 기본값으로 표시할지?
|
||||
4. 피드백을 읽은 것으로 처리하는 시점은 리스트 노출 시점인지, 제목 클릭/상세 팝업 진입 시점인지?
|
||||
5. 읽음 상태를 사용자별로 저장할지, 피드백별 최초 읽음 여부만 저장할지?
|
||||
6. 업데이트일을 기존 `feedback.updated_at`으로 통일할지, 댓글까지 포함한 별도 `lastActivityAt`을 둘지?
|
||||
7. 리스트 기본 정렬은 최신 업데이트일순인지, 최신 생성일순인지?
|
||||
8. 통합 관리페이지의 프로젝트 번호 `0`을 API 프로젝트 목록에도 포함할지, 화면에서만 가상 항목으로 처리할지?
|
||||
|
||||
## 8. 피드백·이슈 자동처리 시퀀스 및 예외 정책
|
||||
|
||||
- 작성일: 2026-09-03
|
||||
- 목적: 현재 수동으로 처리하는 피드백·이슈 상태 변경 중 자동화할 수 있는 범위와 예외 상황을 공유한다.
|
||||
- 용어 정의:
|
||||
- `Q&A`는 사용자가 Q&A 작성 페이지에서 직접 작성한 원문이다.
|
||||
- `피드백`은 작성된 Q&A가 관리페이지에 도착해 관리되는 접수 항목이다. 즉, Q&A와 피드백은 같은 내용을 가리키지만 사용되는 화면과 역할이 다르다.
|
||||
- 표기 규칙: 아래 다이어그램의 `S`는 관리페이지 시스템, `A`는 관리자, `F`는 Q&A 작성자, `D`는 개발자를 의미한다. NaverWorks(SMS) 알림은 관리페이지 시스템이 직접 처리하며 별도 참여자로 표시하지 않는다.
|
||||
|
||||
### 8.1 공통 상태 정의
|
||||
|
||||
관리페이지에서 관리되는 피드백과 이슈는 모두 다음 다섯 단계만 사용한다. Q&A는 작성자가 입력한 원문이므로 별도의 처리 상태를 가지지 않고, 관리페이지에 도착한 뒤 피드백으로 관리된다.
|
||||
|
||||
| 상태 | 코드 예시 | 정의 | 자동 변경 여부 |
|
||||
| ------ | ------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------- |
|
||||
| 신규 | `NEW` | 피드백이나 이슈가 처음 만들어져 아직 누구도 처리하지 않은 상태 | 생성할 때 관리페이지 시스템이 자동으로 지정 |
|
||||
| 접수 | `RECEIVED` | 관리자가 내용을 확인해 처리 대상으로 받아들인 상태 | 관리자가 피드백을 처음 열면 자동 변경. Gitea 이슈 연결 시 이슈도 자동 변경 |
|
||||
| 진행중 | `IN_PROGRESS` | 관리자나 개발자가 실제로 답변·수정 작업을 진행하는 상태 | 관리자 댓글이나 이슈 연결 뒤 피드백은 자동 변경. 개발자의 이슈 변경은 수동 처리 |
|
||||
| 완료 | `COMPLETED` | 처리 결과가 확정되어 추가 조치가 필요하지 않은 상태 | Q&A 작성자 확인 또는 확인 기간 만료 뒤 피드백 자동 변경. 이슈 완료는 개발자 수동 처리 |
|
||||
| 보류 | `ON_HOLD` | 관리자가 판단해 처리를 잠시 멈춘 상태 | 관리자가 직접 설정하고 직접 해제 |
|
||||
|
||||
#### 완료 확인을 별도 상태로 추가하지 않는 원칙
|
||||
|
||||
- 5단계 상태를 유지하기 위해 `완료 대기`라는 여섯 번째 상태값은 만들지 않는다.
|
||||
- 관리자가 처리 완료 안내 댓글을 등록하면 피드백 상태는 `진행중`을 유지하고, `completion_requested=true`와 `completion_requested_at` 메타데이터를 기록한다.
|
||||
- Q&A 작성자가 완료 확인을 하거나 확인 기간이 만료된 때에만 피드백 상태를 `완료`로 변경한다.
|
||||
- 화면에는 `진행중` 상태와 함께 “Q&A 작성자 확인 대기” 배지를 표시할 수 있다. 이 배지는 상태값이 아니다.
|
||||
- 완료 확인 대기 중 Q&A 작성자의 재문의가 발생하면 `completion_requested`를 해제하고 피드백을 `진행중`으로 유지한다.
|
||||
|
||||
### 8.2 자동 변경의 기본 규칙
|
||||
|
||||
#### 피드백
|
||||
|
||||
- 생성: `신규`
|
||||
- 관리자가 관리페이지의 피드백 상세 페이지를 처음 열람: 피드백 상태를 `신규 → 접수`로 자동 변경
|
||||
- 관리자가 처리 과정에 대한 댓글을 등록: 피드백 상태를 `신규/접수 → 진행중`으로 자동 변경
|
||||
- 관리자가 피드백에 이슈를 연결해 처리를 시작: 피드백 상태를 `신규/접수 → 진행중`으로 자동 변경
|
||||
- 관리자가 처리 완료 안내 댓글을 등록: `completion_requested=true` 기록 후 `진행중` 유지
|
||||
- 보류 상태에서는 위 자동 변경을 적용하지 않는다. 관리자가 먼저 수동으로 보류를 해제해야 한다.
|
||||
- 완료 상태에서 단순 열람·댓글·동기화만으로 자동 재오픈하지 않는다. 동일 문제에 대한 Q&A 재문의는 관리자에게 알리고, 관리자가 `완료 → 진행중`으로 수동 변경한다.
|
||||
- 완료 확인 대기 중 Q&A 작성자의 재문의는 관리자 확인 없이 피드백을 `진행중`으로 되돌리고 관리자에게 알린다.
|
||||
|
||||
#### 관리자 댓글 유형
|
||||
|
||||
- 일반 관리자 댓글은 처리 과정의 안내·질문·추가 확인에 사용한다. 피드백이 `신규/접수` 상태라면 댓글 등록과 함께 `진행중`으로 자동 변경한다.
|
||||
- 처리 완료를 알리는 댓글은 `completion_notice=true` 메타데이터를 가진 “처리 완료 안내 댓글”로 구분한다.
|
||||
- 처리 완료 안내 댓글은 별도의 완료 처리 버튼이 아니라, 관리자 댓글 작성 영역에서 완료 안내 유형을 선택해 등록하는 방식으로 정의한다.
|
||||
- 처리 완료 안내 댓글을 등록하면 Q&A 작성자에게 완료 확인을 표시하고, `completion_notice_comment_id`를 저장한다.
|
||||
- 완료 확인 대기 중 일반 댓글은 대기 상태를 유지하지만, Q&A 작성자의 재문의 댓글은 완료 확인 대기를 해제한다.
|
||||
|
||||
#### 이슈
|
||||
|
||||
- 이슈 생성: `신규`
|
||||
- 관리자가 이슈를 처음 열람하거나 Gitea 이슈를 연결: 이슈 상태를 `신규 → 접수`로 자동 변경
|
||||
- 개발자가 Gitea 이슈를 확인한 뒤 이슈 상태를 수동 변경: `접수 → 진행중`
|
||||
- 개발자가 처리를 끝낸 뒤 이슈 상태를 수동 변경: `진행중 → 완료`
|
||||
- 개발자 또는 관리자가 완료된 이슈를 다시 열면 이슈를 `진행중`으로 수동 변경한다.
|
||||
- 이슈의 `보류` 설정·해제는 항상 관리자 수동 처리이며, 외부 Gitea 상태 동기화가 덮어쓰지 않는다.
|
||||
|
||||
### 8.3 보류 상태의 우선 규칙
|
||||
|
||||
- `보류`는 피드백과 이슈 모두 관리자만 직접 설정하고 해제할 수 있다.
|
||||
- 자동화 이벤트는 현재 상태가 `보류`인 피드백·이슈의 상태를 변경하지 않는다.
|
||||
- 관리자 댓글 등록
|
||||
- 이슈 연결
|
||||
- Gitea 상태 동기화
|
||||
- Q&A 작성자의 댓글 또는 재문의
|
||||
- 기간 만료
|
||||
- 보류 중 Q&A 작성자의 재문의는 댓글과 NaverWorks(SMS) 알림 이력만 기록하고, 피드백 또는 이슈 상태는 `보류`로 유지한다.
|
||||
- 관리자가 보류를 해제할 때 `접수` 또는 `진행중` 중 하나를 직접 선택한다.
|
||||
- 보류 중인 이슈가 포함된 피드백은 모든 이슈가 완료되어도 자동 완료 처리하지 않는다.
|
||||
|
||||
### 8.4 다중 이슈 집계 규칙
|
||||
|
||||
하나의 피드백에는 이슈가 여러 개 연결될 수 있으며, 각 이슈는 서로 독립적으로 상태를 가진다.
|
||||
|
||||
- 이슈가 하나라도 `신규`, `접수`, `진행중`이면 피드백은 `진행중`으로 유지한다.
|
||||
- 이슈 하나가 `완료`가 되어도 다른 이슈가 미완료이면 피드백을 완료 처리하지 않는다.
|
||||
- 연결된 모든 이슈가 `완료`여도 관리자가 결과를 확인하고 처리 완료 안내 댓글을 등록해야 한다.
|
||||
- 관리자가 처리 완료 안내 댓글을 등록한 뒤 이슈를 추가로 연결하면 완료 확인 대기를 해제하고 피드백을 `진행중`으로 되돌린다.
|
||||
- 이슈를 모두 연결 해제해도 피드백을 자동으로 완료하지 않는다. 관리자가 피드백 처리 결과를 직접 확인해야 한다.
|
||||
- 이슈 중 하나라도 `보류`이면 피드백 자동 완료를 금지한다.
|
||||
- 이슈별 완료 여부, 완료 시각, 외부 Gitea 이슈 번호·상태를 각각 보존해 감사 추적이 가능해야 한다.
|
||||
|
||||
### 8.5 기본 처리·완료·재문의·재오픈 통합 시퀀스
|
||||
|
||||
피드백이 생성된 뒤 접수·처리·완료되는 기본 흐름과, 완료 확인 전후에 재문의가 발생하는 예외 흐름을 하나의 시퀀스로 정리한다. 완료 확인 전 재문의는 기존 완료 절차를 취소하고 `진행중`으로 유지하며, 완료 확정 후 재문의는 관리자의 검토와 수동 재오픈을 거친다.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant F as Q&A 작성자
|
||||
participant S as 관리페이지 시스템
|
||||
participant A as 관리자
|
||||
|
||||
F->>S: 작성 페이지에서 Q&A 작성
|
||||
S->>S: 작성된 Q&A를 관리페이지의 피드백으로 등록
|
||||
S->>S: 피드백 상태를 신규로 설정
|
||||
|
||||
A->>S: 관리페이지에서 피드백 상세 페이지를 처음 열람
|
||||
S->>S: 피드백 상태를 신규에서 접수로 자동 변경
|
||||
|
||||
A->>S: 피드백 처리 과정에 대한 관리자 댓글 등록
|
||||
S->>S: 피드백 상태를 접수에서 진행중으로 자동 변경
|
||||
|
||||
A->>S: 처리 결과를 알리는 관리자 댓글 등록
|
||||
|
||||
alt 완료 확인 대기 중
|
||||
S->>S: Q&A 작성자의 완료 확인 대기 정보 저장
|
||||
Note over S: 피드백 상태는 진행중으로 유지
|
||||
S-->>F: 관리페이지에 완료 확인 요청 표시
|
||||
S-->>F: NaverWorks(SMS)로 Q&A 작성자에게 완료 확인 요청 알림 발송
|
||||
|
||||
alt Q&A 작성자가 완료 확인
|
||||
F->>S: 처리 결과 확인 버튼 클릭
|
||||
S->>S: 피드백 상태를 진행중에서 완료로 자동 변경
|
||||
S-->>A: NaverWorks(SMS)로 관리자에게 완료 확정 알림 발송
|
||||
else Q&A 작성자가 확인하지 않고 기간 만료
|
||||
S->>S: 완료 확인 대기 기간 만료 여부를 자동 확인
|
||||
S->>S: 피드백 상태를 진행중에서 완료로 자동 변경
|
||||
S-->>F: NaverWorks(SMS)로 Q&A 작성자에게 기간 만료 완료 안내 발송
|
||||
S-->>A: NaverWorks(SMS)로 관리자에게 기간 만료 완료 처리 알림 발송
|
||||
else 완료 확인 전에 Q&A 작성자가 재문의
|
||||
F->>S: 같은 내용에 대한 추가 댓글 또는 재문의
|
||||
S->>S: 완료 확인 대기 정보를 해제하고 진행중 상태 유지
|
||||
S-->>A: NaverWorks(SMS)로 관리자에게 재문의 알림 발송
|
||||
A->>S: 추가 처리 또는 기존 이슈 보완
|
||||
end
|
||||
else 완료 확정 후 동일 문제 재문의
|
||||
S->>F: 피드백이 완료 상태임을 표시
|
||||
F->>S: 같은 문제에 대한 추가 댓글 작성
|
||||
S->>S: 완료 상태를 자동으로 변경하지 않음
|
||||
S-->>A: NaverWorks(SMS)로 관리자에게 재문의 검토 알림 발송
|
||||
A->>S: 피드백 상태를 완료에서 진행중으로 직접 변경
|
||||
A->>S: 재처리 안내 댓글 작성 또는 이슈 재연결
|
||||
else 완료 확정 후 새로운 문제 제기
|
||||
F->>S: 새로운 내용으로 추가 Q&A 작성
|
||||
S->>S: 기존 완료 피드백과 새 Q&A의 연관 정보 기록
|
||||
S-->>A: NaverWorks(SMS)로 관리자에게 새 문의 검토 알림 발송
|
||||
A->>S: 필요한 경우 기존 피드백을 완료에서 진행중으로 직접 변경
|
||||
end
|
||||
```
|
||||
|
||||
### 8.6 이슈 처리 및 피드백 연결 시퀀스
|
||||
|
||||
이슈는 피드백과의 연결 여부, Gitea 개발 이슈 연결 여부에 따라 처리 흐름을 구분한다.
|
||||
|
||||
- 피드백과 연결되지 않은 내부 이슈는 관리자가 관리페이지에서 직접 처리한다.
|
||||
- 피드백에 연결됐지만 Gitea와 연결되지 않은 내부 이슈는 관리자가 처리하고, 연결된 피드백의 완료 절차까지 이어진다.
|
||||
- Gitea와 연결된 이슈는 개발자가 Gitea에서 처리하고, 관리자가 결과를 확인한 뒤 연결된 피드백의 완료 절차를 진행한다.
|
||||
- 하나의 피드백에는 여러 이슈를 연결할 수 있으며, 모든 이슈가 완료되기 전에는 피드백을 완료하지 않는다.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant F as Q&A<br/>작성자
|
||||
participant S as 관리페이지<br/>시스템
|
||||
participant A as 관리자
|
||||
participant I1 as 내부 이슈<br/>A
|
||||
participant I2 as 내부 이슈<br/>B
|
||||
participant G1 as Gitea 이슈<br/>A
|
||||
participant G2 as Gitea 이슈<br/>B
|
||||
participant D as 개발자
|
||||
|
||||
alt 피드백 연결 없이 내부 이슈만 발생
|
||||
A->>S: 관리페이지에서 내부 이슈 A 등록
|
||||
S->>I1: 내부 이슈 A 생성 요청
|
||||
I1-->>S: 내부 이슈 A를 신규 상태로 생성
|
||||
A->>S: 내부 이슈 A 상세 페이지 열람
|
||||
S->>S: 이슈 A 상태를 신규에서 접수로 자동 변경
|
||||
A->>S: 내부 이슈 A 처리 시작
|
||||
A->>S: 이슈 A 상태를 접수에서 진행중으로 직접 변경
|
||||
A->>S: 내부 이슈 A 처리 완료
|
||||
A->>S: 이슈 A 상태를 진행중에서 완료로 직접 변경
|
||||
Note over S: 피드백 연결이 없어 Q&A 작성자 완료 확인 절차는 없음
|
||||
|
||||
else 피드백과 연결된 내부 이슈<br/>(Gitea 연결 없음)
|
||||
F->>S: 작성 페이지에서 Q&A 작성
|
||||
S->>S: 작성된 Q&A를 관리페이지의 피드백으로 등록
|
||||
S->>S: 피드백 상태를 신규로 설정
|
||||
A->>S: 관리페이지에서 피드백 상세 페이지 열람
|
||||
S->>S: 피드백 상태를 신규에서 접수로 자동 변경
|
||||
A->>S: 내부 이슈 A를 새로 등록하고 피드백에 연결
|
||||
S->>I1: 내부 이슈 A 생성 요청
|
||||
I1-->>S: 내부 이슈 A를 신규 상태로 생성
|
||||
S->>S: 피드백 상태를 접수에서 진행중으로 자동 변경
|
||||
A->>S: 내부 이슈 A 상세 페이지 열람
|
||||
S->>S: 이슈 A 상태를 신규에서 접수로 자동 변경
|
||||
A->>S: 내부 이슈 A 처리 시작
|
||||
A->>S: 이슈 A 상태를 접수에서 진행중으로 직접 변경
|
||||
A->>S: 내부 이슈 A 처리 완료
|
||||
A->>S: 이슈 A 상태를 진행중에서 완료로 직접 변경
|
||||
S->>S: 연결된 이슈가 모두 완료되었는지 확인
|
||||
A->>S: 처리 결과를 확인하고 완료 안내 댓글 등록
|
||||
S->>S: Q&A 작성자의 완료 확인 대기 정보 저장
|
||||
S-->>F: Q&A 작성자에게 완료 확인 요청 표시
|
||||
|
||||
alt Q&A 작성자가 완료 확인
|
||||
F->>S: 처리 결과 확인 버튼 클릭
|
||||
S->>S: 피드백 상태를 진행중에서 완료로 자동 변경
|
||||
else Q&A 작성자가 확인하지 않고 기간 만료
|
||||
S->>S: 완료 확인 대기 기간 만료 여부를 자동 확인
|
||||
S->>S: 피드백 상태를 진행중에서 완료로 자동 변경
|
||||
end
|
||||
|
||||
else 피드백과 연결된 Gitea 개발 이슈
|
||||
F->>S: 작성 페이지에서 Q&A 작성
|
||||
S->>S: 작성된 Q&A를 관리페이지의 피드백으로 등록
|
||||
S->>S: 피드백 상태를 신규로 설정
|
||||
Note over A,S: 관리자가 댓글을 남기면 피드백은 진행중 상태가 됨
|
||||
|
||||
A->>S: 이슈 A를 새로 등록
|
||||
S->>I1: 내부 이슈 A 생성 요청
|
||||
I1-->>S: 내부 이슈 A를 신규 상태로 생성
|
||||
A->>S: 피드백에 이슈 A 연결
|
||||
S->>S: 이슈 A 상태를 신규에서 접수로 자동 변경
|
||||
S->>S: 피드백 상태를 신규에서 진행중으로 자동 변경
|
||||
A->>S: 이슈 A에 Gitea 이슈 연결
|
||||
S->>G1: 내부 이슈와 Gitea 이슈 연결
|
||||
S->>S: 외부 이슈 연결 이력 저장
|
||||
|
||||
D->>G1: 개발자가 Gitea 이슈 A의 내용을 확인
|
||||
D->>S: 이슈 A 상태를 진행중으로 직접 변경
|
||||
D->>G1: 개발자가 이슈 A를 처리
|
||||
D->>S: 이슈 A 상태를 완료로 직접 변경
|
||||
|
||||
A->>S: 이슈 B를 새로 등록하고<br/>같은 피드백에 연결
|
||||
S->>I2: 내부 이슈 B 생성 요청
|
||||
I2-->>S: 내부 이슈 B를 신규 상태로 생성
|
||||
A->>S: 이슈 B에 Gitea 이슈 연결
|
||||
S->>G2: 내부 이슈와 Gitea 이슈 연결
|
||||
S->>S: 이슈 B 상태를 신규에서 접수로 자동 변경
|
||||
|
||||
alt 이슈 A만 완료되고<br/>이슈 B는 아직 처리 중
|
||||
S->>S: 다른 이슈가 남아 있으므로 피드백을 진행중으로 유지
|
||||
Note over S: 연결된 이슈 일부만 완료되어도 피드백은 완료하지 않음
|
||||
D->>G2: 개발자가 Gitea 이슈 B를<br/>확인하고 처리
|
||||
D->>S: 이슈 B 상태를 접수에서 진행중으로 직접 변경
|
||||
D->>S: 이슈 B 상태를 진행중에서 완료로 직접 변경
|
||||
else 연결된 모든 이슈가 완료
|
||||
S->>S: 연결된 이슈가 모두 완료되었는지 확인
|
||||
A->>S: 처리 결과를 확인하고 완료 안내 댓글 등록
|
||||
S->>S: Q&A 작성자의 완료 확인 대기 정보 저장
|
||||
S-->>F: Q&A 작성자에게 완료 확인 요청 표시
|
||||
|
||||
alt Q&A 작성자가 완료 확인
|
||||
F->>S: 처리 결과 확인 버튼 클릭
|
||||
S->>S: 피드백 상태를 진행중에서 완료로 자동 변경
|
||||
else Q&A 작성자가 확인하지 않고 기간 만료
|
||||
S->>S: 완료 확인 대기 기간 만료 여부를 자동 확인
|
||||
S->>S: 피드백 상태를 진행중에서 완료로 자동 변경
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### 8.7 보류·외부 상태 충돌 예외 시퀀스
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant A as 관리자
|
||||
participant S as 관리페이지 시스템
|
||||
participant G as Gitea
|
||||
participant D as 개발자
|
||||
participant F as Q&A 작성자
|
||||
|
||||
A->>S: 피드백 또는 이슈 상태를 보류로 직접 설정
|
||||
S->>S: 보류 상태 저장
|
||||
|
||||
par 보류 중 이벤트
|
||||
A->>S: 관리자 댓글 작성
|
||||
S->>S: 댓글은 저장하고 보류 상태는 유지
|
||||
and
|
||||
D->>G: 개발자가 Gitea 이슈 상태 변경
|
||||
G->>S: Gitea 상태 변경 내용을 전달
|
||||
S->>S: 보류 상태는 보호하고 동기화 이력만 저장
|
||||
and
|
||||
F->>S: Q&A 작성자가 추가 문의
|
||||
S->>S: 문의 내용을 저장
|
||||
S-->>A: NaverWorks(SMS)로 관리자에게 보류 중 문의 알림 발송
|
||||
end
|
||||
|
||||
A->>S: 관리자가 보류를 해제하고 다음 상태 선택
|
||||
alt 처리 재개
|
||||
A->>S: 접수 또는 진행중 상태 선택
|
||||
S->>S: 선택한 상태로 변경
|
||||
else 다시 보류
|
||||
A->>S: 보류 상태 유지
|
||||
end
|
||||
```
|
||||
|
||||
### 8.8 전이 우선순위 및 예외 처리표
|
||||
|
||||
| 이벤트 | 현재 상태 | 기본 처리 | 예외 |
|
||||
| ------------------------ | --------------- | ---------------------------------- | ----------------------------------------------------- |
|
||||
| 피드백 생성 | 없음 | `신규` | 생성 실패 시 상태 전이 없이 재시도·오류 기록 |
|
||||
| 관리자 최초 열람 | `신규` | `접수` 자동 변경 | `보류`·`완료`는 변경하지 않음 |
|
||||
| 관리자 댓글 등록 | `신규/접수` | `진행중` 자동 변경 | `보류`는 유지. `완료`는 재오픈 요청만 생성 |
|
||||
| 이슈 연결 | `신규/접수` | 피드백을 `진행중`으로 자동 변경 | `보류`는 유지하고 관리자 재개 필요 |
|
||||
| Gitea 이슈 연결 | 이슈 `신규` | 이슈 `접수` 자동 변경 | 이슈 `보류/완료`는 외부 연결만 기록하고 status 보호 |
|
||||
| 개발자 작업 시작 | 이슈 `접수` | 이슈 `진행중` 수동 변경 | 권한 없는 사용자는 변경 불가 |
|
||||
| 개발자 작업 완료 | 이슈 `진행중` | 이슈 `완료` 수동 변경 | 다른 연결 이슈가 미완료면 피드백은 `진행중` 유지 |
|
||||
| 처리 완료 안내 댓글 등록 | 피드백 `진행중` | 완료 확인 대기 메타데이터 기록 | 미완료 이슈·보류 이슈가 있으면 안내 댓글 등록 전 경고 |
|
||||
| Q&A 작성자 완료 확인 | 완료 확인 대기 | 피드백 `완료` 자동 변경 | 재문의가 먼저 발생하면 완료 처리 취소 |
|
||||
| 확인 기간 만료 | 완료 확인 대기 | 피드백 `완료` 자동 변경 | 보류로 수동 전환된 경우 자동 완료 금지 |
|
||||
| 완료 후 동일 문제 재문의 | 피드백 `완료` | 재오픈 검토 알림 | 피드백은 관리자가 수동으로 `진행중` 전환 |
|
||||
| 보류 설정 | 모든 상태 | 관리자 수동으로 `보류` | 자동 이벤트가 덮어쓰지 않음 |
|
||||
| 보류 해제 | `보류` | 관리자 수동으로 `접수/진행중` 선택 | 자동으로 이전 상태를 추정하지 않음 |
|
||||
|
||||
### 8.9 자동화 구현 시 필수 데이터
|
||||
|
||||
- 피드백 상태, 상태 변경자, 상태 변경 시각
|
||||
- 이슈별 상태, 상태 변경자, 상태 변경 시각
|
||||
- 피드백-이슈 연결·해제 이력
|
||||
- Gitea 이슈 번호, URL, 마지막 외부 상태 동기화 시각
|
||||
- `completion_requested`, `completion_requested_at`, `completion_requested_by`
|
||||
- `completion_notice_comment_id` 및 처리 완료 안내 댓글의 원문
|
||||
- Q&A 작성자의 완료 확인 시각과 확인 사용자
|
||||
- 완료 확인 만료 기준 시각 및 자동 완료 처리 시각
|
||||
- 완료 이후 재문의 여부와 관리자 재오픈 여부
|
||||
- 보류 설정자, 보류 사유, 보류 시작·해제 시각
|
||||
- 자동화 이벤트의 원본 이벤트 ID와 처리 결과
|
||||
|
||||
### 8.10 구현 전 합의가 필요한 정책
|
||||
|
||||
1. 완료 확인 대기 기간을 며칠로 할지, 프로젝트별로 다르게 둘지 결정한다.
|
||||
2. 완료 확인 대기 중 작성자 재문의가 발생하면 즉시 `진행중`으로 되돌릴지 결정한다. 본 문서는 즉시 되돌리는 안을 기준으로 한다.
|
||||
3. 완료 상태에서 작성자의 추가 댓글을 새 피드백으로 분리할지, 기존 피드백의 재오픈 요청으로 처리할지 결정한다.
|
||||
4. 여러 이슈 중 하나가 보류일 때 피드백 전체를 보류로 자동 표시할지 결정한다. 본 문서는 피드백 status 자동 변경은 하지 않고 완료만 차단하는 안을 기준으로 한다.
|
||||
5. 이슈 연결을 피드백 `신규/접수`에서도 허용할지, `진행중`에서만 허용할지 결정한다. 본 문서는 연결 시 `진행중`으로 자동 승격하는 안을 기준으로 한다.
|
||||
6. Gitea 웹훅으로 이슈 상태를 자동 반영할 범위와 개발자의 수동 변경을 병행할지 결정한다.
|
||||
7. 처리 완료 안내 댓글을 일반 댓글과 구분하기 위한 `completion_notice` 선택 UI를 둘지, 특정 댓글 템플릿으로 제한할지 결정한다. 본 문서는 댓글 작성 영역의 유형 선택 UI를 기준으로 한다.
|
||||
|
||||
## 9. 피드백·이슈 자동화 구현 작업계획
|
||||
|
||||
### 9.1 구현 목표
|
||||
|
||||
- 8장의 시퀀스와 전이 우선순위를 실제 관리페이지 처리 흐름에 반영한다.
|
||||
- 피드백과 이슈의 상태 변경을 화면별 임의 처리에서 공통 상태 전이 규칙으로 통합한다.
|
||||
- 자동 변경과 관리자 수동 변경을 구분하고, 모든 전이 결과와 실패 원인을 추적한다.
|
||||
- 기존 피드백 원문·댓글·이슈 연결 데이터는 보존하고, 단계별로 호환성을 확인한다.
|
||||
- 이번 구현에서는 PDF를 수정하지 않고 MD를 기준 문서로 사용한다.
|
||||
|
||||
### 9.2 현재 구조와 상태값 호환 계획
|
||||
|
||||
현재 코드는 피드백·이슈에 각각 6개 상태값을 사용한다. 8장의 업무 상태는 5단계이므로 데이터 마이그레이션과 API/UI 호환 처리가 필요하다.
|
||||
|
||||
| 현재 코드 상태 | 새 업무 상태 | 처리 원칙 |
|
||||
| --- | --- | --- |
|
||||
| `INIT` | `NEW` | 신규 작성·생성 직후 상태 |
|
||||
| `ON_REVIEW` | `RECEIVED` | 관리자가 확인하기 전후의 기존 검토 상태를 접수로 통합 |
|
||||
| `DETAILED_REVIEW` | `RECEIVED` | 상세 검토 상태를 접수로 통합하고 별도 상태로 유지하지 않음 |
|
||||
| `IN_PROGRESS` | `IN_PROGRESS` | 처리 진행 상태 유지 |
|
||||
| `RESOLVED` | `COMPLETED` | 완료 상태로 통합 |
|
||||
| `PENDING` | `ON_HOLD` | 관리자 보류 상태로 통합 |
|
||||
|
||||
- DB 기존 값은 마이그레이션으로 새 코드로 치환한다.
|
||||
- API 응답·검색·필터·통계·화면 문구는 새 5단계만 노출한다.
|
||||
- `ON_HOLD`의 설정·해제는 관리자 수동 전이만 허용한다.
|
||||
- `COMPLETED`는 단순 열람·댓글·외부 동기화로 자동 재오픈하지 않는다.
|
||||
- 전이 로직은 피드백과 이슈에서 동일한 우선순위와 보호 규칙을 사용한다.
|
||||
|
||||
### 9.3 단계별 작업 목록
|
||||
|
||||
현재 진행 상태: 0~5단계 핵심 흐름 구현 완료. 상태 5단계 통합, 최초 열람, 관리자 댓글·이슈 연결에 따른 자동 전환, 완료 안내 댓글, 작성자 완료 확인, 7일 만료 자동 완료, 다중 이슈 완료 조건, 보류 메타데이터, 재문의 처리, 관리자 알림과 상태 이력을 반영. 목록 응답 확장, 자동화 실패 화면, 통합 테스트와 운영 전환 검증은 후속 대상으로 남김.
|
||||
|
||||
#### 0단계. 기준선 고정 및 전이 계약 작성
|
||||
|
||||
- [x] 현재 피드백/이슈 상태의 저장 위치, 변경 API, 화면별 직접 변경 지점을 목록화
|
||||
- [x] `NEW → RECEIVED → IN_PROGRESS → COMPLETED` 기본 전이와 `ON_HOLD` 보호 규칙을 공통 전이표로 코드화
|
||||
- [x] 자동 전이와 관리자 수동 전이를 구분하는 입력값 정의
|
||||
- `actorType`: `SYSTEM`, `ADMIN`, `USER`, `DEVELOPER`
|
||||
- `trigger`: 생성, 최초 열람, 댓글, 이슈 연결, 완료 안내, 완료 확인, 기간 만료, 재문의, 외부 동기화 등
|
||||
- [x] 같은 이벤트가 반복되어도 결과가 중복 생성되지 않도록 멱등성 기준 정의
|
||||
- [x] 권한 없는 사용자의 전이·보류 해제·완료 후 재오픈 차단
|
||||
|
||||
#### 1단계. 데이터 모델 및 마이그레이션
|
||||
|
||||
- [x] 피드백 상태를 새 5단계로 정리하고 기존 6단계 데이터를 매핑
|
||||
- [x] 이슈 상태를 새 5단계로 정리하고 기존 6단계 데이터를 매핑
|
||||
- [x] 피드백 자동화 메타데이터 저장 구조 추가
|
||||
- 완료 확인 요청 여부·요청 시각·요청 댓글 ID
|
||||
- 완료 확인 시각·확인 사용자
|
||||
- 완료 확인 만료 기준 시각·자동 완료 시각
|
||||
- 재문의 및 수동 재오픈 여부
|
||||
- [x] 보류 정보 저장 구조 추가
|
||||
- 보류 설정자·사유·시작 시각·해제 시각·해제 후 선택 상태
|
||||
- [x] 피드백-이슈 연결·해제 이력과 이슈별 완료 시각 보존
|
||||
- [x] 기존 데이터가 손실되지 않는 `up/down` 마이그레이션 작성
|
||||
- [ ] 상태·메타데이터 변경을 하나의 트랜잭션으로 저장
|
||||
|
||||
#### 2단계. 공통 상태 전이 서비스
|
||||
|
||||
- [x] 피드백 상태 전이 서비스 구현
|
||||
- [x] 이슈 상태 전이 서비스 구현
|
||||
- [x] 현재 상태, 요청 주체, 이벤트, 연결 이슈 상태를 함께 검사하는 guard 구현
|
||||
- [x] `ON_HOLD` 자동 변경 차단
|
||||
- [x] `COMPLETED` 자동 재오픈 차단 및 관리자 수동 재오픈 지원
|
||||
- [x] 다중 이슈 집계 구현
|
||||
- 미완료 이슈가 하나라도 있으면 피드백 완료 차단
|
||||
- 보류 이슈가 하나라도 있으면 피드백 완료 차단
|
||||
- 연결 해제만으로 피드백을 완료하지 않음
|
||||
- [x] 상태 변경 이력과 자동화 이벤트 처리 결과 저장
|
||||
- [x] 기존 직접 상태 수정 API가 공통 전이 서비스를 거치도록 변경
|
||||
|
||||
#### 3단계. 자동 이벤트 연결
|
||||
|
||||
- [x] 피드백 생성 시 `NEW` 자동 설정
|
||||
- [x] 관리자가 피드백 상세를 최초 열람하면 `NEW → RECEIVED` 자동 변경
|
||||
- [x] 관리자가 공개 댓글을 등록하면 `NEW/RECEIVED → IN_PROGRESS` 자동 변경
|
||||
- [x] 이슈 연결 시 피드백을 `IN_PROGRESS`로 자동 변경
|
||||
- [x] 이슈 생성 시 `NEW` 설정
|
||||
- [x] 관리자가 이슈를 최초 열람하거나 Gitea 이슈를 연결하면 `NEW → RECEIVED` 자동 변경
|
||||
- [ ] 개발자 작업 시작·완료는 개발자 권한의 수동 전이로 처리
|
||||
- [x] 처리 완료 안내 댓글 등록 시 완료 확인 요청 메타데이터 생성
|
||||
- [x] 완료 확인 대기 중 재문의 발생 시 대기 정보 해제 및 `IN_PROGRESS` 유지
|
||||
- [x] 완료 확정 후 재문의 발생 시 알림만 자동 처리하고 관리자의 수동 재오픈을 요구
|
||||
- [x] Gitea 상태 동기화가 `ON_HOLD` 상태를 덮어쓰지 않도록 보호
|
||||
|
||||
#### 4단계. 완료 확인 및 만료 처리
|
||||
|
||||
- [x] Q&A 작성자 상세 화면에 처리 결과 확인 동작 추가
|
||||
- [x] 완료 확인 전용 API를 멱등하게 구현
|
||||
- [x] 완료 확인 시 `IN_PROGRESS → COMPLETED` 자동 변경
|
||||
- [x] 완료 확인 대기 기간은 프로젝트 설정값으로 분리하고 초기 기본값은 7일로 적용
|
||||
- [x] 만료 대상 조회 배치 구현
|
||||
- [x] 다중 인스턴스 실행을 고려한 스케줄러 락 적용
|
||||
- [x] 만료 처리 성공·실패·재시도 이력 저장
|
||||
- [x] 완료 확인 및 만료 시 Q&A 작성자·관리자 알림 처리
|
||||
|
||||
#### 5단계. 관리자 처리 화면 반영
|
||||
|
||||
- [x] 피드백 상세 팝업에서 상태·담당자·이슈 연결·댓글 유형을 전이 규칙에 맞게 제공
|
||||
- [x] 처리 완료 버튼은 추가하지 않고, `처리 완료 안내 댓글`로 완료 확인 요청 생성
|
||||
- [x] 완료 확인 대기 중인 피드백에 대기 배지·요청 시각 표시
|
||||
- [x] 이슈 상세 팝업에서 상태 변경 시 수동/자동 전이 규칙 적용
|
||||
- [x] 보류 설정 시 사유 입력, 보류 해제 시 `접수/진행중` 선택 제공
|
||||
- [x] 완료 상태의 재오픈은 관리자 수동 동작으로만 제공
|
||||
- [x] 다중 이슈 연결 시 개별 이슈 상태와 피드백 완료 가능 여부 표시
|
||||
- [ ] 자동 전이 실패 시 관리자에게 원인과 재처리 방법 표시
|
||||
|
||||
#### 6단계. 목록·통계·알림 반영
|
||||
|
||||
- [x] 목록의 상태 필터·정렬·요약 박스를 새 5단계 기준으로 변경
|
||||
- [x] 기본 목록에서는 완료·보류를 제외하되 검색으로 조회 가능하도록 유지
|
||||
- [x] 피드백/이슈 상태, 연결 이슈 수, 완료 확인 대기 여부를 목록 응답에 포함
|
||||
- [x] 상태 변경·댓글·이슈 연결·완료 확인·재문의 알림을 기존 관리페이지 시스템 알림 흐름에 연결
|
||||
- [x] NaverWorks(SMS)는 별도 시퀀스 참여자가 아닌 관리페이지 시스템의 알림 처리 결과로 기록
|
||||
- [ ] 대시보드 집계와 통계가 상태 매핑 이후에도 기존 데이터와 일관성을 갖는지 검증
|
||||
|
||||
#### 7단계. 검증 및 전환
|
||||
|
||||
- [x] 상태 전이 단위 테스트 작성
|
||||
- [ ] 다중 이슈·보류·완료 확인 대기·재문의·완료 후 재오픈 통합 테스트 작성
|
||||
- [ ] 댓글 중복 등록, 이슈 연결 중복, 완료 확인 중복 요청의 멱등성 테스트 작성
|
||||
- [ ] 마이그레이션 전후 상태·연결·댓글·이력 건수 비교
|
||||
- [ ] 관리자/개발자/Q&A 작성자 권한별 화면·API 접근 검증
|
||||
- [ ] Gitea 웹훅 지연·중복·실패 상황 검증
|
||||
- [ ] 단계적 적용을 위한 기능 플래그 또는 롤백 기준 정의
|
||||
- [ ] 운영 전환 체크리스트와 장애 시 수동 처리 절차 작성
|
||||
|
||||
### 9.4 우선 구현 순서
|
||||
|
||||
1. 상태값 5단계 호환 및 공통 전이 guard
|
||||
2. 피드백 댓글·이슈 연결·이슈 상태 변경 이벤트 연결
|
||||
3. 다중 이슈 집계와 보류 보호
|
||||
4. 완료 안내 댓글·Q&A 작성자 완료 확인·만료 처리
|
||||
5. 목록·상세 화면과 알림 반영
|
||||
6. 마이그레이션·통합 테스트·운영 전환
|
||||
|
||||
### 9.5 이번 작업의 완료 기준
|
||||
|
||||
- [x] 시퀀스에 정의된 기본 흐름이 실제 API 이벤트와 상태 변경으로 재현됨
|
||||
- [x] 피드백과 이슈 모두 `신규/접수/진행중/완료/보류`만 사용함
|
||||
- [x] 보류 상태는 자동 이벤트가 변경하지 않음
|
||||
- [x] 한 피드백에 여러 이슈가 연결되어도 모든 이슈 완료 전 피드백이 완료되지 않음
|
||||
- [x] 관리자의 처리 완료 안내 댓글 이후 Q&A 작성자 확인 또는 기간 만료를 거쳐서만 피드백이 완료됨
|
||||
- [x] 완료 확인 전 재문의와 완료 후 재문의가 서로 다른 규칙으로 처리됨
|
||||
- [x] 상태 변경·자동 처리·알림·실패 이력이 조회 가능함
|
||||
- [ ] 기존 데이터와 권한 모델을 유지하면서 단계적 롤백이 가능함
|
||||
Reference in New Issue
Block a user