Campaign 3 - Push Notification theo hành vi ứng dụng

1. Mục tiêu

Campaign 3 gửi Push Notification cho thiết bị đã mở ứng dụng nhưng sau 24 giờ vẫn chưa đăng nhập hoặc kích hoạt tài khoản.

Thiết kế không chỉ giải quyết một điều kiện hard-code. Hệ thống cần tạo được Segment động từ:

Các nguồn FILE, OCS và SDK phải cùng cập nhật một kho customer chung. Segment chỉ lưu định nghĩa chọn customer và các phiên bản kết quả, không tạo một kho customer riêng cho từng nguồn hoặc từng Campaign.

2. Phạm vi và quyết định thiết kế

2.1. Tên nghiệp vụ mới

Nguồn chọn đối tượng mới sử dụng tên backend:

APP_BEHAVIOR

Tên hiển thị trên FE đề xuất là Hành vi ứng dụng.

Không dùng tên EVENT hoặc APP_EVENT vì dễ nhầm với API event-trigger cũ. Các enum và API event-trigger cũ không thuộc thiết kế Campaign 3; có thể xóa ở một thay đổi riêng nếu không còn luồng Lucky Draw/Laoshop sử dụng.

2.2. Ba cách chọn đối tượng trên màn tạo Campaign

Lựa chọn trên FE Giá trị backend Cách xử lý
Upload file FILE Import customer, tạo Segment và SegmentVersion từ file.
Chọn Segment có sẵn AVAILABLE Campaign sử dụng Segment đã tồn tại.
Hành vi ứng dụng APP_BEHAVIOR Lưu cây điều kiện và build SegmentVersion tại thời điểm CampaignRun chạy.

Trong phạm vi Campaign 3, APP_BEHAVIOR chỉ được kết hợp với action NOTIFICATION_PUSH. Backend phải kiểm tra quy tắc này; không chỉ ẩn lựa chọn trên FE.

2.3. Nguyên tắc lưu trữ

Dữ liệu Nơi lưu chính Lý do
Customer hiện tại MongoDB Cần tạo/cập nhật, chống trùng và tìm nhanh theo identity.
Installation và FCM token hiện tại MongoDB Cần tính nhất quán khi đăng nhập và trước khi gửi Push.
PostHog event lịch sử ClickHouse Số lượng lớn, chủ yếu ghi mới và phân tích theo thời gian.
Bản chiếu customer/installation phục vụ query lớn ClickHouse Cho phép lọc nhiều triệu customer kết hợp với lịch sử event.
Segment, SegmentVersion, CampaignRun MongoDB Là metadata và state machine nghiệp vụ.
SegmentVersion member và TargetSnapshot member ClickHouse Danh sách có thể rất lớn và cần đọc theo batch.

MongoDB là nguồn đúng cuối cùng đối với customer, trạng thái đăng nhập và FCM token. ClickHouse là nơi tính toán audience số lượng lớn. Không đưa FCM token vào ClickHouse, Kafka command hoặc SegmentVersion member.

3. Các khái niệm chính

Khái niệm Ý nghĩa
CustomerProfile Hồ sơ khách hàng chung trong một company. FILE, OCS và SDK đều dùng chung customer này.
CustomerIdentity Phone/email đã chuẩn hóa dùng để tìm customer hiện có.
AppInstallation Một lượt cài ứng dụng trên một thiết bị, bao gồm FCM token và trạng thái đăng nhập.
PostHogEvent Một hành vi đã xảy ra tại một thời điểm, ví dụ mở app hoặc xem sản phẩm.
APP_BEHAVIOR Segment Segment lưu cây điều kiện trên customer, installation và event.
SegmentVersion Kết quả đã chốt của một lần build Segment.
CampaignRun Một lần chạy thực tế của Campaign.
TargetSnapshot Danh sách người nhận đã chốt cho một CampaignRun.

Một customer có thể xuất hiện trong nhiều file, nhiều Segment và nhiều Campaign nhưng vẫn sử dụng cùng một customerId trong company.

4. Kiến trúc tổng thể

[Diagram]

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

5.1. MongoDB customer_profile

Đây là hồ sơ customer hiện tại, không phải bảng event hoặc danh sách member của Segment.

