@@ -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
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 상세페이지 표시 테스트
|
||||
|
||||
## 보안 요구사항
|
||||
|
||||
|
||||
+60
-39
@@ -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 '<div class="comment"><div class="comment-meta"><strong>' + escapeHtml(comment.author) + '</strong><span>' + escapeHtml(comment.date) + '</span></div><div class="comment-content">' + escapeHtml(comment.content) + '</div></div>'; }).join('') : '<div class="comment-empty">등록된 답변이 없습니다.</div>';
|
||||
const attachmentBox = qs('[data-detail-attachments]');
|
||||
const attachments = Array.isArray(post.attachments) ? post.attachments : [];
|
||||
if (attachmentBox) attachmentBox.innerHTML = attachments.length ? attachments.map(function (attachment) {
|
||||
return '<div class="attachment-item">📎 ' + escapeHtml(attachment.originalFileName || '첨부파일') + '</div>';
|
||||
}).join('') : '<span class="help-text">첨부된 파일이 없습니다.</span>';
|
||||
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 || '' };
|
||||
}
|
||||
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 '<div class="comment"><div class="comment-meta"><strong>' + escapeHtml(comment.author) + '</strong><span>' + escapeHtml(comment.date) + '</span></div><div class="comment-content">' + escapeHtml(comment.content) + '</div></div>'; }).join('') : '<div class="comment-empty">등록된 답변이 없습니다.</div>';
|
||||
return comments;
|
||||
}
|
||||
let comments = renderComments(getComments()[post.id] || []);
|
||||
const commentsPath = '/api/feedbacks/' + encodeURIComponent(post.id) + '/comments';
|
||||
requestJson(commentsPath, { method: 'GET' }).then(function (result) {
|
||||
comments = renderComments(result.comments || result.items || []);
|
||||
}).catch(function (error) {
|
||||
console.warn('[QA] comments load failed:', error.message);
|
||||
});
|
||||
const commentForm = qs('#commentForm');
|
||||
if (commentForm) commentForm.addEventListener('submit', async function (event) {
|
||||
event.preventDefault();
|
||||
const contentInput = qs('#commentContent');
|
||||
const errorBox = qs('#commentError');
|
||||
const submitButton = qs('#commentSubmit');
|
||||
const content = contentInput.value.trim();
|
||||
errorBox.hidden = true;
|
||||
if (!content) { errorBox.textContent = '댓글 내용을 입력해주세요.'; 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() }));
|
||||
renderComments(comments);
|
||||
contentInput.value = '';
|
||||
} catch (error) {
|
||||
errorBox.textContent = error.message || '댓글 등록에 실패했습니다.';
|
||||
errorBox.hidden = false;
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = '댓글 등록';
|
||||
}
|
||||
});
|
||||
qs('#backButton').addEventListener('click', function () { window.location.href = 'index.html'; });
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ window.QA_CONFIG = Object.assign({
|
||||
apiBaseUrl: '',
|
||||
ssoUrl: '/auth/login',
|
||||
ssoSessionEndpoint: '/auth/session',
|
||||
presignPath: '/v1/qa/uploads/presign',
|
||||
createFeedbackPath: '/api/feedbacks',
|
||||
projectId: '01a0ae3f-fcf6-74b5-bdc4-70d942d6ad72',
|
||||
workspaceId: 6,
|
||||
|
||||
+1
-1
@@ -3,6 +3,6 @@
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>문의 상세 | EG-BIM Q&A</title><link rel="stylesheet" href="../assets/styles.css?v=20260921-4"></head>
|
||||
<body>
|
||||
<main class="egbim-page"><section class="page-hero"><div class="hero-inner"><span class="hero-kicker">EG-BIM SUPPORT</span><h1>Q&A</h1><p>EG-BIM 관련 문의하기</p></div><nav class="sub-nav" aria-label="EG-BIM 지원 메뉴"><div class="sub-nav-inner"><a href="index.html" aria-current="page">문의하기(Q&A)</a><a href="#" aria-disabled="true" class="disabled-tab">원격지원</a></div></nav></section>
|
||||
<section class="page-shell" id="detail"><div class="section-heading"><div><h2>문의 상세</h2><p>문의 내용과 답변을 확인할 수 있습니다.</p></div><a class="button button-primary" href="write.html">문의 등록</a></div><article class="detail-card"><div class="detail-head"><div class="detail-title-line"><h2><span class="category" data-detail-category>문의</span> <span data-detail-title>문의 제목</span></h2><span class="status review" data-detail-status>문의접수</span></div><div class="detail-meta"><span data-detail-date>2026-09-17</span><span data-detail-author>작성자</span><span data-detail-company>회사</span><span data-detail-department>부서</span><span data-detail-secret hidden>🔒 비밀글</span></div></div><div class="detail-body" data-detail-content></div><div class="attachments"><h3>첨부파일</h3><span class="help-text">첨부된 파일이 없습니다.</span></div><div class="detail-actions"><a class="button button-outline" href="index.html">목록</a><a class="button button-outline" href="write.html">문의 등록</a></div></article><section class="comment-box"><h3>답변 <span id="commentCount">0</span></h3><div id="comments"></div></section><div class="form-footer"><button class="button button-outline" id="backButton" type="button">목록으로</button></div></section>
|
||||
<section class="page-shell" id="detail"><div class="section-heading"><div><h2>문의 상세</h2><p>문의 내용과 답변을 확인할 수 있습니다.</p></div></div><article class="detail-card"><div class="detail-head"><div class="detail-title-line"><h2><span class="category" data-detail-category>문의</span> <span data-detail-title>문의 제목</span></h2><span class="status review" data-detail-status>문의접수</span></div><div class="detail-meta"><span data-detail-date>2026-09-17</span><span data-detail-author>작성자</span><span data-detail-company>회사</span><span data-detail-department>부서</span><span data-detail-secret hidden>🔒 비밀글</span></div></div><div class="detail-body" data-detail-content></div><div class="attachments"><h3>첨부파일</h3><div data-detail-attachments><span class="help-text">첨부된 파일이 없습니다.</span></div></div><div class="detail-actions"><a class="button button-outline" href="index.html">목록</a></div></article><section class="comment-box"><h3>답변 <span id="commentCount">0</span></h3><div id="comments"></div><form id="commentForm" class="comment-form"><label class="sr-only" for="commentContent">댓글 내용</label><textarea id="commentContent" class="textarea" maxlength="5000" placeholder="답변 또는 추가 문의를 입력해주세요" required></textarea><div class="comment-form-actions"><span id="commentError" class="form-error" role="alert" hidden></span><button class="button button-primary" id="commentSubmit" type="submit">댓글 등록</button></div></form></section><div class="form-footer"><button class="button button-outline" id="backButton" type="button">목록으로</button></div></section>
|
||||
</main><script src="config.js?v=20260921-3"></script><script src="app.js?v=20260921-3"></script>
|
||||
</body></html>
|
||||
|
||||
+1
-1
@@ -3,6 +3,6 @@
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>문의 등록 | EG-BIM Q&A</title><link rel="stylesheet" href="../assets/styles.css?v=20260921-4"></head>
|
||||
<body>
|
||||
<main class="egbim-page"><section class="page-hero"><div class="hero-inner"><span class="hero-kicker">EG-BIM SUPPORT</span><h1>Q&A</h1><p>EG-BIM 관련 문의하기</p></div><nav class="sub-nav" aria-label="EG-BIM 지원 메뉴"><div class="sub-nav-inner"><a href="index.html" aria-current="page">문의하기(Q&A)</a><a href="#" aria-disabled="true" class="disabled-tab">원격지원</a></div></nav></section>
|
||||
<section class="page-shell"><div class="section-heading"><div><h2>문의 등록</h2><p>로그인한 사용자 정보로 문의가 등록됩니다.</p></div></div><div class="form-notice" data-auth-state>로그인 상태를 확인하고 있습니다.</div><div class="form-error" id="formError" role="alert"></div><form id="qaForm" novalidate><div class="form-card" id="writeFields"><div class="form-row"><label class="form-label" for="category">구분 <span class="required">*</span></label><div class="form-control"><select class="select" id="category" required><option value="">선택해주세요</option><option>오류문의</option><option>개선문의</option><option>일반문의</option></select></div></div><div class="form-row"><label class="form-label" for="title">제목 <span class="required">*</span></label><div class="form-control"><input class="input" id="title" type="text" maxlength="100" placeholder="문의 제목을 입력해주세요" required></div></div><div class="form-row"><span class="form-label">공개 설정</span><div class="form-control"><label class="check"><input id="secret" type="checkbox"> 비밀글로 등록</label></div></div><div class="form-row"><label class="form-label" for="content">내용 <span class="required">*</span></label><div class="form-control column"><textarea class="textarea" id="content" placeholder="문의 내용을 작성해주세요" required></textarea><span class="help-text">개인정보, 비밀번호, 인증번호 등 민감한 정보는 입력하지 마세요.</span></div></div><div class="form-row"><label class="form-label" for="attachment">첨부파일</label><div class="form-control column"><input class="file-input" id="attachment" type="file" multiple><span class="help-text">파일은 presigned URL을 통해 qa_cdn 버킷에 저장됩니다. 파일당 최대 30MB</span></div></div></div><div class="form-footer"><a class="button button-outline" href="index.html">취소</a><button class="button button-primary" id="submitButton" type="submit">문의 등록</button></div></form></section>
|
||||
<section class="page-shell"><div class="section-heading"><div><h2>문의 등록</h2><p>로그인한 사용자 정보로 문의가 등록됩니다.</p></div></div><div class="form-notice" data-auth-state>로그인 상태를 확인하고 있습니다.</div><div class="form-error" id="formError" role="alert"></div><form id="qaForm" novalidate><div class="form-card" id="writeFields"><div class="form-row"><label class="form-label" for="category">구분 <span class="required">*</span></label><div class="form-control"><select class="select" id="category" required><option value="">선택해주세요</option><option>오류문의</option><option>개선문의</option><option>일반문의</option></select></div></div><div class="form-row"><label class="form-label" for="title">제목 <span class="required">*</span></label><div class="form-control"><input class="input" id="title" type="text" maxlength="100" placeholder="문의 제목을 입력해주세요" required></div></div><div class="form-row"><span class="form-label">공개 설정</span><div class="form-control"><label class="check"><input id="secret" type="checkbox"> 비밀글로 등록</label></div></div><div class="form-row"><label class="form-label" for="content">내용 <span class="required">*</span></label><div class="form-control column"><textarea class="textarea" id="content" placeholder="문의 내용을 작성해주세요" required></textarea><span class="help-text">개인정보, 비밀번호, 인증번호 등 민감한 정보는 입력하지 마세요.</span></div></div><div class="form-row"><label class="form-label" for="attachment">첨부파일</label><div class="form-control column"><input class="file-input" id="attachment" type="file" multiple><span class="help-text">파일은 문의와 함께 안전하게 업로드됩니다. 파일당 최대 30MB</span></div></div></div><div class="form-footer"><a class="button button-outline" href="index.html">취소</a><button class="button button-primary" id="submitButton" type="submit">문의 등록</button></div></form></section>
|
||||
</main><div id="toast" class="toast" role="status" aria-live="polite"></div><script src="config.js?v=20260921-3"></script><script src="app.js?v=20260921-3"></script>
|
||||
</body></html>
|
||||
|
||||
+118
-12
@@ -11,6 +11,7 @@ export default {
|
||||
if (url.pathname === '/auth/logout') return logout(request, env);
|
||||
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 (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
return json({ error: 'method_not_allowed' }, 405);
|
||||
@@ -302,10 +303,24 @@ async function createFeedback(request, env) {
|
||||
if (!env.ABC_PROJECT_ID || !env.ABC_CHANNEL_ID) return json({ error: 'feedback_target_not_configured' }, 503);
|
||||
|
||||
let input;
|
||||
const uploadedImages = [];
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
try {
|
||||
input = await request.json();
|
||||
if (contentType.toLowerCase().includes('multipart/form-data')) {
|
||||
const formData = await request.formData();
|
||||
input = {};
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (key === 'images' && typeof value !== 'string') {
|
||||
uploadedImages.push(value);
|
||||
} else if (typeof value === 'string' && !(key in input)) {
|
||||
input[key] = value;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
input = await request.json();
|
||||
}
|
||||
} catch (error) {
|
||||
return json({ error: 'invalid_json' }, 400);
|
||||
return json({ error: contentType.toLowerCase().includes('multipart/form-data') ? 'invalid_multipart' : 'invalid_json' }, 400);
|
||||
}
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) return json({ error: 'invalid_json' }, 400);
|
||||
|
||||
@@ -321,6 +336,8 @@ async function createFeedback(request, env) {
|
||||
if (!requesterId || !requesterTenantId) return json({ error: 'requester_identity_missing' }, 422);
|
||||
const requesterPhone = String(user.phone || '');
|
||||
if (!requesterPhone) return json({ error: 'requester_phone_missing' }, 422);
|
||||
const oversizedImage = uploadedImages.find((file) => Number(file.size || 0) > 30 * 1024 * 1024);
|
||||
if (oversizedImage) return json({ error: 'attachment_too_large', message: '첨부파일은 파일당 30MB 이하만 등록할 수 있습니다.' }, 413);
|
||||
|
||||
const upstreamPayload = {
|
||||
title,
|
||||
@@ -340,20 +357,39 @@ async function createFeedback(request, env) {
|
||||
const endpoint = `${apiBaseUrl}/api/projects/${encodeURIComponent(env.ABC_PROJECT_ID)}/channels/${encodeURIComponent(env.ABC_CHANNEL_ID)}/feedbacks`;
|
||||
const sourceNamespace = env.ABC_SOURCE_NAMESPACE || 'EGBIM_QA';
|
||||
const idempotencyConsumer = env.ABC_IDEMPOTENCY_CONSUMER || sourceNamespace;
|
||||
const upstreamHeaders = {
|
||||
accept: 'application/json',
|
||||
'x-api-key': env.ABC_API_KEY,
|
||||
'x-source-namespace': sourceNamespace,
|
||||
'x-source-record-id': feedbackId,
|
||||
'x-idempotency-consumer': idempotencyConsumer,
|
||||
'idempotency-key': feedbackId
|
||||
};
|
||||
let upstreamBody;
|
||||
if (uploadedImages.length) {
|
||||
const multipart = new FormData();
|
||||
multipart.append('title', title);
|
||||
multipart.append('contents', contents);
|
||||
multipart.append('Category', category);
|
||||
multipart.append('requester_id', requesterId);
|
||||
multipart.append('requester_tenant_id', requesterTenantId);
|
||||
multipart.append('requester_email', String(user.email || ''));
|
||||
multipart.append('requester_name', String(user.name || ''));
|
||||
multipart.append('requester_department', String(user.department || ''));
|
||||
multipart.append('requester_phone_number', requesterPhone);
|
||||
multipart.append('is_secret', upstreamPayload.is_secret);
|
||||
uploadedImages.forEach((file) => multipart.append('images', file, file.name || 'attachment'));
|
||||
upstreamBody = multipart;
|
||||
} else {
|
||||
upstreamHeaders['content-type'] = 'application/json';
|
||||
upstreamBody = JSON.stringify(upstreamPayload);
|
||||
}
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': env.ABC_API_KEY,
|
||||
'x-source-namespace': sourceNamespace,
|
||||
'x-source-record-id': feedbackId,
|
||||
'x-idempotency-consumer': idempotencyConsumer,
|
||||
'idempotency-key': feedbackId
|
||||
},
|
||||
body: JSON.stringify(upstreamPayload)
|
||||
headers: upstreamHeaders,
|
||||
body: upstreamBody
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('feedback_api_request_failed', error instanceof Error ? error.message : 'unknown');
|
||||
@@ -371,6 +407,76 @@ async function createFeedback(request, env) {
|
||||
return json({ id });
|
||||
}
|
||||
|
||||
async function feedbackComments(request, env, url) {
|
||||
if (request.method !== 'GET' && request.method !== 'POST') return json({ error: 'method_not_allowed' }, 405, { allow: 'GET, POST' });
|
||||
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 apiBaseUrl = String(env.ABC_API_BASE_URL || 'https://feedback.hmac.kr').replace(/\/$/, '');
|
||||
let endpoint = `${apiBaseUrl}/api/projects/${encodeURIComponent(env.ABC_PROJECT_ID)}/channels/${encodeURIComponent(env.ABC_CHANNEL_ID)}/feedbacks/${encodeURIComponent(feedbackId)}/comments`;
|
||||
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 sourceNamespace = env.ABC_SOURCE_NAMESPACE || 'EGBIM_QA';
|
||||
const headers = {
|
||||
accept: 'application/json',
|
||||
'x-api-key': env.ABC_API_KEY,
|
||||
'x-requester-id': requesterId,
|
||||
'x-requester-tenant-id': requesterTenantId
|
||||
};
|
||||
let body;
|
||||
let commentId;
|
||||
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 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);
|
||||
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({
|
||||
author_id: requesterId,
|
||||
author_tenant_id: requesterTenantId,
|
||||
author_name: String(user.name || user.email || ''),
|
||||
content,
|
||||
is_internal: false,
|
||||
actor_type: 'USER',
|
||||
comment_type: 'COMMENT'
|
||||
});
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(endpoint, { method: request.method, headers, body });
|
||||
} catch (error) {
|
||||
console.error('feedback_comments_api_request_failed', error instanceof Error ? error.message : 'unknown');
|
||||
return json({ error: 'feedback_comments_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_comments_api_rejected', message: upstreamMessage || '댓글 저장 또는 조회에 실패했습니다.', status: response.status }, response.status >= 500 ? 502 : response.status);
|
||||
}
|
||||
if (request.method === 'GET') {
|
||||
const comments = Array.isArray(result) ? result : (result.items || result.comments || result.data?.items || result.data?.comments || result.data || []);
|
||||
return json({ comments: Array.isArray(comments) ? comments : [] });
|
||||
}
|
||||
return json({ id: result.id || result.data?.id || result.comment?.id || commentId, comment: result.comment || 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user