โค้ดที่แชร์
โปรแกรมจัดการวงแชร์
ทีม EasyGASPRO· แชร์เมื่อ 13 ต.ค. 69
ต้นฉบับวางหน้าเว็บบน GitHub Pagesโคลนไปแล้วเผยแพร่บน Google ได้ฟรี ไม่ต้องใช้ Pro
เว็บแอปสมุดแชร์สำหรับวงแชร์แบบดอกตาม สมาชิกเปิดลิงก์ดูไทม์ไลน์งวด สถิติ และจำลองกำไรขาดทุนได้ โดยข้อมูลส่วนตัวเก็บไว้ในเครื่องของแต่ละคน เหมาะกับท้าวแชร์ที่อยากเปิดข้อมูลวงให้ลูกวงดูได้อย่างโปร่งใส หลังโคลนและเผยแพร่ ระบบจะสร้างชีตข้อมูลให้เอง จากนั้นเปิดชีต Admin เพื่อดูรหัสตั้งค่า แล้วนำมาตั้ง PIN ท้าวแชร์ในหน้าเว็บ ก่อนกรอกข้อมูลวงและบันทึกงวด
โค้ดที่แชร์ · โปรแกรมจัดการวงแชร์5 ไฟล์ · 195.7 KB · เวอร์ชัน 1 · สแกนแล้วไม่พบคีย์ส่วนตัว · โคลนแล้ว 2 ครั้ง
easygaside.tech/s/4myhnu5draไฟล์Code.gs799 บรรทัดIndex.html386 บรรทัดJavaScript.html2069 บรรทัดStylesheet.html241 บรรทัดappsscript.json14 บรรทัด
สิทธิ์ที่ Google จะถามตอนเผยแพร่อ่าน/เขียน Google Sheets (spreadsheets)ไฟล์ที่แอปสร้างเองใน Drive (drive.file)Sheets
โดย ทีม EasyGASPRO
ไฟล์ทั้งหมด
อ่านก่อนโคลนได้ ใน EasyGAS IDE จะได้ไฟล์ชุดนี้เป็นโปรเจกต์ใหม่ แล้วค่อยเผยแพร่เข้าบัญชี Google ของคุณ
1/**2 * สมุดแชร์ — เว็บกลางของวงแชร์ (ดอกตาม)3 * - ทุกคนที่มีลิงก์ดูข้อมูลวงได้ (อ่านอย่างเดียว)4 * - แก้ข้อมูลวงได้เฉพาะท้าวแชร์ที่ใส่ PIN ถูก (session token มีวันหมดอายุ)5 * - ข้อมูลส่วนตัวของสมาชิก (ชื่อ, จำนวนมือ, แผนจำลอง) เก็บใน localStorage ของแต่ละเครื่อง ไม่ส่งมาที่นี่6 *7 * กติกาดอกตาม: มือที่ยังไม่เปียจ่ายงวดละ "เงินต่อมือ", มือที่เปียแล้วจ่าย "เงินต่อมือ + ดอกที่ตัวเองใส่"8 * ผู้เปียงวด r ได้รับ = (จำนวนมือ - 1) × เงินต่อมือ + ผลรวมดอกของงวดก่อนหน้า9 */1011var APP_NAME = 'สมุดแชร์ของฉัน';12var TZ = 'Asia/Bangkok';13var MAX_TOTAL_HANDS = 100;14var MAX_SHRINK_ROWS = 5; // ลดจำนวนมือแล้วลบแถวงวดเกินนี้ต้องยืนยันจำนวนเป๊ะ15var PIN_ITERATIONS = 10000; // PBKDF2-style16var SESSION_DAYS = 30;17var MAX_PIN_FAILS = 5;18var PIN_LOCK_MINUTES = 10;1920var SHEET_DEFS = {21 settings: { name: 'Settings', headers: ['key', 'value'], textCols: [1, 2] },22 // คอลัมน์ myHand (5) เลิกใช้แล้ว — คงไว้เพื่อไม่ต้องย้ายข้อมูลเดิม23 rounds: { name: 'Rounds', headers: ['round', 'date', 'winner', 'bid', 'myHand', 'updatedAt'], textCols: [2, 3, 6] },24 sessions: { name: 'Sessions', headers: ['tokenHash', 'expiresAt', 'createdAt'], textCols: [1, 3] },25 admin: { name: 'Admin', headers: ['รายการ', 'ค่า'], textCols: [1, 2] }26};2728var DEFAULT_SETTINGS = {29 circleName: 'วงแชร์ของฉัน',30 totalHands: 26,31 handAmount: 1000,32 minBid: 150,33 frequency: 'monthly',34 day1: 1,35 day2: 16,36 startDate: '',37 configured: false38};3940// ---------------------------------------------------------------- web app4142function doGet(e) {43 var __egsDiag = e && e.parameter && e.parameter.__egsdiag === 'egsverify';44 var __egsLogs = [];45 if (__egsDiag) {46 try {47 var __ol = Logger.log;48 Logger.log = function (m) { try { __egsLogs.push(String(m)); } catch (_) {} return __ol.apply(Logger, arguments); };49 } catch (_) {}50 }51 try {52 try {53 ensureSheets_();54 Logger.log('doGet: sheets ready');55 } catch (setupErr) {56 Logger.log('doGet: setup skipped — ' + (setupErr && setupErr.stack ? setupErr.stack : setupErr));57 }58 var html = HtmlService.createTemplateFromFile('Index').evaluate()59 .setTitle(APP_NAME)60 .addMetaTag('viewport', 'width=device-width, initial-scale=1.0, viewport-fit=cover')61 .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);62 Logger.log('doGet: page rendered');63 return html;64 } catch (err) {65 if (__egsDiag)66 return ContentService.createTextOutput(67 'EGS_ERROR: ' + (err && err.stack ? err.stack : err) + '\nEGS_LOGS: ' + JSON.stringify(__egsLogs)68 ).setMimeType(ContentService.MimeType.TEXT);69 return HtmlService.createHtmlOutput('<p style="font-family:sans-serif;padding:16px">ขออภัย เกิดข้อผิดพลาด ลองใหม่อีกครั้ง</p>');70 }71}7273function include(filename) {74 return HtmlService.createHtmlOutputFromFile(filename).getContent();75}7677// ---------------------------------------------------------------- public API: อ่าน (ทุกคน)7879/** token ไม่บังคับ — ถ้าเป็น token ท้าวที่ยังไม่หมดอายุ จะได้ isAdmin = true และลิงก์ Sheet */80function getBundle(token) {81 try {82 var sh = ensureSheets_();83 var b = bundle_(sh, isAdminToken_(sh, token));84 Logger.log('getBundle: rounds=' + b.rounds.length + ' admin=' + b.isAdmin);85 return b;86 } catch (err) {87 Logger.log('[getBundle] ' + (err.stack || err));88 return { ok: false, message: friendly_(err) };89 }90}9192// ---------------------------------------------------------------- public API: PIN ท้าวแชร์9394function adminLogin(pin) {95 try {96 var p = String(pin == null ? '' : pin);97 if (!p || p.length > 20) throw new Error('กรุณาใส่ PIN');98 var sh = ensureSheets_();99 var props = PropertiesService.getScriptProperties();100 if (!props.getProperty('ADMIN_PIN_HASH')) throw new Error('ยังไม่ได้ตั้ง PIN ท้าวแชร์ กรุณาตั้ง PIN ก่อน');101 var lock = acquireLock_();102 try {103 checkPinLock_();104 var ok = hashPin_(p, props.getProperty('ADMIN_PIN_SALT')) === props.getProperty('ADMIN_PIN_HASH');105 if (!ok) { recordPinFail_(); throw new Error('PIN ไม่ถูกต้อง'); }106 resetPinFails_();107 var s = createSession_(sh);108 Logger.log('adminLogin: success');109 return { ok: true, token: s.token, expiresAt: s.expiresAt, message: 'เข้าโหมดท้าวแชร์แล้ว' };110 } finally {111 lock.releaseLock();112 }113 } catch (err) {114 Logger.log('[adminLogin] ' + (err.message || err));115 return { ok: false, message: friendly_(err) };116 }117}118119/** ตั้ง PIN ครั้งแรก หรือรีเซ็ตเมื่อลืม — ต้องใช้ "รหัสตั้งค่า" ที่อยู่ในชีต Admin (เจ้าของ Sheet เท่านั้นที่เปิดได้) */120function setAdminPin(setupCode, newPin) {121 try {122 var pin = validatePin_(newPin);123 var code = String(setupCode == null ? '' : setupCode).trim().toUpperCase();124 if (!code || code.length > 20) throw new Error('กรุณาใส่รหัสตั้งค่า');125 var sh = ensureSheets_();126 var lock = acquireLock_();127 try {128 checkPinLock_();129 var current = getSetupCode_(sh);130 if (code !== current) { recordPinFail_(); throw new Error('รหัสตั้งค่าไม่ถูกต้อง'); }131 resetPinFails_();132 storePin_(pin);133 rotateSetupCode_(sh);134 clearSessions_(sh);135 var s = createSession_(sh);136 Logger.log('setAdminPin: pin set, setup code rotated');137 return { ok: true, token: s.token, expiresAt: s.expiresAt, message: 'ตั้ง PIN ท้าวแชร์แล้ว' };138 } finally {139 lock.releaseLock();140 }141 } catch (err) {142 Logger.log('[setAdminPin] ' + (err.message || err));143 return { ok: false, message: friendly_(err) };144 }145}146147function changeAdminPin(token, currentPin, newPin) {148 try {149 var pin = validatePin_(newPin);150 var sh = ensureSheets_();151 var props = PropertiesService.getScriptProperties();152 var lock = acquireLock_();153 try {154 requireAdmin_(sh, token);155 checkPinLock_();156 if (hashPin_(String(currentPin || ''), props.getProperty('ADMIN_PIN_SALT')) !== props.getProperty('ADMIN_PIN_HASH')) {157 recordPinFail_();158 throw new Error('PIN ปัจจุบันไม่ถูกต้อง');159 }160 resetPinFails_();161 storePin_(pin);162 clearSessions_(sh);163 var s = createSession_(sh);164 Logger.log('changeAdminPin: done, other sessions revoked');165 return { ok: true, token: s.token, expiresAt: s.expiresAt, message: 'เปลี่ยน PIN แล้ว เครื่องอื่นที่เคยเข้าโหมดท้าวต้องใส่ PIN ใหม่' };166 } finally {167 lock.releaseLock();168 }169 } catch (err) {170 Logger.log('[changeAdminPin] ' + (err.message || err));171 return authError_(err);172 }173}174175function adminLogout(token) {176 try {177 var sh = ensureSheets_();178 var lock = acquireLock_();179 try {180 deleteSession_(sh, token);181 } finally {182 lock.releaseLock();183 }184 return { ok: true, message: 'ออกจากโหมดท้าวแชร์แล้ว' };185 } catch (err) {186 Logger.log('[adminLogout] ' + (err.stack || err));187 return { ok: false, message: friendly_(err) };188 }189}190191// ---------------------------------------------------------------- public API: เขียน (ท้าวแชร์เท่านั้น)192193function saveSettings(token, payload) {194 try {195 var clean = validateSettings_(payload);196 var sh = ensureSheets_();197 var lock = acquireLock_();198 try {199 requireAdmin_(sh, token);200 if (!revMatches_(payload.rev)) return conflict_(sh);201 var old = readRounds_(sh.rounds);202 var byRound = {};203 old.forEach(function (r) {204 byRound[r.round] = r;205 if (r.winner && r.round > clean.settings.totalHands)206 throw new Error('งวดที่ ' + r.round + ' บันทึกผลไว้แล้ว ลดจำนวนมือรวมให้น้อยกว่านี้ไม่ได้');207 });208 // กันลบยกชุด: ลดจำนวนมือแล้วแถวหายเกิน MAX_SHRINK_ROWS ต้องยืนยันด้วยจำนวนแถวที่ตรงกันเป๊ะ209 var removeCount = Math.max(0, old.length - clean.settings.totalHands);210 if (removeCount > MAX_SHRINK_ROWS && Number(payload.confirmShrink) !== removeCount) {211 Logger.log('saveSettings: shrink blocked, remove=' + removeCount);212 return {213 ok: false, needConfirmShrink: true, removeCount: removeCount,214 message: 'การลดจำนวนมือครั้งนี้จะลบตารางงวด ' + removeCount + ' งวด (งวด ' + (clean.settings.totalHands + 1) + '–' + old.length + ')'215 };216 }217 var now = nowStr_();218 var rounds = [];219 for (var i = 1; i <= clean.settings.totalHands; i++) {220 var o = byRound[i] || { winner: '', bid: null, updatedAt: '' };221 rounds.push({ round: i, date: clean.dates[i - 1], winner: o.winner, bid: o.bid, updatedAt: o.updatedAt });222 }223 // ผลงวดแรกแก้ได้เฉพาะเมื่อฟอร์มส่งฟิลด์นี้มาจริง224 if (clean.hasFirst) {225 if (clean.firstWinner && clean.dates[0] > todayYmd_())226 throw new Error('งวดแรกยังไม่ถึงวันเปีย ปิดสวิตช์ "งวดแรกเปียไปแล้ว" ไว้ก่อน แล้วบันทึกผลเมื่อถึงวัน');227 if (!clean.firstWinner && byRound[1] && byRound[1].winner && old.some(function (r) { return r.round > 1 && r.winner; }))228 throw new Error('ล้างผลงวดแรกไม่ได้ เพราะงวดหลังบันทึกผลแล้ว');229 var r1 = rounds[0];230 r1.winner = clean.firstWinner;231 r1.bid = clean.firstWinner ? clean.firstBid : null;232 r1.updatedAt = now;233 }234 assertResultsKept_(old, rounds, clean.hasFirst ? [1] : []);235 bumpRev_();236 writeSettings_(sh.settings, clean.settings);237 writeRounds_(sh.rounds, rounds);238 Logger.log('saveSettings: wrote ' + rounds.length + ' rounds, removed ' + removeCount);239 } finally {240 lock.releaseLock();241 }242 return afterCommit_(sh, true, { message: 'บันทึกการตั้งค่าแล้ว' });243 } catch (err) {244 Logger.log('[saveSettings] ' + (err.stack || err));245 return authError_(err);246 }247}248249/** บันทึก/แก้ไขงวดเดียว — winner ว่าง = งวดนี้ยังไม่มีผู้เปีย (แก้แค่วันที่) */250function saveRound(token, payload) {251 try {252 if (!payload || typeof payload !== 'object') throw new Error('ข้อมูลไม่ถูกต้อง');253 var date = String(payload.date || '').trim();254 if (!isYmd_(date)) throw new Error('วันที่ไม่ถูกต้อง');255 var sh = ensureSheets_();256 var lock = acquireLock_();257 try {258 requireAdmin_(sh, token);259 if (!revMatches_(payload.rev)) return conflict_(sh);260 var s = readSettings_(sh.settings);261 if (!s.configured) throw new Error('กรุณาตั้งค่าวงแชร์ก่อน');262 var old = readRounds_(sh.rounds);263 var rounds = JSON.parse(JSON.stringify(old));264 var n = intIn_(payload.round, 1, s.totalHands, 'งวด');265 var target = null;266 rounds.forEach(function (r) { if (r.round === n) target = r; });267 if (!target) throw new Error('ไม่พบงวดที่ ' + n);268269 var winner = cleanText_(payload.winner, 40);270 var bid = null;271 if (winner) {272 bid = Number(payload.bid);273 if (payload.bid === '' || payload.bid == null || !isFinite(bid) || bid < 0 || bid > s.handAmount * 10)274 throw new Error('ดอกของงวดที่ ' + n + ' ไม่ถูกต้อง');275 bid = round2_(bid);276 }277 // บันทึกผลได้เมื่อถึงวันเปียแล้ว และงวดก่อนหน้าต้องมีผลครบ / ล้างผลได้เมื่องวดหลังยังไม่มีผล278 if (winner) {279 if (date > todayYmd_()) throw new Error('งวดที่ ' + n + ' ยังไม่ถึงวันเปีย บันทึกผลได้เมื่อถึงวัน (เลื่อนวันเปียได้)');280 rounds.forEach(function (r) {281 if (r.round < n && !r.winner) throw new Error('ต้องบันทึกผลงวดที่ ' + r.round + ' ก่อน จึงจะบันทึกงวดที่ ' + n + ' ได้');282 });283 } else if (target.winner) {284 rounds.forEach(function (r) {285 if (r.round > n && r.winner) throw new Error('ล้างผลงวดที่ ' + n + ' ไม่ได้ เพราะงวดที่ ' + r.round + ' บันทึกผลแล้ว');286 });287 }288 var delta = target.date ? diffDays_(target.date, date) : 0;289 target.date = date;290 target.winner = winner;291 target.bid = bid;292 target.updatedAt = nowStr_();293 if (payload.shiftNext === true && delta !== 0) {294 rounds.forEach(function (r) { if (r.round > n && r.date) r.date = addDays_(r.date, delta); });295 }296 // แก้ได้แค่ผลของงวดนี้งวดเดียว — งวดอื่นต้องคงผลเดิมทุกตัว และจำนวนแถวต้องเท่าเดิม297 if (rounds.length !== old.length) throw new Error('ข้อมูลงวดไม่ครบ กรุณาโหลดหน้าใหม่');298 assertResultsKept_(old, rounds, [n]);299 bumpRev_();300 writeRounds_(sh.rounds, rounds);301 Logger.log('saveRound: round ' + n + ' winner=' + (winner ? 'set' : 'none') + ' shift=' + delta);302 } finally {303 lock.releaseLock();304 }305 return afterCommit_(sh, true, { message: 'บันทึกงวดแล้ว' });306 } catch (err) {307 Logger.log('[saveRound] ' + (err.stack || err));308 return authError_(err);309 }310}311312// ---------------------------------------------------------------- spreadsheet provisioning313314function getDataSpreadsheet_() {315 var props = PropertiesService.getScriptProperties();316 var id = props.getProperty('DATA_SS_ID');317 if (id) return SpreadsheetApp.openById(id);318 var lock = LockService.getScriptLock();319 lock.waitLock(30000);320 try {321 id = props.getProperty('DATA_SS_ID');322 if (id) return SpreadsheetApp.openById(id);323 var ss = SpreadsheetApp.create(APP_NAME + ' Data');324 props.setProperty('DATA_SS_ID', ss.getId());325 Logger.log('getDataSpreadsheet_: created ' + ss.getId());326 return ss;327 } finally {328 lock.releaseLock();329 }330}331332function setupSheet_(ss, def) {333 var sheet = ss.getSheetByName(def.name);334 if (sheet) return sheet;335 var lock = LockService.getScriptLock();336 lock.waitLock(30000);337 try {338 sheet = ss.getSheetByName(def.name);339 if (!sheet) {340 var all = ss.getSheets();341 sheet = (all.length === 1 && all[0].getLastRow() === 0) ? all[0].setName(def.name) : ss.insertSheet(def.name);342 def.textCols.forEach(function (c) { sheet.getRange(1, c, 1000, 1).setNumberFormat('@'); });343 sheet.appendRow(def.headers);344 sheet.setFrozenRows(1);345 sheet.getRange(1, 1, 1, def.headers.length).setFontWeight('bold');346 }347 return sheet;348 } finally {349 lock.releaseLock();350 }351}352353/** เรียกก่อนจับ lock เสมอ (setupSheet_ จับ lock ของตัวเอง) */354function ensureSheets_() {355 var ss = getDataSpreadsheet_();356 var sh = {357 ss: ss,358 settings: setupSheet_(ss, SHEET_DEFS.settings),359 rounds: setupSheet_(ss, SHEET_DEFS.rounds),360 sessions: setupSheet_(ss, SHEET_DEFS.sessions),361 admin: setupSheet_(ss, SHEET_DEFS.admin)362 };363 ensureSetupCode_(sh);364 return sh;365}366367/** ข้อมูลที่ส่งให้ทุกคน — ไม่มีความลับ; ลิงก์ Sheet ให้เฉพาะท้าวแชร์ */368function bundle_(sh, isAdmin) {369 var props = PropertiesService.getScriptProperties();370 return {371 ok: true,372 rev: getRev_(),373 isAdmin: !!isAdmin,374 adminSet: !!props.getProperty('ADMIN_PIN_HASH'),375 settings: readSettings_(sh.settings),376 rounds: readRounds_(sh.rounds).map(function (r) {377 return { round: r.round, date: r.date, winner: r.winner, bid: r.bid };378 }),379 today: todayYmd_(),380 sheetUrl: isAdmin ? sh.ss.getUrl() : ''381 };382}383384/** หลังเขียนสำเร็จแล้ว ถ้าอ่านข้อมูลกลับไม่ได้ ต้องไม่บอกผู้ใช้ว่าบันทึกล้มเหลว */385function afterCommit_(sh, isAdmin, extra) {386 var b;387 try {388 b = bundle_(sh, isAdmin);389 } catch (e) {390 Logger.log('[afterCommit_] reload failed after commit: ' + (e.stack || e));391 b = { ok: true, needReload: true };392 }393 Object.keys(extra || {}).forEach(function (k) { b[k] = extra[k]; });394 return b;395}396397// ---------------------------------------------------------------- admin session & PIN398399/** token ดิบอยู่ที่เครื่องท้าวเท่านั้น — ใน Sheet เก็บแค่ hash + วันหมดอายุ */400function createSession_(sh) {401 var token = Utilities.getUuid() + Utilities.getUuid();402 var expiresAt = Date.now() + SESSION_DAYS * 86400000;403 pruneSessions_(sh);404 sh.sessions.appendRow([sha256_(token), expiresAt, nowStr_()]);405 SpreadsheetApp.flush();406 return { token: token, expiresAt: expiresAt };407}408409function isAdminToken_(sh, token) {410 var t = String(token == null ? '' : token);411 if (!t || t.length > 100) return false;412 var h = sha256_(t);413 var last = sh.sessions.getLastRow();414 if (last < 2) return false;415 var now = Date.now();416 return sh.sessions.getRange(2, 1, last - 1, 2).getValues().some(function (r) {417 return String(r[0]) === h && Number(r[1]) > now;418 });419}420421function requireAdmin_(sh, token) {422 if (!isAdminToken_(sh, token)) {423 var e = new Error('สิทธิ์ท้าวแชร์หมดอายุหรือไม่ถูกต้อง กรุณาใส่ PIN ใหม่');424 e.authExpired = true;425 throw e;426 }427}428429function deleteSession_(sh, token) {430 var t = String(token == null ? '' : token);431 if (!t) return;432 var h = sha256_(t);433 var last = sh.sessions.getLastRow();434 if (last < 2) return;435 var vals = sh.sessions.getRange(2, 1, last - 1, 1).getValues();436 for (var i = vals.length - 1; i >= 0; i--) {437 if (String(vals[i][0]) === h) sh.sessions.deleteRow(i + 2);438 }439 SpreadsheetApp.flush();440}441442function pruneSessions_(sh) {443 var last = sh.sessions.getLastRow();444 if (last < 2) return;445 var vals = sh.sessions.getRange(2, 1, last - 1, 2).getValues();446 var now = Date.now();447 for (var i = vals.length - 1; i >= 0; i--) {448 if (!(Number(vals[i][1]) > now)) sh.sessions.deleteRow(i + 2);449 }450}451452function clearSessions_(sh) {453 var last = sh.sessions.getLastRow();454 if (last >= 2) sh.sessions.getRange(2, 1, last - 1, 3).clearContent();455 SpreadsheetApp.flush();456}457458function validatePin_(pin) {459 var p = String(pin == null ? '' : pin);460 if (!/^\d{4,12}$/.test(p)) throw new Error('PIN ต้องเป็นตัวเลข 4–12 หลัก');461 return p;462}463464function storePin_(pin) {465 var props = PropertiesService.getScriptProperties();466 var salt = Utilities.getUuid();467 props.setProperties({ ADMIN_PIN_SALT: salt, ADMIN_PIN_HASH: hashPin_(pin, salt) });468}469470/** HMAC-SHA256 ซ้ำหลายรอบ + salt ต่อ PIN + pepper ใน Script Properties (ไม่อยู่ใน Sheet) */471function hashPin_(pin, salt) {472 var bytes = Utilities.newBlob(String(pin) + getPepper_()).getBytes();473 var saltBytes = Utilities.newBlob(String(salt || '')).getBytes();474 for (var i = 0; i < PIN_ITERATIONS; i++) bytes = Utilities.computeHmacSha256Signature(bytes, saltBytes);475 return Utilities.base64Encode(bytes);476}477478function getPepper_() {479 var props = PropertiesService.getScriptProperties();480 var p = props.getProperty('PIN_PEPPER');481 if (!p) { p = Utilities.getUuid() + Utilities.getUuid(); props.setProperty('PIN_PEPPER', p); }482 return p;483}484485/** hex เพื่อให้เก็บในชีตได้ปลอดภัย (ไม่มี + / = นำหน้าแบบ base64) */486function sha256_(s) {487 return Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, String(s), Utilities.Charset.UTF_8)488 .map(function (b) { return ('0' + (b & 0xff).toString(16)).slice(-2); }).join('');489}490491function checkPinLock_() {492 var st = readPinFails_();493 if (st.until > Date.now()) {494 var mins = Math.ceil((st.until - Date.now()) / 60000);495 throw new Error('ใส่ PIN/รหัสผิดหลายครั้ง กรุณารออีก ' + mins + ' นาที');496 }497}498499function readPinFails_() {500 try { return JSON.parse(PropertiesService.getScriptProperties().getProperty('PIN_FAILS') || '{"n":0,"until":0}'); }501 catch (e) { return { n: 0, until: 0 }; }502}503504function recordPinFail_() {505 var st = readPinFails_();506 st.n = (st.n || 0) + 1;507 if (st.n >= MAX_PIN_FAILS) { st.until = Date.now() + PIN_LOCK_MINUTES * 60000; st.n = 0; }508 PropertiesService.getScriptProperties().setProperty('PIN_FAILS', JSON.stringify(st));509 Logger.log('pin fail recorded');510}511512function resetPinFails_() {513 PropertiesService.getScriptProperties().deleteProperty('PIN_FAILS');514}515516// ---------------------------------------------------------------- setup code (ชีต Admin)517518function newSetupCode_() {519 return Utilities.getUuid().replace(/-/g, '').slice(0, 8).toUpperCase();520}521522/** สร้างรหัสตั้งค่าครั้งแรก — เจ้าของเปิดดูได้ในชีต Admin ของ Google Sheet ใน Drive */523function ensureSetupCode_(sh) {524 if (sh.admin.getLastRow() >= 2 && String(sh.admin.getRange(2, 2).getDisplayValue()).trim()) return;525 var lock = LockService.getScriptLock();526 lock.waitLock(30000);527 try {528 if (sh.admin.getLastRow() >= 2 && String(sh.admin.getRange(2, 2).getDisplayValue()).trim()) return;529 writeSetupCode_(sh, newSetupCode_());530 } finally {531 lock.releaseLock();532 }533}534535function writeSetupCode_(sh, code) {536 sh.admin.getRange(2, 1, 3, 2).setNumberFormat('@').setValues([537 ['รหัสตั้งค่า (ใช้ตั้ง/รีเซ็ต PIN ท้าวแชร์)', code],538 ['วิธีใช้', 'ในเว็บกดปุ่มกุญแจ → ตั้ง PIN / ลืม PIN → ใส่รหัสนี้ (รหัสเปลี่ยนใหม่ทุกครั้งที่ใช้)'],539 ['คำเตือน', 'ห้ามแชร์ไฟล์ Sheet นี้ให้คนอื่น']540 ]);541 SpreadsheetApp.flush();542}543544function getSetupCode_(sh) {545 return String(sh.admin.getRange(2, 2).getDisplayValue()).trim().toUpperCase();546}547548function rotateSetupCode_(sh) {549 writeSetupCode_(sh, newSetupCode_());550}551552// ---------------------------------------------------------------- lock & data version553554function acquireLock_() {555 var lock = LockService.getScriptLock();556 if (!lock.tryLock(10000)) throw new Error('ระบบกำลังบันทึกข้อมูลอื่นอยู่ กรุณาลองใหม่ในอีกสักครู่');557 return lock;558}559560function getRev_() {561 return Number(PropertiesService.getScriptProperties().getProperty('DATA_REV')) || 0;562}563564function bumpRev_() {565 var r = getRev_() + 1;566 PropertiesService.getScriptProperties().setProperty('DATA_REV', String(r));567 return r;568}569570/** ไม่ส่ง rev มา = ไม่รู้ว่าข้อมูลในหน้าเว็บเก่าแค่ไหน → ไม่ยอมให้เขียน */571function revMatches_(rev) {572 if (rev === undefined || rev === null || rev === '') return false;573 var n = Number(rev);574 return isFinite(n) && n === getRev_();575}576577function conflict_(sh) {578 Logger.log('conflict: client rev is stale, current=' + getRev_());579 return {580 ok: false,581 conflict: true,582 message: 'ข้อมูลถูกแก้จากหน้าอื่นระหว่างนี้ ระบบโหลดข้อมูลล่าสุดให้แล้ว กรุณาตรวจแล้วบันทึกอีกครั้ง',583 bundle: bundle_(sh, true)584 };585}586587// ---------------------------------------------------------------- read / write588589function readSettings_(sheet) {590 var raw = {};591 var last = sheet.getLastRow();592 if (last >= 2) {593 sheet.getRange(2, 1, last - 1, 2).getDisplayValues().forEach(function (r) {594 var k = String(r[0]).trim();595 if (k && DEFAULT_SETTINGS.hasOwnProperty(k)) raw[k] = r[1];596 });597 }598 var d = DEFAULT_SETTINGS;599 return {600 circleName: String(raw.circleName || d.circleName),601 totalHands: numOr_(raw.totalHands, d.totalHands),602 handAmount: numOr_(raw.handAmount, d.handAmount),603 minBid: numOr_(raw.minBid, d.minBid),604 frequency: raw.frequency === 'twice' ? 'twice' : 'monthly',605 day1: numOr_(raw.day1, d.day1),606 day2: numOr_(raw.day2, d.day2),607 startDate: isYmd_(String(raw.startDate || '')) ? String(raw.startDate) : todayYmd_(),608 configured: String(raw.configured) === 'true'609 };610}611612function writeSettings_(sheet, s) {613 var rows = Object.keys(DEFAULT_SETTINGS).map(function (k) {614 return [k, guardFormula_(String(s[k] == null ? '' : s[k]))];615 });616 var last = sheet.getLastRow();617 var range = sheet.getRange(2, 1, rows.length, 2);618 range.setNumberFormat('@');619 range.setValues(rows);620 if (last > rows.length + 1) sheet.getRange(rows.length + 2, 1, last - rows.length - 1, 2).clearContent();621 SpreadsheetApp.flush();622}623624function readRounds_(sheet) {625 var last = sheet.getLastRow();626 if (last < 2) return [];627 var vals = sheet.getRange(2, 1, last - 1, 6).getValues();628 var out = [];629 vals.forEach(function (r) {630 var n = Number(r[0]);631 if (!n || n < 1 || Math.floor(n) !== n) return;632 var winner = String(r[2] == null ? '' : r[2]).trim();633 out.push({634 round: n,635 date: normYmd_(r[1]),636 winner: winner,637 bid: winner ? numOr_(r[3], 0) : null,638 updatedAt: r[5] instanceof Date ? Utilities.formatDate(r[5], TZ, 'yyyy-MM-dd HH:mm:ss') : String(r[5] || '')639 });640 });641 out.sort(function (a, b) { return a.round - b.round; });642 return out;643}644645/** เขียนทับตารางงวดทั้งชุด (เรียกภายใต้ lock เท่านั้น) — เขียนก่อนแล้วค่อยล้างแถวเกิน */646function writeRounds_(sheet, rounds) {647 var last = sheet.getLastRow();648 var n = rounds.length;649 if (n) {650 sheet.getRange(2, 2, n, 2).setNumberFormat('@');651 sheet.getRange(2, 6, n, 1).setNumberFormat('@');652 var data = rounds.map(function (r) {653 return [r.round, r.date, guardFormula_(r.winner || ''), r.winner ? r.bid : '', '', r.updatedAt || ''];654 });655 sheet.getRange(2, 1, n, 6).setValues(data);656 }657 if (last > n + 1) sheet.getRange(n + 2, 1, last - n - 1, 6).clearContent();658 SpreadsheetApp.flush();659}660661// ---------------------------------------------------------------- validation662663function validateSettings_(p) {664 if (!p || typeof p !== 'object') throw new Error('ข้อมูลไม่ถูกต้อง');665 var circleName = cleanText_(p.circleName, 60);666 if (!circleName) throw new Error('กรุณาตั้งชื่อวง');667 var totalHands = intIn_(p.totalHands, 2, MAX_TOTAL_HANDS, 'จำนวนมือรวม');668 var handAmount = Number(p.handAmount);669 if (!isFinite(handAmount) || handAmount <= 0 || handAmount > 10000000) throw new Error('เงินต่อมือไม่ถูกต้อง');670 var minBid = Number(p.minBid);671 if (!isFinite(minBid) || minBid < 0 || minBid > handAmount) throw new Error('ดอกขั้นต่ำต้องอยู่ระหว่าง 0 ถึงเงินต่อมือ');672 var frequency = p.frequency === 'twice' ? 'twice' : 'monthly';673 var day1 = intIn_(p.day1, 1, 31, 'วันเปีย');674 var day2 = frequency === 'twice' ? intIn_(p.day2, 1, 31, 'วันเปียครั้งที่ 2') : numOr_(p.day2, 16);675 if (frequency === 'twice' && day1 === day2) throw new Error('วันเปียทั้ง 2 ครั้งต้องไม่ซ้ำกัน');676 var startDate = String(p.startDate || '').trim();677 if (!isYmd_(startDate)) throw new Error('วันเปียงวดแรกไม่ถูกต้อง');678 var dates = Array.isArray(p.dates) ? p.dates.map(function (x) { return String(x || '').trim(); }) : [];679 if (dates.length !== totalHands) throw new Error('ตารางวันที่ต้องมีครบ ' + totalHands + ' งวด');680 dates.forEach(function (x, i) { if (!isYmd_(x)) throw new Error('วันที่ของงวดที่ ' + (i + 1) + ' ไม่ถูกต้อง'); });681 var firstWinner = cleanText_(p.firstWinner, 40);682 var firstBid = Number(p.firstBid || 0);683 if (!isFinite(firstBid) || firstBid < 0 || firstBid > handAmount * 10) throw new Error('ดอกงวดแรกไม่ถูกต้อง');684 return {685 settings: {686 circleName: circleName, totalHands: totalHands, handAmount: round2_(handAmount), minBid: round2_(minBid),687 frequency: frequency, day1: day1, day2: day2, startDate: startDate, configured: true688 },689 dates: dates,690 hasFirst: Object.prototype.hasOwnProperty.call(p, 'firstWinner'),691 firstWinner: firstWinner,692 firstBid: round2_(firstBid)693 };694}695696/**697 * ด่านสุดท้ายก่อนเขียนตารางงวดทั้งชุด: งวดที่มีผลอยู่แล้วต้องยังอยู่และผลเหมือนเดิมทุกตัว698 * ยกเว้นงวดใน allowRounds (งวดที่ผู้ใช้ตั้งใจแก้ในคำขอนี้) — กันบั๊กที่ทำผลงวดหายยกชุด699 */700function assertResultsKept_(oldRounds, newRounds, allowRounds) {701 var next = {};702 newRounds.forEach(function (r) { next[r.round] = r; });703 var allow = {};704 (allowRounds || []).forEach(function (n) { allow[n] = true; });705 var broken = [];706 oldRounds.forEach(function (o) {707 if (!o.winner || allow[o.round]) return;708 var n = next[o.round];709 if (!n || n.winner !== o.winner || Number(n.bid) !== Number(o.bid)) broken.push(o.round);710 });711 if (broken.length) {712 Logger.log('assertResultsKept_: blocked, rounds=' + broken.join(','));713 throw new Error('ระบบระงับการบันทึก เพราะผลของงวด ' + broken.slice(0, 5).join(', ') + ' จะถูกเปลี่ยนโดยไม่ได้ตั้งใจ กรุณาโหลดหน้าใหม่แล้วลองอีกครั้ง');714 }715}716717// ---------------------------------------------------------------- helpers718719function friendly_(err) {720 var msg = (err && err.message) ? err.message : String(err);721 if (/lock|timed out|timeout/i.test(msg)) return 'ระบบกำลังบันทึกข้อมูลอื่นอยู่ กรุณาลองใหม่ในอีกสักครู่';722 if (/ข้อผิดพลาดของบริการ|Service Spreadsheets|Service error/i.test(msg)) return 'ระบบ Google Sheet ขัดข้องชั่วคราว กรุณาลองใหม่อีกครั้ง';723 if (/execution time/i.test(msg)) return 'ประมวลผลนานเกินกำหนด กรุณาลองใหม่อีกครั้ง';724 if (/permission|authorization/i.test(msg)) return 'แอปยังไม่ได้รับสิทธิ์เข้าถึง Google Sheet กรุณาให้เจ้าของเปิดแอปแล้วกดอนุญาตสิทธิ์อีกครั้ง';725 return msg;726}727728function authError_(err) {729 var out = { ok: false, message: friendly_(err) };730 if (err && err.authExpired) out.authExpired = true;731 return out;732}733734function cleanText_(v, max) {735 return String(v == null ? '' : v).replace(/[\u0000-\u001F\u007F]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);736}737738/** กันสูตร: ข้อความที่ขึ้นต้นด้วย = + - @ จะถูกเก็บเป็นข้อความธรรมดา */739function guardFormula_(s) {740 s = String(s == null ? '' : s);741 return /^[=+\-@]/.test(s) ? "'" + s : s;742}743744function intIn_(v, min, max, label) {745 var n = Number(v);746 if (v === '' || v == null || !isFinite(n) || Math.floor(n) !== n || n < min || n > max)747 throw new Error(label + ' ต้องเป็นจำนวนเต็ม ' + min + '–' + max);748 return n;749}750751function numOr_(v, def) {752 if (v === '' || v == null) return def;753 var n = Number(v);754 return isFinite(n) ? n : def;755}756757function round2_(n) {758 return Math.round(Number(n) * 100) / 100;759}760761function isYmd_(s) {762 var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(s || ''));763 if (!m) return false;764 var d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));765 return d.getUTCFullYear() === +m[1] && d.getUTCMonth() === +m[2] - 1 && d.getUTCDate() === +m[3];766}767768function normYmd_(v) {769 if (v instanceof Date) return isNaN(v.getTime()) ? '' : Utilities.formatDate(v, TZ, 'yyyy-MM-dd');770 var s = String(v == null ? '' : v).trim();771 return isYmd_(s) ? s : '';772}773774function ymdToUtc_(s) {775 var p = s.split('-');776 return Date.UTC(+p[0], +p[1] - 1, +p[2]);777}778779function pad2_(n) {780 return (n < 10 ? '0' : '') + n;781}782783function addDays_(s, days) {784 var d = new Date(ymdToUtc_(s) + days * 86400000);785 return d.getUTCFullYear() + '-' + pad2_(d.getUTCMonth() + 1) + '-' + pad2_(d.getUTCDate());786}787788function diffDays_(a, b) {789 return Math.round((ymdToUtc_(b) - ymdToUtc_(a)) / 86400000);790}791792function todayYmd_() {793 return Utilities.formatDate(new Date(), TZ, 'yyyy-MM-dd');794}795796function nowStr_() {797 return Utilities.formatDate(new Date(), TZ, 'yyyy-MM-dd HH:mm:ss');798}799รีวิว
รีวิวได้หลังโคลนไปใช้ · ทำได้ในโปรแกรม EasyGAS IDE
ยังไม่มีรีวิว
ถามตอบ
ถามคนแชร์ เล่าว่าเอาไปใช้แล้วเป็นยังไง หรือแนบเวอร์ชันต่อยอดของคุณ
ยังไม่มีใครถาม เป็นคนแรกก็ได้