บัตรสะสมแต้มและระบบสมาชิก
ภาพรวม
แอปเสริม บัตรสะสมแสตมป์และแต้ม ที่ออกแบบมาใช้แทนระบบสะสมแต้มของ LINE เอง
ตารางทั้งหมดอยู่ใน schema loyalty ไม่มีนิยามใน Prisma และ apply ด้วย psql
เช่นเดียวกับแอป appointment และ bulletin
มีแนวคิดหลักสองข้อที่อธิบายรูปร่างของ schema เกือบทั้งหมด และถูกระบุไว้ชัดเจนในหัวไฟล์ migration
- บัญชีแยกประเภทคือความจริง — ระบบไม่เก็บยอดคงเหลือเป็นคอลัมน์ แต่รวมสดจาก lot
ในตาราง
transactionที่ยังไม่ถูกใช้และยังไม่หมดอายุ เหตุผลคือยอดที่ cache ไว้จะผิดทันที ที่ lot ใด lot หนึ่งหมดอายุ เพราะการหมดอายุเป็นฟังก์ชันของเวลา ไม่ใช่ของการเขียนข้อมูล - บัตรในกระเป๋าต้องรักษาข้อตกลงเดิม —
card_instanceเก็บ snapshot ของprogram_versionและcard_sizeไว้ ทำให้ร้านแก้ไขเงื่อนไขบัตรทีหลังได้โดยไม่กระทบบัตรที่ลูกค้ากำลังสะสมอยู่
โครงสร้างข้อมูลหลัก
loyalty.program — คอนฟิกบัตร
mode(stampหรือpoint),name,unit_label(ค่าเริ่มต้น "ดวง") และversioncard_sizeคือจำนวนช่องแสตมป์ (1–20) ส่วนbaht_per_pointใช้ในโหมดแต้ม- รูปภาพ
cover_path,logo_pathและstamp_icon_pathเก็บเป็น object path ไม่ใช่ URL เพราะการเก็บ URL จะผูกข้อมูลไว้กับ layout ของ bucket แล้วพังเมื่อ config เปลี่ยน - การหมดอายุ:
expiry_mode(from_first,from_lastหรือnone) และexpiry_months - คูลดาวน์:
cooldown_mode(unlimited,per_hoursหรือper_day) โดยมีค่าเริ่มต้นเป็นunlimitedซึ่งต่างจาก LINE เพราะโค้ดของระบบนี้ออกโดยพนักงานที่ล็อกอินแล้วและหมดอายุใน 60 วินาที status(draft,active,paused,superseded) พร้อม partial unique index(line_oa_id) WHERE status IN ('draft','active','paused')ซึ่งบังคับว่าแต่ละช่องทาง มีโปรแกรมที่ยังไม่ถูกแทนที่ได้ครั้งละหนึ่งโปรแกรม- คอลัมน์ที่เกี่ยวกับระดับสมาชิก อธิบายไว้ที่ ระดับสมาชิก Loyalty
loyalty.branch และ loyalty.staff
loyalty.branch เก็บข้อมูลสาขา ส่วน loyalty.staff คือ allowlist ของพนักงานที่ออกแสตมป์ได้
แบบเชิญเข้าเท่านั้น
- วงจรชีวิตของพนักงาน:
invited→pending→active→revoked display_nameคือชื่อที่เจ้าของร้านตั้งและจะไม่ถูกเขียนทับ ส่วนโปรไฟล์ LINE ของคนที่มา claim เก็บแยกไว้ที่line_display_nameและline_picture_urlเพื่อให้ผู้อนุมัติเห็นความไม่ตรงกันได้invite_tokenใช้ partial unique และถูกเคลียร์เมื่อ claim สำเร็จ จึงใช้ได้ครั้งเดียว คู่กับinvite_expires_at- ใช้หลัก deny by default คือมีเพียงสถานะ
activeเท่านั้นที่ออกแสตมป์ได้
loyalty.account และ loyalty.card_instance
loyalty.account คือบัญชีลูกค้าหนึ่งคนต่อหนึ่ง OA จุดออกแบบที่สำคัญคือ line_user_id
เป็น nullable ตั้งแต่วันแรก เพื่อรองรับการให้แต้มด้วยเบอร์โทรก่อนที่ลูกค้าจะมาผูกบัญชี LINE
โดยมี partial unique สองตัวคู่กัน
(line_oa_id, line_user_id) WHERE line_user_id IS NOT NULL(line_oa_id, mobile_no) WHERE mobile_no IS NOT NULL AND line_user_id IS NULL
ดีไซน์นี้เองที่ทำให้การรวมบัญชีเบอร์โทรเข้ากับบัญชี LINE ทำได้ในภายหลัง
loyalty.card_instance แทนใบบัตรแต่ละใบ เมื่อสะสมเต็มจะขึ้นใบใหม่เหมือนบัตรตอกกระดาษ
เก็บ sequence_no (unique ร่วมกับบัญชี), slots_filled, card_size,
program_version และ status (active, complete หรือ expired)
loyalty.milestone — รางวัลบนโปรแกรม
รางวัลถูกออกแบบเป็น รายการอิสระ ไม่ใช่โครงสร้าง "รางวัลหลักและรางวัลรอง" แบบ LINE
required_units,title,image_path,reward_expiry_daysและmin_tier_rank- unique
(program_id, program_version, required_units)ป้องกันรางวัลซ้ำที่ระดับเดียวกัน
loyalty.transaction — บัญชีแยกประเภท
ตารางนี้เป็น append-only มีเพียง consumed_units เท่านั้นที่ถูกอัปเดตภายหลัง
kind(earn,burn,expire,adjust),units,mode(stampหรือpoint) และamount_spentexpires_dateใช้เฉพาะ lot ฝั่ง earn คู่กับconsumed_unitsidempotency_keyพร้อม partial unique(line_oa_id, idempotency_key)ป้องกันการกดซ้ำ หรือการ retry เมื่อเน็ตหลุด โดยบังคับที่ระดับฐานข้อมูลไม่ใช่ที่โค้ด- index
(account_id, mode, kind, expires_date, id) WHERE kind='earn'ใช้ทั้งการรวมยอดคงเหลือ และการหักแบบ FIFO - การ scope ด้วย
modeทำให้เมื่อร้านเปลี่ยนโปรแกรมจากแสตมป์เป็นแต้ม lot ของโหมดเดิม จะถูกพักไว้ไม่ถูกนับ แต่ไม่ถูกลบ หากสลับกลับมาก็ได้ยอดเดิมคืน
ตารางประกอบ
loyalty.reward— รางวัลที่ได้แล้วรอใช้codeVARCHAR(16) ใช้ชุดตัวอักษรที่ตัด I, L, O, U และเลข 0 กับ 1 ออก เพื่อลดการอ่านหรือพิมพ์ผิด พร้อมstatus(unclaimed,claimed,expired),claimed_by_staff_idและclaimed_branch_idloyalty.earn_token— QR แบบหมุนเวียน ใช้ครั้งเดียว อายุสั้น โดยstaff_idและbranch_idเป็น NOT NULL เพราะทุกการออกแสตมป์ต้องระบุตัวบุคคลและสาขาได้เสมอloyalty.bonus_ruleและloyalty.bonus_grant— ระบบโบนัส โดย welcome bonus เป็นเพียง หนึ่ง instance ของกฎทั่วไปtrigger_typeรองรับjoin,birthday,first_earn,time_window,spend_thresholdและaudienceส่วนeffectเป็นgrant_unitsหรือmultiplyและcadenceเป็นonce,yearlyหรือdailyการควบคุมทุก cadence ทำผ่านคอลัมน์เดียว คือbonus_grant.period_keyคู่กับ unique(rule_id, account_id, period_key)loyalty.audit_log— บันทึกว่าใครแก้โปรแกรม อนุมัติพนักงาน หรือปรับยอดด้วยมือ
ไฟล์ที่เกี่ยวข้อง
apps/loyalty/migrations/001_create_schema.sql— คำสั่งCREATE SCHEMA loyaltyapps/loyalty/migrations/002_create_tables.sql— ตารางหลักทั้งหมด พร้อมคอมเมนต์อธิบายเหตุผลอย่างละเอียดapps/loyalty/migrations/003_staff_invites.sql— เปลี่ยนระบบพนักงานเป็นแบบเชิญเข้าเท่านั้นapps/loyalty/migrations/004_stamp_icon.sql— คอลัมน์stamp_icon_pathapps/loyalty/migrations/005_transaction_mode.sql— scope ledger ด้วยmodeapps/loyalty/migrations/006ถึง009— ระบบระดับสมาชิก ดู ระดับสมาชิก Loyaltydocs/superpowers/specs/2026-07-28-loyalty-tier-design.mdและdocs/superpowers/plans/2026-07-28-loyalty-tier.mddocs/ROLLBACK-tier.md— จุด rollback ที่ครอบคลุมหลาย repo
จุดเชื่อมต่อกับ Service อื่น
- client-api-go ให้บริการ LIFF ฝั่งลูกค้า (ดูบัตร สแกนรับแสตมป์ แลกรางวัล) และ LIFF ฝั่งพนักงาน (ออก QR รับโค้ดรางวัล)
- cms-api-go ดูแลการตั้งค่าโปรแกรม รางวัล สาขา การเชิญและอนุมัติพนักงาน การปรับยอดด้วยมือ และรายงาน
- ผูกกับผู้ใช้ผ่าน LINE userId ของ เพื่อน LINE
และเปิดปิดแอปด้วย
line_oa_appใน ช่องทาง LINE OA