Campaign 2 - Chăm sóc khách hàng mua Online

1. Mục tiêu

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

2. Những gì giữ nguyên và những gì bổ sung

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.

3. Cấu hình campaign trên FE

3.1. Luồng tạo campaign

[Diagram]

3.2. Action chain CAM2

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.

3.3. Validate bắt buộc

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
eventCampaignTypeONLINE_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.

4. Dữ liệu và liên kết giữa D0, D3, D5

4.1. Bảng và cột BCCS3 dùng cho CAM2

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_IDSALE_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.

4.2. Quét một lần mỗi ngày

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:

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àycohortAnchorAt + 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.

4.3. SegmentVersion là cohort D0

Mỗ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.

4.4. Bổ sung metadata vào CampaignRun

private 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

4.5. campaign_bccs_sync_state

Collection 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.

5. Luồng runtime đầy đủ

[Diagram]

5.1. Một cohort cụ thể

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
[Diagram]

6. Quy tắc xử lý lỗi và chạy nhiều node

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.

7. Phân chia task

[Diagram]

T1 - Contract CAM2, model và validate

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:

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.

T2 - Quét BCCS3 và tạo cohort SegmentVersion

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:

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.

T3 - Orchestration CampaignRun D0, D3 và D5

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:

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.

T4 - OCS tra cứu trạng thái đăng nhập Super App theo batch

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:

Phần giao tiếp phía LaoAds cũng thuộc task này:

Câu query định hướng:

SELECT
    isdn,
    MAX(
        CASE
            WHEN TRIM(login_superapp) RLIKE '^[0-9]+
    
THEN CAST(TRIM(login_superapp) AS BIGINT) ELSE NULL END ) AS login_count, SUM( CASE WHEN TRIM(login_superapp) RLIKE '^[0-9]+
THEN 1 ELSE 0 END ) AS valid_value_count, MAX( CASE WHEN login_superapp IS NOT NULL AND TRIM(login_superapp) <> '' AND NOT (TRIM(login_superapp) RLIKE '^[0-9]+
) THEN 1 ELSE 0 END ) AS invalid_value_count FROM all_report_stl_new WHERE isdn IN (?, ?, ...) GROUP BY isdn

Ánh xạ kết quả theo thứ tự sau:

  1. valid_value_count > 0login_count > 0: LOGGED_IN.
  2. valid_value_count > 0login_count = 0: NOT_LOGGED_IN.
  3. Có bản ghi nhưng không có giá trị số hợp lệ, bao gồm null, rỗng hoặc sai định dạng: UNKNOWN.
  4. ISDN đầu vào không xuất hiện trong kết quả query: NOT_FOUND.

Nếu cùng một ISDN có nhiều dòng, chỉ cần có một giá trị số lớn hơn 0 thì thuê bao được xác định là LOGGED_IN. LaoAds chỉ đưa kết quả NOT_LOGGED_IN vào TargetSnapshot D3/D5.

Query chính xác cần được verify trên Hive/Impala thực tế và đo thời gian với batch 100, 500 và 1.000 ISDN trước khi chốt batch size production.

Luồng task:

LaoAds đọc member của Run D3/D5
-> chia batch tối đa 1.000 ISDN
-> gọi API OCS kèm requestId
-> OCS chuẩn hóa và loại trùng
-> query all_report_stl_new một lần cho batch
-> ánh xạ đủ trạng thái cho từng ISDN, kể cả NOT_FOUND
-> LaoAds chỉ giữ NOT_LOGGED_IN
-> chuyển sang T5 để tạo TargetSnapshot và gửi đúng template

Hoàn thành khi: API không query N+1; trả đủ kết quả cho mọi ISDN đầu vào; phân biệt được NOT_LOGGED_IN với NOT_FOUND/UNKNOWN; không log ISDN gốc; có timeout và mã lỗi rõ ràng khi Hadoop không phản hồi.

T5 - Kiểm tra OCS, chốt TargetSnapshot và gửi đúng template

Mục tiêu: mỗi run chỉ dùng đúng danh sách nguồn và đúng template theo D0/D3/D5.

Thay đổi chính:

Luồng task:

Run đến hạn
-> đọc đúng source version/snapshot
-> check OCS nếu là D3/D5
-> tạo target snapshot đã lọc
-> lấy duy nhất template của onlinePurchaseDay
-> dispatch SMS
-> ghi delivery log
-> hoàn tất run hoặc lập lịch bước tiếp theo

Hoàn thành khi: D5 không thể đọc member ngoài Snapshot D3; template D3 không thể bị gửi bởi Run D0/D5; template D5 không thể bị gửi nếu D3 không còn khách đạt điều kiện.

8. Thứ tự triển khai

T1 -> (T2, T3 dùng mock và T4 làm song song) -> tích hợp T2 vào T3 -> T5

T1 phải hoàn thành trước để không có hai cách biểu diễn CAM2 khác nhau. Sau khi chốt model và interface giao tiếp, T2 và T3 có thể làm song song. T3 chỉ tích hợp hoàn chỉnh khi T2 tạo được SegmentVersion READY. T4 có thể phát triển song song với T3 bằng dữ liệu ISDN mẫu. T5 ghép kết quả của T2, T3 và T4.

9. Ngoài phạm vi hiện tại