EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Linux / แอปเก็บเอกสารหน้าเดียวด้วย PHP กับ SQLite ที่เก็บต้นฉบับไว้เสมอ
Linux

แอปเก็บเอกสารหน้าเดียวด้วย PHP กับ SQLite ที่เก็บต้นฉบับไว้เสมอ

โดย Ton-Or ·27 ก.ย. 2026·อ่าน 9 นาที
แอปเก็บเอกสารหน้าเดียวด้วย PHP กับ SQLite ที่เก็บต้นฉบับไว้เสมอ

ครอปรูปเพื่อให้มันพอดีกรอบแล้วเก็บแต่ผลลัพธ์ไว้ คือการทำลายข้อมูลถาวรโดยไม่รู้ตัว กว่าจะรู้ว่าครอปพลาดคือตอนที่รูปต้นฉบับหายไปจากมือถือคนถ่ายแล้ว

พอดีผมทำระบบเก็บเอกสารสำหรับงานยื่นขออนุญาตกับหน่วยงานราชการชุดหนึ่ง ลักษณะงานคือมีรายการเอกสารที่ต้องเตรียมสามสิบกว่ารายการ คนหน้างานถ่ายรูปเอกสารด้วยมือถือแล้วอัปเข้าเว็บทีละใบ ระบบเก็บให้ แล้วเอาไปวางในเอกสารพร้อมพิมพ์อีกทีหนึ่ง

ตอนแรกผมทำแบบตรงไปตรงมาที่สุด คือรับรูปมาแล้วครอปให้พอดีกรอบที่จะแสดง เก็บผลลัพธ์ไว้ไฟล์เดียว ใช้ได้อยู่พักหนึ่งจนวันที่มีเอกสารแผ่นใหญ่ใบหนึ่งโดนครอปหลุดขอบไปสองสามมิลลิเมตร แล้วปรากฏว่ากลับไปแก้ไม่ได้เลย เพราะรูปต้นฉบับมีอยู่ที่เดียวคือในมือถือของคนถ่ายซึ่งลบไปแล้ว บทเรียนของวันนั้นเลยกลายเป็นสถาปัตยกรรมของทั้งระบบ

ชื่อกิจการ ชื่อบุคคล คีย์ รหัสผ่าน และที่อยู่เว็บทั้งหมดในบทความนี้เป็นตัวอย่างที่ผมสมมติขึ้น ส่วนโครงสร้าง ตัวเลข และโค้ดคือของจริงจากงานนั้น

หนึ่งการอัปโหลดรูปจากมือถือไม่กำหนดขนาด->ตัวปรับรูปในหน้าเว็บซูม เลื่อน หรือไม่แตะเลย->key.jpgไฟล์ที่ใช้แสดงและพิมพ์->key.orig.jpgต้นฉบับ เก็บไว้เสมอ->key.crop.jsonค่าซูมและตำแหน่ง

หนึ่งการอัปโหลด เท่ากับสามไฟล์

กฎข้อเดียวที่ผมยึดตลอดทั้งระบบคือ ห้ามให้ผลลัพธ์ของการครอปเป็นข้อมูลชุดเดียวที่เหลืออยู่ เพราะฉะนั้นการอัปหนึ่งครั้งเขียนสามไฟล์เสมอ ไฟล์แรกคือรูปที่จะเอาไปแสดงและพิมพ์ ไฟล์ที่สองคือต้นฉบับที่ย่อลงมาแล้วแต่ยังไม่ได้ตัดอะไรเลย ไฟล์ที่สามคือค่าซูมกับตำแหน่งที่ใช้ครอปครั้งนั้น

