Thiết kế Segment trích xuất động từ kết quả Campaign

1. Mục tiêu

Cho phép người dùng cấu hình Campaign A tự động sinh Segment từ kết quả Goal:

A       = toàn bộ recipient thực tế trong TargetSnapshot FROZEN của Campaign A
A'      = recipient thuộc A đã đạt Conversion Goal
A - A'  = recipient thuộc A chưa đạt Conversion Goal

FE chỉ gửi danh sách enum cần trích xuất:

{
  "outcomeExtractions": [
    "GOAL_ACHIEVED",
    "GOAL_NOT_ACHIEVED"
  ]
}

Quy tắc cố định:

2. Quyết định thiết kế

2.1. Segment có ID cố định, version thay đổi

Backend tự tạo tối đa hai Segment:

{Tên Campaign} - Đã đạt mục tiêu
{Tên Campaign} - Chưa đạt mục tiêu

Segment chỉ được tạo một lần và giữ ID ổn định. Khi dữ liệu Campaign nguồn thay đổi, hệ thống tạo SegmentVersion mới.

Segment A'
  V1: trạng thái sau chu kỳ 1
  V2: trạng thái sau chu kỳ 2
  V3: trạng thái sau chu kỳ 3

Segment A-A'
  V1: trạng thái sau chu kỳ 1
  V2: trạng thái sau chu kỳ 2
  V3: trạng thái sau chu kỳ 3

Không sửa member của version READY. CampaignRun đã dùng V1 luôn giữ V1 để audit.

2.2. Không đưa recipient vào Kafka message

Sau mỗi lần target hoặc Goal scan hoàn tất, producer phát event refresh chứa ID và watermark. Worker nhận event rồi query ClickHouse để tạo version mới.

Không gửi phone, email hoặc danh sách hàng nghìn recipient qua Kafka.

Event chỉ là tín hiệu. Source of truth vẫn là:

campaign_target_member
campaign_conversion_event

Nếu event bị mất, reconcile scheduler phát hiện watermark lệch và yêu cầu refresh lại.

2.3. A lấy từ TargetSnapshot đã chốt

A là hợp của tất cả TargetSnapshot FROZEN thuộc Campaign nguồn.

Không lấy từ Segment đầu vào vì:

Recipient xuất hiện ở nhiều Run chỉ tồn tại một lần trong version trích xuất, deduplicate bằng recipient_key.

2.4. Hai nhánh là kết quả tại cùng một thời điểm

Tại sourceDataAsOf:

Chưa có campaign_conversion_event -> GOAL_NOT_ACHIEVED
Có campaign_conversion_event      -> GOAL_ACHIEVED

Một recipient chỉ thuộc một nhánh trong cùng generation.

Khi conversion đến:

Generation N:     X thuộc A-A'
Generation N + 1: X thuộc A'

Generation N không bị sửa.

2.5. Backend cố định toàn bộ cách vận hành

FE không gửi và không hiển thị:

Backend cố định:

scope              = ALL_FROZEN_RUNS
refreshMode        = EVENT_DRIVEN_WITH_RECONCILE
deduplicateKey     = recipient_key
evaluationTime     = sourceDataAsOf

3. Contract FE và API

3.1. Enum

CampaignOutcomeType:
  GOAL_ACHIEVED
  GOAL_NOT_ACHIEVED

Không cần enum NONE, BOTH hoặc mode tổng hợp. Trạng thái được biểu diễn trực tiếp bằng List.

3.2. Request Campaign

{
  "name": "CAM2 - Chăm sóc khách online",
  "conversionGoal": {
    "mode": "AUTO_OCS",
    "rules": []
  },
  "outcomeExtractions": [
    "GOAL_ACHIEVED",
    "GOAL_NOT_ACHIEVED"
  ]
}

Validation:

3.3. Response Campaign

