Skip to main content

จับคู่ผู้กรอกกับฐานข้อมูลลูกค้า (Profile Mapping)

ภาพรวม

กลไกที่ทำให้ฟอร์มธรรมดากลายเป็น "ฟอร์มยืนยันตัวตนสมาชิก" โดยแอดมินเลือกฐานข้อมูลลูกค้า (customer_database) หนึ่งชุด แล้วกำหนดคำถามชนิด db_validation ที่ผูกกับคอลัมน์ในฐานนั้นผ่าน fieldKey ผู้กรอกต้องตอบให้ตรงกับแถวใดแถวหนึ่ง ทุกข้อพร้อมกัน จึงจะถือว่า match

เมื่อ match สำเร็จ ระบบจะติดแท็กให้ LINE user ผ่าน custom_attribute และผูก matchedRowId ไว้กับ submission เพื่อป้องกันการอ้างสิทธิ์ซ้ำ

Business Flow

ValidateSubmission(fb, answers, lineUserID)

  1. อ่าน config จาก form_builder.profile_mapping หากปิดอยู่หรือ decode ไม่ได้จะข้ามทั้งหมด (checked:false)
  2. เก็บ criteria จาก นิยามฟอร์มเท่านั้น โดยวนคำถามชนิด db_validation เพื่อนำ fieldKey (ซึ่งต้องเป็น JSON string ที่ไม่ว่าง) มาจับคู่กับคำตอบแล้ว trim ส่วนค่าว่างจะถูกข้าม จุดสำคัญคือ fieldKey มาจาก config ที่แอดมินควบคุม ไม่ใช่จาก body ของผู้ใช้ ซึ่งเป็นการป้องกัน SQL และ JSONB key injection นอกจากนี้ลำดับ key ยังถูกรักษาไว้เพื่อให้ SQL ที่ generate ออกมาเหมือนกันทุกครั้ง
  3. หากไม่มี criteria เลยจะข้าม (checked:false)
  4. ตรวจว่า databaseId coerce เป็นจำนวนเต็มบวกได้ และแถว customer_database มีอยู่จริงพร้อมเงื่อนไข status <> 'delete' หากไม่ผ่านจะเข้าสู่ handleNotFound
  5. ยิง query เพียงคำสั่งเดียว ที่บังคับว่า criteria ทุกข้อต้องอยู่ในแถวเดียวกัน มีรูปแบบ SELECT id FROM customer_database_row WHERE database_id = $1 AND data->>$2 = $3 AND data->>$4 = $5 ... LIMIT 1 โดยทั้ง key และ value ถูกส่งเป็น parameter ไม่มีการ interpolate สตริงใด ๆ
  6. กรณี match ได้
    • หากเปิด oneAccountPerRecord จะค้นหา form_submission ที่ is_submitted = true และมี metadata->'profileMapping'->>'matchedRowId' ตรงกับแถวนี้ หากพบและเป็นของ LINE user คนอื่น (หรือไม่ทราบว่าเป็นของใคร) จะตอบ 400 พร้อม {code:"RECORD_ALREADY_CLAIMED"} และข้อความจาก alreadyClaimedMessage หรือค่า default This record is already linked to another LINE account.
    • หากผ่านจะคืน {checked:true, matched:true, matchedRowID}
  7. กรณี match ไม่ได้ จะเข้า handleNotFound
    • หาก registerIfNotFound เป็น true จะให้ผ่านแบบ unverified คือ {checked:true, matched:false} ซึ่งเป็นการยอมให้ผู้ใช้ใหม่สมัครได้
    • มิฉะนั้นจะตอบ 400 พร้อม {code:"PROFILE_NOT_FOUND"} และข้อความจาก notFoundMessage หรือค่า default Record not found

ApplyVerifiedAttribute(fb, lineUserID)

ฟังก์ชันนี้ถูกเรียกหลังบันทึกสำเร็จเมื่อ match ได้ โดยทำงานแบบ best-effort

  • key คือ verifiedAttribute ซึ่งมีค่า default เป็น verified และหากขึ้นต้นด้วย custom. จะตัด prefix ออก
  • หาก verifiedAttribute ถูกตั้งเป็นสตริงว่างโดยเจตนา (แอดมินเลือกว่าไม่ติดแท็ก) ระบบจะไม่ทำอะไร แต่ถ้าไม่มี config เลยจะยัง default เป็น verified ตาม parity
  • ค่าที่เป็น "true" หรือ "false" จะถูกแปลงเป็น boolean ส่วนค่าอื่นเก็บเป็นสตริง โดย default คือ true
  • การเขียนใช้การ merge ด้วย custom_attribute = COALESCE(custom_attribute,'{}'::jsonb) || $2::jsonb

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

feature นี้ไม่มี route ของตัวเอง แต่เป็นขั้นที่ 6 ของ Submit และเป็นขั้นแรกของการขอ OTP

ไฟล์ฟังก์ชัน
internal/formsubmission/profilemapping.goNewProfileMappingService(db), ValidateSubmission, ApplyVerifiedAttribute, handleNotFound, decodeProfileMapping, decodeDbValidationQuestions, jsonString, jsNumberInt, anyToString
โครง configprofileMappingConfig ประกอบด้วย enabled, databaseId, registerIfNotFound, notFoundMessage, verifiedAttribute, verifiedValue, oneAccountPerRecord, alreadyClaimedMessage
Error bodyinternal/formsubmission/errors.gonewCodeError สำหรับโค้ด PROFILE_NOT_FOUND และ RECORD_ALREADY_CLAIMED

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

  • ตาราง customer_database, customer_database_row (jsonb data), form_submission (jsonb metadata) และ line_user (jsonb custom_attribute)
  • ใช้ sqlx pool โดยตรงโดยไม่มี repository แยก เพื่อเลียน raw query ของ source
  • ผลลัพธ์ matchedRowID ถูกใช้ต่อโดย ยืนยันตัวตนด้วย OTP ทั้งตอน resolve เบอร์โทรหรืออีเมลปลายทาง และตอนเทียบกับ session ที่ verified แล้ว
  • ถูกเรียกจาก ส่งคำตอบฟอร์ม
  • ฝั่ง client-web ที่เกี่ยวข้องคือ feature form-fill ซึ่งมี lookup table สำหรับแปลง PROFILE_NOT_FOUND และ RECORD_ALREADY_CLAIMED ให้เป็นข้อความภาษาไทย