LINE Connectivity — Architecture ปัจจุบัน
ภาพรวมสถาปัตยกรรม LINE Connectivity หลังเปลี่ยนเป็นโมเดล 2 แกน (Email + Company) — component, data model, กฎแข็ง, contract, error code, และของที่ยังไม่มี
อัปเดต: 2026-08-07
อ้างอิงจากโค้ดจริงที่ merge เข้า
developmentและรันอยู่บน dev แล้ว (PR #1977 / #1978, 06/08/2569) ฝั่ง FE =Frontend_LINE_Connectivitybranchfeature/line-connectivity-register(d16b347, ยังไม่เปิด PR ตามที่ Owner สั่ง) ยืนยันด้วยการเดินหน้าจอจริงทั้งชุด 07/08/2569 — ดู “ของที่ยังไม่มี / ยังไม่ได้ทำ” ท้ายหน้าเอกสารคู่กัน: ทุก Scenario + Sequence Diagram
1. LINE Connectivity คืออะไร
ลูกค้านิติบุคคลผูกบัญชี LINE ของตัวเองเข้ากับบัญชี SuperApp เพื่อรับข่าวสารผ่าน LINE แทน/เพิ่มเติมจากอีเมล
เปิดจากใน LINE เท่านั้น (LIFF) — ทั้ง /register และ /unsubscribe มี lineClientGuard ครอบ ถ้าเปิดจาก browser ปกติจะเด้งไปหน้า /open-in-line
ไม่มี login — ทั้ง flow เป็น anonymous ไม่มี Entra / JWT / cookie ตัวที่ทำหน้าที่ “ใบสั่งงาน” คือ instanceId (GUID) ที่ backend ออกให้ตอนเริ่ม flow
2. แกนความคิดหลัก — บัญชีมี 2 แกน
นี่คือหัวใจของการเปลี่ยนรอบนี้ ถ้าเข้าใจข้อนี้ข้อเดียว ที่เหลือตามได้หมด
เดิม ระบบมองว่า 1 อีเมล = 1 บัญชี ตอนนี้ บัญชี = คู่ (Email, Company)
somchai@abc.com + บริษัท A ← นี่คือ 1 บัญชี
somchai@abc.com + บริษัท B ← นี่คืออีก 1 บัญชี คนละใบกัน
จากนิยามนี้ได้กฎ 2 ข้อที่เป็นภาพกลับด้านกัน:
flowchart LR
subgraph R1["กฎ 1 — คู่หนึ่ง ผูกได้บัญชี LINE เดียว"]
P1["somchai@abc.com<br/>+ บริษัท A"] -->|ผูกอยู่| L1["LINE: qa1111"]
P1 -.->|"❌ ผูกซ้ำไม่ได้<br/>ต้องยกเลิกก่อน"| L2["LINE: qa2222"]
end
flowchart LR
subgraph R2["กฎ 2 — บัญชี LINE เดียว ผูกได้หลายคู่"]
LN["LINE: qa1111"] --> A["somchai@abc.com + บริษัท A"]
LN --> B["somchai@abc.com + บริษัท B"]
LN --> C["somsri@xyz.com + บริษัท C"]
LN --> D["...สูงสุด 20 คู่"]
end
พูดเป็นภาษาคน: พนักงานคนหนึ่งที่ดูแลหลายบริษัท ใช้ LINE ส่วนตัวเบอร์เดียวรับข่าวของทุกบริษัทที่ตัวเองดูแลได้ แต่บริษัทหนึ่ง+อีเมลหนึ่ง จะมี LINE ที่รับข่าวได้แค่เครื่องเดียว ไม่ใช่ใครก็มาผูกทับได้
3. Component — ใครคุยกับใคร
flowchart TB
subgraph LINE["LINE Platform"]
LIFF["LIFF SDK<br/>getProfile / getIDToken"]
VERIFY["api.line.me<br/>/oauth2/v2.1/verify"]
end
FE["Frontend_LINE_Connectivity<br/>(Angular 21 standalone, ใน LIFF)"]
subgraph US["Backend_UserService"]
CTRL["LineConnectivityController<br/>(AllowAnonymous)"]
DRV["LineFlowDriver<br/>ขับเฉพาะ LINE_CONNECT / LINE_UNSUBSCRIBE"]
H["Step Handlers ×6"]
VER["HttpLineIdTokenVerifier"]
DB[("AuthDb<br/>LineConnectivitySubscriptions<br/>LineConnectivityConsents<br/>FlowInstance / FlowInstanceStepData")]
end
TP["Backend_ThirdPartyService<br/>fx-registration-profile/{juristicId}"]
CEN["Backend_Centralized<br/>terms/current"]
NS["Backend_NotificationService<br/>OTP internal-generate"]
FE -->|"ขอ idToken"| LIFF
FE -->|"ค้นชื่อบริษัท"| TP
FE -->|"โหลดข้อกำหนด"| CEN
FE -->|"start / execute step / get state"| CTRL
CTRL --> DRV --> H
H --> VER -->|"verify id_token + client_id"| VERIFY
H -->|"ส่ง/ตรวจ OTP"| NS
H -->|"ดึง terms มา verify + hash"| CEN
H --> DB
:::details ทำไม FE ถึงเรียก Centralized เองด้วย ทั้งที่ backend ก็เรียก
FE เรียกเพื่อเอาเนื้อหามาแสดง (ต้องให้ผู้ใช้อ่านได้) ส่วน backend เรียกเพื่อพิสูจน์ ว่าเวอร์ชันที่ผู้ใช้กดยอมรับ ตรงกับฉบับที่ published อยู่จริง แล้วเก็บ SHA-256 ของเนื้อหาที่ server ดึงเอง ลงเป็นหลักฐาน
ถ้าเชื่อค่าที่ FE ส่งมาอย่างเดียว หลักฐานจะเป็นแค่ “คำกล่าวอ้างของ client” ไม่ใช่หลักฐาน :::
⚠️ ข้อควรรู้: LINE Connectivity ไม่ได้ ใช้
Backend_ConsentService(OneTrust) เลย — integration ตัวนั้นใน UserService เป็น dead code, path constant ผิด, config ว่างทุก env ที่ใช้จริงคือBackend_Centralizedterms/currentซึ่งมีข้อดีกว่าคือ แก้ทับเวอร์ชันที่ publish แล้วไม่ได้ (ต้องออกเวอร์ชันใหม่) — คุณสมบัติที่จำเป็นสำหรับหลักฐาน
4. Data model
erDiagram
LineConnectivitySubscriptions ||--o{ LineConnectivityConsents : "มีหลักฐาน"
LineConnectivitySubscriptions {
uuid Id PK
varchar Email "normalized: trim + lowercase"
varchar JuristicId "13 หลัก"
varchar LineUserId "จาก ID token ที่ verify แล้ว, null ได้"
uuid CompanyId "null ได้"
varchar CompanyNameTh "snapshot ตอนสมัคร"
varchar CompanyNameEn
varchar Status "Active | Inactive"
varchar TermsVersion "เพื่อแสดงผล"
uuid FlowInstanceId
timestamptz DeactivatedAt
}
LineConnectivityConsents {
uuid Id PK
uuid SubscriptionId FK
uuid FlowInstanceId "กัน retry ซ้ำ"
varchar Email
uuid UserId "SuperApp user ณ ตอนนั้น"
varchar LineUserId
varchar AssertedJuristicId "ยอมรับ*ในนาม*นิติบุคคลใด"
uuid CompanyId
uuid UserCompanyMappingId "membership ที่ให้อำนาจ"
varchar MembershipStatusAtConsent "สถานะ ณ วันนั้น"
varchar ActingCapacity "JuristicRepresentative"
varchar AssertionConsentCode "LINE-JURISTIC-CAPACITY"
text AssertionText "ข้อความที่ผู้ใช้เห็นจริง"
varchar LanguageCode
varchar TermsDocumentId "server-verified เท่านั้น"
varchar TermsVersion "server-verified เท่านั้น"
char TermsBodySha256 "server-verified เท่านั้น"
jsonb ConsentDocumentsJson
varchar ConsentAction "Granted | Withdrawn"
varchar OtpRefCode "หลักฐานว่าคุมอีเมลบริษัทได้"
timestamptz OtpVerifiedAt
timestamptz ConsentedAt "นาฬิกาของ server"
}
Index ที่เป็น “กฎ” จริง ๆ
| Index | ทำหน้าที่อะไร |
|---|---|
UX_LineConnectivitySubscriptions_Email_JuristicId unique | บังคับกฎ 1 — คู่ (email, บริษัท) มีได้แถวเดียวเสมอ ทั้ง Active และ Inactive |
IX_LineConnectivitySubscriptions_LineUserId_Active ไม่ unique | เดิมเป็น unique = ตัวที่บล็อกกฎ 2 · ตอนนี้เหลือหน้าที่ทำให้ query “รายการของ LINE นี้” เร็ว |
UX_LineConnectivityConsents_FlowInstanceId_Action unique | flow ที่ retry step เดิม ต้องไม่เกิดหลักฐานซ้ำ |
:::details ทำไม consent ต้องเป็นตารางแยก ไม่เก็บใน subscription
เพราะ Reactivate() เขียนทับ ค่าเดิมทุกครั้งที่สมัครใหม่ ถ้าเก็บ TermsVersion ไว้ที่แถว subscription อย่างเดียว ประวัติการยอมรับครั้งก่อน ๆ จะหายไปทันทีที่มีการสมัครรอบใหม่ — ใช้เป็นหลักฐานย้อนหลังไม่ได้
ตาราง consent จึงเป็น append-only: entity ไม่มี setter สาธารณะ ไม่มี method แก้ค่า repository ไม่มี Update/Delete และ “การยกเลิก” คือ แถวใหม่ ที่ ConsentAction = Withdrawn ไม่ใช่การไปแก้แถวเดิม
:::
ทำไมต้องเก็บถึงขนาดนี้ — ปัญหาที่ Owner ตั้งไว้
โจทย์คือ “แยกไม่ออกว่า LINE ที่ subscribe ไว้เป็นบุคคลธรรมดาหรือนิติบุคคล” — งานนี้เป็นงานนิติบุคคล
ความจริงที่ต้องยอมรับก่อน: บัญชี LINE เป็นของส่วนตัวโดยธรรมชาติ ไม่มีทาง “บังคับ” ให้เป็นบัญชีบริษัทได้ ต่อให้ติ๊ก checkbox ก็ไม่เปลี่ยนความจริงข้อนั้น
สิ่งที่ระบบทำได้จริงคือ เก็บหลักฐานว่าคนที่กดยอมรับ ทำในฐานะตัวแทนนิติบุคคล ซึ่งพิสูจน์ด้วย 3 ชั้นรวมกัน:
- OTP — พิสูจน์ว่าคนกดคุมกล่องอีเมลของบริษัทได้จริง (
OtpRefCode+OtpVerifiedAt) - Membership — พิสูจน์ว่า ณ วินาทีนั้นเขาเป็นสมาชิก active ของบริษัทนั้น (
UserCompanyMappingId+MembershipStatusAtConsent) — ค่านี้เปลี่ยนได้ทีหลัง จึง re-derive ย้อนหลังไม่ได้ ต้องเก็บ ณ ตอนนั้น - คำรับรองที่แสดงชัด —
ActingCapacity+ ข้อความที่ผู้ใช้เห็นจริง (AssertionText) + เวอร์ชัน/แฮชของข้อกำหนดที่ server ดึงเอง
📌 ข้อความคำรับรอง ไม่ได้ hardcode ในโค้ด — มาจาก consent entry รหัส
LINE-JURISTIC-CAPACITYที่ทีมกฎหมายเขียนเองใน Centralized admin terms ตราบใดที่ยังไม่ได้เขียน หน้าจอจะไม่แสดง checkbox นี้และทำงานเหมือนเดิมทุกอย่าง (ตั้งใจให้ degrade เงียบ)
5. Flow engine — 2 flow, 6 step
Step ทั้งหมด seed เป็น แถวใน DB (seed/line-connectivity-seed.sql → StepCatalog + FlowDefinition + FlowStep) ไม่ใช่ EF HasData — เพิ่ม step ใหม่ = งาน DB ที่ต้องรันมือทุก env
flowchart LR
subgraph CONNECT["LINE_CONNECT (สมัคร)"]
direction LR
C1["1. LineConnectivityInfoStep<br/>Interactive"] --> C2["2. LineOtpVerificationStep<br/>Interactive"] --> C3["3. LineTcConsentStep<br/>Interactive"] --> C4["4. PersistLineSubscriptionStep<br/>⚙️ Automatic"]
end
flowchart LR
subgraph UNSUB["LINE_UNSUBSCRIBE (ยกเลิก)"]
direction LR
U1["1. LineUnsubInfoStep<br/>Interactive"] --> U2["2. LineDeactivateSubscriptionStep<br/>Interactive"]
end
ผลลัพธ์ของแต่ละ step เก็บใน FlowInstanceStepData (PK = FlowInstanceId + StepType, คอลัมน์ DataJson เป็น jsonb) — step หลังอ่านของ step หน้าได้จากตรงนี้
🔑 หน้า AccountInfo ไม่ได้เป็น step ใหม่ — มันคือหน้าจอ FE ที่ render จาก output ของ
LineUnsubInfoStepซึ่งตอนนี้คืน รายการ เหตุผล: step definition อยู่ใน DB seed ที่ต้องรันมือทุก env ส่วน routing ของ FE เป็น per-URL อยู่แล้ว การเพิ่ม step จึงเป็นงาน ops ที่ไม่จำเป็น
6. Endpoint contract
| Method | Route | Auth | ใช้ทำอะไร |
|---|---|---|---|
| POST | /api/user-service/v1/line-connectivity/register/start | anonymous | เปิดใบสมัครใหม่ |
| POST | /api/user-service/v1/line-connectivity/unsubscribe/start | anonymous | เปิดใบยกเลิกใหม่ |
| POST | /api/user-service/v1/line-connectivity/instances/{id}/steps/{stepType} | anonymous | เดิน step |
| GET | /api/user-service/v1/line-connectivity/instances/{id} | anonymous | อ่านสถานะ flow |
| GET | /api/user-service/v1/line-subscriptions/me | [Authorize] | รายการ binding ของอีเมลผู้ล็อกอิน |
สิ่งที่แต่ละ step รับ/คืน (contract ที่ FE ต้องรู้)
| Step | Input | Output |
|---|---|---|
LineConnectivityInfoStep | { email, juristicId, lineIdToken? } | { email, juristicId, companyNameTh, companyNameEn, lineUserId, userId, companyId, userCompanyMappingId, membershipStatusAtConsent } |
LineOtpVerificationStep | SaveDraft = ส่ง OTP · Next = { otpCode } | { email, otpVerified, refCode, verifiedAt } |
LineTcConsentStep | { documents: [{ documentId, version, lang, consents: [{ consentCode, agreed }] }] } | เดิม + { termsDocumentId, termsVersion, termsBodySha256, assertionConsentCode, assertionText, languageCode } |
PersistLineSubscriptionStep | — (Automatic) | { email, juristicId, status } |
LineUnsubInfoStep | { lineIdToken } หรือ { email, juristicId } | { lineUserId, bindings: [{ email, juristicId, companyNameTh, companyNameEn, subscribedAt }] } |
LineDeactivateSubscriptionStep | { email, juristicId } | { email, juristicId, status } |
⚠️ Breaking จากของเดิม — ต้อง deploy BE + FE พร้อมกัน:
lineUserIdที่ client ส่ง ถูกเมิน เปลี่ยนเป็นlineIdTokenLineUnsubInfoStepคืน รายการ ไม่ใช่ binding เดียวLineDeactivateSubscriptionStepเดิมไม่ส่ง step data เลย ตอนนี้ต้องส่งคู่ที่เลือก/line-subscriptions/meเปลี่ยนรูปเป็น{ isConnected, bindings[] }
7. กฎแข็ง 6 ข้อ (ห้ามฝ่าฝืน)
| # | กฎ | บังคับที่ไหน |
|---|---|---|
| 1 | คู่ (email, บริษัท) ผูกได้ 1 บัญชี LINE ขณะ active — เปลี่ยนต้องยกเลิกก่อน | unique index + gate ที่ step 1 และ ซ้ำอีกที step 4 |
| 2 | 1 บัญชี LINE ผูกได้หลายคู่ สูงสุด 20 | ไม่มี unique index แล้ว + cap ที่ step 1 และ step 4 |
| 3 | อีเมลตัวพิมพ์ต่างกัน = คนเดียวกัน | NormalizeEmail (trim + lowercase) ทุกจุดที่เป็น identity ของ subscription · ⚠️ การ หา SuperApp user เคยเทียบตรงตัวจนกฎนี้ไปไม่ถึง — แก้ที่ PR #2004 ยังไม่ merge (ดู R4 ในหน้า scenario) |
| 4 | lineUserId มาจาก ID token ที่ verify กับ LINE แล้วเท่านั้น | HttpLineIdTokenVerifier — fail closed ทุกกรณี |
| 5 | ทุกการผูก/ยกเลิก ต้องมีแถวหลักฐานใน transaction เดียวกัน | AddAsync ไม่ self-commit แล้ว, handler คุม SaveChanges เดียว |
| 6 | คอลัมน์หลักฐาน terms เก็บได้เฉพาะค่าที่ server ดึงมาเอง | ICentralizeTermsClient.IsAuthoritative — ถ้า false เก็บ null ไม่เก็บค่าปลอม |
:::details ทำไมกฎ 1 ต้องเช็ค 2 ที่
flow instance ค้างได้ — ผู้ใช้ผ่าน step 1 แล้วปิดจอทิ้งไว้ ระหว่างนั้นคนอื่นสมัครคู่เดียวกันจนสำเร็จ พอผู้ใช้คนแรกกลับมาเดินต่อจนถึง step 4 Reactivate() จะเขียนทับ LineUserId ของคนที่สมัครทีหลังแบบเงียบ ๆ และ binding ของคนนั้นหายไป
เกณฑ์ที่ step 4 ใช้คือ เทียบ FlowInstanceId ไม่ใช่เทียบ LineUserId เพราะการสมัครนอก LINE ทั้งสองฝั่งจะมี lineUserId = null เหมือนกัน การเทียบ LineUserId จึงไม่ทำงาน (null == null)
:::
8. Error code ทั้งหมด
ของใหม่จากงานนี้
| Code | เกิดเมื่อ |
|---|---|
LineConnect.PairAlreadyBound | คู่ (email, บริษัท) นี้ active อยู่แล้ว — ต้องยกเลิกก่อน |
LineConnect.BindingLimitReached | บัญชี LINE นี้ผูกครบ 20 คู่แล้ว |
LineConnect.LineIdentityRequired | ไม่มี/verify ID token ไม่ผ่าน — FE ตกไปเส้นทางค้นด้วยอีเมล |
LineConnect.TermsVersionMismatch | เวอร์ชันข้อกำหนดที่ส่งมาไม่ตรงฉบับ published — ให้โหลดใหม่ |
LineConnect.SelectionNotFound | คู่ที่เลือกไม่อยู่ในรายการที่แสดง หรือถูกผูกกับ LINE อื่นไปแล้ว |
ของเดิมที่ยังใช้อยู่
| กลุ่ม | Code |
|---|---|
| input ไม่ถูกต้อง | InvalidInput · InvalidEmail · InvalidJuristicId |
| eligibility | JuristicNotFound · CompanyNotOnboarded · CompanyInactive · NotEligible |
| flow/state | FlowNotFound · FlowClosed · StepMismatch · InvalidCursor · InvalidAction · NoHandler · StepFailed · UnknownFlow · Forbidden · Unauthorized · NotFound |
| ข้อมูลขาด | MissingConnectivityInfo · MissingUnsubInfo · NoActiveSubscription |
| อื่น | OtpRequired · TermsNotAccepted · PersistFailed |
NotEligibleตั้งใจรวม 3 กรณี (ไม่พบ user / user ไม่ active / ไม่ได้เป็นสมาชิกบริษัท) เป็นข้อความเดียว เพื่อไม่ให้ใช้ endpoint นี้ไล่เดาว่าอีเมลไหนมีในระบบ
9. Config ที่ต้องมี
| Key | ที่ตั้ง | สถานะตอนนี้ |
|---|---|---|
LineLogin:ChannelId | appsettings + IaC ทุก env | dev ใส่แล้ว · sit/uat ว่าง · prod ยังไม่มี |
LineLogin:VerifyUrl | เดียวกัน | https://api.line.me/oauth2/v2.1/verify |
CentralizedService:BaseUrl | เดียวกัน | ถ้าว่าง → ระบบใช้ stub และ ไม่เก็บหลักฐาน terms (เก็บ null) |
🔴 CI overlay ทับ
appsettings.{ENV}.jsonทั้งไฟล์จาก IaC ตอน build — ใส่แค่ใน service repo ไม่มีผลบน cluster ต้องใส่ที่ IaC ด้วยเสมอ ถ้าChannelIdว่าง ตัว verifier จะ fail closed = เส้นทาง LIFF ใช้ไม่ได้ทั้งเส้น (แต่เส้นทางค้นด้วยอีเมลยังใช้ได้)
10. ของที่ยังไม่มี / ยังไม่ได้ทำ
อัปเดต 07/08/2569 หลังรัน E2E ชุดเต็มบน dev
| เรื่อง | สถานะ |
|---|---|
| Deploy | ✅ ขึ้น dev แล้ว — PR #1977 + #1978 merge 06/08 · ชุด E2E รันได้จริงแล้ว |
| ขอบเขต environment | dev เท่านั้น (Owner 07/08) — ตัด SIT / UAT / PROD ออกจากงานและเอกสารทั้งหมด · แต่ห้ามลบ key LineLogin ใน IaC ของ sit/uat เพราะ check-config-parity.py จะทำ build fail |
| ข้อความคำรับรองนิติบุคคล | รอทีมกฎหมาย author entry LINE-JURISTIC-CAPACITY ใน Centralized admin terms — โค้ดพร้อมแล้ว ยึดพฤติกรรมปัจจุบัน (ปล่อยว่าง ไม่ทำให้ flow ล้ม) ไปก่อน |
| หา user ด้วยอีเมลตัวพิมพ์ต่าง | 🟡 PR #2004 เปิดแล้ว รอ merge (ดู R4) |
| สมัครนอก LINE | ⛔ Owner สั่ง 07/08 ว่าต้องไม่ได้ — ที่ยังทำได้เป็นทางลัดช่วงทดสอบ ยังไม่ได้ปิด (ดู R12) |
| ถอดเส้นทาง “ค้นด้วยอีเมล” | ✅ ฝั่ง FE ถอดแล้ว 07/08 (ปุ่ม + จอกรอกอีเมล + resolveByEmail) · 🟡 ฝั่ง backend LineUnsubInfoStepHandler ยังรับ email+juristicId อยู่ ควรตัดสาขานั้นด้วยเพื่อปิดช่องจริง |
liff.login() | 🔴 ยังไม่มี — LiffService.init() ไม่เรียกเมื่อยังไม่ล็อกอิน ตอนนี้ไม่มีทางสำรองแล้ว สถานะ “ยืนยันตัวตนไม่ได้” จึงกลายเป็นทางตัน ต้องเติมก่อนขึ้นใช้จริง |
| ชุดทดสอบอัตโนมัติฝั่งยกเลิก | 🔴 ตายทั้งหมดหลังถอดเส้นทางอีเมล (harness รันด้วย liffId ว่าง จึงเดินทางนั้น 100%) — Owner รับทราบและให้คงไว้แบบเดิม ไม่ต้องรื้อ |
| Race: 2 flow reactivate คู่เดียวกันพร้อมกัน | รู้แล้ว ยังไม่แก้ (ต้องใช้ concurrency token) — เกิดยากมาก ต้อง unsub แล้วมี 2 flow สมัครคู่เดียวกันพร้อมกันเป๊ะ |
| ยิง consent receipt เข้า ConsentService กลาง | ตัดสินใจไม่ทำ (Owner) — เพิ่มทีหลังได้โดยไม่แตะ schema |
| E2E ของหน้า AccountInfo แบบ list | ยัง Manual ถาวร — ต้องมี LINE ID token จริงจาก LIFF แต่ชุดอัตโนมัติรันด้วย liffId ว่างเสมอ · ดูดีไซน์ได้ผ่านโหมด preview ของ dev build (/unsubscribe?preview=cards) ซึ่งเป็นข้อมูลปลอม ไม่ใช่การทดสอบ |
| ชุดทดสอบรันรวดเดียวจบไม่ได้ | 🔴 ทุกเคสใช้อีเมลที่ผ่านเงื่อนไขตัวเดียวกัน รอบเต็มขอ OTP สิบกว่าครั้งใน 19 นาที เคสท้าย ๆ จึงอ่าน OTP ไม่ได้ (รันแยกผ่านหมด) — ต้องได้อีเมลทดสอบตัวที่สอง |