Skip to main content

Segment audience — Upload audience

ภาพรวม​

Upload audience คือโหมดสร้างกลุ่มเป้าหมาย (Audience) จากไฟล์ CSV รายชื่อ LINE User ID ที่ผู้ใช้เตรียมมาเอง เหมาะกับทีมที่มีรายชื่อลูกค้าจากระบบอื่นอยู่แล้ว (เช่น CRM) และต้องการนำเข้ามาใช้งานตรง ๆ โดยไม่ต้องตั้งเงื่อนไขกรองใด ๆ

จุดสำคัญที่ต้องเข้าใจก่อนเริ่มคือ cms-api ไม่ได้อ่านไฟล์ CSV ที่อัปโหลดเลย — มันเพียงเก็บไฟล์ขึ้น object storage แล้วส่งงานเข้าคิว RabbitMQ ให้ worker เป็นผู้แกะไฟล์ ตรวจรหัสผู้ใช้ และผูกสมาชิกเข้ากลุ่ม การกดบันทึกสำเร็จจึงหมายถึง "รับไฟล์เข้าคิวแล้ว" ไม่ใช่ "รายชื่อพร้อมใช้แล้ว" (ดูหัวข้อ รายละเอียดสำคัญ)

บทบาทที่ใช้งาน — ทีมการตลาดที่มีรายชื่อลูกค้าพร้อมอยู่แล้ว และได้รับสิทธิ์โมดูล audience (โมดูลนี้มี ModuleGate บังคับจริงที่ backend — ดูหัวข้อ สิทธิ์การเข้าถึง)

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

1. ดูรายการกลุ่มที่อัปโหลดไว้​

เปิดเมนู Segment audience > Upload audience หน้ารายการนี้ใช้ container เดียวกันกับหน้า Filter audience แต่ยิง GET /audiences ด้วย dataSource=csv ตรึงไว้ จึงเห็นแต่กลุ่มที่มาจากไฟล์ CSV เท่านั้น (ไม่มีแท็บให้สลับ) ตัวกรองมี 2 ช่อง — ค้นหาจากชื่อกลุ่ม และเลือกสถานะ (เสร็จแล้ว / กำลังประมวลผล)

คอลัมน์ที่แสดง — ลำดับ, ชื่อกลุ่ม (คลิกเข้าหน้ารายชื่อสมาชิก), Api Client, คำอธิบาย, ยอดสมาชิก (totalSuccess / totalOriginal), สถานะ, ชื่อไฟล์, วันที่สร้าง และปุ่มดำเนินการ

2. อัปโหลดไฟล์สร้างกลุ่มใหม่​

  1. กดปุ่ม Upload CSV เข้าสู่ฟอร์ม

  2. กรอกชื่อและคำอธิบาย — บังคับทั้งคู่ (backend ตรวจ IsNotEmpty ทั้งสองช่อง) ชื่อต้องไม่ซ้ำกับกลุ่มอื่นใน LINE OA เดียวกัน

  3. เตรียมไฟล์ — มีแผงพับ "CSV file guideline" อธิบาย 5 ขั้นตอน พร้อมปุ่มดาวน์โหลดไฟล์ต้นแบบ (/template/audiences_template.csv ซึ่งมีแต่หัวคอลัมน์ Line UserID บรรทัดเดียว)

  4. ลากไฟล์ลงพื้นที่อัปโหลด — รับเฉพาะไฟล์ที่ MIME type มีคำว่า text/csv ระบบตรวจไฟล์ฝั่งหน้าเว็บทันทีก่อนส่ง โดยไล่ทีละแถวหาปัญหา 3 แบบ

    • แถวว่าง → "ไม่พบ UID"
    • รหัสผู้ใช้ซ้ำ → "พบ UID ซ้ำกับแถวที่ {row}"
    • รหัสผู้ใช้ผิดรูปแบบ → "รูปแบบ UID ไม่ถูกต้องในแถวที่ {row}"

    ถ้าพบข้อผิดพลาดจะเปิดกล่อง "ข้อมูลไม่ถูกต้อง" แสดงตารางแถว/คำอธิบาย และ ไม่รับไฟล์นั้นไว้เลย ต้องไปแก้ไฟล์แล้วลากเข้ามาใหม่ (เลขแถวที่รายงานนับรวมบรรทัดหัวคอลัมน์เป็นแถวที่ 1)

  5. ยืนยัน — เมื่อผ่านการตรวจ ระบบเปิดกล่องยืนยันระบุ "วิเคราะห์รูปแบบพบ {count} แถว" ถ้าเป็นการแก้ไขกลุ่มเดิมจะเพิ่มบรรทัด "ต้องการแทนที่ข้อมูลเดิมหรือไม่?" เพราะโหมดแก้ไขเป็นการ แทนที่รายชื่อทั้งหมด ไม่ใช่การเพิ่มต่อท้าย

  6. ยืนยันแล้วหน้าเว็บส่ง multipart/form-data ไปที่ POST /audiences (สร้าง) หรือ PUT /audiences/{id} (แก้ไข) แล้วพากลับหน้ารายการ กลุ่มจะขึ้นสถานะ กำลังประมวลผล ก่อน จนกว่า worker จะทำงานเสร็จจึงเปลี่ยนเป็น เสร็จแล้ว พร้อมยอดสมาชิกจริง

