Private Docs

AMLO Screening — Phase 2 Design (Backend)

design ที่ลงมือได้จริงของ Phase 2 ฝั่ง backend — แบ่งตาม repo พร้อมลำดับข้าม repo, จุดเสียบในโค้ดจริง, กับดักของ pipeline behavior ที่มีอยู่ และ test plan

อัปเดต: 2026-08-20

หน้านี้เป็นงานของทีมเรา — ต่างจาก Phase 1 ที่ส่งต่อให้ dev คนอื่น จึงไม่ต้อง self-contained · อ่านคู่กับ Architecture และ Implementation Plan แตะ 4 repo: Backend_Package · Backend_Centralized · Backend_UserService · Backend_FileManagementService


1. ลำดับข้าม repo — ทำผิดลำดับแล้วติด

flowchart TB
    P0["ขั้น 0 — Backend_Package<br/>เพิ่ม field AMLO ใน UserInfoForRedis<br/>แล้ว publish version ใหม่"]
    P1["ขั้น 1 — consumer ที่อ่าน blob<br/>bump package version"]
    P2["ขั้น 2 — Centralized<br/>API อ่าน screened list แบบ bulk"]
    P3["ขั้น 3 — UserService<br/>schema + reconcile + unblock + audit"]
    P4["ขั้น 4 — FileManagementService<br/>เปิด upload/download + category หลักฐาน"]
    P5["ขั้น 5 — UserService<br/>ต่อ FMS เข้ากับ flow ปลดล็อก"]

    P0 --> P1 --> P3
    P2 --> P3
    P4 --> P5
    P3 --> P5
ขั้นทำไมต้องอยู่ตรงนี้
0 ก่อนทุกอย่างUserInfoForRedis เป็น contract กลาง ถ้า UserService เขียน field ใหม่ลง blob ก่อนที่ consumer จะรู้จัก field นั้น จะเกิดช่วงที่ blob มีข้อมูลที่ไม่มีใครอ่านออก · ลำดับที่ถูกคือ package → consumer ที่อ่าน → producer ที่เขียน
2 ขนานกับ 0-1 ได้Centralized API ไม่พึ่ง package version ใหม่
3 ต้องรอทั้ง 1 และ 2reconcile ต้องมีทั้ง contract ใน blob และเส้นอ่าน screened list
4 ขนานกับทุกอย่างFMS ไม่พึ่งใครเลย ทำแยกได้ตั้งแต่วันแรก
5 สุดท้ายต่อ flow ปลดล็อกเข้ากับ FMS ได้ก็ต่อเมื่อทั้งสองฝั่งพร้อม

2. Backend_Package — contract กลาง

2.1 สิ่งที่เพิ่ม

เพิ่ม field ลง UserCompanyInfo (record ย่อยใน UserInfoForRedis.cs) ที่วันนี้มี CompanyId, CompanyName, IsDefault, Status, Role, CustCode:

AmloStatus    string?   "amloStatus"     Flagged | Clear | NotScreenable | Unknown
AmloAsOfUtc   DateTime? "amloAsOfUtc"

ทั้งสองตัว optional (?) และมี [JsonPropertyName] ตาม pattern ของ field ที่เพิ่มทีหลังในไฟล์นี้ (CardId, PassportNo, CustCode, RoleType ล้วนเป็น additive แบบเดียวกัน)

AmloStatus เป็น string? ไม่ใช่ enum เพราะ blob เก่าที่ไม่มี field จะ deserialize เป็น null ได้ตรงไปตรงมา และ null ต้องอ่านว่า Unknown ไม่ใช่ Clear

2.2 เงื่อนไขรับงานขั้น 0

  • blob เก่าที่ไม่มี field ใหม่ deserialize ได้ ไม่ throw และได้ null
  • CurrentSchemaVersion ยังเป็น 1
  • service ที่ยังใช้ package เวอร์ชันเก่าอ่าน blob ที่มี field ใหม่ได้ตามปกติ (ignore field ที่ไม่รู้จัก)
  • publish ผ่าน checklist ของ skill publish-backend-package แล้ว verify ว่า DLL ใน package ตรงกับ source จริง

