Q&A
EG-BIM 관련 문의하기
문의 상세
문의 내용과 답변을 확인할 수 있습니다.
답변 0
문의 상세
문의 내용과 답변을 확인할 수 있습니다.
diff --git a/README.md b/README.md index e63fa43..d888cb4 100644 --- a/README.md +++ b/README.md @@ -31,15 +31,15 @@ python3 -m http.server 4173 - `feedback_comment_attachments`: 답변 댓글 이미지가 생길 때 `comment_id`와 `qa_cdn` 메타데이터 - `support_attachments`: 운영 호환 레이어가 필요할 때 동일 파일의 티켓 첨부 메타데이터 -현재 작성 페이지에는 댓글 입력 UI가 없으므로 `comments`와 `commentAttachments`는 빈 배열로 보냅니다. 관리 페이지에서 댓글과 이미지를 작성할 때 같은 규칙으로 `feedback_comments`/`feedback_comment_attachments`와 `ticket_comments`/`support_attachments`를 생성할 수 있도록 envelope를 열어 두었습니다. +상세 페이지에는 공개 댓글 입력 UI가 있으며, 댓글은 같은 Worker를 통해 ABC의 `feedback_comments`에 저장하고 `is_internal=false`인 공개 댓글만 조회합니다. 댓글 첨부파일은 별도 댓글 첨부 API 계약을 확인한 뒤 추가합니다. ## 연동 지점 - SSO: Q&A는 홈페이지 로그인 정보를 전달받지 않고, `https://app.brsw.kr/oidc`에 독립적인 PKCE Public Client로 직접 인증합니다. callback은 `https://qa-test.baroncs.co.kr/auth/callback`이며, Worker가 검증한 claim을 `baron_qa_session` HttpOnly 쿠키에 저장합니다. 브라우저의 `GET /auth/session`은 Q&A 세션에 저장된 작성자 정보를 반환합니다. feedback 서버는 반드시 SSO 세션 또는 토큰을 서버 측에서 검증해야 합니다. - 작성자 식별자: `ssoSubject`/`requesterId`, `userUuid`, `tenantId`/`requesterTenantId`, `tenantIds`, `scope`, `roles`, 이메일·이름·부서·전화번호를 payload에 넣습니다. `requester_id`와 `requester_tenant_id`가 없으면 제출을 차단합니다. - API: 작성페이지는 같은 Worker의 `POST /api/feedbacks`를 호출합니다. Worker가 `baron_qa_session`을 검증하고, SSO requester 정보와 `ABC_API_KEY` Secret을 추가한 뒤 `POST https://feedback.hmac.kr/api/projects/{projectId}/channels/{channelId}/feedbacks`로 전달합니다. API Key와 내부 ABC 주소는 브라우저에 노출하지 않습니다. -- presign 응답: `{ "uploads": [{ "uploadUrl": "...", "storageKey": "...", "storageBucket": "qa_cdn", "headers": {} }] }` 형태를 기대합니다. R2 access key/secret은 정적 페이지에 넣지 않습니다. -- API 주소가 비어 있으면 테스트를 위해 브라우저 `localStorage`에만 저장하며, 첨부파일은 `local-preview/...` 메타데이터만 생성합니다. +- 첨부파일: 작성페이지가 `multipart/form-data`의 반복 `images` 필드로 파일을 Worker에 전달하고, Worker가 검증된 requester 정보와 API Key를 추가해 ABC API로 전달합니다. ABC API가 `qa_cdn` 저장소에 파일을 저장하므로 R2 access key/secret은 정적 페이지에 넣지 않습니다. +- 텍스트만 등록할 때는 기존 JSON 요청을 사용하고, 파일이 있을 때만 multipart 요청을 사용합니다. ## Cloudflare R2 diff --git a/assets/styles.css b/assets/styles.css index caf9bf5..4e1c3dd 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -271,6 +271,7 @@ button { cursor: pointer; } .detail-body { min-height: 260px; padding: 38px 22px 56px; white-space: pre-wrap; word-break: break-word; } .attachments { padding: 16px 22px; border-top: 1px solid var(--line); background: #fafafa; } .attachments h3 { margin: 0 0 8px; font-size: 14px; } +.attachment-item { padding: 7px 0; color: #555; font-size: 13px; } .attachments a { color: var(--navy); text-decoration: underline; } .detail-actions { display: flex; justify-content: space-between; padding: 23px 22px 0; } .comment-box { margin-top: 54px; } @@ -279,6 +280,10 @@ button { cursor: pointer; } .comment-meta { display: flex; justify-content: space-between; margin-bottom: 7px; color: #777; font-size: 13px; } .comment-content { white-space: pre-wrap; } .comment-empty { padding: 32px; border-bottom: 1px solid var(--line); color: #999; text-align: center; } +.comment-form { padding: 20px 10px 0; } +.comment-form .textarea { min-height: 120px; } +.comment-form-actions { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding-top: 10px; } +.comment-form-actions .form-error { display: block; flex: 1; margin: 0; } .site-footer { padding: 38px 32px; background: #111; color: #b9b9b9; } .footer-inner { display: flex; align-items: flex-start; justify-content: space-between; max-width: 1280px; margin: 0 auto; gap: 40px; } diff --git a/docs/qa-feedback-api-integration-task.md b/docs/qa-feedback-api-integration-task.md index 8038008..2271595 100644 --- a/docs/qa-feedback-api-integration-task.md +++ b/docs/qa-feedback-api-integration-task.md @@ -60,10 +60,20 @@ ## 2차 범위: 첨부파일 -- [ ] R2 업로드 결과의 `storageKey`를 ABC `images` 필드에 연결 -- [ ] ABC API가 허용하는 이미지 메타데이터 형식 확인 -- [ ] presigned URL 및 첨부파일 오류 처리 -- [ ] 이미지 포함 저장 테스트 +- [x] 작성페이지에서 `images` multipart 바이너리 전송 +- [x] Worker가 SSO requester 메타데이터와 `images`를 ABC API로 전달 +- [x] ABC API가 허용하는 이미지 포함 multipart 계약 확인 +- [x] 파일당 30MB 제한 및 첨부파일 오류 처리 +- [ ] 이미지 포함 실제 저장 테스트 +- [ ] ABC 상세 조회 응답의 첨부파일 다운로드 URL 연결 + +## 3차 범위: 공개 댓글 + +- [x] 상세페이지 댓글 입력 UI 추가 +- [x] Worker 댓글 목록 조회 라우트 추가 +- [x] Worker 공개 댓글 생성 라우트 추가 +- [x] `is_internal=false` 공개 댓글만 조회 +- [ ] 실제 관리자 답변 등록 후 Q&A 상세페이지 표시 테스트 ## 보안 요구사항 diff --git a/egbim/app.js b/egbim/app.js index 5f768c1..2d54446 100644 --- a/egbim/app.js +++ b/egbim/app.js @@ -177,7 +177,7 @@ } async function requestJson(path, options) { - const sameOriginProxy = !config.apiBaseUrl && path === config.createFeedbackPath; + const sameOriginProxy = !config.apiBaseUrl && (path === config.createFeedbackPath || /^\/api\/feedbacks\/[^/]+\/comments$/.test(path)); if (!config.apiBaseUrl && !sameOriginProxy) return { local: true }; const target = config.apiBaseUrl ? config.apiBaseUrl.replace(/\/$/, '') + path : path; const response = await fetch(target, Object.assign({ credentials: 'include' }, options)); @@ -189,7 +189,8 @@ } async function postToApi(path, payload) { - return requestJson(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); + const isFormData = typeof FormData !== 'undefined' && payload instanceof FormData; + return requestJson(path, isFormData ? { method: 'POST', body: payload } : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); } function makeUuid() { @@ -215,40 +216,17 @@ return Array.prototype.map.call(new Uint8Array(digest), function (byte) { return ('00' + byte.toString(16)).slice(-2); }).join(''); } - async function uploadFiles(files, feedbackId, purpose) { + async function buildFeedbackSubmission(post, feedbackId, files) { files = Array.prototype.slice.call(files || []); - if (!files.length) return []; const descriptors = await Promise.all(files.map(async function (file) { - return { clientId: makeUuid(), originalFileName: file.name, mimeType: file.type || 'application/octet-stream', fileSize: file.size, checksumSha256: await sha256(file) }; + return { id: makeUuid(), originalFileName: file.name, mimeType: file.type || 'application/octet-stream', fileSize: file.size, checksumSha256: await sha256(file), purpose: 'FEEDBACK_ATTACHMENT' }; })); - if (!config.apiBaseUrl) { - return descriptors.map(function (item) { return Object.assign(item, { id: makeUuid(), storageBucket: config.storageBucket, storageKey: 'local-preview/' + feedbackId + '/' + item.clientId + '-' + item.originalFileName, purpose: purpose }); }); - } - const presign = await postToApi(config.presignPath, { feedbackId: feedbackId, workspaceId: config.workspaceId, workspaceCode: config.workspaceCode, bucket: config.storageBucket, purpose: purpose, files: descriptors }); - const uploads = presign.uploads || presign.items || []; - if (uploads.length !== descriptors.length) throw new Error('첨부파일 업로드 URL을 모두 받지 못했습니다.'); - return Promise.all(uploads.map(async function (upload, index) { - const file = files[index]; - const headers = Object.assign({}, upload.headers || {}); - if (!headers['Content-Type'] && file.type) headers['Content-Type'] = file.type; - const response = await fetch(upload.uploadUrl, { method: upload.method || 'PUT', headers: headers, body: file }); - if (!response.ok) throw new Error(file.name + ' 업로드에 실패했습니다.'); - if (!upload.storageKey) throw new Error(file.name + '의 storageKey를 받지 못했습니다.'); - return Object.assign(descriptors[index], upload.metadata || {}, { - id: upload.id || makeUuid(), storageBucket: upload.storageBucket || config.storageBucket, - storageKey: upload.storageKey, purpose: purpose - }); - })); - } - - function buildFeedbackPayload(post, user, feedbackId, attachments) { - const categoryCode = CATEGORY_CODES[post.category]; - const imageMetadata = attachments.filter(function (item) { return item.purpose === 'FEEDBACK_ATTACHMENT'; }).map(function (item) { - return { id: item.id, original_file_name: item.originalFileName, storage_bucket: item.storageBucket, storage_key: item.storageKey, mime_type: item.mimeType, file_size: item.fileSize, checksum_sha256: item.checksumSha256 }; - }); - const payload = { feedbackId: feedbackId, title: post.title, contents: post.content, Category: categoryCode, is_secret: post.secret ? 1 : 0 }; - if (imageMetadata.length) payload.images = imageMetadata; - return payload; + const payload = { feedbackId: feedbackId, title: post.title, contents: post.content, Category: CATEGORY_CODES[post.category], is_secret: post.secret ? 'true' : 'false' }; + if (!files.length) return { body: payload, attachments: descriptors }; + const formData = new FormData(); + Object.keys(payload).forEach(function (key) { formData.append(key, payload[key]); }); + files.forEach(function (file) { formData.append('images', file, file.name); }); + return { body: formData, attachments: descriptors }; } function initList() { @@ -325,12 +303,12 @@ submitButton.disabled = true; submitButton.textContent = '등록 중...'; try { - const attachments = await uploadFiles(qs('#attachment').files, feedbackId, 'FEEDBACK_ATTACHMENT'); - const payload = buildFeedbackPayload(post, currentUser, feedbackId, attachments); + const submission = await buildFeedbackSubmission(post, feedbackId, qs('#attachment').files); console.info('[QA] sending feedback to Worker:', config.createFeedbackPath); - const result = await postToApi(config.createFeedbackPath, payload); + const result = await postToApi(config.createFeedbackPath, submission.body); console.info('[QA] Worker feedback response:', result); post.id = result.id || post.id; + post.attachments = submission.attachments; savePost(post); showToast(result.local ? '테스트 글로 저장했습니다. UUID가 생성되었습니다.' : '문의가 등록되었습니다.'); window.setTimeout(function () { window.location.href = 'detail.html?id=' + encodeURIComponent(post.id); }, 500); @@ -363,9 +341,52 @@ qs('[data-detail-department]').textContent = post.department; qs('[data-detail-content]').textContent = post.content; qs('[data-detail-secret]').hidden = !post.secret; - const comments = getComments()[post.id] || []; - qs('#commentCount').textContent = comments.length; - qs('#comments').innerHTML = comments.length ? comments.map(function (comment) { return '
EG-BIM 관련 문의하기
문의 내용과 답변을 확인할 수 있습니다.
문의 내용과 답변을 확인할 수 있습니다.