From d197c0cf14b4594d95a9ca85be855bd8c4cfb79f Mon Sep 17 00:00:00 2001 From: root Date: Mon, 21 Sep 2026 20:25:43 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8C=93=EA=B8=80=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B0=8F=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EA=B0=92=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- assets/styles.css | 10 ++ docs/qa-feedback-api-integration-task.md | 10 ++ egbim/app.js | 145 +++++++++++++++++++++-- egbim/detail.html | 2 +- src/index.js | 74 +++++++++++- 6 files changed, 229 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index d888cb4..40cf80f 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ python3 -m http.server 4173 - `feedback_comment_attachments`: 답변 댓글 이미지가 생길 때 `comment_id`와 `qa_cdn` 메타데이터 - `support_attachments`: 운영 호환 레이어가 필요할 때 동일 파일의 티켓 첨부 메타데이터 -상세 페이지에는 공개 댓글 입력 UI가 있으며, 댓글은 같은 Worker를 통해 ABC의 `feedback_comments`에 저장하고 `is_internal=false`인 공개 댓글만 조회합니다. 댓글 첨부파일은 별도 댓글 첨부 API 계약을 확인한 뒤 추가합니다. +상세 페이지에는 공개 댓글 입력 UI가 있으며, 댓글은 같은 Worker를 통해 ABC의 `feedback_comments`에 저장하고 `is_internal=false`인 공개 댓글만 조회합니다. 댓글 이미지가 있으면 `attachments` multipart 필드로 Worker가 ABC에 전달하며, 응답으로 받은 이미지 URL을 썸네일로 표시합니다. 목록·상세 진입 시에는 ABC의 `feedback_status`를 다시 조회해 관리페이지에서 변경된 상태를 반영합니다. ## 연동 지점 diff --git a/assets/styles.css b/assets/styles.css index 4e1c3dd..4e8cc1c 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -282,6 +282,16 @@ button { cursor: pointer; } .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-attachment-input { padding-top: 14px; color: #555; font-size: 13px; } +.comment-attachment-input label { display: block; margin-bottom: 8px; font-weight: 600; } +.comment-attachment-input input { max-width: 100%; } +.comment-image-preview, .comment-attachments-list { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; } +.comment-image-preview-item { width: 104px; overflow: hidden; border: 1px solid #ddd; background: #fafafa; } +.comment-image-preview-item img { display: block; width: 104px; height: 78px; object-fit: cover; } +.comment-image-preview-item span { display: block; overflow: hidden; padding: 5px 6px; color: #666; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.comment-image-link { display: block; width: 124px; } +.comment-image { display: block; width: 124px; height: 94px; border: 1px solid #ddd; object-fit: cover; } +.comment-attachment-name { color: #666; font-size: 13px; } .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; } diff --git a/docs/qa-feedback-api-integration-task.md b/docs/qa-feedback-api-integration-task.md index 2271595..3c9ef1e 100644 --- a/docs/qa-feedback-api-integration-task.md +++ b/docs/qa-feedback-api-integration-task.md @@ -70,11 +70,21 @@ ## 3차 범위: 공개 댓글 - [x] 상세페이지 댓글 입력 UI 추가 +- [x] 댓글 이미지 multipart(`attachments`) 전송 +- [x] 댓글 이미지 선택 시 썸네일 미리보기 - [x] Worker 댓글 목록 조회 라우트 추가 - [x] Worker 공개 댓글 생성 라우트 추가 - [x] `is_internal=false` 공개 댓글만 조회 - [ ] 실제 관리자 답변 등록 후 Q&A 상세페이지 표시 테스트 +## 4차 범위: 관리자 상태 동기화 + +- [x] Worker에 `GET /api/feedbacks/{feedbackId}` 상세 조회 프록시 추가 +- [x] ABC `feedback_status`를 사용자 화면 상태명으로 변환 +- [x] 목록 진입 시 저장된 문의의 최신 상태 재조회 +- [x] 상세페이지 진입 시 최신 상태 재조회 +- [ ] 실제 관리페이지에서 상태 변경 후 Q&A 화면 반영 테스트 + ## 보안 요구사항 - [x] API Key를 정적 JavaScript, HTML, `egbim/config.js`에 넣지 않음 diff --git a/egbim/app.js b/egbim/app.js index 2d54446..cc8975f 100644 --- a/egbim/app.js +++ b/egbim/app.js @@ -6,6 +6,14 @@ const STORAGE_KEY = 'baron.qa.posts.egbim'; const COMMENTS_KEY = 'baron.qa.comments.egbim'; const CATEGORY_CODES = { '오류문의': 'ERROR_QNA', '개선문의': 'IMPROVEMENT_QNA', '일반문의': 'GENERAL_QNA' }; + const STATUS_LABELS = { + INIT: '접수', + ON_REVIEW: '문의검토', + DETAILED_REVIEW: '정밀검토', + IN_PROGRESS: '처리중', + RESOLVED: '답변완료', + PENDING: '보류' + }; let authReady; function qs(selector, root) { return (root || document).querySelector(selector); } @@ -30,6 +38,63 @@ localStorage.setItem(STORAGE_KEY, JSON.stringify(posts)); } + function updateStoredPost(post) { + const posts = readJson(STORAGE_KEY, []); + const index = posts.findIndex(function (item) { return item.id === post.id; }); + if (index < 0) return; + posts[index] = Object.assign({}, posts[index], post); + localStorage.setItem(STORAGE_KEY, JSON.stringify(posts)); + } + + function normalizeStatus(value) { + if (value && typeof value === 'object') value = value.code || value.key || value.value || value.name || ''; + const raw = String(value || '').trim(); + if (!raw) return ''; + const upper = raw.toUpperCase(); + return STATUS_LABELS[upper] || ({ + NEW: '접수', RECEIVED: '접수', 접수: '접수', 문의접수: '접수', + REVIEW: '문의검토', 문의검토: '문의검토', + DEEP: '정밀검토', 정밀검토: '정밀검토', + PATCH: '처리중', 처리중: '처리중', + DONE: '답변완료', COMPLETED: '답변완료', 답변완료: '답변완료', + HOLD: '보류', 보류: '보류' + }[upper] || raw); + } + + function extractFeedbackStatus(payload) { + const root = payload && payload.feedback ? payload.feedback : payload; + const candidates = [root, root && root.data, root && root.feedback, root && root.fields]; + for (let index = 0; index < candidates.length; index += 1) { + const candidate = candidates[index]; + if (!candidate || typeof candidate !== 'object') continue; + const value = candidate.feedback_status || candidate.feedbackStatus || candidate.status_code || candidate.status; + if (value != null && value !== '') return normalizeStatus(value); + } + const fields = root && (Array.isArray(root.fields) ? root.fields : (root.data && Array.isArray(root.data.fields) ? root.data.fields : [])); + const statusField = fields.find(function (field) { return field && (field.key === 'feedback_status' || field.fieldKey === 'feedback_status' || field.name === 'feedback_status'); }); + return statusField ? normalizeStatus(statusField.value || statusField.val || statusField.data) : ''; + } + + async function refreshRemoteStatus(post) { + if (!post || !post.id || post.category === '공지사항') return false; + try { + const result = await requestJson('/api/feedbacks/' + encodeURIComponent(post.id), { method: 'GET' }); + const status = extractFeedbackStatus(result); + if (!status || status === post.status) return false; + post.status = status; + updateStoredPost(post); + return true; + } catch (error) { + console.warn('[QA] feedback status sync failed:', post.id, error.message); + return false; + } + } + + async function refreshRemoteStatuses(posts) { + const changed = await Promise.all((posts || []).map(refreshRemoteStatus)); + return changed.some(Boolean); + } + function getComments() { return readJson(COMMENTS_KEY, {}); } function parseJson(value, fallback) { @@ -177,7 +242,7 @@ } async function requestJson(path, options) { - const sameOriginProxy = !config.apiBaseUrl && (path === config.createFeedbackPath || /^\/api\/feedbacks\/[^/]+\/comments$/.test(path)); + 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)); @@ -260,12 +325,13 @@ tableBody.innerHTML = rows.map(function (post, index) { const isNotice = post.category === '공지사항'; const number = isNotice ? '공지' : filtered.length - ((page - 1) * pageSize + index); - const statusClass = post.status === '답변완료' ? 'done' : (post.status === '문의검토' || post.status === '정밀검토' ? 'review' : ''); + const status = normalizeStatus(post.status); + const statusClass = status === '답변완료' ? 'done' : (status === '문의검토' || status === '정밀검토' ? 'review' : ''); return '' + '' + number + '' + escapeHtml(post.category.replace('문의', '')) + '' + '' + escapeHtml(post.company) + '' + escapeHtml(post.department) + '' + escapeHtml(post.author) + '' + '' + (post.secret ? '🔒' : '') + escapeHtml(post.title) + '' + (!isNotice && post.date === '2026-09-17' ? 'N' : '') + '' + - '' + escapeHtml(post.date) + '' + (post.status ? '' + escapeHtml(post.status) + '' : '-') + ''; + '' + escapeHtml(post.date) + '' + (status ? '' + escapeHtml(status) + '' : '-') + ''; }).join(''); qsa('#qaRows tr').forEach(function (row) { row.addEventListener('click', function () { window.location.href = 'detail.html?id=' + encodeURIComponent(row.dataset.id); }); }); pagination.innerHTML = Array.from({ length: totalPages }, function (_, i) { return ''; }).join(''); @@ -275,6 +341,7 @@ searchForm.addEventListener('submit', function (event) { event.preventDefault(); page = 1; render(); }); qsa('input[name="category"], #onlyMine').forEach(function (input) { input.addEventListener('change', function () { page = 1; render(); }); }); render(); + refreshRemoteStatuses(getPosts()).then(function (changed) { if (changed) render(); }); } function initWrite() { @@ -334,7 +401,7 @@ } qs('[data-detail-category]').textContent = post.category; qs('[data-detail-title]').textContent = post.title; - qs('[data-detail-status]').textContent = post.status || '공지'; + qs('[data-detail-status]').textContent = normalizeStatus(post.status) || '공지'; qs('[data-detail-date]').textContent = post.date; qs('[data-detail-author]').textContent = post.author; qs('[data-detail-company]').textContent = post.company; @@ -346,14 +413,48 @@ if (attachmentBox) attachmentBox.innerHTML = attachments.length ? attachments.map(function (attachment) { return '
📎 ' + escapeHtml(attachment.originalFileName || '첨부파일') + '
'; }).join('') : '첨부된 파일이 없습니다.'; + function safeMediaUrl(value) { + value = String(value || ''); + return /^(https?:\/\/|\/|blob:)/i.test(value) ? value : ''; + } + + function normalizeAttachment(attachment) { + if (typeof attachment === 'string') return { name: '첨부 이미지', url: safeMediaUrl(attachment), thumbnailUrl: safeMediaUrl(attachment) }; + attachment = attachment || {}; + const url = attachment.url || attachment.download_url || attachment.downloadUrl || attachment.signed_url || attachment.signedUrl || attachment.presigned_url || attachment.presignedUrl || ''; + const thumbnailUrl = attachment.thumbnail_url || attachment.thumbnailUrl || attachment.thumb_url || attachment.thumbUrl || url; + return { + name: attachment.original_file_name || attachment.originalFileName || attachment.file_name || attachment.fileName || attachment.name || '첨부 이미지', + url: safeMediaUrl(url), + thumbnailUrl: safeMediaUrl(thumbnailUrl) + }; + } + function normalizeComment(comment) { comment = comment || {}; - return { id: comment.id || '', author: comment.author_name || comment.authorName || comment.author || comment.author?.name || '작성자', date: comment.created_at || comment.createdAt || comment.updated_at || '', content: comment.content || comment.body || '' }; + const rawAttachments = comment.attachments || comment.images || comment.comment_attachments || comment.commentAttachments || []; + return { + id: comment.id || '', + author: comment.author_name || comment.authorName || comment.author?.name || comment.author || '작성자', + date: comment.created_at || comment.createdAt || comment.updated_at || '', + content: comment.content || comment.body || '', + attachments: (Array.isArray(rawAttachments) ? rawAttachments : [rawAttachments]).map(normalizeAttachment) + }; } + + function renderCommentAttachments(attachments) { + return (attachments || []).map(function (attachment) { + const imageUrl = attachment.thumbnailUrl || attachment.url; + if (!imageUrl) return '
📎 ' + escapeHtml(attachment.name) + '
'; + const linkUrl = attachment.url || imageUrl; + return '' + escapeHtml(attachment.name) + ''; + }).join(''); + } + function renderComments(comments) { comments = (comments || []).map(normalizeComment).filter(function (comment) { return comment.content; }); qs('#commentCount').textContent = comments.length; - qs('#comments').innerHTML = comments.length ? comments.map(function (comment) { return '
' + escapeHtml(comment.author) + '' + escapeHtml(comment.date) + '
' + escapeHtml(comment.content) + '
'; }).join('') : '
등록된 답변이 없습니다.
'; + qs('#comments').innerHTML = comments.length ? comments.map(function (comment) { return '
' + escapeHtml(comment.author) + '' + escapeHtml(comment.date) + '
' + escapeHtml(comment.content) + '
' + (comment.attachments.length ? '
' + renderCommentAttachments(comment.attachments) + '
' : '') + '
'; }).join('') : '
등록된 답변이 없습니다.
'; return comments; } let comments = renderComments(getComments()[post.id] || []); @@ -369,16 +470,32 @@ const contentInput = qs('#commentContent'); const errorBox = qs('#commentError'); const submitButton = qs('#commentSubmit'); + const imageInput = qs('#commentImages'); + const files = imageInput ? Array.prototype.slice.call(imageInput.files || []) : []; const content = contentInput.value.trim(); errorBox.hidden = true; if (!content) { errorBox.textContent = '댓글 내용을 입력해주세요.'; errorBox.hidden = false; return; } + if (files.some(function (file) { return !/^image\//i.test(file.type); })) { errorBox.textContent = '댓글 첨부는 이미지 파일만 등록할 수 있습니다.'; errorBox.hidden = false; return; } + if (files.some(function (file) { return file.size > 30 * 1024 * 1024; })) { errorBox.textContent = '댓글 이미지는 파일당 30MB 이하만 등록할 수 있습니다.'; errorBox.hidden = false; return; } submitButton.disabled = true; submitButton.textContent = '등록 중...'; try { - const result = await postToApi(commentsPath, { content: content }); - comments.push(normalizeComment(result.comment || { id: result.id, content: content, author: (getAuthUser() || {}).name || '작성자', createdAt: new Date().toISOString() })); + let payload = { content: content }; + if (files.length) { + payload = new FormData(); + payload.append('content', content); + files.forEach(function (file) { payload.append('attachments', file, file.name); }); + } + const result = await postToApi(commentsPath, payload); + const localAttachments = files.map(function (file) { const url = URL.createObjectURL(file); return { name: file.name, url: url, thumbnailUrl: url }; }); + const savedComment = normalizeComment(result.comment || { id: result.id, content: content, author: (getAuthUser() || {}).name || '작성자', createdAt: new Date().toISOString(), attachments: localAttachments }); + if (!savedComment.attachments.length) savedComment.attachments = localAttachments; + comments.push(savedComment); renderComments(comments); contentInput.value = ''; + if (imageInput) imageInput.value = ''; + const imagePreview = qs('#commentImagePreview'); + if (imagePreview) imagePreview.innerHTML = ''; } catch (error) { errorBox.textContent = error.message || '댓글 등록에 실패했습니다.'; errorBox.hidden = false; @@ -387,6 +504,18 @@ submitButton.textContent = '댓글 등록'; } }); + const commentImages = qs('#commentImages'); + const commentImagePreview = qs('#commentImagePreview'); + if (commentImages && commentImagePreview) commentImages.addEventListener('change', function () { + const files = Array.prototype.slice.call(commentImages.files || []); + commentImagePreview.innerHTML = files.filter(function (file) { return /^image\//i.test(file.type); }).map(function (file) { + const url = URL.createObjectURL(file); + return '
' + escapeHtml(file.name) + '' + escapeHtml(file.name) + '
'; + }).join(''); + }); + refreshRemoteStatus(post).then(function (changed) { + if (changed) qs('[data-detail-status]').textContent = normalizeStatus(post.status) || '공지'; + }); qs('#backButton').addEventListener('click', function () { window.location.href = 'index.html'; }); } diff --git a/egbim/detail.html b/egbim/detail.html index 765cecd..68295b9 100644 --- a/egbim/detail.html +++ b/egbim/detail.html @@ -3,6 +3,6 @@ 문의 상세 | EG-BIM Q&A
EG-BIM SUPPORT

