출석부 QR 서명 시스템 초기 커밋
This commit is contained in:
171
Code.gs
Normal file
171
Code.gs
Normal file
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user