/** Taiwan AED Phase 1 — deploy as a Google Apps Script Web App. * Script properties: SPREADSHEET_ID, PHOTO_FOLDER_ID, WRITE_TOKEN, ALERT_EMAIL. * No token belongs in GitHub or a public JSON cache. See README.md. */ const SCHEMA = { AED_Master: 'aed_id source_mapping place_name city district address lat lng floor_level location_desc access_type open_hours safe_place_cert safe_place_exp manager_name manager_phone staff_cpr_trained cabinet_status cabinet_buzzer aed_status aed_brand aed_model pediatric_mode pads_exp_date ped_pads_exp_date battery_exp_date accessory_items has_guardian guardian_vendor guardian_query_no guardian_contract_no guardian_0800 photo_env_url photo_closeup_url last_verified_at last_verified_by'.split(' '), Inspection_Logs: 'log_id timestamp aed_id inspector verify_result cabinet_status cabinet_buzzer aed_status notes photo_env_url photo_closeup_url'.split(' '), Guardian_Test_Logs: 'test_id timestamp aed_id inspector guardian_vendor guardian_query_no call_center_status operator_id callback_required carry_case_zipper status_indicator power_button_test pad_flag_visible pad_package_intact cabinet_restored callback_completed test_notes'.split(' ') }; const PUBLIC_FIELDS = 'aed_id source_mapping place_name city district address lat lng floor_level location_desc access_type open_hours cabinet_status cabinet_buzzer aed_status aed_brand aed_model pediatric_mode pads_exp_date ped_pads_exp_date battery_exp_date has_guardian last_verified_at latest_verify_result'.split(' '); const ENUMS = { access_type: ['OPEN_PUBLIC', 'GUARD_PASS', 'RESTRICTED', 'PRIVATE'], cabinet_status: ['NORMAL', 'STUCK', 'LOCKED', 'BLOCKED'], cabinet_buzzer: ['NORMAL_SOUND', 'SILENT_DEAD_BATTERY', 'DISABLED_MANUALLY', 'NO_BUZZER'], aed_status: ['GREEN_OK', 'YELLOW_WARN', 'RED_ERROR', 'MISSING'], pediatric_mode: ['NONE', 'KEY_SWITCH', 'PEDIATRIC_PADS', 'BOTH'], guardian_vendor: ['立偉電子', '立遠電子', '新光電通', '凱樂思', '中華電信', '國興保全', '自購自管', '其他'], verify_result: ['VERIFIED', 'UPDATED', 'ISSUE_FOUND', 'NOT_FOUND'], call_center_status: ['AUTHORIZED', 'NO_GUARDIAN', 'UNREACHABLE'], callback_required: ['REQUIRED', 'NOT_REQUIRED'], carry_case_zipper: ['SMOOTH', 'DAMAGED'], status_indicator: ['GREEN_BLINK', 'RED_OR_OFF'], power_button_test: ['NORMAL_VOICE', 'LOW_BATTERY', 'NO_RESPONSE'], pad_flag_visible: ['VISIBLE', 'HIDDEN'], pad_package_intact: ['INTACT_PRECONNECTED', 'INTACT_UNPLUGGED', 'DAMAGED_OR_OPENED'] }; const PHYSICAL = ['carry_case_zipper', 'status_indicator', 'power_button_test', 'pad_flag_visible', 'pad_package_intact']; const BOOL_FIELDS = ['safe_place_cert', 'staff_cpr_trained', 'has_guardian', 'cabinet_restored', 'callback_completed']; const SOURCE_KEYS = ['MOHW', 'HEAR_AED', 'ICANHELP', 'SELF_FIELD']; function doGet(e) { try { const action = (e && e.parameter && e.parameter.action) || 'health'; if (action === 'health') return json_({ ok: true, service: 'Taiwan AED', api_version: 2 }); if (action !== 'master') throw new Error('不支援的讀取操作'); return json_({ ok: true, generated_at: new Date().toISOString(), is_demo: false, records: recordsWithLatest_().map(publicRecord_) }); } catch (error) { return json_({ ok: false, error: error.message }); } } function doPost(e) { try { const raw = e && e.postData && e.postData.contents; if (!raw || raw.length > 4000000) throw new Error('請求為空或超過 4MB'); const body = JSON.parse(raw); if (body.action === 'cloudCheck') { authenticate_(body.token, 'ADMIN_TOKEN'); return json_(cloudCheck_(body)); } if (body.action === 'importMaster') { authenticate_(body.token, 'ADMIN_TOKEN'); return json_(importMaster_(body)); } authenticate_(body.token); if (body.action === 'master') return json_({ ok: true, generated_at: new Date().toISOString(), records: privateRecords_() }); if (body.action !== 'submit') throw new Error('不支援的寫入操作'); return json_(submit_(body)); } catch (error) { return json_({ ok: false, error: error.message }); } } function props_() { return PropertiesService.getScriptProperties(); } function requireProp_(name) { const value = props_().getProperty(name); if (!value) throw new Error('尚未設定 Script Property:' + name); return value; } function authenticate_(token, property) { const expected = requireProp_(property || 'WRITE_TOKEN'); if (expected.length < 24) throw new Error('管理者需設定至少 24 字元的 WRITE_TOKEN'); if (typeof token !== 'string' || token.length !== expected.length) throw new Error('寫入密鑰錯誤'); let mismatch = 0; for (let n = 0; n < expected.length; n++) mismatch |= token.charCodeAt(n) ^ expected.charCodeAt(n); if (mismatch) throw new Error('寫入密鑰錯誤'); } function json_(value) { return ContentService.createTextOutput(JSON.stringify(value)).setMimeType(ContentService.MimeType.JSON); } function book_() { return SpreadsheetApp.openById(requireProp_('SPREADSHEET_ID')); } function sheet_(name) { const sheet = book_().getSheetByName(name); if (!sheet) throw new Error('工作表不存在,請先執行 setup:' + name); const headers = sheet.getRange(1, 1, 1, SCHEMA[name].length).getValues()[0]; if (JSON.stringify(headers) !== JSON.stringify(SCHEMA[name])) throw new Error('欄位順序不符,請核對工作表:' + name); return sheet; } function decode_(key, value) { if (value instanceof Date) return Utilities.formatDate(value, 'Asia/Taipei', /_date$|_exp$/.test(key) ? 'yyyy-MM-dd' : "yyyy-MM-dd'T'HH:mm:ssXXX"); if (BOOL_FIELDS.indexOf(key) !== -1) return value === '' ? '' : value === true || value === 'TRUE'; if (key === 'source_mapping' || key === 'accessory_items') { if (!value) return key === 'source_mapping' ? {} : []; try { return JSON.parse(value); } catch (_) { throw new Error(key + ' 必須是合法 JSON;請修正試算表'); } } return value; } function read_(name) { const sheet = sheet_(name); if (sheet.getLastRow() <= 1) return []; return sheet.getRange(2, 1, sheet.getLastRow() - 1, SCHEMA[name].length).getValues().filter(row => row[0]).map(row => Object.fromEntries(SCHEMA[name].map((key, i) => [key, decode_(key, row[i])]))); } function cell_(value) { if (value == null) return ''; if (typeof value === 'object') value = JSON.stringify(value); // Prevent untrusted text becoming a spreadsheet formula, including leading whitespace. return typeof value === 'string' && /^\s*[=+\-@]/.test(value) ? "'" + value : value; } function row_(name, record) { return SCHEMA[name].map(key => cell_(record[key])); } function findRow_(sheet, id) { if (sheet.getLastRow() <= 1) return 0; const rows = sheet.getRange(2, 1, sheet.getLastRow() - 1, 1).getValues(); for (let n = 0; n < rows.length; n++) if (String(rows[n][0]) === String(id)) return n + 2; return 0; } function upsert_(name, record) { const sheet = sheet_(name), existing = findRow_(sheet, record[SCHEMA[name][0]]), target = existing || sheet.getLastRow() + 1; if (target > sheet.getMaxRows()) sheet.insertRowsAfter(sheet.getMaxRows(), target - sheet.getMaxRows()); sheet.getRange(target, 1, 1, SCHEMA[name].length).setValues([row_(name, record)]); } function publicText_(value) { return String(value == null ? '' : value).replace(/(?:聯絡人|連絡人|管理人員|管理員)\s*[::]\s*[^;;,,\n]*/g, '[聯絡資訊已隱去]').replace(/(?:\+886[-\s]?(?:9\d{2}|[2-8]\d{0,2})|0(?:9\d{2}|[2-8]\d{0,2}))[-\s]?\d{3,4}[-\s]?\d{3,4}(?:\s*(?:#|分機|ext\.?)\s*\d+)?/gi, '[電話已隱去]'); } function publicRecord_(record) { return Object.fromEntries(PUBLIC_FIELDS.map(key => [key, ['place_name', 'address', 'location_desc', 'open_hours', 'aed_brand', 'aed_model'].indexOf(key) !== -1 ? publicText_(record[key]) : record[key] == null ? '' : record[key]])); } function cleanRecord_(input, fields) { if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('資料物件格式錯誤'); const result = {}; fields.forEach(key => { if (!Object.prototype.hasOwnProperty.call(input, key)) return; const value = input[key]; if (typeof value === 'string' && value.length > (['notes', 'test_notes', 'location_desc'].indexOf(key) !== -1 ? 3000 : 500)) throw new Error('欄位過長:' + key); if (typeof value === 'object' && key !== 'source_mapping' && key !== 'accessory_items') throw new Error('欄位型別錯誤:' + key); if (BOOL_FIELDS.indexOf(key) !== -1 && typeof value !== 'boolean') throw new Error('布林欄位必須為 true / false:' + key); if (key === 'source_mapping') { if (!value || Array.isArray(value) || typeof value !== 'object' || !Object.keys(value).length || Object.keys(value).some(k => SOURCE_KEYS.indexOf(k) === -1 || typeof value[k] !== 'string' || value[k].length > 200)) throw new Error('來源對應格式錯誤'); } else if (key === 'accessory_items') { if (!Array.isArray(value) || value.length > 30 || value.some(x => typeof x !== 'string' || x.length > 100)) throw new Error('急救包配件格式錯誤'); } else if (BOOL_FIELDS.indexOf(key) === -1 && typeof value !== 'string' && !(typeof value === 'number' && (key === 'lat' || key === 'lng'))) throw new Error('欄位型別錯誤:' + key); result[key] = typeof value === 'string' ? value.trim() : value; }); return result; } function validDate_(value) { return /^\d{4}-\d{2}-\d{2}$/.test(value) && !isNaN(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value; } function validate_(m, i, g) { ['place_name', 'city', 'address'].forEach(key => { if (!m[key]) throw new Error('缺少必填欄位:' + key); }); if (!i.inspector || i.inspector.length > 80) throw new Error('巡檢人員必填且限 80 字'); const notFound = i.verify_result === 'NOT_FOUND'; (notFound ? [] : ['access_type', 'cabinet_status', 'cabinet_buzzer', 'aed_status']).forEach(key => { if (ENUMS[key].indexOf(m[key]) === -1) throw new Error('請確認必填欄位:' + key); }); if (notFound && g) throw new Error('未找到 AED 時不可提交開箱實測紀錄'); [m, i, g].filter(Boolean).forEach(record => Object.keys(record).forEach(key => { if (ENUMS[key] && record[key] !== '' && ENUMS[key].indexOf(record[key]) === -1) throw new Error('列舉值錯誤:' + key); })); if (ENUMS.verify_result.indexOf(i.verify_result) === -1) throw new Error('查核結果必填'); ['pads_exp_date', 'ped_pads_exp_date', 'battery_exp_date', 'safe_place_exp'].forEach(key => { if (m[key] && !validDate_(m[key])) throw new Error('日期格式錯誤:' + key); }); [['lat', 90], ['lng', 180]].forEach(([key, limit]) => { if (m[key] !== '' && m[key] != null && (!Number.isFinite(Number(m[key])) || Math.abs(Number(m[key])) > limit)) throw new Error('座標超出範圍:' + key); }); if ((!m.lat && m.lat !== 0) !== (!m.lng && m.lng !== 0)) throw new Error('緯度與經度需同時填寫'); if (!m.source_mapping || !Object.keys(m.source_mapping).length) throw new Error('來源不得為空'); if (!notFound && m.has_guardian === true && ENUMS.guardian_vendor.indexOf(m.guardian_vendor) === -1) throw new Error('保母機廠商必填'); if (!g) return; if (ENUMS.call_center_status.indexOf(g.call_center_status) === -1 || ENUMS.callback_required.indexOf(g.callback_required) === -1) throw new Error('報備與回電狀態必填'); // Mandatory server-side interlock. Do not weaken this check. if (g.callback_required === 'REQUIRED' && g.callback_completed !== true) throw new Error('需要回電:必須完成回電並勾選確認後才能提交'); if (m.has_guardian === true && g.call_center_status === 'NO_GUARDIAN') throw new Error('有保母機不可選擇無保母機'); if (m.has_guardian !== true && g.call_center_status === 'AUTHORIZED') throw new Error('無保母機請使用場所管理人同意的選項'); if (m.has_guardian !== true && g.callback_required === 'REQUIRED') throw new Error('無保母機不得標示管制中心需回電'); if (g.call_center_status === 'AUTHORIZED' && !g.operator_id) throw new Error('需填寫管制中心受理人員/授權代號'); const canOpen = g.call_center_status === 'AUTHORIZED' || (g.call_center_status === 'NO_GUARDIAN' && m.has_guardian !== true); if (canOpen) { PHYSICAL.forEach(key => { if (ENUMS[key].indexOf(g[key]) === -1) throw new Error('請完成開箱實測:' + key); }); if (g.cabinet_restored !== true) throw new Error('必須完成設備與收納箱復原'); } else if (PHYSICAL.some(key => g[key]) || g.cabinet_restored === true) throw new Error('未獲授權不可填寫開箱實測或復原紀錄'); } function submit_(body) { if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(body.request_id || '')) throw new Error('request_id 必須為 UUID v4'); if (!body.captured_at || isNaN(Date.parse(body.captured_at)) || Date.parse(body.captured_at) > Date.now() + 300000) throw new Error('巡檢時間無效或裝置時間超前超過 5 分鐘'); const p = body.payload; if (!p || p.demo === true || /^DEMO-/.test(p.master && p.master.aed_id)) throw new Error('示範紀錄不可寫入正式資料庫'); const incoming = cleanRecord_(p.master, SCHEMA.AED_Master.filter(key => ['photo_env_url', 'photo_closeup_url', 'last_verified_at', 'last_verified_by'].indexOf(key) === -1)); if (!/^[A-Za-z0-9_-]{1,100}$/.test(incoming.aed_id || '')) throw new Error('aed_id 格式錯誤'); const inspection = cleanRecord_(p.inspection, ['inspector', 'verify_result', 'notes']); const guardian = p.guardian ? cleanRecord_(p.guardian, SCHEMA.Guardian_Test_Logs.filter(key => ['test_id', 'timestamp', 'aed_id', 'inspector', 'guardian_vendor', 'guardian_query_no'].indexOf(key) === -1)) : null; // Validate photos before acquiring a lock or writing any files. const photoBlobs = photoBlobs_(p.photos || {}, body.request_id); const lock = LockService.getScriptLock(); lock.waitLock(30000); try { const logs = sheet_('Inspection_Logs'); const existingLog = findRow_(logs, body.request_id); if (existingLog) { const savedAed = logs.getRange(existingLog, SCHEMA.Inspection_Logs.indexOf('aed_id') + 1, 1, 1).getValues()[0][0]; if (savedAed !== incoming.aed_id) throw new Error('重試識別碼與點位不符'); return { ok: true, saved: true, duplicate: true, request_id: body.request_id }; } const existing = read_('AED_Master').find(m => m.aed_id === incoming.aed_id); if (!existing && !validNewPoint_(incoming)) throw new Error('新增點位必須使用 SELF- 與 SELF_FIELD,或有效的標準 TW 來源識別碼'); if (existing && existing.last_verified_at && Date.parse(existing.last_verified_at) > Date.parse(body.captured_at)) throw new Error('此點位已有較新的巡檢;請備份此筆、更新點位並核對後重建巡檢'); const master = Object.assign({}, existing || {}, incoming); master.source_mapping = Object.assign({}, existing && existing.source_mapping || {}, incoming.source_mapping || {}); // A failed search cannot confirm the condition of the equipment or its cabinet. if (inspection.verify_result === 'NOT_FOUND') ['cabinet_status', 'cabinet_buzzer', 'aed_status'].forEach(key => { master[key] = ''; }); validate_(master, inspection, guardian); const meta = guardian ? validateGuardianMeta_(p.guardian_meta, guardian) : null; if (!guardian && p.guardian_meta && (p.guardian_meta.audio || p.guardian_meta.elapsed_seconds != null || (p.guardian_meta.call_tags || []).length)) throw new Error('實測附加資料必須隨保母機紀錄提交'); if (Object.prototype.hasOwnProperty.call(p, 'guardian_exp_date') && p.guardian_exp_date !== '' && !validDate_(p.guardian_exp_date)) throw new Error('保母機契約效期格式錯誤'); const urls = savePhotos_(photoBlobs); if (urls.env) master.photo_env_url = urls.env; if (urls.closeup) master.photo_closeup_url = urls.closeup; master.last_verified_at = new Date(body.captured_at).toISOString(); master.last_verified_by = inspection.inspector; // Upsert stages are replayable after partial execution. The inspection row is the final commit marker. // A retry reuses photo filenames and guardian test_id, so neither creates duplicates. upsert_('AED_Master', master); if (guardian) { if (meta) guardian.test_notes = JSON.stringify(Object.assign({ format: 'aed-sop-v1', notes: guardian.test_notes || '' }, meta, { recording_url: saveAudio_(p.guardian_meta && p.guardian_meta.audio, body.request_id) })); upsert_('Guardian_Test_Logs', Object.assign({}, guardian, { test_id: body.request_id, timestamp: master.last_verified_at, aed_id: master.aed_id, inspector: inspection.inspector, guardian_vendor: master.guardian_vendor || '', guardian_query_no: master.guardian_query_no || '' })); } if (Object.prototype.hasOwnProperty.call(p, 'guardian_exp_date')) { if (p.guardian_exp_date) props_().setProperty('GUARDIAN_EXP_' + master.aed_id, p.guardian_exp_date); else props_().deleteProperty('GUARDIAN_EXP_' + master.aed_id); } upsert_('Inspection_Logs', Object.assign({}, inspection, { log_id: body.request_id, timestamp: master.last_verified_at, aed_id: master.aed_id, cabinet_status: master.cabinet_status, cabinet_buzzer: master.cabinet_buzzer, aed_status: master.aed_status, photo_env_url: master.photo_env_url || '', photo_closeup_url: master.photo_closeup_url || '' })); SpreadsheetApp.flush(); return { ok: true, saved: true, request_id: body.request_id }; } finally { lock.releaseLock(); } } function photoBlobs_(photos, id) { if (!photos || typeof photos !== 'object' || Array.isArray(photos) || Object.keys(photos).some(k => ['env', 'closeup'].indexOf(k) === -1)) throw new Error('照片欄位格式錯誤'); return Object.entries(photos).map(([key, photo]) => { if (!photo || typeof photo.data !== 'string' || photo.data.length > 1400000 || !/^data:image\/jpeg;base64,[A-Za-z0-9+/]+={0,2}$/.test(photo.data)) throw new Error('照片須為小於 1MB 的 JPEG:' + key); const bytes = Utilities.base64Decode(photo.data.split(',')[1]); if (bytes.length > 1048576 || bytes.length < 4 || (bytes[0] & 255) !== 255 || (bytes[1] & 255) !== 216 || (bytes[2] & 255) !== 255 || (bytes[bytes.length - 2] & 255) !== 255 || (bytes[bytes.length - 1] & 255) !== 217) throw new Error('JPEG 內容無效:' + key); return { key, name: id + '-' + key + '.jpg', blob: Utilities.newBlob(bytes, 'image/jpeg', id + '-' + key + '.jpg') }; }); } function savePhotos_(photos) { if (!photos.length) return {}; const folder = DriveApp.getFolderById(requireProp_('PHOTO_FOLDER_ID')), result = {}; photos.forEach(photo => { const found = folder.getFilesByName(photo.name); const file = found.hasNext() ? found.next() : folder.createFile(photo.blob); result[photo.key] = file.getUrl(); }); // Inherit the folder's restricted access. Never make inspection photos public. return result; } /** Run once manually after setting Script Properties; safe for existing matching headers. */ function setup() { const book = book_(); Object.entries(SCHEMA).forEach(([name, fields]) => { const sheet = book.getSheetByName(name) || book.insertSheet(name); if (sheet.getMaxColumns() < fields.length) sheet.insertColumnsAfter(sheet.getMaxColumns(), fields.length - sheet.getMaxColumns()); if (sheet.getMaxRows() < 2) sheet.insertRowsAfter(sheet.getMaxRows(), 2 - sheet.getMaxRows()); if (sheet.getLastRow() && JSON.stringify(sheet.getRange(1, 1, 1, fields.length).getValues()[0]) !== JSON.stringify(fields)) throw new Error('既有欄位不符,請先備份並手動遷移:' + name); sheet.getRange(1, 1, 1, fields.length).setValues([fields]).setFontWeight('bold').setBackground('#dff2e9'); sheet.setFrozenRows(1); sheet.getRange(2, 1, sheet.getMaxRows() - 1, fields.length).setNumberFormat('@'); fields.forEach((key, index) => { const range = sheet.getRange(2, index + 1, sheet.getMaxRows() - 1, 1); if (ENUMS[key]) range.setDataValidation(SpreadsheetApp.newDataValidation().requireValueInList(ENUMS[key], true).setAllowInvalid(false).build()); if (BOOL_FIELDS.indexOf(key) !== -1) range.setDataValidation(SpreadsheetApp.newDataValidation().requireCheckbox().setAllowInvalid(false).build()); }); sheet.autoResizeColumns(1, Math.min(4, fields.length)); }); DriveApp.getFolderById(requireProp_('PHOTO_FOLDER_ID')).getName(); if (requireProp_('WRITE_TOKEN').length < 24) throw new Error('WRITE_TOKEN 至少需 24 字元'); return '三張工作表已就緒'; } /** Export only public fields. Download this private Drive file, then replace the repo JSON. */ function exportPublicCache() { const data = { schema_version: 1, generated_at: new Date().toISOString(), is_demo: false, records: recordsWithLatest_().map(publicRecord_) }; const file = DriveApp.getFolderById(requireProp_('PHOTO_FOLDER_ID')).createFile('aed_master_tw.json', JSON.stringify(data, null, 2), MimeType.PLAIN_TEXT); console.log('下載公開快取後提交至 GitHub:' + file.getUrl()); return file.getUrl(); } /** Daily GmailApp digest. Install explicitly via installDailyAlertTrigger(). */ function sendDailyAlerts() { const recipient = requireProp_('ALERT_EMAIL'); const today = Utilities.formatDate(new Date(), 'Asia/Taipei', 'yyyy-MM-dd'); const alerts = privateRecords_().map(m => { const issues = []; if (m.latest_verify_result === 'NOT_FOUND') issues.push('最近一次巡檢未找到 AED,請追蹤確認'); [['pads_exp_date', '成人貼片'], ['ped_pads_exp_date', '小兒貼片'], ['battery_exp_date', '電池'], ['safe_place_exp', '安心場所認證'], ['_guardian_exp_date', '保母機契約']].forEach(([key, label]) => { if (m[key] && validDate_(m[key])) { const days = Math.round((Date.parse(m[key]) - Date.parse(today)) / 86400000); if (days <= 30) issues.push(label + ':' + (days < 0 ? '紅燈 已過期' : '黃燈 ' + days + ' 天後到期') + ' (' + m[key] + ')'); } }); if (['YELLOW_WARN', 'RED_ERROR', 'MISSING'].indexOf(m.aed_status) !== -1) issues.push('AED 狀態:' + m.aed_status); if (['STUCK', 'LOCKED', 'BLOCKED'].indexOf(m.cabinet_status) !== -1) issues.push('收納箱:' + m.cabinet_status); if (['SILENT_DEAD_BATTERY', 'DISABLED_MANUALLY'].indexOf(m.cabinet_buzzer) !== -1) issues.push('蜂鳴器:' + m.cabinet_buzzer); return issues.length ? m.place_name + ' (' + m.aed_id + ')\n' + issues.join('\n') : ''; }).filter(Boolean); if (!alerts.length) return '今日無待提醒項目'; const lock = LockService.getScriptLock(); lock.waitLock(30000); try { if (props_().getProperty('LAST_ALERT_DATE') === today) return '今日已寄送'; GmailApp.sendEmail(recipient, '[AED 巡檢預警] ' + today + ' · ' + alerts.length + ' 個點位', alerts.join('\n\n') + '\n\n請登入巡檢系統核對與處理。'); props_().setProperty('LAST_ALERT_DATE', today); } finally { lock.releaseLock(); } return '已寄送 ' + alerts.length + ' 個點位提醒'; } function installDailyAlertTrigger() { requireProp_('ALERT_EMAIL'); ScriptApp.getProjectTriggers().filter(t => t.getHandlerFunction() === 'sendDailyAlerts').forEach(t => ScriptApp.deleteTrigger(t)); ScriptApp.newTrigger('sendDailyAlerts').timeBased().everyDays(1).atHour(8).inTimezone('Asia/Taipei').create(); } function recordsWithLatest_() { const latest = new Map(); read_('Inspection_Logs').forEach(log => { const time = Date.parse(log.timestamp); if (!log.aed_id || !Number.isFinite(time) || ENUMS.verify_result.indexOf(log.verify_result) === -1) return; const old = latest.get(log.aed_id); if (!old || time >= old.time) latest.set(log.aed_id, { time, result: log.verify_result }); }); return read_('AED_Master').map(m => Object.assign({}, m, { latest_verify_result: latest.get(m.aed_id)?.result || '' })); } function privateRecords_() { return recordsWithLatest_().map(m => Object.assign({}, m, { _guardian_exp_date: props_().getProperty('GUARDIAN_EXP_' + m.aed_id) || '' })); } /** Pasted Code.gs only: first Google authorization and Web App publication still require the account owner. */ function oneClickSetup() { const info = setupResources_(); console.log('設定資訊含密鑰,僅提供授權人員;勿貼到公開 repo。\n' + JSON.stringify(info, null, 2)); if (info.GAS_WEB_APP_URL) printBindingConfig(); else console.log('資源已建立。請以自己身分、任何人可存取發布網頁應用程式一次,之後執行 printBindingConfig() 取得完整設定碼。'); return info; } function setupResources_() { const lock = LockService.getScriptLock(); lock.waitLock(30000); let info; try { const properties = props_(); let spreadsheetId = properties.getProperty('SPREADSHEET_ID'); if (!spreadsheetId) { const active = SpreadsheetApp.getActiveSpreadsheet(); const book = active || SpreadsheetApp.create('台灣 AED 實地巡檢資料庫'); spreadsheetId = book.getId(); properties.setProperty('SPREADSHEET_ID', spreadsheetId); } if (!properties.getProperty('PHOTO_FOLDER_ID')) properties.setProperty('PHOTO_FOLDER_ID', DriveApp.createFolder('AED 巡檢照片與錄音備忘').getId()); ['WRITE_TOKEN', 'ADMIN_TOKEN'].forEach(name => { if (!properties.getProperty(name)) { const entropy = [Utilities.getUuid(), Utilities.getUuid(), Utilities.getUuid()].join(':'); const bytes = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, entropy, Utilities.Charset.UTF_8); properties.setProperty(name, bytes.map(b => ('0' + (b & 255).toString(16)).slice(-2)).join('')); } }); if (!properties.getProperty('ALERT_EMAIL')) { const owner = Session.getEffectiveUser().getEmail(); if (!owner) throw new Error('Google 未提供執行者信箱;請在 Script Properties 設定 ALERT_EMAIL 後重跑'); properties.setProperty('ALERT_EMAIL', owner); } setup(); if (read_('AED_Master').length === 0 && typeof SEED_RECORDS !== 'undefined') { const sheet = sheet_('AED_Master'); if (sheet.getMaxRows() < SEED_RECORDS.length + 1) sheet.insertRowsAfter(sheet.getMaxRows(), SEED_RECORDS.length + 1 - sheet.getMaxRows()); if (SEED_RECORDS.length) sheet.getRange(2, 1, SEED_RECORDS.length, SCHEMA.AED_Master.length).setValues(SEED_RECORDS.map(m => row_('AED_Master', m))); } installWeeklyAlertTrigger_(); properties.setProperty('SETUP_VERSION', '2'); info = { spreadsheet_url: 'https://docs.google.com/spreadsheets/d/' + spreadsheetId + '/edit', photo_folder_url: 'https://drive.google.com/drive/folders/' + properties.getProperty('PHOTO_FOLDER_ID'), ALERT_EMAIL: properties.getProperty('ALERT_EMAIL'), schedule: 'Asia/Taipei 每週一 08 時附近', API_WRITE_KEY: properties.getProperty('WRITE_TOKEN'), ADMIN_IMPORT_KEY: properties.getProperty('ADMIN_TOKEN'), GAS_WEB_APP_URL: ScriptApp.getService().getUrl() || '' }; } finally { lock.releaseLock(); } return info; } /** First verification of a public cache point creates its private master lazily. * Hash checks source identity consistency; it does not claim official authenticity. */ function sourceId_(text) { let hi = 0xcbf29ce4, lo = 0x84222325; Utilities.newBlob(text).getBytes().forEach(byte => { lo = (lo ^ (byte & 255)) >>> 0; const product = lo * 435; hi = (hi * 435 + Math.floor(product / 4294967296) + lo * 256) >>> 0; lo = product >>> 0; }); return 'TW-' + ('00000000' + hi.toString(16)).slice(-8) + ('00000000' + lo.toString(16)).slice(-8); } function validNewPoint_(m) { if (!m.source_mapping) return false; if (/^SELF-/.test(m.aed_id) && m.source_mapping.SELF_FIELD) return true; return Object.keys(m.source_mapping).some(s => SOURCE_KEYS.includes(s) && String(m.source_mapping[s]).split('|').some(id => sourceId_(s + ':' + id) === m.aed_id)); } /** Admin-only test. A pending mail marker prevents duplicate mail after an ambiguous timeout. */ function cloudCheck_(body) { if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(body.request_id || '')) throw new Error('測試請求須為 UUID v4'); const info = setupResources_(), master = sheet_('AED_Master'); sheet_('Inspection_Logs'); sheet_('Guardian_Test_Logs'); DriveApp.getFolderById(requireProp_('PHOTO_FOLDER_ID')).getName(); const lock = LockService.getScriptLock(); lock.waitLock(30000); try { const properties = props_(), previous = JSON.parse(properties.getProperty('CLOUD_CHECK_LAST') || 'null'); if (previous && previous.request_id === body.request_id) return Object.assign({}, previous, { duplicate: true }); if (previous && Date.now() - Date.parse(previous.checked_at) < 15 * 60 * 1000) throw new Error('測試信每 15 分鐘最多一次;請稍後再試'); const result = { ok: true, request_id: body.request_id, checked_at: new Date().toISOString(), spreadsheet_url: info.spreadsheet_url, photo_folder_url: info.photo_folder_url, master_count: master.getLastRow() - 1, email_recipient: info.ALERT_EMAIL, email_status: 'PENDING_CONFIRMATION' }; properties.setProperty('CLOUD_CHECK_LAST', JSON.stringify(result)); try { GmailApp.sendEmail(info.ALERT_EMAIL, 'AED 系統首次上線測試', '雲端 API、工作表欄序、照片資料夾與每週預警排程已檢查。\n試算表:' + info.spreadsheet_url + '\n測試時間:' + result.checked_at + '\n若未見此信,請檢查垃圾郵件與 Gmail 寄件備份。'); result.email_status = 'ACCEPTED'; properties.setProperty('CLOUD_CHECK_LAST', JSON.stringify(result)); } catch (_) { result.email_status = 'FAILED_OR_UNKNOWN'; properties.setProperty('CLOUD_CHECK_LAST', JSON.stringify(result)); } return result; } finally { lock.releaseLock(); } } function setupWizard() { return oneClickSetup(); } function printBindingConfig() { const url = ScriptApp.getService().getUrl(); if (!url || !/^https:\/\/script\.google\.com\/macros\/s\/.+\/exec$/.test(url)) throw new Error('請先在 GAS 部署為網頁應用程式,再執行此函式'); const config = { version: 1, GAS_WEB_APP_URL: url, API_WRITE_KEY: requireProp_('WRITE_TOKEN') }; const code = 'AED1.' + Utilities.base64EncodeWebSafe(JSON.stringify(config), Utilities.Charset.UTF_8).replace(/=+$/, ''); console.log('巡檢設定碼(可貼到前端或由前端生成 QR;含寫入密鑰)\n' + code); return code; } function installWeeklyAlertTrigger_() { ScriptApp.getProjectTriggers().filter(t => ['sendDailyAlerts', 'sendWeeklyAlerts'].indexOf(t.getHandlerFunction()) !== -1).forEach(t => ScriptApp.deleteTrigger(t)); ScriptApp.newTrigger('sendWeeklyAlerts').timeBased().everyWeeks(1).onWeekDay(ScriptApp.WeekDay.MONDAY).atHour(8).inTimezone('Asia/Taipei').create(); } function sendWeeklyAlerts() { return sendDailyAlerts(); } function validateGuardianMeta_(meta, guardian) { if (!meta) return null; if (typeof meta !== 'object' || Array.isArray(meta)) throw new Error('實測附加資料格式錯誤'); const allowed = ['訊號正常收到開門警報', '未收到門磁訊號', '客服要求更換電池', '通訊訊號不穩', '已確認復原訊號', '需安排維修']; if (!Array.isArray(meta.call_tags) || meta.call_tags.length > allowed.length || meta.call_tags.some(tag => allowed.indexOf(tag) === -1)) throw new Error('客服標籤格式錯誤'); if (meta.elapsed_seconds !== null && (!Number.isInteger(meta.elapsed_seconds) || meta.elapsed_seconds < 0 || meta.elapsed_seconds > 86400)) throw new Error('開箱耗時須為 0–86400 秒整數'); if (meta.elapsed_seconds != null && guardian.call_center_status === 'UNREACHABLE') throw new Error('未獲授權不可記錄開箱計時'); ['started_at', 'stopped_at'].forEach(k => { if (meta[k] && (typeof meta[k] !== 'string' || isNaN(Date.parse(meta[k])))) throw new Error('計時時間格式錯誤'); }); if (meta.elapsed_seconds != null && (!meta.started_at || !meta.stopped_at || Date.parse(meta.stopped_at) < Date.parse(meta.started_at))) throw new Error('計時必須完成停止'); if (meta.audio) audioBlob_(meta.audio, 'validation'); return { elapsed_seconds: meta.elapsed_seconds, started_at: meta.started_at || '', stopped_at: meta.stopped_at || '', call_tags: [...new Set(meta.call_tags)] }; } function audioBlob_(audio, id) { if (!audio || typeof audio.data !== 'string' || audio.data.length > 720000 || !/^data:audio\/(webm|mp4|ogg);base64,[A-Za-z0-9+/]+={0,2}$/.test(audio.data)) throw new Error('錄音備忘須為小於 512KB 的 WebM/MP4/OGG'); const match = audio.data.match(/^data:(audio\/(webm|mp4|ogg));base64,(.+)$/), bytes = Utilities.base64Decode(match[3]); if (bytes.length > 524288 || bytes.length < 8) throw new Error('錄音內容無效或過大'); const b = bytes.map(n => n & 255), webm = b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3, ogg = b.slice(0, 4).map(x => String.fromCharCode(x)).join('') === 'OggS', mp4 = b.slice(4, 8).map(x => String.fromCharCode(x)).join('') === 'ftyp'; if ((match[2] === 'webm' && !webm) || (match[2] === 'ogg' && !ogg) || (match[2] === 'mp4' && !mp4)) throw new Error('錄音檔頭與類型不符'); return { blob: Utilities.newBlob(bytes, match[1], id + '-memo.' + match[2]), name: id + '-memo.' + match[2] }; } function saveAudio_(audio, id) { if (!audio) return ''; const value = audioBlob_(audio, id), folder = DriveApp.getFolderById(requireProp_('PHOTO_FOLDER_ID')), found = folder.getFilesByName(value.name); return (found.hasNext() ? found.next() : folder.createFile(value.blob)).getUrl(); } function importMaster_(body) { if (!Array.isArray(body.records) || body.records.length < 1 || body.records.length > 200) throw new Error('每批匯入須為 1–200 筆'); const input = body.records.map(record => { const m = cleanRecord_(record, SCHEMA.AED_Master.filter(k => !['photo_env_url', 'photo_closeup_url', 'last_verified_by', 'last_verified_at'].includes(k))); if (!/^[A-Za-z0-9_-]{1,100}$/.test(m.aed_id || '') || /^DEMO-/.test(m.aed_id)) throw new Error('匯入點位編號無效'); ['place_name', 'city', 'address', 'source_mapping'].forEach(k => { if (!m[k]) throw new Error('匯入缺少 ' + k); }); Object.entries(m).forEach(([k, v]) => { if (ENUMS[k] && v && ENUMS[k].indexOf(v) === -1) throw new Error('匯入列舉值錯誤:' + k); }); ['pads_exp_date', 'ped_pads_exp_date', 'battery_exp_date', 'safe_place_exp'].forEach(k => { if (m[k] && !validDate_(m[k])) throw new Error('匯入日期錯誤:' + k); }); if ((m.lat != null && m.lat !== '') !== (m.lng != null && m.lng !== '')) throw new Error('匯入座標需成對'); [['lat', 90], ['lng', 180]].forEach(([k, max]) => { if (m[k] != null && m[k] !== '' && (!Number.isFinite(Number(m[k])) || Math.abs(Number(m[k])) > max)) throw new Error('匯入座標無效'); }); return m; }); if (new Set(input.map(m => m.aed_id)).size !== input.length) throw new Error('同一批次不得有重複 aed_id'); const lock = LockService.getScriptLock(); lock.waitLock(30000); try { const existing = new Map(read_('AED_Master').map(m => [m.aed_id, m])); const staged = input.map(m => { const old = existing.get(m.aed_id); if (!old) return m; const merged = Object.assign({}, old); Object.keys(m).forEach(k => { if (k === 'source_mapping') return; if (!old.last_verified_at || old[k] == null || old[k] === '') merged[k] = m[k]; }); const mapping = Object.assign({}, old.source_mapping); Object.entries(m.source_mapping).forEach(([src, ids]) => { const joined = [...new Set((mapping[src] || '').split('|').concat(ids.split('|')).filter(Boolean))].sort().join('|'); if (joined.length > 200) throw new Error('來源編號合併超過 200 字元'); mapping[src] = joined; }); merged.source_mapping = mapping; return merged; }); staged.forEach(m => upsert_('AED_Master', m)); SpreadsheetApp.flush(); return { ok: true, imported: staged.length, ids: staged.map(m => m.aed_id) }; } finally { lock.releaseLock(); } } // BEGIN GENERATED SEED — official registered locations, not field-verified status const SEED_RECORDS = [{"aed_id":"TW-033affb458a433e3","source_mapping":{"MOHW":"3789"},"place_name":"金水學校展示館","city":"金門縣","district":"金城鎮","address":"金門縣金城鎮前水頭47號","lat":24.409321,"lng":118.297989,"location_desc":"展示館入口處右側","open_hours":"除夕休館"},{"aed_id":"TW-033b08b458a4432e","source_mapping":{"MOHW":"3782"},"place_name":"交通部民用航空局馬祖航空站","city":"連江縣","district":"南竿鄉","address":"連江縣南竿鄉復興村220號","lat":26.157711,"lng":119.955533,"location_desc":"放置於航廈大廳靠近立榮櫃臺","open_hours":"平日 06:30:00–18:30:00;週六 06:30:00–18:30:00;週日 06:30:00–18:30:00"},{"aed_id":"TW-033b0ab458a44694","source_mapping":{"MOHW":"3784"},"place_name":"連江縣東莒猛澳旅客服務中心","city":"連江縣","district":"莒光鄉","address":"連江縣莒光鄉大坪村83-3號","lat":26.154431,"lng":119.938602,"location_desc":"猛澳港旅客中心內售票櫃臺旁","open_hours":"平日 08:00:00–17:30:00;週六 08:00:00–17:30:00;週日 08:00:00–17:30:00"},{"aed_id":"TW-033b0bb458a44847","source_mapping":{"MOHW":"3785"},"place_name":"連江縣西莒青帆旅客服務中心","city":"連江縣","district":"莒光鄉","address":"連江縣莒光鄉青帆村18之2號","lat":25.967604,"lng":119.933302,"location_desc":"西莒青帆旅客服務中心入口左側","open_hours":"平日 08:00:00–17:30:00;週六 08:00:00–17:30:00;週日 08:00:00–17:30:00"},{"aed_id":"TW-033e8cb458a75951","source_mapping":{"MOHW":"3790"},"place_name":"特約茶室展示館","city":"金門縣","district":"金湖鎮","address":"金門縣金湖鎮金門縣金湖鎮瓊林里小徑162號","lat":24.448314,"lng":118.384834,"location_desc":"特約茶室展示館","open_hours":"平日 00:00:00–23:59:00;週六 00:00:00–23:59:00;週日 00:00:00–23:59:00"},{"aed_id":"TW-1cb9fbb466f1d702","source_mapping":{"MOHW":"3452"},"place_name":"長榮桂冠酒店(基隆)","city":"基隆市","district":"中正區","address":"基隆市中正區中正路62之1號","lat":25.135105,"lng":121.746124,"location_desc":"長榮桂冠酒店(基隆)大廳一樓沙發旁","open_hours":"平日 00:00:00–23:59:00;週六 00:00:00–23:59:00;週日 00:00:00–23:59:00"},{"aed_id":"TW-29642fa510abec53","source_mapping":{"MOHW":"832"},"place_name":"國立苑裡高級中學","city":"苗栗縣","district":"苑裡鎮","address":"苗栗縣苑裡鎮育才街100號","lat":24.447814,"lng":120.65627,"location_desc":"大門 右側 創新樓 1樓中廊 過後第一間","open_hours":"平日 08:00:00–17:00:00"},{"aed_id":"TW-296ac0a510b14e64","source_mapping":{"MOHW":"815"},"place_name":"國立臺灣圖書館","city":"新北市","district":"中和區","address":"新北市中和區中安街85號","lat":25.00122,"lng":121.51282,"location_desc":"國立臺灣圖書館---(新北市中和區中安街85號)---博愛門入口右側","open_hours":"國定假日及每週一休館"},{"aed_id":"TW-296e3ba510b4553c","source_mapping":{"MOHW":"868"},"place_name":"臺中豐邑MOXY酒店 (Moxy Taichung)","city":"臺中市","district":"南屯區","address":"臺中市南屯區文心南六路二八八號","lat":24.131002147339,"lng":120.64672030273,"location_desc":"PLAY ON MOXY TAICHUNG\nMoxy是一家有趣、富有活力及時尚的酒店,專為 "Always-On” - 總是在尋找新體驗的客人所設計的。我們活潑的公共空間及有活力的隊員們為客人帶來開"},{"aed_id":"TW-296e3ca510b456ef","source_mapping":{"MOHW":"869"},"place_name":"明湖水漾會館","city":"苗栗縣","district":"頭屋鄉","address":"苗栗縣頭屋鄉明德村明德路54號","lat":24.585977,"lng":120.887402,"location_desc":"明湖水漾會館一樓藝品區右側角落、明湖水漾會館後門進入大廳右手邊","open_hours":"全年無休"},{"aed_id":"TW-296e44a510b46487","source_mapping":{"MOHW":"861"},"place_name":"新竹豐邑喜來登大飯店","city":"新竹縣","district":"竹北市","address":"新竹縣竹北市光明六路東一段265號","lat":24.820282,"lng":121.027919,"location_desc":"由大廳進入,往左邊的沙發區旁,掛在牆壁上,\n1樓大廳沙發區 旁(靠近check in 櫃臺)","open_hours":"平日 00:00:00–23:59:00;週六 00:00:00–23:59:00;週日 00:00:00–23:59:00"},{"aed_id":"TW-296e45a510b4663a","source_mapping":{"MOHW":"862"},"place_name":"新竹豐邑喜來登大飯店","city":"新竹縣","district":"竹北市","address":"新竹縣竹北市光明六路東一段265號","lat":24.820282,"lng":121.027919,"location_desc":"5樓健身中心內健身器材旁邊","open_hours":"平日 08:00:00–22:00:00;週六 08:00:00–22:00:00;週日 08:00:00–22:00:00"},{"aed_id":"TW-29752da510ba5c20","source_mapping":{"MOHW":"842"},"place_name":"臺元科技園區","city":"新竹縣","district":"竹北市","address":"新竹縣竹北市臺元街26號","lat":24.842534,"lng":121.013981,"location_desc":"1樓大廳","open_hours":"平日 00:00:00–23:59:00;週六 00:00:00–23:59:00;週日 00:00:00–23:59:00"},{"aed_id":"TW-2978b2a510bd73f6","source_mapping":{"MOHW":"857"},"place_name":"科頡工業股份有限公司","city":"新北市","district":"深坑區","address":"新北市深坑區北深路一段151號","lat":25.010178527781,"lng":121.63344008358,"location_desc":"一樓客梯梯廳旁","open_hours":"平日 00:00:00–23:59:00;週六 00:00:00–23:59:00;週日 00:00:00–23:59:00"},{"aed_id":"TW-2978b7a510bd7c75","source_mapping":{"MOHW":"852"},"place_name":"中國文化大學 大恩館","city":"臺北市","district":"士林區","address":"臺北市士林區華岡路55號","lat":25.134499,"lng":121.539216,"location_desc":"中國文化大學校本部大孝館(體育館)2樓,進入體育館後右手邊方向。","open_hours":"寒暑假開放時間另行公告"},{"aed_id":"TW-2982c0a510c5e045","source_mapping":{"MOHW":"889"},"place_name":"大統益股份有限公司","city":"臺南市","district":"官田區","address":"臺南市官田區二鎮村工業西路32號","lat":23.21135,"lng":120.320359,"location_desc":"警衛室","open_hours":"警衛室24小時值班"},{"aed_id":"TW-327a68a515f04cb8","source_mapping":{"MOHW":"914"},"place_name":"友達光電L6A廠","city":"臺中市","district":"西屯區","address":"臺中市西屯區中科路1號","lat":24.204385,"lng":120.616322,"location_desc":"F 棟 3F 無塵室人員進出口","open_hours":"工廠內部營運時間全年無休,但進入內部有人員出入管制,不對外開放"},{"aed_id":"TW-327a6ea515f056ea","source_mapping":{"MOHW":"912"},"place_name":"高新牙醫診所","city":"彰化縣","district":"員林市","address":"彰化縣員林市萬年路三段105號","lat":23.958464,"lng":120.573634,"location_desc":"高新牙科二樓走道","open_hours":"禮拜日休診"},{"aed_id":"TW-328160a515f65dce","source_mapping":{"MOHW":"938"},"place_name":"市府轉運站","city":"臺北市","district":"信義區","address":"臺北市信義區忠孝東路五段六號","lat":25.040914,"lng":121.565136,"location_desc":"售票大廳","open_hours":"由服務臺專業人員使用"},{"aed_id":"TW-39c901a519b15fb3","source_mapping":{"MOHW":"692"},"place_name":"中興保全股份有限公司臺南二分公司","city":"臺南市","district":"永康區","address":"臺南市永康區中正七街103號碼","lat":23.04188,"lng":120.24025,"location_desc":"中興保全股份有限公司臺南二分公司辦公室1F大門入口處右側","open_hours":"平日 08:00:00–18:00:00"},{"aed_id":"TW-39e110a519c60b11","source_mapping":{"MOHW":"603"},"place_name":"財團法人國家衛生研究院(行政大樓)","city":"苗栗縣","district":"竹南鎮","address":"苗栗縣竹南鎮科研路35號","lat":24.70524,"lng":120.91392,"location_desc":"財團法人國家衛生研究院行政大樓一樓警衛室旁","open_hours":"平日 08:30:00–17:00:00"},{"aed_id":"TW-39e796a519cb5a71","source_mapping":{"MOHW":"625"},"place_name":"一滴水紀念館旅客服務中心","city":"新北市","district":"淡水區","address":"新北市淡水區中正路一段6巷30號","lat":25.178206,"lng":121.430772,"location_desc":"一滴水遊課服務中心1樓","open_hours":"開放時間為週二至週日上午9時至下午5時,週日及週一休館。"},{"aed_id":"TW-39ee83a519d158d6","source_mapping":{"MOHW":"646"},"place_name":"宜蘭縣立體育場羅東遊客管理中心","city":"宜蘭縣","district":"羅東鎮","address":"宜蘭縣羅東鎮公正路666號","lat":24.681747,"lng":121.751894,"location_desc":"羅東運動公園游泳池遊客中心","open_hours":"平日 08:00:00–17:00:00;週六 08:00:00–17:00:00;週日 08:00:00–17:00:00"},{"aed_id":"TW-39ee84a519d15a89","source_mapping":{"MOHW":"647"},"place_name":"宜蘭縣立體育場-宜蘭運動公園體育館","city":"宜蘭縣","district":"宜蘭市","address":"宜蘭縣宜蘭市中山路1段755號","lat":24.738568,"lng":121.753385,"location_desc":"宜蘭縣立體育場-宜蘭運動公園田徑場西六區入口處","open_hours":"宜蘭運動公園田徑場西六區入口處"},{"aed_id":"TW-39f208a519d470ac","source_mapping":{"MOHW":"653"},"place_name":"國立臺灣師範大學校本部(和平東路1段162號)","city":"臺北市","district":"大安區","address":"臺北市大安區和平東路1段162號","lat":25.025797,"lng":121.526706,"location_desc":"校本部體育館2樓體育室外","open_hours":"星期六.日視舉辦活動採不定期開放"},{"aed_id":"TW-39f589a519d781b6","source_mapping":{"MOHW":"668"},"place_name":"國立臺灣史前文化博物館","city":"臺東縣","district":"臺東市","address":"臺東縣臺東市博物館路1號","lat":22.769455,"lng":121.093926,"location_desc":"展廳1樓入口大廳","open_hours":"星期一休館"},{"aed_id":"TW-39f894a519d9ca3e","source_mapping":{"MOHW":"677"},"place_name":"統一健身俱樂部-高雄夢時代館","city":"高雄市","district":"前鎮區","address":"高雄市前鎮區中華五路769號6樓","lat":22.594984,"lng":120.306784,"location_desc":"大廳入口右側","open_hours":"平日 07:00:00–23:00:00;週六 07:00:00–23:00:00;週日 07:00:00–23:00:00"},{"aed_id":"TW-3ee525b47a292bcc","source_mapping":{"MOHW":"3018"},"place_name":"金酒公司實業股份有限公司-金城廠","city":"金門縣","district":"金城鎮","address":"金門縣金城鎮金門城68號","lat":24.399434,"lng":118.304713,"location_desc":"金酒公司金城廠大門入口左手邊員工福利社門口","open_hours":"平日 08:00:00–17:30:00;週六 08:00:00–17:30:00;週日 08:00:00–17:30:00"},{"aed_id":"TW-42f730a51f0a40fb","source_mapping":{"MOHW":"708"},"place_name":"錄銳製罐企業股份有限公司","city":"彰化縣","district":"北斗鎮","address":"彰化縣北斗鎮大新路649號","lat":23.856007,"lng":120.554754,"location_desc":"辦公室前方之會議室內","open_hours":"星期六不定時上班"},{"aed_id":"TW-4bf256a52437b95f","source_mapping":{"MOHW":"487"},"place_name":"臺南市新營區衛生所","city":"臺南市","district":"新營區","address":"臺南市新營區三民路72號","lat":23.307466,"lng":120.317153,"location_desc":"臺南市新營區衛生所","open_hours":"平日 11:39:00–11:39:00"},{"aed_id":"TW-4c06d9a524494102","source_mapping":{"MOHW":"422"},"place_name":"日月潭纜車股份有限公司","city":"南投縣","district":"魚池鄉","address":"南投縣魚池鄉日月村中正路102號","lat":23.852278459584,"lng":120.93513565404,"location_desc":"服務臺旁","open_hours":"遇纜車保養日園區暫停開放 (保養日請上官網查詢)"},{"aed_id":"TW-4c06dfa524494b34","source_mapping":{"MOHW":"428"},"place_name":"日月潭國家風景區伊達邵遊客中心","city":"南投縣","district":"魚池鄉","address":"南投縣魚池鄉日月村文化街127號","lat":23.84867,"lng":120.929881,"location_desc":"伊達邵遊客中心急診醫療站救護車","open_hours":"於颱風警報期間經南投縣政府宣布停止上班上課情形者除外"},{"aed_id":"TW-4c09daa5244b788c","source_mapping":{"MOHW":"433"},"place_name":"國立臺東專科學校","city":"臺東縣","district":"臺東市","address":"臺東縣臺東市正氣北路889號","lat":22.757392,"lng":121.126084,"location_desc":"保健室門口","open_hours":"平日 08:00:00–17:30:00"},{"aed_id":"TW-4c0d5ba5244e8996","source_mapping":{"MOHW":"408"},"place_name":"漢翔航空工業股份有限公司 岡山廠","city":"高雄市","district":"岡山區","address":"高雄市岡山區崗德路1號","lat":22.811184142357,"lng":120.31995731277,"location_desc":"漢翔航空工業股份有限公司-岡山廠-鑄造門口","open_hours":"本公司目前之商品(服務)為國防、民用航空以及工業技術服務三大業務區塊。\n國防業務包含國內軍用飛機之製造、維修、性能提升、機隊商維、軍工廠委託民間經營案等,以及軍用引擎業務。\n民用航空業務則包含民用"},{"aed_id":"TW-54c10ea5293f3bee","source_mapping":{"MOHW":"556"},"place_name":"桃園市復興區衛生所","city":"桃園市","district":"復興區","address":"桃園市復興區澤仁里中正路25號","lat":24.814848,"lng":121.350988,"location_desc":"本所進大門左側牆面","open_hours":"平日 08:00:00–17:00:00"},{"aed_id":"TW-54c804a52945499e","source_mapping":{"MOHW":"578"},"place_name":"文藻外語大學","city":"高雄市","district":"三民區","address":"高雄市三民區民族一路900號","lat":22.670349,"lng":120.31914,"location_desc":"正氣樓穿堂靠近樓梯處","open_hours":"平日 08:00:00–17:00:00"},{"aed_id":"TW-54ce89a5294a974b","source_mapping":{"MOHW":"591"},"place_name":"華通電腦股份有限公司大園廠","city":"桃園市","district":"大園區","address":"桃園市大園區中山北路275號","lat":25.075077,"lng":121.187425,"location_desc":"一樓辦公室醫務室置物櫃上","open_hours":"平日 08:00:00–00:00:00;週六 08:00:00–00:00:00;週日 08:00:00–00:00:00"},{"aed_id":"TW-54d20ca5294dabbb","source_mapping":{"MOHW":"582"},"place_name":"華儲股份有限公司","city":"桃園市","district":"大園區","address":"桃園市大園區航勤北路10-1號","lat":25.089699,"lng":121.245364,"location_desc":"警備隊","open_hours":"平日 00:00:00–23:59:00;週六 00:00:00–23:59:00;週日 00:00:00–23:59:00"},{"aed_id":"TW-54d20fa5294db0d4","source_mapping":{"MOHW":"587"},"place_name":"神木谷假期大飯店","city":"臺中市","district":"和平區","address":"臺中市和平區博愛里東關路一段溫泉巷7號","lat":24.204311,"lng":121.009436,"location_desc":"餐廳自動門旁","open_hours":"平日 00:00:00–23:59:00;週六 00:00:00–23:59:00;週日 00:00:00–23:59:00"},{"aed_id":"TW-5506729c93d02c41","source_mapping":{"MOHW":"4661"},"place_name":"慶安宮","city":"基隆市","district":"仁愛區","address":"基隆市仁愛區忠二路1號","lat":25.129628,"lng":121.740609,"location_desc":"媽祖殿旁","open_hours":"平日 06:00:00–22:00:00;週六 06:00:00–22:00:00;週日 06:00:00–22:00:00"},{"aed_id":"TW-5b40a7c63469b3fb","source_mapping":{"MOHW":"1967"},"place_name":"交通部觀光署阿里山國家風景區管理處里佳資訊站","city":"嘉義縣","district":"阿里山鄉","address":"嘉義縣阿里山鄉里佳31號","lat":23.404577,"lng":120.718632,"location_desc":"AED放置於里佳資訊站內","open_hours":"平日 08:30:00–17:30:00;週六 08:30:00–17:30:00;週日 08:30:00–17:30:00"},{"aed_id":"TW-5de82aa52e91de3f","source_mapping":{"MOHW":"281"},"place_name":"臺東縣長濱鄉衛生所","city":"臺東縣","district":"長濱鄉","address":"臺東縣長濱鄉長濱村5鄰15號","lat":23.318852,"lng":121.453891,"location_desc":"放置於衛生所一樓候診區,位置明顯。","open_hours":"逢例假日及國定假日關閉。"},{"aed_id":"TW-8041349cac46642f","source_mapping":{"MOHW":"4300"},"place_name":"吉隆秀泰影城股份有限公司","city":"基隆市","district":"中正區","address":"基隆市中正區信一路177號7-10樓","lat":25.130813,"lng":121.744834,"location_desc":"基隆秀泰影城10樓","open_hours":"平日 09:00:00–23:59:00;週六 09:00:00–23:59:00;週日 09:00:00–23:59:00"},{"aed_id":"TW-869379c64cf498fa","source_mapping":{"MOHW":"1410"},"place_name":"雲林縣元長鄉農會","city":"雲林縣","district":"元長鄉","address":"雲林縣元長鄉長南村中山路42號","lat":23.650196,"lng":120.313549,"location_desc":"農會進門左邊靠ATM入口處","open_hours":"週休二日不營業"},{"aed_id":"TW-8696e3c64cf782ef","source_mapping":{"MOHW":"1404"},"place_name":"雲林縣議會","city":"雲林縣","district":"斗六市","address":"雲林縣斗六市大學路3段222號","lat":23.695817,"lng":120.528525,"location_desc":"一樓入口左側約中段處","open_hours":"平日 08:00:00–17:30:00;週六 00:00:00–00:00:00;週日 00:00:00–00:00:00"},{"aed_id":"TW-869de8c64cfdaa1c","source_mapping":{"MOHW":"1463"},"place_name":"大鵬灣遊客中心","city":"屏東縣","district":"東港鎮","address":"屏東縣東港鎮大鵬里大潭路169號","lat":22.456418,"lng":120.478807,"location_desc":"大鵬灣遊客中心大門進去,左前方靠牆位子","open_hours":"平日 08:30:00–17:30:00;週六 08:30:00–17:30:00;週日 08:30:00–17:30:00"},{"aed_id":"TW-86a46ec64d02f97c","source_mapping":{"MOHW":"1449"},"place_name":"屏東縣屏東市中正國民小學","city":"屏東縣","district":"屏東市","address":"屏東縣屏東市蘇州街75號","lat":22.673742698909,"lng":120.48839208483,"location_desc":"進入蘇州街大門 左轉 至健康中心 (學務處旁)","open_hours":"下班時間、星期六日及例假日不開放校外使用"},{"aed_id":"TW-8fad1cc6523be354","source_mapping":{"MOHW":"1706"},"place_name":"雲林縣褒忠鄉衛生所","city":"雲林縣","district":"褒忠鄉","address":"雲林縣褒忠鄉中勝村添財街21號","lat":23.693269,"lng":120.304166,"location_desc":"衛生所大門入口處左側","open_hours":"平日 08:00:00–17:30:00"},{"aed_id":"TW-8fb0a0c6523ef977","source_mapping":{"MOHW":"1710"},"place_name":"嘉義縣私立協同高級中學","city":"嘉義縣","district":"民雄鄉","address":"嘉義縣民雄鄉建國二路31號","lat":23.532317,"lng":120.436753,"location_desc":"行政中心一樓.樓梯左手邊.大門口右手邊","open_hours":"平日 08:00:00–17:00:00"},{"aed_id":"TW-986b39c657359e4f","source_mapping":{"MOHW":"1660"},"place_name":"長庚科技大學嘉義分部","city":"嘉義縣","district":"朴子市","address":"嘉義縣朴子市嘉朴路西段2號","lat":23.463167223554,"lng":120.2897710646,"location_desc":"位於學生第二宿舍一樓大廳","open_hours":"平日 08:00:00–22:00:00;週六 08:00:00–22:00:00;週日 08:00:00–22:00:00"},{"aed_id":"TW-a173e1c65c6e7df5","source_mapping":{"MOHW":"1125"},"place_name":"環球購物中心股份有限公司屏東分公司","city":"屏東縣","district":"屏東市","address":"屏東縣屏東市仁愛路90號","lat":22.673267,"lng":120.493402,"location_desc":"環球購物中心 五樓客梯前方","open_hours":"平日 11:00:00–22:00:00;週六 10:30:00–22:00:00;週日 10:30:00–22:00:00"},{"aed_id":"TW-a8bf0cc6602ca02f","source_mapping":{"MOHW":"1053"},"place_name":"花蓮縣卓溪鄉衛生所","city":"花蓮縣","district":"卓溪鄉","address":"花蓮縣卓溪鄉卓溪村中正65號","lat":23.345856,"lng":121.304177,"location_desc":"一樓茶水間外","open_hours":"平日 08:00:00–17:00:00"},{"aed_id":"TW-a8c298c6602fc3ea","source_mapping":{"MOHW":"1047"},"place_name":"花蓮縣花蓮市公所","city":"花蓮縣","district":"花蓮市","address":"花蓮縣花蓮市林森路252號","lat":23.982087,"lng":121.606636,"location_desc":"辦公大樓一樓大廳","open_hours":"平日 08:00:00–17:30:00"},{"aed_id":"TW-a8c611c66032c75c","source_mapping":{"MOHW":"1038"},"place_name":"國立東華大學","city":"花蓮縣","district":"壽豐鄉","address":"花蓮縣壽豐鄉大學路2段1號","lat":23.893543,"lng":121.539396,"location_desc":"游泳池大門右側售票處內","open_hours":"游泳池開放才可取得 開放時間: 06:00-10:00;16:00-22:00 每周一及國定假日休館"},{"aed_id":"TW-a8c61cc66032da0d","source_mapping":{"MOHW":"1033"},"place_name":"國立宜蘭高級商業職業學校","city":"宜蘭縣","district":"宜蘭市","address":"宜蘭縣宜蘭市延平路50號","lat":24.753379,"lng":121.768684,"location_desc":"學務處外","open_hours":"平日 00:00:00–23:59:00;週六 00:00:00–23:59:00;週日 00:00:00–23:59:00"},{"aed_id":"TW-a8c919c660350acb","source_mapping":{"MOHW":"1026"},"place_name":"工業技術研究院","city":"新竹市","district":"北區","address":"新竹市北區光復路2段321號","lat":24.800407,"lng":120.988306,"location_desc":"27館門口","open_hours":"平日 08:00:00–17:00:00"},{"aed_id":"TW-a8cc8dc6603805be","source_mapping":{"MOHW":"1018"},"place_name":"漢民科技竹科分公司","city":"新竹市","district":"東區","address":"新竹市東區新竹市科學園區研新一路18號","lat":24.79206,"lng":120.993368,"location_desc":"漢民科技竹科分公司通往4F餐廳走道","open_hours":"平日 08:40:00–18:00:00"},{"aed_id":"TW-a8d013c6603b1f47","source_mapping":{"MOHW":"1008"},"place_name":"陽明交通大學光復校區13舍便利超商","city":"新竹市","district":"東區","address":"新竹市東區大學路1001號","lat":24.789062,"lng":120.998719,"location_desc":"光復校區13舍","open_hours":"平日 08:00:00–23:30:00"},{"aed_id":"TW-a8eb12c66051e54c","source_mapping":{"MOHW":"1089"},"place_name":"新光三越嘉義垂楊店","city":"嘉義市","district":"西區","address":"嘉義市西區垂楊路726號","lat":23.473116,"lng":120.44137,"location_desc":"1樓東側門進入之後,左手邊打卡機旁,三部電梯斜對面","open_hours":"平日 11:00:00–22:00:00;週六 11:00:00–22:00:00;週日 11:00:00–22:00:00"},{"aed_id":"TW-a8eb1ac66051f2e4","source_mapping":{"MOHW":"1081"},"place_name":"嘉義市東區衛生所","city":"嘉義市","district":"東區","address":"嘉義市東區大雅路二段625號","lat":23.476948,"lng":120.466271,"location_desc":"放置1樓大廳,面向電梯前左側〈於預訪注射掛號室右側〉。","open_hours":"平日 08:00:00–17:30:00;週六 00:00:00–00:00:00;週日 00:00:00–00:00:00"},{"aed_id":"TW-a8eb1bc66051f497","source_mapping":{"MOHW":"1080"},"place_name":"嘉義市西區衛生所","city":"嘉義市","district":"西區","address":"嘉義市西區德明路1號","lat":23.479976,"lng":120.431072,"location_desc":"嘉義市西區衛生所大門口","open_hours":"全日開放 但不定時支援救護時 不提供服務."},{"aed_id":"TW-baffe7c66ac6bc6e","source_mapping":{"MOHW":"1230"},"place_name":"國道高速公路南投服務區","city":"南投縣","district":"南投市","address":"南投縣南投市南鄉路250巷142弄6號","lat":23.899923,"lng":120.711336,"location_desc":"一樓服務臺旁","open_hours":"平日 00:00:00–23:59:00;週六 00:00:00–23:59:00;週日 00:00:00–23:59:00"},{"aed_id":"TW-bb0368c66ac9cd78","source_mapping":{"MOHW":"1225"},"place_name":"建國科技大學","city":"彰化縣","district":"彰化市","address":"彰化縣彰化市建寶里001鄰介壽北路1號","lat":24.066246,"lng":120.550058,"location_desc":"師生活動中心門口","open_hours":"平日 08:00:00–24:00:00;週六 08:00:00–24:00:00;週日 08:00:00–24:00:00"},{"aed_id":"TW-bc577eade7c40893","source_mapping":{"MOHW":"2463"},"place_name":"澎湖縣西嶼鄉公所","city":"澎湖縣","district":"西嶼鄉","address":"澎湖縣西嶼鄉池東村72號","lat":23.601714,"lng":119.512221,"location_desc":"西嶼鄉公所1樓辦公廳大門旁","open_hours":"平日 08:00:00–17:30:00;週六 00:00:00–00:00:00;週日 00:00:00–00:00:00"},{"aed_id":"TW-d5cf87adf60ba31b","source_mapping":{"MOHW":"2711"},"place_name":"澎湖縣政府警察局澎湖警光會館","city":"澎湖縣","district":"馬公市","address":"澎湖縣馬公市明遠路52號","lat":23.570882,"lng":119.565811,"location_desc":"警光會館一樓大廳","open_hours":"24小時全年開放"},{"aed_id":"TW-d5d290adf60de83d","source_mapping":{"MOHW":"2704"},"place_name":"交通部公路總局高雄區監理所澎湖監理站","city":"澎湖縣","district":"馬公市","address":"澎湖縣馬公市光華里121號","lat":23.558981,"lng":119.60043,"location_desc":"澎湖監理站行政大樓1樓","open_hours":"平日 08:00:00–17:00:00"}]; // END GENERATED SEED