<?php
// action=savestep — รับรูปที่ครอปแล้ว + ต้นฉบับ + ค่าครอป ในคำขอเดียว
if (isset($_GET['action']) && $_GET['action'] === 'savestep') {
  header('Content-Type: application/json; charset=utf-8');
  if (!authed()) { echo json_encode(['ok'=>false,'err'=>'ยังไม่ได้เข้าสู่ระบบ']); exit; }

  $k = preg_replace('/[^a-z0-9_]/i', '', (string)($_POST['key'] ?? ''));   // คีย์มาจากผู้ใช้ ต้องล้าง
  if ($k === '' || empty($_FILES['file']['tmp_name']) || !is_uploaded_file($_FILES['file']['tmp_name'])) {
    echo json_encode(['ok'=>false,'err'=>'ไม่พบไฟล์ที่อัปโหลด']); exit;
  }
  $bin = (string)file_get_contents($_FILES['file']['tmp_name']);
  $sz  = strlen($bin);
  // เช็กสองไบต์แรกว่าเป็น JPEG จริง ไม่เชื่อนามสกุลและไม่เชื่อ content-type ที่เบราว์เซอร์ส่งมา
  if ($sz < 500 || $sz > 8*1024*1024 || substr($bin, 0, 2) !== chr(0xFF).chr(0xD8)) {
    echo json_encode(['ok'=>false,'err'=>'ไฟล์ไม่ถูกต้องหรือใหญ่เกินไป']); exit;
  }
  if (!is_dir($STEPDIR)) {
    @mkdir($STEPDIR, 0755, true);
    @file_put_contents($STEPDIR.'/.htaccess', 'Deny from all');   // ปิดตั้งแต่วินาทีที่สร้างโฟลเดอร์
  }
  file_put_contents($STEPDIR.'/'.$k.'.jpg', $bin);

  // เก็บ 'ต้นฉบับ' ไว้เสมอ เพื่อให้กลับมาปรับครอปหรือซูมใหม่ได้ภายหลัง
  if (!empty($_FILES['orig']['tmp_name']) && is_uploaded_file($_FILES['orig']['tmp_name'])) {
    $ob = (string)file_get_contents($_FILES['orig']['tmp_name']);
    $os = strlen($ob);
    if ($os >= 500 && $os <= 16*1024*1024 && substr($ob, 0, 2) === chr(0xFF).chr(0xD8)) {
      file_put_contents($STEPDIR.'/'.$k.'.orig.jpg', $ob);
    }
  }
  // ค่าครอปและซูมล่าสุด ไว้เปิดกลับมาแก้ต่อจากเดิมได้
  $meta = (string)($_POST['meta'] ?? '');
  if ($meta !== '' && strlen($meta) < 2000) {
    $j = json_decode($meta, true);
    if (is_array($j)) file_put_contents($STEPDIR.'/'.$k.'.crop.json', json_encode($j));
  }
  echo json_encode(['ok'=>true, 'key'=>$k, 't'=>time(),
                    'orig'=>is_file($STEPDIR.'/'.$k.'.orig.jpg') ? 1 : 0]); exit;
}

สังเกตว่าเพดานขนาดของสองไฟล์ไม่เท่ากัน ไฟล์แสดงจำกัดที่ 8 เมกะไบต์ ส่วนต้นฉบับให้ถึง 16 เมกะไบต์ เพราะต้นฉบับความละเอียดสูงกว่าโดยธรรมชาติ

ตัวปรับรูปฝั่งเบราว์เซอร์ ค่าตั้งต้นคือเก็บทั้งรูป

การย่อและครอปทำในเบราว์เซอร์ทั้งหมด เซิร์ฟเวอร์ไม่ต้องแตะรูปเลยสักครั้ง ซึ่งบนโฮสต์ที่ทรัพยากรจำกัดนี่ช่วยได้เยอะมาก

จุดที่ผมออกแบบใหม่หลังเจอปัญหาคือ ค่าตั้งต้นของช่องติ๊กคือ "เก็บทั้งรูป ไม่ตัดขอบ" คนหน้างานที่แค่กดอัปแล้วกดบันทึกโดยไม่แตะอะไรเลย จะได้รูปเต็มใบทุกครั้ง การครอปตามสัดส่วนกรอบเกิดขึ้นเฉพาะเมื่อเขาตั้งใจถ่างนิ้วซูมหรือเลื่อนรูปเท่านั้น