3. Backend_Centralized — เส้นอ่าน screened list

3.1 ทำไมต้องมีเส้นใหม่ ไม่ใช้ของเดิม

ของเดิม GET /v1/amlo-screening/{custCode} มีปัญหา 3 ข้อกับงานนี้:

ปัญหาผล
ถามได้ทีละ custCodereconcile ต้องไล่ทุกบริษัทในระบบ = ยิง HTTP หลายพันครั้งต่อรอบ
ไม่บอกว่าคำตอบมาจาก generation ไหนเขียน AmloAuditLog ที่อ้าง snapshot ไม่ได้ ต้อง lookup ซ้ำ
อ่านจาก staging ที่ยังไม่ผ่านการจัดกลุ่มได้แถวดิบที่ซ้ำกัน ไม่ใช่คำตอบ

เส้นใหม่ต้องคืน generation identity มาพร้อมข้อมูลในครั้งเดียว เพื่อให้ audit เขียนค่าแช่แข็งได้เลย ไม่ต้องถามซ้ำ

3.2 Contract

// GET /api/centralized-service/v1/amlo-screening/current-generation
{
  "snapshotHeaderId": "…",
  "generationNo": 42,
  "gateResult": "Passed",          // Passed | Held | Failed
  "asOfUtc": "2026-08-20T05:00:00Z",
  "companies": [
    { "custCode": "0079195", "matchCount": 3, "evidenceFingerprint": "…" }
  ]
}

ห้ามใส่ชื่อ/นามสกุล/Similarity/SearchMethod ลง response นี้เด็ดขาด — §7 ของ architecture ห้ามให้ข้อมูลการคัดกรองหลุดออกจาก Centralized · UserService ไม่จำเป็นต้องรู้ว่าใครคือคนที่ match มันต้องการแค่ “custCode ไหนติด และหลักฐานเปลี่ยนหรือยัง”

ต้องมี [RequireApiKey] — ต่างจากเส้นเดิมที่ไม่มี auth เลย (ซึ่งเป็นช่องที่ต้องปิดแยกต่างหาก ดู Plan §4.1 ข้อ 10)

ถ้า companies ยาวมาก ต้องมี paging — แต่ข้อมูลจริงตอนนี้คือ 84 custCode จึงยังไม่จำเป็น ให้เขียน guard ไว้ว่าถ้าเกินจำนวนที่กำหนดให้ตอบ error แทนที่จะส่งก้อนยักษ์เงียบๆ

3.3 วางที่ไหน

controller ใหม่ หรือ action ใหม่ใน AmloScreeningController ก็ได้ · service impl วางที่ Centralized03.Application/Features/AmloScreening/ ตาม pattern AmloScreeningQueryService · port ที่ Centralized04.Domain/Ports/Amlo/ · repository ที่ Centralized02.Infrastructure/Persistence/Repositories/Amlo/

⚠️ ตารางที่อ่าน (amlo_screened_company, amlo_screening_snapshot_header) เป็นของ Phase 1 — schema เป็น contract ที่ freeze แล้ว ถ้าจะขอเปลี่ยนต้องคุยกับคนที่ทำ Phase 1 ก่อน


4. Backend_UserService — งานหลักของเฟสนี้

4.1 กับดัก 3 ข้อของ pipeline ที่มีอยู่ — อ่านก่อนเขียน handler

4.2 Schema ที่เพิ่ม

คอลัมน์ใหม่บน Companies — allow NULL ทุกตัว · NULL แปลว่า “ยังไม่เคยตรวจ” ไม่ใช่ “ผ่าน”

AmloStatus              varchar(20)  NULL
AmloAsOfUtc             timestamptz  NULL
AmloGenerationId        uuid         NULL
AmloEvidenceFingerprint varchar(64)  NULL

