172 lines
6.5 KiB
JavaScript
172 lines
6.5 KiB
JavaScript
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 };
|
|
}
|