function cropSave() {
  var im = document.getElementById('cropimg'), v = document.getElementById('cropview');
  var vw = v.clientWidth, vh = v.clientHeight;
  var cv = document.createElement('canvas'), g;
  var whole = document.getElementById('cropwhole').checked;
  // "ไม่ได้แตะอะไรเลย" = ซูมยังเป็น 1 และไม่ได้เลื่อนเกิน 3 พิกเซล
  var untouched = (Math.abs(CROP.scale - 1) < 0.02 && Math.abs(CROP.x) < 3 && Math.abs(CROP.y) < 3);

  if (whole && untouched) {                 /* เก็บทั้งรูป ไม่ตัดขอบ — ย่อด้านยาวสุดเหลือ 1800 */
    var L = 1800, kk = Math.min(1, L / Math.max(CROP.natural[0], CROP.natural[1]));
    cv.width  = Math.round(CROP.natural[0] * kk);
    cv.height = Math.round(CROP.natural[1] * kk);
    g = cv.getContext('2d'); g.fillStyle = '#fff'; g.fillRect(0, 0, cv.width, cv.height);
    g.drawImage(im, 0, 0, cv.width, cv.height);
  } else {                                  /* ครอปตามสัดส่วนกรอบที่กำลังเห็นอยู่ */
    var OUT = 1400;
    cv.width = OUT; cv.height = Math.round(OUT * vh / vw);
    g = cv.getContext('2d'); g.fillStyle = '#fff'; g.fillRect(0, 0, cv.width, cv.height);
    var r = OUT / vw, k = CROP.base * CROP.scale;
    var dw = CROP.natural[0] * k * r, dh = CROP.natural[1] * k * r;
    var dx = (cv.width / 2) + (CROP.x * r) - dw / 2;
    var dy = (cv.height / 2) + (CROP.y * r) - dh / 2;
    g.drawImage(im, dx, dy, dw, dh);
  }
  cv.toBlob(function (blob) {
    var fd = new FormData();
    fd.append('key', CROP.key);
    fd.append('file', blob, CROP.key + '.jpg');
    // เก็บค่าครอปไว้ทั้งชุด เพื่อคืนสภาพหน้าตัวปรับรูปให้เหมือนเดิมตอนกลับมาแก้
    fd.append('meta', JSON.stringify({ whole: whole ? 1 : 0, ar: CROP.ar,
      scale: CROP.scale, x: CROP.x, y: CROP.y, vw: vw, vh: vh }));
    cropSend(fd);
  }, 'image/jpeg', 0.86);
}

อีกจุดที่ต้องมีคือ ถ้าผู้ใช้ติ๊ก "เก็บทั้งรูป" ไว้แล้วเผลอไปเลื่อนหรือซูม ให้ปลดติ๊กออกให้เอง ไม่งั้นเขาจะงงว่าทำไมปรับแล้วไม่มีผล

function cropApply() {
  var im = document.getElementById('cropimg');
  var w = document.getElementById('cropwhole');
  // ผู้ใช้ลงมือปรับเมื่อไร แปลว่าเขาไม่ได้ต้องการทั้งรูปแล้ว ปลดติ๊กให้เอง
  if (w && w.checked && (Math.abs(CROP.scale - 1) > 0.02 ||
                         Math.abs(CROP.x) > 3 || Math.abs(CROP.y) > 3)) w.checked = false;
  var k = CROP.base * CROP.scale;
  im.style.width  = (CROP.natural[0] * k) + 'px';
  im.style.height = (CROP.natural[1] * k) + 'px';
  im.style.transform = 'translate(-50%,-50%) translate(' + CROP.x + 'px,' + CROP.y + 'px)';
}

กลับมาแก้ครอปจากต้นฉบับ โดยไม่อัปต้นฉบับซ้ำ

ตรงนี้คือส่วนที่ทำให้ทั้งเรื่องคุ้มค่ากับความยุ่งยาก เมื่อผู้ใช้กดปุ่มปรับรูปบนกล่องที่มีรูปอยู่แล้ว ระบบไม่ได้ให้เขาไปหารูปในมือถือใหม่ แต่โหลด ต้นฉบับ ที่เก็บไว้กลับมา พร้อมคืนค่าซูมและตำแหน่งเดิมให้ตรงเป๊ะ เขาแค่ขยับต่อจากที่ค้างไว้

