Skip to main content

บัตรสะสมแต้มและระบบสมาชิก

รายละเอียดคอลัมน์ของ schema loyalty ทั้งหมด (program/branch/staff/account/card_instance/milestone/transaction/reward/earn_token/bonus)

ภาพรวม

Schema loyalty (ไม่รวมส่วน tier ซึ่งอยู่ในหน้า ระดับสมาชิก Loyalty) มี 12 ตาราง จาก migration 001-005 — ฝั่งการตั้งค่ามี program (บัตร), branch (สาขา), staff (พนักงาน), milestone (ของรางวัลตามระดับ), bonus_rule; ฝั่งข้อมูลลูกค้ามี account, card_instance, transaction (บัญชีแยกประเภท), reward, earn_token (QR หมุนเวียน), bonus_grant และ audit_log schema นี้ apply แยกด้วย psql ไม่ผ่าน Prisma

หลักการสำคัญสองข้อที่กำหนดรูปร่าง schema นี้: บัญชีแยกประเภทคือความจริง — ยอดคงเหลือไม่ถูกเก็บ เป็นคอลัมน์ แต่คำนวณจากผลรวมของ lot ใน transaction ที่ยังไม่ถูกใช้และยังไม่หมดอายุ ณ เวลาที่อ่าน และ บัตรที่อยู่ในมือลูกค้ารักษาเงื่อนไขเดิมcard_instance snapshot เวอร์ชันโปรแกรมและ ขนาดบัตรที่ออกให้ ร้านค้าจึงแก้บัตรได้โดยไม่กระทบบัตรที่ลูกค้าสะสมอยู่

ตาราง loyalty.program

ColumnTypeNullableDefaultคำอธิบาย
idSERIALNOauto incrementPrimary key
line_oa_idINTEGERNO-LINE OA ที่โปรแกรมนี้สังกัด
organization_idINTEGERNO-องค์กรเจ้าของข้อมูล
versionINTEGERNO1เวอร์ชันของโปรแกรม เพิ่มขึ้นเมื่อมีการแก้เชิงโครงสร้าง
modeVARCHAR(10)NO'stamp'รูปแบบการสะสม: stamp หรือ point
nameVARCHAR(120)NO-ชื่อโปรแกรม/บัตร
unit_labelVARCHAR(30)NO'ดวง'คำเรียกหน่วยสะสมที่แสดงบนบัตร
card_sizeINTEGERYES-จำนวนช่องต่อบัตร (โหมด stamp) จำกัดไม่เกิน 20
baht_per_pointNUMERIC(12,2)YES-ยอดใช้จ่ายต่อ 1 แต้ม (โหมด point)
cover_pathTEXTYES-path ของรูปหน้าปกบัตรใน object storage (เก็บ path ไม่ใช่ URL)
logo_pathTEXTYES-path ของโลโก้ร้าน
themeJSONBNO'{}'ธีม/สีของบัตร
termsTEXTYES-เงื่อนไขการใช้บัตร
expiry_modeVARCHAR(20)NO'from_first'วิธีคิดวันหมดอายุ: from_first, from_last, none
expiry_monthsINTEGERYES-อายุบัตร/แต้ม (เดือน) ใช้คู่กับ expiry_mode
cooldown_modeVARCHAR(20)NO'unlimited'ข้อจำกัดความถี่การรับแต้ม: unlimited, per_hours, per_day
cooldown_hoursINTEGERYES-ระยะเวลาเว้นช่วง (ชั่วโมง) เมื่อ cooldown_mode = 'per_hours'
statusVARCHAR(20)NO'draft'สถานะ: draft, active, paused, superseded
published_dateTIMESTAMPTZ(3)YES-วันเวลาที่เผยแพร่โปรแกรม
created_byINTEGERYES-ผู้สร้างเรกคอร์ด
updated_byINTEGERYES-ผู้แก้ไขล่าสุด
created_dateTIMESTAMPTZ(3)NOCURRENT_TIMESTAMPวันเวลาที่สร้าง
updated_dateTIMESTAMPTZ(3)YES-วันเวลาที่แก้ไขล่าสุด
stamp_icon_pathTEXTYES-path ของไอคอนแสตมป์ที่ร้านอัปโหลด ถ้าไม่มีจะแสดง unit_label แทน (migration 004)

ตาราง loyalty.branch

