Private Docs

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รับไฟล์ → จับคู่ → ปิดรอบเป็น generationDEV_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) ตัวกันที่เหลือคือ:

  1. บริษัทต้องเป็น Flagged อยู่จริง
  2. เป้าหมายต้องเป็นสมาชิก Active ของบริษัทนั้น
  3. ต้องมีเหตุผล 1–500 ตัวอักษร
  4. ต้องแนบไฟล์หลักฐาน 1–10 ไฟล์ (ไม่ได้ตรวจว่าไฟล์มีอยู่จริง)
  5. ห้ามซ้ำ — มีสิทธิ์ 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.EnabledCentralizedเปิด/ปิดการอ่านไฟล์จาก SFTP — ปิดแล้วปิดทั้ง 2 ทาง ทั้ง scheduler และปุ่มสั่งเอง (คืน 409) · IaC: dev = true · sit/uat = false
AmloIngestion.FileSemanticsCentralizedDelta = ไม่มี Clear sweep · Snapshot = บริษัทที่หายจากไฟล์ถูกปลดอัตโนมัติ
AmloEnforcement.EnabledUserServiceปิดอยู่ = ข้ามเฉพาะท่อน AMLO ด่านตรวจสมาชิก (COMPANY_NOT_MEMBER) ยังทำงาน · reconcile job ยังเขียนสถานะตามปกติ
AmloIngestion.DailyRunHourBangkokCentralizedingestion รัน วันละครั้ง 23:00 เวลาไทย (ต่างจาก reconcile ที่รายชั่วโมง)

🔴 FileSemantics = Delta ทำให้ Held ทุก generation ไม่ใช่แค่รอบแรก — เป็นกฎ 2 ข้อแยกกันใน AmloCompletenessGate.cs:43-47:

  1. generationNo == 1Held (bootstrap ไม่มีรอบก่อนหน้าให้เทียบ)
  2. FileSemantics == DeltaHeld เสมอ ไม่มีเงื่อนไข

⇒ ภายใต้ 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/operationsAdmin Portalดู generation ปัจจุบัน + สั่งตรวจรอบใหม่
/amlo/casesAdmin Portalบริษัทที่ติด → สมาชิก → ฟอร์มปลดล็อก
/amlo/historyAdmin Portalประวัติการปลดล็อกทุกบริษัท + ดู/ดาวน์โหลดไฟล์หลักฐาน
modal บล็อกHostAppผู้ใช้ที่โดนบล็อกเห็นตอนเรียก API ที่มี guard

⚠️ เมนู Admin Portal มาจาก permission dataset ในฐานข้อมูล ไม่ใช่ route ในโค้ด — deploy โค้ดอย่างเดียวแล้วเปิดหน้าไม่ได้ ต้องเพิ่ม menu row + ผูก role ในทุก env

ปุ่ม “รีเฟรช” กับ “ตรวจ AMLO ตอนนี้” ต่างกัน: รีเฟรช = อ่าน generation ปัจจุบันมาแสดงเฉย ๆ · ตรวจ AMLO ตอนนี้ = สั่ง Centralized ไปอ่าน SFTP สร้าง generation ใหม่ (ไม่ทันที ทำเบื้องหลัง) · ทั้งสองปุ่มไม่ได้ทำให้ใครถูกบล็อกทันที เพราะต้องรอ reconcile job รอบถัดไป