{
  "_id": "customerId",
  "companyId": "companyId",
  "profileType": "IDENTIFIED",
  "canonicalCustomerId": "customerId",
  "firstName": "Somchai",
  "lastName": "...",
  "language": "lo",
  "attributes": {
    "province": "Vientiane",
    "subscriberType": "PREPAID"
  },
  "status": "ACTIVE",
  "profileVersion": 7,
  "firstSeenAt": "...",
  "lastSeenAt": "...",
  "createdAt": "...",
  "updatedAt": "..."
}

Enum:

CustomerProfileType: ANONYMOUS, IDENTIFIED
CustomerProfileStatus: ACTIVE, INACTIVE, MERGED, ANONYMIZED

canonicalCustomerId có ý nghĩa:

Index tối thiểu:

db.customer_profile.createIndex(
  { companyId: 1, status: 1, updatedAt: -1 },
  { name: "customer_profile_company_status_updated_idx" }
)

db.customer_profile.createIndex(
  { companyId: 1, canonicalCustomerId: 1 },
  { name: "customer_profile_company_canonical_idx" }
)

5.2. MongoDB customer_identity

Collection này chống tạo trùng customer khi cùng phone/email xuất hiện từ FILE, OCS hoặc app login.

{
  "_id": "identityId",
  "companyId": "companyId",
  "customerId": "customerId",
  "type": "MSISDN",
  "valueHash": "sha256(normalizedValue)",
  "valueEncrypted": "encryptedValue",
  "status": "ACTIVE",
  "verified": false,
  "createdAt": "...",
  "updatedAt": "..."
}

Enum:

CustomerIdentityType: MSISDN, EMAIL
CustomerIdentityStatus: ACTIVE, REVOKED

Index bắt buộc:

db.customer_identity.createIndex(
  { companyId: 1, type: 1, valueHash: 1 },
  { unique: true, name: "customer_identity_company_type_hash_unique_idx" }
)

db.customer_identity.createIndex(
  { companyId: 1, customerId: 1, status: 1 },
  { name: "customer_identity_company_customer_status_idx" }
)

Không lưu LaoID ID hoặc OCS subscriber ID làm khóa customer trong phạm vi này. Các nguồn dùng phone/email đã chuẩn hóa để resolve customer.

5.3. MongoDB app_installation

Một customer có thể có nhiều installation. Một installation tại một thời điểm chỉ trỏ tới một customer hiện tại.

{
  "_id": "installationRecordId",
  "companyId": "companyId",
  "appId": "UNITEL_SUPER_APP",
  "installationId": "uuid-generated-by-app",
  "currentCustomerId": "customerId",
  "firstOpenedAt": "...",
  "lastSeenAt": "...",
  "everLoggedIn": false,
  "lastLoggedInAt": null,
  "fcmTokenEncrypted": "...",
  "fcmTokenHash": "...",
  "fcmStatus": "ACTIVE",
  "platform": "ANDROID",
  "appVersion": "1.0.0",
  "language": "lo",
  "installationVersion": 3,
  "status": "ACTIVE",
  "createdAt": "...",
  "updatedAt": "..."
}

Enum:

InstallationStatus: ACTIVE, INACTIVE
FcmTokenStatus: ACTIVE, INVALID, REVOKED
PlatformType: ANDROID, IOS

Index:

db.app_installation.createIndex(
  { companyId: 1, appId: 1, installationId: 1 },
  { unique: true, name: "app_installation_company_app_installation_unique_idx" }
)

db.app_installation.createIndex(
  { companyId: 1, appId: 1, everLoggedIn: 1, fcmStatus: 1, status: 1, firstOpenedAt: 1 },
  { name: "app_installation_campaign3_filter_idx" }
)

db.app_installation.createIndex(
  { companyId: 1, appId: 1, currentCustomerId: 1, status: 1 },
  { name: "app_installation_company_app_customer_idx" }
)

db.app_installation.createIndex(
  { companyId: 1, appId: 1, fcmTokenHash: 1 },
  { name: "app_installation_company_app_token_idx", sparse: true }
)

5.4. MongoDB app_behavior_field_config

Collection này quy định FE được phép sử dụng field nào và backend phải đọc field đó ở đâu.

