Skip to main content

Apps — Loyalty

ภาพรวม​

Loyalty เป็นหนึ่งใน "Apps" — โมดูลเสริมที่คุมด้วย AppEnabledGuard ไม่ใช่ระบบสิทธิ์ตามบทบาทเหมือนเมนูอื่น ๆ พูดให้ตรงคือ ผู้ดูแลระดับแพลตฟอร์มเปิด/ปิดแอปนี้ให้ แต่ละองค์กร ผ่านคอนโซลของตัวเอง แล้วทุกเส้นทางของแอปจะเช็กแถวเดียวในตาราง line_oa_app ว่าองค์กรนี้ได้รับอนุญาตหรือยัง — ไม่ได้ก็ 403 ทั้งโมดูล องค์กรเปิดให้ตัวเองไม่ได้ (ดู สิทธิ์การเข้าถึง)

หน้าที่ของแอปคือให้ร้านค้าออกแบบ "บัตรสะสม" ให้ลูกค้าใช้ผ่าน LINE เลือกได้ 2 โหมด — ดวงตรา (stamp) บัตรมีช่องคงที่ สแกน QR ได้ดวงต่อการซื้อ หรือ คะแนน (point) ได้คะแนนตามยอดใช้จ่าย พร้อมเปิดระบบชั้นสมาชิก (tier) ได้ ฝั่ง CMS มี 8 แท็บ — บัตร, ดีไซน์, รางวัล, ระดับสมาชิก, กลุ่มลูกค้า, พนักงาน, ประวัติ, ลูกค้า

บทบาทที่ใช้งาน — เจ้าของร้านหรือแอดมินที่ตั้งค่าโปรแกรมทั้งหมดผ่าน CMS นี้ — เมนูปรากฏเฉพาะเมื่อแอปถูกเปิดให้องค์กรแล้ว

เส้นทางผู้ใช้งาน​

1. ตั้งค่าโปรแกรมและหน้าตาบัตร​

  1. เปิดเมนู Apps > Loyalty เข้าแท็บบัตร เลือกโหมด ดวงตรา หรือ คะแนน — สลับดูได้อิสระ ระบบขึ้นข้อความกำกับว่า "กำลังดูตัวอย่างโหมดนี้ แต่ที่บันทึกไว้ยังเป็นโหมดเดิม ไม่มีอะไรเปลี่ยนจนกดบันทึก"
  2. กรอกค่าตามโหมด — โหมดดวงตราเลือกจำนวนช่องบัตร (หน้าเว็บเสนอ 5 / 10 / 15 แต่ backend รับได้ถึง 20) โหมดคะแนนกรอกอัตราบาทต่อคะแนน (1–100,000) และเปิดใช้ชั้นสมาชิกได้
  3. ตั้งนโยบายวันหมดอายุ (from_first / from_last / none — โหมดคะแนนไม่มี from_last), ระยะเว้นการสะสม (unlimited / per_hours 1–72 ชม. / per_day), แต้มต้อนรับ และข้อความเงื่อนไข (ไม่เกิน 2,000 ตัวอักษร)
  4. กดบันทึก — ถ้าเปลี่ยนโหมดจากโปรแกรมที่มีอยู่แล้ว ระบบเปิดกล่องยืนยันที่อธิบายผลกระทบ 3 ข้อ ก่อนเสมอ (ออกเวอร์ชันใหม่ / ยอดสะสมเดิมหยุดนับแต่ไม่ถูกลบ / บันไดรางวัลเปลี่ยนไปใช้ราคาของหน่วยใหม่) — กล่องนี้ขึ้นตอน กดบันทึก ไม่ใช่ตอนสลับปุ่มโหมด เพราะการสลับดูยังไม่มีผลอะไร
  5. กดเผยแพร่โปรแกรม (active) หรือหยุดออกแต้มชั่วคราว (paused)
  6. เข้าแท็บดีไซน์ อัปโหลดภาพปก โลโก้ ไอคอนดวงตรา (เฉพาะโหมดดวงตรา) และเลือกสีบัตร/สีตัวอักษร/สีปุ่มสแกน/สีดวงที่สะสมแล้ว ดูตัวอย่างบัตรเต็มใบที่คอลัมน์ขวา — การบันทึกดีไซน์ ไม่ออกเวอร์ชันใหม่