key.orig.jpgต้นฉบับ ไม่ถูกแตะตัวปรับรูปขยับต่อจากค่าเดิมkey.jpgไฟล์แสดง ถูกเขียนทับkey.crop.jsonค่าครอป ถูกเขียนทับโหลดต้นฉบับบันทึกคืนค่าซูมกับตำแหน่งเดิมปรับกี่รอบก็ได้ ทุกรอบเริ่มจากต้นฉบับเดิม คุณภาพไม่ลดสะสมต้นฉบับขึ้นเซิร์ฟเวอร์เฉพาะครั้งแรกที่แนบรูป (ด้านยาวสุด 2400 px)
ลูกศรทึบคือการอ่านและเขียนในแต่ละรอบ เส้นประคือการคืนค่าเดิมให้ตัวปรับรูป ไฟล์ต้นฉบับถูกอ่านอย่างเดียวและไม่ถูกเขียนทับ
/** แนบสถานะของกล่องรูปไปกับ HTML: มีต้นฉบับไหม และค่าครอปเดิมคืออะไร */
function stepattrs($k) {
  global $STEPDIR;
  $o = '';
  $of = $STEPDIR.'/'.$k.'.orig.jpg';
  if (is_file($of)) $o .= ' data-orig="'.filemtime($of).'"';   // เวลาแก้ไข = ตัวกัน cache ในตัว
  $cf = $STEPDIR.'/'.$k.'.crop.json';
  if (is_file($cf)) {
    $j = (string)file_get_contents($cf);
    // ค่านี้ไปอยู่ในแอตทริบิวต์ HTML ต้องถอดอักขระที่ทำให้หลุดออกจากแอตทริบิวต์ได้
    if (strlen($j) < 2000) $o .= " data-crop='".str_replace(["'", '<', '>'], ['', '', ''], $j)."'";
  }
  return $o;
}
  1. ผู้ใช้กดปุ่มปรับรูปบนกล่องที่มีรูปอยู่แล้ว โค้ดอ่านค่าครอปเดิมจากแอตทริบิวต์ แล้วตั้งธงว่ารอบนี้ทำงานจากต้นฉบับ
  2. ตัวปรับรูปเปิดโดยโหลดไฟล์ต้นฉบับผ่านสคริปต์ ไม่ใช่ไฟล์ที่ครอปแล้ว รูปเลยยังคมเท่าเดิม
  3. คืนค่าซูมและตำแหน่งเดิม โดยปรับตามอัตราส่วนของขนาดหน้าจอตอนนี้กับตอนที่บันทึกไว้ครั้งก่อน
  4. ผู้ใช้ขยับต่อ แล้วกดบันทึก ระบบเขียนทับเฉพาะไฟล์แสดงกับไฟล์ค่าครอป ไม่แตะต้นฉบับ
  5. ผลคือปรับกี่รอบก็ได้ คุณภาพไม่ลดลงสะสม เพราะทุกรอบเริ่มจากต้นฉบับเดิมเสมอ
/* ส่งขึ้นเซิร์ฟเวอร์ พร้อมต้นฉบับ เฉพาะครั้งแรกที่แนบรูปนั้น */
function cropSend(fd) {
  var im = document.getElementById('cropimg');
  function post() {
    fetch('doc.php?action=savestep', { method: 'POST', body: fd })
      .then(function (r) { return r.json(); })
      .then(function (j) {
        if (!j.ok) { alert(j.err || 'บันทึกไม่สำเร็จ'); return; }
        var fr = document.getElementById('fr_' + j.key);
        fr.innerHTML = '<img src="doc.php?stepimg=' + j.key + '&v=' + j.t + '" alt="">';
        cropClose();
      })
      .catch(function () { alert('เชื่อมต่อไม่ได้'); });
  }
  // แก้จากต้นฉบับเดิม -> ไม่ต้องอัปต้นฉบับซ้ำ ประหยัดทั้งเวลาและแบนด์วิดท์
  if (CROP.fromOrig) { post(); return; }

  // ครั้งแรกเท่านั้น: ย่อต้นฉบับด้านยาวสุดเหลือ 2400 คุณภาพ 0.90 แล้วแนบไปด้วย
  var L = 2400, k = Math.min(1, L / Math.max(CROP.natural[0], CROP.natural[1]));
  var oc = document.createElement('canvas');
  oc.width  = Math.round(CROP.natural[0] * k);
  oc.height = Math.round(CROP.natural[1] * k);
  var og = oc.getContext('2d');
  og.fillStyle = '#fff'; og.fillRect(0, 0, oc.width, oc.height);
  og.drawImage(im, 0, 0, oc.width, oc.height);
  oc.toBlob(function (ob) {
    if (ob) fd.append('orig', ob, CROP.key + '.orig.jpg');
    post();
  }, 'image/jpeg', 0.9);
}
2400pxด้านยาวสุดของต้นฉบับ
1800pxด้านยาวสุดเมื่อเก็บทั้งรูป
1400pxด้านกว้างเมื่อครอปตามกรอบ
0.90คุณภาพ JPEG ของต้นฉบับ
0.86คุณภาพ JPEG ของไฟล์แสดง

เก็บข้อมูลส่วนบุคคล ปิดที่เว็บเซิร์ฟเวอร์แล้วเสิร์ฟผ่านสคริปต์

เอกสารในระบบแบบนี้มีข้อมูลส่วนบุคคลปนอยู่แน่นอน ทั้งสำเนาบัตรประจำตัว สำเนาทะเบียนบ้าน หนังสือรับรอง ซึ่งถ้าปล่อยให้ไฟล์อยู่ในโฟลเดอร์ที่เว็บเซิร์ฟเวอร์เสิร์ฟตรงได้ ใครเดาชื่อไฟล์ถูกก็เปิดดูได้ทันทีโดยไม่ต้องล็อกอิน

