Campaign 2 chăm sóc khách hàng có giao dịch mua Online. Dữ liệu giao dịch được lấy từ BCCS3. Sau khi một đợt dữ liệu được đưa vào LaoAds, hệ thống gửi hoặc kiểm tra theo ba mốc cố định:
| Mốc | Việc cần làm |
|---|---|
| D0 | Gửi template ngày 0 cho cohort vừa nhận từ BCCS3. |
| D3 | Sau 3 ngày, kiểm tra OCS theo ISDN. Chỉ khách chưa đăng nhập Super App mới nhận template ngày 3. |
| D5 | Sau 5 ngày, kiểm tra lại OCS cho khách chưa đăng nhập ở D3. Chỉ khách vẫn chưa đăng nhập Super App mới nhận template ngày 5. |
Campaign này không dùng A/B testing, không có nhóm đối chứng và không cho tạo các nhánh hành động song song.
Mã nghiệp vụ backend:
ONLINE_PURCHASE_CUSTOMER_CARE
Tên hiển thị trên FE:
Chăm sóc khách hàng mua Online
Thiết kế này tận dụng luồng đã có, không tạo kho customer chung chỉ để phục vụ Campaign 2.
| Thành phần | Cách dùng trong Campaign 2 |
|---|---|
SegmentVersion |
Một lần quét BCCS3 thành công tạo một cohort dữ liệu bất biến. |
segment_version_member ở ClickHouse |
Lưu ISDN và thuộc tính nguồn của cohort BCCS3. |
CampaignRun |
Một lần xử lý cohort tại D0, D3 hoặc D5. |
CampaignTargetSnapshot |
Danh sách khách đã qua kiểm tra OCS tại D3 hoặc D5. |
campaign_target_member ở ClickHouse |
Member thật sự được gửi tại từng mốc. |
| Delivery log / tracking hiện có | Ghi kết quả gửi SMS; không tạo bảng log CAM2 riêng. |
Các phần mới cần bổ sung:
| Thành phần | Lý do |
|---|---|
Loại event campaign ONLINE_PURCHASE_CUSTOMER_CARE |
Phân biệt CAM2 với event game, topup và event-trigger cũ. |
| Ba template D0/D3/D5 trong một action chain | Chọn đúng nội dung theo từng mốc. |
Metadata CAM2 trong CampaignRun |
Nối cohort D0 -> D3 -> D5 chính xác. |
sourceTargetSnapshotId trong CampaignRun |
Run D5 chỉ đọc tập khách chưa đăng nhập Super App tại D3. |
campaign_bccs_sync_state |
Lưu ngày nghiệp vụ đã lấy thành công để lần quét sau chỉ lấy ngày mới. |
| BCCS scan job và CAM2 run orchestration | Tạo cohort, lập lịch và kiểm tra OCS đúng thời điểm. |
CAM2 chỉ có đúng một action chain. Trong chain này có đúng ba action SMS, mỗi action gắn với một mốc cố định.
{
"eventCampaignType": "ONLINE_PURCHASE_CUSTOMER_CARE",
"actionChains": [
{
"actions": [
{
"type": "NOTIFICATION_SMS",
"onlinePurchaseDay": "D0",
"notificationSmsData": {
"content": "Nội dung ngày 0"
}
},
{
"type": "NOTIFICATION_SMS",
"onlinePurchaseDay": "D3",
"notificationSmsData": {
"content": "Nội dung ngày 3"
}
},
{
"type": "NOTIFICATION_SMS",
"onlinePurchaseDay": "D5",
"notificationSmsData": {
"content": "Nội dung ngày 5"
}
}
]
}
]
}
Enum mới:
public enum OnlinePurchaseDayEnum {
D0,
D3,
D5
}
onlinePurchaseDay chỉ có ý nghĩa khi eventCampaignType = ONLINE_PURCHASE_CUSTOMER_CARE. Với campaign khác, field này phải là null.
Backend phải validate độc lập, không tin vào việc FE đã ẩn control hoặc khóa nút thêm action.
| Quy tắc | Kết quả khi sai |
|---|---|
eventCampaignType là ONLINE_PURCHASE_CUSTOMER_CARE nhưng type không phải FROM_EVENT |
Từ chối request. |
| CAM2 có khác đúng một action chain | Từ chối request. |
| Action chain CAM2 không có đúng ba action | Từ chối request. |
| Thiếu D0, D3 hoặc D5 | Từ chối request. |
| Có hai action cùng mốc D0/D3/D5 | Từ chối request. |
| Có action có mốc khác D0/D3/D5 | Từ chối request. |
Action CAM2 không phải NOTIFICATION_SMS |
Từ chối request. |
| SMS template thiếu nội dung hợp lệ | Từ chối request. |
Bật A/B hoặc có allocationPercent trong CAM2 |
Từ chối request. |
Campaign thường gửi onlinePurchaseDay khác null |
Từ chối request. |
| Campaign đã có cohort/run CAM2 nhưng sửa template | Không cho sửa; tạo campaign mới nếu cần đổi nội dung. |
Mỗi lỗi phải có StatusCodeEnum riêng theo quy ước dự án và message trong file đa ngôn ngữ. Service không hard-code tiếng Việt/tiếng Anh trong LaoAdsException khi đã có error code tương ứng.
CAM2 dùng bảng sale_trans. Không dùng tên bảng/cột do FE truyền vào và không tạo query động.
Cột sale_trans |
Cách dùng |
|---|---|
SALE_TRANS_ID |
ID giao dịch nguồn. Cùng với SALE_TRANS_DATE là khóa chính của bảng. |
SALE_TRANS_DATE |
Ngày nghiệp vụ dùng để lấy dữ liệu và tạo cohort theo ngày. Bảng đã partition theo cột này. |
ISDN |
Số thuê bao để kiểm tra OCS và gửi SMS. Record không có ISDN hợp lệ bị loại trước khi tạo member. |
| Các cột xác định giao dịch Online | Rule cố định trong BCCS Audience Service; phải được BCCS xác nhận trước khi bật production. |
Mẫu sale_trans.csv cho thấy một ISDN có thể có nhiều dòng cùng thời điểm giao dịch. CAM2 chăm sóc theo khách hàng, không chăm sóc theo từng dòng chi tiết, do đó một cohort chỉ giữ một member cho mỗi ISDN trong một ngày nghiệp vụ.
Khóa member dùng để chống gửi trùng trong ngày:
SHA-256(ONLINE_PURCHASE_CUSTOMER_CARE | businessDate | normalizedISDN)
SALE_TRANS_ID và SALE_TRANS_DATE vẫn được lưu trong attributes để truy vết dữ liệu nguồn. Không tự tạo hoặc yêu cầu BCCS trả field transactionHash.
Quét một lần mỗi ngày là phù hợp hơn quét 2-3 giờ/lần cho CAM2:
sale_trans partition theo SALE_TRANS_DATE; query theo khoảng ngày giúp database chỉ đọc partition cần thiết.Lịch đề xuất: chạy mỗi ngày lúc 02:00 theo múi giờ đã thống nhất với BCCS3, lấy trọn dữ liệu của ngày trước đó.
SELECT
ISDN,
MIN(SALE_TRANS_ID) AS first_sale_trans_id,
MIN(SALE_TRANS_DATE) AS first_sale_trans_date,
COUNT(*) AS source_row_count
FROM sale_trans
WHERE SALE_TRANS_DATE >= :businessDateStart
AND SALE_TRANS_DATE < :businessDateEnd
AND ISDN IS NOT NULL
AND TRIM(ISDN) <> ''
AND ... -- rule giao dịch Online đã được BCCS xác nhận
GROUP BY ISDN
MIN(...) trong query trên chỉ dùng để lưu thông tin truy vết đại diện; không dùng số lượng dòng sale_trans để gửi nhiều SMS. Rule xác định giao dịch Online chưa được suy luận từ file mẫu. Các điều kiện như SALE_TRANS_TYPE, STATUS, SHOP_CODE, PAY_METHOD hoặc cột khác phải do BCCS xác nhận và được hard-code trong BCCS Audience Service trước khi triển khai production.
Ví dụ job chạy lúc 02:00 ngày 06/08:
businessDateStart = 05/08 00:00:00
businessDateEnd = 06/08 00:00:00
SALE_TRANS_DATE dùng để chọn đúng dữ liệu theo ngày nghiệp vụ và để trace nguồn. Mốc D0/D3/D5 của cohort tính từ khi cohort được import thành công vào LaoAds (cohortAnchorAt). D0 được gửi ngay sau import; D3/D5 lần lượt là cohortAnchorAt + 3 ngày và cohortAnchorAt + 5 ngày.
Ví dụ: job lúc 02:00 ngày 06/08 lấy giao dịch của 05/08. Sau khi import xong lúc 02:10, đó là D0 của cohort; D3 chạy khoảng 02:10 ngày 09/08 và D5 chạy khoảng 02:10 ngày 11/08. Đây là đánh đổi của mô hình quét ngày. Nếu sau này bắt buộc mốc chính xác theo từng thời điểm SALE_TRANS_DATE, phải chuyển sang xử lý theo từng giao dịch hoặc quét thường xuyên hơn.
SegmentVersion là cohort D0Mỗi lần quét BCCS3 có dữ liệu mới tạo một SegmentVersion. Member được ghi vào segment_version_member hiện có.
Các thông tin nguồn lưu trong attributes của member:
{
"saleTransId": "1122722034",
"saleTransDate": "2026-08-05T09:18:01Z",
"businessDate": "2026-08-05",
"sourceSystem": "BCCS3"
}
SegmentVersion.sourceDefinitionSnapshot cần lưu metadata: nguồn BCCS3, bảng sale_trans, businessDate, khoảng SALE_TRANS_DATE đã query và rule giao dịch Online đang áp dụng.
cohortAnchorAt là thời điểm cohort được import thành công và bắt đầu D0, ví dụ 2026-08-06 02:10. Run D3 và D5 lần lượt chạy sau 3 ngày và 5 ngày từ mốc này.
CampaignRunprivate OnlinePurchaseDayEnum onlinePurchaseDay;
private String rootRunId;
private String parentCampaignRunId;
private String sourceTargetSnapshotId;
private LocalDateTime cohortAnchorAt;
Ý nghĩa:
| Field | D0 | D3 | D5 |
|---|---|---|---|
onlinePurchaseDay |
D0 |
D3 |
D5 |
sourceSegmentVersionId |
cohort BCCS3 | cohort BCCS3 | giữ để trace nguồn gốc |
sourceTargetSnapshotId |
null |
null |
Snapshot D3 |
rootRunId |
chính ID run D0 | ID run D0 | ID run D0 |
parentCampaignRunId |
null |
run D0 | run D3 |
scheduleOccurrenceKey dùng chống tạo trùng:
ONLINE_PURCHASE:{segmentVersionId}:D0
ONLINE_PURCHASE:{segmentVersionId}:D3
ONLINE_PURCHASE:{segmentVersionId}:D5
campaign_bccs_sync_stateCollection Mongo nhỏ, mỗi campaign CAM2 có một document:
{
"campaignId": "...",
"companyId": "...",
"source": "BCCS3",
"lastCompletedBusinessDate": "2026-08-05",
"lastSuccessAt": "...",
"lastErrorCode": null,
"updatedAt": "..."
}
Index unique:
{ companyId: 1, campaignId: 1, source: 1 } unique
lastCompletedBusinessDate chỉ được cập nhật sau khi tất cả member của cohort đã ghi thành công vào ClickHouse và SegmentVersion đã chuyển READY. Nếu stream BCCS3 hoặc insert ClickHouse lỗi, giữ nguyên ngày cũ để lần sau lấy lại cả ngày đó; member key theo businessDate + ISDN bảo đảm không gửi trùng.
BCCS cần xác nhận thời điểm dữ liệu của một ngày đã hoàn tất trước giờ quét. Nếu BCCS cho phép dữ liệu ngày cũ cập nhật muộn, cần thêm job đối soát riêng để đọc lại một số ngày gần nhất; job này phải tạo lại cohort theo cơ chế idempotent, không được tự động gửi trùng SMS.
02/08 02:00: Job lấy sale_trans của ngày nghiệp vụ 01/08: A, B
-> SegmentVersion V100
-> Run R100-D0 chạy ngay
-> Run R100-D3 hẹn 04/08 09:00
04/08 09:00: R100-D3 đọc V100
-> OCS: A đã đăng nhập, B chưa đăng nhập Super App
-> TargetSnapshot S100-D3 chỉ có B
-> Gửi template D3 cho B
-> Run R100-D5 hẹn 06/08 09:00
06/08 09:00: R100-D5 chỉ đọc S100-D3
-> OCS: B vẫn chưa đăng nhập Super App
-> TargetSnapshot S100-D5 chỉ có B
-> Gửi template D5 cho B
| Tình huống | Cách xử lý |
|---|---|
| BCCS stream lỗi trước khi cohort hoàn tất | SegmentVersion = FAILED; không tạo run D0/D3; không cập nhật ngày nghiệp vụ. |
| ClickHouse insert lỗi | SegmentVersion = FAILED; không cập nhật ngày nghiệp vụ. |
| D0 gửi lỗi một phần | Ghi delivery log từng recipient. Run dùng retry/reconciliation theo cơ chế dispatch hiện có; không tạo cohort mới. |
| OCS lỗi ở D3/D5 | Run không được COMPLETED; chuyển trạng thái retryable theo state machine, chưa gửi template và chưa tạo D5. |
| Không có khách đạt điều kiện ở D3 | Chốt snapshot rỗng, D3 hoàn tất, không tạo D5. |
| Không có khách đạt điều kiện ở D5 | Chốt snapshot rỗng, D5 hoàn tất. |
| Hai node cùng thấy một run đến hạn | Dùng atomic claim trạng thái SCHEDULED -> PREPARING_TARGET; chỉ node claim thành công được xử lý. |
| Job BCCS chạy lại | Cursor và scheduleOccurrenceKey ngăn tạo cohort/run trùng. |
| Campaign bị dừng trước D3/D5 | Scheduler không dispatch run mới; run chưa chạy chuyển CANCELLED theo state machine. |
Log bắt buộc không in ISDN gốc. Log campaignId, segmentVersionId, campaignRunId, targetSnapshotId, onlinePurchaseDay, số lượng member, thời gian xử lý và mã lỗi.
Mục tiêu: tạo hợp đồng dữ liệu rõ ràng để FE, API và scheduler hiểu cùng một loại campaign.
Thay đổi chính:
ONLINE_PURCHASE_CUSTOMER_CARE cho loại event campaign.OnlinePurchaseDayEnum: D0, D3, D5.onlinePurchaseDay vào CampaignAction.Luồng task:
FE gửi cấu hình CAM2
-> API xác định đúng loại event campaign
-> Validate 1 chain, 3 template SMS, D0/D3/D5 không trùng
-> Lưu DRAFT hoặc trả error code đa ngôn ngữ
-> Approve chốt definition snapshot bất biến
Hoàn thành khi: campaign thường không bị ảnh hưởng; CAM2 sai cấu hình không thể tạo hoặc duyệt qua Swagger/Postman; definition snapshot của run chứa đủ ba template.
Mục tiêu: nhận dữ liệu mới từ BCCS3 theo chu kỳ, lưu thành cohort có thể trace và dùng lại luồng SegmentVersion hiện tại.
Thay đổi chính:
campaign_bccs_sync_state và index unique.IN_PROGRESS.sale_trans, lọc bằng SALE_TRANS_DATE; không cho FE truyền query BCCS3.segment_version_member.normalizedISDN + businessDate trước khi insert để không gửi lặp do nhiều dòng sale_trans.SegmentVersion với source snapshot BCCS3 và metadata ngày nghiệp vụ/cohort.lastCompletedBusinessDate sau khi version READY.Luồng task:
Đọc ngày nghiệp vụ chưa hoàn tất
-> stream `sale_trans` theo `SALE_TRANS_DATE`
-> tạo SegmentVersion BUILDING
-> insert member ClickHouse theo batch
-> READY
-> cập nhật lastCompletedBusinessDate
-> chuyển sang T3 để tạo run D0/D3
Hoàn thành khi: restart job không tạo lại member đã xử lý của cùng ngày; lỗi stream/insert không làm mất dữ liệu do ngày nghiệp vụ chưa được đánh dấu hoàn tất; log trace đủ cả campaign, version và business date.
Mục tiêu: lập lịch đúng mốc và liên kết được đầy đủ dữ liệu của cùng một cohort.
Thay đổi chính:
CampaignRun: onlinePurchaseDay, rootRunId, parentCampaignRunId, sourceTargetSnapshotId, cohortAnchorAt.SegmentVersion BCCS3 READY, tạo idempotent Run D0 và Run D3.SegmentVersion; Run D3 cũng dùng source SegmentVersion.sourceTargetSnapshotId của D3, không đọc lại toàn bộ SegmentVersion ban đầu.Luồng task:
V100 READY
-> R100-D0 scheduled ngay
-> R100-D3 scheduled sau 3 ngày
-> D3 thành công và snapshot có member
-> R100-D5 scheduled sau 5 ngày từ cohortAnchorAt
Hoàn thành khi: có thể truy từ Run D5 về Snapshot D3, Run D3, Run D0 và SegmentVersion gốc; không có hai run cùng segmentVersionId + day.
Module thực hiện: ocs-audience-service và package integration OCS trong laoads.
Mục tiêu: cung cấp API nội bộ chuyên biệt để LaoAds gửi một batch ISDN và nhận đúng trạng thái đăng nhập Super App của từng ISDN. API này không dùng query động từ FE và không dùng endpoint stream audience hiện tại.
Nguồn dữ liệu đã xác nhận từ schema OCS:
| Thành phần | Giá trị |
|---|---|
| Bảng | all_report_stl_new |
| Cột định danh thuê bao | isdn kiểu string |
| Cột kiểm tra | login_superapp kiểu string |
Ý nghĩa login_superapp |
Số lần thuê bao đăng nhập Super App |
Do login_superapp là số lần đăng nhập, CAM2 chỉ được kết luận khách đã/chưa đăng nhập Super App. Cột này không đủ căn cứ để kết luận khách đã cài ứng dụng nhưng chưa từng đăng nhập.
API nội bộ cần bổ sung:
POST /api/v1/internal/subscribers/super-app-login-status
X-API-Key: <internal-api-key>
Content-Type: application/json
{
"requestId": "campaignRunId-or-correlation-id",
"isdns": ["85620xxxxxxx", "85620yyyyyyy"]
}
{
"requestId": "campaignRunId-or-correlation-id",
"results": [
{"isdn": "85620xxxxxxx", "status": "LOGGED_IN", "loginCount": 2},
{"isdn": "85620yyyyyyy", "status": "NOT_LOGGED_IN", "loginCount": 0}
]
}
Quy tắc xử lý bắt buộc:
INVALID.WHERE isdn IN (...) cho cả batch, không query từng ISDN theo vòng lặp.login_superapp sang số."1", "2", ... trả LOGGED_IN; chỉ chuỗi số hợp lệ "0" mới trả NOT_LOGGED_IN.UNKNOWN; không được quy đổi các giá trị này thành 0.NOT_FOUND; giá trị không chuyển được sang số trả UNKNOWN.NOT_LOGGED_IN vào TargetSnapshot. NOT_FOUND, UNKNOWN và INVALID không được coi mặc định là chưa đăng nhập.requestId, batch size, số lượng từng status, thời gian query và error code; tuyệt đối không ghi ISDN gốc.Phần giao tiếp phía LaoAds cũng thuộc task này:
ocs-audience-service, chia batch tối đa 1.000 ISDN và gom kết quả.SuperAppLoginLookupPort; phần orchestration không được phụ thuộc trực tiếp vào RestClient hoặc DTO của OCS.Câu query định hướng:
SELECT
isdn,
MAX(
CASE
WHEN TRIM(login_superapp) RLIKE '^[0-9]+