{
  "appId": "UNITEL_SUPER_APP",
  "fieldCode": "FIRST_OPENED_AT",
  "domain": "APP_INSTALLATION",
  "storageType": "CURRENT_STATE",
  "queryBackend": "MONGO",
  "dataType": "DATETIME",
  "allowedOperators": ["BEFORE", "AFTER", "BEFORE_RELATIVE"],
  "labels": {
    "vi": "Thời điểm mở ứng dụng lần đầu",
    "en": "First opened at",
    "lo": "..."
  },
  "status": "ACTIVE",
  "sortOrder": 1
}

Các enum nội bộ:

BehaviorDataDomain: CUSTOMER_PROFILE, APP_INSTALLATION, POSTHOG_EVENT
BehaviorStorageType: CURRENT_STATE, EVENT_HISTORY, EVENT_AGGREGATE
BehaviorQueryBackend: MONGO, CLICKHOUSE
BehaviorDataType: STRING, NUMBER, BOOLEAN, DATETIME, ENUM

physicalField hoặc biểu thức SQL không được nhận từ FE. Nếu lưu mapping vật lý trong Mongo thì giá trị phải được backend kiểm tra bằng allowlist trước khi tạo query.

5.5. MongoDB app_event_catalog

Chỉ các event được bật audienceEnabled mới xuất hiện trên FE và được dùng để build audience.

{
  "appId": "UNITEL_SUPER_APP",
  "eventCode": "PRODUCT_VIEWED",
  "sourceEventNames": ["Product Viewed", "product_viewed"],
  "allowedMetrics": ["COUNT", "FIRST_OCCURRED_AT", "LAST_OCCURRED_AT", "EXISTS"],
  "maxLookbackDays": 90,
  "audienceEnabled": true,
  "status": "ACTIVE",
  "labels": {
    "vi": "Xem sản phẩm",
    "en": "Product viewed",
    "lo": "..."
  }
}

5.6. ClickHouse analytics.posthog_events

Giữ bảng PostHog hiện tại nhưng bổ sung các dimension typed:

event_id UUID
company_id String
app_id LowCardinality(String)
event_code LowCardinality(String)
source_event_name String
installation_id String
customer_id_at_event Nullable(String)
event_time DateTime64(3, 'UTC')
received_at DateTime64(3, 'UTC')
properties_json String
trace_id String
ip String
user_agent String

Khóa sắp xếp đề xuất:

PARTITION BY toYYYYMM(event_time)
ORDER BY (
    company_id,
    app_id,
    event_code,
    toDate(event_time),
    installation_id,
    event_time,
    event_id
)

Quy tắc:

5.7. ClickHouse analytics.customer_profile_current

Đây là bản chiếu phục vụ query lớn, không thay thế MongoDB source of truth.

company_id
customer_id
canonical_customer_id
profile_type
status
language
country_code
province_code
subscriber_type
attributes
profile_version
updated_at

Engine đề xuất:

ReplacingMergeTree(profile_version)
ORDER BY (company_id, customer_id)

Query phải lấy version mới nhất bằng argMax(..., profile_version) hoặc một lớp view chuẩn. Không dùng FINAL mặc định cho query lớn.

5.8. ClickHouse analytics.app_installation_current

company_id
app_id
installation_id
current_customer_id
first_opened_at
last_seen_at
ever_logged_in
last_logged_in_at
has_active_fcm
platform
app_version
language
status
installation_version
updated_at

Engine đề xuất:

ReplacingMergeTree(installation_version)
ORDER BY (company_id, app_id, installation_id)

FCM token không tồn tại trong bảng này. Bảng chỉ có has_active_fcm để lọc candidate.

5.9. ClickHouse analytics.app_event_daily_aggregate

Dùng cho các điều kiện như “mở app ít nhất 3 lần trong 7 ngày”.

event_date
company_id
app_id
event_code
installation_id
customer_id_at_event Nullable(String)
event_count
first_event_at
last_event_at

Chỉ tạo aggregate cho event có audienceEnabled = true. Không tổng hợp mọi event PostHog theo installation nếu không có nhu cầu audience.

5.10. ClickHouse segment_version_member

Member cần hỗ trợ cả customer-level và installation-level:

company_id
segment_id
segment_version_id
subject_type         -- CUSTOMER hoặc INSTALLATION
customer_id Nullable(String)
installation_id Nullable(String)
recipient_key
source_type          -- FILE, OCS, APP_BEHAVIOR
created_at

Campaign 3 sử dụng subject_type = INSTALLATION. SMS/mail có thể sử dụng subject_type = CUSTOMER.

5.11. Đồng bộ trạng thái MongoDB sang ClickHouse

Không thực hiện kiểu ghi MongoDB xong gửi Kafka bằng hai thao tác độc lập. Nếu MongoDB thành công nhưng Kafka lỗi, bản chiếu ClickHouse sẽ thiếu dữ liệu.

Phương án đề xuất là Mongo Change Stream:

customer_profile/app_installation thay đổi
    -> Change Stream Publisher đọc change event
    -> gửi snapshot có version vào Kafka
    -> Behavior Projection Worker ghi ClickHouse

Publisher lưu resume token/checkpoint để tiếp tục sau khi restart. Nếu oplog đã hết thời gian giữ và resume token không còn dùng được, chạy reconcile/full rebuild theo companyId và khoảng updatedAt.

Projection Worker chỉ nhận snapshot có version lớn hơn version đã biết. Kafka retry hoặc nhiều node xử lý cùng event không được làm trạng thái cũ ghi đè trạng thái mới.

6. Luồng hợp nhất customer từ FILE, OCS và SDK

Không nguồn nào được tự ghi trực tiếp theo quy tắc riêng vào customer_profile. Tất cả sử dụng chung:

CustomerIngestionService
    -> CustomerDataNormalizer
    -> CustomerIdentityResolver
    -> CustomerProfileUpsertService

6.1. Quy tắc nhận diện

  1. Chuẩn hóa phone/email.
  2. Băm giá trị đã chuẩn hóa.
  3. Tìm customer_identity trong đúng companyId.
  4. Không tìm thấy identity nào: tạo customer mới.
  5. Tìm thấy một customer: cập nhật customer đó.
  6. Phone và email cùng trỏ tới một customer: cập nhật bình thường.
  7. Phone và email trỏ tới hai customer khác nhau: không tự gộp; áp dụng primary identity của nguồn và ghi nhận conflict để xử lý sau.

Không được tìm customer khác company. Cùng một phone ở hai company là hai customer độc lập.

6.2. Import FILE

[Diagram]

6.3. Dữ liệu OCS

[Diagram]

FILE và OCS vừa cập nhật kho customer chung, vừa tạo membership cho đúng SegmentVersion. Không đổ raw row vào customer_profile mà không normalize/resolve.

6.4. SDK tạo anonymous customer

Khi app mở lần đầu:

  1. App tạo installationId dạng UUID và lưu bền vững.
  2. App gọi API register installation.
  3. Backend tạo anonymous customer_profile nếu installation chưa có customer.
  4. Backend tạo hoặc cập nhật app_installation trỏ tới anonymous customer.
  5. Event trước đăng nhập ghi customer_id_at_event là anonymous customer.

Khi đăng nhập:

  1. Backend lấy phone/email từ tài khoản đã xác thực, không tin phone/email client tự truyền.
  2. Resolve customer thật trong kho chung. Customer này có thể đã được tạo từ FILE hoặc OCS.
  3. Cập nhật app_installation.currentCustomerId sang customer thật.
  4. Đặt everLoggedIn = true; logout không đưa field này về false.
  5. Anonymous customer được đặt MERGEDcanonicalCustomerId trỏ tới customer thật.
  6. Không cập nhật lại toàn bộ event cũ.

6.5. Liên kết hành vi trước và sau đăng nhập

[Diagram]

Không nối toàn bộ event cũ vào currentCustomerId hiện tại của installation, vì một thiết bị có thể đổi tài khoản. customer_id_at_event giữ đúng customer tại thời điểm event; canonicalCustomerId chỉ dùng để hợp nhất anonymous customer đã được xác định.

7. API Mobile và PostHog

7.1. API installation

POST /sdk/v1/apps/{appId}/installations/register
PUT  /sdk/v1/apps/{appId}/installations/{installationId}/fcm-token
POST /sdk/v1/apps/{appId}/installations/{installationId}/identify
POST /sdk/v1/apps/{appId}/installations/{installationId}/logout