{
  "id": "campaign-A",
  "outcomeExtractions": [
    "GOAL_ACHIEVED",
    "GOAL_NOT_ACHIEVED"
  ],
  "outcomeSegments": [
    {
      "outcome": "GOAL_ACHIEVED",
      "segmentId": "segment-A-achieved",
      "segmentName": "CAM2 - Chăm sóc khách online - Đã đạt mục tiêu",
      "status": "READY",
      "latestReadyVersionId": "version-A-achieved-12",
      "memberCount": 370,
      "sourceDataAsOf": "2026-09-03T03:00:00Z"
    },
    {
      "outcome": "GOAL_NOT_ACHIEVED",
      "segmentId": "segment-A-not-achieved",
      "segmentName": "CAM2 - Chăm sóc khách online - Chưa đạt mục tiêu",
      "status": "READY",
      "latestReadyVersionId": "version-A-not-achieved-12",
      "memberCount": 830,
      "sourceDataAsOf": "2026-09-03T03:00:00Z"
    }
  ]
}

Status:

PENDING
BUILDING
READY
FAILED

3.4. FE cần bổ sung

Tại phần Conversion Goal:

Màn chi tiết Campaign hiển thị:

Màn tạo Campaign sau không cần control mới. Segment A' và A-A' xuất hiện trong danh sách Segment có sẵn.

3.5. Quy tắc cập nhật danh sách enum

4. Thiết kế dữ liệu

4.1. MongoDB campaign

Bổ sung:

outcomeExtractions: List<CampaignOutcomeType>

Campaign không lưu member và không cần lưu hai segmentId trùng lặp. Segment được tìm bằng sourceCampaignId + outcome.

4.2. MongoDB segment

Bổ sung:

SegmentFromEnum.CAMPAIGN_OUTCOME
SegmentVersionSourceTypeEnum.CAMPAIGN_OUTCOME

Segment tự sinh:

{
  "companyId": "companyId",
  "name": "CAM2 - Chăm sóc khách online - Chưa đạt mục tiêu",
  "type": "CAMPAIGN_OUTCOME",
  "campaignOutcomeDefinition": {
    "sourceCampaignId": "campaign-A",
    "outcome": "GOAL_NOT_ACHIEVED"
  },
  "status": "ACTIVE"
}

Unique index:

db.segment.createIndex(
  {
    companyId: 1,
    "campaignOutcomeDefinition.sourceCampaignId": 1,
    "campaignOutcomeDefinition.outcome": 1
  },
  {
    unique: true,
    sparse: true,
    name: "segment_campaign_outcome_unique_idx"
  }
)

4.3. MongoDB campaign_outcome_projection

Một Campaign nguồn có một checkpoint:

{
  "companyId": "companyId",
  "sourceCampaignId": "campaign-A",
  "outcomes": [
    "GOAL_ACHIEVED",
    "GOAL_NOT_ACHIEVED"
  ],
  "status": "READY",
  "dirty": false,
  "requestedDataAsOf": "2026-09-03T03:00:00Z",
  "lastReadyDataAsOf": "2026-09-03T03:00:00Z",
  "generation": 12,
  "versionIds": {
    "GOAL_ACHIEVED": "version-A-achieved-12",
    "GOAL_NOT_ACHIEVED": "version-A-not-achieved-12"
  },
  "sourceTargetCount": 1200,
  "achievedCount": 370,
  "notAchievedCount": 830,
  "leaseOwner": null,
  "leaseUntil": null,
  "lastErrorCode": null,
  "updatedAt": "..."
}

Index:

db.campaign_outcome_projection.createIndex(
  { companyId: 1, sourceCampaignId: 1 },
  { unique: true, name: "campaign_outcome_projection_unique_idx" }
)

db.campaign_outcome_projection.createIndex(
  { dirty: 1, status: 1, leaseUntil: 1 },
  { name: "campaign_outcome_projection_work_idx" }
)

Checkpoint không lưu recipient.

4.4. MongoDB segment_version

Các version cùng lần refresh dùng chung generation và sourceDataAsOf:

{
  "segmentId": "segment-A-achieved",
  "sourceType": "CAMPAIGN_OUTCOME",
  "status": "READY",
  "buildKey": "CAMPAIGN_OUTCOME:campaign-A:12:GOAL_ACHIEVED",
  "sourceDefinitionSnapshot": {
    "sourceCampaignId": "campaign-A",
    "outcome": "GOAL_ACHIEVED",
    "generation": 12,
    "sourceDataAsOf": "2026-09-03T03:00:00Z",
    "sourceTargetSnapshotIds": ["snapshot-1", "snapshot-2"]
  }
}

Version READY là bất biến. Retry cùng generation dùng lại build key.

4.5. ClickHouse

Tái sử dụng:

campaign_target_member
campaign_conversion_event
segment_version_member

Không tạo bảng member A' hoặc A-A' riêng.

Nguồn A:

SELECT recipient_key
FROM campaign_target_member
WHERE company_id = :companyId
  AND campaign_id = :sourceCampaignId
  AND target_snapshot_id IN (:frozenSnapshotIds)
GROUP BY recipient_key

A':

INSERT INTO segment_version_member (...)
SELECT ...
FROM source_target AS target
INNER JOIN source_conversion AS conversion
    ON conversion.company_id = target.company_id
   AND conversion.campaign_id = :sourceCampaignId
   AND conversion.recipient_key = target.recipient_key
WHERE conversion.detected_at <= :sourceDataAsOf

A-A':

INSERT INTO segment_version_member (...)
SELECT ...
FROM source_target AS target
LEFT ANTI JOIN source_conversion AS conversion
    ON conversion.company_id = target.company_id
   AND conversion.campaign_id = :sourceCampaignId
   AND conversion.recipient_key = target.recipient_key
   AND conversion.detected_at <= :sourceDataAsOf

Query phải deduplicate target/conversion, lấy contact data từ target mới nhất và chạy INSERT SELECT. Không tải toàn bộ ID vào Java.

5. Kafka refresh

5.1. Topic và message

campaign-outcome-refresh-requested
{
  "schemaVersion": 1,
  "eventId": "uuid",
  "companyId": "companyId",
  "sourceCampaignId": "campaign-A",
  "sourceCampaignRunId": "run-12",
  "sourceTargetSnapshotId": "snapshot-12",
  "sourceDataAsOf": "2026-09-03T03:00:00Z",
  "reason": "CONVERSION_SCAN_COMPLETED",
  "traceId": "traceId",
  "occurredAt": "2026-09-03T03:00:01Z"
}

Reason:

TARGET_SNAPSHOT_FROZEN
CONVERSION_SCAN_COMPLETED
CONVERSION_IMPORT_COMPLETED
MANUAL_RETRY
RECONCILE

Kafka key:

companyId + ":" + sourceCampaignId

5.2. Thứ tự dữ liệu

TARGET_SNAPSHOT_FROZEN chỉ đánh dấu dirty. Với AUTO_OCS, worker chờ Goal scan tương ứng hoàn tất:

TargetSnapshot FROZEN
-> refresh event TARGET_SNAPSHOT_FROZEN
-> projection dirty
-> Goal scan ghi conversion
-> commit scan dataAsOf
-> refresh event CONVERSION_SCAN_COMPLETED
-> build generation mới

Nếu build A-A' trước Goal scan, target mới có thể bị phân loại sai là chưa đạt Goal.

6. Luồng đầy đủ

6.1. FE tạo Campaign và backend ensure Segment

[Diagram]

6.2. Chu kỳ Campaign A tạo dữ liệu mới

[Diagram]

6.3. Recipient chuyển nhánh

[Diagram]

6.4. Campaign B sử dụng Segment

[Diagram]

Refresh mới của Campaign A không thay đổi TargetSnapshot của Campaign B đã chốt.

7. Publish generation nhất quán

Khi list chứa cả hai outcome, hai version phải cùng sourceDataAsOf.

Worker:

  1. Claim lease projection.
  2. Chốt generation và sourceDataAsOf.
  3. Resolve snapshot FROZEN.
  4. Tạo các SegmentVersion BUILDING theo list enum.
  5. Insert member.
  6. Verify:
count(A) = count(A') + count(A-A')
intersection(A', A-A') = 0
  1. Chỉ publish generation READY khi tất cả nhánh hợp lệ.
  2. Cập nhật latestReadyVersionId của các Segment.