สองชั้นเสมอ ปิดที่เว็บเซิร์ฟเวอร์ แล้วเสิร์ฟผ่านสคริปต์ที่ตรวจล็อกอิน

ชั้นแรกคือไฟล์กำหนดค่าในโฟลเดอร์เก็บของ ที่สั่งปฏิเสธทุกคำขอที่วิ่งเข้าไฟล์ตรงๆ ชั้นที่สองคือสคริปต์ PHP ตัวเดียวที่เป็นทางเข้าเดียวของรูป และตรวจสถานะล็อกอินก่อนส่งไบต์แรกออกไปเสมอ อย่าพึ่งชั้นใดชั้นหนึ่งเดี่ยวๆ ถ้าเว็บเซิร์ฟเวอร์เปลี่ยนรุ่นหรือย้ายโฮสต์แล้วไฟล์กำหนดค่าไม่ถูกอ่าน ชั้นที่สองยังกันอยู่ และถ้าโค้ดพลาด ชั้นแรกก็ยังกันอยู่

<?php
// ชั้นแรก: สร้างไฟล์ปฏิเสธการเข้าถึงตรงตั้งแต่ตอนสร้างโฟลเดอร์ ไม่ต้องรอคนมาตั้งค่าเอง
if (!is_dir($DOCS)) @mkdir($DOCS, 0775, true);
$ht = $DOCS . '/.htaccess';
if (!is_file($ht)) {
  @file_put_contents($ht,
      "Require all denied\n"                       // เว็บเซิร์ฟเวอร์รุ่นใหม่
    . "<IfModule !mod_authz_core.c>\n"
    . "Order allow,deny\nDeny from all\n"          // รุ่นเก่าที่ยังไม่มีโมดูลนั้น
    . "</IfModule>\n");
}

// ชั้นที่สอง: ทางเข้าเดียวของไฟล์ ตรวจล็อกอินก่อนส่งไบต์แรกเสมอ
if (($_GET['action'] ?? '') === 'view') {
  if (!authed()) { http_response_code(403); exit; }
  $key  = preg_replace('/[^a-z0-9_]/', '', (string)($_GET['item'] ?? ''));
  $file = basename((string)($_GET['file'] ?? ''));      // basename กันการไต่ไดเรกทอรี
  $p = "$DOCS/$key/$file";
  if (!$key || !$file || !is_file($p)) { http_response_code(404); exit; }
  $ext = strtolower(pathinfo($p, PATHINFO_EXTENSION));
  $ct  = ['jpg'=>'image/jpeg','jpeg'=>'image/jpeg','png'=>'image/png',
          'webp'=>'image/webp','pdf'=>'application/pdf'][$ext] ?? 'application/octet-stream';
  header('Content-Type: '.$ct);
  header('Content-Length: '.filesize($p));
  header('Cache-Control: private, max-age=3600');       // private เท่านั้น ห้ามให้พร็อกซีเก็บ
  readfile($p); exit;
}

จุดเล็กๆ ที่คนพลาดกันบ่อยคือ Cache-Control ถ้าเผลอปล่อยเป็นค่าปริยายหรือใส่ public ตัวกลางระหว่างทางเก็บสำเนารูปเอกสารส่วนบุคคลไว้ได้ ซึ่งเสียของที่อุตส่าห์ทำสองชั้นมา

อย่าใช้เอกสารจริงเป็นข้อมูลทดสอบ

ตอนทดสอบระบบ ผมใช้รูปเอกสารสมมติที่พิมพ์ตารางเปล่าออกมาถ่ายเอง ไม่ใช้สำเนาบัตรประจำตัวหรือทะเบียนบ้านของใครจริงๆ แม้จะเบลอแล้วก็ตาม เพราะไฟล์ทดสอบมีนิสัยหลุดไปอยู่ในที่ที่เราไม่ได้ตั้งใจเสมอ ทั้งในสกรีนช็อต ในรายงาน ในโฟลเดอร์ชั่วคราวที่ลืมลบ และในไฟล์สำรองข้อมูล

รับไฟล์เข้ามาแล้วอย่าเชื่อสิ่งที่ผู้ใช้ส่งมา

อีกทางเข้าหนึ่งของระบบคือหน้าเช็กลิสต์ ที่อัปได้ทีละหลายไฟล์และรับ PDF ด้วย ตรงนี้ผมย่อรูปฝั่งเซิร์ฟเวอร์ด้วยไลบรารีสร้างภาพของ PHP เพราะไฟล์มาจากหลายทางกว่า

