Private Docs

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); ไม่พบ → 404Controllers/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 keyApiKeyAuthFilter register แบบ global ก็จริง แต่ เป็น opt-in — ไม่มี attribute = ปล่อยผ่านทันทีBackend_Package/src/Filters/ApiKeyAuthFilter.cs:33-41
ingestionSFTP → 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 triggerPOST .../v1/admin/amlo-ingestion/run → 200 AmloIngestionRunSummary(Total, Processed, Skipped, Failed); 409 เฉพาะตอน Enabled=falseAdminAmloIngestionController.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 ทั้งหมด

  1. ไม่มี column ที่บอกว่าติด/ไม่ติด — ติด = มีแถวผลตรวจอยู่เท่านั้น
  2. ไม่มีสถานะ “cleared” และแถวเก่าไม่เคยถูกลบข้าม batch (ลบเฉพาะ batch ของตัวเองตอน retry) → query ผลล่าสุดจะคืนของเก่าตลอดไป แม้บริษัทหลุดจากรายชื่อไปแล้ว — AmloIngestionOrchestrator.cs:154-157, AmloScreeningMatchRepository.cs:22-28

ทำไมนิยาม “ติดถ้าอยู่ใน batch ล่าสุด” ถึงใช้ไม่ได้

ทางหลักฐานผลถ้าใช้นิยามนั้น
CompletedWithErrors ก็เป็น terminalAmloIngestionBatch.cs:55,69-78มีแถวเสียแถวเดียว = ข้าม batch ล่าสุดทั้งใบ แล้วถอยไปใช้ข้อมูลเก่าเงียบ ๆ
หนึ่งรอบมีหลายไฟล์ = หลาย batch และหนึ่งวันมีได้หลายไฟล์AmloIngestionRunService.cs:89-108, AmloFileName.cs:16”batch ล่าสุด” ทิ้งไฟล์อื่นของวันเดียวกัน
Failed ไม่ใช่ terminal → retry ไฟล์เก่าได้AmloIngestionBatch.cs:55,81-87retry ไฟล์เมื่อ 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/default body {companyId}SetDefaultCompanyHandler เรียก ICompanyAccessGuard.ValidateAccessAsync ก่อนเสมอ · มี DELETE สำหรับล้าง default
  • ✅ switch ปัจจุบัน refresh Redis user-info แล้ว: SetDefaultCompanyCommand implement IUserMutationCommand; CacheRefreshBehavior register ใน application DI และเรียก IUserCacheRefreshService.RefreshAsync หลัง handler return; service query object graph ใหม่ → ToUserInfoForRedis map DefaultCompanyId + Companies[].IsDefaultStringSet พร้อม TTL ใหม่
  • 🔴 refresh เป็น best-effort: CacheRefreshBehavior catch 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 เดียว AppHub route /api/notification-service/hubs/app [Authorize] · group = user-{internalUserId}
  • PersonalSignalConsumer relay event name/payload อะไรก็ได้ จาก topic notification-personal → เพิ่ม event ใหม่ได้ โดยไม่ต้องแก้โค้ด NotificationService
  • ⚠️ แต่ถ้า payload deserialize เป็น InAppNotificationPayload แล้วมี Title ไม่ว่าง มันจะถูก เก็บเป็น in-app notification แทนการ relay → payload ของเรา ห้ามมี field titlePersonalSignalConsumer.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.switchCompanysyncUserInfoAndPermPATCH companies/default → patch signal ในหน่วยความจำ (ไม่ reload และไม่แตะ SignalR)
  • APIM cache ผล resolve-session 120 วินาทีตาม 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:

  1. checkout ของ Centralized ตามหลัง 19 commit → สรุปว่า “ไม่มี endpoint amlo-screening/{custCode} ในระบบ” ทั้งที่มีอยู่จริง
  2. checkout เดียวกันทำให้สรุปว่า admin endpoint ไม่มี auth เลย ทั้งที่ติด [RequireApiKey] แล้ว — ตัวที่เปิดโล่งจริงคือ endpoint อ่านผล ต่างหาก
  3. Backend_Package ตามหลัง 52 commit ทำให้ citation ของ comment ในไฟล์เลื่อนไปคนละที่

กฎ: fact ที่ใช้ตัดสิน design ต้อง git fetch แล้วอ่านจาก origin/<branch> เสมอ — และเครื่องมือที่ไม่มีสิทธิ์รัน git อ่าน origin ไม่ได้เลย ต้องให้คนหรือ agent ที่รัน git ได้เป็นคนตรวจ