ColumnTypeNullableDefaultคำอธิบาย
idSERIALNOauto incrementPrimary key
line_oa_idINTEGERNO-LINE OA ที่สาขานี้สังกัด
organization_idINTEGERNO-องค์กรเจ้าของข้อมูล
nameVARCHAR(120)NO-ชื่อสาขา
codeVARCHAR(40)YES-รหัสสาขาที่ร้านใช้เอง
statusVARCHAR(20)NO'active'สถานะการใช้งาน
created_dateTIMESTAMPTZ(3)NOCURRENT_TIMESTAMPวันเวลาที่สร้าง
updated_dateTIMESTAMPTZ(3)YES-วันเวลาที่แก้ไขล่าสุด
deleted_dateTIMESTAMPTZ(3)YES-วันเวลาที่ soft delete

ตาราง loyalty.staff

ColumnTypeNullableDefaultคำอธิบาย
idSERIALNOauto incrementPrimary key
line_oa_idINTEGERNO-LINE OA ที่พนักงานคนนี้สังกัด
organization_idINTEGERNO-องค์กรเจ้าของข้อมูล
line_user_idVARCHAR(255)YES-LINE user id ของพนักงาน — NULL ได้ตั้งแต่ migration 003 เพราะแถวสถานะ invited ยังไม่มีตัวตน LINE
display_nameVARCHAR(255)YES-ชื่อที่เจ้าของร้านตั้งให้ และไม่ถูกเขียนทับตอนรับคำเชิญ
picture_urlTEXTYES-URL รูปประจำตัว
branch_idINTEGERYES-FK ไปยัง loyalty.branch(id) — NULL = ประจำได้ทุกสาขา
statusVARCHAR(20)NO'pending'สถานะ: invited, pending, active, revoked
approved_byINTEGERYES-ผู้อนุมัติ
approved_dateTIMESTAMPTZ(3)YES-วันเวลาที่อนุมัติ
created_dateTIMESTAMPTZ(3)NOCURRENT_TIMESTAMPวันเวลาที่สร้าง
updated_dateTIMESTAMPTZ(3)YES-วันเวลาที่แก้ไขล่าสุด
line_display_nameVARCHAR(255)YES-ชื่อจากโปรไฟล์ LINE ของผู้ที่กดรับคำเชิญ (migration 003)
line_picture_urlTEXTYES-รูปจากโปรไฟล์ LINE ของผู้ที่กดรับคำเชิญ (migration 003)
invite_tokenVARCHAR(64)YES-โทเคนคำเชิญแบบใช้ครั้งเดียว ถูกล้างเมื่อมีผู้รับคำเชิญ (migration 003)
invite_expires_atTIMESTAMPTZ(3)YES-วันเวลาที่คำเชิญหมดอายุ (migration 003)
invited_byINTEGERYES-ผู้ส่งคำเชิญ (migration 003)

ตาราง loyalty.account

ColumnTypeNullableDefaultคำอธิบาย
idSERIALNOauto incrementPrimary key
line_oa_idINTEGERNO-LINE OA ที่บัญชีนี้สังกัด
organization_idINTEGERNO-องค์กรเจ้าของข้อมูล
line_user_idVARCHAR(255)YES-LINE user id ของลูกค้า — NULL ได้ เพราะบัญชีที่สร้างจากเบอร์โทรยังไม่มีตัวตน LINE
mobile_noVARCHAR(32)YES-เบอร์โทรลูกค้า ใช้กับบัญชีที่สร้างก่อนลูกค้าลงทะเบียน
statusVARCHAR(20)NO'active'สถานะบัญชี
created_dateTIMESTAMPTZ(3)NOCURRENT_TIMESTAMPวันเวลาที่สร้าง
updated_dateTIMESTAMPTZ(3)YES-วันเวลาที่แก้ไขล่าสุด

คอลัมน์ tier_id, tier_since, tier_evaluated_date ที่เพิ่มใน migration 006 อธิบายไว้ในหน้า ระดับสมาชิก Loyalty

ตาราง loyalty.card_instance

