Apps — Loyalty
ภาพรวม
Loyalty เป็นหนึ่งใน "Apps" — โมดูลเสริมที่คุมด้วย AppEnabledGuard ไม่ใช่ระบบสิทธิ์ตามบทบาทเหมือนเมนูอื่น ๆ พูดให้ตรงคือ ผู้ดูแลระดับแพลตฟอร์มเปิด/ปิดแอปนี้ให้ แต่ละองค์กร ผ่านคอนโซลของตัวเอง แล้วทุกเส้นทางของแอปจะเช็กแถวเดียวในตาราง line_oa_app ว่าองค์กรนี้ได้รับอนุญาตหรือยัง — ไม่ได้ก็ 403 ทั้งโมดูล องค์กรเปิดให้ตัวเองไม่ได้ (ดู สิทธิ์การเข้าถึง)
หน้าที่ของแอปคือให้ร้านค้าออกแบบ "บัตรสะสม" ให้ลูกค้าใช้ผ่าน LINE เลือกได้ 2 โหมด — ดวงตรา (stamp) บัตรมีช่องคงที่ สแกน QR ได้ดวงต่อการซื้อ หรือ คะแนน (point) ได้คะแนนตามยอดใช้จ่าย พร้อมเปิดระบบชั้นสมาชิก (tier) ได้ ฝั่ง CMS มี 8 แท็บ — บัตร, ดีไซน์, รางวัล, ระดับสมาชิก, กลุ่มลูกค้า, พนักงาน, ประวัติ, ลูกค้า
บทบาทที่ใช้งาน — เจ้าของร้านหรือแอดมินที่ตั้งค่าโปรแกรมทั้งหมดผ่าน CMS นี้ — เมนูปรากฏเฉพาะเมื่อแอปถูกเปิดให้องค์กรแล้ว
เส้นทางผู้ใช้งาน
1. ตั้งค่าโปรแกรมและหน้าตาบัตร
- เปิดเมนู Apps > Loyalty เข้าแท็บบัตร เลือกโหมด ดวงตรา หรือ คะแนน — สลับดูได้อิสระ ระบบขึ้นข้อความกำกับว่า "กำลังดูตัวอย่างโหมดนี้ แต่ที่บันทึกไว้ยังเป็นโหมดเดิม ไม่มีอะไรเปลี่ยนจนกดบันทึก"
- กรอกค่าตามโหมด — โหมดดวงตราเลือกจำนวนช่องบัตร (หน้าเว็บเสนอ 5 / 10 / 15 แต่ backend รับได้ถึง 20) โหมดคะแนนกรอกอัตราบาทต่อคะแนน (1–100,000) และเปิดใช้ชั้นสมาชิกได้
- ตั้งนโยบายวันหมดอายุ (
from_first/from_last/none— โหมดคะแนนไม่มีfrom_last), ระยะเว้นการสะสม (unlimited/per_hours1–72 ชม. /per_day), แต้มต้อนรับ และข้อความเงื่อนไข (ไม่เกิน 2,000 ตัวอักษร) - กดบันทึก — ถ้าเปลี่ยนโหมดจากโปรแกรมที่มีอยู่แล้ว ระบบเปิดกล่องยืนยันที่อธิบายผลกระทบ 3 ข้อ ก่อนเสมอ (ออกเวอร์ชันใหม่ / ยอดสะสมเดิมหยุดนับแต่ไม่ถูกลบ / บันไดรางวัลเปลี่ยนไปใช้ราคาของหน่วยใหม่) — กล่องนี้ขึ้นตอน กดบันทึก ไม่ใช่ตอนสลับปุ่มโหมด เพราะการสลับดูยังไม่มีผลอะไร
- กดเผยแพร่โปรแกรม (
active) หรือหยุดออกแต้มชั่วคราว (paused) - เข้าแท็บดีไซน์ อัปโหลดภาพปก โลโก้ ไอคอนดวงตรา (เฉพาะโหมดดวงตรา) และเลือกสีบัตร/สีตัวอักษร/สีปุ่มสแกน/สีดวงที่สะสมแล้ว ดูตัวอย่างบัตรเต็มใบที่คอลัมน์ขวา — การบันทึกดีไซน์ ไม่ออกเวอร์ชันใหม่
2. ตั้งของรางวัลและชั้นสมาชิก
- เข้าแท็บรางวัล เพิ่มของรางวัลพร้อมจำนวนหน่วยที่ต้องใช้แลก — โหมดคะแนนคือ "ราคา" (สูงสุด 100,000) โหมดดวงตราคือ "ที่ช่องที่" (สูงสุดเท่าจำนวนช่องบัตร) ใส่รูปได้ และถ้าเปิดชั้นสมาชิกไว้จะกำหนด "เฉพาะสมาชิกระดับนี้ขึ้นไป" ได้
- หากเปิดโหมดคะแนนพร้อมชั้นสมาชิก เข้าแท็บระดับสมาชิก สร้างชั้น กำหนดลำดับ (rank 1–20) และกฎจาก ยอดใช้จ่าย (
spend) / จำนวนคำสั่งซื้อ (orders) / คะแนน (points) / จำนวนเดือนเป็นสมาชิก (member_months) ด้วยตัวเปรียบเทียบgte/lte/eqและเชื่อมกฎแบบ "ทุกข้อ" หรือ "ข้อใดข้อหนึ่ง" - ตั้งชั้นหนึ่งเป็น "ชั้นเริ่มต้น" ได้ — ชั้นเริ่มต้นไม่ต้องมีกฎเลย เพราะเป็นที่ที่ลูกค้าไปอยู่เมื่อ ไม่ เข้าเงื่อนไขชั้นอื่น
- กด "คำนวณเดี๋ยวนี้" เพื่อประเมินชั้นของสมาชิกทั้งหมดทันที ระบบรายงานกลับว่า "ตรวจสอบลูกค้าแล้ว X คน — เปลี่ยนระดับ Y คน"
3. เชิญพนักงานและออกกลุ่มเป้าหมาย
- เข้าแท็บพนักงาน เพิ่มสาขาก่อน (ระบบเตือนว่า "ควรมีอย่างน้อยหนึ่งสาขา เพราะทุกดวงที่ให้จะบันทึกว่าให้ที่ไหน")
- เชิญพนักงานด้วยการกรอก ชื่อ (แอดมินตั้งชื่อเอง ไม่ใช้ชื่อ LINE เพราะชื่อที่แอดมินตั้งคือชื่อที่ทุกดวงจะถูกอ้างถึงในภายหลัง) และเลือกสาขา — ระบบออก token ครั้งเดียว แล้วแปลงเป็นลิงก์คำเชิญให้คัดลอกทันที (ลิงก์ใช้ได้ครั้งเดียว หมดอายุใน 48 ชั่วโมง และ ดูซ้ำไม่ได้อีก)
- พนักงานเปิดลิงก์เพื่อยืนยันตัว (สถานะเปลี่ยนจาก
invited→pending) แอดมินกดอนุมัติให้เป็นactiveจากตารางนี้ — จะเพิกถอน (revoked) หรือคืนสิทธิ์ภายหลังก็ได้ - เข้าแท็บกลุ่มลูกค้า เลือกชุดเงื่อนไขสำเร็จรูปที่ตรงกับโหมดโปรแกรม (มี 9 ชุด เช่น "ใกล้ครบบัตร", "มีแต้มพอแลกแต่ยังไม่แลก", "สมาชิกระดับสูง") ระบบจะพาไปหน้าสร้างกลุ่มเป้าหมายที่เมนู Segment audience > Multi-source filter พร้อมเงื่อนไขตั้งต้นและชื่อกลุ่มให้ปรับต่อได้
4. ค้นหาลูกค้าและดูประวัติ
- เข้าแท็บลูกค้า ค้นด้วยเบอร์โทร ชื่อ หรืออีเมล — ถ้าเจอผลลัพธ์เดียวระบบเปิดหน้ารายละเอียดให้เลย ดูยอดสะสม ชั้นสมาชิก จำนวนครั้งที่มา ยอดใช้จ่ายรวม และประวัติธุรกรรม (เป็นหน้าอ่านอย่างเดียว ไม่มี endpoint ปรับแต้มด้วยมือใน CMS เลย)
- เข้าแท็บประวัติเพื่อดูรายการล่าสุดของทั้งโปรแกรม (หน้าเว็บขอมา 200 รายการ) ทั้งการให้ดวงจากพนักงาน การแลกรางวัล การหมดอายุ แต้มต้อนรับ และการปรับด้วยมือจากระบบอื่น
รายละเอียดสำคัญที่ตรวจสอบจากซอร์สโค้ดจริง
ยืนยันจาก internal/modules/loyalty/{controller,service_program,service_catalog,scope}.go และ internal/modules/apps/guard.go ของ line-management-cms-api-go:
- มีแค่ 2 ฟิลด์เท่านั้นที่ถือว่า "เปลี่ยนข้อตกลง" และเป็นตัวสั่งให้ออกเวอร์ชันใหม่ —
ProgramInput.IsStructuralยอมรับเฉพาะmodeและcardSizeเพราะทั้งสองเปลี่ยน "หนึ่งดวงมีค่าเท่าไหร่" การแก้ในที่เดิมจะเท่ากับเขียนเงื่อนไขของบัตรที่ลูกค้าสะสมไปครึ่งใบแล้วใหม่โดยเงียบ ๆ ที่เหลือทั้งหมด — ชื่อ, ภาพปก, สี, ข้อความเงื่อนไข, นโยบายวันหมดอายุ, ระยะเว้น — มีผลกับลูกค้าทุกคนทันที ซึ่งเป็นเจตนา และสำคัญกว่านั้นคือ การตัดสินใจว่าจะออกเวอร์ชันใหม่หรือไม่ ถูกอนุมานจากรูปร่างของข้อมูลที่ส่งมา ไม่ใช่จากธงที่ผู้เรียกส่ง — ผู้เรียกจึงไม่มีทางขอให้แก้โครงสร้างในที่เดิมได้เลย เมื่อออกเวอร์ชันใหม่ แถวเดิมถูกตั้งสถานะsupersededแถวใหม่ได้version + 1และบัตรที่ออกไปแล้วเก็บprogram_versionของตัวเองไว้จึงยังใช้เงื่อนไขเดิม - การเปลี่ยนโหมดไม่ได้คัดลอกบันไดรางวัลใบล่าสุดมา — มันไป "ตามหาบันไดของโหมดที่กำลังจะเปลี่ยนไป" กลับมาแทน —
copyChildrenมีเหตุผลชัด: รางวัลตั้งราคาเป็นหน่วย และการเปลี่ยนโหมดเปลี่ยนความหมายของหน่วย ยกเลข "5" จากบัตรดวงตรามาใส่โปรแกรมคะแนนคือตั้งราคาเครื่องดื่มฟรีไว้ที่ห้าแต้ม ซึ่งแทบไม่มีค่า จึงไปค้นเวอร์ชันล่าสุดที่เคยใช้โหมดปลายทางแล้วกู้บันไดของเวอร์ชันนั้นกลับมา — เลียนแบบพฤติกรรมของบัญชีแยกในระบบหลังบ้านพอดี (พักไว้ตอนอีกโหมดทำงาน แล้วคืนกลับมาเหมือนเดิม) ร้านที่ลองโหมดคะแนนหนึ่งสัปดาห์แล้วกลับมาจึงเจอราคาบัตรดวงตราอยู่ที่เดิม ถ้าไม่เคยมีเวอร์ชันของโหมดนั้นเลยก็ไม่มีอะไรให้คัดลอก ต้องตั้งราคาใหม่ในหน่วยใหม่ - รางวัลที่ตกขอบบัตรใบใหม่ถูก "ตัดทิ้ง" ไม่ใช่ "ขยับเข้ามา" — ถ้าลดจำนวนช่องบัตร รางวัลที่ตั้งไว้ที่ช่อง 10 บนบัตรที่เหลือ 8 ช่องจะไม่ถูกคัดลอกไปเวอร์ชันใหม่เลย เพราะมันไม่มีทางไปถึงได้อยู่แล้ว และการย้ายมันมาที่ช่อง 8 เงียบ ๆ จะไปชนกับรางวัลที่อยู่ที่นั่นแล้ว และเปลี่ยนรางวัลที่ร้านไม่ได้ขอให้เปลี่ยน การตัดทิ้งมองเห็นได้ใน CMS ซึ่งเป็นผลลัพธ์ที่ตรงไปตรงมากว่า
- ชั้นสมาชิก (tier) ไม่ถูกออกเวอร์ชันตามโปรแกรม — โดยเจตนา และคอมเมนต์ระบุไว้ตรง ๆ —
copyChildrenปล่อยตารางชั้นสมาชิกไว้ไม่แตะ เพราะชั้นสมาชิกแขวนอยู่กับ LINE OA ไม่ใช่กับเวอร์ชันของโปรแกรม การคัดลอกชั้นสมาชิกตอนออกเวอร์ชันใหม่จะทำให้tier_idของบัญชีลูกค้าทุกคนกลายเป็นอ้างอิงกำพร้า (ชั้นสมาชิกเป็น "คำแถลงเรื่องสถานะของลูกค้าจากยอดหลายเดือน" ส่วนเวอร์ชันโปรแกรมเป็น "การเปลี่ยนเงื่อนไขของบัตร" — คนละเรื่อง) ด้วยเหตุเดียวกันการลบชั้นสมาชิกจึงเป็นการ เก็บเข้าคลัง (archived) และย้ายคนที่อยู่ชั้นนั้นไปยังชั้นเริ่มต้นที่เหลืออยู่ (หรือไม่มีชั้นเลย) ในจังหวะที่ลบ พร้อมบันทึกการย้ายทุกคนลงtier_historyด้วยเหตุผลdemoteเพราะถ้าไม่ย้ายตอนนั้น ชั้นที่ถูกเก็บจะถูกกรองออกจากบันไดและการคำนวณใหม่จะไม่เคยเคลียร์อ้างอิงค้างนั้นเลย — "ทำไม Silver ของฉันหายไป" คือคำถามที่กลไกนี้ตอบ - backend บังคับค่าที่ขัดกับโหมดทับลงไปเอง ไม่เชื่อค่าที่หน้าเว็บส่งมา — ในโหมดคะแนน
applyInputเซ็ตCardSize = nilและเมื่อโหมดไม่ใช่คะแนนก็เซ็ตTierEnabled = falseคอมเมนต์ระบุสาเหตุจริง: antd เก็บค่าของฟิลด์ที่ถูก unmount ไว้ ทำให้แท็บบัตรยังส่งจำนวนช่องเดิมมาต่อหลังสลับไปโหมดคะแนน และค่าช่องที่หลุดมาจะเปลี่ยนยอดคะแนนกลับไปเป็นบัตรดวงตราโดยเงียบ ส่วนชั้นสมาชิกที่ค้างเปิดไว้ข้ามโหมดจะทำให้งานคำนวณรายคืนประเมินลูกค้าทุกคนที่ศูนย์ - การตั้งค่าชั้นสมาชิกเป็นการแก้ "ในที่เดิม" และเคยมีบั๊กที่บันทึกสำเร็จแต่ค่าไม่เข้า — คอมเมนต์ใน
updateMapบันทึกไว้ว่าการลืมใส่คอลัมน์ tier ในสายแก้ในที่เดิมทำให้การบันทึก "สำเร็จเงียบ ๆ" คือ response ถูกประกอบจาก struct จึงตอบว่าtierEnabled = trueขณะที่คำสั่ง UPDATE ไม่เคยพาคอลัมน์นั้นไป ฐานข้อมูลจึงยังเป็น false อยู่ - การกด "หยุดออกแต้ม" ไม่แตะยอดสะสมของใครเลย และนี่คือเหตุผลที่ฟีเจอร์นี้มีอยู่ —
SetStatus('paused')หยุดการออกดวง/แต้มเท่านั้น ยอดสะสม บัตร และรางวัลที่ยังไม่ได้แลก อยู่ครบและยังแลกได้ คอมเมนต์ในโค้ดเทียบตรง ๆ ว่าระบบเทียบเคียงของ LINE ลบคะแนนที่สะสมมาทั้งหมด ซึ่งเป็นพฤติกรรมที่ฟีเจอร์นี้ตั้งใจหลีกเลี่ยง - token คำเชิญพนักงานถูกออกแบบให้ "อ่านซ้ำไม่ได้" ตั้งแต่ระดับ struct —
randomToken(32)สุ่มจากcrypto/rand(ไม่ใช่math/randเพราะคำเชิญที่เดาได้เท่ากับให้สิทธิ์พนักงานแก่ใครก็ตามที่เดาถูก), อายุInviteTTL = 48 ชั่วโมง, และคืนค่าใน response ของPOST /apps/loyalty/staff/inviteครั้งเดียวเท่านั้น — ฟิลด์invite_tokenบน entity เป็นjson:"-"จึงไม่มีทางรั่วออกมาทาง endpoint รายการได้เลย คอมเมนต์สรุปหลักการไว้ว่า "ความลับที่อ่านซ้ำได้ คือความลับที่นั่งอยู่ในทุก response ในอนาคต" ถ้าลิงก์หาย วิธีเดียวคือยกเลิกคำเชิญแล้วเชิญใหม่ ฝั่งหน้าเว็บก็ไม่แสดง token เปล่าเลย — สร้างเป็นลิงก์ให้คัดลอก และถ้ายังไม่รู้ hash ของ OA จะแจ้งข้อผิดพลาดแทนที่จะแสดงอะไรก็ตาม - การลบคำเชิญเป็นการลบจริง แต่การเพิกถอนพนักงานไม่ใช่ — และเส้นแบ่งอยู่ที่ "มีประวัติหรือยัง" —
RevokeInviteลบแถวออกจริงแต่เฉพาะแถวที่ยังสถานะinvitedเพราะคำเชิญที่ยังไม่ถูกใช้ไม่มีประวัติผูกอยู่ ไม่มีอะไรต้องรักษา ส่วนพนักงานตัวจริงถูกเปลี่ยนสถานะเป็นrevokedและเก็บแถวไว้ เพื่อให้ดวงที่เขาเคยให้ยังสาวกลับหาคนได้ และเพื่อให้แคชเชียร์ที่ถูกเพิกถอนเปิด LIFF ใหม่ยังเป็นrevokedไม่ใช่ตกกลับไปอยู่ในคิวอนุมัติเหมือนคำขอใหม่ ส่วนการกดอนุมัติแถวที่ยังเป็นinvitedถูกปฏิเสธด้วย 400 ตรง ๆ เพราะยังไม่มีตัวตน LINE ให้อนุมัติ (และ CHECK constraint จะปฏิเสธอยู่ดี) — เลือกตอบให้ชัดกว่าปล่อยเป็น 500 - ลำดับชั้นสมาชิก (rank) บังคับด้วย unique index ไม่ใช่การเช็กก่อนบันทึก — คอมเมนต์ระบุว่าการเช็กก่อนคือการแข่งกันระหว่างสองแท็บที่เปิดอยู่ ระบบจึงปล่อยให้ index (partial บน
status = 'active'— ซึ่งเป็นเหตุผลที่การเก็บชั้นเข้าคลังปล่อย rank นั้นให้ใช้ซ้ำได้) เป็นผู้บังคับ แล้วแปลง unique violation (23505) เป็นLOYALTY_TIER_RANK_TAKENและมีบันทึกไว้ว่า เฉพาะ unique violation เท่านั้นที่แปลงเป็นรหัสนี้ เพราะการแปลงทุก error ไปที่รหัสนี้เคยกลบสาเหตุจริงไปแล้วครั้งหนึ่ง — ชื่อคอลัมน์ที่พิมพ์ผิดอ่านออกมาเป็น "เลือกเลขอื่น" ซึ่งส่งร้านไปตามหาผิดที่ - กฎชั้นสมาชิกเฉพาะ "ยอดใช้จ่าย" เท่านั้นที่ต้องใช้โหมดคะแนน —
validateTierInputปฏิเสธด้วยLOYALTY_TIER_POINT_MODE_ONLYเฉพาะเมื่อมีกฎspendในโหมดที่ไม่ใช่pointส่วนจำนวนคำสั่งซื้อและจำนวนเดือนเป็นสมาชิกทำงานบนบัตรดวงตราได้ปกติ การปิดฟีเจอร์ทั้งก้อนที่นั่นจึงจะเป็นการห้ามแบบไม่มีเหตุผล และตัวตรวจนี้ถูกใช้ร่วมกันทั้งขาสร้างและขาแก้ไข เพื่อไม่ให้กฎที่ขาสร้างปฏิเสธ กลับเข้ามาได้ทางการกดบันทึกการแก้ไข - "คำนวณเดี๋ยวนี้" ใช้กฎชุดเดียวกับงานรายคืนทุกข้อ รวมถึงการลดชั้น —
RecalculateTiersต้องเป็นโหมดคะแนน และ เปิดชั้นสมาชิกไว้ ไม่ใช่ก็ 400 ถ้าบันไดว่างเปล่าจะคืน{evaluated: 0, changed: 0}แทนการลดชั้นทุกคน (คอมเมนต์ว่า "การลดชั้นทุกคนเป็นปฏิกิริยาที่น่าตกใจเกินไปต่อการตั้งค่าที่ยังทำไม่เสร็จ") จุดที่ละเอียดคือมันคำนวณสถิติ แยกต่อชั้น เพราะแต่ละชั้นตั้งช่วงเวลาของตัวเองได้ บันไดที่ผสม "5,000 ใน 12 เดือน" กับ "500 ใน 3 เดือน" ต้องมีทั้งสองยอด และ query ใช้LEFT JOINเพื่อให้บัญชีที่ไม่มียอดในช่วงนั้นมาถึงด้วยค่าศูนย์ ไม่ใช่หายไป — เพราะกลุ่มนั้นคือกลุ่มที่ถึงเวลาถูกลดชั้นพอดี งานรายคืนใน worker-go เวลา 03:00 ยังเป็นตัวหลัก ปุ่มนี้มีเพราะร้านที่เพิ่งสร้างบันไดต้องเห็นว่ามันทำอย่างที่คิดไหม และ "รอดูพรุ่งนี้" ไม่ใช่คำตอบขณะที่ยังตัดสินใจเรื่องเกณฑ์อยู่ (คอมเมนต์ระบุด้วยว่านี่เป็น สำเนาที่สาม ของตรรกะประเมินชั้นสมาชิก — client-api ประเมินที่หน้าเคาน์เตอร์, worker-go กวาดรายคืน, ตัวนี้คำนวณตามคำสั่ง และทั้งสามไม่ได้ใช้แพ็กเกจร่วมกัน) - แต้มต้อนรับไม่ใช่คอลัมน์ในตารางโปรแกรม แต่ถูกพับให้ดูเหมือนเป็น — มันคือแถวใน
bonus_rule(trigger_type = 'join',effect = 'grant_units',cadence = 'once') และ controller พับค่านั้นเข้ามาใน payload เดียวกับโปรแกรมในชื่อwelcomeUnitsเพราะ "ในสายตาร้าน มันคือฟิลด์หนึ่งบนบัตร ฟอร์มไม่ควรต้องรู้ว่ามันคือแถว bonus_rule" และมันถูกบันทึกเฉพาะเมื่อผู้เรียกส่งค่ามาจริง เพื่อให้การแก้บัตรแค่บางส่วนไม่ล้างแต้มต้อนรับทิ้งโดยเงียบ ใส่ 0 หรือค่าลบคือปิดกฎนั้น - จำนวนรายการในบันทึกกิจกรรมถูกจำกัดที่ backend ไม่ใช่ที่หน้าเว็บ — หน้าเว็บขอ 200 รายการ แต่
Activityมีกฎว่าlimit <= 0 || limit > 500จะถูกบังคับกลับเป็น 100 ชื่อพนักงาน/สาขา/ลูกค้าถูก JOIN เข้ามาไม่ใช่คัดลอกลงแถวธุรกรรม เพื่อให้พนักงานที่ถูกเปลี่ยนชื่อใน CMS แสดงถูกในแถวเก่าด้วย และการ join ตารางline_userถูกบีบด้วยline_oa_idควบคู่กับuser_idเพราะ LINE userId เป็นค่า global — join ด้วย userId เพียงตัวเดียวจะดึงโปรไฟล์จากแถวของ OA อื่นมา - บัตรมีเพดาน 20 ช่องที่ backend ไม่ใช่ที่ LINE —
MaxCardSize = 20โดยคอมเมนต์ระบุว่า LINE อนุญาต 50 แต่ตะแกรง 50 ช่องอ่านไม่ได้บนมือถือ ค่านี้ตรงกับ CHECK constraint ในฐานข้อมูลและกับ client API
เมนูที่เกี่ยวข้องกันในเส้นทางนี้
- Segment audience > Multi-source filter — ปลายทางของกลุ่มเป้าหมายสำเร็จรูป แท็บกลุ่มลูกค้าไม่ได้สร้างกลุ่มเอง แต่ส่งชุดเงื่อนไข (ประเภทแหล่งข้อมูล
loyaltyพร้อม metric/operator/ค่า) และชื่อกลุ่มไปเป็น query parameterpresetที่/audience/filter/multi-source/form/?id=create&preset=...ฟอร์มปลายทางรับมาใช้ ครั้งเดียว วางทุกเงื่อนไขไว้ในกลุ่มเดียว ตั้งชื่อกลุ่มให้ แล้วขึ้นแบนเนอร์ว่า"ใช้กลุ่มสำเร็จรูป: <ชื่อ>"ให้ปรับต่อได้อิสระ — ทิศทางกลับกัน หน้าตัวกรองก็ดึงบันไดชั้นสมาชิกจากแอปนี้มาให้เลือกด้วย - Content Management — แอปนี้ ไม่มี endpoint อัปโหลดรูปของตัวเอง ทั้งภาพปกบัตร โลโก้ ไอคอนดวงตรา รูปของรางวัล และภาพพื้นหลังของชั้นสมาชิก ใช้บริการอัปโหลดของโมดูล Content Management ทั้งหมด (
CONTENT_PAGE_SERVICE.uploadImage→POST /content-pages/upload-image) รับเฉพาะ JPEG / PNG / GIF (ปฏิเสธ SVG โดยเจตนา) และภาพพื้นหลังชั้นสมาชิกยังถูกตัดขอบสีเรียบออกที่ฝั่งหน้าเว็บก่อนอัปโหลดด้วย - Apps (คอนโซลผู้ดูแลแพลตฟอร์ม) — เป็นที่เดียวที่เปิด/ปิดแอปนี้ให้องค์กรได้
จุดที่ควรระวัง
- เปลี่ยนโหมดโปรแกรม (ดวงตรา ↔ คะแนน) หรือเปลี่ยนจำนวนช่องบัตร คือการออกเวอร์ชันใหม่ — ยอดสะสมเดิมหยุดนับ (แต่ไม่ถูกลบ และกลับมาเหมือนเดิมถ้าสลับกลับ) และบันไดรางวัลถูกแทนที่ด้วยราคาของหน่วยใหม่ ระบบเตือนผลกระทบทั้ง 3 ข้อก่อนบันทึกเสมอ ส่วนการแก้ชื่อ/สี/ดีไซน์/เงื่อนไข ไม่ ออกเวอร์ชัน แต่มีผลกับลูกค้าทุกคนทันที
- ลดจำนวนช่องบัตรแล้วรางวัลที่เกินช่องใหม่จะหายไปจากเวอร์ชันใหม่ ต้องตั้งขึ้นมาใหม่เอง — ระบบไม่ขยับมันเข้ามาให้
- ลิงก์คำเชิญพนักงานแสดงครั้งเดียว ใช้ได้ครั้งเดียว หมดอายุ 48 ชั่วโมง — ถ้าลืมคัดลอกหรือลิงก์หาย ทางเดียวคือยกเลิกคำเชิญแล้วเชิญใหม่ ไม่มีวิธีเปิดดูซ้ำ
- เชิญพนักงานแล้วยังใช้งานไม่ได้ทันที — ต้องผ่านสองจังหวะคือพนักงานเปิดลิงก์ยืนยันตัว (
pending) แล้วแอดมินกดอนุมัติ (active) - ปุ่ม "คำนวณเดี๋ยวนี้" ใช้ได้เฉพาะโหมดคะแนนที่เปิดชั้นสมาชิกไว้และมีชั้นอยู่จริง — ถ้าบันไดยังว่าง ระบบจะรายงานว่าตรวจ 0 คน ไม่ใช่ลดชั้นทุกคน
- การให้แต้มและแลกรางวัลจริงไม่เกิดที่หน้า CMS นี้ — เกิดที่หน้า LIFF ของพนักงานและลูกค้า หน้านี้ตั้งค่าและอ่านประวัติกลับมาดูเท่านั้น และ ไม่มี endpoint ปรับแต้มด้วยมือ ในโมดูลนี้เลย
- บันทึกกิจกรรมแสดงได้จริงไม่เกิน 100 รายการ แม้หน้าเว็บจะขอมา 200 เพราะ backend บังคับกลับเป็น 100 เมื่อค่าที่ขอเกิน 500 หรือไม่เข้าเงื่อนไข
สิทธิ์การเข้าถึง (ตรวจสอบจากซอร์สโค้ดจริง)
- ฝั่งเซิร์ฟเวอร์ —
AppEnabledGuardถูกต่อเข้าไปจริงในทุกเส้นทางทั้ง 22 เส้นทางของโมดูลนี้ ยืนยันจากloyalty/controller.goที่สร้างappEnabled := apps.AppEnabledGuard(d)แล้วใส่เป็น middleware ตัวแรกของทุกบรรทัด ตัว guard (apps/guard.go) ดึงชื่อแอปจาก path ด้วย regex/apps/(\w+)แล้วเรียกIsAppEnabled(orgID, "loyalty")ซึ่งหาแถวในline_oa_appที่organization_id = ? AND app_id = ? AND enabled = true— ไม่พบแถว = ปิด และตอบ 403App "loyalty" is not enabled for this organizationโดย guard นี้ scope ด้วย organization_id เท่านั้น ไม่แยกตาม LINE OA - การเปิด/ปิดแอปเป็น endpoint สำหรับผู้ดูแลระดับแพลตฟอร์มเท่านั้น —
PUT /api/apps/:appIdถูกครอบด้วยmodulegate.PlatformOnly(d)ที่อ่านuser.typeของผู้เรียกจากฐานข้อมูลและปฏิเสธผู้ใช้ประเภทcustomerทันที เหตุผลในคอมเมนต์คือถ้าองค์กรสลับแอปของตัวเองได้ ก็เปิดสิ่งที่ผู้ดูแลแพลตฟอร์มยึดไว้กลับคืนได้เอง และมี ความไม่สมมาตรที่ตั้งใจ:ModuleGateของโมดูลปกติ fail open แต่PlatformOnlyfail closed — ระบุตัวผู้เรียกไม่ได้ = ถือเป็น customer = ปฏิเสธ เพราะความเสี่ยงที่นี่คือการยกระดับสิทธิ์ ไม่ใช่การถูกล็อกออก - policy annotation ระดับการกระทำในโค้ดไม่ได้บังคับอะไรเลย — ทุกเส้นทางมี
auth.CheckPolicies(auth.Can(..., auth.PolicyModuleLineOa))ประกาศครบ แต่auth.CheckPoliciesเป็น no-op จริง ๆ (internal/modules/auth/policy.goเขียนกำกับว่า "Enforcement disabled — allow all authenticated requests (TS parity)") — การบังคับจริงของโมดูลนี้จึงมีชั้นเดียว คือAppEnabledGuardระดับแอป ไม่มีModuleGateแบบที่โมดูล Campaign มี และไม่มีการแยกสิทธิ์ระหว่างคนตั้งค่าโปรแกรมกับคนอนุมัติพนักงาน — ใครเข้าถึงแอปได้ ทำได้ทุกอย่างในแอป รวมถึงการออก token คำเชิญพนักงาน - การแยกข้อมูลระหว่างองค์กรบังคับที่ชั้น query — ทุก query ผ่าน
repo.scoped/repo.tx(loyalty/scope.go) ที่บีบทั้งorganization_idและline_oa_idและ raw SQL ทุกก้อนในRecalculateTiers/DeleteTierก็มีline_oa_idในเงื่อนไขด้วยตัวเอง - การแบ่งงานกับ client API เป็นอีกชั้นของการควบคุมโดยตัวสถาปัตยกรรม — CMS ตั้งค่าและอ่าน ส่วนการออกแต้ม/แลกรางวัลจริงอยู่ที่ client API และหน้า LIFF ของพนักงาน ผลคือถึงบัญชี CMS จะถูกยึด ก็ไม่มี endpoint ในโมดูลนี้ที่เพิ่มแต้มให้บัญชีลูกค้าได้เลย
ไฟล์อ้างอิงจากซอร์สโค้ด
Frontend (cms-web-2026)
src/app/apps/loyalty/layout.tsx— แถบ 8 แท็บและป้ายบอกโหมดโปรแกรมsrc/app/apps/loyalty/card/page.tsx— สวิตช์โหมด ค่าจำกัดของฟอร์ม และกล่องยืนยันผลกระทบ 3 ข้อตอนกดบันทึกsrc/app/apps/loyalty/design/page.tsx+components/full-card-preview.tsx— ภาพ/สี และตัวอย่างบัตรเต็มใบsrc/app/apps/loyalty/rewards/—page.tsx,reward-form.modal.tsx(หน่วยและเพดานที่เปลี่ยนตามโหมด)src/app/apps/loyalty/tiers/—page.tsx(ปุ่มคำนวณใหม่),tier-form.modal.tsx(กฎ metric/operator),lib/describe-rule.ts,lib/trim-border.tssrc/app/apps/loyalty/segments/—page.tsx,lib/presets.ts(9 ชุดสำเร็จรูป และการประกอบ querypreset)src/app/apps/loyalty/staff/page.tsx— การเชิญ ลิงก์ที่แสดงครั้งเดียว และวงจรสถานะพนักงานsrc/app/apps/loyalty/{activity,customer}/page.tsx— บันทึกกิจกรรมและการค้นหาลูกค้าsrc/services/loyalty.service.ts— endpoint ทั้งหมดที่หน้าเว็บเรียกใช้
Backend (line-management-cms-api-go)
internal/modules/apps/guard.go—AppEnabledGuardและการอ่านตารางline_oa_appinternal/modules/apps/controller.go—PUT /apps/:appIdกับmodulegate.PlatformOnlyinternal/modules/modulegate/modulegate.go—PlatformOnlyที่ fail closedinternal/modules/loyalty/controller.go— 22 เส้นทาง และการต่อAppEnabledGuardเข้าทุกเส้นทางinternal/modules/loyalty/service_program.go—IsStructural, การออกเวอร์ชันใหม่,copyChildren, การบังคับค่าตามโหมด,SetStatusinternal/modules/loyalty/service_catalog.go— รางวัล, สาขา, พนักงาน/คำเชิญ (InviteTTL,randomToken), แต้มต้อนรับ, ชั้นสมาชิก,RecalculateTiers, บันทึกกิจกรรม, การค้นหาลูกค้าinternal/modules/loyalty/scope.go— การบีบ scope สองชั้นของทุก query ในโมดูล
ดูรายละเอียดเชิงเทคนิคเพิ่มเติมที่ แอปสะสมแต้ม (Loyalty)