3. ดูสมาชิกและส่งออกข้อมูล​

คลิกชื่อกลุ่มเพื่อเปิดหน้ารายชื่อสมาชิก — ตารางแสดง UID, ชื่อที่แสดง, ชื่อ, นามสกุล และสถานะเพื่อน (Friend / Blocked) กรองด้วยคำค้นและสถานะได้ มีปุ่มส่งออก 2 ปุ่ม

  • Export — เปิด presigned URL ของไฟล์ CSV รายชื่อที่ระบบสร้างไว้บน object storage โดยตรง (อายุ 24 ชั่วโมง)
  • Export with attributes — เรียก GET /audiences/{id}/export-detail ได้ CSV ที่มีคอลัมน์โปรไฟล์ครบ บวกคอลัมน์ custom attribute ทุกตัวของ OA นั้น (นำ label จาก attribute master มาเป็นหัวคอลัมน์ พร้อม UTF-8 BOM ให้ Excel อ่านภาษาไทยได้)

4. จัดการกลุ่มที่มีอยู่​

  • แก้ไข — พาไปฟอร์มเดิม ต้องแนบไฟล์ใหม่ทับของเก่า
  • ลบ — ต้องยืนยันก่อน เป็น soft delete แล้วส่งงานลบเข้าคิวให้ worker ถอด audience_ids ออกจากผู้ใช้
  • กลุ่มที่สร้างโดยระบบภายนอก (apiClientId > 0) จะซ่อนปุ่มดำเนินการทั้งหมด และคอลัมน์ auto-refresh แสดง -

รายละเอียดสำคัญที่ตรวจสอบจากซอร์สโค้ดจริง​

