Private Docs

LINE Connectivity — ทุก Scenario + Sequence Diagram

22 scenario ครบทั้งฝั่งสมัครและฝั่งยกเลิก พร้อม sequence diagram ทีละอัน — happy path, กฎใหม่ 2 แกน, ทุกทางที่ถูกปฏิเสธ, และ race ที่ระบบกันไว้

อัปเดต: 2026-08-07

ต่อจาก Architecture ปัจจุบัน — อ่านหัวข้อ “แกน 2 มิติ” ที่นั่นก่อนจะเข้าใจ scenario ชุดนี้ง่ายขึ้นมาก

:::note ยืนยันด้วยการรันจริงแล้ว 07/08/2569

feature ขึ้น dev แล้ว และเดินผ่านหน้าจอจริงทั้งชุด (34 scenario อัตโนมัติ) — สิ่งที่พบและแก้เอกสารตามในรอบนี้:

  • R4 ไปไม่ถึงการเทียบคู่เลย เพราะการหา user เทียบตรงตัว ⇒ ผู้ใช้ได้ข้อความผิดชนิด (แก้ที่ PR #2004 รอ merge)
  • R12 Owner ตัดสินว่า “สมัครนอก LINE” ต้องไม่ได้ — ที่ยังทำได้เป็นทางลัดช่วงทดสอบ
  • หลักฐาน consent (R1/U8) ตรวจจากแถวจริงใน LineConnectivityConsents แล้ว ครบทุกชั้นตามที่ออกแบบ รวมถึงคุณสมบัติเพิ่มอย่างเดียว (ยกเลิก = แถว Withdrawn ใหม่ ไม่แตะของเดิม)
  • ช่อง “วันที่สมัคร” บนจอตรวจสอบบัญชี มีค่าจริงแล้ว (เดิมว่างเสมอทุกกรณี) :::

ตัวละครใน diagram

ย่อคือ
Uผู้ใช้ (เปิดหน้าจากใน LINE)
FEFrontend_LINE_Connectivity
LINELINE Platform (LIFF SDK + endpoint verify)
USBackend_UserService
DBAuthDb
TPThirdPartyService (ข้อมูลนิติบุคคล)
CENCentralized (ข้อกำหนดและเงื่อนไข)
NSNotificationService (OTP)

ฝั่งสมัคร (LINE_CONNECT)

R1 — สมัครสำเร็จ (happy path)

เส้นทางหลัก ทุก scenario ที่เหลือคือการแตกแขนงจากเส้นนี้

sequenceDiagram
  autonumber
  participant U as U
  participant FE as FE
  participant LINE as LINE
  participant US as US
  participant TP as TP
  participant NS as NS
  participant CEN as CEN
  participant DB as DB

  U->>FE: เปิด /register จากใน LINE
  FE->>LINE: liff.getIDToken()
  LINE-->>FE: idToken
  FE->>US: POST register/start
  US-->>FE: instanceId

  Note over U,FE: หน้า Identity — กรอกอีเมล + เลขนิติบุคคล
  FE->>TP: fx-registration-profile/{juristicId}
  TP-->>FE: ชื่อบริษัท (แสดงให้ยืนยัน)
  FE->>US: step ConnectivityInfo {email, juristicId, idToken}

  US->>LINE: verify(id_token, client_id)
  LINE-->>US: sub = lineUserId
  US->>DB: หา user + membership ของอีเมลนี้
  DB-->>US: user active + เป็นสมาชิกบริษัท ✓
  US->>DB: คู่ (email, juristicId) active อยู่ไหม
  DB-->>US: ไม่มี ✓
  US->>DB: LINE นี้ผูกไปกี่คู่แล้ว
  DB-->>US: < 20 ✓
  US-->>FE: ok + เก็บ userId/companyId/membership ไว้เป็นหลักฐาน

  Note over U,FE: หน้า OTP
  FE->>US: step OtpVerification (SaveDraft = ขอรหัส)
  US->>NS: internal-generate OTP
  NS-->>US: refCode
  US-->>FE: refCode
  U->>FE: กรอกรหัส 8 หลัก
  FE->>US: step OtpVerification (Next) {otpCode}
  US->>NS: verify
  NS-->>US: ผ่าน
  US-->>FE: ok + เก็บ refCode / verifiedAt

  Note over U,FE: หน้าข้อกำหนด
  FE->>CEN: terms/current (เอาเนื้อหามาแสดง)
  CEN-->>FE: เอกสาร + purpose list
  U->>FE: เลื่อนอ่านจนจบ + ติ๊กยอมรับ
  FE->>US: step TcConsent {documents[]}
  US->>CEN: terms/current (ดึงเองเพื่อพิสูจน์)
  CEN-->>US: ฉบับ published จริง
  US->>US: เทียบเวอร์ชัน ✓ แล้ว hash เนื้อหา (SHA-256)
  US-->>FE: ok

  Note over US: step 4 อัตโนมัติ
  US->>DB: เช็คซ้ำอีกรอบ — คู่นี้ยัง active โดย flow อื่นไหม
  DB-->>US: ไม่มี ✓
  US->>DB: บันทึก subscription + แถวหลักฐาน Granted<br/>(SaveChanges เดียวกัน)
  US-->>FE: Finalized
  FE-->>U: หน้าสมัครสำเร็จ

:::details ทำไม step 4 ต้องเช็คซ้ำอีกรอบ

เพราะระหว่างที่ผู้ใช้กรอก OTP กับอ่านข้อกำหนด (อาจกินเวลาหลายนาที หรือปิดจอทิ้งไว้) คนอื่นอาจสมัครคู่เดียวกันจนสำเร็จไปแล้ว ถ้าไม่เช็ค การเขียนรอบนี้จะทับ LineUserId ของคนที่สมัครทีหลังแบบเงียบ ๆ :::


R2 — LINE เดิม สมัครบริษัทที่สอง ✅ (กฎใหม่)

นี่คือความสามารถหลักที่เพิ่มมาในรอบนี้ — เดิมจะถูกบล็อกด้วยข้อความ “บัญชี LINE นี้เชื่อมต่อกับอีเมลอื่นอยู่แล้ว”

sequenceDiagram
  autonumber
  participant FE as FE
  participant US as US
  participant DB as DB

  Note over DB: มีอยู่แล้ว — somchai@abc.com + บริษัท A ↔ LINE qa1111 (Active)

  FE->>US: ConnectivityInfo {somchai@abc.com, บริษัท B, idToken ของ qa1111}
  US->>DB: คู่ (somchai@abc.com, บริษัท B) active ไหม
  DB-->>US: ไม่มี — คนละคู่กับของเดิม ✓
  US->>DB: qa1111 ผูกไปกี่คู่
  DB-->>US: 1 คู่ (< 20) ✓
  US-->>FE: ok เดินต่อได้

  Note over DB: จบ flow แล้วจะมี 2 แถว<br/>somchai@abc.com + A ↔ qa1111<br/>somchai@abc.com + B ↔ qa1111

✅ ตรงกับที่ Owner สั่ง: testA@mail.com/company A, testA@mail.com/company AA, testB@mail.com/company B ผูก qa1111 ได้พร้อมกันทั้งหมด


R3 — คู่นี้ผูกอยู่แล้ว ❌ PairAlreadyBound

sequenceDiagram
  autonumber
  participant FE as FE
  participant US as US
  participant DB as DB

  Note over DB: somchai@abc.com + บริษัท A ↔ LINE qa1111 (Active)

  FE->>US: ConnectivityInfo {somchai@abc.com, บริษัท A, idToken ของ qa2222}
  US->>DB: คู่นี้ active ไหม
  DB-->>US: active อยู่ (ผูกกับ qa1111)
  US-->>FE: ❌ PairAlreadyBound<br/>"อีเมลนี้เชื่อมต่อกับนิติบุคคลนี้อยู่แล้ว กรุณายกเลิกการเชื่อมต่อเดิมก่อน"

ใช้ได้กับทุกกรณี ไม่ว่าจะเป็น LINE เครื่องเดิมหรือเครื่องอื่น — คู่ที่ active แล้ว สมัครซ้ำไม่ได้เสมอ


R4 — อีเมลตัวพิมพ์ต่างกัน ❌ ถือเป็นคนเดียวกัน

sequenceDiagram
  autonumber
  participant FE as FE
  participant US as US
  participant DB as DB

  Note over DB: bob@x.com + บริษัท A (Active)

  FE->>US: ConnectivityInfo {Bob@x.com, บริษัท A, idToken}
  US->>DB: หา SuperApp user ด้วย "Bob@x.com" (ตรงตัว)
  DB-->>US: ไม่เจอ
  US->>DB: หาใหม่แบบไม่สนตัวพิมพ์
  DB-->>US: เจอ user bob@x.com
  US->>US: NormalizeEmail → "bob@x.com"
  US->>DB: คู่ (bob@x.com, บริษัท A) active ไหม
  DB-->>US: active อยู่
  US-->>FE: ❌ PairAlreadyBound

:::caution แก้ 07/08/2569 — ก่อนหน้านี้เคสนี้ไปไม่ถึงการเทียบคู่เลย

การหา SuperApp user เคย จงใจไม่ normalize เพราะ Users.Email เก็บแบบ trim อย่างเดียวไม่เคย lowercase — ถ้าส่งค่าที่ lowercase แล้วไปค้น user ที่อีเมลใน DB มีตัวพิมพ์ใหญ่จะหาไม่เจอ

ผลข้างเคียงที่ไม่ได้ตั้งใจ: ผู้ใช้ที่พิมพ์ Bob@x.com ถูกปฏิเสธที่ ด่านคุณสมบัติ (NotEligible — “อีเมลนี้ไม่สามารถเชื่อมต่อ LINE กับนิติบุคคลนี้ได้”) ตั้งแต่ก่อนถึงการเทียบคู่ ⇒ กฎข้อนี้พิสูจน์ผ่านหน้าจอไม่ได้เลย และผู้ใช้เข้าใจว่าตัวเองกรอกอีเมลผิด ทั้งที่ความจริงคือเชื่อมต่ออยู่แล้วและต้องไปยกเลิกก่อน (ปุ่มลัดไปหน้ายกเลิกก็ไม่ขึ้น เพราะขึ้นเฉพาะ PairAlreadyBound)

เจอตอนรัน E2E จริง 07/08/2569 — เคส LNC-024 ผ่านด้วย error code ที่ผิด

แก้แล้ว (PR #2004): หา user แบบเทียบตรงตัวก่อน ไม่เจอค่อยผ่อนเป็นไม่สนตัวพิมพ์ — ทำ 2 จังหวะไม่ใช่เปลี่ยนไปไม่สนตัวพิมพ์อย่างเดียว เพราะข้อมูลจริงมีบัญชีที่ต่างกันแค่ตัวพิมพ์อยู่จริง (2 คู่บน dev) ถ้าเทียบหลวมตั้งแต่แรก คนที่กรอกตรงเป๊ะอาจถูกจับคู่กับอีกบัญชีหนึ่งแทน

⚠️ ยังไม่ merge — จนกว่าจะขึ้น dev เคสนี้ยังให้ NotEligible อยู่ :::


R5 — ครบ 20 คู่ ❌ BindingLimitReached

sequenceDiagram
  autonumber
  participant FE as FE
  participant US as US
  participant DB as DB

  FE->>US: ConnectivityInfo {..., idToken ของ qa1111}
  US->>DB: qa1111 ผูกไปกี่คู่
  DB-->>US: 20
  US-->>FE: ❌ BindingLimitReached

เพดานนี้เพิ่มมาเพราะการลบกฎ “1 LINE = 1 subscription” ทำให้ endpoint ที่เป็น anonymous และไม่มี throttle เสียตัวจำกัดไปโดยปริยาย


R6 — ID token ใช้ไม่ได้ ❌ LineIdentityRequired

sequenceDiagram
  autonumber
  participant FE as FE
  participant US as US
  participant LINE as LINE

  FE->>US: ConnectivityInfo {..., idToken (หมดอายุ/ผิด)}
  US->>LINE: verify(id_token, client_id)
  LINE-->>US: ไม่ผ่าน
  US-->>FE: ❌ LineIdentityRequired

ทุกกรณีที่ verify ไม่ผ่านถือว่าไม่ผ่านหมด (fail closed): token ผิด · หมดอายุ · LINE ตอบไม่ใช่ 200 · timeout · ChannelId ไม่ได้ตั้งค่า — ไม่มีทางไหนที่ระบบยอมเดินต่อด้วยตัวตนที่ยังไม่ได้พิสูจน์


R7 — ไม่ผ่านเงื่อนไขคุณสมบัติ ❌

sequenceDiagram
  autonumber
  participant FE as FE
  participant US as US
  participant DB as DB

  FE->>US: ConnectivityInfo {email, juristicId, idToken}
  US->>DB: ตรวจ user + company + membership
  alt เลขนิติบุคคลไม่มีในระบบ
    US-->>FE: ❌ JuristicNotFound
  else บริษัทยังไม่ผ่าน onboarding
    US-->>FE: ❌ CompanyNotOnboarded
  else บริษัทถูกปิดใช้งาน
    US-->>FE: ❌ CompanyInactive
  else ไม่พบ user / user ไม่ active / ไม่ได้เป็นสมาชิกบริษัท
    US-->>FE: ❌ NotEligible (ข้อความเดียวกันหมด)
  end

3 กรณีสุดท้ายจงใจรวมเป็นข้อความเดียว เพื่อไม่ให้ใครใช้ endpoint นี้ไล่เดาว่าอีเมลไหนมีอยู่ในระบบ


R8 — OTP ผิด / ถูกล็อก ❌

sequenceDiagram
  autonumber
  participant U as U
  participant FE as FE
  participant US as US
  participant NS as NS

  U->>FE: กรอกรหัสผิด
  FE->>US: OtpVerification (Next) {otpCode}
  US->>NS: verify
  NS-->>US: ไม่ผ่าน (นับครั้ง)
  US-->>FE: ❌ ให้กรอกใหม่
  Note over NS: ผิดเกินโควตา → ล็อกประมาณ 15 นาที<br/>ช่วงนั้นขอรหัสใหม่ก็ไม่ได้

⚠️ ล็อกผูกกับอีเมล ไม่ใช่กับ flow — ทดสอบด้วยอีเมลเดียวกันหลายเคสติดกันจะชนกันเอง (เคยทำให้ E2E ตก 8 เคสมาแล้ว)


R9 — เวอร์ชันข้อกำหนดไม่ตรง ❌ TermsVersionMismatch

เกิดเมื่อผู้ใช้เปิดหน้าค้างไว้แล้วมีการ publish ข้อกำหนดฉบับใหม่ระหว่างนั้น

sequenceDiagram
  autonumber
  participant FE as FE
  participant US as US
  participant CEN as CEN

  Note over FE: โหลด terms v1 ไว้ตั้งแต่เมื่อกี้
  Note over CEN: publish v2 แล้ว
  FE->>US: TcConsent {documents: v1}
  US->>CEN: terms/current
  CEN-->>US: v2
  US->>US: v1 ≠ v2
  US-->>FE: ❌ TermsVersionMismatch — ให้โหลดข้อกำหนดใหม่

R10 — Centralized ล่ม ⚠️ ไปต่อได้ แต่ไม่มีหลักฐาน terms

sequenceDiagram
  autonumber
  participant FE as FE
  participant US as US
  participant CEN as CEN
  participant DB as DB

  FE->>US: TcConsent {documents}
  US->>CEN: terms/current
  CEN--xUS: timeout / 5xx
  US->>US: log warning + ข้ามการ verify
  US-->>FE: ok (ไม่บล็อกการสมัคร)
  US->>DB: แถวหลักฐาน — termsDocumentId / termsVersion / termsBodySha256 = null

ทำไมถึงยอมให้ผ่าน: Centralized ล่มไม่ควรทำให้สมัครไม่ได้เลยทั้งระบบ ทำไมต้องเก็บเป็น null: ถ้าเก็บค่าที่ client ส่งมาแทน มันจะกลายเป็น “หลักฐาน” ที่ server ไม่เคยยืนยัน — หลักฐานที่ว่างไว้ ดีกว่าหลักฐานที่ดูเหมือนจริงแต่ปลอม

กรณีเดียวกันนี้ครอบ env ที่ไม่ได้ตั้ง CentralizedService:BaseUrl ด้วย (ระบบใช้ stub ที่คืนค่าคงที่ทุก input — ห้ามเก็บค่านั้นเด็ดขาด)


R11 — flow ค้างไว้แล้วกลับมาช้า ❌ กันที่ step 4

sequenceDiagram
  autonumber
  participant A as ผู้ใช้ A
  participant B as ผู้ใช้ B
  participant US as US
  participant DB as DB

  A->>US: ConnectivityInfo (คู่ P) — ผ่าน ✓
  Note over A: ปิดจอทิ้งไว้

  B->>US: สมัครคู่ P จนจบ flow
  US->>DB: คู่ P → Active (LINE ของ B)

  A->>US: กลับมาเดินต่อจนถึง step 4
  US->>DB: คู่ P active โดย flow อื่นไหม
  DB-->>US: active — FlowInstanceId ไม่ตรงกับของ A
  US-->>A: ❌ PairAlreadyBound
  Note over DB: binding ของ B ปลอดภัย

เกณฑ์คือ เทียบ FlowInstanceId ไม่ใช่เทียบ LineUserId — เพราะการสมัครนอก LINE ทั้งสองฝั่งมี lineUserId = null เหมือนกัน การเทียบ LineUserId จะไม่ยิงเลย (null == null)


R12 — สมัครโดยไม่ได้อยู่ใน LINE ⛔ ไม่ใช่เคสที่รองรับ

:::danger Owner ตัดสิน 07/08/2569 — จะสมัครนอก LINE ไม่ได้เลย

ที่วันนี้ยังทำได้ เป็น ทางลัดช่วงทดสอบ เท่านั้น (เปิด liffId ว่างไว้เพื่อกดทดสอบเร็ว ๆ โดยข้าม LINE) ไม่ใช่พฤติกรรมของ product — ห้ามเขียนเป็น test case หรือยกไปอ้างเป็นความสามารถ :::

sequenceDiagram
  autonumber
  participant FE as FE
  participant US as US
  participant DB as DB

  FE->>US: ConnectivityInfo {email, juristicId} — ไม่ส่ง idToken
  US->>US: ข้ามการ verify (ไม่ได้อ้างตัวตน LINE มาตั้งแต่ต้น)
  US->>DB: บันทึกด้วย LineUserId = null
  Note over DB: binding นี้ไม่โผล่บนหน้ารายการบัญชี<br/>ยกเลิกได้เฉพาะทาง "ค้นด้วยอีเมล + เลขนิติบุคคล"

ต่างจาก R6 ตรงไหน: R6 คือ ส่ง token มาแต่ใช้ไม่ได้ → ปฏิเสธ · R12 คือ ไม่ได้อ้างตัวตน LINE เลย → ปัจจุบันยังผ่าน แต่ binding นั้นไม่มีสิทธิ์อะไรกับ LINE เครื่องไหน

ผลข้างเคียงที่ต้องรู้ตอนนี้: ชุดทดสอบอัตโนมัติทั้งชุดสมัครด้วยเส้นทางนี้ (รันด้วย liffId ว่าง) ⇒ ทุก subscription ที่มันสร้างมี LineUserId = null และ ไม่โผล่บนหน้ารายการบัญชี ต้องค้นด้วยอีเมลเสมอ — เป็นข้อจำกัดของ harness ไม่ใช่ feature เมื่อบังคับ LIFF จริงเมื่อไหร่ ชุดทดสอบทั้งชุดจะต้องมี LINE ID token จริง


R13 — สมัครใหม่หลังเคยยกเลิก (reactivate)

sequenceDiagram
  autonumber
  participant FE as FE
  participant US as US
  participant DB as DB

  Note over DB: คู่ P — Inactive (เคยผูกกับ LINE qa1111)

  FE->>US: สมัครคู่ P ด้วย LINE qa9999
  US->>DB: คู่ P active ไหม
  DB-->>US: Inactive → reactivate ได้ ✓
  US->>DB: Reactivate — LineUserId := qa9999 (ทับของเดิมเสมอ)
  US->>DB: + แถวหลักฐาน Granted ใบใหม่

:::details ทำไม LineUserId ต้องทับเสมอ แม้ค่าใหม่เป็น null

เดิมโค้ดทับเฉพาะเมื่อค่าใหม่ไม่ null ผลคือ: คู่ P เคยผูกกับ qa1111 → ยกเลิก → สมัครใหม่จากนอก LINE (ค่าใหม่ = null) → แถวกลับมา Active แต่ยังค้าง qa1111 ไว้

แปลว่า qa1111 ยังเห็น binding นี้ในรายการของตัวเอง ยกเลิกมันได้ และมันยังนับเข้าโควตา 20 คู่ของ qa1111 ทั้งที่ไม่ใช่คนสมัครรอบนี้ — ตอนนี้แก้ให้ทับตรง ๆ แล้ว

ความหมายของคอลัมน์นี้คือ “ใครสมัครรอบล่าสุด” ไม่ใช่ “เจ้าของถาวร” :::


ฝั่งยกเลิก (LINE_UNSUBSCRIBE)

U1 — ยกเลิกผ่าน LIFF (เส้นทางหลัก) ⭐ หน้าใหม่อยู่ตรงนี้

sequenceDiagram
  autonumber
  participant U as U
  participant FE as FE
  participant LINE as LINE
  participant US as US
  participant DB as DB

  U->>FE: เปิด /unsubscribe จากใน LINE
  FE->>LINE: liff.getIDToken()
  LINE-->>FE: idToken
  FE->>US: POST unsubscribe/start
  US-->>FE: instanceId
  FE->>US: step UnsubInfo {lineIdToken}
  US->>LINE: verify
  LINE-->>US: sub = qa1111
  US->>DB: หา binding ที่ active ของ qa1111 ทั้งหมด
  DB-->>US: 3 คู่
  US-->>FE: {lineUserId, bindings: [3 คู่]}

  Note over U,FE: 🆕 หน้า AccountInfo (/unsubscribe)<br/>การ์ดต่อ 1 binding — ชื่อบริษัท TH/EN, อีเมล, วันที่สมัคร
  U->>FE: แตะการ์ดที่ต้องการยกเลิก
  FE->>FE: select(binding) → ไป /unsubscribe/confirm

  Note over U,FE: หน้ายืนยัน — แสดงเฉพาะคู่ที่เลือก
  U->>FE: กดยืนยัน
  FE->>US: step DeactivateSubscription {email, juristicId}
  US->>US: คู่นี้อยู่ในรายการที่แสดงไหม ✓
  US->>US: คู่นี้ยังเป็นของ qa1111 อยู่ไหม ✓
  US->>DB: Status := Inactive + แถวหลักฐาน Withdrawn<br/>(SaveChanges เดียวกัน)
  US-->>FE: Finalized
  FE-->>U: หน้ายกเลิกสำเร็จ

หน้า AccountInfo แสดงอะไร

ส่วนเนื้อหา
หัวข้อบัญชีที่เชื่อมต่อกับ LINE ของคุณ
ตัวเลขจำนวนบัญชีที่เชื่อมต่อ: N
การ์ด (ต่อ 1 binding)ชื่อบริษัทไทย (ตัวหลัก) · ชื่ออังกฤษ (ตัวรอง) · อีเมลที่เชื่อมต่อ · สมัครเมื่อ · chevron บอกว่าแตะได้
ปุ่มล่างค้นหาด้วยอีเมลและเลขนิติบุคคล
สถานะอื่นกำลังโหลด · โหลดไม่สำเร็จ + ลองใหม่ · ไม่พบบัญชี · ยืนยันตัวตนไม่ได้

โทน design ยกมาจากหน้าเดิมทั้งหมด (top-bar → เนื้อหา → แถบปุ่มล่างติดขอบ) ใช้ design token ของ @exim/ui-kit ล้วน ไม่มีสีใหม่ ข้อความไทยทุกตัวอยู่ในไฟล์ labels ไม่ฝังใน template


:::danger เปลี่ยนใหญ่ 07/08/2569 — ถอดเส้นทาง “ค้นด้วยอีเมล + เลขนิติบุคคล” ออกทั้งเส้น

Owner ตัดสินว่าจะไม่เปิดช่องนี้: มันหา binding เจอ โดยไม่สนว่าผูกกับบัญชี LINE ไหน และไม่มี OTP กำกับ ⇒ ใครที่รู้แค่อีเมลกับเลขนิติบุคคลก็ยกเลิกการรับข่าวสารของคนอื่นได้ · ใช้งานจริงเปิดผ่าน LIFF เสมอ ทางสำรองนี้จึงไม่มีเหตุผลให้มีอยู่

เหลือทางเดียว: verify ID token → ได้รายการของบัญชี LINE ตัวเอง → เลือกจากรายการ · จอรายการเพิ่มช่องกรองรายการที่โหลดมาแล้ว (โผล่เมื่อเกิน 5 การ์ด) แทนการค้นหาที่หลังบ้าน

U2 / U3 ด้านล่างปรับตามแล้ว · U4 ตัดออก (ไม่มีเส้นทางนี้อีกต่อไป) :::

U2 — ไม่มี binding เลย → ชวนไปสมัคร

sequenceDiagram
  autonumber
  participant FE as FE
  participant US as US
  participant DB as DB

  FE->>US: UnsubInfo {lineIdToken}
  US->>DB: binding active ของ LINE นี้
  DB-->>US: ไม่มีเลย
  US-->>FE: ❌ NoActiveSubscription
  Note over FE: FE รู้จัก code นี้ → แสดง "บัญชี LINE นี้ยังไม่ได้เชื่อมต่อกับบริษัทใด..."<br/>ปุ่มหลักคือ "สมัครรับข่าวสารผ่าน LINE" (ไปหน้าสมัคร)

จงใจ ไม่ คืน ok พร้อมรายการว่าง — เพราะ (1) FE ผูกเส้นทางสำรองไว้กับ error code นี้ (2) การตอบ ok จะดัน cursor ของ flow ข้าม step นี้ไปแล้ว


U3 — ยืนยันตัวตน LINE ไม่ได้ → ต่างจาก U2

sequenceDiagram
  autonumber
  participant FE as FE
  participant US as US

  alt ไม่มี idToken เลย (ปิด LIFF / token หมดอายุ)
    FE->>FE: ข้ามการยิง แล้วตอบ no_identity ทันที
  else ส่งไปแล้วแต่ verify ไม่ผ่าน
    FE->>US: UnsubInfo {lineIdToken}
    US-->>FE: ❌ LineIdentityRequired
  end
  Note over FE: "ไม่สามารถยืนยันตัวตนผ่าน LINE นี้ได้<br/>กรุณาเปิดหน้านี้จากแอป LINE อีกครั้ง" + ปุ่ม "ลองใหม่อีกครั้ง"<br/>❗ ไม่แสดงตัวเลข "จำนวนบัญชี: 0"

ความต่างที่สำคัญ: U2 = ค้นแล้วไม่เจอ · U3 = ยังไม่เคยค้นเลย

เดิมสองกรณีนี้ถูกยุบเป็นอันเดียว ทำให้ผู้ใช้ที่ยังมี subscription อยู่จริงถูกบอกว่า “จำนวนบัญชีที่เชื่อมต่อ: 0” ทั้งที่ระบบไม่เคยไปดูเลยสักครั้ง

:::caution ต้องเติม liff.login() ก่อน ไม่งั้นสถานะนี้กลายเป็นทางตัน

LiffService.init() เช็ค liff.isLoggedIn() แล้ว ไม่มี else ที่เรียก liff.login() — เปิดจากในแอป LINE จริงแต่ session ยังไม่เคย LIFF login ก็ได้ idToken() = null · บวกกับ ID token มีอายุ

เดิมมีทางสำรองให้กรอกอีเมลรับไว้ ตอนนี้ไม่มีแล้ว เหลือแค่ปุ่ม “ลองใหม่อีกครั้ง” ซึ่งจะไม่ช่วยอะไรถ้าสาเหตุคือยังไม่ได้ล็อกอิน :::


U4 — กรองรายการที่ยาว (แทนที่ “ค้นด้วยอีเมล” ที่ถูกถอดออก)

sequenceDiagram
  autonumber
  participant U as U
  participant FE as FE

  Note over FE: รายการทั้งหมดโหลดมาแล้วตั้งแต่ U1 (สูงสุด 20 คู่)
  U->>FE: พิมพ์บางส่วนของชื่อบริษัท / เลขนิติบุคคล
  FE->>FE: กรองรายการในหน่วยความจำทันที (ไม่ยิงหลังบ้าน)
  alt มีที่ตรง
    FE-->>U: เหลือเฉพาะการ์ดที่ตรง
  else ไม่มีที่ตรง
    FE-->>U: "ไม่พบบัญชีที่ตรงกับคำค้นหา" (จำนวนบัญชีด้านบนยังเป็นจำนวนเต็มเหมือนเดิม)
  end

ช่องกรองโผล่เมื่อมีการ์ด เกิน 5 ใบ เท่านั้น — เพดานจริงคือ 20 คู่ และคนส่วนใหญ่มีไม่กี่ใบ โชว์ตลอดเวลาจะเป็นของรกที่ไม่มีใครใช้

:::note ของเดิมคืออะไร และทำไมถอด

เดิม U4 คือ ค้นหาด้วยอีเมล + เลขนิติบุคคล ซึ่งยิงหลังบ้านหา binding โดยไม่สนว่าผูกกับบัญชี LINE ไหน (จงใจคืนไม่เกิน 1 คู่ เพื่อกันการไล่ดูว่าอีเมลใดผูกบริษัทอะไรบ้าง)

แต่ต่อให้คืนแค่ 1 คู่ มันก็ยังแปลว่า ใครที่รู้อีเมลกับเลขนิติบุคคลของคนอื่นก็ยกเลิกการรับข่าวสารของคนนั้นได้ โดยไม่ต้องมี OTP หรือบัญชี LINE ของเจ้าตัว — Owner จึงตัดสินให้ถอดออกทั้งเส้น (07/08/2569)

จอกรอกอีเมลที่ /unsubscribe/confirm และ resolveByEmail() ถูกลบตามไปด้วย · เข้าจอยืนยันได้ทางเดียวคือเลือกการ์ดมา :::


U5 — คู่ที่เลือกถูกเปลี่ยนมือไปแล้ว ❌ SelectionNotFound

sequenceDiagram
  autonumber
  participant A as LINE qa1111
  participant US as US
  participant DB as DB
  participant B as LINE qa2222

  A->>US: UnsubInfo → เห็นรายการมีคู่ P
  Note over A: ยังไม่กดยืนยัน

  B->>US: (ที่อื่น) ยกเลิกคู่ P แล้วสมัครใหม่ด้วย qa2222
  DB-->>DB: คู่ P → Active, LineUserId = qa2222

  A->>US: DeactivateSubscription {คู่ P}
  US->>DB: คู่ P ยังเป็นของ qa1111 อยู่ไหม
  DB-->>US: ไม่ — เป็นของ qa2222 แล้ว
  US-->>A: ❌ SelectionNotFound
  Note over DB: binding ของ qa2222 ปลอดภัย

ระบบตรวจ 2 ชั้น ตอนกดยืนยัน:

  1. คู่ที่ส่งมา อยู่ในรายการที่เพิ่งแสดงให้ดูไหม — กันการยิงคู่ที่ไม่เคยเห็น
  2. คู่นั้น ยังเป็นของบัญชี LINE ที่ยืนยันตัวตนไว้ใน session นี้ไหม — กัน race ข้างบน (ข้ามชั้นนี้เฉพาะเส้นทางค้นด้วยอีเมล ที่ไม่มีตัวตน LINE ให้เทียบ)

U6 — กด back หลังยกเลิกสำเร็จ

sequenceDiagram
  autonumber
  participant U as U
  participant FE as FE
  participant US as US

  U->>FE: ยืนยัน → สำเร็จ → หน้าสำเร็จ
  FE->>FE: ล้างรายการ + ตัวที่เลือก + instanceId ทิ้ง
  U->>FE: กด back กลับมาหน้ารายการ
  FE->>US: เปิดใบใหม่ + ค้นใหม่
  US-->>FE: รายการล่าสุด (คู่ที่เพิ่งยกเลิกหายไปแล้ว)

state ของ flow ผูกกับ route /unsubscribe ทั้ง subtree จึงรอดข้ามการกด back ถ้าไม่ล้าง ผู้ใช้จะเห็นคู่ที่เพิ่งยกเลิกไปยังอยู่ในรายการและกดยกเลิกซ้ำได้ (ซึ่งจะ error)


U7 — ยกเลิกคู่เดียว คู่อื่นไม่กระทบ

sequenceDiagram
  autonumber
  participant FE as FE
  participant US as US
  participant DB as DB

  Note over DB: qa1111 → คู่ A, คู่ B, คู่ C (Active ทั้งหมด)
  FE->>US: DeactivateSubscription {คู่ B}
  US->>DB: เฉพาะคู่ B → Inactive + แถว Withdrawn
  Note over DB: คู่ A, C ยัง Active ไม่ถูกแตะ
  FE->>US: เปิดหน้ารายการใหม่
  US-->>FE: เหลือ 2 คู่ (A, C)

U8 — กด step ซ้ำ (retry) ต้องไม่เกิดหลักฐานซ้ำ

sequenceDiagram
  autonumber
  participant FE as FE
  participant US as US
  participant DB as DB

  FE->>US: DeactivateSubscription (ครั้งที่ 1)
  US->>DB: Inactive + แถว Withdrawn
  FE->>US: DeactivateSubscription (ยิงซ้ำ — เน็ตหลุด/กดซ้ำ)
  US->>DB: flow ใบนี้มีแถว Withdrawn แล้วหรือยัง
  DB-->>US: มีแล้ว → ข้าม
  US-->>FE: ok (ไม่เกิดแถวที่สอง)

มี unique index (FlowInstanceId, ConsentAction) เป็นด่านสุดท้ายอีกชั้น เผื่อการเช็คข้างบนแข่งกันเอง


อื่น ๆ

O1 — ดูรายการของตัวเอง (ต้องล็อกอิน)

sequenceDiagram
  autonumber
  participant APP as SuperApp (มี JWT)
  participant US as US
  participant DB as DB

  APP->>US: GET /line-subscriptions/me
  US->>US: อ่านอีเมลจาก user info ที่ middleware ใส่ไว้
  US->>DB: binding active ของอีเมลนี้ (ทุกบริษัท)
  DB-->>US: N แถว
  US-->>APP: {isConnected, bindings: [{companyNameTh, subscribedAt, maskedLineUserId}]}

เดิมคืน binding เดียว ซึ่งพอ 1 อีเมลผูกได้หลายบริษัทแล้วจะกลายเป็น “หยิบมาอันหนึ่งแบบมั่ว” — ตรวจแล้วว่า ยังไม่มี FE หรือ QA repo ไหนเรียก endpoint นี้เลย จึงเปลี่ยนรูปได้ปลอดภัย · maskedLineUserId ยังปิดบังเหมือนเดิม ไม่ได้เปิดเผยเพิ่ม


O2 — เปิดจาก browser ปกติ (ไม่ใช่ใน LINE)

sequenceDiagram
  autonumber
  participant U as U
  participant FE as FE

  U->>FE: เปิด /register หรือ /unsubscribe จาก browser
  FE->>FE: lineClientGuard — LIFF ใช้งานไม่ได้
  FE-->>U: เด้งไป /open-in-line

สรุปตารางเทียบ เก่า → ใหม่

เรื่องเดิมตอนนี้
หน่วยของบัญชีอีเมลคู่ (อีเมล, บริษัท)
1 อีเมล ผูกได้กี่บริษัท1หลายบริษัท
1 บัญชี LINE ผูกได้กี่คู่1สูงสุด 20
อีเมลตัวพิมพ์ต่างกันคนละบัญชี (ช่องโหว่)บัญชีเดียวกัน — ฝั่งเทียบคู่ทำแล้ว ส่วนการหา user รอ PR #2004 (ดู R4)
ตัวตน LINEเชื่อค่าที่ browser ส่งverify ID token กับ LINE, fail closed
หน้าแรกของ flow ยกเลิกหน้ากรอกอีเมลหน้ารายการบัญชีที่เชื่อมต่อ
ค้นด้วยอีเมลอีเมลอย่างเดียวถอดออกทั้งเส้น (07/08) — เหลือเลือกจากรายการของบัญชี LINE ตัวเอง + ช่องกรองรายการฝั่งหน้าจอ
หลักฐาน consentTermsVersion 1 ช่อง ที่ถูกเขียนทับทุกครั้งที่สมัครใหม่ตาราง append-only — ตัวตน, นิติบุคคล, membership ณ ตอนนั้น, OTP, เวอร์ชัน+แฮชข้อกำหนดที่ server ดึงเอง
ยกเลิกยกเลิก binding ที่ระบบเลือกให้เลือกเองจากรายการ + ตรวจสิทธิ์ 2 ชั้น