บัตรสะสมแต้ม - รับแสตมป์/แต้มจาก 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 เดียว และลำดับของขั้นตอนมีความหมาย
- โปรแกรมไม่ live ตอบ 400
LOYALTY_PROGRAM_INACTIVEส่วน token ที่ว่างตอบ 400LOYALTY_TOKEN_INVALID EnsureAccountเพราะต้องมี account ก่อนจึงจะ claim token ได้ClaimTokenทำงานเป็น UPDATE แบบมีเงื่อนไข การแข่งกันของหลาย request จึงถูกตัดสินที่จุดเขียน ไม่ใช่ที่จุดอ่าน หาก claim ไม่สำเร็จ ระบบจะแยกแยะกรณี "ไม่เคยมี token นี้" ออกจาก "ใช้แล้วหรือ หมดอายุ" ด้วยTokenExistsโดยถ้ามีอยู่จริงตอบ 409LOYALTY_TOKEN_CONSUMEDถ้าไม่เคยมีเลยตอบ 400LOYALTY_TOKEN_INVALID(กรณีหลังไม่มีประโยชน์ที่จะอธิบายให้ละเอียดกว่านี้)- ตรวจ cooldown หลังจาก claim โดยเจตนา หากตรวจก่อน token ที่ถูกปฏิเสธเพราะติด cooldown จะยัง
มีชีวิตอยู่และคนถัดไปสแกนจอเดียวกันได้ ซึ่งคือพฤติกรรมการแชร์ QR ที่ระบบพยายามป้องกันอยู่พอดี
หากติด cooldown จะตอบ 409
LOYALTY_COOLDOWN applyEarnทำเลขคณิตของบัตรและเขียน ledgerapplyJoinBonusมอบ welcome bonusmaybePromoteเลื่อนชั้น โดยความล้มเหลวไม่ถือเป็น fatal
applyEarn — ทำไมต้องเดินบัตรทีละใบ
- หาบัตรที่เปิดอยู่ แล้วคำนวณวันหมดอายุจาก
expiry_modeและexpiry_monthsโดยอ้างอิงการ earn ครั้งแรกของบัตรใบนั้น - เขียนแถว ledger ก่อนแตะบัตร เพราะ idempotency key (ซึ่งก็คือ token) จะสะดุดก่อนที่การ mutate ใดๆ จะเกิดขึ้น token ที่ถูกส่งซ้ำจึงไม่เปลี่ยนแปลงอะไรเลย แทนที่จะอัปเดตบัตรไปครึ่งทางแล้วล้ม
- โหมดที่
sizeเป็น 0 หรือน้อยกว่า (point mode ที่ไม่มีกริด) จะบวก units แล้วจบ - โหมดแสตมป์จะ วนเติมทีละใบและ 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}
ไฟล์และฟังก์ชันหลัก
| รายการ | ค่า |
|---|---|
| Route | POST /api/loyalty/:hash/earn (rate limit 10/60s) |
| Handler | internal/loyalty/handler.go → (*Handler).Earn |
| Service | internal/loyalty/service.go → (*Service).Earn, applyEarn, applyJoinBonus, maybePromote, rewardExpiry และ type EarnResult |
| Repository | internal/loyalty/repository.go → WithTx, EnsureAccount, ClaimToken, TokenExists, LastEarnAt, FindOpenCard, InsertEarn, AttachEarnToCard, InsertCard, UpdateCardProgress, ListMilestones, InsertReward, FindJoinBonus, GrantBonus, ListTiers, StatsInWindow, SetTier |
| Entity | CardExpiry, 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 รับแต้ม)