ColumnTypeNullableDefaultคำอธิบาย
idSERIALNOauto incrementPrimary key
account_idINTEGERNO-FK ไปยัง loyalty.account(id) ลบแบบ CASCADE
program_idINTEGERNO-FK ไปยัง loyalty.program(id)
line_oa_idINTEGERNO-LINE OA ที่บัตรใบนี้สังกัด
organization_idINTEGERNO-องค์กรเจ้าของข้อมูล
program_versionINTEGERNO-snapshot เวอร์ชันโปรแกรมที่ออกบัตรใบนี้
card_sizeINTEGERNO-snapshot จำนวนช่องของบัตรใบนี้ ณ ตอนออกบัตร
sequence_noINTEGERNO-ลำดับที่ของบัตรในบัญชีลูกค้า (ใบที่ 1, 2, 3 ...)
slots_filledINTEGERNO0จำนวนช่องที่ประทับแล้ว
statusVARCHAR(20)NO'active'สถานะบัตร: active, complete, expired
expires_dateTIMESTAMPTZ(3)YES-วันเวลาที่บัตรหมดอายุ
completed_dateTIMESTAMPTZ(3)YES-วันเวลาที่บัตรเต็ม
created_dateTIMESTAMPTZ(3)NOCURRENT_TIMESTAMPวันเวลาที่ออกบัตร

ตาราง loyalty.milestone

ColumnTypeNullableDefaultคำอธิบาย
idSERIALNOauto incrementPrimary key
program_idINTEGERNO-FK ไปยัง loyalty.program(id) ลบแบบ CASCADE
program_versionINTEGERNO-เวอร์ชันโปรแกรมที่ของรางวัลนี้ผูกอยู่
line_oa_idINTEGERNO-LINE OA ที่ของรางวัลนี้สังกัด
organization_idINTEGERNO-องค์กรเจ้าของข้อมูล
required_unitsINTEGERNO-จำนวนหน่วยสะสมที่ต้องมีเพื่อได้รางวัลนี้ (ต้องมากกว่า 0)
titleVARCHAR(150)NO-ชื่อของรางวัล
descriptionTEXTYES-คำอธิบายของรางวัล
image_pathTEXTYES-path ของรูปของรางวัลใน object storage
reward_expiry_daysINTEGERYES-อายุของรางวัลหลังได้รับ (วัน) — NULL = ใช้วันหมดอายุตามบัตร
sort_orderINTEGERNO0ลำดับการแสดงผล
statusVARCHAR(20)NO'active'สถานะการใช้งาน
created_dateTIMESTAMPTZ(3)NOCURRENT_TIMESTAMPวันเวลาที่สร้าง
updated_dateTIMESTAMPTZ(3)YES-วันเวลาที่แก้ไขล่าสุด

คอลัมน์ min_tier_rank ที่เพิ่มใน migration 006 อธิบายไว้ในหน้า ระดับสมาชิก Loyalty

ตาราง loyalty.transaction

ColumnTypeNullableDefaultคำอธิบาย
idBIGSERIALNOauto incrementPrimary key
account_idINTEGERNO-FK ไปยัง loyalty.account(id) ลบแบบ CASCADE
line_oa_idINTEGERNO-LINE OA ที่รายการนี้สังกัด
organization_idINTEGERNO-องค์กรเจ้าของข้อมูล
program_idINTEGERYES-โปรแกรมที่รายการนี้เกิดขึ้นภายใต้
program_versionINTEGERYES-เวอร์ชันโปรแกรม ณ เวลาที่บันทึกรายการ
kindVARCHAR(20)NO-ชนิดรายการ: earn, burn, expire, adjust
unitsINTEGERNO-จำนวนหน่วยของรายการนี้
amount_spentNUMERIC(12,2)YES-ยอดใช้จ่าย (โหมด point) — NULL สำหรับแสตมป์
branch_idINTEGERYES-FK ไปยัง loyalty.branch(id) — สาขาที่เกิดรายการ
staff_idINTEGERYES-FK ไปยัง loyalty.staff(id) — พนักงานที่ทำรายการ
sourceVARCHAR(30)NO-ที่มาของรายการ เช่น การสแกน QR, การปรับด้วยมือ, โบนัส
source_refINTEGERYES-id อ้างอิงไปยังต้นทางตาม source
card_instance_idINTEGERYES-FK ไปยัง loyalty.card_instance(id) — บัตรที่รายการนี้ลง
expires_dateTIMESTAMPTZ(3)YES-วันหมดอายุของ lot (ใช้เฉพาะรายการ earn)
consumed_unitsINTEGERNO0จำนวนหน่วยของ lot นี้ที่ถูกใช้ไปแล้ว — คอลัมน์เดียวในตารางที่ถูก UPDATE
idempotency_keyVARCHAR(80)YES-คีย์กันบันทึกซ้ำ ปกติคือ id ของ earn token
noteTEXTYES-หมายเหตุ
created_byINTEGERYES-ผู้บันทึกรายการ
created_dateTIMESTAMPTZ(3)NOCURRENT_TIMESTAMPวันเวลาที่บันทึก
modeVARCHAR(10)NO'stamp'โหมดของรายการ: stamp หรือ point — กันไม่ให้แสตมป์กับแต้มปนกัน (migration 005)