ตารางใหม่ 2 ตัว — โครงเต็มอยู่ที่ Architecture §4.2

  • AmloUserUnblock — การปลดราย user · unique partial index บน (CompanyId, UserId) WHERE Status = 'Active'
  • AmloAuditLog — append-only · เก็บ snapshot identity เป็นค่าแช่แข็ง ไม่ใช่ FK (คนละ DB กับ Centralized)

branch MIGRATION ของ UserService มีอยู่แล้ว แต่ตามหลัง development อยู่ 75 commit (แยกออกไปตั้งแต่ 07/08 มี 3 commit ของตัวเอง)

⚠️ ห้ามแก้ด้วยการ merge developmentMIGRATION ตามกติกาของทีม — วิธีที่ถูกคือ port เฉพาะ source ของ data tier ที่ต้องใช้ (entity / enum / EF config) เข้าไปใน MIGRATION เอง แล้วค่อย dotnet ef migrations add บนนั้น · จบแล้ว merge MIGRATION กลับเข้า feature branch

4.3 งานย่อยและจุดเสียบจริง

#งานไฟล์/จุดเสียบ
1entity + EF config + DbSet ของ 2 ตารางใหม่ + คอลัมน์บน CompaniesUserService04.Domain/Entities/Companies/ · UserService02.Infrastructure/Persistence/Configurations/ · AuthDbContext.cs DbSet บล็อก Companies · ทำบน MIGRATION
2repository interface + impl + DIport ที่ UserService04.Domain/Ports/Persistence/ · impl ที่ UserService02.Infrastructure/Persistence/Repositories/ · DI แถว AddScoped<ICompanyRepository, …>
3client เรียก Centralizedcopy pattern ของ ICentralizeDocumentNoClient (ตัวที่ส่ง X-API-Key — ไม่ใช่ terms client ที่จงใจไม่ส่ง) · ต้องมีทั้ง Http* และ Stub* เพราะ BaseUrl ว่าง = local/test ใช้ stub · path เก็บใน settings record ไม่ hardcode ในคลาส
4service คำนวณ effective status ต่อ (user × company)UserService03.Application/Services/ ข้างๆ CompanyAccessGuard — เป็น logic ที่หลายที่เรียกใช้ ไม่ใช่ feature เดียว
5reconcile background jobUserService02.Infrastructure/BackgroundJobs/ — ลอก pattern LegoEngineWorkers.cs: primary constructor (IServiceProvider sp, ILogger<T> logger), sealed : BackgroundService, while (!ct.IsCancellationRequested), using var scope = sp.CreateScope() ต่อรอบ, try/catch ครอบทั้งลูปกัน worker ตาย, await Task.Delay(interval, ct) ท้ายลูป · ต้องเปิด transaction เอง (ไม่ผ่าน MediatR)
6command ปลดราย userUserService03.Application/Features/ โฟลเดอร์ใหม่ ตาม pattern Commands/<Op>/{<Op>Command,<Op>Dtos,<Op>Handler,<Op>Validator}.cs · เป็น ICommand<Result<T>> เพื่อให้ TransactionBehavior ครอบให้ · capture actor email ใน command
7เพิ่ม AMLO check ใน ICompanyAccessGuardUserService03.Application/Services/CompanyAccessGuard.cs — signature รับ userId อยู่แล้ว แค่เพิ่มการตัดสิน · call site 4 จุด (DetachSelf, SetDefaultCompany, GetCompanyDetail, ListCorporateMembers) + test 5 ไฟล์
8/users/me เพิ่ม fieldFeatures/Users/Queries/GetUserDetail/GetUserDetailDtos.cs — เพิ่มใน CompanyItem ต่อท้ายพารามิเตอร์ที่มี default อยู่แล้ว จึง source-compatible · แล้ว project ที่ GetUserDetailHandler ตรงที่ประกอบ companies[]

