เครื่องในโรงงานหลัง NAT ขึ้นคลาวด์ ด้วย push snapshot + คิวคำสั่ง
เครื่องที่เก็บข้อมูลจริงอยู่ในโรงงาน หลัง NAT ของ ISP ไม่มี public IP และนโยบายคือห้ามเปิดพอร์ตห้ามตั้ง VPN แต่เจ้าของอยากเปิดมือถือดูข้อมูลได้ทุกที่ และกดแก้จากบนเว็บแล้วให้ลงไปถึงฐานข้อมูลข้างในด้วย ทางออกไม่ใช่การเจาะเข้าไป แต่คือกลับทิศให้เครื่องข้างในเป็นฝ่ายออกมาหยิบงานเอง
พอดีมีงานระบบหนึ่งของลูกค้าที่เครื่องเก็บข้อมูลจริงตั้งอยู่ใน LAN ของโรงงาน รันเว็บ PHP กับ MariaDB อยู่ในนั้น ดึงข้อมูลจากเครื่องปลายทางตามรอบ cron วันละหลายช่วง เจ้าของบอกว่าอยากเปิดมือถือดูสรุปได้จากข้างนอก แล้วทีหลังก็ขอเพิ่มอีกว่า "แก้ข้อมูลจากบนเว็บได้ด้วยไหม" ปัญหาคือเครื่องนั้นไม่มี public IP อยู่หลัง NAT ของ ISP และนโยบายชัดเจนว่าไม่เปิด port forward ไม่ตั้ง VPN เพราะไม่มีคนดูแลประจำ ผมเลยต้องออกแบบใหม่ทั้งเส้นทาง
ในบทความนี้ผมเปลี่ยนชื่อระบบ ชื่อ route ชื่อ env ทั้งหมดเป็นชื่อกลาง attsync โดเมนใช้ example.com ส่วนวง LAN ในตัวอย่างสมมติเป็น 192.168.1.0/24 โครงสร้างและตรรกะยกมาจากของจริงทุกบรรทัดครับ
เปิดพอร์ตหรือกลับทิศ — เลือกให้ถูกตั้งแต่ต้น
port forward / DDNS / VPN
ต้องมีขาเข้าจากอินเทอร์เน็ตเข้าถึงเครื่องในโรงงานเสมอ แปลว่ามีพื้นผิวโจมตีที่ต้องมีคนเฝ้า ต้องหมั่นอัปเดต ต้องต่ออายุ cert ต้องจัดการ key และเวลา ISP เปลี่ยน IP หรือเปลี่ยนเราเตอร์ ระบบพังทันทีโดยไม่มีใครรู้
outbound-only: เครื่องในเป็นฝ่ายออกไปหา
ไฟร์วอลล์ขาเข้าปิดสนิทได้ ไม่ต้องมี public IP ไม่ต้องมี DDNS ไม่ต้องมี tunnel ค้างไว้ เครื่องในยิง HTTPS ออกอย่างเดียวเหมือนเปิดเว็บทั่วไป เปลี่ยนเราเตอร์เปลี่ยน ISP ก็ยังทำงานต่อได้เอง
ส่วนตัวผมมองว่าสำหรับงานเล็กที่ไม่มีคนดูแลเต็มเวลา การเปิดขาเข้าเข้าไปในวง LAN ของลูกค้ามีต้นทุนความเสี่ยงสูงเกินคุณค่าของงาน ถ้าโจทย์คือ "อ่านข้อมูล + สั่งแก้บางช่อง" ไม่ใช่ "รีโมตเข้าไปทำงานเต็มรูปแบบ" การกลับทิศคุ้มกว่ามาก
โครงรวม: สามเส้นทาง ออกจากข้างในทั้งหมด
ฝั่งคลาวด์มีสองตาราง ตัวแรกคือกระจกเงาอ่านอย่างเดียว ตัวที่สองคือคิวคำสั่ง ส่วนผู้ใช้ที่เปิดมือถือกดปุ่มแก้ ไม่ได้เขียนอะไรลงเครื่องในโรงงานตรงๆ เลย แค่หย่อนคำสั่งลงคิวเท่านั้น แล้วรอเครื่องในมาหยิบ
ต้นทางคือความจริงเสมอ คลาวด์เป็นแค่กระจก การกดแก้จากคลาวด์มีสถานะเป็น "คำขอ" ไม่ใช่ "การเขียน" จนกว่าเครื่องในโรงงานจะรับไปทำจริงแล้ว push ผลกลับขึ้นมา ถ้ายึดหลักนี้ไม่หลุด ทุกกรณีที่เน็ตหลุดกลางทางจะมีคำตอบว่าใครถูกโดยไม่ต้องเดา
สองตารางบนคลาวด์
ตารางพวกนี้อยู่คนละฐานข้อมูลกับเว็บหลัก ผมเลยสร้างจากโค้ดตรงๆ ไม่ผ่าน migration ตัว DDL จริงเป็นแบบนี้ครับ
CREATE TABLE IF NOT EXISTS att_day (
d DATE NOT NULL PRIMARY KEY,
active_total SMALLINT UNSIGNED NULL,
cnt_full SMALLINT UNSIGNED NULL,
cnt_partial SMALLINT UNSIGNED NULL,
cnt_absent SMALLINT UNSIGNED NULL,
gen_ts INT UNSIGNED NULL,
payload MEDIUMTEXT NOT NULL,
updated_at DATETIME NOT NULL
) DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS att_edit (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
d DATE NOT NULL,
period TINYINT UNSIGNED NOT NULL,
employee_no VARCHAR(12) NOT NULL,
full_name VARCHAR(120) NULL,
status ENUM('present','late','absent') NOT NULL,
created_at DATETIME NOT NULL,
applied_at DATETIME NULL,
UNIQUE KEY uq_edit (d, period, employee_no),
KEY k_pending (applied_at, d)
) DEFAULT CHARSET=utf8mb4;
ฝั่ง PHP เรียกผ่าน connection แยกและสร้างตารางเองเมื่อยังไม่มี:
private function conn()
{
return DB::connection('legacy'); // DB เดิมของอีกระบบ ไม่ปน DB หลักของเว็บ
}
private function ensureTable(): void
{
$this->conn()->statement('CREATE TABLE IF NOT EXISTS att_day ( ... )');
$this->conn()->statement("CREATE TABLE IF NOT EXISTS att_edit ( ... )");
}
att_day เก็บหนึ่งแถวต่อหนึ่งวัน ใช้วันที่เป็น primary key แล้วยัดข้อมูลทั้งวันเป็น JSON ลง payload พร้อมคอลัมน์นับสามตัวไว้ทำหน้ารวมเร็วๆ ส่วน att_edit คือคิว หัวใจอยู่ที่ UNIQUE KEY uq_edit (d, period, employee_no) และ applied_at NULL แปลว่ายังไม่ได้ทำ
ทำไมคิวถึงเป็น UNIQUE ไม่ใช่ append-only เพราะเจตนาสุดท้ายของผู้ใช้ต่อหนึ่งคนหนึ่งช่วงหนึ่งวัน มีได้ค่าเดียว ถ้าเขากดว่า "มา" แล้วเปลี่ยนใจกดเป็น "สาย" ผลที่ต้องได้คือ "สาย" ไม่ใช่ไปทำสองคำสั่งเรียงกันแล้วลุ้นว่าใครถึงก่อน คิวแบบ append-only เหมาะกับ event ที่ทุกใบมีความหมายของตัวเอง แต่งานนี้เป็น state ไม่ใช่ event
route กับ CSRF ที่ต้องยกเว้น
// routes/web.php — ซ่อน ไม่อยู่ใน nav เข้าด้วยรหัสอย่างเดียว
Route::post('/att-sync/ingest', [AttSyncController::class, 'ingest']); // push จาก on-prem (X-API-KEY)
Route::get('/att-sync', [AttSyncController::class, 'index'])->name('attsync');
Route::post('/att-sync/login', [AttSyncController::class, 'login'])->middleware('throttle:10,1');
Route::post('/att-sync/logout', [AttSyncController::class, 'logout']);
Route::get('/att-sync/api', [AttSyncController::class, 'api']);
Route::post('/att-sync/mark', [AttSyncController::class, 'mark'])->middleware('throttle:120,1'); // ผู้ใช้กดแก้ -> เข้าคิว
Route::get('/att-sync/pending', [AttSyncController::class, 'pending']); // on-prem ดึงคิว (X-API-KEY)
Route::post('/att-sync/applied',[AttSyncController::class, 'applied']); // on-prem ยืนยันทำแล้ว (X-API-KEY)
// bootstrap/app.php — server-to-server ไม่มี CSRF token ต้องยกเว้น
$middleware->validateCsrfTokens(except: ['att-sync/ingest', 'att-sync/applied']);
pending เป็น GET เลยไม่ต้องยกเว้น ส่วน mark ผมตั้ง throttle ไว้ 120 ต่อนาทีเพราะคนกดรัวได้ และ login ตั้งไว้ 10 ต่อนาที เพราะหน้านี้เจ้าของขอให้ล็อกอินด้วยรหัสตัวเดียวไม่มี username การกันเดารหัสเลยต้องพึ่ง throttle เป็นหลัก
ถ้าไม่ใส่ route สอง endpoint นี้ใน validateCsrfTokens(except: ...) ฝั่งเครื่องในจะได้ 419 กลับไปเงียบๆ ซึ่ง curl ไม่มี token อยู่แล้วเลยไม่มีทางผ่านเอง อาการที่เห็นคือ "ยิงแล้วไม่ขึ้น" โดยไม่มี error ในล็อกแอปให้ดู อีกกับดักคู่กันคือ WAF บางตัวบล็อก User-Agent ของ curl ตรงๆ ต้องตั้ง UA เองด้วย
auth สองชั้น แยกกันให้ชัด
ชั้นแรกคือเครื่องคุยกับเครื่อง ใช้ shared secret ใน header เทียบแบบ timing-safe ชั้นที่สองคือคนเปิดหน้าเว็บ ใช้รหัสเดียวตามที่เจ้าของขอ แล้วจำด้วย cookie ที่เก็บ hmac ไม่ใช่ตัวรหัส
private const SESS = 'attsync_ok';
private function authed(Request $r): bool
{
if ($r->session()->get(self::SESS)) {
return true;
}
// cookie จำการ login (เข้ารหัสโดย EncryptCookies) — เปลี่ยนรหัสเมื่อไหร่ token เก่าใช้ไม่ได้ทันที
$c = (string) $r->cookie(self::SESS, '');
if ($c !== '' && hash_equals($this->token(), $c)) {
$r->session()->put(self::SESS, true);
return true;
}
return false;
}
private function token(): string
{
$secret = (string) config('attsync.password_hash') ?: (string) config('attsync.password');
return hash_hmac('sha256', 'attsync|' . $secret, (string) config('app.key'));
}
private function apiKeyOk(Request $r): bool
{
$key = (string) config('attsync.key');
return $key !== '' && hash_equals($key, (string) $r->header('X-API-KEY'));
}
// config/attsync.php
return [
'key' => env('ATTSYNC_KEY', ''), // shared secret ให้ on-prem (header X-API-KEY)
'password' => env('ATTSYNC_PASSWORD', ''),
'password_hash' => env('ATTSYNC_PASSWORD_HASH', ''), // ถ้าตั้ง hash จะใช้ hash ก่อน
'remember_days' => (int) env('ATTSYNC_REMEMBER_DAYS', 30),
];
ผลพลอยได้ของการคำนวณ token จากรหัสคือ เปลี่ยนรหัสเมื่อไหร่ cookie เก่าทุกเครื่องตายทันที ไม่ต้องมีตาราง session ให้ไล่ลบ และการเอา app.key มาเป็นเกลือทำให้ก๊อป cookie ข้ามเครื่องข้ามไซต์ไปใช้ไม่ได้ ส่วน hash_equals() ผมใช้ทั้งสองที่ ไม่เทียบด้วย == เพราะการเทียบสตริงธรรมดารั่วเวลาให้เดาทีละไบต์ได้
ขาอ่าน: ingest ที่ยิงซ้ำกี่รอบก็ได้
public function ingest(Request $r)
{
if (!$this->apiKeyOk($r)) abort(401, 'unauthorized');
$body = $r->json()->all();
$date = (string) ($body['date'] ?? '');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || empty($body['periods'])) {
return response()->json(['ok' => false, 'error' => 'bad payload'], 400);
}
$this->ensureTable();
$stat = $body['stat'] ?? [];
$this->conn()->table('att_day')->updateOrInsert(
['d' => $date],
[
'active_total' => (int) ($body['active_total'] ?? 0),
'cnt_full' => (int) ($stat['full'] ?? 0),
'cnt_partial' => (int) ($stat['partial'] ?? 0),
'cnt_absent' => (int) ($stat['absent'] ?? 0),
'gen_ts' => (int) ($body['gen'] ?? time()),
'payload' => json_encode($body, JSON_UNESCAPED_UNICODE),
'updated_at' => $this->nowTh(),
]
);
return response()->json(['ok' => true, 'date' => $date, 'workers' => count($body['workers'] ?? [])]);
}
updateOrInsert บนคีย์ d คือจุดที่ทำให้ยิงซ้ำได้ไม่จำกัด ส่วน nowTh() ของผมคือ gmdate('Y-m-d H:i:s', time() + 25200) บังคับ UTC+7 ตรงๆ ไม่พึ่ง timezone ของโฮสต์ เพราะเครื่องคลาวด์กับเครื่องในโรงงานตั้ง tz คนละแบบได้ และเวลาที่เพี้ยนไปเจ็ดชั่วโมงจะไปโผล่เป็นบั๊กตอนคำนวณ retention ทีหลัง
ทำไมต้อง idempotent — ไม่ใช่ศัพท์สวยแต่เป็นเงื่อนไขของสายที่ขาดได้
คำว่า idempotent แปลตรงตัวว่า "ทำซ้ำแล้วได้ผลเดิม" ฟังดูเป็นทฤษฎี แต่เหตุผลที่ต้องมีมันเป็นเรื่องกายภาพล้วนๆ ครับ เส้นทางระหว่างเครื่องในโรงงานกับคลาวด์คือเน็ตบ้าน มันหลุดได้ทุกเมื่อ และจังหวะที่หลุดมีสามแบบที่ผลต่างกันสิ้นเชิง
สองแบบหลังเป็นตัวปัญหา เพราะเครื่องในทำงานไปแล้วจริงแต่คลาวด์ยังเห็นว่าคิวค้าง รอบหน้าเครื่องในก็จะมาดึงงานเดิมไปทำอีก ถ้าโค้ดฝั่งที่ทำงานเป็นแบบ INSERT ตรงๆ จะได้ข้อมูลซ้อนกันทันที แต่ถ้าเป็น "ตั้งค่าให้เท่ากับ X" ทำกี่รอบผลก็เท่าเดิม
ทั้งระบบผมเลยบังคับตัวเองสามข้อ ข้อแรก ทุกการเขียนต้องเป็น updateOrInsert หรือ set ค่าเป้าหมาย ไม่ใช่ insert หรือ increment ข้อสอง คีย์ธรรมชาติต้องมีจริงและ unique ในที่นี้คือ (วัน, ช่วง, รหัสคน) ข้อสาม การ ack ต้องไม่ใช่การลบทิ้ง แต่เป็นการ mark เวลาแบบมีเงื่อนไข ทั้งสามข้อรวมกันแปลว่าถ้า ack หายกลางทาง รอบถัดไปแค่ทำซ้ำแล้วผลเหมือนเดิมเป๊ะ ไม่มีใครต้องมาไล่ซ่อมมือ
ลองตอบคำถามนี้กับทุก endpoint: ถ้าคำขอเดิมถูกส่งซ้ำสามรอบ ผลลัพธ์ในฐานข้อมูลต่างจากส่งรอบเดียวไหม ถ้าตอบว่าต่าง แปลว่ายังไม่ idempotent และวันหนึ่งที่เน็ตกระตุกคุณจะได้เจอกับมันแน่นอน ผมใช้คำถามนี้ไล่ทีละ endpoint ก่อนปล่อยระบบทุกครั้ง
ack แบบ compare-and-set — ที่กันไม่ให้คำสั่งใหม่หายไปกับ ack เก่า
ขาดึงคิวธรรมดามาก คืออ่านแถวที่ applied_at ยังเป็น NULL:
/** GET /att-sync/pending — คิวที่ยังไม่ได้ทำ (เรียงเก่า->ใหม่) */
public function pending(Request $r)
{
if (!$this->apiKeyOk($r)) abort(401, 'unauthorized');
$this->ensureTable();
$rows = $this->conn()->table('att_edit')
->select('id', 'd', 'period', 'employee_no', 'full_name', 'status', 'created_at')
->whereNull('applied_at')->orderBy('id')->limit(200)->get();
return response()->json(['ok' => true, 'edits' => $rows]);
}
ที่ต้องคิดเยอะคือขา ack ครับ:
/**
* POST /att-sync/applied — on-prem ยืนยันว่าเขียนตารางจริงแล้ว
* body: {"acks":[{"id":1,"status":"late"}, ...]}
* มาร์คเฉพาะแถวที่ status ยังตรงกับตอนที่หยิบไป — ถ้าผู้ใช้กดแก้ซ้ำระหว่างทาง แถวนั้นค้างคิวไว้ทำรอบหน้า
*/
public function applied(Request $r)
{
if (!$this->apiKeyOk($r)) abort(401, 'unauthorized');
$this->ensureTable();
$acks = $r->json()->all()['acks'] ?? [];
$n = 0;
foreach ((array) $acks as $a) {
$id = (int) ($a['id'] ?? 0);
$st = (string) ($a['status'] ?? '');
if ($id <= 0 || !in_array($st, ['present', 'late', 'absent'], true)) continue;
$n += $this->conn()->table('att_edit')
->where('id', $id)->where('status', $st)->whereNull('applied_at')
->update(['applied_at' => $this->nowTh()]);
}
// เก็บกวาดคิวเก่าที่ทำไปแล้วเกิน 30 วัน
$this->conn()->table('att_edit')->whereNotNull('applied_at')
->where('applied_at', '<', gmdate('Y-m-d H:i:s', time() + 25200 - 30 * 86400))->delete();
return response()->json(['ok' => true, 'acked' => $n]);
}
บรรทัดที่อยากให้ดูคือ ->where('id', $id)->where('status', $st)->whereNull('applied_at') สามเงื่อนไขนี้คือ compare-and-set ทั้งดุ้น ถ้า ack เข้ามาแล้วสถานะในแถวไม่ตรงกับตอนที่เครื่องในหยิบไป การอัปเดตจะกระทบศูนย์แถว แถวนั้นจะค้างคิวไว้ให้ทำรอบหน้าเอง
ลองไล่เคสนี้ดูครับ ผู้ใช้กด "มา" คิวถูกสร้างเป็น present เครื่องในดึงไปตอน 10:00:00 เขียนลงฐานข้อมูลข้างในเสร็จตอน 10:00:03 ระหว่างนั้นตอน 10:00:02 ผู้ใช้เปลี่ยนใจกด "สาย" ซึ่งไปทับแถวเดิมเป็น late พร้อมเซ็ต applied_at กลับเป็น NULL พอ ack ของ present มาถึงตอน 10:00:04 เงื่อนไข where('status','present') ไม่แมตช์แล้ว ack เลยตกไปเงียบๆ แถวยังค้างคิวเป็น late รอบหน้าเครื่องในมาหยิบก็ได้ late ไปทำ ผลสุดท้ายตรงกับเจตนาผู้ใช้พอดี
ถ้าเขียน ack เป็น DELETE WHERE id = ? เคสข้างบนจะกลายเป็นคำสั่ง late ของผู้ใช้ถูกลบทิ้งโดยไม่มีใครรู้ ผู้ใช้เห็นบนจอว่าเป็น "สาย" แต่ในเครื่องจริงเป็น "มา" แล้วไม่มีใครสังเกตจนกว่าจะมีคนทักตอนสิ้นเดือน บั๊กชนิดนี้ไม่ error ไม่ crash ไม่ขึ้นล็อก แต่ข้อมูลผิด ซึ่งอันตรายกว่าระบบล่มเสียอีก
ขาเขียน: mark ที่ guard ไว้ฝั่งเซิร์ฟเวอร์
ปุ่มบนมือถือกดแล้วเข้าที่นี่ สิ่งที่ต้องระวังคือห้ามเชื่อฝั่ง client เด็ดขาด การซ่อนปุ่มบนหน้าเว็บไม่ใช่การกันครับ ต้องตรวจซ้ำที่เซิร์ฟเวอร์
public function mark(Request $r)
{
if (!$this->authed($r)) return response()->json(['ok' => false, 'error' => 'auth'], 401);
$this->ensureTable();
$date = (string) $r->input('date', '');
$period = (int) $r->input('period', 0);
$emp = preg_replace('/[^0-9]/', '', (string) $r->input('employee_no', ''));
$status = (string) $r->input('status', '');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || $emp === ''
|| !in_array($status, ['present', 'late', 'absent'], true)) {
return response()->json(['ok' => false, 'error' => 'ข้อมูลไม่ครบ'], 400);
}
// ตรวจกับ snapshot ล่าสุด (+คิวที่ยังไม่ applied) — กันแก้คนที่ไม่มีจริง / ลบเวลาสแกนจริง
$row = $this->conn()->table('att_day')->where('d', $date)->first();
if (!$row) return response()->json(['ok' => false, 'error' => 'ยังไม่มีข้อมูลของวันนี้'], 404);
$payload = $this->mergePending(json_decode($row->payload, true) ?: [], $date);
$codes = array_map(fn ($p) => (int) $p['code'], $payload['periods'] ?? []);
if (!in_array($period, $codes, true)) {
return response()->json(['ok' => false, 'error' => 'ช่วงเวลาไม่ถูกต้อง'], 400);
}
$worker = null;
foreach ($payload['workers'] ?? [] as $w) {
if ((string) $w['no'] === $emp) { $worker = $w; break; }
}
if (!$worker) return response()->json(['ok' => false, 'error' => 'ไม่พบคนงานคนนี้'], 404);
$cur = $worker['p'][(string) $period] ?? null; // null | 'HH:MM' | present | late
$isManual = in_array($cur, ['present', 'late'], true);
if ($status === 'absent' && !$isManual) {
return response()->json(['ok' => false, 'error' => 'ยกเลิกได้เฉพาะรายการที่แก้มือไว้ (เวลาสแกนจริงลบไม่ได้)'], 409);
}
if ($status !== 'absent' && $cur !== null && !$isManual) {
return response()->json(['ok' => false, 'error' => 'คนนี้สแกนจริงแล้ว (' . $cur . ') ไม่ต้องแก้'], 409);
}
// 1 แถวต่อ วัน/ช่วง/คน — แก้ซ้ำ = ทับ + re-queue (applied_at กลับเป็น NULL)
$this->conn()->table('att_edit')->updateOrInsert(
['d' => $date, 'period' => $period, 'employee_no' => $emp],
['status' => $status, 'full_name' => $worker['name'] ?? null,
'created_at' => $this->nowTh(), 'applied_at' => null]
);
return response()->json([
'ok' => true,
'pending' => $this->conn()->table('att_edit')->where('d', $date)->whereNull('applied_at')->count(),
'data' => $this->payloadFor($date),
]);
}
กติกาที่ผมตั้งไว้คือข้อมูลที่เครื่องปลายทางบันทึกมาเองห้ามลบจากคลาวด์ ยกเลิกได้เฉพาะรายการที่คนเป็นคนแก้เข้าไปเองเท่านั้น เกินกว่านั้นตอบ 409 กลับไปพร้อมข้อความไทยที่คนใช้อ่านรู้เรื่อง ไม่ใช่ error code เปล่าๆ
optimistic UI ที่ยังพูดความจริง
ผู้ใช้กดปุ่มแล้วต้องเห็นผลทันที ไม่งั้นจะกดซ้ำ แต่ถ้าโชว์ว่าสำเร็จแล้วทั้งที่ยังไม่ถึงเครื่องในโรงงาน ก็เท่ากับโกหก ทางที่ผมเลือกคือ merge คิวที่ยังไม่ ack ลงใน snapshot ตอนอ่าน แล้วติดธง pending ไปด้วย
/** รวมคิวที่ยังไม่ applied ลงใน payload (ให้ UI เห็นผลทันทีระหว่างรอ on-prem) */
private function mergePending(array $payload, string $date): array
{
$edits = $this->conn()->table('att_edit')->where('d', $date)->whereNull('applied_at')->orderBy('id')->get();
if ($edits->isEmpty()) return $payload;
foreach ($edits as $e) {
$emp = (string) $e->employee_no;
$pc = (int) $e->period;
foreach ($payload['periods'] ?? [] as $i => $p) {
if ((int) $p['code'] !== $pc) continue;
$scan = $p['scan'] ?? [];
$miss = $p['miss'] ?? [];
$name = $e->full_name;
$g = null;
foreach ($miss as $m) if ((string) $m['no'] === $emp) { $name = $m['name']; $g = $m['g'] ?? null; }
foreach ($scan as $s) if ((string) $s['no'] === $emp) { $name = $s['name']; $g = $s['g'] ?? null; }
$scan = array_values(array_filter($scan, fn ($s) => (string) $s['no'] !== $emp));
$miss = array_values(array_filter($miss, fn ($m) => (string) $m['no'] !== $emp));
if ($e->status === 'absent') {
$miss[] = ['no' => $emp, 'name' => $name, 'g' => $g, 'pending' => 1];
} else {
$scan[] = ['no' => $emp, 'name' => $name, 't' => null, 'g' => $g, 'm' => $e->status, 'x' => 0, 'pending' => 1];
}
$payload['periods'][$i]['scan'] = $scan;
$payload['periods'][$i]['miss'] = $miss;
$payload['periods'][$i]['count_scan'] = count($scan);
$payload['periods'][$i]['count_miss'] = count($miss);
}
foreach ($payload['workers'] ?? [] as $i => $w) {
if ((string) $w['no'] !== $emp) continue;
$payload['workers'][$i]['p'][(string) $pc] = $e->status === 'absent' ? null : $e->status;
$payload['workers'][$i]['pd'][(string) $pc] = 1; // ธง "รอซิงก์"
}
}
$payload['pending'] = $edits->count();
return $payload;
}
จุดที่ผมชอบที่สุดของท่านี้คือ snapshot ใน DB ไม่ถูกแก้เลย merge เกิดตอนอ่านอย่างเดียว พอเครื่องในโรงงาน push ของจริงกลับขึ้นมาแล้ว คิวถูก ack ไปแล้ว รอบถัดไป mergePending ก็ไม่มีอะไรให้ merge ค่าที่ผู้ใช้เห็นจะกลายเป็นค่าจากต้นทางเองอัตโนมัติ ผมไม่ต้องเขียน logic "ลบสถานะ optimistic" สักบรรทัดเดียว
ฝั่งหน้าเว็บก็เร่งรอบ poll ให้ถี่ขึ้นเฉพาะตอนมีคิวค้าง:
// รีเฟรชเอง: มีคิวค้าง = ทุก 20 วิ (รอผลจาก on-prem) · ปกติวันนี้ = ทุก 2 นาที (คงการ์ดที่เปิดไว้)
setInterval(async ()=>{
if(BUSY || !DATA) return;
const waiting = DATA.pending>0, t=Date.now();
if(t-LASTPOLL < (waiting?20000:120000)) return;
if(!waiting && $('#date').value!==todayISO()) return;
LASTPOLL=t;
const o=openCards(), sc=window.scrollY;
await load($('#date').value);
o.forEach(c=>{const el=document.querySelector('.pcard[data-c="'+c+'"]'); if(el) el.classList.add('open');});
window.scrollTo(0,sc);
}, 5000);
ฝั่งเครื่องในโรงงาน: สคริปต์ตัวเดียว ทำสองขา
สคริปต์ตัวนี้ทั้ง push snapshot ขึ้นไป และดึงคิวลงมาทำในตัวเดียว เรียกได้ทั้งจาก cron ทุกนาที และจากท้ายงาน cron ที่ดึงข้อมูลตามรอบ
/**
* push_cloud.php — ส่ง snapshot รายวันขึ้นคลาวด์ + ดึงคิวแก้ไขลงมาทำ
*
* ปลายทาง : POST https://example.com/att-sync/ingest (header X-API-KEY)
*
* ขาแก้ไข (เว็บ -> on-prem): คลาวด์เขียนเข้า MariaDB ของ on-prem ตรงๆ ไม่ได้ (อยู่หลัง NAT)
* 1. GET /att-sync/pending -> คิวที่ผู้ใช้กดแก้ไว้
* 2. เขียนลงตารางจริงผ่าน AttendanceService::setManual()
* 3. POST /att-sync/applied {acks:[{id,status}]} -> เว็บมาร์คว่าทำแล้ว
* 4. push snapshot ของวันที่ถูกแก้กลับขึ้นเว็บทันที (--force)
* ทุกขั้น idempotent: ack ไม่ถึง/เน็ตหลุด -> รอบหน้าทำซ้ำ ผลเท่าเดิม
*
* Usage:
* php push_cloud.php # วันนี้ (+ ดึงคิวแก้ไขมาทำ)
* php push_cloud.php 2026-07-28 # วันที่ระบุ
* php push_cloud.php --days=60 # ย้อนหลัง N วัน (backfill)
* php push_cloud.php --force # ส่งแม้ข้อมูลไม่เปลี่ยน (ข้าม hash cache)
* php push_cloud.php --no-edits # ไม่ดึงคิวแก้ไข (push อย่างเดียว)
*/
require_once __DIR__ . '/../vendor/autoload.php';
$URL = getenv('ATTSYNC_URL') ?: 'https://example.com/att-sync/ingest';
$KEY = getenv('ATTSYNC_KEY') ?: '';
$STATE = '/var/lib/att/push_cloud.state.json';
$BASE = preg_replace('#/ingest$#', '', $URL); // .../att-sync
ในโค้ดชุดแรกผมเผลอเขียน getenv('ATTSYNC_KEY') ?: 'ค่าคีย์จริง' ไว้กันลืมตั้ง env ซึ่งเป็นนิสัยที่ควรเลิก เพราะพอไฟล์นี้ถูกก๊อปไปเครื่องอื่น ถูกใส่ใน backup หรือถูกส่งให้ช่างอีกคนดู คีย์ก็หลุดไปพร้อมกัน ให้ปล่อยเป็นสตริงว่างแล้วโยน error ตั้งแต่บรรทัดแรกดีกว่า ระบบไม่ทำงานเพราะลืมตั้ง env ยังแก้ง่ายกว่าคีย์รั่วเยอะ
ลูปหลักมี state hash กันยิงซ้ำเปล่าๆ เพราะ cron วิ่งทุกนาทีตลอดวัน ถ้าข้อมูลไม่เปลี่ยนก็ไม่ต้องรบกวนเว็บ
// ─── ขาแก้ไข: ดึงคิวจากเว็บมาเขียนลงตารางจริง ────
$forceDates = [];
if ($edits && !$dry) {
$forceDates = syncEdits($BASE, $KEY, $attSvc, $config);
foreach ($forceDates as $d) if (!in_array($d, $dates, true)) $dates[] = $d;
}
$sent = 0;
foreach ($dates as $date) {
$payload = buildPayload($date, $db, $attSvc, $config, $syncAt);
$hash = md5(json_encode($payload['periods'], JSON_UNESCAPED_UNICODE));
if (!$force && !in_array($date, $forceDates, true) && ($state[$date] ?? null) === $hash) {
echo "[" . date('Y-m-d H:i:s') . "] {$date}: ไม่เปลี่ยน ข้าม\n";
continue;
}
[$code, $resp] = post($URL, $KEY, $payload);
if ($code === 200) {
$state[$date] = $hash;
$sent++;
}
}
@file_put_contents($STATE, json_encode($state, JSON_UNESCAPED_UNICODE));
ขาที่ดึงคิวมาทำ ตัวเต็มเป็นแบบนี้ครับ
/**
* ดึงคิวแก้ไขจากเว็บ -> setManual() -> ack กลับ
* @return string[] วันที่ที่ถูกแก้ (ต้อง push ซ้ำแบบ force)
*/
function syncEdits(string $base, string $key, $attSvc, $config): array
{
[$code, $body] = httpGet($base . '/pending', $key);
if ($code !== 200) {
if ($code !== 0) echo '[' . date('Y-m-d H:i:s') . "] pending: http={$code}\n";
return [];
}
$j = json_decode((string) $body, true);
$rows = $j['edits'] ?? [];
if (!$rows) return [];
$acks = [];
$days = [];
foreach ($rows as $e) {
$d = (string) ($e['d'] ?? '');
$pc = (int) ($e['period'] ?? 0);
$emp = preg_replace('/[^0-9]/', '', (string) ($e['employee_no'] ?? ''));
$st = (string) ($e['status'] ?? '');
$id = (int) ($e['id'] ?? 0);
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) || !isset($config->periods[$pc])
|| $emp === '' || !in_array($st, ['present', 'late', 'absent'], true) || $id <= 0) {
continue; // ข้อมูลเพี้ยน — ไม่ ack ปล่อยให้ค้างคิวไว้ตรวจ
}
try {
$attSvc->setManual($d, $pc, $emp, $st); // present|late = บันทึก · absent = ลบ override
$acks[] = ['id' => $id, 'status' => $st];
if (!in_array($d, $days, true)) $days[] = $d;
} catch (\Throwable $ex) {
echo '[' . date('Y-m-d H:i:s') . '] แก้ไม่สำเร็จ id=' . $id . ' ' . $ex->getMessage() . "\n";
}
}
if ($acks) {
[$c2, $r2] = post($base . '/applied', $key, ['acks' => $acks]);
if ($c2 !== 200) return $days; // ack ไม่ผ่าน -> รอบหน้าทำซ้ำ (idempotent)
}
return $days;
}
สังเกตบรรทัด continue; ตรงที่ validate ไม่ผ่านนะครับ ผมตั้งใจไม่ ack แถวที่ข้อมูลเพี้ยน ปล่อยให้ค้างคิวไว้ให้คนมาดู ดีกว่ามาร์คว่าทำแล้วทั้งที่ไม่ได้ทำ คิวที่ค้างนานผิดปกติคือสัญญาณให้เข้าไปตรวจ ถ้าเรา ack ทิ้งไปเงียบๆ สัญญาณนั้นก็หายไปด้วย
ส่วน HTTP helper มีจุดที่โดน WAF เล่นงานอยู่บรรทัดเดียว
function post(string $url, string $key, array $payload): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json', "X-API-KEY: {$key}"],
CURLOPT_USERAGENT => 'Mozilla/5.0 (att pusher on-prem)', // WAF บล็อก UA แปลกๆ
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
]);
$resp = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($resp === false) $resp = 'curl error: ' . curl_error($ch);
curl_close($ch);
return [$code, $resp];
}
กับดักที่ผมอยากเตือนที่สุด: cron ที่หายเงียบเพราะระบบเขียนทับเอง
ระบบเดิมบนเครื่องในโรงงานมีฟีเจอร์ให้ผู้ใช้ตั้งช่วงเวลาทำงานจากหน้าเว็บได้ ซึ่งเบื้องหลังมันจะ generate crontab ใหม่ทั้งบล็อกทุกครั้งที่มีการแก้ โค้ดส่วนที่ลบของเก่าเป็นแบบนี้
private const CRON_BEGIN = '# ATT_PERIODS_AUTO_BEGIN';
private const CRON_END = '# ATT_PERIODS_AUTO_END';
// อ่าน crontab ปัจจุบัน + ลบ block marker เก่า
$current = (string) shell_exec("{$crontabCmd} -l 2>/dev/null");
$current = preg_replace(
'/' . preg_quote(self::CRON_BEGIN, '/') . '.*?' . preg_quote(self::CRON_END, '/') . '\n?/s',
'',
$current
);
// ลบ cron ที่อยู่นอก marker ด้วย — แต่เฉพาะที่ชื่อ script อยู่ในลิสต์ managed
$managedScripts = ['pull_period_1.php', 'pull_period_2.php', 'pull_period_3.php', 'pull_period_4.php',
'line_notify.php', 'line_daily_summary.php', 'hik_sleep_mode.php'];
$kept = [];
foreach (explode("\n", $current) as $line) {
$skip = false;
foreach ($managedScripts as $s) {
if (strpos($line, $s) !== false) { $skip = true; break; }
}
if (!$skip) $kept[] = $line;
}
$current = implode("\n", $kept);
$newCrontab = rtrim($current, "\n") . "\n" . self::CRON_BEGIN . "\n" . implode("\n", $generated) . "\n" . self::CRON_END . "\n";
// ถ้าไม่ใช่ root -> ใช้ sudo crontab (Apache รันเป็น www-data, cron จริงอยู่ที่ root)
$isRoot = function_exists('posix_geteuid') && posix_geteuid() === 0;
$crontabCmd = $isRoot ? '/usr/bin/crontab' : 'sudo -n /usr/bin/crontab';
มีสองด่านที่ลบของคนอื่นได้ ด่านแรกคือทุกอย่างที่อยู่ระหว่าง marker โดนลบทิ้งหมด ด่านที่สองคือบรรทัดที่อยู่นอก marker แต่ชื่อไฟล์ตรงกับลิสต์ managed ก็โดนลบด้วย
ถ้าผมเอาบรรทัด push ไปวางในบล็อก marker หรือดันตั้งชื่อไฟล์ให้ตรงกับลิสต์ managed มันจะหายทุกครั้งที่ผู้ใช้เข้าไปแก้ช่วงเวลาจากหน้าเว็บ อาการที่เจอคือระบบเดินดีมาสองเดือนแล้วจู่ๆ ข้อมูลบนคลาวด์ก็หยุดอัปเดต โดยไม่มี error ที่ไหนเลยเพราะสคริปต์ไม่ได้พัง มันแค่ไม่ถูกเรียก และเวลาไปไล่หาก็จะเจอ crontab ที่ดู "ปกติดี" เพราะบรรทัดนั้นหายไปแล้ว ไม่เหลือร่องรอยให้ดู
ตำแหน่งที่ผมวางจริงเลยเป็นแบบนี้ อยู่นอก marker และตั้งชื่อไฟล์ไม่ให้ชนลิสต์
# root crontab — 2 บรรทัดนี้อยู่ "นอก" marker ที่ระบบ regenerate
* * * * * /usr/bin/php /var/www/html/att/cron/push_cloud.php >> /var/log/att/push_cloud.log 2>&1
20 0 * * * /usr/bin/php /var/www/html/att/cron/push_cloud.php --days=2 >> /var/log/att/push_cloud.log 2>&1
อีกจุดที่ผมทำแล้วชอบคือ แทนที่จะตั้ง cron ยิง push ตามเวลาตายตัว ผมไปเรียกท้ายงานที่ดึงข้อมูลตามรอบแทน แยกเป็นคนละ process จะได้ไม่กระทบงานหลักถ้ามันพัง
// ท้าย cron/pull_period.php : push snapshot แยก process ไม่ให้ error กระทบงานหลัก
$pushCmd = sprintf('/usr/bin/php %s %s >> %s 2>&1 &',
escapeshellarg(__DIR__ . '/push_cloud.php'),
escapeshellarg($date),
escapeshellarg("{$logDir}/push_cloud.log")
);
@exec($pushCmd);
ผลคือเวลาที่ข้อมูลถูกส่งขึ้นคลาวด์เดินตามช่วงเวลาที่ผู้ใช้ตั้งเสมอ ผู้ใช้เปลี่ยนช่วงเวลาใหม่ ผมไม่ต้องกลับไปแก้ cron ของ push เลย
อีกบั๊กที่ไม่ error แต่ตัวเลขผิด
ตอนแรกหน้าเว็บบนคลาวด์นับ "มาครบทั้งวัน" ด้วยการไล่เช็คทีละช่วงว่าต้องมีครบทุกช่วง ผลคือเหลือคนที่มาครบแค่คนเดียว ทั้งที่ความจริงคนมาเกือบหมด สาเหตุคือช่วงแรกของวันแทบไม่มีใครเข้าเลย คนส่วนใหญ่มาทันช่วงที่สอง ซึ่งฝั่งเครื่องในโรงงานจับกลุ่มไว้แล้วว่าช่วงเช้าคือ "ช่วง 1 หรือ 2" แต่ฝั่งหน้าเว็บดันไปนับใหม่แบบไม่จับกลุ่ม ตัวเลขสองฝั่งเลยไม่ตรงกัน
// จับกลุ่มช่วงแบบเดียวกับที่ on-prem สรุป: เช้า = ช่วง 1 หรือ 2 · ช่วง 3 · ช่วง 4
const GRP=[['1','2'],['3'],['4']].map(g=>g.filter(c=>PST[c]!==undefined)).filter(g=>g.length);
const endedG=GRP.filter(g=>g.every(c=>PST[c]==='end'));
const st={full:0,partial:0,absent:0};
(d.workers||[]).forEach(w=>{
if(!endedG.length){ w.st2='wait'; return; }
const came=endedG.filter(g=>g.some(c=>w.p&&w.p[c])).length;
w.st2 = came===endedG.length?'full':(came===0?'absent':'partial');
st[w.st2]++;
});
พ่วงกับการเปลี่ยนป้ายตอนวันยังไม่จบด้วย ไม่งั้นตอนเก้าโมงเช้าเว็บจะบอกว่าเกือบทุกคนขาดทั้งวัน
const openDay = isToday && (d.periods||[]).some(p=>pstate(p)!=='end'); OPEN=openDay;
// tile: 'มาเต็มวัน' -> 'มาครบทุกช่วงที่ผ่านมา' , 'ขาดทั้งวัน' -> 'ยังไม่มาเลย'
if(openDay) h+='<div class="hint">วันนี้ยังไม่จบ — นับเฉพาะช่วงที่ผ่านไปแล้ว</div>';
บทเรียนคือถ้ามีการสรุปตัวเลขสองที่ กติกาต้องเหมือนกันเป๊ะ ไม่งั้นจะได้บั๊กที่ไม่มี error ไม่มี exception ไม่มี log แต่ตัวเลขบนจอผิด ซึ่งกว่าจะมีคนทักก็ผ่านไปหลายวันแล้ว
ตัวเลขที่วัดได้จริงหลังใช้งาน
ตอนทดสอบขาเขียนจริง ผมกดปุ่มแก้สถานะให้หนึ่งรายการจากมือถือ แล้วเข้าไปนับแถวในตารางของเครื่องในโรงงาน แถวเพิ่มขึ้นหนึ่งภายในนาทีเดียว พอกดยกเลิกจากมือถืออีกที แถวก็กลับมาเท่าเดิม ไม่มีขยะค้าง ทดสอบซ้ำหลายรอบผลเหมือนกันทุกครั้ง ซึ่งเป็นผลที่ควรได้ถ้า idempotent ถูกจริง
ถ้าจะเอาโครงนี้ไปใช้กับงานอื่น
- ทำตารางกระจกฝั่งคลาวด์ก่อน หนึ่งแถวต่อหนึ่งหน่วยเวลา เก็บ payload เป็น JSON แล้ว updateOrInsert บนคีย์เดียว จะได้ยิงซ้ำได้ไม่จำกัด
- ทำตารางคิวคำสั่งที่มี UNIQUE บนคีย์ธรรมชาติของ "เจตนา" ไม่ใช่ auto increment อย่างเดียว แล้วใช้ applied_at เป็นตัวบอกสถานะ
- เปิดสาม endpoint ให้เครื่องในเรียก คือ ingest, pending, applied ป้องกันด้วย shared secret ใน header เทียบด้วย hash_equals แล้วอย่าลืมยกเว้น CSRF ให้สอง endpoint ที่เป็น POST
- เขียนสคริปต์ฝั่งเครื่องในตัวเดียวที่ทำทั้งสองขา มี state hash กันยิงซ้ำ และมีโหมด force กับ backfill ไว้ใช้ตอนติดตั้งและตอนแก้ปัญหา
- วาง cron ให้พ้นระบบอื่นที่อาจเขียนทับ แล้วเช็คซ้ำด้วย crontab -l จริงหลังผู้ใช้เข้าไปแก้ตั้งค่าในระบบเดิมสักรอบ
- ทำ UI ที่บอกว่ายังไม่ยืนยัน ด้วยการ merge คิวตอนอ่านและติดธง pending อย่าเขียนทับ snapshot ในฐานข้อมูลเด็ดขาด
ส่วนตัวผมคิดว่าแพตเทิร์นนี้ใช้ได้กว้างกว่างานที่ผมทำเยอะครับ ระบบ POS หน้าร้าน เครื่องจักรที่มี log เก็บในเครื่อง ระบบสต๊อกในคลัง หรืออะไรก็ตามที่ข้อมูลจริงอยู่ในวง LAN แล้วอยากเปิดหน้าดูจากข้างนอก ใช้โครงเดียวกันได้หมด ของที่ต้องเปลี่ยนมีแค่รูปร่างของ payload กับกติกาการ guard ตอนรับคำสั่ง ส่วนใครที่มีคนดูแลเต็มเวลาและต้องการรีโมตเข้าไปทำงานเต็มรูปแบบจริงๆ VPN ก็ยังเป็นคำตอบที่ถูกกว่า ตรงนี้เลือกตามหน้างานได้ตามสะดวก ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



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