Q&A

EG-BIM 관련 문의하기

-

문의 상세

문의 내용과 답변을 확인할 수 있습니다.

문의 문의 제목

문의접수
2026-09-17작성자회사부서

첨부파일

첨부된 파일이 없습니다.

답변 0

+

문의 상세

문의 내용과 답변을 확인할 수 있습니다.

문의 문의 제목

문의접수
2026-09-17작성자회사부서

첨부파일

첨부된 파일이 없습니다.

답변 0

이미지 파일만 등록할 수 있으며, 파일당 최대 30MB입니다.
diff --git a/src/index.js b/src/index.js index dcaf291..a37213f 100644 --- a/src/index.js +++ b/src/index.js @@ -12,6 +12,7 @@ export default { if (url.pathname === '/auth/session') return sessionResponse(request, env); if (url.pathname === '/api/feedbacks') return createFeedback(request, env); if (/^\/api\/feedbacks\/[^/]+\/comments$/.test(url.pathname)) return feedbackComments(request, env, url); + if (/^\/api\/feedbacks\/[^/]+$/.test(url.pathname)) return feedbackDetail(request, env, url); if (request.method !== 'GET' && request.method !== 'HEAD') { return json({ error: 'method_not_allowed' }, 405); @@ -432,22 +433,40 @@ async function feedbackComments(request, env, url) { }; let body; let commentId; + const uploadedAttachments = []; if (request.method === 'GET') { const query = new URLSearchParams({ includeInternal: 'false' }); endpoint = endpoint + '?' + query.toString(); } else { let input; - try { input = await request.json(); } catch (error) { return json({ error: 'invalid_json' }, 400); } + const requestContentType = request.headers.get('content-type') || ''; + try { + if (requestContentType.toLowerCase().includes('multipart/form-data')) { + const formData = await request.formData(); + input = {}; + for (const [key, value] of formData.entries()) { + if (key === 'attachments' && typeof value !== 'string') uploadedAttachments.push(value); + else if (typeof value === 'string' && !(key in input)) input[key] = value; + } + } else { + input = await request.json(); + } + } catch (error) { + return json({ error: requestContentType.toLowerCase().includes('multipart/form-data') ? 'invalid_multipart' : 'invalid_json' }, 400); + } const content = String(input && input.content || '').trim(); if (!content) return json({ error: 'comment_content_required' }, 400); if (content.length > 5000) return json({ error: 'comment_content_too_long' }, 400); + const invalidAttachment = uploadedAttachments.find((file) => !String(file.type || '').toLowerCase().startsWith('image/')); + if (invalidAttachment) return json({ error: 'comment_image_required', message: '댓글 첨부는 이미지 파일만 등록할 수 있습니다.' }, 415); + const oversizedAttachment = uploadedAttachments.find((file) => Number(file.size || 0) > 30 * 1024 * 1024); + if (oversizedAttachment) return json({ error: 'comment_attachment_too_large', message: '댓글 이미지는 파일당 30MB 이하만 등록할 수 있습니다.' }, 413); commentId = uuidv7(); - headers['content-type'] = 'application/json'; headers['x-source-namespace'] = sourceNamespace; headers['x-source-record-id'] = commentId; headers['x-idempotency-consumer'] = sourceNamespace; headers['idempotency-key'] = commentId; - body = JSON.stringify({ + const commentPayload = { author_id: requesterId, author_tenant_id: requesterTenantId, author_name: String(user.name || user.email || ''), @@ -455,7 +474,16 @@ async function feedbackComments(request, env, url) { is_internal: false, actor_type: 'USER', comment_type: 'COMMENT' - }); + }; + if (uploadedAttachments.length) { + const multipart = new FormData(); + Object.entries(commentPayload).forEach(([key, value]) => multipart.append(key, String(value))); + uploadedAttachments.forEach((file) => multipart.append('attachments', file, file.name || 'comment-image')); + body = multipart; + } else { + headers['content-type'] = 'application/json'; + body = JSON.stringify(commentPayload); + } } let response; @@ -477,6 +505,44 @@ async function feedbackComments(request, env, url) { return json({ id: result.id || result.data?.id || result.comment?.id || commentId, comment: result.comment || result.data || result }); } +async function feedbackDetail(request, env, url) { + if (request.method !== 'GET') return json({ error: 'method_not_allowed' }, 405, { allow: 'GET' }); + const user = await getSession(request, env); + if (!user) return json({ error: 'unauthenticated' }, 401); + if (!env.ABC_API_KEY) return json({ error: 'feedback_api_not_configured' }, 503); + if (!env.ABC_PROJECT_ID || !env.ABC_CHANNEL_ID) return json({ error: 'feedback_target_not_configured' }, 503); + + const feedbackId = decodeURIComponent(url.pathname.split('/')[3] || ''); + if (!isUuidv7(feedbackId)) return json({ error: 'invalid_feedback_id' }, 400); + + const requesterId = String(user.requesterId || user.ssoSubject || user.userUuid || ''); + const requesterTenantId = String(user.requesterTenantId || user.tenantId || ''); + if (!requesterId || !requesterTenantId) return json({ error: 'requester_identity_missing' }, 422); + const apiBaseUrl = String(env.ABC_API_BASE_URL || 'https://feedback.hmac.kr').replace(/\/$/, ''); + const endpoint = `${apiBaseUrl}/api/projects/${encodeURIComponent(env.ABC_PROJECT_ID)}/channels/${encodeURIComponent(env.ABC_CHANNEL_ID)}/feedbacks/${encodeURIComponent(feedbackId)}`; + let response; + try { + response = await fetch(endpoint, { + method: 'GET', + headers: { + accept: 'application/json', + 'x-api-key': env.ABC_API_KEY, + 'x-requester-id': requesterId, + 'x-requester-tenant-id': requesterTenantId + } + }); + } catch (error) { + console.error('feedback_detail_api_request_failed', error instanceof Error ? error.message : 'unknown'); + return json({ error: 'feedback_api_unreachable' }, 502); + } + const result = await response.json().catch(() => ({})); + if (!response.ok) { + const upstreamMessage = String(result.message || result.error || result.code || '').slice(0, 240); + return json({ error: 'feedback_api_rejected', message: upstreamMessage || '문의 상세 조회에 실패했습니다.', status: response.status }, response.status >= 500 ? 502 : response.status); + } + return json({ feedback: result.feedback || result.data || result }); +} + function isUuidv7(value) { return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); }