Nếu một nhánh lỗi:

8. Reconcile khi mất event

Reconcile định kỳ:

1. Tìm Campaign có outcomeExtractions không rỗng.
2. Lấy watermark TargetSnapshot FROZEN mới nhất.
3. Lấy Goal scan/import dataAsOf mới nhất.
4. So sánh với projection.lastReadyDataAsOf.
5. Nếu nguồn mới hơn, set dirty và enqueue RECONCILE.

Không full build khi watermark không đổi.

Event giúp cập nhật nhanh; reconcile bảo đảm không mất dữ liệu.

9. Lineage

Quan hệ được suy ra:

Campaign A
  -> Segment GOAL_ACHIEVED
      -> Campaign B
  -> Segment GOAL_NOT_ACHIEVED
      -> Campaign C

Nguồn quan hệ:

Segment.campaignOutcomeDefinition.sourceCampaignId
Campaign.segmentId

API:

GET /campaigns/{campaignId}/lineage

FE hiển thị Campaign nguồn, Segment trích xuất, Campaign sử dụng, latest version và sourceDataAsOf.

Backend từ chối vòng lặp trực tiếp hoặc gián tiếp như A -> B -> A.

10. Các trường hợp bắt buộc

10.1. Request và Segment tự sinh

Trường hợp Xử lý
outcomeExtractions null Chuẩn hóa thành list rỗng.
List rỗng Không tạo Segment.
List có một enum Ensure đúng một Segment.
List có hai enum Ensure hai Segment.
Enum bị lặp Normalize distinct hoặc trả validation error thống nhất.
Enum không hợp lệ/null Từ chối request.
Campaign chưa có Goal Không cho list khác rỗng.
Request create/update retry Unique index trả Segment cũ, không tạo trùng.
Thêm outcome sau khi đã chạy Tạo Segment còn thiếu và full build generation hiện tại.
Bỏ outcome sau khi đã chạy Không xóa Segment/version; archive riêng nếu cần.
Campaign khác company Không được xem hoặc dùng Segment.

10.2. Dữ liệu theo chu kỳ

Trường hợp Xử lý
TargetSnapshot mới FROZEN Mark dirty và chờ Goal scan ready.
TargetSnapshot rỗng Cập nhật watermark, count không đổi.
Recipient xuất hiện nhiều chu kỳ Deduplicate theo recipient_key.
Recipient mới chưa conversion Vào A-A' generation mới.
Recipient mới đã conversion Vào A' generation mới.
Recipient cũ vừa conversion Chuyển A-A' sang A' ở version mới.
Conversion duplicate Deduplicate bằng conversion_id.
Snapshot FAILED/CANCELLED/BUILDING Không đưa vào A.
Nhiều attempt một Run Chỉ lấy snapshot FROZEN.
Target CONTROL Vẫn phân nhánh theo conversion.
Delivery FAILED Vẫn thuộc A vì A là target đã chốt.

10.3. Ordering và dữ liệu chậm

Trường hợp Xử lý
TARGET_FROZEN đến trước Goal scan Chỉ dirty, chưa publish.
Event đến sai thứ tự Query source of truth theo watermark, không áp dụng delta mù.
Event cũ đến lại Bỏ nếu sourceDataAsOf không mới hơn.
Goal scanner lỗi Giữ generation READY cũ, không phân loại target mới vào A-A'.
Conversion đến trong lúc build Thuộc generation sau theo sourceDataAsOf.
Manual import đến muộn Import completed tạo generation mới.
Kafka publish thất bại Reconcile phát hiện watermark lệch.
Khác timezone Backend/Kafka/ClickHouse dùng UTC; FE chỉ đổi khi hiển thị.

10.4. Retry và toàn vẹn

Trường hợp Xử lý
Hai worker cùng nhận event Mongo lease chỉ cho một worker build.
Worker chết sau insert một nhánh Generation chưa READY; retry hoàn tất hoặc dọn attempt.
ClickHouse lỗi Không đổi latestReadyVersionId.
Mongo publish lỗi sau insert Retry theo build key, không nhân đôi member.
A' + A-A' khác A Generation FAILED.
Hai nhánh giao nhau Generation FAILED.
Chỉ trích xuất một nhánh Chỉ build và validate nhánh đó không vượt A.
Reconcile trùng event Lease và build key giữ idempotency.

