Skip to main content

Settings — BigQuery Sync

ภาพรวม​

BigQuery Sync คือหน้าตั้งค่าดึงข้อมูลจาก Google BigQuery เข้ามาเป็นคุณสมบัติเพิ่มเติม (custom attribute) ของผู้ใช้ในระบบ — แม้ชื่อจะอ่านว่า "ซิงก์ไป BigQuery" แต่ทิศทางข้อมูลจริงคือจาก BigQuery เข้าสู่ ระบบ เหมาะกับทีมที่มีคลังข้อมูลลูกค้าบน BigQuery อยู่แล้ว (เช่น ยอดใช้จ่ายสะสม ระดับสมาชิก) และต้องการดึงมาทำกลุ่มเป้าหมายต่อ

เป็นการตั้งค่าชุดเดียวต่อ LINE OA (มี unique index (organization_id, line_oa_id) รองรับ) จึงไม่มีหน้ารายการ เปิดเมนูแล้วเจอฟอร์มเดียวเลย และตัวปุ่มบันทึกจะเปลี่ยนข้อความเป็น "สร้าง" หรือ "อัปเดต" ตามว่ามีการตั้งค่าเดิมอยู่หรือไม่

บทบาทที่ใช้งาน — ผู้ดูแลระบบที่เชื่อมข้อมูลจากคลังข้อมูลภายนอกเข้ามาใช้ในระบบ (โมดูลนี้ ไม่มี ModuleGate และ ไม่มี การบังคับ policy ใด ๆ — ดูหัวข้อ สิทธิ์การเข้าถึง ซึ่งเป็นเรื่องที่ควรอ่านก่อนเปิดใช้จริง)

เส้นทางผู้ใช้งาน​

  1. เปิดเมนู Settings > BigQuery Sync — ถ้ายังไม่เคยตั้งค่าจะเห็นฟอร์มเปล่าพร้อมค่าเริ่มต้น (คอลัมน์ผู้ใช้ user_id, ตารางเวลา 0 6 * * * = ทุกวัน 06:00 น., ปิดใช้งาน, การแม็ปฟิลด์ว่าง)
  2. กรอกการ์ด การเชื่อมต่อ
    • Project ID (บังคับ) — รหัสโปรเจกต์ GCP เช่น my-gcp-project-id
    • ตารางสรุปข้อมูล (บังคับ) — พาธในรูป project.dataset.table_name
    • คอลัมน์ User ID (ไม่บังคับ ค่าเริ่มต้น user_id) — คอลัมน์ใน BigQuery ที่เก็บ LINE user ID เพื่อใช้จับคู่กับผู้ใช้ในระบบ
    • คีย์บัญชีบริการ (Service Account Key) — วางเนื้อหา JSON ของคีย์ทั้งก้อนลงในช่องข้อความ
  3. กดปุ่ม ทดสอบการเชื่อมต่อ — ระบบยิงคำสั่ง SELECT 1 FROM <ตารางสรุป> LIMIT 1 จริงด้วยข้อมูลรับรองที่กรอกไว้ในฟอร์ม (ไม่ต้องบันทึกก่อน) ผลลัพธ์เป็น success/failure พร้อมข้อความ และไม่โยน error ออกมาเลย ทุกความล้มเหลวถูกแปลงเป็นข้อความให้อ่าน
  4. กรอกการ์ด กำหนดเวลา เป็น cron expression และเปิดสวิตช์เปิดใช้งานการซิงก์
  5. เพิ่มแถวในการ์ด การแม็ปฟิลด์ — แต่ละแถวมี 4 ช่อง (บังคับทุกช่อง): คอลัมน์ BigQuery, คีย์แอตทริบิวต์ (มีคำนำหน้า custom. ติดไว้ให้อัตโนมัติ), ประเภทข้อมูล (string / number / date / boolean), และป้ายกำกับที่ใช้แสดงผล
  6. กดบันทึก
  7. เมื่อมีการตั้งค่าที่บันทึกแล้ว การ์ด สถานะการซิงก์ จะปรากฏขึ้น (การ์ดนี้ ไม่แสดงเลยถ้ายังไม่บันทึก) แสดงเวลาซิงก์ล่าสุด ผลลัพธ์รอบล่าสุด และมีปุ่ม ซิงก์ทันที — ปุ่มนี้ต้องเปิดสวิตช์เปิดใช้งานไว้ก่อน ไม่งั้นได้ 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 แถว แต่ละแถว UPDATE line_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 — จึงเป็น "สถานะยังไม่ตั้งค่า" ที่ปกติ ส่วนการสร้างซ้ำจะได้ 400 BigQuery sync config already exists for this LINE OA และการอ้าง id ที่ไม่ใช่ของ OA ตัวเองจะได้ 400 BigQuery 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) โมดูลนี้ไม่มีการประกาศไว้เลย
  • ทุกเส้นทางถูกลงทะเบียนบน public group เพราะ controller ต้นทางเป็น @Public() — นี่คือจุดที่อ่อนกว่าเมนู OTP Verification ด้วยซ้ำ: @Public() ทำให้ทั้ง 6 เส้นทาง ข้าม JwtAuthGuard ระดับ global ไปเลย แล้วผูก middleware JwtLoginAuth เข้าที่เส้นทางแทน ซึ่งเป็น strategy jwt-login ที่ ไม่บังคับให้ token มี claim lineOaId (ต่างจาก jwt strategy ปกติที่บังคับ) ในทางปฏิบัติ 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 เส้นทางบน public group, JwtLoginAuth, ไม่มี ModuleGate/CheckPolicies
  • internal/modules/bigquerysync/service.go — tenant scope, SyncNow ที่ยังเป็น TODO, TestConnection
  • internal/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 และชื่อคอลัมน์/คีย์ที่ไม่ตรงกับฝั่ง CMS
  • internal/bigqueryx/bigqueryx.go — ตัว client ที่สร้างจาก project id + credential

ดูรายละเอียดเชิงเทคนิคเพิ่มเติมที่ ซิงก์ข้อมูลไป BigQuery