เชื่อมแอปที่สร้างเองกับธนาคารที่สร้างเอง จนเงินขยับจริง — เพิ่มการโอนเข้าบัญชีในการชำระเงิน แล้วพิสูจน์ด้วย E2E
บทนำ
ฉันกำลังสร้างสองระบบแยกกันด้วยตัวคนเดียว ระบบหนึ่งคือแพลตฟอร์มรวมบริการที่มัดหลายบริการไว้ด้วยกัน (การเช่า/ขายวิดีโอและเพลง ฯลฯ) อีกระบบหนึ่งคือระบบธนาคารที่มีบัญชี การแนะนำแต่ละระบบเขียนไว้ใน จากการเช่า DVD สู่แพลตฟอร์มแบบ DMM และ ระบบธนาคารที่สร้างเองมีอะไรทำงานอยู่จริงบ้าง
ฉันเชื่อมสองสิ่งนี้ด้วยเส้นเดียวเป็นครั้งแรก เพิ่ม “การโอนเข้าบัญชี” ของธนาคารที่สร้างเอง เข้าไปเป็นช่องทางชำระเงินของฝั่งแพลตฟอร์ม ให้ผู้ใช้ชำระค่าเช่าด้วยการโอนจากบัญชีธนาคารของตนเองได้ และไม่ใช่แค่เชื่อมกันบนกระดาษออกแบบแล้วจบ แต่ต่อจนถึงจุดที่ยอดเงินขยับจริง ด้วย E2E ที่รันจริงโดยถอดม็อกออก บทความนี้คือบันทึกว่าออกแบบการเชื่อมนี้อย่างไร และยืนยันด้วย E2E อย่างไร
ปลายทางที่การโอนไปลงคือบัญชีของธนาคารที่สร้างเองนี้ (Mainichi Bank / EVERY DAYS BANK)

