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. อัปโหลดไฟล์สร้างกลุ่มใหม่
-
กดปุ่ม Upload CSV เข้าสู่ฟอร์ม
-
กรอกชื่อและคำอธิบาย — บังคับทั้งคู่ (backend ตรวจ
IsNotEmptyทั้งสองช่อง) ชื่อต้องไม่ซ้ำกับกลุ่มอื่นใน LINE OA เดียวกัน -
เตรียมไฟล์ — มีแผงพับ "CSV file guideline" อธิบาย 5 ขั้นตอน พร้อมปุ่มดาวน์โหลดไฟล์ต้นแบบ (
/template/audiences_template.csvซึ่งมีแต่หัวคอลัมน์Line UserIDบรรทัดเดียว) -
ลากไฟล์ลงพื้นที่อัปโหลด — รับเฉพาะไฟล์ที่ MIME type มีคำว่า
text/csvระบบตรวจไฟล์ฝั่งหน้าเว็บทันทีก่อนส่ง โดยไล่ทีละแถวหาปัญหา 3 แบบ- แถวว่าง → "ไม่พบ UID"
- รหัสผู้ใช้ซ้ำ →
"พบ UID ซ้ำกับแถวที่ {row}" - รหัสผู้ใช้ผิดรูปแบบ →
"รูปแบบ UID ไม่ถูกต้องในแถวที่ {row}"
ถ้าพบข้อผิดพลาดจะเปิดกล่อง "ข้อมูลไม่ถูกต้อง" แสดงตารางแถว/คำอธิบาย และ ไม่รับไฟล์นั้นไว้เลย ต้องไปแก้ไฟล์แล้วลากเข้ามาใหม่ (เลขแถวที่รายงานนับรวมบรรทัดหัวคอลัมน์เป็นแถวที่ 1)
-
ยืนยัน — เมื่อผ่านการตรวจ ระบบเปิดกล่องยืนยันระบุ
"วิเคราะห์รูปแบบพบ {count} แถว"ถ้าเป็นการแก้ไขกลุ่มเดิมจะเพิ่มบรรทัด "ต้องการแทนที่ข้อมูลเดิมหรือไม่?" เพราะโหมดแก้ไขเป็นการ แทนที่รายชื่อทั้งหมด ไม่ใช่การเพิ่มต่อท้าย -
ยืนยันแล้วหน้าเว็บส่ง
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ซึ่งเป็นของที่เพิ่มเข้ามาในพอร์ต GoGET /audiences/{id}/memberกลบข้อผิดพลาดทุกชนิดเป็น 400 เดียว — service ห่อทั้งฟังก์ชันไว้ใน try/catch ที่โยนAPP_000ออกมาเสมอ แปลว่าไม่ว่าจะเป็น "ไม่พบกลุ่มนี้" หรือ query ล้มเหลว ผู้เรียกจะเห็นแค่ 400APP_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ของ subjectAudiences) ถูกประกาศครบทุกเส้นทาง แต่วิ่งผ่าน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), การประกอบ FormDatasrc/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.tsxsrc/components/audience/csv-detail-table/filter-detail-table.container.tsx— หน้าสมาชิกและปุ่มส่งออกทั้งสองแบบsrc/services/audience.service.ts— endpoint ทั้งหมดที่หน้าเว็บเรียกใช้messages/{th,en}/audience.json— ข้อความทั้งหมด (namespaceaudience)
Backend (line-management-cms-api-go)
internal/modules/audience/controller.go— 11 เส้นทาง,ModuleGate("audiences"), การลงทะเบียน croninternal/modules/audience/service.go— ตรรกะสร้าง/แก้/ลบ, การอัปโหลดไฟล์, presigned URL,export-detailinternal/modules/audience/dto.go— กฎ validation ของฟอร์ม multipart และเพดานไฟล์ 200 MBinternal/modules/audience/scheduler.go— cron auto refresh (อธิบายที่หน้า Filter audience)
Worker (line-management-worker-go)
internal/audience/consumer.go— ผูกคิว create/update/delete/mookept เข้ากับ serviceinternal/audience/service.go— regex ตรวจรหัสผู้ใช้, การตัดรหัสที่ไม่รู้จัก, การเขียนไฟล์ CSV และสถิติinternal/csvaudience/csvaudience.go— ตัวอ่าน CSV จาก object storage ที่มาแทน DuckDB
ดูรายละเอียดเชิงเทคนิคเพิ่มเติมที่ จัดการกลุ่มเป้าหมาย (Audience Management)