Skip to main content

บัตรสะสมแต้ม - รับแสตมป์/แต้มจาก QR

ภาพรวม

หัวใจของการทำธุรกรรมหน้าร้าน ลูกค้าสแกน QR ที่พนักงานสร้างขึ้น (อายุ 60 วินาที) แล้วส่งโค้ดนั้นมาที่ endpoint นี้ ระบบจะแลกโค้ดเป็นแสตมป์หรือแต้ม เดินบัตรทีละใบ ปิดบัตรที่เต็ม ออกรางวัล เพิ่ม welcome bonus และเลื่อนชั้น tier ให้ทันทีที่เคาน์เตอร์

endpoint นี้มี rate limit ต่ำที่สุดในกลุ่ม (10/60s) เพราะเป็นพื้นผิวที่ถูกโจมตีได้มากที่สุด

Business Flow

POST /api/loyalty/:hash/earn (rate limit 10/60s) — body {token}

ทั้งหมดรันภายใน transaction เดียว และลำดับของขั้นตอนมีความหมาย

  1. โปรแกรมไม่ live ตอบ 400 LOYALTY_PROGRAM_INACTIVE ส่วน token ที่ว่างตอบ 400 LOYALTY_TOKEN_INVALID
  2. EnsureAccount เพราะต้องมี account ก่อนจึงจะ claim token ได้
  3. ClaimToken ทำงานเป็น UPDATE แบบมีเงื่อนไข การแข่งกันของหลาย request จึงถูกตัดสินที่จุดเขียน ไม่ใช่ที่จุดอ่าน หาก claim ไม่สำเร็จ ระบบจะแยกแยะกรณี "ไม่เคยมี token นี้" ออกจาก "ใช้แล้วหรือ หมดอายุ" ด้วย TokenExists โดยถ้ามีอยู่จริงตอบ 409 LOYALTY_TOKEN_CONSUMED ถ้าไม่เคยมีเลยตอบ 400 LOYALTY_TOKEN_INVALID (กรณีหลังไม่มีประโยชน์ที่จะอธิบายให้ละเอียดกว่านี้)
  4. ตรวจ cooldown หลังจาก claim โดยเจตนา หากตรวจก่อน token ที่ถูกปฏิเสธเพราะติด cooldown จะยัง มีชีวิตอยู่และคนถัดไปสแกนจอเดียวกันได้ ซึ่งคือพฤติกรรมการแชร์ QR ที่ระบบพยายามป้องกันอยู่พอดี หากติด cooldown จะตอบ 409 LOYALTY_COOLDOWN
  5. applyEarn ทำเลขคณิตของบัตรและเขียน ledger
  6. applyJoinBonus มอบ welcome bonus
  7. maybePromote เลื่อนชั้น โดยความล้มเหลวไม่ถือเป็น fatal

applyEarn — ทำไมต้องเดินบัตรทีละใบ

  1. หาบัตรที่เปิดอยู่ แล้วคำนวณวันหมดอายุจาก expiry_mode และ expiry_months โดยอ้างอิงการ earn ครั้งแรกของบัตรใบนั้น
  2. เขียนแถว ledger ก่อนแตะบัตร เพราะ idempotency key (ซึ่งก็คือ token) จะสะดุดก่อนที่การ mutate ใดๆ จะเกิดขึ้น token ที่ถูกส่งซ้ำจึงไม่เปลี่ยนแปลงอะไรเลย แทนที่จะอัปเดตบัตรไปครึ่งทางแล้วล้ม
  3. โหมดที่ size เป็น 0 หรือน้อยกว่า (point mode ที่ไม่มีกริด) จะบวก units แล้วจบ
  4. โหมดแสตมป์จะ วนเติมทีละใบและ persist ทุกใบที่เต็ม
    • เวอร์ชันก่อนหน้าคำนวณตำแหน่งสุดท้ายทีเดียวแล้วเขียนเฉพาะบัตรใบสุดท้าย ทำให้บัตรที่เต็มระหว่างทาง หายไปเงียบๆ ลูกค้าที่สแกนครั้งแรกได้ 7 แสตมป์บนบัตรขนาด 5 ช่องจะได้บัตร #1 ที่มีเศษเหลือเพียง ใบเดียว ไม่มีบัตรที่ complete ในประวัติ และรางวัลก็ชี้ไปยังสิ่งที่ไม่มีอยู่
    • ถ้ายังไม่มีบัตรเลย ระบบจะ insert บัตร sequence_no = 1 แล้วเรียก AttachEarnToCard เพื่อผูกแถว ledger ที่เขียนไว้ก่อนหน้าเข้ากับบัตร มิฉะนั้น log จะมีแสตมป์ที่ไม่สังกัดบัตรใด
    • เมื่อเติมจนเต็ม ระบบเรียก UpdateCardProgress(status=complete) แล้วออกรางวัลสำหรับทุก milestone ที่ required_units ไม่เกินขนาดบัตร จากนั้นเปิดบัตรใบใหม่ที่ sequence_no + 1
    • อายุของรางวัลใช้ reward_expiry_days ของ milestone ถ้ามีกำหนดไว้ มิฉะนั้นใช้วันหมดอายุของบัตร เพราะรางวัลที่อยู่นานกว่าบัตรจะกลายเป็นรางวัลที่ต้องแลกกับบัตรที่ลูกค้ามองไม่เห็นแล้ว