ตาราง loyalty.reward

ColumnTypeNullableDefaultคำอธิบาย
idSERIALNOauto incrementPrimary key
account_idINTEGERNO-FK ไปยัง loyalty.account(id) ลบแบบ CASCADE
milestone_idINTEGERNO-FK ไปยัง loyalty.milestone(id) — ของรางวัลที่ได้
card_instance_idINTEGERYES-FK ไปยัง loyalty.card_instance(id) — บัตรที่ทำให้ได้รางวัลนี้
line_oa_idINTEGERNO-LINE OA ที่รางวัลนี้สังกัด
organization_idINTEGERNO-องค์กรเจ้าของข้อมูล
codeVARCHAR(16)NO-รหัสรับรางวัล ตัดตัวอักษร I, L, O, U และเลข 0/1 ออกเพื่อกันอ่านผิด
statusVARCHAR(20)NO'unclaimed'สถานะ: unclaimed, claimed, expired
expires_dateTIMESTAMPTZ(3)YES-วันหมดอายุของรางวัล
claimed_dateTIMESTAMPTZ(3)YES-วันเวลาที่รับรางวัล
claimed_by_staff_idINTEGERYES-FK ไปยัง loyalty.staff(id) — พนักงานที่จ่ายรางวัล
claimed_branch_idINTEGERYES-FK ไปยัง loyalty.branch(id) — สาขาที่รับรางวัล
claimed_transaction_idBIGINTYES-id ของรายการ burn ที่คู่กับการรับรางวัลนี้
created_dateTIMESTAMPTZ(3)NOCURRENT_TIMESTAMPวันเวลาที่ได้รับรางวัล

ตาราง loyalty.earn_token

ColumnTypeNullableDefaultคำอธิบาย
idSERIALNOauto incrementPrimary key
line_oa_idINTEGERNO-LINE OA ที่โทเคนนี้สังกัด
organization_idINTEGERNO-องค์กรเจ้าของข้อมูล
program_idINTEGERNO-FK ไปยัง loyalty.program(id)
tokenVARCHAR(64)NO-ค่าโทเคนใน QR (unique) ใช้ได้ครั้งเดียวและอายุสั้น
staff_idINTEGERNO-FK ไปยัง loyalty.staff(id) — พนักงานที่ออกโทเคน (บังคับ)
branch_idINTEGERNO-FK ไปยัง loyalty.branch(id) — สาขาที่ออกโทเคน (บังคับ)
unitsINTEGERNO-จำนวนหน่วยที่จะได้เมื่อสแกน (ต้องมากกว่า 0)
amount_spentNUMERIC(12,2)YES-ยอดใช้จ่ายที่ผูกกับโทเคน (โหมด point)
expires_atTIMESTAMPTZ(3)NO-วันเวลาที่โทเคนหมดอายุ
consumed_by_account_idINTEGERYES-FK ไปยัง loyalty.account(id) — บัญชีที่สแกนโทเคนนี้
consumed_dateTIMESTAMPTZ(3)YES-วันเวลาที่โทเคนถูกใช้
created_dateTIMESTAMPTZ(3)NOCURRENT_TIMESTAMPวันเวลาที่ออกโทเคน

ตาราง loyalty.bonus_rule

ColumnTypeNullableDefaultคำอธิบาย
idSERIALNOauto incrementPrimary key
program_idINTEGERNO-FK ไปยัง loyalty.program(id) ลบแบบ CASCADE
line_oa_idINTEGERNO-LINE OA ที่กติกานี้สังกัด
organization_idINTEGERNO-องค์กรเจ้าของข้อมูล
trigger_typeVARCHAR(30)NO-เงื่อนไขกระตุ้น: join, birthday, first_earn, time_window, spend_threshold, audience
effectVARCHAR(20)NO-ผลลัพธ์: grant_units (แจกหน่วย) หรือ multiply (คูณ)
unitsINTEGERYES-จำนวนหน่วยที่แจก เมื่อ effect = 'grant_units'
multiplierNUMERIC(4,2)YES-ตัวคูณ เมื่อ effect = 'multiply'
conditionsJSONBNO'{}'เงื่อนไขเพิ่มเติมของกติกา
cadenceVARCHAR(20)NO'once'ความถี่ที่ให้ได้: once, yearly, daily
valid_fromTIMESTAMPTZ(3)YES-วันเวลาที่กติกาเริ่มมีผล
valid_toTIMESTAMPTZ(3)YES-วันเวลาที่กติกาสิ้นผล
statusVARCHAR(20)NO'active'สถานะการใช้งาน
priorityINTEGERNO0ลำดับความสำคัญเมื่อมีหลายกติกาเข้าเงื่อนไขพร้อมกัน
created_dateTIMESTAMPTZ(3)NOCURRENT_TIMESTAMPวันเวลาที่สร้าง
updated_dateTIMESTAMPTZ(3)YES-วันเวลาที่แก้ไขล่าสุด

