AMLO — สถาปัตยกรรมและ Flow ทั้งระบบ
ภาพใหญ่ของ AMLO company blocking ตั้งแต่ไฟล์จาก SFTP จนถึงการบล็อกผู้ใช้ พร้อม sequence diagram ครบทุกเส้นทาง — เขียนจากโค้ดและฐานข้อมูลจริงบน dev
อัปเดต: 2026-09-01
เอกสารนี้แทนที่ชุดเก่า 9 ไฟล์ทั้งหมด · ทุกอย่างในนี้ verify กับโค้ดและฐานข้อมูล dev จริงเมื่อ 01/09/2026 คู่กับ การลงข้อมูลและสถานะ ที่ลงรายละเอียดระดับตารางและ status
1. AMLO คืออะไรในระบบเรา
ธนาคารได้ไฟล์รายชื่อบุคคล/นิติบุคคลที่ถูกเฝ้าระวังจาก AMLO เป็นงวด ๆ · ระบบเอาไฟล์นั้นมาจับคู่กับฐานลูกค้า แล้ว ห้ามผู้ใช้ที่เป็นสมาชิกของบริษัทที่ตรงเงื่อนไขเข้าใช้งานบางส่วนของระบบ จนกว่า Compliance จะพิจารณาปลดล็อกให้เป็นรายคน
แยกเป็น 2 ระบบคนละฐานข้อมูล:
| ระบบ | หน้าที่ | ฐานข้อมูล |
|---|---|---|
| Centralized Service | รับไฟล์ → จับคู่ → ปิดรอบเป็น generation | DEV_CentralizedDb schema los |
| UserService | อ่านผลมาเขียนสถานะบริษัท → บังคับใช้จริง | AuthDb schema public |
🔴 สองฝั่งไม่มี foreign key ถึงกัน — จับคู่ด้วย CustCode ที่ normalize แล้ว (Trim().ToUpperInvariant() เหมือนกันทั้งสองฝั่ง ณ ตอนนี้)
ถ้าวันหนึ่ง normalize ไม่ตรงกัน: พลาดทั้งหมด = reconcile abort ดัง ๆ · พลาดบางส่วน = หลุดเงียบ ๆ ซึ่งอันตรายกว่า
2. ภาพรวม — ข้อมูลไหลจากไฟล์ถึงหน้าจอผู้ใช้
flowchart LR
SFTP[/"ไฟล์ CSV จาก SFTP"/] --> ING["ingest<br/>อ่านไฟล์ทีละบรรทัด"]
ING --> B[("amlo_ingestion_batch<br/>+ amlo_screening_match")]
B --> GEN{"สร้าง generation<br/>AmloGenerationBuilder"}
GEN --> H[("snapshot_header<br/>+ _detail<br/>+ screened_company")]
H -.->|"reconcile job<br/>ทุกชั่วโมง<br/>จับคู่ด้วย CustCode"| C[("Companies.AmloStatus<br/>ฝั่ง UserService")]
C --> G{{"CompanyAccessGuard"}}
U[("AmloUserUnblocks<br/>สิทธิ์รายคน")] --> G
G -->|"Clear"| OK(["เข้าใช้งานได้"])
G -->|"Flagged / NotScreenable / Unknown"| NO(["403 + modal บนหน้าจอ"])
style GEN fill:#fff3cd
style C fill:#f8d7da
style G fill:#d1ecf1
จุดที่คนเข้าใจผิดบ่อยที่สุด: ระหว่าง screened_company กับ Companies.AmloStatus เป็นเส้นประ — ไม่ใช่การอ่านสด แต่เป็น reconcile job ที่รันทุกชั่วโมง ⇒ ข้อมูลใหม่ใน Centralized ยังไม่มีผลกับการบล็อก จนกว่า job รอบถัดไปจะรัน
3. Sequence — เส้นทางที่ 1: ไฟล์เข้าจนบริษัทถูกบล็อก
sequenceDiagram
autonumber
participant SFTP
participant RUN as AmloIngestionRunService (Centralized)
participant GB as AmloGenerationBuilder
participant CDB as DEV_CentralizedDb
participant REC as AmloReconcileWorker (UserService)
participant UDB as AuthDb
Note over RUN: รันตามเวลา หรือถูกสั่งจากปุ่ม "ตรวจ AMLO ตอนนี้"
RUN->>SFTP: list ไฟล์
SFTP-->>RUN: AMLO_yyyyMMddHHmmssfff.csv
alt ไฟล์นี้เคย ingest แล้ว
RUN->>RUN: นับเป็น Skipped
else ไฟล์ใหม่
RUN->>CDB: insert amlo_ingestion_batch (Processing)
loop ทีละบรรทัด
RUN->>CDB: insert amlo_screening_match<br/>หรือ amlo_ingestion_reject ถ้า parse ไม่ผ่าน
end
RUN->>CDB: update batch = Completed
RUN->>RUN: processed += 1
end
alt processed = 0
RUN--x GB: ข้ามการสร้าง generation
Note over RUN,GB: 🔴 ไม่มีไฟล์ใหม่ = ไม่เรียก builder เลย<br/>กลไกกู้ batch ค้างอยู่ใน builder จึงไม่ได้ทำงานตามไปด้วย
else processed > 0
RUN->>GB: BuildAsync(input)
GB->>CDB: ResolveBatchIds — batch รอบนี้ + batch เก่าที่ GenerationBuiltAtUtc ยัง null
GB->>CDB: อ่าน match ของทุก batch ที่ resolve ได้
GB->>GB: จัดกลุ่มตาม CustCode<br/>คำนวณ EvidenceFingerprint
GB->>GB: ด่านตรวจความครบถ้วน -> GateResult
GB->>CDB: insert snapshot_header (IsCurrent=true)<br/>+ snapshot_detail + screened_company
GB->>CDB: update batch.GenerationBuiltAtUtc
end
Note over REC: คนละ service คนละจังหวะ — รันทุกชั่วโมง
REC->>CDB: อ่าน generation ปัจจุบัน (IsCurrent=true)
REC->>UDB: จับคู่ CustCode แล้วเขียน<br/>Companies.AmloStatus = Flagged / Clear
REC->>UDB: เพิกถอนสิทธิ์ที่ fingerprint ต่างและ version ตรง
REC->>UDB: insert AmloAuditLog (เขียนหลังเพิกถอน)
🔴 กับดักที่เจอจริงบน dev (01/09)
processed = 0 ⇒ ไม่เรียก BuildAsync เลย (AmloIngestionRunService.cs:174-180 return ก่อน) · ไฟล์ที่เคย ingest แล้วนับเป็น Skipped ไม่ใช่ processed
เกิดขึ้นแล้วบน dev: ไฟล์ถูก ingest สำเร็จ 31/07 แต่โค้ด AmloGenerationBuilder เพิ่งเขียน 23/08 ⇒ ตอน ingest ยังไม่มีตัวสร้าง generation · หลังจากนั้นไฟล์เดิม Skipped ทุกรอบ ⇒ processed = 0 ตลอด ⇒ generation ไม่เคยถูกสร้างจากข้อมูลจริงเลยสักครั้ง
หลักฐาน (dev 01/09): amlo_ingestion_batch.GenerationBuiltAtUtc = NULL · amlo_screening_snapshot_detail มี 0 แถว ทั้งที่ match มี 409 แถว
✅ แต่ระบบ มี กลไกกู้ batch ที่ค้าง — อย่าไปแก้ด้วยมือ
AmloGenerationBuilder.ResolveBatchIdsAsync (:70-93) union batch เก่าที่ GenerationBuiltAtUtc IS NULL เข้ามาด้วยทุกครั้งที่ถูกเรียก — comment ในโค้ดระบุเคสนี้ตรงตัว (“a past round whose build step threw after ingest already committed”)
⇒ วางไฟล์ใหม่แค่ 1 ไฟล์ ระบบจะลาก batch เก่าที่ค้างทั้งหมดเข้า generation ให้เอง ไม่ต้อง insert อะไรด้วยมือ
🔴 ห้าม insert
snapshot_header/screened_companyด้วยมือ — บน dev มีรอยแล้ว (CreatedBy = manual:amlo-simulate-29-08) ทำให้ระบบมีข้อมูลปลอมที่ดูเหมือนของจริง
กดปุ่ม “ตรวจ AMLO ตอนนี้” ซ้ำไม่ช่วย — ไปดู SFTP เจอไฟล์เดิมก็
Skippedอยู่ดี · ต้องมีไฟล์ที่ระบบยังไม่เคยเห็น (วางไฟล์เดิมด้วยชื่อใหม่ก็ได้ เพราะชื่อไฟล์มี timestamp)
4. Sequence — เส้นทางที่ 2: ผู้ใช้ถูกบล็อกตอนเรียก API
sequenceDiagram
autonumber
participant FE as HostApp (เบราว์เซอร์)
participant APIM
participant SG as Sentinel Gateway
participant US as UserService
participant DB as AuthDb
FE->>APIM: เรียก API พร้อม cookie
APIM->>SG: resolve-session (cookie -> token)
SG-->>APIM: accessToken
APIM->>US: forward + Authorization: Bearer
Note over APIM: 🔴 APIM ไม่รู้จัก AMLO เลย<br/>ทำแค่แปลง cookie เป็น JWT
US->>US: validate JWT -> [Authorize]
US->>DB: CompanyAccessGuard ด่านที่ 1 — เป็นสมาชิก Active ของบริษัทนี้ไหม
alt ไม่ใช่สมาชิก
US-->>FE: 403 COMPANY_NOT_MEMBER หรือ COMPANY_NO_MEMBERSHIP
end
alt AmloEnforcement.Enabled = false
US-->>FE: 200 — ท่อน AMLO ทั้งก้อนถูกข้าม
end
US->>DB: ด่านที่ 2 — อ่าน Companies.AmloStatus
US->>DB: มี AmloUserUnblocks ที่ Status=Active ไหม
US->>US: GetEffectiveStatus(rawStatus, hasActiveGrant)
alt effective = Clear
US-->>FE: 200 ทำงานปกติ
else effective = Flagged
US-->>FE: 403 COMPANY_AMLO_BLOCKED
FE->>FE: แสดง modal บล็อก
else effective = NotScreenable
US-->>FE: 403 COMPANY_AMLO_NOT_SCREENABLE
else effective = Unknown (AmloStatus เป็น null)
US-->>FE: 403 COMPANY_AMLO_STATUS_UNKNOWN
end
ตัวตัดสินอยู่ที่ UserService ที่เดียว — APIM ทำหน้าที่ authentication และเป็นทางเข้าเท่านั้น เอกสารเก่าที่เขียนว่า “บล็อกที่ APIM ครอบทุกบริการ” ไม่ตรงกับของจริง
⚠️ guard ครอบแค่ 4 handler ของ UserService ไม่ใช่ทั้งระบบ — DetachSelf · SetDefaultCompany · GetCompanyDetail · ListCorporateMembers (นับจาก call site ของ ValidateAccessAsync ทั้งหมด) · service อื่น (Workflow, Notification, ThirdParty, FX) ยังเรียกได้ปกติ
5. Sequence — เส้นทางที่ 3: admin ปลดล็อกให้รายคน
sequenceDiagram
autonumber
participant AD as Admin Portal
participant FMS as FileManagementService
participant US as UserService
participant DB as AuthDb
participant SB as Service Bus
participant FE as HostApp ของผู้ใช้
AD->>US: GET /admin/amlo/cases
US-->>AD: บริษัทที่ AmloStatus = Flagged
AD->>US: GET /companies/{id}/members
US-->>AD: สมาชิก + สถานะปลดล็อกรายคน
Note over AD: admin กรอกเหตุผล + แนบไฟล์<br/>ตรวจไฟล์ฝั่ง client 8 กฎก่อน
AD->>FMS: POST /files/upload (category=amlo-evidence)
Note over FMS: ตรวจซ้ำ: content-type, extension,<br/>ชื่อไฟล์, ขนาด, magic bytes
FMS-->>AD: fileId
AD->>US: POST /admin/amlo/companies/{id}/unblock<br/>{targetUserId, reason, evidenceFileIds}
US->>DB: ตรวจเป็นสมาชิก Active ก่อน (โดยตั้งใจ — คนนอกต้องไม่ทำให้เกิด lock)
US->>DB: ล็อกแถว Companies (FOR UPDATE)
US->>US: ตรวจต่อ: บริษัท Flagged + ยังไม่มีสิทธิ์ Active อยู่แล้ว
US->>DB: insert AmloUserUnblocks (Status=Active)
US->>DB: insert AmloAuditLog (UserUnblockGranted)
US->>SB: publish AmloStateChanged
US-->>AD: 200
SB->>FE: SignalR AmloStateChanged
FE->>US: ดึงสถานะใหม่
FE->>FE: modal บล็อกหายไป
สิ่งที่ตรวจก่อนอนุมัติ — ไม่มี role check โดยเจตนา (มติ Owner 26/08) ตัวกันที่เหลือคือ:
- บริษัทต้องเป็น
Flaggedอยู่จริง - เป้าหมายต้องเป็นสมาชิก
Activeของบริษัทนั้น - ต้องมีเหตุผล 1–500 ตัวอักษร
- ต้องแนบไฟล์หลักฐาน 1–10 ไฟล์ (ไม่ได้ตรวจว่าไฟล์มีอยู่จริง)
- ห้ามซ้ำ — มีสิทธิ์
Activeอยู่แล้วตอบ 409
6. Sequence — เส้นทางที่ 4: เพิกถอนสิทธิ์อัตโนมัติ
sequenceDiagram
autonumber
participant REC as AmloReconcileWorker
participant CDB as Centralized
participant UDB as AuthDb
participant SB as Service Bus
Note over REC: ทุกชั่วโมง ไม่ขึ้นกับ AmloEnforcement
REC->>CDB: อ่าน generation ปัจจุบัน
REC->>UDB: เทียบกับ Companies ทีละราย
loop ทุกบริษัทในรอบ
REC->>UDB: เขียนทับ Companies 6 คอลัมน์ทุกรอบ (ExecuteUpdate ไม่เทียบค่าเดิม)
alt FingerprintVersion ไม่ตรงกับของ generation
REC->>UDB: ข้าม ไม่เพิกถอน — อัลกอริทึมเปลี่ยน ไม่ใช่หลักฐานเปลี่ยน
else version ตรง + fingerprint เท่าเดิม
REC->>UDB: ข้าม ไม่มีอะไรเปลี่ยน
else version ตรง + fingerprint ต่าง
REC->>UDB: Status = RevokedBySync
REC->>UDB: insert AmloAuditLog (RevokedBySync)
REC->>SB: publish AmloStateChanged<br/>เฉพาะคนที่สถานะจริงเปลี่ยน
end
end
หัวใจคือ EvidenceFingerprint — ตอนให้สิทธิ์ ระบบบันทึกว่าอ้างหลักฐานชุดไหน (GrantedAgainstFingerprint) · รอบถัดไปถ้าหลักฐานเปลี่ยน สิทธิ์เดิมถูกเพิกถอนอัตโนมัติ โดยไม่ต้องมีคนสั่ง
🔴 แต่มีด่าน FingerprintVersion กั้นอยู่ก่อน (AmloReconcileWorker.cs:270-272) — เงื่อนไขจริงคือ version ต้องตรง AND fingerprint ต้องต่าง
⇒ วันไหนเปลี่ยนอัลกอริทึมคำนวณ fingerprint (version หมุน) สิทธิ์ Active ทุกใบรอดหมด ไม่มีใครถูกเพิกถอน และคนกลุ่มนั้นผ่าน guard ต่อไป — เป็นพฤติกรรมที่ตั้งใจ (แยกแยะ “อัลกอริทึมเปลี่ยน” ออกจาก “หลักฐานเปลี่ยน”) แต่ต้องรู้ไว้เวลา rotate version
7. Config ที่เปลี่ยนพฤติกรรมทั้งระบบ
| key | ที่ | ผล |
|---|---|---|
AmloIngestion.Enabled | Centralized | เปิด/ปิดการอ่านไฟล์จาก SFTP — ปิดแล้วปิดทั้ง 2 ทาง ทั้ง scheduler และปุ่มสั่งเอง (คืน 409) · IaC: dev = true · sit/uat = false |
AmloIngestion.FileSemantics | Centralized | Delta = ไม่มี Clear sweep · Snapshot = บริษัทที่หายจากไฟล์ถูกปลดอัตโนมัติ |
AmloEnforcement.Enabled | UserService | ปิดอยู่ = ข้ามเฉพาะท่อน AMLO ด่านตรวจสมาชิก (COMPANY_NOT_MEMBER) ยังทำงาน · reconcile job ยังเขียนสถานะตามปกติ |
AmloIngestion.DailyRunHourBangkok | Centralized | ingestion รัน วันละครั้ง 23:00 เวลาไทย (ต่างจาก reconcile ที่รายชั่วโมง) |
🔴 FileSemantics = Delta ทำให้ Held ทุก generation ไม่ใช่แค่รอบแรก — เป็นกฎ 2 ข้อแยกกันใน AmloCompletenessGate.cs:43-47:
generationNo == 1→Held(bootstrap ไม่มีรอบก่อนหน้าให้เทียบ)FileSemantics == Delta→Heldเสมอ ไม่มีเงื่อนไข
⇒ ภายใต้ config ปัจจุบัน (dev/sit/uat = Delta ทั้งหมด) จะไม่มีวันได้ Passed อย่ารอ generation ที่ 2 แล้วคาดว่า auto-unblock จะทำงาน
Held ยังบล็อกได้ปกติ (ReconcileMode.BlockOnly) แต่ไม่มี Clear sweep และไม่มีการปลดอัตโนมัติ · gate มีเหตุ Held ทั้งหมด 6 ข้อ ไม่ใช่ข้อเดียว
🔴 AmloEnforcement.Enabled ไม่มีอยู่ใน service repo เลย — key นี้อยู่เฉพาะใน Backend_Iac/config/user-service/{dev,sit,uat}/appsettings.json ⇒ แก้ appsettings ใน repo ของ service = ไม่มีผล
แต่ยังมีทางลัด: platform นี้ inject config เป็น env var Section__Key ผ่าน KV/CSI อยู่แล้ว และ env var มี precedence สูงกว่า appsettings ทุกชั้นใน .NET
⇒ AmloEnforcement__Enabled ทำเป็น KV secret แล้ว restart pod เปลี่ยนได้ ไม่ต้อง rebuild image (ยังต้องแก้ IaC manifest ให้ mount alias)
⚠️ ทางกลับก็จริง — KV secret หลงมาตัวเดียวเปิด enforcement ได้โดยไม่มีใครแตะ appsettings
8. ขอบเขตจริงของการบล็อก (อย่าเข้าใจเกิน)
บล็อกได้: 4 handler ของ UserService ที่มี ICompanyAccessGuard ครอบ — DetachSelf · SetDefaultCompany · GetCompanyDetail · ListCorporateMembers
บล็อกไม่ได้: ทุกอย่างนอกนั้น — service อื่นทั้งหมด และ endpoint อื่นของ UserService เอง
ถ้าอยากให้ครอบทุก service มี 2 ทาง — ทำ guard กลางใน Backend_Package (ตรงกับโครงระบบ) หรือบล็อกที่ APIM (ต้องแก้ให้ APIM รู้สถานะ AMLO ก่อน ซึ่งติดข้อจำกัด 2 ข้อ: Sentinel ไม่ได้ mint JWT เองจึงเติม claim ไม่ได้ · และ AMLO เป็นสถานะราย (user, company) แต่ APIM ไม่รู้ว่า request นี้ของบริษัทไหน)
9. หน้าจอที่เกี่ยวข้อง
| หน้า | ใคร | ทำอะไร |
|---|---|---|
/amlo/operations | Admin Portal | ดู generation ปัจจุบัน + สั่งตรวจรอบใหม่ |
/amlo/cases | Admin Portal | บริษัทที่ติด → สมาชิก → ฟอร์มปลดล็อก |
/amlo/history | Admin Portal | ประวัติการปลดล็อกทุกบริษัท + ดู/ดาวน์โหลดไฟล์หลักฐาน |
| modal บล็อก | HostApp | ผู้ใช้ที่โดนบล็อกเห็นตอนเรียก API ที่มี guard |
⚠️ เมนู Admin Portal มาจาก permission dataset ในฐานข้อมูล ไม่ใช่ route ในโค้ด — deploy โค้ดอย่างเดียวแล้วเปิดหน้าไม่ได้ ต้องเพิ่ม menu row + ผูก role ในทุก env
ปุ่ม “รีเฟรช” กับ “ตรวจ AMLO ตอนนี้” ต่างกัน: รีเฟรช = อ่าน generation ปัจจุบันมาแสดงเฉย ๆ · ตรวจ AMLO ตอนนี้ = สั่ง Centralized ไปอ่าน SFTP สร้าง generation ใหม่ (ไม่ทันที ทำเบื้องหลัง) · ทั้งสองปุ่มไม่ได้ทำให้ใครถูกบล็อกทันที เพราะต้องรอ reconcile job รอบถัดไป