o2o-infinith-demo/scripts/apps-script/lead-intake.gs
Haewon Kam 8a96aa0e97 feat(contact): 푸터에 사업자 정보 추가, 문의 폼에 허니팟·속도 제한
푸터 (haewon 2026-10-01)
- aio2o.webforai.kr 푸터와 같은 내용으로 맞춘다: 법인명·본사·연구소 주소와
  전화·팩스·이메일. 값의 원본은 그쪽 data/facts.json 이며 지어내지 않았다.
- 전화 표기를 점에서 하이픈으로 바꿔 두 사이트를 같게 한다.

문의 폼 보호 장치
- 허니팟: 사람 눈에 안 보이는 입력칸(website)을 두고, 값이 차 있으면 스크립트가
  저장하지 않는다. display:none 을 건너뛰는 봇이 있어 화면 밖으로 밀어내고
  aria-hidden·tabIndex -1 로 보조기기와 탭 이동에서는 뺀다.
- 속도 제한: Apps Script 가 한 시간 30건까지만 받는다. VITE_LEAD_TOKEN 은 빌드
  시점에 번들로 들어가 공개값이라 보호 장치가 못 된다. 주소와 토큰이 알려져도
  피해가 상한 안에 묶이게 한다.
- 알림 실패 분리: 메일 발송이 실패해도 행은 남기고 접수로 응답한다. 지금까지는
  메일이 막히면(한도 소진·구글 장애) 저장됐는데도 고객 화면에 "전송 실패"가 떠
  접수된 줄 모르고 돌아가게 되어 있었다. 시트에 '알림' 열을 추가해 '보냄' /
  '실패(메일만)' 을 적어 메일이 안 간 문의를 찾을 수 있게 한다.

토큰 실값은 저장소에 넣지 않는다. 배포본 Apps Script 와 Vercel 환경변수에만 둔다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 11:53:06 +09:00

242 lines
9.4 KiB
JavaScript