ตาราง loyalty.bonus_grant

ColumnTypeNullableDefaultคำอธิบาย
idSERIALNOauto incrementPrimary key
rule_idINTEGERNO-FK ไปยัง loyalty.bonus_rule(id) ลบแบบ CASCADE
account_idINTEGERNO-FK ไปยัง loyalty.account(id) ลบแบบ CASCADE
transaction_idBIGINTYES-id ของรายการใน ledger ที่เกิดจากโบนัสนี้
line_oa_idINTEGERNO-LINE OA ที่การแจกโบนัสนี้สังกัด
period_keyVARCHAR(20)NO-คีย์รอบการแจก: '' = ครั้งเดียวตลอดกาล, '2026' = รายปี, '2026-07-27' = รายวัน
created_dateTIMESTAMPTZ(3)NOCURRENT_TIMESTAMPวันเวลาที่แจกโบนัส

ตาราง loyalty.audit_log

ColumnTypeNullableDefaultคำอธิบาย
idBIGSERIALNOauto incrementPrimary key
line_oa_idINTEGERNO-LINE OA ที่เหตุการณ์นี้สังกัด
organization_idINTEGERNO-องค์กรเจ้าของข้อมูล
actor_typeVARCHAR(20)NO-ชนิดผู้กระทำ เช่น admin / staff / system
actor_idVARCHAR(255)YES-รหัสผู้กระทำ
actor_nameVARCHAR(255)YES-ชื่อผู้กระทำ ณ เวลาที่บันทึก
actionVARCHAR(50)NO-การกระทำ เช่น แก้โปรแกรม อนุมัติพนักงาน ปรับยอดด้วยมือ
target_typeVARCHAR(20)NO-ชนิดของสิ่งที่ถูกกระทำ
target_idINTEGERYES-id ของสิ่งที่ถูกกระทำ
beforeJSONBYES-ข้อมูลก่อนเปลี่ยนแปลง
afterJSONBYES-ข้อมูลหลังเปลี่ยนแปลง
ipVARCHAR(45)YES-IP address ของผู้กระทำ
user_agentTEXTYES-User agent ของผู้กระทำ
created_dateTIMESTAMPTZ(3)NOCURRENT_TIMESTAMPวันเวลาที่บันทึก

