ระบบรวมแชทลูกค้า (Chat Hub)
รวมแชทจาก LINE และ Facebook ไว้ในหน้าจอเดียว แบ่งตามโปรเจกต์ ทุกครั้งที่ลูกค้าทักจะเปิดเป็น task เพื่อมอบหมายคนดูแล และบันทึกข้อมูลลูกค้า (ชื่อเล่น เบอร์ รหัส P) ไว้กับแชท เพิ่มช่องทางใหม่ได้โดยไม่ต้องแก้แกนระบบ
สิ่งที่ตัดสินใจแล้ว
- สถาปัตยกรรม
- Modular monolith — Go binary ตัวเดียว แบ่งโมดูลชัด ดึงออกเป็น service ทีหลังได้
- Traffic
- รับ webhook เองทั้งหมด — ไม่มีทางเลือกอื่น Go instance เดียวบวก Kafka รับ burst 5,000 แชทได้
- ช่องทางแชท
- Channel adapter — เพิ่มช่องทางใหม่ = เพิ่ม package เดียว แกนระบบไม่รู้จัก LINE หรือ Facebook
- คนดูแล
- ผูกผ่าน task — ลูกค้าทัก → เปิด task → มอบหมาย → ปิด task แล้วคนดูแลหลุด ประวัติแชทยังอยู่
- Realtime
- WebSocket + Redis pub/sub — รันหลาย instance ได้
- ประวัติแชท
- เก็บเองทั้งหมด — LINE ไม่มี API ดึงย้อนหลัง Facebook ดึงได้ตอนเริ่มต่อ Page ข้อความ 1 ปีใช้พื้นที่ราว 1 GB ไม่บวม
- ฐานข้อมูล
- Kafka (ที่องค์กรมีอยู่แล้ว) เป็นคิว webhook และ event bus, MongoDB เก็บข้อมูล, Redis ทำ pub/sub และ cache, Object storage เก็บรูปและไฟล์
- เข้าสู่ระบบ
- Lark OAuth 2.0 → JWT ใน httpOnly cookie (role และ permission ไว้ phase 2)
ภาพรวมสถาปัตยกรรม
ตัวเลขในภาพคือลำดับการทำงาน อธิบายทีละขั้นด้านล่าง (บนจอเล็กเลื่อนภาพซ้ายขวาได้)
- ลูกค้าทัก — LINE หรือ Facebook ยิง webhook มาที่
/webhooks/{type}/{id}ของเราtypeบอกว่าใช้ adapter ตัวไหนidคือ channel ในระบบ - รับ webhook — ตรวจลายเซ็นด้วย secret ของ channel → produce raw event ลง Kafka topic
chat.inboundโดยใช้ contact เป็น key → รอ ack → ตอบ 200 ใช้เวลาไม่กี่มิลลิวินาที จึงรับ burst ได้โดยไม่ล้ม และ raw event อยู่ใน Kafka ตาม retention ย้อนมา replay ได้ - Ingest worker (goroutine ใน binary เดียวกัน) เป็น Kafka consumer group ของ
chat.inboundแต่ละ partition มี worker เดียว ข้อความของลูกค้าคนเดียวกันจึงถูกประมวลผลตามลำดับ ไม่เปิด task ซ้ำ จากนั้นadapter.ParseEvents→ upsert contact → หาหรือสร้าง conversation → insert message (กันซ้ำด้วย externalMsgID) → เปิด task ใหม่หรืออัปเดต task ที่ค้างอยู่ รูปและไฟล์ดาวน์โหลดไปเก็บใน object storage ถ้าเป็นลูกค้าใหม่ ส่งงานต่อให้ Profile worker ดึงชื่อและรูปแบบจำกัดอัตรา ข้อความไม่ต้องรอ - บันทึก — ทุกอย่างลง MongoDB ตาม data model ด้านล่าง
- Publish event — worker ส่ง domain event (message.created, task.opened, task.assigned, …) ไป 2 ทาง: Kafka topic
chat.eventsให้ CRM, BI หรือระบบอื่น consume และ replay ได้เอง และ Redis Pub/Sub สำหรับ WebSocket ที่ต้องการ latency ต่ำและถึงทุก instance - WebSocket — hub ส่ง event ให้ agent ที่เปิดโปรเจกต์นั้นอยู่ inbox อัปเดตทันทีโดยไม่ต้อง refresh
- Agent ทำงานผ่าน REST — ตอบข้อความ (
POST /tasks/{id}/messages), มอบหมาย, ปิด task, แก้ข้อมูลลูกค้า ทุกการเปลี่ยนแปลงวนกลับไปข้อ 5–6 ให้ทุกคนเห็นตรงกัน - ส่งออกไปแพลตฟอร์ม — บันทึก message สถานะ pending →
adapter.Sendเรียก LINE Push API หรือ Facebook Send API → อัปเดตเป็น sent หรือ failed พร้อมเหตุผล Profile worker ใช้ทางเดียวกันนี้ดึงโปรไฟล์ - เข้าสู่ระบบ — หน้าจอ redirect ไป Lark → Lark ส่ง code กลับที่
/auth/lark/callback→ backend แลก code เป็น token ดึงข้อมูลผู้ใช้ → ออก JWT ใส่ httpOnly cookie
Scale: รัน binary นี้ 2 instance หลัง load balancer ต่อ Kafka, Redis และ Mongo ชุดเดียวกัน webhook เข้า instance ไหนก็ได้ ตั้ง chat.inbound ไว้ 12 partition ขยาย worker ได้ถึง 12 ตัวโดยลำดับต่อลูกค้ายังถูกต้อง ส่วน event ที่ parse ไม่ผ่านจะส่งเข้า chat.inbound.dlq แล้วแจ้งเตือน ไม่บล็อกคิว
ทำไม modular monolith
- ทีมเล็กและเพิ่งเริ่ม: deploy และ monitor ชิ้นเดียว ไม่ต้องทำ service discovery หรือ tracing ข้าม service
- ข้อมูลผูกกันแน่น (task, conversation, message, contact) ถ้าแยก service จะเรียกข้ามกันแทบทุก request
- โหลดเป็น burst ไม่ใช่โหลดต่อเนื่อง queue ซับได้ ยังห่างจากคอขวดมาก
- กติกาที่ทำให้แยกทีหลังได้: แต่ละโมดูลอยู่ใน package ของตัวเอง คุยกันผ่าน interface ห้ามอ่าน collection ของโมดูลอื่น
ถึงเวลาแยกเมื่อ webhook เกินหลักร้อยข้อความต่อวินาทีต่อเนื่อง หรือหลายทีม deploy ชนกันบ่อย ตัวแรกที่ดึงออกคือ ingest
Traffic: ลูกค้า 2 ล้านคน, burst 5,000 แชทใน 1–2 ชั่วโมง
ช่วง burst (ราวเดือนละ 2 ครั้ง หลัง broadcast) ประมาณ 5,000 แชท × 4–5 ข้อความ ≈ 25,000 ข้อความใน 1–2 ชั่วโมง เฉลี่ย 5–7 ข้อความต่อวินาที นาทีแรกอาจพุ่ง 100–200 ต่อวินาที Go รับ webhook ระดับนี้ได้ด้วย instance เดียว Kafka ซับ spike ให้ worker ค่อยไล่ ไม่ต้องสร้างระบบใหญ่ตาม peak
ฐานลูกค้า 2 ล้านคนไม่ใช่ภาระโดยตรง contact ถูกสร้างเมื่อลูกค้าทักครั้งแรกเท่านั้น
รับ webhook อย่างไร
- Handler ทำแค่ 3 อย่าง: ตรวจลายเซ็น → produce ลง Kafka → ตอบ 200 (ถ้าตอบช้าบ่อย Meta จะปิด webhook ส่วน LINE จะ retry)
- กันบันทึกซ้ำด้วย message id ของแพลตฟอร์ม เพราะทั้งคู่มี retry
- Raw event อยู่ใน Kafka ตาม retention (7 วัน) parse พลาดก็ย้อน replay ได้ ไม่ต้องมี collection เก็บ raw แยก
จุดที่ burst ทำให้ต้องระวัง
- ดึงโปรไฟล์ — webhook ไม่มีชื่อลูกค้ามาด้วย ต้องเรียก API แยก ลูกค้าใหม่ 5,000 คน = 5,000 call ชน rate limit แน่ ให้แยกเป็นอีก queue จำกัด 5–10 call ต่อวินาที และแสดง "ลูกค้าใหม่" ใน inbox ไปก่อน
- Task ค้าง 5,000 ใบ — รายการต้องแบ่งหน้าด้วย cursor และ virtualize, เก็บ unreadCount เป็นตัวเลขแทนการนับทุกครั้ง, และ round robin ควรอยู่ใน MVP เพราะกดรับทีละใบไม่ทัน
- รัน 2 instance — เพื่อไม่ล่มตอน deploy ไม่ใช่เพื่อรับโหลด
ข้อจำกัดของแพลตฟอร์ม
- LINE — reply token หมดอายุเร็ว agent ต้องตอบด้วย Push Message API ซึ่งนับโควตาตามแพ็กเกจ OA ประมาณ 3 push ต่อแชท × 5,000 × 2 ครั้ง ≈ 30,000 push ต่อเดือน ยืนยันกับยอดจริงก่อนเลือกแพ็กเกจ
- Facebook — ตอบได้ภายใน 24 ชั่วโมง หลังลูกค้าทักล่าสุด เกินนั้นต้องใช้ message tag (
HUMAN_AGENTต้องขออนุมัติ) หน้าจอควรบอกว่าแชทไหนหมดช่วงตอบแล้ว
Channel adapter
แกนระบบรู้จักแค่ interface นี้ LINE, Facebook และช่องทางใหม่เป็น package ที่ implement มัน
// internal/channel/adapter.go type Adapter interface { // ขาเข้า VerifySignature(r *http.Request, body []byte) error ParseEvents(body []byte) ([]InboundEvent, error) FetchProfile(ctx context.Context, externalUserID string) (Profile, error) // ขาออก Send(ctx context.Context, to string, msg Outbound) (externalMsgID string, err error) } type InboundEvent struct { ExternalUserID string // LINE userId / Facebook PSID ExternalMsgID string // กันบันทึกซ้ำ Type string // text, image, sticker, file Text string MediaURL string Timestamp time.Time } // ลงทะเบียนตอน start: registry["line"] = line.New(cfg), registry["facebook"] = facebook.New(cfg) // router เลือก adapter จาก path /webhooks/{type}/{channelId}
Credential ของแต่ละ channel เก็บแบบเข้ารหัส (AES-GCM, key จาก env หรือ KMS)
Data model (MongoDB)
ลูกค้า 1 คน = 1 contact = 1 conversation (ห้องแชทถาวร) ส่วน task เปิดปิดได้หลายใบ และเป็นตัวเดียวที่ผูก agent กับลูกค้า
Index และหมายเหตุ
| Collection | หมายเหตุ | Index |
|---|---|---|
users | agent ที่ login ด้วย Lark | unique larkOpenId |
projects | members เก็บ role ไว้เผื่อ phase 2 | members.userId |
channels | credentials เข้ารหัส | unique (type, externalId) |
contacts | 1 คนต่อช่องทางต่อโปรเจกต์ รหัส P ซ้ำได้ | unique (channelId, externalUserId), (projectId, meta.pCode), (projectId, meta.phone) |
conversations | 1 ห้องต่อ contact ไม่มีคนดูแลติดอยู่ | unique contactId, (projectId, lastMessageAt) |
tasks | ผูก agent กับลูกค้าเป็นช่วงเวลา | (projectId, status, openedAt), (assigneeId, status), (conversationId, openedAt) |
messages | taskId ว่างได้ถ้าข้อความมานอกช่วง task รูปและไฟล์เก็บเป็น URL ของ object storage | (conversationId, createdAt), unique externalMsgID (sparse) |
รหัส P เป็นข้อความอิสระและซ้ำได้ ลูกค้าเปลี่ยน LINE หรือทักจาก Facebook ก็ใส่รหัสเดิม เมื่อกรอก ระบบจะหา contact อื่นที่ใช้รหัสเดียวกันมาแสดงว่า "มีแชทเก่าที่..." ที่แยกเป็นฟิลด์ไม่ฝังใน note เพราะ MongoDB ค้นหาข้อความไทยในช่องอิสระได้ไม่ดี ฟิลด์แยกค้นหาผ่าน index ได้ทันที
ประวัติแชทเก่าและขนาดข้อมูล
ต้องเก็บข้อความเองทั้งหมด ไม่ใช่เพราะอยากเก็บ แต่เพราะแพลตฟอร์มไม่ได้เก็บให้ใช้
- LINE — ไม่มี API ดึงข้อความย้อนหลัง ได้แค่ webhook ตอนข้อความเข้ามา ประวัติในระบบเราจึงเริ่มนับจากวันที่ต่อ webhook ควรต่อให้เร็วที่สุดแม้หน้าจอยังไม่เสร็จ เพื่อสะสมข้อมูลไว้ก่อน
- Facebook — ดึงได้ผ่าน Conversations API ของ Graph API (รายการ conversation ของ Page และข้อความในแต่ละ conversation) ทำเป็น job import ครั้งเดียวตอนเพิ่ม Page เข้าโปรเจกต์ ไล่ทีละหน้าตาม rate limit
- รูปและไฟล์ — ทั้งสองเจ้าให้ดาวน์โหลดได้แค่ช่วงเวลาหนึ่ง worker ต้องโหลดมาเก็บใน object storage ทันที แล้วบันทึก URL ของเราใน message
จะบวมไหม
ข้อความ text 1 รายการประมาณ 1 KB ถ้า burst ละ 25,000 ข้อความ เดือนละ 2 ครั้ง บวกช่วงปกติ รวมราว 100,000 ข้อความต่อเดือน = 100 MB ต่อเดือน หรือประมาณ 1 GB ต่อปี MongoDB กับ index ที่วางไว้รองรับหลักร้อยล้าน document ได้ ส่วนที่กินพื้นที่จริงคือรูป ถ้า 10% ของข้อความเป็นรูปขนาด 200 KB จะราว 2 GB ต่อเดือน ซึ่งอยู่ใน object storage ไม่ใช่ Mongo ตั้ง lifecycle ย้ายไฟล์เก่ากว่า 1 ปีไป cold storage ได้
หน้าจอโหลดข้อความแบบแบ่งหน้าด้วย cursor (?before=) ครั้งละ 50 รายการ ไม่ดึงทั้งห้อง ห้องที่คุยกันมา 3 ปีก็เปิดเร็วเท่าห้องใหม่
วงจรชีวิตของ task
- open — ระบบเปิดให้อัตโนมัติเมื่อลูกค้าทักและ conversation ไม่มี task ค้าง ยังไม่มีคนดูแล ถ้าเพิ่งปิด task ไปไม่เกิน grace period (เช่น 10 นาที ตั้งค่าได้ต่อโปรเจกต์) จะเปิด task เดิมกลับมาแทน กัน "ขอบคุณค่ะ" กลายเป็น task ใหม่
- in_progress —
PATCH /tasks/{id}/assignจากการกดรับเอง หัวหน้ามอบหมาย หรือ round robin เปลี่ยนคนได้ระหว่างทาง - done —
PATCH /tasks/{id}/close→ ล้าง activeTaskId ของ conversation คนดูแลหลุดออก ประวัติแชทยังอยู่ครบ ลูกค้าทักใหม่จะได้ task ใบใหม่
เปิด task ด้วยมือได้เช่นกัน (POST /conversations/{id}/tasks) สำหรับกรณีทีมอยากทักลูกค้าก่อน
API หลัก
| Endpoint | ทำอะไร |
|---|---|
GET /auth/lark/login, /auth/lark/callback | Login ผ่าน Lark |
GET/POST /projects | รายการ / สร้างโปรเจกต์ |
POST /projects/{id}/channels | เพิ่ม LINE OA หรือ Facebook Page |
POST /webhooks/{type}/{channelId} | รับ webhook (public, ตรวจลายเซ็น) |
GET /projects/{id}/tasks?status=&assignee=&channel= | รายการ task ใน inbox |
GET /tasks/{id} | รายละเอียด task พร้อม contact |
POST /tasks/{id}/messages | ส่งข้อความตอบ |
PATCH /tasks/{id}/assign | กำหนดหรือเปลี่ยนคนดูแล |
PATCH /tasks/{id}/close | ปิด task |
POST /conversations/{id}/tasks | เปิด task ด้วยมือ (ทีมอยากทักลูกค้าก่อน) |
GET /conversations/{id}/messages?before= | ประวัติข้อความทั้งหมด ข้ามทุก task |
GET /conversations/{id}/tasks | ประวัติ task ของลูกค้า |
PATCH /contacts/{id}/meta | แก้ชื่อเล่น เบอร์ รหัส P, note |
GET /projects/{id}/contacts?q= | ค้นหาลูกค้าจากรหัส P, เบอร์, ชื่อ |
WS /ws?projectId= | รับ event แบบ realtime |
โครงสร้าง repo
chat-hub/ ├─ cmd/server/main.go # binary เดียว รัน API + worker + WebSocket ├─ internal/ │ ├─ auth/ # Lark OAuth, JWT, middleware │ ├─ project/ │ ├─ channel/ │ │ ├─ adapter.go # interface กลาง + registry │ │ ├─ line/ │ │ └─ facebook/ │ ├─ contact/ # ลูกค้า + ชื่อเล่น เบอร์ รหัส P note │ ├─ conversation/ # ห้องแชทถาวร │ ├─ task/ # เปิด / มอบหมาย / ปิด, grace period │ ├─ message/ │ ├─ ingest/ # webhook handler + Kafka consumer + DLQ │ ├─ history/ # import แชทเก่าจาก Facebook │ └─ realtime/ # WebSocket hub + Redis pub/sub ├─ pkg/ # kafka, mongo, redis, s3, config, logger, crypto └─ web/ # React + Vite
หน้าจอ agent (React + Vite)
หน้าหลักคือ inbox 3 คอลัมน์ต่อโปรเจกต์: รายการ task, ห้องแชท, ข้อมูลลูกค้า
- Route:
/login,/projects,/p/{projectId}/inbox - รายการซ้ายคือ task ปิดแล้วจะหายจาก "ของฉัน" แต่แชทยังเปิดดูได้จากประวัติลูกค้า
- TanStack Query ดึงข้อมูล WebSocket อัปเดต cache โดยตรง
- Zustand เก็บ state เบาๆ: task ที่เปิดอยู่, สถานะเชื่อมต่อ, filter
- ส่งข้อความแบบ optimistic แสดงทันทีแล้วค่อยอัปเดตสถานะ
แบ่ง phase
Phase 1 (MVP)
- Login ด้วย Lark
- โปรเจกต์ + LINE OA + Facebook Page (import แชทเก่าจาก Facebook)
- เปิด task อัตโนมัติ พร้อม grace period
- มอบหมาย / กดรับ / เปลี่ยน / ปิด task + round robin
- Inbox realtime ตอบข้อความและรูป
- ข้อมูลลูกค้า + ประวัติ task
Phase 2
- Role และ permission
- Task เพิ่ม priority, category, SLA
- มอบหมายตามเวรหรือความถนัด โอนข้ามทีม
- ข้อความสำเร็จรูป, tag, ค้นหา
- รวมลูกค้าข้ามช่องทางด้วยรหัส P
- Instagram, WhatsApp, เว็บแชท
- รายงานจาก task: เวลารับ เวลาปิด จำนวนต่อ agent
ทำตั้งแต่วันแรก
- ตรวจลายเซ็น webhook ทุกครั้ง: LINE ใช้
X-Line-Signature(HMAC-SHA256 กับ channel secret), Facebook ใช้X-Hub-Signature-256(กับ app secret) - Structured log และ metrics: webhook ต่อวินาที, เวลาประมวลผล, ส่งล้มเหลว, consumer lag ของ Kafka
- Rate limit ที่ endpoint ส่งข้อความ
- เก็บ payload จริงไว้เป็น fixture จะได้ทดสอบโดยไม่พึ่ง LINE / Facebook
- ต่อ webhook ของ LINE ตั้งแต่วันแรกที่ได้ channel เพื่อเริ่มสะสมประวัติ