From 819bc9d789fed500ba1cfe5a08d60da55c48e727 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 00:54:02 +0900 Subject: [PATCH] fix(support): stop faking refund approvals and ticket submissions in the support modal --- .../support/SupportContactPanel.tsx | 175 ++++++++ .../components/support/SupportFaqPanel.tsx | 61 +++ .../components/support/SupportModal.tsx | 385 ++---------------- .../components/support/support-contact.ts | 115 ++++++ .../components/support/support-copy.ts | 60 +++ .../support-contact-redteam-r1-27.test.ts | 139 +++++++ 6 files changed, 580 insertions(+), 355 deletions(-) create mode 100644 apps/desktop/src/renderer/components/support/SupportContactPanel.tsx create mode 100644 apps/desktop/src/renderer/components/support/SupportFaqPanel.tsx create mode 100644 apps/desktop/src/renderer/components/support/support-contact.ts create mode 100644 apps/desktop/src/renderer/components/support/support-copy.ts create mode 100644 apps/desktop/tests/unit/support-contact-redteam-r1-27.test.ts diff --git a/apps/desktop/src/renderer/components/support/SupportContactPanel.tsx b/apps/desktop/src/renderer/components/support/SupportContactPanel.tsx new file mode 100644 index 0000000..aabd7ea --- /dev/null +++ b/apps/desktop/src/renderer/components/support/SupportContactPanel.tsx @@ -0,0 +1,175 @@ +// src/renderer/components/support/SupportContactPanel.tsx +// 지원 모달 — 이메일 문의 패널. +// 서버 쪽 티켓 시스템이 없으므로 mailto 초안만 연다. 전송 여부는 앱이 알 수 없어서 +// "접수됐다"는 문구·티켓 번호를 만들지 않는다. + +import React, { useEffect, useState } from 'react' +import { Box, Button, MenuItem, TextField, Typography } from '@mui/material' +import { Activity, Mail } from 'lucide-react' +import { d3roPalette, d3roFontSans, d3roFontMono } from '@d3ro/ui/theme' +import { + SUPPORT_CATEGORIES, + SUPPORT_EMAIL, + buildSupportMailto, + formatSupportDiagnostics, + isSupportCategory, + validateSupportDraft, + type SupportChannel, + type SupportDiagnostics, + type SupportDraft, + type SupportDraftField, +} from './support-contact' +import { SUPPORT_CATEGORY_LABELS, SUPPORT_COPY } from './support-copy' + +interface SupportContactPanelProps { + channel: SupportChannel +} + +type OpenStatus = 'idle' | 'opening' | 'opened' | 'failed' + +const EMPTY_DRAFT: SupportDraft = { category: 'general', subject: '', details: '' } +const UNKNOWN_DIAGNOSTICS: SupportDiagnostics = { appVersion: null, platform: null } +const FIELD_SX = { '& .MuiInputBase-root': { fontSize: '12px' } } as const + +export function SupportContactPanel({ channel }: SupportContactPanelProps): React.ReactElement { + const [draft, setDraft] = useState(EMPTY_DRAFT) + const [missing, setMissing] = useState([]) + const [diagnostics, setDiagnostics] = useState(UNKNOWN_DIAGNOSTICS) + const [status, setStatus] = useState('idle') + + useEffect(() => { + let cancelled = false + void channel.getDiagnostics().then((diag) => { + if (!cancelled) setDiagnostics(diag) + }) + return () => { + cancelled = true + } + }, [channel]) + + const updateDraft = (patch: Partial): void => { + setDraft((prev) => ({ ...prev, ...patch })) + setStatus('idle') + } + + const handleSubmit = async (e: React.FormEvent): Promise => { + e.preventDefault() + const validation = validateSupportDraft(draft) + if (!validation.ok) { + setMissing(validation.missing) + return + } + setMissing([]) + setStatus('opening') + const opened = await channel.openMail(buildSupportMailto(draft, diagnostics)) + setStatus(opened ? 'opened' : 'failed') + } + + const subjectMissing = missing.includes('subject') + const detailsMissing = missing.includes('details') + + return ( + +
void handleSubmit(e)} noValidate style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}> + + {SUPPORT_COPY.contactIntro} + + + { + if (isSupportCategory(e.target.value)) updateDraft({ category: e.target.value }) + }} + sx={FIELD_SX} + > + {SUPPORT_CATEGORIES.map((category) => ( + + {SUPPORT_CATEGORY_LABELS[category]} + + ))} + + updateDraft({ subject: e.target.value })} + required + error={subjectMissing} + helperText={subjectMissing ? SUPPORT_COPY.requiredError : undefined} + sx={FIELD_SX} + /> + updateDraft({ details: e.target.value })} + required + error={detailsMissing} + helperText={detailsMissing ? SUPPORT_COPY.requiredError : undefined} + sx={FIELD_SX} + /> + + + + + + {SUPPORT_COPY.diagnosticsLabel} + + + + {formatSupportDiagnostics(diagnostics)} + + + + + + {status === 'opened' && ( + + {SUPPORT_COPY.openedNotice} + + )} + {status === 'failed' && ( + + {SUPPORT_COPY.openFailed} + + )} + + {SUPPORT_COPY.directAddress}: {SUPPORT_EMAIL} + + +
+ ) +} diff --git a/apps/desktop/src/renderer/components/support/SupportFaqPanel.tsx b/apps/desktop/src/renderer/components/support/SupportFaqPanel.tsx new file mode 100644 index 0000000..986bb71 --- /dev/null +++ b/apps/desktop/src/renderer/components/support/SupportFaqPanel.tsx @@ -0,0 +1,61 @@ +// src/renderer/components/support/SupportFaqPanel.tsx +// 지원 모달 — FAQ 검색 패널 (정적 안내만, IO 없음) + +import React, { useMemo, useState } from 'react' +import { Box, TextField, Typography } from '@mui/material' +import { Search } from 'lucide-react' +import { d3roPalette, d3roFontSans, d3roRadius } from '@d3ro/ui/theme' +import { SUPPORT_COPY, SUPPORT_FAQS } from './support-copy' + +export function SupportFaqPanel(): React.ReactElement { + const [searchQuery, setSearchQuery] = useState('') + + const filteredFaqs = useMemo(() => { + const query = searchQuery.trim().toLowerCase() + if (!query) return SUPPORT_FAQS + return SUPPORT_FAQS.filter((f) => f.q.toLowerCase().includes(query) || f.a.toLowerCase().includes(query)) + }, [searchQuery]) + + return ( + + setSearchQuery(e.target.value)} + InputProps={{ + startAdornment: , + }} + sx={{ + '& .MuiInputBase-root': { + bgcolor: 'var(--d3-bg-inset-soft)', + color: 'var(--d3-text-inverse)', + fontSize: '12px', + borderRadius: '10px', + }, + }} + /> + + + {filteredFaqs.length === 0 && ( + + {SUPPORT_COPY.faqEmpty} + + )} + {filteredFaqs.map((faq) => ( + + + Q. {faq.q} + + + {faq.a} + + + ))} + + + ) +} diff --git a/apps/desktop/src/renderer/components/support/SupportModal.tsx b/apps/desktop/src/renderer/components/support/SupportModal.tsx index bcbabb2..182d469 100644 --- a/apps/desktop/src/renderer/components/support/SupportModal.tsx +++ b/apps/desktop/src/renderer/components/support/SupportModal.tsx @@ -1,109 +1,34 @@ // src/renderer/components/support/SupportModal.tsx -// In-App Customer Assistance (CA/CS) & Diagnostics Modal +// 고객지원 모달 — FAQ + 이메일 문의. +// +// 환불 자격 조회·결제 취소·티켓 접수 백엔드가 없으므로 그런 기능을 흉내 내지 않는다. +// 문의는 사용자의 메일 앱(mailto:)으로만 넘기고, 서버가 확인하지 않은 성공은 표시하지 않는다. -import React, { useState } from 'react' -import { - Modal, - Box, - Typography, - Button, - IconButton, - Tabs, - Tab, - TextField, - MenuItem, - CircularProgress, -} from '@mui/material' -import { - Headphones, - Bot, - Activity, - Ticket, - RefreshCw, - Search, - X, - Send, - CheckCircle, - HelpCircle, -} from 'lucide-react' -import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow } from '@d3ro/ui/theme' +import React, { useMemo, useState } from 'react' +import { Modal, Box, Typography, IconButton, Tabs, Tab } from '@mui/material' +import { Headphones, HelpCircle, Mail, X } from 'lucide-react' +import { d3roPalette, d3roFontSans, d3roRadius, d3roShadow } from '@d3ro/ui/theme' +import { useI18n } from '@d3ro/i18n' +import { createElectronSupportChannel, type SupportChannel } from './support-contact' +import { SUPPORT_COPY } from './support-copy' +import { SupportFaqPanel } from './SupportFaqPanel' +import { SupportContactPanel } from './SupportContactPanel' interface SupportModalProps { open: boolean onClose: () => void + /** 테스트·다른 셸에서 IO를 바꿔 끼우는 포트. 기본은 Electron preload. */ + channel?: SupportChannel } -const FAQS = [ - { - q: '마이크 음성 인식이 작동하지 않거나 소리가 입력되지 않습니다.', - a: '1. Windows 설정 > 개인 정보 및 보안 > 마이크에서 "데스크톱 앱이 마이크에 액세스하도록 허용"이 켜져 있는지 확인하세요.\n2. D3RO Voice 설정 > STT 탭에서 사용 중인 실제 입력 마이크 장치(Realtek 또는 USB 인터페이스)가 선택되었는지 확인합니다.\n3. 시스템 진단 탭에서 텔레메트리 로그를 확인하세요.', - }, - { - q: 'NVIDIA CUDA GPU 가속을 어떻게 활성화하나요?', - a: 'NVIDIA GTX 1060 / RTX 2060 이상 그래픽 카드가 장착되어 있다면 faster-whisper CTranslate2 엔진이 자동으로 CUDA v12 런타임을 감지하여 GPU 모드로 동작합니다. VRAM 4GB 이상 시 large-v3-turbo 모델에서 130ms 초저지연을 지원합니다.', - }, - { - q: '7일 이내 환불 규정 및 자동 환불 신청 방법은 어떻게 되나요?', - a: '결제일로부터 7일 이내 및 클라우드 AI 토큰 사용량이 10% 미만인 경우 무조건 100% 전액 환불이 가능합니다. 이 모달의 "7일 자동 환불 확인" 탭에서 즉시 자격을 조회하고 취소할 수 있습니다.', - }, - { - q: '법인 전자세금계산서 발행은 어떻게 신청하나요?', - a: '토스페이먼츠 또는 포트원 결제 시 사업자등록번호를 기재하시면 결제 즉시 국세청 홈택스로 전자세금계산서가 자동 발행됩니다. 사업자 정보 변경이 필요하신 경우 티켓을 접수해 주세요.', - }, -] +type SupportTab = 'faq' | 'contact' -export function SupportModal({ open, onClose }: SupportModalProps): React.ReactElement { - const [tabIndex, setTabIndex] = useState(0) - const [searchQuery, setSearchQuery] = useState('') - const [chatMessages, setChatMessages] = useState>([ - { - role: 'ai', - text: '안녕하세요! D3RO Voice 고객지원 AI 어시스턴트입니다. 마이크 설정, 모델 구동, 결제/환불 등 무엇이든 질문해 주세요.', - }, - ]) - const [inputQuestion, setInputQuestion] = useState('') - const [isAiThinking, setIsAiThinking] = useState(false) - const [ticketSubmitted, setTicketSubmitted] = useState(null) - const [refundStatus, setRefundStatus] = useState(null) - - const handleAskAI = (questionText?: string) => { - const q = questionText ?? inputQuestion - if (!q.trim()) return - - setChatMessages((prev) => [...prev, { role: 'user', text: q }]) - if (!questionText) setInputQuestion('') - setIsAiThinking(true) - - setTimeout(() => { - let answer = '문의해 주셔서 감사합니다. 상세한 진단 분석을 위해 시스템 텔레메트리 로그를 수집했습니다. 추가 도움이 필요하시면 [티켓 접수] 탭을 통해 기술 지원팀 엔지니어에게 바로 연결해 드릴 수 있습니다.' - if (q.includes('마이크') || q.includes('소리') || q.includes('인식')) { - answer = '마이크 입력 진단 결과:\n1. Windows 마이크 개인정보 권한이 활성화되어 있는지 확인해 주세요.\n2. D3RO 설정 > STT에서 올바른 입력 마이크를 선택했는지 점검하세요.\n3. 노이즈 억제 필터를 기본값으로 재설정해보시기 바랍니다.' - } else if (q.includes('환불') || q.includes('취소')) { - answer = '환불 규정 안내: 결제 후 7일 이내, 클라우드 사용량 10% 미만일 경우 100% 자동 환불이 가능합니다. 상단 "7일 자동 환불" 탭에서 원클릭으로 검증 후 즉시 취소할 수 있습니다.' - } else if (q.includes('gpu') || q.includes('cuda') || q.includes('속도')) { - answer = 'CUDA 가속 안내: faster-whisper CTranslate2 엔진이 로컬 GPU를 사용하여 대기시간을 140ms 이하로 단축합니다. VRAM 사용량이 4GB 이상 확보되었는지 확인하세요.' - } - - setChatMessages((prev) => [...prev, { role: 'ai', text: answer }]) - setIsAiThinking(false) - }, 400) - } - - const handleRefundCheck = () => { - setRefundStatus('checking') - setTimeout(() => { - setRefundStatus('eligible') - }, 800) - } - - const handleTicketSubmit = (e: React.FormEvent) => { - e.preventDefault() - const tckId = 'TCK-' + Math.floor(1000 + Math.random() * 9000) - setTicketSubmitted(tckId) - } - - const filteredFaqs = FAQS.filter( - (f) => f.q.toLowerCase().includes(searchQuery.toLowerCase()) || f.a.toLowerCase().includes(searchQuery.toLowerCase()) +export function SupportModal({ open, onClose, channel }: SupportModalProps): React.ReactElement { + const { t } = useI18n() + const [tab, setTab] = useState('faq') + const supportChannel = useMemo( + () => channel ?? createElectronSupportChannel(window.electronAPI.system), + [channel], ) return ( @@ -134,13 +59,13 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE - D3RO Voice Customer Assistance (CA/CS) + {SUPPORT_COPY.title} @@ -149,8 +74,8 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE {/* Navigation Tabs */} setTabIndex(v)} + value={tab} + onChange={(_, v: SupportTab) => setTab(v)} sx={{ minHeight: 'auto', borderBottom: `1px solid ${d3roPalette.glass.hairline}`, @@ -168,262 +93,12 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE '& .MuiTabs-indicator': { bgcolor: d3roPalette.tag.purple }, }} > - } iconPosition="start" label="AI 어시스턴트" /> - } iconPosition="start" label="자주 묻는 질문 (FAQ)" /> - } iconPosition="start" label="티켓 접수 & 진단" /> - } iconPosition="start" label="7일 자동 환불 확인" /> + } iconPosition="start" label={SUPPORT_COPY.tabFaq} /> + } iconPosition="start" label={SUPPORT_COPY.tabContact} /> - {/* TAB 0: AI BOT CHAT */} - {tabIndex === 0 && ( - - - {chatMessages.map((msg, idx) => ( - - {msg.text} - - ))} - {isAiThinking && ( - - AI 어시스턴트가 답변을 생성하고 있습니다... - - )} - - - {/* Quick Prompts */} - - - - - - - {/* Input Bar */} - - setInputQuestion(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleAskAI()} - sx={{ - '& .MuiInputBase-root': { - bgcolor: 'var(--d3-bg-inset-soft)', - color: 'var(--d3-text-inverse)', - fontSize: '12px', - fontFamily: d3roFontSans, - borderRadius: '10px', - }, - }} - /> - - - - )} - - {/* TAB 1: FAQS */} - {tabIndex === 1 && ( - - setSearchQuery(e.target.value)} - InputProps={{ - startAdornment: , - }} - sx={{ - '& .MuiInputBase-root': { - bgcolor: 'var(--d3-bg-inset-soft)', - color: 'var(--d3-text-inverse)', - fontSize: '12px', - borderRadius: '10px', - }, - }} - /> - - - {filteredFaqs.map((faq, idx) => ( - - - Q. {faq.q} - - - {faq.a} - - - ))} - - - )} - - {/* TAB 2: TICKET SUBMISSION */} - {tabIndex === 2 && ( - - {ticketSubmitted ? ( - - - - 티켓이 성공적으로 접수되었습니다! - - - Ticket ID: {ticketSubmitted} - - - 시스템 진단 로그와 함께 기술 지원팀에 전달되었습니다. 30분 이내에 이메일로 답변을 전송해 드립니다. - - - - ) : ( -
- - - 마이크 / 오디오 하드웨어 - CUDA GPU / 모델 로딩 오류 - 결제 / 세금계산서 / 환불 - 기타 기술 문의 - - - 긴급 (SLA: 30분) - 높음 (SLA: 2시간) - 보통 (SLA: 24시간) - - - - - - - - - - - 로컬 시스템 진단 텔레메트리 자동 첨부됨 - - - - Windows 11 • RTX 3080 • faster-whisper - - - - - - )} -
- )} - - {/* TAB 3: 7-DAY REFUND CHECKER */} - {tabIndex === 3 && ( - - - - 7일 이내 무조건 100% 자동 환불 확인 - - - D3RO Voice는 결제일로부터 7일 이내 및 클라우드 AI 정제 토큰 사용량이 10% 미만인 경우 상담원 연결 없이 원클릭으로 카드 승인 취소 및 전액 환불을 보장합니다. - - - {refundStatus === 'eligible' && ( - - - ✓ 100% 전액 환불 대상입니다! - - - • 결제일: 최근 2일 전 (자격 충족) - - - • 클라우드 사용량: 0% (자격 충족) - - - - )} - - - {!refundStatus && ( - - )} - - )} + {tab === 'faq' && } + {tab === 'contact' && } ) diff --git a/apps/desktop/src/renderer/components/support/support-contact.ts b/apps/desktop/src/renderer/components/support/support-contact.ts new file mode 100644 index 0000000..5ef6977 --- /dev/null +++ b/apps/desktop/src/renderer/components/support/support-contact.ts @@ -0,0 +1,115 @@ +// src/renderer/components/support/support-contact.ts +// 고객지원 문의 정책(순수 함수)과 IO 포트. +// +// 앱에는 환불 자격 조회·결제 취소·티켓 접수 백엔드가 없다. 그래서 지원 모달은 "처리됐다"고 +// 주장하지 않고, 사용자가 직접 보내는 이메일(mailto:)로만 문의를 넘긴다. 서버가 확인한 성공이 +// 없으므로 성공 문구·티켓 번호·환불 처리 결과는 만들지 않는다. + +import type { IPCResult } from '@d3ro/core/errors' + +/** 지원 문의 수신 주소. 사이트의 개인정보·약관 문의 주소와 같다 (site/public/terms). */ +export const SUPPORT_EMAIL = 'yunchan8804@gmail.com' + +export const SUPPORT_CATEGORIES = ['audio', 'gpu', 'billing', 'general'] as const +export type SupportCategory = (typeof SUPPORT_CATEGORIES)[number] + +export interface SupportDraft { + category: SupportCategory + subject: string + details: string +} + +export interface SupportDiagnostics { + appVersion: string | null + platform: string | null +} + +export type SupportDraftField = 'subject' | 'details' + +export type SupportDraftValidation = + | { ok: true } + | { ok: false; missing: SupportDraftField[] } + +/** Windows 셸이 mailto 인자를 잘라내지 않도록 본문 길이를 제한한다. */ +export const MAX_MAILTO_BODY_CHARS = 1500 + +export function isSupportCategory(value: string): value is SupportCategory { + return (SUPPORT_CATEGORIES as readonly string[]).includes(value) +} + +export function validateSupportDraft(draft: SupportDraft): SupportDraftValidation { + const missing: SupportDraftField[] = [] + if (!draft.subject.trim()) missing.push('subject') + if (!draft.details.trim()) missing.push('details') + return missing.length === 0 ? { ok: true } : { ok: false, missing } +} + +export function formatSupportDiagnostics(diag: SupportDiagnostics): string { + const version = diag.appVersion?.trim() || 'unknown' + const platform = diag.platform?.trim() || 'unknown' + return `D3RO Voice ${version} • ${platform}` +} + +/** + * 문의 초안 → mailto: URL. 수신·전송은 사용자의 메일 앱이 한다. + * 본문 라벨은 지원 담당자가 읽는 기술 정보라 영어로 고정한다. + */ +export function buildSupportMailto(draft: SupportDraft, diag: SupportDiagnostics): string { + const subject = `[D3RO Voice][${draft.category}] ${draft.subject.trim()}` + const details = draft.details.trim() + const trimmedDetails = + details.length > MAX_MAILTO_BODY_CHARS ? `${details.slice(0, MAX_MAILTO_BODY_CHARS)}…` : details + const body = [ + trimmedDetails, + '', + '---', + `Category: ${draft.category}`, + `Diagnostics: ${formatSupportDiagnostics(diag)}`, + ].join('\n') + return `mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}` +} + +// ── 포트 ──────────────────────────────────────────────── + +/** 지원 모달이 의존하는 IO 경계. UI는 이 인터페이스만 안다. */ +export interface SupportChannel { + /** 메일 앱 열기를 시도한다. 메인 프로세스가 성공을 돌려줄 때만 true. */ + openMail(url: string): Promise + getDiagnostics(): Promise +} + +/** preload `electronAPI.system` 중 이 기능이 쓰는 최소 표면. */ +export interface SupportSystemApi { + openExternal(params: { url: string }): Promise> + getVersion(): Promise> + getPlatform(): Promise> +} + +async function readResult(call: () => Promise>): Promise { + try { + const result = await call() + return result.success ? result.data : null + } catch { + return null + } +} + +export function createElectronSupportChannel(api: SupportSystemApi): SupportChannel { + return { + async openMail(url) { + try { + const result = await api.openExternal({ url }) + return result.success + } catch { + return false + } + }, + async getDiagnostics() { + const [appVersion, platform] = await Promise.all([ + readResult(() => api.getVersion()), + readResult(() => api.getPlatform()), + ]) + return { appVersion, platform } + }, + } +} diff --git a/apps/desktop/src/renderer/components/support/support-copy.ts b/apps/desktop/src/renderer/components/support/support-copy.ts new file mode 100644 index 0000000..dac98b6 --- /dev/null +++ b/apps/desktop/src/renderer/components/support/support-copy.ts @@ -0,0 +1,60 @@ +// src/renderer/components/support/support-copy.ts +// 지원 모달 문구 모음. +// +// TODO(i18n): 공용 로케일(packages/i18n/src/locales/*.json)에 `support.*` 키가 아직 없다. +// 문구를 이 한 파일에 모아 두었으니, 키를 추가할 때 이 객체를 t() 호출로 옮기면 된다. +// 결제·환불·티켓 처리를 약속하는 문구는 넣지 않는다 (백엔드가 없다). + +import type { SupportCategory } from './support-contact' + +export interface SupportFaq { + q: string + a: string +} + +export const SUPPORT_FAQS: readonly SupportFaq[] = [ + { + q: '마이크 음성 인식이 작동하지 않거나 소리가 입력되지 않습니다.', + a: '1. Windows 설정 > 개인 정보 및 보안 > 마이크에서 "데스크톱 앱이 마이크에 액세스하도록 허용"이 켜져 있는지 확인하세요.\n2. D3RO Voice 설정 > STT 탭에서 실제로 쓰는 입력 마이크 장치가 선택되었는지 확인하세요.\n3. 그래도 해결되지 않으면 "문의하기" 탭에서 이메일로 알려 주세요.', + }, + { + q: 'NVIDIA CUDA GPU 가속을 어떻게 활성화하나요?', + a: 'NVIDIA 그래픽 카드와 CUDA 런타임이 준비되어 있으면 faster-whisper(CTranslate2) 엔진이 GPU를 감지해 GPU 모드로 동작합니다. 큰 모델일수록 VRAM이 더 필요합니다.', + }, + { + q: '환불은 어떻게 요청하나요?', + a: '환불과 결제 취소는 구매 화면에 안내된 조건과 해당 결제 처리자(Google Play 등)의 정책을 따릅니다. 앱 안에서 자동으로 환불되지는 않습니다. "문의하기" 탭에서 "결제 / 환불" 분류로 이메일을 보내 주시면 확인 후 답변드립니다.', + }, + { + q: '법인 전자세금계산서 발행은 어떻게 요청하나요?', + a: '"문의하기" 탭에서 "결제 / 환불" 분류로 사업자 정보와 결제 내역을 적어 이메일을 보내 주세요.', + }, +] + +export const SUPPORT_CATEGORY_LABELS: Record = { + audio: '마이크 / 오디오', + gpu: 'CUDA GPU / 모델 로딩 오류', + billing: '결제 / 환불', + general: '기타 문의', +} + +export const SUPPORT_COPY = { + title: '고객지원', + tabFaq: '자주 묻는 질문 (FAQ)', + tabContact: '문의하기', + faqSearchPlaceholder: '자주 묻는 질문 검색...', + faqEmpty: '일치하는 질문이 없습니다.', + contactIntro: + '문의는 이메일로 받습니다. 아래 내용을 채우고 버튼을 누르면 메일 앱에 초안이 열립니다. 메일 앱에서 직접 보내야 문의가 전달됩니다.', + fieldCategory: '분류', + fieldSubject: '문의 제목', + fieldSubjectPlaceholder: '문제를 요약해 주세요', + fieldDetails: '상세 내용', + fieldDetailsPlaceholder: '어떤 상황에서 문제가 생겼는지 적어 주세요...', + requiredError: '필수 입력 항목입니다.', + diagnosticsLabel: '메일 본문에 포함될 진단 정보', + submit: '메일 앱에서 문의 작성하기', + openedNotice: '메일 앱에 초안을 열었습니다. 메일 앱에서 보내기를 눌러야 문의가 전달됩니다.', + openFailed: '메일 앱을 열지 못했습니다. 아래 주소로 직접 이메일을 보내 주세요.', + directAddress: '지원 이메일', +} as const diff --git a/apps/desktop/tests/unit/support-contact-redteam-r1-27.test.ts b/apps/desktop/tests/unit/support-contact-redteam-r1-27.test.ts new file mode 100644 index 0000000..8003341 --- /dev/null +++ b/apps/desktop/tests/unit/support-contact-redteam-r1-27.test.ts @@ -0,0 +1,139 @@ +// tests/unit/support-contact-redteam-r1-27.test.ts +// 고객지원 모달이 가짜 환불 승인·가짜 티켓 접수를 표시하던 결함의 회귀 테스트. +// - 모달 소스에 가짜 흐름(무조건 eligible, alert 환불, 랜덤 티켓 번호, 하드코딩 진단)이 없어야 한다. +// - 문의는 mailto 초안으로만 나가고, 메인 프로세스가 실패를 돌려주면 성공으로 보지 않는다. + +import { describe, it, expect, vi } from 'vitest' +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { + MAX_MAILTO_BODY_CHARS, + SUPPORT_EMAIL, + buildSupportMailto, + createElectronSupportChannel, + formatSupportDiagnostics, + isSupportCategory, + validateSupportDraft, + type SupportSystemApi, +} from '../../src/renderer/components/support/support-contact' + +const SUPPORT_DIR = join(__dirname, '../../src/renderer/components/support') + +function supportSources(): Array<{ name: string; text: string }> { + return readdirSync(SUPPORT_DIR) + .filter((name) => /\.(ts|tsx)$/.test(name)) + .map((name) => ({ name, text: readFileSync(join(SUPPORT_DIR, name), 'utf8') })) +} + +describe('SupportModal — 가짜 처리 흐름 제거', () => { + it('환불 승인·티켓 번호·하드코딩 진단을 만들어내는 코드가 없다', () => { + const forbidden: RegExp[] = [ + /\balert\s*\(/, + /Math\.random/, + /'eligible'/, + /TCK-/, + /RTX 3080/, + /환불 승인/, + /전액 환불 대상/, + /성공적으로 접수/, + /Payple/, + ] + for (const { name, text } of supportSources()) { + for (const pattern of forbidden) { + expect(pattern.test(text), `${name} contains ${pattern}`).toBe(false) + } + } + }) + + it('모달은 IO 포트를 통해 실제 시스템 API로 연결된다', () => { + const modal = readFileSync(join(SUPPORT_DIR, 'SupportModal.tsx'), 'utf8') + expect(modal).toContain('createElectronSupportChannel(window.electronAPI.system)') + }) +}) + +describe('support-contact 정책', () => { + const diag = { appVersion: '1.9.0', platform: 'win32' } + + it('제목·내용이 비면 검증에 실패한다', () => { + expect(validateSupportDraft({ category: 'general', subject: ' ', details: '' })).toEqual({ + ok: false, + missing: ['subject', 'details'], + }) + expect(validateSupportDraft({ category: 'billing', subject: 'a', details: 'b' })).toEqual({ ok: true }) + }) + + it('mailto URL에 수신 주소·분류·실제 진단 정보를 인코딩해 담는다', () => { + const url = buildSupportMailto({ category: 'billing', subject: '환불 & 문의', details: 'line1\nline2' }, diag) + expect(url.startsWith(`mailto:${SUPPORT_EMAIL}?subject=`)).toBe(true) + const params = new URLSearchParams(url.slice(url.indexOf('?') + 1)) + expect(params.get('subject')).toBe('[D3RO Voice][billing] 환불 & 문의') + const body = params.get('body') ?? '' + expect(body).toContain('line1\nline2') + expect(body).toContain('Category: billing') + expect(body).toContain('D3RO Voice 1.9.0 • win32') + expect(url).not.toContain(' ') + }) + + it('긴 본문은 잘라서 mailto 길이를 제한한다', () => { + const url = buildSupportMailto({ category: 'general', subject: 's', details: 'x'.repeat(10_000) }, diag) + const body = new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('body') ?? '' + expect(body.length).toBeLessThan(MAX_MAILTO_BODY_CHARS + 200) + }) + + it('진단 정보를 모르면 unknown으로 표시한다 (가짜 하드웨어 문구 금지)', () => { + expect(formatSupportDiagnostics({ appVersion: null, platform: null })).toBe('D3RO Voice unknown • unknown') + }) + + it('분류 값 가드', () => { + expect(isSupportCategory('billing')).toBe(true) + expect(isSupportCategory('refund_auto')).toBe(false) + }) +}) + +describe('createElectronSupportChannel', () => { + function fakeApi(overrides: Partial = {}): SupportSystemApi { + return { + openExternal: vi.fn(async () => ({ success: true as const, data: undefined })), + getVersion: vi.fn(async () => ({ success: true as const, data: '1.9.0' })), + getPlatform: vi.fn(async () => ({ success: true as const, data: 'win32' })), + ...overrides, + } + } + + it('openExternal 성공일 때만 true', async () => { + const api = fakeApi() + const channel = createElectronSupportChannel(api) + await expect(channel.openMail('mailto:x@example.com')).resolves.toBe(true) + expect(api.openExternal).toHaveBeenCalledWith({ url: 'mailto:x@example.com' }) + }) + + it('메인 프로세스가 거절하거나 예외가 나면 false (성공을 꾸며내지 않는다)', async () => { + const rejected = createElectronSupportChannel( + fakeApi({ + openExternal: async () => ({ success: false as const, error: { code: 1, message: 'nope' } }), + }), + ) + await expect(rejected.openMail('mailto:x@example.com')).resolves.toBe(false) + + const thrown = createElectronSupportChannel( + fakeApi({ + openExternal: async () => { + throw new Error('ipc down') + }, + }), + ) + await expect(thrown.openMail('mailto:x@example.com')).resolves.toBe(false) + }) + + it('진단 조회 실패는 null로 둔다', async () => { + const channel = createElectronSupportChannel( + fakeApi({ + getVersion: async () => { + throw new Error('boom') + }, + getPlatform: async () => ({ success: false as const, error: { code: 1, message: 'x' } }), + }), + ) + await expect(channel.getDiagnostics()).resolves.toEqual({ appVersion: null, platform: null }) + }) +})