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:
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.
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.
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.
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.
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
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
{
"name": "CAM2 - Chăm sóc khách online",
"conversionGoal": {
"mode": "AUTO_OCS",
"rules": []
},
"outcomeExtractions": [
"GOAL_ACHIEVED",
"GOAL_NOT_ACHIEVED"
]
}
Validation:
{
"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
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.
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.
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"
}
)
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.
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.
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.
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
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.
Refresh mới của Campaign A không thay đổi TargetSnapshot của Campaign B đã chốt.
Khi list chứa cả hai outcome, hai version phải cùng sourceDataAsOf.
Worker:
count(A) = count(A') + count(A-A')
intersection(A', A-A') = 0
Nếu một nhánh lỗi:
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.
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.
| 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. |
| 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. |
| 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ị. |
| 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. |
| 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. |
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ữ.
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:
Hoàn thành khi request chỉ cần list enum và retry không tạo Segment trùng.
Hoàn thành khi event lỗi hoặc lặp không làm mất/sai generation.
Hoàn thành khi target/conversion mới xuất hiện đúng trong version kế tiếp.
Hoàn thành khi Campaign đích truy được nguồn/outcome/version và snapshot cũ không đổi sau refresh.
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.