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>
This commit is contained in:
parent
1710437874
commit
8a96aa0e97
@ -5,6 +5,13 @@
|
|||||||
* 1. 폼 POST 를 받아 구글시트에 행 추가
|
* 1. 폼 POST 를 받아 구글시트에 행 추가
|
||||||
* 2. 같은 실행 안에서 알림 메일 발송 (sales@o2o.kr, haewonkam@o2o.kr)
|
* 2. 같은 실행 안에서 알림 메일 발송 (sales@o2o.kr, haewonkam@o2o.kr)
|
||||||
*
|
*
|
||||||
|
* 보호 장치 (2026-10-01 추가)
|
||||||
|
* - 허니팟: 사람 눈에 안 보이는 칸(HONEYPOT_FIELD)이 채워져 있으면 저장하지 않는다
|
||||||
|
* - 속도 제한: 한 시간 RATE_MAX 건까지. 토큰은 사이트 번들에 그대로 실려 비밀이
|
||||||
|
* 아니므로, 주소와 토큰이 알려져도 피해가 상한 안에 묶이게 한다
|
||||||
|
* - 알림 실패 분리: 메일이 막혀도 행은 남기고 접수로 응답한다. 시트의 '알림' 열에
|
||||||
|
* '보냄' / '실패(메일만)' 을 적어 메일이 안 간 문의를 눈으로 찾을 수 있게 한다
|
||||||
|
*
|
||||||
* 왜 트리거가 아니라 여기서 메일을 보내는가
|
* 왜 트리거가 아니라 여기서 메일을 보내는가
|
||||||
* 시트 onChange 트리거는 스크립트가 쓴 변경에는 늦거나 누락되는 경우가 있다.
|
* 시트 onChange 트리거는 스크립트가 쓴 변경에는 늦거나 누락되는 경우가 있다.
|
||||||
* 행을 추가한 직후 같은 실행에서 보내면 "시트에 들어오면 알림" 을 가장 확실하게 지킨다.
|
* 행을 추가한 직후 같은 실행에서 보내면 "시트에 들어오면 알림" 을 가장 확실하게 지킨다.
|
||||||
@ -42,7 +49,24 @@ var NOTIFY_TO = 'sales@o2o.kr,haewonkam@o2o.kr';
|
|||||||
/** 행이 쌓일 시트 탭 이름. 없으면 만든다. */
|
/** 행이 쌓일 시트 탭 이름. 없으면 만든다. */
|
||||||
var SHEET_NAME = '문의';
|
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) {
|
function doPost(e) {
|
||||||
try {
|
try {
|
||||||
@ -55,12 +79,24 @@ function doPost(e) {
|
|||||||
return json_({ ok: false, error: 'unauthorized' });
|
return json_({ ok: false, error: 'unauthorized' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 허니팟. 봇에게 걸렸다고 알리지 않으려고 ok 를 돌려주되 행은 만들지 않는다.
|
||||||
|
if (trim_(body[HONEYPOT_FIELD])) {
|
||||||
|
console.log('허니팟에 걸림. 저장하지 않는다.');
|
||||||
|
return json_({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
// 이름이 비어 있으면 받지 않는다. 사이트 폼도 같은 규칙으로 막는다.
|
// 이름이 비어 있으면 받지 않는다. 사이트 폼도 같은 규칙으로 막는다.
|
||||||
var name = trim_(body.name);
|
var name = trim_(body.name);
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return json_({ ok: false, error: 'name required' });
|
return json_({ ok: false, error: 'name required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rateLimited_()) {
|
||||||
|
// 성공으로 위장하지 않는다. 사이트가 메일 폴백을 띄우게 실패로 돌려준다.
|
||||||
|
console.warn('속도 제한에 걸림: 한 시간 ' + RATE_MAX + '건 초과');
|
||||||
|
return json_({ ok: false, error: 'rate limited' });
|
||||||
|
}
|
||||||
|
|
||||||
var row = [
|
var row = [
|
||||||
new Date(),
|
new Date(),
|
||||||
name,
|
name,
|
||||||
@ -70,12 +106,24 @@ function doPost(e) {
|
|||||||
trim_(body.message),
|
trim_(body.message),
|
||||||
trim_(body.source),
|
trim_(body.source),
|
||||||
e && e.parameter ? trim_(e.parameter.ua) : '',
|
e && e.parameter ? trim_(e.parameter.ua) : '',
|
||||||
|
'',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 저장이 먼저다. 알림이 실패해도 문의는 남아야 한다.
|
||||||
appendRow_(row);
|
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) {
|
} catch (err) {
|
||||||
// 실패를 삼키지 않는다. 실행 로그에 남기고 사이트에도 실패를 알린다.
|
// 실패를 삼키지 않는다. 실행 로그에 남기고 사이트에도 실패를 알린다.
|
||||||
console.error('doPost 실패: ' + err);
|
console.error('doPost 실패: ' + err);
|
||||||
@ -88,7 +136,40 @@ function doGet() {
|
|||||||
return json_({ ok: true, service: 'webforai lead intake' });
|
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) {
|
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 ss = SpreadsheetApp.getActiveSpreadsheet();
|
||||||
var sh = ss.getSheetByName(SHEET_NAME);
|
var sh = ss.getSheetByName(SHEET_NAME);
|
||||||
if (!sh) {
|
if (!sh) {
|
||||||
@ -98,8 +179,14 @@ function appendRow_(row) {
|
|||||||
sh.appendRow(HEADERS);
|
sh.appendRow(HEADERS);
|
||||||
sh.getRange(1, 1, 1, HEADERS.length).setFontWeight('bold');
|
sh.getRange(1, 1, 1, HEADERS.length).setFontWeight('bold');
|
||||||
sh.setFrozenRows(1);
|
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) {
|
function notify_(row) {
|
||||||
@ -140,7 +227,15 @@ function trim_(v) {
|
|||||||
* 시트에 테스트 행이 들어가고 알림 메일이 오는지 바로 볼 수 있다.
|
* 시트에 테스트 행이 들어가고 알림 메일이 오는지 바로 볼 수 있다.
|
||||||
*/
|
*/
|
||||||
function testRun() {
|
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);
|
appendRow_(row);
|
||||||
notify_(row);
|
var mailed = true;
|
||||||
|
try {
|
||||||
|
notify_(row);
|
||||||
|
} catch (e) {
|
||||||
|
mailed = false;
|
||||||
|
console.error('알림 메일 실패: ' + e);
|
||||||
|
}
|
||||||
|
markNotified_(mailed);
|
||||||
|
console.log('testRun 완료. 메일 발송: ' + (mailed ? '성공' : '실패'));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,6 +32,8 @@ export default function ContactModal({ open, onClose, source }: ContactModalProp
|
|||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
// 허니팟. 사람은 이 칸을 볼 수 없어 늘 빈 값이고, 폼을 자동으로 채우는 봇만 값을 넣는다.
|
||||||
|
const [website, setWebsite] = useState('');
|
||||||
const [state, setState] = useState<SubmitState>('idle');
|
const [state, setState] = useState<SubmitState>('idle');
|
||||||
const [fieldError, setFieldError] = useState<string | null>(null);
|
const [fieldError, setFieldError] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -68,6 +70,7 @@ export default function ContactModal({ open, onClose, source }: ContactModalProp
|
|||||||
email: email.trim(),
|
email: email.trim(),
|
||||||
message: message.trim(),
|
message: message.trim(),
|
||||||
source,
|
source,
|
||||||
|
website,
|
||||||
});
|
});
|
||||||
setState(result.ok ? 'done' : 'error');
|
setState(result.ok ? 'done' : 'error');
|
||||||
};
|
};
|
||||||
@ -149,6 +152,21 @@ export default function ContactModal({ open, onClose, source }: ContactModalProp
|
|||||||
간단히 남겨주시면 담당자가 회신드립니다.
|
간단히 남겨주시면 담당자가 회신드립니다.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* 허니팟. display:none 을 피하는 봇이 있어 화면 밖으로 밀어내고,
|
||||||
|
보조기기와 탭 이동에서는 빼둔다. 사람이 채울 일이 없는 칸이다. */}
|
||||||
|
<div aria-hidden="true" className="absolute left-[-9999px] top-0 w-px h-px overflow-hidden">
|
||||||
|
<label htmlFor="contact-website">웹사이트 (입력하지 마세요)</label>
|
||||||
|
<input
|
||||||
|
id="contact-website"
|
||||||
|
name="website"
|
||||||
|
type="text"
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
value={website}
|
||||||
|
onChange={(e) => setWebsite(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="block text-xs font-semibold text-slate-500 mb-1.5">
|
<label className="block text-xs font-semibold text-slate-500 mb-1.5">
|
||||||
성함 <span className="text-[#7C3A4B]">*</span>
|
성함 <span className="text-[#7C3A4B]">*</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@ -9,7 +9,15 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import ContactModal from './ContactModal';
|
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() {
|
export default function Footer() {
|
||||||
const [contactOpen, setContactOpen] = useState(false);
|
const [contactOpen, setContactOpen] = useState(false);
|
||||||
@ -40,17 +48,28 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 구분선 + 연락처 한 줄 */}
|
{/* 구분선 + 사업자 정보 + 연락처. aio2o.webforai.kr 푸터와 같은 내용으로 맞춘다. */}
|
||||||
<div className="border-t border-slate-100 pt-6 text-sm text-slate-500 text-center md:text-left">
|
<div className="border-t border-slate-100 pt-6 flex flex-col gap-2 text-sm text-slate-500 text-center md:text-left">
|
||||||
<span className="text-slate-400">전화</span>{' '}
|
<p className="[word-break:keep-all]">
|
||||||
<a href={CONTACT_PHONE_HREF} className="underline underline-offset-4 decoration-slate-300 hover:text-primary-900 transition-colors">
|
<span className="font-semibold text-slate-600">{COMPANY_LEGAL_NAME}</span>
|
||||||
{CONTACT_PHONE}
|
<span className="mx-2 text-slate-300">·</span>
|
||||||
</a>
|
{COMPANY_HQ}
|
||||||
<span className="mx-2 text-slate-300">·</span>
|
<span className="mx-2 text-slate-300">·</span>
|
||||||
<span className="text-slate-400">이메일</span>{' '}
|
{COMPANY_LAB}
|
||||||
<a href={`mailto:${CONTACT_EMAIL}`} className="underline underline-offset-4 decoration-slate-300 hover:text-primary-900 transition-colors">
|
</p>
|
||||||
{CONTACT_EMAIL}
|
<p>
|
||||||
</a>
|
<span className="text-slate-400">전화</span>{' '}
|
||||||
|
<a href={CONTACT_PHONE_HREF} className="underline underline-offset-4 decoration-slate-300 hover:text-primary-900 transition-colors">
|
||||||
|
{CONTACT_PHONE}
|
||||||
|
</a>
|
||||||
|
<span className="mx-2 text-slate-300">·</span>
|
||||||
|
<span className="text-slate-400">팩스</span> {CONTACT_FAX}
|
||||||
|
<span className="mx-2 text-slate-300">·</span>
|
||||||
|
<span className="text-slate-400">이메일</span>{' '}
|
||||||
|
<a href={`mailto:${CONTACT_EMAIL}`} className="underline underline-offset-4 decoration-slate-300 hover:text-primary-900 transition-colors">
|
||||||
|
{CONTACT_EMAIL}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -13,11 +13,22 @@
|
|||||||
|
|
||||||
export const CONTACT_EMAIL = 'sales@o2o.kr';
|
export const CONTACT_EMAIL = 'sales@o2o.kr';
|
||||||
|
|
||||||
/** 화면에 보여주는 표기. 사용자가 지정한 점 구분 표기를 그대로 쓴다. */
|
/** 화면 표기. aio2o.webforai.kr 푸터와 같은 하이픈 표기로 맞춘다. */
|
||||||
export const CONTACT_PHONE = '010.2755.6463';
|
export const CONTACT_PHONE = '010-2755-6463';
|
||||||
|
|
||||||
/** tel: 링크용. 다이얼러는 점을 못 읽는 기기가 있어 하이픈으로 바꾼다. */
|
export const CONTACT_PHONE_HREF = `tel:${CONTACT_PHONE}`;
|
||||||
export const CONTACT_PHONE_HREF = `tel:${CONTACT_PHONE.replace(/\./g, '-')}`;
|
|
||||||
|
/** 팩스. 전화·이메일과 달리 링크를 걸지 않는다. */
|
||||||
|
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 링크 생성.
|
* INFINITH 태그를 자동으로 prefix한 mailto 링크 생성.
|
||||||
|
|||||||
@ -24,6 +24,12 @@ export interface LeadInput {
|
|||||||
message: string;
|
message: string;
|
||||||
/** 어느 화면에서 열었는지 (footer · navbar) */
|
/** 어느 화면에서 열었는지 (footer · navbar) */
|
||||||
source: string;
|
source: string;
|
||||||
|
/**
|
||||||
|
* 허니팟. 사람에게는 보이지 않는 칸이라 항상 빈 문자열이어야 한다.
|
||||||
|
* 값이 차 있으면 스크립트가 봇으로 보고 저장하지 않는다.
|
||||||
|
* 이름은 Apps Script 의 HONEYPOT_FIELD 와 같아야 한다.
|
||||||
|
*/
|
||||||
|
website: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LeadResult = { ok: true } | { ok: false; reason: 'unconfigured' | 'failed' };
|
export type LeadResult = { ok: true } | { ok: false; reason: 'unconfigured' | 'failed' };
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user