/**
* webforai.kr 문의 폼 수신기 (Google Apps Script 웹앱)
*
* 하는 일
* 1. 폼 POST 를 받아 구글시트에 행 추가
* 2. 같은 실행 안에서 알림 메일 발송 (sales@o2o.kr, haewonkam@o2o.kr)
*
* 보호 장치 (2026-10-01 추가)
* - 허니팟: 사람 눈에 안 보이는 칸(HONEYPOT_FIELD)이 채워져 있으면 저장하지 않는다
* - 속도 제한: 한 시간 RATE_MAX 건까지. 토큰은 사이트 번들에 그대로 실려 비밀이
* 아니므로, 주소와 토큰이 알려져도 피해가 상한 안에 묶이게 한다
* - 알림 실패 분리: 메일이 막혀도 행은 남기고 접수로 응답한다. 시트의 '알림' 열에
* '보냄' / '실패(메일만)' 을 적어 메일이 안 간 문의를 눈으로 찾을 수 있게 한다
*
* 왜 트리거가 아니라 여기서 메일을 보내는가
* 시트 onChange 트리거는 스크립트가 쓴 변경에는 늦거나 누락되는 경우가 있다.
* 행을 추가한 직후 같은 실행에서 보내면 "시트에 들어오면 알림" 을 가장 확실하게 지킨다.
*
* ─────────────────────────────────────────────────────────────
* 설치 순서 (haewon 이 직접)
*
* 1. 구글시트를 새로 만든다. 이름 예: "webforai 문의 접수"
* 2. 시트에서 확장프로그램 → Apps Script 를 연다.
* 3. 기본 Code.gs 내용을 지우고 이 파일 전체를 붙여넣는다.
* 4. 아래 SHARED_TOKEN 을 임의의 문자열로 바꾼다 (예: 영문+숫자 24자).
* 빈 문자열로 두면 토큰 검사를 하지 않는다.
* 5. 배포 → 새 배포 → 유형 "웹 앱"
* - 실행 계정: 나
* - 액세스 권한: 모든 사용자 ← 이 값이어야 사이트에서 호출된다
* 배포하면 https://script.google.com/macros/s/AKfyc.../exec 주소가 나온다.
* 6. 첫 배포 때 권한 승인을 한 번 묻는다. 승인한다
* (시트 쓰기 + 메일 발송 권한).
* 7. 그 주소를 Vercel 환경변수에 넣는다 (infinith-discovery 프로젝트):
* VITE_LEAD_ENDPOINT = https://script.google.com/macros/s/AKfyc.../exec
* VITE_LEAD_TOKEN = 4번에서 정한 문자열 (토큰을 쓸 때만)
* 넣은 뒤 재배포해야 반영된다 (Vite 는 빌드 시점에 값을 넣는다).
*
* 코드를 고친 뒤에는 반드시 "배포 → 배포 관리 → 편집 → 버전: 새 버전" 으로
* 다시 배포해야 반영된다. 저장만 하면 기존 배포는 옛 코드 그대로다.
* ─────────────────────────────────────────────────────────────
*/
/** 4번에서 정한 값. 사이트의 VITE_LEAD_TOKEN 과 같아야 한다. 빈 문자열이면 검사 안 함. */
var SHARED_TOKEN = '';
/** 알림 받을 주소. */
var NOTIFY_TO = 'sales@o2o.kr,haewonkam@o2o.kr';
/** 행이 쌓일 시트 탭 이름. 없으면 만든다. */
var SHEET_NAME = '문의';
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 {
var body = {};
if (e && e.postData && e.postData.contents) {
body = JSON.parse(e.postData.contents);
}
if (SHARED_TOKEN && body.token !== SHARED_TOKEN) {
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,
trim_(body.company),
trim_(body.phone),
trim_(body.email),
trim_(body.message),
trim_(body.source),
e && e.parameter ? trim_(e.parameter.ua) : '',
'',
];
// 저장이 먼저다. 알림이 실패해도 문의는 남아야 한다.
appendRow_(row);
// 알림 실패를 요청 실패로 번지게 하지 않는다. 메일이 막혔다고(한도 소진·구글 장애)
// 고객 화면에 "전송 실패"가 뜨면, 실제로는 접수됐는데 고객이 모르고 돌아간다.
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);
return json_({ ok: false, error: String(err) });
}
}
/** 브라우저로 주소를 열었을 때 살아 있는지 확인용. */
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) {
sh = ss.insertSheet(SHEET_NAME);
}
if (sh.getLastRow() === 0) {
sh.appendRow(HEADERS);
sh.getRange(1, 1, 1, HEADERS.length).setFontWeight('bold');
sh.setFrozenRows(1);
return sh;
}
// 열을 추가한 뒤에도 기존 시트의 헤더가 옛 상태로 남아 있지 않게 한다.
var width = sh.getLastColumn();
if (width < HEADERS.length) {
sh.getRange(1, 1, 1, HEADERS.length).setValues([HEADERS]).setFontWeight('bold');
}
return sh;
}
function notify_(row) {
var subject = '[webforai 문의] ' + row[1] + (row[2] ? ' · ' + row[2] : '');
var lines = [
'성함: ' + (row[1] || '-'),
'회사: ' + (row[2] || '-'),
'전화: ' + (row[3] || '-'),
'이메일: ' + (row[4] || '-'),
'유입: ' + (row[6] || '-'),
'',
'문의 내용:',
row[5] || '-',
'',
'시트: ' + SpreadsheetApp.getActiveSpreadsheet().getUrl(),
];
MailApp.sendEmail({
to: NOTIFY_TO,
subject: subject,
body: lines.join('\n'),
// 회신하면 문의한 사람에게 바로 가게 한다. 이메일을 안 남겼으면 설정하지 않는다.
replyTo: row[4] || undefined,
});
}
function json_(obj) {
return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType(
ContentService.MimeType.JSON
);
}
function trim_(v) {
return v === null || v === undefined ? '' : String(v).trim();
}
/**
* 설치 확인용. Apps Script 편집기에서 이 함수를 한 번 실행하면
* 시트에 테스트 행이 들어가고 알림 메일이 오는지 바로 볼 수 있다.
*/
function testRun() {
var row = [new Date(), '테스트', '설치 확인', '010-0000-0000', 'test@example.com', '설치 확인용 테스트입니다.', 'test', '', ''];
appendRow_(row);
var mailed = true;
try {
notify_(row);
} catch (e) {
mailed = false;
console.error('알림 메일 실패: ' + e);
}
markNotified_(mailed);
console.log('testRun 완료. 메일 발송: ' + (mailed ? '성공' : '실패'));
}