ยืนยันจาก internal/modules/audience/{controller,service,dto,scheduler}.go ของ line-management-cms-api-go และ internal/audience/{consumer,service}.go + internal/csvaudience/csvaudience.go ของ line-management-worker-go:

  • สมาชิกของกลุ่มไม่ได้เก็บเป็นแถวในตารางเชื่อม แต่เก็บสองที่พร้อมกัน — (1) คอลัมน์ jsonb line_user.audience_ids ซึ่งเป็นอาเรย์ของ audience id ค้นด้วยตัวดำเนินการ audience_ids @> to_jsonb(?::int) และ (2) ไฟล์ CSV หนึ่งไฟล์ต่อกลุ่มบน object storage ที่พาธเก็บไว้ใน info.lineUserIdsPathFilename เวลาเปิดหน้ารายละเอียด backend จะสร้าง presigned URL อายุ 24 ชั่วโมงยัดเพิ่มเข้าไปใน info.presignedUrl ให้หน้าเว็บใช้ดาวน์โหลด — ถ้าไฟล์ยังไม่มี (ยังไม่ประมวลผลเสร็จ) จะคืน entity ดิบกลับไปโดยไม่มี presignedUrl เลย
  • กฎตรวจรหัสผู้ใช้ของหน้าเว็บกับของ worker ไม่เหมือนกัน และ worker เข้มกว่า — หน้าเว็บตรวจแค่ "ขึ้นต้นด้วย U และยาว 33 ตัวอักษร" ไม่สนว่าตัวอักษรที่เหลือเป็นอะไร ส่วน worker ใช้ regex ^U[a-f0-9]{32}$ ซึ่งรับ เลขฐานสิบหกตัวพิมพ์เล็กเท่านั้น ผลคือรหัสที่พิมพ์เป็นตัวใหญ่ (เช่น U1A2B...) ผ่านการตรวจในเบราว์เซอร์ได้สบาย แต่ถูก worker ทิ้งเงียบ ๆ และไปโผล่เป็นยอด totalFail ทีหลัง
  • worker เก็บเฉพาะรหัสผู้ใช้ที่ "รู้จักอยู่แล้ว" ในระบบเท่านั้น — ขั้นตอนสร้างกลุ่มจากไฟล์จะอ่าน CSV จาก storage เป็นก้อนละ 1,000 แถว แล้วทุกก้อนจะไปเทียบกับตาราง line_user ของ OA/องค์กรนั้น รหัสที่ ยังไม่เคยมีอยู่ในระบบจะถูกตัดออก (ไม่มีการยิงถาม LINE เพื่อดึงโปรไฟล์มาสร้างผู้ใช้ใหม่ในเส้นทางนี้) นี่คือสาเหตุหลักที่อัปโหลดไป 10,000 แถวแต่ได้สมาชิกจริงน้อยกว่านั้นมาก — สถิติที่บันทึกกลับคือ totalOriginal (จำนวนแถวในไฟล์), totalSuccess (ที่เก็บได้) และ totalFail (ส่วนต่าง)
  • NestJS เดิมใช้ DuckDB อ่านไฟล์ ส่วนพอร์ต Go ไม่ใช้ — ต้นฉบับโหลด CSV เข้าตาราง DuckDB ในหน่วยความจำเพื่อนับและแบ่งหน้า พอร์ต Go เปลี่ยนไปสตรีมไฟล์จาก S3/MinIO แล้วแกะด้วย encoding/csv สด ๆ ทุกครั้ง คอมเมนต์ในซอร์สระบุเหตุผลไว้ตรง ๆ ว่าแบบเดิมมี temp table ค้างอยู่ ซึ่งเป็นความเสี่ยงข้อมูลรั่วข้าม OA
  • การกด "สร้าง" โดยไม่แนบไฟล์ ฝั่ง API ไม่ error แต่ได้กลุ่มเปล่า — POST /audiences เปิดช่อง file เป็น optional ถ้าไม่มีไฟล์มาเลย backend จะสร้างแถวสถานะ completed ทันทีพร้อมสถิติศูนย์ทั้งหมด และไม่ publish งานเข้าคิว (เส้นทางนี้มีไว้รองรับการสร้างกลุ่มว่างจากระบบอื่น) แต่ฝั่งหน้าเว็บจะพังก่อนถึงจุดนั้น เพราะโค้ดตอนกดยืนยันอ่าน formValue.csvDetails.file.originFileObj โดยไม่เช็ก null — ทำให้ "กดบันทึกโหมดแก้ไขโดยไม่แนบไฟล์ใหม่" เป็น JavaScript error ในเบราว์เซอร์ ไม่ใช่ข้อความ validation ที่อ่านรู้เรื่อง
  • ขนาดไฟล์ถูกจำกัดที่ backend เท่านั้น — DTO ตรวจ MaxFileSize ไว้ที่ 200,000,000 ไบต์ (ประมาณ 200 MB) เกินแล้วตอบ File size 200000000 exceeded. แต่ฝั่งหน้าเว็บ ไม่มีการตรวจขนาดไฟล์เลย ตรวจแค่ MIME type จึงเป็นไปได้ที่ผู้ใช้จะรออัปโหลดไฟล์ยักษ์จนเสร็จแล้วเพิ่งได้ error กลับมา
  • การแก้ไขเป็นการล้างแล้วสร้างใหม่ทั้งชุด ไม่ใช่การแก้บางส่วน — PUT /audiences/{id} เขียนทับ info ด้วยไฟล์ใหม่ ตั้งสถานะกลับเป็น processing แล้วส่งงานเข้าคิว update ฝั่ง worker จะจดรายชื่อสมาชิกเดิมไว้ก่อน → ถอด audience_ids ของกลุ่มนี้ออกจากผู้ใช้ทุกคน → รันขั้นตอนสร้างใหม่ทั้งหมด → เทียบว่าใครเข้าใหม่ (added) ใครหลุดออก (removed) แล้วส่งผลต่างนั้นไปให้ระบบประเมิน trigger ของ audience ต่อ — กลุ่มที่นำไปผูกกับ automation ไว้จึงถูกกระตุ้นตามคนที่เข้า/ออกจริง ไม่ใช่ตามรายชื่อทั้งชุด
  • กลุ่มของระบบภายนอกถูกล็อกด้วยรหัสข้อผิดพลาดที่ชวนเข้าใจผิด — ถ้า apiClientId > 0 ทั้ง PUT และ DELETE จะถูกปฏิเสธด้วย AUD_001 ซึ่งข้อความมาตรฐานคือ "Duplicate title" ทั้งที่เหตุผลจริงคือ "กลุ่มนี้เป็นของ 3rd party" — เป็นพฤติกรรมที่ยกมาจากต้นฉบับตรง ๆ
  • การแก้/ลบกลุ่มที่ไม่มีอยู่ได้ 500 ไม่ใช่ 404 — ต้นฉบับ TypeScript อ่าน audienceInfo.apiClientId โดยไม่เช็ก null พอร์ต Go จึงรักษาพฤติกรรมนั้นไว้ด้วยการตอบ 500 พร้อมข้อความ Cannot read properties of null (reading 'apiClientId') แบบเดียวกัน (bug parity โดยเจตนา)
  • GET /audiences/{id}/export ใช้งานไม่ได้จริงมาตั้งแต่ต้นฉบับ — ถ้าเป็นกลุ่มแบบ filter จะตอบ AUD_004 และถ้าเป็นกลุ่มแบบ CSV โค้ดจะไปอ้างฟิลด์ csvDetails.file ที่ไม่มีอยู่บน entity แล้วโยน TypeError → 500 ทุกครั้ง โดยไม่เคยเขียนไบต์ CSV ออกมาเลย ปุ่มส่งออกที่ใช้งานได้จริงคือ /export-detail ซึ่งเป็นของที่เพิ่มเข้ามาในพอร์ต Go
  • GET /audiences/{id}/member กลบข้อผิดพลาดทุกชนิดเป็น 400 เดียว — service ห่อทั้งฟังก์ชันไว้ใน try/catch ที่โยน APP_000 ออกมาเสมอ แปลว่าไม่ว่าจะเป็น "ไม่พบกลุ่มนี้" หรือ query ล้มเหลว ผู้เรียกจะเห็นแค่ 400 APP_000 เหมือนกันหมด แยกสาเหตุจากคำตอบ API ไม่ได้
  • จำนวนกลุ่มถูกจำกัดด้วยแพ็กเกจ — ทุกเส้นทางที่สร้างกลุ่มเรียก AssertQuota(ResourceSegment) ก่อนเสมอ (นับจำนวนแถวในตาราง audience ขององค์กรที่ยังไม่ถูกลบ) เกินโควตาจะถูกปฏิเสธก่อนจะแตะไฟล์หรือคิวใด ๆ