<?php
const MAX_BYTES = 25 * 1024 * 1024;   // 25MB ต่อไฟล์
const MAX_SIDE  = 2000;               // ย่อด้านยาวสุดเหลือ 2000 พิกเซล

function save_downscaled($tmp, $dst, $max, $q) {
  $info = @getimagesize($tmp);        // อ่านจากเนื้อไฟล์จริง ไม่ใช่จากนามสกุล
  if (!$info) return false;
  switch ($info['mime']) {
    case 'image/jpeg': $im = @imagecreatefromjpeg($tmp); break;
    case 'image/png':  $im = @imagecreatefrompng($tmp);  break;
    case 'image/webp': $im = @imagecreatefromwebp($tmp); break;
    default: return false;            // อย่างอื่นไม่รับ รวมถึงไฟล์ที่แอบเปลี่ยนนามสกุลมา
  }
  if (!$im) return false;
  $w = imagesx($im); $h = imagesy($im); $nw = $w; $nh = $h;
  if (max($w, $h) > $max) { $r = $max / max($w, $h); $nw = (int)round($w*$r); $nh = (int)round($h*$r); }
  $dstIm = imagecreatetruecolor($nw, $nh);
  // ถมพื้นขาวก่อนเสมอ ไม่งั้น PNG ที่มีพื้นโปร่งจะกลายเป็นพื้นดำตอนบันทึกเป็น JPEG
  imagefilledrectangle($dstIm, 0, 0, $nw, $nh, imagecolorallocate($dstIm, 255, 255, 255));
  imagecopyresampled($dstIm, $im, 0, 0, 0, 0, $nw, $nh, $w, $h);
  imageinterlace($dstIm, true);       // โหลดแบบค่อยๆ ชัด บนเน็ตมือถือรู้สึกเร็วกว่า
  $ok = imagejpeg($dstIm, $dst, $q);
  imagedestroy($im); imagedestroy($dstIm);
  return $ok;
}

ชื่อไฟล์ที่เขียนลงดิสก์ผมไม่ใช้ชื่อเดิมที่ผู้ใช้ส่งมาเลยแม้แต่ครั้งเดียว ตั้งใหม่เป็นเวลาบวกค่าสุ่มสั้นๆ แล้วบังคับนามสกุลตามชนิดที่ตรวจได้จริง วิธีนี้ปิดปัญหาเรื่องอักขระแปลกในชื่อไฟล์ ชื่อซ้ำ และไฟล์ที่แอบใส่นามสกุลสองชั้นไปพร้อมกันทีเดียว

ย้ายจาก JSON มาเป็น SQLite แล้วดูดของเดิมเข้าให้เอง

ตอนเริ่มผมเก็บสถานะเป็นไฟล์ JSON สามไฟล์ คือหมายเหตุ ผู้รับผิดชอบ และสถานะติ๊กแล้ว ส่วนรายการไฟล์อ่านด้วยการไล่สแกนโฟลเดอร์ทีละรายการ ใช้ได้ดีตอนของยังน้อย พอรูปเริ่มเยอะ การเปิดหน้าเดียวต้องสแกนสามสิบกว่าโฟลเดอร์ทุกครั้ง

ก่อน: JSON กับการสแกนโฟลเดอร์

เริ่มง่าย แต่โตแล้วเจ็บ

  • เปิดหน้าเดียวสแกนสามสิบกว่าโฟลเดอร์
  • เขียนพร้อมกันสองคำขอแล้วทับกันเงียบๆ
  • ถามว่า "รายการไหนยังไม่มีไฟล์" ต้องไล่เองทั้งหมด
  • ขนาดไฟล์กับชนิดต้องไปถามดิสก์ทุกครั้ง
หลัง: SQLite ไฟล์เดียว

คิวรีเดียวจบ ไม่ต้องลงอะไรเพิ่ม

  • รายการทั้งหน้าได้จากคิวรีเดียว
  • เขียนผ่านคำสั่งเดียวแบบมีก็อัปเดตไม่มีก็เพิ่ม
  • ดัชนีทำให้ถามอะไรก็เร็วเท่าเดิม
  • ไฟล์รูปยังอยู่บนดิสก์ ฐานข้อมูลเก็บแค่ข้อมูลกำกับ