Vai trò:

API Vai trò
register Ghi nhận lần mở app đầu tiên và thông tin thiết bị. Gọi lại phải idempotent.
fcm-token Cập nhật token khi Firebase cấp token mới.
identify Liên kết installation với customer thật sau login thành công.
logout Xóa customer đang đăng nhập khỏi session hiện tại nhưng không reset everLoggedIn.

firstOpenedAt chỉ được gán lần đầu. Các request register tiếp theo chỉ cập nhật lastSeenAt, app version, language và platform.

7.2. API PostHog giữ lại

POST /posthog/capture
POST /posthog/e
POST /posthog/batch
GET/POST /posthog/decide

capture, e, batch tiếp tục được tái sử dụng. decide chỉ phục vụ tương thích SDK, không dùng để build audience.

PostHogEventMessage mới:

{
  "schemaVersion": 2,
  "eventId": "uuid",
  "companyId": "companyId",
  "appId": "UNITEL_SUPER_APP",
  "eventCode": "APP_OPENED",
  "sourceEventName": "Application Opened",
  "installationId": "installationId",
  "customerIdAtEvent": "customerId",
  "eventTime": "...",
  "receivedAt": "...",
  "propertiesJson": "{}",
  "traceId": "..."
}

Backend resolve companyIdappId từ integration API key. Không nhận hai field này từ client làm nguồn tin cậy.

Event dùng cho audience bắt buộc có installationId. Event thiếu installation vẫn có thể lưu raw để debug nếu nghiệp vụ cho phép, nhưng phải đặt audienceEligible = false và không được dùng để tạo SegmentVersion.

8. Giao diện tạo APP_BEHAVIOR Segment

[Diagram]

FE lấy metadata từ backend, không tự hard-code danh sách field/operator:

GET  /apps
GET  /apps/{appId}/behavior-fields
GET  /apps/{appId}/behavior-events
POST /app-behavior/validate
POST /app-behavior/estimate

estimate có timeout và rate limit riêng. Không dùng kết quả estimate làm TargetSnapshot.

9. Cấu trúc điều kiện động

9.1. Các loại node

GROUP
    Nhóm điều kiện con bằng AND hoặc OR.

FIELD
    Điều kiện trên trạng thái customer hoặc installation hiện tại.

EVENT_METRIC
    Điều kiện dựa trên số lần, lần đầu hoặc lần cuối event xảy ra trong một khoảng thời gian.

Ví dụ Campaign 3:

{
  "source": "APP_BEHAVIOR",
  "appId": "UNITEL_SUPER_APP",
  "rootCondition": {
    "nodeType": "GROUP",
    "logicalOperator": "AND",
    "children": [
      {
        "nodeType": "FIELD",
        "fieldCode": "FIRST_OPENED_AT",
        "operator": "BEFORE_RELATIVE",
        "relativeValue": 24,
        "relativeUnit": "HOUR"
      },
      {
        "nodeType": "FIELD",
        "fieldCode": "EVER_LOGGED_IN",
        "operator": "EQUALS",
        "value": false
      },
      {
        "nodeType": "FIELD",
        "fieldCode": "FCM_STATUS",
        "operator": "EQUALS",
        "value": "ACTIVE"
      }
    ]
  }
}

Ví dụ tương lai: customer đã xem sản phẩm ít nhất ba lần trong bảy ngày:

{
  "nodeType": "EVENT_METRIC",
  "eventCode": "PRODUCT_VIEWED",
  "metric": "COUNT",
  "operator": "GREATER_THAN_OR_EQUALS",
  "value": 3,
  "timeWindow": {
    "type": "LAST",
    "value": 7,
    "unit": "DAY"
  }
}

Giới hạn đề xuất:

maxDepth = 3
maxTotalNodes = 20
maxGroupChildren = 10
maxInValues = 100
maxEventLookbackDays = 90

10. Audience Query Planner

10.1. Vì sao cần Query Planner

Segment không được lưu tên bảng Mongo, ClickHouse hoặc SQL. Segment chỉ lưu fieldCode, eventCode, operator và value.

AudienceQueryPlanner đọc metadata của các field rồi quyết định query ở đâu:

public enum AudienceQueryMode {
    MONGO_ONLY,
    CLICKHOUSE_ONLY,
    HYBRID
}
Điều kiện Query mode Cách chạy
Chỉ trạng thái hiện tại MONGO_ONLY Query MongoDB bằng index và cursor.
Chỉ lịch sử/aggregate event CLICKHOUSE_ONLY Query ClickHouse theo company, app, event và time range.
Có cả trạng thái hiện tại và event HYBRID Query trong ClickHouse bằng current projection kết hợp event/aggregate.

Các strategy:

MongoCurrentStateQueryStrategy
ClickHouseBehaviorQueryStrategy
HybridBehaviorQueryStrategy

Controller và Campaign service không chứa Mongo query hoặc ClickHouse SQL. Chúng chỉ gọi AudienceQueryService; service chọn strategy dựa trên plan.

10.2. Campaign 3 hiện tại dùng MongoDB

Campaign 3 chỉ có field CURRENT_STATE:

firstOpenedAt <= runAt - 24h
everLoggedIn = false
fcmStatus = ACTIVE
status = ACTIVE

Planner tạo MONGO_ONLY. Worker đọc app_installation theo index và cursor/batch 1.000 bản ghi, sau đó ghi kết quả vào segment_version_member.

Không dùng skip/offset cho job lớn. Build job lưu checkpoint để node khác có thể tiếp tục khi worker dừng giữa chừng.

10.3. Điều kiện lịch sử dùng ClickHouse

Ví dụ:

PRODUCT_VIEWED COUNT >= 3 trong 7 ngày

Planner tạo CLICKHOUSE_ONLY và query app_event_daily_aggregate hoặc posthog_events nếu event chưa có aggregate phù hợp. Event chưa đăng ký audienceEnabled không được query.

10.4. Điều kiện kết hợp dùng HYBRID

Ví dụ:

province = VIENTIANE
AND everLoggedIn = false
AND PRODUCT_VIEWED COUNT >= 3 trong 7 ngày

Planner tạo HYBRID. Query sử dụng:

customer_profile_current
JOIN app_installation_current
JOIN app_event_daily_aggregate

Toàn bộ phép giao/hợp tập customer được thực hiện trong ClickHouse. Không tải hàng triệu candidate ID về RAM Java để tự giao tập hợp.

Trước khi hỗ trợ HYBRID trên production, projection customer và installation phải hoàn thành và có cơ chế kiểm tra độ trễ. Không fallback âm thầm sang quét Mongo nếu projection chưa sẵn sàng; trả mã lỗi nguồn query chưa khả dụng.

10.5. Có thể chuyển backend mà không sửa Segment cũ

Ví dụ ban đầu FIRST_OPENED_AT có:

queryBackend = MONGO

Khi app_installation_current đã ổn định, backend có thể chuyển mapping nội bộ sang ClickHouse. Segment cũ vẫn giữ fieldCode = FIRST_OPENED_AT, nên không phải migrate definition.

10.6. Luồng lập kế hoạch và build

[Diagram]

11. Luồng Campaign 3 đầy đủ

[Diagram]

12. Push Worker

Worker hiện tại dự kiến bị loại bỏ nên tạo module/service Push Worker mới, không tiếp tục gắn logic mới vào worker cũ.

Quy trình một batch:

  1. Nhận campaignRunId, targetSnapshotId, actionIndex và danh sách installationId.
  2. Bulk load app_installation mới nhất từ MongoDB.
  3. Loại installation không tồn tại, INACTIVE, everLoggedIn=true hoặc token không ACTIVE.
  4. Giải mã token chỉ ngay trước khi gọi Firebase.
  5. Gửi theo batch limit của Firebase.
  6. Ghi delivery event theo từng installation.
  7. Token được Firebase xác nhận không hợp lệ phải đổi thành INVALID.

Idempotency key:

campaignRunId + installationId + actionIndex

Không log raw FCM token, phone, email hoặc payload chứa thông tin nhạy cảm.

13. Phân chia task

Task 1 - BE Core: Hợp nhất customer từ FILE, OCS và SDK

Mục tiêu

Mọi nguồn tạo/cập nhật cùng một customer trong company và trả về customerId ổn định.

Phạm vi

Thay đổi dữ liệu

Case bắt buộc

