commit 48630067ff731db5654b0cdddf2657af4b5356e4 Author: dotlee Date: Tue Aug 25 11:14:55 2026 +0900 출석부 QR 서명 시스템 초기 커밋 diff --git a/Code.gs b/Code.gs new file mode 100644 index 0000000..c09ccfa --- /dev/null +++ b/Code.gs @@ -0,0 +1,171 @@ +const SHEET_NAME = '출석부'; +const SIGNATURE_FOLDER_NAME = '출석_서명이미지'; +const HEADERS = ['날짜', '이름', '입실시각', '퇴실시각', '학습시간', '입실서명', '퇴실서명', '입실기기ID', '입실기기정보', '퇴실기기ID', '퇴실기기정보']; +const COL = { + DATE: 1, + NAME: 2, + IN_TIME: 3, + OUT_TIME: 4, + STUDY_TIME: 5, + IN_SIGNATURE: 6, + OUT_SIGNATURE: 7, + IN_DEVICE_ID: 8, + IN_DEVICE_INFO: 9, + OUT_DEVICE_ID: 10, + OUT_DEVICE_INFO: 11 +}; +const MAX_SIGNATURE_BASE64_LENGTH = 2000000; // 서명 이미지 base64 길이 상한(약 1.5MB), 남용 방지용 + +// 학생용 페이지는 이 Apps Script가 아니라 별도 정적 호스팅(예: GitHub Pages)에 둔다. +// 이 웹앱은 그 페이지가 fetch()로 호출하는 JSON API 역할만 한다 (배포 URL을 직접 열람하지 않게 하기 위함). +function doGet(e) { + if (e && e.parameter && e.parameter.action === 'names') { + return ContentService.createTextOutput(JSON.stringify({ ok: true, names: getKnownNames_() })) + .setMimeType(ContentService.MimeType.JSON); + } + return ContentService.createTextOutput('출석부 API가 정상 동작 중입니다.'); +} + +// 이름 자동완성용: 시트에 이미 기록된 이름을 중복 없이 반환한다. +function getKnownNames_() { + const sheet = getOrCreateSheet_(); + const lastRow = sheet.getLastRow(); + if (lastRow < 2) return []; + const values = sheet.getRange(2, COL.NAME, lastRow - 1, 1).getValues(); + const seen = {}; + const names = []; + values.forEach(function (row) { + const n = row[0]; + if (n && !seen[n]) { + seen[n] = true; + names.push(n); + } + }); + return names; +} + +function doPost(e) { + let result; + try { + const payload = JSON.parse(e.postData.contents); + result = submitAttendance(payload); + } catch (err) { + result = { ok: false, error: err.message }; + } + return ContentService.createTextOutput(JSON.stringify(result)) + .setMimeType(ContentService.MimeType.JSON); +} + +function getOrCreateSheet_() { + const ss = SpreadsheetApp.getActiveSpreadsheet(); + let sheet = ss.getSheetByName(SHEET_NAME); + if (!sheet) { + sheet = ss.insertSheet(SHEET_NAME); + } + if (sheet.getLastRow() === 0) { + sheet.appendRow(HEADERS); + sheet.setFrozenRows(1); + sheet.getRange('A2:A').setNumberFormat('yyyy-mm-dd'); + sheet.getRange('C2:D').setNumberFormat('hh:mm:ss'); + sheet.getRange('E2:E').setNumberFormat('[h]"시간" mm"분"'); + sheet.setColumnWidth(COL.IN_SIGNATURE, 160); + sheet.setColumnWidth(COL.OUT_SIGNATURE, 160); + sheet.hideColumns(COL.IN_DEVICE_ID, 4); + } + return sheet; +} + +function getOrCreateFolder_() { + const folders = DriveApp.getFoldersByName(SIGNATURE_FOLDER_NAME); + if (folders.hasNext()) { + return folders.next(); + } + return DriveApp.createFolder(SIGNATURE_FOLDER_NAME); +} + +// 이름/기기정보가 "=", "+" 등으로 시작하면 시트에서 수식으로 실행될 수 있어(수식 삽입 취약점) 앞에 작은따옴표를 붙여 텍스트로 강제 +function sanitizeForSheet_(text) { + const str = (text || '').toString(); + return /^[=+\-@\t\r]/.test(str) ? "'" + str : str; +} + +// 같은 이름 + 같은 날짜의 행을 찾아 반환하고, 없으면 새로 만들어서 반환한다. +function findOrCreateRow_(sheet, dateStr, name) { + const lastRow = sheet.getLastRow(); + const tz = Session.getScriptTimeZone(); + if (lastRow >= 2) { + const values = sheet.getRange(2, COL.DATE, lastRow - 1, 2).getValues(); + for (let i = 0; i < values.length; i++) { + const rowDate = values[i][0]; + const rowDateStr = rowDate instanceof Date ? Utilities.formatDate(rowDate, tz, 'yyyy-MM-dd') : ''; + if (rowDateStr === dateStr && values[i][1] === name) { + return i + 2; + } + } + } + const newRow = lastRow + 1; + sheet.getRange(newRow, COL.DATE).setValue(new Date(dateStr)); + sheet.getRange(newRow, COL.NAME).setValue(sanitizeForSheet_(name)); + // 입실/퇴실시각이 둘 다 채워지면 자동으로 학습시간을 계산하는 수식 (둘 중 하나라도 없으면 빈 값) + sheet.getRange(newRow, COL.STUDY_TIME).setFormula( + '=IF(AND(C' + newRow + '<>"",D' + newRow + '<>""),D' + newRow + '-C' + newRow + ',"")' + ); + return newRow; +} + +function submitAttendance(payload) { + if (!payload || !payload.name || !payload.signature) { + throw new Error('이름과 서명을 모두 입력해주세요.'); + } + if (payload.type !== 'in' && payload.type !== 'out') { + throw new Error('입실/퇴실 여부를 선택해주세요.'); + } + + const name = payload.name.toString().trim().slice(0, 50); + if (!name) { + throw new Error('이름을 입력해주세요.'); + } + + const base64 = payload.signature.split(',').pop(); + if (!base64 || base64.length > MAX_SIGNATURE_BASE64_LENGTH) { + throw new Error('서명 데이터가 올바르지 않습니다.'); + } + + const now = new Date(); + const tz = Session.getScriptTimeZone(); + const dateStr = Utilities.formatDate(now, tz, 'yyyy-MM-dd'); + + const sheet = getOrCreateSheet_(); + const row = findOrCreateRow_(sheet, dateStr, name); + + const isIn = payload.type === 'in'; + const timeCol = isIn ? COL.IN_TIME : COL.OUT_TIME; + const existing = sheet.getRange(row, timeCol).getValue(); + const shouldUpdate = !(existing instanceof Date) || (isIn ? now < existing : now > existing); + + // 입실은 그날의 가장 빠른 시각, 퇴실은 가장 늦은 시각만 채택한다. + // 채택되지 않는 제출은 시각/서명/기기정보를 아예 기록하지 않는다 (기록된 시각과 서명이 항상 같은 제출에서 나오도록 보장). + if (!shouldUpdate) { + return { ok: true, updated: false }; + } + + const bytes = Utilities.base64Decode(base64); + const blob = Utilities.newBlob(bytes, 'image/png', name + '_' + now.getTime() + '.png'); + const folder = getOrCreateFolder_(); + const file = folder.createFile(blob); + file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW); + const imageUrl = 'https://drive.google.com/thumbnail?id=' + file.getId() + '&sz=w1000'; + + const sigCol = isIn ? COL.IN_SIGNATURE : COL.OUT_SIGNATURE; + const devIdCol = isIn ? COL.IN_DEVICE_ID : COL.OUT_DEVICE_ID; + const devInfoCol = isIn ? COL.IN_DEVICE_INFO : COL.OUT_DEVICE_INFO; + + sheet.getRange(row, timeCol).setValue(now); + const cellImage = SpreadsheetApp.newCellImage().setSourceUrl(imageUrl).build(); + sheet.getRange(row, sigCol).setValue(cellImage); + sheet.getRange(row, devIdCol).setValue(sanitizeForSheet_(payload.deviceId)); + sheet.getRange(row, devInfoCol).setValue(sanitizeForSheet_(payload.deviceInfo)); + sheet.setRowHeight(row, 60); + + return { ok: true, updated: true }; +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..65f5e02 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# 온라인 출석부 (QR 서명 방식) + +## 구조 + +구글 계정이 여러 개 있는 폰에서 `script.google.com` 페이지를 직접 열면, 구글이 계정 컨텍스트를 정하는 과정에서 주소가 `.../u/1/exec`, `.../u/3/exec` 처럼 바뀌면서 접속이 안 되는 문제가 있습니다. 이 문제는 링크를 단축 URL로 감싸도, 최종적으로 그 페이지를 "열람"하는 순간 다시 발생합니다. + +그래서 이 구성에서는 **학생이 여는 페이지는 구글 도메인이 아닌 곳에 두고**, Apps Script는 그 페이지가 백그라운드에서 `fetch()`로 호출하는 JSON API 역할만 하도록 나눴습니다. 브라우저가 `script.google.com`을 화면에 열람하는 게 아니라 JS가 조용히 API만 호출하기 때문에 계정 선택 문제가 아예 발생하지 않습니다. + +- `Code.gs` → Google Apps Script에 붙여넣음. 서명 이미지 저장 + 시트 기록을 담당하는 JSON API (`doPost`) +- `site/index.html` → **Apps Script가 아닌** 일반 정적 호스팅(GitHub Pages 등)에 올림. 학생이 QR로 여는 실제 페이지 (이름 입력 + 손글씨 서명 캔버스) + +기록되는 열: 날짜, 이름, 입실시각, 퇴실시각, 학습시간(자동 계산), 입실서명(이미지), 퇴실서명(이미지), 입실기기ID·입실기기정보·퇴실기기ID·퇴실기기정보(기본적으로 숨김 처리됨) + +- **학습시간**은 입실시각·퇴실시각이 둘 다 있을 때만 자동 계산되는 수식(`=D-C`)입니다. 시각이 바뀌면 자동으로 다시 계산됩니다. +- **이름 입력창 자동완성**: 페이지를 열 때 시트에 이미 기록된 이름 목록을 불러와 입력창에 제안으로 띄워줍니다 (브라우저 기본 자동완성 UI 사용, 새 이름도 자유롭게 입력 가능). +- 페이지 상단에 학교 로고 이미지가 표시됩니다. + +시트는 **학생 1명 + 날짜 1개 = 1행**으로 관리됩니다. 같은 날 같은 학생이 여러 번 서명해도 행이 늘어나지 않고, 다음 규칙으로 같은 행이 갱신됩니다. +- **입실**: 그날 기록된 시각보다 더 빠른 경우에만 갱신 (그날 첫 입실 시각 유지) +- **퇴실**: 그날 기록된 시각보다 더 늦은 경우에만 갱신 (그날 마지막 퇴실 시각 유지) +- 갱신되지 않는 제출은 서명 이미지도 기록되지 않습니다 (시트에 남는 서명은 항상 실제로 채택된 입실/퇴실 시각과 같은 순간에 찍힌 서명입니다). + +이 구조라서 날짜별로 등교/하교 시간을 볼 때 피벗테이블이 단순해집니다 — 이미 한 줄에 그날의 입실/퇴실시각이 정리돼 있으므로, 피벗테이블에서 행에 `날짜`(또는 `이름`), 값에 `입실시각`/`퇴실시각`을 그대로 넣으면 됩니다 (별도 MIN/MAX 집계 설정 불필요). + +> **열 구조가 바뀔 때마다 기존 `출석부` 시트는 그대로 재사용하면 안 됩니다** (헤더가 이미 있으면 코드가 헤더를 다시 쓰지 않기 때문에 새 열이 추가되지 않습니다). 학습시간 열을 추가하면서 다시 한 번 열 구조가 바뀌었으니, 기존 `출석부` 탭 이름을 `출석부_구버전` 등으로 바꿔 보관해두고 Apps Script를 재배포하면 새 구조의 `출석부` 시트가 새로 만들어집니다. + +## 1단계 — Apps Script API 배포 + +1. [sheets.google.com](https://sheets.google.com)에서 새 스프레드시트 생성 (이름 예: "출석부") +2. 상단 메뉴 **확장 프로그램 → Apps Script** 클릭 +3. 기본 생성된 `Code.gs` 내용을 전부 지우고, 이 폴더의 `Code.gs` 내용을 붙여넣기 +4. `Ctrl+S`로 저장 +5. 오른쪽 위 **배포 → 새 배포** + - 유형: **웹 앱** + - 실행 계정: **나** + - 액세스 권한이 있는 사용자: **전체** + - 배포 클릭 +6. 처음 배포 시 권한 승인 창이 뜨면 **고급 → (프로젝트명)으로 이동 → 허용** (Sheets/Drive 접근 권한 승인 — 본인 계정으로만 실행되므로 안전합니다) +7. 발급된 웹 앱 URL(`https://script.google.com/macros/s/.../exec`)을 복사 — **이 URL은 학생에게 직접 주지 않습니다.** 2단계에서 페이지 안에만 넣습니다. + +## 2단계 — 학생용 페이지 호스팅 (구글이 아닌 곳) + +`site/index.html`을 열어 맨 위 스크립트의 + +```js +const EXEC_URL = 'PASTE_YOUR_APPS_SCRIPT_WEB_APP_URL_HERE'; +``` + +부분을 1단계에서 복사한 실제 URL로 교체한 뒤, 정적 호스팅에 올립니다. 아무 계정 없이 가장 빨리 해보려면: + +- **Netlify Drop**: [app.netlify.com/drop](https://app.netlify.com/drop)에 `site` 폴더를 그대로 드래그 앤 드롭 → 즉시 URL 발급 (계정 없이도 되지만, 계속 쓰려면 무료 계정 연결 권장) + +계속 쓸 주소를 원하면: + +- **GitHub Pages**: GitHub 저장소에 `site` 폴더 내용을 올리고 저장소 설정에서 Pages를 켜면 `https://<사용자명>.github.io/<저장소명>/` 형태의 고정 주소가 생김 +- **Cloudflare Pages / Vercel**: 같은 방식으로 `site` 폴더 하나만 배포하면 됨 (별도 빌드 설정 불필요, 정적 HTML 파일 1개) + +## 3단계 — QR 코드 만들기 + +호스팅에서 받은 주소(구글 도메인이 아닌 주소)로 QR 코드를 생성해 인쇄 후 교실 입구에 부착하세요. +- Chrome 주소창 오른쪽 **공유 아이콘 → "QR 코드 만들기"** 기능을 쓰면 별도 사이트 없이 바로 생성됩니다. +- 원하는 QR 생성 사이트/앱을 사용해도 무방합니다. + +이제 QR을 찍으면 곧바로 `site/index.html` 페이지가 열리고(구글 계정과 무관), 제출 시에만 뒤에서 조용히 Apps Script API를 호출합니다. + +## 대리서명 확인 방법 + +- **입실기기ID/퇴실기기ID** 열(기본 숨김)이 같은데 **이름**이 다른 경우, 같은 기기(브라우저)에서 여러 사람 이름으로 서명이 이뤄진 것이므로 대리서명 의심 대상입니다. 확인할 때만 열을 펼쳐서(선택 후 우클릭 → 열 표시) 기기ID 기준으로 정렬하거나 조건부 서식으로 중복을 표시하면 됩니다. +- **입실기기정보/퇴실기기정보** 열로 기종·브라우저가 같은 서명이 몰려 있는지 함께 확인하세요. +- 기기ID는 브라우저의 localStorage에 저장되는 값이라, 시크릿 모드나 브라우저 데이터 삭제 시 매번 새로 생성됩니다. 완벽한 기기 식별은 아니고 보조 지표로만 활용하세요. + +## 피벗테이블로 일자별 등교/하교 시간 보기 + +1. `출석부` 시트에서 데이터 범위를 선택하고 **삽입 → 피벗 테이블** +2. 행: `날짜` (필요하면 `이름`도 추가) +3. 값: `입실시각`, `퇴실시각` — 요약 방식은 "없음"(그대로 표시)이나 "최소"/"최대" 어느 걸 골라도 결과가 같습니다 (한 학생·한 날짜에 이미 값이 하나뿐이기 때문입니다). + +## 코드 수정 시 주의 + +- `Code.gs`를 수정한 뒤에는 **배포 → 배포 관리 → 수정(연필 아이콘) → 버전: 새 버전 → 배포**를 해야 반영됩니다. (같은 URL이 유지되므로 `site/index.html`의 `EXEC_URL`은 그대로 둬도 됩니다.) +- `site/index.html`을 수정한 뒤에는 호스팅에 다시 올려야 반영됩니다 (GitHub Pages는 push, Netlify Drop은 다시 드래그). + +## 자동 생성되는 것들 + +첫 제출이 들어오면 자동으로: +- 스프레드시트에 `출석부` 시트와 헤더가 생성됩니다. +- 내 드라이브에 `출석_서명이미지` 폴더가 생성되고, 서명 PNG 파일들이 여기에 저장됩니다 (시트 셀에는 이 이미지가 바로 삽입되어 보입니다). diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..656f4e1 --- /dev/null +++ b/site/index.html @@ -0,0 +1,234 @@ + + + + + +자율학습 출석 서명 + + + +
+ +

자율학습 출석 서명

+ + +
+ + +
+ + + + + + +
+ + +
+
손가락 또는 마우스로 직접 서명해주세요.
+ + +
+
+ + + +