บัตรสะสมแต้ม (ฝั่งลูกค้า)
ภาพรวม
บัตรสะสมแต้มคือบัตรของร้านที่ลูกค้าเปิดดูได้จาก LINE แสดงในรูปแบบการ์ดที่ปรับสี ตามแบรนด์ของร้าน รองรับการทำงานสองโหมด:
- โหมดแสตมป์ — สะสมเป็นดวง เมื่อสะสมครบใบก็ได้รางวัล เหมาะกับร้านที่นับ จำนวนครั้งการซื้อ เช่น ร้านกาแฟ
- โหมดแต้ม — สะสมเป็นแต้มจากยอดใช้จ่าย แลกรางวัลได้ตามต้องการ และรองรับ ระบบระดับสมาชิก (tier) เหมาะกับร้านที่ยอดต่อบิลแตกต่างกันมาก
วงจรการใช้งานคือ ลูกค้าได้แต้มจากการสแกน QR ที่พนักงานสร้างให้ และใช้รางวัล ด้วยการเปิดโค้ดคูปองให้พนักงานสแกน
Business Flow
การรับแต้ม
รองรับสองเส้นทาง เพื่อครอบคลุมทั้งลูกค้าเดิมและลูกค้าใหม่:
- สแกนจากในแอปบัตร — ลูกค้าที่เปิดหน้าบัตรอยู่แล้วกดปุ่มสแกน ระบบเรียก สแกนเนอร์ของ LIFF แล้วดึง token ออกจากค่าที่สแกนได้ (รองรับทั้งกรณีที่ QR บรรจุ token เปล่าและกรณีที่บรรจุ URL เต็ม) จากนั้นส่งไปบันทึกแต้ม
- สแกนจากกล้องมือถือโดยตรง (cold scan) — ลูกค้าใหม่ที่ยังไม่ได้เพิ่ม OA
เป็นเพื่อน ใช้กล้องมือถือทั่วไปสแกน QR ที่เป็นลิงก์ LIFF ได้เลย LINE จะเปิดหน้าบัตร
ให้พร้อมชวนเพิ่มเพื่อน และหน้าบัตรจะบันทึกแต้มให้อัตโนมัติโดยไม่ต้องกดอะไรเพิ่ม
- ระบบอ่าน token จาก URL โดยรองรับทั้งพารามิเตอร์ตรง ๆ และกรณีที่ซ้อนอยู่ใน
liff.stateซึ่งเกิดขึ้นเมื่อ LINE นำทางผ่านตัวเอง - รอให้ข้อมูลบัตรโหลดเสร็จก่อนจึงบันทึกแต้ม เพื่อให้ข้อความแจ้งผลใช้คำที่ตรงกับ โหมดของบัตร (เป็น "แต้ม" หรือ "ดวง")
- มีการกันการยิงซ้ำ เพราะ token ใช้ได้เพียงครั้งเดียว และลบพารามิเตอร์ออกจาก URL หลังใช้เสร็จ
- ระบบอ่าน token จาก URL โดยรองรับทั้งพารามิเตอร์ตรง ๆ และกรณีที่ซ้อนอยู่ใน
การแลกและใช้รางวัล
- ในโหมดแต้ม ลูกค้ากดแลกรางวัลที่ต้องการ ระบบหักแต้มแล้วออกคูปองให้ จากนั้นสลับไปแท็บคูปองของฉันและเปิดแผ่นแสดงโค้ดให้ทันที
- โค้ดคูปองถูกขอตอนเปิดแผ่นแสดงโค้ดเท่านั้น ไม่ได้ติดมากับข้อมูลบัตรตอนโหลดหน้า เพื่อไม่ให้โค้ดค้างอยู่ในข้อมูลที่ลูกค้าแคปหน้าจอเก็บไว้ใช้ภายหลังได้
- โค้ดมีอายุแสดงผล 180 วินาที เมื่อหมดแล้วขอใหม่ได้ — นี่เป็นมาตรการป้องกัน การแคปหน้าจอไปใช้ซ้ำ ไม่ใช่วันหมดอายุของคูปอง ตัวคูปองยังคงรออยู่ในแท็บคูปองของฉัน
- พนักงานเป็นฝ่ายสแกนโค้ดเพื่อตัดการใช้คูปอง (ดู loyalty-staff)
สถานะพิเศษที่ผู้ใช้อาจพบ
- เข้าสู่ระบบไม่สำเร็จ — แสดงหน้าจอให้เข้าสู่ระบบอีกครั้ง
- แอดมินแพลตฟอร์มปิดฟีเจอร์นี้ — แสดงข้อความว่าฟีเจอร์ปิดใช้งาน และไม่ลองเรียกซ้ำ เพราะเป็นคำตอบที่ไม่เปลี่ยนแปลง
- ร้านยังไม่ได้เปิดใช้บัตรสะสมแต้ม — แสดงข้อความแจ้งตรง ๆ
- เปิดหน้านี้นอกแอป LINE — แจ้งล่วงหน้าว่าจะสแกนไม่ได้ แทนที่จะปล่อยให้กดแล้วค่อยพบปัญหา
หน้าจอและองค์ประกอบหลัก
หน้าบัตรอยู่ที่ /{hash}/loyalty โดยองค์ประกอบทั้งหมดอยู่ใต้
src/app/[hash]/loyalty/:
- ส่วนหัวการ์ด (
CardHeader) — หน้าตาของบัตร ระดับสมาชิก และความคืบหน้า - ตะแกรงแสตมป์ (
StampGrid,StampMark) — ใช้ในโหมดแสตมป์ แสดงดวงที่สะสมแล้ว - รายการรางวัล (
RewardList) — แบ่งเป็นแท็บรางวัลที่แลกได้และคูปองของฉัน - แผ่นแสดงโค้ด (
RewardSheet,CodeBlocks) — เปิดขึ้นมาแสดงโค้ดพร้อมนับถอยหลัง - หน้าจอเข้าสู่ระบบใหม่ (
SignInAgain) — ใช้เมื่อการยืนยันตัวตนหลุด - ตัวช่วยสแกน (
lib/scanner.ts) — ตรวจว่าสภาพแวดล้อมสแกนได้หรือไม่ เปิดสแกนเนอร์ และแยกสาเหตุที่สแกนไม่สำเร็จออกเป็นกรณี เช่น เปิดนอกแอป LINE ฟังก์ชันไม่รองรับ ผู้ใช้ยกเลิกเอง หรือล้มเหลวจากสาเหตุอื่น - ตัวควบคุมหน้า (
loyalty.container.tsx) — ประกอบทุกส่วนเข้าด้วยกัน รวมถึง การอ่าน token จาก URL การแลกรางวัล และการทำงานอัตโนมัติของ cold scan
สีทั้งหมดของบัตร (สีเน้น สีดวงแสตมป์ สีตัวอักษร สีปุ่ม) มาจาก API ซึ่งตรวจความถูกต้อง
มาแล้ว หน้าเว็บเพียงนำไปใช้ ฟอนต์โหลดผ่าน next/font โดยใช้ Noto Sans Thai
Endpoint ที่ใช้
| Method | Path |
|---|---|
| GET | /loyalty/{hash} |
| GET | /loyalty/{hash}/cards |
| POST | /loyalty/{hash}/earn |
| POST | /loyalty/{hash}/rewards/redeem |
| POST | /loyalty/{hash}/rewards/{rewardId}/code |
ระบบแปลข้อความผิดพลาดที่พบบ่อยเป็นภาษาไทย ครอบคลุมกรณี token ถูกใช้ไปแล้ว token ไม่ถูกต้อง อยู่ในช่วงพักการรับแต้ม โปรแกรมถูกปิดใช้งาน และแต้มไม่เพียงพอ
จุดเชื่อมต่อกับฟีเจอร์อื่น
- สแกนเนอร์ของ LIFF เป็นสแกนเนอร์ตัวเดียวที่ใช้ได้ เพราะหน้า LIFF ไม่สามารถ เข้าถึงกล้องผ่าน API ของเบราว์เซอร์ได้โดยตรง ข้อกำหนดคือ ต้องเปิดสิทธิ์ Scan QR ใน LINE Developers Console, LIFF ต้องตั้งขนาดเป็น Full และบน iOS ต้องใช้ LINE เวอร์ชัน 9.19.0 ขึ้นไป
- ระบบยืนยันตัวตนของบอร์ดประกาศ ถูกนำมาใช้ซ้ำที่นี่ เพราะลำดับการยืนยันตัวตน เหมือนกันทุกขั้น (ดู bulletin-board)
- Ant Design — ใช้
Drawer,QRCode,Spinและระบบ message ผ่านApp.useApp() - ฝั่งพนักงานหน้าร้าน เป็นคู่ที่ทำงานร่วมกัน token ที่ออกจากฝั่งนั้นถูกใช้ที่นี่ และคูปองที่สร้างที่นี่ถูกตัดการใช้ที่นั่น (ดู loyalty-staff)
- มีชุดทดสอบครอบคลุมการอ่านและใช้ token รับแต้ม การกันสิทธิ์ฝั่งพนักงาน และการแสดงตะแกรงแสตมป์
รายละเอียดฝั่ง Backend (Client API)
ด่านก่อนถึง handler
route group /loyalty/:hash ถูกครอบด้วย middleware เปิด/ปิดแอปรายองค์กรชุดเดียวกับบอร์ดประกาศ
(appID loyalty) องค์กรที่ปิดแอปไว้จะได้ 403 message APP_DISABLED ซึ่งตรงกับหน้าจอ
"ฟีเจอร์ปิดใช้งาน" ที่ฝั่งเว็บแสดงและไม่ลองเรียกซ้ำ — ด่านนี้ครอบ ทั้งฝั่งลูกค้าและฝั่งพนักงาน
ปิดแอปแล้วเครื่องพนักงานก็ใช้ไม่ได้เช่นกัน
จากนั้นทุก endpoint เริ่มด้วยการ resolve OA จาก :hash (ไม่พบ → 404
"loyalty card not found") แล้ว verify LIFF token กับ LINE Login channel ของ OA นั้น
ด้วยบันไดเดียวกับบอร์ดประกาศ (channel binding)
GET /api/loyalty/:hash — บัตรทั้งใบใน round trip เดียว
endpoint นี้ส่งทุกอย่างที่หน้า LIFF ต้องใช้มาพร้อมกัน: โครงโปรแกรม (โหมด, ธีม, unit label), บัตรใบที่กำลังใช้งาน, บันไดรางวัล, รางวัลที่ถืออยู่, ยอดคงเหลือ, ระดับชั้น และระยะที่เหลือถึงชั้นถัดไป
- ร้านที่ยังไม่มีโปรแกรมไม่ใช่ error — คืน response ที่มีแค่ชื่อและรูป OA พร้อมรายการว่าง เพื่อให้หน้าเว็บแสดงข้อความ "ยังไม่เปิดให้บริการ" ได้โดยไม่ต้องอ่าน error
- สร้างแถวบัญชี loyalty ให้ LINE user นี้ถ้ายังไม่มี แล้วเลือกบัตรที่สถานะ active เป็นบัตรปัจจุบัน
- บันไดรางวัลถูกโหลดตาม program id + version โปรแกรมมี versioning ดังนั้นบันไดของ เวอร์ชันเก่าจะไม่ถูกนำมาแสดง
- ยอดคงเหลือคำนวณจาก ledger lot ที่ ยังไม่หมดอายุ ไม่ใช่ตัวเลขที่เก็บไว้บนบัญชี
- รูปทั้งหมด (ปกบัตร โลโก้ รูปรางวัล รูปพื้นหลังของ tier) resolve เป็น public URL และถ้าไม่มี path หรือไม่มี storage ก็ ไม่ส่งรูปมาเลย ไม่ใช่ URL ที่ชี้ bucket root
GET /api/loyalty/:hash/cardsเป็นหน้าประวัติบัตรย้อนหลัง คืนบัตรทุกใบของบัญชี
ทำไม tier ถูกคำนวณสดทุกครั้ง
ระดับชั้นไม่ถูก cache ไว้บนบัญชี เพราะบันไดชั้นถูกแก้ราคาใน CMS ได้ตลอด และชื่อชั้นที่ cache ไว้จะยังโชว์ชั้นที่ถูกเปลี่ยนชื่อหรือลบไปแล้ว
- ชั้นปัจจุบันถูกอ่าน ด้วย tier id ตรงๆ เพราะ tier ผูกกับ OA ไม่ได้ version ตามโปรแกรม ถ้าไป match กับบันไดของ program version ปัจจุบัน ลูกค้าที่ได้ชั้นมาจากเวอร์ชันก่อนจะกลายเป็น ไม่มีชั้นเลย
- ชั้นถัดไปคือชั้นที่ active และ rank สูงกว่าปัจจุบันที่ใกล้ที่สุด
- ตัวเลข "อีกกี่บาทถึงชั้นถัดไป" คำนวณจากเป้าหมายของชั้นถัดไปลบยอดใช้จ่ายในหน้าต่างเวลา (default 3 เดือน) โดย ชั้นที่ไม่มีกฎวัดยอดใช้จ่ายจะได้เป้าหมาย 0 = ไม่แสดงตัวเลข ระบบไม่คิดเลขขึ้นมาเอง เพราะบันไดที่สร้างจากจำนวนออเดอร์หรืออายุสมาชิกไม่มีจำนวนเงิน ให้นับถอยหลัง
กลไกระดับชั้น (Tier Engine)
ระดับชั้นตัดสินจาก ชุดกฎ ไม่ใช่ threshold ตัวเดียว วัดได้ 4 metric: ยอดใช้จ่าย, จำนวนครั้งที่รับแต้ม, แต้มที่ได้ในหน้าต่างเวลา และอายุสมาชิก (metric สุดท้ายไม่สนใจหน้าต่างเวลา) กฎรวมกันด้วย and หรือ or และใช้เฉพาะโหมดแต้มที่เปิดระบบ tier
- กฎที่ parse ไม่ได้ = ไม่ตรงกับใครเลย ไม่ใช่ตรงกับทุกคน — การ fail open จะเลื่อนชั้น ลูกค้าทั้งฐานเพราะแอดมินพิมพ์ผิด ซึ่งเป็นความผิดที่ถอนคืนไม่ได้เมื่อคนเห็นชั้นใหม่ของตัวเองแล้ว
- ชุดกฎว่าง = ไม่ตรงกับใคร ด้วยเหตุผลเดียวกัน ชั้นที่ยังไม่ใส่เงื่อนไขคือชั้นที่ทำครึ่งทาง
- การเดินบันไดไล่ จากล่างขึ้นบนแล้วเก็บ match ตัวสุดท้าย = ได้ rank สูงสุดที่ผ่าน ไม่ใช่ตัวแรกที่ผ่าน เพราะแอดมินเขียนกฎที่ทับกันได้ และคนที่ผ่านทั้ง Silver และ Gold ต้องได้ Gold
- ชั้น default ถูกข้ามในการเดิน กฎของมันไม่ถูกประเมินเลย และถูกคืนเฉพาะเมื่อไม่มีชั้นอื่นผ่าน จึงเป็น "พื้น" จริงๆ ที่ชนะชั้นที่ลูกค้าหามาไม่ได้ ไม่ว่าจะตั้ง rank ไว้เท่าไร
- บันไดเรียงด้วย rank เท่านั้น ไม่เคยด้วย threshold เพราะแอดมินที่กำลังแก้บันไดอาจปล่อยให้ 2 ชั้นมีค่าเท่ากันชั่วขณะ แล้วลำดับที่อิงตัวเลขจะสลับตัวเองใต้มือเขา
- หน้าต่างเวลาใช้ของชั้นก่อน ถ้าไม่ได้ตั้งจึงใช้ของโปรแกรม ถ้าไม่มีอีกจึงเป็น 3 เดือน — แต่ละชั้นวัดในหน้าต่างของตัวเอง จึงต้องเก็บสถิติ ต่อชั้น ไม่ใช่ครั้งเดียวต่อบัญชี
POST /api/loyalty/:hash/earn — รับแต้ม (rate limit 10/60s)
rate limit ต่ำที่สุดในกลุ่ม เพราะเป็นพื้นผิวที่ถูกโจมตีได้มากที่สุด ทั้งหมดรันใน transaction เดียว และ ลำดับมีความหมาย:
- โปรแกรมไม่ live → 400
LOYALTY_PROGRAM_INACTIVE; token ว่าง → 400LOYALTY_TOKEN_INVALID - สร้างบัญชีถ้ายังไม่มี (ต้องมีบัญชีก่อนจึงจะ claim token ได้)
- claim token ด้วย UPDATE แบบมีเงื่อนไข — การแข่งกันของสองคนที่สแกน QR ใบเดียวกัน
ถูกตัดสินที่นี่ ไม่ใช่ที่การอ่าน; claim ไม่ได้แล้วยังมี token อยู่จริง → 409
LOYALTY_TOKEN_CONSUMEDแต่ถ้าไม่เคยมี token นั้นเลย → 400LOYALTY_TOKEN_INVALID - เช็ค cooldown หลัง claim โดยเจตนา — ถ้าเช็คก่อน token ที่ถูกปฏิเสธเพราะ cooldown
จะยังมีชีวิตให้คนถัดไปสแกนจอเดียวกันได้ ซึ่งคือการแชร์ QR ที่กำลังพยายามป้องกัน;
ติด cooldown → 409
LOYALTY_COOLDOWN - เดินเลขคณิตของบัตรและเขียน ledger, ให้ welcome bonus, แล้วพิจารณาเลื่อนชั้น
การเดินบัตรทีละใบ — ระบบเขียนแถว ledger ก่อนแตะบัตร เพราะ idempotency key (ซึ่งก็คือ token) จะสะดุดก่อนที่การ mutate ใดจะเกิด token ที่ถูกส่งซ้ำจึงไม่เปลี่ยนอะไรเลย ไม่ใช่อัปเดตบัตรครึ่งทางแล้วล้ม จากนั้น:
- โหมดแต้มที่ไม่มีกริด → บวก units จบ
- โหมดแสตมป์ → วนเติมทีละใบและ persist ทุกใบที่เต็ม เวอร์ชันก่อนคำนวณตำแหน่งสุดท้าย ทีเดียวแล้วเขียนแค่ใบสุดท้าย ทำให้บัตรที่เต็มระหว่างทาง หายไปเงียบๆ: ลูกค้าที่สแกนครั้งแรก ได้ 7 แสตมป์บนบัตร 5 ช่องจะเหลือบัตรใบเดียวที่มีเศษ ไม่มีบัตรที่ complete ในประวัติ และรางวัลชี้ไปที่ไม่มีอะไร
- บัตรที่เต็มจะถูกปิดสถานะ ออกรางวัลทุก milestone ที่ต้องการหน่วยไม่เกินขนาดบัตร แล้วเปิดบัตรใบใหม่ต่อลำดับ
- อายุของรางวัลใช้ค่าของ milestone ถ้าตั้งไว้ ไม่งั้นใช้วันหมดอายุของบัตร — รางวัลที่อยู่นานกว่าบัตร จะกลายเป็นรางวัลที่แลกกับบัตรที่ลูกค้ามองไม่เห็นแล้ว
Welcome bonus ให้ตอน รับแต้มครั้งแรก ไม่ใช่ตอนสร้างบัญชี เพราะแถวบัญชีถูกสร้างทันที ที่ใครก็ตามเปิดดูบัตร การให้ตอนนั้นเท่ากับแจกแสตมป์ให้คนที่แค่เปิดดูเล่นๆ; หลักประกันจริงคือ unique index ไม่ใช่การเช็คก่อน และยอด bonus ถูก แยกออกจากยอดที่พนักงานตอกให้ ในผลลัพธ์ เพราะลูกค้าที่ถูกตอก 1 แสตมป์แล้วเห็นเลข 2 จะคิดว่าระบบผิด
การเลื่อนชั้นตอนรับแต้ม เลื่อนขึ้นเท่านั้น — การลดชั้นเป็นงานของ job รายคืน ไม่มีใครควร ตกชั้นกลางธุรกรรมที่หน้าพนักงานซึ่งมีคิวต่อแถวอยู่ ในทางกลับกัน การรอถึงพรุ่งนี้เพื่อบอกว่าเพิ่งได้ Gold คือการทิ้งช่วงเวลาเดียวที่ tier มีความหมาย; ความล้มเหลวของขั้นนี้ถูกกลืน (log เป็น warning) เพราะ units เข้าบัญชีไปแล้ว การล้มทั้ง earn เพราะ lookup tier พังจะเปลี่ยนปัญหาความสวยงาม ให้กลายเป็นธุรกรรมที่สูญหาย และ job รายคืนประเมินใหม่อยู่แล้ว
POST /api/loyalty/:hash/rewards/redeem — แลกรางวัลด้วยแต้ม (rate limit 10/60s)
เข้มกว่าการอ่านเพราะเป็นการ หักยอด และรันใน transaction เดียว: การหัก lot สำเร็จแล้ว ออกรางวัลไม่สำเร็จ = เอาแต้มลูกค้าไปโดยไม่ได้อะไรคืน ซึ่งเป็นความล้มเหลวเดียวที่กู้ไม่ได้ โดยไม่ต้องขอโทษด้วยมือ
- โปรแกรมไม่ live → 400
LOYALTY_PROGRAM_INACTIVE; ไม่ใช่โหมดแต้ม → 400LOYALTY_NOT_POINT_MODE(ถ้าปล่อยให้รันในโหมดแสตมป์จะเป็นการเสกรางวัลออกมาจากอากาศ โดยไม่ต้องทำบัตรครบ) - โหลด milestone ของ program version ปัจจุบันเท่านั้น — id จากเวอร์ชันที่ถูกแทนที่แล้ว ไม่ได้อยู่บนบันไดอีก ถ้าค้นด้วย id เพียวๆ หน้าจอ LIFF ที่ค้างอยู่จะซื้อรางวัลที่ปลดไปแล้วได้
- ด่านระดับชั้น — รางวัลที่กำหนดชั้นขั้นต่ำต้องให้ rank ของบัญชีถึง ไม่งั้น 400
LOYALTY_TIER_TOO_LOWบังคับที่ server ไม่ใช่แค่ซ่อนปุ่ม เพราะหน้าจอค้างหรือ request ทำมือต้องซื้อรางวัล Gold บนบัญชี Silver ไม่ได้ — แต่ด่านนี้กันเฉพาะ การแลกครั้งใหม่ รางวัลที่ออกไปแล้วยังใช้ได้แม้ลูกค้าถูกลดชั้นภายหลัง (เขาจ่ายไปแล้ว การยึดคืนเพราะตกจาก หน้าต่างเวลาแบบ rolling ปกป้องไม่ได้) - อ่านยอด lot ภายใน transaction ถ้าอ่านข้างนอก การกดปุ่มแลก 2 ครั้งจะเห็นยอดเดียวกัน
และสำเร็จทั้งคู่; ยอดไม่พอ → 400
LOYALTY_INSUFFICIENT_UNITS - เผา lot ที่จะหมดอายุก่อน (FIFO ตามวันหมดอายุ) เพื่อให้ลูกค้าได้ใช้แต้มที่จะเสียไปอยู่ดี
- การหักแต่ละ lot เป็นการเขียนแบบมีเงื่อนไข ตัวใดไม่สำเร็จ (มีการแลกอื่นแทรกระหว่างอ่านกับเขียน) → ยกเลิกทั้งหมด ไม่หักบางส่วน
- เขียนแถว ledger ของการเผา แล้วออกรางวัล โดยไม่ผูกกับบัตรใบใด เพราะรางวัลโหมดแต้ม เป็นของบัญชี ไม่ใช่ของบัตร
POST /api/loyalty/:hash/rewards/:id/code — ขอโค้ดคูปอง (rate limit 20/60s)
- ค้นรางวัล จากรายการของบัญชีผู้เรียกเท่านั้น รางวัลของคนอื่นจึงไม่อยู่ในรายการและตอบ 404
LOYALTY_REWARD_INVALIDโดยไม่ยืนยันว่ามี id นั้นอยู่จริง - สถานะไม่ใช่ "ยังไม่ถูกใช้" → 409
LOYALTY_REWARD_CLAIMED; หมดอายุแล้ว → 409LOYALTY_REWARD_EXPIRED - โค้ดไม่เคยอยู่ใน response ของหน้าบัตร โดยเจตนา — ตรงกับที่ฝั่งเว็บอธิบายไว้ว่าโค้ดถูกขอ ตอนเปิดแผ่นแสดงโค้ดเท่านั้น ถ้าใส่มาในข้อมูลบัตร ภาพหน้าจอที่ถูกแคปไว้จะใช้แลกได้ตลอดกาล