ภาพรวมทั้งหมด
เมื่อผู้ใช้เลือก “โอนเข้าบัญชี” ที่หน้าจอชำระเงิน แพลตฟอร์มจะขอให้ธนาคารทำการโอนอยู่เบื้องหลัง เมื่อเสร็จก็บันทึกการชำระเงินและยืนยันสินค้า (ในที่นี้คือการเช่าส่งถึงบ้าน)
ผู้ใช้:เลือก "โอนเข้าบัญชี" ที่หน้าจอชำระเงินแล้วจ่าย
│
▼
แพลตฟอร์ม (=เมื่อมองจากธนาคารคือ "ร้านค้าสมาชิก")
├─▶ ① ยืนยันตัวตนแบบบริการกับธนาคาร (ในฐานะร้านค้าสมาชิก) → รับโทเคนอายุสั้น
├─▶ ② ใช้โทเคนนั้นขอทำการโอน (พร้อมคีย์กันซ้ำ)
│ │
│ ▼
│ ธนาคาร (บัญชี・การโอน)──▶ ยอดเงินขยับ
│
└─▶ ③ โอนเสร็จ → บันทึกการชำระเงิน แล้วยืนยันการจัดส่ง
ดูเผิน ๆ ก็ง่าย แต่ที่นี่มีการตัดสินใจเชิงออกแบบหลายจุดที่พลาดไม่ได้
การยืนยันตัวตนระหว่างบริการ:ไม่ใช่ด้วยสิทธิ์ของผู้ใช้ แต่ด้วยสิทธิ์ของร้านค้าสมาชิก
ก่อนอื่น เมื่อแพลตฟอร์มขอให้ธนาคารทำการโอน จะขอด้วยสิทธิ์ของใคร
ห้ามยืมสิทธิ์ล็อกอินของผู้ใช้มาใช้ สำหรับธนาคารแล้ว แพลตฟอร์มไม่ใช่ผู้ใช้ แต่เป็น ร้านค้าสมาชิก ดังนั้นจึงยืนยันตัวตนแบบบริการกับธนาคารด้วยข้อมูลสิทธิ์ในฐานะร้านค้าสมาชิก (client credentials) แล้วรับ โทเคนอายุสั้นเฉพาะครั้งนั้น โทเคนถูกสลักไว้ว่า “นี่คือของร้านค้าสมาชิกไว้ใช้ชำระเงิน” และระบุลูกค้าปลายทางไว้แล้ว ความลับของธนาคารทั้งหมดถูกปิดไว้ในธนาคาร ฝั่งแพลตฟอร์มเพียงรับโทเคนมาใช้เท่านั้น
เขียนเป็นโค้ด ฝั่งแพลตฟอร์มทำ “จับคู่ → จัดหาโทเคน → โอน” ด้วยสิทธิ์ของร้านค้าสมาชิกเท่านั้น
// ฝั่งแพลตฟอร์ม:ยืนยันตัวตนแบบบริการกับธนาคารในฐานะร้านค้าสมาชิก → โทเคนอายุสั้น → โอน
UUID bankCustomerId = mapping.resolve(everydaysCustomerId); // จับคู่ ID สมาชิกไปยัง ID ฝั่งธนาคาร
String token = bank.issueServiceToken(bankCustomerId); // สิทธิ์ร้านค้า・อายุสั้น・วัตถุประสงค์=ชำระเงิน
String idemKey = idempotency.newKey(); // คีย์กันซ้ำเพื่อกันโอนซ้ำสองครั้ง
bank.transfer(token, bankCustomerId, amount, idemKey); // Authorization: Bearer <token>
ทำแบบนี้แล้ว ข้อมูลยืนยันตัวตนของผู้ใช้จะไม่ต้องวิ่งไปมาทุกครั้งที่ชำระเงิน และสิทธิ์ก็ถูกจำกัดให้ “น้อยที่สุดเท่าที่จำเป็นต่อการชำระเงิน”
แม้ความลับรั่ว เงินก็ถูกดูดออกไม่ได้:การจำกัดปลายทาง
โทเคนระหว่างบริการ สักวันย่อมรั่วได้ ออกแบบความเสียหายบนสมมติฐานว่ามันจะรั่ว
หากโทเคนสำหรับชำระเงินของร้านค้าสมาชิกรั่ว แล้วผู้โจมตีสามารถขอโอนไปยังบัญชีใดก็ได้ การดูดเงินก็จะสำเร็จ ดังนั้นฉันจึงวางด่านไว้ที่ฝั่งธนาคาร “โทเคนสำหรับชำระเงินของร้านค้าสมาชิก โอนได้เฉพาะไปยังบัญชีร้านค้าสมาชิกที่ลงทะเบียนไว้เท่านั้น” ปลายทางอื่นนอกจากนั้น ปฏิเสธทันทีโดยไม่มีข้อแม้ (fail-closed)
ขอโอนด้วยโทเคนสำหรับชำระเงินของร้านค้าสมาชิก
│
▼
ธนาคาร:ปลายทางเป็น "บัญชีร้านค้าสมาชิกที่ลงทะเบียนไว้" หรือไม่?
├─ ใช่ ─▶ ทำการโอน
└─ ไม่ใช่ ─▶ ปฏิเสธ (fail-closed)
(=แม้ความลับของร้านค้าสมาชิกรั่ว เงินก็ขยับไปบัญชีคนอื่นไม่ได้แม้แต่บาทเดียว)
// ฝั่งธนาคาร:โทเคนสำหรับชำระเงินของร้านค้าสมาชิก โอนได้เฉพาะ "บัญชีร้านค้าสมาชิกที่ลงทะเบียนไว้"
if (token.purpose() == MERCHANT_PAYMENT
&& !merchantAllowlist.contains(destinationAccountId)) {
throw new DestinationNotAllowedException(); // → 403 (fail-closed)
}
ปิดขังความเสียหายจากการรั่วไว้ที่จุดเดียวคือ “บัญชีร้านค้าสมาชิกที่ลงทะเบียนไว้” เท่านี้ แม้ความลับรั่ว การดูดเงินก็ไม่สำเร็จ
และอีกอย่างหนึ่ง คือออกคีย์กันซ้ำทุกครั้งที่ขอโอน เพื่อ ไม่ทำการโอนเดียวกันสองครั้ง ป้องกันการโอนซ้ำจากการส่งซ้ำของการสื่อสารหรือการที่ผู้ใช้กดสองครั้ง
ม็อกไม่ได้พิสูจน์ว่า “ใช้งานได้”
จากตรงนี้คือประเด็นหลัก
ระหว่างพัฒนาการเชื่อมต่อ บริการธนาคารฝั่งตรงข้ามถูกแทนที่ด้วยม็อก (WireMock) เพื่อทดสอบ ม็อกคืนคำตอบที่กำหนดไว้ต่อคำขอที่กำหนดไว้ การทดสอบเป็นสีเขียว และดู “เหมือนทำงานได้”
แต่พอถอดม็อกออกแล้ว รันทะลุด้วยกลุ่มบริการจริง (E2E จริง) ก็มีตัวขวางโผล่มาสองอย่างทันที ทั้งคู่คือความไม่ตรงกันที่ถูกซ่อนไว้เพราะม็อกคืนคำตอบที่กำหนดไว้ ฉันเก็บทีละอย่าง แล้วสุดท้าย — ยืนยันจนถึงจุดที่ ยอดเงินในบัญชีจริงขยับจริง
【ม็อก (WireMock)】
ชำระเงิน ─▶ ธนาคารปลอมคืน "OK" ─▶ การทดสอบเขียว … แต่ความไม่ตรงกันจริงยังซ่อนอยู่
【E2E จริง】
ชำระเงิน ─▶ ทะลุกลุ่มบริการจริง ─▶ ความไม่ตรงกันที่ซ่อนอยู่โผล่มา 2 จุด ─▶ แก้ ─▶ ยอดเงินขยับ
ม็อกพิสูจน์ได้ว่าโค้ดที่ตัวเองเขียน “ทำงานตามที่ตัวเองคาดไว้” แต่ ความไม่ตรงกันของขอบเขตกับฝั่งตรงข้าม ถูกซ่อนไว้หลังคำตอบที่กำหนดไว้ การเชื่อมต่อจะสำเร็จจริงหรือไม่ ต้องรันทะลุจริง แล้วดูจนถึง ผลข้างเคียง (ยอดเงินขยับ) เท่านั้นจึงจะรู้
จะพูดได้ว่า “เชื่อมกันแล้ว” ก็ต่อเมื่อยอดเงินขยับ
การเชื่อมต่อ พิสูจน์ได้ด้วย E2E จริงเท่านั้น แม้การทดสอบด้วยม็อกจะเขียว นั่นก็เป็นเรื่องของฝั่งตัวเอง ความไม่ตรงกันของขอบเขตกับฝั่งตรงข้ามถูกม็อกซ่อนไว้ สุดท้ายต้องรันทะลุของจริง แล้วยืนยันจนถึงจุดที่ผลข้างเคียงที่ตั้งใจ (ถ้าเป็นเงินก็คือการเปลี่ยนแปลงของยอด) เกิดขึ้น
ระหว่างบริการ ไม่ใช่ด้วยสิทธิ์ของผู้ใช้ แต่ด้วยสิทธิ์ของร้านค้าสมาชิกแบบสิทธิ์น้อยที่สุด จากนั้นจำกัดปลายทางไว้เฉพาะที่ลงทะเบียนไว้ (fail-closed) แม้ความลับรั่วก็ปิดขังความเสียหายไว้ที่จุดเดียว การเคลื่อนเงินป้องกันการซ้ำด้วยคีย์กันซ้ำ
เก็บ “ถ้าใช้จริงไม่ได้ก็ไม่มีความหมาย” ให้จบก่อน การเลื่อนการยืนยันออกไปไม่มีข้อดี พอเชื่อมแล้ว ก็รันของจริงตรงนั้นเลย
สองระบบที่สร้างเอง เชื่อมกันด้วยเส้นของเงินเป็นครั้งแรก บนกระดาษออกแบบมันเชื่อมกันมานานแล้ว แต่ต่อเมื่อยอดเงินขยับจริงเท่านั้น จึงจะพูดได้ว่า “เชื่อมกันแล้ว”
บทความที่เกี่ยวข้อง
- เนื้อในของระบบธนาคารที่สร้างเองซึ่งกลายเป็นปลายทางการโอน สรุปไว้ใน ระบบธนาคารที่สร้างเองมีอะไรทำงานอยู่จริงบ้าง
- เรื่องที่ทำการโอนนี้เองให้เป็นทรานแซกชันแบบกระจาย เพื่อไม่ให้พังแม้ล้มกลางคัน อยู่ใน ทำอย่างไรให้เงินไม่หายแม้บริการจะล่มระหว่างการโอน — รักษาความสอดคล้องของบัญชีที่กระจายตัวด้วย Saga และ Outbox
- เรื่องที่ API ธนาคารนั้น การอนุญาตของฝั่งอ่านหลุดไปจากฝั่งอัปเดต อยู่ใน IDOR ที่เกือบสร้างขึ้นใน API บัญชี
- การออกแบบหน้าแรกของแพลตฟอร์มที่เพิ่มการชำระเงินเข้าไป เขียนไว้ใน ทำไมดอนกิโฮเต้จึงเก่งในการทำให้คนหยิบของขึ้นมา