เมนูที่เกี่ยวข้องกันในเส้นทางนี้​

  • Filter audience — โหมดพี่น้องที่สร้างกลุ่มจากเงื่อนไขแทนไฟล์ ใช้ตาราง audience เดียวกันและแยกกันด้วยคอลัมน์ dataSource (csv กับ filter) เท่านั้น กลุ่มที่อัปโหลดจากไฟล์จะเปิด auto refresh ไม่ได้เด็ดขาด (ดูหัวข้อจุดที่ควรระวัง)
  • Multi-source filter — ฟอร์มสร้างกลุ่มจากหลายเงื่อนไข อยู่ในตระกูลเดียวกันแต่ผ่านโมดูล backend อีกตัว
  • Campaign — เลือกกลุ่มนี้เป็นผู้รับตอนส่งแบบ Multicast และยอด totalSuccess ของกลุ่มนี้คือตัวเลขที่แคมเปญนำไปคำนวณช่อง "ส่งแล้ว"
  • Rich menu — เลือกกลุ่มนี้เพื่อจำกัดว่าใครเห็นเมนูใด (คอลัมน์ audienceId บนเมนู)
  • Auto-response message และ Form builder — ไม่เกี่ยวกับโหมดอัปโหลดโดยตรง แต่เป็นแหล่งข้อมูลของโหมด Filter ในหน้าถัดไป