4.4 เงื่อนไขรับงาน Phase 2 (UserService)

  • คอลัมน์ใหม่ NULL ได้ทุกตัว และรันแอปเวอร์ชันก่อนหน้าชี้ DB ใหม่แล้วไม่พัง
  • NULL ถูกอ่านเป็น Unknown ไม่ใช่ Clear ทุกจุด
  • enforcement ยังปิดอยู่ — เปิด flag = ปิด แล้วทุก flow เดิมผ่านเหมือนเดิม
  • AmloAuditLog เขียนใน transaction เดียวกับการเปลี่ยนสถานะจริง — มี test ที่ทำให้ audit เขียนไม่ได้ แล้วพิสูจน์ว่าสถานะ rollback ตาม
  • AmloAuditLog.ActorEmail เป็นอีเมลคนกดจริง ไม่ใช่ system@exim.go.th — มี test ครอบ
  • มี test ของ Redis refresh ล้มเหลว แล้วพิสูจน์ว่าไม่ถูกรายงานว่า propagation สำเร็จ
  • guard เดิม 4 call site ยังทำงานถูก + test 5 ไฟล์ยังผ่าน
  • has-pending-model-changes = none หลัง merge MIGRATION เข้า feature และ migration รันผ่านกับ Postgres จริงใน dev
  • key ใหม่ทุกตัวเพิ่มที่ Backend_Iac/config/user-service/{env}/ ครบทุก env
  • dotnet build -c Release ผ่าน (analyzer เป็น error)

5. Backend_FileManagementService — ไฟล์หลักฐาน

5.1 สถานะจริงของ endpoint ที่ต้องใช้

endpoint ที่ Phase 2 ต้องใช้ (POST /files/upload, GET /files/{id}/download, /metadata, DELETE /files/{id}) ถูก comment ออกทั้งบล็อกใน controller เหลือ live แค่ 4 เส้นของ profile-photo

ข่าวดี: handler เบื้องหลังยังอยู่ครบทุกตัวบน origin/developmentUploadFileHandler, DownloadFileQueryHandler, GetFileMetadataQueryHandler, PreviewFileQueryHandler, DeleteFileCommandHandler และ UploadFileValidator · งานนี้จึงเป็น “เปิดและ hardening” จริง ไม่ใช่เขียนใหม่ทั้งชุด

⚠️ แต่โค้ดใน controller ที่ comment ไว้ไม่เคยถูก compile ตั้งแต่วันที่ comment — ต้องเผื่อว่า signature ของ DTO/ctor drift ไปแล้ว งานแรกคือ uncomment แล้ว build ดูว่าพังตรงไหนบ้าง ก่อนจะไปคิดเรื่อง authorization

5.2 สิ่งที่ต้องเพิ่มนอกจากการ uncomment

งานรายละเอียด
authorization ต่อไฟล์วันนี้ controller มีแค่ [Authorize] ระดับคลาส = ผู้ใช้ที่ล็อกอินคนไหนก็ได้ดาวน์โหลดได้ถ้ารู้ fileId · ไฟล์หลักฐาน AMLO ต้องจำกัดคนที่มีสิทธิ์ดูเท่านั้น ไม่ใช่ทุกคนที่ล็อกอิน
category ใหม่เพิ่มใน FileManagement:Categories — container แยกจาก profile-photos/kyc-documents · AccessLevel = private · MaxFileSizeBytes = 10485760 (10 MB ต่อไฟล์ ตามมติ)
magic byte ของ .xls/.docFileUploadSecurityHelper มี PDF (%PDF-) และ OOXML (PK\x03\x04) แล้ว แต่ไม่มี OLE2 ต้องเพิ่ม D0 CF 11 E0 A1 B1 1A E1 พร้อม content-type application/vnd.ms-excel และ application/msword
upload ทีละไฟล์10 ไฟล์ × 10 MB = 100 MB เกินเพดาน request กลาง (10 MB) → ต้อง upload ทีละไฟล์แล้วผูกด้วย correlation id ไม่ใช่ยิงทั้งชุดใน request เดียว
ไฟล์ค้างที่ไม่มีใครอ้างถ้า upload สำเร็จแล้ว command ปลดล็อกล้ม ไฟล์จะค้างใน Blob โดยไม่มี audit อ้างถึง → ให้ upload เป็น draft ผูก correlation id แล้ว promote ตอน command สำเร็จ · draft ที่ไม่ถูก promote ภายในเวลาที่กำหนดให้ลบทิ้ง · ห้ามลบไฟล์ที่ถูกอ้างใน AmloAuditLog แล้วเด็ดขาด
virus scanVirusScanning.Enabled = false อยู่ (provider ClamAV) — ยังไม่ตัดสิน ต้องเคาะก่อน go-live

