EasyGAS IDE

โค้ดที่แชร์

โปรแกรมจัดการวงแชร์

ทีม 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

ยังไม่มีรีวิว

ถามตอบ

ถามคนแชร์ เล่าว่าเอาไปใช้แล้วเป็นยังไง หรือแนบเวอร์ชันต่อยอดของคุณ

ยังไม่มี

ยังไม่มีใครถาม เป็นคนแรกก็ได้

ถามหรือเล่าผลการใช้งาน