จุดที่ควรระวัง​

  • กดบันทึกสำเร็จ ≠ รายชื่อพร้อมใช้ — cms-api เพียงเก็บไฟล์และส่งงานเข้าคิว ควรรอให้สถานะเปลี่ยนเป็น "เสร็จแล้ว" และมีเลขในคอลัมน์ยอดสมาชิกก่อนนำกลุ่มไปยิงข้อความ
  • ยอดสมาชิกที่ได้จะน้อยกว่าจำนวนแถวในไฟล์แทบทุกครั้ง — เพราะรหัสผู้ใช้ที่ยังไม่เคยรู้จักในระบบและรหัสที่เป็นตัวพิมพ์ใหญ่จะถูกตัดออก ให้ดูช่องยอดสมาชิกเป็นรูปแบบ totalSuccess / totalOriginal เพื่อรู้ว่าหายไปเท่าไร
  • กลุ่มจากไฟล์ CSV ไม่รองรับ auto refresh และการกดคำนวณใหม่ — ทั้ง PATCH /audiences/:id/auto-refresh และ POST /audiences/:id/refresh ตรวจว่า dataSource ต้องเป็น filter ไม่ใช่ก็ตอบ 400 พร้อมข้อความ "Only filter-based audiences can be auto-refreshed" / "...can be refreshed" อยากได้กลุ่มที่อัปเดตตัวเองต้องใช้โหมด Filter audience หรือ Multi-source filter
  • โหมดแก้ไขเป็นการแทนที่ทั้งหมด และการกดบันทึกโดยไม่แนบไฟล์จะทำให้หน้าเว็บพัง — ไม่ใช่ error ที่อ่านรู้เรื่อง ให้ถือว่า "แก้ไข" หมายถึง "อัปโหลดไฟล์ชุดใหม่" เสมอ
  • ตัวตรวจไฟล์อ่านข้อความดิบทั้งไฟล์ ไฟล์ที่มีบรรทัดว่างปิดท้าย (ซึ่งเป็นเรื่องปกติของ editor หลายตัว) จะถูกรายงานว่า "ไม่พบ UID" ที่แถวสุดท้าย

