ระบบรวมแชทลูกค้า (Chat Hub)

รวมแชทจาก LINE และ Facebook ไว้ในหน้าจอเดียว แบ่งตามโปรเจกต์ ทุกครั้งที่ลูกค้าทักจะเปิดเป็น task เพื่อมอบหมายคนดูแล และบันทึกข้อมูลลูกค้า (ชื่อเล่น เบอร์ รหัส P) ไว้กับแชท เพิ่มช่องทางใหม่ได้โดยไม่ต้องแก้แกนระบบ

ฉบับร่าง v0.3, 14 ตุลาคม 2026 — React + Vite, Go, Kafka, MongoDB, Redis

สิ่งที่ตัดสินใจแล้ว

สถาปัตยกรรม
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)

ภาพรวมสถาปัตยกรรม

ตัวเลขในภาพคือลำดับการทำงาน อธิบายทีละขั้นด้านล่าง (บนจอเล็กเลื่อนภาพซ้ายขวาได้)

Go API — modular monolith binary เดียว รันได้หลาย instance HTTP layer Webhook endpoints /webhooks/{type}/{id} REST API /projects, /tasks, … WebSocket hub /ws?projectId= Domain modules Auth Lark OAuth, JWT Project โปรเจกต์และสมาชิก Contact ชื่อเล่น เบอร์ รหัส P Conversation ห้องแชทถาวร Task เปิด / มอบหมาย / ปิด Message ข้อความเข้าและออก Workers Ingest worker อ่านคิว › บันทึก › เปิด task › publish Profile worker ดึงชื่อ/รูป จำกัด 5–10 call/วินาที Channel adapters line/ Messaging API facebook/ Send API, Graph API ช่องทางใหม่ เพิ่ม package เดียว LINE OA Messaging API Facebook Page Messenger Platform ช่องทางอื่นในอนาคต Instagram, WhatsApp, เว็บแชท 1 webhook 8 Push / Send API และดึงโปรไฟล์ Lark SSO SSO ขององค์กร 9 OAuth 2.0 หน้าจอ agent React + Vite inbox · ห้องแชท · ข้อมูลลูกค้า เข้าสู่ระบบ 6 WebSocket 7 REST Kafka chat.inbound: คิว webhook (key = contact) chat.events: ให้ CRM และระบบอื่น retention 7 วัน replay ได้ Redis Pub/Sub: event ไปทุก instance Cache: session, counter MongoDB users, projects, channels, contacts, conversations, tasks, messages Object storage (S3) รูปและไฟล์จากแชท โหลดมาเก็บเอง เพราะแพลตฟอร์มเก็บให้ไม่นาน 2 produce chat.inbound แล้วตอบ 200 3 worker consume ตาม partition 5 event ไป Kafka chat.events และ Redis Pub/Sub 4 บันทึกข้อมูล รูปและไฟล์
  1. ลูกค้าทัก — LINE หรือ Facebook ยิง webhook มาที่ /webhooks/{type}/{id} ของเรา type บอกว่าใช้ adapter ตัวไหน id คือ channel ในระบบ
  2. รับ webhook — ตรวจลายเซ็นด้วย secret ของ channel → produce raw event ลง Kafka topic chat.inbound โดยใช้ contact เป็น key → รอ ack → ตอบ 200 ใช้เวลาไม่กี่มิลลิวินาที จึงรับ burst ได้โดยไม่ล้ม และ raw event อยู่ใน Kafka ตาม retention ย้อนมา replay ได้
  3. 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 ดึงชื่อและรูปแบบจำกัดอัตรา ข้อความไม่ต้องรอ
  4. บันทึก — ทุกอย่างลง MongoDB ตาม data model ด้านล่าง
  5. 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
  6. WebSocket — hub ส่ง event ให้ agent ที่เปิดโปรเจกต์นั้นอยู่ inbox อัปเดตทันทีโดยไม่ต้อง refresh
  7. Agent ทำงานผ่าน REST — ตอบข้อความ (POST /tasks/{id}/messages), มอบหมาย, ปิด task, แก้ข้อมูลลูกค้า ทุกการเปลี่ยนแปลงวนกลับไปข้อ 5–6 ให้ทุกคนเห็นตรงกัน
  8. ส่งออกไปแพลตฟอร์ม — บันทึก message สถานะ pending → adapter.Send เรียก LINE Push API หรือ Facebook Send API → อัปเดตเป็น sent หรือ failed พร้อมเหตุผล Profile worker ใช้ทางเดียวกันนี้ดึงโปรไฟล์
  9. เข้าสู่ระบบ — หน้าจอ 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

ถึงเวลาแยกเมื่อ 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 อย่างไร

จุดที่ burst ทำให้ต้องระวัง

ข้อจำกัดของแพลตฟอร์ม

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 กับลูกค้า