6. Test plan

6.1 pattern ที่ต้องลอก

UserService05.Tests — xUnit + Moq + EF SQLite/InMemory · โครง test สะท้อนโครง feature (Application/Features/<Feature>/{Commands,Queries,...}/<Name>Test.cs)

pattern ของ handler test: field เป็น Mock<T> ต่อ dependency · wiring ทั้งหมดใน constructor ที่ไม่มีพารามิเตอร์ แล้วสร้าง handler เก็บไว้ · private static BuildCommand() อ่านจาก InitialData.ValidData ของ feature นั้น · ชื่อ test เป็น Handle_Should_<Behavior>_When_<Condition> · comment // Arrange / // Act / // Assert · assert result.IsSuccess + .Verify(..., Times.Once)

repository test ที่ต้องใช้ DB จริงอยู่แยกที่ Infrastructure/Persistence/Repositories/*PostgresTest.cs

6.2 ของเดิมที่จะพัง

ของทำไมพัง
test ของ ICompanyAccessGuard 5 ไฟล์ (DetachSelfHandlerTest, SetDefaultCompanyHandlerTest, GetCompanyDetailHandlerTest, ListCorporateMembersHandlerTest, CompanyAccessGuardTest)guard เปลี่ยนพฤติกรรม — mock setup เดิมที่คืน success ทันทีจะไม่สะท้อนกฎใหม่
test ที่สร้าง CompanyItemถ้าเพิ่ม field ไม่ได้ต่อท้าย positional จะพัง — เพิ่มต่อท้ายแล้วไม่พัง
AdditionalServiceExtensionsTestตรวจ DI registration — service ใหม่ต้องถูกเพิ่มเข้าไปด้วย

6.3 ของใหม่ที่ต้องเขียน

ระดับทดสอบอะไร
unitdecision ladder ครบ 6 ข้อของ Architecture §3 — โดยเฉพาะ NULLUnknown ไม่ใช่ Clear
unitกฎ revoke ตาม fingerprint — fingerprint เดิม = อยู่ต่อ · เปลี่ยน = revoke
unitactor ใน AmloAuditLog เป็นอีเมลจริง ไม่ใช่ system@exim.go.th
handleraudit เขียนไม่ได้ → สถานะต้อง rollback ทั้งก้อน
handlerunblock ที่ไม่แนบไฟล์ → ต้อง fail
serviceRedis refresh ล้มเหลว → ไม่ mark propagation ว่าสำเร็จ
backgroundreconcile รันซ้ำด้วยข้อมูลเดิม → ผลเท่าเดิม (idempotent)

7. ของที่ยังไม่ตัดสิน

เรื่องสถานะ
virus scan ของไฟล์หลักฐานปิดอยู่ ยังไม่เคาะ — ต้องตัดสินก่อน go-live
ใครมีสิทธิ์ดาวน์โหลดไฟล์หลักฐานยังไม่ระบุ · ตอนนี้ระบุแค่ว่า “ไม่ใช่ทุกคนที่ล็อกอิน”
amlo-max-age-seconds ครึ่ง performanceวัดได้หลัง implement เท่านั้น (บล็อก go-live ไม่บล็อกการเริ่ม)
D7 snapshot vs deltaรอไฟล์ AMLO เข้ามาอีกรอบ — ไม่บล็อก Phase 2 เพราะ auto-unblock ปิดอยู่