Private Docs

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_Connectivity branch feature/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_Centralized terms/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 uniqueflow ที่ 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 ชั้นรวมกัน:

  1. OTP — พิสูจน์ว่าคนกดคุมกล่องอีเมลของบริษัทได้จริง (OtpRefCode + OtpVerifiedAt)
  2. Membership — พิสูจน์ว่า ณ วินาทีนั้นเขาเป็นสมาชิก active ของบริษัทนั้น (UserCompanyMappingId + MembershipStatusAtConsent) — ค่านี้เปลี่ยนได้ทีหลัง จึง re-derive ย้อนหลังไม่ได้ ต้องเก็บ ณ ตอนนั้น
  3. คำรับรองที่แสดงชัด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.sqlStepCatalog + 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

MethodRouteAuthใช้ทำอะไร
POST/api/user-service/v1/line-connectivity/register/startanonymousเปิดใบสมัครใหม่
POST/api/user-service/v1/line-connectivity/unsubscribe/startanonymousเปิดใบยกเลิกใหม่
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 ต้องรู้)

StepInputOutput
LineConnectivityInfoStep{ email, juristicId, lineIdToken? }{ email, juristicId, companyNameTh, companyNameEn, lineUserId, userId, companyId, userCompanyMappingId, membershipStatusAtConsent }
LineOtpVerificationStepSaveDraft = ส่ง 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 ส่ง ถูกเมิน เปลี่ยนเป็น lineIdToken
  • LineUnsubInfoStep คืน รายการ ไม่ใช่ binding เดียว
  • LineDeactivateSubscriptionStep เดิมไม่ส่ง step data เลย ตอนนี้ต้องส่งคู่ที่เลือก
  • /line-subscriptions/me เปลี่ยนรูปเป็น { isConnected, bindings[] }

7. กฎแข็ง 6 ข้อ (ห้ามฝ่าฝืน)

#กฎบังคับที่ไหน
1คู่ (email, บริษัท) ผูกได้ 1 บัญชี LINE ขณะ active — เปลี่ยนต้องยกเลิกก่อนunique index + gate ที่ step 1 และ ซ้ำอีกที step 4
21 บัญชี LINE ผูกได้หลายคู่ สูงสุด 20ไม่มี unique index แล้ว + cap ที่ step 1 และ step 4
3อีเมลตัวพิมพ์ต่างกัน = คนเดียวกันNormalizeEmail (trim + lowercase) ทุกจุดที่เป็น identity ของ subscription · ⚠️ การ หา SuperApp user เคยเทียบตรงตัวจนกฎนี้ไปไม่ถึง — แก้ที่ PR #2004 ยังไม่ merge (ดู R4 ในหน้า scenario)
4lineUserId มาจาก ID token ที่ verify กับ LINE แล้วเท่านั้นHttpLineIdTokenVerifierfail 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
eligibilityJuristicNotFound · CompanyNotOnboarded · CompanyInactive · NotEligible
flow/stateFlowNotFound · 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:ChannelIdappsettings + IaC ทุก envdev ใส่แล้ว · 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 รันได้จริงแล้ว
ขอบเขต environmentdev เท่านั้น (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 ไม่ได้ (รันแยกผ่านหมด) — ต้องได้อีเมลทดสอบตัวที่สอง