users (agent) _id larkOpenId name email avatar projects _id name members[{userId, role}] channels _id projectId type (line / facebook) externalId credentials (เข้ารหัส) status contacts (ลูกค้า) _id projectId channelId externalUserId displayName meta.nickname meta.phone meta.pCode (ซ้ำได้) meta.note tags[] tasks _id projectId conversationId contactId assigneeId status openedAt closedAt closedBy unreadCount conversations _id projectId channelId contactId activeTaskId lastMessageAt lastMessagePreview messages _id conversationId taskId direction (in / out) senderType senderId type text attachments[] externalMsgID deliveryStatus createdAt members[] projectId channelId contactId (1:1) conversationId conversationId taskId (ข้อความในช่วง task นั้น) assigneeId, closedBy หนึ่ง → หลาย ฟิลด์สีเขียว = อ้างอิง collection อื่น
ไม่ได้ลากเส้น projectId ทุกเส้นเพื่อไม่ให้รก

Index และหมายเหตุ

CollectionหมายเหตุIndex
usersagent ที่ login ด้วย Larkunique larkOpenId
projectsmembers เก็บ role ไว้เผื่อ phase 2members.userId
channelscredentials เข้ารหัสunique (type, externalId)
contacts1 คนต่อช่องทางต่อโปรเจกต์ รหัส P ซ้ำได้unique (channelId, externalUserId), (projectId, meta.pCode), (projectId, meta.phone)
conversations1 ห้องต่อ contact ไม่มีคนดูแลติดอยู่unique contactId, (projectId, lastMessageAt)
tasksผูก agent กับลูกค้าเป็นช่วงเวลา(projectId, status, openedAt), (assigneeId, status), (conversationId, openedAt)
messagestaskId ว่างได้ถ้าข้อความมานอกช่วง task รูปและไฟล์เก็บเป็น URL ของ object storage(conversationId, createdAt), unique externalMsgID (sparse)

รหัส P เป็นข้อความอิสระและซ้ำได้ ลูกค้าเปลี่ยน LINE หรือทักจาก Facebook ก็ใส่รหัสเดิม เมื่อกรอก ระบบจะหา contact อื่นที่ใช้รหัสเดียวกันมาแสดงว่า "มีแชทเก่าที่..." ที่แยกเป็นฟิลด์ไม่ฝังใน note เพราะ MongoDB ค้นหาข้อความไทยในช่องอิสระได้ไม่ดี ฟิลด์แยกค้นหาผ่าน index ได้ทันที

ประวัติแชทเก่าและขนาดข้อมูล

ต้องเก็บข้อความเองทั้งหมด ไม่ใช่เพราะอยากเก็บ แต่เพราะแพลตฟอร์มไม่ได้เก็บให้ใช้

จะบวมไหม

ข้อความ 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

  1. open — ระบบเปิดให้อัตโนมัติเมื่อลูกค้าทักและ conversation ไม่มี task ค้าง ยังไม่มีคนดูแล ถ้าเพิ่งปิด task ไปไม่เกิน grace period (เช่น 10 นาที ตั้งค่าได้ต่อโปรเจกต์) จะเปิด task เดิมกลับมาแทน กัน "ขอบคุณค่ะ" กลายเป็น task ใหม่
  2. in_progress — PATCH /tasks/{id}/assign จากการกดรับเอง หัวหน้ามอบหมาย หรือ round robin เปลี่ยนคนได้ระหว่างทาง
  3. done — PATCH /tasks/{id}/close → ล้าง activeTaskId ของ conversation คนดูแลหลุดออก ประวัติแชทยังอยู่ครบ ลูกค้าทักใหม่จะได้ task ใบใหม่

เปิด task ด้วยมือได้เช่นกัน (POST /conversations/{id}/tasks) สำหรับกรณีทีมอยากทักลูกค้าก่อน

API หลัก

Endpointทำอะไร
GET /auth/lark/login, /auth/lark/callbackLogin ผ่าน 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, ห้องแชท, ข้อมูลลูกค้า

Task
ยังไม่ได้มอบหมาย (12) | ของฉัน (3) | ปิดแล้ว
พลอย — สนใจสินค้าตัวนี้...
Bank — ส่งของหรือยังครับ
Nan — ขอบคุณค่า
พลอย task #1042 ดูแลโดย: สมชาย
สนใจสินค้าตัวนี้ค่ะ ยังมีไหมคะ
มีค่ะ รับกี่ชิ้นดีคะ
พิมพ์ข้อความ...
[เปลี่ยนคนดูแล] [ปิด task]
ข้อมูลลูกค้า
ชื่อ LINEPloy P. ชื่อเล่นพลอย เบอร์08x-xxx-xxxx รหัส PP-1042 noteชอบโอนผ่าน K+
มีแชทเก่าที่ LINE เดิม (ปิดเมื่อ มิ.ย.)
task ก่อนหน้า
#0871 ปิดโดย น้ำ, 2 ก.ย.
#0512 ปิดโดย สมชาย, 14 ก.ค.

แบ่ง 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

ทำตั้งแต่วันแรก