จับคู่ผู้กรอกกับฐานข้อมูลลูกค้า (Profile Mapping)
ภาพรวม
กลไกที่ทำให้ฟอร์มธรรมดากลายเป็น "ฟอร์มยืนยันตัวตนสมาชิก" โดยแอดมินเลือกฐานข้อมูลลูกค้า (customer_database) หนึ่งชุด แล้วกำหนดคำถามชนิด db_validation ที่ผูกกับคอลัมน์ในฐานนั้นผ่าน fieldKey ผู้กรอกต้องตอบให้ตรงกับแถวใดแถวหนึ่ง ทุกข้อพร้อมกัน จึงจะถือว่า match
เมื่อ match สำเร็จ ระบบจะติดแท็กให้ LINE user ผ่าน custom_attribute และผูก matchedRowId ไว้กับ submission เพื่อป้องกันการอ้างสิทธิ์ซ้ำ
Business Flow
ValidateSubmission(fb, answers, lineUserID)
- อ่าน config จาก
form_builder.profile_mappingหากปิดอยู่หรือ decode ไม่ได้จะข้ามทั้งหมด (checked:false) - เก็บ criteria จาก นิยามฟอร์มเท่านั้น โดยวนคำถามชนิด
db_validationเพื่อนำfieldKey(ซึ่งต้องเป็น JSON string ที่ไม่ว่าง) มาจับคู่กับคำตอบแล้ว trim ส่วนค่าว่างจะถูกข้าม จุดสำคัญคือfieldKeyมาจาก config ที่แอดมินควบคุม ไม่ใช่จาก body ของผู้ใช้ ซึ่งเป็นการป้องกัน SQL และ JSONB key injection นอกจากนี้ลำดับ key ยังถูกรักษาไว้เพื่อให้ SQL ที่ generate ออกมาเหมือนกันทุกครั้ง - หากไม่มี criteria เลยจะข้าม (
checked:false) - ตรวจว่า
databaseIdcoerce เป็นจำนวนเต็มบวกได้ และแถวcustomer_databaseมีอยู่จริงพร้อมเงื่อนไขstatus <> 'delete'หากไม่ผ่านจะเข้าสู่handleNotFound - ยิง 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 สตริงใด ๆ - กรณี match ได้
- หากเปิด
oneAccountPerRecordจะค้นหาform_submissionที่is_submitted = trueและมีmetadata->'profileMapping'->>'matchedRowId'ตรงกับแถวนี้ หากพบและเป็นของ LINE user คนอื่น (หรือไม่ทราบว่าเป็นของใคร) จะตอบ 400 พร้อม{code:"RECORD_ALREADY_CLAIMED"}และข้อความจากalreadyClaimedMessageหรือค่า defaultThis record is already linked to another LINE account. - หากผ่านจะคืน
{checked:true, matched:true, matchedRowID}
- หากเปิด
- กรณี match ไม่ได้ จะเข้า
handleNotFound- หาก
registerIfNotFoundเป็น true จะให้ผ่านแบบ unverified คือ{checked:true, matched:false}ซึ่งเป็นการยอมให้ผู้ใช้ใหม่สมัครได้ - มิฉะนั้นจะตอบ 400 พร้อม
{code:"PROFILE_NOT_FOUND"}และข้อความจากnotFoundMessageหรือค่า defaultRecord 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.go | NewProfileMappingService(db), ValidateSubmission, ApplyVerifiedAttribute, handleNotFound, decodeProfileMapping, decodeDbValidationQuestions, jsonString, jsNumberInt, anyToString |
| โครง config | profileMappingConfig ประกอบด้วย enabled, databaseId, registerIfNotFound, notFoundMessage, verifiedAttribute, verifiedValue, oneAccountPerRecord, alreadyClaimedMessage |
| Error body | internal/formsubmission/errors.go → newCodeError สำหรับโค้ด PROFILE_NOT_FOUND และ RECORD_ALREADY_CLAIMED |
จุดเชื่อมต่อกับ Service อื่น
- ตาราง
customer_database,customer_database_row(jsonbdata),form_submission(jsonbmetadata) และline_user(jsonbcustom_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ให้เป็นข้อความภาษาไทย