Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
162 lines
16 KiB
JavaScript
162 lines
16 KiB
JavaScript
'use strict';
|
|
// ============================================================================
|
|
// 사용자 제안·오류 보고 (v1.1.2)
|
|
// 사용자가 작업 중 개선 제안이나 오류를 올리면 저장하고, 서버가 고급 모델로 자동 분석(무엇인지·필요 작업량·중요도)해
|
|
// 그 글의 댓글로 붙인다. 분석에 든 비용은 사용자에게 청구하지 않고 글에 기록해 관리자가 본다.
|
|
// 저장: data/feedback.json { items: [{ id, userId, userName, siteId, siteTitle, type, title, body, page, env, at, status, comments: [], analysis, cost,
|
|
// planned, planNote, plannedAt, plannedBy, collectedAt, collectedBy }] }
|
|
// env : 신고 당시 브라우저·화면·언어·시간대·서비스 버전 등 (v1.1.6, 사용자에게 사전 고지)
|
|
// planned : 관리자가 AI 분석을 보고 "다음 작업에 반영" 으로 결정했는지 (v1.1.6)
|
|
// planNote : 반영할 내용 — 관리자가 구체적으로 적은 작업 지시. 작업 서버가 수집해 다음 릴리스 작업 목록으로 쓴다
|
|
// collectedAt/By : 작업 서버가 마지막으로 가져간 시각/주체
|
|
// type: inquiry(문의, v1.3.2) | suggestion(제안) | bug(오류) | request(요구) status: new | reviewing | done | rejected
|
|
// ============================================================================
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
|
|
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
|
|
const FILE = path.join(DATA_ROOT, 'feedback.json');
|
|
const TYPES = ['inquiry', 'suggestion', 'bug', 'request'];
|
|
const TYPE_LABEL = { inquiry: '문의', suggestion: '제안', bug: '오류', request: '요구' };
|
|
// v1.2.0 상태 흐름: new(접수) → reviewing(AI 분석 완료, 검토 중) → done(관리자 답변 = 처리 완료). 반려는 없다.
|
|
const STATUS = ['new', 'reviewing', 'done'];
|
|
const normStatus = (st) => (st === 'rejected' ? 'done' : (STATUS.includes(st) ? st : 'new')); // 예전 '반려' 는 처리 완료로 읽는다
|
|
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
|
|
const clip = (v, n) => String(v == null ? '' : v).trim().slice(0, n);
|
|
|
|
/**
|
|
* 저장 파일 읽기.
|
|
* v1.2.0 이전에 관리자 답변이 달린 글은 상태가 "검토 중" 으로 남아 있다(당시에는 답변해도 상태가 바뀌지 않았음).
|
|
* 새 규칙(관리자 답변 = 처리 완료)에 맞춰, 답변 시각(answeredAt)이 없는 옛 글만 읽을 때 처리 완료로 맞춘다.
|
|
* answeredAt 이 있는 글은 1.2.0 이후 답변된 것이므로 관리자가 바꾼 상태를 그대로 둔다.
|
|
*/
|
|
function load() {
|
|
let d; try { d = JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (_) { return { items: [] }; }
|
|
for (const x of d.items || []) {
|
|
x.status = normStatus(x.status);
|
|
const adm = (x.comments || []).filter((c) => c.by === 'admin');
|
|
if (adm.length && !x.answeredAt) { x.status = 'done'; x.answeredAt = adm[adm.length - 1].at; }
|
|
}
|
|
return d;
|
|
}
|
|
function save(d) { fs.mkdirSync(DATA_ROOT, { recursive: true }); const tmp = FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(d, null, 1)); fs.renameSync(tmp, FILE); }
|
|
|
|
/** 새 글 등록 */
|
|
/** 신고 환경 정보 정리: 평평한 키/값만, 키 40개·값 300자 제한 (임의 데이터가 커지지 않게) */
|
|
function cleanEnv(env) {
|
|
if (!env || typeof env !== 'object') return null; const out = {}; let n = 0;
|
|
for (const [k, v] of Object.entries(env)) { if (n >= 40) break; if (!/^[\w.-]{1,40}$/.test(k)) continue; if (v === null || v === undefined) continue; out[k] = typeof v === 'number' || typeof v === 'boolean' ? v : clip(v, 300); n += 1; }
|
|
return n ? out : null;
|
|
}
|
|
function add({ userId, userName, siteId, siteTitle, type, title, body, page, env }) {
|
|
const t = TYPES.includes(type) ? type : 'suggestion'; title = clip(title, 120); body = clip(body, 5000);
|
|
if (!title && !body) throw httpError('내용을 입력하세요');
|
|
const d = load();
|
|
const item = { id: crypto.randomBytes(6).toString('hex'), userId, userName: clip(userName, 60), siteId: siteId || null, siteTitle: clip(siteTitle, 80), type: t, title: title || body.slice(0, 60), body, page: clip(page, 300), env: cleanEnv(env), at: new Date().toISOString(), status: 'new', comments: [], analysis: null, cost: { usd: 0, krw: 0 } };
|
|
d.items.push(item); if (d.items.length > 5000) d.items.splice(0, d.items.length - 5000); save(d); return item;
|
|
}
|
|
function get(id) { const x = load().items.find((i) => i.id === id); return x ? { ...x, status: normStatus(x.status) } : null; }
|
|
/** 관리자용 전체 목록(최신순), 사용자용은 본인 글만 */
|
|
function list(userId) { return load().items.filter((x) => !userId || x.userId === userId).map((x) => ({ ...x, status: normStatus(x.status) })).sort((a, b) => b.at.localeCompare(a.at)); }
|
|
/** 부분 갱신(상태, 분석 결과, 비용) */
|
|
function update(id, patch) { const d = load(); const x = d.items.find((i) => i.id === id); if (!x) throw httpError('글이 없습니다', 404); if (patch.status && STATUS.includes(patch.status)) x.status = patch.status; else x.status = normStatus(x.status); if (patch.analysis !== undefined) x.analysis = patch.analysis; if (patch.cost) x.cost = { usd: (x.cost.usd || 0) + (patch.cost.usd || 0), krw: (x.cost.krw || 0) + (patch.cost.krw || 0) }; x.updatedAt = new Date().toISOString(); save(d); return x; }
|
|
/** 댓글(AI 분석 또는 관리자 답변) 추가. by: 'ai' | 'admin' | 사용자ID */
|
|
/**
|
|
* 댓글 추가. by: 'ai'(분석) | 'admin'(관리자 답변).
|
|
* 관리자 답변이 달리면 상태를 '처리 완료' 로 바꾸고 answeredAt 을 남긴다(사용자 화면에 답변과 함께 표시). AI 분석이 붙으면 새 글 → 검토 중.
|
|
*/
|
|
function comment(id, { by, text, model, usd, krw, byName }) { const d = load(); const x = d.items.find((i) => i.id === id); if (!x) throw httpError('글이 없습니다', 404); x.status = normStatus(x.status); x.comments.push({ id: crypto.randomBytes(4).toString('hex'), at: new Date().toISOString(), by, byName: byName ? clip(byName, 60) : null, text: clip(text, 8000), model: model || null, usd: usd || 0, krw: krw || 0 });
|
|
if (by === 'admin') { x.status = 'done'; x.answeredAt = new Date().toISOString(); } else if (by === 'ai' && x.status === 'new') x.status = 'reviewing'; if (usd) x.cost = { usd: (x.cost.usd || 0) + usd, krw: (x.cost.krw || 0) + (krw || 0) }; x.updatedAt = new Date().toISOString(); save(d); return x; }
|
|
/**
|
|
* 다음 작업 반영 결정 (관리자). planned=false 로 바꾸면 작업 목록에서 빠진다.
|
|
* @param {string} id
|
|
* @param {{planned: boolean, planNote?: string, by: string}} p
|
|
*/
|
|
function setPlan(id, { planned, planNote, by }) {
|
|
const d = load(); const x = d.items.find((i) => i.id === id); if (!x) throw httpError('글이 없습니다', 404);
|
|
const note = clip(planNote, 4000);
|
|
if (planned && !note) throw httpError('반영할 내용을 구체적으로 적어 주세요');
|
|
x.planned = !!planned; x.planNote = note; x.plannedAt = planned ? new Date().toISOString() : null; x.plannedBy = planned ? by : null;
|
|
x.status = normStatus(x.status); if (planned && x.status === 'new') x.status = 'reviewing';
|
|
x.updatedAt = new Date().toISOString(); save(d); return x;
|
|
}
|
|
/** 다음 작업 목록: 반영 결정되었고 아직 처리 완료/반려가 아닌 글 (오래된 순) */
|
|
function backlog() {
|
|
return load().items.filter((x) => x.planned && !x.implementedAt).sort((a, b) => (a.plannedAt || '').localeCompare(b.plannedAt || ''))
|
|
.map((x) => ({ id: x.id, type: x.type, title: x.title, planNote: x.planNote, plannedAt: x.plannedAt, plannedBy: x.plannedBy, priority: x.analysis && x.analysis.priority, effort: x.analysis && x.analysis.effort, category: x.analysis && x.analysis.category, summary: x.analysis && x.analysis.summary, body: x.body, userId: x.userId, siteTitle: x.siteTitle, page: x.page, env: x.env || null, at: x.at, collectedAt: x.collectedAt || null }));
|
|
}
|
|
/** 릴리스에 반영 완료 표시 (작업 서버가 배포 후 호출). 사용자 화면에 "v… 에 반영됨" 으로 보인다 */
|
|
function markImplemented(ids, version, note) { const d = load(); const at = new Date().toISOString(); let n = 0; for (const x of d.items) if (ids.includes(x.id)) { x.implementedAt = at; x.implementedVersion = clip(version, 20); if (note) x.implementedNote = clip(note, 500); n += 1; } if (n) save(d); return n; }
|
|
/** 작업 서버가 가져간 표시 */
|
|
function markCollected(ids, by) { const d = load(); const at = new Date().toISOString(); let n = 0; for (const x of d.items) if (ids.includes(x.id)) { x.collectedAt = at; x.collectedBy = clip(by, 80); n += 1; } if (n) save(d); return n; }
|
|
/** 다음 작업 목록 → 마크다운 (작업 서버의 BACKLOG.md / 관리자 복사용) */
|
|
function backlogMarkdown(items, meta = {}) {
|
|
const T = TYPE_LABEL;
|
|
const lines = [`# 다음 작업 반영 목록${meta.server ? ' — ' + meta.server : ''}`, '', `수집 ${meta.at || new Date().toISOString()} · ${items.length}건 (관리자가 AI 분석 후 "다음 작업에 반영" 으로 결정한 사용자 제안·오류)`, ''];
|
|
items.forEach((x, i) => {
|
|
lines.push(`## ${i + 1}. [${T[x.type] || x.type}] ${x.title}`, '', `- 중요도: ${x.priority || '-'} · 작업량: ${x.effort || '-'} · 분류: ${x.category || '-'}`, `- 결정: ${x.plannedAt || ''} (${x.plannedBy || ''}) · 접수: ${x.at} · 사용자 ${x.userId}${x.siteTitle ? ' · 사이트 ' + x.siteTitle : ''}${x.page ? ' · 화면 ' + x.page : ''}`, '', '**반영할 내용 (관리자)**', '', x.planNote || '', '');
|
|
if (x.summary) lines.push(`AI 요약: ${x.summary}`, '');
|
|
lines.push('<details><summary>원문·환경</summary>', '', x.body || '', '', x.env ? '```\n' + Object.entries(x.env).map(([k, v]) => `${k}: ${v}`).join('\n') + '\n```' : '', '</details>', '');
|
|
});
|
|
return lines.join('\n');
|
|
}
|
|
function counts() { const items = load().items.map((x) => ({ ...x, status: normStatus(x.status) })); return { total: items.length, planned: items.filter((x) => x.planned && !x.implementedAt).length, implemented: items.filter((x) => x.implementedAt).length, new: items.filter((x) => x.status === 'new').length, reviewing: items.filter((x) => x.status === 'reviewing').length, done: items.filter((x) => x.status === 'done').length, costUsd: items.reduce((a, x) => a + ((x.cost && x.cost.usd) || 0), 0), costKrw: items.reduce((a, x) => a + ((x.cost && x.cost.krw) || 0), 0) }; }
|
|
|
|
/**
|
|
* 서비스 안내 요약 (v1.3.2): "문의" 1차 답변 초안을 만들 때 AI 가 참고하는 사용법. 화면이 바뀌면 함께 고친다.
|
|
*/
|
|
// i18n-ignore-start (AI 에게 주는 안내·지시문 — 화면 문구 아님)
|
|
const SERVICE_GUIDE = `AI Webmaker 사용 안내 (요약)
|
|
- 생성 탭: "AI에게 맡기기"(설명만 적으면 AI 가 기획→생성) / "내가 고르기"(업종·페이지·디자인 직접 선택) / 기존 사이트 모양으로 시작("사이트 복사하기": 웹 주소만으로 기본 HTML 페이지와 CSS·이미지를 복사해 새 사이트로 만듦. 로그인·게시판·신청·결제 같은 기능은 복사되지 않으며 필요하면 수정 지시로 빌더 모듈을 붙임. 서버 접속으로 가져오는 "사이트 관리하기" 는 1.3.4 에서 종료).
|
|
- 관리 탭: 수정 지시(모델 등급 기본·중급·고급 선택), 발행·배포(운영 주소·도메인 연결), 버전 되돌리기, 접속 통계, 공통 기능 모듈(회원·게시판·문의·설문·예약·갤러리·지도·후기·FAQ)과 사이트 관리자 페이지, 메일(운영자가 사이트 단위로 켜며 DNS 확인 전에는 "메일시스템 설정중").
|
|
- 캐시: 작업마다 사용한 만큼 차감, 잔액은 화면 위쪽 버전 줄 오른쪽. 소진되면 작업이 멈추고 충전 후 자동으로 이어서 진행. 충전은 관리자(운영팀)에게 요청.
|
|
- AI에게 맡기기는 발행 전 수정 횟수 한도가 있으며 한도에 이르면 발행 후 다시 수정할 수 있음.
|
|
- 언어: 화면 위쪽에서 화면 언어를 바꿀 수 있음(운영자가 올린 언어만).
|
|
- 문의·제안·오류는 설정 탭 "내 문의 내역" 에서 상태(접수→검토 중→처리 완료)와 운영팀 답변을 볼 수 있음.`;
|
|
/**
|
|
* AI 1차 분석 프롬프트 (v1.3.2: 글 종류별로 다르게).
|
|
* - inquiry(문의) : 질문 의도 파악 + 사용자에게 보낼 1차 답변 초안(서비스 안내 기준, 모르면 운영팀 확인 필요로 표시)
|
|
* - bug(오류) : 추정 원인·재현 조건·영향 범위·수정 방향·심각도
|
|
* - suggestion/request : 필요성·구현 방법·작업량·우선순위
|
|
* 공통 JSON 필드(category·summary·priority·effort·suggestion·replyDraft)는 같아서 관리자 화면·다음 작업 목록이 그대로 쓴다.
|
|
*/
|
|
function analysisPrompt(item, context) {
|
|
const kind = TYPE_LABEL[item.type] || '제안';
|
|
const focus = item.type === 'inquiry'
|
|
? `이 글은 사용 "문의" 입니다. 질문의 의도를 파악하고, 아래 서비스 안내를 근거로 사용자에게 보낼 1차 답변 초안을 작성하세요. 안내로 답할 수 없거나 계정·결제·데이터처럼 운영팀 확인이 필요한 내용이면 그 사실을 답변에 밝히고 needsOperator 를 true 로 하세요. 문의 속에 오류 신고나 기능 요청이 섞여 있으면 category 와 suggestion 에 함께 적으세요.\n\n${SERVICE_GUIDE}`
|
|
: item.type === 'bug'
|
|
? '이 글은 "오류" 보고입니다. 사용 환경과 사이트 정보를 근거로 추정 원인과 재현 조건을 정리하고, 영향 범위·심각도·수정 방향을 제시하세요. 사용자가 당장 시도할 수 있는 우회 방법이 있으면 답변 초안에 넣으세요.'
|
|
: `이 글은 "${kind}" 입니다. 필요성과 기대 효과, 구현 방법, 작업량, 기존 기능과의 관계(이미 있는 기능이면 사용 방법 안내)를 분석하세요.`;
|
|
return `다음은 웹사이트 빌더 서비스(AI Webmaker)의 사용자가 올린 "${kind}" 글입니다. 운영자가 처리 방향과 우선순위를 정할 수 있게 1차 분석을 해 JSON 으로만 답하세요.
|
|
${focus}
|
|
|
|
[종류] ${kind}
|
|
[제목] ${item.title}
|
|
[내용]
|
|
${item.body}
|
|
[작성 화면] ${item.page || '-'}
|
|
[사용 환경] ${item.env ? Object.entries(item.env).map(([k, v]) => `${k}=${v}`).join(', ') : '-'}
|
|
[관련 사이트] ${item.siteTitle || '-'}
|
|
${context || ''}
|
|
출력 JSON:
|
|
{"category": "사용 문의|요금·캐시|계정|버그|UI/UX|기능 추가|성능|문구/번역|배포/운영|메일|기타",
|
|
"summary": "무엇에 대한 글인지 2문장 이내",
|
|
"cause": "오류라면 추정 원인, 제안·요구라면 배경, 문의라면 질문의 핵심 (1~2문장)",
|
|
"reproduce": "오류일 때 재현 조건 (아니면 빈 문자열)",
|
|
"scope": "영향 범위: 어느 화면/기능과 관련되는지",
|
|
"effort": "없음|소|중|대", "effortNote": "필요한 작업 설명과 대략의 소요 (문의로 답변만 하면 되는 경우 '답변만 필요')",
|
|
"priority": "높음|중간|낮음", "priorityReason": "중요도 판단 이유",
|
|
"suggestion": "운영자에게 권장하는 처리 방안 (1~3문장)",
|
|
"replyDraft": "사용자에게 보낼 1차 답변 초안 (존댓말, 3~6문장, 사용자가 쓴 언어로)",
|
|
"needsOperator": true,
|
|
"questions": ["사용자에게 더 확인할 점이 있으면 0~2개"]}`;
|
|
}
|
|
// i18n-ignore-end
|
|
/** 분석 결과를 댓글 문장으로 */
|
|
function analysisText(a) {
|
|
return `[AI 1차 분석] 분류: ${a.category || '-'} · 중요도: ${a.priority || '-'} · 작업량: ${a.effort || '-'}${a.needsOperator ? ' · 운영팀 확인 필요' : ''}\n요약: ${a.summary || ''}\n${a.cause ? '원인/배경: ' + a.cause + '\n' : ''}${a.reproduce ? '재현 조건: ' + a.reproduce + '\n' : ''}영향 범위: ${a.scope || '-'}\n필요 작업: ${a.effortNote || '-'}\n중요도 근거: ${a.priorityReason || '-'}\n권장 처리: ${a.suggestion || '-'}${a.questions && a.questions.length ? '\n확인 필요: ' + a.questions.join(' / ') : ''}${a.replyDraft ? '\n\n[답변 초안]\n' + a.replyDraft : ''}`;
|
|
}
|
|
|
|
module.exports = { TYPE_LABEL, SERVICE_GUIDE, TYPES, STATUS, normStatus, add, get, list, update, comment, counts, setPlan, backlog, markCollected, markImplemented, backlogMarkdown, analysisPrompt, analysisText, cleanEnv };
|