Settings — BigQuery Sync
ภาพรวม
BigQuery Sync คือหน้าตั้งค่าดึงข้อมูลจาก Google BigQuery เข้ามาเป็นคุณสมบัติเพิ่มเติม (custom attribute) ของผู้ใช้ในระบบ — แม้ชื่อจะอ่านว่า "ซิงก์ไป BigQuery" แต่ทิศทางข้อมูลจริงคือจาก BigQuery เข้าสู่ ระบบ เหมาะกับทีมที่มีคลังข้อมูลลูกค้าบน BigQuery อยู่แล้ว (เช่น ยอดใช้จ่ายสะสม ระดับสมาชิก) และต้องการดึงมาทำกลุ่มเป้าหมายต่อ
เป็นการตั้งค่าชุดเดียวต่อ LINE OA (มี unique index (organization_id, line_oa_id) รองรับ) จึงไม่มีหน้ารายการ เปิดเมนูแล้วเจอฟอร์มเดียวเลย และตัวปุ่มบันทึกจะเปลี่ยนข้อความเป็น "สร้าง" หรือ "อัปเดต" ตามว่ามีการตั้งค่าเดิมอยู่หรือไม่
บทบาทที่ใช้งาน — ผู้ดูแลระบบที่เชื่อมข้อมูลจากคลังข้อมูลภายนอกเข้ามาใช้ในระบบ (โมดูลนี้ ไม่มี ModuleGate และ ไม่มี การบังคับ policy ใด ๆ — ดูหัวข้อ สิทธิ์การเข้าถึง ซึ่งเป็นเรื่องที่ควรอ่านก่อนเปิดใช้จริง)
เส้นทางผู้ใช้งาน
- เปิดเมนู Settings > BigQuery Sync — ถ้ายังไม่เคยตั้งค่าจะเห็นฟอร์มเปล่าพร้อมค่าเริ่มต้น (คอลัมน์ผู้ใช้
user_id, ตารางเวลา0 6 * * *= ทุกวัน 06:00 น., ปิดใช้งาน, การแม็ปฟิลด์ว่าง) - กรอกการ์ด การเชื่อมต่อ
- Project ID (บังคับ) — รหัสโปรเจกต์ GCP เช่น
my-gcp-project-id - ตารางสรุปข้อมูล (บังคับ) — พาธในรูป
project.dataset.table_name - คอลัมน์ User ID (ไม่บังคับ ค่าเริ่มต้น
user_id) — คอลัมน์ใน BigQuery ที่เก็บ LINE user ID เพื่อใช้จับคู่กับผู้ใช้ในระบบ - คีย์บัญชีบริการ (Service Account Key) — วางเนื้อหา JSON ของคีย์ทั้งก้อนลงในช่องข้อความ
- Project ID (บังคับ) — รหัสโปรเจกต์ GCP เช่น
- กดปุ่ม ทดสอบการเชื่อมต่อ — ระบบยิงคำสั่ง
SELECT 1 FROM <ตารางสรุป> LIMIT 1จริงด้วยข้อมูลรับรองที่กรอกไว้ในฟอร์ม (ไม่ต้องบันทึกก่อน) ผลลัพธ์เป็น success/failure พร้อมข้อความ และไม่โยน error ออกมาเลย ทุกความล้มเหลวถูกแปลงเป็นข้อความให้อ่าน - กรอกการ์ด กำหนดเวลา เป็น cron expression และเปิดสวิตช์เปิดใช้งานการซิงก์
- เพิ่มแถวในการ์ด การแม็ปฟิลด์ — แต่ละแถวมี 4 ช่อง (บังคับทุกช่อง): คอลัมน์ BigQuery, คีย์แอตทริบิวต์ (มีคำนำหน้า
custom.ติดไว้ให้อัตโนมัติ), ประเภทข้อมูล (string / number / date / boolean), และป้ายกำกับที่ใช้แสดงผล - กดบันทึก
- เมื่อมีการตั้งค่าที่บันทึกแล้ว การ์ด สถานะการซิงก์ จะปรากฏขึ้น (การ์ดนี้ ไม่แสดงเลยถ้ายังไม่บันทึก) แสดงเวลาซิงก์ล่าสุด ผลลัพธ์รอบล่าสุด และมีปุ่ม ซิงก์ทันที — ปุ่มนี้ต้องเปิดสวิตช์เปิดใช้งานไว้ก่อน ไม่งั้นได้ 400 ว่า
BigQuery sync is not enabled
รายละเอียดสำคัญที่ตรวจสอบจากซอร์สโค้ดจริง
ยืนยันจาก internal/modules/bigquerysync/{controller,service,dto}.go และ internal/entities/bigquery_sync_config.go ของ line-management-cms-api-go เทียบกับ internal/bigquerysync/service.go ของ line-management-worker-go และ DDL จริงของตาราง bigquery_sync_config:
- ปุ่ม "ซิงก์ทันที" ยังไม่ได้สั่งซิงก์อะไรเลย —
POST /bigquery-sync-config/:id/sync-nowตรวจว่ามีการตั้งค่าอยู่และเปิดใช้งานอยู่ แล้วเขียน log กับตอบ{success: true, message: "Manual sync triggered successfully"}เท่านั้น ในซอร์สมีคอมเมนต์// TODO: Publish to RabbitMQ for worker to handle actual syncค้างอยู่ตรงจุดที่ควรจะส่งงานเข้าคิว หน้าเว็บจึงขึ้นข้อความ "เริ่มการซิงก์สำเร็จ" แล้ว refetch แต่ตัวเลขจะไม่ขยับ การซิงก์ที่ทำงานจริงมีทางเดียวคือรอตามตารางเวลา cron ที่ฝั่ง worker - ฝั่ง worker อ่านชื่อคอลัมน์ข้อมูลรับรองผิดจากที่ CMS เขียนลงไป — CMS บันทึกลงคอลัมน์
credentials(ยืนยันจาก DDL:credentials text) แต่ตัวอ่านค่าใน worker ทำSELECT *แล้ววนจับชื่อคอลัมน์ทีละตัว โดยมีเคสชื่อcredentials_jsonซึ่งไม่มีอยู่ในตาราง ผลคือค่าข้อมูลรับรองถูกข้ามไปเงียบ ๆ และ worker สร้าง BigQuery client ด้วย credential ว่าง (ตกไปใช้ credential ของสภาพแวดล้อมที่รันอยู่แทน) — ข้อมูลรับรองที่กรอกในหน้านี้จึงไม่ถูกใช้จริงบนเส้นทางซิงก์ตามตารางเวลา - โครงสร้าง
field_mappingsที่ CMS เขียนกับที่ worker อ่านไม่ตรงกัน — CMS เก็บเป็น array ของ object[{gaField, attributeKey, dataType, label}, …](คอลัมน์เป็นjsonb DEFAULT '[]') แต่ worker ประกาศชนิดเป็นmap[string]stringและคาดหวังรูป{"bigquery_column": "custom_attribute_key"}การถอดรหัส array ลงเป็น map จะล้มเหลวและคืนnilซึ่งทำให้ไม่มี attribute ใดถูกแม็ปเลย และแถวนั้นถูกนับเป็น "ไม่อัปเดต" (ไม่ใช่ error) — สรุปผลจะออกมาเป็นupdated: 0, errors: 0 - ตัวเลขในการ์ดสถานะอ่านคีย์ไม่ตรงกับที่ worker เขียน — หน้าเว็บอ่าน
lastSyncResult.usersUpdated,.errors,.durationแต่ worker เขียนupdated,errors,totalRows,durationMs— จึงมีแค่ป้าย จำนวนข้อผิดพลาด ที่แสดงได้ ส่วนป้าย "อัปเดตผู้ใช้แล้ว N คน" และ "ระยะเวลา" จะไม่ปรากฏไม่ว่าซิงก์จะสำเร็จหรือไม่ (ค่าเป็นundefinedจึงถูกข้ามการเรนเดอร์) - ข้อมูลรับรอง service account ถูกเก็บเป็นข้อความธรรมดาและถูกส่งกลับมาทั้งก้อนใน API — คอลัมน์เป็น
textไม่มีการเข้ารหัส และ entity ประกาศjson:"credentials"ไม่มีjson:"-"จึงถูกใส่ในผลลัพธ์ของGET /bigquery-sync-configเต็ม ๆ หน้าเว็บก็นำค่านั้นมาเติมกลับในช่องข้อความให้เห็น (credentials: existingConfig.credentials) — ต่างจากเมนู OTP Verification อย่างชัดเจน ซึ่งเข้ารหัส secret ด้วย AES-256-GCM ก่อนเก็บและคืนออกมาเพียงค่าที่ปิดบังไว้เท่านั้น เมื่อรวมกับหัวข้อ สิทธิ์การเข้าถึง นี่คือประเด็นสำคัญที่สุดของหน้านี้ - ไม่มีการตรวจรูปแบบ cron expression และไม่มีการตรวจว่า credential เป็น JSON ที่ถูกต้อง — DTO ตรวจแค่ชนิดข้อมูล (
syncCronเป็น string,credentialsเป็น string) ทั้งฝั่งหน้าเว็บและ backend จึงบันทึกค่าที่ผิดรูปแบบได้สำเร็จ แล้วไปพังตอน worker ลงทะเบียน cron job (AddFuncจะ error แล้วถูกเขียน log และข้ามการลงทะเบียน job นั้นไปเลยแบบไม่มีใครเห็น) - worker อ่านตารางเวลาแค่ครั้งเดียวตอนบูต —
Startโหลดทุกแถวที่enabled = trueแล้วลงทะเบียน cron job ชื่อbigquery-sync-<id>ครั้งเดียว การแก้ cron expression หรือเปิดสวิตช์ใหม่จึงยังไม่มีผลจนกว่า worker จะรีสตาร์ท (ตัวงานจะอ่าน config ใหม่ทุกครั้งที่รัน แต่ตารางเวลาไม่ถูกโหลดซ้ำ) ค่าที่ว่างจะตกไปใช้0 6 * * * - การซิงก์จริงเป็น "อ่านทั้งตารางแล้วผสานเข้า jsonb" — worker ยิง
SELECT * FROM <project>.<summary_table>(ชื่อตารางถูกครอบด้วย backtick แบบ BigQuery) โดยไม่มี WHERE และไม่มี LIMIT (ตารางใหญ่จะถูกอ่านทั้งหมดทุกรอบ) แล้วประมวลผลเป็นชุดละ 100 แถว แต่ละแถว UPDATEline_userด้วยcustom_attribute = COALESCE(custom_attribute,'{}') || <ค่าใหม่>จับคู่ด้วยuser_id— เป็นการผสาน (merge) ไม่ใช่การเขียนทับทั้งก้อน และแถวที่ error จะถูกนับแล้วข้ามไป ไม่หยุดทั้งงาน - การ UPDATE ผู้ใช้ไม่ได้กรองด้วย
line_oa_id— คำสั่งใน worker กรองแค่user_id = ? AND deleted_date IS NULLดังนั้นถ้ามี LINE user ID เดียวกันปรากฏในหลาย OA ค่าที่ดึงมาจาก BigQuery ของ OA หนึ่งจะถูกเขียนทับให้ผู้ใช้ใน OA อื่นด้วย GET /bigquery-sync-configตอบnullเมื่อยังไม่เคยตั้งค่า ไม่ใช่ 404 — จึงเป็น "สถานะยังไม่ตั้งค่า" ที่ปกติ ส่วนการสร้างซ้ำจะได้ 400BigQuery sync config already exists for this LINE OAและการอ้าง id ที่ไม่ใช่ของ OA ตัวเองจะได้ 400BigQuery sync config not found(400 ไม่ใช่ 404 — สังเกตว่าข้อความคำว่า "not found" แต่สถานะเป็น 400)PUTเขียนทับด้วยเงื่อนไขWHERE id = ?เพียงค่าเดียว — ก่อนหน้านั้นมีการอ่านตรวจว่าแถวนี้เป็นของorganization_id+line_oa_idของผู้เรียกจริง จึงยังปลอดภัย แต่คำสั่ง UPDATE เองไม่มี tenant scope (คอมเมนต์ในซอร์สระบุว่าเป็นการคง parity กับ.update(id, …)ของต้นทาง TS)- การส่ง
userIdColumn: ""หรือsyncCron: ""ตอนสร้างจะไม่ได้ค่าว่าง แต่ได้ค่า default ของฐานข้อมูล — คอมเมนต์ในซอร์สยอมรับเป็นความต่างเล็กน้อยจากต้นทาง TS โดยเจตนา เพราะ GORM มองสตริงว่างเป็น zero value แล้วปล่อยให้ DB default ทำงาน
เมนูที่เกี่ยวข้องกันในเส้นทางนี้
- User Attribute Setup — คีย์ที่แม็ปมาจาก BigQuery ใช้ namespace
custom.เดียวกับ custom attribute ที่ประกาศไว้ในเมนูนั้น และตัว worker เขียนค่าลงline_user.custom_attributeโดยตรง ทำให้ค่าไปโผล่ในที่เดียวกัน แต่หน้านี้ไม่ได้อ่านนิยาม attribute จากเมนูนั้นเลย — ช่องคีย์แอตทริบิวต์เป็นช่องพิมพ์อิสระที่มีคำนำหน้าcustom.ติดไว้ให้ ไม่มีตัวเลือกและไม่มีการตรวจว่าคีย์นั้นถูกประกาศไว้แล้วหรือไม่ พิมพ์ผิดตัวเดียวก็ได้ attribute กำพร้าที่กรองใน Segment audience ไม่ได้ - Segment audience — ปลายทางที่แท้จริงของข้อมูลชุดนี้ แต่จะกรองด้วย attribute ที่ดึงมาได้ก็ต่อเมื่อคีย์นั้นถูกประกาศไว้ในเมนู User Attribute Setup พร้อมเปิดสวิตช์ filterable
- All friends — เห็นค่าที่ดึงมาได้เป็นคอลัมน์เพิ่มเมื่อ attribute นั้นเปิด displayInReport ไว้ในเมนู User Attribute Setup
จุดที่ควรระวัง
- ปุ่ม "ซิงก์ทันที" ยังไม่ทำงานจริง — ได้ข้อความว่าสำเร็จแต่ไม่มีอะไรเกิดขึ้น การซิงก์ที่ได้ผลจริงต้องรอตามตารางเวลาที่ฝั่ง worker
- ตัวเลขในการ์ดสถานะการซิงก์แสดงได้ไม่ครบ — "อัปเดตผู้ใช้แล้ว" และ "ระยะเวลา" จะไม่โผล่เลยเพราะชื่อคีย์ระหว่าง worker กับหน้าเว็บไม่ตรงกัน ให้ดูที่ log ของ worker เป็นหลัก
- คีย์บัญชีบริการที่กรอกไปถูกเก็บเป็นข้อความธรรมดา และใครก็ตามที่ล็อกอินเข้าระบบได้ก็เรียก
GET /bigquery-sync-configมาอ่านทั้งก้อนได้ (โมดูลนี้ไม่มี ModuleGate และไม่มีการบังคับ policy) — ควรใช้ service account ที่มีสิทธิ์อ่านเฉพาะตารางที่ต้องใช้เท่านั้น (principle of least privilege) และหมุนเวียนคีย์อย่างสม่ำเสมอ - การแก้ตารางเวลาหรือเปิดสวิตช์ใหม่จะไม่มีผลจนกว่า worker จะรีสตาร์ท — เพราะ cron job ถูกลงทะเบียนครั้งเดียวตอนบูต
- หน้านี้ไม่ตรวจรูปแบบ cron expression หรือ JSON ของข้อมูลรับรองไว้ล่วงหน้า — ค่าที่ผิดรูปแบบบันทึกผ่านและไปพังตอนรันจริง โดยที่ความล้มเหลวของการลงทะเบียน cron ไม่ปรากฏในหน้าเว็บใด ๆ ให้ใช้ปุ่มทดสอบการเชื่อมต่อยืนยันข้อมูลรับรองก่อนบันทึกเสมอ
- ตารางสรุปถูกอ่านทั้งตารางทุกรอบ ไม่มีการอ่านแบบเพิ่มเฉพาะส่วนที่เปลี่ยน — ควรชี้ไปที่ตารางสรุป (summary/aggregate) ที่มีขนาดพอเหมาะ ไม่ใช่ตารางเหตุการณ์ดิบ ทั้งเพื่อประสิทธิภาพและเพื่อค่าใช้จ่ายในการ query BigQuery
- การเขียนค่ากลับจับคู่ด้วย LINE user ID เท่านั้น ไม่ได้กรอง OA — ถ้ามีผู้ใช้คนเดียวกันอยู่ในหลาย OA ในระบบเดียวกัน ค่าที่ซิงก์มาจะกระจายไปทุก OA
- ปุ่ม "ซิงก์ทันที" จะไม่ปรากฏจนกว่าจะบันทึกการตั้งค่าครั้งแรกสำเร็จ (การ์ดสถานะผูกกับ
existingConfig.id)
สิทธิ์การเข้าถึง (ตรวจสอบจากซอร์สโค้ดจริง)
ยืนยันแล้วว่าโมดูลนี้ไม่มีการควบคุมสิทธิ์ระดับโมดูลหรือระดับการกระทำเลย และเป็นโมดูลที่ควบคุมอ่อนที่สุดในสี่โมดูลของกลุ่มเอกสารชุดนี้
- ไม่มี
ModuleGate— ตรวจแล้วทั้ง repository:modulegate.ModuleGate(...)ถูกเรียกใช้ใน 15 โมดูล และbigquerysyncไม่ใช่หนึ่งในนั้น ผู้ดูแลระดับแพลตฟอร์มจึงไม่มีทางปิดโมดูลนี้ให้องค์กรใดองค์กรหนึ่งได้เลย - ไม่มี
CheckPoliciesแม้แต่เส้นทางเดียว — ต่างจากโมดูลอื่นที่ยังประกาศ policy annotation ไว้เผื่ออนาคต (แม้ middleware จะเป็น no-op) โมดูลนี้ไม่มีการประกาศไว้เลย - ทุกเส้นทางถูกลงทะเบียนบน
publicgroup เพราะ controller ต้นทางเป็น@Public()— นี่คือจุดที่อ่อนกว่าเมนู OTP Verification ด้วยซ้ำ:@Public()ทำให้ทั้ง 6 เส้นทาง ข้าม JwtAuthGuard ระดับ global ไปเลย แล้วผูก middlewareJwtLoginAuthเข้าที่เส้นทางแทน ซึ่งเป็น strategyjwt-loginที่ ไม่บังคับให้ token มี claimlineOaId(ต่างจากjwtstrategy ปกติที่บังคับ) ในทางปฏิบัติ token ที่ไม่มี OA ผูกอยู่จะอ่านlineOaIdได้เป็น 0 แล้ว query ไม่เจอแถวใด — จึงไม่เกิดการรั่วข้ามองค์กร แต่หมายความว่าด่านตรวจที่เหลืออยู่มีเพียง "มี JWT ที่ยังไม่หมดอายุ" เท่านั้น - การป้องกันจริงที่เหลืออยู่คือ tenant scope ในชั้น service — ทุก query (
GetConfig,Create,Update,SyncNow,GetSyncStatus) กรองด้วยorganization_idและline_oa_idที่ดึงจาก context ของ token ผู้ใช้จาก OA อื่นจึงอ่านหรือแก้ config ข้าม OA ไม่ได้ แต่ผู้ใช้ที่ล็อกอินอยู่ในองค์กรเดียวกัน ไม่ว่าจะมีสิทธิ์ระดับใด ก็อ่านคีย์บัญชีบริการของ OA ตัวเองได้ทั้งก้อน เพราะไม่มีชั้นตรวจสิทธิ์อื่นเหลืออยู่ - ฝั่งหน้าเว็บ — ไม่มี
PermissionGuardครอบหน้านี้ การซ่อนเมนูจึงเป็นการควบคุมเดียวในระดับ UI ซึ่งไม่ใช่การบังคับใช้จริง (ตามหลักที่แพ็กเกจmodulegateเขียนไว้เองว่า "การซ่อนเมนูใน CMS ไม่ใช่การบังคับใช้") - ไม่มีเส้นทางใดต้องเป็น Super Admin และไม่มีการลบ config ในโมดูลนี้เลย (มีแค่ GET / POST / PUT / test-connection / sync-now / sync-status)
ไฟล์อ้างอิงจากซอร์สโค้ด
Frontend (cms-web-2026)
src/app/bigquery-sync/page.tsx— จุดเข้า (ไม่มี PermissionGuard)src/components/bigquery-sync/bigquery-sync-form.tsx— การ์ด 4 ใบ, ช่องแม็ปฟิลด์, การ์ดสถานะการซิงก์และคีย์ที่มันอ่านsrc/components/bigquery-sync/bigquery-sync-form.container.tsx— การเติมค่ากลับเข้าฟอร์ม (รวมถึงcredentials), การเติมคำนำหน้าcustom.src/services/bigquery-sync.service.ts,src/services/types/bigquery-sync.type.ts— endpoint และรูปทรงข้อมูล
Backend (line-management-cms-api-go)
internal/modules/bigquerysync/controller.go— 6 เส้นทางบนpublicgroup,JwtLoginAuth, ไม่มี ModuleGate/CheckPoliciesinternal/modules/bigquerysync/service.go— tenant scope,SyncNowที่ยังเป็น TODO,TestConnectioninternal/modules/bigquerysync/dto.go— กฎตรวจข้อมูลขาเข้า (ไม่มีการตรวจ cron/JSON)internal/entities/bigquery_sync_config.go— คอลัมน์credentialsแบบข้อความธรรมดาที่ถูกส่งออกใน JSON
Worker (line-management-worker-go)
internal/bigquerysync/service.go— การลงทะเบียน cron ตอนบูต,SELECT * FROM <table>, การผสาน jsonb ทีละชุด 100 แถว, การเขียนlast_sync_resultและชื่อคอลัมน์/คีย์ที่ไม่ตรงกับฝั่ง CMSinternal/bigqueryx/bigqueryx.go— ตัว client ที่สร้างจาก project id + credential
ดูรายละเอียดเชิงเทคนิคเพิ่มเติมที่ ซิงก์ข้อมูลไป BigQuery