สิ่งที่ผมคิดว่าสำคัญกว่าตัวเลือกฐานข้อมูลคือ การย้ายต้องเกิดขึ้นเอง ไม่ใช่งานที่ต้องมีคนไปสั่ง ผมเลยผูกการย้ายไว้กับการสร้างฐานข้อมูลครั้งแรก ครั้งที่ใครก็ตามเปิดหน้าเว็บหลังอัปเดตโค้ด ระบบจะเห็นว่ายังไม่มีไฟล์ฐานข้อมูล สร้างโครงตาราง แล้วดูดทั้งไฟล์บนดิสก์และ JSON เดิมเข้ามาให้ในทรานแซกชันเดียว ไฟล์ JSON เดิมยังอยู่ครบในฐานะไฟล์สำรอง

-- โครงตารางทั้งหมดของระบบ มีแค่สองตาราง
CREATE TABLE IF NOT EXISTS files (
  id      INTEGER PRIMARY KEY AUTOINCREMENT,
  item    TEXT NOT NULL,          -- รายการเอกสารที่ไฟล์นี้สังกัด
  fname   TEXT NOT NULL,          -- ชื่อไฟล์บนดิสก์ (ที่เราตั้งเอง)
  mime    TEXT,
  size    INTEGER,
  created TEXT
);
CREATE INDEX  IF NOT EXISTS ix_files_item ON files(item);
CREATE UNIQUE INDEX IF NOT EXISTS ux_files ON files(item, fname);   -- กันบันทึกซ้ำตอนย้ายซ้ำ

CREATE TABLE IF NOT EXISTS items (
  item    TEXT PRIMARY KEY,
  note    TEXT,                   -- หมายเหตุที่บันทึกอัตโนมัติระหว่างพิมพ์
  owner   TEXT,                   -- ผู้รับผิดชอบรายการนี้
  checked INTEGER DEFAULT 0,      -- ติ๊กว่ามีเอกสารแล้ว แม้ยังไม่ได้อัปไฟล์
  updated TEXT
);
<?php
function db() {
  global $DBF;
  static $pdo = null;
  if ($pdo) return $pdo;
  $new = !is_file($DBF);                       // ยังไม่มีไฟล์ = ครั้งแรก = ต้องย้ายของเก่า
  $pdo = new PDO('sqlite:'.$DBF);
  $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
  $pdo->exec('PRAGMA busy_timeout=5000');      // สองคำขอชนกันให้รอ ไม่ใช่โยน error ใส่หน้าผู้ใช้
  $pdo->exec('CREATE TABLE IF NOT EXISTS files(...)');
  $pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS ux_files ON files(item,fname)');
  $pdo->exec('CREATE TABLE IF NOT EXISTS items(...)');
  if ($new) db_migrate($pdo);
  return $pdo;
}

function db_migrate($pdo) {                    // ดูดไฟล์บนดิสก์ + JSON เดิมเข้าฐานข้อมูล
  global $DOCS, $STATE, $OWN, $CHK;
  $pdo->beginTransaction();                    // ล้มกลางทางแล้วไม่เหลือของครึ่งๆ กลางๆ
  $ins = $pdo->prepare('INSERT OR IGNORE INTO files(item,fname,mime,size,created) VALUES(?,?,?,?,?)');
  foreach (glob($DOCS.'/*', GLOB_ONLYDIR) as $dir) {
    $item = basename($dir);
    foreach (scandir($dir) as $f) {
      if ($f === '' || $f[0] === '.') continue;
      $ext = strtolower(pathinfo($f, PATHINFO_EXTENSION));
      if ($ext === '' || !is_file("$dir/$f")) continue;
      $mime = ['jpg'=>'image/jpeg','png'=>'image/png','pdf'=>'application/pdf'][$ext] ?? 'application/octet-stream';
      $ins->execute([$item, $f, $mime, filesize("$dir/$f"), date('Y-m-d H:i:s', filemtime("$dir/$f"))]);
    }
  }
  $rd = function ($p) { return is_file($p) ? (json_decode(file_get_contents($p), true) ?: []) : []; };
  $st = $rd($STATE); $ow = $rd($OWN); $ck = $rd($CHK);
  $keys = array_unique(array_merge(array_keys($st), array_keys($ow), array_keys($ck)));
  $up = $pdo->prepare('INSERT OR REPLACE INTO items(item,note,owner,checked,updated) VALUES(?,?,?,?,?)');
  foreach ($keys as $k) {
    $up->execute([$k, isset($st[$k]) ? (string)$st[$k] : null,
                  $ow[$k] ?? null, !empty($ck[$k]) ? 1 : 0, date('Y-m-d H:i:s')]);
  }
  $pdo->commit();
}

