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) |
| FE | Frontend_LINE_Connectivity |
| LINE | LINE Platform (LIFF SDK + endpoint verify) |
| US | Backend_UserService |
| DB | AuthDb |
| TP | ThirdPartyService (ข้อมูลนิติบุคคล) |
| CEN | Centralized (ข้อกำหนดและเงื่อนไข) |
| NS | NotificationService (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 ชั้น ตอนกดยืนยัน:
- คู่ที่ส่งมา อยู่ในรายการที่เพิ่งแสดงให้ดูไหม — กันการยิงคู่ที่ไม่เคยเห็น
- คู่นั้น ยังเป็นของบัญชี 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 ตัวเอง + ช่องกรองรายการฝั่งหน้าจอ |
| หลักฐาน consent | TermsVersion 1 ช่อง ที่ถูกเขียนทับทุกครั้งที่สมัครใหม่ | ตาราง append-only — ตัวตน, นิติบุคคล, membership ณ ตอนนั้น, OTP, เวอร์ชัน+แฮชข้อกำหนดที่ server ดึงเอง |
| ยกเลิก | ยกเลิก binding ที่ระบบเลือกให้ | เลือกเองจากรายการ + ตรวจสิทธิ์ 2 ชั้น |