AMLO Screening — Code Baseline
หลักฐานจากโค้ดจริงที่สถาปัตยกรรมนี้ยืนอยู่บน — Centralized, UserService, Redis user-info, SignalR และ Frontend พร้อม file:line ทุกข้อ รวมถึงกับดักที่คนลงมือเขียนต้องรู้
อัปเดต: 2026-08-18
หน้านี้สำหรับคนที่จะลงมือเขียน — ไม่ต้องอ่านถ้าต้องการแค่ภาพรวม (Architecture) ทุกข้อในหน้านี้อ่านจาก
origin/developmentของแต่ละ repo (fetch 18/08/2026) ไม่ใช่ working tree — เหตุผลอยู่ที่ §6
1. Centralized — เจ้าของข้อมูลผลตรวจ
| เรื่อง | ความจริง | หลักฐาน |
|---|---|---|
| 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 ตัวอย่าง (…/amlo-screening/6329225) | คือ CustCode ไม่ใช่ juristicId หรือ internal id | เหมือนข้างบน |
| query เบื้องหลัง | ผลล่าสุดต่อ CustCode — OrderByDescending(LogDatetimeUtc).ThenByDescending(CreatedAt).ThenByDescending(Id) | Repositories/Amlo/AmloScreeningMatchRepository.cs:30-42 |
| 🔴 auth ของ endpoint อ่าน | ไม่มีเลย — ไม่มี [RequireApiKey] ไม่มี [Authorize] (ไฟล์ import security namespace ไว้แต่ไม่ได้ใช้) | AmloScreeningController.cs:1-26 |
| auth ของ endpoint สั่ง ingest | มี [RequireApiKey] แล้ว | AdminAmloIngestionController.cs:19 |
| ⚠️ กลไก API key | ApiKeyAuthFilter register แบบ global ก็จริง แต่ เป็น opt-in — ไม่มี attribute = ปล่อยผ่านทันที | Backend_Package/src/Filters/ApiKeyAuthFilter.cs:33-41 |
| ingestion | SFTP → BackgroundService รายวันตาม AmloIngestion:DailyRunHourBangkok default 23 · กันซ้อนด้วย 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 |
| 🔴 ผลลัพธ์ที่แยกไม่ออก | ชน lock กับ ไม่มีไฟล์ใหม่ คืน 200 (0,0,0,0) เหมือนกันทั้งคู่ | AmloIngestionRunService.cs:45-50,113 |
| 🔴 ingestion ไม่ publish อะไรเลย | grep Publish/Outbox/IMediator ใน feature = 0 ทั้งที่ repo มี IAuditEventPublisher/ISignalPublisher/outbox ใช้ที่อื่น | Centralized02.Infrastructure/DependencyInjection.cs:252-265 |
| index ที่มีแล้ว | (CustCode, LogDatetimeUtc) | migration 20260806094117 |
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 ที่บอกว่าติด/ไม่ติด — ติด = มีแถวผลตรวจอยู่เท่านั้น
- ไม่มีสถานะ “cleared” และแถวเก่าไม่เคยถูกลบข้าม batch (ลบเฉพาะ batch ของตัวเองตอน retry) → query ผลล่าสุดจะคืนของเก่าตลอดไป แม้บริษัทหลุดจากรายชื่อไปแล้ว —
AmloIngestionOrchestrator.cs:154-157,AmloScreeningMatchRepository.cs:22-28
ทำไมนิยาม “ติดถ้าอยู่ใน batch ล่าสุด” ถึงใช้ไม่ได้
| ทาง | หลักฐาน | ผลถ้าใช้นิยามนั้น |
|---|---|---|
CompletedWithErrors ก็เป็น terminal | AmloIngestionBatch.cs:55,69-78 | มีแถวเสียแถวเดียว = ข้าม batch ล่าสุดทั้งใบ แล้วถอยไปใช้ข้อมูลเก่าเงียบ ๆ |
| หนึ่งรอบมีหลายไฟล์ = หลาย batch และหนึ่งวันมีได้หลายไฟล์ | AmloIngestionRunService.cs:89-108, AmloFileName.cs:16 | ”batch ล่าสุด” ทิ้งไฟล์อื่นของวันเดียวกัน |
Failed ไม่ใช่ terminal → retry ไฟล์เก่าได้ | AmloIngestionBatch.cs:55,81-87 | retry ไฟล์เมื่อ 3 วันก่อนกลายเป็น “ล่าสุด” แล้วปลดบล็อกทุกคนที่เพิ่งโดนวันนี้ |
⚠️ และ แถวที่ parse ไม่ผ่าน = custCode ที่ยังไม่ถูกตรวจ — batch ที่มี reject ห้ามถือว่าครอบคลุมครบ ต้องคงสถานะเดิมไว้แล้ว alert
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 - ✅ switch ปัจจุบัน refresh Redis user-info แล้ว:
SetDefaultCompanyCommandimplementIUserMutationCommand;CacheRefreshBehaviorregister ใน application DI และเรียกIUserCacheRefreshService.RefreshAsyncหลัง handler return; service query object graph ใหม่ →ToUserInfoForRedismapDefaultCompanyId+Companies[].IsDefault→StringSetพร้อม TTL ใหม่ - 🔴 refresh เป็น best-effort:
CacheRefreshBehaviorcatch exception แล้ว log warning โดยไม่เปลี่ยน response ดังนั้น DB switch อาจสำเร็จและ API ตอบ success ทั้งที่ Redis ยังเป็นบริษัทเดิม — สำหรับ AMLO ต้องปิดช่องนี้และทดสอบ Redis write failure 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]ในไฟล์เลย (CompanyController.cs:196,199)- cache API:
POST /v1/cache/users/warm/{oid},POST /v1/cache/users/refresh/{userId},DELETE /v1/cache/users/{userId}—[AllowAnonymous]โดย[RequireApiKey]ถูก comment ไว้ (UserCacheController.cs:16-18) - 🔴 ไม่มี chokepoint ระดับ pipeline ที่เห็นบริษัทที่ active — ตัวที่ใกล้สุดคือ
ICompanyAccessGuardซึ่งเป็น application-layer helper ถูกเรียกจาก แค่ 4 handler (SetDefaultCompanyHandler.cs:41,DetachSelfHandler.cs:39,GetCompanyDetailHandler.cs:32,ListCorporateMembersHandler.cs:84) และตรวจแค่ “เป็นสมาชิกบริษัทนี้ไหม” ไม่ได้ตรวจสถานะบริษัท - Service Bus = ใช้
Azure.Messaging.ServiceBusตรง ๆ publisher/consumer ต่อ message type ไม่มี outbox
4. Redis user-info blob (ของกลางใน SupApp_util_lib)
- เจ้าของ contract คือ
Backend_Package— การเพิ่ม field AMLO ต้องแก้UserInfoForRedis, serialization/deserialization และ middleware ที่นี่ ห้ามประกาศ DTO รูปเดียวกันแยกใน UserService/Sentinel แล้วหวังว่าจะตรงกันเอง - key
{KeyPrefix}:user:info:{oid}· มีdefaultCompanyId,defaultCompanyName,companies[] (companyId, companyName, isDefault, status, role, custCode) - 🔴 middleware ทิ้ง blob ทั้งก้อน ถ้า
SchemaVersionไม่ตรงแบบ strict → field ใหม่ต้องเป็น optional และ ห้าม bump schema version (comment ในไฟล์สั่งไว้ตรง ๆ) —RedisUserInfoMiddleware.cs:193-198,UserInfoForRedis.cs:65,70 - ผลที่ตามมา: field บอกการบล็อกใน blob fail-closed ไม่ได้โดยธรรมชาติ — service ที่ยังใช้ lib เก่าจะอ่านเป็น null แล้วเมินเงียบ ๆ
- middleware re-warm จาก HTTP fallback แล้วต่ออายุ TTL ใหม่ → การล้าง cache อาจถูกเขียนทับได้ถ้าลำดับผิด
- 🔴 blob ไม่มีลายเซ็น — อะไรก็ตามที่เขียน Redis ได้ ก็แก้ค่าในนั้นได้ จึงใช้เป็น “ตัวช่วยปฏิเสธ” ได้ แต่เป็น “ใบอนุญาต” ไม่ได้
- call แบบ service-to-service (X-API-Key) ไม่ผ่าน middleware อยู่แล้ว และ WorkflowService ยังใช้
BypassAuthHandlerบน non-prod
5. SignalR + Frontend
- hub เดียว
AppHubroute/api/notification-service/hubs/app[Authorize]· group =user-{internalUserId} - ✅
PersonalSignalConsumerrelay event name/payload อะไรก็ได้ จาก topicnotification-personal→ เพิ่ม event ใหม่ได้ โดยไม่ต้องแก้โค้ด NotificationService - ⚠️ แต่ถ้า payload deserialize เป็น
InAppNotificationPayloadแล้วมีTitleไม่ว่าง มันจะถูก เก็บเป็น in-app notification แทนการ relay → payload ของเรา ห้ามมี fieldtitle—PersonalSignalConsumer.cs:99-110 - ตัวส่งถือว่างานเสร็จทันทีหลังเรียก push ไม่ว่าปลายทางจะออนไลน์หรือไม่ (
PersonalSignalConsumer.cs:109-112) — ไม่มีการรับประกันการส่งถึง - FE subscribe อยู่ 4 event:
Notification,UnreadCountUpdated,MaintenanceToggle,AppStatusChanged(ไม่มีReceiveNotification) - FE bootstrap:
syncUserProfile()→realtime.initialize()→syncUserInfoAndPerm()— SignalR ต่อก่อน perms กลับมา = มีช่วงที่ event มาถึงก่อนข้อมูลพร้อม - modal:
ex-modal(@exim/ui-kit) ทำให้ปิดไม่ได้ด้วย[closeOnBackdrop]="false" [closeOnEscape]="false"และไม่ผูก(closed) - สลับบริษัทฝั่ง FE:
CorporateState.switchCompany→syncUserInfoAndPerm→PATCH companies/default→ patch signal ในหน่วยความจำ (ไม่ reload และไม่แตะ SignalR) - APIM cache ผล
resolve-session120 วินาทีตาม session cookie (sentinel-facade-inbound-v6.xml:149,199); grep policy ทั้งหมดบนorigin/developmentพบcache-lookup-value/cache-store-valueแต่ ไม่พบcache-remove-valueดังนั้น Redis refresh หลัง switch ไม่ได้ทำให้ cache ชั้นนี้หายเอง ต้องเพิ่ม eviction/session revision มิฉะนั้น request หลัง switch ยังตัดสินจากบริษัทเดิมได้ถึง 120 วินาที - 🔴 R3 ยังต้องมีงานใน
Frontend_AdminSuperApp: หน้า/action สำหรับ Admin/Ops กด sync มือและแสดง running/result/conflict/error; endpoint backend ที่มีอยู่เพียงอย่างเดียวไม่ทำให้ requirement นี้ใช้งานได้
6. ทำไมต้องอ่านจาก origin ไม่ใช่ working tree
ระหว่างทำเอกสารชุดนี้ การอ่าน checkout ในเครื่องให้ข้อสรุปผิด 3 ครั้ง และสองครั้งแรกเปลี่ยน design:
- checkout ของ Centralized ตามหลัง 19 commit → สรุปว่า “ไม่มี endpoint
amlo-screening/{custCode}ในระบบ” ทั้งที่มีอยู่จริง - checkout เดียวกันทำให้สรุปว่า admin endpoint ไม่มี auth เลย ทั้งที่ติด
[RequireApiKey]แล้ว — ตัวที่เปิดโล่งจริงคือ endpoint อ่านผล ต่างหาก Backend_Packageตามหลัง 52 commit ทำให้ citation ของ comment ในไฟล์เลื่อนไปคนละที่
กฎ: fact ที่ใช้ตัดสิน design ต้อง git fetch แล้วอ่านจาก origin/<branch> เสมอ — และเครื่องมือที่ไม่มีสิทธิ์รัน git อ่าน origin ไม่ได้เลย ต้องให้คนหรือ agent ที่รัน git ได้เป็นคนตรวจ