10.5. Campaign sử dụng

Trường hợp Xử lý
Segment chưa có version READY Không cho Run dispatch; chờ/retry.
Projection đang refresh Run chờ generation đáp ứng watermark bắt buộc.
Segment rỗng Version READY 0 member; Run hoàn tất không gửi.
Campaign B đã freeze rồi A refresh B giữ version/snapshot cũ.
Nhiều Campaign dùng cùng Segment Cho phép; mỗi Run lưu version đã dùng.
Campaign nguồn archive Ngừng refresh mới, giữ version để audit.
Hard delete Campaign nguồn Từ chối khi Segment trích xuất tồn tại.
Cycle A -> B -> A Backend từ chối.

11. Error code

CAMPAIGN_OUTCOME0001  Campaign phải có Conversion Goal để trích xuất Segment
CAMPAIGN_OUTCOME0002  Danh sách loại kết quả trích xuất không hợp lệ
CAMPAIGN_OUTCOME0003  Không thể tạo Segment trích xuất
CAMPAIGN_OUTCOME0004  TargetSnapshot nguồn chưa sẵn sàng
CAMPAIGN_OUTCOME0005  Dữ liệu Goal scan chưa sẵn sàng
CAMPAIGN_OUTCOME0006  Không thể build SegmentVersion kết quả
CAMPAIGN_OUTCOME0007  Hai nhánh kết quả không toàn vẹn
CAMPAIGN_OUTCOME0008  Không thể publish generation
CAMPAIGN_OUTCOME0009  Segment kết quả chưa có version READY
CAMPAIGN_OUTCOME0010  Phát hiện vòng lặp Campaign

Message lấy từ StatusCodeEnum và resource đa ngôn ngữ.

12. Log, metric và bảo mật

Log:

traceId, eventId
companyId, sourceCampaignId, sourceCampaignRunId
sourceTargetSnapshotId, sourceDataAsOf
projectionId, generation
outcome, segmentId, segmentVersionId
sourceTargetCount, achievedCount, notAchievedCount
Kafka topic/partition/offset
durationMs, errorCode

Không log raw phone, email hoặc evidence.

Metric:

13. Task triển khai

Task 01 - List enum và Segment tự sinh

Hoàn thành khi request chỉ cần list enum và retry không tạo Segment trùng.

Task 02 - Kafka refresh và checkpoint

Hoàn thành khi event lỗi hoặc lặp không làm mất/sai generation.

Task 03 - Outcome SegmentVersion Builder

Hoàn thành khi target/conversion mới xuất hiện đúng trong version kế tiếp.

Task 04 - Campaign sử dụng và lineage FE

Hoàn thành khi Campaign đích truy được nguồn/outcome/version và snapshot cũ không đổi sau refresh.

Phụ thuộc

[Diagram]

14. Nghiệm thu xuyên suốt

1. FE tạo Campaign A có Goal và gửi outcomeExtractions gồm hai enum.
2. Backend tạo đúng hai Segment.
3. Chu kỳ 1 freeze 100 target, Goal scan có 30 conversion.
4. Worker publish generation 1: A'=30, A-A'=70.
5. Chu kỳ 2 thêm 20 target; 5 khách mới và 2 khách cũ conversion.
6. Worker publish generation 2: A'=37, A-A'=83.
7. Generation 1 vẫn giữ 30/70.
8. Campaign B chọn Segment A-A' từ Segment có sẵn.
9. Run B chốt generation 2 và TargetSnapshot 83 member.
10. Campaign A refresh generation 3 không sửa snapshot B.
11. Tắt Kafka producer rồi tạo dữ liệu mới.
12. Reconcile phát hiện watermark lệch và build generation bị bỏ lỡ.
13. Retry không tạo Segment/version/member trùng.

15. Ngoài phạm vi