2. ตั้งของรางวัลและชั้นสมาชิก​

  1. เข้าแท็บรางวัล เพิ่มของรางวัลพร้อมจำนวนหน่วยที่ต้องใช้แลก — โหมดคะแนนคือ "ราคา" (สูงสุด 100,000) โหมดดวงตราคือ "ที่ช่องที่" (สูงสุดเท่าจำนวนช่องบัตร) ใส่รูปได้ และถ้าเปิดชั้นสมาชิกไว้จะกำหนด "เฉพาะสมาชิกระดับนี้ขึ้นไป" ได้
  2. หากเปิดโหมดคะแนนพร้อมชั้นสมาชิก เข้าแท็บระดับสมาชิก สร้างชั้น กำหนดลำดับ (rank 1–20) และกฎจาก ยอดใช้จ่าย (spend) / จำนวนคำสั่งซื้อ (orders) / คะแนน (points) / จำนวนเดือนเป็นสมาชิก (member_months) ด้วยตัวเปรียบเทียบ gte / lte / eq และเชื่อมกฎแบบ "ทุกข้อ" หรือ "ข้อใดข้อหนึ่ง"
  3. ตั้งชั้นหนึ่งเป็น "ชั้นเริ่มต้น" ได้ — ชั้นเริ่มต้นไม่ต้องมีกฎเลย เพราะเป็นที่ที่ลูกค้าไปอยู่เมื่อ ไม่ เข้าเงื่อนไขชั้นอื่น
  4. กด "คำนวณเดี๋ยวนี้" เพื่อประเมินชั้นของสมาชิกทั้งหมดทันที ระบบรายงานกลับว่า "ตรวจสอบลูกค้าแล้ว X คน — เปลี่ยนระดับ Y คน"

3. เชิญพนักงานและออกกลุ่มเป้าหมาย​

  1. เข้าแท็บพนักงาน เพิ่มสาขาก่อน (ระบบเตือนว่า "ควรมีอย่างน้อยหนึ่งสาขา เพราะทุกดวงที่ให้จะบันทึกว่าให้ที่ไหน")
  2. เชิญพนักงานด้วยการกรอก ชื่อ (แอดมินตั้งชื่อเอง ไม่ใช้ชื่อ LINE เพราะชื่อที่แอดมินตั้งคือชื่อที่ทุกดวงจะถูกอ้างถึงในภายหลัง) และเลือกสาขา — ระบบออก token ครั้งเดียว แล้วแปลงเป็นลิงก์คำเชิญให้คัดลอกทันที (ลิงก์ใช้ได้ครั้งเดียว หมดอายุใน 48 ชั่วโมง และ ดูซ้ำไม่ได้อีก)
  3. พนักงานเปิดลิงก์เพื่อยืนยันตัว (สถานะเปลี่ยนจาก invited → pending) แอดมินกดอนุมัติให้เป็น active จากตารางนี้ — จะเพิกถอน (revoked) หรือคืนสิทธิ์ภายหลังก็ได้
  4. เข้าแท็บกลุ่มลูกค้า เลือกชุดเงื่อนไขสำเร็จรูปที่ตรงกับโหมดโปรแกรม (มี 9 ชุด เช่น "ใกล้ครบบัตร", "มีแต้มพอแลกแต่ยังไม่แลก", "สมาชิกระดับสูง") ระบบจะพาไปหน้าสร้างกลุ่มเป้าหมายที่เมนู Segment audience > Multi-source filter พร้อมเงื่อนไขตั้งต้นและชื่อกลุ่มให้ปรับต่อได้

4. ค้นหาลูกค้าและดูประวัติ​

  1. เข้าแท็บลูกค้า ค้นด้วยเบอร์โทร ชื่อ หรืออีเมล — ถ้าเจอผลลัพธ์เดียวระบบเปิดหน้ารายละเอียดให้เลย ดูยอดสะสม ชั้นสมาชิก จำนวนครั้งที่มา ยอดใช้จ่ายรวม และประวัติธุรกรรม (เป็นหน้าอ่านอย่างเดียว ไม่มี endpoint ปรับแต้มด้วยมือใน CMS เลย)
  2. เข้าแท็บประวัติเพื่อดูรายการล่าสุดของทั้งโปรแกรม (หน้าเว็บขอมา 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 parameter preset ที่ /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 — ไม่พบแถว = ปิด และตอบ 403 App "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 แต่ PlatformOnly fail 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.ts
  • src/app/apps/loyalty/segments/ — page.tsx, lib/presets.ts (9 ชุดสำเร็จรูป และการประกอบ query preset)
  • 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_app
  • internal/modules/apps/controller.go — PUT /apps/:appId กับ modulegate.PlatformOnly
  • internal/modules/modulegate/modulegate.go — PlatformOnly ที่ fail closed
  • internal/modules/loyalty/controller.go — 22 เส้นทาง และการต่อ AppEnabledGuard เข้าทุกเส้นทาง
  • internal/modules/loyalty/service_program.go — IsStructural, การออกเวอร์ชันใหม่, copyChildren, การบังคับค่าตามโหมด, SetStatus
  • internal/modules/loyalty/service_catalog.go — รางวัล, สาขา, พนักงาน/คำเชิญ (InviteTTL, randomToken), แต้มต้อนรับ, ชั้นสมาชิก, RecalculateTiers, บันทึกกิจกรรม, การค้นหาลูกค้า
  • internal/modules/loyalty/scope.go — การบีบ scope สองชั้นของทุก query ในโมดูล

ดูรายละเอียดเชิงเทคนิคเพิ่มเติมที่ แอปสะสมแต้ม (Loyalty)