Tài liệu này mô tả phần backend cần có để màn tạo Campaign hỗ trợ chia khách hàng vào nhiều nhóm hành động theo tỷ lệ phần trăm.
Ví dụ FE cấu hình:
10%.20%.70%, được hiểu là nhóm đối chứng CONTROL.CONTROL là nhóm khách hàng vẫn thuộc tệp mục tiêu và vẫn được tính trong báo cáo, nhưng hệ thống không thực hiện hành động nào với họ. Nhóm này giúp so sánh kết quả của việc chạy quảng cáo với trường hợp không chạy quảng cáo.
Luồng trong phạm vi 5 Campaign được chốt như sau:
| Chế độ | Cách xử lý một khách hàng |
|---|---|
abTestingEnabled = false |
Campaign có một nhóm gửi thông thường. Mỗi lần dispatch gửi đúng nội dung/kênh đã cấu hình. |
abTestingEnabled = true |
Mỗi khách hàng chỉ thuộc đúng một actionChain hoặc CONTROL. Không chạy các chain còn lại. |
FE chỉ gửi tỷ lệ của các nhóm hành động thực sự. FE không tạo một actionChain giả cho CONTROL và không cần gửi controlPercent.
{
"abTestingEnabled": true,
"actionChains": [
{
"chainCode": "VARIANT_A",
"allocationPercent": 10.00,
"actions": []
},
{
"chainCode": "VARIANT_B",
"allocationPercent": 20.00,
"actions": []
}
]
}
BE tính tỷ lệ nhóm đối chứng bằng công thức:
controlPercent = 100 - tổng allocationPercent của các actionChain
Trong ví dụ trên, controlPercent = 70%.
FE có thể tính và hiển thị phần còn lại, nhưng BE vẫn phải kiểm tra vì request có thể được gửi từ Swagger, Postman, script hoặc một phiên bản FE cũ.
BE phải bảo đảm:
allocationPercent lớn hơn 0 và không lớn hơn 100.0.01%.100.100% và không có CONTROL không phải là một thử nghiệm A/B hợp lệ.20% và CONTROL 80% là thử nghiệm hợp lệ.CONTROL không được lưu trong cấu hình CampaignKhông bổ sung các trường sau:
controlPercent
chainType
controlActionChain
CONTROL chỉ xuất hiện khi BE chốt danh sách người nhận vào campaign_target_member. Nhờ vậy:
Với tệp 1.000.000 người, tỷ lệ thực tế thường rất gần cấu hình. Với tệp chỉ có 10 người, kết quả có thể không đúng chính xác 1 người cho 10% và 2 người cho 20%.
BE sử dụng phép chia ổn định theo assignmentSeed và recipientKey, không lấy ngẫu nhiên lại mỗi lần. Cùng một Campaign, cùng một khách hàng và cùng một seed luôn cho cùng một nhóm.
Campaign action trong phạm vi hiện tại chỉ có nhiệm vụ gửi thông điệp. Nội dung “tặng data”, “ưu đãi” trong tài liệu khách hàng là nội dung truyền thông; việc cấp ưu đãi không phải action của Campaign.
Các action gửi được sử dụng:
NOTIFICATION_SMS
WHATSAPP_SEND_MESSAGE
NOTIFICATION_PUSH
| Campaign | Cách chọn đối tượng | Kênh gửi | Sự kiện/trạng thái cần theo dõi |
|---|---|---|---|
| Camp 1 - Chuyển đổi chéo BU | OCS: thuê bao active, có giao dịch BU và chưa kích hoạt Super App | SMS, WhatsApp | APP_INSTALLED, APP_ACTIVATED, click theo kênh |
| Camp 2 - Khách mua online mới | E-shop/CRM: đơn hàng vừa hoàn tất | SMS hoặc WhatsApp theo cấu hình từng bước | ONLINE_ORDER_COMPLETED, APP_INSTALLED, APP_ACTIVATED |
| Camp 3 - Tải app chưa kích hoạt | SDK/PostHog: đã cài app quá 24 giờ nhưng chưa kích hoạt | Push | APP_INSTALLED, APP_REGISTERED, APP_ACTIVATED, push open/click |
| Camp 4 - Nâng cấp gói data tháng | OCS: có dùng data, chưa có gói tháng | SMS, WhatsApp | MONTHLY_DATA_PACKAGE_REGISTERED để dừng gửi |
| Camp 5 - Phát triển thuê bao data | OCS: máy 4G nhưng chưa phát sinh data | SMS, WhatsApp | DATA_USAGE_STARTED để dừng gửi |
Camp 1, 4 và 5 có yêu cầu A/B rõ ràng giữa SMS và WhatsApp. Camp 2 là Journey ở các mốc ngày 0, 3 và 5; mỗi mốc tạo một lần dispatch riêng. Camp 3 là Push theo hành vi ứng dụng. Cơ chế chia variant có thể tái sử dụng cho Camp 2/3 khi BA bật A/B, nhưng không hard-code logic riêng theo số thứ tự Campaign.
worker_log MongoDB.dispatchCursor, dispatchedCount và lease trong campaign_run chỉ là một bản ghi tiến độ cho cả lần chạy. Dù run có hàng triệu người nhận, MongoDB không phát sinh hàng triệu worker log.
| Hạng mục | Hiện trạng | Kết luận |
|---|---|---|
Lưu nhiều actionChains |
CampaignModel và request đã có danh sách chain. |
Đã có nền tảng. |
| Gửi SMS/WhatsApp | Đã có channel worker và Kafka command tương ứng. | Có nền tảng, cần chuẩn hóa contract theo run/variant. |
| Gửi Push | Đã có hướng tách push-worker, nhưng contract Campaign và routing chưa hoàn chỉnh. |
Chưa đáp ứng đầy đủ Camp 3. |
CampaignRun |
Đã có collection campaign_run và snapshot cấu hình theo từng lần chạy. |
Đã có nền tảng. |
SegmentVersion và TargetSnapshot |
Đã có metadata MongoDB và member ở ClickHouse. | Đã có nền tảng. |
| Cột nhận diện biến thể | campaign_target_member, delivery, click, open và bảng metric đã có variant_code. |
Có schema nhưng chưa nối đủ luồng. |
| Tracking link | Model, cache payload và event click/open đã có variantCode. |
Có trường nhưng worker chưa truyền đầy đủ. |
| Báo cáo A/B theo run | Đã có endpoint POST /campaigns/{campaignId}/runs/{runId}/reports/ab-comparison. |
Có API nền nhưng cách lấy dữ liệu còn sai với control và channel. |
| Sự kiện hành vi của 5 Campaign | Camp 3 đã có thiết kế PostHog; OCS/đơn hàng và conversion event chưa được nối thống nhất vào báo cáo Campaign. | Chưa đáp ứng đầy đủ. |
| Cấu hình bật A/B | Chưa có abTestingEnabled. |
Chưa đáp ứng. |
| Mã ổn định của chain | Chain chỉ có danh sách actions, chưa có chainCode. |
Chưa đáp ứng. |
| Tỷ lệ của chain | Chưa có allocationPercent. |
Chưa đáp ứng. |
| Seed chia nhóm | Chưa có assignmentSeed. |
Chưa đáp ứng. |
| Validate tổng tỷ lệ | Chưa có. | Chưa đáp ứng. |
| Chia người nhận theo tỷ lệ | Cronjob đang truyền variantCode = "" cho toàn bộ target. |
Chưa đáp ứng. |
| Dispatcher chọn đúng một chain | Dispatcher đang chạy tất cả chain cho mọi người. | Chưa đáp ứng A/B. |
Bỏ gửi cho CONTROL |
Chưa có khái niệm control trong target. | Chưa đáp ứng. |
Truyền variantCode đến worker |
Command và UnifiedEventMessage chưa có trường này. |
Chưa đáp ứng. |
| Resume khi dispatcher chết giữa chừng | Consumer đọc toàn bộ target trong một lần, chưa lưu cursor bền vững. | Chưa bảo đảm ở production. |
| Chống gửi trùng khi Kafka phát lại | Có idempotencyKey trong message nhưng key chưa ổn định theo target và worker chưa thực thi chống trùng đầy đủ. |
Chưa hoàn chỉnh. |
Kết luận: BE đã có khoảng khung dữ liệu và tracking cần thiết, nhưng chưa có luồng A/B hoạt động từ đầu đến cuối. Chỉ thêm hai trường phần trăm vào request sẽ không đủ.
campaignBổ sung:
abTestingEnabled: Boolean
assignmentSeed: String
Ý nghĩa:
| Trường | Ý nghĩa |
|---|---|
abTestingEnabled |
Quyết định Campaign chạy theo luồng cũ hay chia mỗi người vào một nhóm. Mặc định false. |
assignmentSeed |
Chuỗi bí mật do BE sinh để chia nhóm ổn định. FE chỉ đọc, không được tự sửa. |
Quy tắc assignmentSeed:
campaignId làm seed duy nhất vì sau này có thể cần chủ động chạy lại cùng hoặc khác cách chia.CampaignActionChainsBổ sung:
chainCode: String
allocationPercent: BigDecimal
actions: List<CampaignAction>
| Trường | Ý nghĩa |
|---|---|
chainCode |
Mã ổn định của nhóm, ví dụ VARIANT_A. Mã này được lưu xuyên suốt vào target, event và báo cáo. |
allocationPercent |
Tỷ lệ người nhận thuộc nhóm khi bật A/B. |
actions |
Trong phạm vi 5 Campaign, mỗi variant chứa một action gửi SMS, WhatsApp hoặc Push. |
Không sử dụng vị trí mảng groupIndex làm mã biến thể vì khi FE thêm, xóa hoặc đổi thứ tự chain, vị trí có thể thay đổi. groupIndex vẫn có thể giữ để hiển thị vị trí action, còn chainCode là khóa nghiệp vụ.
Quy tắc tạo chainCode:
VARIANT_A, VARIANT_B, ... hoặc một mã không đổi dạng VAR_<random>.chainCode.chainCode đã nhận.chainCode sẽ được BE sinh mã mới.chainCode.CONTROL, DEFAULT, FALLBACK.campaign_run.campaignDefinitionSnapshotSnapshot của mỗi lần chạy phải lưu đầy đủ:
abTestingEnabled
assignmentSeed
actionChains[].chainCode
actionChains[].allocationPercent
actionChains[].actions
Dispatcher chỉ đọc cấu hình đã chốt trong campaign_run, không đọc cấu hình Campaign đang có thể bị thay đổi. Nhờ đó báo cáo của một run luôn khớp với cấu hình thực tế lúc run được tạo.
campaign_target_snapshotGiữ vai trò metadata của lần chốt tệp và bổ sung:
attemptNo: Long
assignmentSummary: Map<String, Long>
Ví dụ:
{
"status": "FROZEN",
"recipientCount": 100000,
"assignmentSummary": {
"VARIANT_A": 10024,
"VARIANT_B": 19931,
"CONTROL": 70045
}
}
Không chuyển snapshot sang FROZEN trước khi ClickHouse insert và kiểm tra count thành công.
Để retry an toàn:
targetSnapshotId mới.campaignRunId thành (campaignRunId, attemptNo) unique.campaign_run.targetSnapshotId chỉ trỏ đến snapshot đã FROZEN.targetSnapshotId; không xóa rồi ghi lại vào snapshot đã dùng.campaign_target_memberCác trường quan trọng:
company_id
campaign_id
campaign_run_id
target_snapshot_id
segment_version_id
recipient_key
phone
personalization_attributes
variant_code
selection_reason
created_at
Quy ước variant_code:
| Giá trị | Ý nghĩa |
|---|---|
DEFAULT |
Campaign không bật A/B; dispatcher chạy nhóm gửi thông thường duy nhất. |
VARIANT_A, VARIANT_B, ... |
Khách hàng chỉ chạy chain có chainCode tương ứng. |
CONTROL |
Khách hàng thuộc tệp nhưng không chạy action. |
FALLBACK |
Chỉ dùng nếu sau này có chính sách fallback được phê duyệt; không dùng trong phạm vi hiện tại. |
channel trong bảng target không được dùng làm nguồn xác định biến thể. chainCode mới là nhóm A/B; channel là SMS, WhatsApp hoặc Push của variant và được lưu trong delivery/engagement event. Query target không được group theo channel vì target được chốt theo variant trước khi gửi.
Không tạo worker_log hoặc bảng thực thi action theo từng người trong MongoDB. Dữ liệu cần cho 5 Campaign được chia thành hai nhóm.
Nhóm 1 - Kết quả gửi và tương tác với thông điệp
Tái sử dụng các bảng hiện có và truyền đúng variant_code:
campaign_delivery_event
campaign_click_event
campaign_open_event
Quy ước theo kênh:
| Kênh | Event cần ghi |
|---|---|
| SMS | SENT, FAILED, DELIVERED nếu provider hỗ trợ, CLICKED qua tracking link |
SENT, FAILED, DELIVERED, READ, CLICKED |
|
| Push | SENT, FAILED, OPENED, CLICKED |
campaign_open_event có thể lưu READ của WhatsApp và OPENED của Push khi bổ sung engagement_type, hoặc đổi thành bảng chung campaign_engagement_event nếu team quyết định làm schema mới. Không tạo bảng riêng cho từng Campaign.
Nhóm 2 - Sự kiện hành vi dùng để chọn target, dừng gửi và tính conversion
Sử dụng một event contract chung; dữ liệu App từ SDK/PostHog tiếp tục nằm trong analytics.posthog_events, còn event từ E-shop/CRM/OCS được chuẩn hóa về cùng các trường logic sau:
event_date
event_time
event_id
company_id
customer_id Nullable(String)
recipient_key Nullable(String)
installation_id Nullable(String)
event_code
source
source_event_id
properties
received_at
Danh mục event tối thiểu:
ONLINE_ORDER_COMPLETED
APP_INSTALLED
APP_REGISTERED
APP_ACTIVATED
MONTHLY_DATA_PACKAGE_REGISTERED
DATA_USAGE_STARTED
Không bắt buộc mọi nguồn phải ghi vào đúng một bảng vật lý. posthog_events vẫn là bảng raw cho App; OCS có thể cung cấp snapshot/trạng thái theo ngày. Lớp report/audience phải chuẩn hóa chúng thành cùng event_code và recipient_key/customer_id, không đọc từ Mongo worker log.
Khi tính conversion theo A/B, Report Service nối event hành vi với campaign_target_member bằng company_id + recipient_key/customer_id và cửa sổ thời gian của CampaignRun. variant_code lấy từ target đã chốt, không yêu cầu nguồn OCS/PostHog biết Campaign nào đã gửi.
campaign_metric_1mSchema hiện có trường target_snapshot_id và variant_code, nhưng hai trường này chưa nằm trong ORDER BY của SummingMergeTree. Khi ClickHouse merge dữ liệu, các dòng khác biến thể có nguy cơ bị cộng chung.
Khóa mới phải chứa tối thiểu:
ORDER BY (
company_id,
campaign_id,
campaign_run_id,
target_snapshot_id,
variant_code,
window_start,
channel
)
Vì ClickHouse không thay đổi sorting key đơn giản như database giao dịch, môi trường chưa có dữ liệu nên nên tạo lại bảng hoặc tạo bảng campaign_metric_1m_v2, backfill nếu cần, rồi đổi view/query.
Sử dụng 10.000 bucket để hỗ trợ độ chính xác 0.01%:
bucket = sipHash64(assignmentSeed + ":" + recipientKey) % 10000
Với cấu hình A 10%, B 20%:
| Khoảng bucket | Kết quả |
|---|---|
0 - 999 |
VARIANT_A |
1000 - 2999 |
VARIANT_B |
3000 - 9999 |
CONTROL |
Phép chia được thực hiện ngay trong câu INSERT SELECT từ segment_version_member sang campaign_target_member. Không tải toàn bộ người nhận lên RAM của Java.
CONTROL dù họ không sinh delivery event.assignmentSeed và targetSnapshotId riêng.Không được tự chuyển người đó sang variant khác vì việc chuyển nhóm sẽ làm sai tỷ lệ và sai kết quả thử nghiệm.
Luồng xử lý:
variant_code đã được chia.SKIPPED hoặc FAILED với mã lý do rõ ràng.eligible, skipped và failed; không tính họ thành gửi thành công.FE/BA nên tạo Segment có dữ liệu phù hợp với các kênh được chọn. Khi so sánh SMS với WhatsApp hoặc Push, đây là điều kiện quan trọng để kết quả có ý nghĩa.
Các request tạo/cập nhật bổ sung:
{
"abTestingEnabled": true,
"actionChains": [
{
"chainCode": "VARIANT_A",
"allocationPercent": 10.00,
"actions": [
{
"type": "NOTIFICATION_SMS"
}
]
},
{
"chainCode": "VARIANT_B",
"allocationPercent": 20.00,
"actions": [
{
"type": "WHATSAPP_SEND_MESSAGE"
}
]
}
]
}
Response trả lại cấu hình các chain đã được BE chuẩn hóa, đặc biệt là chainCode do BE sinh:
{
"abTestingEnabled": true,
"actionChains": [
{
"chainCode": "VARIANT_A",
"allocationPercent": 10.00,
"actions": []
},
{
"chainCode": "VARIANT_B",
"allocationPercent": 20.00,
"actions": []
}
]
}
FE tự tính tỷ lệ hiển thị của nhóm đối chứng bằng 100 - tổng allocationPercent. BE cũng tính công thức này độc lập để validate và chia TargetSnapshot, nhưng không nhận, không lưu và không cần trả trường controlPercent.
| Trạng thái | Quy tắc |
|---|---|
DRAFT, REJECTED |
Cho sửa A/B config. |
SUBMITTED |
Nếu sửa nội dung/tỷ lệ thì đưa Campaign về DRAFT và yêu cầu submit/approve lại; không sửa âm thầm. |
IN_PROGRESS, FINISHED, STOPPED |
Không cho sửa cấu hình A/B. Muốn thay đổi phải nhân bản Campaign. |
Không cho sửa tỷ lệ của một run đã tạo. Mỗi CampaignRun dùng snapshot cấu hình riêng.
Tạo CampaignAbErrorCode implements ErrorCode. Khi throw chỉ truyền error code; message được lấy từ file đa ngôn ngữ, không hard-code message tiếng Anh trong service.
| Error code | HTTP | Ý nghĩa |
|---|---|---|
CAMPAIGN_AB0001 |
400 | A/B đã bật nhưng không có nhóm hành động hợp lệ. |
CAMPAIGN_AB0002 |
400 | Tỷ lệ của một nhóm phải lớn hơn 0 và không vượt quá 100. |
CAMPAIGN_AB0003 |
400 | Tổng tỷ lệ các nhóm vượt quá 100%. |
CAMPAIGN_AB0004 |
400 | Thử nghiệm phải có ít nhất hai nhóm có ý nghĩa, tính cả CONTROL. |
CAMPAIGN_AB0005 |
400 | chainCode trùng hoặc sử dụng mã dành riêng. |
CAMPAIGN_AB0006 |
409 | Cấu hình A/B không thể thay đổi sau khi Campaign đã chạy. |
CAMPAIGN_AB0007 |
400 | Loại Campaign/action hiện chưa hỗ trợ A/B. |
CAMPAIGN_AB0008 |
409 | Không thể chốt nhóm A/B do TargetSnapshot không hợp lệ. |
CAMPAIGN_AB0009 |
500 | Tổng số target sau khi chia nhóm không khớp tệp nguồn. |
Thêm message cho tối thiểu các file:
messages_vi.properties
messages_en.properties
messages_lo.properties
messages_zh.properties
Lưu ý: DISPATCHED có nghĩa đã phát hết command, không đồng nghĩa tất cả worker đã xử lý thành công. Chỉ chuyển COMPLETED sau khi kết quả action cuối cùng đã được đối soát hoặc sau bước reconciliation.
Idempotency key đề xuất:
campaignRunId:targetSnapshotId:recipientKey:chainCode
Key này không thay đổi khi Kafka phát lại message hoặc dispatcher restart. Lease PROCESSING phải ngắn và có thể được node khác claim lại sau khi hết hạn; trạng thái COMPLETED giữ theo thời gian campaign còn có khả năng retry. MongoDB không lưu worker log theo từng người nhận. ClickHouse giữ event để audit và báo cáo.
Query hiện tại đang coi variant_code rỗng là CONTROL. Cách này sai vì dữ liệu rỗng là Campaign cũ hoặc dữ liệu chưa gắn variant, không phải người được chủ động giữ lại làm nhóm đối chứng.
Quy ước mới:
variant_code rỗng/null -> DEFAULT hoặc LEGACY
variant_code = CONTROL -> đúng nhóm đối chứng
Target được chia theo chain, còn channel thuộc từng action. Vì vậy:
targetCount group theo variant_code.variant_code + channel.variant_code + event_code của từng Campaign.{
"campaignRunId": "runId",
"totalTargetCount": 100000,
"variants": [
{
"variantCode": "VARIANT_A",
"configuredPercent": 10.00,
"actualTargetCount": 10024,
"actualPercent": 10.024,
"sentCount": 9980,
"failedCount": 44,
"deliveredCount": null,
"deliveryMetricAvailable": false,
"readCount": 0,
"uniqueOpenCount": 4300,
"uniqueClickCount": 900,
"conversionEventCode": "APP_ACTIVATED",
"conversionCount": 620
},
{
"variantCode": "CONTROL",
"configuredPercent": 70.00,
"actualTargetCount": 70045,
"actualPercent": 70.045,
"sentCount": 0,
"failedCount": 0,
"readCount": 0,
"uniqueOpenCount": 0,
"uniqueClickCount": 0,
"conversionEventCode": "APP_ACTIVATED",
"conversionCount": 90
}
]
}
Không đổi tên SENT thành DELIVERED. deliveredCount chỉ có giá trị khi nhà cung cấp có callback xác nhận giao thành công.
| Task | Kết quả bàn giao chính | Module chính | Phụ thuộc |
|---|---|---|---|
| T01 | API Campaign lưu và validate được cấu hình A/B | common, laoads |
Không |
| T02 | TargetSnapshot có variant_code ổn định và có CONTROL |
common, cronjob |
T01 |
| T03 | Dispatcher chạy đúng chain, có checkpoint và chống phát lại | common, campaign-dispatcher |
T02 |
| T04 | Channel worker/tracking giữ đúng variant và chuẩn hóa event của 5 Campaign | SMS, WhatsApp, Push, common |
T03; T06 đối với Push |
| T05 | Flink và Report API trả số liệu đúng từng variant | ads-analytics-flink-job, laoads, ClickHouse |
T02, T04 |
| T06 | Camp 3 gửi và theo dõi Push được | push-worker, dispatcher, API |
T01 |
| T07 | Script, feature flag và thứ tự deploy an toàn | Tất cả module liên quan | T01-T05 |
Mục tiêu
Cho phép API tạo, sửa, xem và nhân bản Campaign lưu đúng cấu hình A/B mà không làm thay đổi Campaign cũ.
Phạm vi thay đổi
common, laoads.CampaignModel, CampaignActionChains, CampaignRunDefinitionSnapshotModel, CreateCampaignRequest, UpdateCampaignRequest, CampaignDto, CampaignResponse, CampaignService.CampaignModel, CampaignActionChains.CreateCampaignRequest, UpdateCampaignRequest và response Campaign.CampaignRunDefinitionSnapshotModel.CampaignAbValidator riêng, không đặt toàn bộ validation vào CampaignService.NOTIFICATION_SMS, WHATSAPP_SEND_MESSAGE, NOTIFICATION_PUSH trong phạm vi 5 Campaign.ModelMapper cho contract mới.CampaignAbErrorCode và message đa ngôn ngữ.assignmentSeed và chainCode phù hợp.expectedCost: khi bật A/B, cost của mỗi chain phải nhân với allocationPercent; CONTROL có cost bằng 0.Quy tắc cost
expectedCost = audienceSize * tổng(chainCost * allocationPercent / 100)
Tiêu chí hoàn thành
allocationPercent > 100 bị từ chối bằng error code đúng.20% tạo control tính toán 80%.100% bị từ chối vì không tạo được phép so sánh.chainCode.Mục tiêu
Chia từng target vào đúng một variant ngay khi tạo snapshot, thực hiện hoàn toàn theo batch trong ClickHouse.
Phạm vi thay đổi
common, cronjob.SegmentMemberClickHouseService, CronjobService, CampaignTargetSnapshotModel, CampaignRunModel, repository/index liên quan.CampaignRunDefinitionSnapshotModel bằng MapStruct.attemptNo, assignmentSummary cho TargetSnapshot.buildTargetSnapshot(..., variantCode, channel) bằng request/strategy rõ nghĩa, không truyền chuỗi rỗng từ Cronjob.CampaignVariantAssignmentPlanner để chuyển phần trăm thành các khoảng bucket.INSERT SELECT với multiIf/CASE để gán variant_code.FROZEN.FROZEN là bất biến.ALTER DELETE rồi insert lại trên cùng snapshot đang được dùng.Log bắt buộc
companyId, campaignId, campaignRunId, targetSnapshotId
segmentVersionId, attemptNo, assignmentSeedHashPrefix
sourceCount, insertedCount, count theo variant, durationMs
failureCode
Không log toàn bộ assignmentSeed, phone, FCM token hoặc dữ liệu cá nhân.
Tiêu chí hoàn thành
recipientCount.FROZEN.CONTROL được ghi rõ, không dùng chuỗi rỗng.Mục tiêu
Dispatcher đọc variant_code, chỉ chạy chain tương ứng và có thể tiếp tục khi một node chết giữa chừng.
Phạm vi thay đổi
common, campaign-dispatcher.CampaignRunTriggerConsumer, CampaignDispatcherService, SegmentMemberClickHouseService, UnifiedEventMessage, CampaignActionCommandMessage.CampaignTargetDispatchMember gồm recipient, variantCode.SegmentMemberClickHouseService.pageMembers trả DTO mới thay vì làm mất variant_code.CONTROL: không gọi dispatcher.start.DEFAULT: chạy nhóm gửi thông thường duy nhất của Campaign không A/B.chainCode, không dùng groupIndex làm khóa.variantCode, chainCode vào UnifiedEventMessage và CampaignActionCommandMessage.dispatchCursor, dispatchedCount, controlCount, leaseOwner, leaseUntil, lastHeartbeatAt trong run hoặc dispatch job.DISPATCHED khỏi COMPLETED; không đánh hoàn thành ngay sau khi publish command.Log bắt buộc
campaignRunId, targetSnapshotId, batchNo, cursor
batchSize, treatmentCount, controlCount, skippedCount
leaseOwner, durationMs, publishFailureCount
Tiêu chí hoàn thành
CONTROL không sinh send command.Mục tiêu
Giữ variantCode từ command đến kết quả gửi, tương tác và conversion để báo cáo không bị mất dấu nhóm.
Phạm vi thay đổi
common, sms-worker, whatsapp-worker, push-worker, PostHog/behavior event ingestion và tracking ingestion đang dùng.WorkerTrackingService, TrackingLinkModel, TrackingLinkCachePayload, các command consumer và outcome publisher của từng worker.variantCode, chainCode.WorkerTrackingService ghi variantCode vào TrackingLinkModel và cache payload.ONLINE_ORDER_COMPLETED, APP_INSTALLED, APP_REGISTERED, APP_ACTIVATED, MONTHLY_DATA_PACKAGE_REGISTERED, DATA_USAGE_STARTED từ các nguồn của 5 Campaign.PROCESSING, COMPLETED) kết hợp provider idempotency khi có; không lưu worker log trong MongoDB.Tiêu chí hoàn thành
recipient_key trong target có thể trace đến command, delivery, tracking và behavior event bằng run/snapshot/variant.Mục tiêu
Trả báo cáo đúng theo từng variant, không trộn dữ liệu giữa run/snapshot và không coi dữ liệu legacy là control.
Phạm vi thay đổi
ads-analytics-flink-job, laoads, scripts/clickhouse.CampaignAnalyticsReportService, CampaignRunReportController, CampaignAbComparisonResponse, Flink metric key/sink và các script campaign_metric_1m.campaign_metric_1m có target_snapshot_id, variant_code.DEFAULT hoặc LEGACY, không map thành CONTROL.CONTROL lấy target count từ campaign_target_member dù không có event.Tiêu chí hoàn thành
CONTROL có send/read/open/click bằng 0 nhưng vẫn có thể có conversion tự nhiên để làm số nền so sánh.SENT thành DELIVERED khi chưa có callback provider.campaignRunId, time range, variant và channel.Mục tiêu
Gửi Push cho Camp 3 và cho phép cơ chế variant tái sử dụng Push khi được cấu hình.
Phạm vi thay đổi
common, laoads, campaign-dispatcher, push-worker.push-worker.Task này có thể triển khai song song với T02, nhưng phải hoàn thành trước khi nghiệm thu A/B có Push.
Mục tiêu
Đưa các trường và message mới lên nhiều service mà không làm Campaign cũ dừng chạy hoặc làm Kafka consumer cũ không đọc được message.
Phạm vi thay đổi
abTestingEnabled được hiểu là false.allocationPercent; các trường này chỉ bắt buộc khi bật A/B.assignmentSeed, chainCode còn thiếu.DEFAULT/LEGACY, tuyệt đối không backfill thành CONTROL.campaign.ab-testing.enabled; khi flag tắt, API từ chối request bật A/B bằng error code rõ ràng nhưng Campaign cũ vẫn hoạt động.schemaVersion và giữ consumer đọc được cả version cũ trong thời gian chuyển đổi.Thứ tự deploy
ClickHouse/Mongo scripts
-> common contract
-> worker consumers
-> campaign-dispatcher
-> cronjob/target builder
-> laoads API/report
-> Flink job
-> bật feature flag cho company thử nghiệm
Tiêu chí hoàn thành
DEFAULT trong suốt quá trình deploy.DEFAULT, treatment và CONTROL.| Case | Kết quả mong đợi |
|---|---|
| A/B tắt, request có nhiều hơn 1 chain | Từ chối; Campaign thường chỉ có một nhóm gửi. |
| A/B bật, A=10, B=20 | Target được gán A, B hoặc CONTROL; chỉ A/B sinh command. |
| A/B bật, A=50, B=50 | Không có target CONTROL. |
| A/B bật, A=20 | Hợp lệ: A 20%, CONTROL 80%. |
| A/B bật, A=100 | Không hợp lệ vì chỉ có một nhóm có ý nghĩa. |
| A/B bật, A=60, B=50 | Từ chối CAMPAIGN_AB0003. |
Hai chain trùng chainCode |
Từ chối CAMPAIGN_AB0005. |
| Target của variant SMS thiếu phone | Không chuyển variant; ghi SKIPPED/FAILED cho đúng variant. |
Target CONTROL |
Có trong TargetSnapshot và báo cáo, không có send command. |
| Kafka phát lại command | Worker nhận cùng idempotency key và không gọi provider lần hai. |
| Dispatcher chết sau 20 batch | Node khác tiếp tục từ checkpoint đã lưu. |
| Campaign cũ có variant rỗng | Báo cáo xếp DEFAULT/LEGACY, không xếp CONTROL. |
| Nhân bản Campaign | Campaign mới có seed mới và chainCode hợp lệ. |
Sửa Campaign đã SUBMITTED |
Chuyển về DRAFT, yêu cầu duyệt lại. |
| Sửa Campaign đã chạy | Từ chối; người dùng phải nhân bản Campaign. |
| Camp 1 chia SMS 10%, WhatsApp 20% | 70% còn lại được ghi CONTROL; conversion dùng APP_ACTIVATED. |
| Camp 2 đến mốc ngày 3 nhưng user đã kích hoạt App | Không đưa user vào TargetSnapshot của bước ngày 3. |
| Camp 3 target không còn FCM token hợp lệ | Không chuyển variant; ghi SKIPPED_INVALID_ENDPOINT. |
Camp 4 đã có MONTHLY_DATA_PACKAGE_REGISTERED |
Loại khỏi các lần gửi tuần tiếp theo và ghi nhận conversion cho run phù hợp. |
Camp 5 đã có DATA_USAGE_STARTED |
Loại khỏi các lần gửi tuần tiếp theo và ghi nhận conversion cho run phù hợp. |
Người thuộc CONTROL tự kích hoạt App |
Vẫn tính conversion cho CONTROL để có số nền so sánh. |
variantCode và có chống trùng.Không bật abTestingEnabled trên production trước khi T01 đến T05 hoàn thành. Có thể deploy schema/field trước với mặc định false để Campaign cũ tiếp tục chạy bình thường.
CONTROL như một action chain rỗng.Các phần này có thể bổ sung sau mà không phải thay đổi nguyên tắc chainCode, assignmentSeed, variant_code và TargetSnapshot đã thiết kế ở trên.