SA-1380 — FX Outstanding Notification: Architecture
สถาปัตยกรรม ต้นทาง → กลางทาง → ปลายทาง ของการแจ้งเตือน Forward Contract ใกล้ครบกำหนดแบบ in-app: topic ใหม่ + Business ใหม่ใน NotificationService, ของที่มีอยู่แล้วและไม่ต้องสร้าง, gate นอก scope, cutover และพื้นผิวที่ห้ามแตะ
อัปเดต: 2026-08-06
SA-1380 — FX Outstanding Notification: สถาปัตยกรรม ต้นทาง → กลางทาง → ปลายทาง
สิ่งที่เอกสารนี้ตอบ: สัญญา Forward ที่ใกล้ครบกำหนดเดินทางจาก AS/400 ไปโผล่ที่กระดิ่งในแอปของลูกค้าได้อย่างไร — ใครถือข้อมูลอะไร ที่ไหนเป็นคนแปลงมันเป็นข้อความ และอะไรคือของที่ มีอยู่แล้ว กับอะไรคือของที่ ต้องสร้าง
คู่กับ: FX_OUTSTANDING_NOTIFICATION_IMPLEMENTATION_PLAN.md — งานที่ต้องทำจริงเรียงตาม dependency พร้อม test coverage
0. อ่านตรงนี้ก่อน — 3 ข้อที่เปลี่ยนรูปงานทั้งหมด
0.1 🔴 เอกสารนี้ ล้ม decision §6 ของแผนเดิมบน branch FX
บน branch ที่ Owner ให้มา มีไฟล์ docs/FxOutstandingNotification-Plan-v2-CompanyFanout.md §6 “ไม่ต้องทำ” เขียนไว้ว่า:
❌ migration ใดๆ · ❌ IaC topic ใหม่ (ใช้
notification-personalเดิม) · ❌ แก้ NotificationService / Frontend (ใช้ appCode=FX เดิม)
⚠️ เรื่อง branch: ชื่อ branch นี้มีคำว่า
origin/เป็นส่วนหนึ่งของชื่อจริง (refs/heads/origin/feature/sprint-08/SA-1380-noti-0utstanding) ⇒git rev-parse origin/feature/...จะ fail ต้อง fetch ด้วย refspec เต็ม:git fetch origin 'refs/heads/origin/feature/sprint-08/SA-1380-noti-0utstanding:refs/heads/sa1380-noti'
Owner ล้มข้อนี้เมื่อ 06/08/2026 — ต้องการ topic แยกและ business แยกสำหรับ FX โดยเฉพาะ เหตุผลคือ blast radius (FX พังไม่ควรลาม onboarding/OTP), template+config แยก, และเปิดทางให้ต่อ email (Jira AC3) ทีหลังโดยไม่แตะ path ที่ใช้ร่วมกัน
สองเอกสารนี้อยู่ร่วมกันไม่ได้ ผู้ implement ต้องอ่านเอกสารนี้เป็นหลัก และต้องรู้ว่าส่วนไหนของ v2 ตายแล้วบ้าง:
| ส่วนของ v2 | สถานะ |
|---|---|
| §1 sequence, §3.D6 loop, decision D2 (ส่งให้ user active ทุกคนในบริษัท), D3 (สูตร messageId), D4 (currency=null) | ✅ ยังใช้ได้ — สืบทอดมาในเอกสารนี้ |
| กลไก scheduler: slot / Redis lock / re-check ใต้ lock / marker / Codex config | ✅ ยังใช้ได้ ไม่แตะ |
§2.3 “UserService recipients (GET)” ที่ระบุ route /internal/fx-notification/recipients คืน {data:{recipients:[…]}} | 🔴 ตกยุค — ของจริงที่ merge แล้วคือ api/user-service/v1/companies/cust-codes คืน {data:{custCodes:[…]}} (CompanyController.cs:199-207) และ gateway ฝั่ง FX ก็ชี้ไปที่ route นี้อยู่แล้ว (UserServiceHttpGateway.cs:98-110) |
| §3.C ทั้ง section (สร้าง endpoint ใหม่ใน UserService) | 🔴 ตกยุค — งานนี้ทำเสร็จและ merge แล้วในรูปแบบอื่น |
| §4 Master Checklist บรรทัด UserService | 🔴 ตกยุค ตามข้อบน |
§6 “ไม่ต้องทำ” ข้อ 2 (topic ใหม่) และข้อ 3 (แตะ NS/FE) + §3.D5 publisher ที่ยิง notification-personal | 🔴 ตกยุค — Owner ล้มแล้ว |
| §6 “ไม่ต้องทำ” ข้อ 1 (❌ migration ใดๆ) | ✅ ยังใช้ได้ และแข็งขึ้น — เอกสารนี้ยืนยันซ้ำใน §0.3 และ §10 ข้อ 4 ว่างานนี้ต้องไม่มี migration |
| §3.A (FX_400) และ §3.B (ThirdPartyFX) | ⚠️ ยังไม่ทำ — เป็น gate G-1 ของเอกสารนี้ (ยืนยันแล้วว่ายังไม่มี endpoint ปลายทาง) |
| §5 ข้อควรระวัง | ⚠️ อ่านได้ แต่ข้อ 5 (“FX_Contract near-expiry เดิม ✅ ลบแล้ว”) ไม่ได้ตรวจซ้ำในรอบนี้ |
0.2 ✅ ของที่นึกว่าต้องสร้าง — สร้างเสร็จแล้วทั้งคู่
| นึกว่าต้องทำ | ความจริง | หลักฐาน |
|---|---|---|
| สร้าง endpoint ใหม่ใน UserService เพื่อดึง custCode + userIds | มีแล้ว merge เข้า development แล้ว พร้อม unit test | CompanyController.cs:199-207 (GET api/user-service/v1/companies/cust-codes, [AllowAnonymous] ระดับ action ที่บรรทัด 199 override [Authorize] ของ controller ที่บรรทัด 30) · GetCompanyCustCodesQuery.cs:16-19 คืน CompanyCustCodeItem(CustCode, CompanyId, UserIds) · CompanyRepository.cs:64-79 · tests/UserService05.Tests/.../GetCompanyCustCodesHandlerTest.cs |
| เพิ่มคอลัมน์แยก app ใน In-App Notification + migration | มีแล้ว — AppCode ลง development เมื่อ 05/08/2026 (วันก่อนหน้า session นี้) และเดินครบสาย write → store → read filter → unread filter → SignalR | migration 20260805045911_AddAppCodeToInAppNotifications · InAppNotification.cs:23,68 · InAppNotificationConfiguration.cs:50-53 (HasMaxLength(50).IsRequired(false)) · InAppNotificationRepository.cs:54,84 · GetNotificationsQuery.cs:14 · GetUnreadCountQuery.cs:7 · NotificationItemDto.cs:12 · InAppNotificationService.cs:52,61 |
⚠️ กับดักที่ทำให้เกือบวางแผนผิด: local checkout ของทั้ง UserService และ NotificationService ค้างเก่ากว่า
origin/development(NS ตามหลัง 5 commit) ข้อเท็จจริงทั้ง 2 แถวข้างบนมองไม่เห็นจาก working tree ต้องใช้git show origin/development:<path>ถึงจะเจอ — ผู้ implement ต้องgit fetch+ ตรวจกับorigin/developmentก่อนเริ่มเสมอ
0.3 ✅ “สร้าง Business ใหม่” ทำได้โดย ไม่มี migration เลย
NotificationService ไม่มี entity/table ชื่อ Business หรือ Channel (ตรวจแล้ว: AuthDbContext มีแค่ NotificationDeliveries, InAppNotifications, EmailTemplateKeys, EmailMacros, EmailTemplates; NotificationDelivery.ChannelId เป็น Guid? ลอย ๆ ไม่มีตารางรองรับ) — ดังนั้น “Business” ไม่ใช่สิ่งที่ไป ค้นหา ได้ แต่เป็นสิ่งที่เอกสารนี้ต้อง นิยาม
นิยามที่เลือก — Business = ช่องทางที่ประกอบจาก 4 อย่างที่มีอยู่แล้ว ไม่ใช่ตารางใหม่:
| องค์ประกอบ | ค่าของ FX | มีอยู่แล้ว/สร้างใหม่ |
|---|---|---|
| ASB topic + subscription เฉพาะตัว | fx-inapp-event / FxInApp-Sub | 🆕 สร้าง (IaC) |
| Consumer + Dispatcher คู่เฉพาะตัว | FxInAppEventConsumer + FxInAppNotificationDispatcher | 🆕 สร้าง (NS) |
| ตัวประกอบข้อความเฉพาะตัว | formatter ใน dispatcher (ดู §4.1 — ไม่ใช่ template engine ของ email) | 🆕 สร้าง |
| ตัวแยกแยะบน record ปลายทาง | AppCode="FX" + SourceService="FxOrchestrator" | ✅ คอลัมน์มีแล้ว |
เทียบกับทางเลือกที่แพงกว่า: ถ้านิยาม Business เป็น entity+table จริง จะต้องมี migration ใน NotificationService ซึ่ง repo นี้มี gotcha ที่ยืนยันแล้วว่า migration chain replay จากศูนย์ไม่ได้ (สร้าง env ใหม่/DR ต้องใช้ physical restore) — เพิ่ม migration ที่ไม่จำเป็นคือเพิ่มหนี้ให้ gotcha นั้น โดยไม่ได้ประโยชน์อะไรที่ AppCode + topic แยก + dispatcher แยก ให้ไม่ได้อยู่แล้ว
🔎 ความซื่อสัตย์ของนิยามนี้ — อ่านให้จบก่อนเห็นชอบ
“สร้าง Business ใหม่” เป็นคำที่ตีความได้ 3 ทาง และเอกสารนี้เลือกทางที่ถูกที่สุด นี่คือการตีความ ไม่ใช่สิ่งที่ Owner ระบุตรง ๆ:
การตีความ ได้อะไร ราคา (ก) ช่องทางแยกระดับ runtime ← เอกสารนี้เลือก topic/consumer/dispatcher/discriminator แยก · blast radius แยก · ต่อ email ได้ ไม่มี migration · ตรงกับ precedent WorkflowMakerDispatcherที่ใช้sourceService:"Workflow.Maker"อยู่แล้ว(ข) ทะเบียน business เป็น entity+table + หน้าจอ admin เปิด-ปิด เปิด-ปิด business ได้จากหน้าจอ · query รายการ business ได้ migration ใหม่ + CRUD + หน้าจอ + seed ทุก env (ค) ทำ NotificationDelivery.ChannelIdที่ ค้างครึ่งทางอยู่แล้ว ให้เป็น entity จริงปิดหนี้เดิมของ repo ไปด้วย ( ChannelIdเป็นGuid?ลอย ๆ แต่ProcessBatchCommandValidatorบังคับว่า “Batch.ChannelId is required”)migration + ต้องไปแตะ path email/batch ที่ใช้งานจริงอยู่ = blast radius ที่ Owner ต้องการเลี่ยง Owner ล็อกไว้ชัดแค่ “topic ใหม่” ไม่ได้ล็อกว่า “ห้ามมี/ต้องมี table ใหม่” — ถ้าเจตนาจริงคือ (ข) หรือ (ค) ให้ตีกลับก่อน implement เพราะรูปงานเปลี่ยนทั้งใบ
1. ภาพรวม ต้นทาง → กลางทาง → ปลายทาง
flowchart LR
subgraph SRC["ต้นทาง — Backend_FxOrchestratorService"]
direction TB
CFG["FX Codex ConfigurationMaster<br/>FxNotificationOutstanding<br/>Enabled · StartTimes · ExpireDate"]
SCH["FxOutstandingNotificationScheduler<br/>(BackgroundService)<br/>Redis lock + slot marker/วัน"]
PUB["FxOutstandingNotificationPublisher<br/>→ ASB topic"]
CFG -->|refresh ทุก N วิ| SCH
SCH --> PUB
end
subgraph EXT["แหล่งข้อมูล (นอก scope งานนี้)"]
direction TB
US["UserService<br/>GET /companies/cust-codes<br/>✅ มีแล้วบน development"]
TP["ThirdPartyFX → FX_400<br/>POST /contracts/outstanding/near-expiry<br/>⚠️ prerequisite ยังไม่ยืนยัน"]
end
subgraph MID["กลางทาง — Azure Service Bus (Backend_Iac)"]
TOPIC["topic: fx-inapp-event<br/>sub: FxInApp-Sub<br/>🆕 ต้อง provision"]
end
subgraph DST["ปลายทาง — Backend_NotificationService"]
direction TB
CONS["FxInAppEventConsumer<br/>(BackgroundService — ASB plumbing)"]
DISP["FxInAppNotificationDispatcher<br/>(plain class — validate + ประกอบข้อความในโค้ด + settle)"]
SVC["IInAppNotificationService.SendPersonalAsync<br/>✅ มีแล้ว"]
DB[("InAppNotifications<br/>AppCode='FX'<br/>✅ คอลัมน์มีแล้ว")]
HUB["AppHub → group user-{userId}<br/>event 'Notification'"]
CONS --> DISP
DISP --> SVC
SVC --> DB
SVC --> HUB
end
subgraph FE["ลูกค้า"]
BELL["กระดิ่ง / Inbox<br/>GET /notifications?appCode=FX<br/>GET /notifications/unread-count?appCode=FX<br/>✅ API มีแล้ว"]
end
US -.->|"(1) custCode + userIds"| SCH
TP -.->|"(2) สัญญาใกล้ครบกำหนด"| SCH
PUB -->|"(3) 1 message / (สัญญา × user)"| TOPIC
TOPIC --> CONS
HUB -->|"realtime push"| BELL
DB -->|"query ย้อนหลัง"| BELL
style TOPIC fill:#fff3cd,stroke:#d39e00
style CONS fill:#d4edda,stroke:#28a745
style DISP fill:#d4edda,stroke:#28a745
style TP fill:#f8d7da,stroke:#dc3545
อ่านสี: 🟨 = ต้อง provision ที่ IaC (นอก 3 repo) · 🟩 = โค้ดใหม่ที่ต้องเขียน · 🟥 = prerequisite ที่ยังยืนยันไม่ได้ (ดู §8)
2. ลำดับเวลาแบบเต็ม (sequence)
sequenceDiagram
autonumber
participant CX as FX Codex Config
participant SC as FxOutstandingNotificationScheduler
participant RD as Redis
participant US as UserService
participant TP as ThirdPartyFX → FX_400
participant SB as ASB topic fx-inapp-event
participant NC as FxInAppEventConsumer
participant DP as FxInAppNotificationDispatcher
participant DB as InAppNotifications
participant HB as AppHub (SignalR)
participant FE as กระดิ่งลูกค้า
CX-->>SC: Enabled / StartTimes / ExpireDate (refresh, ไม่ต้อง redeploy)
Note over SC: ตื่นทุก RefreshIntervalSeconds (default 900s)
SC->>RD: slot ของวันนี้ถูกทำไปหรือยัง?
RD-->>SC: ยัง → slot due
SC->>RD: TryAcquireLock (TTL 5 นาที)
RD-->>SC: ได้ lock (pod เดียวเท่านั้นที่ทำงาน)
SC->>RD: re-check ใต้ lock (กัน 2 pod ยิงพร้อมกัน)
SC->>US: GET /companies/cust-codes
US-->>SC: [{custCode, companyId, userIds[]}]
SC->>TP: POST /contracts/outstanding/near-expiry {custCode[], expireDate}
TP-->>SC: [{custCode, contractNumber, currencyCode, outstandingAmount, maturityDate}]
Note over SC: map custCode → userIds · คำนวณ daysLeft ด้วย TZ Asia/Bangkok
loop ทุก (สัญญา × user ในบริษัทนั้น)
SC->>SB: publish FxOutstandingNotificationMessage<br/>MessageId = fx-outstanding:{cust}:{fw}:{user}:{yyyyMMdd}:{HHmm}
end
SC->>RD: mark slot เสร็จ (TTL 48 ชม.)
SB->>NC: deliver message
NC->>DP: DispatchAsync(message, repos…)
alt payload ผิดรูป / template หาย
DP-->>NC: DeadLetter(reason)
NC->>SB: DeadLetterMessageAsync (ไม่ retry — retry ไม่ช่วย)
else DB/transient error
NC->>SB: AbandonMessageAsync (retry ตาม MaxDeliveryCount)
else สำเร็จ
DP->>DP: ประกอบ title/subtitle ด้วย string interpolation ในโค้ด (ไม่ใช่ template engine)
DP->>DB: SendPersonalAsync(userId, payload{AppCode='FX'},<br/>sourceService='FxOrchestrator', externalMessageId=MessageId)
Note over DB: dedup ด้วย unique index<br/>(ExternalMessageId, RecipientUserId)
DB-->>DP: บันทึกแล้ว (หรือข้ามเพราะซ้ำ)
DP->>HB: push event "Notification" → group user-{userId}
HB->>FE: {id, title, subtitle, notificationType, actionLink, appCode:'FX', unreadCount}
DP-->>NC: Complete
NC->>SB: CompleteMessageAsync
end
FE->>DB: GET /notifications?appCode=FX (ย้อนหลัง / เปิดแอปทีหลัง)
3. ตารางต่อ hop — คลาส · topic · config key · contract
| # | Hop | คลาส/ไฟล์จริง | topic / route | config key | contract | สถานะ |
|---|---|---|---|---|---|---|
| 1 | อ่านค่าตั้ง | FxOutstandingNotificationSettingsProvider + …SettingsRefresher | — | FX Codex ConfigurationMaster type FxNotificationOutstanding | FxOutstandingNotificationSettings{ Enabled, StartTimes[], TimeZone, ExpireDate, RefreshIntervalSeconds } | ✅ มีแล้ว ไม่แตะ |
| 2 | ตั้งเวลา + กันซ้ำ | FxOutstandingNotificationScheduler (FxOrchestrator01.API/BackgroundServices/) | — | — | Redis lock fx-outstanding-notification:lock (TTL 5 นาที) · marker fx-outstanding-noti:{date}:{HH:mm} (TTL 48 ชม.) | ✅ มีแล้ว ไม่แตะ |
| 3 | ดึงผู้รับ | UserServiceHttpGateway.GetFxNotificationRecipientsAsync | GET api/user-service/v1/companies/cust-codes | UserApi:BaseUrl + UserApi:GetCompanyCustCodes | {data:{custCodes:[{custCode, companyId, userIds[]}]}} | ✅ ทั้ง 2 ฝั่งมีแล้ว |
| 4 | ดึงสัญญา | OutstandingContractClient.GetNearExpiryAsync | POST api/thirdpartyfx-service/v1/contracts/outstanding/near-expiry | FxThirdPartyApi:GetNearExpiryOutstandingContracts (มีใน appsettings แล้ว) | {data:[{custCode, contractNumber, currencyCode, outstandingAmount, maturityDate}]} | ⚠️ ฝั่ง FX มีแล้ว · ปลายทางยังไม่ยืนยัน (§8) |
| 5 | ส่งขึ้น bus | 🆕 IFxOutstandingNotificationPublisher → AsbFxOutstandingNotificationPublisher / NullFxOutstandingNotificationPublisher (แทน AsbPersonalNotificationPublisher ที่ถูกลบ) | topic fx-inapp-event | 🆕 ServiceBus:FxInAppTopicName — guard อยู่ที่ DI registration ไม่ใช่ ctor | FxOutstandingNotificationMessage (§4) | 🆕 ต้องเขียน |
| 6 | provision bus | — | topic fx-inapp-event + sub FxInApp-Sub | Backend_Iac/config/{svc}/{env}/appsettings.json | dup-detection + MaxDeliveryCount + TTL | 🆕 นอก scope (§8) |
| 7 | รับจาก bus | 🆕 FxInAppEventConsumer (Notification02.Infrastructure/Messaging/) — ลอกโครง ASB จาก FxEmailEventConsumer | topic+sub เป็น const ในคลาส (ตาม house style) | — | deserialize → FxOutstandingNotificationMessage | 🆕 ต้องเขียน |
| 8 | ตัดสิน + ประกอบข้อความ | 🆕 FxInAppNotificationDispatcher (plain class) — ลอกการเขียน+dedup จาก WorkflowMakerDispatcher | — | — | FxInAppDispatchResult{ Action, Reason, Description } | 🆕 ต้องเขียน |
| 9 | ประกอบข้อความไทย | formatter ภายใน dispatcher (string interpolation) | — | — | (contract, daysLeft) → (title, subtitle) — ดู §4.1 | 🆕 ต้องเขียน |
| 10 | เขียน + push | IInAppNotificationService.SendPersonalAsync (InAppNotificationService.cs) | SignalR event "Notification" → group user-{userId} | — | SendPersonalAsync(recipientUserId, InAppNotificationPayload, sourceService, externalMessageId, ct) | ✅ มีแล้ว |
| 11 | เก็บ | InAppNotifications | — | — | AppCode='FX' · SourceService='FxOrchestrator' · ExternalMessageId=MessageId · unique index (ExternalMessageId, RecipientUserId) filtered NOT NULL | ✅ มีแล้ว |
| 12 | อ่าน | NotificationsController.cs:21 — route base api/notification-service/v{version:apiVersion}/notifications | GET ?appCode=FX · GET /unread-count?appCode=FX · GET /{id} · PATCH /{id}/read · PATCH /read-all | — | NotificationItemDto{…, AppCode} | ✅ มีแล้ว — ⚠️ 3 จุดไม่แยก app ดู §6 |
4. Wire contract ใหม่ — FxOutstandingNotificationMessage
หลักการที่เปลี่ยน: วันนี้ต้นทาง ประกอบข้อความไทยเอง (FxOutstandingNotificationScheduler.cs:166-171 สร้าง "สัญญาใกล้ครบอายุ ภายใน N วัน" แล้วส่ง title/subtitle เป็น string สำเร็จรูป) — Owner ระบุว่าต้นทางควรส่ง “data ข้อมูลสัญญา พร้อมกับ user ที่จะได้รับ” ดังนั้นข้อความย้ายมาเรนเดอร์ที่ NotificationService
{
"schemaVersion": 1,
"messageId": "fx-outstanding:0107537002559:FW-1:9d37dde9-…:20260806:0900",
"appCode": "FX",
"sourceService": "FxOrchestrator",
"notificationType": "Warning",
"recipient": { "userId": "9d37dde9-…" },
"contract": {
"custCode": "0107537002559",
"contractNumber": "FW-1",
"currencyCode": "USD",
"outstandingAmount": 250.50,
"maturityDate": "2027-06-16",
"daysLeft": 3
},
"occurredAt": "2026-08-06T09:00:00+07:00"
}
กฎที่ต้องบังคับใน dispatcher:
| กฎ | เหตุผล |
|---|---|
messageId ต้องมีเสมอ และถูกส่งต่อเป็น externalMessageId | เป็น dedup key เดียวที่ DB บังคับได้ — ปัจจุบัน PersonalSignalConsumer ไม่ส่ง ค่านี้ ทำให้ path เดิมไม่มี dedup ที่ระดับ DB เลย |
daysLeft คำนวณที่ ต้นทาง ไม่ใช่ปลายทาง | NotificationService ไม่รู้ TZ ธุรกิจของ FX และไม่ควรรู้ ต้นทางมี options.TimeZone (Asia/Bangkok) อยู่แล้ว ถ้าให้ปลายทางคำนวณจะได้ผลต่างกันตามนาฬิกา pod |
maturityDate เป็น date ไม่ใช่ timestamp | ตัดปัญหา offset ข้ามวัน; ปลายทางใช้แค่แสดงผล |
| ทุกค่าที่มาจาก payload ต้อง escape ก่อนใส่ template | บทเรียนจาก fx-email-channel (HTML injection เป็น defect ที่เคยเจอจริง) |
| 1 message = 1 ผู้รับ | ให้ dedup key (ExternalMessageId, RecipientUserId) ทำงานตรง ๆ และให้ 1 คนพัง ไม่ลามคนอื่น |
schemaVersion ต้องมีตั้งแต่ v1 | ถ้าเพิ่ม field ทีหลัง consumer เก่าจะ reject ได้อย่างตั้งใจ ไม่ใช่ตีความมั่ว |
4.1 ⚠️ “เรนเดอร์ที่ NotificationService” หมายถึงอะไร (และไม่ได้หมายถึงอะไร)
หมายถึง: FxInAppNotificationDispatcher ประกอบ Title/Subtitle ด้วย string interpolation ธรรมดาในโค้ด จากฟิลด์ใน contract แล้วส่งเข้า InAppNotificationPayload — เป็น pattern เดียวกับที่ WorkflowMakerDispatcher ทำอยู่แล้วในบ้านตัวเอง
ไม่ได้หมายถึง: ต่อเข้ากับ IEmailTemplateKeyRepository / IEmailTemplateRepository / IEmailMacroRepository — กลไกนั้นเป็นของสาย email/ANE โดยเฉพาะ ไม่เคยถูกต่อเข้ากับ IInAppNotificationService เลย และ InAppNotificationPayload ไม่มีฟิลด์ template key ให้เริ่มด้วยซ้ำ การลาก template engine มาใช้กับ in-app = สร้าง infra ใหม่ที่ไม่มีใครขอ ผิดข้อ “เรียบง่ายก่อน”
ราคาที่ยอมจ่าย: แก้ข้อความต้อง deploy NotificationService (ไม่ใช่แก้ row ใน DB) — ยอมรับได้เพราะข้อความมีแบบเดียวและยังไม่มีใครขอให้แก้ได้ตอน runtime ถ้าวันหนึ่งต้องการจริง ค่อยยก formatter ขึ้นเป็น template แล้วเพิ่มฟิลด์ template key เข้า payload
สิ่งที่ dispatcher ห้ามทำเด็ดขาด:
- ❌ ห้ามคำนวณ
daysLeftใหม่จากmaturityDateเทียบกับ “ตอนนี้” — ให้ถือว่าdaysLeftเป็นตัวเลขทึบที่ต้นทางคำนวณมาแล้ว. NotificationService ไม่มี timezone provider เดินอยู่เลยใน DI จะได้เวลาของ pod (UTC) และถ้า message ถูก redeliver จาก backlog/DLQ ข้ามวัน ข้อความจะกลายเป็นค่าที่ผิดจากตอน publish - ❌ ห้ามไปดึงข้อมูลสัญญาเพิ่มจาก FX/ThirdParty — ทุกอย่างที่ต้องใช้ต้องอยู่ใน message แล้ว
ทำไมไม่ส่งเป็น batch (1 message ต่อสัญญา + recipients[]): ประหยัด message จริง แต่แลกกับ (ก) dedup ต่อคนต้องย้ายไปทำในโค้ดแทน index (ข) ผู้รับคนที่ 7 พังทำให้ทั้ง batch ถูก retry แล้วคนที่ 1-6 ได้ noti ซ้ำ — ปริมาณระดับนี้ (สัญญา × user ต่อวันไม่กี่ slot) ไม่คุ้มเสี่ยง ถ้าปริมาณโตจนเป็นปัญหาจริงค่อยเปลี่ยน แล้ว schemaVersion รองรับไว้แล้ว
5. ทำไมต้อง topic แยก — และมันกันอะไรได้จริง
ถ้าใช้ notification-personal ร่วมกัน (แผน v2 เดิม) | ถ้าแยก fx-inapp-event (แผนนี้) |
|---|---|
| FX fan-out (สัญญา × user ทุกบริษัท) เข้าคิวเดียวกับ OTP / onboarding / workflow — FX ยิงพรวดทำให้ noti อื่นหน่วง | คิวแยก คิวใครคิวมัน |
PersonalSignalConsumer พัง = พังทุก feature พร้อมกัน | consumer แยก ตายเฉพาะ FX (และ FxEmailEventConsumer มี pattern จับ exception ไม่ให้ล้ม host อยู่แล้ว — ลอกมา) |
| DLQ ปนกัน แยกไม่ออกว่าอันไหน FX | DLQ ของ FxInApp-Sub เป็นของ FX ล้วน |
| ข้อความไทยฝังในโค้ด FxOrchestrator (service ที่ deploy บ่อยกว่า) | ข้อความอยู่ใน dispatcher ของ NS — ยังต้อง deploy อยู่ดีเมื่อจะแก้ถ้อยคำ แต่ย้ายมาอยู่ใน service ที่เป็นเจ้าของเรื่องการแจ้งเตือน และทำให้ email เฟสหน้าใช้ข้อมูลชุดเดียวกันได้ |
| ต่อ email (AC3) ต้องไปแตะ path ที่ใช้ร่วมกัน | เพิ่ม dispatcher ตัวที่สองบน topic เดิมได้เลย |
6. ปลายทางครบ 4 ข้อที่ Owner ขอ — สถานะจริง
| ข้อ | สถานะ | หลักฐาน / สิ่งที่ต้องทำ |
|---|---|---|
| persist ลง inbox query ย้อนหลังได้ | ✅ ใช้ของเดิมทั้งหมด | InAppNotifications + GET api/notification-service/v{version}/notifications?appCode=FX |
| push realtime ผ่าน SignalR | ✅ ใช้ของเดิม | InAppNotificationService.cs:57-62 push event "Notification" เข้า group user-{userId} พร้อม appCode — ⚠️ แต่ unreadCount ใน push เป็นค่ารวมทุก app ดูกล่องล่าง |
| อ่าน/นับ unread + mark-as-read | ⚠️ ได้บางส่วน — endpoint /unread-count?appCode= ใช้ได้จริง แต่มีอีก 2 จุดที่ไม่แยก app | ดูกล่องล่าง |
| เตรียมโครงรองรับ email | ✅ ได้จากรูปแบบ | topic เดิมรับ subscription+dispatcher ตัวที่สอง และ message พก contract data ไม่ใช่ข้อความสำเร็จรูป — ดู §7 |
⚠️ 3 จุดที่ปลายทาง “ยังไม่แยกต่อ app” — ต้องรู้ก่อนบอกลูกค้าว่าเสร็จ
จุด อาการ หลักฐาน unreadCountใน SignalR pushpush บอกว่า appCode:"FX"แต่unreadCountที่แนบมาเป็นยอดรวมทุกแอปInAppNotificationService.cs:56เรียกGetUnreadCountAsync(recipientUserId, ct)overload ที่ไม่รับ appCodeUnreadCountใน envelope ของGET /notifications?appCode=FXรายการถูก filter เป็น FX ถูกต้อง แต่เลข unread ใน envelope เดียวกันเป็นยอดรวม GetNotificationsHandler.cs:21เรียก overload ที่ไม่รับ appCodePATCH /read-allกด “อ่านทั้งหมด” ในแอป FX จะ mark ของทุกแอป endpoint ไม่รับ appCodeผลต่อ FE: กระดิ่งในแอป FX ต้องใช้
GET /notifications/unread-count?appCode=FXเท่านั้น ห้ามใช้เลขจาก envelope ของ list หรือจาก push payload ไม่งั้นจะเห็นยอดของแอปอื่นปนมาทั้ง 3 จุดแก้ได้ แต่แตะ shared read path = blast radius ที่ Owner ต้องการเลี่ยง → ยกเป็น Owner decision G-6 ใน implementation plan ไม่ตัดสินเงียบ ๆ ในงานนี้
7. เส้นทางต่อ email ในอนาคต (ยังไม่ทำ)
🔴 แก้ข้อเสนอเดิมของเอกสารนี้เอง — ร่างแรกเสนอให้ตั้งชื่อ topic เป็น fx-notification-event (per-app) เพื่อให้ email เฟสหน้าใช้ topic เดียวกันได้ หลักฐานจาก IaC ล้มข้อเสนอนั้น:
Backend_Iac/docs/operation/SERVICEBUS_TOPICS_INVENTORY.md แสดงว่า FX มี topic email ของตัวเองอยู่แล้ว — fx-email-event (แถวที่ 15, สร้างจริง 31/07/2026 ครบ dev/sit/uat, duplicate detection ON window PT10M) subscription FxEmail-Sub และ convention ของทั้ง namespace เป็น per-channel/per-purpose ทั้งหมด (notification-personal, notification-broadcast, flow-status-notification, workflow-maker-notification, fx-email-event) ไม่มี topic per-app สักตัว
ผลที่ตามมา 2 ข้อ:
- ชื่อที่ถูกต้องคือ
fx-inapp-event/FxInApp-Sub— เป็นพี่น้องกับfx-email-event/FxEmail-Subตรงตาม convention ที่ใช้อยู่ ส่วนfx-notification-eventจะทับความหมายกับfx-email-event(ซึ่งก็เป็น “FX แจ้งเตือน” เหมือนกัน) แล้วสร้างความสับสน - เส้นทาง email เฟสหน้าไม่ใช่การเพิ่ม subscription บน topic นี้ แต่คือ ใช้
fx-email-eventที่มีอยู่แล้ว + เพิ่ม template key ใหม่ (FxEmailDispatcherroute ด้วยtemplateKeyอยู่แล้ว)
flowchart LR
SRC["FxOrchestrator<br/>FxOutstandingNotificationScheduler"]
T1["topic fx-inapp-event<br/>sub FxInApp-Sub<br/>🆕 เฟสนี้"]
T2["topic fx-email-event<br/>sub FxEmail-Sub<br/>✅ มีอยู่แล้ว (31/07)"]
SRC --> T1
SRC -.->|"เฟสถัดไป: publish ซ้ำอีกใบ<br/>ด้วย templateKey ของ outstanding"| T2
T1 --> D1["FxInAppNotificationDispatcher<br/>→ InAppNotifications + SignalR"]
T2 -.-> D2["FxEmailDispatcher (มีอยู่แล้ว)<br/>→ ITemplateEmailService → ANE"]
style T2 stroke-dasharray: 5 5
style D2 stroke-dasharray: 5 5
การเพิ่ม email = ต้นทาง publish อีกใบไปที่ fx-email-event ด้วย templateKey ใหม่ + เพิ่ม row template — ไม่ต้องเขียน consumer ใหม่เลย เพราะ FxEmailEventConsumer/FxEmailDispatcher มีอยู่แล้วและ route ด้วย templateKey
นี่คือเหตุผลที่ message ของเฟสนี้ต้องพก ข้อมูลสัญญา ไม่ใช่ ข้อความสำเร็จรูป — เฟส email จะใช้ข้อมูลชุดเดียวกันประกอบเป็นอีเมลได้ทันที
8. Prerequisite นอก 3 repo — ต้องยืนยันก่อนเริ่ม (STOP gate)
Owner จำกัด scope ไว้ที่ 3 repo แต่สายข้อมูลจริงพาดผ่านของนอก scope 2 จุด แผนนี้ไม่วางงานให้ 2 จุดนั้น แต่ประกาศเป็น gate ที่ถ้ายังไม่เขียว งาน implement จะรันไม่ผ่าน
| Gate | เรื่อง | ถ้ายังไม่พร้อมจะเป็นยังไง | ตรวจยังไง |
|---|---|---|---|
| G-1 | POST api/thirdpartyfx-service/v1/contracts/outstanding/near-expiry ที่ ThirdPartyFX และ POST /api/v1/fx/forward/contracts/outstanding/near-expiry ที่ FX_400 | 🔴 retry storm เงียบ ๆ — ดูกล่องด้านล่าง | ยิงจริงจาก dev ด้วย custCode จริง 1 ตัว ก่อนเริ่ม task ใด ๆ |
| G-2 | topic fx-inapp-event + subscription ที่ ASB ทุก env (Backend_Iac) พร้อมชื่อที่เคาะแล้ว + dup-detection window + MaxDeliveryCount | publisher โยน error ตอน CreateSender/ส่ง หรือ consumer หา sub ไม่เจอแล้วตาย | ยืนยันกับทีม infra + ตรวจ inventory ก่อน; ค่าที่ตั้งตอนสร้าง แก้ทีหลังไม่ได้ |
| G-3 | config key ใหม่ (ServiceBus:FxInAppTopicName ฝั่ง FX) ต้องไปเพิ่มที่ Backend_Iac/config/{service}/{env}/appsettings.json ครบทุก env | CI มี check-config-parity.py ที่ทำ build FAIL ถ้า IaC ขาด key ที่ service มี — และ CI overlay ทับ appsettings.{ENV}.json แบบ full-file replace ตอน build image ดังนั้นแก้ใน repo อย่างเดียว = ไม่มีผลบน cluster | เพิ่มพร้อมกันทั้ง 2 ที่ในรอบเดียว |
| G-3b | 🔴 parity gate ของ FxOrchestrator dev แดงอยู่แล้ววันนี้ — ยืนยันด้วยการรันจริง ไม่ใช่ข้อสงสัย | ถ้าไม่ปิดก่อน งาน T-08 จะไปเพิ่ม key ใหม่ทับบน gate ที่ fail อยู่แล้ว และจะแยกไม่ออกว่าพังเพราะของเก่าหรือของใหม่ | ดูกล่องล่าง — รัน check-config-parity.py แล้วเติม key ที่ขาดให้ครบก่อน |
| G-4 | เส้นทางเครือข่ายที่ deploy จริงใช้ cluster-internal DNS ไม่ใช่ APIM — Backend_Iac/config/fxorchestrator-service/dev/appsettings.json ตั้ง UserApi.BaseUrl = http://user-service.superappdev.svc.cluster.local และ FxThirdPartyApi.BaseUrl = http://thirdpartyfx-service.superappdev.svc.cluster.local:5001 (ค่า gateway-dev.exim.go.th อยู่แค่ใน base appsettings.json ของ repo ซึ่งถูก env file ทับตอน deploy) | ถ้าไปทดสอบผ่าน APIM แล้วผ่าน จะสรุปผิดว่าพร้อม ทั้งที่เส้นทางจริงบน cluster เป็นอีกเส้น | ทดสอบเส้น cluster DNS ที่ config จริงใช้ · การยิงผ่าน gateway-dev.exim.go.th ใช้ได้เฉพาะตอน dev บนเครื่องตัวเอง |
🔴 G-3b ขยายความ — หลักฐานจริง ไม่ใช่ข้อสงสัย
ผลการรันจริง ณ 06/08/2026 (รันคู่ env ให้ตรงกัน — service
appsettings.{ENV}.jsonเทียบกับ IaC{env}/appsettings.json):S=Backend_Iac/pipelines/scripts/check-config-parity.py python $S Backend_FxOrchestratorService/src/FxOrchestrator01.API/appsettings.Development.json \ Backend_Iac/config/fxorchestrator-service/dev/appsettings.json # → exit 1, ขาด 8 key python $S Backend_FxOrchestratorService/src/FxOrchestrator01.API/appsettings.SIT.json \ Backend_Iac/config/fxorchestrator-service/sit/appsettings.json # → exit 0, ผ่าน8 key ที่ IaC dev ขาด:
FxContractApi.ResetHolidaySync·FxThirdPartyApi.GetNearExpiryOutstandingContracts·HolidaySync.DailyCheck.MinDaysSinceFirstOfYear·ScheduledJobs.Jobs.HolidayDailyCheckSyncJob.{CronExpression,Enabled,RunImmediatelyOnStartup,TimeZone}·UserApi.GetCompanyCustCodes
- 🔴 dev แดงอยู่แล้ว — ข้อความจาก script เองคือ “IaC overlay would STRIP keys the service config defines (runtime breakage risk)” แปลว่าค่าที่ service คาดว่าจะมี จะหายไปตอน overlay
- ⚠️ guard เทียบกับ env file (
appsettings.{ENV}.json) ไม่ใช่ baseappsettings.json— ข้อโต้แย้งว่า “base รอด overlay จึงไม่เป็นไร” ใช้กับ gate นี้ไม่ได้🔴 และของแถมที่ใหญ่กว่า — sit/uat ไม่มี guard คุ้มกันเลย และ
appsettings.SIT.jsonตายบน cluster
Backend_Iac/pipelines/templates/overlay-cloud-config.ymlmapenvDir→ASPNETCORE_ENVIRONMENTแบบนี้:case "${{ parameters.envDir }}" in dev) ENV_NAME="Development" ;; sit) ENV_NAME="Sit" ;; # ← "Sit" ไม่ใช่ "SIT" uat) ENV_NAME="Uat" ;; esac DST=".../appsettings.${ENV_NAME}.json" if [ -f "$DST" ]; then python3 check-config-parity.py "$DST" "$SRC" || exit 1; fi cp "$SRC" "$DST"FxOrchestrator มีไฟล์ชื่อ
appsettings.SIT.json(ตัวใหญ่) แต่ pipeline หาappsettings.Sit.jsonบน agentubuntu-latestซึ่ง case-sensitive ⇒
[ -f "$DST" ]เป็นเท็จ → parity guard ไม่เคยรันให้ sit เลย (uat ก็เช่นกัน เพราะไม่มีไฟล์appsettings.Uat.json)cpสร้างappsettings.Sit.jsonจาก IaC ขึ้นมาใหม่ → นั่นคือไฟล์ที่ .NET โหลดจริง ส่วนappsettings.SIT.jsonใน repo ไม่เคยถูกอ่านบน clusterผลต่องานนี้: ค่า
ServiceBus:FxInAppTopicNameของ sit และ uat ต้องอยู่ที่ IaC เท่านั้น เติมในฝั่ง service ไม่มีผล — และเพราะ publisher throw ตอน startup เมื่อ key หาย (ดู §10 ข้อ 10) ถ้าลืม IaC env ใด service จะไม่ขึ้นใน env นั้นผลนอกงานนี้ (ควรแจ้งเจ้าของ repo): ทุกค่าที่ใครก็ตามใส่ไว้ใน
appsettings.SIT.jsonของ FxOrchestrator ไม่มีผลบน SIT มาตลอด — เป็นหนี้แยกต่างหาก ไม่ได้แก้ในงานนี้สิ่งที่ต้องทำ: เติม 8 key ข้างบน บวก
ServiceBus:FxInAppTopicNameลงBackend_Iac/config/fxorchestrator-service/dev/appsettings.jsonและเติม key ใหม่ลงsitด้วย
🔴 G-1 ขยายความ — ทำไมถึงต้องเป็น gate ไม่ใช่แค่ “task ที่ยังไม่เสร็จ”
ถ้าเปิด
Enabled=trueก่อนที่ปลายทาง near-expiry จะพร้อม พฤติกรรมจะเป็นแบบนี้:
GetNearExpiryAsyncโยนFxThirdPartyApiException(404/405)- exception หลุดออกจาก
RunTickAsyncไปถูกจับที่ExecuteAsyncแล้วLogErrorเฉย ๆ- บรรทัด
cache.SetAsync(MarkerKey(...))ไม่เคยถูกรัน เพราะอยู่หลังจุดที่ throw- slot ของวันนั้นจึงยังนับว่า “due” ตลอด → tick ถัดไป (ทุก
RefreshIntervalSeconds= 15 นาที) ลองใหม่ แล้วพังใหม่ วนทั้งวันผลคือ error log ท่วมโดยไม่มี noti ออกสักใบ และไม่มีอะไรดังพอให้รู้ว่าผิด (ไม่ใช่ crash, pod ยัง healthy) — ต้องรู้ล่วงหน้าว่านี่คืออาการที่คาดไว้ ไม่ใช่บั๊กของโค้ดใหม่ ห้ามเปิด kill switch จนกว่า G-1 จะเขียว
9. ทางเลือกที่พิจารณาแล้วไม่เลือก
| ทางเลือก | ทำไมไม่เลือก |
|---|---|
A. ใช้ notification-personal เดิม (แผน v2) | Owner ล้มแล้ว (§0.1) และตาราง §5 อธิบายว่าแลกอะไร |
| B. Business เป็น entity+table จริง พร้อมหน้าจอ admin เปิด-ปิดราย business | ราคา: migration ใหม่ใน NS (ซึ่ง chain replay พังอยู่แล้ว) + CRUD + หน้าจอ + seed ทุก env — และไม่ได้ทำให้ 4 เจตนาของ Owner สำเร็จเพิ่มขึ้นเลย เพราะ kill switch มีอยู่แล้วที่ FX Codex (Enabled) และ AppCode แยกได้แล้วที่ read side เปิดทางไว้: ถ้า Owner ต้องการทะเบียน business จริง ให้ทำเป็น story แยก อย่ายัดเข้า SA-1380 |
| C. เรนเดอร์ข้อความที่ต้นทางต่อไป (เก็บ title/subtitle string) | ขัดกับที่ Owner สั่งว่าต้นทางส่ง “data ข้อมูลสัญญา” · และทำให้ email เฟสหน้าต้องกลับไปแก้ FxOrchestrator อีกรอบ · และแก้ข้อความต้อง deploy service |
| D. ให้ NotificationService ไปดึงข้อมูลสัญญาเอง | NS จะต้องรู้จัก FX_400/ThirdPartyFX/custCode = ลาก FX domain เข้ามาใน NS ผิดทิศ และทำให้ NS พึ่ง AS/400 ตอน consume |
E. เพิ่ม sourceService/externalMessageId ให้ PersonalSignalConsumer แล้วใช้ path เดิม | แตะ consumer ที่ทุก feature ใช้ร่วมกัน = blast radius ที่ Owner ต้องการหลีกเลี่ยงพอดี · แต่ บันทึกไว้เป็นหนี้ที่ควรแก้แยกต่างหาก เพราะ path เดิมไม่มี DB dedup เลยทุกวันนี้ |
10. พื้นผิวที่ห้ามเปลี่ยน (blast radius)
งานนี้ต้อง ไม่แตะ ของพวกนี้ — ถ้า diff ไปโดน แปลว่าออกนอก scope:
PersonalSignalConsumer· topicnotification-personal· subscriptionNotification-Personal-SubBroadcastSignalConsumer,NotificationEventConsumer,InvitationEventConsumer,FlowStatusNotificationConsumer,WorkflowMakerNotificationConsumer,FxEmailEventConsumer— และ subscription ของทุกตัว- Signature ของ
IInAppNotificationServiceและพฤติกรรมของSendPersonalAsync(เรียกใช้ ไม่ใช่แก้) - Schema ของ
InAppNotifications— ห้ามมี migration ใหม่ในงานนี้ ถ้ารู้สึกว่าต้องมี แปลว่าออกนอกแนวทาง §0.3 ให้หยุดแล้วถาม - Query ของ
GetByUserIdAsync/GetUnreadCountAsync— ใช้ optionalappCodeที่มีแล้ว ห้ามเพิ่ม filter ใหม่ AppHubและ contract ของ groupuser-{userId}- กลไก lock / slot marker / Codex config ของ
FxOutstandingNotificationScheduler— เปลี่ยนเฉพาะ “ส่งอะไรออกไป” ไม่เปลี่ยน “ส่งเมื่อไหร่” UserService— endpointcompanies/cust-codesใช้ตามเดิม ไม่แก้ (การเปลี่ยน[AllowAnonymous]→[RequireApiKey]เป็นข้อเสนอแยก ดู implementation plan G-5)
และ 2 กับดัก DI ที่ทำให้ของใหม่ “เงียบไม่ทำงาน” โดยไม่มี error:
- NotificationService register consumer ซ้ำ 2 ที่ —
Notification02.Infrastructure/DependencyInjection.csมี branch Mode B (Entra RBAC, บรรทัด 206-226) และ Mode A (connection string, 227-246) ที่ register ชุด consumer เดียวกัน ทั้งคู่ ลืม branch ใดก็ตาม = consumer ตายเงียบใน mode นั้น (มักไปเจอตอน SIT/UAT ซึ่งใช้คนละ mode กับเครื่อง dev) · หมายเหตุ: มี branch ที่สาม (else, 247-250) ที่ไม่ register consumer เลย — คือกรณีไม่มี ASB config ซึ่งเป็นสถานะปกติของเครื่อง dev ที่ไม่ได้ตั้งค่า อย่าสรุปว่า “โค้ดไม่ทำงาน” จากเครื่องที่ตกอยู่ใน branch นี้ - FxOrchestrator ผูก publisher ไว้กับเงื่อนไข ASB —
IPersonalNotificationPublisherถูก register เป็นตัวจริงเมื่อมี ServiceBus client เท่านั้น ไม่งั้นได้NullPersonalNotificationPublisherที่เงียบสนิท (ดูFxOrchestrator02.Infrastructure/DependencyInjection.csช่วงhasAzureServiceBus) publisher ตัวใหม่ต้องเดินตาม pattern เดียวกัน และ ต้องตรวจว่า env ที่จะทดสอบมี ASB จริง ไม่งั้นจะสรุปผิดว่า “โค้ดไม่ทำงาน”
10.1 🔄 Cutover — FX เคยเดินผ่าน notification-personal อยู่แล้ว
ข้อเท็จจริงที่ §10 ข้อ 1 ปิดบังไว้: วันนี้ FX in-app ทำงานอยู่แล้ว ผ่านเส้นเดิม (scheduler → AsbPersonalNotificationPublisher → notification-personal → PersonalSignalConsumer → SendPersonalAsync → row + SignalR) งานนี้ไม่ได้ “เพิ่มของใหม่” เฉย ๆ แต่ ย้าย traffic ออกจากเส้นเดิม — §10 ข้อ 1 หมายถึงห้ามแก้ไฟล์ของเส้นเดิม ไม่ได้แปลว่าไม่มีอะไรเปลี่ยนพฤติกรรม
| ประเด็น | รายละเอียด |
|---|---|
| ลำดับการตัด | ปิด Enabled ที่ FX Codex → deploy ทั้ง NS และ FX → ยืนยัน topic+subscription มีจริง (G-2) → เปิด Enabled · ถ้าเปิดก่อน NS พร้อม message จะค้างใน topic (ไม่หาย แต่ก็ไม่มีใครกิน) |
| rollback | revert 1 บรรทัดใน FxOrchestrator02.Infrastructure/DependencyInjection.cs กลับไป publisher เดิม แล้ว deploy — เส้นเดิมยังอยู่ครบ ไม่ได้ถูกลบฝั่ง NS |
| 🔴 ความเสี่ยงซ้ำช่วงตัด | row ที่เขียนผ่านเส้นเดิมมี ExternalMessageId = NULL (เพราะ PersonalSignalConsumer ไม่ส่งค่านี้) และ unique index มี HasFilter("ExternalMessageId IS NOT NULL") ⇒ dedup กันซ้ำข้ามเส้นทางไม่ได้ ถ้า slot เดียวกันของวันเดียวกันถูกส่งทั้งเส้นเก่าและเส้นใหม่ ผู้ใช้จะเห็น 2 ใบ · วิธีกัน: อย่าเปิด Enabled ซ้ำ slot ที่วันนั้นส่งไปแล้ว หรือรอข้ามวัน |
| ของที่ไม่กระทบ | feature อื่นบน notification-personal ไม่เปลี่ยนเลย — ทั้ง topic, consumer และ subscription ยังทำงานเหมือนเดิมทุกอย่าง |
11. ข้อสมมติที่ยังไม่ verify (อย่าถือเป็นข้อเท็จจริง)
| # | ข้อสมมติ | ตรวจยังไง |
|---|---|---|
| A-1 | ASB namespace ของแต่ละ env รองรับการเพิ่ม topic ใหม่ได้ทันที (ไม่ติด SKU limit) | ถาม infra + ดู inventory — fx-email-channel เคยติดประเด็นนี้ค้างไว้ |
| A-2 | 1 custCode = 1 company (ถ้าซ้ำ โค้ดต้นทาง union userIds ให้แล้ว) | query DB จริงว่ามี custCode ซ้ำข้าม company ไหม |
| A-3 | ปริมาณ fan-out ต่อ slot อยู่ในระดับที่ topic เดี่ยวรับไหว | นับ จำนวนสัญญาใกล้ครบ × user เฉลี่ยต่อบริษัท จาก dev/uat ก่อน go-live |
| A-4 | ผู้รับที่ถูกต้องคือ “user active ทุกคนในบริษัทที่ถือสัญญา” ไม่ใช่เฉพาะคนที่สร้างสัญญา | เป็น decision D2 ของแผน v2 ที่สืบทอดมา — ถ้า business ไม่ยืนยัน จำนวน noti จะผิดคาดมาก |
| A-5 | NotificationType="Warning" เป็นค่าที่ FE เรนเดอร์ถูก | ฝั่ง backend ยืนยันแล้วว่ารองรับ — NotificationTypes.All (Notification04.Domain/Constants/NotificationTypes.cs:10) มี Info, Warning, Success, Error และ GetNotificationsQueryValidator.cs:19-21 บังคับ whitelist นี้ที่ read side อยู่แล้ว ที่ยังไม่ verify คือฝั่ง FE เท่านั้น ว่ามี icon/สีสำหรับ Warning — ถามทีม FE (G-7) |
| A-6 | ASB MessageId ที่ต้นทางตั้งอยู่แล้ว มีผลเป็น dedup จริงก็ต่อเมื่อเปิด duplicate detection บน topic | ค่าเหล่านี้ แก้ทีหลังไม่ได้ ต้องตั้งตอนสร้าง topic — ยืนยันกับ infra ตอน G-2 · อย่าพึ่ง ASB อย่างเดียว ตัว dedup ที่เชื่อได้คือ unique index ที่ DB |
| A-7 | ผู้ส่งจริงที่ทำให้ message ไหลได้คือ FxOrchestrator เท่านั้น | fx-email-channel เคยเจอว่า publisher ที่คิดว่ามี จริง ๆ grep ไม่เจอ — ยืนยันว่า FxOutstandingNotificationPublisher ถูก register จริงในทั้ง 2 โหมด ASB (ดู §10 ข้อ 9) |
12. เอกสารที่เกี่ยวข้อง
- FX_OUTSTANDING_NOTIFICATION_IMPLEMENTATION_PLAN.md — งานเรียงตาม dependency + test coverage
PLAN_FX_EMAIL_CHANNEL.md·SPEC_FX_EMAIL_CHANNEL_PUBLISHER.md— pattern พี่น้องที่ลอกโครงมาIN_APP_NOTIFICATION_API_SPEC.md·SA-1473_CODE_REVIEW.md— read-side ที่งานนี้ใช้ต่อNOTIFICATIONSERVICE_REPO_KNOWLEDGE.md— gotcha ของ repo (migration replay, OTP email dev, worktrees)- Jira SA-1380 (AC1 in-app = งานนี้ · AC2 highlight หน้า Outstanding = FE นอก scope · AC3 email = เฟสหน้า) · SA-1556 (ตั้งค่าจำนวนวันล่วงหน้า — ถูกครอบคลุมแล้วบางส่วน ด้วย
ExpireDateใน FX Codex config ที่แก้ได้โดยไม่ deploy ขาดแค่หน้าจอตั้งค่า)