หมายเหตุ

  • ไม่มีคอลัมน์ยอดคงเหลือ — ยอดคำนวณจากผลรวมของ lot ใน transaction ที่ kind = 'earn' ซึ่งยังไม่ถูกใช้และยังไม่หมดอายุ ณ เวลาที่อ่าน ถ้าเก็บเป็นคอลัมน์จะผิดทันทีที่ lot หมดอายุ เพราะการหมดอายุเป็นฟังก์ชันของเวลา ไม่ใช่ของการเขียนข้อมูล
  • Check constraint ของ program: chk_loy_program_mode (mode IN ('stamp','point')), chk_loy_program_expiry, chk_loy_program_cooldown, chk_loy_program_status, chk_loy_program_size (โหมด stamp ต้องมี card_size ระหว่าง 1-20 มิฉะนั้นจะหารด้วยศูนย์), chk_loy_program_cdhours (cooldown_mode = 'per_hours' ต้องมี cooldown_hours > 0)
  • Unique ของ program: idx_loy_program_active บน line_oa_id WHERE status IN ('draft','active','paused') — หนึ่ง OA มีโปรแกรมที่ยังมีชีวิตได้เพียงตัวเดียว ส่วนแถว superseded อยู่ร่วมกันได้เพื่อให้อ่านบัตรเก่าได้
  • Check constraint ของ staff: chk_loy_staff_status (status IN ('invited','pending','active','revoked')) และ chk_loy_staff_invite ที่บังคับว่า แถว invited ต้องไม่มี line_user_id แต่ต้องมี invite_token ส่วนแถวสถานะอื่นต้องมี line_user_id และต้องไม่มี invite_token ค้าง
  • เส้นทางสถานะพนักงาน: invited → (รับคำเชิญ) → pending → (อนุมัติ) → active → (เพิกถอน) → revoked ขั้น pending มีไว้โดยตั้งใจ เพราะลิงก์คำเชิญถูกส่งต่อได้ การค้างที่ pending ทำให้เจ้าของร้านเห็นว่าบัญชี LINE ใดเป็นผู้รับคำเชิญจริง เทียบกับชื่อที่ตนพิมพ์ไว้ และปฏิเสธได้ถ้าไม่ตรง
  • Deny by default: เฉพาะ status = 'active' เท่านั้นที่ออกแสตมป์ได้ สถานะอื่นทั้งหมด รวมถึงค่าที่ไม่รู้จักหรือว่างเปล่าถือว่าไม่ผ่าน
  • Unique ของ staff: idx_loy_staff_user บน (line_oa_id, line_user_id) WHERE line_user_id IS NOT NULL (partial เพื่อให้คำเชิญที่ยังไม่ถูกรับหลายใบอยู่ร่วมกันได้) และ idx_loy_staff_invite บน invite_token WHERE invite_token IS NOT NULL ซึ่งทำหน้าที่ บังคับ single-use ไปในตัว เพราะการรับคำเชิญจะล้าง invite_token ทิ้ง
  • Unique ของ account: idx_loy_account_line บน (line_oa_id, line_user_id) WHERE line_user_id IS NOT NULL และ idx_loy_account_phone บน (line_oa_id, mobile_no) WHERE mobile_no IS NOT NULL AND line_user_id IS NULL — เมื่อบัญชีเบอร์โทรรวมเข้ากับบัญชี LINE index ตัวหลังจะเลิกมีผล ซึ่งเป็นสิ่งที่ทำให้การรวมบัญชีเกิดขึ้นได้
  • Constraint ของ card_instance: chk_loy_card_status, chk_loy_card_filled (slots_filled ต้องอยู่ระหว่าง 0 ถึง card_size) และ unique idx_loy_card_seq บน (account_id, sequence_no)
  • Unique ของ milestone: idx_loy_milestone_at บน (program_id, program_version, required_units) — ห้ามมีสองรางวัลที่เกณฑ์เดียวกัน เพราะจะตอบไม่ได้ว่าครบ 10 ดวงแล้วได้อะไร
  • Constraint ของ transaction: chk_loy_txn_kind, chk_loy_txn_consumed (consumed_units ต้องอยู่ระหว่าง 0 ถึง units), chk_loy_txn_mode (migration 005) และ unique idx_loy_txn_idem บน (line_oa_id, idempotency_key) WHERE idempotency_key IS NOT NULL — ลูกค้าที่กดซ้ำหรือเปิด LIFF ใหม่จึงไม่ถูกประทับสองครั้ง โดยบังคับที่ระดับฐานข้อมูล ไม่ใช่ที่ตรรกะแอป
  • transaction.mode แยกสกุลหน่วย (migration 005): แสตมป์กับแต้มเป็นคนละสกุล การอ่านยอด จึงกรองด้วย mode ด้วย lot ของโหมดเดิมไม่ถูกลบทิ้ง เพียงหยุดนับระหว่างที่โหมดอื่นทำงาน และกลับมาใช้ได้เหมือนเดิมถ้าร้านสลับกลับ
  • Unique ของ reward / earn_token / bonus_grant: idx_loy_reward_code บน (line_oa_id, code); idx_loy_token บน token; idx_loy_bonus_once บน (rule_id, account_id, period_key) ซึ่งทำให้การแจกโบนัสซ้ำเป็นไปไม่ได้ที่ระดับฐานข้อมูล ครอบคลุมทุก cadence ด้วยคอลัมน์เดียว
  • Storage path ไม่ใช่ URL: cover_path, logo_path, stamp_icon_path, image_path เก็บเป็น object path เท่านั้น การเก็บ URL จะผูกติดกับ layout ของ bucket และพังเมื่อค่า STORAGE_PUBLIC_URL_INCLUDE_BUCKET เปลี่ยน ฝั่ง client แปลงเป็น URL ตอนอ่านผ่าน storagex