Case Xử lý
Cùng phone xuất hiện lại từ OCS Update customer cũ, không tạo mới.
Customer từ FILE đăng nhập app Installation liên kết đúng customer FILE.
Chưa có phone/email khi mở app Tạo anonymous customer.
Phone/email khác company Không được match chéo company.
Worker chạy lại cùng batch Không tạo customer trùng.

Kết quả nghiệm thu

Task 2 - BE SDK API: App Installation và FCM Token

Mục tiêu

Quản lý chính xác lượt cài ứng dụng, trạng thái đăng nhập và endpoint gửi Push.

Phạm vi

Case bắt buộc

Case Xử lý
Register lặp Upsert idempotent, không đổi firstOpenedAt.
Firebase đổi token Thay token, tăng version.
Một customer nhiều thiết bị Cho phép nhiều installation.
Thiết bị đổi tài khoản Event tương lai dùng customer mới; event cũ giữ customer tại thời điểm xảy ra.
Logout Xóa session customer hiện tại nhưng giữ everLoggedIn=true.

Kết quả nghiệm thu

Task 3 - BE Tracking: Chuẩn hóa PostHog Event

Mục tiêu

Giữ API PostHog hiện tại nhưng mọi event dùng cho audience có đủ company, app, installation và customer tại thời điểm xảy ra.

Phạm vi

Thay đổi dữ liệu

Kết quả nghiệm thu

Task 4 - Data/Worker: Projection và Event Aggregate trong ClickHouse

Mục tiêu

Chuẩn bị dữ liệu để tương lai query hành vi kết hợp customer mà không quét Mongo hoặc parse JSON toàn bảng.

Phạm vi

Kết quả nghiệm thu

Task 5 - BE Segment: APP_BEHAVIOR, Metadata và Query Planner

Mục tiêu

Tạo Segment động bằng cây điều kiện và tự chọn MongoDB hoặc ClickHouse theo loại dữ liệu.

Phạm vi

Case bắt buộc

Case Xử lý
Chỉ field current state MONGO_ONLY.
Chỉ event history CLICKHOUSE_ONLY.
Có field và event HYBRID; yêu cầu projection sẵn sàng.
Field/event không có trong catalog Từ chối bằng error code.
Event không có time window Từ chối.
Query source không khả dụng Không fallback ngầm; trả lỗi rõ ràng.

Kết quả nghiệm thu

Task 6 - BE Campaign: Build SegmentVersion theo CampaignRun

Mục tiêu

Mỗi lần Campaign chạy phải chốt đúng audience tại runAt và không dùng nhầm version cũ.

Phạm vi

Kết quả nghiệm thu

Task 7 - Push Worker: Gửi Firebase và Delivery Event

Mục tiêu

Gửi Push theo batch, không gửi nhầm user vừa đăng nhập và ghi đầy đủ kết quả.

Phạm vi

Kết quả nghiệm thu

Task 8 - FE: Form Hành vi ứng dụng và Push Notification

Mục tiêu

Cho người dùng tạo Campaign 3 trên màn Campaign hiện tại mà không cần biết MongoDB, ClickHouse hoặc SQL.

Phạm vi

Kết quả nghiệm thu

14. Thứ tự triển khai

[Diagram]

Có thể chia giai đoạn:

  1. Campaign 3 cơ bản: Task 1, 2, phần event contract tối thiểu, Mongo strategy, CampaignRun, Push Worker và FE preset.
  2. Hành vi lịch sử: hoàn thiện PostHog schema, ClickHouse projection/aggregate và ClickHouse strategy.
  3. Điều kiện kết hợp: bật HYBRID sau khi projection lag và reconcile đạt yêu cầu vận hành.

15. Các trường hợp bắt buộc xử lý