applyJoinBonus

โบนัสถูกมอบตอน earn ครั้งแรก ไม่ใช่ตอนสร้าง account เพราะแถว account ถูกสร้างทันทีที่ใครก็ตาม เปิดดูหน้าบัตร การให้โบนัสตอนนั้นเท่ากับแจกแสตมป์ให้คนที่แค่เปิดดูเล่นๆ หลักประกันที่แท้จริงคือ unique index บนตาราง bonus_grant โดย GrantBonus ที่คืนค่า false หมายถึงมี request อื่นชนะ race ไปแล้ว

ยอดโบนัสถูกแยกออกจาก awarded ไปเป็น bonusAwarded เพราะลูกค้าที่พนักงานตอกให้ 1 แสตมป์แล้วเห็น ตัวเลข "2" จะเข้าใจว่าระบบทำงานผิด

maybePromote — เลื่อนขึ้นเท่านั้น

การลดชั้นเป็นงานของ job รายคืน ไม่มีใครควรตกชั้นกลางธุรกรรมต่อหน้าพนักงานที่มีคิวลูกค้าต่อแถวอยู่ ในทางกลับกัน การรอถึงวันรุ่งขึ้นเพื่อบอกว่าเพิ่งได้ Gold ก็คือการทิ้งช่วงเวลาเดียวที่ tier มีความหมาย

ความล้มเหลวของขั้นตอนนี้ถูกกลืน (log ระดับ warn) เพราะ units เข้าบัญชีไปแล้ว การล้มทั้ง earn เพียง เพราะ lookup tier พังจะเปลี่ยนปัญหาด้านความสวยงามให้กลายเป็นธุรกรรมที่สูญหาย และ job รายคืนก็ ประเมินชั้นใหม่อยู่แล้ว

Response

{awarded, bonusAwarded, cardCompleted, newRewards, card}

ไฟล์และฟังก์ชันหลัก

รายการค่า
RoutePOST /api/loyalty/:hash/earn (rate limit 10/60s)
Handlerinternal/loyalty/handler.go(*Handler).Earn
Serviceinternal/loyalty/service.go(*Service).Earn, applyEarn, applyJoinBonus, maybePromote, rewardExpiry และ type EarnResult
Repositoryinternal/loyalty/repository.goWithTx, EnsureAccount, ClaimToken, TokenExists, LastEarnAt, FindOpenCard, InsertEarn, AttachEarnToCard, InsertCard, UpdateCardProgress, ListMilestones, InsertReward, FindJoinBonus, GrantBonus, ListTiers, StatsInWindow, SetTier
EntityCardExpiry, CooldownBlocks, Program.Size(), Program.Live(), SourceStaffQR, SourceBonus

จุดเชื่อมต่อกับ Service อื่น

  • ตารางฐานข้อมูล loyalty.earn_token, loyalty.account, loyalty.card_instance, loyalty.transaction, loyalty.milestone, loyalty.reward, loyalty.bonus_rule, loyalty.bonus_grant, loyalty.tier, loyalty.tier_history, loyalty.program
  • token ที่ใช้ที่นี่ถูกออกโดย loyalty-staff ผ่าน POST /staff/tokens
  • กฎการจัดชั้นอยู่ที่ loyalty-tier ส่วนรางวัลที่ออกมาถูกนำไปใช้ที่ loyalty-reward-redeem
  • job รายคืนสำหรับประเมิน tier ใหม่และลดชั้นอยู่ฝั่ง worker/CMS ไม่ใช่ service นี้
  • ตรงกับ client-web feature: loyalty-card (ส่วนสแกน QR รับแต้ม)