AMLO Screening — Architecture
สถาปัตยกรรมการ block ผู้ใช้ที่สังกัดบริษัทติด AMLO — สถานะโค้ดปัจจุบันที่ verify แล้ว, นิยาม flagged แบบ sticky, จุดบังคับใช้ฝั่ง server (session layer), การ fan-out แบบ reconcile-by-pull, สัญญา API, data model, config และกฎแข็ง
อัปเดต: 2026-08-18
หน้านี้อธิบายว่าระบบจะเป็นยังไง — แนวคิด, จุดบังคับใช้, ข้อมูล, สัญญา API, config ทุก scenario พร้อม Sequence Diagram อยู่ที่ Scenario + Sequence Diagram · เรื่องที่ยังต้องตัดสินใจและงานที่ต้องปิดก่อน go-live อยู่ที่ Open Items
ฐานหลักฐาน: ทุก fact ในหน้านี้ตรวจกับโค้ดจริงบน origin/development ของแต่ละ repo (fetch 18/08/2026) ไม่ใช่ working tree ในเครื่อง — สำคัญเพราะ checkout หลายตัวตามหลัง origin หลายสิบ commit และ fact ที่ต่างกันมีผลต่อ design จริง (ดูตัวอย่างที่ §9)
1. โจทย์
AMLO screening = การตรวจว่าบริษัทลูกค้าติด watchlist ฟอกเงินหรือไม่ ระบบได้ไฟล์รายวันจากต้นทางแล้ว ingest เข้ามาเก็บไว้ที่ centralized-service
สิ่งที่ต้องได้:
- ผู้ใช้ที่ บริษัทที่ active อยู่ ติด AMLO ต้อง ทำกระบวนการใด ๆ ไม่ได้
- ผู้ใช้ที่ค้าง session อยู่ต้องรู้ทันทีผ่าน SignalR → modal
- sync มือได้ ไม่ต้องรอรอบ
- ผู้ใช้ที่ login ทีหลังต้องรู้ตอน bootstrap ผ่านการเรียก API เส้นเดียว
🔴 หลักการที่ทั้งเอกสารนี้ยืนอยู่บน: modal คือ UX ไม่ใช่การ block ผู้ใช้ที่ไม่ได้รับ push (offline, ปิด browser, ยิง API ตรง, remote app เรียก service อื่น) ต้องถูกปฏิเสธที่ฝั่ง server อยู่ดี ถ้าออกแบบให้ modal เป็นตัวกั้น = ควบคุมด้าน compliance ล้มเหลวโดยไม่มีใครรู้
2. สถานะโค้ดปัจจุบัน (verify แล้ว — ห้ามเดาเกินนี้)
2.1 Centralized — เจ้าของข้อมูล match
| เรื่อง | ความจริง | หลักฐาน |
|---|---|---|
| endpoint อ่านผล | GET api/centralized-service/v{v}/amlo-screening/{custCode} → AmloScreeningMatchResponse(Id, CustCode, SearchMethod, Similarity, FlagSystem, AmloType, LogDatetimeUtc, CreatedAt); ไม่พบ → 404 | Controllers/v1/AmloScreeningController.cs:15-26, Features/AmloScreening/DTOs/AmloScreeningMatchResponse.cs:6-24 |
ความหมายของ id ใน URL ที่ Owner ส่งมา (.../amlo-screening/6329225) | คือ CustCode ไม่ใช่ juristicId หรือ internal id | เหมือนข้างบน |
| query ที่อยู่เบื้องหลัง | latest match ต่อ CustCode — OrderByDescending(LogDatetimeUtc).ThenByDescending(CreatedAt).ThenByDescending(Id) | Repositories/Amlo/AmloScreeningMatchRepository.cs:30-42 |
| 🔴 auth ของ endpoint อ่าน | ไม่มี — [RequireApiKey] ไม่ได้ติด และ [Authorize] ก็ไม่มี (ไฟล์ import SupApp_util_lib.Abstractions.Security ไว้แต่ไม่ได้ใช้) | AmloScreeningController.cs:1-26 |
| auth ของ endpoint สั่ง ingest | มี [RequireApiKey] แล้ว | AdminAmloIngestionController.cs:19 |
| ⚠️ กลไก API key | ApiKeyAuthFilter register แบบ global ก็จริง แต่ เป็น opt-in — ไม่มี [RequireApiKey] = ปล่อยผ่านทันที | Backend_Package/src/Filters/ApiKeyAuthFilter.cs:33-41 |
| ingestion | SFTP → BackgroundService รายวัน ตาม AmloIngestion:DailyRunHourBangkok default 23 (ไม่ใช่ตี 5) กันซ้อนด้วย leader-lock Redis amlo-ingest:leader TTL 30 นาที | BackgroundJobs/AmloScreeningIngestionBackgroundService.cs, BackgroundJobs/AmloIngestionRunService.cs:21,45-50, Configuration/AmloIngestionSettings.cs:26 |
| manual trigger | POST .../v1/admin/amlo-ingestion/run → 200 AmloIngestionRunSummary(Total, Processed, Skipped, Failed); 409 เฉพาะตอน Enabled=false | AdminAmloIngestionController.cs:26-38 |
| 🔴 manual trigger ตอนชน lock | คืน 200 (0,0,0,0) เงียบ ๆ ซึ่งแยกไม่ออกจากกรณี “ไม่มีไฟล์ใหม่” | AmloIngestionRunService.cs:45-50,113 |
| 🔴 ingestion ไม่ publish event ใดเลย | grep Publish/Outbox/IMediator ใน feature = 0 ทั้งที่ repo มี IAuditEventPublisher/ISignalPublisher/outbox ใช้ที่อื่น | Centralized02.Infrastructure/DependencyInjection.cs:252-265 |
2.2 Data model ปัจจุบัน (schema los)
erDiagram
amlo_ingestion_batch ||--o{ amlo_screening_match : "BatchId"
amlo_ingestion_batch ||--o{ amlo_ingestion_reject : "BatchId"
amlo_ingestion_batch {
uuid Id PK
varchar FileName UK "unique = idempotency key"
date StampDate "จากชื่อไฟล์"
int RowCount
int RowCountSuccess
varchar Status "Processing/Completed/CompletedWithErrors/Failed"
timestamptz ProcessedAt
text ErrorMessage
}
amlo_screening_match {
uuid Id PK
uuid BatchId FK
varchar CustCode "คีย์ที่ผูกกับบริษัทเรา"
varchar SearchMethod
numeric Similarity "fuzzy score"
uuid RecordGuid
varchar AmloType
timestamptz LogDatetimeUtc
}
amlo_ingestion_reject {
uuid Id PK
uuid BatchId FK
int LineNumber
text RawLine
varchar ErrorReason
}
🔴 สองข้อที่กำหนดรูปร่างของ design ทั้งหมด:
- ไม่มี column ที่บอกว่า “ติด/ไม่ติด” — flagged = มี match row อยู่เท่านั้น
- ไม่มี state “cleared” และ match เก่าไม่เคยถูกลบข้าม batch (ลบเฉพาะ
BatchIdของตัวเองตอน retry) → latest-match query จะคืน match เก่าตลอดไป แม้บริษัทหลุด watchlist ไปแล้ว —AmloIngestionOrchestrator.cs:154-157,AmloScreeningMatchRepository.cs:22-28
2.3 UserService — เจ้าของความสัมพันธ์ user ↔ company
- membership =
UserCompanyMapping(UserId, CompanyId, IsStatus, IsDefault)· บริษัทที่ active =IsDefault=true - สลับบริษัท:
PATCH /api/user-service/v1/companies/defaultbody{companyId}→SetDefaultCompanyHandlerเรียกICompanyAccessGuard.ValidateAccessAsyncก่อนเสมอ · มีDELETEสำหรับล้าง default GET /v1/users/meคืนCompanies[]=(Id, NameTH, NameEN, IsDefault, Type, JuristicId, ProfileImageURL, Role, CustCode)— มี CustCode ต่อบริษัทอยู่แล้วGET /v1/companies/cust-codesคืน(CustCode, CompanyId, UserId)ของทั้งระบบ — มีอยู่แล้วแต่ยังไม่มี service ไหนเรียกเลย ([AllowAnonymous]และ ไม่มี[RequireApiKey]อยู่ในไฟล์เลย ไม่ใช่แค่ถูก comment ไว้)- cache API:
POST /v1/cache/users/warm/{oid},POST /v1/cache/users/refresh/{userId},DELETE /v1/cache/users/{userId}([AllowAnonymous],[RequireApiKey]comment ไว้เช่นกัน) - 🔴 ไม่มี chokepoint ระดับ pipeline ที่เห็นบริษัทที่ active — ตัวที่ใกล้สุดคือ
ICompanyAccessGuardซึ่งเป็น application-layer helper และถูกเรียกจาก แค่ 4 handler ใน feature เดียว (SetDefaultCompanyHandler.cs:41,DetachSelfHandler.cs:39,GetCompanyDetailHandler.cs:32,ListCorporateMembersHandler.cs:84) แถมตรวจแค่ “เป็นสมาชิกบริษัทนี้ไหม” ไม่ได้ตรวจสถานะบริษัท
2.4 Redis user-info blob (ของกลางใน SupApp_util_lib)
- key
{KeyPrefix}:user:info:{oid}· มีdefaultCompanyId,defaultCompanyName,companies[] (companyId, companyName, isDefault, status, role, custCode) - 🔴 middleware ทิ้ง blob ทั้งก้อน ถ้า
SchemaVersion != CurrentSchemaVersion(เทียบแบบ strict) → field ใหม่ต้องเป็น optional และ ห้าม bump schema version (comment ในไฟล์สั่งไว้ตรง ๆ) —RedisUserInfoMiddleware.cs:193-198,UserInfoForRedis.cs:64-73 - middleware re-warm จาก HTTP fallback แล้ว stamp TTL ใหม่ → invalidate อาจถูก re-warm ทับได้ถ้าลำดับผิด
- 🔴 blob ไม่มีลายเซ็น — อะไรก็ตามที่เขียน Redis ได้ ก็แก้ค่าใน blob ได้
2.5 SignalR + Frontend
- hub เดียว
AppHubroute/api/notification-service/hubs/app[Authorize]· group =user-{internalUserId} - ✅
PersonalSignalConsumerrelay event name/payload อะไรก็ได้แบบ ephemeral จาก topicnotification-personal→ เพิ่ม event ใหม่ได้โดยไม่ต้องแก้โค้ด NotificationService เลย - ⚠️ แต่ถ้า payload deserialize เป็น
InAppNotificationPayloadแล้วมีTitleไม่ว่าง มันจะถูก เก็บเป็น in-app notification แทนการ relay → payload ของเราต้อง ห้ามมี fieldtitle—PersonalSignalConsumer.cs:99-110 - FE subscribe อยู่ 4 event:
Notification,UnreadCountUpdated,MaintenanceToggle,AppStatusChanged - FE bootstrap:
syncUserProfile()→realtime.initialize()→syncUserInfoAndPerm()(/users/me) — SignalR ต่อก่อน perms เสร็จ = มี race window - modal:
ex-modal(@exim/ui-kit) ทำให้ปิดไม่ได้ด้วย[closeOnBackdrop]="false" [closeOnEscape]="false"และไม่ผูก(closed) - สลับบริษัทฝั่ง FE:
CorporateState.switchCompany→syncUserInfoAndPerm→PATCH companies/default→ patch signal ในหน่วยความจำ (ไม่ reload, ไม่แตะ SignalR)
3. นิยาม “ติด AMLO” — sticky ไม่ใช่ตาม batch ล่าสุด
นี่คือจุดที่ผิดง่ายที่สุดและพังเงียบที่สุด
นิยามที่ใช้: บริษัทถือว่า Flagged เมื่อมี match row ของ custCode นั้นอยู่ (เคยมีก็นับ) และ การหายไปจากไฟล์รอบหลังไม่ปลดบล็อกเอง — ปลดได้ทางเดียวคือ clearance ที่บันทึกไว้ (ใคร ปลดเมื่อไหร่ ด้วยเหตุผล/หลักฐานอะไร)
ทำไมไม่ใช้ “มี match ใน batch ล่าสุด”: นิยามนั้นถูกหักล้างด้วยโค้ดจริง 3 ทาง
Complete()ตั้งสถานะเป็นCompletedWithErrorsทันทีที่มีแถวเสียแม้แถวเดียว และทั้งสองสถานะเป็น terminal — กรองStatus == Completedอย่างเดียว = ข้าม batch ล่าสุดของวันนั้นทั้งใบ แล้วเงียบ ๆ ถอยไปใช้ข้อมูลเก่า (AmloIngestionBatch.cs:55,69-78)- หนึ่งรอบรันประมวลผลหลายไฟล์ แต่ละไฟล์เป็นคนละ batch และชื่อไฟล์อนุญาตหลายไฟล์ต่อวัน (
AMLO_(?<ts>\d{8,17})\.csv) — “batch ล่าสุด” = ทิ้งไฟล์อื่นของวันเดียวกัน (AmloIngestionRunService.cs:89-108,AmloFileName.cs:16) Failedไม่ใช่ terminal → ไฟล์เก่าที่เคย fail ถูก retry ทีหลังได้ ถ้าเรียง “ล่าสุด” ด้วยProcessedAtการ retry ไฟล์เมื่อ 3 วันก่อนจะกลายเป็น “ล่าสุด” แล้ว ปลดบล็อกทุกคนที่เพิ่งโดนบล็อกวันนี้ (AmloIngestionBatch.cs:55,81-87)
นิยาม sticky ยังถูกต้องไม่ว่าไฟล์ต้นทางจะเป็น full snapshot หรือ delta ซึ่งเรายังไม่รู้ (ดู Open Items §1) — และตรงกับสิ่งที่ endpoint ปัจจุบันคืนอยู่แล้ว จึงไม่ต้องเปลี่ยน query semantics ในเฟสแรก
⚠️ แถวที่ parse ไม่ผ่าน = custCode ที่ยังไม่ถูกตรวจ — batch ที่มี reject ห้ามถือว่าครอบคลุมครบ ต้องคง flag เดิมไว้แล้ว alert ห้ามให้ parse error กลายเป็น false negative ด้าน compliance
สามสถานะ ไม่ใช่สอง
| สถานะ | ความหมาย | ผลต่อผู้ใช้ |
|---|---|---|
Flagged | มี match | บล็อก |
Clear | ตรวจแล้วไม่พบ (asOfUtc + batch ที่อ้างอิง) | ผ่าน |
NotScreenable | ตรวจไม่ได้ — CustCode ว่าง/null/รูปแบบไม่ตรงกับที่อยู่ในไฟล์ | ไม่ใช่ Clear ต้องขึ้น ops alert |
ถ้าไม่มี NotScreenable บริษัทที่ยังไม่ได้ sync custCode จาก core-banking จะตกลงถัง “ไม่ติด” อย่างเงียบ ๆ และไม่มีวันถูกบล็อกได้เลย (Company.CustCode เป็น nullable จริง)
4. จุดบังคับใช้ (enforcement) — หัวใจของงานนี้
4.1 ทำไม “ใส่ flag ใน Redis blob แล้วให้ UserService ตรวจ” ไม่พอ
ICompanyAccessGuardถูกเรียกจาก 4 handler ใน feature เดียวของ UserService เท่านั้นBackend_WorkflowServiceและBackend_TaskServicegrep หาGetUserInfo()/DefaultCompanyId= 0 → field ใหม่ใน blob มองไม่เห็นเลยโดยสิ้นเชิง- service ที่ยังใช้
SupApp_util_libเวอร์ชันเก่า จะ deserialize field ใหม่เป็น null แล้วเมินเงียบ ๆ และเราไม่มีทางรู้จาก runtime - field ต้องเป็น optional (default = ไม่บล็อก) และทางเลือกที่จะบังคับให้ fail-closed คือ bump
CurrentSchemaVersionซึ่ง ห้ามทำ เพราะ strict!=จะทำให้ service ที่ยังไม่อัปเกรด ทิ้ง blob ทั้งก้อน จนNameIdentifierหายและพังหนักกว่าเดิม - call แบบ service-to-service (X-API-Key) ไม่ผ่าน middleware อยู่แล้ว และ WorkflowService ยังใช้
BypassAuthHandlerบน non-prod
สรุป: blob layer ทำให้ flag มองเห็นได้ แต่ไม่ได้ทำให้ บังคับใช้ และโดยโครงสร้างแล้วมันเป็น fail-open เสมอ
4.2 โมเดล 3 ชั้นที่เสนอ
flowchart TB
subgraph L1["ชั้น 1 — Session (การรับประกันจริง)"]
A["APIM facade อ่าน cookie<br/>→ POST /sentinel/internal/resolve-session"]
B["resolve-session คืน accessToken<br/><b>+ amloBlocked: true/false/null</b>"]
C{"amloBlocked = true<br/>และ path ไม่อยู่ใน allowlist?"}
A --> B --> C
C -->|ใช่| D["403 COMPANY_AMLO_BLOCKED<br/>ไม่ต้องถึง backend เลย"]
C -->|ไม่| E["แนบ Bearer แล้ว forward ตามเดิม"]
end
subgraph L2["ชั้น 2 — UserService (แหล่งความจริงของ allow)"]
F["state ระดับ company ที่ materialize ไว้<br/>Flagged / Clear / NotScreenable + asOfUtc"]
G["ตรวจใน CompanyAccessGuard + handler ที่เป็น 'การกระทำ'"]
end
subgraph L3["ชั้น 3 — Redis blob (deny hint เท่านั้น)"]
H["amloBlocked ใน UserInfoForRedis<br/>ใช้ปฏิเสธเร็ว ๆ ได้ แต่ห้ามใช้เป็นใบอนุญาต"]
end
E --> G
G --> F
G -.อ่านเป็น hint.-> H
กฎแข็ง 3 ข้อ
- ชั้น 1 คือสิ่งที่รับประกันความครอบคลุม เพราะทุก request ที่ถือ cookie วิ่งผ่าน facade อยู่แล้ว — APIM ไม่ต้องรู้ business logic อะไรเลย แค่อ่าน boolean หนึ่งตัวจาก response ที่มันแกะอยู่แล้ว และ staleness ถูกจำกัดด้วย cache 120 วินาทีของ facade ที่มีอยู่แล้ว
- blob เป็นได้แค่ “hint สำหรับปฏิเสธ” ห้ามเป็น “ใบอนุญาต” — handler ที่เห็น
amloBlocked=falseยังต้องยืนยันกับ state ในชั้น 2 ก่อนทำรายการ เพราะ blob ไม่มีลายเซ็นและใครที่เขียน Redis ได้ก็แก้ได้ - tri-state + fail-closed — ค่าที่เป็นไปได้คือ
true(ปฏิเสธ) /false(ตรวจแล้วสะอาด ณamloAsOfUtc) /null= UNKNOWN (blob ก่อนมีฟีเจอร์, lib เก่า, เขียนไม่ครบ, หรือGetUserInfo()เป็น null). UNKNOWN และ “สะอาดแต่เก่าเกิน N ชั่วโมง” ต้องปฏิเสธหรือไปตรวจซ้ำ สำหรับทุก action ที่มีมูลค่า ส่วน read-only ปล่อยผ่านได้
⚠️ ถ้า Owner ไม่อนุมัติให้แตะ Sentinel/APIM ชั้น 1 หายไป ทางเลือกที่ซื่อสัตย์คือย้ายการบังคับใช้ไปไว้ใน middleware ของ
SupApp_util_lib(short-circuit 403 เอง) พร้อมบัญชีเวอร์ชัน lib รายบริการ และต้องเขียนตรง ๆ ในเอกสารว่า service ที่ยังไม่ deploy ใหม่ = ยังไม่ถูกบังคับใช้ ห้ามพูดว่า “ครอบคลุมแล้ว”
4.3 Allowlist — ถ้าลืมข้อนี้ ผู้ใช้ที่ติด AMLO จะติดค้างจนทำอะไรไม่ได้เลย
ถ้าบล็อกทุก path ผู้ใช้จะเห็นแค่จอว่างหรือ spinner หมุน แล้วสลับไปบริษัทที่สะอาดก็ไม่ได้ ออกจากระบบก็ไม่ได้ ต้องยกเว้น:
| path | เหตุผล |
|---|---|
GET /v1/users/me | modal ต้องใช้รายชื่อบริษัท + flag ต่อบริษัท |
PATCH / DELETE /v1/companies/default | คือทางออกเดียวของผู้ใช้ (สลับไปบริษัทสะอาด) |
auth-flow paths (/sentinel/login, /signin-oidc-, /signout-callback-oidc-, /sentinel/logout) | passthrough อยู่แล้วในนโยบาย facade ปัจจุบัน |
/api/notification-service/hubs/app | ถ้าตัด hub ทิ้ง จะไม่มีทาง push “ปลดบล็อกแล้ว” กลับไปหาผู้ใช้ได้อีกเลย |
และต้องสะท้อนกฎเดียวกันในชั้น 2: ปฏิเสธเฉพาะ action ที่ผูกกับบริษัทที่ติด — การสลับ เข้าไป บริษัทที่สะอาดต้องทำได้เสมอ
5. การกระจายผล (fan-out) — pull เป็นหลัก push เป็นของแถม
flowchart LR
SFTP[/"ไฟล์ AMLO_*.csv<br/>บน SFTP"/] --> ING["Centralized ingestion<br/>(รอบเวลา หรือสั่งมือ)"]
ING --> DB[("los.amlo_screening_match")]
ING -->|เสร็จรอบ| REC
TIMER["timer เดินช้า ๆ<br/>(safety net)"] --> REC
REC["UserService reconcile<br/>1. อ่าน /companies/cust-codes<br/>2. ถาม Centralized ทีละ custCode<br/>3. คำนวณสถานะใหม่"]
REC --> ST[("company AMLO state<br/>Flagged/Clear/NotScreenable")]
ST -->|commit แล้วค่อย| INV["invalidate Redis user-info<br/>ของ user ที่กระทบ"]
ST -->|best-effort| PUSH["publish topic notification-personal<br/>→ SignalR event AmloBlocked"]
PUSH --> FE["modal เด้ง"]
INV --> NEXT["request ถัดไปเห็นค่าใหม่"]
ทำไม pull ไม่ใช่ push: สายส่งวันนี้รั่วได้ทุกข้อต่อ — Centralized ไม่มี outbox (crash หลัง commit ก่อน publish = event หายถาวร), consumer ฝั่ง UserService เป็น raw Service Bus ไม่มี outbox ไม่มี dedup (ข้อความเน่าเข้า DLQ = ผู้ใช้คนนั้นไม่ถูกบล็อกตลอดกาลแบบเงียบ ๆ), และ PersonalSignalConsumer ถือว่างานเสร็จทันทีหลัง NotifyUserAsync ไม่ว่าจะมี connection อยู่จริงหรือไม่ (PersonalSignalConsumer.cs:109-112)
การ reconcile ด้วยการดึงสถานะทั้งชุดมาคำนวณใหม่ ทำให้ระบบ self-healing: event หายก็แค่ช้า ไม่ใช่ผิดถาวร
ลำดับที่ห้ามสลับ: commit state ก่อน → invalidate cache ทีหลัง → invalidate ซ้ำอีกครั้งหลัง commit ถ้าสลับลำดับ request ที่วิ่งพร้อมกันจะ re-warm ค่าเก่ากลับเข้า Redis พร้อมประทับ TTL ใหม่ยาวอีก 24 ชม.
payload ของ event: ต้องไม่มี field title (มิฉะนั้นถูกเก็บเป็น in-app notification แทนที่จะ relay ออก SignalR) และต้องไม่มีข้อมูล match ใด ๆ (ดู §7)
6. สัญญา API ที่เปลี่ยน
6.1 /users/me — ตอบ scenario 4 ด้วย endpoint ที่ FE เรียกอยู่แล้ว
Owner ขอ “call api 1 เส้นตอน login” — เส้นนั้นควรเป็น /users/me ที่ FE เรียกอยู่แล้วตอน bootstrap ไม่ใช่ endpoint ใหม่ เพราะ modal ต้องใช้ทั้ง “ถูกบล็อกไหม” และ “บริษัทไหนสะอาดบ้าง” ซึ่งอยู่ก้อนเดียวกัน การแยกเป็นสองเส้น = สอง round trip ที่ต้อง sync กันเอง และไปขยาย race window ที่ §2.5 มีอยู่แล้ว
// GET /api/user-service/v1/users/me (เพิ่ม field เท่านั้น ไม่ breaking)
{
"user": { /* เหมือนเดิม */ },
"companies": [
{
"id": "…", "nameTH": "…", "isDefault": true, "custCode": "6329225",
"amloBlocked": true // ← เพิ่ม: true | false | null (null = UNKNOWN/ยังตรวจไม่ได้)
}
],
"amloAsOfUtc": "2026-08-18T05:00:00Z" // ← เพิ่ม: ข้อมูลนี้สดแค่ไหน
}
6.2 ห้ามคืนอะไรกลับไปที่ FE
Similarity, SearchMethod, FlagSystem, AmloType, RecordGuid, match Id, LogDatetimeUtc, batchId และชื่อ/เลขประจำตัวใน watchlist — ห้ามหลุดไป FE และห้ามลง log เพราะเป็นทั้งวิธีการคัดกรองของธนาคารและข้อมูลตัวตนจาก watchlist ของบุคคลที่สาม (รู้ score/method = จูนข้อมูลหลบการ match ได้)
การปฏิเสธคืน 403 + code COMPANY_AMLO_BLOCKED พร้อมข้อความไทยกลาง ๆ ไม่มีรายละเอียด match
6.3 manual sync
reuse POST /v1/admin/amlo-ingestion/run แต่ต้องแก้ 2 อย่าง:
- แยกผลลัพธ์ให้ออก — วันนี้ทั้ง “ชน lock” และ “ไม่มีไฟล์ใหม่” คืน
200 (0,0,0,0)เหมือนกัน ต้องคืน 409 เมื่อชน lock และคืน outcome ที่อ่านออก ไม่ใช่ให้ผู้เรียกเดาจากตัวเลข - 🔴 sync มือต้องสั่ง recompute ด้วย ไม่ใช่แค่อ่าน SFTP ซ้ำ — ถ้าไม่มีไฟล์ใหม่ ปุ่มนี้จะ “สำเร็จ” โดยไม่เกิดอะไรขึ้นเลย ซึ่งไม่ตรงกับที่ Owner ต้องการ (“sync มือได้โดยไม่ต้องรอรอบ” = ให้ผลการบล็อกอัปเดตเดี๋ยวนี้)
7. Audit trail (ข้อบังคับ ไม่ใช่ของแถม)
ต้องบันทึกให้ครบ 5 อย่าง: (1) วงจรชีวิตของ batch รวมถึงรอบที่ได้ 0 แถว (2) การเปลี่ยนสถานะรายบริษัท (companyId, custCode, flagged/cleared, batchId, asOfUtc, เหตุผล) (3) ทุกครั้งที่บังคับใช้จริง (403 พร้อม user/company/batch ที่ใช้ตัดสิน — นี่คือหลักฐานว่าระบบทำงาน) (4) ใครสั่ง sync มือ เมื่อไหร่ ผลอะไร (5) ใครปลดบล็อกด้วยหลักฐานอะไร
ใช้ IAuditEventLogger + AsbAuditEventPublisher ที่มีอยู่แล้ว (เติม actor/source/correlation-id ให้อัตโนมัติ) — ⚠️ แต่มันจะ degrade เป็น NullAuditEventPublisher เงียบ ๆ ถ้า env ไม่ได้ตั้ง Service Bus ต้องมี startup guard/health check ใน env ที่เปิดใช้ควบคุมนี้ ไม่งั้น audit trail หายทั้งชุดโดยไม่มี error
8. Config
| key | ค่าที่ต้องเป็น | ที่แก้ |
|---|---|---|
AmloIngestion:DailyRunHourBangkok | 5 (ตี 5 ตาม Owner) | IaC ทุก env — แก้ appsettings.{ENV}.json ใน repo อย่างเดียวไม่มีผล CI overlay ทับตอน build |
AmloIngestion:Enabled | true ใน env ที่มี SFTP | IaC |
AmloIngestion:StabilityWindowMinutes | ต้องคำนวณคู่กับเวลาไฟล์มาถึง | IaC |
RedisUserInfo TTL (CacheTtlSeconds) | ต้องรู้ค่าจริงต่อ env เพราะมันคือขอบบนของความ stale | IaC/KV — ต้องไปยืนยัน |
⚠️ กับดักเวลา: รอบรันจะหยิบเฉพาะไฟล์ที่นิ่งเกิน StabilityWindowMinutes ถ้าไฟล์มาถึงประมาณ 04
9. บันทึกวิธีทำงาน — ทำไมต้องอ่านจาก origin ไม่ใช่ working tree
ระหว่างทำเอกสารนี้ การอ่าน checkout ในเครื่องให้ข้อสรุปผิด 2 ครั้ง และทั้งสองครั้งเปลี่ยน design:
- checkout ของ Centralized ตามหลัง 19 commit → สรุปว่า “ไม่มี endpoint
amlo-screening/{id}ในระบบ” ทั้งที่มีอยู่จริงบนorigin/development - checkout เดียวกันทำให้สรุปว่า admin endpoint ไม่มี auth เลย ทั้งที่บน
origin/developmentติด[RequireApiKey]แล้ว — ของจริงที่ยังเปิดโล่งคือ endpoint อ่านผล (AmloScreeningController) ต่างหาก
Backend_Package ในเครื่องตามหลัง 52 commit — ของที่ใหม่จริงในช่วงนั้นคือ field encryption at rest ส่วน strict schema-version guard มีมาก่อนแล้ว (ยืนยันบน origin/development: UserInfoForRedis.cs:65,70) ทั้งคู่คือสิ่งที่กำหนดข้อจำกัดใน §4.1 โดยตรง
กฎ: fact ที่ใช้ตัดสิน design ต้องอ่านจาก origin/<branch> หลัง fetch เสมอ