สิทธิ์การเข้าถึง (ตรวจสอบจากซอร์สโค้ดจริง)​

  • ฝั่งหน้าเว็บ — หน้า audience ทั้งหมด ไม่ได้ถูกครอบด้วย PermissionGuard ต่างจากหน้าอื่นอีกกว่า 20 หน้าในระบบ การควบคุมเดียวที่มีคือการซ่อน/แสดงเมนูข้าง ซึ่งตรวจ ability.can('view', 'audience') — พิมพ์ URL เข้าตรง ๆ ไม่ถูกกัน จึงต้องพึ่งการบังคับที่ฝั่งเซิร์ฟเวอร์เป็นหลัก
  • ฝั่งเซิร์ฟเวอร์ — โมดูลนี้มี ModuleGate บังคับใช้จริง — RegisterRoutes ใส่ modulegate.ModuleGate(d, "audiences") ครอบกลุ่ม /audiences ทั้งกลุ่ม ทุกเส้นทาง (list, get, create, update, delete, member, member-logs, export, export-detail, auto-refresh, refresh) จึงถูกกันหมด ถ้าผู้ดูแลระดับแพลตฟอร์มปิดโมดูล audiences ให้องค์กรใด ผู้ใช้ประเภท customer ในองค์กรนั้นจะได้ 403 ทันทีแม้เรียก API ตรง (ผู้เรียกประเภท onemoby ข้ามด่านนี้ได้)
  • แต่โมดูล audiences-filter ที่เป็นพี่น้องกันไม่มี ModuleGate เลย — ดูรายละเอียดที่หน้า Filter audience และ Multi-source filter ซึ่งเป็นเรื่องที่ต้องรู้เพราะเส้นทางสร้างกลุ่มด้วยเงื่อนไขอยู่ในโมดูลนั้น
  • policy annotation ระดับการกระทำ (readAll/read/create/update/delete/export ของ subject Audiences) ถูกประกาศครบทุกเส้นทาง แต่วิ่งผ่าน auth.CheckPolicies ที่เป็น no-op เหมือนโมดูลอื่นส่วนใหญ่ — การบังคับจริงจึงอยู่ที่ ModuleGate ระดับโมดูล ไม่ใช่ที่ policy ระดับการกระทำ
  • ทุกเส้นทางอยู่หลัง JWT guard ระดับ global (ในต้นฉบับ NestJS บรรทัด @Public() ระดับคลาสถูกคอมเมนต์ทิ้งไว้ พอร์ต Go จึงลงทะเบียนบน authed group ทั้งหมด) และทุก query ถูกจำกัดขอบเขตด้วย line_oa_id + organization_id จาก CLS context เสมอ
  • การลบเป็น soft delete (ประทับ deleted_date) และทุก query ใส่ deleted_date IS NULL — ไม่มีเส้นทาง hard delete ในโมดูลนี้

ไฟล์อ้างอิงจากซอร์สโค้ด​

Frontend (cms-web-2026)

  • src/app/audience/csv/page.tsx, src/app/audience/csv/form/page.tsx, src/app/audience/csv/detail/page.tsx — จุดเข้าหน้ารายการ ฟอร์ม และหน้าสมาชิก
  • src/components/audience/csv/csv-form.container.tsx — ตัวตรวจไฟล์ (checkCorrectUID, csvCheckEmptyAndDuplicate), การประกอบ FormData
  • src/components/audience/csv/csv-table.container.tsx, csv-table.tsx — ตาราง/ตัวกรอง/ปุ่มดำเนินการ (ใช้ร่วมกับหน้า Filter audience)
  • src/components/audience/csv/modal/ — modal-confirm-upload.tsx, modal-error-upload.tsx, modal-csv-template.tsx
  • src/components/audience/csv-detail-table/filter-detail-table.container.tsx — หน้าสมาชิกและปุ่มส่งออกทั้งสองแบบ
  • src/services/audience.service.ts — endpoint ทั้งหมดที่หน้าเว็บเรียกใช้
  • messages/{th,en}/audience.json — ข้อความทั้งหมด (namespace audience)

Backend (line-management-cms-api-go)

  • internal/modules/audience/controller.go — 11 เส้นทาง, ModuleGate("audiences"), การลงทะเบียน cron
  • internal/modules/audience/service.go — ตรรกะสร้าง/แก้/ลบ, การอัปโหลดไฟล์, presigned URL, export-detail
  • internal/modules/audience/dto.go — กฎ validation ของฟอร์ม multipart และเพดานไฟล์ 200 MB
  • internal/modules/audience/scheduler.go — cron auto refresh (อธิบายที่หน้า Filter audience)

Worker (line-management-worker-go)

  • internal/audience/consumer.go — ผูกคิว create/update/delete/mookept เข้ากับ service
  • internal/audience/service.go — regex ตรวจรหัสผู้ใช้, การตัดรหัสที่ไม่รู้จัก, การเขียนไฟล์ CSV และสถิติ
  • internal/csvaudience/csvaudience.go — ตัวอ่าน CSV จาก object storage ที่มาแทน DuckDB

ดูรายละเอียดเชิงเทคนิคเพิ่มเติมที่ จัดการกลุ่มเป้าหมาย (Audience Management)