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 และ 2 | reconcile ต้องมีทั้ง 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 ข้อกับงานนี้:
| ปัญหา | ผล |
|---|---|
| ถามได้ทีละ custCode | reconcile ต้องไล่ทุกบริษัทในระบบ = ยิง 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 development → MIGRATION ตามกติกาของทีม — วิธีที่ถูกคือ port เฉพาะ source ของ data tier ที่ต้องใช้ (entity / enum / EF config) เข้าไปใน MIGRATION เอง แล้วค่อย dotnet ef migrations add บนนั้น · จบแล้ว merge MIGRATION กลับเข้า feature branch
4.3 งานย่อยและจุดเสียบจริง
| # | งาน | ไฟล์/จุดเสียบ |
|---|---|---|
| 1 | entity + EF config + DbSet ของ 2 ตารางใหม่ + คอลัมน์บน Companies | UserService04.Domain/Entities/Companies/ · UserService02.Infrastructure/Persistence/Configurations/ · AuthDbContext.cs DbSet บล็อก Companies · ทำบน MIGRATION |
| 2 | repository interface + impl + DI | port ที่ UserService04.Domain/Ports/Persistence/ · impl ที่ UserService02.Infrastructure/Persistence/Repositories/ · DI แถว AddScoped<ICompanyRepository, …> |
| 3 | client เรียก Centralized | copy pattern ของ ICentralizeDocumentNoClient (ตัวที่ส่ง X-API-Key — ไม่ใช่ terms client ที่จงใจไม่ส่ง) · ต้องมีทั้ง Http* และ Stub* เพราะ BaseUrl ว่าง = local/test ใช้ stub · path เก็บใน settings record ไม่ hardcode ในคลาส |
| 4 | service คำนวณ effective status ต่อ (user × company) | UserService03.Application/Services/ ข้างๆ CompanyAccessGuard — เป็น logic ที่หลายที่เรียกใช้ ไม่ใช่ feature เดียว |
| 5 | reconcile background job | UserService02.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) |
| 6 | command ปลดราย user | UserService03.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 ใน ICompanyAccessGuard | UserService03.Application/Services/CompanyAccessGuard.cs — signature รับ userId อยู่แล้ว แค่เพิ่มการตัดสิน · call site 4 จุด (DetachSelf, SetDefaultCompany, GetCompanyDetail, ListCorporateMembers) + test 5 ไฟล์ |
| 8 | /users/me เพิ่ม field | Features/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 หลัง mergeMIGRATIONเข้า 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/development — UploadFileHandler, 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/.doc | FileUploadSecurityHelper มี 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 scan | VirusScanning.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 ของใหม่ที่ต้องเขียน
| ระดับ | ทดสอบอะไร |
|---|---|
| unit | decision ladder ครบ 6 ข้อของ Architecture §3 — โดยเฉพาะ NULL → Unknown ไม่ใช่ Clear |
| unit | กฎ revoke ตาม fingerprint — fingerprint เดิม = อยู่ต่อ · เปลี่ยน = revoke |
| unit | actor ใน AmloAuditLog เป็นอีเมลจริง ไม่ใช่ system@exim.go.th |
| handler | audit เขียนไม่ได้ → สถานะต้อง rollback ทั้งก้อน |
| handler | unblock ที่ไม่แนบไฟล์ → ต้อง fail |
| service | Redis refresh ล้มเหลว → ไม่ mark propagation ว่าสำเร็จ |
| background | reconcile รันซ้ำด้วยข้อมูลเดิม → ผลเท่าเดิม (idempotent) |
7. ของที่ยังไม่ตัดสิน
| เรื่อง | สถานะ |
|---|---|
| virus scan ของไฟล์หลักฐาน | ปิดอยู่ ยังไม่เคาะ — ต้องตัดสินก่อน go-live |
| ใครมีสิทธิ์ดาวน์โหลดไฟล์หลักฐาน | ยังไม่ระบุ · ตอนนี้ระบุแค่ว่า “ไม่ใช่ทุกคนที่ล็อกอิน” |
amlo-max-age-seconds ครึ่ง performance | วัดได้หลัง implement เท่านั้น (บล็อก go-live ไม่บล็อกการเริ่ม) |
| D7 snapshot vs delta | รอไฟล์ AMLO เข้ามาอีกรอบ — ไม่บล็อก Phase 2 เพราะ auto-unblock ปิดอยู่ |