Trường hợp Xử lý
Customer đã có từ FILE rồi đăng nhập app Liên kết installation với customer FILE, không tạo customer thật thứ hai.
Customer đã có từ OCS rồi đăng nhập app Liên kết installation với customer OCS.
Mở app nhưng chưa có phone/email Tạo anonymous customer và installation.
Đăng nhập trước 24 giờ everLoggedIn=true, không thuộc Campaign 3.
Đăng nhập sau khi build nhưng trước lúc gửi Push Worker ghi SUPPRESSED_LOGGED_IN.
Logout Không reset everLoggedIn; không quay lại Campaign 3.
Cùng thiết bị đăng nhập tài khoản khác Event mới gắn customer mới; event cũ không đổi.
FCM token refresh Sử dụng token mới nhất từ Mongo.
FCM token invalid Mark INVALID, không retry token đó.
PostHog event đến trùng Không tính thành hai hành vi độc lập.
PostHog event đến muộn Lưu theo eventTime; chỉ ảnh hưởng run chưa build hoặc run tương lai.
Event thiếu installation Không dùng để build audience.
Projection ClickHouse chậm Không bật HYBRID nếu vượt ngưỡng; final send vẫn check Mongo.
Campaign interval chạy lần tiếp theo Tạo SegmentVersion mới theo runAt mới.
Build mới thất bại CampaignRun lỗi/chờ retry; không dùng version cũ.
Hai node cùng nhận build job Chỉ một node claim được lease.
Worker chết giữa batch Chạy lại idempotent và tiếp tục theo checkpoint.

16. Mã lỗi cần bổ sung

APP_BEHAVIOR0001  Ứng dụng không tồn tại hoặc không hoạt động
APP_BEHAVIOR0002  Field không được phép dùng làm audience
APP_BEHAVIOR0003  Event không được phép dùng làm audience
APP_BEHAVIOR0004  Operator không phù hợp với kiểu dữ liệu
APP_BEHAVIOR0005  Cây điều kiện vượt giới hạn
APP_BEHAVIOR0006  Event condition thiếu time window
APP_BEHAVIOR0007  Khoảng thời gian vượt giới hạn cho phép
APP_BEHAVIOR0008  Không thể estimate audience
APP_BEHAVIOR0009  APP_BEHAVIOR hiện chỉ hỗ trợ Push Notification
APP_BEHAVIOR0010  Nguồn dữ liệu query chưa sẵn sàng
APP_BEHAVIOR0011  Không thể lập kế hoạch query từ condition

APP_PROFILE0001   Installation không tồn tại
APP_PROFILE0002   FCM token không hợp lệ
APP_PROFILE0003   Không thể identify installation
APP_PROFILE0004   Installation không thuộc company/app hiện tại

CUSTOMER0001      Không có identity hợp lệ để tạo customer
CUSTOMER0002      Customer identity đã thuộc customer khác
CUSTOMER0003      Không thể resolve customer

PUSH0001          Cấu hình Push không hợp lệ
PUSH0002          Firebase tạm thời không khả dụng
PUSH0003          FCM token đã hết hiệu lực
PUSH0004          Push command vượt số lần retry

Message được lấy từ StatusCodeEnum và file đa ngôn ngữ. Service throw error code tương ứng; không hard-code message nghiệp vụ khi error code đã có message.

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

Log bắt buộc có các ID phù hợp với từng luồng:

traceId
companyId
appId
customerId
installationId
campaignId
campaignRunId
segmentId
segmentVersionId
buildJobId
Kafka topic/partition/offset
batchSize
durationMs

Không log:

Raw FCM token
Raw phone/email
Access token
Toàn bộ propertiesJson nếu có PII

Metric tối thiểu:

18. Kết luận

Campaign 3 không tạo một kho user riêng. Nó sử dụng kho customer chung đã được FILE, OCS và SDK cùng cập nhật.

Điều kiện hiện tại chỉ cần trạng thái mới nhất nên được thực thi bằng MONGO_ONLY:

firstOpenedAt <= runAt - 24h
everLoggedIn = false
fcmStatus = ACTIVE
status = ACTIVE

Khi bổ sung điều kiện lịch sử PostHog, cùng definition APP_BEHAVIOR sẽ được AudienceQueryPlanner chuyển sang CLICKHOUSE_ONLY hoặc HYBRID. Segment không biết và không phụ thuộc database vật lý.

Mỗi CampaignRun build một SegmentVersion tại thời điểm chạy, chốt TargetSnapshot rồi Push Worker kiểm tra lại Mongo trước khi gửi. Cách này vừa giữ đúng lịch sử, vừa tránh gửi nhầm người vừa đăng nhập, đồng thời mở rộng được cho các hành vi ứng dụng mới mà không phải hard-code thêm một loại Campaign.