diff --git a/scripts/apps-script/lead-intake.gs b/scripts/apps-script/lead-intake.gs index 0a1d30a..46add98 100644 --- a/scripts/apps-script/lead-intake.gs +++ b/scripts/apps-script/lead-intake.gs @@ -5,6 +5,13 @@ * 1. 폼 POST 를 받아 구글시트에 행 추가 * 2. 같은 실행 안에서 알림 메일 발송 (sales@o2o.kr, haewonkam@o2o.kr) * + * 보호 장치 (2026-10-01 추가) + * - 허니팟: 사람 눈에 안 보이는 칸(HONEYPOT_FIELD)이 채워져 있으면 저장하지 않는다 + * - 속도 제한: 한 시간 RATE_MAX 건까지. 토큰은 사이트 번들에 그대로 실려 비밀이 + * 아니므로, 주소와 토큰이 알려져도 피해가 상한 안에 묶이게 한다 + * - 알림 실패 분리: 메일이 막혀도 행은 남기고 접수로 응답한다. 시트의 '알림' 열에 + * '보냄' / '실패(메일만)' 을 적어 메일이 안 간 문의를 눈으로 찾을 수 있게 한다 + * * 왜 트리거가 아니라 여기서 메일을 보내는가 * 시트 onChange 트리거는 스크립트가 쓴 변경에는 늦거나 누락되는 경우가 있다. * 행을 추가한 직후 같은 실행에서 보내면 "시트에 들어오면 알림" 을 가장 확실하게 지킨다. @@ -42,7 +49,24 @@ var NOTIFY_TO = 'sales@o2o.kr,haewonkam@o2o.kr'; /** 행이 쌓일 시트 탭 이름. 없으면 만든다. */ var SHEET_NAME = '문의'; -var HEADERS = ['접수시각', '성함', '회사', '전화', '이메일', '문의내용', '유입', 'User-Agent']; +var HEADERS = ['접수시각', '성함', '회사', '전화', '이메일', '문의내용', '유입', 'User-Agent', '알림']; + +/** + * 허니팟. 사람 눈에 보이지 않는 입력칸이라 값이 차 있으면 봇으로 본다. + * 사이트 폼(ContactModal)의 숨은 칸 이름과 같아야 한다. + */ +var HONEYPOT_FIELD = 'website'; + +/** + * 속도 제한. 한 시간(RATE_WINDOW_SEC) 안에 RATE_MAX 건까지만 받는다. + * 토큰은 사이트 번들에 그대로 실려 비밀이 아니다. 누가 주소와 토큰을 알아내도 + * 피해가 이 상한 안에 묶이게 한다. 메일 하루 한도(Gmail 100통)가 장난 제출로 + * 소진되면 진짜 문의가 와도 알림이 가지 않기 때문이다. + * 고정 창(fixed window) 방식이고, CacheService 라 동시 요청에서 몇 건은 + * 느슨하게 샐 수 있다. 정확한 과금이 아니라 상한이 목적이라 이 정도로 둔다. + */ +var RATE_WINDOW_SEC = 3600; +var RATE_MAX = 30; function doPost(e) { try { @@ -55,12 +79,24 @@ function doPost(e) { return json_({ ok: false, error: 'unauthorized' }); } + // 허니팟. 봇에게 걸렸다고 알리지 않으려고 ok 를 돌려주되 행은 만들지 않는다. + if (trim_(body[HONEYPOT_FIELD])) { + console.log('허니팟에 걸림. 저장하지 않는다.'); + return json_({ ok: true }); + } + // 이름이 비어 있으면 받지 않는다. 사이트 폼도 같은 규칙으로 막는다. var name = trim_(body.name); if (!name) { return json_({ ok: false, error: 'name required' }); } + if (rateLimited_()) { + // 성공으로 위장하지 않는다. 사이트가 메일 폴백을 띄우게 실패로 돌려준다. + console.warn('속도 제한에 걸림: 한 시간 ' + RATE_MAX + '건 초과'); + return json_({ ok: false, error: 'rate limited' }); + } + var row = [ new Date(), name, @@ -70,12 +106,24 @@ function doPost(e) { trim_(body.message), trim_(body.source), e && e.parameter ? trim_(e.parameter.ua) : '', + '', ]; + // 저장이 먼저다. 알림이 실패해도 문의는 남아야 한다. appendRow_(row); - notify_(row); - return json_({ ok: true }); + // 알림 실패를 요청 실패로 번지게 하지 않는다. 메일이 막혔다고(한도 소진·구글 장애) + // 고객 화면에 "전송 실패"가 뜨면, 실제로는 접수됐는데 고객이 모르고 돌아간다. + var mailed = true; + try { + notify_(row); + } catch (mailErr) { + mailed = false; + console.error('알림 메일 실패(문의는 저장됨): ' + mailErr); + } + markNotified_(mailed); + + return json_({ ok: true, mailed: mailed }); } catch (err) { // 실패를 삼키지 않는다. 실행 로그에 남기고 사이트에도 실패를 알린다. console.error('doPost 실패: ' + err); @@ -88,7 +136,40 @@ function doGet() { return json_({ ok: true, service: 'webforai lead intake' }); } +/** + * 한 시간 안에 RATE_MAX 건을 넘었는지 본다. 넘었으면 true. + * 시각을 창 크기로 나눈 몫을 열쇠로 써서 창이 바뀌면 자동으로 0부터 센다. + */ +function rateLimited_() { + var cache = CacheService.getScriptCache(); + var bucket = Math.floor(Date.now() / (RATE_WINDOW_SEC * 1000)); + var key = 'lead_' + bucket; + var n = Number(cache.get(key) || 0); + if (n >= RATE_MAX) return true; + // 창이 끝난 뒤에도 잠깐 남겨 두어 경계에서 두 번 세지 않게 한다. + cache.put(key, String(n + 1), RATE_WINDOW_SEC + 60); + return false; +} + function appendRow_(row) { + var sh = sheet_(); + sh.appendRow(row); +} + +/** 방금 넣은 행의 '알림' 칸에 결과를 적는다. 메일이 안 간 문의를 눈으로 찾을 수 있게 한다. */ +function markNotified_(mailed) { + try { + var sh = sheet_(); + var last = sh.getLastRow(); + if (last < 2) return; + sh.getRange(last, HEADERS.length).setValue(mailed ? '보냄' : '실패(메일만)'); + } catch (e) { + console.error('알림 표시 실패: ' + e); + } +} + +/** 시트를 돌려준다. 없으면 만들고, 헤더가 짧으면 현재 HEADERS 로 맞춘다. */ +function sheet_() { var ss = SpreadsheetApp.getActiveSpreadsheet(); var sh = ss.getSheetByName(SHEET_NAME); if (!sh) { @@ -98,8 +179,14 @@ function appendRow_(row) { sh.appendRow(HEADERS); sh.getRange(1, 1, 1, HEADERS.length).setFontWeight('bold'); sh.setFrozenRows(1); + return sh; } - sh.appendRow(row); + // 열을 추가한 뒤에도 기존 시트의 헤더가 옛 상태로 남아 있지 않게 한다. + var width = sh.getLastColumn(); + if (width < HEADERS.length) { + sh.getRange(1, 1, 1, HEADERS.length).setValues([HEADERS]).setFontWeight('bold'); + } + return sh; } function notify_(row) { @@ -140,7 +227,15 @@ function trim_(v) { * 시트에 테스트 행이 들어가고 알림 메일이 오는지 바로 볼 수 있다. */ function testRun() { - var row = [new Date(), '테스트', '설치 확인', '010-0000-0000', 'test@example.com', '설치 확인용 테스트입니다.', 'test', '']; + var row = [new Date(), '테스트', '설치 확인', '010-0000-0000', 'test@example.com', '설치 확인용 테스트입니다.', 'test', '', '']; appendRow_(row); - notify_(row); + var mailed = true; + try { + notify_(row); + } catch (e) { + mailed = false; + console.error('알림 메일 실패: ' + e); + } + markNotified_(mailed); + console.log('testRun 완료. 메일 발송: ' + (mailed ? '성공' : '실패')); } diff --git a/src/components/ContactModal.tsx b/src/components/ContactModal.tsx index b73d9e8..0a25841 100644 --- a/src/components/ContactModal.tsx +++ b/src/components/ContactModal.tsx @@ -32,6 +32,8 @@ export default function ContactModal({ open, onClose, source }: ContactModalProp const [phone, setPhone] = useState(''); const [email, setEmail] = useState(''); const [message, setMessage] = useState(''); + // 허니팟. 사람은 이 칸을 볼 수 없어 늘 빈 값이고, 폼을 자동으로 채우는 봇만 값을 넣는다. + const [website, setWebsite] = useState(''); const [state, setState] = useState('idle'); const [fieldError, setFieldError] = useState(null); @@ -68,6 +70,7 @@ export default function ContactModal({ open, onClose, source }: ContactModalProp email: email.trim(), message: message.trim(), source, + website, }); setState(result.ok ? 'done' : 'error'); }; @@ -149,6 +152,21 @@ export default function ContactModal({ open, onClose, source }: ContactModalProp 간단히 남겨주시면 담당자가 회신드립니다.

+ {/* 허니팟. display:none 을 피하는 봇이 있어 화면 밖으로 밀어내고, + 보조기기와 탭 이동에서는 빼둔다. 사람이 채울 일이 없는 칸이다. */} + + diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index e3ea09b..03cea47 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -9,7 +9,15 @@ import { useState } from 'react'; import { Link } from 'react-router'; import ContactModal from './ContactModal'; -import { CONTACT_EMAIL, CONTACT_PHONE, CONTACT_PHONE_HREF } from '../lib/contact'; +import { + CONTACT_EMAIL, + CONTACT_PHONE, + CONTACT_PHONE_HREF, + CONTACT_FAX, + COMPANY_LEGAL_NAME, + COMPANY_HQ, + COMPANY_LAB, +} from '../lib/contact'; export default function Footer() { const [contactOpen, setContactOpen] = useState(false); @@ -40,17 +48,28 @@ export default function Footer() { - {/* 구분선 + 연락처 한 줄 */} -
- 전화{' '} - - {CONTACT_PHONE} - - · - 이메일{' '} - - {CONTACT_EMAIL} - + {/* 구분선 + 사업자 정보 + 연락처. aio2o.webforai.kr 푸터와 같은 내용으로 맞춘다. */} +
+

+ {COMPANY_LEGAL_NAME} + · + {COMPANY_HQ} + · + {COMPANY_LAB} +

+

+ 전화{' '} + + {CONTACT_PHONE} + + · + 팩스 {CONTACT_FAX} + · + 이메일{' '} + + {CONTACT_EMAIL} + +

diff --git a/src/lib/contact.ts b/src/lib/contact.ts index 11e7992..e424d44 100644 --- a/src/lib/contact.ts +++ b/src/lib/contact.ts @@ -13,11 +13,22 @@ export const CONTACT_EMAIL = 'sales@o2o.kr'; -/** 화면에 보여주는 표기. 사용자가 지정한 점 구분 표기를 그대로 쓴다. */ -export const CONTACT_PHONE = '010.2755.6463'; +/** 화면 표기. aio2o.webforai.kr 푸터와 같은 하이픈 표기로 맞춘다. */ +export const CONTACT_PHONE = '010-2755-6463'; -/** tel: 링크용. 다이얼러는 점을 못 읽는 기기가 있어 하이픈으로 바꾼다. */ -export const CONTACT_PHONE_HREF = `tel:${CONTACT_PHONE.replace(/\./g, '-')}`; +export const CONTACT_PHONE_HREF = `tel:${CONTACT_PHONE}`; + +/** 팩스. 전화·이메일과 달리 링크를 걸지 않는다. */ +export const CONTACT_FAX = '0504-335-6463'; + +/** + * 사업자 정보. 값의 원본은 aio2o.webforai.kr 의 data/facts.json 이다(haewon 2026-09-28 승인). + * 두 사이트 푸터를 같은 내용으로 맞추라는 지시(2026-10-01)로 여기에 옮겨 적었다. + * 바뀌면 그쪽 facts.json 과 함께 고쳐야 한다. 임의로 지어내지 않는다. + */ +export const COMPANY_LEGAL_NAME = '(주)AI오투오 (AIO2O)'; +export const COMPANY_HQ = '본사 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 에이05호'; +export const COMPANY_LAB = '연구소 (13453) 경기도 성남시 수정구 금토로 32 KT판교빌딩 East 504호'; /** * INFINITH 태그를 자동으로 prefix한 mailto 링크 생성. diff --git a/src/lib/submitLead.ts b/src/lib/submitLead.ts index af959c7..6ee5453 100644 --- a/src/lib/submitLead.ts +++ b/src/lib/submitLead.ts @@ -24,6 +24,12 @@ export interface LeadInput { message: string; /** 어느 화면에서 열었는지 (footer · navbar) */ source: string; + /** + * 허니팟. 사람에게는 보이지 않는 칸이라 항상 빈 문자열이어야 한다. + * 값이 차 있으면 스크립트가 봇으로 보고 저장하지 않는다. + * 이름은 Apps Script 의 HONEYPOT_FIELD 와 같아야 한다. + */ + website: string; } export type LeadResult = { ok: true } | { ok: false; reason: 'unconfigured' | 'failed' };