ผลข้างเคียงที่ต้องรู้ทันคือ ฟังก์ชันเดิมที่ใช้สแกนโฟลเดอร์และอ่าน JSON กลายเป็นโค้ดตายทันทีที่ย้ายเสร็จ ผมปล่อยไว้รอบหนึ่งเพื่อกันเหนียว แล้วค่อยลบทีหลัง แต่ต้องเขียนคอมเมนต์กำกับไว้ ไม่งั้นเดือนหน้ากลับมาอ่านแล้วนึกว่ายังใช้อยู่

ลบให้ครบทั้งสามไฟล์

ข้อสุดท้ายสั้นแต่พลาดง่ายมาก ในเมื่อการอัปหนึ่งครั้งสร้างสามไฟล์ การลบก็ต้องลบครบสามไฟล์ ไม่งั้นต้นฉบับของเอกสารที่ผู้ใช้ตั้งใจลบทิ้งจะยังนอนอยู่บนดิสก์ ซึ่งในระบบที่เก็บข้อมูลส่วนบุคคลถือว่าเป็นเรื่องใหญ่ ไม่ใช่แค่เปลืองที่

<?php
if (($_GET['action'] ?? '') === 'delstep') {
  header('Content-Type: application/json; charset=utf-8');
  if (!authed()) { echo json_encode(['ok'=>false,'err'=>'ยังไม่ได้เข้าสู่ระบบ']); exit; }
  $k = preg_replace('/[^a-z0-9_]/i', '', (string)($_POST['key'] ?? ''));
  if ($k !== '') {
    // ลบครบทั้งสามไฟล์ ไม่งั้นต้นฉบับที่มีข้อมูลส่วนบุคคลค้างอยู่บนดิสก์
    foreach ([$k.'.jpg', $k.'.orig.jpg', $k.'.crop.json'] as $fn) {
      $f = $STEPDIR.'/'.$fn;
      if (is_file($f)) @unlink($f);
    }
  }
  echo json_encode(['ok'=>true]); exit;
}

ตอนออกแบบ ผมแนะนำให้เขียนรายชื่อนามสกุลทั้งชุดไว้เป็นค่าคงที่ที่เดียว แล้วให้ทั้งตัวบันทึก ตัวลบ และตัวสำรองข้อมูล อ่านจากค่าเดียวกัน วันที่เพิ่มไฟล์ที่สี่เข้ามาจะได้ไม่ลืมแก้ที่ใดที่หนึ่ง

สรุป

ทั้งระบบนี้เป็นไฟล์ PHP ไม่กี่ไฟล์กับฐานข้อมูล SQLite หนึ่งไฟล์ อยู่บนโฮสต์ร่วมธรรมดา ไม่มีเฟรมเวิร์ก ไม่มีคิว ไม่มีอะไรที่ต้องคอยดูแล แต่ของที่ผมคิดว่าคุ้มที่สุดในงานนี้ไม่ใช่เรื่องเทคนิคเลย มันคือการตัดสินใจว่าต้นฉบับต้องอยู่เสมอ

ส่วนตัวผมสรุปเป็นกฎสั้นๆ ว่า ทุกครั้งที่ระบบแปลงข้อมูลของผู้ใช้ให้เล็กลง สวยขึ้น หรือพอดีขึ้น ต้องเก็บของก่อนแปลงไว้ด้วยเสมอ ไม่ว่าจะเป็นรูป ไฟล์เสียง ข้อความที่ตัดให้สั้น หรือข้อมูลที่ปัดเศษ เพราะการแปลงกลับมักทำไม่ได้ และตอนที่รู้ว่าแปลงผิด ต้นทางมักหายไปแล้ว ใครจะเก็บที่ความละเอียดเท่าไรก็ตามกำลังพื้นที่ที่มีครับ

ปล. ยังมีอีกเรื่องที่ผมยังไม่ได้ทำในรอบนี้คือการล้างข้อมูลตำแหน่งและข้อมูลกล้องที่ติดมากับรูปจากมือถือ ตอนนี้การวาดลงพื้นที่วาดในเบราว์เซอร์มันตัดทิ้งให้โดยบังเอิญอยู่แล้ว แต่ผมยังไม่ได้ตรวจครบทุกเส้นทางการอัป เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

อยากให้เราทำให้ไหม?

ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า

คุยเรื่องงานกับเรา

เคยเจองานแบบนี้ไหม? ติดตรงไหน?

มาแลกวิธีกันต่อในบอร์ด LinuxThai

→ คุยต่อในบอร์ด

ความคิดเห็น 0

ยังไม่มีความคิดเห็น — เป็นคนแรกเลย